
从发布订阅语义到三次握手,说清三档可靠性契约各自保证什么、代价几何
一、背景:为什么物联网最终选了 MQTT
HTTP 的请求-响应模型在物联网场景里有两个硬伤:服务端无法主动下发指令,且每次请求都要携带完整头部,在 2G / NB-IoT 这类窄带链路上开销过高。MQTT 用长连接加发布订阅换掉了这两个问题——设备与 Broker 之间只维持一条 TCP 连接,消息按主题路由,最小固定头部可以压到 2 字节。
更关键的是,它把可靠性做成了可选档位。传感器上报温度丢一条无所谓,而远程开锁的指令重复执行一次就是事故。MQTT 允许同一套协议承载这两种截然不同的诉求,这正是它在工业与消费物联网里同时成为默认选项的原因。
二、核心概念
在讨论 QoS 之前,有两个更基础的问题必须先讲清楚:消息究竟靠什么被路由到接收方,以及一条长连接是如何被确认仍然活着的。这两点决定了后面所有可靠性机制的边界。
(一)发布 / 订阅与主题
MQTT 里没有"收件人",只有主题(Topic)。发布者把消息丢到 factory/line1/temp,Broker 负责把它推送给所有订阅了该主题的客户端。主题以 / 分层,支持 +(单层通配)与 #(多层通配):factory/+/temp 能匹配所有产线,factory/# 能匹配整个工厂。
订阅关系天然解耦——发布者不需要知道有几个订阅者,订阅者掉线也不会影响发布者。
(二)会话与 Keep Alive
客户端连接时使用 clean_start=False 并固定 client_id,Broker 就会为它保留订阅关系与未确认消息,这条特性是"离线消息"能力的前提。Keep Alive 则用于探测半开连接:客户端在 1.5 倍周期内未发出心跳,Broker 即判定其离线。
三、三级 QoS:分别保证了什么
| QoS | 语义 | 握手过程 | 代价 |
| 0 | 最多一次 | 无 | 可能丢失,不重传 |
| 1 | 至少一次 | PUBLISH → PUBACK | 可能重复 |
| 2 | 恰好一次 | PUBLISH → PUBREC → PUBREL → PUBCOMP | 两次往返,内存开销最大 |
有一个容易被忽略的细节:QoS 由发布侧与订阅侧各自协商。发布者以 QoS 2 发送、订阅者却以 QoS 1 订阅时,最终生效的是两者中的较小值。因此整条链路的可靠性由最低的那一环决定。
四、实战:用 Python 验证行为差异
以 paho-mqtt 2.1 为例(Python 3.11+,Broker 使用 Mosquitto 2.0):
# publisher.py —— 逐级发送同一条消息
import paho.mqtt.client as mqtt
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="pub-1")
client.connect("localhost", 1883, keepalive=60)
client.loop_start()
for qos in (0, 1, 2):
info = client.publish(f"demo/qos{qos}", f"payload-qos{qos}", qos=qos)
# QoS 0 时 is_published() 立即为 True;QoS 1/2 需等握手完成
print(qos, "published:", info.is_published()) 把订阅端断网 5 秒再重连,可以观察到清晰的分野:QoS 0 在断线期间的消息永久丢失;QoS 1 会在重连后补发,但可能收到两条重复;QoS 2 不丢不重,代价是 Broker 要为每条消息维护状态直到 PUBCOMP。五、原理深挖:重复与状态从哪来
QoS 1 的重复源于"确认丢失"。Broker 收到消息后回 PUBACK,如果这个 ACK 在链路上丢了,发布者会重发同一条消息——它并不知道重复,其设计取向是宁可重复也不能丢。订阅侧同理。
QoS 2 用四次握手消除歧义:PUBREC 让发布者确认"Broker 已收到",但此时 Broker 尚不能投递;直到收到 PUBREL,Broker 才认定"对方不会再重发同一条",随后投递并回 PUBCOMP。代价是每条在途消息在 Broker 上至少占一份状态,高并发下这是实打实的内存压力。
六、踩坑记录
- QoS 2 不是万能药。 它只保证 MQTT 层不重复,应用层重试引发的重复它管不着,幂等键仍然必须做。
- QoS 1 的重复是常态而非异常。 下游处理必须幂等,不要指望靠计数来对账。
- 不要全链路开 QoS 2。 设备到云、云到设备通常用 1,仅在涉及金钱或安全的指令上才升到 2。
七、性能与容量:QoS 的真实开销
QoS 的代价并不是线性的。在同一台 Broker 上,QoS 1 的吞吐大致是 QoS 0 的 70% 到 85%(具体取决于网络 RTT),而 QoS 2 往往只剩 QoS 0 的一半左右——每条消息都要经历两次往返外加状态落盘。
更值得警惕的是在线客户端数量对内存的放大效应。QoS 2 会话中每条未确认消息都要按客户端维度持有状态;若同时在线十万台设备、每台积压十条未确认消息,Broker 持有的状态对象就是百万量级。这正是许多生产环境最终把 QoS 2 限制在少数关键主题上的直接原因。
另一个常被忽略的变量是持久化会话带来的消息积压。开启 clean_start=False 后,Broker 会为离线客户端缓存消息;如果客户端长期不回来,积压会持续占用内存或磁盘。上线前务必为每个会话设置积压上限(如 Mosquitto 的 max_queued_messages、EMQX 的 max_mqueue_len),否则一次设备批量离线就足以拖垮整个集群。
八、总结
选 QoS 的判断顺序是:先问"丢一条的后果",再问"重复一条的后果"。两个后果都能接受就选 0;怕丢不怕重选 1;两者都不能接受才选 2,并接受它的吞吐代价。
HarmonyOS开发|ArkTS UI颜色API通用规则
