Single-file формат: заголовок и magic bytes DUCK
Когда вы выполняете CREATE TABLE в persistent-базе DuckDB, всё — каталог, данные, индексы, статистика — оказывается в одном файле на диске. Никаких сопровождающих директорий, никаких отдельных файлов под каждую таблицу, как в Postgres. Один файл .duckdb, который можно скопировать, переслать, положить в Git LFS или приложить к письму. Эта простота не случайна: она унаследована от SQLite, на чью нишу DuckDB и претендует, только для аналитики вместо транзакций.
Но «один файл» — это не «куча байт без структуры». Внутри лежит строго определённый формат: фиксированный заголовок в начале, блоки фиксированного размера дальше, метаданные, которые связывают всё в граф. Этот урок разбирает самое начало файла — первые несколько килобайт, по которым DuckDB при открытии решает: это вообще наш файл, не повреждён ли он, и какой версией движка он записан.
Зачем вообще нужен заголовок
Представьте, что вы открываете файл и читаете его как базу данных. Первый вопрос — а это точно база DuckDB? Пользователь мог передать вам ZIP-архив, Parquet-файл или просто текст, переименованный в .duckdb. Движку нужен дешёвый способ отличить «свой» файл от чужого, не прочитав его целиком.
Второй вопрос — не побился ли файл. Диски ошибаются, копирование обрывается, сетевые ФС теряют байты. Если movement прочитает мусор как структуру каталога, он либо упадёт с непонятной ошибкой, либо — хуже — отдаст вам неправильные данные. Нужен механизм раннего обнаружения порчи.
Третий вопрос — какой версией движка файл записан. DuckDB 1.5 умеет читать файл, созданный DuckDB 1.2, но не наоборот. Чтобы движок мог принять решение «я понимаю этот формат» или «откажусь и скажу пользователю обновиться», ему нужно прочитать номер версии формата — раньше, чем он попытается интерпретировать структуру.
На все три вопроса отвечает заголовок файла. Он лежит в самом начале и читается за один короткий I/O.
Заголовков на самом деле три: один main header и два database header. Main header статичен — записан один раз при создании базы и больше не меняется. Два database header — это механизм атомарной фиксации, к нему вернёмся в уроке про WAL и checkpoint. Пока запомните: начало файла — это три блока служебной информации, и только потом идут данные.
Magic bytes: четыре буквы DUCK
В main header после поля checksum лежат ровно четыре байта: D, U, C, K — ASCII-коды 0x44 0x55 0x43 0x4B. Это magic bytes — сигнатура формата. При открытии файла DuckDB читает их и сравнивает с константой. Не совпало — движок сразу возвращает ошибку вида «not a valid DuckDB database file», не пытаясь интерпретировать остальное.
Magic bytes — стандартный приём в дизайне бинарных форматов. У Parquet файл начинается и заканчивается на PAR1, у PNG первые байты — \x89PNG, у ELF-исполняемых — \x7fELF. Идея одна: дешёвая проверка типа файла по первым байтам, до полноценного разбора. Стоимость проверки — одно сравнение четырёх байт, а защищает она от целого класса ошибок, когда движок принимает чужой файл за свой.
Посмотрим на это вживую. Создадим базу и заглянем в её первые байты через hexdump.
# Создаём базу с одной таблицей
duckdb demo.duckdb "CREATE TABLE t AS SELECT range AS id FROM range(1000);"
# Смотрим первые 64 байта файла
hexdump -C demo.duckdb | head -4
Вывод:
00000000 fb 1e 9a 5e 7c 03 00 00 44 55 43 4b 44 00 00 00 |...^|...DUCKD...|
00000010 00 00 00 00 1b 00 00 00 00 00 00 00 00 00 00 00 |................|
00000020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
00000030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
Разберём первую строку байт за байтом. Offset 0x00-0x07 — восемь байт fb 1e 9a 5e 7c 03 00 00 — это uint64 checksum заголовка. Offset 0x08-0x0B — 44 55 43 4b — те самые magic bytes DUCK, видны и в правой ASCII-колонке hexdump. Offset 0x0C-0x13 — восемь байт 44 00 00 00 00 00 00 00 — это uint64 storage version. Число записано в little-endian порядке, поэтому младший байт 0x44 идёт первым: 0x44 = 68 в десятичной системе. Версия 68 — это формат DuckDB 1.5.x.
Little-endian означает, что многобайтовое число хранится «младшим байтом вперёд». Число 68 в 64-битном поле — это байты 44 00 00 00 00 00 00 00, а не 00 00 00 00 00 00 00 44. Все целые в storage-формате DuckDB записаны в little-endian, потому что подавляющее большинство современных CPU (x86-64, ARM в обычном режиме) — little-endian, и движок просто копирует байты в память без перестановки.
Checksum: как файл узнаёт о своей порче
Самое первое поле файла — восьмибайтный checksum. Это не криптографическая подпись и не защита от злоумышленника: это защита от случайной порчи. DuckDB считает контрольную сумму по содержимому заголовка и записывает её в начало. При открытии файла движок пересчитывает сумму по тем же байтам и сравнивает с записанной. Не совпало — значит байты изменились с момента записи, файл повреждён, и DuckDB откажется его открывать вместо того, чтобы отдать испорченные данные.
Checksum в DuckDB считается не только для заголовка. Каждый блок данных тоже несёт свою контрольную сумму. Алгоритм — быстрый некриптографический хеш (DuckDB использует вариант семейства, оптимизированный под скорость, а не под стойкость). Выбор в пользу скорости логичен: checksum проверяется при каждом чтении блока, и тяжёлый алгоритм типа SHA-256 замедлил бы сканирование. Задача здесь — поймать «битый бит», а не отбить атаку, поэтому хватает дешёвого хеша.
Почему это важно для аналитика. Аналитический запрос читает миллионы строк из десятков блоков. Если один блок незаметно побился на диске, без checksum вы получите неправильный SUM или потеряете часть строк — и не узнаете об этом. С checksum DuckDB поймает порчу при чтении блока и упадёт с явной ошибкой. Лучше явная ошибка, чем тихо неверный ответ в отчёте.
Storage version: контракт между файлом и движком
Поле storage version — это номер формата, а не версия самого DuckDB. Их легко спутать. Версия DuckDB — 1.5.2, 1.4.4 — меняется с каждым релизом. Storage version — целое число 68, 67, 66 — меняется только тогда, когда меняется сам формат файла на диске. Несколько релизов DuckDB подряд могут писать один и тот же storage version, если в этих релизах формат не трогали.
Соответствие на сегодня: версия 68 — DuckDB 1.5.x, версия 67 — 1.4.x, версия 66 — 1.3.x, версия 65 — 1.2.x, версия 64 — диапазон 0.9.x-1.1.x. Подробно про версии и совместимость — отдельный урок этого модуля; здесь важно понять роль поля. Когда DuckDB 1.5 открывает файл, он читает storage version и спрашивает себя: «я умею читать формат 68? а 67? а 64?» Если умеет — открывает. Если в файле версия из будущего, которой движок не знает, — отказывается с сообщением, что нужно обновить DuckDB.
Это и есть назначение поля: явный контракт между файлом и движком, проверяемый до интерпретации структуры. Без него старый движок попытался бы прочитать новый формат, наткнулся бы на незнакомые структуры и упал бы где-то в середине с невнятной ошибкой. С полем версии отказ происходит сразу, на третьем поле заголовка, с понятным пользователю сообщением.
Узнать версию формата своего файла можно не только через hexdump. У DuckDB есть pragma:
-- Версия формата текущей присоединённой базы
PRAGMA database_size;
Среди полей вывода PRAGMA database_size нет storage version напрямую, но размер базы, число блоков и размер блока он показывает. Точную версию формата файла удобнее всего прочитать ровно так, как мы делали — четыре байта по смещению 0x0C. А вот размер базы в блоках pragma отдаёт сразу:
database_name database_size block_size total_blocks used_blocks free_blocks wal_size memory_usage memory_limit
demo 780.0 KiB 262144 3 2 1 0 bytes ... ...
block_size здесь 262144 байта — это и есть 256 KiB, размер блока по умолчанию. К блокам мы перейдём в следующем уроке.
In-memory против persistent: когда формата нет вообще
Важная оговорка. Всё, что описано выше, относится к persistent-базе — той, что открыта из файла. Если вы запустили DuckDB в режиме in-memory (duckdb без аргумента, или :memory:), никакого файла нет, заголовок не пишется, magic bytes негде хранить. Данные живут только в оперативной памяти процесса и исчезают при его завершении.
Это объясняет одно практическое следствие: in-memory база не сжимается. Колоночное сжатие — часть storage-формата, оно применяется, когда данные пишутся в блоки файла. В памяти данные лежат в «рабочем» виде, удобном для исполнения запросов, а не для компактного хранения. Поэтому одна и та же таблица в файле может занимать в разы меньше, чем она же в in-memory базе. К сжатию вернёмся в следующем модуле; пока зафиксируйте связку: формат файла, заголовок, блоки и сжатие — это всё про persistent-режим.
Попробуй сам
Откройте терминал и проделайте вскрытие файла руками.
- Создайте базу:
duckdb lab.duckdb "CREATE TABLE nums AS SELECT range AS n FROM range(100000);" - Посмотрите первые 32 байта:
hexdump -C lab.duckdb | head -2. Найдите в выводе ASCII-последовательностьDUCKв правой колонке. - Определите смещение magic bytes (должно быть
0x08) и сразу за ними прочитайте восемь байт storage version. Переведите первый ненулевой байт из шестнадцатеричной системы в десятичную — какая это версия формата? Совпадает ли она с вашей версией DuckDB (узнайте её командойduckdb --version)? - Скопируйте файл:
cp lab.duckdb broken.duckdb. Теперь испортите копию — перезапишите один байт внутри заголовка, например черезprintf '\xff' | dd of=broken.duckdb bs=1 seek=2 count=1 conv=notrunc. - Попробуйте открыть испорченный файл:
duckdb broken.duckdb "SELECT count(*) FROM nums;". DuckDB должен отказаться открывать файл из-за несовпадения checksum. Сравните с тем, что оригинальныйlab.duckdbоткрывается нормально.
Этот эксперимент показывает живьём, как заголовок, magic bytes и checksum работают вместе: они превращают «просто файл» в самоописывающийся, самопроверяющийся контейнер.
Parquet: magic bytes PAR1 и заголовок файла