Маленький, а уже с изоляцией транзакций: пишем Jepsen-тесты для Antietcd
22.01.2026
Начиная с версии 1.7.0, в Vitastor есть встроенный заменитель etcd — Antietcd.
Он реализован на node.js и очень простой — занимает буквально пару тысяч строк кода. Конечно, он умеет чуть меньше, чем etcd, но его функционала абсолютно достаточно для полноценной работы кластера Vitastor — все основные функции присутствуют, а кое в чём он даже лучше, чем etcd — например, Antietcd позволяет не хранить на диске “временные” данные.
Однако до последнего времени не существовало ответа на вопрос — правда ли его можно использовать в продуктиве? Точно ли он корректен?
Ниже история изысканий ответа. История со счастливым концом :)
Оглавление
- Jepsen
- TinyRaft
- Antietcd
- Запускаем Jepsen
- О логике тестов
- Уровень изоляции транзакций
- Ошибки выполнения операций
- Зависания генератора
- Кэш wget
- Первые ошибки
- Тесты наблюдателей
- G1a Aborted Read
- Изоляция транзакций
- Разбор ещё одной аномалии
- Итоги
- Ссылки
Jepsen
Хороший способ проверить корректность — хаос-тестирование с помощью фреймворка Jepsen.
Что это такое?
В общем, жил-был чувак по имени Kyle Kingsbury (с ником Aphyr) и решил он в какой-то момент, что было бы прикольно поломать распределённые СУБД и посмотреть, а правда ли они такие классные, как заявляют их авторы. Правда ли они не теряют данные, если сегфолтятся процессы, флапает сеть, отваливаются диски, прыгают туда-сюда показания системных часов?
И написал он для этого Jepsen. Это как раз и есть фреймворк, которому вы скармливаете базу данных, реализацию клиента, генератор случайных операций, проверялку и ломалку — “Немезиду”, nemesis. Всё это запускается в контролируемой среде (обычно на виртуалках), выполняет случайные операции, а Jepsen следит за их выполнением и проверяет, не произошла ли какая-нибудь гадость.
Написал он его уже очень давно, первый коммит в репозитории Jepsen датирован 2013 годом, в 2015 его уже использовали для тестирования. Я тогда ещё на PHP писал :D, мне про него впервые рассказали где-то в 2018. С тех пор Aphyr примерно раз в полгода вылезает из норки и описывает (во всех смыслах) очередную базу данных, обычно показывая, что там всё плохо. За это время Jepsen стал уже практически общепринятым инструментом для тестирования распределённых баз данных, а Aphyr стал серьёзным чуваком, зарегал компанию и даже, кажется, перестал постить в Twitter развратные картинки. :-)
Генераторы-проверялки-ломалки там есть встроенные плюс можно дописать свои. “Гадости” называются феноменами (“доктор, я феномен, у меня яйца звенят — вы не феномен, вы м#$озвон”), или же аномалиями, они удобно описаны на его сайте и их там целый набор.
Самые первые проверялки (checker-ы) там были простые и проверяли только один уровень изоляции транзакций, самый строгий, но сформулированный только в терминах отдельно взятого объекта — линеаризуемость. А потом автор взботнул научных трудов, закорешился с ещё несколькими прошаренными чуваками и они вместе написали уже более умный чекер — Elle, способный точечно определять все аномалии и давать заключение, какой же у вас получился уровень изоляции транзакций, serializable или всё-таки read uncommitted. И текстовые описания аномалий делает, и даже графы зависимостей рисует. Терминология зависимостей (write-write, read-write, write-read, process, realtime) тоже описана у него на сайте, там мне особенно понравился каламбур про “traSNACKtion”.
Ну в общем всё в Jepsen-е классно, кроме одной вещи — написан он на Clojure, и заставить себя попробовать это извращение я очень долго не мог. Clojure — “современный диалект Lisp для JVM”, то бишь, функциональный ЯП с обилием скобочек. Хотя, попробовав писать на нём тесты, могу сказать, что не так уж он и плох, довольно выразительный.
В общем, принимаем волевое решение о том, что Jepsen с кложурой нам нужны, и переходим к Antietcd… нет, сначала к TinyRaft.
TinyRaft
Antietcd, на самом деле, появился во многом как модельный пример для тестирования TinyRaft, являющего собой Raft, от которого отпилена репликация лога и оставлен только выбор лидера.
TinyRaft я породил в попытках ещё больше упростить Raft.
Собственно, мне всегда казалось, что в Raft репликация по логу — это какое-то тяжёлое решение. Зачем реплицировать лог? Ведь с ним каждое изменение ты пишешь дважды — сначала в лог, а потом в саму базу данных. Причём писать лог нужно обязательно на диск, а все изменения обязательно нужно прогонять через лог, иначе гарантии корректности алгоритма нарушаются. Поэтому Raft БД обычно предназначены только для мелких объектов.
При этом там всё равно довольно часты ситуации, в которых БД копируется с лидера на другой узел целиком, путём снятия и восстановления полного дампа. Поэтому Raft БД обычно предназначены только для небольших баз данных. В etcd, например, по умолчанию лимит размера БД — всего лишь 2 гигабайта, а максимально это ограничение можно поднять до 8 гигабайт.
Но даже с этими ограничениями логи Raft совсем не легковесные, особенно с учётом того, что обычно для нормальной работы алгоритма персистентно хранят 10-100 тысяч последних записей лога. Из практики мне очень запомнилась кривая реализация встроенного Raft в OpenNebula, которая хранила логи записями в таблице MySQL (до 100 тысяч записей), работа с логом постоянно тормозила и таймаутила, в итоге там постоянно было то 2 лидера из 3, то 0 лидеров из 3. В общем, мораль — Raft в OpenNebula использовать не надо, лучше поставьте какую-нибудь Galera и живите спокойно.
Другой пример из жизни — это сам etcd, который однажды в кластере из 1 узла (!) съел у меня 23 гигабайта
оперативной памяти. Съел он их именно потому, что не успевал чистить Raft-логи и они почти бесконечно
копились. Да и когда etcd успевает чистить логи, он всё равно с настройками по умолчанию жрёт порядка
6 ГБ RAM. Напоминаю, это при том, что Vitastor хранит в нём всего пару МЕГАБАЙТ данных. Так что если вы
обратите внимание на опции etcd в скрипте make-etcd
Vitastor-а, то заметите там опцию --snapshot-count 10000, которая как раз и означает “делать снапшот
после 10000 закоммиченных транзакций” (а не после дефолтных 100000). Это позволяет снизить потребление
памяти примерно до 1.5-2 гигабайт.
Библиотеки с готовыми реализациями Raft (коих очень много) мне тоже всегда казались тяжёлыми. Зачастую в стремлении предоставить максимально коробочную реализацию вам дают чуть ли не готовый etcd, с готовым незаменяемым сетевым слоем и с готовой реализацией хранилища лога на основе какого-нибудь встраиваемого K/V движка БД. Хранилище лога обычно заменить можно, но толку от этого никакого нет, ибо семантика не подразумевает особых отступлений от дефолтной логики его работы. Занимает вся эта машинерия, естественно, никак не меньше 10000 строк кода, в реальных библиотеках ближе к 20000 и выше. Хорошо хоть саму БД (называемую “стейт-машиной”, да, это она) дают подсунуть свою.
В общем, постепенно пришла мысль — может быть, просто убрать из Raft репликацию лога?
Так и появился TinyRaft. Он занимает буквально 300 строчек, которые тривиально переписать на любой язык программирования, и решает ровно одну задачу: корректный выбор лидера. О сети он ничего не знает, в него нужно просто запихивать сообщения вызовами функций. О синхронизации он тоже ничего не знает, алгоритм синхронизации к нему можно прикручивать любой, можно от стандартного Raft с логами, можно другой — я, когда писал README, сразу придумал парочку.
Это, на самом деле, офигенная штука, потому что в каком-нибудь Patroni (кластеризаторе Postgres) ровно это и нужно — выбор лидера без репликации. Репликация-то там используется от самого Postgres.
Antietcd
Итак, как же работает репликация в Antietcd, если он основан на TinyRaft? Очень просто:
- Репликация синхронная. Изменения вносятся только на лидере, он их отправляет по websocket-у своим репликам (их список он знает от TinyRaft), и только после этого лидер подтверждает успешную запись клиенту. Если же репликация не удалась — лидер просто запускает перевыборы в TinyRaft.
- При успешном выборе лидера происходит простая начальная синхронизация: новый лидер запрашивает полные дампы БД у своих реплик, выбирает из них дампы с максимальным Term-ом, сливает их в один, принимает его как эталонный, загружает к себе и копирует обратно всем репликам. И только после этого начинает приём запросов записи/чтения.
По сути, это самый простой из возможных вариантов алгоритмов репликации.
Вся БД держится в памяти, а на диск сбрасывается (и fsync-ается!) целиком, в один JSON-файл. Для применения в Vitastor это более чем нормально, так как реальный размер данных в Vitastor-овом etcd редко превышает пару мегабайт, а часто меняются там только “временные” ключи, содержащие данные типа статистики, которые можно вообще не хранить на диске.
При этом Antietcd, как и любое другое node.js приложение, однопоточен. Так что с транзакционностью никаких проблем нет — логично, все изменения применяются синхронно и в памяти. API оптимистичных транзакций etcd (txn с compare, success, failure) реализуется очень просто.
Antietcd довольно красиво нарезан на модули, коих там всего несколько:
- etctree.js — реализация самой etcd-подобной базы данных в памяти
- antipersistence.js — персистентность (сохранение данных на диск)
- anticluster.js — репликация и синхронизация
- antietcd.js — головной модуль, склеивающий всё это воедино
Запускаем Jepsen
Кажется, что можно было просто пойти путём копипасты: взять готовые jepsen-тесты etcd, заменить там etcd на antietcd и наслаждаться жизнью.
Однако у Jepsen есть пошаговое руководство — tutorial, и, чтобы лучше понимать, что вообще происходит, лучше сначала пройти по нему и повторить все шаги с самого начала, а уже потом заимствовать туда тесты etcd. Руководство по Clojure я читать не стал, хотя никогда раньше не писал ни на нём, ни на Lisp, ни вообще на какой-либо функциональщине. Вместо этого просто разбирался по ходу дела.
Отличие туториала от настоящих etcd-тестов в том, что туториал, видимо, писался ещё во времена etcd 2.x, и там применяется клиентская библиотека собственного разлива автора под названием Verschlimmbessergung, в общем, то ли что-то про шлюх, то ли про нефть (Шлюмбергер какой-то).
Ну, нам это не важно, мы всё равно всё это заменяем на простые HTTP-запросы, которые мы будем делать через httpkit, потому что его уже использует сам Jepsen — одной зависимостью меньше.
Jepsen-у для работы нужно, по умолчанию, 5 доступных по ssh виртуалок, называющихся просто n1-n5. Виртуалки эти я поднял себе локально на голом qemu — это делается тривиально, качаем официальный образ Debian netinst, ставим его в одну виртуалку, потом через qemu-img создаём 5 клонов диска, и запускаем уже 5 виртуалок подобным скриптом:
#!/bin/bash
echo 1 > /proc/sys/net/ipv4/ip_forward
iptables -t nat -C POSTROUTING -o wlan0 -s 10.0.2.0/24 -j MASQUERADE || \
iptables -t nat -A POSTROUTING -o wlan0 -s 10.0.2.0/24 -j MASQUERADE
brctl addbr br0
for i in {1..5}; do
TAP=tap$((i-1))
sudo -E kvm -m 2048 \
-drive file=debian13_n$i.qcow2,if=virtio \
-cpu host \
-netdev tap,ifname=$TAP,script=no,id=n0 \
-device virtio-net-pci,netdev=n0,mac=52:54:00:12:34:5$i &
done
sleep 1
ip l set br0 up
for i in {1..5}; do
TAP=tap$((i-1))
ip l set $TAP up
brctl addif br0 $TAP
done
ip a a 10.0.2.2/24 dev br0
iptables -C FORWARD -i br0 -j ACCEPT || \
iptables -I FORWARD 1 -i br0 -j ACCEPT
iptables -C FORWARD -o br0 -m state --state RELATED,ESTABLISHED -j ACCEPT || \
iptables -I FORWARD 1 -o br0 -m state --state RELATED,ESTABLISHED -j ACCEPT
service dnsmasq start
wait
Ну, или если вы не такие упоротые, как я, можете повторить ту же процедуру через какой-нибудь UI виртуализации.
Правда, Jepsen не дружил с Debian 13 (это было так на момент января 2026) и пытался установить пару пакетов, которых там уже нет, поэтому его пришлось чуть-чуть подпатчить — убрать из списка устанавливаемых пакетов libzip4, а ntpdate заменить на ntpsec-ntpdate. После этого говорим lein install — jar-ник собирается и устанавливается в обычную для Java локальную maven-помойку под версией с суффиксом -SNAPSHOT, и его становится можно использовать уже в зависимостях наших тестов.
О логике тестов
Дальше наконец начинается интересное — логика работы тестов.
В туториале нам предлагают реализовать 2 теста:
- register. Тест регистров со случайными чтениями, записями и CAS-обновлениями. Запись просто пишет в ключ случайное значение от 1 до 5, CAS пытается его обновить с одного случайного значения до другого, а чтение просто читает ключ. Результаты проверяются на корректность Knossos-ом — первым Jepsen-овским чекером линеаризуемости.
- set. Тест на полноту множеств. Клиенты только дописывают записи в список, трактуемый как множество, и потом, в самом конце, проверяется, что множество полное, то есть, что в процессе ни одна запись не потерялась.
Такие же 2 теста есть и в наборе тестов jepsen etcd. Но, кроме них, там есть и более мощные:
- append (Elle list-append). Самый мощный тест, основанный на “умном” чекере Elle, способном ловить аномалии и определять уровни изоляций, строя граф зависимостей между транзакциями. Elle и генерирует транзакции, и проверяет результаты; наш код должен лишь интерпретировать и выполнять их. Сами транзакции содержат по нескольку операций над ключами, операций всего 2 типа: чтение всего списка и добавление элемента в конец списка (обязательно в конец, не в середину, по позиции элемента сверяется порядок выполнения операций!).
- wr (Elle rw-register). Это тоже тест на основе Elle, но более простой — он похож на тест register, но тоже генерирует и проверяет транзакции над несколькими ключами. Однако он слабее списочных тестов, т.к. в списках можно наблюдать всю последовательность, а не только одно последнее значение.
- watch. Тест API наблюдения ключей etcd. Изменяет ключи и проверяет, что все наблюдатели корректно получают эти изменения. В Antietcd наблюдатели тоже есть, так что нам такой тест тоже понадобится.
Уровень изоляции транзакций
Уровень изоляции транзакций, проверяемый в тестах etcd — строго сериализуемый.
Он же заявляется и самими авторами etcd.
Его можно понизить до просто сериализуемого (не строго), если добавить параметр запроса ?serializable=true —
название параметра идиотское, так как интуитивно возникает ощущение, что он включает serializable, которого
по умолчанию нет, но на самом-то деле дефолтный strict serializable также является и serializable. Правильно
было бы назвать параметр stale=true или как-то похоже, потому что на самом деле он просто разрешает
чтение потенциально устаревших данных из локальной БД реплики без общения с лидером. Это позволяет чуть
улучшить производительность ценой снижения консистентности.
Antietcd — прямая замена etcd, кроме того, он однопоточный и работает с данными в памяти, так что для нас
нет никаких причин использовать другие уровни изоляции. Мы тоже будем использовать strict serializable, а
если в конфигурации Antietcd включена опция stale_read — просто serializable, по тому же принципу, что в etcd.
Собственно, устаревшие чтения возможны только в моменты, когда падает сеть и реплика ещё не успевает понять,
что на неё больше не идёт репликация. В Antietcd stale_read по умолчанию включён, если его выключить, то
перед каждым чтением реплика делает поход к лидеру, проверяя, что он ещё с нами.
Ошибки выполнения операций
Первый важный вопрос, с которым я столкнулся в процессе написания тестов — это что клиент должен делать с неуспешно завершёнными операциями. Повторять попытку, возвращать ошибку (результатом операции может быть :ok или :fail), или ещё что-то?
Ответ:
- Ошибку (:fail) нужно возвращать, только если операция точно неуспешна. То есть, если клиент знает, что операция к БД точно примениться не могла. Например, если CAS-обновление не прошло по причине несовпадения исходного значения.
- Если клиент не уверен в результате записи, то есть, если операция, может быть, применилась, а может быть, и нет — например, если запрос таймаутнул — нужно бросить исключение. Так Jepsen поймёт, что клиент упал и результат выполнения операции неизвестен.
- В некоторых тестах, однако, любые неудачные записи нужно бесконечно повторять — например, в том самом тесте set, так как в этом смысл теста — там проверяется, что множество полное, а значит, нужно, чтобы все добавления в любом случае прошли успешно.
Зависания генератора
Также, в принципе, всегда можно бесконечно повторять чтения, потому что они не меняют состояние базы. Но обычно лучше этого не делать, так как от этого может зависнуть генератор операций.
Это была следующая проблема, на которую я наступил в процессе написания тестов.
И вот почему так происходит. Логичное устройство генератора для теста — это последовательный генератор (gen/phases из библиотеки jepsen.generator) из двух фаз:
- Первая фаза — завёрнутый в лимит времени (gen/time-limit) комбинированный генератор для клиентов и Немезиды (gen/nemesis).
- Вторая фаза — восстановление кластера — сначала final-generator из nemesis, отменяющий все поломки кластера, а потом какой-нибудь gen/sleep, просто ожидающий восстановления.
Однако, gen/phases перед переходом к следующей фазе ждёт завершения всех операций предыдущей фазы через Synchronize. А часть операций иногда выполниться не могут, потому что каждая операция привязана к определённой ноде кластера, а ноды поломаны Немезидой.
Итог — зависание… Лечится оно, правда, довольно просто — достаточно gen/phases засунуть внутрь gen/nemesis, а не наоборот. Тогда становится можно тестировать и операции с бесконечными ретраями.
Кэш wget
Следующий забавный момент был такой — вношу изменения, перезапускаю тест, а он опять запускается на старой версии кода. Где-то кэшируется старая версия.
Но где? Оказывается, install-archive! из jepsen.control.util кэширует скачанные файлы через cached-wget, а кэш располагается на нодах в директории /tmp/jepsen. Так что почистить его можно вот так:
for i in {1..5}; do ssh root@n$i 'rm -rf /tmp/jepsen'; done
Первые ошибки
Первые написанные тесты (register, set) не выявили почти никаких багов. То есть, все проблемы, которые я с ними ловил — это были проблемы недоотлаженных тестов, а не баги Antietcd. Было несколько мелочей:
- При ошибке перенаправления запроса лидеру возвращался статус HTTP 200, а не ошибочный;
- Иногда Antietcd пытался пинговать ещё не соединённые вебсокеты и падал с исключением;
- Если Antietcd передавали нестроковой ключ, запросы падали с исключением из-за попытки работать с не-строкой (например, с числом) как со строкой.
Но это всё, естественно, тривиально исправилось и тесты заработали.
Дальше я перешёл к портированию Elle-теста append. И с параллелизмом 100, как ни странно, прошёл и он. И даже при включении stale_read и смене проверяемого уровня на обычный serializable всё тоже получилось. Не сразу, конечно, в процессе отладки теста я успел понаблюдать много довольно безумных “найденных” аномалий, но они вызывались то тем, что я ретраил запись, то тем, что пытался сделать добавление элемента в список идемпотентным, то тем, что в читающей транзакции пропускал часть ключей… Elle исправно пытался всё это превратить в отчёты об аномалиях в формате — эй, чувак, твоя база не вернула тебе созданный ключ, а вернула nil, значит, транзакция выполнилась раньше предыдущей… Но на самом деле всё это были баги теста. Аналогично я портировал и тест wr (Elle rw-register).
При этом то, что здесь не поймались реальные проблемы — было лишь следствием низкого параллелизма. При параллелизме 200 они уже проявились бы. Но я перешёл к тестам watcher-ов быстрее, чем попробовал 200 потоков.
Тесты наблюдателей
Итак, переходим к тестам наблюдателей (watcher-ов). Заменяем etcd-клиент на вебсокеты через hato, как-то адаптируем остальное и пробуем запустить тест.
Тест не проходит. Почему? Потому что тест проверяет, что все watcher-ы, даже постоянно отключаясь и переподключаясь заново, получают одинаковую последовательность событий. В etcd это работает потому, что он хранит всю историю изменений и досылает её подключившимся watcher-ам. А в Antietcd нет, потому что он историю не хранит.
Да и нафиг она вообще нужна, это же не Kafka какая-нибудь! Сложно придумать применение etcd, которое бы зависело от неизменности событий в истории. В Vitastor полная история точно не нужна — да, наверное, и в Kubernetes (самом известном пользователе etcd) тоже. И там, и там главное, чтобы в итоге все изменения доходили.
Причём и в etcd на самом деле доставка полной истории тоже работает не всегда — историю он хранит
не бесконечно, и если уже прошёл compaction, при попытке начать слежение с ревизии, предшествующей
компакшену, вам вернут сообщение с canceled: true и заполненной compact_revision.
Что же тогда проверять? А проверять нужно, что все наблюдатели получают корректную подпоследовательность состояний базы данных. То есть, клиент может не получать все изменения отдельно, но то, что он получает, должно приводить его к какому-то промежуточному консистентному состоянию базы.
Ну, что ж, меняем тест под новую логику проверки. Это, кстати, не так-то просто — корректный подход такой: нужно взять все события, полученные всеми клиентами, извлечь из них изменения отдельных ключей (events) и пересобрать их в новую последовательность событий, в каждом из которых содержатся правки только по одному номеру ревизии. Это будет эталон. А вот использовать для построения эталона результаты пишущих запросов некорректно, потому что часть их таймаутит и тогда не известно, применяются они или нет.
После этого эталон можно сравнивать с событиями, видимыми каждым клиентом, пересобирая изменения в полные состояния БД на каждом из номеров ревизий, и сравнивая, соответствует ли то, что получил клиент, тому, что произошло в реальности.
G1a Aborted Read
Запускаем тест наблюдателей… И наконец-то находим настоящую проблему! О которой я, в общем-то, знал сразу, когда делал Antietcd, но к которой не знал, как правильно относиться.
Проблема проявляется так — часть наблюдателей видит изменения, которых нет. В тесте в каждой новой ревизии меняется только один ключ, но некоторые наблюдатели это видят так, как будто их меняется два. Причём у других наблюдателей второе изменение отсутствует.
По сути, это эквивалентно аномалии G1a Aborted Read в терминологии Jepsen (взятой из диссертации какого-то чувака со странной фамилией Адия). G1a — это когда читатель видит изменения, внесённые транзакцией другого писателя, которая после этого чтения будет отменена.
В Antietcd такое могло происходить в момент поломки кластера, если изменение успевало среплицироваться только на ноду, которая сразу после этого отваливалась и не участвовала в кворуме. Остальные ноды перевыбирали лидера и продолжали работу без изменённого ключа. Причём это могло происходить даже на лидере — если он успевал сохранить изменение только себе, а потом вываливался из кворума. С точки зрения гарантий записи это нормально — писателю изменение ещё не подтверждено и его можно потерять, но вот с точки зрения читателей…
Кстати, даже если кластера нет, а есть только 1 нода, то такая же проблема тоже возможна, если изменение успеет примениться в памяти, но не успеет сброситься на диск, после чего Antietcd перезапустится.
Watcher-ы так хорошо ловят это потому, что получают изменения максимально быстро — как только Antietcd вносит изменение, он сразу рассылает об этом уведомления. Но проблема обязана была воспроизводиться и в обычных тестах чтения/записи, просто для этого был нужен параллелизм хотя бы 200.
Окей, а G1a в Antietcd для Vitastor — это плохо? Скорее всего, потенциально да, это может приводить либо к залипанию каких-то компонентов в некорректном состоянии, либо даже к некорректным обновлениям — номер ревизии в отменённом изменении тоже увеличивается, и если мы прочитаем ключ, увидим такое изменение и сделаем на основе этого чтения CAS-транзакцию, а тем временем кто-то поменяет тот же ключ ещё раз — мы перезапишем его изменение и оно будет потеряно.
Изоляция транзакций
И вот тут наступает самый интересный момент: а как исправить этот Aborted Read?
Почему-то первая идея, которая пришла мне в голову, была такая — может, нужно сделать две копии базы данных, одну “чистую” для читателей и вторую “грязную” для писателей? Это будет что-то вроде “read committed”. CAS будет работать корректно — транзакции клиентов, построенные на основе прочитанной старой версии, не пройдут, но это и не страшно — подождут, повторят ещё раз, и всё будет нормально. Хм, а что, если транзакция и пишет, и читает?.. С какой копией она должна работать? Видимо, с “грязной”, но тогда она опять потенциально будет видеть отменённые чтения.
Может, тогда вообще не нужно применять изменение к БД до тех пор, пока оно не отреплицируется на другие ноды? Но нет, так тоже нельзя — ведь тогда пишущие транзакции вообще не увидят новую версию и просто перезапишут её своими правками.
И что же делать? Правильный ответ — нужно внедрять изоляцию транзакций на основе блокировок ключей. Не важно каких — можно пессимистичных, можно оптимистичных.
То есть, когда транзакция меняет ключ, он должен блокироваться, и все другие транзакции не должны его ни читать, ни писать до тех пор, пока изменение не будет сохранено на диск и отреплицировано на все остальные ноды текущего кворума. Если блокировки оптимистичные, можно просто обрубить запрос со статусом “попробуйте позже”. Если пессимистичные, то запрос должен встать в очередь ожидания блокировки.
Блокировки должны распространяться и на watcher-ов:
- Во-первых, начало наблюдения за ключами, если клиент запрашивает начальную ревизию наблюдения (start_revision), тоже работает, как чтение.
- Во-вторых, пока изменение не зафиксировано во всём кластере, и рассылать его клиентам в уведомлениях тоже нельзя.
Интересно, что без блокировок проблема, видимо, не решается вообще никак и не зависит от момента применения изменения к БД и используемого алгоритма консенсуса (с лог-репликацией было бы то же самое). А внедряя блокировки, мы волей-неволей уже немножко ныряем в мир изоляции транзакций, несмотря на максимальную простоту нашей базы данных. “Row-level locking”, ага.
Разбор ещё одной аномалии
Окей, сказано — сделано, и теперь в Antietcd есть блокировки. Повторяем тест watcher-ов, и он наконец проходит корректно! Ура!
Остаётся вернуться и проверить, что в процессе не сломали что-то в других тестах. Запускаем снова append, теперь с параллелизмом 200… хм. Нам пишут, что у нас целых 5 видов аномалий: G-nonadjacent-item-realtime, G-single-item, G0-realtime, G1c, incompatible-order. Интересно. Это блокировки всё так сломали? Откатываемся на версию без блокировок, перепроверяем — нет, всё то же самое, через раз что-то вылезает.
Ну ок, смотрим описания аномалий. Их Elle складывает в папочку store/current/elle/.
Вот начало файла G-single-item.txt оттуда:
G-single-item #0
Let:
T1 = {:index 276, :time 14732767003, :type :ok, :process 133, :f :txn, :value [[:r 4 [19]] [:append 0 26] [:r 4 [19]]]}
T2 = {:index 264, :time 14709137549, :type :ok, :process 93, :f :txn, :value [[:append 4 3] [:append 0 15]]}
Then:
- T1 < T2, because T1 did not observe T2's append of 3 to 4.
- However, T2 < T1, because T1 appended 26 after T2 appended 15 to 0: a contradiction!
Другие описания более стрёмные, некоторые состоят из 12 пунктов, а тут всего 2, так что самую простую аномалию и будем разбирать. Рядом лежит и картинка, но, в целом, текстовое описание понятнее:
Лезем в history.edn / jepsen.log. Видим, что:
- Да, T2 на
:index 264вроде как добавила в ключ 4 значение 3. - Но T1 на
:index 276эту 3 не увидела, зато увидела 4 = [19]. А откуда взялось 19? - А 19 дописала ещё одна транзакция на индексе 273:
{:index 273, :time 14720717184, :type :ok, :process 118, :f :txn, :value [[:append 4 19]]} - Таак, T1 увидела изменения с индекса 273, но не увидела 264?
- О, а на индексе 272 кто-то ещё и читал ключ 4 и видел там [3]:
{:index 272, :time 14719563878, :type :ok, :process 128, :f :txn, :value [[:r 4 [3]]]}
Но мы же добавляем элементы в списки с помощью CAS-транзакций? Каким образом мы перезаписали 3 19-ю?
Окей, мы, к счастью, гоняли тест с access-логами antietcd — по умолчанию нужно гонять без них, так как с ними часть багов может не воспроизводиться из-за дополнительной сериализации о вывод в лог. Лезем в лог, находим там эти успешные запросы записи 4 = [3] и 4 = [19]. Всё честно, только 3 куда-то потерялось:
2026-01-18T09:04:03.709Z ::ffff:10.0.2.2:60946 POST /v3/kv/txn 200
{"compare":[{"key":"4","target":"MOD","result":"LESS","mod_revision":46},{"key":"0","target":"MOD","result":"LESS","mod_revision":46}],"success":[{"request_put":{"key":4,"value":[3]}},{"request_put":{"key":0,"value":[1,2,3,4,5,6,7,8,9,10,11,15]}}]}
{"header":{"revision":47},"succeeded":true,"responses":[{"response_put":{}},{"response_put":{}}]}
2026-01-18T09:04:03.721Z ::ffff:10.0.2.2:60914 POST /v3/kv/txn 200
{"compare":[{"key":"4","target":"MOD","result":"LESS","mod_revision":48}],"success":[{"request_put":{"key":4,"value":[19]}}]}
{"header":{"revision":48},"succeeded":true,"responses":[{"response_put":{}}]}
Окей, ищем предзапросы чтения, по которым делается CAS, находим такое:
2026-01-18T09:04:03.708Z ::ffff:10.0.2.2:60990 POST /v3/kv/txn 200
{"success":[{"request_range":{"key":"4"}}]}
{"header":{"revision":47},"succeeded":true,"responses":[{"response_range":{"kvs":[]}}]}
Странный ответ. Ревизия 47, а 4 = [3] нет. О, а ещё рядом есть такое:
2026-01-18T09:04:03.709Z ::ffff:10.0.2.2:60930 POST /v3/kv/txn 200
{"compare":[{"key":"3","target":"MOD","result":"LESS","mod_revision":46},{"key":"1","target":"MOD","result":"LESS","mod_revision":46}],"success":[{"request_put":{"key":1,"value":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,25]}},{"request_put":{"key":3,"value":[1,2,3,4,5,6,7,9,10,11,12,13,14,32]}},{"request_range":{"key":3}}]}
{"header":{"revision":47},"succeeded":true,"responses":[{"response_put":{}},{"response_put":{}},{"response_range":{"kvs":[{"key":"3","value":[1,2,3,4,5,6,7,9,10,11,12,13,14,32],"mod_revision":46}]}}]}
Э-э? Как это? Изменения другие, а ревизия та же — 47?
Оказывается, всё было просто — после применения изменения Antietcd отвечал текущей ревизией БД на момент ответа, а не той, что была на момент внесения изменения.
Исправляем, повторяем тест — все аномалии исчезли, баг побёжден,
Everything looks good! ヽ(‘ー`)ノ`.
Итоги
Мой философский трактат наконец подошёл к концу.
Почему-то мне кажется, что я породил отличный учебный пример для отработки логики распределённых БД.
Jepsen-тесты заняли примерно 1500 строк кода. Теперь мои велосипеды официально переходят в статус проверенных: и алгоритм выбора лидера TinyRaft, и своя система консенсуса Antietcd прошли проверки, обеспечивают честный STRICT SERIALIZABLE и с версии Antietcd 1.2.0 могут использоваться в продуктиве, например, в Vitastor.
При этом Antietcd, с одной стороны, почти не усложнился — 3000 строк кода (+500 с блокировками) всё ещё тривиально переписать на любой язык. Кстати, ребята из ALT Linux уже переписали на Rust TinyRaft. С другой стороны, Antietcd сохранил потенциал развития. Может быть, например, удастся сделать из него взрослую K/V базу, допилив возможность хранить большие объёмы данных?
А я отлично развлёкся на новогодних праздниках, получил массу удовольствия от разборок в логике работы Jepsen и теперь не смотрю на все эти G0, G1a, wr и rw-зависимости, как баран на новые ворота. :-)
В общем, всем спасибо, все свободны, встретимся в production-е!
Ссылки
- Antietcd
- TinyRaft
- Jepsen
- Описания аномалий Jepsen
- Аномалия G1a Aborted Read
- Зависимости транзакций Jepsen
- Строгая сериализуемость
- Пошаговое руководство Jepsen
- Чекер транзакций Elle
- Статья про Elle
- Elle list-append
- Elle rw-register
- Jepsen-тесты etcd
- Гарантии консистентности etcd
- Описание алгоритма Raft
- Сравнение встраиваемых K/V движков для Go
- Клиент HTTP для clojure hato
- Диссертация Atul Adya — Weak Consistency: A Generalized Theory and Optimistic Implementations for Distributed Transactions
- TinyRaft, переписанный на Rust