2024 年我们做服务拆分,把订单、库存、积分拆成了三个独立服务,各自独立数据库。拆完没多久就出问题:对账时发现一批订单"创建成功了,但库存没扣"。排查下来根因很清晰 —— 跨服务的操作没有任何事务保障,订单库 commit 了,调库存服务时网络抖了一下失败,两个库的数据就此不一致。投了两周做分布式事务专项治理,本文复盘这次实战。
问题背景
业务:下单流程,拆分后跨 3 个服务
订单服务(order_db) + 库存服务(stock_db) + 积分服务(point_db)
事故现象:
- 对账发现:当天 200 多笔订单,订单存在但库存没扣
- 还有反过来的:库存扣了,但订单创建失败,库存白扣
- 用户侧:有人下单成功却收到"缺货",有人下单失败积分却加了
现场排查:
# 出问题的下单代码(伪代码)
@Transactional // 这个事务只管得住 order_db!
public void createOrder(OrderReq req) {
orderMapper.insert(order); // 本地库,事务内
stockClient.deduct(req); // RPC 调库存服务 —— 不在事务内!
pointClient.add(req); // RPC 调积分服务 —— 不在事务内!
}
# 失败场景还原:
# 1. orderMapper.insert 成功,本地事务提交
# 2. stockClient.deduct 时库存服务抖动,RPC 超时抛异常
# 3. 本地事务确实回滚了 order?—— 不,insert 那步已经...
# @Transactional 会回滚 order_db,但 RPC 已经发出去了
根因:
1. @Transactional 是【本地事务】,只能保证单个数据库的一致性
2. 跨服务、跨库的操作,本地事务完全管不了
3. 三个服务三个库,任何一步失败,数据就不一致
4. 团队误以为加了 @Transactional 就万事大吉
修复 1:认清本地事务管不了跨服务
// === 误区:以为 @Transactional 能保证整个下单流程 ===
@Transactional
public void createOrder(OrderReq req) {
orderMapper.insert(order); // order_db
stockClient.deduct(req); // stock_db(另一个服务/库)
pointClient.add(req); // point_db(另一个服务/库)
}
// @Transactional 的作用范围 = 当前线程 + 当前数据源的一个连接。
// 它能回滚的只有 order_db 的操作。
// stockClient.deduct 是一次 RPC,库存服务在自己的库里
// 自己开事务、自己提交,和当前这个 @Transactional 毫无关系。
// === 几种失败组合,本地事务都救不了 ===
// 组合 A:order 插入成功,stock 扣减 RPC 失败
// -> order_db 回滚(没问题),但如果 stock 其实扣了只是响应丢了
// -> 库存少了、订单没了
// 组合 B:order 插入成功,stock 扣成功,point RPC 失败
// -> order_db 回滚,但 stock 已经实扣且无法感知回滚
// -> 库存白扣
// 组合 C:order 插入成功,stock 成功,point 成功,
// 但提交 order_db 时数据库连接断了
// -> stock/point 都生效了,order 没了
// === 结论 ===
// 服务一旦拆分、库一旦拆开,就必须引入【分布式事务】方案。
// 没有银弹,只有针对场景的取舍。下面是几种主流方案。
修复 2:方案一 —— 本地消息表(最终一致性)
// === 思路:把"要做的事"和"业务数据"写进同一个本地事务 ===
// 订单服务里建一张 message 表,和订单表在同一个库。
@Transactional // 这个本地事务现在能 100% 保证两件事原子
public void createOrder(OrderReq req) {
// 1. 写订单
orderMapper.insert(order);
// 2. 把"要扣库存""要加积分"作为消息,写进【同库】的消息表
// 订单和消息要么一起成功,要么一起回滚 —— 本地事务保证
msgMapper.insert(new LocalMessage("STOCK_DEDUCT", req, "PENDING"));
msgMapper.insert(new LocalMessage("POINT_ADD", req, "PENDING"));
}
// 关键:订单和消息同库同事务,这一步绝不会出现"订单成功消息丢"。
// === 一个后台任务,扫消息表,把消息真正投出去 ===
@Scheduled(fixedDelay = 1000)
public void dispatchMessages() {
List pending = msgMapper.selectPending(100);
for (LocalMessage msg : pending) {
try {
mqProducer.send(msg.getTopic(), msg.getPayload());
msgMapper.markSent(msg.getId()); // 投递成功,标记
} catch (Exception e) {
// 投递失败不要紧,下次扫描重投(所以消费端必须幂等)
msgMapper.incrRetry(msg.getId());
}
}
}
// 库存服务、积分服务作为消费者,收到消息后执行扣减/加分。
//
// === 这个方案的本质 ===
// 把"分布式一致性"问题,转化成"本地事务 + 可靠消息投递"。
// 订单一旦提交,消息就一定在表里,后台任务保证它最终被投出去,
// 下游最终一定会执行 —— 这就是【最终一致性】。
// 代价:不是强一致,下游有秒级延迟;消费端必须幂等(消息会重投)。
修复 3:方案二 —— 事务消息(RocketMQ)
// 本地消息表要自己建表、自己写后台扫描任务。
// RocketMQ 的事务消息把这套机制内置了,本质思路一样。
public void createOrderWithTxMsg(OrderReq req) {
// 1. 发送"半消息":消息到了 MQ,但消费者【暂时看不到】
TransactionSendResult result = txProducer.sendMessageInTransaction(
buildMessage("STOCK_DEDUCT", req), req);
}
// 2. 半消息发送成功后,MQ 回调这个方法执行【本地事务】
public class OrderTxListener implements TransactionListener {
@Override
public LocalTransactionState executeLocalTransaction(
Message msg, Object arg) {
try {
orderMapper.insert(buildOrder((OrderReq) arg)); // 本地事务
return LocalTransactionState.COMMIT_MESSAGE; // 提交 -> 半消息转正
} catch (Exception e) {
return LocalTransactionState.ROLLBACK_MESSAGE; // 回滚 -> 半消息丢弃
}
}
// 3. 如果上面没明确返回(进程挂了等),MQ 会回查事务状态
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// 根据 msg 里的订单号,查订单到底建没建成功
String orderId = msg.getUserProperty("orderId");
return orderMapper.exists(orderId)
? LocalTransactionState.COMMIT_MESSAGE
: LocalTransactionState.ROLLBACK_MESSAGE;
}
}
// 流程:半消息(消费者不可见)-> 执行本地事务 -> 成功则消息转正可见、
// 失败则消息丢弃 -> 异常中断则 MQ 回查兜底。
// 保证了"订单落库"和"消息发出"的原子性,且不用自己维护消息表。
// 同样是最终一致性,消费端同样必须幂等。
修复 4:方案三 —— TCC(强一致,适合核心链路)
// TCC = Try-Confirm-Cancel,把一个操作拆成三段,
// 适合对一致性要求高、不能接受中间状态的核心场景(如扣款)。
// 以"扣库存"为例,库存服务要提供三个方法:
public interface StockTccService {
// Try:预留资源 —— 不是真扣,而是"冻结"
boolean tryDeduct(String txId, Long skuId, int count);
// Confirm:确认 —— 把冻结的真正扣掉
boolean confirm(String txId);
// Cancel:取消 —— 把冻结的释放回去
boolean cancel(String txId);
}
// 库存表要支持"冻结"语义:
// available(可用) | frozen(冻结) | total
// Try: available -= count, frozen += count (钱还在,只是冻住)
// Confirm: frozen -= count (冻结的真正消耗掉)
// Cancel: available += count, frozen -= count (解冻,还回去)
// === 下单的协调流程 ===
public void createOrder(OrderReq req) {
String txId = generateTxId();
try {
// 第一阶段:所有参与者都 Try(预留)
boolean ok1 = stockTcc.tryDeduct(txId, req.getSkuId(), req.getCount());
boolean ok2 = pointTcc.tryAdd(txId, req.getUserId(), req.getPoint());
boolean ok3 = orderTcc.tryCreate(txId, req);
if (ok1 && ok2 && ok3) {
// 全部 Try 成功 -> 第二阶段全部 Confirm
stockTcc.confirm(txId);
pointTcc.confirm(txId);
orderTcc.confirm(txId);
} else {
// 任一 Try 失败 -> 第二阶段全部 Cancel
stockTcc.cancel(txId);
pointTcc.cancel(txId);
orderTcc.cancel(txId);
}
} catch (Exception e) {
stockTcc.cancel(txId); // 异常也要 Cancel 兜底
pointTcc.cancel(txId);
orderTcc.cancel(txId);
}
}
// TCC 三个方法都必须幂等(Confirm/Cancel 可能因网络重试被多次调用),
// 还要处理"空回滚"(没 Try 就来了 Cancel)和"悬挂"(Cancel 比 Try 先到)。
// TCC 一致性强、性能好,但侵入业务重 —— 每个操作要拆成三个方法。
修复 5:方案四 —— Seata AT 模式(低侵入)
// 上面的方案要么改业务、要么自己维护消息表。
// Seata 的 AT 模式追求"几乎零业务侵入"——一个注解搞定。
@GlobalTransactional // 就这一个注解,开启全局分布式事务
public void createOrder(OrderReq req) {
orderMapper.insert(order); // 各服务里仍是普通的 @Transactional
stockClient.deduct(req); // RPC 调用,Seata 自动串起全局事务
pointClient.add(req);
}
// === AT 模式的原理(简化)===
// 1. Seata 的代理数据源,在你执行业务 SQL【之前】,
// 自动查出"被修改行的修改前镜像"(before image)存进 undo_log 表
// 2. 业务 SQL 执行 + undo_log 写入,在同一个本地事务里提交
// 3. 全局事务由 Seata 的 TC(事务协调者)统一指挥:
// - 全部成功 -> TC 通知各服务删掉 undo_log(异步,很快)
// - 任一失败 -> TC 通知各服务,用 undo_log 里的前镜像
// 把数据【反向恢复】回去(自动回滚)
//
// 每个参与的库都要建一张 undo_log 表:
// CREATE TABLE undo_log ( id, branch_id, xid,
// rollback_info LONGBLOB, ... );
// === AT 模式的取舍 ===
// 优点:业务几乎零侵入,一个注解,开发体验最好
// 代价:
// 1. 不是严格强一致 —— 两阶段之间有个"全局锁"窗口,
// Seata 用全局锁防脏写,但隔离性弱于本地事务
// 2. 每条写 SQL 都要多查一次前镜像、多写一次 undo_log,有性能损耗
// 3. 引入 TC 这个中心化组件,它本身要保证高可用
修复 6:方案选型与监控
=== 四种方案怎么选 ===
方案 一致性 侵入性 适用场景
本地消息表 最终一致 中 异步解耦、可接受秒级延迟
事务消息 最终一致 中 同上,且不想自己维护消息表
TCC 强一致 高 核心交易、不能接受中间态(扣款)
Seata AT 弱于强一致 极低 内部系统、追求开发效率、并发不极端
=== 决策原则 ===
1. 能不用分布式事务就不用 —— 最好的分布式事务是"没有"
能把强相关的操作收进【同一个服务、同一个库】,就用本地事务
2. 业务能接受最终一致 -> 优先本地消息表 / 事务消息(最成熟稳定)
3. 必须强一致的核心链路 -> TCC(愿意付出业务改造成本)
4. 内部系统、追求快速落地 -> Seata AT
=== 共同的铁律 ===
- 所有下游操作必须【幂等】—— 不管哪种方案,重试都不可避免
- 一定要有【对账】兜底 —— 分布式事务方案再可靠也会有漏网,
每天定时跑对账,把不一致的数据捞出来人工/自动修复
# 分布式事务监控
groups:
- name: distributed-tx
rules:
# 1. 全局事务失败率
- alert: GlobalTxFailRate
expr: |
rate(global_tx_total{status="fail"}[5m])
/ rate(global_tx_total[5m]) > 0.01
for: 5m
annotations:
summary: "全局事务失败率 > 1%,排查参与方服务"
# 2. 本地消息表积压(消息没投出去)
- alert: LocalMessagePending
expr: local_message_pending_count > 1000
for: 10m
annotations:
summary: "本地消息表积压 > 1000,投递任务可能异常"
# 3. 对账发现的不一致数据量
- alert: ReconcileMismatch
expr: increase(reconcile_mismatch_total[1h]) > 10
annotations:
summary: "对账发现 {{ $value }} 笔数据不一致,需介入"
# 4. TCC/AT 悬挂事务(长时间未 Confirm/Cancel)
- alert: HangingTransaction
expr: hanging_tx_count > 0
for: 10m
annotations:
summary: "存在悬挂事务,需人工排查 Confirm/Cancel"
优化效果
指标 治理前 治理后
=============================================================
数据不一致(每天) 200+ 笔 对账偶发 < 3 笔
下单跨服务一致性 无任何保障 本地消息表最终一致
核心扣款链路 无保障 TCC 强一致
下游幂等 部分没做 全部幂等
对账机制 无 每日定时对账 + 自动修复
不一致的发现 用户投诉才知道 对账 + 监控主动发现
中间态(下单成功未扣库存)长期残留 秒级最终一致
改造过程:
- 梳理所有跨服务写操作(14 处):2 天
- 下单主链路接本地消息表:3 天
- 核心扣款链路 TCC 改造:4 天
- 下游服务幂等改造:2 天
- 对账系统 + 监控:3 天
避坑清单
- @Transactional 是本地事务,只能保证单库一致,跨服务跨库完全管不了
- 服务一旦拆分、库一旦拆开,跨服务写操作就必须引入分布式事务
- 本地消息表:业务数据和消息写进同一本地事务,后台任务保证消息最终投出
- 事务消息(RocketMQ)是本地消息表的内置版,半消息 + 本地事务 + 回查
- TCC 把操作拆成 Try-Confirm-Cancel 三段,强一致但业务侵入重
- Seata AT 靠 undo_log 自动反向回滚,一个注解低侵入,但隔离性弱于本地事务
- 最好的分布式事务是"没有",能把强相关操作收进同库就用本地事务
- 可接受最终一致优先选消息方案,核心强一致选 TCC,追求效率选 AT
- 无论哪种方案,所有下游操作必须幂等,重试永远不可避免
- 一定要有对账兜底,定时捞出不一致数据,分布式事务方案都会有漏网
总结
这次服务拆分后的数据不一致事故,本质上是一次典型的"拆分的代价没算清楚"。微服务化几乎是所有团队都会走的路,把订单、库存、积分拆成独立服务、独立数据库,在职责清晰、独立部署、独立扩容这些方面确实带来了好处,但很多人(包括当时的我们)都忽略了一个根本性的代价:一旦库被拆开,那个用了无数遍、闭着眼睛都会写的 @Transactional 注解,它的能力边界就被�"地缩小了。@Transactional 管的是一个数据库连接上的一个本地事务,它能回滚的永远只有当前这一个库,而当下单流程横跨订单、库存、积分三个库时,它对其中两个库的操作完全无能为力 —— 一次 RPC 调用发出去,对方服务在自己的库里开事务、提交事务,和你这边的 @Transactional 没有半点关系。理解了这个边界,就会明白分布式事务不是什么高深的奢侈品,而是服务拆分后的必需品。但分布式事务没有银弹,它本质上是一组针对不同场景的取舍:如果业务能接受秒级的最终一致,本地消息表和事务消息是最成熟稳定的选择,它的精妙之处在于把"分布式一致性"这个难题,巧妙地降维成了"本地事务 + 可靠消息投递"这两个我们更有把握的问题;如果是扣款这种绝对不能容忍中间状态的核心强一致链路,就得上 TCC,用 Try 阶段的资源预留换取强一致,代价是每个操作都要拆成三个方法、业务侵入很重;如果是内部系统、追求快速落地,Seata 的 AT 模式用一个注解和 undo_log 的自动反向回滚,提供了最好的开发体验,但要清楚它的隔离性是弱于本地事务的。而贯穿所有方案的两条铁律,一是下游必须幂等 —— 因为分布式环境里重试永远无法避免,二是必须有对账兜底 —— 因为再可靠的方案也会有极端情况下的漏网之鱼,一个每天定时运行、能主动把不一致数据捞出来的对账系统,才是真正让人睡得着觉的最后一道防线。最后我想说的是,这次事故教给我最重要的一课其实是:最好的分布式事务,是想办法让它根本不需要出现 —— 在做服务拆分的设计阶段,就应该尽量把强一致相关的操作收拢在同一个服务、同一个库里,能用一个本地事务解决的,就绝不要把它拆散成一个分布式难题。
—— 别看了 · 2026