SpringCloud Alibaba无人售货柜实战(四):设备接入模块——设备注册、心跳上报、在线状态管理
SpringCloud Alibaba无人售货柜实战(四):设备接入模块——设备注册、心跳上报、在线状态管理
一台售货柜上电后怎么让服务器知道"我来了"?怎么判断它还活着?怎么知道它掉线了?这篇把设备接入的全流程讲透。
一、设备接入流程总览
无人售货柜不是普通的服务器,它是一台跑在RK3588工控板上的Android设备,通过MQTT和服务器通信。设备从上电到"在线可用",要经过四个阶段:
设备上电 → 连接MQTT Broker → 注册报到 → 心跳保活 → 在线服务详细步骤:
- 工控板上电启动,Android App拉起
- 读取本地存储的设备SN码,连接EMQX MQTT Broker
- 连接成功后,向服务端发送注册请求(携带SN码)
- 服务端校验SN合法性,分配Token,更新设备状态为"在线"
- 设备每30秒发送一次心跳包
- 服务端定时扫描,90秒未收到心跳则标记离线
二、MQTT Topic设计
MQTT通信的核心是Topic设计。本项目采用{前缀}/{设备SN}/{动作}的层级结构:
| Topic | 方向 | 说明 |
|---|---|---|
device/{sn}/register | 设备→服务端 | 设备注册 |
device/{sn}/heartbeat | 设备→服务端 | 心跳上报 |
device/{sn}/status | 设备→服务端 | 状态上报(开门/关门/故障) |
device/{sn}/command | 服务端→设备 | 下行指令(开柜/重启等) |
device/{sn}/callback | 设备→服务端 | 指令执行回调 |
每个设备订阅自己的
device/{sn}/commandTopic,服务端往这个Topic发消息,设备就能收到。SN码天然就是路由Key。
三、设备注册流程
3.1 注册逻辑
设备首次上电(或Token过期后)需要注册:
- 设备读取本地SN码(出厂烧录,不可篡改)
- 拼接SN + 设备型号 + 固件版本,发送到
device/{sn}/registerTopic - 服务端收到消息,查
device表确认SN存在且未被禁用 - 生成JWT Token,存入Redis(key=
device:token:{sn},有效期7天) - 更新设备状态为"在线",记录上线时间
- 通过MQTT返回Token给设备
3.2 注册Controller
设备注册走MQTT消息,不走HTTP。服务端用MQTT消息监听器处理:
@Slf4j@ComponentpublicclassDeviceRegisterListener{@AutowiredprivateDeviceServicedeviceService;@AutowiredprivateRedisUtilsredisUtils;@AutowiredprivateMqttGatewaymqttGateway;privatestaticfinalStringDEVICE_TOKEN_PREFIX="device:token:";privatestaticfinallongTOKEN_EXPIRE_DAYS=7L;/** * 监听设备注册Topic */@MqttMessageListener(topic="device/+/register")publicvoidonRegister(MqttMessagemessage){Stringtopic=message.getTopic();Stringsn=topic.split("/")[1];Stringpayload=newString(message.getPayload(),StandardCharsets.UTF_8);log.info("收到设备注册请求: sn={}, payload={}",sn,payload);DeviceRegisterReqreq=JSON.parseObject(payload,DeviceRegisterReq.class);req.setSn(sn);// 1. 校验设备合法性Devicedevice=deviceService.getBySn(sn);if(device==null){log.error("设备未登记: sn={}",sn);sendRegisterResult(sn,false,"设备未登记",null);return;}if(device.getStatus()==DeviceStatus.MAINTENANCE.getCode()){sendRegisterResult(sn,false,"设备维护中",null);return;}// 2. 生成TokenStringtoken=JwtUtils.generateDeviceToken(sn,device.getId());// 3. 缓存Token到RedisredisUtils.set(DEVICE_TOKEN_PREFIX+sn,token,TOKEN_EXPIRE_DAYS,TimeUnit.DAYS);// 4. 更新设备状态device.setStatus(DeviceStatus.ONLINE.getCode());device.setLastOnlineTime(LocalDateTime.now());device.setLastHeartbeatTime(LocalDateTime.now());device.setFirmwareVersion(req.getFirmwareVersion());device.setIpAddress(req.getIpAddress());deviceService.updateById(device);// 5. 返回注册结果sendRegisterResult(sn,true,"注册成功",token);log.info("设备注册成功: sn={}",sn);}privatevoidsendRegisterResult(Stringsn,booleansuccess,Stringmsg,Stringtoken){JSONObjectresult=newJSONObject();result.put("success",success);result.put("message",msg);if(token!=null){result.put("token",token);}mqttGateway.sendToMqtt("device/"+sn+"/register/resp",result.toJSONString());}}四、心跳上报机制
4.1 心跳协议
设备每30秒上报一次心跳,内容非常精简:
{"sn":"VM202601001","timestamp":1753766400000,"cpuTemp":52.3,"memoryUsage":68,"diskUsage":45,"signalStrength":-67}心跳不只是"我还活着",顺便带上设备健康指标。cpuTemp超过80度就该告警了。
4.2 心跳处理Service
@Slf4j@ServicepublicclassDeviceHeartbeatService{@AutowiredprivateDeviceServicedeviceService;@AutowiredprivateRedisUtilsredisUtils;privatestaticfinalStringDEVICE_ONLINE_PREFIX="device:online:";privatestaticfinallongONLINE_EXPIRE_SECONDS=90L;/** * 处理心跳消息 */@MqttMessageListener(topic="device/+/heartbeat")publicvoidonHeartbeat(MqttMessagemessage){Stringtopic=message.getTopic();Stringsn=topic.split("/")[1];Stringpayload=newString(message.getPayload(),StandardCharsets.UTF_8);HeartbeatReqreq=JSON.parseObject(payload,HeartbeatReq.class);// 1. 更新Redis在线状态(90秒过期)StringonlineKey=DEVICE_ONLINE_PREFIX+sn;redisUtils.set(onlineKey,"1",ONLINE_EXPIRE_SECONDS,TimeUnit.SECONDS);// 2. 更新数据库最后心跳时间(异步,不阻塞MQTT回调)Devicedevice=newDevice();device.setSn(sn);device.setLastHeartbeatTime(LocalDateTime.now());deviceService.updateHeartbeatBySn(sn,LocalDateTime.now());// 3. 异常告警检查if(req.getCpuTemp()!=null&&req.getCpuTemp()>80){log.warn("设备CPU温度过高: sn={}, temp={}℃",sn,req.getCpuTemp());// 发送告警通知...}log.debug("心跳处理完成: sn={}",sn);}}心跳处理一定要快。MQTT消息回调是串行的,如果心跳处理慢了,后面的消息全部排队。所以DB更新用异步,核心逻辑只做Redis更新。
五、在线状态判定
5.1 双重判定机制
设备在线状态用Redis + 定时扫描双重保障:
- Redis快速判定:
device:online:{sn}这个Key存在就算在线,过期时间90秒。心跳每30秒续期,一旦心跳停止,Key自动过期。 - DB定时扫描:兜底机制,防止Redis故障导致状态不一致。
5.2 定时状态扫描
@Slf4j@ComponentpublicclassDeviceStatusScanner{@AutowiredprivateDeviceServicedeviceService;@AutowiredprivateRedisUtilsredisUtils;privatestaticfinalStringDEVICE_ONLINE_PREFIX="device:online:";privatestaticfinallongOFFLINE_THRESHOLD_SECONDS=90L;/** * 每60秒扫描一次,将超时设备标记为离线 */@Scheduled(fixedRate=60000)publicvoidscanOfflineDevices(){log.info("开始扫描离线设备...");// 查询所有状态为"在线"的设备List<Device>onlineDevices=deviceService.listByStatus(DeviceStatus.ONLINE.getCode());intofflineCount=0;LocalDateTimethreshold=LocalDateTime.now().minusSeconds(OFFLINE_THRESHOLD_SECONDS);for(Devicedevice:onlineDevices){// 优先检查RedisBooleanisOnline=redisUtils.hasKey(DEVICE_ONLINE_PREFIX+device.getSn());if(BooleanUtil.isFalse(isOnline)){// Redis已过期,再检查DB最后心跳时间兜底if(device.getLastHeartbeatTime()==null||device.getLastHeartbeatTime().isBefore(threshold)){// 标记离线device.setStatus(DeviceStatus.OFFLINE.getCode());deviceService.updateById(device);offlineCount++;log.warn("设备离线: sn={}, lastHeartbeat={}",device.getSn(),device.getLastHeartbeatTime());}}}log.info("离线扫描完成: 在线={}, 新增离线={}",onlineDevices.size(),offlineCount);}}5.3 设备状态机
注册成功 心跳超时/异常 OFFLINE ────────→ ONLINE ──────────→ OFFLINE │ │ 故障上报 重新注册 ↓ │ FAULT ←──────────────┘ │ 维修开始 ↓ MAINTENANCE │ 维修完成 ↓ OFFLINE → 重新注册 → ONLINE六、设备信息缓存策略
设备信息是高频读取数据(每次下单都要查设备状态),直接查DB扛不住。缓存策略:
| 数据 | 缓存Key | TTL | 说明 |
|---|---|---|---|
| 在线状态 | device:online:{sn} | 90秒 | 心跳续期 |
| 设备信息 | device:info:{sn} | 30分钟 | DB变更时主动刷新 |
| 认证Token | device:token:{sn} | 7天 | 注册时写入 |
| 设备锁 | device:lock:{sn} | 10秒 | 防止并发操作同一设备 |
publicDevicegetDeviceBySn(Stringsn){Stringkey="device:info:"+sn;Devicedevice=redisUtils.get(key,Device.class);if(device!=null){returndevice;}device=this.getBySn(sn);if(device!=null){redisUtils.set(key,device,30,TimeUnit.MINUTES);}returndevice;}七、常见问题
7.1 设备频繁上下线(网络抖动)
设备网络不稳定,一会儿上线一会儿掉线,状态频繁切换,产生大量日志。
解决方案:引入"离线延迟确认"机制。心跳超时后不立即标记离线,先进入"疑似离线"状态,再等一个心跳周期(30秒),如果还没心跳,才正式标记离线。
// 疑似离线缓存,30秒后过期redisUtils.set("device:pending_offline:"+sn,"1",30,TimeUnit.SECONDS);7.2 心跳丢失
MQTT QoS设置为1(至少一次),但极端情况下仍可能丢消息。解决:
- 设备端本地记录心跳序号,连续3次未收到服务端ACK就重连
- 服务端用
@Scheduled补偿扫描,以DB时间戳为最终判据
八、设备管理表补充
在第一篇的device表基础上,补充设备扩展信息表:
CREATETABLE`device_config`(`id`bigintNOTNULL,`device_sn`varchar(64)NOTNULLCOMMENT'设备SN',`heartbeat_interval`intNOTNULLDEFAULT30COMMENT'心跳间隔(秒)',`offline_threshold`intNOTNULLDEFAULT90COMMENT'离线判定阈值(秒)',`door_timeout`intNOTNULLDEFAULT120COMMENT'开门超时(秒)',`max_open_duration`intNOTNULLDEFAULT300COMMENT'最大开门时长(秒)',`auto_close_enabled`tinyintNOTNULLDEFAULT1COMMENT'是否自动关门',`volume`intNOTNULLDEFAULT50COMMENT'音量(0-100)',`create_time`datetimeNOTNULLDEFAULTCURRENT_TIMESTAMP,`update_time`datetimeNOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,PRIMARYKEY(`id`),UNIQUEKEY`uk_device_sn`(`device_sn`))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4COMMENT='设备配置表';九、小结
设备接入是IoT项目的第一道关卡。核心就三件事:注册让设备拿到身份、心跳让服务端知道设备活着、状态扫描兜底处理异常掉线。MQTT Topic设计要提前规划好层级结构,后期改Topic成本极高。Redis做在线状态缓存是标配——DB扛不住每30秒一次的心跳写入。