Saga pattern 与 Kafka 分布式事务:预订旅游套餐示例
1. 概述
当一个业务需要经过多个微服务,而每个服务都有独立的数据库时,你无法将所有操作封装在一个 @Transactional 中。Saga 模式解决了这个问题。与其用一个大事务,你可以运行一系列本地事务。每一步都有一个补偿行为(compensation),在后续步骤失败时用来撤销。
在本文中,你将使用 Spring Boot 3 和 Kafka 为旅游套餐预订业务实现一个 Saga orchestration 模式。读完本文后,你将拥有一个协调各步骤的 orchestrator、通过 Kafka 响应的 participant service,以及一套测试补偿流程。
2. 旅游套餐预订问题
一次旅游套餐预订包含三个步骤,每个步骤属于一个不同的服务:
- FlightService:预留机票座位。
- HotelService:预留酒店房间。
- PaymentService:扣款。
假设机票和房间已预留,但因为卡达到限额而支付失败。此时系统必须取消房间,然后取消机票。否则客户无需支付就占用了位置。
两阶段提交(2PC)可以解决这个问题,但它在整个事务期间锁定资源。它还要求所有服务支持 XA,而 Kafka 无法参与 XA。Saga 接受最终一致性:数据可能暂时不一致,但最终会保持一致。
3. Choreography 还是 Orchestration
Saga 有两种组织方式:
| 标准 | Choreography | Orchestration |
|---|---|---|
| 协调方式 | 每个服务监听其他服务的事件 | 一个 orchestrator 发送命令,接收回复 |
| 业务流程 | 分散在多个服务 | 集中在一处 |
| 适用场景 | 2–3 个步骤,变化少 | 多个步骤,有复杂的补偿逻辑 |
| 风险 | 循环依赖,难以调试 | Orchestrator 成为逻辑中枢 |
对于三个步骤和一个反向补偿流程,orchestration 更易读且易于测试。本文采用这种方式。Kafka topic 的组织如下:
tour.commands.flight ← orchestrator 发送命令给 FlightService
tour.commands.hotel ← orchestrator 发送命令给 HotelService
tour.commands.payment ← orchestrator 发送命令给 PaymentService
tour.replies ← 所有服务将结果发送给 orchestrator
4. 项目设置
4.1. 依赖
你需要添加 Spring for Apache Kafka。Spring Boot 为你管理版本:
<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. 配置
消息以 JSON 格式发送。配置序列化器和反序列化器如下:
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 将类名写入 header __TypeId__。在消费者端,JsonDeserializer 读取该 header 以了解需要创建什么对象。trusted.packages 限制了允许反序列化的包,以防止安全漏洞。
接下来,你声明各个 topic:
@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. 消息模型
你使用 Java 21 的 record 来表示命令和回复:
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) {}
每个消息携带 bookingId。这个值既是 saga 的标识符,也是发送到 Kafka 时的消息 key。这样,同一个预订的所有消息都会落入同一个分区并按正确的顺序处理。
6. 将 Saga 定义为状态机
最关键的部分是决定下一步的逻辑。你应该将这个逻辑与 Kafka 分离,以便用纯单元测试进行测试。
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 是一个堆栈,保存已成功的步骤。当需要补偿时,你从堆栈顶部弹出,因此步骤会以相反的顺序被撤销。
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 已结束,忽略重复的回复
}
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)); // 重新尝试取消
}
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());
}
}
onReply 函数处理四种情况:
- RESERVE 成功:将该步骤记录到堆栈中。如果这是最后一步,saga 转换到
COMPLETED。否则,saga 向下一步发送 RESERVE 命令。 - RESERVE 失败:saga 转换到
COMPENSATING并开始取消最近成功的步骤。 - CANCEL 成功:saga 取消前一步骤。当堆栈为空时,saga 转换到
CANCELLED。 - CANCEL 失败:saga 重新发送取消命令。补偿不能中途放弃。
注意失败的步骤不在堆栈中。如果支付失败,没有什么需要退款,所以 saga 只需要取消房间和机票。
7. Orchestrator
Orchestrator 做两件事:向 Kafka 发送命令并从 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() 使用 bookingId 作为 key。Topic 名称从 Step 推导而来,例如 HOTEL 变成 tour.commands.hotel。
注意:
ConcurrentHashMap仅用于演示。在生产环境中,saga 状态必须持久化到数据库中。如果 orchestrator 在中途重启,它需要读取状态以继续或补偿。
8. Participant Service
每个 participant 监听自己的 command topic,执行本地事务,然后发送回复。以 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 : "没有可用房间"));
}
private boolean reserve(String bookingId) {
if (reservedRooms.contains(bookingId)) {
return true; // 已预留,消息被重新发送
}
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; // 取消从未预留过的也算成功
}
}
上述代码中的两个重要细节:
reserve()是幂等的。Kafka 保证 at-least-once 交付,所以同一个命令可能到达两次。如果已为该bookingId预留了房间,handler 返回成功而不再扣减房间。cancel()也是幂等的,且始终成功。取消命令可能先于预留命令到达,或到达一个从未预留过房间的预订。这两种情况都不应该破坏数据。
FlightCommandHandler 和 PaymentCommandHandler 结构类似。对于 Payment,CANCEL 意味着退款(refund)。
当 PaymentService 拒绝支付时,orchestrator 的日志如下:
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
房间先被取消,机票后被取消,这是与预留时相反的顺序。
9. Saga 流程测试
因为 TourBookingSaga 不依赖 Kafka,你可以使用 JUnit 5 和 AssertJ 进行测试,无需 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, "卡被拒绝")).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);
}
}
第二个测试检查了 saga 最重要的行为。支付失败应该导致取消酒店然后取消机票,且 saga 必须以 CANCELLED 状态结束。如果需要进行端到端测试,你可以使用 spring-kafka-test 中的 @EmbeddedKafka 或 Testcontainers。逻辑部分应该按上面的方式进行单元测试。
10. 常见错误
双重写入。 Participant 先写数据库,然后再发送回复。如果服务在这两个操作之间崩溃,saga 会卡住。标准的解决方案是 Transactional Outbox:在与业务数据相同的事务中将回复写入 outbox 表。然后一个单独的进程(或 Debezium CDC)将这些记录推送到 Kafka。
缺乏幂等性。 Consumer rebalance 或 retry 会重新发送消息。所有处理程序,包括补偿处理程序,都必须安全地处理重复。在 orchestrator 中,你应该额外保存待回复的步骤并忽略不匹配的回复。
不使用 key。 如果发送没有 key 的消息,同一预订的消息可能落入不同的分区。这样取消命令可能先于预留命令被处理。
缺乏隔离性。 Saga 没有 ACID 中的隔离性。当 saga 运行时,其他服务可能看到房间“已预留”,尽管稍后会被取消。你应该使用中间状态如 PENDING(语义锁)并仅在 saga 完成时转换为 CONFIRMED。
缺少超时机制。 如果一个服务永远不回复,saga 会一直等待。你需要一个定期扫描超期 saga 进行补偿的任务。对于持续失败的 CANCEL 命令,应该限制重试次数并将消息转移到死信队列供人工处理。
**注意:**如果所有数据都在同一个数据库中,不要使用 Saga。普通的
@Transactional更简单、更安全。Saga 也不适合那些不接受中间状态的业务,或有无法补偿的步骤。例如,已发送的确认邮件无法“撤回”。你应该将这类步骤放在 saga 的最后。
11. 总结
在本文中,你为旅游套餐预订业务实现了 Kafka 的 Saga orchestration 模式:
- Saga 逻辑被分离成一个纯粹的状态机,可以在不需要 broker 的情况下进行测试。
- Orchestrator 通过 Kafka 发送命令并接收回复,使用
bookingId作为 key 来维护顺序。 - 补偿步骤通过堆栈按相反的顺序执行。
- Participant 以幂等方式处理命令。
将 saga 投入生产时,你需要补充三个方面:将 saga 状态持久化到数据库,使用 Transactional Outbox 来避免双重写入,以及拥有超时机制和死信队列。这些是决定你的 saga 是否真正可靠的关键部分。