任务调度

Spring框架内本身包含了一个任务调度模块(Task Execution and Scheduling),这个模块主要支撑两方面功能:定义声明式的定时任务和异步任务。之前的Event事件机制章节中我们就曾涉及过异步事件处理的写法,它其实就用到了异步任务功能,这篇笔记我们详细学习一下任务调度模块的用法。

开启任务调度模块注解扫描支持

实际开发中,异步任务和定时任务通常采用@Scheduled@Async注解方式声明,对于传统Spring工程,开启任务调度模块的注解扫描需要在XML配置中添加<task:annotation-driven />标签。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:task="http://www.springframework.org/schema/task"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
       http://www.springframework.org/schema/task http://www.springframework.org/schema/task/spring-task.xsd">
    <context:component-scan base-package="com.gacfox.demo"/>
    <task:annotation-driven />
</beans>

对于SpringBoot则比较简单,我们只需要在启动类上添加@EnableScheduling@EnableAsync就行了。

定时任务

实际开发中,我们可能经常遇到需要定时处理的业务逻辑,比如定时刷新缓存、定时发送邮件等,Spring提供了@Scheduled注解实现了一套简单易用的定时任务实现。

编写定时任务

下面代码创建了一个最简单的定时任务,运行项目后,每隔1秒会在控制台打印一个包含线程ID的字符串。

package com.gacfox.demo.schedule;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class TestSchedule {
    @Scheduled(fixedDelay = 1000L)
    public void test() {
        System.out.println("Scheduled on thread [" + Thread.currentThread().getId() + "]");
    }
}

输出例子如下。

Scheduled on thread [16]
Scheduled on thread [16]
Scheduled on thread [16]
Scheduled on thread [16]
Scheduled on thread [16]

创建定时任务很简单,在SpringBean上使用@Scheduled注解就可以实现。上面代码不一定非要放在Web容器中,只要我们的JVM进程没有退出,Spring就会分派线程执行定时任务。

指定时间间隔

指定时间间隔其实有两种方式,@Scheduled注解支持以下属性:

  • fixedDelay上一次执行完成后,延迟若干毫秒执行(比较常用)
  • fixedRate从上一次任务开始时间计时,定时若干毫秒执行

使用cron表达式

除了指定定时执行的间隔,更为复杂的场景我们可以使用cron表达式。下面例子中,我们设置每分钟从0秒开始每2秒执行一次。

@Scheduled(cron = "0/2 * * * * *")

要注意一点的是这里使用的cron表达式和Linux的crontab规则有些不同,Linux的crontab更像是一个简化版,最明显的区别是不支持秒级控制,如果把这里的cron表达式写进Linux的crontab会报语法错误,这里不要搞混了。

调度器设置

在Spring定时任务中,Scheduler(调度器)是负责安排和触发定时任务的核心组件。它本质上是一个线程池加定时触发器的组合,线程池负责执行任务,触发器(Trigger)定义任务的执行规则,比如前面我们介绍过的fixedRatefixedDelaycron。但这里要注意的是Spring的默认Scheduler是单线程Scheduler,这意味着如果我们有多个定时任务,它们之间会互相阻塞。以下代码展示了这种情况。

package com.gacfox.demo.schedule;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class TestSchedule {
    @Scheduled(fixedDelay = 1000L)
    public void taskA() {
        System.out.println("Scheduled taskA on thread [" + Thread.currentThread().getId() + "]");
    }

    @Scheduled(fixedDelay = 1000L)
    public void taskB() {
        try {
            Thread.sleep(5000L);
            System.out.println("Scheduled taskB on thread [" + Thread.currentThread().getId() + "]");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

实际运行后我们发现,两个任务其实是在同一线程中串行执行的,也就是说,尽管我们可能希望表达的是taskA是每秒执行一次,但是它被taskB阻塞了,不可能每秒执行。此时我们手动指定一个调度器,它包含一个大小为5的线程池。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:task="http://www.springframework.org/schema/task"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
       http://www.springframework.org/schema/task http://www.springframework.org/schema/task/spring-task.xsd">
    <context:component-scan base-package="com.gacfox.demo"/>
    <task:annotation-driven />
    <task:scheduler id="taskScheduler" pool-size="5"/>
</beans>

现在再次运行,就会发现taskA不会再被taskB阻塞了,两个任务会在线程池上调度,而非之前的单线程调度。

定时任务同类技术比较

除了Spring自带的定时任务功能,Quartz是另一个定时任务调度框架。相对而言@Scheduled的优势是使用非常简单,Spring项目中直接就可以使用,缺点是缺乏对分布式的支持,如果是分布式的项目依赖大量定时任务调度还是应该考虑用Quartz这类调度框架,这部分内容可以参考Quartz相关章节。

除了定时任务调度框架,对于类似“X小时后关闭订单”或是“调用失败后每隔X分钟重新调用”等类似需求,我们应该优先考虑是否可以用消息队列中的“延迟队列”功能代替。过多的使用定时任务会产生复杂的任务依赖问题,在这类功能中设置过多定时任务不是最佳实践,通常来说,一个功能需要的定时任务越多,系统稳定性就越差!

常见问题:分布式锁

如果我们的应用是多实例部署的,就不得不考虑分布式锁的问题。比如,我们的程序要定时给相关人员发送一封邮件,而应用节点部署了3个实例,如果没有分布式锁的考虑,这三个实例可能会造成在规定时间内,发出三封邮件,这显然是不行的。分布式锁有很多种实现方式,基于MySQL、Redis、Zookeeper等均能实现,具体可以参考相关笔记章节,这里就不重复介绍了。

常见问题:任务重复执行

对于传统Spring工程,我们知道,SpringMVC会初始化一个Web子容器,它和Spring的配置文件也通常是分开的,子容器中一般只定义和Web相关的内容,如ViewResolver等。如果将定时任务Bean在两个容器中重复加载,重复执行问题就出现了,这会造成所有Bean在Spring容器和SpringMVC容器中各初始化了一份。大多数情况即使这么配置程序也能恰好正常工作,但我们定时任务的Bean如果初始化两次,定时任务也会连续运行两次,一些诡异的系统问题就可能源于此。

异步任务

软件程序中,当用户触发一个耗时操作时,让用户一直等着显然不是一个友好的做法,更好的方式是将任务调度到其它后台线程异步执行,并立刻告知用户任务已经“安排上”了,现在可以继续进行其它操作。虽然我们也能手动用JDK的线程池实现类似的逻辑,但Spring已经直接通过AOP机制和声明式注解支持异步任务调度了。

编写异步任务

编写异步任务非常简单,在方法上添加@Async即可。

package com.gacfox.demo.service;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service("demoService")
public class DemoService {
    @Async
    public void demoTask() {
        try {
            System.out.println("Async demoTask on thread [" + Thread.currentThread().getId() + "]");
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

当其它地方多次调用demoTask时,我们可以观察到调用立刻返回了,耗时操作是在其它线程执行的,可能的输出例子如下。

Async demoTask on thread [34]
Async demoTask on thread [34]
Async demoTask on thread [53]
Async demoTask on thread [52]
Async demoTask on thread [54]

设置异步执行器

和调度器之于定时任务类似,异步任务调度的核心是Executor(执行器)。Spring会默认注册一个执行器SimpleAsyncTaskExecutor,但它并不是一个真正的线程池,而是每次调度都创建新的线程。如果我们的调度场景比较简单使用它是没太大问题的,但如果是高并发场景,我们通常需要创建自己的线程池执行器。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:task="http://www.springframework.org/schema/task"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
       http://www.springframework.org/schema/task http://www.springframework.org/schema/task/spring-task.xsd">
    <context:component-scan base-package="com.gacfox.demo"/>
    <task:annotation-driven executor="appTaskExecutor"/>
    <bean id="appTaskExecutor" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
        <property name="corePoolSize" value="5"/>
        <property name="maxPoolSize" value="10"/>
        <property name="queueCapacity" value="100"/>
        <property name="threadNamePrefix" value="AsyncTask-"/>
        <property name="rejectedExecutionHandler">
            <bean class="java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy"/>
        </property>
    </bean>
</beans>

我们手动创建的执行器需要基于Spring的ThreadPoolTaskExecutor类来声明,它其实是java.util.concurrent.ThreadPoolExecutor的封装,如果你对JDK的并发编程比较熟悉应该很容易理解它的配置策略。上面配置中用到的几个核心参数如下:

  • corePoolSize:线程池中常驻线程的数量,即最少线程数
  • maxPoolSize:最大线程数
  • queueCapacity:线程池用于存放等待执行任务的队列容量
  • threadNamePrefix:线程池创建的线程名称前缀
  • rejectedExecutionHandler:拒绝策略,我们这里选择的CallerRunsPolicy表示让调用任务的线程自己执行这个任务而不是抛弃或抛出异常,这可能让系统性能下降(因为阻塞了提交线程),但优点是能避免任务丢失。除此之外也可以选择AbortPolicy直接抛异常、DiscardPolicy直接丢弃任务和DiscardOldestPolicy丢弃最旧的任务再尝试提交新任务,实际开发中我们应根据实际情况选择合适的拒绝策略

此时我们再次执行之前的代码,就会观察到异步任务实际上是在线程池上调度执行了。

一种不推荐的迷惑写法

实际开发中,初学者或是一些水平参差不齐的团队经常会写出下面这种迷惑性写法,将@Async@Scheduled同时标注在了定时任务方法上。

/**
 * ❌ 错误写法演示
 */
@Async
@Scheduled(fixedDelay = 1000L)
public void taskA() { ... }

首先得说,它确实能实现“异步”执行定时任务,但同样明确的一点是这段代码的作者肯定是把Scheduler和Executor搞混淆了。本质上,@Scheduled方法是由Spring的TaskScheduler定时触发执行的,这个调度器本身可以多线程;而@Async则是Spring提供的异步执行机制,它基于Executor。而写出@Async + @Scheduled从代码表达的语义上就很迷惑,定时任务本身归Scheduler调度,现在又加了个Executor,到底谁负责调度?此外,在这种写法下,fixedDelay的行为可能也不是我们想要的,fixedDelay原本表达的是上次执行完后延迟若干秒执行,而加了@Async后Scheduler只负责将任务提交给Executor,然后就“以为”任务执行完了,fixedDelay直接退化成了fixedRate,完全失去了延迟控制的作用,这显然是不对的。如果你确实是要用定时任务调用一个异步方法,那你应该把@Async加到被调用的Service方法上去,而不是混写在定时任务方法里。

总而言之,不要把@Async@Scheduled写在同一个方法上!

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。