Распределённые системы
Выбор лидера: один писатель, term''''ы и split-brain, который останавливают fencing-токены
Батч-воркер держит кластерный лок и начинает писать файлы в общее хранилище. И тут его JVM ловит 14-секундную stop-the-world GC-паузу. Изнутри процесса ничего не случилось — одна строка кода, затем следующая. Но lease, который он держал, истёк на 10-й секунде; координатор объявил его мёртвым и выбрал нового лидера, и тот начал свои записи. На 14-й секунде старый воркер просыпается и дописывает свою запись — в тот же файл, устаревшими данными. Два лидера, один испорченный файл и постмортем, который начинается со слов «но у нас же был лок».
Зачем вообще выбирать лидера
Большинство задач координации резко упрощаются в тот момент, когда ровно одному узлу разрешено принимать решение. С единственным лидером записи сериализуются через одно место: нет конфликтов параллельных обновлений, которые надо мёржить, нет неоднозначности last-write-wins, нет двух узлов, присваивающих один и тот же sequence number. Лидер становится точкой линеаризации: каждое изменение проходит через него в порядке, с которым все согласны. Поэтому столько инфраструктуры построено вокруг лидера — partition-лидеры в Kafka, primary-база в replica set, контроллер кластера, cron-задача, которая обязана выполниться ровно на одном хосте.
Цена в том, что ты сознательно создал единую точку отказа, поэтому сложность не в том, чтобы выбрать лидера, — а в том, чтобы безопасно перевыбрать его, когда текущий умрёт, ни на миг не допустив двух активных сразу. Именно во втором требовании живёт почти вся реальная сложность (и почти каждый продакшен-инцидент).
Term’ы и рандомизированные таймауты: как выбирает Raft
Raft, алгоритм консенсуса за etcd и многими другими, делит время на term’ы — последовательные целые числа, работающие как логические часы. У каждого term’а не больше одного лидера. Фолловер ждёт регулярных heartbeat’ов от лидера; если в пределах его election timeout ничего не пришло, он считает лидера ушедшим, инкрементирует term, становится кандидатом и просит всех голосовать за него. Кандидат побеждает, собрав голоса большинства (кворума), и каждый узел голосует не больше одного раза за term, в порядке first-come-first-served.
Хитрость — в значении таймаута. Если бы все фолловеры использовали один фиксированный таймаут, они стали бы кандидатами в один миг, расщепили голоса, и никто бы не победил — потом повторили бы и снова расщепили. Raft чинит это, выбирая election timeout каждого узла случайно из диапазона, обычно 150–300 мс. Таймер одного узла почти всегда срабатывает первым; он агитирует и побеждает раньше, чем проснётся любой сосед. Рандомизация превращает синхронную давку в разнесённую очередь.
| Система | Механизм выбора | Ключевое число | Сигнал жизни |
|---|---|---|---|
| Raft (etcd, Consul) | Term + голос большинства | Election timeout 150–300 мс (рандомизирован) | Heartbeat’ы лидера |
| ZooKeeper | Наименьший ephemeral sequential znode | Session timeout (часто секунды) | Heartbeat’ы сессии; znode авто-удаляется при истечении |
| etcd | Lease + compare-and-swap по ключу | Lease TTL (задаётся на каждый acquire) | Keep-alive продления lease |
Lease, сессии и часы, которым нельзя верить
После выбора лидер должен постоянно доказывать, что жив, потому что кластеру нужно достаточно быстро обнаружить мёртвого лидера, чтобы выбрать замену. Обычный механизм — lease (etcd) или сессия (ZooKeeper): лидерство выдаётся на ограниченное время и должно продлеваться. В ZooKeeper лидер держит ephemeral znode, привязанный к сессии; если heartbeat’ы прекращаются на время session timeout, ZooKeeper удаляет znode, и следующий кандидат берёт верх. В etcd лидер привязывает к ключу lease с TTL и держит его живым; если продления прекращаются, lease истекает, ключ исчезает и роль освобождается.
Вот ловушка, которую сеньор не забывает никогда: lease — это утверждение о времени, а локальные часы лидера и часы координатора — это не одни и те же часы. Lease «действует 10 секунд» безопасен, только если обе стороны согласны, сколько длятся 10 секунд, — а они согласны не вполне. Хуже того, лидер может вообще не работать часть этого окна. GC-пауза, гипервизор, замораживающий VM ради live-миграции, выгруженный из памяти процесс или скачок NTP — всё это может заставить лидера верить, что его lease ещё действует, спустя долгое время после того, как координатор уже истёк его и выбрал кого-то другого.
Почему это работает
Lease ограничивает, сколько координатор ждёт перед тем, как объявить узел мёртвым, — но он не ограничивает, сколько приостановленный лидер будет верить, что всё ещё держит lease. Это двое разных часов. Лидер может застыть посреди инструкции, пока настенное время и его lease истекают под ним. Этот зазор — ровно то место, где рождается split-brain.
Split-brain и фикс через fencing-токен
Split-brain — это когда два (или больше) узла одновременно уверены, что они лидер. К этому может привести partition сети — сторона-меньшинство, не видящая кворум, всё ещё может думать, что лидирует, — но коварнее причина из Hook: старого лидера не отрезали, его просто остановили, и он просыпается, держа lease, который мир уже отдал. Теперь пишут два лидера, и общее состояние портится.
Фикс Клеппманна — fencing-токен: каждый раз при выдаче лока/lease координатор раздаёт строго возрастающее число — 33, потом 34, потом 35. Лидер обязан прикладывать свой токен к каждой записи в защищаемый ресурс, а сам ресурс обязан отвергать любую запись, чей токен ниже наибольшего, который он уже видел. Поэтому когда клиент 1 (токен 33) просыпается из GC-паузы и пытается писать, хранилище уже приняло запись клиента 2 с токеном 34 — оно сразу отвергает токен 33. Устаревший писатель отсекается на границе ресурса. Ключевой, часто упускаемый момент: токен работает, только если нижестоящий ресурс активно его проверяет. Лок-сервис, который просто раздаёт токены, но пишет в тупое хранилище, их игнорирующее, не даёт тебе ничего.
Лидер держит 10-секундный lease, пишет в объектное хранилище, и ты видел многосекундные GC-паузы. Как предотвратить устаревшую запись после паузы?
Почему Raft выбирает election timeout каждого узла случайно из 150–300 мс, а не использует одно фиксированное значение?
Fencing-токен предотвращает баг устаревшей записи только при каком условии?
Расставь последовательность классической устаревшей записи split-brain под GC-паузой:
- 1 Лидер A берёт lease (токен 33) и начинает запись в общее хранилище
- 2 Лидер A входит в долгую stop-the-world GC-паузу и перестаёт выполнять код
- 3 Lease A истекает; координатор объявляет A мёртвым и выбирает лидера B (токен 34)
- 4 B пишет успешно; слой хранилища фиксирует наибольший токен = 34
- 5 A просыпается, дописывает с токеном 33; слой хранилища отвергает запись как отсечённую
- 01Объясни коллеге, почему один lease не мешает двум узлам писать как лидер, и что чинит это на самом деле.
- 02Почему Raft использует рандомизированные election timeout, и какой провал это предотвращает?
Единственный лидер существует, чтобы сериализовать записи, и кластер избегает конфликтов параллельных обновлений и неоднозначного порядка — но платит за эту простоту единой точкой отказа, которую надо безопасно перевыбирать. Raft делает это через term’ы (логические часы, один лидер на term) и голоса большинства, используя election timeout, выбранные случайно примерно из 150–300 мс, чтобы кандидаты разносились, а не расщепляли голос вечно. Лидерство затем держится как lease или сессия, которую надо продлевать; ZooKeeper привязывает его к ephemeral znode, etcd — к lease TTL. Глубокая опасность в том, что lease — это утверждение о времени, а часы и паузы лгут: GC-пауза, приостановленная VM или partition могут оставить старого лидера пишущим после истечения lease и выбора нового — split-brain, два писателя, испорченное состояние. Лекарство не в более коротком таймауте и не в проверке часов, ведь застывший процесс не может действовать; это монотонный fencing-токен, который навязывает сам ресурс, отвергая любую запись, чей токен пошёл назад. Выбирай ради живости, отсекай ради безопасности.