做物联网开发的同学,迟早会被 MQTT 控制报文折腾一遍。连接、发布、心跳这些平时用得最多,真到排查问题的时候,SUBSCRIBE、SUBACK、UNSUBSCRIBE、UNSUBACK 这四个订阅相关的报文反而最容易让人犯迷糊。明明只是“发一个订阅请求、等一个应答、再发一个退订请求”这么简单的事,为什么总有人在报文格式、QoS 协商、报文标识符上栽跟头?这篇就把这四类控制报文一次讲透:从固定报头到载荷逐字节拆开,从 QoS 协商到重发机制,再把实际项目里常见的坑都列出来。不管你是正在写 MQTT 客户端、自己做 Broker,还是只想把抓包数据看明白,这篇都能给你省下不少排查时间。
1. 先看全局:订阅与退订是怎么被设计成“请求-应答”的
1.1 四类报文在协议里的身份编号
MQTT 3.1.1 控制报文一共有 14 种,靠固定报头第一字节的高 4 位来区分类型。订阅相关的恰好是连续的 8、9、10、11 这四类:
| 报文类型 | 编号 | 方向 | 固定报头第一字节特征值 |
|---|---|---|---|
| SUBSCRIBE | 8 | 客户端 -> 服务端 | 0x82 |
| SUBACK | 9 | 服务端 -> 客户端 | 0x90 |
| UNSUBSCRIBE | 10 | 客户端 -> 服务端 | 0xA2 |
| UNSUBACK | 11 | 服务端 -> 客户端 | 0xB0 |
这几个字节值我建议你直接背下来。抓包的时候一眼扫过去,看到 0x82 开头就知道是订阅请求,看到 0xB0 开头就知道是退订应答,排查效率能提升不少。
这里有个很关键的设计:为什么 SUBSCRIBE 和 UNSUBSCRIBE 都要有对应的应答?核心原因是“订阅行为的建立和拆除必须被可靠确认”。订阅不是发一次就完事的事,客户端需要知道服务端到底有没有接受这个订阅。如果服务端只是默默收下不回应,客户端根本没法判断是网络丢了还是服务端没处理,所以协议强制要求应答。
1.2 固定报头标志位为什么不能乱动
固定报头第一字节的低 4 位是标志位,但不同报文有不同约束。对于 PUBLISH 报文,这 4 位分别代表 DUP、QoS、Retain,组合比较灵活;但对于 SUBSCRIBE、SUBACK、UNSUBSCRIBE、UNSUBACK,协议把标志位写死了,不允许实现者自行修改。
具体来说:
- SUBSCRIBE 的标志位固定是 0010,QoS 位恒为 1,DUP 位在首次发送时为 0。所以第一字节正常情况下是 0x82(1000 0010)。如果你构造报文的时候写成了 0x80,那在服务端眼里这就是一个“格式非法”的报文,大概率会被直接断开连接。
- UNSUBSCRIBE 的标志位同样固定是 0010,第一字节是 0xA2。
- SUBACK 和 UNSUBACK 是应答报文,标志位固定是 0011,第一字节分别对应 0x90 和 0xB0。
这块有一个非常实用的排查技巧:抓包时如果看到 0x8A 开头,0x8A 是 0x82 加上了 DUP 位,说明这个 SUBSCRIBE 是重发的。0x80 是 QoS0 的 PUBLISH,0x82 才是 SUBSCRIBE,这两个字节从表现形式上就完全不同,但很多新手在排查时会把 0x80 开头的报文误以为是订阅报文,结果绕了很大圈子。把固定报头第一字节的含义印在脑子里,能省掉很多无谓的排查时间。
1.3 报文标识符:订阅事务的“快递单号”
可变头里有一个非常重要的字段——报文标识符,两个字节,无符号整数。它只出现在需要确认的报文里:QoS 大于 0 的 PUBLISH、SUBSCRIBE、SUBACK、UNSUBSCRIBE、UNSUBACK。
报文标识符的作用可以理解为“快递单号”。你同时给服务端发了三个订阅请求,服务端回了三份 SUBACK,你怎么知道哪份应答对应哪个订阅?靠的就是这个标识符。协议规定,客户端发送 SUBSCRIBE 时,必须在当前会话中分配一个“尚未被使用且还没有收到应答”的标识符,从 1 开始递增。0x0000 是禁区,协议明确禁止使用。
这个设计带来的一个隐藏约定是:客户端内部必须维护一套标识符分配表。每发一个 SUBSCRIBE,占一个号;收到对应 SUBACK 后,这个号才能释放复用。如果分配表管理得不好,把同一个号同时发给两个未确认的请求,服务端返回的应答就会错乱。我在实际项目里见过一台设备,代码里报文标识符永远写死为 1,设备重连后旧的 SUBSCRIBE 还没收到应答,新的 SUBSCRIBE 又用同一个号发出去,服务端只能按协议把两条请求当作同一个事务处理,订阅结果自然对不上号。
2. 逐字节拆解 SUBSCRIBE 订阅报文
2.1 固定报头里的 0x82 到底在说什么
SUBSCRIBE 报文的结构分三段:固定报头、可变头、载荷。固定报头由两个部分组成:第一字节 0x82,后面是剩余长度。
0x82 的二进制是 1000 0010,高 4 位 1000 是报文类型 8,低 4 位 0010 是标志位。这里的 QoS 位(bit1)恒为 1,意思是 SUBSCRIBE 本身采用 QoS1 的语义——至少一次送达。这个“至少一次”不是指订阅消息的投递等级,而是指订阅请求这个控制报文本身需要可靠传输,服务端收到必须回 SUBACK,否则客户端会重发。
剩余长度是变长编码的。如果剩余长度小于 128,一个字节就够;如果超过 128,就需要用多字节编码,每个字节的高位是连续标志,低 7 位是有效数据。对于普通设备来说,一个订阅报文里载荷一般就几百字节,剩余长度通常一个字节就够,但如果你写代码时要支持一次订阅几百个主题的场景,就一定要正确处理多字节编码。我见过有人用固定一个字节存储剩余长度,结果订阅列表一长,报文直接截断,服务端解析出来语义完全错误。
2.2 可变头:两字节报文标识符的分配规则
SUBSCRIBE 的可变头非常简单,只有报文标识符这一个字段,两字节,网络字节序。前面说过,这个值不能为 0,而且在同一时刻不能重复。
实际编码时,很多人会忽略一个点:SUBSCRIBE 的报文标识符和 PUBLISH(QoS>0)的报文标识符是共用一套分配空间的。也就是说,你不能在发了一个 QoS1 的 PUBLISH、还没收到 PUBACK 的情况下,又把同一个标识符用在一个新的 SUBSCRIBE 上。协议要求的是“在会话中没有未确认的报文使用过这个标识符”,因为服务端是靠这个号来匹配所有类型报文的。最好的做法是客户端内部维护一个全局递增计数器,所有需要标识符的报文共用,收到任何 ACK 类报文后,再把对应的号回收。
2.3 载荷:一个报文可以装多个订阅请求
这是 SUBSCRIBE 和普通消息报文最不一样的地方:它的载荷里不是一条消息,而是“一个或多个主题过滤器 + 请求 QoS”的组合。
载荷格式如下:
- 主题过滤器:UTF-8 编码字符串,前面有两个字节的长度。比如订阅 sensor/temp,先写 0x00 0x0B,再写这个字符串的 11 个字节。
- 请求 QoS:紧跟一个字节,这个字节只用到低 2 位,高 6 位必须为 0。也就是说,请求 QoS2 就写 0x02,请求 QoS1 就写 0x01,不能写 0x12 这类带额外位的值。
这里有一个很关键的语义:一个 SUBSCRIBE 报文里的多个订阅是一起生效的,所以 SUBACK 应答也必须按同样的顺序一次性返回。客户端不能假设“发了两个过滤器,服务端可能只处理一个”,协议规定,要么全部处理(每个各自返回成功或失败),要么协议异常。
2.4 一个完整报文的十六进制对照
我们构造一个实际的 SUBSCRIBE 报文:报文标识符 1,订阅 sensor/temp,请求 QoS1。
0x82 0x10 0x00 0x01 0x00 0x0B 0x73 0x65 0x6E 0x73 0x6F 0x72 0x2F 0x74 0x65 0x6D 0x70 0x01逐段拆开看:
| 字节 | 含义 |
|---|---|
| 0x82 | 固定报头,SUBSCRIBE + QoS1 标志 |
| 0x10 | 剩余长度,16 字节 |
| 0x00 0x01 | 报文标识符,十进制 1 |
| 0x00 0x0B | 主题过滤器长度,11 字节 |
| sensor/temp 的 ASCII | 主题过滤器内容 |
| 0x01 | 请求 QoS1 |
如果同一个报文里再订阅第二个主题 a/b,请求 QoS0,报文就变成:
0x82 0x16 0x00 0x01 0x00 0x0B 0x73 0x65 0x6E 0x73 0x6F 0x72 0x2F 0x74 0x65 0x6D 0x70 0x01 0x00 0x03 0x61 0x2F 0x62 0x00剩余长度从 0x10 变成了 0x16,因为多了 6 个字节(2 字节长度前缀 + 3 字节字符串 + 1 字节 QoS)。这两个实例你可以在抓包里比照验证,只要按照格式逐段解,所有字节都能对上号。
3. SUBACK 应答:QoS 是怎么被“协商”出来的
3.1 返回码列表与订阅请求的一一对应
SUBACK 的结构同样是三段。固定报头第一字节是 0x90,剩余长度后面跟着可变头(两字节报文标识符)和载荷(返回码列表)。
SUBACK 的载荷是若干个 1 字节返回码,每种取值含义如下:
| 返回码 | 含义 |
|---|---|
| 0x00 | 订阅成功,最大 QoS0 |
| 0x01 | 订阅成功,最大 QoS1 |
| 0x02 | 订阅成功,最大 QoS2 |
| 0x80 | 订阅失败 |
需要注意的是,返回码列表中没有主题名,它只会按下标顺序与 SUBSCRIBE 载荷里的过滤器一一对应。也就是说,如果客户端一个报文订阅了三个主题,服务端返回三个字节,第一个字节对应第一个过滤器,第二个字节对应第二个,第三个对应第三个。客户端解析时必须保证顺序不乱。
这里有一个实际开发中特别容易踩的坑:部分客户端代码在解析 SUBACK 时,只取第一个返回码,没有把列表遍历完。如果订阅了多个主题,后面主题实际是成功还是失败,根本不知道。问题在于协议报文里没有主题名可以对照,你只能靠下标去映射,所以解析逻辑务必按顺序遍历。我建议在客户端代码里直接维护一个“请求顺序表”,收到 SUBACK 后按顺序逐项更新订阅状态。
3.2 服务端把 QoS 打折了,怎么办
客户端请求 QoS2,服务端返回 0x01,这种情况在真实环境里太常见了。为什么?因为 QoS 最终生效值不是客户端单方面能决定的,而是“取客户端请求值、服务端最大支持值、服务端对特定主题的策略三者之间的最低值”。
协议要求服务端返回的值不能大于客户端请求的值。比如客户端请求 QoS2,服务端返回 0x02,没问题;如果客户端请求 QoS1,服务端返回 0x02,那这个应答就是非法的,客户端应当视为协议错误并断开连接。反过来,客户端请求 QoS2,服务端返回 0x01 或 0x00,这属于正常的降级。
很多人在排查“为什么订阅的是 QoS2,实际消息却只有 QoS1”时,第一反应是去查发布端的配置,其实先看一眼 SUBACK 返回码就什么都清楚了。SUBACK 里返回的才是最终生效的 QoS,客户端后续收到的消息投递等级只可能低于或等于这个值。这里也顺带提醒一句:消息的最终投递 QoS 是“发布 QoS”和“订阅生效 QoS”两者取较低的那个,不是只看订阅侧。
3.3 通配符和共享订阅在 SUBACK 里的表现
SUBACK 里不会告诉你“你订阅的是哪个过滤器”,它只返回成功或失败。这意味着,如果客户端一次性订阅了 sensor/# 和 device/+/status,两个都成功,SUBACK 看起来就是连续的 0x00 0x00,没有额外信息。
这里有一个容易被忽视的点:同时订阅了宽泛过滤器(如 sensor/#)和具体主题(如 sensor/temp)时,服务端对同一份消息只会投递一次,按最早匹配到的订阅规则去查找,而不是每个匹配的订阅都发一份。所以不要因为两条订阅都成功就觉得会收到两条重复消息,MQTT 的消息路由规则就是不重复投递同一条消息给同一个客户端。
共享订阅是另一个话题。MQTT 3.1.1 标准里没有正式的共享订阅,但不少 Broker 在 3.1.1 的基础上通过特殊前缀(如$share/组名/主题过滤)实现了共享订阅。这种过滤器的 SUBACK 返回码和普通订阅没有任何区别,都是 0x00/0x01/0x02。真正的影响在消息投递阶段:同一共享组里的多个客户端会轮流消费,而不是每个客户端都收到一份。如果你用了共享订阅却发现某个客户端收不到消息,先确认它是不是和别的设备在同一个共享组内,别把锅甩给 SUBACK。
4. 取消订阅:UNSUBSCRIBE 与 UNSUBACK 的对称逻辑
4.1 结构很像 SUBSCRIBE,但语义完全不同
UNSUBSCRIBE 的报文结构和 SUBSCRIBE 几乎一模一样,固定报头、可变头、载荷三段式。固定报头第一字节是 0xA2,同样要求 QoS 位为 1。可变头也是两字节报文标识符。载荷部分则是一个或多个 UTF-8 主题过滤器。
最大的区别在载荷:UNSUBSCRIBE 的载荷里只有过滤器,没有 QoS 字节。因为取消订阅根本不关心原来的订阅是 QoS0、QoS1 还是 QoS2,只要把这条订阅关系删掉就行,没必要带服务等级。
还有一点容易忽略:UNSUBSCRIBE 只能取消普通订阅和共享订阅,但它不会影响遗嘱消息、不会影响保留消息的缓存,也不会取消客户端在 Broker 上的其他会话数据。它只做一件事——删除“主题过滤器 -> 客户端”这条路由关系。
4.2 UNSUBACK 应答里有状态吗
UNSUBACK 的结构更简单:固定报头 0xB0、剩余长度、两字节报文标识符,载荷为空。是的,在 MQTT 3.1.1 里,UNSUBACK 不携带任何原因码或状态信息。客户端收到 UNSUBACK,只能说明服务端“已经处理”了这个退订请求,但处理结果是成功还是失败,协议里没有字段表达。
这一点和很多人的直觉不一样。有人以为 UNSUBACK 能告诉我们“你退订的主题原本不存在”之类的信息,实际上 v3.1.1 里完全没有这个语义。协议设计者的思路是:退订是幂等操作,服务端尽力执行,如果主题从来没有被订阅过,也不构成错误,正常返回 UNSUBACK 就行。真正需要精细化反馈的需求,一直到 MQTT 5.0 才通过原因码解决了。
所以在开发客户端时不要依赖 UNSUBACK 来判断退订是否生效。正确做法是:发出 UNSUBSCRIBE 之后,本地就立刻把订阅状态标记为“已退订”,把对应的主题过滤器从自己的订阅表里删除。等收到 UNSUBACK 后再清理报文标识符的占用即可。如果你把“从客户端订阅表里删掉”这个动作放在收到 UNSUBACK 之后,万一应答在网络里丢了,客户端本地就永远留着一条已经不存在的订阅记录。
4.3 取消订阅的前后状态管理
实际工程里,退订不是“发一个报文就完事”那么简单的。你要考虑几个问题:
- 如果 UNSUBSCRIBE 发出后网络断了,客户端重连后要不要重发?要,因为服务端可能根本没收到。重发时保持相同的报文标识符,并把 DUP 位置 1。
- 如果消息还在传输中,退订会不会把这条消息“截胡”掉?不会。已经进入下发流程的消息不受退订影响,可能你退订后还是能收到一条在途消息,这不算异常。
- 如果客户端重启,cleanSession 为 true,那么服务端在会话结束后会清除所有订阅,客户端重连后需要重新订阅,即使之前发过 UNSUBSCRIBE 也不需要再补发。
这些细节放在一起来看,退订的状态管理其实就是一个“本地订阅表 + 会话生命周期”的问题,不要把它简化成一锤子买卖。
5. 报文标识符、DUP 重发和会话保持
5.1 报文标识符的完整生命周期
前面的章节已经提到报文标识符不能为 0、不能重复,那它完整的生命周期是怎样的?以 SUBSCRIBE 为例:
- 客户端申请一个新的报文标识符,把 SUBSCRIBE 报文发出,同时在本地等待表中登记这个号。
- 服务端返回 SUBACK,报文里带上同一个标识符。
- 客户端把 SUBACK 的标识符与等待表中的记录匹配,找到对应的订阅请求,解析返回码,更新订阅状态。
- 从等待表中删除这个标识符,它才允许被后续报文复用。
如果某个 SUBSCRIBE 发出后长时间收不到 SUBACK,客户端可以选择重发,重发时不能重新分配一个新号,必须沿用原来的号。这样才能让服务端感知到“这是那个事务的重试”而不是“一个新的事务”。
实际编码时,等待表要处理好并发场景。MQTT 客户端是允许多个 SUBSCRIBE 同时在途的,如果每次收到底层数据包就把等待表遍历一遍,每一条都判断一下标识符是否匹配,代码逻辑一定要严谨。我见过一个案例,客户端收到 SUBACK 后直接清空了整张等待表,导致其他还在途的订阅请求全部丢失匹配关系,服务端那边订阅倒是建立了,客户端这边却认为全部失败了,重试又发一遍,Broker 上全是重复订阅。
5.2 DUP 位在订阅报文里怎么用
DUP 位平时大家关注得不多,因为常规抓包里看到的都是 0x82、0xA2 这种 DUP 为 0 的报文。但重发时,比如网络抖动导致 SUBACK 丢失,客户端重发 SUBSCRIBE,固定报头第一字节就要从 0x82 变成 0x8A。UNSUBSCRIBE 重发时从 0xA2 变成 0xAA。
这里有一个值得注意的点:SUBSCRIBE 本身的操作是幂等的。服务端对于“重复收到同一个过滤器、同一个 QoS、同一个报文标识符”的订阅请求,其实不需要额外处理,直接当作正常请求再回一个 SUBACK 就行。但作为客户端,重发时把 DUP 位置 1 是协议明确要求的,不能省略。原因在于服务端可能依赖 DUP 位来判断报文是否属于“重传”,在 QoS1 的 PUBLISH 场景里,DUP 位的置位还有助于避免重复投递,虽然订阅场景里不太影响,但代码规范该写还是得写。
另外,不要把 SUBSCRIBE 的 DUP 位和 QoS1 消息的 DUP 位混为一谈。SUBSCRIBE 报文的 QoS 位恒为 1,这是固定报头本身就有的,和 DUP 重发没有关系,抓包时看到 0x8A 才说明这是一次重传。
5.3 cleanSession 对订阅状态的影响
MQTT 连接参数里有一个 cleanSession。很多人在排查订阅丢失问题时,第一个想到的就是检查这个参数有没有被误配。
- cleanSession 为 true(MQTT 5.0 里叫 Clean Start):每次连接都是全新会话,服务端不会保留之前的订阅关系,断开后订阅全部清空,重连必须重新 SUBSCRIBE。
- cleanSession 为 false:服务端会持久化会话状态,包括订阅关系、离线消息、未确认的 QoS1/QoS2 消息等。重连后不用重新订阅,Broker 会自动知道这个客户端还保留着原先的过滤器。
这里有一个很常见的误解:有人以为只要 cleanSession 为 false,客户端代码里就不用再调用 SUBSCRIBE 了。这个想法是错的。协议允许服务端保存订阅,但客户端通常不能假设服务端一定重启过、持久化数据没丢。更稳妥的做法是:每次建立连接后,主动重发一遍自己的订阅列表。因为订阅本身是幂等操作,重复订阅并不会造成消息重复投递,最多是让服务端的订阅记录刷新一遍。如此重连后的行为就是可控的,不依赖 Broker 是否缓存了旧会话。
6. 实战:手写 SUBSCRIBE 报文与常见问题速查
6.1 用 Python 手动构造一个 SUBSCRIBE 报文
理解协议最好的方式就是自己动手组一个包。下面是一个轻量级 Python 实现,构造上面那个单主题订阅报文。
import struct def encode_remaining_length(n: int) -> bytes: out = bytearray() while True: digit = n % 128 n //= 128 if n > 0: digit |= 0x80 out.append(digit) if n == 0: return bytes(out) def encode_utf8(s: str) -> bytes: raw = s.encode('utf-8') return struct.pack('!H', len(raw)) + raw def build_subscribe(packet_id: int, subscriptions): # subscriptions: [(过滤器, QoS), ...] variable_header = struct.pack('!H', packet_id) payload = b'' for topic, qos in subscriptions: payload += encode_utf8(topic) payload += bytes([qos & 0x03]) remaining_length = len(variable_header) + len(payload) fixed_header = bytes([0x82]) + encode_remaining_length(remaining_length) return fixed_header + variable_header + payload packet = build_subscribe(1, [("sensor/temp", 1)]) print(packet.hex(' '))输出结果:
82 10 00 01 00 0b 73 65 6e 73 6f 72 2f 74 65 6d 70 01和前面手动分析的十六进制完全一致。
再写一个解析 SUBACK 的小函数:
def parse_suback(data: bytes): assert data[0] >> 4 == 9, "不是 SUBACK 报文" remaining_length = data[1] body = data[2:2 + remaining_length] packet_id = struct.unpack('!H', body[:2])[0] return_codes = list(body[2:]) return packet_id, return_codes suback = bytes.fromhex("90 03 00 01 01") pid, codes = parse_suback(suback) print("packet_id:", pid, "return_codes:", codes)这段代码能直接跑,你可以在本机用任意 TCP 调试工具连接一个 Broker 试着发一发,看看服务端回什么。注意这里是演示编码逻辑,实际项目里建议直接用成熟的 MQTT 客户端库,别自己手搓协议栈。
6.2 常见问题速查表
把实际项目里和订阅报文有关的典型问题整理成一张表,排查的时候对照着看能省不少时间。
| 现象 | 常见原因 | 处理建议 |
|---|---|---|
| 发出 SUBSCRIBE 后一直收不到 SUBACK | 报文标识符是 0 或已被占用;剩余长度编码错误;网络丢包 | 抓包确认 0x82 和剩余长度;确认报文标识符分配合规;超时后用原报文标识符重发 |
| SUBACK 返回 0x80 | 主题过滤器非法、权限不足、Broker 限制 | 检查过滤器格式(空层级、非法通配符);检查账号权限 |
| 请求 QoS2 实际只拿到 QoS1 | 服务端最大支持 QoS 或主题策略限制 | 以 SUBACK 返回值为准,不要管客户端请求值 |
| 取消订阅后仍收到消息 | 还有别的订阅匹配;在途消息;本地订阅表未及时清理 | 检查是否订阅了更宽的通配符;确认 UNSUBACK 已收到;本地立即删除订阅状态 |
| 重连后订阅丢失 | cleanSession 被置为 true;Broker 持久化数据丢失 | 重新发送订阅列表;业务要求持久的场景改为 cleanSession=false 并主动重订阅 |
| 收到重复消息 | 客户端重发订阅、应用层重复消费 | 订阅是幂等的,重复订阅不会导致重复投递,重点检查消费逻辑和消息确认机制 |
6.3 抓包调试时该看哪几个关键字段
最后说一下抓包看订阅报文时的习惯。无论你用 Wireshark 的 MQTT 解析器还是直接用十六进制看,建议按下面几个顺序核对:
第一,看固定报头第一字节。0x82 是首次订阅,0x8A 是重发订阅;0x90 是订阅应答;0xA2 是首次退订,0xAA 是重发退订;0xB0 是退订应答。这一眼扫过就能确认报文方向对不对。
第二,看剩余长度。这个值和实际报文体长度对不上,说明组包有问题,常见于手写协议栈的设备。
第三,看报文标识符。同一个标识符不能同时出现在两个未确认的订阅请求里,客户端排查时可以在发送日志里把标识符打出来。
第四,看 SUBACK 的返回码列表数量和顺序,和 SUBSCRIBE 载荷里的过滤器数量必须一致。如果服务端返回的返回码比请求的过滤器少,就是严重的协议错误,直接断开连接并抓包回放。
把这个检查流程固化成习惯,90% 的订阅报文问题三分钟内能定位。另外说一个个人经验,我在排查线上设备“订阅不上”的问题时,经常发现不是协议本身的错,而是设备端代码把报文标识符写死成了 0,Broker 按 MQTT 规范直接断连,设备表现为反复掉线重连。这种问题如果只盯着网络状态看,永远找不到根因,把抓包打开看一眼字节,真相立刻出来。建议所有写 MQTT 客户端的同学,花半个小时把订阅、应答、退订这几类报文的字节结构亲手过一遍,后续调试的底气会完全不一样。