Bootstrap

CompletableFuture 引发的线上问题

原文地址:http://www.linzichen.cn/article/1575774767477161984

有次在打开自己网站的时候,发现请求一直在转圈圈,没有页面响应。第一反应是服务器被攻击了?

服务器状态

但随即就否定了这个可能,正经大佬谁会闲着没事找这个小站的麻烦。如果是学网络的同学拿着本站练手还有可能,但是本站知道的人寥寥无几,平时更是无人踏足,也不太可能。于是纠结中,还是打开了服务器的后台,发现各项指标一切正常,也就不存在被黑的情况。

服务器状态.png

Nginx状态

网站域名是通过nginx转发到后台服务的,随后想到了是不是nginx 的问题,于是查看了下nginx 的状态,发现nginx 也是正常的,且网站的静态资源可以正常访问。看了下log,果然发现存在 error 日志,打开看下,异常信息为:

upstream timed out (110: Connection timed out) while reading response header from upstream

很明显,请求上游服务器超时,那上游服务器可不就是后台应用嘛,大致定位到问题源头了,在程序端。

应用程序排查

查看后台日志,发现接口可以正常进来,但是程序走到某个地方停住了,且线程已经在这儿堆积了几十个在等待了。

线程堆积.jpg

首先觉得不可能是并发导致的,因为并发再高,只要程序是正常的,即使没有做熔断降级处理,也不至于一直阻塞在同一个地方。且每次请求线程都是依次递增阻塞,说明之前的线程压根就没有释放。很明显是程序在某个地方被锁住了。

锁排查

分析到锁了,首先想到的 是不是与其他服务的连接被阻塞了,导致一直获取不到连接,且没有设置超时时间,所以程序就一直锁在某个地方。心里觉得不太可能,首先不存在并发,且日志里没有 连接超时相关的打印。但处于不确定因素,还是对程序里连接的其他服务挨个排查了下。

es排查

code.png

网站首页的列表数据是查的 es,会不会是 es 阻塞了,但是日志里很明显有 es 响应的数据返回(201行有日志输出),所以不是es 的问题。

mysql 排查

项目里并没有复杂的业务,且读多写少,不会存在数据库层死锁的情况。出于严谨,还是查看了下数据库的连接数,发现也没有问题。

连接数.png

redis 排查

用第三方工具连接了redis,发现也可以正常查询,说明也不是因为redis阻塞的

Jstack 排查

百思不得其解,看了半天代码,实在没有发现哪里存在问题,于是打算用 jstack 分析下堆栈信息。

1、获取java进程的pid

[xxx@VM-16-3-centos /]# ps -ef | grep java
xxx      3932 25457  0 21:50 pts/0    00:00:00 grep --color=auto java
xxx     20472     1  1 20:43 ?        00:01:11 java -jar xxx.jar

获取到 程序 pid 为 20472

2、查看进程里占用资源最多的线程

topHp.png

虽然发生了死锁,但是查看 cpu 的资源占用的并不多,找到进程中占用资源最多的线程的 pid 20586

3、将pid转16进制

[xxx@VM-16-3-centos /]# printf "%x\n" 20586
506a

得到 506a

4、jstack查询

jstack.png

根据堆栈信息,发现 CompletableFuture 中的线程都处在 WAITING(parking) 中,定位到图中异常处代码的地方,发现在 CompletableFuture.allOf().get() 处发生了阻塞。

CompletableFuture.allOf(categoryFuture, hotArticleFuture, websiteInfoFuture).get() ;

排查到此,大致明白了阻塞的原因。

阻塞原因

在接口中,除了查询文章列表信息外,还会将网站的公共数据也查询出来,由于这些数据之间是不存在前后查询顺序的,所以为了提高接口的响应速度。我在代码里 通过 CompletableFuture 做了几个异步查询。

// 查分类信息
CompletableFuture<Void> categoryFuture = CompletableFuture.runAsync(() -> {
    List<CategoryVo> categoryList = categoryService.selectCategoryList();
    commonData.setCategoryList(categoryList);
}, threadPoolExecutor);
// 查热门文章
CompletableFuture<Void> hotArticleFuture = CompletableFuture.runAsync(() -> {
    List<CommonData.HotArticle> hotArticleList = selectHotArticle();
    commonData.setHotArticleList(hotArticleList);
}, threadPoolExecutor);
// 查网站信息
CompletableFuture<Void> websiteInfoFuture = CompletableFuture.runAsync(() -> {
    CommonData.WebsiteInfo websiteInfo = selectWebsiteInfo();
    commonData.setWebsiteInfo(websiteInfo);
}, threadPoolExecutor);

这些异步任务的线程是来自同一个线程池 threadPoolExecutor 。但代码仅仅是这样,还不至于产生阻塞的情况,真正阻塞的是,我在每一个异步任务的 service 方法中,又细分了多个子异步任务,且它们都公用的一个线程池。

比如在查询网站信息 selectWebsiteInfo() 方法中,里面又细分为查询文章数目评论数目运行天数 等任务,还有一些其他的日志记录等代码 都是通过异步去实现的。

而以上这个情况,如果线程池设置的核心线程数 不多,那么就很容易造成一种现象:

父任务在等待子任务结束,而子任务又在等待父任务释放资源。所以就造成了死锁状态。

验证

为了验证上述结论是否正确,通过一个demo 复现一下场景。

public class ThreadPoolTest {

    public static ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
            3,50,5, TimeUnit.SECONDS,
            new LinkedBlockingDeque<>(10),
            Executors.defaultThreadFactory(),
            new ThreadPoolExecutor.AbortPolicy()
    ) ;

    public static void main(String[] args) throws Exception {

        List<CompletableFuture<Void>> list = new ArrayList<>(0) ;
        for(int i = 0 ; i < 3 ; i++) {
            // 2、线程池中就3个空闲线程,因为做了 sleep,所以 3个资源都给了父任务
            CompletableFuture<Void> parentTask = CompletableFuture.runAsync(() -> {
                try {
                    System.out.println("父任务执行了:" + Thread.currentThread().getName());
                    Thread.sleep(10);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                // 3、子任务在等待父任务释放资源,父任务在等待子任务执行完,死锁
                CompletableFuture<Void> childTask = CompletableFuture.runAsync(() -> {
                    System.out.println("子任务执行了:" + Thread.currentThread().getName());
                }, threadPoolExecutor);
                childTask.join() ;

            }, threadPoolExecutor);
            list.add(parentTask) ;
        }
        // 1、开始创建3个异步任务并执行
        CompletableFuture.allOf(list.toArray(new CompletableFuture[0])).get();
        threadPoolExecutor.shutdown();
        System.out.println("exit");
    }
}

console.png

输出结果是阻塞的,接下来再根据 jstack 分析下原因:

根据 jps,找到类的 pid:

jps.png

根据 jstack 15769 ,找到线程阻塞原因:

log.png

根据日志,发现线程代码阻塞在第33行,而 33行 正式子任务获取线程执行的地方。
child.png

由上可知,问题产生的原因确实是因为线程池获取连接导致死锁阻塞了。

解决方案

1、提高线程池的核心线程数,但是此方法治标不治本。

2、嵌套线程之间最好不用同一个线程池,做线程池隔离,避免死锁问题。

总结

1、不建议直接使用CompletableFuture的get()方法,而是使用future.get(5, TimeUnit.SECONDS);方法指定超时时间。

2、在使用CompletableFuture的时候线程池拒绝策略最好使用AbortPolicy,如果线程池满了直接抛出异常中断主线程,达到快速失败的效果。

3、耗时的异步线程和CompletableFuture的线程做线程池隔离,让耗时操作不影响主线程的执行。

;