资讯动态

分布式事务实战:从2PC到TCC、SAGA与本地消息表的选型与落地

发布时间:2026/9/30 3:19:27 来源:尧图企业网站定制
分布式事务这个话题只要做过订单、库存、支付这类交易链路的人迟早都会撞上。我最早接触它是在一单“订单创建减库存”的业务改造里单体应用拆成订单服务和库存服务数据库一拆原本一个本地事务能搞定的事情突然变成两个库独立提交要么订单建了库存没扣要么库存扣了订单丢了。那段时间排查数据不一致全靠手工写脚本对账苦不堪言。这篇内容算是对我这些年分布式事务场景实践的一次梳理把核心方案、选型思路、实操细节和踩过的坑都拉出来聊聊适合正在做交易链路、被数据一致性问题折磨、或者准备引入分布式事务框架的同行参考。1. 分布式事务问题的本质与场景拆解1.1 事务拆开之后原子性成了谁的锅单体应用里订单和库存通常在同一条数据库连接里完成begin transaction扣库存、建订单全成功才commit任何一个失败则rollback那个版本的ACID是数据库帮你扛的。服务一拆分逻辑还是同一套物理上变成两个独立库、两个独立事务local transaction的begin和commit就管不到对端了。订单服务自己的事务提交成功调用库存服务接口时挂了两边数据就坚定地走向不一致——这就是分布式事务问题的来源。很多人会把注意力放在“选哪个分布式事务框架”上我反而觉得先想明白问题的边界更重要。分布式事务并不是所有场景都需要强一致订单和库存这一点尤其典型用户下单时扣库存需要即时反馈但支付回调、发优惠券、加积分这些环节稍微延迟几秒甚至几分钟用户根本感知不到。所以拆解场景时首先要回答的问题是“这个动作必须同步完成还是可以异步兜底”这个答案直接决定了你后面用的是TCC、消息表还是SAGA。1.2 订单与库存的一致性到底属于哪种需求讨论分布式事务绕不开CAP理论。在分区发生的现实中C一致性、A可用性不能同时完美满足互联网交易系统绝大多数情况下选的是AP也就是让部分节点暂时不可用、但整个服务保持可用通过后续手段把数据拉回一致。这也是“柔性事务”和“最终一致”的立论根基。订单和库存的扣减场景我的经验判断是这样的用户在下单那几秒扣库存必须有结果不然会出现超卖这是准强一致需求但用户下单后异步去更新积分、发送短信、生成推荐记录这些完全可以走最终一致。把整条链路一刀切全做成强一致是很多人方案失控的根源——后续对账、补偿、幂等都是不必要的复杂度。先把需求边界分清再谈方案这是我在这个场景实践里最深刻的经验。2. 分布式事务主流方案与选型逻辑2.1 2PC/XA方案教科书正确实战却不好用两阶段提交2PC是最经典的分布式事务方案XA协议是它的标准实现。第一阶段prepare所有参与方把资源锁住、执行完SQL但不提交向协调者报告“我准备好了”协调者确认全票通过后再发commit指令任何一方失败则全员rollback。理论上看它保证了强一致。但实践里XA在互联网的高并发场景并不受欢迎原因有三一是prepare阶段要长期持有数据库锁并发一高锁等待成片出现性能急剧下降二是协调者本身成为单点协调者挂了所有参与者都卡在锁等待整个链路僵死三是XA依赖数据库驱动和连接池的深度支持常见的MySQL、MyBatis、Spring事务混用起来坑非常深。我见过有团队上了XA压测时TPS一旦超过阈值数据库锁等待直接拖垮核心服务最后黯然下线。所以对订单和库存这种高频交易链路我基本不建议首选2PC/XA。它更适合数据一致性优先级极高、并发量可控的内部系统比如跨行转账或者数仓同步而不是前端高并发写的交易服务。2.2 TCC方案Try/Confirm/Cancel的本质与三个经典坑TCCTry-Confirm-Cancel在2PC思路上做了业务化的改造每个参与方提供三个业务动作Try阶段做资源的检查与预留Confirm阶段真正执行业务Cancel阶段释放预留资源。以扣库存为例Try是检查库存并锁住数量Confirm是实际扣减Cancel是解锁释放。TCC把锁从数据库层挪到了业务层不再长时间占据连接灵活性优于XA。但TCC有三个必须处理的经典问题几乎是每个实践者都会踩到的空回滚、悬挂和幂等控制。空回滚是指某个参与者从未执行过Try却收到了Cancel命令必须识别并直接忽略悬挂是指Cancel先于Try到达网络超时重试导致Try后到达的数据会一直滞留幂等控制则要求Confirm和Cancel无论被调用多少次结果一致。这些问题不解决TCC就谈不上可靠。解决方案通常是为每个事务生成全局唯一的transactionId并在事务参与者本地记录执行状态未执行/已Try/已Confirm/已Cancel空回滚通过状态判断悬挂则在Try时检查是否已有Cancel记录。TCC强在灵活弱在开发量大——每个参与方都要手写Try/Confirm/Cancel三个方法业务侵入度高。订单和库存这种核心链路如果确定需要同步强一致且并发很高、对性能敏感TCC是比XA更值得考虑的选项但要有心理准备它的复杂度是“写代码时”就要还的。2.3 本地消息表与MQ事务消息最终一致的经典实践本地消息表是我个人非常推崇的方案尤其适合订单和库存中“异步可接受”的部分。核心思路在本地业务数据库里建一张消息表业务操作和写消息表放在同一个本地事务里。比如扣库存的同时往消息表插入一条“订单创建成功需要积分加10”的记录事务提交后消息才可见后台定时任务扫描这张表把status为pending的消息投递到MQ消费方处理成功后回调更新status为done。这里面的关键点是“业务操作和消息写入同库同事务”它保证了业务成功则消息一定存在消息存在则业务一定属于已提交状态——这是本地消息表能够实现可靠投递的基石。我见过很多第一次接触这个方案的人误以为消息表是辅助工具业务事务和消息插入分开执行结果业务提交了消息没插消息永远丢了方案就名存实亡了。MQ事务消息如RocketMQ的事务消息在本质上和本地消息表类似只是把“本地消息表”搬到了MQ服务端先发送半消息本地事务执行成功后再确认提交失败则回滚。用起来更简洁不需要自己维护轮询表和补偿线程。不过它要求MQ本身具备事务消息的能力且对消息中间件的依赖更深。如果你所在团队已经把RocketMQ作为基础组件我建议优先考虑事务消息如果技术栈里只有自建Kafka那本地消息表配合定时任务反而是更可控的选择。2.4 SAGA方案长事务场景的另一种选择SAGA是一种通过“正向流程逆向补偿”来保证最终一致的长事务模式。每个参与者只需要实现正向操作和对应的补偿操作正常流程按顺序执行任一环节失败则逆序调用补偿操作把已完成的动作撤回。订单库存场景里用SAGA的感觉就是“允许中间状态的存在”。比如完整的订单流程包含创建订单、扣库存、冻结余额、生成运单是一个跨多个服务的长链路SAGA会先创建订单、再扣库存如果后面冻结余额失败就反向把库存加回去、把订单取消。和TCC相比SAGA不需要Try阶段的资源预留开发量明显减少但代价是中间状态对外可见——用户在极端情况下能看到“订单已创建但库存还没扣”的短暂状态时序不好处理。订单库存这种同步性要求较高的场景SAGA用得不多但在旅行预订、机票酒店组合下单这种长时间跨度的业务里SAGA是主流。如果业务本身能容忍中间状态闪一下就恢复SAGA的性价比远高于TCC。2.5 方案对比速查表方案一致性强度开发成本性能影响典型适用场景2PC/XA强一致低依赖框架高长期锁资源内部系统、低频高一致场景TCC准强一致高三方法幂等空回滚悬挂中等业务锁高并发核心交易链路的同步扣减本地消息表最终一致中加表定时任务低异步通知、积分、短信、日志类MQ事务消息最终一致低依赖MQ低已有RocketMQ需异步解耦SAGA最终一致中正向补偿低长链路、可容忍中间状态选型时的经验法则发布会问用户“到底哪个方案好”的人往往还没想清自己的场景。先画一遍业务链路标出哪些步骤必须同步反馈、哪些可以异步再去套方案决策会迅速很多。3. 订单与库存场景的完整实操设计3.1 业务链路分析与方案“水泥配比”以一个典型的电商下单流程为例用户点击“提交订单”系统需要完成创建订单、扣减库存、锁定优惠券、生成支付单四件事。我的设计思路是把“创建订单扣减库存”放同步链路走TCC把“锁定优惠券、发放积分、发送通知”放异步链路走本地消息表。为什么要这么切因为扣减库存的失败必须立刻知道——如果库存不够用户应该马上看到“库存不足”而不是下单成功后再异步通知他订单挂了而优惠券、积分这些即使晚点处理对用户体验几乎无感。把刚性需求和柔性需求放在同一个方案里是对技术方案的基本尊重。很多事故的根源是把柔性需求硬做成刚性事务或者反过来把必须同步的扣减硬扛成异步投递最终都在极端场景下崩了。3.2 事务状态机与接口设计在TCC扣减库存的实现里我把每个库存事务的执行状态都记录下来核心接口设计如下。接口作用关键参数tryDecreaseStock(orderId, skuId, qty)检查库存并锁定数量orderId作为事务IDconfirmDecreaseStock(orderId, skuId, qty)实际扣减orderId 幂等标识cancelDecreaseStock(orderId, skuId, qty)释放锁定orderId 幂等标识状态机设计上每一笔锁定记录都包含一个status字段取值顺序为INIT→TRIED→CONFIRMED/CANCELLED。所有接口都要求幂等——库存服务本地维护一个以orderId为唯一键的执行记录表每次收到请求先查记录已处理则直接返回成功避免网络重试导致重复扣减。状态机的核心保障是状态转移的严格单向性INIT只允许到TRIEDTRIED只允许到CONFIRMED或者CANCELLED。假如网络抖动导致Confirm请求重发了10次状态已经在CONFIRMED直接返回成功即可不会多扣一次。这是TCC实践里我认为设计上最重要的一环顺序错了后面全是脏数据。3.3 可靠消息投递与消费的落地细节异步链路我采用本地消息表的方案具体操作流程如下。第一步业务操作与消息写表同库。在“订单创建完成”这个本地事务里插入订单记录的同时插入一条outbox_message记录字段包括message_id、biz_type、payload、status、retry_count、next_retry_time。这个事务提交后订单和消息一定同时存在。第二步定时任务扫描并投递。我用一个分布式定时任务每5秒扫描statusPENDING next_retry_time now()的数据把payload发送到MQ发送成功后把status更新为SENT。如果M Q发送异常本轮不更新状态等下一次扫描继续重试重试超过5次则置为DEAD_LETTER并触发告警。第三步消费端幂等处理。消费方处理消息时先用message_id查本地去重表如果已处理过直接ack否则执行业务逻辑并用message_id作为唯一键插入处理记录。这一步是防止“投递成功但ack丢失导致MQ重发”之类情况下的重复消费。第四步对账与修复。每天凌晨跑一个对账任务把outbox_message表里的SENT状态记录和MQ消费端的处理结果做比对发现不一致就触发补单。这个兜底机制很重要因为任何消息系统都不敢保证100%不丢定时对账是把“万一”限制在可控范围内。3.4 超时、重试与补偿参数的选择选参数这件事第一次做的人很容易掉进“拍脑袋”的坑。我的经验是try阶段接口超时设为3秒超过则认为失败并触发cancelconfirm和cancel的重试次数设为5次重试间隔使用指数退避1秒、2秒、4秒、8秒、16秒本地消息表定任务扫描间隔设为5秒重试次数上限5次超过进死信。这些参数不是凭空定的而是基于链路的P99延迟和数据库的锁等待情况估算而来。如果美团这种量级的场景3秒超时显然太慢如果要支撑普通电商的中等并发3秒已经可以覆盖绝大多数正常接口耗时。重点是参数必须可配置并配合监控指标超时率、重试次数分布、死信队列长度持续调优而不是设定一次就再也不动。4. 实战中的典型问题与排查实录4.1 悬挂问题Cancel先到了Try却后到有一次压测时发现库存服务里有大量INIT状态的锁定记录一直没被处理排查日志发现原因是tryDecreaseStock请求因为网络超时被调用方判定失败触发了cancelDecreaseStock但那个超时的Try请求其实在网络上多绕了一会儿最终还是到达了库存服务。此时Cancel已经到了状态置成了CANCELLED随后Try才到达按正常逻辑应该进入TRIED状态但这笔事务已经撤销了Try就成了“悬挂操作”。解决办法是在Try逻辑里增加一个判断如果当前事务已经存在CANCELLED记录直接拒绝Try并返回成功避免调用方继续重试。同时在Cancel逻辑里通过事务ID判断如果该事务从未执行过Try就标记为“已取消”但不做真实的库存释放——这是空回滚的处理。4.2 重复消息引发的库存多次扣减另一类高频事故出现在本地消息表的消费端。当时我们某个消费逻辑没有做幂等消息在极端情况下被MQ重投了两次消费端就执行了两次“积分扣减”用户积分直接变负。后来排查发现重投的原因是消费端在业务执行完成后、ack之前进程崩溃消息重新进入队列导致第二次消费。修复方案就是前面说的“message_id唯一键”方案消费开始时先插入一条processed_message记录插入成功再执行业务逻辑如果插入报错唯一键冲突说明已经处理过直接返回成功。这里有个细节两条操作必须在同一个数据库事务里否则可能出现“业务执行完了但去重表记录没写”的间隙重复消费依然会趁虚而入。4.3 消息丢失后如何定位恢复曾经有一批订单的积分加成了但是用户查询时却发现部分订单没有生成积分流水。定位过程分为三步先查outbox_message表发现有几条记录状态是PENDING但next_retry_time已经过去很久说明定时任务没有及时扫到再查定时任务的执行日志发现某台机器在执行扫描时报了数据库连接池满重试逻辑又没有生效最终确认是定时任务并发执行导致同一批消息被多台机器并发扫描互相抢锁后一部分被跳过。解决方案是给定时任务加分布式锁用数据库行锁或Redis的SETNX确保同一时刻只有一个实例在扫描同时把每批扫描的消息数量限制在1000条以内防止一次执行太久。这之后消息丢失率明显降低但即使这样我还是保留了每天凌晨的对账任务因为系统链路里任何一个环节都不敢100%保证不出错。4.4 数据最终不一致时的排查路径哪怕方案设计得再完整生产环境还是会偶发对账异常。我通常的排查路径是先看outbox_message表里消息状态与MQ消费端记录是否匹配不一致则定位到具体message_id再查库存服务的执行记录表确认try/confirm/cancel的调用顺序和时间点最后翻接口日志看是哪一次网络超时或重试导致了时序错乱。这条路径能把绝大多数问题定位到小时级别。我见过太多团队在数据不一致时第一反应是“改配置重试”结果越改越乱。正确的做法是先把“这个事务经历了哪些状态”完整还原再动手修复。所以在设计阶段我特别强调所有状态变更都必须记录时间戳和操作人或调用方这些日志到了排查时就是救命稻草。4.5 常见问题速查表问题典型现象排查思路解决办法悬挂有Try记录但事务已取消查Cancel是否先于TryTry到达时检查Cancel状态直接忽略空回滚收到Cancel但从未Try查调用链是否重复Cancel时判断无Try记录则标记取消即可重复扣减库存被多扣查幂等记录以事务ID做唯一键重复请求直接返回消息重复消费积分/通知重复查消息去重表消费前插入message_id唯一记录消息丢失业务缺失查outbox状态定时任务扫描重发配合对账任务兜底对账不一致数据偏差还原状态机完整时序按事务ID逐级核对try/confirm/cancel5. 对程序设计者的几点忠告踩过足够多的坑之后我对分布式事务最大的体会是不要一上来就想着“选一个框架解决所有问题”而是要把业务拆细分清哪些步骤是强一致刚需哪些是最终一致就足够。扣库存你先做TCC还是直接同步调用加事务消息完全取决于用户在下单瞬间必须看到什么结果异步通知和积分加成就没必要强行纳入强一致事务。另外一个很实用的原则是“能异步就异步”。很多团队在重构交易链路时习惯性把每个环节都包进一个大事务结果性能直线下降还引入了大量不必要的协调逻辑。实际数据是订单链路里大约70%的动作都是可以异步的真正需要同步反馈的只有库存扣减、支付状态校验等少数几个环节。把其余动作从强一致事务中剥离系统能轻松很多。最后无论你选了TCC、SAGA还是消息表请务必安排对账任务。它不是锦上添花而是保障最终一致的最后一道防线。我参与过的项目里但凡出过大问题的几乎都是因为“觉得方案完美所以没做对账”。分布式系统里没有100%把兜底做扎实心里才踏实。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑