分布式事务六种方案实战对比:从2PC到最终对账,订单库存场景选型指南

发布时间:2026/9/16 0:38:37
分布式事务六种方案实战对比:从2PC到最终对账,订单库存场景选型指南 先问大家一个问题你们在做微服务拆分之后有没有被“分布式事务”这四个字折磨过我印象特别深以前在公司做电商订单系统单库单表时代下订单扣库存就一条SQL的事事务一包就完事。后来拆了服务、拆了库用户下单付完钱结果库存扣减失败超卖、客诉、半夜被电话叫醒……最后全组人围着会议室白板争论到底该上2PC、TCC还是SAGA争了三天没结论。那次经历之后我花了很长时间把分布式事务的常见方案全部撸了一遍也踩了不少坑。今天这篇就把我从实践角度整理的完整思路写出来从强一致的2PC到最终对账兜底一共6种方案全部围绕一个最典型的业务场景——订单和库存——展开。这篇文章不是教科书不会把每个协议背一遍而是告诉你每一种方案在真实项目里到底怎么落地、有什么坑、怎么选。1. 先从一个最经典的下单场景说起订单与库存为什么需要事务1.1 拆库之前一条SQL就解决的事想象一下最传统的电商单体架构订单表、库存表、账户表全在同一个数据库里。用户下单代码大概是这个感觉BEGIN; INSERT INTO orders(user_id, sku_id, amount, status) VALUES(1001, 888, 1, CREATED); UPDATE stock SET available available - 1 WHERE sku_id 888 AND available 1; COMMIT;任何一个步骤失败整个事务回滚数据绝不会有中间态。这就是ACID原子性、一致性、隔离性、持久性全由数据库引擎帮你保证。这种方案的优点太明显了简单、可靠、出了问题不需要你介入数据库自己处理一切。问题是业务量上来以后单库扛不住了。于是拆库拆表订单库放订单数据库存库放库存数据账户库放余额。服务也跟着拆订单服务只管订单库存服务只管库存。1.2 服务一拆事务的边界就断了现在用户下单订单服务在自己的库里插入一条订单库存服务在自己的库里扣减库存。两个操作不再属于同一个数据库事务你没法用一个COMMIT同时提交两个库。有同学说“那我在代码里先插入订单再调用库存服务的RPC扣库存如果扣库存失败就删除订单不就行了”理论上听起来很简单但仔细想一下订单删掉以后用户可能已经收到订单成功的消息了扣库存成功了但是订单服务删除订单的操作自己挂了两边数据就不一致。我们当时线上就出过这种问题订单表有订单、库存表没扣结果超卖。所以在分布式环境里“要么都成功要么都失败”这个朴素愿望跟我们面临的技术现实之间出现了巨大鸿沟。这也是分布式事务方案存在的原因它不是在制造复杂而是在解决“跨进程、跨库之后单机事务的语义失效”这个客观问题。1.3 理解分布式事务前先理清CAP和BASE的思维框架要理解后面所有方案脑子里必须先有一个框架——CAP定理和BASE理论。CAP说的是一致性Consistency、可用性Availability、分区容错性Partition Tolerance三者不可兼得。在分布式系统里网络分区是必然发生的也就是说P是必须选的所以实际就是在C和A之间做取舍。所谓强一致的2PC本质上是优先保C、牺牲A而消息事务、对账这类方案是优先保A、接受短时间不一致最终靠补偿和对账兜底。BASE理论更贴合互联网实践基本可用Basically Available、软状态Soft State、最终一致Eventually Consistent。翻译成人话就是系统在大多数时候都可用允许中间态存在但经过一段时间后数据最终会一致。为什么要把这个放在开头因为很多人一上来就纠结“哪种分布式事务方案最强”其实没有最强的方案只有更适合你业务约束的方案。你先要问自己这个业务能接受多久的不一致窗口如果数据库挂了你更希望用户看到“系统忙”还是“转圈等待”订单和库存这种高频交易场景和银行跨行转账这种低频强监管场景答案完全不一样。2. 强一致方案的扛把子2PC两阶段提交到底在做什么2.1 协议流程Prepare和Commit两个阶段都在干什么2PCTwo-Phase Commit两阶段提交是分布式事务最经典的方案。别看它几十年了它的思想到现在还在被各种分布式数据库内部使用。整个流程里有两个角色协调者Coordinator也叫事务管理器负责决定最终提交还是回滚参与者Participant就是各个资源管理器比如数据库、消息队列、应用服务。第一阶段叫Prepare阶段也叫投票阶段。协调者问所有参与者“你们能不能一起提交”各参与者收到请求后执行本地事务操作写入undo/redo日志锁住相关资源但不提交返回一个“可以提交”或“不能提交”的响应。第二阶段叫Commit/Rollback阶段也叫执行阶段。协调者把手里的票汇总如果所有参与者的回答都是Yes那就广播Commit只要有一个人回答No或者超过一定时间没有响应就广播Rollback。画成文字时序就是协调者 订单库 库存库 |--- Prepare(下单) -- | |------ OK ---------- | |--- Prepare(扣库存) - | |------ OK ----------- | |--- Commit ---------- | |--- Commit ---------- |2.2 订单库存场景下2PC怎么走把上面的模型套到订单和库存上。订单库和库存库是两个参与者协调者由事务管理器承担。具体流程协调者向订单库发送Prepare请求订单库执行INSERT INTO orders...执行成功但不提交返回Yes协调者向库存库发送Prepare请求库存库执行UPDATE stock...执行成功但不提交返回Yes协调者收到两个Yes向两个库发送Commit请求两个库同时提交事务。任何一个参与者返回No比如库存库存量不足协调者就向订单库发送Rollback订单库刚才那条INSERT就会被回滚。这样保证了“订单创建”和“库存扣减”要么同时成功要么同时失败。值得注意的是2PC的前提是各个库里的事务要能支持挂起和恢复所以底层通常依赖XA协议。Java世界里对应的标准是JTAXADataSource、XAResource这套东西。如果你用过Seata的XA模式或者用过Spring的JtaTransactionManager就是走这条路线。2.3 2PC的四大槽点为什么互联网公司很少直接用作为强一致方案2PC最被人诟病的几个问题我这里一个个说。槽点一同步阻塞资源锁死。在Prepare阶段参与者要锁住资源直到协调者发出第二轮指令。锁多久协调者不表态参与者就一直等。这个时候订单表的行锁、库存表的行锁全部不释放其他所有线程只要碰这些数据就得排队。如果一次交易涉及的数据恰好在热卖商品的库存行上那一波大促流量可以直接把系统拖到瘫痪。这就是牺牲可用性的典型表现。槽点二协调者单点故障。协调者挂了所有参与者都等不到第二阶段的指令已锁资源就一直锁着事务挂到数据库超时为止。如果协调者宕机后还丢了状态连它自己都不知道该让参与者提交还是回滚这就是最尴尬的“不知所措”状态。槽点三脑裂和网络分区会导致数据不一致。协调者在第二阶段广播Commit时如果网络抖动订单库存收到提交指令库存库没有收到结果订单创建成功、库存扣减失败。这在理论上不完美因为没有任何方案能在网络分区下做到完全一致——CAP定理不是闹着玩的。槽点四性能差扩展性弱。每加一个参与者交互相应会增加资源锁时间全局的吞吐量也下降。2PC能支撑的并发量不高。所以我的结论是如果你在做一个日请求量几千笔的低频、强监管系统比如某个内部工单审批、或者传统信贷渠道2PC还能用。但如果你是电商订单库存这类高频、高并发场景直接用2PC大流量一来就等着报警吧。2.4 你该记住的2PC一句话总结2PC通过引入协调者和“先Prepare后Commit”的投票机制实现了跨库事务的强一致。它的代价是阻塞、性能差、协调者单点、且仍然存在极端情况下不一致的风险。如果项目里有人直接让你上2PC你先问他一个问题“参与者资源和锁的等待时间你打算怎么扛”3. 在2PC之间找出路3PC的改良和TCC的强势补位3.1 3PC改进了什么为什么总是存在感不高3PC三阶段提交是2PC的改良版把Prepare拆成了CanCommit和PreCommit加上最终的DoCommit一共三个阶段。最大的变化是引入了超时机制参与者在等待指令时如果超时默认执行回滚或提交而不是像2PC那样傻等协调者。表面上看3PC解决了2PC的同步阻塞问题降低了协调者单点故障的影响范围。但在实际工程里用得很少。原因很简单多了一个阶段就意味着多一轮网络交互整体延迟更高而且它并没有根治脑裂问题在极端网络异常下仍然可能出现不一致。我自己的看法是3PC更适合做论文研究和面试谈资在真实业务系统里出场率极低。如果你正在为项目选型轻易别选3PC你用它的成本不会比后面要讲的TCC低收益却并不突出。3.2 TCC的核心思想把资源锁变成业务操作TCC的全称是Try-Confirm-Cancel补偿型事务是目前互联网业务里强一致需求场景下最实用的一套方案。它的核心思想是不让数据库一直锁着资源而是把“锁定资源”“真正扣减”“释放资源”三个动作都改成由业务代码自行实现。三个阶段的含义Try尝试阶段做资源检查和预留。比如扣库存不是说直接扣而是把可用库存变成冻结库存Confirm确认阶段真正执行业务。把冻结库存变成已扣库存Cancel取消阶段释放预留的资源。把冻结库存还原成可用库存。TCC要求每个参与的服务都必须实现这三个接口并且要保证Confirm和Cancel的幂等性。很多同学第一次接触到TCC都觉得“这不就是自己写回滚逻辑吗”对核心就是这个意思。但它比“你写个catch然后调另一个接口”的野路子要规范得多。3.3 以库存扣减为例TCC的Try/Confirm/Cancel都怎么写还是订单库存这个场景。假设库存表里有total_count总库存、frozen_count冻结库存、available_count可用库存三个字段。Try阶段-- 预扣库存把可用库存转到冻结库存 UPDATE stock SET available_count available_count - #{count}, frozen_count frozen_count #{count} WHERE sku_id #{skuId} AND available_count #{count};如果这条SQL影响行数为0说明库存不足Try失败整个事务Cancel。Confirm阶段-- 真正扣减把冻结库存清掉 UPDATE stock SET frozen_count frozen_count - #{count} WHERE sku_id #{skuId};Confirm阶段理论上不应该再做可用性检查因为Try已经检查过了。Cancel阶段-- 回滚把冻结库存还回去 UPDATE stock SET available_count available_count #{count}, frozen_count frozen_count - #{count} WHERE sku_id #{skuId};这三条SQL看着都不难但落到代码里问题就会出现在“这三个操作之间系统崩溃了”以及“这三个接口被调了不止一次”的情况上。3.4 TCC三座大山空回滚、悬挂、幂等TCC方案最考验人的不是写Try/Confirm/Cancel三个方法而是应对下面的三类异常空回滚。所谓空回滚就是Cancel比Try先到或者Try压根没执行成功结果Cancel被调用了。这时候如果直接去执行Cancel的库存回加SQL就可能把没冻结的库存多出来形成脏数据。标准做法是给每个事务生成一个唯一的事务ID在Try和Cancel里都先查一下事务记录表如果发现Try没执行过或者记录状态不是Try成功就不能执行回滚。悬挂。悬挂指Try的执行延迟了线程A执行Cancel成功后线程B才执行Try导致一个已经取消的事务又被执行了。为避免悬挂需要在Try执行前检查事务状态发现已经Cancel了就直接忽略Try。为了解决上面两个问题通常要维护一张transaction_control表用来记录每个事务ID的状态流转。典型结构就是字段说明transaction_id全局事务ID唯一statusTRYING / CONFIRMED / CANCELLED / FINISHEDcreate_time创建时间update_time最后更新时间每次执行Try、Confirm、Cancel前都用事务ID先查这张表确保状态机只能按正确顺序流转。这就是“空回滚、悬挂、幂等三件套”。当时我们做TCC时框架层面帮忙覆盖了一部分但真正上线前还是靠大量故障注入测试才敢放量。4. 面向长事务的SAGA把补偿动作交还给业务4.1 SAGA的核心思想一个大事务拆成N个小步失败就一步步退回去SAGA模式来自一篇上世纪80年代的数据库论文但真正被大规模应用是在微服务和分布式系统流行之后。它的核心思想很简单把一个分布式事务拆成一组有序的本地事务每一步都在本地库提交同时为每一步设置一个“反向补偿操作”。当某一步失败时就依次逆向执行前面所有步骤的补偿动作。还是用订单库存举例一个下单扣库存的SAGA大概长这样创建订单正向操作扣减库存正向操作扣减账户余额正向操作。如果第3步失败SAGA会执行释放账户余额补偿第3步回补库存补偿第2步订单标记为失败/取消补偿第1步。这里要特别注意SAGA的每一步之间事务是各自提交的。也就是说你创建完订单、扣完库存都已经是既成事实外部系统在这段时间里是能看到这个中间状态的。SAGA保证的是最终一致而不是实时强一致。如果你的业务场景不允许中间状态被看到那SAGA不适合你。4.2 编排式还是协同式两种SAGA组织形态SAGA落地时有两种主流形态。编排式Orchestration。由中心化的流程引擎或协调者统一编排比如Seata的SAGA模式、Temporal、Zeebe都是这个思路。协调者负责定义执行顺序、调用各个服务、记录当前执行到哪一步、失败时触发补偿。好处是流程集中可控全局状态在协调者那里都有记录出问题容易排查坏处是协调者本身可能成为瓶颈或单点。协同式Choreography。每个服务完成自己的本地事务后通过事件或消息触发下一个服务整个过程没有中心协调者像接力棒一样一个传一个。好处是解耦、性能好坏处是流程分散在各服务里全局状态难以追踪出了问题排查比较费劲。从我的实践经验看如果团队人数不多、业务流程相对固定用编排式更合适因为状态都持久化在引擎里后面做故障恢复和人工干预都很方便。协同式更适合那个事件流本来就是核心架构、每个领域事件都有充分建模的团队否则别为了“分布式编排很酷”去选它。4.3 SAGA的成败关键正向操作和补偿操作都得完整设计SAGA最坑的地方在于补偿操作不是你随便写一个反向操作就完事的。比如“创建订单”的补偿是“将订单状态改为已取消”但“扣减库存”的补偿是“库存加回去”。问题来了——如果我们加库存加得太快又有新的订单来就可能把库存加回去的数量分配给新订单导致实际库存超卖。这时候你要做的不只是“加库存”而是要保证回补的库存不参与新的超卖分配或者配合订单状态机控制。所以SAGA在设计阶段必须为每个服务画出状态机。拿订单服务来讲至少有这些状态创建中 - 已创建 - 扣库存中 - 已扣库存 - 已完成 - 扣库存失败 - 待补偿每一条边都对应一个明确动作和一条逆向动作。这个状态机的设计比选什么框架重要一万倍。另外SAGA的执行记录也要持久化存储。如果执行到第2步时宕机了重启之后你要知道“当前这个全局事务走到哪了、该继续执行还是触发补偿”。没有持久化的状态SAGA就悬在空中恢复起来只能靠人肉排查。5. 最终一致性方案本地消息表与消息事务谁更靠谱5.1 先接受一个现实多数业务场景不需要强一致到了第5种方案我们的心态要换一下。前面讲的2PC、3PC、TCC、SAGA本质上都想在业务代码或数据库层把一致性做实时处理。但在订单库存这类高频、高并发的互联网场景里绝大多数团队的选择其实是“最终一致性”。什么叫最终一致性就是我先让订单创建成功立刻返回用户下单成功库存扣减通过消息异步执行。哪怕扣库存晚了一秒、两秒但最终一定会做到并且在完成之前库存数据对外可能是“短暂不一致”的。这种不一致通常通过冻结库存、余额校验等手段放到可接受区间里。这里最著名的问题就是“先改数据库后发消息怎么保证两步一致性”。如果先插入订单再发MQ消息订单插入成功但发消息失败库存不会扣如果先发消息再插入订单消息被消费了但订单没创建成功库存可能被莫名扣掉。5.2 本地消息表把发消息也塞进本地事务本地消息表的思路是把“发消息”这个动作降级为“往本地数据库里写入一条消息记录”。具体步骤订单服务开启本地事务同时插入订单数据和一条消息记录message_record(msg_id, statuspending)一起提交或一起回滚后台有个定时任务或者抓取工具扫描message_record里状态为pending的记录把消息投递到MQ或者直接调用库存服务的接口MQ/接口返回成功后把消息记录状态改成sent或done。这样“订单数据”和“消息投递”就被绑定在同一个本地事务里不会出现“订单成功但消息没进库”的情况。就算投递时MQ服务器挂了消息记录还在库里下次定时任务还能继续投。本地消息表的优点是实现简单、不依赖特殊组件。缺点也明显你有多少业务消息就要维护多少张消息表或者起码维护一套通用的消息表框架定时任务轮询有秒级延迟如果MQ长期不可用本地消息表的消息会越堆越多你必须考虑积压告警和消息重试上限。5.3 MQ事务消息用半消息协议替代轮询表本地消息表的“轮询”思路虽然有效但总让人觉得有点糙。于是RocketMQ这一类的消息中间件直接把这个模式做成了内置能力叫事务消息。以RocketMQ为例流程是这样的生产者先发送一条“半消息”到Broker半消息对消费者不可见生产者执行本地事务比如插入订单本地事务执行成功提交半消息消费者才能看到消息如果本地事务执行失败回滚半消息消息直接被丢弃如果把半消息提交后Broker一直没有收到确认它会反向调用生产者的回查接口询问本地事务到底成功没再根据结果决定提交或回滚。这个机制是不是很眼熟没错它就是把本地消息表的“扫描”和“投递”搬到了Broker内部让中间件来保证“本地事务”和“消息可见性”的一致性。使用事务消息的前提是你用的MQ得有这个能力RocketMQ支持RabbitMQ本身不支持Kafka靠Exactly Once语义的KIP-98做了类似的事情但用法和场景限制比较多。所以如果你选型时已经定了RocketMQ事务消息是个比较顺手的方案。5.4 消费端的幂等设计这才是最终一致的胜负手很多团队把最终一致性方案做成消息出来后忽略了消费端幂等然后线上疯狂出重复扣库存的问题。要知道MQ投递消息是至少一次at least once语义消息可能被重复投递消费端如果没做幂等重复执行一次扣库存库存就多扣一次。幂等设计最常用的几种手段数据库唯一键约束在消费记录表里用biz_id建唯一索引重复消费时插入失败状态机判断先查订单状态如果已经是“已扣库存”就直接返回成功不再处理Redis去重锁用SETNX占位存在则跳过操作流水表所有扣减操作都插入一条流水流水号唯一重复消费被唯一约束挡掉。从我的经验看数据库唯一键约束是最稳的因为它是事务性的不会像Redis那样有缓存丢失的风险。我的习惯是消费端始终维护一张操作流水表主键就是业务流水号把消费端幂等落到数据库约束上。6. 兜底方案最大努力通知和最终对账最后的防线6.1 为什么做了这么多方案还是要对账看到这里你可能会想我把上面这些方案都做好了是不是就高枕无忧了我的回答是不是。哪怕你把TCC、SAGA、事务消息全做完美线上还是可能因为网络抖动、代码Bug、人为误操作、缓存的库存和数据库的库存没对齐等原因产生不一致。有句话说得好没有对账体系的分布式事务方案都是在裸奔。因为所有方案都存在极端情况2PC可能在网络分区下不一致TCC可能在接口无限重试时状态错乱SAGA可能在流程引擎自身出Bug时丢失推进消息可能被MQ彻底丢在某个未确认的角落。别问为什么丢问就是发生过。所以最后兜底的方案一定是“对账”。6.2 对账系统的核心设计快照、比对、差异处理、人工介入一个可落地的对账系统通常有四个模块。第一步数据快照。每个系统都要把自己参与交易的流水表或者交易记录保存足够长的时间。比如订单服务要保留订单表和事务控制表库存服务要保留库存流水表。没有流水后面没法比。第二步差异比对。定时任务按业务唯一键比如order_id或trade_no把两边的数据取出来做比对。比对字段通常包括订单存在性、库存扣减流水是否存在、扣减数量是否一致、状态是否匹配。这一步可以做成离线批处理一般一小时跑一次比实时强一致的成本低得多。第三步差异处理。比对结果出现差异后进入差异表并按规则自动冲正或补偿。比如发现订单存在但库存没扣就自动触发一次库存补偿扣减发现库存扣了但订单不存在就把库存冲正回来。冲正动作也要落地流水不能重复执行。第四步人工介入。自动冲正解决不了的问题比如两边数据都对不上、金额差了0.01元一定要有个后台列表展示出来并支持人工编辑状态。但要记住人工操作一旦落库同样要记日志、要留痕。6.3 订单和库存对账时最容易出现的不一致项这里我列几个实战中经常出现的差异都是踩过的坑现象可能原因处理方式订单存在库存流水缺失MQ消息丢失或消费端被重复逻辑跳过触发补偿扣减库存订单不存在库存扣减流水存在订单本地事务回滚但消息已经被发送并消费触发库存回补库存流水重复消费端幂等没有覆盖所有场景保留唯一索引人工核查数量不一致并行处理多个订单时扣减SQL条件写得不对比对数量差异自动告警不要小看这张差异表它才是保证线上数据一致性的最后一道防线。对账跑不出来差异固然最好但一旦跑出差来是你救回数据、规避资损或者品类超卖的最后机会。6.4 最大努力通知就再服务端补一刀如果对账发现差异了怎么通知我们总不能全靠人去数据库里翻。这就要说最大努力通知模式当系统无法确认某个下游操作结果时主动把结果“尽力”通知到对方通知失败就按退避策略重试重试超过N次后记录并转人工。这个概念听起来抽象但实现起来很朴素一个带重试机制的结果通知服务加上一张通知记录表。它本身不保证必达但可以把“收不到结果”的概率降到足够低。再加上对账系统兜底基本就能保证项目数据一致性出大问题后发现得足够早。当时的做法是订单中心、库存中心都往一个“结果通知中心”发事件通知中心负责把结果投递给所有需要知道这件事的下游包括对账系统。投递失败会退避重试超过10次就亮红灯人工处理。7. 六种方案怎么选一张决策表帮你做取舍7.1 横向对比维度前面讲了6种方案这里我把它们放到一张表里对比。注意这张表的结论不是绝对的但可以帮你快速排出方向。方案一致性吞吐量业务侵入关键成本适用场景2PC强一致低低依赖数据库协调者阻塞与单点低并发、数据库跨节点、须强一致3PC强一致低低多一轮交互工程上少用理论参考为主TCC接近强一致中高三接口三件套三个接口开发与状态控制支付、账户、高价值订单等必须实时可控SAGA最终一致中高中高状态机补偿补偿流程和状态机设计跨服务长事务可接受中间状态本地消息表最终一致高中要维护消息表定时任务和消息表堆积管理普通异步场景无事务消息MQ事务消息最终一致高低中依赖MQ消息中间件是否支持已使用RocketMQ且需要异步扣减最大努力通知对账最终一致兜底—中对账任务和差异处理机制所有分布式事务场景都应该有7.2 实战选型建议回到最初的订单库存场景。如果是新项目我的建议路径是这样订单服务和库存服务如果没有极端的一致性好要求优先考虑本地消息表或RocketMQ事务消息通过消息异步扣减库存库存设计上用“冻结库存”模型配合订单状态机而不是简单扣减available字段所有消费端必须做唯一键幂等强烈建议上一套对账任务至少每小时跑一次订单表和库存流水表的比对如果订单金额比较大、流程不允许出现任何中间态可以考虑TCC但你要准备好把库存冻结模型和事务控制表做好并且做好压测。再说简单点就是能用消息 状态机 对账解决的就不要上TCC和2PC这类重方案。它们听起来很正规但带来的开发成本、故障恢复成本、人员门槛都很高。当年我们做订单库存场景时最终用的组合是“锁单 RocketMQ事务消息 状态机补偿 每日对账”没有上任何重量级分布式事务框架。还有一点不得不提无论选哪种方案项目里要有一个专门记录事务状态的地方。你可以叫它transaction_control表也可以叫event_record表。它是整个分布式事务方案的“黑匣子”出问题第一件事就是查这张表看状态机走到哪一步了。我自己的体会是分布式事务没有一个银弹60%靠设计、30%靠工程保障、10%靠运气。少点纠结“最强方案”把重点放在梳理业务状态机和补偿路径上你的系统会比大多数方案都稳。最后再提醒一遍千万别忘了幂等这是所有方案都能失效时救你命的底牌。