如果有人再問你怎麼實現分散式延時訊息,這篇文章丟給他

咖啡拿鐵發表於2022-12-05

1.背景

上篇文章介紹了RocketMQ整體架構和原理有興趣的可以閱讀一下,在這篇文章中的延時訊息部分,我寫道開源版的RocketMQ只提供了18個層級的訊息佇列延時,這個功能在開源版中顯得特別雞肋,但是在阿里雲中的RocketMQ卻提供了支援40天之內任意秒級延時佇列,果然有些功能你只能充錢才能擁有。當然你或許想換一個開源的訊息佇列,在開源社群中訊息佇列延時訊息很多都沒有被支援比如:RabbitMQ,Kafka等,都只能透過一些特殊方法才能完成延時的功能。為什麼這麼多都沒有實現這個功能呢?是因為技術難度比較複雜嗎?接下來我們分析一下如何才能實現一個延時訊息。

2.本地延時

在實現分散式訊息佇列的延時訊息之前,我們想想我們平時是如何在自己的應用程式上實現一些延時功能的?在Java中可以透過下面的方式來完成我們延時功能:

  • ScheduledThreadPoolExecutor:ScheduledThreadPoolExecutor繼承了ThreadPoolExecutor,我們提交任務的時候,會將任務首先提交到DelayedWorkQueue一個優先順序佇列中,按照過期時間進行排序,這個優先順序佇列也就是我們堆結構,每次提交任務排序的複雜度是O(logN)。然後取任務的時候就會從堆頂取出我們的任務,也就是我們延遲時間最小的任務。ScheduledThreadPoolExecutor有個好處是執行延時任務可以支援多執行緒並行執行,因為他繼承的是ThreadPoolExecutor。

  • Timer:Timer也是利用優先順序佇列結構做的,但是其沒有繼承執行緒池,相對來說比較獨立,不支援多執行緒,只能使用單獨的一個執行緒。

3.分散式訊息佇列延時

我們實現本地延時比較簡單,直接使用Java中現成的即可,那我們分散式訊息佇列的實現有哪些難點呢?

有很多同學首先會想到我們實現分散式訊息佇列的延時任務,可不可以直接使用本地的那一套,用ScheduledThreadPoolExecutor,Timer,當然這是可以的,前提是你的訊息量很小,但是我們分散式訊息佇列往往都是企業級別的中介軟體,資料量都是非常的大,那麼我們純記憶體的方案肯定是行不通的。所以我們就有了下面這幾個方案來解決我們這個問題。

3.1 資料庫

資料庫一般來說是我們很容易想到的一個辦法,我們通常可以建立下面這樣一個表:

CREATE TABLE `delay_message` (
  `id` bigint(20unsigned NOT NULL AUTO_INCREMENT,
  `excute_time` bigint(16DEFAULT NULL COMMENT '執行時間,ms級別',
  `body` varchar(4096COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '訊息體',
  PRIMARY KEY (`id`),
  KEY `time_index` (`excute_time`)
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

這個表中我們使用excute_time代表我們真實的執行時間,並且對其建立索引,然後在我們的訊息服務中,啟動一個定時任務,定時從資料庫中掃描已經可以執行的訊息,然後開始執行,具體流程如下面所示:

如果有人再問你怎麼實現分散式延時訊息,這篇文章丟給他

使用資料庫的方法是一個比較原始的方法,在沒有延時訊息這個概念之前,要做一個訂單多少分鐘過期的這種功能,通常使用這個方法去完成。而這個方法通常也比較侷限於我們單個業務,如果想擴充套件為我們企業級的一箇中介軟體的話是不行的,因為mysql由於BTree的特性,會隨著維護二級索引的開銷越來越大,導致寫入會越來越慢,所以這個方案通常不會被考慮。

RocksDB/LevelDB

我們之前介紹RocketMQ在開源版本中只實現了18個Level的延時訊息,但是有很多公司基於RocketMQ做了自己的一套支援任意時間的延時訊息,在美團內部封裝了RocketMQ使用LevelDB做了對延時訊息的封裝,在滴滴開源的DDMQ中,使用了RocksDB對RocketMQ的延時訊息部分進行了封裝。

其原理基本和Mysql類似,如下圖所示:

如果有人再問你怎麼實現分散式延時訊息,這篇文章丟給他
  • Step1: DDMQ傳送訊息的時候會有一個代理層,用於將訊息做分發,因為其內部有多種訊息佇列,kafka,rocketMQ等等,如果是延時訊息會將訊息傳送到RockesDB的儲存。

  • Step2: 透過定時任務輪訓掃描將資料轉發投遞至RocketMQ叢集。

  • Step3: 消費者進行消費。

為什麼同樣是資料庫RocksDB會比Mysql更加合適呢?因為RocksDB的特性是LSM樹,其使用場景適用於大量寫入,和訊息佇列的場景更加契合,所以這個也是滴滴和美團選擇其作為延時訊息封裝的儲存介質。

3.2 時間輪+磁碟儲存

再說時間輪之前,讓我們再次回到我們的實現本地延時的時候使用的ScheduledThreadPoolExecutor還有Timer,他們都是使用的優先順序佇列完成的,優先順序佇列本質上也就是堆結構,堆結構的插入的時間複雜度是O(LogN),如果未來我們的記憶體可以做到無限,我們使用使用優先順序佇列去做延時訊息的儲存,但是隨著訊息的增多,我們的插入訊息的效率也會越來越低,那麼怎麼才能讓我們的插入訊息的效率不隨著訊息的增多而變低呢?答案就是時間輪。

什麼是時間輪呢?其實我們可以簡單的將其看做是一個多維陣列。在很多框架中都使用了時間輪來做一些定時的任務,用來替代我們的Timer,比如我之前講過的有關本地快取Caffeine一篇文章,在Caffeine中是一個二層時間輪,也就是二維陣列,其一維的資料表示較大的時間維度比如,秒,分,時,天等,其二維的資料表示該時間維度較小的時間維度,比如秒內的某個區間段。當定位到一個TimeWhile[i][j]之後,其資料結構其實是一個連結串列,記錄著我們的Node。在Caffeine利用時間輪記錄我們在某個時間過期的資料,然後去處理。

如果有人再問你怎麼實現分散式延時訊息,這篇文章丟給他

由於時間輪是一個陣列的結構,那麼其插入複雜度是O(1)。我們解決了效率之後,但是我們的記憶體依舊不是無限的,我們時間輪如何使用呢?答案當然就是磁碟,在去哪兒開源的QMQ中已經實現了時間輪+磁碟儲存,這裡為了方便描述我將其轉化為RocketMQ中的結構來進行講解,實現圖如下:

如果有人再問你怎麼實現分散式延時訊息,這篇文章丟給他
  • Step 1: 生產者投遞延時訊息到CommitLog,這個時候使用了偷換Topic的那招,來達到後面的效果。

  • Step 2: 後臺有一個Reput的任務定時拉取,延時Topic相關的Message。

  • Step 3: 判斷這個Message是否在當前時間輪範圍中,如果不在則來到Step4,如果在的話就直接將訊息投遞進入時間輪。

  • Step 4: 找到當前訊息所屬的scheduleLog,然後寫入進去,去哪兒預設劃分是一個小時為一段,這裡可以根據業務自行調整。

  • Step 5:時間輪會定時預載入下個時間段的scheduleLog到記憶體。

  • Step 6: 到點的訊息會還原topic再次投遞到CommitLog,如果投遞成功這裡會記錄dispatchLog。記錄的原因是因為時間輪是記憶體的,你不知道已經執行到哪個位置了,如果執行到最後最後1s鐘的時候掛了,這段時間輪之前的所有資料又得重新載入,這裡是用來過濾已經投遞過的訊息。

時間輪+磁碟儲存我個人覺得比上面的RocksDB要更加正統一點,不依賴其他的中介軟體就可以完成,可用性自然也就更高,當然阿里雲的RocketMQ具體怎麼實現的這個兩種方案都有可能。

3.3 redis

在社群中也有很多公司使用的Redis做的延時訊息,在Redis中有一個資料結構是Zest,也就是有序集合,他可以實現類似我們的優先順序佇列的功能,同樣的他也是堆結構,所以插入演算法複雜度依然是O(logN),但是由於Redis足夠快,所以這一塊可以忽略。(這塊沒有做對比的基準測試,只是猜測)。有同學會問,redis不是純記憶體的k,v嗎,同樣的應該也會受到記憶體限制啊,為什麼還會選擇他呢?

其實在這個場景中,Redis是很容易水平擴充套件的當一個Redis記憶體不夠,這裡可以使用兩個甚至更多,來滿足我們的需要,redis延時訊息的原理圖(原圖出自:https://www.cnblogs.com/lylife/p/7881950.html)如下:

如果有人再問你怎麼實現分散式延時訊息,這篇文章丟給他
  • Delayed Messages Pool: Redis Hash結構,key為訊息ID,value為具體的message,當然這裡也可以用磁碟或者資料庫代替。這裡主要儲存我們所有訊息的內容。

  • Delayed Queue: ZSET資料結構,value為訊息ID,score為執行時間,這裡Delayed Queue可以水平擴充套件從而增加我們可以支援的資料量。

  • Worker Thread Pool: 其中有多個Worker,可以部署在多個機器上形成一個叢集,叢集中的所有Worker透過ZK進行協調,分配Delayed Queue。

我們怎麼才能知道Delayed Queue中的訊息到期了呢?這裡有兩種方法:

  • 每個Worker定時掃描,ZSET的最小執行時間,如果到了就取出,這個方法在訊息少的時候特別浪費資源,在訊息量多的時候,由於輪訓不及時導致延時的時間不準確。

  • 因為第一個方法問題比較多,所以這裡借鑑了Timer中的一些思想,透過wait-notify可以達到一個比較好的延時效果,並且資源也不會浪費,第一次的時候還是獲取ZSET中最小的時間,然後wait(執行時間-當前時間),這樣就不需要浪費資源到達時間時會自動響應,如果當前ZSET有新的訊息進入,並且比我們等待的訊息還要小,那麼直接notify喚醒,重新獲取這個更小的訊息,然後又wait,如此迴圈。

總結

本文介紹了三種方式實現分散式延時訊息,希望能在你實現自己的延遲訊息的時候提供一點思路。總的來說可能前兩種方法來說適用面更加廣一點,畢竟在RocketMQ這些大型的訊息佇列中介軟體,還有一些其他的整合功能,比如順序訊息,事務訊息等,延時訊息可能更加傾向於是分散式訊息佇列中的一個功能,而不是作為一個獨立的元件存在。當然其中還有一些細節並沒有一一介紹,具體細節可以去參考QMQ和DDMQ的原始碼。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31555607/viewspace-2672190/,如需轉載,請註明出處,否則將追究法律責任。

相關文章