性能优化的角度看异步及全链路异步实现
前言
一般在服务遇到性能瓶颈的时候我们有以下一些优化手段
- 硬件升级,使用更多数量、更多核数、更大内存的服务器,SSD 替换机械硬盘等
- 代码逻辑优化,JVM 优化
- 缓存。本机堆内堆外缓存、分布式缓存等
- 集群化。单体服务可以拆分为分布式服务
- 微服务化。例如进行数据与服务拆分、计算密集型与/IO密集型服务拆分、C端/B端服务拆分、核心/非核心服务拆分、高频服务单独部署等。以避免业务间的互相影响而导致的性能短板。
- 异步化,异步化既包括利用线程池、消息队列等技术层面的异步化,也包括业务流程的异步化优化。合理巧妙的使用异步化往往能最小代价的带来最显著的性能优化效果。
在这些优化方式中,服务器硬件升级、集群化这些工作在基于容器的Paas平台变得日益简单、便捷;而合理的服务拆分、缓存使用往往需要老道的项目经验;异步化和代码优化是我们在日常的需求开发中惯用的性能优化手段。优雅的代码体现了良好的编程素养,但效果往往不那么明显;而巧妙的异步化使用,却总能发挥奇效。
全链路异步
异步化,是一些技术实现,更是一种思维方法。下面从一个 HTTP 请求讲起,阐述Http异步、Tomcat异步化、Servlet异步化、RPC异步化、业务流程异步化等方法的使用。
HTTP 异步
HTTP 异步原理其实和 RPC 异步一样,因为底层都是依赖 TCP 协议通信,而之前介绍过 TCP 通信本质就是异步的,通过消息 id 关联请求和响应。异步 http 就是客户端向服务器发送http请求,然后去做别的事情了,至于服务器何时把响应数据包发送过来,客户端可以不管,至少在当前执行的上下文中可以不管,因为当服务器响应时,会有另外的线程被唤醒处理这个响应。实现方式有 CloseableHttpAsyncClient、AsyncRestTemplate、WebClient 等。
Tomcat 连接器的异步化
一个 HTTP 请求经过网络、路由和负载均衡后,会到达相应的服务器进行请求的处理,而服务器中的 Tomcat 则一般监听着服务器的8080端口,对请求提供服务。我们知道 Tomcat 是通过 Connector 与客户端建立链接,解析应用层协议(RPC或HTTP)然后将请求转化成 ServletRequest 分发到相应的 Servlet 进行处理。Tomcat 同时支持 BIO、NIO、NIO2(AIO)方式,或者底层网络IO通信可以借助 Netty 框架,BIO 和 NIO 简单对比如下
NIO 会把接受到的链接放入事件队列,然后多个 epoll 线程会从事件队列获取事件,并且 NIO 可以让每个 epoll 线程去监听多个链接 socket 的事件,然后交给线程池去处理,也就说一个 epoll 线程可以监听多个 socket 的读写事件,然后交给线程池去处理。
Servlet 异步
连接器向 Servlet 传递解析并封装后的请求对象( HttpServletRequest )以及对应的响应对象( HttpServletResponse ),Servlet 容器接收到请求后,会分配一个 Servlet 线程(或 Tomcat 线程,Tomcat 的 executor 配置的线程池)处理请求,根据请求的 URL 和 Servlet 的映射关系,找到相应的 Servlet,如果 Servlet 还没有被加载,就用反射机制创建这个 Servlet,并调用 Servlet 的 init 方法来完成初始化,然后 Servlet 实例根据请求对象得到客户的请求信息;Tomcat 会给每个请求生成一个 Filter 链,Filter 链中的最后一个 Filter 负责调用 Servlet 的 service 方法来进行请求的处理,如果没有异步化则在业务 Service 没有返回之前 Servlet 线程会一直阻塞。
如果 Servlet 这一步没有支持异步化, HTTP 协议的处理仍然是同步的。因为 Tomcat 需要同步阻塞的等待业务逻辑处理 HTTP 请求。考虑一些极端的场景,Web 应用进行业务逻辑处理时,需要较长的处理时间,那么 Tomcat 线程一直不回收,会占用系统资源,甚至导致“线程饥饿”—— Tomcat 没有更多的线程来处理新的请求。那 Servlet 中又是如何来实现异步处理模式的呢?
在 Servlet 3.0 中引入了异步 Servlet,通过在 Web 应用里启动一个单独的线程来执行这些比较耗时的请求,而 Tomcat 线程立即返回,不再等待 Web 应用将请求处理完。这样 Tomcat 线程可以立即被回收到线程池,用来响应其他请求,降低了系统的资源消耗,同时还能提高系统的吞吐量。接下来我们具体看看Servlet是如何异步化处理 HTTP 请求的。首先给出异步 Servlet 的示意图,如图所示。
总结一下,异步 Servlet 机制减少了线程的阻塞等待,将 Tomcat 线程和 Web 应用的业务线程分离开来,Tomcat 线程不再需要等待业务代码的执行,而是通过 AsyncContext 保存 Request 和 Response 对象上下文,当 Web 应用完成请求的处理,回调 AsyncContext 的 complete 方法,生成 SocketProcessor 交给Tomcat线程池处理、返回给客户端。在我们的日常开发中,如果发现 Tomcat 的线程不够了,大量线程阻塞在等待 Web 应用的处理上,而 Web 应用又没有优化的空间了,确实需要长时间处理,这个时候不妨尝试一下异步 Servlet。
RPC 的异步
在 RPC框架支持异步 中专门介绍过 RPC 异步的实现原理。异步 RPC 重点关注的是从服务调用方发出 RPC 请求到收到服务提供方回调响应结果,这整个过程中服务调用方并没有同步阻塞的等待调用结果返回。
而实现 RPC 异步的方式有很多,例如最简单的使用线程池处理 RPC 的调用,当然我们最好是能够利用 RPC 框架本身提供的异步接口实现异步调用,例如利用 Netty NIO 特性实现异步则不需要客户端使用线程池,减小线程切换的开销。甚至可以利用 RxJava 提供的协程的功能实现异步。
业务流程的异步化
使用异步消息队列来异步化日志、消息等,是最典型、最教科书级的实践。另外可以结合业务做很多异步化处理。
总结
在进行全面异步化改造过程中,除了对移动端、服务端的业务进行基于 Rx* 编程框架的代码重构、实现,还需要对各种中间件,如网关、服务框架、缓存、消息队列、DB、限流组件等进行异步化支撑的改造。在编程实现过程中,需要考虑线程模型统一接管、异步化上下文传递、上线最后一公里稳定性的问题,基于响应式的全面异步化架构难点总结如图所示。可以看到,这些全面异步化改造中的难点,解决难度大、涉及业务范围广、耗费开发人力多。因此如果只是为了性能优化,我们可以根据系统的特点只做一些针对性的异步化就可以起到意想不到的效果。
业界趋势及思考
提到异步化,我们的脑海里肯定会想到线程,毫无疑问,异步化的基础就是线程,线程是操作系统层面对异步化的支持。但是传统的线程异步化模型是完美的吗?当然不是,线程来实现异步化依然存在如下的问题:
线程数目过多,导致线程上下文切换的成本陡增,非常不适合于cpu密集型应用
上下文切换对于cpu密集的应用来说是噩梦,因为cpu的速度比内存的速度快的多,导致上下文切换的成本肉眼可见。但是对于网络请求这种,上下文切换的成本就可以忽略不计。
线程无法解决阻塞问题,对于常用的io操作,比如数据库io,网络请求io等等开发中常见的io操作,依然会阻塞当前线程。
典型的示例就是java中的future,使用future的时候,虽然可以立即获取结果,但是最终future的get依然会阻塞线程,也即只是减少了等待,实质上依然无法完全消除阻塞,依然在浪费线程资源。
其中第二个问题是导致线程异步化无法实现全异步化的最重要问题,那么有没有办法完全消除阻塞呢?答案是可以的。现在在整个java届比较主流的方向有两个
协程化技术
协程化技术其实是现在非常流行的,协程,是轻量化的线程。和利用线程进行调度来提高进程的能力一样,利用协程是提高线程能力的,让线程不再空等,利用每一个cpu时间片。但是现在java没有官方支持的协程,都是民间的开源技术。
响应式编程
响应式编程其实不是一个新概念,在jdk中,其实已经有现成的工具。比如callback。但是callback容易形成很长的callback调用链,代码非常的不优雅以及难以理解,所以才有了很多响应式编程的开源库,来帮助开发写出更容易理解的代码,其中rxjava和reactor是现在比较流行的开源响应式编程库。
对比上述的两种技术,会发现,虽然都没有官方的支持(暂时),但是响应式是一种趋势,连jdk都在9以后加入了响应式的api。协程化技术过于笨重,且需要对源码进行编译时改动,上手成本大,不容易维护。可以等jdk官方支持协程化后在考虑。
但是最后再说一点,JDK 官方为什么迟迟不支持协程呢?不仅仅是因为有很重的历史包袱,一个很重要的原因我想是大部分业务场景下线程模型就可以支持的很好了,并且随着硬件成本的持续降低,协程相对的那些优势都没那么明显了,因此除非有特别适合并且性能有极致要求的场景,否则没有太大必要去可以引入新的技术栈。感觉这篇知乎的帖子说的有道理:你会在实际工作中使用 rxjava 吗?