一、整体设计:一条命令的四个状态
我项目里的约定(来源: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 共享订阅。欢迎关注,一起把这个项目做扎实。