mqtt-plus 架构解析(七):动态订阅与重连恢复,为什么能走同一条协调路径
摘要
在很多 MQTT 项目里,动态订阅和重连恢复往往是两套逻辑:一套负责运行时变化,一套负责断线后的重新订阅。mqtt-plus 当前的做法更收敛一些,它把静态 listener 订阅和运行时动态订阅都纳入统一协调模型里:动态变更先通过 MqttSubscriptionRefreshEventListener 写入 MqttSubscriptionManager 并立即作用到当前 adapter,而 MqttSubscriptionReconciler 则在连接建立时统一回放静态和动态订阅。本文会结合真实源码,拆解这条链路为什么成立,以及它当前的边界在哪里。
项目地址
项目地址:
https://github.com/mqttplus/mqtt-plus
配套的示例工程:
https://github.com/mqttplus/mqtt-plus-examples
如果你对这个方向感兴趣,欢迎关注、试用,也欢迎一起交流 issue 和 PR。
如果这篇文章对你有帮助,欢迎点赞、收藏,也欢迎给项目一个 Star。
到了第 7 篇,问题开始进入"运行态稳定性"这一层。
前面第 6 篇讲的是:一个应用如何同时持有多个 broker 连接实例。
但只要连接是真实存在的,就一定会碰到三类变化:
- 应用启动,首次建立连接
- 运行过程中新增或删除订阅
- 连接中断后重新建立,需要恢复订阅
很多项目在这里会慢慢长成三套分离逻辑:
- 启动时订阅一套
- 动态刷新一套
- 重连再补一套
而 mqtt-plus 当前的设计更克制:
它没有把这三件事完全拆开,而是让"动态变化的订阅集合"与"连接建立时的统一回放"协作,从而收敛成一条协调路径。
一、这篇文章到底想回答什么?
这一篇只回答三个问题:
- 动态订阅变更是如何被记录并立即应用的
- 重连之后,框架如何恢复静态 listener 订阅和运行时新增订阅
- 为什么首次连接、运行时刷新和重连恢复,本质上可以共享同一个协调模型
如果只记住一句话,那就是:
mqtt-plus 把"订阅变化"存进
MqttSubscriptionManager,把"连接建立后的恢复"交给MqttSubscriptionReconciler,于是运行时变化和重连恢复自然被串到了一起。
二、先看整条协调链路
先把三个阶段放在一张图里看清楚。
Application startup or reconnect
Adapter connected
MqttSubscriptionReconciler.onConnected(brokerId)
Replay static listener subscriptions
Replay dynamic subscriptions from MqttSubscriptionManager
Adapter.subscribe(...)
Running state
MqttSubscriptionRefreshEvent
MqttSubscriptionRefreshEventListener
Update MqttSubscriptionManager
Apply subscribe/unsubscribe to active adapter immediately
这张图里最关键的是两条线:
- 连接建立时,统一做一次"恢复式回放"
- 运行时变化时,既更新内存中的动态订阅集合,也立即作用到当前 adapter
于是系统的行为就会变得很稳定:
- 当前连接能立刻生效
- 将来重连还能自动恢复
这正是 README 里那句非常关键的话背后的实现含义:
动态订阅刷新会立即应用到当前 adapter,同时也会保留下来,用于未来重连恢复。
三、动态订阅变更,为什么不是直接改 adapter 就结束?
这一层由 MqttSubscriptionRefreshEventListener 负责。
它监听的是 MqttSubscriptionRefreshEvent,事件里包含:
actionbrokerIdtopicqos
当前支持两种动作:
SUBSCRIBEUNSUBSCRIBE
它的处理逻辑非常直接:
如果是 SUBSCRIBE
- 调用
subscriptionManager.addSubscription(brokerId, topic, qos) - 尝试从
adapterRegistry找到当前 broker 对应的 adapter - 如果当前 adapter 存在,立即执行
adapter.subscribe(topic, qos)
如果是 UNSUBSCRIBE
- 调用
subscriptionManager.removeSubscription(brokerId, topic) - 尝试找到当前 adapter
- 如果当前 adapter 存在,立即执行
adapter.unsubscribe(topic)
这一层最重要的设计不是"用 Spring 事件做桥接",而是:
动态变更同时更新两个对象:
- 当前运行态 adapter
- 持久在内存里的动态订阅集合
如果它只改 adapter,不写 MqttSubscriptionManager,那一旦重连,这些运行时新增的 topic 就丢了。
如果它只写 MqttSubscriptionManager,不立即改 adapter,那当前连接又不会马上生效。
也就是说,这里必须同时兼顾"现在"和"未来"。
设计决策: 动态订阅刷新不是直接对 adapter 做一次 subscribe/unsubscribe 就结束,而是必须同时更新
MqttSubscriptionManager。这样做的重点,是让运行时变更既能立即生效,也能进入未来重连时的恢复集合。
四、MqttSubscriptionManager 真正保存了什么?
当前默认实现 DefaultMqttSubscriptionManager 比很多人想象得更简单。
它内部维护的是:
Map<String, Set<String>> subscriptionsByBroker
也就是说,它按 brokerId 维护一个 topic 集合。
接口也非常克制:
addSubscription(String brokerId, String topic, int qos)removeSubscription(String brokerId, String topic)getSubscriptions(String brokerId)
这里有一个特别值得注意的现实边界:
虽然 addSubscription(...) 接口里接收了 qos,但默认实现当前只保存 topic,并没有保存每个动态订阅对应的 qos。
这也直接影响了后面的恢复逻辑:
- 动态订阅在运行时添加时,可以用事件里的 qos 立即订阅
- 但未来重连时,
Reconciler当前只能把这些动态 topic 以qos=0再次回放
所以这套设计已经把"动态 topic 会不会丢"解决了,但还没有把"动态 qos 是否完整恢复"做到完全闭环。
这类细节特别值得写出来,因为它体现的不是 bug,而是当前实现的真实取舍。
五、Reconciler 为什么是这篇的真正主角?
真正把"首次连接"和"重连恢复"统一起来的,是 MqttSubscriptionReconciler。
它本身就是一个 MqttConnectionListener,也就是说,它不是运行在业务事件层,而是直接挂在 broker 连接生命周期上。
当前实现里,它在 onConnected(String brokerId) 做了两件事:
- 从
MqttListenerRegistry.resolveByBroker(brokerId)取出所有该 broker 应有的静态 listener 定义 - 从
MqttSubscriptionManager.getSubscriptions(brokerId)取出运行时累积的动态 topic
然后分别回放到 adapter 上。
也就是说,它恢复的是两类订阅:
- 静态订阅 :来自
@MqttListener或注册表中的 listener 定义 - 动态订阅 :来自运行时事件驱动写入的
subscriptionManager
onConnected(brokerId)
adapterRegistry.find(brokerId)
listenerRegistry.resolveByBroker(brokerId)
subscriptionManager.getSubscriptions(brokerId)
subscribe static topics with definition.getQos()
subscribe dynamic topics with qos 0
Recovered adapter state
这张图其实就是第 7 篇最重要的逻辑骨架。
因为它说明:
- 首次连接时,静态订阅会被统一建立
- 重连时,同样的逻辑会再次执行
- 动态订阅不需要单独写一套"重连补偿逻辑",因为已经提前被收进
subscriptionManager
这就是为什么第 7 篇的题目里会强调"同一条协调路径"。
六、为什么说首次连接、动态刷新和重连恢复,本质上是同一个模型?
从实现上看,这三件事当然不是同一个方法里完成的。
- 首次连接和重连恢复,走
MqttSubscriptionReconciler.onConnected() - 运行时变更,走
MqttSubscriptionRefreshEventListener.onApplicationEvent(...)
但从架构模型上看,它们其实共享同一个核心逻辑:
- 当前应该有哪些静态订阅
- 当前还应该额外保留哪些动态订阅
- 一旦连接建立,就把两者统一回放到 adapter
所以真正共享的,不是"代码入口",而是"恢复模型"。
可以换个更直白的说法:
- 动态刷新负责维护"未来要恢复什么"
- Reconciler 负责在"连接恢复时把它们重新落到 adapter 上"
这就让系统避免了一个很常见的问题:
- 动态变更是动态变更
- 重连恢复是重连恢复
- 两边维护两份订阅真相
mqtt-plus 当前选择的是:
订阅真相尽量收敛,恢复动作统一触发。
设计决策: mqtt-plus 没有为"首次连接""动态新增 topic""断线重连"分别设计三套互相独立的订阅恢复机制,而是让动态变更先沉淀到
MqttSubscriptionManager,再由MqttSubscriptionReconciler在连接建立时统一回放。这样做的重点,是减少状态分叉,让恢复路径保持单一。
七、broker="*" 这种 listener 定义,会怎么参与恢复?
这里还有一个很容易被忽略、但很值得讲的点。
MqttListenerRegistry.resolveByBroker(brokerId) 的逻辑不是只找 definition.getBroker().equals(brokerId),而是:
- broker 等于当前 brokerId
- 或者 broker 等于
"*"
这意味着:
- 某个 listener 明确绑定
primary,只会在primary恢复时参与回放 - 某个 listener 如果写成
broker="*",那它会在每个 broker 的恢复时都参与回放
这个细节和第 6 篇的多 broker 设计是直接衔接的。
因为多 broker 不是简单"多几条连接",而是会把 broker 维度带进整个 listener 匹配和恢复模型里。
所以第 7 篇其实也补了一层认知:
- broker 维度不只是消息路由时的过滤条件
- 它也决定了连接恢复时哪些静态订阅应该被重新建立
八、当前实现的边界在哪里?
这一篇最值得诚实写出来的边界有两个。
1. 动态订阅当前只持久化了 topic,没有持久化 qos
这意味着:
- 运行时新增订阅时,当前连接能拿到事件里的 qos
- 但重连恢复时,默认实现只能按
qos=0回放动态 topic
这不是"设计不存在",而是"恢复模型已经成立,但动态 qos 还没有被完整保留下来"。
2. 恢复目前只关心订阅集合,不处理更复杂的订阅编排
比如当前没有继续抽象:
- 动态订阅优先级
- 条件订阅批次
- 部分 broker 的差异化恢复策略
- 去重之外更复杂的订阅冲突解决
这说明当前 Reconciler 的目标非常明确:
- 把静态订阅和动态订阅统一回放
- 保证首次连接和重连恢复的基本一致性
它是一个"恢复协调器",但还不是一个"高级订阅编排引擎"。
九、小结
第 7 篇真正想讲清楚的是,mqtt-plus 没有把动态订阅和重连恢复拆成两套互相独立的真相来源,而是做了一个非常清楚的分工:
MqttSubscriptionRefreshEventListener负责记录动态变化,并立即作用到当前 adapterMqttSubscriptionManager负责保存未来需要恢复的动态 topic 集合MqttSubscriptionReconciler负责在连接建立时统一回放静态和动态订阅
于是系统的行为就很稳定:
- 当前变化可以立即生效
- 将来重连也能恢复回来
- 首次连接、动态变更、重连恢复都围绕同一个订阅模型展开
这也是为什么第 7 篇看起来讲的是"恢复",其实真正讲的是另一件事:
如何让运行时变化和连接生命周期共享同一套状态模型。
下一篇会继续往上走一层,进入 Spring Boot 自动装配本身:这些核心零件到底是怎么被 starter 粘合起来的。
系列导航
本文是 mqtt-plus 架构解析 系列的第 7/10 篇。
| # | 主题 | 链接 |
|---|---|---|
| 1 | 总览:分层架构与设计哲学 | 链接 |
| 2 | 消息路由:一条 MQTT 消息如何到达你的 @MqttListener |
链接 |
| 3 | Payload 序列化与反序列化:双链设计的取舍 | 链接 |
| 4 | 拦截器链:MqttMessageInterceptor 的扩展点设计 |
链接 |
| 5 | 错误处理:ErrorAction 聚合策略的设计逻辑 |
链接 |
| 6 | 多 Broker 管理:如何让一个应用同时连接多个 MQTT 服务 | 链接 |
| 7 | 动态订阅与重连恢复:Reconciler 的协调机制 |
本文 |
| 8 | Spring Boot 自动装配:零件是怎么被粘合起来的 | 链接 |
| 9 | 测试体系:MqttTestTemplate 与 EmbeddedBroker 的设计 |
链接 |
| 10 | 从内部项目到开源框架:mqtt-plus 的抽取过程与决策 | 链接 |