RocketMQ 系列(四) 消息存储

RocketMQ 系列(四) 消息存储

本文是 RocketMQ 系列的第四篇,下面是前面几篇的文章,不清楚的话点击看一下吧。

RocketMQ 作为一款优秀的分布式消息中间件,可以为业务方提供高性能低延迟的稳定可靠的消息服务。其核心优势是可靠的消费存储、消息发送的高性能和低延迟、强大的消息堆积能力和消息处理能力。

从存储方式来看,主要有几个方面:

  • 文件系统
  • 分布式KV存储
  • 关系型数据库

从效率上来讲,文件系统高于KV存储,KV存储又高于关系型数据库。因为直接操作文件系统肯定是最快的,那么业界主流的消息队列中间件,如RocketMQ 、RabbitMQ 、kafka 都是采用文件系统的方式来存储消息。

这篇文章我们来探索一下 RocketMQ 消息存储的机制。

1、整体架构

这里我们直接引用官方的流程图,如下:

消息存储架构图中主要有下面三个跟消息存储相关的文件构成:

  • CommitLog:存储消息的元数据
  • ConsumerQueue:存储消息在 CommitLog 的索引
  • IndexFile:为了消息查询提供了一种通过 key 或时间区间来查询消息的方法,这种通过 IndexFile 来查找消息的方法不影响发送与消费消息的主流程。

打开相关文件目录,可以找到对应的三个文件(我用的是 Linux 服务器,路径是 home/root/store/):

接下来我们来逐一分析这 3 个消息存储密切相关的文件。

2、Commitlog

Commitlog,消息存储文件,所有主题的消息都存储在 Commitlog 文件中。

所有的消息都会顺序写入 Commitlog,当文件写满了,会写入下一个文件

如上图所示,单个文件大小默认 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 结构如下:

可以看出 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 是一个二进制文件,我们没有办法直接读取其内容,所以需要通过程序读取它的字节数组:

java 复制代码
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 字节,我们不再一一赘述,直接看代码。

java 复制代码
/**
     * 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 文件拷贝到本地进行测试:

java 复制代码
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());
    }
}

控制台成功输出解析后的内容:

java 复制代码
主题: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 源码,找到方法如下:

java 复制代码
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文件:

具体作用

ConsumeQueue文件的结构如下:

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文件的内容:

java 复制代码
/**
     * 根据路径读取文件
     *
     * @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)

控制台输出结果如下:

java 复制代码
消息长度:291 消息偏移量:24204
消息长度:291 消息偏移量:25368
消息长度:292 消息偏移量:26532
消息长度:292 消息偏移量:27700
消息长度:292 消息偏移量:28868

可以发现,上面输出的结果中,消息偏移量的差值等于 = 消息长度 * 队列长度。

现在我们通过ConsumerQueue已经知道了消息的长度和偏移量,那么查找消息就比较容易了。

编写读取commitlog文件中消息内容的方法:

java 复制代码
/**
     * 根据消息偏移量和消息长度,在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消息:

java 复制代码
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 条数据:

java 复制代码
消息主题: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,获取其文件内容:

java 复制代码
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文件中的偏移量和消息长度,获取消息:

java 复制代码
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:

java 复制代码
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 地址和消息的偏移量解析出来:

java 复制代码
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 的组成有如下两种形式

  1. Topic#Unique Key
  2. Topic#Message Key

Unique Key 是在 Producer 端发送消息生成的

java 复制代码
// DefaultMQProducerImpl#sendKernelImpl
if (!(msg instanceof MessageBatch)) {
    MessageClientIDSetter.setUniqID(msg);
}

Message Key 是我们在发送消息的时候设置的,通常具有业务意义,方便我们快速查找消息

java 复制代码
/**
     * 发送 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

具体的过程为

  1. 将消息顺序放到 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 数组下标)
  2. 将 34 放到 index[2],34%10=4,index[2].preIndexNo=hash[0]=0
  3. 将 21 放到 index[3],21%10=1,index[3].preIndexNo=hash[1]=1

从图中可以看出来,当发生 hash 冲突时 Index 条目的 preIndexNo 属性充当了链表的作用。查找的过程和 HashMap 基本类似,先定位到槽的位置,然后顺着链表找就行了。

4.2、解析

为了便于理解,我们还是以代码的方式,来解析这个文件。

java 复制代码
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开始,顺序按字节写入。源码如下:

java 复制代码
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 的时候,有两种写磁盘方式,分别为同步刷盘和异步刷盘。

  1. 同步刷盘 :在返回写成功状态时,消息已经被写入磁盘。具体流程是,消息写入内存的 PAGECACHE 后,立刻通知刷盘线程刷盘,然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,返回消息写成功的状态。
    • 优点:性能高。
    • 缺点:Master 宕机,磁盘损坏的情况下,会丢失少量的消息, 导致MQ的消息状态和生产者/消费者的消息状态不一致。
  2. 异步刷盘 :在返回写成功状态时,消息可能只是被写入了内存的 PAGECACHE,写操作的返回快,吞吐量大;当内存里的消息量积累到一定程度时,统一触发写磁盘动作,快速写入。
    • 优点:可以保持MQ的消息状态和生产者/消费者的消息状态一致
    • 缺点:性能比异步的低

同步刷盘和异步刷盘,都是通过 Broker 配置文件里的 flushDiskType 参数设置的,把这个参数被配置成 SYNC_FLUSH(同步)、ASYNC_FLUSH (异步)中的一个。

到这里 RocketMQ 消息存储的几个主要文件 Commitlog、ConsumeQueue、Index 都一一讲解完毕,然后简略带过刷盘机制, 如果你对消息存储感兴趣的话最好自己拉下源码研究一番加深印象,又到了睡大觉的时间点了。

参考资料: