ARTICLE / GO BACKEND

Todos los artículos

Sharding de PostgreSQL bajo carga cuando ya no bastan opciones más baratas

Cómo demostrar que hace falta sharding, elegir una clave estable y anticipar el enrutado, las operaciones entre shards y la migración de datos.

El texto completo está en ruso.

PostgreSQLArchitectureHighloadDistributed SystemsObservability

Один узел PostgreSQL приблизился к пределу, и команда предлагает разделить данные. После разделения выясняется, что самый тяжёлый сценарий всё равно обращается ко всем шардам, крупный клиент перегружает один из них, а ночная сверка теперь должна переживать частичный отказ. Нагрузка распределилась не так, как ожидалось, зато каждая операция получила новый способ сломаться.

Шардирование помогает, когда данные и работу можно разделить по устойчивой границе. Оно не исправляет плохой запрос, спорный бизнес-инвариант или отсутствие контроля над ростом. Поэтому решение начинается не со схемы маршрутизатора, а с доказательства, что предел действительно находится в одном узле и более дешёвые изменения уже не дают нужного запаса.

Контекст и ограничения

Ниже шард — самостоятельный PostgreSQL-кластер со своим контуром отказа, резервными копиями и локальными транзакциями. Приложение определяет размещение по ключу и в обычном запросе обращается к одному шарду. Между шардами нет общей транзакции, ограничения уникальности или согласованного снимка только потому, что везде установлен PostgreSQL.

Модель рассчитана на операционные данные с большим числом независимых групп: например, заказы одного продавца, аренды одного объекта или складские операции одной площадки. Конкретная граница зависит от инвариантов. Региональная изоляция, требования к размещению данных и ограничение радиуса отказа тоже могут оправдать разделение раньше предела мощности, но это уже другое исходное требование.

Сначала доказать предел одного узла

Отделить насыщение ресурса от очереди, созданной приложением

Очередь за соединением ещё не означает, что базе нужно больше узлов. Слишком большой пул способен увеличить конкуренцию и ухудшить время каждого запроса. Я связываю пользовательскую задержку с насыщением конкретного ресурса: процессорным временем, чтением и записью, ожиданием WAL, блокировками, работой autovacuum, размером активного набора данных и окнами резервного копирования или обслуживания.

Снимок в пике недостаточен. Нужны профиль представительных интервалов, скорость роста и повторяемый нагрузочный сценарий. PostgreSQL даёт для этого статистику ожиданий и WAL через pg_stat_activity и pg_stat_wal, а начиная с PostgreSQL 16 — pg_stat_io. Но решение строится на связи этих сигналов с операцией пользователя: средняя загрузка кластера может быть нормальной, пока один сериализованный инвариант создаёт очередь.

Исчерпать решения, которые не меняют модель согласованности

До шардирования я проверяю планы запросов, индексы, блокировки, границы транзакций, пул соединений, архивирование холодных данных и возможность изменить модель чтения. Этот путь разобран в заметке о PostgreSQL под реальной нагрузкой.

Партиционирование помогает отсечь ненужные секции, обслуживать данные частями и уменьшать отдельные индексы. Само по себе оно не создаёт независимые узлы записи, а разбивает логическую таблицу на дочерние; плохой ключ увеличивает время планирования и расход памяти. Документация PostgreSQL отдельно предупреждает о цене большого числа секций и требует включать ключ секционирования в UNIQUE или PRIMARY KEY всей секционированной таблицы, потому что дочерние индексы видят только свои секции (ограничения и рекомендации).

Реплика чтения снимает часть запросов с основного узла, если сценарий допускает задержку данных. Она не добавляет независимого пути записи: hot standby обслуживает запросы только для чтения, а изменения по-прежнему проходят через основной узел и его WAL. Если после этих шагов ограничение осталось, нужно проверить второе условие: нагрузка действительно делится по будущему ключу.

Рабочая модель: размещать данные по инварианту

Выбирать ключ по локальности, а не только по равномерности

Хороший ключ шардирования отвечает на вопрос «какие данные должны меняться одной локальной транзакцией». Для каждого кандидата я считаю долю операций, остающихся внутри одного значения ключа, размер крупнейшей группы, скорость её роста и распределение записей по времени. Высокая кардинальность бесполезна, если один глобальный ресурс участвует во всех командах.

Ключ должен быть известен до обращения к базе и редко меняться. Если его приходится искать широким запросом или менять вместе с жизненным циклом сущности, маршрутизация превращается в дополнительную распределённую операцию. Равномерный хеш сглаживает множество небольших групп, но не дробит одну горячую группу. Для неё нужна внутренняя граница, отдельное размещение или пересмотр инварианта — добавление шардов проблему не решит.

Я предпочитаю отделять логическое размещение от физических кластеров: ключ попадает в один из заранее заданных виртуальных сегментов, а каталог связывает сегмент с текущим шардом и поколением маршрута.

flowchart LR
    C[Команда с ключом шардирования] --> R[Маршрутизатор]
    M[(Каталог и кеш: сегмент шард + поколение)] --> R
    R -->|ровно один маршрут| O[На шарде: блокировка владения]
    O -->|одна локальная транзакция| D[(Данные выбранного шарда)]
    D --> E[Поток изменений]
    E --> P[(Проекции для глобального чтения)]
    X[Контроллер переноса] -->|после сверки публикует новое поколение| M

Виртуальный слой позволяет переносить часть пространства, не меняя формулу для всех ключей. Цена — критичный каталог. Его кешируют, версионируют и восстанавливают как часть системы данных. При неизвестном маршруте запись должна остановиться, а не уйти на «шард по умолчанию».

Поколение защищает только тогда, когда его проверка сериализуется с закрытием маршрута. Один рабочий протокол: каждая бизнес-транзакция блокирует запись владения сегментом перед изменением данных, а перенос обновляет ту же запись. Оба изменения входят в локальную транзакцию; простого чтения поколения при Read Committed недостаточно. Цена протокола — дополнительная конкуренция на записи владения, поэтому гранулярность сегментов выбирают вместе с профилем записи.

Оставлять глобальные правила за явным владельцем

Локальный уникальный индекс не обеспечивает уникальность между независимыми кластерами. Глобальный человекочитаемый идентификатор требует отдельного владельца — реестра — либо детерминированного пространства имён, исключающего коллизии. Асинхронная сверка способна обнаружить дубль, но не заменить немедленный запрет.

То же относится к транзакциям. PostgreSQL поддерживает подготовленные транзакции для внешнего координатора, однако документация советует не использовать PREPARE TRANSACTION без менеджера, который гарантированно завершает их: забытая подготовленная транзакция удерживает блокировки и мешает очистке. Обычно устойчивее изменить границу так, чтобы команда писала в один шард, а межшардовый процесс имел явные промежуточные состояния и компенсации.

Глобальные списки и отчёты лучше строить в отдельной проекции. Запрос «разослать SQL всем шардам и склеить ответ» переносит задержку самого медленного шарда в пользовательский путь, усложняет пагинацию и требует семантики частичного результата. Рассылка по шардам допустима для редкой диагностики, но не должна незаметно становиться основным интерфейсом.

Переносить сегмент как восстанавливаемый процесс

Перенос состоит из копирования снимка, доставки последующих изменений, сверки и переключения маршрута. Логическая репликация PostgreSQL умеет начать со снимка и затем применять изменения в порядке источника в пределах одной подписки. Это механизм доставки, а не готовый протокол смены владельца данных.

Перед запуском фиксируют источник истины и точку, с которой можно повторить поток. При собственном сочетании копирования и потока снимок связывают с позицией слота через экспортированный снимок; независимый запуск двух процессов оставляет окно потери. Целевой шард сначала не обслуживает пользовательские чтения и записи: PostgreSQL копирует таблицы отдельными процессами, и они завершают начальную синхронизацию в разное время. Готовность означает состояние r для всех переносимых таблиц в pg_subscription_rel и применение контрольного барьера.

После копирования и догоняющего применения команда сверяет количество объектов, контрольные суммы по диапазонам и предметные инварианты. Простое равенство общего числа строк не ловит две взаимно компенсирующие ошибки.

Для переключения нужен один из двух честных вариантов. Первый — коротко остановить записи для сегмента, применить хвост, сверить и сменить маршрут. Второй — использовать поколение в каждой команде. Старый шард одной локальной транзакцией закрывает прежнее поколение и записывает барьер в передаваемый поток; блокировка записи владения заставляет прежних писателей завершиться раньше барьера. После его применения и сверки новый шард активирует следующее поколение, а каталог публикует маршрут. Отдельный поток репликации требует своего барьера.

Между переходами запись может получить временный отказ, но два шарда не принимают её одновременно. Повторы сохраняют идентификатор операции. Обычная двойная запись без общей фиксации создаёт два источника истины и неизвестный исход при первом тайм-ауте.

Изменения схемы и значения последовательностей логическая репликация автоматически не переносит, поэтому схема и генерация идентификаторов входят в отдельный план (ограничения PostgreSQL). Должен существовать и путь назад: до переключения это удаление неполной копии и повтор, после переключения — новый контролируемый перенос, а не возврат старого маршрута к уже изменившимся данным.

Типичные поломки и как их ловят

Среднее здоровье скрывает горячий шард

Общая панель мониторинга показывает свободную мощность, пока один ключ шардирования создаёт очередь. Нужны распределение нагрузки по сегментам, крупнейшие группы и одинаковые графики ресурсов для каждого шарда. Автоматическое перераспределение по размеру опасно: маленький сегмент с частыми блокирующими записями может быть тяжелее большого архива.

Устаревший маршрут принимает запись после переключения

Кеш каталога пережил смену владельца, старый экземпляр приложения отправил команду в прежний шард, а тот её принял. Ловят это тестом с задержанным запросом через границу переключения. Закрывает гонку не только обновление кеша, а проверяемое поколение либо остановка записи на время смены маршрута.

Репликация остановилась, а отставание выглядит стабильным

Конфликт ограничения может остановить применение логической репликации. Поэтому проверяют состояние процесса применения, ошибки и позицию потока, а не только возраст последнего сообщения. Отставший слот способен удерживать WAL вплоть до заполнения диска; PostgreSQL прямо описывает этот риск для слотов репликации. При конечном max_slot_wal_keep_size слот может перейти в состояние lost; тогда продолжение потока уже требует новой синхронизации.

Схема и восстановление расходятся между шардами

Изменение схемы прошло на части кластеров, либо один шард восстановили на другую логическую точку. Версии схемы проверяют как данные, а восстановление регулярно репетируют для одного шарда и для связанного бизнес-процесса. Независимые резервные копии не создают общий согласованный снимок; после частичного восстановления нужен план повтора событий и предметной сверки.

Что измерять

До разделения сохраняют исходную причину: время пользовательской операции, очередь, насыщенный ресурс, скорость роста и длительность обслуживания. После запуска к ней добавляют:

Количество шардов не является метрикой успеха. Решение оправдано, если исходный предел получил измеримый запас, обычная операция осталась локальной, а перенос сегмента укладывается в проверенное окно. Запас считают по наиболее нагруженному шарду, а не по среднему.

Когда шардирование вредно

Разделение преждевременно, если проблему создают один запрос, блокировка, чрезмерный пул, неограниченная история или чтение, которое можно вынести на реплику. Оно также вредно, когда большая часть команд требует глобального ограничения или рассылки по шардам, нет стабильного ключа, а команда ещё не умеет одинаково выпускать схемы, восстанавливать кластеры и сверять данные.

Не стоит делить базу ради будущего роста без проверяемой модели. Но и ждать полного исчерпания узла опасно: миграция сама потребляет чтение, WAL, сеть и время команды. Проектирование и испытание переноса начинают, когда запас ещё есть; пользовательский трафик переводят после доказательства необходимости и готовности обратного пути.

Вывод для архитектурного ревью

На ревью я прошу три доказательства. Первое: назван предел одного узла и показано, почему запросы, индексы, партиционирование, архив и реплики его не снимают. Второе: ключ шардирования оставляет бизнес-инварианты и основной путь внутри одного шарда, включая крупнейшие группы. Третье: на каждом этапе есть один владелец записи, включая уже начатые операции; сегмент можно скопировать, догнать, сверить, переключить и при необходимости перенести обратно.

Шардирование начинается не с hash(key) % N. Оно начинается с границы данных, которую система способна соблюдать и переносить под нагрузкой.