背景介紹
最近業務上有個需求,背景如下:有一個養殖類遊戲,通過給養的寵物餵食來升級,一次餵食後,寵物需要花4個小時吃完。現在有個新需求,可以使用道具卡來豐富玩法。道具卡有兩種,一種是加速卡,一種是自動餵食卡。加速卡會使吃食的時間縮短兩個小時,自動餵食卡可以在寵物吃完當前餵食的狗糧後系統幫助其自動餵食一次。
業務需求裡的自動餵食就是一種典型的延時任務。延時任務是指需要在指定的未來的某個時間點自動觸發。與之類似的場景還有:
- 活動結束前2小時給使用者推送訊息;
- 優惠券過期前2小時給使用者推送訊息;
- 秒殺時,下單後10分鐘內未付款就自動取消訂單等;
業界解決方案
掃表
對於延時任務,常見的方案就是掃表。掃表就是用一個後臺程式,每隔一段時間掃描資料庫的整張資料表,判斷每個任務是否達到觸發的條件。如果達到條件就執行相應的業務。掃描全表對資料庫壓力較大,所以一般選擇掃從庫。掃表的最大優勢是實現起來比較簡單,而且資料本身存在DB裡,因此也不用擔心任務資料會丟失,失敗的任務可以下次掃描時再重入。但是掃表存在以下問題:
- 掃表一整張表需要一段時間,會造成任務的觸發有延時,有的時候一個程式每個還要掃多個表;
- 掃表不可能太頻繁,因為太頻繁會對資料庫造成太大壓力,每隔一段較長的時間才能再掃一遍,這個時間間隔一般至少在一分鐘以上。這也會造成任務延時;
- 掃表掃的是從庫,而主從同步存在延時。特別是當大事務出現時,會導致幾分鐘甚至幾小時的延時;
- 掃表的方法很笨重,每次掃描一整張表而實際需要觸發的任務可能沒幾個,資源利用很低下;
掃表最大的問題就是會有延遲,不能再指定的時間裡觸發,對於時效性高的場景,這種方案是不能滿足需求的。
延時訊息佇列
目前,有些MQ訊息佇列可以支援延時訊息,如kafka。延時訊息就是訊息傳送後,可以指定在多少時間之後才會傳送到消費者那裡。這個方案,開發成本也很小,不過需要使用的中介軟體能支援延時訊息。而且該方案也存在一個瓶頸就是如果,延時任務需要重新更新時間就做不到了,因為訊息已經發出去了,收不回了。
時間片輪詢
用環形佇列做成時間片,環形佇列的每個格子裡維護一個連結串列。每個時刻有一個當前指標指向環形佇列某個格子,定時器每超時一次,就把當前指標指向下環形佇列的下一個格子。然後處理這個格子儲存的連結串列裡的任務。如果只是這樣維護,如果要做到秒級的粒度,時間長度最長一天,那麼這個環形佇列就會非常大。因此,有人又有人改進了一下,當存在任務進入佇列時,就用時間長度除以環形佇列的長度,記為圈數。這樣每次遍歷到該元素時,將圈數減一,如果減一後為0就執行改任務,否者不執行。
kafka的延時訊息的內部實現就是採用時間片輪詢的方式來實現的。
對於時間跨度非常大的場景,如果使用這種方法會導致連結串列上的元素非常多,遍歷連結串列的開銷也不小,甚至在一個時間片內遍歷不完。因此,又有了進一步的改進,將時間片分為不同粒度的。比如,粒度為小時的時間輪,粒度為分鐘的時間輪,粒度為秒鐘的時間輪。小時裡的時間輪達到觸發的條件後會放到分鐘的時間輪裡,分鐘的時間輪到達觸發的條件後會放到秒的時間輪裡。(圖片來自網路,侵刪)
該方案時間片存放在記憶體,因此輪詢起來效率非常高,也可以根據不同的粒度調整時間片,因此也非常靈活。但是該方案需要自己實現持久化與高可用,以及對儲存的管理,如果沒有現成的輪子開發耗時會比較長。
Redis的ZSET實現
Redis實現延時任務,是通過其資料結構ZSET來實現的。ZSET會儲存一個score和一個value,可以將value按照score進行排序,而SET是無序的。
延時任務的實現分為以下幾步來實現:
(1) 將任務的執行時間作為score,要執行的任務資料作為value,存放在zset中; (2) 用一個程式定時查詢zset的score分數最小的元素,可以用ZRANGEBYSCORE key -inf +inf limit 0 1 withscores命令來實現; (3) 如果最小的分數小於等於當前時間戳,就將該任務取出來執行,否則休眠一段時間後再查詢
redis的ZSET是通過跳躍表來實現的,複雜度為O(logN),N是存放在ZSET中元素的個數。用redis來實現可以依賴於redis自身的持久化來實現持久化,redis的叢集來支援高併發和高可用。因此開發成本很小,可以做到很實時。
具體實現
掃表的方法延時太高不能滿足實時的需求,團隊目前使用的訊息佇列還不支援延時訊息佇列,時間輪的方法開發起來很耗時,因此最終選擇了Redis來實現。
前面介紹了Redis實現延時任務的原理,為了實現更高的併發還需要在原理的基礎上進行設計。接下來將詳細闡述具體的實現。架構設計圖如下:
說明:
- 為了避免一個key儲存在資料量變多以後,首先會導致查詢速度變慢,因為其時間複雜度為O(logN),其次如果在同一個時間點有多個任務時,一個key會分發不過來,造成擁堵。因此,我們將其設計為多個key來儲存,通過uuid進行hash路由到對應的key中,如果任務量增長,我們可以快速擴容redis key的數量來抗住增長的數量;
- 建立與多個key相同的程式或者執行緒數,每個程式一個編號,分別對應一個key,不斷輪詢相應的key;
- 輪詢key的程式我們將其稱為event程式,event程式只查詢出任務,但是不處理業務,將該任務寫入到訊息佇列中。另外有work進行從訊息佇列取訊息,然後執行業務。這樣work進行可以分散式部署,event進行只需做分發,這樣可以把併發做到非常高,即使同一時間有大量的任務,也能很小的延時內完成任務;
- 為了避免event程式單機部署,在機器當機後導致無法取訊息,redis儲存的資料還會被積壓。我們多機部署event程式,並使用zookeeper選主,只有leader主機上的程式才從redis取訊息。leader主機當機後,zookeeper會自動選擇新的leader;
- 在實際的業務中,還依賴DB寫入資料。延時任務產生是先修改DB然後再向redis寫入資料,那麼就存在DB更新成功,然後redis寫失敗的場景,這個時候首先是通過重試來減少redis寫入失敗的概率,如果重試任然不能成功,就傳送一條訊息給daemon程式進行非同步補償;
在延時任務的基礎上,本次業務還有一個需求,就是延時任務如果還沒有到達執行時間,那麼該延時任務的時間是可以被更改的。為了實現這個需求,我們另外給每個使用者維護一個ZSET,這個ZSET中存放該使用者所有的延時任務。為了便於描述,我們將這個ZSET稱為ZSET-USER。如果使用者需要修改其延時任務,如果沒有辦法從整體的延時任務的ZSET中找到這個任務,而是即使能找到,也只能遍歷這個ZSET,顯然這種方法太慢,太耗資源。我們採取的方法是從ZSET-USER中取出這個使用者的延時任務,然後修改score,最後重新ZADD到延時任務ZSET和ZSET-USER中,ZADD會覆蓋原來的任務,而score則發生了更新。這樣看來,這個需求還只能通過Redis來實現。
本篇文章,藉著業務需求的背景首先探討了延時任務的業界實現方案,然後詳細闡述了通過redis來實現延時任務方法,並分析了高併發,高可用的設計思路。