剖析 Redis List 訊息佇列的三種消費執行緒模型

勇哥编程游记發表於2024-09-10

Redis 列表(List)是一種簡單的字串列表,它的底層實現是一個雙向連結串列。

生產環境,很多公司都將 Redis 列表應用於輕量級訊息佇列 。這篇文章,我們聊聊如何使用 List 命令實現訊息佇列的功能以及剖析消費者執行緒模型 。

剖析 Redis List 訊息佇列的三種消費執行緒模型

1 核心流程

生產者使用 LPUSH key element[element...] 將訊息插入到佇列的頭部,如果 key 不存在則會建立一個空的佇列再插入訊息。

如下,生產者向佇列 queue 先後插入了 「Java」「勇哥」「Go」,返回值表示訊息插入佇列後的個數。

> LPUSH queue Java 勇哥 Go
(integer) 3

消費者使用 RPOP key 依次讀取佇列的訊息,先進先出,所以 「Java」會先讀取消費:

> RPOP queue
"Java"
> RPOP queue
"勇哥"
> RPOP queue
"Go"
剖析 Redis List 訊息佇列的三種消費執行緒模型

接下來,我們可以透過 spring-data-redis API 演示生產消費流程:

  • 生產者
redisTemplate.opsForList().leftPush("queue" , "Java");
redisTemplate.opsForList().leftPush("queue" , "勇哥");
redisTemplate.opsForList().leftPush("queue" , "Go");
  • 消費者

我們啟動一個獨立的執行緒從佇列中讀取訊息(RPOP 命令),讀取成功之後,消費訊息,若沒有訊息,則休眠一會,下一次迴圈再繼續。

剖析 Redis List 訊息佇列的三種消費執行緒模型

上圖的虛擬碼中, while(true) 迴圈內不停地呼叫 RPOP 指令,當有訊息時,可以及時處理,但假如沒有讀取到訊息,則需要休眠一會。

這裡要加休眠,主要是為了減少空讀的頻率,避免 CPU 無意義的消耗。

有什麼更最佳化的方式嗎? 有,那就是使用 Redis 阻塞讀取 List 的命令。

Redis 提供了 BLPOP、BRPOP 阻塞讀取的命令,消費者在在讀取佇列沒有資料的時自動阻塞,直到有新的訊息寫入佇列,才會繼續讀取新訊息執行業務邏輯。

BRPOP queue 0

引數 0 表示阻塞等待時間無限制 。

剖析 Redis List 訊息佇列的三種消費執行緒模型

如圖,我們啟動一個消費執行緒永動機,消費執行緒拉取訊息後,執行消費邏輯。

這種消費者執行緒模型非常容易理解,同時也非常適合順序消費的模式。同時,假如我們在消費訊息時,伺服器當機或者斷電,可能丟失一條訊息。

接下來,我們想一想,有沒有消費速度更高的消費模型嗎? 筆者根據過往的經歷,列舉三種模式:

  • 拉取執行緒 + 消費執行緒池(非阻塞模式)
  • 拉取執行緒 + 消費執行緒池 (阻塞模式)
  • 拉取執行緒 + Disruptor(阻塞模式)

2 拉取執行緒 + 消費執行緒池(非阻塞模式)

為了提升消費速度,我們可以將拉取和消費拆分成兩種動作,分別透過不同的執行緒池來處理。拉取執行緒池負責拉取訊息,消費執行緒池負責消費訊息。

剖析 Redis List 訊息佇列的三種消費執行緒模型

虛擬碼類似:

剖析 Redis List 訊息佇列的三種消費執行緒模型

如圖,在拉取執行緒內部,我們拉取完訊息後,將訊息提交到消費執行緒 consumeExecutor 。

這樣方式可以透過多執行緒執行大幅度提升消費速度 ,但是這裡還是有一個問題:

假如消費速度很慢,生產者速度很高,那麼就會線上程池內容易產生訊息堆積,這裡面會產生兩個隱形風險:

  • 執行緒池佇列無限堆積,則可能有 OOM 的風險 ;
  • 假如消費者伺服器當機或者斷電,那麼會丟失大量的訊息。

那麼如何最佳化這種模式呢 ?

答案是:拉取執行緒提交訊息到執行緒池時,當佇列中訊息數量到達一定數量時,提交訊息到執行緒池會阻塞。

3 拉取執行緒 + 消費執行緒池(阻塞模式)

我們將訊息包裝為 Runnable ,然後透過消費執行緒池執行 execute ,拉取執行緒會不會阻塞呢 ?

下圖是執行的原始碼:

剖析 Redis List 訊息佇列的三種消費執行緒模型

可以看到,第 30 行呼叫的是 workQueue 的非阻塞的 offer 方法。

如果佇列已滿,新提交的任務並不會被 block 住,反而會呼叫後續的 reject 流程。

如果我們想要達到阻塞生產者的目的的話,可以採取如下的兩種方案:

  • 訊號量限制同時進入執行緒池等待佇列的任務數 。
剖析 Redis List 訊息佇列的三種消費執行緒模型
  • 使用執行緒池的拒絕機制,把新加入的任務 put 到等待佇列裡,這樣也可以阻塞住生產者。
剖析 Redis List 訊息佇列的三種消費執行緒模型

4 拉取執行緒 + Disruptor

下圖展示了 Disruptor 的流程圖 。

剖析 Redis List 訊息佇列的三種消費執行緒模型

和執行緒池機制非常類似, Disruptor 也是非常典型的生產者/消費者模式。執行緒池儲存提交任務的容器是阻塞佇列,而 Disruptor 使用的是環形緩衝區 RingBuffer。

環形緩衝區的設計相比阻塞佇列有如下優點:

  • 環形陣列結構

為了避免垃圾回收,採用陣列而非連結串列。同時,陣列對處理器的快取機制更加友好。

  • 元素位置定位

陣列長度 2^n,透過位運算,加快定位的速度。下標採取遞增的形式,不用擔心 index 溢位的問題。index 是 long 型別,即使100萬QPS的處理速度,也需要30萬年才能用完。

  • 無鎖設計

每個生產者或者消費者執行緒,會先申請可以操作的元素在陣列中的位置,申請到之後,直接在該位置寫入或者讀取資料。

此刻大家並不需要理解環形緩衝區的讀寫機制,只需要明白 環形緩衝區 RingBuffer 是 Disruptor 的精髓即可。

將消費執行緒池替換成 Disruptor 有兩個明顯的優點:

  • 無鎖佇列,寫入讀取效能非常好
  • 當拉取執行緒提交訊息到 Disruptor 時,若環形緩衝區 RingBuffer 已經滿了,則拉取執行緒會阻塞,這樣天然的可以避免無限拉取,同時避免 OOM 的問題。

虛擬碼類似:

1、定義 Disruptor

剖析 Redis List 訊息佇列的三種消費執行緒模型

2、拉取執行緒將訊息傳送到 Disruptor Ringbuffer

剖析 Redis List 訊息佇列的三種消費執行緒模型

3、消費訊息

剖析 Redis List 訊息佇列的三種消費執行緒模型

整體的消費者執行緒模型如下圖:

剖析 Redis List 訊息佇列的三種消費執行緒模型

5 平滑停服 + 定時任務補償

當我們分析消費者執行緒模型時,無論我們使用哪種方式,假如伺服器突然當機、或者物理機斷電,則會丟失訊息。

筆者推薦兩種方式:

1、平滑停服

平滑停服是指在停止應用程式時,儘量避免中斷正在進行的請求或任務,儘量讓正在進行的任務處理完成,並且不再接收新的任務,等所有任務執行完成後關閉應用。

在 Unix/Linux 系統中,可以使用 kill 命令傳送訊號給執行中的程序。

常見的訊號有:

  • SIGTERM (15):請求程序終止,可以被捕捉和處理,用於優雅地停止程序。
  • SIGKILL (9):強制終止程序,不能被捕捉或忽略。
  • SIGQUIT (3):程序退出並生成核心轉儲(core dump)。

為了實現平滑停服,可以使用 Java 的 Runtime.getRuntime().addShutdownHook 方法註冊一個關閉鉤子(shutdown hook)。當 JVM 接收到SIGTERM訊號時,關閉鉤子會被執行,從而可以在應用程式停止前執行一些清理工作。

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
        System.out.println("Shutdown hook triggered. Performing cleanup...");
        // 在這裡執行清理工作,如關閉資源、儲存狀態等
}));

我們可以在鉤子裡,關閉拉取執行緒池 ,優雅關閉消費執行緒池等 ,這樣可以儘量避免丟失訊息。

2、定時任務補償

使用 List 做訊息佇列,不可避免的會有訊息丟失,所以我們需要用定時任務做補償,每隔一段時間去業務表裡查詢業務狀態機,若狀態機不符合條件,則觸發補償策略。

相關文章