2023年2月21日
Redis 不只是快取:五種資料結構、各自的使用情境與上線前要知道的事
Redis 是什麼
Redis 是一個把資料放在記憶體裡的 Key-Value 資料庫。它快的原因有三個:
- 資料在記憶體:讀寫不用碰磁碟,單一指令通常在微秒等級完成
- 單執行緒執行指令:所有指令依序一個一個執行,沒有鎖的競爭。Redis 6.0 之後網路 I/O 可以多執行緒處理,但執行指令的仍然是單一執行緒
- 資料結構內建在伺服器端:要對一個集合加一個元素,不用「讀出整個集合 → 改 → 寫回」,直接下一個指令就好
第二點帶來一個非常重要的特性:每一個指令都是原子的。INCR 一個計數器,就算一千個請求同時打進來,也不會有兩個請求讀到同一個值、互相覆蓋。這是很多應用情境的基礎。
但它也代表另一件事:一個很慢的指令,會卡住所有其他的指令。後面的注意事項大多都和這點有關。
五種主要的資料結構
String:最基本的值
一個 Key 對應一個值,值可以是字串、數字或序列化後的 JSON,最大 512MB(但實務上不該放這麼大)。
| 指令 | 用途 |
|---|---|
SET key value EX 300 | 寫入並設定 300 秒後過期 |
GET key | 讀取 |
INCR key/INCRBY key 5 | 原子地加一、加五 |
SET key value NX | 只有 Key 不存在時才寫入 |
情境:
- 物件瀏覽次數:每次瀏覽
INCR house:1024:views,不用每次都更新資料庫,定時再把數字寫回去 - 快取:把查詢結果序列化後
SET,設定過期時間 - 限流:
INCR rate:{ip}:{分鐘},第一次時設定 60 秒過期,數字超過上限就拒絕 - 防止重複送出:
SET submit:{訂單號} 1 NX EX 10,回傳失敗就代表 10 秒內已經送過了
Hash:一個物件的多個欄位
一個 Key 底下有多個「欄位 → 值」,很像一個小型的物件或字典。
| 指令 | 用途 |
|---|---|
HSET key field value | 設定一個欄位 |
HGET key field | 讀一個欄位 |
HINCRBY key field 1 | 對一個欄位原子地加減 |
HGETALL key | 讀出全部欄位 |
情境:
- 購物車:
cart:{使用者}底下,欄位是商品 ID、值是數量。加一件商品就是HINCRBY cart:u77 sku-1 1,不用把整台購物車讀出來改 - 物件的部分欄位常常變動:例如物件的瀏覽數、收藏數、狀態各自是一個欄位,各自更新,不必每次都重新序列化整個物件
和「把整個物件序列化成 JSON 存進 String」相比,Hash 的優勢是可以只讀、只改其中一個欄位;缺點是巢狀結構不好表達,而且不能對單一欄位設定過期時間。
List:有順序的串列
可以從頭或尾加入、取出的串列。
| 指令 | 用途 |
|---|---|
LPUSH key value | 從左邊(頭)加入 |
LTRIM key 0 9 | 只保留第 0~9 筆 |
LRANGE key 0 9 | 讀取第 0~9 筆 |
BRPOP key timeout | 從右邊取出,沒有資料就等待 |
情境:
- 最近瀏覽的 10 個物件:每次瀏覽
LPUSH recent:u77 1024再LTRIM recent:u77 0 9,串列永遠只有最新的 10 筆 - 簡單的工作佇列:生產者
LPUSH、消費者BRPOP。但取出後處理到一半當掉,這筆工作就沒了,也沒有重試、死信這些機制,重要的工作還是交給真正的訊息佇列
Set:不重複的集合
元素不重複、沒有順序的集合,而且可以做交集、聯集、差集。
| 指令 | 用途 |
|---|---|
SADD key member | 加入(已存在就忽略) |
SISMEMBER key member | 判斷是否存在 |
SINTER key1 key2 | 交集 |
SCARD key | 元素數量 |
情境:
- 收藏清單:
SADD fav:u77 1024,重複按收藏也不會出現兩筆;SISMEMBER判斷這個物件要不要顯示「已收藏」 - 共同關注:
SINTER fav:u77 fav:u88,兩個人都收藏的物件 - 標籤:
tag:近捷運底下放所有符合的物件 ID,多個條件就取交集
Sorted Set:依分數排序的集合
每個元素多了一個分數(Score),Redis 會一直依分數維持排序。這是 Redis 最有特色的結構。
| 指令 | 用途 |
|---|---|
ZINCRBY key 1 member | 把某個元素的分數加一 |
ZREVRANGE key 0 9 WITHSCORES | 分數由高到低取前 10 名 |
ZRANK/ZREVRANK key member | 某個元素的名次 |
ZRANGEBYSCORE key min max | 取分數在某個範圍內的元素 |
情境:
- 熱門排行榜:每次瀏覽
ZINCRBY hot:houses:2023-02-21 1 1024,首頁直接ZREVRANGE取前 10 名,不用每次對資料庫做GROUP BY加ORDER BY - 延遲任務:分數放「要執行的時間戳」,排程每秒
ZRANGEBYSCORE key 0 {現在}取出到期的任務 - 滑動視窗限流:分數放請求的時間戳,每次先刪掉一分鐘以前的,再數剩下幾筆
其他結構
- Bitmap:用一個 bit 代表一個狀態,例如每日簽到,一個使用者一年只要 365 個 bit
- HyperLogLog:用約 12KB 估算「不重複的數量」,例如每日不重複訪客數,有約 0.81% 的誤差
- Geo:存經緯度,查詢某個點方圓幾公里內的成員
- Stream:5.0 加入的訊息串流,支援 Consumer Group 與 Ack,比 List 更適合做訊息佇列
使用情境:房仲網站的物件頁
一個物件頁面打開時,背後可以這樣用 Redis:
var db = redis.GetDatabase();
var houseId = 1024;
var userId = "u77";
var today = DateTime.Today.ToString("yyyy-MM-dd");
// 1. 瀏覽次數 +1(String)
await db.StringIncrementAsync($"house:{houseId}:views");
// 2. 今日熱門排行 +1,並讓排行榜兩天後自動過期(Sorted Set)
var hotKey = $"hot:houses:{today}";
await db.SortedSetIncrementAsync(hotKey, houseId, 1);
await db.KeyExpireAsync(hotKey, TimeSpan.FromDays(2));
// 3. 加入最近瀏覽,只保留 10 筆(List)
var recentKey = $"recent:{userId}";
await db.ListLeftPushAsync(recentKey, houseId);
await db.ListTrimAsync(recentKey, 0, 9);
// 4. 這個物件是否已收藏(Set)
var isFavorite = await db.SetContainsAsync($"fav:{userId}", houseId);
// 首頁:今日熱門前 10 名
var top10 = await db.SortedSetRangeByRankWithScoresAsync(hotKey, 0, 9, Order.Descending);
上面 1~4 是四次網路往返。對延遲敏感時可以用 IBatch 或 CreateTransaction() 把多個指令一次送出;需要「多個指令中間不被插隊」時,用 Lua Script 讓它們在 Redis 內一次執行完。
過期、記憶體與持久化
過期時間
用 EXPIRE 或 SET ... EX 設定的 Key,到期後就會被刪除。Redis 用兩種方式清理:存取時發現過期就刪(惰性刪除),加上背景定期抽樣檢查。所以過期的 Key 不一定馬上從記憶體消失,但讀取時一定讀不到。
記憶體滿了怎麼辦
maxmemory 設定 Redis 最多用多少記憶體,用滿之後依 maxmemory-policy 處理:
| 策略 | 行為 | 適合 |
|---|---|---|
noeviction(預設) | 不刪任何東西,寫入直接報錯 | 資料不能被刪的情境 |
allkeys-lru | 從所有 Key 中刪除最久沒用的 | 純快取 |
volatile-lru | 只從有設過期時間的 Key 中刪最久沒用的 | 快取與重要資料混放 |
allkeys-lfu/volatile-lfu | 依使用頻率刪除 | 有明顯熱點的快取 |
純粹當快取用的 Redis,記得改成 allkeys-lru;用預設的 noeviction,記憶體滿的那一刻,所有寫入都會失敗。
持久化
Redis 重啟時,記憶體的資料要從磁碟載回來,有兩種方式:
- RDB:定期把整個記憶體快照存成一個檔案。檔案小、重啟快,但兩次快照之間的資料會遺失
- AOF:把每個寫入指令記錄下來,
appendfsync everysec時最多遺失 1 秒的資料,檔案比較大,需要定期重寫壓縮
兩者可以同時開啟。如果 Redis 只是快取,掉了可以從資料庫重建,RDB 就夠了;如果裡面有不能遺失的資料(例如計數器還沒寫回資料庫),就要開 AOF。
上線前要知道的事
- 不要在正式環境下
KEYS *:它會掃過所有的 Key,幾百萬個 Key 時要好幾秒,這段時間 Redis 完全無法處理其他請求,所有服務一起逾時。要找 Key 用SCAN,它每次只掃一小部分 - 小心 Big Key:一個 Hash 裡有幾百萬個欄位、一個 List 有幾十萬筆,
HGETALL、LRANGE 0 -1、SMEMBERS這種讀取全部的指令會很慢,刪除它也會卡住(改用UNLINK在背景刪除)。資料量大的集合要拆成多個 Key - 小心 Hot Key:所有請求都打同一個 Key,例如首頁的熱門排行榜,在 Cluster 模式下會集中在同一個節點上。可以在應用程式加一層短時間的本機快取
- Key 要有命名規則:用
:分層,例如{服務}:{實體}:{ID}:{用途},在監控工具裡才找得到、才知道是誰放的 - 快取的 Key 一定要設過期時間:沒有過期時間的 Key 只會越來越多,直到記憶體用滿
- 不要把 Redis 當成唯一的資料來源:即使開了 AOF,Redis 也不是設計來取代關聯式資料庫的。重要的資料最終還是要落地到資料庫
- 連線要共用:StackExchange.Redis 的
ConnectionMultiplexer設計上就是整個應用程式共用一個,註冊成 Singleton,不要每次請求都建立新連線
一句話總結
Redis 是記憶體中的資料結構伺服器:String 做計數與快取、Hash 存物件欄位、List 做最近 N 筆、Set 做不重複與交集、Sorted Set 做排行與延遲任務,每個指令都是原子的;但也因為單執行緒,KEYS、Big Key 這種慢指令會拖垮所有請求,上線前要設定好過期時間、淘汰策略與持久化方式。