
作者 | 默達
來源 | 阿裡技術公衆号
一 背景
在很多産品中都存在生命周期相關的設計,時間節點到了之後需要做對應的事情。
逾時中心(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 缺點
- 需要定時器定時排程,定時器排程間隔時間加長了逾時任務處理的延遲時間;
- 資料庫掃描器為避免重複掃描資料,一張表隻能屬于一台機器,任務庫分表的數量就是任務處理的并發度,并發度受限制;
- 當單表資料量龐大時,即使從單張表中掃描所有待處理的逾時任務也需要花費很長的時間;
- 本方案總體處理步驟為:先掃描出所有逾時任務,再對單個逾時任務進行處理;逾時任務處理延遲時間需要加上逾時任務掃描時間;
- 本方案處理逾時任務的最小延遲為定時器的定時間隔時間,在任務數量龐大的情況下,本方案可能存在較大延遲。
三 低延遲方案
任務輸入後分為兩個步驟。第一個步驟是将任務存儲到任務庫,本方案的任務庫模型設計和上面方案中的任務庫模型設計一樣;第二步驟是任務定時,将任務的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)