Por que seu evento some entre o banco e o broker (Dual Write Problem)
Toda arquitetura orientada a eventos esbarra nisso mais cedo ou mais tarde.
Você abre uma transação, salva o pedido no banco, dá o commit e, logo depois, publica um evento no seu broker (Kafka, RabbitMQ, SQS) para avisar o resto do sistema. Parece atômico. Não é.
Entre o commit no banco e o publish no broker existe uma janela. Se o processo morrer ali (o container reiniciou, a rede piscou, uma exceção estourou), o banco tem o pedido mas o evento nunca sai, e o consumidor lá na frente nunca fica sabendo. Ou o inverso, igualmente cruel: o evento é publicado e o banco faz rollback, e agora existe um sistema reagindo a um pedido que, oficialmente, nunca existiu.
Isso é o Dual Write Problem: uma operação de negócio precisa escrever em dois data stores que não compartilham a mesma transação. Duas tabelas do mesmo Postgres? A transação te salva. Um Postgres e um Kafka? Não existe atomicidade entre eles.
As tentativas ingênuas (e por que todas falham)
Quase todo mundo tenta resolver reordenando as operações:
- Salva no banco, depois publica. Cai entre os dois? Dado sem evento.
- Publica, depois salva no banco. Pior: evento no mundo para um estado que nunca persistiu.
- Publica dentro da transação. Rollback do banco não desfaz uma mensagem que já saiu do broker.
- Emite um evento de compensação quando algo falha. A compensação é outra escrita remota, e ela também pode falhar. Em algum momento você vai precisar compensar a compensação da compensação.
É o clássico problema do cobertor curto: cobre a cabeça, o pé fica de fora. Cada rearranjo só move o momento da falha, nunca elimina.
A ideia do Transactional Outbox
O truque é transformar dois writes em um só.
Em vez de escrever no banco E no broker, você abre uma transação e escreve em duas tabelas do mesmo banco: a sua tabela de negócio e uma tabela outbox. Como é a mesma transação, ou as duas escritas acontecem, ou nenhuma. Atômico, garantido pelo banco de graça.
BEGIN;
INSERT INTO orders (...);
INSERT INTO outbox (evento, payload, status = 'pendente');
COMMIT;
Aí entra o segundo componente: um relay (um job/worker) que fica lendo a tabela outbox, pega o que está pendente, publica no broker e marca como enviado.
"Mas o relay não tem o mesmo problema? Ele publica no broker E atualiza a tabela." Tem. E é aqui que está a sacada: o outbox te dá at least once. O relay fica tentando até conseguir. Se publicou mas não conseguiu marcar como enviado, na próxima rodada ele publica de novo. Pode duplicar, mas nunca perde.
Ou seja: o outbox troca "posso perder mensagem" por "posso duplicar mensagem". E duplicação você resolve com idempotência no consumidor (uma chave de deduplicação, ou operações que dão o mesmo resultado quando aplicadas N vezes). Mensagem perdida, essa você não recupera depois.
Onde ele te trai
Nada é de graça:
- A tabela outbox cresce rápido. Precisa de estratégia de limpeza e, em escala, você vai encontrar Dead Tuples e Vacuum no Postgres.
- Ordenação vira dor de cabeça. Head-of-Line Blocking: uma mensagem envenenada no topo pode travar a fila inteira.
- Retry sem limite e sem backoff/jitter vira loop (tem um caso real no vídeo de um batch que ficou horas duplicando milhares de registros em produção).
E o mais importante: você só precisa do outbox se tem o Dual Write Problem. Se o seu caso tolera perder mensagem (métricas, localização de GPS a cada 10s, notificação não crítica), o outbox é overkill. A pergunta que resolve tudo: o seu negócio tolera perder essa mensagem?
Quer se aprofundar?
Isso aqui é um resumo. Gravamos um encontro da comunidade Craft & Code Club de quase 2h30 destrinchando tudo numa mesa redonda: garantias de entrega, idempotência (com o exemplo do webhook do Stripe), CDC com Debezium e o pecado de vazar o schema interno, Inbox Pattern no lado do consumidor, e duas demos ao vivo de Chaos Engineering (K6 + Grafana, ~36 mil mensagens) mostrando as mensagens sumindo sem outbox e zero perdas com outbox.
🎥 https://www.youtube.com/watch?v=I-Cne71tuM0
No fim, como a gente sempre repete: não existem soluções, só trade-offs.
E você, já lidou com este problema em produção? Já aplicou outbox pattern?
Conta ai nos comentários sua experiência e os principais aprendizados!