☰
SAGA模式详解:从分布式事务补偿机制到Java实现示例
2026/10/27 20:23:52 网站建设 项目流程

1. 一个典型的分布式事务问题:扣款成功了,库存却没扣掉

先从一个我再熟悉不过的场景讲起。你在电商平台下单,订单服务、库存服务、账户服务分属三个独立的微服务模块,各自拥有独立的数据库。客户点击"提交订单"后,系统需要同时完成:创建订单记录、扣减商品库存、冻结账户余额。任何一个环节失败,整个操作都必须回退。如果三个服务在同一套数据库里,一个本地事务就能搞定,但微服务架构下,这种跨库、跨服务的操作就成了最早的分布式事务难题。

很多刚接触分布式的同学第一反应是:能不能用本地事务的思维去处理?把三个操作放在一个事务里,任意一个失败就整体回滚。这个思路在单体时代完全正确,但到了微服务时代就变了。每个服务有自己独立的数据库连接、独立的事务边界,A服务提交成功之后,B服务的事务和A服务互不可见,一旦B服务失败,A服务已经提交的数据无法被自动撤销,这就是所谓的一致性问题。

更麻烦的是,互联网业务天然追求高并发和快速响应,交叉事务的持续时间一长,数据库锁就会被长时间持有,吞吐量直接掉到哪里去不用我说。所以业界逐渐分流出两条路线:追求强一致性的XA/2PC方案,以及追求最终一致性的柔性事务方案。本文要聊的SAGA模式,就是柔性事务里最重要、最适合长事务场景的一种解决方案。

SAGA这个名词源自1987年Hector Garcia-Molina和Kenneth Salem发表的论文《Sagas》,思想非常朴素:把一个长事务拆成一串有顺序的本地子事务,每个子事务都有对应的补偿动作,一旦后续某一步失败,就逐层反向执行补偿操作,让数据回到初始状态。既然叫"入门示例",我会先用一张显式的执行流程把SAGA的运转逻辑讲透,再用纯Java实现一个订单场景的最小可用Demo,最后补充生产环境里最容易被忽视的几个坑。

2. SAGA模式的内在逻辑:为什么它是长事务的最优解

2.1 从提交与补偿的对照表看SAGA的本质

SAGA的核心是一张"正向操作与补偿操作对照表"。正向操作完成业务动作,补偿操作用来撤销正向操作已经产生的效果。拿订单场景举例,正向操作依次是:创建订单、扣减库存、扣减账户余额。对应的补偿操作分别是:将订单标记为取消、恢复库存数量、将账户余额退回原值。

关键在于:正向操作和补偿操作各自都是完整的本地事务,它们本身遵循ACID。所以SAGA全局来看不满足隔离性和原子性,但最终数据会收敛到一致状态。原子性靠什么保证?靠补偿机制。如果第三步"扣减账户余额"失败,SAGA引擎会依次执行第二步的补偿"恢复库存"、第一步的补偿"取消订单",最终所有数据回到操作前的状态。这就是SAGA最终一致性的全部秘密。

这个设计思路和2PC(两阶段提交)有本质区别。2PC追求的是"要么全部成功、要么全部不执行",在RM(资源管理器)层面通过全局锁保证任何时刻对外可见的数据要么是旧值、要么是新值。而SAGA允许中间状态被其他事务看到,只保证"最终归位"。代价是隔离性变弱,但换来了极高的可用性——没有全局锁、没有长期阻塞、各个子事务独立提交、失败后自行补偿,这对互联网高并发场景来说至关重要。

2.2 SAGA的三种执行路径:正常、回滚、复活

我用一条时间线拆解SAGA的完整执行路径:

  • 正常路径:T1执行成功 → T2执行成功 → T3执行成功 → 全局结束,所有操作提交,无补偿操作执行。
  • 回滚路径:T1成功 → T2成功 → T3失败 → 执行C3(T3的补偿)→ 执行C2(T2的补偿)→ 执行C1(T1的补偿)→ 全部补偿完成,全局回滚。
  • 部分成功回滚路径:T1成功 → T2失败 → 此时T3尚未执行,无需补偿T3,只需要依次执行C2、C1即可。

这里读者可能会问:T2失败的时候,T2自己的本地事务是不是已经回滚了?是的,T2自身的数据库操作在服务内部已经回滚,所以补偿要从T2的本地事务边界之外开始算。如果T2的本地事务在服务内部回滚了,SAGA引擎仍然会执行C2吗?答案是:要根据实现而定。严谨的做法是T2的补偿依然保留,因为T2可能已经对消息队列、第三方接口等外部资源产生了副作用,这些副作用不会随本地事务回滚而消失。库存扣减如果调用了外部仓储系统的接口,就算本地事务回滚,外部系统的库存可能已经减了,所以补偿必须能覆盖这类外部副作用。

在Seata的SAGA状态机实现里,每个状态节点都配置了Compensate节点,引擎自动根据执行结果决定是否进入补偿分支。理解了这一层,后续看代码就非常顺畅了。

2.3 SAGA与2PC、TCC的适用边界

很多文章喜欢把SAGA、TCC、2PC放在一起对比,但实际选型时最关键的是看业务对一致性的容忍度。

  • 2PC/XA:适用于并发量不大、业务对强一致性要求极高的场景,比如金融核心账务、跨行转账等。缺点明显,协调者单点、资源锁定时间长、参与者故障会导致阻塞。
  • TCC:Try-Confirm-Cancel模式,要求业务方把每个操作显式拆成三个动作,对业务侵入性最大,但性能高、一致性较好。适合需要预留资源的场景,比如账户扣款先冻结、库存扣减先预占。
  • SAGA:业务侵入性低于TCC,不需要预留资源,只需要为每个正向事务编写补偿逻辑。缺点是隔离性弱,中间态对外可见,需要业务层额外做防脏读处理。适合:涉及多个长事务步骤、对实时一致性要求不高的场景,比如订单全流程、旅游预订、采购审批等。

我在多个项目里的体会是:如果每个子事务执行时间在秒级以内、失败概率不高、数据敏感度中等,用SAGA最省事。如果子事务必须快速失败并需要强隔离,则考虑TCC。如果整个流程只在极低并发下运行且必须严格一致,再考虑XA。SAGA不是银弹,但它是"工程性价比"最高的一类方案。

3. 编排式与协同式:两种实现SAGA的路线怎么选

3.1 编排式SAGA:一个中心协调者掌控全局

编排式(Orchestration)的做法是:定义一个Saga编排器(Orchestrator),由它统一向各个参与者服务发送指令,依次调用正向接口,捕获异常后再主动调用补偿接口。所有步骤的状态、顺序、补偿分支都由编排器内部的状态机管理。

这种方式的优点非常明显:

  • 流程集中管控,可直观看到当前执行到第几步。
  • 新增或调整步骤时,只需要改编排器代码,各参与者无感知。
  • 补偿逻辑集中,不容易乱。
  • 易于监控和运维,状态流转日志天然存在于编排器。

缺点也很直观:编排器本身是中心点,如果它宕机,整个SAGA流程就卡住。但工程上这个缺点完全可控——编排器无业务状态,只负责调度,可以做集群部署;执行进度持久化到数据库,恢复后从状态机断点继续执行即可。

我在生产环境看到过不少团队用状态机或BPMN流程引擎实现编排式SAGA,甚至可以结合Apache Camel、Spring StateMachine等框架。状态机的最大好处是:每个节点都有明确的前置状态和后置状态,重试、补偿、超时处理都可以挂在节点上,逻辑变得非常规整。

3.2 协同式SAGA:事件驱动让服务自己接力

协同式(Choreography)不设中心编排器。每个服务执行完本地事务后,向消息队列发布一个事件,下一个服务监听该事件并执行自己的业务,执行成功后继续发事件。一旦某个服务执行失败,它发布一个失败事件,上游服务监听后执行各自的补偿操作。

协同式的优点是:服务间解耦极彻底,新增一个参与者只需要订阅相关事件、发布自己的事件,不需要改动任何现有服务。组合起来很方便。缺点是:流程分散在各个服务的事件订阅逻辑里,全局执行链路非常难观测,排查问题需要靠链路追踪和日志串联,心智负担很重。再加上事件消息的乱序、重复投递等问题,协同式在复杂业务中容易失控,所以刚入门时我不建议直接上协同式。

表格式对比更直观:

对比维度编排式SAGA协同式SAGA
控制中心有,编排器统一调度无,事件链驱动
业务耦合服务间无直接依赖,但依赖编排器服务间通过事件解耦,依赖消息Topic
流程可观测性高,集中查看状态机日志低,需链路追踪串联事件
故障排查难度相对容易相对困难
改动成本调整编排器即可可能动到多个服务订阅关系
适用场景流程固定、步骤较多的复杂业务流程简单、事件流清晰的场景

3.3 实际选型建议

我的个人倾向是:业务步骤在三到六步之间、执行顺序相对固定、团队对SAGA不熟悉的场景,一律先做编排式。等团队积累了足够经验、事件流足够清晰,再针对特定链路尝试协同式。

为什么?因为编排式的代码路径一目了然,出了故障,打开编排器的日志就能定位到哪一步失败、补偿到哪一步;协同式则需要把每个服务的消费者日志拉出来拼链路,一旦消息中间件崩了,跟踪成本直线上升。对于"入门"阶段的团队,编排式的容错空间更大。

而且现在很多重量级框架都支持编排式SAGA的状态机定义,比如Seata的SAGA模式,可以在配置中心预定义JSON状态图,运行时由引擎驱动。个人实现一个简单版本也很容易,下一节我会用状态机思路写一个极简核心。

4. 手写一个Java示例:从零实现订单场景的SAGA编排器

4.1 场景设定与状态定义

为了让示例够直观又不至于堆一堆无用的Spring配置,我用纯Java加少量线程池模拟三个微服务:订单服务、库存服务、账户服务。每个服务内部用一个数据库存储接口抽象,真实项目里替换为对应的DAO和远程调用即可。

流程定义:下单接口被调用后,依次执行以下三步:

  1. 创建订单(CreateOrder)——向订单表写入一条状态为"待支付"的订单记录。
  2. 扣减库存(DeductStock)——把商品库存减1。
  3. 扣减余额(DeductBalance)——从用户账户余额中扣减订单金额。

补偿定义:

  1. 如果扣减余额失败:恢复库存(AddStock),然后把订单状态置为"已取消"。
  2. 如果扣减库存失败:直接把订单状态置为"已取消"。

整个流程中的状态我用一个枚举表示:

public enum SagaState { INIT, ORDER_CREATED, STOCK_DEDUCTED, BALANCE_DEDUCTED, COMPLETED, COMPENSATING, COMPENSATED }

状态机的核心思想很朴素:每次正向操作成功后,推进状态到下一个值;一旦某个操作抛异常,切换到COMPENSATING分支,反向调用补偿操作,最终把所有操作恢复到初始附近的状态。这里为了演示,我把补偿的最终态定义为COMPENSATED。

4.2 核心代码实现

先定义Saga参与者接口。每个可回滚的步骤实现该接口,提供正向执行和反向补偿两个方法:

public interface SagaParticipant { void execute(); void compensate(); }

然后分别写三个服务类。注意:为了演示状态流转,我在每个方法里都加了输出,真实项目这里就是业务方法或远程调用。

public class OrderService implements SagaParticipant { @Override public void execute() { System.out.println("Step 1: create order status=PENDING"); // 真实实现: orderDao.insert(new Order("PENDING")); } @Override public void compensate() { System.out.println("Compensate Step 1: cancel order"); // 真实实现: orderDao.updateStatus(orderId, "CANCELED"); } } public class StockService implements SagaParticipant { @Override public void execute() { System.out.println("Step 2: deduct stock"); // 真实实现: stockDao.deduct(productId, 1); } @Override public void compensate() { System.out.println("Compensate Step 2: release stock"); // 真实实现: stockDao.increase(productId, 1); } } public class BalanceService implements SagaParticipant { @Override public void execute() { System.out.println("Step 3: deduct account balance"); // 真实实现: accountDao.deduct(userId, price); // 故意抛出异常模拟余额不足 // throw new RuntimeException("insufficient balance"); } @Override public void compensate() { System.out.println("Compensate Step 3: refund account balance"); // 真实实现: accountDao.refund(userId, price); } }

接下来是SAGA编排器的核心。我用一个列表保存正向步骤,一个列表保存对应的逆序补偿步骤,正常执行时按序调用execute,捕获到异常后倒序遍历补偿列表:

import java.util.ArrayList; import java.util.List; public class SagaOrchestrator { private final List<SagaParticipant> forwardSteps = new ArrayList<>(); private final List<SagaParticipant> compensateSteps = new ArrayList<>(); public void addStep(SagaParticipant forward, SagaParticipant compensate) { forwardSteps.add(forward); compensateSteps.add(compensate); } public void execute() { int completed = 0; try { for (int i = 0; i < forwardSteps.size(); i++) { forwardSteps.get(i).execute(); completed++; } System.out.println("Saga completed successfully."); } catch (Exception e) { System.err.println("Saga failed at step " + (completed + 1) + ": " + e.getMessage()); compensate(completed); } } private void compensate(int fromIndex) { System.out.println("Start compensating..."); for (int i = fromIndex - 1; i >= 0; i--) { try { compensateSteps.get(i).compensate(); } catch (Exception ex) { System.err.println("Compensation failed at index " + i + ": " + ex.getMessage()); // 生产环境此处需要记录补偿失败状态并触发告警/人工处理 } } System.out.println("Compensation finished."); } }

配套的测试类如下,故意打开BalanceService里的异常模拟余额不足,观察补偿流程如何逆向触发:

public class SagaDemo { public static void main(String[] args) { OrderService orderService = new OrderService(); StockService stockService = new StockService(); BalanceService balanceService = new BalanceService(); SagaOrchestrator orchestrator = new SagaOrchestrator(); // 正向步骤与补偿步骤一一对应 orchestrator.addStep(orderService, orderService); orchestrator.addStep(stockService, stockService); orchestrator.addStep(balanceService, balanceService); orchestrator.execute(); } }

执行结果是这样的:

Step 1: create order status=PENDING Step 2: deduct stock Step 3: deduct account balance Saga failed at step 3: insufficient balance Start compensating... Compensate Step 2: release stock Compensate Step 1: cancel order Compensation finished.

值得注意:第三步自己的本地事务已经回滚,所以补偿列表中没有"refund balance"这一步,直接从第二步开始倒序补偿,这正是前面说的局部回滚语义。这个"completed计数器"有一个隐藏风险,下面我会专门讲空补偿问题。

4.3 状态机版本:带持久化的进阶思路

上面的Demo适合演示核心思想,但真实项目里你还需要把执行进度持久化到数据库,否则进程崩溃后无从恢复。我简单说一下进阶设计,读者可以作为扩展方向:

  • 建一张saga_instance表,字段包括:saga_id、current_step、status、payload、created_time、updated_time。
  • 每执行完一个正向步骤,更新current_step和status为已执行。
  • 补偿开始前,先把整体状态置为COMPENSATING。
  • 补偿每完成一步,更新一个补偿步骤记录,方便断点续操作。
  • 如果长时间卡在某个步骤,需要单独的恢复任务去扫描超时未完成实例,触发重试或告警。

状态机的核心是事件驱动。在这个设计里,每个步骤相当于一个状态节点,正向执行是Transition,抛出异常是进入补偿分支的Trigger。这样一整套流程下来,排查故障时只要看当前实例卡在哪个节点,就知道是哪一个服务出了问题,比纯代码走读高效太多。

5. 生产环境里绕不开的三个大坑:空补偿、幂等、悬挂

5.1 空补偿:补偿动作执行了,但正向操作根本没成功

这是最隐蔽的一个问题。以我的Demo为例,如果第三步balanceService.execute()抛异常是在本地事务提交之后抛出的,那么第三步的本地操作可能已经生效,需要补偿;但如果异常是在本地事务提交之前抛出的,第三步的操作根本没落库,就不应该补偿。而SagaOrchestrator里的completed计数器的逻辑是:第3步失败时fromIndex等于3,会从索引2开始补偿前两步,根本不会补偿第三步本身,所以反而碰巧避开了这个问题。

但如果你把补偿逻辑写在另外的独立方法里,比如步骤3失败时你执行了C3,而C3中以为账户已经扣款成功并做了退款,结果账户余额变成负数,数据就出错了。正确做法是:在C3补偿方法里先查询第三步是否产生了实际效果,只有确实扣款成功才执行退款,否则直接跳过。这个查证动作业内就叫"空补偿判断",核心原则是:每个补偿方法在执行前必须检查正向操作是否真的生效。

5.2 幂等:同一操作重复执行不能产生叠加效果

分布式环境里,超时重试、消息重复投递、补偿操作重复触发都可能导致同一操作被执行两次。比如库存扣减接口被调用了两次,库存就多减了;退款接口被调用两次,用户余额就多退了。解决办法很通用:所有正向接口和补偿接口都要支持幂等。

常见实现方案:

  • 利用数据库唯一约束:为每次业务操作生成唯一业务流水号,插入操作记录表时利用唯一索引防重。
  • 状态机校验:同一个订单只有在状态为"待支付"时才能扣库存,如果状态已经变成"已取消",说明此前已经补偿过,第二次补偿直接返回成功。
  • 防重表:记录每个请求的requestId,处理前先查表,已存在则直接返回上次结果。

从工程上讲,分布式事务的可靠性不取决于某一步执行多少次,而取决于执行结果是否一致。幂等是SAGA落地的绝对底线。

5.3 悬挂:补偿成功之后,迟到的正向操作又执行了

悬挂问题比幂等更反直觉。想象一个场景:正向操作因为网络超时被判定为失败,SAGA引擎执行了补偿操作,结果迟到的正向请求又到达服务端,把数据改回去了。这样补偿就白做了,数据回到错误状态。

我见过最典型的案例是:订单服务调用库存服务扣减库存,因RPC超时返回失败,SAGA进入补偿流程,把订单取消、恢复库存。但库存服务那边其实扣减请求已经到达并且成功落库,只是返回响应超时了。补偿触发后库存加回,过一会儿原本的扣减请求因为重试机制又到达,又扣了一次库存,最终库存比原值少一。

解决办法通常有两个层面:

  • 调度层面:允许重试,但重试请求必须携带全局唯一的请求标识;每个服务执行操作前先检查该请求标识是否已经在本服务执行过,如果执行过就直接返回。这就是"请求去重"。
  • 对账层面:即便做了请求去重,也无法100%避免极端场景,所以核心资金类业务需要离线对账任务,定期扫描订单状态与库存余额的偏差,发现悬挂就自动修复。

5.4 隔离性缺失的应对策略

SAGA最大的理论短板是没有隔离性。两个并发事务可能同时读到对方的中间状态。比如同一件商品,订单A扣减库存成功但还未确认完成,订单B也去扣减同一件商品库存,此时库存可能被扣成负数。

应对思路是按业务隔离,不追求全局隔离:

  • 语义锁:在业务数据上增加一个锁定字段,SAGA流程开始先设置锁,其他流程看到锁就等待或报错。
  • 可重读:补偿操作本身要能容忍读取到正在变动中的中间态,比如先比较当前值与预期值,不一致则说明有其他事务介入,需要重试或人工介入。
  • 预检:在正向操作中提前校验业务规则,比如库存预检、余额预检,把大部分冲突消灭在执行前。

这些手段虽然在分布式事务方案里不属于SAGA自身的机制,但没有它们,SAGA在生产环境根本无法落地。我自己在项目里是把幂等、空补偿、悬挂检测、隔离性兜底四件事合在一起称为"SAGA四件套",缺一件,线上迟早出事故。

6. 主流框架的落地方案与生产建议

6.1 Seata SAGA状态机模式

国内做Java分布式事务绕不开Seata。Seata的AT模式对业务无侵入,但本质上依赖数据库undo_log表和全局锁,性能瓶颈和2PC类似;TCC模式需要手写三套接口,代码量极大;SAGA模式则提供了完整的可视状态机定义,用JSON描述状态流转,适合流程灵活多变的业务。

用Seata SAGA时,你会在一个JSON文件里定义状态机,大致结构是:

{ "stateMachine": { "name": "OrderSaga", "startState": "CREATE_ORDER", "states": { "CREATE_ORDER": { "type": "ServiceTask", "serviceName": "orderService", "serviceMethod": "createOrder", "compensateState": "COMP_CREATE_ORDER", "next": "DEDUCT_STOCK" }, "DEDUCT_STOCK": { "type": "ServiceTask", "serviceName": "inventoryService", "serviceMethod": "deduct", "compensateState": "COMP_DEDUCT_STOCK", "next": "DEDUCT_BALANCE" }, "DEDUCT_BALANCE": { "type": "ServiceTask", "serviceName": "accountService", "serviceMethod": "deduct", "compensateState": "COMP_DEDUCT_BALANCE" } } } }

引擎会把每个ServiceTask的执行成功与否记录下来,一旦下游返回失败,自动沿compensateState链路逆向补偿。这个模式我在项目里用过,流程可视化很好,生产排障非常快。

6.2 自研引擎的几个必要模块

如果不想引入重量级中间件,自研SAGA引擎至少要具备以下模块:

  • 执行引擎:负责正向串联执行和异常时转向补偿。
  • 状态存储:用数据库表存储每笔SAGA实例的执行进度,支持断点恢复。
  • 超时调度:单独任务扫描超时的执行实例并触发重试或告警。
  • 幂等组件:统一生成和校验请求标识。
  • 监控大盘:展示每笔SAGA实例当前所在步骤、成功数、失败数、补偿数。

自研的好处是足够贴合自己的业务模型,坏处是要维护的东西很多,特别是对账模块。如果团队人少、业务刚起步,我还是建议先用Seata这类成熟框架,把精力放在业务补偿逻辑的完善上。

6.3 领域建模建议:补偿逻辑一定要内聚在服务内部

无论采用何种框架,我强烈建议:把"正向操作+补偿操作"封装在同一个领域服务内部,对外只暴露"执行"和"补偿"两个接口,不要让上层编排器操作底层DAO。这样,当数据库表结构变化时,只需要修改领域服务实现,编排器完全不用动。

更深一层,补偿操作不要简单地理解为"反向update",而是要理解成"对正向操作产生的业务结果做一次逆向业务操作"。比如"扣减库存"的补偿不是执行"delete from stock_log where ...",而是执行"add stock +1 并记录一条库存变动流水"。只有把补偿当作独立的业务动作去建模,才能避免很多低级错误。

7. 我的实践心得:从Demo到生产,SAGA最容易被低估的三个点

文章最后,分享几个我从实际项目里总结出的小经验,供各位参考。

第一,写好每个补偿方法的"业务日志"。很多团队写正向逻辑时会打日志,写补偿逻辑时却很潦草,出了问题根本不知道补偿动作执行到了哪一步、是否触发。建议补偿方法里至少记录:sagaId、补偿步骤名、正向操作产生的记录ID、补偿执行前后的数据快照、执行结果。排查问题时,这套日志比什么监控都好使。

第二,一定要把"空补偿判断"和"幂等"做成公共组件,而不是每个服务各自实现。数据一致性相关的基础能力最怕各家实现不统一。我所在的团队早期就是每个服务自己写幂等逻辑,结果线下联调频繁暴露问题;后来统一封装了一个annotation(注解),所有对外接口暴露前自动做防重校验,事故率骤降。

第三,SAGA的补偿链路测试必须模拟"每一步失败"的情况,而不是只测"最后一步失败"。真实的故障分布里,第一步、第二步失败也经常发生,而且它们触发的补偿范围不同,很容易在单元测试里被忽视。建议写一套自动化集成测试,覆盖:第1步失败、第2步失败、第3步失败、以及"失败后补偿操作本身也失败"四种情形。第三步这个"补偿又失败"的case,正式环境里真的会见到,必须提前有预案。

从我个人的经验看,SAGA能不能落地成功,七成取决于对补偿业务的理解,三成取决于框架选型。本文给出的Demo已经是最小的可运行骨架,你完全可以在此基础上扩展状态机版本、接入自己的业务逻辑。下一篇我打算写Seata SAGA状态机的详细实战配置,包含JSON状态文件如何和Spring Boot服务无缝衔接,有兴趣的可以持续关注。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询