标签:RocketMQ | CommitLog | ConsumeQueue | 存储模型 | 顺序写 | 零拷贝 | Kafka | 面试
适合人群:想搞懂 RocketMQ 存储为什么这么设计、被"顺序写高性能"一句话打发过的工程师
阅读收益:搞清楚 CommitLog 和 ConsumeQueue 各自存了什么、为什么 RocketMQ 要用"写一条、读两次"的模型、Kafka 的多分区存储在 Topic 数量增长后为什么会出现随机写、以及 ConsumeQueue 的 20 字节到底装了什么
前言:一个被"顺序写"掩盖的问题
几乎所有 RocketMQ 入门文章都会说:CommitLog 顺序写,所以性能高。这句话没错,但它没有回答一个更关键的问题:Kafka 的 Partition 也是顺序写,为什么 RocketMQ 还要另搞一套?
Kafka 的每个 Partition 是一个 append-only 的 segment 文件,写入也是顺序的。单看一个 Partition,Kafka 的写入路径同样是顺序写。问题不在单个 Partition 内部,在多个 Partition 之间。当一个 Broker 承载几十上百个 Topic、每个 Topic 有多个 Partition 时,生产者并发写入不同的 Partition,磁盘上实际发生的是多个文件之间的交替写入------每个文件内部是顺序的,但文件之间的写指针在来回跳。Topic 数量越多,这种"跨文件的随机性"越明显。
RocketMQ 的 CommitLog 设计,本质上是对这个问题的一个回答:把所有 Topic 的所有消息,写进同一个逻辑文件。
一、CommitLog:唯一的"真相来源"
CommitLog 是 RocketMQ 存储层最底层的文件。无论消息属于哪个 Topic、哪个队列,全部顺序追加到 CommitLog 中。单个文件默认 1 GB,文件名是 20 位的起始物理偏移量(如 00000000000000000000),写满自动滚动到下一个文件。
这个设计的效果是:在任意时刻,Broker 只有一条活跃的写入文件。无论有多少 Topic、多少队列,生产者并发写入最终都被串行追加到这条 CommitLog 的末尾。Topic 数量增长不再制造跨文件的随机写,写入路径的磁盘行为与 Topic 数量解耦。
代价是 CommitLog 里的消息是混杂的。消息 A 属于 order-topic 的队列 0,紧接着的消息 B 可能属于 inventory-topic 的队列 3。你无法从 CommitLog 本身直接知道"某个 Topic 的下一条消息在哪"。这就是 ConsumeQueue 要解决的问题。
二、ConsumeQueue:20 字节的"二级索引"
ConsumeQueue 是消费者实际"看见"的队列。每个 Topic 的每个队列对应一个 ConsumeQueue 目录,目录里的每条记录固定 20 字节:
8 字节:消息在 CommitLog 中的物理偏移量;
4 字节:消息长度;
8 字节:Tag 的哈希值,用于消费端过滤。
消费者拉取消息时,先读 ConsumeQueue 拿到偏移量和长度,再根据偏移量去 CommitLog 读出完整消息。这是一个典型的"二级索引 → 回表"模型,和 MySQL 的二级索引找聚簇索引在结构上完全同构。
这个"读两次"的流程经常被拿来和 Kafka 对比。Kafka 的消费者直接从 Partition 文件读取完整消息,一次读取。RocketMQ 需要先读索引再读数据,两次读取。看起来 Kafka 更高效,但这个对比漏掉了写入侧的收益:RocketMQ 用一次额外的索引读取,换来了写入侧对 Topic 数量不敏感的顺序写。
三、顺序写 + 零拷贝:高性能的真正组合
CommitLog 的高性能不单来自"顺序写",来自顺序写 + mmap 零拷贝的组合。
CommitLog 的写入通过 MappedFile(mmap 内存映射)完成,消息先写入 PageCache,写入 PageCache 即返回生产者 ACK(异步刷盘模式下)。消费时,如果消息还在 PageCache 中,读取直接命中内存,不产生磁盘 I/O;如果不在,操作系统按"跳跃读"的方式从磁盘预读连续数据页。
ConsumeQueue 的构建是异步的:ReputMessageService 后台线程读取 CommitLog 中已写入的消息,异步构建 ConsumeQueue 和 IndexFile。写入 CommitLog 是同步的,构建索引是异步的,索引构建的延迟不影响写入吞吐。这也是为什么 CommitLog 的写入可以保持极低的延迟------它不等待索引完成。
对比 Kafka:Kafka 依赖操作系统的 PageCache 和 sendfile 零拷贝,消费者读取时数据从 PageCache 直接到网卡,不经过用户态。RocketMQ 的 mmap 路径在写入侧同样避免了用户态到内核态的拷贝,但消费路径需要两次读取(索引 + 数据),这是它的结构性代价。
四、代价清单:这个设计不是免费的
CommitLog + ConsumeQueue 的分离设计有几个必须正视的代价:
第一,读放大。 每次消费至少两次读取(ConsumeQueue + CommitLog),索引本身也占用存储和内存。在消费吞吐敏感的场景中,这个开销真实存在。
第二,ConsumeQueue 重建。 ConsumeQueue 是异步构建的派生数据。如果 ConsumeQueue 文件损坏或落后,需要从 CommitLog 重新构建。CommitLog 是唯一真相来源,ConsumeQueue 是可再生的。这和 Kafka 的 Partition 文件"自包含"的设计不同------Kafka 的 Partition 文件本身就是完整数据,不需要额外的重建步骤。
第三,刷盘策略决定了可靠性边界。 异步刷盘(默认)写入 PageCache 就返回 ACK,Broker 断电可能丢失 PageCache 中尚未刷盘的消息;同步刷盘(flushDiskType=SYNC_FLUSH)真正写入磁盘才返回,可靠性更高但吞吐下降。消息中间件的可靠性不是免费的,它由刷盘策略和主从复制共同定义。
本篇小结
