Skip to main content

Quartz 教程

1 Quartz简介

1.1 什么是Quartz

我们在项目中经常会需要执行一些定时任务,例如定时进行数据备份和清理;定时检查创建的订单有没有支付,没有支付超过30分钟就将其取消,这些都会用到定时任务。

Quartz 就是一个开源的、用于 Java 编程语言的任务调度框架。它的主要作用是允许开发者根据预定的时间执行特定的任务。这些任务可以是一次性的,也可以是周期性的,Quartz 可以有效地管理这些任务的执行。

1.2 Quartz中的核心组件

在动手编码之前,先了解一下 Quartz 中的核心组件。

  • 任务 Job。也就是任务,需要被定时执行的任务
  • 触发器 Trigger。定义了 Job 的执行时间规则。它确定了 Job 何时被执行。Quartz 提供了不同类型的 Trigger,允许根据不同的时间规则安排 Job 的执行。
  • 调度器 Scheduler。Scheduler 是 Quartz 的核心,负责管理 Job 和 Trigger,控制它们的执行。它可以启动、暂停、停止和重新启动任务的执行,并管理它们的生命周期。
  • 任务存储 JobStore。JobStore 负责存储 Quartz 的状态信息,包括 Job、Trigger、调度信息等。它存储了所有被调度的任务和相关的配置信息,确保即使在重启后也能恢复调度器的状态。

架构图如下:

架构图

2. HelloWorld

下面先来实现一个简单的定时任务,让大家了解一下。

  1. 新建项目

在 IDEA 中先创建一个项目

  • 在使用的时候,通过 JobBuilder 构建 JobDetail,JobDetail 对 Job 进行了包装,添加了更多的信息描述。
  • 通过 TriggerBuilder 创建 Trigger,触发器一般有4种,最常用的就是 CronTrigger 了。多个触发器是可以执行同一个任务的。
  • 通过 SchedulerFactory 创建 Scheduler,使用 Scheduler 来管理任务信息和触发器;
  • 监听器有三大类,可以分别用来监听任务信息、触发器和调度器的执行情况、生命周期等信息。
  • JobStore 部分负责 Quartz 信息的存储,默认是使用 RAMJobStore 存储在内存中的,重启后,信息会丢失;JDBCJobStore 是数据库存储,在集群的时候,就需要用到数据库存储,各个节点共享数据库的信息。

如果不引入 slf4j-simple,quartz 的日志是打印不出来的,但是要注意版本问题,因为 quartz 中的 slf4j-api 的版本是 1.7.x 的,这里我使用 2.x.x 版本的 slf4j-simple 因为版本不兼容,也无法打印。

如果使用 logback,引入 logback-classic,也存在版本兼容问题,使用1.4.x 和 1.3.x都不行,需要使用 1.2.x 的 logback-classic 。

当然也可以排除 quartz 中的 slf4j-api ,重新引入新版本的 slf4j-api 。

1. 创建 Job 类

创建一个 Job 类,名称自定义,需要实现 Job 接口,并实现 execute 方法。

2. 编写定时任务

需要创建任务详情(JobDetail)、触发器(Trigger)、调度器(Scheduler)。

任务详情就是 JobDetail,这个 JobDetail 其实是对 Job 的包装和描述,每次执行任务,调度器就会根据 JobDetail 的描述信息通过反射创建一个新的 Job 实例,这样可以规避并发访问的问题。而且这样设计,可以让 Job 更加专注于业务逻辑。

我们创建一个测试类,在测试类中编写:

TestClass.java

JobDetail 通过 JobBuilder 创建。Trigger 通过 TriggerBuilder 创建。在触发器上设置触发的时机,例如频率和次数等,然后将触发器和任务交给调度器来管理。

3. 传递参数

如果我们想在创建任务和触发器的时候,给任务传递一些参数,在执行任务的时候,可以读取到这些参数,那么该如何处理呢?这里就需要用到 JobDataMap 。

3.1 传递参数

我们可以在创建 Job 或 Trigger 的时候,设置参数。

举个栗子:

在创建 Job 或 Trigger 的时候,都可以通过 JobDataMap 以 key-value 的形式设置参数,这是因为 JobDataMap 实现了 JDK 中的 Map 接口。下面就可以来获取上面传递的参数了。

3.2 接收参数

接收参数有两种方式,一种是在 Job 中获取到 JobDataMap 对象,通过 JobDataMap 获取参数。一种是通过 Job 中的属性来接收参数。

  1. 通过 JobDataMap 获取参数 在 Job 类中的 execute 方法中,通过 JobExecutionContext 对象可以获取到 JobDetail 和 Trigger,然后获取到 JobDataMap。JobExecutionContext 对象是任务执行的上下文对象,可以获取到全局的信息。可以通过 JobExecutionContext 对象获取到 JobDetail 和 Trigger 的信息,然后获取到 JobDataMap 的信息。通过 getMergedJobDataMap() 可以获取 JobDetail 和 Trigger 传递的参数合并后的结果,如果传递参数的时候 JobDetail 和 Trigger 传递了相同的 key 值,那么 Trigger 中传递的参数会覆盖 JobDetail 中的参数

ParamTest.java

  1. 通过属性获取参数

首先在 Job 中定义与传递数据的时候 key 相同属性和 set 方法。这样在创建 job 的时候,会通过 setter 将参数设置进来。

SetterTest.java

4. Job

Quartz 每次在执行任务的时候,都会创建新的 Job 对象和 JobDetail 对象。修改 Job 对象,添加打印 job 和 jobDetail 对象信息,如下:

[DefaultQuartzScheduler_QuartzSchedulerThread] ERROR org.quartz.core.ErrorLogger - An error occured instantiating job to be executed. > job= 'jGroup1.job1'
org.quartz.SchedulerException: Problem instantiating class 'org.ma5d.job.QuartzJUCJob$MyJobJUC' [See nested exception: java.lang.? InstantiationException: org.ma5d.job.QuartzJUCJob$MyJobJUC]
Caused by: java.lang.InstantiationException: org.ma5d.job.QuartzJUCJob$MyJobJUC
Caused by: java.lang.NoSuchMethodException: org.ma5d.job.QuartzJUCJob$MyJobJUC.<init>()

非静态内部类确实会隐式地包含对其外部类的引用,这是因为非静态内部类的实例需要与其外部类的实例关联。因此,当你创建一个非静态内部类的对象时,需要提供一个外部类的实例,以确保内部类能够访问外部类的成员。

Caused by: java.lang.IllegalAccessException: class org.quartz.simpl.SimpleJobFactory cannot access a member of class org.ma5d.job.QuartzJUCJob$MyJobJUC with modifiers ""

修改为 public 即可

InstanceTest.java

每次打印日志的 Job 和 JobDetail 对象都是不一样的,也就是每次执行任务都会创建新的 Job 和 JobDetail 对象。

为什么要每次创建一个新的对象呢,因为可能任务执行比较久,一次任务没执行完,下一次任务就开始执行的问题,如果使用的是同一个对象,就可能存在并发问题。

4.1 禁止并发

但是如果不想要并发执行,而是想要上一次任务执行完成才可以执行下一次的任务,变成串行执行,该如何处理呢?可以为 Job 类添加 @DisallowConcurrentExecution 注解来禁止并发执行同一个 Job定义(JobDetail定义的)的多个实例。

举个栗子:给 Job 类添加 @DisallowConcurrentExecution 注解,我们使用的还是之前使用的触发器,每1秒执行一次,但是我们的任务每次执行需要花费3秒执行。

ThreeTest.java

如果没有 @DisallowConcurrentExecution 注解,虽然任务没有执行完成,但是每过1秒都会执行一次新的任务,导致任务会一直累积。而添加了 @DisallowConcurrentExecution 注解,上一次执行完成,才会执行下一次任务。可以看到每隔三秒执行一次,不会并发执行,而是变成了串行执行。

4.2 Job的状态

每次执行任务都是新的 JobDetail 和 Job 实例,那么在传递数据的时候,每次的 JobDataMap 都是相同的数据。这个就是无状态的 Job。即使我们在 Job 类中,修改了 JobDataMap 中的数据,也不会对下一次的执行产生影响。如果想要用一个计数器记录执行的次数,即我们想要 JobDataMap 的数据能一直保存下来应该如何设置呢?这个就需要有状态的 Job 了。

这个使用static field 不就行了。

举个栗子:使用 count 记录任务执行的次数,首先在 JobDetail 中初始化 count 的值。

// 1.定义jobDetail
@PersistJobDataAfterExecution

JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
jobDataMap.put("count", count);

5. 触发器

触发器(Triggers)用于定义何时和如何执行与 Job 关联的任务。它们决定了任务的调度规则,比如何时执行、执行频率以及在何种条件下触发执行。我们在前面使用的触发器是SimpleTrigger, SimpleTrigger 只能定义按照指定频率执行的任务,如果要定义复杂的执行规则,SimpleTrigger 是无法支持的,一般我们用的最多的就是 CronTrigger 。

5.1 CronTrigger

CronTrigger 是 Quartz 中用于基于 Cron 表达式定义任务执行时间表的触发器类型。它允许用户按照日历时间来定义任务的执行规则,非常灵活且支持各种复杂的调度需求。

  1. CronTrigger演示

先举个栗子:任务还是之前的任务类:

TriggerTest.java

上面的代码和之前的 HelloWorld 基本是一样的,只是使用了不同的触发器。上面定义了 CronTrigger 触发器,使用 Cron 表达式 */2 * * * * ? 来定义执行规则,并时候用 startAt 推迟了10秒执行。这个 Cron 表达式看上去有点懵逼,如果不会写表达式,可以百度一下 Cron 表达式在线工具,有很多网站可以编写 Cron 表达式。下面简单介绍一下 Cron 表达式 。

  1. Cron 表达式基础

crontab

秒 分 时 日 月 周 年(可选)
  1. Cron 表达式示例
# 每天下午 1 点执行任务:
0 0 13 * * ?

# 每隔5分钟执行一次:
0 */5 * * * ?

# 周一到周五上午 9 点到下午 5 点,每隔半小时执行一次:
0 */30 9-17 * * MON-FRI

# 在每月的最后一天,上午10点执行任务:
0 0 10 L * ?

5.2 MisFire策略

在 Quartz 中,Misfire 指的是触发器错过了预定的触发时间。例如下面一些情况可能会导致任务错过执行:

  • 当前没有空闲的线程池资源可用调度器暂停;系统宕机;
  • 使用了 @DisallowConcurrentExecution 注解,要执行下一次任务了,但是上一次任务还没有执行完成;
  • 指定了过期开始的执行时间,现在是08:00:00,指定开始执行时间为06:00:00;

对于 CronTrigger 是否构成 misfire,有两个条件:

  • job 到达触发时间时没有被执行;
  • job 延迟执行的时间超过了Quartz 配置的 misfireThreshold 阈值。如果延迟执行的时间小于阈值,则 Quartz 不认为发生了misfire,会立即执行 job;如果延迟执行的时间大于或者等于阈值,则被判断为misfire,然后会按照指定的策略来执行。如果没有配置 Quartz 的 misfireThreshold,默认配置为60秒。

Quartz 针对 CronTrigger 错过触发提供了不同的 Misfire 策略,确保作业能够尽可能地被执行。Misfire策略由 withMisfireHandlingInstructionXXX()方法来设置,其中 XXX 可以是IgnoreMisfires()、DoNothing()、FireAndProceed(),下面讲解一下。

  1. withMisfireHandlingInstructionDoNothing():所有错过的触发都被忽略,并按照原计划执行任务,也就是错过的就错过了,等待下一次执行时间到了再执行。

  2. withMisfireHandlingInstructionFireAndProceed():错过了很多次,但是只会立即执行一次,然后按照原计划执行任务。这个是默认的策略!

  3. withMisfireHandlingInstructionIgnoreMisfires():立即执行所有错过的触发,错过了100个,一下子把错过的100个全部执行了,然后按照原计划执行任务。

在实际使用中,根据您的需求选择合适的Misfire策略非常重要,这可以确保即使在意外情况下,任务也能够按照期望的方式执行。


因为现在还没有将 Quartz 的数据持久化到数据库,所以现在使用策略会发现没有效果,继续向后学习,在后面讲到将 Quartz 的数据持久化到数据库后,然后停掉项目,重新启动后,Quart 会重新启动运行,并使用不同的策略恢复任务,就可以看到效果了,例如配置为withMisfireHandlingInstructionIgnoreMisfires(),你会发现项目重新启动后,可能会出现立刻执行了很多次任务,因为这些任务是停掉项目后错过的。

6. 调度器

6.1 Calendar例外

如果我们想在某些时候不执行任务,例如有一个任务,一年每天都会执行,但是想在节假日不执行,该如何做呢?这个时候,就可以使用例外规则的调度器。在 Quartz 中可以使用 Calendar 来定义例外规则。举个栗子:org.quartz.Calendar 在Quartz 中是一个接口,它有很多的实现类,我们这里演示一下,在指定的日期不执行任务。

主要有三个步骤:

  1. 定义例外

  1. 将例外添加到调度器中

  • 第三个参数表示已存在具有相同名称的 Calendar,将其替换为新的 Calendar。如果设置为 false,如果已存在具有相同名称的 Calendar,则不会进行替换,而是保留现有的 Calendar。
  • 第四个参数如果设置为 true,则更新与 Calendar 相关联的触发器(如果有的话)。如果设置为 false,则不会更新与 Calendar 相关联的任何触发器。
  1. 完整代码

上面的 AnnualCalendar 是 org.quartz.Calendar 接口的一个实现,org.quartz.Calendar 接口定义了日历的抽象,而具体的日历实现则有多种方式,其中包括一些内置的和自定义的实现。

一些常见的 Quartz 内置日历实现包括:

  • AnnualCalendar(年度日历): 允许你指定每年的特定日期,标记为不执行的日期,比如节假日等。
  • CronCalendar(Cron日历): 根据 Cron 表达式指定一组不执行的日期。
  • DailyCalendar(每日日历): 允许你指定每天的特定时间段,任务只能在该时间段内触发。
  • HolidayCalendar(节假日日历): 类似 AnnualCalendar,允许你指定一年中不执行任务的特定日期。
  • MonthlyCalendar(每月日历): 允许你指定每月的特定日期,标记为不执行的日期。

这些是 Quartz 中常见的内置日历实现,每种日历都有其特定的用途和功能。除了这些内置的日历实现外,你还可以实现自定义的 org.quartz.Calendar 接口,以满足你特定的业务需求。通过实现这个接口,你可以定义自己的日历逻辑,用于控制任务的执行时间。

7 SpringBoot集成Quartz

上面使用的是原生的方法演示 Quartz 的时候,但是在实际的使用中,肯定不会像前面那样使用,一般会在 Spring 项目中或 SpringBoot 项目中进行使用,下面演示一下在 SpringBoot 项目中使用 Quartz。

  1. 新建SpringBoot项目: 新建 SpringBoot 项目这里就不介绍了。可能你的项目已经是 SpringBoot 的项目了。

  2. 添加依赖: 在项目的 pom.xml 中添加依赖


直接使用 starter 的方式集成。

  1. 创建Job

新建一个 MyJob,和之前类似,不过在 SpringBoot 中,可以继承QuartzJobBean。


继承 QuartzJobBean 和 实现 Job 接口使用上没什么区别。在实际使用中,我们可以在 Job 类中注入我们业务 Service 的依赖,这样就可以调用 Service 中的方法,执行业务逻辑了。

  1. 注册Job和Trigger

首先创建一个 Quartz 的配置类,在这个配置类中,创建 JobDetail 和 Trigger,并将他们添加到Spring 容器中。


8. 持久化与集群

9. 动态调度