1. 从一次实际故障聊起:为什么选错 QoS 会让人半夜爬起来修
先说个我踩过的坑。某次给一套物联网设备做数据采集,传感器每隔几秒上报一次温湿度,我图省事把所有消息都设成了 QoS 0,觉得"丢了就丢了,下一条马上就来"。结果客户现场出现间歇性数据空洞,运维同事查到半夜,最后发现是弱网环境下报文频繁丢失,而业务侧又拿这条数据做告警判断,一个空洞直接触发误报。反过来,另一个项目里我把所有下行控制指令都设成了 QoS 2,结果设备量上来之后,broker 的 Qos 2 报文处理队列积压严重,指令延迟从几十毫秒飙升到好几秒。两次教训让我彻底明白一件事:MQTT 的 QoS 不是越高越好,选错才是真麻烦。
在选择 QoS 之前得先弄清楚它到底解决什么问题。MQTT 是应用层协议,跑在 TCP 之上,按理说 TCP 已经保证了字节流的可靠传输,为什么还需要业务层的 QoS 等级?原因在于 MQTT 的通信模型是"客户端 - broker - 客户端",而 broker 是独立进程,TCP 只保证单条连接上的字节不丢不乱,不保证 broker 在转发消息、处理 session、管理订阅关系时不出岔子。同时,MQTT 消息是"发布 / 订阅"的异步模式,发布者发完就完事,订阅者可能在某个时间点才上线,这中间消息在 broker 里怎么存、怎么保证不重复不丢失,TCP 根本管不着。所以 QoS 实际上是客户端与 broker 之间、以及 broker 与订阅端之间的协议层可靠性约定,从每次握手需要交换多少个报文就能看得很清楚。
这篇文章我把 QoS 0、1、2 逐个拆开,核心关注点只有一个:它们各自在投递一条消息时,到底要交换多少报文、状态机长什么样、失败时谁来负责重试、以及这些机制在真实业务中对应的代价是什么。把这几个问题算明白,你自然知道什么场景用什么等级。
2. QoS 0:一发一收的"裸奔"模式,到底省在哪
QoS 0 是整个协议里最干脆的等级,官方术语叫 At most once,最多一次投递。它的行为就是:发送端把 PUBLISH 报文扔出去,然后什么都不管,没有 ACK,没有重传,没有消息 ID。broker 收到之后往匹配的订阅者那边转发一条 PUBLISH,同样不确认。
2.1 报文数量与协议行为
一条 QoS 0 消息的完整链路,报文数量极其固定:
- 发送端向 broker 发送 1 个 PUBLISH 报文
- broker 向每个匹配的订阅端转发 1 个 PUBLISH 报文
- 全程 0 个确认报文
如果发送端和订阅端有 N 个匹配订阅关系,那么一条原始消息最多产生"1 + N"个 PUBLISH 报文,除此之外没有任何额外的握手开销。这里有一个容易忽略的细节:PUBLISH 报文的固定头里,QoS 等级只占 2 bit,QoS 0 对应的值是 0,所以 DUP 标志位、RETAIN 标志位的组合也相对简单。没有 QoS 0 的消息没有 Packet ID(或者说 Packet ID 字段长度为 0),这意味着从协议层面根本无法追踪某一条消息是否被送达。
2.2 省下来的开销背后是什么
QoS 0 最大的价值是极低的延迟和极小的带宽占用。对于上报型业务,比如环境温湿度、GPS 坐标、设备心跳,这类数据天然有"时间价值衰减":一条数据过了几秒就是废数据,重传旧数据反而会干扰下游分析。与其纠结丢没丢,不如尽快把最新值送过去。而且这类消息量大、频率高,如果每条都走确认流程,broker 的 CPU 和网络 IO 都会明显上升,最终反而挤压了真正重要的控制消息的带宽。
但省下的代价也清清楚楚:消息可能丢失。TCP 层只保证字节流到达 broker,不保证 broker 应用层一定能把消息成功写入订阅者的会话。比如订阅者正好在断线重连瞬间,消息被 broker 发往旧的 socket,TCP 连接关闭时数据被丢弃,QoS 0 不会触发任何重发;再比如 broker 内部路由处理时发现没有匹配的订阅者,消息直接丢弃,也不会告诉发送端。这些行为在协议规范里都是允许的,所以业务代码必须把"可能丢消息"当成默认前提来设计。
2.3 一个容易犯的认知错误
很多人以为"反正应用层自己会做数据校验,QoS 0 丢了我可以从别的地方补",然后给所有消息都用 QoS 0。这个思路在纯数据采集、且下游能做时序拼接的场景下是合理的,但在以下场景就危险了:
- 告警触发类消息(比如设备温度越限、电量低),丢了可能就是一次安全事故
- 状态变更类消息(比如设备在线 / 离线切换、门锁开闭),丢了会导致业务侧状态字段和实际不符
- 幂等性较弱的下行控制消息(比如"切换工作模式"),重发或丢失都会造成状态漂移
所以我现在的习惯是:把所有消息按"可丢性"分三档,QoS 0 只放"丢掉之后对业务无影响或影响可自然恢复"的那一档。这不是保守,而是吃过亏之后的经验。
3. QoS 1:多一来回,买到的是"至少一次"的确定性
QoS 1 全称 At least once,至少一次投递。它的引入提供了消息到达确认机制,但同时也引入了重复消息的可能性。这个等级应该是 IoT 项目里用得最多的,比 QoS 0 多了一倍的握手,但可靠性提升非常明显。
3.1 报文交换流程逐字节算清
一条 QoS 1 消息从发布端到 broker 的流程如下,我做了一个逐步骤拆解:
- 发送端先给消息分配一个 Packet ID(2 字节,从 1 到 65535)
- 发送端发送 PUBLISH 报文,QoS 位设为 1,Packet ID 填入报文
- broker 收到后,把消息按订阅关系转发给所有匹配的订阅端
- broker 向发送端回复一个 PUBACK 报文,确认收到
- 发送端收到 PUBACK 后,把这条消息的本地状态标记为已完成
如果发送端在发出 PUBLISH 之后,等待 PUBACK 期间发生了超时或连接断开,发送端就不知道 broker 到底有没有收到。此时协议允许发送端在重连后重新发送一次 PUBLISH,并且把 DUP 标志置 1,表示这是一条可能重复的报文。这个 "DUP + 重发" 的机制,正是 QoS 1 重复消息的来源。
从报文数量上看,一条 QoS 1 消息发布和确认至少需要 2 个报文(PUBLISH + PUBACK),如果发生超时重发,那就是 4 个、6 个甚至更多。对于每个匹配的订阅端,broker 向它转发时同样会进行 QoS 1 的流程,因此完整链路的报文量是 2 + 2N(不考虑重发)。
3.2 重复消息为什么不可避免
注意上面对话里第三步的时序很关键:broker 在"转发给订阅端"和"回复 PUBACK"之间是有顺序的。不同 broker 的实现可能先转发再回 ACK,也可能先回 ACK 再转发,甚至可能是异步并发。如果 broker 已经向订阅端转发了 QoS 1 消息,但是发送端断线了没收到 PUBACK,那发送端重发时,订阅端就会收到两条一模一样的消息。两条消息的 Packet ID 一样,DUP 标志也一样(因为都是同一会话重发),订阅端无法简单地通过 Packet ID 去重,因为 QoS 1 规范里压根没要求它做去重。
这就是"至少一次"的代价:消息不丢,但你得自己处理重复。最常用的办法是在应用层加业务去重,比如每条消息带一个全局唯一的 messageId 字段,订阅端用一个滑动窗口或者 Redis set 记录最近处理过的消息 ID。另一个常规思路是把消息做成幂等操作,例如"把温度设为 25 度"这种赋值型指令天然幂等,重复执行两次结果一样;而"把计数器加 1"这种非幂等操作就必须配合业务去重。
3.3 QoS 1 实际项目中扩展出来的花样
实际项目里我还见过 QoS 1 配 session 过期时间的方式来做离线消息缓存。比如控制指令场景,设备可能离线一小段时间,如果 cleanSession=false 且 session 未过期,broker 会把 QoS 1 消息缓存在会话里,等设备重连时再补发。这比直接用 QoS 2 便宜得多,而且大部分业务对"重复指令"是可以容忍的——用幂等设计把重复消掉就好。所以 QoS 1 是"可靠性和成本折中最均衡"的等级,也是我默认的起步等级。
4. QoS 2:两轮四次握手,为了"恰好一次"付出多少代价
QoS 2 全称 Exactly once,恰好一次投递。这是 MQTT 协议里最复杂、也是最容易被误解的部分。很多人以为 QoS 2 就是"更可靠一点",实际上它是用一整套握手状态机换来"消息既不丢失也不重复"的语义,报文量和协议复杂度都远超 QoS 0 和 QoS 1。
4.1 每一条消息的四步完整往返
QoS 2 的发送端到 broker 这一段,协议规定的报文交换流程严格分成两阶段:
第一阶段:
- 发送端发送 PUBLISH 报文,QoS 位设为 2,携带 Packet ID
- broker 收到后,不会立刻转发给订阅端,而是先持久化这个 Packet ID 到会话状态,标记为"已收到但未释放",然后向发送端回复 PUBREC 报文
第二阶段:
- 发送端收到 PUBREC 后,知道 broker 已经确认收到该消息,于是发送一个 PUBREL 报文,表示"我这边已经完成发送流程,你可以正式释放这条消息"
- broker 收到 PUBREL 后,才把消息转发给订阅端,同时向发送端回复 PUBCOMP 报文,表示整个 QoS 2 发送流程关闭
现象上,一条 QoS 2 消息从发送端到 broker 至少要交换 4 个报文:PUBLISH、PUBREC、PUBREL、PUBCOMP。在最理想情况下是各 1 个,总共 4 个。如果在某个环节发生重传,那报文数量还会成倍增加。这个成本已经非常直观了:传输 1 条 QoS 2 消息,控制报文的个数是 QoS 1 的 2 倍,是 QoS 0 的 4 倍以上。
4.2 为什么是"两阶段提交",而不是简单 ACK 两次
初学 MQTT 的人都会有一个疑问:QoS 1 是 PUBLISH + PUBACK,QoS 2 为什么不能做成 PUBLISH + PUBACK + 再 PUBACK 一次?因为"两次独立的 ACK"解决不了重复问题。如果只用一次 ACK,重复完全靠语义;用两次独立 ACK,发送端重发 PUBLISH 时,broker 无法区分"这条 PUBLISH 是全新的还是重发的",也就无法决定到底要不要把消息转发两次。
QoS 2 的关键设计在于中间那个 PUBREL。PUBREL 是一张"释放令牌",相当于告诉 broker:之前的 PUBLISH 我已经确认你收到了,现在我们要完成最后的交接。broker 在收到 PUBLISH 时记录 Packet ID,但并不会立即把它投入转发流程;收到 PUBREL 之后,这个 Packet ID 的状态才从"待处理"变为"可转发",从而保证任何一个 Packet ID 在 broker 侧只会被"消费"一次。这套机制和分布式事务里的两阶段提交思路非常像,本质上就是用额外的握手换取"唯一一次投递"的语义保证。
从 broker 侧视角来看,还隐含了一个重要逻辑:收到 PUBREC 后,broker 一方面要保存 Packet ID,另一方面可以回复 PUBREC;但是对外转发消息必须等到 PUBREL 之后。如果发送端在发出 PUBREL 后一直没收到 PUBCOMP,发送端会重发 PUBREL(而不是重发 PUBLISH),broker 收到重复的 PUBREL 时不会再次转发消息,只会再次回复 PUBCOMP。这个细节保证了无论如何重试,订阅端最多收到一次。
4.3 QoS 2 的状态机:异常情况都从哪里来
我把 QoS 2 发送端的典型状态变化列一下,更直观:
| 发送端状态 | 触发事件 | 动作 |
|---|---|---|
| 待发送 | 业务产生消息 | 发送 PUBLISH,记录 Packet ID,进入"等待 PUBREC" |
| 等待 PUBREC | 收到 PUBREC | 发送 PUBREL,进入"等待 PUBCOMP" |
| 等待 PUBREC | 超时 / 断线 | 重连后重发 PUBLISH(DUP=1),仍在"等待 PUBREC" |
| 等待 PUBCOMP | 收到 PUBCOMP | 清理 Packet ID,流程结束 |
| 等待 PUBCOMP | 超时 / 断线 | 重连后重发 PUBREL,仍在"等待 PUBCOMP" |
你可能注意到了,等待 PUBREC 阶段重发的是 PUBLISH,等待 PUBCOMP 阶段重发的是 PUBREL。这两次重发的报文类型是不同的,很多 SDK 的 debug 日志里可以看到同一 Packet ID 的 PUBLISH 和 PUBREL 交替出现,如果你不理解状态机,很容易误判成协议 bug。其实这正是 QoS 2 为了防重复做的一个细节:只有收到 PUBREC 之后才允许把消息提交给 broker,否则无条件重发 PUBLISH;而一旦进入 PUBREL 阶段,PUBLISH 已经确认落地了,重发 PUBREL 不会造成二次转发。
4.4 一个值得警惕的坑:QoS 2 不是"端到端恰好一次"
QoS 2 只保证了"发布端到 broker"这一段恰好一次,以及"broker 到单个订阅端"这一段恰好一次。如果一条消息从发布端发出,broker 把它转发给两个订阅者 A 和 B,那么在 A 和 B 那边,各自会独立执行 QoS 2 流程。也就是说,broker 和 A 之间的 Packet ID 可能与 broker 和 B 之间的完全不同,A 和 B 收到消息的时间也可能差很多。QoS 2 从来不做跨订阅端的全局去重,它只做连接级的语义保证。
更关键的是,如果发布端和 broker 用 QoS 2,但 broker 向某个订阅端转发时,订阅策略或会话配置把 QoS 降级了(比如订阅时请求 QoS 1,或者 broker 对 QoS 2 做了下钻映射),那么端到端的语义就不是恰好一次。所以当你看到某个架构说自己"实现了端到端 exactly once",一定要追问一句:发布端到 broker 是什么等级,broker 到订阅端是什么等级,两个等级不一致的时候,消息语义以更低的那个为准。
5. 三张表看懂 QoS 选型:性价比才是第一原则
把前三节的内容压缩一下,QoS 等级的核心差异其实可以收敛成一张报文对照表、一张可靠性对照表、和一张选型决策表。我在实际评审方案时,基本就是拿这三张表逐条过需求。
5.1 报文量与开销对照
| QoS等级 | 最简报文数 | 确认机制 | 消息ID | 超时重发动作 | 重复可能性 |
|---|---|---|---|---|---|
| QoS 0 | 1(PUBLISH) | 无 | 无 | 无(没有重发逻辑) | 不可能重复(但可能丢失) |
| QoS 1 | 2(PUBLISH + PUBACK) | 有 | 有 | 重发 PUBLISH(DUP=1) | 可能重复 |
| QoS 2 | 4(PUBLISH + PUBREC + PUBREL + PUBCOMP) | 两阶段确认 | 有 | 等待 PUBREC 时重发 PUBLISH,等待 PUBCOMP 时重发 PUBREL | 不重复(保证恰好一次) |
这个表格应该是你电脑前贴着的默认参考。凡是让你"一把梭直接全上 QoS 2"的,把这个表格甩给他看:同样的消息吞吐,QoS 2 的协议开销是 QoS 1 的两倍,是 QoS 0 的四倍,如果消息量大,这带来的 CPU、带宽、延迟成本会直接反馈在监控曲线上。
5.2 典型场景决策参考
以下选型建议来自我参与过的几类真实项目,不是教科书标准答案,但大概率能帮你少走弯路:
| 业务场景 | 消息特征 | 推荐QoS | 理由 |
|---|---|---|---|
| 传感器高频数据上报 | 周期性、容忍少量丢失、量大 | QoS 0 | 省带宽、低延迟,下游可以做插值或跳变检测 |
| 设备心跳 / 在线状态 | 低频、需要不丢失 | QoS 1 | 丢了就误判离线,正常重发可接受 |
| 控制指令(开关、调参) | 低频、需要必达 | QoS 1 + 业务幂等 | 重发可接受,但要做好指令去重 |
| 计费 / 工单 / 支付回调 / 授信变更 | 低频、语义重大、不允许重复 | QoS 2 | 用协议层保证恰好一次,省掉大量对账 |
| 离线设备指令缓存 | 设备可能长期离线 | QoS 1 + 持久会话 | 用 session 缓存,成本比 QoS 2 低很多 |
要注意的是,表格里 QoS 1 + 业务幂等和 QoS 2 是两条不同的技术路线。很多时候 QoS 2 能解决的问题,QoS 1 配合幂等 ID 也能兜住,只是实现位置不同:QoS 2 把去重做在协议栈里,QoS 1 把去重做在应用代码里。前者省心但费带宽,后者省带宽但费开发。
5.3 不要盲目升级 QoS 的另一个原因
有人可能想:我都用 QoS 2,成本高点就高点,至少保险。但 QoS 2 的"恰好一次"有一个前提条件:broker 必须能够维护每个 Packet ID 的会话状态。如果 broker 宕机重启、或者客户端长时间离线导致会话过期,那么 QoS 2 的握手状态也会丢失,此时若发送端重连并重发 PUBREL,broker 可能已经不认识这个 Packet ID 了,这时候会回复一个 DISCONNECT 或直接当作新消息处理。所以 QoS 2 并不等于"消息绝对可达",它只是尽最大努力,保证在链路和会话正常的前提下不重不丢。一个真正可靠的系统还是要靠业务层的最终一致性设计和重试机制兜底,而不是寄希望于单一协议等级。
6. 实际工程里影响 QoS 选择的隐藏因素,我不说你很难注意到
上面聊的都是协议本身的语义,但在真实项目里,QoS 最终表现还受一堆外部因素制约。这些因素在报文数量层面看不出差异,一旦上线都是暗坑。
6.1 cleanSession 与持久会话对 QoS 的影响
MQTT 连接由 CONNECT 报文里的 cleanSession 标志控制。如果 cleanSession=true,broker 不保存任何会话状态,客户端重连后一切清零。此时 QoS 1 或 QoS 2 的"重发机制"依旧存在,但当客户端断开期间到达的消息,broker 不会缓存,直接丢弃。如果 cleanSession=false,broker 会为这个客户端保留 session,包括未确认的 QoS 1 / QoS 2 消息状态和离线消息队列,这才会出现我们常说的"离线消息补发"。
所以做选型的时候,要问的不只是 QoS 等级,还有 cleanSession 配置。设备上报场景通常 cleanSession=true,简单干脆;但控制指令缓存、遗嘱消息、状态同步这些场景,往往要开持久会话。我见过一个项目用了 QoS 2 但忘了关 cleanSession,结果设备一离线消息就丢,排查了很久才发现是会话状态没保留。协议等级选得再高,会话策略不对等于白搭。
6.2 Broker 实现差异:同一 QoS,不同表现
不同 broker 内部实现方式差别很大,直接体现在 QoS 处理路径上。轻量级 broker 可能把所有消息都放在内存里,投递完就删;企业级 broker 会对消息做持久化,甚至在 QoS 2 转发之前写入消息存储。如果你的 QoS 2 消息数量很大,broker 的磁盘 IO 就是新的瓶颈。我实测过同一组 QoS 2 消息在嵌入式 broker 和企业级 broker 上的表现,吞吐可以差一个数量级,原因就是后者多了持久化步骤。
另外,某些 broker 为了性能会在转发时做 QoS 降级。比如发布端用 QoS 2 发送,但某个订阅端订阅主题时用的是 QoS 0,那么 broker 按照"取发布和订阅 QoS 的较小值"这个规则,只会用 QoS 0 转发给该订阅端。很多人不知道这个规则,以为发布端选了 QoS 2 订阅端就一定能 QoS 2 收到,实际上协议规定是 min(pubQoS, subQoS)。所以客户端订阅时的 QoS 也必须按业务需求明确设置,否则前面省心后面闹心。
6.3 链路层不等于应用层:TCP 超时重传与 MQTT 重发的叠加
MQTT 跑在 TCP 上,TCP 自己也有超时重传,这会不会和 MQTT 的 QoS 重发叠加?会,但它们的语义不同。TCP 重传解决的是字节流层面的丢包,保证"整段字节到达对端";MQTT 重传解决的是 broker 应用层是否处理完毕。举一个极端例子:发送端发出 QoS 1 的 PUBLISH,TCP 层把它拆成几个 segment 发出去,第一个 segment 到了 broker,第二个 segment 丢了,TCP 会重传丢掉的 segment,最终 broker 完整收到 PUBLISH,回复 PUBACK。这一过程 TCP 和 MQTT 都在参与,但 MQTT 的重发逻辑不会介入,因为发送端还没到"超时"条件。如果网络质量极差,TCP 重传多次仍失败,MQTT 层才会启动自己的重发定时器。
所以做测试时,不要用"拔网线"来模拟 MQTT 消息丢失,那模拟的是连接断开,触发的行为往往是会话重建和 DUP 重发,而不是 TCP 重传。想要看 MQTT QoS 机制的真实表现,更好的方式是人为丢包、乱序或延迟注入,比如 tc 命令加 netem 模块,这样可以分别观察 QoS 1 的 PUBACK 超时和 QoS 2 的 PUBREC/PUBCOMP 超时。搞清楚这两层机制,线上出问题时才不至于把锅甩错对象。
6.4 消息体大小对 QoS 开销的放大效应
协议开销的计算不应该只算报文个数,还得乘消息体长度。QoS 0 和 QoS 1 / QoS 2 在 PUBLISH 报文的可变头部上差异很小,差值基本上就是 0 到 2 字节的 Packet ID。但是 QoS 1 和 QoS 2 的确认报文 PUBACK / PUBREC / PUBREL / PUBCOMP 都很短,固定头 + 可变头不超过 4 字节。所以当消息体很大时(比如固件升级包分片),QoS 1 和 QoS 2 的额外开销占比反而小;当消息体很小时(比如开关状态就几个字节),QoS 2 的控制报文开销占比可能超过 100%。这也是为什么大量小报文的遥测场景要坚持用 QoS 0,哪怕业务方一开始喊着"不能丢",讲完成本之后通常也会接受加业务层兜底而不是全局升 QoS。
7. 最后的实操建议和几个值得收藏的细节
写到这里,把我自己项目里沉淀下来的几条硬经验放出来,基本上可以直接"抄作业"。
第一,默认从 QoS 1 起步,再按需降级或升级。不要在项目启动会上拍脑袋定 QoS,而是先按消息分类梳理出"丢得起 / 丢不起但可重复 / 绝不能重复"三类,每一类对应 QoS 0、QoS 1 + 幂等、QoS 2。这套方法我用在十几个项目里都稳。
第二,消息体里一定要带业务 ID,即使你已经用了 QoS 2。因为跨 broker 桥接、版本升级迁移、以及订阅端做多副本消费时,都可能出现重复或乱序,业务 ID 是最后的兜底手段。QoS 2 节省了去重代码,但省不掉业务排重字段。
第三,QoS 2 的 PUBREL 重发是很常见的现象,不要当成错误。正常网络抖动下,每次 QoS 2 消息的传播都可能伴随一次 PUBREL 重发,字节流日志里出现 DUP 标志是正常行为。真正需要警惕的是 PUBLISH 的 DUP 在 QoS 2 下频繁出现,那说明 PUBREC 迟迟未到,大概率是 broker 处理能力存在瓶颈。
第四,做压测时把报文数量统计带上。只要在发布端和订阅端分别统计收到的 PUBLISH 数,就能立刻算出实际 QoS 生效比例。比如发布端发了 1000 条 QoS 1,订阅端收了 1012 条,那说明至少有 12 条经历了重复投递;如果订阅端只收到 958 条,那不是 QoS 的错,而是会话配置或 broker 丢消息,得另行排查。
第五,不要在 QoS 2 上强行叠加"离线永久缓存"。离线缓存场景用 QoS 1 + 持久会话就够了,QoS 2 的握手状态在长时间离线后大概率会失效,到时候反而多出一堆 PUBREL 重发日志和数据残留。把话说到底:QoS 2 是为在线链路可靠性设计,不是为离线海量缓存设计。
我觉得 MQTT 的 QoS 选型本质上是个系统工程问题,协议只是给了一个解决可靠性的杠杆,而你真正要做的,是搞清楚业务能容忍什么语义,再决定要不要花报文数量的成本去换取更严格的语义。报文数量会告诉你每个选择的代价,业务语义会告诉你每个选择的收益,两者对齐,答案自然就出来了。