本文共 8074 字,大约阅读时间需要 26 分钟。
《Netty 进阶之路》、《分布式服务框架原理与实践》作者李林锋深入剖析通信层和 RPC 调用的异步化。李林锋此后还将在 InfoQ 上开设 Netty 专题持续出稿,感兴趣的同学可以持续关注。
随着业务分布式架构的发展,系统间的系统调用日趋复杂,以电商的商品购买为例,前台界面的购买操作涉及到底层上百次服务调用,形成复杂的调用链,示例如下:
对于一些逻辑上不存在互相依赖关系的服务,可以通过异步RPC调用,实现服务的并行调用,通过并行调用来降低服务调用总耗时,以手游购买道具流程为例,消费次数限制鉴权、账户余额鉴权和下载记录鉴权三个服务可以通过异步的方式并行调用,来降低游戏道具购买的耗时:
对于一些业务场景,服务调用耗时与消息本身、调用的资源对象有关系,例如上传和下载接口,如果下载的资源较多则耗时就会相应的增加。对于这类场景,接口的调用超时时间比较难配置,如果配置过大,服务端自身响应慢之后会拖垮调用方,如果配置过小,万一遇到一个需要较长耗时的RPC调用就会超时。通过异步RPC调用,就不用再担心调用方业务线程被阻塞,超时时间可以相应配置大一些,减少超时导致的失败。
对于大部分的第三方服务调用,都需要采用防御性编程,防止因为第三方故障导致自身不能正常工作。如果采用同步RPC方式调用第三方服务,一旦第三方服务的处理耗时增加,就会导致客户端调用线程被阻塞,当超时时间配置不合理时,系统很容易被阻塞。通过异步化的RPC调用,可以防止被第三方服务端阻塞,Hystrix的第三方故障隔离就是采用类似机制,只不过它底层创建了线程池,通过Hystrix的线程池将第三方服务调用与业务线程做了隔离,实现了非侵入式的故障隔离。
对于一个同步串行化调用的系统,大量的业务线程都在等待服务端返回响应,系统的CPU使用率很低,但是性能却无法有效提升,这个问题几乎是所有采用同步RPC调用的业务都遇到的一个通病。要想充分利用CPU资源,需要让业务线程尽可能的跑满CPU,而不是经常性的处于同步等待状态。采用异步RPC调用之后,在单位时间内业务线程可以接收并处理更多的请求消息,更充分的利用CPU资源,提升系统的吞吐量。
根据一些公开的测试数据,一些业务采用异步RPC替换同步RPC调用之后,综合性能提升2-3倍+。
Servlet异步是指Servlet 3规范中提供了对异步处理Servlet请求的支持,可以把HTTP协议处理线程和业务逻辑执行线程隔离开:
1.Servlet3.0对异步的支持:Servlet3之前一个HTTP请求消息的处理流程,包括:HTTP请求消息的解析、Read Body、Response Body,以及后续的业务逻辑处理都是由Tomcat线程池中的工作线程处理。Servlet3之后可以让I/O线程和业务处理线程分开,进而对业务做隔离和异步化处理。还可以根据业务重要性进行业务分级,同时把业务线程池分类,实现业务的优先级处理,隔离核心业务和普通业务,提升应用可靠性。
2.Servlet3.1对非阻塞I/O的支持:Servlet3.1以后增加了对非阻塞I/O的支持,根据Servlet3.1规范中描述:非阻塞I/O仅对在Servlet中的异步处理请求有效,否则,当调用ServletInputStream.setReadListener或ServletOutputStream.setWriteListener方法时抛出IllegalStateException异常。Servlet3.1对非阻塞I/O的支持是对之前异步化版本的增强,配套Tomcat8.X版本。
Tomcat + Servlet3的异步化处理原理如下所示:
关键处理流程如下:
1.声明Servlet,增加asyncSupported属性,开启异步支持,例如@WebServlet(urlPatterns=“/AsyncLongRunningServlet”,asyncSupported=true)。
2.通过request获取异步上下文AsyncContext, AsyncContext context = request.startAsync(),相关接口定义如下:
3.启动业务逻辑处理线程,并将AsyncContext对象传递给业务线程。例如:Executor.execute(()-\u0026gt;{context, request, response…})。
4.在业务线程中,通过获取request进行业务逻辑处理,完成之后填充response对象。
5.业务逻辑处理完成之后,调用AsyncContext的complete()方法完成响应消息的发送。
SpringMVC 3.2+版本基于Servlet3做了封装,以简化业务使用。它的工作原理如下所示:
SpringMVC支持多种异步化模式,常用的有两种:
1.Controller的返回值为DeferredResult,在业务Controller方法中构造DeferredResult对象,然后将请求封装成Task投递到业务线程池中异步执行,业务执行完成之后,构造ModelAndView,调用deferredResult.setResult(ModelAndView)完成异步化处理和响应消息的发送。
2.Controller的返回值为WebAsyncTask,实现Callable,在call方法中完成业务逻辑处理,由SpringMVC框架的线程池来异步执行业务逻辑(非Tomcat工作线程)。
以DeferredResult为例,它的异步处理流程如下所示:
Apache ServiceComb是一个开箱即用、高性能、兼容流行生态、支持多语言的一站式开源微服务解决方案。它同时支持同步和异步服务调用,下面一起分析下它的异步化服务调用机制。
纯Reactive模式的特点是:
1.异步化接口,消费端不需要同步等待服务提供端返回响应,不会产生阻塞。
2.与传统流程不同的,所有功能都在eventloop中执行,并不会进行线程切换。
3.只要有任务,线程就不会停止,会一直执行任务,可以充分利用cpu资源,也不会产生多余的线程切换,去无谓地消耗cpu。
它的处理流程如下所示:
关键流程解读:
1.异步:橙色箭头走完后,对本线程的占用即完成了,不会阻塞等待应答,该线程可以处理其他任务。
2.当收到远端应答后,由网络数据驱动开始走红色箭头的应答流程。
对应的代码示例如下所示:
通过代码示例可以看出,ServiceComb的Reactive工作模式采用了JDK8的CompletableFuture作为异步编程模型,利用CompletableFuture可以方便的对多个异步操作结果做编排,以及做级联异步操作,功能强大,使用灵活。
纯Reactive模式的使用约束:所有在eventloop中执行的逻辑,不允许有任何的阻塞动作,包括不限于wait、sleep、巨大循环、同步查询DB等等。实际上就是如果业务的微服务采用了Reactive,则需要做全栈异步,否则会阻塞eventloop线程,导致消息收发出现问题。如果业务的微服务想做异步化,但是由于数据库、缓存等原因无法实现全栈异步,则可以采用后面介绍的混合Reactive模式。
混合Reactive模式的实现策略如下:
1.服务端接口返回值为CompletableFuture,这样采用透明RPC调用时就可以实现异步化。
2.对于可能产生同步阻塞的业务逻辑代码,采用独立线程池的方式进行处理,防止阻塞平台的eventloop线程。
混合Reactive模式与纯Reactive模式相比,主要有两点差异:
1.存在线程切换。
2.可能导致同步阻塞的业务逻辑放到独立的线程池中执行,纯Reactive模式所有业务逻辑都在eventloop线程中执行(与I/O线程相同)。
它的处理流程如下所示:
相比于其它的微服务框架(RPC框架),ServiceComb的Reactive有如下几个特点:
对于微服务提供端:
1.producer是否使用reactive与consumer如何调用,没有任何联系。
2.当operation返回值为CompletableFuture类型时,默认此operation工作于reactive模式,此时如果需要强制此operation工作于线程池模式,需要在微服务的配置文件中(microservice.yaml)中明确配置,指定业务线程池。这样业务逻辑的执行就可以由eventloop线程(I/O线程)切换到业务线程。
对于微服务消费端:
1.consumer是否使用reactive与producer如何实现,没有任何联系。
2.当前只支持透明RPC模式,使用JDK原生的CompletableFuture来承载此功能ompletableFuture的when、then等等功能都可直接使用。
对于ServiceComb,无论服务端定义的接口是同步还是异步的,消费端都可以采用异步的方式调用它,对具体细节感兴趣的读者可以到ServiceComb官网下载Demo示例学习。
ServiceComb微服务的完整线程模型如下图所示:
ServiceComb通过线程绑定技术来减少锁竞争,提升性能:
1.业务线程在第一次调用时会绑定某一个网络线程,避免在不同网络线程之间切换,无谓地增加线程冲突的概率。
2.业务线程绑定网络线程后,会再绑定该网络线程内部的某个连接,同样是为了避免线程冲突。
gRPC的服务调用有三种方式:
业务调用代码示例如下:
调用GreeterFutureStub的sayHello方法返回的不是应答,而是ListenableFuture,它继承自JDK的Future,接口定义如下:
将ListenableFuture加入到gRPC的Future列表中,创建一个新的FutureCallback对象,当ListenableFuture获取到响应之后,gRPC的DirectExecutor线程池会调用新创建的FutureCallback,执行onSuccess或者onFailure,实现异步回调通知。
接着我们分析下ListenableFuture的实现原理,ListenableFuture的具体实现类是GrpcFuture,代码如下:
获取到响应之后,调用complete方法:
将ListenableFuture加入到Future列表中之后,同步获取响应(在gRPC线程池中阻塞,非业务调用方线程):
获取到响应之后,回调callback的onSuccess,代码如下:
除了将ListenableFuture加入到Futures中由gRPC的线程池执行异步回调,也可以自定义线程池执行异步回调,代码示例如下:
业务调用代码示例如下:
构造响应StreamObserver,通过响应式编程,处理正常和异常回调,接口定义如下:
将响应StreamObserver作为入参传递到异步服务调用中,该方法返回空,程序继续向下执行,不阻塞当前业务线程,代码如下所示:
下面分析下基于Reactive方式异步调用的代码实现,把响应StreamObserver对象作为入参传递到异步调用中,代码如下:
当收到响应消息时,调用StreamObserver的onNext方法,代码如下:
当Streaming关闭时,调用onCompleted方法,如下所示:
通过源码分析可以发现,Reactive风格的异步调用,相比于Future模式,没有任何同步阻塞点,无论是业务线程还是gRPC框架的线程都不会同步等待,相比于Future异步模式,Reactive风格的调用异步化更彻底一些。
gRPC的通信协议基于标准的HTTP/2设计,除了普通的RPC调用,还支持streaming调用。
客户端发送N个请求,服务端返回N个或者M个响应,利用该特性,可以充分利用HTTP/2.0的多路复用功能,在某个时刻,HTTP/2.0链路上可以既有请求也有响应,实现了全双工通信(对比单行道和双向车道),示例如下:
proto文件定义如下:
业务代码示例如下:
构造Streaming响应对象StreamObserver并实现onNext等接口,由于服务端也是Streaming模式,因此响应是多个的,也就是说onNext会被调用多次。
通过在循环中调用requestObserver的onNext方法,发送请求消息,代码如下所示:
requestObserver的onNext方法实际调用了ClientCall的消息发送方法,代码如下:
对于双向Streaming模式,只支持异步调用方式。
gRPC服务调用支持同步和异步方式,同时也支持普通的RPC和streaming模式,可以最大程度的满足业务的需求。
对于streaming模式,可以充分利用HTTP/2.0协议的多路复用功能,实现在一条HTTP链路上并行双向传输数据,有效的解决了HTTP/1.X的数据单向传输问题,在大幅减少HTTP连接的情况下,充分利用单条链路的性能,可以媲美传统的RPC私有长连接协议:更少的链路、更高的性能:
gRPC的网络I/O通信基于Netty构建,服务调用底层统一使用异步方式,同步调用是在异步的基础上做了上层封装。因此,gRPC的异步化是比较彻底的,对于提升I/O密集型业务的吞吐量和可靠性有很大的帮助。
当采用异步编程之后,异步抛出的异常传递给调用方会变得非常困难,例如Runnable,当异步执行它时,异常需要在run方法中捕获和处理,否则会导致线程跑飞,run方法中的异常是无法回传到调用方的。
使用JDK8的CompletableFuture之后,它的常用方法参数基本是Lambda表达式,由于函数接口中的方法通常不允许检查期异常,在表达式中发生的异常无法回传给调用方,相比于以前同步调用可以将异常抛给调用方处理的方式有很大差异。
异步异常的解决策略:
1.如果异步的编程模型基于JDK8的CompletableFuture,可以通过whenComplete对返回值的异常进行非空判断,当异常非空时,进行异常逻辑处理,相关接口如下:
也可以通过exceptionally方法来处理异步执行发生的异常,相关接口如下所示:
2.异步回调(Lambda表达式)代码块中的异常处理有两种策略:1)一定要通过exceptionally方法或者whenComplete对异常进行捕获处理,否则会导致Lambda表达式异常退出,后续操作被忽略,最终导致业务逻辑跑飞。2)运行期异常,通常是无法抛出来由调用方处理的,需要在发生异常的地方就地捕获和处理。
异步代码块(Lambda表达式)中可能会涉及到多种业务逻辑操作,例如:
1.数据库、缓存、MQ等中间件平台调用。
2.第三方接口调用。
3.级联嵌套其它微服务调用。
对于异步的超时控制,建议策略如下:
1.对单个原子的中间件、第三方接口、微服务做超时控制。
2.不建议直接对异步代码块(Lambda表达式)整体做超时控制,例如包装出一个支持异步超时的CompletableFuture,主要原因如下:
超时并不能确保中断当前正在执行的业务逻辑,例如同步Redis缓存调用。
如果超时发生时,正好又发起了一次异步RPC调用,创建了一个新的CompletableFuture,外层超时之后,已经创建的CompletableFuture异步回调仍然可能会被执行,这会带来各种混乱。
由于异步代码块(Lambda表达式)中的业务逻辑可能会非常复杂,所以超时之后的补偿操作非常困难。例如充值操作已经成功了,但是外层调用方超时失败了,这会给后续业务的处理带来很多困难,因为超时发生时调用方并不知道异步代码块中的哪些操作被执行,哪些没被执行。
没有超时控制之后,要确保CompletableFuture能够正常或者异常的结束,否则会导致CompletableFuture积压,最终发生OOM。
在传统的同步RPC调用时,业务往往通过线程变量来传递上下文,例如:TraceID、会话Session、IP等信息。异步化之后,由于潜在的线程切换和线程被多个消息交叉复用,通常不建议继续使用线程变量传递上下文。
异步化之后,上下文传递的建议策略:
1.如果是Lambda表达式,可以直接引用局部变量,通过变量引用的方式将上下文信息传递到Lambda表达式中,后续可以通过方法传参等层层传递下去。
2.在所有发生线程切换的地方,显式的进行上下文信息的拷贝和清理,特别需要注意的是隐式线程切换,例如Hystrix,底层会自己启线程池。
3.建议通过调用级的消息上下文来做参数传递,每个上下文都关联一次RPC调用,调用完成之后自动清理掉。
4.异步化之后,需要排重点查所有使用ThreadLocal的地方,通常情况下都会存在问题,需要做改造。
如果使用的是JDK8的CompletableFuture,它支持对异步操作结果做编排以及级联操作,能够比较好的解决类似JS和传统Future-Listener的回调地域问题,感兴趣的读者可以体会下CompletableFuture的异步化接口。
李林锋,10年Java NIO、平台中间件设计和开发经验,精通Netty、Mina、分布式服务框架、API Gateway、PaaS等,《Netty进阶之路》、《分布式服务框架原理与实践》作者。目前在华为终端应用市场负责业务微服务化、云化、全球化等相关设计和开发工作。
联系方式:新浪微博 Nettying 微信:Nettying
Email:neu_lilinfeng@sina.com
转载地址:http://cixkl.baihongyu.com/