作者 | 默達
來源 | 阿里技術公眾號
一 背景
在很多產品中都存在生命週期相關的設計,時間節點到了之後需要做對應的事情。
超時中心(TimeOutCenter,TOC)負責存儲和調度生命週期節點上面的超時任務,當超時任務設置的超時時間到期後,超時中心需要立即調度處理這些超時任務。對於一些需要低延遲的超時場景,超時中心調度延遲會給產品帶來不可估量的影響。
因此本文提出一種低延遲的超時中心實現方式,首先介紹傳統的超時中心的實現方案,以及傳統方案中的缺點,然後介紹低延遲的方案,說明如何解決傳統方案中的延遲問題。
二 傳統高延遲方案
1 整體框架
傳統的超時中心整體框架如下所示,任務輸入後存儲在超時任務庫中,定時器觸發運行數據庫掃描器,數據庫掃描器從超時任務庫中掃描已經到達超時時間的任務,已經到達超時時間的任務存儲在機器的內存隊列中,等待交給業務處理器進行處理,業務處理器處理完成後更新任務狀態。
在大數據時代,超時任務數量肯定是很大的,傳統的超時中心通過分庫分表支持存儲海量的超時任務,定時器觸發也需要做相應的改變,需要充分利用集群的能力,下面分別從超時任務庫和定時器觸發兩方面詳細介紹。
2 任務庫設計
任務庫數據模型如下所示,採用分庫分表存儲,一般可設計為8個庫1024個表,具體可以根據業務需求調整。biz_id為分表鍵,job_id為全局唯一的任務ID,status為超時任務的狀態,action_time為任務的執行時間,attribute存儲額外的數據。只有當action_time小於當前時間且status為待處理時,任務才能被掃描器加載到內存隊列。任務被處理完成後,任務的狀態被更新成已處理。
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分配表。分配表時需要考慮好幾件事:一張表只屬於一臺機器,不會出現重複掃描;機器上線下線需要重新分配表。當前機器從所分配的表中掃描出所有狀態為待處理的超時任務,遍歷掃描出的待處理超時任務。對於每個超時任務,當內存隊列不存在該任務且內存隊列未滿時,超時任務才加入內存隊列,否則循環檢查等待。
4 缺點
- 需要定時器定時調度,定時器調度間隔時間加長了超時任務處理的延遲時間;
- 數據庫掃描器為避免重複掃描數據,一張表只能屬於一臺機器,任務庫分表的數量就是任務處理的併發度,併發度受限制;
- 當單表數據量龐大時,即使從單張表中掃描所有待處理的超時任務也需要花費很長的時間;
- 本方案總體處理步驟為:先掃描出所有超時任務,再對單個超時任務進行處理;超時任務處理延遲時間需要加上超時任務掃描時間;
- 本方案處理超時任務的最小延遲為定時器的定時間隔時間,在任務數量龐大的情況下,本方案可能存在較大延遲。
三 低延遲方案
1 整體框架
任務輸入後分為兩個步驟。第一個步驟是將任務存儲到任務庫,本方案的任務庫模型設計和上面方案中的任務庫模型設計一樣;第二步驟是任務定時,將任務的jobId和actionTime以一定方式設置到Redis集群中,當定時任務的超時時間到了之後,從Redis集群pop超時任務的jobId,根據jobId從任務庫中查詢詳細的任務信息交給業務處理器進行處理,最後更新任務庫中任務的狀態。
本方案與上述方案最大的不同點就是超時任務的獲取部分,上述方案採用定時調度掃描任務庫,本方案採用基於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開發手冊》必備的伴讀書目!
掃碼加阿里妹好友,回覆“靈魂13問”獲取吧~(若掃碼無效,可直接添加alimei4、alimei5、alimei6、alimei7)