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
