Saga 分布式事务:你以为的最终一致性,其实是个慢动作炸弹
我曾负责过一个订单系统,号称用了 Saga 模式做分布式事务。上线第三天就出事了:用户支付成功后,订单状态卡在"待支付"——钱扣了,订单没更新。
排查了两天,最后发现是 Saga 协调器在补偿阶段崩了,但补偿消息已经发出去了。下游服务(库存服务)收到补偿消息后回滚了库存,但订单服务没收到补偿确认(Kafka 消息丢失),就一直卡着。
这就是 Saga 模式在生产环境的真实面貌:理论上能跑,实际上每个环节都可能掉链子。
Saga 模式的核心设计
Saga 模式把分布式事务拆成多个本地事务,每个本地事务都有对应的"补偿事务":
T1 (创建订单) → T2 (扣库存) → T3 (扣款) → 完成 ↓ 失败 C2 (恢复库存) ← C1 (取消订单) ← 触发补偿两个核心角色:
- 协调器(Orchestrator):控制整个 Saga 流程,决定下一步是执行还是补偿
- 参与者(Participant):执行具体业务逻辑,发布事件
设计模式层面的真相
Saga 模式本质上是状态机模式 + 观察者模式 + 责任链模式的组合应用:
publicclassSagaStateMachine{privateSagaStatecurrentState=SagaState.STARTED;privateList<Step>steps=newArrayList<>();privateList<Compensation>compensations=newArrayList<>();publicvoidexecute(){for(Stepstep:steps){try{step.execute();}catch(Exceptione){compensate();return;}}}privatevoidcompensate(){// 责任链模式:倒序执行补偿Collections.reverse(compensations);for(Compensationc:compensations){c.execute();}}}看上去很优雅。但生产环境里,状态机会因为各种原因卡住。
Saga 模式的四个真实陷阱
陷阱 1:补偿操作不是幂等的
T1 创建订单,T1 失败需要补偿"取消订单"。但如果"取消订单"这个补偿操作执行了一半崩了呢?
publicvoidcompensateCreateOrder(LongorderId){Orderorder=orderRepository.findById(orderId).orElseThrow();order.setStatus(OrderStatus.CANCELED);// 步骤 1orderRepository.save(order);// 步骤 2:崩在这里notificationService.sendCancelNotify(order);// 步骤 3}如果步骤 2 崩了,步骤 3 没执行,下次重试时步骤 1 是幂等的(setStatus重复执行无害),但步骤 3 可能重复发通知——用户收到 3 条"订单已取消"的短信。
解决方案:每个补偿操作都要设计成幂等,用唯一键 + 状态机:
publicvoidcompensateCreateOrder(LongorderId){Orderorder=orderRepository.findById(orderId).orElseThrow();if(order.getStatus()==OrderStatus.CANCELED){return;// 幂等:已取消直接返回}order.setStatus(OrderStatus.CANCELED);orderRepository.save(order);// 通知用消息表去重,不在这里直接发outboxRepository.save(newNotificationOutbox(orderId,"CANCELED"));}陷阱 2:隔离性缺失导致"脏读"
Saga 没有 ACID 中的隔离性。如果两个 Saga 同时修改同一个订单:
Saga A: 订单状态 PENDING → PAID Saga B: 订单状态 PENDING → CANCELED两个 Saga 并发执行,A 把订单改成 PAID 后崩溃,触发补偿(订单改回 PENDING)。但这时 B 已经把订单改成 CANCELED 了。A 的补偿操作setStatus(PENDING)覆盖了 B 的setStatus(CANCELED),B 看到的状态是错的。
这就是经典的"丢失更新"问题。Saga 模式天生没有隔离性,必须用应用层补偿:
// Saga A 的补偿publicvoidcompensatePay(LongorderId){Orderorder=orderRepository.findById(orderId).orElseThrow();if(order.getStatus()==OrderStatus.CANCELED){return;// B 已经处理了,A 的补偿跳过}order.setStatus(OrderStatus.PENDING);orderRepository.save(order);}但这个判断本身就可能出错(如果还有 Saga C 也在改这个订单呢?)。Saga 模式的隔离性问题,本质上是无解的,只能用业务规则尽量减少并发冲突。
陷阱 3:消息可靠投递的复杂性
Saga 依赖消息传递(Kafka/RabbitMQ)来推进状态机。但消息可能丢失、重复、顺序错乱。
最常见的事故:用户支付成功后,订单服务发了"支付成功"事件给下游,但 Kafka 那次写入失败。协调器没收到事件,整个 Saga 卡住。
解决:用事务性 outbox 模式:
@TransactionalpublicvoidpayOrder(LongorderId){Orderorder=orderRepository.findById(orderId).orElseThrow();order.setStatus(OrderStatus.PAID);orderRepository.save(order);// 业务表和 outbox 表在同一个事务里OutboxMessagemsg=newOutboxMessage();msg.setTopic("order.paid");msg.setPayload(orderId.toString());outboxRepository.save(msg);}后台有个 poller 进程不断扫描 outbox 表,把消息发到 Kafka。发送成功后标记为已发送。如果 poller 崩了,重启后从 outbox 表继续发送。
但 outbox 模式又带来新的问题:消息顺序性、重复消费、下游幂等。每解决一个问题,就引入两个新问题。
陷阱 4:长时间运行的 Saga 状态爆炸
一个复杂的业务 Saga 可能涉及 7、8 个步骤,每个步骤都有"成功/失败/补偿中/补偿失败"四种状态。状态机的状态数会爆炸到 2^N:
STARTED → T1_DONE → T2_DONE → T3_FAILED → COMPENSATING ↓ T1_COMPENSATED → T2_COMPENSATING ↓ COMPENSATION_FAILED → 人工介入每加一个步骤,状态空间翻倍。生产环境里,Saga 状态机的状态数很快就会超过 50 种,调试极其困难。
那为什么还要用 Saga?
因为两阶段提交(XA)在高并发场景下完全不可用:
- 协调者单点故障
- 同步阻塞导致性能极差
- 数据库连接被锁住,吞吐量降为 1/N
而 TCC(Try-Confirm-Cancel)需要业务方写三套方法,开发成本是 Saga 的 2-3 倍。
Saga 在"最终一致性"和"开发成本"之间找了个平衡点。但生产环境用 Saga,你必须接受以下几个事实:
- 状态会卡住,必须有人工介入通道(运营后台强制推进状态)
- 必须有对账系统(每天定时跑一遍,找出不一致的状态)
- 必须有业务补偿机制(状态卡住时,业务上怎么处理——退款?重试?人工?)
- 监控和告警必须覆盖每一个步骤的耗时(某个步骤慢 5 秒,整个 Saga 就会慢 5 秒)
一句话总结
Saga 模式是分布式事务的"次优解",不是"最优解"。它用状态机换来了性能,但代价是失去了隔离性 + 引入了消息复杂性 + 状态空间爆炸。
如果你正在设计分布式事务,先问自己三个问题:
- 业务真的需要分布式事务吗?能不能改成事件驱动 + 最终一致性?
- 能不能用本地消息表 + 单服务事务搞定?
- 如果非用 Saga,状态机怎么设计?补偿操作怎么幂等?消息怎么可靠投递?
答不上来就别用 Saga,老老实实用本地事务 + 异步消息,业务上 90% 的"分布式事务"问题根本不存在。
