SpringBoot定时任务

2023-12-13 12:10:06
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬

定时任务是实际开发中非常普遍的需求,比如定时统计报表、定时更新用户状态等。如果你使用SpringBoot开发项目,那么只需加上@EnableScheduling + @Scheduled两个注解即可启用定时任务。

但是SpringBoot提供的定时任务也存在一些小小的坑以及诸多不足,今天我们一起来了解它。

为了避免大家觉得我偷懒,先放几篇上来。其实这些应该安排在另一些文章后,不然一部分读者看起来会有点懵。到时都放上来了我再微调一下。

定时任务示例

/**
 * @author mx
 */
@Slf4j
@Component
@EnableScheduling
public class TaskOne {

    /**
     * 每隔10秒执行test1()
     */
    @Scheduled(cron = "*/10 * * * * ?")
    public void test1() {
        log.info("=========test1任务启动============");
        try {
            Thread.sleep(7 * 1000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("=========test1任务结束============");
    }

}

test1()每隔10秒启动,所以启动时间是13:31:10、13:31:20、13:31:30。由于内部调用Sleep睡眠了7秒,所以每次任务都是在启动7秒后结束。

另外,@EnableScheduling放在Application启动类上也可以,方便整体把控,有可能随着项目的开发,会出现多个定时任务类。

@Scheduled的几个属性

@Schedule提供了多个属性供我们使用,不同的属性有不同的功效。

fixedDelay:距离上一次结束的时间

第一个任务从48秒开始,执行任务耗时2秒,在50秒时结束第,第二个任务在第一个任务结束后5秒后开始。

所以,fixDelay=5*1000L 的含义是上一个任务结束5秒后开始下一个任务。

受网络影响,每次任务执行的时间可能会有变化,但不管怎样,下一个任务会在上一个任务结束5秒后开始。

fixedRate:距离上一次开始的时间

第一个任务从45秒开始,执行任务耗时2秒,在50秒时结束第,第二个任务在第一个任务结束3秒后就开始了。

所以,fixRate=5*1000L 的含义是上一个任务开始5秒后开始下一个任务。

不论上一个任务何时结束,下一个任务会在上一个任务开始5秒后开始。

通过上面这幅图,我们很容易想到里面的长方形很有可能超出外面的虚线框,即任务耗时比设定fixRate时间长。比如,即使上一个任务耗时6秒,那么按理来说下一个任务应该还是要在第5秒开始。

然而实际情况是,由于上一个任务执行了6秒才结束,导致下一个任务多等待了1秒。

不是说在fixedRate设定下,下一个任务会在上一个任务开始后固定时间启动吗?

其实并不是fixRate的问题,而是因为SpringBoot的定时任务默认是单线程的(从截图可以看出始终只有pool-2-thread-1在执行当前定时任务)。

好了,我们先停下来梳理一下:

  • fixDelay:用于指定上一个任务结束下一个任务开始的时间间隔

  • fixRate:用于指定上一个任务开始下一个任务开始的时间间隔

对于fixDelay,任务实际的执行耗时不会影响整个流程,它只保证两个任务首尾的时间间隔。

对于fixRate,它预期每隔多久就启动一个新任务,不论上一个任务执行结束与否。

但很遗憾,SpringBoot默认定时任务是单线程的,如果任务执行时间较短,那么fixRate可以保证每个任务开始时间的间隔稳定性,但如果上一个任务耗时异常,那么下一个任务会被往后顶

解决办法:为定时任务指定线程池,每个任务都跑在独立的线程中,不存在谁把谁往后顶的情况。

关于如何将SpringBoot的定时任务配置为多线程模式,我们会在后面介绍。

initialDelay:启动多少秒后开始首次任务

不论fixDelay还是fixRate,默认都是项目启动就立即执行,随后按照指定间隔时间重复。

如果希望推迟首次执行时间,可以用initialDelay指定。

项目在15:26:03启动完毕,而定时任务由于设定了initialDelay=20*1000L,初次启动往后延迟20秒。

cron:定时执行(最常用)

cron是实际工作中最常用的,什么fixDelay和fixRate往往用的很少。

指定cron表达式,一般只要写6位即可,分别代表@Schedule(cron = "秒 分 时 日 月 星期 [年]"),第7位[年]可以不写。

表达式的书写规则大家去在线Cron表达式生成器玩一下就知道了。个人觉得最实用的就是记住以下两点:

  • */number表示“每隔...”,是最实用的
  • 逗号表示“或”,比如 8,13,18 表示 8或13或18

比如,在[秒]的位置写上 */10,表示每隔10秒执行一次。

在[分]的位置写上 */10,表示每隔10分钟执行一次。

此时记得把[秒]位置的设置为0,表示“每隔10分钟,且整分才执行”,你如果设定为*,而*表示任意,含义就变成“每隔10分钟,且任意秒都执行”。

为了验证这个解释,我特意把[秒]改为5,意思就是“每隔10分钟,且秒数为5才执行”:

接下来,举个日常最普遍的例子,在[时]的位置设置 0,8,18 表示:每天0点、8点、18点更新

一个线上的坑

我相信,很多人实际开发都写过下面这样的定时任务,它们原本的设定都是在同一个时刻开始的。

@Slf4j
@Component
@EnableScheduling
public class TaskOne {

    @Scheduled(cron = "*/10 * * * * ?")
    public void test1() {
        log.info("=========test1任务启动============");
        try {
            Thread.sleep(2 * 1000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("=========test1任务结束============");
    }

    @Scheduled(cron = "*/10 * * * * ?")
    public void test2() {
        log.info("=========test2任务启动============");
        try {
            // 模拟远程服务卡死
            Thread.sleep(1000 * 1000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("=========test2任务结束============");
    }

    @Scheduled(cron = "*/10 * * * * ?")
    public void test3() {
        log.info("=========test3任务启动============");
        try {
            Thread.sleep(2 * 1000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("=========test3任务结束============");
    }

}

一般情况下是没有问题的,甚至你根本没意识到它们其实不是同时开始的~因为你可能都不知道SpringBoot定时任务默认单线程。但由于每个任务执行时间短一般需求对时间准确度要求并不特别严格,串行执行慢个2~3秒都可以接受。

但某次项目发布后,同事告诉我他的定时任务不跑了。我看了半天,才发现他没有配置线程池,而且又因为引入了Redis分布式锁,不小心发生了死锁卡在那了,最终导致其他任务都没法启动(串行化)。

配置线程池

/**
 * 线程池配置
 * @author mx
 *
 */
@EnableAsync // 来了,这里挖了一个坑
@Configuration
public class ThreadPoolTaskConfig implements WebMvcConfigurer {

    @Bean("taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(3);
        executor.setQueueCapacity(5);
        executor.setKeepAliveSeconds(10);
        executor.setThreadNamePrefix("async-task-");

        // 线程池对拒绝任务的处理策略
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        // 初始化
        executor.initialize();
        return executor;
    }

}

这里只设定了3个线程数(配置可以抽取到yml)。

然后给每个定时任务的方法上加上@Async("taskExecutor")即可。

由于项目中可能配置多个线程池,所以个人建议创建线程池时最好指定有意义的名字(比如async-task-),不然线上日志就炸了,根本不知道这个线程是干啥的。另外,使用@Async时最好明确使用哪个线程池,比如@Async("taskExecutor"),因为项目中的线程池一般都是“专池专用”,这是一个好习惯。

这样就没问题了吗?

看起来还行...任务2卡在那并不影响其他两个任务执行。

但我们把时间拉长,就会发现最终线程池的全部3个线程都卡在任务2上了:

因为每到时间点,都需要去执行3个任务。而线程池总共就3个线程,其中一个是大坑,3个线程随机分配给每个任务,最终3个线程都会掉坑里出不来。

如何保证定时任务可用

所以啊,对于定时任务异常,光靠配置定时任务线程池还是不行的,最终线程池仍会枯竭,导致所有定时任务阻塞。除非你每个定时任务都专门配一个线程池...

所以发生定时任务耗时异常这种情况,最重要的是及时发现并修复。

在配置线程池时,我们可以指定拒绝策略(线程池队列满了之后触发),SpringBoot默认提供了4种:

  • CallerRunsPolicy:不使用异步线程,直接主线程执行
  • AbortPolicy:丢弃当前任务,直接抛异常
  • DiscardPolicy:丢弃当前任务,无声无息(不抛异常)
  • DiscardOldestPolicy:丢弃队列里最老的任务,执行当前任务

这4种拒绝策略被定义在ThreadPoolExecutor类的内部,是静态内部类。

在前面几篇文章中,大家会发现我经常是这样写的:

但实际开发建议自定义拒绝策略。为什么呢?

实际发现线程池不够用了,你直接跑主线程吗?还记得Tomcat被卡爆的案例吗?直接丢弃?你确定对业务没影响吗?如果业务本身不在乎请求失败,那是没关系的,否则丢弃策略就不合适了。

一般可以选择在丢弃策略里使用MQ(延后缓冲)或者发邮件告警(及时发现),只要实现RejectedExecutionHandler接口即可:

/**
 * 线程池配置
 * @author mx
 *
 */
@Slf4j
@EnableAsync // 第二次提示这个坑
@Configuration
public class ThreadPoolTaskConfig implements WebMvcConfigurer {

    @Bean("taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(3);
        executor.setQueueCapacity(5);
        executor.setKeepAliveSeconds(10);
        executor.setThreadNamePrefix("async-task-");

        // 线程池对拒绝任务的处理策略
        executor.setRejectedExecutionHandler(new RejectedExecutionHandler(){
            /**
             * 自定义线程池拒绝策略(模拟发送告警邮件)
             */
            @Override
            public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                log.info("发送告警邮件======>:嘿沙雕,线上定时任务卡爆了, 当前线程名称为:{}, 当前线程池队列长度为:{}",
                        r.toString(),
                        executor.getQueue().size());
            }
        });
        // 初始化
        executor.initialize();
        return executor;
    }

}

好了,这样我们就具备完善的报警机制,可以及时发现线上的问题。

而我的同事最终也发现了线上的bug:

由于项目是多节点部署,为了不重复执行,他引入了Redis分布式锁,不知道为什么产生了死锁。

同事引入分布式锁的目的是保证某一时刻不同节点的定时任务不会重复执行(谁获取锁谁执行)。

SpringBoot定时任务的不足

其实,SpringBoot的定时任务是很鸡肋的:

  • 不支持集群时单节点启动(同事使用Redis分布式锁就是为了解决避免多节点重复执行)
  • 不支持分片任务
  • 不支持失败重试(一个任务失败了就失败了,不会重试)
  • 动态调整比较繁琐(我曾经做过一个项目,要求前端页面可以动态配置任务启动的时间点)
  • ...

所以,对于多节点部署或者分布式项目,还是乖乖用Elastic-Job或者XXL-Job吧。

看到这,似乎很完美。其实我配错了。上面线程池的配置严格意义上来说不是定时任务线程池,而是异步线程池。定时任务理论上只要加@Scheduled,@Async是异步线程相关的。

不要觉得我在钻牛角尖,我以前犯的错,大部分人也会犯。我想说,线程池本身体系蛮复杂的,很多人其实都分不清哪个是哪个,所以我想借这个错误让大家正视线程池。

正确的配置方法请看下一篇@Scheduled源码解析。

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

进群,大家一起学习,一起进步,一起对抗互联网寒冬

文章来源:https://blog.csdn.net/smart_an/article/details/134965374
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。