RocketMQ Lite Topic 流程梳理与设计解读

3 阅读5分钟

前言

RocketMQ 里的 Topic 是有成本的:每个 Topic 都要有自己的元数据、重试队列、路由信息,broker 和 nameserver 都要为此维护状态。可是很多业务场景需要按用户、按设备这类维度拆分出海量的小队列——量级可能是几十万甚至上百万,远超普通 Topic 的承载能力。

Lite Topic 就是 RocketMQ 为这类场景设计的轻量方案:它挂在一个父 Topic 下,创建和销毁的代价都很低,能够支撑海量子队列的动态生成与自动回收。要做到这一点,它依赖了两样东西:一是复用已有的 LMQ 多路分发机制来实现消息的挂载,二是用 RocksDB 替代传统的 mmap 文件存储索引,把"建一个队列"的成本从"建一组文件"降到"写几条 KV"。

下面从命名和发送机制讲起,再看 RocksDB 具体存了什么、生命周期如何管理,最后用一个完整例子把全流程串起来。

一、Lite Topic 是什么

先看一个场景:一个订单 Topic OrderTopic,需要按用户维度拆分成海量子队列,比如 user_10086、user_10087 ……可能有几十万甚至上百万个。

如果每个用户都建一个真正的 Topic,Topic 数量会爆炸——每个 Topic 都要有独立的元数据、独立的重试 Topic、独立的路由信息,broker 和 nameserver 都扛不住。

Lite Topic 就是为了解决这个问题:它是挂在一个父 Topic 下的轻量子队列,没有自己的重试 Topic、没有独立的路由元数据,创建和销毁都很轻。发消息的时候只要在父 Topic OrderTopic 上发送,并带上一个 liteTopic 标识(比如 user_10086),broker 就会自动把这条消息也投递到这个子队列里。

二、Lite Topic 的本质:一次"多路分发"

RocketMQ 里本来就有一种叫 LMQ(Light Message Queue)的机制:一条消息写入 CommitLog 后,可以同时往多个逻辑队列里各建一条索引,这叫"多路分发"(multi-dispatch)。Lite Topic 就是直接复用了这套机制。

命名规则在 LiteUtil 里(common/lite/LiteUtil.java):

lmqName = %LMQ%$parentTopic$liteTopic

举例:parentTopic = OrderTopic,liteTopic = user_10086,拼出来就是:

%LMQ%$OrderTopic$user_10086

这个字符串本身就是一个 lmq 名字,broker 内部把它当作一个"虚拟 Topic"的 queue 0 来处理。

发消息时(SendMessageProcessor):

String liteTopic = oriProps.get(MessageConst.PROPERTY_LITE_TOPIC);
if (StringUtils.isNotEmpty(liteTopic)) {
    String lmqName = LiteUtil.toLmqName(requestHeader.getTopic(), liteTopic);
    oriProps.put(MessageConst.PROPERTY_INNER_MULTI_DISPATCH, lmqName);
}

也就是说:客户端发消息时带上 __LITE_TOPIC = user_10086 这个属性,broker 算出 lmqName,塞进"多路分发"属性里。消息本身只在 CommitLog 里写一份,分发线程会额外为 %LMQ%$OrderTopic$user_10086 建一条 ConsumeQueue 索引,指向同一条物理消息——相当于同一份文件在两个目录下各放了一个快捷方式,而不是复制了两份。

三、为什么要接入 RocksDB

问题来了:假设有 50 万个用户,就是 50 万个 lite topic。RocketMQ 默认的 ConsumeQueue 是基于内存映射文件(mmap)实现的——每个 Topic 的每个队列都要维护自己的一组文件。50 万个队列意味着 50 万套文件、文件句柄和内存映射,创建和删除的开销都极高,系统撑不住。

RocksDB 版本的 ConsumeQueue(RocksDBConsumeQueueStore)把索引存成 KV 键值对,不再有"一个队列一组文件"的概念。创建一个 lite topic 只是往 RocksDB 写几条 KV,删除也只是删几条 KV,没有文件系统层面的开销,天然适合支撑百万级的轻量队列。这就是 Lite Topic 要依赖 RocksDB CQ 的根本原因。

四、RocksDB 里具体存了什么

RocksDBConsumeQueueOffsetTable 只记录每个队列的最小/最大 offset,不是每条消息都存一条索引(每条消息的索引在另一张表 RocksDBConsumeQueueTable 里,这里只看 lite topic 关心的部分)。

Key 的结构:

Topic长度(4B) + CTRL_1(1B) + Topic字节(nB) + CTRL_1(1B) + "max"或"min"(3B) + CTRL_1(1B) + QueueId(4B)

Value 的结构:

CommitLog物理偏移量(8B) + ConsumeQueue逻辑偏移量(8B)

举例:%LMQ%$OrderTopic$user_10086 这个 lite topic 目前已经有 6 条消息(逻辑 offset 从 0 到 5),最新一条写在 CommitLog 物理偏移 88888 处。那么它的 "max" 记录就是:

key   = (topic="%LMQ%$OrderTopic$user_10086", queueId=0, max)
value = (phyOffset=88888, cqOffset=5)

每次新消息进来,updateCqOffset 就把这条 KV 覆盖成新的 offset。

为了避免每次都去查 RocksDB,还有一份内存缓存 topicQueueMaxCqOffset(一个 ConcurrentHashMap<String, Long>,key 是 topic-queueId),热数据都在内存里,查询基本不落盘。

五、生命周期管理:怎么发现、怎么判断过期、怎么删除

Lite Topic 是自动创建的,也需要自动清理——不然用户一多,废弃的子队列会越堆越多。这件事由 RocksDBLiteLifecycleManager 负责,它是 AbstractLiteLifecycleManager 的一个实现。

1. 怎么发现所有的 lite topic

RocksDBLiteLifecycleManager 用反射直接拿到了上面那个内存 Map topicQueueMaxCqOffset 的引用:

maxCqOffsetTable = ... // 直接引用 RocksDBConsumeQueueOffsetTable 里的 topicQueueMaxCqOffset

之后遍历所有 lite topic,只需要遍历这个内存 Map 一次,过滤出以 %LMQ%$ 开头的 key 即可——不用扫 RocksDB 磁盘,也不用扫文件系统,成本很低,适合高频轮询。

2. 怎么判断过期

AbstractLiteLifecycleManager 是一个 ServiceThread,启动后按 brokerConfig.getLiteTtlCheckInterval() 配置的间隔循环执行 cleanExpiredLiteTopic()。

对每个 lite topic,isLiteTopicExpired 的判断逻辑是:

  1. 拿到这个队列最新一条消息的写入时间 latestStoreTime;
  2. 算出距今多久没有新消息了:inactiveTime = 当前时间 - latestStoreTime;
  3. 取父 Topic 上配置的过期时间 topicConfig.getLiteTopicExpiration()(单位分钟);
  4. 如果 inactiveTime 超过这个配置值,就判定为过期。

举个具体例子:OrderTopic 配置的 liteTopicExpiration = 30 分钟;user_10086 这个子队列最后一条消息是 14:00 写入的。如果当前时间是 14:35,inactiveTime = 35 分钟 > 30 分钟,就会被判定过期,进入清理队列。如果只过了 20 分钟,则不会被清理,继续保留。

3. 怎么删除

判定过期后,deleteLmq(parentTopic, lmqName) 会做几件事:

  • 清空这个子队列在各个订阅组下的消费位点(ConsumerOffsetManager);
  • 清掉订阅关系登记(LiteSubscriptionRegistry);
  • 调用 messageStore.deleteTopics(...) 删除这个虚拟 Topic 的 ConsumeQueue——在 RocksDB 场景下,这一步本质就是删掉 RocksDBConsumeQueueOffsetTable 里那几条 min/max KV,成本很低,不涉及物理文件删除。

六、消费端(Pop)怎么读取 Lite Topic

Lite Topic 只支持 Pop 模式消费,由 PopLiteMessageProcessor 处理。消费者发起 Pop 请求时,带的是 parentTopic(比如 OrderTopic)和消费组 group,并不知道自己名下具体挂了哪些 lmqName——一个消费者进程可能同时负责几千甚至上万个用户分片,不可能每次 Pop 都去逐个子队列查一遍"有没有新消息",开销撑不住。

RocketMQ 的做法是把"发现新消息"这件事做成事件驱动,由 LiteEventDispatcher 负责:

1. 消息一到,就把事件推给对应的消费者

还是接着 user_10086 的例子:消息写入 CommitLog、完成多路分发之后,broker 会调用 LiteEventDispatcher.dispatch(group, lmqName, ...)。这个方法会找到当前订阅了 %LMQ%$OrderTopic$user_10086 的消费者(比如 clientId = consumer-A),把这个 lmqName 塞进 consumer-A 在 broker 内存里维护的一个事件队列(ClientEventSet),再唤醒它挂起的长轮询请求。

也就是说,broker 端为每个活跃的 clientId 维护一份"这个客户端有哪些子队列来了新消息"的待处理列表,消息一到就主动记一笔,而不是等消费者来问。

2. Pop 请求只处理"被通知过"的子队列

PopLiteMessageProcessor.popByClientId 收到 Pop 请求后,调用 liteEventDispatcher.getEventIterator(clientId) 拿到 consumer-A 事件队列里的 lmqName 列表,逐个调用 popLiteTopic 去真正读消息:

GetMessageResult result = getMessage(clientHost, group, lmqName, consumeOffset, maxNum);

这里的 getMessage 最终就是普通的 messageStore.getMessage(group, lmqName, 0, offset, ...)——和读一个真实 Topic 的某个 queue 没有区别,只是 topic 参数换成了 lmqName。取够 maxNum 条或者事件队列取空为止。

换句话说:Pop 请求不会扫描消费者名下的全部子队列,只处理事件队列里明确"有新消息"的那几个,这正是能扛住一个消费者同时挂海量子队列的关键。

3. 每个子队列独立记消费位点

和普通 Topic 一样,每个 lmqName 在 ConsumerOffsetManager 里有自己独立的消费位点(group + lmqName + queueId(固定为0) 作为 key)。getPopOffset 会先查这个位点,查不到就按 ConsumeInitMode.MAX 初始化。

4. 兜底:全量扫描补发事件

事件驱动依赖内存队列,进程重启、事件队列满了等情况下可能会丢事件。为此还有一个兜底的全量扫描 doFullDispatchForClient:遍历这个消费者订阅的所有 lmqName,逐个比较 liteLifecycleManager.getMaxOffsetInQueue(lmqName)(子队列当前最大 offset)和 consumerOffsetManager.queryOffset(group, lmqName, 0)(消费位点),落后的就重新塞回事件队列。这一步能做到低成本,同样是因为第五节提到的内存 Map(RocksDBLiteLifecycleManager 里的 maxCqOffsetTable)已经把所有子队列的最大 offset 缓存在内存里,不需要逐个查 RocksDB。

七、串起来看一遍完整流程

以 OrderTopic 下的用户 user_10086 为例:

  1. 发消息:客户端发送一条消息到 OrderTopic,带上属性 __LITE_TOPIC = user_10086。
  2. broker 分发:SendMessageProcessor 拼出 lmqName = %LMQ%$OrderTopic$user_10086,消息写入 CommitLog 一次,同时为这个 lmq 建一条 ConsumeQueue 索引。
  3. 索引落地:这条索引以 KV 形式写进 RocksDB 的 offset 表,同时更新内存缓存 topicQueueMaxCqOffset。
  4. 通知消费者:LiteEventDispatcher 发现有新消息写入 %LMQ%$OrderTopic$user_10086,把这个 lmqName 塞进正在订阅它的消费者 consumer-A 的事件队列,并唤醒其长轮询。
  5. 消费拉取:consumer-A 的下一次 Pop 请求从事件队列里取出这个 lmqName,按自己的消费位点去 messageStore 读消息,读完后位点前移。
  6. 持续发送:只要 user_10086 一直有新订单消息,latestStoreTime 就不断刷新,这个子队列一直存活。
  7. 归于沉寂:某天之后 user_10086 不再下单,latestStoreTime 停止刷新。
  8. 定时巡检:RocksDBLiteLifecycleManager 每隔 liteTtlCheckInterval 扫一次内存 Map,发现 user_10086 已经 35 分钟没有新消息,超过了 OrderTopic 配置的 30 分钟 TTL。
  9. 回收:判定过期,清理消费位点、订阅关系,并删除 RocksDB 里这几条 KV。这个子队列彻底消失,直到 user_10086 下次下单时被重新自动创建。

整个过程中,Topic 数量、broker 元数据都没有膨胀——真正变化的只是 RocksDB 里几条 KV 的增删,这正是 Lite Topic 能支撑海量子队列的关键。