(六)Redis 訊息佇列 List、Streams

冬先生發表於2024-07-29

Redis 適合做訊息佇列嗎?有什麼解決方案?首先要明白訊息佇列的訊息存取需求和工作流程。

1、訊息佇列

我們一般把訊息佇列中傳送訊息的元件稱為生產者,把接收訊息的元件稱為消費者,下圖是一個通用的訊息佇列的架構模型:
訊息佇列在存取訊息時,必須要滿足三個需求,分別是訊息保序、處理重複的訊息和保證訊息可靠性。

(1)訊息保序

雖然消費者是非同步處理訊息,但是,消費者仍然需要按照生產者傳送訊息的順序來處理訊息,避免後傳送的訊息被先處理了。

(2)重複訊息處理

消費者從訊息佇列讀取訊息時,有時會因為網路堵塞而出現訊息重傳的情況。如果多次處理重複訊息的話,就可能造成一個業務邏輯被多次執行,從而出現資料問題。

(3)訊息可靠性保證

消費者在處理訊息的時候,還可能出現因為故障或當機導致訊息沒有處理完成的情況。此時,訊息佇列需要能提供訊息可靠性的保證,也就是說,當消費者重啟後,可以重新讀取訊息再次進行處理,否則,就會出現訊息漏處理的問題了。

2、List 方案

List 本身就是按先進先出的順序對資料進行存取的,所以,如果使用 List 作為訊息佇列儲存訊息的話,已經能滿足訊息 保序 的需求了。具體來說,生產者可以使用 LPUSH 命令把要傳送的訊息依次寫入 List,而消費者則可以使用 RPOP 命令,從 List 的另一端按照訊息的寫入順序,依次讀取訊息並進行處理。
List 並不會主動地通知消費者有新訊息寫入,如果消費者迴圈呼叫 RPOP 命令又會帶來 CPU 開銷問題。Redis 提供了 BRPOP 命令,稱為阻塞式讀取,客戶端在沒有讀到佇列資料時,自動阻塞,直到有新的資料寫入佇列,再開始讀取新資料。和消費者程式自己不停地呼叫 RPOP 命令相比,這種方式能節省 CPU 開銷。

在解決 重複訊息處理 的問題上,一方面,訊息佇列要能給每一個訊息提供全域性唯一的 ID,另一方面,消費者程式要把已經處理過的訊息的 ID 記錄下來,如果已經處理過,消費者程式就不再進行處理了。這種處理特性也稱為冪等性,指對於同一條訊息,消費者收到一次或多次的處理結果是一致的。不過,List 本身是不會為每個訊息生成 ID,所以,訊息的全域性唯一 ID 需要生產者程式在傳送訊息前自行生成,幷包含在訊息中以供消費者處理。

為了 保證訊息可靠性 ,List 型別提供了 BRPOPLPUSH 命令,這個命令的作用是讓消費者程式從一個 List 中讀取訊息,同時,Redis 會把這個訊息再插入到另一個 List(可以叫作備份 List)留存,這樣一來,如果消費者程式讀了訊息但沒能正常處理,等它重啟後,就可以從備份 List 中重新讀取訊息並進行處理了。

3、Streams 方案

如果生產者訊息傳送很快,而消費者處理訊息的速度比較慢,會導致 List 中的訊息越積越多,給 Redis 的記憶體帶來很大壓力,而 List 並不支援多個消費者同時處理。這時候就要用到 Redis 從 5.0 版本開始提供的 Streams 資料型別了。Streams 是 Redis 專門為訊息佇列設計的資料型別,它提供了豐富的訊息佇列操作命令:

  • XADD:插入訊息,保證有序,可以自動生成全域性唯一 ID
  • XREAD:用於讀取訊息,可以按 ID 讀取資料
  • XREADGROUP:按消費組形式讀取訊息
  • XPENDING:命令可以用來查詢每個消費組內所有消費者已讀取但尚未確認的訊息
  • XACK:命令用於向訊息佇列確認訊息處理已完成

XADD 命令插入新訊息的格式是鍵 - 值對形式,例如往名稱為 mqstream 的訊息佇列中插入一條訊息:

XADD mqstream * repo 5
"1599203861727-0"

其中,* 表示讓 Redis 為插入的資料自動生成一個全域性唯一的 ID,例如“1599203861727-0”。也可以不用 *,直接在訊息佇列名稱後自行設定一個 ID,只要保證全域性唯一就行。

自動生成的 ID 由兩部分組成,第一部分“1599203861727”是資料插入時,以毫秒為單位計算的當前伺服器時間,第二部分表示插入訊息在當前毫秒內的訊息序號,從 0 開始。例如,“1599203861727-0”就表示在“1599203861727”毫秒內的第 1 條訊息。

XREAD 在讀取訊息時,可以指定一個訊息 ID,並從這個訊息 ID 的下一條訊息開始進行讀取。設定 block 配置項,可實現類似於 BRPOP 的阻塞讀取操作,單位是毫秒。例如,從 ID 為 1599203861727-0 的訊息開始,讀取後續的所有訊息(共 3 條)

XREAD BLOCK 100 STREAMS  mqstream 1599203861727-0
1) 1) "mqstream"
   2) 1) 1) "1599274912765-0"
         2) 1) "repo"
            2) "3"
      2) 1) "1599274925823-0"
         2) 1) "repo"
            2) "2"
      3) 1) "1599274927910-0"
         2) 1) "repo"
            2) "1"

再看一個例子,命令以 $ 結尾表示讀取最新的訊息,同時設定了 block 10000 的配置項,表明 XREAD 在讀取最新訊息時,如果沒有訊息到來將阻塞 10000 毫秒(即 10 秒),然後再返回。當訊息佇列 mqstream 中一直沒有訊息時,XREAD 在 10 秒後返回空值(nil)

XREAD block 10000 streams mqstream $
(nil)
(10.00s)

XGROUP 建立消費組,是區別於 List 的功能,建立後 Streams 可以使用 XREADGROUP 命令讓消費組內的消費者讀取訊息。
例如,我們執行下面的命令,建立一個名為 group1 的消費組,這個消費組消費的訊息佇列是 mqstream

XGROUP create mqstream group1 0
OK

執行命令,讓 group1 消費組裡的消費者 consumer1 從 mqstream 中讀取所有訊息,命令最後的引數“>”,表示從第一條尚未被消費的訊息開始讀取。在 consumer1 讀取訊息前,group1 中沒有其他消費者讀取過訊息,所以,consumer1 就得到 mqstream 訊息佇列中的所有訊息共4條。

XREADGROUP group group1 consumer1 streams mqstream >
1) 1) "mqstream"
   2) 1) 1) "1599203861727-0"
         2) 1) "repo"
            2) "5"
      2) 1) "1599274912765-0"
         2) 1) "repo"
            2) "3"
      3) 1) "1599274925823-0"
         2) 1) "repo"
            2) "2"
      4) 1) "1599274927910-0"
         2) 1) "repo"
            2) "1"

如果佇列中的訊息已經被其他消費者讀取,則其他消費者無法讀取,例如,再讓 group1 內的 consumer2 讀取訊息時,返回空值。

XREADGROUP group group1 consumer2  streams mqstream 0
1) 1) "mqstream"
   2) (empty list or set)

消費組的目的是讓組內的多個消費者共同分擔讀取,從而實現負載均衡,例如,讓 group2 中的 consumer1、2、3 各自讀取一條訊息

XREADGROUP group group2 consumer1 count 1 streams mqstream >
1) 1) "mqstream"
   2) 1) 1) "1599203861727-0"
         2) 1) "repo"
            2) "5"
XREADGROUP group group2 consumer2 count 1 streams mqstream >
1) 1) "mqstream"
   2) 1) 1) "1599274912765-0"
         2) 1) "repo"
            2) "3"
XREADGROUP group group2 consumer3 count 1 streams mqstream >
1) 1) "mqstream"
   2) 1) 1) "1599274925823-0"
         2) 1) "repo"
            2) "2"

為了保證消費者在發生故障或當機再次重啟後,仍然可以讀取未處理完的訊息,Streams 會自動使用內部佇列(也稱為 PENDING List)留存消費組裡每個消費者讀取的訊息,直到消費者使用 XACK 命令通知 Streams“訊息已經處理完成”。如果消費者沒有成功處理訊息,它就不會給 Streams 傳送 XACK 命令,訊息仍然會留存。此時,消費者可以在重啟後,用 XPENDING 命令檢視已讀取、但尚未確認處理完成的訊息。

例如,檢視一下 group2 中各個消費者已讀取、但尚未確認的訊息個數。其中,XPENDING 返回結果的第二、三行分別表示 group2 中所有消費者讀取的訊息最小 ID 和最大 ID。

XPENDING mqstream group2
1) (integer) 3
2) "1599203861727-0"
3) "1599274925823-0"
4) 1) 1) "consumer1"
      2) "1"
   2) 1) "consumer2"
      2) "1"
   3) 1) "consumer3"
      2) "1"

如果需要進一步檢視某個消費者具體讀取了哪些資料,可以執行以下命令,consumer2 已讀取的訊息的 ID 是 1599274912765-0

XPENDING mqstream group2 - + 10 consumer2
1) 1) "1599274912765-0"
   2) "consumer2"
   3) (integer) 513336
   4) (integer) 1

當 1599274912765-0 被 consumer2 處理了,consumer2 就可以使用 XACK 命令通知 Streams,然後這條訊息就會被刪除。當我們再使用 XPENDING 命令檢視時,就可以看到,consumer2 已經沒有已讀取、但尚未確認處理的訊息了。


 XACK mqstream group2 1599274912765-0
(integer) 1
XPENDING mqstream group2 - + 10 consumer2
(empty list or set)

一張表格,彙總了用 List 和 Streams 實現訊息佇列的特點和區別
Redis 是一個非常輕量級的鍵值資料庫,Kafka、RabbitMQ 是專門面向訊息佇列場景的重量級軟體,例如 Kafka 的執行就需要再部署 ZooKeeper。如果分散式系統中的元件訊息通訊量不大,那麼,Redis 只需要使用有限的記憶體空間就能滿足訊息儲存的需求,而且,Redis 的高效能特效能支援快速的訊息讀寫,不失為訊息佇列的一個好的解決方案。

相關文章