老板喊你設(shè)計(jì)一個(gè)高效的定時(shí)任務(wù)系統(tǒng)!
原創(chuàng)【51CTO.com原創(chuàng)稿件】今天想跟大家一起探討一個(gè)聽起來很簡單的話題:定時(shí)任務(wù)機(jī)制。
圖片來自 Pexels
無非就是一個(gè)計(jì)時(shí)器,到了指定時(shí)間就開始跑唄。too young,要是這么簡單我還說啥呢,干不就完了。
那如果是幾千上萬個(gè)定時(shí)任務(wù),你的計(jì)時(shí)器該如何設(shè)計(jì)呢?如果是 A 任務(wù)執(zhí)行完后再執(zhí)行 B 任務(wù)你會(huì)怎么調(diào)度呢?
如果是幾十臺機(jī)器同時(shí)要處理一些任務(wù),你又該如何設(shè)計(jì)呢?帶著這些看似不簡單的問題我們開始時(shí)間之旅。
操作系統(tǒng)的時(shí)間系統(tǒng)
應(yīng)用程序部署在操作系統(tǒng)上,定時(shí)任務(wù)依賴操作系統(tǒng)的時(shí)鐘。鑒于大部分的服務(wù)器都部署在 Linux 上,我們就只討論 Linux 的時(shí)間系統(tǒng),Windows 服務(wù)器別打我。
大部分 PC 機(jī)中有兩個(gè)時(shí)鐘源,他們分別叫做 RTC(Real Time Clock,實(shí)時(shí)時(shí)鐘) 和 OS(操作系統(tǒng))時(shí)鐘。
RTC
RTC(Real Time Clock,實(shí)時(shí)時(shí)鐘)也叫做 CMOS 時(shí)鐘,它是 PC 主機(jī)板上的一塊芯片(或者叫做時(shí)鐘電路),它靠電池供電,即使系統(tǒng)斷電也可以維持日期和時(shí)間。
由于獨(dú)立于操作系統(tǒng)所以也被稱為硬件時(shí)鐘,它為整個(gè)計(jì)算機(jī)提供一個(gè)計(jì)時(shí)標(biāo)準(zhǔn),是最原始最底層的時(shí)鐘數(shù)據(jù)。
OS 時(shí)鐘
OS 時(shí)鐘產(chǎn)生于 PC 主板上的定時(shí)/計(jì)數(shù)芯片(8253/8254),由操作系統(tǒng)控制這個(gè)芯片的工作,OS 時(shí)鐘的基本單位就是該芯片的計(jì)數(shù)周期。
在開機(jī)時(shí)操作系統(tǒng)取得 RTC 中的時(shí)間數(shù)據(jù)來初始化 OS 時(shí)鐘,然后通過計(jì)數(shù)芯片的向下計(jì)數(shù)形成了 OS 時(shí)鐘,所以 OS 時(shí)鐘并不是本質(zhì)意義上的時(shí)鐘,它更應(yīng)該被稱為一個(gè)計(jì)數(shù)器。
OS 時(shí)鐘只在開機(jī)時(shí)才有效,而且完全由操作系統(tǒng)控制,所以也被稱為軟時(shí)鐘或系統(tǒng)時(shí)鐘。
時(shí)鐘中斷
Linux 的 OS 時(shí)鐘的物理產(chǎn)生原因是可編程定時(shí)/計(jì)數(shù)器產(chǎn)生的輸出脈沖,這個(gè)脈沖送入 CPU,就可以引發(fā)一個(gè)中斷請求信號,我們就把它叫做時(shí)鐘中斷。
Linux 中用全局變量 jiffies 表示系統(tǒng)自啟動(dòng)以來的時(shí)鐘滴答數(shù)目。每個(gè)時(shí)鐘滴答,時(shí)鐘中斷得到執(zhí)行。
時(shí)鐘中斷執(zhí)行的頻率很高:100 次/秒(Linux 設(shè)計(jì)者將一個(gè)時(shí)鐘滴答(tick)定義為 10ms),時(shí)鐘中斷的主要工作是處理和時(shí)間有關(guān)的所有信息、決定是否執(zhí)行調(diào)度程序。
和時(shí)間有關(guān)的所有信息包括系統(tǒng)時(shí)間、進(jìn)程的時(shí)間片、延時(shí)、使用 CPU 的時(shí)間、各種定時(shí)器,進(jìn)程更新后的時(shí)間片為進(jìn)程調(diào)度提供依據(jù),然后在時(shí)鐘中斷返回時(shí)決定是否要執(zhí)行調(diào)度程序。
在單處理器系統(tǒng)中,每個(gè) tick 只發(fā)生一次時(shí)鐘中斷。在對應(yīng)的中斷處理程序中完成更新系統(tǒng)時(shí)間、統(tǒng)計(jì)、定時(shí)器、等全部功能。
而在多處理器系統(tǒng)下,時(shí)鐘中斷實(shí)際上是分成兩個(gè)部分:
- 全局時(shí)鐘中斷,系統(tǒng)中每個(gè) tick 只發(fā)生一次。對應(yīng)的中斷處理程序用于更新系統(tǒng)時(shí)間和統(tǒng)計(jì)系統(tǒng)負(fù)載。
- 本地時(shí)鐘中斷,系統(tǒng)中每個(gè) tick 在每個(gè) CPU 上發(fā)生一次。對應(yīng)的中斷處理程序用于統(tǒng)計(jì)對應(yīng) CPU 和運(yùn)行于該CPU上的進(jìn)程的時(shí)間,以及觸發(fā)對應(yīng) CPU 上的定時(shí)器。
于是,在多處理器系統(tǒng)下,每個(gè) tick,每個(gè) CPU 要處理一次本地時(shí)鐘中斷;另外,其中一個(gè) CPU 還要處理一次全局時(shí)鐘中斷。
時(shí)鐘中斷的應(yīng)用
更新系統(tǒng)時(shí)間:在 Linux 內(nèi)核中,全局變量 jiffies_64 用于記錄系統(tǒng)啟動(dòng)以來所經(jīng)歷的 tick 數(shù)。
每次進(jìn)入時(shí)鐘中斷處理程序(多處理器系統(tǒng)下對應(yīng)的是全局時(shí)鐘中斷)都會(huì)更新 jiffies_64 的值,正常情況下,每次總是給 jiffies_64 加 1。
而時(shí)鐘中斷存在丟失的可能。內(nèi)核中的某些臨界區(qū)是不能被中斷的,所以進(jìn)入臨界區(qū)前需要屏蔽中斷。
當(dāng)中斷屏蔽取消的時(shí)候,硬件只能告訴內(nèi)核是否曾經(jīng)發(fā)生了時(shí)鐘中斷、卻不知道已經(jīng)發(fā)生過多少次。
于是,在極端情況下,中斷屏蔽時(shí)間可能超過 1 個(gè) tick,從而導(dǎo)致時(shí)鐘中斷丟失。
如果計(jì)算機(jī)上的時(shí)鐘振蕩器有很高的精度,Linux 內(nèi)核可以讀振蕩器中的計(jì)數(shù)器,通過比較上一次讀的值與當(dāng)前值,以確定中斷是否丟失;如果發(fā)現(xiàn)中斷丟失,則本次中斷處理程序會(huì)給 jiffies_64 增加相應(yīng)的計(jì)數(shù)。
但是如果振蕩器硬件不允許(不提供計(jì)數(shù)器、或者計(jì)數(shù)器不允許讀、或者精度不夠),內(nèi)核也沒法知道時(shí)鐘中斷是否丟失了。
內(nèi)核中的全局變量 xtime 用于記錄當(dāng)前時(shí)間(自 1970-01-01 起經(jīng)歷的秒數(shù)、本秒中經(jīng)歷的納秒數(shù))。xtime 的初始值就是內(nèi)核啟動(dòng)時(shí)從 RTC 讀出的。
在時(shí)鐘中斷處理程序更新 jiffies_64 的值后,便更新 xtime 的值。通過比較 jiffies_64 的當(dāng)前值與上一次的值(上面說到,差值可能大于 1),決定 xtime 應(yīng)該更新多少。
系統(tǒng)調(diào)用 gettimeofday(對應(yīng)庫函數(shù) time 和 gettimeofday)就是用來讀 xtime 變量的,從而讓用戶程序獲取系統(tǒng)時(shí)間。
實(shí)現(xiàn)定時(shí)器:既然已知每個(gè) tick 是 10ms,用 tick 來做定時(shí)任務(wù)統(tǒng)計(jì)再好不過。無論是內(nèi)核還是應(yīng)用系統(tǒng)其實(shí)都有大量的定時(shí)任務(wù)需求,這些定時(shí)任務(wù)類型不一,但是都是依賴于 tick。
已知的操作系統(tǒng)實(shí)現(xiàn)定時(shí)任務(wù)的方式有哪些呢?
①維護(hù)一個(gè)帶過期時(shí)間的任務(wù)鏈表
簡單且行之有效的方式。在一個(gè)全局鏈表中維護(hù)一個(gè)定時(shí)任務(wù)鏈。每次 tick 中斷來臨,遍歷該鏈表找到 expire 到期的任務(wù)。
如果將任務(wù)以 expire 排序,每次只用找到鏈頭的元素即可,時(shí)間復(fù)雜度為 O(1)。
這種方式對于早期的 Linux 系統(tǒng)來說沒有問題,隨著現(xiàn)在的系統(tǒng)復(fù)雜度漸漸變化,它無法支撐如今的網(wǎng)絡(luò)流量暴增時(shí)代的需求。
②時(shí)間輪(Timing-Wheel)算法
時(shí)間輪很容易理解,上圖有 n 個(gè) bucket,每一個(gè) bucket 表示一秒,當(dāng)前 bucket 表示當(dāng)前這一秒到來之后要觸發(fā)的事件。
每個(gè) bucket 會(huì)對應(yīng)著一個(gè)鏈表,鏈表中存儲的就是當(dāng)前時(shí)刻到來要處理的事件。
那這里有個(gè)問題來了,如果有個(gè)定時(shí)任務(wù)需要在 16 小時(shí)后執(zhí)行,換算成秒就是 57600s,難道我們的時(shí)間輪也要這么多個(gè) bucket 嗎。幾萬個(gè)對內(nèi)存也是一種損耗。
為了減少 bucket 的數(shù)量,時(shí)間輪算法提供了一個(gè)擴(kuò)展算法,即 Hierarchy 時(shí)間輪。
Hierarchy 很好理解,層級制度。既然一個(gè)時(shí)間輪可能會(huì)導(dǎo)致 bucket 過多,那么為什么不能多弄幾個(gè)輪子來代替時(shí)分秒呢?
基于時(shí)、分、秒各自實(shí)現(xiàn)一個(gè) wheel,每個(gè) wheel 維護(hù)一個(gè)自己的 cursor,在 Hour 數(shù)組中,每個(gè) bucket 代表一個(gè)小時(shí)。
Minute 數(shù)組中每一個(gè) bucket 代表 1 分鐘,Second 數(shù)組中每個(gè) bucket 代表 1 秒。
采用分層時(shí)間輪,我們只需要 24+60+60=144 個(gè) bucket 就可以表示所有的時(shí)間。
完全模擬到時(shí)鐘的用法,Second wheel 每轉(zhuǎn)完 60 個(gè) bucket ,要聯(lián)動(dòng) Minute wheel 轉(zhuǎn)動(dòng)一格,同理 Minite wheel 轉(zhuǎn)動(dòng) 60 個(gè) bucket 也要聯(lián)動(dòng) Hours wheel 轉(zhuǎn)動(dòng)一格。
③維護(hù)一個(gè)基于小根堆算法的定時(shí)任務(wù)
小根堆的性質(zhì)是滿足除了根節(jié)點(diǎn)以外的每個(gè)節(jié)點(diǎn)都不小于其父節(jié)點(diǎn)的堆?;谶@種性質(zhì)從根節(jié)點(diǎn)開始遍歷每個(gè)節(jié)點(diǎn)能保證獲取到一個(gè)最小優(yōu)先級的隊(duì)列。
那么應(yīng)用到定時(shí)器中,每次只用獲取當(dāng)前最小堆的 root 節(jié)點(diǎn)看是否到期即可。最小堆的插入時(shí)間復(fù)雜度為 O(lgn),獲取頭結(jié)點(diǎn)時(shí)間復(fù)雜度為 O(1)。
開箱即用的定時(shí)器
單機(jī)版定時(shí)器
①cron/crontab
cron 是 Linux 中的一個(gè)定時(shí)任務(wù)機(jī)制。cron 表示一個(gè)在后臺運(yùn)行的守護(hù)進(jìn)程,crontab 是一個(gè)設(shè)置 cron 的工具,所有的定時(shí)任務(wù)都寫在 crontab 文件中。
cron 調(diào)度的是 /etc/crontab 文件中的內(nèi)容。crontab 的命令構(gòu)成為時(shí)間+動(dòng)作,其時(shí)間有分、時(shí)、日、月、周五種。
這里要注意,最小單位為分鐘,默認(rèn)是不到秒的級別,大家也給出了各種精確到秒的方案,有興趣的可以搜索一下。
/etc/crontab 文件中的每一行都代表一項(xiàng)任務(wù),它的格式是:
- minute hour day month dayofweek user-name command
- * minute — 分鐘,從 0 到 59 之間的任何整數(shù)
- * hour — 小時(shí),從 0 到 23 之間的任何整數(shù)
- * day — 日期,從 1 到 31 之間的任何整數(shù)(如果指定了月份,必須是該月份的有效日期)
- * month — 月份,從 1 到 12 之間的任何整數(shù)(或使用月份的英文簡寫如 jan、feb 等等)
- * dayofweek — 星期,從 0 到 7 之間的任何整數(shù),這里的 0 或 7 代表星期日(或使用星期的英文簡寫如 sun、mon 等等)
- * user-name - 用戶,腳本以什么用戶執(zhí)行
- * command — 要執(zhí)行的命令(命令可以是 ls /proc >> /tmp/proc 之類的命令,也可以是執(zhí)行你自行編寫的腳本的命令。)
②JDK 提供的定時(shí)器:Timer
Timer 的思路很簡單,基于最小堆的方案創(chuàng)建一個(gè) TaskQueue 來盛 TimerTask。
Timer 中有一個(gè) TimerThread 線程,該線程是 Timer 中唯一負(fù)責(zé)任務(wù)輪詢和任務(wù)執(zhí)行的線程。
這就意味著如果一個(gè)任務(wù)耗時(shí)很久,久到已經(jīng)超過了下個(gè)任務(wù)的開始執(zhí)行時(shí)間,那么就意味下一個(gè)任務(wù)會(huì)延遲執(zhí)行。
另外 Timer 線程是不會(huì)捕獲異常的,如果某個(gè) TimerTask 執(zhí)行過程中發(fā)生了異常而被終止,那么后面的任務(wù)將不會(huì)被執(zhí)行。所以要做好異常處理防止出現(xiàn)異常影響任務(wù)繼續(xù)。
因?yàn)橛凶枞彤惓=K止的缺點(diǎn),JDK 又封裝了另一個(gè)定時(shí)器的實(shí)現(xiàn)方式,這次保證不會(huì)阻塞。
因?yàn)樗蔷€程池實(shí)現(xiàn)方式的一種:ScheduledExecutorService。ScheduledExecutorService 內(nèi)部將任務(wù)封裝之后交給了 DelayQueue。
DelayQueue 是一個(gè)依靠 AQS 隊(duì)列同步器所實(shí)現(xiàn)的無界延遲阻塞隊(duì)列,內(nèi)部通過 PriorityQueue 來實(shí)現(xiàn),本質(zhì)還是還是一個(gè)堆,所以插入的時(shí)間復(fù)雜度也是 O(lgn)。
③Netty 封裝的時(shí)間輪:HashedWheelTimer
上面簡要描述了操作系統(tǒng)中的時(shí)間輪實(shí)現(xiàn),在著名框架 Netty 中也封裝了一個(gè)自己的時(shí)間輪實(shí)現(xiàn):HashedWheelTimer 類。
因?yàn)?Netty 中需要管理大量的 I/O 超時(shí)事件,基于時(shí)間輪的方案有助于節(jié)省資源。
Netty 中采用一個(gè)輪子的方案,一個(gè)格子代表的時(shí)間是 100ms,默認(rèn)有 512 個(gè)格子。
來看看 HashedWheelTimer 的構(gòu)造函數(shù)參數(shù):
- HashedWheelTimer(
- ThreadFactory threadFactory, //類似于Clock中的updater, 負(fù)責(zé)創(chuàng)建Worker線程.
- long tickDuration, //時(shí)間刻度之間的時(shí)長(默認(rèn)100ms), 通俗的說, 就是多久tick++一次.
- TimeUnit unit, //tickDuration的單位.
- int ticksPerWheel //類似于Clock中的wheel的長度(默認(rèn)512).
- );
另外為了不無休止的增加 bucket,這里采用了輪(round)的概念,一輪所花費(fèi)的時(shí)間:round time=ticksPerWheel*tickDuration。
如果 bucket 只有 512 個(gè), 而當(dāng)前休眠時(shí)間長于一輪,那么就增加相應(yīng)的輪次來表示當(dāng)前休眠時(shí)長。
HashedWheelTimer 中有一些主要的成員:
- HashedWheelTimer 類本身,主要負(fù)責(zé)啟動(dòng) Worker 線程、添加任務(wù)等。
- Worker:內(nèi)部負(fù)責(zé)添加任務(wù),累加 tick,執(zhí)行任務(wù)等。
- HashedWheelTimeout:任務(wù)的包裝類,鏈表結(jié)構(gòu),負(fù)責(zé)保存 deadline,輪數(shù)等。
- HashedWheelBucket:wheel 數(shù)組元素,負(fù)責(zé)存放 HashedWheelTimeout 鏈表。
Worker 線程是 HashedWheelTimer 的核心,主要負(fù)責(zé)每當(dāng)已過 tickDuration 時(shí)間就累加一次 tick。
同時(shí)也負(fù)責(zé)執(zhí)行到期的 timeout 任務(wù)和添加 timeout 任務(wù)到指定的 wheel 中。
當(dāng)添加 Timeout 任務(wù)的時(shí)候,會(huì)根據(jù)設(shè)置的時(shí)間來計(jì)算出需要等待的時(shí)間長度,根據(jù)時(shí)間長度進(jìn)而算出要經(jīng)過多少次 tick,然后根據(jù) tick 的次數(shù)來算出經(jīng)過多少輪最終得出 task 在 wheel 中的位置。
對于這種時(shí)間輪一般是怎么遍歷判斷任務(wù)到期呢?每個(gè) ticket 到來,都要去遍歷每一個(gè) bucket ,以此來判斷是否有 bucket 到期。
所以這種方式就要求 bucket 盡量不要太多,如果太多每次遍歷都需要很長的時(shí)間。另外就是每次都會(huì)遍歷,必然會(huì)有很多空轉(zhuǎn),也是一種資源的浪費(fèi)。
④Kafka 中的時(shí)間輪:TimingWheel
Netty 中的時(shí)間輪實(shí)現(xiàn)采用了單輪+round 的模式,在 Kafka 中采用了多輪的模式。
上面說過多輪模式下如果按照時(shí)分秒來表達(dá),每個(gè)輪所需的 bucket 都非常的少,遍歷輪的時(shí)候就會(huì)很快。
但是多輪也會(huì)帶來另一個(gè)問題就是輪的維護(hù):比如有個(gè)定時(shí)任務(wù)是 1*60*60+50=36050s,這時(shí)候就需要分鐘和秒輪同時(shí)維護(hù)這個(gè)任務(wù)。
當(dāng)這個(gè)任務(wù)繼續(xù)走,只剩下 59s 的時(shí)候,分鐘輪就無需在維護(hù)它的信息,只剩下秒輪來維護(hù),這里出現(xiàn)了降輪的概念 。
單機(jī)定時(shí)機(jī)制對比
以上簡單描述了各個(gè)實(shí)現(xiàn)方案,簡單對比可以得出:
Timer 的實(shí)現(xiàn)方案毋庸置疑是最差的。阻塞,異常退出這兩條“罪名”無疑讓現(xiàn)代程序員無法承受因?yàn)槌鲥e(cuò)被老板罵的鍋。
ScheduledExecutorService 使用線程池的方式來異步的執(zhí)行任務(wù),當(dāng)任務(wù)量巨大的時(shí)候,如果設(shè)置了優(yōu)先數(shù)量的可執(zhí)行線程,無疑還是會(huì)阻塞任務(wù),好在可執(zhí)行線程多。
而 HashedWheelTimer 是面向 bucket 設(shè)計(jì),如果采用多輪的方式可以不受任務(wù)量限制,任務(wù)量非常大的時(shí)候,維護(hù)數(shù)組的成本遠(yuǎn)遠(yuǎn)要低于維護(hù)堆的成本。
但是如果是任務(wù)量很少的情況,時(shí)間輪依舊需要全盤掃描,出現(xiàn)空轉(zhuǎn)的狀態(tài),這種空載無疑也是浪費(fèi)資源的提現(xiàn)。
所以面向使用場景編程的話:
- 如果當(dāng)前待運(yùn)行的定時(shí)任務(wù)屬于耗時(shí)長一點(diǎn),任務(wù)量也不是那么大的時(shí)候,可以采用 ScheduledExecutorService 的方式來實(shí)現(xiàn)。
- 如果任務(wù)量比較大,任務(wù)耗時(shí)短,無疑使用 HashedWheelTimer 對內(nèi)存更加友好。
定時(shí)任務(wù)系統(tǒng)
前面從操作系統(tǒng)時(shí)鐘源開始,說到時(shí)鐘中斷產(chǎn)生了時(shí)鐘滴答,所有的定時(shí)任務(wù)都依賴于此。
軟件層面,通過各種有效的算法在節(jié)約資源的前提下通過監(jiān)聽時(shí)鐘滴答來實(shí)現(xiàn)任務(wù)。
還記得開篇提到我們本篇文章的意圖是什么嗎,要設(shè)計(jì)一個(gè)高效的定時(shí)任務(wù)系統(tǒng)。
既然談到了設(shè)計(jì),是不是要先出一版產(chǎn)品需求文檔呢。這個(gè)真的可以有,我們先提提需求再聊聊方案。
你要的需求設(shè)計(jì)
定時(shí)任務(wù)系統(tǒng)的核心功能是什么?既然是第一版,我們不要那些花里胡哨,錦上添花的功能,從本質(zhì)出發(fā)。
我理解應(yīng)該有三個(gè)核心模塊:
任務(wù)錄入:提供錄入定時(shí)任務(wù)的入口,支持最基本的定時(shí)任務(wù)機(jī)制:cron 表達(dá)式,自定義執(zhí)行時(shí)間等等方式。
任務(wù)調(diào)度:通過合適的調(diào)度算法從任務(wù)庫中觸發(fā)到期的任務(wù)以期執(zhí)行,當(dāng)然調(diào)度系統(tǒng)最好不要直接參數(shù)執(zhí)行,做好自己的事即可。
任務(wù)執(zhí)行:調(diào)度系統(tǒng)已經(jīng)觸發(fā)了任務(wù),那么可以由專門的執(zhí)行系統(tǒng)來負(fù)責(zé)任務(wù)執(zhí)行,執(zhí)行不會(huì)阻塞任務(wù)調(diào)度,縱然執(zhí)行有阻塞也是在執(zhí)行系統(tǒng)中阻塞,保持調(diào)度的可用性。
以上 3 個(gè)模塊就能滿足基本的任務(wù)系統(tǒng)需求,接下來聊聊實(shí)現(xiàn)方案。
技術(shù)實(shí)現(xiàn)方案
①錄入模塊實(shí)現(xiàn)
一般執(zhí)行定時(shí)任務(wù)的場景是:每隔多久執(zhí)行一次操作,這種在業(yè)務(wù)系統(tǒng)中最常見的就是使用 cron 表達(dá)式來代替,所以錄入模塊要做到可以解析 cron 表達(dá)式即可。
這種錄入模式主要是針對后臺手動(dòng)錄入任務(wù)的場景,對于開發(fā)人員來說最優(yōu)解就是能用代碼實(shí)現(xiàn)就不去切換鼠標(biāo)(有同學(xué)說能點(diǎn)點(diǎn)鼠標(biāo)誰還去砌磚)。
所以還需要提供可執(zhí)行 jar 包用于業(yè)務(wù)系統(tǒng)集成,方便開發(fā)人員通過編碼的方式將任務(wù)錄入到系統(tǒng)。
總結(jié)一下錄入任務(wù)的兩種途徑:
- 提供業(yè)務(wù)系統(tǒng)可集成 jar 包,由開發(fā)人員編碼錄入任務(wù)。
- 提供管理后臺界面,提供可配置方式錄入任務(wù)。
對于業(yè)務(wù)代碼植入式的任務(wù)業(yè)務(wù)服務(wù)器啟動(dòng)的時(shí)候會(huì)通過 jar 包把任務(wù)推送過來,對于后臺錄入的任務(wù)那就需要入庫保存。
②調(diào)度模塊實(shí)現(xiàn)
在拿到錄入模塊的定時(shí)任務(wù)配置信息之后接下來要做的事情:將 cron 表達(dá)式變?yōu)橐粋€(gè)個(gè)可執(zhí)行的時(shí)間點(diǎn)。
比如在 Spring 中就已經(jīng)提供解析 cron 的功能:CronSequenceGenerator 類可以幫我們執(zhí)行此操作。
有了可執(zhí)行時(shí)間點(diǎn)之后要做的事情就是管理它,讓它調(diào)度起來。上面我們討論過的各種調(diào)度算法此時(shí)可以派上用場。
如果任務(wù)密度不是很大,多為固定的定期執(zhí)行任務(wù),小根堆算法就可以勝任;如果任務(wù)密集,很多短期快速執(zhí)行的任務(wù),可以采用時(shí)間輪的方式提高效率。
另外,比如有個(gè)任務(wù)是 5 分鐘執(zhí)行一次,那么你一次要解析出來多少個(gè)可執(zhí)行的時(shí)間點(diǎn)?一天,一周,一個(gè)月?
這樣肯定是有問題的,目前的實(shí)現(xiàn)方案是任務(wù)首次啟動(dòng)的時(shí)候給出第一次執(zhí)行的時(shí)間,每次執(zhí)行的時(shí)候去計(jì)算下次任務(wù)開始的時(shí)間。
這里有一個(gè)點(diǎn):Java 相關(guān)的框架現(xiàn)在實(shí)現(xiàn)的方案都是當(dāng)前任務(wù)執(zhí)行完成之后再計(jì)算下次任務(wù)開始執(zhí)行的時(shí)間。
如果任務(wù)是 5 分鐘一次,當(dāng)前時(shí)間是:10:00,第一個(gè)任務(wù)完成需要 6 分鐘,那么第二個(gè)任務(wù)開始的時(shí)間就是:
我們預(yù)期是每隔 5 分鐘執(zhí)行一次,事實(shí)上除了第一次是按照預(yù)期的準(zhǔn)點(diǎn)執(zhí)行以外,后面都會(huì)在絕對時(shí)間上有延期。
到這里我們解決了兩個(gè)問題:
- 解析時(shí)間表達(dá)式為時(shí)間點(diǎn),如何確認(rèn)周期性任務(wù)的下一個(gè)可執(zhí)行時(shí)間點(diǎn)。
- 將可執(zhí)行時(shí)間點(diǎn)送入調(diào)度器中,讓時(shí)間流動(dòng)起來。
③任務(wù)執(zhí)行模塊
任務(wù)錄入,任務(wù)調(diào)度我們都完成了,執(zhí)行模塊才是最后的重頭戲。這里我們再細(xì)化一下,任務(wù)錄入不能說只是把任務(wù)所屬的表達(dá)式載入系統(tǒng)就完事,要把任務(wù)對象化,達(dá)到招手即用的狀態(tài)。
這里我們把每個(gè)任務(wù)都封裝為一個(gè)對象 Job,所有的 Job 都在內(nèi)存中加載,調(diào)度器定義為 Scheduler,把每個(gè)可執(zhí)行時(shí)間封裝為 Trigger 對象。
Trigger 用于定義調(diào)度任務(wù)的事件規(guī)則,唯一關(guān)聯(lián)一個(gè) Job 并標(biāo)識當(dāng)前 Job 的執(zhí)行狀態(tài)。
上圖就是我們的極簡版定時(shí)任務(wù)系統(tǒng)核心功能,怎么樣,麻雀雖小,五臟俱全。該有的功能一樣不少,不該有的功能一個(gè)都沒有。
到這里為止我們已經(jīng)輸出了極簡版定時(shí)任務(wù)調(diào)度系統(tǒng)的核心設(shè)計(jì)和實(shí)現(xiàn)方案,依據(jù)這個(gè)方案你可以實(shí)現(xiàn)定時(shí)任務(wù)調(diào)度系統(tǒng)的單機(jī)版核心功能。
我們先不提加需求的問題,先來個(gè)高可用的問題,上面的方案是將任務(wù)加載到一臺機(jī)器的內(nèi)存中定時(shí)執(zhí)行,那么如果要實(shí)現(xiàn)高可用,多臺機(jī)器的情況任務(wù)如何防止多次執(zhí)行呢?
很顯然上面的方案肯定是行不通了,下面我們開始擴(kuò)容。
高可用
回答高可用的問題先說目前的思路:單機(jī)純內(nèi)存抗所有任務(wù)。要做高可用必然會(huì)大于等于 2 臺機(jī)器。
那么兩臺機(jī)器都執(zhí)行任務(wù)必然會(huì)重復(fù)運(yùn)行,該用什么方案在多機(jī)環(huán)境中可以統(tǒng)一管理,統(tǒng)一調(diào)度,統(tǒng)一運(yùn)行任務(wù)呢?
方案一:傳統(tǒng)方案-數(shù)據(jù)庫(獨(dú)占鎖)
任務(wù)觸發(fā)的關(guān)鍵在于 Trigger 觸發(fā)器,我們只用管住 Trigger 的手讓它別亂動(dòng) task 就好,基于數(shù)據(jù)庫操作的話,保證任一時(shí)刻某個(gè) Trigger 只會(huì)被觸發(fā)一次即可。這里可以使用行級鎖來實(shí)現(xiàn)。
某臺機(jī)器執(zhí)行到這個(gè) Trigger 的時(shí)候向數(shù)據(jù)庫插入一條 Trigger 記錄并持有該鎖,那么其余機(jī)器即使遇到了這個(gè)任務(wù)也不能執(zhí)行。
方案二:分布式組件特性支持(分布式鎖)
一般來說數(shù)據(jù)庫肯定是值得信任的,但是面對實(shí)施要求高,任務(wù)執(zhí)行頻繁的場景的時(shí)候,數(shù)據(jù)庫又是不敢信任的,數(shù)據(jù)庫有一定的并發(fā)瓶頸。
要保證同一時(shí)刻的唯一性,除了數(shù)據(jù)庫的鎖特性以外,分布式組件肯定也支持,比如 Zookeeper,ETCD 等等。
可以利用 ZK 的臨時(shí)節(jié)點(diǎn)性質(zhì),同一個(gè)任務(wù)注冊一個(gè)唯一的節(jié)點(diǎn),哪個(gè)機(jī)器搶到這個(gè)節(jié)點(diǎn)誰就來執(zhí)行任務(wù)即可。
產(chǎn)品加需求
基礎(chǔ)功能我們已經(jīng)完成,高可用也做到了,上線一段時(shí)間,產(chǎn)品覺得的整點(diǎn)幺蛾子啊,不然 KPI 咋整。
①新增功能
基于事件分發(fā)的任務(wù)機(jī)制:可能有一些任務(wù)是基于特定的條件觸發(fā),這種任務(wù)在分布式環(huán)境下一般自己實(shí)現(xiàn)分布式鎖來實(shí)現(xiàn),那么任務(wù)系統(tǒng)既然提供分布式特性也可以實(shí)現(xiàn)分布式鎖的功能。
所以對于這一類任務(wù)完全可以交給任務(wù)系統(tǒng)來做,把它當(dāng)成一次性觸發(fā)的任務(wù)。
②新增特性
任務(wù)終止:如果某個(gè)任務(wù)因?yàn)闃I(yè)務(wù)需求不再執(zhí)行,那么是否可以不發(fā)布的條件下終止該任務(wù)呢?這個(gè)時(shí)候任務(wù)終止的功能就很重要,產(chǎn)品經(jīng)理暗暗自喜,老板加雞腿。
任務(wù)依賴:B 任務(wù)依賴 A 任務(wù)的結(jié)果才能執(zhí)行,所以要提供任務(wù)之間的級聯(lián)操作。
任務(wù)分片:如果我們有 3 臺執(zhí)行任務(wù)的機(jī)器,有 10 個(gè)每 5s 執(zhí)行一次的定時(shí)任務(wù),恰恰每個(gè)任務(wù)都打到第一臺機(jī)器。它累如黃牛的時(shí)候另外兩臺還在曬太陽這豈不是資源的浪費(fèi)嘛。
為了避免任務(wù)集中到某一臺機(jī)和提高資源利用率,我們需要一種將任務(wù)均衡分配到當(dāng)前所有可執(zhí)行機(jī)器的能力,這就是所謂的分片機(jī)制。
常用的分片算法有如下:
平均分配算法:
- 如果有 3 個(gè)任務(wù)實(shí)例,分成 9 片,每個(gè)實(shí)例對應(yīng)到的分片就是:1=[1,2,3],2=[4,5,6],3=[7,8,9]。
- 如果有 3 個(gè)任務(wù)實(shí)例,分成 8 片,每個(gè)實(shí)例對應(yīng)到的分片就是:1=[0,1,6],2=[2,3,7],3=[4,5]。
- 如果有 3 個(gè)任務(wù)實(shí)例,分成 10 片,每個(gè)實(shí)例對應(yīng)到的分片就是:1=[0,1,2,9],2=[3,4,5],3=[6,7,8]。
根據(jù)作業(yè)名 hash 值決定根據(jù) IP 升序/降序算法:
- 如果有 3 個(gè)任務(wù)實(shí)例分別為 1,2,3,作業(yè)名稱對應(yīng)的 hash 值如果為奇數(shù)就按照 IP 升序?qū)ふ覚C(jī)器執(zhí)行,作業(yè)名稱對應(yīng)的 hash 值如果為偶數(shù)就按照 IP 降序?qū)ふ覚C(jī)器執(zhí)行。這種算法最多要求最多只有兩個(gè)分片,即只有兩臺機(jī)器參與執(zhí)行。
輪詢算法:
- 輪詢的原理就很簡單,基于可執(zhí)行機(jī)器依次執(zhí)行。
任務(wù)日志:日志功能肯定不可少,檢測任務(wù)執(zhí)行成功與否,任務(wù)執(zhí)行記錄、時(shí)長,統(tǒng)計(jì)任務(wù)系統(tǒng)每日任務(wù)量等等。
③新增容錯(cuò)機(jī)制
容錯(cuò)機(jī)制:任務(wù)執(zhí)行失敗,可能是任務(wù)本身邏輯問題,也可能是外部條件,所以可以設(shè)置一些容錯(cuò)機(jī)制,給它一次重試的機(jī)會(huì)。
故障轉(zhuǎn)移:集群中如果某一臺機(jī)器發(fā)生了故障,它如果還在注冊中心注冊,那么任務(wù)會(huì)被該機(jī)器執(zhí)行,很顯然如果僅有失敗重試策略,那么這個(gè)任務(wù)永遠(yuǎn)都不會(huì)執(zhí)行成功。
首先需要心跳檢測機(jī)制,檢測活動(dòng)機(jī)器是否健康;其次需要在重試失敗之后做任務(wù)轉(zhuǎn)移操作,防止多次失敗仍在同一臺機(jī)器吊死。
手動(dòng)觸發(fā):如果萬不得已遇到任務(wù)沒有執(zhí)行到的情況 ,那么是否要提供手動(dòng)觸發(fā)的機(jī)制呢?我想產(chǎn)品經(jīng)理這種人精肯定不想背鍋,所以你還是做吧!
后話
做完上面的功能之后,產(chǎn)品經(jīng)理躺在他的折疊床上打著呼嚕鼻子不時(shí)的還冒幾個(gè)泡安心的睡著了。
程序員小哥整苦逼的構(gòu)思這些功能該如何實(shí)現(xiàn),是給 3 天還是給 3 個(gè)月,一場人月神話即將上演。
目前圈子里比較流行的定時(shí)任務(wù)系統(tǒng)有 Quartz,XXLJob,Elastic Job 等,實(shí)現(xiàn)方式不會(huì)脫離上文描述的范圍。
這些都是程序員自己沒事?lián)v鼓的實(shí)用型系統(tǒng),有需求就有產(chǎn)出,有方向就有動(dòng)力。
工作之余大家也可以自己思考目前在寫的東西是否可以抽象為大層次的一個(gè)功能,簡單說,你是否也能整出個(gè)中臺來。
在這個(gè)萬物皆中臺的時(shí)代,大家不遺余力的照虎畫瓢,雖說可能畫出個(gè)四不像,起碼對于寫代碼的人,抽象能力是得到了鍛煉。
作者:楊越
簡介:目前就職廣州歡聚時(shí)代,專注音視頻服務(wù)端技術(shù),對音視頻編解碼技術(shù)有深入研究。日常主要研究怎么造輪子和維護(hù)已經(jīng)造過的輪子,深耕直播類 APP 多年,對垂直直播玩法和應(yīng)用有廣泛的應(yīng)用經(jīng)驗(yàn),學(xué)習(xí)技術(shù)不局限于技術(shù),歡迎大家一起交流。
編輯:陶家龍
征稿:有投稿、尋求報(bào)道意向技術(shù)人請聯(lián)絡(luò) editor@51cto.com
【51CTO原創(chuàng)稿件,合作站點(diǎn)轉(zhuǎn)載請注明原文作者和出處為51CTO.com】