Quartz 入门

Job、Trigger、Scheduler 与 Cron 表达式

Posted by Ekko on September 18, 2020

这篇笔记用于梳理 Quartz 的核心模型,包括 JobJobDetailTriggerSchedulerJobDataMap 以及 CronTrigger 的基本用法。重点不是展示一个最短可运行 Demo,而是把调度框架内部各个角色的分工和协作关系讲清楚。

Quartz 经常与 TimerScheduledExecutorService、Spring 的 TaskScheduler@Scheduled 放在一起比较。真正需要理解的不是“谁也能定时执行”,而是“谁适合简单定时任务,谁适合复杂触发规则、持久化、恢复与集群调度”。

参考资料:

官方文档:Quartz 2.3.0 TutorialsQuartz API 2.3.0Lesson 3: More About Jobs and JobDetailsLesson 6: CronTrigger

实践参考:入门Java开源任务调度框架-Quartz(前篇)入门Java开源任务调度框架-Quartz(后篇)

[TOC]


Quartz 是什么

Quartz 是一款 Java 生态中使用非常广泛的开源任务调度框架,适合处理“某个任务在什么时间、以什么规则、在什么异常条件下继续执行”这一类问题。和只负责延迟执行、周期执行的基础定时能力相比,Quartz 额外提供了 TriggerJobDetailJobStore、恢复策略、监听器、集群等更完整的调度语义。

它和常见定时方案的关系大致可以概括为:

  • Timer:适合非常轻量的定时任务,但能力简单,异常处理与线程模型都比较有限
  • ScheduledExecutorService:适合应用内部的固定频率调度,线程池控制更灵活,但缺少复杂日历规则和持久化能力
  • Spring @Scheduled / TaskScheduler:适合 Spring 应用内的常规任务编排,使用门槛低,但默认并不等同于 Quartz
  • Quartz:适合复杂触发规则、错过执行处理、任务持久化、故障恢复、多 Trigger 绑定同一 Job 等场景

Quartz 中主要用到了 Builder 建造者模式和 Factory 工厂模式,整体可以拆成下面几个核心组件:

sequenceDiagram
    participant App as Client
    participant S as Scheduler
    participant T as Trigger
    participant F as JobFactory
    participant J as Job

    App->>S: scheduleJob(JobDetail, Trigger)
    S->>T: calculate next fire time
    T-->>S: fire
    S->>F: create Job instance
    F->>J: new Job()
    S->>J: execute(JobExecutionContext)
    J-->>S: complete / exception
组件 作用 理解重点
Job 真正执行业务逻辑的类 每次触发通常都会新建实例,不要把跨次执行状态放到成员变量中
JobDetail Job 的定义信息 负责描述 Job 类型、标识、数据和运行属性
Trigger 触发规则 决定任务何时开始、何时结束、按什么节奏触发
Scheduler 调度入口 负责注册、调度、暂停、恢复、关闭任务

maven 依赖包

1
2
3
4
5
<dependency>
    <groupId>org.quartz-scheduler</groupId>
    <artifactId>quartz</artifactId>
    <version>2.3.2</version>
</dependency>

Quartz 使用的是 slf4j 日志,但是没有具体的实现;为了更直观的看到任务的执行时间,这里导入 Logback 的日志实现,在 pom 文件中新增依赖:

1
2
3
4
5
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.3</version>
</dependency>

默认的日志格式不是很好,在 maven 项目中的 resources 目录下直接放置一个名为 logback.xml 的文件,这里只要配置控制台的输出即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds" debug="false" >
    <!-- 日志级别 -->
    <property name="logLevel" value="INFO"/>

    <!-- 异步缓冲队列的深度,该值会影响性能.默认值为256 -->
    <property name="queueSize" value="512" />

    <!-- LOGGER  PATTERN  配置化输出格式 -->
    <property name="logPattern" value="%d{yyyy-MM-dd HH:mm:ss} [%-5level] %logger - %msg%n"/>

    <!-- 控制台打印日志的相关配置 -->
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <!-- 日志格式 -->
        <encoder>
            <charset>UTF-8</charset>
            <pattern>${logPattern}</pattern>
        </encoder>
    </appender>

    <root level="${logLevel}">
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

Job 任务接口

Job 任务接口是具体任务的执行入口,也就是业务逻辑代码,类似 TimerTask 的 run 方法

package org.quartz 包下 Job 接口,只有一个方法

1
2
3
4
5
public interface Job {

    void execute(JobExecutionContext context) throws JobExecutionException;

}

创建一个任务实现该接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 部分注释

/**
* Instances of <code>Job</code> must have a <code>public</code>
* no-argument constructor.
*/
public class QuartzJobImpl implements Job {
    
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        // 业务逻辑代码
    }
}

通过官方注释中提到的,实现 Job 接口的类必须要有一个无参构造器,这是因为 Quartz 默认会通过反射机制实例化任务类

但是 Job 并不能直接被调度器使用,它需要通过 JobDetail 绑定后传递给 Scheduler

一个 Job 可以对应多个 Trigger 触发器。每当调度器要执行 Job 的 execute() 方法前,通常都会创建一个新的 Job 实例;执行结束后,这个实例就会被丢弃并等待垃圾回收。因此真正需要跨执行保存的数据,不应该放在 Job 成员变量里,而应该放到 JobDataMap 或外部存储中


JobDetail 任务接口

package org.quartz 包下 JobDetail 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public interface JobDetail extends Serializable, Cloneable {

    public JobKey getKey();

    public String getDescription();

    public Class<? extends Job> getJobClass();

    public JobDataMap getJobDataMap();

    /**
     * <p>
     * Whether or not the <code>Job</code> should remain stored after it is
     * orphaned (no <code>{@link Trigger}s</code> point to it).
     * </p>
     * 
     * <p>
     * If not explicitly set, the default value is <code>false</code>.
     * </p>
     * 
     * @return <code>true</code> if the Job should remain persisted after
     *         being orphaned.
     */
    public boolean isDurable();

    /**
     * @see PersistJobDataAfterExecution
     * @return whether the associated Job class carries the {@link PersistJobDataAfterExecution} annotation.
     */
    public boolean isPersistJobDataAfterExecution();

    /**
     * @see DisallowConcurrentExecution
     * @return whether the associated Job class carries the {@link DisallowConcurrentExecution} annotation.
     */
    public boolean isConcurrentExectionDisallowed();

    /**
     * <p>
     * Instructs the <code>Scheduler</code> whether or not the <code>Job</code>
     * should be re-executed if a 'recovery' or 'fail-over' situation is
     * encountered.
     * </p>
     * 
     * <p>
     * If not explicitly set, the default value is <code>false</code>.
     * </p>
     * 
     * @see JobExecutionContext#isRecovering()
     */
    public boolean requestsRecovery();

    public Object clone();

    public JobBuilder getJobBuilder();

}

isDurableisPersistJobDataAfterExecutionisConcurrentExectionDisallowedrequestsRecovery 这些属性都和 Job 的运行方式有关,可通过 JobBuilder 或相关注解间接影响

isDurable():

任务是否可持续保存。如果一个任务不是 durable 的,那么当它没有任何 Trigger 关联时,Quartz 会把它从 Scheduler 中移除

isPersistJobDataAfterExecution():

是否在任务执行后重新持久化 JobDataMap。这通常用于任务需要把本次执行结果写回 JobDataMap 的场景,可通过在 Job 实现类上标注 @PersistJobDataAfterExecution 注解设置

isConcurrentExectionDisallowed():

是否禁止并发运行。这里的“并发”是以 JobDetail / JobKey 为粒度,而不是以 Java 类名为粒度;也就是说,@DisallowConcurrentExecution 的语义是“同一个 JobKey 不允许并发执行”,而不是“这个 Job 类在整个系统中永远只有一个实例在跑”

requestsRecovery():

是否请求恢复。如果一个任务在执行期间发生系统崩溃或进程异常退出,且该任务被标记为可恢复,那么调度器重新启动后会尝试恢复这次执行;在这种情况下,JobExecutionContext.isRecovering() 会返回 true

在真实业务里,@PersistJobDataAfterExecution 往往会和 @DisallowConcurrentExecution 一起考虑:前者解决“状态要不要写回”,后者解决“写回时会不会并发踩数据”

Scheduler 调度器需要借助 JobDetail 对象来添加 Job 实例 , JobDetail 接口的实例要通过 JobBuilder 类构建( Builder 建造者模式)

1
2
3
// 构建一个JobDetail实例,通过newJob方法绑定QuartzJob类,之后指定Job的名称和组名,但这不是必须的
    JobDetail jobDetail = JobBuilder.newJob(QuartzJob.class)
            .withIdentity("job1", "group1").build();

JobExecutionContext 任务上下文接口

回顾下 Job 接口的 execute 方法参数 JobExecutionContext

1
void execute(JobExecutionContext context) throws JobExecutionException;

package org.quartz 包下 JobExecutionContext 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
public interface JobExecutionContext {

    /**
     * <p>
     * Get a handle to the <code>Scheduler</code> instance that fired the
     * <code>Job</code>.
     * </p>
     */
    public Scheduler getScheduler();

    /**
     * <p>
     * Get a handle to the <code>Trigger</code> instance that fired the
     * <code>Job</code>.
     * </p>
     */
    public Trigger getTrigger();

    /**
     * <p>
     * Get a handle to the <code>Calendar</code> referenced by the <code>Trigger</code>
     * instance that fired the <code>Job</code>.
     * </p>
     */
    public Calendar getCalendar();

    /**
     * <p>
     * If the <code>Job</code> is being re-executed because of a 'recovery'
     * situation, this method will return <code>true</code>.
     * </p>
     */
    public boolean isRecovering();

    /**
     * Return the {@code TriggerKey} of the originally scheduled and now recovering job.
     * <p>
     * When recovering a previously failed job execution this method returns the identity
     * of the originally firing trigger.  This recovering job will have been scheduled for
     * the same firing time as the original job, and so is available via the
     * {@link #getScheduledFireTime()} method.  The original firing time of the job can be
     * accessed via the {@link Scheduler#FAILED_JOB_ORIGINAL_TRIGGER_FIRETIME_IN_MILLISECONDS}
     * element of this job's {@code JobDataMap}.
     * 
     * @return the recovering trigger details
     * @throws IllegalStateException if this is not a recovering job.
     */
    public TriggerKey getRecoveringTriggerKey() throws IllegalStateException;

    public int getRefireCount();

    /**
     * <p>
     * Get the convenience <code>JobDataMap</code> of this execution context.
     * </p>
     * 
     * <p>
     * The <code>JobDataMap</code> found on this object serves as a convenience -
     * it is a merge of the <code>JobDataMap</code> found on the 
     * <code>JobDetail</code> and the one found on the <code>Trigger</code>, with 
     * the value in the latter overriding any same-named values in the former.
     * <i>It is thus considered a 'best practice' that the execute code of a Job
     * retrieve data from the JobDataMap found on this object.</i>
     * </p>
     * 
     * <p>NOTE: Do not expect value 'set' into this JobDataMap to somehow be set
     * or persisted back onto a job's own JobDataMap - even if it has the
     * <code>@PersistJobDataAfterExecution</code> annotation.
     * </p>
     * 
     * <p>
     * Attempts to change the contents of this map typically result in an 
     * <code>IllegalStateException</code>.
     * </p>
     * 
     */
    public JobDataMap getMergedJobDataMap();

    /**
     * <p>
     * Get the <code>JobDetail</code> associated with the <code>Job</code>.
     * </p>
     */
    public JobDetail getJobDetail();

    /**
     * <p>
     * Get the instance of the <code>Job</code> that was created for this
     * execution.
     * </p>
     * 
     * <p>
     * Note: The Job instance is not available through remote scheduler
     * interfaces.
     * </p>
     */
    public Job getJobInstance();

    /**
     * The actual time the trigger fired. For instance the scheduled time may
     * have been 10:00:00 but the actual fire time may have been 10:00:03 if
     * the scheduler was too busy.
     * 
     * @return Returns the fireTime.
     * @see #getScheduledFireTime()
     */
    public Date getFireTime();

    /**
     * The scheduled time the trigger fired for. For instance the scheduled
     * time may have been 10:00:00 but the actual fire time may have been
     * 10:00:03 if the scheduler was too busy.
     * 
     * @return Returns the scheduledFireTime.
     * @see #getFireTime()
     */
    public Date getScheduledFireTime();

    public Date getPreviousFireTime();

    public Date getNextFireTime();

    /**
     * Get the unique Id that identifies this particular firing instance of the
     * trigger that triggered this job execution.  It is unique to this 
     * JobExecutionContext instance as well.
     * 
     * @return the unique fire instance id
     * @see Scheduler#interrupt(String)
     */
    public String getFireInstanceId();
    
    /**
     * Returns the result (if any) that the <code>Job</code> set before its 
     * execution completed (the type of object set as the result is entirely up 
     * to the particular job).
     * 
     * <p>
     * The result itself is meaningless to Quartz, but may be informative
     * to <code>{@link JobListener}s</code> or 
     * <code>{@link TriggerListener}s</code> that are watching the job's 
     * execution.
     * </p> 
     * 
     * @return Returns the result.
     */
    public Object getResult();

    /**
     * Set the result (if any) of the <code>Job</code>'s execution (the type of 
     * object set as the result is entirely up to the particular job).
     * 
     * <p>
     * The result itself is meaningless to Quartz, but may be informative
     * to <code>{@link JobListener}s</code> or 
     * <code>{@link TriggerListener}s</code> that are watching the job's 
     * execution.
     * </p> 
     */
    public void setResult(Object result);

    /**
     * The amount of time the job ran for (in milliseconds).  The returned 
     * value will be -1 until the job has actually completed (or thrown an 
     * exception), and is therefore generally only useful to 
     * <code>JobListener</code>s and <code>TriggerListener</code>s.
     * 
     * @return Returns the jobRunTime.
     */
    public long getJobRunTime();

    /**
     * Put the specified value into the context's data map with the given key.
     * Possibly useful for sharing data between listeners and jobs.
     *
     * <p>NOTE: this data is volatile - it is lost after the job execution
     * completes, and all TriggerListeners and JobListeners have been 
     * notified.</p> 
     *  
     * @param key the key for the associated value
     * @param value the value to store
     */
    public void put(Object key, Object value);

    /**
     * Get the value with the given key from the context's data map.
     * 
     * @param key the key for the desired value
     */
    public Object get(Object key);

}

当 Scheduler 调用一个 Job 时,会把 JobExecutionContext 传给 execute() 方法。Job 可以通过它访问 Quartz 运行时环境以及当前这次触发对应的各种信息,例如 SchedulerTriggerCalendarJobDetailfireTimenextFireTime

其中最常用的方法之一是 getMergedJobDataMap()。它会把 JobDetail 上的 JobDataMapTrigger 上的 JobDataMap 合并起来,并且在键名冲突时以 Trigger 中的值覆盖 JobDetail 中的值,这也是多 Trigger 复用同一个 Job 时最方便的取值入口


Trigger 触发器

Trigger 定义的是任务的触发规则,而 JobDetail 代表的是要执行什么任务

任务什么时候开始执行、执行几次、按固定间隔还是按日历规则执行,都是通过 Trigger 来描述的

多个触发器可以指向同一个任务,但一个触发器只能指向一个任务

trigger触发器.png

Quartz 常见的 Trigger 不止两种,但入门阶段最常见的是 SimpleTriggerCronTrigger

Trigger 类型 适用场景 特点
SimpleTrigger 固定间隔、固定次数 最适合“每隔 N 秒执行一次,共执行 M 次”
CronTrigger 按日历规则调度 最适合“每天 10:15”“每周三中午”“每月最后一个工作日”
CalendarIntervalTrigger 按自然时间单位间隔 更适合按天、周、月、年递进的时间间隔
DailyTimeIntervalTrigger 每天某个时间窗口内重复执行 适合“工作日 9 点到 18 点之间每 10 分钟执行一次”

Trigger 接口主要描述一个任务的调度优先级、开始时间、结束时间、Calendar 名称、关联的 JobKey 等信息

此外还有两个很重要的标识类型:JobKeyTriggerKey。它们本质上都是名称与分组的组合,用来唯一标识某个 Job 或 Trigger

同样,和 JobDetail 类似,Trigger 通常通过 TriggerBuilder 来构造

1
2
3
4
5
6
7
8
// 构建一个Trigger,指定Trigger名称和组,规定该Job立即执行,且两秒钟重复执行一次
SimpleTrigger trigger = TriggerBuilder.newTrigger()
        .startNow() // 立即开始
        .withIdentity("trigger1", "group1") // 不是必须的
        .withSchedule(SimpleScheduleBuilder.simpleSchedule()
                .withIntervalInSeconds(2)
                .repeatForever())
        .build();

上面最关键的是 withSchedule()。它决定了当前 Trigger 的调度类型:使用 SimpleScheduleBuilder 构建时,对应的就是 SimpleTrigger;如果使用 CronScheduleBuilder,对应的则是 CronTrigger

底层确实存在 SimpleTriggerImplCronTriggerImpl 这样的实现类,但入门阶段更应该关注接口语义和 Builder 用法,而不是直接依赖具体实现类

ScheduleBuilder子类.png

SimpleScheduleBuilder 继承自抽象类 ScheduleBuilder,其他调度规则 Builder 也遵循同样的模式

通过查看 TriggerBuilder 的代码可以知道更多属性设置:

1
2
3
4
5
6
7
8
9
10
11
public class TriggerBuilder<T extends Trigger> {
    private TriggerKey key;  // Trigger 的名称和组
    private String description;  // Trigger的描述
    private Date startTime = new Date();  // 任务开始时间,不设置默认立即开始
    private Date endTime; // 结束时间
    private int priority = Trigger.DEFAULT_PRIORITY; // 任务优先级
    private String calendarName;  // 日历名称
    private JobKey jobKey;  // Job 的名称和组
    private JobDataMap jobDataMap = new JobDataMap(); // 用于携带数据
    private ScheduleBuilder<?> scheduleBuilder = null; // 调度规则
}

创建 SimpleTrigger 时使用的是 SimpleScheduleBuilder

1
2
3
4
5
public class SimpleScheduleBuilder extends ScheduleBuilder<SimpleTrigger> {
    private long interval = 0; // 执行的时间间隔
    private int repeatCount = 0; // 任务执行的次数
    private int misfireInstruction = SimpleTrigger.MISFIRE_INSTRUCTION_SMART_POLICY; // 任务未正常执行时的处理策略
}

SimpleScheduleBuilder 使用静态工厂方法返回实例,相关配置方法基本都以 withrepeat 开头。理解了这些属性之后,Builder 风格会非常自然

最后关于 TriggerBuilderSimpleScheduleBuilder,还有几点很容易混淆:

  • repeatCount 指的是“首次触发之后再重复多少次”,因此 0 表示只执行一次
  • interval 表示两次触发之间的间隔,实际使用中通常应该设置为正数
  • 如果设置了 endTime,那么触发器到了结束时间后会停止,即使理论上的重复次数还没用完
  • misfireInstruction 决定的是“错过本次触发后如何处理”,例如应用停机、线程池耗尽、数据库阻塞等情况都可能导致 misfire

JobDataMap 数据存储类

通过查看 JobDetailTriggerJobExecutionContext 的源码可以发现,它们都和 JobDataMap 有关系。JobDataMap 本质上就是 Quartz 用来传递任务参数和保存任务状态的数据容器,实现上它是一个带有额外便捷方法的 Map

当 Job 的 execute() 方法被调用时,任务通常会通过 JobExecutionContext 读取这些数据。如果使用的是持久化 JobStore,那么写入 JobDataMap 的对象还会涉及序列化问题,因此更稳妥的做法是优先存放基础类型、String 或稳定的可序列化对象

JobDataMap继承关系.png

如果只是读取当前这次执行所需的参数,通常优先使用 context.getMergedJobDataMap(),因为它已经处理了 JobDetailTrigger 两侧数据的合并关系


Scheduler 任务调度器

Scheduler 任务调度器也是一个接口,它定义了调度任务中的基本操作骨架

Scheduler实现类.png

通过 SchedulerFactory 调度器构建工厂的接口子工厂实例来完成,它有两个具体的工厂实现

SchedulerFactory实现类.png

一般使用的是 StdSchedulerFactory 工厂来获取 Scheduler 实例,因为它可以使用配置文件方式配置参数

DirectSchedulerFactory 则是使用硬编码(通过调用方法)的方式来设置参数;下面先说 StdSchedulerFactory 工厂获取 Scheduler 实例的方法

1
2
// StdSchedulerFactory
Scheduler scheduler = new StdSchedulerFactory().getScheduler();

这里返回的是一个 Scheduler 类型 , StdScheduler 是 Scheduler 的子类型。所有的组件都集齐了,就可以实际来使用 Quartz 完成自定义的任务了


测试案例

创建一个 Quartz 任务,从 JobExecutionContext 读取 JobDetail 和 Trigger 中的 JobDataMap,并获取客户端 QuartzScheduler 传入的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class QuartzJobImpl implements Job {

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        // 业务逻辑代码
        // 获取JobDetail中的JobDataMap
        JobDataMap jobDetailDataMap = context.getJobDetail().getJobDataMap();
        // 获取Trigger中的JobDataMap
        JobDataMap triggerDataMap = context.getTrigger().getJobDataMap();
        System.out.println(jobDetailDataMap.get("message"));
        System.out.println(triggerDataMap.get("number"));

        // 获取JobDetail与Trigger合并后的JobDataMap
        // JobDataMap mergedJobDataMap = context.getMergedJobDataMap();
        // System.out.println(mergedJobDataMap.get("message"));
        // System.out.println(mergedJobDataMap.get("number"));
    }
}

创建 Quartz 客户端,构建 JobDetail 和 Trigger 并使用 Scheduler 开始任务调度( 这里要注意的是 Scheduler 实例创建后处于 “待机” 状态,还需要调用 start 方法启动调度器,否则任务是不会执行 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class QuartzScheduler {
    public static void main(String[] args) throws SchedulerException {
        // 创建一个JobDetail实例
        JobDetail jobDetail = JobBuilder.newJob(QuartzJobImpl.class)
                // 指定JobDetail的名称和组名称
                .withIdentity("job1", "group1")
                // 使用JobDataMap存储用户数据
                .usingJobData("message", "JobDetail传递的文本数据").build();

        // 创建一个SimpleTrigger,规定该Job立即执行,且两秒钟重复执行一次
        SimpleTrigger trigger = TriggerBuilder.newTrigger()
                // 设置立即执行,并指定Trigger名称和组名称
                .startNow().withIdentity("trigger1", "group1")
                // 使用JobDataMap存储用户数据
                .usingJobData("number", 128)
                // 设置运行规则,每隔两秒执行一次,一直重复下去
                .withSchedule(SimpleScheduleBuilder.simpleSchedule()
                        .withIntervalInSeconds(2).repeatForever()).build();

        // 得到Scheduler调度器实例
        Scheduler scheduler = new StdSchedulerFactory().getScheduler();
        scheduler.scheduleJob(jobDetail, trigger); // 绑定JobDetail和Trigger
        scheduler.start();                         // 开始任务调度
    }
}

Quartz运行结果.png


JobDataMap 补充

之前 JobDataMap 的数据获取方式,都是通过 JobExecutionContext 上下文来完成

1
2
3
4
5
6
// 获取JobDetail中的JobDataMap
JobDataMap jobDetailDataMap = context.getJobDetail().getJobDataMap();
// 获取Trigger中的JobDataMap
JobDataMap triggerDataMap = context.getTrigger().getJobDataMap();
System.out.println(jobDetailDataMap.get("message"));
System.out.println(triggerDataMap.get("number"));
1
2
3
JobDataMap mergedJobDataMap = context.getMergedJobDataMap();
log.info(mergedJobDataMap.get("message"));
log.info(mergedJobDataMap.get("number"));

如果同名键同时存在于 JobDetail 和 Trigger 中,那么 mergedJobDataMap 会以 Trigger 中的值为准

还有另一种选择,那就是在 Job 的实现类中定义与 JobDataMap 键名对应的字段,并提供对应的 setter 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class QuartzJobImpl implements Job {
    private String message;
    private Integer number;

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public Integer getNumber() {
        return number;
    }

    public void setNumber(Integer number) {
        this.number = number;
    }

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        // 业务逻辑代码
        System.out.println(message);
        System.out.println(number);
    }
}

CronTrigger 触发器

前面的案例使用的是 SimpleTrigger。另一类使用频率非常高的触发器是 CronTrigger,它更适合基于日历概念来描述运行规则,而不是像 SimpleTrigger 那样直接指定“间隔多久、重复多少次”

Quartz 的 cron 表达式通常由 6 到 7 个字段组成,分别对应【秒】【分】【时】【日】【月】【周】【年】。其中“年”是可选字段。各字段之间使用空格分隔,常见规则如下:

字段 允许值 允许的特殊字符
秒(Seconds) 0 ~ 59 的整数 , - * / 四个字符
分(Minutes) 0 ~ 59 的整数 , - * / 四个字符
小时(Hours) 0 ~ 23 的整数 , - * / 四个字符
日期(DayOfMonth) 1 ~ 31 的整数(需要考虑月的天数) , - * ? / L W
月份(Month) 数字或 JAN-DEC , - * /
星期(DayOfWeek) 1 ~ 7 的整数或者 SUN-SAT(1 = SUN) , - * ? / L #
年(可选,留空)(Year) 1970~2099 , - * / 四个字符

几个最常见的特殊字符如下:

  1. *:匹配该域的任意值,例如分钟位写 * 表示“每分钟”
  2. ?:只能出现在 DayOfMonthDayOfWeek,表示“不指定这个域”,通常用于避免这两个域同时约束
  3. -:表示范围,例如 10-12
  4. /:表示步长,例如 0/15 表示从 0 开始每隔 15 个单位触发一次
  5. ,:表示枚举值,例如 MON,WED,FRI
  6. L:表示最后,例如 L 表示当月最后一天,6L 表示当月最后一个星期五
  7. W:表示离指定日期最近的工作日,只能出现在 DayOfMonth
  8. LW:表示当月最后一个工作日
  9. #:表示“第几个星期几”,只能出现在 DayOfWeek,例如 6#3 表示当月第三个星期五

常用表达式例子:

  • 0 0 2 1 * ?:每月 1 日凌晨 2 点
  • 0 15 10 ? * MON-FRI:周一到周五上午 10:15
  • 0 0 10,14,16 * * ?:每天上午 10 点、下午 2 点、下午 4 点
  • 0 0/30 9-17 * * ?:每天 9 点到 17 点之间每 30 分钟
  • 0 0 12 ? * WED:每周三中午 12 点
  • 0 15 10 L * ?:每月最后一天上午 10:15
  • 0 15 10 ? * 6L:每月最后一个星期五上午 10:15
  • 0 15 10 ? * 6#3:每月第三个星期五上午 10:15

使用 CronTrigger 触发器

Job 任务类:

1
2
3
4
5
6
7
public class QuartzJob implements Job {
    public void execute(JobExecutionContext jobExecutionContext)
            throws JobExecutionException {
        log.info("开始执行"); 
        // 业务逻辑
    }
}

如果希望任务在每天凌晨 1:00:002:59:58 之间每隔两秒执行一次,那么 cron 表达式可以写成:0/2 * 1,2 * * ?

Scheduler任务调度类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class QuartzScheduler {
    public static void main(String[] args) throws SchedulerException {
        // 创建一个JobDetail实例
        JobDetail jobDetail = JobBuilder.newJob(QuartzJob.class)
                // 指定JobDetail的名称和组名称
                .withIdentity("job1", "group1").build();

        // 创建一个CronTrigger,在凌晨1点和2点这两个小时内每2秒执行一次
        CronTrigger trigger = TriggerBuilder.newTrigger()
                // 指定Trigger名称和组名称
                .startNow().withIdentity("trigger1", "group1")
                // 设置cron运行规则
                .withSchedule(CronScheduleBuilder.cronSchedule("0/2 * 1,2 * * ?")).build();

        // 得到Scheduler调度器实例
        Scheduler scheduler = new StdSchedulerFactory().getScheduler();
        scheduler.scheduleJob(jobDetail, trigger); // 绑定JobDetail和Trigger
        scheduler.start();                         // 开始任务调度
    }
}

CronTrigger 除了表达式本身,还需要重点关注 misfire。所谓 misfire,可以理解为“本来该触发,但由于调度器停机、线程池忙不过来、持久化存储阻塞等原因,实际没有按时触发”

对于 CronTrigger,Quartz 提供了几种常见的 misfire 处理方式:

  • withMisfireHandlingInstructionDoNothing():跳过已经错过的触发点,等待下一次合法时间
  • withMisfireHandlingInstructionFireAndProceed():立即补触发一次,然后按后续时间继续执行
  • 默认的 SMART_POLICY:对于 CronTrigger 来说,等价于 FIRE_NOW

例如:

1
2
3
4
5
CronTrigger trigger = TriggerBuilder.newTrigger()
        .withIdentity("trigger1", "group1")
        .withSchedule(CronScheduleBuilder.cronSchedule("0 0/5 * * * ?")
                .withMisfireHandlingInstructionDoNothing())
        .build();

Scheduler 任务调度器补充

Scheduler 维护着 JobDetail 和 Trigger 的注册信息。当某个 Trigger 到达触发时间后,Scheduler 会负责获取可执行的任务定义、创建 Job 实例并驱动执行

程序通常通过工厂方式获取 Scheduler。前面提到过两个常见工厂类:StdSchedulerFactoryDirectSchedulerFactory。前者基于配置文件或 Properties 初始化,适合绝大多数场景;后者偏向手工硬编码配置,理解原理时有帮助,但业务代码里更常见的仍然是 StdSchedulerFactory


Scheduler 的主要方法(部分)

1
2
Date scheduleJob(JobDetail jobDetail, Trigger trigger)
        throws SchedulerException;

调度任务,并返回开始执行的时间

1
void start() throws SchedulerException;

调度器实例化后仍处于“待命”状态,需要 start 方法启动调度器

1
void standby() throws SchedulerException;

挂起调度器,暂停执行任务,可以恢复

1
2
void shutdown(boolean waitForJobsToComplete)
        throws SchedulerException;

关闭调度器,如果传入的参数为 true,等待所有任务完成后再关闭,否则立即关闭

1
void shutdown() throws SchedulerException;

立即关闭调度器,不等待任务正常完成

1
boolean isShutdown() throws SchedulerException;

查看调度器是否关闭了

1
void resumeAll() throws SchedulerException;

重新执行挂起的任务


JobStore 选择

Quartz 的任务定义、Trigger 状态和恢复能力,最终都和 JobStore 有关。入门阶段最常见的是下面两类:

JobStore 特点 适用场景
RAMJobStore 数据只放内存,速度快,配置简单,进程重启后任务状态丢失 单机演示、本地开发、对恢复要求不高的场景
JDBCJobStore 把任务与 Trigger 状态持久化到数据库,可支持恢复、故障转移与集群 生产环境、对可靠性和恢复能力有要求的场景

如果只是学习 API,用 RAMJobStore 足够;如果业务要求“服务重启后继续按原计划执行”“多节点协同调度”或“需要利用数据库进行故障恢复”,就应该进一步了解 JDBCJobStore


StdSchedulerFactory 工厂补充

StdSchedulerFactory 通过名为 quartz.properties 文件来创建和初始化 Quartz 调度器 Scheduler,导入的 Quartz 依赖中自带了一个默认的 quartz.properties 文件,可以到项目的 External Libraries 找到 Quartz 相关的 jar 文件,在 org.quartz 包下即可看到,打开文件内容如下(注意这里键与值是通过冒号加空格的方式分割的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 调度器的配置
org.quartz.scheduler.instanceName: DefaultQuartzScheduler
org.quartz.scheduler.rmi.export: false
org.quartz.scheduler.rmi.proxy: false
org.quartz.scheduler.wrapJobExecutionInUserTransaction: false

# 线程池的配置
org.quartz.threadPool.class: org.quartz.simpl.SimpleThreadPool
org.quartz.threadPool.threadCount: 10
org.quartz.threadPool.threadPriority: 5
org.quartz.threadPool.threadsInheritContextClassLoaderOfInitializingThread: true

# misfire阈值设置
org.quartz.jobStore.misfireThreshold: 60000

# 任务存储配置
org.quartz.jobStore.class: org.quartz.simpl.RAMJobStore

以上是 Quartz 运行时的必要参数设置,Quartz 可以配置的属性远不止这些

org.quartz.scheduler.instanceName 用来设置调度器的实例名称(任意字符串)

还有一个比较重要的属性是 org.quartz.scheduler.instanceId。上面没有显式设置它,它用于设置调度器实例 ID,并且需要全局唯一;如果不想手工指定,可以设置为 AUTO 让 Quartz 自动生成

在使用 StdSchedulerFactory 获取 Scheduler 实例时,Quartz 会优先在 classpath 中查找 quartz.properties;如果没有找到,则使用内置默认配置

如果需要自定义配置参数,可以在项目 classpath 下创建一个名为 quartz.properties 的文件,把这些默认配置复制出来后按需修改

如果想指定 properties 文件的名称和路径,就可以使用 StdSchedulerFactoryinitialize() 方法。常见有三种方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class QuartzScheduler {
    public static void main(String [] args)  throws SchedulerException {
        StdSchedulerFactory schedulerFactory = new StdSchedulerFactory();

        // 第一种方式 通过Properties创建,可以没有properties文件,直接代码设置properties属性
        Properties props = new Properties();
        props.load(new FileInputStream("config.properties"));
        schedulerFactory.initialize(props);

        // 第二种方式 直接通过文件名,properties文件放置在classpath下
        // schedulerFactory.initialize("config.properties");

        // 第三种方式 传入文件流
        // InputStream is = new FileInputStream(new File("config.properties"));
        // schedulerFactory.initialize(is);

        // 获取调度器实例
        Scheduler scheduler = schedulerFactory.getScheduler();
    }
}