分布式事务方案深度解析:从2PC到TCC与SAGA的选型指南
2026/9/16 5:57:13 网站建设 项目流程

作为一个在电商、支付、物流这些行当里摸爬滚打过十多年的老技术人,我几乎每年都要面对一次“分布式事务”的灵魂拷问。尤其是在系统拆成微服务、订单和库存分家之后,数据一致性就成了悬在头顶的达摩克利斯之剑。很多刚接触分布式的朋友,一听到“2PC”、“TCC”、“SAGA”这些词就头大,感觉每个字都认识,拼在一起不知道在说什么。

这篇文章我打算换个思路,不堆概念,也不贴晦涩的源码,就用咱们实际业务里最常碰到的“下单扣库存”这个场景,把6种主流的分布式事务方案从头到尾捋一遍。从强一致的2PC,到最终一致的对账方案,每一张图、每一步交互、每一个坑,我都会讲清楚它解决什么问题、牺牲了什么,以及你该怎么选。这篇文章适合正在做微服务改造、准备分布式事务方案,或者纯粹想把这几个缩写彻底搞懂的架构师和开发同学。

1. 把问题说清楚:分布式事务到底在解决什么痛点

在聊方案之前,咱们得先把问题的本质揪出来。你想想,在单体应用时代,订单和库存都在一个数据库里,扣库存和建订单就是一个UPDATEINSERT的事,包在一个数据库事务里,要么都成功,要么都回滚,根本没有歧义。

但一旦做了微服务拆分,订单服务管订单库,库存服务管库存库,业务操作就变成了跨库跨服务的调用。此时你面对的就是一个经典的分布式事务场景:用户下单时,订单库要插入一条订单记录,库存库要扣减一个库存数量。这两个操作必须保持一致——不能说订单建成功了,库存没扣,结果超卖;更不能说库存扣了,订单没建成,结果库存凭空消失。

这个痛点牵扯到分布式系统里最底层的那个“不可能三角”——CAP理论。在分布式环境下,网络分区是不可避免的,一旦发生网络抖动或节点宕机,你必须在“一致性”(Consistency)和“可用性”(Availability)之间做个取舍,而且这个取舍不是绝对的“要C不要A”,而是要么选择强一致(牺牲部分可用性),要么选择最终一致(牺牲数据实时一致性,换取高可用和高吞吐)。

所以说,你选哪种方案,本质上是在选“当系统出故障时,你希望它怎么表现”。想清楚这一点,下面聊的每一种方案就都能对号入座了。

2. 强一致阵营:从2PC到3PC的执念与妥协

强一致方案的核心思路是“所有人都得点头,操作才算数”。它们保证了在事务周期内,所有参与者看到的数据库状态是瞬间一致的,代价就是每一笔事务都会有额外的通信开销和锁定时间,吞吐量自然上不去。

2.1 2PC(两阶段提交):最经典的强一致协议,但不要神化它

2PC这个词你应该不陌生,它大概是所有教科书里第一个提到的分布式事务协议。它的流程特别像一场“全体一致通过”的投票,由一个协调者(Coordinator)主导,所有参与者(Participants)配合,分两个阶段完成:

  • 第一阶段:准备阶段(Prepare Phase / Voting Phase)协调者给所有参与者发送“准备提交”的请求。参与者收到请求后,执行本地事务,写日志,锁定资源,但不提交。然后把执行结果(成功或失败)反馈给协调者。

  • 第二阶段:提交阶段(Commit Phase / Decision Phase)协调者收集所有反馈。如果所有人都返回“准备好了”,协调者就发送“正式提交”指令,所有参与者完成提交。但凡有一个人返回“执行失败”或者干脆超时了,协调者就会发送“回滚”指令,让所有参与者回滚自己刚才执行的事务。

说到这,你可能会觉得,这不挺简单的吗?逻辑上很完美啊。但在实际落地的时候,2PC有一个被无数人诟病的致命弱点——同步阻塞

举个例子,还是订单和库存。订单服务执行完本地事务,给协调者回复“OK”之后,它手里的那条数据库记录、那些行锁,都不能释放,要一直等着协调者的最终指令。如果这时候协调者宕机了,或者订单服务和协调者之间的网络断了,订单服务根本不知道是该提交还是该回滚,只能一直阻塞着。这个阻塞时间有可能是几秒,也有可能是半小时,这在高并发场景下是不可接受的,几个卡住的订单请求就能把连接池打满,整个服务直接雪崩。

另外还有一个数据不一致的隐患,那就是在第二阶段,如果协调者发出的“提交”指令,有的参与者收到了也执行了,有的参与者因为网络问题没收到,就会陷入“一部分提交、另一部分未知”的尴尬局面。所以严格来说,2PC只是“看起来强一致”,它在故障场景下的语义是有缺陷的。

在Java生态里,AtomikosNarayana干的就是2PC的活,基于XA协议和数据库自己的XA事务支持。我的建议是,除非你的业务量极低、对一致性要求苛刻到极致、且能忍受故障时的长时间阻塞,否则别在生产环境直接用裸的2PC。它的价值更多在于让你理解强一致的天花板和代价。

2.2 3PC(三阶段提交):在2PC基础上解决阻塞,但并不能一劳永逸

3PC是针对2PC阻塞问题的一个改进,它把2PC的“准备阶段”又拆成了两步:

  • 第一阶段:CanCommit(询问阶段)协调者问所有参与者:“你们能不能提交?” 参与者此时不执行事务,只检查自身状态,回复“能”或“不能”。

  • 第二阶段:PreCommit(预提交阶段)如果所有人都回复“能”,协调者就广播“预提交”指令。参与者收到后执行本地事务,写undo日志,但先不提交,然后给协调者回复“预提交完成”。

  • 第三阶段:DoCommit(最终提交阶段)协调者收到“预提交完成”后,广播“正式提交”指令。参与者收到后正式提交事务。

3PC的改进在于引入了超时机制,不管是协调者还是参与者,只要等待超时,就默认按“提交”或“回滚”处理。也就是说,在协调者挂掉或网络超时的情况下,参与者不会像2PC那样傻等着,而是根据超时自己做出决定。

但这里就又引出一个新问题:正因为有了超时自动决策,3PC反而增大了数据不一致的概率。比如协调者发出“预提交”指令,一部分参与者执行完了,另一部分参与者网络超时,然后在超时后自己回滚了;接着协调者又发出“正式提交”,那执行了预提交的参与者提交成功,超时回滚的参与者就只能处于不一致状态。3PC也没有办法把这些状态拉齐。

实际使用中,3PC远没有2PC普及,因为它把算法搞复杂了,换来的收益却有限。你如果去调研,会发现业界真正用得多的还是2PC(借助Seata AT、XA商业数据库支持)和后面的柔性事务方案。3PC更像是一个学术上的过渡方案,知道它的场景和取舍就够了。

3. 柔性事务主流派:TCC与SAGA的业务补偿艺术

既然强一致方案在性能和容错上这么吃力,业界的主流思路慢慢转向了“柔性事务”。它的核心思想是:既然不能保证整个过程瞬间一致,那就保证最终一致;既然没法用数据库锁,那就用业务逻辑来做补偿。

3.1 TCC(Try-Confirm-Cancel):代码写得多,但可控性最强

TCC是目前互联网公司用得最广泛的分布式事务方案之一,它的思想是把一个完整的业务操作拆成三个动作来对应实现,每个动作都需要你在业务代码里显式实现:

  • Try(尝试):完成资源检查和预留。比如下单场景,Try阶段不真正的扣库存,而是把库存“冻结”起来(例如在库存表里加一个frozen_stock字段,冻结5件商品)。同时,订单状态置为“待确认”。
  • Confirm(确认):如果所有参与者的Try都成功,协调者调用Confirm,真正执行提交动作。比如把冻结的库存扣减掉,订单状态置为“已创建”。Confirm要保证幂等。
  • Cancel(取消):如果任何一个参与者的Try失败,协调者调用所有已成功的参与者的Cancel,执行补偿回滚。比如把冻结的库存解冻,订单状态置为“已取消”。

TCC最大的优势是它把数据库锁从2PC的整个事务级别,降级到了业务手工控制的级别。Try阶段的“冻结”,本质上是你用一个字段来代替数据库行锁,这样数据库能承受的并发量就大多了。而且它的最终一致性是靠明确的业务动作保证的,不像后面的消息方案那样有延迟和不确定性。

但TCC的代价也很明显——业务侵入性太强了。你每接入一个事务分支,就要多写Try、Confirm、Cancel三套逻辑,代码量翻倍不说,对业务人员的抽象能力要求也很高。你还要自己处理空回滚(Cancel被调用时Try还没有执行)、幂等(Confirm或Cancel被重复调用)、悬挂(Try在Cancel之后到达)这些边角问题。虽然Seata的TCC模式帮你做了空回滚和悬挂的部分处理,但Confirm和Cancel的具体业务逻辑还得你自己写。

我的实操感受是:TCC特别适合那种“资源明确、需要强约束”的场景,余额扣减、库存冻结、积分扣减这类。如果你们公司有一套成熟的分布式事务中间件(比如Seata、dtm),且业务团队有足够的开发精力,TCC是优先考虑的方案。但如果只是两三个服务之间的简单调用,引入TCC的复杂度会比它解决的问题还多。

3.2 SAGA:长事务审批流,靠“错后补偿”走天下

SAGA模式最早是数据库领域的一篇论文提出来的,它的逻辑非常朴素:把一个长事务拆成一组有顺序的本地事务,每两个本地事务之间都配有一个对应的“补偿事务”。如果整个链路中,中间某个本地事务失败了,就逆序触发前面所有本地事务的补偿事务,把数据回滚到初始状态。

以订单库存为例,一个SAGA事务大概是这样的:

  1. 订单服务创建订单(订单状态“待处理”)。
  2. 库存服务扣减库存(若失败,调用第1步的补偿:取消订单)。
  3. 支付服务扣款(若失败,调用第2步的补偿:库存加回去,调用第1步的补偿:取消订单)。
  4. 整个业务全部完成,订单状态“已完成”。

SAGA分两种实现方式:事件编排(Choreography)命令编排(Orchestration)。事件编排没中心节点,各个服务通过监听事件互相触发,耦合性强但实现简单;命令编排则有一个中心化的协调器(比如Seata SAGA里的状态机引擎),由它来定义执行顺序和补偿顺序,适合复杂链路,可控性好。

SAGA最大的问题在于隔离性缺失。因为没有行锁,在“订单创建成功”和“库存扣减成功”之间,其他事务是可以看到订单存在、但库存还没扣减这种中间状态的。如果这时候用户查询库存,可能会看到错误的数字。另外,补偿事务本身也可能会失败(比如补偿时网络又断了),所以SAGA场景下,为了保证最终一致,往往还得配一个兜底的定时对账机制。

我的建议是:如果你的业务链路很长(比如3个以上服务)、中间步骤又是不可逆的资源占用(比如预订酒店、发起退款),用SAGA配合可靠消息来源,会是一个相对舒适的方案。

4. 异步解耦三件套:消息、本地表与对账

说完了带“补偿动作”的在线柔性事务,我们进入业界应用最广、也是运维成本最低的阵营——最终一致性方案。这类方案的核心原则是:先改自己的库,再发消息让别人改,如果失败了,定时任务和人工补偿兜底。

4.1 本地消息表:eBay经典方案的朴素力量

本地消息表,我第一次看到这个方案是在eBay的一篇技术博客里,思路极其朴素,但非常管用。它把“发送消息”这个操作和“写业务数据”放进了同一个本地数据库事务里:

  1. 订单服务在同一个本地事务里,完成两件事:

    • 插入一条订单记录(状态“待处理”)。
    • 插入一条消息记录(状态“待发送”)。 因为这两个操作在同一个库、同一个事务里,所以它们要么都成功,要么都失败,绝对可靠。
  2. 有一个后台定时任务,每隔几秒扫描消息表,把“待发送”的消息捞出来,发给消息队列(MQ)或者直接调库存服务的接口。

  3. 库存服务消费消息扣减库存,处理成功之后,回调订单服务的接口,把消息状态更新为“已发送”或“已完成”。

  4. 如果消费失败,定时任务会重试;如果消息一直没能成功投递或消费,就停留在消息表里,由监控大盘盯住,开发人工处理。

这个方案好在哪?零框架依赖,逻辑极其透明。你不用引入Seata、不用理解什么TCC,只要会写定时任务和消息表,就能在主流的订单-库存一致性场景下立足。它把从“服务间调用”变成了“基于本地事务的可靠消息落库”,借助数据库事务保证了“业务成功必然消息成功”。

但它也有缺点:消息表和业务表耦合在同一个库里,对数据库有额外的访问压力;定时轮询有秒级的延迟,做不到实时一致性;消息表本身如果没有清理机制,会像垃圾堆一样膨胀。不过,对于很多中小公司来说,本地消息表就是那个最省心的兜底方案,因为它足够“傻”,足够可靠。

4.2 MQ事务消息:RocketMQ把“本地消息表”搬进了中间件

消息中间件的进化,基本就是把这套“本地消息表”的逻辑内置到了Broker里。以RocketMQ的事务消息为例,发送端(订单服务)的操作流程是这样的:

  1. 订单服务先向RocketMQ发送一条“半消息”(Half Message),这个消息在Broker端是处于“暂不可见”状态的,消费者消费不到。

  2. 发送成功后,订单服务执行自己的本地事务(插入订单记录)。

  3. 如果本地事务执行成功,订单服务回调MQ的接口,把半消息的状态改为“提交”(Commit),消息变为可见,库存服务就能消费了。 如果本地事务执行失败,就回调“回滚”(Rollback),MQ删除半消息。

  4. 假如第3步的回调因为网络原因没送达,RocketMQ还有一套事务回查机制:Broker会定期反查发送端的本地事务状态,订单服务需要提供一个查询接口,告诉Broker“我这笔订单到底成没成”。

这套机制,本质上是把本地消息表里的那个“扫描消息表”的任务从你手里拿走,交给了MQ的Broker。它的好处是解耦了业务库和消息表的强绑定,对数据库侵入更小,实时性更高,但代价是你需要引入一套支持事务消息的MQ(基本就是RocketMQ),并且要理解半消息和回查机制,这又有一个学习门槛。

我的经验是,如果在你的技术栈里,RocketMQ已经存在,并且你们对消息可靠性要求很高,那事务消息基本就是解决订单-库存这类场景的最优解之一。它既保证了最终一致,又让业务代码保持干净,不用写定时任务。

4.3 最大努力通知与对账平台:最后一道兜底防线

说实话,哪怕你前面用了TCC、用了事务消息,我还是强烈建议你在核心链路上补一套“对账”系统。所谓最大努力通知,就是:业务处理好之后,我把消息/通知发出去,至于你能不能收到、什么时候收到,我不保证实时,但我保证我会一直重试,直到你告诉我“处理成功”为止。

对账的核心逻辑更朴素。把它落地成具体操作,其实是三个步骤:

  1. 实现幂等接收方:库存服务接收消息后,扣库存逻辑必须幂等,同一个“扣减5件商品”的消息推送多少次,最终效果都只能是一次。一般通过一张“消息消费记录表”配合唯一键去重来实现。

  2. 定时对账脚本:每天凌晨跑一个脚本,把订单库的“已创建订单”和库存库的“扣减流水”做一次比对。把那些订单存在、但库存流水缺失的“脏数据”捞出来,自动触发重发消息或人工介入。

  3. 监控大盘:把“处理失败重试次数”和“对账发现差异条数”做成监控指标,超过阈值就告警。我见过很多线上故障,最终都是被对账脚本在深夜默默捞出来,然后开发被电话叫醒处理的。如果没有对账,数据可能就永远在“不一致”里躺着了。

所以,在对账这一环,怎么强调都不为过。它本身不是一种独立的分布式事务方案,但它是所有最终一致方案最终能闭环的基石。你可以没有Seata,可以没有RocketMQ,但不能没有对账脚本。

5. 用一个完整案例拆解:Seata框架下四种模式的落地与比较

讲完纯理论,得落到实际框架上。目前国内最火的分布式事务框架就是Seata。它把XA、AT、TCC、SAGA四种模式全部集成了。我拿“下单扣库存”场景,分别看看它在Seata下怎么玩,以及它的优势和需要注意的地方。

5.1 AT模式:自动化的“补偿版2PC”,无侵入的首选

Seata AT模式可以说是目前开发成本最低的分布式事务方案,因为它对业务代码几乎零侵入。它的核心思想是利用数据库的undo_log表来实现自动化回滚:

  • 一阶段:业务SQL正常执行,Seata的代理数据源会自动拦截SQL,生成“前置镜像”和“后置镜像”,写入全局事务的undo_log里。业务代码不需要额外写Try、Confirm、Cancel任何一个方法。
  • 二阶段:如果所有分支事务都成功了,异步删除undo_log就行;如果某个分支失败了,TC(事务协调器)通知所有分支回滚,各个分支就根据undo_log里的“后置镜像”反向生成UPDATEDELETE语句,把数据恢复到操作前的样子。

听起来很完美,但它有“脏写”问题。因为AT模式靠的是镜像比对,如果两个并发事务同时对同一条库存记录做操作,前置镜像和后置镜像是会冲突的。Seata默认会开启全局锁来避免这种冲突,但这又回到了类似2PC的阻塞老路上,全局锁的存在让AT模式在超高并发下会有些吃力,需要把超时设置得很谨慎,不然经常报“获取全局锁失败”的异常。

我的实操经验是:如果你要在一个已经成型的老项目里快速引入分布式事务,且表结构相对简单、并发不是那种峰值几十万的秒杀场景,Seata AT模式是最香的选择。投入产出比极高,几行注解就能搞定。

5.2 TCC模式、SAGA模式与XA模式在Seata里的定位
  • Seata TCC模式:需要你自己实现三个方法,Seata帮你处理空回滚和悬挂问题,但Confirm和Cancel逻辑得自己写。适合那种对资源有明确“冻结/释放”需求的业务,比如金融账户余额、优惠券。在这个模式下,你要特别注意幂等控制,因为Cancel和Confirm都可能被TC重试调用。

  • Seata SAGA模式:可以用代码或者JSON配置编排状态机,把Saga的执行步骤和补偿步骤定义好。它的优势是长链路清晰,但需要注意的是,Saga的补偿动作写在状态机里,这些动作对应的方法照样也得你自己实现,并且要考虑它们本身的幂等和容错。

  • Seata XA模式:实际上就是把数据库的XA事务能力包装了一下,与2PC一致。它需要你的数据库本身支持XA协议(MySQL、Oracle、PostgreSQL基本都行),而且因为要保持强一致和锁资源,性能和阻塞问题同样存在。在Seata里它属于“标准模式”,但在目前的云原生和微服务高并发环境下,用的并不多。

6. 避坑指南:那些年我在生产环境踩过的分布式事务的雷

最后这部分,才是这篇文章真正的“私货”。上面那些方案理论大家都看得懂,但真到了生产环境,各种鬼故事就都冒出来了。我总结几个最常见的坑,你遇到的时候,可以直接照着查。

6.1 常见故障速查表
症状可能原因排查与解决思路
Seata AT模式频繁报“获取全局锁失败”全局锁超时太短,或同一个表记录被高并发更新调大lockTabletimeout参数;考虑改用TCC或队列化扣减
TCC Cancel阶段报错,补偿失败幂等控制没做好,部分补偿SQL重复执行或执行顺序错乱给业务表加唯一索引,用status字段做状态机流转,禁止重复回退
RocketMQ事务消息一直查不到,回查也不触发半消息状态一直未提交,或回查接口没注册成功检查发送端executeLocalTransactioncheckLocalTransaction实现,确认Broker版本开启事务消息
库存扣减重复执行消费者没做幂等,网络重试导致消息被消费多次引入“消费记录表”,用消息ID做唯一键,杀重
对账任务发现大量不一致,但业务显示正常补偿动作成功但回调通知失败,导致状态没同步检查消息状态更新逻辑,把回调失败纳入重试体系
本地消息表一条消息重试几十次消费接口有bug,或消费后更新状态失败监控重试次数上限,超过阈值直接进“死信表”人工处理,同时查代码
6.2 架构选型的最终建议

你肯定想问,那到底该选哪种方案?我的习惯是,你先回答下面三个问题:

  1. 你是否能忍受几秒钟的数据延迟?如果不能,只能选2PC、TCC、AT这类“准实时”方案;如果能忍受秒级甚至分钟级延迟,果断选MQ事务消息或本地消息表方案。
  2. 你的核心业务链路有多少个服务参与?2个服务,用本地消息表或MQ事务消息很舒服;3个以上且需要强约束,TCC;长链路且不用实时一致,SAGA。
  3. 你的团队能接受多少额外开发成本?既然选了微服务,就难免需要学习一个分布式事务框架(Seata是首选),或者把消息中间件用好。为了一个简单的“下单扣库存”而自研一套TCC框架,纯粹是浪费人力。
6.3 两个被低估的“下策”

最后说两个听起来不够“高大上”,但实际很管用的办法:

第一个是状态机与本地事件表。对于订单这种业务,完全可以把它拆得更细,把“订单”、“支付”、“库存”都当成订单状态机的不同状态或事件来流转。用一张本地事件表记录每一次状态变化,再配一个后台调度器去驱动状态流转。这个世界里不存在“分布式事务”的概念,你只需要保证每个状态转移是原子的,并用事件表做状态机推进的凭证。

另一个是尽量把多库操作变成单库操作。这一招在单体应用还撑得住的时候特别香。在数据库层面做“分库分表”之前,先想想能不能把“商品库存”和“待支付订单”放在同一个MySQL实例的不同库甚至同一张库里,直接用本地SQL更新,闭包一切分布式问题。很多看似需要分布式事务的架构,其实在拆分之初,可以通过边界划分规避掉。

分布式事务这个话题永远没有银弹。不同方案是不同场景下的取舍,你需要结合自己的业务量、团队水平、运维成本,选择最合适的那一个。我在实际项目里最常看到的场景,就是团队为了追求所谓的“强一致”,硬生生引入一套重框架,最后被全局锁阻塞搞到焦头烂额。但真正上线后,大家发现对账脚本和幂等设计才是真正救命的那个家伙。如果你现在正纠结选型,我给你的建议是:优先考虑能保证最终一致、且具备可观测性和补偿手段的方案,然后把对账系统做实,这一点永远不会错。

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

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

立即咨询