前言
相信大家對Quartz這框架並不陌生,日常工作經常會接觸到,我們團隊也在使用。但是我發現大家在工作中對其僅停留在簡單配置使用層面,很多時候發生問題,並不知道它問題root cause
是什麼,配置引數也是隨便在網上copy回來亂用,並不是基於專案實際情況。自從前幾年開始做技術管理後,工作期間也沒多少時間可以在一線擼碼,剛好趁週末時間重新把原始碼看了一遍整理下,希望對大家有幫助!PS:本文基於Quartz2.3.0,不會介紹如何使用Quartz,完全沒有接觸過Quartz的朋友建議先閱讀官方文件。
常見問題
-
Quartz的核心元件?
-
Quartz的核心執行機制?
-
Quartz的執行緒模型
-
Quartz叢集程式間如何通訊?
-
Quartz叢集如何保證高併發下不重複跑?
-
Quartz如何保證不漏跑
-
Quartz預設任務鎖機制?
-
Quartz常見問題
Quartz的核心元件
JobDetail
我們建立一個實現 Job
介面的類,使用 JobBuilder
包裝成 JobDetail
,它可以攜帶 KV 的資料,方面使用者可以擴充套件自己任務要用的引數。
Trigger
定義任務的觸發規則,使用 TriggerBuilder
來構建。
為什麼JobDetail和Trigger是一對多的關係
因為通常我們一個任務實際上是有多種觸發規則的,例如:我想我的跑批任務週一9點跑一次,週三5點跑一起,它實際上是屬於同一個Job,只是不同的觸發規則,這時候我們就可以定義多個Trigger組合起來用。
Set<Trigger> triggersForJob = new HashSet();
triggersForJob.add(trigger);
triggersForJob.add(trigger1);
// 繫結關係是1:N
scheduler.scheduleJob(jobDetail, triggersForJob,true);
常見的Tigger型別
介面 | 描述 | 特點 |
---|---|---|
SimpleTrigger | 簡單觸發器 | SimpleTrigger 可以定義固定時刻或者固定時間間隔的排程規則(精確到毫秒) 例如:每天 9 點鐘執行;每隔 30 分鐘執行一次 |
CalendarIntervalTrigger | 基於日曆的觸發器 | CalendarIntervalTrigger 可以定義更多時間單位的排程需求,精確到秒 好處是不需要去計算時間間隔,比如 1 個小時等於多少毫秒 例如每年、每個月、每週、每天、每小時、每分鐘、每秒 每年的月數和每個月的天數不是固定的,這種情況也適用 |
DailyTimeIntervalTrigger | 基於日期的觸發器 | 每天的某個時間段 例如:每天早上 9 點到晚上 9 點,每隔半個小時執行一次,並且只在週一到週六執行。 |
CronTrigger | 基於 Cron 表示式的觸發器 | 可以支援任意時間(推薦) 如:0/10 * * * * ? |
怎麼排除掉一些日期不觸發
比較常見的需求是週末不計息、節假日不觸發郵件通知
如果要在觸發器的基礎上,排除一些時間區間不執行任務,就要用到 Quartz 的 Calendar
類(注意不是 JDK 的 Calendar
)。可以按年、月、周、日、特定日期、Cron 表示式
排除
使用方法
-
呼叫排程器的
addCalendar()
方法註冊排除規則 -
呼叫
Trigger
的modifiedByCalendar()
新增到觸發器中
//排除營業時間
scheduler.addCalendar("workingHours",new CronCalendar("* * 0-7,18-23?* *”"),false,false);
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("trigger1", "group1")
.startNow()
.modifiedByCalendar("workingHours") //排除時間段
.withSchedule(SimpleScheduleBuilder.simpleSchedule()
.withIntervalInSeconds(2)
.repeatForever())
.build();
Scheduler
排程器,是 Quartz 的指揮官,由
StdSchedulerFactory
產生,它是單例的,並且是 Quartz 中最重要的 API,預設是實現類是StdScheduler
,裡面包含了一個QuartzScheduler
。QuartzScheduler
裡面又包含了一個QuartzSchedulerThread
。
Scheduler 中的方法主要分為三大類:
-
操作排程器本身,例如排程器的啟動
start()
、排程器的關閉shutdown()
。 -
操作
Trigger
,例如pauseTriggers()
、resumeTrigger()
。 -
操作
Job
,例如scheduleJob()
、unscheduleJob()
、rescheduleJob()
這些方法非常重要,可以實現任務的動態排程。
Listener
事件監聽器。Quartz框架採用觀察者模式設計,可以無入侵式地讓使用者可以收到對應的通知。提供三種型別監聽器,分別是
SchedulerListener
(監聽 Scheduler 的),TriggerListener
(監聽 Trigger 的),JobListener
(監聽 Job 的)
場景
- 任務完成了,發郵件給對應的人。例如:跑批完成了,我想系統自動給我發一個郵件通知
- 監控任務整個生命週期。例如:作為一箇中央分散式排程器需要通過
Webhook
或者MQ
觸發多個服務,想監控每個任務的執行情況,是否有遺漏
工具類:ListenerManager
,用於新增、獲取、移除監聽器
工具類:Matcher
,主要是基於 groupName
和 keyName
進行匹配。
JobStore
Jobstore 用來儲存任務和觸發器相關的資訊,例如所有任務的名稱、數量、狀態等等。Quartz 中有兩種儲存任務的方式,一種在在記憶體,一種是在資料庫。
RAMJobStore
Quartz 預設
的 JobStore
是 RAMJobstore
,也就是把任務和觸發器資訊執行的資訊儲存在記憶體中,用到了 HashMap
、TreeSet
、HashSet
等等資料結構。
如果程式崩潰或重啟,所有儲存在記憶體中的資料都會丟失。所以我們需要把這些數 據持久化到磁碟。
JDBCJobStore
JDBCJobStore
可以通過 JDBC 介面,將任務執行資料儲存在資料庫中。
JDBC 的實現方式有兩種,JobStoreSupport
類的兩個子類:
-
JobStoreTX
:在獨立的程式中使用,自己管理事務,不參與外部事務。 -
JobStoreCMT
:(Container Managed Transactions (CMT),如果需要容器管理事 務時,使用它。
Quartz的核心執行機制
以上只是梳理了Quartz的核心流程,列舉了一些核心元件,通過一下幾個方法作為原始碼入口:
// Scheduler
Scheduler scheduler = factory.getScheduler();
// 繫結關係是1:N
scheduler.scheduleJob(jobDetail, trigger);
scheduler.start();
從上圖可以看到,Quartz的核心流程大致分為三個階段:
- 獲取排程例項階段
- 通過
getScheduler
方法根據配置檔案載入配置和初始化,建立執行緒池ThreadPool
(預設是SimpleThreadPool
,用來執行Quartz
排程任務),建立排程器QuartzScheduler
,建立排程執行緒QuartzSchedulerThread
,並將排程執行緒初始狀態設定為暫停狀態。
- 通過
- 繫結
JobDetail
和Trigger
階段Scheduler
將任務新增到JobStore
中,如果是使用資料庫儲存資訊,這時候會把任務持久化到Quartz
核心表中,同時也會對實現JobListener
的監聽者通知任務已新增
- 啟動排程器階段
Scheduler
會呼叫QuartzScheduler
的Start()
方法,這時候會把排程執行緒從暫停切為啟動狀態,通知QuartzSchedulerThread
正式幹活。QuartzSchedulerThread
會從SimpleThreadPool
檢視下有多少可用工作執行緒,然後找JobStore
去拿下一批符合條件的待觸發的Trigger
任務列表,包裝成FiredTriggerBundle
。通過JobRunShellFactory
建立FiredTriggerBundle
的執行執行緒例項JobRunShell
,然後把JobRunShell
例項交給SimpleThreadPool
的工作執行緒去執行。SimpleThreadPool
會從可用執行緒佇列拿出對應數量的執行緒,去呼叫JobRunShell
的run()
方法,此時會執行任務類的execute
方法 :job.execute(JobExecutionContext context)
。
獲取排程例項階段
載入配置和初始化排程器
StdSchedulerFactory.getScheduler
public Scheduler getScheduler() throws SchedulerException {
if (cfg == null) {
//載入quartz.properties 配置檔案
initialize();
}
//排程倉庫裡維護著一個HashMap<String, Scheduler>,這裡使用單例是為了全域性共享
SchedulerRepository schedRep = SchedulerRepository.getInstance();
//實際上是從HashMap<String, Scheduler>裡查詢Scheduler,保證了排程器名稱必須是唯一
Scheduler sched = schedRep.lookup(getSchedulerName());
//如果排程器已經存在
if (sched != null) {
if (sched.isShutdown()) {
//假如排程器是關閉狀態,則從排程倉庫的HashMap移除
schedRep.remove(getSchedulerName());
} else {
return sched;
}
}
//排程器不存在則要進行初始化
sched = instantiate();
return sched;
}
StdSchedulerFactory.instantiate
對排程器進行初始化工作
private Scheduler instantiate() throws SchedulerException {
//...省略...
//儲存任務資訊的 JobStore
JobStore js = null;
//執行緒池,預設是SimpleThreadPool
ThreadPool tp = null;
//核心排程器
QuartzScheduler qs = null;
//資料庫聯結器
DBConnectionManager dbMgr = null;
//ID生成器,用來自動生成唯一的instance id
String instanceIdGeneratorClass = null;
//執行緒執行器,預設為 DefaultThreadExecutor
ThreadExecutor threadExecutor;
//...省略...
建立執行緒池(SimpleThreadPool)
StdSchedulerFactory.instantiate
這裡建立了執行緒池,預設是配置檔案指定的SimpleThreadPool
//從配置中獲取執行緒池類名,如果沒,預設選用SimpleThreadPool作為執行緒池
String tpClass = cfg.getStringProperty(PROP_THREAD_POOL_CLASS, SimpleThreadPool.class.getName());
if (tpClass == null) {
initException = new SchedulerException(
"ThreadPool class not specified. ");
throw initException;
}
try {
//反射建立執行緒池
tp = (ThreadPool) loadHelper.loadClass(tpClass).newInstance();
} catch (Exception e) {
initException = new SchedulerException("ThreadPool class '"
+ tpClass + "' could not be instantiated.", e);
throw initException;
}
SimpleThreadPool
此時SimpleThreadPool在建立過程中,會初始化三個列表:
workers
(總工作執行緒佇列):存放所有的工作執行緒availWorkers
(可用工作執行緒佇列) :存放可用於做任務的工作執行緒busyWorkers
(繁忙工作執行緒佇列):存放已經佔用的工作執行緒
private List<WorkerThread> workers;
private LinkedList<WorkerThread> availWorkers = new LinkedList<WorkerThread>();
private LinkedList<WorkerThread> busyWorkers = new LinkedList<WorkerThread>();
初始化執行緒池
StdSchedulerFactory.instantiate
在該方法下面有一行對該執行緒池進行初始化
if(tp instanceof SimpleThreadPool) {
if(threadsInheritInitalizersClassLoader)
((SimpleThreadPool)tp).setThreadsInheritContextClassLoaderOfInitializingThread(threadsInheritInitalizersClassLoader);
}
//呼叫執行緒池初始化方法
tp.initialize();
SimpleThreadPool.initialize
在該方法裡,會開始建立工作執行緒(WorkerThread),用於後面的任務執行,真正執行任務的是WorkerThread
的run()
方法
//根據使用者配置檔案設定的執行緒數,來建立對應數量的工作執行緒
Iterator<WorkerThread> workerThreads = createWorkerThreads(count).iterator();
while(workerThreads.hasNext()) {
WorkerThread wt = workerThreads.next();
//啟用每個工作執行緒
wt.start();
//放在可用執行緒佇列等待被使用
availWorkers.add(wt);
}
建立核心排程器QuartzScheduler
StdSchedulerFactory.instantiate
這裡建立核心排程器
//這裡建立核心排程器,並且把QuartzSchedulerResources排程資源資訊和idleWaitTime(排程器空閒等待的時間量)傳進去,預設30秒
qs = new QuartzScheduler(rsrcs, idleWaitTime, dbFailureRetry);
QuartzScheduler.QuartzScheduler
建立排程器時,會對排程器的成員變數進行初始化,這裡還會建立排程執行緒QuartzSchedulerThread
,它會負責把任務分配給執行緒池裡的工作執行緒執行
public QuartzScheduler(QuartzSchedulerResources resources, long idleWaitTime, @Deprecated long dbRetryInterval)
//...省略...
//建立排程執行緒,resouces 裡面有執行緒名稱
this.schedThread = new QuartzSchedulerThread(this, resources);
//建立執行緒執行器 ,預設是DefaultThreadExecutor
ThreadExecutor schedThreadExecutor = resources.getThreadExecutor();
//這裡執行緒執行器會呼叫QuartzSchedulerThread的run()方法
schedThreadExecutor.execute(this.schedThread);
//...省略...
}
QuartzSchedulerThread.QuartzSchedulerThread
排程執行緒在例項化的時候,會把排程執行緒控制變數paused=ture
,是把排程執行緒暫停處理任務,halted=false
是要把排程執行緒開始監聽排程器控制變數paused
,就是讓排程執行緒開始執行但是不處理任務,等待被喚醒,下一步會提到
QuartzSchedulerThread(QuartzScheduler qs, QuartzSchedulerResources qsRsrcs, boolean setDaemon, int threadPrio) {
//...省略...
// start the underlying thread, but put this object into the 'paused'
// state
// so processing doesn't start yet...
paused = true;
halted = new AtomicBoolean(false);
}
QuartzSchedulerThread.run
上面提到,排程執行緒會被schedThreadExecutor
執行,此時由於halted
被設定為false
,paused
設定為true,此時排程執行緒run()方法並不會向下處理任務,等待被啟用,這裡會等到後面Scheduler
呼叫start()
才會真正被啟用
public void run() {
int acquiresFailed = 0;
//這裡!halted.get() = true,因此會向下執行
while (!halted.get()) {
try {
//sigLock是排程執行緒內的一個成員變數,用於控制執行緒併發
synchronized (sigLock) {
// 檢查是否為暫停狀態,此時paused && !halted.get() =false,會在這裡迴圈等待,不會往下執行
while (paused && !halted.get()) {
try {
//暫停狀態時,嘗試去獲得訊號鎖,使當前執行緒等待直到另一個執行緒呼叫,超時時間是1秒
sigLock.wait(1000L);
} catch (InterruptedException ignore) {
}
// 暫停時重置失敗計數器,這樣我們就不會取消暫停後再次等待
acquiresFailed = 0;
}
//這裡為false,因此會直接跳出迴圈,不會向後執行任務
if (halted.get()) {
break;
}
//...省略...
}
繫結JobDetail和Trigger階段
執行作業排程
StdScheduler.scheduleJob
public Date scheduleJob(JobDetail jobDetail, Trigger trigger)
throws SchedulerException {
//這裡實際呼叫的是QuartzScheduler
return sched.scheduleJob(jobDetail, trigger);
}
QuartzScheduler.scheduleJob
public Date scheduleJob(JobDetail jobDetail,
Trigger trigger) throws SchedulerException {
//...省略...
//持久化JobDetail和trigger
resources.getJobStore().storeJobAndTrigger(jobDetail, trig);
//通知scheduler監聽者
notifySchedulerListenersJobAdded(jobDetail);
notifySchedulerThread(trigger.getNextFireTime().getTime());
notifySchedulerListenersSchduled(trigger);
return ft;
}
啟動排程器階段
呼叫排程器啟動方法
StdScheduler.start
StdScheduler
只是代理類,實際上還是呼叫QuartzScheduler
public void start() throws SchedulerException {
//呼叫QuartzScheduler.start()方法
sched.start();
}
通知排程執行緒開始幹活
QuartzScheduler.start
public void start() throws SchedulerException {
//...省略...
//通知Scheduler監聽者任務開始啟動
notifySchedulerListenersStarting();
//第一次啟動,這裡initialStart為空
if (initialStart == null) {
initialStart = new Date();
//這裡將恢復任何失敗或誤觸發的作業並根據需要清理資料儲存,錯過的任務會在這裡重跑
this.resources.getJobStore().schedulerStarted();
startPlugins();
} else {
//如果initialStart不為空,意味著之前已經做過初始化,則把排程器狀態恢復成執行中
resources.getJobStore().schedulerResumed();
}
//這裡實際上讓排程執行緒QuartzSchedulerThread開始執行任務,前面有提到排程執行緒雖然已經啟用,但是由於Pause為true,因此它沒辦法處理任務,實際處於停止狀態
schedThread.togglePause(false);
getLog().info(
"Scheduler " + resources.getUniqueIdentifier() + " started.");
//通知Scheduler監聽者任務已經啟動
notifySchedulerListenersStarted();
}
QuartzSchedulerThread.togglePause
//切換暫停狀態
void togglePause(boolean pause) {
synchronized (sigLock) {
paused = pause;
if (paused) {
//如果暫停,這裡是要中斷任何可能發生的睡眠,等待著被喚醒
signalSchedulingChange(0);
} else {
//喚醒在此物件監視器上等待的所有執行緒。
sigLock.notifyAll();
}
}
}
排程執行緒正式開始執行任務
QuartzSchedulerThread.run
這裡由於上面一步已經把pause切換成false,因此排程執行緒的run()方法可以開始處理任務
//...省略...
//由於pause已經被切換成flase,這裡會跳出迴圈,執行緒會往下繼續執行
while (paused && !halted.get()) {
try {
// wait until togglePause(false) is called...
sigLock.wait(1000L);
} catch (InterruptedException ignore) {
}
acquiresFailed = 0;
}
//...省略...
// 獲取執行緒池可用執行緒數量
int availThreadCount = qsRsrcs.getThreadPool().blockForAvailableThreads();
//可用執行緒數量>0才往下執行
if(availThreadCount > 0) {
List<OperableTrigger> triggers;
long now = System.currentTimeMillis();
clearSignaledSchedulingChange();
try {
// 獲取需要下次執行的 triggers
// idleWaitTime: 預設 30s
// availThreadCount:獲取可用(空閒)的工作執行緒數量,總會大於 1,因為該方法會一直阻塞, 直到有工作執行緒空閒下來。
// maxBatchSize:一次拉取 trigger 的最大數量,預設是 1
// batchTimeWindow:時間視窗調節引數,預設是 0
// misfireThreshold: 超過這個時間還未觸發的 trigger,被認為發生了 misfire,預設 60s
// 排程執行緒一次會拉取 NEXT_FIRETIME 小於(now + idleWaitTime +batchTimeWindow),大 於(now - misfireThreshold)的,min(availThreadCount,maxBatchSize)個 triggers,預設情況下,會拉取未來 30s、 過去 60s 之間還未 fire 的 1 個 trigger
triggers = qsRsrcs.getJobStore().acquireNextTriggers(
now + idleWaitTime, Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()), qsRsrcs.getBatchTimeWindow());
//...省略...
// set triggers to 'executing'
List<TriggerFiredResult> bndles = new ArrayList<TriggerFiredResult>();
boolean goAhead = true;
synchronized(sigLock) {
goAhead = !halted.get();
}
if(goAhead) {
try {
// 觸發 Trigger,把 ACQUIRED 狀態改成 EXECUTING
// 如果這個 trigger 的 NEXTFIRETIME 為空,也就是未來不再觸發,就將其狀態改為 COMPLETE // 如果 trigger 不允許併發執行(即 Job 的實現類標註了@DisallowConcurrentExecution), 則將狀態變為 BLOCKED,否則就將狀態改為 WAITING
List<TriggerFiredResult> res = qsRsrcs.getJobStore().triggersFired(triggers);
//...省略...
continue;
}
}
//迴圈處理trigger
for (int i = 0; i < bndles.size(); i++) {
//從trigger任務集合取出一個
TriggerFiredResult result = bndles.get(i);
//把trigger任務包裝成TriggerFiredBundle
TriggerFiredBundle bndle = result.getTriggerFiredBundle();
//...省略...
JobRunShell shell = null;
try {
// 根據 trigger 資訊例項化 JobRunShell(implements Runnable),同時依據 JOB_CLASS_NAME 例項化 Job,隨後我們將 JobRunShell 例項丟入工作線。
shell = qsRsrcs.getJobRunShellFactory().createJobRunShell(bndle);
shell.initialize(qs);
} catch (SchedulerException se) {
qsRsrcs.getJobStore().triggeredJobComplete(triggers.get(i), bndle.getJobDetail(), CompletedExecutionInstruction.SET_ALL_JOB_TRIGGERS_ERROR);
continue;
}
//呼叫執行緒池的runInThread方法,實際上是呼叫JobRunShell的run()方法
if (qsRsrcs.getThreadPool().runInThread(shell) == false) {
//...省略...
SimpleThreadPool.runInThread
這裡執行緒池開始從可用執行緒佇列分配工作執行緒去處理JobRunShell
的run()方法
public boolean runInThread(Runnable runnable) {
//...省略...
//假如執行緒沒有關閉
if (!isShutdown) {
//從可用工作執行緒佇列移除一條工作執行緒
WorkerThread wt = (WorkerThread)availWorkers.removeFirst();
//把工作執行緒加入到繁忙工作執行緒佇列
busyWorkers.add(wt);
//執行JobRunShell的run方法
wt.run(runnable);
} else {
//加入執行緒池準備要關閉,開啟一個執行緒池裡沒有的新工作執行緒
WorkerThread wt = new WorkerThread(this, threadGroup,
"WorkerThread-LastJob", prio, isMakeThreadsDaemons(), runnable);
//加入到繁忙工作執行緒佇列
busyWorkers.add(wt);
//工作執行緒佇列加入該新工作執行緒
workers.add(wt);
//執行JobRunShell的run方法
wt.start();
}
//...省略...
return true;
}
JobRunShell
用來為 Job
提供安全的執行環境的,執行 Job
中所有的作業,捕獲執行中的異常,在任務執行完畢的
時候更新 Trigger
狀態,等等。
JobRunShell
例項是用 JobRunShellFactory
為 QuartzSchedulerThread
建立的,在排程器決定一個 Job
被觸發的時候,它從執行緒池中取出一個執行緒來執行任務。
Quartz執行緒模型
SimpleThreadPool
:包工頭,管理所有WorkerThread
WorkerThread
:工人,把Job
包裝成JobRunShell
執行QuartSchedulerThread
:專案經理,獲取即將觸發的Trigger
,從問包工頭拿一個空閒的worker
,執行Trigger
繫結的任務
Quartz叢集程式間如何通訊
Quartz叢集之間是通過資料庫幾張核心的Quartz表進行通訊
表名 | 作用 |
---|---|
QRTZ_BLOB_TRIGGERS | Trigger 作為 Blob 型別儲存 |
QRTZ_CALENDARS | 儲存 Quartz 的 Calendar 資訊 |
QRTZ_CRON_TRIGGERS | 儲存 CronTrigger,包括 Cron 表示式和時區資訊 |
QRTZ_FIRED_TRIGGERS | 儲存與已觸發的 Trigger 相關的狀態資訊,以及相關 Job 的執行資訊 |
QRTZ_JOB_DETAILS | 儲存每一個已配置的 Job 的詳細資訊 |
QRTZ_LOCKS | 儲存程式的悲觀鎖的資訊 |
QRTZ_PAUSED_TRIGGER_GRPS | 儲存已暫停的 Trigger 組的資訊 |
QRTZ_SCHEDULER_STATE | 儲存少量的有關 Scheduler 的狀態資訊,和別的 Scheduler 例項 |
QRTZ_SIMPLE_TRIGGERS | 儲存 SimpleTrigger 的資訊,包括重複次數、間隔、以及已觸的次數 |
QRTZ_SIMPROP_TRIGGERS | 儲存 CalendarIntervalTrigger 和 DailyTimeIntervalTrigger 兩種型別的觸發器 |
QRTZ_TRIGGERS | 儲存已配置的 Trigger 的資訊 |
Quartz叢集如何保證高併發下不重複跑
Quartz有多個節點同時在執行,而任務是共享的,這時候肯定存在資源競爭問題,容易造成併發問題,Quartz節點之間是否存在分散式鎖去控制?
Quartz
是通過資料庫去作為分散式鎖來控制多程式併發問題,Quartz
加鎖的地方很多,Quartz
是使用悲觀鎖的方式進行加鎖,讓在各個instance操作Trigger
任務期間序列,這裡挑選核心的程式碼來看看它是符合利用資料庫防止併發的。
使用資料庫鎖需要在quartz.properties
中加以下配置,讓叢集生效Quartz才會對多個instance進行併發控制
org.quartz.jobStore.isClustered = true
QRTZ_LOCKS
表,它會為每個排程器建立兩行資料,獲取 Trigger 和觸發 Trigger 是兩把鎖,加鎖入口在JobStoreSupport
類中,Quartz提供的鎖表,為多個節點排程提供分散式鎖,實現分散式排程,預設有2個鎖
SCHED_NAME | LOCK_NAME |
---|---|
Myscheduler | STATE_ACCESS |
Myscheduler | TRIGGER_ACCESS |
STATE_ACCESS
主要用在scheduler定期檢查是否失效的時候,保證只有一個節點去處理已經失效的scheduler;
TRIGGER_ACCESS
主要用在TRIGGER被排程的時候,保證只有一個節點去執行排程
QuartzSchedulerThread.run
排程執行緒在獲取下一個Trigger任務的時候,會在Quartz表加行級鎖,入口在這
//...省略...
//
triggers = qsRsrcs.getJobStore().acquireNextTriggers(
now + idleWaitTime, Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()), qsRsrcs.getBatchTimeWindow());
//...省略...
JobStoreSupport.acquireNextTriggers
public List<OperableTrigger> acquireNextTriggers(final long noLaterThan, final int maxCount, final long timeWindow)
throws JobPersistenceException {
//...省略...
//這裡會進入加鎖控制,lockName是鎖的key
return executeInNonManagedTXLock(lockName,
new TransactionCallback<List<OperableTrigger>>() {
//...省略...
JobStoreSupport.executeInNonManagedTXLock
這裡會進入非託管事務,加入lockName不為空,需要先獲取鎖才能執行事務回撥方法和事務校驗方法
protected <T> T executeInNonManagedTXLock(
String lockName,
TransactionCallback<T> txCallback, final TransactionValidator<T> txValidator) throws JobPersistenceException {
boolean transOwner = false;
Connection conn = null;
try {
if (lockName != null) {
//只要作為鎖的key不為空,在這裡就會呼叫JobStoreTx獲取資料庫連線
if (getLockHandler().requiresConnection()) {
conn = getNonManagedTXConnection();
}
//真正加鎖的入口,通過LockHandler去呼叫DBSemaphore運算元據庫獲取鎖
transOwner = getLockHandler().obtainLock(conn, lockName);
}
//...省略...
DBSemaphore.obtainLock
這裡會通過執行兩條SQL
去向呼叫執行緒授予對已識別資源的鎖定(阻塞)直到可用
public boolean obtainLock(Connection conn, String lockName)
throws LockException {
//...省略...
//判斷當前呼叫執行緒是否對標識的資源持有鎖,加入已經持有該鎖,則直接跳過
if (!isLockOwner(lockName)) {
//通過呼叫StdRowLockSemaphore的executeSQL方法對expandedSQL, expandedInsertSQL對lockName進行加鎖控制
executeSQL(conn, lockName, expandedSQL, expandedInsertSQL);
//...省略...
}
StdRowLockSemaphore.executeSQL
如果已經有lockName
代表的行,直接加鎖,如果沒有插入。但是在加鎖時或插入時有可能失敗,失敗則重試,重試如果超過一定次數就會直接丟擲異常。這裡是使用悲觀鎖的方式進行加鎖
protected void executeSQL(Connection conn, final String lockName, final String expandedSQL, final String expandedInsertSQL) throws LockException {
//...省略...
ps = conn.prepareStatement(expandedSQL);
//...省略...
ps.setString(1, lockName);
//先執行查詢,看看錶裡是否已經有該存在
rs = ps.executeQuery();
//...省略...
// 如果查詢結果不為空
if (!rs.next()) {
ps.setString(1, lockName);
//
int res = ps.executeUpdate();
//...省略...
return; // obtained lock, go
}
這兩條SQL是在DBSemaphore
初始化的時候塞進來的
public DBSemaphore(String tablePrefix, String schedName, String defaultSQL, String defaultInsertSQL) {
this.tablePrefix = tablePrefix;
this.schedName = schedName;
setSQL(defaultSQL);
setInsertSQL(defaultInsertSQL);
}
再看看呼叫鏈會發現,這兩條SQL是在StdRowLockSemaphore
初始化的時候呼叫父類DBSemaphore
構造方法傳進來,分別是selectWithLockSQL
和SELECT_FOR_LOCK
public StdRowLockSemaphore(String tablePrefix, String schedName, String selectWithLockSQL) {
super(tablePrefix, schedName, selectWithLockSQL != null ? selectWithLockSQL : SELECT_FOR_LOCK, INSERT_LOCK);
}
兩條SQL分別是:
public static final String SELECT_FOR_LOCK = "SELECT * FROM "
+ TABLE_PREFIX_SUBST + TABLE_LOCKS + " WHERE " + COL_SCHEDULER_NAME + " = " + SCHED_NAME_SUBST
+ " AND " + COL_LOCK_NAME + " = ? FOR UPDATE";
public static final String INSERT_LOCK = "INSERT INTO "
+ TABLE_PREFIX_SUBST + TABLE_LOCKS + "(" + COL_SCHEDULER_NAME + ", " + COL_LOCK_NAME + ") VALUES ("
+ SCHED_NAME_SUBST + ", ?)";
把引數替換進去就比較清晰可以看到,Quartz通過在qrtz_LOCKS
表對當前schedule job
加兩個行級鎖
expandedSQL:select * from QRTZ_LOCKS t where t.lock_name='TRIGGER_ACCESS' for update
expandedInsertSQL:INSERT INTO qrtz_LOCKS(SCHED_NAME, LOCK_NAME) VALUES ('MySchedule', 'TRIGGER_ACCESS')
Quartz叢集如何保證高併發下不漏跑
有時候Quartz
可能會錯過我們的排程任務:
- 服務重啟,沒能及時執行任務,就會misfire
- 工作執行緒去執行優先順序更高的任務,就會misfire
- 任務的上一次執行還沒結束,下一次觸發時間到達,就會misfire
Quartz
可提供了一些補償機制應對misfire
情況,使用者可以根據需要選擇對應的策略,這裡挑選常用的cronTrigger
作為示例
-
withMisfireHandlingInstructionDoNothing
- 不觸發立即執行
- 等待下次Cron觸發頻率到達時刻開始按照Cron頻率依次執行
-
withMisfireHandlingInstructionIgnoreMisfires
- 以錯過的第一個頻率時間立刻開始執行
- 重做錯過的所有頻率週期後當下一次觸發頻率發生時間大於當前時間後,再按照正常的Cron頻率依次執行
-
withMisfireHandlingInstructionFireAndProceed
(預設)- 以當前時間為觸發頻率立刻觸發一次執行,然後按照Cron頻率依次執行
假如使用者沒有設定Misfire
指令,Quartz
預設指定MISFIRE_INSTRUCTION_SMART_POLICY
作為預設策略,在Trigger
介面的getMisfireInstruction
原始碼可以看到:
/**
* Get the instruction the <code>Scheduler</code> should be given for
* handling misfire situations for this <code>Trigger</code>- the
* concrete <code>Trigger</code> type that you are using will have
* defined a set of additional <code>MISFIRE_INSTRUCTION_XXX</code>
* constants that may be set as this property's value.
*
* <p>
* If not explicitly set, the default value is <code>MISFIRE_INSTRUCTION_SMART_POLICY</code>.
* </p>
*
* @see #MISFIRE_INSTRUCTION_SMART_POLICY
* @see SimpleTrigger
* @see CronTrigger
*/
public int getMisfireInstruction();
這裡繼續以CronTrigger
舉例,其他型別Trigger
也類似 。如果是預設策略MISFIRE_INSTRUCTION_SMART_POLICY
,在CronTrigger
會選用MISFIRE_INSTRUCTION_FIRE_ONCE_NOW
,該策略的特點是立刻執行一次,然後後面的任務就按照正常的計劃執行。
@Override
public void updateAfterMisfire(org.quartz.Calendar cal) {
int instr = getMisfireInstruction();
if(instr == Trigger.MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY)
return;
if (instr == MISFIRE_INSTRUCTION_SMART_POLICY) {
instr = MISFIRE_INSTRUCTION_FIRE_ONCE_NOW;
}
if (instr == MISFIRE_INSTRUCTION_DO_NOTHING) {
Date newFireTime = getFireTimeAfter(new Date());
while (newFireTime != null && cal != null
&& !cal.isTimeIncluded(newFireTime.getTime())) {
newFireTime = getFireTimeAfter(newFireTime);
}
setNextFireTime(newFireTime);
} else if (instr == MISFIRE_INSTRUCTION_FIRE_ONCE_NOW) {
setNextFireTime(new Date());
}
}
Quartz對於misfire任務大致處理流程
-
QuartzScheduler.start()
啟動排程 -
JobStoreSupport.schedulerStarted()
執行啟動排程方法 -
建立和初始化
misfireHandler
-
非同步執行
misfireHandler.run
方法處理misfire
任務 -
MisfileHandler
通過JobStoreSupport
去查詢有沒有misfire
的任務,查詢條件是當前狀態是waiting
,下一次trigger時間
<當前時間-misfire預設閾值
(預設1分鐘)
int misfireCount = (getDoubleCheckLockMisfireHandler()) ?
getDelegate().countMisfiredTriggersInState(
conn, STATE_WAITING, getMisfireTime()) :
Integer.MAX_VALUE;
String COUNT_MISFIRED_TRIGGERS_IN_STATE = "SELECT COUNT("
+ COL_TRIGGER_NAME + ") FROM "
+ TABLE_PREFIX_SUBST + TABLE_TRIGGERS + " WHERE "
+ COL_SCHEDULER_NAME + " = " + SCHED_NAME_SUBST + " AND NOT ("
+ COL_MISFIRE_INSTRUCTION + " = " + Trigger.MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY + ") AND "
+ COL_NEXT_FIRE_TIME + " < ? "
+ "AND " + COL_TRIGGER_STATE + " = ?";
protected long getMisfireTime() {
long misfireTime = System.currentTimeMillis();
if (getMisfireThreshold() > 0) {
//當前時間減去misfire預設閾值,閾值預設一分鐘
misfireTime -= getMisfireThreshold();
}
return (misfireTime > 0) ? misfireTime : 0;
}
-
JobStoreSupport
通過StdRowLockSemaphore
去獲取TRIGGER_ACCESS
鎖 -
查詢所有
misfire
任務,查詢條件:status=waiting,current_time-next_fire_time>misfireThreshold
(可配置,預設1分鐘)【即實際觸發時間-預計觸發時間大於容忍度時間】,獲取misfired的trigger,maxToRecoverAtATime
預設一個事務中只能最大有20
個misfired trigger(可配置) -
通過
updateAfterMisfired
方法獲取misfired的策略(預設是MISFIRE_INSTRUCTION_SMART_POLICY
該策略在CronTrigger
中為MISFIRE_INSTRUCTION_FIRE_ONCE_NOW
),根據策略設定nexFireTime
。 -
將
nextFireTime
等更新或者插入到trigger
表; -
提交事務,釋放鎖
Quartz預設任務鎖機制
Quartz是否一定會加鎖?什麼情況下不會加鎖?應該怎麼避免併發問題?
什麼情況下不會加鎖?
回到JobStoreSupport
的 acquireNextTriggers()
方法,可以看到當isAcquireTriggersWithinLock()
為true
或者maxCount>1
才會加鎖,否則lockName
為空
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>>() {
//..省略..
}
});
}
protected <T> T executeInNonManagedTXLock(
String lockName,
TransactionCallback<T> txCallback, final TransactionValidator<T> txValidator) throws JobPersistenceException {
boolean transOwner = false;
Connection conn = null;
try {
if (lockName != null) {
// If we aren't using db locks, then delay getting DB connection
// until after acquiring the lock since it isn't needed.
if (getLockHandler().requiresConnection()) {
conn = getNonManagedTXConnection();
}
transOwner = getLockHandler().obtainLock(conn, lockName);
}
//..省略...
}
Quartz
加鎖的條件有以下兩個:
- 如 果
acquireTriggersWithinLock=true
或 者batchTriggerAcquisitionMaxCount>1
時 ,lockName
賦 值 為
LOCK_TRIGGER_ACCESS
,此時獲取 Trigger
會加鎖。
- 否則,如果
isAcquireTriggersWithinLock()
值是false
並且maxCount=1
的話,lockName
賦值為null
,這種情況獲取Trigger
下不加鎖。
那這兩個引數的預設值是什麼?
acquireTriggersWithinLock
變數預設是 false
private boolean acquireTriggersWithinLock = false;
maxCount
來自 QuartzSchedulerThread
triggers = qsRsrcs.getJobStore().acquireNextTriggers( now + idleWaitTime, Math.min(availThreadCount, qsRsrcs.getMaxBatchSize()), qsRsrcs.getBatchTimeWindow());
getMaxBatchSize()
來自 QuartzSchedulerResources
,代表 Scheduler
一次拉取
trigger
的最大數量,預設是 1
org.quartz.scheduler.batchTriggerAcquisitionMaxCount=1
什麼情況下需要加鎖?
QuartzSchedulerThread
的 triggersFired()
方法
List<TriggerFiredResult> res = qsRsrcs.getJobStore().triggersFired(triggers);
呼叫了 JobStoreSupport
的 triggersFired()
方法,接著又呼叫了triggerFired(Connection conn, OperableTrigger trigger)
方法:
public List<TriggerFiredResult> triggersFired(final List<OperableTrigger> triggers) throws JobPersistenceException {
return executeInNonManagedTXLock(LOCK_TRIGGER_ACCESS,
new TransactionCallback<List<TriggerFiredResult>>() {
public List<TriggerFiredResult> execute(Connection conn) throws JobPersistenceException {
List<TriggerFiredResult> results = new ArrayList<TriggerFiredResult>();
TriggerFiredResult result;
for (OperableTrigger trigger : triggers) {
try {
//觸發
TriggerFiredBundle bundle = triggerFired(conn, trigger);
result = new TriggerFiredResult(bundle);
//...省略...
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;
}
//...省略...
如果 Trigger
的狀態不是 ACQUIRED
,也就是說被其他的執行緒 fire
了,返回空。但是這種樂觀鎖的檢查在高併發下難免會出現 ABA
的問題,比如執行緒 A 拿到的時候還是 ACQUIRED
狀態,但是剛準備執行的時候已經變成了 EXECUTING
狀態,這個時候就會 出現重複執行的問題。
把執行步驟拆解下,比較容易看到該問題:
推薦
如果設定的數量為 1(預設值),並且使用 JDBC JobStore(RAMJobStore 不支援 分 布 式 , 只 有 一 個 調 度 器 實 例 , 所 以 不 加 鎖 ) , 則 屬 性 org.quartz.jobStore.acquireTriggersWithinLock
應設定為 true
。否則不加鎖可能會導致任務重複執行。
org.quartz.scheduler.batchTriggerAcquisitionMaxCount=1 org.quartz.jobStore.acquireTriggersWithinLock=true
Quartz常見問題
伺服器始終不一致問題
常見異常:
This scheduler instance (SchedulerName) is still active but was recovered by another instance in the cluster
解決:
同步所有叢集節點的時間然後重啟服務
Quartz叢集負載不均衡
Quartz叢集是採用搶佔式加鎖方式去處理任務,因此你會看到每個節點的任務處理日誌並不是均衡分配的,很可能一個節點會搶佔大量任務導致負載過重,但是這一點官方並沒有解決。
錯過預定觸發時間
常見異常:
Handling 1 trigger(s) that missed their scheduled fire-time
解決:
很可能是你執行緒數設定太少,而任務執行時間太長,超過的misfire
閾值,導致執行緒池沒有可用執行緒而錯過了觸發事件。嘗試把配置檔案執行緒數調大org.quartz.threadPool.threadCount
或者把misfire
閾值調大org.quartz.jobStore.misfireThreshold