您的位置 首页 java

异步Servlet在转转图片服务的实践

一、问题背景

为了提供公司内部统一的图片上传平台,去年架构部开发并上线了一个SpringBoot的web服务,客户端可以通过Http上传图片,web服务端对图片进行内部处理(如裁剪、加水印等)后最终将图片放入远端存储。 前段时间,突然收到监控系统的报警信息,同时业务反馈有 大量的上传图片请求超时 。 针对这个问题,开始进行了排查优化。

二、排查问题

首先看JVM的状态,可以发现两个关键的异常指标。其中一个是FullGC次数。从公司内部的监控系统可以看出在 18:20 这个时间点某台机器的 FullGC次数开始暴增

众所周知,FullGC的原因是JVM在堆中的对象总和大于JVM堆的总内存,需要进行FullGC对非存活的对象进行回收。而当FullGC频繁时,一般有两个原因:

  1. 有大对象直接进入老年代
  2. 对象不断在创建并且一直存活,即内存泄漏

仅从已有的信息尚不能确定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 初步方案

知道了问题产生的原因后,通过排查服务端的配置,可以发现两个参数设置得不合理:

  1. Tomcat最大线程数设置了1000

从刚才的图看出,线程数到200左右就开始FullGC,最大线程数1000只会加剧FullGC,所以最大线程数应该减少,可以参考Tomcat默认的最大线程数,即为200。

  1. 下载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中处理后返回了上传地址,而现在改成另一个线程池中处理, 处理后的结果应该怎么返回给客户端 呢。

有读者可能会认为可以使用以下方式提交获取返回值

 <T> Future<T> submit(Callable<T> task);
复制代码  

但是一旦使用了 future.get() 就会对Tomcat现在造成阻塞,又会遇到最开始的问题。所以用 submit() 提交属于本末倒置了。那么到底该怎么解决, 既能实现线程隔离,又能同步返回处理后的结果给客户端 ?经过调研,我们最终使用了异步Servlet技术解决这个问题。

3.3 异步Servlet

异步Servlet是Servlet3.0规范中一个新特性,支持Servlet在处理过程中可以开启异步模式,然后在相应的业务线程里面进行一些业务操作,完成业务操作之后才对请求返回响应。

3.3.1 Servlet3.0规范

异步Servlet的示例如下:

 @WebServlet(URLPatterns = "/async",asyncSupported = true)
public class AsyncServlet extends HttpServlet {

    ExecutorService executorService =Executors.newSingleThreadExecutor();

    @Override
     protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        //1. 开启异步,获取异步上下文
        final AsyncContext ctx = req.startAsync();
        //2. 提交线程池异步执行
        executorService.execute(new Runnable() {


            @Override
            public void run() {
                try {
                    //3.模拟任务并输出
                    Thread.sleep(10000L);
                    ServletResponse response = ctx.getResponse();
                    outputStream = response.getOutputStream();
                    outputStream.write("task complete".getBytes(StandardCharsets.UTF_8));
                    outputStream.flush();
                } catch (IOException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //4. 执行完成后完成回调
                ctx.complete();
            }
        });
    }
}
复制代码  

Servlet规范只是一个标准,在不同的web容器中有不同的实现。由于我们平时使用基本都是Tomcat容器,所以下面介绍下Tomcat的实现。

3.3.2 Tomcat实现

从上面的demo看出,除了 req.startAsync() ctx.complete() 这两行代码,其他的步骤和我们平时使用线程池异步处理没有什么区别。那么这两行有什么作用呢?为了避免本文篇幅过长,这里对具体的源码实现进行概括:

req.startAsync() 主要干了两件事:

  1. 设置当前请求为一个异步类型的请求
  2. 把当前的请求的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:

 @RequestMapping("/quotes")
@ResponseBody
public DeferredResult<String> quotes() {
    DeferredResult<String> deferredResult = new DeferredResult<String>();
    // Save the deferredResult somewhere..
    return deferredResult;
}
// In some other thread...
deferredResult.setResult(data);
复制代码  

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代码为:

 @RestController
public class PictureUploadController {
    private ExecutorService threadPool = new ThreadPoolExecutor(16, 16, 30, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(5000), new ThreadFactory() {
        private final AtomicInteger uploadUrlPicThreadNum = new AtomicInteger(1);

        @Override
        public Thread newThread(Runnable runnable) {
            Thread thread = Executors.defaultThreadFactory().newThread(runnable);
            thread.setName("uploadUrlPicThread-" + uploadUrlPicThreadNum.getAndIncrement());
            return thread;
        }
    }, new ThreadPoolExecutor.DiscardOldestPolicy());

    @Autowired
PictureUploadService pictureUploadService;

    @PostMapping("/asyncUpload")
    public DeferredResult<ApiResult> asyncUploadPicture(@Valid PictureUploadDTO pictureUploadDTO) {    
        //1. 构建DeferredResult对象,设置超时时间
        DeferredResult<ApiResult> deferredResult = new DeferredResult<>(5000);
        threadPool.execute(new Runnable() {
            @Override
            public void run() {
                //2.异步上传图片
                apiResult = pictureUploadService.upload(pictureUploadDTO);
                //3.设置返回值
                deferredResult.setResult(apiResult);
            }
        });
        return deferredResult;
    }
}
复制代码  

四、总结

对异步Servlet再抽象:

  1. 异步Servlet,释放容器(Tomcat)工作线程,耗时处理在子线程中且返回结果可通过子线程返回。
  2. 关键词:耗时、释放容器线程、子线程返回结果数据。

容易得出以下异步结论:

  1. 异步化后,快速释放容器工作线程,提升容器响应更多客户端请求。
  2. 当应用明确有耗时请求和非耗时请求时,采用异步技术,可以达到耗时请求隔离效果,即耗时请求不占用容器线程,容器更好地为非耗时请求提供服务。

对异步技术再具象:

  1. 配置中心Apollo配置更新使用异步Servlet技术。
  2. Dubbo服务端异步实现原理AsyncContext技术。
  3. 阿里开源Nacos更新配置使用异步Servlet技术。

最后附上笔者一点小小的思考:先获取一个技术的使用和原理,进而去思考和领悟技术背后 设计思想 的巧妙之处,再去发现该思想的其他应用,其乐无穷。与君共勉。

文章来源:智云一二三科技

文章标题:异步Servlet在转转图片服务的实践

文章地址:https://www.zhihuclub.com/174665.shtml

关于作者: 智云科技

热门文章

网站地图