Todos os artigos

// Knowledge.log — 技術記事

Saga pattern: coreografia ou orquestração para transação distribuída sem 2PC

Depois de cortar o serviço: como coordenar pedido, pagamento e estoque sem 2PC, escolher entre coreografia e orquestração e provar que a compensação rodou.

O pedido chega, precisa debitar o pagamento e reservar estoque — e esses dois passos vivem em serviços com deploy, banco e ciclo de vida próprios. Se o estoque recusar depois que o pagamento já foi capturado, alguém precisa desfazer o pagamento, e não existe um COMMIT único amarrando os dois bancos. Este texto assume que o corte de serviço já aconteceu — se essa decisão ainda está em aberto, bounded contexts é o artigo anterior — e resolve o que vem depois: como coordenar a transação sem two-phase commit (2PC), quando usar eventos e quando usar um orquestrador central, e como provar que a compensação realmente rodou quando um passo falha.

Por que 2PC não escala entre serviços implantados de forma independente

A tentação é replicar o que o banco já resolve sozinho: um coordenador manda todo mundo preparar, todo mundo confirma, ou todo mundo desfaz. É exatamente isso que o protocolo de duas fases faz — e ele continua especificado e funcional para recursos XA sob um único gerenciador de transações. O XAResource do Java SE 25 documenta o contrato: "at transaction commit time, the resource managers are informed by the transaction manager to prepare, commit, or rollback a transaction according to the two-phase commit protocol" — prepare(Xid) é o voto, e os recursos ficam retidos até o gerenciador decidir. A especificação Jakarta Transactions 2.0 descreve o mesmo desenho: o TransactionManager invoca XAResource.prepare e depois XAResource.commit em cada recurso do grupo.

O problema não é o protocolo — é aplicá-lo entre serviços que ninguém implanta, versiona ou reinicia juntos. Chris Richardson resume assim na página do padrão saga: "2PC is not an option", e completa na primeira parte da série: usar 2PC numa arquitetura de microsserviços "is generally a bad idea. It's a form of synchronous communication that results in runtime coupling that significantly impacts the availability of an application". O guia de padrões da AWS chega à mesma conclusão para banco-por-serviço: "the two-phase commit is not an option" porque "each transaction is distributed across various databases, and there is no single controller that can coordinate a process that's similar to the two-phase commit". Quatro problemas concretos aparecem nessas fontes, não um genérico "2PC é ruim":

  • Bloqueio síncrono. Prepare-então-commit é uma ida e volta síncrona; os participantes esperam o coordenador decidir (Richardson: "runtime coupling").
  • Disponibilidade do coordenador. Sem um controlador único vivo, transações preparadas ficam paradas (AWS: "no single controller"; a recuperação via XAResource.recover existe justamente porque branches ficam "in prepared or heuristically completed states" quando o coordenador cai).
  • Tecnologia mista. Só participam recursos que implementam XAResource — bancos, filas ou serviços HTTP fora desse contrato não entram no mesmo Xid global.
  • Recursos retidos por muito tempo. O prepare mantém locks até o commit ou rollback chegar; um coordenador lento (ou fora do ar) segura esses recursos indefinidamente.

A saga troca isso por outra premissa: cada passo comita localmente, sem esperar ninguém. Se um passo posterior falhar, o trabalho anterior "já está committed" (Richardson, parte 1) — e desfazer significa uma transação compensatória nova, não um rollback.

Saga coreografada: eventos, sem coordenador central

Na coreografia "each local transaction publishes domain events that trigger local transactions in other services" (Richardson) — "there isn't a central coordinator"; os serviços "subscribe to each other's events". A Microsoft descreve o mesmo desenho como serviços que "exchange events without a centralized controller".

Caminho feliz para pedido → pagamento → estoque:

  1. OrderService grava o pedido como PENDING e publica OrderPlaced{sagaId, orderId, amount}.
  2. PaymentService, ao ouvir OrderPlaced, captura o pagamento e publica PaymentCaptured{sagaId, paymentId}.
  3. InventoryService, ao ouvir PaymentCaptured, reserva o estoque e publica InventoryReserved{sagaId}.
  4. OrderService, ao ouvir InventoryReserved, confirma o pedido e publica OrderConfirmed{sagaId}.

Quando o estoque recusa depois que o pagamento já foi capturado:

  1. InventoryService publica InventoryRejected{sagaId, reason}.
  2. PaymentService, ao ouvir InventoryRejected, roda a compensação (estorno) e publica PaymentRefunded{sagaId}.
  3. OrderService, ao ouvir InventoryRejected ou PaymentRefunded, rejeita o pedido e publica OrderRejected{sagaId}.

Nenhum serviço chama outro diretamente; cada um reage a um evento de domínio que já aconteceu. A Microsoft resume a regra de compensação da mesma forma: ao falhar, o serviço "publishes a failure message. Services that subscribe to that message can run predefined compensating actions." O ganho é desacoplamento — o custo é que ninguém sozinho enxerga o fluxo inteiro; ele está distribuído nos handlers de três serviços diferentes.

Saga orquestrada: uma máquina de estado central

Na orquestração "an orchestrator (object) tells the participants what local transactions to execute" (Richardson). A comunicação vira comando/resposta: o orquestrador "sends a command message", processa a resposta e decide o próximo passo. Ele também persiste o progresso da saga e, ao falhar, "executes the compensating transactions in bottom to top order" (parte 4 da série). A Microsoft descreve o mesmo papel: um "centralized controller ... stores and interprets the states of each task, and handles failure recovery by using compensating transactions."

Mesmo fluxo, agora orquestrado:

  1. A API cria a instância da saga (sagaId), grava o pedido como PENDING.
  2. O orquestrador envia o comando CapturePayment; PaymentService responde PaymentCaptured; o orquestrador registra o passo.
  3. O orquestrador envia ReserveInventory; InventoryService responde. Se InventoryReserved, o orquestrador confirma o pedido e marca a saga COMPLETED.

Se a resposta for de falha, o orquestrador envia RefundPayment, espera PaymentRefunded, rejeita o pedido localmente e marca a saga COMPENSATED. O ponto que costuma confundir: participantes não assinam eventos de domínio uns dos outros — eles recebem comandos e respondem só ao orquestrador. É essa diferença de assinatura de mensagem, não o desenho do banco, que separa os dois estilos.

Critério objetivo, não preferência de time

As mesmas fontes dão testes verificáveis, não "depende do gosto":

CritérioCoreografia quando...Orquestração quando...
Nº de participantesPoucos serviços, sem lógica de coordenação (Microsoft; AWS: "only a few participants")Fluxo complexo ou cresce com o tempo (Microsoft); Richardson: lógica "scattered around the participating services" fica difícil de acompanhar
Dono do processoNinguém precisa possuir o processo — é colaboração puraUm serviço (aqui, Order) é o dono natural da sequência
AcoplamentoAceita acoplamento a contratos de evento e risco de dependência cíclicaParticipantes acoplam só ao contrato comando/resposta com o orquestrador; evita ciclo
Visibilidade do progressoAceita não ter uma visão única do que está em andamentoPrecisa de uma máquina de estado consultável — "stores and interprets the states of each task"
Domínio de falhaUm serviço central de processo seria um SPOF inaceitávelAceita (e reforça) um coordenador único; AWS admite que ele "can become a single point of failure"

Regra prática para o caso deste artigo: três participantes com um dono de processo claro (Order) → orquestração como padrão. Coreografia se justifica quando o time quer explicitamente não ter um serviço de processo e consegue manter o grafo de eventos pequeno e acíclico — o risco que a Microsoft e a AWS citam é justamente PaymentService reagir a um evento e sem querer republicar algo que OrderService já escuta, fechando um ciclo.

Observando a compensação: como provar que ela rodou

"Sagas restauraram a consistência" não é uma frase que se verifica lendo o código do handler — é uma frase que se verifica lendo o log. A Microsoft é direta sobre isso no padrão de transação compensatória: a infraestrutura precisa "reliably monitor compensation logic progress" e permitir "correlate and audit both the original operation and its compensation end-to-end"; passos compensatórios "can fail" e precisam ser idempotentes. Quatro sinais cobrem isso sem exigir tracing distribuído:

  1. sagaId em toda mensagem e toda linha de log — comando, evento, resposta.
  2. Log de passos append-only: sagaId, step, dir=FORWARD|COMPENSATE, ok. Na orquestração esse log é a máquina de estado persistida; na coreografia é a única forma de reconstruir o fluxo depois, já que nenhum serviço vê o todo.
  3. Eventos de resultado, não só *Failed: publique PaymentRefunded, InventoryReleased, OrderRejected — o sucesso da compensação é informação tão importante quanto a falha original.
  4. Contador de compensação (um LongAdder já serve em produção): compensação rodou de fato se e somente se existe uma linha COMPENSATE com ok=true e o evento de resultado correspondente para aquele sagaId. Alerte se FORWARD falhou e não apareceu nenhum COMPENSATE depois.

Exemplo prático em Java 25

O demo abaixo roda em processo, sem broker: um Bus publica/assina por tipo de mensagem, o orquestrador manda comandos e reage a respostas, e o SagaLog é ao mesmo tempo o log estruturado e a fonte de verdade de compensação. Compila e roda direto com java SagaDemo.java (Java 25, sem preview, sem dependência externa).

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.Consumer;

public final class SagaDemo {
    public static void main(String[] args) {
        Bus bus = new Bus();
        SagaLog log = new SagaLog();

        Set<String> paymentShouldFail = Set.of();
        Set<String> outOfStock = Set.of("saga-2");

        new PaymentService(bus, paymentShouldFail);
        new InventoryService(bus, outOfStock);
        Orchestrator orchestrator = new Orchestrator(bus, log);

        System.out.println("--- saga-1: caminho feliz ---");
        orchestrator.start("saga-1", "order-1");

        System.out.println("--- saga-2: estoque recusa, compensa pagamento e pedido ---");
        orchestrator.start("saga-2", "order-2");

        System.out.println("--- saga-2: mesma mensagem de novo (reentrega) ---");
        bus.publish(new Msg("cmd.reserveInventory", "saga-2", "order-2", ""));

        System.out.printf("compensacoes ok = %d%n", log.compensateOkCount());
        System.out.printf("compensated(saga-1) = %s%n", log.compensated("saga-1"));
        System.out.printf("compensated(saga-2) = %s%n", log.compensated("saga-2"));
    }
}

record Msg(String type, String sagaId, String orderId, String detail) {}

final class Bus {
    private final Map<String, List<Consumer<Msg>>> handlers = new ConcurrentHashMap<>();

    void on(String type, Consumer<Msg> handler) {
        handlers.computeIfAbsent(type, k -> new CopyOnWriteArrayList<>()).add(handler);
    }

    void publish(Msg m) {
        for (Consumer<Msg> h : handlers.getOrDefault(m.type(), List.of())) h.accept(m);
    }
}

final class SagaLog {
    enum Dir { FORWARD, COMPENSATE }
    record Line(String sagaId, String step, Dir dir, boolean ok) {}

    private final List<Line> lines = new CopyOnWriteArrayList<>();
    private final Set<String> seen = ConcurrentHashMap.newKeySet();
    private final LongAdder compensateOk = new LongAdder();

    void record(String sagaId, String step, Dir dir, boolean ok) {
        if (!seen.add(sagaId + ":" + step + ":" + dir)) {
            System.out.printf("sagaId=%s step=%s dir=%s ok=%s duplicate=true%n", sagaId, step, dir, ok);
            return;
        }
        lines.add(new Line(sagaId, step, dir, ok));
        System.out.printf("sagaId=%s step=%s dir=%s ok=%s%n", sagaId, step, dir, ok);
        if (dir == Dir.COMPENSATE && ok) compensateOk.increment();
    }

    boolean compensated(String sagaId) {
        return lines.stream().anyMatch(l -> l.sagaId().equals(sagaId) && l.dir() == Dir.COMPENSATE && l.ok());
    }

    long compensateOkCount() { return compensateOk.sum(); }
}

final class PaymentService {
    private final Bus bus;
    private final Set<String> shouldFail;
    private final Set<String> captureAttempted = ConcurrentHashMap.newKeySet();
    private final Set<String> refundAttempted = ConcurrentHashMap.newKeySet();

    PaymentService(Bus bus, Set<String> shouldFail) {
        this.bus = bus;
        this.shouldFail = shouldFail;
        bus.on("cmd.capturePayment", this::onCapture);
        bus.on("cmd.refundPayment", this::onRefund);
    }

    private void onCapture(Msg m) {
        if (!captureAttempted.add(m.sagaId())) return;
        if (shouldFail.contains(m.sagaId())) {
            bus.publish(new Msg("reply.paymentFailed", m.sagaId(), m.orderId(), "cartao-recusado"));
            return;
        }
        bus.publish(new Msg("reply.paymentCaptured", m.sagaId(), m.orderId(), ""));
    }

    private void onRefund(Msg m) {
        if (!refundAttempted.add(m.sagaId())) return;
        bus.publish(new Msg("reply.paymentRefunded", m.sagaId(), m.orderId(), ""));
    }
}

final class InventoryService {
    private final Bus bus;
    private final Set<String> outOfStock;
    private final Set<String> reserveAttempted = ConcurrentHashMap.newKeySet();

    InventoryService(Bus bus, Set<String> outOfStock) {
        this.bus = bus;
        this.outOfStock = outOfStock;
        bus.on("cmd.reserveInventory", this::onReserve);
    }

    private void onReserve(Msg m) {
        if (!reserveAttempted.add(m.sagaId())) return;
        if (outOfStock.contains(m.sagaId())) {
            bus.publish(new Msg("reply.inventoryRejected", m.sagaId(), m.orderId(), "sem-estoque"));
            return;
        }
        bus.publish(new Msg("reply.inventoryReserved", m.sagaId(), m.orderId(), ""));
    }
}

final class Orchestrator {
    private final Bus bus;
    private final SagaLog log;

    Orchestrator(Bus bus, SagaLog log) {
        this.bus = bus;
        this.log = log;
        bus.on("reply.paymentCaptured", this::onPaymentCaptured);
        bus.on("reply.paymentFailed", this::onPaymentFailed);
        bus.on("reply.inventoryReserved", this::onInventoryReserved);
        bus.on("reply.inventoryRejected", this::onInventoryRejected);
        bus.on("reply.paymentRefunded", this::onPaymentRefunded);
    }

    void start(String sagaId, String orderId) {
        bus.publish(new Msg("cmd.capturePayment", sagaId, orderId, ""));
    }

    private void onPaymentCaptured(Msg m) {
        log.record(m.sagaId(), "PAYMENT", SagaLog.Dir.FORWARD, true);
        bus.publish(new Msg("cmd.reserveInventory", m.sagaId(), m.orderId(), ""));
    }

    private void onPaymentFailed(Msg m) {
        log.record(m.sagaId(), "PAYMENT", SagaLog.Dir.FORWARD, false);
        log.record(m.sagaId(), "ORDER", SagaLog.Dir.COMPENSATE, true);
    }

    private void onInventoryReserved(Msg m) {
        log.record(m.sagaId(), "INVENTORY", SagaLog.Dir.FORWARD, true);
        log.record(m.sagaId(), "ORDER", SagaLog.Dir.FORWARD, true);
    }

    private void onInventoryRejected(Msg m) {
        log.record(m.sagaId(), "INVENTORY", SagaLog.Dir.FORWARD, false);
        bus.publish(new Msg("cmd.refundPayment", m.sagaId(), m.orderId(), ""));
    }

    private void onPaymentRefunded(Msg m) {
        log.record(m.sagaId(), "PAYMENT", SagaLog.Dir.COMPENSATE, true);
        log.record(m.sagaId(), "ORDER", SagaLog.Dir.COMPENSATE, true);
    }
}

Para a saga-2, a sequência de log é PAYMENT FORWARD ok=trueINVENTORY FORWARD ok=falsePAYMENT COMPENSATE ok=trueORDER COMPENSATE ok=true. Ao final, compensateOkCount() == 2 e compensated("saga-2") == true; compensated("saga-1") fica false, porque nada precisou ser desfeito. A reentrega manual de cmd.reserveInventory para saga-2 não gera evento novo: reserveAttempted.add já retornou false na primeira tentativa, então o handler sai sem publicar — a idempotência está no serviço, não só no log. O SagaLog tem sua própria checagem de duplicidade (seen) como segunda camada, para o caso de dois orquestradores (ou uma reentrega em outro ponto do fluxo) tentarem gravar o mesmo sagaId:step:dir duas vezes.

A coreografia usaria o mesmo Bus, mas sem a classe Orchestrator: cada serviço assinaria eventos de domínio em vez de comandos.

// PaymentService assina o evento de domínio, não um comando do orquestrador
bus.on("OrderPlaced", m -> capturarPagamentoEPublicar("PaymentCaptured", m));
bus.on("InventoryRejected", m -> estornarEPublicar("PaymentRefunded", m));

// InventoryService reage ao evento do serviço anterior na cadeia
bus.on("PaymentCaptured", m -> reservarEPublicar("InventoryReserved", m));

A troca de arquitetura acontece inteira na fiação (bus.on): quem assina o quê. O SagaLog e a lógica de idempotência não mudam — a observabilidade descrita na seção anterior serve para os dois estilos.

Armadilhas comuns

  • Tratar compensação como rollback. O passo anterior já comitou local (Richardson: "already committed"); a Microsoft reforça que "data can't be rolled back because saga participants commit changes". Correção: modele a compensação como uma transação de negócio nova (estorno, cancelamento), não como desfazer um UPDATE. Observe pelo par FORWARD ok=true seguido de COMPENSATE ok=true no log — se só existe o primeiro, a compensação nunca terminou.
  • Dual-write sem outbox. Gravar o estado local e publicar o evento são duas operações; uma pode falhar depois da outra. Isso é assunto de infraestrutura de mensageria, coberto em idempotência e outbox — aqui fica só o aviso de que o demo em processo não tem esse problema porque não há dual-write.
  • Compensação não-idempotente. Reentrega de mensagem (reconexão de fila, retry de cliente) não pode estornar duas vezes. Correção: o handler verifica um conjunto de tentativas por sagaId antes de agir, como em captureAttempted/refundAttempted/reserveAttempted acima. Observe contando quantas vezes duplicate=true aparece no log — se o número cresce, algum produtor está reentregando mais do que deveria.
  • Ciclo na coreografia. Se PaymentService reagir a InventoryRejected publicando algo que OrderService já escuta como gatilho de novo pagamento, o grafo de eventos fecha um ciclo. Correção: desenhe o grafo de eventos antes de codar e confirme que é acíclico; se não for, é sinal de que a saga precisa de um orquestrador. Observe rastreando sagaId através dos tipos de evento publicados — um sagaId que aparece mais vezes do que passos existem no fluxo indica ciclo.
  • Orquestrador como SPOF não declarado. O demo acima roda em processo e perde tudo se o processo cair no meio de uma saga. Em produção o estado do orquestrador (o próprio SagaLog) precisa sobreviver ao restart do processo. Observe monitorando sagas com passo FORWARD gravado há mais tempo que o SLA do fluxo sem o passo seguinte — é o sinal de orquestrador que caiu no meio.
  • Isolamento fantasma. Entre PaymentCaptured e InventoryReserved, o pedido existe como PENDING e outro fluxo pode tentar operar sobre ele. Mantenha o estado PENDING explícito até confirmação ou rejeição — é o "semantic lock" que a saga não resolve sozinha (esse é assunto de modelagem de domínio, já coberto em bounded contexts, não repetido aqui).

Recomendação

Para o caso de três participantes com um dono de processo claro — pedido, pagamento, estoque, com Order como serviço que inicia e fecha o fluxo — a orquestração é o padrão default: dá visibilidade única do progresso, evita ciclo de eventos e concentra a lógica de compensação num lugar auditável. A DevDojo trocaria para coreografia apenas quando o time quiser deliberadamente não ter um serviço de processo (poucos participantes, sem plano de crescer) e conseguir manter o grafo de eventos pequeno e acíclico — nesse caso, o ganho de desacoplamento compensa a perda de visibilidade central. Em qualquer um dos dois estilos, o critério de "funcionou" não é o código compilar: é o sagaId aparecer em toda mensagem, o log de passos registrar FORWARD e COMPENSATE de forma append-only, e o evento de resultado (PaymentRefunded, não só InventoryRejected) existir para provar que a compensação terminou.

javaarchitecture

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos