后台定时统计任务太耗时如何优化一下

1. 事件背景

最近公司开发了一个新功能:统计有关联课程的学习信息,数据是每天晚上12点开始统计。由于数据量有点大,所以每次统计当天的任务需要差不多10个小时。运营人员需要第二天就看到数据,但是每天10点后才能看到确实有点不合理。于是组长让我把代码统计任务程序时间缩短一下。

2.案例还原演示

下面是我通过简单代码大概模拟的统计代码(当然和真实代码不是一样的),主要有2个核心的方法:一个是获取所有的课程信息,一个是根据课程Id获取有关联课程的统计信息(课程单元数量和所有关联课程学习总人数)。

为了方便演示获取所有的课程信息和根据课程Id获取有关联课程的统计信息 没有对接数据库,而是采用来写死的数据。

其中 获取所有的课程信息 中课程信息为20个,为了模拟真实效果该方法获取需要耗时 20秒,根据课程Id获取有关联课程的统计信息每调用一次需要2秒。

课程实体类

public class Course {

    private Long id;
    private String name;
    //省略getter and setter
}

课程服务类包含上面讲的2个核心方法。

public class CourseService {
    /**
     * 获取所有的课程
     * @return
     */
    public List<Course>  findAll(){
        List list = new ArrayList();
        for (int i = 0; i < 20; i++) {
            list.add(new Course((long) i,"course"+i));
        }
        try {
            Thread.sleep(1000*1L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return list;
    }

    /**
     * 获取有关联课程的统计信息
     */
    public void getClassStatisticsInfo(Course course){
        getUnitNum(course.getId());
        getLearnNum(course.getId());
    }

    /**
     * 获取课程的单元数量
     */
    private void getUnitNum(Long id) {
        try {
            Thread.sleep(1000*1L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    /**
     * 获取所有关联课程学习总人数
     */
    private void getLearnNum(Long id) {
        try {
            Thread.sleep(1000*1L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

普通单线程的处理方式,这个是没有优化前代码模拟,首先是获取所有的课程信息,然后遍历每个课程信息根据课程ID 获取有关联课程的统计信息,任务是一个一个执行的所以比较耗时。

普通单线程的处理方式具体代码如下:

/**
     * 普通单线成任务
     */
    private void ordinaryTask() {
        long startTime = System.currentTimeMillis();
        //获取数据库中数据
        CourseService courseStatisticsService = new CourseService();
        List<Course> list = courseStatisticsService.findAll();
        //处理任务
        for (Course course : list) {
            courseStatisticsService.getClassStatisticsInfo(course);
            System.out.println("课程:" + course.getName() + "处理完成!完成时间:"+ LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        }
        long endTime = System.currentTimeMillis();
        System.out.println("任务耗时:"+(endTime - startTime));
    }
 }

测试普通单线程的处理方式执行时间测试类:

public class Application {
    public static void main(String[] args) {
        Application application = new Application();
        application.ordinaryTask();
    }

普通单线程的处理方式日志如下:

课程:course0处理完成!完成时间:2020-04-18 17:54:04
课程:course1处理完成!完成时间:2020-04-18 17:54:06
课程:course2处理完成!完成时间:2020-04-18 17:54:08
课程:course3处理完成!完成时间:2020-04-18 17:54:10
课程:course4处理完成!完成时间:2020-04-18 17:54:12
课程:course5处理完成!完成时间:2020-04-18 17:54:14
课程:course6处理完成!完成时间:2020-04-18 17:54:16
课程:course7处理完成!完成时间:2020-04-18 17:54:18
课程:course8处理完成!完成时间:2020-04-18 17:54:20
课程:course9处理完成!完成时间:2020-04-18 17:54:22
课程:course10处理完成!完成时间:2020-04-18 17:54:24
课程:course11处理完成!完成时间:2020-04-18 17:54:26
课程:course12处理完成!完成时间:2020-04-18 17:54:28
课程:course13处理完成!完成时间:2020-04-18 17:54:30
课程:course14处理完成!完成时间:2020-04-18 17:54:32
课程:course15处理完成!完成时间:2020-04-18 17:54:34
课程:course16处理完成!完成时间:2020-04-18 17:54:36
课程:course17处理完成!完成时间:2020-04-18 17:54:38
课程:course18处理完成!完成时间:2020-04-18 17:54:40
课程:course19处理完成!完成时间:2020-04-18 17:54:42
任务耗时:41225

普通单线程的处理方式执行时间是 41225 毫秒 转换成秒就是:41.225秒。

3.解决方案

在解决前首先第一步要做的是定位具体那个方法比较耗时并分析原因。分析可以从以下几个方面入手

1.查询sql 是否过于耗时
2.是否存在临时对象的大量使用或者内存泄漏

如果是查询sql 问题那么需要通过优化sql 来提高查询效率,如果是程序问题应该避免临时对象的大量使用,对于程序中对象已不再使用,但对它的引用还保留着,这些对象就造成了内存泄漏。所以要及时已经清理对象的引用。

处理的第一步通过日志查看每个方法运行时间,然后查看具体sql 执行发现好多查询条件字段并没有索引,所以造成查询时间过长。在对表某些高频率字段添加索引后时间虽然有所提升,但是整体运行时间还是很长。

为了快速提升程序执行效率,我没有看程序代码的问题。而是采用使用多线程的方式解决,就是多个线程同时执行这个统计任务来提升程序的运行时间。

具体处理思路:假设我们有4个需要执行的任务,每个任务执行的时间为1 秒,如果我们单线程执行需要 4秒,如果有2个线程同时执行的话就可以将执行时间缩短为2秒。通俗点讲就是:4件事情交给一个人做需要4秒,那么交给2个人同时处理就需要2秒。具体如下图所示:

在这里插入图片描述

处理方式是通过 Executors.newFixedThreadPool(threadNum)来创建一个固定数量的线程池,将任务遍历通过线程池的线程来执行。

创建一个固定数量为 5 个的线程池,可以理解成任务可以在同一时间处理5个,比单线程的方式可以缩短5倍的时间。

Executors线程池的处理方式代码如下:

    public void executors(){
        long startTime = System.currentTimeMillis();
        //获取数据库中数据
        CourseService courseStatisticsService = new CourseService();
        List<Course> list = courseStatisticsService.findAll();
        //常见固定数量的线程池
        ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5);
        //taskTimes 用于记录执行的次数 当任务执行完毕是统计执行时间
        List<String> taskTimes = new ArrayList();
        //处理任务
        for (Course course : list) {
            fixedThreadPool.execute(() -> {

                courseStatisticsService.getClassStatisticsInfo(course);
                System.out.println("课程:" + course.getName() + "处理完成!完成时间:"+ LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));

                if(taskTimes.size() == list.size()-1){
                    Long endTime = System.currentTimeMillis();
                    System.out.println("任务耗时:"+(endTime - startTime));
                }
                taskTimes.add("1");
            });
        }
        fixedThreadPool.shutdown();
    }

Executors线程池的处理方式日志如下:

课程:course0处理完成!完成时间:2020-04-18 17:55:42
课程:course4处理完成!完成时间:2020-04-18 17:55:42
课程:course3处理完成!完成时间:2020-04-18 17:55:42
课程:course1处理完成!完成时间:2020-04-18 17:55:42
课程:course2处理完成!完成时间:2020-04-18 17:55:42
课程:course5处理完成!完成时间:2020-04-18 17:55:44
课程:course7处理完成!完成时间:2020-04-18 17:55:44
课程:course6处理完成!完成时间:2020-04-18 17:55:44
课程:course9处理完成!完成时间:2020-04-18 17:55:44
课程:course8处理完成!完成时间:2020-04-18 17:55:44
课程:course10处理完成!完成时间:2020-04-18 17:55:46
课程:course13处理完成!完成时间:2020-04-18 17:55:46
课程:course14处理完成!完成时间:2020-04-18 17:55:46
课程:course12处理完成!完成时间:2020-04-18 17:55:46
课程:course11处理完成!完成时间:2020-04-18 17:55:46
课程:course15处理完成!完成时间:2020-04-18 17:55:48
课程:course19处理完成!完成时间:2020-04-18 17:55:48
课程:course17处理完成!完成时间:2020-04-18 17:55:48
课程:course18处理完成!完成时间:2020-04-18 17:55:48
课程:course16处理完成!完成时间:2020-04-18 17:55:48
任务耗时:9183

Executors 线程池的处理方式执行时间是 9183 毫秒 转换成秒就是:9.183秒。虽然没有准确的提升5倍,但是和预期的差不多。

4.解决方案存在问题

翻阅阿里巴巴 Java手册,其中有有一条明确规定线程池不允许使用 Executors 去创建,因为FixedThreadPool 可能导致 OOM,具体内容如下:

【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这
样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 说明:Executors 返回的线程池对象的弊端如下: 1)
FixedThreadPool 和 SingleThreadPool: 允许的请求队列长度为
Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。 2) CachedThreadPool: 允许的创建线程数量为
Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

在使用ThreadPoolExecutor 改造之前先介绍一下 ThreadPoolExecutor 构造方法7个参数:

  • corePoolSize:线程池中的核心线程数
  • maximumPoolSize:线程池中最大线程数
  • keepAliveTime:闲置超时时间
  • unit:和 keepAliveTime 配合使用标明超时时间的单位
  • workQueue:线程池的任务队列
  • threadFactory:创建新线程的线程工厂
  • rejectedExecutionHandler:线程池任务队列超过最大值之后的拒绝策略

线程池任务队列超过最大值之后的拒绝策略如下:

  • new ThreadPoolExecutor.DiscardPolicy():超出的任务直接丢弃,不进行任何处理。
  • new ThreadPoolExecutor.DiscardOldestPolicy():执行最新的任务,丢弃掉未执行的老任务。
  • new ThreadPoolExecutor.AbortPolicy():抛出 RejectedExecutionException
    异常。
  • new ThreadPoolExecutor.CallerRunsPolicy():超出的任务由主线程(调用方)来执行处理。

下面是我通过ThreadPoolExecutor 创建的线程池的代码:

任务队列为15个,线程池任务队列超过最大值之后的拒绝策略采用 new ThreadPoolExecutor.AbortPolicy()。也就是报异常。

    public void threadPoolExecutor() {
        long startTime = System.currentTimeMillis();
        //获取数据库中数据
        CourseService courseStatisticsService = new CourseService();
        List<Course> list = courseStatisticsService.findAll();
        //常见固定数量的线程池 和 Executors.newFixedThreadPool 一样区别是队列数量不一样
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(5, 5,
                0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(15)
                ,Executors.defaultThreadFactory()
                , new ThreadPoolExecutor.AbortPolicy());

        //taskTimes 用于记录执行的次数 当任务执行完毕是统计执行时间
        List<String> taskTimes = new ArrayList();
        for (Course course : list) {
            threadPool.execute(() -> {
                courseStatisticsService.getClassStatisticsInfo(course);
                System.out.println("课程:" + course.getName() + "处理完成!完成时间:" +  LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
                if(taskTimes.size() == list.size()-1){
                    Long endTime = System.currentTimeMillis();
                    System.out.println("任务耗时:"+(endTime - startTime));
                }
                taskTimes.add("1");
            });
        }

        threadPool.shutdown();
    }
课程:course0处理完成!完成时间:2020-04-18 17:56:33
课程:course3处理完成!完成时间:2020-04-18 17:56:33
课程:course4处理完成!完成时间:2020-04-18 17:56:33
课程:course1处理完成!完成时间:2020-04-18 17:56:33
课程:course2处理完成!完成时间:2020-04-18 17:56:33
课程:course9处理完成!完成时间:2020-04-18 17:56:35
课程:course7处理完成!完成时间:2020-04-18 17:56:35
课程:course6处理完成!完成时间:2020-04-18 17:56:35
课程:course5处理完成!完成时间:2020-04-18 17:56:35
课程:course8处理完成!完成时间:2020-04-18 17:56:35
课程:course12处理完成!完成时间:2020-04-18 17:56:37
课程:course11处理完成!完成时间:2020-04-18 17:56:37
课程:course13处理完成!完成时间:2020-04-18 17:56:37
课程:course14处理完成!完成时间:2020-04-18 17:56:37
课程:course10处理完成!完成时间:2020-04-18 17:56:37
课程:course18处理完成!完成时间:2020-04-18 17:56:39
课程:course19处理完成!完成时间:2020-04-18 17:56:39
课程:course15处理完成!完成时间:2020-04-18 17:56:39
课程:course16处理完成!完成时间:2020-04-18 17:56:39
课程:course17处理完成!完成时间:2020-04-18 17:56:39
任务耗时:9158

通过日志我们可以发现ThreadPoolExecutor 和 Executors线程池的处理方式耗时时间差不多。这个也和预期一样。

我将CourseService 中获取所有课程的数据提升到 21 个,因为我们采取的是new ThreadPoolExecutor.AbortPolicy()。所以超出的一个任务不会被处理,并报RejectedExecutionException 异常。

具体代码如下:

 /**
     * 获取所有的课程
     * @return
     */
    public List<Course>  findAll(){
        List list = new ArrayList();
        for (int i = 0; i < 21; i++) {
            list.add(new Course((long) i,"course"+i));
        }
        try {
            Thread.sleep(1000*1L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return list;
    }

执行统计程序会报了一个 RejectedExecutionException异常,并且任务只能处理20个。具体报错日志信息如下:

Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task cn.zhuoqianmingyue.statistics.Application$$Lambda$1/1329552164@10f87f48 rejected from java.util.concurrent.ThreadPoolExecutor@b4c966a[Running, pool size = 5, active threads = 5, queued tasks = 15, completed tasks = 0]
	at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
	at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
	at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)
	at cn.zhuoqianmingyue.statistics.Application.threadPoolExecutor(Application.java:75)
	at cn.zhuoqianmingyue.statistics.Application.main(Application.java:15)
课程:course4处理完成!完成时间:2020-04-18 18:22:49
课程:course0处理完成!完成时间:2020-04-18 18:22:49
课程:course1处理完成!完成时间:2020-04-18 18:22:49
课程:course2处理完成!完成时间:2020-04-18 18:22:49
课程:course3处理完成!完成时间:2020-04-18 18:22:49
课程:course7处理完成!完成时间:2020-04-18 18:22:51
课程:course9处理完成!完成时间:2020-04-18 18:22:51
课程:course8处理完成!完成时间:2020-04-18 18:22:51
课程:course5处理完成!完成时间:2020-04-18 18:22:51
课程:course6处理完成!完成时间:2020-04-18 18:22:51
课程:course12处理完成!完成时间:2020-04-18 18:22:53
课程:course10处理完成!完成时间:2020-04-18 18:22:53
课程:course13处理完成!完成时间:2020-04-18 18:22:53
课程:course11处理完成!完成时间:2020-04-18 18:22:53
课程:course14处理完成!完成时间:2020-04-18 18:22:53
课程:course19处理完成!完成时间:2020-04-18 18:22:55
课程:course18处理完成!完成时间:2020-04-18 18:22:55
课程:course16处理完成!完成时间:2020-04-18 18:22:55
课程:course17处理完成!完成时间:2020-04-18 18:22:55
课程:course15处理完成!完成时间:2020-04-18 18:22:55

我通过Executors 方式处理 21 个课程信息并没有报错,为什么 FixedThreadPool 方式没有报错呢?上面我们说到 阿里Java 手册中说到:
FixedThreadPool 和 SingleThreadPool: 允许的请求队列长度Integer.MAX_VALUE,而我通过 ThreadPoolExecutor 方式创建的队列长度为 15个。

因为我们的核心线程数为5 最大线程数也是5 ,队列为15,所以可以处理的线程数为 20,当我们增加到 21 个后任务队列里面放不下了,就采取报错的拒绝策略。

我通过参看FixedThreadPool 验证了确实和阿里Java 手册所说一样它的任务队列的长度是:Integer.MAX_VALUE

进入newFixedThreadPool 方法如下所示newFixedThreadPool 采用了LinkedBlockingQueue 队列。

    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

在进入 LinkedBlockingQueue 构造方法中我们可以看到其将队列的数量设置为 Integer.MAX_VALUE 个长度。

    public LinkedBlockingQueue() {
        this(Integer.MAX_VALUE);
    }

接下来你可能还有疑问,为什么任务队列超了就要执行拒绝策略。线程池的原理是什么样的? 请您继续往下看。

5.问题引发原理深究

接下来我将通过通俗易懂的方式带你快速带你了解线程池原理。假设一个电影院有 4 个放映室,每个放映室有10个位置,如果同时放映 4 场电影就可以卖 40 张票。同时还有一个备用的放映室(如果人流程大时使用)。

现在外面下雨了,有人想买电影票想在休息室等待看电影同时还能避雨的话怎么处理呢?

商家通过在搞个等待室,一个等待室可以坐10个人。这样的话可以一次卖50张票。40个人看电影,10个在等待室等待下一场电影。

如果一次来了60个人来买票的话可以启动备用放映室,这样一次可以卖60张票,50个人看电影,10个人等待看下一场。

如果同时来70个人的话,那么商家只能跟多出的10个人说:不好意思我们现在电影院的位置都坐满了,等一会您在来吧。

案例中的例子于线程池对应的概念如下:

  • 线程池 == 电影院
  • 放映室 == 核心线程数
  • 备用放映室 + 放映室 == 最大线程数
  • 等待室 == 队列的数量
  • 商家跟多余10个人说等会再来 == 线程池任务队列超过最大值之后的拒绝策略

到这里你是不是有种恍然大明白的感觉,如果你想验证我的内容是否正确,可以具体参读 Executors 和 ThreadPoolExecutor 源码。由于篇幅有限我这里不在过多阐述。

6.小结

通过 Executors 或者 ThreadPoolExecutor 方式来创建线程池来提供任务的执行时间,Executors 相对于 ThreadPoolExecutor 使用方式上简单了许多,那是因为 Executors 是在ThreadPoolExecutor基础之上做了一些封装。

需要注意的是:使用 Executors 方式可能会堆积大量的请求,从而导致 OOM。 如果说你处理的任务量不大,也可以使用Executors 但是还是比较推荐ThreadPoolExecutor的方式。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章