做后端开发的这些年,只要业务量一上来,事务这块迟早要踩一遍坑。尤其是当你从单库单服务的架构,走向微服务化、走向多数据源的时候,你会发现过去在本地事务里理所当然的ACID,突然就变得遥不可及了。原因很简单:跨服务、跨数据库的操作,没有一个“总事务管理器”能像单库事务那样帮你统一回滚。这个问题在分布式系统里是核心挑战,今天我就围绕“分布式事务一致性”这个主题,把里面的理论、方案、实战经验和踩坑记录一次讲透。
这篇文章适合谁看?如果你正在做微服务改造,或者你的系统已经开始拆分数据库、拆分服务,而你正在为订单、库存、账户这些跨模块的数据一致性问题头疼,那么这篇文章应该能帮你省下不少试探的时间。我会从ACID为什么失效讲起,再对比主流的分布式事务方案,最后用一个订单系统的完整改造案例,带着你从选型到落地走一遍,并整理生产环境中常见的故障排查思路。
1. 分布式事务到底难在哪:先拆开ACID看它为什么会失效
1.1 本地事务为什么能用:一套日志加一把锁
在单库单机的架构里,事务能保证ACID,靠的是两件东西:undo/redo日志和数据库锁(包括MVCC版本链)。当你在一个数据库连接里执行begin、update、commit或rollback时,数据库内部通过WAL(预写日志)把操作的痕迹记录在磁盘上,崩溃了可以恢复,回滚了可以用undo日志还原,同时用行锁、间隙锁控制并发,避免别人读到中间状态。
你可以把本地事务想象成一个厨师在自己的后厨里做一桌菜:所有食材都在他自己冰箱里,所有锅灶都是他自己的,他炒糊了一道菜,可以直接倒掉重来,没有协调成本。但一旦食材要从隔壁餐馆借,锅也要去隔壁用,他就没法单方面决定“这桌菜到底成不成功”了。
1.2 跨服务和跨库之后,ACID四个特性各自是怎么塌的
当操作拆成多个服务、多个数据库之后,ACID的每个特性都面临各自的挑战。
原子性(Atomicity)首先出问题。本地事务里,要么全部提交,要么全部回滚,数据库帮你兜底。但跨服务时,A服务的数据库已经提交了“扣减库存”,B服务的数据库却因为网络超时没执行“扣减余额”,你没有办法让A服务再把已经提交的数据撤回。这里的关键问题是:每个服务的事务是独立提交的,没有一个全局协调者能控制所有参与方的最终决定。
一致性(Consistency)也变味了。本地事务的一致性约束,比如外键、唯一索引、CHECK约束,由数据库强制执行。跨服务之后,A库和B库各自只能约束自己的数据。比如A库刚扣了库存,B库还没来得及记账,从全局看数据就处于“不一致”的中间状态。哪怕你加了补偿逻辑,这个中间状态仍然会存在一段时间。
隔离性(Isolation)同样失去保障。本地事务的隔离级别控制着并发事务之间的可见性。跨库之后,另一个请求可能刚好读到“库存扣了但订单还没完成”的中间数据。如果这个请求触发了一些统计逻辑或对账逻辑,结果就会偏差。
持久性(Durability)相对好些,但也不是没有问题。数据提交到A库后,如果B库的写入因为节点宕机失败,持久化的结果同样处于分裂状态。
注意,我这里的“失效”是指标准的单库ACID语义失效,不是说跨服务之后绝对不能保证一致性,而是你要引入别的机制来重新建立约束。
1.3 CAP与BASE:先接受“强一致不可能”,再谈最终一致
理解分布式事务绕不开CAP理论。在分布式环境下,网络分区(P)是必然发生的,你不做任何处理,消息就是可能丢、可能迟、可能乱。那么在P发生的时候,你在C(强一致)和A(可用)之间只能选一个。
分布式事务的核心思路,其实就是:在允许网络分区存在的前提下,想办法逼近“一致性”这个诉求,要么牺牲可用性做同步协调(2PC方向),要么牺牲实时一致性换最终一致(消息和补偿方向)。
于是就有了BASE理论:基本可用(Basically Available)、软状态(Soft State)、最终一致(Eventually Consistent)。我个人的理解是,BASE不是在否定ACID,而是把“一致性”这个目标从“任何时刻全局一致”放宽到“在一段窗口内允许不一致,但最终会达到一致”。
你可以把最终一致想象成两地的对账系统:你给异地转了一笔钱,转账瞬间,两个账户的余额肯定不同步,但经过银行后台的清算、对账,过一段时间两边账目会对齐。分布式事务设计的绝大多数方案,追求的都是这个“最终一致”,而不是分布式版本的强一致。
2. 四大主流方案横向对比:2PC、本地消息表、事务消息、TCC与SAGA
2.1 两阶段提交和三阶段提交:教科书给了答案,但生产要打折扣
两阶段提交(2PC)是最经典的分布式事务协议。它引入一个协调者角色,第一阶段向所有参与者发送prepare请求,参与者执行本地事务但不提交,并把可以提交的“许可”报告给协调者;第二阶段协调者根据所有参与者的反馈,决定提交或回滚。
这个方案在理论上很美好,但生产环境落地痛点也很明显:
- 同步阻塞:第一阶段prepare之后,所有参与者资源被锁住,只要有一个参与者网络慢或宕机,整个事务就一直挂着。我在实际项目里见过一例,一个2PC协调者等一个库的prepare响应等了30秒,结果所有带锁事务全部积压,直接把数据库连接池打爆。
- 协调者单点:协调者如果挂了,整个事务卡在未知状态,所有参与者既不敢提交也不敢回滚。
- 脑裂问题:第二阶段协调者发出commit后宕机,一部分参与者收到了commit,一部分没收到,两边数据就永久不一致了。
三阶段提交(3PC)在2PC基础上增加了超时机制和准备阶段,降低了阻塞风险,但核心问题没有消除,而且实现复杂度更高。所以我的结论是,这两个协议在看源码和论文时理解价值很大,但一般业务团队不要轻易自研,哪怕引入现成的分布式事务中间件,也要先评估好锁冲突和可用性损失。
2.2 本地消息表:最朴素但很好用的可靠事件方案
本地消息表的思路很巧妙:把“业务操作”和“发送消息”放进同一个本地事务里。
以订单服务和库存服务举例:
- 订单服务在自己的库中创建订单,同时往消息表插入一条“扣减库存”的消息,这两个操作在一个本地事务中完成。
- 消息表里的事务提交后,通过一个后台定时任务或进程,把消息投递到MQ。
- 库存服务消费消息,完成库存扣减,然后回调或发送确认,订单服务的定时任务把已确认消息删除。
这个方案的好处是简单、理解成本低、不依赖特定中间件,只要能保证“业务表+消息表”在同一个库同一个事务里写成功即可。缺点也很明显:消息表耦合在业务库中,长期运行需要定期清理,投递依赖定时任务,存在一定延迟,而且消息投递时如果MQ挂了,需要手动恢复。
提示:本地消息表的设计核心是“最终一致”,它对消息的可靠性要求极高,所以定时扫描任务一定要做好异常告警,否则消息卡在表里是不会被发现的。
2.3 事务消息:把“发送消息”本身也变成事务的一部分
事务消息可以理解为本地消息表的一种“中间件版实现”。它的典型代表是RocketMQ的事务消息机制。
过程是这样的:
- 生产者先发送一条“半消息”,这条消息对消费者不可见。
- 然后生产者执行本地事务。
- 本地事务成功,生产者提交半消息,消费者才看到这条消息;如果本地事务失败,生产者回滚半消息。
- 如果半消息长时间没有收到提交或回滚指令,MQ会反向询问生产者“这个事务到底怎么样了”,这叫回查。
这个方案把“写消息表+定时扫描”的活转移给了消息中间件,业务代码更干净。但也有一个容易被忽略的坑:回查机制要求生产者端实现一个反查本地事务状态的接口,如果这个接口实现不好、查不到状态,消息会一直滞留,最终被堆积。
2.4 TCC与SAGA:用业务补偿来对抗数据不一致
TCC(Try-Confirm-Cancel)是一种服务化的柔性事务方案,它要求每个参与事务的服务提供三个接口:
- Try:预留资源,比如冻结库存、冻结余额。
- Confirm:确认执行业务,比如真正扣减。
- Cancel:取消执行,比如释放冻结资源。
TCC框架会在全局事务流程里依次调用所有参与方的Try,全部Try成功后再逐个调用Confirm;如果任何一个Try失败,就反向调用所有已经Try成功的参与方的Cancel。
这个方案的优点是比较通用,能处理比较宽泛的业务场景;缺点是侵入性极强,接口设计要显式考虑冻结态和补偿态,业务代码复杂度明显上升。比如一个简单的账户扣款,本地事务里一个update就完事,换成TCC后你得写冻结、确认、解冻三套逻辑。
SAGA则是一个长事务模式,核心思想是把一个大事务拆分成一系列小事务,每个小事务都有对应的补偿事务。比如订票、订酒店、订车这三个动作,一步成功之后如果后面失败,就反向调用前两步的补偿操作(取消订票、取消酒店)。
SAGA与TCC最大的区别是:TCC的Try阶段会做资源预留,SAGA则是直接执行真实业务操作,失败后再补偿。这意味着SAGA在正常路径上效率更高,但补偿逻辑必须有能力“撤销已经发生的真实影响”。
我把这几个方案放在一起,做一个直接对比:
| 方案 | 一致性强度 | 代码侵入度 | 对中间件依赖 | 适用场景 |
|---|---|---|---|---|
| 2PC/3PC | 强一致(理论) | 高 | 高 | 银行核心转账、强一致且低并发场景,生产落地需谨慎 |
| 本地消息表 | 最终一致 | 低 | 低(普通MQ即可) | 内部系统、对实时性要求不高的异步场景 |
| 事务消息 | 最终一致 | 低 | 高(需支持半消息的MQ) | 跨服务消息通知、异步解耦场景 |
| TCC | 柔性事务,接近强一致 | 高 | 中(需要事务框架) | 账务敏感场景,资源预留模式 |
| SAGA | 最终一致 | 中 | 中(需要编排框架) | 长业务流程、跨多系统编排场景 |
2.5 方案选型逻辑:先看业务能不能接受最终一致
我做过不少分布式系统的选型工作,总结下来的判断原则其实就两条:
第一条,能不进分布式事务就不进。如果能把相关的表放在同一个库里,就尽量放同一个库;如果能合并服务,就尽量合并服务。分布式事务是复杂度的放大器,能用架构规避就是赢。
第二条,如果一定要分布式事务,先问业务方:能接受多长时间的最终一致?如果业务要求强一致,且并发量不高,可以考虑TCC或者甚至2PC方向;如果业务天然允许异步,比如订单通知、积分累积、日志同步,那本地消息表和事务消息就是首选。
我见过很多团队一上来就选了最重的方案,结果开发和运维成本爆炸。比如一个简单的跨服务用户积分服务,其实用本地消息表就够了,非要去套TCC框架,所有参与方都要改接口加冻结字段,排期翻倍,收益几乎为零。这就是典型的过度设计。
3. 订单系统实战:从选型到落地的完整改造过程
3.1 背景与目标:模拟项目中遇到的真实困境
两年前我参与过一个某电商平台的订单中心改造,业务链条大概是这样的:用户下单后,订单服务要创建订单,库存服务要扣减库存,账户服务要锁定并扣减用户余额。最开始所有表都在同一个数据库里,服务也是单体,那时候用一个本地事务就能覆盖下单、扣库存、扣款三个操作。
后来业务量涨了,库存服务拆分出去独立成库,账户服务也独立成独立系统,问题就来了。原代码里是顺序调用三个服务:先调库存接口,再调账户接口,最后写订单库。每个接口都可能超时,超时后程序做了try-catch,然后尝试调用前置接口的“反查”去补偿。结果生产环境还是频繁出现订单创建了但库存没扣、或者钱扣了但订单状态还是待支付的情况。数据对不上的工单,每天都要人手工去修。
改造的目标很明确:
- 下单链路达到最终一致性,允许短暂不一致窗口,但最终库存、余额、订单状态要收敛。
- 所有关键写操作幂等,不因重试产生重复扣款或重复扣库存。
- 调度链路要有一整套监控、对账机制,出了问题能快速定位自动修复。
3.2 方案选型:为什么选择了本地消息表加幂等消费
当时我们对比了TCC、事务消息和本地消息表三条路线。
TCC首先被否决,原因是上下游有太多系统不愿意配合改造,特别是账户服务是另一个团队维护的,让他们提供冻结、确认、解冻三套接口,对方明确表示排期排不上,而且现有账户模型根本没有“冻结金额”这个字段。
事务消息其实是最理想的,因为我们的MQ本身支持半消息机制。但有一个历史包袱:订单服务里的数据要同时同步给老系统的解析程序,老系统在数据库层面直接读订单表,绕过MQ,导致消息表还得保留。既然消息表已经存在了,再引入事务消息反而两套机制并存,维护成本更高。
最终选了本地消息表加定时投递,下游幂等消费的方案。理由很简单:
- 改造成本低,只在订单库加一张消息表,业务代码里把“写订单+写消息”放进一个本地事务。
- 不要求库存服务和账户服务改接口,他们只需要保证消费逻辑幂等。
- 排查直观,消息表随时能查,消息流转状态一目了然。
注意,这个选型能否成立有一个前提:下游服务的消费接口必须真正实现幂等。如果下游无法幂等,本地消息表和事务消息都会因为重复投递而产生重复数据。我当时花了很多精力和下游团队确认幂等逻辑,这是整个改造里最难推进的部分。
3.3 核心代码实现:消息表结构、事务写法、投递逻辑
先看消息表结构,这张表放在订单服务的数据库里:
CREATE TABLE `order_msg_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_id` varchar(64) NOT NULL COMMENT '业务主键,幂等键', `msg_type` varchar(32) NOT NULL COMMENT '扣库存/扣款', `payload` text NOT NULL COMMENT '消息内容', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待投递 1已投递 2已完成', `retry_count` int(11) NOT NULL DEFAULT '0', `next_retry_time` datetime NOT NULL, `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_msg_type` (`order_id`, `msg_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个表的关键设计有两个:一是设置了**(order_id, msg_type)唯一键**,防止同一订单同一类型消息重复插入;二是next_retry_time字段,让定时任务按时间窗口扫数据,避免每次都要全表扫描。
然后是订单创建的事务逻辑,核心要点在同一个事务里写业务表和消息表:
@Transactional(rollbackFor = Exception.class) public void createOrderAndSendMsg(OrderRequest request) { // 1. 创建订单 orderMapper.insert(customerOrder); // 2. 创建库存消息 orderMsgLogMapper.insert( new OrderMsgLog(request.getOrderId(), "STOCK_DEDUCT", buildStockPayload(request), 0, 0, now)); // 3. 创建余额扣减消息 orderMsgLogMapper.insert( new OrderMsgLog(request.getOrderId(), "BALANCE_DEDUCT", buildBalancePayload(request), 0, 0, now)); }由于这两步处于同一个本地事务里,只要事务提交,订单和两条待投递消息就同时落库了;事务回滚,两边都消失。这就把“消息丢失”的问题消灭在了起点。
接下来是定时投递任务。我用的是固定线程池轮询,每30秒扫描一次待投递的消息,把超时的投递到MQ:
public void scanAndDispatch() { List<OrderMsgLog> pendingList = orderMsgLogMapper.scanPending( now, btn); for (OrderMsgLog msg : pendingList) { try { boolean sent = mqTemplate.send("order-topics", msg); if (sent) { orderMsgLogMapper.updateStatus(msg.getId(), 1); } } catch (Exception e) { log.error("消息投递失败 msgId={}", msg.getId(), e); orderMsgLogMapper.increaseRetry(msg.getId()); } } }这个任务看起来简单,但有一个容易踩的坑:不要把状态马上改成“已投递”。因为MQ投递成功后,下游消费还有可能失败,消息会重投。我这里的做法是把“已投递”作为一个中间状态,下游处理成功了会发确认,确认后才标记“已完成”。
下游消费端的核心逻辑是幂等消费。以库存服务为例:
public void consumeStockMsg(StockMsg msg) { // 1. 判断这个订单是否已经处理过 StockLog stockLog = stockLogMapper.findByOrderId(msg.getOrderId()); if (stockLog != null) { log.info("重复消息,跳过 orderId={}", msg.getOrderId()); return; } // 2. 执行库存扣减 int updated = stockMapper.tryDeduct(msg.getSkuId(), msg.getQty()); if (updated == 0) { throw new RuntimeException("库存不足"); } // 3. 记扣减流水,用于幂等和后续对账 stockLogMapper.insert(new StockLog(msg.getOrderId(), msg.getQty())); }幂等判断最好依赖查询流水表,而不是先更新再判断。因为你先更新了库存再查流水,如果事务回滚,不会查到这个流水;但如果你不建立流水就扣减库存,那消息重复消费时库存会重复扣减。流水表在这里同时承担了“幂等键”和“审计日志”的作用。
3.4 上线前要做的事:幂等约束、索引设计、监控指标
改造上线之前,我强烈建议把下面几件事做扎实,不然后面会一直在救火。
第一,给业务表和数据表都加上唯一约束。比如下游库存流水表、余额流水表,都要给(order_id, msg_type)建唯一索引。这比程序里先查询再判断的幂等方案更可靠。我在生产环境见过太多“明明先查了没查到,然后并发插入”的例子,数据库的唯一索引才是最后的防线。
第二,定期清理消息表。本地消息表会一直累积数据,如果不清理,半年后可能就是几千万条,扫描会越来越慢。我的做法是保留最近30天的数据,已完成的记录定期归档到历史表,每天凌晨跑一个清理任务。
第三,监控指标要提前规划。至少要盯四个指标:待投递消息数、投递失败率、下游消费失败数、最大消费延迟。这四个指标反映的是消息链路健康度。我把它们接进了现有的监控平台,并设置了三个告警级别:待投递积压超过1000条就警告,超过5000条就紧急告警。实际运行帮了大忙,有次一个下游服务发版改崩了,10分钟不到我就收到了延迟告警,赶在用户投诉之前把问题定位了。
4. 常见问题与排查技巧实录:反复踩过的那些坑
4.1 消息重复投递导致重复扣款,怎么彻底解决
本地消息表方案天然会重复投递。定时任务扫描时,如果网络闪断导致MQ实际收到了消息,但我们的代码没收到确认,那么这条消息状态仍然“待投递”,下一秒又会发一遍。下游如果没有幂等保障,重复扣款就发生了。
我的排查经验和处理思路是:
先看有没有流水记录。如果库存流水或余额流水里有重复数据,说明幂等逻辑没有拦截住。常见原因有两个,一个是唯一索引没建,另一个是代码里先做了业务更新,后做幂等校验,而且校验发生在事务之外,导致并发穿透。
修复方案很简单:所有消费逻辑先查流水,流水存在就直接返回,流水不存在才执行业务,业务和写流水必须在一个本地事务里。这样即使重复消息进来了,也会被流水表的唯一索引挡住。
4.2 消息一直卡在待投递状态,怎么定位
这种情况我在生产里遇到好几次,表现是消息表的status一直是0,next_retry_time已经过了,但就是不发出去。
排查方向:
- 先确认定时任务进程是不是活着的。我们有段时间因为容器内存配置的问题,定时任务线程池被耗尽了,任务看起来“在跑”,但实际已经没有新线程执行扫描了。
- 再看消息体是否反序列化失败。比如消息payload里塞了BigDecimal类型,生产者序列化时是字符串,消费者反序列化时改了字段类型,就会在投递前的转换环节直接抛异常,被catch吞掉,只记录了一条日志。
- 最后看是否死循环。如果某条消息投递后返回成功,但更新状态那一步事务冲突,状态又回滚成0,这条消息会反复投递,看起来一直“待投递”。
提示:我建议扫描任务里加一个“连续失败次数”的保护机制,单条消息重试超过10次就置为“死亡”状态,不再参与投递,只发告警让人工介入。不然一条坏消息会反复消耗线程池,拖垮整条消息链路。
4.3 数据不一致,但消息都处理完了,怎么发现问题
消息链路偶尔会出现一种隐蔽问题:所有消息都消费成功了,但业务数据依然不对。这种情况通常是下游服务在处理时把数据写到了错误的状态,或者别的老链路还在写同一份数据。
我的做法是设计一个对账任务,定期检查订单状态、库存扣减流水、余额流水三者之间的关系。对账规则例如:每个已支付订单必须存在一条对应的库存扣减流水和余额扣减流水;库存扣减总量和订单需求总量需要一致;余额扣减总额和订单支付总额需要一致。
对账任务发现差异后,不要自动修复,只生成差异报告并告警。自动修复风险太高,尤其账务相关,一旦修错比不修还严重。人工根据差异报告定位原因,再走手动补偿流程。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查步骤 | 处理方案 |
|---|---|---|---|
| 消息重复消费导致数据重复 | 消费逻辑未幂等或唯一索引缺失 | 查业务流水表有无重复记录 | 加唯一索引,消费逻辑先查流水再执行 |
| 消息一直待投递不发出 | 定时任务故障、序列化异常、状态更新失败 | 查任务日志、手动模拟投递、看消息状态变化 | 修复任务,补发消息,异常消息置为死亡并人工处理 |
| 消息消费失败频繁重试 | 下游服务异常、消息体解析失败、依赖数据库死锁 | 看消费端日志、压测复现 | 修复下游,重试次数耗尽后走死信队列 |
| 业务数据不一致但消息正常 | 老链路直连数据库、状态机更新错乱 | 跑对账任务,分析差异明细 | 人工修复或写补偿脚本,避免自动修复 |
5. 我的一些心得和想对你说的话
按我个人这几年的实践经验,分布式事务一致性问题最有效的解法其实是尽量从架构上规避它。当你发现一个操作需要同时跨两个以上的数据库或者服务时,先问自己:这两个步骤能否合并?其中一个步骤能否改成异步?能否让用户重试而不是系统自动补偿?架构上的简化永远比引入复杂的分布式事务方案更可靠。
如果确实绕不开,我的建议是从“最终一致”入手,能用本地消息表解决的,不要轻易上TCC;能用事务消息解决的,不要自研2PC。高一致性要求的系统,优先保证业务接口幂等和数据对账能力,这比纠结用什么框架更重要。
最后分享一个小技巧:无论你选哪一种方案,一定要在建表之初就把幂等键、状态字段、重试次数、时间戳这些字段设计进去。我见过很多系统,业务表完全没考虑幂等和补偿,等出了问题再改表结构,不仅排期长,还要处理历史脏数据,非常被动。把这些基础字段提前准备好,后面做分布式事务改造时真的能少踩很多坑。