Artigos /

Outbox e saga coreografada: quatro serviços fechando um pagamento

Nenhuma transação de banco cobre quatro serviços. O que muda quando o evento é gravado na mesma transação do estado e a compensação vira responsabilidade de quem começou a saga.

6 min de leitura#arquitetura #go #kafka #postgres

Um pagamento passa por quatro serviços: registrar o pagamento, checar fraude, mover saldo, notificar. Se o terceiro falha, como você desfaz os dois primeiros? Não existe BEGIN cobrindo os quatro.

Escrevi uma implementação de referência em Go para trabalhar esse cenário com calma, com Postgres por serviço e Kafka como espinha dorsal de eventos. O que segue são as decisões que sobraram depois de montar tudo.

Antes da saga existe um problema menor

Todo serviço que muda estado e avisa alguém faz duas escritas em sistemas diferentes. Grava no banco, publica no broker. Não existe transação em comum entre os dois, e qualquer ordem tem uma janela de falha:

commit no banco  ok   →  publish no Kafka falha    (estado sem evento)
publish no Kafka ok   →  crash antes do commit     (evento sem estado)

Isso é o dual write. O sintoma aparece longe da causa: um pedido que existe no banco e nunca chegou no serviço seguinte, ou um consumidor reagindo a um pagamento que não foi persistido. Com volume baixo demora a aparecer, e quando aparece vem como divergência de dados, não como erro.

Transactional outbox: uma escrita só

A saída é parar de publicar direto. O evento vira uma linha em uma tabela do próprio banco, gravada na mesma transação do estado de negócio:

BEGIN;
  INSERT INTO payments (id, amount, from_account, to_account, status)
       VALUES ($1, $2, $3, $4, 'CREATED');

  INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload)
       VALUES ($5, 'payment', $1, 'payment.received.v1', $6);
COMMIT;

Ou os dois inserts existem, ou nenhum existe. O broker sai do caminho crítico e o evento passa a ter exatamente a mesma durabilidade do dado que ele descreve.

A tabela é banal, e o índice é a parte que importa:

CREATE TABLE outbox (
    id             UUID PRIMARY KEY,
    aggregate_type TEXT NOT NULL,
    aggregate_id   UUID NOT NULL,
    event_type     TEXT NOT NULL,
    payload        JSONB NOT NULL,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_at   TIMESTAMPTZ
);

CREATE INDEX idx_outbox_unpublished
    ON outbox (created_at)
    WHERE published_at IS NULL;

O índice parcial cobre só as linhas pendentes. É a diferença entre varrer uma tabela que cresce para sempre e varrer uma fila que, em regime normal, tem quase nada dentro.

O relay publica depois, pelo menos uma vez

Um processo separado lê o que ainda não foi publicado e empurra para o Kafka:

SELECT id, aggregate_type, aggregate_id, event_type, payload
  FROM outbox
 WHERE published_at IS NULL
 ORDER BY created_at ASC
   FOR UPDATE SKIP LOCKED
 LIMIT 100;

SKIP LOCKED é o que permite rodar várias réplicas do relay sem coordenação: cada uma pega um lote diferente em vez de esperar o lock da outra. Publicou, marca published_at = now().

Repare na ordem: publica primeiro, marca depois. Se o processo morre entre as duas coisas, o evento vai de novo no ciclo seguinte. A garantia é at-least-once, e ela não tem conserto do lado do produtor. Marcar antes de publicar só troca duplicata por perda, que é bem pior.

O inbox fecha o ciclo do outro lado

Se a entrega repete, o consumidor precisa aguentar repetição. Cada serviço registra o que já processou e ignora o resto:

INSERT INTO processed_events (event_id, consumer)
     VALUES ($1, $2)
ON CONFLICT DO NOTHING;

Zero linhas afetadas significa que aquele evento já passou por ali, e o consumidor encerra a transação sem tocar em nada. O detalhe que faz funcionar é a chave primária ser composta, (event_id, consumer): dedup é por consumidor, não global. O mesmo payment.settled.v1 precisa ser processado pelo serviço de notificação e pelo de pagamento, cada um uma vez.

O insert de dedup, a mudança de estado e o evento seguinte vão na mesma transação:

BEGIN
  INSERT INTO processed_events ... ON CONFLICT DO NOTHING   -- já processado? encerra aqui
  <aplica a mudança de estado>
  INSERT INTO outbox (próximo evento)
COMMIT

Entrega at-least-once mais consumo idempotente dá efeito exactly-once. Vale ser exato aqui: entrega exactly-once não existe no meio de rede, o que existe é efeito aplicado uma vez só.

Coreografia: ninguém manda em ninguém

Com o transporte confiável, dá para distribuir o fluxo. Duas opções clássicas: orquestração, com um serviço central chamando os outros, ou coreografia, em que cada serviço só sabe qual evento consome e qual emite.

Aqui a escolha foi coreografia:

payment.received.v1payment.approved.v1payment.settled.v1payment.settled.v1paymentfraudledgernotification
payment.received.v1payment.approved.v1payment.settled.v1payment.settled.v1paymentfraudledgernotification
ServiçoConsomeEmiteEstado
paymentsettled, rejected, failedpayment.received.v1máquina de status do pagamento
fraudpayment.received.v1approved, rejecteddecisão de risco
ledgerpayment.approved.v1settled, failedcontas e lançamentos
notificationsettled, rejected, failednada, é terminalnotificações

Nome do tópico igual ao tipo do evento, chave do Kafka igual ao payment_id. A chave é o que garante ordem por pagamento: tudo de um mesmo pagamento cai na mesma partição, e a ordem entre pagamentos diferentes não importa.

Compensação sem coordenador

O caso interessante não é o caminho feliz. Quando o ledger não tem saldo, ele não avisa um orquestrador, ele emite payment.failed.v1. Quem começou a saga consome esse evento e reverte o próprio estado para FAILED. O mesmo vale para payment.rejected.v1, emitido pelo fraud.

É rollback distribuído, escrito como reação, não como comando.

E aqui mora a diferença que costuma passar batida: compensação não é rollback. O banco desfaz porque nada estava visível antes do commit. Uma saga já expôs cada passo intermediário para o resto do mundo, então compensar é aplicar uma operação nova, de sentido contrário, que também pode falhar e também precisa ser idempotente. Se o passo já disparou efeito externo, um e-mail, uma cobrança, um webhook, não existe compensação que apague aquilo. O melhor que dá para fazer é emitir a correção.

O que a coreografia cobra

Tirar o orquestrador não deixa o sistema mais simples, muda o lugar da complexidade.

O fluxo deixa de existir escrito em algum lugar. Nenhum arquivo descreve a sequência dos quatro passos, ela só emerge de quem escuta o quê. Ler o código de um serviço não conta a história completa, e é por isso que instrumentação deixa de ser opcional: o projeto propaga trace via OpenTelemetry em cada salto justamente para que a saga inteira apareça em um lugar só.

Somam-se as coisas que ninguém cuida por você: a tabela de outbox cresce e precisa de expurgo, a de processed_events também, o payload é um contrato público no momento em que outro serviço lê aquele JSON, e a borda HTTP precisa de idempotência própria, no caso um SETNX no Redis com a x-idempotency-key, senão o cliente que reenvia o POST cria dois pagamentos antes de qualquer evento existir.

Rodando

Sobe tudo, incluindo Postgres, Redis, Redpanda, coletor OTel e Jaeger:

make up

Um pagamento normal, que o fraud aprova em torno de 70% das vezes:

curl -X POST http://localhost:8080/payment \
  -H 'Content-Type: application/json' \
  -H 'x-idempotency-key: 11111111-1111-1111-1111-111111111111' \
  -d '{"amount": 1500, "from": "alice", "to": "bob"}'

E um valor alto o suficiente para o ledger não conseguir liquidar, que é o caminho de compensação:

curl -X POST http://localhost:8080/payment \
  -H 'x-idempotency-key: 33333333-3333-3333-3333-333333333333' \
  -d '{"amount": 999999999, "from": "carol", "to": "bob"}'

Depois disso o Jaeger mostra o pagamento atravessando os quatro serviços, e o status final no Postgres conta como a saga terminou.

O código está em mathehluiz/outbox-saga-choreography.