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个字节。如果报文超过127字节,剩余长度就是2个甚至更多字节。我见过有人只读1个字节,结果大报文全解析错了。
- 报文标识符不能为0:除了CONNECT、CONNACK、DISCONNECT、PINGREQ、PINGRESP这5种报文,其他报文如果有标识符,取值范围是1-65535。0是无效值。
- UTF-8字符串长度:MQTT里的字符串(主题名、客户端ID等)前面都有2个字节的长度字段。这个长度是字节数,不是字符数。如果字符串包含中文,一个字符可能占3个字节。
- QoS 2的报文顺序:PUBLISH → PUBREC → PUBREL → PUBCOMP,这个顺序不能乱。我调试过一个设备,它发了PUBLISH后直接发PUBCOMP,结果服务器一直等PUBREC,两边都卡死了。
嗯,报文结构这块就讲这么多。说白了,MQTT的报文设计非常精巧,固定头保证最小开销,可变头提供灵活性,有效载荷承载业务数据。你只要把这三层搞明白,解析和构造MQTT报文就不是难事。下一节我们聊聊怎么用代码实现报文解析,到时候我会带大家手写一个简单的MQTT客户端。