Saga / 分布式事务在实际生产中的几个问题
从State Explosion、宕机恢复到幂等传递。
关于 compensation state explosion、orchestrator crash 了谁来接盘、idempotency 要传递到 undo 步骤、FIFO 跨服务比你想的难、2PC 什么时候反而是正确答案
在实际生产中,跨服务的数据一致性是绕不过去的基础问题。学校里讲 distributed transaction,基本上是 2PC 的理论推导,加一句 “saga pattern 是轻量替代” 就结束了。生产环境不一样——生产环境里 saga 是一台 state machine,happy path 只占它全部 state space 的不到 10%。
面试时我会问一道场景题:你有一个下单流程,五步——创建订单、预扣款、减库存、创建物流单、发通知。第四步物流单创建失败了。怎么办?
90% 的候选人会说回滚。前三步 compensate 掉就行。
好。你的退款 compensate 请求发出去了,支付网关返回 timeout。你现在不知道钱退了没有。怎么办?到这里大多数人就安静了。saga 听起来简单——每一步配一个 undo,失败了倒着走。真跑起来你会发现,saga 的复杂度不在 happy path,在 failure path 的 failure path。
01 compensation 的 state space 是指数级的
大多数人对 saga 的理解停在 “每步配一个 compensate 函数,失败了倒着执行”。五步 saga,五个 compensate,正反加起来十个函数,看起来清清楚楚。
哪里崩?
一个五步 saga 的 happy path state machine:ORDER_CREATED → PAYMENT_RESERVED → INVENTORY_DEDUCTED → SHIPPING_SCHEDULED → NOTIFICATION_SENT → COMPLETED。六个状态,五个 transition。现在加上 failure 和 timeout。第三步 INVENTORY_DEDUCTED 之后,shipping service 返回 timeout——你不知道物流单到底创建了没有。要不要 compensate 库存?扣款要不要释放?你决定 compensate 库存,但库存服务也返回了 timeout。你不知道库存加回去了没有。
这时候 state machine 膨胀出一堆新状态:SHIPPING_TIMEOUT → SHIPPING_VERIFY_PENDING → INVENTORY_COMPENSATE_PENDING → INVENTORY_COMPENSATE_TIMEOUT → MANUAL_INTERVENTION_REQUIRED。五步 saga,每步三种结果(success / failure / timeout),compensation 本身也是三种结果,state space 从 6 个状态爆到几十个。
我之前接手过一个支付结算系统的 saga,原设计者画了 7 步流程,文档里 compensate 逻辑写了 14 行伪代码,看着挺简洁。实际上线之后,state machine 有将近 40 个 distinct state,其中十几个是各种 partial failure + compensation failure 的组合态。光 REFUND_TIMEOUT_INVENTORY_RESTORED_SHIPPING_UNKNOWN 这样的状态名就列了好几个。
业界的做法是把 saga 当一个显式的 finite state machine 建模,每个 state transition 持久化。不是 “try-catch 逆序 rollback”——是一张 saga_step 表,每一行记录当前 saga instance 在哪个 state、上一步结果是什么、下一步该做什么。Temporal workflow、Cadence、或者自建的 saga orchestrator 帮你管这张表。代价是每一步 dispatch 都要先写一次 DB 记录 transition,完成后再写一次记录结果。一个 5 步 saga 的 happy path 就多了 10 次 DB write,P99 延迟增加约 40-80ms。
02 orchestrator 自己 crash 了——saga 停在半路谁来收场
所有 saga compensation 逻辑都有一个隐含前提:有一个 orchestrator 在看着整个流程,某步失败了它来协调 compensate。
那 orchestrator 自己挂了呢?
第三步 INVENTORY_DEDUCTED 写入成功了,orchestrator 正准备 dispatch 第四步的 shipping request。这时候进程 OOM 被 kill。没有任何 process 知道这个 saga 卡在哪了。库存已经扣了,扣款已经预留了,但后续步骤没人推。如果没有恢复机制,这个订单就永远停在 INVENTORY_DEDUCTED——用户付了钱、库存扣了,物流单没创建,通知没发。就这么卡着。
我面试里会这样追问:
我:你的 saga orchestrator crash 了怎么办?
候选人:重启之后继续执行就好了。
我:重启之后它怎么知道自己上次到了哪一步?
候选人:……从数据库读状态?
我:数据库里的状态写到了哪一步?crash 发生在 “第三步成功” 写入 DB 之后、”dispatch 第四步” 之前呢?
候选人:那就从第四步继续。
我:你怎么确认第三步的 side effect 真的完成了?如果第三步成功写了 DB,但 inventory service 的 response 还没回来呢?
候选人:……
到这里候选人通常会意识到,saga recovery 不是 “重启就行” 这么简单的事。
修法是 saga log + polling recovery。每个 saga instance 的每一步 state transition 写入一张 saga_log 表——本质上是 write-ahead log。orchestrator 重启后,扫描所有 status != COMPLETED AND status != FAILED 的 saga instance,根据最后一条 log 判断当前 state,继续执行或触发 compensate。Temporal 和 Cadence 把这件事内置了——workflow 的每一步都是一个 persistent event,worker crash 后 replay event log 恢复到 crash 前的 exact state。
代价是 saga log 的写入量。一个高吞吐的订单系统每秒创建 3000 个 saga instance,每个 saga 5 步,每步 2 条 log(begin + complete),每秒 30000 条 saga log 写入。这张表的写 QPS 比业务表还高,需要 partition、定期归档。而且 polling recovery 的扫描频率本身也是 trade-off——太频繁浪费资源,太慢用户卡着没人管。
03 退款 retry 了三次,用户多收到三笔——idempotency 不只是正向步骤的事
我之前见过一个真实 case。一个电商系统的 saga compensation 对 refund 做了 retry——支付网关 timeout 了,orchestrator 自动重试。问题是 refund API 不是 idempotent 的。一批 timeout 的 saga 触发了自动 compensation,每个 saga 的 refund 被执行了两到三次。一晚上多退了十几万。第二天早上财务对账才发现。
大多数人知道分布式系统里正向步骤要做 idempotent——网络 retry 可能让同一个请求被 dispatch 两次,所以加 idempotency key 做 dedup。但 compensation 呢?compensation 也是一个 RPC call,也会 timeout,也会被 retry。退款就是 compensation。退款被 retry 了,接口不 idempotent,就是退两次钱。
idempotency 的要求是传递性的——saga 的每一个正向 step 和每一个 compensation step 都必须是 idempotent 的。 而且 key 的 scope 不一样:正向步骤的 idempotency key 通常是 saga_id + step_name,compensation 的 key 是 saga_id + step_name + “compensate”。不能复用正向步骤的 key——否则 compensation request 到达 inventory service,service 查到这个 key “已处理”,直接返回上次的正向结果,compensation 等于没执行。
修法是每个参与方的每个接口——正向和 compensation——都带 idempotency key check,用 INSERT ... ON CONFLICT DO NOTHING 或 Redis SET NX 做 dedup。trade-off 是 key 的存储和 TTL。一个 saga 最多 10 个 idempotency key(5 正向 + 5 compensation),key 存多久?太短会误判——key 过期后 late retry 进来当新请求处理了;太长 storage 涨。业界常见做法是 TTL 7 天,覆盖绝大多数 retry window。
04 跨服务的 FIFO 比你想的难得多
saga 的步骤按顺序执行——先扣款,再减库存,再创建物流单。听起来是顺序 dispatch 就行了。但在分布式环境里,”顺序” 这两个字背后全是坑。
orchestrator 通过 message queue 给各个 service 发 command。Kafka partition 内是 FIFO,跨 partition 不保证顺序。如果 step 3 和 step 4 的 command 被发到了不同 partition,到达顺序不确定。更麻烦的是 retry:step 3 第一次 timeout,orchestrator retry,这时候 step 3 的第二次 attempt 和 step 4 的 command 可能同时在 queue 里。consumer 先拿到 step 4,再拿到 step 3 的 retry。顺序反了。
还有更隐蔽的 race condition:saga A 和 saga B 同时操作同一个用户的库存。saga A 先扣库存,saga B 后扣。但 saga A 后来失败了,触发 compensation 加回库存。saga B 的扣库存和 saga A 的 compensation 几乎同时到达 inventory service。最终库存数取决于谁先执行——这不是确定的。
修法是在关键 step 加 fencing token 或 version check。inventory service 的每次操作带一个 monotonically increasing version,compensation 请求带原始操作的 version。如果当前 version 已经被后续操作推进了,compensation 不能简单 “+= 原数量”,要做带 version 校验的条件逆操作。代价是每个参与方都要维护 per-entity 的 version counter,compensation 逻辑从 “简单逆操作” 变成 “带条件的逆操作”。复杂度又涨一层。
05 2PC 不是你想的那么差——saga 也不是你想的那么好
大多数 system design 面试准备材料告诉你:2PC 有 blocking problem,性能差,不 scalable,所以用 saga。
很多场景下这个结论是错的。
2PC 的核心问题是 coordinator crash 后 participant 会 block 在 prepared 状态直到 coordinator 恢复。确实严重。但 2PC 给你 atomicity——要么全 commit 要么全 rollback,没有 intermediate state。saga 给你 eventual consistency——中间态对用户可见。”扣了款但还没发货” 这种状态你得处理展示、客服话术、用户投诉。
什么时候 2PC 反而更合适?参与方少(≤ 3 个 service),同一个 data center,latency 额外 10-20ms 可以接受,最重要的是你不想维护那一整套 compensation 逻辑。一个两方参与的跨库转账,2PC 就是两个 prepare + 一个 commit,代码大概 50 行搞定。用 saga?正向两步、两个 compensation、idempotency key、saga log、recovery polling、state machine 建模——500 行起步,运维复杂度高一个量级。
我之前见过一个团队把内部结算系统从 2PC 迁到 saga,理由是 “2PC 不 scalable”。参与方?两个 internal service,同一个 VPC,round trip < 2ms。迁完之后 saga 的 compensation 逻辑出了三次 bug,每次都是半夜 on-call page。后来又迁回了 2PC。
trade-off 的核心在这里:2PC 的代价是 availability(coordinator 挂了就 block),saga 的代价是 complexity(compensation + idempotency + ordering + recovery 全部自己搞)。 参与方多、跨 region、latency 敏感——用 saga。参与方少、同 DC、强一致性要求高——2PC 大概率是更好的选择。大多数 architect 的问题不是不知道 saga,是不知道什么时候该用回 2PC。
为什么这些问题在面试里能直接分出 level
2026 年的 system design 面试,HC 在收紧,面试官需要更强的 signal 区分候选人。distributed transaction 不再是抽象理论题——面试官会给一个具体场景,电商下单、跨行转账、多步审批流,让你设计事务协调。候选人说 “用 saga”,下一个追问就是 “compensation 失败了怎么办”“orchestrator 挂了怎么恢复”“为什么不用 2PC”。
能把 compensation 的 state machine 画出来——带具体状态名,不是泛泛说 “逆向执行”——能讲清楚 orchestrator crash recovery、能判断什么时候 2PC 比 saga 更合适的候选人,评价表上通常是 Strong Hire。我之前带一个同学重做 saga 相关的架构设计,把 compensation 的完整 state machine、idempotency key 的 scope、recovery 逻辑全部重新推了一遍。他后来面试被追问 “compensation timeout 了你怎么处理”,完整讲了 state machine 建模和 saga log recovery——最终 package 比同级别 high 了 25k。不是多背了概念,是展示出来的判断力不一样。
这个 gap 怎么补
分布式事务有个悖论:教科书把它讲得太简单了。2PC 一页推导,saga 再加一页 “每步配个 undo”。你觉得你会了。
但会了什么?compensation state machine 怎么建模、saga log 的 partition 策略怎么设计、idempotency key 到了 compensation step scope 怎么变——这些是被 compensation failure 半夜 page 起来、写完 post-mortem 之后才慢慢长出来的直觉。不是多读几篇博客能补的。在 production 里摸爬个三五年,才算入门。
最后
这些问题是我帮人重新拆项目架构时花最多时间的一块。很多同学简历上写了 “designed distributed transaction with saga pattern”——面试官看到这行,下一个问题就是 “compensation 失败了怎么办”“orchestrator 挂了谁来接”“为什么不用 2PC”。如果只能答 “每步配一个 undo 函数”,那就是一个暴露 gap 的瞬间。
跟教程最大的区别是,我会在你走错的分叉口拦住你。你觉得 saga 就是 try-catch 的时候,我让你画完整的 state machine;你觉得 compensation 肯定能成功的时候,我问你 “退款 timeout 了怎么办”。不是教代码,是在每个决策点帮你建立 “为什么这么选” 的判断力。
学校不教这些。公司也不会等你学会了再给你 offer。



