DEV Community

Cover image for Dominando Transações Distribuídas em Microsserviços: Do 2PC ao Padrão Saga com Java 17, Spring Boot 3 e Kafka
Matheus Sena
Matheus Sena

Posted on

Dominando Transações Distribuídas em Microsserviços: Do 2PC ao Padrão Saga com Java 17, Spring Boot 3 e Kafka

1. O problema: o dia em que o @Transactional parou de funcionar

No monólito, fechar um pedido era assim:

@Transactional
public void fecharPedido(PedidoRequest req) {
    pedidoRepository.save(pedido);        // tabela PEDIDO
    pagamentoRepository.save(pagamento);  // tabela PAGAMENTO
    estoqueRepository.baixar(itens);      // tabela ESTOQUE
}
Enter fullscreen mode Exit fullscreen mode

Se o estoque falhasse, o banco fazia rollback de tudo. ACID de graça.

Aí o sistema foi quebrado em microsserviços — Pedido, Pagamento e Estoque — e cada um ganhou seu próprio banco (database per service). Agora o fluxo vira três chamadas, para três processos, com três transações independentes.

O que acontece se o pagamento foi aprovado e o estoque falhou? O cliente foi cobrado por um produto que não vai receber. Não existe mais um rollback() que desfaça tudo.

Esse é o problema das transações distribuídas: garantir que uma operação de negócio que envolve vários serviços termine em um estado consistente, mesmo com falhas de rede, timeouts e serviços fora do ar.

Existem duas grandes famílias de solução:

Abordagem Consistência Ideia central
2PC (Two-Phase Commit) Forte (atômica) Um coordenador trava todo mundo até todos concordarem
Saga Eventual Cada passo comita sozinho; se der errado, compensa

Vamos às duas.


2. Two-Phase Commit (2PC): a solução "clássica"

O 2PC (padronizado no XA, e no Java via JTA) funciona em duas fases:

  1. Prepare: o coordenador pergunta a cada participante "você consegue comitar?". Cada banco executa a operação, grava no log, mantém os locks e responde SIM ou NÃO.
  2. Commit/Rollback: se todos disseram SIM, o coordenador manda commit. Se alguém disse NÃO (ou não respondeu), manda rollback para todos.

No papel é perfeito: atomicidade entre bancos diferentes. Na prática, em microsserviços, ele cobra caro:

  • É bloqueante. Se o coordenador cair depois do prepare e antes do commit, os participantes ficam "em dúvida", segurando locks em linhas que outras transações precisam.
  • Latência = o participante mais lento. Todo mundo espera todo mundo. Um serviço lento derruba a vazão do fluxo inteiro.
  • Acoplamento síncrono. Todos os serviços precisam estar no ar ao mesmo tempo. Isso vai contra a ideia de serviços independentes.
  • Suporte limitado. O Kafka não participa de transações XA, e boa parte dos bancos NoSQL e APIs REST de terceiros (gateway de pagamento, por exemplo) também não.

💡 Quando 2PC ainda faz sentido? Dentro de um mesmo serviço, quando você precisa escrever atomicamente em dois recursos XA que você controla (dois bancos relacionais, um banco + uma fila JMS) e o volume é baixo. Entre microsserviços, quase nunca.

Em sistemas distribuídos modernos, a escolha costuma ser abrir mão da consistência imediata em troca de disponibilidade e escala (o famoso trade-off do teorema CAP). É aí que entra a Saga.


3. O Padrão Saga

O conceito vem de um artigo de 1987 (Garcia-Molina e Salem) sobre transações de longa duração, e foi popularizado no mundo de microsserviços por Chris Richardson.

Uma Saga é uma sequência de transações locais. Cada passo:

  • atualiza o banco do seu próprio serviço e comita;
  • publica um evento/mensagem que dispara o próximo passo.

Se um passo falha, a Saga executa transações de compensação para desfazer, semanticamente, o que os passos anteriores já fizeram.

Repare na palavra semanticamente. Compensar não é ROLLBACK: o dado já foi comitado e pode até ter sido lido por outro processo. Compensar é executar uma nova ação de negócio que anula o efeito da anterior:

Transação Compensação
Criar pedido Cancelar pedido (status CANCELADO, não DELETE)
Cobrar cartão Estornar cobrança
Reservar estoque Liberar reserva
Enviar e-mail de confirmação Enviar e-mail de cancelamento 😅

O último exemplo mostra por que nem tudo é compensável. Por isso se classificam os passos assim:

  • Compensáveis: podem ser desfeitos (cobrança, reserva).
  • Pivô: o ponto de não retorno. Se ele comita, a Saga vai até o fim.
  • Retentáveis: vêm depois do pivô e são garantidos de terminar (com retry), como agendar a entrega.

🧠 Dica de modelagem: coloque os passos que mais falham (validação de estoque, antifraude) antes dos passos caros de compensar (cobrança no cartão).

Existem duas formas de coordenar uma Saga: coreografia e orquestração.


4. Coreografia: cada serviço sabe o próximo passo

Não existe coordenador. Cada serviço escuta eventos e reage:

  1. Pedido cria o pedido como PENDENTE e publica PedidoCriado.
  2. Pagamento consome, cobra e publica PagamentoAprovado (ou PagamentoRecusado).
  3. Estoque consome, reserva e publica EstoqueReservado (ou EstoqueFalhou).
  4. Pagamento escuta EstoqueFalhou e estorna. Pedido escuta e cancela.

Vantagens: baixíssimo acoplamento, nenhum ponto central de falha, rápido de começar.

Desvantagens: a lógica do fluxo fica espalhada por N serviços. Para responder "em que passo está o pedido 123?" você precisa juntar logs de todo mundo. Com 5+ passos vira um emaranhado de eventos, e é fácil criar dependências cíclicas.

👉 Boa para fluxos curtos (2–4 passos) e estáveis.


5. Orquestração: um maestro conduzindo a Saga

Um componente, o orquestrador, guarda a máquina de estados da Saga. Ele envia comandos ("processe o pagamento") e reage às respostas ("pagamento aprovado/recusado"), decidindo o próximo passo ou disparando compensações.

Vantagens: o fluxo inteiro está num lugar só, é fácil de testar, de monitorar (uma tabela saga_state responde "onde está o pedido 123?") e de evoluir. Os serviços participantes ficam "burros": só executam comandos.

Desvantagens: o orquestrador vira peça crítica. Ele precisa persistir estado, ser idempotente e ter alta disponibilidade. Também existe o risco de concentrar regra de negócio demais nele.

👉 Boa para fluxos longos, com muitas regras, ou quando a auditabilidade importa.

Resumo da escolha

Critério Coreografia Orquestração
Nº de passos Poucos Muitos
Visibilidade do fluxo Baixa Alta
Acoplamento Mínimo Serviços → orquestrador
Testes de ponta a ponta Difíceis Mais simples
Ponto central de falha Não Sim (mitigável)

Na implementação a seguir vou usar orquestração, porque ela deixa os conceitos mais visíveis no código.


6. Os três problemas que ninguém te conta

Antes do código, três armadilhas que derrubam a maioria das Sagas em produção.

6.1 Dual-write → Transactional Outbox

O código ingênuo:

@Transactional
public void criarPedido(...) {
    pedidoRepository.save(pedido);
    kafkaTemplate.send("pagamento.commands", comando); // 💣
}
Enter fullscreen mode Exit fullscreen mode

Se o send funcionar e o commit do banco falhar, você publicou um comando para um pedido que não existe. Se o commit funcionar e o Kafka estiver fora, o pedido fica PENDENTE para sempre. São dois sistemas e não há transação entre eles.

A solução é o Transactional Outbox: grave o evento numa tabela outbox na mesma transação do dado de negócio. Um processo separado (um poller ou o Debezium via CDC) lê essa tabela e publica no Kafka.

6.2 At-least-once → consumidores idempotentes

Com outbox + retries, a garantia é pelo menos uma vez. Mensagens vão chegar duplicadas: o relay publica e cai antes de marcar como enviada, o consumidor processa e cai antes de comitar o offset, um rebalance acontece no meio, etc.

Todo consumidor precisa ser idempotente: processar a mesma mensagem duas vezes tem que dar o mesmo resultado de processar uma vez. A forma mais robusta é uma tabela processed_messages com o messageId como chave primária, gravada na mesma transação do efeito de negócio.

6.3 Falta de isolamento → contramedidas

Sagas não têm o "I" do ACID. Enquanto a Saga roda, outros processos enxergam estados intermediários. Contramedidas comuns:

  • Semantic lock: status como PENDENTE ou APROVACAO_PENDENTE sinalizam "não mexa, tem saga em andamento".
  • Valores comutativos: prefira saldo = saldo - 100 a saldo = 900.
  • Reler o valor antes de agir, quando ele pode ter mudado.
  • Versionamento (@Version) para optimistic locking.

7. Mão na massa: Saga orquestrada com Java 17, Spring Boot 3 e Kafka

Cenário

Um e-commerce com três serviços:

  • order-service (orquestrador + dono do pedido)
  • payment-service
  • inventory-service

Fluxo feliz: Pedido PENDENTE → Pagamento → Estoque → Pedido APROVADO.
Se o estoque falhar: Estorno do pagamento → Pedido CANCELADO.

Tópicos Kafka:

Tópico Quem publica Quem consome
payment.commands Orquestrador payment-service
inventory.commands Orquestrador inventory-service
saga.replies Participantes Orquestrador

Use o sagaId como chave da mensagem: assim todas as mensagens de uma mesma saga caem na mesma partição e mantêm a ordem.

7.1 Infra local (docker-compose)

services:
  kafka:
    image: apache/kafka:3.8.0
    ports: ["9092:9092"]
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1

  postgres:
    image: postgres:16
    ports: ["5432:5432"]
    environment:
      POSTGRES_USER: saga
      POSTGRES_PASSWORD: saga
      POSTGRES_DB: orders
Enter fullscreen mode Exit fullscreen mode

Kafka em modo KRaft, sem Zookeeper.

7.2 Dependências (pom.xml do order-service)

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.3.5</version>
</parent>

<properties>
  <java.version>17</java.version>
</properties>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
  </dependency>
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
  </dependency>
</dependencies>
Enter fullscreen mode Exit fullscreen mode

7.3 Contratos de mensagem com records e sealed interfaces

Java 17 deixa os contratos enxutos e fechados: o compilador sabe exatamente quais comandos existem.

public sealed interface SagaCommand
        permits ProcessPayment, RefundPayment, ReserveInventory {
    UUID sagaId();
}

public record ProcessPayment(UUID sagaId, UUID orderId, BigDecimal amount)
        implements SagaCommand {}

public record RefundPayment(UUID sagaId, UUID orderId, BigDecimal amount)
        implements SagaCommand {}

public record ReserveInventory(UUID sagaId, UUID orderId, List<OrderItem> items)
        implements SagaCommand {}

public record OrderItem(String sku, int quantity) {}

// Resposta única para todos os participantes
public record SagaReply(
        UUID messageId,   // usado para idempotência
        UUID sagaId,
        SagaStep step,    // PAYMENT, INVENTORY, REFUND
        boolean success,
        String reason) {}

public enum SagaStep { PAYMENT, INVENTORY, REFUND }
Enter fullscreen mode Exit fullscreen mode

7.4 Estado da Saga (a máquina de estados persistida)

public enum SagaStatus {
    STARTED,
    PAYMENT_APPROVED,
    COMPENSATING,
    COMPLETED,
    CANCELLED
}

@Entity
@Table(name = "saga_state")
public class SagaState {

    @Id
    private UUID sagaId;
    private UUID orderId;

    @Enumerated(EnumType.STRING)
    private SagaStatus status;

    private String failureReason;
    private Instant updatedAt;

    @Version // optimistic lock: evita duas respostas mudando o estado ao mesmo tempo
    private Long version;

    protected SagaState() {}

    public SagaState(UUID sagaId, UUID orderId) {
        this.sagaId = sagaId;
        this.orderId = orderId;
        this.status = SagaStatus.STARTED;
        this.updatedAt = Instant.now();
    }

    public void transitionTo(SagaStatus next) {
        this.status = next;
        this.updatedAt = Instant.now();
    }

    // getters...
}
Enter fullscreen mode Exit fullscreen mode

7.5 Outbox

@Entity
@Table(name = "outbox")
public class OutboxMessage {

    @Id
    private UUID id;
    private String topic;
    private String messageKey;
    private String type;      // ex.: "ProcessPayment"

    @Column(columnDefinition = "jsonb")
    @JdbcTypeCode(SqlTypes.JSON)
    private String payload;

    private Instant createdAt;
    private Instant publishedAt;

    protected OutboxMessage() {}

    public OutboxMessage(String topic, String key, String type, String payload) {
        this.id = UUID.randomUUID();
        this.topic = topic;
        this.messageKey = key;
        this.type = type;
        this.payload = payload;
        this.createdAt = Instant.now();
    }

    public void markPublished() { this.publishedAt = Instant.now(); }
    // getters...
}
Enter fullscreen mode Exit fullscreen mode

Um componente que enfileira comandos na outbox. Ele roda dentro da transação de quem chamou (MANDATORY garante isso):

@Component
@RequiredArgsConstructor
public class OutboxWriter {

    private final OutboxRepository repository;
    private final ObjectMapper mapper;

    @Transactional(propagation = Propagation.MANDATORY)
    public void enqueue(String topic, SagaCommand command) {
        enqueue(topic, command.sagaId().toString(), command);
    }

    @Transactional(propagation = Propagation.MANDATORY)
    public void enqueue(String topic, String key, Object payload) {
        try {
            repository.save(new OutboxMessage(
                    topic,
                    key,
                    payload.getClass().getSimpleName(),
                    mapper.writeValueAsString(payload)));
        } catch (JsonProcessingException e) {
            throw new IllegalStateException("Falha ao serializar mensagem", e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

E o relay, que publica o que está pendente. Aqui uso polling para manter o exemplo simples; em produção, Debezium + Outbox Event Router é uma ótima alternativa.

@Component
@RequiredArgsConstructor
@Slf4j
public class OutboxRelay {

    private final OutboxRepository repository;
    private final KafkaTemplate<String, String> kafka;

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publishPending() {
        // SELECT ... ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED
        List<OutboxMessage> batch = repository.lockNextBatch(100);

        for (OutboxMessage msg : batch) {
            ProducerRecord<String, String> record =
                    new ProducerRecord<>(msg.getTopic(), msg.getMessageKey(), msg.getPayload());
            record.headers().add("type", msg.getType().getBytes(StandardCharsets.UTF_8));
            record.headers().add("messageId", msg.getId().toString().getBytes(StandardCharsets.UTF_8));

            try {
                kafka.send(record).get(5, TimeUnit.SECONDS); // espera o ack
                msg.markPublished();
            } catch (Exception e) {
                log.warn("Falha ao publicar {}, tenta no próximo ciclo", msg.getId(), e);
                break; // preserva a ordem: não pula mensagens
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
public interface OutboxRepository extends JpaRepository<OutboxMessage, UUID> {

    @Query(value = """
            SELECT * FROM outbox
             WHERE published_at IS NULL
             ORDER BY created_at
             LIMIT :limit
             FOR UPDATE SKIP LOCKED
            """, nativeQuery = true)
    List<OutboxMessage> lockNextBatch(@Param("limit") int limit);
}
Enter fullscreen mode Exit fullscreen mode

O FOR UPDATE SKIP LOCKED permite subir várias instâncias do serviço sem que duas publiquem a mesma linha ao mesmo tempo.

⚠️ Repare: se o send funcionar e a instância cair antes do commit, a mensagem será publicada de novo. Isso é esperado. É por isso que o próximo passo, idempotência, não é opcional.

7.6 O orquestrador

Iniciando a Saga. Pedido, estado da Saga e primeiro comando são gravados numa única transação local:

@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {

    private final OrderRepository orders;
    private final SagaStateRepository sagas;
    private final OutboxWriter outbox;

    @Transactional
    public UUID start(CreateOrderRequest req) {
        Order order = orders.save(Order.pending(req.customerId(), req.items(), req.total()));

        UUID sagaId = UUID.randomUUID();
        sagas.save(new SagaState(sagaId, order.getId()));

        outbox.enqueue("payment.commands",
                new ProcessPayment(sagaId, order.getId(), order.getTotal()));

        return order.getId();
    }
Enter fullscreen mode Exit fullscreen mode

Reagindo às respostas. Toda a lógica de transição fica aqui, legível de cima a baixo:

    @Transactional
    public void handle(SagaReply reply) {
        SagaState saga = sagas.findById(reply.sagaId())
                .orElseThrow(() -> new IllegalStateException("Saga desconhecida " + reply.sagaId()));
        Order order = orders.getReferenceById(saga.getOrderId());

        switch (reply.step()) {
            case PAYMENT -> onPayment(saga, order, reply);
            case INVENTORY -> onInventory(saga, order, reply);
            case REFUND -> onRefund(saga, order, reply);
        }
    }

    private void onPayment(SagaState saga, Order order, SagaReply reply) {
        if (saga.getStatus() != SagaStatus.STARTED) return; // resposta duplicada/atrasada

        if (reply.success()) {
            saga.transitionTo(SagaStatus.PAYMENT_APPROVED);
            outbox.enqueue("inventory.commands",
                    new ReserveInventory(saga.getSagaId(), order.getId(), order.itemsAsDto()));
        } else {
            // nada a compensar: o pagamento nem aconteceu
            order.cancel(reply.reason());
            saga.transitionTo(SagaStatus.CANCELLED);
        }
    }

    private void onInventory(SagaState saga, Order order, SagaReply reply) {
        if (saga.getStatus() != SagaStatus.PAYMENT_APPROVED) return;

        if (reply.success()) {
            order.approve();
            saga.transitionTo(SagaStatus.COMPLETED);
        } else {
            // compensação: estorna o que já foi cobrado
            saga.transitionTo(SagaStatus.COMPENSATING);
            outbox.enqueue("payment.commands",
                    new RefundPayment(saga.getSagaId(), order.getId(), order.getTotal()));
        }
    }

    private void onRefund(SagaState saga, Order order, SagaReply reply) {
        if (saga.getStatus() != SagaStatus.COMPENSATING) return;

        if (reply.success()) {
            order.cancel("Estoque indisponível");
            saga.transitionTo(SagaStatus.CANCELLED);
        } else {
            // compensação falhou: NÃO dá pra simplesmente desistir
            throw new CompensationFailedException(saga.getSagaId(), reply.reason());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Dois detalhes importantes:

  • Guard clauses por status (if (status != X) return) tornam o orquestrador idempotente: uma resposta repetida não avança o estado duas vezes.
  • Compensações precisam funcionar. Se o estorno falhar, a exceção faz o Kafka reentregar a mensagem (com retry e, no limite, Dead Letter Topic + alerta para um humano). Uma saga parada em COMPENSATING é um incidente, não um detalhe.

O listener que liga o Kafka ao orquestrador:

@Component
@RequiredArgsConstructor
public class SagaReplyListener {

    private final OrderSagaOrchestrator orchestrator;
    private final ObjectMapper mapper;

    @KafkaListener(topics = "saga.replies", groupId = "order-orchestrator")
    public void onReply(String payload) throws JsonProcessingException {
        orchestrator.handle(mapper.readValue(payload, SagaReply.class));
    }
}
Enter fullscreen mode Exit fullscreen mode

7.7 Um participante idempotente: payment-service

@Entity
@Table(name = "processed_messages")
public class ProcessedMessage {
    @Id
    private UUID messageId;
    private Instant processedAt = Instant.now();

    protected ProcessedMessage() {}
    public ProcessedMessage(UUID messageId) { this.messageId = messageId; }
}
Enter fullscreen mode Exit fullscreen mode
@Component
@RequiredArgsConstructor
@Slf4j
public class PaymentCommandListener {

    private final ProcessedMessageRepository processed;
    private final PaymentService paymentService;
    private final OutboxWriter outbox;
    private final ObjectMapper mapper;

    @KafkaListener(topics = "payment.commands", groupId = "payment-service")
    @Transactional
    public void onCommand(@Payload String payload,
                          @Header("type") String type,
                          @Header("messageId") String messageId) throws JsonProcessingException {

        UUID msgId = UUID.fromString(messageId);
        if (processed.existsById(msgId)) {
            log.info("Mensagem {} já processada, ignorando", msgId);
            return;
        }

        SagaReply reply = switch (type) {
            case "ProcessPayment" -> {
                var cmd = mapper.readValue(payload, ProcessPayment.class);
                yield paymentService.charge(cmd);
            }
            case "RefundPayment" -> {
                var cmd = mapper.readValue(payload, RefundPayment.class);
                yield paymentService.refund(cmd);
            }
            default -> throw new IllegalArgumentException("Comando desconhecido: " + type);
        };

        processed.save(new ProcessedMessage(msgId)); // mesma transação do efeito
        outbox.enqueue("saga.replies", reply.sagaId().toString(), reply); // resposta também via outbox
    }
}
Enter fullscreen mode Exit fullscreen mode

save em processed_messages, alteração do pagamento e resposta na outbox: tudo na mesma transação. Se duas instâncias processarem a mesma mensagem em paralelo, a PK duplicada faz uma delas falhar e dar rollback.

O paymentService.charge também deve ser idempotente do lado do gateway: envie o sagaId como idempotency key na chamada ao provedor de pagamento (Stripe, Adyen, Pagar.me e afins suportam isso).

7.8 Configuração do Kafka

spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
      acks: all
      properties:
        enable.idempotence: true
        max.in.flight.requests.per.connection: 5
    consumer:
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      auto-offset-reset: earliest
      enable-auto-commit: false
    listener:
      ack-mode: record
Enter fullscreen mode Exit fullscreen mode

E um error handler com retry exponencial e Dead Letter Topic:

@Configuration
public class KafkaErrorConfig {

    @Bean
    public DefaultErrorHandler errorHandler(KafkaTemplate<String, String> template) {
        var recoverer = new DeadLetterPublishingRecoverer(template); // publica em <topico>.DLT

        var backoff = new ExponentialBackOffWithMaxRetries(5);
        backoff.setInitialInterval(1_000);
        backoff.setMultiplier(2.0);
        backoff.setMaxInterval(30_000);

        var handler = new DefaultErrorHandler(recoverer, backoff);
        handler.addNotRetryableExceptions(IllegalArgumentException.class,
                                          JsonProcessingException.class);
        return handler;
    }
}
Enter fullscreen mode Exit fullscreen mode

Erros de desserialização ou comandos inválidos vão direto para a DLT. Falhas transitórias (banco fora, timeout) são retentadas.


8. Não deixe sagas "penduradas": timeouts

E se o inventory-service ficar fora por horas? A saga fica em PAYMENT_APPROVED indefinidamente, com o dinheiro do cliente preso.

Um job simples resolve:

@Scheduled(fixedDelay = 60_000)
@Transactional
public void expireStuckSagas() {
    Instant limit = Instant.now().minus(Duration.ofMinutes(15));
    sagas.findByStatusAndUpdatedAtBefore(SagaStatus.PAYMENT_APPROVED, limit)
         .forEach(saga -> {
             saga.transitionTo(SagaStatus.COMPENSATING);
             outbox.enqueue("payment.commands", new RefundPayment(/* ... */));
         });
}
Enter fullscreen mode Exit fullscreen mode

Como o orquestrador ignora respostas que não batem com o status atual, se a resposta do estoque chegar atrasada ela é descartada com segurança. Para sagas muito longas (horas, dias), vale considerar um motor de workflow como Temporal ou Camunda, que já trazem timers, retries e histórico prontos.


9. Observabilidade: rastreando uma saga de ponta a ponta

Uma saga cruza vários serviços de forma assíncrona. Sem observabilidade, debugar é adivinhação.

  • Correlation ID: propague o sagaId em headers do Kafka e coloque no MDC dos logs (MDC.put("sagaId", ...)).
  • Tracing distribuído: Spring Boot 3 usa Micrometer Tracing. Com spring.kafka.template.observation-enabled=true e spring.kafka.listener.observation-enabled=true, o trace atravessa produtor e consumidor e aparece inteiro no Zipkin, Jaeger ou Grafana Tempo.
  • Métricas de negócio: sagas por status, tempo médio até COMPLETED, taxa de compensação, mensagens em DLT. Um pico de COMPENSATING costuma ser o primeiro sinal de problema num serviço downstream.
  • A tabela saga_state é seu melhor painel de suporte: "o pedido 123 está em COMPENSATING desde 14:02".

10. Checklist de produção

  • [ ] Cada passo da saga tem uma compensação definida (ou é pivô/retentável)
  • [ ] Eventos publicados via Outbox, nunca save() + send() soltos
  • [ ] Todos os consumidores são idempotentes (processed_messages ou chave natural)
  • [ ] Chamadas externas usam idempotency key
  • [ ] sagaId como chave da mensagem para garantir ordem por saga
  • [ ] Producer com acks=all e enable.idempotence=true
  • [ ] Retry com backoff + DLT + alerta na DLT
  • [ ] Timeout para sagas paradas
  • [ ] Contramedidas de isolamento (status PENDENTE, @Version)
  • [ ] Tracing e correlation ID ponta a ponta
  • [ ] Testes de integração com Testcontainers (Kafka + Postgres) cobrindo o caminho feliz e os de compensação

Conclusão

Transações distribuídas não são um problema de framework, são um problema de modelagem. O 2PC tenta esconder a distribuição atrás de uma atomicidade que custa disponibilidade e escala. A Saga assume que a rede falha, que mensagens duplicam e que serviços caem, e projeta o fluxo para convergir a um estado consistente mesmo assim.

O resumo em uma frase: transação local + outbox + consumidor idempotente + compensação bem definida. Domine esses quatro pilares e o resto (coreografia ou orquestração, Kafka ou outro broker) vira detalhe de implementação.

Se você já implementou Sagas em produção, conta nos comentários: coreografia ou orquestração? Qual foi a compensação mais difícil de modelar? 👇


Referências

  • Hector Garcia-Molina e Kenneth Salem, Sagas (ACM SIGMOD, 1987)
  • Chris Richardson, Microservices Patterns (Manning) e microservices.io
  • Documentação do Spring for Apache Kafka
  • Documentação do Debezium: Outbox Event Router

Top comments (0)