当前位置: 首页 > 科技观察

HTTP服务异步改造实践

时间:2023-03-14 18:30:25 科技观察

背景我们有一个在ingress层提供HTTP服务的应用。随着业务的复杂性,一个用户请求的处理涉及到对后端远程服务的多次调用。为了简单起见,目前采用同步的方式进行,即在处理一个请求的过程中,会占用一个容器线程进行逻辑操作和同步远程调用。这种开发方式的优点是直观,开发成本低,但也带来了一些稳定性和资源浪费的问题。对于我们的HTTP服务来说,同步的实现带来了以下三个问题。下游服务超时导致的服务可用性问题。部分请求超时会导致HTTP服务线程池爆满,导致其他请求无法获取线程资源而失败。性能问题,多次调用远程服务串行执行,导致服务响应时间长。容量问题,服务吞吐量有限。每个请求长时间占用一个线程,导致线程未被充分利用。为了解决这些问题,结合目前使用的技术栈和适配成本,我们对HTTP服务进行了异步改造。解决方案异步编程中著名的CallbackHell让很多同学望而却步。当业务复杂时,各种回调相互嵌套,使得代码更容易出错,难以理解。业界也有很多框架提供异步编程支持。有以下三种思路:Fibers可以看作是轻量级的用户线程,脱离了OS的调度机制,在应用层进行调度管理。由于它只维护基本的执行栈信息,不立即分配执行资源,因此可以轻松创建数千个纤程(受内存大小限制),并通过极少的线程实现完成纤程的调度。这个方向的代表有微信团队开源的libco,语言层面支持的Go语言。libcohook底层IO相关的系统函数,通过底层IO事件驱动fibers的调度和执行。当遇到同步网络请求时,libco会自动注册一个回调监听器并让出CPU。当IO事件完成或超时后,fiber会自动恢复,然后调度执行。它的实现机制决定了它非常适合依赖于耗时IO服务的实现。承载着微信***通话的基石。不幸的是,libco是一个高效的c/c++协程库,它并没有在JVM上实现。Quasar在JVM之上实现了纤程机制。基本上,基于Quasar的类库,可以用同步的方式编写异步代码。在代码真正执行之前,以编译或InstrumentAgent的形式编织相关的字节码。从头开始引入纤维仍然是一个不错的选择。对于现有项目的改造,需要将现有的thread类修改为fiber类,这需要我们底层的中间件改动很多。另外,目前业界公布的使用经验较少,后续可继续关注其发展。Actor模型Actor模型并不是一个新概念。近年来,有越来越受欢迎的趋势。Actor模型中的一个核心概念是Actor实体。每个Actor实体负责一个逻辑计算。传统的并发编程都是基于共享内存来达到多线程间通信的目的。Actor之间不共享数据,也不直接通信,而是在mailbox/queque中发送或接收消息,达到通信的目的。Actor由消息驱动。形式上由于发送方和接收方的分离,是的,Actor具有与生俱来的并发特性。它可以在不考虑Actor之间同步问题的情况下,调度执行接收消息的Actor,从而优化IO等待问题。Scala、Golang等在语言层面支持Actor模型。在新版本的Scala中,引入了Akka来完成Actor模型,还有Java版本。但是需要引入新的API,将现有的业务代码块改造为Actor模型,并对现有代码进行较大改动。RXRx也是一种编程模型,它试图提供一个统一的异步编程接口包来操作一个可观察的数据流。它吸取了函数式编程的优秀思想,将观察者模式和迭代器模式实现的淋漓尽致。目前流行的语言基本上都有相应的实现。例如RxJava类库提供了java版本的实现,RxJava已经成功应用在Netflix的Zuul项目中。Rx看起来更像是一种编程思维的突破。它提供统一的函数式编程接口,简化异步程序的编写。同时,它内部也使用了回调机制,以获得比Actor更好的响应速度。在我们的研究中,我们发现它还需要对现有代码进行重大更改,并将之前的同步模式转换为函数式编程风格。综合来看,上面的一些优秀的框架并不能马上用到我们的项目中,引进成本还是很高的。结合现有的技术架构和产品快速迭代的环境,我们对HTTP服务进行了轻量级的异步改造。在这次转型中,引入了Graph-BasedExecutionEngine来解决服务之间复杂的依赖关系,集中管理异步状态。结合Servlet3.0,提供了请求和释放tomcat容器线程的接口,充分利用了Servlet容器线程资源。***,通过springmvc的异步模块把这两种异步机制串联起来,达到了全栈异步的目的。原理分析Servlet从3.0开始加入了异步规范。SpringMVC从3.2开始也支持异步Servlet3.0。对于现有的技术栈,全栈异步的实现可以用下面一段代码来说明:可以看到,orderService.createOrderAsync(request)的调用并没有在请求发送后等待返回结果,但立即返回。在返回的未来对象上注册了一个侦听器。***返回延迟结果。当springmvc收到返回结果为DeferredResult(当然也可以是WebAsyncTask和Callable)时,会调用AsyncContextcontext=HttpServletRequest.startAsync(req,response);获取上下文,然后退出容器线程。当createOrderAsync完成并获得结果时,将调用在future上注册的侦听器并开始执行。这里忽略一些中间处理,直接在DeferredResult上设置RPC结果。springmvc获取到执行结果后,调用Servet的上下文context.dispatch()通知容器继续进行后续操作,比如重新进入springmvc拦截器的完整流程,最后将结果输出到客户端。整个过程可以用下图来表示:图中的三个框表示将整个请求打散,分三个阶段执行。***框和第二个框之间,表示正在执行RPC服务。此时处理请求的线程已经被释放。它可以继续接受其他请求。当RPC服务返回值或超时时,它会在单独的线程池中调用已注册的侦听器。最后在第三个框通知Servlet容器继续执行interceptor.complete。通过回调通知机制,CPU将得到充分利用。避免启动等待IO完成的宝贵线程。Graph-BasedExecutionEngine的真实业务场景要比上面的代码复杂的多。例如,订单业务一般依赖于用户、报价、支付、折扣等服务。服务之间存在依赖关系,比如黑名单服务验证提交订单。还有一些服务是对等关系,相互之间没有依赖关系,可以并行调用,以减少服务的整体响应时间。如下图所示,这是一个普通的服务依赖:图中的A、B、C没有依赖关系,实际上是可以并行执行的。C服务不关心返回结果,所以调用通知发出后即可结束。D服务需要等待A的结果,E需要等待B和D的执行结果。如果用传统的异步编程,看起来是这样的:可以看到服务依赖隐藏在代码行之间,业务逻辑穿插在每个回调中,中间引入ListeableFuturefutureBT来管理异步状态。不是很容易阅读和维护。为此,我们提供了一个基于图形的执行引擎(GBEE)。GBEE的主要目标是解决以下问题:(1)管理服务之间的依赖关系,将服务之间的依赖关系从业务代码中分离出来,通过有向无环图数据结构来描述服务之间的依赖关系。图中的每个节点都保存其前任(后续驱动程序)节点。每个节点执行的先决条件是其所有前任节点都已完成。(2)回调的统一注册每个节点都可以覆盖回调来注册自己的监听器。一般用于结果的转换、记录和监控。回调由执行者统一注册。避免在代码嵌套中注册监听器。(3)采用异步事件驱动执行,在GBEE中统一注册异步事件监听器,在事件发生时驱动回调的执行,或者在条件成熟时唤起下一个节点的执行。具体做法:(1)将业务逻辑分离到多个节点中,每个节点负责具体的业务逻辑执行,但没有任何状态,比如发起一个异步RPC调用,返回一个ListenableFuture。(2)通过配置文件定义依赖管理每个Node定义自己的parents,也就是依赖关系。Spring本身提供了服务的依赖管理能力。因此,其依赖定义如下:(3)提供一个执行器Graph-BasedExecutor,负责监听器的统一注册和异步状态的管理。每次请求到达后,可以通过上面的依赖配置构造一个Graph-Basedexecutor:Graph会找到根节点,多个根节点可以同时并行。apply(node,context)是一个递归调用。当前节点每次执行时,主动检测父节点是否可以作为自己的节点执行:Graph-BasedExecutor将业务代码与底层异步机制解耦,让每个节点更专注于自己的业务。后记在具体业务的迁移过程中,我们也遇到了一些常见的问题,供后续实施者参考。(1)公司的RPC服务主要由dubbo提供,使用公司的基础组件可以方便的进行异步调用。(2)网上还有很多应用使用tomcat6,从tomcat7开始支持Servlet3,相关应用要升级到tomcat7。(3)web.xml配置有几个重要的配置。springmvc要想真正开启异步支持,除了激活org.springframework.web.servlet.DispatcherServlet的异步选项,即:true外,还需要激活此servletasync-supported之前的所有过滤器都设置为true。只要中间有一个过滤器没有设置,后面的设置都是无效的。并且在后续的开发中,如果添加了过滤器,也必须进行配置。(4)ThreadLocal问题。现有系统的一些常用上下文参数都是通过ThreadLocal传递的。异步改造后,代码并不总是在请求线程中执行。这会使通过ThreadLocal传递的变量无效。我们采用了两种方法来解决它。一种是改造一些业务代码,以参数的形式传递。另一种是在HttpServletRequest的Attribute中存放一些常用的变量。对HttpServletRequest的引用保存在异步上下文中。然后通过工具类直接从HttpServletRequest中提取公共变量。(5)异常处理在同步代码中,一般我们会自定义一些业务异常。捕获到这些业务异常后,根据异常合理性和状态码做一些业务逻辑。ListeableFuture继承的Future接口规定所有异步计算过程抛出的异常都封装在ExecutionException中。这时候同步代码中的catch是抓不到ExecutionException的。这时业务代码需要修改捕获的具体类型,然后通过Exception.getCause()获取原始异常。这块可以由Graph-BasedExecutionEngine统一处理。将原始异常转换后,调用节点的onException。