设备影子、命令状态与跨语言契约:重复、过期与迟到确认
U88 把设备接进了 broker:身份可信、会话记得住、QoS 说得清。但协议层"消息到了"不等于"业务做对了"。一台设备离线十分钟,云端发了三条调速命令;设备重连后应该执行哪几条、按什么顺序、反馈怎么记、重复的怎么办——这些问题协议都不回答,必须由设备影子、命令状态机和一份跨语言契约共同回答。本页把这三样拆开讲,再用真实 broker 与真实状态机跑一遍重复、过期、迟到确认与断线恢复。
本页是 U89(#126)的 D-IOT 权威正文,依赖 U88 设备身份与 MQTT 会话。协议层的连接、QoS、保留消息、遗嘱在那里;通用幂等模型(Inbox、outbox、去重表)在重复消费、幂等与顺序消息;消息投递语义在MQ 生产存储消费可靠性。本页只讲 IoT 方向特有的三件事:影子文档、命令生命周期、跨语言契约,不重复通用幂等机制,也不教固件、控制算法或完整 C/C++ 技术栈。
三个部件各回答什么
先给一张分工表,后面展开:
| 部件 | 回答的问题 | 失败时的典型症状 | 不负责 |
|---|---|---|---|
| 设备影子 | "云端期望设备是什么状态、设备现在是什么状态、差在哪" | 面板显示旧值、下发指令后状态对不上 | 消息投递、重连补发 |
| 命令状态机 | "这条命令走到哪一步、能不能再执行、过期没有" | 重复执行、过期命令仍生效、迟到确认被记成成功 | 状态快照(那是影子的事) |
| 跨语言契约 | "云端和不同语言的设备端对同一份 JSON 理解一致吗" | 字段名漂移、时间格式不一致、枚举值不同 | 传输、鉴权、重试 |
三者缺一不可:只有影子的系统不知道命令有没有执行;只有命令状态机的系统无法回答"设备现在到底是什么状态";没有契约的系统在第二种语言接入时必然出现"Java 端说成功、Python 端说不认识"。
版本、工程与观察入口
固定 HiveMQ Community Edition 2026.5(嵌入式 broker,与 U88 同一版本与装配方式)、HiveMQ MQTT Client 1.4.1、Jackson 2.21.7(影子与命令 JSON)、Python 3 标准库(跨语言客户端)。运行环境:BellSoft Liberica JDK 21.0.11+11-LTS、Maven 3.9.9、macOS 27.2 arm64。查询与运行日期 2026-10-28。
公开入口:
python3 labs/iot-command/run.pyrun.py 执行 labs/iot-command 的 clean test,核对两份 Surefire 报告共 15 个测试,解析依赖版本,校验 15 条 U89 观察行,把输入、退出码、版本与源文件 SHA-256 写入 labs/iot-command/evidence/u89-2026-10-28.json。完整命令、退出码与计数见运行证据,工程边界见实验说明。测试里的 broker 是真实的嵌入式 CE(真实 TCP 监听器、file 持久化、真实扩展点 ACL),命令状态机、影子、幂等表是真实 Java 实现,跨语言客户端是独立进程的 Python 脚本——不是同一进程里假装跨语言。
三种证据的强度必须分开:模型(状态机的分支与不变量)、真实组件(broker、MQTT 客户端、Java 状态机、Python 客户端)、尚未运行(生产数据库、云 IoT 平台、固件)。本页每个结论都会标明属于哪一种。
设备影子
影子文档的结构
设备影子是一份文档,不是消息队列,也不是保留消息的别名。它至少有三个部分:
{
"deviceId": "e2e-a",
"version": 2,
"updatedAt": "2026-10-28T00:00:12.000Z",
"deleted": false,
"desired": {"speed": 4},
"reported": {"speed": 4},
"delta": {}
}| 字段 | 含义 | 为什么必须有 |
|---|---|---|
desired | 云端期望的状态 | 设备离线时意图有地方存,不依赖设备在线 |
reported | 设备上报的实际状态 | 云端能判断"意图执行了没有" |
version | 单调递增的整数 | 乐观并发控制,防止并发更新丢写 |
delta | desired 与 reported 的差集 | 设备上线后知道"还差什么要做",是投影不是第三份存储 |
deleted | 影子是否进入终态 | 删除后拒绝更新,但历史可查,不是清空 desired |
影子与保留消息的区别
影子文档不能和 MQTT 保留消息混为一谈。保留消息是 broker 保存的"主题最新值",没有版本、没有 desired/reported 双分区、没有差集、没有历史。U88 已经验证保留消息能跨正常重启存活,但那是"最新值"语义。影子需要的是"意图 + 状态 + 版本 + 冲突检测",必须存在应用侧存储里。把 retain 当影子用,第一次并发更新就会丢写,而且无法回答"设备上报的值和云端期望的值差在哪"。
部分更新是合并,不是替换
影子更新最常见的错误是把"我只想改 speed"写成整份替换,结果把 mode 字段清掉了。正确语义是按键合并:
long v = shadow.updateDesired(Map.of("speed", 3, "mode", "auto"), 0); // v = 1
v = shadow.updateDesired(Map.of("speed", 5), v); // v = 2,mode 保留
// desired = {"speed": 5, "mode": "auto"}实验 shadowMerge 验证了这一点:第二次只传 speed,mode 仍在。这与 MQTT 保留消息不同——保留消息每次都是整份替换;影子是文档合并。
版本冲突:乐观并发
影子更新必须带 expectedVersion。版本不匹配就拒绝,而不是 last-writer-wins:
shadow.updateDesired(Map.of("speed", 3), 0); // 成功,version 0 → 1
shadow.updateDesired(Map.of("speed", 5), 0); // 冲突:expected 0 but was 1
shadow.updateDesired(Map.of("speed", 5), 1); // 成功,version 1 → 2为什么不能 last-writer-wins?两个操作员同时改同一台设备的速度,一个设 3、一个设 5。如果后写的赢,先写的操作员以为设置成功了(HTTP 200),实际上被覆盖;设备端可能执行了 3 再执行 5,也可能只执行 5——行为不确定。版本冲突至少让第二个操作员知道"你看到的是旧状态,请重读再改"。
删除是终态
delete(expectedVersion) 之后影子进入 deleted=true,任何更新都以冲突拒绝;但 desired 的历史值不清空,审计和排障还要看。实验 shadowDelete 验证了删除后仍能读到原 desired,更新被拒。
影子与设备上报的闭环
完整闭环是:云端 updateDesired → 设备收到命令执行 → 设备 report 上报 → delta 收敛。实验 lifecycle 与 e2e 都验证了收敛:desired.speed=4、reported.speed=4 时 delta 为空。
这里有一个容易忽略的边界:delta 为空不代表设备状态正确,只代表"设备上次上报的值等于云端期望的值"。设备可能上报了错误值、可能根本没执行只是抄了 desired、可能执行后物理状态又变了。影子是"最后已知状态",不是"真实状态"。生产系统需要额外的健康检查或遥测来发现"上报值与实际不符"。
命令状态机
状态与迁移
命令不是"发出去就完了",它有明确的生命周期:
CREATED ──publish──► PUBLISHED ──device receives──► DELIVERED
│ │ │
│ │ ├── acknowledge(success) ──► ACKED ──► SUCCEEDED
│ │ └── acknowledge(fail) ─────► FAILED
│ │
└── expired ──► EXPIRED ◄── expired ───────────────┘每个状态都有进入条件与超时责任:
| 状态 | 进入条件 | 谁负责迁移 | 超时后的归宿 |
|---|---|---|---|
| CREATED | 云端受理、写入幂等表 | 云端 | 超过 expireAt → EXPIRED |
| PUBLISHED | 已发到 broker | 云端 | 设备不在线时停在 PUBLISHED,直到会话队列或过期 |
| DELIVERED | 设备确认收到(协议层或应用层) | 设备/云端 | 未 ACK 则等待重投或过期 |
| ACKED | 设备回执,云端受理 | 云端 | 立即转 SUCCEEDED/FAILED |
| SUCCEEDED / FAILED | 业务终态 | 云端 | 不再变化 |
| EXPIRED | 任一非终态超过 expireAt | 云端 | 终态 |
关键设计:EXPIRED 不是一个"失败"状态,而是"没有被执行"的状态。它和 FAILED 的区别是:FAILED 表示设备尝试执行但失败了(可以重试或告警),EXPIRED 表示设备根本没执行(重试要用新命令、新过期时间)。
幂等:同一个 commandId 只产生一次效果
命令服务端的幂等表以 commandId 为唯一键。重复到达时:
| 当前状态 | 重复到达的结果 | 业务效果 |
|---|---|---|
| 不存在 | ACCEPTED,新建 | 后续按状态机 |
| CREATED/PUBLISHED/DELIVERED(进行中) | DUPLICATE_IN_FLIGHT | 不重复执行 |
| SUCCEEDED | DUPLICATE_SUCCEEDED | 不重复执行 |
| EXPIRED/FAILED | DUPLICATE_EXPIRED | 不执行 |
在途命令重复投递
实验 duplicate 验证了"同一命令执行两次、回执两次,效果仍只有一条";duplicateInFlight 验证了命令还在途中时重复下发会得到 DUPLICATE_IN_FLIGHT 而不是新建一条。业务效果写在 EffectLog 里,append 对同一 commandId 第二次调用直接抛异常——这是最后一道防线,防止代码路径绕过幂等表。
为什么不能靠 QoS:U88 已经说明 QoS2 只保证 broker 到订阅者的一次投递。设备重连重放、云端重试、跨系统转发都会造成同一业务命令的多次到达,这些都不带 DUP 或 DUP 无意义。幂等必须靠 commandId,而 commandId 必须由云端生成——设备自己生成的话,重试会生成新 id,去重就失效了。
过期命令与迟到确认
过期是 IoT 里最容易被忽略、后果最严重的一类。设备离线十分钟,云端下发"开门"命令,设备二十小时后上线——如果照单执行,就是事故。所以命令必须带绝对时间 expireAt(而不是"相对 TTL",因为设备时钟不可靠,绝对时间跨设备可比):
- 发布前过期:云端受理时就已经过期,直接 EXPIRED,不发送。
- 投递后过期:命令在会话队列里等待期间过期,设备上线时收到但仍应拒绝执行。
- 迟到确认:设备执行了(或声称执行了)但回执晚于 expireAt,云端必须拒绝,不能记成 SUCCEEDED。
应用层 expireAt 与 MQTT 5 消息过期是两层。规范 [MQTT-3.3.2-5/-6]:Message Expiry Interval 是应用消息的寿命(秒);超期未开始投递的副本,Server MUST 删除;转发给订阅者时,Server MUST 把间隔减去消息在 Server 等待的时间。broker 的 MessageExpiryHandler 实现的就是这条:出站时按已等待秒数扣减剩余寿命,减到 0 就丢弃并不再投递:
@ChannelHandler.Sharable
public class MessageExpiryHandler extends ChannelOutboundHandlerAdapter {
static final Logger log = LoggerFactory.getLogger(MessageExpiryHandler.class);
@Override
public void write(
final @NotNull ChannelHandlerContext ctx,
final @NotNull Object msg,
final @NotNull ChannelPromise promise) throws Exception {
if (msg instanceof PUBLISH) {
final PUBLISH publish = (PUBLISH) msg;
checkAndSetPublishExpiry(publish);
final boolean expireInflight = InternalConfigurations.EXPIRE_INFLIGHT_MESSAGES_ENABLED;
final boolean isInflight = (publish.getQoS() == QoS.EXACTLY_ONCE) && publish.isDuplicateDelivery();
final boolean drop = (publish.getMessageExpiryInterval() == 0) && (!isInflight || expireInflight);
if (drop) {
ctx.fireUserEventTriggered(new PublishDroppedEvent(publish));
return;
}
} else if (msg instanceof PUBREL) {
final PUBREL pubrel = (PUBREL) msg;
checkAndSetPubrelExpiry(pubrel);
final boolean expireInflight = InternalConfigurations.EXPIRE_INFLIGHT_PUBRELS_ENABLED;
final boolean drop = (pubrel.getMessageExpiryInterval() != null) &&
(pubrel.getMessageExpiryInterval() == 0) && expireInflight;
if (drop) {
ctx.fireUserEventTriggered(new PubrelDroppedEvent(pubrel));
return;
}扣减逻辑本身很短:
private void checkAndSetPublishExpiry(final @NotNull PUBLISH message) {
if (message.getMessageExpiryInterval() != MAX_EXPIRY_INTERVAL_DEFAULT) {
final long waitingInSeconds = (System.currentTimeMillis() - message.getTimestamp()) / 1000;
final long remainingInterval = Math.max(0, message.getMessageExpiryInterval() - waitingInSeconds);
message.setMessageExpiryInterval(remainingInterval);
}
}注意它是相对间隔(消息还能活多久),且只在 broker 转发时扣减;它回答"这条 MQTT 消息还能不能送到",不回答"这条业务命令是否还该执行"。一条命令的消息可能还没过期(设备 5 分钟内上线就能收到),但业务上已经不该执行(比如"开门"命令的有效期只有 30 秒)。所以命令状态机必须有自己的 expireAt 并在云端裁决,不能把 MQTT 消息过期当成命令过期。
实验 expired 验证发布前过期,lateAck 用可控时钟把时间推进到过期之后,验证迟到的 ACK 得到 EXPIRED 且不写效果,e2eOffline 在真实 broker 上验证了"旧命令过期被拒、新命令正常执行"的完整闭环。
迟到确认为什么不能算成功
命令的语义是"在某个时刻之前把设备切换到某个状态"。过期后执行等于用旧意图覆盖新状态——设备可能已经根据后续命令调整过,迟到执行反而制造错误。正确做法是拒绝、记录、让业务层决定是否发新命令。
断线恢复
设备离线期间命令进入会话队列(U88 验证过 QoS1 离线补发),重连后设备会收到所有排队的命令。会话本身是否还在,由 broker 用"断开时长 ≥ sessionExpiryInterval"判定:
public boolean isExpired(final long timeSinceDisconnectMsec) {
if (connected) {
return false;
}
return timeSinceDisconnectMsec / 1000 >= sessionExpiryIntervalSec;
}
}这就是为什么"离线命令到底能不能收到"取决于两个独立的过期:会话过期(会话没了,队列一起丢)与命令过期(会话还在,命令不该执行)。前者由 broker 按秒判定,后者由云端按业务规则判定;调大 sessionExpiryInterval 只能延长前者,不能延长后者。
恢复逻辑不是"全部执行",而是:
- 按
commandId去重:同一 id 只执行一次。 - 按
expireAt过滤:过期命令拒绝并回 EXPIRED。 - 按业务顺序执行剩余命令(如果是调速类命令,后发的覆盖先发的;如果是动作类命令,需要全部执行)。
- 上报最终状态,影子的 reported 更新。
实验 e2eOffline 完整跑了这个流程:设备重连收到两条命令,旧命令 EXPIRED、新命令 SUCCEEDED,效果列表只有新命令的 id。这里没有实现"按业务顺序合并"的逻辑——它依赖具体业务,属于 U90/U91 的贯通案例范围。
跨语言契约
契约的最小内容
IoT 系统一定不止一种语言:云端 Java,设备端可能是 C、C++、Python、Lua,网关可能是 Go。同一份命令 JSON 在不同语言里必须被理解成同一件事。契约至少要固定:
| 维度 | 规则 | 反例 |
|---|---|---|
| 字段名 | 固定 snake_case 或 camelCase,不随语言习惯变 | Java 端 commandId,Python 端 command_id |
| 时间格式 | UTC、毫秒精度、Z 结尾(RFC 3339 子集) | Java 端 2026-10-28T00:00:00Z,Python 端 2026-10-28 08:00:00+08:00 |
| 数字类型 | 整数/浮点明确,不用字符串传数字 | "speed": "3" 与 "speed": 3 |
| 枚举值 | 固定字符串,不用语言特有枚举序列化 | Java 序列化 enum 序号 0,Python 收到 0 不知道是什么 |
| 缺字段 | 必需字段缺失必须拒绝,可选字段缺省有明确默认 | Python 端缺字段用 None 继续跑 |
| 版本 | schemaVersion 字段,不兼容变更递增 | 加字段不升版本,旧设备静默忽略 |
本页契约 v1:
{"schemaVersion": 1, "commandId": "c-1", "deviceId": "device-a", "type": "set-speed",
"payload": "{\"speed\":3}", "createdAt": "2026-10-28T00:00:00.000Z", "expireAt": "2026-10-28T00:05:00.000Z"}摘要一致性:可验证的契约
只说"两端格式一致"没有说服力。本实验用一个契约摘要做可验证的断言:把命令的六个字段按固定顺序拼成规范串,算 SHA-256。Java 与 Python 各自实现,对同一命令必须得到同一个摘要:
commandId|deviceId|type|payload|createdAt|expireAt时间必须先归一化成 yyyy-MM-dd'T'HH:mm:ss.SSS'Z'(UTC、毫秒、Z 结尾)。Java 的 Instant.toString() 会省略毫秒(2026-10-28T00:00:00Z),Python 的 isoformat() 会带 +00:00——不归一化,两端摘要永远对不上。实验 crossLanguage 验证了 Java 生成的 JSON 被 Python 解析后摘要一致;pythonAck 验证了 Python 生成的 ack 被 Java 解析;malformedRejected 验证了两端对缺字段的命令都拒绝。
为什么用摘要而不是逐字段比对:摘要能捕获字段顺序、时间格式、编码的细微差异,而且可以写进日志用于事后核对。它不是加密签名(不防篡改),只是"两端理解一致"的指纹。
契约演进
契约一定会变。兼容规则:
- 加可选字段:不升版本(旧设备忽略未知字段)。本实验 Java 端解析时忽略未知字段,但 Python 端用
json.loads后只取已知字段,也天然兼容。 - 加必需字段:升版本,旧设备拒绝。
- 改字段含义:升版本,且要双写过渡。
- 删字段:升版本,先标记 deprecated 再删。
schemaVersion 不匹配时两端都拒绝(实验 malformedRejected 的失败分支覆盖了缺字段,decodeCommand 里也检查版本不匹配)。
观测、排障与恢复顺序
命令与影子出问题时,排查顺序应该固定:
- 看命令状态:EXPIRED 说明时间问题(时钟、TTL 设置、设备太晚上线);FAILED 说明设备执行失败;停在 DELIVERED 说明设备收到没回执。
- 看影子版本:版本不涨说明更新没到服务端;版本涨了但 delta 不收敛说明设备没上报或上报值不对。
- 看效果日志:效果条数与成功命令数是否一致;不一致说明幂等表被绕过。
- 看契约摘要:两端日志里的摘要对不上,说明契约漂移。
- 看会话:sessionPresent=false 说明设备用了 cleanStart 或会话过期,离线命令全丢。
恢复顺序:先确认契约一致(否则修好的命令设备也读不懂),再确认会话参数(否则命令没地方排队),再确认过期时间(否则恢复后执行的是过期命令),最后才恢复业务流量。反过来做等于用新错误掩盖旧错误。
实验读法
python3 labs/iot-command/run.py 跑 15 个测试,观察行前缀 U89:
U89 lifecycle=PASS:命令完整生命周期到 SUCCEEDED,影子 delta 收敛,效果一条。U89 duplicate=PASS:同一命令重复投递与重复回执,效果仍一条。U89 duplicateInFlight=PASS:命令在途中时重复下发,得到 DUPLICATE_IN_FLIGHT。U89 lateAck=PASS:时间推进到过期后,迟到 ACK 被拒且无效果。U89 expired=PASS:发布前已过期的命令直接被拒。U89 shadowConflict=PASS:过期版本号更新被拒,用新版本成功。U89 shadowJson=PASS:影子 JSON 序列化与反序列化后版本、desired、reported、delta 一致。U89 shadowDelete=PASS:删除后进入终态,更新被拒。U89 shadowMerge=PASS:部分更新是合并,不清空其他键。U89 offlineQueue=PASS:设备离线期间命令进入会话队列,重连后按序收到。U89 crossLanguage=PASS:Java 编码的命令 Python 解析,契约摘要一致。U89 pythonAck=PASS:Python 生成的 ack Java 解析成功。U89 malformedRejected=PASS:缺字段的命令两端都拒绝。U89 e2e=PASS:真实 broker 上云端下发、设备收到、回执、影子收敛、重复投递效果仍一条。U89 e2eOffline=PASS:离线队列中旧命令 EXPIRED、新命令 SUCCEEDED,效果只有新命令。
闭卷验收
本页 25 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
未运行与不能由本页证明的
- 真实云 IoT 平台(AWS IoT Device Shadow、阿里云物联网平台影子)未运行;本页的影子是教学实现,行为参考通用模型,不声称与任何厂商 API 一致。
- 生产数据库事务、多节点命令服务、分布式幂等表未运行;本实验是单进程内存表 + 本地文件效果日志。
- TLS/mTLS、增强认证、WebSocket、外部凭据源与 broker 集群/桥接未运行(沿用 U88 的边界)。
- 时间过期用可控 Clock 制造,不是真实等待数分钟或小时;"设备时钟不准"只做规范推演,未实跑时钟漂移。
- 断线恢复是正常 DISCONNECT 后重连,不是 kill -9、断电或网络分区。
- 跨语言客户端只覆盖契约的编解码与摘要,不含固件 C/C++、C# 或 Go 实现;未做多语言 SDK 的一致性矩阵。
- 未做容量与并发压测;影子并发更新按顺序模拟,不是真实多线程争用;"两路并发更新一成一冲突"是逻辑断言,不是并发压力测试。
- 教材完成不等于学习者掌握;未运行条件不得由"命令写好"或"配置正确"推出。