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个字节。构成如下:
长度(字节) | 说明 |
---|---|
4 | Key的哈希值 |
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指针。