Pular para o conteúdo
Voltar para todos os posts

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 (...);
COMMIT

Uma 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.