rocketmq_design_1.png
上面是从官网上找的一张图,producer发送消息后,由broker生成CommitLog与ConsumerQueue文件,然后consumer根据ConsumerQueue里获取消息在commitLog里的起始物理地址+message size后,拿到消息进行消费。
下面分别介绍下CommitLog,ConsumerQueue与IndexFile这三个文件。

一,CommitLog文件

存储Producer端写入的消息主体内容,消息内容不是定长的。单个文件大小默认1G ,文件名长度为20位,左边补零,剩余为起始偏移量,这里的偏移量也就是文件大小的字节表示,这样的好处是只要我们知道了消息的偏移量就能很快知道该消息在哪个文件里了。假设某消息物理偏移量是1073741830,则相对的偏移量是6(6 = 1073741830 - 1073741824),于是判断出该消息位于第二个commitLog文件上。
image.png
它有下面几个特性:

  • 顺序写入,一个文件写满再写下一个
  • 刷盘前会有一个mappedFile内存映射文件,消息是先写入到这个内存映射文件中,然后根据刷盘策略写到硬盘中,最后进行主从同步
  • header+body格式,其中header是定长的,记录了消息的长度
  • 读取消息时先解析出header,从中获取到消息长度,接着读取消息体

CommitLog文件生成的相关代码在CommitLog.handleDiskFlush()和一个DefaultMessageStore.start()里
具体可参考后一篇文章:RocketMQ学习十二-消息刷盘

二,ConsumerQueue文件

在介绍ConsumerQueue文件之前需要先提一下消费进度,它是Broker管理每个一个消费者消费topic的进度。这个进度有可能是正常消费后产生的进度,也可能是重置的消费进度,这两种情形下,消费者都会上报进度然后Broker进行记录。之所以要管理消费进度是为了确保消费者在正常状态,重启,异常关闭情况下能准确的接着上一次消费的进度进行消费,也就是确保消息可以‘至少消费一次’,所以消费者需要有幂等措施确保不会重复消费,这个后续再提。

再回到ConsumerQueue文件上,这个文件里记录着某个消息投递到某个队列里的位置信息,我们知道消息是存在CommitLog文件里的,但必须要先获取消息的偏移量然后再根据偏移量去CommitLog里进行查询,而消息的偏移量是记录在ConsumerQueue文件里的,也可以这样理解:ConsumerQueue是CommitLog的一个索引文件。

另外,这里提一下顺序写,Kafka 在分区级别实现文件顺序写,所以随着topic、分区变大时会对性能有一定影响;而RocketMQ不分主题一律顺序写入commitlog 文件,所以性能比较稳定。这里有个问题,既然RocmetMQ里的commitlog没有区分队列,那消息是如何定位的呢?答案是在comsummerQueue里,comsummerQueue是按队列组织的:/topic/{queue}
这里参考:性能之道:RocketMQ与Kafka高性能设计对比

在Broker中,构建ComsummerQueue不是存储完CommitLog就马上同步构建的,而是通过一个线程任务异步的去做这个事情。在DefaultMessageStore中有一个ReputMessageService成员,它就是负责构建ComsumerQueue的任务线程。

ConsumerQueue是按照topic维度存储的,每个topic默认4个队列,里面存放的consumequeue文件。
image.png
里面记录了一个Topic下的队列里消息在CommitLog中的起始物理偏移量offset,消息大小size和消息Tag的HashCode值。

它有如下几个特性:

  • 每个topic默认为4个队列
  • 单个队列下最大可有30W个条目,每个ConsumeQueue文件(条目)大小约5.72M
  • consumequeue文件采取定长设计,共20个字节。使之可以使用类似访问数组的方式快速定位数据。
    ConsumerQueue不存消息的tag而是存tag的hashCode主要是为了保证条目的固定长度。

这样我们定位一条消息的流程就有2步:

  1. 先读ConsumeQueue得到消息在CommitLog中所在的offset
  2. 再通过offset找到CommitLog对应的消息内容

消费者通过broker保存的offset(offsetTable.offset json文件中保存的ConsumerQueue的下标)可以在ConsumeQueue中获取消息,下一章再详细写。

ConsumerQueue除了基本的消息检索外还有两个作用:

  1. 通过tag过滤消息。过滤tag是也是通过遍历ConsumeQueue来实现的(先比较hash(tag)符合条件的再到consumer比较tag原文)
  2. ConsumeQueue还能借助于操作系统的PageCache进行缓存提升检索性能

ConsumerQueue存储在内存里与将其存储到磁盘也是两个步骤。其中存储在内存里的入口与构建CommitLog同样是在DefaultMessageStore#start()方法里,里面有这样一行代码this.reputMessageService.start();,然后在ReputMessageService这个线程体里每间隔1ms执行doReput方法.

        public void run() {
            DefaultMessageStore.log.info(this.getServiceName() + " service started");

            while (!this.isStopped()) {
                try {
                    //先写commitLog再写consumerQueue,这里sleep 1ms是为了保证顺序
                    Thread.sleep(1);
                    this.doReput();//构建ConsumerQueue
                } catch (Exception e) {
                    DefaultMessageStore.log.warn(this.getServiceName() + " service has exception. ", e);
                }
            }

            DefaultMessageStore.log.info(this.getServiceName() + " service end");
        }
        //代码有省略
        private void doReput() {
            for (boolean doNext = true; this.isCommitLogAvailable() && doNext; ) {
                SelectMappedBufferResult result = DefaultMessageStore.this.commitLog.getData(reputFromOffset);// 拿到所有的最新写入CommitLog的数据
                if (result != null) {
                    try {
                        this.reputFromOffset = result.getStartOffset();

                        for (int readSize = 0; readSize < result.getSize() && doNext; ) {
                            DispatchRequest dispatchRequest =
                            DefaultMessageStore.this.commitLog.checkMessageAndReturnSize(result.getByteBuffer(), false, false); // 一条一条的读消息
                            int size = dispatchRequest.getMsgSize();

                            if (dispatchRequest.isSuccess()) {
                                if (size > 0) {
                                    DefaultMessageStore.this.doDispatch(dispatchRequest); // 派发消息,进行处理,其中就包括构建ComsumerQueue
                                    this.reputFromOffset += size;
                                    readSize += size;
                                } else if (size == 0) { // 
                                    this.reputFromOffset = DefaultMessageStore.this.commitLog.rollNextFile(this.reputFromOffset);
                                    readSize = result.getSize();
                                }
                            } else if (!dispatchRequest.isSuccess()) { // 获取消息异常

                                if (size > 0) {
                                    log.error("[BUG]read total count not equals msg total size. reputFromOffset={}", reputFromOffset);
                                    this.reputFromOffset += size;
                                } else {
                                    doNext = false;
                                    if (DefaultMessageStore.this.brokerConfig.getBrokerId() == MixAll.MASTER_ID) {
                                        this.reputFromOffset += result.getSize() - readSize;
                                    }
                                }
                            }
                        }
                    } finally {
                        result.release();
                    }
                } else {
                    doNext = false;
                }
            }
        }

doReput大概的流程:

  1. 获取最新写入到CommitLog中的数据byteBuffer。
  2. 从byteBuffer中一条条的读取消息,并派发出去处理。
  3. 更新reputFromOffset位移。

三,IndexFile文件
消息还可以通过key或时间进行检索,当然我们也不可能直接从CommitLog里查寻,而是需要借助IndexFile。
IndexFile里的文件名fileName是以创建时的时间戳命名的,固定的单个IndexFile文件大小约为400M,一个IndexFile可以保存 2000W个索引,IndexFile的底层存储设计为在文件系统中实现HashMap结构,故rocketmq的索引文件其底层实现为hash索引。

  1. IndexFile生成原理
    每当一个新的消息的index进来,首先取MessageKey的hashCode,然后用hashCode对slot总数取模,得到应该放到哪个slot中,slot总数系统默认500W个。只要是取hash就必然面临hash冲突的问题,跟HashMap一样,IndexFile也是使用一个链表结构来解决hash冲突。只是这里跟HashMap稍微有点区别的地方是,slot中放的是最新index的指针,也就是发生冲突后最新的放在slot里,这个是因为一般查询的时候肯定是优先查最近的消息。
    每个slot中放的指针值是索引在indexFile中的偏移量,每个索引大小是20字节,所以根据当前索引是这个文件中的第几个(偏移量),就很容易定位到索引的位置。然后每个索引都保存了跟它同一个slot的前一个索引的位置,以此类推形成一个链表的结构。
  2. IndexFil组成

image.png

IndexFile由三部分组成:
1)索引文件由索引文件头IndexHeader。头文件由40个字节的数据组成,主要内容有:

//8位 该索引文件的第一个消息(Message)的存储时间(落盘时间)
this.byteBuffer.putLong(beginTimestampIndex, this.beginTimestamp.get());
//8位 该索引文件的最后一个消息(Message)的存储时间(落盘时间)
this.byteBuffer.putLong(endTimestampIndex, this.endTimestamp.get());
//8位 该索引文件第一个消息(Message)的在CommitLog(消息存储文件)的物理位置偏移量(可以通过该物理偏移直接获取到该消息)
this.byteBuffer.putLong(beginPhyoffsetIndex, this.beginPhyOffset.get());
//8位 该索引文件最后一个消息(Message)的在CommitLog(消息存储文件)的物理位置偏移量
this.byteBuffer.putLong(endPhyoffsetIndex, this.endPhyOffset.get());
//4位 该索引文件目前的hash slot的个数
this.byteBuffer.putInt(hashSlotcountIndex, this.hashSlotCount.get());
//4位 索引文件目前的索引个数
this.byteBuffer.putInt(indexCountIndex, this.indexCount.get());

2)槽位Slot
紧临着IndexHeader,默认slot是500万个,每个固定大小为4byte,slot中存着一个int值,表示当前slot下最新的一个index序号。
在计算对应的槽位时,会先算出MessageKey的hashCode,然后用Hashcode对slot的总数进行取模,决定该消息key的位置,slot的总数默认是500W个。
只要取hash就必然面临着hash冲突的问题,indexfile也是采用链表结构来解决hash冲突(注意,500w个slot很大,另外冲突的情形一般不会很大,所以没有使用红黑树)。slot的值对应当前slot下最新的那个index的序号,index中存储了当前slot下、当前index的前一个index序号,这就把slot下的所有index链起来了

//slot的数据存放位置 40 + keyHash %(500W)* 4
int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;
//Slot Table 4字节
//记录该slot当前index,如果hash冲突(即absSlotPos一致)作为下一次该slot新增的前置index
this.mappedByteBuffer.putInt(absSlotPos, this.indexHeader.getIndexCount());

image.png

3)消息的索引内容

//Index Linked list
//topic+message key的hash值
this.mappedByteBuffer.putInt(absIndexPos, keyHash);
//消息在CommitLog的物理文件地址, 可以直接查询到该消息(索引的核心机制)
this.mappedByteBuffer.putLong(absIndexPos + 4, phyOffset);
//消息的落盘时间与header里的beginTimestamp的差值(为了节省存储空间,如果直接存message的落盘时间就得8bytes)
this.mappedByteBuffer.putInt(absIndexPos + 4 + 8, (int) timeDiff);
//9、记录该slot上一个index
//hash冲突处理的关键之处, 相同hash值上一个消息索引的index(如果当前消息索引是该hash值的第一个索引,则prevIndex=0, 也是消息索引查找时的停止条件),每个slot位置的第一个消息的prevIndex就是0的
this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue);

image.png

四,查询流程

由于indexHeader,slot,index都是固定大小,所以:

  • 公式1:第n个slot在indexFile中的起始位置是这样:40+(n-1)*4
  • 公式2:第s个index在indexFile中的起始位置是这样:40+50000004+(s-1)20
  • 查询的传入值除了key外,还包含一个时间起始值以及截止值,为啥还要传时间范围呢?

一个indexFile写完一个会继续写下一个,仅仅一个key无法定位到具体的indexFile,时间范围就为了更精确的定位到具体的indexFile,缩小查找的范围,indexFile文件名是一个时间戳,根据这个日期就可以定位到传入的日期范围对应在哪个或者哪些indexFile中。

所以整体的查询流程如下:

key-->计算hash值-->hash值%500w,算出对应的slot序号-->根据40+(n-1)4(公式1)算出该slot在文件中的位置-->读取slot值,也就是index序号-->根据40+50000004+(s-1)*20(公式2)算出该index在文件中的位置-->读取该index-->将key的hash值以及传入的时间范围与index的keyHash值以及timeDiff值进行比对。不满足则根据index中的preIndexNo找到上一个index,继续上一步;满足则根据index中的phyOffset拿到commitLog中的消息

为啥比对时还要带上时间范围呢?只比key不行吗?答案是不行,因为key可能会重复,producer在消息生产时可以指定消息的key,这个key显然无法保证唯一性,那自动生成的msgId呢?也不能保证唯一。

五,构建IndexFile过程
IndexFile两个索引文件的构建是放在同一个后台任务中ReputMessageService中的,具体流程如下:
indexFile的索引构建流程如下:

  1. 拿到消息的msgId-->hash值计算-->对500万取余计算对应的slot序号-->根据40+(n-1)*4算出该slot的文件位置-->读取slot值,也就是index序号
  2. 追加写入一条Index数据,keyHash、phyOffset、timeDiff不用多说,preIndexNo我们说了是前一个index的序号,也就是slot的当前值,如果没有值,说明该slot下没有index
  3. 更新slot值为插入的index的序号(更新前存的是上一个Index的序号或者空,更新后存的是新插入的Index的序号)
  4. 更新IndexHeader中的endTimestamp、endPhyOffset、indexCount、hashSlotCount(这一项可能不会更新)

如果消息设置了一个或多个key属性,则重复上面的过程,构建索引。

1)构建index索引代码如下:

它的入口与构建ConsumerQueue一样,也是在doReput方法里,不过是在

DefaultMessageStore.this.doDispatch(dispatchRequest);

使用了不同的处理器:构建ConsumerQueue是CommitLogDispatcherBuildConsumeQueue这个分发处理器;构建IndexFile是CommitLogDispatcherBuildIndex这个分发处理器。

public boolean putKey(final String key, final long phyOffset, final long storeTimestamp) {
        //1. 判断该索引文件的索引数小于最大的索引数,如果>=最大索引数,IndexService就会尝试新建一个索引文件
        if (this.indexHeader.getIndexCount() < this.indexNum) {
            //2. 计算该message key的hash值
            int keyHash = indexKeyHashMethod(key);
            //3. 根据message key的hash值散列到某个hash slot里
            int slotPos = keyHash % this.hashSlotNum;
            //4. 计算得到该hash slot的实际文件位置Position
            int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;


            try {
                //5. 根据该hash slot的实际文件位置absSlotPos得到slot里的值
                //这里有两种情况:
                //1). slot=0, 当前message的key是该hash值第一个消息索引
                //2). slot>0, 该key hash值上一个消息索引的位置
                int slotValue = this.mappedByteBuffer.getInt(absSlotPos);


                //6. 数据校验及修正
                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;
                }


                //7. 计算当前消息索引具体的存储位置(Append模式)
                int absIndexPos =
                    IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
                        + this.indexHeader.getIndexCount() * indexSize;
                //8. 存入该消息索引
                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);


                //9. 关键之处:在该key hash slot处存入当前消息索引的位置,下次通过该key进行搜索时
                //会找到该key hash slot -> slot value -> curIndex -> 
                //if(curIndex.prevIndex>0) pre index (一直循环 直至该curIndex.prevIndex==0就停止)
                this.mappedByteBuffer.putInt(absSlotPos, this.indexHeader.getIndexCount());


                if (this.indexHeader.getIndexCount() <= 1) {
                    this.indexHeader.setBeginPhyOffset(phyOffset);
                    this.indexHeader.setBeginTimestamp(storeTimestamp);
                }


                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;
 }

2)indexfile的索引搜索代码如下
搜索的是消息的物理偏移量

public void selectPhyOffset(final List<Long> phyOffsets, final String key, final int maxNum,
        final long begin, final long end, boolean lock) {
        if (this.mappedFile.hold()) {
            //1. 计算该key的hash
            int keyHash = indexKeyHashMethod(key);
            //2. 计算该hash value 对应的hash slot位置
            int slotPos = keyHash % this.hashSlotNum;
            //3. 计算该hash value 对应的hash slot物理文件位置
            int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;

            FileLock fileLock = null;
            try {
                if (lock) {
                    // fileLock = this.fileChannel.lock(absSlotPos,
                    // hashSlotSize, true);
                }
                //4. 取出该hash slot 的值
                int slotValue = this.mappedByteBuffer.getInt(absSlotPos);
                // if (fileLock != null) {
                // fileLock.release();
                // fileLock = null;
                // }
                
                //5. 该slot value <= 0 就代表没有该key对应的消息索引,直接结束搜索
                //该slot value > maxIndexCount 就代表该key对应的消息索引超过最大
                //限制,数据有误,直接结束搜索
                if (slotValue <= invalidIndex || slotValue > this.indexHeader.getIndexCount()
                    || this.indexHeader.getIndexCount() <= 1) {
                } else {
                    6. 从当前slot value 开始搜索
                    for (int nextIndexToRead = slotValue; ; ) {
                        if (phyOffsets.size() >= maxNum) {
                            break;
                        }
                    
                        7. 找到当前slot value(也就是index count)物理文件位置
                        int absIndexPos =
                            IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
                                + nextIndexToRead * indexSize;
                        8. 读取消息索引数据
                        int keyHashRead = this.mappedByteBuffer.getInt(absIndexPos);
                        long phyOffsetRead = this.mappedByteBuffer.getLong(absIndexPos + 4);

                        long timeDiff = (long) this.mappedByteBuffer.getInt(absIndexPos + 4 + 8);
                        //9. 获取该消息索引的上一个消息索引index(可以看成链表的prev 指向上一个链节点的引用)
                        int prevIndexRead = this.mappedByteBuffer.getInt(absIndexPos + 4 + 8 + 4);
                        10. 数据校验
                        if (timeDiff < 0) {
                            break;
                        }

                        timeDiff *= 1000L;

                        long timeRead = this.indexHeader.getBeginTimestamp() + timeDiff;
                        boolean timeMatched = (timeRead >= begin) && (timeRead <= end);
                        //10. 数据校验比对 hash值和落盘时间
                        if (keyHash == keyHashRead && timeMatched) {
                            phyOffsets.add(phyOffsetRead);
                        }
                        //当prevIndex <= 0 或prevIndex > maxIndexCount 或prevIndexRead == nextIndexToRead 或 timeRead < begin 停止搜索
                        if (prevIndexRead <= invalidIndex
                            || prevIndexRead > this.indexHeader.getIndexCount()
                            || prevIndexRead == nextIndexToRead || timeRead < begin) {
                            break;
                        }

                        nextIndexToRead = prevIndexRead;
                    }
                }
            } catch (Exception e) {
                log.error("selectPhyOffset exception ", e);
            } finally {
                if (fileLock != null) {
                    try {
                        fileLock.release();
                    } catch (IOException e) {
                        log.error("Failed to release the lock", e);
                    }
                }

                this.mappedFile.release();
            }
        }
    }

参考文章:通过这三个文件彻底搞懂rocketmq的存储原理
rocketMq存储模型_indexFile
RocketMQ如何构建ComsumerQueue的?


步履不停
38 声望13 粉丝

好走的都是下坡路