Статья представляет небольшое введение в DST и будет полезна тем, кто тестирует распределённые, асинхронные и многопоточные системы, где ошибки зависят от порядка событий, сетевых задержек, ретраев и частичных отказов. Это распределённые базы данных, платёжные сервисы, брокеры сообщений, кластеры с репликацией и другие продукты с конкурентной обработкой, ретраями, тайм‑аутами и частичными отказами.
Если баг может возникнуть из‑за редкого сочетания задержки сети, перезапуска узла и повторной доставки сообщения, такой подход действительно может помочь. Для обычного CRUD‑приложения без сложной асинхронности, репликации и конкурентного состояния полноценный DST, скорее всего, будет избыточен.
Баг случается раз в месяц и исчезает под отладчиком
Рассмотрим условный сбой в распределённой базе данных. Такая база работает не на одном сервере, а на группе связанных процессов или машин. Эту группу называют кластером, а отдельные её участники узлами. Один узел может координировать запись данных, остальные хранят реплики и при необходимости заменяют отказавшего участника.
Клиент отправляет операцию записи. Текущий лидер кластера подтверждает её выполнение, после чего почти сразу перезапускается. Система выбирает нового лидера, но нужная запись ещё не успела попасть на одну из реплик. Следующий запрос клиента возвращает старые данные, хотя предыдущая операция уже была подтверждена.
Команда изучает логи и несколько раз повторяет сценарий, но ошибка больше не возникает. Дополнительное логирование и подключение отладчика тоже меняют скорость выполнения процессов, поэтому события происходят уже в другом порядке. В одном запуске сообщение успевает дойти до реплики до перезапуска лидера, в другом новый лидер выбирается позже, а в третьем клиентский запрос обрабатывает другой узел.
В подобных случаях входными данными являются не только запросы клиента. Результат зависит от порядка доставки сообщений, срабатывания таймеров, скорости дисковых операций, планирования задач и моментов отказа отдельных процессов. Обычный повтор запроса не воспроизводит все эти условия.
Deterministic simulation testing, или DST, создаёт контролируемую среду, в которой тест управляет временем, сетью, хранилищем, отказами и источниками случайности. Благодаря этому он может исследовать множество вариантов выполнения системы, а найденную последовательность событий затем запускать повторно.
Что именно происходит внутри симуляции
В реальном кластере процесс отправляет сообщение через сетевой стек операционной системы. Неизвестно, когда пакет будет доставлен, задержится ли он в очереди и в каком порядке придут несколько сообщений. В детерминированной симуляции вызов отправки не передаётся физической сети. Сообщение помещается во внутреннюю очередь событий, которой управляет симулятор.
Аналогично обрабатываются таймеры и дисковые операции. Когда узел устанавливает таймер на 30 секунд, тесту не приходится ждать реальные 30 секунд. Симулятор записывает событие с соответствующим виртуальным временем. Если до этого момента в системе ничего не должно произойти, виртуальные часы сразу переходят к следующему событию.
Такую модель называют дискретно‑событийной симуляцией. В ней время изменяется не непрерывно, а переходит от одного запланированного события к другому. Планировщик выбирает следующее событие из очереди, обновляет виртуальное время, выполняет соответствующий обработчик и добавляет в очередь новые события.
Один запуск может содержать клиентские запросы, доставку и потерю сетевых сообщений, завершение записи на диск, срабатывание таймеров, отключение узлов и их последующее восстановление. Порядок этих действий определяется псевдослучайным генератором.
Псевдослучайный генератор создаёт последовательность значений на основе начального параметра, который называют seed. Сам seed не содержит журнал всех событий. Он лишь позволяет генератору снова выдать ту же последовательность решений при условии, что код, конфигурация и порядок обращений к генератору не изменились.
Допустим, при seed 481516 симулятор задержал сообщение между первым и вторым узлами, затем завершил процесс лидера, допустил выбор новой реплики и только после этого доставил задержанное сообщение. Если в этом состоянии нарушился проверяемый инвариант, значение seed сохраняется в отчёте. Разработчик запускает тест с тем же параметром и получает тот же порядок решений.
У этого механизма есть ограничение. После значительного изменения кода количество и порядок обращений к генератору могут измениться, поэтому прежний seed не всегда создаёт идентичную трассу. На практике вместе с ним сохраняют конфигурацию теста, версию программы, параметры нагрузки и подробный журнал событий.
DST решает три разные задачи.
Он контролирует недетерминированные факторы, генерирует сложные последовательности событий и проверяет корректность полученного состояния.
Повторяемость сама по себе не гарантирует качество тестирования.
Симулятор должен сначала создать нужный сценарий, а затем правильно определить, произошло ли нарушение.
Как этот подход реализован в FoundationDB
FoundationDB здесь рассматривается не как обязательная технология, а как пример архитектуры, специально подготовленной для детерминированной симуляции. Большинство команд не будут строить аналогичный симулятор, но могут использовать отдельные принципы DST: контролировать время и случайность, сохранять seed, моделировать порядок событий и проверять системные инварианты.
FoundationDB представляет собой распределённое транзакционное хранилище данных типа key‑value. Оно поддерживает ACID‑транзакции и используется как основа для систем, которым необходимы согласованность, доступность и устойчивость к отказам. Детерминированная симуляция была заложена в разработку FoundationDB до появления самой рабочей базы. В первые 18 месяцев команда создавала и тестировала систему в симуляции до отправки первых реальных сетевых пакетов.
В симуляторе запускается реальный код FoundationDB вместе с синтетической нагрузкой и механизмами внесения отказов. Несколько логических серверов работают внутри одного физического процесса и общаются через симулируемую сеть. Все внешние источники недетерминизма абстрагированы, включая сеть, диск, время и генератор псевдослучайных значений.
FoundationDB написана с использованием Flow. Это расширение C++, реализующее акторную модель конкурентного выполнения. Актор в данном случае является независимой задачей, которая может ожидать результат асинхронной операции и продолжать выполнение после его получения. В продакшене задачи взаимодействуют с реальными системными интерфейсами. В симуляции физические интерфейсы заменяются специальными прослойками, а основной цикл обработки событий заменяется планировщиком виртуального времени. Благодаря этому несколько логических процессов кластера можно выполнить детерминированно в одном потоке.
Однопоточное выполнение здесь важно не из‑за производительности. Реальная многопоточность добавила бы решения планировщика операционной системы, которые симулятор не может полностью контролировать. В FoundationDB конкурентность сохраняется на уровне акторов, но порядок их продолжения определяет управляемый цикл событий.
Нагрузку создают специальные workloads. Workload является тестовым компонентом, который подготавливает данные, выполняет операции с базой и проверяет итоговое состояние. Один и тот же workload во многих случаях можно запустить как внутри симулятора, так и на реальном кластере. Тестовая конфигурация определяет используемые нагрузки, размеры кластера, длительность запуска и дополнительные действия, например перезапуск нескольких машин.
Симулятор FoundationDB моделирует разные конфигурации машин и дисков, заполнение накопителей, сетевые задержки, разрывы соединений, остановки и перезагрузки процессов, отказ стоек и дата‑центров. Частота отказов в тестовой среде значительно выше, чем при обычной эксплуатации, поскольку задача теста состоит в исследовании редких состояний.
При этом слишком большое количество отказов тоже снижает полезность теста. Если все узлы постоянно отключены, система остаётся в небольшом наборе очевидных нерабочих состояний. Поэтому FoundationDB настраивает распределение отказов так, чтобы увеличивать разнообразие состояний, а не просто создавать максимальную нагрузку.
Что такое buggification
Аппаратных и сетевых сбоев недостаточно для проверки всех редких ветвей программы. Многие ошибки находятся в допустимых, но редко возникающих реакциях самого приложения. Операция может иногда вернуть предусмотренную ошибку, таймер может сработать позже обычного, а внутренний параметр может получить крайнее значение.
FoundationDB использует для этого механизм, который разработчики называют buggification. В коде размещаются специальные точки, где симулятор может включить необычное поведение, не нарушающее формальный контракт операции. Например, успешно работающая функция возвращает допустимую ошибку, быстрая операция получает задержку, а параметр настройки принимает редко используемое значение.
Такой подход проверяет, не стала ли нормальная работа системы случайно зависеть от конкретных значений тайм‑аутов, размеров буферов или высокой скорости определённой операции. Если алгоритм корректен только при типичной конфигурации, изменение параметров может вывести его в состояние, которое раньше практически не встречалось.
Каждый запуск FoundationDB может использовать случайный размер и состав кластера, разные нагрузки, параметры отказов, настройки базы и набор активных точек buggification. Такой подход называется swarm testing. Вместо многократного выполнения одной конфигурации система исследует большое количество сочетаний функций и условий.
Разработчик также может добавить проверку достижения конкретного внутреннего состояния. Например, его интересует, выполнялся ли новый участок кода при заполненном буфере. Результаты симуляций показывают, сколько запусков достигло этого условия. Если состояние не возникает, команда изменяет генератор нагрузки, параметры отказов или точки buggification.
Как тест определяет, что система работает неправильно
Симулятор может задержать сообщения, отключить несколько машин и перезапустить дата‑центр, но сам факт отказа ещё не означает наличие дефекта. Система может временно стать недоступной в соответствии со своим контрактом, а затем корректно восстановиться.
Для определения результата нужен test oracle. Так называют правило или механизм, который определяет, соответствует ли фактическое поведение ожидаемому. В обычном тесте oracle часто представляет собой конкретное ожидаемое значение. При проверке распределённой системы удобнее контролировать свойства, которые должны сохраняться при разных последовательностях событий.
Такие свойства называют инвариантами. Например, подтверждённая транзакция не должна исчезнуть, две окончательно синхронизированные реплики не должны содержать разные данные, а общая сумма денег в замкнутой системе не должна меняться без операции пополнения или списания.
Проверки корректности распределённых систем часто разделяют на safety и liveness. Safety означает, что недопустимое состояние не должно возникнуть ни при каких обстоятельствах. Потеря подтверждённой записи относится к нарушению safety. Liveness означает, что система должна в итоге выполнить необходимое действие. Если после восстановления связи кластер навсегда остаётся без лидера, нарушается liveness.
FoundationDB проверяет свойства как через тестовые нагрузки, так и через внутренние assertions. Для проверки восстановления симулятор может сначала создать достаточное число отказов, чтобы база потеряла доступность, затем вернуть моделируемое оборудование в рабочее состояние и убедиться, что кластер в итоге восстановился.
Качество oracle определяет, какие ошибки тест способен заметить. Проверка только общей суммы денег не обнаружит перевод между неправильными счетами, если итоговая сумма осталась прежней. Контроль количества записей не выявит их неверный порядок или содержание. Поэтому для одной системы обычно требуется несколько независимых инвариантов и более подробная эталонная модель поведения.
Подробнее о подходах к поиску скрытых ошибок и построению надёжного тестирования сложных систем можно почитать в материалах:
➞ Как тестировать то, что сложно проверить: практики поиска скрытых ошибок.
➞ Как находить проблемы в системах с помощью современных подходов к тестированию.
В FoundationDB один из тестов использует ключи и значения, организованные в кольцевую структуру. Транзакции изменяют значения так, чтобы целостность кольца должна была сохраняться. После большого количества конкурентных операций и отказов нарушение структуры указывает на проблему транзакционной изоляции.
Виртуальное время и скорость тестирования
Многие ошибки распределённых систем проявляются не сразу. Узлу нужно дождаться тайм‑аута, выполнить несколько повторных попыток, запустить восстановление, выбрать нового координатора и синхронизировать данные. На реальном стенде тест расходует время даже тогда, когда процессы ничего не вычисляют и только ожидают следующего события.
Дискретно‑событийная симуляция может пропускать такие интервалы. Если ближайшее событие запланировано через 30 виртуальных секунд и до него ничего не должно произойти, симулятор сразу переводит часы на нужный момент. Поэтому моделируемое время способно проходить быстрее физического.
FoundationDB выполняет десятки тысяч симуляций каждую ночь. По оценке команды, суммарный объём тестирования достиг эквивалента примерно одного триллиона процессорных часов. Типичный запуск моделирует приблизительно в десять раз больше системного времени, чем занимает само выполнение теста. Эти значения относятся к конкретной архитектуре FoundationDB и не являются универсальной характеристикой DST.
После обнаружения ошибки разработчик повторно запускает тест с тем же seed и добавляет дополнительную диагностику. В детерминированной среде новое логирование обычно не меняет порядок событий, поэтому проблему можно исследовать поэтапно. В статье FoundationDB указано, что при редком обнаружении дефекта в реальной эксплуатации команда сначала расширяла симулятор до воспроизведения проблемы и только затем переходила к обычной отладке.
TigerBeetle и симулятор VOPR
TigerBeetle представляет собой распределённую базу данных для финансовых транзакций. Она использует модель двойной записи, сохраняет неизменяемую историю переводов и обеспечивает строгую сериализуемость операций. Эти свойства требуют проверки не только доступности, но и корректности каждого изменения финансового состояния.
Собственный детерминированный симулятор TigerBeetle называется VOPR, или Viewstamped Operation Replicator. Он запускает несколько реплик базы и тестовых клиентов в одном процессе. Реальные сетевые и дисковые операции заменяются моделями в памяти, которые создают задержки, потерю и повторную доставку пакетов, ошибки хранения, повреждение данных и отказы процессов.
VOPR генерирует клиентские операции и одновременно меняет условия работы кластера. Основной узел получает запрос, реплицирует его на резервные узлы и подтверждает выполнение только после достижения требований протокола. Во время этого процесса симулятор может разорвать связь, остановить реплику или задержать дисковую операцию.
Кроме бизнес‑инвариантов VOPR способен проверять линеаризуемость. Линеаризуемость означает, что результат конкурентных операций должен соответствовать некоторому допустимому последовательному порядку, причём этот порядок не может противоречить фактической последовательности завершения запросов. Клиент не должен увидеть состояние, которое невозможно получить ни при каком корректном последовательном выполнении.
В опубликованном демонстрационном сценарии 3,3 секунды работы VOPR соответствовали 39 минутам моделируемого поведения. Команда оценивала один час такого тестирования примерно как месяц работы системы, а сутки примерно как два года. Это соотношение относится именно к конкретной модели и нагрузке TigerBeetle. Оно показывает эффект виртуального времени, но не позволяет заранее оценить ускорение для другой системы.
Почему VOPR всё равно пропустил дефект
Детерминированная симуляция не обеспечивает полного перебора состояний. Возможное количество входных данных, порядков событий и отказов практически неограниченно. Симулятор исследует только те сценарии, которые способен создать его генератор.
Это ограничение хорошо видно в разборе TigerBeetle, опубликованном после тестирования с помощью Jepsen. Jepsen представляет собой проект и методику проверки распределённых систем. Он запускает реальные компоненты, создаёт конкурентную нагрузку и отказы, записывает историю операций, а затем сопоставляет фактический результат с независимой эталонной моделью.
Во время проверки TigerBeetle Jepsen создал перевод, а позднее отправил запрос, который должен был вернуть эту операцию. Кластер ответил пустым результатом. Ошибка проявлялась при пересечении фильтров по нескольким полям. Независимая модель Jepsen знала, что созданный перевод соответствует запросу, поэтому зафиксировала расхождение.
В TigerBeetle уже существовало около двадцати фаззеров, причём несколько из них проверяли запросы. VOPR также умел создавать запросы с пересечением нескольких полей, поэтому команда отдельно исследовала, почему дефект не был найден раньше.
Причина находилась не в проверке результата, а в генерации данных. VOPR заранее создавал набор непересекающихся комбинаций фильтров, а затем формировал переводы, соответствующие ровно одной такой комбинации. Эта схема упрощала расчёт ожидаемого количества результатов, поскольку для проверки не требовалась полноценная модель поискового механизма. Однако данные с нужным перекрытием полей практически не возникали, поэтому конкретная ошибка оставалась вне исследуемой области.
Этот случай показывает различие между количеством тестов и разнообразием состояний. Генератор может выполнить миллионы операций, но систематически избегать одной важной комбинации. Детерминизм помогает воспроизвести найденную ошибку, однако не гарантирует, что тест создаст условия для её появления.
После обнаружения подобных пробелов необходимо изменять модель нагрузки, расширять набор инвариантов или добавлять независимый oracle. Использование нескольких подходов снижает вероятность общей слепой зоны, поскольку симулятор, Jepsen, property‑based тесты и проверки на реальной инфраструктуре по‑разному формируют нагрузку и анализируют результат.
Ограничения DST
Для полноценной детерминированной симуляции недостаточно написать набор тестов вокруг готового приложения. Система должна предоставлять контроль над источниками недетерминизма. Получение времени, выполнение таймеров, сетевые операции, доступ к диску и генерация случайных значений проходят через интерфейсы, которые можно заменить симулируемыми реализациями.
FoundationDB создавалась с учётом такого подхода. Проект избегает неконтролируемой многопоточности в симуляции, использует собственную акторную модель и ограничивает зависимости, которые невозможно выполнить в детерминированной среде. Для уже существующей системы такое изменение архитектуры может потребовать значительных затрат.
Симулятор также ограничен точностью своих моделей. Если модель диска предполагает более сильные гарантии, чем предоставляет настоящая файловая система, тест не обнаружит зависимый от этого дефект. FoundationDB прямо указывает, что некоторые ошибки возникали из‑за того, что реальный контракт операционной системы оказался слабее предполагаемого.
DST плохо подходит для достоверной оценки производительности, если виртуальная среда не повторяет характеристики реального оборудования. Она не проверяет сторонние библиотеки и внешние сервисы, не включённые в симуляцию. Ошибки сетевого стека, драйвера диска, контейнерной среды или используемой библиотеки могут проявиться только в интеграционных и системных тестах на настоящей инфраструктуре.
Поэтому FoundationDB и TigerBeetle не рассматривают симуляцию как единственный уровень тестирования. Она дополняется обычными модульными и интеграционными тестами, фаззингом, проверками реальных бинарных файлов и контролем целостности во время эксплуатации.
Разработка полноценного симулятора оправданна прежде всего для распределённых баз данных, платёжных систем, брокеров сообщений, систем хранения и других продуктов, где результат зависит от сложной последовательности конкурентных событий, а цена нарушения согласованности высока. Для обычного сервиса с небольшим числом внешних зависимостей стоимость создания такой среды может оказаться выше потенциальной пользы.
При этом отдельные принципы DST применимы без собственного аналога FoundationDB Simulation. Команда может внедрить виртуальные часы для тестирования тайм‑аутов и повторных попыток, сохранять seed фаззинга и property‑based тестов, моделировать сетевые задержки через контролируемый транспорт, отказаться от прямого обращения к системному времени и фиксировать порядок событий во flaky‑сценариях.
Основная идея DST состоит в расширении понятия тестовых данных. Для конкурентной системы недостаточно сохранить запрос и состояние базы. Необходимо учитывать порядок доставки сообщений, срабатывания таймеров, завершения дисковых операций и возникновения отказов.
Когда эти факторы контролируются тестовой средой, редкий сбой получает воспроизводимую конфигурацию, seed, историю выполнения и конкретное нарушенное свойство. После этого его можно исследовать как обычный регрессионный сценарий, а не ждать следующего случайного проявления в продакшене.
Как всё вышесказанное можно применить у вас на проекте
Рассмотренные подходы отличаются глубиной внедрения. FoundationDB показывает вариант, при котором система изначально проектируется для детерминированной симуляции. Время, сеть, диск и случайность доступны через заменяемые интерфейсы, поэтому значительную часть реального кода можно запускать в полностью контролируемой среде. Такой уровень требует серьёзных архитектурных изменений и оправдан в продуктах, где ошибки конкурентности и потери данных особенно дороги.
TigerBeetle использует похожую модель, но её симулятор VOPR полезен ещё и как пример специализированного тестового окружения вокруг конкретного протокола. Необязательно сразу моделировать весь продукт. Можно выбрать один критичный процесс, например обработку платежа, доставку сообщения или смену состояния заказа, затем создать тестовый стенд, который управляет задержками, повторной доставкой, отказами и порядком операций. Такой подход реалистичнее для большинства команд, чем разработка полноценного аналога среды FoundationDB.
Jepsen решает другую задачу. Он проверяет систему снаружи, запускает реальные узлы, создаёт сетевые разделения и сбои, сохраняет историю клиентских операций и сравнивает её с формальной моделью ожидаемого поведения. Этот вариант подходит, если приложение уже существует и глубоко перестраивать его ради симуляции невозможно. Jepsen требует понимания гарантий согласованности и подготовки модели, но не заставляет переносить продукт в специальную среду исполнения.
Antithesis предлагает промежуточный вариант. Это готовая платформа для запуска приложений в контролируемом и воспроизводимом окружении. Она может быть полезна командам, которым нужен deterministic testing, но которые не готовы самостоятельно разрабатывать планировщик, виртуальную сеть и систему внедрения отказов. При этом коммерческая платформа не устраняет необходимость определить нагрузки, инварианты и ожидаемые свойства продукта.
Для начала стоит выбрать не инструмент, а класс проблемы.
Если дефект связан с тайм‑аутами и ретраями, достаточно виртуальных часов и управляемых ошибок зависимостей.
Если важен порядок сообщений, можно создать тестовый брокер или транспорт, который задерживает, дублирует и переставляет события.
Если риск возникает при отказе нескольких узлов, потребуется стенд с fault injection или внешняя проверка в духе Jepsen.
Полноценный DST нужен только тогда, когда редкие последовательности конкурентных событий являются одной из основных угроз для корректности системы.
Независимо от выбранного уровня, тесту нужны проверяемые свойства.
Для платёжного продукта это может быть сохранение общей суммы средств и запрет двойного списания.
Для очереди сообщений, отсутствие потери подтверждённых событий.
Для системы заказов, невозможность одновременно завершить и отменить одну операцию.
Без таких инвариантов даже сложный симулятор сможет создать множество отказов, но не всегда поймёт, что результат стал неправильным.
Практическое внедрение разумно начинать с уже известного проблемного сценария.
Команда описывает не только запрос и ответ, но и последовательность событий, которая привела к сбою.
Затем выбирает минимальный уровень контроля, необходимый для его воспроизведения.
Это может быть фиксированный seed, виртуальное время, управляемая очередь, fault injection, отдельный симулятор или внешняя проверка реального кластера.
FoundationDB, VOPR, Jepsen и Antithesis не являются взаимозаменяемыми инструментами. Они показывают разные способы контролировать недетерминизм и проверять распределённую систему. Конкретный выбор зависит от архитектуры продукта, стоимости ошибки и готовности команды инвестировать в тестовую инфраструктуру.
Редкие сбои в распределённых системах проще разбирать, когда понятны механизмы отказоустойчивости, межсервисного взаимодействия и доставки сообщений. На бесплатных занятиях можно сверить свой подход с практикой экспертов, задать вопросы и заодно посмотреть, как устроено обучение в OTUS.
3 августа, 20:00 — «Использование брокера сообщений Apache Kafka в распределённых очередях». Записаться
4 августа, 20:00 — «Секреты межсервисных запросов: как сделать приложение быстрым и надёжным». Записаться
18 августа, 19:00 — «Готовим инфраструктуру к сбоям: отказоустойчивый кластер на базе VRRP и HAProxy». Записаться
Больше бесплатных уроков июля смотрите в дайджесте.