1

Quando o gfix não resolve: lendo um banco Firebird corrompido na unha

TL;DR: O banco Firebird corrompeu, o gfix não resolve, o gbak reclama, e os dados eram para ontem. Escrevi uma ferramenta de código aberto que lê o arquivo .fdb direto, sem o motor do Firebird, e neste texto mostro como o arquivo é por dentro e como dá para tirar os dados de lá na unha.

Aviso: português não é a minha língua nativa (sou desenvolvedor russo). Se alguma frase soar estranha, me corrijam nos comentários.

Se você já trabalhou com Firebird ou InterBase — e no Brasil muito ERP, sistema de automação comercial e software de clínica roda nele —, conhece este momento: o banco não abre, o gfix mostra erros, o gbak não termina o backup, e o cliente está ligando.

O caminho padrão:

  1. gfix -v -full — erros de validação.
  2. gfix -mend -full -ignore — "agora vai".
  3. gbak -b -ig -g — Exiting before completion due to errors.
  4. Google — ferramenta paga por banco ou "serviço de recuperação".
  5. Choramos.

O caminho alternativo: entender como o Firebird guarda os dados no disco e ler o arquivo diretamente. O formato não é segredo — o código do Firebird é aberto —, só não é muito documentado.

Contexto

A primeira versão dessa ferramenta eu escrevi em Delphi, para um caso concreto, e publiquei no Habr (o "TabNews russo"). Ela resolvia o meu caso, mas era Windows-only, sem testes e cheia de suposições sobre o formato que eu nunca tinha conferido.

Então reescrevi do zero em Go. O resultado se chama fbrescue: um executável sem dependências para Windows, Linux e macOS, que lê bancos do Firebird 2.5 ao 5.0 e nunca grava no arquivo. E, o mais importante, cada suposição sobre o formato agora é testada contra bancos criados pelos motores reais do Firebird — sobre isso falo no fim.

Como é o arquivo do Firebird

Um banco Firebird é um arquivo dividido em páginas de tamanho fixo (4, 8, 16 ou 32 KB). Cada página começa com um cabeçalho, e o primeiro byte diz o tipo:

TipoPáginaPara que serve
1headero "passaporte" do banco: tamanho de página, versão do formato (ODS), contadores de transações
2PIPpage inventory: quais páginas estão livres
3TIPtransaction inventory: o estado de cada transação
4pointera lista das páginas de dados de uma tabela
5dataos registros
6, 7index root, indexíndices (B-tree)
8blobBLOBs grandes
9generatorvalores dos generators

Um detalhe que depois salva a vida: a partir do Firebird 3 (ODS 12), cada página guarda o próprio número no offset 0x0C. No Firebird 2.5 (ODS 11), no offset 0x02 fica um "checksum" que é sempre 12345. Com essas duas assinaturas dá para descobrir o tamanho de página mesmo quando a página de cabeçalho está destruída: basta testar 4K, 8K, 16K, 32K e ver em qual delas as páginas "se reconhecem".

TIP — quem está vivo, quem está morto

O Firebird é MVCC: uma linha pode ter várias versões, e qual delas uma consulta enxerga depende do estado da transação que gravou cada versão. Esse estado fica nas páginas TIP, dois bits por transação:

BitsEstado
0active
1limbo
2dead (rollback)
3committed

Numa página de 4K cabem (4096 − 20) × 4 = 16 304 transações. E tem uma regra útil: toda transação mais antiga que a OIT (oldest interesting transaction, que está no cabeçalho) está confirmada por definição. Ou seja, mesmo com a TIP destruída, para a maior parte das linhas dá para saber qual versão vale.

Data page — onde moram os registros

A página de dados tem a tabela a que pertence (0x14), a sua posição na tabela (0x10) e uma lista de slots: offset e tamanho de cada registro. Cada registro começa com um cabeçalho de 13 bytes:

transaction  u32   quem gravou esta versão
back page    u32   onde está a versão anterior
back line    u16
flags        u16   0x01 apagado, 0x02 versão antiga, 0x04 fragmento,
                   0x08 continua em outra página, 0x20 versão anterior guardada como diferença,
                   0x800 não comprimido (Firebird 5)
format       u8    qual layout da tabela usar

Depois do cabeçalho vêm os dados, comprimidos com um RLE bem simples: um byte de controle positivo n significa "copie os próximos n bytes"; negativo significa "repita o próximo byte −n vezes". Simplificado, em Go:

func unpack(src []byte) []byte {
	var out []byte
	for i := 0; i < len(src); {
		n := int(int8(src[i]))
		i++
		switch {
		case n > 0: // n bytes literais
			out = append(out, src[i:i+n]...)
			i += n
		case n < 0: // um byte repetido -n vezes
			out = append(out, bytes.Repeat([]byte{src[i]}, -n)...)
			i++
		}
	}
	return out
}

O Firebird 5 mudou isso um pouco: −1 passou a significar "o tamanho da repetição vem nos próximos 2 bytes", e registros que não comprimem são guardados como estão, com a flag 0x800. Quem escreveu um leitor para o 3.0 e testou só nele leva um susto aqui.

O format aponta para RDB$FORMATS: a cada ALTER TABLE nasce um formato novo, e os registros antigos continuam no formato antigo até serem regravados. Por isso uma tabela alterada sete vezes pode ter registros em sete layouts diferentes no mesmo arquivo — e cada um precisa ser lido com o seu.

Por que linhas apagadas podem ser recuperadas

Um DELETE não apaga nada fisicamente. Ele grava uma nova versão "toco" com a flag 0x01, que aponta (back page/back line) para a versão anterior — a linha inteira. Um UPDATE faz o mesmo, e muitas vezes guarda a versão antiga só como diferença em relação à nova (flag 0x20).

Tudo isso fica no disco até a coleta de lixo passar. Então, se alguém rodou um DELETE sem WHERE e ninguém mexeu no banco depois, as linhas ainda estão lá. O fbrescue exporta isso com --include-deleted (linhas apagadas em arquivo separado) e --versions all (todas as versões de cada linha, com a transação e o estado dela).

Juntando tudo

O algoritmo de leitura de um banco saudável:

  1. Página 0 → tamanho de página, ODS, OIT.
  2. RDB$PAGES → onde estão as páginas TIP e as páginas de ponteiros de cada tabela.
  3. RDB$RELATIONS, RDB$RELATION_FIELDS, RDB$FORMATS → tabelas, colunas e layouts.
  4. Para cada tabela: páginas de ponteiros → páginas de dados → registros.
  5. Para cada registro: percorre a cadeia de versões e pega a primeira confirmada.
  6. Descomprime, aplica o layout do formato, converte os valores.

Detalhe divertido: as tabelas de sistema (RDB$...) são tabelas comuns, guardadas do mesmo jeito que as suas. Para ler o catálogo você já precisa saber ler registros.

Quando a estrutura quebra

Aqui está a parte que dá trabalho de verdade. O gbak lê pelo motor, e o motor confia na estrutura: se a página de ponteiros de uma tabela está zerada, para ele a tabela inteira sumiu. Mas os dados quase sempre continuam no arquivo:

  • Página de ponteiros perdida: cada página de dados sabe a que tabela pertence e qual a sua posição. Uma passada pelo arquivo inteiro encontra as páginas "órfãs" e preenche as lacunas.
  • Página fora do lugar (cópia malfeita, reparo do sistema de arquivos): no Firebird 3+ a página diz qual é o seu número, então é lida de onde está — e nunca é lida como se fosse outra.
  • TIP danificada: transações abaixo da OIT estão confirmadas; para as mais novas cujo estado se perdeu, vale a versão mais nova, com um aviso de quantas linhas foram decididas assim.
  • Cabeçalho destruído: o tamanho de página vem das assinaturas acima; a versão do formato, de testar qual ODS faz os registros de RDB$RELATIONS terem os nomes certos (RDB$PAGES, RDB$RELATIONS, ...).
  • Catálogo danificado: tabelas sem nome saem como TABLE_131, colunas como FIELD_2. Se até os layouts se perderam, você passa o script CREATE TABLE do banco (--ddl schema.sql) e o fbrescue calcula o layout pelas regras de alinhamento do motor.

Caso prático

Peguei um banco de teste de uma "loja" no Firebird 5.0 — CUSTOMERS (2 000 linhas), ORDERS (5 000) e ITEMS (14 230) — e, numa cópia, zerei duas páginas: a página de ponteiros de ORDERS e uma das suas páginas de dados. Coisa que um disco com defeito faz sem pedir licença.

O caminho padrão, com o próprio Firebird 5.0:

$ gfix -mend -full -ignore loja-danificada.fdb
$ gbak -b -ig -g loja-danificada.fdb loja.fbk
gbak: ERROR:database file appears corrupt (loja-danificada.fdb)
gbak: ERROR:    wrong page type
gbak: ERROR:    page 286 is of wrong type (expected pointer, found purposely undefined)
gbak: ERROR:gds_$compile_request failed
gbak:Exiting before completion due to errors

O gfix -mend não ajuda, e o backup não sai — nem das tabelas que estão perfeitas.

O caminho alternativo:

$ fbrescue scan loja-danificada.fdb
...
129 problems:
  page 286       invalid-type       page is marked as used but has type 0
  page 528       invalid-type       page is marked as used but has type 0
  page 345       orphan-data-page   data page of table 129 is not referenced by any pointer page
  ...

$ fbrescue export loja-danificada.fdb --out resgate --format csv
CUSTOMERS                            2000 rows  ✓
ORDERS                               4948 rows  ⚠ 1 skipped
ITEMS                               14230 rows  ✓
Wrote resgate: schema.sql and 3 csv files
1 rows or pages could not be exported: details in _errors.jsonl
⚠ relation 129: 127 data pages not reachable through its pointer pages were found by scanning the file

21 178 de 21 230 linhas. As 52 que faltam estavam na página zerada — essas não voltam, nenhuma ferramenta inventa bytes. O resto, incluindo as 127 páginas de ORDERS que o motor já não enxergava, saiu inteiro. O código de saída é 2 ("parte dos dados ficou de fora"), e o _errors.jsonl diz exatamente o quê.

Como eu sei que isso funciona

Ferramenta de recuperação que "parece funcionar" é pior que nenhuma: você só descobre o erro quando o cliente reclama de um valor errado seis meses depois. Então:

  • 79 bancos de teste são gerados no CI pelos motores reais do Firebird 2.5, 3.0, 4.0 e 5.0: todos os tipos de coluna com valores-limite, tabelas alteradas sete vezes, registros maiores que uma página, BLOBs de todos os níveis, banco multiarquivo, transações desfeitas, linhas apagadas.
  • Teste de ida e volta: a exportação em SQL é carregada num banco novo pelo mesmo Firebird, e o SELECT no banco novo precisa imprimir exatamente o mesmo que no original, valor por valor.
  • Teste de dano: o banco da loja é danificado de 13 jeitos diferentes, 10 cópias por tipo em cada uma das 4 versões — 520 bancos quebrados —, e cada linha exportada é comparada com a original pela chave primária. A tabela completa está no README; alguns números:
DanoLinhas recuperadas
página de índice zerada100%
página de ponteiros zerada100%
página TIP zerada100%
página de cabeçalho zerada100%
página de dados zerada99,8%
bloco de 64 KB zerado90,3% (97,8% com --ddl)
arquivo truncado68,3%

Uma linha dessa tabela me incomoda: com 16 bits trocados aleatoriamente, alguns valores mudam sem aviso. As páginas do Firebird não têm checksum, então um byte alterado dentro de um registro é indistinguível de um dado legítimo. Prefiro deixar isso escrito do que esconder.

  • E um teste que roda todos os comandos em cópias somente leitura e confere que o arquivo não mudou nem um byte.

Na prática

Baixe o executável em Releases (Windows, Linux, macOS) e trabalhe numa cópia do arquivo, com o servidor parado:

fbrescue scan copia.fdb                                   # o que está danificado, por tabela
fbrescue export copia.fdb --out resgate                   # cada tabela em CSV + schema.sql
fbrescue export copia.fdb --out resgate --format sql      # INSERTs para carregar com o isql
fbrescue export copia.fdb --out resgate --include-deleted # mais as linhas apagadas

Duas coisas bem brasileiras:

  • Acentos quebrados: muito sistema antigo grava texto em CHARACTER SET NONE, com os bytes na codificação do Windows. Acrescente --charset WIN1252 (ou ISO8859_1) e o CSV sai em UTF-8 correto.
  • Excel: o Excel brasileiro espera ; e vírgula decimal, então abrir o CSV com dois cliques junta tudo numa coluna. Use Dados → De Texto/CSV, origem UTF-8 e delimitador vírgula.

E o fbrescue não substitui as ferramentas do motor: sempre tente gfix e gbak primeiro. Se o backup sair, você recupera o banco inteiro, com índices, triggers e procedures. O fbrescue é para quando eles param.

Limitações e TODO

✅ Firebird 2.5, 3.0, 4.0 e 5.0, incluindo os tipos do Firebird 4 (INT128, DECFLOAT, fusos horários)
✅ BLOBs, bancos multiarquivo, registros maiores que uma página
✅ Linhas apagadas e versões antigas
✅ Páginas perdidas, fora do lugar, TIP e cabeçalho danificados
✅ Relatório sem dados (fbrescue report) para pedir ajuda sem expor o banco do cliente
⏳ Colunas do tipo array
⏳ Números de transação de 48 bits (bancos com mais de 4 bilhões de transações)
⏳ Carregar direto num banco novo, sem passar pelo isql
❌ InterBase e Firebird 1.x–2.1 — formato em disco mais antigo, não suportado

Índices, triggers, procedures e generators não são exportados: a ferramenta extrai dados, não conserta o arquivo.

Conclusões

O Firebird é um formato bem amigável para engenharia reversa: páginas simples, registros com cabeçalho fixo, RLE de uma linha, e o código-fonte aberto para tirar dúvidas. A parte difícil não é ler um banco saudável — é não confiar em nada quando o banco está quebrado, e provar com testes que o que você leu está certo.

Quando as ferramentas padrão não funcionam, o acesso direto ao arquivo pode salvar os dados. Mas o principal continua o mesmo: façam backup. E testem o restore.

GitHub: formeo/fbrescue — licença MIT. Se tiverem um banco quebrado de um jeito que a ferramenta não aguenta, o fbrescue report gera um relatório sem nenhum dado; abram uma issue, pode ser em português.

Recursos úteis:

Carregando publicação patrocinada...