基於REDIS實現延時任務

背景介紹

最近業務上有個需求,背景以下:有一個養殖類遊戲,經過給養的寵物餵食來升級,一次餵食後,寵物須要花4個小時吃完。如今有個新需求,可使用道具卡來豐富玩法。道具卡有兩種,一種是加速卡,一種是自動餵食卡。加速卡會使吃食的時間縮短兩個小時,自動餵食卡能夠在寵物吃完當前餵食的狗糧後系統幫助其自動餵食一次。redis

業務需求裏的自動餵食就是一種典型的延時任務。延時任務是指須要在指定的將來的某個時間點自動觸發。與之相似的場景還有:數據庫

  • 活動結束前2小時給用戶推送消息;
  • 優惠券過時前2小時給用戶推送消息;
  • 秒殺時,下單後10分鐘內未付款就自動取消訂單等;

業界解決方案

掃表

對於延時任務,常見的方案就是掃表。掃表就是用一個後臺進程,每隔一段時間掃描數據庫的整張數據表,判斷每一個任務是否達到觸發的條件。若是達到條件就執行相應的業務。掃描全表對數據庫壓力較大,因此通常選擇掃從庫。掃表的最大優點是實現起來比較簡單,並且數據自己存在DB裏,所以也不用擔憂任務數據會丟失,失敗的任務能夠下次掃描時再重入。可是掃表存在如下問題:網絡

  • 掃表一整張表須要一段時間,會形成任務的觸發有延時,有的時候一個進程每一個還要掃多個表;
  • 掃表不可能太頻繁,由於太頻繁會對數據庫形成太大壓力,每隔一段較長的時間才能再掃一遍,這個時間間隔通常至少在一分鐘以上。這也會形成任務延時;
  • 掃表掃的是從庫,而主從同步存在延時。特別是當大事務出現時,會致使幾分鐘甚至幾小時的延時;
  • 掃表的方法很笨重,每次掃描一整張表而實際須要觸發的任務可能沒幾個,資源利用很低下;

掃表最大的問題就是會有延遲,不能再指定的時間裏觸發,對於時效性高的場景,這種方案是不能知足需求的。數據結構

延時消息隊列

目前,有些MQ消息隊列能夠支持延時消息,如kafka。延時消息就是消息發送後,能夠指定在多少時間以後纔會發送到消費者那裏。這個方案,開發成本也很小,不過須要使用的中間件能支持延時消息。並且該方案也存在一個瓶頸就是若是,延時任務須要從新更新時間就作不到了,由於消息已經發出去了,收不回了。架構

時間片輪詢

用環形隊列作成時間片,環形隊列的每一個格子裏維護一個鏈表。每一個時刻有一個當前指針指向環形隊列某個格子,定時器每超時一次,就把當前指針指向下環形隊列的下一個格子。而後處理這個格子保存的鏈表裏的任務。若是隻是這樣維護,若是要作到秒級的粒度,時間長度最長一天,那麼這個環形隊列就會很是大。所以,有人又有人改進了一下,當存在任務進入隊列時,就用時間長度除以環形隊列的長度,記爲圈數。這樣每次遍歷到該元素時,將圈數減一,若是減一後爲0就執行改任務,否者不執行。併發

kafka的延時消息的內部實現就是採用時間片輪詢的方式來實現的。異步

對於時間跨度很是大的場景,若是使用這種方法會致使鏈表上的元素很是多,遍歷鏈表的開銷也不小,甚至在一個時間片內遍歷不完。所以,又有了進一步的改進,將時間片分爲不一樣粒度的。好比,粒度爲小時的時間輪,粒度爲分鐘的時間輪,粒度爲秒鐘的時間輪。小時裏的時間輪達到觸發的條件後會放到分鐘的時間輪裏,分鐘的時間輪到達觸發的條件後會放到秒的時間輪裏。(圖片來自網絡,侵刪)分佈式

該方案時間片存放在內存,所以輪詢起來效率很是高,也能夠根據不一樣的粒度調整時間片,所以也很是靈活。可是該方案須要本身實現持久化與高可用,以及對儲存的管理,若是沒有現成的輪子開發耗時會比較長。高併發

Redis的ZSET實現

Redis實現延時任務,是經過其數據結構ZSET來實現的。ZSET會儲存一個score和一個value,能夠將value按照score進行排序,而SET是無序的。ui

延時任務的實現分爲如下幾步來實現:

(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來實現延時任務方法,並分析了高併發,高可用的設計思路。

相關文章
相關標籤/搜索