Репликация: копии данных для доступности и отказоустойчивости

Предпосылки: сеть (клиент/сервер, запрос/ответ), понятие записи и её подтверждения.

Шардинг

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


Шардинг