MQTT 我已经用了好几年,最早是在一个农业物联网项目里,给两百多个大棚传感器做数据上报。第一版方案用的是 HTTP 轮询,网关每几秒拉一次数据,服务器压力大不说,一旦网络抖动,一批数据直接丢失。后来切到 MQTT,整个系统才真正稳定下来,设备状态也能实时拿到。这篇文章我想把 MQTT 最核心的三块内容——发布订阅模型、QoS 等级、遗嘱消息——结合我实际踩过的坑讲清楚,最后附一套可以照着跑的实战链路。如果你正准备入门 MQTT,或者已经在用但只停留在会调用库的层面,这篇应该能帮你把协议的框架补全。
1. 为什么物联网场景绕不开 MQTT
1.1 MQTT 诞生的背景与协议定位
MQTT 全称 MQ Telemetry Transport,最早由 IBM 在 1999 年前后提出,设计目标是在低带宽、不可靠的卫星网络上传输遥测数据。这个背景很关键,它决定了协议很多设计取向:报文尽量小、允许断线重连、支持状态感知。
和 HTTP 这种通用协议不一样,MQTT 不是为网页设计的,它天生是给“机器与机器”通信用的。协议底层跑在 TCP 上,但是把不可靠网络、弱网、NAT 穿透当成了常态来处理。现在的物联网云平台,比如阿里云物联网平台、AWS IoT Core,基本都把 MQTT 作为设备接入的默认协议之一,生态已经非常成熟。
所以如果你做嵌入式、边缘网关、上层业务系统,只要涉及设备接入、远程控制、状态上报,MQTT 几乎是一个绕不开的选项。它不是唯一答案,但一定是一个值得优先考虑的标准答案。
1.2 和 HTTP 长连接比,优势在哪
很多人第一次接触 MQTT 会把注意力放在“长连接”上,误以为它和 HTTP 长轮询、WebSocket 差不多。其实最本质的区别不是连接方式,而是通信模型。
HTTP 是请求/响应模型,客户端不主动问,服务器就很难把数据推给客户端,所以 Web 场景只能靠轮询或者 WebSocket 补。MQTT 是发布/订阅模型,Broker 作为消息中枢,消息一旦发布,所有订阅者都能在第一时间收到。打个比方:HTTP 像你不断打电话给报刊亭问“今天有什么新闻”,MQTT 像你直接订了一份报纸,出了就直接送到家。
这个模型带来的另一个优势是解耦。发布者不需要知道订阅者是谁、有多少个、在不在线,订阅者也不需要知道发布者的地址。设备上线、下线、更换 IP,对消息链路没有影响。这在海量设备场景里非常重要,因为你不可能让每个设备之间都建立点到点连接。
1.3 适合谁来用,解决什么问题
MQTT 适合的场景主要有几类:传感器数据采集上报、远程控制指令下发、设备在线状态管理、告警事件通知。典型行业是智能家居、工业自动化、车联网、充电桩、环境监测、物流追踪。
它不适合的场景也很明显:大数据文件传输、实时音视频流、超低延迟运动控制。这些场景要么带宽不够,要么延迟要求太高,MQTT 定位就不是干这个的。
如果你是后端开发,可以用 MQTT 做服务端消息接入;如果你是嵌入式开发,可以把 MQTT 跑在 MCU、Linux 板卡、4G 模块上;如果你是测试或者运维,MQTT 也很适合做联调和压测。可以说,这个协议几乎是物联网从业者的通用语言。
2. 发布订阅机制:理解 MQTT 的“邮局”模型
2.1 主题的层级结构与通配符规则
MQTT 的发布订阅模型里,最核心的概念就是主题(Topic)。主题是一个 UTF-8 字符串,用斜杠 / 做层级分割,比如:
device/1001/sensor/temperature device/1001/control/relay sensor/room1/temperatureBroker 不关心主题里写的是什么,它只负责字符串匹配和消息路由。所以主题设计非常自由,但也非常容易踩坑。我的建议是:一开始就规划好层级结构,把设备 ID、数据类型、上报链路放对位置。否则后面设备多了,ACL 权限、数据转发、问题排查都会很难受。
通配符有两个:
+匹配一层,例如sensor/+/temperature可以匹配sensor/room1/temperature、sensor/room2/temperature,但不能匹配sensor/room1/floor/temperature。#匹配多层,而且必须放在主题末尾。例如device/1001/#可以匹配device/1001/sensor/temperature,也可以匹配device/1001/control/relay。
有一点要注意:+匹配的是“一整个层级”,不能匹配层级之间的斜杠,也不能匹配空层级。比如sensor/+/temperature不会匹配sensor//temperature。#可以匹配零个或多个层级,所以device/1001/#能匹配device/1001本身。
发布者和订阅者不需要事先认识。客户端 A 往某个主题发布消息,客户端 B 订阅这个主题,消息就会通过 Broker 路由过去。主题不需要提前创建,也不需要删除,Broker 在这块几乎是无状态的。
2.2 保留消息:新订阅者也能拿到“上一次的状态”
默认情况下,新客户端订阅一个主题后,只能收到之后发布的消息,之前发过的消息是收不到的。这在很多场景下不够用。比如设备状态,你希望新接入的订阅者一上线就能看到当前设备是“在线”还是“离线”,而不是等设备下一次上报。
MQTT 用保留消息(Retained Message)解决这个问题。发布消息时把 Retain 标志设为 true,Broker 就会为这个主题保存最后一条消息。之后有新的订阅者订阅该主题,Broker 会立刻把这条保留消息推给订阅者。
比如设备启动后,向device/1001/status发布一条online,retain=true。以后任何人订阅这个主题,第一眼看到的就是online。设备正常下线或异常掉线时,再发布一条offline,retain=true,状态就被覆盖了。
这里有几个容易被忽略的细节:保留消息每个主题只保存一条,不是消息队列;如果你想清除保留消息,可以向该主题发布一条空 payload 且 retain=true 的消息,Broker 会删除保留消息但不推送空消息给已有订阅者。
2.3 会话与离线消息:clean session 怎么影响后续恢复
MQTT 客户端连接 Broke r时,需要指定一个唯一的 Client ID。Broker 会根据 Client ID 维护会话信息,其中就包括订阅关系,以及 QoS 1/2 下尚未确认的离线消息。
这里的关键参数是 Clean Session,在 MQTT 3.1.1 里叫 clean session,在 MQTT 5.0 里被拆成了 Session Expiry Interval。简单理解:
- Clean Session = true(会话不持久):连接断开后,Broker 直接删除会话,订阅关系、离线消息全部清空。下次重连,客户端要重新订阅。
- Clean Session = false(会话持久):断线后,Broker 保留订阅关系和离线消息。客户端下次用同一个 Client ID 重连,会自动恢复订阅,并收到离线期间积压的 QoS 1/2 消息。
这个机制在实际项目里非常重要。比如一个传感器设备每隔 5 分钟上报一次数据,如果网络临时断开 10 分钟,用 clean session=false 的话,重连后会把断线期间的 QoS 1 消息补传上来,减少数据空洞。
但要注意:持久会话并不能保证消息绝对不丢。如果 Broker 重启时没开启持久化,或者客户端 Clean Session 状态被重置,离线消息还是可能丢失。而且,持久会话会让 Broker 一直保存会话状态,如果有几万设备长期失联,内存压力会很大,需要设置合理的 Session Expiry 时间。
3. QoS 等级详解:从 0 到 2 可靠性不是越高越好
3.1 QoS 0:最多一次,丢就丢了
QoS 0 是“最多一次”(At most once)。消息发出后,Broker 不会确认,发布者也不知道消息到底有没有到达。这种模式开销最小、吞吐量最高,但网络抖动时消息可能丢失。
什么场景适合 QoS 0?高频环境传感器数据、位置上报、日志。比如一个设备每 2 秒上报一次温度,丢一条甚至丢十条都不影响整体曲线,那完全可以用 QoS 0。我见过有人把温度数据设成 QoS 2,结果是弱网环境下消息积压几百条,设备内存都被撑爆了,完全没必要。
QoS 0 并不是协议不保证,而是你自己选择接受“可能丢”。在实际调试中,如果发现订阅端数据出现了空洞,先用工具确认是不是发布端 QoS 设成了 0,再考虑网络问题。
3.2 QoS 1:至少一次,重复可以接受吗
QoS 1 是“至少一次”(At least once)。发布者发送 PUBLISH 后,Broker 收到会回 PUBACK。发布者收到 PUBACK,就知道消息已经被 Broker 接收。
听起来很完美,但有一个隐藏问题:如果 PUBACK 在网络中丢失,发布者会重新发送 PUBLISH,Broker 会再次收到同一条消息。也就是说,QoS 1 保证消息不丢,但不保证不重复。
所以 QoS 1 适合那些“重复可以容忍,丢失不能容忍”的场景,比如控制指令、报警事件。设备收到“打开继电器”指令,即使收到两次,只要指令是幂等的(打开两次结果一样),就没问题。但如果是“增加余额”这类操作,重复就麻烦了。
工程上处理重复很简单:在消息 payload 里加一个唯一消息 ID,消费端做去重。这是很通用也很实用的做法。
3.3 QoS 2:恰好一次,用四步握手换可靠性
QoS 2 是“恰好一次”(Exactly once),协议设计上用四次握手来保证消息不丢、不重复。流程大致是:
- 发布者发送 PUBLISH;
- Broker 收到后回 PUBREC,表示已经接收;
- 发布者回 PUBREL,表示确认;
- Broker 收到 PUBREL 后回 PUBCOMP,整个流程结束。
如果中间任何一步报文丢失,发送方和 Broker 都会重发对应的控制报文,直到完成流程。这套机制保证了消息在 MQTT 协议层是幂等的。
代价也很明显:报文数量多、延迟高、实现复杂。在弱网环境下,QoS 2 的流程更容易被中断,如果客户端处理不好,反而可能出现消息卡住的情况。所以 QoS 2 只建议用在真正不能重复的业务上,比如计费、订单、数据库写主记录。
3.4 三个等级的选型组合:工程上的折中
QoS 是一个很容易被误解的参数,尤其在订阅端。实际交付给某个订阅者的 QoS 等级,是由发布 QoS 和该订阅者订阅时请求的 QoS 共同决定的,取两者中较低的那个。
举个例子:发布者以 QoS 1 发布消息,订阅者订阅时请求 QoS 0,那么这个订阅者实际只会收到 QoS 0 的消息。反过来,发布者以 QoS 0 发布,订阅者订阅时请求 QoS 2,实际收到也是 QoS 0。所以你在客户端订阅界面里把 QoS 拉到 2,并不能保证消息一定可靠投递。
工程上的选型组合我一般是这样的:
- 基础状态上报、遥测数据:QoS 0,丢了就丢了,重传反而增加负担;
- 控制指令、告警事件:QoS 1,配合应用层幂等去重;
- 计费、订单、关键配置变更:QoS 2,但要评估 Broker 压力和弱网表现。
还要注意,同一套链路上如果大量使用 QoS 2,Broker 的会话状态会不断膨胀,消息吞吐也会下降。我踩过的一个坑就是早期把所有消息都设成 QoS 2,结果设备多了之后 Broker 内存涨得飞快,后面才改成“QoS 1 + 业务去重”的方案。
4. 遗嘱消息:设备掉线后的最后一声“遗言”
4.1 遗嘱到底是什么,什么时候触发
遗嘱消息也叫 Last Will and Testament,简称 LWT,是 MQTT 里一个非常有意思的机制。客户端在连接时就告诉 Broker:如果之后我被检测到异常掉线,就请你替我发布一条消息到指定主题。
遗嘱不是在客户端本地发的,而是由 Broker 代发。触发条件有几种:
- Broker 在 Keep Alive 时间内没有收到客户端的任何报文,包括 PINGREQ,判定客户端失联;
- 客户端异常断开 TCP,触发 Socket 错误;
- 客户端违反协议,被 Broker 断开连接。
不触发的情况也很重要:客户端主动发送 DISCONNECT 之后正常下线,这是“我主动走”,Broker 不会发遗嘱。我还要提醒一句:Broker 自身崩溃或重启,有些实现不会处理遗嘱,需要看具体 Broker 配置。
你可以把遗嘱理解成“失联后由 Broker 替设备发布的告别消息”。它不是协议层的错误处理,而是让其他订阅者有机会感知“这个设备可能已经不在了”。
4.2 遗嘱消息的配置参数和使用场景
在连接时设置遗嘱,主要涉及几个参数:Will Topic、Will QoS、Will Retain、Will Payload。
典型配置是:
- Will Topic:
device/{device_id}/status - Will Payload:
offline - Will QoS:1
- Will Retain:true
这样设备掉线后,Broker 会向device/{device_id}/status发布一条offline,并且作为保留消息保存。之后任何人订阅这个状态主题,都能看到设备是离线的。
实际场景里,遗嘱消息最常用在设备在线状态管理,还有网关断电告警、分布式节点失联通知。比如一个网关同时管理多台 PLC,网关掉线时上层系统需要及时知道,就能靠遗嘱消息。还有无人值守充电桩,断网了要能在运营平台上看到离线状态。
配置遗嘱时不要只设置文本,还要考虑 QoS。如果遗嘱消息本身用 QoS 0,在弱网下也可能丢,那“离线告警”这个动作的意义就减弱了。我一般习惯用 QoS 1,至少保证 Broker 到订阅端这段链路尽力送达。
4.3 用遗嘱做在线状态管理时的常见坑
遗嘱消息看起来简单,实际用起来坑不少。
第一个坑:触发有延迟。Broker 只有在 Keep Alive 超时后才会判定客户端失联,不是立刻触发。假如 Keep Alive 设成 60 秒,那设备断网后,订阅端可能要等 60 到 90 秒才能真正收到离线消息。如果你想要秒级感知,必须把 Keep Alive 设小,同时接受客户端可能因为网络抖动频繁被判定离线。
第二个坑:正常退出不一定不触发。很多客户端库如果程序崩溃被强杀,TCP 连接会被操作系统重置,Broker 会判定异常断开,从而触发遗嘱。所以如果你在测试时发现“客户端明明自己退出了,怎么还是收到了遗嘱”,先检查代码里有没有正确调用 disconnect 方法。
第三个坑:遗嘱和持久会话的交互。如果客户端使用 clean session=false 且断线后很快重新连接,Broker 可能不会立刻发布遗嘱,因为它还没等到 Keep Alive 超时。这时上层系统如果只订阅遗嘱,会以为设备一直在线,实际上它已经断了 20 秒。许多团队做在线状态会用“心跳上报 + 遗嘱覆盖”两个机制配合,而不是只依赖遗嘱。
5. 实战:从零搭一条能跑通的 MQTT 链路
5.1 服务端选型与本地搭建
学习 MQTT 时,最常用的 Broker 是 Mosquitto,轻量、开源、部署简单。拿来做单机测试、中小型项目完全够用。生产环境如果设备量很大,或者需要集群、规则引擎、插件化接入,可以用 EMQX,它是目前开源社区里很活跃的 MQTT Broker。
本地搭建最简单的方式是 Docker:
docker run -d --name mosquitto -p 1883:1883 eclipse-mosquitto:2如果你本机装了 Ubuntu,也可以用 apt 安装:
sudo apt-get install mosquitto mosquitto-clients安装完之后,默认监听 1883 端口。注意 Mosquitto 2.x 版本默认只允许本机连接,如果想允许局域网测试,要改配置文件。新建一个mosquitto.conf:
listener 1883 allow_anonymous true然后启动:
mosquitto -c mosquitto.conf -d这样本地和一个简单的 MQTT Broker 就算跑起来了。生产环境建议开启用户名密码认证和 TLS 加密,默认配置不适合直接暴露到公网。
5.2 可视化客户端验证消息流
命令行工具可以快速测试,但做联调时我更推荐用 MQTT X,这是一个跨平台的可视化 MQTT 客户端,界面简单,支持多连调、遗嘱设置、保留消息、QoS 设置。
你可以建立两个连接。第一个连接作为订阅端,订阅test/#。第二个连接作为发布端,向test/hello发送一条消息。立刻就能在第一个连接里看到消息实时到达。
MQTT X 还支持在连接设置里配遗嘱。你可以新建一个连接,填上 Will Topic、Will Payload,然后直接断开这个连接的网络来模拟异常掉线,在订阅端观察遗嘱消息是否到达。这个过程是用很直观的方式验证协议机制。
5.3 用 Python 实现 QoS 与遗嘱的完整例子
接下来我写一个完整的 Python 示例,用 paho-mqtt 库实现连接、设置遗嘱、订阅、发布 QoS 1 消息。
先安装依赖:
pip install paho-mqtt客户端代码:
import paho.mqtt.client as mqtt import time BROKER_HOST = "127.0.0.1" BROKER_PORT = 1883 CLIENT_ID = "demo-device-001" def on_connect(client, userdata, flags, rc): print(f"connected, rc={rc}") client.subscribe("device/001/control", qos=1) def on_message(client, userdata, msg): print(f"topic: {msg.topic}, payload: {msg.payload.decode()}, qos: {msg.qos}") client = mqtt.Client(client_id=CLIENT_ID, clean_session=False) client.on_connect = on_connect client.on_message = on_message # 设置遗嘱:如果这个设备异常掉线,Broker 帮忙发布 offline client.will_set("device/001/status", payload="offline", qos=1, retain=True) client.connect(BROKER_HOST, BROKER_PORT, keepalive=30) client.loop_start() # 设备上线,覆盖遗嘱中的 offline 为 online client.publish("device/001/status", payload="online", qos=1, retain=True) for i in range(5): print(f"publish message {i}") client.publish("device/001/sensor", payload=f"{i}", qos=1) time.sleep(1) # 等待消息接收 time.sleep(3) client.disconnect() client.loop_stop()这个例子做了几件事:
- 设置 clean_session=False,让 Broker 保存会话和订阅关系;
- 连接时设置遗嘱主题和内容;
- 设备上线后立刻发布 retained 的 online 状态;
- 循环发布 QoS 1 的传感器数据。
你可以打开两个终端,一个跑这段代码,另一个用 MQTT X 订阅device/001/#,就能看到消息流。如果你想验证遗嘱,把代码里的disconnect()注释掉,然后直接 Ctrl+C 强杀进程,订阅端会在 Keep Alive 超时后看到offline。
5.4 嵌入式、边缘网关与后端集成的一点经验
实战中,MQTT 往往不会只跑在单一端点上,而是贯穿设备侧、边缘侧、云端和业务后台。
嵌入式 STM32 这类 MCU 上移植 MQTT,最常用的方案是 paho 的 embedded-c 库,但它只提供协议解析,你要自备 TCP 传输。如果需要上 TLS,通常配合 mbedTLS 做加密通信。很多 4G 模块,比如移远的 EC200 系列,支持 PPP 拨号或者内置 TCP/IP 协议栈,MCU 通过 AT 指令建立 TCP 连接后,再跑 MQTT 协议。也有模块直接提供 MQTT 的 AT 指令,省了 MCU 上协议栈的活儿,但灵活性会差一些。
边缘侧,Node-RED 是很多工业项目熟悉的工具,用node-red-contrib-opcua读取 OPC UA 服务器里的点位,再用mqtt out节点把数据转成 JSON 发布到 MQTT Broker,就能把老旧的工业协议和现代物联网平台打通。
后端集成也常见。比如在 RuoYi 这类管理系统里接 MQTT,写一个消费者订阅设备主题,设备数据进来后落库,再通过 WebSocket 推给前端页面。这个链路已经很成熟了。
如果你做的是 ROS2 相关的机器人项目,要注意 ROS2 里也有 QoS 概念,但它描述的是可靠性策略、历史数据保留策略,和 MQTT 的 QoS 等级完全是两码事。做 ROS2 到 MQTT 的桥接时,别把两套“QoS”混为一谈。性能压测方面,JMeter 可以通过安装 MQTT 插件来构造并发连接和消息风暴,这对评估 Broker 容量很有用。
6. 常见问题与排查技巧实录
6.1 客户端连接后反复断开:先查心跳与 keepalive
这是我最常被问的问题之一。客户端连上 Broker 后又断开,过一会儿又连上,日志里能看到大量连接和断开记录。
第一个要查的就是 Keep Alive。默认可能 60 秒,如果网络链路中间有 NAT 超时,比如设备在家庭路由器后面,路由器对空闲连接的空闲超时只有 30 秒,那客户端不主动发包,连接就会被中间设备掐掉。解决方案是把 Keep Alive 调小到 10 到 30 秒,让客户端更频繁地发 PINGREQ 保活。
第二个要查 Broker 的连接限制。Mosquitto 默认有max_connections限制,EMQX 也有连接数、客户端数量限制。设备多了之后,连接被拒绝是很常见的。第三个是认证问题,如果用户名密码或 TLS 证书配置错误,Broker 会在握手阶段断开连接,客户端日志不会一直报“connection refused”,而是连接成功后立刻断开。
排查这类问题,建议先开客户端日志,再看 Broker 日志,两边对一下时间点。多数时候,一个 Keep Alive 参数就能解决一大半问题。
6.2 订阅不到消息:主题、通配符、权限逐个排查
订阅不到消息,按下面顺序排查:
- 主题是否完全一致。MQTT 主题区分大小写,
Device/001和device/001是两个完全不同的主题。 - 通配符是否放对位置。
sensor/+/temperature不能匹配sensor/room1/floor/temperature,#如果不在主题末尾也会导致解析失败。 - 发布 QoS 和订阅 QoS 是否低于预期。如果两边都是 0,在弱网下消息丢失概率不是零,可以先改成 QoS 1 测试。
- 是否启用了 ACL 权限。Mosquitto 默认允许匿名,但生产环境通常会配权限,订阅端没有订阅权限时,Broker 会直接拒绝或者静默丢弃。
- 保留消息的问题。如果你订阅的时候主题上没有保留消息,你自然看不到“旧状态”,但这不代表订阅失败。
还有一个容易被忽略的点:同一个 Client ID 被多个客户端连接时,后一个连接会把前一个踢掉,订阅关系也会跟着乱。排查时确认每个客户端都用唯一 Client ID。
6.3 QoS 2 消息卡住或重复:看会话与消息 ID
如果你的业务用了 QoS 2,经常会遇到一种现象:消息只是发出去一次,但消费端却收到了两条。
先别急着怀疑 Broker 有问题,多半是消费端的会话设置导致重复投递。比如客户端用 clean_session=false 订阅了主题,断线时 Broker 保存了离线消息,重连后补投了这些消息。此时如果应用层不处理去重,就会看到重复。
另一个情况是 QoS 2 流程卡住。比如 PUBREL 报文丢了,Broker 会一直等待,消费端也一直等新消息。这种时候要检查客户端有没有正确处理 PUBREC 和 PUBREL,另外调整 Broker 端的 allowed protocol violations 和报文超时设置。
从实践来看,业务系统里最好别完全依赖协议来去重。我在很多项目里都会在 payload 里带一个msg_id字段,消费端用 Redis 或者数据库做唯一键,比改用 QoS 2 更省心。
6.4 遗嘱消息误触发或漏触发:重连与超时设置
遗嘱触发不总是符合预期。最典型的是弱网环境,设备其实还运行着,只是网络闪断了几十秒,Broker 在 Keep Alive 超时后判定设备失联,于是发布遗嘱。等设备重新连上,下层业务组件看到离线又上线两条消息,就会产生误告警。
这种情况可以从两个方向优化:把 Keep Alive 调大,容忍一定时间的网络抖动;或者让客户端重连后第一时间发布“上线”状态,用 online 覆盖掉之前的 offline 遗嘱。我用的方案一般是后者,因为 Keep Alive 调太大会让真实离线感知变慢。
还有一个漏触发场景:客户端异常掉线,但它和 Broker 之间的中间网络设备没有及时发 TCP RST,Broker 还是要等 Keep Alive 超时。如果你的上层系统依赖遗嘱做秒级响应,最好在客户端侧自己加一个“心跳定时任务”,同时订阅心跳主题,而不是只等遗嘱消息。
最后再说点实在的
整套 MQTT 机制我用下来,感受最深的是“不要盲目追求最高可靠性”。协议只是工具,QoS 2 不代表更好,遗嘱也不是万能的。真正重要的是先把业务场景想清楚:哪些消息丢一两帧无所谓,哪些消息不能丢,哪些消息重复了会出大事。想清楚了再选 QoS,再配遗嘱和会话策略,才不会在弱网上被自己的消息积压拖垮。
我早期有一次把所有设备数据都设置成 QoS 2,结果是弱网下积压了大量未确认消息,设备内存持续增长,最后整个链路卡死。后来改成 QoS 1 + 业务去重,反而稳定得多。这个经验放在这里,希望读到这里的你少走一次弯路。