在微服务环境下,定时任务也需要独立为一个服务。这里使用spring+quartz搭建定时任务开发环境。
在Config加载quartz.properties配置文件时,本地环境因为资源文件我们都存放在项目resource下,可使用ClassPathResource去拿到资源文件。可是在集成、测试、生产环境下,一般会把配置文件都拿出来统一放在项目外的一个文件中,而ClassPathResource会从项目根目录下开始查找资源,于是会拿不到项目外的quartz.properties,导致定时任务执行可能会与预期结果不一致,尤其是在集群环境中。读取资源文件可采用PathResouce读取配置文件的绝对路径。
我们将调度信息存储在mysql中,按照quartz规范在数据库建立QRTZ_JOB_DETAILS,QRTZ_TRIGGERS等共11张表。建表sql可在quartz发型包中/docs/dbTables里看到。配置
org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX
org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.StdJDBCDelegate
org.quartz.jobStore.tablePrefix = QRTZ_
quartz.properties有几个配置可以注意下。集群的配置
org.quartz.jobStore.isClustered = true 开启集群特性
org.quartz.jobStore.clusterCheckinInterval = 20000 设置Scheduler实例节点检测频率,节点出现问题会被发现
org.quartz.jobStore.misfireThreshold = 60000 设置定时任务失火阈值,当前时间超过原定执行时间若是在阈值之内,就可以执行
新建一个任务表用于存放我们配置要执行的任务信息quartz_config表,任务(组)名,触发器(组)名,执行类,cron表达式等,可以在前端页面对任务管理。在进行周期性任务状态变化检测时,需要取quartz_config内的值来进行判断。这里使用实现SchedulingConfigurer接口来完成动态定时任务
/**
* 执行定时任务.
*/
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.addTriggerTask(
//1.添加任务内容(Runnable)
() -> quartzManager.chickJobs(),
//2.设置执行周期(Trigger)
triggerContext -> {
//2.1 从数据库获取执行周期
String cron = env.getProperty("job.cron");
//2.2 合法性校验.
if (StringUtils.isEmpty(cron)) {
cron="0/5 * * * * ?";
}
//2.3 返回执行周期(Date)
return new CronTrigger(cron).nextExecutionTime(triggerContext);
}
);
}
如图,配置每分钟检测一次任务状态变化,检测功能在quartzManager.chickJobs()中实现。目前我们设置Job的状态有启动,暂停,删除,删除就逻辑删除,页面不展示,在quartz_config中以status字段来标示。我们点击启动任务后,做的操作就将status置为1,此时虽然显示已启动,可实际上是还未注册实例的,然后等待下一次任务状态检测,取到status为1的任务数据,然后使用scheduler.checkExists()检测当前scheduler实例是否已经存在该job,如果已经存在,则获取当前job实例的Cron表达式,判断任务的触发时间是否有变化,若有,则更新触发器
// 触发器
TriggerBuilder triggerBuilder = TriggerBuilder.newTrigger();
// 触发器名,触发器组
triggerBuilder.withIdentity(triggerName, triggerGroupName);
triggerBuilder.startNow();
// 触发器时间设定
triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(cron).withMisfireHandlingInstructionDoNothing());
// 创建Trigger对象
trigger = (CronTrigger) triggerBuilder.build();
// 方式一 :修改一个任务的触发时间
sched.rescheduleJob(triggerKey, trigger);
否则添加一个job实例
log.info("===============add Job:{}===============",jobName);
Scheduler sched = schedulerFactory.getScheduler();
// 任务名,任务组,任务执行类
JobDetail jobDetail= JobBuilder.newJob(jobClass).withIdentity(jobName, jobGroupName).build();
// 触发器
TriggerBuilder triggerBuilder = TriggerBuilder.newTrigger();
// 触发器名,触发器组
triggerBuilder.withIdentity(triggerName, triggerGroupName);
triggerBuilder.startNow();
// 触发器时间设定
triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(cron).withMisfireHandlingInstructionDoNothing());
// 创建Trigger对象
CronTrigger trigger = (CronTrigger) triggerBuilder.build();
// 调度容器设置JobDetail和Trigger
sched.scheduleJob(jobDetail, trigger);
// 启动
if (!sched.isShutdown()) {
sched.start();
}
动态检测时对暂停和删除的Job的处理逻辑是,先取出quartz_config中status不等于1的数据,然后判断scheduler中是否存在该Job,存在就证明该job还是已注册的状态,就将该job从调度器中移除。
Scheduler sched = schedulerFactory.getScheduler();
TriggerKey triggerKey = TriggerKey.triggerKey(triggerName, triggerGroupName);
log.info("===============remove Job:{}===============",jobName);
sched.pauseTrigger(triggerKey);// 停止触发器
sched.unscheduleJob(triggerKey);// 移除触发器
sched.deleteJob(JobKey.jobKey(jobName, jobGroupName));// 删除任务
在集群多节点时,动态检测管理job状态,还需要做进一步控制,之前就因为没做控制,在本地时,就一台服务器,一直运行无误,找原因找了许久。假设集群中的两台服务器同时执行了任务检测逻辑,此时有一个任务点击启动(status=1),正在等待检测逻辑开始运行添加进实例(即insert进JOB_DETAILS,TRIGGERS等表),两台服务器同时拿到了这条待添加的job,必定有一台服务器先将任务实例持久化到数据库,另一台服务器在执行sched.scheduleJob(jobDetail,trigger)时,执行到底层storeJob方法时,就会报出ObjectAlreadyExistsException异常
if (existingJob) {
if (!replaceExisting) {
throw new ObjectAlreadyExistsException(newJob);
}
this.getDelegate().updateJobDetail(conn, newJob);
} else {
this.getDelegate().insertJobDetail(conn, newJob);
}
我们的处理方法是,在quartz_config表中新增一个process_status字段,来标示当前任务处理状态,1为待处理,2为处理中,3为处理完成,保证同时只有一个节点能执行该添加修改操作。quartz_config数据初始化改状态为1,暂停或修改Cron,都会讲process_status置为1,因为它是发生变化待检测逻辑处理的。这样的话,如上例,两节点同时执行下来,先到的一个会将process_status更新为2(处理中)。
UPDATE plms_quartz_config SET process_status = #{processStatus,jdbcType=VARCHAR} WHERE job_name = #{jobName,jdbcType=VARCHAR} AND process_status = '1'
更新成功则返回result = 1,只有result = 1的时候才会执行接下来的添加修改操作。当前处理状态已经从1变成2了,因为where条件的限制,另一节点到此已经更新不到改状态了,所以返回result = 0,就不会再一次addJob或addTirgger,避免对象已存在异常。
有些情况下,到了指定时间才触发某个任务的执行可能满足不了需求,我们需要能手动触发一个任务立即执行来完成有些特殊情况。立即触发一个任务,我判断了当前正在执行中的任务不能立即执行。在任务执行中,该任务真正触发时间到了,需要执行,会导致任务重复执行,job类上因加上@DisallowConcurrentExecution防止任务重复执行(集群都需要)。解决重复执行了,可是任务可能会因为misfire失火机制在空闲时间或者下个轮询周期补偿此次的错失执行。看业务需要,配置失火策略,此处防止只执行一次的任务多次执行,我选择了失火之后忽略该任务不做补偿执行,实现方法是在rescheduleJob或scheduleJob之前设置触发器时,如下:
riggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(cron).withMisfireHandlingInstructionDoNothing());
此处手动触发立即执行任务
//执行任务
JobDetail job = JobBuilder.newJob((Class extends Job>) Class.forName(performJobReqDTO.getJobClassName())).withIdentity(jobKey).storeDurably().build();
scheduler.addJob(job, true);
scheduler.triggerJob(jobKey);
log.info("job:{}任务已手动触发",JSON.toJSONString(jobKey));
又遇到了个坑。此处立即执行任务triggerJob触发后,会在JOB_DETAILS,TRIGGERS,SIMPLE_TRIGGERS表存入数据,触发完后会删除TRIGGER的数据,如若此时该任务正好处于刚点击了启动但是还未注册的情况,或者点立即执行马上又点击启动,因为立即执行导致JobDetail已经有该job的数据,任务状态检测的时候就不会将该任务新注册进去,导致只有jobDetail,但缺失触发器,该任务就永远不会执行。处理方法为,若任务处理状态为非处理完成,在立即执行触发后,清除该任务CronTrigger,SimpleTrigger,Trigger,JobDetail,当动态检测执行时,就能正常注册任务触发器。
又有个坑,立即执行触发(scheduler.triggerJob(jobKey))后删除那几张表,可能会导致job实例不执行,任务触发成功,但是实际任务没跑,怀疑是triggerJob(jobKey)方法内部执行,开启一个线程后还需要去查表拿数据,清楚太快导致没拿到数据,就没跑成功,具体原因没仔细研究,我在此处的解决方法是删除之前让线程睡一秒,确保任务能正常执行。
本次也是初次对集群轮询环境做这些大致的构建,做一下遇到的问题记录笔记。当然以上处理方式还有很多更好更严谨的处理方式有待优化。