7AA7 Blog

MQTT 协议详解:QoS、遗嘱与断线重连

苏念禾 UID:79
2026-10-14 15:14
110 3 3

MQTT 跑在 TCP 之上,报文头最小只有 2 字节,这套设计是为窄带宽、高延迟、不稳定的链路准备的。理解它的关键点不在于”能连上发消息”,而在于想清楚消息在哪个环节可能丢、丢了之后由谁负责重发。

发布订阅模型与 broker 的位置

设备不直接互相寻址。发布者把消息发到 broker,带上一个主题字符串;订阅者提前告诉 broker 自己关心哪些主题;broker 负责匹配和转发。这个解耦带来三个结果:发布者不需要知道订阅者是谁、有几个;双方不需要同时在线;拓扑变化时不用改任何一端的代码。

代价是 broker 成为单点。broker 挂掉,所有设备都失联,所以生产环境需要考虑 broker 的高可用与集群,而设备侧要能承受 broker 不可用的时间段——这正是断线重连逻辑存在的理由。

三种 QoS 的语义差异

QoS 不是一个全局开关,而是两段独立的协商:发布者到 broker 一段,broker 到订阅者一段。客户端 PUBLISH 时指定第一段,订阅时 SUBSCRIBE 指定第二段。最终订阅者收到的消息 QoS 取两者的较小值。

QoS 0(最多一次):发出去就不管了。没有确认,没有重传。适合周期性上报且允许丢一帧的传感器数据,比如每秒一次的温度采样——丢一帧下一秒就补上了。

QoS 1(至少一次):PUBLISH 之后等 PUBACK。没收到就在超时后重发,重发时 DUP 标志置位。发送方必须把报文存在本地队列里直到收到 PUBACK。这个”至少一次”意味着会重复,接收方必须做幂等处理:在消息里带一个单调递增的序号或唯一 ID,重复的直接丢弃。常见的做法是业务层维护一个最近 N 条 ID 的集合。

QoS 2(恰好一次):两阶段握手。PUBLISH → PUBREC → PUBREL → PUBCOMP。发送方收到 PUBREC 后把报文状态从”未确认”改成”已确认待释放”,这样即使重发 PUBREL 也不会重复投递。QoS 2 的开销是 4 个报文加上两端都要持久化中间状态,对 MCU 来说意味着额外的 RAM 和 flash 写入。只有在确实无法容忍重复、也无法在业务层去重的场景下才用它——比如计费类指令。多数场景用 QoS 1 加业务去重更划算。

QoS 换不来端到端可靠

一个高频误解:发布端用 QoS 1 就认为消息一定到了订阅端。实际上 QoS 1 只保证消息到了 broker。如果订阅者此时离线、或者 broker 到订阅者那一段用 QoS 0 下发,消息照样丢。

真正需要可靠投递时,要同时满足三件事:发布端 QoS ≥ 1、订阅端持久会话(clean session 为 false)、订阅端 SUBSCRIBE 的 QoS ≥ 1。缺一个环节整条链就断在最弱的那一段。另外 QoS 1 的”至少一次”在 broker 侧也会重复投递给订阅者,订阅端的去重是跑不掉的。

遗嘱消息(LWT)

遗嘱在 CONNECT 报文里设置,包含主题、负载、QoS、retain 标志。触发条件是 broker 检测到连接异常断开:

  • TCP 连接被重置或超时,且 Keep Alive 超时(1.5 × Keep Alive 时间内没有收到任何报文)
  • 协议层错误导致 broker 主动关闭连接

关键点是:客户端发送 DISCONNECT 正常下线时,遗嘱不会触发。所以”设备正常关机”和”设备掉电”在 broker 看来是两回事。如果希望正常下线也留个记录,得自己发一条普通消息再 DISCONNECT。

遗嘱的典型用法是发布一条 device/{id}/status 的 offline 消息,且带 retain 标志,这样新订阅者一上来就能看到该设备的最后状态。设备上线时主动发 online 覆盖它。

Keep Alive 与 PINGREQ

Keep Alive 是客户端在 CONNECT 里声明的最大空闲间隔,单位是秒。客户端在这个间隔内如果没有别的报文可发,就发 PINGREQ,broker 回 PINGRESP。

它的作用是让两端及时发现半开连接——比如 NAT 超时、链路中断但 TCP 还没感知。链路真的断了的时候,TCP 可能要等很久才超时,而 PINGREQ 没回应在 1.5 个 Keep Alive 周期后就判死。

设置上要权衡:太短(比如 10 秒)会增加空流量和功耗,对电池设备不友好;太长(比如十几分钟)则故障发现太慢。常见的取值量级在几十秒到几分钟。要注意 PINGREQ 的发送时机必须由客户端的发送逻辑驱动,如果客户端在高优先级任务里死循环导致网络任务饿死,PINGREQ 发不出去,连接一样会被判死。

持久会话与离线消息

clean session = true 时,broker 不为该客户端保留任何状态,重连后之前的订阅全部失效,必须重新 SUBSCRIBE。MQTT 5.0 里这个字段拆成了 clean start 和 session expiry interval,可以只清理一次连接但保留会话。

clean session = false 时,broker 保留订阅关系和未确认的 QoS 1/2 消息,客户端重连后 broker 会补发。这对网络不稳定的设备很有用,但要注意:客户端 ID 必须稳定不变。如果每次开机随机生成 client ID,持久会话就毫无意义,而且会出现多个连接互相踢下线的问题。用芯片唯一 ID 或烧录时写入的序列号拼 client ID 是常见做法。

主题设计规范

主题是 UTF-8 字符串,用 / 分层,区分大小写。设计上建议:

{产品}/{设备ID}/{数据类型}
sensor/abc123/temperature

避免以 / 开头,会产生一个空的第一层,匹配时容易踩坑。

通配符的差异:+ 匹配单层,# 匹配多层且必须放在最后。

  • sensor/+/temperature 匹配所有设备的温度
  • sensor/# 匹配 sensor 下的所有层级
  • sensor/#/temperature 是非法的

订阅风暴是真实存在的风险。如果每个设备都订阅 sensor/# 来做广播,broker 侧要维护 N × M 的匹配关系,设备在群里数量上去之后会明显吃 broker 的 CPU。窄一点的订阅表达式更省资源。设备侧也不要用 # 兜底订阅,一个错误的主题拼写就会把不该收的消息灌进设备。

断线重连的退避策略

重连不是”失败了立刻重试”。链路故障往往持续一段时间,立刻重试会打满 CPU 并在 broker 侧造成连接风暴。合理的做法是指数退避加随机抖动:

#define RECONNECT_MIN_MS   1000
#define RECONNECT_MAX_MS  60000

static uint32_t backoff_ms = RECONNECT_MIN_MS;

void on_disconnect(void) {
    /* 抖动避免成千上万设备同时重连 */
    uint32_t jitter = rand() % (backoff_ms / 2);
    delay_ms(backoff_ms + jitter);

    if (mqtt_connect() == 0) {
        resubscribe_all();      /* clean session 为 true 时必须重新订阅 */
        backoff_ms = RECONNECT_MIN_MS;
    } else {
        backoff_ms = (backoff_ms * 2 > RECONNECT_MAX_MS)
                     ? RECONNECT_MAX_MS : backoff_ms * 2;
    }
}

几个容易被忽略的细节:重连成功后要重新订阅;重连期间产生的数据要么缓存起来补传,要么明确丢弃,不要让它静默消失;退避上限不要设得太大,否则故障恢复后设备要等很久才回来。

协议版本与实现的坑

MQTT 3.1.1 和 5.0 在 CONNECT 报文上有差异,5.0 多了属性字段和原因码,排查问题时先确认两端版本一致,版本不匹配通常表现为 CONNECT 后直接被断开且没有明确错误。

再就是报文长度限制。协议允许最大 256 MB 的报文,但绝大多数嵌入式 SDK 会限制在几 KB 到几十 KB,超了会直接断开。把一堆传感器历史数据塞进一条 PUBLISH 之前,先确认 SDK 的收发缓冲配置。

🔁 3
2.6kViews
110Likes
3Comments
3Participants
9 minread time

Comments (3)

  • Cache 孙 · 2026-10-14 17:40

    这种文章应该多写,比那些「XX 深度解析」实在多了。

    Reply
  • Kael · 2026-10-15 02:32

    有个更狠的做法:直接不用。不过适用面窄。

    Reply
  • 江眠 · 2026-10-15 12:02

    我们线上就是这么翻车的,事后复盘写了三页,最后结论是「早该看文档」。

    Reply