一、问题:拔了电,设备还是"在线"
做设备接入平台的时候,我遇到一个很迷惑的现象:把模拟设备的 Python 进程用任务管理器直接杀掉——模拟断电、断网这种"非正常死亡"——几分钟后打开看板,设备状态还端端正正地挂着"在线"两个绿字。
第一反应是看板没刷新。但刷新接口返回的 JSON 里,status字段就是"online"。数据库里它就是在线的。
设备已经不在了,平台凭什么认为它在线?这篇文章就是排查这个问题的完整过程,涉及三段真实代码和 MQTT 里一个容易被"以为做了"的机制:遗嘱消息(Last Will and Testament,LWT)。
二、先搞清现状:在线/离线是怎么判定的
翻自己项目(Spring Boot 3.5 + EMQX)的代码,在线和离线的判定是两条完全不同的路。
在线判定:服务端作为 MQTT 订阅者,收到设备的遥测数据就把它标记为在线。来自TelemetryMessageHandler.java:
// 来源:src/main/java/com/iothub/mqtt/TelemetryMessageHandler.javaprivatevoidhandleTelemetry(Devicedevice,StringproductKey,Stringpayload){// 校验是合法 JSON,不合法直接丢弃(防脏数据进库)try{objectMapper.readTree(payload);}catch(Exceptione){log.warn("payload 不是合法 JSON,丢弃: {}",payload);return;}device.setStatus("online");deviceRepository.save(device);// ... 后面是入库逻辑}这个设计有个好处:收到数据 = 设备一定在线,比额外做心跳轮询更及时、更省事。
离线判定:设备主动在状态主题st/{productKey}/{deviceId}上发一条"offline",服务端更新状态:
// 来源:src/main/java/com/iothub/mqtt/TelemetryMessageHandler.javaprivatevoidhandleStatus(Devicedevice,Stringpayload){Stringstatus="offline".equalsIgnoreCase(payload.trim())?"offline":"online";device.setStatus(status);deviceRepository.save(device);log.info("设备[{}]状态变更为 {}",device.getId(),status);}服务端启动时订阅了up/+/+和st/+/+两个通配主题(见MqttConnection.java的connectComplete回调,QoS 都是 1),所以这两条路都能收到消息。
问题就出在离线判定上:它依赖设备"自己"告诉平台我要下线了。看设备端(模拟设备的 Python 脚本,paho-mqtt 实现)是怎么发这条消息的:
# 来源:scripts/demo_device.py(节选)# 干净退出:发个离线遗嘱状态再断开client.publish("st/%s/%s"%(args.product_key,device_id),"offline",qos=1)time.sleep(0.3)client.loop_stop()client.disconnect()看到这我直接绷不住了:这条 offline 只在用户按 Ctrl+C 正常退出时才会执行。任务管理器杀进程、网线被拔、设备断电——任何一种"非正常死亡",这段代码一行都不会跑,offline 消息自然永远不会发出来。
更讽刺的是,项目 README 里明晃晃写着:
- 设备上下线:
st/{productKey}/{deviceId}主题(遗嘱消息 payload=offline)
勾打了,字写了,但遗嘱从来没设置过。这大概是独立开发最常见的一种翻车:文档先于实现,自己骗自己。
三、设备"死"的两种方式,决定了你需要什么机制
整理一下,设备的下线其实分两类:
| 下线方式 | 设备还有机会发消息吗 | 现有代码的表现 |
|---|---|---|
| 干净断开(Ctrl+C、优雅关机) | 有 | ✅ 手动发 offline,状态正确 |
| 非正常死亡(断电、杀进程、断网) | 没有 | ❌ 状态永远停在"online" |
对付第二类,靠设备自己发消息是不可能的——它已经死了。这时候需要 MQTT 的遗嘱消息:设备连接 Broker 时预先登记一条"遗言",之后由Broker 检测到设备异常离线时代它发布。
四、修复一:设置遗嘱消息(设备端 3 行代码)
paho-mqtt 里就是在connect之前调用will_set:
# 修复代码:连接前登记遗嘱(demo_device.py)client.will_set("st/%s/%s"%(args.product_key,device_id),# 遗嘱发到状态主题"offline",# payload 和手动下线保持一致qos=1,)client.connect(args.host,args.port,keepalive=30)原理拆开说:
will_set只是登记,不立即发送。遗嘱内容存在 Broker 侧。- 连接时
keepalive=30表示设备承诺 30 秒内至少和 Broker 有一次心跳交互。超过 1.5 倍 keepalive 没有任何数据,Broker 判定连接异常断开。 - 判定异常断开的那一刻,Broker 代设备发布遗嘱——
st/{pk}/{deviceId}主题上出现一条"offline"。 - 服务端订阅了
st/+/+,handleStatus收到后照常更新状态,服务端一行代码都不用改。
这也是遗嘱设计的妙处:它把"发现设备死了"这件事交给了最合适的角色—— Broker。设备生死只有 Broker 最清楚,业务服务端没必要自己猜。
服务端唯一的要求是订阅遗嘱主题,项目里已经有了(来源MqttConnection.java):
// 来源:src/main/java/com/iothub/mqtt/MqttConnection.java(节选)String[]topics={props.getTelemetryTopic(),props.getStatusTopic()};// up/+/+ 和 st/+/+int[]qos={1,1};IMqttTokentoken=client.subscribe(topics,qos);token.waitForCompletion(10_000);五、修复二:遗嘱不是银弹,服务端还得有兜底
设完遗嘱就高枕无忧了吗?还不是。三个理由:
- Broker 自己也会挂。Broker 重启期间掉线的设备,遗嘱可能根本没机会发出来。
- 判定有延迟窗口。keepalive=30 意味着最坏情况要等 45 秒左右才判死。对温度传感器无所谓,对告警类场景可能太慢。
- "在线"的语义应该跟着数据走。设备连着但长时间不上报数据,对业务来说和死了没区别。
所以成熟平台都会在服务端加一层心跳超时兜底:记录每台设备最后一次上报时间,定时扫描,超时就强制置离线。思路如下(贴合项目现有结构的新增代码):
// 兜底方案:定时扫描长时间未上报的设备(新增 DeviceOfflineScanner.java 思路)@Scheduled(fixedDelay=30_000)publicvoidsweepStaleDevices(){Instantthreshold=Instant.now().minusSeconds(90);// 3 倍上报间隔// 上报时更新 lastReportAt 字段(Device 实体新增),超时的置 offlineList<Device>stale=deviceRepository.findByStatusAndLastReportAtBefore("online",threshold);for(Deviced:stale){d.setStatus("offline");log.warn("设备[{}]超过 {}s 未上报,判定离线",d.getId(),90);}deviceRepository.saveAll(stale);}三个方向合在一起,才是完整的离线判定体系:
干净断开 → 设备自己发 offline(主动) 非正常死亡 → Broker 代发遗嘱 LWT(被动,秒~分钟级) 长时间不上报 → 服务端心跳超时扫描(兜底,业务可配)六、小结与面试考点
这次踩坑的本质:把"设备的死活"寄托在死者自己的遗言上,而遗言根本没立。修复后两条被动链路(遗嘱 + 超时扫描)都指向同一个状态主题,服务端逻辑保持单一。
面试时可以主动讲的几个点:
- 为什么在线判定用"收到数据即在线":最及时,且不需要额外心跳协议;代价是需要离线侧机制补齐。
- 遗嘱 LWT 的触发条件:keepalive 超时、TCP 异常断开、协议错误导致断连;设备主动
DISCONNECT不会触发遗嘱(这是和"异常断开"的关键区别,也是为什么干净退出要手动发 offline)。 - keepalive 怎么选:太短误判多(弱网设备频繁"假死"),太长离线感知慢;一般取上报周期的 2~3 倍。
- 多层兜底的思维:认证、鉴权、状态判定都是同一原则——不假设上一层一定生效,各层各拦各的。
作者:软件工程在读(专升本),正在从零搭一套物联网设备接入平台(Spring Boot + EMQX + MySQL),踩过的坑都会写成文章。上一篇写了 MQTT 断线重连,这一篇是它的姊妹篇——设备侧的生死判定。欢迎关注,下期见。