Saga pattern com Kafka para transações distribuídas
1. Visão Geral
Quando um fluxo de negócio atravessa múltiplos microserviços, cada serviço com seu próprio banco de dados, você não pode envolver tudo em um @Transactional. O Saga pattern resolve este problema. Em vez de uma transação grande, você executa uma série de transações locais. Cada etapa tem uma ação de compensação para desfazer se a próxima etapa falhar.
Neste artigo, você implementará um saga de tipo orchestration usando Spring Boot 3 e Kafka para o fluxo de negócio de reserva de tour. Ao final, você terá um orquestrador que coordena os passos, serviços participantes respondendo via Kafka e um conjunto de testes para o fluxo de compensação.
2. Problema de Reserva de Tour
Uma reserva de tour consiste em três etapas, cada uma pertencendo a um serviço:
- FlightService: reservar assento de voo.
- HotelService: reservar quarto de hotel.
- PaymentService: debitar do cliente.
Suponha que o voo e o quarto já foram reservados, mas o pagamento falha porque o cartão atingiu o limite. Neste caso, o sistema deve cancelar o quarto e depois cancelar o voo. Caso contrário, o cliente não paga mas ainda ocupa o espaço.
Two-phase commit (2PC) pode resolver isso, mas bloqueia recursos durante toda a transação. Também requer que todo serviço suporte XA, e Kafka não pode participar de XA. Saga aceita eventual consistency: os dados podem estar temporariamente inconsistentes, mas eventualmente ficarão consistentes.
3. Choreography ou Orchestration
Saga tem dois estilos de organização:
| Critério | Choreography | Orchestration |
|---|---|---|
| Coordenação | Cada serviço ouve eventos de outros serviços | Um orquestrador envia comandos e recebe respostas |
| Fluxo de negócio | Espalhado por múltiplos serviços | Centralizado em um único lugar |
| Apropriado para | 2–3 etapas, poucas mudanças | Muitas etapas, lógica de compensação complexa |
| Risco | Dependências circulares, difícil de debugar | Orquestrador se torna um ponto único de lógica |
Com três etapas e um fluxo de compensação reverso, orchestration é mais fácil de ler e testar. Este artigo usa essa abordagem. Os tópicos do Kafka são organizados como segue:
tour.commands.flight ← orquestrador envia comandos para FlightService
tour.commands.hotel ← orquestrador envia comandos para HotelService
tour.commands.payment ← orquestrador envia comandos para PaymentService
tour.replies ← todos os serviços enviam resultados para o orquestrador
4. Configuração do Projeto
4.1. Dependency
Você precisa adicionar Spring para Apache Kafka. Spring Boot gerencia a versão para você:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
4.2. Cấu hình
As mensagens são enviadas em JSON. Configure os serializadores e desserializadores assim:
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
consumer:
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: com.nonla.tour
JsonSerializer escreve o nome da classe no header __TypeId__. No lado do consumer, JsonDeserializer lê esse header para saber qual objeto criar. trusted.packages limita quais packages podem ser desserializados, prevenindo vulnerabilidades de segurança.
Em seguida, declare os tópicos:
@Configuration
public class KafkaTopicConfig {
@Bean
KafkaAdmin.NewTopics sagaTopics() {
return new KafkaAdmin.NewTopics(
TopicBuilder.name("tour.commands.flight").partitions(3).build(),
TopicBuilder.name("tour.commands.hotel").partitions(3).build(),
TopicBuilder.name("tour.commands.payment").partitions(3).build(),
TopicBuilder.name("tour.replies").partitions(3).build());
}
}
5. Modelo de Mensagem
Use record do Java 21 para representar comandos e respostas:
package com.nonla.tour;
public enum Step { FLIGHT, HOTEL, PAYMENT }
public enum Action { RESERVE, CANCEL }
public record SagaCommand(String bookingId, Step step, Action action, BigDecimal amount) {}
public record SagaReply(String bookingId, Step step, Action action,
boolean success, String reason) {}
Cada mensagem carrega o bookingId. Esse valor é tanto o identificador do saga quanto a chave da mensagem ao enviar para o Kafka. Dessa forma, todas as mensagens de uma mesma reserva caem na mesma partição e são processadas em ordem.
6. Definindo Saga como uma Máquina de Estados
A parte mais importante é a lógica que decide a próxima etapa. Você deve separar essa lógica do Kafka para poder testá-la com testes unitários puros.
public enum SagaStatus { RUNNING, COMPENSATING, COMPLETED, CANCELLED }
public class SagaState {
private final String bookingId;
private final BigDecimal amount;
private final Deque<Step> completed = new ArrayDeque<>();
private SagaStatus status = SagaStatus.RUNNING;
public SagaState(String bookingId, BigDecimal amount) {
this.bookingId = bookingId;
this.amount = amount;
}
public String bookingId() { return bookingId; }
public BigDecimal amount() { return amount; }
public Deque<Step> completed() { return completed; }
public SagaStatus status() { return status; }
public void status(SagaStatus status) { this.status = status; }
}
completed é uma pilha que armazena as etapas concluídas com sucesso. Quando precisa compensar, você faz pop do topo da pilha, então as etapas são desfeitas em ordem reversa.
public class TourBookingSaga {
static final List<Step> STEPS = List.of(Step.FLIGHT, Step.HOTEL, Step.PAYMENT);
public Optional<SagaCommand> start(SagaState s) {
return Optional.of(command(s, STEPS.getFirst(), Action.RESERVE));
}
public Optional<SagaCommand> onReply(SagaState s, SagaReply r) {
if (s.status() == SagaStatus.COMPLETED || s.status() == SagaStatus.CANCELLED) {
return Optional.empty(); // saga já terminou, ignore resposta duplicada
}
if (r.action() == Action.RESERVE && r.success()) {
s.completed().push(r.step());
int idx = STEPS.indexOf(r.step());
if (idx == STEPS.size() - 1) {
s.status(SagaStatus.COMPLETED);
return Optional.empty();
}
return Optional.of(command(s, STEPS.get(idx + 1), Action.RESERVE));
}
if (r.action() == Action.CANCEL && !r.success()) {
return Optional.of(command(s, r.step(), Action.CANCEL)); // tente cancelar novamente
}
s.status(SagaStatus.COMPENSATING);
return compensateNext(s);
}
private Optional<SagaCommand> compensateNext(SagaState s) {
if (s.completed().isEmpty()) {
s.status(SagaStatus.CANCELLED);
return Optional.empty();
}
return Optional.of(command(s, s.completed().pop(), Action.CANCEL));
}
private SagaCommand command(SagaState s, Step step, Action action) {
return new SagaCommand(s.bookingId(), step, action, s.amount());
}
}
A função onReply lida com quatro casos:
- RESERVE com sucesso: registra essa etapa na pilha. Se esta for a última etapa, saga muda para
COMPLETED. Caso contrário, saga envia o comando RESERVE para a próxima etapa. - RESERVE falha: saga muda para
COMPENSATINGe começa a cancelar a etapa mais recentemente concluída com sucesso. - CANCEL com sucesso: saga cancela a próxima etapa anterior. Quando a pilha está vazia, saga muda para
CANCELLED. - CANCEL falha: saga reenvia o comando de cancelamento. Compensação não pode ser interrompida.
Note que a etapa que falha não fica na pilha. Se o pagamento falhar, não há nada para reembolsar, então saga apenas precisa cancelar o quarto e o voo.
7. Orchestrator
O orquestrador faz duas coisas: envia comandos para Kafka e escuta respostas do tour.replies.
@Service
public class TourBookingOrchestrator {
private static final Logger log = LoggerFactory.getLogger(TourBookingOrchestrator.class);
private final KafkaTemplate<String, Object> kafka;
private final TourBookingSaga saga = new TourBookingSaga();
private final Map<String, SagaState> store = new ConcurrentHashMap<>();
public TourBookingOrchestrator(KafkaTemplate<String, Object> kafka) {
this.kafka = kafka;
}
public void book(String bookingId, BigDecimal amount) {
SagaState state = new SagaState(bookingId, amount);
store.put(bookingId, state);
saga.start(state).ifPresent(this::send);
}
@KafkaListener(topics = "tour.replies", groupId = "tour-orchestrator")
public void onReply(SagaReply reply) {
SagaState state = store.get(reply.bookingId());
if (state == null) {
return;
}
saga.onReply(state, reply).ifPresent(this::send);
log.info("booking={} reply={}/{} ok={} -> status={}",
reply.bookingId(), reply.step(), reply.action(), reply.success(), state.status());
}
private void send(SagaCommand cmd) {
String topic = "tour.commands." + cmd.step().name().toLowerCase();
kafka.send(topic, cmd.bookingId(), cmd);
}
public SagaStatus statusOf(String bookingId) {
return store.get(bookingId).status();
}
}
send() usa bookingId como chave. O nome do tópico é derivado de Step, por exemplo HOTEL vira tour.commands.hotel.
Nota:
ConcurrentHashMapé apenas para demo. Em produção, o estado do saga deve ser persistido em um banco de dados. Se o orquestrador reiniciar no meio do caminho, ele precisa ler o estado novamente para continuar ou compensar.
8. Participant service
Cada participante ouve seu tópico de comando, executa uma transação local e envia uma resposta. Por exemplo, com HotelService:
@Component
public class HotelCommandHandler {
private final KafkaTemplate<String, Object> kafka;
private final Set<String> reservedRooms = ConcurrentHashMap.newKeySet();
private final AtomicInteger availableRooms = new AtomicInteger(10);
public HotelCommandHandler(KafkaTemplate<String, Object> kafka) {
this.kafka = kafka;
}
@KafkaListener(topics = "tour.commands.hotel", groupId = "hotel-service")
public void handle(SagaCommand cmd) {
boolean ok = switch (cmd.action()) {
case RESERVE -> reserve(cmd.bookingId());
case CANCEL -> cancel(cmd.bookingId());
};
kafka.send("tour.replies", cmd.bookingId(),
new SagaReply(cmd.bookingId(), Step.HOTEL, cmd.action(), ok, ok ? null : "Sem quartos disponíveis"));
}
private boolean reserve(String bookingId) {
if (reservedRooms.contains(bookingId)) {
return true; // já foi reservado, a mensagem foi reenviada
}
if (availableRooms.getAndUpdate(n -> n > 0 ? n - 1 : n) == 0) {
return false;
}
reservedRooms.add(bookingId);
return true;
}
private boolean cancel(String bookingId) {
if (reservedRooms.remove(bookingId)) {
availableRooms.incrementAndGet();
}
return true; // cancelar algo que nunca foi reservado ainda é considerado sucesso
}
}
Dois detalhes importantes no código acima:
reserve()é idempotente. Kafka garante entrega at-least-once, então o mesmo comando pode chegar duas vezes. Se o quarto já foi reservado para essebookingId, o handler retorna sucesso sem decrementar o quarto novamente.cancel()também é idempotente e sempre tem sucesso. O comando de cancelamento pode chegar antes do comando de reserva, ou para uma reserva que nunca reservou um quarto. Ambos os casos não devem corromper dados.
FlightCommandHandler e PaymentCommandHandler têm estrutura similar. Para Payment, CANCEL significa reembolso (refund).
Quando PaymentService rejeita o pagamento, o log do orquestrador será assim:
booking=BK-1001 reply=FLIGHT/RESERVE ok=true -> status=RUNNING
booking=BK-1001 reply=HOTEL/RESERVE ok=true -> status=RUNNING
booking=BK-1001 reply=PAYMENT/RESERVE ok=false -> status=COMPENSATING
booking=BK-1001 reply=HOTEL/CANCEL ok=true -> status=COMPENSATING
booking=BK-1001 reply=FLIGHT/CANCEL ok=true -> status=CANCELLED
O quarto é cancelado primeiro, depois o voo, na ordem exata oposta à reserva.
9. Testando o Fluxo do Saga
Como TourBookingSaga não depende de Kafka, você pode testá-lo com JUnit 5 e AssertJ sem precisar de um broker:
class TourBookingSagaTest {
private final TourBookingSaga saga = new TourBookingSaga();
private SagaReply ok(Step step, Action action) {
return new SagaReply("BK-1", step, action, true, null);
}
@Test
void whenAllStepsSucceed_thenSagaCompleted() {
SagaState s = new SagaState("BK-1", new BigDecimal("15000000"));
assertThat(saga.start(s)).get().extracting(SagaCommand::step).isEqualTo(Step.FLIGHT);
assertThat(saga.onReply(s, ok(Step.FLIGHT, Action.RESERVE)))
.get().extracting(SagaCommand::step).isEqualTo(Step.HOTEL);
assertThat(saga.onReply(s, ok(Step.HOTEL, Action.RESERVE)))
.get().extracting(SagaCommand::step).isEqualTo(Step.PAYMENT);
assertThat(saga.onReply(s, ok(Step.PAYMENT, Action.RESERVE))).isEmpty();
assertThat(s.status()).isEqualTo(SagaStatus.COMPLETED);
}
@Test
void whenPaymentFails_thenCompensateHotelThenFlight() {
SagaState s = new SagaState("BK-1", new BigDecimal("15000000"));
saga.start(s);
saga.onReply(s, ok(Step.FLIGHT, Action.RESERVE));
saga.onReply(s, ok(Step.HOTEL, Action.RESERVE));
SagaCommand first = saga.onReply(s,
new SagaReply("BK-1", Step.PAYMENT, Action.RESERVE, false, "Cartão rejeitado")).orElseThrow();
assertThat(first).extracting(SagaCommand::step, SagaCommand::action)
.containsExactly(Step.HOTEL, Action.CANCEL);
assertThat(s.status()).isEqualTo(SagaStatus.COMPENSATING);
SagaCommand second = saga.onReply(s, ok(Step.HOTEL, Action.CANCEL)).orElseThrow();
assertThat(second).extracting(SagaCommand::step, SagaCommand::action)
.containsExactly(Step.FLIGHT, Action.CANCEL);
assertThat(saga.onReply(s, ok(Step.FLIGHT, Action.CANCEL))).isEmpty();
assertThat(s.status()).isEqualTo(SagaStatus.CANCELLED);
}
}
O segundo teste verifica o comportamento mais importante do saga. A falha do pagamento deve levar ao cancelamento do hotel, depois do voo, e o saga deve terminar em CANCELLED. Se precisar de testes end-to-end, você pode usar @EmbeddedKafka do spring-kafka-test ou Testcontainers. A lógica deve ser testada no nível de unidade como acima.
10. Erros Comuns
Dual write. O participante grava no banco de dados e depois envia a resposta. Se o serviço morrer entre essas duas ações, o saga fica preso. A solução padrão é Transactional Outbox: escrever a resposta na tabela outbox na mesma transação que os dados de negócio. Depois, um processo separado (ou Debezium CDC) envia esses registros para Kafka.
Falta de idempotência. Rebalanceamento do consumer ou retry reenviará a mensagem. Todo handler, incluindo handlers de compensação, deve lidar com duplicatas com segurança. No orquestrador, você deve rastrear qual etapa está aguardando uma resposta e ignorar respostas que não correspondem.
Não usar chave. Se enviar uma mensagem sem chave, mensagens da mesma reserva podem cair em partições diferentes. Nesse caso, o comando CANCEL pode ser processado antes do RESERVE.
Falta de isolamento. Saga não tem a propriedade de Isolamento como ACID. Enquanto o saga está em execução, outro serviço pode ver o quarto como ‘reservado’ mesmo que seja cancelado depois. Você deve usar um estado intermediário como PENDING (semantic lock) e apenas mudar para CONFIRMED quando o saga terminar.
Falta de timeout. Se um serviço nunca responder, o saga esperará para sempre. Você precisa de um trabalho periódico para verificar sagas expirados e compensá-los. Para comandos CANCEL que falham continuamente, limite o número de tentativas e envie a mensagem para um tópico dead-letter para processamento manual.
Nota: Não use Saga quando todos os dados estão no mesmo banco de dados. Um
@Transactionalcomum é mais simples e muito mais seguro. Saga também não é apropriado para operações de negócio que não aceitam estado intermediário, ou etapas que não podem ser compensadas. Por exemplo, um email de confirmação já enviado não pode ser ‘retirado’. Você deve colocar essas etapas no final do saga.
11. Conclusão
Neste artigo, você implementou o Saga pattern de tipo orchestration com Kafka para o fluxo de negócio de reserva de tour:
- A lógica do saga é separada em uma máquina de estados pura, que pode ser testada sem broker.
- O orquestrador envia comandos e recebe respostas via Kafka, usando
bookingIdcomo chave para manter a ordem. - Os passos de compensação são executados em ordem reversa usando uma pilha.
- Participantes processam comandos de forma idempotente.
Ao colocar saga em produção, você precisa adicionar três coisas: persistir o estado do saga em um banco de dados, usar Transactional Outbox para evitar dual write e ter mecanismo de timeout e tópico dead-letter. Essas são as partes que determinam se seu saga é realmente confiável ou não.