深入解读 RocketMQ 源码系列
深入解读 RocketMQ 源码(一):Producer 启动、配置与使用详解
深入解读 RocketMQ 源码(二):详解 Message 发送流程
深入解读 RocketMQ 源码(三):解析 Broker 存储 Message 流程
深入解读 RocketMQ 源码(四):Consumer 启动流程全解析
深入解读 RocketMQ 源码(五):详解 Message 消费流程
深入解读 RocketMQ 源码(六):延迟消息与事务消息的实现原理
深入解读 RocketMQ 源码(七):Broker 消费队列 ConsumeQueue 的持久化机制
前言
说到RocketMq的持久化,我们可能第一时间想到的是Message的持久化,Message通过Broker最终持久化到磁盘的CommitLog文件,但是这仅仅只是将Message进行了存储,它最终是需要提供给Consumer消费的,通过前面的学习我们知道一个CommitLog文件可以存储1G的数据,在Message量大的情况下一个CommitLog文件是不够存储所有Message的,因此就会存在多个CommitLog文件。如果Consumer需要消费某一条消息,在这庞大的数据中Broker怎样才能快速定位到需要的Message呢?这就是本篇我们探讨的核心内容ConsumeQueue 。
准备
源码地址:https://github.com/apache/rocketmq
目前最新版本为:5.2.0
那么我们在idea上切换分支为 release-5.2.0
持久化文件种类
在深入学习ConsumeQueue之前我们先来认识下RocketMq需要持久化文件的种类。RocketMq一共有三种重要的持久化文件
- CommitLog文件(MappedFile):存储的是Message数据(可参考:《读「RocketMq源码」(三)Broker存储Message流程解析》)
- ConsumeQueue文件:存储的是消费Message的索引
- IndexFile文件:存储的是通过 Key 或者时间区间来查询 Message 的索引
初识ConsumeQueue
ConsumeQueue文件持久化到磁盘的位置是一种类似多级索引的存储结构方式,ConsumeQueue就是为了能够开始定位Message而创建的索引。
ConsumeQueue结构
因为ConsumeQueue是message的索引,所以一条message数据就会对应一条ConsumeQueue数据,一条ConsumeQueue是固定的20个字节组成,而一条message在commitLog文件中的大小并不固定,因为每条message的内容大小并不一样。
// 源码位置:
// 包名: org.apache.rocketmq.store;
// 文件: ConsumeQueue
// 行数: 35
/**
* ConsumeQueue's store unit. Format:
* <pre>
* ┌───────────────────────────────┬───────────────────┬───────────────────────────────┐
* │ CommitLog Physical Offset │ Body Size │ Tag HashCode │
* │ (8 Bytes) │ (4 Bytes) │ (8 Bytes) │
* ├───────────────────────────────┴───────────────────┴───────────────────────────────┤
* │ Store Unit │
* │ │
* </pre>
* ConsumeQueue's store unit. Size: CommitLog Physical Offset(8) + Body Size(4) + Tag HashCode(8) = 20 Bytes
*/
public static final int CQ_STORE_UNIT_SIZE = 20;
ConsumeQueue的20个字节各部分情况大致如下:
- 物理偏移量:占 8 个字节,即在 CommitLog 文件当中的实际偏移量。
- 消息体长度:占 4 个字节,代表索引指向的这条 Message 的长度。
- Tag 哈希值:占 8 个字节,这个也是 RocketMQ 的特性 —— 消息过滤的原理。在 Broker 侧消费 Message 时,即可根据 Consumer 指定的 Tag 来对消息进行过滤。
RocketMq中也默认设置了一个ConsumeQueue文件可以包含 30 万条记录
// 源码位置:
// 包名: org.apache.rocketmq.store.config;
// 文件: MessageStoreConfig
// 行数: 44
// ConsumeQueue file size,default is 30W
private int mappedFileSizeConsumeQueue = 300000 * ConsumeQueue.CQ_STORE_UNIT_SIZE;
ConsumeQueue文件存储位置
存储位置和commitLog文件一样,可以在broker.conf文件中配置指定位置
storePathConsumeQueue = home/docker/rocketmq/store/consumequeue
如果不配置,rocketmq也会默认生成文件在store文件夹中,这里博主使用的是docker启动的Broker服务容器中的位置:/home/rocketmq/store
可以看到在docker容器中三种持久化文件夹都在这个路径下
进入consumequeue文件夹中,你会发现并不像commitlog文件夹一样直接存储commitLog文件,其实里面还存在多级目录。
执行tree命令可查看目录结构:
└── queue_test_topic
├── 0
│ └── 00000000000000000000
├── 1
│ └── 00000000000000000000
├── 2
│ └── 00000000000000000000
└── 3
└── 00000000000000000000
提示命令不存在需要手动安装tree:yum install tree
由此可见consumequeue文件夹下还有两级目录
- queue_test_topic:这是以Topic名称命名的文件夹,每一个topic都有单独的文件夹目录
- 数字0,1,2,3:这是topic的队列id命名的文件夹,一个topic中有多少个队列那么就会有多少个文件夹
- 00000000000000000000:这就是具体的ConsumeQueue文件了,这串数字就是它的名称,命名规则和commitLog文件相似,也是按照起始偏移量,这是就不简述了。
到这里心中不禁发问,ConsumeQueue文件的存储为什么是多级目录结构呢?
是否还记得在Consumer服务端,我们会指定订阅的Topic,以及在重平衡机制中会为消费者分配MessageQueue,那么消费者端就已经确定了上面的两级目录,从而我们可以直接就找到需要的ConsumeQueue索引文件。就像我们去快递站取包裹一样,包裹到了会给你发送一个取货码,取货码就会告诉你的包裹在第几个货架的第几个格子的第几个编码就能准确定位到了。
同时又冒出了一个疑问,ConsumeQueue文件中存储了30万条索引,我们怎么找到需要的索引呢?
- 顺序读取:
ConsumeQueue
文件中的索引记录是按消息的生产顺序顺序存储的,消费者通常也是顺序消费消息的,即按消息的生产顺序依次消费 。 消费者维护每个消息队列的消费进度(consumeOffset
),表示已经消费到哪个位置 。- 定位读取: 因为每条
ConsumeQueue
记录是固定长度的(20 字节), 消费者可以通过消费进度(consumeOffset
)计算要读取的索引记录在ConsumeQueue
文件中的位置 ,可以直接跳到ConsumeQueue
文件的相应位置,读取所需的索引记录。
源码解析
虽然Messge的索引ConsumeQueue数据是在Message写入CommitLog文件成功后才能生成,但并不是在commitLog文件存储成功后立马生成的,他们是两个单独的线程进行处理的,在流程上没有直接的关联。
启动定时任务
ConsumeQueue索引其实是通过定时任务来进行创建的。
// 源码位置:
// 包名: org.apache.rocketmq.store
// 文件: DefaultMessageStore
// 行数: 2925
while (!this.isStopped()) {
try {
TimeUnit.MILLISECONDS.sleep(1);
this.doReput();
} catch (Exception e) {
DefaultMessageStore.LOGGER.warn(this.getServiceName() + " service has exception. ", e);
}
}
看源码我们知道每隔1ms执行一次对ConsumeQueue创建方法this.doReput();像别的定时任务都是几百毫秒执行一次,由此可见一斑对其实时性的要求也比较高,整个业务的运行对其依赖也是非常强得。
如果你真的深入源码上下文跟读,该定时器的启动就是Broker服务启动时触发的线程
获取CommitLog中的Message
// 源码位置:
// 包名: org.apache.rocketmq.store
// 文件: DefaultMessageStore
// 行数: 2824
SelectMappedBufferResult result = DefaultMessageStore.this.commitLog.getData(reputFromOffset);
- reputFromOffset:该字段表示当前从commitLog文件获取的开始偏移量,服务刚启动初始值为0.
- SelectMappedBufferResult:该对象则存储的就是本次需要创建索引的message信息集合。
深入getData()方法中
获取MappedFile
// 源码位置:
// 包名: org.apache.rocketmq.store
// 文件: CommitLog
// 行数: 240
MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset, returnFirstOnNotFound);
我们知道CommitLog文件的逻辑映射对象就是MappedFile,这里我们就是要确定获取哪一个CommitLog文件的message数据,并且映射到MappedFile中。
深入源码逻辑处理中可知:
- 如果偏移量reputFromOffset为0,直接取第一个CommitLog文件firstMappedFile
- 如果偏移量reputFromOffset不为0,则需要通过计算得到CommitLog文件对应mappedFile在mappedFiles集合中的位置并获取。算法见下方源码。
// 源码位置:
// 包名: org.apache.rocketmq.store
// 文件: MappedFileQueue
// 行数: 684
int index = (int) ((offset / this.mappedFileSize) - (firstMappedFile.getFileFromOffset() / this.mappedFileSize));
MappedFile targetFile = null;
try {
targetFile = this.mappedFiles.get(index);
} catch (Exception ignored) {
}
获取Message的ByteBuffer
通过上面确定了MappedFile,因为Message实际上是存储在byteBuffer中的,所以我们需要确定获取MappedFile中的某一段区间的Buffer数据。
// 源码位置:
// 包名: org.apache.rocketmq.store
// 文件: MappedFileQueue
// 行数: 242
int pos = (int) (offset % mappedFileSize);
SelectMappedBufferResult result = mappedFile.selectMappedBuffer(pos);
- pos:该字段就是表示从byteBuffer中读取的开始偏移量位置,mappedFileSize获取的就是一个CommitLog文件大小1G(1024*1024*1024)
- selectMappedBuffer():该方法就是当前处理的MappedFile对象获取消息的操作
直接进入方法内部查看获取逻辑
// 源码位置:
// 包名: org.apache.rocketmq.store.logfile
// 文件: DefaultMappedFile
// 行数: 514
ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
byteBuffer.position(pos);
int size = readPosition - pos;
ByteBuffer byteBufferNew = byteBuffer.slice();
byteBufferNew.limit(size);
return new SelectMappedBufferResult(this.fileFromOffset + pos, byteBufferNew, size, this);
- readPosition:该字段表示最大可读位置
- size:表示本次读取的长度
- 由(readPosition - pos)操作我们可以知道(最大可读位置-开始偏移量位置),pos其实也可以理解为上次读取的最后的位置,那么size就也可理解为剩下的所有可读数据的长度。
总结:简单理解就是每次获取指定MappedFile的缓存数据时,都是直接获取全部可读的Buffer,同时设置最后可读的偏移量到reputFromOffset中。因为一次Message存储的时候并不一定会将整个CommitLog文件填满,那么下次Message存储时还会在这个文件中新增,那么ConsumeQueue就会通过reputFromOffset往后读取新增的所有Message。
初始化DispatchRequest
这是创建MessageQueue之前的最后异步操作,dispatchRequest对象就是创建MessageQueue所需要的的请求对象
// 源码位置:
// 包名: org.apache.rocketmq.store
// 文件: DefaultMessageStore
// 行数: 2833
for (int readSize = 0; readSize < result.getSize() && reputFromOffset < DefaultMessageStore.this.getConfirmOffset() && doNext; ) {
DispatchRequest dispatchRequest =
DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false, false);
int size = dispatchRequest.getBufferSize() == -1 ? dispatchRequest.getMsgSize() : dispatchRequest.getBufferSize();
//...
}
- 这是的for循环作用就是每次只处理一条Message数据,因为获取的Buffer中包含多条数据,每条数据都需要单独处理。
- dispatchRequest中存储的就是对单条Message处理后一些必要的数据,比如topic、queueId、commitLogOffset、msgSize、tagsCode等
对于ByteBuffer不太熟悉的可能会疑惑ByteBuffer单条数据的获取是怎么完成的呢,这里不是重点就略过了,后面会单独出一篇来学习。(因为博主之前也有相同的疑惑)
执行CommitLogDispatcher
// 源码位置:
// 包名: org.apache.rocketmq.store
// 文件: DefaultMessageStore
// 行数: 1971
public void doDispatch(DispatchRequest req) throws RocksDBException {
for (CommitLogDispatcher dispatcher : this.dispatcherList) {
dispatcher.dispatch(req);
}
}
源码可知就是将DispatchRequest作为参数传给所以需要处理该对象的调度服务中。
- dispatcherList集合就是存储的索引调度服务-LinkedList<CommitLogDispatcher> dispatcherList;
那么dispatcherList中的调度服务在什么地方设置的呢,又有那些调度服务呢?
// 源码位置:
// 包名: org.apache.rocketmq.store
// 文件: DefaultMessageStore
// 行数: 263
this.dispatcherList = new LinkedList<>();
this.dispatcherList.addLast(new CommitLogDispatcherBuildConsumeQueue());
this.dispatcherList.addLast(new CommitLogDispatcherBuildIndex());
if (messageStoreConfig.isEnableCompaction()) {
//...
this.dispatcherList.addLast(new CommitLogDispatcherCompaction(compactionService));
}
通过查找dispatcherList集合的引用,我们找到了添加调度服务的位置,并且结合源码上下文发现该段代码的触发源头也是在Broker服务启动流程中执行的。
由源码可知一共有三种调度服务:
- CommitLogDispatcherBuildConsumeQueue:这就是处理ConsumeQueue的服务,看着很长直接看名称后半段BuildConsumeQueue就能知道
- CommitLogDispatcherBuildIndex:BuildIndex是否眼熟,这也是前面讲到的持久化种类中的一种IndexFile,它持久化处理原来也在这个位置。
- CommitLogDispatcherCompaction: 主要负责压缩消息的逻辑处理
初始化ConsumeQueue
// 源码位置:
// 包名: org.apache.rocketmq.store.queue
// 文件: ConsumeQueueStore
// 行数: 152
ConsumeQueueInterface cq = this.findOrCreateConsumeQueue(dispatchRequest.getTopic(), dispatchRequest.getQueueId());
this.putMessagePositionInfoWrapper(cq, dispatchRequest);
ConsumeQueueInterface的获取逻辑大致为,通过topic以及queueId在
ConcurrentMap<String/* topic */, ConcurrentMap<Integer/* queueId */, ConsumeQueueInterface>> consumeQueueTable;
中获取对象,存在就直接获取,不存在就创建后存入Map中并返回。
持久化存储ConsumeQueue
// 源码位置:
// 包名: org.apache.rocketmq.store.queue
// 文件: ConsumeQueue
// 行数: 795
this.byteBufferIndex.flip();
this.byteBufferIndex.limit(CQ_STORE_UNIT_SIZE);
this.byteBufferIndex.putLong(offset);
this.byteBufferIndex.putInt(size);
this.byteBufferIndex.putLong(tagsCode);
由源码可知ConsumeQueue数据也是和CommitLog一样通过byteBuffer先存储在内存缓冲区中然后再异步持久化,这就是mmap的实现方式。
- CQ_STORE_UNIT_SIZE:固定长度20
前面讲到的ConsumeQueue固定长度的三种组成内容:offset(在commitLog的偏移量)、size(消息长度)、tagsCode(tag标签的hash值)。
最终调用与commitLog相同的存储接口进行保存:由此可见ConsumeQueue与CommitLog文件的最终的底层映射对象都是MappedFile
// 源码位置:
// 包名: org.apache.rocketmq.store.queue
// 文件: ConsumeQueue
// 行数: 836
return mappedFile.appendMessage(this.byteBufferIndex.array());
就此ConsumeQueue的持久化工作就完成了。
认识IndexFile
前面简单介绍了IndexFile,它也是需要持久化到磁盘的数据之一,存储的是通过 Key 或者时间区间来查询 Message 的索引,它作用主要应用在哪些场景呢。主要是用于RocketMq的控制台面板的查询中。
Key查询:
时间区间查询:
总结
本篇RocketMQ 通过 ConsumeQueue
文件来存储消息的消费位置和元数据信息,以实现消息的快速消费和定位。ConsumeQueue
的持久化主要通过内存映射文件(MappedFile
)实现,并采用异步刷盘机制来提高 I/O 性能和数据可靠性。通过对 ConsumeQueue
持久化源码的学习,可以更好地理解 RocketMQ 的消息存储和消费机制,以及其在高性能消息中间件中的应用。