Цели главы. Разобрать три архитектуры репликации — лидерскую, мульти-лидерную и кворумную — с их точными гарантиями и точными провалами: где теряются подтверждённые записи, откуда берётся split brain и почему кворум сам по себе ещё не линеаризуемость. Отдельный раздел — CRDT, репликация «без арбитра» с математической гарантией сходимости. Завершает главу первая лабораторная работа курса: потоковая репликация PostgreSQL, аварийное переключение своими руками — и собственноручно потерянные транзакции.
Самая распространённая схема: один узел — лидер — принимает все записи, пишет их в упорядоченный журнал и рассылает репликам, применяющим журнал в том же порядке (узнаёте реплицируемый автомат? он вернётся в главе 6 в строгом виде). Чтения — с лидера (сильные) или с реплик (дёшево, но с отставанием — сессионные гарантии главы 4 обязательны). Так устроены PostgreSQL, MySQL, MongoDB, Redis.
Ключевой выбор — момент подтверждения клиенту:
Этот выбор — не настройка, а обещание клиентам, и он обязан быть явным в проектной документации. В лабораторной вы измерите обе стороны монеты на настоящем PostgreSQL.
Лидер отказал; нужно назначить нового (failover). Последовательность обманчиво проста: обнаружить отказ (тайм-аут — то есть подозрение, глава 1), выбрать реплику посвежее, повысить её, перенаправить клиентов и старого лидера. Каждый шаг минирован:
Системное лекарство от зомби и split brain — ограждение (fencing): каждому лидерству присваивается монотонно растущий номер эпохи (терм — увидите в Raft), и все нижележащие ресурсы (хранилище, реплики, внешние сервисы) отвергают запросы с устаревшей эпохой. Не «вежливо попросить старого лидера остановиться» (он может не услышать — асинхронность!), а сделать его запросы недействительными. Правильный вывод из раздела: автоматический failover — это консенсус в миниатюре (кто лидер? — вопрос, требующий согласия), и честное его решение — глава 6; самодельные скрипты failover без ограждения — источник историй, которыми пугают новичков.
Несколько узлов принимают записи одновременно, обмениваясь ими асинхронно. Законных ниши две: гео-распределение (лидер в каждом регионе — локальная задержка записи, межрегиональная синхронизация в фоне) и офлайн-клиенты (каждое устройство — «лидер» своих данных; вся часть II статьи о надёжности — мульти-лидерная репликация под псевдонимом). Цена постоянна и неустранима: конкурентные записи в разные лидеры — норма, конфликты не исключение, а режим работы. Стратегии их разрешения — лестница из статьи о надёжности, теперь с точными основаниями: LWW (глава 2 объяснила, чьи записи он систематически теряет), явные версии с обнаружением конкурентности (векторные часы), слияние по полям, CRDT (5.5). Мульти-лидер без спроектированной стратегии конфликтов — это отложенная порча данных.
Схема Dynamo: лидера нет вовсе; клиент (или координатор) пишет в N реплик, ждёт подтверждений от W, читает с R, и при W + R > N множества записи и чтения пересекаются. После успешно завершённой записи чтение встретит хотя бы одну подтвердившую её копию; координатор должен сравнить версии и выбрать или объединить актуальные. Регулятор в руках инженера: W=N, R=1 — быстрые чтения, хрупкие записи; W=1, R=N — наоборот; W=R=⌈(N+1)/2⌉ — симметричный компромисс. Обслуживание расходящихся реплик: read repair (читатель, увидев разнобой версий, чинит отставших) и фоновая анти-энтропия (сверка реплик деревьями Меркла: хеш-дерево позволяет локализовать расхождения в большом наборе данных, не передавая его целиком).
Два разочарования, которые обязан знать инженер:
Рис. 5.1. Условие W+R>N гарантирует пересечение кворумов, но само по себе ещё не строит линеаризуемый регистр.
Можно ли реплицировать без арбитра и без ручных конфликтов — так, чтобы реплики сходились гарантированно? Да, если ограничить операции. Для state-based CRDT (conflict-free replicated data types) состояние растёт в полурешётке, а слияние коммутативно, ассоциативно и идемпотентно; после доставки всех обновлений любой порядок и любая кратность обменов дают один результат. У operation-based CRDT условия формулируются через доставку и коммутативность операций. Разберём два state-based примера:
Готовые библиотеки (Automerge, Yjs) доводят подход до совместного редактирования текстов. Границы честности: CRDT гарантируют сходимость, но не инварианты — счётчик остатка на складе сойдётся к −3 совершенно корректно с точки зрения CRDT; глобальные инварианты («не меньше нуля», «уникально») требуют координации — и мы снова у дверей главы 6.
Ответы и указания. 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-ограничение.
Цель — руками пройти разделы 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 или новой базовой копии? После опыта удалите стенд вместе с учебными томами.