1. 引子
公司前期改用quartz做任务调度,一日的调度量均在两百万次以上。随着调度量的增加,突然开始出现job重复调度的情况,且没有规律可循。网上也没有说得较为清楚的解决办法,于是我们开始调试Quartz源码,并最终找到了问题所在。
如果没有耐性看完源码解析,可以直接拉到文章最末,有直接简单的解决办法。 注:本文中使用的quartz版本为2.3.0,且使用JDBC模式存储Job。
2. 准备
首先,因为本文是代码级别的分析文章,因而需要提前了解Quartz的用途和用法,网上还是有很多不错的文章,可以提前自行了解。
其次,在用法之外,我们还需要了解一些Quartz框架的基础概念:
1)Quartz把触发job,叫做fire。TRIGGER_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.java中obtainLock方法的具体实现:
我们通过调试查看expandedSQL和expandedInsertSQL这两个变量:
图3-3可以看出,obtainLock方法通过locks表的一个行锁(lockName确定)来保证callback方法的事务和线程安全。拿到锁后,obtainLock方法将lockName写入threadlocal。当然在releaseLock的时候,会将lockName从threadlocal中删除。
总而言之,executeInNonManagedTXLock()方法,保证了在分布式的情况,同一时刻,只有一个线程可以执行这个方法。