Что вам продали и что вы купили
«Отказоустойчивый кластер», «репликация», «автоматическое переключение» — это описание того, как система собрана. Вопрос, который стоит денег, звучит иначе: если прямо сейчас упадёт узел, только что подтвердивший клиенту запись, останется ли эта запись в системе после того, как поднимется новый лидер.
Между «у нас три реплики» и «подтверждённое не пропадает» лежит алгоритм консенсуса — Raft, Paxos, ZAB. Он отвечает на два вопроса: кто сейчас главный и что считать зафиксированным. Ошибка в нём не выглядит как ошибка. Система работает, отвечает, метрики зелёные — просто в одном исполнении из миллиона клиенту сказали «сохранено», а записи нет.
Четыре способа потерять подтверждённую запись
Все четыре — не экзотика. У каждой в статье про Raft есть свой подраздел, а подразделы пишут не просто так: значит, очевидную версию кто-то уже отгружал в продакшн.
Третья строка — самая коварная: она требует четырёх смен лидерства в определённом порядке и появляется в отладке примерно никогда. В статье Raft это Figure 8, и ради неё в алгоритм добавлено отдельное правило.
Почему обычные тесты этого не ловят
- Интеграционный тест гоняет счастливый путь: все живы, сеть работает, сообщения приходят по одному разу и в порядке отправки. Алгоритм консенсуса именно на этом пути и не нужен.
- Хаос-инженерия убивает контейнеры в случайные моменты — но не записывает, что именно она сделала. Упало один раз, и повторить это уже нельзя.
- Отказ, который нельзя повторить, не чинят: его списывают на инфраструктуру. Это тот же механизм, что и с плавающим тестом, только цена ошибки другая.
- Времени мало. Чтобы поймать редкое расписание в реальном кластере, нужно ждать реальные секунды — а нужных расписаний тысячи.
Симуляция вместо стойки с серверами
Первый шаг — сделать узел машиной состояний без ввода-вывода. Узел не трогает ни часы, ни сокет, ни диск: tick двигает его таймеры, receive отдаёт сообщение, propose — команду, а всё, что он хочет сделать, забирают наружу takeMessages и takeApplied. Так устроен raft в etcd, и по той же причине: реализацию, которая сама вызывает setTimeout и пишет в сокет, можно тестировать только запуском и надеждой.
Второй шаг — кластер целиком существует внутри симуляции. Каждый тик, каждая задержка, потерянный пакет, дубликат, перестановка и падение узла — решение, вытянутое из сида. Проверки Raft рассчитаны ровно на такой мир: сообщения теряются, задерживаются, дублируются и приходят не по порядку, а узлы падают и возвращаются. Инъекция чего-то меньшего проверяет сеть, для которой алгоритм не предназначался.
import { check } from 'unflake';
import { runScenario, SafetyMonitor, checkLinearizable, operationsFrom, HOSTILE } from 'bulwark';
await check('raft остаётся безопасным', async (sim) => {
const { cluster } = await runScenario(sim, {
size: 5,
clients: 3,
faults: HOSTILE,
chaos: true, // падения, перезапуски, разделения сети
});
// Реплики сравниваются после того, как отказы прекратились.
const disagreement = SafetyMonitor.replicasAgree(cluster);
if (disagreement) sim.fail(disagreement);
// История клиентских операций — на линеаризуемость.
const report = checkLinearizable(operationsFrom(cluster.history));
if (report.status === 'not-linearizable') sim.fail(report.reason);
}, { runs: 200 });Что именно проверяется
Пять свойств безопасности из статьи Raft проверяются после каждого перехода состояния — после тика, после доставленного сообщения, — а не опросом раз в секунду. Тогда нарушение приписывается тому переходу, который его вызвал, а не тому месту, где тест наконец решил посмотреть.
Отдельно — линеаризуемость истории клиентских операций: каждая операция должна выглядеть выполненной мгновенно в некоторый момент между вызовом и ответом, и все вместе — в одном порядке, согласованном с реальным временем. Система может соблюсти все пять свойств и всё равно показать клиенту невозможное: применить повторно присланную команду дважды. Консенсус при этом цел, а клиент увидел то, чего не бывает — ровно поэтому в платёжных системах существует идемпотентность операций.
Задача о линеаризуемости NP-полна, поэтому у поиска есть бюджет. Когда бюджет кончается, честный ответ — inconclusive, а не «всё хорошо». Разница между «не нашли» и «не существует» здесь такая же, как в выборке расписаний.
Проверка проверки: музей багов
Набор тестов, который всегда зелёный, может ловить ошибки, а может не смотреть вообще — по цвету это неразличимо. Поэтому берётся работающая реализация, в ней выключается ровно одно правило алгоритма, и от харнесса требуется поймать это с сидом и с именем нарушенного свойства.
нет проверки актуальности лога → Leader Completeness: n2 стал лидером в терме 4
без зафиксированной записи r6 (прогон 2)
нет проверки предыдущей записи → Log Matching: индекс 1 терм 1 держит noop:n1:1
в одном месте и r2 на n4 (прогон 1)
конфликтующая запись остаётся → State Machine Safety: индекс 8: n5 применил
noop:n5:6, n2 применил r7 (прогон 4)
нет пустой записи лидера → реплики разошлись: n1={"a":"p1.2"}
против n4={"a":"p1.2","b":"p1.3"} (прогон 25)
игнорирует более новый терм → клиент 2 сдался на операции cas (прогон 1)
Figure 8 → Leader Completeness: n5 стал лидером в терме 4
без зафиксированной записи entry-A (прогон 1)
голос не пережил перезапуск → Election Safety: n2 и n3 — лидеры в терме 1Важно не то, что все семь ловятся, а что ловятся по-разному. Три ломают свойство безопасности прямо. Один оставляет все пять свойств целыми и молча лишает кластер сходимости. Один не стоит безопасности вообще — только прогресса. Свести это к «тест покраснел» значит выбросить самое содержательное.
Два последних экспоната — направленные, а не случайные. Figure 8 требует четырёх смен лидерства в заданном порядке с заданными разделениями; окно, в котором голос не успевает попасть на диск, шириной в один тик. Надеяться набрести на такое случайно — не план.
Баг, который харнесс нашёл в самой реализации
Это случай, ради которого всё и строится. Все пять свойств держались на любом расписании — и хаос-прогон всё равно упал на двадцать пятом сиде: две реплики с разными значениями. Логи при этом совпадали. Никто не падал. Ни одно правило из статьи нарушено не было.
Причина — §5.4.2, работающий ровно так, как написано. Лидер не имеет права фиксировать унаследованную от прошлого терма запись подсчётом реплик: он обязан зафиксировать запись своего терма, которая утянет за собой предыдущие. Но если клиенты замолчали сразу после смены лидера, такая запись не появляется никогда — и унаследованные остаются незафиксированными навсегда. Реплики, успевшие применить их при прошлом лидере, оказываются впереди тех, кто сигнала о фиксации не дождался.
Лекарство описано в статье одной фразой в §8: новый лидер сразу дописывает пустую запись своего терма. Теперь она в реализации есть, а её отсутствие — четвёртый экспонат музея.
Чего этот метод не умеет
- Прохождение — не доказательство. Сотни сидов — это сотни расписаний из пространства, которое астрономически больше. Очень хороший фаззинг, но не теорема; TLA+-спецификации Raft доказывают то, чего симуляция доказать не может.
- Модель отказов конечна. Потеря, задержка, дублирование, перестановка, разделение и падение с сохранением диска — да. Порча диска, частичная запись, расхождение часов и византийское поведение — нет.
- Это не готовая база данных. Нет изменения состава кластера, нет компакции лога и снапшотов — а именно они превращают корректный алгоритм в эксплуатируемую систему, и у каждого свой отдельный аргумент безопасности.
- Чекер может сдаться. Линеаризуемость NP-полна, бюджет конечен,
inconclusive— реальный исход, и он сообщается как есть.
Что спрашивать, когда вам обещают отказоустойчивость
- Что именно проверяет ваш тест отказоустойчивости: что кластер поднялся — или что подтверждённые клиенту записи на месте?
- Последний найденный отказ воспроизводится по идентификатору прогона — или существует в виде «мы такое однажды видели»?
- Отвечает ли система клиенту до того, как запись принята кворумом?
- Есть ли проверка, что тест вообще способен поймать ошибку, — например, прогон с намеренно выключенным правилом?
- Что произойдёт с клиентским запросом в момент, когда кворум недостижим: отказ с внятной ошибкой или тихое ожидание?
Последний вопрос практичнее прочих: чаще всего теряют не консенсус, а границу вокруг него — повтор запроса без ключа идемпотентности, ответ «ок» до фиксации, ретрай, который дублирует платёж. Мы разбираем это в материалах о финансовых системах и облачной инфраструктуре, а весь список технических разборов — в рубрике «Инженерия».
Частые вопросы
Мы не пишем свой Raft, мы берём готовую базу. Это всё ещё про нас?
Да, в двух местах. Первое — выбор и настройка: обещание «HA из коробки» имеет цену, и её платят кворумом и задержкой, а параметры по умолчанию редко совпадают с вашими требованиями к потере данных. Второе — ваш собственный код вокруг: ответ клиенту, повторы, ключи идемпотентности. Большинство потерь происходит там, а не внутри алгоритма.
Чем это отличается от Jepsen?
Jepsen атакует настоящий кластер снаружи и проверяет наблюдаемую историю на линеаризуемость — это про готовые системы. Симуляция работает изнутри и детерминированно: тот же сид даёт тот же прогон, а виртуальное время делает сотни расписаний дешёвыми. Подходы дополняют друг друга: один находит то, что происходит в реальной сети, другой — то, что происходит редко.
Сколько прогонов достаточно?
Двести — разумное значение по умолчанию, потому что время в симуляции виртуальное и прогон стоит миллисекунды. Но количество не заменяет разнообразия: направленные сценарии вроде Figure 8 не выпадают случайно, их нужно строить руками.
Что даёт клиенту то, что вы написали свой Raft?
Не сам Raft — его писали многие. Даёт метод: реализация и харнесс уличают друг друга, и это переносится на обычные проекты, где консенсуса нет, а гонки, повторы и потерянные подтверждения есть. Код открыт: bulwark, MIT, ссылка в источниках.
Источники
Утверждения из статьи можно проверить: ниже первоисточники, а не пересказ.
- 01Ongaro, Ousterhout. In Search of an Understandable Consensus Algorithm — Расширенная версия статьи: Figure 3 со свойствами, §5.4.2 и §8
- 02etcd raft — Узел без ввода-вывода — та же форма и по той же причине
- 03FoundationDB: Testing — База данных, корректность которой держится на детерминированной симуляции
- 04Jepsen — Проверка линеаризуемости настоящих распределённых систем снаружи
- 05bulwark — Реализация и харнесс из этой статьи: MIT, музей багов внутри

Этот материал подписан именем, в отличие от остальных: за ним не стоит клиент. Код, о котором идёт речь, открыт целиком и лежит под тем же именем — его можно прочитать, запустить и проверить утверждения статьи, не поверив ни одному из них на слово.