RocketMQ如何实现与其它事务的一致性

以"秒杀券"业务为例,业务流程为Redis校验库存与一人一单合法性-->发送消息给RocketMQ消费者-->消费者取消息并入库创建订单。但是这个过程并不是原子性的,即使Redis可以通过Lua脚本保证原子性,由于它们分属于不同的数据源和进程,无法使用传统的数据库事务(ACID)来保证原子性。

为了解决这一问题,RocketMQ 提供了**事务消息(Transactional Message)**机制,通过"二次确认"和"状态回查"来确保本地操作与消息发送的最终一致性。

以下是该业务逻辑的深度解析。


分布式一致性的挑战

在秒杀场景下,如果按顺序执行"Redis 扣减 →\rightarrow→ 发送 MQ →\rightarrow→ 数据库下单",会面临两个无法回避的风险点:

  • 执行成功但发送失败 :Redis 成功执行了 Lua 脚本(库存减 111),但由于网络波动,MQ 消息发送失败。此时库存被白白扣除,但数据库从未生成订单,导致数据缺失。
  • 发送成功但执行失败:MQ 消息已发出,但随后的本地代码发生异常(如宕机)。消费者收到了消息并向数据库写入了订单,但 Redis 里的库存却并未真正扣除,导致超卖。

1.预备阶段------半消息(Half Message)

为了确保 MQ 服务器(Broker)在业务开始前是可用的,流程从 半消息 开始:

  1. 发送半消息:生产者在执行任何 Redis 逻辑之前,先向 RocketMQ 发送一条包含了"订单 ID"、"用户 ID"和"券 ID"的消息。
  2. 消息暂存(不可见):Broker 接收到该消息并持久化,但将其标记为"不可投递"状态。此时消费者(Consumer)完全感知不到这条消息的存在。
  3. 响应确认:只有当生产者收到了 Broker 的成功响应,才代表通信链路通畅,此时才会进入核心业务逻辑。

2.核心阶段------本地事务执行

在确认 MQ 已经接到了半消息后,生产者开始在本地执行关键操作:

  1. 执行 Lua 脚本:在 Redis 内部完成原子性校验(判断库存是否存在、判断该用户是否已下单)。
  2. 原子性写入标记 :如果校验通过,Lua 脚本会同时完成两件事:
    • DECR 扣减库存。
    • SADD 将该用户 ID 加入"已购名单"集合。
  3. 提交状态给 MQ
    • 成功(COMMIT):如果 Lua 返回成功,生产者告知 Broker 提交消息。Broker 将该消息改为"可见",消费者开始异步入库。
    • 失败(ROLLBACK):如果 Lua 返回失败(如库存不足),生产者告知 Broker 删除该消息。

3.补救机制------事务回查(Check Logic)

如果生产者在执行完 Lua 脚本后突然宕机,或者网络中断导致 COMMIT/ROLLBACK 信号未能到达 Broker,分布式事务将进入回查逻辑

  1. 发起询查:Broker 发现某条半消息停留时间过长,会主动向生产者集群发起询问:"这条消息对应的本地业务到底成了没?"
  2. 查询 Redis 凭证 :生产者的回查监听器(Listener)被触发。它不需要重新跑一遍 Lua 脚本,而是去 Redis 的"已购名单"集合中查询:"该用户 ID 是否存在于对应的券 ID 集合中?"
  3. 反馈结果
    • 存在 :说明之前 Lua 脚本成功执行了,只是通知 MQ 时断了,返回 COMMIT
    • 不存在 :说明之前执行失败或根本没跑,返回 ROLLBACK

4.最终阶段------消费者幂等入库

当消息最终变为"可见"后,消费者从队列拉取消息进行数据库写入:

  1. 异步持久化 :消费者将消息内容插入 MySQL 的 voucher_order 表。
  2. 处理失败重试:如果数据库由于繁忙导致写入失败,RocketMQ 会根据梯度时间自动发起重试(阶梯退避)。
  3. 幂等性保证 :为了防止 MQ 的重复投递,消费者在写入前必须通过 数据库唯一索引 (如:用户 ID + 券 ID 为联合主键)或 前置查询 来确保:即便同一条消息被消费了多次,数据库里也只会有一条订单记录。

关键逻辑

为了保证 Redis 扣减与数据库下单的一致性,系统采用 RocketMQ 事务消息 逻辑:Java 先向 MQ 发送一条消费者不可见的 半消息(用于确认 MQ 服务可用);随后在本地执行 Redis Lua 脚本进行库存扣减与资格校验。

  • 若 Lua 执行成功 :Java 向 MQ 发送 COMMIT 信号,使消息对消费者可见,驱动下游入库。
  • 若 Lua 执行失败 :Java 向 MQ 发送 ROLLBACK 信号,MQ 直接丢弃该消息。
  • 若执行后宕机(信号丢失) :MQ 会定期发起 回查(Check),Java 接收请求后通过 Redis 中的成功标记(如已购名单)判断业务实况,补发 COMMIT 或 ROLLBACK。

通过这种"先占位、后业务、崩了就回查"的机制,强制确保了 MQ 消息状态与 Redis 扣减结果的最终一致。


代码实现

第一步:Service 层触发事务消息

这是业务入口。注意:发送半消息失败会直接抛出异常,不会执行后续逻辑。

java 复制代码
@Service
@RequiredArgsConstructor
public class VoucherOrderServiceImpl implements VoucherOrderService {
    private final VoucherOrderProducer voucherOrderProducer;
    private final RedisIdWorker redisIdWorker;

    @Override
    public VoucherOrderDO seckillVoucherLua(Long voucherId) {
        Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
        Long orderId = redisIdWorker.nextId("voucher:order");

        // 【步骤1】发送半消息并等待本地事务结果
        // 若此处抛出异常,说明 MQ 挂了,业务中断,Redis 逻辑不会执行
        TransactionSendResult result = voucherOrderProducer.sendSeckillOrderTransaction(orderId, voucherId, loginUserId);

        // 【步骤4】处理最终一致性结果
        // 若本地事务(Lua)执行失败或回滚,result 状态非 COMMIT,需抛异常给前端
        if (result.getLocalTransactionState() != LocalTransactionState.COMMIT_MESSAGE) {
            throw new ServiceException(500, "抢购失败,库存不足或重复下单");
        }
        
        return VoucherOrderDO.builder().id(orderId).voucherId(voucherId).userId(loginUserId).build();
    }
}

第二步:Producer 封装发送逻辑

负责构建消息体并调用 RocketMQ 模板。

java 复制代码
@Component
@RequiredArgsConstructor
public class VoucherOrderProducer {
    private final RocketMQTemplate rocketMQTemplate;

    public TransactionSendResult sendSeckillOrderTransaction(Long orderId, Long voucherId, Long loginUserId) {
        VoucherOrderMessage message = VoucherOrderMessage.builder()
                .orderId(orderId).voucherId(voucherId).userId(loginUserId).build();

        // sendMessageInTransaction 会先发送 Half Message,随后自动触发监听器
        return rocketMQTemplate.sendMessageInTransaction(
                VoucherOrderMqConstants.TOPIC_SECKILL,
                MessageBuilder.withPayload(message).build(),
                null // 可传参给 executeLocalTransaction 的 arg 变量
        );
    }
}

第三步:Listener 实现核心闭环

这是保证数据一致性的"灵魂"所在,包含本地逻辑执行与宕机后的状态回查。

java 复制代码
@RocketMQTransactionListener
@RequiredArgsConstructor
@Slf4j
public class VoucherOrderTransactionListener implements RocketMQLocalTransactionListener {
    private final VoucherOrderRedisDAO voucherOrderRedisDAO;
    private final StringRedisTemplate stringRedisTemplate;

    /**
     * 【步骤2】执行本地事务:操作 Redis Lua 脚本
     */
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        try {
            VoucherOrderMessage payload = JSON.parseObject(new String((byte[]) msg.getPayload()), VoucherOrderMessage.class);
            // 原子操作:校验库存、判重、扣减并 sadd 写入成功标记(用于回查)
            Long result = voucherOrderRedisDAO.executeSeckillVoucherOrderLua(payload.getVoucherId(), payload.getUserId());
            
            // 0 代表 Lua 脚本执行成功
            return result == 0 ? RocketMQLocalTransactionState.COMMIT : RocketMQLocalTransactionState.ROLLBACK;
        } catch (Exception e) {
            log.error("本地事务异常(可能是 Redis 挂了),准备回滚消息", e);
            return RocketMQLocalTransactionState.ROLLBACK;
        }
    }

    /**
     * 【步骤3】回查机制:处理网络抖动或 Java 后端意外宕机导致信号丢失
     */
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
        VoucherOrderMessage payload = JSON.parseObject(new String((byte[]) msg.getPayload()), VoucherOrderMessage.class);
        // 直接从 Redis 查询 Lua 脚本写入的"成功名单"
        Boolean isExisted = stringRedisTemplate.opsForSet().isMember(
                "seckill:order:" + payload.getVoucherId(), 
                payload.getUserId().toString());
        
        // 只要 Redis 里有记录,说明逻辑跑通了,补发 COMMIT
        return Boolean.TRUE.equals(isExisted) ? 
                RocketMQLocalTransactionState.COMMIT : RocketMQLocalTransactionState.ROLLBACK;
    }
}

第四步:Consumer 异步入库与幂等保障

java 复制代码
@Component
@RocketMQMessageListener(
    topic = VoucherOrderMqConstants.TOPIC_SECKILL,
    consumerGroup = "g_hmdp_consumer",
    consumeMode = ConsumeMode.CONCURRENTLY // 并发消费提升吞吐量
)
@RequiredArgsConstructor
@Slf4j
public class VoucherOrderConsumer implements RocketMQListener<VoucherOrderMessage> {

    private final VoucherOrderMapper voucherOrderMapper;

    @Override
    public void onMessage(VoucherOrderMessage message) {
        log.info("【步骤5】开始异步入库,订单信息:{}", message);
        
        try {
            // 1. 构造数据库实体对象
            VoucherOrderDO order = VoucherOrderDO.builder()
                    .id(message.getOrderId())
                    .voucherId(message.getVoucherId())
                    .userId(message.getUserId())
                    .build();

            // 2. 执行数据库插入
            // 【关键点】数据库层必须建立 (user_id, voucher_id) 的唯一联合索引
            // 如果消息重复投递,SQL 会触发 DuplicateKeyException,从而保证幂等
            voucherOrderMapper.insert(order);
            
            log.info("订单入库成功:{}", message.getOrderId());
        } catch (DuplicateKeyException e) {
            // 3. 幂等性兜底
            // 出现此异常说明该用户已经下过单了(数据库已经有记录)
            // 这种情况直接吞掉异常,返回成功(ACK),不再触发 MQ 重试
            log.warn("检测到重复下单消息,已自动拦截。订单ID:{}", message.getOrderId());
        } catch (Exception e) {
            // 4. 重试机制
            // 如果是非幂等类异常(如数据库宕机、连接超时),抛出异常
            // RocketMQ 会根据阶梯时间(1s 5s 10s...)自动发起重试,直到最终一致
            log.error("订单入库失败,准备重试。订单ID:{}", message.getOrderId(), e);
            throw new RuntimeException("异步入库异常,等待重试", e);
        }
    }
}

代码实现关键逻辑

以下是该流程的逻辑拆解,重点揭示被封装在内部的执行顺序:

一、 执行流程:从发送到入库
  1. 发送半消息(Half Message)
    当你在代码中调用 sendMessageInTransaction 时,底层首先向 MQ Broker 发送一条半消息
    • 关键点 :此时本地业务逻辑(如 Redis 扣减)尚未执行
    • 状态:消息已到达 MQ,但对消费者不可见。这一步是为了确认 MQ 服务可用。
  2. 触发回调(executeLocalTransaction)
    只有当半消息成功到达 Broker 并返回确认信号(ACK)后,Java 客户端才会自动触发 executeLocalTransaction 方法。
    • 动作:在此方法内执行真正的 Redis 业务逻辑(判断库存、判重、扣减)。
    • 反馈 :Java 执行完后,向 MQ 反馈结果。成功则发 COMMIT ,失败则发 ROLLBACK
  3. 消息生效与消费
    • 若 MQ 收到 COMMIT,将半消息转为普通消息,消费者(Consumer)立即拉取并执行 MySQL 入库。
    • 若 MQ 收到 ROLLBACK,直接删除半消息,数据库入库动作永远不会发生。
二、 异常处理:信号丢失后的回查

这是解决"Java 服务器突然宕机"或"网络波动导致 COMMIT 信号丢失"的核心逻辑:

  1. 超时监控
    MQ Broker 会监控所有半消息。如果某条消息在规定时间内(如 30 秒)既没收到 COMMIT,也没收到 ROLLBACK,它不会一直等待。
  2. 主动回查(checkLocalTransaction)
    MQ Broker 主动向 Java 后端集群发起请求,调用 checkLocalTransaction 方法。
    • 逻辑:由于原本执行任务的 Java 实例可能已宕机,回查请求会发送到同微服务的其他实例。
    • 核实:Java 代码去 Redis 查询是否存在"下单成功标记"。
    • 补偿:查到标记则补发 COMMIT,没查到则补发 ROLLBACK。
相关推荐
yyongsheng13 小时前
微服务项目整合rocketMq
微服务·架构·rocketmq
阿里云云原生9 天前
秒触达、零资损:亲宝宝基于 RocketMQ 支撑千万家庭实时互动与成长记录
serverless·rocketmq
初次攀爬者9 天前
RocketMQ 消息可靠性保障与堆积处理
后端·消息队列·rocketmq
用户83071968408210 天前
RabbitMQ vs RocketMQ 事务大对决:一个在“裸奔”,一个在“开挂”?
后端·rabbitmq·rocketmq
初次攀爬者10 天前
RocketMQ 集群介绍
后端·消息队列·rocketmq
初次攀爬者10 天前
RocketMQ在Spring Boot上的基础使用
java·spring boot·rocketmq
初次攀爬者10 天前
RocketMQ 基础学习
后端·消息队列·rocketmq
阿里云云原生13 天前
下单丝滑,大促自由:古茗奶茶背后的云原生力量
serverless·rocketmq
Javatutouhouduan15 天前
RocketMQ是怎么保存偏移量的?
java·消息队列·rocketmq·java面试·消息中间件·后端开发·java程序员