Системный дизайн · 3

Данные в масштабе

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

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки. Предыдущие части: подход, масштабирование.

Хранилище — под паттерн доступа

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

Подробно о каждой технологии — в презентации «Бэкенд-стек». На собеседовании назовите паттерн, выбор и цену: «Cassandra, потому что 200 тыс. записей в секунду и чтение только по chat_id; платим отсутствием join и транзакций».

Выберите паттерн доступа.

Лидер и реплики: синхронно или нет

  • Все записи идут в лидер, он шлёт журнал изменений репликам. Читать можно с любых узлов.
  • Асинхронно: коммит не ждёт реплик. Быстро, но при падении лидера теряется всё, что не доехало.
  • Синхронно: коммит ждёт подтверждения. Каждая запись платит сетевой RTT, а падение реплики останавливает запись.
  • Компромисс — кворум: ждать любую одну из двух (ANY 1 (r1, r2) в Postgres, semi-sync в MySQL).

Выберите режим и где стоит r2. RTT: та же зона ~0,3 мс, соседняя зона ~1,5 мс, другой регион 60–90 мс.

Лаг реплик: «я же только что сохранил»

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

  • Чтение с лидера какое-то время после своей записи.
  • Липкая реплика: пользователь всегда читает с одной и той же. Не уходит назад во времени, но свою запись может не увидеть.
  • Позиция в журнале: клиент помнит LSN своей записи, реплика отвечает, только догнав его.

Сохраните имя и сразу откройте профиль. Переключайте стратегии чтения.

Фейловер и split brain

Лидер перестал отвечать: умер или просто отрезан сетью? Снаружи не отличить. Если назначить нового лидера, а старый жив, — лидеров два, и оба принимают запись.

  • Лидером становится только тот, за кого проголосовало большинство (Raft, etcd, Patroni). Каждые выборы — новый номер эпохи (term).
  • Старый лидер в меньшинстве сам снимает с себя роль, когда истекает аренда (lease).
  • Fencing token: хранилище отвергает запросы со старой эпохой — даже если старый лидер проснулся после паузы GC.

Разрежьте сеть, запишите с обеих сторон и восстановите связь — с защитой и без.

Без лидера: кворумы N, W, R

В Dynamo-подобных базах (Cassandra, ScyllaDB, Riak) пишет любой узел: координатор шлёт запись всем N репликам и ждёт W ответов, чтение опрашивает R реплик и берёт самую свежую версию.

  • W + R > N — множества записи и чтения пересекаются, чтение увидит последнюю подтверждённую запись.
  • Запись переживает падение N − W узлов, чтение — N − R.
  • Отставшие реплики догоняют при чтении (read repair) и фоновой сверкой (anti-entropy).

Двигайте N, W, R, нажимайте на узлы, чтобы ронять их, пишите и читайте.

Конфликты записи

Несколько лидеров (по одному в каждом ДЦ, офлайн-клиенты, совместное редактирование) или кворумы без лидера — значит, две записи одного ключа могут случиться одновременно. Кто-то должен их слить.

  • Last write wins: побеждает бо́льшая метка времени. Просто, но одна запись молча пропадает, а часы серверов расходятся на миллисекунды.
  • Векторы версий отличают «позже» от «одновременно» и отдают приложению обе версии.
  • CRDT — типы данных, которые сливаются сами: счётчики, множества, текст (Riak, Redis Enterprise, Automerge).

Корзина {молоко}. В Европе удаляют молоко и кладут книгу, в США одновременно кладут чайник. Выберите стратегию и синхронизируйте.

Лестница моделей консистентности

Чем выше ступень, тем меньше странностей видит клиент — и тем больше координации, задержки и недоступности при сбоях. Требование выбирают для каждой операции, а не для всей системы.

ЛинеаризуемостьСистема ведёт себя как одна копия: после завершения записи все чтения видят её. Нужна для блокировок, выбора лидера, уникальности, остатков на счёте.etcd, ZooKeeper (запись), Spanner, Postgres на одном узле
ПоследовательнаяВсе видят операции в одном общем порядке, но он может отставать от реального времени.чтения ZooKeeper с follower
ПричиннаяЕсли B — следствие A, никто не увидит B без A: ответ на комментарий не появится раньше самого комментария.MongoDB causal sessions, COPS
Чтение своих записей и монотонностьГарантии одного клиента: видит то, что сам записал, и не откатывается назад при повторном чтении.липкие сессии, токены LSN
В конечном счёте (eventual)Если записи прекратятся, реплики когда-нибудь сойдутся. Когда — не обещано.DNS, Cassandra с ONE, чтения DynamoDB по умолчанию

CAP и PACELC

CAP говорит одно: при разрыве сети (P) узел, который не видит остальных, либо отвечает и рискует отдать старые данные (A), либо отказывает (C). «Выбрать два из трёх» — неверная формулировка: разрывы не выбирают.

PACELC добавляет главное: Else, без разрыва, выбор между задержкой (L) и консистентностью (C). Это цена каждого запроса, а не только аварии.

Три реплики: две в ДЦ A, одна в ДЦ B. Разрежьте сеть, попробуйте писать и читать в CP- и AP-режиме; посмотрите, как RTT между ДЦ влияет на задержку.

Проверьте себя

Cassandra, коэффициент репликации 3, запись и чтение с CONSISTENCY QUORUM. Одна из трёх реплик ключа упала. Что будет?

QUORUM для трёх реплик — два узла. Два живы, значит W = 2 и R = 2 набираются, а 2 + 2 > 3 — множества пересекаются. Упадёт вторая реплика — оба запроса получат UnavailableException. С ONE система переживёт и это, но чтение сможет вернуть устаревшее значение.

Шардирование: по диапазону или по хешу

  • Диапазон ключей на шард: запросы «от — до» идут в один шард, но последовательные ключи (время, автоинкремент) пишутся в один и тот же последний шард.
  • Хеш ключа: нагрузка ровная, но любой запрос по диапазону опрашивает все шарды.
  • Горячий ключ (знаменитость, вирусный пост) не лечится ни тем, ни другим: ключ всегда в одном шарде. Помогает соль — key#0..key#7 — и кэш перед базой.

Выберите нагрузку и способ разбиения. Красная черта — потолок одного шарда, ~12 тыс. запросов в секунду.

Вторичные индексы на шардах

Таблица шардирована по user_id, а искать нужно по городу. Индекс по городу можно хранить двумя способами.

Локальный индекс

  • Каждый шард индексирует свои строки. Запись — в один шард, атомарно.
  • Поиск по городу — во все шарды (scatter-gather). Задержка = самый медленный шард.
  • MongoDB, Elasticsearch, Cassandra SAI, Citus.

Глобальный индекс

  • Индекс сам шардирован — по значению. Поиск по городу идёт в один шард.
  • Запись затрагивает несколько шардов. Обычно индекс обновляется асинхронно и отстаёт.
  • DynamoDB GSI (только eventual), Spanner, CockroachDB (синхронно, ценой распределённой транзакции).

Решардинг: mod N против кольца

shard = hash(key) % N — при переходе с N на N + 1 шардов меняет адрес почти каждый ключ: переезжает доля N/(N + 1) данных. Консистентное хеширование кладёт шарды и ключи на кольцо, ключ принадлежит следующему шарду по часовой — и при добавлении шарда переезжает только ~1/(N + 1).

Несколько точек на шард (виртуальные узлы) выравнивают нагрузку. Тот же приём — в вероятностных структурах. Добавляйте и убирайте шарды в разных режимах.

Фиксированные партиции и каталог

  • Партиций заранее больше, чем узлов: ключ → партиция по хешу навсегда, а партиции целиком переезжают между узлами. Redis Cluster — 16 384 слота, Elasticsearch — число шардов задаётся при создании индекса.
  • Каталог: отдельный сервис хранит «диапазон → шард» и делит разросшиеся диапазоны (MongoDB config servers, Vitess, HBase, Bigtable). Гибко, но каталог — ещё одна критичная зависимость; его кэшируют на клиентах.
  • Kafka: partition = hash(key) % partitions. Добавили партиции — ключи переехали, порядок сообщений по ключу нарушился.

# Redis Cluster: слот = CRC16(key) mod 16384
CLUSTER KEYSLOT user:42          → 15880
CLUSTER KEYSLOT {user:42}:cart   → 15880  # тот же слот
# multi-key операции только в одном слоте

# MongoDB: ключ шардирования и чанки
sh.shardCollection("app.msgs", { chat_id: "hashed" })
sh.status()   # какие чанки на каком шарде
      

Ключ шардирования выбирают по самому частому запросу: он должен попадать в один шард. Менять его потом — значит переливать все данные.

Проверьте себя

Мессенджер. Таблица сообщений шардирована по хешу message_id. Самый частый запрос — последние 50 сообщений чата. Что не так?

Ключ шардирования должен совпадать с ключом самого частого запроса. Здесь сообщения одного чата раскиданы по всем шардам, и каждое открытие чата — scatter-gather с задержкой самого медленного шарда. Discord хранит сообщения в Cassandra с ключом партиции (channel_id, bucket), где bucket — окно времени, чтобы огромные чаты не превращались в бесконечную партицию. По времени — все записи в один последний шард.

Транзакция через шарды: 2PC

Запрос и транзакция внутри шарда — дешёвые. Через шарды — нужен двухфазный коммит: координатор спрашивает всех «готовы?» (PREPARE), и только если все сказали «да», рассылает COMMIT.

  • Цена: 2 круга по сети и несколько fsync на каждую транзакцию; блокировки держатся всё это время.
  • Координатор упал после PREPARE — участники не могут ни закоммитить, ни откатить и держат блокировки, пока он не вернётся.
  • Поэтому 2PC стараются не применять: подбирают ключ шардирования, чтобы транзакция жила в одном шарде, или используют саги — о них в следующей части.

Денормализация и CQRS

  • Join через шарды дорог, поэтому данные копируют туда, где их читают: имя автора — в пост, счётчик лайков — в карточку.
  • CQRS доводит идею до конца: запись — в нормализованную модель, чтение — из моделей, собранных под конкретные запросы.
  • Модели чтения обновляются из журнала изменений (outbox или CDC через Debezium → Kafka). Они отстают на десятки миллисекунд — секунды, и их можно пересобрать, проиграв журнал заново.
  • Цена: несколько копий, eventual consistency и код, который следит, чтобы копии не разошлись.

Генерация ID без центра

  • Автоинкремент: компактно и по порядку, но один источник на всё — не годится для многих шардов. Ещё и выдаёт число заказов конкурентам.
  • UUIDv4: 122 случайных бита, генерируется где угодно, 16 байт, никакого порядка.
  • UUIDv7: 48 бит времени в мс + случайные биты. Сортируется по времени (uuidv7() в Postgres 18).
  • Snowflake (Twitter, Discord, Instagram): 64 бита, влезает в bigint, растёт со временем. Нужно раздать узлам номера и следить за часами.

Генерируйте ID и смотрите, из чего он состоит. Попробуйте перевести часы назад.

Почему случайный UUID тормозит вставки

Первичный ключ — B-дерево. Растущие ключи всегда попадают в самую правую страницу: она горячая и лежит в памяти. Случайные ключи попадают в случайную страницу.

  • Пока индекс меньше кэша — разницы почти нет. Когда больше — почти каждая вставка читает страницу с диска.
  • Страницы делятся пополам и в среднем заполнены на ~70% — индекс больше.
  • Первое изменение страницы после контрольной точки пишет её в WAL целиком — 8 КБ на вставку.

Вставляйте ключи в индекс. Кэш вмещает 12 страниц.

Проверьте себя

Таблица на 500 млн строк, первичный ключ — UUIDv4. Индекс ключа 40 ГБ, RAM 32 ГБ. Вставки становятся всё медленнее, процессор при этом свободен. Почему?

Рабочий набор случайных вставок — весь индекс, и он не помещается в память: вставка ждёт чтения с диска, а после каждой контрольной точки страница целиком уходит в WAL. С UUIDv7 или bigint из Snowflake вставки идут в правый край индекса, который всегда горячий. Коллизии UUIDv4 на таком объёме практически невозможны.

На собеседовании и в проде

  • Оцените нагрузку и проверьте, нужен ли шардинг вообще: один Postgres на NVMe держит 10–50 тыс. записей и сотни тысяч чтений по индексу в секунду и несколько ТБ данных.
  • Сначала реплики для чтения и кэш, потом шардирование. Назовите ключ шардирования и самый частый запрос по нему.
  • Для каждой операции — требуемая консистентность: платёж линеаризуем, лента — eventual.
  • Проговорите сбои: упал лидер, лаг реплики, горячий ключ, разрыв между ДЦ, добавление шарда.

-- Postgres: лаг реплик на лидере
SELECT client_addr, state, sync_state, replay_lag
  FROM pg_stat_replication;
-- на реплике: до какой позиции дошли
SELECT pg_last_wal_replay_lsn(),
       now() - pg_last_xact_replay_timestamp() AS lag;

# Cassandra: узлы, токены, где лежит ключ
nodetool status
nodetool getendpoints app messages 'chat:42'

# Redis Cluster: слоты и баланс
redis-cli --cluster check 10.0.0.1:6379

# Kafka: отставание потребителей (модели чтения)
kafka-consumer-groups.sh --describe --group projections
    

Главное

  • Хранилище — по паттерну доступа. Один хороший сервер тянет больше, чем кажется: сначала посчитайте.
  • Асинхронная реплика теряет хвост при фейловере, синхронная платит RTT. Лаг ломает «чтение своих записей» — лечится маршрутизацией чтений.
  • Лидера выбирает большинство, старого отсекают эпохой и fencing token. Кворумы: W + R > N.
  • LWW теряет данные, векторы версий находят конфликты, CRDT их сливают. CAP — про разрыв сети, PACELC — про цену каждого запроса.
  • Ключ шардирования = ключ частого запроса. Горячие ключи — солью и кэшем, решардинг — кольцом или фиксированными партициями.
  • Избегайте 2PC, денормализуйте под чтение, ID делайте растущими: UUIDv7 или Snowflake.

Распределённые данные — это выбор, что именно вы готовы увидеть устаревшим.