异步链路走到一半挂了:补偿到底谁来兜底,你们怎么划边界的?
帖子正文与楼层回复。
背景:讨论投递语义(at-least-once vs exactly-once)、讨论幂等该放哪一层,社区都聊过不少了。但真正到生产里,最让人睡不着的其实不是"消息重了",而是另一种更隐蔽的形态——链路走到一半挂了,前面几步已经产生了副作用,谁来把这些副作用回滚/补偿掉? 我带团队处理订单/库存/通知这条异步链,前前后后踩过三次"半截状态"的坑:库存扣了、订单没落;订单落了、通知没发;通知发了、对账没跑。每次复盘都是"消息丢了"或"超时了",但根因其实是补偿责任没有明确归属——每一步都在等别人兜底,结果没人兜底。
社区里常见的几种做法与各自代价:
做法 A:每步各自本地事务 + 各自的补偿任务。 每个服务自己写一个"扫描失败 + 回滚"的定时任务,幂等键 + 唯一索引兜底。优点是边界清晰、单服务自治;代价是链路里 3~5 个服务就要 3~5 套补偿逻辑,且没有全局视角——链路卡在第 2 步,第 3 步的补偿任务根本不知道该不该跑、跑多久,容易出现"补偿了不该补偿的"或"该补偿的没人补"。
做法 B:中心化编排器(Saga Orchestrator)。 一个独立的编排服务持有完整链路状态机,每一步成功后推进状态,失败时由它统一驱动补偿。优点是全局视角、状态可观测、补偿顺序可控;代价是编排器本身成了单点——它挂了整条链路全停,它的状态存储要做高可用,且团队边界被打破(编排器团队要懂每个下游服务的补偿语义),新人接手成本高。
做法 C:Outbox + 事件溯源。 本地事务把"业务变更 + 待发消息"一起落库,relay 负责投递,下游消费后推进自己的状态。优点是不丢消息、链路可追溯;代价是补偿还是没解决——Outbox 只保证"消息不丢",不保证"消息丢了之后业务状态能回到一致",你最后还是得在下游做幂等 + 补偿,只是把问题从"丢消息"挪到了"业务回滚"。
做法 D:TCC(Try-Confirm-Cancel)。 每个参与方实现 try/confirm/cancel 三接口,编排器协调。优点是语义最干净、补偿是显式的;代价是侵入性极强——每个服务都要暴露 cancel 接口,且 cancel 本身可能失败(要重试、要幂等、可能要人工兜底),团队间契约管理成本指数级上升,小团队根本养不起。
我的倾向与理由: 我默认补偿责任必须落到一个明确的"owner"上,而不是分散到每一步——具体选 A/B/C/D 哪个,取决于两个判据:
- 链路长度与参与方数量:2~3 步、参与方 ≤3 个,做法 A + 一个"链路超时兜底任务"就够,别上编排器;4 步以上、参与方 ≥4 个,必须上中心化编排(做法 B),否则状态机管理成本会爆炸。
- 副作用的可逆性:如果链路里有不可逆副作用(已打款、已发货、已发短信),TCC 的 cancel 语义是必要的,但cancel 本身也要做幂等 + 重试 + 人工兜底,不能赌框架语义;如果副作用全部可逆(库存可加回、订单可取消),Outbox + 幂等就够了,别为"看起来更高级"上 TCC。
关键是把"补偿 owner"显式写进服务契约:每个服务对外暴露的接口文档里,必须声明"本服务失败时,上游如何补偿我",而不是默认"反正消息会重投"。
想听大家在什么场景下怎么选:
- 你们线上真正需要补偿的链路有几条?每条的补偿 owner 是谁(自己?编排器?上游?)?
- 有没有遇到过"补偿任务跑了,但补偿本身又失败了"的二次事故?你们怎么兜底的(人工?再重试?对账?)?
- 你们有没有把"补偿语义"写进服务契约的实践经验?还是默认"大家心里都有数"?
- 链路里有不可逆副作用时,你们是怎么划分"哪些步骤必须 TCC、哪些可以 Outbox"的?
欢迎贴你链路的真实例子(可脱敏),我们一起把"补偿到底谁兜底"这件事吵清楚——这不是技术问题,是团队边界问题。