物联网平台怎么给设备下发指令-SpringBoot下行命令与回执状态机实战

一、整体设计:一条命令的四个状态

我项目里的约定(来源:com.iothub.command 包):

  • 下发 :服务端 publish 到 cmd/{productKey}/{deviceId},payload 带 commandId、name、params

  • 回执 :设备执行完,publish 到 ack/{productKey}/{deviceId},带同一个 commandId 和 status: ok|failed

  • 落库:每条命令在数据库有一行记录,状态机如下

    SENT(已下发,等回执)
    ├── 设备回执 ok → ACKED(执行成功)
    ├── 设备回执 failed → FAILED(设备明确说干不了)
    └── 超时还没回执 → TIMEOUT(定时任务兜底标记)

为什么状态要存在数据库而不是内存里?三个原因:设备回执可能比你想的慢很多(甚至隔天)、出了事故要能追溯"谁在什么时候给哪台设备发了什么"、超时未回执必须能被发现------这几点光靠 MQTT 是给不了的。

二、下发:先落库,再 publish,顺序不能反

来源文件:src/main/java/com/iothub/command/CommandService.java

java 复制代码
@Transactional
public DeviceCommand send(Long deviceId, String name, Map<String, Object> params) {
    Device device = deviceRepository.findById(deviceId)
            .orElseThrow(() -> new BusinessException(404, "设备不存在"));
    if (DeviceService.STATUS_DISABLED.equals(device.getStatus())) {
        throw new BusinessException(409, "设备已禁用,无法下发命令");
    }

    String commandId = UUID.randomUUID().toString().replace("-", "");
    DeviceCommand command = new DeviceCommand();
    command.setCommandId(commandId);
    command.setDeviceId(device.getId());
    command.setProductKey(device.getProductKey());
    command.setName(name);
    DeviceCommand saved = commandRepository.save(command);   // ① 先落库

    String topic = mqttProperties.getCommandTopicPrefix()
            + "/" + device.getProductKey() + "/" + device.getId();
    mqttConnection.publish(topic, buildDownlinkPayload(commandId, name, params), 1); // ② 再 publish
    return saved;
}

三个容易踩的坑都藏在这十几行里:

坑 1:顺序不能反。 如果先 publish 再落库,设备执行得飞快、回执 10ms 就到了,服务端按 commandId 查记录------还没入库,查不到,回执只能丢弃,这条命令永远停在"查无此命令"。先落库拿到持久记录,再发出去,回执无论多快都接得住。@Transactional 兜住反向情况:publish 抛异常时命令记录回滚,"没真正发出去的命令"不会留在库里骗人。

坑 2:QoS 用 1,不用 0 也不用 2。 QoS0 可能丢,命令不能丢;QoS2"恰好一次"握手成本高,而且设备侧本来就要按 commandId 幂等去重(QoS1 至少一次,意味着可能重复下发)。QoS1 + 应用层幂等是物联网下行的标准答案。

坑 3:MQTT 没连上时 publish 会炸。 我在 MqttConnection.publish(来源:src/main/java/com/iothub/mqtt/MqttConnection.java)里做了前置检查:

java 复制代码
public void publish(String topic, String payload, int qos) {
    MqttAsyncClient current = this.client;
    if (current == null || !current.isConnected()) {
        throw new BusinessException(503, "MQTT 未连接,命令未下发");
    }
    ...
}

不加这个检查,命令落了库、publish 静默失败,记录永远停在 SENT,等超时任务 10 秒后才"冤枉"它超时。快速失败 + 事务回滚,让调用方立刻知道没发出去。

三、回执:收到 ack 后,状态怎么推进

设备侧(来源:scripts/demo_device.py)连上 Broker 后第一件事就是订阅自己的命令主题,收到命令、执行、回执:

python 复制代码
def on_connect(c, userdata, flags, reason_code, properties):
    ...
    c.subscribe(topic_cmd, qos=1)   # 订阅 cmd/{pk}/{deviceId},这是"可被远程控制"的关键

def publish_ack(command_id, status, result):
    payload = json.dumps({"commandId": command_id, "status": status, "result": result})
    client.publish(topic_ack, payload, qos=1)

服务端收到 ack 消息后,有个架构上的取舍。来源文件:src/main/java/com/iothub/mqtt/TelemetryMessageHandler.java

java 复制代码
private void handleAck(Device device, String payload) {
    log.info("收到设备[{}]命令回执: {}", device.getId(), payload);
    eventPublisher.publishEvent(new CommandAckEvent(device.getId(), payload));
}

为什么不直接调 CommandService?因为 依赖方向会成环 :CommandService 要注入 MqttConnection 来下发命令,而消息链路是 MqttConnection → TelemetryMessageHandler → CommandService------直接调用就是构造器循环依赖,Spring 启动直接报错。把"收到一条回执"发布成 Spring 事件,让 CommandService 反过来 @EventListener 监听,依赖变成单向,环被打断。这是面试里"Spring 事件机制干什么用"的现成答案。

CommandService 侧推进状态时有一条幂等铁律(来源:CommandService.java):

java 复制代码
// 幂等:只推进仍是 SENT 的命令;重复回执或迟到的回执不覆盖已终结的终态
if (!DeviceCommand.STATUS_SENT.equals(command.getStatus())) {
    log.debug("命令已是终态,忽略重复回执: commandId={} status={}", ...);
    return;
}
boolean ok = "ok".equalsIgnoreCase(node.path("status").asText(""));
command.setStatus(ok ? DeviceCommand.STATUS_ACKED : DeviceCommand.STATUS_FAILED);

QoS1 意味着回执可能重复到、迟到。只允许 SENT → 终态单向推进,重复回执直接忽略,终态绝不被覆盖。

四、超时兜底:让"石沉大海"可见

设备离线、固件卡死、回执丢了------无论哪种原因,结果都是命令停在 SENT 没人管。我用一个定时任务扫描(来源:CommandService.java):

java 复制代码
@Scheduled(fixedDelayString = "${iot.command.timeout-scan-ms:5000}")
public void scanTimeouts() {
    markTimeouts(timeoutMs);   // 默认下发 10 秒未回执即超时
}

public int markTimeouts(long timeoutMs) {
    Instant cutoff = Instant.now().minusMillis(timeoutMs);
    List<DeviceCommand> stale = commandRepository
            .findTop100ByStatusAndCreatedAtBeforeOrderByIdAsc(STATUS_SENT, cutoff);
    for (DeviceCommand command : stale) {
        command.setStatus(DeviceCommand.STATUS_TIMEOUT);
        command.setResult("设备未在 " + timeoutMs + "ms 内回执");
    }
    ...
}

五、实测:同一台设备的两种命运

环境:EMQX 5.3.2 + 本机 Spring Boot(H2 profile),真实运行。

场景一:设备没在线(没订阅 cmd 主题)。先注册设备拿到密钥:

下发命令,接口同步返回命令记录,状态 SENT;立刻查历史,依然停在 SENT:

10 秒过去设备毫无反应,超时扫描任务把它标记为 TIMEOUT,result 写明原因:

对应服务端真实日志(节选自 app 运行日志):

ini 复制代码
08:39:47  INFO  MQTT 已下发命令: topic=cmd/productA/1 payload={"commandId":"227e...","name":"set_interval",...}
08:39:47  INFO  命令已下发: device=1 commandId=227e... name=set_interval
08:39:58  WARN  命令超时任务:标记 1 条命令为 TIMEOUT     ← scheduling-1 线程兜底

场景二:设备在线并订阅了 cmd 主题 (我用 Python 脚本模拟设备,走一机一密认证)。再下发一条,设备秒级回执,状态被回执事件推进为 ACKED,result 是设备自己写的执行结果:

sql 复制代码
08:41:16  INFO  MQTT 已下发命令: topic=cmd/productA/1 payload={"commandId":"6d68...",...}
08:41:16  INFO  收到设备[1]命令回执: {"commandId":"6d68...","status":"ok","result":"interval set to 5"}
08:41:16  INFO  设备[1]回执: commandId=6d68... status=ACKED result=interval set to 5

注意时间戳:下发到回执推进完成只隔了约 25ms------先落库的顺序保证了再快的回执也接得住。

最后查一次命令历史,三条记录同屏:1 条 ACKED、2 条 TIMEOUT------这就是一张命令的"对账单",谁执行了、谁没理你,清清楚楚。

六、面试怎么讲这个设计

被问"物联网平台命令下发怎么设计"时,我按这个顺序答:①命令落库 (发出去≠执行了,要有审计和状态);②QoS1 + commandId 幂等 (至少一次送达,应用层去重);③ack 主题回执 + 状态机 (SENT 只进不退,终态不覆盖);④超时兜底 (没有回执也要有结局);⑤Spring 事件解循环依赖(消息处理层不直接依赖业务层)。五个点每个都有我项目里的真实代码和截图背书,比背八股有说服力得多。


关于作者 :软件工程在读(专升本),正在从零搭一个物联网设备接入平台(Spring Boot + EMQX + MySQL),把踩过的每个坑都写成文章。上一篇讲了设备密钥泄露后的轮换与禁用,下一篇计划聊 EMQX 共享订阅。欢迎关注,一起把这个项目做扎实。

相关推荐
权球物联分享物联网连接服务2 小时前
AGV 机器人商业化落地验证,物联网卡保障产线交付稳定性!
物联网·机器人
码流子3 小时前
事件告警延迟拆解:从摄像头到手机通知,延迟卡在哪六个环节
数据库·人工智能·物联网
数字新视界3 小时前
机房环境监控系统的关键应用与发展趋势
大数据·物联网·信息可视化·信息与通信·一体化机柜
博纬智能RFID3 小时前
服装旗舰店防盗升级:RFID吊顶门禁如何实现无感防损?
物联网
话梅姐姐呀4 小时前
浅谈智能硬件 OTA
物联网
kiros_wang4 小时前
BLE蓝牙基础通信:扫描、连接、收发数据,IoT简易Demo
物联网
做萤石二次开发的哈哈13 小时前
连锁门店、园区、交通枢纽的视频如何统一汇集?应用模板的接入与分权管理详解
人工智能·物联网·音视频·二次开发·视频监控·蓝海aiot一站式工作台·萤石云开放平台
QYR_1116 小时前
机械制造用固体润滑涂层市场分析:2032年全球市场规模预计达8.78亿元,CAGR为6.2%
大数据·人工智能·物联网
李永奉20 小时前
日常使用的锂电池上,新、旧锂电池的差异分析
单片机·嵌入式硬件·物联网·智能手机·电脑