MQTT 协议精讲:QoS 0/1/2 背后的工程权衡,不是文档翻译
MQTT 协议精讲:QoS 0/1/2 背后的工程权衡,不是文档翻译
专栏第 4 篇|这篇文章不会告诉你「QoS 2 是最可靠的,所以应该用 QoS 2」。15 年里我见过太多这样的决策——选了最高级别,系统反而出了更难排查的问题。QoS 是工程权衡,不是质量排名。
先把文档说的话扔掉
MQTT 规范对 QoS 的描述是这样的:
- QoS 0:At most once(最多一次)
- QoS 1:At least once(至少一次)
- QoS 2:Exactly once(恰好一次)
这三句话没有错,但它们没有告诉你任何值得你停下来思考的事情。
真正的工程问题是:
- QoS 1 的「至少一次」意味着什么?意味着你的业务代码必须处理重复消息,否则一条控制指令可能被执行两次。
- QoS 2 的「恰好一次」是对谁保证的?是对 Broker,不是对你的业务层。如果你的业务层代码在处理消息时崩溃了,QoS 2 救不了你。
- QoS 0 真的不可靠吗?在稳定局域网环境下,TCP 本身就保证了消息送达,QoS 0 在实践中几乎不丢消息。
所以,选 QoS 的正确姿势是:先想清楚消息丢失和消息重复哪个对你的业务危害更大,再选对应的保障机制。
一、QoS 0:被低估的「不可靠」
它的实际传输过程
设备 Broker | | |──── PUBLISH (QoS=0) ────────>| 发完即忘 | | | [收到 or 丢失,设备永远不知道]一次网络写操作,仅此而已。没有 ACK,没有重传,没有状态机。这是 MQTT 所有机制中最简单的一个。
什么时候 QoS 0 是正确答案
有三类数据天然适合 QoS 0:
高频周期性采样。每秒上报一次温度,丢掉一条无所谓,下一秒还有。如果用 QoS 1,每条消息都要等 PUBACK,加上重传逻辑,在弱网环境下反而会造成消息堆积——设备在等确认的时候,新的采样数据又来了,队列撑满,系统更容易崩。
MQTT 心跳/保活包。心跳本来就是「我还在线」的信号,丢一个没关系,下一个心跳自然会更新状态。用 QoS 1 发心跳是浪费。
可被最新值覆盖的状态。如果云端只关心「当前最新值」,历史中间值丢了不影响业务,QoS 0 是正确选择。
QoS 0 真实的丢包率是多少
很多人有个误区:觉得 QoS 0 会大量丢包。
现实是:在 TCP 连接正常的情况下,QoS 0 的消息和 QoS 1 一样,都走 TCP,TCP 本身有重传机制保证字节流送达。QoS 0 丢消息的场景只有两个:
一是 TCP 连接断开的瞬间,断开前缓冲区里的数据丢失。
二是 Broker 内存队列满了,直接丢弃。
所以在稳定的 Wi-Fi 或以太网环境下,QoS 0 实测丢包率接近 0。只有在 4G/NB-IoT 这类链路频繁切换的场景,才会出现明显的 QoS 0 消息丢失。
结论:QoS 0 不是「不可靠」,而是「不保证」。两者的差距,在稳定网络下可以忽略不计。
二、QoS 1:工程上最常用,也最容易用错
传输流程
正常情况:
设备 Broker | | |──── PUBLISH (QoS=1, id=42) ->| | | [存储消息] |<─── PUBACK (id=42) ──────────| | | | [删除待确认队列中的 id=42] |PUBACK 丢失时(这是最容易被忽视的场景):
设备 Broker | | |──── PUBLISH (QoS=1, id=42) ->| | | [存储消息] | × PUBACK 在网络中丢失 | | | | [超时,重发] | |──── PUBLISH (QoS=1, id=42, ->| ← DUP 标志位置 1 | DUP=1) ─────────────────>| | | [Broker 可能再次存储!] |<─── PUBACK (id=42) ──────────|注意第二次 PUBLISH 上的 DUP 标志。MQTT 规范里 DUP 只是一个提示:「这条消息可能是重发的」。Broker 并不保证根据 DUP 做去重——这完全取决于 Broker 实现。很多 Broker 会把带 DUP 标志的消息当新消息处理,导致订阅者收到两条一样的消息。
QoS 1 的核心坑:消息重复
这个坑我在项目里踩过不止一次,踩的最狠的一次是:
一个工业阀门控制系统,云端下发「打开阀门」指令,使用 QoS 1。弱网环境下,设备收到指令、执行了开阀操作,但回复 PUBACK 的包在网络里丢了。云端超时重发,设备又收到一次「打开阀门」——阀门已经开着,第二条指令触发了状态校验逻辑,把一个中间锁死状态标记置位了,导致后续的「关闭阀门」指令无法执行。
处理完这个问题花了三天。问题的根源就是:我们没有在业务层做幂等处理。
QoS 1 的正确使用姿势,必须配合业务层去重:
// 消息处理函数voidmqtt_msg_handler(constchar*topic,constchar*payload,uint32_tmsg_id){// 用消息 ID 去重(Packet Identifier)if(msg_cache_contains(msg_id)){// 已处理过,直接返回(不重复执行业务逻辑)LOG_WARN("Duplicate message id=%u, skip",msg_id);return;}// 记录已处理的消息 ID(带 TTL,避免缓存无限增长)msg_cache_add(msg_id,MSG_CACHE_TTL_SECONDS);// 执行业务逻辑process_command(payload);}实际工程中,这个去重缓存通常是一个循环数组,保存最近 N 条消息的 Packet Identifier。因为 MQTT 的 Packet ID 是 16 位整数,空间是 1~65535,配合时间戳去重更稳健。
QoS 1 vs QoS 0 的性能对比
在一个典型的 NB-IoT 场景(单次 RTT 约 500ms)下:
| QoS 0 | QoS 1(正常) | QoS 1(PUBACK 丢失,重传一次) | |
|---|---|---|---|
| 网络交互次数 | 1 | 2 | 4 |
| 理论完成时间 | ~80ms | ~580ms | ~1580ms |
| 唤醒时间(功耗影响) | 最短 | 中等 | 显著增加 |
对于电池供电的 NB-IoT 设备,这个时间差直接反映在功耗上。每次唤醒多 1 秒,在 PSM 模式下,意味着整体平均功耗上升约 3~5%——乘以 10 万台设备和 5 年使用周期,是真实的电池寿命差距。
三、QoS 2:最贵的保证,用之前先问三个问题
四次握手的完整流程
设备 Broker | | |──── PUBLISH (QoS=2, id=77) ->| 第 1 次:发布 | | [存储,但尚不分发] |<─── PUBREC (id=77) ──────────| 第 2 次:已收到 | | | [存储 PUBREC 状态] | |──── PUBREL (id=77) ─────────>| 第 3 次:确认释放 | | [分发给订阅者] |<─── PUBCOMP (id=77) ─────────| 第 4 次:完成 | | | [清除状态,完成] |四次握手,最少 4 个网络来回(RTT)。在 NB-IoT 上,这意味着约 2~3 秒的完成时间,以及期间持续的射频唤醒功耗。
问题一:你的业务真的需要「恰好一次」吗
QoS 2 保证的是:消息在 MQTT 协议层面恰好被 Broker 接收并分发一次。但:
- 如果你的订阅者(云端服务)在处理消息时崩溃了,消息照样「丢失了」——QoS 2 不管应用层的处理结果。
- 如果你的订阅者幂等(即处理相同消息两次和一次效果一样),那 QoS 1 + 业务层去重 = QoS 2 的效果,且开销低得多。
所以大多数「我需要 QoS 2」的需求,其实是「我需要业务层的幂等处理 + QoS 1」。
问题二:你的 MCU 撑得住 QoS 2 的状态机吗
QoS 2 在设备端需要维护一个持久化的状态机——记录哪些消息已经发出但还没完成四次握手。如果设备在 PUBREL 和 PUBCOMP 之间断电,重启后需要从持久化存储恢复状态,继续完成握手。
这意味着:
- 需要一块 Flash 区域存储未完成的 QoS 2 消息队列
- 需要在设备重启后恢复并重放这些消息
- 这套逻辑写起来不简单,测试覆盖断电场景更麻烦
对于 STM32L0 这类 Flash 只有 64KB 的 MCU,额外的 QoS 2 状态存储可能是一个不小的成本。
问题三:你的 Broker 支持 QoS 2 吗
不是所有 Broker 都完整实现了 QoS 2。一些云平台的 IoT Hub(包括某些版本的阿里云 IoT)对 QoS 2 的支持有限制,或者降级处理。在选定云平台后,务必测试验证 QoS 2 行为,而不是假设它工作正常。
真正适合 QoS 2 的场景
经过 15 年的项目积累,我总结出 QoS 2 真正值得用的场景只有两类:
计费相关指令。比如预付费电表的购电指令、共享设备的计费触发——消息多执行一次会直接影响用户金额,不可重复不可丢失,值得付出 4 次握手的代价。
不可逆操作的触发。比如工业设备的「格式化存储」「恢复出厂设置」这类操作,执行两次会造成不可恢复的后果。
日常的设备控制(开关灯、调节温度)、数据上报、OTA 触发——通通不需要 QoS 2。QoS 1 + 业务层幂等足够了。
四、工程实践:我实际项目里的 QoS 分配策略
不同类型的消息用不同的 QoS,这是一套在多个量产项目里验证过的策略:
// 消息类型与 QoS 映射typedefenum{MSG_HEARTBEAT=0,// 心跳包 → QoS 0MSG_SENSOR_DATA=0,// 周期采样数据 → QoS 0(高频,允许偶尔丢)MSG_EVENT=1,// 事件上报 → QoS 1 + 业务去重MSG_ALERT=1,// 告警上报 → QoS 1 + 业务去重MSG_CMD_RESPONSE=1,// 指令执行回报 → QoS 1MSG_BILLING=2,// 计费触发 → QoS 2(仅此一类)}MsgQoS;// Topic 到 QoS 的映射函数intget_qos_for_topic(constchar*topic){if(strstr(topic,"/heartbeat"))return0;if(strstr(topic,"/data"))return0;if(strstr(topic,"/event"))return1;if(strstr(topic,"/alert"))return1;if(strstr(topic,"/cmd/resp"))return1;if(strstr(topic,"/billing"))return2;return1;// 默认 QoS 1}这套策略有三个原则:
原则一:能用 QoS 0 的坚决不用 QoS 1。周期性数据、心跳、状态同步——全部 QoS 0。在电池设备上,这能减少 30~50% 的通信等待时间。
原则二:用 QoS 1 必须配业务层去重。不论是设备端还是云端,任何处理 QoS 1 消息的代码,第一行必须是检查消息 ID 是否已处理。
原则三:QoS 2 只用于金融/不可逆操作。其他场景的「我需要可靠」需求,都可以用 QoS 1 + 去重来满足。
五、一个容易被忽视的细节:订阅方的 QoS 和发布方不一样
很多人以为发布时用了 QoS 1,订阅方收到的就一定是 QoS 1 级别的保障。
不是这样的。
MQTT 的 QoS 有一个「降级」机制:实际的消息送达等级取决于发布方 QoS 和订阅方 QoS 的最小值。
发布方 QoS 1 + 订阅方 QoS 0 → 实际送达 QoS 0(可能丢消息) 发布方 QoS 1 + 订阅方 QoS 1 → 实际送达 QoS 1(可能重复) 发布方 QoS 2 + 订阅方 QoS 1 → 实际送达 QoS 1(可能重复)这意味着:如果你的订阅者(云端服务)订阅时指定了 QoS 0,即使设备发布时用的是 QoS 1,Broker 转发给订阅者的时候可能降级为 QoS 0,丢消息不会有任何提示。
这个细节在调试阶段极其容易被忽视,因为你看设备端日志一切正常——消息发出去了,PUBACK 也收到了。但云端就是没收到数据,检查半天查不出原因。
实践建议:订阅者的 QoS 等级要和发布者保持一致,或者明确知道为什么要降级。
六、QoS 和 CleanSession 的联动关系
QoS 0、1、2 和 MQTT 的CleanSession标志有一个重要的联动,很多教程没有讲清楚:
CleanSession = true:设备断开时,Broker 丢弃该设备的所有订阅和未发送消息。设备重连后是一个全新的会话。
CleanSession = false:设备断开时,Broker 保留订阅关系,并且缓存 QoS 1 和 QoS 2 的消息(QoS 0 不缓存)。设备重连后,Broker 把离线期间的消息补发过来。
这里有一个严重的坑:如果 CleanSession = false 但设备长期离线,Broker 的消息缓存会无限增长,直到 Broker 内存耗尽或者达到配置的最大缓存数量被丢弃。
我见过一个项目,NB-IoT 设备用了 CleanSession = false + QoS 1,设备白天定时上线 6 小时,其余时间离线。云端有一个定时任务每 5 秒推一次配置同步消息。设备离线 18 小时,积累了约 12960 条消息在 Broker 缓存里。设备一上线,Broker 立刻把这 12960 条消息全部涌过来,设备直接被淹没,消息处理队列撑爆,系统重启。
正确的做法:
对于大多数 IoT 设备,如果不需要接收离线消息,CleanSession = true是更安全的选择。
如果确实需要离线消息(比如 OTA 触发指令,不能错过),使用CleanSession = false时必须:
// 上线后限速处理离线消息// 在 MQTT 连接建立后,设置一个处理速率上限voidon_connected(void){// 方案 1:设置一个标志,上线后先暂停业务操作,// 等待消息队列处理完毕(带超时保护)g_draining_offline_msgs=true;g_drain_timeout=systick_ms()+5000;// 最多等 5 秒// 方案 2:Broker 侧限制每个会话的最大缓存消息数// 在 EMQX 配置中设置:// mqtt.max_mqueue_len = 100// mqtt.mqueue_store_qos0 = false}七、在 ESP-IDF 里正确配置 QoS
把上面的理论落地到代码,以 ESP32 + ESP-IDF 为例:
#include"mqtt_client.h"staticesp_mqtt_client_handle_tclient;// 发布不同 QoS 级别的消息voidpublish_sensor_data(floattemp,floathum){charpayload[64];snprintf(payload,sizeof(payload),"{\"temp\":%.1f,\"hum\":%.1f}",temp,hum);// QoS 0:周期采样数据,不等确认esp_mqtt_client_publish(client,"/devices/sensor001/data",payload,0,0,// QoS = 00);// retain = false}voidpublish_alert(constchar*alert_msg,uint32_tmsg_id){// QoS 1:告警消息,等待确认intmsg_id_out=esp_mqtt_client_publish(client,"/devices/sensor001/alert",alert_msg,0,1,// QoS = 10);// msg_id_out 是本次发布的 Packet Identifier// 在 MQTT_EVENT_PUBLISHED 回调里用它确认发布完成LOG_INFO("Alert published with packet_id=%d",msg_id_out);}// 事件回调中处理 PUBACKstaticvoidmqtt_event_handler(void*arg,esp_event_base_tbase,int32_tevent_id,void*event_data){esp_mqtt_event_handle_tevent=event_data;switch(event->event_id){caseMQTT_EVENT_PUBLISHED:// QoS 1/2 消息被 Broker 确认LOG_INFO("Message published, msg_id=%d",event->msg_id);// 在这里可以从「待确认队列」中移除该消息pending_queue_remove(event->msg_id);break;caseMQTT_EVENT_DATA:// 收到订阅消息// 先检查是否重复(QoS 1 保护)if(!msg_cache_contains(event->msg_id)){msg_cache_add(event->msg_id,60);process_incoming_message(event->topic,event->data,event->data_len);}break;}}总结:选 QoS 的决策框架
用一个问题链来做最终决策:
消息丢了,业务能接受吗? ├─ 能接受(心跳、高频采样)→ QoS 0 └─ 不能接受 → 消息重复执行,业务能接受吗? ├─ 能接受(或已做幂等处理)→ QoS 1 └─ 不能接受(计费、不可逆操作)→ QoS 2记住一句话:QoS 是协议层的保障,不能替代应用层的健壮性设计。选了 QoS 1 就必须做去重,选了 QoS 2 就必须测断电恢复,不能因为选了高 QoS 就觉得万事大吉。
下一篇文章,我们进入代码实战——ESP32 + MQTT 从 Wi-Fi 连接到第一条消息上云,给出完整可运行的工程代码。
下一篇:[ESP32 + MQTT 实战:从 Wi-Fi 连接到第一条消息上云,完整可运行代码]
作者:15 年嵌入式软件工程师,专注物联网设备端开发
专栏:嵌入式物联网工程实战:从连接到上云
QoS 选型上有疑问?在评论区留下你的使用场景,我来帮你分析。
