前言无论是在大数据处理领域还是消息处理领域,任务系统都有一个非常关键的能力——任务触发去重的保障。这个能力对于一些对精度要求极高的场景(比如金融等)来说是必不可少的。ServerlessTask作为一个serverless任务处理平台,也需要提供这样的保障。它在用户应用层和内部系统两个维度上具有准确的任务触发语义。本文以消息处理可靠性为主题,介绍函数计算内部的一些技术细节,并展示如何在实际应用中利用函数计算提供的这一能力来增强任务执行的可靠性。谈任务去重在讨论异步消息处理系统时,消息处理的基本语义是绕不开的话题。在异步消息处理系统(任务系统)中,一条消息的处理流程简化如下图:图1.用户发送任务-进入队列-任务处理单元监听并获取消息-派发到实际工作人员执行任务消息在整个传递过程中,任何可能的组件(链路)宕机等问题都会导致消息的错误传递。一个通用的任务系统最多会提供三个层次的消息处理语义:●At-Most-Once:保证一条消息最多传递一次。当出现网络分区和系统组件宕机时,消息可能会丢失;●At-Least-Once:确保消息至少传递一次。消息传递链路支持错误重试,使用消息重发机制保证下游必须收到上游消息。但是,在宕机或网络分区的情况下,同一条消息可能会被传输多次。Exactly-Once机制可以保证消息恰好传输一次。Exactlyonce并不是说??在宕机或者网络分区的情况下不会重传,而是说重传不会改变接收方的状态,这和传输一次的结果是一样的。在实际生产中,ExactlyOnce往往是依靠接收方的重传机制&去重(幂等)来实现的。函数计算可以为任务分发提供ExactlyOnce语义,即无论在什么情况下,重复的任务都会被系统认为是同一个触发器,然后只分发一次任务。结合图1,如果要对任务进行去重,系统至少需要提供两个维度的保障:1.系统侧保障:任务调度系统本身的故障转移不影响消息传递的正确性和唯一性;2.为用户提供了一种可以触发整个业务逻辑去重语义的机制。下面我们就来谈谈函数计算是如何结合简化的ServerlessTask系统架构来实现上述能力的。函数计算的异步任务触发去重。函数计算的任务系统架构如下图所示。图2首先,用户调用FunctionComputeAPI向系统的API-Server发送任务(步骤1),API-Server进行校验后将消息传递给内部队列(步骤2.1)。后台有异步模块实时监控内部队列(步骤2.2),然后调用资源管理模块获取运行时资源(步骤2.2-2.3)。调度模块获取运行时资源后,将任务数据发送给VM级客户端(步骤3.1),客户端将任务转发给实际用户运行资源(步骤3.2)。为了保证上述两个维度,我们需要以下层面的支持:1、系统端保证:在步骤2.1-3.1中,任何一个中间过程的failover只能触发一次步骤3.2的执行,即只有一个用户实例将被安排运行;2、用户端应用级去重能力:可以支持用户重复执行步骤1,但实际上只触发一次步骤3.2的执行。系统侧优雅的upgrade&failover任务分发,保证用户的消息进入函数计算系统时(即2.1步骤完成),用户的请求会收到HTTP状态码为202的Response,用户可以考虑它已成功提交一次任务。由于任务消息进入MQ,其生命周期由Scheduler维护,所以Scheduler和MQ的稳定性将直接影响到系统ExactlyOnce的实现。在大多数开源消息系统(如MQ、Kafka)中,一般都提供了消息的多副本存储和唯一消费的语义。函数计算使用的消息队列(底层是RocketMQ)也是如此。底层存储的3副本实现,让我们无需关注消息存储的稳定性。此外,函数计算使用的消息队列还具有以下特点:1、消费的唯一性:每个队列中的每条消息被消费后,都会进入“隐形模式”。在这种模式下,其他消费者无法获取消息;2、每条消息的实际消费者需要实时更新本模式的隐身时间;当消费者完成消费后,需要显式删除消息。因此,消息在队列中的整个生命周期如下图所示:图3.Scheduler主要负责消息的处理,其任务主要包括以下几个部分:1.根据消息的调度策略函数计算负载均衡模块,monitor自己负责2.当队列中出现消息时,拉取消息并在内存中维护一个状态:直到消息消费完成(用户实例返回函数执行结果),不断更新消息的可见时间,保证消息不会重复出现在队列中;3.任务执行完成后,会显示删除信息。在队列调度模型上,函数计算对普通用户采用“单队列”管理模式;即每个用户的所有异步执行请求都由一个独立的队列相互隔离,由一个Scheduler固定负责。这个负载的映射关系由函数计算的负载均衡服务管理,如下图所示(我们会在后续文章中对这部分内容进行更详细的介绍):图4当Scheduler1宕机或升级时,任务有两种执行状态:1.如果消息还没有投递到用户的执行实例(图2中的步骤3.1~3.2),那么当负责这个Scheduler的队列被其他Scheduler拾取时,消息就会可见在consumer一段时间后重新出现,所以Scheduler2会再次获取消息来做后续的触发。2、如果消息已经开始执行(步骤3.2),当消息再次出现在Scheduler2中时,我们依赖用户VM中的Agent进行状态管理。此时Scheduler2会向对应的Agent发送执行请求;此时Agent发现内存中已经存在该消息,则直接忽略执行请求,执行完成后通过该链路将执行结果通知Scheduler2,然后完成Failover恢复。用户侧的服务级分发和去重使得函数计算系统在单点故障下能够准确消费每条消息。但是,如果用户侧针对同一条业务数据多次触发函数执行,函数计算无法识别不同的消息。它在逻辑上是相同的任务吗?这通常发生在网络分区中。在图2中,如果用户调用1超时,此时可能有两种情况:1、消息还没有到达函数计算系统,任务没有提交成功;2.消息到达函数计算并加入队列,任务提交成功。但是由于超时,用户无法得知提交成功的信息。在大多数情况下,用户将重试此提交。情况2,同一个任务会被多次提交执行。因此,函数计算需要提供一种机制来保证该场景下业务的准确性。函数计算提供了TaskID(StatefulAsyncInvocationID)的概念。这个ID是全局唯一的。用户可以在每次提交任务时指定这样一个ID。当发生请求超时时,用户可以无限次重试。所有重复的重试都会在函数计算端进行验证。函数计算内部使用DB存储任务元数据;当相同的ID进入系统时,请求将被拒绝并返回400错误。此时客户端就可以知道任务的提交状态了。在实际使用中,以GoSDK为例,您可以编辑触发任务的代码如下:("ServiceName","FunctionName")invokeInput=invokeInput.WithAsyncInvocation().WithStatefulAsyncInvocationID("TaskUUID")invokeOutput,err:=fcClient.InvokeFunction(invokeInput)...}然后提交一个独特的任务。总结本文介绍FunctionComputeServerlessTask对任务触发器去重的技术细节,以支持对任务执行精度有严格要求的场景。使用ServerlessTask后,您无需担心任何系统组件的故障转移,您提交的每个任务都将恰好执行一次。为了支持业务端语义分布去重,您可以在提交任务时设置任务的全局唯一ID,利用函数计算提供的能力帮助您对任务进行去重。
