分布式事务踩坑:服务拆分后订单成功库存却没扣的复盘

服务拆分后订单、库存、积分各自独立数据库,对账发现一批订单创建成功了但库存没扣。根因是 @Transactional 只管本地单库,跨服务操作毫无事务保障。两周治理:认清本地事务边界、本地消息表、RocketMQ 事务消息、TCC 强一致、Seata AT 低侵入,以及方案选型与对账兜底。

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 天

避坑清单

  1. @Transactional 是本地事务,只能保证单库一致,跨服务跨库完全管不了
  2. 服务一旦拆分、库一旦拆开,跨服务写操作就必须引入分布式事务
  3. 本地消息表:业务数据和消息写进同一本地事务,后台任务保证消息最终投出
  4. 事务消息(RocketMQ)是本地消息表的内置版,半消息 + 本地事务 + 回查
  5. TCC 把操作拆成 Try-Confirm-Cancel 三段,强一致但业务侵入重
  6. Seata AT 靠 undo_log 自动反向回滚,一个注解低侵入,但隔离性弱于本地事务
  7. 最好的分布式事务是"没有",能把强相关操作收进同库就用本地事务
  8. 可接受最终一致优先选消息方案,核心强一致选 TCC,追求效率选 AT
  9. 无论哪种方案,所有下游操作必须幂等,重试永远不可避免
  10. 一定要有对账兜底,定时捞出不一致数据,分布式事务方案都会有漏网

总结

这次服务拆分后的数据不一致事故,本质上是一次典型的"拆分的代价没算清楚"。微服务化几乎是所有团队都会走的路,把订单、库存、积分拆成独立服务、独立数据库,在职责清晰、独立部署、独立扩容这些方面确实带来了好处,但很多人(包括当时的我们)都忽略了一个根本性的代价:一旦库被拆开,那个用了无数遍、闭着眼睛都会写的 @Transactional 注解,它的能力边界就被�"地缩小了。@Transactional 管的是一个数据库连接上的一个本地事务,它能回滚的永远只有当前这一个库,而当下单流程横跨订单、库存、积分三个库时,它对其中两个库的操作完全无能为力 —— 一次 RPC 调用发出去,对方服务在自己的库里开事务、提交事务,和你这边的 @Transactional 没有半点关系。理解了这个边界,就会明白分布式事务不是什么高深的奢侈品,而是服务拆分后的必需品。但分布式事务没有银弹,它本质上是一组针对不同场景的取舍:如果业务能接受秒级的最终一致,本地消息表和事务消息是最成熟稳定的选择,它的精妙之处在于把"分布式一致性"这个难题,巧妙地降维成了"本地事务 + 可靠消息投递"这两个我们更有把握的问题;如果是扣款这种绝对不能容忍中间状态的核心强一致链路,就得上 TCC,用 Try 阶段的资源预留换取强一致,代价是每个操作都要拆成三个方法、业务侵入很重;如果是内部系统、追求快速落地,Seata 的 AT 模式用一个注解和 undo_log 的自动反向回滚,提供了最好的开发体验,但要清楚它的隔离性是弱于本地事务的。而贯穿所有方案的两条铁律,一是下游必须幂等 —— 因为分布式环境里重试永远无法避免,二是必须有对账兜底 —— 因为再可靠的方案也会有极端情况下的漏网之鱼,一个每天定时运行、能主动把不一致数据捞出来的对账系统,才是真正让人睡得着觉的最后一道防线。最后我想说的是,这次事故教给我最重要的一课其实是:最好的分布式事务,是想办法让它根本不需要出现 —— 在做服务拆分的设计阶段,就应该尽量把强一致相关的操作收拢在同一个服务、同一个库里,能用一个本地事务解决的,就绝不要把它拆散成一个分布式难题。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

日志打爆磁盘:一次 error.log 涨到 388G 的周末宕机复盘

2026-5-20 12:50:31

技术教程

线程池踩坑:无界队列把堆撑爆,一次 OOM 宕机的复盘

2026-5-20 12:57:09

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索