en
· 3 min de leitura

O padrão outbox em palavras simples

Commite o evento junto com os dados, deixe um relay publicar, faça consumidores idempotentes. A escrita dupla, resolvida sem jargão.

system-designdata

Aqui vai um bug que existe na maioria dos sistemas que publicam eventos, e que a maioria dos times só descobre depois que ele corrompeu alguma coisa.

O problema da escrita dupla

Um serviço de pedidos salva um pedido no Postgres e depois publica um evento OrderCreated em uma fila, para que cobrança, e-mail e analytics reajam. Duas escritas, dois sistemas. Agora pense no que acontece entre elas.

O commit no banco dá certo, e então o processo cai, ou o broker fica inacessível por um segundo. O pedido existe, o evento nunca foi enviado. A cobrança nunca acontece. Ou inverta a ordem: publique primeiro, depois commite. O evento sai, o commit falha em uma constraint. A cobrança é feita para um pedido que não existe.

Envolver os dois em um try/catch não ajuda; não existe transação que abrange um banco de dados e um message broker. Retry na publicação ajuda um pouco e cria duplicatas. Transações distribuídas entre os dois existem em teoria e são um sofrimento na prática. Esse é o problema da escrita dupla, e não dá para corrigir sendo cuidadoso. Ele precisa ser eliminado no design.

Escreva o evento onde você escreve os dados

O padrão outbox é esse design. Em vez de publicar no broker, você insere o evento em uma tabela chamada outbox, no mesmo banco, dentro da mesma transação do pedido. Um commit, duas linhas: o pedido e o evento. Ou os dois existem ou nenhum existe. A escrita dupla sumiu, porque só existe uma escrita.

A linha do outbox carrega um id, o agregado a que pertence (pedido 123), o tipo do evento, o payload em JSON, um timestamp de criação e um flag ou timestamp de publicado. Essa é a tabela inteira.

O relay

Alguma coisa ainda precisa levar os eventos da tabela para o broker. É o relay: um processo pequeno que consulta o outbox em busca de linhas não publicadas, publica cada uma e depois marca como publicada. Se o relay cai depois de publicar mas antes de marcar, ele vai publicar aquele evento de novo quando reiniciar. Isso é proposital, e leva à regra que faz o padrão funcionar.

A entrega é at-least-once. Todo consumidor precisa ser idempotente. A cobrança precisa conseguir receber OrderCreated do pedido 123 duas vezes e cobrar uma, o que geralmente significa guardar os ids dos eventos já processados e pular repetidos, ou fazer a operação ser naturalmente segura de repetir. Isso não é opcional; um consumidor que não é idempotente vai, cedo ou tarde, cobrar alguém em dobro.

Ordenação: um único relay lendo as linhas na ordem de inserção e publicando em uma partição com chave pelo id do agregado dá entrega em ordem por pedido. Eventos de pedidos diferentes podem se intercalar livremente, e tudo bem. Não prometa ordenação global; você não precisa dela e ela não escala.

Limpeza e a alternativa com CDC

O outbox cresce para sempre a menos que você apague as linhas publicadas. Um job que remove linhas com mais de alguns dias basta; mantenha a janela longa o suficiente para investigar um incidente. Índice em (published, created) para que a consulta do relay continue barata mesmo com a tabela grande.

Polling adiciona latência, normalmente de algumas centenas de milissegundos a uns dois segundos. Se isso importa, a alternativa é change data capture: uma ferramenta como o Debezium lê o write-ahead log do banco e publica os inserts do outbox conforme eles são commitados, sem polling e sem carga extra na tabela. Mesmo padrão, mesma tabela, relay diferente. CDC é mais infraestrutura para operar, então comece com polling a menos que tenha medido que precisa de algo melhor.

Quando vale a pena

Se um evento perdido ou duplicado só afeta um dashboard, talvez você não precise disso. Se afeta dinheiro, estoque, uma notificação que uma pessoa está esperando, ou qualquer coisa que precise ser conciliada depois, você precisa. O outbox custa uma tabela e um processo pequeno, e converte uma classe de bugs quase impossível de reproduzir em uma classe que não existe.