Skip to content

想象这个场景:凌晨两点报警响起——下单服务成功扣了库存,但订单服务超时了,积分也没加上。用户没买到东西,库存却少了。系统拆成多个服务多个库之后,这种"一半成功一半失败"的事故就注定会来:单个数据库里靠事务就能原子提交,拆开了,@Transactional 就管不到了。

这就是分布式事务要解决的问题。这篇按演进顺序把方案捋一遍:本地消息表 → 事务消息 → TCC → Seata AT。每一版解决什么、代价是什么,最后给你一张选型表。代码按 Spring Boot 落地。


一、问题先讲清楚:跨库事务为什么难

下单链路有四个参与方:订单库、库存库、用户余额库、积分服务。理想状态是"要么全成功、要么全失败":

参与方做什么失败影响
订单服务写订单表用户没订单
库存服务扣库存超卖
余额服务扣钱多扣/少扣
积分服务加积分用户少拿积分

如果还挤在一个库里,一个 @Transactional 包住就完事。拆开后问题来了:分布式系统里没有全局事务管理器,没法让四个库同时提交/回滚。业界把解决方案分成两类,理解这个分类比背方案重要:

  • 强一致(XA/TCC):提交时所有参与方一起成功或一起失败,代价是性能、复杂度
  • 最终一致(本地消息表/事务消息/SAGA):先保证主业务成功,副业务异步补做,失败靠重试兜底,代价是一段时间内数据不一致

电商下单这种链路,90% 场景用最终一致就够了——用户等 1 秒看到积分到账,完全能接受。

二、方案 v1:本地消息表(最终一致的教科书做法)

思路一句话:把"要发给别人的消息"和"自己的业务"放在同一个本地事务里,消息落库,别人异步来取。

本地消息表时序图

核心表结构:

sql
CREATE TABLE `t_local_message` (
  `id`          BIGINT AUTO_INCREMENT PRIMARY KEY,
  `biz_type`    VARCHAR(32)  NOT NULL COMMENT '业务类型',
  `biz_no`      VARCHAR(64)  NOT NULL COMMENT '业务单号',
  `payload`     TEXT         NOT NULL COMMENT '消息内容(JSON)',
  `status`      TINYINT      NOT NULL DEFAULT 0 COMMENT '0=待发送 1=已发送 2=发送失败',
  `retry_count` INT          NOT NULL DEFAULT 0,
  `next_retry`  DATETIME     NOT NULL COMMENT '下次重试时间',
  `create_time` DATETIME     NOT NULL,
  UNIQUE KEY `uk_biz` (`biz_type`, `biz_no`)
) COMMENT '本地消息表';

关键代码:订单和消息同事务

java
@Transactional
public void createOrder(CreateOrderCmd cmd) {
    // 1. 写订单 —— 和消息表同一个本地事务
    orderMapper.insert(buildOrder(cmd));
    // 2. 写消息 —— 状态待发送,事务提交后由定时任务/队列投递
    localMessageMapper.insert(LocalMessage.builder()
            .bizType("ORDER_CREATED")
            .bizNo(cmd.getOrderNo())
            .payload(JsonUtils.toJson(cmd))
            .status(0)
            .build());
}
java
// 定时重试:捞"待发送 + 到重试时间"的消息,投递成功后改状态
@Scheduled(fixedDelay = 30_000)
public void resend() {
    List<LocalMessage> pending = localMessageMapper.selectPending(30);
    for (LocalMessage msg : pending) {
        try {
            mqTemplate.send(msg.getBizType(), msg.getPayload());
            localMessageMapper.markSent(msg.getId());   // 成功才改状态
        } catch (Exception e) {
            localMessageMapper.bumpRetry(msg.getId());  // 失败累计重试次数
        }
    }
}

为什么说它可靠:订单和消息在同一个本地事务里,要么都成、要么都败——不可能出现"订单成功了但消息没落库"。下游没消费,消息就一直躺在表里,定时任务重试,直到成功为止。这就是"最终一致"的保证来源。

代价

  • 消息表是额外的状态,得自己维护"待发送/已发送/重试"的生命周期
  • 需要定时任务扫表,实时性差(秒级到分钟级)
  • 消息积压时,selectPending 要控制好扫描范围,别全表扫

三、方案 v2:事务消息(RocketMQ 把本地消息表藏进中间件)

本地消息表方案,麻烦在要自己维护表状态。RocketMQ 的事务消息把这套逻辑搬进 MQ 内部:

  1. 发送方先发一条「半消息」(half message),MQ 存着但不投递
  2. 发送方执行本地事务
  3. 本地事务成功 → 提交半消息(MQ 开始投递);失败 → 回滚半消息(不投递)
  4. 如果 MQ 一直没收到提交/回滚决定 → 反向询问发送方(check 回查),发送方查本地事务结果再决定
java
@Transactional
public void createOrder(CreateOrderCmd cmd) {
    // 本地业务先执行(还没提交)
    orderMapper.insert(buildOrder(cmd));
    // 事务消息:本地事务提交后,MQ 才真正投递
    TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction(
        "order_tx_topic", payload, cmd.getOrderNo());
}
java
// 事务回查实现:MQ 不知道本地事务结果时问过来
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
    String orderNo = msg.getUserProperty("orderNo");
    // 订单存在 = 本地事务已提交 → 消息该投递
    return orderMapper.exists(orderNo)
        ? LocalTransactionState.COMMIT_MESSAGE
        : LocalTransactionState.ROLLBACK_MESSAGE;
}

对比本地消息表

对比点本地消息表RocketMQ 事务消息
状态管理自己建表维护MQ 内部处理
实时性定时扫表,秒~分钟级提交即投递,毫秒级
事务回查无(靠重试)内置 check 机制
复杂度低(就一张表 + 定时任务)中(要理解半消息/回查语义)

注意:RabbitMQ 原生不支持事务消息(有 txSelect 但那不是半消息语义,别混)。如果中间件是 RabbitMQ/Kafka,就用本地消息表方案,或者让下游自己幂等(配合《接口幂等与防重提交》)。

四、方案 v3:TCC(Try-Confirm-Cancel,手动补偿)

最终一致方案的问题是:下游可能一直失败(积分服务挂了),订单状态就永远差一口气。有些场景(比如扣钱)不能容忍"对不上账",需要两阶段确认——这就是 TCC。

TCC 把一个操作拆成三步:

阶段干什么例子(扣余额)
Try检查资源 + 冻结/预留检查余额够不够,冻结 100 元
Confirm确认执行(用预留的资源)把冻结的 100 元真正扣掉
Cancel回滚(释放预留)解除冻结,余额恢复
java
// 扣余额服务实现 TCC 三接口
public boolean tryDeduct(Long userId, BigDecimal amount) {
    // 检查余额 + 冻结(不动真实余额)
    return accountService.freeze(userId, amount);
}

public boolean confirmDeduct(Long userId, BigDecimal amount) {
    // 真正扣款:把冻结转成扣款
    return accountService.confirmFreeze(userId, amount);
}

public boolean cancelDeduct(Long userId, BigDecimal amount) {
    // 解冻
    return accountService.unfreeze(userId, amount);
}

关键点

  • Try 里只冻结不真扣,这样 Confirm 一定成功(资源已预留)
  • Confirm/Cancel 必须幂等(框架可能重试)
  • 冻结要有超时释放(用户取消下单,冻结余额要能自动解)

代价:每个服务都要写三个接口,业务侵入极大;冻结/解冻的账务处理复杂。能用最终一致解决的场景,别轻易上 TCC

五、方案 v4:Seata AT(框架替你干脏活)

Seata AT 模式是 TCC 的"自动化版本":业务代码几乎不用改,框架通过代理拦截 SQL,自动记录数据快照(undo_log),出错时自动反向生成补偿 SQL 回滚。

java
// 业务代码:看起来就是普通事务,Seata 在背后记录 undo_log
@GlobalTransactional
public void createOrder(CreateOrderCmd cmd) {
    orderService.insert(cmd);       // 框架记录订单表快照
    stockService.deduct(cmd);       // 框架记录库存表快照
    accountService.deduct(cmd);     // 框架记录余额表快照
}
  • 下单链路每个服务配 @GlobalTransactional(全局事务入口)
  • 各服务数据源接 Seata 的 undo_log
  • 任一服务失败 → 全局事务回滚 → 每个服务执行反向 SQL 还原

对比一览

方案一致性业务侵入实时性适用
本地消息表最终秒~分钟大多数业务
事务消息最终毫秒RocketMQ 场景
TCC强(两阶段)毫秒资金类强一致
Seata AT毫秒单体→微服务过渡

六、我的选型结论

回到开头的下单链路,我最后用的组合是:

  1. 下单主链路:本地消息表(订单 + 消息同事务),消息给库存/积分异步消费——订单成功即保证消息必达
  2. 消费方幂等:每个消费者用业务单号做消费幂等键,重投不重复处理
  3. 资金类强一致(真扣钱的场景)才上 Seata AT,且只圈住必要的几个服务

一个核心认知:分布式事务没有银弹。强一致方案(TCC/Seata)保证的是"全成或全败",但性能和侵入成本高;最终一致方案牺牲"即时一致性",换来简单可靠。先问业务能不能接受最终一致——绝大多数能。

想深入了解消息侧的基础设施,看《RabbitMQ 快速入门》《Kafka 快速入门》和《AMQP 协议详解》;消费幂等的完整做法见《接口幂等与防重提交》。


验证记录:本地消息表的核心语义("订单和消息同一本地事务、消息靠定时任务重试直到成功")依赖 Spring + MySQL 完整环境,文中已按标准语义静态核对(@Transactional 同事务提交、selectPending 条件扫描均为 Spring/MyBatis 标准 API)。TCC 的"冻结/确认/取消"三步语义与 Seata 的 undo_log 机制为框架标准行为,标注为静态核对,未实跑。