【RocketMQ】Index构建过程分析

本文解析了RocketMQ中的Index文件设计,阐述其如何通过哈希链表提高消息查询性能,以及索引构建过程。重点讲解了索引结构、关键操作如哈希碰撞处理和索引条目的构成。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1. 前言

Broker会把Producer发送的消息写入到CommitLog,理论上来说,RocketMQ只要有CommitLog文件就可以正常运行了。构建额外的ConsumeQueue是为了加速消费者的消费效率,构建Index文件的目的又是什么呢?

Index文件是否存在,都不影响RocketMQ生产者和消费者的正常运行,它的目的仅仅是提高消息查询的效率。如果我们要根据Key或时间段查询消息,通过CommitLog去检索无疑性能是非常差的,通过空间换时间,往CommitLog写入消息后,再往Index文件写入一份索引数据,就可以实现快速查询了。

和ConsumeQueue的构建过程一样,消息写入CommitLog后并不会立马写Index,而是由异步线程ReputMessageService重放消息时进行构建的。

2. 索引设计

索引文件存储路径为$HOME/store/index/{fileName},fileName以文件创建时的时间戳命名,单个IndexFile是定长的,大小约400M,一个IndexFile可以保存2000万个索引,IndexFile底层存储结构为哈希+链表的结构,借鉴了HashMap的设计。

2.1 IndexFile

索引文件,对应的类是org.apache.rocketmq.store.index.IndexFile。索引文件的构成:

长度(字节)说明
40索引头信息
5000000*4哈希槽
Other索引数据

2.2 IndexHeader

索引头信息,对应的类是org.apache.rocketmq.store.index.IndexHeader。索引头包含一下信息:

长度(字节)说明
8索引起始时间戳
8结束时间戳
8索引起始CommitLog偏移量
8结束CommitLog偏移量
4哈希槽数量
4索引数量

2.3 索引条目

单个索引条目是定长的,为20个字节。构成如下:

长度(字节)说明
4Key的哈希值
8消息在CommitLog中的偏移量
4存盘时间差(秒)
4链接下一个索引的指针

链接下一个索引的指针默认值为0,当遇到哈希碰撞时,采用头插法,哈希槽指向最新的索引数据,指针链接下一个索引。为什么采用头插法?因为对于RocketMQ来说,关心的总是最新的消息。

3. 源码分析

笔者画了Index构建过程的时序图,流程也不复杂。
在这里插入图片描述

1.ReputMessageService服务1毫秒会执行一次,根据消息重放偏移量reputFromOffset去CommitLog文件中读取待重放的消息,并构建DispatchRequest对象,然后将它分发出去,交给各个CommitLogDispatcher处理。CommitLogDispatcherBuildIndex就是其中之一,它用来构建Index索引数据。

class CommitLogDispatcherBuildIndex implements CommitLogDispatcher {
    @Override
    public void dispatch(DispatchRequest request) {
        // 是否开启消息索引?
        if (DefaultMessageStore.this.messageStoreConfig.isMessageIndexEnable()) {
            // 构建索引
            DefaultMessageStore.this.indexService.buildIndex(request);
        }
    }
}

因为Index文件可有可无,如果没有消息查询的需求,那它就没有价值,因此你可以按需选择是否开启消息索引,只有开启了才会构建Index。

2.如果开启了消息索引,就会调用索引服务IndexService去构建索引。要想写入索引数据,首先要定位到索引文件IndexFile,方法为retryGetAndCreateIndexFile,它会尝试获取最新的索引文件,如果文件写满了会自动创建新的索引文件继续写。

IndexFile indexFile = retryGetAndCreateIndexFile();

然后获取IndexFile索引的CommitLog最终偏移量,和CommitLog中的偏移量做比较,如果索引数据已经和CommitLog保持同步了,就没必要走后续流程了。

3.如果CommitLog还有没构建索引的消息,就会调用putKey追加索引。先调用buildKey构建索引,索引值为Topic+#+uniqKey

private String buildKey(final String topic, final String key) {
    return topic + "#" + key;
}

然后就是追加索引数据了,IndexFile采用哈希➕链表的结构来存储,首先肯定是计算Key的哈希值了,然后用哈希值对哈希槽数量进行取模,得到哈希槽下标。然后根据当前索引数量计算索引应该写入的文件地址偏移量,最后写入索引数据,更新头信息即可。

/**
 *
 * @param key 消息唯一键
 * @param phyOffset  消息偏移量
 * @param storeTimestamp 消息存盘时间
 * @return
 */
public boolean putKey(final String key, final long phyOffset, final long storeTimestamp) {
    if (this.indexHeader.getIndexCount() < this.indexNum) {
        // 计算Key哈希值
        int keyHash = indexKeyHashMethod(key);
        // 计算槽位
        int slotPos = keyHash % this.hashSlotNum;
        // 槽位在索引文件中的地址偏移量
        int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;
        FileLock fileLock = null;
        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;
            }

            // 索引数据写入的位置:索引头+哈希槽+索引数*索引大小
            int absIndexPos =
                    IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
                            + this.indexHeader.getIndexCount() * indexSize;
            /**
             * 索引构成:
             * 1.4字节 哈希值
             * 2.8字节 CommitLog偏移量
             * 3.4字节 存盘时间与 时间差(秒)
             * 4.链接下一个索引的指针
             */
            this.mappedByteBuffer.putInt(absIndexPos, keyHash);
            this.mappedByteBuffer.putLong(absIndexPos + 4, phyOffset);
            this.mappedByteBuffer.putInt(absIndexPos + 4 + 8, (int) timeDiff);
            // Next指针指向的是当前槽位索引
            this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue);

            // 更新槽位的值,槽位存储的总是最新的索引,对于MQ来说,总是关心最新的数据
            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);
        } finally {
            if (fileLock != null) {
                try {
                    fileLock.release();
                } catch (IOException e) {
                    log.error("Failed to release the lock", e);
                }
            }
        }
    } else {
        log.warn("Over index file capacity: index count = " + this.indexHeader.getIndexCount()
                + "; index max num = " + this.indexNum);
    }

    return false;
}

当遇到哈希碰撞了,RocketMQ采用头插法,哈希槽指向的永远是最新的消息索引,因为对于RocketMQ而言,关心的永远是最新的消息。

IndexFile的前40个字节是头信息,在头信息里存储了索引的起止时间和偏移量,根据头信息就能快速判断当前文件是否有指定时间区间的消息。

4. 总结

Index是RocketMQ用来根据Key和时间范围检索消息的索引文件,它的存在与否并不影响RocketMQ生产者和消费者的正常运行,如果没有消息检索的需求,可以选择关闭索引功能。

和ConsumeQueue一样,Index索引的构建也是通过ReputMessageService线程重放消息来完成的。消息重放时,根据消息Key计算哈希值,对哈希槽数量进行取模得到槽位下标,采用头插法插入索引数据。

单个索引条目为20个字节,分别是4字节哈希码、8字节消息偏移量、4字节存盘时间差、4字节Next指针。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员小潘

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值