深入专题:MQTT 与设备状态¶
这篇把协议层连接、MQTT 会话、业务在线状态、设备影子和命令回执放在同一张图中。
很多 IoT 系统的问题来自把不同层的“状态”混为一谈:
它们有关联,但不是同一个事实。
第一层:从设备建立连接开始¶
简化流程:
TCP 已连接¶
只说明两端存在传输连接。设备业务进程可能卡住,仍未及时上报有效数据。
MQTT 已连接¶
说明 Broker 接受了客户端身份和连接参数。仍不代表设备所有传感器、业务模块都健康。
业务在线¶
应该由产品定义:
最近 90 秒收到有效心跳,并且身份未冻结,视为在线。
业务在线是推导状态,不是协议直接给出的永恒真相。
Client ID 与身份¶
Broker 使用 Client ID 标识 MQTT 客户端会话,但 Client ID 本身不是安全凭据。
如果两个连接使用相同 Client ID,很多 Broker 会让新连接替换旧连接。可能出现:
- 设备被错误配置成相同 ID;
- 攻击者抢占会话;
- 设备快速重连导致旧连接被踢;
- 在线/离线事件快速抖动。
需要同时设计:
- Client ID 唯一性;
- 每设备认证凭据;
- Topic ACL;
- 重复连接策略;
- 审计。
QoS 1 为什么会重复¶
QoS 1 简化交互:
如果 Broker 已收到并处理 PUBLISH,但 PUBACK 丢失:
协议层根据 packet_id 处理当前会话中的交付流程,但业务系统仍可能在 Broker 之后收到重复投递,尤其跨重连、桥接、规则转发和业务 MQ 时。
业务消息需要独立 message_id:
{
"message_id": "device-1001-boot-72-seq-8801",
"device_id": "1001",
"boot_id": 72,
"seq": 8801,
"timestamp": 1753761600
}
boot_id + seq 帮助区分设备重启后的序号重新开始。
QoS 2 为什么也不等于业务恰好一次¶
QoS 2 提供 MQTT 协议交付范围内的 exactly once 流程,但后续链路可能是:
任意后续组件仍可能重试或重复。因此关键业务仍使用幂等。
Session 解决断线后的什么¶
持久 Session 可以保存:
- 订阅;
- 未完成 QoS 1/2 流程;
- 离线期间符合条件的消息。
Session 不等于消息历史数据库,也不能无限保存。
需要配置:
- Session Expiry;
- 离线消息上限;
- 消息过期;
- 单设备积压;
- 重连后的流量控制。
一台设备离线一周后重连,如果补发海量过期命令,可能造成危险。
Retained Message 与设备影子¶
Retained Message 可以保存 Topic 最后一条值:
它适合快速把最新值给新订阅者,但完整设备影子还需要:
- desired;
- reported;
- version;
- 更新时间;
- 修改来源;
- 权限;
- 冲突处理;
- 历史。
Retain 是协议能力,影子是业务模型。可以配合,不能等同。
Will 与离线判断的竞态¶
场景:
10:00:00 网络断开
10:00:02 Broker 发布 Will=offline
10:00:03 设备通过 4G 重连
10:00:04 新心跳 online
10:00:05 旧离线事件晚到状态服务
如果状态服务无条件写 offline,最终状态错误。
解决需要状态版本:
也可以在处理离线前再次检查最新连接和 last_seen。
心跳、Keep Alive 与遥测¶
Keep Alive¶
协议层判断连接空闲是否异常。
业务心跳¶
可以携带:
- boot_id;
- 固件版本;
- uptime;
- 网络质量;
- 存储/电量;
- 业务模块健康;
- 时间同步状态。
遥测¶
业务数据到达也能证明设备在某个时间点活跃,但若某类设备长时间无遥测,不能只靠遥测判断在线。
在线判断可以组合多信号:
最终规则必须可解释。
TTL 方案的内部边界¶
每次心跳:
查询 key 存在非常快,但要理解:
- Redis 过期不是严格实时调度;
- key 消失不会自动更新数据库;
- key 消失不会自动从 Set 移除;
- Keyspace Notification 不应成为唯一可靠业务事件;
- Redis 故障后 key 需要重建。
因此完整设计常见两条路径:
ZSet 超时调度¶
心跳:
Worker:
为什么再次检查?
设备可能在 Worker 取出后、真正处理前刚好重连并更新 deadline。
并发 Worker 需要:
- 分片;
- 领取 lease;
- 条件更新;
- 幂等迁移;
- 失败重试。
命令下发的完整语义¶
平台要重启设备:
1. 创建 command_id
2. 检查用户、租户、设备权限
3. 保存 created
4. 发布 MQTT command
5. Broker 接管 → dispatched
6. 设备收到 → received
7. 设备开始 → executing
8. 重启后上报 → succeeded
Broker PUBACK 只能帮助确认第 5 步附近的协议交付,不能代替第 8 步业务回执。
重复命令¶
设备端也需要记录最近 command_id:
只在云端幂等不够,因为云端可能重新发布。
大规模同时重连¶
十万设备因网络恢复在一分钟重连:
每次可能触发:
- TLS;
- 鉴权数据库/缓存;
- Session 恢复;
- 多个订阅;
- 上线事件;
- 补发消息;
- 配置同步。
连接本身不是唯一负载。要限制:
- 随机重连退避;
- 每设备离线队列;
- 会话和消息过期;
- 鉴权缓存;
- 状态事件去抖;
- 下游消费并发。
完整状态模型¶
连接事实:
connected/disconnected + connection_epoch
业务在线:
unknown/offline/online + last_seen + version
设备影子:
desired_version / reported_version
命令:
created/dispatched/received/executing/succeeded/failed/timeout
不要用一个 status 字段承载所有含义。
深度自测¶
- TCP connected、MQTT connected、业务 online 为什么不同?
- QoS 1 的重复发生在哪个故障窗口?
- QoS 2 为什么不保证数据库业务端到端恰好一次?
- Retained Message 与设备影子的差别是什么?
- Will 离线事件晚到时怎样避免覆盖新上线?
- TTL key 为什么不能独立产生可靠离线事件?
- ZSet Worker 为什么在迁移前再次检查 deadline?
- Broker PUBACK 为什么不能表示设备已经重启?
参考答案要点
- 分别是传输、协议会话和业务规则推导的不同层事实。
- PUBLISH 已处理而 PUBACK 丢失,发布者会重发。
- Broker 后仍有规则、HTTP、MQ、消费者和数据库等重试边界。
- Retain 只保存 Topic 最后一条;影子包含 desired/reported、版本、权限和冲突。
- 使用连接 epoch/状态版本,或迁移前检查最新连接和 last_seen。
- 过期不严格实时,也不会自动执行数据库迁移和集合维护。
- 设备可能在领取和执行之间已经刷新心跳。
- 它只确认协议交付,不确认设备业务动作完成。
完成标准¶
- 能画出六种不同状态
- 能推导 QoS 1 重复
- 能设计带 boot_id/seq 的业务消息
- 能处理 Will 与重连竞态
- 能设计 TTL 查询路径和 ZSet 迁移路径
- 能设计命令回执和设备端幂等