Azure 存储提供程序(Azure Functions)
本文档介绍 Durable Functions Azure 存储提供程序的特征,重点介绍性能和可伸缩性方面。 Azure 存储提供程序是默认提供程序。 它将实例状态和队列存储在 Azure 存储(经典)帐户中。
注意
要详细了解 Durable Functions 支持的存储提供程序以及它们之间的比较,请参阅 Durable Functions 存储提供程序文档。
在 Azure 存储提供程序中,所有函数执行都由 Azure 存储队列驱动。 业务流程和实体状态以及历史记录存储在 Azure 表中。 Azure Blob 和 Blob 租用用于将业务流程实例和实体分布到多个应用实例(也称为辅助角色或简称 VM)中 。 本部分详细介绍了各种 Azure 存储项目及其对性能和可伸缩性的影响。
存储表示形式
任务中心持久保存所有实例状态和所有消息。 有关如何使用这些内容跟踪业务流程进度的快速概述,请参阅任务中心执行示例。
Azure 存储提供程序使用以下组件表示存储中的任务中心:
- 介于两个到三个 Azure 表之间。 两个表用于表示历史记录和实例状态。 如果启用了表分区管理器,则会引入第三个表来存储分区信息。
- 一个 Azure 队列存储活动消息。
- 一个或多个 Azure 队列存储实例消息。 每个所谓的控制队列都表示一个分区,根据实例 ID 的哈希分配所有实例消息的子集。
- 几个额外的 Blob 容器,用于租用 Blob 和/或大型消息。
例如,名为 xyz
的任务中心 (PartitionCount = 4
) 包含以下队列和表:
接下来,我们将更详细地介绍这些组件及其扮演的角色。
历史记录表
“历史记录”表是一个 Azure 存储表,包含任务中心内所有业务流程实例的历史记录事件。 此表的名称采用 TaskHubNameHistory 格式。 当实例运行时,会在此表中添加新行。 此表的分区键派生自业务流程的实例 ID。 默认情况下,实例 ID 是随机的,这是为了确保在 Azure 存储中以最佳方式分配内部分区。 此表的行键是用于对历史记录事件进行排序的序列号。
需要运行业务流程实例时,将使用单个表分区内的范围查询将“历史记录”表中的相应行载入内存。 然后,这些历史记录事件将重播到业务流程协调程序函数代码中,使其恢复到以前的检查点状态。
提示
“历史记录”表中存储的业务流程数据包括来自活动函数和子业务流程函数的输出有效负载。 来自外部事件的有效负载也存储在“历史记录”表中。 由于每次需要执行业务流程时都会将完整的历史记录载入内存,因此,如果历史记录足够大,可能会对指定 VM 带来巨大的内存压力。 可将大型业务流程拆分为多个子业务流程,或减小其调用的活动函数和子业务流程函数返回的输出大小,从而减小业务流程历史记录的长度和大小。 或者,也可降低每个 VM 的并发限制(用于限制同时加载到内存中的业务流程数),从而减少内存使用量。
实例表
“实例”表包含任务中心内所有业务流程和实体实例的状态。 创建实例时,会在此表中添加新行。 此表的分区键是业务流程实例 ID 或实体键,行键是空字符串。 每个业务流程或实体实例对应一行。
使用此表可满足来自代码以及状态查询 HTTP API 调用的实例查询请求。 它与前面所述的“历史记录”表内容保持最终一致。
提示
“实例”表进行分区后,可存储数百万个业务流程实例,而不会对运行时性能或缩放产生任何显著影响。 但实例数可能会对多实例查询性能产生重大影响。 若要控制这些表中存储的数据量,请考虑定期清除旧实例数据。
分区表
注意
仅当启用 Table Partition Manager
时,此表才会显示在任务中心。 若要应用它,请在应用的 host.json 中配置 useTablePartitionManagement
设置。
分区表存储 Durable Functions 应用的分区状态,用于在应用的辅助角色之间分配分区。 每个分区有一行。
队列
业务流程协调程序、实体和活动函数均由函数应用任务中心内的内部队列触发。 以这种方式使用队列可以提供可靠的“至少一次”消息传送保证。 Durable Functions 中有两种类型的队列:“控制队列”和“工作项队列” 。
工作项队列
Durable Functions 中的每个任务中心都有一个工作项队列。 它是一个基本队列,其行为类似于 Azure Functions 中的其他任何 queueTrigger
队列。 此队列每次将一条消息取消排队,可用于触发无状态活动函数。 其中的每个消息包含活动函数输入和其他元数据,例如要执行的函数。 当 Durable Functions 应用程序横向扩展到多个 VM 时,这些 VM 将会竞争,以从工作项队列中获取任务。
控制队列
Durable Functions 中的每个任务中心有多个控制队列。 与较为简单的工作项队列相比,控制队列更加复杂。 控制队列用于触发有状态的业务流程协调程序和实体函数。 由于业务流程协调程序和实体函数实例均为有状态的单一实例,因此,每个业务流程协调程序或实体一次仅由一个辅助角色处理非常重要。 为实现此约束,每个业务流程实例或实体均分配给一个控制队列。 这些控制队列会在辅助角色之间进行负载均衡,以确保每个队列一次仅由一个辅助角色处理。 后续部分将会更详细地介绍此行为。
控制队列包含各种业务流程生命周期消息类型。 示例包括业务流程协调程序控制消息、活动函数响应消息和计时器消息。 在单次轮询中,最多会从一个控制队列中取消 32 条消息的排队。 这些消息包含有效负载数据以及元数据,包括适用的业务流程实例。 如果将多个取消排队的消息用于同一业务流程实例,将会批处理这些消息。
系统将使用后台线程不断轮询控制队列消息。 每个队列轮询的批次大小由 host.json 中的 controlQueueBatchSize
设置控制,默认值为 32(Azure 队列支持的最大值)。 内存中缓冲的预提取控制队列消息的最大数目由 host.json 中的 controlQueueBufferThreshold
设置控制。 controlQueueBufferThreshold
的默认值取决于各种因素,包括托管计划的类型。 要详细了解这些设置,请参阅 host.json 架构文档。
提示
增加 controlQueueBufferThreshold
的值可让单个业务流程或实体更快地处理事件。 但增大此值也可能导致内存使用量增加。 内存使用率之所以增高,一部分原因是从队列中提取了更多的消息,还有一部分原因是将更多的业务流程历史记录提取到了内存中。 因此,减小 controlQueueBufferThreshold
的值可能是减少内存使用量的有效方法。
队列轮询
Durable Task 扩展实现了随机指数退让算法,以降低空闲队列轮询对存储事务成本造成的影响。 找到消息时,运行时会立即检查另一条消息。 如果未找到消息,它将等待一段时间,然后重试。 如果后续尝试获取队列消息失败,则等待时间会继续增加,直到达到最长等待时间(默认为 30 秒)。
可以通过 host.json 文件中的 maxQueuePollingInterval
属性配置最大轮询延迟。 将此属性设置为较高的值时,可能导致的消息处理延迟也越高。 只有在不活动的时间段过后,才会出现较高的延迟。 将此属性设置为较低的值时,可能导致的存储成本会较高,因为存储事务数增多。
注意
在 Azure Functions 消耗计划和高级计划中运行时,Azure Functions 缩放控制器每隔 10 秒就会轮询每个控件和工作项队列一次。 若要确定何时激活函数应用实例并进行缩放决策,这种额外的轮询是必需的。 在撰写本文时,这种 10 秒的时间间隔为常量,不能进行配置。
业务流程启动延迟
通过在某个任务中心的控制队列中放置 ExecutionStarted
消息来启动业务流程实例。 在某些情况下,你可能会观察到计划的业务流程运行时间与它实际开始运行的时间之间存在数秒延迟。 在此时间间隔内,业务流程实例仍处于 Pending
状态。 造成这种延迟的潜在原因有两个:
积压的控制队列:如果此实例的控制队列包含大量消息,则运行时可能需要一段时间才能接收并处理
ExecutionStarted
消息。 当业务流程同时处理大量事件时,可能会出现消息积压。 进入控制队列的事件包括业务流程启动事件、活动完成、持久计时器、终止和外部事件。 如果在正常情况下发生此延迟,请考虑创建具有大量分区的新任务中心。 配置更多分区会使运行时创建更多用于分配负载的控制队列。 每个分区对应 1:1,其中包含一个控制队列,分区数量最多为 16 个。回退轮询延迟:业务流程延迟的另一个常见原因是之前所说的控制队列的回退轮询行为。 但是,仅当应用横向扩展到两个或更多实例时,才会出现这种延迟。 如果只有一个应用实例,或启动业务流程的应用实例与轮询目标控制队列的是同一个实例,则不会出现队列轮询延迟。 如前所述,可以通过更新 host.json 设置来减少回退轮询延迟。
Blob
大多数情况下,Durable Functions 不会使用 Azure 存储 Blob 来保留数据。 但是,队列和表的大小限制可能会阻止 Durable Functions 将所有必需数据保留到存储行或队列消息中。 例如,当序列化后需要保留到队列中的一段数据大于 45 KB 时,Durable Functions 会改为压缩数据并将其存储在 blob 中。 以这种方式将数据保留到 blob 存储时,Durable Functions 会在表行或队列消息中存储对该 blob 的引用。 当 Durable Functions 需要检索数据时,它会自动从 blob 中提取数据。 这些 blob 存储在 blob 容器 <taskhub>-largemessages
中。
性能注意事项
对于较大的消息,额外的压缩和 blob 操作步骤可能会导致 CPU 和 I/O 延迟方面的成本高昂。 此外,Durable Functions 需要在内存中加载保留的数据,并且可能会同时为许多不同的函数执行而这样做。 因此,保留较大的数据有效负载也可能导致内存使用率过高。 若要最大程度地减少内存开销,请考虑手动保留较大的数据有效负载(例如,将其保留在 blob 存储中),改为传递对该数据的引用。 这样,代码就可以仅在需要时加载数据,避免在业务流程协调程序函数重播期间出现冗余负载。 但是,不建议将有效负载存储到本地磁盘,因为不能保证磁盘上的状态可用,因为函数可能会在其整个生命周期内在不同的 VM 上执行。
存储帐户的选择
在配置的 Azure 存储帐户中创建 Durable Functions 使用的队列、表和 Blob。 可以使用 host.json 文件中的 durableTask/storageProvider/connectionStringName
设置(或 Durable Functions 1.x 中的 durableTask/azureStorageConnectionStringName
设置指定要使用的帐户。
Durable Functions 2.x
{
"extensions": {
"durableTask": {
"storageProvider": {
"connectionStringName": "MyStorageAccountAppSetting"
}
}
}
}
Durable Functions 1.x
{
"extensions": {
"durableTask": {
"azureStorageConnectionStringName": "MyStorageAccountAppSetting"
}
}
}
如果未指定,则使用默认的 AzureWebJobsStorage
存储帐户。 但是,对于性能敏感型工作负荷,我们建议配置非默认的存储帐户。 Durable Functions 重度使用 Azure 存储,它通过专用的存储帐户将 Durable Functions 存储的使用与 Azure Functions 主机的内部使用隔离开来。
注意
使用 Azure 存储提供程序时,需要标准常规用途 Azure 存储帐户。 所有其他存储帐户类型均不受支持。 强烈建议将旧版 v1 常规用途存储帐户用于 Durable Functions。 对于 Durable Functions 工作负载,使用较新的 v2 存储帐户可能要贵得多。 若要详细了解 Azure 存储帐户类型,请参阅存储帐户概述文档。
业务流程协调程序横向扩展
尽管可以通过灵活添加更多的 VM 横向扩展活动功能,但单个协调程序实例和实体仅可拥有单个分区,最大分区数受到partitionCount
设置限制(host.json
中)。
注意
一般而言,业务流程协调程序函数是轻量型的,应该不需要大量的计算能力。 因此,无需创建大量的控制队列分区即可让业务流程获得极佳的吞吐量。 大部分繁重工作应在可无限横向扩展的无状态活动函数中完成。
控制队列的数目在 host.json 文件中定义。 以下示例 host.json 片段将 durableTask/storageProvider/partitionCount
属性(或 Durable Functions 1.x 中的 durableTask/partitionCount
)设置为 3
。 请注意,控制队列与分区数量相同。
Durable Functions 2.x
{
"extensions": {
"durableTask": {
"storageProvider": {
"partitionCount": 3
}
}
}
}
Durable Functions 1.x
{
"extensions": {
"durableTask": {
"partitionCount": 3
}
}
}
可将任务中心配置为包含 1 到 16 个分区。 如果未指定分区数,则会使用默认分区数 4。
通信量较少时,应用程序将缩放,因此分区将由少量的辅助角色管理。 以下图为例。
上图表明协调程序 1 到 6 在分区之间进行负载均衡。 同样,分区(如活动)在辅助角色之间进行负载均衡。 无论启动多少业务流程协调程序,分区均可在辅助角色之间进行负载平衡。
如果在 Azure Functions 消耗或弹性高级计划中运行,或者如果配置了基于负载的自动缩放,则在通信量增加时将分配更多的辅助角色,而分区最终会在所有辅助角色之间进行负载均衡。 如果继续横向扩展,则最终每个分区由单个辅助角色管理。 另一方面,活动将继续在所有辅助角色之间进行负载均衡。 下图显示了这种方法。
在“任意给定时间”,最大并发活动业务流程数的上限等于分配给应用程序的辅助角色数量乘以 maxConcurrentOrchestratorFunctions
的值。 当分区在辅助角色之间充分横向扩展时,此上限会更精确。 完全向外扩展时,由于每个辅助角色只有单个函数主机实例,因此活动并发业务流程协调程序实例的最大数目等于分区数乘以 maxConcurrentOrchestratorFunctions
的值 。
注意
在这种情况下,“活动”意味着业务流程或实体加载到了内存中并在处理新事件 。 如果业务流程或实体正在等待更多事件(例如活动函数的返回值),它将从内存中卸载,且不再被视为“活动”。 仅当存在要处理的新事件时,业务流程和实体才会重新加载到内存中。 在单个 VM 上运行的业务流程或实体没有实际的数量上限,即使它们都处于“正在运行”状态也是如此。 唯一的限制是并发活动业务流程或实体实例的数量。
下图展示了充分横向扩展的情况,其中添加了更多的业务流程协调程序,但有些处于非活动状态,以灰色显示。
横向扩展期间可跨 Functions 主机实例重新分配控制队列租用,确保分区均匀分布。 这些租用在内部以 Azure Blob 存储租用的形式实现,确保一个业务流程实例或实体每次仅在一个主机实例上运行。 如果为任务中心配置了三个分区(三个控制队列),则可以在所有三个包含租约的主机实例上对业务流程实例和实体进行负载均衡。 可以添加更多的 VM,以提高活动函数执行容量。
下图演示了 Azure Functions 主机如何与横向扩展环境中的存储实体交互。
如上图所示,所有 VM 都会争用工作项队列中的消息。 但是,只有三个 VM 可从控制队列获取消息,每个 VM 锁定了单个控制队列。
业务流程实例和实体分布在所有控制队列实例之间。 可以通过哈希处理业务流程的实例 ID 或实体的名称和键对来执行分布。 业务流程实例实例 ID 默认是随机的 GUID,确保将实例均匀分布在所有控制队列之间。
一般而言,业务流程协调程序函数是轻量型的,应该不需要大量的计算能力。 因此,无需创建大量的控制队列分区即可获得业务流程的极佳吞吐量。 大部分繁重工作应在可无限横向扩展的无状态活动函数中完成。
扩展会话
扩展会话是一种高速缓存机制,可将业务流程和实体保留在内存中,即使它们已处理完消息。 启用扩展会话的典型影响是会减少针对基础持久性存储区的 I/O,并提高整体吞吐量。
可以通过在 host.json 文件中将 durableTask/extendedSessionsEnabled
设置为 true
来启用扩展会话。 durableTask/extendedSessionIdleTimeoutInSeconds
设置可用于控制空闲会话在内存中的保存时间长短:
Functions 2.0
{
"extensions": {
"durableTask": {
"extendedSessionsEnabled": true,
"extendedSessionIdleTimeoutInSeconds": 30
}
}
}
Functions 1.0
{
"durableTask": {
"extendedSessionsEnabled": true,
"extendedSessionIdleTimeoutInSeconds": 30
}
}
需要注意此设置的两个潜在缺点:
- 函数应用的内存使用量总体上有所增加,因为空闲实例不会很快从内存中卸载。
- 如果存在许多并发的且生存期较短的不同业务流程协调程序或实体函数执行,吞吐量可能会总体下降。
例如,如果 durableTask/extendedSessionIdleTimeoutInSeconds
设置为 30 秒,则执行时间不超过 1 秒的、生存期较短的业务流程协调程序或实体函数周期仍会占用内存 30 秒。 这种消耗还会计入前面所述的 durableTask/maxConcurrentOrchestratorFunctions
配额,从而可能导致其他业务流程协调程序或实体函数无法运行。
后续部分将描述扩展会话对业务流程协调程序和实体函数的具体影响。
注意
当前仅在 .NET 语言(如 C# 或 F#)中支持扩展会话。 为其他平台将 extendedSessionsEnabled
设置为 true
可能会导致运行时问题,例如在执行活动和业务流程触发的功能失败时无提示。
业务流程协调程序函数重播
如前所述,业务流程协调程序函数是使用“历史记录”表的内容重播的。 默认情况下,每当从控制队列中取消一批消息的排队时,都会重播业务流程协调程序函数代码。 即使你使用扇出、扇入模式并等待所有任务完成(例如,使用 Task.WhenAll()
( .NET)、context.df.Task.all()
(JavaScript) 或 context.task_all()
(Python)),在一段时间内处理批次任务响应时也会发生重播。 启用扩展会话后,业务流程协调程序函数实例将在内存中保存更长时间,同时,无需重播完整历史记录即可处理新消息。
对于以下情况,往往可以观测到扩展会话对性能的改进:
- 当并发运行的业务流程实例数量有限时。
- 当业务流程包含大量可快速完成的连续操作(例如数百个活动函数调用)时。
- 当业务流程扇出和扇入可在大致相同的时间完成的大量操作时。
- 当业务流程协调程序函数需要处理大型消息或执行任何 CPU 密集型数据处理时。
在所有其他情况下,通常观测不到业务流程协调程序函数有明显的性能改进。
注意
只能在全面开发并测试业务流程协调程序函数之后才使用这些设置。 在开发时,默认的激进重播行为可用于检测业务流程协调程序函数代码约束违规,因此默认已禁用此行为。
性能目标
下表显示了性能和缩放一文的性能目标部分中所述的方案的预期最大吞吐量数字。
“实例”是指在 Azure 应用服务中单个小型 (A1) VM 上运行的业务流程协调程序函数的单个实例。 在各种情况下,都假设已启用扩展会话。 实际结果可能根据函数代码执行的 CPU 或 I/O 工作而异。
方案 | 最大吞吐量 |
---|---|
顺序活动执行 | 每个实例每秒 5 个活动 |
并行活动执行(扇出) | 每个实例每秒 100 个活动 |
并行响应处理(扇入) | 每个实例每秒 150 个响应 |
外部事件处理 | 每个实例每秒 50 个事件 |
实体操作处理 | 每秒 64 个操作 |
如果发现吞吐量数字不符合预期,但 CPU 和内存用量似乎正常,请检查原因是否与存储帐户的运行状况相关。 Durable Functions 扩展可能会在 Azure 存储帐户中施加大量的负载,负载高到一定程度会导致存储帐户受限制。
提示
在某些情况下,可通过增加 host.json 中 controlQueueBufferThreshold
设置的值来大幅提升外部事件、活动扇入和实体操作的吞吐量。 将此值增大到其默认值后,持久任务框架存储提供程序可使用更多内存来更主动地预提取这些事件,从而减少从 Azure 存储控制队列中取消消息排队时的相关延迟。 有关详细信息,请参阅 host.json 参考文档。
弹性消耗计划
弹性消耗计划是一个 Azure Functions 托管计划,它兼具消耗计划的许多优势(包括无服务器计费模型),同时又添加了实用的功能,如专用网络、实例内存大小选择,以及对托管标识身份验证的完全支持。
Azure 存储是目前唯一支持在弹性消耗计划中托管 Durable Functions 的存储提供程序。
在弹性消耗计划中托管 Durable Functions 时,应遵循以下性能建议:
- 针对
durable
组将始终就绪的实例数设置为1
。 这可确保始终有一个实例可以处理 Durable Functions 相关的请求,从而减少应用程序的冷启动。 - 将队列轮询间隔减少到 10 秒或更少。 由于此计划类型对队列轮询延迟更为敏感,因此降低轮询间隔将有助于增加轮询操作的频率,从而确保更快地处理请求。 但是,更频繁的轮询操作将导致更高的 Azure 存储帐户成本。
高吞吐量处理
Azure 存储后端的体系结构对 Durable Functions 理论上的最大性能和可伸缩性施加了一些限制。 如果测试显示 Azure 存储上的 Durable Functions 不满足吞吐量要求,你应考虑改用适用于 Durable Functions 的 Netherite 存储提供程序。
若要比较各种基本方案的可实现吞吐量,请参阅 Netherite 存储提供程序文档的基本方案部分。
Netherite 存储后端由 Microsoft Research 设计和开发。 它在 Azure 页 Blob 之上使用 Azure 事件中心和 FASTER 数据库技术。 与其他提供程序相比,Netherite 的设计支持以更高的吞吐量处理业务流程和实体。 在某些基准方案中,与默认的 Azure 存储提供程序相比,其吞吐量提高了一数量级以上。
要详细了解 Durable Functions 支持的存储提供程序以及它们之间的比较,请参阅 Durable Functions 存储提供程序文档。