趣講 PowerJob 超強大的排程層,開始表演真正的技術了

削微寒發表於2020-08-05

本文適合有 Java 基礎知識的人群

作者:HelloGitHub-Salieri

HelloGitHub 推出的《講解開源專案》系列。

寫在前面的碎碎念:終於到了萬眾期待的排程層原理了。其實很早之前就想動筆把這部分好好給大家講講,因為問的人實在是太多了...大部分小夥伴進使用者群的第一句話就是:“群豬,請問無鎖化排程是怎麼實現的?”,剩下的犀利點的小夥伴甚至直接問:“群豬,你這個效能強勁無上限體現在什麼地方啊?”。

可惜不巧的是,鄙人在 7 月初給自己安排了一個驚險刺激的大西北旅遊,每天不是在坐車就是在前往坐車的路上,雖然感受到了祖國疆域之遼闊、風景之秀麗、文化之璀璨,人累個半死也是確有其事。文章嘛,自然也就是一路鴿到了現在...

那麼,是時候表演真正的技術了~

一、排程層概覽

PowerJob 目前支援 4 種定時執行策略,分別是 CRON、固定頻率、固定延遲 和 API。API 指的是通過 PowerJob 提供的客戶端介面直接啟動任務的方式,不需要 server 來支援排程,此處忽略。而剩下的 3 種排程策略,根據其執行頻率的不同,可以劃分為常規任務秒級任務。我們先講常規任務。

常規任務指由 CRON 表示式指定定時策略的任務,這一類任務的特點是 執行頻率不高。 對於這類任務,PowerJob 採用基於資料庫輪詢的策略來進行排程,具體的原理圖如下。

PowerJob 的任務表中,除了維護任務的基礎後設資料(如任務名稱、定時策略、執行器資訊等)之外,還會額外增加一個欄位 next_trigger_time,也就是下一次排程時間,當任務被成功建立時,系統會使用 CRON 表示式去初始化該欄位,保證每一個 CRON 任務都存在可用的下一次排程時間

有了這個欄位,具體的排程就好辦了。powerjob-server 會啟用一個後臺執行緒定期掃描任務表,查詢那些由本機排程的、即將執行(即下一次排程時間與當前時間的差值小於系統規定的閾值)的任務。

(這裡埋個小小的伏筆,“由本機排程”其實是實現無鎖化排程的關鍵,將在下一篇文章為大家揭祕,本文主要講述排程流程,因此直接以單機為例)

一旦發現接下來的一段時間內有任務需要被排程執行,就會為這些任務生成執行記錄並推入時間輪,最後完成任務的排程。

聽起來似乎很平淡無奇的一個流程,存在著那些精彩的設計與實現呢?請聽我細細分解~

二、高效能排程——時間輪

假如,現在給你一個任務,要求 2 秒後執行,你會怎麼解決的?

最簡單的方案,也就是利用休眠。1 秒後執行,那麼我讓當前執行緒 sleep 1 秒,不就達到目的了嗎?沒錯,基於執行緒休眠的特性,可以用三行程式碼實現一個最簡單的定時執行器,但是它的效能嘛...自然也是相當的拉垮...由於每一個任務都需要繫結一個單獨的執行緒,當系統中存在大量任務時,這種方案消耗的資源極其龐大。

那麼如何實現高效的排程呢?

也許,就和牛頓被蘋果砸出萬有引力引力一樣,發明時間輪演算法的大神,在為尋找高效排程方案而苦惱不已時,低頭看了看自己的勞力士~覺得這個表如此的樸實無華的同時,似乎找到了那麼一點點靈感~

根據前面分析,執行緒休眠型排程器之所以低效,是因為它需要用到大量的執行緒資源,這浪費了大量的 CPU 和記憶體資源。那麼有沒有辦法來避免這個消耗呢?看著這個表,有人找到了答案。

時間輪是一種高效利用執行緒資源來進行批量化排程的一種排程模型。把大批量的排程任務全部都繫結到同一個的排程器(一個執行緒)上面,使用這一個排程器來進行所有任務的管理,觸發以及執行,能夠高效的管理各種延時任務,週期任務,通知任務等等。

時間輪的演算法模型如上圖所示,每個時間輪存在著 N 個槽,兩個槽之間的間隔時間固定。每走一個時間間隔,指標就向前推進一格,然後開始處理當前槽內的所有任務。指標不斷迴圈推進,直到時間輪中不存在任何任務。

當新增排程任務時,可根據任務的排程時間和當前時間計算出具體的時間槽。為了能以時間複雜度 O(1) 的代價將任務放入指定位置,需要時間槽具有隨機訪問的能力,為此該部分使用迴圈陣列實現。每一個時間槽對應的任務佇列長度不確定,且只需要提供順序訪問能力,為此任務佇列使用單向連結串列實現。

每一個時間輪都有兩個必備引數,時間間隔 tickDuration 和 刻度數量 ticksPerWheel。這兩個引數也很好理解,時間間隔就是指標轉動的頻率,刻度數量就是這個錶盤內任務槽的數量,拿現實中的手錶來說,tickDuration 就是 1,ticksPerWheel 是 12。

講了那麼多理論,這裡舉個具體的例子來幫助大家理解時間輪(其實時間輪的概念非常好理解,具體的實現也不算很難,可以說是一種價效比超高的資料結構了~)

假如我現在有一個時間間隔為 1 秒,刻度數為 12 的時間輪,現在需要排程 3 個定時任務,分別在 1 秒、6 秒和 13 秒後執行,那麼時間輪的工作流程是怎麼樣的呢?

首先,第一步是任務的插入。由於錶盤的設計是環形資料,通過 (預計執行時間 - 時間輪啟動時間)% 刻度數 這個公式便能算出該任務的插槽下標,即這些任務會分別被插入到 0、5 和 0 號槽對應的連結串列中。

完成任務的插入後,接下來就等著排程執行緒取出任務並執行了。排程執行緒通過休眠 tickDuration 的方式,迴圈讀取下一個槽中連結串列中的任務並執行。由於連結串列中的任務可能不是本輪需要排程的(就比如 13 秒後執行的任務,其實是下一個排程週期才需要執行),需要額外對任務的預計執行時間做判斷,只有符合要求的任務才會被排程執行,並從連結串列中移除。

這樣就做到了 1 個執行緒完成大量任務的排程,兼備效能和效率。唯一的缺點是由於採取了 tickDuration,那麼排程會存在著一定的誤差。如果你對排程執行的時間精度要求極高,那時間輪可能不是你的菜,否則,還不趕緊抱走?

時間輪的概念講完了,接下來回歸框架本身。PowerJob 所使用的時間輪設計整體參考 Netty,並在一些地方做了定製化處理,比如由於 PowerJob 排程後執行任務有一定的開銷(涉及資料庫操作),因此除了指標執行緒,還額外引入了處理執行緒池來保證排程的精度。原始碼一共 326 行,有興趣的話,快去看吧,類名都給你準備好啦!

com.github.kfcfans.powerjob.server.common.utils.timewheel.HashedWheelTimer

三、可靠排程——WAL

可靠排程也是大家廣為關注的一個問題,甚至還有同學在 GitHub Issue 留言告訴我他們自研的排程系統在生產環境中遇到的不可靠排程問題:

那麼 PowerJob 存在著錯過排程的問題嗎?答案顯然是否定的。(作為一款一直強調極高可用性和穩定性的生產級排程中介軟體,要是這一點都做不到,那還有臉見人嗎?

那麼問題又來了,這,又是如何實現的呢?

不知道大家有沒有聽說過 WAL(Write-Ahead Logging,預寫式日誌),這是主流關係型資料庫(MS SQLServer、MySQL、Oracle)用來確保了事務原子性和永續性的關鍵技術。WAL 的核心思想是: 在資料寫入到資料庫之前,先寫入到日誌中。 這樣,在硬碟資料不損壞的情況下,預寫式日誌允許儲存系統在崩潰後能夠在日誌的指導下恢復到崩潰前的狀態,避免資料丟失。

PowerJob 為了實現任務的可靠排程,也借鑑了該思想。每一個任務被排程執行時,系統都會為其生成一條記錄,這條記錄包含了該任務例項(任務的一次執行叫任務例項)的預期排程時間。之後,PowerJob 會首先將該記錄持久化到資料庫中,只有持久化成功後,該任務才會被正式推入時間輪進行排程。

一旦這一臺 server 當機,任務沒有被準時執行。其他 server 就能根據已經寫入資料庫中的任務例項記錄將其恢復,做到可靠排程~

也就是說,只要你的系統中還有一臺 powerjob-server 活著,就不會有缺失排程的情況。

四、秒級任務

說夠了常規任務的排程,讓我們來侃侃秒級任務~

秒級任務的特點是執行頻率極高(吐槽:這不是廢話嗎),那麼能不能用支援常規任務排程的這套方法來支撐秒級任務的排程呢?

首先是任務的獲取。emmm...“一定時間間隔掃描任務表獲取待執行任務”,這...等你獲取到任務,黃花菜都涼了...這不中啊...沒錯,使用傳統排程方案,第一步就掛了。(我想到了路途艱難,但沒想到居然那麼難!)

不過,比較聰明的同學可能想到了。既然秒級任務執行頻率很高,那 server 獲取這個任務後,可以將它儲存起來,這樣下一次排程就不需要單獨查資料庫了,而是選擇記憶體遍歷,要多快有多快,似乎就解決了這個問題。

然而,這種方式仍不完美。俗話說得好,物以稀為貴,秒級任務的執行頻率那麼高,在大部分情況下,其實失敗個一兩次也沒什麼關係,畢竟立即就會有下一個任務補上。因此,傳統任務那一套為了可靠排程而生的機制並不適用於秒級任務,秒級任務使用了那套機制後,也會對資料庫產生較大的衝擊,導致 PowerJob 整體的效能大幅度下降。那麼出路究竟在何方呢?

此時就不得不提解決計算機領域問題的終極神器了:分治。既然不強要求任務執行有非常高的可靠性,那麼 powerjob-server 此時就可以放權了。

每一個秒級任務,都會直接被投遞到叢集中的某一臺 powerjob-worker 上,由 powerjob-worker 全權負責執行。而 powerjob-server 此時只需要負責故障恢復即可。

這樣一來,server 的壓力進一步減輕,同時,由於秒級任務的排程與執行全部落在了 worker 身上,排程的精度也會上升(至少能省下通訊的網路延遲),可謂是一個完美至極的雙贏方案。

五、最後

那麼以上就是本篇文章全部的內容啦~

本篇文章講述了 PowerJob 排程層的實現與其中一些精巧的設計。不過限於篇幅,整個排程層其實並沒有完全呈現在大家眼前,目前還是猶抱琵琶半遮面的狀態~大家最關心的多 server 下任務如何避免重複排程、多 server 如何實現水平的能力擴充套件本文都沒有詳細提及,只是簡單說了幾個字。具體的內容,就放在下一篇文章講啦~提前劇透一下吧,核心就四個字:分組隔離。等不及的話,自己去程式碼中尋找答案吧,少年~

專案地址:

https://github.com/KFCFans/PowerJob

相關文章