Redis系列:使用Stream實現訊息佇列 (圖文總結+Go案例)

Hello-Brand發表於2024-08-07

Redis24篇集合

1 先導

我們在《Redis系列14:使用List實現訊息佇列》這一篇中詳細討論瞭如何使用List實現訊息佇列,但同時也看到很多侷限性,比如:

  • 不支援訊息確認機制,沒有很好的ACK應答
  • 不支援訊息回溯,無法排查問題和做訊息分析
  • List按照FIFO機制執行,所以存在訊息堆積的風險。
  • 查詢效率低,作為線性結構,List中定位一個資料需要進行遍歷,O(N)的時間複雜度
  • 不存在消費組(Consumer Group)的概念,無法實現多個消費者組成分組進行消費

2 關於Stream

Redis Stream是Redis 5.0版本中引入的一種新的資料結構,它主要用於高效地處理流式資料,特別適用於訊息佇列、日誌記錄和實時資料分析等場景
以下是對Redis Stream的 主要特徵:
1. 資料結構:Redis Stream是一個由有序訊息組成的日誌資料結構,每個訊息都有一個全域性唯一的ID,確保訊息的順序性和可追蹤性。
2. 訊息ID:訊息的ID由兩部分組成,分別是毫秒級時間戳和序列號。這種設計確保了訊息ID的單調遞增性,即新訊息的ID總是大於舊訊息的ID。
3. 消費者組Redis Stream支援消費者組的概念,允許多個消費者以組的形式訂閱Stream,並且每個訊息只會被組內的一個消費者處理,避免了訊息的重複消費。

以及主要優勢:

1. 持久化儲存:Stream中的訊息可以被持久化儲存,確保資料不會丟失,即使在Redis伺服器重啟後也能恢復訊息。
2. 有序性:訊息按照產生順序生成訊息ID, 被新增到Stream中,並且可以按照指定的條件檢索訊息,保證了訊息的有序性
3. 多播與分組消費:支援多個消費者同時消費同一流中的訊息,並且可以將消費者組織成消費組,實現訊息的分組消費
4. 訊息確認機制:消費者可以透過XACK命令確認是否成功消費訊息,保證訊息至少背消費一次,確保訊息不會被重複處理。
5. 阻塞讀取:消費者可以選擇阻塞讀取模式,當沒有新訊息時,消費者會等待直至新訊息到達。
6. 訊息可回溯: 方便補數、特殊資料處理, 以及問題回溯查詢

3 主要命令

1. XADD:向Stream中新增訊息。如果指定的Stream不存在,則會自動建立。
2. XREAD:以阻塞/非阻塞方式獲取Stream中的訊息列表。
3. XREADGROUP:從消費者組中讀取訊息,支援阻塞讀取。
4. XACK:確認消費者已經成功處理了訊息。
5. XGROUP:用於管理消費者組,包括建立、設定ID、銷燬消費者組等操作。
6. XPENDING:查詢消費者組中的待處理訊息。

3.1 XADD 訊息記錄

XADD命令用於向Redis Stream(流)資料結構中新增訊息。

3.1.1 XADD 命令的基本語法

XADD stream_name [MAXLEN maxlen] [ID id] field1 value1 [field2 value2 ...]

1. stream_name:指定要新增訊息的Stream的名字。
2. MAXLEN maxlen:可選引數,用於限制Stream的最大長度。當Stream的長度達到maxlen時,舊的訊息會被自動刪除。
3. ID id:可選引數,用於指定訊息的ID。如果不指定該引數,Redis會自動生成一個唯一的ID。
4. field1 value1 [field2 value2 ...]:訊息的欄位和值,訊息的內容以key-value的形式存在。

XADD命令的一個重要用途是實現訊息釋出功能,釋出者可以使用XADD命令向Stream中新增訊息。

3.1.2 XADD 示例

假設我們有一個名為userinfo_stream的Stream,並希望向其中新增一個包含sensor_idtemperature欄位的訊息,我們可以使用以下命令:

XADD userinfo_stream * user_name brand age 18

在這個例子中,*表示讓Redis自動生成一個唯一的訊息ID。訊息包含兩個欄位:usernameage,它們的值分別是brand18。所以這邊記錄了一個使用者資訊,姓名為brand, 年齡18歲。

3.1.3 有啥需要注意的呢

  • 如果指定的Stream不存在,XADD命令會建立一個新的Stream
  • 訊息的ID是唯一的,並且Redis會保證Stream中訊息的ID是單調遞增的。如果指定了ID,則新訊息的ID必須大於Stream中現有的所有訊息的ID。
  • 使用MAXLEN引數可以限制Stream的大小,這在處理大量訊息時非常有用,可以避免Stream佔用過多的記憶體或磁碟空間。

3.2 XREAD 訊息消費

即將訊息從佇列中讀取出來(消費)

3.2.1 XREAD 命令的基本語法

XREAD命令的基本語法如下:

XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]

1. COUNT count:這是一個可選引數,用於指定一次讀取的最大訊息數量。如果不指定,預設為1。
2. BLOCK milliseconds:這也是一個可選引數,用於指定阻塞的時間(以毫秒為單位)。如果指定了阻塞時間,並且當前沒有可消費的訊息,客戶端將在指定的時間內阻塞等待。如果不設定該引數或設定為0,則命令將立即返回,無論是否有可消費的訊息。
3. STREAMS key [key ...] ID [ID ...]:這部分指定了要消費的流(Streams)和對應的起始訊息ID。可以一次指定多個流和對應的起始ID。

XREAD命令的工作機制
1. 讀取指定ID之後的訊息:XREAD命令會返回指定ID之後的訊息(不包含指定ID的訊息本身)。如果沒有指定ID,或者指定的ID不存在於流中,那麼命令將從流的開始或結束處讀取訊息,具體取決於ID的值(如“0-0”表示從流的開始處讀取,“$”表示從流的當前最大ID處讀取)。
2. 阻塞讀取:當設定了BLOCK引數後,如果當前沒有可消費的訊息,客戶端將進入阻塞狀態,直到有新的訊息到達或阻塞時間超時。這種機制非常適合實現消費者等待生產者產生新訊息的場景。
3. 支援多個流:XREAD命令支援同時從多個流中讀取訊息,只需在命令中指定多個流和對應的起始ID即可。

3.2.2 XREAD 示例

假設我們有一個名為userinfo_stream的流,並且想要從該流中讀取訊息。以下是一些示例:
1. 非阻塞讀取最新訊息

XREAD COUNT 1 STREAMS userinfo_stream $

這條命令會嘗試從userinfo_stream流中讀取最新的訊息(如果有的話)。$是一個特殊ID,表示流的當前最大ID。

2. 阻塞讀取最新訊息

XREAD COUNT 1 BLOCK 1000 STREAMS userinfo_stream $

這條命令會阻塞1000毫秒,等待userinfo_stream流中出現新的訊息。如果在1000毫秒內有新訊息到達,則命令會返回該訊息;否則,命令將超時並返回nil。

3. 從特定ID開始讀取

XREAD COUNT 2 STREAMS userinfo_stream 1722159931000-0
1) 1) "userinfo_stream"
    2)  1) 1) "1722159931000-0"
         2) 1) "user_name"
             2) "brand"
             3) "age"
             4) "18"

這條命令會從userinfo_stream流中讀取ID大於或等於1722159931000-0的訊息,最多返回資料。

3.2.3 需要注意啥呢?

1. 訊息ID的唯一性:在Redis Streams中,每個訊息都有一個全域性唯一的訊息ID,這個訊息ID由兩部分組成:時間戳和序列號。時間戳表示訊息被新增到流中的時間,序列號表示在同一時間戳內新增的訊息的順序。
2. 消費者組:雖然XREAD命令本身不直接涉及消費者組的概念,但Redis Streams還支援消費者組模式,允許一組消費者協作消費同一流中的訊息。在消費者組模式下,通常會使用XREADGROUP命令而不是XREAD命令來讀取訊息。
3. 效能考慮:XREAD命令在讀取大量訊息時可能會消耗較多的CPU和記憶體資源。因此,在實際應用中需要根據實際情況合理設定COUNT引數的值,避免一次性讀取過多訊息導致效能問題。

3.3 Consumer Group 消費組模式

典型的多播模式,在實時性要求比較高的場景,如果你想加快對訊息的處理。那這是一個不錯的選擇,我們讓佇列在邏輯上進行分割槽,用不同的消費組來隔離消費。所以:

image

消費者組允許多個消費者(client 或 process)協同處理同一個流(Stream)中的訊息。每個消費者組維護自己的消費偏移量(即已處理訊息的位置),以支援消費者之間的負載均衡和容錯。

3.3.1 建立消費者組

使用 XGROUP CREATE 命令建立消費者組。

# stream_name:佇列名稱
# consumer_group:消費者組
# msgIdStartIndex:訊息Id開始位置
# msgIdStartIndex:訊息Id結束位置
# $ 表示從流的當前末尾(即最新訊息)開始建立消費者組。如果流不存在,MKSTREAM 選項將自動建立流
XGROUP CREATE stream_name consumer_group msgIdStartIndex-msgIdStartIndex
# 或者
XGROUP CREATE stream_name consumer_group $ MKSTREAM

下面是具體實現示例,為佇列 userinfo_stream 建立了消費組1(consumer_group1)和 消費組2(consumer_group2):

> xgroup create userinfo_stream consumer_group1 0-0
OK
> xgroup create userinfo_stream consumer_group2 0-0
OK

3.3.2 讀取訊息

消費者可以透過 XREADGROUP 命令從消費者組中讀取訊息。XREADGROUP 命令不僅讀取訊息,還會更新消費者組中的消費者狀態,即標記哪些訊息已被讀取。

# group_name: 消費者群組名
# consumer_name: 消費者名稱
# COUNT number: count 消費個數
# BLOCK ms: 表示如果流中沒有新訊息,則命令將阻塞最多 xx 毫秒,0則無限阻塞
# stream_name: 佇列名稱 
# id: 訊息消費ID
# []:代表可選引數
# `>`:放在命令引數的最後面,表示從尚未被消費的訊息開始讀取;

XREADGROUP GROUP group_name consumer_name [COUNT number] [BLOCK ms] STREAMS stream_name [stream ...] id [id ...]
# 或者
XREADGROUP GROUP group_name consumer_name COUNT 1 BLOCK 2000 STREAMS stream_name >

下面是具體實現示例,消費組 consumer_group1 的消費者 consumer1 從 userinfo_stream 中以阻塞的方式讀取一條訊息:

XREADGROUP GROUP consumer_group1 consumer1 COUNT 1 BLOCK 0 STREAMS userinfo_stream >
1) 1) "userinfo_stream"
   2) 1) 1) "1722159931000-0"
         2) 1) "user_name"
            2) "brand"
            3) "age"
            4) "18"

3.3.3 確認訊息

處理完訊息後,消費者需要傳送 XACK 命令來確認訊息。這告訴 Redis 這條訊息已經被成功處理,並且可以從消費者組的待處理訊息列表中移除

# stream_name: 佇列名稱 
# group_name: 消費者群組名
# <message-id> 是要確認的訊息的 ID。

XACK stream_name group_name <message-id>
# ACK 確認兩條訊息
XACK userinfo_stream consumer_group1 1722159931000-0 1722159932000-0
(integer) 2

3.3.4 PLE:訊息可靠性保障

PEL(Pending Entries List)記錄了當前被消費者讀取但尚未確認(ACK)的訊息。這些訊息在消費者成功處理併傳送ACK命令之前,會一直保留在PEL中。如果消費者崩潰或未能及時傳送ACK命令,Redis將確保這些訊息能夠被重新分配給其他消費者進行處理,從而實現訊息的可靠傳遞。

XPENDING stream_name group_name

以下的例子中,我們檢視 userinfo_stream 中的 消費組 consumer_group1 中各個消費者已讀取但未確認的訊息資訊。

XPENDING userinfo_stream consumer_group1
1) (integer) 2   # 未確認訊息條數
2) "1722159931000-0"
3) "1722159932000-0"

詳細的stream操作見官網文件:https://redis.io/docs/data-types/streams-tutorial/

4 使用Golang實現Stream佇列能力

4.1 先安裝go-redis/redis庫

> go get github.com/go-redis/redis/v8
go: downloading github.com/go-redis/redis v6.15.9+incompatible
go: downloading github.com/go-redis/redis/v8 v8.11.5
go: downloading github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f
go: downloading github.com/cespare/xxhash/v2 v2.1.2
go: added github.com/cespare/xxhash/v2 v2.1.2
go: added github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f
go: added github.com/go-redis/redis/v8 v8.11.5

注意:這裡的v8是庫的版本號,你可以根據實際情況進行調整

邏輯實現

package main  
  
import (  
	"context"  
	"fmt"  
	"log"  
	"time"  
  
	"github.com/go-redis/redis/v8"  
)  
  
func main() {  
	// 連線到Redis  
	rdb := redis.NewClient(&redis.Options{  
		Addr:     "localhost:6379", // Redis地址  
		Password: "",               // 密碼(如果有的話)  
		DB:       0,                // 使用預設DB  
	})  
  
	ctx := context.Background()  
  
	// 建立Stream  
	_, err := rdb.XAdd(ctx, &redis.XAddArgs{  
		Stream: "mystream",  
		Values: map[string]interface{}{  
			"field1": "value1",  
			"field2": "value2",  
		},  
	}).Result()  
	if err != nil {  
		log.Fatalf("Failed to add message to stream: %v", err)  
	}  
  
	// 建立Consumer Group  
	_, err = rdb.XGroupCreate(ctx, "mystream", "mygroup", "$").Result()  
	if err != nil && err != redis.Nil {  
		log.Fatalf("Failed to create consumer group: %v", err)  
	}  
  
	// 消費者讀取訊息  
	go func() {  
		for {  
			msgs, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{  
				Group:    "mygroup",  
				Consumer: "myconsumer",  
				Streams:  []string{"mystream", ">"},  
				Count:    1,  
				Block:    1000, // 阻塞1000毫秒  
			}).Result()  
			if err != nil {  
				if err == redis.Nil {  
					// 超時,沒有新訊息  
					continue  
				}  
				log.Fatalf("Failed to read from stream: %v", err)  
			}  
  
			for _, msg := range msgs[0].Messages {  
				fmt.Printf("Received: %s %s\n", msg.ID, msg.Values)  
  
				// 確認訊息  
				_, err = rdb.XAck(ctx, "mystream", "mygroup", msg.ID).Result()  
				if err != nil {  
					log.Fatalf("Failed to ack message: %v", err)  
				}  
			}  
		}  
	}()  
  
	// 模擬生產者繼續傳送訊息  
	for i := 0; i < 5; i++ {  
		_, err := rdb.XAdd(ctx, &redis.XAddArgs{  
			Stream: "mystream",  
			Values: map[string]interface{}{  
				"field1": fmt.Sprintf("value%d", i+1),  
				"field2": "another value",  
			},  
			MaxLen:     100,  
			Approximate: true,  
		}).Result()  
		if err != nil {  
			log.Fatalf("Failed to add message to stream: %v", err)  
		}  
		time.Sleep(2 * time.Second) // 模擬生產間隔  
	}  
  
	// 注意:在實際應用中,主goroutine通常不會立即退出,而是會等待某些觸發條件

5 應用場景

1. 訊息佇列:Redis Stream可以作為訊息佇列使用,支援訊息的釋出、訂閱和消費。
2. 日誌記錄:將日誌資訊寫入Redis Stream,方便後續的查詢和分析。
3. 實時資料分析:結合Redis的其他資料結構(如Sorted Set、Hash等),對Stream中的資料進行實時分析。

6 總結

Redis Stream是Redis在訊息佇列和流式資料處理領域的一個重要補充,它提供了簡單但功能強大的資料流處理能力,為開發者提供了更多的選擇和靈活性。相對List,Stream的優勢如下:

  • 支援訊息確認機制(ACK應答確認)
  • 支援訊息回溯,方便排查問題和做訊息分析
  • 存在消費組(Consumer Group)的概念,可以進行分組消費和批次消費,可以負載多個消費例項

相關文章