设备断电了平台还显示在线?MQTT 遗嘱消息(LWT)与离线判定踩坑实录

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

做设备接入平台的时候,我遇到一个很迷惑的现象:把模拟设备的 Python 进程用任务管理器直接杀掉------模拟断电、断网这种"非正常死亡"------几分钟后打开看板,设备状态还端端正正地挂着"在线"两个绿字。

第一反应是看板没刷新。但刷新接口返回的 JSON 里,status 字段就是 "online"。数据库里它就是在线的。

设备已经不在了,平台凭什么认为它在线?这篇文章就是排查这个问题的完整过程,涉及三段真实代码和 MQTT 里一个容易被"以为做了"的机制:遗嘱消息(Last Will and Testament,LWT)。

二、先搞清现状:在线/离线是怎么判定的

翻自己项目(Spring Boot 3.5 + EMQX)的代码,在线和离线的判定是两条完全不同的路。

在线判定 :服务端作为 MQTT 订阅者,收到设备的遥测数据就把它标记为在线。来自 TelemetryMessageHandler.java:

java 复制代码
// 来源:src/main/java/com/iothub/mqtt/TelemetryMessageHandler.java
private void handleTelemetry(Device device, String productKey, String payload) {
    // 校验是合法 JSON,不合法直接丢弃(防脏数据进库)
    try {
        objectMapper.readTree(payload);
    } catch (Exception e) {
        log.warn("payload 不是合法 JSON,丢弃: {}", payload);
        return;
    }

    device.setStatus("online");
    deviceRepository.save(device);
    // ... 后面是入库逻辑
}

这个设计有个好处:收到数据 = 设备一定在线,比额外做心跳轮询更及时、更省事。

离线判定 :设备主动在状态主题 st/{productKey}/{deviceId} 上发一条 "offline",服务端更新状态:

java 复制代码
// 来源:src/main/java/com/iothub/mqtt/TelemetryMessageHandler.java
private void handleStatus(Device device, String payload) {
    String status = "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 实现)是怎么发这条消息的:

python 复制代码
# 来源: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:

python 复制代码
# 修复代码:连接前登记遗嘱(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):

java 复制代码
// 来源:src/main/java/com/iothub/mqtt/MqttConnection.java(节选)
String[] topics = {props.getTelemetryTopic(), props.getStatusTopic()};  // up/+/+ 和 st/+/+
int[] qos = {1, 1};
IMqttToken token = client.subscribe(topics, qos);
token.waitForCompletion(10_000);

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

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

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

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

java 复制代码
// 兜底方案:定时扫描长时间未上报的设备(新增 DeviceOfflineScanner.java 思路)
@Scheduled(fixedDelay = 30_000)
public void sweepStaleDevices() {
    Instant threshold = Instant.now().minusSeconds(90);  // 3 倍上报间隔
    // 上报时更新 lastReportAt 字段(Device 实体新增),超时的置 offline
    List<Device> stale = deviceRepository.findByStatusAndLastReportAtBefore("online", threshold);
    for (Device d : 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 断线重连,这一篇是它的姊妹篇------设备侧的生死判定。欢迎关注,下期见。

相关推荐
做萤石二次开发的哈哈4 小时前
海康甲烷监测终端二次开发:四级能力门控、规则联动与两类异常事件的全链路拆解
物联网·二次开发·工业安全·蓝海aiot一站式工作台·aiot开发·危险气体监测·甲烷检测
阿乔外贸日记5 小时前
全球电子产业高度集中:中、韩、日和中国台湾地区占全球数字硬件产能八成
大数据·服务器·人工智能·物联网·云计算
北京盛世宏博大卤蛋6 小时前
多机房分布式采集实战:网口温湿度传感器网段划分与网关解耦要点
物联网
Yiran_G6 小时前
BLE终端数据积压如何处理?缓存队列、断连补传与流量调度设计
嵌入式硬件·物联网
做萤石二次开发的哈哈7 小时前
网络音箱如何接入?音频文件夹、TTS播报、音量管控与ISAPI透传对接详解
人工智能·物联网·蓝海aiot一站式工作台·aiot开发·网络音箱·智慧广播·二次开放
by————组态7 小时前
Ricon组态适用领域全景解析:工业制造、能源、市政民生与智慧城市四大场景落地实践
后端·物联网·数学建模·智慧城市·能源·制造·组态
资深电气设计8 小时前
ABB AFC/AF/AX系列接触器在数据中心液冷机组中的负载适配技术
运维·物联网·创业创新·业界资讯
rtu遥测终端机9 小时前
四信智慧船舶动态感知方案实现船岸一体化智能监管
物联网
星恒讯工业路由器9 小时前
工信部调整超宽带设备频段:7-9GHz为5G/6G腾频谱,工业UWB设备面临合规升级
网络·物联网·5g·无线通信·5g专网·超宽带uwb·频谱管理