當前位置:
首頁 > 科技 > 如何高效排查日均調度量超兩百萬次的重複調度問題?

如何高效排查日均調度量超兩百萬次的重複調度問題?

作者 | 余慧娟

責編 | 郭芮

系統自從改用Quartz做任務調度後,一日的調度量均在兩百萬次以上。隨著調度量的增加,突然開始出現job重複調度的情況,且沒有規律可循。網上也沒有說得較為清楚的解決辦法,於是我們開始調試Quartz源碼,並最終找到了問題所在。

如果沒有耐性看完源碼解析,可以直接拉到文章最末,有直接簡單的解決辦法。本文中使用的Quartz版本為2.3.0,且使用JDBC模式存儲Job。

準備

首先,因為本文是代碼級別的分析文章,因而需要提前了解Quartz的用途和用法,網上有很多不錯的文章,可以提前自行了解。

其次,在用法之外,我們還需要了解一些Quartz框架的基礎概念:

Quartz把觸發job叫做fire。TRIGGERSTATE是當前trigger的狀態,PREVFIRE_TIME是上一次觸發的時間,NEXTFIRETIME是下一次觸發的時間,misfire是指這個job在某一時刻要觸發、卻因為某些原因沒有觸發的情況。

Quartz在運行時,會起兩類線程(不止兩類),一類用於調度job的調度線程(單線程),一類是用於執行job具體業務的工作池。

Quartz自帶的表裡面,本文將涉及其中3張表:

triggers表。triggers表裡記錄了某個trigger的PREVFIRETIME(上次觸發時間),NEXT_FIRETIME(下一次觸發時間),TRIGGERSTATE(當前狀態)。雖未盡述,但是本文用到的只有這些。

locks表。Quartz支持分散式,也就是會存在多個線程同時搶佔相同資源的情況,而Quartz正是依賴這張表處理這種狀況,具體見下文。

fired_triggers表。記錄正在觸發的triggers信息。

TRIGGER_STATE,也就是trigger的狀態,主要有以下幾類:

圖 1 trigger狀態變化圖

trigger的初始狀態是WAITING,處於WAITING狀態的trigger等待被觸發。調度線程會不停地掃triggers表,根據NEXTFIRETIME提前拉取即將觸發的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()方法:

圖 2 executeInNonManagedTXLock方法的具體實現

這個方法的官方介紹:

也就是說,傳入的callback方法在執行過程中攜帶了指定的鎖,並開啟了事務,注釋也提到,lockName就是指定的鎖的名字,如果lockName是空的,那麼callback方法的執行不在鎖的保護下,但依然在事務中。

這意味著,我們使用這個方法,不僅可以保證事務,還可以選擇保證callback方法的線程安全。

接下來,我們來看一下executeInNonManagedTXLock(…)中的obtainLock(conn,lockName)方法,即搶鎖的過程。這個方法是在Semaphore介面中定義的,Semaphore介面通過鎖住線程或者資源,來保護資源不被其他線程修改,由於我們的調度信息是存在資料庫的,所以現在查看DBSemaphore.java中obtainLock方法的具體實現:

圖 3 obtainLock方法具體實現

我們通過調試查看expandedSQL和expandedInsertSQL這兩個變數:

圖 4 expandedSQL和expandedInsertSQL的具體內容

圖4可以看出,obtainLock方法通過locks表的一個行鎖(lockName確定)來保證callback方法的事務和線程安全。拿到鎖後,obtainLock方法將lockName寫入threadlocal。當然在releaseLock的時候,會將lockName從threadlocal中刪除。

總而言之,executeInNonManagedTXLock()方法保證了在分散式的情況下,同一時刻只有一個線程可以執行這個方法。

Quartz的調度過程

圖 5 Quartz的調度時序圖

QuartzSchedulerThread是調度線程的具體實現,圖5是這個線程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_FIRETIME小於(now + idleWaitTime +batchTimeWindow),大於(now - misfireThreshold)的,min(availThreadCount,maxBatchSize)個triggers,默認情況下,會拉取未來30s、過去60s之間還未fire的1個trigger。隨後將這些triggers的狀態由WAITING改為ACQUIRED,並插入firedtriggers表。

2、觸發trigger:

首先,我們會檢查每個trigger的狀態是不是ACQUIRED,如果是,則將狀態改為EXECUTING,然後更新trigger的NEXTFIRETIME,如果這個trigger的NEXTFIRETIME為空,也就是未來不再觸發,就將其狀態改為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。注意,這些是在工作線程池非同步完成。

排查問題

在前文,我們可以看到,Quartz的調度過程中有3次(可選的)上鎖行為,為什麼稱為可選?因為這三個步驟雖然在executeInNonManagedTXLock方法的保護下,但executeInNonManagedTXLock方法可以通過設置傳入參數lockName為空,取消上鎖。

在翻閱代碼時,我們看到第一步拉取待觸發的trigger時:

在加鎖之前對lockName做了一次判斷,而非像其他加鎖方法一樣,默認傳入的就是LOCKTRIGGERACCESS:

通過調試發現isAcquireTriggersWithinLock()的值是false,因而導致傳入的lockName是null。我在代碼中加入日誌,可以更清楚地看到這個過程。

圖 6 調度日誌

由圖6可以清楚看到,在拉取待觸發的trigger時,默認是不上鎖。如果這種默認配置有問題,豈不是會頻繁發生重複調度的問題?而事實上並沒有,原因在於Quartz默認採取樂觀鎖,也就是允許多個線程同時拉取同一個trigger。我們看一下Quartz在調度流程的第二步fire trigger的時候做了什麼,注意此時是上鎖狀態:

調度線程如果發現當前trigger的狀態不是ACQUIRED,也就是說,這個trigger被其他線程fire了,就會返回null。在之前我們提到,在調度流程的第三步,如果發現某個trigger第二步的返回值是null,就會跳過第三步,取消fire。在通常的情況下,樂觀鎖能保證不發生重複調度,但是難免發生ABA問題,我們看一下這是發生重複調度時的日誌:

圖 7 重複調度的日誌

在第一步時,也就是Quartz在拉取到符合條件的triggers 到將他們的狀態由WAITING改為ACQUIRED之間停頓了有超過9ms的時間,而另一台伺服器正是趁著這9ms的空檔完成了WAITING-->ACQUIRED-->EXECUTING-->WAITING(也就是一個完整的狀態變化周期)的全部過程,參見下圖。

圖 8 重複調度原因示意圖

如何去解決這個問題呢?在配置文件加上org.quartz.jobStore.acquireTriggersWithinLock=true,這樣,在調度流程的第一步,也就是拉取待即將觸發的triggers時,是上鎖的狀態,即不會同時存在多個線程拉取到相同的trigger的情況,也就避免了重複調度的危險。

解決辦法

如何去解決這個問題呢?在配置文件加上org.quartz.jobStore.acquireTriggersWithinLock=true,這樣,在調度流程的第一步,也就是拉取待即將觸發的triggers時,是上鎖的狀態,即不會同時存在多個線程拉取到相同的trigger的情況,也就避免的重複調度的危險。

心得

此次排查過程並非一帆風順,走過一些坑,也有一些非技術相關的體會:

學習是一個需要不斷打磨、修正的能力。就我個人而言,為了學Quartz,剛開始去翻一個2.4MB大小的源碼時毫無頭緒,且效率低下,所以立刻轉換方向,先了解這個框架的運行模式,在做什麼,有哪些模塊,是怎麼做的,再找主線,翻相關的源碼。之後在一次次使用中,碰到問題再翻之前沒看的源碼,就越來越順利。

之前也聽過其他同事的學習方法,感覺並不完全適合自己,可能每個人狀態經驗不同,學習方法也稍有不同。在平時的學習中,需要去感受自己的學習效率,參考建議,嘗試,感受效果,改進,會越來越清晰自己適合什麼。這裡很感謝我的師父,用簡短的話先幫我捋順了調度流程,這樣我再看源碼就不那麼吃力了。

要質疑「經驗」和「理所應當」,慣性思維會蒙住你的雙眼。在大規模的代碼中很容易被習慣迷惑,一開始,我們看到上鎖的那個方法的時候,認為這個上鎖技巧很棒,這個方法就是為了解決並發的問題,「應該」都上鎖了,上鎖了就不會有並發的問題了,怎麼可能幾次與資料庫的交互都上鎖,突然某一次不上鎖呢?直到看到拉取待觸發的trigger方法時,覺得有絲絲不對勁,打下日誌,才發現實際上是沒上鎖的。

日誌很重要。雖然我們可以調試,但是沒有日誌,我們是無法發現並證明程序發生了ABA問題。

最重要的是,不要害怕問題,即使是Quartz這樣大型的框架,解決問題也不一定需要把2.4MB的源碼通通讀懂。只要有時間,問題都能解決,只是好的技巧能縮短這個時間,而我們需要在一次次實戰中磨練技巧。

作者介紹:余慧娟,拍拍貸研發工程師,平時喜歡寫一些幫助新手入門的文章,翻譯一些外文,看到點贊會很開心,希望自己碼字的速度越來越快。yuhuijuan.com是我的個人博客,歡迎關注。

聲明:本文為作者投稿,版權歸其個人所有。

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 CSDN 的精彩文章:

經過 180 年的訓練,OpenAI 在 DOTA 2 上完虐人類!
Python讀寫Excel表格,就是這麼簡單粗暴又好用

TAG:CSDN |