-
-
[翻译]理解Azure Durable函数
-
发表于: 2019-1-22 06:13 7044
-
在无状态无服务器的云函数之上建立有状态的工作流 - 这是Azure Durable函数的本质。 这句话中有很多花哨的词汇,可能会使大多数读者难以理解。
请允许我解释这些流行语是如何组合在一起的。 我将分三步解释:
传统来说,服务器端应用程序构建于一种现在被称为巨石的模式。如果多人和团队在开发相同应用程序的一部分,那么他们主要是为相同的代码库做出贡献。如果代码库结构良好,它将具有一些不同的模块或组件,并且单个团队各自拥有一个模块:
>巨石应用的多个组件
通常,模块将在构建时打包在一起,然后作为单个单元进行部署,因此模块之间的大量通信将保留在OS进程中。
尽管模块可以在开发过程中保持松散耦合,但耦合几乎总是发生在数据存储层上,因为所有团队都使用单个集中式数据库。
这个模型适用于中小型应用程序,但事实证明,随着应用程序的增长,团队开始相互影响,因为同步不同团队的贡献需要花费越来越多的精力。
作为一种复杂但可行的替代方案,IT行业提出了一种改进的面向服务的方法,通常称为微服务。团队将大型应用程序拆分为围绕不同业务功能构建的“垂直切片”:
> 基于微服务的应用程序的多个组件
然后,每个团队拥有一个完整的垂直切片 - 包含公共通信合同,UI,及数据存储。 强烈建议不要使用明确共享的数据库。 服务之间可以通过记录和版本化的公共合同相互通信。
如果分割的边界选择得很好 - 这是最棘手的部分 - 合同会随着时间的推移保持稳定,并且足够薄以避免过多的干扰。 这为每个团队提供了足够的自主权,以最佳速度进行创新并做出独立的技术决策。
微服务的一个缺点是部署模型的改变。 现在,服务部署到通过网络连接的各自独立的服务器:
> 分布式组件之间通信的挑战
网络从根本上说是不可靠的:它们在大多数情况下工作得很好,但是当它们失败时,它们会以各种不可预测和最不希望的方式失败。 市面上有很多关于分布式系统架构主题的书籍。 简而言之,构建稳定的分布式系统架构是很难的。
很多新的微服务采用者倾向于忽略这些可能的失败。 REST over HTTP(S)是连接微服务的主流方式。 与任何其他同步通信协议一样,它会使系统变得脆弱。
考虑当一个服务暂时发生问题时会发生什么:可能是它的数据库脱机,或者它正在努力跟上请求负载,或者正在部署新版本的服务。 对有问题的服务的所有请求开始失败 - 或者更糟 - 变得非常慢。 从属服务为了等待响应,只能阻止所有传入它的请求。 错误很快传播到上游,导致整个地方发生级联故障:
> 一个组件中的错误会导致级联故障
应用程序不工作了。 每个人都尖叫并开始指责对方。
虽然可以通过断路器和优雅降级等模式来缓解HTTP通信的级联故障,但更好的解决方案是切换到默认为异步通信方式。 比方说将持久性排队服务用作中介。
基于在服务之间发送事件的应用程序体系结构被称为事件驱动。 当服务执行某些有用的操作时,它会发布一个事件 - 一个关于其业务领域发生事实的记录。 另一项服务监听已发布的事件并执行其自己的职责以响应:
事件驱动应用程序中的通信
产生事件的服务可能不知道服务的对象。 随着时间的推移可以引入新的事件订阅者。这种方式在理论上比在实践中更好,但确实服务耦合的几率变小了。
更重要的是,如果一项服务中断,其他服务不会立即被其影响。 上游服务不断发布事件,这些事件可以在队列中累积并安全地存储数小时或数天。 下游服务可能没有对此特定流程执行任何有用的操作,但至少它可以保持正常运转。
然而,另一个潜在问题与松散耦合密切相关:低内聚。 正如Martin Fowler在他的文章事件驱动是指什么中提出的:
通过事件通知容易做出很好的低耦合系统,但却可能忽视大规模的数据流动。
鉴于许多组件发布和订阅大量的事件类型,我们很容易就看不到应用程序的整体。 事件的组合通常构成渐进工作流程,并且及时被执行。 但工作流程不仅仅只是其各个部分的总和,对于流程整体化的高度理解对控制系统行为更是至关重要。
我们稍后再回过头来再讨论这个问题。 现在是时候谈论云了。
公共云的诞生改变了我们构建应用程序的方式。 它使许多事情变得更加简单:在几分钟而不是几个月内部署新的资源,根据需求弹性扩展,以及全球范围内的弹性和灾难恢复能力。
它也使其他事情变得更加复杂。 以下是全球Azure网络的图片:
> 具有网络连接的Azure位置
我们有很好的理由将应用程序部署到多个地理位置:比如说通过靠近客户来减少网络延迟,通过不同地理位置实现弹性。 公共云究其本质是分布式系统。 而正如我们上文所说的,构建稳定的分布式系统架构是很难的。
此外,每个云提供商都有许多个托管服务,这是诅咒也是祝福。 专业的服务非常适合为常见的复杂问题提供现成的解决方案。 但另一方面,每项服务对于一致性,弹性和容错性的定义都有差别。
在我看来,开发人员必须拥抱公共云并在其基础上进行分布式系统设计。 如果您同意,我有一个很好的方法来实现它。
无服务器这个术语很激动人心,它指的是不需要配置虚拟机,实例,工作程序或任何需要固定容量以在其上运行自定义应用程序的云服务。资源是动态且透明地分配的,成本基于实际消耗,而不是预购容量。
无服务器更多地是关于系统的操作性和经济性,而不是技术本身。 服务器还是存在的,但这不是自己关注的问题。 你不用管理无服务器应用程序的正常运行时间:云提供商会管理。
最重要的是,你只需要支付使用的费用,这类似于电力等其他商品资源的消费。 你只需从电力公司购买能源,而不是购买发电机来为你的房屋供电。 你会失去一些控制(例如,无法选择电压),但在大多数情况下这没有影响。 最大的好处是无需购买和维护硬件。
无服务器计算也是如此:它以按使用付费的方式提供标准服务。
如果我们更具体地讨论Azure函数之类提供的函数即服务(FAAS)产品,他们会提供一个标准模型来在云中运行小块代码。 你可以压缩代码或二进制文件并将其发送到Azure; Microsoft负责提供运行它所需的所有硬件和软件。 基础架构会根据需求自动向上或向下扩展,并按每个请求中,应用程序消耗的CPU时间和内存进行支付。 没有使用就不用付钱。
但总有一个“但是”。 函数即服务产品自带特定的开发模型,应用程序必须遵循:
坦率地说,大多数现有的应用程序并不真正适合这个模型。 如果你很幸运能够使用新的应用程序(或其新模块),那么这个模型对你来说会更有利。
很多无服务器应用程序都可以参照Serverless360博客中的示例来设计:
使用“多服务”的无服务器架构的示例应用程序
此应用程序中共有9个托管Azure服务。 他们中的大多数都有一个独特的目的,但服务都由Azure函数连接在一起。 图像上传到Blob存储,Azure函数调用Vision API识别车牌并将结果发送到事件网格,另一个Azure函数将该事件发送到Cosmos DB,依此类推。
这种云应用程序有时被称为Serviceful(多服务),以强调使用大量的托管服务,并通过无服务器函数将这些托管服务“粘合”在一起。
如果应用程序必须大规模运行,那么创建一个没有任何托管服务的类似应用程序将是一项更艰巨的任务。 而且,在自服务领域,没有办法保持按需付费的定价模式。
上图所示的应用程序仍然非常简单。 企业应用程序中的流程通常要复杂得多。请记住Martin Fowler关于忽视大规模的数据流动的引用。 微服务是如此,云功能的“纳米服务”更是如此。
下面我们将深入了解,并列出几个相关问题的例子。
在本文余下部分,我将定义一个虚构的业务应用程序,用于预订软件会议之旅。 为了参加会议,我需要购买会议门票,购买机票,并在酒店预订房间。
在这种情况下,创建三个Azure函数是有意义的,每个函数负责预订过程的一个步骤。 由于我们更喜欢消息传递,每个函数都会发出一个下一个函数可以侦听的事件:
>会议之旅预定程序
这种方法可行,但也存在问题。
由于我们需要按顺序执行整个预订过程,因此通过配置一个函数的输出以匹配下游函数的事件源,Azure函数将依次连接。
在上图中,函数的序列是硬编码的。 如果我们要交换预订航班和预订酒店的顺序,则需要更改代码 - 至少要改输入/输出的定义,但也可能要修改函数的参数类型。
在这种情况下,Azure Fuctions真的实现解耦了吗?
如果预订航班不成功,比方说由于第三方航班预订服务的中断,会发生什么呢?这就是我们使用异步消息传递的原因:在函数执行失败后,消息返回队列并被另一次执行再次拾取。
但是,对于大多数事件源,此类重试几乎是立即发生的。 这不是我们想要的:指数退避策略可能是一个更聪明的想法。 此时,重试逻辑变为有状态:下一次尝试应该“知道”先前尝试的历史并由此决定下次重试的时间。
还有更高级的错误处理模式。 如果执行失败不是间歇性的,我们可能会决定取消整个过程并针对已完成的步骤运行补偿操作。
>连续3次失败后退回
使用无状态函数实现此方案并非易事。 我们可以等到消息进入死信队列然后从那里引导出去,但这不稳定而且不易于用函数表达。
有时业务流程不一定必须按顺序进行。 在我们的预订场景中,我们预定酒店和机票是不必有先后顺序的。 并行运行在此是更为理想的操作。
使用事件总线的发布-订阅(pub-sub)功能可以轻松并行执行操作:两个函数都应订阅同一事件并独立地对其执行操作。但当我们需要协调并行操作的结果时,问题出现了。例如,计算费用报告目的的最终价格:
>扇出/扇入模式
报表费用块不能用单个Azure函数实现:两个事件无法触发一个函数,更不用说用这个函数关联两个相关事件。
解决这一问题很可能要用到两个函数,每个事件一个,函数之间共享存储,以将关于第一个完成的预订的信息传递给最后完成的预订。 所有这些逻辑必须在自定义代码中实现。 如果需要并行运行两个以上的函数,则复杂性会增加。
另外,不要忘记极端的情况。 如果其中一个函数失败怎么办? 在共享存储中写入和读取时,如何确保不会发生条件竞争的情况?
所有这些例子都显示了,我们需要一个额外的工具来将低级单用途独立函数组织到高级工作流程中。这样的工具可以称为协调器(Orchestrator),因为它的唯一任务是将工作委托给无状态操作,同时保持对于流程的历史和全局掌控。
Azure Durable函数旨在提供此类工具。
Azure函数是Microsoft提供的无服务器计算服务。 函数是事件驱动的:每个函数定义一个触发器 - 事件源的确切定义,例如,存储队列的名称。
Azure函数可以用多种语言编写。 一个使用C#实现的存储队列触发器的基本函数如下所示:
FunctionName属性将C#静态方法公开为名为MyFirstFunction的Azure函数。 QueueTrigger属性定义要侦听的存储队列的名称。 函数体记录有关传入消息的信息。
Durable函数是一个为Azure函数提供工作流协调抽象的库。 它引入了许多习惯用法和工具来定义有状态的,可能长期运行的操作,并在幕后管理许多可靠通信和状态管理的机制。该库记录Azure存储服务中所有操作的历史记录,从而实现持久性和对故障的恢复能力。
Durable函数是开源的,Microsoft接受外部贡献,社区非常活跃。目前可以用3种编程语言编写Durable 函数:C#,F#和Javascript(Node.js)。 我下面的所有例子都使用C#。 对于Javascript,请查看此快速入门和这些示例。 对于F#,请参阅示例,F#特定的库和我的文章F#和Durable函数的童话故事。
通过引入另外两种类型的触发器:Activity函数和Orchestrator函数,我们可以实现工作流构建功能。
Activity函数是简单的无状态单用途构建块,它只执行一项任务,并不了解更大的工作流程。 ActivityTrigger是新引入的一种触发器类型,它将函数公开为工作流程步骤,下面是一个用C#实现的简单Activity函数:
它有一个通用的FunctionName属性,可以将C#静态方法公开为名为BookConference的Azure函数。 名称很重要,因为它用于从Orchestrator中调用活动。ActivityTrigger属性定义触发器类型并指向每次调用时应该获取的输入参数Conference。该函数可以返回任何可序列化类型的结果; 我的示例函数返回一个名为ConfTicket的简单属性包。
Activity函数几乎可以做任何事情:调用其他服务,从/向数据库加载和保存数据,以及使用任何.NET库。
Orchestrator函数是Durable函数引入的独特概念。 其唯一目的是管理多个Activity函数之间的执行和数据流。其最基本的形式将多个独立活动链接到单个顺序工作流程中。
让我们先从一个例子开始,在例子中我们逐一预订会议票,机票和酒店房间:
> 按顺序执行工作流程的3个步骤
此工作流的实现由另一个C# Azure函数定义,这次使用OrchestrationTrigger:
此处属性同样是用于描述Azure运行时的函数。唯一的输入参数具有DurableOrchestrationContext类型。 此处Context是指启用Orchestrator操作的工具。要特别指出的是,CallActivityAsync方法被使用了三次,一个接一个地调用三个活动。 对于使用基于任务的API的任何C#代码,这一方法体看起来非常典型。 但是,具体的行为完全不同。 我们来看看实现细节。
让我们来看看上面顺序工作流的一次执行的生命周期。
当Orchestrator开始运行时,将进行第一次CallActivityAsync调用,来预订会议票。 这里实际发生的是队列消息从orchestrator发送到activity函数。相应的activity函数由队列消息触发。 它完成它的工作(预订会议票)并返回结果。 activity函数序列化结果并将其作为队列消息发送回orchestrator:
> Orchestrator和Activity之间的消息传递
当消息到达时,Orchestrator再次被触发并可以继续进行第二个活动。 依次循环重复 - 消息被发送到订机票的activity函数,它被触发,完成其工作,并将消息发送回Orchestrator。 第三次调用会发生相同的消息流。
如前所述,消息传递旨在及时解耦发送方和接收方。 对于上述场景中的每条消息,我们并不期待立即的响应。
在C#代码中,执行await运算符时,代码不会阻止整个orchestrator的执行。 相反,它只是退出:orchestrator停止活动并且完成其当前步骤。
每当收到来自Activity的返回消息时,Orchestrator代码将重新启动。 它总是从第一行开始。 是的,这意味着多次执行相同的行:最多重复执行Orchestrator拥有的所有消息数。
然而Orchestrator将其过去执行的历史记录存储在Azure存储中,因此第一行的第二次执行的效果是不同的:不是向Activity发送消息,而是已经知道该Activity的结果,因此await返回此结果并将其分配给conference变量。
由于这些“重放”,Orchestrator的实现必须是确定性的:不要使用DateTime.Now,随机数或多线程操作; 这里有更多细节。
Azure函数是无状态的,而工作流需要状态来跟踪其进度。 每当执行工作流的新操作时,框架都会自动在表存储中记录事件。
每当Orchestrator因收到新消息而重新启动时,它就会从存储中加载此特定执行的完整历史记录。 Durable Context使用此历史记录来决定是调用Activity还是返回先前存储的结果。
将完整的状态变化历史存储为附加事件存储的模式称为事件源。 这次存储模式有以下好处:
下图展示了顺序工作流程中记录的重要事件:
> 在Orchestrator进程中记录事件的过程
无服务器基于消耗的Azure函数按执行+每次执行持续时间计费。Durable orchestrators的停止-重放行为导致单个工作流“实例”多次执行相同的Orchestrator function, 这也意味着要为几次短期执行买单。
但是,与同步调用activity因障碍停止的潜在成本相比,总账单通常最终会低得多。 每5次100毫秒的执行的价格明显低于1次执行30秒的成本。
顺便说一下,每月首批的百万次执行是免费的,所以很多时候都不会产生任何Azure Functions服务的费用。
另一个会产生消费的组件是Azure存储。客户会被收取在操作中使用的队列和表的费用。 但根据我的经验,对于中低负载的应用,这种费用仍然接近于零。
在你的Orchestrators中要警惕无意中产生的永恒循环或无限的递归扇出。 如果让它们失去控制,那些这些操作可能会变得昂贵。
当工作流程中间出现错误时会发生什么? 例如,第三方航班预订服务可能无法处理该请求:
> 一个activity出现错误
Durable Functions有预计这种情况。 Activity函数不会静默失败,而是会将包含有关错误信息的消息发送回Orchestrator。Orchestrator反序列化错误详细信息,并在重放时从相应的调用中抛出.NET异常。 开发人员可以自由地在调用周围放置一个try .. catch块并处理异常:
上面的代码可以追溯到预订另一个行程的“备份计划”。 另一种典型模式是运行补偿活动以取消任何先前操作的效果(在我们的情况下取消预订会议)并使系统处于干净状态。
通常来说,错误可能是暂时的,因此在暂停后重试失败的操作是有意义的。 这是一个常见的场景,Durable 函数提供了一个专用的API:
上面的代码指示库进行如下操作:
重要的一点是,Orchestrator在等待重试时不会停止工作。 调用失败后,一条消息会被安排在将来重新运行orchestrator时重试呼叫。
业务流程可能包含许多步骤。 为了使orchestrator的代码易于管理,Durable函数允许嵌套的orchestrator。 “父”orchestrator可以通过context.CallSubOrchestratorAsync方法调用子orchestrator:
上面的代码依次预定了两个会议。
如果我们想并行运行多个活动怎么办?
例如,在上面的示例中,我们希望预订两个会议,但预订顺序可能无关紧要。 但是,当两项预订完成后,我们希望将结果合并为财务部门制作费用报告:
> 并行调用紧跟着一个最终步骤
在这种情况下,BookTrip orchestrator接受带有会议名称的输入参数并返回费用信息。 ReportExpenses需要同时收到两项费用。
通过安排两个任务(即,发送两个消息)而无需单独等待它们,可以容易地实现该目标。 我们使用熟悉的Task.WhenAll方法来等待并合并结果:
请记住,在等待WhenAll方法时Orchestrator不会被同步阻止。 它在第一次退出,然后在从活动收到的回复消息后重新启动两次。 第一次重启再次退出,只有第二次重启才会传递信息给await。Task.WhenAll返回结果数组(每个输入任务一个结果),然后传递给产生报告的activity。
并行化的另一个例子是向数百个接收者发送电子邮件的工作流程。 使用正常的队列触发功能,这种扇出不会很难:只需发送数百条消息。 但如果工作流程的下一步需将结果组合起来,则会非常具有挑战性。使用Durable orchestrator可以很简单实现这一功能:
对活动进行数百次往返可能会导致对Orchestrator进行大量重放。 作为优化,如果多个活动功能大约在同一时间完成,则orchestrator可以在内部处理多个消息作为批处理,并且每个批处理仅重启一次orchestrator函数。
Durable函数拥有更多模式。 下面是一个简单的列表,为您提供一些已有模式作为参考:
进一步的解释和代码示例在文档中。
我坚信,因为拥有快速的开发流程和精确的计费模式,利用各种托管云服务的无服务器应用程序对许多公司非常有利。无服务器技术仍然很年轻; 我们需要更多的高级架构模式,以对大型业务系统进行富有表现力和可组合性的实现。
Azure Durable函数提供了一些可能的答案。 它将有序RPC样式代码的清晰度和可读性与事件驱动架构的强大功能和弹性相结合。Durable函数的文档非常好,有大量的示例和操作指南。 学习它,尝试运用现实生活中的场景,让我知道你的看法 - 我对无服务器的未来感到兴奋!
非常感谢Katy Shimizu,Chris Gillum,Eric Fleming,KJ Jones,William Liebenberg,Andrea Tosato审阅本文的草稿及其宝贵的贡献和建议。 围绕Azure函数和Durable函数的网络社区非常棒!
原文发布于mikhail.io。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课