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