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