O handler grava o pedido no banco, commita e em seguida publica PedidoCriado no broker. Se o processo morrer entre o commit e o publish, o pedido existe e ninguém soube. Se a ordem for invertida, o evento sai e o pedido nunca persiste. Não há ordem correta para duas escritas em sistemas sem transação compartilhada. A decisão real é entre aceitar essa janela de inconsistência ou pagar o custo do padrão outbox.
O que exatamente falha na publicação direta
Publicar direto após o commit tem uma janela de falha estreita, mas não nula: crash do processo, deploy que mata o pod no meio da requisição, timeout do broker depois de o commit ter sido confirmado. Em volume baixo, a janela quase nunca se materializa. Em volume alto, com deploys diários e autoscaling, ela aparece como o ticket recorrente de “o cliente pagou e o estoque não baixou”, que ninguém consegue reproduzir.
O retry no cliente não resolve. Se o publish falha após o commit, o retry da requisição HTTP esbarra em uma chave única e devolve conflito, ou duplica o pedido se não houver idempotência. O problema é de atomicidade entre dois sistemas, e nenhuma camada de retry no meio devolve essa atomicidade.
Tentar o contrário, publicar antes de commitar, é pior: consumidores recebem um evento sobre um registro que pode não existir e, ao consultar a API de origem, recebem 404. Consumidores que dependem de leitura posterior ao evento passam a ter condição de corrida com o commit.
Como o outbox fecha a janela
O padrão: na mesma transação que grava a entidade, grava-se uma linha em uma tabela de outbox com o payload do evento. Um processo separado, o relay, lê as linhas pendentes, publica no broker e as marca como enviadas. A atomicidade passa a ser a do próprio banco: ou entidade e evento existem juntos, ou nenhum existe.
O preço aparece no outro lado. O relay entrega pelo menos uma vez, nunca exatamente uma vez: se publicar e cair antes de marcar a linha, republica na volta. Isso empurra a exigência de idempotência para os consumidores. Quem adota outbox sem tornar os consumidores idempotentes troca perda de evento por evento duplicado, o que costuma ser mais barato de corrigir, mas continua sendo bug.
A idempotência do consumidor tem dois desenhos comuns. O primeiro é uma tabela de mensagens processadas com o id do evento como chave primária: o consumidor insere o id na mesma transação que aplica o efeito, e uma violação de unicidade indica repetição. O segundo é tornar o efeito naturalmente idempotente, como um upsert por chave natural ou um UPDATE condicionado à versão. O primeiro serve para efeitos arbitrários, ao custo de uma tabela a mais para purgar. O segundo é mais barato, mas só cabe quando o efeito é uma atribuição e não um incremento. Contadores incrementais, envio de e-mail e chamadas a APIs externas exigem o primeiro desenho, ou uma chave de idempotência repassada ao serviço de destino quando ele a suporta.
Relay por polling ou por log de replicação
Há duas formas de implementar o relay, e a escolha pesa mais do que o padrão em si.
Polling. Um worker consulta a tabela a cada poucas centenas de milissegundos, pega um lote com bloqueio por linha pulando as já travadas (no PostgreSQL, SELECT … FOR UPDATE SKIP LOCKED permite vários workers sem disputa), publica e marca. Vantagens: zero infraestrutura nova, depuração com SQL, funciona em qualquer banco relacional. Desvios: latência limitada pelo intervalo, carga constante de leitura e crescimento da tabela, que exige limpeza periódica ou particionamento por data.
Captura de mudanças pelo log (CDC). Uma ferramenta como o Debezium lê o log de replicação do banco e transforma inserts na tabela de outbox em mensagens no broker. A latência cai para a faixa de dezenas de milissegundos e não há polling. Em troca, entra um componente operacional a mais: slot de replicação que precisa de monitoramento (um slot parado faz o WAL crescer até encher o disco), conectores para versionar e uma equipe que entenda o que fazer quando o conector perde posição.
A posição aqui: até algumas centenas de eventos por segundo, polling com SKIP LOCKED resolve e é o que deve ser escolhido primeiro. CDC se justifica quando a latência fim a fim importa de verdade ou quando o polling começa a competir por I/O com o tráfego transacional. Migrar de um para o outro depois é barato, porque o contrato de eventos e a tabela permanecem iguais.
Ordenação e o que o outbox não garante
Outbox não garante ordem global. Com vários workers de relay em paralelo, dois eventos da mesma entidade podem chegar ao broker invertidos: o PedidoCancelado ultrapassa o PedidoCriado. Duas saídas existem. A primeira é um único relay por partição lógica, particionando a leitura pelo id do agregado, de modo que eventos da mesma entidade sempre passem pelo mesmo worker em sequência. A segunda é carregar um número de versão do agregado no evento e fazer o consumidor ignorar versões mais antigas que a já aplicada.
A segunda é mais robusta, porque funciona também quando o broker reordena por partição ou reentrega depois de rebalanceamento. A primeira é suficiente quando o broker preserva ordem por chave de partição e o consumo é serial por chave.
Outro ponto que costuma ser esquecido: o payload no outbox é uma fotografia do momento da transação. Se o consumidor precisar do estado atual e buscar na API de origem, voltou a ter condição de corrida. O evento deve carregar o que o consumidor precisa, ou o consumidor deve aceitar trabalhar com o estado do evento.
Quando a publicação direta é a decisão certa
Nem todo evento merece outbox. Três cenários em que publicar direto é defensável:
- Eventos de telemetria, auditoria leve ou invalidação de cache, em que perder um em dez mil não gera dano e a próxima mensagem corrige o estado.
- Consumidores que reconciliam periodicamente contra a fonte, de modo que a perda ocasional vira atraso e não divergência.
- Serviços cujo banco já é o próprio broker, como filas implementadas sobre a mesma instância transacional, em que o enfileiramento já participa do commit.
Em contrapartida, qualquer evento que dispara dinheiro, estoque, e-mail transacional ou integração com terceiro precisa de entrega garantida na origem. A pergunta de triagem é objetiva: se este evento sumir, alguém descobre sozinho ou só quando o cliente reclama? Se for a segunda opção, outbox.
Custos operacionais que entram na conta
Adotar outbox adiciona trabalho contínuo, e convém listar antes. A tabela cresce à razão de um registro por evento: sem rotina de purga, ela vira a maior tabela do banco em poucos meses em sistemas movimentados. Apagar em lote de linhas antigas já publicadas, ou particionar por dia e descartar partições, evita o bloat que um DELETE massivo gera no PostgreSQL.
É preciso também alarme sobre a idade da linha pendente mais antiga. Esse é o indicador que importa: não a taxa de publicação, mas há quanto tempo o evento mais velho espera. Um relay travado em silêncio parece saudável em qualquer painel de throughput, e só esse indicador denuncia.
Por fim, o esquema dos eventos passa a ser contrato público. Eventos persistidos no outbox sobrevivem a deploys, então mudar o formato do payload exige compatibilidade com linhas ainda não publicadas e com consumidores em versões diferentes. Campos novos opcionais, nunca renomear nem remover sem janela de transição.
Um teste simples de maturidade: derrubar o relay por dez minutos em ambiente de homologação e observar. Se, ao religá-lo, os eventos pendentes saem em ordem razoável, nenhum consumidor quebra com duplicatas e o alarme de idade disparou durante a pausa, o desenho está pronto para produção. Se alguma dessas três coisas falha, o problema está na idempotência ou na observabilidade, e não no outbox.
Decisão: publicação direta para eventos descartáveis ou reconciliados; outbox com polling e SKIP LOCKED como padrão para o resto; CDC apenas quando a latência ou a carga de leitura provarem que o polling não basta.
Deixe um comentário