再談時間輪

時間輪很早前就很流行了,在很多優秀開源框架中都有用到,像kafka、netty。也算是現在工程師基本都瞭解的一個知識儲備了。有幸在工作中造過兩次輪子,所以今天聊聊時間輪。

時間輪是一種高性能定時器。

時間輪,顧名思義,就是一個基於時間的輪子,輪子劃分爲多個槽,每個槽代表一個時間跨度,槽的數量*時間跨度等於時間輪可以支持的最大延遲時間。在每個槽上掛載若干同一時間跨度內需要執行的任務。隨着時間的流動,每過一個時間跨度,當前位置向前推進一格,觸發當前槽掛載的所有定時任務。

定時任務如何找到需要掛載的槽呢,我們可以利用公式來計算:

targetSlot=delay/slotDuration+currentSlot

delay:延遲時間

slotDuration:槽時間跨度

currentSlot:當前推進的槽

例如,我們時間輪精度爲1s,當前推進到了第10個槽,新來的延遲10s的任務放在第20個槽上。

 

在實際應用中,一般單時間輪無法滿足需求。例如我們需要秒級的精度,最大延遲可能是10天,那我們時間輪就要至少864000個格子,這就產生了如下幾個問題:

  1. 佔用存儲過大
  2. 利用率太低,比如我們只有1秒一個任務,而7天若干任務,那大部分時間,整個輪子都是在空轉。

所以一般又會有對基本的時間輪做一些改造。一般有兩種改造方式,我們依次介紹一下。

第一種改造,對每個任務增加一個round屬性,即輪次的屬性,只有當前任務的round=0時纔會觸發執行,如果發現round>0,則只需要對round減一即可。

比如我們一個輪子還是秒級精度,總共3600個槽,即單圈支持1小時的延遲。當我們有一個需要延遲3小時的任務時,我們只需要把任務放到指定槽,並且設置round=3即可。

計算公式如下:

slot=(delay/slotDuration)%slotSize+tick

round=math.floor((delay/slotDuration)/slotSize)

這樣又會產生另外一個問題,即每個槽上的任務數量過多,並且大部分是不需要執行的,只需要進行round-1的任務,又會產生性能問題。

第二種改造,多級時間輪。

 

我們可以把時間輪分級,n+1層時間輪的槽時間跨度爲n層時間輪的一圈的總時間跨度,所以當n層時間輪推進一圈時,n+1層時間輪推進一個槽,並且只有第一層時間輪實際處理定時任務,其餘n+1層時間輪轉動後,都是把當前槽的任務降級掛載到第n層時間輪上。

例如,我們如圖有三級時間輪,一級時間輪每個槽1秒時間跨度,3600槽,即一圈總時間跨度1小時。二級時間輪每個槽1小時時間跨度,24個槽,即一圈總時間跨度1天。三級時間輪每個槽1天時間跨度,10個槽,即總時間跨度10天。

我們只需要3600+24+10=3634個槽,就可以支持1秒級精度,最大10天的時間延遲,相比於單級時間輪的864000個槽,是很大的優化。

當我們有一個5小時10分鐘的定時任務,我們可以很容易看出他應該屬於第二個時間輪,按照前面的公式掛載到相應的位置。當一級時間輪推進5圈後,即二級時間輪推進5次後,處理到該定時任務所在的槽,該定時任務只剩下10分鐘延遲,再通過公式把該定時任務降級到一級時間輪的指定槽中。

但是也產生了另外一個問題,即n級時間輪推進一圈後,需要等待n+1級時間輪降級後纔可以繼續推進,如果n+1級時間輪的降級操作很耗時,則會影響n級時間輪的正常推進。

所以一般會採用預熱的方式,提前觸發n+1級時間輪的降級,解耦多級時間輪之間推進的強關聯,保證一級時間輪推進的連續性。預加載方式很多,比如n級時間輪增加round信息,n+1級時間輪推進時處理下一槽,而不處理當前槽;比如不等n級時間輪轉一圈後再推進n+1級時間輪,可以在推進一半或某些位置時,提前觸發n+1級時間輪的降級;等等。

這樣我們就解決了佔用內存過大的問題,一般兩種模型會結合使用。

對於時間輪空轉的問題依舊存在,一般我們還會結合延遲隊列來配合時間輪的推進。

一般會把每個使用到的槽都會放到DelayQueue中,然後根據DelayQueue來協助時間輪的推進,防止空推進的情況。

例如,當有延遲500s的任務時,除了掛載到時間輪外,我們還會把其放到DelayQueue中,這樣DelayQueue的頭結點爲延遲500s,如果期間沒有小於500s的延遲任務再加進來時,我們只需要等待500s,時間輪推進一次即可。如果有小於500s的定時任務新加進來,我們只需要喚醒DelayQueue,重新計算等待時間即可。

即當有定時任務新增時,如果對應槽爲新槽(即新增任務爲該槽的第一個任務),在DelayQueue中增加延遲任務,並判斷是否爲頭結點,是的話喚醒DelayQueue重新計算等待時間。

這樣我們對於時間輪的改造就完成了。

那麼接下來看一下在實際工作中,我們是如何使用的,並且使用到了什麼場景。

第一個造輪子場景就是我們最常見的延遲任務。場景是,一大批的數據庫執行的任務,每行記錄都可以自行設置更新、刪除操作,並且可以設置任意的延遲時間。

這個場景就非常適合使用時間輪,首先因爲數據量非常大,而一般我們用的DelayQueue的插入、刪除時間複雜度都爲nlongn,我們的時間精度要求非常高,所以不太適合。如果對每條記錄都創建一個定時任務,那更不現實。並且我們是一個微服務架構,產生定時任務的服務與處理定時任務的模塊是分開的。

因爲當時業務場景,單級時間輪完全可以滿足,所以就利用了redis來實現了單級時間輪的功能。

首先,我們在redis中按照時間精度存了若干個key-list結構,key爲槽所對應的延遲時間,這裏與上面介紹的時間輪不同,這裏的時間不是相對延遲時間,而是絕對的時間戳。list就保存了該槽掛載的所有任務,這裏爲數據庫主鍵與操作類型。

當有新任務產生時,首先計算出實際執行的時間戳,並轉換爲我們需要的精度,然後利用lpush放到對應key的list裏。在我們的定時任務處理服務中,會通過sleep的方式來推進時間輪,每推進一次,根據當前時間l前時間lrange對應key的定時任務,然後執行,最後把key刪除。

這會存在可靠性的問題,如果定時任務服務掛了,宕機期間未執行的定時任務都無法再執行了。因此我們還會在redis中保存一個key-value,用來記錄已經推進完的key。這樣當服務重啓時,首先從k/v結構中獲取已經推進的位置,然後從該位置連續推進到當前時間戳。正常服務運行時,每推進一次,都會更新一次k/v結構中的值,更新已經推進的位置。

以上是第一個造輪子的場景,利用redis實現了一個簡單的一級時間輪。

 

第二個造輪子的場景是消息隊列對於任意延遲消息的支持。

這裏我們採用了兩級時間輪+多round組合的方式來實現。一級時間輪爲1s精度,3600個槽的時間輪,二級時間輪爲1h精度,240個槽的時間輪。這樣我們就可以支持1s精度,最大10天的任意延遲消息。

時間輪實際保存爲一個數組結構,數組每個位置爲一個鏈表,保存所有的任務,通過本地sleep的方式進行推進。

我們以RocketMQ爲例,說明如何支持任意延遲消息的。我們知道RocketMQ中接收到一條消息後,通過DispatchServer會將其保存到commitLog與consumeQueue中。我們在其中又加入一個流程,即如果是任意延遲消息類型,會將其保存到DelayLog與DelayLogIndex中。DelayLog按小時存儲,每小時內的消息存儲到一組文件中,保存實際的消息。DelayLogIndex則保存了消息的索引,即發送的timeStamp、所處DelayLog的offset、與消息的大小size。

一級時間輪上掛載了當前小時所有DelayLogIndex。二級時間輪上,掛載了所有的DelayLog,當然這裏只是一個文件引用,不會保存文件內容,否則內存壓力會很大。

當有新消息進來時,首先計算出其實際發送的時間戳,判斷是否可以直接掛載到一級時間輪上,並且如果該消息爲當前消息的第一條消息,則需要將DelayLog掛載到二級時間輪的指定槽上。

每秒一級時間輪推進時,通過DelayLogIndex從DelayLog中獲取實際消息內容,重新保存到commitLog與consumeQueue中,這樣消費者就可以消費到了數據。

每一個小時二級時間輪推進時,要獲取當前槽處的DelayLog,將其對應的DelayLogIndex全部掛載到一級時間輪上。

因爲有多級時間輪推進的問題,所以一般也都會採用預加載的方式。哪種方案都可以採用。

這裏我們同樣面臨第一個場景的問題,可靠性。如果服務端宕機,如何恢復數據。這個就不像第一個任務那麼簡單,只需要記錄已經推進完哪個slot即可,因爲數據庫操作我們可以做到冪等,而這裏我們需要保證消息的不丟不重。

所以,我們不僅需要記錄當前已經推進slot,還需要記錄推進到當前slot掛載的任務鏈表的哪個位置。這就需要恢復數據的穩定性,即數據恢復後,鏈表與最原始鏈表一致。所以我們在掛載數據時,需要按照消息到達服務端先後順序進行排序,並且每處理一條消息,要記錄一下該消息的位置。在啓動恢復時,就可以根據該位置只處理其後的消息,這樣,我們就可以保證消息在時間輪上不重不丟。

這樣結束了嗎?我們繼續看一個問題,我們算一條索引30B,一級時間輪有3600個槽,兩個round,一共是7200個槽。10GB內存平均每個槽可以存儲多少條索引?(10*1024*1024*1024)/(30*7200)=49710。也就是說我們最大能支持的QPS只有不到5W,這也太低了。怎麼解決這個問題?

我們提出一種文件倒排鏈表的組織結構,每個DelayLogIndex增加一個前置索引preIndex,把每秒鐘的延遲消息按照時間上的倒敘串成鏈表,即鏈表頭爲當前秒的最後一條消息的DelayLogIndex,其preIndex指向前一條消息。這樣我們只需要一級時間輪上保存鏈表頭即可。當推進到某個槽時,我們通過鏈表頭可以倒敘遍歷找到該秒的所有DelayLogIndex。如此解決了內存問題。

但是又會引入另一個問題,如果遍歷鏈表耗時很長,那麼每次推進就會產生延遲。所以我們依舊需要預加載,即對當前推進槽後的n個槽提前觸發遍歷,把所有數據加載到一級時間輪上,保證了推進的連續性。

以上是第二個造輪子的場景。

對於時間輪的一些實現細節,在kafka和netty中都有最佳實踐,不過kafka中是結合DelayQueue來做推進,避免了空推進的場景。而netty中採用了修正時間的sleep的方式進行推進,有興趣可以閱讀源碼。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章