Шардинг: горизонтальное разделение данных между узлами

Предпосылки: репликация (копии данных, failover, кворум — почему копии не решают проблему ёмкости).

Репликация | CAP-теорема

Платформа из сценария репликации выросла: 50 000 заказов в день, каталог на 10 миллионов товаров, primary упирается в диск и CPU. Добавление реплик не помогает — каждая хранит полный набор данных, а все записи идут на один узел. Репликация масштабирует чтение, но записи и ёмкость ограничены одним сервером.

Шардинг: разделить данные, а не копировать

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

             ┌──────────────────────────────┐
             │         Router / App         │
             └──────┬───────┬───────┬───────┘
                    │       │       │
              ┌─────v─┐ ┌───v───┐ ┌─v─────┐
              │Shard A│ │Shard B│ │Shard C│
              │users  │ │users  │ │users  │
              │1–33%  │ │34–66% │ │67–100%│
              └───────┘ └───────┘ └───────┘

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

Цена шардинга

Шардинг — не бесплатное масштабирование. То, что раньше делалось «внутри одной базы», теперь пересекает границы узлов.

Распределённые запросы. Запрос, который не содержит shard key, должен опросить все шарды и агрегировать результаты (scatter-gather). «Покажи заказы пользователя 42» попадает в один шард. «Посчитай все заказы за сегодня» — при 10 шардах это 10 сетевых вызовов вместо одного, а итоговая latency определяется самым медленным шардом.

Утрата глобальных гарантий. Уникальность, внешние ключи, атомарность транзакций — всё это работает внутри одного шарда. Между шардами каждую гарантию нужно проектировать отдельно: распределённая транзакция требует координации (two-phase commit: координатор сначала спрашивает все узлы «готовы?», затем отдаёт команду «фиксируй»), которая замораживает строки на нескольких узлах до подтверждения; глобальная уникальность требует отдельного сервиса или схемы генерации ID.

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

Shard key: главное решение в шардинге

Все три проблемы — scatter-gather, утрата гарантий, операционная сложность — определяются одним решением: как именно данные разделяются между шардами. Шардинг почти всегда строится вокруг shard key — значения, по которому запрос направляется на конкретный шард. Выбор shard key определяет, как распределяется нагрузка, какие запросы будут быстрыми, а какие — дорогими.

Платформа хранит заказы и чаще всего показывает пользователю «мои заказы». Shard key = user_id: любой запрос про конкретного пользователя попадает в один шард. user_id присутствует почти в каждом запросе — значит, большинство запросов обойдётся без scatter-gather.

orders(user_id, order_id, created_at, ...)
 
3 шарда, распределение через hash(user_id) % 3:
  shard 0: hash(user_id) % 3 == 0
  shard 1: hash(user_id) % 3 == 1
  shard 2: hash(user_id) % 3 == 2
 
"Покажи заказы user_id=42" → shard(hash(42) % 3) → один шард
"Посчитай заказы за сегодня" → user_id нет → scatter по всем шардам

Хеширование (hash(user_id) % N вместо user_id % N) обеспечивает близкое к равномерному распределение даже если значения user_id не случайны (например, последовательные ID). Миллионы пользователей дают достаточно различных значений, чтобы нагрузка распределялась между шардами без перекосов. И user_id стабилен — пользователь не меняет свой ID, поэтому строки не нужно перемещать между шардами.

Но при неудачном выборе ключа эти свойства рушатся. Если шардировать по country_code в глобальном сервисе, где 70% пользователей из одной страны — один шард получит 70% нагрузки, остальные будут простаивать. А если шардировать по email, при смене адреса все заказы пользователя придётся физически переносить на другой шард.

Resharding: добавить узел — значит переместить данные

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

Resharding — тяжёлая операция: нужно копировать данные, поддерживать корректную маршрутизацию во время миграции (запросы к перемещаемым данным должны находить их и на старом, и на новом месте) и не терять записи, которые приходят в процессе.

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

Шардинг + репликация: два слоя одного решения

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

         ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
         │   Shard A   │    │   Shard B   │    │   Shard C   │
         │  primary    │    │  primary    │    │  primary    │
         │  replica 1  │    │  replica 1  │    │  replica 1  │
         │  replica 2  │    │  replica 2  │    │  replica 2  │
         └─────────────┘    └─────────────┘    └─────────────┘

Платформа с 3 шардами, каждый реплицирован на 2 реплики: 9 узлов суммарно. Primary шарда принимает записи, реплики обслуживают чтения и готовы к failover. Потеря одного узла не влияет на систему — реплика берёт на себя роль primary для своего шарда.

Конкретные реализации этой схемы: шардирование в PostgreSQL (application-level routing, Citus, FDW) и Redis Cluster (16 384 слота, CRC16, MOVED/ASK).

Sources


Репликация | CAP-теорема