3、MQTT协议原理深度解析

MQTT 这玩意儿,说白了就是为物联网场景量身定做的「信使」。我最早接触它是在一个智能路灯项目里,当时设备端资源紧张得要命,HTTP 太重了,CoAP 又不太适合双向通信。试了一圈,最后还是 MQTT 扛下了所有。

这一章咱们就把 MQTT 的核心原理掰开揉碎。不扯虚的,全是实战里用得上的东西。

3.1 发布/订阅模型:解耦的艺术

传统客户端-服务器模型里,客户端直接跟服务器对话。MQTT 不一样,它引入了一个「中间人」——Broker(代理服务器)。

发布者(Publisher)只管往某个「主题」(Topic)上扔消息。订阅者(Subscriber)只管说自己想听哪些主题。两边谁都不认识谁,全靠 Broker 在中间撮合。

核心优势:发布者和订阅者在时间、空间、同步三个维度上完全解耦。设备离线了?没关系,消息可以存着。设备多了?Broker 负责分发,发布者不用管有多少人在听。

我习惯把 Topic 想象成「消息的邮编号码」。比如一个温度传感器发布到 sensor/temperature/room1,所有订阅了这个主题的客户端都能收到。你想想看,这比点对点通信灵活了多少倍?

Topic 支持通配符,这是实战中特别常用的:

  • +:单级通配符,匹配一层。比如 sensor/+/temperature 匹配 sensor/room1/temperature 和 sensor/room2/temperature。
  • #:多级通配符,匹配剩余所有层级。比如 sensor/# 匹配 sensor/temperature/room1 和 sensor/humidity/room2。

注意:通配符只能用在订阅端,发布端不能使用通配符。我曾经见过新手在发布消息时用了 #,结果 Broker 直接拒绝连接——这是协议规定的硬限制。

下面这张图展示了发布/订阅模型的核心流程:

MQTT 发布/订阅模型 发布者 (Publisher) Broker 消息路由 主题匹配 订阅者 A (Subscriber) 订阅者 B (Subscriber) 发布消息 (Topic: sensor/temp) 转发消息 转发消息 关键特点 • 发布者和订阅者互不知晓对方存在 • 一个消息可被多个订阅者接收(一对多) • 订阅者只接收自己感兴趣的主题消息

3.2 MQTT 报文结构:三层拆解

MQTT 报文由三部分组成:固定头、可变头、有效载荷。我当年啃协议文档时,就是靠这个三层结构把报文吃透的。

3.2.1 固定头(Fixed Header)

所有报文都必须有固定头,最少 2 个字节。第一个字节包含消息类型和标志位,第二个字节开始是剩余长度。

Bit 7-4 3 2 1 0
Byte 1 消息类型 DUP QoS 高位 QoS 低位 RETAIN
Byte 2+ 剩余长度(可变字节编码)

消息类型有 14 种,常用的就几个:CONNECT(连接)、PUBLISH(发布)、SUBSCRIBE(订阅)、PINGREQ(心跳)。

实战技巧:剩余长度使用可变字节编码,每个字节最高位是延续位。数值小于 128 时只用 1 个字节,大于 128 时用多个字节。我调试时经常用 Wireshark 抓包看剩余长度,确认报文是否被截断。

3.2.2 可变头(Variable Header)

可变头的内容取决于消息类型。拿 PUBLISH 报文来说,可变头里最重要的就是主题名和报文标识符。

  • 主题名:UTF-8 编码的字符串,前面 2 个字节是长度。
  • 报文标识符:只在 QoS 1 和 QoS 2 时出现,用于消息确认。

我记得有一次排查消息重复问题,发现就是报文标识符没处理好,客户端重复使用了同一个 ID,导致 Broker 把重发消息当成了新消息。

3.2.3 有效载荷(Payload)

有效载荷就是消息的「肉」。对于 PUBLISH 报文,这里放的就是实际数据。对于 CONNECT 报文,这里放的是客户端 ID、用户名、密码等。

Payload 可以是任意二进制数据。我习惯用 JSON 格式,结构清晰,调试方便。但在资源极度受限的设备上,可以考虑用 CBOR 或 Protobuf 压缩体积。

3.3 QoS 等级:三种可靠性,三种代价

QoS(服务质量)是 MQTT 最核心的特性之一。说白了,就是消息能「靠谱」到什么程度。

QoS 等级 名称 发送次数 确认机制 可靠性
0 至多一次 1 次 无确认 最低,可能丢失
1 至少一次 至少 1 次 PUBACK 中等,可能重复
2 恰好一次 2 次往返 PUBREC/PUBREL/PUBCOMP 最高,不丢不重

QoS 0:至多一次

发出去就不管了,不确认,不重发。适合传感器数据上报,丢一帧无所谓,下一帧马上来。我做过一个温湿度采集项目,每秒上报一次,用的就是 QoS 0,省带宽省电。

QoS 1:至少一次

发送后会等待 PUBACK 确认。没收到就重发,所以消息可能重复。适合控制指令,比如开关灯,重复执行一次问题不大。

QoS 2:恰好一次

最复杂,也最可靠。四次握手:PUBLISH -> PUBREC -> PUBREL -> PUBCOMP。确保消息不丢不重。适合计费、支付等场景。

性能代价:QoS 2 的吞吐量大约是 QoS 0 的 1/3 到 1/2。我在一个车联网项目里试过,1000 个设备同时用 QoS 2 上报位置,Broker 直接扛不住。后来改成 QoS 1 加去重逻辑,问题解决。

3.4 保留消息与遗嘱消息

保留消息(Retained Message)

发布消息时把 RETAIN 标志位置 1,Broker 就会把这条消息存下来。新订阅者上线时,Broker 会立即把保留消息推给它。

这玩意儿特别适合「状态类」数据。比如网关的在线状态、设备的当前配置。新设备上线不用等,直接拿到最新状态。

我的习惯:每个设备启动时,先发布一条保留消息到 device/{id}/status,内容是 {"online": true}。这样其他模块随时能查到设备状态,不用轮询。

要清除保留消息,发一条空 Payload 且 RETAIN 为 1 的消息到同一个主题就行。

遗嘱消息(Will Message)

遗嘱消息是客户端在连接时「预立」的。如果客户端异常断开(比如掉电、网络中断),Broker 会代它发布这条消息。

配置遗嘱消息需要在 CONNECT 报文里设置:

  • Will Topic:遗嘱主题
  • Will Payload:遗嘱内容
  • Will QoS:遗嘱消息的 QoS
  • Will Retain:是否保留遗嘱消息

我曾经用遗嘱消息做设备离线检测。设备连接时设遗嘱 {"online": false},正常断开时发一条 {"online": false} 的普通消息覆盖掉。如果 Broker 突然收到遗嘱,说明设备非正常离线,立刻触发告警。

3.5 会话清理与持久会话

这是 MQTT 里容易搞混的概念。Clean Session 标志位决定了 Broker 要不要为客户端保留会话状态。

Clean Session 行为 适用场景
true(1) 连接时清理旧会话,断开时丢弃所有状态 临时客户端、传感器上报
false(0) 连接时恢复旧会话,断开时保留订阅和离线消息 控制面板、移动端 App

持久会话(Clean Session = false)时,Broker 会保存:

  • 客户端的所有订阅
  • QoS 1 和 QoS 2 的未确认消息
  • 离线期间收到的 QoS 1 和 QoS 2 消息

关键区别:持久会话 ≠ 持久订阅。订阅本身是持久化的,但离线消息只保存 QoS 1 和 QoS 2 的。QoS 0 的消息在客户端离线时直接丢弃,不管是不是持久会话。

我建议这样选:

  • 电池供电的传感器:Clean Session = true,省电省内存
  • 需要接收离线消息的控制端:Clean Session = false,确保不丢指令
  • 网关设备:Clean Session = false,但要注意 Broker 的存储压力