分布式事务这个话题,只要做过订单、库存、支付这类交易链路的人,迟早都会撞上。我最早接触它是在一单“订单创建减库存”的业务改造里,单体应用拆成订单服务和库存服务,数据库一拆,原本一个本地事务能搞定的事情,突然变成两个库独立提交,要么订单建了库存没扣,要么库存扣了订单丢了。那段时间排查数据不一致,全靠手工写脚本对账,苦不堪言。这篇内容算是对我这些年分布式事务场景实践的一次梳理,把核心方案、选型思路、实操细节和踩过的坑都拉出来聊聊,适合正在做交易链路、被数据一致性问题折磨、或者准备引入分布式事务框架的同行参考。
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的本质与三个经典坑
TCC(Try-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作为事务ID |
confirmDecreaseStock(orderId, skuId, qty) | 实际扣减 | orderId + 幂等标识 |
cancelDecreaseStock(orderId, skuId, qty) | 释放锁定 | orderId + 幂等标识 |
状态机设计上,每一笔锁定记录都包含一个status字段,取值顺序为INIT→TRIED→CONFIRMED/CANCELLED。所有接口都要求幂等——库存服务本地维护一个以orderId为唯一键的执行记录表,每次收到请求先查记录,已处理则直接返回成功,避免网络重试导致重复扣减。
状态机的核心保障是状态转移的严格单向性:INIT只允许到TRIED,TRIED只允许到CONFIRMED或者CANCELLED。假如网络抖动导致Confirm请求重发了10次,状态已经在CONFIRMED,直接返回成功即可,不会多扣一次。这是TCC实践里我认为设计上最重要的一环,顺序错了,后面全是脏数据。
3.3 可靠消息投递与消费的落地细节
异步链路我采用本地消息表的方案,具体操作流程如下。
第一步,业务操作与消息写表同库。在“订单创建完成”这个本地事务里,插入订单记录的同时插入一条outbox_message记录,字段包括message_id、biz_type、payload、status、retry_count、next_retry_time。这个事务提交后,订单和消息一定同时存在。
第二步,定时任务扫描并投递。我用一个分布式定时任务,每5秒扫描status='PENDING' && 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秒,超过则认为失败并触发cancel;confirm和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是否先于Try | Try到达时检查Cancel状态,直接忽略 |
| 空回滚 | 收到Cancel但从未Try | 查调用链是否重复 | Cancel时判断无Try记录则标记取消即可 |
| 重复扣减 | 库存被多扣 | 查幂等记录 | 以事务ID做唯一键,重复请求直接返回 |
| 消息重复消费 | 积分/通知重复 | 查消息去重表 | 消费前插入message_id唯一记录 |
| 消息丢失 | 业务缺失 | 查outbox状态 | 定时任务扫描重发,配合对账任务兜底 |
| 对账不一致 | 数据偏差 | 还原状态机完整时序 | 按事务ID逐级核对try/confirm/cancel |
5. 对程序设计者的几点忠告
踩过足够多的坑之后,我对分布式事务最大的体会是:不要一上来就想着“选一个框架解决所有问题”,而是要把业务拆细,分清哪些步骤是强一致刚需,哪些是最终一致就足够。扣库存你先做TCC还是直接同步调用加事务消息,完全取决于用户在下单瞬间必须看到什么结果;异步通知和积分加成就没必要强行纳入强一致事务。
另外一个很实用的原则是“能异步就异步”。很多团队在重构交易链路时,习惯性把每个环节都包进一个大事务,结果性能直线下降,还引入了大量不必要的协调逻辑。实际数据是,订单链路里大约70%的动作都是可以异步的,真正需要同步反馈的只有库存扣减、支付状态校验等少数几个环节。把其余动作从强一致事务中剥离,系统能轻松很多。
最后,无论你选了TCC、SAGA还是消息表,请务必安排对账任务。它不是锦上添花,而是保障最终一致的最后一道防线。我参与过的项目里,但凡出过大问题的,几乎都是因为“觉得方案完美所以没做对账”。分布式系统里没有100%,把兜底做扎实,心里才踏实。