DuckLake: ACID, time-travel, data inlining и deletion vectors
В прошлом уроке мы установили главное: DuckLake держит метаданные в SQL-каталоге, а данные — в иммутабельных Parquet-файлах. Теперь разберём, что эта архитектура даёт в работе. Четыре механизма: ACID-транзакции сразу по нескольким таблицам, snapshot-изоляция с time-travel запросами, data inlining против проблемы мелких файлов и deletion vectors для эффективного удаления строк. Все четыре — прямое следствие того, что метаданные живут в реляционной СУБД.
ACID сразу по нескольким таблицам
Начнём с того, что в файловых лейкхаус-форматах болит сильнее всего. В Iceberg или Delta Lake атомарность ограничена одной таблицей: «коммит» — это атомарная замена указателя на текущий снапшот именно этой таблицы. Если вам нужно согласованно обновить три таблицы (типичный случай: загрузили факты в fct_sales, обновили dim_customers и записали строку аудита в etl_log), то атомарно это сделать нечем. Между обновлением первой и третьей таблицы есть окно, в котором читатель увидит несогласованное состояние.
DuckLake снимает это ограничение бесплатно. Коммит в DuckLake — это обычная транзакция в каталожной СУБД. А реляционная транзакция атомарна по определению: либо применяются все изменения метаданных, либо ни одного. Раз метаданные всех таблиц лежат в одной базе, одна транзакция каталога меняет их согласованно.
-- Все три изменения метаданных коммитятся одной транзакцией каталога
BEGIN TRANSACTION;
INSERT INTO lake.fct_sales SELECT * FROM 'new_sales.parquet';
UPDATE lake.dim_customers SET segment = 'vip' WHERE total_spent > 10000;
INSERT INTO lake.etl_log VALUES (now(), 'sales load', 'ok');
COMMIT;
-- Либо все три таблицы переходят в новое состояние, либо ни одна.
-- Промежуточного несогласованного состояния читатель не увидит.
Модель изоляции — snapshot isolation. Каждая транзакция видит фиксированный снимок лейкхауса на момент своего старта; параллельные коммиты её не трогают. Конкуренция разрешается оптимистично: если две транзакции пытаются изменить одно и то же, одна коммитится, вторая получает конфликт и повторяется. Это та же snapshot-модель, что у самого DuckDB, только распространённая на лейкхаус.
Snapshots и time-travel
Каждый коммит в DuckLake порождает новый снапшот — именованную точку в истории лейкхауса. Снапшот не копирует данные: это запись в каталоге о том, какой набор data-файлов составляет каждую таблицу в этот момент. У снапшота есть snapshot_id, временная метка и версия схемы.
Так как старые снапшоты остаются в каталоге, а Parquet-файлы иммутабельны и не удаляются при правках, лейкхаус хранит свою историю. Это даёт time-travel — возможность запросить состояние таблицы на любой прошлый снапшот.
Запрос к прошлому делается синтаксисом AT:
-- Текущее состояние
SELECT count(*) FROM lake.orders;
-- 842_119
-- Состояние на конкретный снапшот по его id
SELECT count(*) FROM lake.orders AT (VERSION => 1);
-- 500_000
-- Состояние на момент времени
SELECT count(*) FROM lake.orders AT (TIMESTAMP => '2026-05-20 09:00:00');
-- 500_000
-- Что именно изменилось между снапшотами — разница, а не полное состояние
SELECT * FROM lake.table_changes('orders', 1, 2);
Time-travel закрывает практические задачи: воспроизвести отчёт «как он выглядел в понедельник», расследовать, когда в данные попала ошибка, откатиться к состоянию до неудачной загрузки. Стоимость хранения истории — это удерживаемые старые Parquet-файлы; когда история больше не нужна, специальная операция expiration удаляет снапшоты старше заданного возраста и физически чистит файлы, на которые уже никто не ссылается.
Снапшот в DuckLake дёшев потому, что это запись в каталоге, а не копия данных. Создать снапшот — значит добавить строки в служебные таблицы каталога. Поэтому коммитить часто не страшно: история — это рост каталога и удерживаемые файлы, а не дублирование данных на каждый коммит.
Data inlining: ответ на проблему мелких файлов
Теперь — механизм, который прямо лечит классическую болезнь лейкхаусов. Проблема мелких файлов (small file problem) выглядит так: каждая запись в Parquet-формат создаёт новый файл, потому что Parquet-файлы иммутабельны. Если приложение делает много мелких изменений — вставило 3 строки, потом ещё 5, потом обновило 2, — каждое порождает крошечный Parquet-файл. Через сутки таблица — это десятки тысяч файлов по несколько килобайт. Чтение деградирует: открыть и распарсить footer десятков тысяч файлов дороже, чем прочитать сами данные. Лечат это отдельной процедурой compaction, которая периодически склеивает мелкие файлы в крупные.
DuckLake предлагает другой подход — data inlining. Раз каталог это полноценная SQL-база, маленькую порцию изменений можно записать прямо в строки каталога, вообще не создавая Parquet-файл. Изменение меньше порога (по умолчанию около 10 строк, настраивается через DATA_INLINING_ROW_LIMIT) уезжает не в объектное хранилище, а в таблицу каталога.
При чтении движок прозрачно объединяет данные из Parquet-файлов и inlined-строки из каталога — для запроса разницы нет, таблица выглядит цельной. Когда inlined-данных накапливается достаточно, команда CHECKPOINT сбрасывает их из каталога в нормальные Parquet-файлы одним крупным файлом вместо россыпи мелких.
-- Мелкая вставка: уходит в каталог как inlined-данные, без Parquet-файла
INSERT INTO lake.orders VALUES (900001, 'EU', 149.00, '2026-05-20');
-- Накопленные inlined-данные сбрасываются в нормальный Parquet
CHECKPOINT lake;
Эффект: проблема мелких файлов решается на корню. Частые мелкие записи больше не плодят мусорные файлы — они аккумулируются в каталоге и материализуются крупными порциями. Это снимает нужду в постоянной фоновой compaction для типичной нагрузки с частыми мелкими апдейтами.
Deletion vectors: удаление без переписывания файлов
Последний механизм — про удаление строк. Parquet-файл иммутабелен: физически вырезать из него строку нельзя. Наивное удаление одной строки из миллионного файла означало бы переписать весь файл заново — дорого.
DuckLake использует deletion vectors (векторы удаления). Идея: data-файл оставить как есть, а отдельно записать компактную структуру, которая помечает, какие позиции строк в этом файле считаются удалёнными. Реализуются такие векторы как roaring bitmap — битовая карта, эффективно сжимающая множества позиций, — и хранятся в Puffin-файлах (формат для вспомогательных метаданных, совместимый с экосистемой Iceberg).
При чтении движок берёт data-файл и применяет к нему deletion vector — строки, помеченные удалёнными, в результат не попадают. DELETE становится дешёвой операцией: вместо переписывания гигабайтных файлов записывается компактный bitmap. UPDATE выражается через ту же механику: старые версии строк помечаются deletion vector, новые версии дописываются.
-- DELETE не переписывает Parquet-файлы, а записывает deletion vector
DELETE FROM lake.orders WHERE order_date < '2025-01-01';
-- Результат:
-- удалено 120_544 строк; data-файлы не тронуты,
-- помеченные позиции записаны в deletion vector
Со временем, если deletion-векторы пометили удалённой большую долю строк файла, тот же CHECKPOINT или процедура обслуживания может физически переписать файл, выкинув удалённые строки и обнулив вектор. Deletion vectors в DuckLake опираются на подход Iceberg v3; на момент версии 1.0 поддержка нескольких deletion-векторов в одном Puffin-файле помечена экспериментальной и дорабатывается к версии 1.1.
Три механизма работают вместе вокруг иммутабельности Parquet. Inlining не даёт мелким вставкам плодить файлы. Deletion vectors не дают удалениям переписывать файлы. CHECKPOINT периодически наводит порядок — материализует inlined-данные и переписывает файлы с большой долей удалений. Иммутабельность data-файлов при этом сохраняется всегда: меняются только каталог и набор файлов, а не содержимое существующих.
Попробуй сам
Понадобится DuckDB 1.5.x с расширением ducklake и созданный в прошлом уроке локальный лейкхаус (каталог SQLite, данные в папке).
Задания:
- Сделайте в одной таблице лейкхауса три отдельных
INSERTс интервалом, каждый раз проверяяFROM lake.snapshots();. Убедитесь, что каждый коммит — это новый снапшот с новымsnapshot_id. - Выполните
SELECT count(*)к таблице на текущем состоянии и черезAT (VERSION => N)на ранний снапшот. Проверьте, что time-travel возвращает прошлое состояние. - Вставьте 3-5 строк (меньше порога inlining) и посмотрите на содержимое папки данных — появился ли новый Parquet-файл. Затем выполните
CHECKPOINT lake;и проверьте папку снова. - Удалите часть строк через
DELETE ... WHERE ...и сравните: изменился ли размер существующих Parquet-файлов. Объясните себе, почемуDELETEотработал быстро, хотя Parquet-файлы иммутабельны.