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 的收发缓冲配置。
Comments (3)
这种文章应该多写,比那些「XX 深度解析」实在多了。
有个更狠的做法:直接不用。不过适用面窄。
我们线上就是这么翻车的,事后复盘写了三页,最后结论是「早该看文档」。
Please Sign in to join the discussion.