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