导语

在分布式消息队列 RocketMQ 中,ConsumeQueue(消费队列) 是消息消费的核心组件之一。它作为  CommitLog 的索引机制,帮助消费者快速定位并拉取消息。如果没有 ConsumeQueue,消费者将无法高效地从海量消息中筛选出自己订阅的数据。

本文将基于 RocketMQ 5.0 源码,深入探讨 ConsumeQueue 的设计原理与实现细节。

图片

为什么需要 ConsumeQueue?

在深入探讨 ConsumeQueue 之前,我们有必要先了解 RocketMQ 的消息写入和存储方式。

CommitLog 是 RocketMQ 的消息存储模块,用户生产的所有消息都持久化存储在该模块中,它具备两个特点:

  1. 使用的持久化存储是一个文件队列,文件保存于指定目录下,每个文件的大小是固定的,通常是1GB。

  2. 只有一个文件可写入,且仅支持追加写,文件写满后自动切换至新的文件。

RocketMQ 设计者出于写入优先的考虑,没有为不同 Topic 队列的消息分配不同的存储文件,而是将消息直接写入 CommitLog,不同 Topic 的消息混合分布在 CommitLog 的文件中。

图片

从上图中可以看出,尽管消息的写入非常高效,但是消费者需要按照其订阅的 Topic 来从 CommitLog 中读取该 Topic 的消息,显而易见,RocketMQ 需要一种索引机制来快速读取指定 Topic 队列的消息,这正是 ConsumeQueue 要做的事情。

图片

ConsumeQueue 的设计原理

ConsumeQueue 作为 RocketMQ 的消息索引枢纽,其设计核心在于高效映射逻辑队列与物理存储。我们通过下面的图示来介绍 ConsumeQueue 的核心设计:

图片

  1. 每个 Topic 队列有其对应的唯一的 ConsumeQueue,当一条消息写入到 CommitLog 后,RocketMQ 会构建该消息的索引,按异步方式将其写入到对应 Topic 队列的 ConsumeQueue 中。使用索引可以快速定位到消息在 CommitLog 文件的位置并读取它。

  2. 消息索引对象在 ConsumeQueue 中的位置被称为 Offset,是个从0开始的序号数,maxOffset 即 ConsumeQueue 索引的最大 Offset,会随着新消息的写入递增。

  3. 基于这个设计,消费者通过与 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 以及该批次读取的最大消息数量。下面截取了部分源码展示实现的基本思路:

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

上述代码片段的要点:

  1. Topic 队列的 ConsumeQueue 的 IterateFrom 方法依据 Offset 生成一个 Iterator对象。

  2. 在 Iterator 有效的情况,不断从 Iterator 拉取 CqUnit 对象,即按 Offset 顺序读取 CqUnit。

  3. 使用 CqUnit 对象中的 OffsetPy 和 SizePy 从 CommitLog 中读取消息内容,返回给消费者。

接下来,我们介绍 ConsumeQueue 的 IterateFrom 方法是如何读取 CqUnit 的。从下面的源码中可以看到,GetIndexBuffer 方法先从 MappedFileQueue 中找到 Offset 所在的 MappedFile,然后找到 Offset 在 MappedFile 中的位置,从该位置读取文件剩余的内容。

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,进行访问。

    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。

    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。

@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 的具体实现主要分为两个部分:

  1. 找到每个 MappedFile 的 StartTimestamp 和 StopTimestamp,即 MappedFile 里第一个 CqUnit 对应消息的时间戳和最后一个 CqUnit 对应消息的时间戳,需要访问两次 CommitLog 来得到消息内容。

  2. 使用 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的具体实现(https://github.com/facebook/rocksdb/wiki/RocksDB-Overview),这里不展开说明。

图片

如果我们使用 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 主要完成两件事:

  1. 往 DefaultColumnFamily 写入消息对应的 CqUnit。
  2. 往 OffsetColumnFamily 更新消息对应 Topic 队列的 maxOffset。
    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 方法的实现,以下是该方法的代码片段。

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 列表上提供迭代器的功能。

   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 特性(https://github.com/facebook/rocksdb/wiki/Iterator),相比 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 的理论最大值。

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。

    @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 的产品上,我们开始逐渐在新版本中灰度开启该方案,为客户提供更好性能更稳定的消息队列服务。

文章来源于腾讯云开发者社区,点击查看原文