簡介: 在很多產品中都存在生命周期相關的設計,時間節點到了之后需要做對應的事情。超時中心(TimeOutCenter,TOC)負責存儲和調度生命周期節點上面的超時任務,當超時任務設置的超時時間到期后,超時中心需要立即調度處理這些超時任務。對於一些需要低延遲的超時場景,超時中心調度延遲會給產品帶來不可估量的影響。
作者 | 默達
來源 | 阿里技術公眾號
一 背景
在很多產品中都存在生命周期相關的設計,時間節點到了之后需要做對應的事情。
超時中心(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)。
- 高可用:至少消費一次保障了定時消息一定被消費,重試次數控制保證消費不被阻塞。
原文鏈接
本文為阿里雲原創內容,未經允許不得轉載。