dbit.one© 2026
000
booting_
Loading experience0%
dbit.one

Все пять safety-свойств Raft прошли. Две реплики разошлись

Короткая версия: набор тестов, который никогда не краснел, одинаково описывает две ситуации — либо код правильный, либо никто не смотрит. По цвету они неразличимы. Единственный способ узнать, что проверка работает, — сломать код намеренно и потребовать, чтобы она назвала поломку по имени.

18 мин чтения

Коротко

  • Реализация Raft прошла все пять safety-свойств из статьи и всё равно оставила реплики с разными данными: правила алгоритма и цель алгоритма — разные утверждения.
  • Зелёный прогон нефальсифицируем, пока не существует случая, на котором он обязан покраснеть. Такой случай надо построить и держать в наборе тестов постоянно.
  • Чекер и движок должны уличать друг друга: корректность уровня изоляции ничего не значит без требования, чтобы уровень допускал аномалию следующей ступени.
  • Внутренние проверки согласуются даже при общем заблуждении, поэтому чекер отдельно сверяется с PostgreSQL, ответы которого известны заранее.
  • Сокращение перебора, выбрасывающее нужные состояния, печатает ровно тот же ответ, что и рабочее. Спасает только полный перебор рядом.

Что случилось на двадцать пятом прогоне

Я писал реализацию Raft на TypeScript — не в продакшен, а чтобы построить вокруг неё обвязку. Обвязка и была целью: каждый тик, каждая задержка сообщения, каждый потерянный пакет и каждая перезагрузка узла берутся из сида, поэтому падение в кластере из пяти узлов можно отдать другому человеку, а не пересказывать своими словами. Как это устроено, разобрано отдельно: детерминированная симуляция.

На двадцать пятом прогоне хаос-набор упал. Две реплики применили разные значения.

К этому моменту сеть уже была склеена, ничего не падало, логи у реплик совпадали. И все пять safety-свойств из Figure 3 статьи Онгаро и Оустерхаута — Election Safety, Leader Append-Only, Log Matching, Leader Completeness, State Machine Safety — держались на каждом переходе состояния, включая тот, что был непосредственно перед проверкой.

Причина: одно предложение в §8

§5.4.2 запрещает лидеру коммитить запись, унаследованную от предыдущего терма, простым подсчётом реплик: большинство, держащее старую запись, ещё не делает её безопасной — более поздний лидер всё ещё может её затереть. Правило такое: лидер коммитит запись своего терма, а унаследованные едут следом за ней.

Правило верное. И оно тихо зависит от того, что запись текущего терма вообще когда-нибудь появится.

В моём прогоне клиенты замолчали сразу после смены лидера. Такая запись не появилась. Унаследованные записи остались незакоммиченными навсегда — а реплики, которые успели применить их при прошлом лидере, остались навсегда впереди тех, кто сигнала о коммите так и не получил.

Лекарство в статье занимает одно предложение, §8: при вступлении в должность лидер дописывает пустую запись своего терма. Она текущего терма, значит её можно закоммитить подсчётом, и она тянет за собой всё, что было раньше. Восемь строк кода.

Я бы это чтением не нашёл. §8 я читал. Оно там на одно предложение, выглядит как оптимизация, и я его не реализовал.

Какая проверка это поймала

Интересен не баг. Интересно, какая именно проверка сработала.

Не проверка свойств. Все пять держались — я потом прошёл трассу руками, они действительно держатся. Тест, проверяющий правила самого Raft, прошёл бы. Тест, проверяющий, что итоговые значения правильные, тоже прошёл бы: на тех репликах, которые их получили, значения были правильные.

Сработало сравнение, дописанное почти между делом: когда отказы прекратились и кластеру дали время устояться — все ли реплики сошлись. Это не свойство Raft. Это то, ради чего Raft существует, и оказалось, что это разные утверждения.

И прогон двадцать пятый. Не второй. Не первый. Двадцать пять случайных расписаний должны были пройти мимо, прежде чем клиенты замолчали ровно в том окне, где это имело значение.

Дальше вопрос перестал быть «правильный ли у меня Raft» и стал хуже: откуда я знаю, что вся остальная обвязка вообще на что-то смотрит.

Работает только одно: дать чекеру то, про что заранее известно, что оно неправильное, и потребовать, чтобы он это сказал. И сказал правильным именем.

Музей багов

У реализации есть переключатели. Каждый выключает ровно одно правило алгоритма, и обвязка обязана поймать каждый.

text
нет проверки актуальности лога → Leader Completeness: n2 стал лидером в терме 4
                                 без закоммиченной записи r6        (прогон 2)
нет проверки prevLog           → Log Matching: индекс 1 терм 1 держит
                                 noop:n1:1, но r2 на n4             (прогон 1)
конфликтующая запись остаётся  → State Machine Safety: индекс 8: n5 применил
                                 noop:n5:6, n2 применил r7          (прогон 4)
нет no-op лидера               → реплики разошлись: n1={"a":"p1.2"},
                                 n4={"a":"p1.2","b":"p1.3"}         (прогон 25)
игнорирует более новый терм    → клиент 2 сдался на cas             (прогон 1)
По одному выключенному правилу на строку. Справа — что именно поймалось и на каком прогоне

Соломенных чучел здесь нет. У каждого из этих правил в статье есть свой подраздел, объясняющий, почему очевидная реализация небезопасна, — и этих подразделов в статье бы не было, если бы очевидную реализацию никто никогда не выкатил.

Четвёртая строка — тот самый баг, теперь приколотый как постоянный экспонат. Если рефакторинг когда-нибудь снова уберёт no-op, красное загорится намеренно.

Да, это мутационное тестирование

Притворяться, что здесь придумано что-то новое, было бы глупо. Разница одна, и она практическая.

Инструменты вроде Stryker мутируют операторы языка: меняют > на >=, выкидывают вызов, инвертируют условие. А выключить нужно правило протокола, и правило протокола не живёт в одном операторе.

Проверка актуальности лога — это условие в обработчике RequestVote, плюс сравнение двух полей, плюс то, как эти поля вообще попадают в сообщение. Автоматический мутант, поменявший там знак, с большой вероятностью просто сломает выборы целиком, и тест упадёт по причине, к делу не относящейся. А нужен экспонат, в котором алгоритм остаётся работоспособным и нарушает ровно одно своё обещание — иначе он не проверяет ничего, кроме того, что кластер умеет ломаться.

ts
upToDateCheck         // §5.4.1 — кандидат должен быть не отстающим
prevLogCheck          // §5.3   — согласование логов при репликации
truncateConflicting   // §5.3   — конфликтующий хвост срезается
leaderNoop            // §8     — пустая запись своего терма при вступлении
stepDownOnHigherTerm  // §5.1   — увидел больший терм, сложил полномочия
persistBeforeReply    // §5.1   — голос на диск до ответа
currentTermCommitOnly // §5.4.2 — коммитить только запись своего терма
Семь переключателей и параграфы статьи, которым они соответствуют

Автоматически такое не сгенерируешь. Список — это конспект статьи, записанный так, что его можно исполнить.

Устроено оно скучно: набор правил — обычный объект, приезжает в конструктор узла, по умолчанию всё включено, а тест музея создаёт кластер с одним выставленным в false. Никакой инструментации байт-кода, никакой подмены модулей. Из-за этого ветки остаются в рабочем пути — семь условий, которые в нормальной жизни всегда идут в одну сторону. Мне это не нравится, но альтернатива — держать вторую копию реализации, а вторая копия разъезжается с первой на третьем рефакторинге.

Ловится разное, и в этом смысл

Три экспоната из пяти ломают названное safety-свойство. Один оставляет все пять свойств целыми и просто перестаёт сводить кластер — это no-op. Один не стоит безопасности вообще, только прогресса: клиент в какой-то момент сдаётся.

Если сплющить это в «тест покраснел», выбрасывается самое полезное, что дало упражнение, — карта того, какая строчка статьи держит какую гарантию.

Два экспоната случайный поиск не достанет вообще. Figure 8 — сценарий, ради которого §5.4.2 и написан, — требует четырёх смен лидера в предписанном порядке с предписанными разделениями сети. Непереживший перезапуск голос требует, чтобы узел упал внутри окна в один тик между «проголосовал» и «записал голос на диск». Надеяться, что сидированный поиск туда забредёт, — не план, поэтому оба строятся руками.

Оправдание, которое ничего не стоит

Та же проблема в более резкой форме вылезла в соседнем проекте: MVCC-движок с четырьмя уровнями изоляции и чекер, который ищет циклы зависимостей в наблюдённых историях — G0, G1a, G1b, G1c, G-single, G2-item. Имена взяты у Адьи, чтобы читатель мог сверить реализацию с диссертацией, а не с моим пересказом.

Очевидная проверка здесь — на корректность уровня: на уровне L, на сотнях сидированных расписаний, движок ни разу не выдаёт аномалию, которую L обязан предотвращать.

Эта проверка не доказывает почти ничего. Движок, который отменяет каждую транзакцию, корректен на всех уровнях сразу. Чекер, который никогда ничего не находит, с ним восторженно согласится. Оба пройдут, и вдвоём они будут очень дорогим способом печатать слово «готово».

Поэтому от каждой ступени требуется второе, в противоположную сторону: должно существовать расписание, на котором она выдаёт аномалию, запрещённую уже следующей ступенью.

Уровень
Чисто в одну сторону, слабо в другую
read-uncommitted
чисто на 150 расписаниях → допускает G1b на сиде 0
read-committed
чисто на 150 расписаниях → допускает G-single на сиде 1
snapshot-isolation
чисто на 150 расписаниях → допускает G2-item на сиде 25
serializable
не допускает ничего, коммитит 51% транзакций

Read committed обязан показать read skew. Snapshot isolation обязан показать write skew. Теперь уровень стоит там, где его ставит литература, а не оказывается более строгим уровнем под чужим именем. Но полезнее другое: это требование проверяет чекер. Чекер, который не умеет найти write skew под snapshot isolation, ничем не подкреплял и своё оправдание serializable.

Последняя строка несёт вторую половину. Serializable не допускает ничего — и коммитит 51% транзакций на конкурентной нагрузке. Это число не отчёт, а утверждение в тесте: оправдание, купленное отменой всех транзакций, — вырожденный случай сверху, и единственный способ его не пустить — включить пропускную способность в саму формулировку.

Ни одной стороне не веришь отдельно. Каждая уличает другую.

Спросить того, кому на вас плевать

Всё это по-прежнему замкнуто. Мой движок, мой чекер, мои сиды. Если обе стороны разделяют одно и то же заблуждение — например, я последовательно неправильно прочитал Адью, — набор тестов этого не заметит. Все внутренние проверки согласятся друг с другом, и согласятся по неправильной причине.

Поэтому чекер наводится на PostgreSQL 18, поведение которого задокументировано не мной и ответы которого известны до начала прогона. Repeatable read у Postgres — это snapshot isolation; так написано в руководстве, и там же написано, что write skew там возможен. Serializable — это SSI Кэхилла, Рёма и Фекете.

Направленный write skew
Исход и вердикт чекера
read committed
обе транзакции коммитятся → G2-item
repeatable read
обе транзакции коммитятся → G2-item
serializable
одна отменена → аномалий нет
Случайная выборка, 25 раундов по три транзакции
Отмены и находки
read committed
0 отмен из 100 → G-single найден 24 раза
repeatable read
32 отмены из 100 → аномалий нет
serializable
32 отмены из 100 → аномалий нет

Это та же лестница. Read committed допускает read skew и никогда не отменяет транзакции ради изоляции. Repeatable read read skew предотвращает, платит за это отменами и всё равно пропускает write skew, если сценарий построен под него. Serializable не допускает ничего и платит ту же цену.

PostgreSQL воспроизвёл мою лестницу, и он для этого со мной не советовался. Из всего раздела работает только это предложение.

Сокращение, которое врёт теми же словами

Дальше картина становится неприятно общей. Перебор расписаний находит баги и никогда не может доказать их отсутствие: двести расписаний из комбинаторного пространства — это хороший фаззинг, а не теорема. Доказать отсутствие значит обойти все достижимые состояния, а это экспонента — и поэтому вся инженерия в проверке моделей не про сам поиск, а про то, как его избежать.

Спецификация
Состояний: без сокращений → с обоими
workers(4)
625 → 17, выигрыш 37×
workers(6)
15 625 → 25, выигрыш 625×
peterson
20 → 20, выигрыша нет, и это проверяется

Теперь режим отказа. Сокращение по частичным порядкам, выбрасывающее не те состояния, выдаёт побайтово тот же вывод, что и работающее. Обе версии печатают «нарушений не найдено». Одна из них врёт, и изнутри сокращённого поиска понять, какая у вас в руках, нельзя.

Это тот же зелёный набор тестов, этажом ниже. И хуже, потому что сокращение — ровно та часть, которую дописывают ради скорости, а значит та, которую с наименьшей вероятностью станут выводить заново.

Поэтому полный перебор никуда не девается. Медленный, полный и не способный ошибиться. Каждая спецификация прогоняется обоими, и вердикт сокращённого обязан совпасть. Сокращению верят не потому, что теория в книжке правильная, а потому, что на каждом экземпляре, который влезает в оба режима, оно сходится с тем, что ошибиться не может.

Результат, который не хотелось публиковать

Планировщик, на котором всё это стоит, навели на семь популярных async-пакетов: p-limit, p-queue, async-mutex, async-sema, generic-pool, p-retry и bottleneck. Девятнадцать задокументированных контрактов, сотни сидированных расписаний на каждый.

Все контракты выполнились. Ни одного бага.

Это честный заголовок, и я его чуть не выкинул, потому что «новый инструмент ничего не нашёл» — не та история, которую хочется рассказывать. Но нулевой результат здесь и есть калибровка. Это зрелые пакеты, чьи основные контракты каждый пользователь трогает ежедневно. Инструмент, объявивший, что в первый же вечер сломал ограничение конкурентности в p-limit, сообщил бы что-то о себе, а не о p-limit.

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

Один случай выглядел настоящим. Bottleneck запускал две задачи с разрывом 9 мс при минимальном интервале 10 мс. Оно воспроизвелось вне симулятора — обычно на этом вопрос закрывается. А потом выяснилось, что разрыв ровно 1 мс на всех 400 расписаниях. Не распределение, а константа. Это стоимость старта, а не дефект планирования.

Где кончается выборка

Когда в одном наборе лежат и сэмплер, и проверка моделей, появляется сравнение, которое я не ожидал увидеть настолько перекошенным. Одно и то же свойство, двумя способами.

Свойство
Выборкой → доказано
Election Safety, 3 узла, термы ≤ 3
сотни расписаний → 2 428 состояний, исчерпывающе
Election Safety, 5 узлов, термы ≤ 2
сотни расписаний → 148 318 состояний, исчерпывающе
Election Safety, 5 узлов, термы ≤ 3
сотни расписаний → 6 801 084 состояния, исчерпывающе
Write skew под snapshot isolation
найден на сиде 25 из 300 → достижим за 5 шагов, кратчайших

И режимы отказа совпали с теми, которые пришлось строить руками. Экспонат с непережившим перезапуск голосом — тот, которому нужно падение внутри окна в один тик, тот, в который случайный поиск не попадает, — в модели оказывается просто достижимым состоянием.

text
✗ выборы в raft (3 узла, термы ≤ 2, один-голос-на-терм ВЫКЛ)
  инвариант «не более одного лидера в терме» не держится

  трасса
    (начало)                  n0:f0 n1:f0 n2:f0
    n0: выдвигается           n0:c1→0 n1:f0   n2:f0
    n1: выдвигается           n0:c1→0 n1:c1→1 n2:f0
    n0: голосует за n1        n0:c1→1 n1:c1→1 n2:f0
    n1: вступает в должность  n0:c1→1 n1:l1→1 n2:f0
    n1: голосует за n0        n0:c1→1 n1:l1→0 n2:f0
    n0: вступает в должность  n0:l1→1 n1:l1→0 n2:f0
Контрпример кратчайший по построению, никакого сида

Поэтому существуют оба. Ни одного не хватает, и отказ каждого закрывается ровно другим. Про вторую половину этой пары — отказ мастера и консенсус.

Чего это не даёт и чего стоит

Про TypeScript спросят, поэтому отвечу сразу. Он выбран не потому, что подходит для распределённых систем лучше Go или Rust, — он для этого подходит хуже. Он выбран потому, что детерминированной симуляции в JavaScript не было, а готовые поддельные таймеры управляют временем, но не тем, какой из нескольких готовых колбэков пойдёт первым, — а баг живёт именно там. В Rust это делает loom, и делает лучше: код под тестом использует примитивы самого loom, поэтому он видит каждое обращение к общему состоянию и умеет сокращать перебор. В JavaScript так не выйдет, не попросив пользователя объявлять разделяемые ресурсы руками. Это открытый вопрос, и хорошего ответа у меня нет.

Реального параллелизма здесь тоже нет: Node однопоточный, и симулятор тоже. Это про конкурентность, не про параллелизм. Гонки между воркерами и внутри нативных модулей — вне области.

И это дорого — не в деньгах, в объёме. У проверки моделей 944 строки исходников и 1767 строк того, что эти 944 строки проверяет. Почти вдвое больше кода, чем кода. Не потому, что я аккуратный: у неё нет способа сообщить, что она ошиблась. Единственный способ это узнать — держать рядом второй поиск, который ошибиться не может, и требовать совпадения.

Большинству кода всё это не нужно. В линейном коде путей конечное число и покрытие их закрывает. Смысл появляется ровно там, где порядок шагов выбирает не программист: два запроса на один счёт, платёж, повторённый после таймаута, очередь и её потребитель, блокировка и ожидание на ней. Там пространство поведений комбинаторное, и набор тестов достаёт из него горсть.

Что из этого забрать

Почти ничего из перечисленного не ново. Мутационному тестированию десятки лет, Jepsen занимается тем же снаружи много лет, TLC и SPIN — инструменты лучше того, что я когда-либо напишу, а FoundationDB выкатила базу, у которой вся история про корректность сводится к «мы это отсимулировали». Алгоритмы все лежат в книжках.

Одна мысль на разных этажах
  • Сломать реализацию и потребовать, чтобы чекер заметил и назвал свойство.
  • Заставить чекер уличать движок, а движок — позорить чекер.
  • Навести оба на систему, которой всё равно, что вы думаете.
  • Держать медленный поиск, который не может ошибиться, и требовать от быстрого совпадения.
  • Утверждать в тестах места, где сокращение обязано не дать ничего.
  • Публиковать нулевой результат, чтобы следующий чистый прогон что-то значил.

Вопрос к своему набору тестов стоит задавать не «проходит ли он». А: что должно сломаться, чтобы он покраснел, и проверял ли я хоть раз, что он краснеет.

Частые вопросы

Чем это отличается от обычного мутационного тестирования?

Идея та же, объект другой. Stryker и его аналоги мутируют операторы языка: меняют знак сравнения, выкидывают вызов, инвертируют условие. Правило протокола в одном операторе не живёт — проверка актуальности лога это условие в обработчике, сравнение двух полей и способ их доставки в сообщении. Случайный мутант там ломает алгоритм целиком, и тест падает по не относящейся к делу причине. Нужен экспонат, где алгоритм остаётся рабочим и нарушает ровно одно обещание, а такое пишется руками по тексту статьи.

Мы не пишем свой Raft. Нам это зачем?

Затем же, зачем и любая проверка проверок. Вопрос «что должно сломаться, чтобы этот тест покраснел, и проверяли ли мы это» задаётся к любому набору тестов, включая тот, что стоит у вас на платёжном ядре. Ответ на него занимает день и обычно обнаруживает один-два теста, которые не падают ни при какой поломке своего предмета.

Сколько это стоит по времени?

Сам музей багов дешёвый: переключатели — обычные флаги в конструкторе, каждый экспонат это тест на десяток строк. Дорого стоит другое — привести код к состоянию, в котором отдельное правило вообще можно выключить, не сломав остальное. Если логика размазана по слоям и лезет за временем и сетью сама, сначала придётся её собрать; это та же работа, что нужна для тестируемости вообще.

Почему TypeScript, а не Go или Rust?

Для распределённых систем он подходит хуже, и это не спор. Причина в другом: детерминированной симуляции в экосистеме JavaScript не было, а поддельные таймеры управляют временем, но не порядком готовых колбэков — а баги живут именно в порядке. В Rust эту задачу решает loom, и решает лучше, потому что видит каждое обращение к общему состоянию через собственные примитивы. Повторить это в JavaScript без ручного объявления разделяемых ресурсов пока не получается.

Источники

Утверждения из статьи можно проверить: ниже первоисточники, а не пересказ.

  1. 01Ongaro, Ousterhout. In Search of an Understandable Consensus AlgorithmFigure 3 со свойствами безопасности, §5.4.2 и no-op из §8
  2. 02Adya. Weak Consistency: A Generalized Theory and Optimistic ImplementationsЯвления G0, G1a, G1b, G1c, G-single и G2-item
  3. 03PostgreSQL: Transaction IsolationRepeatable read как snapshot isolation и допустимость write skew
  4. 04bulwark — Raft под сидированными отказами и музей баговОткрытый код, MIT
Автор
Дониёр Ботиров
Основатель dbit.one · автор материала

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

Услуги по теме

Читать дальше

Инженерия10 мин чтения

Тесты не доказывают отсутствие бага. Что доказывает

Коротко: набор тестов — это поиск. Он умеет сказать «нашёл» и не умеет сказать «нет». Для большей части кода этого достаточно, но не для протокола оплаты, конечного автомата заказа и любого места, где две задачи трогают общее состояние: там редкое сочетание шагов и есть баг. Проверка моделей обходит все достижимые состояния и отвечает на вопрос, на который тесты не отвечают в принципе.

Инженерия8 мин чтения

Тест падает раз в триста прогонов. Это не погода

Коротко: тест, который падает раз в триста прогонов, уже нашёл настоящую гонку — вы просто не можете её повторить. Случаен не баг, а порядок выполнения, который выбрала операционная система. Отберите у неё этот выбор — и падение из анекдота превращается в адрес.

Инженерия9 мин чтения

Падает мастер: что на самом деле происходит с вашими данными

Коротко: «у нас кластер из трёх узлов» — это описание конфигурации, а не свойство системы. Свойство появляется тогда, когда кто-то проверил: при разделении сети два узла не решат одновременно, что они главные, а запись, подтверждённая клиенту, не исчезнет вместе с упавшим лидером. Проверяется это симуляцией, а не выключением сервера руками.

Остались вопросы по вашему проекту?

Опишите задачу — в течение 24 часов вернёмся с оценкой, сроками и планом.

[email protected]