Blog

Particionar a tabela de eventos antes que ela te particione.

Log de eventos, audit trail e ledger têm uma coisa em comum: só crescem.

Nos primeiros meses isso não importa. Depois de dois anos, você tem uma tabela de centenas de milhões de linhas, e algumas coisas param de funcionar de um jeito que ninguém associa ao tamanho.

O VACUUM demora horas. O índice não cabe mais na memória. Apagar dado antigo trava a tabela. Backup e restauração viram evento de fim de semana. E criar um índice novo passa a ser uma operação de risco.

Particionar resolve tudo isso. Particionar depois é bem mais caro que particionar antes.

Como fica

No Postgres, partição declarativa por faixa de tempo:

create table event (
  id           bigserial,
  stream_type  text not null,
  stream_id    uuid not null,
  type         text not null,
  payload      jsonb not null,
  occurred_at  timestamptz not null,
  recorded_at  timestamptz not null default now(),
  primary key (id, occurred_at)
) partition by range (occurred_at);

create table event_2026_09 partition of event
  for values from ('2026-09-01') to ('2026-10-01');

create table event_2026_10 partition of event
  for values from ('2026-10-01') to ('2026-11-01');

Repare na chave primária: precisa incluir a coluna de particionamento. É uma restrição do Postgres e é a primeira coisa que surpreende quem está migrando.

Isso tem uma consequência: uma constraint única global, do tipo unique (hash), não é possível numa tabela particionada a menos que inclua a chave de partição. Se o seu desenho depende de unicidade global, como o de rastreabilidade com hash, você precisa decidir isso antes: ou a unicidade é por partição, ou vive numa tabela separada de índice.

O que você ganha

Consulta com poda. Uma busca com filtro em occurred_at toca só as partições relevantes. Consultar o último mês numa tabela de dois anos lê 1/24 do dado.

Isso só funciona se o filtro estiver na coluna de particionamento. Consulta por stream_id sem faixa de data varre todas as partições, e aí você não ganhou nada.

Remoção instantânea. Essa é a maior vantagem, e é a que mais gente subestima.

-- apagar um mês: metadado, sem tocar em linha
drop table event_2026_01;

-- ou tirar da tabela pai e arquivar
alter table event detach partition event_2026_01;

Comparado com delete from event where occurred_at < ..., que numa tabela grande gera lock longo, WAL enorme, replicação atrasada e um VACUUM doloroso depois, a diferença é entre segundos e um incidente.

Manutenção por pedaço. VACUUM e ANALYZE rodam por partição. Índice se cria numa partição por vez. A partição antiga, que não recebe escrita, pode até ficar em tablespace mais barato.

A automação obrigatória

O erro operacional clássico é esquecer de criar a partição do mês seguinte. Aí, no dia primeiro, todo insert falha.

create or replace function ensure_event_partitions(months_ahead int default 3)
returns void as $$
declare
  d date := date_trunc('month', now())::date;
  i int;
  name text;
begin
  for i in 0..months_ahead loop
    name := 'event_' || to_char(d + (i || ' month')::interval, 'YYYY_MM');
    if not exists (select 1 from pg_class where relname = name) then
      execute format(
        'create table %I partition of event for values from (%L) to (%L)',
        name,
        d + (i || ' month')::interval,
        d + ((i+1) || ' month')::interval
      );
    end if;
  end loop;
end;
$$ language plpgsql;

Isso roda num job diário e cria com meses de antecedência. E precisa ter alerta: se a função falhar por três dias seguidos e ninguém souber, você descobre na virada do mês, em produção.

Uma partição padrão, DEFAULT, evita a falha catastrófica ao custo de esconder o problema. Se usar, monitore: linha caindo na partição padrão é sinal de que a automação quebrou.

Escolher o tamanho da partição

A regra prática que eu uso: cada partição deve ficar entre alguns milhões e algumas dezenas de milhões de linhas.

Muito granular, particionando por dia com pouco volume, produz milhares de partições, e o planejador do Postgres fica lento com isso. Muito grosso, particionando por ano, não resolve o problema que motivou particionar.

Volume alto pede mês ou semana. Volume moderado pede trimestre. E vale revisar: uma tabela que cresce dez vezes muda a resposta.

Migrar uma tabela existente

O caminho sem downtime é chato e conhecido.

Crie a estrutura particionada com outro nome. Anexe a tabela antiga inteira como uma partição, cobrindo a faixa histórica. Crie as partições novas para frente. Renomeie, em transação, dentro de uma janela curta.

alter table event rename to event_historico;
create table event (...) partition by range (occurred_at);
alter table event attach partition event_historico
  for values from ('2000-01-01') to ('2026-10-01');

O ATTACH precisa validar que todas as linhas cabem na faixa, o que numa tabela grande demora e pega lock. Adicionar uma constraint CHECK compatível antes, com NOT VALID e depois validada, permite que o ATTACH seja rápido.

Teste isso numa cópia do tamanho da produção. É uma operação que dá certo em tabela de teste e demora quarenta minutos na real.

O que não resolver com partição

Partição não substitui índice. Dentro de cada partição você continua precisando dos índices certos, e eles se criam por partição.

Partição não conserta consulta ruim. Se a query não filtra por tempo, ela varre tudo, agora em pedaços.

E partição não é escalabilidade horizontal. Continua sendo um banco. Se o gargalo é escrita concorrente, o problema é outro.

A decisão que eu tomaria

Se a tabela é append-only e tem qualquer expectativa de crescer, já nasça particionada. Custa uma tarde no começo e uma função de automação.

A alternativa é aquela conversa, dois anos depois, sobre como apagar dado antigo sem derrubar a produção, com o disco em 85% e o relógio correndo.

← Voltar pro blog