Репликация, шардирование и высокая доступность
Есть две принципиально разные операции, которые постоянно склеивают в одно слово «масштабирование». Репликация — это копирование: одни и те же данные лежат на нескольких узлах целиком. Шардирование — это разрезание: каждый узел хранит свой кусок, и ни один не знает всей картины.
Они решают разные задачи и стоят разного. Репликация не увеличивает объём хранимых
данных и почти не помогает записи — она даёт живучесть и параллельное чтение.
Шардирование даёт запись и объём, но ломает всё, что вы любили в SQL: джойны,
уникальные ограничения, транзакции, ORDER BY ... LIMIT. Почти всегда правильный
порядок такой: сначала выжать одну машину, потом реплики, и только потом — шарды.
| Проблема | Инструмент | Чем платим |
|---|---|---|
| Узел умер — сервис лежит | Репликация + автоматический failover | Лаг, риск split-brain, вторая машина |
| Чтений больше, чем тянет сервер | Реплики только на чтение | Устаревшие ответы, аномалии консистентности |
| Отчёты душат OLTP | Выделенная реплика или CDC-поток | Дублирование данных, лаг до минут |
| Пользователи за 200 мс RTT | Гео-реплики на чтение | Запись всё равно едет в лидера |
| Данные не влезают на диск | Шардирование | Кросс-шардовые запросы, респлиты |
| Записей больше, чем тянет лидер | Шардирование | Распределённые транзакции, сложность |
| ЦОД сгорел | Мульти-ЦОД репликация | Латентность коммита, стоимость трафика |
Предполагается, что транзакции и уровни изоляции вы уже разобрали: без понимания аномалий на одном узле разговор про распределённые аномалии превращается в заклинания.
Часть I. Репликация
Что именно копируем: три уровня журнала
Реплика не «копирует таблицы» — она проигрывает поток изменений. Вопрос в том, на каком
уровне абстракции этот поток записан. От ответа зависит буквально всё: можно ли
реплицировать между разными версиями и разными СУБД, фильтровать таблицы, и переживёт
ли репликация NOW() внутри запроса.
| Уровень | Что передаётся | Плюсы | Минусы | Где встречается |
|---|---|---|---|---|
| По операторам | Текст SQL: UPDATE t SET x=x+1 ... |
Компактно, читаемо | Недетерминизм: NOW(), RAND(), триггеры, порядок при LIMIT |
MySQL binlog_format=STATEMENT (легаси) |
| По строкам | «строка с PK=42 стала такой» | Детерминизм, кросс-версионность, фильтрация, CDC | Объём: массовый UPDATE = миллионы событий; нужен PK |
MySQL ROW, PostgreSQL logical replication, oplog MongoDB |
| Физический | Байтовые изменения страниц и WAL | Дёшево, точная копия вместе с индексами | Только та же мажорная версия и платформа; всё или ничего | PostgreSQL streaming replication, Oracle Data Guard |
Следствия, которые кусают на практике:
- Мажорный апгрейд PostgreSQL без даунтайма физической репликацией невозможен —
формат WAL версионный. Делают логической репликацией или
pg_upgradeс окном. binlog_format=STATEMENTдо сих пор встречается в легаси и до сих пор тихо расходится с мастером. Единственный правильный ответ —ROW(доки MySQL).- Oplog MongoDB идемпотентен:
$incпревращается в присвоение конкретного значения, поэтому журнал можно безопасно переигрывать с любой точки. - Физическая реплика реплицирует и распухание, и повреждение страницы. Она не защищает от «удалил не ту таблицу» и от битого диска — для этого нужны бэкапы и PITR. Это самая частая и самая дорогая подмена понятий в проде.
Синхронность: где стоит подтверждение и что обещано клиенту
Момент, когда клиент получает «ОК» на COMMIT, — это и есть определение вашего RPO.
PostgreSQL даёт не «вкл/выкл», а шкалу из пяти ступеней synchronous_commit:
| Значение | Ждём | Потеря при падении primary | Потеря при падении primary + replica |
|---|---|---|---|
off |
ничего | до 200 мс коммитов | те же |
local |
локальный fsync | всё, что не доехало (лаг) | всё, что не доехало |
remote_write |
реплика приняла в ОС | ноль | возможна — не на диске реплики |
on (по умолчанию) |
реплика сделала fsync | ноль | ноль |
remote_apply |
реплика применила | ноль | ноль, и чтение с реплики видит коммит |
Разница между on и remote_apply тонкая, но принципиальная: on гарантирует, что
данные не потеряются, но не гарантирует, что запрос к реплике сразу после COMMIT
их увидит. remote_apply гарантирует и это — ценой ожидания применения, которое на
реплике однопоточно и может встать за конфликтом с длинным читающим запросом.
Цена в цифрах, порядок величины: локальный fsync на NVMe — 0.1–0.5 мс; синхронная реплика в той же зоне доступности — плюс 0.5–2 мс; в соседней зоне — плюс 1–3 мс; в другом регионе за 1500 км — плюс 15–40 мс, и это физика, а не настройка.
MySQL решает ту же задачу через semi-sync, и там важен исторический нюанс:
AFTER_COMMIT (до 5.7) подтверждал транзакцию локально до ответа реплики, из-за
чего другие сессии видели коммит, который потом мог исчезнуть.
# my.cnf на источнике — semi-sync без «фантомных» чтений
rpl_semi_sync_source_enabled = ON
rpl_semi_sync_source_wait_point = AFTER_SYNC # не AFTER_COMMIT
rpl_semi_sync_source_timeout = 1000 # мс, дальше деградация в async
rpl_semi_sync_source_wait_for_replica_count = 1
gtid_mode = ON
enforce_gtid_consistency = ON
binlog_format = ROW
sync_binlog = 1
innodb_flush_log_at_trx_commit = 1
Ловушка semi-sync: при таймауте MySQL молча деградирует в асинхронный режим и продолжает принимать записи. Ваш RPO=0 действует ровно до первой сетевой икоты. PostgreSQL в синхронном режиме поступает наоборот — блокирует коммиты, пока не появится синхронная реплика. Это разные философии: MySQL выбирает доступность, PostgreSQL — сохранность. Обе защитимы, но вы обязаны знать, какую выбрали.
Отдельная мина PostgreSQL: если synchronous_standby_names указывает на единственную
реплику и она уходит на обслуживание, все коммиты на мастере встают. Лечится
ANY 1 (r1, r2) с двумя кандидатами или честным решением «нам подходит async».
Топологии
| Топология | Консистентность | Отказ узла | Когда НЕ брать |
|---|---|---|---|
| Single-leader | Сильная на лидере, eventual на репликах | Нужен failover, есть окно недоступности записи | Когда нужна быстрая запись в двух регионах |
| Каскадная | Как выше, лаг накапливается | Падение промежуточного узла отрезает ветку | Когда важен предсказуемый лаг |
| Multi-leader | Конфликты неизбежны | Регион работает автономно | Когда есть уникальные ограничения и счётчики — почти всегда |
| Leaderless | Настраиваемая через N/R/W | Выборов нет вообще | Когда нужны транзакции и джойны |
Multi-leader — не «репликация посильнее», а другая модель данных. Если два региона
одновременно меняют одну строку, СУБД обязана решить конфликт: last-write-wins по
метке времени (тихо теряет данные и зависит от рассинхронизации часов), хранение всех
версий с разрешением в приложении, или бесконфликтные типы — CRDT. Уникальный индекс на
email в multi-leader топологии в принципе не обеспечивается без глобальной
координации: два региона примут одинаковый email, и конфликт вы увидите постфактум.
Каноническое обсуждение — глава 5 в
«Designing Data-Intensive Applications» Клеппманна.
В leaderless-системах правило R + W > N гарантирует пересечение множеств реплик, но
не даёт линеаризуемости: два параллельных чтения во время незавершённой записи
вернут разное, а частично применённая запись не откатывается. Кворум — про вероятность
увидеть свежее, а не про гарантии; подробнее в
статье про NoSQL.
Что ломается из-за лага
Асинхронная реплика — машина времени, показывающая прошлое. Пользователь этого не знает, и получаются баги, невоспроизводимые локально.
- Read-your-writes. Меняем аватарку,
COMMITуходит на лидера, редирект на профиль читает с реплики — старая аватарка. «Я сохранил, ничего не изменилось». - Monotonic reads. Два чтения попали на реплики с разным лагом: комментарий появился, обновили страницу — исчез. Время пошло назад.
- Consistent prefix. Реплика применила ответ раньше самого сообщения. Особенно актуально при шардировании: причинно связанные записи живут на разных шардах.
Лечится не «мощными репликами», а маршрутизацией. Рабочая техника — токен позиции журнала: после записи запоминаем LSN и не читаем с реплики, которая до него не доехала.
# Read-your-writes без «всё читаем с мастера»: маршрутизация по LSN.
# Цена: один лишний round-trip на запись, ноль накладных на 95% чтений.
def write_and_capture_lsn(primary, sql, params):
with primary.transaction() as tx:
tx.execute(sql, params)
lsn = tx.execute("SELECT pg_current_wal_insert_lsn()").scalar()
return lsn # в сессию/куку пользователя, TTL порядка секунд
def pick_read_connection(pools, required_lsn):
"""Реплика, догнавшая required_lsn; иначе деградируем в primary."""
if required_lsn is None:
return pools.any_replica() # догонять нечего
for replica in pools.replicas_by_load():
if replica.scalar("SELECT pg_last_wal_replay_lsn()") >= required_lsn:
return replica
return pools.primary()
В MySQL то же делается штатной функцией, блокирующейся до применения GTID:
-- 0 — дождались, 1 — таймаут, NULL — репликация не работает
SELECT WAIT_FOR_EXECUTED_GTID_SET('3E11FA47-71CA-11E1-9E33-C80AA9429562:1-42', 1);
Не пытайтесь сделать read-your-writes глобально. Определите 5–10 сценариев, где устаревание заметно пользователю (профиль, корзина, только что созданный заказ), и примените токен там. Ленты, каталоги, поиск и отчёты прекрасно живут с лагом в секунды, а попытка сделать консистентным всё просто вернёт нагрузку на лидера.
Как правильно мерить лаг
Главная ошибка — смотреть на лаг в секундах. На простое «секунды отставания» растут, хотя реплика идеально синхронна: новых транзакций просто нет.
-- На PRIMARY: картина по каждой реплике в байтах WAL.
SELECT application_name, state, sync_state,
pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn) AS pending_bytes,
pg_wal_lsn_diff(flush_lsn, replay_lsn) AS replay_lag_bytes,
write_lag, flush_lag, replay_lag
FROM pg_stat_replication ORDER BY replay_lag_bytes DESC;
-- На REPLICA: честные секунды, без ложной тревоги на простое.
SELECT CASE WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() THEN 0
ELSE EXTRACT(EPOCH FROM now() - pg_last_xact_replay_timestamp())
END AS lag_seconds;
-- Слоты: источник инцидента «на мастере кончился диск».
SELECT slot_name, active, wal_status,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots ORDER BY 4 DESC;
Про слоты отдельно, потому что это самый частый инцидент. Слот гарантирует, что WAL не
удалится, пока реплика его не забрала. Реплику погасили, слот забыли — мастер копит WAL
до заполнения диска и останавливается. Обязательные меры: max_slot_wal_keep_size
(PG 13+), алерт на retained больше 20% диска и алерт на active = false дольше
10 минут. В MySQL аналогичная ложь — Seconds_Behind_Source: она обнуляется при разрыве
соединения, смотреть надо на разницу GTID-множеств и на
performance_schema.replication_applier_status_by_worker.
Часть II. Шардирование
Сначала — не шардировать
Шардирование навсегда удваивает эксплуатационную сложность. Прежде чем идти туда, честно пройдите список более дешёвых вариантов.
обычно 3 запроса дают 80% нагрузки] B1 --> A B -->|Да| C{Индексы и запросы
уже оптимальны?} C -->|Нет| C1[Индексы, N+1, убрать OFFSET-пагинацию] C -->|Да| D{Упираемся в чтение
или в запись?} D -->|Чтение| E[Реплики, кэш, материализованные представления] D -->|Запись| F{Влезаем в одну машину?} F -->|Да| G["Вертикально: 96 vCPU, 768 ГБ RAM, NVMe —
это дешевле кластера"] F -->|Нет| H{По какой оси режется?} H -->|Время| I[Партиционирование + архив холодного
в объектное хранилище] H -->|Арендатор| J[Шардирование по tenant_id] H -->|Никак| K[Пересмотреть модель или класс СУБД]
Цифры для калибровки: машина на 96 vCPU и 768 ГБ RAM с NVMe уверенно держит 30–80 тысяч простых OLTP-транзакций в секунду на PostgreSQL и терабайты горячих данных; в облаке это порядка 4–8 тысяч долларов в месяц. Кластер из восьми машин поменьше стоит примерно столько же по железу, но добавляет шардирующий слой, респлиты, распределённые миграции схемы, дежурства и, как правило, одного-двух инженеров. Инженеры дороже железа. Вертикальное масштабирование не «непрофессионально» — оно рационально довольно долго.
Стратегии разрезания
| Стратегия | Плюсы | Минусы | Когда НЕ брать |
|---|---|---|---|
| По диапазону | Эффективные range-сканы, простые сплиты | Горячие точки: свежие данные всегда на последнем шарде | Монотонный ключ — время, автоинкремент |
| По хешу | Равномерность | Range-запросы веером по всем шардам | Когда нужны выборки по диапазону ключа |
| Справочник | Гибкость, ручное расселение крупных клиентов | Лишний хоп и точка отказа, надо кэшировать | Когда ключей миллиарды |
| Гео / по арендатору | Локальность, изоляция, резидентность данных | Перекос: один клиент — половина данных | Когда клиенты неравномерны и топ-10 нельзя изолировать |
Range и hash часто комбинируют: хеш арендатора для равномерности, внутри — диапазон по времени для сканов. Ровно так устроен ключ в Cassandra: partition key определяет узел, clustering key — порядок внутри партиции.
Ключ шардирования — решение, которое почти нельзя отменить
Смена ключа на живой системе — это перелив всей базы с двойной записью, сверкой и окном отката. Считайте, что выбираете один раз. Хороший ключ удовлетворяет четырём условиям одновременно: высокая кардинальность; равномерность; присутствие в 90%+ запросов; совпадение с границей транзакции.
| Домен | Хороший ключ | Плохой ключ | Что сломается |
|---|---|---|---|
| B2B SaaS | tenant_id |
user_id |
Отчёты по компании становятся кросс-шардовыми |
| Мессенджер | conversation_id |
message_id |
Чтение переписки собирает данные со всех шардов |
| Соцсеть | user_id |
post_id |
«Все посты пользователя» — веер по кластеру |
| E-commerce | customer_id |
order_date |
Все сегодняшние заказы бьют в один шард |
| Метрики | hash(series_id) + время |
голое время | 100% записи в один шард |
Проблема тяжёлого арендатора приходит всегда: в любой B2B-системе есть клиент в сто
раз больше медианного, и после шардирования по tenant_id он один кладёт свой шард. Три
рабочих ответа: справочник вместо чистого хеша с ручным переселением топ-клиентов;
составной ключ (tenant_id, bucket) с солью только для крупных; либо тарифный ответ —
крупные клиенты едут в изолированный инстанс, и это продаётся дороже.
Ключевой приём с картинки: никогда не отображайте ключ на узел напрямую. Отображайте ключ на фиксированное большое число виртуальных бакетов, а бакеты — на узлы. Число бакетов выбирается раз и навсегда (Redis Cluster — 16384 слота, Couchbase — 1024 vbucket). Тогда добавление узла — перемещение части бакетов, а не пересчёт адресации.
Маршрутизация и цена распределённости
маршрутизация?} Q -->|В клиенте| CL["Библиотека в приложении:
ShardingSphere-JDBC
+ нет хопа, − логика в каждом сервисе"] Q -->|В прокси| PX["Отдельный слой:
Vitess VTGate, ProxySQL, mongos
+ языконезависимо, − ещё один слой"] Q -->|В СУБД| DB["Координатор внутри:
Citus, CockroachDB, YugabyteDB
+ прозрачно, − vendor lock"] CL --> S1[(shard 1)] & S2[(shard 2)] PX --> S1 & S2 DB --> S1 & S2
Наименее болезненный путь для команды на PostgreSQL — Citus: шардирование остаётся внутри Postgres, приложение продолжает говорить обычным SQL.
-- Колокация связанных таблиц по одному ключу делает большинство джойнов локальными.
SELECT create_distributed_table('tenants', 'tenant_id');
SELECT create_distributed_table('projects', 'tenant_id', colocate_with => 'tenants');
SELECT create_distributed_table('events', 'tenant_id', colocate_with => 'tenants');
SELECT create_reference_table('currencies'); -- маленький справочник на все узлы
EXPLAIN (COSTS OFF)
SELECT p.name, count(*) FROM projects p JOIN events e USING (tenant_id, project_id)
WHERE p.tenant_id = 42 AND e.created_at > now() - interval '7 days'
GROUP BY p.name;
-- Custom Scan (Citus Adaptive)
-- Task Count: 1 <-- отлично: один шард, один сетевой запрос
-- -> Task Node: host=worker-3 port=5432
-- -> HashAggregate ...
--
-- Без фильтра по tenant_id было бы Task Count: 32 — тридцать два сетевых запроса
-- и агрегация на координаторе. Это и есть цена «забыл ключ шардирования».
Первое, что надо построить после шардирования, — метрика доли одношардовых запросов. Упала ниже 90–95% — вы получили стоимость распределённой системы и производительность хуже одиночного сервера.
| Операция | На одном узле | На шардах | Обходной путь |
|---|---|---|---|
| JOIN больших таблиц | План оптимизатора | Broadcast или repartition по сети | Колокация по ключу шардирования |
Глобальный UNIQUE(email) |
Индекс | Невозможен без координации | Таблица-реестр или email как ключ шардирования |
| Автоинкремент ID | bigserial |
Коллизии между шардами | UUIDv7, Snowflake ID, диапазоны на шард |
COUNT(*) по всему |
Скан или статистика | Fan-out ко всем шардам | Инкрементальные счётчики, HyperLogLog |
ORDER BY x LIMIT 20 |
Индексный скан | Взять по 20 с каждого и слить | Курсорная пагинация по ключу |
| Транзакция на 2 шарда | ACID бесплатно | 2PC: плюс RTT, зависшие prepared | Проектировать так, чтобы не требовалось; иначе саги |
| Миграция схемы | Одна команда | N раз, с частичными отказами | Оркестратор + строгая обратная совместимость |
| Внешние ключи | Проверяются | Не существуют | Проверка в приложении, регулярная сверка |
Про 2PC знайте конкретный операционный риск: PREPARE TRANSACTION в PostgreSQL держит
блокировки и удерживает горизонт очистки, а координатор может умереть между prepare
и commit. Забытая prepared-транзакция останавливает VACUUM по всей базе и тихо съедает
диск. Если 2PC используется — обязателен алерт на pg_prepared_xacts старше минуты.
Респлит без даунтайма
Алгоритм одинаков в Vitess (Reshard), Citus (citus_move_shard_placement) и в
самописных решениях. Забывают обычно детали: заморозка только на один бакет, а не на
весь шард; сверка сумм до переключения, а не после; идемпотентность на время двойной
записи; заранее написанная и проверенная на стенде процедура отката. Респлит, который
нельзя откатить, — не респлит, а ставка.
Часть III. Высокая доступность
Три задачи failover: заметить, выбрать, отрезать
Автоматический failover разваливается почти всегда на третьем шаге.
- Заметить. Health-check не отличает «узел умер» от «сеть до узла умерла». Агрессивный таймаут даёт ложные срабатывания на каждом GC-стопе, мягкий — минуты простоя. Реалистичная база: 3 проверки по 3–5 секунд.
- Выбрать. Выбирать лидера должно большинство, иначе две половины разорванного кластера выберут себе по лидеру. Отсюда нечётное число узлов в DCS: два не переживают ни одного отказа (большинства из двух нет), три переживают один, пять — два.
- Отрезать (fencing). Старый лидер может быть жив и продолжать принимать записи. Его нужно принудительно демонтировать: остановить сервис, снять VIP, забрать сеть, в железе — выключить питание (STONITH). Без фенсинга вы получаете split-brain: две ветки истории, обе с реальными коммитами клиентов. Слить их автоматически невозможно.
через pg_rewind — иначе расхождение истории
Реальный конфиг: Patroni + etcd
Patroni — де-факто стандарт HA для PostgreSQL: он не изобретает консенсус, а арендует ключ лидерства в etcd/Consul/ZooKeeper.
scope: prod-billing
name: pg-node-1
restapi: { listen: "0.0.0.0:8008", connect_address: "10.0.1.11:8008" }
etcd3: { hosts: "10.0.1.21:2379,10.0.1.22:2379,10.0.1.23:2379" }
bootstrap:
dcs:
ttl: 30 # аренда лидерства; это нижняя граница RTO
loop_wait: 10 # период самопроверки узла
retry_timeout: 10 # инвариант: ttl >= loop_wait + 2 * retry_timeout
maximum_lag_on_failover: 1048576 # 1 МБ: отставшую реплику не повышаем
synchronous_mode: true # RPO = 0
synchronous_node_count: 1 # но не блокируемся, пока жива хоть одна реплика
postgresql:
use_pg_rewind: true # вернуть старого лидера без базового бэкапа
use_slots: true
parameters:
wal_level: replica
hot_standby: "on"
max_wal_senders: 10
max_slot_wal_keep_size: 64GB # страховка от «диск съели слоты»
synchronous_commit: "on"
wal_log_hints: "on" # обязателен для pg_rewind
archive_mode: "on"
archive_command: "wal-g wal-push %p"
postgresql:
listen: "0.0.0.0:5432"
connect_address: "10.0.1.11:5432"
data_dir: /var/lib/postgresql/16/main
authentication:
replication: { username: replicator, password: "${REPL_PASSWORD}" }
superuser: { username: postgres, password: "${SU_PASSWORD}" }
tags: { nofailover: false, noloadbalance: false }
ttl: 30 — это ваш пол по RTO: раньше тридцати секунд кластер не признает лидера
мёртвым. Снижать до 15 можно в стабильной сети внутри одной зоны; ниже 10 — приглашение
к ложным failover’ам на каждом всплеске нагрузки. Клиентов маршрутизирует HAProxy,
опрашивающий REST API Patroni, — именно этот слой определяет, за сколько приложение
узнает о новом лидере:
listen postgres_write
bind *:5000
mode tcp
option httpchk OPTIONS /primary # 200 только на текущем лидере
http-check expect status 200
default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
server pg1 10.0.1.11:5432 check port 8008
server pg2 10.0.1.12:5432 check port 8008
server pg3 10.0.1.13:5432 check port 8008
listen postgres_read
bind *:5001
mode tcp
balance leastconn
option httpchk GET /replica?lag=10MB # только не отставшие реплики
http-check expect status 200
default-server inter 3s fall 3 rise 2
server pg2 10.0.1.12:5432 check port 8008
server pg3 10.0.1.13:5432 check port 8008
on-marked-down shutdown-sessions — не косметика: без него старые TCP-соединения к
демонтированному лидеру живут и получают ошибки записи вместо переподключения.
И отдельно: никогда не переключайте трафик БД через DNS. JVM кэширует DNS бессрочно
по умолчанию, резолверы игнорируют низкий TTL, а пулы соединений вообще не перерезолвят
адрес. Правильно — TCP-прокси, виртуальный IP или target_session_attrs=read-write
в строке подключения с перечислением всех хостов: драйвер сам найдёт лидера.
Сравнение решений HA
| Решение | RPO | Реалистичный RTO | Fencing | Замечание |
|---|---|---|---|---|
| Ручной failover по ранбуку | зависит от режима | 5–30 мин | человек | Честный выбор при SLA 99.9 и ночном окне |
| repmgr (PostgreSQL) | лаг при async | 30–120 с | слабое, нужен свой witness | Проще Patroni, но split-brain реален |
| pg_auto_failover | 0 при sync | 20–60 с | через монитор | Монитор сам становится точкой отказа |
| Patroni + etcd | 0 при synchronous_mode |
20–45 с | демоут + pg_rewind |
Стандарт индустрии; нужен DCS из 3+ узлов |
| MySQL InnoDB Cluster | 0, кворумная запись | 10–30 с | встроенный | Чувствителен к сети; MySQL Router как маршрутизатор |
| Orchestrator (MySQL) | лаг при async | 15–60 с | скриптами | Отлично видит топологию, фенсинг пишете сами |
| MongoDB Replica Set | 0 при w: majority |
10–20 с | встроенный | Выборы в протоколе, драйвер сам находит primary |
| AWS RDS Multi-AZ | 0, синхронная копия | 60–120 с | у провайдера | Вы не управляете таймаутами и не видите деталей |
| Aurora / Spanner | 0 | 10–60 с / прозрачно | у провайдера | Дорого, зато не ваша забота |
Арифметика доступности
Чем больше компонентов последовательно нужны для работы, тем ниже итог: три сервиса по 99.9% в цепочке дают 99.7% — почти два часа простоя в месяц.
| SLA | Простой в месяц | Простой в год | Что для этого нужно |
|---|---|---|---|
| 99% | 7.2 ч | 3.65 дня | Один сервер и бэкапы |
| 99.9% | 43.8 мин | 8.77 ч | Реплика и ранбук ручного failover |
| 99.95% | 21.9 мин | 4.38 ч | Автофейловер и прокси-слой |
| 99.99% | 4.38 мин | 52.6 мин | Мульти-AZ, синхронная репликация, дежурства 24/7 |
| 99.999% | 26 с | 5.26 мин | Мульти-регион, кворумные записи, отдельная команда |
Отрезвляющий вывод: 99.99% нельзя купить настройкой СУБД. При RTO в 30 секунд один инцидент съедает 11% годового бюджета простоя. Четыре девятки — это про архитектуру приложения (retry, идемпотентность, graceful degradation, очереди на запись), а не про конфиг Patroni. И почти всегда дешевле договориться с бизнесом о 99.95%.
Про географию: скорость света в оптоволокне около 200 000 км/с, Москва — Франкфурт даёт RTT 35–40 мс, значит синхронный коммит между ними стоит минимум 40 мс независимо от бюджета. Отсюда стандартный компромисс: синхронная репликация внутри региона (между зонами доступности, RTT 0.5–2 мс), асинхронная — между регионами, с явно принятым и записанным ненулевым RPO. Кому нужен RPO=0 между регионами — идут в системы с консенсусом на уровне записи и платят той же латентностью, но получают за неё гарантии; это тема статьи про NewSQL.
Типичные ошибки в проде
- Реплика вместо бэкапа.
DROP TABLEреплицируется за миллисекунды. Нужны PITR и отложенная реплика (recovery_min_apply_delay = '1h') как стоп-кран. - Забытый слот репликации заполняет диск мастера и роняет его. Алерт на
wal_statusиactive=falseплюсmax_slot_wal_keep_size. - Все чтения переведены на реплики разом. Через день приходит первый баг read-your-writes и всё откатывают. Переводить надо по сценариям, с токеном LSN.
- Автофейловер без фенсинга. Работает год, потом сетевой раздел даёт две ветки истории. Фенсинг проверяется на учениях, а не читается в документации.
- DCS из двух узлов или etcd на тех же машинах, что БД. Большинства нет — кластер встаёт при любом отказе. Нужно три узла, желательно в разных зонах.
- Failover ни разу не проверяли. Учения раз в квартал: убить лидера в рабочее время под нагрузкой. Непротестированный failover — это не HA, а надежда.
- Конфликты применения WAL на реплике. Долгий аналитический запрос на hot standby
блокирует применение: либо реплика отстаёт, либо запрос убивается по
max_standby_streaming_delay.hot_standby_feedback=onзащищает запросы ценой распухания мастера — выбор осознанный. - Шардирование по автоинкременту или дате. Вся запись бьёт в последний шард. Классика, заметная только под нагрузкой.
- Кросс-шардовые запросы «временно». Метрика доли одношардовых запросов должна быть на дашборде с первого дня, иначе она тихо деградирует до 50%.
- Синхронная реплика одна. Она уходит на обслуживание — мастер перестаёт коммитить.
Спасает
ANY 1 (r1, r2)илиsynchronous_node_count. - Мониторинг лага только в секундах. На простое врёт вверх, при разрыве репликации врёт вниз. Мерить в байтах и отдельно следить за самим фактом соединения.
Мини-итог
- Репликация даёт живучесть и чтение; шардирование — объём и запись. Не путайте задачи.
- Уровень журнала (по операторам / по строкам / физический) определяет, что вы вообще можете: кросс-версионность, фильтрацию, CDC, апгрейд без даунтайма.
- RPO задаётся точкой подтверждения коммита, RTO — таймаутами детектора и скоростью переключения клиентов. Оптимизируйте самое длинное звено, а не самое интересное.
- Лаг реплики — не баг, а свойство. Аномалии лечатся маршрутизацией по LSN/GTID в 5–10 сценариях, а не переводом всего трафика на лидера.
- Шардировать — после того, как исчерпаны индексы, кэш, реплики, партиционирование и вертикальный рост. Инженеры дороже железа.
- Ключ шардирования выбирается один раз: кардинальность, равномерность, присутствие в
запросах, совпадение с границей транзакции. Виртуальные бакеты вместо прямого
mod N. - Автоматический failover без фенсинга и без учений — это отложенный split-brain.
- Четыре девятки покупаются архитектурой приложения, а не конфигом СУБД.
Источники
- Martin Kleppmann. Designing Data-Intensive Applications, главы 5–9 — эталонный разбор репликации, партиционирования и распределённых гарантий: https://dataintensive.net/
- PostgreSQL: High Availability, Load Balancing, and Replication
и
synchronous_commit - Patroni documentation — synchronous mode и связь
параметров
ttl/loop_wait/retry_timeout - MySQL: Replication и Group Replication
- Vitess: Sharding — практика респлитов на масштабе YouTube; Citus — шардирование внутри PostgreSQL
- DeCandia et al. Dynamo: Amazon’s Highly Available Key-value Store, SOSP 2007: https://www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf
- Corbett et al. Spanner: Google’s Globally-Distributed Database, OSDI 2012: https://research.google/pubs/pub39966/
- Jepsen — независимые тесты распределённых СУБД на реальные гарантии, а не на обещания маркетинга
Что дальше
Мы разобрали, как масштабировать и защищать реляционную систему, и увидели, где реляционная модель начинает сопротивляться: кросс-шардовые джойны, глобальные ограничения, распределённые транзакции. Это ровно те места, где исторически появились альтернативные модели данных. Дальше — NoSQL: таксономия, CAP, BASE и когда реляционка не подходит.