Шардирование и репликация баз данных: в чём разница и что выбрать

Шардирование и репликация простыми словами: какую проблему решает каждая, лаг и read-your-writes, выбор ключа, горячие шарды, решардинг и как рассуждать об этом на собеседовании.

21 мин чтениябазы данных · системный дизайн · масштабирование · собеседования

Коротко

РепликацияШардирование
Что делаетхранит копии одних и тех же данных на разных узлахделит данные на части и раскладывает по узлам
Что решаетчтения, отказоустойчивость, задержку для регионовзаписи и объём данных
Чего не решаетзаписи и объём: каждая копия хранит и пишет всёотказоустойчивость: упавший шард недоступен
Чем платимлаг, устаревшие чтения, сложный 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:

  1. Синхронная реплика не значит свежая. При on я поставил применение журнала на реплике на паузу. Коммит прошёл, а чтение с реплики вернуло старые данные. Журнал уже лежит на её диске, но ещё не применён. Свежие чтения даёт только remote_apply: в том же опыте коммит с ним завис до снятия паузы.
  2. Синхронность снижает доступность. Когда я остановил единственную подходящую реплику, обычный 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. Клиент пишет на WW узлов и читает с RR из NN. При W+R>NW + R > N множества узлов пересекаются, и чтение застанет хотя бы одну свежую копию. При сбоях и одновременных записях гарантии слабее, чем кажется по формуле.

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

Три стратегии

СтратегияКак работаетПлюсыМинусы
По диапазону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 ограничивает передачу данных за пределы ЕС. Детали и исключения лучше уточнять у юристов, а на интервью достаточно спросить, есть ли такие требования. Внутри региона обычно всё равно шардируют по хешу.

Как выбрать ключ

Хороший ключ отвечает на четыре вопроса «да»:

  1. Значений много? country или status дадут единицы шардов, которые нельзя поделить дальше.
  2. Нагрузка размазана ровно? Важен не только объём, но и активность. Один большой клиент способен загрузить целый шард.
  3. Он есть в большинстве запросов? Иначе почти каждый запрос пойдёт на все шарды.
  4. Он не меняется? Смена ключа означает перенос строки на другой шард.
СистемаХороший ключПочему
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, поэтому функцию маршрутизации трогать не пришлось: изменилась только карта.

Сам переезд без простоя обычно выглядит так:

  1. Поднять новые узлы и начать копировать данные: снимок плюс логическая репликация или двойная запись.
  2. Дождаться, пока новые узлы догонят старые.
  3. Сравнить результаты: часть чтений параллельно идёт в новые узлы и сверяется со старыми.
  4. На короткое время приостановить запись, убедиться, что лаг нулевой, и переключить карту.
  5. Сохранить обратную репликацию, чтобы было куда откатиться, и только потом удалить старые копии.

Примерно так Notion переехала на 96 серверов: в худшем случае пользователи около секунды видели индикатор сохранения.

Кросс-шардовые запросы

Это главная цена шардирования, причём платит за неё всё приложение. Если в запросе нет ключа, он уходит на все шарды, а результаты сливаются (scatter-gather).

Медленный хвост. Ответ scatter-gather ждёт самый медленный шард. Каждый шард выходит за свой p99 в 1% случаев. Если шарды независимы, вероятность встретить хотя бы один такой равна 1−0.99N1 - 0.99^N:

ШардовЗапросов, которые упрутся в p99 хотя бы одного шарда
11%
88%
3228%
10063%

Сортировка и пагинация. Топ-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 ТБ в год. Нужны ли все эти данные горячими? Если нет, хватит партиций по времени и архива. Для второго сценария одной машины не хватит ни по записи, ни по объёму, поэтому шардирование обсуждаем сразу и переходим к выбору ключа.

Лестница до шардов

Перечисляйте шаги в таком порядке:

  1. Индексы, медленные запросы, пул соединений.
  2. Вертикальное масштабирование. Железо обычно дешевле, чем время команды.
  3. Кэш для частых чтений.
  4. Реплики для остальных чтений.
  5. Партиции и архив, чтобы таблицы оставались управляемыми.
  6. Очереди и пакетная запись, если проблема в пиках.
  7. Вынос групп таблиц в отдельные базы.
  8. Шарды.

Большие компании идут примерно так же. 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 без разговора о конфликтах. Первым же вопросом будет: а что произойдёт при одновременной записи?

Потренироваться в спокойной обстановке

Все задачи