MQTT QoS深度解析:报文交换机制与选型建议

发布时间:2026/10/15 16:52:07
MQTT QoS深度解析:报文交换机制与选型建议
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 bitQoS 0 对应的值是 0所以 DUP 标志位、RETAIN 标志位的组合也相对简单。没有 QoS 0 的消息没有 Packet ID或者说 Packet ID 字段长度为 0这意味着从协议层面根本无法追踪某一条消息是否被送达。2.2 省下来的开销背后是什么QoS 0 最大的价值是极低的延迟和极小的带宽占用。对于上报型业务比如环境温湿度、GPS 坐标、设备心跳这类数据天然有时间价值衰减一条数据过了几秒就是废数据重传旧数据反而会干扰下游分析。与其纠结丢没丢不如尽快把最新值送过去。而且这类消息量大、频率高如果每条都走确认流程broker 的 CPU 和网络 IO 都会明显上升最终反而挤压了真正重要的控制消息的带宽。但省下的代价也清清楚楚消息可能丢失。TCP 层只保证字节流到达 broker不保证 broker 应用层一定能把消息成功写入订阅者的会话。比如订阅者正好在断线重连瞬间消息被 broker 发往旧的 socketTCP 连接关闭时数据被丢弃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 ID2 字节从 1 到 65535发送端发送 PUBLISH 报文QoS 位设为 1Packet 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 过期时间的方式来做离线消息缓存。比如控制指令场景设备可能离线一小段时间如果 cleanSessionfalse 且 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 IDbroker 收到后不会立刻转发给订阅端而是先持久化这个 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 PUBACKQoS 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而不是重发 PUBLISHbroker 收到重复的 PUBREL 时不会再次转发消息只会再次回复 PUBCOMP。这个细节保证了无论如何重试订阅端最多收到一次。4.3 QoS 2 的状态机异常情况都从哪里来我把 QoS 2 发送端的典型状态变化列一下更直观发送端状态触发事件动作待发送业务产生消息发送 PUBLISH记录 Packet ID进入等待 PUBREC等待 PUBREC收到 PUBREC发送 PUBREL进入等待 PUBCOMP等待 PUBREC超时 / 断线重连后重发 PUBLISHDUP1仍在等待 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 01PUBLISH无无无没有重发逻辑不可能重复但可能丢失QoS 12PUBLISH PUBACK有有重发 PUBLISHDUP1可能重复QoS 24PUBLISH 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 的握手状态也会丢失此时若发送端重连并重发 PUBRELbroker 可能已经不认识这个 Packet ID 了这时候会回复一个 DISCONNECT 或直接当作新消息处理。所以 QoS 2 并不等于消息绝对可达它只是尽最大努力保证在链路和会话正常的前提下不重不丢。一个真正可靠的系统还是要靠业务层的最终一致性设计和重试机制兜底而不是寄希望于单一协议等级。6. 实际工程里影响 QoS 选择的隐藏因素我不说你很难注意到上面聊的都是协议本身的语义但在真实项目里QoS 最终表现还受一堆外部因素制约。这些因素在报文数量层面看不出差异一旦上线都是暗坑。6.1 cleanSession 与持久会话对 QoS 的影响MQTT 连接由 CONNECT 报文里的 cleanSession 标志控制。如果 cleanSessiontruebroker 不保存任何会话状态客户端重连后一切清零。此时 QoS 1 或 QoS 2 的重发机制依旧存在但当客户端断开期间到达的消息broker 不会缓存直接丢弃。如果 cleanSessionfalsebroker 会为这个客户端保留 session包括未确认的 QoS 1 / QoS 2 消息状态和离线消息队列这才会出现我们常说的离线消息补发。所以做选型的时候要问的不只是 QoS 等级还有 cleanSession 配置。设备上报场景通常 cleanSessiontrue简单干脆但控制指令缓存、遗嘱消息、状态同步这些场景往往要开持久会话。我见过一个项目用了 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 的 PUBLISHTCP 层把它拆成几个 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 选型本质上是个系统工程问题协议只是给了一个解决可靠性的杠杆而你真正要做的是搞清楚业务能容忍什么语义再决定要不要花报文数量的成本去换取更严格的语义。报文数量会告诉你每个选择的代价业务语义会告诉你每个选择的收益两者对齐答案自然就出来了。