Репликация: копии данных для доступности и отказоустойчивости
Предпосылки: сеть (клиент/сервер, запрос/ответ), понятие записи и её подтверждения.
Шардинг →
E-commerce платформа обслуживает 2 000 заказов в час. Все данные хранятся на одном сервере — PostgreSQL primary. Ночью диск выходит из строя. Заказы, созданные за последние несколько минут до сбоя, потеряны безвозвратно. Сайт недоступен 4 часа, пока команда восстанавливает сервер из бэкапа. Бизнес теряет выручку и доверие клиентов. Причина — единственная точка отказа: один сервер хранил единственную копию данных.
Зачем нужны копии
Один узел — Single Point of Failure (SPOF). Если он недоступен, вся система стоит. Репликация устраняет SPOF, создавая копии данных на нескольких узлах.
Типичная схема — leader-based replication: один узел (primary, leader, master) принимает все записи, остальные узлы (replicas, followers, standbys) получают от него поток изменений и применяют к своей копии данных.
clients (writes) ───→ primary
clients (reads) ───→ replicas (опционально)
primary ─────────────→ replicas (поток изменений)Копии дают три практических результата. Во-первых, высокая доступность (HA): если primary недоступен, одна из реплик берёт на себя роль primary — система продолжает работать. Во-вторых, масштабирование чтения: часть запросов на чтение можно отдавать репликам, разгружая primary. В-третьих, изоляция нагрузки: тяжёлые аналитические запросы или бэкапы выполняются на отдельной реплике, не мешая основной работе.
Этот механизм не специфичен для баз данных. Kafka реплицирует партиции между broker’ами (ISR — In-Sync Replicas), etcd и Consul хранят несколько копий конфигурации и метаданных кластера, CDN реплицирует контент на edge-серверы ближе к пользователям. Везде задача одна: не потерять данные при отказе и продолжить обслуживание. Конкретные реализации: streaming replication в PostgreSQL (WAL-поток, hot standby, Patroni) и асинхронная репликация в Redis (full resync, replication backlog, WAIT).
Когда запись «подтверждена»: синхронная и асинхронная репликация
Репликация создаёт копии, но не отвечает на вопрос: в какой момент primary считает запись завершённой и сообщает клиенту «OK»? Ответ определяет баланс между задержкой записи и риском потери данных.
Асинхронная репликация. Primary подтверждает запись клиенту сразу, не дожидаясь реплик. Это даёт низкую задержку записи, но создаёт два эффекта: чтения с реплик могут быть устаревшими (replication lag), а при отказе primary последние подтверждённые записи, не успевшие попасть на реплику, теряются — фактически at-most-once на уровне репликации.
Клиент Primary Replica
│ │ │
│── INSERT ─────→│ │
│←── OK ─────────│ │
│ │── данные ──────────→│ (позже)В нашем сценарии: платформа настроила асинхронную репликацию. Primary упал через 200ms после подтверждения записи клиенту. За эти 200ms данные не успели дойти до реплики — 5 заказов потеряны. Клиенты получили «заказ оформлен», но заказов в системе нет.
Синхронная репликация. Primary подтверждает запись только после того, как реплика подтвердила получение (а в некоторых системах — применение) изменений. Окно потери данных исчезает, но появляется задержка: каждая запись ждёт сетевого round-trip (RTT) до реплики и обратно. Если синхронная реплика недоступна, primary может быть вынужден остановить записи — доступность на запись падает.
Клиент Primary Replica
│ │ │
│── INSERT ─────→│ │
│ │── данные ──────────→│
│ │←── ACK ────────────│
│←── OK ─────────│ │Платформа переключает репликацию на синхронную. Потери данных больше нет, но задержка записи выросла с 2ms до 5ms (добавился RTT до реплики). А когда реплика зависла на 30 секунд из-за перегрузки — все записи на primary встали, и 2 000 заказов в час превратились в 0.
Компромисс неустраним: либо скорость и риск потери, либо надёжность и зависимость от реплики. Многие системы используют полусинхронную схему: одна реплика синхронная (гарантия хотя бы одной копии), остальные — асинхронные (не замедляют запись дополнительно).
Replication lag: чтение «из прошлого»
Большинство систем используют асинхронную репликацию (или полусинхронную с одной синхронной репликой) — задержка записи важнее. Но у этого выбора есть оборотная сторона: реплика применяет изменения позже, чем они произошли на primary, и чтение с неё может не видеть только что выполненную запись.
Клиент обновил адрес доставки и тут же открыл страницу профиля. Запись ушла на primary, чтение — на реплику. Реплика ещё не получила обновление. Клиент видит старый адрес и думает, что изменение не сохранилось.
Решение для таких случаев — read-your-writes consistency: после записи направлять чтение на primary (или на реплику, которая гарантированно получила эти данные). Это частный случай из спектра моделей консистентности, где каждая модель ослабляет или усиливает гарантии в зависимости от требований.
Replication lag — неизбежное следствие асинхронной репликации. Его можно минимизировать (быстрая сеть, мощные реплики), контролировать (мониторинг задержки), обходить (направлять критичные чтения на primary), но полностью устранить без синхронной репликации нельзя.
Failover: что происходит, когда primary умирает
Платформа настроила полусинхронную репликацию: primary в DC1, синхронная реплика — в DC2. Данные в безопасности. Но в пиковый час primary зависает и перестаёт отвечать. Реплика хранит актуальные данные — однако сама по себе она read-only. Кто-то должен обнаружить сбой, назначить реплику новым primary и переключить на неё приложение.
Failover (fail — «отказ», over — «переход к другому») — переключение системы на другой узел. Минимальный набор шагов почти везде один. Сначала нужно обнаружить отказ — обычно через heartbeat: primary периодически отправляет сигнал «я жив», и если сигнала нет N секунд, primary считается недоступным. Затем выбрать кандидата — реплику с наиболее актуальными данными (минимальный replication lag). Прежде чем переключить клиентов, нужно оградить старый primary (fencing) — убедиться, что он больше не принимает записи. Без этого шага возможны два primary одновременно. И наконец — перенаправить реплики и приложения на новый primary.
Весь цикл занимает от секунд до минут в зависимости от системы. В это время записи недоступны — это цена переключения.
Split brain: два primary одновременно
Допустим, primary платформы не упал, а потерял связь с DC2 из-за сетевого сбоя. Мониторинг в DC2 не получает heartbeat и промоутит реплику в новый primary. Но старый primary в DC1 жив и продолжает принимать заказы. Возникает split brain — два узла одновременно считают себя primary и принимают записи.
partition
X
Old Primary ──────┼────── New Primary
│ │ │
v │ v
принимает │ принимает
записи │ записиОба узла работают независимо, не зная друг о друге. Результат — расходящиеся данные:
DC1 (изолирован) DC2
┌────────────────┐ X ┌────────────────┐
│ Old Primary │ │ New Primary │
│ заказ #10042 │ │ заказ #10042 │
│ (отменён) │ │ (оплачен) │
└────────────────┘ └────────────────┘
^ ^
клиент отменяет клиент оплачиваетКлиенты в DC1 продолжают писать в старый primary, клиенты в DC2 — в новый. Один и тот же заказ может оказаться в разных состояниях. Когда сеть восстанавливается, «склеить» два расходящихся потока записей очень дорого, а часто невозможно без потерь.
Fencing: ограда для старого primary
Чтобы исключить split brain, старый primary нужно физически лишить возможности принимать записи. Механизмы называются fencing (fence — «ограда»): отзыв токена лидерства, закрытие сетевого доступа, принудительный перезапуск сервера. Крайний вариант — STONITH (Shoot The Other Node In The Head): физическое отключение питания узла.
Когда старый primary восстанавливается после fencing, он не может вернуться в роль primary — эту роль уже занял другой узел. Бывший primary переподключается как реплика: откатывает записи, которые не успели попасть на новый primary (они оказались в «тупике» — подтверждены локально, но не реплицированы), и начинает получать поток изменений от нового лидера.
Fencing решает проблему на уровне одного переключения. Но при сетевом разделении (partition) ситуация сложнее: каждый узел видит только свою часть кластера и не знает, кто на самом деле изолирован.
Кворум: как узлы определяют, кто изолирован
Платформа добавила третий узел — арбитр в DC3, который не обслуживает клиентов, но участвует в голосовании. При сетевом разделении каждый узел видит только свои соединения. Primary в DC1 думает: «я работаю нормально, это DC2 и DC3 пропали». Узлы в DC2 и DC3 думают: «мы работаем, это DC1 пропал». Объективной картины ни у кого нет.
Кворум решает эту проблему правилом большинства: узел (или группа узлов) имеет право продолжать работу, только если он связан с большинством узлов кластера. Большинство — строго больше половины: в кластере из 3 узлов это 2, из 5 — это 3.
DC1 (изолирован) DC2 <──────> DC3
┌─────────┐ X ┌─────────┐ ┌─────────┐
│ Primary │ │ Replica │ │ Arbiter │
└─────────┘ └─────────┘ └─────────┘
Primary видит: 1 узел (себя) Replica видит: 2 узла
1 < 2 (большинство из 3) 2 >= 2 (большинство из 3)
→ меньшинство → большинство
→ отказывает в записи → продолжает работатьПочему это работает: большинство может быть только одно. Невозможно, чтобы две изолированные группы одновременно имели больше половины узлов — для этого потребовалось бы больше узлов, чем есть в кластере. Ровно одна группа продолжит принимать записи, остальные остановятся — split brain исключён на уровне арифметики.
Отсюда практическое правило: кластеры обычно содержат нечётное число узлов (3, 5, 7). При чётном числе (например, 4) возможно разделение 2-2, при котором ни одна из сторон не набирает большинства — система полностью останавливается, хотя половина узлов исправна.
Кворум предотвращает split brain, но не определяет порядок действий: кто инициирует выборы, как кандидат собирает голоса, что происходит при одновременных кандидатах. Эти вопросы решают алгоритмы консенсуса — в частности, Raft использует рандомизированные таймауты и монотонные термы поверх кворума.
Репликация решает доступность, но не ёмкость
Платформа выросла. Реплики с кворумом и автоматическим failover обеспечивают HA: при отказе любого узла система продолжает работать. Но заказов теперь 50 000 в день, каталог содержит 10 миллионов товаров, и primary упирается в потолок: диск, CPU, RAM. Добавление реплик не помогает — каждая реплика хранит полный набор данных, а все записи по-прежнему идут на один primary. Репликация масштабирует чтение, но не масштабирует запись и не увеличивает ёмкость. Для этого нужно разделить данные между узлами — шардинг.
Sources
- Kleppmann, M. Designing Data-Intensive Applications: Replication. https://dataintensive.net/
Шардинг →