使用 Redis 實現訊息佇列
Redis 中也是可以實現訊息佇列
不過談到訊息佇列,我們會經常遇到下面的幾個問題
1、訊息如何防止丟失;
2、訊息的重複傳送如何處理;
3、訊息的順序性問題;
關於 mq 中如何處理這幾個問題,可參看RabbitMQ,RocketMQ,Kafka 事務性,訊息丟失,訊息順序性和訊息重複傳送的處理策略
基於List的訊息佇列
對於 List
使用 LPUSH 寫入資料,使用 RPOP 讀出資料
127.0.0.1:6379> LPUSH test "ceshi-1"
(integer) 1
127.0.0.1:6379> RPOP test
"ceshi-1"
使用 RPOP 客戶端就需要一直輪詢,來監測是否有值可以讀出,可以使用 BRPOP 可以進行阻塞式讀取,客戶端在沒有讀到佇列資料時,自動阻塞,直到有新的資料寫入佇列,再開始讀取新資料。
127.0.0.1:6379> BRPOP test 10
後面的 10 是監聽的時間,單位是秒,10秒沒資料,就退出。
如果客戶端從佇列中拿到一條訊息時,但是還沒消費,客戶端當機了,這條訊息就對應丟失了, Redis 中為了避免這種情況的出現,提供了 BRPOPLPUSH 命令,BRPOPLPUSH 會在消費一條訊息的時候,同時把訊息插入到另一個 List,這樣如果消費者程式讀了訊息但沒能正常處理,等它重啟後,就可以從備份 List 中重新讀取訊息並進行處理了。
127.0.0.1:6379> LPUSH test "ceshi-1"
(integer) 1
127.0.0.1:6379> LPUSH test "ceshi-2"
(integer) 2
127.0.0.1:6379> BRPOPLPUSH test a-test 100
"ceshi-1"
127.0.0.1:6379> BRPOPLPUSH test a-test 100
"ceshi-2"
127.0.0.1:6379> BRPOPLPUSH test a-test 100
127.0.0.1:6379> RPOP a-test
"ceshi-1"
127.0.0.1:6379> RPOP a-test
"ceshi-2"
不過 List 型別並不支援消費組的實現,Redis 從 5.0 版本開始提供的 Streams 資料型別,來支援訊息佇列的場景。
分析下原始碼實現
在版本3.2之前,Redis中的列表是 ziplist 和 linkedlist 實現的,針對 ziplist 存在的問題, 在3.2之後,引入了 quicklist 來對 ziplist 進行優化。
對於 ziplist 來講:
1、儲存過大的元素,否則容易導致記憶體重新分配,甚至可能引發連鎖更新的問題。
2、儲存過多的元素,否則訪問效能會降低。
quicklist 使多個資料項,不再用一個 ziplist 來存,而是分拆到多個 ziplist 中,每個 ziplist 用指標串起來,這樣修改其中一個資料項,即便發生級聯更新,也只會影響這一個 ziplist,其它 ziplist 不受影響。
下面看下 list 的實現
程式碼連結https://github.com/redis/redis/blob/6.2/src/t_list.c
void listTypePush(robj *subject, robj *value, int where) {
if (subject->encoding == OBJ_ENCODING_QUICKLIST) {
int pos = (where == LIST_HEAD) ? QUICKLIST_HEAD : QUICKLIST_TAIL;
if (value->encoding == OBJ_ENCODING_INT) {
char buf[32];
ll2string(buf, 32, (long)value->ptr);
quicklistPush(subject->ptr, buf, strlen(buf), pos);
} else {
quicklistPush(subject->ptr, value->ptr, sdslen(value->ptr), pos);
}
} else {
serverPanic("Unknown list encoding");
}
}
/* Wrapper to allow argument-based switching between HEAD/TAIL pop */
void quicklistPush(quicklist *quicklist, void *value, const size_t sz,
int where) {
if (where == QUICKLIST_HEAD) {
quicklistPushHead(quicklist, value, sz);
} else if (where == QUICKLIST_TAIL) {
quicklistPushTail(quicklist, value, sz);
}
}
可以看下上面主要用到的是 quicklist
這裡再來分析下 quicklist 的資料結構
typedef struct quicklist {
// quicklist的連結串列頭
quicklistNode *head;
// quicklist的連結串列尾
quicklistNode *tail;
// 所有ziplist中的總元素個數
unsigned long count; /* total count of all entries in all ziplists */
// quicklistNodes的個數
unsigned long len; /* number of quicklistNodes */
int fill : QL_FILL_BITS; /* fill factor for individual nodes */
unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
typedef struct quicklistNode {
// 前一個quicklistNode
struct quicklistNode *prev;
// 後一個quicklistNode
struct quicklistNode *next;
// quicklistNode指向的ziplist
unsigned char *zl;
// ziplist的位元組大小
unsigned int sz; /* ziplist size in bytes */
// ziplist中的元素個數
unsigned int count : 16; /* count of items in ziplist */
// 編碼格式,原生位元組陣列或壓縮儲存
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
// 儲存方式
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
// 資料是否被壓縮
unsigned int recompress : 1; /* was this node previous compressed? */
// 資料能否被壓縮
unsigned int attempted_compress : 1; /* node can't compress; too small */
// 預留的bit位
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
quicklist 作為一個連結串列結構,在它的資料結構中,是定義了整個 quicklist 的頭、尾指標,這樣一來,我們就可以通過 quicklist 的資料結構,來快速定位到 quicklist 的連結串列頭和連結串列尾。
來看下 quicklist 是如何插入的
/* Add new entry to head node of quicklist.
*
* Returns 0 if used existing head.
* Returns 1 if new head created. */
int quicklistPushHead(quicklist *quicklist, void *value, size_t sz) {
quicklistNode *orig_head = quicklist->head;
assert(sz < UINT32_MAX); /* TODO: add support for quicklist nodes that are sds encoded (not zipped) */
if (likely(
// 檢測插入位置的 ziplist 是否能容納該元素
_quicklistNodeAllowInsert(quicklist->head, quicklist->fill, sz))) {
quicklist->head->zl =
ziplistPush(quicklist->head->zl, value, sz, ZIPLIST_HEAD);
quicklistNodeUpdateSz(quicklist->head);
} else {
// 容納不了,就重新建立一個 quicklistNode
quicklistNode *node = quicklistCreateNode();
node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_HEAD);
quicklistNodeUpdateSz(node);
_quicklistInsertNodeBefore(quicklist, quicklist->head, node);
}
quicklist->count++;
quicklist->head->count++;
return (orig_head != quicklist->head);
}
quicklist 採用的是連結串列結構,所以當插入一個新元素的時候,首先判斷下 quicklist 插入位置的 ziplist 是否能容納該元素,即單個 ziplist 是否不超過 8KB,或是單個 ziplist 裡的元素個數是否滿足要求。
如果可以插入就當前的節點進行插入,否則就新建一個 quicklistNode 來儲存先插入的節點。
quicklist 通過控制每個 quicklistNode 中,ziplist 的大小或是元素個數,就有效減少了在 ziplist 中新增或修改元素後,發生連鎖更新的情況,從而提供了更好的訪問效能。
基於 Streams 的訊息佇列
Streams 是 Redis 專門為訊息佇列設計的資料型別。
-
是可持久化的,可以保證資料不丟失。
-
支援訊息的多播、分組消費。
-
支援訊息的有序性。
來看下幾個主要的命令
XADD:插入訊息,保證有序,可以自動生成全域性唯一ID;
XREAD:用於讀取訊息,可以按ID讀取資料;
XREADGROUP:按消費組形式讀取訊息;
XPENDING和XACK:XPENDING命令可以用來查詢每個消費組內所有消費者已讀取但尚未確認的訊息,而XACK命令用於向訊息佇列確認訊息處理已完成。
下面看幾個常用的命令
XADD
使用 XADD 向佇列新增訊息,如果指定的佇列不存在,則建立一個佇列,XADD 語法格式:
$ XADD key ID field value [field value ...]
-
key:佇列名稱,如果不存在就建立
-
ID:訊息 id,我們使用 * 表示由 redis 生成,可以自定義,但是要自己保證遞增性
-
field value:記錄
$ XADD teststream * name xiaohong surname xiaobai
"1646650328883-0"
可以看到 1646650328883-0
就是自動生成的全域性唯一訊息ID
XREAD
使用 XREAD 以阻塞或非阻塞方式獲取訊息列表
$ XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] id [id ...]
-
count:數量
-
milliseconds:可選,阻塞毫秒數,沒有設定就是非阻塞模式
-
key:佇列名
-
id:訊息 ID
$ XREAD BLOCK 100 STREAMS teststream 0
1) 1) "teststream"
2) 1) 1) "1646650328883-0"
2) 1) "name"
2) "xiaohong"
3) "surname"
4) "xiaobai"
BLOCK 就是阻塞的毫秒數
XGROUP
使用 XGROUP CREATE 建立消費者組
$ XGROUP [CREATE key groupname id-or-$] [SETID key groupname id-or-$] [DESTROY key groupname] [DELCONSUMER key groupname consumername]
-
key:佇列名稱,如果不存在就建立
-
groupname:組名
-
$:表示從尾部開始消費,只接受新訊息,當前 Stream 訊息會全部忽略
從頭開始消費
$ XGROUP CREATE teststream test-consumer-group-name 0-0
從尾部開始消費
$ XGROUP CREATE teststream test-consumer-group-name $
XREADGROUP GROUP
使用 XREADGROUP GROUP
讀取消費組中的訊息
$ XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
-
group:消費組名
-
consumer:消費者名
-
count:讀取數量
-
milliseconds:阻塞毫秒數
-
key:佇列名
-
ID:訊息 ID
$ XADD teststream * name xiaohong surname xiaobai
"1646653392799-0"
$ XREADGROUP GROUP test-consumer-group-name test-consumer-name COUNT 1 STREAMS teststream >
1) 1) "teststream"
2) 1) 1) "1646653392799-0"
2) 1) "name"
2) "xiaohong"
3) "surname"
4) "xiaobai"
訊息佇列中的訊息一旦被消費組裡的一個消費者讀取了,就不能再被該消費組內的其他消費者讀取了。
如果沒有通過 XACK 命令告知訊息已經成功消費了,該訊息會一直存在,可以通過 XPENDING 命令檢視已讀取、但尚未確認處理完成的訊息。
$ XPENDING teststream test-consumer-group-name
1) (integer) 3
2) "1646653325535-0"
3) "1646653392799-0"
4) 1) 1) "test-consumer-name"
2) "3"
分析下原始碼實現
stream 的結構
typedef struct stream {
// 這是使用字首樹儲存資料
rax *rax; /* The radix tree holding the stream. */
uint64_t length; /* Number of elements inside this stream. */
// 當前stream的最後一個id
streamID last_id; /* Zero if there are yet no items. */
// 儲存當前的消費者組資訊
rax *cgroups; /* Consumer groups dictionary: name -> streamCG */
} stream;
typedef struct streamID {
// 訊息建立時的時間
uint64_t ms; /* Unix time in milliseconds. */
// 訊息的序號
uint64_t seq; /* Sequence number. */
} streamID;
可以看到 stream 的實現用到了 rax 樹
再來看 rax 的實現
typedef struct rax {
// radix tree 的頭節點
raxNode *head;
// radix tree 所儲存的元素總數,每插入一個 ID,計數加 1
uint64_t numele;
// radix tree 的節點總數
uint64_t numnodes;
} rax;
typedef struct raxNode {
// 表示從 Radix Tree 的根節點到當前節點路徑上的字元組成的字串,是否表示了一個完整的 key
uint32_t iskey:1; /* Does this node contain a key? */
// 表明當前key對應的value是否為空
uint32_t isnull:1; /* Associated value is NULL (don't store it). */
// 表示當前節點是非壓縮節點,還是壓縮節點。
uint32_t iscompr:1; /* Node is compressed. */
// 壓縮節點壓縮的字串長度或者非壓縮節點的子節點個數
uint32_t size:29; /* Number of children, or compressed string len. */
// 包含填充欄位,同時儲存了當前節點包含的字串以及子節點的指標,key對應的value指標。
unsigned char data[];
} raxNode;
下面的字首樹就是儲存了(radix、race、read、real 和 redis)這幾個 key 的佈局
Radix Tree 非葉子節點,要不然是壓縮節點,只指向單個子節點,要不然是非壓縮節點,指向多個子節點,但每個子節點只表示一個字元。所以,非葉子節點無法同時指向表示單個字元的子節點和表示合併字串的子節點。
data 是用來儲存實際資料的。不過,這裡儲存的資料會根據當前節點的型別而有所不同:
-
對於非壓縮節點來說,data 陣列包括子節點對應的字元、指向子節點的指標,以及節點表示 key 時對應的 value 指標;
-
對於壓縮節點來說,data 陣列包括子節點對應的合併字串、指向子節點的指標,以及節點為 key 時的 value 指標。
Stream 儲存的訊息資料,按照 key-value 形式來看的話,訊息 ID 就相當於 key,而訊息內容相當於是 value。也就是說,Stream 會使用 Radix Tree 來儲存訊息 ID,然後將訊息內容儲存在 listpack 中,並作為訊息 ID 的 value,用 raxNode 的 value 指標指向對應的 listpack。
這個 listpack 有點臉盲,listpack 是在 redis5.0 引入了一種新的資料結構,listpack 相比於 ziplist 有哪些優點呢
壓縮列表的細節可參見壓縮列表
對於壓縮列表來講:儲存過大的元素,否則容易導致記憶體重新分配,甚至可能引發連鎖更新的問題。
在 listpack 中,因為每個列表項只記錄自己的長度,而不會像 ziplist 中的列表項那樣,會記錄前一項的長度。所以,當我們在 listpack 中新增或修改元素時,實際上只會涉及每個列表項自己的操作,而不會影響後續列表項的長度變化,這就避免了連鎖更新。
streamCG 消費者組
typedef struct streamCG {
// 當前我這個stream的最大id
streamID last_id;
// 還沒有收到ACK的訊息列表
rax *pel;
// 消費組中的所有消費者,消費者名稱為鍵,streamConsumer 為值
rax *consumers;
} streamCG;
-
last_id: 每個組的消費者共享一個last_id代表這個組消費到了什麼位置,每次投遞後會更新這個group;
-
pel: 已經傳送給客戶端,但是還沒有收到XACK的訊息都儲存在pel樹裡面;
-
consumers: 儲存當前這個消費者組中的消費者。
streamConsumer 消費者結構
typedef struct streamConsumer {
// 為該消費者最後一次活躍的時間
mstime_t seen_time;
// 消費者名稱,為sds結構
sds name;
// 待ACK的訊息列表,和 streamCG 中指向的是同一個
rax *pel;
} streamConsumer;
訊息佇列中的訊息一旦被消費組裡的一個消費者讀取了,就不能再被該消費組內的其他消費者讀取了。
消費者組中會維護 last_id,代表消費者組消費的位置,同時未經 ACK 的訊息會存在於 pel 中。
釋出訂閱
Redis 釋出訂閱(pub/sub)是一種訊息通訊模式:傳送者(pub)傳送訊息,訂閱者(sub)接收訊息。
來看下幾個主要的命令
PSUBSCRIBE pattern [pattern ...]
訂閱一個或多個符合給定模式的頻道。
PUBSUB subcommand [argument [argument ...]]
檢視訂閱與釋出系統狀態。
PUBLISH channel message
將資訊傳送到指定的頻道。
PUNSUBSCRIBE [pattern [pattern ...]]
退訂所有給定模式的頻道。
SUBSCRIBE channel [channel ...]
訂閱給定的一個或多個頻道的資訊。
UNSUBSCRIBE [channel [channel ...]]
指退訂給定的頻道。
普通的訂閱
訂閱 test
$ SUBSCRIBE test
向 test 釋出資訊
$ PUBLISH test 1
基於模式(pattern)的釋出/訂閱
相當於是模糊匹配,訂閱的時候通過加入萬用字元中來實現,?表示1個佔位符,表示任意個佔位符(包括0),?表示1個以上佔位符。
訂閱
$ psubscribe p-test*
傳送資訊
$ PUBLISH p-testa ceshi-1
看下原始碼實現
Redis 將所有頻道和模式的訂閱關係分別儲存在 pubsub_channels 和 pubsub_patterns 中。
程式碼路徑https://github.com/redis/redis/blob/6.0/src/server.h
struct redisServer {
// 儲存訂閱頻道的資訊
dict *pubsub_channels; /* Map channels to list of subscribed clients */
// 儲存著所有和模式相關的資訊
dict *pubsub_patterns; /* A dict of pubsub_patterns */
// ...
}
pubsub_channels 屬性是一個字典,字典的鍵為正在被訂閱的頻道,而字典的值則是一個連結串列, 連結串列中儲存了所有訂閱這個頻道的客戶端。
使用 PSUBSCRIBE 命令訂閱頻道時,就會將訂閱的頻道和客戶端在 pubsub_channels 中進行關聯
程式碼路徑 https://github.com/redis/redis/blob/6.2/src/pubsub.c
// 訂閱一個頻道,成功返回1,已經訂閱返回0
int pubsubSubscribeChannel(client *c, robj *channel) {
dictEntry *de;
list *clients = NULL;
int retval = 0;
/* Add the channel to the client -> channels hash table */
// 將頻道新增到客戶端本地的雜湊表中
// 客戶端自己也有一個訂閱頻道的列表,記錄了此客戶端所訂閱的頻道
if (dictAdd(c->pubsub_channels,channel,NULL) == DICT_OK) {
retval = 1;
incrRefCount(channel);
// 新增到伺服器中的pubsub_channels中
// 判斷下這個 channel 是否已經建立了
de = dictFind(server.pubsub_channels,channel);
if (de == NULL) {
// 沒有建立,先建立 channel,後新增
clients = listCreate();
dictAdd(server.pubsub_channels,channel,clients);
incrRefCount(channel);
} else {
// 已經建立過了
clients = dictGetVal(de);
}
// 在尾部新增客戶端
listAddNodeTail(clients,c);
}
/* Notify the client */
addReplyPubsubSubscribed(c,channel);
}
typedef struct client {
dict *pubsub_channels; /* channels a client is interested in (SUBSCRIBE) */
list *pubsub_patterns; /* patterns a client is interested in (SUBSCRIBE) */
} client;
1、客戶端進行訂閱的時候,自己本身也會維護一個訂閱的 channel 列表;
2、服務端會將訂閱的客戶端新增到自己的 pubsub_channels 中。
再來看下取消訂閱 pubsubUnsubscribeChannel
// 取消 client 訂閱
int pubsubUnsubscribeChannel(client *c, robj *channel, int notify) {
dictEntry *de;
list *clients;
listNode *ln;
int retval = 0;
// 客戶端在本地的雜湊表中刪除channel
incrRefCount(channel); /* channel may be just a pointer to the same object
we have in the hash tables. Protect it... */
if (dictDelete(c->pubsub_channels,channel) == DICT_OK) {
retval = 1;
/* Remove the client from the channel -> clients list hash table */
de = dictFind(server.pubsub_channels,channel);
serverAssertWithInfo(c,NULL,de != NULL);
clients = dictGetVal(de);
ln = listSearchKey(clients,c);
serverAssertWithInfo(c,NULL,ln != NULL);
listDelNode(clients,ln);
if (listLength(clients) == 0) {
/* Free the list and associated hash entry at all if this was
* the latest client, so that it will be possible to abuse
* Redis PUBSUB creating millions of channels. */
dictDelete(server.pubsub_channels,channel);
}
}
/* Notify the client */
if (notify) addReplyPubsubUnsubscribed(c,channel);
decrRefCount(channel); /* it is finally safe to release it */
return retval;
}
取消訂閱的邏輯也比較簡單,先在客戶端本地維護的 channel 列表移除對應的 channel 資訊,然後在服務端中的 pubsub_channels 移除對應的客戶端資訊。
再來看下資訊是如何進行釋出的呢
/* Publish a message */
int pubsubPublishMessage(robj *channel, robj *message) {
int receivers = 0;
dictEntry *de;
dictIterator *di;
listNode *ln;
listIter li;
/* Send to clients listening for that channel */
// 找到Channel所對應的dictEntry
de = dictFind(server.pubsub_channels,channel);
if (de) {
// 獲取此 channel 對應的所有客戶端
list *list = dictGetVal(de);
listNode *ln;
listIter li;
listRewind(list,&li);
// 一個個傳送資訊
while ((ln = listNext(&li)) != NULL) {
client *c = ln->value;
addReplyPubsubMessage(c,channel,message);
receivers++;
}
}
/* Send to clients listening to matching channels */
// 拿到所有的客戶端資訊
di = dictGetIterator(server.pubsub_patterns);
if (di) {
channel = getDecodedObject(channel);
while((de = dictNext(di)) != NULL) {
robj *pattern = dictGetKey(de);
list *clients = dictGetVal(de);
// 這裡進行匹配
// 擁有相同的 pattern 的客戶端會被放入到同一個連結串列中
if (!stringmatchlen((char*)pattern->ptr,
sdslen(pattern->ptr),
(char*)channel->ptr,
sdslen(channel->ptr),0)) continue;
listRewind(clients,&li);
while ((ln = listNext(&li)) != NULL) {
client *c = listNodeValue(ln);
addReplyPubsubPatMessage(c,pattern,channel,message);
receivers++;
}
}
decrRefCount(channel);
dictReleaseIterator(di);
}
return receivers;
}
訊息的釋出,除了會向 pubsub_channels 中的客戶端傳送資訊,也會通過 pubsub_patterns 給匹配的客戶端傳送資訊。
通過 channel 訂閱,通過 channel 找到匹配的客戶端連結串列,然後逐一傳送
通過 pattern 訂閱,拿出所有的 patterns ,然後根據規則,對 傳送的 channel ,進行一一匹配,找到滿足條件的客戶端然後傳送資訊。
再來看下 pubsub_patterns 中的客戶端資料是如何儲存的
/* Subscribe a client to a pattern. Returns 1 if the operation succeeded, or 0 if the client was already subscribed to that pattern. */
int pubsubSubscribePattern(client *c, robj *pattern) {
dictEntry *de;
list *clients;
int retval = 0;
// 如果客戶端沒有訂閱過
if (listSearchKey(c->pubsub_patterns,pattern) == NULL) {
retval = 1;
// 客戶端端本地進行記錄
listAddNodeTail(c->pubsub_patterns,pattern);
incrRefCount(pattern);
/* Add the client to the pattern -> list of clients hash table */
de = dictFind(server.pubsub_patterns,pattern);
if (de == NULL) {
// 沒有建立,先建立
clients = listCreate();
dictAdd(server.pubsub_patterns,pattern,clients);
incrRefCount(pattern);
} else {
clients = dictGetVal(de);
}
listAddNodeTail(clients,c);
}
/* Notify the client */
addReplyPubsubPatSubscribed(c,pattern);
return retval;
}
這裡訂閱 pattern 的流程和訂閱 channel 的流程有點類似,只是這裡儲存的是 pattern。pubsub_patterns 的型別也是 dict。
擁有相同的 pattern 的客戶端會被放入到同一個連結串列中。看 redis 的提交記錄可以發現,原本 pubsub_patterns 的型別是 list,後面調整成了 dict。issues
This commit introduced a dictionary on the server side to efficiently handle the pub sub pattern matching. However, there is another list maintaining the same information which is redundant as well as expensive to operate on. Hence removing it.
如果是一個連結串列,就需要遍歷所有的連結串列,使用 dict ,將有相同 pattern 的客戶端放入同一個連結串列中,這樣匹配前面的 pattern 就好了,不用遍歷所有的客戶端節點。
總結
redis 中訊息佇列的實現,可以使用 list,Streams,pub/sub。
1、list 不支援消費者組;
2、釋出訂閱 (pub/sub) 訊息無法持久化,如果出現網路斷開、Redis 當機等,訊息就會被丟棄,分發訊息,無法記住歷史訊息;
3、5.0 引入了 Streams,專門為訊息佇列設計的資料結構,其中支援了消費者組,支援訊息的有序性,支援訊息的持久化;
參考
【Redis核心技術與實戰】https://time.geekbang.org/column/intro/100056701
【Redis設計與實現】https://book.douban.com/subject/25900156/
【Redis Streams 介紹】http://www.redis.cn/topics/streams-intro.html
【Centos7.6安裝redis-6.0.8版本】https://blog.csdn.net/roc_wl/article/details/108662719
【Stream 資料型別原始碼分析】https://blog.csdn.net/weixin_45505313/article/details/109060761
【訂閱與釋出】https://redisbook.readthedocs.io/en/latest/feature/pubsub.html
【Redis學習筆記】https://github.com/boilingfrog/Go-POINT/tree/master/redis
【使用 Redis 實現訊息佇列】https://boilingfrog.github.io/2022/03/14/redis中實現訊息佇列的幾種方式/