尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Saga分布式事务模式详解:从补偿机制到Seata落地实践

Saga分布式事务模式详解:从补偿机制到Seata落地实践 现在开始创作。1. Saga模式到底在解决什么问题先聊一个场景你在一个电商系统里用户下单之后后端要依次扣减库存、创建订单、扣减用户余额最后通知物流系统揽收。早期单体架构里这些操作都在同一个数据库事务里一个BEGIN TRANSACTION包住任何一步失败就直接ROLLBACK一致性完全不用操心。但到了微服务架构下订单服务、库存服务、账户服务、物流服务各自独立部署、独立数据库原来那个“万能”的本地事务被拆散了问题立刻冒出来库存扣成功了但订单创建失败怎么办很多人第一反应是“分布式事务”但分布式事务不是单一技术而是一族方案的统称。业界常说的2PC两阶段提交、TCCTry-Confirm-Cancel、Saga、本地消息表、事务消息它们解决的问题类似但适用场景和代价完全不同。Saga 是其中非常特别的一个它不追求“任何时刻都强一致”而是通过一系列本地事务加补偿操作最终把系统收敛到一致状态。这个思路对微服务场景特别友好。Saga 这个概念的源头是 1987 年普林斯顿大学一篇数据库论文比微服务这个词早出现几十年。论文的核心思想很简单把一个长事务拆成一组有序的本地子事务每个子事务都有对应的补偿操作。如果从第 n 步开始失败就依次执行第 n-1、第 n-2……步的补偿把所有已生效的操作回滚到起点。注意这里不是“撤销”而是“补偿”两者的差别我会在后面详细讲。那这个模式适合谁如果你在做电商订单、支付清结算、供应链调度、旅行预订这类业务涉及多个服务间的数据联动又不想引入重量级的分布式事务中间件Saga 大概率是值得研究的方案。它不需要数据库层面做任何特殊改造不需要全局锁不需要长时间持有数据库连接对业务侵入相对可控这也是它成为微服务架构下分布式事务主流方案的核心原因。2. Saga的两种实现方式编排式与协同式2.1 编排式Orchestration中央控制器统一指挥编排式 Saga 的核心是引入一个“指挥者”。这个指挥官知道整个业务流程要经过哪些服务、每一步执行什么、失败后该调谁的补偿接口。流程执行时指挥官依次调用各个服务的接口根据返回结果决定是继续下一步还是触发补偿链条。用订单加库存的场景举例OrderService 收到下单请求后先调用 StockService 扣库存再调用 AccountService 扣余额最后调用 OrderService 自己更新订单状态。如果扣余额失败指挥官会先触发库存补偿加回库存再终止整个流程。这里的“指挥官”可以是一个独立的服务比如 SagaOrchestrator也可以直接嵌在流程发起方里。编排式的优势是流程清晰所有分支、补偿关系都集中在一个地方对开发者来说“可读性极强”出问题容易跟踪。但劣势也很明显指挥者容易变成流程瓶颈和单点而且如果业务流程特别长指挥者代码会越来越臃肿。此外指挥者本身的可靠性也要考虑需要持久化状态、支持恢复。2.2 协同式Choreography事件驱动自动接力协同式 Saga 没有中央指挥官。每个服务执行完自己的本地事务后向消息队列发布一个领域事件下一个服务监听该事件并触发自己的操作依次接力。每一步都可能发布新事件直到整个流程完成。如果某一步失败就发布反向事件由相关服务执行补偿。订单加库存的协同式流程大概是OrderService 创建“待支付订单”后发出OrderCreated事件StockService 监听到事件后扣减库存发出StockDeducted事件AccountService 看到库存已扣执行扣款发出BalanceCharged事件最后 OrderService 收到扣款成功事件将订单状态置为“已支付”。协同式的最大好处是服务之间零直接调用关系扩展性非常强新增一个参与方只需要监听现有事件并发布自己的事件即可。坏处也藏在“去中心化”里流程隐性地散落在各个服务和多个事件中出了问题排查链路很长而且很容易出现“事件风暴”——多个服务同时发布事件谁先谁后、重复消费、消息乱序每一项都是坑。我个人的经验是超过三个服务参与的流程协同式 Saga 维护成本会急剧上升。下面用表格快速对比两者对比维度编排式 Saga协同式 Saga流程控制集中在中央控制器分散在各服务事件中可读性高一个地方看完整流程低需要跨服务追踪事件链耦合度服务间无感知与控制器交互服务间通过事件间接耦合扩展性需要改控制器只需新增监听与事件故障排查相对容易困难消息链路长适用规模3~6 个服务的业务链2~3 个服务的简单链路判断用哪种我建议先看团队规模和系统复杂度。早期项目、流程不超过五步直接编排式省心。服务数量多、团队分工边界分明、想减少服务间直接依赖协同式可以试但前提是你们已经熟练使用消息中间件并配好了可靠的事件追踪机制。3. Saga与XA、TCC等方案的横向对比3.1 为什么不用两阶段提交XAXA 是数据库层面的一套分布式事务规范核心思想是“全局事务管理器”协调多个资源管理器数据库先让所有参与者执行 prepare全部成功后统一 commit只要有一个失败就全部 rollback。看起来非常完美但它有一个致命代价二阶段提交期间资源要被全局锁锁定而锁的持有时间取决于最慢的那个参与者。微服务架构里一个跨服务的分布式事务往往要经过网络调用、业务处理、消息发送等环节慢的可能几十秒甚至几分钟。数据库全局锁持有这么久高并发下一准把系统拖死。另外 XA 要求所有参与数据库支持 XA 协议很多 NoSQL 和消息中间件并不支持落地场景受限。所以现在除了银行、支付等对一致性要求极高且并发可控的场景微服务项目里很少直接裸用 XA。3.2 TCC和Saga的取舍TCC 把每个服务操作拆成 Try预留资源、Confirm确认执行、Cancel取消释放三个阶段。它的优点是隔离性好Try 阶段就锁定了资源可以避免超卖这类问题。缺点是业务侵入极大每个参与方都要把接口改造成三个方法而且要做好幂等和防悬挂处理实现成本是几个方案里最高的。Saga 的补偿模式就没有这种预留资源的机制。每个参与方只实现一个正向操作和一个反向补偿操作业务代码更贴近正常逻辑唯一的额外工作是要把已经生效的业务改动想办法“抵消掉”。如果拿做饭类比TCC 是“先订好餐厅座位再出门”Saga 是“先到先吃如果吃不了就取消订单、退掉食材、销毁半成品”。TCC 更安全但仪式感重Saga 更灵活但需要处处考虑“收拾残局”的能力。3.3 Saga与消息事务、本地消息表的关系很多人容易把 Saga 和“事务消息”“本地消息表”混在一起。它们其实是不同层次的东西。事务消息比如 RocketMQ 的事务消息解决的是“本地数据库操作与消息发送的一致性”保证消息一定能发出去但消息发出之后下游如何处理并不负责。Saga 更关注的是“多服务共完成一个业务动作且任意失败都能最终收敛”。实际项目里两者可以配合使用Saga 里每个子事务通过本地事务把业务数据和待发送消息一起提交再由消息中间件把事件可靠投递给下一个参与者。这是比较常见的工程实践。4. 订单与库存场景的完整Saga落地4.1 业务流程与状态机设计我拿一个实际的“订单 库存 支付”链路来演示。用户下单购买一件商品业务流程分四步创建订单状态置为“待支付”扣减库存记录库存流水扣减用户余额记录余额流水订单状态更新为“已支付”对应的补偿操作分别是订单不存在“创建”的补偿直接取消订单置为“已取消”库存扣减的补偿是“加回库存记录冲正流水”余额扣减的补偿是“退回余额记录冲正流水”订单已支付状态不需要补偿因为它是终态基于上面的分析我建议直接用一个状态机来定义 Saga 的运转逻辑。每个参与者对应一个状态状态有PENDING、SUCCEEDED、COMPENSATED三种。所有参与者都SUCCEEDED整个 Saga 成功任何一步失败进入补偿流程把已完成步骤标记为COMPENSATED。这里有一个关键点Saga 的补偿操作必须与正向操作一一对应否则会出现漏补偿。比如扣减库存的同时如果还记录了“冻结库存”那补偿时就要“解冻”而不只是“加回库存”必须严格按业务语义来不能笼统地“反向操作”。4.2 编排式Saga的核心代码实现使用编排式实现时我通常会把 Saga 的执行流程抽象成几个简单的类。下面用 Java 伪代码做演示重点看结构不纠结具体框架。// 定义参与者接口 public interface SagaParticipant { void execute(SagaContext context); // 执行本地事务 void compensate(SagaContext context); // 执行补偿操作 } // 定义流程节点 public class SagaStep { private SagaParticipant participant; private boolean compensating; // 当前是否处于补偿阶段 // getter / setter ... } // 核心编排器 public class SagaOrchestrator { private ListSagaStep steps new ArrayList(); private DequeSagaStep executedSteps new ArrayDeque(); public void addStep(SagaParticipant participant) { steps.add(new SagaStep(participant)); } public void execute(SagaContext context) { for (SagaStep step : steps) { try { step.getParticipant().execute(context); executedSteps.push(step); } catch (Exception e) { // 执行失败进入补偿阶段 compensateAll(); // 这里要标记整个 Saga 为失败并抛出业务异常 throw new SagaExecutionException(Saga execution failed, e); } } } private void compensateAll() { while (!executedSteps.isEmpty()) { SagaStep step executedSteps.pop(); try { step.getParticipant().compensate(context); } catch (Exception ce) { // 补偿失败的情况需要特殊处理 // 实际生产环境要把补偿失败的信息持久化并触发后续重试 log.error(补偿失败需人工介入或重试, ce); } } } }实际的工程实现会比这段复杂很多至少还要考虑流程上下文在各个参与者之间传递、幂等控制、补偿逻辑的重试和告警、Saga 执行记录入库。但核心骨架就是上面这段理解它就能掌握 Saga 的中心思想。扣减库存的服务核心逻辑大致是这样Service public class StockService implements SagaParticipant { Transactional Override public void execute(SagaContext context) { Long productId context.get(productId); Integer count context.get(count); // 扣减库存 stockMapper.deduct(productId, count); // 记录库存流水用于补偿 stockFlowMapper.insert(StockFlow.negate(productId, count, SAGA_DEDUCT)); } Transactional Override public void compensate(SagaContext context) { Long productId context.get(productId); Integer count context.get(count); // 注意这里必须做幂等控制防止重复补偿 if (stockFlowMapper.existNegateFlow(productId, SAGA_DEDUCT)) { stockMapper.increase(productId, count); stockFlowMapper.insert(StockFlow.positive(productId, count, SAGA_COMPENSATE)); } } }注意compensate方法里的幂等判断如果补偿操作被重复触发只有第一次真正执行后续直接忽略。因为补偿流程中可能因为网络超时、消息重试等原因导致同一个补偿操作被调用多次不做幂等保护就会出现“库存加了两次”的问题。4.3 事务边界的确定原则很多刚接触 Saga 的开发者会把“细粒度子事务”和“业务操作”搞混。比如“扣减库存”这个子事务内部是否要把“库存不足检查”“库存数量扣减”“库存流水写入”拆成三个子事务我不建议拆。Saga 子事务的边界应该是一个“具有完整业务意义且能够独立提交的本地事务”。库存的三件事本来就该在同一个本地事务里完成原子性地成功或失败。Saga 管的是跨服务的流程编排不是把本地事务再碎化。如果子事务拆分过细Saga 流程会变得冗长补偿链路也会指数级增加排查困难和性能损耗都不划算。4.4 状态机与持久化Saga 编排器不能是“无状态”的否则进程重启后流程状态就丢了。生产级别至少要有一张 saga 实例表记录当前流程走到哪一步每个参与者的执行状态和补偿状态。这张表的存在为后续的问题排查和补偿重试提供了依据。如果使用状态机框架比如 Spring Statemachine来实现可以将每个参与者的状态流转展示得很清晰INIT - ORDER_CREATED - STOCK_DEDUCTED - BALANCE_CHARGED - ORDER_PAID (成功终态) INIT - ORDER_CREATED - STOCK_DEDUCTED - BALANCE_CHARGED_FAILED - STOCK_COMPENSATED - ORDER_CANCELLED (失败终态)有了这样的状态定义不仅写代码更清晰排查问题时也能直接从状态机图上看出流程卡在哪个环节。5. 落地过程中最常见的坑与排查经验5.1 补偿操作的幂等性前面提过一次幂等但这里必须再强调因为它是最容易出事的点。生产环境里消息重复投递、接口重试机制、网络抖动引发的重放都会导致同一个补偿操作被调用多次。解决办法有几个层次数据库层面做唯一约束比如补偿流水表有(business_no, compensate_type)的唯一索引重复插入直接报错被吞掉使用状态字段控制比如补偿前先查询是否已经处于“已补偿”状态采用 Redis 分布式锁或去重表保证同一个 SagaId 的补偿操作只执行一次我见过不止一次因为忽略幂等导致库存数量被多次加回的事故最终只能靠人工手动校准数据。这种问题在开发环境很难复现因为测试基本没有并发和重试但一上线就爆发。5.2 Saga毕竟不是真正的事务Saga 有个天然的短板它不具备数据库事务那样的隔离性。一个 Saga 执行过程中如果其他请求并发读取中间数据会看到尚未最终收敛的中间状态。这个问题处理不好就会出现“订单已支付但库存还没扣完”的中间状态被其他业务读到。缓解手段包括所有中间状态对用户不可见订单状态保持“处理中”对关键资源加乐观锁或版本号对于确实需要强隔离的场景比如防止库存超卖可以在本地事务里用行锁或select for update来保护临界资源。用设计模式上的一句话来说Saga 追求的是最终一致性任何试图把 Saga 当成强事务用的思路都会在实际运行中碰壁。5.3 隔离级别与业务形态的匹配严格按照数据库理论Saga 属于长事务最常见的问题是“脏读”。为了避免脏读导致业务异常最直接的办法是每个参与方都增加状态机流转不让其他业务直接读取中间状态。比如订单服务先创建PENDING状态支付完成后再更新为PAID所有对下游的接口展示都以PAID为准。如果系统并发量较高还要处理“补偿操作与正常业务同时变更同一资源”的冲突。比如库存扣减正在进行补偿操作想加回库存最后哪一边生效答案是必须通过数据库行锁或 CAS 来保证原子性不能让两个操作同时修改库存数量。5.4 补偿失败怎么处理补偿操作本身也可能失败比如下游服务宕机、数据库连接异常。这种情况下 Saga 流程就卡在“半补偿”状态。工程上必须有一套“补偿重试任务”定时扫描补偿失败的记录按照退避策略比如 1 分钟、5 分钟、30 分钟持续重试。重试次数达到上限仍然失败就要触发告警转人工处理。注意补偿操作必须写成“可重试”的绝不能出现类似“执行一次后永久失效”的逻辑。另一个容易忽略的问题是补偿操作耗时长时用户已经收到了“下单失败”的提示但系统后台仍在执行补偿。这种异步补偿的体验反而更好但如果用户又发起了同样的下单请求就必须通过全局幂等号比如 businessNo做防重避免生成两条订单。5.5 排查实录一次“库存被加回两次”的线上问题这里记录一个我实际踩过的坑非常典型。系统上线后监控发现某个 SKU 的库存数量异常偏高追查之后发现是 Saga 补偿链路里库存服务既实现了compensate接口又在消息队列 Consumer 里监听了一个“库存冲正”事件。结果补偿流程触发时compensate接口加了一次库存消息 Consumer 也消费到了冲正事件又加了一次库存。排查过程很简单先用全局唯一的业务流水号sagaId stepId在库存流水表里查操作记录发现同一个补偿操作产生了两次正向流水。然后检查代码发现补偿动作确实存在双通道触发。解决办法是把补偿逻辑收敛成单一入口统一走一个带唯一约束的补偿服务。所以有一个铁律同一个业务动作只能有一条执行路径。编排式 Saga 尤其要防止“接口调用 消息监听”同时触发同一个补偿逻辑。6. Seata SAGA的实现原理与配置要点国内做分布式事务绕不开 Seata。Seata 社区主推 AT、TCC、Saga、XA 四种模式。其中 AT 模式对业务代码侵入最小靠数据源代理自动生成 undo_log 来回滚业务数据变更但它对 SQL 有约束且依赖全局锁并发高时有性能损耗。Saga 模式则是把业务编排和补偿逻辑交给用户自定义不走全局锁适合长流程、对性能敏感的场景。Seata 的 Saga 实现基于“状态机 流程引擎”支持 JSON/JSONPath 定义流程。核心组件包括StateMachineEngine负责启动、驱动、恢复状态机StateLang基于 JSON 的流程定义语言SagaResourceRegister服务参与者注册一个最简的流程定义大致如下{ Name: orderStockPaySaga, Start: { Type: ServiceTask, ServiceName: orderService, ServiceMethod: createOrder, Next: deductStock }, SagaStates: { deductStock: { Type: ServiceTask, ServiceName: stockService, ServiceMethod: deduct, CompensateType: ServiceTask, CompensateServiceName: stockService, CompensateServiceMethod: compensateDeduct, Next: chargeBalance }, chargeBalance: { Type: ServiceTask, ServiceName: accountService, ServiceMethod: charge, CompensateType: ServiceTask, CompensateServiceName: accountService, CompensateServiceMethod: compensateCharge, Next: markPaid }, markPaid: { Type: ServiceTask, ServiceName: orderService, ServiceMethod: markPaid, End: true } } }跑起来之后Seata 的 Saga 引擎会按节点顺序执行任一步失败自动跳到已执行步骤的Compensate节点执行补偿。这个 DSL 的优势是流程定义与代码分离流程变更不用重新发版对运维友好。Seata Saga 有几个需要注意的点参与者的接口实现里不能直接操作全局事务上下文需要通过GlobalTransactional注解或业务参数的透传链路处理状态机定义里每个方法的入参和返回值要与实际接口签名一致否则引擎无法正确绑定数据默认的补偿执行是串行的可以通过配置调整并发补偿对无状态服务支持良好但服务端需要高可用部署否则状态机引擎挂了整个流程就断了配置方面Seata Server 端需要开启 Saga 相关的支持客户端需要把seata.useSaga打开事务分组名称要与 Server 端一致。整体用起来不会太难但建议先拿两个服务的简化流程做验证不要在项目刚起步就引入完整的多级 Saga。7. 什么场景才值得上Saga写了这么多最后给一个更实际的问题你的项目到底要不要用 Saga我的回答是能不用就不用的情况居多。如果你可以接受把一个大操作拆成几个独立的小操作并且业务上允许中间状态短暂存在甚至最终由对账任务补偿那么本地事务加定时对账就够了不需要 Saga。比如数据报表同步、缓存更新、日志采集这类非核心链路错误处理可以靠重试和幂等解决。如果业务要求严格的“同生共死”比如账户开户时同时创建默认安全设置、默认套餐那里边数据库支持分布式事务或同一个库时就优先用本地事务解决不要让架构复杂度上升。真正适合 Saga 的场景有这几个特征业务流程跨多个微服务且数据分布在不同数据库单个子事务的执行时间在几十毫秒到几秒之间不适合用 2PC 长锁业务允许短暂的数据不一致只要能通过补偿最终收敛团队能承担补偿逻辑、幂等处理、状态机维护的复杂度以一个真实项目为例我们用 Saga 重构过一个“订单 优惠券 积分 库存”的结算链路。改造前的方案是“前端串行调用多个接口失败靠前端提示重试”用户体验差且失败率高。改造后用编排式 Saga一条链路在 2 秒内跑完失败自动补偿用户无感知。但在这个项目里我们也确实要额外维护十几个补偿方法、一张 Saga 实例表、一套补偿重试定时任务架构成本不容小觑。最后说句掏心窝的话Saga 不是什么银弹它是众多权衡中的一种选择。我最看重的不是它那群补偿方法怎么写而是团队需要理解“最终一致”这四个字的重量——一旦线上出了数据不一致你再也不能用“回滚数据库”这种粗暴方式解决问题你只能靠设计良好的补偿机制和可靠的监控告警把人拉回来。个人体会是Saga 项目上线前一定要做一次故障注入演练。把每个参与者的接口都模拟一遍超时、异常、宕机看看补偿链路到底能不能把所有状态归位。别等线上真出事了才去拿生产数据试错。
返回列表