Administrator
发布于 2024-09-16 / 4 阅读
0
0

MQTT 3.1.1 协议精讲:发布订阅模型、QoS 与 Java 落地

最近项目里要接一批物联网设备,领导提出学习MQTT协议,用于未来统一管理设备接入。之前我做的都是 HJ 212、SL 651 这类「一问一答」的规约——字节帧、功能码、CRC、串口链路,思路是「中心站主动召测,设备被动应答」。第一次认真看 MQTT 的时候有点不适应:没有地址、没有功能码、没有轮询,设备只管往一个主题上「喊」,谁关心谁来听。这篇就是我这段时间边学边整理的笔记,从发布订阅模型一路记到 QoS、会话和 Java 落地,尽量把「协议为什么这么设计」讲清楚。

文中涉及的标准与文档我列在最后一节,方便回头复习。

一、先搞清楚 MQTT 解决什么问题

MQTT 全称 Message Queuing Telemetry Transport,最早是 1999 年 IBM 的 Andy Stanford-Clark 和 Arcom(后来被 Cirrus Logic 收购)的 Arlen Nipper 为石油管道遥测场景设计的:当时设备通过卫星链路回传数据,带宽贵、延迟高、链路还不稳,需要一个「包头极小、能在弱网下可靠工作」的协议。这段出身决定了它的所有设计取向。

它现在是 OASIS 的标准:

版本

发布时间

标准化组织

说明

MQTT 3.1

2010

IBM 等(非正式)

早期事实标准,很多老设备仍只支持它

MQTT 3.1.1

2014-10

OASIS

第一个正式标准,编号 ISO/IEC 20922:2016,目前使用最广

MQTT 5.0

2019-03

OASIS

加了原因码、属性、共享订阅、请求响应等,功能补齐

几个一眼能记住的特征:

  • 基于 TCP(也可以用 WebSocket 承载),默认端口 1883,TLS 走 8883,WebSocket 走 8083/8084;

  • 发布/订阅模型,客户端之间不直连,全部经过 Broker 转发;

  • 包极小:固定头最少只要 2 个字节,这是它在窄带、低功耗场景能赢的关键;

  • 报文类型总共 14 种,全部用第一个字节的高 4 位表示,协议体量小到可以用一个下午通读一遍标准。

我一开始的误区是把它当成「又一个 HTTP」。其实定位完全不同:HTTP 是「请求—响应」的同步模型,客户端必须知道服务端地址、必须等响应、连接用完就关;MQTT 是「事件流」模型,客户端只管订阅自己关心的主题,消息什么时候来、有几条、从哪来,发布方和订阅方互相都不知道。

二、发布/订阅模型:一次彻底的解耦

整个 MQTT 世界里只有两种角色:

角色

职责

Client(客户端)

既能发布也能订阅;可以和 Broker 建立一条 TCP 长连接

Broker(服务端)

接收所有 PUBLISH,按主题匹配订阅关系并转发;维护会话、保留消息、遗嘱消息

Broker 是唯一的「中间人」,带来的价值是三个维度的解耦:

  • 空间解耦:发布者不需要知道订阅者的 IP、端口,连有几个订阅者都不知道;

  • 时间解耦:订阅者不在线时消息可以被 Broker 暂存(依赖会话,见第九节);

  • 流程解耦:发布者发完就走,不需要等待处理结果,天然是异步的。

和传统的「HTTP 轮询」相比,差异非常直观:

维度

HTTP 轮询

MQTT

方向

拉(Pull),客户端主动问

推(Push),Broker 主动发

连接

短连接,每次都要握手

长连接,一次握手长期复用

实时性

取决于轮询间隔,要么慢要么费

事件到达即推送

开销

HTTP 头部动辄几百字节

心跳包只有 2 字节

拓扑

N 个设备 × M 个消费者,组合爆炸

N + M,全部经 Broker 解耦

最后一行是我觉得最本质的一点:如果 1000 个设备要把数据送给 5 个下游系统,轮询或直连是 5000 条链路要维护;用 MQTT 就是 1005 条连接,而且下游增加、减少完全不影响设备侧。

三、主题 Topic:分层寻址与通配符

MQTT 里没有「设备地址」的概念,取而代之的是 主题(Topic)——一个用 / 分层的 UTF-8 字符串,比如:

sensor/room1/temperature
sensor/room1/humidity
sensor/room2/temperature
factory/line3/plc/status

几条必须记牢的规则:

  • 主题区分大小写,Sensor/Room1 和 sensor/room1 是两个不同的主题;

  • 长度上限 65535 字节;

  • sensor/room1 和 /sensor/room1、sensor/room1/ 是三个不同的主题,实际项目里一定要约定好前缀,否则收不到消息时排查起来很痛苦;

  • 层级可以为空,a//b 是合法的(中间是一级空主题);

  • 以 $ 开头的主题是 Broker 保留的(例如 Mosquitto/EMQX 的 $SYS/ 系统主题),普通客户端不建议使用。

3.1 两个通配符

订阅时可以带通配符,但发布时不允许使用(发布了也没人知道该匹配给谁):

通配符

含义

示例

能匹配

不能匹配

+

匹配单层,必须独占一层

sensor/+/temperature

sensor/room1/temperature

sensor/room1/a/temperature

#

匹配多层,必须放最后

sensor/#

sensor/room1/temperature、sensor/a/b/c

—

几个容易写错的点:

  • sport/# 会匹配 sport 本身(# 包含父级),但 sport/+ 不会;

  • + 必须独占一层,sensor/room+/temperature 是非法的,它只是一个普通层级名;

  • 以 $ 开头的主题不会被 # 或 + 意外匹配到,比如 # 收不到 $SYS/broker/uptime,得显式订阅 $SYS/#。

3.2 主题设计的一点经验

这块是我踩过坑的地方。主题不是「随便起个名字」,它同时承担了路由键、权限粒度和运维维度的作用,建议按「业务域/位置/设备类型/指标」的固定层级设计,把「变化的部分」放在层级中间,把「需要整体订阅的部分」放在前缀。举个例子:

# 按位置订阅(运维视角)
factory/+/line3/#

# 按指标订阅(算法侧视角)
factory/+/+/temperature

如果一个主题层级里塞了业务上不稳定的字段,后面必然会出现「订阅写不出来、只能订阅 # 然后全量过滤」的窘境——那等于放弃了 Broker 的路由能力。

四、报文结构:14 种控制报文

MQTT 的所有交互都由 控制报文(Control Packet) 承载。每个报文都是三段式:固定头 + 可变头 + 载荷,其中固定头必有,后两段视报文类型而定。

4.1 固定头

固定头最少 2 字节,第 1 字节拆开看是「类型 + 标志位」:

bit:  7 6 5 4 | 3  2 1  0
      ────────┼──────────
      Packet  |  Flags
       Type   |
  • 高 4 位是报文类型(1~14,0 保留禁用);

  • 低 4 位是标志位,只有 PUBLISH 用满(DUP / QoS / RETAIN),SUBSCRIBE、UNSUBSCRIBE、PUBREL 是固定值,其余报文必须为 0,否则服务端直接断开连接;

  • 第 2 字节起是剩余长度(Remaining Length),用「可变字节整数」编码,1~4 字节,最大 268435455(约 256 MB)。前 7 位存数值,最高位是「还有后续字节」的标志。

这个「2 字节起步」的设计就是它比 HTTP 省的地方:一个心跳包、一个只有主题和空载荷的 PUBLISH,开销都小到可以忽略。

4.2 14 种报文类型

值

报文

方向

作用

1

CONNECT

客户端 → 服务端

发起连接,携带 ClientId、遗嘱、账号密码、Clean Session

2

CONNACK

服务端 → 客户端

确认连接,返回 Session Present 与返回码

3

PUBLISH

双向

发布消息,QoS/DUP/RETAIN 都在它的标志位里

4

PUBACK

双向

QoS 1 的确认

5

PUBREC

双向

QoS 2 第一步确认(收到)

6

PUBREL

双向

QoS 2 第二步(释放)

7

PUBCOMP

双向

QoS 2 第三步确认(完成)

8

SUBSCRIBE

客户端 → 服务端

订阅主题,可一次订阅多个

9

SUBACK

服务端 → 客户端

订阅确认,逐条返回授予的 QoS

10

UNSUBSCRIBE

客户端 → 服务端

取消订阅

11

UNSUBACK

服务端 → 客户端

取消订阅确认

12

PINGREQ

客户端 → 服务端

心跳请求

13

PINGRESP

服务端 → 客户端

心跳响应

14

DISCONNECT

客户端 → 服务端

正常断开(协议层面「我要走了」)

把这 14 种记住,基本就抓住了 MQTT 的骨架:连接、发布/订阅、确认、心跳、断开。

五、CONNECT:连接标志位逐位拆解

CONNECT 是唯一一个「信息密度极高」的报文,很多线上问题都能在连接阶段看出来。它的可变头是:

字段

长度

说明

协议名

变长

3.1.1 固定为 MQTT(3.1 是 MQIsdp)

协议级别

1 字节

3.1.1 为 4(3.1 是 3,5.0 是 5)

连接标志位

1 字节

见下表

Keep Alive

2 字节

心跳间隔(秒),0 表示不启用

连接标志位这一字节按位定义:

位

名称

含义

7

User Name Flag

1 表示载荷里有用户名

6

Password Flag

1 表示载荷里有密码(前提是用户名标志为 1)

5

Will Retain

遗嘱消息是否作为保留消息发布

4~3

Will QoS

遗嘱消息的 QoS(0/1/2,不能是 3)

2

Will Flag

是否设置遗嘱消息

1

Clean Session

1 = 丢弃旧会话新建;0 = 复用同名会话

0

Reserved

必须为 0,否则服务端断开连接

两个标准里的硬性约束值得单独记一下:

  • Will Flag = 0 时,Will QoS 必须是 0、Will Retain 必须是 0;

  • Will Flag = 1 时,载荷里必须同时出现 Will Topic 和 Will Message。

载荷部分按固定顺序排列:ClientId → Will Topic → Will Message → User Name → Password。服务端返回的 CONNACK 里有一个 Session Present 标志位和 1 字节返回码:

返回码

含义

0x00

连接已接受

0x01

协议版本不被接受

0x02

ClientId 被拒绝(典型场景:CleanSession=0 却传了空 ClientId)

0x03

服务端不可用

0x04

用户名或密码错误

0x05

未授权

现场排查「连不上」时,先看返回码,基本能一步定位是配置问题、鉴权问题还是服务端问题,比翻日志快得多。

六、QoS:三级语义与四次握手

QoS 是 MQTT 最核心也最容易被误解的概念。它描述的是「消息传递的保证强度」,不是「消息的价值」,而且它只保证「至少/恰好到达 Broker 或客户端」,不保证业务处理成功。

等级

语义

交互流程

代价

适用场景

0

至多一次(At most once)

PUBLISH 一发即忘

可能丢,不重发

高频传感器遥测,丢一条无所谓

1

至少一次(At least once)

PUBLISH → PUBACK

可能重复,需要业务幂等

大多数业务数据的默认选择

2

恰好一次(Exactly once)

PUBLISH → PUBREC → PUBREL → PUBCOMP

两轮往返,开销最大

计费、下发指令等不能重复的动作

QoS 2 的「两次确认」是它最反直觉的地方,我画过好几遍才记住:

发布方                        服务端
  │  PUBLISH (QoS2)            │
  │ ─────────────────────────► │   ① 收到并存下来
  │  PUBREC                    │
  │ ◄───────────────────────── │
  │  PUBREL                    │   ② 告诉服务端:可以释放了
  │ ─────────────────────────► │
  │  PUBCOMP                   │
  │ ◄───────────────────────── │   ③ 双方都确认完成,删除消息

真正需要注意的规则有三条:

  1. 有效 QoS = min(发布 QoS, 订阅 QoS)。发布方以 QoS 2 发,订阅方以 QoS 0 订,最终收到的是 QoS 0,中间会被「降级」。所以 QoS 要在两端都对齐,只改一头没有用。

  2. QoS 1 会产生重复消息。PUBACK 丢了、客户端重连后重发,都会导致重复投递,业务侧必须做幂等(一般用消息里的业务主键 + 唯一索引)。

  3. QoS 只覆盖「客户端 ↔ Broker」这一段。Broker 转发给订阅者时用的是订阅时的 QoS,整条链路的保证强度取最小值。

七、保留消息 Retained Message

保留消息解决的是「新来的订阅者拿不到旧状态」的问题。

普通的 PUBLISH 是「过去的就过去了」——一个客户端在消息发出 5 秒后才订阅,它永远看不到那条消息。但如果发布时把 RETAIN 标志位置 1,Broker 会把这条消息按主题保存下来,之后任何新订阅者订阅该主题,都会立刻收到这份「最后已知状态」。

  • 每个主题只保留最后一条保留消息,新的覆盖旧的;

  • 清除方式:向该主题发布一条 payload 长度为 0 的保留消息;

  • 保留消息不属于会话,它是 Broker 侧按主题存的,与会话是否持久化无关;

  • 订阅时收到的保留消息,标志位里 RETAIN 为 1,可以用来区分「历史状态」和「实时消息」。

典型用法就是设备状态:设备上线时把 device/room1/status 以 RETAIN=1 发布 online,前端页面一打开订阅就能立刻拿到所有设备当前状态,不需要等下一次上报。这个用法我觉得是 MQTT 设计里最优雅的一处。

八、遗嘱消息 Will:离线检测的官方答案

设备掉电、拔网线、SIM 卡欠费——这些场景下客户端是没机会主动告警的。遗嘱消息(Last Will and Testament, LWT)就是为此设计的:连接时就把「我死了以后帮我发的消息」交给 Broker 存着。

触发时机很明确:

  • 网络异常断开、客户端未发 DISCONNECT 就消失;

  • Keep Alive 超时(服务端在 1.5 倍 Keep Alive 时间内没收到任何报文);

  • 协议错误导致服务端主动断开。

而在 3.1.1 里,客户端发送 DISCONNECT 正常断开时,遗嘱消息不会发布(5.0 里可以通过属性改变这个行为)。

实际用法就是配合保留消息做在线状态:

连接时声明:
  Will Topic   = device/room1/status
  Will Payload = offline
  Will QoS     = 1
  Will Retain  = true

在线时自己发布:
  device/room1/status = online (RETAIN)

一旦异常掉线,Broker 自动发:
  device/room1/status = offline (RETAIN)

这样下游永远能拿到设备的真实状态,不用自己搞一套超时兜底。

九、会话:Clean Session 与离线消息

「会话(Session)」是 MQTT 里另一个容易被忽略但影响很大的概念。它包含:订阅关系、未确认的 QoS 1/2 消息、准备发给客户端但尚未发送的消息。

Clean Session

行为

适用场景

1(true)

每次连接都新建会话,断开即销毁;离线期间的消息全部丢弃

临时客户端、只关心实时数据

0(false)

复用同名 ClientId 的会话;订阅关系保留,离线期间的 QoS 1/2 消息被暂存,重连后补发

需要「断线不漏消息」的设备

这里有两个我踩过的坑:

  1. 用 CleanSession=0 就必须有稳定的 ClientId。如果传空 ClientId,服务端会直接返回 0x02 拒绝连接;而如果 ClientId 每次都随机生成,那会话永远对不上,离线消息等于没配。

  2. 持久会话 ≠ 永久保存。Broker 侧通常有会话过期时间,而且离线消息是按订阅的 QoS 暂存的:如果你订的是 QoS 0,断线期间的 QoS 0 消息本来就不保证,别指望补发。

顺带一提,MQTT 5.0 把这块改成了更好理解的两个概念:Clean Start(这次连接是否新建会话)+ Session Expiry Interval(会话保留多久),两个维度分开配置,比 3.1.1 的单一布尔值清晰很多。

十、Keep Alive 与心跳

TCP 长连接本身可能「假死」——物理链路断了但对端不知道。MQTT 用 Keep Alive 解决:连接时约定一个秒数(0~65535,0 表示不启用)。

  • 客户端在 Keep Alive 时间内只要发过任意报文(包括 PUBLISH、PINGREQ)就不算超时;

  • 如果一直没发,客户端必须发 PINGREQ,等 PINGRESP;

  • 服务端在 1.5 倍 Keep Alive 时间内没收到任何报文,就判定客户端失联,断开连接并发布遗嘱消息。

取值经验:移动网络/弱网设备一般取 30~60 秒;太长会导致掉线发现不及时(要考虑与遗嘱消息的配合),太短则白耗流量和电量。客户端侧一定要把心跳交给库去做(Paho 会自动处理),不要试图自己写定时器。

十一、MQTT 3.1.1 与 5.0 的差异

3.1.1 目前兼容性最好,但 5.0 补掉了很多工程上很痛的短板。如果用新版 Broker 和新设备,建议直接上 5.0:

能力

3.1.1

5.0

失败原因

只有 CONNACK 的 6 个返回码

全面引入原因码,几乎所有确认报文都能带回具体原因

自定义元数据

不支持

用户属性(User Property),可随报文携带键值对

会话生命周期

单一 Clean Session 布尔值

Clean Start + Session Expiry Interval 分开控制

消息有效期

不支持

Message Expiry Interval,过期消息不再投递

主题长度优化

不支持

Topic Alias,用整数代替长主题名,省流量

负载均衡

不支持

共享订阅 $share/group/topic,多消费者竞争消费

请求/响应

要靠业务自己约定主题

Response Topic + Correlation Data 原生支持

流控

无

Receive Maximum 控制未确认消息上限

遗嘱消息

Will QoS 不能为 3;正常断开不发遗嘱

可通过 Will Delay Interval 延迟发送遗嘱

十二、Java 实践:Eclipse Paho 最小可用示例

我平时是写 Java 的,这里把学习时搭的 demo 记下来。客户端用的是 Eclipse Paho(最轻量、依赖最少),Broker 本地用 Mosquitto 就够跑通。

<dependency>
    <groupId>org.eclipse.paho</groupId>
    <artifactId>org.eclipse.paho.client.mqttv3</artifactId>
    <version>1.2.5</version>
</dependency>

连接 + 订阅 + 发布,一个类就能跑通:

import org.eclipse.paho.client.mqttv3.*;
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;

public class MqttDemo {

    public static void main(String[] args) throws Exception {
        String broker = "tcp://127.0.0.1:1883";
        // ClientId 要稳定,持久会话才有效
        String clientId = "demo-device-room1";

        MqttClient client = new MqttClient(broker, clientId, new MemoryPersistence());

        MqttConnectOptions options = new MqttConnectOptions();
        options.setCleanSession(true);          // 需要离线消息就设为 false
        options.setKeepAliveInterval(60);       // 心跳 60 秒
        options.setConnectionTimeout(10);
        options.setAutomaticReconnect(true);    // 断线自动重连
        options.setUserName("demo");
        options.setPassword("demo123".toCharArray());

        // 遗嘱消息:异常掉线时由 Broker 代发,配合 RETAIN 做在线状态
        options.setWill("device/room1/status", "offline".getBytes(), 1, true);

        client.connect(options);
        System.out.println("connected: " + client.isConnected());

        // 订阅:通配符 + 指定 QoS
        client.subscribe("device/+/status", 1, (topic, msg) -> {
            System.out.printf("[recv] topic=%s qos=%d retain=%s payload=%s%n",
                    topic, msg.getQos(), msg.isRetained(),
                    new String(msg.getPayload()));
        });

        // 发布:上线状态,RETAIN=true 让后订阅的人也能立刻拿到
        MqttMessage online = new MqttMessage("online".getBytes());
        online.setQos(1);
        online.setRetained(true);
        client.publish("device/room1/status", online);

        Thread.sleep(3000);
        client.disconnect();
        client.close();
    }
}

几个我在调试时真正卡住的点:

  • ClientId 重复会互相踢线。同一个 ClientId 建立第二条连接时,Broker 会断开前一条。用「设备序列号」这类稳定值是对的,但千万别在本地多开几个进程用同一个 ID 调试,会以为是「代码有问题」。

  • 持久会话要用 MqttDefaultFilePersistence。用 MemoryPersistence 时进程一挂,未确认消息就没了;生产环境建议落盘。

  • 回调里不要做阻塞操作。Paho 的单线程回调队列被占住会连带影响心跳和确认,重活要丢到自己的线程池里。

  • 要按顺序保证用同一个客户端实例发布;不同实例之间 MQTT 不保证顺序。

十三、我整理的落地清单

把上面这些串起来,如果从零接一个 MQTT 项目,我会按这个顺序确认需求:

  1. 先确定消息重要程度,再定 QoS。遥测高频数据 QoS 0,业务数据 QoS 1 + 幂等,控制指令再考虑 QoS 2。

  2. 主题命名规范定死,包括层级、大小写、业务域前缀,写进对接文档;保留 $SYS 给运维。

  3. ClientId 用设备唯一标识,并明确是否启用持久会话;启用就要评估 Broker 侧的会话与消息堆积容量。

  4. 在线状态统一用「遗嘱 + 保留消息」,别自己圆一套超时机制。

  5. Keep Alive 与遗嘱配套设置,让掉线能在可接受的时间内被发现。

  6. 做幂等。只要是 QoS 1,就必须假设消息会重复。

  7. 上 TLS(8883)。明文 1883 只适合内网调试,公网暴露基本等于把设备数据公开。

  8. Broker 侧关注连接数、消息堆积、内存与磁盘,共享订阅/集群在 5.0 才比较完善。

十四、小结与学习资料

回头看,MQTT 的设计其实非常「克制」:14 种报文、2 字节起步的包头、一套主题通配符规则,就把「海量弱网设备的事件上报」这件事讲清楚了。它和 HJ 212、SL 651 这类规约的区别,本质上是「面向链路与召测」和「面向事件与解耦」的区别——前者由中心站主导节奏,后者由事件驱动,设备只负责把状态喊出来。

我学习时主要参考的资料:

  • MQTT Version 3.1.1(OASIS 标准原文,第 2~4 章是报文定义的精华)

  • MQTT Version 5.0(OASIS 标准原文,重点看属性与原因码)

  • Eclipse Paho 官方文档与 API 说明

  • Eclipse Mosquitto / EMQX 的文档,用于本地搭环境和验证行为

这篇是学习笔记,难免有理解不到位的地方,后面用到了再回来补充。下一篇打算把「MQTT over TLS + 设备鉴权方案」和「Broker 集群选型」这两块单独整理一下。


评论