2026 г.

Курс «Распределённые системы». Глава 5. Репликация

Цели главы. Разобрать три архитектуры репликации — лидерскую, мульти-лидерную и кворумную — с их точными гарантиями и точными провалами: где теряются подтверждённые записи, откуда берётся split brain и почему кворум сам по себе ещё не линеаризуемость. Отдельный раздел — CRDT, репликация «без арбитра» с математической гарантией сходимости. Завершает главу первая лабораторная работа курса: потоковая репликация PostgreSQL, аварийное переключение своими руками — и собственноручно потерянные транзакции.

5.1. Лидерская репликация

Самая распространённая схема: один узел — лидер — принимает все записи, пишет их в упорядоченный журнал и рассылает репликам, применяющим журнал в том же порядке (узнаёте реплицируемый автомат? он вернётся в главе 6 в строгом виде). Чтения — с лидера (сильные) или с реплик (дёшево, но с отставанием — сессионные гарантии главы 4 обязательны). Так устроены PostgreSQL, MySQL, MongoDB, Redis.

Ключевой выбор — момент подтверждения клиенту:

  • Синхронная репликация: подтверждать после записи на лидере и на выбранной реплике (репликах). Подтверждённое переживает отказ лидера, если failover допускает повышение только реплики, подтвердившей эту запись, а журналы сохраняются долговечно. Цена — дополнительный round-trip и возможная недоступность записи, когда требуемого числа синхронных реплик нет.
  • Асинхронная: подтверждать сразу, реплики догоняют. Быстро и доступно; при отказе лидера хвост подтверждённых, но не доехавших записей теряется — молча, с точки зрения клиентов.
  • Полусинхронная: хотя бы одна (или кворум) реплика синхронно, остальные — как получится. Рабочий компромисс большинства производств.

Этот выбор — не настройка, а обещание клиентам, и он обязан быть явным в проектной документации. В лабораторной вы измерите обе стороны монеты на настоящем PostgreSQL.

5.2. Аварийное переключение — самое опасное место схемы

Лидер отказал; нужно назначить нового (failover). Последовательность обманчиво проста: обнаружить отказ (тайм-аут — то есть подозрение, глава 1), выбрать реплику посвежее, повысить её, перенаправить клиентов и старого лидера. Каждый шаг минирован:

  • Ложный failover: лидер был жив (пауза GC, перегрузка), а его уже «заменили». Теперь лидеров два — split brain: оба принимают записи, данные расходятся необратимо.
  • Потеря хвоста: при асинхронной репликации новый лидер отстаёт; записи старого, не доехавшие до него, выбрасываются при возвращении «старика» в строй — включая те, о которых внешние системы уже узнали (классические инциденты с рассинхронизацией счётчиков и внешних индексов — ровно отсюда).
  • Зомби-лидер: старый лидер, очнувшись, продолжает обслуживать давно переставших быть актуальными клиентов.

Системное лекарство от зомби и split brain — ограждение (fencing): каждому лидерству присваивается монотонно растущий номер эпохи (терм — увидите в Raft), и все нижележащие ресурсы (хранилище, реплики, внешние сервисы) отвергают запросы с устаревшей эпохой. Не «вежливо попросить старого лидера остановиться» (он может не услышать — асинхронность!), а сделать его запросы недействительными. Правильный вывод из раздела: автоматический failover — это консенсус в миниатюре (кто лидер? — вопрос, требующий согласия), и честное его решение — глава 6; самодельные скрипты failover без ограждения — источник историй, которыми пугают новичков.

5.3. Мульти-лидер

Несколько узлов принимают записи одновременно, обмениваясь ими асинхронно. Законных ниши две: гео-распределение (лидер в каждом регионе — локальная задержка записи, межрегиональная синхронизация в фоне) и офлайн-клиенты (каждое устройство — «лидер» своих данных; вся часть II статьи о надёжности — мульти-лидерная репликация под псевдонимом). Цена постоянна и неустранима: конкурентные записи в разные лидеры — норма, конфликты не исключение, а режим работы. Стратегии их разрешения — лестница из статьи о надёжности, теперь с точными основаниями: LWW (глава 2 объяснила, чьи записи он систематически теряет), явные версии с обнаружением конкурентности (векторные часы), слияние по полям, CRDT (5.5). Мульти-лидер без спроектированной стратегии конфликтов — это отложенная порча данных.

5.4. Кворумная репликация

Схема Dynamo: лидера нет вовсе; клиент (или координатор) пишет в N реплик, ждёт подтверждений от W, читает с R, и при W + R > N множества записи и чтения пересекаются. После успешно завершённой записи чтение встретит хотя бы одну подтвердившую её копию; координатор должен сравнить версии и выбрать или объединить актуальные. Регулятор в руках инженера: W=N, R=1 — быстрые чтения, хрупкие записи; W=1, R=N — наоборот; W=R=⌈(N+1)/2⌉ — симметричный компромисс. Обслуживание расходящихся реплик: read repair (читатель, увидев разнобой версий, чинит отставших) и фоновая анти-энтропия (сверка реплик деревьями Меркла: хеш-дерево позволяет локализовать расхождения в большом наборе данных, не передавая его целиком).

Два разочарования, которые обязан знать инженер:

  • Кворум ≠ линеаризуемость. Пересечение W+R>N защищает завершённую запись в фиксированном наборе реплик, но не закрывает незавершённые записи. Чтение может увидеть версию, успевшую попасть лишь на одну реплику до тайм-аута записи, а следующее чтение — не пересечься с этой репликой и вернуть старую. Чтобы построить атомарный регистр, чтению нужен дополнительный этап записи выбранной версии обратно в кворум (как в алгоритме ABD) либо другая координация. Dynamo-системы обычно линеаризуемость не обещают.
  • Нестрогие кворумы (sloppy quorum + hinted handoff): ради доступности при отказах записи принимают «соседние» узлы вне канонического множества N с последующей передачей владельцу. Доступность растёт, а пересечение W и R больше не гарантировано — арифметика кворума молча перестаёт работать. Проверяйте, включён ли режим по умолчанию в вашей системе.

Кворумы записи и чтения пересекаются в одной из пяти реплик
Рис. 5.1. Условие W+R>N гарантирует пересечение кворумов, но само по себе ещё не строит линеаризуемый регистр.

5.5. CRDT: сходимость по построению

Можно ли реплицировать без арбитра и без ручных конфликтов — так, чтобы реплики сходились гарантированно? Да, если ограничить операции. Для state-based CRDT (conflict-free replicated data types) состояние растёт в полурешётке, а слияние коммутативно, ассоциативно и идемпотентно; после доставки всех обновлений любой порядок и любая кратность обменов дают один результат. У operation-based CRDT условия формулируются через доставку и коммутативность операций. Разберём два state-based примера:

  • G-Counter (растущий счётчик): вектор «сколько инкрементов сделала каждая реплика»; инкремент — своя ячейка +1; слияние — поэлементный максимум; значение — сумма. Проверьте свойства слияния — упражнение 5. Пара таких (P, N) даёт PN-Counter с декрементом.
  • OR-Set (множество с добавлением и удалением): каждый add снабжается уникальной меткой; remove удаляет виденные им метки элемента. Конкурентные add и remove разрешаются в пользу add — осмысленная, явно выбранная семантика вместо гонки.

Готовые библиотеки (Automerge, Yjs) доводят подход до совместного редактирования текстов. Границы честности: CRDT гарантируют сходимость, но не инварианты — счётчик остатка на складе сойдётся к −3 совершенно корректно с точки зрения CRDT; глобальные инварианты («не меньше нуля», «уникально») требуют координации — и мы снова у дверей главы 6.

Итоги главы

  • Лидерская репликация: порядок бесплатно, выбор sync/semi-sync/async — это обещание о потерях при failover, и оно должно быть явным.
  • Failover опасен: ложные срабатывания → split brain; асинхронный хвост → молчаливая потеря подтверждённого; зомби-лидеры лечатся ограждением (эпохи + отказ нижних слоёв), а честный автоматический failover — это консенсус.
  • Мульти-лидер оправдан географией и офлайном; конфликты — режим работы, стратегия их разрешения проектируется заранее.
  • Кворумы: W+R>N — пересечение, но не линеаризуемость; sloppy quorum тихо отменяет арифметику; сходимость обслуживают read repair и анти-энтропия по деревьям Меркла.
  • CRDT дают гарантированную сходимость без координации (полурешётка), но не глобальные инварианты.

Упражнения

  1. N=5. Подберите (W, R) для: (а) конфигурация, читаемая при трёх отказах; (б) запись, переживающая два отказа, при максимально дешёвом чтении с пересечением; (в) режим наибольшей доступности записи — и чем он расплачивается?
  2. Лидер с асинхронной репликой; лаг реплики — 2 с; failover срабатывает через 10 с после отказа. Оцените, сколько подтверждённых записей теряется при потоке 1000 записей/с и что произойдёт с клиентом, успевшим прочитать потерянную запись с лидера до аварии. Какая гарантия главы 4 нарушена с точки зрения этого клиента?
  3. Разыграйте split brain без ограждения: лидер L1 завис на 30 с, система повысила L2, L1 очнулся. Придумайте последовательность из четырёх записей двух клиентов, дающую необратимое расхождение. Затем добавьте эпохи и покажите, на каком шаге запросы L1 будут отвергнуты.
  4. Покажите историю, нарушающую линеаризуемость при N=3, W=2, R=2: запись нового значения успела попасть только на A и завершилась для клиента тайм-аутом; чтение 1 опрашивает {A, B}, а более позднее чтение 2 — {B, C}. Какое значение может вернуть каждое чтение? Почему запись выбранной при чтении версии обратно в кворум (read write-back, как в ABD) запрещает такую историю?
  5. Докажите для G-Counter: слияние коммутативно, ассоциативно, идемпотентно; после доставки реплике всех инкрементов её значение равно их сумме. Почему «наивный CRDT-счётчик» — одно число со слиянием max — неверен?
  6. Продуктовая команда просит «лайки через CRDT, но чтобы пользователь не мог лайкнуть дважды». Какая часть требования — сходимость, какая — глобальный инвариант? Предложите решение без консенсуса (подсказка: инвариант локализуем — уникальность на уровне пары пользователь–объект, OR-Set идентификаторов пользователей).

Ответы и указания. 1: (а) читаемость при трёх отказах означает R ≤ 2, пересечение W+R>5 требует W ≥ 4 — итого (W=4, R=2), записи при этом требуют четыре живых реплики; (б) чтобы завершённая запись пережила любые два последующих отказа и могла завершиться при двух уже отказавших узлах, нужно W=3, а пересечение требует R=3; (в) W=1 — доступнейшая запись, но единственная принявшая реплика может отказать, а пересечение потребовало бы R=5. 2: при постоянном потоке верхняя оценка потерянного хвоста — около 2000 записей; клиент видит откат уже прочитанного значения, то есть нарушение монотонных чтений и, если писал сам, «читай свои записи». 3: L1 пишет a, зависает; L2 получает новую эпоху и пишет b; проснувшийся L1 принимает c, а L2 — d, после чего журналы расходятся. Ресурс, хранящий максимальную эпоху, примет b и d от L2 и отвергнет c со старой эпохой L1. 4: первое чтение может увидеть новое значение на A и вернуть его, а второе — опросить B и C и вернуть старое. Запись выбранной версии первым чтением в W=2 реплики делает её видимой любому последующему R=2; одна только фоновая read repair такой порядок не гарантирует. 5: две реплики независимо делают +1 от нуля, у обеих значение 1, а max(1,1)=1 вместо 2; векторная форма хранит независимые монотонные компоненты. 6: сходимость обеспечивает сливаемое множество, а запрет повторного лайка выражается идентичностью элемента user_id, а не отдельным глобальным счётчиком. Повторное добавление того же пользователя идемпотентно; для операции «убрать лайк» нужен OR-Set с уникальными тегами добавлений и наблюдаемыми удалениями. Маршрутизация пары на один согласованный шард даёт альтернативное локальное UNIQUE-ограничение.

Лабораторная работа 1. Потоковая репликация PostgreSQL

Цель — руками пройти разделы 5.1–5.2: поднять пару лидер–реплика, измерить лаг, переключиться на синхронную репликацию, устроить failover и предъявить потерянные транзакции. Всё — в двух контейнерах Docker на одной машине: docker-compose.yml, инициализация лидера, запуск реплики и команды работы приложены к курсу. Стенд использует штатные pg_basebackup, физический слот и standby.signal.

Задание 1. Асинхронная пара и лаг. Поднимите лидер и реплику; убедитесь в репликации (запись на лидере видна на реплике). Измерьте лаг под нагрузкой: на лидере — pgbench или цикл INSERT, на реплике — запрос SELECT now() - pg_last_xact_replay_timestamp(); на лидере — представление pg_stat_replication (поля write_lag, replay_lag). В отчёт: лаг в покое и под нагрузкой.

Задание 2. Аномалия чтения с реплики. Клиент А пишет строку на лидере и немедленно читает с реплики. Поймайте отсутствие строки (при необходимости помогите себе паузой репликации: SELECT pg_wal_replay_pause() на реплике). Сформулируйте, какие сессионные гарантии главы 4 нарушены. Затем возобновите воспроизведение, задайте на лидере synchronous_standby_names для этой реплики и установите для сессии записи synchronous_commit = remote_apply: один этот параметр без настроенной синхронной реплики ожидания не включает. Повторите опыт и измерьте задержку записи. Перед заданием 3 обязательно снова возобновите replay, выполните ALTER SYSTEM RESET synchronous_standby_names на лидере, перезагрузите конфигурацию и проверьте в pg_stat_replication, что sync_state вернулся в async.

Задание 3. Потеря подтверждённых транзакций. Убедитесь, что стенд снова работает асинхронно. Остановите контейнер replica, чтобы он гарантированно не принимал WAL; выполните на primary серию INSERT с подтверждениями, затем «убейте» primary командой docker compose kill. Запустите только replica, не поднимая зависимость primary (docker compose up -d --no-deps replica), и повысьте её через pg_promote(). Пересчитайте строки: транзакции, подтверждённые после остановки реплики, отсутствуют. После promotion имя сервиса replica обозначает уже текущий логический лидер, а primary остаётся остановленным старым лидером. В отчёт: сколько потеряно и почему это не баг, а определение асинхронности.

Задание 4. Синхронная репликация. Не продолжайте на изменившихся ролях из задания 3: удалите учебные тома командой docker compose down -v, заново поднимите стенд и дождитесь потоковой репликации. Включите synchronous_standby_names на свежем primary и повторите подтверждённую запись с последующим отказом лидера — на повышенной реплике запись сохраняется. Измерьте цену: задержку одиночного INSERT в async и sync режимах (\timing в psql, 100 повторов). На ещё одном чистом запуске включите синхронный режим, остановите реплику и попробуйте писать на лидер: запись ждёт требуемую реплику — вы наблюдаете выбор C над A из главы 3 в живой системе. В отчёт: таблица «режим → задержка → поведение при отказе реплики → потери при отказе лидера».

Задание 5*. Зомби-лидер. Начните с нового чистого стенда и повторите failover из задания 3. Затем верните старый primary в строй без переинициализации и направьте в оба узла тестовых клиентов. Пронаблюдайте расхождение историй (две «базы» живы, данные разные) и сформулируйте, какие два механизма из раздела 5.2 обязаны были это предотвратить. Почему возвращение старого лидера как реплики требует pg_rewind или новой базовой копии? После опыта удалите стенд вместе с учебными томами.

Литература к главе

  1. M. Kleppmann, "Designing Data-Intensive Applications," O'Reilly, 2017 — гл. 5 (репликация) — основной текст к главе.
  2. G. DeCandia et al., "Dynamo: Amazon's Highly Available Key-value Store," SOSP, 2007.
  3. M. Shapiro et al., "Conflict-free Replicated Data Types," SSS, 2011.
  4. Документация PostgreSQL: High Availability, Load Balancing, and Replication. postgresql.org/docs/current/high-availability.html
  5. K. Kingsbury, разборы Jepsen по кворумным системам. jepsen.io/analyses

Предыдущая глава || Содержание курса || Следующая глава

404 Not Found

404 Not Found


nginx/1.24.0 (Ubuntu)

Связь с редакцией