公司前期改用quartz作任務調度,一日的調度量均在兩百萬次以上。隨着調度量的增長,忽然開始出現job重複調度的狀況,且沒有規律可循。網上也沒有說得較爲清楚的解決辦法,因而咱們開始調試Quartz源碼,並最終找到了問題所在。 若是沒有耐性看完源碼解析,能夠直接拉到文章最末,有直接簡單的解決辦法。
注:本文中使用的quartz版本爲2.3.0,且使用JDBC模式存儲Job。java
首先,由於本文是代碼級別的分析文章,於是須要提早了解Quartz的用途和用法,網上仍是有不少不錯的文章,能夠提早自行了解。mysql
其次,在用法以外,咱們還須要瞭解一些Quartz框架的基礎概念:sql
1) Quartz把觸發job,叫作fire。TRIGGER_STATE是當前trigger的狀態,PREV_FIRE_TIME是上一次觸發時間,NEXT_FIRE_TIME是下一次觸發時間,misfire是指這個job在某一時刻要觸發,卻由於某些緣由沒有觸發的狀況。數據庫
2) Quartz在運行時,會起兩類線程(不止兩類),一類用於調度job的調度線程(單線程),一類是用於執行job具體業務的工做池。安全
3) Quartz自帶的表裏面,本文主要涉及如下3張表:服務器
4) TRIGGER_STATE,也就是trigger的狀態,主要有如下幾類:
併發
圖2-1 trigger狀態變化圖框架
trigger的初始狀態是WAITING,處於WAITING狀態的trigger等待被觸發。調度線程會不停地掃triggers表,根據NEXT_FIRE_TIME提早拉取即將觸發的trigger,若是這個trigger被該調度線程拉取到,它的狀態就會變爲ACQUIRED。由於是提早拉取trigger,並未到達trigger真正的觸發時刻,因此調度線程會等到真正觸發的時刻,再將trigger狀態由ACQUIRED改成EXECUTING。若是這個trigger再也不執行,就將狀態改成COMPLETE,不然爲WAITING,開始新的週期。若是這個週期中的任何環節拋出異常,trigger的狀態會變成ERROR。若是手動暫停這個trigger,狀態會變成PAUSED。異步
前文提到,trigger的狀態儲存在數據庫,Quartz支持分佈式,因此若是起了多個quartz服務,會有多個調度線程來搶奪觸發同一個trigger。mysql在默認狀況下執行select 語句,是不上鎖的,那麼若是同時有1個以上的調度線程搶到同一個trigger,是否會致使這個trigger重複調度呢?咱們來看看,Quartz是如何解決這個問題的。分佈式
首先,咱們先來看下JobStoreSupport
類的executeInNonManagedTXLock()
方法:
圖3-1 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
方法的具體實現:
圖3-2 obtainLock方法具體實現
咱們經過調試查看expandedSQL
和expandedInsertSQL
這兩個變量:
圖3-3 expandedSQL和expandedInsertSQL的具體內容
圖3-3能夠看出,obtainLock
方法經過locks表的一個行鎖(lockName肯定)來保證callback方法的事務和線程安全。拿到鎖後,obtainLock
方法將lockName
寫入threadlocal
。固然在releaseLock
的時候,會將lockName
從threadlocal
中刪除。
總而言之,executeInNonManagedTXLock()
方法,保證了在分佈式的狀況,同一時刻,只有一個線程能夠執行這個方法。
圖3-4 Quartz的調度時序圖
QuartzSchedulerThread
是調度線程的具體實現,圖3-4 是這個線程run()
方法的主要內容,圖中只提到了正常的狀況下,也就是流程中沒有出現異常的狀況下的處理過程。由圖能夠看出,調度流程主要分爲如下三步:
調度線程會一次性拉取距離如今,必定時間窗口內的,必定數量內的,即將觸發的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表。
首先,咱們會檢查每一個trigger的狀態是否是ACQUIRED,若是是,則將狀態改成EXECUTING,而後更新trigger的NEXT_FIRE_TIME,若是這個trigger的NEXT_FIRE_TIME爲空,也就是將來再也不觸發,就將其狀態改成COMPLETE。若是trigger不容許併發執行(即Job的實現類標註了@DisallowConcurrentExecution
),則將狀態變爲BLOCKED,不然就將狀態改成WAITING。
遍歷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。注意,這些是在工做線程池異步完成。
在前文,咱們能夠看到,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 調度日誌
由圖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問題,咱們看一下這是發生重複調度時的日誌:
圖3-5 重複調度的日誌
在第一步時,也就是quartz在拉取到符合條件的triggers 到將他們的狀態由WAITING改成ACQUIRED之間停頓了有超過9ms的時間,而另外一臺服務器正是趁着這9ms的空檔完成了WAITING-->ACQUIRED-->EXECUTING-->WAITING(也就是一個完整的狀態變化週期)的所有過程,圖示參見圖3-6。
圖3-6 重複調度緣由示意圖
如何去解決這個問題呢?在配置文件加上org.quartz.jobStore.acquireTriggersWithinLock=true
,這樣,在調度流程的第一步,也就是拉取待即將觸發的triggers時,是上鎖的狀態,即不會同時存在多個線程拉取到相同的trigger的狀況,也就避免的重複調度的危險。
這次排查過程並不是一路順風,走過一些坑,也有一些非技術相關的體會:
1)學習是一個須要不斷打磨,修正的能力。就我我的而言,爲了學Quartz,剛開始去翻一個2.4MB大小的源碼是毫無頭緒,而且效率低下的,因此馬上轉換方向,先了解這個框架的運行模式,在作什麼,有哪些模塊,是怎麼作的,再找主線,翻相關的源碼。以後在一次次使用中,碰到問題再翻以前沒看的源碼,就愈來愈順利。
以前也聽過其餘同事的學習方法,感受並不徹底適合本身,可能每一個人狀態經驗不一樣,學習方法也稍有不一樣。在平時的學習中,須要去感覺本身的學習效率,參考建議,嘗試,感覺效果,改進,會愈來愈清晰本身適合什麼。這裏很感謝個人師父,用簡短的話先幫我捋順了調度流程,這樣我再看源碼就不那麼吃力了。
2)要質疑「經驗」和「理所應當」,慣性思惟會矇住你的雙眼。在大規模的代碼中很容易被習慣迷惑,一開始,咱們看到上鎖的那個方法的時候,認爲這個上鎖技巧很棒,這個方法就是爲了解決併發的問題,「應該」都上鎖了,上鎖了就不會有併發的問題了,怎麼可能幾回與數據庫的交互都上鎖,忽然某一次不上鎖呢?直到看到拉取待觸發的trigger方法時,以爲有絲絲不對勁,打下日誌,才發現其實是沒上鎖的。
3)日誌很重要。雖然咱們能夠調試,可是沒有日誌,咱們是沒法發現並證實,程序發生了ABA問題。
4)最重要的是,不要懼怕問題,即便是Quartz這樣大型的框架,解決問題也不必定須要把2.4MB的源碼統統讀懂。只要有時間,問題都能解決,只是好的技巧能縮短這個時間,而咱們須要在一次次實戰中磨練技巧。