IoT设备影子功能介绍与实现

IoT平台架构

在物联网领域,应用往往会通过调用集成并实现了MQTT协议的broker的平台与设备进行数据通信,已达到端、云互动与数据流转。常见的IoT架构图如下所示:

在IoT平台中为了达到控制设备实现的功能比较多,但是大致分为3类:

  1. 指令控制 (云端控制设备的动作与操作)
  2. 属性控制 (云端更新or获取设备拥有的属性)
  3. 事件控制 (设备触发某些事件上报给云端)

下面我们来聊聊属性控制中常用的功能-设备影子的实现原理,并通过一些细节来研究其交互过程。

设备影子作用

设备影子数据模型是一个json格式数据,主要用于:存储设备当前上报的属性值和IoT平台期望下发给设备的属性值,且设备影子功能只存储最近一次的上报值和属性值。用户可通过应用或直接操作IoT平台API来查询或修改设备影子,以达到获取设备最新属性或将期望属性下发给设备的操作。

引用自:https://support.huaweicloud.com/intl/zh-cn/productdesc-IoT/iot_04_0006.html (下文中应用程序简称应用)

  • 查询设备属性状态:
    应用直接向设备查询状态时,由于设备可能长时间处于离线状态或因网络不稳定掉线,因此不能及时获取设备当前的状态。使用设备影子机制,设备影子保存的是设备最新的状态,一旦设备状态产生变化,设备会将状态同步到设备影子。应用便可以及时获取查询结果,无需关注设备是否在线。
    很多应用频繁的查询设备状态,由于设备处理能力有限,频繁查询会损耗设备性能。使用设备影子机制,设备只需要主动同步状态给设备影子一次,多个应用程序请求设备影子获取设备状态,即可获取设备最新状态,从而将应用程序和设备解耦。

  • 修改设备属性信息:
    设备管理员通过管理门户或者调用IoT平台API接口修改设备的属性信息,由于设备可能长时间处于离线状态,修改设备属性的操作不能及时下发给设备。在这种情况下,IoT平台可以将修改设备的属性信息存储在设备影子中,待设备上线后,将修改的设备属性值同步给设备,从而完成设备属性的修改。

设备影子数据粒度

业界通常将设备影子数据定义为一个设备有且仅有一个,此时意味着当前设备中不能存在同名的属性,否则无法在设备影子进行区分。
假设某些公司或特殊场景中将设备划分为若干模块,不同模块中允许定义同名属性,此时设备影子使用业界常用的定义方式将会出现问题。针对当前情况,可以允许每个设备拥有多个设备影子数据,即:设备影子数据的粒度为设备-模块

设备影子粒度 每台设备拥有的影子个数 是否允许存在同名属性
设备 有且仅有一个 不允许
设备-模块 等于模块个数 不同模块中允许存在

设备影子数据格式

目前,由于没有任何国际组织对设备影子数据格式进行规范化。所以,一般参考常见云厂商影子数据格式。文档如下所示:

云厂商 文档地址
亚马逊 https://docs.aws.amazon.com/zh_cn/iot/latest/developerguide/iot-device-shadows.html
https://docs.aws.amazon.com/zh_cn/iot/latest/developerguide/using-device-shadows.html
阿里云 https://help.aliyun.com/document_detail/53932.html
https://help.aliyun.com/document_detail/53964.html

业界常用设备影子数据格式:

1
2
3
4
5
6
7
8
9
10
11
12
{
"state":{ // 设备属性状态数据
"desired":{}, // 期望设备属性达到的状态
"reported":{} // 设备属性最近一次上报的状态
},
"metadata":{ // 设备属性最近一次更新的相关元信息
"desired":{},
"reported":{}
},
"timestamp":0, // 最近一次更新影子数据的时间戳
"shadow_version": 1 // 当前设备影子数据版本号
}

为了在设备影子中直观地看出是属于哪个设备的,一般优化为如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"device_id": "device_id-xxxxx-yyyyy-zzzzz", // 当前设备id
"state":{
"desired":{},
"reported":{}
},
"metadata":{
"desired":{},
"reported":{}
},
"timestamp":0,
"shadow_version": 1
}

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
"device_id": "523dd7f49f6341198ec004d1c1d72972",
"state": {
"desired": {
"color": "RED",
"sequence": [ // 设备影子支持数组。更新数组时必须全量更新,不能只更新数组的某一部分
"RED",
"GREEN"
]
},
"reported": {
"color": "GREEN"
}
},
"metadata": {
"desired": {
"color": {
"timestamp": 1469564492
},
"sequence": {
"timestamp": 1469564492
}
},
"reported": {
"color": {
"timestamp": 1469564492
}
}
},
"timestamp": 1469564492,
"shadow_version": 100
}

如果设备影子粒度是设备-模块,则需要在影子数据中添加模块名称,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"device_id": "device_id-xxxxx-yyyyy-zzzzz", // 当前设备id
"module":"module_name", // 模块名称
"state":{
"desired":{}, // 期望设备属性达到的状态
"reported":{} // 设备属性最近一次上报的状态
},
"metadata":{ // 设备属性最近一次更新的时间戳
"desired":{},
"reported":{}
},
"timestamp":0, // 最近一次更新影子数据的时间戳
"shadow_version": 1 // 设备影子数据版本号
}

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"device_id": "523dd7f49f6341198ec004d1c1d72972",
"module":"light",
"state": {
"desired": {
"color": "RED",
"sequence": [
"RED",
"GREEN"
]
},
"reported": {
"color": "GREEN"
}
},
"metadata": {
"desired": {
"color": {
"timestamp": 1469564492
},
"sequence": {
"timestamp": 1469564492
}
},
"reported": {
"color": {
"timestamp": 1469564492
}
}
},
"timestamp": 1469564492,
"shadow_version": 100
}

设备影子版本号评估

假设每秒消耗 100万设备影子id,设备影子版本号使用 int64、uint64 类型分别可以使用的年限为:

数据类型 类型最大值 使用年限
int64 9223372036854775807 9223372036854775807/1000000/3600/24/365 = 292471 年
uint64 18446744073709551615 18446744073709551615/1000000/3600/24/365 = 584942 年

版本号的初始值为1,影子版本号可以使用发号器实现,依次递增每次+1,不会出现重复的情况

考虑编程语言对数据类型的支持,一般使用int64类型即可。

设备影子版本号发号器实现

通过设备影子版本号评估可以得知,使用int64作为设备影子版本号,会有一个发号器为每个设备影子生成新的可用版本号。考虑版本号是数值类型递增的,并且在一定的场景下需要满足一定的性能。可以使用Redis+DB来通过号段的方式,使用lua脚本来生成可用设备影子版本号。

由于设备影子发号器是基于号段来生成的,所以需要号段的最大值与当前可用值。在Redis中使用hash类型来表示一个设备影子的发号器结构,有2个filed来分别表示号段的最大值(v_h)与当前最大值(v_l),lua脚本代码逻辑实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- KEYS[1] 发号器结构的key
-- ARGV[1] 表示filed v_l
-- ARGV[2] 表示filed v_h

local v_l = redis.call('hget', KEYS[1], tostring(ARGV[1])) --号段的当前最大值(该值已被使用)
local v_h = redis.call('hget', KEYS[1], tostring(ARGV[2])) --号段的最大值

if v_h == false then --号段最大值为空,即:发号器数据结构在Redis中不存在
return -1
elseif v_l == v_h then --号段的最大值等于当前最大值,即:当前号段消耗完毕,需要再申请一段
return -2
else
return redis.call('hincrby', KEYS[1], tostring(ARGV[1]), 1) --通过incr获取新版本号
end

当发号器数据在Redis中不存在或者被消耗完,都需要申请一段新的号段。该号段数据的记录,则用DB设备影子数据中的字段max_version来表示。每次申请新的号段都会用到一个号段步长(step),步长数值大小可以视情况而定,一般设置为512或者1024。

发号器数据场景 DB中max_version Redis中新的号段空间 备注
发号器数据不存在 max_version+=2*step [max_version+=step,max_version+=2*step] 会跳过一段数值
发号器数据耗尽 max_version+=step [max_version,max_version+=step] 数值空间连续

对于现在的分布式服务来说同一个服务一般都是多个进程在运行,所以在出现发号器数据在Redis中不存在或者被消耗完,申请新的号段数据时需要使用分布式锁,抢到锁的进程去进行信号段的申请。

新号段申请操作过程如下所示:

  1. 去Redis获取新的版本号
  2. 发号器数据在Redis中不存在或者被消耗完,则抢分布式锁
  3. 抢锁成功则double check,再次去Redis获取新的版本号。如果发号器数据在Redis中显示仍旧不存在或者被消耗完,则进入下一步。否则返回获取的版本号
  4. 去DB申请新的号段
  5. 新的号段数据写会Redis
  6. 去Redis获取新的版本号并返回

发号器申请新的号段在并发场景下,一般只有一个进程从步骤1到步骤6执行一遍,其他的只是从步骤1到步骤3。

设备影子版本号判断逻辑

设备影子处理过程的基本逻辑:设备影子大版本号数据 覆盖 设备影子小版本号数据,设备影子小版本号数据在操作时不予处理。 对于应用、IoT平台、设备在数据流转过程中,对设备影子版本号判断逻辑如下所示:

- 应用 IoT平台 设备
应用 - ①应用带版本号大于IoT平台影子版本号则合并
②应用带版本号小于等于IoT平台影子版本号则不处理
③应用不带版本号则IoT平台自动生成并下发到设备
-
IoT平台 - - ①IoT平台下发设备影子版本号大于等于设备本地版本号设备则执行
②IoT平台下发设备影子版本号小于设备本地版本号设备则不予处理
设备 - ①设备上行版本号等于IoT平台版本号则处理
②设备上行版本号小于IoT平台版本号则不处理
③设备上行版本号不可能大于IoT平台版本号
-

设备影子数据的存储实现

设备影子数据作为一个端、云交互使用比较频繁的数据,在存储实现层面采用cache+DB的方式。

cache使用分布式Redis集群存储,即满足高性能的数据读、写,又满足大容量的数据存储。同时,由于某些设备可能会长时间不在线或者出库之后一直没有使用,这些设备影子数据存储在cache中会导致资源的浪费,所以会对Redis中存储的设备影子数据需要设置过期时间(一般为30天+随机5天时间)。 每次读、写Redis中存储的设备影子数据都会按照一定的概率重置过期时间,这样就可以使频繁操作的数据一直存储在Redis中。

DB作为设备影子数据持久化存储的一部分,用来在Redis中数据失效被淘汰之后,从DB中重新加载到Redis中使用。考虑数据的一致性与出现问题的概率,可以认为只要写Redis成功设备影子数据就是写成功。在设备影子数据Redis写成功之后,再通过队列异步的方式落库DB。此时,假设写Redis成功落库DB失败,也会在未来的某一时刻再次有新的设备影子数据落库成功,最终达到数据的一致性。

设备影子交互&场景分析

应用->IoT平台->设备

场景 情况 影子数据可能发生变动的数据
应用更新设备属性 - state.desired
metadata.desired
timestamp
shadow_version
应用获取设备属性(触达设备) ①阻塞
②非阻塞
-
应用获取设备属性(仅查询IoT平台影子数据) - -

设备->IoT平台->应用

场景 情况 影子数据可能发生变动的数据
设备连接云端 ①开机初次联网
②断网再次联网
state.desired
state.reported
metadata.reported
timestamp
shadow_version
设备本地属性变化主动上报 - state.reported
metadata.reported
timestamp
shadow_version

设备影子常见问题

  • 问1:什么时候设备影子版本号会发生变化?
    答:只要引发state.desired发生变化,版本号都+=1,其他情况版本号一般不变。

  • 问2:应用下行什么时候需要携带版本号?
    答:读操作不需要版本号,写操作可以先申请版本号(也支持不带版本号的情况,此时IoT平台自动生成)。

  • 问3:IoT平台下行desired时,云/端的交互动作都有哪些?
    答:state.desired非空,IoT平台下发desired,设备执行完之后上报执行结果与最新属性值。

  • 问4:应用与IoT平台交互动作有哪些?
    答:获取影子版本号,读、写设备属性数据。

应用->IoT平台->设备

场景1:应用更新设备属性

步骤 影子数据可能发生变动的数据 备注
第2步 发号器从设备影子号段中获取一个可用版本号 可选
第5步 ①合并应用desired数据到设备影子state.desired
②更新设备影子metadata.desired
③更新设备影子timestamp、shadow_version
第9步 ①删除设备影子state.desired中执行成功的属性
②更新设备影子metadata.reported
③更新设备影子timestamp、shadow_version

场景2:应用获取设备属性

情况1:穿透到设备(阻塞)

情况2:穿透到设备(非阻塞)

场景3:应用获取设备属性(仅读取设备影子)

场景2与场景3的主要区别有2点:
① 场景2触达设备,场景3不触达设备而直接查询IoT平台设备影子数据
② 场景2-情况1与场景3返回的数据格式一样,场景2-情况2返回2次数据,场景3仅返回一次数据
为了解决场景2、场景3的数据返回格式与数据流转的不同,可以统一使用场景2-情况2的交互方式。即:都采用异步的方式进行处理。无论是否触达设备都返回2次数据,先返回唯一消息id给应用,再返回具体的设备属性信息给应用。

设备->IoT平台->应用

场景1:设备连云时,云/端同步

情况1:设备初次联网

情况2:设备断网后再次联网

情况1与情况2看似交互流程、数据流转相同,但是却存在一些不同点:
① 情况1设备端发起请求时会携带开机联网标识符,是设备端主动获取
② 情况2由MQTT broker触发告知IoT平台,IoT平台主动下发需要执行的desired
③ 情况2被触发时,若IoT平台不存在需要执行的desired,则不会有desired下发到设备
④ 第一次开机联网与断网联网时,MQTT broker都会被触发并告知IoT平台,IoT平台无法有效区分,都会执行情况2
⑤ 可能存在情况2先被执行之后,情况1再被执行的情况,但最终结果不会对设备影子数据造成任何影响

场景2:设备本地属性发生变化,主动上报

小结

目前来看,并没有任何一家公司或者组织来针对该IoT能力标准化,业界都是基于相似json格式通过数据流转来实现。对于设备影子来说,其仅仅作为IoT领域的一个小功能,实现设备属性信息在云端数据的一个映射,能够解决一些问题,提高查询效率、减少设备功耗。
通过上文可以看出其实现逻辑比较复杂,甚至有些场景还需要取舍。考虑设备影子数据不需要强一致性,所以在设计时只要实现最终一致性即可。