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"
接下來,我們可以透過 spring-data-redis API 演示生產消費流程:
- 生產者
redisTemplate.opsForList().leftPush("queue" , "Java");
redisTemplate.opsForList().leftPush("queue" , "勇哥");
redisTemplate.opsForList().leftPush("queue" , "Go");
- 消費者
我們啟動一個獨立的執行緒從佇列中讀取訊息(RPOP 命令),讀取成功之後,消費訊息,若沒有訊息,則休眠一會,下一次迴圈再繼續。
上圖的虛擬碼中, while(true)
迴圈內不停地呼叫 RPOP
指令,當有訊息時,可以及時處理,但假如沒有讀取到訊息,則需要休眠一會。
這裡要加休眠,主要是為了減少空讀的頻率,避免 CPU 無意義的消耗。
有什麼更最佳化的方式嗎? 有,那就是使用 Redis 阻塞讀取 List 的命令。
Redis 提供了 BLPOP、BRPOP
阻塞讀取的命令,消費者在在讀取佇列沒有資料的時自動阻塞,直到有新的訊息寫入佇列,才會繼續讀取新訊息執行業務邏輯。
BRPOP queue 0
引數 0 表示阻塞等待時間無限制 。
如圖,我們啟動一個消費執行緒永動機,消費執行緒拉取訊息後,執行消費邏輯。
這種消費者執行緒模型非常容易理解,同時也非常適合順序消費的模式。同時,假如我們在消費訊息時,伺服器當機或者斷電,可能丟失一條訊息。
接下來,我們想一想,有沒有消費速度更高的消費模型嗎? 筆者根據過往的經歷,列舉三種模式:
- 拉取執行緒 + 消費執行緒池(非阻塞模式)
- 拉取執行緒 + 消費執行緒池 (阻塞模式)
- 拉取執行緒 + Disruptor(阻塞模式)
2 拉取執行緒 + 消費執行緒池(非阻塞模式)
為了提升消費速度,我們可以將拉取和消費拆分成兩種動作,分別透過不同的執行緒池來處理。拉取執行緒池負責拉取訊息,消費執行緒池負責消費訊息。
虛擬碼類似:
如圖,在拉取執行緒內部,我們拉取完訊息後,將訊息提交到消費執行緒 consumeExecutor 。
這樣方式可以透過多執行緒執行大幅度提升消費速度 ,但是這裡還是有一個問題:
假如消費速度很慢,生產者速度很高,那麼就會線上程池內容易產生訊息堆積,這裡面會產生兩個隱形風險:
- 執行緒池佇列無限堆積,則可能有 OOM 的風險 ;
- 假如消費者伺服器當機或者斷電,那麼會丟失大量的訊息。
那麼如何最佳化這種模式呢 ?
答案是:拉取執行緒提交訊息到執行緒池時,當佇列中訊息數量到達一定數量時,提交訊息到執行緒池會阻塞。
3 拉取執行緒 + 消費執行緒池(阻塞模式)
我們將訊息包裝為 Runnable ,然後透過消費執行緒池執行 execute ,拉取執行緒會不會阻塞呢 ?
下圖是執行的原始碼:
可以看到,第 30 行呼叫的是 workQueue 的非阻塞的 offer 方法。
如果佇列已滿,新提交的任務並不會被 block 住,反而會呼叫後續的 reject 流程。
如果我們想要達到阻塞生產者的目的的話,可以採取如下的兩種方案:
- 訊號量限制同時進入執行緒池等待佇列的任務數 。
- 使用執行緒池的拒絕機制,把新加入的任務 put 到等待佇列裡,這樣也可以阻塞住生產者。
4 拉取執行緒 + Disruptor
下圖展示了 Disruptor 的流程圖 。
和執行緒池機制非常類似, Disruptor 也是非常典型的生產者/消費者模式。執行緒池儲存提交任務的容器是阻塞佇列,而 Disruptor 使用的是環形緩衝區 RingBuffer。
環形緩衝區的設計相比阻塞佇列有如下優點:
- 環形陣列結構
為了避免垃圾回收,採用陣列而非連結串列。同時,陣列對處理器的快取機制更加友好。
- 元素位置定位
陣列長度 2^n,透過位運算,加快定位的速度。下標採取遞增的形式,不用擔心 index 溢位的問題。index 是 long 型別,即使100萬QPS的處理速度,也需要30萬年才能用完。
- 無鎖設計
每個生產者或者消費者執行緒,會先申請可以操作的元素在陣列中的位置,申請到之後,直接在該位置寫入或者讀取資料。
此刻大家並不需要理解環形緩衝區的讀寫機制,只需要明白 環形緩衝區 RingBuffer 是 Disruptor 的精髓即可。
將消費執行緒池替換成 Disruptor 有兩個明顯的優點:
- 無鎖佇列,寫入讀取效能非常好
- 當拉取執行緒提交訊息到 Disruptor 時,若環形緩衝區 RingBuffer 已經滿了,則拉取執行緒會阻塞,這樣天然的可以避免無限拉取,同時避免 OOM 的問題。
虛擬碼類似:
1、定義 Disruptor
2、拉取執行緒將訊息傳送到 Disruptor Ringbuffer
3、消費訊息
整體的消費者執行緒模型如下圖:
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 做訊息佇列,不可避免的會有訊息丟失,所以我們需要用定時任務做補償,每隔一段時間去業務表裡查詢業務狀態機,若狀態機不符合條件,則觸發補償策略。