资讯动态

MQTT订阅与退订报文详解:逐字节拆解与QoS协商

发布时间:2026/10/15 16:19:27 来源:尧图企业网站定制
做物联网开发的同学迟早会被 MQTT 控制报文折腾一遍。连接、发布、心跳这些平时用得最多真到排查问题的时候SUBSCRIBE、SUBACK、UNSUBSCRIBE、UNSUBACK 这四个订阅相关的报文反而最容易让人犯迷糊。明明只是“发一个订阅请求、等一个应答、再发一个退订请求”这么简单的事为什么总有人在报文格式、QoS 协商、报文标识符上栽跟头这篇就把这四类控制报文一次讲透从固定报头到载荷逐字节拆开从 QoS 协商到重发机制再把实际项目里常见的坑都列出来。不管你是正在写 MQTT 客户端、自己做 Broker还是只想把抓包数据看明白这篇都能给你省下不少排查时间。1. 先看全局订阅与退订是怎么被设计成“请求-应答”的1.1 四类报文在协议里的身份编号MQTT 3.1.1 控制报文一共有 14 种靠固定报头第一字节的高 4 位来区分类型。订阅相关的恰好是连续的 8、9、10、11 这四类报文类型编号方向固定报头第一字节特征值SUBSCRIBE8客户端 - 服务端0x82SUBACK9服务端 - 客户端0x90UNSUBSCRIBE10客户端 - 服务端0xA2UNSUBACK11服务端 - 客户端0xB0这几个字节值我建议你直接背下来。抓包的时候一眼扫过去看到 0x82 开头就知道是订阅请求看到 0xB0 开头就知道是退订应答排查效率能提升不少。这里有个很关键的设计为什么 SUBSCRIBE 和 UNSUBSCRIBE 都要有对应的应答核心原因是“订阅行为的建立和拆除必须被可靠确认”。订阅不是发一次就完事的事客户端需要知道服务端到底有没有接受这个订阅。如果服务端只是默默收下不回应客户端根本没法判断是网络丢了还是服务端没处理所以协议强制要求应答。1.2 固定报头标志位为什么不能乱动固定报头第一字节的低 4 位是标志位但不同报文有不同约束。对于 PUBLISH 报文这 4 位分别代表 DUP、QoS、Retain组合比较灵活但对于 SUBSCRIBE、SUBACK、UNSUBSCRIBE、UNSUBACK协议把标志位写死了不允许实现者自行修改。具体来说SUBSCRIBE 的标志位固定是 0010QoS 位恒为 1DUP 位在首次发送时为 0。所以第一字节正常情况下是 0x821000 0010。如果你构造报文的时候写成了 0x80那在服务端眼里这就是一个“格式非法”的报文大概率会被直接断开连接。UNSUBSCRIBE 的标志位同样固定是 0010第一字节是 0xA2。SUBACK 和 UNSUBACK 是应答报文标志位固定是 0011第一字节分别对应 0x90 和 0xB0。这块有一个非常实用的排查技巧抓包时如果看到 0x8A 开头0x8A 是 0x82 加上了 DUP 位说明这个 SUBSCRIBE 是重发的。0x80 是 QoS0 的 PUBLISH0x82 才是 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 的报文标识符和 PUBLISHQoS0的报文标识符是共用一套分配空间的。也就是说你不能在发了一个 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报文标识符十进制 10x00 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订阅成功最大 QoS00x01订阅成功最大 QoS10x02订阅成功最大 QoS20x80订阅失败需要注意的是返回码列表中没有主题名它只会按下标顺序与 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 为 trueMQTT 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 被置为 trueBroker 持久化数据丢失重新发送订阅列表业务要求持久的场景改为 cleanSessionfalse 并主动重订阅收到重复消息客户端重发订阅、应用层重复消费订阅是幂等的重复订阅不会导致重复投递重点检查消费逻辑和消息确认机制6.3 抓包调试时该看哪几个关键字段最后说一下抓包看订阅报文时的习惯。无论你用 Wireshark 的 MQTT 解析器还是直接用十六进制看建议按下面几个顺序核对第一看固定报头第一字节。0x82 是首次订阅0x8A 是重发订阅0x90 是订阅应答0xA2 是首次退订0xAA 是重发退订0xB0 是退订应答。这一眼扫过就能确认报文方向对不对。第二看剩余长度。这个值和实际报文体长度对不上说明组包有问题常见于手写协议栈的设备。第三看报文标识符。同一个标识符不能同时出现在两个未确认的订阅请求里客户端排查时可以在发送日志里把标识符打出来。第四看 SUBACK 的返回码列表数量和顺序和 SUBSCRIBE 载荷里的过滤器数量必须一致。如果服务端返回的返回码比请求的过滤器少就是严重的协议错误直接断开连接并抓包回放。把这个检查流程固化成习惯90% 的订阅报文问题三分钟内能定位。另外说一个个人经验我在排查线上设备“订阅不上”的问题时经常发现不是协议本身的错而是设备端代码把报文标识符写死成了 0Broker 按 MQTT 规范直接断连设备表现为反复掉线重连。这种问题如果只盯着网络状态看永远找不到根因把抓包打开看一眼字节真相立刻出来。建议所有写 MQTT 客户端的同学花半个小时把订阅、应答、退订这几类报文的字节结构亲手过一遍后续调试的底气会完全不一样。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑