Learning Platform
Глоссарий Troubleshooting
Урок 07.01 · 22 мин
Средний
storage-formatfile-layoutinternals

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.

Первые байты файла .duckdb
Main headerСамый первый блок. Содержит uint64 checksum, 4 magic bytes DUCK, uint64 storage version и флаги. Читается первым при открытии файла.
дальше
Database header 1Первая из двух копий указателей на актуальное состояние базы: метаблоки каталога, free list, WAL. Используется поочерёдно с header 2.
Database header 2Вторая копия. DuckDB пишет новое состояние в неактивный header, потом атомарно переключается — старый header остаётся валидным до конца записи.
Блоки данныхВсё остальное: блоки по 256 KiB с данными таблиц, метаданными каталога, статистикой. Начинаются после трёх заголовков.

Заголовков на самом деле три: один 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-0x0B44 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.

TIP

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 поймает порчу при чтении блока и упадёт с явной ошибкой. Лучше явная ошибка, чем тихо неверный ответ в отчёте.

Проверка checksum при открытии файла
Читаем заголовокДвижок читает первые байты файла: записанный checksum плюс содержимое, по которому он считался.
пересчёт
Считаем хешБыстрый некриптографический хеш по тем же байтам содержимого, которые покрывались checksum при записи.
сравнение
РешениеСовпало — файл цел, открываем. Не совпало — порча, возвращаем ошибку и не отдаём данные.

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-режим.

Попробуй сам

Откройте терминал и проделайте вскрытие файла руками.

  1. Создайте базу: duckdb lab.duckdb "CREATE TABLE nums AS SELECT range AS n FROM range(100000);"
  2. Посмотрите первые 32 байта: hexdump -C lab.duckdb | head -2. Найдите в выводе ASCII-последовательность DUCK в правой колонке.
  3. Определите смещение magic bytes (должно быть 0x08) и сразу за ними прочитайте восемь байт storage version. Переведите первый ненулевой байт из шестнадцатеричной системы в десятичную — какая это версия формата? Совпадает ли она с вашей версией DuckDB (узнайте её командой duckdb --version)?
  4. Скопируйте файл: cp lab.duckdb broken.duckdb. Теперь испортите копию — перезапишите один байт внутри заголовка, например через printf '\xff' | dd of=broken.duckdb bs=1 seek=2 count=1 conv=notrunc.
  5. Попробуйте открыть испорченный файл: duckdb broken.duckdb "SELECT count(*) FROM nums;". DuckDB должен отказаться открывать файл из-за несовпадения checksum. Сравните с тем, что оригинальный lab.duckdb открывается нормально.

Этот эксперимент показывает живьём, как заголовок, magic bytes и checksum работают вместе: они превращают «просто файл» в самоописывающийся, самопроверяющийся контейнер.

Parquet: magic bytes PAR1 и заголовок файла
Проверка знанийKnowledge check
Зачем в начале файла .duckdb одновременно нужны и magic bytes DUCK, и checksum, и storage version — почему одного поля недостаточно?
ОтветAnswer
Эти три поля решают три разные задачи, и ни одно не заменяет другое. Magic bytes DUCK отвечают на вопрос "это вообще файл DuckDB?" — дешёвая проверка типа файла по сигнатуре, отсекающая чужие файлы до разбора структуры. Checksum отвечает на вопрос "файл не повреждён?" — пересчитывается при открытии и сравнивается с записанным значением; не совпало значит байты изменились, файл побит, и движок откажется отдавать данные вместо тихо неверного ответа. Storage version отвечает на вопрос "какой версией формата записан файл?" — целое число (68 для 1.5.x, 67 для 1.4.x и так далее), по которому движок решает, понимает ли он этот формат, и отказывается с понятной ошибкой, если версия из будущего. Magic bytes не ловят порчу содержимого, checksum не говорит о версии формата, version не проверяет целостность — поэтому нужны все три, и все три читаются одним коротким I/O в самом начале файла.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что находится в первых четырёх байтах после поля checksum в заголовке файла .duckdb?

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

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

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

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