本文面向中高级后端 / IoT 工程师。我们将跳过"Hello World"式的入门,直接钻进 TCP 字节流里,看看 MQTT 到底是怎么把一条消息从设备送到服务器的。
一、为什么我们要关注 MQTT 协议的底层?
很多开发者对 MQTT 的认知停留在:
go
client.Publish("sensor/temp", 1, false, payload)
client.Subscribe("sensor/temp", 1, handler)
会用,能跑,看起来就够了。
直到生产环境出了问题。
| 你遇到的现象 | 如果不懂底层,你会...... |
|---|---|
| 消息偶尔重复消费 | 以为是代码 bug,疯狂加锁 |
| 设备频繁掉线重连 | 盲目调大 keepalive |
| 微服务扩容后消息被重复处理 | 怀疑 Broker 抽风 |
| 大 payload 导致连接被踢 | 不知道有 Maximum Packet Size |
MQTT 是简单的协议,但不是简单的系统。
底层的价值,不在于"炫技",而在于:当系统规模从 1 个设备变成 10 万个设备、从单体变成微服务集群时,你还能用确定性的知识定位问题,而不是靠猜。
二、MQTT 报文的四个核心组成部分

很多开发者以为 MQTT 报文很复杂,其实从二进制视角看,它极其克制。如上图所示,抛开业务语义,MQTT 报文在 TCP 流中本质上只有四个部分:Fixed Header(固定报头) 、Remaining Length(剩余长度) 、Variable Header(可变报头) 和 Payload(有效载荷)。
1. Fixed Header:协议的"指纹"

这是所有 MQTT 报文的必选项,固定占 1 个字节(8 bits)。如上图所示,这 8 个比特位被清晰地划分为两个域:
- 高 4 位(Packet Type) :定义报文类型。例如
0x10代表 CONNECT,0x30代表 PUBLISH。 - 低 4 位(Flags):随报文类型变化。比如在 PUBLISH 报文中,这 4 个 bit 承载了 QoS 等级、Retain 标记等关键信息。
Fixed Header 的意义在于"断言":当 TCP 流中的一个字节进入缓冲区时,解析器首先通过这 8 个 bit 判断"我是谁",从而决定后续的解析策略。
MQTT 固定报头中的 Packet Type 一览表
| 类型值 (Decimal) | 类型值 (Hex) | 报文名称 (Packet Name) | 发送方向 | 描述 | 必须包含可变报头? | 必须包含有效载荷? |
|---|---|---|---|---|---|---|
| 0 | 0x00 | Reserved | - | 禁止用于传输 (用于协议校验) | - | - |
| 1 | 0x10 | CONNECT | Client → Broker | 客户端请求连接 Broker | ✅ 是 | ✅ 是 |
| 2 | 0x20 | CONNACK | Broker → Client | 连接请求的确认回复 | ✅ 是 | ❌ 否 |
| 3 | 0x30 | PUBLISH | 双向 | 发布消息(核心业务报文) | ✅ 是 | ⚠️ 可选 (QoS=0 时可空) |
| 4 | 0x40 | PUBACK | 双向 | QoS 1 消息的发布确认 | ✅ 是 | ❌ 否 |
| 5 | 0x50 | PUBREC | 双向 | QoS 2 消息的发布已接收 | ✅ 是 | ❌ 否 |
| 6 | 0x60 | PUBREL | 双向 | QoS 2 消息的发布释放 | ✅ 是 | ❌ 否 |
| 7 | 0x70 | PUBCOMP | 双向 | QoS 2 消息的发布完成 | ✅ 是 | ❌ 否 |
| 8 | 0x80 | SUBSCRIBE | Client → Broker | 客户端订阅请求 | ✅ 是 | ✅ 是 |
| 9 | 0x90 | SUBACK | Broker → Client | 订阅请求的确认 | ✅ 是 | ✅ 是 |
| 10 | 0xA0 | UNSUBSCRIBE | Client → Broker | 取消订阅请求 | ✅ 是 | ✅ 是 |
| 11 | 0xB0 | UNSUBACK | Broker → Client | 取消订阅的确认 | ✅ 是 | ⚠️ MQTT 5.0 新增 Payload |
| 12 | 0xC0 | PINGREQ | Client → Broker | 心跳请求(保活) | ❌ 否 | ❌ 否 |
| 13 | 0xD0 | PINGRESP | Broker → Client | 心跳响应 | ❌ 否 | ❌ 否 |
| 14 | 0xE0 | DISCONNECT | 双向 | 断开连接通知 | ✅ 是 (MQTT 5.0) | ⚠️ MQTT 5.0 可选 |
| 15 | 0xF0 | AUTH | 双向 | 认证交换 (MQTT 5.0 新增) | ✅ 是 | ❌ 否 |
-
关于
0x00和0xF0:- 在 MQTT 3.1.1 中,只有 1~14 是有效的。
0xF0(AUTH) 是 5.0 才有的。 - 收到 Type 为 0 或 15(在 3.1.1 环境下)属于协议错误,必须断开连接。
- 在 MQTT 3.1.1 中,只有 1~14 是有效的。
-
关于 Flags(低 4 位):
- 绝大多数报文的 Flags 必须为 0(保留位),除了 PUBLISH。
- PUBLISH 的 Flags 结构很特殊:
DUP(1) + QoS(2) + Retain(1)。
2. Remaining Length:解决粘包半包的"尺子"
紧接在 Fixed Header 之后的,是 Remaining Length(剩余长度)。这是 MQTT 协议设计的精髓所在。
它采用 Base-128 Varints 编码,占用 1~4 个字节。其核心逻辑是:
- 每个字节的最高位(MSB)为延续位(1 表示后续还有字节,0 表示结束)。
- 低 7 位用于存储数值。
这种设计使得 MQTT 既能支持极小的心跳包(仅 2 字节),又能支持最大 256MB 的超大报文。更重要的是,它是 MQTT 在 TCP 字节流中识别报文边界的唯一依据。解析器通过它来确定一个完整的报文何时结束,从而优雅地处理 TCP 的粘包与半包问题。
3. Variable Header:上下文的"变量池"
并非所有报文都包含此部分。它位于 Remaining Length 之后,内容根据 Packet Type 动态变化。常见的包括:
- 协议名与版本(CONNECT)
- 报文标识符(Packet ID)(QoS > 0 的报文,用于应答匹配)
- 属性长度与属性(MQTT 5.0 引入,如 Topic Alias、Response Topic 等)
4. Payload:业务的"载体"
这是真正承载应用数据的地方。对于 PUBLISH 报文,这里是 JSON 或二进制透传数据;对于 SUBSCRIBE 报文,这里是订阅列表。在某些报文(如 PINGREQ)中,Payload 为空。
三、QoS:在不确定网络中构建"确定性"的控制引擎
在前两章中,我们拆解了 TCP 字节流中的四大组成部分。你可能已经注意到,当 Remaining Length(剩余长度)准确划分出报文边界,解析器再通过 Fixed Header(固定报头)的 Packet Type 判断出这是一条 PUBLISH、SUBSCRIBE 还是 PINGREQ 报文时,一条 MQTT 报文就已经完成了二进制层面的解析。
但对于真实的生产环境来说,这只是第一步。
物理网络从来都不是完美的。当设备通过 4G、5G、Wi-Fi 或卫星网络传输数据时,丢包、延迟、抖动、断连几乎不可避免。即使 TCP 已经提供了可靠传输能力,它也只能保证字节流可靠到达 TCP 对端,却无法回答应用层更关心的问题:
- Broker 已经处理了消息,但确认报文(ACK)在返回途中丢失了怎么办?
- 设备刚发完消息就断网了,这条消息到底算成功还是失败?
- 为什么同一条业务消息会被消费两次?
- 为什么有些消息明明没有丢,却还是触发了重发?
为了在不确定的网络环境中构建确定性的消息交付机制,MQTT 在协议层引入了 QoS(Quality of Service,服务质量)。
很多初学者认为 QoS 只是一个数字配置(0、1、2),实际上它远不止如此。
QoS 的可靠性机制主要依赖于 Fixed Header 中的 QoS、DUP 标志,以及 QoS > 0 时 Variable Header 中的 Packet Identifier(Packet ID),再配合一系列确认报文,共同驱动客户端与 Broker 两端的状态机完成消息交付。
换句话说,QoS 不是一个简单的配置项,而是一套完整的可靠消息协议。
1. 协议的"暗门":重新审视 PUBLISH 报文的 Flags
回顾上一章介绍 Fixed Header 时,我们提到:
绝大多数 MQTT 控制报文的低 4 位(Flags)都是固定值。例如:
| 报文 | Flags |
|---|---|
| CONNECT | 0000 |
| CONNACK | 0000 |
| SUBSCRIBE | 0010(固定要求) |
| PUBREL | 0010(固定要求) |
| PINGREQ | 0000 |
如果这些固定值不符合协议要求,Broker 会认为客户端发送了非法报文,从而主动断开连接。
而 PUBLISH 是唯一的例外。
它的低 4 位并不是固定值,而是真正参与协议语义表达:
| Bit 位 | 名称 | 含义 |
|---|---|---|
| Bit 3 | DUP | 是否为重发报文 |
| Bit 2~1 | QoS | 服务质量等级 |
| Bit 0 | RETAIN | 是否作为 Retained Message 保存 |
也就是说,对于一条 PUBLISH 报文而言,这 4 个 bit 不只是几个普通标志位,它们决定了整条消息后续的生命周期。
DUP:是否为重复投递
当发送方在规定时间内没有收到对应确认报文时,它会重新发送同一个 Packet ID 的消息。
此时:
Packet ID保持不变;- Payload 保持不变;
- Topic 保持不变;
- 唯一变化的是 DUP 被置为 1。
它告诉接收方:
这不是一条新的消息,而是一条重发的消息。
需要注意的是,DUP 并不意味着接收方此前一定收到了这条消息。
它仅表示:
发送方认为自己可能需要重发。
至于接收方之前是否已经成功处理,则取决于网络状态以及双方维护的协议状态。
QoS:决定可靠性交付等级
QoS 使用两个比特表示:
| 二进制 | QoS |
|---|---|
00 |
QoS 0 |
01 |
QoS 1 |
10 |
QoS 2 |
11 |
保留值(非法) |
不同的取值,对应完全不同的确认流程。
后面我们将逐一分析。
RETAIN:保留最新一条消息
如果 RETAIN 被置为 1,Broker 会将这条消息保存为该 Topic 的 Retained Message。
之后,当新的客户端首次订阅这个 Topic 时,Broker 会立即将这条保留消息发送给它,而无需等待下一次发布。
需要说明的是:
MQTT 协议只规定 Broker 应保存 Retained Message,并没有规定 Broker 必须使用内存、磁盘还是数据库存储,因此不同 Broker 的具体实现方式可能完全不同。
2. 三种 QoS 等级:可靠性、延迟与吞吐量之间的权衡
很多文章把 QoS 简单总结为:
- QoS 0:最多一次
- QoS 1:至少一次
- QoS 2:仅一次
这些结论没有问题,但更重要的是理解它们为什么会产生这样的行为。
从协议设计来看,不同 QoS 的本质,其实是在以下几个维度之间做权衡:
- 网络交互次数(RTT)
- 字节开销
- Broker 与 Client 保存状态的复杂度
- 消息可靠性
- 重复投递风险
① QoS 0:At Most Once(最多交付一次)
QoS 0 是 MQTT 最轻量的消息模式。
整个协议没有任何确认流程。
Client Broker
| |
|--- PUBLISH (QoS=0) ------>|
| |
它具有两个明显特点。
第一,PUBLISH 报文没有 Packet Identifier。
因为协议没有确认过程,也就不存在后续匹配 ACK 的需求。
第二,发送完成即结束生命周期。
客户端把报文交给 TCP 后,不再维护任何协议状态,也不会等待 Broker 返回确认。
因此:
- 网络中断可能导致消息丢失;
- Broker 尚未收到消息,客户端也不会知道;
- Broker 收到消息后即使崩溃,客户端也不会重发。
因此它被称为:
At Most Once(最多交付一次)
它拥有最低的协议开销,也是吞吐量最高的一种模式。
因此,大量日志、监控指标、设备状态上报等允许偶尔丢失的数据,通常都会采用 QoS 0。
② QoS 1:At Least Once(至少交付一次)
QoS 1 是生产环境中使用最广泛的模式。
它引入了两个新元素:
- Packet Identifier
- PUBACK
通信流程如下:
Client Broker
| |
|--- PUBLISH (Packet ID=1) --------->|
|<----------- PUBACK (ID=1) ---------|
整个过程可以拆成三个阶段。
第一步:发送消息
客户端发送:
PUBLISH
Packet ID = 1
随后,它不会立即删除这条消息,而是将其保存在本地的 Inflight(未确认)队列 中,同时启动重传计时器。
第二步:Broker 返回 PUBACK
Broker 收到后,需要返回:
PUBACK
Packet ID = 1
注意:
PUBACK 的 Packet Identifier 必须与请求保持一致。
客户端收到 PUBACK 后,才能把对应消息从 Inflight 队列中删除。
整个 QoS 1 生命周期到此结束。
第三步:ACK 丢失怎么办?
真正的问题来了。
如果:
PUBLISH -----------> Broker
Broker 已收到
PUBACK ----X-----> Client
ACK 在网络中丢失了。
对于客户端来说,它只知道:
我没有收到确认。
于是客户端会认为:
这条消息可能没有成功。
因此,它会重新发送:
PUBLISH
DUP = 1
Packet ID = 1
Broker 于是可能再次收到同一条消息。
Client Broker
| |
|--- PUBLISH (ID=1) ---------------->|
| X <------ PUBACK -----------|
| |
|--- PUBLISH (DUP=1, ID=1) --------->|
|<----------- PUBACK ----------------|
这就是:
At Least Once(至少一次)
协议保证:
消息尽可能不要丢。
但代价是:
消息可能重复。
因此,对于 QoS 1,幂等性永远属于业务层需要解决的问题。
Broker 可以避免协议层的异常,却无法知道:
"扣款是否已经完成?"
"库存是否已经扣减?"
"订单是否已经创建?"
这些必须依赖业务主键、唯一流水号或去重机制完成幂等处理。
③ QoS 2:Exactly Once(仅交付一次)
QoS 2 是 MQTT 中最复杂的一种消息模式。
为了避免重复投递,它不再采用一次确认,而是设计了一套四阶段握手机制。
Client Broker
| |
|--- PUBLISH ----------------------->|
|<----------- PUBREC ----------------|
|--- PUBREL ------------------------>|
|<----------- PUBCOMP ---------------|
整个流程如下。
第一阶段:PUBLISH
客户端发送:
PUBLISH
Packet ID = X
Broker 收到后,会记录该 Packet Identifier 的处理状态。
第二阶段:PUBREC
Broker 返回:
PUBREC
Packet ID = X
表示:
我已经收到这条消息,并开始进入 QoS 2 的处理流程。
此时,Broker 会保存必要的协议状态,以便后续能够识别重复消息。
需要说明的是:
MQTT 协议并没有规定 Broker 必须在收到 PUBREL 后才向业务系统投递消息。
不同 Broker 可以根据自身实现,在不同阶段完成内部处理,只要最终满足 Exactly Once 的协议语义即可。
第三阶段:PUBREL
客户端收到 PUBREC 后,再发送:
PUBREL
Flags = 0010
Packet ID = X
这里有一个容易忽略的细节。
与普通报文不同:
PUBREL 的 Fixed Header 低 4 位必须固定为 0010。
否则属于协议错误。
第四阶段:PUBCOMP
Broker 完成 QoS 2 流程后:
PUBCOMP
Packet ID = X
双方随后删除对应状态。
整个生命周期结束。
因此:
QoS 2 的真正目标并不是"比 QoS 1 更可靠"。
事实上:
QoS 1 已经能够很好地保证消息不轻易丢失。
QoS 2 真正增加的能力,是避免因为重传导致重复交付。
与此同时,它也意味着:
- 更多 RTT;
- 更多状态维护;
- 更高内存开销;
- 更低吞吐量。
因此,在绝大多数 IoT 场景中,QoS 1 往往已经能够满足需求,而 QoS 2 更多应用于对重复消息极为敏感的业务。
3. QoS 保证的是哪一段链路?
很多开发者第一次接触 MQTT 时,会产生一个误解:
我发送的是 QoS 2,那么消费者收到的一定也是 QoS 2。
实际上并非如此。
MQTT 的 QoS 不是端到端保证。
它保证的是:
每一条 Client 与 Broker 之间连接的可靠性。
例如:
Device
|
QoS2
|
Broker
|
QoS0
|
Subscriber
第一段:
Device <------> Broker
Broker 会按照 QoS 2 完成完整握手。
第二段:
Broker <------> Subscriber
Broker 会根据订阅者声明的 QoS,重新构造一条新的 PUBLISH 报文发送出去。
因此:
Broker 并不是简单"转发(Forward)"收到的网络报文,而是作为协议参与者,重新发布(Republish)消息。
所以:
最终发送给订阅端的 QoS 等级,始终取决于发布 QoS 与订阅 QoS 的较小值:
Effective QoS=min(Publish QoS, Subscribe QoS) \text{Effective QoS} = \min\left(\text{Publish QoS},\ \text{Subscribe QoS}\right) Effective QoS=min(Publish QoS, Subscribe QoS)
例如:
Device
|
Publish QoS2
|
Broker
|
Subscribe QoS0
|
Consumer
虽然设备花费了更多网络开销完成 QoS 2 握手,但 Broker 向消费者发送时,仍然只会构造一条 QoS 0 的 PUBLISH 报文。
因此:
设备到 Broker 的可靠性依旧成立;
但 Broker 到消费者这一段链路,只保证 QoS 0。
这也是很多开发者排查"为什么消息等级降了"时最容易忽略的地方。
小结
理解 QoS,并不仅仅是记住 "0、1、2" 三个数字。
真正需要理解的是:
- 为什么 QoS 0 不需要 Packet Identifier?
- 为什么 QoS 1 会出现 DUP?
- 为什么 QoS 2 要维护更多协议状态?
- 为什么 Packet Identifier 能驱动整个确认流程?
- 为什么 QoS 保证的是单条连接,而不是端到端链路?
当真正理解这些机制之后,再回头去看线上那些"消息重复消费""消息偶尔丢失""ACK 超时重发""QoS 降级"等问题,就会发现它们并不是 Broker 的"随机故障",而是 MQTT 协议为了在可靠性、性能与复杂度之间取得平衡所做出的设计选择。
四、断线重连的底层洗牌:Clean Session 到底在清除什么?
理解了 QoS 的确认机制之后,很多工程师在编写 MQTT 客户端时,很快就会遇到另一个几乎所有 SDK 都会提供的配置项:
go
// 几乎每个人都写过这行代码
opts.SetCleanSession(true)
无论是 Go、Java、Python,还是嵌入式 C SDK,这个参数几乎都会出现在建立连接的配置中。
它的名字叫 Clean Session(清理会话)。
到了 MQTT 5.0,它又演进成了 Clean Start ,并配合 Session Expiry Interval(会话过期时间) 一起使用,语义也更加灵活。
很多人的第一反应是:
"是不是清一下缓存?"
"是不是每次重新连接都会更快?"
事实上,这两个理解都不准确。
Clean Session 控制的从来不是 TCP 连接,也不是网络缓存,而是 Broker 是否保留当前客户端的 MQTT 会话(Session)。
它真正决定的是:
设备断线之后,这个客户端在 Broker 上积累的协议状态,是继续保留,还是立即丢弃。
而这些协议状态,正是上一章介绍 QoS 时反复提到的那些内容:
- QoS 1 尚未确认的消息;
- QoS 2 尚未完成的握手流程;
- 已建立的订阅关系;
- 离线期间等待投递的 QoS 1 / QoS 2 消息。
因此,理解 Clean Session,本质上就是理解:
MQTT 如何处理一次"断线重连"。
1. CONNECT 报文里的那个比特位
当客户端发起连接时,会发送一条 CONNECT 报文。
除了协议名、版本号、Keep Alive 等字段之外,它的 Variable Header 中还有一个只有 1 字节 的字段:
Connect Flags
它的结构如下:
text
Connect Flags(1 Byte)
Bit: 7 6 5 4 3 2 1 0
+-----+-----+------+-----+------+-----+------+------+
|User |Pass |Will |Will |Will |Will |Clean |Reserved|
|Name |Word |Retain|QoS1 |QoS0 |Flag |Session| |
+-----+-----+------+-----+------+-----+------+------+
其中:
Bit 1,就是 Clean Session。
对于 MQTT 3.1.1:
0:保留已有 Session;1:创建新的 Session。
Broker 收到 CONNECT 后,第一件事情就是读取这个 Bit。
随后,它会根据:
- ClientID
- Clean Session
共同决定:
这个客户端,是回来继续上次的会话,还是彻底重新开始。
2. Session 到底保存了什么?
很多人第一次看到 Session,会误以为:
"是不是保存 TCP Socket?"
当然不是。
TCP 连接一旦断开,对应 Socket 就已经不存在了。
Broker 保留的是 MQTT 会话状态(Session State)。
对于 MQTT 3.1.1,一个 Session 通常会保存:
- 当前客户端的订阅列表(Subscriptions)
- 尚未完成确认的 QoS 1 消息
- 尚未完成四阶段握手的 QoS 2 状态
- 等待客户端重新上线后投递的离线消息(QoS 1 / QoS 2)
这些内容是否存放于内存、磁盘,还是其他持久化介质,属于 Broker 的实现细节,MQTT 协议本身并没有规定。
因此,不同 Broker 的表现可能略有差异,但它们对外体现出的 Session 语义是一致的。
3. 当 Clean Session = 1:重新开始一次新的会话
如果客户端设置:
go
opts.SetCleanSession(true)
意味着它告诉 Broker:
不要恢复我以前的 Session,请从零开始建立一个新的会话。
Broker 会删除此前与这个 ClientID 关联的 Session(如果存在),然后创建一个新的 Session。
注意,这里删除的是 MQTT 会话状态,而不是简单地关闭 TCP 连接。
因此:
此前保存的:
- 未确认 QoS 1 消息;
- QoS 2 协议状态;
- 离线消息;
- 已保存的订阅关系(对于 MQTT 3.1.1);
都会一起失效。
对于客户端来说,它完全不知道 Broker 是否已经把旧 Session 删除。
它只知道:
"我之前还有一条 QoS 1 消息没有收到 PUBACK。"
于是,它可能会继续按照 QoS 协议要求重发:
text
PUBLISH
Packet ID = 100
DUP = 1
但是:
Broker 此时已经没有任何关于旧 Session 的上下文。
因此,它只能按照一条新的 QoS 消息重新处理。
这也是为什么:
Clean Session = true 时,断线重连后更容易看到重复投递。
需要强调的是:
这里出现重复,并不是 Broker "忘记了"消息,而是因为:
双方已经不再共享同一个 Session。
客户端认为:
"这是上一条消息。"
Broker 认为:
"这是新的会话里的第一条消息。"
双方的协议上下文已经发生了分叉。
4. 当 Clean Session = 0:继续上一次会话
如果:
go
opts.SetCleanSession(false)
那么客户端告诉 Broker:
请保留我的 Session,我还会回来。
此时,如果网络突然断开:
text
Device
X
Network Disconnect
Broker 不会立即删除 Session。
而是保留与该 ClientID 关联的协议状态。
等客户端重新连接,并且:
- ClientID 相同;
- Clean Session 仍然为 0;
Broker 就会尝试恢复之前的 Session。
此时恢复的,并不是 TCP 连接。
而是:
恢复 MQTT 协议状态。
包括:
- 尚未确认的 QoS 1 消息;
- QoS 2 尚未结束的状态机;
- 已保存的订阅关系;
- 离线期间缓存的 QoS 消息。
从协议角度来看,就像:
text
旧 TCP 连接
│
(网络断开)
│
────────┼────────
│
新 TCP 连接
│
继续使用同一个 MQTT Session
对于应用层来说,看起来像:
连接断了。
对于 MQTT Session 来说:
会话从未结束。
5. QoS 为什么能够"断点续传"?
上一章介绍 QoS 时,我们提到:
QoS 的可靠性交付依赖双方维护协议状态。
因此,当 Session 被保留下来以后:
对于 QoS 1:
Broker 可以重新发送那些此前尚未完成确认的消息。
对于重新发送的消息:
Broker 会按照协议要求将 DUP 位置为 1,提醒客户端:
"这是一条重发消息。"
客户端则根据 Packet Identifier 继续完成后续确认流程。
对于 QoS 2:
情况会更加复杂。
由于 QoS 2 本身就是一个状态机,因此:
Broker 与客户端会根据各自保存的状态,继续完成尚未结束的握手流程。
需要注意的是:
协议并没有规定重连后一定重新发送 PUBREC、PUBREL 或其他某一个固定报文。
真正发送哪一步,取决于:
双方此前已经执行到了哪个状态。
也就是说:
QoS 2 恢复的是:
整个协议状态机。
而不是:
简单重新发送某一种控制报文。
6. Session 恢复带来的两个常见坑
理解 Session 恢复以后,再来看很多线上问题,就会容易得多。
坑一:设备重连后突然收到大量历史消息
很多 IoT 场景都会遇到这样的现象:
设备断网几个小时。
重新联网以后:
短短几秒钟,就收到几千条消息。
很多人第一反应是:
Broker 出 Bug 了?
实际上,大多数情况下:
这是 Session 恢复导致的正常行为。
如果:
- Clean Session = 0;
- Broker 为该客户端缓存了 QoS 1 / QoS 2 消息;
那么客户端重新上线以后:
Broker 会继续投递这些离线期间尚未送达的消息。
对于资源受限的设备来说:
如果没有做好限流、背压或者消息处理能力控制,就可能出现:
- 网络缓冲区迅速堆积;
- CPU 被持续占满;
- 看门狗复位;
- 刚连上又再次掉线。
因此:
对于嵌入式设备而言,离线消息恢复能力虽然提高了可靠性,也意味着更高的资源压力。
坑二:ClientID 冲突
另一个经典问题就是:
两个客户端使用了相同的 ClientID。
例如:
text
Device A
ClientID = sensor001
Device B
ClientID = sensor001
对于 Broker 来说:
ClientID 才是 Session 的唯一标识。
因此:
当 Device B 建立连接时:
Broker 会认为:
"这个 ClientID 已经重新上线了。"
按照 MQTT 协议:
旧连接会被关闭。
新的连接接管对应 Session。
随后:
如果 Device A 又尝试重新连接:
Broker 又会关闭 Device B。
于是线上经常会看到:
text
A 上线
↓
B 上线(踢掉 A)
↓
A 自动重连(踢掉 B)
↓
B 自动重连(踢掉 A)
↓
......
表现出来就是:
两个设备不断互相踢下线。
很多排查了几天都定位不到原因的问题,最后往往只是:
两个客户端使用了同一个 ClientID。
小结
Clean Session 看似只是 CONNECT 报文中的一个比特位,却直接决定了 Broker 如何管理客户端的 Session。
它不会影响 TCP 是否可靠,也不会改变 QoS 的确认流程。
真正决定的是:
- 是否保留客户端 Session;
- 是否恢复之前的协议状态;
- 是否继续投递离线消息;
- 是否继续完成未结束的 QoS 状态机。
理解了这一点,再回头去分析设备断线重连、消息重复、离线消息恢复、ClientID 冲突等问题,就会发现:
这些现象并不是 MQTT 的"异常行为",而恰恰是协议设计者为了在可靠性、资源占用与连接恢复能力之间取得平衡而做出的设计选择。
五、MQTT 的演进与生态:从 3.1.1 到 5.0,再到现代 Broker
经过前面几章的介绍,我们已经从 TCP 字节流的角度,分析了 MQTT 报文结构、QoS 消息可靠性以及 Session 会话恢复机制。
如果说前面的内容回答的是 "MQTT 是如何工作的?",那么这一章,我们再回答两个工程实践中最常见的问题:
- 新项目应该选择 MQTT 3.1.1 还是 MQTT 5.0?
- EMQX、Mosquitto、HiveMQ 这些 Broker 到底是什么关系?
1. MQTT 版本是如何演进的?
MQTT 最早诞生于 1999 年,由 IBM 提出,最初用于石油管道等远程监控场景,希望在低带宽、高延迟、不稳定网络环境下,实现可靠的设备通信。
随后 MQTT 经历了多次版本演进,其中真正具有代表性的主要有两个版本:
text
1999
MQTT(IBM 提出)
│
▼
2014
MQTT 3.1.1(OASIS 标准化)
│
▼
2019
MQTT 5.0
其中,MQTT 3.1.1 是整个行业真正普及的起点。
目前,无论是各大云平台,还是主流 Broker,几乎都完整支持 MQTT 3.1.1,因此今天很多人说的"MQTT 协议",实际上默认指的就是 MQTT 3.1.1。
而 MQTT 5.0 并不是一次推倒重来的升级,它保留了 MQTT 一贯的轻量设计,只是在保持兼容性的基础上,补齐了大量企业级能力。
2. MQTT 5.0 相比 3.1.1 增强了什么?
很多文章喜欢罗列几十项新特性,但真正影响日常开发的其实并不多,下面几个是最值得关注的。
| 能力 | MQTT 3.1.1 | MQTT 5.0 |
|---|---|---|
| 更丰富的错误码(Reason Code) | ❌ | ✅ |
| 可扩展属性(Properties) | ❌ | ✅ |
| 会话过期时间(Session Expiry) | ❌ | ✅ |
| 请求/响应模式(Request/Response) | ❌ | ✅ |
| 流量控制(Maximum Packet Size、Receive Maximum 等) | ❌ | ✅ |
其中最重要的有三项。
第一,Properties(属性机制)。
MQTT 5.0 为 CONNECT、PUBLISH、SUBSCRIBE 等报文增加了统一的 Properties 字段,协议的扩展能力大幅增强。例如常见的 Topic Alias、Response Topic、User Property 等功能,都基于这一机制实现。
第二,Session Expiry(会话过期时间)。
在 MQTT 3.1.1 中,Clean Session 只有"保留"和"删除"两种选择。
而 MQTT 5.0 则允许指定会话保留时间,例如 30 秒、10 分钟、24 小时甚至永久保留,Session 生命周期变得更加灵活。
第三,更完善的错误反馈。
在 MQTT 3.1.1 中,大多数失败场景只能得到一个非常有限的返回码。
MQTT 5.0 引入了大量 Reason Code,当连接失败、订阅失败或发布失败时,客户端能够获得更加明确的错误原因,也让线上问题排查变得更加容易。
3. 现在应该选择 MQTT 3.1.1 还是 MQTT 5.0?
从协议能力来看,MQTT 5.0 几乎全面优于 MQTT 3.1.1。
但是在实际项目中,MQTT 3.1.1 仍然拥有极高的占有率。
原因也很简单。
大量嵌入式设备、MCU、RTOS 以及一些历史较长的 SDK,至今仍然只支持 MQTT 3.1.1。对于已经稳定运行多年的物联网项目而言,仅仅为了升级协议版本而改造整个设备生态,成本往往远高于收益。
因此,目前行业里的主流方案基本都是:
- 新项目:优先采用 MQTT 5.0。
- 存量项目:继续使用 MQTT 3.1.1,无需为了升级而升级。
事实上,目前绝大多数主流 Broker 都已经同时支持 MQTT 3.1.1 与 MQTT 5.0,客户端可以根据自身能力自由选择协议版本。
MQTT 5.0 没有推翻 MQTT 3.1.1,而是在保持兼容的基础上,为物联网平台补齐了很多工程能力。下面来看几个最典型的例子。
场景一:连接失败
3.1.1:不知道为什么失败。
5.0:直接告诉你 Not Authorized。
场景二:设备断线
3.1.1:Session 只有删或留。
5.0:可以保留 10 分钟。
场景三:请求响应
3.1.1:全靠业务自己约定 Topic。
5.0:协议支持 Response Topic。
场景四:长 Topic
3.1.1:每次都发完整 Topic。
5.0:Topic Alias,大幅减少带宽。
4. MQTT 与 Broker 到底是什么关系?
很多刚接触 MQTT 的开发者,经常会把 MQTT 与 EMQX、Mosquitto 混为一谈。
实际上,它们并不是同一个概念。
text
MQTT 协议
│
┌───────────────┼───────────────┐
│ │ │
Mosquitto EMQX HiveMQ
│ │ │
└───────────────┼───────────────┘
都是 MQTT Broker
MQTT 是一种通信协议。
它规定了:
- 报文格式;
- QoS 流程;
- Session 管理;
- Keep Alive;
- Topic 订阅等协议行为。
而 Broker 则是 MQTT 协议的具体实现。
它们之间的关系,就像:
text
HTTP 协议
│
┌──┼──────────────┐
│ │ │
Nginx Apache Caddy
协议定义"应该怎么做",Broker 负责"把协议实现出来"。
5. 几种常见 Broker
目前社区中最常见的 MQTT Broker 大致有以下几种。
Mosquitto
Mosquitto 是 Eclipse 基金会维护的开源 Broker,也是很多开发者接触 MQTT 时使用的第一个 Broker。
它最大的特点就是轻量、稳定、部署简单,非常适合学习、功能验证以及中小规模项目。
EMQX
EMQX 是国内应用最广泛的 MQTT Broker 之一,也是目前物联网领域最成熟的 Broker 之一。
除了完整支持 MQTT 5.0,它还提供了集群、高可用、规则引擎、Dashboard、认证鉴权、数据桥接等丰富的企业级能力,能够支撑百万级甚至千万级设备连接,因此被大量物联网平台采用。
HiveMQ
HiveMQ 在海外企业中拥有较高的市场占有率。
它采用 Java 开发,插件体系完善,商业支持成熟,在金融、制造业以及大型企业物联网平台中应用较多。
不同 Broker 在实现细节、性能优化和企业能力方面各有侧重,但它们遵循的都是同一套 MQTT 协议,因此客户端通常无需针对不同 Broker 修改业务代码。
小结
MQTT 从来都不是一个复杂的协议。
它只有十几种控制报文、少量固定字段,以及一个看似普通的 Packet Identifier,却构建出了一套能够支撑海量设备稳定通信的消息协议。
从 Fixed Header 到 Remaining Length,从 QoS 到 Session,再到 Broker 的协议实现,MQTT 始终坚持着一个设计理念:
用尽可能简单的协议,解决尽可能复杂的通信问题。
当真正理解了这些底层机制之后,再回头去分析消息重复、断线重连、QoS 降级、ClientID 冲突等线上问题,就会发现,它们并不是 Broker 的"随机故障",而是 MQTT 协议在可靠性、性能与实现复杂度之间所做出的工程权衡。
理解协议,比记住 API 更重要。
希望读完本文后,当你再次打开 Wireshark,看到那一串熟悉的 MQTT 字节流时,看到的不再只是十六进制数据,而是一套正在有条不紊运行的协议状态机。