异步 Servlet 在转转图片服务的实践
一、问题背景
为了提供公司内部统一的图片上传平台,去年架构部开发并上线了一个 SpringBoot 的 web 服务,客户端可以通过 Http 上传图片,web 服务端对图片进行内部处理(如裁剪、加水印等)后最终将图片放入远端存储。前段时间,突然收到监控系统的报警信息,同时业务反馈有大量的上传图片请求超时。针对这个问题,开始进行了排查优化。
二、排查问题
首先看 JVM 的状态,可以发现两个关键的异常指标。其中一个是 FullGC 次数。从公司内部的监控系统可以看出在18:20
这个时间点某台机器的 FullGC 次数开始暴增。
众所周知,FullGC 的原因是 JVM 在堆中的对象总和大于 JVM 堆的总内存,需要进行 FullGC 对非存活的对象进行回收。而当 FullGC 频繁时,一般有两个原因:
有大对象直接进入老年代
对象不断在创建并且一直存活,即内存泄漏
仅从已有的信息尚不能确定 FullGC 的主要原因,所以需要继续寻找其他的异常点。
第二个异常是 Runnable 线程的数量。同样地从18:20
开始 Runnable 线程不断增加,并且波动的趋势和 FullGC 次数的趋势几乎一模一样。
这时候基本可以锁定 FullGC 频繁的原因是大量的 Runnable 线程所携带的对象不能被回收。那么下一个问题是,为什么会突然有这么多 Runnable 线程?登录其中一台服务器,使用 jps
命令获取当前进程 id,接着jstack
命令带上该进程 id 可以查看各个线程当前的堆栈信息。
找到其中一个 Runnable 线程可以看到堆栈停在了 socketRead0()
方法,再往下看,可以看到是 PictureUploadServiceImpl
的getInputSreamFromUrl()
方法,该方法的逻辑是下载业务方提供的第三方图片 URL。所以出现大量的 Runnable 线程说明某个业务方提供的第三方图片 URL 下载异常!
即因为图片 URL 下载超时->当前线程阻塞->不断创建 Tomcat 线程(且下载图片)->FUllGC 频繁!
三、解决方案
3.1 初步方案
知道了问题产生的原因后,通过排查服务端的配置,可以发现两个参数设置得不合理:
Tomcat 最大线程数设置了 1000
从刚才的图看出,线程数到 200 左右就开始 FullGC,最大线程数 1000 只会加剧 FullGC,所以最大线程数应该减少,可以参考 Tomcat 默认的最大线程数,即为 200。
下载 URL 的超时时间设置了 5000ms
过大的超时时间会使线程一直处于 Runnable 状态,减少超时时间可以让线程快速完成任务,避免线程一直创建。
在调整完这两个参数后,FullGC 频繁的问题是解决了,但是因为限制了 Tomcat 最大线程数,又会带来新的问题。
3.2 新的问题
如下图所示,该服务的 Tomcat 线程池主要接收了两类请求供客户端上传图片
其中 A 类请求是接收客户端上传的 MultipartFile
对象,而 B 类请求是接收客户端上传的 URL。
对于 A 类请求,服务端接收的是二进制流,Tomcat 线程可以直接把流上传到存储终端,而对于 B 类请求,服务端接收的是图片 URL,Tomcat 线程需要对 URL 先进行下载,再上传到存储终端。
所以当图片 URL 的服务器出现异常时,Tomcat 线程在下载图片会被阻塞,导致 Tomcat 线程不能被释放,在流量大的情况下,整个 Tomcat 线程池都是正在处理 B 类请求的阻塞线程,当 Tomcat 的阻塞队列被打满后,服务端将不能接收任何请求!
而在公司的业务场景中,A 类请求的比例远远大于 B 类请求,这种情况是绝对不能接受的。为了保证重要业务的正常运行,需要增加另外的线程池进行线程隔离。
将 B 类请求的实际处理交给新增的业务线程池去执行,只需对业务线程池设置合理参数(容量,阻塞队列和拒绝策略等),就能够保证 B 类请求不会对 Tomcat 线程池造成任何影响,从而保证 A 类请求的正常处理。
加入线程池就能够完美地解决问题吗?仔细想一下,客户端上传了一张图片,原本在 Tomcat 中处理后返回了上传地址,而现在改成另一个线程池中处理,处理后的结果应该怎么返回给客户端呢。
有读者可能会认为可以使用以下方式提交获取返回值
但是一旦使用了future.get()
就会对 Tomcat 现在造成阻塞,又会遇到最开始的问题。所以用submit()
提交属于本末倒置了。那么到底该怎么解决,既能实现线程隔离,又能同步返回处理后的结果给客户端?经过调研,我们最终使用了异步 Servlet 技术解决这个问题。
3.3 异步 Servlet
异步 Servlet 是 Servlet3.0 规范中一个新特性,支持 Servlet 在处理过程中可以开启异步模式,然后在相应的业务线程里面进行一些业务操作,完成业务操作之后才对请求返回响应。
3.3.1 Servlet3.0 规范
异步 Servlet 的示例如下:
Servlet 规范只是一个标准,在不同的 web 容器中有不同的实现。由于我们平时使用基本都是 Tomcat 容器,所以下面介绍下 Tomcat 的实现。
3.3.2 Tomcat 实现
从上面的 demo 看出,除了req.startAsync()
和 ctx.complete()
这两行代码,其他的步骤和我们平时使用线程池异步处理没有什么区别。那么这两行有什么作用呢?为了避免本文篇幅过长,这里对具体的源码实现进行概括:
req.startAsync()
主要干了两件事:
设置当前请求为一个异步类型的请求
把当前的请求的 request 和 response 保存在一个上下文对象中
当 Tomcat 线程的任务结束之前会判断当前的请求是否为异步类型,如果是异步类型,不会对当前的 request 和 response 进行 finish 操作。这就是为什么 Tomcat 线程能够释放并且保持服务端和客户端连接仍然打开的原因!
ctx.complete()
的作用则是使用另外一个 Tomcat 线程对之前保存的 response 进行 finish 操作,对请求进行了返回。
Tomcat 的异步 servlet 的实现原理就是这么简单,想知道更多的细节可以自行阅读和调试源码。
但是 Tomcat 只实现了ctx.complete()
这个 api 对 response 进行返回,如果需要对 response 进行其他操作,比如把异步上传后的图片地址进行返回,需要在调用ctx.complete()
之前进行设置。
3.3.3 Spring MVC 实现
为了使用更便捷,SpringMVC 3.2 版本对 Tomcat 的异步 servlet 实现进行了进一步的封装和拓展,以下是官方文档提供的demo:
Spring MVC 自己封装了 DeferredResult
类,让开发者使用起来更加简单,只需要在 controller 的方法把需要返回的对象类型包装在DeferredResult
里面,然后进行 return 就可以。Spring MVC 的核心处理类DispatchServlet
会对DeferredResult
类型的返回值进行特殊处理,其流程和 Tomcat 的实现大同小异,都是对当前请求的 request 和 response 进行保存并保持 response 打开。当其他线程调用deferredResult.setResult(data)
之后会将异步处理的结果返回给客户端。
3.4 最终方案
到目前为止,我们已经可以针对一开始出现的问题给出完善的解决方案。
针对减少 FullGC:
调整 Tomcat 线程数 200
调整下载图片超时时间 2000ms
针对线程隔离:
使用线程池 + DeferredResult 进行异步处理和同步返回
最终应用到项目的 controller 代码为:
四、总结
对异步 Servlet 再抽象:
异步 Servlet,释放容器(Tomcat)工作线程,耗时处理在子线程中且返回结果可通过子线程返回。
关键词:耗时、释放容器线程、子线程返回结果数据。
容易得出以下异步结论:
异步化后,快速释放容器工作线程,提升容器响应更多客户端请求。
当应用明确有耗时请求和非耗时请求时,采用异步技术,可以达到耗时请求隔离效果,即耗时请求不占用容器线程,容器更好地为非耗时请求提供服务。
对异步技术再具象:
配置中心 Apollo 配置更新使用异步 Servlet 技术。
Dubbo 服务端异步实现原理 AsyncContext 技术。
阿里开源 Nacos 更新配置使用异步 Servlet 技术。
最后附上笔者一点小小的思考:先获取一个技术的使用和原理,进而去思考和领悟技术背后设计思想的巧妙之处,再去发现该思想的其他应用,其乐无穷。与君共勉。
作者简介
纪智焓,转转架构部基础服务方向开发工程师,负责配置中心、多媒体服务、分布式锁、短网址等多个基础服务。热爱思考,保持学习,擅长总结。
相信一切尚未解决的 BUG 都是当下自身知识储备不足导致的。
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。
关注公众号「转转技术」(综合性)、「大转转 FE」(专注于 FE)、「转转 QA」(专注于 QA),更多干货实践,欢迎交流分享~
评论