Quartz中CronExpression的“BUG”
准确说,是Quartz自实现的Cron解析器对标准Cron表达式的兼容性问题。
项目比较旧,没有用到当下流行的spring-cloud体系,也没有诸如eureka、nacos这类的配置中心,配置类信息一般是使用定时器从DB中加载到内存,定时任务中间件使用的是Quartz。此前也有在用,本次的需求是想增加一个某类配置的加载任务,规则是每半小时执行一次,因此开发人员仿照之前的定时器复制了一个,调整了CronExpression为“ * */30 * * * ?”,但根据日志观察,定时任务重复执行了50多次。
环境
spring 4.3.22.RELEASE
quartz 2.2.1
IDE IDEA 2018.1
代码
<bean id="xxxTaskFactoryBean" class="org.springframework.scheduling.quartz.SchedulerFactoryBean">
<property name="triggers">
<list>
<ref local="xxxConfigSyncTask"/>
</list>
</property>
</bean>
<!-- 定时任务定义 -->
<bean id="xxxConfigSyncTask" class="org.springframework.scheduling.quartz.CronTriggerFactoryBean" lazy-init="false">
<property>
<bean class="org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean">
<property name="targetObject">
<ref bean="xxxConfigSyncTaskTarget"></ref>
</property>
<property name="targetMethod">
<value>runTask</value>
</property>
</bean>
</property>
<property name="cronExpression">
<value>* */30 * * * ?</value>
</property>
</bean>
上述定义的含义是创建一个每30分钟执行一次的定时任务,实际执行体是xxxConfigSyncTaskTarget这个bean的runTask方法。但实际效果是到30min的时候,任务就会隔1s执行一次。
原因分析
Quartz的基本单位是QuartzScheduler,即上述xml配置中定义的xxxTaskFactoryBean,其中可管理多个trigger(即真实的定时任务),SchedularFactory在初始化QuartzScheduler时,同时会创建一个worker线程池,大小默认是10(在SchedulerFactoryBean中DEFAULT_THREAD_COUNT,可通过配置修改),用于实际执行定时任务。QuartzScheduler在初始化时会创建一个QuartzSchedulerThread作为调度线程,其作用就是调度trigger到worker线程池中去执行。上图描述的就是QuartzSchedulerThread在调度时的核心逻辑:
- 获取worker线程池的可用线程
- 根据当前时间计算接下来要触发的trigger
- 获取trigger的下次触发时间,计算需要等待的时间并进行等待,随后继续执行
可以看出,关键点其实就在于acquireNextTriggers和getNextFireTime这两个方法,而我们遇到的问题是任务每隔1s重复执行,那么极有可能问题点就是getNextFireTime出问题了。
通过上图可以发现getNextFireTime获取的nextFireTime是在上次执行任务后就计算好的,即图中的triggered()——>getFireTimeAfter()——>getTimeAfter()。其中getTimeAfter方法如下:
protect Date getTimeAfter(Date afterTime){
return cronEx==null?null:cronEx.getTimeAfter(afterTime);
}
其中变量cronEx是Quartz中类CronExpression的实例,不是jdk的,因此怀疑CronExpression的计算结果可能有问题,实际试验了一下发现果然如此。具体测试结果如下:
new CronExpression(“* */30 * * * ?”).getTimeAfter(DateUtils.from(“2022-11-19 15:30:00”));
结果为:2022-11-19 15:30:01,并非期望的2022-11-19 16:00:00
new CronExpression(“* */30 * * * ?”).getTimeAfter(DateUtils.from(“2022-11-19 15:31:01”));
结果为:2022-11-19 16:00:00
new CronExpression(“0 */30 * * * ?”).getTimeAfter(DateUtils.from(“2022-11-19 15:30:00”));
结果为:2022-11-19 16:00:00
new CronExpression(“0 */30 * * * ?”).getTimeAfter(DateUtils.from(“2022-11-19 15:31:01”));
结果为:2022-11-19 16:00:00
new CronExpression(“0 */31 * * * ?”).getTimeAfter(DateUtils.from(“2022-11-19 15:31:01”));
结果为:2022-11-19 16:00:00
结论
这里省略其他试验,最后可以发现,Quartz的CronExpression对于Cron表达式的支持,是不太完全的,当设置分钟、小时甚至更大时间段的定时任务时,低级别占位符上都要设置0才可以,让CronExpression以低级别的0时刻为基准去计算,这样当时间到fireTime任务执行过后计算下一次执行时间时,才能够正常递增,否则就会出现递增1s的情况。这也符合根据日志观察到的现象,当到30分0秒时,任务执行,计算出的nextFireTime为30分01秒,然后30分01秒时,任务执行,计算出的nextFireTime为30分02秒,循环往复,直到31分00秒时,任务执行后,计算出的nextFireTime才变成下一个整点。
另外,最后一个实验也表明,Quartz的CronExpression,是不支持不能整除最大范围数的,比如31不能整除60,那么31分时去计算nextFireTime,就是下一小时的00分,而非02分。
修改方法
将“ * */30 * * * ?”调整为“ 0 */30 * * * ?”
改动很简单,但是找到问题原因更重要,特别是这个问题是框架产生的,需要根据源码去调试。