深入 MQTT 协议底层:构建可靠物联网通信的关键机制

本文面向中高级后端 / 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 新增) ✅ 是 ❌ 否
  1. 关于 0x000xF0

    • MQTT 3.1.1 中,只有 1~14 是有效的。0xF0 (AUTH) 是 5.0 才有的。
    • 收到 Type 为 0 或 15(在 3.1.1 环境下)属于协议错误,必须断开连接
  2. 关于 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 判断出这是一条 PUBLISHSUBSCRIBE 还是 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 字节流时,看到的不再只是十六进制数据,而是一套正在有条不紊运行的协议状态机。

相关推荐
K成长日志1 天前
BLE链路层空口包--LE Uncoded PHY
物联网·嵌入式·蓝牙·iot·ble·无线·通信
TDengine (老段)4 天前
TDengine 函数完整参考 — 聚合、时序、字符串、时间、数学
大数据·数据库·物联网·时序数据库·iot·tdengine·涛思数据
donoot4 天前
CentOS9操作系统安装EMQX5.8.9最后一个开源版
mqtt·开源·centos9·emqx5.8.9
慧都小妮子5 天前
ThingsBoard PE 接入物联网关 MQTT 数据配置流程:从规则链到遥测入库
物联网·mqtt·数据采集·iot·规则引擎·工业通信·thingsboard
大鱼>6 天前
多宠物家庭智能管理平台:云端架构与多设备协同实战
python·算法·iot·宠物
大鱼>6 天前
宠物异常行为预警系统:边缘计算与实时检测
人工智能·深度学习·算法·iot·宠物
大鱼>6 天前
宠物活动轨迹追踪系统:GPS/BDS+UWB+BLE多定位融合方案
人工智能·深度学习·算法·iot·宠物
大鱼>6 天前
智能喂食器远程控制系统:硬件设计与软件实现
人工智能·iot·宠物
大鱼>6 天前
宠物监控数据安全与隐私保护:端到端加密与合规实践
人工智能·深度学习·算法·iot·宠物