MQTT 协议精讲:QoS 0/1/2 背后的工程权衡,不是文档翻译

📅 2026/6/17 22:36:57
MQTT 协议精讲:QoS 0/1/2 背后的工程权衡,不是文档翻译
MQTT 协议精讲QoS 0/1/2 背后的工程权衡不是文档翻译专栏第 4 篇这篇文章不会告诉你「QoS 2 是最可靠的所以应该用 QoS 2」。15 年里我见过太多这样的决策——选了最高级别系统反而出了更难排查的问题。QoS 是工程权衡不是质量排名。先把文档说的话扔掉MQTT 规范对 QoS 的描述是这样的QoS 0At most once最多一次QoS 1At least once至少一次QoS 2Exactly once恰好一次这三句话没有错但它们没有告诉你任何值得你停下来思考的事情。真正的工程问题是QoS 1 的「至少一次」意味着什么意味着你的业务代码必须处理重复消息否则一条控制指令可能被执行两次。QoS 2 的「恰好一次」是对谁保证的是对 Broker不是对你的业务层。如果你的业务层代码在处理消息时崩溃了QoS 2 救不了你。QoS 0 真的不可靠吗在稳定局域网环境下TCP 本身就保证了消息送达QoS 0 在实践中几乎不丢消息。所以选 QoS 的正确姿势是先想清楚消息丢失和消息重复哪个对你的业务危害更大再选对应的保障机制。一、QoS 0被低估的「不可靠」它的实际传输过程设备 Broker | | |──── PUBLISH (QoS0) ────────| 发完即忘 | | | [收到 or 丢失设备永远不知道]一次网络写操作仅此而已。没有 ACK没有重传没有状态机。这是 MQTT 所有机制中最简单的一个。什么时候 QoS 0 是正确答案有三类数据天然适合 QoS 0高频周期性采样。每秒上报一次温度丢掉一条无所谓下一秒还有。如果用 QoS 1每条消息都要等 PUBACK加上重传逻辑在弱网环境下反而会造成消息堆积——设备在等确认的时候新的采样数据又来了队列撑满系统更容易崩。MQTT 心跳/保活包。心跳本来就是「我还在线」的信号丢一个没关系下一个心跳自然会更新状态。用 QoS 1 发心跳是浪费。可被最新值覆盖的状态。如果云端只关心「当前最新值」历史中间值丢了不影响业务QoS 0 是正确选择。QoS 0 真实的丢包率是多少很多人有个误区觉得 QoS 0 会大量丢包。现实是在 TCP 连接正常的情况下QoS 0 的消息和 QoS 1 一样都走 TCPTCP 本身有重传机制保证字节流送达。QoS 0 丢消息的场景只有两个一是 TCP 连接断开的瞬间断开前缓冲区里的数据丢失。二是 Broker 内存队列满了直接丢弃。所以在稳定的 Wi-Fi 或以太网环境下QoS 0 实测丢包率接近 0。只有在 4G/NB-IoT 这类链路频繁切换的场景才会出现明显的 QoS 0 消息丢失。结论QoS 0 不是「不可靠」而是「不保证」。两者的差距在稳定网络下可以忽略不计。二、QoS 1工程上最常用也最容易用错传输流程正常情况设备 Broker | | |──── PUBLISH (QoS1, id42) -| | | [存储消息] |─── PUBACK (id42) ──────────| | | | [删除待确认队列中的 id42] |PUBACK 丢失时这是最容易被忽视的场景设备 Broker | | |──── PUBLISH (QoS1, id42) -| | | [存储消息] | × PUBACK 在网络中丢失 | | | | [超时重发] | |──── PUBLISH (QoS1, id42, -| ← DUP 标志位置 1 | DUP1) ─────────────────| | | [Broker 可能再次存储] |─── PUBACK (id42) ──────────|注意第二次 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 Identifierif(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 0QoS 1正常QoS 1PUBACK 丢失重传一次网络交互次数124理论完成时间~80ms~580ms~1580ms唤醒时间功耗影响最短中等显著增加对于电池供电的 NB-IoT 设备这个时间差直接反映在功耗上。每次唤醒多 1 秒在 PSM 模式下意味着整体平均功耗上升约 3~5%——乘以 10 万台设备和 5 年使用周期是真实的电池寿命差距。三、QoS 2最贵的保证用之前先问三个问题四次握手的完整流程设备 Broker | | |──── PUBLISH (QoS2, id77) -| 第 1 次发布 | | [存储但尚不分发] |─── PUBREC (id77) ──────────| 第 2 次已收到 | | | [存储 PUBREC 状态] | |──── PUBREL (id77) ─────────| 第 3 次确认释放 | | [分发给订阅者] |─── PUBCOMP (id77) ─────────| 第 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_HEARTBEAT0,// 心跳包 → QoS 0MSG_SENSOR_DATA0,// 周期采样数据 → QoS 0高频允许偶尔丢MSG_EVENT1,// 事件上报 → QoS 1 业务去重MSG_ALERT1,// 告警上报 → QoS 1 业务去重MSG_CMD_RESPONSE1,// 指令执行回报 → QoS 1MSG_BILLING2,// 计费触发 → 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 1Broker 转发给订阅者的时候可能降级为 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_msgstrue;g_drain_timeoutsystick_ms()5000;// 最多等 5 秒// 方案 2Broker 侧限制每个会话的最大缓存消息数// 在 EMQX 配置中设置// mqtt.max_mqueue_len 100// mqtt.mqueue_store_qos0 false}七、在 ESP-IDF 里正确配置 QoS把上面的理论落地到代码以 ESP32 ESP-IDF 为例#includemqtt_client.hstaticesp_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_outesp_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_teventevent_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 选型上有疑问在评论区留下你的使用场景我来帮你分析。