Введение

Репликация - это поддержание копий одних и тех же данных на нескольких узлах, соединённых по сети. И сразу оговорюсь честно: моделью репликации выбор СУБД почти никогда не начинают. Первыми обычно идут модель данных и характер запросов, экспертиза команды, наличие managed-версии в вашем облаке и зрелость экосистемы. Репликация становится решающим фактором в единицах процентов проектов - там, где есть жёсткие требования по геораспределённости, RPO/RTO или масштабу записи. Но знать её нужно всегда, потому что именно она определяет, что произойдёт с вашими данными при отказе узла и какие гарантии вы имеете право обещать бизнесу.

Задач у неё несколько, и путать их вредно - решения у них разные:

  • держать данные ближе к пользователям, уменьшая задержку при запросах

  • продолжать работу при сбое отдельных узлов, повышая доступность

  • масштабировать чтение, распределяя SELECT-запросы между репликами

Чего репликация не делает - так это не масштабирует запись. Для этого нужен шардинг (партиционирование) - разрезание данных на непересекающиеся куски по разным узлам. Это ортогональная вещь: репликация делает копии одного и того же, шардинг раскладывает разное. Почти все крупные системы делают и то, и другое: данные шардируются, и каждый шард реплицируется. Когда вы слышите «эта база линейно масштабирует запись» - почти всегда речь идёт про шардинг.

Репликация описывается не одной шкалой, а тремя независимыми.

  1. Топология - кто имеет право принимать запись: один узел, несколько узлов или любой узел.

  2. Механизм - что именно передаётся по сети: SQL-операторы, физический журнал или логические изменения строк.

  3. Режим подтверждения - когда клиент получает «ок»: асинхронно, полусинхронно, синхронно или по кворуму.

Реальные системы - это комбинации. Galera - это «несколько лидеров + логический write-set + синхронная сертификация». Cassandra на QUORUM - «без лидера + логические мутации + кворумное подтверждение». Именно поэтому фразы вроде «Cassandra - это AP, а Postgres - это CP» бессмысленны без указания настроек. Разбор ниже идёт по первой оси, но большинство практических решений принимается на второй и третьей.

Популярная формулировка «выберите любые два из трёх: Consistency, Availability, Partition tolerance» - неточна, и сам Эрик Брюер публично отказался от неё в статье «CAP Twelve Years Later» (IEEE Computer, 2012). Проблема в том, что P - не свойство, которое выбирают: сети рвутся, пакеты теряются, узлы уходят в GC-паузу и выглядят мёртвыми. Отказаться от устойчивости к разделениям - значит объявить, что вы работаете на идеальной сети, а такой опции не существует.

Корректная формулировка (по доказательству Гилберта и Линч, 2002) звучит так: во время сетевого разделения система обязана выбрать - либо продолжать отвечать на запросы, рискуя вернуть устаревшие или расходящиеся данные (A), либо отказывать в обслуживании до восстановления связи ради сохранения линеаризуемости (C). Про всё остальное время - а это 99,9% жизни системы - CAP не говорит ничего.

Поэтому для повседневных решений полезнее PACELC (Дэниел Абади, 2010/2012): if Partition then A or C, Else Latency or Consistency. То есть при разделении выбирай между доступностью и консистентностью, а в остальное время - между задержкой и консистентностью. Именно вторая половина и есть та, с которой вы живёте каждый день: любая сильная гарантия оплачивается сетевыми round-trip’ами.

Система и настройки

При разделении

В обычном режиме

Итог

PostgreSQL/MySQL, асинхронные реплики

PA

EL

PA/EL

PostgreSQL + Patroni, синхронный кворум

PC

EC

PC/EC

MongoDB, w: "majority"

PC

EC

PC/EC

Cassandra, CL=ONE

PA

EL

PA/EL

Cassandra, CL=QUORUM

клетки нет

см. ниже

DynamoDB

PC на запись

EL на обычное чтение

PC/EL

Spanner, CockroachDB

PC

EC

PC/EC

Обратите внимание: Cassandra стоит в двух строках. Одна и та же база оказывается в разных клетках в зависимости от настроек запроса - это главный аргумент против ярлыков «AP-база» и «CP-база».

А второй её строки в PACELC просто нет, и это стоит проговорить. CL=QUORUM принято записывать в PC/EC, но это неверно. При разделении Cassandra действительно отказывает меньшинству в обслуживании (не A) и в норме действительно платит latency, дожидаясь медленнейшей из R реплик (не L). Вот только линеаризуемости она при этом не даёт ни в какой ветке - почему именно, разбирается в разделе про leaderless. Получается, что она жертвует и A, и L, а C взамен не получает: клетки под такое в PACELC не предусмотрено. Это дефект бинарности фреймворка, а не свойство продукта, и запоминать надо именно так, а не по буквам.

Слово «консистентность» означает разные вещи, и половина путаницы в теме - отсюда.

  • C в ACID - целостность с точки зрения бизнес-правил: баланс сходится, внешние ключи не висят. Свойство ваших транзакций, к репликации отношения не имеет.

  • Изоляция транзакций - Read Committed, Snapshot, Serializable. Про то, что видят параллельные транзакции друг о друге.

  • Модель консистентности распределённой системы - что́ гарантированно увидит клиент при чтении. Вот она и есть предмет разговора, и именно её обозначает буква C в CAP.

Для последней полезна иерархия от сильной к слабой:

  • Линеаризуемость - система ведёт себя так, будто копия одна: подтверждённая запись немедленно видна всем последующим чтениям. Это C в CAP.

  • Причинная консистентность - сохраняется порядок причинно-связанных операций (комментарий не появится раньше поста). Максимум, достижимый при сохранении доступности во время разделений.

  • Сессионные гарантии (Terry et al., 1994): read-your-writes - свои записи видишь сразу, monotonic reads - данные не «едут назад во времени», monotonic writes, writes-follow-reads.

  • Eventual consistency - «если записи прекратятся, реплики когда-нибудь сойдутся». Не говорит ни когда, ни что по дороге не будет отката к старому значению.

Три пары, которые путают чаще всего, стоит развести отдельно. Линеаризуемость - не сериализуемость: первая про реальное время (однажды прочитанное новое значение не отменяется для последующих чтений), вторая про эквивалентность какому-то последовательному порядку транзакций и реальное время не ограничивает вовсе. Strict serializability - это сумма обеих, и именно её даёт Spanner. Snapshot isolation сериализуемости не даёт - на write skew она ломается, и «у нас SI, значит транзакции изолированы» неверно.

Практический вывод: не спрашивайте «эта база консистентна?». Спрашивайте «какая модель консистентности, при каких настройках и на каких операциях».

Таксономия дальше - стандартная, из главы 5 Клеппманна, я её не изобретал и разворачиваю на актуальных примерах с конкретными цифрами. Внутри первой модели отдельно разбирается её современная разновидность, где лидер избирается консенсусом: сегодня на практике это основной вариант.

Репликация с одним ведущим узлом (Single-leader replication)

Иллюстрация работы репликации с одним ведущим узлом
Иллюстрация работы репликации с одним ведущим узлом

Принцип работы

В модели с одним лидером один из узлов кластера назначается главным (leader, мастер, primary). Все операции записи от клиентов направляются исключительно на этот ведущий узел. Лидер выполняет запись у себя локально, фиксирует изменения в журнале и распространяет поток изменений на остальные узлы - последователей (follower, реплика, secondary). Реплики применяют полученные изменения к своим копиям в том же порядке, в каком они были применены на лидере. Чтения могут выполняться с любого узла, но если читать с отстающей реплики, данные могут оказаться неактуальными.

Дальше почти всё решают два параметра, которые обычно проскакивают мимо внимания.

Механизм репликации - что именно передаётся по сети.

Пооператорная (statement-based): реплике передаётся сам SQL-оператор. Компактно, но опасно - любой недетерминизм ломает реплику. NOW(), RAND(), UUID(), автоинкременты, триггеры с побочными эффектами дадут разный результат на лидере и реплике, и данные молча разъедутся. MySQL исторически работал так и с 5.7 использует ROW по умолчанию.

Физическая (перенос журнала, WAL shipping / streaming): передаются байты журнала упреждающей записи - какие байты в каких блоках изменились. Максимально надёжно и дёшево по CPU, реплика байт-в-байт совпадает с лидером. Минус, который решает всё: журнал завязан на физический формат хранения, поэтому лидер и реплика обязаны быть одной мажорной версии. Обновить PostgreSQL с 15 на 16 через физическую репликацию невозможно. Именно так работает стандартная streaming replication в PostgreSQL (с версии 9.0, до этого был только file-based log shipping, появившийся в 8.2).

Логическая (row-based / logical decoding): передаются изменения на уровне строк, формат развязан с физическим представлением на диске. Это открывает три важные вещи: репликацию между разными мажорными версиями (то есть апгрейд без даунтайма), репликацию части данных и репликацию во внешние системы - в Kafka, в аналитическое хранилище, в поисковый индекс. Весь современный CDC (Debezium и родственники) построен на этом. В PostgreSQL это publication/subscription с 10-й версии, в MySQL - binlog_format=ROW, в MongoDB весь oplog логический по природе, и на нём построены change streams.

Практический вывод: если в горизонте планирования у вас есть мажорный апгрейд без даунтайма или интеграция с внешними системами - вменяемая логическая репликация становится критерием выбора не менее важным, чем топология.

Режим подтверждения - когда клиент получает «ок»

Он определяет ваш RPO (сколько данных вы теряете при внезапной смерти узла):

Режим

Лидер отвечает клиенту…

RPO при потере лидера

Цена записи

Асинхронный

сразу после локального коммита

> 0: теряется всё за время лага

0

Полусинхронный

после подтверждения от N реплик (обычно 1)

≈ 0 при отказе одного узла

+1 RTT до ближайшей реплики

Синхронный на всех

после подтверждения от всех реплик

0

+1 RTT до самой медленной

Кворумный

после подтверждения от большинства

0, пока жив кворум

+1 RTT до медианной реплики

Ключевое: синхронный режим «на всех» не только медленный, но и снижает доступность. Одна реплика ушла на обслуживание - запись на лидере останавливается целиком. Поэтому в проде правильный ответ почти всегда - кворум: RPO=0 и переживание отказа меньшинства.

# PostgreSQL: кворумная синхронная репликация
synchronous_standby_names = 'ANY 1 (replica1, replica2, replica3)'
synchronous_commit = on     # off | local | remote_write | on | remote_apply
# remote_apply - реплика ещё и применила изменение; нужно для read-your-writes,
# но дороже: ждём применения, а не только записи журнала
# MySQL 8.0.26+: полусинхронная репликация
rpl_semi_sync_source_enabled = ON
rpl_semi_sync_source_wait_for_replica_count = 1
rpl_semi_sync_source_wait_point = AFTER_SYNC   # безопаснее, чем AFTER_COMMIT
rpl_semi_sync_source_timeout = 1000            # мс до молчаливой деградации в async

Обратите внимание на последнюю строку: по таймауту MySQL молча переключается в асинхронный режим, и ваш RPO становится больше нуля. Мониторить Rpl_semi_sync_source_status обязательно.

// MongoDB: кворумная запись (с версии 5.0 это поведение по умолчанию)
db.orders.insertOne(doc, { writeConcern: { w: "majority", j: true, wtimeout: 5000 } })

Про порядок величин: синхронное подтверждение стоит один сетевой round-trip. Внутри одной зоны доступности это ~0,2–0,5 мс, между зонами одного региона ~0,5–2 мс - приемлемо почти всегда. Между регионами (Франкфурт ↔ Вирджиния ~85–95 мс, Европа ↔ Сидней ~250–300 мс) это уже меняет архитектуру приложения. Отсюда правило: синхронная репликация - внутри региона, между регионами - асинхронная.

Примеры СУБД

Модель с одним ведущим наиболее распространена и поддерживается из коробки во многих СУБД. Практически все классические реляционные системы используют её: PostgreSQL (потоковая репликация с версии 9.0) и MySQL позволяют настроить один основной сервер и несколько реплик. Аналогичный подход применяется в Oracle (Data Guard) и Microsoft SQL Server (группы доступности Always On). Среди NoSQL эту модель использует MongoDB: в её документации прямо указано, что первичный узел единственный принимает все операции записи, а вторичные асинхронно реплицируют журнал операций (oplog) и применяют его к своим данным. Такой подход также называют активный/пассивный (active-passive) или master-slave репликацией.

Автоматического переключения при отказе лидера в базовых конфигурациях нет.

  • PostgreSQL не имеет встроенного автофейловера вообще, ни в каком виде. Нужен внешний слой: Patroni, repmgr, pg_auto_failover либо аналогичная обвязка внутри managed-сервиса.

  • MySQL с классической binlog-репликацией - тоже нет, нужны Orchestrator или MHA.

  • MongoDB - автофейловер есть, потому что с версии 3.2 она использует Raft-подобный протокол выбора лидера.

Если вы разворачиваете PostgreSQL «просто с репликой» и рассчитываете, что кластер переживёт смерть мастера сам - он не переживёт.

Современная разновидность: лидер, избираемый консенсусом (Raft/Paxos)

Этот вариант заслуживает отдельного разговора, потому что сегодня он фактически вытеснил классический «мастер + асинхронная реплика», и большинство свойств, описанных ниже как недостатки, у него отсутствуют.

Формально это по-прежнему один лидер. Но способ, которым он выбирается и подтверждает записи, другой:

  1. Лидер избирается голосованием большинства и получает «срок» (term/epoch). Он лидирует, пока рассылает heartbeat’ы, если они пропали - начинаются перевыборы с новым, бо́льшим номером срока.

  2. Запись коммитится по кворуму - лидер подтверждает клиенту, как только запись принята большинством, а не всеми.

  3. Узел из старого срока не может навредить: записи с устаревшим номером срока отвергаются, поэтому классический split-brain становится невозможным.

Что это даёт по сравнению с классической схемой:

Проблема классического single-leader

Что делает консенсус

Автофейловера нет, нужна внешняя обвязка

Выбор лидера встроен в протокол

RPO > 0 при асинхронной репликации

RPO = 0: подтверждённое принято большинством

Split-brain при неаккуратном фенсинге

Невозможен: старый срок проигрывает новому

Синхронный режим «на всех» роняет запись

Нужно большинство, отказ меньшинства не мешает

Цена - один сетевой RTT кворума на каждую запись. Внутри региона это доли миллисекунды, кворум, растянутый на континенты, делает каждую запись длиной в межрегиональный RTT, и это часто оказывается неприятным сюрпризом.

Где это встречается: etcd, Consul, ZooKeeper (протокол ZAB) - на них стоит половина Kubernetes-мира, MongoDB с 3.2, MySQL Group Replication / InnoDB Cluster с 5.7.17 (под капотом XCom - вариант Paxos), Kafka KRaft. Отдельный большой класс - distributed SQL, где шардинг сочетается с консенсусом на каждый шард: Google Spanner (Paxos-группа на сплит, TrueTime и commit-wait дают строгую сериализуемость), CockroachDB (Raft на каждый range), YugabyteDB (Raft на tablet), TiDB/TiKV (Multi-Raft). Сюда же, вопреки распространённому мнению, относится Amazon DynamoDB - об этом подробно в разделе про leaderless.

Про distributed SQL стоит сказать чуть подробнее, потому что это ответ на задачу «транзакции и масштаб записи одновременно».

В трёх базовых топологиях она не решается - только их комбинацией: данные шардируются на диапазоны или хеш-бакеты, каждый шард становится отдельной консенсусной группой со своим лидером, а кросс-шардовые транзакции идут через 2PC поверх консенсуса. Последнее - центральная идея, а не деталь реализации. Классический 2PC блокируется при отказе координатора: участники, проголосовавшие «да», не могут ни закоммитить, ни откатиться, пока он не вернётся. Починка ровно в том, что состояние координатора само реплицируется консенсусом, и его отказ переживается выбором нового. Без этого NewSQL выглядел бы как «2PC, только медленнее».

Платите вы за это тем, что кросс-шардовая транзакция дороже одношардовой: одношардовая - это раунд консенсуса (~1–5 мс внутри региона), кросс-шардовая с 2PC - минимум два раунда плюс координация, обычно в 2–4 раза дороже и заметно хуже по хвостам. Выбор ключа шардирования снова определяет производительность.

И важная поправка про гарантии, которую нельзя обобщать на класс. Strict serializability из всей четвёрки даёт только Spanner, и покупается она за TrueTime: API возвращает интервал с гарантированной границей неопределённости ε, а транзакция при коммите ждёт её истечения (commit-wait, ~2ε), чтобы таймстамп гарантированно оказался в прошлом для любого наблюдателя. Сериализуемость Spanner при этом получает обычными средствами - 2PL, 2PC и Paxos-порядок внутри группы. TrueTime добавляет именно real-time составляющую. CockroachDB, YugabyteDB и TiDB обходятся без атомных часов - на гибридных логических часах с настраиваемым максимальным офсетом (у CockroachDB --max-offset, дефолт 500 мс) и рестартами по неопределённости вместо commit-wait. Дешевле и работает где угодно, но требует следить за NTP: узел, у которого часы ушли слишком далеко относительно половины кластера, сам себя останавливает. Именно ограниченность скью, а не архитектурный выбор - причина, по которой все трое не strict.

Две оговорки из практики. Чётное число узлов бесполезно: кластер из 4 переживает столько же отказов, сколько из 3 (один), но требует согласия троих вместо двоих. Всегда 3, 5 или 7; для дешёвого «полуузла» существуют witness/arbiter-роли, которые голосуют, но не хранят данные. И кворумный коммит не делает автоматически линеаризуемыми чтения с последователей - для них нужна явная семантика (CockroachDB, например, умеет follower reads «из ближайшей реплики, но данные на N секунд назад», и это отличный компромисс там, где он подходит).

Что вы на самом деле покупаете

Не простоту и не зрелость - их называют первыми, но получить их можно и в другом месте. Покупаете вы единственный, всеми разделяемый порядок изменений: все подтверждённые записи прошли через один узел, параллельные транзакции сериализовались там обычными блокировками и MVCC, реплики просто воспроизвели результат. Отсюда следует то, ради чего модель и держат: база даёт всё, к чему вы привыкли, - multi-statement транзакции, внешние ключи, уникальные индексы, SERIALIZABLE. В двух других моделях с этим существенно хуже, а глобальная уникальность в них недостижима в принципе.

Остальное идёт бонусом. Модель изучена десятилетиями и реализована везде, добавление реплики - это инициализация копии и подключение к лидеру, а для приложения система выглядит как одна база для записи плюс узлы для ускорения чтения.

Реплики разгружают лидера по SELECT-запросам, и добавление реплик действительно увеличивает читающую мощность. Оговорка, которая ломает планы: каждая реплика применяет 100% потока записи лидера. При write-heavy нагрузке реплики упираются не в чтение, а в применение журнала - и тогда новые реплики читающую мощность больше не увеличивают, растёт только лаг. Усугубляется тем, что применение часто однопоточное: в PostgreSQL WAL проигрывает единственный процесс startup, и распараллелить его нельзя. В MySQL параллельное применение есть (replica_parallel_workers с replica_parallel_type=LOGICAL_CLOCK, в 8.0 - по writeset) - одно из немногих мест, где MySQL объективно сильнее.

Чем за это платят

Часть расплат очевидна и неустранима. Лидер упал - записи встали до конца переключения, консенсус сокращает паузу до секунд, но не отменяет её. Пропускная способность записи ограничена одним узлом: даже с десятками реплик быстрее вставлять не выйдет, а горизонтально это лечится только шардингом (так и устроен distributed SQL). И при асинхронной репликации смерть лидера уничтожает последние подтверждённые пользователю транзакции - лидер вернул «успешно» и умер через миллисекунды, новый лидер о них не знает. Последнее чинится полусинхронным или кворумным режимом, и это первое, что надо настроить в проде.

Дальше - то, что стоит денег и внимания.

Репликационное запаздывание и устаревшие данные на репликах. При асинхронной репликации между фиксацией на лидере и появлением данных на репликах есть лаг, и чтения с реплик в этот момент возвращают устаревшую информацию. Это нарушает сразу несколько сессионных гарантий:

  • read-your-writes: пользователь сохранил профиль, перешёл на страницу профиля, читающую с реплики, и не увидел своих изменений;

  • monotonic reads: два последовательных запроса попали на реплики с разным лагом, и данные «поехали назад во времени»;

  • consistent prefix: при шардировании ответ может прийти раньше вопроса.

Причины роста лага, которые надо мониторить: пики записи, долгие запросы на реплике (в PostgreSQL - конфликты восстановления, hot_standby_feedback и max_standby_streaming_delay), медленный диск реплики, однопоточное применение.

Штатные способы получить read-your-writes, по возрастанию цены:

  1. Читать с лидера после записи в рамках сессии. Просто, но убивает смысл реплик.

  2. LSN/GTID-роутинг: запомнить позицию журнала после записи и направлять чтение только на реплику, которая до неё дошла. В PostgreSQL - сравнивать pg_current_wal_lsn() с pg_last_wal_replay_lsn(), в MySQL - WAIT_FOR_EXECUTED_GTID_SET().

  3. Причинно-консистентные сессии: в MongoDB это встроено с версии 3.6 - драйвер передаёт afterClusterTime, и вторичный узел дожидается нужной точки перед ответом.

И readConcern: "majority" в MongoDB проблему лага не решает. Он гарантирует долговечность - прочитанное не откатится при переключении лидера, - но не свежесть, и вернуть устаревшие данные имеет полное право. Для read-your-writes нужны причинно-консистентные сессии или чтение с primary.

Split-brain при неаккуратном переключении

Если старый лидер не понял, что его сместили, он продолжит принимать записи, которые потом придётся выбросить. Поэтому фенсинг - не опция: лизы с ограниченным сроком, отзыв VIP, выключение узла через IPMI или облачный API, super_read_only. Patroni использует внешнее распределённое хранилище (etcd/Consul/ZooKeeper) как источник истины с leader-ключом и TTL - узел, потерявший ключ, демотирует себя сам.

И здесь важно, чем именно ломается аренда. Не дрифтом часов, а паузами. Тот самый восьмисекундный GC замораживает лидера, срок аренды истекает по настенным часам, избирается новый лидер, старый просыпается и считает свою аренду действующей. Механизм требует ограниченного дрифта скорости часов и отсутствия пауз, превышающих его запас - это канонический сценарий и один из главных источников находок Jepsen.

Первая минута инцидента: три вещи, которые надо знать заранее. Их не изобретают на месте.

  • Не уничтожайте улики. Прежде чем чинить, снимите снапшот диска (или скопируйте журнал) со старого primary. pg_rewind отрезает ветку истории, и подтверждённые транзакции после него перестают существовать. Снапшот стоит минуту, восстановление невозможного не стоит ничего, потому что невозможно.

  • Если primary оказалось двое - это split-brain, и первым действием изолируется один из них: отзыв VIP, security group, выключение. До любых попыток чинить данные, а не после.

  • Чего делать нельзя: промоутить второй узел, не убедившись, что первый мёртв и изолирован. Возвращать старый primary в кластер без pg_rewind или перезаливки - получите две ветки истории. Удалять слот репликации «чтобы освободить место», не понимая, кто на нём висит.

Устаревшие чтения с самого лидера - то есть чтение с лидера само по себе линеаризуемости не даёт. Свергнутый лидер, ещё не узнавший об этом, отдаёт устаревшие данные с полной уверенностью. Лечится механизмами lease/ReadIndex в Raft-системах или, в MongoDB, уровнем readConcern: "linearizable" - который для подтверждения лидерства делает служебную запись и потому дорог.

Дефолт для большинства систем - и что из него выталкивает

Модель уместна везде, где нужны строгая консистентность и целостность, а запись помещается в один узел: финансы, учёт, заказы, CMS, любое приложение с инвариантами и транзакциями. Это подавляющее большинство систем.

При этом «помещается в один узел» стоит оценивать трезво: современный сервер спокойно тянет десятки тысяч транзакций в секунду. Большинство систем, которые «переросли Postgres», на деле переросли неоптимальные запросы и отсутствующие индексы.

Репликация здесь используется главным образом для повышения доступности (горячие standby-реплики на случай сбоя) и для распределения нагрузки на чтение. Географически распределённые системы часто начинают именно с этой модели: основной лидер в одном регионе, а в других - только реплики для быстрых локальных чтений, тогда как запись всё равно идёт в главный регион. Целостность данных оплачивается задержкой записи для удалённых пользователей - размен, который оправдан чаще, чем кажется: чтений на порядок больше.

Асинхронный single-leader - не CP

Два варианта модели ведут себя при разделении противоположно, и смешивать их нельзя.

Классическая асинхронная репликация. Утверждение «при разделении сети запись останавливается ради сохранения согласованности» для неё неверно. Отрезанный от остальных узлов мастер, который ещё не понял, что он отрезан, продолжит принимать и подтверждать записи - а после переключения на нового лидера эти данные придётся выбросить. Такая конфигурация не является ни C, ни A в строгом смысле: она не гарантирует линеаризуемость и не гарантирует сохранность подтверждённого. Именно это и делает её опасной, а вовсе не «медленный фейловер».

Консенсусная разновидность - действительно CP. Лидер, потерявший связь с большинством, не может закоммитить новую запись и через таймаут демотируется. Меньшинство перестаёт обслуживать записи, большинство выбирает нового лидера и продолжает работу. Система жертвует доступностью меньшинства ради линеаризуемости - это ровно тот выбор, который описывает CAP.

Доступность чтения в обоих вариантах высокая: даже при недоступности части реплик оставшиеся продолжают отвечать (с оговоркой про свежесть данных). Отказоустойчивость обеспечивается наличием копий: при кворумном подтверждении подтверждённые данные переживают смерть меньшинства узлов гарантированно, при асинхронном - с вероятностью, зависящей от лага.

Потолки: чтение, запись, задержки

Масштабируемость. Чтение масштабируется горизонтально до потолка, задаваемого объёмом записи (реплики применяют весь поток изменений). Запись не масштабируется вовсе - только вертикально либо через шардинг, что означает переход к distributed SQL или к ручному разбиению данных.

Здесь удобно один раз выписать формулу, которая дальше работает для всех трёх топологий:

потолок записи ≈ N / (число копий каждой записи)

Полная репликация, когда каждая запись едет на все N узлов, даёт потолок в один узел, сколько бы узлов ни было. При RF=3 на тридцати узлах потолок такой же, как у десяти. Отсюда сразу следует и то, почему шардинг остаётся единственным способом масштабировать запись, и то, почему частично реплицирующие конфигурации - законный промежуточный дизайн, а не полумера.

Сложность разработки. Самая низкая из всех моделей. Не нужно думать о конфликтующих обновлениях - система гарантирует порядок транзакций. Единственная реальная сложность - корректно обработать лаг репликации при чтении, и это проектное решение, которое надо принять заранее, а не обнаружить на проде.

Задержки. Задержка записи складывается из сети до лидера и фиксации на нём: при синхронном или кворумном режиме добавляется один RTT. Для глобально распределённых клиентов удалённые от лидера пользователи получают увеличенную задержку записи. Чтения могут быть очень быстрыми с ближайшей реплики, но при требовании свежести приходится идти к лидеру.

Доступность при сбоях - в цифрах:

Параметр

Значение

RPO, асинхронная репликация

> 0, равен объёму лага (обычно мс, при инциденте - минуты)

RPO, полусинхронная / кворумная

≈ 0 при отказе одного узла

RPO, консенсус

0 для подтверждённых записей при отказе меньшинства

RTO, ручное переключение

минуты–часы

RTO, Patroni (дефолт ttl=30, loop_wait=10)

~30 с; тюнингом сводится к 10–15 с

RTO, MongoDB (electionTimeoutMillis=10000)

~10–14 с

RTO, etcd (election timeout 1000 мс)

~1–2 с

Синхронное подтверждение внутри зоны доступности

+0,2–0,5 мс

Синхронное подтверждение между зонами региона

+0,5–2 мс

Кворум по трём регионам континента

+30–70 мс

Репликация с несколькими ведущими узлами (Multi-leader replication)

Простой схематичный пример репликации с несколькими ведущими узлами
Простой схематичный пример репликации с несколькими ведущими узлами

Принцип работы

В модели с множеством лидеров (она же master-master, active/active) выделенного единого мастера нет - каждый узел кластера может принимать операции записи. Каждый из них действует независимо для своих локальных клиентов, а затем изменения асинхронно доставляются остальным: узлы обмениваются логами изменений друг с другом по кольцевой, звездообразной или полносвязной топологии. Система стремится к тому, чтобы в конечном итоге все участники получили все изменения. Единого порядка операций при этом не существует, и это определяет все её свойства.

И сразу главное, потому что от этого зависит, стоит ли вообще смотреть в эту сторону: multi-leader не увеличивает пропускную способность записи

Это прямое следствие формулы из предыдущего раздела: копий каждой записи здесь N, значит потолок равен N/N, то есть одному узлу. В кластере из трёх лидеров каждый принимает свою треть записей - но затем обязан применить у себя записи двух других, и по объёму работы оказывается ровно там же, где одиночный мастер. Более того, чуть ниже: добавились сертификация, метаданные версий и трафик обмена.

Единственный способ реально масштабировать запись - шардинг: разные данные пишутся в разные узлы, и тогда узлу не нужно применять чужое. Но это уже партиционирование, а не мультилидерность, и работает оно в любой топологии.

Итак, multi-leader нужен не ради производительности, а ради локальной задержки записи и выживания при разрыве связи между площадками. Всё остальное - издержки, и оценивать модель надо по этим двум критериям.

Консистентность и конфликты

Главная сложность multi-leader состоит в том, что одни и те же данные могут одновременно изменяться на разных узлах. Поскольку репликация асинхронна, противоречащие друг другу транзакции обнаруживаются постфактум, при обмене логами - когда обе уже применены локально и обе подтверждены своим клиентам. Откатить нельзя: пользователю уже сказано «сохранено».

В базе с одним лидером такого не случается: вторая параллельная транзакция либо ждёт, либо завершается с ошибкой, но к расхождению данных не приводит. Если же откладывать подтверждение до глобального соглашения, смысл нескольких лидеров пропадает - это сведётся к распределённому консенсусу. Поэтому реализации multi-master полагаются на асинхронное обнаружение конфликтов и последующее разрешение.

Тоньше и хуже - нарушение причинности в топологии. При полносвязной топологии разные сетевые пути имеют разную скорость, и сообщение об UPDATE строки может обогнать сообщение об её INSERT: реплика получает обновление несуществующей строки. Кольцевая и звездообразная топологии сохраняют порядок лучше, но у них другая беда - выпадение одного узла разрывает цепочку доставки. Промышленные системы решают это версионными метками и буферизацией «до прибытия причины», но реализовано это далеко не везде, и проверять наличие такого механизма стоит явно.

Стратегии разрешения конфликтов

Принцип «последний победил» (Last Write Wins). Побеждает запись с большей временной меткой. Просто - и почти всегда неправильно. При часах, разъехавшихся на секунды (а дрейф NTP между площадками - норма), «последней» станет запись с самых убежавших вперёд часов, а не последняя по факту. И проигравшая запись уничтожается молча: не логируется, не сохраняется, пользователю ничего не сообщается - он видел «сохранено», а данных нет. LWW используют DynamoDB Global Tables и Cassandra. Это осознанный размен простоты и скорости на тихую потерю данных. Если тихая потеря данных для вас неприемлема, LWW брать нельзя.

Векторы версий (version vectors) и слияние версий. Каждая реплика ведёт счётчик, и по сравнению векторов система определяет, было ли изменение B причинно после A или параллельно ему. Параллельные версии сохраняются обе (siblings) и отдаются приложению для явного разрешения - либо сливаются автоматически по известному правилу. Ничего не теряется, но приложение обязано уметь мержить, и эту работу нельзя не сделать. Так работала оригинальная Dynamo. Riak позже перешёл на dotted version vectors, устраняющие ложные конфликты.

CRDT (конфликтно-устойчивые реплицированные типы данных). Структуры, устроенные так, что слияние параллельных изменений математически детерминировано и не теряет вкладов: счётчики (G-Counter, PN-Counter), множества (OR-Set), регистры, списки для текста. Конфликтов не возникает по построению.

Ограничение, которое здесь решает всё: CRDT решает задачу слияния, но не задачу инварианта. Счётчик остатка на складе прекрасно сойдётся к правильной сумме - и при этом спокойно уйдёт в минус, потому что условие «остаток ≥ 0» невыразимо в CRDT без координации. Живые реализации: Riak Data Types, Redis Enterprise CRDB, Azure Cosmos DB, а также Automerge и Yjs для коллаборативных редакторов.

Разделение ответственности (предотвращение конфликтов на уровне дизайна). Лучший способ справиться с конфликтами - сделать их невозможными. Закрепите каждую сущность за одним «домашним» узлом: пользователи EU пишут во Франкфурт, пользователи US - в Вирджинию, и данные конкретного пользователя редактируются только дома. Конфликтов нет, потому что параллельных записей одного объекта нет. Формально это опять шардинг - по гео-признаку. Именно он делает мультилидерную конфигурацию управляемой. Во многих СУБД есть настройки, принуждающие определённые таблицы или разделы быть single-master даже внутри multi-master кластера.

Конфликтами дело не ограничивается. Глобальная уникальность и автоинкременты в multi-leader не работают в принципе - это не недоработка конкретных реализаций, а следствие отсутствия координации. Два узла независимо выдадут id=1000 или зарегистрируют один и тот же e-mail, и при слиянии возникнет коллизия, которую разрешить уже нечем. Лечится UUID/ULID, диапазонами ключей на узел или отдельным сервисом идентификаторов. Транзакции на несколько объектов практически недоступны: атомарность на одном узле не даёт атомарности после слияния.

Одно свойство, за которое платят всем остальным

Уникальное здесь ровно одно: при разрыве связи между площадками каждая сторона продолжает принимать записи от своих клиентов. Ни одной другой топологии это недоступно - консенсус в меньшинстве откажет, single-leader без лидера встанет. Падение отдельного узла тоже не останавливает запись: клиенты уходят на соседний и работают дальше.

Из того же свойства растёт всё остальное, что модели ставят в заслугу. Пользователь в Токио пишет в токийский узел за 2 мс вместо 250 мс до Франкфурта - для интерактивных приложений это разница между «работает» и «не работает». Несколько ЦОДов обслуживают нагрузку одновременно, а не простаивают в ожидании переключения. И крайний случай: заметки, календари, таск-менеджеры, полевые приложения без связи - каждое устройство здесь лидер своей копии, и CouchDB спроектирован именно под это. Офлайн - самая честная и бесспорная ниша модели.

Счёт за это свойство

Отсутствие глобальных инвариантов. Уникальность, неотрицательные остатки, «ровно одна активная бронь», сходящийся баланс - не выражаются. Это не «сложно», а не подходит: если у вашей предметной области есть такие инварианты, multi-leader отпадает без обсуждения.

Конфликты, которые придётся разрешать руками. Приложению нужны обработчики слияния, логи конфликтов, оповещение ответственных систем. Даже при автоматических стратегиях (LWW, CRDT) остаётся риск некорректного объединения, особенно для взаимосвязанных сущностей. Плюс постоянный фон eventual consistency: разные узлы в один момент содержат разное, и клиент, читающий в двух локациях, увидит две версии.

Эксплуатация и отладка. Двунаправленную репликацию сложнее настроить: надо предотвращать бесконечное циркулирование обновлений (обычно идентификаторами источника) и уметь диагностировать расхождения - вопрос «почему у нас разные данные на узлах А и В» превращается в расследование. Тестирование распределённых отказов, то есть разрыв связи между ЦОДами под нагрузкой - отдельная непростая задача.

Оверхед координации, и здесь надо быть точным. Суммарный сетевой объём при одинаковом темпе записи примерно такой же, как в single-leader: каждое изменение всё равно доставляется на N−1 узлов. Издержки не в полосе, а в метаданных конфликтов и версионных векторах, в стоимости сертификации и слияния и в квадратичном росте числа соединений при полносвязной топологии. Последнее решают кольцом или звездой, но тогда растёт задержка распространения и появляется зависимость от промежуточных узлов.

Когда это оправдано, а когда самообман

Географически распределённые активные датацентры (active-active). Компании, не желающие держать пассивный резерв, настраивают несколько ЦОДов на одновременную работу: основные активные базы в Европе и США обслуживают каждый свой регион, изменения реплицируются между ними. Это позволяет пережить падение одного ЦОДа без простоя и улучшает отклик для локальных пользователей. Multi-master внутри одного датацентра смысла не имеет - там уместен консенсус.

Офлайн-режим и синхронизация устройств. CouchDB вместе с PouchDB - одна из немногих зрелых связок для офлайн-первых приложений. Её модель честна в отношении конфликтов: дерево ревизий, детерминированный «победитель» для чтения по умолчанию, но все конкурирующие версии сохраняются в _conflicts, и приложение обязано их разгрести. Ничего не пропадает молча - за это приходится писать код разрешения.

Интеграция разных систем: двунаправленная репликация между базами при миграциях и объединении источников. Здесь помогают внешние инструменты - Oracle GoldenGate, pglogical и подобные.

Про реализации - с точными формулировками. Galera Cluster (MariaDB Galera, Percona XtraDB Cluster) часто описывают как «синхронную мульти-мастер репликацию». Точнее говорить virtually synchronous, certification-based: транзакция выполняется локально, на коммите write-set рассылается всем узлам и сертифицируется в общем порядке, но применяется на других узлах асинхронно. Отсюда и практические следствия: чтение с другого узла может вернуть устаревшие данные, если не выставлен wsrep_sync_wait, а конфликт прилетает приложению на коммите в виде ошибки deadlock, и приложение обязано уметь ретраить. Плюс ограничения: только InnoDB, первичные ключи обязательны на всех таблицах, большие транзакции проблемны. Рекомендация самих разработчиков Galera по горячим таблицам - писать в один узел, что довольно красноречиво.

У MySQL есть и встроенный вариант: Group Replication в multi-primary режиме (с 5.7.17) - сертификация поверх Paxos-подобного XCom. PostgreSQL в стандартной поставке multi-master не имеет, варианты - EDB Postgres Distributed (бывший BDR/2ndQuadrant, коммерческий), открытый pgEdge/Spock, pglogical, Bucardo. Все требуют внимательного отношения к конфликтам.

AP по построению

Multi-leader системы делают упор на доступность и устойчивость к разделениям, жертвуя строгой согласованностью. При сетевом разделении каждый сегмент продолжает автономно обслуживать своих клиентов - это классический выбор A вместо C, и в терминах CAP такие системы относят к AP. Цена - временная рассогласованность между сегментами и конфликты, которые придётся разрешать после схождения.

Существуют варианты, где при обнаружении разделения часть узлов блокирует операции, чтобы избежать конфликтов, - но тогда теряется единственное настоящее преимущество модели, и разумнее взять консенсус. Если вы выбираете multi-leader, надо быть готовым мириться с рассогласованием ради доступности, иначе выбор сделан неправильно.

RPO здесь больше нуля всегда

На уровне узлов отказоустойчивость высокая: выход из строя отдельного сервера почти не влияет, остальные продолжают приём записей, а потеря целого датацентра не уничтожает данные, если они дублировались. Но если узел упал до того, как отреплицировал свои транзакции, эти изменения не сохранятся нигде. RPO здесь больше нуля всегда - в отличие от кворумных схем, где подтверждённое переживает смерть меньшинства гарантированно. Это не аварийный режим, а нормальное свойство модели.

После восстановления связи наступает фаза схождения - потенциально с большим количеством конфликтов. В этот момент может потребоваться временно приостановить часть операций или перевести приложение в режим только чтения, пока рассогласования не устранены. Планировать эту фазу нужно заранее, а не встречать её впервые во время аварии.

Масштаб, сложность, задержки

Масштабируемость. Записи - отсутствует (см. арифметику выше), прирост даёт только шардинг данных по «домашним» узлам. Реалистичное число активных лидеров - 2–4. Кластеры с десятками активных мастеров не встречаются не случайно.

Сложность разработки. Самая высокая из всех моделей. Нужно предусмотреть конфликтные ситуации, реализовать стратегии разрешения, обойтись без глобальной уникальности и транзакций, а иногда - построить отдельный слой согласования (распределённые локи, генераторы идентификаторов, ручное восстановление). Тестирование распределённых отказов обязательно и трудоёмко. Модель применяют опытные команды и только при обоснованной необходимости.

Задержки. Локальная запись - минимальная, как у одиночного узла, и это главный приз. Но данные, записанные в одном регионе, появляются в другом с задержкой репликации: RTT плюс время применения, то есть десятки–сотни миллисекунд. Если пользователь из США обновил профиль, а через секунду его друг в Европе открыл страницу, велик шанс увидеть старую версию. Multi-master улучшает локальные задержки, но не устраняет межрегиональной задержки распространения.

Доступность при сбоях - в цифрах:

Параметр

Значение

Пропускная способность записи кластера

≈ одного узла (без шардинга не растёт)

Задержка локальной записи

как у одиночного узла - главное преимущество

Время видимости записи в другом регионе

RTT + применение: десятки–сотни мс

RPO при потере узла до репликации

> 0 всегда

RTO

≈ 0: остальные узлы продолжают принимать запись

Доступность записи при разрыве между ЦОД

сохраняется в каждом сегменте

Реалистичное число активных лидеров

2-4

Старое правило, которое стоит держать в голове: самый безопасный мульти-мастер - тот, в который приложение пишет как в один мастер. Если вы к этому пришли - мульти-мастер вам, скорее всего, не нужен.

Репликация без ведущих узлов (Leaderless replication)

Принцип работы

Модель leaderless устраняет понятие мастер-узла вовсе: каждый узел равноправен, и клиентская операция обрабатывается совокупностью нескольких узлов. При записи клиент (или координирующий узел) отправляет запрос сразу на несколько реплик и ждёт подтверждения от W из них. При чтении опрашивает R реплик и выбирает наиболее свежую версию.

При общем числе копий N условие W + R > N означает, что множества узлов чтения и записи пересекаются хотя бы в одном узле - то есть чтение увидит хотя бы одну реплику с последней записью. Типовая конфигурация: N=3, W=2, R=2. Ослабляя параметры до W=1, R=1, вы получаете максимальную скорость и доступность, полностью потеряв гарантию свежести. В этом и состоит гибкость модели: баланс между консистентностью и доступностью настраивается, причём в некоторых системах - на каждый запрос.

Отставшие реплики догоняются двумя механизмами. Read repair: при чтении обнаружено расхождение версий, и свежая версия дописывается на отставшую реплику. Anti-entropy: фоновый процесс сверяет диапазоны данных по деревьям Меркла и выравнивает расхождения. Плюс hinted handoff: координатор, не достучавшийся до нужной реплики, запоминает подсказку и доставляет изменение позже.

И главное про кворумы: W + R > N не даёт линеаризуемости. Это полезная эвристика, а не гарантия. Ситуации, в которых она не работает (список по Клеппманну, гл. 5):

  1. Sloppy quorum. Если при недоступности «домашних» реплик система записывает на любые W доступных узлов, пересечение множеств не обеспечено и правило разваливается. Именно этим и занимается hinted handoff. Механизм полезный, но подавать его как усиление консистентности неверно - он её ослабляет. Нюанс по Cassandra: её кворум строже дайнамовского - подсказки не засчитываются в consistency level ни на одном уровне, кроме единственного CL=ANY. То есть QUORUM в Cassandra остаётся строгим, а ANY - нет. Разница между ними - это разница между «данные точно лежат на двух репликах» и «данные, возможно, лежат подсказкой на узле, который к ним отношения не имеет».

  2. Две параллельные записи. Кворум не определяет, какая победила; это решает конфликт-резолюшн, то есть чаще всего LWW со всеми его последствиями.

  3. Параллельные чтение и запись. Читающий может увидеть новое значение, а может старое - гарантии нет.

  4. Запись, успевшая менее чем на W узлов, не откатывается. Клиент получил ошибку, но данные частично записались, и последующие чтения могут их видеть.

  5. Восстановление узла из старого снапшота уменьшает число реплик с новым значением ниже W.

Вывод: если вам нужна линеаризуемость - кворумы её не дадут, нужен консенсус. В Cassandra это lightweight transactions на Paxos (с версии 2.0), и они кратно дороже обычной записи.

Поскольку лидера нет и глобального порядка операций не существует, два клиента могут одновременно обновить одну запись на разных узлах. Как и в multi-leader, нужны стратегии разрешения конфликтов. Чаще всего используется LWW: каждое обновление снабжается временной меткой, и при слиянии выбирается версия с максимальной меткой. Так поступает Apache Cassandra. Это быстро и просто, но проигравшее значение исчезает без следа - со всеми рисками рассинхронизации часов, описанными в предыдущем разделе. Альтернативно, Riak поддерживает сохранение siblings - нескольких конкурирующих версий, отдаваемых клиенту для ручного разрешения; ничего не теряется, но использование утяжеляется.

Ручка компромисса - и её цена

Первым достоинством модели обычно называют отсутствие единой точки отказа. Это следствие, а не суть. Суть - уровень консистентности задаётся для каждой операции: списание бонусов пишется на QUORUM, телеметрия - на ONE, в одной и той же таблице. Одна база обслуживает нагрузки с совершенно разной ценой консистентности, и вам не нужно два хранилища. Ни одна другая модель такого не даёт.

Следствия приятные. Нет лидера - нечего перевыбирать: узел выпал, координатор пошёл к другой реплике, клиент ничего не заметил, а rolling restart всего кластера без единой ошибки становится рутиной. Новый узел получает часть диапазонов и включается сам, что упрощает автомасштабирование. Реплики раскладываются по датацентрам, а LOCAL_QUORUM даёт кворум внутри своего ЦОДа с асинхронным обменом между ними: локальная задержка, живучесть при потере региона, eventual consistency между регионами. Только учтите, что это уже гибрид - leaderless внутри ЦОДа и, по сути, multi-leader между ЦОДами, со всеми конфликтными свойствами последнего.

Горизонтальная масштабируемость - но причину надо называть правильно. Масштаб приходит от шардинга, а не от безлидерности. Cassandra хеширует partition key и раскладывает диапазоны по узлам. Каждый ключ пишется только на RF узлов, отвечающих за его диапазон, а не на весь кластер. В терминах той же формулы это единственная из трёх моделей, где знаменатель не равен N: потолок здесь N/RF и растёт с размером кластера. Безлидерная репликация внутри диапазона этому просто не мешает.

Отсюда же и оценка тезиса «удвойте число узлов - удвоите производительность». Для операций по partition key это близко к правде. Разваливается он на запросах без partition key, горячих партициях, чрезмерно больших партициях, нагрузке от компакции и tombstone-heavy паттернах. То есть это не свойство базы, а свойство модели данных: спроектирована она правильно - тезис работает как ориентир, спроектирована иначе - не работает вовсе.

Счёт за эту гибкость

Слабая консистентность по умолчанию. Если кворумы не выставлены в строгий режим, данные сходятся постепенно: клиент А записал и получил подтверждение, клиент Б почти сразу прочитал с другого узла и увидел старое значение. Приложение приходится проектировать под eventual consistency - чтения могут не отражать последний коммит, при ретраях возможны дубликаты, обе параллельные операции могут примениться.

Тех же глобальных инвариантов, что и в multi-leader, здесь тоже нет - но ломается это иначе. INSERT ... IF NOT EXISTS работает через Paxos, это несколько round-trip’ов и кратно дороже обычной записи, а на горячих ключах ещё и плохо масштабируется. Обычный x = x + 1 в eventual-consistent хранилище теряет обновления: если две операции инкремента получили близкие метки, победит одна. Нужны counter columns со своими ограничениями - их нельзя держать в одной таблице с обычными колонками и нельзя откатывать.

Надгробия (tombstones) - операционная боль номер один. Удаление не удаляет: оно записывает маркер, который живёт gc_grace_seconds (по умолчанию 864000, то есть 10 суток) и только потом собирается компакцией. Следствия: удалённые данные продолжают занимать место. Запрос, проходящий по большому числу надгробий, деградирует вплоть до таймаута. И главное - если repair не отработал в пределах gc_grace, удалённые данные воскресают. Сам anti-entropy repair на больших датасетах - тяжёлая регулярная процедура, которую надо планировать и мониторить.

Стоимость кворумных операций. Запись ждёт подтверждения от W узлов, то есть длится столько, сколько самый медленный из требуемых. Внутри одного датацентра это единицы миллисекунд - заметно, но приемлемо. Кворум, растянутый между регионами, увеличивает задержку на десятки-сотни миллисекунд, поэтому операции, требующие согласованности, стараются держать внутри региона.

Механика кворумного чтения при этом устроена так. Координатор шлёт полный запрос данных одной реплике (обычно самой быстрой по snitch) и digest-запросы остальным, дожидается ответов в количестве CL, сверяет дайджесты и при расхождении запрашивает полные данные и выполняет блокирующий read repair. Он не может ответить «как только увидел свежую версию» - он обязан дождаться CL ответов, иначе гарантия кворума не выполняется. (В Cassandra 4.0 вероятностный фоновый read repair убрали, остался блокирующий.) Есть и оптимизация задержки: speculative retry дублирует запрос на другую реплику, если первая отвечает слишком медленно.

Потенциальная потеря данных при неудачных настройках. Свобода настройки - это и свобода ошибиться. При W=1 запись подтверждается после попадания на один узел, и его смерть до распространения уничтожает данные окончательно. При R=1 легко прочитать устаревшее значение. При этом даже правильное W+R>N не панацея: при разделении сети меньшая часть кластера не наберёт кворума и откажет в обслуживании. Например, при N=5, W=3, R=3 и разделении 3+2 сегмент из двух узлов становится недоступен - консистентность сохранена для большего компонента ценой доступности меньшего. Фундаментальный выбор CAP никуда не девается, leaderless лишь даёт инструмент делать его динамически.

Отсутствие глобальных транзакций и ограниченная модель данных. Большинство leaderless-баз - ключ-значение или wide-column хранилища без сложных транзакций, джойнов и произвольных WHERE. Таблица проектируется под конкретный запрос, данные денормализуются и дублируются, новый тип запроса означает новую таблицу и переливку данных. Для команды, привыкшей к SQL, это самая недооценённая статья затрат, и ошибка в модели данных на старте лечится переливкой всего датасета.

Поток по ключу - да, банковский учёт - нет

Leaderless отлично работает в масштабных сервисах, где темп запросов огромен, а требуемая консистентность - eventual: аналитика кликов и логи, мониторинг и метрики, ленты событий, IoT-телеметрия, временные ряды. Здесь потеря небольшого количества данных или задержка сходимости не критична, а простой системы неприемлем.

Про конкретные внедрения стоит быть точным, потому что классический список собирался по ранним годам проекта и с тех пор изменился. Cassandra была создана в Facebook для Inbox Search, но Facebook Messages в 2010 году ушли на HBase, и внутри Meta Cassandra сегодня живёт в Instagram. Twitter в 2010 году объявил, что твиты на неё не переезжают, и остался на MySQL/Gizzard, а затем перешёл на Manhattan. Из классического списка на месте остался Netflix, у которого Cassandra многие годы является основным операционным хранилищем.

Живых представителей модели сегодня немного. Apache Cassandra и ScyllaDB (переписанная на C++, с Raft для схемы и топологии в 5.x) - действующие. Riak - каноническая Dynamo-реализация с siblings и CRDT, но компания Basho ушла в конкурсное управление в 2017 году: код открыт, проект живёт силами сообщества, так что под новую систему его берут с отдельным обоснованием. Voldemort (LinkedIn) архивирован, LinkedIn перешёл на собственные Venice и Espresso.

Отдельного разбора требует DynamoDB: имя у него общее с родоначальницей модели, а архитектура - нет.

  • Dynamo - статья Amazon 2007 года: безлидерное хранилище со sloppy quorum, векторными часами и siblings. Она и породила модель, вдохновив Cassandra и Riak.

  • DynamoDB - коммерческий сервис, запущенный в 2012 году. По публикации Amazon на USENIX ATC 2022 («Amazon DynamoDB: A Scalable, Predictably Performant, and Fully Managed NoSQL Database Service») таблица разбита на партиции, каждая обслуживается replication group, внутри которой через Multi-Paxos избирается лидер, и все записи идут через него.

Архитектурно это шардинг плюс консенсусная репликация с лидером на партицию - то же устройство, что у CockroachDB или Spanner. Общего с Dynamo из статьи - название и стиль API.

Отсюда объясняется и остальное поведение сервиса: почему ConsistentRead=true вообще возможен (читаем с лидера) и почему он стоит вдвое дороже обычного чтения (1 RCU против 0,5 на 4 КБ), почему есть TransactWriteItems с настоящей атомарностью (с 2018 года). А Global Tables - отдельная фича мультирегиональной активно-активной репликации с разрешением конфликтов по LWW, и вот она относится к multi-leader. В одной системе на разных уровнях: консенсус внутри региона, мультилидерность между регионами.

Leaderless не подходит там, где нужна строгая последовательность операций и инварианты - банковские счета, остатки, брони. Обойти это можно, но ценой надстроек, которые обычно дороже, чем взять подходящую базу сразу.

Ярлык AP зависит от настроек запроса

По умолчанию leaderless-системы относят к классу AP: при проблемах со связью кластер старается продолжать отвечать, пусть иногда и устаревшими данными, вместо того чтобы останавливаться. Но, как показывает таблица PACELC во введении, ярлык зависит от настроек: на CL=ONE Cassandra ведёт себя как AP, на CL=QUORUM - как PC/EC, на CL=ALL фактически как CP. Консистентность здесь - не свойство базы, а параметр запроса, и в этом её сила.

Устойчивость к разделениям - базовое свойство: система спроектирована переживать разрыв сети и восстановление после него. При разделении большинство узлов образует кворум и продолжает работу, меньшинство либо отказывает (при строгом кворуме), либо продолжает обслуживать запросы ценой будущих конфликтов (при ослабленном).

RTO как величины здесь нет, а RPO зависит от W

Отказоустойчивость очень высокая: данные дублируются на нескольких узлах, и чтобы их потерять, должны выйти из строя все реплики конкретного диапазона. При отказе части узлов кластер автоматически маршрутизирует запросы на оставшиеся копии - координатор не ждёт мёртвый узел, а сразу идёт к другим репликам, так что пользователь часто вовсе не замечает инцидента. Переключения лидера как явления не существует, поэтому и RTO как отдельной величины здесь нет.

Оговорка про RPO: он равен нулю только при кворумной записи. При W=1 подтверждённая запись живёт на одном узле, и его внезапная смерть означает безвозвратную потерю. Это самая частая ошибка настройки - выставить W=1 ради скорости и считать, что данные защищены репликацией.

Масштаб, задержки, разработка

Горизонтальный масштаб здесь даёт шардинг, а безлидерная репликация обеспечивает, что отказы узлов ему не мешают: центральных узких мест нет. Отсюда и ниша - большие объёмы и высокая нагрузка при простом паттерне доступа.

Модель данных проектируется от запросов, а не от предметной области, и это главное, к чему надо быть готовым. По задержкам такие базы оптимизированы под небольшие кворумы, при росте требований к консистентности задержки растут, но всё ещё могут быть лучше, чем поход к единственному лидеру за океан.

В цифрах:

Параметр

Значение

Типовая конфигурация

N=3, W=QUORUM(2), R=QUORUM(2)

Переживаемые отказы при N=3, W=R=2

1 узел

RTO

≈ 0, переключение лидера отсутствует как явление

RPO при W=QUORUM

0 при отказе меньшинства

RPO при W=ONE

> 0: узел умер до распространения - данные потеряны

Задержка записи QUORUM внутри ЦОДа

единицы мс

Задержка LWT (Paxos)

в разы выше обычной записи

gc_grace_seconds по умолчанию

864000 (10 суток) - repair обязан укладываться

Заключение

Однозначного ответа нет, но выбор гораздо более детерминирован, чем кажется.

Если вам важны строгая согласованность, транзакции и целостность данных, а нагрузка на запись помещается в один сервер - берите СУБД с одним ведущим узлом: PostgreSQL, MySQL, MongoDB. Это подавляющее большинство систем, и не стоит стесняться такого выбора: современный сервер тянет десятки тысяч транзакций в секунду. Вы получите привычную семантику транзакций, целостность и масштабирование чтения через реплики.

Но сделайте две вещи, которые обычно откладывают: настройте кворумное или полусинхронное подтверждение (иначе вы теряете подтверждённые данные при смерти мастера) и поставьте автофейловер - Patroni для PostgreSQL, Orchestrator или InnoDB Cluster для MySQL. Голая реплика без автопереключения - это инструмент ручного восстановления, а не отказоустойчивость. Проверяется одним вопросом: что произойдёт в три часа ночи, если мастер умрёт? Если в ответе есть слова «дежурный зайдёт и…» - ваш RTO измеряется часами.

Если один пишущий узел исчерпан по-настоящему - переходите к distributed SQL: CockroachDB, YugabyteDB, TiDB, Spanner. Вы сохраняете транзакции и уникальность, платя задержкой на консенсус, стоимостью кросс-шардовых транзакций и заметно более высокой операционной сложностью. Ключевое слово - «по-настоящему»: брать Spanner или CockroachDB под нагрузку, которую тянет один PostgreSQL, - распространённая и дорогая ошибка.

Если система распределена по нескольким активным площадкам и должна работать при разрыве связи между ними, либо если пользователи вносят изменения офлайн - смотрите на multi-leader: CouchDB, MySQL Group Replication в multi-primary, Galera, pgEdge, DynamoDB Global Tables. Применяйте осознанно. Пропускной способности записи это не добавит. Глобальную уникальность и транзакции на несколько объектов вы потеряете. Конфликты придётся проектировать, реализовывать и тестировать - заложите на это время. Лучшая стратегия - закрепить каждую сущность за «домашним» узлом, чтобы параллельные записи одного объекта стали редким исключением.

Если перед вами задачи web-scale - терабайты данных, сотни тысяч операций в секунду, простой паттерн доступа по ключу, приемлемая eventual consistency - подойдёт leaderless: Cassandra или ScyllaDB. Убедитесь, что модель данных вашего приложения ложится на ключ-значение или широкие колонки, и что в команде есть кто-то, понимающий компакцию, repair и надгробия: без этого кластер деградирует за полгода. Для телеметрии с устройств Cassandra отлично справится - она будет поглощать данные, даже если часть узлов отвалится. Для банковского учёта применение AP-хранилища потребует надстроек и, скорее всего, окажется неоправданным.

Полезная сводка для сопоставления:

Один лидер (async)

Консенсус (Raft/Paxos)

Несколько лидеров

Без лидера (кворум)

Масштаб записи

1 узел

1 узел на шард

1 узел (нужен шардинг)

линейно за счёт шардинга

Конфликты записи

невозможны

невозможны

главная проблема

есть, чаще LWW

Глобальная уникальность

да

да

нет

только через LWT

Транзакции

да

да

практически нет

нет

RPO

> 0

0

> 0

0 при W=кворум

RTO

минуты

секунды

≈ 0

≈ 0

Задержка записи

локальная к лидеру

+1 RTT кворума

локальная везде

+1 RTT кворума

Живёт при разрыве между ЦОД

нет

только большинство

да, обе стороны

только большинство

Сложность разработки

низкая

низкая

высокая

средняя-высокая

PACELC

PA/EL

PC/EC

PA/EL

настраивается

И вопросы, ответы на которые почти однозначно определяют модель. Первый из них отменяет половину остальных, поэтому он и первый:

  1. Кто это будет эксплуатировать? Команда, у которой некому в три часа ночи диагностировать расхождение реплик, не может позволить себе ни multi-leader, ни leaderless - независимо от того, что говорят пункты ниже.

  2. Сколько данных вы готовы потерять при внезапной смерти узла (RPO)? Ноль требует кворума. Секунды допускают semi-sync. «Немного» означает async, но убедитесь, что бизнес это подтвердил.

  3. Сколько может не работать запись (RTO)? Секунды требуют консенсуса или leaderless. Минуты закрываются одним лидером с автофейловером. Часы допускают ручное переключение.

  4. Запись действительно нужна в нескольких регионах? Пользователи в разных регионах - ещё не аргумент: чтения прекрасно обслуживают локальные реплики. Аргумент - когда задержка записи влияет на продукт, или когда разрыв связи не должен останавливать бизнес.

  5. Есть ли глобальные инварианты? Если да - multi-leader и leaderless отпадают, и это не вопрос усилий.

  6. Помещается ли запись в один узел? Посчитайте пиковый темп и объём, прежде чем усложнять архитектуру.

  7. Терпит ли приложение устаревшие чтения? Если нет - решите заранее, как: чтение с лидера, LSN/GTID-роутинг или причинно-консистентные сессии.

  8. Какие нужны запросы? Джойны и агрегаты означают реляционную модель. Доступ только по ключу открывает весь NoSQL.

  9. Нужны ли апгрейд без даунтайма и CDC? Если да - нужна вменяемая логическая репликация. Отдельный критерий, не выводимый из топологии.

Что верно при любой выбранной модели

Реплика - не бэкап. Репликация - любая, включая консенсусную с RPO=0 - не защищает от логической порчи. DELETE без WHERE, DROP TABLE в неправильной консоли, плохо выкатившаяся миграция, баг приложения - всё это реплицируется корректно, быстро и сразу на все копии. Чем надёжнее ваша репликация, тем быстрее и надёжнее до всех реплик доедет ваша ошибка.

Защищает от этого другое:

  • PITR - базовый бэкап плюс архив журнала, восстановление на момент до ошибки. Это отдельная ось RPO, и задавать её нужно отдельно от репликационной.

  • Отложенная реплика. recovery_min_apply_delay = '1h' в PostgreSQL, SOURCE_DELAY в MySQL. Дешевле полного восстановления и даёт час на то, чтобы заметить DROP TABLE.

  • Периодические логические бэкапы - единственная защита от порчи на уровне формата хранения, которую физический бэкап аккуратно скопирует вместе с дефектом.

  • Проверка восстановления. Бэкап, из которого ни разу не восстанавливались - гипотеза, а не бэкап.

И как вообще получают уверенность, что гарантии настоящие. Всё написанное выше про гарантии производно от документации и статей вендоров, а этому источнику доверять нельзя. Индустрия выработала три способа проверять.

  • Тестирование отказами - Jepsen (Kyle Kingsbury, jepsen.io): распределённые БД гоняют под разделениями сети, паузами процессов и скачками часов, и раз за разом находят расхождение между заявленными и реальными гарантиями. Практический совет: найдите отчёт по вашей БД и вашей версии до того, как заложитесь на гарантию. Отсутствие отчёта - тоже информация.

  • Формальная верификация. Raft, Paxos и репликация MongoDB верифицированы в TLA+ и Ivy. Это отвечает на вопрос «почему мы верим, что протокол корректен», но верифицирован протокол, а не ваша сборка.

  • Детерминированное симуляционное тестирование. FoundationDB, TigerBeetle, Antithesis: кластер запускается в симуляции, где время, сеть и диски контролируются, а сценарии отказов перебираются с воспроизводимым seed.

Самое дешёвое, что можно сделать сегодня, - учения. Разрыв сети между зонами (iptables -j DROP, tc netem для деградации вместо честного отказа), SIGSTOP лидеру на тридцать секунд, сдвиг часов, заполнение диска. Failover, который никогда не тестировали, - гипотеза.

И то, с чего начиналось

Модель репликации редко бывает первым критерием выбора СУБД - об этом было в самом начале, и это по-прежнему так. Но она единственная из критериев, которая определяет, какие обещания о сохранности данных вы имеете право давать бизнесу. Всё остальное вы поменяете по дороге: модель данных подгоните запросами, managed-сервис смените на self-hosted, людей научите. Это - только миграцией.

И если вы не можете прямо сейчас ответить на четыре вопроса из начала статьи про свою текущую конфигурацию - вы её не выбирали, она сложилась.