10 张图告诉你 RocketMQ 是怎样保存消息的

简介: 10 张图告诉你 RocketMQ 是怎样保存消息的

大家好,我是君哥,今天分享 RocketMQ 是怎样保存消息的。

1 简介

首先,在 RocketMQ 集群中创建一个 Topic,叫做 MyTestTopic,配置如下图:

微信图片_20221213121517.png这里解释一下图中的几个参数:

writeQueueNums:客户端在发送消息时,可以向多少个队列进行发送;readQueueNums:客户端在消费消息时,可以从多少个队列进行拉取;perm:当前 Topic 读写权限,2 只允许读、4 只允许写、6 允许读写,默认是 6


RocketMQ 主要有 3 个消息相关的文件:commitlog、consumequeue 和 index。下面是这几个文件默认的路径:

[root@xxx store]# pwd
/root/store
[root@xxx store]# ls
abort  checkpoint  commitlog  config  consumequeue  index  lock

上面的 writeQueueNums 参数控制 consumequeue 的文件的数量。作为测试,我往 MyTestTopic 这个 Topic 发送了 100 条消息,这些消息保存在了 commitlog 文件。而 consumequeue 文件如下:

[root@xxx MyTestTopic]# pwd
/root/store/consumequeue/MyTestTopic
[root@xxx MyTestTopic]# ls
0  1  2  3  4  5  6  7

可以看到,consumequeue 的保存是在 consumequeue 目录下为每个 Topic 建一个目录,用保存这个 Topic 的 consumequeue 文件。consumequeue 文件为每个 Topic 基于偏移量创建了一个索引。

index 文件保存的是消息基于 key 的 HASH 索引。

2 commitlog 文件

commitlog 是 RocketMQ 保存消息的文件。commitlog 并没有按照 Topic 来分割,所有 Topic 的消息都写入同一个 commitlog。

为了追求高效写入,RocketMQ 使用了磁盘顺序写。commitlog 文件大小默认是 1G,可以通过参数 mappedFileSizeCommitLog 来修改。

下面是服务器磁盘上保存的 commitlog 文件(文件大小 1G):

[root@xxx commitlog]# pwd
/root/store/commitlog
[root@xxx commitlog]# ls
00000000000000000000  00000000001073741824

如果配置 mappedFileSizeCommitLog 参数为 1048576,也就是 1M,则服务器磁盘上保存的 commitlog 文件如下

[root@xxx commitlog]# pwd
/root/store/commitlog
[root@xxx commitlog]# ls
00000000000000000000  00000000000001048576  00000000000002097152  00000000000003145728  00000000000004194304  00000000000005242880

可以看到:commitlog 文件的命名以保存在文件中的消息最小的偏移量来命名的,后一个文件的名字是前一个文件名加文件大小。比如上面的前两个文件,第一个文件中消息最小偏移量是 0,第二个文件中消息最小偏移量是 1048576。这样通过偏移量查找消息时可以先用二分查找找到消息所在的文件,然后通过偏移量减去文件名就可以方便地找到消息在文件中的物理地址。

下面创建文件的代码可以看到 commitlog 文件的命名:

//MappedFileQueue 类
protected MappedFile tryCreateMappedFile(long createOffset) {
 String nextFilePath = this.storePath + File.separator + UtilAll.offset2FileName(createOffset);
 String nextNextFilePath = this.storePath + File.separator + UtilAll.offset2FileName(createOffset
   + this.mappedFileSize);
 return doCreateMappedFile(nextFilePath, nextNextFilePath);
}
//UtilAll 类
public static String offset2FileName(final long offset) {
        final NumberFormat nf = NumberFormat.getInstance();
  //文件名长度是20
        nf.setMinimumIntegerDigits(20);
        nf.setMaximumFractionDigits(0);
        nf.setGroupingUsed(false);
        return nf.format(offset);
    }

为了让 commitlog 操作效率更高,RocketMQ 使用了 mmap 将磁盘上日志文件映射到用户态的内存地址中,减少日志文件从磁盘到用户态内存之间的数据拷贝。代码如下:

//AllocateMappedFileService 类 mmapOperation 方法
//是否开启堆外内存
if (messageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
 try {
  mappedFile = ServiceLoader.load(MappedFile.class).iterator().next();
  mappedFile.init(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
 } catch (RuntimeException e) {
  log.warn("Use default implementation.");
  mappedFile = new MappedFile(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
 }
} else {
 mappedFile = new MappedFile(req.getFilePath(), req.getFileSize());
}

写入消息时,如果 isTransientStorePoolEnable 方法返回 true,则消息数据先写入堆外内存,然后异步线程把堆外内存数据刷到 PageCache,如果返回 false 则直接写入 PageCache。后面根据刷盘策略把 PageCache 中数据持久化到磁盘。如下图:

微信图片_20221213121546.png

对应代码如下:

public CompletableFuture<PutMessageResult> asyncPutMessage(final MessageExtBrokerInner msg) {
 putMessageLock.lock(); //spin or ReentrantLock ,depending on store config
 try {
     //1.获取 mappedFile
  MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile();
        //2.追加消息,如果 mappedFile 写满了,则新建一个
  result = mappedFile.appendMessage(msg, this.appendMessageCallback, putMessageContext);
  switch (result.getStatus()) {
   case PUT_OK:
    break;
   case END_OF_FILE:
    unlockMappedFile = mappedFile;
    // Create a new file, re-write the message
    mappedFile = this.mappedFileQueue.getLastMappedFile(0);
    if (null == mappedFile) {
     // XXX: warn and notify me
     log.error("create mapped file2 error, topic: " + msg.getTopic() + " clientAddr: " + msg.getBornHostString());
     return CompletableFuture.completedFuture(new PutMessageResult(PutMessageStatus.CREATE_MAPEDFILE_FAILED, result));
    }
    result = mappedFile.appendMessage(msg, this.appendMessageCallback, putMessageContext);
    break;
   //...
  }//...
 } finally {
  putMessageLock.unlock();
 }
 //3.请求刷盘
 CompletableFuture<PutMessageStatus> flushResultFuture = submitFlushRequest(result, msg);
 //...
}

论先写对堆外内存还是直接写 PageCache,文件数据都会映射到 MappedByteBuffer。如下图:

微信图片_20221213121611.png

不同的是,如果消息先写入堆外内存,则 MappedByteBuffer 主要用来读消息,堆外内存用来写消息。这一定程度上实现了读写分离,减少 PageCache 写入压力。

再看一下文件映射的代码,如下:

//MappedFile 类
private void init(final String fileName, final int fileSize) throws IOException {
 this.fileName = fileName;
 this.fileSize = fileSize;
 this.file = new File(fileName);
 this.fileFromOffset = Long.parseLong(this.file.getName());
 //...
 try {
  this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
  this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
  //...
 } //省略 catch finally
}

这里使用了 Java 中 FileChannel 的 map 方法来实现 mmap。

有一个细节需要注意:创建 MappedFile 后会进行文件预热,目的是为了预先将 PageCache 加载到内存,防止读写数据发生缺页中断时再加载,影响性能。代码如下:

//AllocateMappedFileService 类 mmapOperation 方法
// pre write mappedFile
if (mappedFile.getFileSize() >= this.messageStore.getMessageStoreConfig()
 .getMappedFileSizeCommitLog()
 &&
 this.messageStore.getMessageStoreConfig().isWarmMapedFileEnable()) {
 mappedFile.warmMappedFile(this.messageStore.getMessageStoreConfig().getFlushDiskType(),
  this.messageStore.getMessageStoreConfig().getFlushLeastPagesWhenWarmMapedFile());
}

最后,附上一张写 commitlog 的 UML 类图:

微信图片_20221213121636.png

3 consumequeue 文件

前面讲到过,所有 Topic 的消息都写到同一个 commitlog 文件,如果直接在 commitlog 文件中查找消息,只能从文件头开始查找,肯定会很慢。因此 RocketMQ 引入了 consumequeue,基于 Topic 来保存偏移量。从 consumequeue 文件的保存目录也能看出来:

[root@xxx MyTestTopic]# pwd
/root/store/consumequeue/MyTestTopic
[root@xxx MyTestTopic]# ls
0  1  2  3  4  5  6  7

consumequeue 目录下会为每个 Topic 创建一个目录,每个 Topic 目录下为每一个 consumequeue 创建一个目录,比如上面的 MyTestTopic 这个 Topic 下面有 8 个 consumequeue。

每个 consumequeue 目录下保存了这个队列的文件内容。以上面第 7 个目录为例:

[root@xxx 7]# pwd
/root/store/consumequeue/MyTestTopic/7
[root@xxx 7]# ls
00000000000000000000

consumequeue 的文件结构如下图:

微信图片_20221213121659.png

其中前 8 个字节保存消息在 commitlog 中的偏移量,中间 4 个字节保存消息消息大小,最后 8 个字节保存消息中 tag 的 hashcode。

这里为什么要保存一个 tag 的 hashcode 呢?

如果一个 Consumer 订阅了 TopicA 这个 Topic 中的 Tag1 和 Tag2 这两个 tag,那这个 Consumer 的订阅关系如下图:

微信图片_20221213121718.png

可以看到,订阅关系这个对象封装了 Topic、tag 以及所订阅 tag 的 hashcode 集合。

Consumer 发送拉取消息请求时,会把订阅关系传给 Broker(Broker 解析成 SubscriptionData 对象),Broker 使用 consumequeue 获取消息时,首先判断判断最后 8 个字节的 tag hashcode 是否在 SubscriptionData 的 codeSet 中,如果不在就跳过,如果存在就根据偏移量从 commitlog 中获取消息返回给 Consumer。如下图:

微信图片_20221213121738.png

参考下面代码:

public boolean isMatchedByConsumeQueue(Long tagsCode, ConsumeQueueExt.CqExtUnit cqExtUnit) {
 //...
 return subscriptionData.getSubString().equals(SubscriptionData.SUB_ALL)
  || subscriptionData.getCodeSet().contains(tagsCode.intValue());
}

commitlog 一样,consumequeue 也会使用 mmap 映射为 MappedFile 存储对象。

4 index 文件

为了支持按照消息的某一个属性来查询,RocketMQ 引入了 index 索引文件。index 文件结构如下图:

微信图片_20221213121800.png

主要由三部分组成:IndexHeader、HashSlog 和 Index 条目。跟 commitlog 一样,Index 文件也会使用 mmap 映射为 MappedFile 存储对象。

4.1 IndexHeader

IndexHead 由如下 6 个属性组成,这些熟悉定义在类 IndexHeader:

1.beginTimestamp:index 文件中最小的消息存储时间;

2.endTimestamp:index 文件中最大的消息存储时间;

3.beginPhyoffset:index 文件中包含的消息中最小的 commitlog 偏移量;

4.endPhyoffset:index 文件中包含的消息中最大的 commitlog 偏移量;

5.hashSlotcount:index 文件中包含的 hash 槽的数量;

6.indexCount:index 文件中包含的 index 条目个数。

4.2 HashSlog

HashSlot 就是 Java HashMap 中的 hash 槽,默认有 500 万个。每个 HashSlot 使用 4 个字节 int 类型保存最后一个 Index 条目的位置。

注意:上面为什么说最后一个 Index 条目?因为 Index 条目保存的是 key 的 hashcode,存在 hash 冲突的情况下,HashSlot 使用链表法解决,在 Index 条目中会保存相同 Hash 值的前一个条目位置。如下图:

微信图片_20221213121825.png

key 为 key1、key2、key3 的三条消息依次写入,并且这 3 个 key 有相同的 hashcode。写入 key1 时,hash 槽保存了 key1 消息的 index 条目位置,写入 key2 时 hash 槽保存了 key2 消息的 index 条目位置,同时 key2 消息 index 条目中的 prevIndex 保存了 key1 消息的 index 条目位置,写入 key3 时 hash 槽保存了 key3 消息的 index 条目位置,同时 key3 消息 index 条目的 prevIndex 保存了 key2 消息的 index 条目位置。

4.3 index 条目

index 条目录由 4 个属性组成:

1.key hashcode:要查找消息的 key 的 hashcode;

2.phyOffset:消息在 commitlog 文件中的物理偏移量;

3.timediff:该消息存储时间与 beginTimestamp 的差值。通过 key 查找消息时,在 key 相同的情况下,还要看 timediff 是否在区间范围内 ,不在时间范围内的就不返回,参考下面代码:

//IndexFile 类
long timeRead = this.indexHeader.getBeginTimestamp() + timeDiff;
boolean timeMatched = (timeRead >= begin) && (timeRead <= end);
if (keyHash == keyHashRead && timeMatched) {
 phyOffsets.add(phyOffsetRead);
}

4.prevIndex:key 发生 hash 冲突后保存相同 hash code 的前一个 index 条目位置。

index 条目默认有 2000 万个。

4.4 查找过程

整个查找过程如下图:

微信图片_20221213121849.png

详细代码见 IndexFile 类 selectPhyOffset 这个方法。

5 文件构建

看到这里可能大家会有一个疑问,consumequeue 和 index 文件的内容是什么时候写入呢?

在 MessageStore 初始化的时候会启动一个线程 ReputMessageService,这个线程的逻辑是死循环里面每个 1ms 执行一次,从 commitlog 中获取消息然后写入 consumequeue 和 index 文件。参考下面代码:

//DefaultMessageStore 类 doReput 方法
DispatchRequest dispatchRequest =
 DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false);
int size = dispatchRequest.getBufferSize() == -1 ? dispatchRequest.getMsgSize() : dispatchRequest.getBufferSize();
if (dispatchRequest.isSuccess()) {
 if (size > 0) {
  DefaultMessageStore.this.doDispatch(dispatchRequest);
        //...
 }
}
public void doDispatch(DispatchRequest req) {
 for (CommitLogDispatcher dispatcher : this.dispatcherList) {
  dispatcher.dispatch(req);
 }
}

下面是 dispatcherList 的定义:

this.dispatcherList = new LinkedList<>();
//CommitLogDispatcherBuildConsumeQueue 类用来写 consumequeue
this.dispatcherList.addLast(new CommitLogDispatcherBuildConsumeQueue());
//CommitLogDispatcherBuildIndex 类用来写 index 文件
this.dispatcherList.addLast(new CommitLogDispatcherBuildIndex());

可以看到,即使 Broker 挂了,只要 commitlog 在,就可以重新构建出 consumequeue 和 index 文件。

6 总结

本文主要介绍了 RocketMQ 的消息存储原理,RocketMQ 的存储很有艺术性,同时理解起来也比较困难。希望本文能带你入门 RocketMQ 的存储,写得不对的地方,欢迎大家指正。


相关实践学习
消息队列RocketMQ版:基础消息收发功能体验
本实验场景介绍消息队列RocketMQ版的基础消息收发功能,涵盖实例创建、Topic、Group资源创建以及消息收发体验等基础功能模块。
消息队列 MNS 入门课程
1、消息队列MNS简介 本节课介绍消息队列的MNS的基础概念 2、消息队列MNS特性 本节课介绍消息队列的MNS的主要特性 3、MNS的最佳实践及场景应用 本节课介绍消息队列的MNS的最佳实践及场景应用案例 4、手把手系列:消息队列MNS实操讲 本节课介绍消息队列的MNS的实际操作演示 5、动手实验:基于MNS,0基础轻松构建 Web Client 本节课带您一起基于MNS,0基础轻松构建 Web Client
相关文章
|
消息中间件 算法 Java
弥补延时消息的不足,RocketMQ 基于时间轮算法实现了定时消息!
弥补延时消息的不足,RocketMQ 基于时间轮算法实现了定时消息!
767 1
弥补延时消息的不足,RocketMQ 基于时间轮算法实现了定时消息!
|
消息中间件 存储 数据可视化
【RocketMq-生产者】消息发送者参数详解
首先注意本次讨论的RokcetMq源码版本为 4.9.4,距离5.0发布 的没有多久。 这一节针对RocketMq的生产者请求发送的部分细节进行阐述,主要包含了下面的内容:DefaultMQProducer 为生产者默认对象,这个对象继承自 ClientConfig,里面包含了请求者的通用配置,所以可以拆分为两个部分进行理解,第一部分为ClientConfig,第二部分为DefaultMQProducer。
695 0
|
消息中间件 uml RocketMQ
3 张图带你彻底理解 RocketMQ 事务消息
3 张图带你彻底理解 RocketMQ 事务消息
67769 2
3 张图带你彻底理解 RocketMQ 事务消息
|
消息中间件 存储 负载均衡
【消息中间件】默认的RocketMQ消息消费者是如何启动的?(下)
在当下的分布式服务中,消息队列中间件是一个解决服务之间耦合的利器,今天我们来瞧一瞧开源的RocketMQ消息中间件,他的消费端是如何启动的,以及在使用他的过程中有哪些配置。
|
消息中间件 Java RocketMQ
【消息中间件】默认RocketMQ消息发送者是如何启动的?
上一篇文章,主要介绍了RocketMQ消息发送-请求与响应,了解了消息发送的请求参数和响应结果,今天我们主要来学习默认消息发送者的源码,看看消息发送主要是做了那哪些事情。
|
消息中间件 负载均衡 算法
【消息中间件】默认的RocketMQ消息消费者是如何启动的?(上)
在当下的分布式服务中,消息队列中间件是一个解决服务之间耦合的利器,今天我们来瞧一瞧开源的RocketMQ消息中间件,他的消费端是如何启动的,以及在使用他的过程中有哪些配置。
|
消息中间件 NoSQL 关系型数据库
实战:如何防止mq消费方消息重复消费、rocketmq理论概述、rocketmq组成、普通消息的发送
实战:如何防止mq消费方消息重复消费 如果因为网络延迟等原因,mq无法及时接收到消费方的应答,导致mq重试。(计算机网络)。在重试过程中造成重复消费的问题
2736 1
实战:如何防止mq消费方消息重复消费、rocketmq理论概述、rocketmq组成、普通消息的发送
|
消息中间件 Java uml
5张图带你理解 RocketMQ 顺序消息实现机制
5张图带你理解 RocketMQ 顺序消息实现机制
683 1
5张图带你理解 RocketMQ 顺序消息实现机制
|
消息中间件 缓存 算法
阿里二面:RocketMQ 消息积压了,增加消费者有用吗?
阿里二面:RocketMQ 消息积压了,增加消费者有用吗?
258 0
阿里二面:RocketMQ 消息积压了,增加消费者有用吗?
|
消息中间件 缓存 数据库
4 张图,9 个维度告诉你怎么做能确保 RocketMQ 不丢失消息
4 张图,9 个维度告诉你怎么做能确保 RocketMQ 不丢失消息
421 0
4 张图,9 个维度告诉你怎么做能确保 RocketMQ 不丢失消息