news 2026/5/14 8:35:25

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
第二篇: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 事务消息发送流程

@ServicepublicclassSeckillServiceImpl{@AutowiredprivateRocketMQTemplaterocketMQTemplate;@AutowiredprivateRedisLuaUtilredisLuaUtil;@Transactional(rollbackFor=Exception.class)publicSeckillResultexecuteSeckill(SeckillReqreq){// ===== 阶段一:发送半消息 =====// 此时消费者还看不到这条消息StringtransactionId=rocketMQTemplate.sendMessageInTransaction("seckill-order-topic",MessageBuilder.withPayload(buildOrderMessage(req)).build(),req.getSkuId()// 业务参数,会传给事务监听器);// ===== 阶段二:执行本地事务 =====// Redis Lua原子扣减库存 + 写入流水标记Longremain=redisLuaUtil.deductStock(req.getSkuId(),req.getUserId(),req.getCount());if(remain<0){// 库存不足 → 本地事务失败// Spring事务回滚 → RocketMQ感知到异常 → 自动回滚半消息thrownewBizException("库存不足");}// 本地事务成功 → Spring事务提交 → RocketMQ感知到提交 → 提交半消息returnSeckillResult.builder().status("PROCESSING").transactionId(transactionId).message("排队中,请稍后查看订单").build();}}

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

@Component@RocketMQTransactionListenerpublicclassSeckillTransactionListenerimplementsRocketMQLocalTransactionListener{@AutowiredprivateRedisLuaUtilredisLuaUtil;@OverridepublicRocketMQLocalTransactionStateexecuteLocalTransaction(Messagemsg,Objectarg){// 本地事务已经在 sendMessageInTransaction 之前执行了// 这里返回 UNKNOWN,让 MQ 通过回查来确认returnRocketMQLocalTransactionState.UNKNOWN;}@OverridepublicRocketMQLocalTransactionStatecheckLocalTransaction(Messagemsg){// MQ回查:检查Redis中的流水标记是否存在LongskuId=(Long)msg.getHeaders().get("skuId");LonguserId=(Long)msg.getHeaders().get("userId");// 如果 Redis 中的流水标记还存在 → 说明扣减成功了 → COMMITbooleanexists=redisLuaUtil.checkDeductLogExists(userId,skuId);returnexists?RocketMQLocalTransactionState.COMMIT:RocketMQLocalTransactionState.ROLLBACK;}}

4.3 Lua脚本中的流水标记

-- 扣减库存的Lua脚本localstock=redis.call('get',KEYS[1])-- 查库存ifstockandtonumber(stock)>=tonumber(ARGV[1])thenredis.call('decrby',KEYS[1],ARGV[1])-- 扣库存-- 同时写入流水标记,供回查使用redis.call('setex',KEYS[2],3600,ARGV[2]..':'..ARGV[1])returnstock-ARGV[1]elsereturn-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持久化与主从同步、消费者重试、死信队列、定时对账五道防线各自防什么、如何配置、以及秒杀项目中每一道的实际参数。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/14 8:28:23

Tree of Thoughts详解:思维树搜索算法

&#x1f333; 多路径探索 | 广度优先 深度优先搜索 | 自我评估 回溯机制 | LangChain实现 | 完整项目代码 &#x1f4d6; 什么是Tree of Thoughts&#xff1f; 核心思想 ToT Tree of Thoughts&#xff08;思维树&#xff09; 传统LLM: 输入 → 线性思考 → 输出&#xf…

作者头像 李华
网站建设 2026/5/14 8:21:37

AgentHeroes:构建全栈AI智能体平台,实现AIGC工作流自动化

1. 项目概述与核心价值 如果你和我一样&#xff0c;对AI生成内容&#xff08;AIGC&#xff09;的自动化流程感兴趣&#xff0c;特别是想打造一个能自己“思考”、自己“干活”的数字角色&#xff0c;那么你肯定会对 AgentHeroes 这个开源项目眼前一亮。它不是一个简单的AI绘…

作者头像 李华
网站建设 2026/5/14 8:21:34

Web3开发实战:基于WDK-SKILL与Tether WDK的多链钱包集成指南

1. 项目概述&#xff1a;当AI助手学会帮你集成钱包 如果你是一名Web3开发者&#xff0c;或者正打算进入这个领域&#xff0c;那么“集成钱包”这个任务&#xff0c;大概率是你项目启动时遇到的第一个&#xff0c;也是最令人头疼的“拦路虎”。从生成助记词、管理私钥&#xff…

作者头像 李华
网站建设 2026/5/14 8:21:23

Adobe-GenP 3.0:5步掌握Adobe Creative Cloud终极激活方案

Adobe-GenP 3.0&#xff1a;5步掌握Adobe Creative Cloud终极激活方案 【免费下载链接】Adobe-GenP Adobe CC 2019/2020/2021/2022/2023 GenP Universal Patch 3.0 项目地址: https://gitcode.com/gh_mirrors/ad/Adobe-GenP Adobe-GenP 3.0是一款专为Adobe Creative Clo…

作者头像 李华
网站建设 2026/5/14 8:21:02

CopilotKit:在React/Next.js应用中快速集成AI助手的全栈框架

1. 项目概述&#xff1a;从“副驾驶”到“AI应用内嵌引擎”的蜕变如果你在过去一年里关注过AI应用开发&#xff0c;尤其是如何将类似ChatGPT的对话能力无缝集成到自己的Web应用中&#xff0c;那么“CopilotKit”这个名字大概率已经出现在你的视野里。它不是一个独立的聊天机器人…

作者头像 李华
网站建设 2026/5/14 8:17:17

零依赖Node.js工具:分析AI编程对话情绪与沟通模式

1. 项目概述&#xff1a;一个为AI编程对话“把脉”的趣味工具 如果你和我一样&#xff0c;日常重度依赖 Claude Code 这类AI编程助手&#xff0c;那你一定有过这样的时刻&#xff1a;面对一段死活调不通的代码&#xff0c;或者一个怎么也解释不清的逻辑&#xff0c;从最初的耐…

作者头像 李华