@Async用了springboot提供的线程池,居然引发了事故?

8,058 阅读6分钟

感觉有点像营销号的标题。。 确实是由于使用@async却不清楚springboot默认线程池配置导致的一次生产环境的事故。承认错误,立正挨打。

太长不看版 这篇文章说了啥

  • @Async默认使用spring boot注解的线程池。线程池参数中的queue_capacity和max_pool_size均是int的上界。由于线程池的工作原理是:当池中的线程数等于核心线程数,且任务队列不满的时候,新来的任务优先排队而不是优先启动线程解决任务。显而易见,我们是不可能填充满这个池的任务队列的。这样就会造成池的整个生命周期只有8个核心线程工作,从而导致任务堆积的问题。

  • 补充一些排查线上问题的手段

问题代码

  // 4.3.3 给报名【接受邀请】的人发消息 user//openid//user_name都是accept
                satisfyMsgService.sendActivitySatisfyMsg(ActivityWrapper.buildActivityMessageBean
                    (activity, StrUtil.EMPTY, StrUtil.EMPTY, userId,
                        acceptUserName, acceptOpenId, maAppId,
                        inviteSceneId, PinYinConstants.ZERO, PinYinConstants.ZERO
                        , channelId, msgType, sceneId, yyChannelId));
            }

            // 5 组装报名事件
            publisher.publishEvent(ActivityWrapper.ActivityEnrollEventForSend(activityEnrollDTO, userId,
                activity.getPicbookCollectionId(), activity.getPid(), appId, inviteUserId, discountFlag, yyChannelId));


    @Async
    public void sendActivitySatisfyMsg(ActivityMessageBean activityMessageBean) { }

该段代码为了解耦设计了2种异步机制【消息异步@Async和事件发布机制@EventPublisher】。并且会触发3次异步。 但是这两种异步机制使用的线程池均为spring默认提供。该线程池配置经过debug如下。

我们看到默认的参数中queue_capactity【任务阻塞队列】的容量为int的上界2147483647,也就是说这是一个可以看作无边界的任务队列。

因为线程池的工作原理为:当【任务队列不满】 且 【线程池中线程数已经达到核心线程数】以后,任务将优先排队而不是新启动线程去处理任务。结果就是线程池在整个生命周期都只有8个线程在工作,这个造成了任务堆积。

问题描述

  • 2020-10-18 22:03:02.410以后 接口activityService中事件发布机制代码,监听器不响应事件。监听器ActivityEnrollListerner最后一次响应时间为2020-10-18 22:03:02.121, es索引e90d9e88ebe165594841e932c5fd41df。监听器ActivityInviteListener最后一次响应时间为2020-10-18 22:03:07.831。es索引f4fc7f83f424318cb591f7e4dec7d05c。

  • 影响功能:用户报名之后本应该触发监听器进行数据入库。事件监听器不再响应之后,用户报名成功但不入库。导致用户在点击绘本阅读时候状态仍然是未报名,无法正常阅读。统计到从2020-10-18 22:03以后到修复时间段 影响用户数量1327人。

问题原因和代码分析

  • 阅读了2020-10-18 22:03:02.41监听器最后一次响应和2020-10-18 22:03:09.382监听器第一次未响应【es索引 653a4d9ab61c3e144937b193da9da947】间从【10586273】- 【10587014】的服务器日志,并没有发现报错和异常。

  • grafana中阿里机器/rds/内存/硬盘资源监控,该时段均正常。[cpu使用不到20% 内存正常 网络出入无异常]

  • skywalking中实例监控正常 jvm堆栈内容均正常。

  • skywalking中服务监控正常且该时段无报错日志

  • 走了一圈发现都没问题以后,我把目光放在了@Async源码上。通过对【org.springframework.aop.interceptor.AsyncExecutionInterceptor】这个代码分析。定位到代码中红框的线程池确确实实是一个无边界的池。猜测池中出现了任务堆积情况。

问题验证

为了验证这一原因 我写了一个demo

1: 自定义线程池 [通过参数的不同配置 来复现和改进]

/**
 * 自定义线程池 配置
 *
 * @author yanghaolei
 * @date 上午 2020-10-22 10:13
 */

@Configuration
public class ThreadConfig {

    @Bean("msgThreadTaskExecutor")
    public ThreadPoolTaskExecutor getMsgSendTaskExecutor(){
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        taskExecutor.setCorePoolSize(10);
        taskExecutor.setMaxPoolSize(25);
        taskExecutor.setQueueCapacity(800);
        taskExecutor.setAllowCoreThreadTimeOut(false);
        taskExecutor.setAwaitTerminationSeconds(60);
        taskExecutor.setThreadNamePrefix("msg-thread-");

        /**
         * 当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略
         * 通常有以下四种策略:
         * ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
         * ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。
         * ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
         * ThreadPoolExecutor.CallerRunsPolicy:重试添加当前的任务,自动重复调用 execute() 方法,直到成功
         */
        taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        taskExecutor.initialize();
        return taskExecutor;
    }
}

2: Controller [分发100个任务]

     @GetMapping("/test/async")
    public void testAsync() {

        CountDownLatch countDownLatch = new CountDownLatch(100);
        System.out.println("主线程执行开始");

        for (int i = 0; i < 100; i++) {
            testService.doAsync(countDownLatch,i);
        }

        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("主线程执行结束......");
    }

3: Service [业务代码]

    @Async("msgThreadTaskExecutor")
    public void doAsync(CountDownLatch countDownLatch, int i) {
        System.out.println(Thread.currentThread().getName() + "running");

        try {
            //todo
            Thread.sleep(2000);
            log.debug("异步方法执行了......" + "第" + i + "个任务");
        } catch (Exception e) {
            e.printStackTrace();
        }

        countDownLatch.countDown();
        System.out.println(Thread.currentThread().getName() + "over");

    }
结论:
  • 在模拟了和10-18晚接近的参数以后,我们发现任务堆积和处理完100个任务的时间达到了12000ms。这个时间是比较容易出现监听器抛弃和无响应的结果的。也比较能解释的通事故出现的原因。

  • 基于这个实验结果。我们设置了一个较短的队列容量。【线上环境800】。好处是当任务超过队列容量的时候,机器将会启动新的线程来处理新来的任务直到线程池线程数量达到池的上限【maxPoolSize】。这样就能比较好的处理任务堆积的问题。 坏处在于加大了cpu的消耗,因为线程的销毁需要启动垃圾回收gc。但是由于cpu是可以监控的,虽然各有利弊,但这里其实是把【不容易监控的线程池资源转换成了容易监控的cpu资源】。

  • 后续也从阿里的技术规范中 以及和 其他不满的技术伙伴交流以后。发现大家的建议还是比较统一,就是要求【@async等异步的代码需要自定义线程池】,尽量不要用默认的池。池资源以及池管理不当引起的频繁gc,再由频繁gc导致的oom也是最为常见的事故诱因之一。

复盘
  • 从代码上我们改进了线程池的配置。重新配置了queue_capacity和max_pool_size。

  • 随着流量的快速增加,面对不可知的风险的概率也增大了。这次事故导致我们市值200亿美金(可能现在不是了,昨天大跌百分之30,害,🐶),与做空机构大战500个来回不分胜负并且股价上升了5倍,割美国资本主义韭菜,即瑞幸以后的新民族之光的, 纳斯达克上市公司损失了2w块钱,痛心疾首。