ELI5 · 说人话

设备影子怎么设计

业务 写 desired 一份设备影子 desired 业务要它变成的样子 reported 设备说自己现在的样子 delta 两者的差 = 还没做到的事 version 1487 · 每次变更 +1 上报 推 delta device shadow · 声明式设备控制

影子不是「机器人的缓存」。它是一张双方各写一半的契约:业务只写「我要它变成什么样」,设备只写「我现在是什么样」,中间那个差,系统自己算。

最核心的一件事

想要的、现在的、还差的

desired mode=cleaning floor=3 speed=0.8 reported mode=idle floor=1 speed=0.8 delta mode=cleaning floor=3 一致,不进 delta

delta 是算出来的,不是写出来的。谁都不能直接修改它。它为空的那一刻,就是这台设备收敛完成的那一刻——你连「状态到位了吗」都不用另外设计一套判断。

一份影子长什么样

四个字段撑起全部设计

{
  "deviceId": "rb-8842",
  "version" : 1487,                    ← 每次变更 +1
  "state": {
    "desired" : { "mode":"cleaning", "floor":3 },
    "reported": { "mode":"idle",     "floor":1, "battery":62 },
    "delta"   : { "mode":"cleaning", "floor":3 }   ← 系统算的
  },
  "metadata": {                          ← 逐字段时间戳
    "reported": { "mode":{"ts":1735900012}, "battery":{"ts":1735900041} }
  },
  "connection": { "online":true, "lastSeen":1735900041 }
}
state
三段式,一个都不能省

只存 reported 就退化成缓存,只存 desired 就退化成指令队列。三块同时在,才有「差」可算。

version
整份文档一个单调递增号

乐观锁、乱序去重、心跳对账,全靠它。别拿时间戳当版本——两台机器的时钟不一样。

metadata
每个字段各自的时间戳

最容易被省掉、也最容易出事的一块。下一节专门讲它防的是什么。

connection
连接态和业务态分开

「离线」是平台判定的,「idle」是设备报的。混进一个字段,你永远说不清它是断了还是闲着。

最容易省掉的那一块

旧包后到,会把新值盖掉

只有整份文档一个时间戳 先到 后到 · 网络重传 ts = 101 battery = 58 ts = 100 battery = 62 battery 变回 62 旧值盖掉新值,业务看到假电量 每个字段各自带时间戳 先到 后到 · 网络重传 battery = 58 ts = 101 battery = 62 ts = 100 battery 保持 58 100 小于 101,这个字段的旧包丢弃

这不是理论问题。MQTT QoS 1 的重传、网关分区切换、设备补发离线积压——每一个都会制造乱序。整份文档一个时间戳时你只能整包取舍;逐字段时间戳让你只丢那一个字段,同一个包里更新的其他字段照常生效。

别什么都往里塞

字段分三类,写法不一样

可控
期望型

清洁模式、目标楼层、最大速度、灯光开关。

业务写 desired,设备写 reported,两边都有,会产生 delta

只读
观测型

电量、坐标、温度、故障码。

只有 reported,永远不许出现在 desired 里——你没法「期望」电量是 80%。

静态
铭牌型

型号、序列号、固件版本、出厂参数。

上线时报一次就够,别放进热路径,单独存关系库。

把观测值写进 desired,是设计影子时最常犯的第一个错。
它会造出一个永远收敛不了的 delta,然后无限重推。

一次完整的收敛

写下期望,等它自己追上

业务 平台影子 设备 写 desired: mode=cleaning version+1 · 算 delta 推 delta 给设备 执行 · 切清洁模式 报 reported: mode=cleaning delta 清空 = 收敛 业务只声明终态,不关心中间过程——这就是声明式控制。

好处在断线时才显出来:设备离线时业务照样能写 desired,delta 就挂在那儿。设备一重连,delta 自动推下去,不需要业务重发、不需要补偿任务、也不用维护一张「待下发指令表」。

最该想清楚的一个判断

什么走影子,什么走指令

重放一遍,会出问题吗? 不会 走影子 「最大速度设成 0.8」 「目标楼层设成 3 楼」 声明终态 · 天然幂等 走指令 「立刻急停」 「送一趟快递到 302」 有时效 · 要 cmdId + ACK

同一个平台两条路都要有,别指望一条通吃。硬套的下场:

硬塞进影子
把动作写成 desired

{"action":"restart"} 挂在 desired 里,设备每次重连拉到 delta 就重启一次。永远收敛不了,因为「重启」不是一个状态。

硬塞进指令
把状态写成一次性指令

「设成清洁模式」发成指令,设备当时离线就丢了。于是你得再造一张待下发表、一个重试器、一套过期清理——把影子重新发明一遍。

一个数字,三个用途

version 是最便宜的正确性

乐观锁
防丢更新

业务写 desired 时带上刚读到的 version。对不上就拒绝,让它重读再写。两个大屏同时改同一台车,不会互相盖掉。

去重
防乱序

设备收到 version 比本地还小的 delta,直接丢。重传、补发、多网关并发下推,全被这一行判断挡住。

对账
几个字节换全量

心跳里捎上 version。云端一比,一样就什么都不做,不一样才拉全量。100 万台每 30 秒只多传几百 KB。

用时间戳当版本号,是另一个常见错误。
设备的时钟和云端不一样,NTP 校时还会往回跳。

100 万份影子放哪

热的、旧的、不变的,分开放

一次影子更新 热态 Redis Hash O(1) 查询 · TTL 兜底 100 万台 ≈ 2 GB 历史 Kafka 压实 每次变更留一条 · 可回放 按 deviceId 压实 静态 关系库 型号 · 固件 · 序列号 几乎不变,不进热路径 影子的分片键要和接入层用同一个 deviceId,本地缓存才命中得上。

影子必须给 TTL 兜底。设备报废、被拆走、换了 ID,没人会来告诉你——没有过期时间,Redis 里会慢慢堆满几年前的僵尸设备。

变了怎么告诉别人

三种消费方式,一条硬规矩

设备影子 变了 GET /shadow 拉:单点查询 工单系统 shadow.updated 推:事件带 diff 数据平台 ws: subscribe 订:批量订阅 调度大屏

硬规矩:变更事件只带 diff,不带全量。100 万台设备的差别就在这里——

推全量文档
≈ 67 MB/s

100 万台 × 2 KB ÷ 30 秒
还没算下游各方各存一份

只推变化字段
≈ 0.3 MB/s

按 5% 设备有变化、每条 200 B 算
相差两百倍

红线

影子最容易被用坏的六处

把影子当数据库用,往里塞订单和任务明细

影子只回答一个问题:这台设备现在长什么样。任务、工单、轨迹各有各的表。影子一旦变胖,每次推送都在浪费带宽。

desired 永不过期,三天前的期望还挂着

给 desired 加 TTL 和写入时间。设备一上线就执行三天前的意图,轻则跑错,重则出事故。过期的要清掉并告警。

把动作写进 desired,每次重连就执行一遍

desired 里只能放状态,不能放动词。「重启」「拍照」「送快递」这类一次性动作走指令通道,带 cmdId 和 ACK。

delta 空了就认为任务完成了

空 delta 只说明状态字段对上了,不说明业务目标达成。「送到 302」的完成信号是一条 task.finished 事件,不是影子里的某个字段。

设备离线时拒绝写 desired

照常接受,但要如实回「已排队,尚未生效」。这正是影子存在的价值——把「意图」和「送达」解耦。骗业务说成功了才是真的错。

试图做双向合并,写一堆冲突解决逻辑

不需要。规则只有两条:观测值永远 reported 赢,控制意图永远 desired 赢。两边写的本来就是不同的字段,不该冲突。

指令说的是「去做这件事」,
影子说的是「成为这个样子」。

← 回到「怎么接一百种机器人」