TL;DR: База Firebird повреждена, gfix не помогает, gbak ругается, данные нужны вчера. Написал утилиту на Delphi, которая читает .fdb/.gdb напрямую, минуя движок БД. Разбираем бинарный формат Firebird по байтам.

Предыстория

Если вы работали с Firebird/InterBase, то знаете этот момент: база не открывается, gfix выдаёт ошибки, gbak отказывается делать бэкап. А данные - критичные: продажи через кассу, товарные остатки, отчеты и т.п.

Стандартный путь:

  1. gfix -v -full database.fdb — не помогло

  2. gbak -b -v -ig database.fdb backup.fbk — ошибка на странице N

  3. Гуглим "firebird database recovery tool" - платные утилиты

  4. Плачем

Альтернативный путь: понять как устроен файл 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:

  1. Создаётся новая версия записи с флагом recDeleted

  2. Старая версия остаётся, пока не пройдёт сборка мусора

Если база повреждена до sweep — старые версии всё ещё на месте!

function TRecordParser.IsRecordDeleted(const Header: TRecordHeader): Boolean;
begin
  Result := (Header.Flags and recDeleted) <> 0;
end;

// При восстановлении: игнорируем recDeleted, читаем предыдущие версии

Собираем все вместе

Алгоритм восстановления:

  1. Читаем Header Page → получаем page_size, ODS version

  2. Сканируем все страницы, классифицируем по типу

  3. Для каждой Data Page:

    • Читаем dpg_relation → определяем таблицу

    • Извлекаем фрагменты записей

    • Парсим заголовки записей

    • Фильтруем по TransactionID (используя TIP)

  4. Склеиваем фрагменты в полные записи

  5. Экспортируем данные

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 выдаёт ошибку на конкретной странице.

Диагностика:

  1. Открываем базу утилитой

  2. Переходим на проблемную страницу

  3. Смотрим тип и содержимое

Часто проблема - битый заголовок страницы. Если данные целы, можно:

  • Исправить pag_type

  • Пересчитать/обнулить checksum

  • Обнулить pag_generation

Кейс 2: Огромный gap транзакций

Симптомы: база занимает 50GB, данных на 5GB.

Диагностика:

  1. Смотрим Header Page: oldest_transaction vs next_transaction

  2. Сканируем TIP: ищем Active и Limbo транзакции

  3. Находим зависшую транзакцию

Решение: если gfix не работает - вручную меняем состояние транзакции в TIP с Active на Dead.

Кейс 3: Нужны данные из удалённой таблицы

Если DROP TABLE был недавно и sweep не прошёл:

  1. Сканируем все Data Pages

  2. Группируем по dpg_relation (ID таблицы)

  3. Извлекаем записи, игнорируя флаг recDeleted

  4. Экспортируем в CSV

Ограничения и TODO

Что реализовано:

  • ✅ Чтение всех типов страниц

  • ✅ Анализ транзакций

  • ✅ Извлечение фрагментов записей

  • ✅ Модификация флагов БД

Что в планах:

  • ⏳ Полная реализация MVCC (склейка версий)

  • ⏳ Экспорт в CSV/новую базу

  • ⏳ Восстановление BLOB-цепочек

  • ⏳ Парсинг B-Tree индексов

Выводы

Firebird — достаточно простой формат для reverse engineering. Документация есть (частично), исходники открыты.

Когда стандартные утилиты не работают — прямой доступ к файлу может спасти данные. Главное — сделать копию перед любыми экспериментами.

GitHub: FirebirdRepairBase

Полезные ресурсы: