发布订阅:设备为什么不必直连
物联网设备数量多、网络差、算力弱,如果用“设备直连设备”的模型,每新增一个设备就要维护一张连接表,任何一端掉线都会牵连另一端。MQTT 用发布订阅解开了这个耦合:所有设备只和 Broker 建立一条连接,发布者把消息发到某个主题,订阅了该主题的客户端就会收到。发送方不需要知道接收方是谁、有几个、在线与否。
MQTT 的核心只有三个词:主题(Topic)、Broker、以及客户端。设备之间从不互相认识,它们只认识中间那台 Broker——这既降低了耦合,也让“离线消息”“一对多广播”成为天然能力。
它跑在 TCP 之上,报文头最小只有两个字节,非常适合窄带、弱网、低功耗场景。代价是语义上的取舍:QoS 越高越可靠,但握手往返也越多;会话越持久越不丢消息,但 Broker 需要为它保存更多状态。理解这些取舍,才谈得上“用对 MQTT”。
连接:ClientID、心跳与持久会话
客户端连接时有几个参数必须想清楚,它们决定了断线重连后的行为。
- ClientID:Broker 用它识别会话。同一 ClientID 重复连接会把前一个连接踢掉,因此绝不能两台设备共用同一个 ID。
- Keep Alive:心跳间隔(秒)。客户端在空闲时按此间隔发送 PINGREQ,Broker 超过约 1.5 倍时间没收到任何报文就判定离线并触发遗嘱。
- Clean Session / Clean Start:为 false(MQTT 5 里配合 Session Expiry)时,Broker 会保存订阅关系与未确认消息,重连后继续投递。
# pip install paho-mqtt
import json
import paho.mqtt.client as mqtt
def on_connect(client, userdata, flags, rc):
print("已连接, rc =", rc)
client.subscribe("site/jjy/devices/+/telemetry", qos=1) # + 匹配单层
def on_message(client, userdata, msg):
data = json.loads(msg.payload.decode("utf-8"))
print(msg.topic, "->", data)
client = mqtt.Client(client_id="gw-jjy-0001", clean_session=False)
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.example.com", 1883, keepalive=60)
client.loop_forever()
clean_session=False 的意义在于:设备短暂断网期间,Broker 会把 QoS 1/2 的消息排队,重连后补发。但要注意队列是有上限的,长时间离线仍会丢消息,因此对关键数据仍需在设备侧落盘重传。
QoS:三级语义与选择标准
QoS 是发布和订阅各自声明的,最终生效的是两者中较低的一级。它不是“质量等级”,而是“投递承诺”。
| 等级 | 语义 | 报文往返 | 典型用途 |
|---|---|---|---|
| QoS 0 | 至多一次,发完即忘 | 1 次 | 高频传感器采样,丢一两帧无所谓 |
| QoS 1 | 至少一次,可能重复 | 2 次(PUBLISH / PUBACK) | 常规遥测上报,业务侧做幂等 |
| QoS 2 | 恰好一次 | 4 次(含 PUBREC / PUBREL / PUBCOMP) | 计费、开关指令等不能重复的动作 |
# 发布时指定 QoS 与保留标志
client.publish("site/jjy/devices/dev-01/telemetry",
payload=json.dumps({"t": 23.5, "h": 48, "ts": 1760000000}),
qos=1, retain=False)
# 订阅时也可以指定 QoS,Broker 会按下游申请的最低 QoS 投递
client.subscribe("site/jjy/devices/dev-01/cmd", qos=2)
实践建议:绝大多数场景用 QoS 1 就够了,重复消息由业务侧用消息 ID 做幂等去重;把 QoS 2 留给真正不能重复的动作。全链路 QoS 2 在高并发下会显著增加 Broker 的内存与往返压力,很多现场故障就是这么来的。
保留消息与遗嘱消息
这两个特性是 MQTT 最实用、也最容易被忽略的部分。
# 1) 保留消息:Broker 保存该主题最后一条 retain=True 的消息
client.publish("site/jjy/devices/dev-01/state",
payload="online", qos=1, retain=True)
# 之后任何新订阅者立刻会收到这条消息,无需等待设备下一次上报
# 2) 遗嘱消息:连接时声明,异常断开由 Broker 代发
client.will_set("site/jjy/devices/dev-01/state",
payload="offline", qos=1, retain=True)
client.connect("broker.example.com", 1883, keepalive=60)
# 注意:客户端主动调用 disconnect() 时不会发送遗嘱
- 保留消息解决“迟到”监控页面上线时不会一片空白,因为 Broker 手上就存着各设备最后一次状态。
- 遗嘱消息解决“失联”设备断电、网线拔出这类异常,能立刻在平台上呈现为离线,不必等业务层超时判断。
- 注意清除保留消息会一直存在,业务下线时记得向该主题发一条空载荷且 retain=True 的消息来清除。
把“在线状态”做成 retain + will 的组合,是物联网平台里最常见也最优雅的实现方式:一条主题承载状态,两种机制分别覆盖首次订阅与异常断开。
主题设计:通配符与命名规范
主题使用斜杠分层,客户端可以用通配符订阅一批设备。通配符只有两个:+ 匹配单层,# 匹配多层且只能出现在末尾。
site/jjy/devices/dev-01/telemetry
site/jjy/devices/dev-02/telemetry
site/jjy/devices/dev-03/telemetry
# 订阅同一个产品的所有设备遥测
site/jjy/devices/+/telemetry # 匹配 dev-01 / dev-02 … 但不匹配 dev-01/raw/x
# 订阅某个设备下所有子主题
site/jjy/devices/dev-01/# # 匹配 telemetry、cmd、raw/temp …
# 系统主题(Broker 自身指标),不要用于业务
$SYS/broker/clients/connected
主题规划要在项目初期定好,因为它一旦上线就很难改。推荐的结构是“租户/产品/设备/g功能”:前缀固定便于 ACL 授权,功能层级放在最后便于批量订阅。要避免的做法包括用中文或空格、把随机值放在中间层(无法使用 + 通配)、以及把设备 ID 反过来放(同一产品的设备无法批量订阅)。
用 Mosquitto 搭一个可用的 Broker
本地验证最快的方式是用 Mosquitto:装好之后,写一份配置文件就能得到一个带认证与 ACL 的 Broker。
# mosquitto.conf
listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd
acl_file /etc/mosquitto/acl
# 需要 TLS 时再加一段(证书路径按实际替换)
# listener 8883
# cafile /etc/mosquitto/certs/ca.crt
# certfile /etc/mosquitto/certs/server.crt
# keyfile /etc/mosquitto/certs/server.key
# 创建用户与授权文件
mosquitto_passwd -c /etc/mosquitto/passwd dev01
mosquitto_passwd -b /etc/mosquitto/passwd gateway s3cret
# acl 文件:主题级读写控制
cat /etc/mosquitto/acl
user dev01
topic readwrite site/jjy/devices/dev-01/#
user gateway
topic read site/jjy/devices/+/telemetry
topic write site/jjy/devices/+/cmd
topic read $SYS/broker/clients/connected
# 启动与自检
mosquitto -c /etc/mosquitto/mosquitto.conf -v
mosquitto_sub -h localhost -u gateway -P s3cret -t 'site/jjy/devices/+/telemetry' -v
mosquitto_pub -h localhost -u dev01 -P dev01pw -t 'site/jjy/devices/dev-01/telemetry' \
-m '{"t":23.5}' -q 1
先用 mosquitto_sub / mosquitto_pub 把链路打通,再写业务代码,是最省时间的顺序。用命令行客户端能立刻区分“是 Broker 配置问题”还是“是我的代码问题”。
安全:TLS、认证与授权三层
把设备直接暴露在公网并允许匿名连接,等着它的就是被当成肉鸡。安全要分三层做,缺一层都不算及格。
- 传输层 TLS:使用 8883 端口,客户端校验服务端证书;高安全场景再叠加客户端证书做双向认证。
- 认证:一机一账号一密码,或使用设备证书 / Token 换取临时凭据。禁止多设备共用账号。
- 授权:用 ACL 把每个账号锁在自己的主题前缀内,让设备只能读写自己的主题,出入口主题分别授权。
# 客户端启用 TLS(paho-mqtt)
client.tls_set(ca_certs="certs/ca.crt") # 校验服务端证书
client.username_pw_set("dev01", "dev01pw")
client.connect("broker.example.com", 8883, keepalive=60)
另外两条现实经验:账户泄漏往往来自固件里的硬编码密码,所以量产设备应该走“首次上电凭证换取”的流程;服务端侧务必开启连接数与发布速率的限制,否则单台设备异常刷数据就能把 Broker 打满。
实战:上报与指令的完整闭环
把前面几节拼起来,就是一台设备与平台之间最小但完整的通信闭环。设备侧负责上报遥测、订阅指令、维护在线状态;平台侧负责接收、下发与状态同步。
import json, time, ssl
import paho.mqtt.client as mqtt
DEV = "dev-01"
BASE = "site/jjy/devices/" + DEV
STATE, TELEMETRY, CMD, ACK = BASE + "/state", BASE + "/telemetry", BASE + "/cmd", BASE + "/ack"
def on_connect(c, u, flags, rc):
c.subscribe(CMD, qos=1)
c.publish(STATE, "online", qos=1, retain=True) # 上线状态,新订阅者立即可见
def on_message(c, u, msg):
cmd = json.loads(msg.payload.decode())
print("收到指令:", cmd)
ok = handle(cmd)
c.publish(ACK, json.dumps({"id": cmd.get("id"), "ok": ok}), qos=1)
def handle(cmd):
if cmd.get("action") == "reboot":
schedule_reboot()
return True
return False
client = mqtt.Client(client_id="dev-" + DEV, clean_session=False)
client.username_pw_set("dev01", "dev01pw")
client.tls_set(ca_certs="certs/ca.crt")
client.will_set(STATE, "offline", qos=1, retain=True) # 异常断线自动置为离线
client.on_connect, client.on_message = on_connect, on_message
client.connect("broker.example.com", 8883, keepalive=60)
client.loop_start()
while True:
client.publish(TELEMETRY, json.dumps({"t": read_temp(), "ts": int(time.time())}), qos=1)
time.sleep(30)
这段代码同时用到了持久会话、保留消息、遗嘱、QoS 1 与指令确认,是一个可以直接改造上线的骨架。真正上线时还需要补三样:断线自动重连与退避、上报失败时的本地缓存、以及指令幂等(同一指令 ID 重复收到只执行一次)。