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

前言

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

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

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

一、Lite Topic 是什么

先看一个场景:一个订单 Topic OrderTopic,需要按用户维度拆分成海量子队列,比如 user_10086user_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 = OrderTopicliteTopic = 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 的判断逻辑是:

  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 去真正读消息:

java 复制代码
GetMessageResult result = getMessage(clientHost, group, lmqName, consumeOffset, maxNum);

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

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

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

和普通 Topic 一样,每个 lmqNameConsumerOffsetManager 里有自己独立的消费位点(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 能支撑海量子队列的关键。

相关推荐
步行cgn1 小时前
MyBatis 查询结果字段为 null?一文讲透下划线命名与驼峰映射问题
后端
技术长镜头1 小时前
别再只会“加索引”:从磁盘页到 B+Tree,彻底理解 MySQL 索引的设计与运行
后端·mysql
生锈的键盘1 小时前
gRPC 多路复用全链路拆解:从客户端到服务端,中间隔着 ELB 和 Nginx 到底是怎么玩的?
后端
laity171 小时前
python发光表白爱心(从零到一实现)
前端·后端
用户921080262861 小时前
2. Java 基础该怎么学,先抓业务开发真正用得上的部分
后端
AI老猿博士1 小时前
SpringBoot自动配置揭秘
后端
程序员爱钓鱼1 小时前
Go for 循环详解
后端·面试·go
程序员爱钓鱼1 小时前
Rust impl详解:为Struct定义方法与关联函数
前端·后端·rust
weixin_431600441 小时前
NestJS 入门(9):连上数据库,SQL 写在哪?
数据库·后端·sql·学习·nest.js