Цели главы. Центральная глава курса. Всё предыдущее сходится сюда: failover без split brain (глава 5) — это консенсус; линеаризуемость (глава 4) реализуется консенсусом; FLP (глава 3) очерчивает его пределы. Разберём Raft полностью — выборы, репликацию журнала, гарантии безопасности с их самой тонкой деталью, смену состава кластера — и практические вопросы, отделяющие учебник от эксплуатации: линеаризуемые чтения, ограждение, снимки. Paxos — обзорно, для чтения литературы. Завершает главу лабораторная: трёхузловой etcd, убийство лидера и попытка устроить split brain (спойлер: не выйдет — и вы увидите, почему).
Требуется не «согласовать одно значение» (формулировка главы 3 была минимальной для теорем), а поддерживать реплицируемый автомат: несколько узлов исполняют одну и ту же последовательность детерминированных команд и потому проходят одни и те же состояния. Вся задача сводится к одному: согласовать содержимое упорядоченного журнала команд — дальше детерминизм делает остальное. Консенсус по журналу должен обеспечить: безопасность — зафиксированные (committed) записи журнала никогда не теряются и не переупорядочиваются, все узлы применяют один и тот же префикс; живость — при работоспособном большинстве и стабильной сети система продвигается. Помним рамку FLP: безопасность будет безусловной, живость — при частичной синхронности.
Магическое число всей главы — большинство (кворум): в кластере из 2f+1 узлов любые два большинства пересекаются хотя бы в одном узле. Пересечение запрещает избрать двух лидеров в одном терме, потому что общий избиратель голосует лишь раз. Для сохранности зафиксированных записей одного пересечения недостаточно: вместе с ним работают проверка актуальности журнала кандидата, Log Matching и правило фиксации записей текущего терма — полный аргумент дан в 6.4. Кластеры делают нечётными: 3 узла терпят 1 отказ, 5 — 2; чётный четвёртый узел не добавляет отказоустойчивости (большинство от 4 — это 3), лишь стоимость.
Каждый узел в одном из трёх состояний: ведомый (follower), кандидат, лидер. Время разбито на монотонно растущие термы; в терме может не быть лидера, но избран не более чем один. Каждый узел помнит наибольший виденный терм, всякое сообщение несёт терм отправителя, и сообщение с устаревшим термом отвергается, а узел, увидевший больший терм, становится ведомым. Терм ограждает сам протокол Raft от сообщений старых эпох, но для внешнего ресурса всё равно нужен отдельный fencing-токен (6.6).
Выборы. Лидер периодически шлёт всем пустые AppendEntries (сердцебиение). Ведомый, не слышавший лидера в течение случайного тайм-аута (типично 150–300 мс в примере статьи, но в эксплуатации диапазон выбирают по задержкам сети и диска), объявляет себя кандидатом: увеличивает терм, голосует за себя и рассылает RequestVote. Узел отдаёт голос при двух условиях: в этом терме ещё не голосовал (одно голосование на терм — хранится на диске!) и журнал кандидата не отстаёт от его собственного (сравнение по терму и индексу последней записи — эта проверка станет ключом к безопасности в 6.4). Кандидат, собравший большинство, — лидер терма; получивший AppendEntries с термом не меньше своего — признаёт чужое лидерство; при расколе голосов никто не побеждает, тайм-аут истекает, начинается новый терм. Случайность тайм-аутов снижает вероятность повторного раскола голосов и ускоряет выборы. Она не превращает Raft в асинхронный рандомизированный консенсус: живость Raft по-прежнему требует периода, когда обмен сообщениями успевает завершаться заметно быстрее election timeout. В худшем случае завершение не гарантировано, безопасность сохраняется.
Репликация журнала идёт через RPC AppendEntries(терм, идентификатор лидера, предыдущий индекс, предыдущий терм, записи, commitIndex); выборы используют отдельный RequestVote, а передача снимка — InstallSnapshot. Лидер принимает команду клиента, дописывает в свой журнал (запись = команда + терм) и рассылает. Ведомый принимает записи, только если у него в журнале по «предыдущему индексу» стоит запись с «предыдущим термом» — проверка согласованности префикса. Индукция по этой проверке даёт Log Matching: если у двух узлов записи с одинаковыми индексом и термом, то совпадают и они, и весь префикс до них.
Расхождения (ведомый отстал или содержит хвост от свергнутого лидера) лечатся откатом: лидер, получив отказ проверки, уменьшает индекс для этого ведомого и пробует раньше, найдя точку совпадения — перезаписывает ведомому весь хвост своим. Незафиксированные хвосты стираемы — это законно; вся тяжесть гарантий лежит на понятии фиксации: запись зафиксирована, когда лидер узнал о её сохранении на большинстве узлов (с оговоркой 6.4). Лидер продвигает commitIndex, сообщает его в следующих AppendEntries, узлы применяют зафиксированный префикс к автомату. Клиент получает ответ после фиксации — и с этого момента запись неуничтожима.
Рис. 6.1. Нормальный путь записи Raft; правила будущих выборов, необходимые для сохранности большинства, разобраны в следующем разделе.
Докажем Leader Completeness аккуратно: одного пересечения большинств недостаточно. Пусть лидер терма T зафиксировал запись x своего терма, то есть x сохранило большинство M₁. Предположим противное и выберем первый последующий терм U, лидер которого x не содержит. Избирающее большинство M₂ пересекается с M₁ в узле v. По минимальности U все промежуточные лидеры содержали x, поэтому ни один из них не мог заставить v удалить x; в момент голосования v всё ещё хранит её. Кандидат U обязан быть не менее актуален, чем v. Если его последний терм не превосходит T, правило сравнения терма и индекса не позволит обойти x. Если последний терм кандидата больше T, соответствующую запись создал промежуточный лидер, который по минимальности U уже содержал x; свойство Log Matching означает, что кандидат вместе с этой более поздней записью также получил весь префикс с x. В обоих случаях кандидат без x не может получить голос v — противоречие. Значит, каждый будущий лидер содержит x. Именно здесь совместно работают пересечение большинств, правило актуальности журнала, Log Matching и ограничение фиксации записей текущего терма.
Тонкость, на которой ломались самодельные реализации (рис. 8 статьи о Raft): лидер не имеет права объявить зафиксированной запись чужого терма, лишь пересчитав копии — существует сценарий, где запись старого терма лежит на большинстве, но будет законно стёрта лидером промежуточного терма, успевшим избраться без неё. Правило Raft: лидер фиксирует по числу копий только записи своего терма, а записи прежних термов фиксируются вместе с ними по Log Matching. Поэтому реализации часто добавляют после выборов пустую запись текущего терма (no-op): зафиксировав её, лидер заодно фиксирует унаследованный префикс. Разбор сценария — упражнение 4, самое поучительное в главе.
Добавить или убрать узел — значит изменить само определение «большинства», и наивная одномоментная замена конфигурации Cold → Cnew опасна: в переходный миг часть узлов считает большинством одно, часть — другое, и два непересекающихся «большинства» могут избрать двух лидеров. Решения: совместный консенсус (joint consensus) — промежуточная конфигурация Cold,new, где решения требуют большинств обеих конфигураций; либо протокол одиночных изменений, где состав меняется строго на один узел, изменение фиксируется до начала следующего, а добавляемый узел предварительно догоняет журнал. Одного бытового правила «добавлять по одному» без этих протокольных условий недостаточно. Изменение конфигурации само едет записью журнала — красиво замкнутая конструкция.
Paxos (Лэмпорт, 1989/1998) решает согласование одного значения двумя фазами: prepare — предложитель захватывает номер раунда и узнаёт у большинства принимающих, не принято ли уже что-то (тогда обязан продвигать именно это значение — аналог Leader Completeness); accept — просит большинство принять. Многократный Paxos с постоянным предложителем (Multi-Paxos) сводится к схеме, структурно эквивалентной Raft: стабильный лидер + реплицируемый журнал + номера эпох. Разница — в изложении и степени свободы деталей: Paxos — ядро с недосказанной инженерией вокруг (что признавали и в Google, реализуя Chubby), Raft — полный протокол, спроектированный ради понятности, с эталонными реализациями (etcd/raft, HashiCorp raft) — потому индустриальный выбор по умолчанию сегодня он. Читателю статей знать Paxos необходимо; строителю систем — начинать с Raft.
Ответы и указания. 1: узел с гигантским термом при воссоединении переведёт действующего лидера в ведомые и вызовет выборы; безопасность цела, но работа прервётся. Pre-vote сначала проверяет возможность собрать голоса без увеличения терма, поэтому изолированный узел его не наращивает. 2: два избирающих большинства обязаны пересечься, а общий узел не может голосовать дважды в одном терме. Если голос не записать на диск, после перезагрузки этот узел забудет первый голос и сможет обеспечить оба большинства. 3: среди четырёх живых узлов x₂ хранит один. Он может быть избран и сохранить x₂, но три узла без x₂ также могут избрать своего кандидата и законно стереть незафиксированный хвост. x₂ становится неуничтожимой после фиксации по правилу 6.4, а не просто после появления некоторого числа копий. 4: кандидат без y в терме 3 получает голоса трёх узлов без y и пишет z терма 3 только себе. В терме 4 узел с y получает другие три голоса и раскладывает y терма 2 на большинство, но не вправе фиксировать её одной арифметикой копий. Затем носитель z, чей последний терм 3 новее терма 2, собирает большинство и перезаписывает y. Если лидер терма 4 сначала зафиксирует no-op своего терма, его большинство получит более свежий журнал и кандидат с z уже не пройдёт проверку актуальности. 5: при добавлении или удалении одного узла размеры старого и нового большинства в общей совокупности дают сумму больше числа различных узлов, поэтому пересечение обязательно; при {A,B,C}→{A,D,E} допустимы непересекающиеся большинства {B,C} и {D,E}. Это арифметическое условие применяется внутри протокола конфигурационных записей, а не заменяет его. 6: файловый сервис хранит максимальную принятую ревизию блокировки и принимает запись только с токеном не меньше неё. После выдачи B более новой ревизии запрос проснувшегося A со старым токеном отвергается. TTL определяет срок аренды в координаторе, но не может остановить зависший процесс или заставить внешний ресурс забыть его запрос.
Цель — увидеть каждую конструкцию главы в живой системе: термы, выборы, поведение большинства и меньшинства, невозможность split brain. Стенд — три контейнера etcd: docker-compose.yml и команды запуска приложены к курсу; клиент etcdctl уже находится в образе.
Задание 1. Кто лидер. Поднимите кластер; командой etcdctl endpoint status -w table --endpoints=... найдите лидера и текущий терм (поле raft term). Запишите пару ключей, прочитайте с каждого узла.
Задание 2. Смерть лидера. docker kill контейнеру-лидеру. Засеките по endpoint status: новый лидер, новый терм, время недоступности записи (цикл etcdctl put с меткой времени — сколько запросов отвалилось?). Верните узел — убедитесь, что он стал ведомым и догнал журнал. В отчёт: терм до/после, время переизбрания.
Задание 3. Меньшинство. Изолируйте один узел (docker network disconnect) и дождитесь, пока линеаризуемое чтение через его локальный endpoint начнёт стабильно завершаться ошибкой; только после этого запишите новый ключ в оставшееся большинство. Проверьте на изолированном узле: запись — отказ; линеаризуемое чтение — отказ; чтение с флагом --consistency=s (серийное) — успех, но нового ключа в нём нет: данные замерли на границе разделения. Такая последовательность исключает гонку, при которой запись успевает реплицироваться одновременно с разрывом сети. Сформулируйте увиденное в терминах CAP (глава 3) и режимов чтения (6.6). В отчёт: таблица «операция → большинство/меньшинство → результат».
Задание 4. Попытка split brain. Разрежьте сеть 1|2: лидер в меньшинстве. Пронаблюдайте: старый лидер перестаёт фиксировать (нет большинства), двойка избирает нового с большим термом, пишет; меньшинство не принимает ни одной записи. Восстановите сеть: старый лидер сложил полномочия по большему терму, незафиксированный хвост (если был) стёрт откатом AppendEntries. Сверьте журналы чтением ключей со всех узлов. В отчёт: почему расхождение зафиксированных данных не возникло ни в один момент — с точной ссылкой на два факта из 6.1–6.2.
Задание 5. Наблюдение выборов под нагрузкой. Устройте лидеру частичную сеть (tc netem delay 400ms на его интерфейс — статья о надёжности в действии): сердцебиения опаздывают, кластер периодически переизбирается. Понаблюдайте нестабильность и объясните связь тайм-аута выборов с реальными задержками сети; верните норму. В отчёт: рекомендация по выбору election timeout относительно RTT.
Задание 6*. Fencing на ревизиях. Реализуйте скетч из упражнения 6: два клиента-скрипта конкурируют за ключ-блокировку (etcdctl lease grant + put с lease), «внешний ресурс» — файл, дописываемый только при предъявлении ревизии не меньше запомненной. Продемонстрируйте, как зависший клиент с устаревшей ревизией получает отказ.