Saga e outbox: transação sem transação distribuída
- sistemas-distribuidos
- microsservicos
- backend
Publicado em por Gabriel Dias · 5 min de leitura · Engenharia
Dentro de um banco, atomicidade é grátis: BEGIN, um punhado de comandos, COMMIT. Ou tudo acontece
ou nada acontece.
No instante em que a operação atravessa dois bancos, ou dois serviços, essa garantia desaparece. E o que a maioria dos times faz é fingir que ela ainda existe, o que produz a categoria de bug mais difícil de reproduzir que existe.
Por que 2PC não é a resposta
A solução acadêmica é o commit em duas fases. Um coordenador pergunta a todos os participantes "vocês conseguem?"; se todos disserem sim, ele manda confirmar.
Funciona. E tem um problema que inviabiliza na prática: se o coordenador cair entre as duas fases, os participantes ficam com lock aberto, esperando indefinidamente, sem saber se devem confirmar ou desfazer.
É bloqueante, exige que todos os participantes suportem o protocolo, e cria um ponto único de falha justamente no componente cuja função é garantir confiabilidade.
Existe, funciona em contextos controlados, e quase ninguém quer.
Saga: quebrar em passos compensáveis
A resposta prática é a saga. Você quebra a operação numa sequência de transações locais, cada uma com uma compensação correspondente.
- Debitou da conta A e o crédito na conta B falhou? Roda o estorno na A.
- Reservou o estoque e o pagamento falhou? Libera a reserva.
- Criou o pedido e a emissão da nota falhou? Cancela o pedido.
O que a saga te dá: nada de lock distribuído. Cada passo é uma transação local normal, no banco que já é dono daquele dado.
O que ela te tira: atomicidade de verdade. Existe um intervalo em que o sistema está num estado intermediário observável. Alguém pode consultar e ver o pedido criado sem a nota.
E aqui está a parte que costuma travar a discussão: o seu produto precisa tolerar isso. Normalmente tolera, porque o mundo real funciona assim mesmo: a passagem é reservada antes do pagamento confirmar, o pedido é criado antes de a nota ser emitida.
Quando não tolera, a resposta não é 2PC. É reconsiderar a fronteira dos serviços: se duas coisas precisam ser atômicas de verdade, talvez elas devessem estar no mesmo banco.
Dois estilos de saga
Coreografia: cada serviço reage a eventos e publica os seus. Ninguém coordena. É simples de começar e vira difícil de entender quando passa de três ou quatro passos: ninguém consegue responder "qual é o fluxo completo?" olhando um lugar só.
Orquestração: existe um orquestrador que conhece o fluxo e chama cada passo. Mais fácil de entender, de monitorar e de depurar. Introduz um componente central, que precisa ser resiliente e ter estado persistido.
A recomendação prática: coreografia para fluxos de dois ou três passos; orquestração a partir daí. E se você escolher orquestração, persista o estado da saga, porque um orquestrador em memória que perde o estado ao reiniciar deixa sagas penduradas pela metade.
O padrão outbox
Agora o problema mais comum de todos, e o que mais causa bug silencioso:
salvarPedido(pedido)
publicarEvento("PedidoCriado", pedido)Não existe transação cobrindo os dois. Dois cenários ruins:
- Salvou e a publicação falhou: o pedido existe e ninguém foi notificado.
- Publicou e o commit falhou: meio sistema reagiu a um pedido que não existe.
O sintoma na vida real é aquele "de vez em quando um pedido não aparece no outro serviço e a gente não conseguiu reproduzir".
O padrão outbox resolve com uma ideia quase constrangedora de simples:
Na mesma transação em que você grava o pedido, grave também uma linha numa tabela de saída.
BEGIN
INSERT INTO pedidos (...) VALUES (...);
INSERT INTO outbox (tipo, payload, criado_em) VALUES (...);
COMMITUma transação local, atômica, sem coordenador, sem protocolo especial.
Depois, um processo separado lê a tabela outbox, publica na fila e marca como publicado. Se ele cair no meio, retoma de onde parou. Se publicar duas vezes (e vai publicar, eventualmente), o consumidor precisa ser idempotente.
A garantia deixa de ser "torcemos para dar certo" e passa a ser demonstrável: se está no banco, está na outbox; se está na outbox, será publicado.
A variante com CDC
Existe uma versão que dispensa até o processo de leitura: usar captura de mudança de dados. Ferramentas como Debezium leem o log de replicação do próprio banco e publicam as mudanças.
A tabela outbox vira apenas mais uma tabela sendo capturada, e você não escreve nem o publicador.
O custo: mais uma peça de infraestrutura para operar, e um cuidado específico no Postgres: o slot de replicação. Se o consumidor parar, o banco segura o WAL para ele, e o disco enche. Monitore o atraso do slot; já derrubou muito banco de produção.
Idempotência: a peça obrigatória
Nada disso funciona sem consumidores idempotentes.
A técnica padrão: todo evento carrega um identificador único. O consumidor mantém uma tabela de
eventos já processados e ignora repetição. Ou, melhor ainda, a operação em si é naturalmente
idempotente: um UPSERT em vez de um INSERT, uma atribuição em vez de um incremento.
O que fazer amanhã
Se você tem uma operação que grava no banco e publica um evento, você tem esse bug. Ele só ainda não incomodou o suficiente.
O caminho mais curto: crie a tabela outbox, mova a publicação para dentro da transação, escreva o publicador em cinquenta linhas.
Um e-mail por semana, sem enrolação
O que eu aprendi construindo software e IA na semana, em texto curto e direto, sem newsletter de 3 mil palavras. Cancele quando quiser.