3、MQTT协议详解:发布/订阅模型、主题、服务质量(QoS)、保留消息、遗嘱消息

3.1 核心原理:从“点对点”到“发布/订阅”

MQTT(Message Queuing Telemetry Transport,消息队列遥测传输)最核心的设计思想,是将传统的“客户端直连客户端”通信,解耦为“客户端通过 Broker 中转”的发布/订阅模型。在 HTTP 或 TCP 直连模型中,如果设备 A 想给设备 B 发数据,A 必须知道 B 的 IP 地址和端口,并且 B 必须在线监听。这在物联网场景中极不现实——设备可能休眠、IP 可能变化、NAT 穿透困难。

MQTT 引入了一个中心节点 Broker(消息代理)。所有客户端(Publisher 发布者、Subscriber 订阅者)只与 Broker 建立 TCP 连接。发布者将消息发给 Broker,Broker 根据消息的主题(Topic),将消息转发给所有订阅了该主题的订阅者。发布者不需要知道订阅者是谁、在哪里、是否在线;订阅者也不需要知道发布者是谁。这种解耦带来了三个关键优势:

  • 空间解耦:双方不需要知道对方的网络地址。
  • 时间解耦
  • 同步解耦:发布者发送后无需阻塞等待订阅者处理。
在 NodeMCU 开发中,你通常使用 PubSubClient 库。该库内部封装了 MQTT 协议的连接、心跳、订阅、发布等细节,你只需调用 client.publish("topic", "payload") 和 client.subscribe("topic") 即可。
MQTT Broker 主题路由表 sensor/temp → 订阅者B sensor/hum → 订阅者C 保留消息存储区 发布者 A NodeMCU + DHT11 publish("sensor/temp") 订阅者 B NodeMCU + OLED subscribe("sensor/temp") 订阅者 C PC 端 MQTT.fx subscribe("sensor/#") ① 发布消息 ② 订阅主题 ③ 转发消息 ② 订阅通配符 ③ 转发消息 新订阅者 D subscribe("sensor/temp") 立即收到保留消息 ④ 保留消息推送

3.2 主题(Topic)与通配符

主题是 MQTT 消息路由的核心寻址机制,采用 UTF-8 字符串,以正斜杠 / 作为层级分隔符。主题不需要提前在 Broker 上创建,发布者直接发布即可。设计良好的主题结构,能让订阅端用最少的通配符获取所需数据。

主题示例含义适用场景
sensor/room1/temperature1号房间温度精确订阅单个传感器
sensor/+/temperature所有房间的温度+ 匹配单层任意值
sensor/#所有传感器数据# 匹配多层任意值
device/status设备在线状态配合遗嘱消息使用
注意:通配符 + 只能匹配一层,而 # 必须放在主题末尾,匹配剩余所有层。例如 sensor/# 能匹配 sensor/temp 和 sensor/room1/hum,但 sensor/+ 只能匹配前者。此外,主题不能包含空层级(如 sensor//temp),且 # 和 + 不能作为主题的第一层字符。

3.3 服务质量(QoS)——消息可达性保障

MQTT 提供三种 QoS 等级,用于控制消息在发布者与 Broker、Broker 与订阅者之间传输的可靠性。注意:QoS 是端到端逐跳的,发布时设置的 QoS 与订阅时设置的 QoS 会取两者中的最大值作为实际转发 QoS。

  • QoS 0(至多一次):消息直接发出,不等待确认,不重发。适用于传感器高频数据(如每 2 秒上报一次温度),丢失一帧影响不大。
  • QoS 1(至少一次):发送后等待 PUBACK 确认,超时未收到则重发。接收方可能收到重复消息,需要应用层做去重。
  • QoS 2(恰好一次):通过四步握手(PUBLISH → PUBREC → PUBREL → PUBCOMP)确保消息不重不漏。适用于控制指令、计费数据等关键场景。
// PubSubClient 中设置 QoS 示例
client.publish("sensor/temp", "25.5", true);   // 第三个参数 true 表示保留消息
client.subscribe("cmd/led", 1);                // 第二个参数 1 表示 QoS 1
工程建议:NodeMCU 上报传感器数据建议使用 QoS 0 或 QoS 1。QoS 2 会显著增加网络开销和延迟,在低功耗场景中慎用。若担心 QoS 1 的重复消息,可在 payload 中加入递增的序列号。

3.4 保留消息(Retained Message)与遗嘱消息(LWT)

保留消息:发布者发布消息时若将 retain 标志置 1,Broker 会将该消息存储为主题的最新状态。当有新的订阅者订阅该主题时,Broker 会立即将这条保留消息推送给新订阅者,而不是等待下一次发布。这非常适合设备状态、配置参数等“需要立即获取当前值”的场景。

遗嘱消息(Last Will and Testament, LWT):客户端在连接 Broker 时,可以预先设置一条遗嘱消息(包含主题和 payload)。如果客户端异常断开(如断电、网络中断、心跳超时),Broker 会自动代发这条遗嘱消息,通知其他订阅者该设备已离线。正常断开(发送 DISCONNECT)时不会触发遗嘱。

// PubSubClient 设置遗嘱消息示例
client.setWill("device/status", "offline", true);  // 遗嘱主题、内容、保留标志
client.connect("NodeMCU_01");                       // 连接成功后,若异常断开,Broker 自动发布遗嘱
特性保留消息遗嘱消息
触发时机新订阅者订阅时立即推送客户端异常断开时由 Broker 代发
典型用途设备最新状态、配置参数设备离线通知、状态监控
设置方式发布时 retain=true连接时 setWill()
清除方式发布空 payload 的保留消息客户端正常断开后自动清除

3.5 案例:智能温控系统消息设计

假设你正在用 NodeMCU 制作一个智能温控面板,需要上报温度、接收控制指令、监控设备在线状态。合理的消息设计如下:

  1. 温度上报:NodeMCU 每 5 秒发布 sensor/room1/temp,QoS 0,retain=false。数据格式为 JSON:{"temp":25.5,"hum":60}。
  2. 控制指令:手机 App 发布 cmd/room1/ac,QoS 1,payload 为 {"power":"on","mode":"cool","set":24}。NodeMCU 订阅该主题并执行。
  3. 设备状态:NodeMCU 连接时设置遗嘱 device/room1/status = offline,同时发布保留消息 online。这样 App 启动时能立即看到所有设备状态,设备掉线时也能及时感知。
// NodeMCU 端核心代码逻辑
void callback(char* topic, byte* payload, unsigned int length) {
  if (strcmp(topic, "cmd/room1/ac") == 0) {
    // 解析 JSON 并控制空调继电器
  }
}
void loop() {
  if (!client.connected()) reconnect();
  client.loop();
  static unsigned long lastPub = 0;
  if (millis() - lastPub > 5000) {
    float t = dht.readTemperature();
    char msg[50];
    snprintf(msg, 50, "{\"temp\":%.1f}", t);
    client.publish("sensor/room1/temp", msg);
    lastPub = millis();
  }
}

3.6 常见误区与复盘

误区一:认为 QoS 1 能保证消息不重复。实际上 QoS 1 是“至少一次”,可能重复。若业务不允许重复,应使用 QoS 2 或在 payload 中携带消息 ID 做去重。
误区二:将保留消息当作“缓存”使用,发布大量不同主题的保留消息。保留消息会占用 Broker 内存,且每次新订阅都会推送,应只对“最新状态”类主题使用。
误区三:忽略遗嘱消息的 retain 标志。如果遗嘱消息不设置 retain,新上线的订阅者将无法得知设备已离线。建议将设备状态主题的遗嘱消息设为 retain=true。
复盘:在一个实际项目中,我们曾遇到设备频繁掉线但监控端无法感知的问题。排查发现,设备在 connect() 前未调用 setWill(),导致 Broker 无法代发离线通知。修复后,监控端能在 10 秒内(心跳超时时间)收到离线消息,及时触发告警。这提醒我们:遗嘱消息是物联网设备“最后的声音”,必须在连接前配置好。