2024 年我们把单体的电商系统拆成了微服务,订单、库存、账户各自独立、各有各的数据库。拆完没多久就出了一类很扎心的故障:用户下单成功了,订单表里明明有这笔订单,可对应的库存却没有扣减;反过来也出现过,库存扣了,订单却因为后续步骤失败没创建成功。运营对账时发现账实不符,客服那边也开始收到投诉。根子很清楚——单体时代一个 @Transactional 就能保证"创建订单"和"扣减库存"要么都成功要么都失败,可拆成微服务、各连各的库之后,本地事务的边界只到自己这个库为止,跨服务的一致性,数据库再也管不了了。投了几天把分布式事务的几种方案彻底搞清并落地,本文复盘这次实战。
问题背景
业务:电商系统微服务化,订单/库存/账户各自独立数据库
事故现象:
- 下单成功,但库存没扣 -> 超卖
- 库存扣了,订单没创建成功 -> 少卖、库存凭空消失
- 对账账实不符,客服收到投诉
现场排查:
# 1. 看下单的核心代码
@Transactional // 这个事务,只对【订单库】生效
public void createOrder(OrderReq req) {
orderMapper.insert(buildOrder(req)); // 写订单库
stockClient.deduct(req.getSkuId(), req.getQty()); // RPC 调库存服务
accountClient.pay(req.getUserId(), req.getAmount()); // RPC 调账户服务
}
# 2. 问题暴露:
# - @Transactional 只能回滚 orderMapper.insert(本地订单库)
# - stockClient.deduct 是远程调用,它有它自己的事务,
# 一旦它成功了、而后面 accountClient.pay 失败,
# 订单库这边回滚了,库存那边却【扣了不会回滚】
# - 网络超时更糟:RPC 超时,你根本不知道对方到底执行了没有
根因:
1. 微服务拆分后,一次业务横跨多个独立数据库
2. 数据库本地事务的边界只到单个库,跨服务它管不了
3. @Transactional 给了团队一种"事务还在生效"的错觉
4. 没有任何机制保证跨服务操作的原子性和最终一致
修复 1:先认清分布式事务的本质难题
=== 单体事务为什么"很简单" ===
单体时代,订单和库存在同一个数据库。
一个 @Transactional,底层靠数据库的 ACID:
所有操作要么一起 commit,要么一起 rollback,
数据库帮你扛下了原子性。
=== 微服务后,难题出在哪 ===
现在订单库、库存库是两个独立的数据库,
各自只能保证【自己内部】的事务。
没有任何一个数据库,能"同时 commit/rollback"另一个库。
跨库的原子性,数据库不再负责 —— 这事得应用自己想办法。
=== 还有一个魔鬼:网络的不确定性 ===
单体里调一个方法,结果只有"成功 / 抛异常"两种。
跨服务 RPC,结果有【三种】:
成功 / 失败 / 超时(不知道成没成)
"超时"是分布式事务里最难处理的状态:
你调库存服务扣库存,超时了 ——
它可能压根没收到、可能扣了但响应丢了。
任何分布式事务方案,都必须能正确处理这个"不确定"。
=== 一个重要的观念转变 ===
分布式系统里,追求"任何时刻都强一致"代价极高、
甚至不现实。绝大多数业务真正需要的是【最终一致】:
允许中间有短暂的不一致,但保证最终会达到一致状态。
想通这点,方案的选择范围一下子就打开了。
修复 2:2PC / XA——强一致,但代价高
=== 两阶段提交(2PC)的过程 ===
引入一个"事务协调者",分两个阶段:
阶段一:准备(Prepare)
协调者问所有参与者:"这个事务,你能提交吗?"
每个参与者执行操作、但【不提交】,锁住资源,
回复"我准备好了"或"我不行"。
阶段二:提交 / 回滚(Commit / Rollback)
- 所有参与者都回复"准备好了" -> 协调者通知全部 commit
- 只要有一个回复"不行" -> 协调者通知全部 rollback
=== XA 就是 2PC 的一个规范实现 ===
数据库(MySQL 等)原生支持 XA 协议,
Seata 的 XA 模式、JTA 都是基于这个思路。
=== 2PC 的问题 ===
1. 同步阻塞:从准备到最终提交,资源一直被【锁住】,
这期间别人动不了这些数据 -> 并发性能很差
2. 协调者单点:协调者挂了,参与者会卡在"已准备、
等指令"的状态,资源迟迟不释放
3. 阶段二若部分参与者没收到指令,会出现数据不一致
=== 结论 ===
2PC/XA 能做到强一致,但它的【锁】和【阻塞】
让它不适合高并发的互联网业务。
它更适合并发不高、对一致性要求极严的传统场景。
互联网业务,大多转向"最终一致"的柔性事务方案。
修复 3:TCC——把一个操作拆成三段
// === TCC = Try / Confirm / Cancel,业务层面的两阶段 ===
// 它要求每个服务,为一个操作提供三个接口:
// Try:尝试,做资源【预留 / 冻结】,不做最终提交
// Confirm:确认,真正执行,把预留的资源用掉
// Cancel:取消,释放 Try 里预留的资源
// === 以"扣库存"为例 ===
public interface StockTccService {
// Try:不直接扣库存,而是【冻结】这部分库存
boolean tryDeduct(String txId, Long skuId, int qty);
// Confirm:把冻结的库存真正扣掉
boolean confirm(String txId);
// Cancel:把冻结的库存解冻、还回去
boolean cancel(String txId);
}
// === 库存表的设计要配合 TCC ===
// stock 表:available(可用) + frozen(冻结)两个字段
// Try : available -= qty; frozen += qty;
// Confirm: frozen -= qty; (真正扣减完成)
// Cancel : available += qty; frozen -= qty; (归还)
// === 下单流程在 TCC 下变成 ===
public void createOrder(OrderReq req) {
String txId = generateTxId();
// 阶段一:所有参与者都 Try(预留资源)
boolean s1 = orderTcc.tryCreate(txId, req); // 订单:预占
boolean s2 = stockTcc.tryDeduct(txId, skuId, qty); // 库存:冻结
boolean s3 = accountTcc.tryPay(txId, userId, amt); // 账户:冻结金额
if (s1 && s2 && s3) {
// 阶段二:全部 Try 成功 -> 全部 Confirm
orderTcc.confirm(txId);
stockTcc.confirm(txId);
accountTcc.confirm(txId);
} else {
// 任一 Try 失败 -> 全部 Cancel,释放已预留的资源
orderTcc.cancel(txId);
stockTcc.cancel(txId);
accountTcc.cancel(txId);
}
}
// === TCC 的三个必须解决的问题 ===
// 1. 空回滚:Try 没执行(如超时没到),却收到了 Cancel
// -> Cancel 要能识别"我没 Try 过",直接返回成功,不做事
// 2. 幂等:Confirm/Cancel 可能被重试多次,必须幂等
// 3. 悬挂:Cancel 先到、Try 后到 -> Try 要能发现
// "这个事务已被 Cancel",于是放弃执行
// 这三个问题靠一张"事务记录表"(记 txId 和状态)来解决。
// === TCC 的取舍 ===
// 优点:不长时间锁数据库资源,性能比 2PC 好,强一致性较好
// 缺点:每个服务都要写 Try/Confirm/Cancel 三个接口,
// 业务侵入性大、开发成本高
修复 4:本地消息表——可靠地"发出一个事件"
// === 思路:把"跨服务调用"变成"可靠地发一条消息" ===
// 核心技巧:让【业务操作】和【记录待发消息】
// 在【同一个本地事务】里完成 —— 这俩要么都成功要么都失败。
// === 第一步:订单服务,本地事务里同时做两件事 ===
@Transactional // 注意:这是订单库的本地事务,绝对可靠
public void createOrder(OrderReq req) {
// 1. 写订单(业务操作)
orderMapper.insert(buildOrder(req));
// 2. 往【本地消息表】插一条"待发送"的消息
// 消息表和订单表在同一个库,所以它俩在同一个事务里
LocalMessage msg = new LocalMessage();
msg.setTxId(generateTxId());
msg.setTopic("order.created");
msg.setPayload(toJson(req));
msg.setStatus("PENDING"); // 待发送
localMessageMapper.insert(msg);
}
// 关键:订单写成功了,消息记录就一定也在;
// 订单回滚了,消息记录也一起回滚。两者命运绑定。
// === 第二步:一个定时任务,把"待发送"的消息真正发出去 ===
@Scheduled(fixedDelay = 1000)
public void publishPendingMessages() {
List list =
localMessageMapper.selectByStatus("PENDING");
for (LocalMessage msg : list) {
try {
mq.send(msg.getTopic(), msg.getPayload()); // 投递到 MQ
localMessageMapper.updateStatus(msg.getId(), "SENT");
} catch (Exception e) {
log.warn("消息发送失败,下次重试, txId={}", msg.getTxId());
// 发失败不要紧,状态还是 PENDING,下轮继续重试
}
}
}
// === 第三步:库存服务消费消息,执行扣减 ===
// 消费时必须【幂等】(用 txId 去重),因为消息可能重复投递。
@RabbitListener(queues = "order.created.queue")
public void onOrderCreated(OrderEvent event) {
if (consumed(event.getTxId())) return; // 幂等:消费过就跳过
stockService.deduct(event.getSkuId(), event.getQty());
markConsumed(event.getTxId());
}
// === 本地消息表的特点 ===
// 优点:实现相对简单,不引入复杂中间件,可靠性好
// 缺点:业务库里多了张消息表,要自己写定时扫描和重试
// 它保证的是【最终一致】:订单创建后,库存"迟早"会被扣。
修复 5:MQ 事务消息与最大努力通知
=== 方案:MQ 事务消息(以 RocketMQ 为例)===
本地消息表要自己维护一张表 + 定时任务,
RocketMQ 把这套机制【内置】进了消息中间件:
1. 生产者发一条"半消息"到 MQ
—— 这条消息暂时对消费者【不可见】
2. MQ 回复"半消息已收到"
3. 生产者执行本地事务(写订单)
4. 生产者根据本地事务结果,告诉 MQ:
- 成功 -> commit,半消息变为正式消息,消费者可见
- 失败 -> rollback,半消息被丢弃
5. 万一第 4 步的确认丢了,MQ 会【回查】生产者:
"那个事务到底成没成?" 生产者据实回答
效果和本地消息表一样(最终一致),但省了自己维护表和定时任务。
=== 方案:最大努力通知 ===
适合"对一致性要求没那么高、且有对账兜底"的场景。
发起方在操作完成后,尽力地、带重试地去通知接收方,
重试若干次(间隔逐次拉长),通知到了就结束。
若最终还是没通知到,就靠【对账 / 接收方主动查询】来补。
典型场景:支付结果回调商户。
=== 方案:Saga ===
把一个长事务,拆成一串【本地事务】 T1、T2、T3...
每个 Ti 都配一个【补偿操作】 Ci。
正常时顺序执行 T1->T2->T3;
若 T3 失败,就【反向】依次执行补偿 C2->C1,
把前面已做的事一步步"撤销"掉。
Saga 没有资源预留,适合流程长、参与者多的业务,
缺点是补偿逻辑要自己设计,且过程中存在中间不一致状态。
修复 6:方案怎么选,以及 Seata 实战
// === 没有最好的方案,只有最合适的方案 ===
// 选型先问自己三个问题:
// 1. 业务能接受"最终一致"吗?能 -> 优先柔性事务(消息/Saga)
// 2. 一致性要求多严?要强一致 -> TCC;能容忍短暂不一致 -> 消息
// 3. 改造成本接受度?TCC 侵入大;消息表中等;Seata AT 模式最低
// === 实战常用:Seata 的 AT 模式(对业务几乎零侵入)===
// AT 模式的原理:Seata 在你执行 SQL 前后,
// 自动记录数据的"前镜像 / 后镜像"(改之前 / 改之后的值),
// 一旦全局事务要回滚,它用前镜像生成反向 SQL 自动补偿,
// 你的业务代码几乎不用改 —— 只加一个注解。
@GlobalTransactional // Seata:声明这是一个全局分布式事务
public void createOrder(OrderReq req) {
orderMapper.insert(buildOrder(req)); // 各服务照常写自己的库
stockClient.deduct(req.getSkuId(), req.getQty());
accountClient.pay(req.getUserId(), req.getAmount());
// 任何一步抛异常,Seata 自动回滚【所有】已执行的服务
}
// AT 模式优点:侵入极小,开发快
// AT 模式代价:执行期间会加全局锁,有一定性能损耗;
// 适合并发不极端、追求开发效率的多数业务。
// === 一条务实的经验 ===
// 分布式事务很复杂,能不用就不用。
// 优先考虑:能不能通过【合理的服务拆分】,
// 让"必须原子"的操作落在【同一个服务、同一个库】里,
// 用一个本地事务就解决?
// 真正需要跨服务原子性的地方,远比想象中少。
// 先优化边界,再上分布式事务 —— 这是成本最低的路。
优化效果
指标 治理前 治理后
=============================================================
跨服务一致性 无保障,账实不符 最终一致,有保障
@Transactional 作用 误以为跨服务生效 明确只管单库
下单-扣库存原子性 不原子,部分成功 消息表/TCC 保证最终一致
RPC 超时处理 无,状态不确定即出错 幂等 + 重试 + 事务记录表
失败补偿 无 Cancel/补偿/反向 SQL
消息/操作幂等 无,重试导致重复扣 txId 去重,全链路幂等
账实不一致 每日多起 对账兜底,基本清零
方案 裸 RPC 调用 Seata AT / 本地消息表
治理过程:
- 梳理跨服务一致性缺口:1 天
- 核心下单链路接入 Seata AT 模式:2 天
- 异步链路改本地消息表 + 幂等消费:1.5 天
- 对账系统补齐 + 全链路压测:1.5 天
避坑清单
- 微服务拆分后一次业务横跨多个独立库,数据库本地事务的边界只到单个库
- @Transactional 只能回滚本地库,跨服务 RPC 调用它管不了,别被它的存在迷惑
- RPC 有成功/失败/超时三种结果,超时是最难处理的不确定状态,方案必须能应对
- 分布式系统追求强一致代价极高,多数业务真正需要的是最终一致
- 2PC/XA 能强一致但全程锁资源、同步阻塞,不适合高并发互联网业务
- TCC 把操作拆成 Try 预留/Confirm 确认/Cancel 释放,要处理空回滚、幂等、悬挂
- 本地消息表把业务操作和记录消息放进同一本地事务,保证消息一定被可靠发出
- RocketMQ 事务消息把本地消息表机制内置,半消息+回查,省去自己维护表
- 所有跨服务方案的消费端都必须幂等,用 txId 去重,因为消息和补偿都会被重试
- 分布式事务能不用就不用,先优化服务拆分边界,让必须原子的操作落在同一个库
总结
这次分布式事务的踩坑,本质上是一次"微服务化的账,迟早要还"的经历。我们把单体拆成微服务的时候,满脑子想的都是拆分带来的好处——服务能独立部署、独立扩容、团队能并行开发,却没有认真算过另一笔账:那些原本被一个数据库事务悄悄保护着的一致性,在拆分的那一刻就被我们亲手打碎了。单体时代,"创建订单"和"扣减库存"为什么那么省心?因为它们在同一个数据库里,我们只要写一个 @Transactional,剩下的脏活累活全交给数据库的 ACID,它会保证这两件事要么一起成功、要么一起失败,原子性是数据库白送给我们的。可微服务化之后,订单和库存住进了两个完全独立的数据库,而世界上没有任何一个数据库,有能力去 commit 或 rollback 另一个数据库里的事务。我们最危险的地方,是拆分之后那个 @Transactional 注解还原封不动地留在下单方法上,它给了整个团队一种"事务还在保护着我们"的虚假安全感——可它能回滚的,从头到尾就只有订单服务自己那个库,至于那个通过 RPC 远远地调出去的扣库存操作,它一旦执行成功,就是泼出去的水,订单这边再怎么回滚也收不回来了。理解分布式事务,我觉得最重要的是先接受两个有点反直觉的事实。第一个是,跨服务调用的结果不是两种而是三种:成功、失败、以及超时。超时是分布式世界里最阴险的状态,你调库存服务去扣库存,它超时了,你根本无法判断对方究竟是没收到请求、还是收到了也执行了只是回复在网络上丢了——任何一个号称能解决分布式事务的方案,如果不能正确地处理这个"我不知道对方到底做没做"的不确定状态,那它就是不及格的,这也正是为什么 TCC 要费那么大劲去处理空回滚、悬挂,为什么所有方案的消费端都必须做幂等。第二个事实是,在分布式系统里追求"任何一个瞬间数据都完全一致"的强一致,代价高到大多数业务根本承受不起,而幸运的是,绝大多数业务其实也并不真的需要它——用户下单之后,库存哪怕晚几百毫秒、甚至晚几秒才扣减,只要它最终一定会被准确地扣掉,业务上就完全可以接受。这个从"强一致"到"最终一致"的观念松动,是整件事的关键转折点,因为一旦你接受了"允许短暂的不一致,但保证最终达成一致",那一大批轻量、高性能的柔性事务方案——本地消息表、MQ 事务消息、Saga——的大门就向你敞开了。这些方案里我个人最欣赏本地消息表的设计,它的精妙之处在于一个四两拨千斤的技巧:它不去挑战"跨库原子性"这个几乎无解的难题,而是把问题巧妙地降维成了一个我们本来就能轻松解决的问题——它让"写订单"和"在消息表里记一条待发送消息"这两件事,落在订单库的同一个本地事务里,于是这两者的命运被牢牢绑定,订单写成功消息记录就一定在,订单回滚消息记录也跟着消失;接下来,只要再有一个不知疲倦的定时任务,负责把消息表里那些"待发送"的记录可靠地、带重试地投递出去,跨服务的最终一致就达成了。它把一个棘手的分布式难题,拆解成了"一个可靠的本地事务"加上"一个可靠的重试投递",每一块都是我们驾轻就熟的。最后,这次复盘留给我的、比任何具体方案都更宝贵的一条经验是:分布式事务这东西,能不用,就尽量不用。它无论选哪种方案,都会给系统带来实打实的复杂度——要么是 TCC 那样沉重的业务侵入,要么是消息表那样额外的表和定时任务,要么是 Seata 那样的性能损耗和新的中间件依赖。所以在动手设计分布式事务之前,真正该先做的,是退一步审视服务拆分的边界本身:那些被你要求"必须原子地一起成功"的操作,是不是因为当初服务拆得不合理,才被硬生生分到了两个服务里?能不能通过调整边界,把它们重新收拢回同一个服务、同一个数据库,让一个朴素可靠的本地事务就把问题解决掉?把这个问题想透你常常会发现,真正非跨服务原子不可的场景,远比你最初以为的要少得多。先把边界划对,再去考虑分布式事务,这永远是成本最低、也最不容易出错的那条路。
—— 别看了 · 2026