Blog

Outbox pattern no Postgres: mensagem e transação no mesmo commit.

O código abaixo está em todo projeto que tem fila, e ele tem um bug que só aparece de vez em quando:

await orderRepo.save(order);
await queue.publish("order.created", { id: order.id });

Duas operações, dois sistemas, duas falhas possíveis.

Se o publish falhar, o pedido existe e ninguém foi avisado. O e-mail não sai, o estoque não baixa, o parceiro não recebe. Do ponto de vista do banco, tudo certo.

Inverta a ordem e fica pior: você publica, o save falha, e agora existe um evento sobre um pedido que não existe.

Não tem ordem certa. O problema é estrutural: você está tentando fazer duas coisas atomicamente em dois sistemas que não compartilham transação.

A ideia

Não publique na fila. Grave a mensagem no seu banco, na mesma transação do dado.

Um processo separado lê a tabela de mensagens pendentes e publica. Se a transação der rollback, a mensagem some junto. Se der commit, a mensagem está lá e vai ser publicada, nem que seja daqui a trinta segundos.

create table outbox (
  id              bigserial primary key,
  aggregate_type  text not null,
  aggregate_id    uuid not null,
  event_type      text not null,
  payload         jsonb not null,
  created_at      timestamptz not null default now(),
  published_at    timestamptz,
  attempts        int not null default 0,
  last_error      text
);

create index on outbox (id) where published_at is null;

O índice parcial importa: a tabela cresce e a consulta só olha o que está pendente, que é sempre um punhado de linhas.

A escrita fica assim:

await dataSource.transaction(async (tx) => {
  await tx.save(order);
  await tx.insert(Outbox, {
    aggregateType: "order",
    aggregateId: order.id,
    eventType: "order.created",
    payload: { id: order.id, total: order.total },
  });
});

Uma transação. Ou as duas coisas acontecem, ou nenhuma.

O publicador

Um processo que roda em laço, pega um lote de pendentes, publica e marca.

async function drainOutbox() {
  await dataSource.transaction(async (tx) => {
    const rows = await tx.query(`
      select * from outbox
      where published_at is null
      order by id
      limit 100
      for update skip locked
    `);

    for (const row of rows) {
      await broker.publish(row.event_type, row.payload, {
        messageId: String(row.id),   // idempotência do lado do consumidor
      });
      await tx.query(`update outbox set published_at = now() where id = $1`, [row.id]);
    }
  });
}

FOR UPDATE SKIP LOCKED é o detalhe que faz isso escalar. Várias instâncias do publicador podem rodar em paralelo, e cada uma pega um lote diferente sem bloquear as outras. Sem o SKIP LOCKED, a segunda instância fica esperando a primeira, e você tem um único consumidor com passos extras.

O ORDER BY id preserva a ordem de criação, o que importa quando os eventos de um mesmo agregado têm dependência entre si.

A garantia que você tem, e a que não tem

Outbox dá entrega ao menos uma vez. Não exatamente uma vez.

O caso: o publicador manda a mensagem pro broker, o broker aceita, e o processo morre antes de gravar published_at. Na volta, a mesma mensagem é publicada de novo.

Isso não é defeito do padrão. É a consequência de não existir transação distribuída entre o seu banco e o broker. Qualquer solução que prometa exatamente uma vez está escondendo essa duplicata em algum lugar.

A resposta é o consumidor ser idempotente. O messageId estável que o publicador manda é a chave pra isso: o consumidor guarda os IDs processados e ignora repetido.

create table processed_message (
  message_id text primary key,
  processed_at timestamptz not null default now()
);

Escrevi sobre o mecanismo geral em idempotência.

Limpeza

Outbox vira tabela gigante se ninguém limpar.

delete from outbox
where published_at is not null
  and published_at < now() - interval '7 days';

Sete dias é um bom padrão: cobre investigação de incidente e não deixa a tabela inchar. Se você precisa do histórico permanente dos eventos, esse histórico pertence a uma tabela de evento própria, não ao outbox. São coisas diferentes: uma é log de negócio, a outra é fila de saída.

Quando as tentativas falham

Mensagem que falha repetidamente não pode travar a fila. Depois de N tentativas, marque como falha e siga:

alter table outbox add column failed_at timestamptz;

E alerte. Outbox com mensagem parada é sintoma de integração quebrada, e o valor de descobrir isso por alerta em vez de por reclamação de cliente é alto.

Uma métrica que vale ter no painel: idade da mensagem pendente mais antiga. Se passar de alguns minutos, alguma coisa está errada com o publicador, e esse é o tipo de falha que passa despercebida porque nada quebra visivelmente.

O custo

Latência. A mensagem sai com o atraso do laço de publicação, tipicamente entre cem milissegundos e alguns segundos.

Para a maioria das integrações, irrelevante. Para notificação em tempo real na interface, não serve, e aí você usa outro canal e aceita que aquele canal é o que pode perder mensagem.

Se o atraso incomodar, existe a variante com change data capture, lendo o WAL do Postgres com Debezium. Latência de milissegundos e uma peça de infraestrutura a mais pra operar. Comece pelo laço simples: ele resolve quase todo caso e cabe na cabeça de qualquer pessoa do time.

Por que eu insisto nisso

Perda de mensagem é o tipo de bug que não aparece em teste, não aparece em desenvolvimento, e acontece uma vez a cada mil operações em produção.

Aí alguém precisa explicar por que quinze clientes não receberam o e-mail de confirmação em três meses, e a resposta é que o publish falhou quinze vezes e ninguém soube. O outbox transforma isso em impossível por construção, ao custo de uma tabela e um laço.

← Voltar pro blog