Bootstrap

任务调度之Quartz

1 漫谈任务调度

1.1 什么时候需要任务调度?

1.1.1 任务调度的背景

在业务系统中有很多这样的场景:

1、账单日或者还款日上午 10 点,给每个信用卡客户发送账单通知,还款通知。如 何判断客户的账单日、还款日,完成通知的发送?

2、银行业务系统,夜间要完成跑批的一系列流程,清理数据,下载文件,解析文件, 对账清算、切换结算日期等等。如何触发一系列流程的执行?

3、金融机构跟人民银行二代支付系统对接,人民银行要求低于 5W 的金额(小额支付)半个小时打一次包发送,以缓解并发压力。所以,银行的跨行转账分成了多个流程: 录入、复核、发送。如何把半个小时以内的所有数据一次性发送?

类似于这种 1、基于准确的时刻或者固定的时间间隔触发的任务,或者 2、有批量数据需要处理,或者 3、要实现两个动作解耦的场景,我们都可以用任务调度来实现。

1.2 任务调度需求分析

任务调度的实现方式有很多,如果要实现我们的调度需求,我们对这个工具有什么样的基本要求呢?

1.2.1 基本需求

1)可以定义触发的规则,比如基于时刻、时间间隔、表达式。

2)可以定义需要执行的任务。比如执行一个脚本或者一段代码。任务和规则是分开的。

3)集中管理配置,持久配置。不用把规则写在代码里面,可以看到所有的任务配置,方便维护。重启之后任务可以再次调度——配置文件或者配置中心。

4)支持任务的串行执行,例如执行 A 任务后再执行 B 任务再执行 C 任务。

5)支持多个任务并发执行,互不干扰(例如 ScheduledThreadPoolExecutor)。

6)有自己的调度器,可以启动、中断、停止任务。

7)容易集成到 Spring。

1.3 任务调度工具对比

层次举例特点
操作系统Linux crontab <br>Windows 计划任务只能执行简单脚本或者命令
数据库MySQL、Oracle可以操作数据。不能执行 Java 代码
工具Kettle可以操作数据,执行脚本。没有集中配置
开发语言JDK Timer、ScheduledThreadPoolTimer:单线程<br>JDK1.5 之后:ScheduledThreadPool(Cache、Fiexed、Single):没有集中配置,日程管理不够灵活
容器Spring Task、@Scheduled不支持集群
分布式框架XXL-JOB,Elastic-Job

@Scheduled 也是用 JUC 的 ScheduledExecutorService 实现的Scheduled(cron = “0 15 10 15 * ?”)

1、 ScheduledAnnotationBeanPostProcessor 的 postProcessAfterInitialization 方法将@Scheduled 的方法包装为指定的 task添加到 ScheduledTaskRegistrar 中

2、 ScheduledAnnotationBeanPostProcessor 会监听 Spring 的容器初始化事件,在 Spring 容器初始化完成后进行TaskScheduler 实现类实例的查找,若发现有 SchedulingConfigurer 的实现类实例,则跳过 3

3、 查找 TaskScheduler 的实现类实例默认是通过类型查找,若有多个实现则会查找名字为"taskScheduler"的实现 Bean,若没有找到则在 ScheduledTaskRegistrar 调度任务的时候会创建一个 newSingleThreadScheduledExecutor,将TaskScheduler 的实现类实例设置到 ScheduledTaskRegistrar 属性中

4、 ScheduledTaskRegistrar 的 scheduleTasks 方法触发任务调度

5、 真正调度任务的类是 TaskScheduler 实现类中的 ScheduledExecutorService,由 J.U.C 提供

2 Quartz 基本介绍

官网:http://www.quartz-scheduler.org/

Quartz 的意思是石英,像石英表一样精确。

Quartz is a richly featured, open source job scheduling library that can be integrated within virtually any Java application - from the smallest stand-alone application to the largest e-commerce system. Quartz can be used to create simple or complex schedules for executing tens, hundreds, or even tens-of-thousands of jobs; jobs whose tasks are defined as standard Java components that may execute virtually anything you may program them to do. The Quartz Scheduler includes many enterprise-class features, such as support for JTA transactions and clustering.

Quatz 是一个特性丰富的,开源的任务调度库,它几乎可以嵌入所有的 Java 程序,从很小的独立应用程序到大型商业系统。Quartz 可以用来创建成百上千的简单的或者复杂的任务,这些任务可以用来执行任何程序可以做的事情。Quartz 拥有很多企业级的特性,包括支持 JTA 事务和集群。

Quartz 是一个老牌的任务调度系统,98 年构思,01 年发布到 sourceforge。现在更新比较慢,因为已经非常成熟了。

https://github.com/quartz-scheduler/quartz

Quartz 的目的就是让任务调度更加简单,开发人员只需要关注业务即可。他是用 Java 语言编写的(也有.NET 的版本)。Java 代码能做的任何事情,Quartz 都可以调度。

特点:

精确到毫秒级别的调度

可以独立运行,也可以集成到容器中

支持事务(JobStoreCMT )

支持集群

支持持久化

3.Quartz Java 编程

http://www.quartz-scheduler.org/documentation/quartz-2.3.0/

http://www.quartz-scheduler.org/documentation/quartz-2.3.0/quick-start.html

3.1 引入依赖

<dependency> 
    <groupId>org.quartz-scheduler</groupId> 
    <artifactId>quartz</artifactId> 
    <version>2.3.0</version> 
</dependency>

3.2 默认配置文件

org.quartz.core 包下,有一个默认的配置文件,quartz.properties。当我们没有定义一个同名的配置文件的时候,就会使用默认配置文件里面的配置。

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 org.quartz.jobStore.misfireThreshold: 60000 
org.quartz.jobStore.class: org.quartz.simpl.RAMJobStore

3.3 创建 Job

实现唯一的方法 execute(),方法中的代码就是任务执行的内容。此处仅输出字符串。

public class MyJob implements Job { 
    public void execute(JobExecutionContext context) throws JobExecutionException { 
        System.out.println("假发在哪里买的");
    } 
}

在测试类 main()方法中,把 Job 进一步包装成 JobDetail。

必须要指定 JobName 和 groupName,两个合起来是唯一标识符。

可以携带 KV 的数据(JobDataMap),用于扩展属性,在运行的时候可以从 context 获取到

JobDetail jobDetail = JobBuilder.newJob(MyJob1.class) 
    .withIdentity("job1", "group1") 
    .usingJobData("abc","123") 
    .usingJobData("moon",5.21F) 
    .build();

3.4 创建 Trigger

在测试类 main()方法中,基于 SimpleTrigger 定义了一个每 2 秒钟运行一次、不断重复的 Trigger:

Trigger trigger = TriggerBuilder.newTrigger() 
    .withIdentity("trigger1", "group1") 
    .startNow() 
    .withSchedule(SimpleScheduleBuilder.simpleSchedule() 
        .withIntervalInSeconds(2) 
        .repeatForever()) 
    .build();

3.5 创建 Scheduler

在测试类 main()方法中,通过 Factory 获取调度器的实例,把 JobDetail 和 Trigger绑定,注册到容器中。

Scheduler 先启动后启动无所谓,只要有 Trigger 到达触发条件,就会执行任务。

SchedulerFactory factory = new StdSchedulerFactory(); 
Scheduler scheduler = factory.getScheduler(); 
scheduler.scheduleJob(jobDetail, trigger); 
scheduler.start();

注意这里,调度器一定是单例的。

3.6 体系结构总结

image.png

3.6.1 JobDetail

我们创建一个实现 Job 接口的类,使用 JobBuilder 包装成 JobDetail,它可以携带 KV 的数据。

3.6.2 Trigger
定义任务的触发规律,Trigger,使用 TriggerBuilder 来构建。
JobDetail 跟 Trigger 是 1:N 的关系。
思考:为什么要解耦?
Trigger 接口在 Quartz 有 4 个继承的子接口:
子接口描述特点
SimpleTrigger简单触发器固定时刻或时间间隔,毫秒
CalendarIntervalTrigger基于日历的触发器比简单触发器更多时间单位,支持非固定时间的触发,例如一年可能 365/366,一个月可能 28/29/30/31
DailyTimeIntervalTrigger基于日期的触发器每天的某个时间段
CronTrigger基于 Cron 表达式的触发器

MutableTrigger 和 CoreTrigger 最终也是用到以上四个类的实现类

image.png

代码:standalone com.abc.trigger.TriggerDefine

SimpleTrigger
SimpleTrigger 可以定义固定时刻或者固定时间间隔的调度规则(精确到毫秒)。
例如:每天 9 点钟运行;每隔 30 分钟运行一次。
CalendarIntervalTrigger
CalendarIntervalTrigger 可以定义更多时间单位的调度需求,精确到秒。
好处是不需要去计算时间间隔,比如 1 个小时等于多少毫秒。
例如每年、每个月、每周、每天、每小时、每分钟、每秒。
每年的月数和每个月的天数不是固定的,这种情况也适用。
DailyTimeIntervalTrigger
每天的某个时间段内,以一定的时间间隔执行任务。
例如:每天早上 9 点到晚上 9 点,每隔半个小时执行一次,并且只在周一到周六执行。
CronTrigger

CronTirgger 可以定义基于 Cron 表达式的调度规则,是最常用的触发器类型。

Cron 表达式

位置时间域特殊值
10-59, - * /
2分钟0-59, - * /
3小时0-23, - * /
4日期1-31, - * ? / L W C
5月份1-12, - * /
6星期1-7, - * ? / L W C
7年份(可选)1-31, - * /

星号(*):可用在所有字段中,表示对应时间域的每一个时刻,例如,在分钟字段时,表示“每分钟”;

问号(?):该字符只在日期和星期字段中使用,它通常指定为“无意义的值”,相当于点位符;

减号(-):表达一个范围,如在小时字段中使用“10-12”,则表示从 10 到 12 点,即 10,11,12;

逗号(,):表达一个列表值,如在星期字段中使用“MON,WED,FRI”,则表示星期一,星期三和星期五;

斜杠(/):x/y 表达一个等步长序列,x 为起始值,y 为增量步长值。如在分钟字段中使用 0/15,则表示为 0,15,30 和 45 秒,而 5/15 在分钟字段中表示 5,20,35,50,你也可以使用*/y,它等同于 0/y;

L:该字符只在日期和星期字段中使用,代表“Last”的意思,但它在两个字段中意思不同。L 在日期字段中,表示 这个月份的最后一天,如一月的 31 号,非闰年二月的 28 号;如果 L 用在星期中,则表示星期六,等同于 7。但是,如果 L 出现在星期字段里,而且在前面有一个数值 X,则表示“这个月的最后 X 天”,例如,6L 表示该月的最后星期五;

W:该字符只能出现在日期字段里,是对前导日期的修饰,表示离该日期最近的工作日。例如 15W 表示离该月 15号最近的工作日,如果该月 15 号是星期六,则匹配 14 号星期五;如果 15 日是星期日,则匹配 16 号星期一;如果 15号是星期二,那结果就是 15 号星期二。但必须注意关联的匹配日期不能够跨月,如你指定 1W,如果 1 号是星期六,结果匹配的是 3 号星期一,而非上个月最后的那天。W 字符串只能指定单一日期,而不能指定日期范围;

LW 组合:在日期字段可以组合使用 LW,它的意思是当月的最后一个工作日;

井号(#):该字符只能在星期字段中使用,表示当月某个工作日。如 6#3 表示当月的第三个星期五(6 表示星期五,#3 表示当前的第三个),而 4#5 表示当月的第五个星期三,假设当月没有第五个星期三,忽略不触发;

C:该字符只在日期和星期字段中使用,代表“Calendar”的意思。它的意思是计划所关联的日期,如果日期没有被关联,则相当于日历中所有日期。例如 5C 在日期字段中就相当于日历 5 日以后的第一天。1C 在星期字段中相当于星期日后的第一天。

Cron 表达式对特殊字符的大小写不敏感,对代表星期的缩写英文大小写也不敏感。

上面我们定义的都是在什么时间执行,但是我们有一些在什么时间不执行的需求,比如:理财周末和法定假日购买不计息;证券公司周末和法定假日休市。

是不是要把日期写在数据库中,然后读取基于当前时间判断呢?

基于Calendar的排除规则

如果要在触发器的基础上,排除一些时间区间不执行任务,就要用到Quartz的Calendar类(注意不是JDK的Calendar)。可以按年、月、周、日、特定日期、Cron表达式排除。

image.png

调用Trigger的modifiedByCalendar()添加到触发器中,并且调用调度器的addCalendar()方法注册排除规则。

代码示例:standalone工程:com.abc.calendar.CalendarDemo

Calendar名称用法
BaseCalendar为高级的 Calendar 实现了基本的功能,实现了 org.quartz.Calendar 接口
AnnualCalendar排除年中一天或多天
CronCalendar日历的这种实现排除了由给定的CronExpression表达的时间集合。 例如,您可以使用此日历使用表达式“* * 0-7,18-23?* *”每天排除所有营业时间(上午8点至下午5点)。 如果CronTrigger具有给定的cron表达式并且与具有相同表达式的CronCalendar相关联,则日历将排除触发器包含的所有时间,并且它们将彼此抵消。
DailyCalendar您可以使用此日历来排除营业时间(上午8点 - 5点)每天。 每个DailyCalendar仅允许指定单个时间范围,并且该时间范围可能不会跨越每日边界(即,您不能指定从上午8点至凌晨5点的时间范围)。 如果属性invertTimeRange为false(默认),则时间范围定义触发器不允许触发的时间范围。 如果invertTimeRange为true,则时间范围被反转 - 也就是排除在定义的时间范围之外的所有时间。
HolidayCalendar特别的用于从 Trigger 中排除节假日
MonthlyCalendar排除月份中的指定数天,例如,可用于排除每月的最后一天
WeeklyCalendar排除星期中的任意周几,例如,可用于排除周末,默认周六和周日
Scheduler

调度器,是Quartz的指挥官,由StdSchedulerFactory产生。它是单例的。

并且是Quartz中最重要的API,默认是实现类是StdScheduler,里面包含了一个QuartzScheduler。QuartzScheduler里面又包含了一个QuartzSchedulerThread。

image.png

Scheduler中的方法主要分为三大类:

1)操作调度器本身,例如调度器的启动start()、调度器的关闭shutdown()。

2)操作Trigger,例如pauseTriggers()、resumeTrigger()。

3)操作Job,例如scheduleJob()、unscheduleJob()、rescheduleJob()

这些方法非常重要,可以实现任务的动态调度。

Scheduler中的方法主要分为三大类:

1)操作调度器本身,例如调度器的启动start()、调度器的关闭shutdown()。

2)操作Trigger,例如pauseTriggers()、resumeTrigger()。

3)操作Job,例如scheduleJob()、unscheduleJob()、rescheduleJob()

这些方法非常重要,可以实现任务的动态调度。

Listener

我们有这么一种需求,在每个任务运行结束之后发送通知给运维管理员。那是不是要在每个任务的最后添加一行代码呢?这种方式对原来的代码造成了入侵,不利于维护。如果代码不是写在任务代码的最后一行,怎么知道任务执行完了呢?或者说,怎么监测到任务的生命周期呢?

观察者模式:定义对象间一种一对多的依赖关系,使得每当一个对象改变状态,则所有依赖它的对象都会得到通知并自动更新。

Quartz中提供了三种Listener,监听Scheduler的,监听Trigger的,监听Job的。

只需要创建类实现相应的接口,并在Scheduler上注册Listener,便可实现对核心对象的监听。

standalone工程:com.abc.listener

MyJobListenerTest

MySchedulerListenerTest

MyTriggerListenerTest

JobListener

四个方法:

方法作用或****执行实际
getName()返回JobListener 的名称
jobToBeExecuted()Scheduler 在 JobDetail 将要被执行时调用这个方法
jobExecutionVetoed()Scheduler 在 JobDetail 即将被执行,但又被 TriggerListener 否决了时调用这个方法
jobWasExecuted()Scheduler 在 JobDetail 被执行之后调用这个方法

工具类:ListenerManager,用于添加、获取、移除监听器

工具类:Matcher,主要是基于groupName和keyName进行匹配。

image.png

TriggerListener
方法作用或执行实际
getName()返回监听器的名称
triggerFired()Trigger 被触发,Job 上的 execute() 方法将要被执行时,Scheduler 就调用这个方法
vetoJobExecution()在 Trigger 触发后,Job 将要被执行时由 Scheduler 调用这个方法。TriggerListener 给了一个选择去否决 Job 的执行。假如这个方法返回 true,这个 Job 将不会为此次 Trigger 触发而得到执行
triggerMisfired()Trigger 错过触发时调用
triggerComplete()Trigger 被触发并且完成了 Job 的执行时,Scheduler 调用这个方法
SchedulerListener

方法比较多,省略。

JobStore

问题:最多可以运行多少个任务(磁盘、内存、线程数)

Jobstore用来存储任务和触发器相关的信息,例如所有任务的名称、数量、状态等等。Quartz中有两种存储任务的方式,一种在在内存,一种是在数据库。

RAMJobStore

Quartz默认的JobStore是RAMJobstore,也就是把任务和触发器信息运行的信息存储在内存中,用到了HashMap、TreeSet、HashSet等等数据结构。

如果程序崩溃或重启,所有存储在内存中的数据都会丢失。所以我们需要把这些数据持久化到磁盘。

JDBCJobStore

JDBCJobStore可以通过JDBC接口,将任务运行数据保存在数据库中。

image.png

JDBC的实现方式有两种,JobStoreSupport类的两个子类:

JobStoreTX:在独立的程序中使用,自己管理事务,不参与外部事务。

JobStoreCMT:(Container Managed Transactions (CMT),如果需要容器管理事务时,使用它。

使用JDBCJobSotre时,需要配置数据库信息:

org.quartz.jobStore.class:org.quartz.impl.jdbcjobstore.JobStoreTX
org.quartz.jobStore.driverDelegateClass:org.quartz.impl.jdbcjobstore.StdJDBCDelegate
\# 使用quartz.properties,不使用默认配置
org.quartz.jobStore.useProperties:true
\#数据库中quartz表的表名前缀
org.quartz.jobStore.tablePrefix:QRTZ_
org.quartz.jobStore.dataSource:myDS
\#配置数据源
org.quartz.dataSource.myDS.driver:com.mysql.jdbc.Driver
org.quartz.dataSource.myDS.URL:jdbc:mysql://localhost:3306/quartz?useUnicode=true&characterEncoding=utf8
org.quartz.dataSource.myDS.user:root
org.quartz.dataSource.myDS.password:123456
org.quartz.dataSource.myDS.validationQuery=select 0 from dual

问题来了?需要建什么表?表里面有什么字段?字段类型和长度是什么?

在官网的Downloads链接中,提供了11张表的建表语句:

quartz-2.2.3-distribution\quartz-2.2.3\docs\dbTables

2.3的版本在这个路径下:src\org\quartz\impl\jdbcjobstore

表名与作用:

表名作用
QRTZ_BLOB_TRIGGERSTrigger作为Blob类型存储
QRTZ_CALENDARS存储Quartz的Calendar信息
QRTZ_CRON_TRIGGERS存储CronTrigger,包括Cron表达式和时区信息
QRTZ_FIRED_TRIGGERS存储与已触发的Trigger相关的状态信息,以及相关Job的执行信息
QRTZ_JOB_DETAILS存储每一个已配置的Job的详细信息
QRTZ_LOCKS存储程序的悲观锁的信息
QRTZ_PAUSED_TRIGGER_GRPS存储已暂停的Trigger组的信息
QRTZ_SCHEDULER_STATE存储少量的有关Scheduler的状态信息,和别的Scheduler实例
QRTZ_SIMPLE_TRIGGERS存储SimpleTrigger的信息,包括重复次数、间隔、以及已触的次数
QRTZ_SIMPROP_TRIGGERS存储CalendarIntervalTrigger和DailyTimeIntervalTrigger两种类型的触发器
QRTZ_TRIGGERS存储已配置的Trigger的信息

4.Quartz集成到Spring

Spring-quartz工程

Spring在spring-context-support.jar中直接提供了对Quartz的支持。

image.png

可以在配置文件中把JobDetail、Trigger、Scheduler定义成Bean。

定义Job

<bean name="myJob1" class="org.springframework.scheduling.quartz.JobDetailFactoryBean">
  <property name="name" value="my_job_1"/>
  <property name="group" value="my_group"/>
  <property name="jobClass" value="com.abc.quartz.MyJob1"/>
  <property name="durability" value="true"/>
</bean>

定义Trigger

<bean name="simpleTrigger" class="org.springframework.scheduling.quartz.SimpleTriggerFactoryBean">   <property name="name" value="my_trigger_1"/>   <property name="group" value="my_group"/>   <property name="jobDetail" ref="myJob1"/>   <property name="startDelay" value="1000"/>   <property name="repeatInterval" value="5000"/>   <property name="repeatCount" value="2"/>
</bean>

定义Scheduler

<bean name="scheduler" class="org.springframework.scheduling.quartz.SchedulerFactoryBean">
  <property name="triggers">
    <list>
      <ref bean="simpleTrigger"/>
      <ref bean="cronTrigger"/>
    </list>
  </property>
</bean>

既然可以在配置文件配置,当然也可以用@Bean注解配置。在配置类上加上@Configuration让Spring读取到。

public class QuartzConfig {
  @Bean
  public JobDetail printTimeJobDetail(){
    return JobBuilder.newJob(MyJob1.class)
       .withIdentity("abcJob")
       .usingJobData("abc", "123")
       .storeDurably()
       .build();
 }
  @Bean
  public Trigger printTimeJobTrigger() {
    CronScheduleBuilder cronScheduleBuilder = CronScheduleBuilder.cronSchedule("0/5 * * * * ?");
    return TriggerBuilder.newTrigger()
       .forJob(printTimeJobDetail())
       .withIdentity("quartzTaskService")
       .withSchedule(cronScheduleBuilder)
       .build();
 }
}

运行 spring-quartz工程的com.abc.quartz.QuartzTest

5.动态调度的实现

springboot-quartz工程
传统的Spring方式集成,由于任务信息全部配置在xml文件中,如果需要操作任务或者修改任务运行频率,只能重新编译、打包、部署、重启,如果有紧急问题需要处理,会浪费很多的时间。
有没有可以动态调度任务的方法?比如停止一个Job?启动一个Job?修改Job的触发频率?
读取配置文件、写入配置文件、重启Scheduler或重启应用明显是不可取的。
对于这种频繁变更并且需要实时生效的配置信息,我们可以放到哪里?
ZK、Redis、DB tables。
并且,我们可以提供一个界面,实现对数据表的轻松操作。

配置管理

这里我们用最简单的数据库的实现。

问题1:建一张什么样的表?参考JobDetail的属性。

CREATE TABLE `sys_job` (
`id` INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`job_name` VARCHAR ( 512 ) NOT NULL COMMENT '任务名称',
`job_group` VARCHAR ( 512 ) NOT NULL COMMENT '任务组名',
`job_cron` VARCHAR ( 512 ) NOT NULL COMMENT '时间表达式',
`job_class_path` VARCHAR ( 1024 ) NOT NULL COMMENT '类路径,全类型',
`job_data_map` VARCHAR ( 1024 ) DEFAULT NULL COMMENT '传递map参数',
`job_status` INT ( 2 ) NOT NULL COMMENT '状态:1启用 0停用',
`job_describe` VARCHAR ( 1024 ) DEFAULT NULL COMMENT '任务功能描述',
PRIMARY KEY ( `id` ) 
) ENGINE = INNODB AUTO_INCREMENT = 25 DEFAULT CHARSET = utf8;

数据操作与任务调度

操作数据表非常简单,SSM增删改查。
但是在修改了表的数据之后,怎么让调度器知道呢?
	 调度器的接口:Scheduler

	 在我们的需求中,我们需要做的事情:
1、 新增一个任务
2、 删除一个任务
3、 启动、停止一个任务
4、 修改任务的信息(包括调度规律)

因此可以把相关的操作封装到一个工具类中。com.abc.demo.util.SchedulerUtil

前端界面

image.png

接下来我们有两个问题要解决:

容器启动与Service注入

容器启动

因为任务没有定义在ApplicationContext.xml中,而是放到了数据库中,Spring Boot启动时,怎么读取任务信息?
或者,怎么在Spring启动完成的时候做一些事情?
创建一个类,实现CommandLineRunner接口,实现run方法。
从表中查出状态是1的任务,然后构建。

Service类注入到Job中

Spring Bean如何注入到实现了Job接口的类中?
例如在TestTask3中,需要注入ISysJobService,查询数据库发送邮件。
如果没有任何配置,注入会报空指针异常。

原因:

因为定时任务Job对象的实例化过程是在Quartz中进行的,而Service Bean是由Spring容器管理的,Quartz察觉不到Service Bean的存在,所以无法将Service Bean装配到Job对象中。

分析:

Quartz集成到Spring中,用到SchedulerFactoryBean,其实现了InitializingBean方法,在唯一的方法afterPropertiesSet()在Bean的属性初始化后调用。
调度器用AdaptableJobFactory对Job对象进行实例化。所以,如果我们可以把这个JobFactory指定为我们自定义的工厂的话,就可以在Job实例化完成之后,把Job纳入到Spring容器中管理。

解决这个问题的步骤:

1、定义一个AdaptableJobFactory,实现JobFactory接口,实现接口定义的newJob方法,在这里面返回Job实例

image.png

2、定义一个MyJobFactory,继承AdaptableJobFactory。

使用Spring的AutowireCapableBeanFactory,把Job实例注入到容器中。

@Component
public class MyJobFactory extends AdaptableJobFactory {
  @Autowired
  private AutowireCapableBeanFactory capableBeanFactory;

  protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
    Object jobInstance = super.createJobInstance(bundle);
    capableBeanFactory.autowireBean(jobInstance);

    return jobInstance;
 }
}
3、指定Scheduler的JobFactory为自定义的JobFactory。
com.abc.demo.config.InitStartSchedule中:
scheduler.setJobFactory(myJobFactory);

考虑这么一种情况:

正在运行的Quartz节点挂了,而所有人完全不知情……

6.Quartz集群部署

springboot-quartz工程

为什么需要集群?

1、防止单点故障,减少对业务的影响

2、减少节点的压力,例如在10点要触发1000个任务,如果有10个节点,则每个节点之需要执行100个任务

集群需要解决的问题?

1、任务重跑,因为节点部署的内容是一样的,到10点的时候,每个节点都会执行相同的操作,引起数据混乱。比如跑批,绝对不能执行多次。
2、任务漏跑,假如任务是平均分配的,本来应该在某个节点上执行的任务,因为节点故障,一直没有得到执行。
3、水平集群需要注意时间同步问题
4、Quartz使用的是随机的负载均衡算法,不能指定节点执行
所以必须要有一种共享数据或者通信的机制。在分布式系统的不同节点中,我们可以采用什么样的方式,实现数据共享?
两两通信,或者基于分布式的服务,实现数据共享。
例如:ZK、Redis、DB。
在Quartz中,提供了一种简单的方式,基于数据库共享任务执行信息。也就是说,一个节点执行任务的时候,会操作数据库,其他的节点查询数据库,便可以感知到了。
同样的问题:建什么表?哪些字段?依旧使用系统自带的11张表。

集群配置与验证

quartz.properties配置。
四个配置:集群实例ID、集群开关、数据库持久化、数据源信息
注意先清空quartz所有表、改端口、两个任务频率改成一样
验证1:先后启动2个节点,任务是否重跑
验证2:停掉一个节点,任务是否漏跑

7.Quartz调度原理

问题:

1、Job没有继承Thread和实现Runnable,是怎么被调用的?通过反射还是什么?

2、任务是什么时候被调度的?是谁在监视任务还是监视Trigger?

3、任务是怎么被调用的?谁执行了任务?

4、任务本身有状态吗?还是触发器有状态?

看源码的入口

Scheduler scheduler = factory.getScheduler();
scheduler.scheduleJob(jobDetail, trigger);
scheduler.start();

获取调度器实例

读取配置文件
public Scheduler getScheduler() throws SchedulerException {
 if (cfg == null) {
   // 读取quartz.properties配置文件
   initialize();
}
 // 这个类是一个HashMap,用来基于调度器的名称保证调度器的唯一性
 SchedulerRepository schedRep = SchedulerRepository.getInstance();

 Scheduler sched = schedRep.lookup(getSchedulerName());
 // 如果调度器已经存在了
 if (sched != null) {
   // 调度器关闭了,移除
   if (sched.isShutdown()) {
     schedRep.remove(getSchedulerName());
  } else {
     // 返回调度器
     return sched;
  }
}

 // 调度器不存在,初始化
 sched = instantiate();

 return sched;
}

instantiate()方法中做了初始化的所有工作:

// 存储任务信息的JobStore
JobStore js = null;
// 创建线程池,默认是SimpleThreadPool
ThreadPool tp = null;
// 创建调度器
QuartzScheduler qs = null;
// 连接数据库的连接管理器
DBConnectionManager dbMgr = null;
// 自动生成ID
// 创建线程执行器,默认为DefaultThreadExecutor
ThreadExecutor threadExecutor;
创建线程池(包工头)

830行和839行,创建了一个线程池,默认是配置文件中指定的SimpleThreadPool。

String tpClass = cfg.getStringProperty(PROP_THREAD_POOL_CLASS, SimpleThreadPool.class.getName());
tp = (ThreadPool) loadHelper.loadClass(tpClass).newInstance();

SimpleThreadPool里面维护了三个list,分别存放所有的工作线程、空闲的工作线程和忙碌的工作线程。我们可以把SimpleThreadPool理解为包工头。

private List<WorkerThread> workers;
private LinkedList<WorkerThread> availWorkers = new LinkedList<WorkerThread>();
private LinkedList<WorkerThread> busyWorkers = new LinkedList<WorkerThread>();

tp的runInThread()方法是线程池运行线程的接口方法。参数Runnable是执行的任务内容。

取出WorkerThread去执行参数里面的runnable(JobRunShell)。

WorkerThread wt = (WorkerThread)availWorkers.removeFirst();
busyWorkers.add(wt);
wt.run(runnable);
WorkerThread(工人)
WorkerThread是SimpleThreadPool的内部类,用来执行任务。我们把WorkerThread理解为工人。在WorkerThread的run方法中,执行传入的参数runnable任务:
runnable.run();
创建调度线程(项目经理)

1321行,创建了调度器QuartzScheduler:

qs = new QuartzScheduler(rsrcs, idleWaitTime, dbFailureRetry);
在 QuartzScheduler的构造函数中,创建了QuartzSchedulerThread,我们把它理解为项目经理,它会调用包工头的工人资源,给他们安排任务。
并且创建了线程执行器schedThreadExecutor,执行了这个QuartzSchedulerThread,也就是调用了它的run方法。
// 创建一个线程,resouces里面有线程名称
this.schedThread = new QuartzSchedulerThread(this, resources); 
// 线程执行器
ThreadExecutor schedThreadExecutor = resources.getThreadExecutor(); 
//执行这个线程,也就是调用了线程的run方法
schedThreadExecutor.execute(this.schedThread);  

点开 QuartzSchedulerThread 类,找到run方法,这个是Quartz任务调度的核心方法:

  public void run() {
    int acquiresFailed = 0;
   // 检查scheuler是否为停止状态
    while (!halted.get()) {
      try {
        // check if we're supposed to pause...
        synchronized (sigLock) {
          // 检查是否为暂停状态
          while (paused && !halted.get()) {
            try {
              // wait until togglePause(false) is called...
              // 暂停的话会尝试去获得信号锁,并wait一会
              sigLock.wait(1000L);
           } catch (InterruptedException ignore) {
           }

            // reset failure counter when paused, so that we don't
            // wait again after unpausing
            acquiresFailed = 0;
         }

          if (halted.get()) {
            break;
         }
       }

        // wait a bit, if reading from job store is consistently
        // failing (e.g. DB is down or restarting)..
        // 从JobStore获取Job持续失败,sleep一下
        if (acquiresFailed > 1) {
          try {
            long delay = computeDelayForRepeatedErrors(qsRsrcs.getJobStore(), acquiresFailed);
            Thread.sleep(delay);
         } catch (Exception ignore) {
         }
       }
        // 从线程池获取可用的线程
        int availThreadCount = qsRsrcs.getThreadPool().blockForAvailableThreads();
        if(availThreadCount > 0) { // will always be true, due to semantics of blockForAvailableThreads...

          List<OperableTrigger> triggers;

          long now = System.currentTimeMillis();

          clearSignaledSchedulingChange();
          try {
            // 获取需要下次执行的triggers
            // idleWaitTime: 默认30s
            // availThreadCount:获取可用(空闲)的工作线程数量,总会大于1,因为该方法会一直阻塞,直到有工作线程空闲下来。
            // maxBatchSize:一次拉取trigger的最大数量,默认是1
            // batchTimeWindow:时间窗口调节参数,默认是0
            // misfireThreshold: 超过这个时间还未触发的trigger,被认为发生了misfire,默认60s
            // 调度线程一次会拉取NEXT_FIRETIME小于(now + idleWaitTime +batchTimeWindow),大于(now - misfireThreshold)的,min(availThreadCount,maxBatchSize)个triggers,默认情况下,会拉取未来30s、过去60s之间还未fire的1个trigger
            triggers = qsRsrcs.getJobStore().acquireNextTriggers(
                now + idleWaitTime, Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()), qsRsrcs.getBatchTimeWindow());
            // 省略…………

            // set triggers to 'executing'
            List<TriggerFiredResult> bndles = new ArrayList<TriggerFiredResult>();

            boolean goAhead = true;
            synchronized(sigLock) {
              goAhead = !halted.get();
           }
            if(goAhead) {
              try {
                // 触发Trigger,把ACQUIRED状态改成EXECUTING
                // 如果这个trigger的NEXTFIRETIME为空,也就是未来不再触发,就将其状态改为COMPLETE
                // 如果trigger不允许并发执行(即Job的实现类标注了@DisallowConcurrentExecution),则将状态变为BLOCKED,否则就将状态改为WAITING
                List<TriggerFiredResult> res = qsRsrcs.getJobStore().triggersFired(triggers);
                // 省略…………
                continue;
             }

           }
  
            // 循环处理Trigger
            for (int i = 0; i < bndles.size(); i++) {
              TriggerFiredResult result = bndles.get(i);
              TriggerFiredBundle bndle = result.getTriggerFiredBundle();
              Exception exception = result.getException();

              // 省略…………

              JobRunShell shell = null;
              try {
                // 根据trigger信息实例化JobRunShell(implements Runnable),同时依据JOB_CLASS_NAME实例化Job,随后我们将JobRunShell实例丢入工作线。
                shell = qsRsrcs.getJobRunShellFactory().createJobRunShell(bndle);
                shell.initialize(qs);
             } catch (SchedulerException se) {
                qsRsrcs.getJobStore().triggeredJobComplete(triggers.get(i), bndle.getJobDetail(), CompletedExecutionInstruction.SET_ALL_JOB_TRIGGERS_ERROR);
                continue;
             }
              // 执行JobRunShell的run方法
              if (qsRsrcs.getThreadPool().runInThread(shell) == false) {
                // 省略…………
JobRunShell的作用

JobRunShell instances are responsible for providing the ‘safe’ environment for Job s to run in, and for performing all of the work of executing the Job, catching ANY thrown exceptions, updating the Trigger with the Job’s completion code, etc.
A JobRunShell instance is created by a JobRunShellFactory on behalf of the QuartzSchedulerThread which then runs the shell in a thread from the configured ThreadPool when the scheduler determines that a Job has been triggered.

JobRunShell用来为Job提供安全的运行环境的,执行Job中所有的作业,捕获运行中的异常,在任务执行完毕的时候更新Trigger状态,等等。

JobRunShell实例是用JobRunShellFactory为QuartzSchedulerThread创建的,在调度器决定一个Job被触发的时候,它从线程池中取出一个线程来执行任务。

线程模型总结

**SimpleThreadPool:**包工头,管理所有WorkerThread

**WorkerThread:**工人,把Job包装成JobRunShell,执行

**QuartSchedulerThread:**项目经理,获取即将触发的Trigger,从包工头出拿到worker,执行Trigger绑定的任务

绑定JobDetail和Trigger

// 存储JobDetail和Trigger
resources.getJobStore().storeJobAndTrigger(jobDetail, trig);
// 通知相关的Listener
notifySchedulerListenersJobAdded(jobDetail);
notifySchedulerThread(trigger.getNextFireTime().getTime()); 
notifySchedulerListenersSchduled(trigger);

启动调度器

// 通知监听器
notifySchedulerListenersStarting();
if (initialStart == null) {
  initialStart = new Date();
  this.resources.getJobStore().schedulerStarted();   
  startPlugins();
} else {
  resources.getJobStore().schedulerResumed();
}
// 通知QuartzSchedulerThread不再等待,开始干活
schedThread.togglePause(false);
// 通知监听器
notifySchedulerListenersStarted();

源码总结

getScheduler方法创建线程池ThreadPool,创建调度器QuartzScheduler,创建调度线程QuartzSchedulerThread,调度线程初始处于暂停状态。
scheduleJob将任务添加到JobStore中。
scheduler.start()方法激活调度器,QuartzSchedulerThread从timeTrriger取出待触发的任务,并包装成TriggerFiredBundle,然后由	JobRunShellFactory 创建TriggerFiredBundle的执行线程JobRunShell, 调度执行通过线程池SimpleThreadPool去执行JobRunShell,	而JobRunShell执行的就是任务类的execute方法:job.execute(JobExecutionContext context)。

集群原理

基于数据库,如何实现任务的不重跑不漏跑?
问题1:如果任务执行中的资源是“下一个即将触发的任务”,怎么基于数据库实现这个资源的竞争?
问题2:怎么对数据的行加锁?

image.png

QuartzSchedulerThread第287行,获取下一个即将触发的Trigger
triggers = qsRsrcs.getJobStore().acquireNextTriggers(
调用 JobStoreSupport的acquireNextTriggers()方法,2793行
调用JobStoreSupport.executeInNonManagedTXLock()方法,3829行:
return executeInNonManagedTXLock(lockName, 
尝试获取锁,3843行:
transOwner = getLockHandler().obtainLock(conn, lockName);

下面有回滚和释放锁的语句,即使发生异常,锁同样能释放。

调用DBSemaphore的obtainLock()方法,103行

  public boolean obtainLock(Connection conn, String lockName)
    throws LockException {
    if (!isLockOwner(lockName)) {
      executeSQL(conn, lockName, expandedSQL, expandedInsertSQL);

调用StdRowLockSemaphore的executeSQL()方法,78行。

最终用JDBC执行SQL,语句内容是expandedSQL和expandedInsertSQL。

ps = conn.prepareStatement(expandedSQL);

问题:expandedSQL和expandedInsertSQL是一条什么SQL语句?似乎我们没有赋值?

在StdRowLockSemaphore的构造函数中,把定义的两条SQL传进去:

public StdRowLockSemaphore() {
 super(DEFAULT_TABLE_PREFIX, null, SELECT_FOR_LOCK, INSERT_LOCK);
}
public static final String SELECT_FOR_LOCK = "SELECT * FROM "
     + TABLE_PREFIX_SUBST + TABLE_LOCKS + " WHERE " + COL_SCHEDULER_NAME + " = " + SCHED_NAME_SUBST
     + " AND " + COL_LOCK_NAME + " = ? FOR UPDATE";

 public static final String INSERT_LOCK = "INSERT INTO "
   + TABLE_PREFIX_SUBST + TABLE_LOCKS + "(" + COL_SCHEDULER_NAME + ", " + COL_LOCK_NAME + ") VALUES (" 
   + SCHED_NAME_SUBST + ", ?)"; 

它调用了父类DBSemaphore的构造函数:

public DBSemaphore(String tablePrefix, String schedName, String defaultSQL, String defaultInsertSQL) {
   this.tablePrefix = tablePrefix;
   this.schedName = schedName;
   setSQL(defaultSQL);
   setInsertSQL(defaultInsertSQL);
}

在setSQL()和setInsertSQL()中为expandedSQL和 expandedInsertSQL赋值。

执行的SQL语句:

select * from QRTZ_LOCKS t where t.lock_name='TRIGGER_ACCESS' for update

在我们执行官方的建表脚本的时候,QRTZ_LOCKS表,它会为每个调度器创建两行数据,获取Trigger和触发Trigger是两把锁:

image.png

任务为什么重复执行

在我们的演示过程中,有多个调度器,任务没有重复执行,也就是默认会加锁,什么情况下不会上锁呢?
JobStoreSupport的executeInNonManagedTXLock()方法
如果lockName为空,则不上锁
if (lockName != null) {
       // If we aren't using db locks, then delay getting DB connection 
        // until after acquiring the lock since it isn't needed.
       if (getLockHandler().requiresConnection()) {
         conn = getNonManagedTXConnection();
       }
  
       transOwner = getLockHandler().obtainLock(conn, lockName);
     }
  
     if (conn == null) {
       conn = getNonManagedTXConnection();
     }
而上一步JobStoreSupport的acquireNextTriggers()方法,
1)如果acquireTriggersWithinLock=true或者batchTriggerAcquisitionMaxCount>1时,locaName赋值为LOCK_TRIGGER_ACCESS,此时获取Trigger会加锁。
2)否则,如果isAcquireTriggersWithinLock()值是false并且maxCount=1的话,lockName赋值为null,这种情况获取Trigger下不加锁。
public List<OperableTrigger> acquireNextTriggers(final long noLaterThan, final int maxCount, final long timeWindow)
   throws JobPersistenceException {
  
   String lockName;
   if(isAcquireTriggersWithinLock() || maxCount > 1) { 
     lockName = LOCK_TRIGGER_ACCESS;
   } else {
     lockName = null;
}
acquireTriggersWithinLock变量默认是false:
private boolean acquireTriggersWithinLock = false;
maxCount来自QuartzSchedulerThread:
triggers = qsRsrcs.getJobStore().acquireNextTriggers(
               now + idleWaitTime, Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()), qsRsrcs.getBatchTimeWindow());

getMaxBatchSize()来自QuartzSchedulerResources,代表Scheduler一次拉取trigger的最大数量,默认是1:

private int maxBatchSize = 1;

这个值可以通过参数修改,代表允许调度程序节点一次获取(用于触发)的触发器的最大数量,默认值是1。

org.quartz.scheduler.batchTriggerAcquisitionMaxCount=1

根据以上两个默认值,理论上在获取Trigger的时候不会上锁,但是实际上为什么没有出现频繁的重复执行问题?因为每个调度器的线程持有锁的时间太短了,单机的测试无法体现,而在高并发的情况下,有可能会出现这个问题。

QuartzSchedulerThread的triggersFired()方法:

List<TriggerFiredResult> res = qsRsrcs.getJobStore().triggersFired(triggers);
调用了JobStoreSupport的triggersFired()方法,接着又调用了一个triggerFired triggerFired(Connection conn, OperableTrigger trigger)方法:
如果Trigger的状态不是ACQUIRED,也就是说被其他的线程fire了,返回空。但是这种乐观锁的检查在高并发下难免会出现ABA的问题,比如线程A拿到的时候还是ACQUIRED状态,但是刚准备执行的时候已经变成了EXECUTING状态,这个时候就会出现重复执行的问题。
if (!state.equals(STATE_ACQUIRED)) {
  return null;
}

总结,如果:

如果设置的数量为1(默认值),并且使用JDBC JobStore(RAMJobStore不支持分布式,只有一个调度器实例,所以不加锁),则属性org.quartz.jobStore.acquireTriggersWithinLock应设置为true。否则不加锁可能会导致任务重复执行。

org.quartz.scheduler.batchTriggerAcquisitionMaxCount=1
org.quartz.jobStore.acquireTriggersWithinLock=true

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;