MQTT 的四个工程暗礁:遗嘱、保留消息、ACL 与主题设计

上一篇文章我把 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 遗嘱是否作为保留消息发出

关键语义有两处,很多实现细节都从这儿长出来:

  1. 只有异常离线才触发。 客户端主动发 DISCONNECT 报文正常退出,Broker 不会发遗嘱。所以遗嘱天然表达的是"我非正常没了",不是"我下线了"。
  2. 触发时刻不是瞬间。 规范 §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 里干了三件活:

  1. 路由 ------ 决定消息投给谁(靠通配符匹配);
  2. 授权 ------ 决定谁能读写(ACL 就是按主题前缀配的);
  3. 组织 ------ 决定你和同事怎么理解这个系统。

这三件事都建立在主题字符串的结构上。结构定错了,后面 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 加固,缺一不可

能带走的四条:

  1. 协议给你能力,不给你取舍。 遗嘱、保留消息、ACL 都是机制,怎么配是工程判断。
  2. 把"免费的"算一遍再决定。 遗嘱 35 字节、保留消息 4.2 KB ------ 算完了才知道该担心的根本不是它们,是 Keep Alive 和 ACL 范围。
  3. 权限要能说出来"它排除了什么"。 说不出排除范围的规则,基本等于没限制。
  4. 主题结构是架构决策,不是命名风格。 它同时决定路由、授权和可扩展性。

参考资料

更多技术文章在我的博客:hespethorn.github.io

相关推荐
打工仔折腾 AI1 小时前
Spring AI + Qwen 周报生成实战:从原始日志到人工确认卡
人工智能·后端·python·spring·elasticsearch·ai agent 实战
杨文说AI与数字化1 小时前
分时预约系统的库存模型:为什么'加个预约功能'必然出现超卖与假满
后端
Darling噜啦啦1 小时前
# MySQL + Milvus = PostgreSQL?一张表搞定 AI Agent 的长时记忆(附完整代码)
后端·postgresql·orm
用户713874229001 小时前
在 .NET 中使用 AWS Secrets Manager:Config 与 Client 详解
后端
福大大架构师每日一题1 小时前
2026-10-16:交替数列的最大元素。用go语言,给定三个整数 n、s、m。现在要构造一个包含 n 个整数的序列,其中第一个数必须等于 s。序列中相邻数字的
后端
yunwei371 小时前
eBPF 入门实践教程:BPF Token,安全的委托式权限与程序加载
linux·后端·性能优化
136096757231 小时前
旧 SQL 在新 PG 上能不能跑
后端
SamDeepThinking1 小时前
配货单发货数为空告警误报治理:写表延迟复核方案
java·后端·架构
知守观1 小时前
mirrorOf= 把私服请求拦去了公网镜像:本地能跑,CI 上内部包 404 排查
后端·ci/cd·maven