导语
在分布式消息队列 RocketMQ 中,ConsumeQueue(消费队列) 是消息消费的核心组件之一。它作为 CommitLog 的索引机制,帮助消费者快速定位并拉取消息。如果没有 ConsumeQueue,消费者将无法高效地从海量消息中筛选出自己订阅的数据。
本文将基于 RocketMQ 5.0 源码,深入探讨 ConsumeQueue 的设计原理与实现细节。
为什么需要 ConsumeQueue?
在深入探讨 ConsumeQueue 之前,我们有必要先了解 RocketMQ 的消息写入和存储方式。
CommitLog 是 RocketMQ 的消息存储模块,用户生产的所有消息都持久化存储在该模块中,它具备两个特点:
-
使用的持久化存储是一个文件队列,文件保存于指定目录下,每个文件的大小是固定的,通常是1GB。
-
只有一个文件可写入,且仅支持追加写,文件写满后自动切换至新的文件。
RocketMQ 设计者出于写入优先的考虑,没有为不同 Topic 队列的消息分配不同的存储文件,而是将消息直接写入 CommitLog,不同 Topic 的消息混合分布在 CommitLog 的文件中。

从上图中可以看出,尽管消息的写入非常高效,但是消费者需要按照其订阅的 Topic 来从 CommitLog 中读取该 Topic 的消息,显而易见,RocketMQ 需要一种索引机制来快速读取指定 Topic 队列的消息,这正是 ConsumeQueue 要做的事情。
ConsumeQueue 的设计原理
ConsumeQueue 作为 RocketMQ 的消息索引枢纽,其设计核心在于高效映射逻辑队列与物理存储。我们通过下面的图示来介绍 ConsumeQueue 的核心设计:

-
每个 Topic 队列有其对应的唯一的 ConsumeQueue,当一条消息写入到 CommitLog 后,RocketMQ 会构建该消息的索引,按异步方式将其写入到对应 Topic 队列的 ConsumeQueue 中。使用索引可以快速定位到消息在 CommitLog 文件的位置并读取它。
-
消息索引对象在 ConsumeQueue 中的位置被称为 Offset,是个从0开始的序号数,maxOffset 即 ConsumeQueue 索引的最大 Offset,会随着新消息的写入递增。
-
基于这个设计,消费者通过与 ConsumeQueue 的 Offset 交互来实现消息的消费。最常见的场景就是,我们记录消费组在 ConsumeQueue 上当前消费的 Offset,那么消费者下线后再上线仍然可从上次消费的位置继续消费。
基于文件的传统实现方案
数据存储与格式
与 CommitLog 类似,ConsumeQueue 使用文件队列来持久化存储消息索引。ConsumeQueue 使用的文件目录所在路径由其对应的 Topic 队列确定,举例说明,一个名为 ABC 的 Topic,其队列0所在的文件目录路径是 /data/rocketmq_data/store/consumequeue/abc/0/。消息的索引对象是固定的20个字节大小,其内部格式定义见下图。

为了方便描述,从这里开始我们将索引对象叫作 CqUnit。ConsumeQueue 的文件队列中每个文件的大小是固定的,默认配置可存储30万个 CqUnit,当文件写满后,会切换到新文件进行写入。文件名称的命名方式是有讲究的,它以文件存储的第一个 CqUnit 的 Offset 作为名称,这样做的好处是,按 Offset 查询 CqUnit时,可以根据文件名称,快速定位到该 Offset 所在的文件,大幅减少对文件的读取操作频次。

构建过程
当消息写入到 CommitLog 后,该消息对于消费者是不可见的,只有在 ConsumeQueue 中增加这条消息的 CqUnit 后,消费者才能消费到这条消息,因此写入消息时须立刻往 ConsuemQueue 写入消息的 CqUnit。我们需要给每一条消息指定其在 ConsumeQueue 中的 Offset,QueueOffsetOperator 类维护了一个 Topic 队列与其当前 Offset 的表,当写入一条新消息时,DefaultMessageStore 从 QueueOffsetOperator 中取出该 Topic 队列的当前 Offset,将其写入到消息体中,在消息成功写入到 CommitLog 后,指示 QueueOffsetOperator 更新为当前 Offset + 1。为了防止其他写入线程并发访问 Topic 队列的当前 Offset,在读取和修改 Offset 期间,会使用一个 ReentrantLock 锁定该 Topic 队列。

ReputMessageService 作为异步任务,会不停的读取 CommitLog,当有新的消息写入,它会立即读取到该消息,然后根据消息体构建一个 DispatchRequest 对象,CommitLogDispatcherBuildConsumeQueue 处理 DispatchRequest 对象,最终将 CqUnit 写入到 ConsumeQueue 的存储中。

按 Offset 查找消息
消费者通常是从某个 Offset 开始消费消息的,比如消费者下线后再次上线会从上次消费的 Offset 开始消费。DefaultMessageStore 的 GetMessage 方法实现从一个 Topic 队列中拉取一批消息的功能,每次拉取要指定读取的起始 Offset 以及该批次读取的最大消息数量。下面截取了部分源码展示实现的基本思路:
java
@Override
public GetMessageResult getMessage(final String group, final String topic, final int queueId, final long offset,
final int maxMsgNums, final int maxTotalMsgSize, final MessageFilter messageFilter) {
long beginTime = this.getSystemClock().now();
GetMessageStatus status = GetMessageStatus.NO_MESSAGE_IN_QUEUE;
long nextBeginOffset = offset;
long minOffset = 0;
long maxOffset = 0;
GetMessageResult getResult = new GetMessageResult();
final long maxOffsetPy = this.commitLog.getMaxOffset();
ConsumeQueueInterface consumeQueue = findConsumeQueue(topic, queueId);
if (consumeQueue != null) {
minOffset = consumeQueue.getMinOffsetInQueue();
maxOffset = consumeQueue.getMaxOffsetInQueue();
if (maxOffset == 0) {
//
} else {
long maxPullSize = Math.max(maxTotalMsgSize, 100);
if (maxPullSize > MAX_PULL_MSG_SIZE) {
LOGGER.warn("The max pull size is too large maxPullSize={} topic={} queueId={}", maxPullSize, topic, queueId);
maxPullSize = MAX_PULL_MSG_SIZE;
}
status = GetMessageStatus.NO_MATCHED_MESSAGE;
long maxPhyOffsetPulling = 0;
int cqFileNum = 0;
while (getResult.getBufferTotalSize() <= 0
&& nextBeginOffset < maxOffset
&& cqFileNum++ < this.messageStoreConfig.getTravelCqFileNumWhenGetMessage()) {
ReferredIterator<CqUnit> bufferConsumeQueue = null;
try {
bufferConsumeQueue = consumeQueue.iterateFrom(group, nextBeginOffset, maxMsgNums);
long nextPhyFileStartOffset = Long.MIN_VALUE;
long expiredTtlOffset = -1;
while (bufferConsumeQueue.hasNext() && nextBeginOffset < maxOffset) {
CqUnit cqUnit = bufferConsumeQueue.next();
long offsetPy = cqUnit.getPos();
int sizePy = cqUnit.getSize();
SelectMappedBufferResult selectResult = this.commitLog.getMessage(offsetPy, sizePy);
getResult.addMessage(selectResult, cqUnit.getQueueOffset(), cqUnit.getBatchNum());
status = GetMessageStatus.FOUND;
nextPhyFileStartOffset = Long.MIN_VALUE;
}
} catch (RocksDBException e) {
ERROR_LOG.error("getMessage Failed. cid: {}, topic: {}, queueId: {}, offset: {}, minOffset: {}, maxOffset: {}, {}",
group, topic, queueId, offset, minOffset, maxOffset, e.getMessage());
} finally {
if (bufferConsumeQueue != null) {
bufferConsumeQueue.release();
}
}
}
long diff = maxOffsetPy - maxPhyOffsetPulling;
long memory = (long) (StoreUtil.TOTAL_PHYSICAL_MEMORY_SIZE
* (this.messageStoreConfig.getAccessMessageInMemoryMaxRatio() / 100.0));
getResult.setSuggestPullingFromSlave(diff > memory);
}
} else {
status = GetMessageStatus.NO_MATCHED_LOGIC_QUEUE;
nextBeginOffset = nextOffsetCorrection(offset, 0);
}
getResult.setStatus(status);
getResult.setNextBeginOffset(nextBeginOffset);
getResult.setMaxOffset(maxOffset);
getResult.setMinOffset(minOffset);
return getResult;
}
上述代码片段的要点:
-
Topic 队列的 ConsumeQueue 的 IterateFrom 方法依据 Offset 生成一个 Iterator对象。
-
在 Iterator 有效的情况,不断从 Iterator 拉取 CqUnit 对象,即按 Offset 顺序读取 CqUnit。
-
使用 CqUnit 对象中的 OffsetPy 和 SizePy 从 CommitLog 中读取消息内容,返回给消费者。
接下来,我们介绍 ConsumeQueue 的 IterateFrom 方法是如何读取 CqUnit 的。从下面的源码中可以看到,GetIndexBuffer 方法先从 MappedFileQueue 中找到 Offset 所在的 MappedFile,然后找到 Offset 在 MappedFile 中的位置,从该位置读取文件剩余的内容。
java
public SelectMappedBufferResult getIndexBuffer(final long startIndex) {
int mappedFileSize = this.mappedFileSize;
long offset = startIndex * CQ_STORE_UNIT_SIZE;
if (offset >= this.getMinLogicOffset()) {
MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset);
if (mappedFile != null) {
return mappedFile.selectMappedBuffer((int) (offset % mappedFileSize));
}
}
return null;
}
@Override
public ReferredIterator<CqUnit> iterateFrom(long startOffset) {
SelectMappedBufferResult sbr = getIndexBuffer(startOffset);
if (sbr == null) {
return null;
}
return new ConsumeQueueIterator(sbr);
}
ConsumeQueueIterator 的 Next 方法和 hasNext 方法是对 getIndexBuffer 方法返回的 SelectMappedBufferResult 对象,即文件内容的 ByteBuffer,进行访问。
java
private class ConsumeQueueIterator implements ReferredIterator<CqUnit> {
private SelectMappedBufferResult sbr;
private int relativePos = 0;
public ConsumeQueueIterator(SelectMappedBufferResult sbr) {
this.sbr = sbr;
if (sbr != null && sbr.getByteBuffer() != null) {
relativePos = sbr.getByteBuffer().position();
}
}
@Override
public boolean hasNext() {
if (sbr == null || sbr.getByteBuffer() == null) {
return false;
}
return sbr.getByteBuffer().hasRemaining();
}
@Override
public CqUnit next() {
if (!hasNext()) {
return null;
}
long queueOffset = (sbr.getStartOffset() + sbr.getByteBuffer().position() - relativePos) / CQ_STORE_UNIT_SIZE;
CqUnit cqUnit = new CqUnit(queueOffset,
sbr.getByteBuffer().getLong(),
sbr.getByteBuffer().getInt(),
sbr.getByteBuffer().getLong());
return cqUnit;
}
}
我们再讲下 MappedFileQueue 的 FindMappedFileByOffset 方法,该方法从其维护的文件队列中查找到 Offset 所在的文件。前面我们介绍过,ConsumeQueue 的文件队列中的文件是按 Offset 命名的,MappedFile 的 GetFileFromOffset 就是文件的名称,那么只需要按照 Offset 除以文件的大小便可得文件在队列中的位置。这里要注意的是,这个位置必须要先减去 FirstMappedFile 的位置后才是有效的,因为 ConsumeQueue 会定期清除过期的文件,所以 ConsumeQueue 管理的 MappedFileQueue 的第一个文件对应的 Offset 未必是0。
kotlin
public MappedFile findMappedFileByOffset(final long offset, final boolean returnFirstOnNotFound) {
try {
MappedFile firstMappedFile = this.getFirstMappedFile();
MappedFile lastMappedFile = this.getLastMappedFile();
if (firstMappedFile != null && lastMappedFile != null) {
if (offset < firstMappedFile.getFileFromOffset() || offset >= lastMappedFile.getFileFromOffset() + this.mappedFileSize) {
LOG_ERROR.warn("Offset not matched. Request offset: {}, firstOffset: {}, lastOffset: {}, mappedFileSize: {}, mappedFiles count: {}",
offset,
firstMappedFile.getFileFromOffset(),
lastMappedFile.getFileFromOffset() + this.mappedFileSize,
this.mappedFileSize,
this.mappedFiles.size());
} else {
int index = (int) ((offset / this.mappedFileSize) - (firstMappedFile.getFileFromOffset() / this.mappedFileSize));
MappedFile targetFile = null;
try {
targetFile = this.mappedFiles.get(index);
} catch (Exception ignored) {
}
if (targetFile != null && offset >= targetFile.getFileFromOffset()
&& offset < targetFile.getFileFromOffset() + this.mappedFileSize) {
return targetFile;
}
for (MappedFile tmpMappedFile : this.mappedFiles) {
if (offset >= tmpMappedFile.getFileFromOffset()
&& offset < tmpMappedFile.getFileFromOffset() + this.mappedFileSize) {
return tmpMappedFile;
}
}
}
if (returnFirstOnNotFound) {
return firstMappedFile;
}
}
} catch (Exception e) {
log.error("findMappedFileByOffset Exception", e);
}
return null;
}
按时间戳查找消息
除了从指定 Offset 消费消息这种方式,消费者还有回溯到某个时间点开始消费的需求,这要求 RocketMQ 支持查询指定的 Timestamp 所在的 Offset,然后从这个 Offset 开始消费消息。
我们可以从 ConsumeQueue 的 GetOffsetInQueueByTime 方法直接了解按时间戳查找消息的具体实现。
消息是按时间先后写入的,ConsumeQueue 文件队列中的 CqUnit 也是按时间先后排列的,那么每个 MappedFile 都对应一段时间区间内的 CqUnit。从下面代码可以看出,我们可以先根据 Timestamp 找到其落在时间区间的 MappedFile,然后在该 MappedFile 里查找最接近该 Timestamp 的 CqUnit。
java
@Override
public long getOffsetInQueueByTime(final long timestamp, final BoundaryType boundaryType) {
MappedFile mappedFile = this.mappedFileQueue.getConsumeQueueMappedFileByTime(timestamp,
messageStore.getCommitLog(), boundaryType);
return binarySearchInQueueByTime(mappedFile, timestamp, boundaryType);
}
GetConsumeQueueMappedFileByTime 的具体实现主要分为两个部分:
-
找到每个 MappedFile 的 StartTimestamp 和 StopTimestamp,即 MappedFile 里第一个 CqUnit 对应消息的时间戳和最后一个 CqUnit 对应消息的时间戳,需要访问两次 CommitLog 来得到消息内容。
-
使用 Timestamp 和每个 MappedFile 的 StartTimestamp 和 StopTimestamp 比较。当 Timestamp 落在某个 MappedFile 的 StartTimestamp 和 StopTimestamp 区间内时,那么该 MappedFile 是下一步查找 CqUnit 的目标。
接下来,要按照二分查找法在该 MappedFile 中找到最接近 Timestamp 的 CqUnit。根据二分查找的法则,每次查找需要比较中间位置的 CqUnit 引用消息的存储时间和目标 Timestamp 以确定下一个查找区间,直至 CqUnit 满足最接近目标 Timestamp 的条件。要注意的是,获取 CqUnit 引用消息的存储时间需从 CommitLog 中读取消息。
基于 RocksDB 的优化方案
尽管基于文件的实现比较直观,但是当 Topic 队列达到一定数量后,会出现明显的性能和可用性问题。Topic 队列数量越多,代表着 ConsumeQueue 文件越多,产生的随机读写也就越多,这会影响系统整体的 IO 性能,导致出现生产消费 TPS 不断下降,延迟不断增高的趋势。在我们内部的测试环境和客户的生产环境中,我们都发现使用的队列数过多直接影响系统的可用性,而且我们无法通过不断升级 Broker 节点配置来消除这种影响,因此我们腾讯云 TDMQ RocketMQ 版在产品控制台上会限制客户可创建的 Topic 数量以确保消息服务的稳定性。
那么有没有办法能够解决上面的问题让服务能够承载更多的 Topic 呢?我们可以把 ConsumeQueue 提供的功能理解为使用 Topic 队列的 Offset 来找到 CqUnit,那么 Topic 队列和 Offset 构成了 Key,CqUnit 是 Value,是一个典型的 KV 使用场景。在单机 KV 存储的软件里,最著名的莫过于 RocksDB了,它被广泛使用于 Facebook,LinkedIn 等互联网公司的业务中。从下面的设计图看,RocksDB 基于 SSTable + MemTable 的实现能够提供高效写入和查找 KV 的能力,有兴趣的读者可以研究下RocksDB的具体实现(github.com/facebook/ro...%25EF%25BC%258C%25E8%25BF%2599%25E9%2587%258C%25E4%25B8%258D%25E5%25B1%2595%25E5%25BC%2580%25E8%25AF%25B4%25E6%2598%258E%25E3%2580%2582 "https://github.com/facebook/rocksdb/wiki/RocksDB-Overview)%EF%BC%8C%E8%BF%99%E9%87%8C%E4%B8%8D%E5%B1%95%E5%BC%80%E8%AF%B4%E6%98%8E%E3%80%82")

如果我们使用 RocksDB 读写 CqUnit,那么 ConsumeQueue 文件数量不会随着 Topic 队列的数量线性增长,便不必担心由此带来的 IO 开销。
下面我们来介绍如何使用 RocksDB 来实现 ConsumeQueue。
数据存储与格式
在基于 RocksDB 的实现里,RocketMQ 使用两个 ColumnFamily 来管理不同类型的数据,这里不熟悉 RocksDB 的读者可以将 ColumnFamily 视作 MySQL 里的 Table。
-
第一个 ColumnFamiliy,简称为 DefaultColumnFamily,用于管理 CqUnit 数据。
Key 的内容格式定义参考下图,其包含 Topic 名称、QueueId 和 ConsumeQueue 的 Offset。

Value 的内容格式,与前文中文件实现里的索引对象定义类似,但是多了一个消息存储时间的字段。

-
第二个 ColumnFamily,简称为 OffsetColumnFamily,用于管理 Topic 队列的 MaxOffset 和 MinOffset。
MaxOffset 是指 Topic 队列最新一条消息在 ConsumeQueue 中的 Offset,随着消息的新增而变化。MinOffset 是指 Topic 队列最早一条消息在 ConsumeQueue 中的 Offset,当消息过期被删除后发生变化。MaxOffset 和 MinOffset 确定消费者可读取消息的范围,在基于文件的实现里,通过访问 ConsumeQueue 文件队列里的队尾和队首文件得到这两个数值。而在 RocksDB 的实现里,我们单独保存这两个数值。
下图是 Key 的格式定义,其包含 Topic 名称、QueueId 以及用于标记是 MaxOffset 或 MinOffset 的字段。

Value 保存 ConsumeQueue的 Offset,以及该 Offset 对应消息在 CommitLog 的位置。

构建过程
ConsumeQueue 的 CqUnit 的构建过程与前文中基于文件的实现的过程一致,此处不再赘述,不同的是前文中 ReputMessageService 使用的 ConsumeQueueStore 被替换为 RocksDBConsumeQueueStore。在这个过程中,RocksDBConsumeQueueStore 主要完成两件事:
-
往 DefaultColumnFamily 写入消息对应的 CqUnit。
-
往 OffsetColumnFamily 更新消息对应 Topic 队列的 maxOffset。
ini
private boolean putMessagePosition0(List<DispatchRequest> requests) {
if (!this.rocksDBStorage.hold()) {
return false;
}
try (WriteBatch writeBatch = new WriteBatch(); WriteBatch lmqTopicMappingWriteBatch = new WriteBatch()) {
final int size = requests.size();
if (size == 0) {
return true;
}
long maxPhyOffset = 0;
for (int i = size - 1; i >= 0; i--) {
final DispatchRequest request = requests.get(i);
DispatchEntry entry = DispatchEntry.from(request);
dispatch(entry, writeBatch, lmqTopicMappingWriteBatch);
dispatchLMQ(request, writeBatch, lmqTopicMappingWriteBatch);
final int msgSize = request.getMsgSize();
final long phyOffset = request.getCommitLogOffset();
if (phyOffset + msgSize >= maxPhyOffset) {
maxPhyOffset = phyOffset + msgSize;
}
}
// put lmq topic Mapping to DB if there has mapping exist
if (lmqTopicMappingWriteBatch.count() > 0) {
// write max topicId and all the topicMapping as atomic write
ConfigHelperV2.stampMaxTopicSeqId(lmqTopicMappingWriteBatch, this.topicSeqIdCounter.get());
this.configStorage.write(lmqTopicMappingWriteBatch);
this.configStorage.flushWAL();
}
this.rocksDBConsumeQueueOffsetTable.putMaxPhyAndCqOffset(tempTopicQueueMaxOffsetMap, writeBatch, maxPhyOffset);
this.rocksDBStorage.batchPut(writeBatch);
this.rocksDBConsumeQueueOffsetTable.putHeapMaxCqOffset(tempTopicQueueMaxOffsetMap);
long storeTimeStamp = requests.get(size - 1).getStoreTimestamp();
if (this.messageStore.getMessageStoreConfig().getBrokerRole() == BrokerRole.SLAVE
|| this.messageStore.getMessageStoreConfig().isEnableDLegerCommitLog()) {
this.messageStore.getStoreCheckpoint().setPhysicMsgTimestamp(storeTimeStamp);
}
this.messageStore.getStoreCheckpoint().setLogicsMsgTimestamp(storeTimeStamp);
notifyMessageArriveAndClear(requests);
return true;
} catch (Exception e) {
ERROR_LOG.error("putMessagePosition0 failed.", e);
return false;
} finally {
tempTopicQueueMaxOffsetMap.clear();
consumeQueueByteBufferCacheIndex = 0;
offsetBufferCacheIndex = 0;
this.rocksDBStorage.release();
}
}
按 offset 查找消息
在前文中我们已介绍过按 Offset 查找消息的流程,RocksDB 的实现里,DefaultMessageStore 的 GetMessage 方法中使用的 ConsumeQueue 被替换成了 RocksDBConsumeQueue。这里我们只关注其 IterateFrom 方法的实现,以下是该方法的代码片段。
ini
public ReferredIterator<CqUnit> iterateFrom(String group, long startIndex, int count) throws RocksDBException {
long maxCqOffset = getMaxOffsetInQueue();
if (startIndex < maxCqOffset) {
int num = Math.min((int) (maxCqOffset - startIndex), count);
if (MixAll.isLmq(topic) || PopAckConstants.isStartWithRevivePrefix(topic)) {
return iterateUseMultiGet(startIndex, num);
}
if (num <= messageStore.getMessageStoreConfig().getUseScanThreshold()) {
return iterateUseMultiGet(startIndex, num);
}
if (!messageStore.getMessageStoreConfig().isEnableScanIterator()) {
return iterateUseMultiGet(startIndex, num);
}
final String scannerIterKey = group + "-" + Thread.currentThread().getId();
ScanRocksDBConsumeQueueIterator scanRocksDBConsumeQueueIterator = scanIterators.get(scannerIterKey);
if (scanRocksDBConsumeQueueIterator == null) {
if (RocksDBConsumeQueue.this.messageStore.getMessageStoreConfig().isEnableRocksDBLog()) {
LOG.info("new ScanIterator Group-threadId{} Topic:{}, queueId:{},startIndex:{}, count:{}",
scannerIterKey, topic, queueId, startIndex, num);
}
ScanRocksDBConsumeQueueIterator newScanIterator = new ScanRocksDBConsumeQueueIterator(startIndex, num);
scanRocksDBConsumeQueueIterator = scanIterators.putIfAbsent(scannerIterKey, newScanIterator);
if (scanRocksDBConsumeQueueIterator == null) {
scanRocksDBConsumeQueueIterator = newScanIterator;
} else {
newScanIterator.closeRocksIterator();
}
return scanRocksDBConsumeQueueIterator;
}
if (!scanRocksDBConsumeQueueIterator.isValid()) {
scanRocksDBConsumeQueueIterator.closeRocksIterator();
if (RocksDBConsumeQueue.this.messageStore.getMessageStoreConfig().isEnableRocksDBLog()) {
LOG.info("new ScanIterator not valid Group-threadId{} Topic:{}, queueId:{},startIndex:{}, count:{}",
scannerIterKey, topic, queueId, startIndex, count);
}
ScanRocksDBConsumeQueueIterator newScanIterator = new ScanRocksDBConsumeQueueIterator(startIndex, num);
scanIterators.put(scannerIterKey, newScanIterator);
return newScanIterator;
} else {
if (RocksDBConsumeQueue.this.messageStore.getMessageStoreConfig().isEnableRocksDBLog()) {
LOG.info("new ScanIterator valid then reuse Group-threadId{} Topic:{}, queueId:{},startIndex:{}, count:{}",
scannerIterKey, topic, queueId, startIndex, count);
}
scanRocksDBConsumeQueueIterator.reuse(startIndex, num);
return scanRocksDBConsumeQueueIterator;
}
}
return null;
}
在上面的代码中,首先通过 GetMaxOffsetInQueue 方法获取该 Topic 队列 ConsumeQueue 的 MaxOffset,MaxOffset 结合 Count 参数共同指定 Iterator 扫描的 Offset 区间。
然后,我们可以看到 IterateFrom 方法中根据不同的条件判断分支返回不同类型的 Iterator 类对象,RocksDBConsumeQueueIterator 和 ScanRocksDBConsumeQueueIterator。下面是 IteratorUseMultiGet 方法中创建 RocksDBConsumeQueueIterator 对象的调用链中最核心的代码, RangeQuery 方法根据 StartIndex 和 Num 构建了要查询的 Key 列表,然后调用 RocksDB 的 MultiGet 方法查询到 Key 列表对应的 Value 列表,RocksDBConsumeQueueIterator 使用该 Value 列表上提供迭代器的功能。
ini
public List<ByteBuffer> rangeQuery(final String topic, final int queueId, final long startIndex,
final int num) throws RocksDBException {
final byte[] topicBytes = topic.getBytes(StandardCharsets.UTF_8);
final List<ColumnFamilyHandle> defaultCFHList = new ArrayList<>(num);
final ByteBuffer[] resultList = new ByteBuffer[num];
final List<Integer> kvIndexList = new ArrayList<>(num);
final List<byte[]> kvKeyList = new ArrayList<>(num);
for (int i = 0; i < num; i++) {
ByteBuffer keyBB;
// must have used topicMapping
if (this.topicMappingTable != null) {
Long topicId = topicMappingTable.get(topic);
if (topicId == null) {
throw new RocksDBException("topic: " + topic + " topicMapping not existed error when rangeQuery");
}
keyBB = buildCQFixKeyByteBuffer(topicId, queueId, startIndex + i);
} else {
keyBB = buildCQKeyByteBuffer(topicBytes, queueId, startIndex + i);
}
kvIndexList.add(i);
kvKeyList.add(keyBB.array());
defaultCFHList.add(this.defaultCFH);
}
int keyNum = kvIndexList.size();
if (keyNum > 0) {
List<byte[]> kvValueList = this.rocksDBStorage.multiGet(defaultCFHList, kvKeyList);
final int valueNum = kvValueList.size();
if (keyNum != valueNum) {
throw new RocksDBException("rocksdb bug, multiGet");
}
for (int i = 0; i < valueNum; i++) {
byte[] value = kvValueList.get(i);
if (value == null) {
continue;
}
ByteBuffer byteBuffer = ByteBuffer.wrap(value);
resultList[kvIndexList.get(i)] = byteBuffer;
}
}
final int resultSize = resultList.length;
List<ByteBuffer> bbValueList = new ArrayList<>(resultSize);
for (int i = 0; i < resultSize; i++) {
ByteBuffer byteBuffer = resultList[i];
if (byteBuffer == null) {
break;
}
bbValueList.add(byteBuffer);
}
return bbValueList;
}
ScanRocksDBConsumeQueueIterator 则是使用了 RocksDB 的 Iterator 特性(github.com/facebook/ro... MultiGet,其拥有更好的性能。
下面是 ScanQuery 的实现,代码比较简洁,指定 Iterator 的 BeginKey 和 UpperKey,再调用 RocksDB 的 API 返回 Iterator 对象。
BeginKey 是通过 Topic 队列信息和 StartIndex 参数构造的 Key。UpperKey 的构造比较精妙,还记得在 DefaultColumnFamily 介绍里 Key 的格式吧,Key 的倒数第二个部分是 CTRL_1,作为 CqUnit 的 Key 时是个常量,Unicode 值为1。构造 UpperKey 时,CTRL_1 被替换为 CTRL_2, Uinicode 值为2,这样能保证 Iterator 扫描区间的上限不超过 Topic 队列 Offset 的理论最大值。
java
public RocksIterator scanQuery(final String topic, final int queueId, final long startIndex,
ReadOptions scanReadOptions) throws RocksDBException {
final ByteBuffer beginKeyBuf = getSeekKey(topic, queueId, startIndex);
if (scanReadOptions.iterateUpperBound() == null) {
ByteBuffer upperKeyForInitScanner = getUpperKeyForInitScanner(topic, queueId);
byte[] buf = new byte[upperKeyForInitScanner.remaining()];
upperKeyForInitScanner.slice().get(buf);
scanReadOptions.setIterateUpperBound(new Slice(buf));
}
RocksIterator iterator = this.rocksDBStorage.scan(scanReadOptions);
iterator.seek(beginKeyBuf.slice());
return iterator;
}
按时间戳查找消息
与基于文件的实现类似,使用 RocksDB 来按时间戳查找消息,首先也需要确定 Topic 队列 ConsumeQueue 的 MinOffset 和 MaxOffset,然后使用二分查找法查找到最接近指定时间戳的 CqUnit。
ini
@Override
public long getOffsetInQueueByTime(String topic, int queueId, long timestamp,
BoundaryType boundaryType) throws RocksDBException {
final long minPhysicOffset = this.messageStore.getMinPhyOffset();
long low = this.rocksDBConsumeQueueOffsetTable.getMinCqOffset(topic, queueId);
Long high = this.rocksDBConsumeQueueOffsetTable.getMaxCqOffset(topic, queueId);
if (high == null || high == -1) {
return 0;
}
return this.rocksDBConsumeQueueTable.binarySearchInCQByTime(topic, queueId, high, low, timestamp,
minPhysicOffset, boundaryType);
}
与基于文件的实现不同的是,由于 RocksDB 的 CqUnit 里保存了消息存储的时间,比较时间戳时不必再读取 CommitLog 获取消息的存储时间,这样提升了查找的时间效率。
总结及展望
本文和读者分享了 ConsumeQueue 的设计与实现,着重介绍其在消息消费场景的应用。鉴于篇幅限制,仍有许多细节未涉及,比如 ConsumeQueue 的容错恢复、过期清理机制等。近些年,RocketMQ 往 Serveless 化方向发展,在5.0的架构里,已经将计算和存储分离,Proxy 作为计算集群,Broker 作为存储集群。从实际应用上来讲,Broker 作为存储角色,从计算的角色释放出来之后,多出的性能和资源应该用于承载更多的 Topic,而基于文件的ConsumeQueue 实现限制了 Broker 的上限,因此我们需要 RocksDB 的实现方案来解决这个问题。
目前,腾讯云的 TDMQ RabbitMQ Serveless、MQTT 产品均基于 RocketMQ 5.0 的架构部署运行,Broker 集群已采用 RocksDB 的方案支持百万级的 Topic 队列,满足 RabbitMQ 和 MQTT 协议需要大量 Topic 支持的场景。在腾讯云 RocketMQ 5.0 的产品上,我们开始逐渐在新版本中灰度开启该方案,为客户提供更好性能更稳定的消息队列服务。