作者:Lavender
來源:https://segmentfault.com/a/1190000015492260
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()方法,保證了在分布式的情況,同一時刻,只有一個執行緒可以執行這個方法,
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實體丟入作業線,
在JobRunShell的run()方法,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開發手冊(嵩山版)》最新發布,速速下載!
覺得不錯,別忘了隨手點贊+轉發哦!
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/509736.html
標籤:其他
