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

代码

  1. <bean id="xxxTaskFactoryBean" class="org.springframework.scheduling.quartz.SchedulerFactoryBean">
  2. <property name="triggers">
  3. <list>
  4. <ref local="xxxConfigSyncTask"/>
  5. </list>
  6. </property>
  7. </bean>
  8. <!-- 定时任务定义 -->
  9. <bean id="xxxConfigSyncTask" class="org.springframework.scheduling.quartz.CronTriggerFactoryBean" lazy-init="false">
  10. <property>
  11. <bean class="org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean">
  12. <property name="targetObject">
  13. <ref bean="xxxConfigSyncTaskTarget"></ref>
  14. </property>
  15. <property name="targetMethod">
  16. <value>runTask</value>
  17. </property>
  18. </bean>
  19. </property>
  20. <property name="cronExpression">
  21. <value>* */30 * * * ?</value>
  22. </property>
  23. </bean>

上述定义的含义是创建一个每30分钟执行一次的定时任务,实际执行体是xxxConfigSyncTaskTarget这个bean的runTask方法。但实际效果是到30min的时候,任务就会隔1s执行一次。

原因分析

QuartzScheduler

Quartz的基本单位是QuartzScheduler,即上述xml配置中定义的xxxTaskFactoryBean,其中可管理多个trigger(即真实的定时任务),SchedularFactory在初始化QuartzScheduler时,同时会创建一个worker线程池,大小默认是10(在SchedulerFactoryBean中DEFAULT_THREAD_COUNT,可通过配置修改),用于实际执行定时任务。QuartzScheduler在初始化时会创建一个QuartzSchedulerThread作为调度线程,其作用就是调度trigger到worker线程池中去执行。上图描述的就是QuartzSchedulerThread在调度时的核心逻辑:

  1. 获取worker线程池的可用线程
  2. 根据当前时间计算接下来要触发的trigger
  3. 获取trigger的下次触发时间,计算需要等待的时间并进行等待,随后继续执行

可以看出,关键点其实就在于acquireNextTriggers和getNextFireTime这两个方法,而我们遇到的问题是任务每隔1s重复执行,那么极有可能问题点就是getNextFireTime出问题了。

CronTriggerImpl

通过上图可以发现getNextFireTime获取的nextFireTime是在上次执行任务后就计算好的,即图中的triggered()——>getFireTimeAfter()——>getTimeAfter()。其中getTimeAfter方法如下:

  1. protect Date getTimeAfter(Date afterTime){
  2. return cronEx==null?null:cronEx.getTimeAfter(afterTime);
  3. }

其中变量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 * * * ?
改动很简单,但是找到问题原因更重要,特别是这个问题是框架产生的,需要根据源码去调试。