
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失败 → 执行C3T3的补偿→ 执行C2T2的补偿→ 执行C1T1的补偿→ 全部补偿完成全局回滚。部分成功回滚路径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适用于并发量不大、业务对强一致性要求极高的场景比如金融核心账务、跨行转账等。缺点明显协调者单点、资源锁定时间长、参与者故障会导致阻塞。TCCTry-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和远程调用即可。流程定义下单接口被调用后依次执行以下三步创建订单CreateOrder——向订单表写入一条状态为待支付的订单记录。扣减库存DeductStock——把商品库存减1。扣减余额DeductBalance——从用户账户余额中扣减订单金额。补偿定义如果扣减余额失败恢复库存AddStock然后把订单状态置为已取消。如果扣减库存失败直接把订单状态置为已取消。整个流程中的状态我用一个枚举表示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 statusPENDING); // 真实实现: 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 ListSagaParticipant forwardSteps new ArrayList(); private final ListSagaParticipant 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 statusPENDING 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服务无缝衔接有兴趣的可以持续关注。