1. 项目缘起与整体设计思路
1.1 为什么要在Spring 5.1.6里集成MQTT
先把场景说清楚。很多做物联网后台或者消息中台的团队,技术栈是Spring 5.x这一代,还没升到Spring Boot 3,也没用上Spring Integration那套比较重的消息抽象。这时候如果业务上需要接入设备上报数据、做指令下发,MQTT几乎是绕不开的协议。它轻量、基于发布订阅、支持QoS分级,在弱网和大量长连接场景下比HTTP轮询舒服太多。
但问题来了:Spring 5.1.6本身并不自带MQTT客户端。你得自己选一个客户端库,然后把它优雅地塞进Spring的容器生命周期里。我见过不少项目直接new一个MqttClient在Service里用,结果连接断了不重连、线程池乱飞、配置散落各处,后期维护非常痛苦。所以这个项目的核心目标就一句话:让MQTT客户端像DataSource一样,成为Spring容器里一个受管理的、可配置的、能自动重连的基础设施Bean。
这个标题适合谁看?如果你正在用Spring 5.1.6(或者相近的5.x版本),需要接入MQTT,又不想引入Spring Integration那么重的依赖,那这篇就是给你写的。我会把选型理由、配置细节、连接管理、消息收发、异常排查都拆开讲,代码可以直接抄。
1.2 客户端选型:为什么是Eclipse Paho
MQTT客户端库市面上主要有几个选择:Eclipse Paho、HiveMQ MQTT Client、Fusesource等。在Spring 5.1.6这个时间点,Paho是最稳妥的。原因有三点。
第一,Paho的API足够简单,MqttClient和MqttAsyncClient两个类基本覆盖所有场景,学习成本低。第二,它的依赖极轻,核心就一个jar,不会像某些库那样拖一堆传递依赖进来。第三,社区资料多,遇到问题好查。HiveMQ的客户端API更现代,但在5.1.6那个年代生态还没现在这么成熟,团队接受度也低一些。
这里有个关键决策点:用同步的MqttClient还是异步的MqttAsyncClient。我的建议是,如果你只是做简单的收发,同步客户端配合Spring的线程池就够了;但如果你要处理高并发消息,或者需要在回调里做耗时操作,异步客户端更合适。这个项目里我选的是同步客户端,因为大多数后台场景消息量可控,同步模型调试起来直观,出问题好定位。
注意:Paho的同步客户端在
publish和subscribe时是阻塞的,如果broker响应慢,会拖住调用线程。所以一定要把它放在独立的线程池里执行,不要占用Web请求线程。
1.3 整体架构分层
整个集成方案我分成四层来设计,这样职责清晰,后期扩展也方便。
- 配置层:用
@Configuration和@ConfigurationProperties把broker地址、客户端ID、用户名密码、超时时间这些外部化到配置文件。 - 连接管理层:封装一个
MqttClientManager,负责客户端的创建、连接、断线重连、销毁。它实现InitializingBean和DisposableBean,跟着Spring容器生命周期走。 - 消息收发层:一个
MqttMessageHandler接口定义消息处理逻辑,一个MqttPublisher负责发送。订阅的回调里把消息转交给handler,做到收发解耦。 - 业务接入层:具体的业务Service实现handler接口,或者注入publisher发消息。
这样分层的好处是,换broker、改QoS、加topic,都只动配置层和少量管理层代码,业务代码基本无感。下面我逐层拆开讲。
2. 核心细节解析与实操要点
2.1 依赖引入与版本对齐
Spring 5.1.6对应的Java版本一般是JDK 8,所以Paho选1.2.5这个版本比较稳,再高的版本有些API变动,在JDK 8上虽然能跑,但没必要冒险。Maven依赖就一个:
<dependency> <groupId>org.eclipse.paho</groupId> <artifactId>org.eclipse.paho.client.mqttv3</artifactId> <version>1.2.5</version> </dependency>如果你用Gradle,对应写法自己转一下。这里要提醒一句:不要引入paho的android版本,那个是给安卓用的,API不一样,混进来会报ClassNotFound。
另外,Spring 5.1.6本身不需要额外加什么,spring-context和spring-beans就够了。如果你项目里已经有Spring Boot,那更方便,但注意Spring Boot 2.1.x对应的是Spring 5.1.x,版本是匹配的。
2.2 配置项设计:哪些参数必须外部化
配置这块我踩过坑,一开始把broker地址写死在代码里,结果测试环境和生产环境切换要改代码重新打包,非常蠢。后来全部抽到application.properties或者application.yml里。核心配置项如下表:
| 配置项 | 说明 | 建议值 |
|---|---|---|
| broker.url | MQTT broker地址 | tcp://host:1883 |
| client.id | 客户端唯一标识 | 服务名+随机后缀 |
| username | 用户名 | 按broker要求 |
| password | 密码 | 按broker要求 |
| clean.session | 是否清除会话 | false(重要) |
| connection.timeout | 连接超时秒数 | 30 |
| keep.alive.interval | 心跳间隔秒数 | 60 |
| automatic.reconnect | 自动重连 | true |
| default.qos | 默认QoS等级 | 1 |
这里重点说两个。clean.session一定要设成false,否则每次断线重连,broker都会把你的订阅关系清掉,你得重新订阅,而且离线期间的消息全丢。设成false之后,broker会为你保留会话,重连后订阅关系还在,QoS 1和2的消息也能补发。client.id必须唯一,如果两个实例用同一个ID,broker会把前一个踢下线,表现为连接反复断开,这个坑很隐蔽。
2.3 连接管理Bean的生命周期
连接管理是整个集成的核心。我写了一个MqttClientManager,它实现InitializingBean,在afterPropertiesSet里创建并连接客户端;实现DisposableBean,在destroy里断开连接、释放资源。这样Spring启动时自动连,关闭时自动断,不用手动管。
创建客户端的代码大概是这样:
MqttClient client = new MqttClient(brokerUrl, clientId, new MemoryPersistence()); MqttConnectOptions options = new MqttConnectOptions(); options.setCleanSession(false); options.setAutomaticReconnect(true); options.setConnectionTimeout(connectionTimeout); options.setKeepAliveInterval(keepAliveInterval); if (username != null) { options.setUserName(username); options.setPassword(password.toCharArray()); } client.connect(options);MemoryPersistence表示不把未确认消息持久化到磁盘,适合大多数后台场景。如果你对消息可靠性要求极高,可以换成MqttDefaultFilePersistence,指定一个目录,这样即使进程崩溃,重启后未确认的消息还能继续发。但文件持久化有IO开销,要权衡。
提示:
setAutomaticReconnect(true)是Paho 1.2.x才有的,1.1.x没有这个方法,所以版本别选太低。自动重连的间隔是递增的,第一次1秒,然后2秒、4秒……最大到128秒,这个策略对broker重启场景比较友好。
2.4 订阅回调与消息分发
订阅的时候要传一个IMqttMessageListener,消息到了就在这个回调里处理。但回调是在Paho自己的线程里执行的,如果你在回调里做数据库写入、远程调用这些耗时操作,会阻塞Paho的消息接收线程,导致后续消息堆积。所以我的做法是:回调里只做消息的初步解析和转发,真正的业务处理丢到业务线程池。
client.subscribe(topic, qos, (t, message) -> { String payload = new String(message.getPayload(), StandardCharsets.UTF_8); businessExecutor.submit(() -> messageHandler.handle(t, payload)); });businessExecutor是一个自定义的ThreadPoolExecutor,核心线程数按业务量配,队列要有界,拒绝策略用CallerRunsPolicy,这样队列满了会反压到Paho线程,避免消息无限堆积把内存撑爆。这个细节很多教程不讲,但生产环境很关键。
2.5 消息发送的线程安全与重试
MqttClient的publish方法是线程安全的,多个线程同时发没问题。但要注意,如果broker不可达,publish会抛MqttException。我的处理是包一层重试,最多重试3次,每次间隔500毫秒,还是失败就记录日志并抛出业务异常。
public void publish(String topic, String payload, int qos, boolean retained) { MqttMessage message = new MqttMessage(payload.getBytes(StandardCharsets.UTF_8)); message.setQos(qos); message.setRetained(retained); for (int i = 0; i < 3; i++) { try { client.publish(topic, message); return; } catch (MqttException e) { if (i == 2) throw new RuntimeException("publish failed", e); try { Thread.sleep(500); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } } }retained这个参数要慎用。设成true的话,broker会保留这条消息,新订阅的客户端一上来就能收到。适合做设备状态同步,但不适合做指令下发,否则新上线的服务会收到一堆历史指令,很危险。
3. 实操过程与核心环节实现
3.1 完整配置类编写
先把配置类写出来。我用@ConfigurationProperties(prefix = "mqtt")把配置绑定到一个POJO上,然后在@Configuration里创建MqttClientManager的Bean。
@Component @ConfigurationProperties(prefix = "mqtt") public class MqttProperties { private String brokerUrl; private String clientId; private String username; private String password; private boolean cleanSession = false; private int connectionTimeout = 30; private int keepAliveInterval = 60; private int defaultQos = 1; // getter/setter 省略 }配置类:
@Configuration @EnableConfigurationProperties(MqttProperties.class) public class MqttConfig { @Bean(destroyMethod = "destroy") public MqttClientManager mqttClientManager(MqttProperties props) { return new MqttClientManager(props); } @Bean public ThreadPoolTaskExecutor mqttBusinessExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(16); executor.setQueueCapacity(1000); executor.setThreadNamePrefix("mqtt-biz-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }这里destroyMethod = "destroy"是因为MqttClientManager实现了DisposableBean,Spring关闭时会自动调用。mqttBusinessExecutor的队列容量设1000,拒绝策略用CallerRunsPolicy,前面解释过原因。
3.2 MqttClientManager的完整实现
这个类是整个方案的心脏,我把关键代码贴出来并逐段解释。
public class MqttClientManager implements InitializingBean, DisposableBean { private final MqttProperties props; private MqttClient client; private final List<Subscription> subscriptions = new CopyOnWriteArrayList<>(); public MqttClientManager(MqttProperties props) { this.props = props; } @Override public void afterPropertiesSet() throws Exception { client = new MqttClient(props.getBrokerUrl(), props.getClientId(), new MemoryPersistence()); MqttConnectOptions options = buildOptions(); client.connect(options); // 重连后重新订阅 client.setCallback(new MqttCallbackExtended() { @Override public void connectComplete(boolean reconnect, String serverURI) { resubscribeAll(); } @Override public void connectionLost(Throwable cause) { // 记录日志,自动重连会处理 } @Override public void messageArrived(String topic, MqttMessage message) { // 由subscribe时的listener处理,这里不做事 } @Override public void deliveryComplete(IMqttDeliveryToken token) { } }); } private MqttConnectOptions buildOptions() { MqttConnectOptions options = new MqttConnectOptions(); options.setCleanSession(props.isCleanSession()); options.setAutomaticReconnect(true); options.setConnectionTimeout(props.getConnectionTimeout()); options.setKeepAliveInterval(props.getKeepAliveInterval()); if (props.getUsername() != null) { options.setUserName(props.getUsername()); options.setPassword(props.getPassword().toCharArray()); } return options; } public void subscribe(String topic, int qos, IMqttMessageListener listener) throws MqttException { subscriptions.add(new Subscription(topic, qos, listener)); client.subscribe(topic, qos, listener); } private void resubscribeAll() { for (Subscription s : subscriptions) { try { client.subscribe(s.topic, s.qos, s.listener); } catch (MqttException e) { // 记录日志 } } } public void publish(String topic, String payload, int qos, boolean retained) throws MqttException { MqttMessage message = new MqttMessage(payload.getBytes(StandardCharsets.UTF_8)); message.setQos(qos); message.setRetained(retained); client.publish(topic, message); } @Override public void destroy() throws Exception { if (client != null && client.isConnected()) { client.disconnect(); } if (client != null) { client.close(); } } private static class Subscription { String topic; int qos; IMqttMessageListener listener; Subscription(String topic, int qos, IMqttMessageListener listener) { this.topic = topic; this.qos = qos; this.listener = listener; } } }这里有个关键点:setCallback里用的是MqttCallbackExtended,它比普通的MqttCallback多了一个connectComplete方法,这个方法在每次重连成功后都会回调。我在里面调用resubscribeAll(),保证断线重连后订阅关系能恢复。虽然cleanSession=false时broker会保留订阅,但为了保险,重连后重新订阅一遍没坏处,而且能覆盖broker端会话丢失的极端情况。
3.3 业务侧如何接入
业务侧接入很简单。定义一个handler接口:
public interface MqttMessageHandler { void handle(String topic, String payload); }然后写一个启动时订阅的组件:
@Component public class DeviceMessageSubscriber { @Autowired private MqttClientManager clientManager; @Autowired private ThreadPoolTaskExecutor mqttBusinessExecutor; @Autowired private DeviceMessageHandler deviceMessageHandler; @PostConstruct public void init() throws MqttException { clientManager.subscribe("device/+/report", 1, (topic, message) -> { String payload = new String(message.getPayload(), StandardCharsets.UTF_8); mqttBusinessExecutor.execute(() -> deviceMessageHandler.handle(topic, payload)); }); } }device/+/report里的+是单层通配符,匹配device/001/report、device/002/report这种。如果要匹配多层,用#,比如device/#能匹配device/001/report/temp。通配符用起来方便,但要注意订阅通配符会增加broker的匹配开销,topic层级特别多的时候要谨慎。
发送消息就更简单了:
clientManager.publish("device/001/command", "{\"cmd\":\"reboot\"}", 1, false);3.4 参数计算与选择过程
QoS等级怎么选,很多人纠结。我给出我的经验判断:
- QoS 0:最多一次,消息可能丢。适合高频的传感器数据,丢一两条无所谓。
- QoS 1:至少一次,消息可能重复。适合大多数业务场景,接收端要做幂等。
- QoS 2:恰好一次,开销最大。适合支付、指令这类绝对不能重复的场景。
心跳间隔keepAliveInterval设多少?默认60秒。如果设得太短,心跳包太频繁浪费流量;设得太长,broker检测到客户端掉线的时间就长。一般设30到120秒之间。我实测下来60秒是个平衡点。
连接超时connectionTimeout设30秒。这个值要大于网络往返时间,但也不能太大,否则broker不可达时客户端会卡很久。30秒对大多数内网环境足够。
线程池大小怎么定?核心线程数按消息处理的平均耗时和消息速率算。假设每秒100条消息,每条处理耗时50毫秒,那需要的线程数是100×0.05=5,核心线程设4到8之间。队列容量设1000,能缓冲突发流量。
4. 常见问题与排查技巧实录
4.1 连接反复断开
这是最常见的报错,日志里会看到Connection lost然后Reconnecting,循环往复。原因通常有三个。
第一,clientId冲突。两个实例用了同一个clientId,broker会互相踢。解决办法是clientId加上实例标识,比如服务名-IP-随机数。第二,用户名密码错误。有些broker对认证失败的处理是直接断开,不返回明确错误码,容易误判。第三,心跳超时。如果客户端所在网络有NAT超时,比如30秒没数据就断连接,而心跳设了60秒,那NAT先把连接断了。这种情况把心跳调到小于NAT超时时间,比如20秒。
排查方法:在connectionLost回调里把cause完整打印出来,包括异常类型和message。Paho的异常信息有时候比较隐晦,但结合broker端日志一起看,基本能定位。
4.2 消息收不到
订阅了topic但收不到消息,按这个顺序排查:
- 确认订阅是否成功。
subscribe方法返回后,检查client.isConnected()和订阅的topic是否和发送方一致。注意topic大小写敏感,Device/001和device/001是两个不同的topic。 - 确认QoS是否匹配。如果发送方用QoS 0,接收方订阅QoS 1,消息还是能收到的,但QoS 0的消息在broker不保留,接收方离线期间的消息会丢。
- 确认
cleanSession设置。如果设成true,每次重连订阅关系清空,重连后没重新订阅就收不到。 - 检查通配符。
device/+匹配不了device/001/report,因为+只匹配一层。要用device/+/report或者device/#。
4.3 消息重复消费
QoS 1下消息重复是正常的,broker保证至少一次,不保证不重复。解决办法是在业务层做幂等。我通常用消息里的唯一ID(比如设备ID+时间戳+序列号)做去重,用一个带过期时间的本地缓存或者Redis记录已处理的消息ID。
如果重复特别频繁,检查是不是automaticReconnect和手动重连同时存在,导致连接抖动。另外,publish重试逻辑如果没做好幂等,也会导致重复发送。我的重试逻辑里,如果第一次publish其实成功了但客户端没收到确认,重试会再发一次,所以接收端必须幂等。
4.4 内存溢出与消息堆积
如果消息处理速度跟不上接收速度,业务线程池的队列会堆积,最终OOM。排查方法是监控队列的getQueue().size(),如果持续增长,说明处理能力不足。解决办法有三个:增加线程数、优化处理逻辑、或者用CallerRunsPolicy反压。我前面推荐CallerRunsPolicy就是这个原因,队列满了让Paho线程自己跑,虽然会阻塞消息接收,但至少不会OOM,而且能形成背压,让发送方感知到压力。
还有一个隐蔽的坑:MqttMessage的payload如果很大,比如几MB,大量消息堆积会迅速吃满内存。MQTT协议本身适合小消息,大消息建议走对象存储,消息里只传URL。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 连接反复断开 | clientId冲突 | 检查多实例clientId | 加唯一后缀 |
| 连接反复断开 | 心跳大于NAT超时 | 检查网络设备超时 | 调小心跳间隔 |
| 收不到消息 | 订阅未成功 | 检查topic和QoS | 重连后重新订阅 |
| 收不到消息 | cleanSession=true | 检查配置 | 改为false |
| 消息重复 | QoS 1特性 | 正常现象 | 业务层幂等 |
| 内存溢出 | 消息堆积 | 监控队列大小 | 反压或扩容 |
| 发送失败 | broker不可达 | 检查网络和broker状态 | 重试+告警 |
| 订阅通配符不生效 | 通配符层级不对 | 检查+和#用法 | 调整topic模式 |
4.6 几个独家避坑技巧
第一个,不要在@PostConstruct里做阻塞的连接操作。如果broker不可达,connect会阻塞到超时,导致Spring启动卡住。我的做法是把连接放到一个异步线程里,或者设置较短的连接超时,启动失败也不影响其他Bean初始化。如果业务强依赖MQTT,那就让它失败,但要有明确的日志。
第二个,优雅关闭很重要。destroy方法里先disconnect再close,disconnect会发送DISCONNECT包给broker,broker能立即知道客户端下线。如果不调disconnect直接close,broker要等心跳超时才发现,这期间发给该客户端的消息会堆积。在容器滚动发布时,这个细节能减少消息延迟。
第三个,日志要打全。Paho的日志默认走java.util.logging,和大多数项目用的SLF4J不统一。可以在启动时设置MqttClient.setTraceEnabled或者配置JUL桥接到SLF4J,这样连接、订阅、收发的日志都能统一管理,排查问题方便很多。
第四个,topic设计要有规范。我一般用业务域/设备类型/设备ID/动作这样的层级,比如iot/sensor/dev001/report。好处是权限控制可以按前缀做,订阅可以用通配符灵活匹配,而且一眼能看出消息来源。避免用纯数字或者无意义的字符串做topic,后期维护会疯。
5. 性能调优与扩展思路
5.1 连接池与多broker支持
单个MqttClient的连接数有限,如果消息量特别大,可以考虑多个客户端分摊。但要注意,MQTT的会话是绑定在clientId上的,多客户端意味着多个会话,订阅关系要分别管理。我的建议是,除非单连接确实成为瓶颈(比如每秒几万条消息),否则不要轻易上多连接,复杂度上升明显。
如果要做多broker容灾,可以配置主备两个broker地址,客户端连接主broker,connectionLost时切换到备broker。Paho本身不支持自动切换,需要自己实现。思路是在connectionLost回调里尝试连接备地址,成功后再切回来。这个逻辑要小心处理,避免在两个broker之间反复横跳。
5.2 消息序列化选型
payload的序列化格式,JSON是最通用的,可读性好,调试方便。但JSON的体积比二进制大,如果消息量极大,可以考虑Protobuf或者MessagePack。不过我要提醒一句,序列化格式的选择要和上下游对齐,如果设备端只能发JSON,后台再优化也没用。大多数物联网场景,JSON够用了,别过度设计。
5.3 监控指标埋点
生产环境一定要有监控。我通常埋这几个指标:连接状态(是否连接)、消息接收速率、消息发送速率、业务线程池队列大小、消息处理平均耗时、失败重试次数。这些指标通过Micrometer或者自定义的MBean暴露出去,接到监控系统里。连接状态变化和队列持续增长要配告警,这两个是故障的前兆。
5.4 与Spring生态的进一步整合
如果项目后来升级到Spring Boot,可以把MqttClientManager改成一个@Bean,用@ConditionalOnProperty控制是否启用。配置项继续用@ConfigurationProperties,无缝迁移。如果引入Spring Integration,它自带的MQTT适配器能省掉不少代码,但灵活性不如自己封装。我的经验是,简单场景自己封装更可控,复杂场景(多种消息源、复杂路由)才上Spring Integration。
我在实际项目里用这套方案跑了两年多,接了几百台设备,日均消息量百万级,稳定性没问题。踩过的坑基本都在上面列出来了,希望对正在做类似集成的你有帮助。最后再分享一个小技巧:在测试环境用Mosquitto搭一个本地broker,配合MQTTX这类客户端工具,调试起来非常快,不用依赖远程环境,能省很多时间。