RMQ——支援合併和優先順序的訊息佇列

xxxtai發表於2020-11-20

業務背景

在一個專案中需要實現一個功能,商品價格發生變化時將商品價格列印在商品主圖上面,那麼需要在價格發生變動的時候觸發合成一張帶價格的圖片,每一次觸發合圖時計算價格都是獲取當前最新的價格。上游價格變化的因素很多,變化很頻繁,下游合圖消耗GPU資源較大,處理容量較低。

上游生產速度很快,下游處理速度很慢,上下游處理速度存在巨大差距時,我們首先可以想到使用訊息佇列進行削峰填谷,比如RocketMQ、Kafka。但是,在本專案的背景中,觸發價格變化的來源很多,產生的觸發訊息可能存在大量重複,下游重複消費不但會浪費資源還會導致延遲。採用現有MQ訊息佇列的問題在於重複的訊息無法合併處理以減少下游重複處理的次數。

在該專案中,由於合圖資源有限,因此需要對不同等級的商家區分優先順序處理。採用現有MQ訊息佇列的問題是,訊息發生堆積後,訊息只能按照FIFO(先進先出)順序消費。由於無法區分優先順序進行消費,緊急的任務也只能等待先到的任務先消費完成。

接下來,我將介紹一種可將訊息合併處理並支援優先順序的訊息佇列——RMQ,RMQ適用於重複訊息比較頻繁、上下游處理速度存在巨大差距的場景。

產品功能

RMQ是一個支援多Topic的訊息佇列,可以用作削峰填谷、非同步解耦。相比已有的訊息佇列,他還具有訊息合併和優先順序的功能,這兩個功能也是它存在的意義。

訊息合併

RMQ是一個可合併訊息的訊息佇列,如果訊息堆積在訊息佇列中時,內容重複的訊息會合併成一條。RMQ支援訊息合併但是不支援訊息去重,多條內容相同的訊息堆積在RMQ中時,多條訊息會被合併成一條訊息,但一條訊息可能由於系統當機而被重複消費。

還有一種情況也無法避免,兩條內容相同的訊息先後產生,還沒等到第二條訊息產生,第一條訊息就被消費了,緊接著第二條訊息產生後也被消費了。但是,這種情況說明上下游處理速度不存在差距,業務上需要保障可以重複處理。

優先順序

RMQ支援訊息設定優先順序,優先順序分為高、中、低三個等級,優先順序高的任務不管什麼時候產生都會比優先順序低的任務先執行,相同優先順序的任務會隨機被執行。

RocketMQ與RMQ功能對比

訊息佇列堆積能力順序訊息優先順序訊息合併訊息去重可用性應用場景
RocketMQ海量支援不支援不支援不支援高可用削峰填谷、非同步解耦、海量堆積、重複訊息不多的場景
RMQ億級不支援支援支援不支援高可用消費填谷、非同步解耦、訊息存在重複、上游生產速度快,下游消費能力低的場景

訊息合併與訊息去重的差異?
訊息合併是指,多個內容相同的訊息只被消費一次。訊息去重是指,同一個訊息只被消費一次。

實現方案

為了快速實現RMQ並具備以上特性,我們選擇站在巨人的肩膀上。我們選擇Redis作為訊息佇列的儲存,選擇RocketMQ來維護消費叢集。RMQ總體架構圖如下所示。
總體框架圖
首先,生產者需要在配置管理服務中註冊一個topic才能傳送訊息,消費者需要在配置管理服務繫結一個topic才能接收訊息。然後,生產者傳送訊息到消費佇列服務,配置管理服務會定時通過心跳傳送繫結的topic資訊到消費者,消費者根據topic資訊從消費佇列服務中拉取訊息進行消費。

接下來將從訊息佇列服務、配置管理服務、生產者和消費者四個方面詳細闡述。

訊息佇列服務

訊息佇列服務主要負責訊息的儲存,在這裡實現了RMQ的訊息合併和優先順序的特性。訊息佇列服務藉助Redis進行實現,Redis的有序集合中的元素具有唯一性,這個特點可以幫助RMQ實現訊息的合併,Redis有序集合中的元素根據分數進行排序,這個特點可以幫助RMQ實現優先順序的功能。基於Redis的ZSet資料結構設計了RMQ的儲存結構,儲存設計的框架圖如下圖所示。
儲存設計

SlotKey和StoreQueue的設計

一個Topic可以根據預估資料量劃分固定的槽數量,槽數量一定需要是2的n次冪,上圖中topic劃分了8個槽位,編號0-7。生產者將訊息體序列化成字串,並計算字串的CRC32值,CRC32值對槽數量進行取模得到槽序號,topic和槽序號拼接組裝成SlotKey(也即Redis的鍵),每個SlotKey對應一個StoreQueue,StoreQueue使用有序集合ZSet作為儲存結構,這樣內容相同的訊息體就會落在同一個StoreQueue裡面,所以內容相同的訊息會進行合併。

Redis的有序集合底層採用壓縮列表或者跳躍表實現,當資料量小的時候採用壓縮列表,資料量大的時候採用跳躍表。有序集合中的元素由分數和字串組成,元素按照分數進行排序。在RMQ的儲存設計中,使用分數來表示優先順序,因此訊息按照優先順序進行排序,消費者每次都拉取優先順序最大的訊息。

PrepareQueue的設計

為了保障RMQ的可用性,做到每條訊息至少消費一次,消費者不是直接pop有序集合中的元素,而是將元素從StoreQueue移動到PrepareQueue並返回訊息給消費者,等消費成功後再從PrepareQueue從刪除,或者消費失敗後從PreapreQueue重新移動到StoreQueue,這便是根據二階段提交的思想實現的二階段消費。

在消費者章節將會詳細介紹二階段消費的實現思路,這裡重點介紹下PrepareQueue的儲存設計。一個topic只有一個PrepareQueue,對應的SlotKey為${topic}_PrepareQueue,PrepareQueue採用有序集合作為儲存,訊息移動到PrepareQueue時刻對應的時間戳作為分數,字串依然是訊息體內容。

為什麼需要使用時間戳作為分數呢?正常情況下,消費者不管消費失敗還是消費成功,都會從PrepareQueue刪除訊息,當消費者系統發生異常或者當機的時候,訊息就無法從PrepareQueue中刪除,我們也不知道消費者是否消費成功,為保障訊息至少被消費一次,我們需要做到超時回滾,因此需要儲存時間戳。當PrepareQueue中的訊息發生超時的時候,將訊息從PrepareQueue移動到StoreQueue。判斷PrepareQueue中訊息是否超時只需要查詢分數最小的訊息是否已經超時,使用有序集合可以有效的提升效能。

死信佇列的設計

如果訊息消費失敗,並且重試消費了16次依然失敗,那麼需要將訊息存入到死信佇列裡面。一個topic只有一個死信佇列,對應的SlotKey為${topic}_DeadQueue,採用Redis的列表結構儲存。儲存在死信佇列的消費無法再被消費。

配置管理服務

為了快速實現RMQ,並沒有採用類似RocketMQ的配置管理服務NameServer,而是利用RocketMQ傳送心跳訊息給叢集消費,消費叢集根據心跳訊息中的topic資訊從訊息佇列服務從拉取訊息進行消費。配置管理服務的工作流程如下所示:

  1. 生產者在配置管理服務註冊topic,並指定topic劃分的槽數量SlotNumber。
  2. 消費者在配置管理服務中繫結消費topic。
  3. 配置管理服務通過RocketMQ定時傳送心跳訊息給消費叢集,心跳訊息中包含消費者訂閱的topic資訊。
  4. 消費者接收到心跳訊息後,解析訊息並把訂閱的topic資訊儲存在本機。

生產者

業務系統中引入RMQ二方包後,可以呼叫生產者介面傳送訊息,生產者的主要工作就是將需要傳送的內容序列化後儲存在對應的位置,生產的工作流程如下所示:

  1. 生產者將需要傳送的內容序列化成字串,因為RMQ是根據訊息內容進行合併的,所以業務上需要只將必要的資訊儲存在訊息內容裡面。
  2. 根據訊息內容字串計算CRC32值,並對槽數量進行取模,這裡採用位運算&代替取模運算可以提升計算效能,並減少衝突、分佈更均勻,因此槽數量一定要是2的n次冪。模數就是槽的序號。
  3. 根據topic和第2步驟求得的槽序號組裝成SlotKey,組裝規則是KaTeX parse error: Expected group after '_' at position 8: {topic}_̲{槽序號}。
  4. 將業務設定的優先順序轉換成double型別的分數,高優先順序對應分數18.0,中優先順序對應分數17.0,低優先順序對應分數16.0(為何這樣設計將在消費者章節中講解)。
  5. 呼叫訊息佇列服務介面傳送訊息,即執行Redis命令sadd,將分數和訊息體內容儲存到對應的鍵值中。

消費者

業務系統消費訊息需引入RMQ二方包,並只需實現一個消費的Handler,RMQ消費者端會自動從訊息佇列服務拉取訊息回撥業務Handler進行消費。在展開消費者端整體工作流程之前,我們先看下消費者端的兩個重要問題,如何保證訊息至少消費一次?消費失敗重試如何實現?

至少消費一次問題

三種消費模式

一般訊息佇列存在三種消費模式,分別是:最多消費一次、至少消費一次、只消費一次。最多消費一次模式訊息可能丟失,一般不怎麼使用。至少消費一次模式訊息不會丟失,但是可能存在重複消費,比較常用。只消費一次模式訊息被精確只消費一次,實現較困難,一般需要業務記錄冪等ID來實現。RMQ實現了至少消費一次的模式,那麼如何保證訊息至少被消費一次呢?

至少消費一次模式實現的難點

從最簡單的消費模式——最多消費一次說起,消費者端只需要從訊息佇列服務中取出訊息就行,即執行Redis的zpopmax命令,不倫消費者是否接收到該訊息併成功消費,訊息佇列服務都認為訊息消費成功。最多一次消費模式導致訊息丟失的因素可能有:網路丟包導致消費者沒有接收到訊息,消費者接收到訊息但在消費的時候當機了,消費者接收到訊息但消費失敗。針對消費失敗導致訊息丟失的情況比較好解決,只需要把消費失敗的訊息重新放入訊息佇列服務就行,但是網路丟包和消費系統異常導致的訊息丟失問題不好解決。
可能有人會想到,我們不把元素從有序集合中pop出來,我們先查詢優先順序最高的元素,然後消費,再刪除消費成功的元素,但是這樣訊息服務佇列就變成了同步阻塞佇列,效能會很差。

至少消費一次模式的實現

至少消費一次的問題比較類似銀行轉賬問題,A向B賬戶轉賬100元,如何保障A賬戶扣減100同時B賬戶增加100,因此我們可以想到二階段提交的思想。第一個準備階段,A、B分別進行資源凍結並持久化undo和redo日誌,A、B分別告訴協調者已經準備好;第二個提交階段,協調者告訴A、B進行提交,A、B分別提交事務。RMQ基於二階段提交的思想來實現至少消費一次的模式。
RMQ儲存設計中PrepareQueue的作用就是用來凍結資源並記錄事務日誌,消費者端即是參與者也是協調者。第一個準備階段,消費者端通過Redis事務將指定訊息從StoreQueue移動到PrepareQueue,同時訊息傳輸到消費者端,消費者端消費該訊息;第二個提交階段,消費者端根據消費結果是否成功協調訊息佇列服務是否回滾,如果消費成功則提交事務,該訊息從PrepareQueue中刪除,如果消費失敗則回滾事務,消費者端通過Redis事務將該訊息從PrepareQueue移動到StoreQueue,如果因為各種異常導致PrepareQueue中訊息滯留超時,將自動執行回滾操作。如何實現事務將指定訊息在StoreQueue和PrepareQueue之間移動呢,Redis可以用Lua指令碼實現。二階段消費的流程圖如下所示:
在這裡插入圖片描述

實現方案的異常情況分析

我們來分析下采用二階段消費方案可能存在的異常情況,從以下分析來看二階段消費方案可以保障訊息至少被消費一次。

  1. 網路丟包導致消費者沒有接收到訊息,這時訊息已經記錄到PrepareQueue,如果到了超時時間,訊息被回滾放回StoreQueue,等待下次被消費,訊息不丟失。
  2. 消費者接收到了訊息,但是消費者還沒來得及消費完成系統就當機了,訊息消費超時到了後,訊息會被重新放入StoreQueue,等待下次被消費,訊息不丟失。
  3. 消費者接收到了訊息並消費成功,消費者端在協調事務提交的時候當機了,訊息消費超時到了後,訊息會被重新放入StoreQueue,等待下次被消費,訊息被重複消費。
  4. 消費者接收到了訊息但消費失敗,消費者端在協調事務提交的時候當機了,訊息消費超時到了後,訊息會被重新放入StoreQueue,等待下次被消費,訊息不丟失。
  5. 消費者接收到了訊息並消費成功,但是由於fullgc等原因使消費時間太長,PrepareQueue中的訊息由於超時已經回滾到StoreQueue,等待下次被消費,訊息被重複消費。

重試次數控制

RMQ支援消費失敗後重試16次,重試16次後還是失敗則轉移到死信佇列,死信佇列中的訊息無法再被消費。失敗重試16次的控制是如何做到的呢?在生產者章節中我們說到,高優先順序對應分數18.0,中優先順序對應分數17.0,低優先順序對應分數16.0,如果訊息消費失敗,則分數減1,直到分數等於0時放入死信佇列。由此可知,重試訊息的優先順序會不斷降低,重試訊息消費的間隔時間會逐漸增長。

整體工作流程

消費者端整體的工作流程如下所示。消費執行緒迴圈隨機遍歷訂閱topic中的所有槽SlotKey,隨機遍歷是為了讓多個topic的多個槽被均勻消費。定時3s邏輯是為了使用消費者端實現PrepareQueue超時回滾功能,PrepareQueue中需要超時回滾的情況一般是由於系統重啟、系統當機、網路丟包導致,一般不會出現很多訊息需要超時回滾,所以這裡採用定時3s檢查避免效能消耗。
在這裡插入圖片描述

實際效果

從實現方案中可以看出RMQ強依賴於Redis,涉及到的Redis命令時間複雜度為O(1)或O(logn),得益於Redis的高效能,RMQ的效能也是非常高。

在該專案中,商品價格發生變化後需要進行合圖,商品價格變化來源較多,觸發合圖訊息重複概率較高,且下游合圖處理速度較慢,我們需要儘可能合併觸發合圖訊息,減輕下游處理壓力,於是我們使用了RMQ作為訊息中介軟體來進行削峰填谷、訊息合併。

在實際專案中做到了在兩分鐘內傳送了500w條訊息,訊息傳送的TPS達4.1w。由於多個來源同時觸發導致觸發訊息大量重複,RMQ對訊息進行了合併,合併率高達82%。在不執行任何業務邏輯的壓測情況下,RMQ的消費TPS可達4W,如果增加消費執行緒可以達到更高的速度。

未來展望

完善配置管理服務

目前配置管理服務依賴於RocketMQ實現,實現方式很重,未來可以考慮使用zookeeper或者自己實現類似NameServer的服務。目前沒有配置管理後臺,註冊、訂閱都是程式碼寫死,未來需要獨立的視覺化配置管理後臺。

支援任意延遲時間的訊息

RocketMQ支援延遲訊息,但是隻支援幾個等級的延遲訊息,比如延遲1s、5s、10s、30m、2h等。很多場景需要能夠設定任意的延遲時間,比如許多TOC超時場景,訂單超時關閉、任務超時關閉、活動結束後清理等。由於RMQ的儲存設計是基於Redis的有序列表,因此可以做到設定任意延遲時間的訊息。主要的實現要點就是把延遲時間作為分數,訊息根據延遲時間從小到大排序,只需要不斷拉取分數小於當前時間戳的元素進行消費就行。

相關文章