关于java:坑爹Quartz-重复调度问题你遇到过么

5次阅读

共计 7073 个字符,预计需要花费 18 分钟才能阅读完成。

作者:Lavender\
起源:https://segmentfault.com/a/11…

1. 引子

公司后期改用 quartz 做任务调度,一日的调度量均在两百万次以上。随着调度量的减少,忽然开始呈现 job 反复调度的状况,且没有法则可循。网上也没有说得较为分明的解决办法,于是咱们开始调试 Quartz 源码,并最终找到了问题所在。

如果没有耐性看完源码解析,能够间接拉到文章最末,有间接简略的解决办法。
注:本文中应用的 quartz 版本为 2.3.0,且应用 JDBC 模式存储 Job。

2. 筹备

首先,因为本文是代码级别的剖析文章,因此须要提前理解 Quartz 的用处和用法,网上还是有很多不错的文章,能够提前自行理解。

其次,在用法之外,咱们还须要理解一些 Quartz 框架的根底概念:

1)Quartz 把触发 job,叫做 fireTRIGGER_STATE 是以后 trigger 的状态,PREV_FIRE_TIME是上一次触发工夫,NEXT_FIRE_TIME是下一次触发工夫,misfire是指这个 job 在某一时刻要触发,却因为某些起因没有触发的状况。

2)Quartz 在运行时,会起两类线程(不止两类),一类用于调度 job 的调度线程(单线程),一类是用于执行 job 具体业务的工作池。

3)Quartz 自带的表外面,本文次要波及以下 3 张表:

  • triggers 表。triggers 表里记录了,某个 trigger 的 PREV_FIRE_TIME(上次触发工夫),NEXT_FIRE_TIME(下一次触发工夫),TRIGGER_STATE(以后状态)。虽未尽述,然而本文用到的只有这些。
  • locks 表。Quartz 反对分布式,也就是会存在多个线程同时抢占雷同资源的状况,而 Quartz 正是依赖这张表,解决这种情况,至于如何做到,参见 3.1。
  • fired_triggers 表,记录正在触发的 triggers 信息。

4)TRIGGER_STATE,也就是 trigger 的状态,次要有以下几类:

trigger 的初始状态是 WAITING,处于WAITING 状态的 trigger 期待被触发。调度线程会不停地扫 triggers 表,依据 NEXT_FIRE_TIME 提前拉取行将触发的 trigger,如果这个 trigger 被该调度线程拉取到,它的状态就会变为ACQUIRED

因为是提前拉取 trigger,并未达到 trigger 真正的触发时刻,所以调度线程会等到真正触发的时刻,再将 trigger 状态由 ACQUIRED 改为EXECUTING

如果这个 trigger 不再执行,就将状态改为COMPLETE, 否则为WAITING,开始新的周期。如果这个周期中的任何环节抛出异样,trigger 的状态会变成ERROR。如果手动暂停这个 trigger,状态会变成PAUSED

3. 开始排查

3.1 分布式状态下的数据拜访

前文提到,trigger 的状态贮存在数据库,Quartz 反对分布式,所以如果起了多个 quartz 服务,会有多个调度线程来争夺触发同一个 trigger。mysql 在默认状况下执行 select 语句,是不上锁的,那么如果同时有 1 个以上的调度线程抢到同一个 trigger,是否会导致这个 trigger 反复调度呢?咱们来看看,Quartz 是如何解决这个问题的。

首先,咱们先来看下 JobStoreSupport 类的 executeInNonManagedTXLock() 办法:

这个办法的官网介绍:

/**

*Execute the given callback having acquired the given lock.

*Depending on the JobStore,the surrounding transaction maybe

*assumed to be already present(managed).

*

*@param lockName The name of the lock to acquire,for example

*"TRIGGER_ACCESS".If null, then no lock is acquired ,but the

*lockCallback is still executed in a transaction.

*/

也就是说,传入的 callback 办法在执行的过程中是携带了指定的锁,并开启了事务,正文也提到,lockName 就是指定的锁的名字,如果 lockName 是空的,那么 callback 办法的执行不在锁的爱护下,但仍然在事务中。

这意味着,咱们应用这个办法,不仅能够保障事务,还能够抉择保障,callback 办法的线程平安。

接下来,咱们来看一下 executeInNonManagedTXLock(…) 中的 obtainLock(conn,lockName) 办法,即抢锁的过程。这个办法是在 Semaphore 接口中定义的,Semaphore接口通过锁住线程或者资源,来爱护资源不被其余线程批改,因为咱们的调度信息是存在数据库的,所以当初查看 DBSemaphore.javaobtainLock办法的具体实现:

咱们通过调试查看 expandedSQLexpandedInsertSQL这两个变量:

图 3 - 3 能够看出,obtainLock办法通过 locks 表的一个行锁(lockName 确定)来保障 callback 办法的事务和线程平安。拿到锁后,obtainLock办法将 lockName 写入 threadlocal。当然在releaseLock 的时候,会将 lockNamethreadlocal中删除。

总而言之,executeInNonManagedTXLock()办法,保障了在分布式的状况,同一时刻,只有一个线程能够执行这个办法。

3.2 quartz 的调度过程

QuartzSchedulerThread是调度线程的具体实现,图 3 -4 是这个线程 run() 办法的次要内容,图中只提到了失常的状况下,也就是流程中没有出现异常的状况下的处理过程。由图能够看出,调度流程次要分为以下三步:

1)拉取待触发 trigger:

调度线程会一次性拉取间隔当初,肯定工夫窗口内的,肯定数量内的,行将触发的 trigger 信息。那么,工夫窗口和数量信息如何确定呢,咱们先来看一下,以下几个参数:

  • idleWaitTime:默认 30s,可通过配置属性 org.quartz.scheduler.idleWaitTime 设置。
  • availThreadCount:获取可用(闲暇)的工作线程数量,总会大于 1,因为该办法会始终阻塞,直到有工作线程闲暇下来。
  • maxBatchSize:一次拉取 trigger 的最大数量,默认是 1,可通过 org.quartz.scheduler.batchTriggerAcquisitionMaxCount 改写
  • batchTimeWindow:工夫窗口调节参数,默认是 0,可通过 org.quartz.scheduler.batchTriggerAcquisitionFireAheadTimeWindow 改写
  • misfireThreshold:超过这个工夫还未触发的 trigger, 被认为产生了 misfire, 默认 60s,可通过 org.quartz.jobStore.misfireThreshold 设置。

调度线程一次会拉取 NEXT_FIRE_TIME 小于(now + idleWaitTime +batchTimeWindow), 大于(now - misfireThreshold)的,min(availThreadCount,maxBatchSize)个 triggers,默认状况下,会拉取将来 30s,过来 60s 之间还未 fire 的 1 个 trigger。随后将这些 triggers 的状态由 WAITING 改为ACQUIRED,并插入 fired_triggers 表。

2)触发 trigger:

首先,咱们会查看每个 trigger 的状态是不是 ACQUIRED,如果是,则将状态改为EXECUTING,而后更新 trigger 的NEXT_FIRE_TIME,如果这个 trigger 的NEXT_FIRE_TIME 为空,也就是将来不再触发,就将其状态改为COMPLETE。如果 trigger 不容许并发执行(即 Job 的实现类标注了@DisallowConcurrentExecution),则将状态变为BLOCKED,否则就将状态改为WAITING

3)包装 trigger,丢给工作线程池:

遍历 triggers,如果其中某个 trigger 在第二步出错,即返回值外面有 exception 或者为 null,就会做一些 triggers 表,fired_triggers 表的内容修改,跳过这个 trigger,持续查看下一个。否则,则依据 trigger 信息实例化 JobRunShell(实现了 Thread 接口),同时根据JOB_CLASS_NAME 实例化 Job,随后咱们将JobRunShell 实例丢入工作线。

JobRunShellrun()办法,Quartz 会在执行 job.execute() 的前后告诉之前绑定的监听器,如果 job.execute() 执行的过程中有异样抛出,则执行后果 jobExEx 会保留异样信息,反之如果没有异样抛出,则 jobExEx 为 null。而后依据 jobExEx 的不同,失去不同的执行指令instCode

JobRunShell将 trigger 信息,job 信息和执行指令传给 triggeredJobComplete() 办法来实现最初的数据表更新操作。例如如果 job 执行过程有异样抛出,就将这个 trigger 状态变为 ERROR,如果是BLOCKED 状态,就将其变为 WAITING 等等,最初从 fired_triggers 表中删除这个曾经执行实现的 trigger。留神,这些是在工作线程池异步实现。

3.3 排查问题

在前文,咱们能够看到,Quartz 的调度过程中有 3 次(可选的)上锁行为,为什么称为可选?因为这三个步骤尽管在 executeInNonManagedTXLock 办法的爱护下,但 executeInNonManagedTXLock 办法能够通过设置传入参数 lockName 为空,勾销上锁。在翻阅代码时,咱们看到第一步拉取待触发的 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;}
    return executeInNonManagedTXLock(lockName,
                                     new TransactionCallback<List<OperableTrigger>>(){public List<OperableTrigger> execute(Connection conn) throws JobPersistenceException {return acquireNextTrigger(conn, noLaterThan, maxCount, timeWindow);
        }
    }, new TransactionValidator<List<OperableTrigger>>() {// 省略});
}

在加锁之前对 lockName 做了一次判断,而非像其余加锁办法一样,默认传入的就是LOCK_TRIGGER_ACCESS

public List<TriggerFiredResult> triggersFired(final List<OperableTrigger> triggers) throws JobPersistenceException {
    // 默认上锁
    return executeInNonManagedTXLock(LOCK_TRIGGER_ACCESS,
        new TransactionCallback<List<TriggerFiredResult>>() {// 省略},new TransactionValidator<List<TriggerFiredResult>>() {// 省略});
}

通过调试发现 isAcquireTriggersWithinLock() 的值是false,因此导致传入的 lockName 是 null。我在代码中退出日志,能够更分明的看到这个过程。

由图 3 - 5 能够分明看到,在拉取待触发的 trigger 时,默认是不上锁。如果这种默认配置有问题,岂不是会频繁产生反复调度的问题?而事实上并没有,起因在于 Quartz 默认采取乐观锁,也就是容许多个线程同时拉取同一个 trigger。咱们看一下 Quartz 在调度流程的第二步 fire trigger 的时候做了什么,留神此时是上锁状态:

protected TriggerFiredBundle triggerFired(Connection conn, OperableTrigger trigger)
    throws JobPersistenceException {
    JobDetail job;
    Calendar cal = null;
    // Make sure trigger wasn't deleted, paused, or completed...
    try { // if trigger was deleted, state will be STATE_DELETED
        String state = getDelegate().selectTriggerState(conn,trigger.getKey());
         if (!state.equals(STATE_ACQUIRED)) {return null;}
    } catch (SQLException e) {
            throw new JobPersistenceException("Couldn't select trigger state: "
                    + e.getMessage(), e);
    }

调度线程如果发现以后 trigger 的状态不是ACQUIRED,也就是说,这个 trigger 被其余线程 fire 了,就会返回 null。在 3.2,咱们提到,在调度流程的第三步,如果发现某个 trigger 第二步的返回值是 null,就会跳过第三步,勾销 fire。在通常的状况下,乐观锁能保障不产生反复调度,然而不免产生 ABA 问题,咱们看一下这是产生反复调度时的日志:

在第一步时,也就是 quartz 在拉取到符合条件的 triggers 到将他们的状态由 WAITING 改为 ACQUIRED 之间进展了有超过 9ms 的工夫,而另一台服务器正是趁着这 9ms 的空档实现了WAITING–>ACQUIRED–>EXECUTING–>WAITING(也就是一个残缺的状态变动周期)的全副过程,图示参见图 3 -6。

3.4 解决办法

如何去解决这个问题呢?在配置文件加上org.quartz.jobStore.acquireTriggersWithinLock=true,这样,在调度流程的第一步,也就是拉取待行将触发的 triggers 时,是上锁的状态,即不会同时存在多个线程拉取到雷同的 trigger 的状况,也就防止的反复调度的危险。

3.5 心得

此次排查过程并非一帆风顺,走过一些坑,也有一些非技术相干的领会:

1)学习是一个须要一直打磨,修改的能力。就我集体而言,为了学 Quartz,刚开始去翻一个 2.4MB 大小的源码是毫无脉络,并且效率低下的,所以立即转换方向,先理解这个框架的运行模式,在做什么,有哪些模块,是怎么做的,再找主线,翻相干的源码。之后在一次次应用中,碰到问题再翻之前没看的源码,就越来越顺利。

之前也听过其余共事的学习办法,感觉并不齐全适宜本人,可能每个人状态教训不同,学习办法也稍有不同。在平时的学习中,须要去感触本人的学习效率,参考倡议,尝试,感触成果,改良,会越来越清晰本人适宜什么。这里很感激我的师父,用简短的话先帮我捋顺了调度流程,这样我再看源码就不那么吃力了。

2)要质疑“教训”和“理所应当”,惯性思维会蒙住你的双眼。在大规模的代码中很容易被习惯蛊惑,一开始,咱们看到上锁的那个办法的时候,认为这个上锁技巧很棒,这个办法就是为了解决并发的问题,“应该”都上锁了,上锁了就不会有并发的问题了,怎么可能几次与数据库的交互都上锁,忽然某一次不上锁呢?直到看到拉取待触发的 trigger 办法时,感觉有丝丝不对劲,打下日志,才发现实际上是没上锁的。

3)日志很重要。尽管咱们能够调试,然而没有日志,咱们是无奈发现并证实,程序产生了 ABA 问题。

4)最重要的是,不要胆怯问题,即便是 Quartz 这样大型的框架,解决问题也不肯定须要把 2.4MB 的源码统统读懂。只有有工夫,问题都能解决,只是好的技巧能缩短这个工夫,而咱们须要在一次次实战中磨难技巧。

近期热文举荐:

1.1,000+ 道 Java 面试题及答案整顿(2022 最新版)

2. 劲爆!Java 协程要来了。。。

3.Spring Boot 2.x 教程,太全了!

4. 别再写满屏的爆爆爆炸类了,试试装璜器模式,这才是优雅的形式!!

5.《Java 开发手册(嵩山版)》最新公布,速速下载!

感觉不错,别忘了顺手点赞 + 转发哦!

正文完
 0