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(恰好一次):四段握手,保证不重不丢:

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 验证部署:三步确认跑通
-
看进程:
docker logs emqx | grep -i started,或进容器docker exec -it emqx emqx ctl status,输出Node ... is started即正常。 -
命令行收发(需要 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 "订阅了但收不到消息"——按这三步排查
-
主题匹配:发布主题与订阅过滤器是否真的匹配(注意
+/#层级、$前缀隔离); -
ACL 权限:订阅者对该主题是否有订阅权限(EMQX 开了认证后默认按 ACL 放行,规则要核对);
-
共享订阅 QoS:组内成员 QoS 不一致可能丢消息,统一 QoS 再看。
4.5 "连接被断开/被拒"——最常见的四个原因
| 现象 | 本质 | 处理 |
|---|---|---|
| 同 Client ID 反复被踢 | 新连接接管旧会话(Session Takeover) | 保证 Client ID 全局唯一 |
| 认证失败(原因码 135) | 账号/密码错或未配置 | 核对认证配置与凭据 |
| 连接被 Broker 拒绝 | 匿名认证被关、连接数达上限 | 检查 max_connections 与认证 |
| 连接反复断开重连 | Keep Alive 小于 NAT 超时 | NAT 场景 Keep Alive 不超过 30 秒 |
5. 结语
MQTT 的巧妙在于:它用极简的报文格式,把"可靠性"拆成可选的档位、把"状态"交给会话、把"容错"交给遗嘱和保留消息——每一个机制都在解决真实网络下真实设备的问题。把这套机制用对,比换一个更"高级"的消息中间件重要得多。
部署层面的建议收个尾:
-
先理解协议再调参数,QoS/会话/遗嘱用错,调参救不回来;
-
生产环境第一件事:关匿名认证、改默认密码、挂数据卷;
-
排查问题先抓包(Wireshark/mqtt),报文不会说谎。
参考:MQTT 3.1.1 / 5.0 OASIS 规范、EMQX 官方文档(Docker 部署、集群、客户端最佳实践)。