GIS开发 | 空间分析 | 软硬件技术
🌿 绿水青山就是金山银山 🌿

MQTT 协议深度解析:从报文原理到 Docker 部署 EMQX 的完整实践

0. MQTT 的本质:为"哑设备"设计的消息协议

MQTT(Message Queuing Telemetry Transport)诞生于 1999 年,是 IBM 为卫星通信设计的——超低带宽、高延迟、不可靠的网络。它的设计目标决定了它的气质:

  • 发布/订阅模型:消息不是点对点发送,而是发布到"主题(Topic)",由 Broker 路由给所有订阅者。

  • 三个解耦:空间解耦(发布者不知道订阅者是谁)、时间解耦(双方不必同时在线,靠 Broker 缓冲)、同步解耦(异步通信)。

  • 极简协议头:固定报头最小 2 字节,对带宽和设备资源极其友好。

  • 可靠性的可选择性:QoS 0/1/2 三档,把"可靠性"的选择权交给业务。

为什么物联网用 MQTT 而不是 HTTP?对比一下:

维度 MQTT HTTP
模型 发布/订阅(一对多) 请求/响应(一对一)
报文头开销 最小 2 字节 通常几百字节以上
实时推送 天然支持(订阅即推送) 需轮询或长连接/SSE
离线消息 会话机制支持 不支持
设备资源占用 低 高
典型场景 传感器、控制、告警 Web 服务、API

1. 报文层:MQTT 到底在传什么

1.1 控制报文结构

MQTT 的控制报文由三部分组成:固定报头(所有报文必有)、可变报头(部分报文有)、载荷(部分报文有)。

固定报头第一字节的含义:

  • bit 7 4:报文类型(015,共 15 种取值,其中 0 保留);

  • bit 3:DUP——重发标志,客户端重发消息时置 1;

  • bit 2~1:QoS——消息服务质量等级;

  • bit 0:RETAIN——保留消息标志。

剩余长度:固定报头的第二段,用 1~4 字节变长编码表示"可变报头 + 载荷"的字节数。每字节低 7 位存数据,最高位为连续标志,127 以内的长度 1 字节搞定——这是 MQTT 报文轻量的关键之一。

1.2 15 种控制报文总览

MQTT 3.1.1 定义了 14 种控制报文(MQTT 5.0 增加 AUTH 认证报文,共 15 种):

报文 方向 作用
CONNECT (1) 客户端 → 服务端 发起连接
CONNACK (2) 服务端 → 客户端 确认连接(含原因码)
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) 服务端 → 客户端 确认取消
PINGREQ (12) 客户端 → 服务端 心跳请求
PINGRESP (13) 服务端 → 客户端 心跳响应
DISCONNECT (14) 双向 正常断开
AUTH (15) 双向 增强认证(MQTT 5.0 新增)

1.3 一次连接的生命周期

CONNECT 报文里藏着连接的全部"初始约定":

  • Client ID:客户端唯一标识(MQTT 3.1.1 规范为 1 23 字符,5.0 放宽到 165535 字节);

  • Clean Session:是否开启干净会话;

  • Keep Alive:心跳间隔;

  • 遗嘱消息:遗嘱主题、载荷、QoS、保留标志。

连接建立后,客户端按 Keep Alive 间隔发送 PINGREQ,服务端回 PINGRESP;超过 1.5 × Keep Alive 未收到任何报文,服务端判定连接失效并断开。很多"设备掉线"问题的根源,就是心跳间隔设计不合理(详见下文)。

2. 核心机制深挖

2.1 QoS:可靠性的三个档位

QoS(Quality of Service)是 MQTT 最核心也最容易用错的概念。它定义的是"**发布者和 Broker(以及 Broker 和订阅者之间)**的消息投递保证",而不是端到端保证。

QoS 0(至多一次):PUBLISH 发出即算完,不确认、不重发。适合遥测等可容忍丢失的数据。

QoS 1(至少一次):Broker 收到 PUBLISH 后回 PUBACK。发送方在收到 PUBACK 前可重发(DUP=1),可能产生重复消息,但保证不丢。

QoS 2(恰好一次):四段握手,保证不重不丢:

MQTT QoS 0 / 1 / 2 投递流程对比(示意)

QoS 2 为什么需要四段握手?因为网络不可靠,任意一步都可能丢。PUBREC 确认"我收到了",PUBREL 表示"我允许你交付了",PUBCOMP 表示"交付完成"。每一段都可以重发,最终靠 Packet ID 去重——这是"恰好一次"的实现基础。

使用建议:QoS 的代价是性能。QoS 1 的吞吐远低于 QoS 0,QoS 2 最重。遥测用 0、告警和指令用 1、计费等强一致场景才用 2。别把"可靠性"当成"越高越好"。

注意:QoS 是逐跳的。发布端 QoS 1 → Broker,Broker 按订阅端的 QoS 降级投递。发布端 QoS 2、订阅端 QoS 0,订阅端收到的是 QoS 0 投递,消息可能因为订阅端离线等场景丢失——共享订阅场景尤其要注意订阅端 QoS 一致性。

2.2 会话:Clean Session 与持久会话

MQTT 会话(Session)是 Broker 为每个客户端维护的状态,包括:

  • 订阅关系;

  • QoS 1/2 未确认消息;

  • QoS 2 收到的 PUBREL 状态;

  • 离线消息队列(持久会话)。

Clean Session = true:连接断开即销毁会话,订阅和离线消息全部清空,重连后必须重新订阅。 Clean Session = false:会话持久,断线期间的消息进入离线队列(受 QoS 和队列长度限制),重连后自动恢复订阅并收到离线消息。

MQTT 5.0 把"会话过期"改成了显式的 Session Expiry Interval:会话不随连接断开立即销毁,而是倒计时过期;设为 0 则连接断开即销毁。注意:持久会话的离线消息存在 Broker 内存/存储里,大量持久会话 + 高 QoS 会显著增加 Broker 内存,这是容量规划时最容易漏掉的一项。

2.3 遗嘱消息(LWT):设备失联时的"最后一句话"

遗嘱消息在 CONNECT 报文中预置(遗嘱主题 + 载荷 + QoS + 保留标志)。当连接非正常关闭(网络断开、设备掉电、心跳超时)时,Broker 立即替客户端发布遗嘱消息;正常 DISCONNECT 不会触发。

典型的用法:遗嘱主题 device/{id}/status,载荷 offline,保留标志开——这样任何新订阅者(包括管理后台)立刻知道设备状态。配合心跳上报 online,就能搭出一套简单的在线状态机制。

client.will_set(topic="device/esp32_001/status", payload="offline", qos=1, retain=True)

注意:遗嘱消息的发布也是走 QoS 的,如果遗嘱 QoS 是 0,它也可能丢;在线状态要求高时用 QoS 1 + 保留。

2.4 保留消息:Broker 上的"最新值缓存"

正常 MQTT 消息是"发完即走"——新订阅者只能收到订阅之后的消息。保留消息(RETAIN)让 Broker 为每个主题保存最后一条保留消息,新订阅者订阅时立即收到。

适用场景:设备状态、最新配置、网关信息——"我想知道现在是什么,而不是历史是什么"。

几个细节:

  • 发布一条空载荷的保留消息 = 删除该主题的保留消息;

  • 保留消息按主题独立存储,主题数量多、保留消息多会占用内存;

  • 别把频繁变化的数据设保留,价值低还浪费。

2.5 主题与通配符:Broker 怎么路由消息

主题(Topic)是 UTF-8 字符串,按 / 分层级:factory/room1/temperature。

  • +:单层通配符,匹配恰好一个层级;

  • #:多层通配符,匹配当前及以下所有层级,必须放在最后。

匹配规则的两个冷门细节(面试和排错都爱考):

  • 发布不能使用通配符——PUBLISH 的主题必须是完整主题;

  • 以 $ 开头的主题(如 $SYS/)不会被 # 或 +/# 开头的通配符订阅匹配(除非过滤器也以 $ 开头)。这是 MQTT 3.1.1 的明确规则,用来隔离系统主题。

匹配性能:EMQX 用前缀树(topic trie)做路由匹配,主题层级越浅、结构越规整,匹配越快。层级过深(超过 7 层)和大量通配符订阅都会增加路由开销。

2.6 共享订阅:消费者横向扩展的钥匙

普通订阅是广播:N 个订阅者收到 N 份消息。共享订阅 $share/{group}/{topic} 是负载均衡:组内所有订阅者轮流消费消息,每条消息只被一个成员处理。

这是后端服务水平扩展的标准姿势——多实例消费设备消息,必须用共享订阅,否则每个实例都收到全量消息(重复处理、重复落库)。

$share/backend/data/+/telemetry

共享订阅的坑:组内成员 QoS 不一致可能丢消息;消费速度跟不上时消息在组内堆积(不会转给其他组)。

2.7 MQTT 5.0:协议升级带来了什么

MQTT 5.0 是 3.1.1 发布 5 年后的重大升级,EMQX 5.x 完整支持。值得用的新能力:

特性 解决什么 用法
会话过期(Session Expiry Interval) 细粒度控制会话生命周期 CONNECT 携带,替代 Clean Session 二元开关
消息过期(Message Expiry Interval) 过期的消息不投递 PUBLISH 携带,省带宽省内存
用户属性(User Properties) 消息附带业务元数据 键值对,类似 HTTP Header
主题别名(Topic Alias) 减少重复主题传输开销 短整数代替长主题字符串
请求-响应(Request/Response) 设备端到端请求 Correlation Data + Response Topic
原因码(Reason Code) 明确失败原因 所有 ACK 报文携带原因码
接收最大值(Receive Maximum) 流量控制 限制并发未确认消息数

实践中,主题别名和用户属性在窄带宽场景很实用;原因码让"为什么订阅失败、为什么被断开"从玄学变成明文。

3. Docker 部署 EMQX:从跑起来到用起来

3.1 单机部署

docker run -d --name emqx \
  -p 1883:1883 \
  -p 8083:8083 \
  -p 8084:8084 \
  -p 18083:18083 \
  -v emqx_data:/opt/emqx/data \
  -v emqx_log:/opt/emqx/log \
  -e EMQX_DASHBOARD__DEFAULT_USERNAME=admin \
  -e EMQX_DASHBOARD__DEFAULT_PASSWORD=public \
  emqx/emqx:5.8

端口说明:

端口 协议 用途
1883 MQTT/TCP 设备接入主端口
8883 MQTT/SSL TLS 加密接入
8083 MQTT/WebSocket 浏览器/小程序接入
8084 MQTT/WebSocket+TLS WSS 加密接入
18083 HTTP Dashboard 管理后台

两个容易忽略的点:

  • 数据持久化:/opt/emqx/data(内置数据库、配置)、/opt/emqx/log(日志)必须挂卷,否则容器重建后配置和认证数据全丢;

  • Dashboard 默认账号:admin / public,生产环境第一时间改掉。

3.2 docker-compose 编排

services:
  emqx:
    image: emqx/emqx:5.8
    container_name: emqx
    restart: unless-stopped
    ports:
      - "1883:1883"
      - "8083:8083"
      - "8084:8084"
      - "18083:18083"
    volumes:
      - emqx_data:/opt/emqx/data
      - emqx_log:/opt/emqx/log
    environment:
      - EMQX_DASHBOARD__DEFAULT_USERNAME=admin
      - EMQX_DASHBOARD__DEFAULT_PASSWORD=你的强密码
      - EMQX_AUTHENTICATION__NONE__ENABLE=false   # 生产环境必须关掉匿名认证

volumes:
  emqx_data:
  emqx_log:

3.3 验证部署:三步确认跑通

  1. 看进程:docker logs emqx | grep -i started,或进容器 docker exec -it emqx emqx ctl status,输出 Node ... is started 即正常。

  2. 命令行收发(需要 mosquitto-clients):

# 终端 1:订阅
mosquitto_sub -h 127.0.0.1 -p 1883 -t 'test/topic' -v
# 终端 2:发布
mosquitto_pub -h 127.0.0.1 -p 1883 -t 'test/topic' -m 'hello mqtt'

订阅端收到 hello mqtt 即全链路通。 3. 图形化验证:MQTTX(桌面客户端)连接 127.0.0.1:1883,发布/订阅同一主题,直观看到消息流转。抓包党可以直接用 Wireshark 过滤 mqtt,看 CONNECT/PUBLISH/PUBACK 的真实报文。

3.4 生产环境必做配置

  • 关闭匿名认证:EMQX_AUTHENTICATION__NONE__ENABLE=false(或在 Dashboard 里配置内置数据库/JWT/HTTP 认证);

  • 限制消息大小:默认单条消息上限较大,按业务配置 MQTT.MAX_PACKET_SIZE,防止脏数据撑爆内存;

  • 连接数规划:LISTENERS.TCP.DEFAULT.MAX_CONNECTIONS 按设备规模设置,留 30% 余量;

  • 系统层调优:关闭 swap、禁用 THP、调大文件描述符(见《EMQX 生产实践》的系统调优部分)。

3.5 三节点集群部署

services:
  emqx1:
    image: emqx/emqx:5.8
    container_name: emqx1
    hostname: emqx1
    environment:
      - EMQX_NODE__NAME=emqx1@node1.emqx.io
      - EMQX_NODE__DISCOVERY_STRATEGY=static
      - EMQX_NODE__STATIC__SEEDS=["emqx1@node1.emqx.io","emqx2@node2.emqx.io","emqx3@node3.emqx.io"]
      - EMQX_CLUSTER__AUTO_CLEAN=on
    networks:
      emqx-net:
        aliases:
          - node1.emqx.io
    ports:
      - "1883:1883"
      - "18083:18083"
    volumes:
      - emqx1_data:/opt/emqx/data

  emqx2:
    image: emqx/emqx:5.8
    container_name: emqx2
    hostname: emqx2
    environment:
      - EMQX_NODE__NAME=emqx2@node2.emqx.io
      - EMQX_NODE__DISCOVERY_STRATEGY=static
      - EMQX_NODE__STATIC__SEEDS=["emqx1@node1.emqx.io","emqx2@node2.emqx.io","emqx3@node3.emqx.io"]
    networks:
      emqx-net:
        aliases:
          - node2.emqx.io
    ports:
      - "1884:1883"
      - "18084:18083"
    volumes:
      - emqx2_data:/opt/emqx/data

  emqx3:
    image: emqx/emqx:5.8
    container_name: emqx3
    hostname: emqx3
    environment:
      - EMQX_NODE__NAME=emqx3@node3.emqx.io
      - EMQX_NODE__DISCOVERY_STRATEGY=static
      - EMQX_NODE__STATIC__SEEDS=["emqx1@node1.emqx.io","emqx2@node2.emqx.io","emqx3@node3.emqx.io"]
    networks:
      emqx-net:
        aliases:
          - node3.emqx.io
    ports:
      - "1885:1883"
      - "18085:18083"
    volumes:
      - emqx3_data:/opt/emqx/data

networks:
  emqx-net:
    driver: bridge

volumes:
  emqx1_data:
  emqx2_data:
  emqx3_data:

关键点:

  • 节点发现:用 static 策略 + seeds 列表,节点名 emqxN@nodeN.emqx.io 必须与容器别名(hostname 解析)一致;

  • 端口:三节点共用一个 Docker 内部网络,节点间集群通信走内部网络,无需全部映射到宿主机;宿主机只暴露每个节点的对外端口(用不同宿主机端口区分);

  • 验证集群:docker exec -it emqx1 emqx ctl cluster status,看到 3 个 Running 节点即组集群成功。

4. 从原理到排错:高频问题的本质

4.1 "消息丢了"——大概率是 QoS 0 + 会话

QoS 0 消息 Broker 不确认不缓存,网络抖动、Broker 重启、订阅端离线都会丢。要"不丢":遥测升 QoS 1、订阅端用持久会话。先搞清楚"丢"发生在哪一跳:设备 → Broker(发布 QoS)、Broker → 消费者(订阅 QoS),两端各自独立。

4.2 "收到重复消息"——QoS 1 的正常代价

QoS 1 至少一次,重发(DUP)会导致重复。业务端必须幂等(按消息 ID/时间戳去重),这是协议设计的一部分,不是 Bug。要严格不重不丢,用 QoS 2。

4.3 "设备明明在线,后台显示离线"——时序问题

典型的在线状态实现:Webhook 推送 connected/disconnected 事件 → 后端更新状态。如果 Webhook 异步转发,两条事件可能乱序到达(先 disconnected 后 connected),后端一覆盖就错了。解法:同步转发,或事件带序号/时间戳按序合并。

4.4 "订阅了但收不到消息"——按这三步排查

  1. 主题匹配:发布主题与订阅过滤器是否真的匹配(注意 +/# 层级、$ 前缀隔离);

  2. ACL 权限:订阅者对该主题是否有订阅权限(EMQX 开了认证后默认按 ACL 放行,规则要核对);

  3. 共享订阅 QoS:组内成员 QoS 不一致可能丢消息,统一 QoS 再看。

4.5 "连接被断开/被拒"——最常见的四个原因

现象 本质 处理
同 Client ID 反复被踢 新连接接管旧会话(Session Takeover) 保证 Client ID 全局唯一
认证失败(原因码 135) 账号/密码错或未配置 核对认证配置与凭据
连接被 Broker 拒绝 匿名认证被关、连接数达上限 检查 max_connections 与认证
连接反复断开重连 Keep Alive 小于 NAT 超时 NAT 场景 Keep Alive 不超过 30 秒

5. 结语

MQTT 的巧妙在于:它用极简的报文格式,把"可靠性"拆成可选的档位、把"状态"交给会话、把"容错"交给遗嘱和保留消息——每一个机制都在解决真实网络下真实设备的问题。把这套机制用对,比换一个更"高级"的消息中间件重要得多。

部署层面的建议收个尾:

  1. 先理解协议再调参数,QoS/会话/遗嘱用错,调参救不回来;

  2. 生产环境第一件事:关匿名认证、改默认密码、挂数据卷;

  3. 排查问题先抓包(Wireshark/mqtt),报文不会说谎。

参考:MQTT 3.1.1 / 5.0 OASIS 规范、EMQX 官方文档(Docker 部署、集群、客户端最佳实践)。