第二篇:RocketMQ事务消息——分布式事务的最终一致性方案

前言

在上一篇中,我们拆解了RocketMQ的四大核心角色和一条消息的完整旅程。但秒杀系统中最关键的技术点还没有展开:事务消息

在秒杀流程中,Redis扣库存和发送MQ消息是两个独立的操作------Redis扣减成功但MQ消息发送失败时怎么办?MQ消息发出去了但Redis扣减实际没完成怎么办?这就是分布式事务要解决的问题。

面试中,事务消息是RocketMQ最深的水区:

"RocketMQ的事务消息是怎么实现的?半消息是什么?"

"回查机制在什么情况下触发?回查时你怎么判断事务是提交还是回滚?"

"如果Redis扣减成功但RocketMQ没收到提交指令,这个状态怎么恢复?"

"事务消息和分布式事务框架Seata有什么区别?为什么不用Seata?"

这些问题,只背八股是答不出来的。本文从半消息的原理出发,拆解事务消息的完整流程,最终落到秒杀系统中的实际实现。

本文核心问题:

  1. 为什么需要事务消息?它解决什么场景下的什么问题?
  2. 半消息是什么?它和普通消息有什么区别?
  3. 事务消息的完整生命周期是怎样的?从发送到提交/回滚经历了哪些状态?
  4. 回查机制在什么情况下触发?回查时你怎么判断本地事务的状态?
  5. 如果回查也失败了,消息会怎样?有没有最终兜底方案?
  6. 秒杀系统中如何用事务消息保证Redis扣库存和发送消息的原子性?
  7. 事务消息和Seata的AT模式、TCC模式有什么区别?为什么选事务消息?

读完本文,你将对RocketMQ事务消息拥有从原理到实战的完整理解。


一、为什么需要事务消息?

疑问:事务消息解决什么问题?普通消息做不到吗?

回答:事务消息解决的是"本地操作和消息发送的原子性"问题------要么两个都成功,要么两个都失败。普通消息无法保证这一点。

1.1 秒杀场景下的分布式事务问题

复制代码
秒杀接口的核心流程:

步骤1:Redis Lua脚本扣减库存        ← 本地操作1
步骤2:记录扣减流水(MySQL)        ← 本地操作2
步骤3:发送MQ消息(异步创建订单)    ← 消息发送
步骤4:返回"排队中"给用户

问题场景:
  如果步骤1、2成功,但步骤3失败(网络中断、MQ宕机)
  → 库存已扣减,但订单永远不会被创建
  → 用户抢到了库存,但永远收不到订单
  → 库存白白浪费

  如果步骤1、2回滚,但步骤3的消息已经发出去了
  → 库存没有扣减,但消费者会创建订单
  → 超卖或数据不一致

这就是分布式事务问题------两个独立的系统(Redis/MySQL 和 RocketMQ)之间,如何保证操作的原子性。

1.2 事务消息的解决思路

事务消息的核心思想是两阶段提交

  • 第一阶段:先发送一条"半消息"(此时消费者不可见),然后执行本地事务
  • 第二阶段:根据本地事务的结果,决定是"提交"(消费者可见)还是"回滚"(消息删除)

如果第二阶段因为网络中断等原因没有完成,RocketMQ会启动回查机制------主动询问生产者"你的本地事务到底成功了没",根据回复决定提交还是回滚。


二、半消息------事务消息的核心机制

疑问:什么是半消息?它和普通消息有什么区别?

回答:半消息是一条"暂时不可见"的消息------它已经存储在Broker上,但消费者无法拉取到它。只有生产者明确"提交"后,它才对消费者可见。

2.1 半消息的特殊状态

复制代码
普通消息的生命周期:
  Producer发送 → Broker存储 → Consumer可拉取 → Consumer消费 → 确认

事务消息的生命周期:
  Producer发送半消息 → Broker存储(标记为"半消息",消费者不可见)
      ↓
  Producer执行本地事务
      ↓
  ┌─ 成功 → 提交 → 半消息转为正常消息 → Consumer可拉取
  └─ 失败 → 回滚 → 半消息被删除 → Consumer永远看不到

半消息的本质是一条带"事务标记"的消息。 Broker在收到半消息后,不会将其放入可消费的队列。只有当生产者显式提交后,这条消息才会被"解锁",进入正常的消费流程。

2.2 为什么半消息能解决原子性问题?

因为半消息是在执行本地事务之前发送的。如果半消息发送失败(网络中断、Broker宕机),整个事务直接终止------本地事务根本不会执行。如果半消息发送成功但本地事务执行失败,生产者发送"回滚"指令,半消息被删除。

关键在于:消息的发送被提前到了本地事务执行之前。这样做的结果是------要么本地事务还没执行(半消息已发送但回滚),要么本地事务已执行(半消息已发送且提交)。本地事务和消息发送的中间状态被"半消息"这条保险绳兜住了。


三、事务消息的完整生命周期

疑问:事务消息从发送到最终确认,经历了哪些状态?

回答:事务消息有三个核心状态------半消息状态、已提交状态、已回滚状态。RocketMQ通过回查机制处理网络中断等异常情况。

3.1 完整状态流转

复制代码
┌─────────────────────────────────────────────────────────┐
│                 事务消息状态流转                          │
│                                                         │
│  1. Producer发送半消息                                    │
│     → Broker存储,标记为"事务消息",对消费者不可见          │
│                          ↓                               │
│  2. Producer执行本地事务(Redis扣库存+写流水)              │
│                          ↓                               │
│     ┌────────────┬───────┴───────┬────────────┐          │
│     ↓            ↓               ↓            ↓          │
│  本地事务成功   本地事务失败   网络中断    Producer宕机     │
│     ↓            ↓               ↓            ↓          │
│  发送COMMIT   发送ROLLBACK   RocketMQ感知   RocketMQ感知   │
│     ↓            ↓           到超时       到超时           │
│  消息可见     消息删除         ↓            ↓             │
│                             触发回查     触发回查          │
│                                 ↓            ↓           │
│                          检查本地事务状态  检查本地事务状态  │
│                                 ↓            ↓           │
│                          COMMIT/ROLLBACK  COMMIT/ROLLBACK │
└─────────────────────────────────────────────────────────┘

3.2 回查机制------网络中断时的兜底方案

回查机制的触发条件:Producer在发送半消息后,因为网络中断、进程宕机、或者超时等原因,没有及时向Broker发送COMMIT或ROLLBACK指令。

RocketMQ的处理方式:Broker发现半消息超过一定时间(默认6秒)未被确认,主动向Producer发起回查请求------"你之前发给我的那条半消息,本地事务到底成功了没?"

Producer的回查逻辑:根据消息中的业务参数(如商品ID、用户ID),查询本地事务的执行状态------比如检查Redis中的扣减流水标记是否存在、MySQL中的扣减流水记录状态是否为"已确认"。如果存在,返回COMMIT;如果不存在,返回ROLLBACK。

如果Producer在回查后仍未回复,Broker会按照配置的回查次数(默认15次)逐步重试。超过最大回查次数后,这条半消息会被移入死信队列,等待人工处理或定时任务兜底。


四、秒杀系统中的实际实现

疑问:在秒杀项目中,事务消息具体是怎么用的?

回答:秒杀接口在Redis扣库存之前先发送半消息,然后执行扣减,根据扣减结果决定提交还是回滚。如果网络中断,回查逻辑检查Redis中的流水标记是否存在。

4.1 事务消息发送流程

java 复制代码
@Service
public class SeckillServiceImpl {
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    @Autowired
    private RedisLuaUtil redisLuaUtil;
    
    @Transactional(rollbackFor = Exception.class)
    public SeckillResult executeSeckill(SeckillReq req) {
        // ===== 阶段一:发送半消息 =====
        // 此时消费者还看不到这条消息
        String transactionId = rocketMQTemplate.sendMessageInTransaction(
            "seckill-order-topic",
            MessageBuilder.withPayload(buildOrderMessage(req)).build(),
            req.getSkuId()  // 业务参数,会传给事务监听器
        );
        
        // ===== 阶段二:执行本地事务 =====
        // Redis Lua原子扣减库存 + 写入流水标记
        Long remain = redisLuaUtil.deductStock(
            req.getSkuId(), req.getUserId(), req.getCount()
        );
        
        if (remain < 0) {
            // 库存不足 → 本地事务失败
            // Spring事务回滚 → RocketMQ感知到异常 → 自动回滚半消息
            throw new BizException("库存不足");
        }
        
        // 本地事务成功 → Spring事务提交 → RocketMQ感知到提交 → 提交半消息
        return SeckillResult.builder()
            .status("PROCESSING")
            .transactionId(transactionId)
            .message("排队中,请稍后查看订单")
            .build();
    }
}

4.2 事务监听器------执行本地事务和回查逻辑

java 复制代码
@Component
@RocketMQTransactionListener
public class SeckillTransactionListener implements RocketMQLocalTransactionListener {
    
    @Autowired
    private RedisLuaUtil redisLuaUtil;
    
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        // 本地事务已经在 sendMessageInTransaction 之前执行了
        // 这里返回 UNKNOWN,让 MQ 通过回查来确认
        return RocketMQLocalTransactionState.UNKNOWN;
    }
    
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
        // MQ回查:检查Redis中的流水标记是否存在
        Long skuId = (Long) msg.getHeaders().get("skuId");
        Long userId = (Long) msg.getHeaders().get("userId");
        
        // 如果 Redis 中的流水标记还存在 → 说明扣减成功了 → COMMIT
        boolean exists = redisLuaUtil.checkDeductLogExists(userId, skuId);
        return exists 
            ? RocketMQLocalTransactionState.COMMIT 
            : RocketMQLocalTransactionState.ROLLBACK;
    }
}

4.3 Lua脚本中的流水标记

lua 复制代码
-- 扣减库存的Lua脚本
local stock = redis.call('get', KEYS[1])        -- 查库存
if stock and tonumber(stock) >= tonumber(ARGV[1]) then
    redis.call('decrby', KEYS[1], ARGV[1])      -- 扣库存
    -- 同时写入流水标记,供回查使用
    redis.call('setex', KEYS[2], 3600, ARGV[2] .. ':' .. ARGV[1])
    return stock - ARGV[1]
else
    return -1  -- 库存不足
end

回查时检查的就是这个流水标记。 如果标记存在,说明Redis扣减成功了,应该COMMIT。如果标记不存在,说明扣减失败或者根本没执行到这一步,应该ROLLBACK。


五、多层兜底------事务消息的可靠性保障

疑问:如果回查也失败了,或者Redis宕机导致流水标记丢失怎么办?

回答:四层保障层层递进------半消息发送失败直接终止流程,RocketMQ回查从Redis/MySQL找证据,死信队列兜底最后一层残留数据,定时对账从MySQL流水表做最终纠正。

复制代码
第一层:半消息发送失败 → 直接抛异常,本地事务不执行
第二层:本地事务失败 → Spring事务回滚 → RocketMQ感知到异常 → 回滚半消息
第三层:网络中断没收到提交/回滚指令 → RocketMQ回查 → 检查Redis流水标记
第四层:回查失败(Redis宕机、网络全断)→ 消息进入死信队列 → 定时任务兜底

死信队列的最终兜底

回查重试15次仍失败的消息,RocketMQ将其移入死信队列。秒杀系统有定时任务定期扫描死信队列,逐条检查:这条半消息对应的订单是否已经创建?如果已创建,忽略;如果未创建,说明当初的扣减应该被回滚------补偿Redis库存并记录异常日志。


六、事务消息 vs Seata------为什么不用Seata?

疑问:分布式事务有Seata这样的框架,为什么不用Seata而是用事务消息?

回答:事务消息解决的是"一个本地操作+一个消息发送"的原子性问题,Seata解决的是"多个服务间的分布式事务"问题。两者解决的场景不同,复杂度也完全不同。

维度 RocketMQ事务消息 Seata AT模式 Seata TCC模式
解决什么问题 本地操作+消息发送的原子性 多个服务间的分布式事务 多个服务间的分布式事务
实现复杂度 较低(半消息+回查) 中(需要Undo Log表) 高(需要实现Try/Confirm/Cancel三个接口)
性能影响 几乎无影响 有一定影响(Undo Log写入) 业务侵入性强
适用场景 异步解耦场景 同步调用的分布式事务 需要两阶段提交的复杂业务

秒杀场景为什么选事务消息? 因为秒杀的核心分布式事务是一个操作组合------"Redis扣库存+发MQ消息"。这个场景天然适合事务消息:发消息之前先发半消息,本地事务完成后决定提交还是回滚。如果引入Seata,需要额外部署Seata Server、每个数据库都需要建Undo Log表、而且还存在全局事务锁和性能开销------这些都是不必要的复杂度。

什么时候该用Seata? 当你需要"订单服务写数据库+库存服务写数据库+优惠券服务写数据库"这三个服务同时成功或同时失败时,事务消息已经不够用了------因为事务消息只保证"一个本地操作+一个消息发送"的原子性,不能保证"A服务写库+B服务写库"的跨服务一致性。这是Seata的AT或TCC模式要解决的场景。秒杀流程中只有一个本地操作+一次消息发送,不需要Seata介入。


七、面试中这样回答

面试官:"RocketMQ的事务消息是怎么实现的?"

回答框架

"事务消息通过半消息+回查机制实现分布式事务。Producer先发送半消息------这时消费者看不到。然后执行本地事务------比如Redis扣库存。如果成功,提交半消息让它变成正常消息;如果失败,回滚半消息让它被删除。如果因为网络中断,Broker没收到提交或回滚的指令,Broker会启动回查------主动询问Producer本地事务到底成功了没。Producer通过检查Redis中的流水标记或者MySQL中的扣减流水记录来回复。回查重试15次仍失败的消息进入死信队列,由定时任务最终兜底。"

面试官:"为什么不用Seata?"

回答

"秒杀的核心分布式事务是'Redis扣库存+发MQ消息'这个操作组合的原子性------RocketMQ的事务消息正好解决这个场景。Seata适合'A服务写库+B服务写库'这种跨服务的分布式事务,引入了Undo Log和全局事务锁,实现复杂度和性能开销都比事务消息大。秒杀场景用事务消息刚好匹配,用Seata反而过度设计。"


总结

  • 事务消息解决本地操作和消息发送的原子性------核心应用场景是"本地写数据库+发MQ消息"这个组合的最终一致性
  • 半消息是Broker中标记为"暂时不可见"的消息------只有被显式COMMIT后才对消费者可见。半消息发送成功后本地事务才开始执行
  • 状态流转:半消息→COMMIT→正常消息;半消息→ROLLBACK→删除。网络中断时触发回查,Broker主动询问Producer本地事务结果
  • 回查逻辑检查Redis流水标记或MySQL扣减记录------有标记就COMMIT,没有就ROLLBACK。回查重试15次后入死信队列,定时任务做最终兜底
  • 事务消息 vs Seata:事务消息解决"一个本地操作+一个消息发送"的原子性,Seata解决"多个服务间"的分布式事务。秒杀场景匹配事务消息,不需要引入Seata的全局事务锁和Undo Log开销

下一篇预告:RocketMQ消息可靠性保障------从生产到消费的五道防线。拆解生产者重试、Broker持久化与主从同步、消费者重试、死信队列、定时对账五道防线各自防什么、如何配置、以及秒杀项目中每一道的实际参数。

相关推荐
momom2 小时前
分布式缓存集群高可用架构与一致性哈希优化实践
分布式·后端·架构
heimeiyingwang3 小时前
【架构实战】分布式事务TCC模式:两阶段提交的工程艺术
分布式·架构
GIS数据转换器3 小时前
蓄能电力大数据监管平台
大数据·人工智能·分布式·数据挖掘·数据分析·智慧城市
zhangzeyuaaa3 小时前
Kafka 核心原理超通俗详解|Offset、消费组、分区、持久化一次讲透
分布式·kafka
隔壁阿布都3 小时前
Kafka `acks` 参数取值全解
分布式·kafka
卷毛迷你猪3 小时前
小肥柴的Hadoop之旅 快速实验篇(0-1)虚拟机模拟完全分布式环境搭建
大数据·hadoop·分布式
飞火流星020273 小时前
Hadoop3.1.1集群+Hive3.1.0环境安装
大数据·hadoop·分布式·hadoop3.1.1集群安装·hive3.1.0安装
Shota Kishi4 小时前
解析 Solana 网络结构:通过领导者调度、验证者分布与质押集中度理解分布式区块生产
分布式·web3·去中心化·区块链
科技AI训练师4 小时前
2026年清虹分布式坐席系统如何破局技术内卷与运维成本困局
运维·分布式