ACID и MVCC: модель версий и snapshot-изоляция
DuckDB часто воспринимают как «движок аналитических запросов» — инструмент, который читает Parquet и считает агрегаты. Это правда, но половинчатая. DuckDB — полноценная транзакционная СУБД с гарантиями ACID: каждое изменение либо применяется целиком, либо не применяется вообще, и параллельные читатели никогда не видят полузаписанное состояние. Аналитический движок без транзакций превращается в источник тихих ошибок: представьте, что отчёт за квартал считается ровно в тот момент, когда идёт загрузка новых строк, и попадает на середину INSERT. Результат — число, которое не сходится ни с чем.
Этот урок разбирает, как DuckDB даёт ACID-гарантии, не превращаясь при этом в медленную OLTP-базу. Ключ — multi-version concurrency control (MVCC) и snapshot-изоляция.
Четыре аномалии: dirty read, non-repeatable, phantom, write skewУровни изоляции: что реально даёт PostgreSQL Понимание этой механики объясняет, почему длинный аналитический SELECT не блокирует загрузку данных, почему два процесса не могут писать в один файл, и почему откат транзакции в DuckDB почти бесплатен.
Что означает ACID на практике
ACID — это четыре свойства, которые СУБД обещает соблюдать для транзакции (любого блока операций между BEGIN и COMMIT).
| Свойство | Что гарантирует | Как DuckDB это делает |
|---|---|---|
| Atomicity | Транзакция применяется целиком или не применяется вообще | Версионная цепочка undo; ROLLBACK отбрасывает локальные изменения |
| Consistency | После коммита база в корректном состоянии (constraints соблюдены) | Проверка NOT NULL, PRIMARY KEY, CHECK до коммита |
| Isolation | Параллельные транзакции не мешают друг другу | Snapshot-изоляция через MVCC |
| Durability | Закоммиченные данные переживают сбой | WAL (write-ahead log) и checkpoint |
Atomicity и Isolation — самые интересные с точки зрения «до железа», и оба держатся на одном механизме. Когда вы пишете UPDATE, DuckDB не затирает старое значение немедленно. Вместо этого он сохраняет старую версию строки в специальной структуре. Это даёт сразу две вещи: откат становится тривиальным (просто восстанови старую версию), и параллельный читатель может продолжать видеть старое значение, как будто UPDATE ещё не произошёл.
MVCC: почему «multi-version»
Классический способ изолировать транзакции — блокировки (locks). Читатель берёт shared-lock, писатель — exclusive-lock, и они выстраиваются в очередь. Для OLTP с короткими транзакциями это работает. Для аналитики — катастрофа: один SELECT, сканирующий 500 млн строк десять секунд, заблокировал бы любую запись на эти десять секунд.
MVCC решает проблему иначе: вместо того чтобы заставлять читателей и писателей ждать друг друга, СУБД хранит несколько версий одной и той же строки. Каждая транзакция видит ту версию, которая была актуальной на момент её старта. Читатель и писатель работают с разными версиями одних данных и физически не пересекаются.
DuckDB использует MVCC-дизайн, описанный в работе Neumann, Mühleisen и Kemper «Fast Serializable Multi-Version Concurrency Control for Main-Memory Database Systems» (2015). Это та же исследовательская линия, что стоит за движком HyPer. Конкретная реализация в DuckDB оптимизирована под колоночное хранение.
Механика такая. Текущие («свежие») данные лежат в основном колоночном хранилище. Когда транзакция меняет строку, старое значение копируется в undo-буфер этой транзакции, а в хранилище записывается новое. К строке привязана информация о версиях: какой transaction_id её создал и какой удалил. Читающая транзакция при сканировании сравнивает свой снимок с этими метками и решает для каждой строки — видеть текущую версию или поднять старую из undo-цепочки.
Важная деталь колоночной оптимизации: DuckDB версионирует не отдельные значения по одному, а работает на уровне групп строк и колоночных изменений. Это позволяет держать undo-информацию компактной и не раздувать её на больших пакетных загрузках.
Snapshot-изоляция
Snapshot isolation — уровень изоляции, который даёт MVCC «из коробки». Правило простое: транзакция видит снимок (snapshot) базы данных на момент своего старта и не видит изменений, закоммиченных другими транзакциями после этого момента.
Из этого следуют важные свойства:
- Repeatable reads. Если транзакция дважды читает одну строку, она получит одинаковый результат, даже если между чтениями кто-то её закоммитил-изменил.
- No dirty reads. Транзакция никогда не видит незакоммиченные изменения других транзакций.
- Читатели не блокируют писателей и наоборот. Длинный аналитический запрос и параллельная загрузка идут независимо.
Рассмотрим конкретный сценарий с двумя соединениями к одной базе.
-- Соединение A
BEGIN TRANSACTION;
SELECT count(*) FROM events;
-- 1000000
-- Соединение B (в это же время)
INSERT INTO events SELECT * FROM new_events; -- 50000 строк
COMMIT;
-- Соединение A продолжает ту же транзакцию
SELECT count(*) FROM events;
-- 1000000 -- по-прежнему! A видит снимок на момент своего BEGIN
COMMIT;
-- А теперь новая транзакция в A:
SELECT count(*) FROM events;
-- 1050000 -- новый снимок видит закоммиченные B строки
Соединение A внутри своей транзакции дважды видит 1000000, несмотря на закоммиченные 50000 строк. Это не баг — это и есть snapshot-изоляция: A работает со стабильным снимком. Как только A коммитит и начинает новую транзакцию, оно получает свежий снимок и видит 1050000.
DuckDB по умолчанию работает в режиме autocommit: каждый отдельный SQL-оператор — это своя транзакция, которая коммитится сразу. Явные BEGIN/COMMIT нужны, когда несколько операторов должны быть атомарны как группа, либо когда вам нужен стабильный снимок на серию запросов.
Конфликты записи и их разрешение
Snapshot-изоляция не отменяет конфликтов полностью. Если две транзакции пытаются изменить одну и ту же строку, возникает write-write conflict. DuckDB разрешает его правилом «first committer wins»: транзакция, закоммитившаяся первой, побеждает; вторая при попытке коммита (или ещё на этапе изменения) получает ошибку конфликта и должна повторить операцию.
-- Транзакция A
BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Транзакция B параллельно
BEGIN; UPDATE accounts SET balance = balance - 50 WHERE id = 1;
-- A коммитит первой:
COMMIT; -- OK
-- B пытается коммитить:
COMMIT;
-- Error: TransactionContext Error:
-- Conflict on update: row was modified by another transaction
Транзакция B обязана откатиться и повторить — уже на свежем снимке. Это нормальное поведение оптимистичной модели: конфликты не предотвращаются блокировками заранее, а обнаруживаются и сигнализируются. Приложение должно быть готово к retry. На практике конфликты редки, потому что аналитические нагрузки чаще добавляют новые строки, чем переписывают одни и те же.
Один писатель, много читателей
MVCC внутри процесса позволяет любому числу потоков читать параллельно и одному потоку писать, не мешая читателям. Но есть граница на уровне процесса: один файл базы DuckDB в один момент времени может быть открыт на запись только одним процессом.
Это сознательное архитектурное решение, не недоработка. DuckDB — embedded-СУБД: она живёт внутри хост-процесса (Python, R, CLI), без отдельного серверного демона. Координировать многопроцессную запись было бы возможно только через сетевой протокол и сервер блокировок — ровно то, чего DuckDB избегает по дизайну. Поэтому модель такая:
- Один процесс открыл файл на запись -> другие процессы могут открыть его только в read-only режиме.
- Несколько процессов могут одновременно открыть один файл в read-only.
- In-memory база вообще приватна для своего процесса.
Если нужна настоящая многопользовательская конкурентная запись — это сигнал, что задача вышла за пределы embedded-сценария: смотрите в сторону MotherDuck (managed DuckDB) или DuckLake с PostgreSQL-каталогом, где координацию берёт на себя реляционная база-каталог.
Откат почти бесплатен
Поскольку старые версии строк уже лежат в undo-буфере, ROLLBACK не требует чтения с диска или сложных вычислений. DuckDB просто проигрывает undo-цепочку транзакции в обратном порядке: восстанавливает старые значения, отменяет вставки, возвращает удалённые строки. Закоммиченные данные при этом не затрагиваются — undo-буфер приватен для транзакции.
Это объясняет, почему в DuckDB дёшево обернуть рискованную операцию в транзакцию «на пробу»: если результат не нравится, откат стоит копейки. Стоимость растёт только с объёмом изменённых данных в самой транзакции, а не с размером базы.
Попробуй сам
Открой два терминала и в каждом запусти CLI на один и тот же файл базы: duckdb demo.db в первом, duckdb demo.db -readonly во втором (второй обязан быть read-only, пока первый держит файл).
- В первом терминале создай таблицу и наполни её:
CREATE TABLE t AS SELECT range AS id FROM range(1000); - В первом начни транзакцию:
BEGIN; SELECT count(*) FROM t;— запомни число. - Не коммить первую транзакцию. Во втором терминале выполни
SELECT count(*) FROM t;— какое число видит read-only соединение? - Вернись в первый терминал, добавь строки внутри той же транзакции:
INSERT INTO t SELECT range + 1000 FROM range(500);СноваSELECT count(*) FROM t;— видит ли первое соединение свои незакоммиченные вставки? - Сделай
ROLLBACKи повториSELECT count(*). Объясни каждое наблюдение через snapshot-изоляцию: что попадает в снимок транзакции, а что нет.
ACID: атомарность, изоляция, долговечность на примере PostgreSQL