深度解析 RocketMQ 核心组件:ConsumeQueue 的设计与实现

导语

在分布式消息队列 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 以及该批次读取的最大消息数量。下面截取了部分源码展示实现的基本思路:

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

上述代码片段的要点:

  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 中的位置,从该位置读取文件剩余的内容。

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

  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的具体实现(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 主要完成两件事:

  1. 往 DefaultColumnFamily 写入消息对应的 CqUnit。

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

相关推荐
量子位3 小时前
直击 CVPR 现场:中国玩家展商面前人从众,腾讯 40 + 篇接收论文亮眼
ai编程·腾讯
腾讯云中间件3 小时前
限流系列之二:TDMQ CKafka 版限流方案详解及最佳实践
kafka·消息队列·腾讯
腾讯云中间件4 小时前
Apache RocketMQ 消息过滤的实现原理与腾讯云的使用实践
消息队列·rocketmq·腾讯
腾讯云中间件4 小时前
限流系列之三:TDMQ for Apache Pulsar 限流技术深度解析
消息队列·腾讯
腾讯云中间件5 小时前
TDMQ for RocketMQ MCP Server 实战,一站式查询提升运维效率
消息队列·rocketmq·腾讯
腾讯云中间件5 小时前
限流系列之四:TDMQ RocketMQ 版限流机制详解与实践教程
消息队列·rocketmq·腾讯
腾讯云中间件8 小时前
TDMQ CKafka 版事务:分布式环境下的消息一致性保障
kafka·消息队列·腾讯
腾讯云中间件8 小时前
TDMQ RocketMQ 版订阅关系一致性原理与实践
消息队列·rocketmq·腾讯
没逻辑1 天前
主流消息队列模型与选型对比(RabbitMQ / Kafka / RocketMQ)
后端·消息队列