前言
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):
ini
lmqName = %LMQ%$parentTopic$liteTopic
举例:parentTopic = OrderTopic,liteTopic = user_10086,拼出来就是:
shell
%LMQ%$OrderTopic$user_10086
这个字符串本身就是一个 lmq 名字,broker 内部把它当作一个"虚拟 Topic"的 queue 0 来处理。
发消息时(SendMessageProcessor):
java
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 的结构:
scss
Topic长度(4B) + CTRL_1(1B) + Topic字节(nB) + CTRL_1(1B) + "max"或"min"(3B) + CTRL_1(1B) + QueueId(4B)
Value 的结构:
scss
CommitLog物理偏移量(8B) + ConsumeQueue逻辑偏移量(8B)
举例:%LMQ%$OrderTopic$user_10086 这个 lite topic 目前已经有 6 条消息(逻辑 offset 从 0 到 5),最新一条写在 CommitLog 物理偏移 88888 处。那么它的 "max" 记录就是:
ini
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 的引用:
java
maxCqOffsetTable = ... // 直接引用 RocksDBConsumeQueueOffsetTable 里的 topicQueueMaxCqOffset
之后遍历所有 lite topic,只需要遍历这个内存 Map 一次,过滤出以 %LMQ%$ 开头的 key 即可------不用扫 RocksDB 磁盘,也不用扫文件系统,成本很低,适合高频轮询。
2. 怎么判断过期
AbstractLiteLifecycleManager 是一个 ServiceThread,启动后按 brokerConfig.getLiteTtlCheckInterval() 配置的间隔循环执行 cleanExpiredLiteTopic()。
对每个 lite topic,isLiteTopicExpired 的判断逻辑是:
- 拿到这个队列最新一条消息的写入时间
latestStoreTime; - 算出距今多久没有新消息了:
inactiveTime = 当前时间 - latestStoreTime; - 取父 Topic 上配置的过期时间
topicConfig.getLiteTopicExpiration()(单位分钟); - 如果
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 去真正读消息:
java
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 为例:
- 发消息 :客户端发送一条消息到
OrderTopic,带上属性__LITE_TOPIC = user_10086。 - broker 分发 :
SendMessageProcessor拼出lmqName = %LMQ%$OrderTopic$user_10086,消息写入 CommitLog 一次,同时为这个 lmq 建一条 ConsumeQueue 索引。 - 索引落地 :这条索引以 KV 形式写进 RocksDB 的 offset 表,同时更新内存缓存
topicQueueMaxCqOffset。 - 通知消费者 :
LiteEventDispatcher发现有新消息写入%LMQ%$OrderTopic$user_10086,把这个lmqName塞进正在订阅它的消费者consumer-A的事件队列,并唤醒其长轮询。 - 消费拉取 :
consumer-A的下一次 Pop 请求从事件队列里取出这个lmqName,按自己的消费位点去messageStore读消息,读完后位点前移。 - 持续发送 :只要
user_10086一直有新订单消息,latestStoreTime就不断刷新,这个子队列一直存活。 - 归于沉寂 :某天之后
user_10086不再下单,latestStoreTime停止刷新。 - 定时巡检 :
RocksDBLiteLifecycleManager每隔liteTtlCheckInterval扫一次内存 Map,发现user_10086已经 35 分钟没有新消息,超过了OrderTopic配置的 30 分钟 TTL。 - 回收 :判定过期,清理消费位点、订阅关系,并删除 RocksDB 里这几条 KV。这个子队列彻底消失,直到
user_10086下次下单时被重新自动创建。
整个过程中,Topic 数量、broker 元数据都没有膨胀------真正变化的只是 RocksDB 里几条 KV 的增删,这正是 Lite Topic 能支撑海量子队列的关键。