你当前正在访问 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 客户端的连接。
  • 关联数据。 当状态存储发送响应时,其中将包括初始请求的关联数据。

下图显示了扩展的请求和响应视图:

状态存储扩展请求和响应过程示意图。

支持的命令

命令 SETGETDEL 按预期方式运行。

SET 命令设置的值和 GET 命令检索的值均可为任意二进制数据。 这些值的大小仅受最大 MQTT 有效负载大小以及 MQTT 代理和客户端的资源限制的约束。

SET 选项

SET 命令提供除基本 keyValuekeyName 以外的更多可选标记:

  • NX。 仅当键尚不存在时,才允许设置键。
  • NEX <value>。 仅当键不存在或键的值已设置为 <value> 时,才允许设置键。 NEX 标记通常用于供客户端对键进行延期 (PX)。
  • PX。 键在过期之前应保留的时间(以毫秒为单位)。

VDEL 选项

VDEL 命令是 DEL 命令的一种特殊情况。 DEL 将无条件删除给定的 keyNameVDEL 需要另一个参数,该参数名为 keyValueVDEL 仅会在给定 keyName 具有相同的 keyValue 时才会将其删除。

有效负载格式

状态存储 PUBLISH 有效负载格式的灵感来自 RESP3,这是 Redis 使用的底层协议。 RESP3 会对谓词(如 SETGET)和参数(如 keyNamekeyValue)进行编码。

事例敏感性

客户端必须同时发送大写的谓词和选项。

请求格式

请求的格式如下例所示。 在 RESP3 之后,* 表示数组中的项数。 $ 字符是以下行中的字符数(不包括尾随 CRLF)。

RESP3 格式支持的命令包括 GETSETDELVDEL

*{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 开头,并包含更多详细信息。

注意

针对不存在的键的 GETDELVDEL 请求不被视为错误。

如果客户端发送无效的有效负载,状态存储将发送如以下示例所示的有效负载:

-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>

DELVDEL 响应

状态存储将返回其在删除请求上删除的值数。 目前,状态存储一次只能删除一个值。

:{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 纪元以来的毫秒数。 counternode-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 更新的工作方式。

此版本还会在成功的 GETDELVDEL 请求中返回。 在这些请求中,客户端未指定 __ts

下图演示了 GET 命令:

获取值版本的状态存储示意图。

注意

状态存储返回的时间戳 __ts 与其在初始 SET 请求中返回的值相同。

如果稍后使用新的 SET 更新给定键,则该过程类似。 客户端应根据其当前时钟来设置其请求 __ts。 状态存储会按照 HLC 更新规则更新值的版本并返回 __ts

时钟偏差

状态存储会拒绝比状态存储的本地时钟早一分钟以上的 __ts(以及 __ft)。

状态存储会接受比状态存储的本地时钟晚的 __ts。 如 HLC 算法中指定的,状态存储会将键的版本设置为其本地时钟,因为该时钟值更大。

锁定和隔离令牌

本部分介绍锁定和隔离令牌的用途和用法。

背景

假设有两个或多个 MQTT 客户端在使用状态存储。 两个客户端都希望写入给定的键。 状态存储客户端需要一种机制来锁定该键,确保同一时间只有一个客户端可以修改给定键。

例如,活动和备用系统中便会出现这种情况。 可能会有两个客户端执行相同的操作,并且该操作可能包含相同的一组状态存储键。 在给定时间,其中一个客户端处于活动状态,另一个客户端处于备用状态(如果活动状态的系统挂起或崩溃,则立即接管)。 理想情况下,在给定时间应该只有一个客户端对状态存储执行写入。 但是,在分布式系统中,可能两个客户端的行为都像处于活动状态一样,并且它们可能同时尝试写入到相同的键。 此场景会创建争用条件。

状态存储提供了阻止此争用条件的机制,方法是使用隔离令牌。 如下详细了解隔离令牌及其旨在防范的争用条件的类别,请参阅此文章

获取隔离令牌

此示例假定我们拥有以下元素:

  • Client1Client2。 这些客户端是充当活动和备用对的状态存储客户端。
  • 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 的版本。

下图显示了 Client1LockName 执行 SET 请求的过程:

客户端对锁定名称属性执行设置请求的关系图。

接下来,Client1 将在修改 ProtectedKey 的请求中使用不经修改的 __ts 属性 (Property=1696374425000:1:StateStore) 作为 __ft 属性的基础。 与所有 SET 请求一样,客户端必须设置 ProtectedKey__ts 属性。

下图显示了 Client1ProtectedKey 执行 SET 请求的过程:

客户端对受保护键属性执行设置请求的关系图。

如果请求成功,则从此时起,ProtectedKey 需要一个等于或大于在 SET 请求中指定的值的隔离令牌。

隔离令牌算法

只要时钟值在最大时钟偏移范围内,状态存储会接受键值对的 __ts 的任何 HLC。 但是,隔离令牌却并非如此。

状态存储的隔离令牌算法如下所示:

  • 如果键值对没有关联的隔离令牌,但 SET 请求设置了 __ft,那么状态存储将存储该键值对的关联 __ft
  • 如果键值对具有关联的隔离令牌:
    • 如果 SET 请求未指定 __ft,则拒绝该请求。
    • 如果 SET 请求指定的 __ft 的 HLC 值早于与键值对关联的隔离令牌,则拒绝该请求。
    • 如果 SET 请求指定的 __ft 的 HLC 值等于或晚于与键值对关联的隔离令牌,则接受该请求。 当请求中设置的隔离令牌更新时,状态存储会将键值对的隔离令牌更新为该令牌。

使用隔离令牌标记键后,若要使请求成功,DELVDEL 请求也需要包含 __ft 属性。 该算法与上一个算法相同,只是由于正在删除键而未存储隔离令牌。

客户端行为

这些锁定机制依赖于行为适当的客户端。 在前面的示例中,行为不当的 Client2 无法拥有 LockName,但仍可通过选择比 ProtectedKey 令牌更新的隔离令牌来成功执行 SET ProtectedKey。 状态存储不知道 LockNameProtectedKey 是否有任何关系。 因此,状态存储不会验证 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} 如果存在与此请求相同的 keyNameclientId 的现有通知,状态存储会将其删除。

以下示例输出显示了监视键 SOMEKEYKEYNOTIFY 请求:

*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-id1NOTIFY 消息和修改后的键名称 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 - 删除值。 当状态存储客户端执行 DELVDEL 命令时将触发此操作。
  • 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 中。 有关详细信息,请参阅版本作为混合逻辑时钟部分。