RocketMQ 存储模型拆解:别再拿它当 BlockingQueue 用了

0. 引言:一个 Javaer 的认知崩塌

每一个刚接触 RocketMQ 的 Java 开发者,大概都会经历一次认知崩塌:

翻开 RocketMQ 源码,找遍所有 package,也找不到一个像 BlockingQueue 那样的 Queue 实现。作为一个"消息队列"(Message Queue),它的 Queue 到底在哪里?如果找不到 Queue 对象,消息是怎么"入队"和"出队"的?

答案很残酷,但也很优雅:RocketMQ 根本没有什么 Queue 数据结构。 RocketMQ 的 Queue 是一个文件夹——硬盘上的文件夹。消息不是"入队",而是 append 到磁盘文件末尾。消费者不是"出队",而是从磁盘文件读取一段定长的字节数组。

整个 RocketMQ,本质上就是一套精心设计的磁盘文件操作方案。

所有的分布式消息特性——消息重试、死信队列、消息积压、限流熔断——都是在这套磁盘文件模型上玩出的花样。理解了 RocketMQ 的文件长什么样、文件之间怎么关联,你就理解了 RocketMQ 的一切。

这篇从最底层开始,一层层往上拆,共七层:

  1. NameServer(路由层)——只管路标,不存消息
  2. CommitLog(存储层)——所有消息顺序写入的大文件
  3. ConsumeQueue(索引层)——被误称为队列的定长指针数组
  4. Topic 参数(运维配置)——目录数量和权限的配置映射
  5. 高级特性——基于文件模型的策略实现
  6. 开发视角——你的代码在操作什么
  7. 总结——一张物理模型图覆盖所有概念

1. 第一层:NameServer(路由层)

NameServer 是 RocketMQ 的路由层。它是一个轻量级的注册中心——只维护 Topic 到 Broker 地址的映射关系,不存储任何消息数据

传统模式

在 RocketMQ 的传统模式下,NameServer 内存中维护着两张核心映射表:

  • BrokerData:Topic 名称 → Broker IP 列表。消费者拿到这个列表,就知道该从哪台机器拉数据。
  • QueueData:Queue 数量 + 权限设置。Broker 上报时携带每个 Topic 配置了多少 Queue。

生产者发消息时先问 NameServer:“TopicA 在哪台机器上?有几个 Queue?” NameServer 返回 Broker 地址列表,生产者选择一个 Broker 发过去。消费者同理。

5.x LiteTopic 变化

RocketMQ 5.x 引入的 LiteTopic 做了一个关键变化:QueueData 从 NameServer 拆分出去,存储到 Broker 本地。 这意味着 NameServer 不再维护 Queue 级的路由信息,只维护 Topic → Broker 的粗粒度映射。Queue 数量的管理下沉到 Broker 端。

这个变化的设计动机是大规模 Topic 场景(LLM 对话、IoT 设备管理)——每个对话、每台设备都可能需要属于自己的 Topic。如果有 10 万个 Topic,每个 Topic 8 个 Queue,NameServer 要维护 80 万条 QueueData。LiteTopic 把这个压力卸给了 Broker。

代价也很实际:运维复杂度上升了。传统模式下 NameServer 重启后可以从 Broker 全量拉回路由信息,LiteTopic 下 Broker 本地存储的 QueueData 需要额外关注冷备策略。

NameServer 的本质

NameServer = 路标(road sign),不是说明书(manual)。它告诉你地址在哪,但不告诉你地址上有什么。就像 DNS——你知道 google.com 对应哪台机器,但不知道那台机器在运行什么代码。

flowchart TD
    NS(["NameServer\n路由注册中心"])
    B1["Broker-A\nTopic: OrderTopic"]
    B2["Broker-B\nTopic: OrderTopic"]
    P(["Producer\n生产者"])
    C(["Consumer\n消费者"])

    B1 -- "注册路由" --> NS
    B2 -- "注册路由" --> NS
    P -- "获取路由(Topic → Broker)" --> NS
    C -- "获取路由(Topic → Broker)" --> NS
    P -- "发送消息" --> B1
    P -- "发送消息" --> B2
    C -- "消费消息" --> B1

    classDef process fill:#bbf,stroke:#333,stroke-width:2px;
    classDef data fill:#9f9,stroke:#333,stroke-width:2px;
    class P,C process;
    class NS data;
    class B1,B2 process;

2. 第二层:CommitLog(存储层 - 消息体)

CommitLog 是 RocketMQ 中真正存放消息体的文件。物理路径默认为 store/commitlog/,文件按固定大小(默认 1GB = 1073741824 字节)分片,写满一个就创建下一个。文件名就是文件起始的物理偏移量,比如 00000000000000000000 表示偏移量 0 开始, 00000000001073741824 表示偏移量 1073741824 开始。

核心特征

  • 所有 Topic 混合写入:OrderTopic、PayTopic、LogTopic 的消息体全部按到达顺序依次写入同一个 CommitLog 文件链,不做任何隔离。
  • 仅追加,永不修改:CommitLog 是一个只增不减的字节数组。已写入的消息不会被修改,除非文件过期被删除。
  • 无锁顺序 IO:写 CommitLog 只需要在写入位置加一把锁,保证内存指针安全移动。磁盘层面是顺序追加,速度远快于随机 IO。

CommitLog.putMessage() 源码

// CommitLog.java (RocketMQ 4.9.x) — 消息追加核心
public PutMessageResult putMessage(MessageExtBrokerInner msg) {
    // 1. 获取当前文件末尾的写入位置
    //    所有 Topic 的消息都追加在这里,没有隔离
    this.lock.writeLock().lockInterruptibly();
    try {
        // 2. 将消息体追加到当前的 MappedFile
        //    MappedFile 是对 1GB 文件的 mmap 封装
        AppendMessageResult result = this.appendMessage(msg);
        return new PutMessageResult(PutMessageStatus.PUT_OK, result);
    } finally {
        this.lock.writeLock().unlock();
    }
    // 3. 返回的结果中包含物理偏移量(offset)
    //    这个 offset 后续会写入 ConsumeQueue,用作索引
}

执行流程: putMessage() 拿到锁 → 找到当前活跃的 MappedFile → 把消息体追加到文件末尾 → 返回物理偏移量。就这么简单。没有分 Topic 写不同的文件,没有 WAL 写两遍——就一个文件链,一条消息接着一条消息地写。

CommitLog 的隐喻

CommitLog 虽然叫 LOG,但它本质上是一个 LIBRARY(图书馆),不是 LOG。每一条消息就是一本书(消息体),所有 Topic 的书按到达顺序排列在书架上。你想找某个 Topic 的书?你得先去索引查——那是 ConsumeQueue 的工作。


3. 第三层:ConsumeQueue(索引层 - 被误称为队列)

ConsumeQueue 是 RocketMQ 存储模型中最容易被误解的部分。很多人以为它"存储消息"——大错特错。

ConsumeQueue 不存储消息,它存储的是消息的索引。

物理路径与结构

store/consumequeue/{Topic}/{QueueId}/

每个 Topic 的每个 Queue 对应一个 ConsumeQueue 文件。如果 OrderTopic 有 8 个 Queue,磁盘上就是:

store/consumequeue/OrderTopic/0/
store/consumequeue/OrderTopic/1/
...
store/consumequeue/OrderTopic/7/

20 字节定长条目

每条 ConsumeQueue 条目刚好 20 个字节,不多不少:

[0-7]   CommitLog 物理偏移量(8 字节 long)
[8-11]  消息体长度(4 字节 int)
[12-19] Tag 哈希值(8 字节 long)

因为是定长结构,消费者按索引位置读取时能做到 O(1) 随机访问:要读第 N 条,直接从文件偏移 N × 20 处读取 20 字节即可。

ConsumeQueue.putMessagePositionInfo() 源码

// ConsumeQueue.java — 每个条目仅 20 字节
public class ConsumeQueue {
    // 固定条目大小
    public static final int CQ_STORE_UNIT_SIZE = 20;

    // CommitLog 刷盘后回调:写入索引条目
    public void putMessagePositionInfo(
            long offset,      // CommitLog 中的物理偏移
            int size,         // 消息体长度
            long tagsCode     // Tag 的 CRC32 哈希
    ) {
        // 20 字节定长追加:
        this.byteBuffer.putLong(offset);    // [0-7]  物理偏移
        this.byteBuffer.putInt(size);       // [8-11] 消息长度
        this.byteBuffer.putLong(tagsCode);  // [12-19] Tag 哈希
        // 追加到文件末尾
        this.mappedFile.appendMessage(
            this.byteBuffer.array()
        );
    }
}

关键认知: Queue 不是队列(Queue ≠ container),Queue 是指针文件夹(Queue = pointer folder)。它不像 BlockingQueue 那样"容纳"消息,它只是告诉你消息在 CommitLog 的什么位置。

CommitLog 与 ConsumeQueue 的关系

flowchart LR
    subgraph CL["CommitLog —— 消息体(图书馆)"]
        CL0["00000000000000000000\n1GB 文件"]
        direction LR
        MSG0["Msg-A(TopicA)\noffset=0, len=128"]
        MSG1["Msg-B(TopicB)\noffset=180, len=96"]
        MSG2["Msg-C(TopicA)\noffset=320, len=64"]
        MSG3["Msg-D(TopicC)\noffset=420, len=200"]
        MSG4["Msg-E(TopicA)\noffset=680, len=80"]
    end

    subgraph CQA["ConsumeQueue — TopicA/0(索引导航)"]
        CQ0["[0] offset=0   len=128  tagHash=xxx"]
        CQ1["[1] offset=320 len=64   tagHash=yyy"]
        CQ2["[2] offset=680 len=80   tagHash=zzz"]
    end

    MSG0 -. "索引" .-> CQ0
    MSG2 -. "索引" .-> CQ1
    MSG4 -. "索引" .-> CQ2

    classDef data fill:#9f9,stroke:#333,stroke-width:2px;
    classDef process fill:#bbf,stroke:#333,stroke-width:2px;
    class CL0,MSG0,MSG1,MSG2,MSG3,MSG4 data;
    class CQ0,CQ1,CQ2 process;

上图展示了 CommitLog 和 ConsumeQueue 的本质关系:CommitLog 里顺序排列着所有 Topic 的消息,ConsumeQueue 则像一个导航索引,只记录"某个 Topic 的某条消息在 CommitLog 的什么位置"。只存指针,不存数据。

DefaultMessageStore:两者之间的桥梁

// DefaultMessageStore.java — 串联 CommitLog 和 ConsumeQueue
public class DefaultMessageStore implements MessageStore {
    private final CommitLog commitLog;                             // 消息体
    private final ConcurrentHashMap<String, ConsumeQueue> consumeQueueTable;  // 索引

    public void putMessage(MessageExtBrokerInner msg) {
        // 第一步:写入 CommitLog(顺序 IO)
        PutMessageResult result = commitLog.putMessage(msg);

        // 第二步:CommitLog 刷盘后,ReputMessageService
        // 轮询读取 CommitLog 新写入的内容,异步构建 ConsumeQueue
        // 这就实现了"先存消息体,再建索引"的流程
        this.reputMessageService.run();
    }
}

消息写入 CommitLog 后,不会立即写入 ConsumeQueue。有一个后台线程 ReputMessageService 轮询 CommitLog 的新增内容,解析出每条消息属于哪个 Topic、哪个 Queue,然后构建对应的 ConsumeQueue 条目。这个异步过程意味着 CommitLog 写入成功但 ConsumeQueue 还没构建时,消费端暂时看不到这条消息——但消息不会丢。


4. 第四层:Topic 参数(运维配置的物理映射)

Topic 的配置参数不是玄学,每一个参数都直接对应磁盘上的物理结构和访问权限。

// TopicConfig.java — Topic 核心参数
public class TopicConfig {
    // 读队列数:消费者可以从多少个 ConsumeQueue 目录读取
    private int readQueueNums = 8;
    // 写队列数:生产者向多少个 ConsumeQueue 目录写入索引
    private int writeQueueNums = 8;
    // 权限:2=只写(WRITE), 4=只读(READ), 6=读写(READ_WRITE)
    private int perm = 6;
    // Topic 名称 = ConsumeQueue 目录名
    private String topicName;
}

Queue 数量 = 子目录数量

readQueueNums=8 意味着在 consumequeue/TopicName/ 下会创建 07 共 8 个 ConsumeQueue 子目录。物理上每个 Queue 都是独立的文件。 增加 Queue 数量就是增加目录数量。

读写分离扩缩容

RocketMQ 支持 writeQueueNumsreadQueueNums 不相等。经典用法是平滑扩 Queue

  1. 先把 writeQueueNums 从 8 扩到 16——生产者开始向新 Queue 写数据
  2. 等老 Queue 的消息被消费完后,再把 readQueueNums 扩到 16——消费者开始读新 Queue

这样扩容过程不会有消息倾斜:老消息还在老目录里被消费,新消息进入新目录。

flowchart LR
    subgraph INIT["初始状态"]
        DIR0["consumequeue/Topic/0"]
        DIR1["consumequeue/Topic/1"]
        DIR7[".../6\n.../7"]
        W0["writeQueueNums=8"]
        R0["readQueueNums=8"]
    end

    subgraph STEP1["第一步:扩写"]
        DIR8[".../8\n.../15(新建)"]
        W1["writeQueueNums=16"]
        R1["readQueueNums=8(不变)"]
    end

    subgraph STEP2["第二步:扩读"]
        R2["readQueueNums=16"]
        ALL["所有 16 个\n目录均可读"]
    end

    INIT -->|"增大 writeQueueNums"| STEP1
    STEP1 -->|"等老消息消费完\n增大 readQueueNums"| STEP2

    classDef initStyle fill:#1e293b,stroke:#0284c7,stroke-width:2px,color:#f8fafc;
    classDef stepStyle fill:#2d2522,stroke:#ea580c,stroke-width:2px,color:#f8fafc;

    class DIR0,DIR1,DIR7,W0,R0 initStyle;
    class DIR8,W1,R1,R2,ALL stepStyle;

Perm 权限控制

perm=6 是最常见的读写模式。改为 perm=2 就变成只写(不接受消费), perm=4 是只读(不接受生产)。在灰度发布或停服迁移时,可以临时将某个 Topic 设为只读或只写。

存储根路径配置

// MessageStoreConfig.java — 磁盘在哪里,你来定
public class MessageStoreConfig {
    // 存储根目录,默认 ${user.home}/store
    private String storePathRootDir = System.getProperty("user.home")
            + File.separator + "store";

    // CommitLog 路径
    private String storePathCommitLog = storePathRootDir
            + File.separator + "commitlog";

    // ConsumeQueue 路径
    private String storePathConsumeQueue = storePathRootDir
            + File.separator + "consumequeue";

    // 单个 CommitLog 文件大小:1GB
    private int mappedFileSizeCommitLog = 1024 * 1024 * 1024;
}

AutoCreate:一个让人又爱又恨的开关

autoCreateTopicEnable=true 意味着生产者在 send() 时如果传入一个不存在的 Topic 名称,Broker 会自动创建对应的 ConsumeQueue 目录。开发环境开着很方便,生产环境建议关掉——一个拼写错误就会在磁盘上创建出一个幽灵 Topic 目录,而且永远不会被自动清理。


5. 第五层:高级特性(基于存储模型的策略)

这一层是前三层物理结构带来的直接收益:所有 RocketMQ 的高级特性,都是在这个文件模型基础上的策略层实现。

5.1 消息重试

消费者消费失败时,这条消息不会直接丢掉。RocketMQ 在内部将消息写入一个特殊的 Retry Topic:%RETRY%{ConsumerGroup}。这个 Retry Topic 的 ConsumeQueue 目录和普通 Topic 一模一样,唯一的区别在于消费者在读取它时,Broker 限制了读取速度。

重试按 16 个延迟级别逐级等待:

RocketMQ 默认消息延迟级别(messageDelayLevel):
 1 → 1s     2 → 5s     3 → 10s    4 → 30s
 5 → 1m     6 → 2m     7 → 3m     8 → 4m
 9 → 5m    10 → 6m    11 → 7m    12 → 8m
13 → 9m    14 → 10m   15 → 20m   16 → 30m
17 → 1h    18 → 2h

第 1 次重试等 1 秒,第 2 次重试等 5 秒,第 3 次等 10 秒……第 16 次等 30 分钟。在物理上,每次重试就是将 ConsumeQueue 的读取偏移延迟一个级别。 没有特殊的数据结构,只是延迟指针移动。

5.2 死信队列(DLQ)

当消息重试 16 次仍然失败,消息进入死信队列(Dead Letter Queue)。DLQ 在物理上是什么?是一个不同名字的 ConsumeQueue 目录。

// DLQ Topic 名称构造(RocketMQ 4.9.x)
// 死信队列没有任何特殊的数据结构
// 只是把 ConsumeQueue 路径从 /Topic/ 改成 /%DLQ%Topic/
public static String getDLQTopic(String originalTopic) {
    // 前缀 %DLQ% 让它在 ConsumeQueue 目录中"看起来"是不同的 Topic
    return "%DLQ%" + originalTopic;
}

当一条消息进入 DLQ 时,CommitLog 里追加一条消息体的副本(新的物理偏移量),ConsumeQueue 中在 %DLQ%OriginalTopic/0/ 目录下建立一条新的索引条目。死信本质上就是换了个目录的普通消息。

这解释了为什么 DLQ 消息可以重新投递——把 DLQ 的 ConsumeQueue 条目读出来,找到 CommitLog 里的消息体,重新发送到一个正常的 Topic 即可。

5.3 消息积压

消息积压是 RocketMQ 最让运维头疼的场景之一。从物理模型看,积压的真正含义是什么?

积压 ≠ 队列满了。消息在 CommitLog 和 ConsumeQueue 两个层面有完全不同的积压表现:

  • CommitLog 层面:所有 Topic 共享一个文件链,积压的 Topic 占用的是文件系统中的磁盘空间。
  • ConsumeQueue 层面:消费者进度落后于生产进度,ConsumeQueue 中积累了未读的指针条目。

积压产生时,磁盘空间被 CommitLog 占用(消息体还在),控制台看到的是 ConsumeQueue 中未被消费的偏移量差距。

积压三板斧:

  1. 扩容消费者(增加 Queue 的并行读者数):增加 Consumer 实例,让更多进程同时从同一个 Topic 的不同 Queue 目录拉取。RocketMQ 的负载均衡会自动分配 Queue。

  2. 增加消费线程数(提高指针 → 消息体处理的并发)

// 调整消费者的线程池大小
consumer.setConsumeThreadMin(20);   // 默认 20
consumer.setConsumeThreadMax(64);   // 默认 64

本质上就是增大 ThreadPoolExecutor 的核心线程数,让更多线程同时处理 ConsumeQueue 指向的消息。

  1. 跳过积压消息(直接移动 ConsumeQueue 读偏移): 通过运维工具直接设置 ConsumeQueue 的读取偏移到最新位置——丢弃所有未消费的指针,消费者从当前最新位置开始。这是"先解决生产问题再说"的终极手段。

5.4 限流/熔断/降级(Resilience4j)

RocketMQ 的限流在物理上就是在控制消费者从磁盘读取 ConsumeQueue 指针的速度。Broker 端有限流参数( maxMsgInFlight 等),但更灵活的限流策略在客户端实现,通常配合 Resilience4j 等限流框架:

// 物理本质:限流 = 控制消费者遍历 ConsumeQueue 目录的速率
// 不管用哪种限流策略(令牌桶、漏桶、滑动窗口),
// 最终效果都是让消费线程从 ConsumeQueue 拉取指针的速度变慢

客户端的限流比 Broker 参数更灵活:可以根据业务逻辑(比如下游数据库压力)动态调整消费速率,而不是一刀切限制所有 Topic 的 Broker 端参数。


6. 第六层:开发视角(你代码里能碰到的东西)

前面五层都在讲"存储"和"策略",这一层回到你的业务代码——你写的代码,最终在这个三层存储模型的哪一层操作?

消费线程池

// 物理映射:控制 ConsumeQueue 指针的处理并发度
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("GroupA");
consumer.setConsumeThreadMin(20);   // 最少 20 个线程同时拉取 ConsumeQueue
consumer.setConsumeThreadMax(64);   // 最多 64 个线程同时拉取 ConsumeQueue
// 这背后是一个 ThreadPoolExecutor,线程数决定了每秒能从
// ConsumeQueue 读取并处理多少个指针条目

消息生产

// 物理映射:确定消息写入哪个 CommitLog 位置 + 哪个 ConsumeQueue 目录
Message msg = new Message(
    "OrderTopic",        // → ./consumequeue/OrderTopic/ 下的某个目录
    "Order",             // Tag → ConsumeQueue 条目中的 tagHash 字段
    "order-12345".getBytes()  // → CommitLog 中的消息体字节
);
producer.send(msg);
// 你写了个 Topic 字符串,它最终变成了一个文件夹路径

顺序消息

// 物理映射:确保同一个 QueueId → 同一个 ConsumeQueue 目录
// RocketMQ 的顺序保证基于"同一 Queue 顺序读取"
// 你不选 Queue,消息就轮询写入;你选了 Queue,消息就写入同一个目录
producer.send(msg, new MessageQueueSelector() {
    @Override
    public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
        // arg = 订单 ID,同一订单的消息进入同一个 Queue 目录
        String orderId = (String) arg;
        int queueIndex = orderId.hashCode() % mqs.size();
        return mqs.get(queueIndex);
    }
}, orderId);
// 物理结果:同一订单的消息进入同一个 ConsumeQueue 子目录,
// 消费端在这个目录上单线程读取,保证有序

你写的每一个 new Message("Topic"),都在操作这个三层存储模型。 Topic 名称是 ConsumeQueue 的目录名,Tag 是条目里的哈希值,消息体字节最终躺在 CommitLog 的某个位置。你的代码就是这套磁盘文件模型的门面。


7. 总结:一张物理模型图覆盖所有概念

站在七层之上回头看,RocketMQ 的存储模型可以用四个等式概括:

  • CommitLog = 消息体数组(数据)——顺序追加,永不修改
  • ConsumeQueue = 定长指针数组(索引)——20 字节条目,O(1) 随机读取
  • Topic = 顶级目录名——ConsumeQueue 路径的第一级
  • Queue = 二级子目录名(QueueId)——ConsumeQueue 路径的第二级
  • Offset = ConsumeQueue 索引位置——读偏移即消费进度
  • DLQ/重试/积压 = 基于文件模型的业务策略——都是怎么读写目录和指针的不同方案

下面这张全层模型图,把 NameServer(路由)到 CommitLog(数据)到 ConsumeQueue(索引)到客户端(生产消费)的完整链路串在一起:

flowchart TD
    subgraph NS["NameServer(路由层)"]
        REG["Topic → Broker IP 映射"]
        HB["Broker 心跳检测"]
    end

    subgraph BRK["Broker 节点"]
        TC["TopicConfig\nreadQueueNums / writeQueueNums / perm"]

        subgraph CL["CommitLog(消息体,图书馆)"]
            CL_MF["MappedFile 队列\n每文件 1GB"]
            CL_SEQ["顺序追加\n所有 Topic 混写"]
        end

        subgraph CQ["ConsumeQueue(索引,导航目录)"]
            CQ_DIR["consumequeue/{Topic}/{QueueId}/"]
            CQ_ENTRY["20B 定长条目\noffset(8) + len(4) + tagHash(8)"]
        end
    end

    subgraph PROD["生产者"]
        P_RT["获取 Topic 路由"]
        P_SEND["发送消息到指定 Queue"]
    end

    subgraph CONS["消费者"]
        C_PULL["按 offset 拉取 ConsumeQueue"]
        C_PROC["ThreadPoolExecutor\n消费线程池"]
        C_STRAT["重试 / DLQ / 限流策略"]
    end

    BRK -->|"心跳注册"| NS
    PROD -->|"获取路由"| NS
    PROD -->|"写出消息体"| CL_SEQ
    CL_SEQ -->|"刷盘回调"| CQ_ENTRY
    CQ_ENTRY -->|"文件路径"| CQ_DIR
    CONS -->|"按偏移读取索引"| CQ_DIR
    CQ_DIR -->|"定位消息体"| CL_MF
    CONS -->|"路由发现"| NS

    classDef root fill:#e1d5e7,stroke:#333,stroke-width:2px;
    classDef branch fill:#d5e8d4,stroke:#333,stroke-width:1px;
    classDef leaf fill:#fff2cc,stroke:#333,stroke-width:1px;
    classDef process fill:#bbf,stroke:#333,stroke-width:1px;
    classDef data fill:#9f9,stroke:#333,stroke-width:1px;
    class REG,HB root;
    class TC,CL_MF,CL_SEQ,CQ_DIR,CQ_ENTRY branch;
    class P_RT,P_SEND leaf;
    class C_PULL,C_PROC,C_STRAT leaf;

最终结论

RocketMQ 不是用一个 BlockingQueue 存消息,而是用了一套极致简洁的磁盘模型:

  • CommitLog 只做一件事,但做到极致——顺序追加,每秒数十万条写入
  • ConsumeQueue 也只做一件事,但做到极致——20 字节定长,O(1) 随机读取
  • 业务在这个模型之上自由发挥——重试、死信、积压治理、限流,都是在操作 CommitLog 的偏移量和 ConsumeQueue 的目录名

RocketMQ = 极致顺序 IO + 极简数据结构 + 灵活的业务策略。 理解了文件(CommitLog)和指针(ConsumeQueue),你就理解了 RocketMQ 的一切。别再拿它当 BlockingQueue 用了——它比那玩意儿硬核得多。