文章目录
本文章基于 RocketMQ 4.9.3
1. 前言
RocketMQ 具有高吞吐量、低延迟、高可靠性和可扩展性等特点,这篇文章中就和大家来学习下 RocketMQ 的存储结构。
大家都知道,RocketMQ 作为消息队列,所产生的消息是会持久化的,那么如何高效持久化,持久化之后如何高效搜索这些消息,这些就是 RocketMQ 的存储架构要考虑的了。下面我们就来看下 RocketMQ 是如何存储消息的。
2. 消息存储整体架构
首先要来看的就是经典的 RocketMQ 消息存储架构图,这个图是官方文档中给出的,这个图中给出了 CommitLog、MessageQueue、ConsumeQueue 的关系。
- CommitLog: 消息主体以及元数据的存储主体,生产者发送的消息会先到达 Broker,然后 Broker 把消息写入 Commit。
- ConsumeQueue: 消息消费队列,引入的目的主要是提高消息消费的性能,提供基于 Topic 来快速检索的功能
- MessageQueue: 消息队列,和 ConsumeQueue 是 1v 1 的关系,类似 Kafka 的分区,MessageQueue 可以落在不同的 Broker,起到负载均衡的效果。
- IndexFile: 上面的 ConsumeQueue 是根据 topic 查询消息,但是除了 topic 查询,还需要根据时间、key 来查询,这时候就轮到 IndexFile 了,IndexFile(索引文件)提供了一种可以通过key或时间区间来查询消息的方法。
RocketMQ采用的是混合型的存储结构,即为Broker单个实例下所有的队列共用一个日志数据文件(即为CommitLog)来存储。在 RocketMQ 的混合存储结构中,一个 Broker 下的所有队列(ConsumeQueue)都存储到一个 CommitLog 中,当然这里 CommitLog 大小默认是 1G,所以当写入的数据到了 1G 之后,就会重新创建一个 CommitLog。
Producer 发送消息到 Broker 之后,Broker 使用同步或者异步的方式将消息持久化,保存到 CommitLog 中,当消息持久化到 CommitLog 之后,消息就不会丢失,反而可以根据 CommitLog 来进行消息重放。
3. CommitLog
上面说过了,CommitLog 是消息写入的载体,Broker 将消息持久化到 CommitLog 中就代表消息不会丢失。那么 CommitLog 内部长什么样,我们看下 CommitLog 的结构。
Commit 里面的消息结构如下,开头 TOTALSIZE
存储消息的总长度,后面就是其他的消息,属性如下:
msg length(4 字节) | magicCode(4 字节) | body crc(4 字节) | queueId(4字节) | msg flag(4 字节) |
---|---|---|---|---|
总长度 | 魔数,用于判断是不是空消息 | 消息体CRC校验码,用于校验消息传输的过程是否有错 | 队列 ID | 消息 flag |
queue offset(8 字节) | physical offset(8 字节) | sys flag(4 字节) | born timestamp(8 字节) | |
消息在 ConsumeQueue 中的偏移量(实际要 * 20) | 消息在 CommitLog 中的物理偏移量 | 消息状态,如压缩、事务的各个阶段等 | 消息生成时间(时间戳) | |
born host(8 字节) | store timestamp(8 字节) | store host(8 字节) | consume times(4 字节) | |
消息生成的 Producer 端地址 | 消息在 Broker 存储的时间戳 | Broker 端的地址 | 消息重试次数 | |
prepared transaction offset(8 字节) | msg body(4 + body.length) | msg topic(1 + topic) | msg properties(2 + properties) | |
prepared 状态的事务消息偏移量 | 消息体长度 + 消息体 | topic 长度和 topic 内容 | 消息属性长度 + 属性内容 |
大家可能会疑惑上面图中的字段和表格不一样的,那是因为上面图中是 RocketMQ 5.x 版本的消息内容,下面表格是 4.x 的内容。虽然有一些改动,但是基本上都是字节什么的有变化,内容含义是一样的。
在这些属性里面,有一个属性可能大家会好奇,就是 sys flag,这玩意在 RocketMQ 4.x 和 RocketMQ 5.x 中就是字节变了,那么这个字段是干什么用的呢?
实际上这个字段表示消息的状态,下面可以来简单看下:
- COMPRESSED_FLAG(0x1): 消息已压缩,接收方接收到消息之后需要进行解压
- MULTI_TAGS_FLAG (0x1 << 1): 表示消息具有多个标签(tags),一般来说消息可以有多个标签,
- TRANSACTION_NOT_TYPE (0): 表示消息不是事务消息,主要是用来区分下面其他的事务消息类型
- TRANSACTION_PREPARED_TYPE (0x1 << 2): 消息是事务消息准备阶段的消息,在事务消息机制中,生产者首先会发送一条半事务消息到 Broker,然后执行本地事务,接着再把本地事务执行的结果发给 Broker,当然 Broker 在没有收到本地事务执行结果的时候(commit 或者 rollback)是不会处理这条消息的,准备状态就是在等待生产者发送本地事务的执行结果。
- TRANSACTION_COMMIT_TYPE (0x2 << 2): 表示消息是被提交的事务消息。当事务逻辑执行成功后,生产者会发送一条 commit 消息,此时消息的状态变更为已提交,可以被消费者消费。
- TRANSACTION_ROLLBACK_TYPE (0x3 << 2): 表示消息是被回滚的事务消息。当事务逻辑执行失败后,生产者会发送一条 rollback 消息,此时消息的状态变更为已回滚,不会被消费者消费。
- BORNHOST_V6_FLAG (0x1 << 4): 表示消息的生产者地址是 IPV6,也就是 128 位
- STOREHOSTADDRESS_V6_FLAG (0x1 << 5): 表示消息的存储端(Broker)地址是 IPV6,也就是 128 位
上面是 RocketMQ 4.x 的属性,而 RocketMQ 5.x 中增加了多几个字段,如下图所示:
这些就等后面解析到了再一起说把,其实里面增加的大部分都是压缩的类型,也就是压缩算法。所以在 RocketMQ 5.x 中 sys flag
用了 long
类型来表示,就是为了能存储更多标识。
那么回到 CommitLog,上面我们已经大概看了 CommitLog 一条消息所包含的内容,CommitLog 本身又是存储在哪的呢?我们可以看下下面这个图:
为了生成上面这些文件,我这里使用 Producer 写入了 2 GB 的数据,代码如下:
public class CommitLogProducer {
public static void main(String[] args) throws Exception {
// 1. 创建一个生产者实例
DefaultMQProducer producer = new DefaultMQProducer("ProducerGroup");
// 2. 设置 NameServer 地址
producer.setNamesrvAddr("localhost:9876");
// 3. 启动生产者
producer.start();
// 4. 定义消息大小(例如 512 bytes)
int messageSize = 512; // 每条消息的大小为 512 字节
byte[] messageBody = new byte[messageSize]; // 创建一个 512 字节的字节数组
// 5. 计算需要发送的消息数量
long totalSizeInBytes = 2L * 1024 * 1024 * 1024;
int numMessages = (int) (totalSizeInBytes / messageSize); // 计算需要发送的消息数量
// 6. 循环发送消息
for (int i = 0; i < numMessages; i++) {
// 创建消息对象
Message msg = new Message("TestTopic", // 主题
"TagA", // 标签
messageBody); // 消息体
// 发送消息
SendResult sendResult = producer.send(msg);
System.out.printf("Sent message #%d, result: %s%n", i, sendResult);
}
// 7. 关闭生产者
producer.shutdown();
}
}
上面的代码中虽然写入 2GB,但是一条消息包括了上面说过的那些字段,所以真实存储的不止 2GB。
从上面图中可以看到 CommitLog 有下面几个特点:
- CommitLog 单个文件大小默认1G,文件名长度为 20 位,左边补零,文件名长度为20位,左边补零,剩余为起始偏移量,比如00000000000000000000代表了第一个文件,起始偏移量为0,文件大小为1G=1073741824
- CommitLog 日志属于顺序写,当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,以此类推
4. ConsumeQueue
上面的 CommitLog 是实际存储消息的地方,但是如果我们要根据 topic 去检索一条消息,直接搜索 CommitLog 怕是效率很低。CommitLog 是顺序写入,所以对于写性能还是很高的,但是对于读,就需要去遍历 CommitLog 了,这时候性能会比较慢。
我们知道 RocketMQ 是基于主题 topic
的订阅模式,消息消费是针对主题进行的,所以当我们要查找这个 topic 下面的消息
的时候就不能直接搜索 CommitLog 了。ConsumeQueue
就是为了解决这个问题诞生的,我们知道如果要快速搜索 CommitLog 中的一条消息,最重要的就是知道这条消息在 CommitLog 中的偏移量,然后就可以根据偏移量快速搜索 CommitLog 了。
下面就来看下 ConsumeQueue
的结构:
- 首先是 8 个字节的 CommitLog Offset,就是消息在 CommitLog 中的偏移量,通过这个偏移量可以快速定位到消息的存储位置,从而读取到消息。
- 其次是消息的长度,其实读取 CommitLog 的时候 CommitLog 中记录的第一个字段就是消息的总长度,所以这个消息长度更多是用来更新一些偏移量,比如当前 CnnsumeQueue 下消息最大有效偏移量。
- 最后是 tag hashcode,其实就是 tag 值的 hashCode,在 RocketMQ 中,消费者可以通过指定 tag 来订阅特定类型的消息,同样的可以通过这个 tag hashcode 快速过滤掉不是这个消费者订阅的 tag。
下面再来看下 ConsumeQueue 的结构:
但是在图中可以看出 ConsumeQueue 上面还有一个 MessageQueue,那么 ConsumeQueue
和 MessageQueue
的关系是什么呢?
4.1 MessageQueue
这里就来简单介绍下 MessageQueue,MessageQueue
和 ConsumeQueue
是 1 -> 1
的关系,也就是一个 MessageQueue
映射到一个 ConsumeQueue
。
正常来说一个 Topic 下面会创建 4 个 MessageQueue
,编号从 0 开始,如下图所示。
MessageQueue
类似 Kafka 的 partation,实际上不同的 MessageQueue 是可以分布到不同的 Broker 上的,起到分区的效果,但是在 RocketMQ 中,MessageQueue 实际上并不存储具体数据,而是存储 topic 和 brokerName 和 queueId 的关系,换句话说,MessageQueue 在 RocketMQ 中只是一个 Entity,只是用于存储队列关系的。
public class MessageQueue implements Comparable<MessageQueue>, Serializable {
private static final long serialVersionUID = 6191200464116433425L;
private String topic;
private String brokerName;
private int queueId;
// ....
}
4.2 ConsumeQueue 的存储
上面我们介绍了 MessageQueue,那么从上面图中一共就能看出了,ConsumeQueue 文件夹的组织方式就是:$RocketMQ_HOME/store/consumequeue/{topic}/{queueId}/{fileName}
,至于consumequeue 文件夹中的条目上面已经解析过了,这里不多说。
可以看到在具体队列下面,每一个文件和上面的 CommitLog 一样,文件夹命名从 0 开始,consumequeue 文件采取定长设计,单个文件由 30W 个 条目构成,所以一个文件大小就是:30w * 20 = 600w 字节
,也就是:600w / 1024 / 1024 ≈ 5.72M
,所以对于 ConsumeQueue,可以像数组一样随机访问每一个条目,每个 ConsumeQueue 文件大小约 5.72 M。这里的随机访问,当然不是指文件的随机访问了,而是 ConsumeQueue 背后的 MappedFileQueue。
最后我们再来看下每一个 ConsumeQueue 的存储文件,如下图:
可以看到,ConsumeQueue 下面每一个队列 ID 下都是相同的文件结构,但是看到这里,不知道各位有没有疑问,不同 ConsumeQueue 下面的文件存储的内容是一样的吗?看文件确实存储结构都是一样的。
那么要了解这个问题,我们就需要看文件里面的内容,但是我们没办法直接通过文件记事本打开这个文件,所以我们可以直接用代码进行读取,那么代码怎么来呢,当然是使用万能的 GPT 了!!!😀。
public class ConsumeQueueReader {
public static void main(String[] args) throws IOException {
// 1. 指定 ConsumeQueue 文件的路径
String consumeQueueFilePath = "D:\\javaCode\\rocketmq-source\\config\\store\\consumequeue\\TestTopic\\0\\00000000000000000000";
// 2. 创建文件对象
File consumeQueueFile = new File(consumeQueueFilePath);
// 3. 读取 ConsumeQueue 文件
readConsumeQueue(consumeQueueFile);
}
/**
* 读取 ConsumeQueue 文件并解析内容
*
* @param consumeQueueFile ConsumeQueue 文件对象
*/
public static void readConsumeQueue(File consumeQueueFile) throws IOException {
// 4. 创建 RandomAccessFile 对象
try (RandomAccessFile raf = new RandomAccessFile(consumeQueueFile, "r");
FileChannel channel = raf.getChannel()) {
// 5. 定义每个条目的大小(固定为 20 字节)
int entrySize = 20; // 每个条目 20 字节(8 + 4 + 8)
// 6. 获取文件大小
long fileSize = channel.size();
// 7. 计算条目数量
long numEntries = fileSize / entrySize;
// 8. 创建 ByteBuffer 来读取数据
ByteBuffer buffer = ByteBuffer.allocate(entrySize);
// 9. 遍历每个条目
for (long i = 0; i < numEntries; i++) {
// 清空 buffer
buffer.clear();
// 读取一个条目的数据
channel.read(buffer);
buffer.flip();
// 解析条目内容
long commitLogOffset = buffer.getLong(); // 消息的物理偏移量(8 字节)
int size = buffer.getInt(); // 消息的大小(4 字节)
long tagHashCode = buffer.getLong(); // 消息的 Tag HashCode(8 字节)
// 打印解析结果
System.out.printf("Entry #%d: CommitLog Offset=%d, Size=%d, Tag HashCode=%d%n",
i, commitLogOffset, size, tagHashCode);
}
}
}
}
我们就使用上面这个代码进行查询,主要就是查询 4 个队列下面第一个文件,也就是:00000000000000000000
,其实主要看下这些消息的读取结果就行了,所以上面我们修改 numEntries = 10
,这时候。
首先是 D:\\javaCode\\rocketmq-source\\config\\store\\consumequeue\\TestTopic\\0\\00000000000000000000
。
然后是 D:\javaCode\rocketmq-source\config\store\consumequeue\TestTopic\1\00000000000000000000
接着是 D:\javaCode\rocketmq-source\config\store\consumequeue\TestTopic\2\00000000000000000000
最后是 D:\javaCode\rocketmq-source\config\store\consumequeue\TestTopic\3\00000000000000000000
所以这几个 ConsumeQueue 的存储是不一样的,也就是说 0
、1
、2
、3
里面存储的结果是不一样的。
5. IndexFile
上面的 ConsumeQueue 提供了根据 topic 快速查询消息的方法,但是我们知道 RocketMQ 中对于消息的查询还可以通过 key 和时间区间来查询,那么如何通过 key 和时间区间快速查询呢?这就要说到 IndexFile 了。
Index文件的存储位置是:{ROCKETMQ_HOME}\store\index\{fileName}
,文件名 fileName 是以创建时的时间戳命名的,固定的单个 IndexFile 文件大小约为 400M
,一个IndexFile可以保存 2000W
个索引,IndexFile 的底层存储设计为在文件系统中实现 HashMap
结构,故 rocketmq 的索引文件其底层实现为 hash
索引。
下面再来看一下 RocketMQ 官方给出来的结构图,这个结构图就很好解释了 IndexFile 的 Hash 结构:
IndexFile 包括 40B 的 Header 头信息,4*500w 的 Slot 信息,20*2000w 的 Index 信息,下面就来解释下这些信息。
首先是 40B 的 Header 头信息,这些信息是什么呢?那么在 RocketMQ 中我们可以回归源码,从 IndexHeader 中找找信息。
IndexHeader 是 IndexFile 的头信息,这个类里面有一个 load
方法就是负责加载 IndexFile 头的一些信息的,下面来看下这些源码。
public void load() {
this.beginTimestamp.set(byteBuffer.getLong(beginTimestampIndex));
this.endTimestamp.set(byteBuffer.getLong(endTimestampIndex));
this.beginPhyOffset.set(byteBuffer.getLong(beginPhyoffsetIndex));
this.endPhyOffset.set(byteBuffer.getLong(endPhyoffsetIndex));
this.hashSlotCount.set(byteBuffer.getInt(hashSlotcountIndex));
this.indexCount.set(byteBuffer.getInt(indexCountIndex));
if (this.indexCount.get() <= 0) {
this.indexCount.set(1);
}
}
我们来解释下这些信息:
- beginTimestamp(8 字节):IndexFile 中存储的第一条索引在 CommitLog 中的存储时间
- endTimestamp(8 字节):IndexFile 中存储的最后一条索引在 CommitLog 中的存储时间
- beginPhyOffset(8 字节):IndexFile 中存储的第一条索引对应的消息在 CommitLog 中的偏移量
- endPhyOffset(8 字节):IndexFile 中存储的最后一条索引对应的消息在 CommitLog 中的偏移量
- hashSlotCount(4 字节):IndexFile 中的 hash 槽个数,就是上面的 Slot Table 的长度
- indexCount(4 字节):索引条目个数。
第二部分是 slot hash 槽位,这部分数据不存储真实的数据,只是存储索引,这部分索引指向了每个槽位对应的单向链表的头结点。 注意:单向链表指向的地方是最新的消息。
第三部分是具体存储的链表节点数据了,存储的条目从上面图中也能看出来。
- keyHash(4 字节):当前 Message Key 的 Hash 值,在 RocketMQ 中,消息的 Key 是一个可选的属性,用于标识消息
- phyOffset(8 字节):IndexFile 中存储的消息在 CommitLog 中的偏移量
- timeDiff(4 字节):这条消息和 IndexFile 中第一条消息的时间差
- Next Index Offset(4 字节):该 IndexFile 节点指向的下一个节点,因为有槽位就可能会有冲突,当当前节点加入 slot 之后如果发生了冲突,那么这个节点需要插入到链表头结点,同时这个指指向原来的头结点
那么现在已经解析完这些数据了,那么 IndexFile 里面是如何存储的呢?
- 当前节点根据 key 的 hash 值算出要存储的 hash 槽位
- 获取这个槽位的节点值(Index 的编号)
- 如果发生了冲突,那么将当前节点的 Next Index Offset 设置为冲突的 Index 的节点,然后设置 hash 槽位的值为新的节点的(Index 编号)。
注意看上面箭头这里指向的地方,这里指向的是 Index 的条目编号,也就是在第三部分的编号。
那么为什么要存储这个编号呢?根据这个编号如何能快速获取到 Index 节点的信息呢?
上面说过了,IndexFile 分为三部分:IndexHeader(40B)+ 4*500w 的 Slot 信息 + 20*2000w 的 Index 信息。
现在有了编号,也就是说我们知道了这个节点在第三部分中是第几条信息,假设这个编号是 index,那么这个节点就可以通过:
int absIndexPos = IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize + index * indexSize
其中 indexSize = 20
,INDEX_HEADER_SIZE = 40,hashSlotNum 默认 500w,hashSlotSize = 4
,换句话说,前面的 IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize 就是一个常数,所以我们可以根据上面的式子快速定位到对应的 Index 节点。
6. MappedFileQueue
还记得上面这张图吗,这张图中给出了 ConsumeQueue 和 MappedFileQueue 的关系,ConsumeQueue 并不是最终存储文件消息的地方,同时 CommitLog 里面也不是最终存储文件消息的地方。
实际上 ConsumeQueue 和 CommitLog 里面都是使用 MappedFileQueue 来存储消息到文件,MappedFileQueue 是一个 MappedFile 队列,MappedFile 使用了 mmap 进行高效的映射,这里只是提一嘴,后面其他文章会展开说。
上面也说过了,对于 ComsumeQueue,一个文件大小是 5.72M,而对于 CommitLog,一个文件大小是 1G。那么在 ComsumeQueue 的构造器创建 MappedFileQueue 的时候,就传入了一个文件的大小:
this.mappedFileQueue = new MappedFileQueue(queueDir, mappedFileSize, null);
这个 mappedFileSize 是从 MessageStoreConfig 中拿到的:
public int getMappedFileSizeConsumeQueue() {
// factor = 300000 * 20 / 20 = 300000
int factor = (int) Math.ceil(this.mappedFileSizeConsumeQueue / (ConsumeQueue.CQ_STORE_UNIT_SIZE * 1.0));
// 300000 * 20 = 5.72M
return (int) (factor * ConsumeQueue.CQ_STORE_UNIT_SIZE);
}
这个方法默认求出来的就是 5.72M,但是其实 mappedFileSizeConsumeQueue 这个值是可以由我们自己设置的,就说到这吧。
而 CommitLog 中创建 ComsumeQueue 的时候传入的大小是 defaultMessageStore.getMessageStoreConfig().getMappedFileSizeCommitLog()
,也就是 1024 * 1024 * 1024
,所以对于 CommitLog,MappedFileQueue 中一个 MappedFile 的大小就是 1G。
7. 混合存储结构
还是回到最开始这个图,最后我们来总结下混合存储结构。在上面的RocketMQ 的消息存储整体架构图中可以看出,RocketMQ 采用的是混合型的存储结构,即为 Broker 单个实例下所有的队列共用一个日志数据文件(即为 CommitLog)来存储。
RocketMQ 的混合型存储结构(多个 Topic 的消息实体内容都存储于一个CommitLog 中)针对 Producer 和 Consumer 分别采用了数据和索引部分相分离的存储结构,Producer 发送消息至 Broker 端,然后 Broker 端使用同步或者异步的方式对消息刷盘持久化,保存至 CommitLog 中。
只要消息被刷盘持久化至磁盘文件 CommitLog ,那么 Produce发送的消息就不会丢失。正因为如此,Consumer 也就肯定有机会去消费这条消息。
当无法拉取到消息后,可以等下一次消息拉取,同时服务端也支持长轮询模式,如果一个消息拉取请求未拉取到消息,Broker 允许等待 30s 的时间,只要这段时间内有新消息到达,将直接返回给消费端。这里,RocketMQ 的具体做法是,使用 Broker 端的 后台服务线程—ReputMessageService
不停地分发请求并异步构建 ConsumeQueue(逻辑消费队列)和 IndexFile(索引文件)数据。
8. 小结
在这篇文章中我们讲解了 RocketMQ 的存储结构,同时也解析了 CommitLog
、ConsumeQueue
、IndexFile
这三个文件的存储结构,但是这些文件是如何高效存储和高效读取的,其实这背后就跟 MappedFile 有关,所以后面我们还会讲一下 MappedFile 后面的存储逻辑。
如有错误,欢迎指出 !!!!