订单扣款成功了,库存却没能扣掉;退款单生成以后,账务系统的流水怎么都查不到。这种问题在单库时代几乎不会出现,可一旦把服务拆开、把库也拆了,类似的诡异现象就会三不五时地冒出来。我在接手的不少系统里都遇到过这个坎,每次排查到最后,都会落到同一个话题上:MySQL 分布式事务一致性方案到底该怎么选、怎么落地。
这篇文章不讲空泛的理论,就聊聊我实际项目里遇过的场景、用过的方案,以及那些明知道会踩还是踩了进去的坑。如果你正在做微服务拆分、分库分表,或者刚接手一套用了柔性事务的老系统,这篇文章应该能帮你少走不少弯路。
1. 订单和库存对不上的那天晚上:分布式事务是咋冒出来的
先从一个几乎每个做交易系统的人都经历过的场景说起。某个电商类项目里,订单服务、库存服务、支付服务各自拥有独立的数据库,一份用户下单的请求从前端打到订单服务,订单服务要写订单表、要远程调库存服务扣库存、要调支付服务生成支付单。
如果库存服务和订单服务共用一个库,一个本地事务就能搞定。但库都拆开了,原来那个BEGIN ... COMMIT就管不到别的库了。订单库的提交成功了,库存库那边可能在网络超时的前一秒才抛出异常,结果订单显示待支付,库存却已经被扣掉,或者反过来,库存扣了但订单没生成,用户过了五分钟又点了两下提交,库存直接变成负数。
这种状态在行业里有个很直接的名字:数据不一致。当时刚接手那个系统时,我第一反应是改接口重试,后来发现光靠重试根本堵不住口子。因为分布式事务要解决的是一个跨进程、跨数据库的原子提交问题,而 MySQL 本地事务的 ACID 里,原子性、隔离性都限定在“同一个数据库连接内部”,连接一旦跨越服务边界,原来的约束就全部失效了。
1.1 一个 PUT 请求引发的“两阶段式脑裂”
我记得第一次排查这类问题的时候,日志显示得很清楚:订单服务收到了前端请求,调库存服务扣减成功、返回 200,然后调支付服务时网关超时了。支付服务其实已经扣了钱,但这个响应迟到了 3 秒,订单服务等不到结果就把订单状态置为“下单失败”。用户退款、客服介入、仓库发现实物已出库但系统没单……每个环节都在喷脏数据。
后来我意识到,这种情况本质上和数据库崩溃恢复很像:要么两边都成功,要么两边都不生效,可服务之间的调用天然没有同一个“提交点”。这也正是分布式事务理论里所谓的协调问题:需要有一个协调者来决定“要不要一起提交”,并让所有参与者都执行这个最终指令。
1.2 从本地事务到全局事务,缺的到底是什么
单独看每个服务,数据库的隔离级别、锁机制都还能正常运转。但一旦把它们放到一笔完整的业务链路里看,就缺少了三样东西:
- 一个跨服务的事务协调者,用来统一发起提交或回滚;
- 一个能让各个库都能感知的事务状态存储,否则协调者本身挂了,没人知道该往前提交还是往后回滚;
- 一套幂等和补偿机制,因为分布式环境下消息可能重复投递、调用可能重复执行。
所以后来我养成了一个习惯:遇到“服务调服务”里的数据不一致,不是先怀疑代码逻辑,而是先问自己,这套链路到底有没有“回滚指令”的传递路径。如果答案是没有,那无论每个服务的本地事务做得多严谨,整条链路依然是裸奔的。
2. 分布式事务的真正硬骨头:CAP 取舍和两阶段提交的两难
要想把方案聊明白,得先把约束讲清楚。分布式事务没法像本地事务那样满足完整的 ACID,它必须在一致性和可用性之间做取舍。这背后的经典依据是 CAP 理论:在分区不可避免的前提下,你只能在一致性(C)和可用性(A)之间选一个优先。
单机关系型数据库为什么能严格满足 ACID?因为它根本不涉及网络分区,节点挂掉以后整个库都不能服务,也就不存在“分区间通信”的问题。但跨库事务不一样,协调者和参与者之间隔了网络,协调者发出“提交”指令后,某个参与者可能整好在那几毫秒内宕机,这时协调者如果坚持强一致,就必须阻塞等待,而这会拖垮整条链路的吞吐。
2.1 两阶段提交(2PC)为什么早期是标配、后来又挨骂
两阶段提交是分布式事务的“元老”方案,也是绝大多数协调器框架的底层模型。它把事务提交拆成准备阶段和提交阶段:
- 协调者向所有参与者发送“准备提交(prepare)”指令,参与者执行本地事务的写操作但先不提交,记录 undo/redo 日志,回复“准备完成”。
- 协调者收到全部“准备完成”后,再广播“正式提交(commit)”;如果任何一个参与者回复失败或超时,则广播“回滚(rollback)”。
这个逻辑很直观,但它有一个致命问题:一切都要等锁不放。参与者执行完 prepare 之后,行锁还得继续持有,等到协调者最终指令到达才能释放。万一协调者进程崩溃了,所有参与者都会一直等下去,连接池被挤爆,本地事务的回滚日志把磁盘塞满,整条链路不可用。
我在一个对账系统里就见过这种情况,某个环节的协调者节点 OOM,接着所有数据库连接全部卡在 prepare 状态,DBA 看到一堆Sleeping on commit的会话,只能手工 kill,但那已经是业务不可用的第十分钟了。
2.2 从 2PC 到 3PC,再到真正生产里的妥协
为了解决 2PC 的阻塞问题,理论界提出了 3PC(三阶段提交),在 prepare 前额外增加一个 canCommit 阶段,并且引入超时机制,参与者超时后默认执行回滚,避免无限期等锁。但在实际工程里,3PC 用得反而更少,因为它多了一轮网络开销,而且超时后默认回滚的规则在极端情况下依然可能和协调者的 commit 指令冲突,造成“部分提交”的脏数据。
生产环境里真正大量落地的,反而是把 2PC 的思想做进具体中间件,同时用各种补偿手段来消化极端场景。比如 MySQL 的 XA 协议,语法上支持跨库的全局事务,但实际跑起来你会发现它需要业务自己维护全局事务 ID、自己处理崩溃恢复,非常考验运维能力,所以业内真正直接裸用 XA 的场景不多。
提示:不要一听到分布式事务就先想到 XA。绝大多数业务场景需要的是最终一致性,而不是数据库层面的强一致。
3. 四个主流的落地方案逐个拆:XA、TCC、本地消息表与 SAGA
方案这块我会结合真实使用体验来聊。先给一张对照表,后面再展开细节。
| 方案 | 一致性类型 | 侵入性 | 吞吐影响 | 典型场景 |
|---|---|---|---|---|
| MySQL XA | 强一致 | 较高 | 高 | 短事务、跨库但量小的内部系统 |
| TCC | 最终一致偏强 | 高 | 中 | 资金类、需要明确回滚动作的业务 |
| 本地消息表 | 最终一致 | 中 | 低 | 异步链路、能接受延迟的订单与库存 |
| SAGA | 最终一致 | 中 | 中 | 长流程、多步骤编排的业务 |
3.1 MySQL XA:SQL 层面的“双库同时提交”
MySQL 从 5.0 开始就支持 XA 协议,它把 MySQL 本身作为一个资源管理器接入全局事务。用法很简单,先在一个连接上开启全局事务的 XA 分支,然后执行本地 SQL,再 prepare、commit。
XA START 'gtrid_001'; UPDATE inventory SET stock = stock - 1 WHERE sku_id = '1001'; XA END 'gtrid_001'; XA PREPARE 'gtrid_001'; XA COMMIT 'gtrid_001';另一个库的参与者用同样的gtrid_001执行自己那部分写语句,最后也要 prepare 和 commit。
听着很完美,但实际用起来有几个很深的坑。第一是XA 分支会长时间持锁,从 prepare 到全局 commit 之间,所有写入行都锁着,高并发下性能会断层式下跌。第二是崩溃恢复很麻烦,如果某个 MySQL 实例在 prepare 之后宕机,DBA 得手工查XA RECOVER,再决定手工 commit 还是 rollback,否则那些行锁会一直不释放。
所以我的结论是:XA 适合业务逻辑简单、跨库数量少、并发量可控的内部系统。只要链路里没有复杂的远程调用重试,XA 还能勉强撑住。一旦引入外部服务调用,比如调了个第三方支付接口,XA 就完全不适用了,因为第三方接口不会跟着你的全局事务一起 prepare。
3.2 TCC:Try、Confirm、Cancel 三段式手动补偿
TCC 是业务侵入性最强,但最灵活的一套方案。它把一个完整的分布式事务拆成三个动作:
- Try:预留资源,比如冻结库存、锁定账户余额;
- Confirm:确认执行业务,比如把冻结的库存真正扣减、把锁定的余额真正扣款;
- Cancel:释放预留资源,比如解冻库存、退回锁定余额。
public class InventoryTccAction { // Try 阶段:预扣库存,写入库存冻结表 public boolean tryDeduct(Long skuId, int count) { return inventoryMapper.freezeStock(skuId, count); } // Confirm 阶段:把冻结库存转成实际扣减 public boolean confirmDeduct(Long skuId, int count) { return inventoryMapper.confirmFreeze(skuId, count); } // Cancel 阶段:解冻库存 public boolean cancelDeduct(Long skuId, int count) { return inventoryMapper.unfreezeStock(skuId, count); } }TCC 的好处是 Cancel 阶段可以由业务自定义,不依赖数据库回滚日志,所以它能覆盖到调用第三方接口这种场景。但它的问题也在这里:每个业务都要写一套完整的 Try/Confirm/Cancel,逻辑稍复杂,后台管理成本直线上升。
我见过不少团队直接把 TCC 的三个方法写成“先 insert,再 delete,再反向 update”,那其实只实现了“手动回滚”,没有实现“预留资源”的精髓。真正的 TCC 应该在 Try 阶段就把资源控住,Confirm 和 Cancel 只能二选一执行,且必须保证幂等。
这里有两个 TCC 特有的经典坑:空回滚和悬挂。空回滚是指 Try 阶段还没执行成功、Cancel 却已经先到了,这时候 Cancel 不能做任何回滚操作,否则会把不属于这个事务的数据误释放;悬挂是指 Cancel 空回滚之后,Try 的迟到请求又来了,这时候 Try 必须拒绝执行,否则预留的资源不会被任何后续操作释放。解决思路是引入一张事务控制表,记录每个事务分支的执行状态,Try、Confirm、Cancel 在执行前都先去查状态、写状态。
3.3 本地消息表:eBay 架构思路在 MySQL 里的朴素实现
本地消息表是我个人最推荐优先考虑的方案,因为它的成本低、逻辑直观、可靠度却不低。核心思路是把“写业务数据”和“写消息记录”放在同一个本地事务里,然后通过一个后台任务把消息表里的记录投递出去。
假设下单时要扣库存:
-- 同一本地事务里同时写订单表和消息表 BEGIN; INSERT INTO order_table (order_id, user_id, sku_id, status) VALUES ('O001', 'U1', 'S1001', 'PENDING'); INSERT INTO outbox_message (msg_id, biz_type, payload, status, retry_count) VALUES ('M001', 'ORDER_CREATED', '{"orderId":"O001","skuId":"S1001"}', 0, 0); COMMIT;后台的投递任务定时扫描outbox_message,把 status=0 且超时未投递的记录捞出来,调用库存服务:
查询:SELECT * FROM outbox_message WHERE status = 0 AND next_retry_time <= NOW() LIMIT 100; 投递:调用库存服务扣减接口; 成功:UPDATE outbox_message SET status = 1, delivered_time = NOW() WHERE msg_id = 'M001'; 失败:UPDATE outbox_message SET retry_count = retry_count + 1, next_retry_time = DATE_ADD(NOW(), INTERVAL 30 SECOND) WHERE msg_id = 'M001';这套方案有意思的地方在于,它在业务数据库内部开辟了一张“消息通道”,业务事务和消息写入天然同生共死,不存在“业务成功但消息丢失”的中间状态。下游系统为了保证不重复扣库存,还要在库存表上做幂等控制,比如记录一个deduct_record表,用order_id做唯一键。
它的缺点也很明显:消息表会和业务表共享数据库压力,数据量大了以后定时扫描会变慢;而且消息的消费是异步的,存在秒级到分钟级的延迟,不适合同步强一致场景。
3.4 SAGA:把长事务拆成一段段可补偿的短流程
SAGA 模式最初来自数据库理论里的长事务论文,核心思想是把一个分布式事务切成多个本地事务,每个本地事务有自己的提交和回滚动作,前一个事务提交后,下一个事务才开始执行。如果中间某个事务失败,就反向依次调用之前每个事务的补偿动作。
SAGA 比 TCC 好的一点是,它不需要 Try 阶段预留资源,业务实现更接近正常的接口调用。但代价是隔离性差:在补偿发生之前,其他并发事务可以看到中间状态的半成品数据。所以 SAGA 通常都要配合“状态机 + 最终对账”来兜底。
比如一个旅行业务,包含订酒店、订机票、租车三段操作,每个操作都有对应的 cancel 接口。当机票订失败以后,SAGA 编排器会依次调用酒店取消、租车取消的接口。这时候如果酒店取消接口本身又失败了,还得靠人工介入或定时对账任务去补偿。
我在某个外包项目里给客户做过一版 SAGA,当时把每一段事务的补偿接口写成配置化,后台用一个状态机表驱动,compensation 失败就自动进入重试队列,重试 5 次仍然失败就发告警给值班群。这套东西运行了一年多,虽然偶尔还是会有补偿失败,但因为有对账任务的存在,最坏情况也只是延迟修复,不会再出现“订单已支付但库存没扣”这种不可逆问题。
4. 工程落地时的魔鬼细节:连接管理、幂等表与对账机制
方案总是看起来很美,真正到了项目里,决定成败的往往是那些不起眼的工程细节。我把实操里踩过的、看到过的坑集中整理一下,每一条后面都附了解决思路。
4.1 连接池和 ThreadLocal 绑定事务,一不小心就串了
本地事务和线程绑定是 Java 体系中默认的模型,一个线程里的多个数据操作默认共享同一个数据库连接。到了分布式事务框架里,事务上下文往往要靠 ThreadLocal 传递,如果代码里再混用了异步线程池,子线程拿不到父线程的事务上下文,就会出现“明明标注了同一个事务,却没在同一个连接里执行”的情况。
我处理过的一个线上事故是:订单回调里用了@Async线程池去调库存服务,回调的主事务还没来得及提交,异步线程里的查询居然已经读到了旧数据。后来统一改用TransactionContext显式透传,并且强制异步操作里不允许开新事务,才把这个问题压下去。
注意:分布式事务框架里,连接持有时长直接影响锁范围。尽量把远程调用放在事务外部,只在事务内部做纯数据库写操作,避免出现“事务调接口、接口查同一行”的循环等待死锁。
4.2 幂等表:分布式环境里唯一靠谱的“防重复”手段
不管你是用本地消息表还是 TCC,幂等设计都是刚需。一个接口在网络抖动时被重复调用,是分布式系统里的常态,如果没有幂等保障,扣款操作重复执行两次,账就平不了。
最朴素也最有效的做法是建一张幂等表:
CREATE TABLE idempotent_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT '业务类型', biz_id VARCHAR(64) NOT NULL COMMENT '业务唯一标识,如订单号', status TINYINT NOT NULL COMMENT '0-处理中 1-已完成', response TEXT, created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL, UNIQUE KEY uk_biz_type_biz_id (biz_type, biz_id) ) COMMENT '幂等控制表';每次处理请求时,先 insert 一条 status=0 的记录,如果 insert 报唯一键冲突,说明是重复请求,直接返回之前记录的结果,或者拒绝处理。这个表可以放在核心业务库,也可以单独拆到一个公共库,关键在于 biz_id 的唯一性一定要保证。
4.3 对账任务:最终一致性方案的最后一道安全网
凡是做最终一致性的系统,我建议都必须配套对账任务。所谓对账,就是定期比对两个系统的数据,把不一致的记录捞出来,重新触发补偿或者发告警。没有对账兜底的最终一致性,就像没有灭火器的机房,平时看着没事,出事就是大事。
我习惯的设计是这样:
- 每隔 10 分钟捞一次双方的关键流水进行比对;
- 对账字段以时间戳、状态、金额这类核心信息为主,不要整行比对,否则字段太多反而容易误报;
- 不一致记录进入待处理表,由补偿任务按预设的规则自动修复,修复不了的转人工窗口。
很多团队早期不做对账,是因为觉得“方案已经保证了最终一致”。但分布式系统里还有个幽灵叫“进程崩溃后没有留下任何补偿指令”,比如协调者所在节点直接断电,既没 commit 也没 rollback,中断的操作只能靠对账去发现。所以在我看来,对账不是可选项,而是必备项。
4.4 全局锁与死锁:事务拆得越细,越要小心锁顺序
分布式事务常会用全局锁来防止并发事务读取中间状态。比如 TCC 的 Try 阶段冻结了库存,其他事务读库存时看到的是“冻结中”,如果没有全局锁,另一个事务可能恰好把剩余库存全部扣完,等 Confirm 一来,库存变负数。
全局锁的手段无非是数据库行锁、Redis 分布式锁或者 ZooKeeper 的临时节点。我用得最多的是数据库行锁加唯一键,简单可靠。但要特别警惕锁顺序问题:两个事务 A 和 B 如果分别持有不同表的锁,又同时去获取对方的锁,就会形成死锁。解决方案是让所有事务按照同一个固定的顺序去访问资源,例如先锁订单表、再锁库存表,永远不反过来。
5. 拿捏一致性需求强度:先分清强一致和最终一致再选型
方案没有绝对的好坏,只有和场景匹配不匹配的问题。我在项目里判断一个链路该用哪种方案,通常先回答三个问题:
- 这笔业务如果出现短暂不一致,用户能不能感知?
- 不一致状态能不能靠后台补偿自动收敛?
- 如果补偿失败,损失边界是多少?
不同答案会导向不同的选型。
5.1 强一致需求:宁可吞吐降一半也不能读到脏数据
资金类、订单状态类场景,往往不允许出现“已支付但订单自始至终不存在”这种不可逆状态,这类需求就要用 TCC 或 XA。XA 适合两个库的事务,TCC 适合带第三方接口的跨系统事务。这类系统的核心代价是性能:强一致意味着锁、等待、协调,吞吐量必然换不来单库那种效率。
我做过一个模拟项目,把一套原来的“定时补偿”方案改造成 TCC,冻结金额、确认扣款、取消退款三段式上线后,接口的 TP99 从 50ms 涨到 180ms,但换来的是资金侧半年没有一笔错账。这笔账我认为是划算的。
5.2 最终一致需求:优先考虑本地消息表和 SAGA
非实时业务的订单创建、积分发放、通知发送、用户资料同步等场景,通常能接受几秒到几分钟的延迟。这类需求我优先选本地消息表,因为它代码量小、不依赖额外中间件,而且事务边界特别清晰。如果消息总量大、对扫描性能有压力,再升级到事务消息方案,也就是把本地事务消息交给消息中间件来管理,业务只负责发送和消费,可靠性由中间件保证。
SAGA 则适合那些步骤多、链路长的场景,尤其是每个子步骤都有现成的 take 和 compensate 接口的时候。它可以让每一步的事务都短小精悍,避免长时间持锁,但一定要配好补偿失败后的告警和人工入口。
5.3 一个能少走弯路的兜底原则
我现在每个项目都会坚持一个原则:能不强一致就不强一致,能用异步补偿就不用同步阻塞,能靠对账修复就不靠事务回滚。很多人一听到分布式事务就紧张,总觉得方案越重越好,其实大部分业务完全可以用消息 + 幂等 + 对账三件套来解决,成本和稳定性的综合体验反而更好。
这套三件套的思路是我在实际项目里反复打磨出来的。最早我也迷信过把一堆分布式事务框架引进来,结果配置复杂、排障困难,线上故障反而变多了。后来收敛到“本地消息表 + 唯一键幂等 + 定时对账”,系统清爽了不少。以后再遇到需要分布式事务的场景,我会先拿这几条按一遍:有没有可能单库解决?能不能接受最终一致?有没有现成补偿接口?每答完一题,方案基本就浮出水面了。
关于 MySQL 分布式事务一致性方案,我能分享的核心经验大概就是这些。如果你手上正好在做类似的拆分改造,建议先别急着上框架,理清楚业务对一致性强度的真实需求,再决定走哪条路。等你的系统跑过几次线上故障、扛过大促流量之后,会明白一个道理:分布式事务的价值不在于让系统永远不出问题,而在于出了问题以后,它能不能把损失控制在你能接受的范围内。