最近项目里要接一批物联网设备,领导提出学习MQTT协议,用于未来统一管理设备接入。之前我做的都是 HJ 212、SL 651 这类「一问一答」的规约——字节帧、功能码、CRC、串口链路,思路是「中心站主动召测,设备被动应答」。第一次认真看 MQTT 的时候有点不适应:没有地址、没有功能码、没有轮询,设备只管往一个主题上「喊」,谁关心谁来听。这篇就是我这段时间边学边整理的笔记,从发布订阅模型一路记到 QoS、会话和 Java 落地,尽量把「协议为什么这么设计」讲清楚。
文中涉及的标准与文档我列在最后一节,方便回头复习。
一、先搞清楚 MQTT 解决什么问题
MQTT 全称 Message Queuing Telemetry Transport,最早是 1999 年 IBM 的 Andy Stanford-Clark 和 Arcom(后来被 Cirrus Logic 收购)的 Arlen Nipper 为石油管道遥测场景设计的:当时设备通过卫星链路回传数据,带宽贵、延迟高、链路还不稳,需要一个「包头极小、能在弱网下可靠工作」的协议。这段出身决定了它的所有设计取向。
它现在是 OASIS 的标准:
几个一眼能记住的特征:
基于 TCP(也可以用 WebSocket 承载),默认端口 1883,TLS 走 8883,WebSocket 走 8083/8084;
发布/订阅模型,客户端之间不直连,全部经过 Broker 转发;
包极小:固定头最少只要 2 个字节,这是它在窄带、低功耗场景能赢的关键;
报文类型总共 14 种,全部用第一个字节的高 4 位表示,协议体量小到可以用一个下午通读一遍标准。
我一开始的误区是把它当成「又一个 HTTP」。其实定位完全不同:HTTP 是「请求—响应」的同步模型,客户端必须知道服务端地址、必须等响应、连接用完就关;MQTT 是「事件流」模型,客户端只管订阅自己关心的主题,消息什么时候来、有几条、从哪来,发布方和订阅方互相都不知道。
二、发布/订阅模型:一次彻底的解耦
整个 MQTT 世界里只有两种角色:
Broker 是唯一的「中间人」,带来的价值是三个维度的解耦:
空间解耦:发布者不需要知道订阅者的 IP、端口,连有几个订阅者都不知道;
时间解耦:订阅者不在线时消息可以被 Broker 暂存(依赖会话,见第九节);
流程解耦:发布者发完就走,不需要等待处理结果,天然是异步的。
和传统的「HTTP 轮询」相比,差异非常直观:
最后一行是我觉得最本质的一点:如果 1000 个设备要把数据送给 5 个下游系统,轮询或直连是 5000 条链路要维护;用 MQTT 就是 1005 条连接,而且下游增加、减少完全不影响设备侧。
三、主题 Topic:分层寻址与通配符
MQTT 里没有「设备地址」的概念,取而代之的是 主题(Topic)——一个用 / 分层的 UTF-8 字符串,比如:
sensor/room1/temperature
sensor/room1/humidity
sensor/room2/temperature
factory/line3/plc/status
几条必须记牢的规则:
主题区分大小写,
Sensor/Room1和sensor/room1是两个不同的主题;长度上限 65535 字节;
sensor/room1和/sensor/room1、sensor/room1/是三个不同的主题,实际项目里一定要约定好前缀,否则收不到消息时排查起来很痛苦;层级可以为空,
a//b是合法的(中间是一级空主题);以
$开头的主题是 Broker 保留的(例如 Mosquitto/EMQX 的$SYS/系统主题),普通客户端不建议使用。
3.1 两个通配符
订阅时可以带通配符,但发布时不允许使用(发布了也没人知道该匹配给谁):
几个容易写错的点:
sport/#会匹配sport本身(#包含父级),但sport/+不会;+必须独占一层,sensor/room+/temperature是非法的,它只是一个普通层级名;以
$开头的主题不会被#或+意外匹配到,比如#收不到$SYS/broker/uptime,得显式订阅$SYS/#。
3.2 主题设计的一点经验
这块是我踩过坑的地方。主题不是「随便起个名字」,它同时承担了路由键、权限粒度和运维维度的作用,建议按「业务域/位置/设备类型/指标」的固定层级设计,把「变化的部分」放在层级中间,把「需要整体订阅的部分」放在前缀。举个例子:
# 按位置订阅(运维视角)
factory/+/line3/#
# 按指标订阅(算法侧视角)
factory/+/+/temperature
如果一个主题层级里塞了业务上不稳定的字段,后面必然会出现「订阅写不出来、只能订阅 # 然后全量过滤」的窘境——那等于放弃了 Broker 的路由能力。
四、报文结构:14 种控制报文
MQTT 的所有交互都由 控制报文(Control Packet) 承载。每个报文都是三段式:固定头 + 可变头 + 载荷,其中固定头必有,后两段视报文类型而定。
4.1 固定头
固定头最少 2 字节,第 1 字节拆开看是「类型 + 标志位」:
bit: 7 6 5 4 | 3 2 1 0
────────┼──────────
Packet | Flags
Type |
高 4 位是报文类型(1~14,0 保留禁用);
低 4 位是标志位,只有 PUBLISH 用满(DUP / QoS / RETAIN),SUBSCRIBE、UNSUBSCRIBE、PUBREL 是固定值,其余报文必须为 0,否则服务端直接断开连接;
第 2 字节起是剩余长度(Remaining Length),用「可变字节整数」编码,1~4 字节,最大 268435455(约 256 MB)。前 7 位存数值,最高位是「还有后续字节」的标志。
这个「2 字节起步」的设计就是它比 HTTP 省的地方:一个心跳包、一个只有主题和空载荷的 PUBLISH,开销都小到可以忽略。
4.2 14 种报文类型
把这 14 种记住,基本就抓住了 MQTT 的骨架:连接、发布/订阅、确认、心跳、断开。
五、CONNECT:连接标志位逐位拆解
CONNECT 是唯一一个「信息密度极高」的报文,很多线上问题都能在连接阶段看出来。它的可变头是:
连接标志位这一字节按位定义:
两个标准里的硬性约束值得单独记一下:
Will Flag = 0 时,Will QoS 必须是 0、Will Retain 必须是 0;
Will Flag = 1 时,载荷里必须同时出现 Will Topic 和 Will Message。
载荷部分按固定顺序排列:ClientId → Will Topic → Will Message → User Name → Password。服务端返回的 CONNACK 里有一个 Session Present 标志位和 1 字节返回码:
现场排查「连不上」时,先看返回码,基本能一步定位是配置问题、鉴权问题还是服务端问题,比翻日志快得多。
六、QoS:三级语义与四次握手
QoS 是 MQTT 最核心也最容易被误解的概念。它描述的是「消息传递的保证强度」,不是「消息的价值」,而且它只保证「至少/恰好到达 Broker 或客户端」,不保证业务处理成功。
QoS 2 的「两次确认」是它最反直觉的地方,我画过好几遍才记住:
发布方 服务端
│ PUBLISH (QoS2) │
│ ─────────────────────────► │ ① 收到并存下来
│ PUBREC │
│ ◄───────────────────────── │
│ PUBREL │ ② 告诉服务端:可以释放了
│ ─────────────────────────► │
│ PUBCOMP │
│ ◄───────────────────────── │ ③ 双方都确认完成,删除消息
真正需要注意的规则有三条:
有效 QoS = min(发布 QoS, 订阅 QoS)。发布方以 QoS 2 发,订阅方以 QoS 0 订,最终收到的是 QoS 0,中间会被「降级」。所以 QoS 要在两端都对齐,只改一头没有用。
QoS 1 会产生重复消息。PUBACK 丢了、客户端重连后重发,都会导致重复投递,业务侧必须做幂等(一般用消息里的业务主键 + 唯一索引)。
QoS 只覆盖「客户端 ↔ Broker」这一段。Broker 转发给订阅者时用的是订阅时的 QoS,整条链路的保证强度取最小值。
七、保留消息 Retained Message
保留消息解决的是「新来的订阅者拿不到旧状态」的问题。
普通的 PUBLISH 是「过去的就过去了」——一个客户端在消息发出 5 秒后才订阅,它永远看不到那条消息。但如果发布时把 RETAIN 标志位置 1,Broker 会把这条消息按主题保存下来,之后任何新订阅者订阅该主题,都会立刻收到这份「最后已知状态」。
每个主题只保留最后一条保留消息,新的覆盖旧的;
清除方式:向该主题发布一条 payload 长度为 0 的保留消息;
保留消息不属于会话,它是 Broker 侧按主题存的,与会话是否持久化无关;
订阅时收到的保留消息,标志位里 RETAIN 为 1,可以用来区分「历史状态」和「实时消息」。
典型用法就是设备状态:设备上线时把 device/room1/status 以 RETAIN=1 发布 online,前端页面一打开订阅就能立刻拿到所有设备当前状态,不需要等下一次上报。这个用法我觉得是 MQTT 设计里最优雅的一处。
八、遗嘱消息 Will:离线检测的官方答案
设备掉电、拔网线、SIM 卡欠费——这些场景下客户端是没机会主动告警的。遗嘱消息(Last Will and Testament, LWT)就是为此设计的:连接时就把「我死了以后帮我发的消息」交给 Broker 存着。
触发时机很明确:
网络异常断开、客户端未发 DISCONNECT 就消失;
Keep Alive 超时(服务端在 1.5 倍 Keep Alive 时间内没收到任何报文);
协议错误导致服务端主动断开。
而在 3.1.1 里,客户端发送 DISCONNECT 正常断开时,遗嘱消息不会发布(5.0 里可以通过属性改变这个行为)。
实际用法就是配合保留消息做在线状态:
连接时声明:
Will Topic = device/room1/status
Will Payload = offline
Will QoS = 1
Will Retain = true
在线时自己发布:
device/room1/status = online (RETAIN)
一旦异常掉线,Broker 自动发:
device/room1/status = offline (RETAIN)
这样下游永远能拿到设备的真实状态,不用自己搞一套超时兜底。
九、会话:Clean Session 与离线消息
「会话(Session)」是 MQTT 里另一个容易被忽略但影响很大的概念。它包含:订阅关系、未确认的 QoS 1/2 消息、准备发给客户端但尚未发送的消息。
这里有两个我踩过的坑:
用 CleanSession=0 就必须有稳定的 ClientId。如果传空 ClientId,服务端会直接返回 0x02 拒绝连接;而如果 ClientId 每次都随机生成,那会话永远对不上,离线消息等于没配。
持久会话 ≠ 永久保存。Broker 侧通常有会话过期时间,而且离线消息是按订阅的 QoS 暂存的:如果你订的是 QoS 0,断线期间的 QoS 0 消息本来就不保证,别指望补发。
顺带一提,MQTT 5.0 把这块改成了更好理解的两个概念:Clean Start(这次连接是否新建会话)+ Session Expiry Interval(会话保留多久),两个维度分开配置,比 3.1.1 的单一布尔值清晰很多。
十、Keep Alive 与心跳
TCP 长连接本身可能「假死」——物理链路断了但对端不知道。MQTT 用 Keep Alive 解决:连接时约定一个秒数(0~65535,0 表示不启用)。
客户端在 Keep Alive 时间内只要发过任意报文(包括 PUBLISH、PINGREQ)就不算超时;
如果一直没发,客户端必须发
PINGREQ,等PINGRESP;服务端在 1.5 倍 Keep Alive 时间内没收到任何报文,就判定客户端失联,断开连接并发布遗嘱消息。
取值经验:移动网络/弱网设备一般取 30~60 秒;太长会导致掉线发现不及时(要考虑与遗嘱消息的配合),太短则白耗流量和电量。客户端侧一定要把心跳交给库去做(Paho 会自动处理),不要试图自己写定时器。
十一、MQTT 3.1.1 与 5.0 的差异
3.1.1 目前兼容性最好,但 5.0 补掉了很多工程上很痛的短板。如果用新版 Broker 和新设备,建议直接上 5.0:
十二、Java 实践:Eclipse Paho 最小可用示例
我平时是写 Java 的,这里把学习时搭的 demo 记下来。客户端用的是 Eclipse Paho(最轻量、依赖最少),Broker 本地用 Mosquitto 就够跑通。
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>1.2.5</version>
</dependency>
连接 + 订阅 + 发布,一个类就能跑通:
import org.eclipse.paho.client.mqttv3.*;
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
public class MqttDemo {
public static void main(String[] args) throws Exception {
String broker = "tcp://127.0.0.1:1883";
// ClientId 要稳定,持久会话才有效
String clientId = "demo-device-room1";
MqttClient client = new MqttClient(broker, clientId, new MemoryPersistence());
MqttConnectOptions options = new MqttConnectOptions();
options.setCleanSession(true); // 需要离线消息就设为 false
options.setKeepAliveInterval(60); // 心跳 60 秒
options.setConnectionTimeout(10);
options.setAutomaticReconnect(true); // 断线自动重连
options.setUserName("demo");
options.setPassword("demo123".toCharArray());
// 遗嘱消息:异常掉线时由 Broker 代发,配合 RETAIN 做在线状态
options.setWill("device/room1/status", "offline".getBytes(), 1, true);
client.connect(options);
System.out.println("connected: " + client.isConnected());
// 订阅:通配符 + 指定 QoS
client.subscribe("device/+/status", 1, (topic, msg) -> {
System.out.printf("[recv] topic=%s qos=%d retain=%s payload=%s%n",
topic, msg.getQos(), msg.isRetained(),
new String(msg.getPayload()));
});
// 发布:上线状态,RETAIN=true 让后订阅的人也能立刻拿到
MqttMessage online = new MqttMessage("online".getBytes());
online.setQos(1);
online.setRetained(true);
client.publish("device/room1/status", online);
Thread.sleep(3000);
client.disconnect();
client.close();
}
}
几个我在调试时真正卡住的点:
ClientId 重复会互相踢线。同一个 ClientId 建立第二条连接时,Broker 会断开前一条。用「设备序列号」这类稳定值是对的,但千万别在本地多开几个进程用同一个 ID 调试,会以为是「代码有问题」。
持久会话要用
MqttDefaultFilePersistence。用MemoryPersistence时进程一挂,未确认消息就没了;生产环境建议落盘。回调里不要做阻塞操作。Paho 的单线程回调队列被占住会连带影响心跳和确认,重活要丢到自己的线程池里。
要按顺序保证用同一个客户端实例发布;不同实例之间 MQTT 不保证顺序。
十三、我整理的落地清单
把上面这些串起来,如果从零接一个 MQTT 项目,我会按这个顺序确认需求:
先确定消息重要程度,再定 QoS。遥测高频数据 QoS 0,业务数据 QoS 1 + 幂等,控制指令再考虑 QoS 2。
主题命名规范定死,包括层级、大小写、业务域前缀,写进对接文档;保留
$SYS给运维。ClientId 用设备唯一标识,并明确是否启用持久会话;启用就要评估 Broker 侧的会话与消息堆积容量。
在线状态统一用「遗嘱 + 保留消息」,别自己圆一套超时机制。
Keep Alive 与遗嘱配套设置,让掉线能在可接受的时间内被发现。
做幂等。只要是 QoS 1,就必须假设消息会重复。
上 TLS(8883)。明文 1883 只适合内网调试,公网暴露基本等于把设备数据公开。
Broker 侧关注连接数、消息堆积、内存与磁盘,共享订阅/集群在 5.0 才比较完善。
十四、小结与学习资料
回头看,MQTT 的设计其实非常「克制」:14 种报文、2 字节起步的包头、一套主题通配符规则,就把「海量弱网设备的事件上报」这件事讲清楚了。它和 HJ 212、SL 651 这类规约的区别,本质上是「面向链路与召测」和「面向事件与解耦」的区别——前者由中心站主导节奏,后者由事件驱动,设备只负责把状态喊出来。
我学习时主要参考的资料:
MQTT Version 3.1.1(OASIS 标准原文,第 2~4 章是报文定义的精华)
MQTT Version 5.0(OASIS 标准原文,重点看属性与原因码)
Eclipse Paho 官方文档与 API 说明
Eclipse Mosquitto / EMQX 的文档,用于本地搭环境和验证行为
这篇是学习笔记,难免有理解不到位的地方,后面用到了再回来补充。下一篇打算把「MQTT over TLS + 设备鉴权方案」和「Broker 集群选型」这两块单独整理一下。