说实话,这两年做设备接入、物联网平台、智能硬件方向的项目,Spring Boot 加 MQTT 这套组合几乎躲不开。后台管理系统做多了之后,第一次接触 MQTT 时很多人会有点懵,因为它和普通的 HTTP 接口完全不是一个思路——不是“请求-响应”,而是“发布-订阅”,服务端和客户端之间是异步的。这篇文章我会把 Spring Boot 整合 MQTT 的完整过程拆开来讲,包括协议层几个关键概念的通俗理解、Broker 环境搭建、核心配置与代码、以及我实际调试过程中踩过的那些坑,尽量让你看完不仅能实现一个可运行的 Demo,还能知道生产环境里哪些地方容易出问题。
有人会问,做这个项目的意义到底在哪?如果你手头有大量联网设备需要上报状态、需要平台主动下发控制指令,或者要对接类似智能家居、充电桩、农机监控、能耗采集这类场景,HTTP 轮询要么延迟高、要么对设备和服务器压力都大,MQTT 的轻量级和实时性优势就非常明显。下面我按自己做项目的顺序,从整体设计一直讲到代码细节和排错经历,一步步来。
1. 动手之前,先想清楚这几件事
1.1 这个项目的典型场景和最终目标
用 Spring Boot 实现 MQTT 通信,听起来像是个技术 Demo,但落到实际业务里其实是两条很核心的链路:一条是“设备数据上行”,也就是传感器、控制器、车载终端等设备把状态数据通过 MQTT 报文发给 Broker,Spring Boot 服务作为 MQTT 客户端订阅相关 Topic 后消费消息,把数据落入业务库或推送消息给前端;另一条是“平台指令下发”,也就是用户在管理后台点了某个按钮,Spring Boot 服务把一条指令通过 MQTT 发布到设备的 Topic,设备收到以后执行动作并上报结果。
目标拆开来说就是三件事:第一,Spring Boot 能稳定地连上 MQTT Broker;第二,能订阅主题并实时处理消息;第三,能按需向指定主题发布消息。听起来不复杂,但真正做的时候你会面临一系列选择——用哪个 Broker、用哪种客户端库、要不要引入 Spring Integration、QoS 怎么定、topic 怎么设计、连接断了怎么重连、消息重复怎么处理。这些没有一个统一的标准答案,都取决于你的业务约束,所以不要一上来就急着写代码,先把自己的场景搞清楚。
1.2 为什么这个场景更适合 MQTT,而不是 HTTP 或 WebSocket
我经常给团队里新来的后端同学讲的一句话是:HTTP 适合“你去问服务器要东西”,MQTT 适合“设备主动找你说事情”。在物联网场景里,设备量大、网络不稳定、流量成本敏感,如果每个设备都通过 HTTP 定时轮询,一是抬高了 Broker 和业务服务器的压力,二是数据实时性很差,三是设备掉线的感知非常滞后。而 MQTT 建立在 TCP 之上,本身协议头非常小,一个消息可能只有几字节,对低带宽网络非常友好,同时基于发布订阅模型天然解决了设备多对多通信的解耦问题。
那为什么不用 WebSocket?WebSocket 也能做双向实时通信,但它的优势是在浏览器端的长时间双向通道,对于设备端来说生态远不如 MQTT 成熟,而且 MQTT 还带了 QoS、遗嘱消息、保留消息等机制,这些在物联网场景里都是非常实用的能力。你可以把 MQTT Broker 理解成一个专门处理主题分发的中转站,设备只跟 Broker 打交道,不关心消息的最终消费者是谁,业务服务也不知道消息到底来自哪台设备,这种解耦方式让后续的设备接入、服务横向扩展都简单很多。
1.3 Spring Boot 整合 MQTT 的三种主流技术路线
在真正动手前,我还想聊一下技术选型,因为很多人一搜“Spring Boot MQTT”会搜到各种完全不同的写法,有的用 Eclipse Paho 原生客户端,有的引入 spring-integration-mqtt,还有的自己写一个包装类管理连接。
第一种是在 Spring Boot 项目中直接使用 Eclipse Paho Java 客户端。这种方式最底层、最灵活,连接、订阅、消息回调全部自己控制,没有框架层面的“魔法”,代码很容易理解,但对生产环境来说要自己处理很多细节,比如断线重连、回调线程模型、线程池管理等,如果连接数多或者并发消息大,很容易因为回调处理不当把业务线程阻塞。
第二种是引入 Spring Integration MQTT,这是 Spring 生态官方提供的一套集成方案。它把 MQTT 客户端封装成了 MessageProducer 和 MessageHandler,你只需要配置好 Adapter,剩下的连接管理、消息通道、消息转换交给 Spring Integration 来处理。我们可以通过 MessageChannel、@ServiceActivator、@MessagingGateway 这些 Spring 开发者熟悉的方式去收发消息,代码量会少很多,也更容易和 Spring Boot 项目的其他模块整合。
第三种是使用第三方封装的 starter,或者直接调用云厂商 IoT 平台的 SDK。这种方式最省事,但绑定比较强,不同厂商的 Topic 规范、物模型定义各不相同,适合快速接入某个特定平台,不适合做通用型网关。
我在实际项目中推荐第二种,但前提是你对 Spring Integration 的基础概念要有一定了解,不要只是把代码复制下来。下面整个项目我都是基于 spring-integration-mqtt 来讲,中间会穿插解释 Paho 层面的行为。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MQTT 协议的几个关键概念,先过一遍
2.1 Broker、Topic、QoS 到底是干什么的
如果你之前完全没接触过 MQTT,可以先花五分钟把下面几个词弄明白。
MQTT Broker(代理服务器)是消息的中转中心,所有的客户端都连接到 Broker 上,客户端之间并不直接通信。Broker 负责接收发布者发来的消息,再根据主题匹配规则把消息推送给所有订阅了对应主题的订阅者。常见的开源 Broker 有 EMQX、Mosquitto、VerneMQ 等,商业的还有各类云物联网平台内置的 Broker。
Topic(主题)是消息的“分类标签”,结构上很像文件路径,用斜杠做层级分隔,比如 device/001/status。发布者把消息发布到这个主题,所有订阅了该主题的客户端都能收到。Topic 不像 HTTP URL 那样需要提前注册,任何客户端都可以向任意主题发消息,但生产环境里通常需要配合权限体系做访问控制。
QoS(服务质量)是 MQTT 最核心也最容易混淆的概念,它分三个等级:QoS 0 表示尽力而为、最多发送一次,消息可能丢失;QoS 1 表示至少送达一次,Broker 会做确认,但接收方可能会收到重复消息;QoS 2 表示恰好送达一次,通过复杂的四次握手协议确保不丢也不重。QoS 等级越高,网络开销越大,实际项目里设备上报数据用 QoS 0 或 QoS 1 比较多,下发控制指令时为了更可靠一般用 QoS 1,QoS 2 用得相对少,因为大部分场景只要业务层做幂等就能处理重复消息。
另外两个很实用的机制是 Retained Message(保留消息)和 Will Message(遗嘱消息)。保留消息的意思是,往某个主题发布消息时如果带上 retained 标志,Broker 会把最后一条消息存下来,之后新订阅这个主题的客户端会立刻收到这条保留消息,这对设备上线后需要立刻获取最新状态非常有用。遗嘱消息是客户端在连接时预先设置一条“遗言”,如果客户端异常断开,Broker 会自动把这个遗嘱消息发到指定主题,这样业务系统就能感知到设备掉线。
2.2 本地把 Broker 和调试工具跑起来
不要一上来就写 Spring Boot 代码,先把消息中转站跑通,用现成的客户端工具验证一下发布订阅的基本流程,你会对接下来的代码有更直观的理解。我用得最多的是 EMQX 开源版,它自带可视化管理 Dashboard,调试、看连接数、看主题订阅都非常方便,对新手很友好。
推荐用 Docker 直接启动,一条命令就能搞定:
bash复制docker run -d --name emqx -p 1883:1883 -p 18083:18083 emqx/emqx:5.8.4
1883 是 MQTT 协议默认端口,18083 是 Dashboard 的 Web 管理端口。启动后浏览器打开 http://localhost:18083,默认账号 admin,默认密码 public,登录以后可以在“连接管理”里看到所有客户端连接状态。如果你实际环境用的是 Mosquitto,可以通过 apt install mosquitto mosquitto-clients 安装,配置文件在 /etc/mosquitto/mosquitto.conf,但调试体验不如 EMQX 直观。
测试工具方面,我最常用的是 MQTTX 这个跨平台桌面客户端,它支持多个连接同时存在,界面里能非常清晰地看到消息收发记录。你也可以用命令行工具快速测:
bash复制mosquitto_sub -h localhost -p 1883 -t "test/topic"
mosquitto_pub -h localhost -p 1883 -t "test/topic" -m "hello mqtt"
先用 MQTTX 连接你本地的 Broker,手动发一条消息到某个主题,再在另一个连接里订阅该主题,亲身感受一下“收消息”的过程。这个基础验证一旦通了,后面 Spring Boot 代码里的问题就只可能出在配置本身,排查范围会小很多。
3. 工程实战:Spring Boot 集成 MQTT 的核心代码拆解
3.1 版本选型和工程结构建议
首先说版本踩坑问题。很多新手从网上下载代码,结果跑不起来,很大概率是 Spring Boot 版本不匹配。Spring Boot 2.7.x 是一个比较稳定且资料丰富的版本,用 spring-integration-mqtt 时依赖版本由 Spring Boot 统一管理,很方便;如果你要用 Spring Boot 3.x,也不是不行,但要注意 Spring Integration 已经升级到 6.x,API 有一些调整,而且整个技术栈基于 Jakarta EE,引入依赖时尽量避免混用老的包。
我一直推荐项目代码里把 MQTT 相关的内容独立成一个包管理,不要散落在业务代码里。比如:
code复制com.example.mqtt
-- config
MqttConfig.java
-- gateway
MqttGateway.java
-- handler
MqttMessageHandler.java
-- service
DeviceDataService.java
config 包放连接配置和消息通道定义,gateway 包放发布接口,handler 包放订阅消息处理逻辑,service 包放业务层。这样后续如果要把项目扩展成多 Broker 连接,或者把设备协议解析抽离出来,模块边界会比较清爽。
3.2 pom.xml 依赖引入
如果使用 Spring Boot 2.7.18,完整的 MQTT 相关依赖就下面这几个,核心是 spring-boot-starter-integration 和 spring-integration-mqtt,前者提供 Spring Integration 的基础能力,后者才是真正把 MQTT 客户端包装成消息适配器的集成模块:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.integration</groupId>
<artifactId>spring-integration-mqtt</artifactId>
</dependency>
这里有一个容易忽略的点是额外引入的 fastjson、gson 等序列化工具,它们不是必需的,但消息体如果不只是简单字符串而是 JSON,业务处理时用 Jackson 就够了,Spring Boot 自带,不需要再引入。
3.3 application.yml 核心配置
配置文件我习惯把 MQTT Broker 相关的连接参数统一放到一个自定义前缀下,而不是直接散落在 Spring Integration 的默认配置里,这样后续切换环境(开发、测试、生产)只需要把这一块改了即可。
yaml复制mqtt:
broker:
# 本地开发时指向自己用 docker 起的 EMQX
url: tcp://localhost:1883
username: admin
password: public
client:
# 发布端 clientId,所有订阅同一 broker 的客户端必须保证唯一
publishClientId: mqtt-server-publish-client
subscribeClientId: mqtt-server-subscribe-client
# 连接超时时间,单位秒
connectTimeout: 10
# 心跳保活间隔,单位秒
keepAliveInterval: 60
# 是否自动重连
automaticReconnect: true
# 清理会话
cleanSession: false
topic:
# 订阅的主题,支持通配符 + 和 #
inbound: device/+/status,device/+/command_reply
# 下发指令的主题模板
outboundPrefix: device/%s/command
尤其注意 clientId 这一项。同一个 MQTT Broker 不允许两个连接使用相同的 clientId,如果后面 Server 跑起来出现“另一个相同 clientId 的连接把当前连接踢下线”的问题,多半就是因为你在多实例部署时把 clientId 写死了。应对方式是让 clientId 带上实例标识,比如 hostname 或随机后缀。
KeepAlive 时间也值得说一下。MQTT 客户端在空闲时会发出 PINGREQ 报文保活,如果 Broker 在超过 1.5 倍 keepAliveInterval 的时间内没收到任何报文,就会判定客户端离线并把它的遗嘱消息发出去。这个值设置太短会增加网络开销,设置太长又会延长掉线感知时间,对于常规采集系统我设置在 30 到 60 秒之间比较合适。
3.4 核心配置类:连接工厂与出入站通道
下面是整个项目里最关键的一个类,MqttConfig。在这个配置类里,我们要做三件事:一是创建 MqttConnectOptions,设置用户名密码、超时时间、心跳间隔、自动重连和会话清理属性;二是创建 MqttPahoClientFactory,这是 Paho 客户端工厂,你后续创建入站和出站消息适配器都会用到它;三是把入站订阅适配器和出站消息处理器作为 Bean 注册到 Spring 容器里。
java复制package com.example.mqtt.config;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.mqtt.core.DefaultMqttPahoClientFactory;
import org.springframework.integration.mqtt.core.MqttPahoClientFactory;
import org.springframework.integration.mqtt.inbound.MqttPahoMessageDrivenChannelAdapter;
import org.springframework.integration.mqtt.outbound.MqttPahoMessageHandler;
import org.springframework.integration.mqtt.support.DefaultPahoMessageConverter;
import org.springframework.integration.channel.DirectChannel;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessageHandler;
@Configuration
public class MqttConfig {
@Value("${mqtt.broker.url}")
private String brokerUrl;
@Value("${mqtt.broker.username}")
private String username;
@Value("${mqtt.broker.password}")
private String password;
@Value("${mqtt.client.publishClientId}")
private String publishClientId;
@Value("${mqtt.client.subscribeClientId}")
private String subscribeClientId;
@Value("${mqtt.client.connectTimeout}")
private int connectTimeout;
@Value("${mqtt.client.keepAliveInterval}")
private int keepAliveInterval;
@Value("${mqtt.client.automaticReconnect}")
private boolean automaticReconnect;
@Value("${mqtt.client.cleanSession}")
private boolean cleanSession;
@Value("${mqtt.topic.inbound}")
private String inboundTopics;
@Bean
public MqttPahoClientFactory mqttClientFactory() {
MqttConnectOptions options = new MqttConnectOptions();
options.setServerURIs(new String[] { brokerUrl });
options.setUserName(username);
options.setPassword(password.toCharArray());
options.setConnectionTimeout(connectTimeout);
options.setKeepAliveInterval(keepAliveInterval);
options.setAutomaticReconnect(automaticReconnect);
options.setCleanSession(cleanSession);
DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory();
factory.setConnectionOptions(options);
return factory;
}
@Bean
public MessageChannel mqttInputChannel() {
return new DirectChannel();
}
@Bean
public MqttPahoMessageDrivenChannelAdapter inboundAdapter() {
MqttPahoMessageDrivenChannelAdapter adapter =
new MqttPahoMessageDrivenChannelAdapter(subscribeClientId, mqttClientFactory(), inboundTopics.split(","));
adapter.setCompletionTimeout(5000);
adapter.setConverter(new DefaultPahoMessageConverter());
adapter.setQos(1);
adapter.setOutputChannel(mqttInputChannel());
return adapter;
}
@Bean
public MessageChannel mqttOutboundChannel() {
return new DirectChannel();
}
@Bean
public MessageHandler outboundAdapter() {
MqttPahoMessageHandler handler = new MqttPahoMessageHandler(publishClientId, mqttClientFactory());
handler.setAsync(true);
handler.setDefaultTopic("default/topic");
handler.setDefaultQos(1);
return handler;
}
}
这个类写完后,Spring 容器里就有了两个核心通道:mqttInputChannel 是消息从 Broker 进来的入口,所有订阅到的报文都会先发到这个通道;mqttOutboundChannel 是要发出去的消息的出口,任何向这个通道发送的消息都会被 outboundAdapter 发布到指定 Topic。如果你不熟悉 Spring Integration,可以先把它想象成两个消息队列:一个收、一个发。
有几个配置细节我要特意强调一下。首先是 cleanSession 参数,很多教程默认不设置这个,Paho 客户端默认是 true,也就是说每次连接不会保留会话记录,离线时订阅关系也会被清理。如果业务希望设备和服务端在断线期间取消订阅由 Broker 缓存消息、等服务恢复后再接收,需要设为 false。但要注意,这会带来服务端离线期间消息积压在 Broker 内存的问题,并不是所有场景都适合。
其次是 adapter.setQos(1)。调用这个相当于统一设置订阅主题的 QoS 等级,对传入的所有主题都生效。如果你订阅的不同主题需要不同 QoS,那就不太适合用一个 adapter 一把梭,我后面会说到多 adapter 的扩展方式。
3.5 消息处理器:订阅到的消息去哪了
有了入站适配器,消息进入 mqttInputChannel 后还需要一个真正的消费端点来处理。这个消费端点是整个 Spring Integration MQTT 里最容易被忽视的地方,很多人把 adapter 配对以后就在等回调方法,但一直没有触发,原因往往是忘了给入站通道绑定 @ServiceActivator 处理器。
我的做法是单独写一个 MqttMessageHandler 组件,用 @ServiceActivator 注解指定输入通道,这样连接消费链路就闭环了。里面拿到的 Message 对象中,headers 里带有 mqtt_topic 等信息,body 是消息内容(默认是字符串或字节数组)。你可以在 beforePublish 等不同阶段插入自己的处理逻辑,一般我就在 handleMessage 方法里统一做消息分发:
java复制package com.example.mqtt.handler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.integration.channel.DirectChannel;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageHandler;
@Slf4j
@Configuration
public class MqttMessageHandler {
@Bean
public MessageChannel mqttInputChannel() {
return new DirectChannel();
}
@ServiceActivator(inputChannel = "mqttInputChannel")
public MessageHandler handleMessage() {
return message -> {
String topic = String.valueOf(message.getHeaders().get("mqtt_receivedTopic"));
Object payload = message.getPayload();
log.info("收到 MQTT 消息, topic: {}, payload: {}", topic, payload);
// 这里根据 topic 分发到不同的业务方法
// deviceDataService.process(topic, payload);
};
}
}
这里要注意的是,如果你把 MessageChannel 的 Bean 定义放在 config 包里,又把 @ServiceActivator 放在 handler 包里,一定要确保 Spring Boot 的包扫描能够扫到 MqttMessageHandler 这个类。我曾经遇到过一次服务启动后没有任何报错但就是收不到消息的情况,排查了半天最后发现是 handler 类没有放在主启动类能扫描到的子包下面。
3.6 通过 MqttGateway 发布消息
发布消息有两种常见的实现方式。一种是在业务代码中直接注入 MqttPahoMessageHandler,但不够 Spring 风格;另一种我更喜欢,是定义一个 @MessagingGateway 接口,把往 mqttOutboundChannel 通道发消息的动作抽象成方法调用,业务层不需要感知 MQTT 协议的细节,只需要调用 gateway.sendMessage(payload, topic) 就行。
java复制package com.example.mqtt.gateway;
import org.springframework.integration.annotation.MessagingGateway;
import org.springframework.integration.mqtt.support.MqttHeaders;
import org.springframework.messaging.handler.annotation.Header;
@MessagingGateway(defaultRequestChannel = "mqttOutboundChannel")
public interface MqttGateway {
void sendToMqtt(String data, @Header(MqttHeaders.TOPIC) String topic);
}
这样写的好处非常明显:如果你在业务代码里要下发控制指令,只需要注入 MqttGateway 这个接口,直接调用它的 sendToMqtt 方法即可。方法的第一个参数是要发送的消息体,第二个参数通过注解指定了要发布的 topic,在 Spring Integration 中这个 @Header(MqttHeaders.TOPIC) 参数会覆盖 outboundAdapter 里的 defaultTopic,从而支持程序里每个调用都传不同主题。
java复制@Autowired
private MqttGateway mqttGateway;
// 向某个设备下发指令
mqttGateway.sendToMqtt("{\"action\":\"open\"}", String.format("device/%s/command", deviceId));
在 @MessagingGateway 的处理逻辑中,方法名随便起,关键是 defaultRequestChannel 必须和 MqttConfig 中定义的 mqttOutboundChannel 通道名称一致。此外需要注意 MqttHeaders.TOPIC 是从 spring-integration-mqtt 中提供的常量,值为 mqtt_topic,如果你在方法参数上打的是 @Header("mqtt_topic") 同样可以。
到这里,一个最基本但完整的 Spring Boot MQTT 收发链路已经通了。启动 Spring Boot 项目后,你可以用 MQTTX 模拟一个设备向 device/001/status 发 JSON 消息,观察项目日志是否打印收到消息;再用 MQTTX 订阅 device/001/command,然后调用一次你项目的下发接口,观察 MQTTX 是否收到消息。如果这两条链路都通了,剩下的事情基本上就是业务逻辑。
4. 实际调试中那些高频问题的排查记录
4.1 服务启动后连不上或日志提示 connection lost
出现这种问题,第一反应先不要看代码,先用 MQTTX 或 mosquitto_sub 本地连一下 Broker,确认 Broker 本身是不是好的。不同机器的防火墙、云安全组经常把 1883 端口挡住,本地能连、服务器不能连非常常见。
Broker 没问题的话,就要去看连接参数。很多人会忽略 username 和 password,EMQX 默认其实不强制认证,但如果你在 Dashboard 里新建了用户,或者 Broker 开了认证插件,客户端就必须带上正确的账号密码。还有一点,Paho 默认走 TCP,如果你的 Broker 在域名后面走的是 SSL 端口(8883),那么 URL 前缀要改成 ssl://,还要额外配置 SSL 证书相关的 trustStore / hostnameVerifier,否则会报连接异常或握手失败。
另外项目部署在公网时,客户端所在网络到 Broker 的网络链路质量可能不稳定。配置 automaticReconnect=true 是个兜底方案,但 Paho 自动重连在断线后并不是立刻执行,而是有退避逻辑,因此如果业务重要,建议再结合 Spring 的 @Scheduled 定时检测连接状态,发现断开就主动重连。
4.2 客户端连接被频繁踢下线
如果你的服务部署了多个实例,并且所有实例的订阅 clientId 都相同,那它们会不断把对方踢下线,表现就是日志里反复出现连接被关闭、莫名其妙断开。MQTT 协议规定同一 broker 下 clientId 必须唯一,这是很多人都踩过的雷。
解决办法是让 clientId 带上实例特征。例如通过 InetAddress.getLocalHost().getHostName() 拼上随机数:
java复制String clientId = "mqtt-server-" + hostName + "-" + UUID.randomUUID().toString().substring(0, 8);
生产环境多实例部署时还要注意一点:如果两个实例都订阅了同一个主题,消息会被广播到两个实例,导致业务重复消费。如果你需要的是负载均衡,也就是一条消息只被一个实例处理,MQTT 本身通过“共享订阅”($share/group/topic)语法实现。EMQX 和 Mosquitto 2.x 以上都支持共享订阅,Spring Integration MQTT 的 adapter 在 topic 前面以 $share/... 开头即可实现。不要靠随机让两个实例各自订阅同一个 topic,那根本不是负载均衡。
4.3 订阅收到消息,但 @ServiceActivator 不执行
出现这个问题,说明消息确实到达了 Spring Integration 的 adapter 层,但没被正确路由到你的业务方法。先看两个地方:第一,MqttPahoMessageDrivenChannelAdapter 是否设置了 outputChannel,如果设置的是别的 channel,而 @ServiceActivator 监听的是你预期那个 channel,自然是收不到;第二,@ServiceActivator(inputChannel = "mqttInputChannel") 中指定的通道名,是否和 MqttConfig 中 @Bean MessageChannel 的方法名/Bean 名称一致,Spring 容器里通道 Bean 默认名称是方法名,比如 public MessageChannel mqttInputChannel() 的 Bean 名称是 mqttInputChannel。
如果这些检查都没问题,再看一下工程包扫描有没有问题,把配置类和 handler 放到主应用启动类同包或子包下,且启动类上保留 @SpringBootApplication。一个比较隐蔽的点是 @ServiceActivator 的方法如果放在 @Configuration 类里,方法体内返回的 MessageHandler 如果抛了异常,消息会被作为错误处理,默认只记录日志,不会重复调用,你可以在日志级别调为 DEBUG 观察是否抛出异常。
4.4 消息重复,或者 QoS 1 依然丢消息
不少同学会认为把 QoS 设为 1 就万事大吉,但 MQTT 的 QoS 保证的是“消息从发布者到 Broker、以及从 Broker 到订阅者”这个链路层面不因网络误判而丢消息,并不代表业务层面不会重复或不会丢失。实际场景中,客户端在发送后没收到确认就断开,重连后会重新发送,Broker 可能已经接收过一次了,于是订阅者就收到了两条同样的消息。也就是说 QoS 1 天然可能重复。
处理思路在业务层做幂等。最简单的方案是每个消息带上唯一的消息 ID,比如设备上报数据里带一个 eventId 或 uuid,服务端处理前先查一下 Redis 或数据库这个 ID 是否消费过。如果不想引入额外存储,还可以在业务表中建立唯一键约束,用数据库的幂等性来兜底。
至于“QoS 1 依然丢消息”的情况,常见原因不是协议问题,而是代码逻辑问题。比如你在接收消息的方法里做了比较耗时的数据库操作,这条消息在消费期间如果进程重启,因为 cleanSession 的原因历史消息已经没了,就会丢。要尽量避免在 MQTT 回调线程里做重逻辑,建议把消息先发到内存队列或直接异步处理,MQTT 回调必须快速返回,否则后续消息会越积越多。
4.5 Spring Boot 版本太高导致的不兼容问题
网上不少文章年代较久,代码直接拿来跑,老报错。Spring Boot 2.7.x 和 3.x 之间一个重要变化是 Java 17+ 和 jakarta 命名空间,MQTT 这个方向本身受影响不大,受影响的是工程里其他依赖。如果你真的要用 Spring Boot 3.2 以上,建议依赖引用的 spring-integration-mqtt 由 Spring Boot 的 BOM 统一管理,不要手工指定低版本,否则可能出现 NoClassDefFoundError。
对于只是想先跑通项目的人来说,我依然建议用 Spring Boot 2.7.18,这是 2.x 的收尾版本,坑已经被踩得比较干净,Spring Integration 5.5 的 API 很稳定。先跑通,再根据生产需要决定是否升级。
5. 从能跑的 Demo 到能上生产的最后一段路
5.1 Topic 的命名规范与设计原则
很多 Demo 直接把主题写成 a/b/c 这种看起来很随意的格式,但真实物联网项目里,Topic 往往是整个平台数据流的骨架,一旦上线之后改动成本很高。我的经验是:结构上采用分组前缀加设备树的方式,例如 smart_home/{productKey}/{deviceId}/{messageType}。productKey 用来区分产品型号,deviceId 是设备的唯一标识,messageType 表示数据的业务类型,比如 status、event、command、command_reply。
几个建议供参考。第一,每个层级的命名使用小写字母、数字、中划线,避免使用空格和中文,虽然 MQTT 协议本身对 UTF-8 主题是支持的,但中间件和下游消费者处理都容易出问题;第二,尽量控制主题层级数,3 到 5 层比较合理,层级越多 topic 越长,Broker 做通配符匹配时要扫描的次数也越多;第三,通配符的使用要克制,订阅 # 虽然开发调试时非常方便,但在生产环境中会让客户端收到全量消息,流量和安全性都不可控。
5.2 设备鉴权、消息加密与 QoS 决策
如果把系统暴露到公网,一定不能裸奔。对设备端来说,常见做法是每个设备下发独立的用户名和密码(比如 AccessKey/SecretKey),Broker 开启 ACL 插件,让每台设备只能发布和订阅自己前缀下的主题,从根上防止一台设备被攻破后影响其他设备。
消息体加密要看场景,敏感数据建议在消息体内做 AES 或国密加解密,而不是依赖传输层 TLS 一把梭。TLS 解决的是传输链路加密问题,但 Broker 端如果本身不可信,或者消息会在某个平台中转,应用层加密更容易控制边界。
QoS 的决策没有一个固定公式,我从实际场景里总结的经验是:普通周期上报数据适合 QoS 0,丢了影响不大,带宽开销最小;关键指令和离线补报数据适合 QoS 1,确保收到;对重复极其敏感的场景才考虑 QoS 2,但需要接受更大的开销。同时建议 control command 的下发使用 Qos 1 并且要求设备回复 ACK,服务端通过超时重发来兜底。
5.3 消息量大了以后,可以考虑的分层方案
如果只是几十台设备,单台 Spring Boot 服务直接订阅并处理没什么问题。但如果设备量上千、消息量每秒几百上千条,你需要考虑几件事:一是 Broker 集群化,二是消息的消费和处理是否要做分离。
很多团队会采用“Broker 接入层 + 消息总线 + 业务处理层”的分层架构。Spring Boot 作为 MQTT 客户端只负责把消息接进来,然后不做业务逻辑,直接把消息转发到 Kafka 或 RocketMQ,由后端的流处理引擎来消费落库、计算、报警。Spring Boot 的 spring-kafka 支持在这种模式下配置得很顺,两者都是成熟的中间件,组合在一起的扩展性会比直接让 Spring Boot 扛全部消息好很多。当然,这取决于团队规模和业务阶段,过早引入 Kafka 也不是好事,单体扛得住时好好优化 JVM 和数据库连接池可能更实际。
5.4 补充一个新手容易忽略的小技巧
调通基本链路之后,你可以在 EMQX Dashboard 的“主题监控”页面里实时查看消息收发速率和延迟,这对判断瓶颈很有帮助。如果你用的是 Mosquitto,可以订阅 $SYS/broker/messages/received、$SYS/broker/messages/sent 这类 $SYS 开头的系统主题来查看 Broker 统计指标。调 MQTT 程序时不要两眼一抹黑地在代码里瞎试,先把协议层和 Broker 状态摸透了,问题范围一下子就缩小了。
我在实际做过的几个项目里,总体感受是 MQTT 这套东西并不难,难的是把业务可靠性做到位。很多方案从 Demo 到产品只差一步——断线重连做了没有、消息幂等做了没有、Broker 挂了怎么做主备切换、设备端会不会重复发布指令。如果这些你都考虑得比团队其他人早一步,你在这个领域的技术判断力就会明显拉开差距。文中给的代码是基于 Spring Integration MQTT 的常用写法,不同版本的细节会有差异,但核心思路是不变的:先定义连接,再定义收发通道,最后把通道接进业务方法。希望这篇文章能帮你少走一些弯路。
