mqtt-plus 架构解析(七):动态订阅与重连恢复,为什么能走同一条协调路径

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,事件里包含:

  • action
  • brokerId
  • topic
  • qos

当前支持两种动作:

  • SUBSCRIBE
  • UNSUBSCRIBE

它的处理逻辑非常直接:

如果是 SUBSCRIBE

  1. 调用 subscriptionManager.addSubscription(brokerId, topic, qos)
  2. 尝试从 adapterRegistry 找到当前 broker 对应的 adapter
  3. 如果当前 adapter 存在,立即执行 adapter.subscribe(topic, qos)

如果是 UNSUBSCRIBE

  1. 调用 subscriptionManager.removeSubscription(brokerId, topic)
  2. 尝试找到当前 adapter
  3. 如果当前 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) 做了两件事:

  1. MqttListenerRegistry.resolveByBroker(brokerId) 取出所有该 broker 应有的静态 listener 定义
  2. 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 负责记录动态变化,并立即作用到当前 adapter
  • MqttSubscriptionManager 负责保存未来需要恢复的动态 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 测试体系:MqttTestTemplateEmbeddedBroker 的设计 链接
10 从内部项目到开源框架:mqtt-plus 的抽取过程与决策 链接

上一篇:多 Broker 管理:如何让一个应用同时连接多个 MQTT 服务

下一篇:Spring Boot 自动装配:零件是怎么被粘合起来的

相关推荐
若水不如远方2 小时前
一文讲透单点登录原理(SSO):从同域共享到跨域票据
java·后端
无巧不成书02182 小时前
Unicode编码机制全解析:从核心原理到Java 实战
java·开发语言·java字符编码·unicode 15.1码点
mu_guang_2 小时前
计算机体系结构3-cache一致性和内存一致性的区别
java·开发语言·计算机体系结构
海兰2 小时前
使用 Spring AI 打造企业级 RAG 知识库第一部分:核心基础
java·人工智能·spring
恼书:-(空寄2 小时前
责任链模式实现流程动态编排
java·责任链模式
星原望野2 小时前
java:volatile关键字的作用
java·开发语言·volatile
William_cl2 小时前
C# ASP.NET 分层架构实战:BLL (Service) 业务层从入门到封神(规范 + 避坑)
架构·c#·asp.net
XiYang-DING2 小时前
【Java】Map和Set
java·开发语言
菜菜小狗的学习笔记2 小时前
八股(二)Java集合
java·开发语言