消息中间件,说是一个通信组件也没有错,因为它的本职工作是做消息的传递。然而要做到高效的消息传递,很重要的一点是数据结构,数据结构设计的好坏,一定程度上决定了该消息组件的性能以及能力上限。

1. 消息中间件的实现方式概述

  消息中间件实现起来自然是很难的,但我们可以从某些角度,简单了说说实现思路。

  它的最基本的两个功能接口为:接收消息的发送(produce), 消息的消费(consume). 就像一个邮递员一样,经过它与不经过它实质性的东西没有变化,它只是一个中介(其他功能效应,咱们抛却不说)。

  为了实现这两个基本的接口,我们就得实现两个最基本的能力:消息的存储和查询。存储即是接收发送过来的消息,查询则包括业务查询与系统自行查询推送。

我们先来看第一个点:消息的存储。

  直接基于内存的消息组件,可以做到非常高效的传递,基本上此时的消息中间件就是由几个内存队列组成,只要保证这几个队列的安全性和实时性,就可以工作得很好了。然而基于内存则必然意味着能力有限或者成本相当高,所以这样的设计适用范围得结合业务现状做下比对。

  另一个就是基于磁盘的消息组件,磁盘往往意味着更大的存储空间,或者某种程度上意味着无限的存储空间,因为毕竟所有的大数据都是存放在磁盘上的,前提是系统需要协调好各磁盘间的数据关系。然而,磁盘也意味着性能的下降,数据存放起来更麻烦。但rocketmq借助于操作系统的pagecache和mmap以及顺序写机制,在读写性能方面已经非常优化。所以,更重要的是如何设计好磁盘的数据据结构。

然后是第二个点:消息的查询。

  具体如何查询,则必然依赖于如何存储,与上面的原理类似,不必细说。但一般会有两种消费模型:推送消息模型和拉取消费模型。即是消息中间件主动向消费者推送消息,或者是消费者主动查询消息中间件。二者也各有优劣,推送模型一般可以体现出更强的实时性以及保持比较小的server端存储空间占用,但是也带来了非常大的复杂度,它需要处理各种消费异常、重试、负载均衡、上下线,这不是件小事。而拉取模型则会对消息中间件减轻许多工作,主要是省去了异常、重试、负载均衡类的工作,将这些工作转嫁到消费者客户端上。但与此同时,也会对消息中间件提出更多要求,即要求能够保留足够长时间的数据,以便所有合法的消费者都可以进行消费。而对于客户端,则也需要中间件提供相应的便利,以便可以实现客户端的基本诉求,比如消费组管理,上下线管理以及最基本的高效查询能力。

2. rocketmq存储模型设计概述

  很明显,rocketmq的初衷就是要应对大数据的消息传递,所以其必然是基于磁盘的存储。而其性能如上节所述,其利用操作系统的pagecache和mmap机制,读写性能非常好,另外他使用顺序写机制,使普通磁盘也能体现出非常高的性能。

  但是,以上几项,只是为高性能提供了必要的前提。但具体如何利用,还需要从重设计。毕竟,快不是目的,实现需求才是意义。

  rocketmq中主要有四种存储文件:commitlog 数据文件, consumequeue 消费队列文件, index 索引文件, 元数据信息文件。最后一个元数据信息文件比较简单,因其数据量小,方便操作。但针对前三个文件,都会涉及大量的数据问题,所以必然好详细设计其结构。

  从总体上来说,rocketmq都遵从定长数据结构存储,定长的最大好处就在于可以快速定位位置,这是其高性能的出发点。定长模型。

  从核心上来说,commitlog文件保存了所有原始数据,所有数据想要获取,都能从或也只能从commitlog文件中获取,由于commitlog文件保持了顺序写的特性,所以其性能非常高。而因数据只有一份,所以也就从根本上保证了数据一致性。

  而根据各业务场景,衍生出了consumequeue和index文件,即 consumequeue 文件是为了消费者能够快速获取到相应消息而设计,而index文件则为了能够快速搜索到消息而设计。从功能上说,consumequeue和index文件都是索引文件,只是索引的维度不同。consumequeue 是以topic和queueId维度进行划分的索引,而index 则是以时间和key作为划分的索引。有了这两个索引之后,就可以为各自的业务场景,提供高性能的服务了。具体其如何实现索引,我们稍后再讲!

  commitlog vs consumequeue 的存储模型如下:

3. commitlog文件的存储结构

  直接顺序写的形式存储,每个文件设定固定大小,默认是1G即: 1073741824 bytes. 写满一个文件后,新开一个文件写入。文件名就是其存储的起始消息偏移量。

  官方描述如下:

CommitLog:消息主体以及元数据的存储主体,存储Producer端写入的消息主体内容,消息内容不是定长的。单个文件大小默认1G ,文件名长度为20位,左边补零,剩余为起始偏移量,比如00000000000000000000代表了第一个文件,起始偏移量为0,文件大小为1G=1073741824;当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件;

  当给定一个偏移量,要查找某条消息时,只需在所有的commitlog文件中,根据其名字即可知道偏移的数据信息是否存在其中,即相当于可基于文件实现一个二分查找,实际上rocketmq实现得更简洁,直接一次性查找即可定位:

    // org.apache.rocketmq.store.CommitLog#getData
public SelectMappedBufferResult getData(final long offset, final boolean returnFirstOnNotFound) {
int mappedFileSize = this.defaultMessageStore.getMessageStoreConfig().getMappedFileSizeCommitLog();
// 1. 先在所有commitlog文件中查找到对应所在的 commitlog 分片文件
MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset, returnFirstOnNotFound);
if (mappedFile != null) {
// 再从该分片文件中,移动余数的大小偏移,即可定位到要查找的消息记录了
int pos = (int) (offset % mappedFileSize);
SelectMappedBufferResult result = mappedFile.selectMappedBuffer(pos);
return result;
} return null;
}
// 查找偏移所在commitlog文件的实现方式:
// org.apache.rocketmq.store.MappedFileQueue#findMappedFileByOffset(long, boolean)
// firstMappedFile.getFileFromOffset() / this.mappedFileSize 代表了第一条记录所处的文件位置编号
// offset / this.mappedFileSize 代表当前offset所处的文件编号
// 那么,两个编号相减就是当前offset对应的文件编号,因为第一个文件编号的相对位置是0
// 但有个前提:就是每个文件存储的大小必须是真实的对应的 offset 大小之差,而实际上consumeQueue根本无法确定它存了多少offset
// 也就是说,只要文件定长,offset用于定位 commitlog文件就是合理的
int index = (int) ((offset / this.mappedFileSize) - (firstMappedFile.getFileFromOffset() / this.mappedFileSize));
MappedFile targetFile = null;
try {
// 所以,此处可以找到 commitlog 文件对应的 mappedFile
targetFile = this.mappedFiles.get(index);
} catch (Exception ignored) {
}
if (targetFile != null && offset >= targetFile.getFileFromOffset()
&& offset < targetFile.getFileFromOffset() + this.mappedFileSize) {
return targetFile;
}
// 如果快速查找失败,则退回到遍历方式, 使用O(n)的复杂度再查找一次
for (MappedFile tmpMappedFile : this.mappedFiles) {
if (offset >= tmpMappedFile.getFileFromOffset()
&& offset < tmpMappedFile.getFileFromOffset() + this.mappedFileSize) {
return tmpMappedFile;
}
}

  定位到具体的消息记录位置后,如何知道要读多少数据呢?这实际上在commitlog的数据第1个字节中标明,只需读出即可知道。

  具体commitlog的存储实现如下:

    // org.apache.rocketmq.store.CommitLog.DefaultAppendMessageCallback#doAppend
...
// Initialization of storage space
this.resetByteBuffer(msgStoreItemMemory, msgLen);
// 1 TOTALSIZE, 首先将消息大小写入
this.msgStoreItemMemory.putInt(msgLen);
// 2 MAGICCODE
this.msgStoreItemMemory.putInt(CommitLog.MESSAGE_MAGIC_CODE);
// 3 BODYCRC
this.msgStoreItemMemory.putInt(msgInner.getBodyCRC());
// 4 QUEUEID
this.msgStoreItemMemory.putInt(msgInner.getQueueId());
// 5 FLAG
this.msgStoreItemMemory.putInt(msgInner.getFlag());
// 6 QUEUEOFFSET
this.msgStoreItemMemory.putLong(queueOffset);
// 7 PHYSICALOFFSET
this.msgStoreItemMemory.putLong(fileFromOffset + byteBuffer.position());
// 8 SYSFLAG
this.msgStoreItemMemory.putInt(msgInner.getSysFlag());
// 9 BORNTIMESTAMP
this.msgStoreItemMemory.putLong(msgInner.getBornTimestamp());
// 10 BORNHOST
this.resetByteBuffer(bornHostHolder, bornHostLength);
this.msgStoreItemMemory.put(msgInner.getBornHostBytes(bornHostHolder));
// 11 STORETIMESTAMP
this.msgStoreItemMemory.putLong(msgInner.getStoreTimestamp());
// 12 STOREHOSTADDRESS
this.resetByteBuffer(storeHostHolder, storeHostLength);
this.msgStoreItemMemory.put(msgInner.getStoreHostBytes(storeHostHolder));
// 13 RECONSUMETIMES
this.msgStoreItemMemory.putInt(msgInner.getReconsumeTimes());
// 14 Prepared Transaction Offset
this.msgStoreItemMemory.putLong(msgInner.getPreparedTransactionOffset());
// 15 BODY
this.msgStoreItemMemory.putInt(bodyLength);
if (bodyLength > 0)
this.msgStoreItemMemory.put(msgInner.getBody());
// 16 TOPIC
this.msgStoreItemMemory.put((byte) topicLength);
this.msgStoreItemMemory.put(topicData);
// 17 PROPERTIES
this.msgStoreItemMemory.putShort((short) propertiesLength);
if (propertiesLength > 0)
this.msgStoreItemMemory.put(propertiesData); final long beginTimeMills = CommitLog.this.defaultMessageStore.now();
// Write messages to the queue buffer
byteBuffer.put(this.msgStoreItemMemory.array(), 0, msgLen);
...

  可以看出,commitlog的存储还是比较简单的,因为其主要就是负责将接收到的所有消息,依次写入同一文件中。因为专一所以专业。

4. consumequeue文件的存储结构

  consumequeue作为消费者的重要依据,同样起着非常重要的作用。消费者在进行消费时,会使用一些偏移量作为依据(拉取模型实现)。而这些个偏移量,实际上就是指的consumequeue的偏移量(注意不是commitlog的偏移量)。这样做有什么好处呢?首先,consumequeue作为索引文件,它被要求要有非常高的查询性能,所以越简单越好。最好是能够一次性定位到数据!

  如果想一次性定位数据,那么唯一的办法是直接使用commitlog的offset。但这会带来一个最大的问题,就是当我当前消息消费拉取完成后,下一条消息在哪里呢?如果单靠commitlog文件,那么,它必然需要将下一条消息读入,然后再根据topic判定是不是需要的数据。如此一来,就必然存在大量的commitlog文件的io问题了。所以,这看起来是非常快速的一个解决方案,最终又变成了非常费力的方案了。

  而使用commitlog文件的offset,则好了许多。因为consumequeue的文件存储格式是一条消息占20字节,即定长。根据这20字节,你可以找到commitlog的offset. 而因为consumequeue本身就是按照topic/queueId进行划分的,所以,本次消费完成后,下一次消费的数据必定就在consumequeue的下一位置。如此简单快速搞得定了。具体consume的存储格式,如官方描述:

ConsumeQueue:消息消费队列,引入的目的主要是提高消息消费的性能,由于RocketMQ是基于主题topic的订阅模式,消息消费是针对主题进行的,如果要遍历commitlog文件中根据topic检索消息是非常低效的。Consumer即可根据ConsumeQueue来查找待消费的消息。其中,ConsumeQueue(逻辑消费队列)作为消费消息的索引,保存了指定Topic下的队列消息在CommitLog中的起始物理偏移量offset,消息大小size和消息Tag的HashCode值。consumequeue文件可以看成是基于topic的commitlog索引文件,故consumequeue文件夹的组织方式如下:topic/queue/file三层组织结构,具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。同样consumequeue文件采取定长设计,每一个条目共20个字节,分别为8字节的commitlog物理偏移量、4字节的消息长度、8字节tag hashcode,单个文件由30W个条目组成,可以像数组一样随机访问每一个条目,每个ConsumeQueue文件大小约5.72M;

  其中fileName也是以偏移量作为命名依据,因为这样才能根据offset快速查找到数据所在的分片文件。

  其存储实现如下:

    // org.apache.rocketmq.store.ConsumeQueue#putMessagePositionInfo
private boolean putMessagePositionInfo(final long offset, final int size, final long tagsCode,
final long cqOffset) { if (offset + size <= this.maxPhysicOffset) {
log.warn("Maybe try to build consume queue repeatedly maxPhysicOffset={} phyOffset={}", maxPhysicOffset, offset);
return true;
}
// 依次写入 offset + size + tagsCode
this.byteBufferIndex.flip();
this.byteBufferIndex.limit(CQ_STORE_UNIT_SIZE);
this.byteBufferIndex.putLong(offset);
this.byteBufferIndex.putInt(size);
this.byteBufferIndex.putLong(tagsCode); final long expectLogicOffset = cqOffset * CQ_STORE_UNIT_SIZE; MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile(expectLogicOffset);
if (mappedFile != null) { if (mappedFile.isFirstCreateInQueue() && cqOffset != 0 && mappedFile.getWrotePosition() == 0) {
this.minLogicOffset = expectLogicOffset;
this.mappedFileQueue.setFlushedWhere(expectLogicOffset);
this.mappedFileQueue.setCommittedWhere(expectLogicOffset);
this.fillPreBlank(mappedFile, expectLogicOffset);
log.info("fill pre blank space " + mappedFile.getFileName() + " " + expectLogicOffset + " "
+ mappedFile.getWrotePosition());
} if (cqOffset != 0) {
long currentLogicOffset = mappedFile.getWrotePosition() + mappedFile.getFileFromOffset(); if (expectLogicOffset < currentLogicOffset) {
log.warn("Build consume queue repeatedly, expectLogicOffset: {} currentLogicOffset: {} Topic: {} QID: {} Diff: {}",
expectLogicOffset, currentLogicOffset, this.topic, this.queueId, expectLogicOffset - currentLogicOffset);
return true;
} if (expectLogicOffset != currentLogicOffset) {
LOG_ERROR.warn(
"[BUG]logic queue order maybe wrong, expectLogicOffset: {} currentLogicOffset: {} Topic: {} QID: {} Diff: {}",
expectLogicOffset,
currentLogicOffset,
this.topic,
this.queueId,
expectLogicOffset - currentLogicOffset
);
}
}
this.maxPhysicOffset = offset + size;
// 将buffer写入 consumequeue 的 mappedFile 中
return mappedFile.appendMessage(this.byteBufferIndex.array());
}
return false;
}
当需要进行查找进,也就会根据offset, 定位到某个 consumequeue 文件,然后再根据偏移余数信息,再找到对应记录,取出20字节,即是 commitlog信息。此处实现与 commitlog 的offset查找实现如出一辙。
// 查找索引所在文件的实现,如下:
// org.apache.rocketmq.store.ConsumeQueue#getIndexBuffer
public SelectMappedBufferResult getIndexBuffer(final long startIndex) {
int mappedFileSize = this.mappedFileSize;
// 给到客户端的偏移量是除以 20 之后的,也就是说 如果上一次的偏移量是 1, 那么下一次的偏移量应该是2
// 一次性消费多条记录另算, 自行加减
long offset = startIndex * CQ_STORE_UNIT_SIZE;
if (offset >= this.getMinLogicOffset()) {
// 委托给mappedFileQueue进行查找到单个具体的consumequeue文件
// 根据 offset 和规范的命名,可以快速定位分片文件,如上 commitlog 的查找实现
MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset);
if (mappedFile != null) {
// 再根据剩余的偏移量,直接类似于数组下标的形式,一次性定位到具体的数据记录
SelectMappedBufferResult result = mappedFile.selectMappedBuffer((int) (offset % mappedFileSize));
return result;
}
}
return null;
}

  如果想一次性消费多条消息,则只需要依次从查找到索引记录开始,依次读取多条,然后同理回查commitlog即可。即consumequeue的连续,成就了commitlog的不连续。如下消息拉取实现:

    // org.apache.rocketmq.store.DefaultMessageStore#getMessage
// 其中 bufferConsumeQueue 是刚刚查找出的consumequeue的起始消费位置
// 基于此文件迭代,完成多消息记录消费
...
long nextPhyFileStartOffset = Long.MIN_VALUE;
long maxPhyOffsetPulling = 0; int i = 0;
final int maxFilterMessageCount = Math.max(16000, maxMsgNums * ConsumeQueue.CQ_STORE_UNIT_SIZE);
final boolean diskFallRecorded = this.messageStoreConfig.isDiskFallRecorded();
ConsumeQueueExt.CqExtUnit cqExtUnit = new ConsumeQueueExt.CqExtUnit();
for (; i < bufferConsumeQueue.getSize() && i < maxFilterMessageCount; i += ConsumeQueue.CQ_STORE_UNIT_SIZE) {
// 依次取出commitlog的偏移量,数据大小,hashCode
// 一次循环即是取走一条记录,多次循环则依次往下读取
long offsetPy = bufferConsumeQueue.getByteBuffer().getLong();
int sizePy = bufferConsumeQueue.getByteBuffer().getInt();
long tagsCode = bufferConsumeQueue.getByteBuffer().getLong(); maxPhyOffsetPulling = offsetPy; if (nextPhyFileStartOffset != Long.MIN_VALUE) {
if (offsetPy < nextPhyFileStartOffset)
continue;
} boolean isInDisk = checkInDiskByCommitOffset(offsetPy, maxOffsetPy); if (this.isTheBatchFull(sizePy, maxMsgNums, getResult.getBufferTotalSize(), getResult.getMessageCount(),
isInDisk)) {
break;
} boolean extRet = false, isTagsCodeLegal = true;
if (consumeQueue.isExtAddr(tagsCode)) {
extRet = consumeQueue.getExt(tagsCode, cqExtUnit);
if (extRet) {
tagsCode = cqExtUnit.getTagsCode();
} else {
// can't find ext content.Client will filter messages by tag also.
log.error("[BUG] can't find consume queue extend file content!addr={}, offsetPy={}, sizePy={}, topic={}, group={}",
tagsCode, offsetPy, sizePy, topic, group);
isTagsCodeLegal = false;
}
} if (messageFilter != null
&& !messageFilter.isMatchedByConsumeQueue(isTagsCodeLegal ? tagsCode : null, extRet ? cqExtUnit : null)) {
if (getResult.getBufferTotalSize() == 0) {
status = GetMessageStatus.NO_MATCHED_MESSAGE;
} continue;
} SelectMappedBufferResult selectResult = this.commitLog.getMessage(offsetPy, sizePy);
if (null == selectResult) {
if (getResult.getBufferTotalSize() == 0) {
status = GetMessageStatus.MESSAGE_WAS_REMOVING;
} nextPhyFileStartOffset = this.commitLog.rollNextFile(offsetPy);
continue;
} if (messageFilter != null
&& !messageFilter.isMatchedByCommitLog(selectResult.getByteBuffer().slice(), null)) {
if (getResult.getBufferTotalSize() == 0) {
status = GetMessageStatus.NO_MATCHED_MESSAGE;
}
// release...
selectResult.release();
continue;
} this.storeStatsService.getGetMessageTransferedMsgCount().incrementAndGet();
getResult.addMessage(selectResult);
status = GetMessageStatus.FOUND;
nextPhyFileStartOffset = Long.MIN_VALUE;
} if (diskFallRecorded) {
long fallBehind = maxOffsetPy - maxPhyOffsetPulling;
brokerStatsManager.recordDiskFallBehindSize(group, topic, queueId, fallBehind);
}
// 分配下一次读取的offset偏移信息,同样要除以单条索引大小
nextBeginOffset = offset + (i / ConsumeQueue.CQ_STORE_UNIT_SIZE); long diff = maxOffsetPy - maxPhyOffsetPulling;
long memory = (long) (StoreUtil.TOTAL_PHYSICAL_MEMORY_SIZE
* (this.messageStoreConfig.getAccessMessageInMemoryMaxRatio() / 100.0));
getResult.setSuggestPullingFromSlave(diff > memory);
...

  以上即理论的实现,无须多言。

5. index文件的存储结构

  index文件是为搜索场景而生的,如果没有搜索业务需求,则这个实现是意义不大的。一般这种搜索,主要用于后台查询验证类使用,或者有其他同的有妙用,不得而知。总之,一切为搜索。它更多的需要借助于时间限定,以key或者id进行查询。

  官方描述如下:

IndexFile(索引文件)提供了一种可以通过key或时间区间来查询消息的方法。Index文件的存储位置是:$HOME \store\index\${fileName},文件名fileName是以创建时的时间戳命名的,固定的单个IndexFile文件大小约为400M,一个IndexFile可以保存 2000W个索引,IndexFile的底层存储设计为在文件系统中实现HashMap结构,故rocketmq的索引文件其底层实现为hash索引。
IndexFile索引文件为用户提供通过“按照Message Key查询消息”的消息索引查询服务,IndexFile文件的存储位置是:$HOME\store\index\${fileName},文件名fileName是以创建时的时间戳命名的,文件大小是固定的,等于40+500W\*4+2000W\*20= 420000040个字节大小。如果消息的properties中设置了UNIQ_KEY这个属性,就用 topic + “#” + UNIQ_KEY的value作为 key 来做写入操作。如果消息设置了KEYS属性(多个KEY以空格分隔),也会用 topic + “#” + KEY 来做索引。
其中的索引数据包含了Key Hash/CommitLog Offset/Timestamp/NextIndex offset 这四个字段,一共20 Byte。NextIndex offset 即前面读出来的 slotValue,如果有 hash冲突,就可以用这个字段将所有冲突的索引用链表的方式串起来了。Timestamp记录的是消息storeTimestamp之间的差,并不是一个绝对的时间。整个Index File的结构如图,40 Byte 的Header用于保存一些总的统计信息,4\*500W的 Slot Table并不保存真正的索引数据,而是保存每个槽位对应的单向链表的头。20\*2000W 是真正的索引数据,即一个 Index File 可以保存 2000W个索引。

  具体结构图如下:

  那么,如果要查找一个key, 应当如何查找呢?rocketmq会根据时间段找到一个index索引分版,然后再根据key做hash得到一个值,然后定位到 slotValue . 然后再从slotValue去取出索引数据的地址,找到索引数据,然后再回查 commitlog 文件。从而得到具体的消息数据。也就是,相当于搜索经历了四级查询: 索引分片文件查询 -> slotValue 查询 -> 索引数据查询 -> commitlog 查询 。

  具体查找实现如下:

    // org.apache.rocketmq.broker.processor.QueryMessageProcessor#queryMessage
public RemotingCommand queryMessage(ChannelHandlerContext ctx, RemotingCommand request)
throws RemotingCommandException {
final RemotingCommand response =
RemotingCommand.createResponseCommand(QueryMessageResponseHeader.class);
final QueryMessageResponseHeader responseHeader =
(QueryMessageResponseHeader) response.readCustomHeader();
final QueryMessageRequestHeader requestHeader =
(QueryMessageRequestHeader) request
.decodeCommandCustomHeader(QueryMessageRequestHeader.class); response.setOpaque(request.getOpaque()); String isUniqueKey = request.getExtFields().get(MixAll.UNIQUE_MSG_QUERY_FLAG);
if (isUniqueKey != null && isUniqueKey.equals("true")) {
requestHeader.setMaxNum(this.brokerController.getMessageStoreConfig().getDefaultQueryMaxNum());
}
// 从索引文件中查询消息
final QueryMessageResult queryMessageResult =
this.brokerController.getMessageStore().queryMessage(requestHeader.getTopic(),
requestHeader.getKey(), requestHeader.getMaxNum(), requestHeader.getBeginTimestamp(),
requestHeader.getEndTimestamp());
assert queryMessageResult != null; responseHeader.setIndexLastUpdatePhyoffset(queryMessageResult.getIndexLastUpdatePhyoffset());
responseHeader.setIndexLastUpdateTimestamp(queryMessageResult.getIndexLastUpdateTimestamp()); if (queryMessageResult.getBufferTotalSize() > 0) {
response.setCode(ResponseCode.SUCCESS);
response.setRemark(null); try {
FileRegion fileRegion =
new QueryMessageTransfer(response.encodeHeader(queryMessageResult
.getBufferTotalSize()), queryMessageResult);
ctx.channel().writeAndFlush(fileRegion).addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
queryMessageResult.release();
if (!future.isSuccess()) {
log.error("transfer query message by page cache failed, ", future.cause());
}
}
});
} catch (Throwable e) {
log.error("", e);
queryMessageResult.release();
} return null;
} response.setCode(ResponseCode.QUERY_NOT_FOUND);
response.setRemark("can not find message, maybe time range not correct");
return response;
}
// org.apache.rocketmq.store.DefaultMessageStore#queryMessage
@Override
public QueryMessageResult queryMessage(String topic, String key, int maxNum, long begin, long end) {
QueryMessageResult queryMessageResult = new QueryMessageResult(); long lastQueryMsgTime = end; for (int i = 0; i < 3; i++) {
// 委托给 indexService 搜索记录, 时间是必备参数
QueryOffsetResult queryOffsetResult = this.indexService.queryOffset(topic, key, maxNum, begin, lastQueryMsgTime);
if (queryOffsetResult.getPhyOffsets().isEmpty()) {
break;
} Collections.sort(queryOffsetResult.getPhyOffsets()); queryMessageResult.setIndexLastUpdatePhyoffset(queryOffsetResult.getIndexLastUpdatePhyoffset());
queryMessageResult.setIndexLastUpdateTimestamp(queryOffsetResult.getIndexLastUpdateTimestamp()); for (int m = 0; m < queryOffsetResult.getPhyOffsets().size(); m++) {
long offset = queryOffsetResult.getPhyOffsets().get(m); try { boolean match = true;
MessageExt msg = this.lookMessageByOffset(offset);
if (0 == m) {
lastQueryMsgTime = msg.getStoreTimestamp();
} if (match) {
SelectMappedBufferResult result = this.commitLog.getData(offset, false);
if (result != null) {
int size = result.getByteBuffer().getInt(0);
result.getByteBuffer().limit(size);
result.setSize(size);
queryMessageResult.addMessage(result);
}
} else {
log.warn("queryMessage hash duplicate, {} {}", topic, key);
}
} catch (Exception e) {
log.error("queryMessage exception", e);
}
} if (queryMessageResult.getBufferTotalSize() > 0) {
break;
} if (lastQueryMsgTime < begin) {
break;
}
} return queryMessageResult;
} public QueryOffsetResult queryOffset(String topic, String key, int maxNum, long begin, long end) {
List<Long> phyOffsets = new ArrayList<Long>(maxNum); long indexLastUpdateTimestamp = 0;
long indexLastUpdatePhyoffset = 0;
maxNum = Math.min(maxNum, this.defaultMessageStore.getMessageStoreConfig().getMaxMsgsNumBatch());
try {
this.readWriteLock.readLock().lock();
if (!this.indexFileList.isEmpty()) {
//从最后一个索引文件,依次搜索
for (int i = this.indexFileList.size(); i > 0; i--) {
IndexFile f = this.indexFileList.get(i - 1);
boolean lastFile = i == this.indexFileList.size();
if (lastFile) {
indexLastUpdateTimestamp = f.getEndTimestamp();
indexLastUpdatePhyoffset = f.getEndPhyOffset();
}
// 判定该时间段是否数据是否在该索引文件中
if (f.isTimeMatched(begin, end)) {
// 构建出 key的hash, 然后查找 slotValue, 然后得以索引数据, 然后将offset放入 phyOffsets 中
f.selectPhyOffset(phyOffsets, buildKey(topic, key), maxNum, begin, end, lastFile);
} if (f.getBeginTimestamp() < begin) {
break;
} if (phyOffsets.size() >= maxNum) {
break;
}
}
}
} catch (Exception e) {
log.error("queryMsg exception", e);
} finally {
this.readWriteLock.readLock().unlock();
} return new QueryOffsetResult(phyOffsets, indexLastUpdateTimestamp, indexLastUpdatePhyoffset);
}
// org.apache.rocketmq.store.index.IndexFile#selectPhyOffset
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()) {
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()
|| this.indexHeader.getIndexCount() <= 1) {
// 超出搜索范围,不处理
} else {
for (int nextIndexToRead = slotValue; ; ) {
if (phyOffsets.size() >= maxNum) {
break;
} int absIndexPos =
IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
+ nextIndexToRead * indexSize;
// 依次读出 keyHash+offset+timeDiff+nextOffset
int keyHashRead = this.mappedByteBuffer.getInt(absIndexPos);
long phyOffsetRead = this.mappedByteBuffer.getLong(absIndexPos + 4); long timeDiff = (long) this.mappedByteBuffer.getInt(absIndexPos + 4 + 8);
int prevIndexRead = this.mappedByteBuffer.getInt(absIndexPos + 4 + 8 + 4); if (timeDiff < 0) {
break;
} timeDiff *= 1000L;
// 根据文件名可得到索引写入时间
long timeRead = this.indexHeader.getBeginTimestamp() + timeDiff;
boolean timeMatched = (timeRead >= begin) && (timeRead <= end); if (keyHash == keyHashRead && timeMatched) {
phyOffsets.add(phyOffsetRead);
} 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();
}
}
}

  看起来挺费劲,但真正处理起来性能还好,虽然没有consumequeue高效,但有mmap和pagecache的加持,效率还是扛扛的。而且,搜索相对慢一些,用户也是可以接受的嘛。毕竟这只是一个附加功能,并非核心所在。

  而索引文件并没有使用什么高效的搜索算法,而是简单从最后一个文件遍历完成,因为时间戳不一定总是有规律的,与其随意查找,还不如直接线性查找。另外,实际上对于索引重建问题,搜索可能不一定会有效。不过,我们可以通过扩大搜索时间范围的方式,总是能够找到存在的数据。而且因其使用hash索引实现,性能还是不错的。

  另外,index索引文件与commitlog和consumequeue有一个不一样的地方,就是它不能进行顺序写,因为hash存储,写一定是任意的。且其slotValue以一些统计信息可能随时发生变化,这也给顺序写带来了不可解决的问题。

  其具体写索引过程如下:

    // org.apache.rocketmq.store.index.IndexFile#putKey
public boolean putKey(final String key, final long phyOffset, final long storeTimestamp) {
if (this.indexHeader.getIndexCount() < this.indexNum) {
int keyHash = indexKeyHashMethod(key);
int slotPos = keyHash % this.hashSlotNum;
int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize; FileLock fileLock = null; try {
// 先尝试拉取slot对应的数据
// 如果为0则说明是第一次写入, 否则为当前的索引条数
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;
// 按协议写入内容即可
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);
// 写入slotValue为当前可知的索引记录条数
// 即每次写入索引之后,如果存在hash冲突,那么它会写入自身的位置
// 而此时 slotValue 必定存在一个值,那就是上一个发生冲突的索引,从而形成自然的链表
// 查找数据时,只需根据slotValue即可以找到上一个写入的索引,这设计妙哉!
// 做了2点关键性保证:1. 数据自增不冲突; 2. hash冲突自刷新; 磁盘版的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);
} 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 巧妙地使用了自增结构和hash slot, 完美实现一个磁盘版的hash索引。相信这也会给我们平时的工作带来一些提示。

  

  以上就是本文对rocketmq的存储模型设计的解析了,通过这些解析,相信大家对其工作原理也会有质的理解。存储实际上是目前我们的许多的系统中的非常核心部分,因为大部分的业务几乎都是在存储之前做一些简单的计算。很显然业务很重要,但有了存储的底子,还何愁业务难?

  

RocketMQ(十):数据存储模型设计与实现的更多相关文章

  1. kafka 和 rocketMQ 的数据存储

    kafka 版本:1.1.1 一个分区对应一个文件夹,数据以 segment 文件存储,segment 默认 1G. 分区文件夹: segment 文件: segment 的命名规则是怎样的? kaf ...

  2. 第十二章:Android数据存储(下)

    一.SQLite介绍 提到数据存储问题,数据库是不得不提的.数据库是用来存储关系型数据的不二利器.Android为开发者提供了强大的数据库支持,可以用来轻松地构造基于数据库的应用.Android的数据 ...

  3. Spark RDD概念学习系列之Spark的数据存储(十二)

    Spark数据存储的核心是弹性分布式数据集(RDD). RDD可以被抽象地理解为一个大的数组(Array),但是这个数组是分布在集群上的. 逻辑上RDD的每个分区叫一个Partition. 在Spar ...

  4. 第十节:Web爬虫之数据存储与MySQL8.0数据库安装和数据插入

    用解析器解析出数据之后,接下来就是存储数据了,保存的形式可以多种多样,最简单的形式是直接保存为文本文件,如 TXT.JSON.csv 另外,还可以保存到数据库中,如关系型数据库MySQL ,非关系型数 ...

  5. Spark的数据存储(十九)

    Spark本身是基于内存计算的架构,数据的存储也主要分为内存和磁盘两个路径.Spark本身则根据存储位置.是否可序列化和副本数目这几个要素将数据存储分为多种存储级别.此外还可选择使用Tachyon来管 ...

  6. Android笔记(三十八) Android中的数据存储——SharedPreferences

    SharedPreferences是Android提供的一种轻型的数据存储方法,其本质是基于xml文件存储的,内部数据以key-value的方式存储,通常用来存储一些简单的配置信息. SharedPr ...

  7. python3下scrapy爬虫(第十二卷:解决scrapy数据存储大量数据时阻塞问题)

    之前我们使用scrapy爬取数据,用的存储方式是直接引入PYMYSQL,或者MYSQLDB,案例中数据量并不大,这种数据存储方式属于同步过程,也就是上一条语句执行完才能执行下一条语句,当数据量变大时, ...

  8. python3下scrapy爬虫(第十卷:scrapy数据存储进mysql)

    上一卷中我将爬取的数据文件直接写入文本文件中,现在我将数据存储到mysql中,我依然用的是pymysql,这个很麻烦建表需要在外面建 这次代码只需要改变pipyline就行 来 现在看下结果: 对比发 ...

  9. Pb (数据存储单位)

    PB (数据存储单位) 编辑 pb指petabyte,它是较高级的存储单位,其上还有EB,ZB,YB等单位. 它等于1,125,899,906,842,624(2的50次方)字节,“大约”是一千个te ...

随机推荐

  1. day1(Django)

    1,web项目工作流程 1.1 了解web程序工作流程 1.2 django生命周期   2,django介绍 目的:了解Django框架的作用和特点作用: 简便.快速的开发数据库驱动的网站 Djan ...

  2. moviepy音视频剪辑:headblur函数遇到的TypeError: integer argument expected, got float错误的解决方案

    运行环境如下: python版本:3.7 opencv-python版本:4.2.0.34 numpy版本:1.19.0 错误信息: 在调用moviepy1.03版本的headblur函数执行人脸跟踪 ...

  3. PyQt编程实战:画出QScrollArea的scrollAreaWidgetContents内容部署层的范围矩形

    老猿Python博文目录 专栏:使用PyQt开发图形界面Python应用 老猿Python博客地址 一.引言 在<PyQt(Python+Qt)学习随笔:QScrollArea滚动区域详解> ...

  4. espcms代码审计(二次urldecode注入分析)

    这里提到的漏洞是二次urldecode注入 这里用到的还是espcms,不过版本应该跟之前的有所不同,在网上找到的espcms源代码都是已经修补了这个漏洞的,我们对比分析吧 先放上漏洞位置代码,也就是 ...

  5. vue之keep-alive组件

    <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8&quo ...

  6. js 导出div 中的类容为 word 文件

    //引入包 <script src="/FileSaver.js"></script>  <script src="/jquery.word ...

  7. 沪苏浙皖共同打造区块链数字经济发展高地,Panda Global表示区块链真的来了!

    近日,在长三角一体化发展重大合作事项签约仪式上,沪苏浙皖经信部门共同签约,推进长三角区块链数字经济一体化发展,共同打造数字经济发展高地.从此次签约活动也能看出来,区块链数字现金的发展已经得到了认可,早 ...

  8. AcWing 392. 会合

    一个思路不难,但是实现起来有点毒瘤的题. 显然题目给出的是基环树(内向树)森林. 把每一个基环抠出来. 大力分类讨论: 若 \(a, b\) 不在一个联通量里,显然是 \(-1, -1\) 若 \(a ...

  9. 四、git学习之——分支管理、解决冲突

    分支就是科幻电影里面的平行宇宙,当你正在电脑前努力学习Git的时候,另一个你正在另一个平行宇宙里努力学习SVN. 如果两个平行宇宙互不干扰,那对现在的你也没啥影响.不过,在某个时间点,两个平行宇宙合并 ...

  10. spring入门学习

    开发步骤: 1.导入Spring开发的基本坐标 2.编写接口和实现类 3.创建Spring核心配置文件 4.在Spring核心配置文件中配置实现类 5.使用Spring的API获得Bean实例Bean ...