3. MQTT协议核心原理:发布/订阅模型、主题与通配符、服务质量、保留消息与遗嘱消息、报文结构解析

MQTT 这玩意儿,说白了就是物联网世界的「消息快递」。我做了这么多年嵌入式网关,跟它打交道最多。今天咱们就把它的核心原理掰开揉碎,讲清楚。

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

传统的客户端-服务器模型,就像打电话——双方必须同时在线。MQTT 不一样,它用的是发布/订阅模型。你可以把它想象成一个微信群:有人发消息(发布者),有人看消息(订阅者),中间有个群聊服务器(Broker)负责转发。

这样做的好处很明显:

  • 空间解耦:发布者和订阅者不需要知道对方在哪
  • 时间解耦:发布者发消息时,订阅者可以不在线
  • 同步解耦:双方不需要同时阻塞等待

核心角色:

  • Publisher(发布者):发送消息的客户端,比如温度传感器
  • Subscriber(订阅者):接收消息的客户端,比如监控平台
  • Broker(代理服务器):负责消息路由和转发,比如 Mosquitto、EMQX

我在项目中遇到过一个问题:有个同事把传感器直接跟云端服务器做 TCP 长连接,结果服务器一重启,所有设备全断了。换成 MQTT Broker 之后,设备只管发消息,服务器重启也不影响数据上报。这就是解耦的价值。

3.2 主题(Topic)与通配符:消息的地址系统

主题就是消息的「地址」。它用斜杠分层,比如:

sensor/temperature/room1
sensor/humidity/room2
actuator/light/room1/switch

主题的命名规则很简单:

  • 区分大小写:Sensor/Temp 和 sensor/temp 是两个不同的主题
  • 不能以 $ 开头(系统保留)
  • 建议用有意义的层级结构

通配符是 MQTT 的杀手锏。订阅者可以用它一次订阅多个主题:

通配符 含义 示例 匹配结果
+ 匹配单层 sensor/+/temp 匹配 sensor/room1/temp、sensor/room2/temp
# 匹配多层 sensor/# 匹配 sensor/temp、sensor/temp/room1 等所有以 sensor/ 开头的主题

注意:# 通配符只能放在主题末尾。比如 sensor/#/temp 是无效的。我曾经见过有人写 +/# 想匹配所有主题,结果 Broker 直接报错。

你想想看,如果网关要采集所有温度数据,只需要订阅 sensor/+/temperature 就够了。不用为每个传感器单独写订阅逻辑。

3.3 服务质量(QoS 0/1/2):消息可靠性的三档选择

MQTT 定义了三个服务质量等级。说白了就是:你愿意花多少代价保证消息送到。

QoS 名称 发送次数 可靠性 适用场景
0 至多一次 1 次 最低 传感器数据、日志(丢了无所谓)
1 至少一次 ≥1 次 中等 控制指令、告警(必须到,重复可接受)
2 恰好一次 2 次握手 最高 计费、关键状态(不能丢也不能重复)

QoS 0 最简单:发出去就不管了。适合温度、湿度这种连续上报的数据,丢一帧无所谓。

QoS 1 会保证消息至少到达一次。如果 Broker 没回复 PUBACK,发布者会重发。但可能重复。我在项目中用 QoS 1 下发控制指令,配合去重逻辑,效果很好。

QoS 2 最复杂,需要四次交互(PUBLISH → PUBREC → PUBREL → PUBCOMP)。开销大,但保证不丢不重。说实话,我在实际项目中很少用 QoS 2,除非是计费系统。

我的建议:

  • 传感器数据上报:QoS 0
  • 控制指令下发:QoS 1
  • 关键状态变更:QoS 1 + 应用层去重
  • 计费/交易:QoS 2

3.4 保留消息与遗嘱消息:两个容易被忽略的利器

这两个功能,我刚开始做 MQTT 时完全没在意,后来才发现它们太有用了。

保留消息(Retained Message)

发布者发消息时可以设置 retain=1。Broker 会存下这条消息。新订阅者上线时,Broker 会立即把保留消息推给它。

有什么用?比如网关的状态信息:

# 发布保留消息
mosquitto_pub -t "gateway/status" -m "online" -r

# 新订阅者上线后立即收到
mosquitto_sub -t "gateway/status"
# 输出: online

这样新设备上线就能立刻知道网关状态,不用等下一次心跳。

遗嘱消息(Will Message)

客户端连接 Broker 时可以设置遗嘱。如果客户端异常断开(比如掉电、断网),Broker 会代它发布这条消息。

配置遗嘱的 CONNECT 报文结构:

// 伪代码示例
connect_packet = {
    will_flag: true,
    will_topic: "device/001/status",
    will_message: "offline",
    will_qos: 1,
    will_retain: true
}

我在项目中用遗嘱消息做设备离线检测。设备正常关闭时会发 offline 消息,异常断开则由 Broker 代发。监控平台订阅 device/+/status,就能实时知道所有设备的状态。

避坑指南:我曾经把遗嘱消息的 QoS 设成 0,结果网络抖动时遗嘱丢了,设备显示一直在线。后来改成 QoS 1 + retain,问题解决。

3.5 MQTT报文结构解析:从字节层面理解协议

MQTT 报文结构很简洁。每个报文都包含三部分:

  1. 固定头部:所有报文都有,2 字节起
  2. 可变头部:某些报文有,比如 CONNECT、SUBSCRIBE
  3. 有效载荷:消息内容

固定头部的格式:

Bit 7-4 3 2 1 0
Byte 1 报文类型 DUP QoS QoS RETAIN
Byte 2+ 剩余长度(可变字节编码)

报文类型有 14 种,常用的就几个:

  • CONNECT (1):客户端请求连接
  • CONNACK (2):连接确认
  • PUBLISH (3):发布消息
  • SUBSCRIBE (8):订阅主题
  • SUBACK (9):订阅确认
  • PINGREQ/PINGRESP (12/13):心跳
  • DISCONNECT (14):断开连接

剩余长度使用可变字节编码。每个字节的最高位表示是否还有后续字节。比如:

// 剩余长度 0~127:1 个字节
0x00 ~ 0x7F

// 剩余长度 128~16383:2 个字节
0x80 0x01  // 128
0xFF 0x7F  // 16383

嗯,这里要注意:解析报文时一定要正确处理剩余长度。我见过有人直接读一个字节,结果消息超过 127 字节就解析错了。

下面这张图展示了 MQTT 报文的整体结构:

MQTT 报文通用结构 固定头部(Fixed Header) 所有报文必备 | 包含报文类型、标志位、剩余长度 可变头部(Variable Header) 某些报文有 | 如协议名、协议版本、连接标志、心跳间隔 有效载荷(Payload) 消息内容 | 如主题名、订阅过滤器、应用消息 注意:CONNECT 报文可变头部包含协议名 "MQTT" 和协议版本号

以 CONNECT 报文为例,它的可变头部包含:

  • 协议名:0x00 0x04 0x4D 0x51 0x54 0x54("MQTT")
  • 协议版本:0x04(MQTT 3.1.1)或 0x05(MQTT 5.0)
  • 连接标志:用户名、密码、遗嘱、保留等标志位
  • 心跳间隔:单位秒,如 0x00 0x3C(60秒)

有效载荷则包含客户端 ID、用户名、密码、遗嘱主题和消息。

调试技巧:用 Wireshark 抓包看 MQTT 报文,比看文档直观多了。我每次排查协议问题,第一件事就是抓包。

好了,MQTT 的核心原理就这些。发布/订阅模型让你解耦,主题和通配符让你灵活订阅,QoS 让你控制可靠性,保留消息和遗嘱消息解决状态同步问题,报文结构让你从底层理解协议。这些基础打牢了,后面做网关集成就顺手了。