Шардирование и репликация баз данных: в чём разница и что выбрать
Шардирование и репликация простыми словами: какую проблему решает каждая, лаг и read-your-writes, выбор ключа, горячие шарды, решардинг и как рассуждать об этом на собеседовании.
Коротко
| Репликация | Шардирование | |
|---|---|---|
| Что делает | хранит копии одних и тех же данных на разных узлах | делит данные на части и раскладывает по узлам |
| Что решает | чтения, отказоустойчивость, задержку для регионов | записи и объём данных |
| Чего не решает | записи и объём: каждая копия хранит и пишет всё | отказоустойчивость: упавший шард недоступен |
| Чем платим | лаг, устаревшие чтения, сложный failover | кросс-шардовые запросы, решардинг, сложный код |
- В продакшене их обычно совмещают: данные делят на шарды, а у каждого шарда есть реплики.
- Репликацию можно добавить почти без правок в коде. Шардирование меняет модель данных, поэтому к нему приходят последним.
- Главное решение при шардировании — ключ. Его сложно поменять потом.
Какую проблему решает каждая
Разницу проще всего увидеть на арифметике. Допустим, база получает 10 000 записей и 50 000 чтений в секунду.
Добавляем четыре реплики. Чтения можно раскидать на пять узлов, каждому достанется по 10 000. Записи никуда не делись: каждая реплика применяет те же 10 000 записей в секунду, что и лидер, и хранит тот же объём.
Вместо этого делим данные на пять шардов. Каждому достаётся по 2 000 записей, 10 000 чтений и пятая часть данных. Но теперь сервер, который упал, уносит с собой пятую часть пользователей, а узлов стало больше, и падать они будут чаще.
Отсюда простая таблица для первых минут разговора:
| Симптом | Инструмент |
|---|---|
| Чтений слишком много, лидер упирается в CPU | реплики, кэш |
| Падение сервера не должно останавливать сервис | реплики и автоматический failover |
| Пользователи на другом континенте ждут чтения | реплика в их регионе |
| Лидер не справляется с записью | сначала пакетная запись и очередь, потом шарды |
| Данные не помещаются или vacuum и бэкапы не успевают | сначала партиции и архив, потом шарды |
| Удалили таблицу по ошибке | бэкапы: реплика удалит её вместе с лидером |
Шарды и партиции легко спутать. Партиционирование делит таблицу на куски внутри одного сервера: запросы и удаление старых данных становятся дешевле, но предел одной машины остаётся. Шардирование разносит куски по разным серверам.
Репликация
Leader-follower
Самая распространённая схема. Все записи идут на лидера, он пишет их в журнал, реплики читают журнал и применяют его у себя. Читать можно откуда угодно.
Схема простая и не знает конфликтов: порядок записей задаёт один узел. Предел у неё тоже один: вся запись проходит через лидера.
Синхронная и асинхронная
Вопрос один: когда лидер скажет клиенту «готово»?
- Асинхронная — сразу после локальной записи. Быстро, реплики не тормозят лидера. Но если лидер умрёт, записи, не успевшие уехать, пропадут.
- Синхронная — после подтверждения от реплики. Подтверждённая запись переживёт смерть лидера. Цена — задержка на каждый коммит и зависимость от реплики.
- Кворумная и полусинхронная — компромисс: ждём не всех, а одну-две реплики из нескольких.
На практике настройка тоньше, чем два режима. В PostgreSQL за неё отвечает synchronous_commit вместе с synchronous_standby_names:
| Значение | Чего ждёт коммит | Что это даёт |
|---|---|---|
off | ничего | самая быстрая запись, можно потерять хвост |
local | сброса на свой диск | реплики не влияют на задержку |
remote_write | реплика получила и записала в файловую систему | переживает падение Postgres на реплике |
on | реплика сбросила на диск | запись не потеряется при смерти лидера |
remote_apply | реплика применила запись | запись сразу видна в чтениях с реплики |
Настройки реплик здесь работают, только если задан synchronous_standby_names, например 'ANY 1 (replica_a, replica_b)'. Режим можно менять для отдельной транзакции: платёж подтверждать синхронно, а счётчик просмотров нет.
Два момента, на которых часто ошибаются. Я проверил оба на паре из лидера и реплики на Postgres 17:
- Синхронная реплика не значит свежая. При
onя поставил применение журнала на реплике на паузу. Коммит прошёл, а чтение с реплики вернуло старые данные. Журнал уже лежит на её диске, но ещё не применён. Свежие чтения даёт толькоremote_apply: в том же опыте коммит с ним завис до снятия паузы. - Синхронность снижает доступность. Когда я остановил единственную подходящую реплику, обычный
INSERTна лидере повис в ожидании. Поэтому синхронных реплик держат несколько и задают кворум. MySQL в полусинхронном режиме поступает иначе: по таймауту переходит на асинхронную репликацию, то есть выбирает доступность.
Лаг и read-your-writes
При асинхронной репликации реплика всегда немного отстаёт. Обычно это миллисекунды, но длинная транзакция или всплеск записи могут растянуть лаг до минут. Классические симптомы:
- Не вижу свою запись. Пользователь сменил имя, страница перезагрузилась, а имя старое. Запрос ушёл на отстающую реплику. Нужная гарантия называется read-your-writes.
- Время пошло назад. Первый запрос попал на свежую реплику, второй — на отстающую, и комментарий исчез. Лечится привязкой пользователя к одной реплике: это monotonic reads.
Как обеспечить read-your-writes, от простого к сложному:
- Читать данные, которые правит только сам пользователь (например, свой профиль), с лидера.
- После записи какое-то время направлять этого пользователя на лидера. Окно должно быть больше типичного лага.
- Выдавать клиенту токен записи и читать только с реплик, которые его догнали.
Вот маршрутизация для третьего варианта:
import random class Node: def __init__(self, name, lsn): self.name = name self.lsn = lsn # позиция в журнале, до которой узел применил записи def pick_node(leader, replicas, token=0):
# Токен — позиция журнала после записи клиента
fresh = [r for r in replicas if r.lsn >= token]
return random.choice(fresh) if fresh else leader
leader = Node("leader", lsn=105) replicas = [Node("replica-0", lsn=100), Node("replica-1", lsn=103)]
print(pick_node(leader, replicas).name) # любая реплика: токена нет print(pick_node(leader, replicas, token=102).name) # replica-1: только она догнала print(pick_node(leader, replicas, token=105).name) # leader: реплики ещё отстают
В PostgreSQL те же действия выглядят так:
```sql
-- На лидере: кто и насколько отстаёт
SELECT application_name, sync_state, replay_lag,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS bytes_behind
FROM pg_stat_replication;
-- На лидере сразу после коммита: получаем токен
SELECT pg_current_wal_lsn();
-- На реплике перед чтением: догнала ли она токен
SELECT pg_last_wal_replay_lsn() >= '0/301F200'::pg_lsn;
Failover: где теряются данные
Когда лидер умирает, одну из реплик назначают новым лидером. На бумаге просто, на деле здесь три проблемы:
- При асинхронной репликации новый лидер не видел последних записей.
- Как отличить мёртвого лидера от сетевого сбоя? Если старый лидер жив и тоже принимает записи, получается split-brain. Поэтому старый узел отсекают: это называется fencing.
- Слишком чувствительный детектор устроит failover на ровном месте и добавит нагрузки в уже больную систему.
Хороший пример — инцидент GitHub в октябре 2018 года. Связь с восточным дата-центром пропала на 43 секунды, но автоматика успела перевести запись на западное побережье. Несколько секунд записей остались только на востоке, новые записи — только на западе, и просто вернуться обратно стало нельзя. Сервис работал с деградацией 24 часа 11 минут.
Multi-leader
Записи принимают несколько лидеров, например по одному в каждом регионе. Это даёт быструю локальную запись и переживает потерю целого региона. Телефон, который копит заметки без сети, тоже работает как лидер: он принимает записи и потом синхронизируется.
Цена — конфликты: две записи на одни данные могли пройти одновременно. Самое популярное решение — last write wins, и оно теряет данные:
from dataclasses import dataclass @dataclass class Write: value: int ts: float # часы региона, где прошла запись def lww(a, b): return a if a.ts > b.ts else b # Баланс 100. Два региона одновременно применяют разные операции europe = Write(value=100 + 50, ts=1000.002) # пополнение на 50 asia = Write(value=100 - 30, ts=1000.001) # списание на 30 print(lww(europe, asia).value) # 150: списание потеряно # Часы Азии спешат на 5 мс, и победитель меняется asia_skewed = Write(value=70, ts=1000.001 + 0.005) print(lww(europe, asia_skewed).value) # 70: потеряно пополнение # Правильный баланс: нужно сливать операции, а не итоговые значения print(100 + 50 - 30) # 120
Как с этим живут:
- Избегают конфликта. Каждый пользователь закреплён за домашним регионом, и его записи идут только туда. Это самый практичный вариант.
- Сливают без потерь. Хранят операции вместо итога или используют CRDT — структуры данных, которые сливаются автоматически.
- Решают на уровне приложения: показывают обе версии, как при конфликте в git.
Кроме обычных конфликтов появляются совпадающие автоинкременты (в MySQL для этого задают auto_increment_increment и auto_increment_offset) и нарушенные уникальные ограничения: один email может зарегистрироваться в двух регионах сразу.
На интервью говорите так: multi-leader нужен, когда есть несколько регионов с локальной записью или офлайн-клиенты. Иначе он не окупается.
Могут спросить и про третью схему — leaderless в духе Dynamo, как в Cassandra. Клиент пишет на узлов и читает с из . При множества узлов пересекаются, и чтение застанет хотя бы одну свежую копию. При сбоях и одновременных записях гарантии слабее, чем кажется по формуле.
Шардирование
Три стратегии
| Стратегия | Как работает | Плюсы | Минусы |
|---|---|---|---|
| По диапазону | A–F на шард 1, G–M на шард 2 | дешёвые запросы по диапазону | горячий конец при растущем ключе, перекосы |
| По хешу | hash(key) определяет шард | ровная нагрузка | запрос по диапазону ключа идёт на все шарды |
| Географическая | Европа в одних шардах, Азия в других | низкая задержка, требования законов | шарды разного размера, переезд пользователей |
По диапазону работает в HBase, Spanner, CockroachDB и MongoDB в режиме range. Границы хранятся в метаданных, а большие диапазоны делятся автоматически. Главная ловушка — ключ, который только растёт: автоинкремент или время.
import bisect import hashlib from collections import Counter # Границы диапазонов по order_id: шард 3 принимает всё, что больше 3 млн bounds = [1_000_000, 2_000_000, 3_000_000] new_orders = range(3_000_001, 3_100_001) # новые id только растут by_range = Counter(bisect.bisect_left(bounds, i) for i in new_orders) by_hash = Counter( int(hashlib.md5(str(i).encode()).hexdigest(), 16) % 4 for i in new_orders ) print("диапазон:", sorted(by_range.items())) # диапазон: [(3, 100000)] print("хеш: ", sorted(by_hash.items())) # хеш: [(0, 25115), (1, 24642), (2, 25136), (3, 25107)]
Все новые заказы попали в один шард, остальные простаивают. Figma хеширует ключи при маршрутизации именно из-за этого: и автоинкремент, и Snowflake ID дают горячие точки. UUIDv7 при разбиении по диапазону ведёт себя так же: он тоже упорядочен по времени.
По хешу — выбор по умолчанию, когда нагрузка строится вокруг точечных запросов по ключу. Но запрос вроде WHERE id BETWEEN 100 AND 200 по такому ключу превращается в опрос всех шардов.
Компромисс — составной ключ. Часть ключа хешируем, а внутри шарда данные лежат упорядоченно. Discord в 2017 году описал хранение сообщений в Cassandra с ключом ((channel_id, bucket), message_id): bucket — окно примерно в 10 дней, чтобы партиция не разрасталась. История одного канала лежит рядом и читается диапазоном, а разные каналы равномерно разлетаются по кластеру.
Географическая — это чаще про законы и задержку, чем про нагрузку. В России при сборе персональных данных граждан нужно использовать базы на территории страны (ч. 5 ст. 18 152-ФЗ), в Европе GDPR ограничивает передачу данных за пределы ЕС. Детали и исключения лучше уточнять у юристов, а на интервью достаточно спросить, есть ли такие требования. Внутри региона обычно всё равно шардируют по хешу.
Как выбрать ключ
Хороший ключ отвечает на четыре вопроса «да»:
- Значений много?
countryилиstatusдадут единицы шардов, которые нельзя поделить дальше. - Нагрузка размазана ровно? Важен не только объём, но и активность. Один большой клиент способен загрузить целый шард.
- Он есть в большинстве запросов? Иначе почти каждый запрос пойдёт на все шарды.
- Он не меняется? Смена ключа означает перенос строки на другой шард.
| Система | Хороший ключ | Почему |
|---|---|---|
| SaaS для команд | workspace_id | запросы живут внутри команды, join работает |
| Мессенджер | (chat_id, time_bucket) | история чата лежит рядом и не растёт бесконечно |
| Сокращатель ссылок | короткий код | единственный запрос — поиск по коду |
| Метрики и IoT | (device_id, day) | чтение по устройству и периоду, запись размазана |
| Маркетплейс, заказы | компромисс | покупатель и продавец смотрят на заказы с двух сторон |
Обычно один ключ не закрывает все запросы. В маркетплейсе шардируем заказы по buyer_id, а для продавца держим отдельную денормализованную витрину с шардированием по seller_id, которая обновляется асинхронно.
Notion шардирует по workspace_id: каждый блок принадлежит ровно одному рабочему пространству, поэтому большинство запросов обходится без cross-shard join.
Горячие шарды
Хеш распределяет ключи, но не нагрузку. Если половина активности приходится на одну звезду, она вся попадёт в один шард, какой бы хорошей ни была хеш-функция. Посмотрим, что происходит с восемью шардами и что даёт соль:
import hashlib import random from collections import Counter random.seed(42) def shard(key, shards=8): return int(hashlib.md5(key.encode()).hexdigest(), 16) % shards def report(title, events): busiest = max(Counter(events).values()) print(f"{title}: на самый загруженный шард — {busiest / len(events):.0%}") accounts = [f"user:{i}" for i in range(1000)] events = ["user:star" if random.random() < 0.5 else random.choice(accounts) for _ in range(100_000)] report("по user_id", [shard(a) for a in events]) # 56% # Записи звезды раскладываем по 16 виртуальным ключам def salted(account): if account == "user:star": return f"{account}#{random.randrange(16)}" return account report("с солью", [shard(salted(a)) for a in events]) # 24%
При идеальном раскладе на шард пришлось бы 12,5%. Получилось 24%, потому что 16 виртуальных ключей легли на шарды неровно: хеш гарантирует ровность только на большом числе ключей.
У соли есть цена: чтобы прочитать данные звезды, придётся опросить все её 16 ключей. Поэтому соль берут для горячих записей, которые редко читаются целиком, например для счётчиков и лайков. Другие варианты:
- Кэш перед базой, если ключ горячий по чтению.
- Отдельный шард для крупного клиента. Здесь выручает карта слотов из следующего раздела.
- Очередь и пакетная запись, если всплески короткие.
- Лимиты на клиента, если нагрузка выше оплаченной.
Отдельно смотрите метрики по шардам: запросы, CPU и размер. Средняя по кластеру загрузка в 30% спокойно скрывает один шард на 95%.
Решардинг
Рано или поздно шардов станет мало. Наивная формула hash(key) % N в этот момент обходится очень дорого:
import hashlib def h(key): return int(hashlib.md5(str(key).encode()).hexdigest(), 16) keys = range(1_000_000) moved = sum(h(k) % 4 != h(k) % 5 for k in keys) print(f"4 -> 5 шардов: переехало {moved / len(keys):.1%}") # 80.0% moved = sum(h(k) % 4 != h(k) % 8 for k in keys) print(f"4 -> 8 шардов: переехало {moved / len(keys):.1%}") # 49.9%
Чтобы добавить один сервер, нужно перевезти 80% данных под нагрузкой. Есть два основных способа этого избежать.
Консистентное хеширование. Узлы и ключи лежат на одном кольце, ключ принадлежит следующему узлу по часовой стрелке. Новый узел забирает только часть ключей у соседей. Виртуальные узлы размазывают эту часть по всему кольцу, чтобы нагрузка осталась ровной. В моём прогоне на 200 000 ключей и кольце со 100 виртуальными узлами на сервер при переходе с 4 узлов на 5 переехало около 21% ключей.
Логические шарды. Ключ хешируется в фиксированное число слотов, намного больше, чем серверов. Карта «слот → сервер» хранится в конфиге. Ключи никогда не меняют слот, переезжают целые слоты. Так устроен Redis Cluster с его 16 384 слотами.
import hashlib SLOTS = 1024 def slot_of(key): return int(hashlib.md5(str(key).encode()).hexdigest(), 16) % SLOTS slot_map = {slot: f"db-{slot % 4}" for slot in range(SLOTS)} def add_server(slot_map, new_server): servers = sorted(set(slot_map.values())) per_server = SLOTS // (len(servers) + 1) // len(servers) for server in servers: own = [s for s, owner in slot_map.items() if owner == server] for slot in own[:per_server]: slot_map[slot] = new_server before = dict(slot_map) add_server(slot_map, "db-4") keys = range(1_000_000) moved = sum(before[slot_of(k)] != slot_map[slot_of(k)] for k in keys) print(f"переехало ключей: {moved / len(keys):.1%}") # 19.9%
Этот вариант удобнее на практике: слот можно перенести отдельно, а горячий слот отправить на собственный сервер. Логических шардов лучше сразу завести с запасом: увеличить их число потом почти так же сложно, как сделать решардинг.
Лучшая иллюстрация — Notion. В 2021 году они завели 480 логических шардов на 32 серверах, по 15 на сервер. В 2023-м расширились до 96 серверов, по 5 логических шардов на каждый. 480 делится и на 32, и на 96, поэтому функцию маршрутизации трогать не пришлось: изменилась только карта.
Сам переезд без простоя обычно выглядит так:
- Поднять новые узлы и начать копировать данные: снимок плюс логическая репликация или двойная запись.
- Дождаться, пока новые узлы догонят старые.
- Сравнить результаты: часть чтений параллельно идёт в новые узлы и сверяется со старыми.
- На короткое время приостановить запись, убедиться, что лаг нулевой, и переключить карту.
- Сохранить обратную репликацию, чтобы было куда откатиться, и только потом удалить старые копии.
Примерно так Notion переехала на 96 серверов: в худшем случае пользователи около секунды видели индикатор сохранения.
Кросс-шардовые запросы
Это главная цена шардирования, причём платит за неё всё приложение. Если в запросе нет ключа, он уходит на все шарды, а результаты сливаются (scatter-gather).
Медленный хвост. Ответ scatter-gather ждёт самый медленный шард. Каждый шард выходит за свой p99 в 1% случаев. Если шарды независимы, вероятность встретить хотя бы один такой равна :
| Шардов | Запросов, которые упрутся в p99 хотя бы одного шарда |
|---|---|
| 1 | 1% |
| 8 | 8% |
| 32 | 28% |
| 100 | 63% |
Сортировка и пагинация. Топ-N собирается легко: берём N записей с каждого шарда и сливаем их. А вот OFFSET работает не так, как кажется:
import heapq from itertools import islice shards = [ [("2026-03-09", "a1"), ("2026-03-05", "a2"), ("2026-03-01", "a3")], [("2026-03-08", "b1"), ("2026-03-07", "b2"), ("2026-03-02", "b3")], [("2026-03-06", "c1"), ("2026-03-04", "c2"), ("2026-03-03", "c3")], ] def merge_top(per_shard, n): merged = heapq.merge(*per_shard, key=lambda row: row[0], reverse=True) return [row[1] for row in islice(merged, n)] # Вторая страница по 2 записи: нужны позиции 3 и 4 общего списка print(merge_top([rows[:4] for rows in shards], 4)[2:]) # ['b2', 'c1'] # OFFSET 2 LIMIT 2 на каждом шарде даёт совсем другое print(merge_top([rows[2:4] for rows in shards], 2)) # ['c3', 'b3'] # Keyset: каждый шард отдаёт записи строго после курсора def next_page(shards, after, n): return merge_top([[r for r in rows if r[0] < after][:n] for rows in shards], n) print(next_page(shards, "2026-03-07", 2)) # ['c1', 'a2']
OFFSET на шардах пропускает нужные записи, а корректная версия тянет offset + limit строк с каждого шарда, и глубокие страницы становятся всё дороже. Поэтому в шардированных системах пагинация обычно строится на курсоре.
Агрегаты. SUM и COUNT просто складываются. AVG нужно собирать из суммы и количества, среднее от средних будет неверным. Точная медиана без передачи данных не считается, обычно берут приближённые алгоритмы.
Join. Есть три рабочих варианта:
- Co-location: таблицы с одним ключом лежат на одном шарде. Figma называет такие группы
coloи разрешает join только внутри них и только по ключу шардирования. - Маленькие справочники копируются на все шарды.
- Денормализация: нужные поля хранятся прямо в строке или в отдельной витрине.
Транзакции. Двухфазный коммит существует, но он медленный и блокируется, если координатор падает. Figma сознательно отказалась от атомарных кросс-шардовых транзакций. Чаще всего модель данных строят так, чтобы транзакция жила в одном шарде, а остальное покрывают сагами.
Уникальность и индексы. Ограничение UNIQUE проверяется только внутри шарда. Для уникального email при шардировании по user_id заводят отдельную таблицу email → user_id, которая сама шардируется по email. Это и есть глобальный индекс, только написанный вручную, и обновлять его приходится отдельно от основной записи. Как устроены индексы внутри шарда, разобрано в статье про B-дерево.
Идентификаторы. Автоинкремент на каждом шарде даст дубли. Берите Snowflake ID, UUIDv7 или выдачу диапазонов из отдельного сервиса. Номер логического шарда можно записать прямо в ID, тогда маршрутизатору не нужен отдельный поиск.
Как рассуждать на собеседовании
Самая частая ошибка — произнести «зашардируем» на второй минуте. Интервьюер чаще всего ждёт другого: посчитайте нагрузку, покажите, что можно сделать проще, и назовите момент, когда это перестанет работать. Общая структура такого разговора описана в статье про подходы к проектированию систем.
Сначала цифры
def estimate(dau): writes_per_user = 20 # записей в день на пользователя reads_per_write = 10 row_bytes = 1_000 # вместе с индексами peak = 3 # пик относительно среднего peak_writes = dau * writes_per_user / 86_400 * peak peak_reads = peak_writes * reads_per_write year_tb = dau * writes_per_user * row_bytes * 365 / 1e12 print(f"{dau:>11,} DAU: запись {peak_writes:>9,.0f}/с, " f"чтение {peak_reads:>10,.0f}/с, {year_tb:>6,.1f} ТБ в год") estimate(2_000_000) # 2,000,000 DAU: запись 1,389/с, чтение 13,889/с, 14.6 ТБ в год estimate(200_000_000) # 200,000,000 DAU: запись 138,889/с, чтение 1,388,889/с, 1,460.0 ТБ в год
Ориентир на интервью: один хороший сервер Postgres или MySQL обычно держит тысячи записей в секунду и терабайты данных. Это грубая оценка: многое зависит от схемы, индексов и дисков. Так и скажите.
Для первого сценария запись укладывается в один лидер, чтения закрывают реплики и кэш. Настоящий вопрос здесь — 14,6 ТБ в год. Нужны ли все эти данные горячими? Если нет, хватит партиций по времени и архива. Для второго сценария одной машины не хватит ни по записи, ни по объёму, поэтому шардирование обсуждаем сразу и переходим к выбору ключа.
Лестница до шардов
Перечисляйте шаги в таком порядке:
- Индексы, медленные запросы, пул соединений.
- Вертикальное масштабирование. Железо обычно дешевле, чем время команды.
- Кэш для частых чтений.
- Реплики для остальных чтений.
- Партиции и архив, чтобы таблицы оставались управляемыми.
- Очереди и пакетная запись, если проблема в пиках.
- Вынос групп таблиц в отдельные базы.
- Шарды.
Большие компании идут примерно так же. Figma в 2020 году жила на одной базе Postgres на самом большом инстансе AWS. К концу 2022-го у неё были кэш, реплики и десяток вертикально разделённых баз, и только тогда дело дошло до горизонтального шардирования: отдельные таблицы выросли до нескольких терабайт, а самые нагруженные по записи приближались к пределу IOPS.
OpenAI в январе 2026 года рассказала, что обслуживает 800 млн пользователей ChatGPT с помощью Postgres с одним лидером и почти 50 репликами. Важная оговорка: нагрузка там в основном читающая, а хорошо шардируемые тяжёлые по записи данные уже вынесены в шардированные системы вроде Cosmos DB. Так что этот пример показывает, как далеко заходит репликация на чтениях, и не отменяет шарды для записи.
Когда пора, а когда рано
Рано, если:
- Лидер упирается в чтения, а реплик и кэша ещё нет.
- Запросы медленные из-за плана, а не из-за объёма.
- Данные влезают в один сервер с запасом на пару лет.
- В схеме нет ключа, который покрывает большинство запросов. Тогда шарды только ухудшат ситуацию.
Пора, если:
- Запись упирается в самую большую доступную машину, и пакетная запись уже не помогает.
- Одна таблица дошла до размера, при котором начинаются операционные проблемы. У Notion это были зависающий
VACUUM, риск TXID wraparound и небезопасные миграции. - По прогнозу роста вы упрётесь в потолок раньше, чем успеете мигрировать.
Последний пункт важнее, чем кажется. Figma понадобилось девять месяцев на первую горизонтально разделённую таблицу. Notion пишет, что стоило начать раньше: перегруженная база ограничила варианты миграции. Поэтому правило такое: сравнивайте запас по росту со временем на миграцию, а не с текущей загрузкой.
Figma показывает и способ снизить риск: сначала логическое шардирование поверх одной базы, потом физический перенос. Логический этап можно откатить изменением конфига.
Как это звучит
Посчитаю для SaaS с рабочими пространствами: 2 млн DAU, около 1 400 записей и 14 000 чтений в секунду в пике, 15 ТБ в год. Запись один лидер выдержит, поэтому начну с него, двух асинхронных реплик в разных зонах и кэша. Профиль и настройки после правки читаем с лидера, чтобы не было проблем с read-your-writes. Для объёма партиционируем по месяцу, старое уносим в холодное хранилище. Шардировать пока рано: это резко усложнит запросы. Если нагрузка вырастет в 10–20 раз, упрёмся в запись. Поэтому уже сейчас включаю
workspace_idв первичные ключи и запросы. Тогда при необходимости мы получим логические шарды, а карту слотов сможем менять без перехеширования.
За минуту вы назвали цифры, причину решения, цену и момент, когда оно перестанет работать. Именно это и оценивают.
Что часто спрашивают дальше
| Вопрос | Короткий ответ |
|---|---|
| Как не потерять запись совсем? | Кворумная синхронная репликация и готовность платить задержкой |
| Пользователь не видит свой комментарий. Почему? | Лаг реплики. Читать с лидера после записи или использовать токен |
| Как добавить шард без простоя? | Логические шарды, копирование с догоняющей репликацией, сверка, переключение карты |
| Один шард перегружен. Что делать? | Найти горячий ключ, затем кэш, соль или выделенный шард |
Как получить заказы продавца при buyer_id? | Асинхронная витрина по seller_id или scatter-gather для редких запросов |
Типичные ошибки на собеседовании
- Шарды против нагрузки на чтение. Большинство продуктов читает намного больше, чем пишет, и здесь хватает реплик и кэша.
- Шарды без реплик. Такая схема падает по частям, и чем больше шардов, тем чаще.
- Ключ
created_atс разбиением по диапазону. Вся запись уходит на один шард. - «Синхронная реплика всегда свежая». Подтверждение записи на диск не значит, что данные видны в чтениях.
- Multi-leader без разговора о конфликтах. Первым же вопросом будет: а что произойдёт при одновременной записи?