MQTT 的连接状态和"设备在线"这个业务概念不是一回事。直接用连接/断开事件驱动设备状态会导致频繁抖动——用户界面上设备一会儿在线一会儿离线,或者后台任务反复重试,都源于这个混淆。
问题的根源在于混淆了网络协议层和业务层的两个不同概念。MQTT broker 记录的"连接"是 TCP 会话的建立,而业务上的"设备在线"应该表达"设备功能可用"。两者之间隔着网络传输、消息队列、客户端重连等多个可能出错的环节。
三个源头
第一个是网络抖动。 网络瞬时中断会触发MQTT连接断开,但通常在秒级内重新连上。MQTT客户端库一般都有自动重连机制,收到断开通知后立即尝试重连。如果业务逻辑依赖连接事件,这短暂的断开就被当作"离线"上报,导致状态反复切换。例如设备在某个Wi-Fi信号不稳定的位置,可能每分钟产生3-5次连接/断开事件。用户看到的就是设备状态在一分钟内闪烁十几次。
这种抖动在移动设备上特别明显。设备从一个Wi-Fi热点移动到另一个、或从Wi-Fi切到蜂窝网络、再切回Wi-Fi,每一次切换都会产生一次短暂的网络中断。后台的指令重试、日志上报等任务也会反复启动和取消,浪费电量和网络带宽。
第二个是遗嘱消息的延迟。 MQTT 的 Last Will and Testament(LWT)机制是这样的:客户端连接时向服务端注册一条遗嘱消息,如果客户端异常断开(而不是正常关闭连接),服务端会在一个keep-alive超时周期后代为发布这条消息。超时时间由客户端的 keep-alive 参数决定,通常是60秒。即使是10秒的超时,对于用户感知来说也是明显的延迟——设备下线了10秒后才被标记离线。
更糟的是消息到达顺序问题。设备先由于网络抖动而异常掉线,服务端记录下这个事件但还在等待超时才能发 LWT。此时设备已经重新连上,上报了新的连接事件和"我在线"的心跳。但随后 LWT 消息才被投递,这条迟到的遗嘱消息被用来更新状态,刚刚上线的设备又被判成离线。这就是"死而复生"的情况。要修复它,需要确保消息的因果顺序被正确处理——新连接的消息必须总是覆盖旧连接的 LWT,而不是相反。
第三个是重连时的会话交叠。 待补:会话标识的具体场景描述。简单说,客户端断开后重新连接的过程中,旧连接和新连接在短时间内可能同时存活在服务端。MQTT 服务端通常用客户端 ID(client ID)来唯一标识一个逻辑客户端,但不区分"同一个 ID 的第N次连接"是旧连接还是新连接。如果处理不当,新连接的消息和旧连接的清理事件可能乱序处理:新连接发来"我在线",但旧连接的"断开"事件此时才被处理,状态又变成离线。或者旧连接还没来得及发送 LWT,新连接就已经改变了状态,但后续 LWT 消息被错误地应用到了新连接之上。这会导致整个状态机陷入不一致。
三层防护
第一层:去抖窗口。 不要在收到单个连接/断开事件时立即改变设备状态。改为记录事件的时间戳,再用一个去抖窗口来过滤(典型值:5-15秒)。只有状态在窗口期内保持一致,才最终确认这个状态变更。这样可以吸收网络毛刺和短暂的异常断开。例如设备在5秒内出现"连->断->连"的抖动,去抖窗口可以忽略这个抖动,最终确认设备仍然在线。
但这只是表面文章。去抖窗口治标不治本——它隐藏了问题,但没有从根本上解决状态不一致的根源。如果关键业务逻辑(如指令下发)直接依赖这个抖动的状态,去抖5秒和去抖15秒的区别就可能是收到指令和没收到的区别。真正的防护靠后面两层。
第二层:心跳而非连接事件。 这是关键转换。设备不依赖 MQTT 连接本身来表示"在线",而是定期上报心跳消息。例如设备每30秒上报一条心跳消息到专用 topic(如 toynet/device/{deviceSn}/heartbeat),服务端通过消费这个 topic 来更新设备的 lastHeartbeat 时间戳。设备在线的判据变成:lastHeartbeat < 现在 - 90秒 则离线,否则在线。
90秒是容错窗口的设计。设备每30秒上报一次,两个周期加上网络延迟最多60秒,保留30秒的余量。这意味着即便连接反复震荡,甚至一次心跳消息丢失,只要没有连续90秒没有心跳,设备就保持在线。这样既吸收了网络抖动,也给了消息传输足够的容错空间。
心跳本身是一条普通的业务消息,不绑定 TCP 连接的生命周期。即使 MQTT 连接因为网络抖动断开又重连了,只要心跳消息能被投递到,服务端就能正确判断设备的实际状态。这打破了"连接 = 在线"的一对一映射,引入了一个独立的信号源。
第三层:会话标识隔离旧连接。 待补:具体的会话标识方案、实现细节。原理是:每次连接时生成一个会话标识符(可以是 UUID、或连接时间戳+随机数),客户端在上报心跳和其他消息时携带这个会话 ID。服务端收到消息时记录当前连接的会话 ID,收到断开事件也记录对应的会话 ID。关键是:只有来自"当前会话"的消息和事件才被用来更新设备状态,来自旧会话的事件直接丢弃。
这防止了会话交叠期间的状态混乱。例如旧连接的 LWT 消息迟到时,服务端检查发现它对应的会话 ID 是"session-old-123",而当前连接的会话 ID 是"session-new-456",就忽略这条 LWT,不用它来改变设备状态。新连接发来的"我在线"(会话 ID:"session-new-456")保持生效。这样即使 LWT 消息交错到达,也不会倒流状态。
落地要点
心跳超时的90秒数字不是随意设置。设备每30秒上报一次心跳,这意味着理想情况下服务端最多30秒收到一条。但网络不总是理想的——消息可能因为网络延迟延后10秒、或因为服务端消费队列繁忙而被延后处理15秒。设置90秒意味着:即便两个心跳周期(60秒)都加上网络和处理延迟的上界(30秒),还有30秒的最后安全边界。超出这个范围再判离线,误触发率控制在可接受的范围。
如果将超时设为30秒(仅一个周期),单次心跳延迟就可能触发误判。设为60秒可能看起来"更稳妥",但代价是真实离线的设备需要等60秒才被发现,影响业务响应速度。90秒是在响应速度和稳定性之间的平衡。
指令下发时,如果设备在线(基于心跳判断)则立即尝试下发。如果设备离线,需要根据业务需求决定是否入队。如果选择入队(例如对关键指令),要清楚地告知前端"设备离线,指令已记录但不保证立即送达"。设备重新在线后,优先处理队列中的待发指令。这避免了用户"我下发指令了为什么没反应"的困惑。
实装时还有一个易忽视的坑:MQTT broker 和客户端的 keep-alive 参数要协调。MQTT keep-alive 是 TCP 连接保活的参数,客户端每隔 keep-alive 时间必须向 broker 发送某条消息(包括 PING),否则 broker 可能会关闭连接。如果 broker 的 keep-alive 是60秒,但业务层的心跳周期是30秒,这两者之间没有冲突。但如果心跳周期是90秒,而 keep-alive 是60秒,TCP 连接可能在接收心跳之前就被 broker 主动断开。通常的做法是让客户端 keep-alive 小于业务心跳周期的一半,或者心跳消息本身也被用作 keep-alive 信号。
■