MQTT API 看起来很简单:连接、订阅、发布。

真正麻烦的地方,通常不在这三个动作本身,而在网络断开、客户端重启、消费跟不上、Broker 重连这些边界场景里。边缘网关和数据转发服务里,MQTT 的可靠性往往不是靠某一个 QoS 配出来的,而是靠一整套处理习惯兜住的。

先说清楚“可靠”是什么意思

不同消息对可靠性的要求并不一样。

高频实时值少丢几条可能问题不大,因为下一条新值马上会覆盖旧值;告警事件不应该丢,但可以通过事件 ID 去重;设备状态通常只关心最新值;配置指令则既不能丢,也不能重复执行。

如果不区分这些业务语义,直接把所有消息都设成高 QoS,只会增加延迟和 Broker 压力,但不一定解决重复、乱序和业务幂等问题。

QoS 解决的是客户端和 Broker 之间的交付等级:

QoS 0:最多一次
QoS 1:至少一次
QoS 2:协议层面的恰好一次

它不保证订阅方处理成功,也不保证数据库事务已经提交。尤其是 QoS 1,本来就可能重复投递,所以消费端必须考虑幂等。

业务消息最好带一个稳定标识:

{
"messageId": "device-guid:event-type:sequence",
"deviceGuid": "...",
"occurredAt": "...",
"payload": {}
}

消费者可以按 messageId 去重,数据库也可以加唯一约束做最后兜底。否则消息重复到达时,只能靠日志猜它到底有没有处理过。

连接恢复不等于业务恢复

Client ID 很容易被低估。Broker 是靠 Client ID 识别客户端的,两个实例如果用了同一个 Client ID,就可能互相踢下线。现场看起来像网络不稳定,实际是客户端身份冲突。

连接状态也不能只靠发布前检查一次 IsConnected()。检查那一刻连接正常,不代表消息一定能发出去。连接模块至少要维护这些事件:建连成功、连接丢失、自动重连、重连完成、订阅恢复、发布失败。

业务层拿到的不是一个永远可用的客户端,而是一个随时可能变化的网络资源。发布接口必须返回明确结果:失败后是进入重试队列,还是允许丢弃,不能悄悄吞掉。

自动重连只恢复客户端到 Broker 的连接,不一定恢复订阅关系。若使用干净会话,断开后订阅会被 Broker 清理,重连后必须重新 SUBSCRIBE;即使使用持久会话,也要确认 Client ID、会话过期时间和客户端库的恢复策略。

更稳妥的做法是把订阅定义集中管理,在连接成功回调里统一检查和恢复。每次连接建立后统一恢复订阅,并记录每个订阅是否成功。不要把订阅散落在各个业务模块的启动代码里,否则重连流程很难完整重放。

队列、缓存和重试要按消息类型设计

另一个常见问题是发布速度跟不上生产速度。

生产者 -> 有界队列 -> 发布 Worker -> Broker

队列必须有上限。队列满以后怎么处理,要看消息类型:实时状态可以丢旧值,只保留最新值;告警要进本地持久队列;配置指令应该拒绝新请求并返回错误;历史补传则要主动降速。

无限增长的内存队列不是可靠,只是在把故障往后推。

如果消息需要跨进程重启保留,就不要只放内存。至少要落本地数据库或日志队列,并记录消息 ID、Topic、QoS、Payload、创建时间、重试次数、下次重试时间和业务优先级。网络恢复后也不能一股脑补发,历史消息需要限速,避免挤占实时消息。

重试也不能无限循环。网络超时可以退避重试:

1s -> 5s -> 30s -> 2m -> 10m

但序列化失败、Topic 非法、权限错误这类问题,通常继续重试也没意义。超过次数后应该进入死信区,保留失败原因,等待人工处理或专项补偿。

Topic、安全和观测都是接口的一部分

Topic 本身也要当成接口契约设计。

v1/{tenant}/{deviceGuid}/telemetry
v1/{tenant}/{deviceGuid}/event
v1/{tenant}/{deviceGuid}/status

不要把易变名称放进关键路径,也不要让同一层级有时表示设备类型、有时表示设备 ID。Topic 一旦被多个系统依赖,后面权限控制、通配符订阅和版本迁移都会受它影响。

日志里要能看见 Client ID、Topic、消息 ID 和失败环节,但不要完整打印敏感 Payload。

MQTT 的可靠性不来自某个 QoS 数字,而来自连接、会话、队列、幂等、缓存、安全和观测共同形成的闭环。QoS 只是其中一环,业务系统真正要兜住的是:消息能不能被正确处理,失败后能不能被看见,恢复后能不能继续。