IoT 系列第 4 篇。第 2 篇我们把 MQTT 玩明白了,第 3 篇把设备接上了云平台。这一篇解决一个特别扎心的问题:设备这会儿不在线,我在手机上点了一下"开灯",等它上线了要不要执行?
适用人群:已经能用 MQTT 把设备连上云平台(看过第 2、3 篇)、但发现"命令发出去设备没收到就丢了"的同学。
读完你能得到:
- 说清楚"物模型"和"设备影子"到底是什么,它们解决的是哪类问题;
- 看懂
desired(期望)/reported(实际)/delta(差异)三份状态是怎么流转的; - 明白"直接发命令"和"走影子"的区别,什么场景该用哪个;
- 一套用阿里云/华为云影子 Topic 做"离线开灯"的最小可跑示例思路。
没看过前两篇也能懂的核心:MQTT 是"发布/订阅"的消息管道;设备想被远程控制,平台得有个地方"记住想让它变成什么样",设备上线了再来认领——这个"记住的地方"就是设备影子。
一、从一个翻车现场说起
去年我帮同学做一个"远程浇花"的小项目。逻辑很简单:手机点一下,继电器吸合,水泵转 10 秒。
本地测试一切正常——设备连着电脑、Wi-Fi 满格。可真布置到阳台之后出问题了:
场景一:设备重启的空窗。设备偶尔会断网重连(Wi-Fi 抖动),刚好我在这几秒钟点了"浇水"。消息发到 broker,设备不在,broker 没订阅者就直接丢了。等它连上来,啥也没发生,我还以为浇了。
场景二:想"预约"一个状态。我人不在家,想让设备"今晚 8 点浇一次"。可我不能 8 点准时点按钮,而且那时候设备也可能没在线。
场景三:状态对不上。App 上显示"已开",设备其实因为继电器卡住实际是"关"的。两边各说各话。
这三个问题,本质是同一件事:平台和设备的"认知"需要有个中立的、持久化的中间层来对齐。这就是设备影子(Device Shadow)+ 物模型(Thing Model)要解决的。
二、白话前置概念表
| 术语 | 白话解释 | 为什么你要在意 |
|---|---|---|
| 物模型 | 平台对"这个设备是什么、能干什么"的结构化描述 | 属性/事件/服务三件套,是所有上层功能的基础 |
| 属性 Property | 设备可读可写的状态量,比如power、temperature | 平台和设备都认同一套字段,才不会各说各话 |
| 事件 Event | 设备主动上报的"发生了什么事",比如"电量低" | 不同于属性(是状态),事件是动作/告警 |
| 服务 Service | 平台调用设备能力的"方法",有输入有返回 | 区别于单向命令,服务要应答 |
| 设备影子 Shadow | 云端替设备保存的一份"状态副本" | 设备离线也能被控制、被查询 |
| desired | 影子里的"期望状态":平台/App 想让设备变成啥 | 持久化保存,设备上线即同步 |
| reported | 影子里的"实际状态":设备当前是啥 | 设备执行后回写,和 desired 对齐 |
| delta | desired - reported的差异:设备"还该做啥" | 设备上线只需认领 delta,不用全量比对 |
三、物模型:给设备造一份"数字简历"
物模型是平台侧对设备的结构化抽象,把"一台设备"翻译成三段:
- 属性(Property):持续存在的状态。灯的开/关、温度、电量。可读(设备上报),也可写(平台下发)。
- 事件(Event):设备主动说的"刚发生了一件事"。比如"电池电量低于 10%"“按键被按下”。事件带时间戳和数据,不要求立即应答。
- 服务(Service):平台主动调用设备能力的"函数",有入参、有返回。比如"获取当前诊断信息"“执行一次自检”。和单向命令的区别是服务要回结果。
举个灯的物模型(伪 JSON Schema):
{"properties":{"power":{"type":"bool","access":"rw"},"brightness":{"type":"int","min":0,"max":100,"access":"rw"}},"events":{"low_power":{"type":"alert"}},"services":{"self_test":{"in":{},"out":{"result":"int"}}}}⚠️ 不同平台叫法略有差异:阿里云叫"物模型"(属性/事件/服务),华为云叫"产品模型"(service/property/command)。内核一模一样,别被名字绕晕。
四、设备影子:云端替设备记着"它该是什么样"
设备影子可以理解成云端替设备维护的一个 JSON 文档,核心就两份状态:
{"state":{"desired":{"power":1,"brightness":80},// 平台/App 希望它变成这样"reported":{"power":0,"brightness":0}// 设备实际现在这样},"version":12}delta(差异)不是单独存的字段,而是平台算出来的:desired里有、reported里没有或值不同的部分。上面这个例子,delta = { "power":1, "brightness":80 },意思是"设备还差这两件事没做到"。
4.1 工作流程(这是全篇最该记住的一张图)
[手机 App] --设置 desired:{"power":1}--> [云端影子] ← 此刻设备离线,命令不丢 │ │ │ (desired 持久化保存) │ │ (设备不在线,什么都没发生) │ │ │ [设备上线] <------ 拉取 delta:{"power":1} -----│─── 平台主动下发 / 设备主动 GET │ │ │ 执行开灯 │ ▼ │ [设备上报] --reported:{"power":1}--> [云端影子] │ delta 清空,两边对齐 ✅关键点:desired 存在云端,不依赖设备在线。设备上线(或者被平台推送)时,认领 delta 去执行,执行完回写 reported,delta 自然清空。这就是"离线也能控"的实现原理。
五、影子 vs 直连命令:到底用哪个
这是面试和实战都常问的。一句话:要"最终一致"用影子,要"即时必达且必须此刻响应"用直连命令。
| 维度 | 设备影子 | 直连命令(如 RRPC/同步命令) |
|---|---|---|
| 设备离线时 | desired 保存,上线后执行 | 直接失败/丢弃 |
| 语义 | 声明"目标状态",幂等 | 触发"一个动作",通常非幂等 |
| 适用 | 开/关、设阈值、配置 | 立即拍一张照、立即重启 |
| 失败处理 | 靠后续同步自然补偿 | 需自己处理超时/重试 |
经验法则:凡是"希望设备最终处于某个状态"的,用影子;"现在立刻做一件事且要知道成没成"的,用直连命令。
六、实战:用影子 Topic 做"离线开灯"
以阿里云为例(其他平台概念一致,只是 Topic 拼法不同)。影子相关 Topic 大致是这些:
更新影子(上行): /sys/{ProductKey}/{DeviceName}/thing/shadow/update 获取影子(上行): /sys/{ProductKey}/{DeviceName}/thing/shadow/get 更新结果(下行): /sys/{ProductKey}/{DeviceName}/thing/shadow/update/accepted delta 下发(下行): /sys/{ProductKey}/{DeviceName}/thing/shadow/update/delta第 1 步:App/平台设 desired(设备此时可以完全离线)
// 发布到 .../thing/shadow/update,payload:{"method":"update","state":{"desired":{"power":1}},"version":12}第 2 步:设备上线后,拉取并认领 delta
设备订阅.../thing/shadow/update/delta,或者用 MQTTX/Python 主动发一个空 GET 到.../thing/shadow/get,平台会回.../shadow/get/accepted,里面带着 delta。
# 伪代码:设备侧处理 deltadefon_shadow_delta(payload):delta=payload["state"]# {"power": 1}if"power"indelta:set_relay(delta["power"])# 执行开/关report_state({"power":delta["power"]})# 立刻回写 reported!第 3 步:回写 reported(关键,否则 delta 永远在)
// 发布到 .../thing/shadow/update{"method":"update","state":{"reported":{"power":1}}}回写之后,云端desired == reported,delta 清空,闭环完成。
⚠️ 华为云用的是 REST 风格的设备影子 API(
GET/PUT /v1/devices/{device_id}/shadow),而不是 Topic。如果你用华为云,记住"影子是 HTTP 接口",和阿里云的 MQTT Topic 路径不一样,但desired/reported/delta的逻辑完全一致。
七、新手必踩的 5 个坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
执行完不回写reported | delta 一直残留,设备下次上线又执行一次(重复开灯) | 执行成功立刻上报 reported,和 desired 对齐 |
| 影子 JSON 塞太多字段 | 消息超平台大小限制、解析慢、流量费涨 | 只放真正需要同步的状态,别把日志塞进影子 |
version乱填或不递增 | 平台按乐观锁拒绝更新,更新静默失败 | 每次更新带正确 version,或让 SDK 自动管 |
| 频繁写影子(每秒一次) | 触发平台限流、产生大量消息计费 | 状态真变了再写,必要时本地去抖 |
| 只设 desired 从不管结果 | 设备永远不上线,状态"假一致" | 监控 delta 残留时长,超阈值告警 |
八、动手练(含故意搞破坏)
- 在阿里云或华为云注册一个免费实例,创建一个产品(灯,含
power属性)和一个设备。 - 让设备保持离线,通过控制台或 API 设置
desired: {"power": 1}。 - 用 MQTTX 或 Python(paho-mqtt)模拟设备上线,订阅
.../shadow/get/accepted或.../update/delta,发一个 GET,验证确实收到了power:1。 - 回写
reported: {"power": 1},再去查影子,确认 delta 已清空、desired == reported。 - 故意破坏:只设 desired 不回写,刷新影子页面,观察 delta 是否一直挂着——这就是"重复执行" bug 的来源。
九、小结
设备影子不是什么黑科技,本质就是云端给设备存的一份带版本号的"期望 vs 实际"状态表。它解决了三个老问题:命令不因离线而丢、状态两端不再各说各话、可以"预约"一个未来状态。记住desired / reported / delta的闭环,以及"执行完必须回写 reported",你就超过了大部分只懂发 MQTT 的初学者。
下一篇:《物联网设备管理实战:批量 OTA、远程配置与设备生命周期》