你当前正在访问 Microsoft Azure Global Edition 技术文档网站。 如果需要访问由世纪互联运营的 Microsoft Azure 中国技术文档网站,请访问 https://docs.azure.cn。
状态存储协议
状态存储是 Azure IoT 操作群集中的分布式存储系统。 状态存储将提供与 MQTT 代理中的 MQTT 消息相同的高可用性保证。 根据 MQTT5/RPC 协议准则,客户端应使用 MQTT5 与状态存储进行交互。 本文为需要实现自己的状态存储客户端的开发人员提供协议指南。
概述
状态存储支持以下命令:
-
SET
<keyName><keyValue><setOptions> -
GET
<keyName> -
DEL
<keyName> -
VDEL
<keyName><keyValue> ## 删除给定的 <keyName>,前提是其值为 <keyValue>
协议将使用以下“请求-响应”模型:
- 请求。 客户端将请求发布到定义完善的状态存储系统主题。 为了发布请求,客户端将使用以下各部分中所述的必需属性和有效负载。
- Response。 状态存储将以异步方式处理请求,并对客户端最初提供的响应主题进行响应。
下图显示了基本的请求和响应视图:
状态存储系统主题、QoS 和所需的 MQTT5 属性
若要与状态存储通信,客户端必须满足以下要求:
- 使用 MQTT5。 有关详细信息,请参阅 MQTT 5 规范。
- 使用 QoS 1(服务质量级别 1)。 MQTT 5 规范中介绍了 QoS 1。
- 拥有一个与 MQTT 代理的时钟相距不到一分钟的时钟。
若要与状态存储通信,客户端必须将请求 PUBLISH
到系统主题 statestore/v1/FA9AE35F-2F64-47CD-9BFF-08E2B32A0FE8/command/invoke
。 由于状态存储是 Azure IoT 操作的一部分,因此它在启动时对本主题执行隐式 SUBSCRIBE
。
若要生成请求,需要提供以下 MQTT5 属性。 如果这些属性不存在,或者请求的类型不是 QoS 1,则请求将失败。
-
响应主题。 状态存储将使用此值来响应初始请求。 最佳做法是将响应主题的格式设置为以下形式:
clients/{clientId}/services/statestore/_any_/command/invoke/response
。 状态存储请求不允许将响应主题设置为statestore/v1/FA9AE35F-2F64-47CD-9BFF-08E2B32A0FE8/command/invoke
或以clients/statestore/v1/FA9AE35F-2F64-47CD-9BFF-08E2B32A0FE8
开头的主题。 状态存储会断开使用无效响应主题的 MQTT 客户端的连接。 - 关联数据。 当状态存储发送响应时,其中将包括初始请求的关联数据。
下图显示了扩展的请求和响应视图:
支持的命令
命令 SET
、GET
和 DEL
按预期方式运行。
SET
命令设置的值和 GET
命令检索的值均可为任意二进制数据。 这些值的大小仅受最大 MQTT 有效负载大小以及 MQTT 代理和客户端的资源限制的约束。
SET
选项
SET
命令提供除基本 keyValue
和 keyName
以外的更多可选标记:
-
NX
。 仅当键尚不存在时,才允许设置键。 -
NEX <value>
。 仅当键不存在或键的值已设置为 <value> 时,才允许设置键。NEX
标记通常用于供客户端对键进行延期 (PX
)。 -
PX
。 键在过期之前应保留的时间(以毫秒为单位)。
VDEL
选项
VDEL
命令是 DEL
命令的一种特殊情况。
DEL
将无条件删除给定的 keyName
。
VDEL
需要另一个参数,该参数名为 keyValue
。
VDEL
仅会在给定 keyName
具有相同的 keyValue
时才会将其删除。
有效负载格式
状态存储 PUBLISH
有效负载格式的灵感来自 RESP3,这是 Redis 使用的底层协议。 RESP3 会对谓词(如 SET
或 GET
)和参数(如 keyName
和 keyValue
)进行编码。
事例敏感性
客户端必须同时发送大写的谓词和选项。
请求格式
请求的格式如下例所示。 在 RESP3 之后,*
表示数组中的项数。
$
字符是以下行中的字符数(不包括尾随 CRLF)。
RESP3 格式支持的命令包括 GET
、SET
、DEL
和 VDEL
。
*{NUMBER-OF-ARGUMENTS}<CR><LF>
${LENGTH-OF-NEXT-LINE}<CR><LF>
{COMMAND-NAME}<CR><LF>
${LENGTH-OF-NEXT-LINE}<CR><LF> // This is always the keyName with the current supported verbs.
{KEY-NAME}<CR><LF>
// Next lines included only if command has additional arguments
${LENGTH-OF-NEXT-LINE}<CR><LF> // This is always the keyValue for set
{KEY-VALUE}<CR><LF>
以下示例输出显示状态存储 RESP3 有效负载:
*3<CR><LF>$3<CR><LF>set<CR><LF>$7<CR><LF>SETKEY2<CR><LF>$6<CR><LF>VALUE5<CR><LF>
*2<CR><LF>$3<CR><LF>get<CR><LF>$7<CR><LF>SETKEY2<CR><LF>
*2<CR><LF>$3<CR><LF>del<CR><LF>$7<CR><LF>SETKEY2<CR><LF>
*3<CR><LF>$4<CR><LF>vdel<CR><LF>$7<CR><LF>SETKEY2<CR><LF>$3<CR><LF>ABC<CR><LF>
注意
SET
需要其他 MQTT5 属性,如版本控制和混合逻辑时钟部分中所述。
响应格式
当状态存储检测到无效的 RESP3 有效负载时,它仍会返回对请求者的 Response Topic
的响应。 无效有效负载的示例包括无效命令、非法 RESP3 或整数溢出。 无效的有效负载以字符串 -ERR
开头,并包含更多详细信息。
注意
针对不存在的键的 GET
、DEL
或 VDEL
请求不被视为错误。
如果客户端发送无效的有效负载,状态存储将发送如以下示例所示的有效负载:
-ERR syntax error
SET
响应
当 SET
请求成功时,状态存储将返回以下有效负载:
+OK<CR><LF>
如果 SET 请求由于 NX 或 NEX 设置选项中指定的条件检查而失败,意味着无法设置该键,则状态存储会返回以下有效负载:
-1<CR><LF>
GET
响应
当针对不存在的键发出 GET
请求时,状态存储将返回以下有效负载:
$-1<CR><LF>
如果找到了相应键,状态存储将返回以下格式的值:
${NumberOfBytes}<CR><LF>
{KEY-VALUE}
返回值 1234
的状态存储的输出将如以下示例所示:
$4<CR><LF>1234<CR><LF>
DEL
和 VDEL
响应
状态存储将返回其在删除请求上删除的值数。 目前,状态存储一次只能删除一个值。
:{NumberOfDeletes}<CR><LF> // Will be 1 on successful delete or 0 if the keyName is not present
以下输出是成功的 DEL
命令的示例:
:1<CR><LF>
如果 VDEL 请求由于指定的值与与键关联的值不匹配而失败,则状态存储会返回以下有效负载:
-1<CR><LF>
-ERR
响应
下面是错误字符串的当前列表。 客户端应用程序应该处理未知错误字符串以支持状态存储的更新。
从状态存储返回的错误字符串 | 说明 |
---|---|
请求时间戳在太远的未来;请确保客户端和代理系统时钟同步 | 由于状态存储和客户端时钟不同步而导致的意外的请求时间戳。 |
此请求需要隔离令牌 | 如果键标有隔离令牌,但客户端未指定隔离令牌,则会发生错误。 |
请求隔离令牌时间戳在太远的未来;请确保客户端和代理系统时钟同步 | 由于状态存储和客户端时钟不同步而导致的意外的隔离令牌时间戳。 |
请求隔离令牌的版本低于保护资源的隔离令牌 | 请求隔离令牌版本不正确。 有关详细信息,请参阅[版本控制和混合逻辑时钟]。(#versioning-and-hybrid-logical-clocks) |
已超出配额 | 状态存储可以存储的键数量有一定的配额,这取决于指定的 MQTT 代理的内存配置文件。 |
语法错误 | 发送的有效负载不符合状态存储的定义。 |
未授权 | 授权错误 |
未知命令 | 无法识别命令。 |
参数数目不正确 | 预期参数的数目不正确。 |
缺少时间戳 | 客户端在执行 SET 时必须将 MQTT5 用户属性 __ts 设置为表示其时间戳的 HLC。 |
时间戳格式不正确 | __ts 或隔离令牌中的时间戳不合法。 |
键长度为零 | 在状态存储中,键不能为零长度。 |
版本控制和混合逻辑时钟
本部分介绍状态存储如何处理版本控制。
以混合逻辑时钟形式表示的版本
状态存储会为它存储的每个值维护一个版本。 状态存储可以使用单调增加的计数器来维护版本。 而在表示版本时,状态存储则会使用混合逻辑时钟 (HLC)。 有关详细信息,请参阅有关 HLC 的原始设计和 HLC 背后的意图的文章。
状态存储使用以下格式来定义 HLC:
{wallClock}:{counter}:{node-Id}
wallClock
是 Unix 纪元以来的毫秒数。
counter
和 node-Id
通常充当 HLC。
当客户端执行 SET
时,它们必须根据客户端的当前时钟将 MQTT5 用户属性 __ts
设置为表示其时间戳的 HLC。 状态存储将在其响应消息中返回值的版本。 该响应还将被指定为 HLC,并使用 __ts
MQTT5 用户属性。 返回的 HLC 必然大于初始请求的 HLC。
设置和检索值版本的示例
本部分展示了一个设置和获取值版本的示例。
客户端设置了 keyName=value
。 客户端时钟为 10 月 3 日晚上 11:07:05(格林尼治标准时间)。 自 Unix 纪元以来的时钟值为 1696374425000
毫秒。 假设状态存储的系统时钟与客户端系统时钟相同。 如前所述,客户端将执行 SET
命令。
下图演示了 SET
命令:
初始集上的 __ts
属性(时间戳)将包含 1696374425000
(作为客户端时钟)、计数器(作为 0
)及其节点 ID(作为 CLIENT
)。 在响应中,状态存储返回的 __ts
属性将包含 wallClock
、实现了计数加一的计数器及其节点 ID(作为 StateStore
)。 如果状态存储的时钟提前,状态存储可能会返回更高的 wallClock
值,具体取决于 HLC 更新的工作方式。
此版本还会在成功的 GET
、DEL
和 VDEL
请求中返回。 在这些请求中,客户端未指定 __ts
。
下图演示了 GET
命令:
注意
状态存储返回的时间戳 __ts
与其在初始 SET
请求中返回的值相同。
如果稍后使用新的 SET
更新给定键,则该过程类似。 客户端应根据其当前时钟来设置其请求 __ts
。 状态存储会按照 HLC 更新规则更新值的版本并返回 __ts
。
时钟偏差
状态存储会拒绝比状态存储的本地时钟早一分钟以上的 __ts
(以及 __ft
)。
状态存储会接受比状态存储的本地时钟晚的 __ts
。 如 HLC 算法中指定的,状态存储会将键的版本设置为其本地时钟,因为该时钟值更大。
锁定和隔离令牌
本部分介绍锁定和隔离令牌的用途和用法。
背景
假设有两个或多个 MQTT 客户端在使用状态存储。 两个客户端都希望写入给定的键。 状态存储客户端需要一种机制来锁定该键,确保同一时间只有一个客户端可以修改给定键。
例如,活动和备用系统中便会出现这种情况。 可能会有两个客户端执行相同的操作,并且该操作可能包含相同的一组状态存储键。 在给定时间,其中一个客户端处于活动状态,另一个客户端处于备用状态(如果活动状态的系统挂起或崩溃,则立即接管)。 理想情况下,在给定时间应该只有一个客户端对状态存储执行写入。 但是,在分布式系统中,可能两个客户端的行为都像处于活动状态一样,并且它们可能同时尝试写入到相同的键。 此场景会创建争用条件。
状态存储提供了阻止此争用条件的机制,方法是使用隔离令牌。 如下详细了解隔离令牌及其旨在防范的争用条件的类别,请参阅此文章。
获取隔离令牌
此示例假定我们拥有以下元素:
-
Client1
和Client2
。 这些客户端是充当活动和备用对的状态存储客户端。 -
LockName
。 状态存储中充当锁的键的名称。 -
ProtectedKey
。 需要防止多个编写器进行写入的密钥。
客户端将首先尝试获取锁。 它们通过执行 SET LockName {CLIENT-NAME} NEX PX {TIMEOUT-IN-MILLISECONDS}
来获得锁。 回想一下 Set 选项部分,其中提到过 NEX
标志意味着仅当满足以下条件之一时,SET
才会成功:
- 键为空
- 键的值已设置为 <value>,并且
PX
指定了超时时间(以毫秒为单位)。
假设 Client1
首先执行请求 SET LockName Client1 NEX PX 10000
。 此请求使其拥有 LockName
10,000 毫秒。 如果 Client2
尝试 SET LockName Client2 NEX ...
,而 Client1
拥有相应锁,则 NEX
标志意味着该 Client2
请求失败。 如果 Client1
想要继续拥有此锁,Client1
需要发送用于获取该锁的相同 SET
命令来续订此锁。
注意
SET NX
在概念上等效于 AcquireLock()
。
在 SET 请求中使用隔离令牌
当 Client1
成功在 LockName
上执行 SET
(“AcquireLock”)时,状态存储将在 MQTT5 用户属性 __ts
中以混合逻辑时钟 (HLC) 的形式返回 LockName
的版本。
当客户端执行 SET
请求时,它可以选择性地包含 MQTT5 用户属性 __ft
以代表“防护令牌”。
__ft
将表示为 HLC。 与给定键值对关联的隔离令牌将提供锁所有权检查。 隔离令牌可以来自任何地方。 对于此方案,它应该来自 LockName
的版本。
下图显示了 Client1
对 LockName
执行 SET
请求的过程:
接下来,Client1
将在修改 ProtectedKey
的请求中使用不经修改的 __ts
属性 (Property=1696374425000:1:StateStore
) 作为 __ft
属性的基础。 与所有 SET
请求一样,客户端必须设置 ProtectedKey
的 __ts
属性。
下图显示了 Client1
对 ProtectedKey
执行 SET
请求的过程:
如果请求成功,则从此时起,ProtectedKey
需要一个等于或大于在 SET
请求中指定的值的隔离令牌。
隔离令牌算法
只要时钟值在最大时钟偏移范围内,状态存储会接受键值对的 __ts
的任何 HLC。 但是,隔离令牌却并非如此。
状态存储的隔离令牌算法如下所示:
- 如果键值对没有关联的隔离令牌,但
SET
请求设置了__ft
,那么状态存储将存储该键值对的关联__ft
。 - 如果键值对具有关联的隔离令牌:
- 如果
SET
请求未指定__ft
,则拒绝该请求。 - 如果
SET
请求指定的__ft
的 HLC 值早于与键值对关联的隔离令牌,则拒绝该请求。 - 如果
SET
请求指定的__ft
的 HLC 值等于或晚于与键值对关联的隔离令牌,则接受该请求。 当请求中设置的隔离令牌更新时,状态存储会将键值对的隔离令牌更新为该令牌。
- 如果
使用隔离令牌标记键后,若要使请求成功,DEL
和 VDEL
请求也需要包含 __ft
属性。 该算法与上一个算法相同,只是由于正在删除键而未存储隔离令牌。
客户端行为
这些锁定机制依赖于行为适当的客户端。 在前面的示例中,行为不当的 Client2
无法拥有 LockName
,但仍可通过选择比 ProtectedKey
令牌更新的隔离令牌来成功执行 SET ProtectedKey
。 状态存储不知道 LockName
和 ProtectedKey
是否有任何关系。 因此,状态存储不会验证 Client2
是否实际拥有该值。
客户端能够写入其实际上并不拥有锁的键,这是一种不可取的行为。 通过正确实现客户端并使用身份验证将可访问键的对象限制为仅限受信任的客户端,可以防止此类客户端不当行为。
通知
客户端可以向状态存储注册以接收键被修改的通知。 请考虑恒温器使用状态存储键 {thermostatName}\setPoint
的场景。 其他状态存储客户端可以更改此键的值来更改恒温器的 setPoint。 恒温器可以向状态存储注册以在 {thermostatName}\setPoint
被修改时接收消息,而不是轮询更改。
KEYNOTIFY 请求消息
状态存储客户端通过发送 KEYNOTIFY
消息来请求状态存储监视给定 keyName
的更改。 与所有状态存储请求一样,客户端通过 MQTT5 将包含此消息的 QoS1 消息发布到状态存储系统主题 statestore/v1/FA9AE35F-2F64-47CD-9BFF-08E2B32A0FE8/command/invoke
。
请求工作负载的格式如下:
KEYNOTIFY<CR><LF>
{keyName}<CR><LF>
{optionalFields}<CR><LF>
其中:
- KEYNOTIFY 是指定命令的字符串字面量。
-
{keyName}
是要侦听通知的键名称。 目前不支持通配符。 -
{optionalFields}
当前支持的可选字段值包括:-
{STOP}
如果存在与此请求相同的keyName
和clientId
的现有通知,状态存储会将其删除。
-
以下示例输出显示了监视键 SOMEKEY
的 KEYNOTIFY
请求:
*2<CR><LF>
$9<CR><LF>
KEYNOTIFY<CR><LF>
$7<CR><LF>
SOMEKEY<CR><LF>
KEYNOTIFY 响应消息
与所有状态存储 RPC 请求一样,状态存储将返回其对 Response Topic
的响应,并使用初始请求中指定的 Correlation Data
属性。 对于 KEYNOTIFY
,成功的响应指示状态存储处理了请求。 状态存储成功处理请求后,它会监视当前客户端的键或停止监视。
成功后,状态存储的响应与成功的 SET
相同。
+OK<CR><LF>
如果客户端发送 KEYNOTIFY SOMEKEY STOP
请求,但状态存储未监视该键,则状态存储的响应与尝试删除不存在的键相同。
:0<CR><LF>
任何其他失败都遵循状态存储的常规错误报告模式:
-ERR: <DESCRIPTION OF ERROR><CR><LF>
KEYNOTIFY 通知主题和生命周期
修改或删除通过 KEYNOTIFY
监视 keyName
时,状态存储会向客户端发送通知。 主题由约定确定 - 客户端不会在 KEYNOTIFY
过程中指定主题。
主题的定义如下例所示。
clientId
是发起 KEYNOTIFY
请求的客户端 MQTT ClientId 的大写十六进制编码表示形式,keyName
是已更改的键的十六进制编码表示形式。 状态存储遵循 RFC 4648 - Base16、Base32 和 Base64 数据编码的 Base 16 编码规则进行编码。
clients/statestore/v1/FA9AE35F-2F64-47CD-9BFF-08E2B32A0FE8/{clientId}/command/notify/{keyName}
例如,MQTT 代理会将发送到 client-id1
的 NOTIFY
消息和修改后的键名称 SOMEKEY
发布到主题:
clients/statestore/v1/FA9AE35F-2F64-47CD-9BFF-08E2B32A0FE8/636C69656E742D696431/command/notify/534F4D454B4559`
使用通知的客户端应 SUBSCRIBE
本主题,并等到收到 SUBACK
后才发送任何 KEYNOTIFY
请求,以免丢失任何消息。
如果客户端断开连接,则它必须重新订阅 KEYNOTIFY
通知主题,并为它需要继续监视的任何键重新发送 KEYNOTIFY
命令。 与可在不干净会话中持久保存的 MQTT 订阅不同,当给定客户端断开连接时,状态存储会在内部删除任何 KEYNOTIFY
消息。
KEYNOTIFY 通知消息格式
修改通过 KEYNOTIFY
监视的键时,状态存储会按照为更改注册的状态存储客户端的格式向通知主题 PUBLISH
消息。
NOTIFY<CR><LF>
{operation}<CR><LF>
{optionalFields}<CR><LF>
消息包含以下详细信息:
-
NOTIFY
是作为有效负载中第一个参数包含的字符串字面量,表示通知已到达。 -
{operation}
是发生的事件。 目前,这些操作包括:-
SET
- 修改值。 仅当状态存储客户端执行SET
命令时才会触发此操作。 -
DEL
- 删除值。 当状态存储客户端执行DEL
或VDEL
命令时将触发此操作。
-
optionalFields
-
VALUE
和{MODIFIED-VALUE}
。VALUE
是一个字符串字面量,指示下一个字段{MODIFIED-VALUE}
,并包含键更改为的值。 此值仅在响应因SET
而修改的键时发送。
-
以下示例输出显示当键 SOMEKEY
修改为值 abc
时发送的通知消息,其中包含 VALUE
,因为初始请求指定了 GET
选项:
*4<CR><LF>
$6<CR><LF>
NOTIFY<CR><LF>
$3<CR><LF>
SET<CR><LF>
$5<CR><LF>
VALUE<CR><LF>
$3<CR><LF>
abc<CR><LF>
KEYNOTIFY
通知消息包含在通知客户端有关 SET 请求(值已更新)或通知客户端有关 DEL 或 VDEL 请求(值已删除)时的值的时间戳。 时间戳包含在消息的 MQTT v5 用户属性 __ts 中。 有关详细信息,请参阅版本作为混合逻辑时钟部分。