分布式事务从原理到落地:五大方案对比与选型指南 上周有个同事跑来问我订单服务和库存服务拆开之后用户下单成功订单状态显示已支付库存却扣了两次数据库事务到底还能不能保证一致性这个问题背后牵扯出来的东西恰恰就是分布式事务的核心。我在订单、支付、库存这条链路里摸爬滚打了几年主流方案基本都实践过一遍今天就想用大白话把分布式事务这件事讲透——它到底解决什么问题、有多少种解法、每种解法适合什么场景、落地时有哪些坑。这篇文章不求覆盖所有理论细节重点放在能落地三个字上。适合正在做微服务拆分、或者刚接手订单类系统的同学哪怕你对分布式事务只有模糊概念读完也能建立一张完整的认知地图。1. 不是事务不行了是事务的边界变了1.1 本地事务为什么能保证一致性在单体应用时代用户下单、扣库存、生成订单详情这些操作都在同一个数据库里完成。一个事务就能把插入订单 扣减库存 更新用户余额三个操作包起来要么全部成功要么全部回滚。这个机制依赖数据库的 ACID 特性原子性保证操作不可分割一致性保证数据约束不被破坏隔离性保证并发事务互不干扰持久性保证提交后数据不丢。数据库底层靠的是行级锁、undo log、redo log 和事务提交协议这套东西已经非常成熟你基本不需要自己操心。但这里有个隐含前提所有参与事务的数据必须落在同一个数据库实例上。一旦把订单表和库存表拆到两个库甚至拆成两个独立服务本地事务的边界就断了——第一个库里的事务提交成功第二个库里的事务提交失败没有任何一个数据库实例能感知全貌回滚动作自然也无从谈起。1.2 服务拆分后原子性为什么失效了拆服务之后一次下单操作被拆成三个远程调用订单服务创建订单库存服务扣减库存如果涉及优惠券还要调用券服务标记已使用每一个远程调用都有网络延迟和失败概率。最常见的问题是订单创建成功但调用库存服务时网络超时库存服务实际扣了库存可订单服务这边收到的是异常直接选择回滚。结果就是订单没了库存少了两边数据对不上。这个场景就是分布式事务要解决的跨服务、跨数据库的一致性问题。它是微服务拆分带来的副产品——你享受了独立部署、独立扩容的好处就得接受分布式事务这个成本。我见过不少团队在拆分之初没认真对待这个成本等到线上出现超卖或者扣款不到账才回头补课。补课的代价比一开始设计要高出好几倍。所以说分布式事务不是一个听着高级的技术而是一个你早晚要面对的工程问题。2. 一致性不是绝对一致而是你能接受多大偏差2.1 CAP的取舍比想象中更严格聊分布式事务绕不开 CAP 理论。它说的是在分布式系统中一致性Consistency、可用性Availability、分区容错性Partition tolerance三者最多同时满足两个。很多人把 CAP 理解成三选二其实不够准确。分区网络分裂是分布式系统中的必然事件不是可选项所以 P 是必须有的。真正能选的是当网络发生分区时你要保 C 还是保 A。保 C牺牲可用性比如两阶段提交分布式事务执行期间相关资源全部锁定宁可系统短暂不可用也要保证数据绝对一致。保 A牺牲一致性比如各服务自己提交先返回用户成功之后通过补偿机制慢慢对齐这就是最终一致性的思想。这里有个关键认知强一致性不等于正确它只是立即正确。最终一致性也不等于错误它只是最终正确但中间会有一个时间窗口数据处于中间态。很多业务场景其实接受不了强一致性的代价。拿下单来说扣库存这种事如果非得等所有服务同步提交用户点击下单可能要卡几百毫秒甚至更久这在高并发场景下根本扛不住。所以现实世界的做法往往是核心链路尽量强一致非核心链路接受最终一致。2.2 BASE理论最终一致性不等于放弃一致性BASE 是 Basically Available基本可用、Soft state软状态、Eventually consistent最终一致的缩写。它是对 CAP 中 AP 路线的实践总结。基本可用意思是系统出现故障时允许降级比如下单高峰时段把查看历史订单功能切成只读缓存但核心支付流程不能挂。软状态意思是允许数据在某个时间窗口不一致比如订单已生成、库存还没扣成功这个中间状态是合理且必须容忍的。最终一致意思是只要系统持续运行经过重试、补偿、对账所有副本最终会收敛到一致。理解 BASE 的关键是明白它没有放弃一致性——它只是放宽了一致性的时间约束把提交那一刻必须一致改成在一段时间内自动达到一致。而这个一段时间就是分布式事务方案设计时的核心参数。你允许系统在多少秒内达到一致直接决定了你会选哪套方案。3. 四大主流方案从强一致到最终一致分别适合什么场景3.1 两阶段提交2PC协调者视角的全局锁2PC 是最经典的强一致性方案Google 的 Spanner 和传统的 XA 协议都是这个思路。核心角色是一个协调者Coordinator和若干参与者Participant流程分两阶段。第一阶段叫准备阶段Prepare协调者向所有参与者发送准备请求参与者执行事务操作写入 undo/redo 日志但不提交然后回复我可以提交或我要回滚。第二阶段叫提交阶段Commit协调者收集所有参与者的回复如果全部同意就广播提交指令任何一个参与者拒绝就广播回滚指令。这套机制的问题是同步阻塞和协调者单点。同步阻塞参与者持有资源锁期间其他事务无法访问这些数据。如果某个参与者执行得很慢整个分布式事务就卡住系统吞吐量被这个最慢的节点拖死。协调者单点如果协调者在广播提交指令前宕机所有参与者既不知道提交还是回滚又不敢释放锁数据库连接池很快被占满形成雪崩。我在实际项目中很少看到团队自研 2PC更多是依赖 XA 协议的数据库中间件。但它有一个致命短板事务执行时间长锁竞争激烈高并发订单场景根本用不起。所以 2PC 更适合银行转账、金额结算这类并发量低、但对一致性要求极高的场景。3.2 三阶段提交3PC与它的改进逻辑3PC 是 2PC 的改良版核心改进是把 2PC 的准备阶段拆成了 CanCommit询问是否可以提交和 PreCommit预提交两个阶段同时引入超时机制参与者不再无限期等待协调者指令。优点很明显降低了阻塞范围参与者可以在超时后主动释放资源协调者挂掉的影响被缩小。但代价是引入了新的不一致窗口——如果网络分区发生可能有的参与者收到了提交指令有的没收到两边执行结果不一样。说实话3PC 在理论界讨论得多在工程界用得少。它治好了 2PC 的阻塞病但引入了新的脑裂病并没有本质上的优势。所以我在选型的时候基本不会考虑它提到它只是为了让你知道业界不是没尝试过改良 2PC只是这条路没有彻底走通。3.3 TCC把锁换成业务补偿TCC 是 Try、Confirm、Cancel 三个词的缩写。它把一笔分布式事务拆成三个阶段由业务方自己定义每一阶段的行为框架只负责协调调用。Try 阶段冻结资源但不真正扣减。比如下单时先冻结库存 1 件冻结优惠券 1 张。此时用户还没真的下单成功但资源已经被预留。Confirm 阶段冻结成功后执行真正的业务逻辑比如把冻结的库存扣减掉把订单状态改成已确认。Cancel 阶段如果某个环节失败把 Try 阶段冻结的资源全部释放。TCC 的最大优势是把资源锁粒度从数据库行锁降到了业务状态。Try 阶段不持有数据库锁只是标记这 1 件库存被预占了其他事务还能读取库存总量并发能力比 2PC 高得多。但 TCC 对业务侵入极强每个参与方都得实现三套逻辑Try、Confirm、Cancel。而且 Cancel 逻辑本身要正确否则资源解冻失败库存永久性减少。我见过不少团队 TCC 落地的失败案例问题往往出在 Cancel 逻辑和业务状态机冲突上——比如订单已经取消但 Cancel 又被重复调用导致库存被释放两次。Seata 是 Java 生态里最常用的 TCC 框架之一。它支持 AT 模式自动补偿框架通过解析 SQL 生成反向 SQL 实现回滚和 TCC 模式业务方手动实现。AT 模式上手快但性能开销比 TCC 更大因为框架要额外记录数据前后镜像。我的经验是对性能敏感的核心链路用 TCC 模式对开发速度要求高的内部系统用 AT 模式。3.4 SAGA长流程切短每一步都有后悔药SAGA 是另一种补偿思路最早来自 1987 年的一篇数据库论文。它的核心是把一个长事务拆成一系列有序的本地子事务每个子事务都有一个对应的补偿事务。如果某个子事务失败就逐个执行前面所有子事务的补偿动作回滚到初始状态。一个典型的 SAGA 调用链长这样订单服务创建订单支付服务完成扣款库存服务扣减库存物流服务创建配送单如果第 4 步失败就依次执行第 3 步的库存回补、第 2 步的退款、第 1 步的取消订单。SAGA 有两种编排方式编排式Orchestration一个中心协调者负责按顺序调用各参与者清晰可控但协调者本身成为瓶颈。协同式Choreography各服务通过事件消息互相驱动比如订单服务发订单已创建事件支付服务监听后扣款再发已支付事件。这种方式去中心化但调用链像蜘蛛网一样难以追踪。我在订单系统里更推荐编排式。业务链路越长一个中心化的流程管理器越有价值——你在支线故障时可以暂停整个流程可以重跑某个失败的子事务这些操作在协同式里都繁琐得多。SAGA 和 TCC 最大的区别是TCC 在 Try 阶段预留资源Cancel 阶段释放资源SAGA 没有预留概念直接执行真实业务操作失败后靠补偿事务反悔。这也导致 SAGA 的补偿动作必须足够健壮因为它面对的是已经真实发生的数据变更。3.5 本地消息表 事务消息用时间差换一致性这是一类非常务实的方案核心思想是把跨服务的分布式事务降级为本地事务 消息可靠投递。本地消息表的做法是在业务数据库中建一张消息表业务操作和消息写入放在同一个本地事务里。订单服务创建订单的同时往消息表插入一条扣减库存的消息。事务提交后一个异步任务轮询消息表把消息发送到 MQ库存服务消费后扣库存再回写消息状态。这套方案的好处是依赖最少只要一个数据库和一台 MQ 就能实现。缺点是轮询有延迟消息表会膨胀需要定期清理而且业务方要自己处理消息发送失败消费失败重试等问题。事务消息是 RocketMQ 提供的原生能力把上面这套逻辑做成了中间件。流程是发送 half 消息半消息到 RocketMQ本地事务执行事务执行成功后向 MQ 发送 commit失败则发送 rollbackMQ 如果长时间没收到 commit/rollback会反查事务状态这套方案最大的价值是解耦。主服务不需要关心消费方是否成功只需要保证消息一定能到 MQ。消费方自己保证幂等失败就重试。4. 方案选型别再一招鲜吃遍天4.1 先判断你需不需要分布式事务这是我最想强调的一点。很多团队一上来就纠结用 TCC 还是 SAGA其实第一步应该问这个场景真的需要分布式事务吗有几种情况其实可以绕开数据不需要实时一致比如用户修改昵称个人中心和订单中心各存了一份允许 10 秒内不一致这种直接用 MQ 异步同步即可。操作可以串行化最终一致性靠下游必成功和幂等重试兜底省掉复杂的回滚逻辑。把写操作收敛到同一个服务比如所有库存变更都走库存服务暴露的接口订单服务不直接操作库存表这样至少数据库层面没有跨库问题。分布式事务是成本最高的一致性方案能不用就不用。它的复杂度不会消失只会转移要么在编码时以 TCC 的三段式逻辑呈现要么在运维时以各种补偿脚本呈现。拿我负责的订单系统举例我们最终只在用户下单 扣库存 扣优惠券这条核心链路上用了分布式事务像订单同步到搜索服务发送通知短信这类操作全部走异步消息 重试没有任何事务语义。4.2 各方案横向对比为了让你一目了然我整理了一张对比表方案一致性强度性能影响业务侵入复杂度典型场景2PC强一致大资源锁低数据库级别中跨行转账、低并发资金操作TCC强一致中无锁高三段式逻辑高订单冻结库存、支付预扣款SAGA最终一致小中补偿逻辑中长流程业务、多系统调用链本地消息表最终一致小低低内部系统数据同步事务消息最终一致小低低订单状态变更通知注意TCC 和 SAGA 虽然都算强一致或最终一致但它们的差异更多体现在业务语义上。TCC 适合资源操作型业务扣库存、扣余额SAGA 适合流程推进型业务创建订单、发货、签收。前者关注预留与释放后者关注推进与回退。4.3 混合架构才是正常状态严格来说一个复杂系统里往往不是只有一种方案。我现在的订单系统就是三套方案并存下单主链路的扣库存、扣券用 TCC支付回调后的订单状态流转用 SAGA订单完成后的数据同步到数仓和搜索用事务消息每套方案解决自己对应场景的问题而不是尝试用一方案打天下。这种混合架构才是生产级的常态。5. 订单-库存-优惠券链路的完整设计实例5.1 场景与业务约束假设你现在要设计一个下单接口涉及三个服务订单服务、库存服务、优惠券服务。业务逻辑是用户从购物车提交订单订单服务创建待支付订单库存服务冻结对应商品库存优惠券服务标记用户优惠券为已使用约束条件有三个不能超卖库存冻结数不能超过真实库存、优惠券不能重复使用、用户取消订单后所有占用资源要释放。5.2 方案设计TCC 事务消息的组合拳核心链路我们选择 TCC 方案。三个服务按如下方式实现各阶段逻辑Try 阶段订单服务插入一条状态为 TRYING 的订单记录库存服务执行 UPDATE inventory SET frozen_qty frozen_qty 1 WHERE id ? AND qty - frozen_qty 1如果影响行数为 0说明库存不足抛出失败异常优惠券服务执行 UPDATE coupon SET status LOCKED WHERE id ? AND status UNUSED同样通过条件更新保证不重复使用Confirm 阶段订单服务把订单状态从 TRYING 更新为 CONFIRMED库存服务执行 UPDATE inventory SET qty qty - 1, frozen_qty frozen_qty - 1把冻结转成真实扣减优惠券服务把优惠券状态从 LOCKED 更新为 USEDCancel 阶段订单服务把订单状态标记为 CANCELLED库存服务执行 UPDATE inventory SET frozen_qty frozen_qty - 1回补冻结优惠券服务把优惠券状态从 LOCKED 更新为 UNUSED这三个阶段都由 TCC 框架统一调度任何一个 Confirm 失败框架会自动触发所有参与方的 Cancel。但这里有个容易被忽略的问题Confirm 阶段也可能因为网络异常而被重复调用。所以每个参与方的 Confirm 逻辑必须天然幂等。比如库存服务 Confirm 的 SQL 改成UPDATE inventory SET qty qty - 1, frozen_qty frozen_qty - 1 WHERE id ? AND frozen_qty 1这样即使 Confirm 被调用两次第二次因为 frozen_qty 已经不满足条件影响行数为 0不会再次扣减。链路执行完之后把订单已创建事件发送到 MQ通知下游服务比如搜索服务、推荐服务做异步数据同步这部分就用事务消息。5.3 幂等、重试与对账方案落地三件套做完上面的设计只完成了 60% 的工作。剩下的 40% 在于三个细节幂等、重试、对账。幂等的核心是每个参与者都要有全局唯一的事务 ID。TCC 框架会把这个事务 ID 透传给所有参与方参与方在 Try/Confirm/Cancel 至少执行一次的前提下保证执行结果一致。最简单的方式是建一张 t_transaction_record 表以事务 ID 为主键每次执行前先插入插入成功才执行对应动作INSERT INTO t_transaction_record (tx_id, service_name, phase, status) VALUES (?, ?, TRY, SUCCESS) ON DUPLICATE KEY UPDATE status status;重试就靠这个记录表确认某个服务 Confirm 失败后由 TCC 框架按指数退避策略重试比如第 1 次 1 秒后重试、第 2 次 2 秒后、第 3 次 4 秒后最多重试 5 次。重试期间要保证请求幂等前面的事务记录表就是干这个用的。对账则是最后的兜底。即使 TCC 框架和重试逻辑都正常仍然可能出现脏数据——比如某次 Cancel 逻辑本身抛了异常导致库存冻结数一直挂着。所以我们每天凌晨跑一个对账任务对比订单状态、库存冻结数、优惠券状态找出不一致的记录自动生成补偿工单。对账这个步骤不能省它是所有一致性方案的最后防线。6. 生产环境里的真实教训那些文档上不写的事6.1 空回滚和悬挂比超时更隐蔽TCC 落地时最容易踩的两个坑是空回滚和悬挂。空回滚指的是Try 阶段没执行成功比如网络超时但 Cancel 阶段被调用。如果 Cancel 里直接执行回滚逻辑可能把根本不存在的冻结记录硬生生扣掉造成数据错乱。解决办法是Cancel 执行前先查本地事务记录表确认 Try 已经成功过才执行回滚如果 Try 还没执行记一条跳过标记等 Try 乱序到达时直接拒绝。悬挂指的是由于网络乱序Cancel 先到了Try 后到。此时订单已经取消但 Try 却成功冻结了资源这个资源永远没人释放。解决办法是Try 执行前检查事务记录如果发现 Cancel 已经执行过Try 直接返回成功但不做任何操作。这两个坑的本质是分布式调用不能保证有序性。写 TCC 代码时一定把Try 先到还是 Cancel 先到当成一个必须处理的正常分支而不是异常分支。6.2 消息重复不是异常是常态用事务消息方案时消费端必须假设同一条消息会被投递多次。原因很多生产者重发、MQ 消费端网络超时导致 MQ 重新投递、消费者处理成功后还没来得及返回 ACK 就宕机。处理方式只有一个消费逻辑必须幂等。而且幂等的判断不能依赖我判断一下状态再决定是否处理必须用先占坑再处理的模式。比如消费订单已创建消息更新搜索索引处理前先往 t_mq_consume_record 表插入消息 ID插入成功才执行更新插入失败说明已经处理过直接跳过。实际操作中我见过不少团队把幂等写成先查再插两个请求并发时全查不到然后都执行了处理逻辑。正确的做法是利用数据库唯一索引做插入用插入是否成功来判断是否重复。6.3 对账任务才是硅基防线最后想说的是再完美的运行时方案也会有漏网之鱼。线下对账不是可选项而是必选项。我们团队的对账策略是这样每天凌晨跑一次全量对账比对订单表、库存流水表、优惠券使用记录找出订单已确认但库存冻结记录还在优惠券状态已使用但订单状态为取消等异常数据。对账脚本本身用简单的定时任务实现不依赖任何分布式框架它要的就是一个慢而全。对账发现异常后不自动修复只生成告警工单由值班人员人工判断处理方式。自动修复听着高效但一旦修复逻辑写错可能把本来就乱的数据改得更乱。人工兜底虽然慢但稳。我踩过最惨的一次坑就是上线初期对账任务没覆盖优惠券冻结未释放的场景结果大促结束后一查几千张优惠券被永久锁定用户投诉电话直接打爆。自那以后我给自己定了个规矩任何分布式事务方案上线之前必须先写好对账脚本——不为别的就为晚上能睡得踏实。分布式事务没有银弹。你选的不是最好的方案而是代价最可控的方案。想通这一点很多纠结自然就解开了。