← 回文章列表

2023年2月21日

Redis 不只是快取:五種資料結構、各自的使用情境與上線前要知道的事

Redis快取資料結構系統設計

Redis 是什麼

Redis 是一個把資料放在記憶體裡的 Key-Value 資料庫。它快的原因有三個:

  • 資料在記憶體:讀寫不用碰磁碟,單一指令通常在微秒等級完成
  • 單執行緒執行指令:所有指令依序一個一個執行,沒有鎖的競爭。Redis 6.0 之後網路 I/O 可以多執行緒處理,但執行指令的仍然是單一執行緒
  • 資料結構內建在伺服器端:要對一個集合加一個元素,不用「讀出整個集合 → 改 → 寫回」,直接下一個指令就好

第二點帶來一個非常重要的特性:每一個指令都是原子的。INCR 一個計數器,就算一千個請求同時打進來,也不會有兩個請求讀到同一個值、互相覆蓋。這是很多應用情境的基礎。

但它也代表另一件事:一個很慢的指令,會卡住所有其他的指令。後面的注意事項大多都和這點有關。

五種主要的資料結構

Redis 五種資料結構示意圖:String 是單一值,例如 house:1024:views 為 3582,用 INCR 計數;Hash 是欄位與值的集合,例如 cart:u77 內有物件與數量;List 是有序串列,例如 recent:u77 用 LPUSH 加 LTRIM 保留最近 10 筆瀏覽;Set 是不重複集合,例如 fav:u77 收藏的物件,可用 SINTER 取交集;Sorted Set 是依分數排序的集合,例如 hot:houses:2023-02-21 以瀏覽數為分數,ZREVRANGE 取前 10 名做排行榜

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:

C#
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 這種慢指令會拖垮所有請求,上線前要設定好過期時間、淘汰策略與持久化方式。