Redis 如何為 List/Set/Hash 的元素設(shè)置單獨(dú)的過(guò)期時(shí)間
大家好,我是小?,一個(gè)漂泊江湖多年的 985 非科班程序員,曾混跡于國(guó)企、互聯(lián)網(wǎng)大廠和創(chuàng)業(yè)公司的后臺(tái)開(kāi)發(fā)攻城獅。
1. 引言
1.1 消費(fèi)隊(duì)列
這天,小?在購(gòu)買(mǎi)火車票時(shí),發(fā)現(xiàn)如果存在一個(gè)未支付的訂單時(shí),就不能再進(jìn)行購(gòu)票了。如果把待支付的訂單放在一個(gè)隊(duì)列里面,那么隊(duì)列的長(zhǎng)度就只能是 1.
正好最近用 Redis 比較多,于是,我突發(fā)奇想,如何用 Redis 原生的數(shù)據(jù)結(jié)構(gòu)實(shí)現(xiàn)一個(gè)簡(jiǎn)易版的延時(shí)消費(fèi)隊(duì)列呢?
業(yè)務(wù)狀態(tài)圖如下:
圖片
并且,需要保證隊(duì)列的長(zhǎng)度是可控的,比如,我們只允許用戶有 3 個(gè)未支付的訂單。
1.2 Redis實(shí)現(xiàn)
Redis,作為一款高性能的緩存和數(shù)據(jù)存儲(chǔ)數(shù)據(jù)庫(kù),一直以來(lái)都是后臺(tái)開(kāi)發(fā)者的得力助手。
如果用 Redis 作為消費(fèi)隊(duì)列,那么我們可以用到的數(shù)據(jù)結(jié)構(gòu)有:List、Hash 和 Set。在上述的業(yè)務(wù)場(chǎng)景中,由于我們只需要關(guān)注 orderId(訂單 ID),因此這三個(gè)數(shù)據(jù)結(jié)構(gòu)都是可用的。
比如,用 hash 來(lái)存儲(chǔ)時(shí),我們可以將 key 設(shè)置為 UnpaidOrder-{userId},每個(gè) field 都是一個(gè)訂單。
圖片
但是,我們現(xiàn)在面臨一個(gè)挑戰(zhàn):每個(gè)訂單的存活時(shí)長(zhǎng)是不同的,分為手動(dòng)消費(fèi)和定期刪除的邏輯。
- 訂單 1 手動(dòng)支付后,需要將 orderId1 從列表中刪除
- 訂單 2 在半小時(shí)內(nèi)還未支付,就自動(dòng)過(guò)期,用戶還可以繼續(xù)提交訂單到未支付狀態(tài)
所以在 List、Set 或者 Hash 結(jié)構(gòu)中,每個(gè) field 都需要設(shè)置單獨(dú)的過(guò)期時(shí)間。
這是一個(gè)常見(jiàn)而又棘手的問(wèn)題,本文將從互聯(lián)網(wǎng)業(yè)務(wù)中常見(jiàn)的解決方案入手,來(lái)深入探討一下 Redis 的底層實(shí)現(xiàn)。
2. 常見(jiàn)方案
在實(shí)際業(yè)務(wù)中,我們經(jīng)常會(huì)遇到這樣的場(chǎng)景:需要統(tǒng)計(jì)某些字段的個(gè)數(shù),并且這些字段的過(guò)期時(shí)間各有先后。
就上述場(chǎng)景而言,我們需要統(tǒng)計(jì)用戶的未支付訂單數(shù),但是每個(gè)訂單數(shù)的過(guò)期時(shí)間是不同的。
在這種情況下,我們需要在業(yè)務(wù)中手動(dòng)刪除過(guò)期的字段,或者讓它們自動(dòng)過(guò)期。
2.1 為單獨(dú)的 field 設(shè)置過(guò)期?
我們知道,Redis 里面暫時(shí)沒(méi)有接口給 List、Set 或者 Hash 的 field 單獨(dú)設(shè)置過(guò)期時(shí)間,只能給整個(gè)列表、集合或者 Hash 設(shè)置過(guò)期時(shí)間。
這樣,當(dāng) List/Set/Hash 過(guò)期時(shí),里面的所有 field 元素就全部過(guò)期了。
但這樣并不滿足需求。
小?嘗試在網(wǎng)上找一些已知方案,其中有一個(gè) Stack Overflow 的問(wèn)題帖子和我面臨的很相似:
圖來(lái)源:StackOverflow,Redis 中如何給 HSET 的孩子key(指 field)設(shè)置過(guò)期時(shí)間?
接著,帖子下面的回答里無(wú)意看到了 Redis 作者的回答:
圖片
中文翻譯如下:
嗨,這是不可能的,要么為該特定字段使用不同的頂級(jí) key,要么與提交的字段一起存儲(chǔ)另一個(gè)具有過(guò)期時(shí)間的字段,然后同時(shí)獲取這兩個(gè)字段,并讓?xiě)?yīng)用程序了解它是否仍然有效(基于當(dāng)前時(shí)間)。
大意就是,不可能,除非你同時(shí)把 field 和過(guò)期時(shí)間都存下來(lái),然后在程序里面判斷它是否過(guò)期。
這真是布袋里失火,很燒包!
2.2. 設(shè)置整體過(guò)期時(shí)間
既然 Redis 創(chuàng)始人都這么說(shuō)了,Redis 是不可能為單獨(dú)的 field 設(shè)置過(guò)期時(shí)間,那我們首先考慮的就是給整個(gè) List/Set/Hash 設(shè)置過(guò)期時(shí)間。
這樣的做法簡(jiǎn)單粗暴,但卻很難滿足每個(gè)字段單獨(dú)設(shè)置過(guò)期時(shí)間的需求。
于是,我思前想后,既然每個(gè)訂單的過(guò)期時(shí)間不一樣,那我們是否可以根據(jù)時(shí)間來(lái)創(chuàng)建不同的集合,將同一時(shí)間過(guò)期的訂單放在同一個(gè)集合里面:
圖片
然后,分別為不同的集合設(shè)置 TTL,當(dāng)訂單過(guò)期未支付時(shí),訂單會(huì)隨著集合的過(guò)期而在同一分鐘內(nèi)被刪除。
但是這樣的問(wèn)題是,每次新增訂單時(shí),都得把過(guò)去 30 分鐘的集合全部遍歷一遍,查詢是否有該用戶的訂單,再判斷用戶的未支付訂單數(shù)有沒(méi)有超量。
并且,以分鐘創(chuàng)建集合,可能存在一個(gè)問(wèn)題:用戶的訂單本來(lái)在 01 秒就過(guò)期了,但是在 59 秒才被刪除。
如果以秒來(lái)創(chuàng)建集合,30 分鐘又需要?jiǎng)?chuàng)建 1800 個(gè)集合,就更難管理了,所以對(duì)集合設(shè)置整體過(guò)期時(shí)間不太可行。
那有沒(méi)有更優(yōu)雅的實(shí)現(xiàn)方式呢?
2.3 zset 結(jié)合 score實(shí)現(xiàn)
當(dāng)然是有的!
Redis 除了常用的 List/Set/Hash 結(jié)構(gòu),它還有一個(gè)專門(mén)用來(lái)排序的數(shù)據(jù)結(jié)構(gòu) zset(即 Sorted Set,排序集合)。
而基于 Redis 的 Zset 結(jié)構(gòu),可以通過(guò) Score 來(lái)表示過(guò)期時(shí)間,我們可以輕松地實(shí)現(xiàn)每個(gè) Field 的單獨(dú)過(guò)期。
圖片
具體實(shí)現(xiàn)為:
- 每當(dāng)新增一個(gè)待支付訂單,就將當(dāng)前時(shí)間的 Unix timestamp 加上過(guò)期時(shí)間 30min 作為 score 設(shè)置到這個(gè)元素上,這樣,sorted set 會(huì)根據(jù)這個(gè)過(guò)期時(shí)間戳對(duì)元素排序存儲(chǔ);
- 當(dāng)訂單被支付后,根據(jù) userId 和 orderId 去刪除 sorted set 里的待支付訂單;
- 同時(shí),在程序里新增一個(gè)定時(shí)任務(wù),每隔一秒去刪除當(dāng)前時(shí)間已過(guò)期的訂單。
2.4 底層實(shí)現(xiàn)
用 Redis 的 zset 一方面可以很方便地實(shí)現(xiàn)了對(duì)每個(gè)字段的單獨(dú)過(guò)期,不再受整個(gè) Key 的過(guò)期時(shí)間限制,提高了靈活性。
另一方面,Redis 的 zset 操作是十分高效的,不會(huì)給系統(tǒng)帶來(lái)顯著的性能壓力。
這得益于 zset 底層的數(shù)據(jù)結(jié)構(gòu),Zset 底層實(shí)現(xiàn)采用了 ZipList(壓縮列表)和 SkipList(跳表)兩種實(shí)現(xiàn)方式,當(dāng)滿足:
- Zset 中保存的元素個(gè)數(shù)小于 128(可通過(guò)修改 zset-max-ziplist-entries 配置來(lái)修改)
- Zset 中保存的所有元素長(zhǎng)度小于 64byte(通過(guò)修改 zset-max-ziplist-values 配置來(lái)修改)
兩個(gè)條件時(shí),Zset 采用 ZipList 實(shí)現(xiàn);否則,用 SkipList 實(shí)現(xiàn)。
ZipList 實(shí)現(xiàn)
圖片
ZipList 是一個(gè)數(shù)組的形式,存儲(chǔ)數(shù)據(jù)時(shí)分為列表頭部分和數(shù)據(jù)部分,列表頭部分有 3 個(gè)元素:
- zlbytes:表示當(dāng)前 list 的存儲(chǔ)元素的總長(zhǎng)度
- zllen:表示當(dāng)前 list 存儲(chǔ)的元素的個(gè)數(shù)
- zltail:表示當(dāng)前 list 的頭結(jié)點(diǎn)的地址,通過(guò) zltail 就是可以實(shí)現(xiàn) list 的遍歷
數(shù)據(jù)部分以鍵值對(duì)的方式依次排列,鍵存儲(chǔ)的是實(shí)際 member,值存儲(chǔ)的是 member 對(duì)應(yīng)的分值(score)。
SkipList 實(shí)現(xiàn)
圖片
SkipList 分為兩部分:
- dict 部分是由字典實(shí)現(xiàn)(其實(shí)就是 HashMap,里面放了成員到 score 的映射);
- zset 部分使用跳躍表實(shí)現(xiàn)(存放了所有的成員,解決了 HashMap 中 key 無(wú)序的問(wèn)題)。
從圖中可以看出,dict 和 zset 都存儲(chǔ)數(shù)據(jù)。
但實(shí)際上 dict 和 zset 最終使用的指針都指向了同一份成員數(shù)據(jù),即數(shù)據(jù)是被兩部分共享的,為了方便表達(dá)將同一份數(shù)據(jù)展示在兩個(gè)地方。
2.5 代碼實(shí)現(xiàn)
當(dāng)我們插入一個(gè)過(guò)期時(shí)間到 zset 時(shí),Redis 會(huì)自動(dòng)幫我們排好序,我們只需要在程序中新增一個(gè)定時(shí)任務(wù),比如:每秒執(zhí)行一次刪除任務(wù),刪除時(shí)間戳從 0 到當(dāng)前時(shí)間戳的 score 值即可。
偽代碼如下:
# 1. 創(chuàng)建新的待支付訂單時(shí),查詢zset個(gè)數(shù)
count = zcard UnpaidOrder-{userId}
# 2. 判斷未支付訂單個(gè)數(shù)
if count >= 3:
return
# 3. 新增訂單
zadd UnpaidOrder-{userId} redis.Z{Score: {timestamp1}, Member: {order1}}
# 4.1 訂單支付后,從 set 中刪除未支付訂單
zrem UnpaidOrder-{userId} order1
# 4.2 過(guò)期時(shí)間到了,從 set 中刪除未支付訂單
zremrange UnpaidOrder-{userId} 0 {current_timestamp}
3. 結(jié)語(yǔ)
通過(guò)合理的數(shù)據(jù)結(jié)構(gòu)選擇和巧妙的應(yīng)用,我們成功地解決了為 List、Set 和 Hash 結(jié)構(gòu)中的字段設(shè)置單獨(dú)過(guò)期時(shí)間的問(wèn)題。
這個(gè)方案在實(shí)際項(xiàng)目中得到了驗(yàn)證,并取得了顯著的效果。對(duì)比其它的延時(shí)隊(duì)列,或者 etcd 的 field 過(guò)期方案,Redis 的實(shí)現(xiàn)相對(duì)而言更為便捷,理解起來(lái)也更為簡(jiǎn)單。
希望這個(gè)方案能夠在你的項(xiàng)目中派上用場(chǎng),提高開(kāi)發(fā)效率,更好地應(yīng)對(duì)實(shí)際需求。如果你有更多關(guān)于 Redis 使用的問(wèn)題,也歡迎在評(píng)論區(qū)交流討論。
愿你在 Redis 的世界里愈發(fā)游刃有余,取得更多技術(shù)的新突破。