上一篇文章我把 MQTT 的报文拆到字节级,讲清楚了它为什么"轻"。但那篇讲的都是协议本身的效率。
这一篇换个角度:协议给你的是能力,不给你答案。 遗嘱、保留消息、ACL、主题设计这四个东西,规范只定义了机制,怎么用全凭工程判断 ------ 而实践中最容易踩的坑,恰恰都在这里。
先说结论:
- 遗嘱(Last Will)几乎不花钱。 拆到字节级,它只让 CONNECT 报文多 35 字节,而且每次建连只付一次 。真正要权衡的不是带宽,是 Keep Alive ------ 它同时决定"设备挂了多久能被发现"和"设备待机时耗多少电"。
- 保留消息(Retain)不是缓存,是"会腐烂的快照"。 它的风险在语义不在容量:一条 4.2 KB 的保留消息集合能误导一整个监控平台。
- ACL 里写
#等于没做权限控制。 我在一个 128 条主题的空间里算过:#命中 100%,而精确规则命中 0.8% ------ 中间那 99.2% 就是越权面。 - 主题不是随便起的字符串,它是你的权限模型和路由模型本身。 结构定错了,后面 ACL 和扩展都跟着错。
- "开了 TLS 就安全了"是最大的误区。 TLS 只保传输层,认证、授权、Client ID 全是另外三件事。
下面逐个拆。
一、遗嘱消息:拆开 CONNECT,它只花 35 字节
1. 机制本身
遗嘱是客户端在建立连接时 就预设好的一条消息。客户端异常离线(断电、断网、连接超时)时,Broker 会代它把这条消息发到指定主题上。
它由 CONNECT 报文里的四个参数定义:
| 参数 | 作用 |
|---|---|
| Will Flag | 是否启用遗嘱(bit2) |
| Will Topic | 遗嘱发到哪个主题 |
| Will QoS | 遗嘱用什么 QoS 发 |
| Will Retain | 遗嘱是否作为保留消息发出 |
关键语义有两处,很多实现细节都从这儿长出来:
- 只有异常离线才触发。 客户端主动发
DISCONNECT报文正常退出,Broker 不会发遗嘱。所以遗嘱天然表达的是"我非正常没了",不是"我下线了"。 - 触发时刻不是瞬间。 规范 §3.1.2.10 写明:Broker 在 1.5 倍 Keep Alive 时间内没收到客户端的任何控制报文,就断开连接 ------ 遗嘱在这个时刻才被发出。
2. 逐字节算一遍
CONNECT 报文结构(MQTT 3.1.1 §3.1):
makefile
固定头: 0x10 | 剩余长度(变长编码)
可变头: 协议名(2+4) | 协议级别(1) | 连接标志(1) | Keep Alive(2) -- 固定 10 字节
载荷: ClientID | Will Topic | Will Message | Username | Password
(每一项都是 2 字节长度前缀 + 内容;后四项按标志位决定是否存在)
用系列的同一套示例:ClientID = device_123、Will Topic = sensor/device_123/status、Will Message = offline。
场景 A:最小连接(CleanSession=1,无遗嘱、无认证)
ini
连接标志 = 0x02 (bit1 Clean Session)
可变头 = 10 字节
载荷 = ClientID 2 + 10 = 12 字节
剩余长度 = 10 + 12 = 22 (变长编码 1 字节)
------------------------------------------------
CONNECT 总长 = 1(报文类型)+ 1(剩余长度)+ 22 = 24 字节
场景 B:带遗嘱(Will Flag=1,Will QoS=1,Will Retain=0)
ini
连接标志 = 0x0E (0b1110: Will QoS=01, Will Flag=1, CleanSession=1)
可变头 = 10 字节(仍是 10,标志位不变长)
载荷 = ClientID 2 + 10 = 12 字节
+ Will Topic 2 + 24 = 26 字节
+ Will Message 2 + 7 = 9 字节
------------------------------
= 47 字节
剩余长度 = 10 + 47 = 57
------------------------------------------------
CONNECT 总长 = 1 + 1 + 57 = 59 字节
场景 C:带遗嘱 + 用户名/密码
scss
连接标志 = 0xCE (叠加 bit7 Username、bit6 Password)
载荷 = 47 + Username(2+7=9) + Password(2+8=10) = 66 字节
剩余长度 = 76
------------------------------------------------
CONNECT 总长 = 78 字节
3. 结论:遗嘱的代价是 35 字节,一次性
24 字节(最小) → 59 字节(+遗嘱) → 78 字节(+认证)
↑ 净增 35 字节 ↑ 净增 19 字节
遗嘱本身只值 35 字节 (Will Topic 段 26 + Will Message 段 9),而且只在建连时报一次,之后所有业务消息都不带它。
对比一下"不用遗嘱、改用轮询"的代价。假设 1000 台设备、平台每 60 秒轮询一次状态,一问一答各按 47 字节量级估:
yaml
遗嘱: 35 字节 × 1000 台 × 1 次/天 = 35 KB/天
轮询: 94 字节 × 1000 台 × 1440 次/天 = 129.1 MB/天
--------------------------------------------------------
≈ 3867 倍
口径说明:遗嘱的 35 字节是按 MQTT 3.1.1 规范手工构造后精确核算的;轮询的"47 字节/报文"是量级估算,不是实测值。这里比较的是数量级差异,不是精确比值。
所以"用遗嘱还是用轮询"这道题,答案从来不在带宽上。 遗嘱真正的价值是事件驱动 vs 周期轮询:设备一挂,平台立刻(最多 1.5×KeepAlive 后)收到通知;轮询则是一笔永不停歇的固定开销,而且轮询间隔内发生的故障一定被漏掉。
4. 真正要权衡的是 Keep Alive
这是素材里几乎没人提、但工程上最要紧的一点:Keep Alive 决定了"设备挂了多久能发现" 。
因为触发阈值是 1.5 倍 Keep Alive:
| Keep Alive | 最坏检出延迟 | 待机心跳次数/天 | 心跳流量/天 |
|---|---|---|---|
| 30 s | 45 s | 2880 | 11.2 KB |
| 60 s | 90 s | 1440 | 5.6 KB |
| 120 s | 180 s | 720 | 2.8 KB |
| 300 s | 450 s(7.5 min) | 288 | 1.1 KB |
| 900 s | 1350 s(22.5 min) | 96 | 0.4 KB |
(心跳一次是 PINGREQ 2 字节 + PINGRESP 2 字节 = 4 字节,按空闲待机算。)
30 秒一档的心跳流量是 900 秒一档的 30 倍。 所以这是一张"感知设备离线"的真实价格表:
- 要秒级感知(工业控制、告警类)→ Keep Alive 调小,接受心跳功耗;
- 要极致省电(电池供电的传感器)→ Keep Alive 调大,接受"挂了要等十几分钟才知道"。
这两个需求天生矛盾,没有免费解。 想清楚你的业务能容忍多长的检出延迟,再反推 Keep Alive ------ 而不是随手填个 60。
5. 两个实践要点
- Will Retain 建议设 0。 遗嘱是"一次性通告",不该留给后续订阅者 ------ 否则半年后新上线的监控平台一订阅,立刻收到一条"设备离线",而这台设备可能早就换了。
- 订阅者要处理重复通知。 设备重连后再次异常离线,会再发一次遗嘱。平台侧按 Client ID 做状态去重,别每次都推告警。
二、保留消息:不是缓存,是"会腐烂的快照"
1. 机制本身
发布时把 Retain 标志置 1 ,Broker 会把这个主题的最新一条 消息存下来。之后任何新订阅者 订阅该主题时,Broker 立刻把这条消息推给它 ------ 不用等下一次发布。
这解决了一个很实际的问题:监控平台订阅 1000 台设备的状态,如果设备 5 分钟上报一次,平台启动后最坏要等 5 分钟才能凑齐所有状态。有了保留消息,订阅那一刻就是全量当前值。
2. 它为什么"会腐烂"
保留消息不会自己过期。这是它和缓存最本质的区别 ------ 缓存有 TTL,保留消息没有。
于是三个坑:
坑一:状态变了但没发新消息 → 快照永久过期。
设备从"开"变成"关",但代码里只在"开"的时候发保留消息。结果 Broker 里永远存着"开"。新上线的监控平台看到的就是错的。
修法:状态变化时必发新的保留消息;每个状态都要发,不能只发其中一个方向。
坑二:多发布者写同一主题 → 互相覆盖。
后到的覆盖先到的。多台设备共用一个主题(比如都发 sensor/temperature)时,Broker 里只会剩最后一台的数据。这不是 bug,是 Retain 的定义 ------ 保留消息按主题存"最新一条"。
修法 :主题必须能唯一确定发布者。用 <设备类型>/<设备ID>/<数据>,别用共享主题。
坑三:清除保留消息需要一条"空消息"。
想把某个主题的保留消息删掉,得发一条 payload 为空、Retain=1 的消息:
shell
# 清除 sensor/device_123/status 上的保留消息
mosquitto_pub -h broker.example.com -t 'sensor/device_123/status' -r -n
# ↑ ↑
# retain 空 payload
这条语义在规范里是明确的,但很多人不知道 ------ 于是只能重启 Broker 清。
3. 顺便纠正一个误解:保留消息不占多少地方
算一下就知道,担心"保留消息撑爆 Broker"基本是多余的。
假设 100 个主题,主题名平均 39 字节,payload 4 字节:
scss
100 × (39 + 4) = 4300 字节 ≈ 4.2 KB
口径说明:这里算的是主题名 + payload 的净字节。Broker 实际内存还包含实现相关的索引与元数据开销 ------ 那部分规范不定义,此处不计,所以真实占用会更大,但量级依然是个位数 KB 到几十 KB。
真正的风险在语义,不在容量。 一条 4 KB 的过期快照,能让整个监控平台读错 1000 台设备的状态。这个代价比磁盘大得多。
三、ACL:别写 #,算一下它的爆炸半径
1. 机制本身
ACL(Access Control List)管的是已认证客户端能干什么 ------ 能发布或订阅哪些主题。核心是三个维度:Client ID / 主题 / 操作(读写)。
主流 Broker 都支持。以 Mosquitto 的配置格式为例:
bash
# 允许该客户端订阅自己设备的状态主题
user device_001
topic read sensor/device_001/#
# 允许平台服务读写全量(慎用!)
user platform_svc
topic readwrite #
2. 算一下 # 到底放开了多少
为了有具体数字,我构造了一个真实规模的主题空间:
bash
4 类设备(sensor / actuator / gateway / camera)
× 8 台(device_001 ~ device_008)
× 4 类消息(telemetry / command / status / event)
= 128 个主题
主题形如 sensor/device_001/telemetry/temperature。然后按 MQTT 的匹配规则(+ 匹配单层,# 匹配剩余所有层)算每条 ACL 规则命中的主题数:
| ACL 规则 | 命中主题 | 覆盖率 |
|---|---|---|
# |
128 / 128 | 100% |
+/+/+/# |
128 / 128 | 100% |
sensor/+/+/# |
32 / 128 | 25.0% |
actuator/+/command/# |
8 / 128 | 6.2% |
sensor/device_001/+/# |
4 / 128 | 3.1% |
sensor/device_001/telemetry/temperature |
1 / 128 | 0.8% |
# 和精确规则之间差了 99.2%。 那 99.2% 就是越权面 ------ 一台被攻破的传感器,能读到摄像头的事件、能往执行器发指令。
而且注意中间两行:即使加了两级通配符,覆盖率仍在 25% 和 6.2%。通配符的收敛效率比直觉差得多 ,多写一个 + 并不等于收紧多少。
3. 订阅侧同样的问题
同样要留意:订阅用的过滤串也会放大范围。
bash
订阅 # -> 命中 128 / 128 (等于把整个系统都接进来)
订阅 sensor/# -> 命中 32 / 128
订阅 sensor/+/+/# -> 命中 32 / 128
订阅 sensor/device_001/# -> 命中 4 / 128
订阅一个宽通配符,意味着你不只接收多余消息、多耗流量和处理能力,还等于给自己开了一个巨大的信息面 ------ 一旦这个订阅者被攻破,泄密面就是它订阅过的全部。
4. 落地写法
- 按角色分账号,别用一个大账号通吃。设备端、平台端、用户端各自的 ACL 完全不同。
- 发布和订阅分开授权 。设备只需要
write自己的上行主题;平台需要read全量,但通常不需要write设备主题。 - 禁止
#出现在权限规则里(运维审计用的只读监听账号除外,且必须只读)。 - 只读监听要单独给账号,别复用业务账号。
回到那个 128 主题的空间,正确的规则长这样:
bash
user device_001
topic write sensor/device_001/telemetry/#
topic write sensor/device_001/status/#
topic read sensor/device_001/command/#
# 该设备无法触及 actuator/camera,也无法读其他设备
四、主题设计:借数据报的思维
1. 为什么主题设计是架构问题
主题在 MQTT 里干了三件活:
- 路由 ------ 决定消息投给谁(靠通配符匹配);
- 授权 ------ 决定谁能读写(ACL 就是按主题前缀配的);
- 组织 ------ 决定你和同事怎么理解这个系统。
这三件事都建立在主题字符串的结构上。结构定错了,后面 ACL 和扩展都跟着错。
2. 四段式结构
借鉴网络数据报的组织思路(来源 / 去向 / 类型 / 操作),主题可以拆成四段:
xml
<source>/<destination>/<type>/<command>
| 段 | 含义 | 示例值 |
|---|---|---|
| source | 消息从哪来 | sensor、gateway、dashboard |
| destination | 消息给谁 | device_123、time_series、real_time |
| type | 消息性质 | telemetry、command、event、config |
| command | 具体操作/指标 | temperature、set_state、alarm |
具体到一个传感器上报温度:
bash
sensor/device_123/telemetry/temperature
一条平台下发的继电器控制指令:
bash
dashboard/relay_456/command/set_state
3. 分层带来的好处
| 好处 | 怎么体现 |
|---|---|
| 订阅可控 | 上层用短前缀 sensor/device_001/# 只订一台设备;跨设备按类型用 sensor/+/+/# ------ 范围由你决定 |
| 授权可配 | ACL 直接按前缀给权限,不必逐条列举(见第三节) |
| 扩展无痛 | 新增设备类型只是加一层值,不用改结构 |
| 可读性 | 看主题就知道消息从哪来、到哪去、是什么 |
4. 命名规范
- 全小写 + 下划线 :MQTT 主题是大小写敏感 的,
Sensor/Device_1和sensor/device_1是两个不同的主题。混用迟早出事。 - 层级控制在 4~6 层:太浅表达力不够,太深匹配与调试都变慢。
- 每层语义单一 :别在 ID 里塞复合信息(比如
device_001_room3_typeB),拆开更灵活。 - 避免前导
/:/sensor/...和sensor/...是两个主题,前导斜杠会产生一个空的顶层,绝大多数情况下是错的。
五、五个安全误区,逐个说破
误区一:开了 TLS 就安全了
TLS 只保传输层 ------ 防窃听、防篡改、防中间人。它管不了:
- 谁在连接(认证)------ 攻击者可以合法建一条 TLS 连接,然后伪造 Client ID;
- 能干什么(授权)------ 认证通过不等于权限正确;
- 消息内容本身 ------ 有合法权限的客户端也可能发坏数据。
修法:传输层加密 + 应用层认证 + ACL 授权,三层都要。敏感 payload 再加一层端到端加密或签名。
误区二:密码明文或弱哈希传输
MQTT 协议本身不强制加密用户名密码字段。 所以:
- 明文传输:中间人直接读走;
- MD5 / SHA-1 等弱哈希:彩虹表可破,且不带盐的哈希可被撞库。
修法 :高强度哈希(SHA-256 及以上)+ 加盐;或直接上令牌(JWT / OAuth 2.0)、mTLS。无论如何,密码字段必须在 TLS 之上传输。
误区三:ACL 用 # 通配
已在第三节用数字说明:128 个主题里 # 命中 100%,而精确规则只命中 0.8%。这就是把整个系统的读写权交给一个可能被攻破的设备。
修法 :最小权限原则。规则精确到设备自己的前缀,别用全局 #。
误区四:Broker 自身没加固
大多数人只盯着"设备到 Broker"这一段,忽略了 Broker 自己:
- 默认管理员账号/密码没改;
- HTTP 管理接口裸奔;
- 版本老旧,已知漏洞没修;
- 1883 明文端口仍然对外开着。
修法:改默认凭据、管理接口上 HTTPS 且限源 IP、及时更新版本、只对必要网段开放端口、开启访问日志审计。
误区五:Client ID 塞了敏感信息且不校验唯一性
Client ID 在 CONNECT 报文里是明文字段。把 MAC 地址、IMEI、序列号直接当 Client ID,等于把这些信息播给链路上所有人 ------ 而这几个标识恰恰是可以用来伪造身份的。
另外,Client ID 不唯一会导致旧连接被顶掉 (MQTT 规定同 ID 后连接踢掉先连接)。这个特性可以被用来做会话劫持:攻击者用受害者的 Client ID 连接,把对方踢下线。
修法:Client ID 用随机串或 UUID,不含设备隐私信息;Broker 启用唯一性校验;身份靠证书/令牌,不靠 Client ID。
六、小结
| 主题 | 一句话 | 关键数字 / 做法 |
|---|---|---|
| 遗嘱 | 几乎不要钱,但检出延迟不是零 | CONNECT 仅增 35 字节 (一次性);检出延迟 = 最坏 1.5×KeepAlive |
| Keep Alive | 感知速度与待机功耗的直接取舍 | 30 s 档心跳是 900 s 档的 30 倍流量 |
| 保留消息 | 会腐烂的快照,风险在语义不在容量 | 100 主题净占 4.2 KB ;清除需发空 payload + Retain=1 |
| ACL | # 等于没做权限 |
128 主题空间里 # = 100% ,精确规则 = 0.8% |
| 主题设计 | 它是路由模型也是权限模型 | 四段式 source/destination/type/command,4~6 层,全小写 |
| 安全 | TLS 只是其中一层 | 传输加密 + 认证 + ACL + Broker 加固,缺一不可 |
能带走的四条:
- 协议给你能力,不给你取舍。 遗嘱、保留消息、ACL 都是机制,怎么配是工程判断。
- 把"免费的"算一遍再决定。 遗嘱 35 字节、保留消息 4.2 KB ------ 算完了才知道该担心的根本不是它们,是 Keep Alive 和 ACL 范围。
- 权限要能说出来"它排除了什么"。 说不出排除范围的规则,基本等于没限制。
- 主题结构是架构决策,不是命名风格。 它同时决定路由、授权和可扩展性。
参考资料
- MQTT Version 3.1.1 --- OASIS Standard(§3.1 CONNECT、§3.1.2.10 Keep Alive、§3.3 PUBLISH、§4.7 主题通配符)
- Mosquitto(8) --- man page(ACL 配置语法)
- RFC 8446 --- The Transport Layer Security (TLS) Protocol Version 1.3
更多技术文章在我的博客:hespethorn.github.io