延時訊息常見實現方案

Richard_Yi 發表於 2022-01-14
原創不易,轉載請註明出處

前言

延時訊息(定時訊息)指的在分散式非同步訊息場景下,生產端傳送一條訊息,希望在指定延時或者指定時間點被消費端消費到,而不是立刻被消費。

延時訊息適用的業務場景非常的廣泛,在分散式系統環境下,延時訊息的功能一般會在下沉到中介軟體層,通常是 MQ 中內建這個功能或者內聚成一個公共基礎服務。

本文旨在探討常見延時訊息的實現方案以及方案設計的優缺點。

實現方案

1. 基於外部儲存實現的方案

這裡討論的外部儲存指的是在 MQ 本身自帶的儲存以外又引入的其他的儲存系統。

基於外部儲存的方案本質上都是一個套路,將 MQ 和 延時模組 區分開來,延時訊息模組是一個獨立的服務/程式。延時訊息先保留到其他儲存介質中,然後在訊息到期時再投遞到 MQ。當然還有一些細節性的設計,比如訊息進入的延時訊息模組時已經到期則直接投遞這類的邏輯,這裡不展開討論。

image-20211228142800378

下述方案不同的是,採用了不同的儲存系統。

基於 資料庫(如MySQL)

基於關係型資料庫(如MySQL)延時訊息表的方式來實現。

CREATE TABLE `delay_msg` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `delivery_time` DATETIME NOT NULL COMMENT '投遞時間',
  `payloads` blob COMMENT '訊息內容',
  PRIMARY KEY (`id`),
  KEY `time_index` (`delivery_time`)
)

通過定時執行緒定時掃描到期的訊息,然後進行投遞。定時執行緒的掃描間隔理論上就是你延時訊息的最小時間精度。

優點:

  • 實現簡單;

缺點:

  • B+Tree索引不適合訊息場景的大量寫入;

基於 RocksDB

RocksDB 的方案其實就是在上述方案上選擇了比較合適的儲存介質。

RocksDB 在筆者之前的文章中有聊過,LSM 樹更適合大量寫入的場景。滴滴開源的DDMQ中的延時訊息模組 Chronos 就是採用了這個方案。

DDMQ 這個專案簡單來說就是在 RocketMQ 外面加了一層統一的代理層,在這個代理層就可以做一些功能維度的擴充套件。延時訊息的邏輯就是代理層實現了對延時訊息的轉發,如果是延時訊息,會先投遞到 RocketMQ 中 Chronos 專用的 topic 中。延時訊息模組 Chronos 消費得到延時訊息轉儲到 RocksDB,後面就是類似的邏輯了,定時掃描到期的訊息,然後往 RocketMQ 中投遞。

chronosArch.png

這個方案老實說是一個比較重的方案。因為基於 RocksDB 來實現的話,從資料可用性的角度考慮,你還需要自己去處理多副本的資料同步等邏輯。

優點:

  • RocksDB LSM 樹很適合訊息場景的大量寫入;

缺點:

  • 實現方案較重,如果你採用這個方案,需要自己實現 RocksDB 的資料容災邏輯;

基於 Redis

再來聊聊 Redis 的方案。下面放一個比較完善的方案。

本方案來源於:https://www.cnblogs.com/lylif...

img

  • Messages Pool 所有的延時訊息存放,結構為KV結構,key為訊息ID,value為一個具體的message(這裡選擇Redis Hash結構主要是因為hash結構能儲存較大的資料量,資料較多時候會進行漸進式rehash擴容,並且對於HSET和HGET命令來說時間複雜度都是O(1))
  • Delayed Queue是16個有序佇列(佇列支援水平擴充套件),結構為ZSET,value 為 messages pool中訊息ID,score為過期時間(分為多個佇列是為了提高掃描的速度)
  • Worker 代表處理執行緒,通過定時任務掃描 Delayed Queue 中到期的訊息

這個方案選用 Redis 儲存在我看來有幾點考慮,

  • Redis ZSET 很適合實現延時佇列
  • 效能問題,雖然 ZSET 插入是一個 O(logn) 的操作,但是Redis 基於記憶體操作,並且內部做了很多效能方面的優化。

但是這個方案其實也有需要斟酌的地方,上述方案通過建立多個 Delayed Queue 來滿足對於併發效能的要求,但這也帶來了多個 Delayed Queue 如何在多個節點情況下均勻分配,並且很可能出現到期訊息併發重複處理的情況,是否要引入分散式鎖之類的併發控制設計?

在量不大的場景下,上述方案的架構其實可以蛻化成主從架構,只允許主節點來處理任務,從節點只做容災備份。實現難度更低更可控。

定時執行緒檢查的缺陷與改進

上述幾個方案中,都通過執行緒定時掃描的方案來獲取到期的訊息。

定時執行緒的方案在訊息量較少的時候,會浪費資源,在訊息量非常多的時候,又會出現因為掃描間隔設定不合理導致延時時間不準確的問題。可以藉助 JDK Timer 類中的思想,通過 wait-notify 來節省 CPU 資源。

獲取中最近的延時訊息,然後wait(執行時間-當前時間),這樣就不需要浪費資源到達時間時會自動響應,如果有新的訊息進入,並且比我們等待的訊息還要小,那麼直接notify喚醒,重新獲取這個更小的訊息,然後又wait,如此迴圈。

2. 開源 MQ 中的實現方案

再來講講目前自帶延時訊息功能的開源MQ,它們是如何實現的

RocketMQ

RocketMQ 開源版本支援延時訊息,但是隻支援 18 個 Level 的延時,並不支援任意時間。只不過這個 Level 在 RocketMQ 中可以自定義的,所幸來說對普通業務算是夠用的。預設值為“1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h”,18個level。

通俗的講,設定了延時 Level 的訊息會被暫存在名為SCHEDULE_TOPIC_XXXX的topic中,並根據 level 存入特定的queue,queueId = delayTimeLevel – 1,即一個queue只存相同延時的訊息,保證具有相同傳送延時的訊息能夠順序消費。broker會排程地消費SCHEDULE_TOPIC_XXXX,將訊息寫入真實的topic。

下面是整個實現方案的示意圖,紅色代表投遞延時訊息,紫色代表定時排程到期的延時訊息:

image-20211229110414587

優點:

  • Level 數固定,每個 Level 有自己的定時器,開銷不大
  • 將 Level 相同的訊息放入到同一個 Queue 中,保證了同一 Level 訊息的順序性;不同 Level 放到不同的 Queue 中,保證了投遞的時間準確性;
  • 通過只支援固定的Level,將不同延時訊息的排序變成了固定Level Topic 的追加寫操作

缺點:

  • Level 配置的修改代價太大,固定 Level 不靈活
  • CommitLog 會因為延時訊息的存在變得很大

Pulsar

Pulsar 支援“任意時間”的延時訊息,但實現方式和 RocketMQ 不同。

通俗的講,Pulsar 的延時訊息會直接進入到客戶端傳送指定的 Topic 中,然後在堆外記憶體中建立一個基於時間的優先順序佇列,來維護延時訊息的索引資訊。延時時間最短的會放在頭上,時間越長越靠後。在進行消費邏輯時候,再判斷是否有到期需要投遞的訊息,如果有就從佇列裡面拿出,根據延時訊息的索引查詢到對應的訊息進行消費。

如果節點崩潰,在這個 broker 節點上的 Topics 會轉移到其他可用的 broker 上,上面提到的這個優先順序佇列也會被重建。

下面是 Pulsar 公眾號中對於 Pulsar 延時訊息的示意圖。

圖片

乍一看會覺得這個方案其實非常簡單,還能支援任意時間的訊息。但是這個方案有幾個比較大的問題

  • 記憶體開銷: 維護延時訊息索引的佇列是放在堆外記憶體中的,並且這個佇列是以訂閱組(Kafka中的消費組)為維度的,比如你這個 Topic 有 N 個訂閱組,那麼如果你這個 Topic 使用了延時訊息,就會建立 N 個 佇列;並且隨著延時訊息的增多,時間跨度的增加,每個佇列的記憶體佔用也會上升。(是的,在這個方案下,支援任意的延時訊息反而有可能讓這個缺陷更嚴重)
  • 故障轉移之後延時訊息索引佇列的重建時間開銷: 對於跨度時間長的大規模延時訊息,重建時間可能會到小時級別。(摘自 Pulsar 官方公眾號文章)
  • 儲存開銷:延時訊息的時間跨度會影響到 Pulsar 中已經消費的訊息資料的空間回收。打個比方,你的 Topic 如果業務上要求支援一個月跨度的延時訊息,然後你發了一個延時一個月的訊息,那麼你這個 Topic 中底層的儲存就會保留整整一個月的訊息資料,即使這一個月中99%的正常訊息都已經消費了。

對於前面第一點和第二點的問題,社群也設計瞭解決方案,在佇列中加入時間分割槽,Broker 只載入當前較近的時間片的佇列到記憶體,其餘時間片分割槽持久化磁碟,示例圖如下圖所示:

圖片

但是目前,這個方案並沒有對應的實現版本。可以在實際使用時,規定只能使用較小時間跨度的延時訊息,來減少前兩點缺陷的影響。另外,因為記憶體中存的並不是延時訊息的全量資料,只是索引,所以可能要積壓上百萬條延時訊息才可能對記憶體造成顯著影響,從這個角度來看,官方暫時沒有完善前兩個問題也可以理解了。

至於第三個問題,估計是比較難解決的,需要在資料儲存層將延時訊息和正常訊息區分開來,單獨儲存延時訊息。

QMQ

QMQ提供任意時間的延時/定時訊息,你可以指定訊息在未來兩年內(可配置)任意時間內投遞。

把 QMQ 放到最後,是因為我覺得 QMQ 是目前開源 MQ 中延時訊息設計最合理的。裡面設計的核心簡單來說就是 多級時間輪 + 延時載入 + 延時訊息單獨磁碟儲存

如果對時間輪不熟悉的可以閱讀筆者的這篇文章 從 Kafka 看時間輪演算法設計

QMQ的延時/定時訊息使用的是兩層 hash wheel 來實現的。第一層位於磁碟上,每個小時為一個刻度(預設為一個小時一個刻度,可以根據實際情況在配置裡進行調整),每個刻度會生成一個日誌檔案(schedule log),因為QMQ支援兩年內的延時訊息(預設支援兩年內,可以進行配置修改),則最多會生成 2 366 24 = 17568 個檔案(如果需要支援的最大延時時間更短,則生成的檔案更少)。第二層在記憶體中,當訊息的投遞時間即將到來的時候,會將這個小時的訊息索引(索引包括訊息在schedule log中的offset和size)從磁碟檔案載入到記憶體中的hash wheel上,記憶體中的hash wheel則是以500ms為一個刻度

img

總結一下設計上的亮點:

  • 時間輪演算法適合延時/定時訊息的場景,省去延時訊息的排序,插入刪除操作都是 O(1) 的時間複雜度;
  • 通過多級時間輪設計,支援了超大時間跨度的延時訊息;
  • 通過延時載入,記憶體中只會有最近要消費的訊息,更久的延時訊息會被儲存在磁碟中,對記憶體友好;
  • 延時訊息單獨儲存(schedule log),不會影響到正常訊息的空間回收;

總結

本文彙總了目前業界常見的延時訊息方案,並且討論了各個方案的優缺點。希望對讀者有所啟發。

參考