前言:写得快不够,还要读得快
上一篇我们解决了写入问题:CommitLog顺序写,让RocketMQ写入TPS达到十万级别。
但写得快只是第一步。消费者怎么快速读取消息?
设想一个场景:
- CommitLog有100GB数据,包含1000个Topic
- Consumer只关心其中一个Topic(1GB数据)
- 从头扫描?读取100GB,丢弃99GB?
实测数据:扫描100MB的CommitLog找2万条消息,耗时102ms。如果是100GB?那就是10万ms,即100秒。
完全不可行。
RocketMQ的答案:ConsumeQueue异步索引。
- 20字节定长结构
- 查询速度提升34倍(102ms → 3ms)
- 数据量减少272倍(100MB → 0.37MB)
今天拆解三个核心问题:
- ConsumeQueue是什么? - 20字节索引如何设计
- 为什么异步构建? - 写入性能如何保持
- 怎么配合工作? - 读写如何分离
一、ConsumeQueue是什么?
1.1 核心设计
ConsumeQueue是CommitLog的索引,采用定长、异步构建的设计。
每个Topic的每个Queue都有一个独立的ConsumeQueue文件:
bash
$ROCKETMQ_HOME/store/consumequeue/
├── TopicA/
│ ├── 0/ ← Queue-0的索引
│ │ └── 00000000000000000000
│ ├── 1/ ← Queue-1的索引
│ │ └── 00000000000000000000
└── TopicB/
├── 0/
│ └── 00000000000000000000
1.2 索引条目结构
每个索引条目固定20字节,包含3个字段:
scss
+-------------------+-------------------+-------------------+
| CommitLog偏移量 | 消息大小 | TagsCode |
| (8字节) | (4字节) | (8字节) |
+-------------------+-------------------+-------------------+
为什么是20字节?
定长设计的好处:
- 快速定位 - 第N条索引位置 = N × 20,O(1)复杂度
- 空间高效 - 1KB消息 → 20字节索引,压缩比51:1
- 批量读取 - 连续存储,PageCache友好
1.3 读写分离的设计
ConsumeQueue和CommitLog分工明确:
CommitLog:物理存储层
- 所有消息顺序写入
- 不关心Topic和Queue
- 只管追加,写入极快
ConsumeQueue:逻辑索引层
- 按Topic/Queue组织
- 只存20字节索引
- 快速定位消息
协作方式:写入快速,读取精准
二、为什么不直接扫描CommitLog?
2.1 性能对比测试
我写了一个简化版的实现,对比两种查询方式:
测试场景:
- 消息总数:10万条,每条1KB
- Topic数量:5个(消息随机分布)
- 查询目标:找出Topic-2的所有消息(约2万条)
方式A:直接扫描CommitLog
java
// 从头到尾扫描,逐条解析
for (Message msg : commitLog.scanAll()) {
if (msg.getTopic().equals("Topic-2")) {
results.add(msg);
}
}
方式B:通过ConsumeQueue索引
java
// 读取索引,直接定位
ConsumeQueue queue = getQueue("Topic-2", 0);
for (IndexEntry entry : queue.getAll()) {
Message msg = commitLog.read(entry.offset, entry.size);
results.add(msg);
}
2.2 测试结果
| 指标 | 直接扫描 | 索引查询 | 差距 |
|---|---|---|---|
| 查询耗时 | 102 ms | 3 ms | 34倍 |
| 扫描数据量 | 100.00 MB | 0.37 MB | 272倍 |
| 找到消息数 | 19,384条 | 19,384条 | 相同 |
为什么差距这么大?
- 数据量差异:100MB vs 0.37MB,减少272倍IO
- 解析开销:全部9.6万条 vs 目标1.9万条,减少5倍解析
- 缓存命中:索引小全在内存,CommitLog太大命中率低
2.3 不同消息数量下的性能
| 消息总数 | 直接扫描 | 索引查询 | 性能提升 |
|---|---|---|---|
| 1万 | 14 ms | 1 ms | 14倍 |
| 5万 | 55 ms | 2 ms | 27.5倍 |
| 10万 | 76 ms | 1 ms | 76倍 |
| 20万 | 59 ms | 1 ms | 59倍 |
趋势分析:
- 直接扫描:耗时随消息数量线性增长
- 索引查询:耗时基本恒定(1-2ms)
- 消息越多,索引优势越明显
关于20万条数据的说明: 细心的读者会发现,20万条消息的扫描耗时(59ms)反而比10万条(76ms)少。这不符合线性增长的预期。
原因是PageCache效应:
- 测试按1万→5万→10万→20万顺序执行
- 前面的测试让大量数据进入PageCache
- 20万测试时,很多数据已在内存中
- 磁盘IO大幅减少,耗时反而降低
这个现象恰好说明:
- PageCache对顺序读取的优化很强
- 真实生产环境中,冷启动时性能会更差
- 索引查询因为数据量小,不受PageCache波动影响
结论:直接扫描CommitLog是不可行的。生产环境百万、千万级消息,扫描耗时会达到秒级甚至分钟级,完全无法接受。
两种查询方式对比:
三、异步构建机制
3.1 为什么要异步?
同步构建会拖累写入性能:
java
// 同步构建(性能差)
commitLog.append(msg);
consumeQueue.buildIndex(msg); // ← 阻塞等待
return success;
// 异步构建(高性能)
commitLog.append(msg);
return success; // ← 立即返回,后台线程异步构建
问题:
- 同步构建需要等待索引完成
- 索引文件分散,写入变随机IO
- 1000个Topic = 1000个索引文件同时写,性能崩溃
异步优势:
- 写入立即返回,不等索引
- 后台线程统一处理,批量高效
- 写入性能100%保留
3.2 ReputMessageService实现
RocketMQ使用ReputMessageService线程异步分发消息到ConsumeQueue。
异步构建流程:
核心逻辑:
java
class ReputMessageService extends Thread {
private long reputFromOffset = 0; // 已处理位置
public void run() {
while (running) {
long maxOffset = commitLog.getMaxOffset();
if (reputFromOffset < maxOffset) {
// 解析消息并分发到ConsumeQueue
DispatchRequest request = parseMessage(reputFromOffset);
doDispatch(request);
reputFromOffset += request.getMsgSize();
} else {
Thread.sleep(1); // 无新消息,休眠1ms
}
}
}
}
关键参数:
- 轮询间隔:1ms
- 每次处理:1条消息
- 延迟窗口:<10ms
3.3 延迟窗口验证
写入1000条消息的过程中:
yaml
写入期间(保持20-50条延迟):
CommitLog: 200条 ████████████████████
ConsumeQueue: 180条 ██████████████████
延迟: 20条 (~20ms)
写入停止后10ms(完全追上):
CommitLog: 1000条 ████████████████████████████████████████
ConsumeQueue: 1000条 ████████████████████████████████████████
延迟: 0条
延迟特点:
- 写入期间保持小幅延迟(20-50条消息)
- 停止写入后10ms内完全追上
- 对消费者影响可忽略(消费者本身异步)
四、设计权衡
4.1 三种方案对比
| 方案 | 写入性能 | 读取性能 | 复杂度 | 一致性 | 适用场景 |
|---|---|---|---|---|---|
| 直接扫描CommitLog | 高 | 极低 | 简单 | 强一致 | 不适用 |
| 同步构建ConsumeQueue | 低 | 高 | 中等 | 强一致 | 小规模 |
| 异步构建ConsumeQueue | 高 | 高 | 较高 | 最终一致 | 大规模生产 |
为什么选择异步构建?
核心权衡:
- 写入性能100%保留(不等索引)
- 查询性能34倍提升(通过索引)
- 延迟<10ms可接受(消费者本身异步)
一致性问题:
Consumer不会读到不一致数据,因为:
- Consumer定期拉取(默认每秒1次),感知不到10ms延迟
- Offset管理保证不漏消息,只是新消息晚几毫秒可见
- Broker重启后ReputService从断点继续构建
最坏情况:宕机瞬间丢失<1ms的索引,重启后重建,对消费者透明。
五、CommitLog与ConsumeQueue的协作
5.1 完整的消息流转
写入路径(Producer → Broker):
- Producer发送消息到Broker
- Broker写入CommitLog(顺序追加)
- 返回成功给Producer
- ReputService异步读取CommitLog
- 构建ConsumeQueue索引
读取路径(Consumer → Broker):
- Consumer向Broker请求消息
- Broker查询ConsumeQueue索引
- 获得消息在CommitLog的offset和size
- 从CommitLog读取完整消息
- 返回给Consumer
5.2 设计思想
CommitLog(写入侧):
- 顺序写,充分利用磁盘性能
- 不关心Topic和Queue
- 只管追加,简单高效
ConsumeQueue(读取侧):
- 按Topic/Queue组织
- 定长索引,快速定位
- 只读关心的消息
核心:读写分离,各自优化到极致。
5.3 文件布局
perl
store/
├── commitlog/
│ ├── 00000000000000000000 # 1GB
│ └── 00000001073741824 # 1GB
│
├── consumequeue/
│ ├── TopicA/
│ │ ├── 0/00000000000000000000 # 5.7MB (30万条)
│ │ └── 1/00000000000000000000
│ └── TopicB/
│ ├── 0/00000000000000000000
│ └── 1/00000000000000000000
│
└── index/ # 按Key查询(可选)
└── 20250103120000000
空间占用(10万条消息):
- CommitLog:100 MB(完整消息)
- ConsumeQueue:1.9 MB(索引)
- 索引开销:仅1.9%
六、升华:主数据 + 异步索引模式
ConsumeQueue的设计,本质是主数据 + 异步索引模式。
6.1 核心模式
主数据层(写优化) → 异步构建 → 索引层(读优化)
设计原则:
- 主数据优先写入 - 保证不丢
- 索引异步构建 - 不阻塞
- 读取走索引 - 快速定位
- 最终一致 - 可接受延迟
6.2 典型应用
MySQL的二级索引:
- 主键索引 = CommitLog(数据存储)
- 二级索引 = ConsumeQueue(查询加速)
- Change Buffer异步合并索引
Elasticsearch的倒排索引:
- _source字段 = 原始文档(CommitLog)
- 倒排索引 = 查询索引(ConsumeQueue)
- refresh间隔控制延迟(默认1秒)
数据仓库的物化视图:
- 明细表 = 原始数据
- 聚合表 = 查询视图
- 定时任务计算更新
6.3 适用条件
适合:
- 写多读少,查询必须快
- 可容忍秒级延迟
- 索引构建代价高
不适合:
- 强一致性要求
- 查询简单(全表扫描也能接受)
总结
ConsumeQueue用20字节定长索引 + 异步构建,解决了CommitLog的读取问题。
核心数据:
- 查询速度:提升34倍(102ms → 3ms)
- 数据量:减少272倍(100MB → 0.37MB)
- 异步延迟:<10ms,对消费者透明
设计精髓:
- 定长索引:offset(8) + size(4) + tagsCode(8) = 20字节
- 异步构建:写入不等待,后台ReputService轮询处理
- 读写分离:CommitLog写入极致,ConsumeQueue查询极致
通用模式:
主数据 + 异步索引 = 写快 + 读快 + 最终一致
这个模式在MySQL二级索引、ES倒排索引、数据仓库物化视图中都能看到。
核心是:用可接受的延迟,换取读写性能的极致。
下一篇:刷盘策略 ------ SYNC_FLUSH和ASYNC_FLUSH到底差多少性能?
参考资料:
- RocketMQ官方文档
- RocketMQ Design
- 《RocketMQ技术内幕》第三章 - 消息存储