簡介: 在不少產品中都存在生命週期相關的設計,時間節點到了以後須要作對應的事情。超時中心(TimeOutCenter,TOC)負責存儲和調度生命週期節點上面的超時任務,當超時任務設置的超時時間到期後,超時中心須要當即調度處理這些超時任務。對於一些須要低延遲的超時場景,超時中心調度延遲會給產品帶來不可估量的影響。sql
做者 | 默達
來源 | 阿里技術公衆號數據庫
一 背景
在不少產品中都存在生命週期相關的設計,時間節點到了以後須要作對應的事情。併發
超時中心(TimeOutCenter,TOC)負責存儲和調度生命週期節點上面的超時任務,當超時任務設置的超時時間到期後,超時中心須要當即調度處理這些超時任務。對於一些須要低延遲的超時場景,超時中心調度延遲會給產品帶來不可估量的影響。框架
所以本文提出一種低延遲的超時中心實現方式,首先介紹傳統的超時中心的實現方案,以及傳統方案中的缺點,而後介紹低延遲的方案,說明如何解決傳統方案中的延遲問題。性能
二 傳統高延遲方案
1 總體框架
傳統的超時中心總體框架以下所示,任務輸入後存儲在超時任務庫中,定時器觸發運行數據庫掃描器,數據庫掃描器從超時任務庫中掃描已經到達超時時間的任務,已經到達超時時間的任務存儲在機器的內存隊列中,等待交給業務處理器進行處理,業務處理器處理完成後更新任務狀態。大數據
在大數據時代,超時任務數量確定是很大的,傳統的超時中心經過分庫分表支持存儲海量的超時任務,定時器觸發也須要作相應的改變,須要充分利用集羣的能力,下面分別從超時任務庫和定時器觸發兩方面詳細介紹。阿里雲
2 任務庫設計
任務庫數據模型以下所示,採用分庫分表存儲,通常可設計爲8個庫1024個表,具體能夠根據業務需求調整。biz_id爲分表鍵,job_id爲全局惟一的任務ID,status爲超時任務的狀態,action_time爲任務的執行時間,attribute存儲額外的數據。只有當action_time小於當前時間且status爲待處理時,任務才能被掃描器加載到內存隊列。任務被處理完成後,任務的狀態被更新成已處理。url
job_id bigint unsigned 超時任務的ID,全局惟一 gmt_create datetime 建立時間 gmt_modified datetime 修改時間 biz_id bigint unsigned 業務id,通常爲關聯的主訂單或子訂單id biz_type bigint unsigned 業務類型 status tinyint 超時任務狀態(0待處理,2已處理,3取消) action_time datetime 超時任務執行時間 attribute varchar 額外數據
3 定時調度設計
定時調度流程圖以下所示,定時器每間隔10秒觸發一次調度,從集羣configserver中獲取集羣ip列表併爲當前機器編號,而後給全部ip分配表。分配表時須要考慮好幾件事:一張表只屬於一臺機器,不會出現重複掃描;機器上線下線須要從新分配表。當前機器從所分配的表中掃描出全部狀態爲待處理的超時任務,遍歷掃描出的待處理超時任務。對於每一個超時任務,當內存隊列不存在該任務且內存隊列未滿時,超時任務才加入內存隊列,不然循環檢查等待。spa
4 缺點
- 須要定時器定時調度,定時器調度間隔時間加長了超時任務處理的延遲時間;
- 數據庫掃描器爲避免重複掃描數據,一張表只能屬於一臺機器,任務庫分表的數量就是任務處理的併發度,併發度受限制;
- 當單表數據量龐大時,即便從單張表中掃描全部待處理的超時任務也須要花費很長的時間;
- 本方案整體處理步驟爲:先掃描出全部超時任務,再對單個超時任務進行處理;超時任務處理延遲時間須要加上超時任務掃描時間;
- 本方案處理超時任務的最小延遲爲定時器的定時間隔時間,在任務數量龐大的狀況下,本方案可能存在較大延遲。
三 低延遲方案
1 總體框架
任務輸入後分爲兩個步驟。第一個步驟是將任務存儲到任務庫,本方案的任務庫模型設計和上面方案中的任務庫模型設計同樣;第二步驟是任務定時,將任務的jobId和actionTime以必定方式設置到Redis集羣中,當定時任務的超時時間到了以後,從Redis集羣pop超時任務的jobId,根據jobId從任務庫中查詢詳細的任務信息交給業務處理器進行處理,最後更新任務庫中任務的狀態。.net
本方案與上述方案最大的不一樣點就是超時任務的獲取部分,上述方案採用定時調度掃描任務庫,本方案採用基於Redis的任務定時系統,接下來將具體講解任務定時的設計。
2 Redis存儲設計
Topic的設計
Topic的定義有三部分組成,topic表示主題名稱,slotAmount表示消息存儲劃分的槽數量,topicType表示消息的類型。主題名稱是一個Topic的惟一標示,相同主題名稱Topic的slotAmount和topicType必定是同樣的。消息存儲採用Redis的Sorted Set結構,爲了支持大量消息的堆積,須要把消息分散存儲到不少個槽中,slotAmount表示該Topic消息存儲共使用的槽數量,槽數量必定須要是2的n次冪。在消息存儲的時候,採用對指定數據或者消息體哈希求餘獲得槽位置。
StoreQueue的設計
上圖中topic劃分了8個槽位,編號0-7。計算消息體對應的CRC32值,CRC32值對槽數量進行取模獲得槽序號,SlotKey設計爲#{topic}_#{index}(也即Redis的鍵),其中#{}表示佔位符。
StoreQueue結構採用Redis的Sorted Set,Redis的Sorted Set中的數據按照分數排序,實現定時消息的關鍵就在於如何利用分數、如何添加消息到Sorted Set、如何從Sorted Set中彈出消息。定時消息將時間戳做爲分數,消費時每次彈出分數大於當前時間戳的一個消息。
PrepareQueue的設計
爲了保障每條消息至少消費一次,消費者不是直接pop有序集合中的元素,而是將元素從StoreQueue移動到PrepareQueue並返回消息給消費者,等消費成功後再從PrepareQueue從刪除,或者消費失敗後從PreapreQueue從新移動到StoreQueue,這即是根據二階段提交的思想實現的二階段消費。
在後面將會詳細介紹二階段消費的實現思路,這裏重點介紹下PrepareQueue的存儲設計。StoreQueue中每個Slot對應PrepareQueue中的Slot,PrepareQueue的SlotKey設計爲prepare_{#{topic}#{index}}。PrepareQueue採用Sorted Set做爲存儲,消息移動到PrepareQueue時刻對應的(秒級時間戳*1000+重試次數)做爲分數,字符串存儲的是消息體內容。這裏分數的設計與重試次數的設計密切相關,因此在重試次數設計章節詳細介紹。
PrepareQueue的SlotKey設計中須要注意的一點,因爲消息從StoreQueue移動到PrepareQueue是經過Lua腳本操做的,所以須要保證Lua腳本操做的Slot在同一個Redis節點上,如何保證PrepareQueue的SlotKey和對應的StoreQueue的SlotKey被hash到同一個Redis槽中呢。Redis的hash tag功能能夠指定SlotKey中只有某一部分參與計算hash,這一部分採用{}包括,所以PrepareQueue的SlotKey中採用{}包括了StoreQueue的SlotKey。
DeadQueue的設計
消息重試消費16次後,消息將進入DeadQueue。DeadQueue的SlotKey設計爲prepare{#{topic}#{index}},這裏一樣採用hash tag功能保證DeadQueue的SlotKey與對應StoreQueue的SlotKey存儲在同一Redis節點。
定時消息生產
生產者的任務就是將消息添加到StoreQueue中。首先,須要計算出消息添加到Redis的SlotKey,若是發送方指定了消息的slotBasis(不然採用content代替),則計算slotBasis的CRC32值,CRC32值對槽數量進行取模獲得槽序號,SlotKey設計爲#{topic}_#{index},其中#{}表示佔位符。發送定時消息時須要設置actionTime,actionTime必須大於當前時間,表示消費時間戳,當前時間大於該消費時間戳的時候,消息纔會被消費。所以在存儲該類型消息的時候,採用actionTime做爲分數,採用命令zadd添加到Redis。
超時消息消費
每臺機器將啓動多個Woker進行超時消息消費,Woker即表示線程,定時消息被存儲到Redis的多個Slot中,所以須要zookeeper維護集羣中Woker與slot的關係,一個Slot只分配給一個Woker進行消費,一個Woker能夠消費多個Slot。Woker與Slot的關係在每臺機器啓動與中止時從新分配,超時消息消費集羣監聽了zookeeper節點的變化。
Woker與Slot關係肯定後,Woker則循環不斷地從Redis拉取訂閱的Slot中的超時消息。在StoreQueue存儲設計中說明了定時消息存儲時採用Sorted Set結構,採用定時時間actionTime做爲分數,所以定時消息按照時間大小存儲在Sorted Set中。所以在拉取超時消息進行只需採用Redis命令ZRANGEBYSCORE彈出分數小於當前時間戳的一條消息。
爲了保證系統的可用性,還須要考慮保證定時消息至少被消費一次以及消費的重試次數,下面將具體介紹如何保證至少消費一次和消費重試次數控制。
至少消費一次
至少消費一次的問題比較相似銀行轉帳問題,A向B帳戶轉帳100元,如何保障A帳戶扣減100同時B帳戶增長100,所以咱們能夠想到二階段提交的思想。第一個準備階段,A、B分別進行資源凍結並持久化undo和redo日誌,A、B分別告訴協調者已經準備好;第二個提交階段,協調者告訴A、B進行提交,A、B分別提交事務。本方案基於二階段提交的思想來實現至少消費一次。
Redis存儲設計中PrepareQueue的做用就是用來凍結資源並記錄事務日誌,消費者端便是參與者也是協調者。第一個準備階段,消費者端經過執行Lua腳本從StoreQueue中Pop消息並存儲到PrepareQueue,同時消息傳輸到消費者端,消費者端消費該消息;第二個提交階段,消費者端根據消費結果是否成功協調消息隊列服務是提交仍是回滾,若是消費成功則提交事務,該消息從PrepareQueue中刪除,若是消費失敗則回滾事務,消費者端將該消息從PrepareQueue移動到StoreQueue,若是由於各類異常致使PrepareQueue中消息滯留超時,超時後將自動執行回滾操做。二階段消費的流程圖以下所示。
消費重試次數控制
採用二階段消費方式,須要將消息在StoreQueue和PrepareQueue之間移動,如何實現重試次數控制呢,其關鍵在StoreQueue和PrepareQueue的分數設計。
PrepareQueue的分數須要與時間相關,正常狀況下,消費者無論消費失敗仍是消費成功,都會從PrepareQueue刪除消息,當消費者系統發生異常或者宕機的時候,消息就沒法從PrepareQueue中刪除,咱們也不知道消費者是否消費成功,爲保障消息至少被消費一次,咱們須要作到超時回滾,所以分數須要與消費時間相關。當PrepareQueue中的消息發生超時的時候,將消息從PrepareQueue移動到StoreQueue。
所以PrepareQueue的分數設計爲:秒級時間戳*1000+重試次數。定時消息首次存儲到StoreQueue中的分數表示消費時間戳,若是消息消費失敗,消息從PrepareQueue回滾到StoreQueue,定時消息存儲時的分數都表示剩餘重試次數,剩餘重試次數從16次不斷下降最後爲0,消息進入死信隊列。消息在StoreQueue和PrepareQueue之間移動流程以下:
5 優勢
- 消費低延遲:採用基於Redis的定時方案直接從Redis中pop超時任務,避免掃描任務庫,大大減小了延遲時間。
- 可控併發度:併發度取決於消息存儲的Slot數量以及集羣Worker數量,這兩個數量均可以根據業務須要進行調控,傳統方案中併發度爲分庫分表的數量。
- 高性能:Redis單機的QPS能夠達到10w,Redis集羣的QPS能夠達到更高的水平,本方案沒有複雜查詢,消費過程當中從Redis拉取超時消息的時間複雜度爲O(1)。
- 高可用:至少消費一次保障了定時消息必定被消費,重試次數控制保證消費不被阻塞。
免費領取電子書
《〈Java開發手冊(泰山版)〉靈魂13問》
《Java開發手冊(泰山版)》新增了5條日期時間規約、新增2條表別名sql規約以及新增統一錯誤碼規約。爲了幫助同窗們更好的理解這些規約背後的原理,本書做者結合自身開發時所遇到的問題,深度剖析Java規約背後的原理,是《Java開發手冊》必備的伴讀書目!
https://developer.aliyun.com/article/784457?utm_content=g_1000275814
本文爲阿里雲原創內容,未經容許不得轉載。