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