Назад к блогу

Jepsen разобрал ParticleDB 1.1.4: что показал анализ распределённой базы

Jepsen разобрал ParticleDB 1.1.4: что показал анализ распределённой базы

Разбор отчёта Jepsen о ParticleDB 1.1.4 показывает, что большинство аномалий согласованности воспроизводились на одном узле без сетевых сбоев и часто при единственном клиенте — то есть проблемы касаются самих транзакционных режимов, а не только распределённой работы. Особый интерес представляет расхождение между заявленными моделями согласованности (Snapshot Isolation, Serializable, Strong Serializable) и реальным поведением, а также то, что репликация через Raft в протестированных сборках вообще не проверялась.

ParticleDB заявляла себя как Postgres-совместимую базу с набором транзакционных режимов и репликацией через Raft. Отчёт Jepsen интересен тем, что почти все найденные аномалии воспроизводились на одном узле, без сетевых сбоев и часто с единственным клиентом. Разбираем, какие режимы что обещали, как устроены рабочие нагрузки, какие классы ошибок нашлись и что это говорит о заявленной архитектуре.

Что обещали режимы транзакций

Документация ParticleDB сопоставляла каждому режиму модель согласованности. occ (он же occ-v3, occ-v2, row-2pl) обещал Snapshot Isolation; zero-copy-occ (он же zocc) — тоже Snapshot Isolation; serializable-ssi (он же ssi) — Serializable; table-2pl (он же serializable) — Strong Serializable. В справке particledb start --help первый описан как «Adaptive SSI (Cahill 2008), PL-3 with optimistic semantics (v1 scaffolding — see SSI_ADAPTIVE_DESIGN.md)», второй — как «Exclusive table locks, full serialization (strictest)». Отдельно существовал режим fast с моделью «Unknown» и описанием «No locks, maximum OLTP throughput (unsafe under concurrency)».

Имена occ, occ-v3, occ-v2 и row-2pl обозначают один и тот же режим: это варианты названия, под которыми он фигурирует в разных версиях и интерфейсах, а не разные механизмы. Аналогично zocc — сокращение для zero-copy-occ, ssi — для serializable-ssi, а serializable в справке указывает на table-2pl. Различие между Serializable и Strong Serializable состоит в требовании реального порядка: Strong Serializable дополнительно требует, чтобы транзакции наблюдались в том порядке, в каком они выполнялись во времени.

Проблема в том, что документация не объясняла, что именно эти режимы гарантируют: «the documentation did not explain what these modes actually guaranteed». Имя serializable при этом меняло смысл от версии к версии: в 1.1.4–1.1.18.11.rc-2 это был либо алиас table-2pl, либо, возможно, occ-v3, а в 1.1.18.11.rc-22 он появился заново как новый режим Strong Serializable на основе SSI.

Что именно тестировалось: один узел, а не кластер

ParticleDB умела работать как одиночный узел и предлагала реплицированный кластер на протоколе консенсуса Raft, но репликация была доступна только в enterprise-облачных развёртываниях. Сборки, предоставленные Orkes, репликацию не поддерживали, поэтому отчёт покрывает только поведение одиночного узла: «The builds Orkes provided did not support replication, so this report covers only single-node behavior».

Стенд запускал версии 1.1.4–1.1.18.11.rc-22 на отдельных узлах Debian 13, в контейнерах и на EC2 VM. Он умел имитировать падение процесса (kill), потерю не-fsync-нутых записей через LazyFS и усечение либо порчу файлов на диске. Ключевой момент: аномалии возникали в здоровых одноузловых инстансах, без инъекции сбоев, часто с одним клиентом — включая внутренние аномалии, P4 (Lost Update) и G-single даже в режиме serializable. При внесении сбоев появлялись новые поведения: порча или усечение WAL давали частичную потерю данных, а имитация неверно направленных чтений и записей — полную.

То есть заявленная распределённая архитектура в протестированных сборках не проверялась вовсе; проверялась одноузловая согласованность и устойчивость к сбоям на одном узле. Многоузловых сценариев в отчёте нет.

Как устроены рабочие нагрузки

Стандартные нагрузки rw и append выполняли случайно сгенерированные транзакции. rw работал с целочисленными значениями по уникальным целочисленным ключам; append — со списками целых чисел, в которые дописывались уникальные целые. Для обеих использовался транзакционный чекер Elle: он строит граф зависимостей транзакций и ищет в нём циклы, а также нециклические явления вроде G1a (Aborted Read). Для append Elle дополнительно выводил version orders.

Когда эти нагрузки выявили нарушения согласованности внутри одной транзакции, Jepsen спроектировал нагрузку internal: «When these workloads revealed consistency violations within a single transaction, we designed an internal workload to stress internal consistency». Она выполняла серию случайных транзакций с INSERT, UPDATE, DELETE и SELECT над простой таблицей ключ-значение и проверяла, что внутри каждой отдельной транзакции чтения видят предшествующие записи. Отличие от rw и append принципиальное: те проверяют зависимости между транзакциями, а internal — согласованность операций внутри одной транзакции.

Ошибки уровня SQL-семантики

В версиях 1.1.4–1.1.17 любой предикат вида WHERE something = x агрессивно трактовался как поиск по первичному ключу со значением x, независимо от левой части. Запрос SELECT * FROM t WHERE b = 5 игнорировал строку с b = 5 и возвращал строку с первичным ключом a = 5 (в примере — 5 | 6). Запрос SELECT * FROM t WHERE 4 = 5 возвращал ту же строку, хотя равенство ложно. К версии 1.1.18.4 ParticleDB «научилась» тому, что не всякое сравнение на равенство связано с первичным ключом (#18); окончательно это подтвердилось в 1.1.18.11.rc-1 (#47).

Та же механика давала странности с UNIQUE-колонкой. Внутри транзакции над таблицей t (a INT UNIQUE) с двумя строками (1 и 2) запрос WHERE a = 1 вернул строку со значением 2; WHERE a = 3 и WHERE a IN (1, 2) — тоже строку 2; WHERE FALSE — снова строку 2. При этом WHERE TRUE и WHERE 3 = 3 вернули 0 строк: предикаты, вычисляющиеся в TRUE без обращения к ключу, давали пустой результат. Результат определялся не значением предиката, а обращением к ключу.

Нарушения ограничений целостности

Ограничения NOT NULL в версиях 1.1.4–1.1.17 игнорировались. Вставка NULL в колонку BOOLEAN NOT NULL проходила успешно, и получалась «частично невидимая» строка: SELECT * возвращал 0 строк, COUNT(*) возвращал 1, а COUNT(*) WHERE a IS NULL возвращал 0 — значение NULL в этой строке не распознавалось как NULL. Ограничения null начали соблюдаться в 1.1.18.4 (#8), поведение улучшилось в 1.1.18.9 (#35).

UNIQUE-ограничения в 1.1.4–1.1.18.4 принимались, но не применялись; в 1.1.18.6 их стали применять частично. С 1.1.4 по 1.1.18.8 UPDATE мог создать несколько строк с одинаковым первичным ключом: после UPDATE t SET id = 'c' обе строки получали id c. Выборка по равенству возвращала только одну из них, а по LIKE — обе: WHERE id = 'c' давал одну строку, WHERE id LIKE 'c' — две.

Первичный ключ можно было обновить в NULL: в 1.1.4–1.1.18.11.rc-1 UPDATE t SET a = NULL оставлял в таблице строки с пустым ключом, хотя вставка NULL в первичный ключ запрещалась. Отклонять такие попытки база стала в 1.1.18.11.rc-3 (#57). Отдельный случай — DELETE и INSERT того же ключа в одной транзакции: в 1.1.18.4–1.1.18.11.rc-11 транзакция прерывалась с PDB wire error 1064: SQL execution error: duplicate key value violates unique constraint (key=7), но только через интерфейс ParticleDB, не через Postgres. Orkes частично исправил это (#82) в 1.1.18.11.rc-9 и ещё один случай — в 1.1.18.11.rc-18.

Джойны: колонки, строки, путаница

В 1.1.4–1.1.18.11.rc-1 при соединении таблиц колонка a переименовывалась в right.a, right.right.a и так далее, а имена таблиц не возвращались вовсе. Запрос SELECT * FROM t FULL OUTER JOIN u ON t.a = u.a возвращал [{:a 1, :right.a 1.0} {:a 2, :right.a 2.0}] вместо ожидаемых :t/a и :u/a. Программы, рассчитанные на обычное SQL-поведение, падали при чтении отсутствующих колонок либо молча получали null. В 1.1.18.11.rc-3 переименование прекратилось, но имена таблиц по-прежнему опускались, и коллизии имён уничтожали информацию.

Внешние соединения непустой таблицы с пустой в 1.1.4–1.1.18.11.rc-1 часто возвращали ноль строк вместо всех строк непустой таблицы (#59): SELECT t.a AS t, u.a AS u FROM t LEFT JOIN u ON u.a = t.a при двух строках в t и пустой u давал «(0 rows)». К 1.1.18.11.rc-3 это исправилось.

При соединении таблиц с одинаковыми именами колонок в 1.1.4–1.1.18.11.rc-6 СУБД путалась даже при полной квалификации (#77). Условие WHERE u.a = 'this' не должно было ничего сопоставить, но вместо этого сопоставлялась строка t с t.a = 'this' и кросс-соединялась со всеми строками u. В 1.1.4–1.1.18.9 выходные колонки как будто менялись местами. Если t.a и u.a имели разные типы, ParticleDB пыталась привести типы к совместимым формам, порождая странные ошибки или причудливые результаты. Версия 1.1.18.11.rc-7 была гораздо меньше запутана.

«Зомби»-строки: удалённые, но видимые

После DELETE внутри транзакции строка оставалась присутствующей в некотором смысле: SELECT COUNT(*) FROM t WHERE a = TRUE возвращал 1, WHERE a = FALSE — 0, то есть агрегат видел строку как имеющую a = TRUE. Прямое чтение SELECT a FROM t WHERE a = TRUE при этом возвращало 0 строк. Следы удалённых записей в агрегатах перестали воспроизводиться в 1.1.18.11.rc-1 (#45).

Отдельный класс — строки, которые нельзя удалить. После UPDATE t SET i = 1.5 для int-колонки строка в SELECT выглядела как целое 1, но была «специальной», поскольку UPDATE передал 1.5. DELETE FROM t возвращал DELETE 0, а SELECT * продолжал показывать 1. Обновление той же строки по первичному ключу тем же значением 1.5 зомби не создавало — проблема (#23) была связана с предикатами. В 1.1.18.4 поведение исчезло. Похожая проблема (#84, «Team Rocket») была у таблиц с колонкой double precision: их можно было обновлять целыми значениями, но это порождало зомби-строки, которые нельзя ни обновить, ни удалить.

Самое радикальное проявление — DROP TABLE в 1.1.18.9–1.1.18.11.rc-1 оставлял «тень» таблицы, чьи строки появлялись в новых таблицах с тем же именем и более-менее совместимой схемой (#46, Pet Sematary). После создания t (a text, b real) и вставки ('boo', 18.83) внутри транзакции, затем создания новой t (a text, b int) и SELECT * в новой транзакции возвращалась строка boo | 19 — воскрешённая строка не идентична прежней: 18.83 превратилось в целое 19. Поведение казалось связанным с транзакциями и исчезло в 1.1.18.11.rc-3.

Порча состояния: NaN, Infinity, порядок колонок

В 1.1.4–1.1.17 интерфейс Postgres неправильно сериализовал бесконечные значения: INSERT INTO t (a) VALUES ('Infinity') проходил, а SELECT * давал PSQLException: "Bad value for type double : inf". NaN сериализовался корректно, а в psql значения возвращались как inf, а не Infinity. В 1.1.18.6 бесконечные значения стали сериализоваться правильно (#15).

Отдельно NaN в 1.1.4–1.1.18.4 иногда превращался в 0.0: после вставки 2.5, затем 'NaN' выборка возвращала 2.5 и 0. Наличие порчи зависело от порядка вставки: если первым в таблицу вставлялось NaN, оно и последующие NaN сохранялись. В 1.1.18.6 NaN стал храниться правильно (#21).

INSERT в 1.1.4–1.1.17 игнорировал список колонок и использовал порядок, заданный при создании таблицы. Строка, вставленная как INSERT INTO t (b, a), физически записывалась с значениями в другом порядке и не читалась через SELECT *, хотя база считала, что в таблице две строки. При чтении по первичному ключу был виден другой порядок колонок: WHERE a = 'x' возвращал a | b со значениями x | t, а WHERE a = 'y' — b | a со значениями f | y. В общем случае любой запрос, встречавший такую строку, молча давал ноль результатов. Второй DELETE, когда в таблице оставались только строки с порядком (b, a), успешно удалял оставшуюся строку — то есть удаление затрагивало не те строки.

UPDATE по только что созданной пустой таблице возвращал UPDATE 1 и создавал строку (#24). Эту строку нельзя было увидеть напрямую по первичному ключу, но она конфликтовала со вставками: INSERT со значением (0, 100) проходил, а (10, 110) падал с ERROR: duplicate key value violates unique constraint (key=10). Внутри транзакции созданная строка читалась по первичному ключу id = 5 и печаталась так, будто у неё только одна колонка: val = 50. Такие повреждённые строки заставляли запросы, встречавшие их, молча возвращать ноль строк, даже если были другие подходящие корректные строки.

Транзакции: откаты, вложенность, агрегаты

После синтаксической ошибки транзакция становилась проваленной, но её записи сохранялись: после INSERT INTO t (a) VALUES (3) и ошибочного THIS IS NOT VALID SQL последующий COMMIT вернул ROLLBACK, однако SELECT * FROM t показал [{:a 3}]. Клиент ParticleDB на COMMIT проваленной транзакции возвращал [#:next.jdbc{:update-count 0}] вместо ошибки — транзакция якобы фиксировалась, но эффекты были невидимы. Оба интерфейса в 1.1.18.11.rc-18 стали возвращать ошибки и не показывать эффекты отменённых транзакций (#43).

Вложенный BEGIN внутри транзакции в 1.1.18.11.rc-1 выдавал предупреждение there is already a transaction in progress и сохранял записи текущей транзакции. В более раннем поведении (#44) после INSERT и повторного BEGIN запрос SELECT * возвращал 0 строк, хотя строка была вставлена.

Агрегаты иногда не видели вторую строку: после INSERT значения 3 внутри транзакции SELECT COUNT(*) показывал 1, тогда как SELECT * видел две строки (2 и 3). Агрегаты, взаимодействующие со значением столбца, например SELECT COUNT(*) FROM t WHERE a < 5, иногда наблюдали корректное состояние. В других случаях SELECT-запросы создавали дубликаты строк внутри транзакции, и каждый запрос видел всё больше строк; эти поведения (#48) исчезли в 1.1.18.11.rc-1. Дубликаты одной строки при наличии первичного ключа (#89, Double Trouble) наблюдались в 1.1.18.11.rc-9 и исчезли в 1.1.18.11.rc-18.

Аномалии изоляции без сбоев

Read Skew (A5A) — аномалия, при которой транзакция наблюдает одну часть другой транзакции, но не наблюдает другую. В тесте первая транзакция добавила 1 к ключу 112 и 29 к ключу 103; вторая увидела добавление к 112, но пропустила добавление к 103. Поскольку T1 read-write зависит от T2, а T2 write-read зависит от T1, это одновременно G-single и нарушение Snapshot Isolation. Аномалия воспроизводилась с интерфейсом ParticleDB, режимом WAL groupsync и режимом транзакций table-2pl (Strong Serializable) в версии 1.1.18.11.rc-18; к 1.1.18.11.rc-19 больше не наблюдалась (#106).

Режимы WAL задают, как журнал предзаписи сбрасывается на диск. В режиме groupsync записи журнала синхронизируются группой, а в режиме sync — синхронно; именно поэтому stale read при sync возникал реже и требовал более длительного прогона, чтобы проявиться.

Stale Read — аномалия, при которой транзакция читает устаревшее значение, нарушая требование Strong Serializability о реальном порядке транзакций. В 1.1.18.11.rc-18 она возникала даже при WAL-режиме sync, примерно раз на 300 000 транзакций при 10 клиентах и ~80 транзакциях в секунду (распределение ключей — экспоненциальное, как в Elle). Структура одинаковая: цикл из real-time, read-write и write-read рёбер — верхняя транзакция добавила 66 к ключу 22209, средняя прочитала это значение, а нижняя, начавшаяся после завершения средней, прочитала 22209 без 66. Это нарушение Strong Snapshot Isolation и Strong Serializability. Orkes сообщил, что 1.1.19-rc.10 больше не проявляет stale read (#109).

Потеря данных при сбоях и рестартах

После kill -9 и рестарта WAL-лог recover_streaming — журнал восстановления потока записей WAL — выдал предупреждение о несовпадении контрольной суммы в хвосте сегмента: checksum mismatch in segment tail, stopping read segment_id=0. Далее он сообщил, что воспроизводит только валидный префикс уже запечатанного повреждённого сегмента WAL (при наличии маркера .corrupt) и продолжает со следующими сегментами полосы, с параметрами corruption_offset=25184, valid_size=25184, recovered_entries=242 и детализацией checksum mismatch: stored 0xf3b8aa37, computed 0x4a774da2. Запечатанный сегмент — это уже закрытый для записи кусок журнала, который восстановление только читает; полоса — набор сегментов, которые журнал ведёт последовательно и восстанавливает один за другим. Сервер всё равно запустился и штатно обслуживал клиентские запросы. Чтение ключа 1754 показало, что 44 элемента исчезли — осталось лишь [:r 1754 [81]].

Ещё жёстче повёл себя узел при порче файла: стенд перемешал 13 четырёхкилобайтных кусков первого файла .wal — примерно одну десятую содержимого — и убил узел. Хотя в файле было сотни килобайт, при старте ParticleDB залогировал No data found in storage — starting fresh и начал выдавать ERROR: table not found на каждый запрос; ни один запрос после этого не завершился успешно (#108). Похоже, база удалила содержимое своего WAL-файла: через несколько секунд его размер был ноль байт, и то же самое в конце теста. Разнообразие сообщений об ошибках указывает на возможные несколько путей к потере данных. Кроме того, почти во всех тестах ParticleDB писала только WAL-файлы и хранила таблицы целиком в памяти.

Почему так вышло: процесс разработки и роль LLM

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

В разделе QA Jepsen задаётся вопросом, почему при почти каждой протестированной функции обнаруживались грубые ошибки, и предполагает структурные недостатки процесса, включая вариант, что «инженеры доверили работу по тестированию большим языковым моделям». В разделе об LLM говорится, что качество пострадало: ParticleDB была «фракталом ошибок корректности», инструменты LLM неоднократно заявляли об исправлении ошибок, которые на деле не были исправлены, а Orkes не мог объяснить причины или решения почти каждой найденной проблемы. Использование LLM, по мнению Jepsen, подрывает доверие к документации, программному обеспечению и организациям, которые их производят.

Ограничения методологии

Jepsen прямо признаёт: тесты могут доказать наличие багов, но не их отсутствие. Часть находок осталась непроверенной на финальных версиях: для #54 не было возможности протестировать версию, в которой Orkes заявляет об исправлении (1.1.19-rc.13), а #83 Orkes считает исправленной в 1.1.19-rc.12. По версии 1.0.0 тестировался только один узел за раз, поэтому о безопасности системы репликации судить нельзя.

Что из этого следует на практике

  • Заявленный уровень изоляции ничего не значит без проверки. table-2pl обещал Strong Serializable, но давал Read Skew и stale read; serializable в разных версиях означал разные режимы. Прежде чем полагаться на имя режима, его нужно проверять на своих сценариях.
  • Ошибки SQL-семантики опаснее редких аномалий изоляции. Предикат, трактованный как поиск по ключу, WHERE 4 = 5, возвращающий строку, или SELECT *, не видящий вставленную строку, ломают приложение постоянно, а не раз в сотни тысяч транзакций.
  • «Частичная видимость» строк — худший вид порчи. Строка, которую видит COUNT(*), но не видит SELECT *, или которую нельзя удалить, означает, что приложение и база расходятся в представлении о содержимом таблицы без единой ошибки.
  • Durability не гарантирована при порче WAL. Узел, стартующий с No data found in storage поверх непустого файла и затем удаляющий его содержимое, теряет подтверждённые записи молча — без отказа стартовать.
  • Репликация осталась непроверенной. Все выводы относятся к одиночному узлу; о безопасности Raft-кластера по этому отчёту судить нельзя.
  • Документация как источник истины ненадёжна. Если она ссылается на несуществующие зависимости и выдумывает имена классов, проверять поведение приходится экспериментально.

Источники

Похожее