Learning Platform
Глоссарий Troubleshooting
Урок 16.04 · 24 мин
Средний
ducklakeacidtime-traveldeletion-vectors

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;

-- Либо все три таблицы переходят в новое состояние, либо ни одна.
-- Промежуточного несогласованного состояния читатель не увидит.
Атомарность: один коммит каталога против N таблиц
Транзакция каталогаОдин BEGIN/COMMIT в каталожной СУБД. ACID самой СУБД распространяется на все изменения внутри.
атомарно меняет
Метаданные N таблицЗаписи о новых снапшотах fct_sales, dim_customers, etl_log меняются вместе. Либо все, либо ни одна.

Модель изоляции — snapshot isolation. Каждая транзакция видит фиксированный снимок лейкхауса на момент своего старта; параллельные коммиты её не трогают. Конкуренция разрешается оптимистично: если две транзакции пытаются изменить одно и то же, одна коммитится, вторая получает конфликт и повторяется. Это та же snapshot-модель, что у самого DuckDB, только распространённая на лейкхаус.


Snapshots и time-travel

Каждый коммит в DuckLake порождает новый снапшот — именованную точку в истории лейкхауса. Снапшот не копирует данные: это запись в каталоге о том, какой набор data-файлов составляет каждую таблицу в этот момент. У снапшота есть snapshot_id, временная метка и версия схемы.

Так как старые снапшоты остаются в каталоге, а Parquet-файлы иммутабельны и не удаляются при правках, лейкхаус хранит свою историю. Это даёт time-travel — возможность запросить состояние таблицы на любой прошлый снапшот.

История лейкхауса как цепочка снапшотов
snapshot 0Первый коммит: таблица создана. Каталог помнит набор data-файлов этого момента.
INSERT
snapshot 1Добавлены строки. Появились новые Parquet-файлы, старые не тронуты.
UPDATE
snapshot 2Часть строк изменена. Каталог фиксирует новое состояние; снапшоты 0 и 1 по-прежнему доступны для запроса.

Запрос к прошлому делается синтаксисом 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 удаляет снапшоты старше заданного возраста и физически чистит файлы, на которые уже никто не ссылается.

NOTE

Снапшот в 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-файл против inlining
Без inliningКаждая мелкая вставка создаёт крошечный Parquet-файл на объектном хранилище. Тысячи мелких файлов, деградация чтения, нужна compaction.
vs
С data inliningМелкая правка (меньше DATA_INLINING_ROW_LIMIT) пишется строками прямо в каталог. Parquet-файл не создаётся вовсе.

При чтении движок прозрачно объединяет данные из 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).

Удаление строк через deletion vector
Data-файл ParquetИммутабелен. При удалении строк не переписывается и не трогается вообще.
DELETE помечает строки
Deletion vectorRoaring bitmap позиций удалённых строк, лежит в Puffin-файле. Записать дёшево, читается быстро.
при чтении
Видимый результатДвижок читает data-файл и пропускает позиции из deletion vector. Удалённые строки в результат не попадают.

При чтении движок берёт 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.

TIP

Три механизма работают вместе вокруг иммутабельности Parquet. Inlining не даёт мелким вставкам плодить файлы. Deletion vectors не дают удалениям переписывать файлы. CHECKPOINT периодически наводит порядок — материализует inlined-данные и переписывает файлы с большой долей удалений. Иммутабельность data-файлов при этом сохраняется всегда: меняются только каталог и набор файлов, а не содержимое существующих.


Попробуй сам

Понадобится DuckDB 1.5.x с расширением ducklake и созданный в прошлом уроке локальный лейкхаус (каталог SQLite, данные в папке).

Задания:

  1. Сделайте в одной таблице лейкхауса три отдельных INSERT с интервалом, каждый раз проверяя FROM lake.snapshots();. Убедитесь, что каждый коммит — это новый снапшот с новым snapshot_id.
  2. Выполните SELECT count(*) к таблице на текущем состоянии и через AT (VERSION => N) на ранний снапшот. Проверьте, что time-travel возвращает прошлое состояние.
  3. Вставьте 3-5 строк (меньше порога inlining) и посмотрите на содержимое папки данных — появился ли новый Parquet-файл. Затем выполните CHECKPOINT lake; и проверьте папку снова.
  4. Удалите часть строк через DELETE ... WHERE ... и сравните: изменился ли размер существующих Parquet-файлов. Объясните себе, почему DELETE отработал быстро, хотя Parquet-файлы иммутабельны.
Apache Iceberg: snapshot isolation и time-travel
Проверка знанийKnowledge check
Как DuckLake обеспечивает кросс-табличный ACID и time-travel, и какие проблемы решают data inlining и deletion vectors?
ОтветAnswer
Кросс-табличный ACID в DuckLake получается из того, что коммит — это обычная транзакция в каталожной СУБД, а реляционная транзакция атомарна по определению: раз метаданные всех таблиц лежат в одной базе, одна транзакция каталога согласованно меняет снапшоты сразу нескольких таблиц — либо все изменения применяются, либо ни одного. Модель изоляции — snapshot isolation с оптимистичным разрешением конфликтов через повтор. Time-travel работает потому, что каждый коммит порождает новый снапшот (запись в каталоге о наборе data-файлов, без копирования данных), старые снапшоты остаются, а Parquet-файлы иммутабельны; синтаксис AT (VERSION => N) или AT (TIMESTAMP => ...) запрашивает прошлое состояние. Data inlining решает проблему мелких файлов: маленькая правка (меньше DATA_INLINING_ROW_LIMIT, по умолчанию около 10 строк) пишется прямо строками в каталог, а не отдельным крошечным Parquet-файлом; при чтении движок прозрачно объединяет inlined-данные с Parquet, а CHECKPOINT периодически материализует их крупным файлом. Deletion vectors решают проблему дорогого удаления: вместо переписывания иммутабельного Parquet-файла записывается компактная структура (roaring bitmap в Puffin-файле), помечающая удалённые позиции строк; при чтении движок пропускает помеченные строки, поэтому DELETE и UPDATE становятся дешёвыми. Все четыре механизма — следствие архитектуры с метаданными в SQL-каталоге и иммутабельными data-файлами.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Почему DuckLake даёт атомарность сразу по нескольким таблицам, а Iceberg и Delta — нет?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6