1. Saga不是银弹:先搞清它的问题模型和适用边界
1.1 从一次对不上账的线上事故说起
几年前我参与过一个电商系统的微服务改造,订单、库存、支付三个模块拆成三个独立服务,各自拥有独立数据库。上线第一天晚上就出了事故:用户在App下单成功,支付也成功了,但库存服务因为一个空指针异常没扣减库存,结果电商后台显示“已付款订单”和“实物库存”对不上账,运营同事凌晨打电话把我从床上叫起来。
排查时发现,订单服务、支付服务、库存服务各自的事务都提交成功了,但从业务整体看,这三件事必须“要么全成功、要么全失败”,很明显,单靠每个服务内的本地事务,根本没法保证跨服务的数据一致性。这种问题经历过一次就会明白:分布式事务不是理论课上学完就忘的概念,它是微服务架构里实打实会爆的雷。
后来我们评估了2PC、TCC、本地消息表、Saga等多种方案,最终选定了Saga模式。原因很简单:订单、库存、支付这类业务链路天然是“长事务”,中间涉及多次远程调用和人工/系统确认,用强一致的2PC方案扛不住高并发的资源锁定,用TCC的话每个接口都要手写三个方法,业务侵入太重。Saga的核心思想是“把一个大事务拆成一串本地事务,任何一个环节失败,就对前面已经成功的步骤逐个做反向补偿”,这个思路和我们业务的实际诉求基本吻合。
1.2 本地事务、2PC与Saga的解决思路差异
先看一个表格,把几类方案放在一起对比,这样最直观:
| 维度 | 本地事务 | 2PC(两阶段提交) | Saga |
|---|---|---|---|
| 一致性类型 | 强一致 | 强一致(通常依赖XA) | 最终一致 |
| 锁与资源占用 | 数据库行锁,短 | 全局锁,资源长期占用 | 无全局锁,资源利用高 |
| 适用场景 | 单库单服务 | 短事务、并发要求不高的场景 | 跨服务长事务、微服务架构 |
| 业务侵入度 | 最低 | 中等,需要XA接口 | 较高,必须为每个子事务设计补偿 |
| 对外可见性 | 事务提交后可见 | 提交后可见,prepare阶段不可见 | 过程中的中间状态可能被外部看到 |
| 失败处理 | 自动回滚 | 协调者回滚 | 反向补偿/失败重试 |
2PC很像生活中“两个人约饭”:先都答应(prepare),确认大家都同意后再一起出发(commit)。问题在于,如果有人一直不表态,所有人就得干等着,这在分布式系统里意味着数据库连接和锁被长时间占着,并发稍高一点,系统直接垮掉。
Saga则更像“接力跑”或“旅行团行程”:每个子服务完成自己的任务后,马上释放资源,把状态和事件传给下一站;如果后面某一站出问题,不会回到起点“时光倒流”,而是按原路把已经走过的步骤逐一取消。这里没有全局锁,也不要求所有参与者同时到达,代价是系统在事务执行过程中的某个时间点,可能处于“订单已创建但还没扣库存”这种中间状态——这就是最终一致性,也是很多人初次接触Saga时最不适应的地方。
1.3 Saga中的“最终一致”到底意味着什么
我经常跟团队成员打一个比方:Saga事务过程中产生的中间状态,就像你在电商平台下单后,后台正在协调仓库和财务,界面上显示“待发货”——用户不关心中间经历了什么,只要你最终给到一个确定结果就行。但如果这个中间状态对内部其他系统可见,就可能引发连锁问题。
比如订单创建成功、库存还没扣减时,如果运营后台直接去查“可售库存”给用户发货,就可能超卖。所以使用Saga,必须有一条铁律:所有暴露在Saga中间状态中的数据,要么加上“事务状态”字段(如待确认、处理中),要么在查询路径上做隔离过滤,绝不能让其他业务在未完成时直接依赖。
Saga还要求你接受一个现实:事务是有可能“部分成功”的,最终靠补偿来收敛。这和地方性事务(ACID)不同,ACID失败后数据库自动把修改全部抹掉,而Saga的补偿代码要自己写、要保证补偿本身成功、还要考虑补偿失败怎么办。想清楚这些,才算真正理解Saga的边界。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 订单下单场景:一个Saga实例的完整拆解
2.1 正向流程的本地事务划分
理论讲再多,不如把“下单”这个经典场景拆开看。假设一个电商订单的生命周期包含以下四步,每一步都是独立服务里的一个本地事务:
- 订单服务创建订单,状态为“待支付”,数据库落一条订单记录;
- 库存服务锁定库存,把可用库存扣减掉,写入“锁定明细”;
- 支付服务调用第三方支付渠道完成扣款;
- 积分服务给用户增加本次订单对应的积分。
注意这里的第2步,我刻意写的是“锁定库存”而不是“直接扣减库存”。理由是:锁定比扣减更安全,锁定意味着库存被先占住,订单如果取消就能立刻释放;如果直接扣减,之后退款退货还得做复杂的库存回补和日志对账。从Saga角度讲,“锁定库存”本身也是幂等设计的基础——后续补偿只要把明细里的锁定状态改成“已释放”就行。
这四步连起来就是一个典型的正向Saga链路:订单创建是第一步,没有前置依赖,失败不需要补偿;之后每一步成功都把状态推给下一步。整个链路对外暴露的关键点只有一个——任何一个环节失败,系统就要进补偿流程。
2.2 补偿流程的设计与顺序
补偿是整个Saga的灵魂。还是上面那个场景,我们把失败点逐一列出来,补偿动作必须严格按“反向遍历”的方式执行:
| 失败位置 | 已经成功的步骤 | 需要执行的补偿动作 |
|---|---|---|
| 第1步订单创建失败 | 无 | 无需补偿,直接返回失败 |
| 第2步库存锁定失败 | 订单已创建 | 将订单状态更新为“已取消” |
| 第3步支付失败 | 订单已创建、库存已锁定 | 释放库存,再将订单状态更新为“已取消” |
| 第4步积分赠送失败 | 订单已创建、库存已锁定、支付已扣款 | 发起支付退款,释放库存,再将订单状态更新为“已取消” |
这里最值得注意的细节是:每一步补偿动作本身也是一次本地事务,而且必须有对应的独立“反向接口”。比如订单服务的“取消订单”不能直接delete订单记录,而应当做一次状态更新:把订单状态从“待支付”改成“已取消”,同时记录取消原因、操作人、操作时间。逻辑删除带来的好处是数据可追溯,后续财务对账、用户投诉都有据可查。
还有一个容易踩的坑:补偿顺序不能乱。库存释放必须发生在退款之前吗?不一定有强制的先后关系,但我的经验是,先释放库存再退款,因为退款动作涉及第三方渠道,可能失败,而库存是内部系统,优先保证“物理资源不受损”。至于订单状态,放到最后更新,做一个收尾动作,表示整条Saga链路已经完成。
2.3 为什么要用“业务单据ID”贯穿全链路
Saga补偿要靠谱,每个子步骤必须能识别出“我是哪笔业务的哪个环节”。所以设计接口时一定要约定一个全局唯一的业务单据ID(比如订单号)随链路透传,并且把它作为所有子事务表的业务主键或唯一索引。没有这个ID,补偿时根本不知道该回补哪一单的哪条库存明细。
此外,正向流程的每个服务在处理请求时,都应该先检查“这笔业务单据是否已经处理过”,这就是幂等判断。举个例子:由于网络超时,订单服务调库存服务的锁定接口,库存服务其实已经执行成功了,但响应超时,订单服务重试了一次。如果库存服务不判断重复请求,就会把同一笔库存锁定两次,最终补偿时也只释放一次,库存账目必乱。
3. 编排与协同:两种Saga实现方式的选型对比
3.1 Choreography协同模式:让服务自己“接力”
Saga有两种主流的实现方式,第一种叫Choreography(协同模式),业界也常翻译成“编排模式”或“事件驱动模式”,核心思想是:没有中心协调者,每个服务处理完自己的本地事务后,发布一个领域事件,由下一个服务监听该事件并继续处理。
下单链路如果用Choreography描述,大致是这样的:
- 订单服务创建订单,发布“OrderCreated”事件;
- 库存服务监听到“OrderCreated”,锁定库存,发布“StockLocked”事件;
- 支付服务监听到“StockLocked”,调用支付渠道完成扣款,发布“PaymentCompleted”事件;
- 积分服务监听到“PaymentCompleted”,增加积分,发布“PointsGranted”事件。
对应的补偿逻辑也由事件触发。如果在支付环节失败,支付服务发布“PaymentFailed”事件,库存服务和订单服务分别监听,依次释放库存、取消订单。
这种模式的优点非常明显:服务之间通过事件解耦,新增一个参与方(比如加个优惠券服务)只需要监听对应事件并发布下一个事件,几乎不用改动已有服务,系统扩展性极佳。缺点也很致命:全局业务流程被分散在各个服务的业务逻辑和事件订阅里,一旦链路深了,排查一个问题要在四五个服务的代码和消息日志之间跳来跳去。而且事件是异步的,链路上任何一个事件丢失或乱序,整个事务就可能卡住。我们团队早期用Choreography,有一次消息堆积导致补偿事件被延迟消费,线上库存多锁了近一刻钟才释放,运营那边可售库存数据一直不对,非常被动。
3.2 Orchestration编排模式:用一个中心调度器指挥全局
第二种叫Orchestration(编排模式/中央调度模式),核心是引入一个独立的“Saga事务协调器”(很多框架里叫Saga Execution Coordinator,简称SEC),由它统一指挥每个参与方做什么、什么时候做、失败后怎么补偿。
还是下单链路,用编排模式实现后,协调器会按顺序依次调用:
- 调用订单服务的
createOrder()接口; - 调用库存服务的
lockStock()接口; - 调用支付服务的
pay()接口; - 调用积分服务的
grantPoints()接口; - 任一接口抛出异常,协调器按已执行步骤的逆序,调用对应的
compensate方法。
相对于Choreography,编排模式最大的优点是“流程对开发人员完全透明”——打开协调器的状态配置或代码,就能看到整个事务的完整执行路径和补偿路径。而且因为所有调度都由协调器同步控制,事务进度可控,重试和超时处理集中在单一模块,排查问题容易得多。缺点也明显:协调器是中心节点,存在单点压力;服务与协调器之间存在调用依赖,协调器必须高可用。
3.3 两种模式到底怎么选
结合我的实践经验,给出一张选型对比表:
| 对比维度 | Choreography(协同) | Orchestration(编排) |
|---|---|---|
| 控制权归属 | 分散在各服务中 | 集中在协调器中 |
| 服务耦合度 | 低,事件解耦 | 高,服务依赖协调器 |
| 业务流程可读性 | 差,分散且隐式 | 好,集中且显式 |
| 故障排查难度 | 高,需要追踪事件流 | 低,看调度日志即可 |
| 对消息中间件的依赖 | 强依赖,事件不能丢 | 较弱,同步调用为主 |
| 新增参与方的成本 | 低,订阅既有事件即可 | 中,需要改协调器配置 |
| 适用团队规模 | 小团队、流程简单 | 中大型团队、链路复杂 |
如果你们的Saga链路只有两三个环节、团队人不多,Choreography能省掉一整个协调器服务的开发和运维成本。但如果像我们一样有订单、库存、支付、积分、优惠券等五六个环节,我会强烈建议直接用Orchestration。中心化不等于坏味道,在分布式事务这种“必须精确控制失败分支”的场景里,流程集中管理反而是最大的安全感来源。
4. Seata Saga状态机引擎原理解读
4.1 Seata的TC、TM、RM三组件定位
聊Seata的Saga实现之前,先明确Seata的整体架构。Seata(Simple Extensible Autonomous Transaction Architecture)把分布式事务框架拆成三个核心组件:
- TC(Transaction Coordinator):事务协调器,独立部署的服务端,维护全局事务和分支事务的状态,驱动全局提交或回滚;
- TM(Transaction Manager):事务管理器,嵌入在业务发起方的应用内,负责开启全局事务、提交/回滚全局事务;
- RM(Resource Manager):资源管理器,嵌入在参与事务的服务应用内,管理每个分支事务的资源,并向TC注册分支事务、上报状态。
在AT模式和TCC模式里,这三个组件的协作方式各不相同,而在Saga模式里,核心是“状态机引擎”——一个由TC驱动、将Saga执行流程定义为状态机和事件流的外置引擎。
4.2 状态机配置长什么样:一个订单Saga的JSON示例
Seata Saga模式是典型的Orchestration实现,它允许你用JSON描述整个Saga流程,执行时由状态机引擎逐项驱动。下面是一个简化但真实的下单Saga状态机配置:
json复制{
"Name": "orderSaga",
"StartState": "CreateOrder",
"States": {
"CreateOrder": {
"Type": "ServiceTask",
"ServiceName": "orderService",
"ServiceMethod": "createOrder",
"CompensateState": "CancelOrder",
"Next": "LockStock"
},
"LockStock": {
"Type": "ServiceTask",
"ServiceName": "stockService",
"ServiceMethod": "lockStock",
"CompensateState": "ReleaseStock",
"Next": "DoPay"
},
"DoPay": {
"Type": "ServiceTask",
"ServiceName": "payService",
"ServiceMethod": "pay",
"CompensateState": "Refund"
},
"CancelOrder": {
"Type": "ServiceTask",
"ServiceName": "orderService",
"ServiceMethod": "cancelOrder"
},
"ReleaseStock": {
"Type": "ServiceTask",
"ServiceName": "stockService",
"ServiceMethod": "releaseStock"
},
"Refund": {
"Type": "ServiceTask",
"ServiceName": "payService",
"ServiceMethod": "refund"
}
}
}
核心概念就三个:
StartState:定义状态机的起点;States:定义所有状态节点,每个节点通常对应一个服务方法;CompensateState:定义该节点失败或全局补偿时对应的补偿步骤。
Type字段除了ServiceTask(调用服务方法),还支持CompensateState(补偿节点)、ChoiceState(条件分支)、StateMachineTask(子流程)等。如果业务里有“库存不足走预售、库存充足走正常下单”这种分支,可以用ChoiceState实现。
4.3 状态机引擎的执行流程与持久化机制
Seata Saga状态机引擎的工作过程,大致分成这几步:
- 业务方通过
StateMachineEngine.start()方法启动一个状态机实例,传入流程名和业务参数; - 引擎读取JSON配置,从
StartState开始,逐个节点执行; - 每完成一个节点,引擎把节点执行结果和当前状态持久化到数据库(默认
saga_state_machine和saga_state_machine_inst两表); - 如果某个节点抛异常,引擎查找该节点配置的
CompensateState,沿着已经执行节点的反向路径,逐一调用补偿逻辑; - 全局流程执行结束后,状态机实例表记录最终状态为
SUCCESS或FAIL,整个全局事务在TC侧随之提交或回滚。
这里的关键机制是“状态持久化”。正因为每个执行节点都能在数据库中找到状态记录,所以即使执行过程中应用宕机,重启后引擎也能根据持久化的状态继续推进或进行补偿。这也是编排式Saga相比事件驱动式Saga更让人放心的核心原因——状态不丢,就能恢复。
4.4 Seata AT、TCC、Saga、XA四种模式怎么定位
说到Seata,很多人会混淆AT、TCC、Saga、XA这四种模式。我的理解是:
- AT模式:Seata的招牌,通过代理数据源自动生成undo_log反向SQL,业务几乎无侵入,但要求数据库支持当前读/快照读,适用并发不极端的场景;
- TCC模式:需要业务自己写Try、Confirm、Cancel三个方法,控制力最强,但开发量大,适合每个参与方都需要明确预留资源的场景;
- Saga模式:面向长事务和流程化业务,状态机编排天然适配,开发量介于AT和TCC之间,补偿逻辑是核心;
- XA模式:依赖数据库原生XA协议,强一致,但性能受限。
如果项目本身没有复杂的跨服务长业务流程,用AT就好,简单省事;如果像订单下单这种流程长、步骤多、并且每一步的成败都需要明确记录的,Saga是更合理的选择。
5. 一致性陷阱:幂等、空补偿与悬挂的实战排查
5.1 幂等设计:所有接口都要能重复调用
Saga场景下,远程调用随时可能超时、重试、乱序,所以每个正向操作和补偿操作都必须是幂等的。不做好幂等,会有两个典型故障:
- 正向操作重复执行:扣了两次库存,补一次库存;
- 补偿操作重复执行:退两次款,用户白赚一笔。
我的实践做法是“业务唯一键+状态检查”。以库存锁定为例,库存锁定表stock_lock_detail中,把order_id设为唯一索引,执行锁定前先INSERT一条锁定记录,如果插入失败说明该订单已经锁过库存,直接返回成功;补偿时把该记录的status改成RELEASED,如果状态已经是RELEASED,说明补偿已经执行过,直接忽略。
sql复制-- 库存锁定表结构(简化)
CREATE TABLE stock_lock_detail (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL UNIQUE,
sku_id BIGINT NOT NULL,
locked_count INT NOT NULL,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-锁定 1-已释放',
created_at DATETIME,
updated_at DATETIME
);
这里把order_id设为唯一索引,就是幂等的第一道防线。数据库层面的唯一约束,比应用层加锁判断可靠得多。
5.2 空补偿:补偿动作先于正向动作执行的离奇场景
空补偿是Saga里最难排查的问题之一,典型场景是:订单服务调用库存服务lockStock(),请求因为网络原因在链路上超时了,订单服务认为锁定失败,于是触发补偿流程,调用库存服务的releaseStock()释放库存。可实际上,库存服务的lockStock()方法在补偿请求到达时压根还没执行成功——补偿先到了,正向操作后到,这就产生了“空补偿”。
等正向的锁定请求慢慢到达库存服务并成功锁库后,整个事务已经宣告失败,库存却一直锁着没人释放。这直接导致“死库存”。
解决空补偿,必须保证“正向操作执行前检查是否已经补偿过”。具体做法是在业务表里增加一个compensated标记字段:补偿请求先到,发现正向操作还没执行,就把补偿标记置为1;正向操作执行前先检查该标记,如果为1,就拒绝执行正向业务。这就是经典的“防悬挂”处理。开源TCC框架里通常有TransactionContext帮你控制这些,但Saga状态下机里经常需要业务自己实现。
5.3 悬挂:补偿结束之后正向操作才到
悬挂和空补偿是一体两面。补偿操作执行完了,系统已经走了退款、库存释放的补偿链路;这时候正向操作的请求才姗姗来迟并成功执行,导致整个事务状态“倒挂”——补偿了,但正向操作也生效了。在订单场景里,这直接表现为:用户收到了退款,但订单却显示支付成功(正向操作最终把订单改成了已支付),这笔账彻底对不上。
处理方式和空补偿一样,就是上面说的补偿标记检查。只要每个参与方在接收正向请求时,先查一下该业务单号是否有补偿记录,有就不执行正向逻辑,这个问题就能从根上杜绝。
5.4 从实际项目中总结的几条铁律
踩过这些坑之后,我把经验沉淀成几条铁律,每次设计Saga链路都拿出来对照:
- 所有参与方接口入参必须包含全局事务ID和业务单据号,这是追溯、幂等、补偿的前提;
- 每条正向业务流程和补偿流程都必须幂等,优先靠数据库唯一键约束而非应用层判断;
- 正向操作执行前检查“是否已补偿”,补偿操作执行前检查“是否已正向执行”,两个检查缺一不可;
- 补偿动作本身也要记录日志和状态,补偿失败时要有重试机制或报警,否则全局事务永远卡在失败态;
- Saga链路中的每个步骤都要有超时控制,超时后的处理策略(重试还是直接触发补偿)必须在设计时明确。
这几条铁律不是写代码时才想,而是要在接口设计阶段就定下来。我们后期维护Saga链路时,遇到的生产事故几乎都能从这些铁律里找到对应的“破戒之处”。
6. 写在最后:我的实际体会
做了几年分布式事务,最大的体感是:Saga模式本身不难理解,难的是让团队每个人都深刻理解“最终一致和补偿”的代价。不少开发第一次接触Saga,会觉得它不如ACID“干净”,总想在业务代码里强行嗅探各种中间状态,结果把链路改得乱七八糟。
按我个人经验,落地Saga最重要的一件事,是在项目启动时就把状态机流程图、补偿方案表、幂等策略都画出来、写下来,而不是先写代码再补文档。状态机配置和业务代码分离带来的好处,是后续每个人接手时都能快速搞清楚“我做的是哪一环,失败了该找谁”。另外一个建议是,能用成熟的编排引擎(比如Seata Saga)就不要自己从零造轮子,状态机的持久化、恢复、异常分支处理,这些自己实现成本高且容易出错,框架级的方案往往已经在生产环境被反复验证过。
最后分享一个调试技巧:测试Saga时,专门写一个故障注入脚本,在每个参与服务的接口里按比例注入随机异常和超时,然后批量跑下单用例,重点看补偿链路是否收敛、库存和金额最终是否对得上。这套脚本我们一直在用,每次重构Saga配置后先跑一遍,能挡掉绝大多数隐性坑。Saga不是万能药,但用好了,它确实是跨服务长事务里最务实的方案之一。
