☰
设备断电了平台还显示在线?MQTT 遗嘱消息(LWT)与离线判定踩坑实录
2026/10/11 1:45:32 网站建设 项目流程

一、问题:拔了电,设备还是"在线"

做设备接入平台的时候,我遇到一个很迷惑的现象:把模拟设备的 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)

原理拆开说:

  1. will_set只是登记,不立即发送。遗嘱内容存在 Broker 侧。
  2. 连接时keepalive=30表示设备承诺 30 秒内至少和 Broker 有一次心跳交互。超过 1.5 倍 keepalive 没有任何数据,Broker 判定连接异常断开。
  3. 判定异常断开的那一刻,Broker 代设备发布遗嘱——st/{pk}/{deviceId}主题上出现一条"offline"。
  4. 服务端订阅了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);

五、修复二:遗嘱不是银弹,服务端还得有兜底

设完遗嘱就高枕无忧了吗?还不是。三个理由:

  1. Broker 自己也会挂。Broker 重启期间掉线的设备,遗嘱可能根本没机会发出来。
  2. 判定有延迟窗口。keepalive=30 意味着最坏情况要等 45 秒左右才判死。对温度传感器无所谓,对告警类场景可能太慢。
  3. "在线"的语义应该跟着数据走。设备连着但长时间不上报数据,对业务来说和死了没区别。

所以成熟平台都会在服务端加一层心跳超时兜底:记录每台设备最后一次上报时间,定时扫描,超时就强制置离线。思路如下(贴合项目现有结构的新增代码):

// 兜底方案:定时扫描长时间未上报的设备(新增 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 断线重连,这一篇是它的姊妹篇——设备侧的生死判定。欢迎关注,下期见。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询