TL;DR: База Firebird повреждена, gfix не помогает, gbak ругается, данные нужны вчера. Написал утилиту на Delphi, которая читает .fdb/.gdb напрямую, минуя движок БД. Разбираем бинарный формат Firebird по байтам.
Предыстория
Если вы работали с Firebird/InterBase, то знаете этот момент: база не открывается, gfix выдаёт ошибки, gbak отказывается делать бэкап. А данные - критичные: продажи через кассу, товарные остатки, отчеты и т.п.
Стандартный путь:
gfix -v -full database.fdb— не помоглоgbak -b -v -ig database.fdb backup.fbk— ошибка на странице NГуглим "firebird database recovery tool" - платные утилиты
Плачем
Альтернативный путь: понять как устроен файл Firebird изнутри и вытащить данные напрямую.
Как устроен файл Firebird
Firebird хранит всё в одном файле (или нескольких, для multi-file баз). Файл разбит на страницы фиксированного размера: 4096, 8192 или 16384 байт.
┌─────────────────────────────────────────┐ │ Page 0: Header Page │ ← Метаданные БД ├─────────────────────────────────────────┤ │ Page 1: Page Inventory Page (PIP) │ ← Карта свободных страниц ├─────────────────────────────────────────┤ │ Page 2: Transaction Inventory Page (TIP)│ ← Состояния транзакций ├─────────────────────────────────────────┤ │ Page 3: Pointer Page │ ← Указатели на Data Pages ├─────────────────────────────────────────┤ │ Page 4-N: Data/Index/Blob Pages │ ← Собственно данные └─────────────────────────────────────────┘
Каждая страница начинается со стандартного заголовка — 16 байт:
TPag = packed record pag_type: SChar; // 0x00 - Тип страницы pag_flags: UChar; // 0x01 - Флаги pag_checksum: UShort; // 0x02 - Контрольная сумма (не используется) pag_generation: ULong; // 0x04 - Номер поколения pag_scn: ULong; // 0x08 - SCN (для WAL) reserved: ULong; // 0x0C - Зарезервировано end;
Типы страниц:
Код | Тип | Назначение |
|---|---|---|
0x01 | Header | Метаданные базы данных |
0x02 | PIP | Page Inventory — карта свободных страниц |
0x03 | TIP | Transaction Inventory — состояния транзакций |
0x04 | Pointer | Указатели на Data Pages для таблицы |
0x05 | Data | Данные записей |
0x06 | Index Root | Корень индекса |
0x07 | Index B-Tree | Узел B-дерева индекса |
0x08 | Blob | Данные BLOB-полей |
0x09 | Generator | Значения генераторов/sequences |
Header Page — паспорт базы данных
Страница 0 всегда Header Page. Здесь хранится все критичное:
THdr = packed record hdr_header: TPag; // Стандартный заголовок hdr_page_size: UShort; // Размер страницы (4096/8192/16384) hdr_ods_version: UShort; // Major ODS version hdr_pages: SLong; // Указатель на первую Pointer Page hdr_next_page: ULong; // Следующий файл (multi-file DB) hdr_oldest_transaction: SLong; // Oldest Interesting Transaction hdr_oldest_active: SLong; // Oldest Active Transaction hdr_next_transaction: SLong; // Next Transaction ID hdr_sequence: UShort; // Номер файла в цепочке hdr_flags: UShort; // Флаги базы данных // ... end;
ODS Version — совместимость
ODS (On-Disk Structure) — версия формата файла:
ODS 10.x - Firebird 1.5
ODS 11.x - Firebird 2.x
ODS 12.x - Firebird 3.x
ODS 13.x - Firebird 4.x
Если ODS не совпадает с версией сервера - база не откроется. Но напрямую читать можно.
Transaction Counters — почему база "распухает"
Три магических числа:
oldest_transaction — самая старая интересная транзакция
oldest_active — самая старая активная транзакция
next_transaction — следующий ID транзакции
Разница между next_transaction и oldest_active — это "gap". Чем больше gap, тем больше мусора в базе. Если gap огромный — скорее всего, есть зависшая транзакция.
Флаги базы данных
TDatabaseFlags = record ActiveShadow: Boolean; // 0x01 — есть активный shadow ForceWrite: Boolean; // 0x02 — синхронная запись NoChecksums: Boolean; // 0x10 — не считать checksum NoReserve: Boolean; // 0x20 — не резервировать место SqlDialect3: Boolean; // 0x100 — SQL Dialect 3 ReadOnly: Boolean; // 0x200 — только чтение end;
Иногда достаточно сбросить флаг ReadOnly или ForceWrite чтобы база "ожила":
procedure TFlagManager.SetForceWrite(Value: Boolean); var CurrentFlags: TDatabaseFlags; begin CurrentFlags := GetFlags(); CurrentFlags.ForceWrite := Value; SetFlags(CurrentFlags); // Пишем напрямую в файл end;
Transaction Inventory Page (TIP) — кто жив, кто мёртв
TIP хранит состояние каждой транзакции. На каждую транзакцию - 2 бита:
Биты | Состояние | Значение |
|---|---|---|
00 | Active | Транзакция выполняется |
01 | Limbo | Зависла (2PC) |
10 | Dead | Откачена (rollback) |
11 | Committed | Зафиксирована |
function TTIPAnalyzer.GetStateFromBits(Bits: Byte): TTransactionState; begin case (Bits and $03) of $00: Result := tsActive; $01: Result := tsLimbo; $02: Result := tsDead; $03: Result := tsCommitted; end; end;
В одном байте - 4 транзакции. На странице 8KB помещается ~32000 транзакций.
Limbo-транзакции — главная головная боль
Limbo возникает при двухфазном коммите (2PC), когда координатор упал между PREPARE и COMMIT. Такие транзакции блокируют сборку мусора навечно.
Стандартное решение: gfix -commit_limbo или -rollback_limbo. Но если база побита — gfix не работает.
Решение: найти TIP-страницы, посчитать limbo-транзакции, при необходимости - исправить биты вручную.
function TTIPAnalyzer.GetTransactionInfo(...): TTransactionInfoArray; var ByteIdx, BitPairIdx: Integer; ByteValue: Byte; begin // 4 транзакции на байт ByteIdx := (TxID div 4); BitPairIdx := (TxID mod 4) * 2; ByteValue := FTipData[ByteIdx]; State := GetStateFromBits(ByteValue shr BitPairIdx); end;
Data Page - где живут записи
Data Page хранит фрагменты записей таблицы:
TData_Page_Fixed_Part = packed record pagHdr_Header: TPag; // Стандартный заголовок (16 байт) dpg_sequence: Longint; // Порядковый номер страницы для таблицы dpg_relation: Word; // ID таблицы (relation) dpg_count: Word; // Количество фрагментов на странице end;
После заголовка идёт массив указателей на фрагменты:
TDpg_rpt = packed record dpg_offset: Word; // Смещение от начала страницы dpg_length: Word; // Длина фрагмента end;
Структура страницы:
┌────────────────────────────────────┐ │ Header (24 байта) │ ├────────────────────────────────────┤ │ dpg_rpt[0]: offset=2048, len=128 │ │ dpg_rpt[1]: offset=2176, len=256 │ │ dpg_rpt[2]: offset=2432, len=64 │ │ ... │ ├────────────────────────────────────┤ │ [свободное пространство] │ ├────────────────────────────────────┤ │ Fragment 2 data (64 байта) │ ← Данные растут снизу вверх │ Fragment 1 data (256 байт) │ │ Fragment 0 data (128 байт) │ └────────────────────────────────────┘
Извлечение фрагментов:
function ExtractDataFragments(const PageBuffer: TBytes; PageSize: Integer): TRecordFragmentsArray; var Header: TData_Page_Fixed_Part; FragmentRec: TDpg_rpt; begin Header := GetDataPageHeader(PageBuffer); SetLength(Result, Header.dpg_count); for i := 0 to Header.dpg_count - 1 do begin // Читаем указатель Move(PageBuffer[24 + i * 4], FragmentRec, 4); // Копируем данные фрагмента Result[i].Offset := FragmentRec.dpg_offset; SetLength(Result[i].Data, FragmentRec.dpg_length); Move(PageBuffer[FragmentRec.dpg_offset], Result[i].Data[0], FragmentRec.dpg_length); end; end;
Record Header — MVCC под капотом
Каждый фрагмент записи начинается с заголовка:
TRecordHeader = record Flags: UShort; // Флаги записи TransactionID: SLong; // ID транзакции, создавшей версию BackPointer: SLong; // Указатель на предыдущую версию NextPointer: SLong; // Указатель на следующую версию Format: UShort; // Номер формата записи DataLength: UShort; // Длина данных end;
Флаги записи:
const recDeleted = $01; // Запись удалена recModified = $02; // Запись изменена recHasNulls = $08; // Есть NULL-значения recVersioned = $10; // Есть другие версии (MVCC)
Почему удаленные записи можно восстановить
Firebird не удаляет данные сразу. При DELETE:
Создаётся новая версия записи с флагом
recDeletedСтарая версия остаётся, пока не пройдёт сборка мусора
Если база повреждена до sweep — старые версии всё ещё на месте!
function TRecordParser.IsRecordDeleted(const Header: TRecordHeader): Boolean; begin Result := (Header.Flags and recDeleted) <> 0; end; // При восстановлении: игнорируем recDeleted, читаем предыдущие версии
Собираем все вместе
Алгоритм восстановления:
Читаем Header Page → получаем
page_size, ODS versionСканируем все страницы, классифицируем по типу
Для каждой Data Page:
Читаем
dpg_relation→ определяем таблицуИзвлекаем фрагменты записей
Парсим заголовки записей
Фильтруем по TransactionID (используя TIP)
Склеиваем фрагменты в полные записи
Экспортируем данные
procedure TfrmMain.checkDB; var PageStats: TPageTypeStats; TxStats: TTransactionStats; begin FDatabaseStats.CalculateStats; PageStats := FDatabaseStats.PageStats; // Статистика по типам страниц lstLog.Items.Add('Header Pages: ' + IntToStr(PageStats.HeaderPages)); lstLog.Items.Add('PIP Pages: ' + IntToStr(PageStats.PipPages)); lstLog.Items.Add('TIP Pages: ' + IntToStr(PageStats.TipPages)); lstLog.Items.Add('Data Pages: ' + IntToStr(PageStats.DataPages)); lstLog.Items.Add('Index Pages: ' + IntToStr(PageStats.IndexBtreePages)); lstLog.Items.Add('Blob Pages: ' + IntToStr(PageStats.BlobPages)); // Статистика транзакций TxStats := FDatabaseStats.TransactionStats; lstLog.Items.Add('Active: ' + IntToStr(TxStats.Active)); lstLog.Items.Add('Limbo: ' + IntToStr(TxStats.Limbo)); // ← Проблема! lstLog.Items.Add('Dead: ' + IntToStr(TxStats.Dead)); lstLog.Items.Add('Committed: ' + IntToStr(TxStats.Committed)); end;
Практические кейсы
Кейс 1: База не открывается после сбоя питания
Симптомы: gfix -v выдаёт ошибку на конкретной странице.
Диагностика:
Открываем базу утилитой
Переходим на проблемную страницу
Смотрим тип и содержимое
Часто проблема - битый заголовок страницы. Если данные целы, можно:
Исправить
pag_typeПересчитать/обнулить checksum
Обнулить
pag_generation
Кейс 2: Огромный gap транзакций
Симптомы: база занимает 50GB, данных на 5GB.
Диагностика:
Смотрим Header Page:
oldest_transactionvsnext_transactionСканируем TIP: ищем Active и Limbo транзакции
Находим зависшую транзакцию
Решение: если gfix не работает - вручную меняем состояние транзакции в TIP с Active на Dead.
Кейс 3: Нужны данные из удалённой таблицы
Если DROP TABLE был недавно и sweep не прошёл:
Сканируем все Data Pages
Группируем по
dpg_relation(ID таблицы)Извлекаем записи, игнорируя флаг
recDeletedЭкспортируем в CSV
Ограничения и TODO
Что реализовано:
✅ Чтение всех типов страниц
✅ Анализ транзакций
✅ Извлечение фрагментов записей
✅ Модификация флагов БД
Что в планах:
⏳ Полная реализация MVCC (склейка версий)
⏳ Экспорт в CSV/новую базу
⏳ Восстановление BLOB-цепочек
⏳ Парсинг B-Tree индексов
Выводы
Firebird — достаточно простой формат для reverse engineering. Документация есть (частично), исходники открыты.
Когда стандартные утилиты не работают — прямой доступ к файлу может спасти данные. Главное — сделать копию перед любыми экспериментами.
GitHub: FirebirdRepairBase
Полезные ресурсы:
Firebird Internals — документация по внутренней структуре
Firebird Source Code — исходники, особенно
src/jrd/ods.h
