作者: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开发手册(嵩山版)》最新公布,速速下载!

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