WAL и checkpointing
До сих пор мы разбирали файл DuckDB как статичную структуру: заголовок, блоки, row groups, сегменты. Но база меняется — в неё пишут. И вот тут возникает фундаментальная проблема надёжности: что, если процесс упадёт прямо посреди записи? Питание отключилось, ОС убила процесс, программа крашнулась. На диске останется наполовину записанное состояние. Этот урок про механизм, который превращает «наполовину записанное» в «либо всё, либо ничего» — про write-ahead log и checkpointing. И про то, как DuckDB 1.5 убрал паузу, которую checkpoint раньше вносил в работу базы.
Проблема: запись не атомарна
Когда вы делаете INSERT или UPDATE, изменения в идеале должны попасть в блоки файла. Но обновление блоков — операция не мгновенная и не атомарная. Транзакция может затронуть много блоков: данные в одних, метаданные каталога в других, free list в третьих. Записать их на диск — это серия операций ввода-вывода. Между первой и последней из них процесс может умереть.
Если он умрёт в этот момент, файл окажется в противоречивом состоянии: часть блоков транзакции обновлена, часть — нет. Указатель в каталоге уже ведёт на новые данные, а самих данных ещё нет. Или строки записаны, а статистика старая. Такой файл нельзя корректно прочитать — он не отражает ни состояние «до транзакции», ни «после».
Транзакция обязана быть атомарной — это буква A в ACID: либо она применилась целиком, либо не применилась вовсе, промежуточного состояния на диске быть не должно.
ACID на пальцах: что именно обещает СУБДDelta Lake: архитектура Transaction Log Просто «писать аккуратно» эту гарантию не даёт: момент сбоя выбираете не вы. Нужен отдельный механизм.
WAL: сначала запиши намерение
Решение — write-ahead log, WAL. По-русски — журнал упреждающей записи. Принцип в названии: ahead, «вперёд», означает, что запись в журнал происходит раньше, чем изменение основного файла.
Работает так. Когда транзакция фиксируется (COMMIT), DuckDB не бросается сразу перекраивать блоки основного файла. Вместо этого он дописывает в конец WAL-файла компактную запись: «вот что сделала эта транзакция» — вставленные строки, удаления, изменения схемы. WAL — это отдельный файл рядом с базой, обычно с расширением .wal. Запись в него — это дозапись в конец, append. Append — самая дешёвая и самая надёжная форма записи на диск: ничего не перемещается, не перезаписывается, просто файл подрастает с конца.
COMMIT считается завершённым, как только запись о транзакции надёжно легла в WAL. Основной файл базы в этот момент ещё не тронут — он отражает старое состояние. Но это уже безопасно, и вот почему. WAL пишется последовательно и его запись либо успела целиком (запись транзакции в журнале есть), либо нет (записи нет). Состояния «полузаписанная транзакция в журнале» система не считает зафиксированным.
Восстановление после сбоя через WAL
Теперь сценарий аварии. База открыта, прошло несколько COMMIT, их записи легли в WAL, основной файл пока обновить не успели — и тут процесс умирает.
При следующем открытии файла DuckDB первым делом проверяет WAL. Если в журнале есть записи о транзакциях, которых нет в основном файле, движок их проигрывает заново — последовательно применяет к основному файлу всё, что записано в WAL. Этот процесс называется recovery, восстановление. После проигрывания WAL основной файл доведён до состояния «все зафиксированные транзакции применены», и база открывается консистентной.
Так WAL даёт durability — букву D в ACID, долговечность: если COMMIT вернул успех, изменения переживут сбой, потому что они уже в журнале, и recovery их докатит. И атомарность: транзакция, чья запись не успела целиком лечь в WAL, при recovery просто игнорируется — будто её и не было. Полузаписанного состояния не возникает: либо запись в журнале целая и транзакция докатывается, либо записи нет и транзакции нет.
Ключевая мысль: durability обеспечивает не основной файл, а WAL. Основной файл может временно отставать от реального состояния базы — недостающее всегда лежит в журнале.
Checkpoint: слияние WAL в основной файл
Если бы WAL только рос, он раздулся бы до огромных размеров, а каждое открытие базы означало бы проигрывание всей истории изменений с начала времён. Нужен механизм, который периодически переносит накопленное из WAL в основной файл и обнуляет журнал. Этот механизм — checkpoint.
Checkpoint делает три вещи. Он применяет все накопленные в WAL изменения к блокам основного файла — данные ложатся в колоночные сегменты, обновляются метаданные каталога. Он переключает один из двух database header (помните, в первом уроке их было два?) на новое, актуальное состояние — атомарным переключением: пока пишется новое состояние, старый header остаётся валидным, и только в самом конце происходит переход. И он очищает WAL — после успешного checkpoint журнал больше не нужен, потому что всё, что в нём было, теперь в основном файле.
После checkpoint основной файл самодостаточен: он содержит полное актуальное состояние базы, WAL пуст, открытие не требует recovery.
Checkpoint случается не только по явной команде CHECKPOINT. DuckDB запускает его автоматически, когда WAL вырастает сверх порога (checkpoint_threshold, по умолчанию около 16 MiB), и при штатном закрытии базы. Явная команда CHECKPOINT нужна, когда вы хотите гарантированно слить WAL прямо сейчас — например, перед копированием файла базы или перед измерением «честного» размера на диске.
-- Порог автоматического checkpoint
SELECT current_setting('checkpoint_threshold');
-- 16.0 MiB
-- Принудительно слить WAL в основной файл прямо сейчас
CHECKPOINT;
Размер WAL виден в выводе PRAGMA database_size — поле wal_size. Сразу после CHECKPOINT или после штатного закрытия и открытия базы оно равно нулю: журнал пуст. Если же база сейчас активно принимает записи, wal_size будет ненулевым — это нормально и означает лишь, что часть зафиксированных изменений пока живёт в журнале и ещё не слита в основной файл.
Non-blocking checkpoint в 1.5: checkpoint без паузы
До версии 1.5 у checkpoint был неприятный побочный эффект — он блокировал. Пока шёл checkpoint, конкурентная запись в базу была невозможна: транзакции, желающие что-то изменить, ждали завершения checkpoint. На большой базе с активной записью это означало заметные паузы: чем больше накопилось в WAL, тем дольше checkpoint, тем длиннее окно, в котором запись стоит.
DuckDB 1.5 ввёл non-blocking checkpointing — неблокирующий checkpoint. Теперь checkpoint выполняется конкурентно с обычной работой базы: и чтения, и записи продолжаются, пока checkpoint сливает WAL в основной файл. Паузы на запись больше нет.
Идея, лежащая в основе, опирается на MVCC и snapshot-изоляцию (детально — в модуле про транзакции). Грубо: checkpoint работает с консистентным снимком состояния на момент своего старта, а транзакции, пришедшие во время checkpoint, пишут поверх, не мешая ему. Checkpoint не пытается «заморозить» базу — он переносит зафиксированный снимок, а новые изменения накапливаются параллельно и попадут в следующий checkpoint.
Эффект измерим. По замерам DuckDB на бенчмарке TPC-H масштаба SF100 неблокирующий checkpoint дал прирост пропускной способности порядка 17% на нагрузке со смешанными чтениями и записями — ровно за счёт того, что записи больше не простаивают в окне checkpoint. Для практики это означает: на DuckDB 1.5 базу можно держать под постоянной пишущей нагрузкой, и фоновый checkpoint не будет периодически её подвешивать.
| Аспект | До 1.5 (blocking) | С 1.5 (non-blocking) |
|---|---|---|
| Запись во время checkpoint | Блокируется, транзакции ждут | Продолжается параллельно |
| Чтение во время checkpoint | Разрешено | Разрешено |
| Пауза на больших базах | Заметная, растёт с размером WAL | Отсутствует |
| Эффект на throughput | Просадка в окне checkpoint | До +17% на TPC-H SF100 (смешанная нагрузка) |
Попробуй сам
Понаблюдайте за WAL и checkpoint руками.
- Создайте базу:
duckdb wal.duckdb. Сразу посмотритеPRAGMA database_size;— полеwal_sizeдолжно быть нулевым. - В той же сессии вставьте данные несколькими отдельными транзакциями: например, трижды выполните
INSERT INTO ...послеCREATE TABLE log AS SELECT range AS n FROM range(100000);. Не закрывая сессию, снова посмотритеwal_size. Изменился ли он? - В отдельном терминале (пока первая сессия открыта) посмотрите файлы на диске:
ls -la wal.duckdb*. Виден ли рядом файлwal.duckdb.wal? Каков его размер? - Вернитесь в сессию DuckDB, выполните
CHECKPOINT;, снова посмотритеwal_sizeи сноваls -la wal.duckdb*в другом терминале. Что стало с.wal-файлом? - Смоделируйте «сбой»: вставьте данные новой транзакцией, НЕ делайте
CHECKPOINT, и завершите процесс DuckDB жёстко (закрытие через.quitштатное — оно делает checkpoint; для эксперимента можно убить процесс из другого терминала). Затем снова откройтеwal.duckdbи проверьтеSELECT count(*) FROM log;— данные на месте? Откуда они восстановились? - Посмотрите текущее значение
checkpoint_thresholdчерезSELECT current_setting('checkpoint_threshold');и прикиньте, сколько примерно нужно вставить, чтобы автоматический checkpoint сработал сам.
Этот эксперимент показывает живьём: WAL растёт при записи, обнуляется при checkpoint, и именно он спасает данные, если процесс умер до checkpoint.