bigywater.tech
网络协议 · 104

MQTT:固定头、QoS 与保持连接

MQTT 是物联网里最常用的发布 / 订阅协议:固定头只有两个字节,报文只有十几种,却有三档送达保证、保活、遗嘱和保留消息。这篇自己写了一个迷你的 MQTT 代理,手工发每个字节,把这些机制逐个做出来看。

26 字节
一条 QoS 0、主题 sensors/room1/temp、载荷 21.5 的 PUBLISH 的总长度
3.11 秒
保活 2 秒的客户端不发任何包时,代理断开它并发布遗嘱所用的时间
2 次
QoS 1 故意不回 PUBACK 后,代理收到同一个包标识的次数

报文结构

实测 · 本机执行输出
  CONNECT(25 字节):10 17 00 04 4d 51 54 54 04 02 00 3c 00 0b 77 31 30 34 2d 63 6c 69 65 6e 74
    = 10(类型 1 = CONNECT,标志 0)| 17(剩余长度 23)| 00 04 "MQTT"(协议名)| 04(协议级别 4 = MQTT 3.1.1)| 02(连接标志:Clean Session)| 00 3c(保活 60 秒)| 00 0c "w104-client"(客户端标识)
  CONNACK(4 字节):20 02 00 00 = 类型 2(CONNACK)| 剩余长度 2 | 00(会话存在标志 0)| 00(返回码 0 = 接受)
  SUBSCRIBE(21 字节):82 13 00 01 00 0e 73 65 6e 73 6f 72 73 2f 2b 2f 74 65 6d 70 01:82 = 类型 8,标志必须是 0010;包标识 00 01;主题过滤器 "sensors/+/temp";请求的 QoS 01。SUBACK:90 03 00 01 01(类型 9,包标识 1,授予的 QoS 1)

固定头 1 字节类型加标志 + 变长的剩余长度;CONNECT 25 字节,CONNACK 4 字节。

PUBLISH 的开销

实测 · 本机执行输出
  发布者发出(QoS 0,26 字节):30 18 00 12 73 65 6e 73 6f 72 73 2f 72 6f 6f 6d 31 2f 74 65 6d 70 32 31 2e 35:30 = PUBLISH,QoS 0 | 18 剩余长度 24 | 00 12 + 18 字节主题 "sensors/room1/temp" | 4 字节载荷 "21.5"
  订阅者收到(26 字节):类型 PUBLISH,主题 'sensors/room1/temp',载荷 b'21.5';单条消息的总开销 = 固定头 2 + 主题长度字段 2 = 4 字节,再加主题名本身 18 字节(MQTT 没有“头部压缩”,主题名每次都要带)
    载荷   100 字节:剩余长度    103 → 编码成 1 字节(67);整个包 105 字节
    载荷   127 字节:剩余长度    130 → 编码成 2 字节(82 01);整个包 133 字节
    载荷   128 字节:剩余长度    131 → 编码成 2 字节(83 01);整个包 134 字节
    载荷 16383 字节:剩余长度  16386 → 编码成 3 字节(82 80 01);整个包 16390 字节
    载荷 16384 字节:剩余长度  16387 → 编码成 3 字节(83 80 01);整个包 16391 字节

固定开销 4 字节,但每次都带完整主题名(无压缩);剩余长度用变长编码。

QoS 1

实测 · 本机执行输出
  第一次 PUBLISH:32 13 00 08 71 31 2f 74 6f 70 69 63 00 07 70 61 79 6c 6f 61 64(32 = QoS 1,DUP 0);代理故意不回;客户端1 秒内没有收到 PUBACK
  重发 PUBLISH:3a 13 00 08 71 31 2f 74 6f 70 69 63 00 07 70 61 79 6c 6f 61 64(3A = QoS 1 + DUP 1,包标识仍是 7);代理回 PUBACK 40 02 00 07(类型 4,包标识 7)
  代理看到的 QoS1 PUBLISH 事件:[('PUBLISH QoS1', 7, False), ('故意不回 PUBACK', 7), ('PUBLISH QoS1', 7, True)]——同一个包标识被收到了两次,所以 QoS 1 是“至少一次”,接收方可能看到重复

至少一次:PUBACK 丢了就用 DUP 重发,接收方可能看到重复。

QoS 2

实测 · 本机执行输出
    C→S  PUBLISH QoS2 pid=9
    S→C  PUBREC pid=9
    C→S  PUBREL pid=9
    S→C  PUBCOMP pid=9
  订阅者此时还没收到消息(代理在等 PUBREL);PUBREL 之后订阅者收到:主题 'q2/topic',载荷 b'exactly once'。QoS 2 用 4 个包(2 个往返)换来“恰好一次”,开销是 QoS 0 的 4 倍

恰好一次:四个包,PUBREL 之后才投递一次。

保活与遗嘱

实测 · 本机执行输出
  不发任何包、保活 2 秒 → 订阅者在 3.1 秒后收到遗嘱:主题 'clients/status',载荷 b'w104-dead went offline';代理记录:[('超时断开', 3.11), ('遗嘱触发', 'clients/status')]
  正常 DISCONNECT(e0 00,2 字节)的客户端:订阅者没有收到遗嘱——遗嘱只在“非正常断开”时发布
  PINGREQ c0 00 → PINGRESP d0 00(各 2 字节,0.1 ms):客户端在空闲时每隔保活时间发一次它来证明自己还活着

超过 1.5 倍保活时间没有包,代理断开并发布遗嘱;正常 DISCONNECT 不发布。

保留消息

实测 · 本机执行输出
  先发布 retain 消息 home/door = closed(31 = PUBLISH, RETAIN 1)。之后才来订阅的客户端,SUBACK 之后立刻收到:类型 PUBLISH,保留位 1,载荷 b'closed'(订阅完 0.1 ms 内);用途:设备的“最新状态”

代理存下最后一条,晚到的订阅者立刻收到。

分步演示

MQTT
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。代理是我写的最小实现(只支持 CONNECT、SUBSCRIBE、PUBLISH QoS 0/1/2、PING、DISCONNECT、遗嘱、保留消息,不支持会话持久化、认证、TLS),全在本机;QoS 1 的“不回 PUBACK”是我故意设置的;没有使用任何公共 MQTT 代理
    • 没有验证:会话持久化(Clean Session = 0)与离线消息;MQTT 5.0 的新特性(原因码、共享订阅、主题别名);认证与 TLS(8883);QoS 订阅降级的全部规则;真实代理(Mosquitto、EMQX)的行为差异;大量订阅者时的扇出性能
    京ICP备2026067502号-1京公网安备11010502063487号