RocketMQ 怎么保证消息的顺序性?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

  1. 顺序性概念理解:面试官不仅仅是想知道 RocketMQ "能不能保序",更是想考察你是否清楚 "全局有序" 和 "局部有序" 的区别,以及大多数业务场景只需要局部有序就够了。

  2. 实现原理掌握:考察你是否理解顺序消息的实现机制——Producer 端的队列选择策略、Broker 端的单 Queue 存储、Consumer 端的单线程消费,三者缺一不可。

  3. 实践经验与权衡:面试官想看你是否知道顺序消息的性能代价,以及在生产环境中如何处理 "顺序性 vs 吞吐量" 的权衡问题。

核心答案

RocketMQ 提供 两种级别 的顺序保证:

顺序级别含义实现方式性能适用场景
全局有序所有消息严格按发送顺序消费Topic 只有一个 Queue极低极少使用
局部有序(分区有序)同一业务 key 的消息有序相同 key 路由到同一 Queue较高主流方案

核心思路:Producer 将需要保序的消息发到同一个 MessageQueue,Consumer 对每个 MessageQueue 单线程顺序消费。这就是 "局部有序" 的实现原理。

深度解析

一、为什么消息会乱序?

先搞清楚消息在哪些环节可能乱序:

上图展示了消息在三个环节可能出现乱序的原因:

  • Producer 端(乱序点 1):默认的轮询策略会将消息分散到不同的 Queue,不同 Queue 之间本身就没有顺序保证。消息 A 和消息 B 如果发到了不同的 Queue,消费顺序就无法保证。
  • Broker 端(乱序点 2):虽然同一个 Queue 内消息是严格有序的(按写入顺序排列),但跨 Queue 的消息没有先后关系。
  • Consumer 端(乱序点 3):即使消息在同一个 Queue 中有序,如果 Consumer 用多线程并发消费,也会打乱顺序。比如 Thread2 可能比 Thread1 先处理完。

二、RocketMQ 的顺序消息方案

要保证顺序,需要 Producer、Broker、Consumer 三端配合

上图展示了 RocketMQ 实现局部有序的完整链路,核心就是 "三端配合"

  • Producer 端:通过 MessageQueueSelector 将相同业务 key(如订单 ID)的消息路由到同一个 Queue。RocketMQ 使用 hash(key) % Queue 数量 来确定目标 Queue,保证同一个 key 的消息始终进入同一个 Queue。

  • Broker 端:同一个 Queue 内的消息天然按写入顺序排列(CommitLog 顺序写),不需要额外处理。

  • Consumer 端:使用 MessageListenerOrderly(顺序监听器)替代 MessageListenerConcurrently(并发监听器),每个 Queue 只用一个线程消费,保证消费顺序和写入顺序一致。

三、Producer 端:队列选择策略

// 发送顺序消息的核心:指定队列选择器
SendResult result = producer.send(msg, new MessageQueueSelector() {
    @Override
    public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
        // arg 就是下面传入的 orderId
        Long orderId = (Long) arg;

        // 根据订单 ID 取模,选择固定的 Queue
        // 相同 orderId 的消息永远进同一个 Queue
        int index = (int) (orderId % mqs.size());
        return mqs.get(index);
    }
}, orderId);  // orderId 作为选择依据

关键点

  • MessageQueueSelector 的第三个参数 arg 就是用于计算队列下标的路由 key
  • 常见的路由策略:hash(key) % Queue数量 或直接 key % Queue数量
  • 只要 key 相同,选出的 Queue 就相同,消息就天然有序

四、Consumer 端:顺序消费监听器

// 关键:使用 MessageListenerOrderly,而不是 MessageListenerConcurrently
consumer.registerMessageListener(new MessageListenerOrderly() {
    @Override
    public ConsumeOrderlyStatus consumeMessage(
            List<MessageExt> msgs, ConsumeOrderlyContext context) {
        try {
            // 这里的 msgs 是同一个 Queue 中的消息,按顺序排列
            for (MessageExt msg : msgs) {
                System.out.println("收到消息:" + new String(msg.getBody()));
            }
            return ConsumeOrderlyStatus.SUCCESS;
        } catch (Exception e) {
            // 消费失败,挂起当前队列一段时间后重试
            return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
        }
    }
});

五、顺序监听器 vs 并发监听器

对比维度MessageListenerOrderlyMessageListenerConcurrently
消费方式每个 Queue 单线程顺序消费多线程并发消费
顺序保证同一 Queue 内严格有序不保证顺序
消费吞吐量较低(受限于 Queue 数量)高(多线程并行)
失败处理挂起当前 Queue,稍后重试直接重试(可能乱序)
适用场景订单状态变更、数据同步日志处理、通知推送

源码层面的区别

  • MessageListenerOrderly 内部对每个 MessageQueue 加了一把锁(ProcessQueueconsumeLock),保证同一个 Queue 同一时刻只有一个线程在消费
  • MessageListenerConcurrently 没有这把锁,多个线程可以同时消费同一个 Queue 的不同消息

六、顺序消息的注意事项

1. 重试可能导致重复消费

上图展示了顺序消息消费失败时的处理逻辑:

  • 消息 M2 消费失败后,返回 SUSPEND_CURRENT_QUEUE_A_MOMENT,Consumer 会挂起当前 Queue,稍后重新从 M2 开始消费
  • 关键点:M3、M4 会被 M2 "阻塞",必须等 M2 消费成功后才能继续。这就是顺序消费的代价——一条消息卡住,后面的全部等待。
  • 因此,顺序消息场景下一定要做好 幂等处理,因为重试可能导致前面的消息被重复消费。

2. Queue 数量决定了最大并行度

  • 如果 Topic 只有 4 个 Queue,那么同一个 key 的消息最多只能由 4 个消费线程处理
  • 需要在创建 Topic 时预估好 Queue 数量,兼顾顺序性和吞吐量

3. Broker 宕机可能导致短暂乱序

  • 如果某个 Queue 所在的 Broker 宕机,该 Queue 的消息会转移到其他 Broker
  • 在主从切换期间,可能会有短暂的顺序不一致

面试高频追问

  1. 追问一:顺序消息失败了怎么办?会不会阻塞后续消息?

    会。MessageListenerOrderly 消费失败后会挂起当前 Queue,后续消息会等待重试。所以业务逻辑要做好幂等,且尽量保证消费成功。

  2. 追问二:RocketMQ 能保证全局有序吗?

    可以,但代价很高——Topic 只能有一个 Queue,所有消息只能串行处理,吞吐量极低。生产环境基本不用,绝大多数场景用局部有序就够了。

  3. 追问三:如果需要保序的消息 key 很多,Queue 数量不够怎么办?

    会有 hash 冲突,不同 key 的消息会落到同一个 Queue。这不影响正确性(同一 key 仍然有序),只是增加了单 Queue 的负载。建议 Queue 数量 >= 预估的并发 key 数量。

  4. 追问四:顺序消息和普通消息可以在同一个 Topic 混用吗?

    可以,但不建议。混用会导致普通消息也被限制为单线程消费,浪费性能。建议顺序消息和普通消息使用不同的 Topic。

常见面试变体

  • 变体一:RocketMQ 的局部有序和全局有序有什么区别?
  • 变体二:怎么保证同一个订单的消息按顺序消费?
  • 变体三:MessageListenerOrderlyMessageListenerConcurrently 有什么区别?
  • 变体四:顺序消息的性能比普通消息差多少?为什么?

记忆口诀

"同 key 同 Queue,单线程顺序走"——Producer 用 hash 路由保证相同 key 的消息进同一个 Queue,Consumer 用 MessageListenerOrderly 保证每个 Queue 单线程按序消费。三端配合:发对队列、存对顺序、消费排队。

总结

RocketMQ 通过 "Producer 端队列选择 + Broker 端单 Queue 有序 + Consumer 端单线程消费" 三端配合实现局部有序。生产环境绝大多数场景只需要局部有序——相同业务 key(如订单 ID)的消息路由到同一个 Queue,由单线程顺序消费即可。代价是吞吐量受限于 Queue 数量,消费失败会阻塞后续消息,因此务必做好幂等处理。