前言在上一篇中我们拆解了RocketMQ的四大核心角色和一条消息的完整旅程。但秒杀系统中最关键的技术点还没有展开事务消息。在秒杀流程中Redis扣库存和发送MQ消息是两个独立的操作——Redis扣减成功但MQ消息发送失败时怎么办MQ消息发出去了但Redis扣减实际没完成怎么办这就是分布式事务要解决的问题。面试中事务消息是RocketMQ最深的水区“RocketMQ的事务消息是怎么实现的半消息是什么”“回查机制在什么情况下触发回查时你怎么判断事务是提交还是回滚”“如果Redis扣减成功但RocketMQ没收到提交指令这个状态怎么恢复”“事务消息和分布式事务框架Seata有什么区别为什么不用Seata”这些问题只背八股是答不出来的。本文从半消息的原理出发拆解事务消息的完整流程最终落到秒杀系统中的实际实现。本文核心问题为什么需要事务消息它解决什么场景下的什么问题半消息是什么它和普通消息有什么区别事务消息的完整生命周期是怎样的从发送到提交/回滚经历了哪些状态回查机制在什么情况下触发回查时你怎么判断本地事务的状态如果回查也失败了消息会怎样有没有最终兜底方案秒杀系统中如何用事务消息保证Redis扣库存和发送消息的原子性事务消息和Seata的AT模式、TCC模式有什么区别为什么选事务消息读完本文你将对RocketMQ事务消息拥有从原理到实战的完整理解。一、为什么需要事务消息疑问事务消息解决什么问题普通消息做不到吗回答事务消息解决的是本地操作和消息发送的原子性问题——要么两个都成功要么两个都失败。普通消息无法保证这一点。1.1 秒杀场景下的分布式事务问题秒杀接口的核心流程 步骤1Redis 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(rollbackForException.class)publicSeckillResultexecuteSeckill(SeckillReqreq){// 阶段一发送半消息 // 此时消费者还看不到这条消息StringtransactionIdrocketMQTemplate.sendMessageInTransaction(seckill-order-topic,MessageBuilder.withPayload(buildOrderMessage(req)).build(),req.getSkuId()// 业务参数会传给事务监听器);// 阶段二执行本地事务 // Redis Lua原子扣减库存 写入流水标记LongremainredisLuaUtil.deductStock(req.getSkuId(),req.getUserId(),req.getCount());if(remain0){// 库存不足 → 本地事务失败// Spring事务回滚 → RocketMQ感知到异常 → 自动回滚半消息thrownewBizException(库存不足);}// 本地事务成功 → Spring事务提交 → RocketMQ感知到提交 → 提交半消息returnSeckillResult.builder().status(PROCESSING).transactionId(transactionId).message(排队中请稍后查看订单).build();}}4.2 事务监听器——执行本地事务和回查逻辑ComponentRocketMQTransactionListenerpublicclassSeckillTransactionListenerimplementsRocketMQLocalTransactionListener{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 中的流水标记还存在 → 说明扣减成功了 → COMMITbooleanexistsredisLuaUtil.checkDeductLogExists(userId,skuId);returnexists?RocketMQLocalTransactionState.COMMIT:RocketMQLocalTransactionState.ROLLBACK;}}4.3 Lua脚本中的流水标记-- 扣减库存的Lua脚本localstockredis.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持久化与主从同步、消费者重试、死信队列、定时对账五道防线各自防什么、如何配置、以及秒杀项目中每一道的实际参数。