3、MQTT报文结构:固定头、可变头、有效载荷详解,以及报文类型分类

好,咱们今天来聊聊MQTT报文的结构。说实话,我刚开始接触MQTT时,觉得这东西不就是个发布订阅协议嘛,报文能复杂到哪去?结果第一次抓包分析时,就被固定头、可变头、有效载荷这三层结构给整懵了。后来在做一个智能网关项目时,因为没搞懂可变头里的协议版本字段,导致设备死活连不上服务器——嗯,从那以后我再也不敢小看报文结构了。

3.1 报文的三层结构:像俄罗斯套娃

MQTT的报文结构,说白了就是三层套在一起:

  • 固定头(Fixed Header):所有报文都必须有,就像快递单上的收件人信息
  • 可变头(Variable Header):某些报文才有,像快递单上的备注栏
  • 有效载荷(Payload):真正要传的数据,像快递包裹里的东西

我个人习惯把这三层比作「信封-信纸-内容」。固定头是信封上的地址,可变头是信纸上的抬头,有效载荷才是你要说的话。你想想看,没有信封,信纸就寄不出去;没有信纸,内容就没地方写。

3.2 固定头:最核心的两个字节

固定头是所有MQTT报文的第一部分,至少2个字节。第一个字节包含报文类型和标志位,第二个字节是剩余长度。

Bit 7-4 3 2 1 0
内容 报文类型 DUP标志 QoS等级 QoS等级 RETAIN标志

报文类型(4位):取值范围0-15,但实际只用了1-14。0和15是保留的。我在项目中遇到过有人把报文类型填成0,结果服务器直接断开连接——嗯,协议规定得很清楚,别乱填。

DUP标志(1位):表示是不是重发的报文。如果客户端没收到PUBACK,就会重发并把这个位置1。我曾经调试一个断线重连的问题,发现设备疯狂重发报文,就是因为DUP标志没处理好,导致服务端一直认为是重复消息。

QoS等级(2位):这个太重要了。00表示最多一次(QoS 0),01表示至少一次(QoS 1),10表示正好一次(QoS 2)。11是保留的。说白了,QoS 0就是发完不管,QoS 1是保证对方收到但可能重复,QoS 2是保证不丢不重但最慢。

RETAIN标志(1位):只有PUBLISH报文用。置1的话,服务器会保留这条消息,新订阅者一上来就能收到。我在做传感器数据上报时,就喜欢把设备状态用RETAIN消息发,这样新设备上线马上就能知道当前状态。

剩余长度(Remaining Length):这个字段很有意思。它表示可变头+有效载荷的总字节数。但它的编码方式不是简单的二进制,而是用了变长编码——每个字节只用7位表示数据,最高位是延续标志。如果最高位是1,说明后面还有字节。

剩余长度编码示例

假设剩余长度是300(0x012C):

  • 第一个字节:300 % 128 = 44,0x2C,最高位置1 → 0xAC
  • 第二个字节:300 / 128 = 2,0x02,最高位置0 → 0x02

所以编码结果是:0xAC 0x02

你想想看,为什么MQTT要用这种编码?因为报文长度不确定啊。如果固定用2个字节,那最大只能表示65535字节;用4个字节又太浪费。这种变长编码最大能表示268435455字节(约256MB),而且短报文只占1个字节,多聪明。

3.3 可变头:不同报文有不同内容

可变头紧跟在固定头后面,内容取决于报文类型。我挑几个最常见的说说:

CONNECT报文的可变头:包含协议名("MQTT")、协议级别(4表示MQTT 3.1.1,5表示MQTT 5.0)、连接标志(用户名、密码、遗嘱等)、保活时间(Keep Alive)。

我记得有一次,客户说设备连不上云平台。我抓包一看,协议级别填的是3(MQTT 3.1),但平台只支持3.1.1。这就是典型的版本不兼容问题。所以我现在做网关时,都会在配置里显式指定协议版本。

SUBSCRIBE报文的可变头:只有一个字段——报文标识符(Packet Identifier)。这个标识符用来匹配SUBSCRIBE和SUBACK。取值范围1-65535,0是保留的。

PUBLISH报文的可变头:包含主题名(Topic Name),如果QoS > 0,还包含报文标识符。主题名是UTF-8编码的字符串,前面2个字节表示长度。

避坑指南:我曾经在解析PUBLISH报文时,忘了主题名是UTF-8编码,直接用ASCII解析。结果主题里带中文时,长度计算全错了。后来我学乖了,所有字符串解析都按UTF-8处理。

3.4 有效载荷:真正要传的数据

有效载荷是报文的最后一部分,也不是所有报文都有。比如:

  • CONNECT报文:包含客户端ID、遗嘱主题、遗嘱消息、用户名、密码
  • PUBLISH报文:就是应用消息本身,可以是任意二进制数据
  • SUBSCRIBE报文:包含主题过滤器列表,每个过滤器后面跟一个QoS字节
  • UNSUBSCRIBE报文:包含要取消订阅的主题过滤器列表

这里要注意,有效载荷的最大长度受剩余长度字段限制。虽然理论上能到256MB,但实际项目中我建议控制在1MB以内。为什么?因为MQTT是长连接协议,大报文会阻塞其他消息的传输。我在一个车联网项目里,有人把车辆轨迹数据(几十MB)直接塞进PUBLISH报文,结果整个网关的吞吐量直接掉到零。

3.5 报文类型分类:14种报文各司其职

MQTT 3.1.1定义了14种报文类型,我按功能分成三组:

连接相关(3种)

  • CONNECT (1):客户端请求连接服务器
  • CONNACK (2):服务器确认连接结果
  • DISCONNECT (14):客户端通知服务器要断开

发布订阅相关(6种)

  • PUBLISH (3):发布消息
  • PUBACK (4):QoS 1的发布确认
  • PUBREC (5):QoS 2的第一步,收到发布
  • PUBREL (6):QoS 2的第二步,释放发布
  • PUBCOMP (7):QoS 2的第三步,完成发布
  • SUBSCRIBE (8):订阅主题
  • SUBACK (9):订阅确认
  • UNSUBSCRIBE (10):取消订阅
  • UNSUBACK (11):取消订阅确认

心跳相关(2种)

  • PINGREQ (12):客户端发送心跳请求
  • PINGRESP (13):服务器响应心跳

注意:报文类型0和15是保留的,不要使用。我曾经见过有人把报文类型填成15,结果服务器直接断开连接,连错误提示都没有。

3.6 实战经验:报文解析的坑

最后分享几个我在项目中踩过的坑:

  1. 剩余长度解析要循环:不要以为剩余长度只占1个字节。如果报文超过127字节,剩余长度就是2个甚至更多字节。我见过有人只读1个字节,结果大报文全解析错了。
  2. 报文标识符不能为0:除了CONNECT、CONNACK、DISCONNECT、PINGREQ、PINGRESP这5种报文,其他报文如果有标识符,取值范围是1-65535。0是无效值。
  3. UTF-8字符串长度:MQTT里的字符串(主题名、客户端ID等)前面都有2个字节的长度字段。这个长度是字节数,不是字符数。如果字符串包含中文,一个字符可能占3个字节。
  4. QoS 2的报文顺序:PUBLISH → PUBREC → PUBREL → PUBCOMP,这个顺序不能乱。我调试过一个设备,它发了PUBLISH后直接发PUBCOMP,结果服务器一直等PUBREC,两边都卡死了。

嗯,报文结构这块就讲这么多。说白了,MQTT的报文设计非常精巧,固定头保证最小开销,可变头提供灵活性,有效载荷承载业务数据。你只要把这三层搞明白,解析和构造MQTT报文就不是难事。下一节我们聊聊怎么用代码实现报文解析,到时候我会带大家手写一个简单的MQTT客户端。