Согласованность, репликация и консенсус: Raft на практике
Как эта тема связана с предыдущими статьями
В первых двух статьях курса мы закрепили две опоры:
В распределённой системе сеть ненадёжна, таймауты и повторы неизбежны.
API — это контракт, который должен учитывать частичные отказы, идемпотентность и обратную совместимость.Теперь добавляем третий слой: данные и состояние. Как только одно и то же состояние существует в нескольких местах (реплики), появляется вопрос: какое состояние считать истинным и кто имеет право его менять.
Эта статья отвечает на вопросы:
что такое согласованность при репликации
зачем нужен консенсус
как Raft обеспечивает согласованную репликацию
как подойти к Raft практически в Go: компоненты, таймауты, хранение, снапшоты, интеграцияБазовые определения: репликация, согласованность, консенсус
Репликация
Репликация — хранение одного и того же логического состояния на нескольких узлах.
Зачем она нужна:
отказоустойчивость (один узел упал — система продолжает работать)
масштабирование чтений (можно читать с разных реплик)
геораспределение (реплики ближе к пользователю)Согласованность
Согласованность в прикладном смысле отвечает на вопрос: что увидит клиент, если читает данные с разных узлов в разные моменты времени.
В реальных системах встречаются разные ожидания:
сильная согласованность: после успешной записи последующие чтения видят эту запись
слабее (eventual consistency): после записи какое-то время разные реплики могут отвечать по-разному, но со временем сходятсяВажно не путать:
согласованность данных
доступность (будет ли ответ вообще)
корректность протокола при сетевых разделенияхЭто напрямую связано с интуитивным CAP из первой статьи: при проблемах сети часто приходится выбирать между тем, чтобы отвечать всегда и тем, чтобы отвечать только когда уверены в согласованном состоянии.
Консенсус
Консенсус — класс задач, где несколько узлов должны договориться об одном результате, несмотря на сбои.
В контексте репликации самый практичный вариант консенсуса — договориться об одном порядке операций (например, порядок команд изменения состояния). Если все применят команды в одинаковом порядке к одинаковому начальному состоянию, они придут к одному результату.
Эта идея называется replicated state machine: реплицированная машина состояний.
Почему просто “сделаем несколько реплик” не работает
Представьте, что у вас есть 3 реплики ключ-значение, и клиент делает Set(balance=100).
Если репликация устроена наивно, вы быстро встретите проблемы:
две реплики приняли разные записи одновременно
лидер недоступен, и две стороны сетевого разделения продолжают принимать записи
клиент получил таймаут, повторил запрос, и операция выполнилась дваждыИз прошлых статей мы уже знаем причину: таймаут не доказывает, что операция не произошла. Если система не контролирует единственный порядок изменений, реплики расходятся.
Рациональный подход: выбрать протокол, который гарантирует единый порядок подтверждённых изменений. Один из самых популярных протоколов для этого — Raft.
Источник: In Search of an Understandable Consensus Algorithm (Raft)
Quorum и большинство: базовая идея “безопасного подтверждения”
В большинстве протоколов консенсуса используется кворум — подмножество узлов, достаточное для принятия решения.
В Raft используется большинство.
Если в кластере узлов, то размер большинства:
Где:
— общее число узлов
— округление вниз
— минимальное количество узлов, которое должно согласитьсяПрактический смысл: любые два большинства пересекаются хотя бы одним узлом. Это пересечение помогает не “подтвердить” два разных решения одновременно.
Raft: что именно он гарантирует
Raft решает задачу: упорядоченно реплицировать лог команд на несколько узлов так, чтобы:
в каждый момент времени был выбран лидер (в нормальном режиме)
лидер принимал команды от клиентов и реплицировал их на последователей
команда считается подтверждённой (committed), когда её приняло большинство
все узлы применяют подтверждённые команды в одном и том же порядкеRaft обычно используют как основу для:
метаданных кластера
распределённых блокировок (если они действительно нужны)
конфигурации, лидершипа
согласованного KV-хранилища (например, etcd)!Общая картина: клиент пишет в лидера, лидер реплицирует записи на остальные узлы
Модель Raft: роли, термы, лог
Роли
Узел Raft всегда в одной из ролей:
Follower: пассивно принимает команды от лидера
Candidate: пытается стать лидером (во время выборов)
Leader: принимает команды, реплицирует лог, управляет подтверждениемТермы
Term — монотонно растущий номер “эпохи” выборов лидера. Он нужен, чтобы узлы могли понять, что информация устарела.
Правило на уровне интуиции:
узел доверяет сообщениям с большим term больше, чем со старым
если узел видит term больше своего, он “обновляет знание” и становится followerЛог
Raft реплицирует не состояние целиком, а лог команд.
Каждая запись лога имеет:
индекс (позиция в логе)
term, в котором запись была добавлена
команду для state machine (бизнес-операцию)После подтверждения записи применяются к state machine (вашей логике), и так получается итоговое состояние.
Выбор лидера: как Raft избегает “двух лидеров”
Выборы запускаются, когда follower долго не получает heartbeat от лидера.
Механика высокого уровня:
follower не видит лидера в течение election timeout
он становится candidate, увеличивает term и голосует за себя
рассылает RequestVote другим узлам
если получает большинство голосов — становится leader
лидер начинает слать AppendEntries как heartbeat, чтобы остальные не стартовали выборыДеталь, которая важна на практике: таймауты должны быть рандомизированы. Иначе узлы будут часто стартовать выборы одновременно.
!Почему рандомизация election timeout снижает вероятность split vote
Репликация лога: AppendEntries, подтверждение и применение
Лидер принимает команду от клиента и превращает её в запись лога.
Дальше:
лидер отправляет AppendEntries followers
follower проверяет, что лог “стыкуется” с лидером (по предыдущему индексу и term)
если стыковка не проходит, follower отклоняет, лидер “откатывается” назад и пробует догнать follower правильной историей
когда запись находится на большинстве узлов, лидер считает её подтверждённой
лидер сообщает commitIndex followers (в следующих AppendEntries)
каждый узел применяет подтверждённые записи к state machine строго по порядку индексовКлючевой практический вывод: узел не должен применять неподтверждённые записи (иначе можно показать клиенту состояние, которое позже откатится).
Безопасность Raft: почему подтверждённые записи “не пропадают”
В распределённых системах опасная ситуация выглядит так:
лидер подтвердил запись клиенту
лидер умер
новый лидер “забыл” эту записьRaft предотвращает это набором ограничений. Самое важное, на уровне инженерной интуиции:
лидер подтверждает запись только после большинства
новый лидер может быть выбран только если его лог достаточно свежийВ Raft это выражено через правило “log up-to-date” при голосовании: follower отдаёт голос кандидату только если у кандидата лог не старее.
Источник: Raft paper
Чтения в Raft: почему “читать с любой реплики” опасно
Если вы читаете с follower, вы можете получить устаревшие данные, потому что follower может отставать.
Чтобы получить сильные чтения, типичные стратегии такие:
читать только с лидера
использовать механизм, который подтверждает актуальность лидера относительно большинства (например, ReadIndex в некоторых реализациях)Практический вывод для дизайна API:
если вам нужны строгие гарантии “прочитал то, что только что записал”, документируйте это и обеспечивайте через лидера
если допускается устаревание, можно разгружать лидера чтениями с followers, но это уже другой контрактСнапшоты и компакция: почему лог не может расти бесконечно
Лог со временем становится большим. Держать бесконечный лог дорого:
дольше старт и восстановление
больше диска
дольше догонять новую репликуПоэтому практический Raft делает:
snapshot: периодически сохраняет снимок состояния state machine на некотором индексе
log compaction: удаляет старые записи лога до snapshot-индексаПри догоне сильно отставшей реплики лидер может отправить snapshot вместо бесконечной цепочки AppendEntries.
Raft в Go на практике: как это обычно выглядит в коде
Вы редко пишете Raft “с нуля” в продакшене. В Go наиболее распространённый подход — использовать готовую реализацию Raft как библиотеку и поверх неё строить:
транспорт (как узлы общаются по сети)
устойчивое хранилище лога и состояния
state machine (ваша бизнес-логика)
API для клиентов (HTTP/gRPC)Две популярные реализации:
etcd Raft library
HashiCorp RaftНиже — практическая “карта компонентов”, которую полезно держать в голове.
Компоненты узла Raft
Raft core: машина состояний протокола (election, replication, commit)
Storage:
- persistent: term, vote, entries (на диске)
- volatile: то, что можно восстановить
Transport: доставка сообщений RequestVote и AppendEntries
Apply loop: применение подтверждённых записей к вашей state machine
Snapshotter: создание и установка снапшотовМинимальный каркас: цикл обработки Ready в etcd/raft
Ниже пример структуры кода (упрощённый), показывающий ключевую идею: библиотека Raft выдаёт порции работы, а вы обязаны правильно их сохранить/отправить/применить.
Что важно в этом каркасе:
порядок действий имеет значение: сначала сохранение на диск, потом сеть, потом apply
CommittedEntries — это то, что можно применять (и обычно именно это можно отвечать клиенту как “успешно”)
этот цикл должен корректно завершаться по ctx.Done() (связь с дисциплиной context из первой статьи)Документация библиотеки: Package raft (etcd)
Как связать Raft и ваш API: где живёт идемпотентность
Raft гарантирует порядок подтверждённых команд, но не решает за вас проблему “клиент повторил запрос после таймаута”. Это слой API/бизнес-логики из предыдущих статей.
Типичный практический подход:
Клиент передаёт idempotency_key (или request_id) для команд изменения состояния.
Команда, которая кладётся в Raft-лог, включает этот ключ.
State machine хранит таблицу “какие ключи уже применялись” и возвращает прежний результат при повторе.Так вы соединяете:
повторы и таймауты (уровень коммуникации)
строгий порядок команд (уровень консенсуса)
отсутствие двойного эффекта (уровень state machine)Таймауты и параметры Raft: практические ориентиры
Raft чувствителен к настройкам времени.
Heartbeat и election timeout
Обычно:
heartbeat interval делают небольшим
election timeout делают заметно больше и рандомизируютВажно учитывать реальность продакшена:
паузы GC
stop-the-world эффекты
кратковременные просадки сети
нагрузка на диск (fsync)Плохая настройка даёт:
постоянные перевыборы лидера
резкие скачки latency
“дребезг” кластера при нагрузкеОтказы и как Raft ведёт себя в реальности
Падение лидера
followers перестают получать heartbeat
один из них выигрывает выборы
система продолжает принимать команды (после failover задержки)Сетевое разделение
часть с большинством может выбрать лидера и продолжать подтверждать команды
часть без большинства не сможет подтверждать новые команды (иначе нарушилась бы безопасность)Практический контракт для клиентов: при разделениях возможны ошибки/таймауты, но подтверждённые записи не должны “откатиться”.
Медленный follower
лидер продолжает работу, не ожидая полного догоняющего
follower догоняется через AppendEntries или snapshotГде Raft используют, а где лучше не надо
Raft полезен, когда вам нужно:
сильная согласованность для небольшой по объёму, но критичной части состояния
понятный и практичный механизм лидерства и подтвержденияRaft может быть плохим выбором, когда:
нужно хранить огромные объёмы данных и масштабировать запись горизонтально (обычно выбирают шардинг и другие подходы)
допустима eventual consistency и важнее доступность и масштабированиеЧастый дизайн: Raft используют для метаданных и управления, а сами большие данные хранят в системах, где другая модель согласованности.
Итоги
Репликация без протокола консенсуса быстро приводит к расхождению реплик.
Консенсус для репликации чаще всего означает “согласовать порядок команд”.
Raft делает это через лидера, термы, репликацию лога и подтверждение большинством.
В Go Raft обычно берут готовой библиотекой (etcd/raft или HashiCorp Raft) и аккуратно строят вокруг неё транспорт, storage, apply loop, снапшоты.
Raft не отменяет требования из предыдущих статей: таймауты, ретраи, идемпотентность и контракт ошибок всё ещё обязательны.