Blog

Tabela de auditoria que virou a maior do banco: guardar histórico de alterações sem pesar na escrita

A tabela auditoria de uma plataforma de pedidos chegou a 2,1 TB e 6,4 bilhões de linhas, 58% de um banco de 3,6 TB. Ela nasceu como um gatilho de quatro linhas que grava a linha inteira antes e depois de cada alteração, e ficou assim porque funcionava. Hoje o backup demora mais por causa dela, os quatro índices somam 740 GB, o UPDATE de pedidos subiu de 9 ms para 14 ms no p95 só pelo tempo do gatilho, e ninguém ousa apagar nada porque a auditoria é "obrigatória". Este artigo mostra como medir onde está o custo, gravar apenas o que mudou, particionar por mês com um índice que ocupa megabytes, aposentar o histórico antigo para fora do banco e, quando a latência de escrita ainda pesa, tirar a gravação do caminho crítico.

2026-11-01 / Arquitetura / 16 min

01

Por que a tabela de auditoria vira a maior do banco

Auditoria cresce por três razões que se multiplicam. A primeira é a amplificação: cada UPDATE de uma linha de 1 KB vira outra linha de mais de 2 KB, porque o gatilho guarda o estado inteiro antes e depois em JSONB, mesmo quando só uma coluna mudou. A segunda é o ruído: colunas como atualizado_em, ultimo_acesso e contador de visualizações mudam o tempo todo e geram registros que ninguém consulta. No cenário do artigo, 62% das linhas de auditoria de pedidos registravam apenas mudanças nessas colunas. A terceira é a ausência de fim: ninguém definiu por quanto tempo o dado precisa estar rápido de consultar, então tudo fica no mesmo lugar para sempre.

Os sintomas aparecem longe da tabela. O backup e a restauração crescem com ela. O autovacuum e a construção de índices passam a competir com o tráfego. A cada alteração, o gatilho acrescenta uma gravação em outra tabela, com quatro índices para manter, dentro da mesma transação do usuário. Cada milissegundo desse trabalho é latência da API, e a conta é feita em todas as escritas, não só nas que alguém vai auditar um dia.

IndicadorAntesMeta do artigo
Tamanho online da auditoria2,1 TB e 740 GB só de índicesCerca de 330 GB, com 12 meses online
Linhas por dia14 milhões, 1,1 KB cada, 15 GB/dia5,3 milhões, 140 bytes cada, 0,8 GB/dia
Tempo do gatilho no UPDATE (p95)5 ms1,2 ms
p95 do UPDATE em pedidos14 ms10,4 ms
Índices na auditoria4, mantidos a cada gravação1 composto + 1 BRIN de poucos MB
Remoção de dado vencidoNão existeDROP de partição, milissegundos

A meta não pede ferramenta nova. Pede três decisões: gravar menos por evento, organizar o que fica por tempo e escolher onde cada idade do dado deve morar.

02

Onde está o custo: medir antes de mexer

Antes de reescrever o gatilho, três medições separam o que é problema de tamanho, de ruído e de latência. A primeira lista as maiores tabelas com a divisão entre dados e índices. A segunda compara antes e depois de cada linha da auditoria e conta quais colunas realmente mudaram, o que mostra o tamanho do ruído. A terceira usa EXPLAIN ANALYZE dentro de uma transação revertida para ver, na última linha do plano, quanto tempo o gatilho consome em um UPDATE real.

-- 1. Quem ocupa o banco: tabela, indices e total, em ordem decrescente.
SELECT relname,
       pg_size_pretty(pg_total_relation_size(relid)) AS total,
       pg_size_pretty(pg_relation_size(relid))       AS tabela,
       pg_size_pretty(pg_indexes_size(relid))        AS indices
  FROM pg_stat_user_tables
 ORDER BY pg_total_relation_size(relid) DESC
 LIMIT 5;

-- 2. O que realmente mudou nos ultimos 7 dias, coluna por coluna. A tabela antiga guarda a linha
--    inteira em antes e depois, entao o que mudou e a diferenca entre as duas.
SELECT d.k AS coluna, count(*) AS alteracoes
  FROM auditoria a,
       LATERAL jsonb_each(a.depois) AS d(k, v)
 WHERE a.tabela = 'pedidos'
   AND a.operacao = 'U'
   AND a.criado_em > now() - interval '7 days'
   AND (a.antes -> d.k) IS DISTINCT FROM d.v
 GROUP BY d.k
 ORDER BY alteracoes DESC
 LIMIT 10;

-- 3. Quanto o gatilho custa em cada UPDATE: a ultima linha do plano mostra o tempo do gatilho.
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE pedidos SET status = 'pago' WHERE id = 981;
ROLLBACK;
-- ... Trigger auditar_alteracao: time=4.812 calls=1

No caso do artigo, a segunda consulta mostrou que atualizado_em, ultimo_acesso e visualizacoes respondiam por 62% das alterações, e a terceira mostrou 4,8 ms de gatilho em um UPDATE de 9 ms. Esses números definem o que atacar e em que ordem, e comparam o antes e o depois sem depender de impressão. As opções reais de arquitetura são as da tabela abaixo.

EstratégiaCusto na escritaTamanhoGarantiaQuando usar
Gatilho gravando linha inteiraAltoMáximoMesma transação, nunca perdeTabela pequena, volume baixo
Gatilho gravando só o diffMédioCerca de 8 vezes menorMesma transação, nunca perdePonto de partida para a maioria
Diff + partição mensal + BRINMédioMenor e com retenção barataMesma transação, nunca perdeAuditoria consultada por registro e por período
Decodificação lógica (WAL)BaixoEscolha do consumidorEventual, com atraso e slot a vigiarLatência de escrita crítica, volume muito alto
Auditoria na aplicaçãoDepende do códigoEscolha da equipePerde o que passa por fora da aplicaçãoEvitar para dados regulados

03

Gravar só o que mudou

A maior economia vem de duas regras simples. A primeira: se nada relevante mudou, não registre. A segunda: quando algo mudou, guarde só as colunas que mudaram, com o valor antigo e o novo. Em INSERT guarda-se o estado novo, em DELETE o estado antigo, e em UPDATE apenas a diferença. O gatilho abaixo implementa isso comparando os dois JSONB, aceita uma lista de colunas ruidosas como argumento e devolve sem gravar nada quando só elas mudaram.

-- Tabela nova, particionada por mes. A PK inclui a chave de particao.
CREATE TABLE auditoria_v2 (
  id          bigserial,
  ocorrido_em timestamptz NOT NULL DEFAULT now(),
  tabela      text        NOT NULL,
  registro_id text        NOT NULL,
  operacao    char(1)     NOT NULL CHECK (operacao IN ('I', 'U', 'D')),
  ator        text,
  antes       jsonb,
  depois      jsonb,
  PRIMARY KEY (id, ocorrido_em)
) PARTITION BY RANGE (ocorrido_em);

-- Grava so o que mudou. Colunas ruidosas passam como argumento do gatilho e sao ignoradas.
-- SECURITY DEFINER: a aplicacao nao precisa (nem deve ter) permissao de escrita na auditoria.
CREATE OR REPLACE FUNCTION auditar_alteracao() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $
DECLARE
  ignoradas text[] := COALESCE(TG_ARGV, ARRAY[]::text[]);
  chave     text;
  antes     jsonb;
  depois    jsonb;
  mudou     jsonb;
BEGIN
  IF TG_OP = 'INSERT' THEN
    chave  := NEW.id::text;
    depois := to_jsonb(NEW);
  ELSIF TG_OP = 'DELETE' THEN
    chave := OLD.id::text;
    antes := to_jsonb(OLD);
  ELSE
    chave := NEW.id::text;
    antes := to_jsonb(OLD);
    SELECT jsonb_object_agg(n.key, n.value) INTO mudou
      FROM jsonb_each(to_jsonb(NEW)) AS n
     WHERE n.key <> ALL (ignoradas)
       AND (antes -> n.key) IS DISTINCT FROM n.value;
    IF mudou IS NULL THEN
      RETURN NULL;  -- so colunas ruidosas mudaram: nada a registrar
    END IF;
    SELECT jsonb_object_agg(o.key, o.value) INTO antes
      FROM jsonb_each(antes) AS o
     WHERE mudou ? o.key;
    depois := mudou;
  END IF;

  INSERT INTO auditoria_v2 (tabela, registro_id, operacao, ator, antes, depois)
  VALUES (TG_TABLE_NAME, chave, left(TG_OP, 1),
          NULLIF(current_setting('app.usuario_id', true), ''), antes, depois);
  RETURN NULL;
END;
$;

-- Troca do gatilho antigo pelo novo em uma transacao curta, com lock_timeout para nao formar fila.
BEGIN;
SET LOCAL lock_timeout = '2s';
DROP TRIGGER IF EXISTS auditar ON pedidos;
CREATE TRIGGER auditar
  AFTER INSERT OR UPDATE OR DELETE ON pedidos
  FOR EACH ROW EXECUTE FUNCTION auditar_alteracao('atualizado_em', 'ultimo_acesso', 'visualizacoes');
COMMIT;
  • O gatilho é AFTER ... FOR EACH ROW e devolve NULL, o que é válido nesse tipo, e roda na mesma transação do UPDATE. Se a transação for revertida, o registro some junto, o que é o comportamento certo para auditoria.
  • SECURITY DEFINER com search_path fixo faz a função gravar com o dono, para que o papel da aplicação não precise de permissão de escrita na auditoria. Sem o search_path fixo, a função ficaria sujeita a sequestro por objetos criados em outro esquema.
  • O argumento da lista de colunas ignoradas fica no CREATE TRIGGER, ao lado da tabela. Cada tabela pode ter a sua lista sem copiar a função.
  • O ator vem de uma configuração local da transação, preenchida pela aplicação. Se ela não estiver definida, o campo fica nulo, e uma consulta por linhas sem ator é um bom alerta de alteração feita fora do caminho esperado, como um acesso direto ao banco.
  • O código assume uma coluna id em cada tabela auditada. Para chave composta, concatene as colunas em chave antes do INSERT.

Para o ator, a aplicação precisa informar quem está alterando dentro da mesma transação. O trecho em Node.js usa set_config com o terceiro argumento verdadeiro, que limita o valor à transação. Isso importa com PgBouncer em modo de transação, em que a mesma conexão atende clientes diferentes em sequência e uma configuração de sessão vazaria de um usuário para outro.

// com-ator.js - informa ao gatilho quem esta alterando, sem mudar nenhuma consulta existente.
import pg from 'pg';

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

export async function comAtor(usuarioId, trabalho) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    // Terceiro argumento true: o valor vale so ate o fim da transacao. Por isso funciona
    // com PgBouncer em modo transaction e nao vaza para a proxima requisicao da conexao.
    await client.query("SELECT set_config('app.usuario_id', $1, true)", [String(usuarioId)]);
    const resultado = await trabalho(client);
    await client.query('COMMIT');
    return resultado;
  } catch (erro) {
    await client.query('ROLLBACK');
    throw erro;
  } finally {
    client.release();
  }
}

// Uso: o UPDATE e o registro de auditoria nascem na mesma transacao, com o mesmo ator.
await comAtor(42, (client) =>
  client.query('UPDATE pedidos SET status = $1 WHERE id = $2', ['pago', 981]),
);

No cenário medido, a linha média de auditoria caiu de 1,1 KB para 140 bytes, o volume diário de 14 milhões para 5,3 milhões de linhas, e o gatilho de 4,8 ms para 1,2 ms. Cada UPDATE ficou, no p95, em 10,4 ms. O ganho veio quase todo de gravar menos, não de um truque de otimização.

04

Partição mensal, índice BRIN e retenção em camadas

Com o volume menor, o que resta é organizar o histórico pelo único eixo que ele tem: o tempo. Particionar a auditoria por mês faz a gravação cair sempre na partição corrente, mantém os índices dessa partição pequenos o bastante para ficar em memória e transforma a expiração em um DROP TABLE. Há dois padrões de leitura, e cada um ganha o índice certo. O histórico de um registro usa um índice composto por tabela, registro e data. A consulta por período usa BRIN, um índice que guarda apenas o mínimo e o máximo de cada faixa de páginas, e funciona aqui porque a gravação segue a ordem do tempo. Ele ocupa megabytes no lugar de centenas de gigabytes.

-- Indices na tabela pai: nascem em cada particao.
-- 1. Historico de um registro: "o que aconteceu com o pedido 981?"
CREATE INDEX auditoria_v2_registro_idx
  ON auditoria_v2 (tabela, registro_id, ocorrido_em DESC);
-- 2. Consulta por periodo: BRIN ocupa megabytes porque a gravacao segue a ordem do tempo.
CREATE INDEX auditoria_v2_ocorrido_brin
  ON auditoria_v2 USING brin (ocorrido_em) WITH (pages_per_range = 32);

-- Particoes criadas com antecedencia pela rotina diaria (mesma ideia do artigo de particionamento).
CREATE TABLE auditoria_v2_2026_11 PARTITION OF auditoria_v2
  FOR VALUES FROM ('2026-11-01 00:00:00+00') TO ('2026-12-01 00:00:00+00');
CREATE TABLE auditoria_v2_2026_12 PARTITION OF auditoria_v2
  FOR VALUES FROM ('2026-12-01 00:00:00+00') TO ('2027-01-01 00:00:00+00');

-- Imutabilidade: a aplicacao le, mas nao escreve nem apaga. Quem escreve e a funcao SECURITY DEFINER.
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON auditoria_v2 FROM app_rw;
GRANT SELECT ON auditoria_v2 TO app_rw;

-- Aposentadoria de um mes vencido (psql), na ordem: exportar, conferir, so entao remover.
\copy (SELECT * FROM auditoria_v2_2025_10) TO PROGRAM 'zstd -T0 -19 > auditoria_2025_10.csv.zst' WITH (FORMAT csv, HEADER)
-- (enviar o arquivo ao armazenamento de objetos e conferir contagem e checksum)
ALTER TABLE auditoria_v2 DETACH PARTITION auditoria_v2_2025_10 CONCURRENTLY;
DROP TABLE auditoria_v2_2025_10;
  • Camada quente: 12 meses em partições no banco, com os dois índices. Cobre as consultas da operação, do suporte e da maioria das auditorias internas.
  • Camada fria: meses vencidos exportados em CSV comprimido com zstd para o armazenamento de objetos, com contagem e checksum conferidos antes de remover a partição. No cenário, os 2,1 TB legados comprimiram para cerca de 240 GB.
  • Consulta sobre o frio: sob demanda, carregando o arquivo do mês em um banco temporário. É lenta de propósito, e o prazo de resposta combinado com quem pede é parte da política de retenção.
  • Imutabilidade: a aplicação só lê. O papel de manutenção que faz DETACH e DROP não é o papel da aplicação, e todo DROP deve ficar registrado em um log fora do banco.
  • Partições futuras precisam existir antes do primeiro dia do mês. A rotina de criação e o cuidado com a partição padrão seguem o que o artigo sobre particionamento por data descreve.

Um detalhe de escolha: a retenção legal costuma ser medida em anos, e a consulta rápida em meses. Misturar as duas é o que fez a tabela chegar a 2,1 TB. Separar o prazo de guarda do prazo de consulta é a decisão de produto que sustenta o resto da engenharia.

05

Tirar a gravação do caminho crítico com decodificação lógica

Mesmo enxuto, o gatilho ainda custa 1,2 ms e uma gravação a mais em cada escrita. Para a maioria dos sistemas, isso é um preço razoável pela garantia mais forte: o registro nasce e morre junto com a alteração. Quando a latência de escrita é crítica ou o volume é muito alto, a alternativa é deixar o banco escrever apenas o que já escreve, o WAL, e um consumidor externo montar a auditoria a partir dele.

Gatilho sincrono (hoje) versus leitura do WAL (alternativa)

  Gatilho:     API --UPDATE--> pedidos --gatilho--> auditoria (mesma transacao, mesmo COMMIT)
                                           custo na latencia de cada escrita

  Decodificacao logica:
               API --UPDATE--> pedidos --WAL--> slot --> consumidor --> auditoria (fora do banco)
                                           custo fora do caminho, mas com atraso e slot a vigiar
-- Alternativa que tira a auditoria do caminho da escrita: ler o WAL e gravar fora da transacao.
-- 1. Publicar as tabelas auditadas. REPLICA IDENTITY FULL leva a linha antiga no WAL,
--    o que aumenta o volume de WAL: so ligue nas tabelas que precisam de "antes".
ALTER TABLE pedidos REPLICA IDENTITY FULL;
CREATE PUBLICATION auditoria_pub FOR TABLE pedidos, clientes;

-- 2. O ator nao esta no WAL. A aplicacao emite uma mensagem logica dentro da mesma transacao;
--    ela chega ao consumidor na ordem certa, junto com as linhas daquela transacao.
SELECT pg_logical_emit_message(true, 'auditoria.ator', '42');
  • O custo sai do caminho do usuário, mas a garantia vira eventual: a auditoria chega com atraso, e o consumidor precisa ser idempotente porque a entrega pode repetir.
  • REPLICA IDENTITY FULL é necessário para ter o valor antigo no UPDATE e no DELETE, e faz o WAL crescer. Ligue só nas tabelas em que o "antes" importa.
  • O slot de replicação segura WAL enquanto o consumidor estiver parado. Um consumidor fora do ar por horas pode encher o disco do servidor, então monitore o atraso do slot e defina um limite com max_slot_wal_keep_size.
  • O ator não está no WAL. A mensagem lógica emitida pela aplicação dentro da transação carrega essa informação até o consumidor, na ordem correta.
  • A mudança de esquema da tabela auditada passa a ser também um contrato com o consumidor, e uma coluna nova precisa ser tratada nos dois lados.

A escolha entre as duas formas é de risco, não de modismo. Se perder um registro de auditoria é inaceitável e o orçamento de latência aguenta 1 ms, fique com o gatilho. Se a latência de escrita é o gargalo medido e o negócio tolera segundos de atraso na auditoria, a decodificação compensa o custo operacional.

06

Migrar a tabela atual sem parar nada

A troca não exige janela. A tabela nova nasce ao lado, o gatilho novo passa a gravar nela, e a antiga envelhece até caber na política de retenção. O que a aplicação enxerga durante a transição é uma visão que une as duas.

  1. Crie auditoria_v2, a função nova e as partições dos próximos meses. Nada muda para quem escreve.
  2. Troque o gatilho tabela por tabela, começando pelas de menor risco, em uma transação curta com lock_timeout. A troca de gatilho pede um bloqueio curto na tabela, e a falha por timeout é segura: tenta-se de novo.
  3. Durante uma semana, compare por amostra: para cada UPDATE auditado nas duas estruturas, o diff novo precisa conter as mesmas colunas que a diferença entre antes e depois do registro antigo, menos as ruidosas.
  4. Crie uma visão auditoria_historico que une a tabela antiga e a nova, para que telas e relatórios existentes continuem funcionando sem alteração.
  5. Exporte e remova a tabela antiga por faixa de data, usando o mesmo processo de aposentadoria. Se a antiga for particionada em um segundo momento, o artigo sobre particionamento por data mostra como transformá-la em partição sem copiar linhas.
  6. Documente a política: o que é auditado, quais colunas são ignoradas e por quê, quanto tempo fica online, quanto tempo fica arquivado e quem pode pedir uma restauração.

O resultado no cenário foi 330 GB online contra 2,1 TB, um backup 6 vezes menor, a gravação de auditoria sem índice pesado e um UPDATE de pedidos em 10,4 ms no p95. A auditoria continuou com a mesma garantia de antes: nenhuma alteração sem registro, e nenhum registro sem alteração.

FAQ

Perguntas frequentes

Gravar só o diff não perde informação importante?

Perde a foto completa da linha em cada evento, mas ela pode ser reconstruída: o INSERT guarda o estado inicial e cada UPDATE guarda o que mudou, então o estado em qualquer data é a aplicação em ordem das diferenças. Para registros em que a reconstrução precisa ser instantânea, guarde um instantâneo completo de tempos em tempos, por exemplo no fechamento do mês. O que deve ficar de fora é só o ruído que ninguém consulta, e a lista de colunas ignoradas deve ser revisada com quem usa a auditoria.

O pgaudit resolve esse problema?

Resolve outro. O pgaudit registra comandos SQL no log do servidor, o que serve a conformidade de acesso e a investigação de quem executou o quê, mas não responde "o que mudou neste registro entre as datas X e Y" com consulta direta. Para histórico de dados por registro, o gatilho ou a decodificação lógica entregam uma tabela consultável. As duas abordagens podem coexistir.

Como garantir que ninguém altere ou apague a auditoria?

Em camadas. O papel da aplicação só tem SELECT na tabela, e quem grava é a função SECURITY DEFINER. O papel de manutenção que remove partições vencidas é separado, com os DROP registrados fora do banco. Para exigência mais forte, grave um hash encadeado por registro ou exporte periodicamente os arquivos comprimidos para armazenamento com bloqueio de exclusão. Nenhuma dessas medidas substitui acesso restrito ao banco, mas elas tornam a adulteração detectável.

Auditoria boa registra o que importa, organiza por tempo e sabe quando esquecer

A tabela de 2,1 TB não cresceu porque auditar é caro, e sim porque ninguém decidiu o que registrar, por quanto tempo consultar e onde cada idade do dado deve ficar. Gravar apenas o que mudou cortou o volume e o tempo do gatilho, a partição mensal com BRIN deixou a expiração em milissegundos, a camada fria tirou os anos antigos do banco, e a decodificação lógica ficou como saída para quando a latência de escrita for o gargalo medido. A garantia continua a mesma, com um banco menor e uma escrita mais rápida.