Quartz调度系统入门和调度高可用实现方案

** 版本:2.2.1 **

Hello world:

  • 调度器:
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
 scheduler.start();
  • 任务详情:任务体实现Job接口
JobDetail job = JobBuilder.newJob(MakeHtml.class)
                .withIdentity("job1", "group1")
                .build();
  • 触发器:
Trigger trigger = TriggerBuilder.newTrigger()
                .withIdentity("trigger1", "group1")
                .startNow()
                .withSchedule(CronScheduleBuilder.cronSchedule("0/2 * * * * ?"))
                .build();
  • 执行调度:
scheduler.scheduleJob(job, trigger);
  • 数据传输、参数传输:
//传入
job.getJobDataMap().put("FAVORITE_COLOR", "red");
//在执行线程获得
jobExecutionContext.getJobDetail().getJobDataMap();
//在该Map中嵌套传输Map可实现对象引用的传输,即实现实时对象参数传输,需要保证线程安全。
  • 多任务对象管理:
    Scheduler对象中保存了JobKey、TriggerKey等和对应的Job、Trigger的映射关系,可以通过该Map进行同对象复用和检索。

配置:classpath下的quartz.properties

触发器类型:ScheduleBuilder接口

  1. CalendarIntervalScheduleBuilder:
  • 通过指定对应日期的定时执行触发器
  1. CronScheduleBuilder:
  • cron表达式实现的定时执行
  1. DailyTimeIntervalScheduleBuilder:
  • 根据时间定时执行
  1. SimpleScheduleBuilder
  • 简单循环执行,设定执行次数,开始结束时间等

注解:

  • @PersistJobDataAfterExecution:执行完成把状态持久化保存
  • 目前发现没这个注解,JobDataMap中的数据依然还是在数据库中保存着,不明所以,可能这个注解的作用是在每次执行调度刷新一次数据保持数据库中的数据是最新的值吧。。。
  • @DisallowConcurrentExecution:Job对象多实例禁止并发执行
  • 就是当这个调度作业还没执行完成的时候,下一次的调度又到了,如果注解了表示不会再申请一个线程让两个Job并发执行,需要等上一次作业执行完成才串行的执行。

任务本身发生异常

  1. 再次尝试执行:
JobExecutionException e2 =
                new JobExecutionException(e);
            // this job will refire immediately
            e2.refireImmediately();
  1. 不再执行,所有该job的调度全部停止:
JobExecutionException e2 =
                new JobExecutionException(e);
            // Quartz will automatically unschedule
            // all triggers associated with this job
            // so that it does not run again
            e2.setUnscheduleAllTriggers(true);

监听器

  • 没难度,就是在各种事件或者生命周期过程中进行回调。
  • scheduler.getListenerManager()添加各式各样的监听器,主要有三种:JobListenerTriggerListenerSchedulerListener
  • 支持通过JobKey和TriggerKey定向对某个Job或Trigger进行专属监听。
  • JobListener
  • getName:获取监听器名字
  • jobToBeExecuted:job执行前
  • jobExecutionVetoed:job执行被触发器拒绝
  • jobWasExecuted:job执行完
  • TriggerListener
  • getName:获取监听器名字
  • triggerFired:触发器触发
  • vetoJobExecution:触发器执行拒绝Job,返回true就是拒绝
  • triggerMisfired:触发器发现MisFire
  • triggerComplete:触发器触发完成
  • SchedulerListener
  • jobScheduled:job调度完
  • jobUnscheduled:作业没被调度
  • triggerFinalized:有调度器被完全停止调度时
  • triggerPaused:单个触发器暂停
  • triggersPaused:全部触发器暂停
  • triggerResumed:单个触发器唤醒
  • triggersResumed:全部触发器唤醒
  • jobAdded:job添加
  • jobDeleted:job删除
  • jobPaused:单个job暂停
  • jobsPaused:全部job暂停
  • jobResumed:单个job唤醒
  • jobsResumed:全部job唤醒
  • schedulerError:调度出错
  • schedulerInStandbyMode:调度器正处于standby模式
  • schedulerStarted:调度器启动完成
  • schedulerStarting:调度器正在启动
  • schedulerShutdown:调度器关闭完成
  • schedulerShuttingdown:调度器正在关闭
  • schedulingDataCleared:调度器数据清除完成

持久化

  • RAMJobStore:将工作中的作业Job和调度触发器Trigger都存储在内存中,宕机都没了。
  • JobStoreX:将Job和Trigger都存储在数据库中,实例重启会自动扫描数据库恢复数据,可进行集群配置,详细请看下面的集群配置。

Misfire处理规则

  • 指的是不小心没调度时,对错过的调度次数如何处理的规则策略选择,情形如下:
  1. 比如调度器休眠了
  2. quartz集群全体宕机了再重启之后扫描表中数据得知之前的调度错失了就是这种情况

策略选择(指的是CronTrigger,而SimpleTrigger有其对应的策略,在这里不做探讨):

  • withMisfireHandlingInstructionDoNothing
——不触发立即执行
——等待下次Cron触发频率到达时刻开始按照Cron频率依次执行
  • 这是网上的说法,表达是正确的,我经过代码测试的结果是,调度刻度依然不变,就是很干脆地把错过的那些调度直接不管了
  • 调度刻度:指的是作业放入调度器之后通过cron表达式计算出的之后的每个需要调度的时间点组成的一段点线段,就如刻度尺上面的刻度,到达刻度点时就触发调度。
  • withMisfireHandlingInstructionIgnoreMisfires
——以错过的第一个频率时间立刻开始执行
——重做错过的所有频率周期后
——当下一次触发频率发生时间大于当前时间后,再按照正常的Cron频率依次执行
  • 这是网上说法,意思很明了,就是指错过的那些调度都会全部重新执行一遍,但是需要注意的是,如果错过的调度数量很多,这一大堆的调度也是在发现misfire的之后的短时间内一次性全部完成的,然后接着按照调度刻度进行执行。这时候在调度任务内部获取的两个时间:fireTime和scheduleFireTime,fireTime指的是misfire发现之后重新调度的实际时间,scheduleFireTime指的是调度刻度上的基准时间,比如我有个本来应该在12:12:12执行的作业,但是发生misfire或者failover了,重启之后根据策略把错过的任务重新执行,这时候这个任务的实际调度时间可能为12:20:20,所有你如果有些任务的执行是需要依赖于标准的调度时间的(比如每隔一小时dump数据库的数据一次,应该获取的时间戳是scheduleFireTime而不是fireTime),这点要注意。
  • withMisfireHandlingInstructionFireAndProceed
——以当前时间为触发频率立刻触发一次执行
——然后按照Cron频率依次执行
  • 这是网上说法,说的是可能misfire错过了一堆任务,这里只在发现misfire的时候补偿性地调度一次该任务,接下来还是按照调度刻度执行。
  • 特别注意!!!网上有的说法是调度刻度会在这种策略下平移,如下:16:00要执行的调度,结果misfire了,到16:15才恢复,网上的说法是16:15会调度一次,然后刻度往后移,下一次调度会在17:15发生,但是!!!我的代码测试结果却是:16:15确实会调用一次,这是策略控制结果,下一次的调度时间是17:00,调度刻度并没有变!!!所以说这个策略和第二个策略其实是类似的,只不过第二个策略是把错过的全部调度一次,这个是只调度一次,而且是用恢复的这一瞬间作为scheduleFireTime。

SimpleTrigger有一堆的另外的MisFire机制,这里先不做讨论,以后有机会再更新,如下:
withMisfireHandlingInstructionFireNowwithMisfireHandlingInstructionIgnoreMisfireswithMisfireHandlingInstructionNextWithExistingCountwithMisfireHandlingInstructionNowWithExistingCountwithMisfireHandlingInstructionNextWithRemainingCountwithMisfireHandlingInstructionNowWithRemainingCountMISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT

执行中Job可获取的时间

这些时间是在某一次的调度作业的作业执行过程中可以获取到的时间戳。

  • PreviousFireTime:当前调度的上一次调度的时间戳。
  • ScheduledFireTime:当前调度的基准调度刻度中的时间戳,就是任务一开始调度就算出来的未来一系列的调度刻度。
  • FireTime:当前调度的实际调度时间戳,通常和ScheduledFireTime一致,但是如果发生misFire或者Fail-Over就可能和ScheduledFireTime不一致。
  • NextFireTime:下一次调度的基准刻度时间。

集群

  • 集群通过故障切换和负载平衡的功能,能给调度器带来高可用性和伸缩性。目前集群只能工作在JDBC-JobStore(JobStore TX或者JobStoreCMT)方式下,从本质上来说,是使集群上的每一个节点通过共享同一个数据库来工作的(Quartz通过启动两个维护线程来维护数据库状态实现集群管理,一个是检测节点状态线程,一个是恢复任务线程)。
  • 负载平衡是自动完成的,集群的每个节点会尽快触发任务。当一个触发器的触发时间到达时,第一个节点将会获得任务(通过锁定),成为执行任务的节点。
  • 故障切换的发生是在当一个节点正在执行一个或者多个任务失败的时候。当一个节点失败了,其他的节点会检测到并且标 识在失败节点上正在进行的数据库中的任务。任何被标记为可恢复(任务详细信息的"requests recovery"属性)的任务都会被其他的节点重新执行。没有标记可恢复的任务只会被释放出来,将会在下次相关触发器触发时执行。

实验结论

  • 简单来说就是多个quartz节点共同访问同一个数据库来保证各个节点的调度信息同步,后台有守护线程实时同步节点内存和数据库中的信息同步

  • 一个节点宕机,mysql数据没丢失,重启后从mysql读取恢复内存信息还原宕机前状态

  • 一个被调度的任务由哪个节点执行调度?所有节点去抢mysql表中一个分布式锁(悲观),谁抢到了就谁执行当前任务

  • 宕机切换:我测试在一台机器上启动4个quartz实例模拟集群,其中把一个抢到锁的实例kill掉,quartz会自动切换另一个实例继续执行剩下的调度

  • quartz能够保证一个作业在cron表达式作用下的一次调度不重不漏,以及宕机调度任务重新分配,但是当一个Job被quartz正确调度了,在Job内部逻辑过程中出错抛异常了、或者此时宕机了,那这个Job在quartz系统中其实是已执行状态,因为的确正确调度了,只不过调度执行的Job本身内部出错了,quartz对Job内部异常也有相应的方案,上面有说,但是在作业平台调度系统设计过程中觉得quartz本身提供的job异常机制不够可靠,对此进行了这方面的高可用拓展,详细请看Jobs作业平台的调度系统设计方案

  • 配置:

#集群名称和id
org.quartz.scheduler.instanceName = MyClusteredScheduler
org.quartz.scheduler.instanceId = AUTO
#线程池
org.quartz.threadPool.class = org.quartz.simpl.SimpleThreadPool
org.quartz.threadPool.threadCount = 25
org.quartz.threadPool.threadPriority = 5
#misfire检测时间
org.quartz.jobStore.misfireThreshold = 60000
#jobStore配置和数据表前缀等基础配置
org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX
org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.oracle.OracleDelegate
org.quartz.jobStore.useProperties = false
org.quartz.jobStore.dataSource = myDS
org.quartz.jobStore.tablePrefix = QRTZ
#集群模式和集群节点间活性检测临界时间
org.quartz.jobStore.isClustered = true
org.quartz.jobStore.clusterCheckinInterval = 20000
#database jdbs配置
org.quartz.dataSource.myDS.driver = oracle.jdbc.driver.OracleDriver
org.quartz.dataSource.myDS.URL = jdbc:oracle:thin:@cluster:1521:dev
org.quartz.dataSource.myDS.user = quartz
org.quartz.dataSource.myDS.password = quartz
org.quartz.dataSource.myDS.maxConnections = 5
org.quartz.dataSource.myDS.validationQuery=select 0 from dual

Fail-Over容灾机制

  • 系统崩溃、某个节点宕机的情况下,其他节点自动主备替换的机制,整个机制了高可用Hadoop的主备切换思路也是类似的,主宕机就备挣锁选举。
  • Fail-Over机制工作在集群环境中,执行recovery工作的线程类叫做ClusterManager,该线程类同样是在调度器初始化时就开启运行了。这个线程类在运行期间每15s进行一次check in操作,所谓check in,就是在数据库的QRTZ2_SCHEDULER_STATE表中更新该调度器对应的LAST_CHECKIN_TIME字段为当前时间,并且查看其他调度器实例的该字段有没有发生停止更新的情况,如果检查到有调度器的check in time比当前时间要早约15s(视具体的执行预配置情况而定),那么就判定该调度实例需要recover,随后会启动该调度器的recovery机制,获取目标调度器实例正在触发的trigger,并针对每一个trigger临时添加一各对应的仅执行一次的simpletrigger。等到调度流程扫描trigger时,这些trigger会被触发,这样就成功的把这些未完整执行的调度以一种特殊trigger的形式纳入了普通的调度流程中,只要调度流程在正常运行,这些被recover的trigger就会很快被触发并执行。
    就是这个机制,使用了SimpleTrigger导致了上面的fireTime和scheduleFireTime可能不同的情况。

负载均衡

  • Quartz集群自动支持节点间任务调度的负载均衡。
  • 由于自身实现调度集群分布式锁、节点数据同步,因此部署好Quartz集群之后就自然而然实现了宕机自动节点切换,服务器压力大直接横向拓展也能迅速应对短时间内的业务爆发。

缺点

Quartz自身提供了对任务调度本身的不重不漏的高可用保证,但是一个任务确实被Quartz正确调度之后呢,Quartz系统中的记录已经标记为这个任务执行完成了,但是这个任务在执行过程中出错了,怎么办?

  • Quartz自身提供了两套策略:
  1. 任务失败不再重试
  2. 任务失败自行重试
  • 这两套方案粒度太粗了,或者说任务自动重试再次失败呢?还要接着重试吗?总而言之就是在我做的统计作业平台对调度要求不重不漏(其实允许重,但是不允许漏),并且要支持在重试次数上限下的失败重试,并且需要对作业平台中的作业调度失败原因做出不同的错误处理策略。

我的方案:

  • 任务调度高可用 —— 依然依靠Quartz集群提供
  • 任务调度了,在任务执行过程中出错,这段处理逻辑中的出错处理,我来控制,策略如下:
  1. 任务失败自动重试,到达重试上线设置失败
  2. 开机重启扫描任务列表,把正在调度状态的作业进行开机恢复
  3. 任务调度前后记录start和end状态位日志(Quartz监听器实现),后台定时扫描start和end的对应关系,对不对应的任务进行恢复,这步执行概率极低,是在极端情况下的调度任务丢失采取的最后的保障措施
  • 下面详细说明我的任务调度高可用方案实现过程

一个Job的整个执行过程分解

  • (1). 记录start日志
  • (2). 调度记录表插入记录之前的数据准备
  • (3). 调度记录表查询本次调度记录,状态ready
  • (4). 准备本次作业流调度执行需要的相关数据,并设置状态位scheduling
  • (5). 调用作业流执行的dubbo服务,延时操作,等待调度结果
  • (6).
  • 成功:状态位success
  • 失败:设置为retrying,等待守护线程扫描之后重试
  • 不支持的调度:直接设置fail,告警
  • (7). 记录日志end

quartz系统在Jobs平台下的高可用保证的改造方案

  1. 系统启动:守护线程调度(jobs-schedule-daemon)、恢复线程调度(jobs-schedule-recovery)这两个是系统分组中的调度
  • jobs-schedule-daemon负责三分钟扫描一次调度记录表中的retrying状态的记录,并将其添加新的调度来重新执行,添加的调度在jobs-retry分组,该线程后台死循环重复执行
  • jobs-schedule-recovery是宕机恢复调度,在系统宕机重启之后自动扫描调度记录表中的ready和scheduling两种状态的记录,将其重新执行调度,分组为jobs-recovery,之后恢复线程结束
  1. 之后按照各个作业调度器cron表达式正确调度
  2. 作业各个状态高可用保证:
  • 步骤(3):ready状态,宕机重启会进行恢复
  • 步骤(4):scheduling状态,宕机重启会进行恢复
  • 步骤(5):retrying状态,守护线程每隔3分钟扫描一次进行重试调度
  • 步骤(6):success和fail状态,属于最终状态,已完成
  • 以上的步骤基于调度记录表对应调度记录存在的情况下保证高可用
  1. 步骤(1)和(2)是在没有MySQL表记录的情况下,要保证高可用计划通过前后的start和end日志对称对比来实现宕机作业恢复,该步骤除了mysql连接以外都是内存计算,宕机可能性极地,并且这几个步骤没有mysql表中对应的一条记录为依托,因此只能依靠任务调度前后的start和end状态日志进行任务丢失恢复。

参考

推荐阅读更多精彩内容