Шардирование в PostgreSQL

Предпосылки: шардинг (shard key, resharding, consistent hashing), репликация, партиционирование, базовая идея хеширования.

Репликация

Репликация решает проблему отказов и разгружает чтения, но не помогает, когда система упирается в пределы одного сервера. Допустим, сервис доставки еды обрабатывает 50 000 заказов в сутки. Таблица orders — 200 млн строк, 80 ГБ. Primary обслуживает 3000 запросов/сек на чтение и 500 транзакций/сек на запись (каждый заказ порождает десятки операций: вставка, обновления статуса, платежи, логирование). Реплики забирают часть чтений, но все 500 записей/сек всё равно идут в один primary. При росте до 2000 записей/сек primary упирается в CPU и I/O — реплики не помогут, потому что запись не масштабируется горизонтально через репликацию.

Шардирование решает эту проблему: данные разносятся по нескольким независимым кластерам PostgreSQL, каждый обслуживает свою долю записей.

Как выглядит система после шардирования

client -> app/router -> shard 0 (PostgreSQL + replicas)
                   |-> shard 1 (PostgreSQL + replicas)
                   |-> shard 2 (PostgreSQL + replicas)
                   |-> shard 3 (PostgreSQL + replicas)

При 4 шардах каждый обслуживает ~125 записей/сек вместо 500. При росте до 2000 записей/сек — 500 на шард, что один PostgreSQL выдерживает комфортно.

Партиционирование vs шардирование

Партиционирование и шардирование оба делят таблицу на части. Разница — где проходит граница.

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

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

Shard key: что определяет маршрутизацию

Для сервиса доставки самые частые запросы — «заказы конкретного пользователя»: история, текущий статус, оплата. Естественный shard key — user_id.

Как работает маршрутизация

Приложение вычисляет номер шарда до отправки запроса в PostgreSQL. Для 4 шардов маршрутизация выглядит так:

user_id = 42
hash(42) = 1397291032        -- хеш-функция (например, murmur3)
1397291032 % 4 = 0           -- остаток от деления на число шардов
-> запрос уходит на shard 0
user_id = 77
hash(77) = 2081694789
2081694789 % 4 = 1
-> запрос уходит на shard 1

Хеширование (hash(user_id) % N вместо user_id % N) обеспечивает близкое к равномерному распределение даже когда значения user_id последовательные. Без хеширования user_id 1, 2, 3, 4 попадут ровно по одному на каждый шард, но 5, 6, 7, 8 снова начнут с первого — и если пользователи с малыми ID давно неактивны, а свежие ID сконцентрированы в узком диапазоне, один-два шарда получат непропорциональную нагрузку. Хеш-функция разбивает эту последовательность.

Все заказы одного пользователя оказываются в одном шарде, потому что user_id не меняется. Запрос «заказы user_id = 42» попадает ровно в один шард без scatter-gather.

Плохой выбор ключа

Shard key определяет не только маршрутизацию, но и равномерность нагрузки. Если шардировать orders по city_id в сервисе, где 60% заказов из одного города — один шард получит 60% записей и фактически вернётся к проблеме единственного primary. А если шардировать по email — при смене адреса все заказы пользователя придётся физически переносить на другой шард.

Хороший shard key: (1) присутствует в большинстве запросов, (2) имеет высокую кардинальность (миллионы различных значений), (3) стабилен — не меняется за жизнь записи.

Cross-shard запросы: что происходит на практике

Запрос «покажи мои заказы» содержит user_id и попадает в один шард — работает как обычный запрос к PostgreSQL, с теми же индексами и тем же latency.

Запрос «общее число заказов за сегодня по всем пользователям» — отчёт для бизнеса — не содержит user_id. Приложение вынуждено опросить все шарды:

4 шарда, каждый отвечает за 10-40 мс
 
app -> shard 0: SELECT count(*) ... WHERE created_at > today  (15 мс)
   |-> shard 1: SELECT count(*) ...                           (12 мс)
   |-> shard 2: SELECT count(*) ...                           (38 мс)  <-- autovacuum
   |-> shard 3: SELECT count(*) ...                           (14 мс)
 
итоговый latency = max(15, 12, 38, 14) + merge в app = ~40 мс
на одном PostgreSQL тот же запрос — 20 мс

При параллельном выполнении итоговое время определяется самым медленным шардом. Любой инцидент на одном шарде (VACUUM, checkpoint flush, медленный диск) влияет на latency агрегатных запросов по всей системе. При 100 шардах вероятность, что хотя бы один «подтормаживает», растёт статистически — это tail latency amplification.

Аналитические запросы, которые по природе не содержат shard key, обычно выносят в отдельное хранилище: реплицируют данные всех шардов в data warehouse (через logical replication или CDC), и бизнес-отчёты идут туда.

Что становится сложнее

Ограничения целостности

UNIQUE и FOREIGN KEY работают внутри одного PostgreSQL. После шардирования это означает «внутри одного шарда».

Задача «email должен быть уникален по всем пользователям» на одном сервере решается одним UNIQUE-индексом. При 4 шардах каждый шард гарантирует уникальность только своих данных. Два пользователя на разных шардах могут зарегистрироваться с одним email, и ни один PostgreSQL не увидит конфликта. Типичные решения: отдельный lookup-сервис (Redis/отдельная таблица), который проверяет глобальную уникальность до вставки, или включение shard key в составной UNIQUE constraint (уникальность в пределах шарда, не глобально).

Транзакции между шардами

Обычная транзакция PostgreSQL атомарна: все операции внутри BEGIN ... COMMIT либо применяются, либо нет. Но эта атомарность заканчивается на границе кластера.

Перевод бонусов от пользователя Alice (shard 0) к пользователю Bob (shard 2) — две независимые транзакции на двух серверах. Если списание у Alice прошло, а начисление Bob упало (сеть, таймаут, переполнение диска) — данные разъехались. Приложение должно явно обрабатывать частичный успех: идемпотентный retry, компенсирующая транзакция, или saga-паттерн.

Распределённый two-phase commit (координатор спрашивает все шарды «готовы?», потом отдаёт «фиксируй») решает задачу атомарности, но замораживает строки на нескольких узлах до завершения протокола. В PostgreSQL PREPARE TRANSACTION существует, но на практике используется редко из-за операционных рисков: зависшая prepared transaction блокирует VACUUM для затронутых строк.

Без координации чтение между шардами становится eventually consistent — модели консистентности описывают спектр промежуточных гарантий.

Операционная сложность

На одном PostgreSQL ALTER TABLE ADD COLUMN — одна команда. При 4 шардах — четыре DDL-команды, которые должны пройти без расхождения схем. При 100 шардах без автоматизации (скрипты миграции, оркестратор) поддерживать консистентность схем нереалистично.

VACUUM, бэкапы, мониторинг pg_stat, обновление версии PostgreSQL — всё умножается на число шардов. Один «забытый» шард с распухшими таблицами может неделями деградировать, пока не заполнит диск.

Как шардируют PostgreSQL на практике

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

На уровне приложения

Приложение само вычисляет шард по shard key и держит отдельные пулы соединений к каждому шарду:

pools = {
  0: pg_pool("shard0.internal:5432"),
  1: pg_pool("shard1.internal:5432"),
  2: pg_pool("shard2.internal:5432"),
  3: pg_pool("shard3.internal:5432"),
}
 
def get_orders(user_id):
    shard = hash(user_id) % len(pools)
    return pools[shard].query("SELECT * FROM orders WHERE user_id = %s", user_id)

Каждый шард — обычный PostgreSQL, ничего не знающий о других шардах. Максимальная гибкость: можно выбирать хеш-функцию, стратегию миграции, обработку scatter-gather. Но вся логика маршрутизации, агрегации, обработки ошибок — ответственность разработчика. Запросы без shard key требуют ручного fan-out по всем пулам.

Этот подход выбирают, когда паттерны доступа хорошо изучены и подавляющее большинство запросов содержат shard key.

Распределённый слой: Citus

Citus (расширение PostgreSQL; Microsoft приобрёл Citus Data в 2019, с 2022 — полностью open source) добавляет координатор и воркеры. Координатор принимает SQL, по метаданным определяет целевые шарды и маршрутизирует запрос. Воркеры — обычные PostgreSQL, хранящие данные.

flowchart LR
    App["app"] --> C["координатор (Citus)"]
    C --> W0["воркер 0<br>шард 0, 1"]
    C --> W1["воркер 1<br>шард 2, 3"]
    C --> W2["воркер 2<br>шард 4, 5"]

Таблицы делятся на distributed (шардированные по shard key) и reference (копируются на все воркеры, для маленьких справочников вроде cities). Координатор умеет push-down: если запрос содержит shard key, SQL отправляется на один воркер. Если нет — координатор рассылает частичные запросы и агрегирует результаты.

Компромиссы

ПриложениеCitus
Маршрутизациякод приложениякоординатор
Scatter-gatherручной fan-outавтоматический push-down
SQL-совместимостьполная (обычный PG)большинство запросов, но некоторые паттерны (CTE с записью, некоторые подзапросы) ограничены
Reshardingполностью на разработчикеrebalance_table_shards()
Зависимостьнетрасширение PG (совместимость с мажорными версиями)

Citus снижает стоимость входа: не нужно писать маршрутизацию и scatter-gather. Но ограничивает свободу: модель данных должна укладываться в «distributed + reference», и не весь SQL проходит через координатор одинаково эффективно.

Resharding: добавление шардов

Платформа растёт, 4 шарда уже не справляются. При hash(user_id) % 4 и переходе на 5 шардов большинство ключей меняют целевой шард — данные нужно физически перемещать. Это не мгновенная операция: копирование гигабайт между узлами, поддержание двойной маршрутизации на время миграции (чтение из старого или нового места в зависимости от статуса переноса), обработка записей, приходящих в процессе.

Consistent hashing минимизирует объём миграции: при добавлении узла перемещается ~1/N данных вместо большинства. Механизм подробнее описан в контексте распределённого кэша.

На практике resharding планируют заранее: начинают с числа шардов «с запасом» (32 или 64 логических шардов на 4 физических сервера). Когда нагрузка растёт — переносят логические шарды на новые серверы без пересчёта хешей.

Когда шардирование преждевременно

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

  • вертикальное масштабирование: 64 ядра и 512 ГБ RAM закрывают потребности многих сервисов на годы;
  • партиционирование: разделение по дате ускоряет запросы и упрощает очистку старых данных без потери транзакционных гарантий;
  • оптимизация запросов: пропущенный индекс или неэффективный JOIN иногда «стоят» нескольких серверов;
  • вынос чтений на реплики: если bottleneck — чтение, а не запись.

Если после всего этого primary упирается в запись или данные не помещаются на один сервер — шардирование оправдано.

Sources


Репликация