Saga 模式实战——分布式事务的编排式 vs 协同式,怎么选
Saga 模式实战——分布式事务的编排式 vs 协同式,怎么选
适读人群:做微服务、关注分布式事务一致性的工程师 | 阅读时长:约17分钟 | 核心价值:Saga 的两种实现方式各有适用场景,选错了会带来维护噩梦
从一次退款失败说起
我们有一个电商系统,下单流程涉及三个服务:订单服务、库存服务、支付服务。
有一次上线了一个促销活动,支付服务因为第三方支付渠道问题,偶尔出现支付超时。超时后,我们的订单服务把订单标记为"支付失败",但库存服务里的库存已经扣减了,没有回滚。
结果:库存明明扣了,但订单是失败状态,库存永久少了,下次同款商品的库存数量不对了。
这是一个典型的分布式事务一致性问题。在微服务架构下,跨服务的操作不能用本地事务来保证原子性,需要别的方案。
Saga 是最常用的解决方案。
为什么不用两阶段提交(2PC)
2PC(Two-Phase Commit)是分布式事务的"教科书"方案,理论上可以保证所有参与者要么全部提交,要么全部回滚。
但在实际工程中有几个问题:
性能差:2PC 要求所有参与者在"准备"阶段持有锁,等待协调者的"提交"指令。如果跨 3 个服务,锁持有时间 = 3 个服务的处理时间之和,这在高并发场景下不可接受。
协调者单点:协调者宕机后,所有参与者都在等待,系统处于不确定状态。
微服务环境不适用:微服务每个服务有独立数据库,数据库不在同一个 XA 资源管理器下,技术上很难实现。
所以微服务里通常用 Saga 来处理分布式事务。
Saga 是什么
Saga 的核心思想:把一个跨服务的"大事务"拆成一系列本地事务,每个本地事务成功后发布事件/消息通知下一步执行;如果某个步骤失败,通过补偿事务(Compensating Transaction)回滚前面已经执行的步骤。
以下单流程为例:
正常流程:
1. 订单服务:创建订单(本地事务)
2. 库存服务:扣减库存(本地事务)
3. 支付服务:发起支付(本地事务)
失败时的补偿流程(假设支付失败):
3. 支付服务:支付失败
2. 库存服务:恢复库存(补偿事务)
1. 订单服务:取消订单(补偿事务)Saga 提供的是最终一致性,而不是强一致性。补偿过程中,系统可能处于中间状态。
两种实现方式
方式一:协同式(Choreography)
没有中央协调者,每个服务订阅其他服务发出的事件,自己决定何时触发什么操作。
代码示例(库存服务):
// 库存服务订阅"订单创建"事件,执行扣减
@KafkaListener(topics = "order.created")
public void onOrderCreated(OrderCreatedEvent event) {
try {
stockService.deduct(event.getOrderId(), event.getItems());
eventPublisher.publish(new StockDeductedEvent(event.getOrderId()));
} catch (InsufficientStockException e) {
eventPublisher.publish(new StockDeductionFailedEvent(event.getOrderId(), e.getMessage()));
}
}
// 库存服务订阅"支付失败"事件,回滚库存
@KafkaListener(topics = "payment.failed")
public void onPaymentFailed(PaymentFailedEvent event) {
stockService.restore(event.getOrderId()); // 补偿事务
eventPublisher.publish(new StockRestoredEvent(event.getOrderId()));
}协同式的优点:
- 简单,没有额外的协调服务
- 服务间松耦合,只依赖事件,不依赖其他服务
协同式的缺点:
- 整个 Saga 的流程分散在多个服务里,没有一个地方能看到全局流程
- 事件依赖关系难以追踪,随着服务增多,事件网络变成"意大利面"
- 调试困难:出了问题,需要跨多个服务的日志才能还原全局流程
方式二:编排式(Orchestration)
有一个中央 Saga 编排器(Orchestrator),统一协调整个流程,告诉每个服务"该你了,做什么"。
代码示例(编排器):
@Component
public class CreateOrderSaga {
@Autowired
private OrderService orderService;
@Autowired
private StockService stockService;
@Autowired
private PaymentService paymentService;
// 用状态机描述 Saga 流程
public SagaDefinition<CreateOrderSagaState> getSagaDefinition() {
return SagaDefinitionBuilder.<CreateOrderSagaState>newSaga()
// 步骤1:创建订单
.step()
.invokeParticipant(orderService::create)
.withCompensation(orderService::cancel) // 补偿动作
// 步骤2:扣减库存
.step()
.invokeParticipant(stockService::deduct)
.withCompensation(stockService::restore)
// 步骤3:支付(无法补偿的最后一步)
.step()
.invokeParticipant(paymentService::charge)
.build();
}
}编排式的优点:
- 流程集中管理,可读性强,一眼看出整个业务流程
- 便于监控和调试,Saga 的执行状态有一个中心化的视图
- 更容易处理复杂的分支逻辑(比如"如果 VIP 用户,走不同的支付流程")
编排式的缺点:
- 编排器可能成为业务逻辑的汇聚点("上帝对象"问题)
- 需要维护编排器的持久化状态,技术复杂度更高
- 服务之间的耦合转移到了"服务 → 编排器",仍然有耦合
踩坑记录
踩坑一:补偿事务不能失败,但它偏偏失败了
补偿事务(比如恢复库存)本身也可能失败。如果恢复库存的 RPC 调用超时怎么办?
坑: 很多项目的补偿事务没有重试机制,失败了就不管了,最终数据不一致。
解决: 补偿事务必须是幂等的,并且要有持久化的重试机制。
// 补偿任务持久化到数据库,确保一定会被执行
public void scheduleCompensation(String sagaId, String step, String compensationData) {
compensationTaskMapper.insert(
CompensationTask.create(sagaId, step, compensationData)
);
}
// 定时任务轮询未完成的补偿任务,重试执行
@Scheduled(fixedDelay = 5000)
public void retryPendingCompensations() {
List<CompensationTask> pending = compensationTaskMapper.findPending();
pending.forEach(this::executeCompensation);
}踩坑二:协同式的事件循环依赖
服务 A 发事件给服务 B,服务 B 发事件给服务 C,服务 C 的某个场景又发事件给服务 A,形成循环。
这种情况下很难追踪一个 Saga 实例的完整执行链路,出了问题根本排查不了。
解决: 协同式要严格规划事件流向,不允许出现循环。如果业务复杂度高到容易出现循环,就应该换成编排式。
踩坑三:Saga 中间状态对外暴露了
Saga 执行过程中,系统处于中间状态(订单已创建,库存已扣,支付还没完成)。
如果在这个时候,用户查询订单,应该看到什么状态?
我们最初的处理是让用户看到"处理中"状态,但前端没有对应的展示,导致用户以为出错了,疯狂刷新,加重了系统压力。
解决: Saga 执行中的中间状态需要有明确的 UI 展示设计,让用户理解这是正常的过渡状态。
怎么选择协同式还是编排式
业务流程简单(3 个以内步骤)且步骤不太可能增加
→ 协同式(简单、松耦合)
业务流程复杂(多步骤、有分支逻辑)
→ 编排式(可读性强、易于调试)
需要可视化监控 Saga 执行状态
→ 编排式
团队没有额外精力维护编排器基础设施
→ 协同式
已经有类似编排框架(Temporal、Axon)
→ 编排式在我实际接触到的项目里,大多数场景用编排式更合适,因为业务流程通常比想象中复杂,协同式在初期感觉简单,但随着业务增长,事件网络会变得越来越难以理解。
完整架构参考
Saga 是处理微服务分布式事务的务实方案,它用"最终一致性 + 补偿"替代了"强一致性",代价是更高的业务复杂度和更难的调试。
在真正使用前,先确认业务上是否能接受"最终一致性"——如果有些操作必须强一致,Saga 解决不了,需要重新考虑服务边界的划分。
