RocketMQ 系列(四) 消息存储
RocketMQ 系列(四) 消息存储
本文是 RocketMQ 系列的第四篇,下面是前面几篇的文章,不清楚的话点击看一下吧。
RocketMQ 作为一款优秀的分布式消息中间件,可以为业务方提供高性能低延迟的稳定可靠的消息服务。其核心优势是可靠的消费存储、消息发送的高性能和低延迟、强大的消息堆积能力和消息处理能力。
从存储方式来看,主要有几个方面:
- 文件系统
- 分布式KV存储
- 关系型数据库
从效率上来讲,文件系统高于KV存储,KV存储又高于关系型数据库。因为直接操作文件系统肯定是最快的,那么业界主流的消息队列中间件,如RocketMQ 、RabbitMQ 、kafka
都是采用文件系统的方式来存储消息。
这篇文章我们来探索一下 RocketMQ 消息存储的机制。
1、整体架构
这里我们直接引用官方的流程图,如下:
![img](https://gitee.com/xiaoshengstudy/typoraPicture/raw/master/202308292218035.png)
消息存储架构图中主要有下面三个跟消息存储相关的文件构成:
CommitLog
:存储消息的元数据ConsumerQueue
:存储消息在CommitLog
的索引IndexFile
:为了消息查询提供了一种通过key
或时间区间来查询消息的方法,这种通过IndexFile
来查找消息的方法不影响发送与消费消息的主流程。
打开相关文件目录,可以找到对应的三个文件(我用的是 Linux 服务器,路径是 home/root/store/):
![image-20230829223539497](https://gitee.com/xiaoshengstudy/typoraPicture/raw/master/202308292236209.png)
接下来我们来逐一分析这 3 个消息存储密切相关的文件。
2、Commitlog
Commitlog
,消息存储文件,所有主题的消息都存储在 Commitlog
文件中。
所有的消息都会顺序写入 Commitlog
,当文件写满了,会写入下一个文件。
![image-20230829224735774](https://gitee.com/xiaoshengstudy/typoraPicture/raw/master/202308292247856.png)
如上图所示,单个文件大小默认 1G , 文件名长度为 20 位,左边补零,剩余为起始偏移量,比如 00000000000000000000 代表了第一个文件,起始偏移量为 0 ,文件大小为1 G = 1073741824。当第一个文件写满了,第二个文件为 00000000001073741824,起始偏移量为 1073741824,以此类推。
这里说一下为什么使用的是 1G ?——MMAP 使用时必须实现指定好内存映射的大小,mmap 在 Java 中一次只能映射 1.5~2G 的文件内存,其中 RocketMQ 中限制了单文件 1G 来避免这个问题。
2.1、Commitlog 结构
一台Broker
只有一个CommitLog
文件(组),RocketMQ
会将所有主题的消息存储在同一个文件中,这个文件中就存储着一条条 Message,每条 Message 都会按照顺序写入。
Commitlog 文件中的 Message 结构如下:
![1](https://gitee.com/xiaoshengstudy/typoraPicture/raw/master/202308292315768.png)
可以看出 Message 一共有 19个字段,每个字段的解释如下:
第几位 | 字段 | 字节数 | 数据类型 | 说明 |
---|---|---|---|---|
1 | MsgLen | 4 | Int | 消息总长度 |
2 | MagicCode | 4 | Int | MESSAGE_MAGIC_CODE |
3 | BodyCRC | 4 | Int | 消息内容CRC |
4 | QueueId | 4 | Int | 消息队列编号 |
5 | Flag | 4 | Int | flag |
6 | QueueOffset | 8 | Long | 消息队列位置 |
7 | PhysicalOffset | 8 | Long | 物理位置。在 CommitLog 的顺序存储位置。 |
8 | SysFlag | 4 | Int | MessageSysFlag |
9 | BornTimestamp | 8 | Long | 生成消息时间戳 |
10 | BornHost | 8 | Long | 生效消息的地址+端口 |
11 | StoreTimestamp | 8 | Long | 存储消息时间戳 |
12 | StoreHost | 8 | Long | 存储消息的地址+端口 |
13 | ReconsumeTimes | 4 | Int | 重新消费消息次数 |
14 | PreparedTransationOffset | 8 | Long | |
15 | BodyLength + Body | 4 + bodyLength | Int + Bytes | 内容长度 + 内容 |
16 | TopicLength + Topic | 1 + topicLength | Byte + Bytes | Topic长度 + Topic |
17 | PropertiesLength + Properties | 2 + PropertiesLength | Short + Bytes | 拓展字段长度 + 拓展字段 |
2.2、顺序写
上面Message
写入 到磁盘的 Commitlog 采用顺序写的方式,保证了消息存储的速度。
磁盘如果使用得当,磁盘的速度完全可以匹配上网络的数据传输速度。目前的高性能磁盘,顺序写速度可以达到 600MB/s
, 超过了一般网卡的传输速度。但是磁盘随机写的速度只有大概 100KB/s
,和顺序写的性能相差 6000 倍!因为有如此巨大的速度差别,好的消息队列系统会比普通的消息队列系统速度快多个数量级。
下图是随机和顺序读写在内存和磁盘中的表现:
2.3、Commitlog 解析
2.3.1、读取文件内容
由于 commitlog 是一个二进制文件,我们没有办法直接读取其内容,所以需要通过程序读取它的字节数组:
public static ByteBuffer read(String path)throws Exception{
File file = new File(path);
FileInputStream fin = new FileInputStream(file);
byte[] bytes = new byte[(int)file.length()];
fin.read(bytes);
ByteBuffer buffer = ByteBuffer.wrap(bytes);
return buffer;
}
如上代码,可以通过传入文件的路径,读取该文件所有的内容。为了方便下一步操作,我们把读取到的字节数组转换为java.nio.ByteBuffer
对象。
2.3.2、解析
在解析之前,我们需要弄明白两件事:
- Message 的格式,即一条消息包含哪些字段;
- 每个字段所占的字节大小。
在上面的图中,我们已经看到了消息的格式,包含了19个字段。关于字节大小,有的是 4 字节,有的是 8 字节,我们不再一一赘述,直接看代码。
/**
* commitlog 文件解析
* @param byteBuffer
* @return
* @throws Exception
*/
public static MessageExt decodeCommitLog(ByteBuffer byteBuffer)throws Exception {
MessageExt msgExt = new MessageExt();
// 1 TOTALSIZE
int storeSize = byteBuffer.getInt();
msgExt.setStoreSize(storeSize);
if (storeSize<=0){
return null;
}
// 2 MAGICCODE
byteBuffer.getInt();
// 3 BODYCRC
int bodyCRC = byteBuffer.getInt();
msgExt.setBodyCRC(bodyCRC);
// 4 QUEUEID
int queueId = byteBuffer.getInt();
msgExt.setQueueId(queueId);
// 5 FLAG
int flag = byteBuffer.getInt();
msgExt.setFlag(flag);
// 6 QUEUEOFFSET
long queueOffset = byteBuffer.getLong();
msgExt.setQueueOffset(queueOffset);
// 7 PHYSICALOFFSET
long physicOffset = byteBuffer.getLong();
msgExt.setCommitLogOffset(physicOffset);
// 8 SYSFLAG
int sysFlag = byteBuffer.getInt();
msgExt.setSysFlag(sysFlag);
// 9 BORNTIMESTAMP
long bornTimeStamp = byteBuffer.getLong();
msgExt.setBornTimestamp(bornTimeStamp);
// 10 BORNHOST
int bornhostIPLength = (sysFlag & MessageSysFlag.BORNHOST_V6_FLAG) == 0 ? 4 : 16;
byte[] bornHost = new byte[bornhostIPLength];
byteBuffer.get(bornHost, 0, bornhostIPLength);
int port = byteBuffer.getInt();
msgExt.setBornHost(new InetSocketAddress(InetAddress.getByAddress(bornHost), port));
// 11 STORETIMESTAMP
long storeTimestamp = byteBuffer.getLong();
msgExt.setStoreTimestamp(storeTimestamp);
// 12 STOREHOST
int storehostIPLength = (sysFlag & MessageSysFlag.STOREHOSTADDRESS_V6_FLAG) == 0 ? 4 : 16;
byte[] storeHost = new byte[storehostIPLength];
byteBuffer.get(storeHost, 0, storehostIPLength);
port = byteBuffer.getInt();
msgExt.setStoreHost(new InetSocketAddress(InetAddress.getByAddress(storeHost), port));
// 13 RECONSUMETIMES
int reconsumeTimes = byteBuffer.getInt();
msgExt.setReconsumeTimes(reconsumeTimes);
// 14 Prepared Transaction Offset
long preparedTransactionOffset = byteBuffer.getLong();
msgExt.setPreparedTransactionOffset(preparedTransactionOffset);
// 15 BODY
int bodyLen = byteBuffer.getInt();
if (bodyLen > 0) {
byte[] body = new byte[bodyLen];
byteBuffer.get(body);
msgExt.setBody(body);
}
// 16 TOPIC
byte topicLen = byteBuffer.get();
byte[] topic = new byte[(int) topicLen];
byteBuffer.get(topic);
msgExt.setTopic(new String(topic, CHARSET_UTF8));
// 17 properties
short propertiesLength = byteBuffer.getShort();
if (propertiesLength > 0) {
byte[] properties = new byte[propertiesLength];
byteBuffer.get(properties);
String propertiesString = new String(properties, CHARSET_UTF8);
Map<String, String> map = string2messageProperties(propertiesString);
}
int msgIDLength = storehostIPLength + 4 + 8;
ByteBuffer byteBufferMsgId = ByteBuffer.allocate(msgIDLength);
String msgId = createMessageId(byteBufferMsgId, msgExt.getStoreHostBytes(), msgExt.getCommitLogOffset());
msgExt.setMsgId(msgId);
return msgExt;
}
解析方法写完之后,开始输出 commitlog 内容。
2.3.3、输出 commitlog 内容
这里将 Linux 服务器上的 commitlog 文件拷贝到本地进行测试:
public static void main(String[] args) throws Exception {
String filePath = "C:\\Users\\Administrator\\Desktop\\00000000000000000000";
ByteBuffer buffer = read(filePath);
List<MessageExt> messageList = new ArrayList<>();
while (true){
MessageExt message = decodeCommitLog(buffer);
if (message==null){
break;
}
messageList.add(message);
}
for (MessageExt ms:messageList) {
System.out.println("主题:"+ms.getTopic()+" 消息:"+
new String(ms.getBody())+" 队列ID:"+ms.getQueueId()+" 存储地址:"+ms.getStoreHost());
}
}
控制台成功输出解析后的内容:
主题:topicClean 消息:syncMessage 队列ID:0 存储地址:/192.168.0.17:10911
3、ConsumeQueue
在了解 ConsumeQueue之前,有必要先了解 MessageQueue 的概念。
3.1、MessageQueue
我们知道,在发送消息的时候,要指定一个 Topic。那么,在创建 Topic 的时候,有一个很重要的参数MessageQueue
。简单来说,就是你这个Topic对应了多少个队列,也就是几个MessageQueue
,默认是4个。那它的作用是什么呢 ?
假设我们的 Topic 里面有 100 条数据,该 Topic 默认是 4 个MessageQueue
,那么每个MessageQueue
中大约 25 条数据。 然后,这些MessageQueue
是和Broker
绑定在一起的,就是说每个MessageQueue
都可能处于不同的Broker
机器上,这取决于你的队列数量和Broker集群。
通过 RocketMQ 控制台可以看到 Topic 下的 MessageQueue
情况:
能够看到 Topic 为 topicClean 的主题下,一共有四个 MessageQueue, 由于我的 Broker 只有一个,所以 brokerName 都是一样的。
既然MessageQueue
是多个,那么在消息发送的时候,势必要通过某种方式选择一个队列。默认的情况下,就是通过轮询来获取一个消息队列。
查看 RocketMQ 源码,找到方法如下:
public MessageQueue selectOneMessageQueue() {
int index = this.sendWhichQueue.incrementAndGet();
int pos = Math.abs(index) % this.messageQueueList.size();
if (pos < 0)
pos = 0;
return this.messageQueueList.get(pos);
}
3.2、ConsumeQueue 结构
介绍完了 MessageQueue
,那么它跟 ConsumeQueue
有什么关系呢?而 ConsumeQueue
又是什么?
与 MessageQueue关系
ConsumeQueue
是一组组文件,而一个 MessageQueue 对应其中一组 ConsumeQueue 文件,主要的作用是记录当前 MessageQueue 被哪些消费者组消费到了 Commitlog 中哪一条消息。
ConsumeQueue
目录下面是以 Topic 命名的文件夹,然后再下一级是以MessageQueue
队列ID命名的文件夹,最后才是一个或多个 ConsumeQueue
文件:
![image-20230830230953969](https://gitee.com/xiaoshengstudy/typoraPicture/raw/master/202308302309080.png)
具体作用
ConsumeQueue文件的结构如下:
![2](https://gitee.com/xiaoshengstudy/typoraPicture/raw/master/202308302337546.png)
ConsumeQueue 文件存储了CommitLog 文件中的偏移量
、消息长度
、消息 Tag 的 hashcode 值
。可以看到 ConsumeQueue 文件不存储消息的内容,它的定位是基于 Topic 的 CommitLog 索引文件!
ConsumeQueue 包含一个 MappedFileQueue 结构,而 MappedFileQueue 结构由多个 MappedFile 文件组成。每个 ConsumeQueue 包含 30 万个条目,每个条目大小是 20 个字节,每个文件的大小是 30 万 * 20 = 60万字节,每个文件大小约5.72M 。和 Commitlog 文件类似,ConsumeQueue 文件的名称也是以偏移量来命名的,可以通过消息的逻辑偏移量定位消息位于哪一个文件里。
消费文件按照主题-队列来保存 ,这种方式特别适配发布订阅模型。
消费者从 broker 获取订阅消息数据时,不用遍历整个 commitlog 文件,只需要根据逻辑偏移量从 Consumequeue 文件查询消息偏移量 , 最后通过定位到 commitlog 文件, 获取真正的消息数据。
这样就可以简化消费查询逻辑,同时因为同一主题下,消费者可以订阅不同的队列或者 tag ,同时提高了系统的可扩展性。
3.3、查询消息
同样的,我们按照上面的格式输出一下ConsumerQueue
文件的内容:
/**
* 根据路径读取文件
*
* @param path
* @return
* @throws Exception
*/
public static ByteBuffer read(String path)throws Exception{
File file = new File(path);
FileInputStream fin = new FileInputStream(file);
byte[] bytes = new byte[(int)file.length()];
fin.read(bytes);
ByteBuffer buffer = ByteBuffer.wrap(bytes);
return buffer;
}
public static void main(String[] args)throws Exception {
String path = "D:\\software\\IdeaProjects\\cloud\\rockemq-producer\\src\\file\\comsumeQueue\\00000000000000000000";
ByteBuffer buffer = read(path);
while (true){
long offset = buffer.getLong();
long size = buffer.getInt();
long code = buffer.getLong();
if (size==0){
break;
}
System.out.println("消息长度:"+size+" 消息偏移量:" +offset);
}
System.out.println("--------------------------");
}
我已经向topicClean
这个主题中写了 20 条数据,所以在这里它的 topicClean#messagequeue#0
里面有 5 条记录。(一个 Topic 默认有四个 messagequeue)
控制台输出结果如下:
消息长度:291 消息偏移量:24204
消息长度:291 消息偏移量:25368
消息长度:292 消息偏移量:26532
消息长度:292 消息偏移量:27700
消息长度:292 消息偏移量:28868
可以发现,上面输出的结果中,消息偏移量的差值等于 = 消息长度 * 队列长度。
现在我们通过ConsumerQueue
已经知道了消息的长度和偏移量,那么查找消息就比较容易了。
编写读取commitlog
文件中消息内容的方法:
/**
* 根据消息偏移量和消息长度,在commitlog文件中读取消息内容
*
* @param commitLog
* @param offset
* @param size
* @return
* @throws Exception
*/
public static MessageExt getMessageByOffset(ByteBuffer commitLog, long offset, int size) throws Exception {
ByteBuffer slice = commitLog.slice();
slice.position((int)offset);
slice.limit((int) (offset+size));
MessageExt message = DecodeCommitLogTools.decodeCommitLog(slice);
return message;
}
调用上述方法,来实现通过ConsumeQueue
读取到Commitlog
消息:
public static void main(String[] args) throws Exception {
//consumerqueue根目录
String consumerPath = "D:\\software\\IdeaProjects\\cloud\\rockemq-producer\\src\\file\\comsumeQueue";
//commitlog目录
String commitLogPath = "D:\\software\\IdeaProjects\\cloud\\rockemq-producer\\src\\file\\commitlog\\00000000000000000000";
//读取commitlog文件内容
ByteBuffer commitLogBuffer = DecodeCommitLogTools.read(commitLogPath);
//遍历consumerqueue目录下的所有文件
File file = new File(consumerPath);
File[] files = file.listFiles();
for (File f : files) {
if (f.isDirectory()) {
File[] listFiles = f.listFiles();
for (File queuePath : listFiles) {
String path = queuePath + "/00000000000000000000";
//读取consumerqueue文件内容
ByteBuffer buffer = DecodeCommitLogTools.read(path);
while (true) {
//读取消息偏移量和消息长度
long offset = (int) buffer.getLong();
int size = buffer.getInt();
long code = buffer.getLong();
if (size == 0) {
break;
}
//根据偏移量和消息长度,在commitloh文件中读取消息内容
MessageExt message = getMessageByOffset(commitLogBuffer, offset, size);
if (message != null) {
System.out.println("消息主题:" + message.getTopic() + " MessageQueue:" +
message.getQueueId() + " 消息体:" + new String(message.getBody()));
}
}
}
}
}
}
运行代码,可以得到之前测试发送的 20 条数据:
消息主题:topicClean MessageQueue:0 消息体:syncMessage2
消息主题:topicClean MessageQueue:0 消息体:syncMessage6
消息主题:topicClean MessageQueue:0 消息体:syncMessage10
消息主题:topicClean MessageQueue:0 消息体:syncMessage14
消息主题:topicClean MessageQueue:0 消息体:syncMessage18
消息主题:topicClean MessageQueue:1 消息体:syncMessage3
消息主题:topicClean MessageQueue:1 消息体:syncMessage7
消息主题:topicClean MessageQueue:1 消息体:syncMessage11
消息主题:topicClean MessageQueue:1 消息体:syncMessage15
消息主题:topicClean MessageQueue:1 消息体:syncMessage19
消息主题:topicClean MessageQueue:2 消息体:syncMessage0
消息主题:topicClean MessageQueue:2 消息体:syncMessage4
消息主题:topicClean MessageQueue:2 消息体:syncMessage8
消息主题:topicClean MessageQueue:2 消息体:syncMessage12
消息主题:topicClean MessageQueue:2 消息体:syncMessage16
消息主题:topicClean MessageQueue:3 消息体:syncMessage1
消息主题:topicClean MessageQueue:3 消息体:syncMessage5
消息主题:topicClean MessageQueue:3 消息体:syncMessage9
消息主题:topicClean MessageQueue:3 消息体:syncMessage13
消息主题:topicClean MessageQueue:3 消息体:syncMessage17
3.4、消费消息
消息消费的时候,其查找消息的过程也是差不多的。不过值得注意的一点是,ConsumerQueue
文件和CommitLog
文件可能都是多个的,所以会有一个定位文件的过程。
首先,根据消费进度来查找对应的ConsumerQueue
,获取其文件内容:
public SelectMappedBufferResult getIndexBuffer(final long startIndex) {
//ConsumerQueue文件大小
int mappedFileSize = this.mappedFileSize;
//根据消费进度,找到ConsumerQueue文件里的偏移量
long offset = startIndex * CQ_STORE_UNIT_SIZE;
if (offset >= this.getMinLogicOffset()) {
//返回ConsumerQueue映射文件
MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset);
if (mappedFile != null) {
//返回文件里的某一块内容
return mappedFile.selectMappedBuffer((int) (offset % mappedFileSize));
}
}
return null;
}
然后拿到消息在CommitLog
文件中的偏移量和消息长度,获取消息:
public SelectMappedBufferResult getMessage(final long offset, final int size) {
//commitlog文件大小
int mappedFileSize = this.defaultMessageStore.getMessageStoreConfig().getMappedFileSizeCommitLog();
//根据消息偏移量,定位到具体的commitlog文件
MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset, offset == 0);
if (mappedFile != null) {
int pos = (int) (offset % mappedFileSize);
//根据消息偏移量和长度,获取消息内容
return mappedFile.selectMappedBuffer(pos, size);
}
return null;
}
3.5、Message Id 查询
上面我们看到了通过消息偏移量来查找消息的方式,但RocketMQ
还提供了其他几种方式可以查询消息。
- 通过Message Key 查询
- 通过Unique Key查询
- 通过Message Id查询。
在这里,Message Key和Unique Key
都是在消息发送之前由客户端生成的。我们可以自己设置,也可以由客户端自动生成,Message Id
是在Broker
端存储消息的时候生成。
Message Id
总共 16 字节,包含消息存储主机地址和在CommitLog
文件中的偏移量 offset:
public static String createMessageId(final ByteBuffer input, final ByteBuffer addr, final long offset) {
input.flip();
int msgIDLength = addr.limit() == 8 ? 16 : 28;
input.limit(msgIDLength);
//Broker地址和消息偏移量
input.put(addr);
input.putLong(offset);
return UtilAll.bytes2string(input.array());
}
当我们根据Message Id
向Broker查询消息时,首先会通过一个decodeMessageId
方法,将 Broker 地址和消息的偏移量解析出来:
public static MessageId decodeMessageId(final String msgId) throws UnknownHostException {
byte[] bytes = UtilAll.string2bytes(msgId);
ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
// address(ip+port)
byte[] ip = new byte[msgId.length() == 32 ? 4 : 16];
byteBuffer.get(ip);
int port = byteBuffer.getInt();
SocketAddress address = new InetSocketAddress(InetAddress.getByAddress(ip), port);
// offset
long offset = byteBuffer.getLong();
return new MessageId(address, offset);
}
所以通过Message Id
查询消息的时候,实际上还是直接从特定 Broker 上的CommitLog
指定位置进行查询,属于精确查询。
4、Index
从存储图看到还有一个 Index 和 CommitLog 也有关系。
Index 文件的主要作用就是用来根据 Message Key 和 Unique Key 查找对应的消息。
Index 文件的存储位置是:$HOME \store\index${fileName},文件名 fileName 是以创建时的时间戳命名的,固定的单个 IndexFile 文件大小约为400M,一个Index 文件 可以保存 2000W 个索引,IndexFile 的底层存储设计为在文件系统中实现 HashMap 结构,故 RocketMQ 的索引文件其底层实现为 hash 索引。
4.1、Index 结构
Index 文件结构如下所示:
从图中可以看出,Index 文件分为如下 3 部分,IndexHead,Hash 槽,Index 条目。
IndexHead 的格式如下
字段 | 解释 |
---|---|
beginTimestamp | 消息的最小存储时间 |
endTimestamp | 消息的最大存储时间 |
beginPhyOffset | 消息的最小偏移量(commitLog 文件中的偏移量) |
endPhyOffset | 消息的最大偏移量(commitLog 文件中的偏移量) |
hashSlotCount | hash 槽个数 |
indexCount | index 条目当前已使用的个数 |
Hash 槽存储的内容为落在该 Hash 槽内的 Index 的索引(看后面图示你就会很清楚了)
每个 Index 条目的格式如下
字段 | 解释 |
---|---|
hashcode | key 的hashcode |
phyoffset | 消息的偏移量(commitLog文件中的偏移量) |
timedif | 该消息存储时间与第一条消息的时间戳的差值,小于0该消息无效 |
pre index no | 该条目的前一条记录的Index索引,当hash冲突时,用来构建链表 |
key 的组成有如下两种形式
- Topic#Unique Key
- Topic#Message Key
Unique Key 是在 Producer 端发送消息生成的
// DefaultMQProducerImpl#sendKernelImpl
if (!(msg instanceof MessageBatch)) {
MessageClientIDSetter.setUniqID(msg);
}
Message Key 是我们在发送消息的时候设置的,通常具有业务意义,方便我们快速查找消息
/**
* 发送 message key 消息
*/
@RequestMapping("/containKeySend")
public void containKeySend() {
//指定Topic与Tag,格式: `topicName:tags`
SendResult sendResult = rocketMQTemplate.syncSend("topicClean:tagTest",
MessageBuilder
.withPayload("this is message")
// key:可通过key查询消息轨迹,如消息被谁消费,定位消息丢失问题。由于是哈希索引,须保证key尽可能唯一
.setHeader(MessageConst.PROPERTY_KEYS, "123456").build());
System.out.println("发送延时消息:" + sendResult.toString());
}
Index 文件构成过程比较麻烦,你可以把 Index文件 想成基于文件实现的 HashMap。
假如说往数组长度为 10 的HashMap依次放入3个 key 为11,34,21 的数据(以尾插法演示了),HashMap 的结构如下:
将key为11,34,21的数据放到IndexFile中的过程如下(假如hash槽的数量为10)
具体的过程为
- 将消息顺序放到 Index 条目中,将 11 放到 index=1 的位置(用index[1]表示),11%1=1,算出 hash 槽的位置是 1,存的值是 0(刚开始都是0,用hash[0]表示),将 index[1].preIndexNo=hash[0]=0,hash[0]=1(1 为 index 数组下标)
- 将 34 放到 index[2],34%10=4,index[2].preIndexNo=hash[0]=0
- 将 21 放到 index[3],21%10=1,index[3].preIndexNo=hash[1]=1
从图中可以看出来,当发生 hash 冲突时 Index 条目的 preIndexNo 属性充当了链表的作用。查找的过程和 HashMap 基本类似,先定位到槽的位置,然后顺着链表找就行了。
4.2、解析
为了便于理解,我们还是以代码的方式,来解析这个文件。
public static void main(String[] args) throws Exception {
//index索引文件的路径
String path = "D:\\software\\IdeaProjects\\cloud\\rockemq-producer\\src\\file\\index\\20230823171341863";
ByteBuffer buffer = DecodeCommitLogTools.read(path);
//该索引文件中包含消息的最小存储时间
long beginTimestamp = buffer.getLong();
//该索引文件中包含消息的最大存储时间
long endTimestamp = buffer.getLong();
//该索引文件中包含消息的最大物理偏移量(commitlog文件偏移量)
long beginPhyOffset = buffer.getLong();
//该索引文件中包含消息的最大物理偏移量(commitlog文件偏移量)
long endPhyOffset = buffer.getLong();
//hashslot个数
int hashSlotCount = buffer.getInt();
//Index条目列表当前已使用的个数
int indexCount = buffer.getInt();
//500万个hash槽,每个槽占4个字节,存储的是index索引
for (int i=0;i<5000000;i++){
buffer.getInt();
}
//2000万个index条目
for (int j=0;j<20000000;j++){
//消息key的hashcode
int hashcode = buffer.getInt();
//消息对应的偏移量
long offset = buffer.getLong();
//消息存储时间和第一条消息的差值
int timedif = buffer.getInt();
//该条目的上一条记录的index索引
int pre_no = buffer.getInt();
}
System.out.println(buffer.position()==buffer.capacity());
}
我们看最后输出的结果为 true,则证明解析的过程无误。
4.3、构建索引
我们发送的消息体中,包含Message Key 或 Unique Key
,那么就会给它们每一个都构建索引。
这里重点有两个:
- 根据消息 Key 计算 Hash 槽的位置;
- 根据 Hash 槽的数量和 Index 索引来计算 Index 条目的起始位置。
将当前 Index条目 的索引值,写在Hash槽absSlotPos
位置上;将Index条目的具体信息(hashcode/消息偏移量/时间差值/hash槽的值)
,从起始偏移量absIndexPos
开始,顺序按字节写入。源码如下:
public boolean putKey(final String key, final long phyOffset, final long storeTimestamp) {
if (this.indexHeader.getIndexCount() < this.indexNum) {
//计算key的hash
int keyHash = indexKeyHashMethod(key);
//计算hash槽的坐标
int slotPos = keyHash % this.hashSlotNum;
int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;
try {
int slotValue = this.mappedByteBuffer.getInt(absSlotPos);
if (slotValue <= invalidIndex || slotValue > this.indexHeader.getIndexCount()) {
slotValue = invalidIndex;
}
//计算时间差值
long timeDiff = storeTimestamp - this.indexHeader.getBeginTimestamp();
timeDiff = timeDiff / 1000;
if (this.indexHeader.getBeginTimestamp() <= 0) {
timeDiff = 0;
} else if (timeDiff > Integer.MAX_VALUE) {
timeDiff = Integer.MAX_VALUE;
} else if (timeDiff < 0) {
timeDiff = 0;
}
//计算INDEX条目的起始偏移量
int absIndexPos =
IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
+ this.indexHeader.getIndexCount() * indexSize;
//依次写入hashcode、消息偏移量、时间戳、hash槽的值
this.mappedByteBuffer.putInt(absIndexPos, keyHash);
this.mappedByteBuffer.putLong(absIndexPos + 4, phyOffset);
this.mappedByteBuffer.putInt(absIndexPos + 4 + 8, (int) timeDiff);
this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue);
//将当前INDEX中包含的条目数量写入HASH槽
this.mappedByteBuffer.putInt(absSlotPos, this.indexHeader.getIndexCount());
if (this.indexHeader.getIndexCount() <= 1) {
this.indexHeader.setBeginPhyOffset(phyOffset);
this.indexHeader.setBeginTimestamp(storeTimestamp);
}
if (invalidIndex == slotValue) {
this.indexHeader.incHashSlotCount();
}
this.indexHeader.incIndexCount();
this.indexHeader.setEndPhyOffset(phyOffset);
this.indexHeader.setEndTimestamp(storeTimestamp);
return true;
} catch (Exception e) {
log.error("putKey exception, Key: " + key + " KeyHashCode: " + key.hashCode(), e);
}
} else {
log.warn("Over index file capacity: index count = " + this.indexHeader.getIndexCount()
+ "; index max num = " + this.indexNum);
}
return false;
}
这样构建完 Index 索引之后,根据Message Key 或 Unique Key
查询消息就简单了。
5、刷盘机制
RocketMQ
的消息是存储到磁盘上的,这样既能保证断电后恢复, 又可以让存储的消息量超出内存的限制。RocketMQ
为了提高性能,会尽可能地保证磁盘的顺序写。消息在通过 Producer 写入 RocketMQ 的时候,有两种写磁盘方式,分别为同步刷盘和异步刷盘。
![e29a44ed868e4978a8bb3db3a7be0eca.png](https://gitee.com/xiaoshengstudy/typoraPicture/raw/master/202309012353596.png)
- 同步刷盘:在返回写成功状态时,消息已经被写入磁盘。具体流程是,消息写入内存的
PAGECACHE
后,立刻通知刷盘线程刷盘,然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,返回消息写成功的状态。- 优点:可以保持MQ的消息状态和生产者/消费者的消息状态一致
- 缺点:性能比异步的低
- 异步刷盘:在返回写成功状态时,消息可能只是被写入了内存的
PAGECACHE
,写操作的返回快,吞吐量大;当内存里的消息量积累到一定程度时,统一触发写磁盘动作,快速写入。- 优点:性能高。
- 缺点:Master 宕机,磁盘损坏的情况下,会丢失少量的消息, 导致MQ的消息状态和生产者/消费者的消息状态不一致。
同步刷盘和异步刷盘,都是通过 Broker
配置文件里的 flushDiskType
参数设置的,把这个参数被配置成 SYNC_FLUSH
(同步)、ASYNC_FLUSH
(异步)中的一个。
到这里 RocketMQ 消息存储的几个主要文件 Commitlog、ConsumeQueue、Index 都一一讲解完毕,然后简略带过刷盘机制, 如果你对消息存储感兴趣的话最好自己拉下源码研究一番加深印象,又到了睡大觉的时间点了。
参考资料:
- https://juejin.cn/post/6844904149725741064
- https://www.51cto.com/article/743692.html
- https://zhuanlan.zhihu.com/p/452550582
- https://juejin.cn/post/6989542586050412580
- https://help.aliyun.com/zh/apsaramq-for-rocketmq/cloud-message-queue-rocketmq-5-x-series/?spm=a2c4g.11186623.0.0.7dfd68aaXBznWB
- https://www.6aiq.com/article/1563130737288
- https://juejin.cn/post/6989542586050412580#heading-69
- https://www.javashitang.com/md/rocketmq/RocketMQ是如何存储消息的.html