上一篇說 Redis 是瑞士刀,關鍵不在它快,在它懂資料結構。這篇就把刀片一片片打開。

先講一個很常見的用法:team 裡有 Redis,但所有人只會兩招——GETSET。要存什麼就 JSON.stringify 整包塞進去,要用就整包拉出來 parse。這樣「能動」,但你會默默付出一堆 code:

  • 訂單只想改個狀態欄位?整包讀出來、parse、改、stringify、寫回去——還要祈禱沒有另一台機器同時在做一樣的事把你蓋掉。
  • 要排行榜?把全部資料撈出來,自己 sort()
  • 要判斷「這筆處理過沒」?自己維護一個清單,自己寫迴圈比對。

這些 code 不是必要的複雜度,是用錯結構的罰款。Redis 每一個都有專門的結構,而且因為操作在伺服器端原子執行,連併發問題都順便幫你解了。


挑結構前,只問一個問題

不是「我的資料長什麼樣」,而是——「我最常對這份資料做什麼操作?」

同樣是「一個商品」,如果你每次都整包讀寫,string 塞 JSON 沒問題;如果你常常只改庫存這一個欄位,那就該用 hash。資料的形狀是次要的,操作的形狀才決定結構。這個問題會貫穿下面每一段。


用一間電商,把六個核心結構走一遍

想像一間網路商店。它需要的每一種資料,剛好對應一個結構。

String——不只是字串,是計數器

商品頁瀏覽數、剩餘庫存,這種「一個會變動的數字」用 string 存,然後靠 INCR / DECR 原子加減。重點在原子兩個字:一百個人同時下單,DECR 一個一個排隊執行,數字不會算錯。

反過來,如果你自己「GET 出來、程式裡 +1、SET 回去」——我在自己的練習 repo 裡真的把這個 race 跑給自己看過:並發一百筆「讀了加一寫回」,最後計數器停在 1。九十九次更新互相蓋掉,而且全程沒有任何錯誤訊息。這就是為什麼「讀-改-寫」要交給伺服器端的原子指令做。

Hash——一筆訂單,欄位分開放

一筆訂單有金額、狀態、email、重試次數。塞 JSON string 的痛前面講過了;hash 讓你把它存成「一個 key 底下的多個欄位」:改狀態就 HSET status paid,累計重試就 HINCRBY attempts 1,各改各的欄位,互不干擾、天生原子。

「先整包讀出來再整包寫回」的樂觀鎖問題,很多時候光是改用 hash 就直接消失了。

List——待出貨佇列

新訂單從一頭 LPUSH 排進去,出貨程式從另一頭 RPOP 拿出來做,先進先出。它底層是鏈結串列,所以兩端進出是 O(1),但也因此別把它當陣列隨機存取——取中間第 N 個要從頭數過去。

另一個好用的招:「最近瀏覽的 5 個商品」。每看一個就 LPUSH,然後 LTRIM 0 4 把第五名以後砍掉——一個永遠只留最新 N 筆的清單,兩行搞定。

Set——去重是它的天性

商品標籤、還有一個更值錢的用法:冪等。付款通知這種東西可能重送,處理前先 SADD processed:orders 訂單編號——回傳 1 代表第一次見到,去出貨;回傳 0 代表處理過了,跳過。一個指令的回傳值就是你的去重判斷,不用先查再寫(先查再寫又是一個 race)。

集合運算也是 list 給不了的:SINTER 交集一下「3C 標籤」和「特價標籤」,就是「既是 3C 又在特價」的商品清單。

Sorted Set——排行榜本人,還有排程

每個成員帶一個分數,Redis 隨時保持排序。銷售排行榜:賣一件就 ZINCRBY 1 商品名,名次自動重排,前十名就是 ZREVRANGE 0 9——你在應用端一行 sort 都不用寫。

分數放「時間戳」就變成另一個東西:限時搶購排程。一直問「分數 ≤ 現在的有哪些」(ZRANGEBYSCORE),撈出來的就是「到點該上架的」。市面上 delayed job queue 的底層,很多就是這一招。

Stream——訂單的事件流水帳

訂單「下單 → 付款 → 出貨」每個狀態變化 XADD 記一筆,每筆有遞增 id。它跟 list 最根本的差別:list 拿走就沒了,stream 讀過還在——可以回放歷史、可以多個讀者各記各的進度。要做事件溯源、稽核軌跡、或多個服務消費同一串事件,是 stream 的場子。


三個特技結構:省記憶體省到誇張

Bitmap——一個 bit 記一個是非題。會員簽到月曆(第幾天有沒有簽)、每日活躍用戶(哪個 user 今天來過)。一百萬個使用者的「今天有沒有來」,一百萬個 bit 除以 8,大約 125KBBITCOUNT 數一下就是 DAU,兩天的 bitmap BITOP AND 起來就是次日留存。

HyperLogLog——算「不重複有幾個」的近似計數。商品的不重複瀏覽人數(UV),用 set 存真實 user id 會隨人數線性長大;HLL 用機率演算法,不管幾百萬人都固定約 12KB,代價是誤差約 0.81%、而且只能問數量、不能列名單。UV 這種「差一點沒關係、但量很大」的統計,它是首選。

Geo——經緯度找「附近的東西」。門市 GEOADD 進去,GEOSEARCH 給座標跟半徑,回「三公里內的門市、由近到遠、各多遠」。有趣的是它底層根本就是 sorted set(經緯度編碼成分數),所以你會的 zset 操作在它身上都通。


選型速查

你最常做的操作用什麼
整包讀寫一個值 / 原子計數String(INCR
只改一個物件的某幾個欄位Hash
排隊、先進先出、最新 N 筆List
去重、存在判斷、交集聯集Set
隨時要排序、取 Top N、按分數範圍撈Sorted Set
事件記錄、可回放、多讀者各自進度Stream
大量是非題(簽到、活躍)Bitmap
不重複計數,量大、可近似HyperLogLog
經緯度找附近Geo

三個一定會踩的雷

Redis 的值底層全是字串。 存數字 100 進去,拿回來是 "100",要算數自己轉。直接 SET 一個物件?它會被硬轉成 "[object Object]",整包資料就毀了——物件要自己 JSON.stringify。我自己沒踩過這雷,但不是運氣:東西進 Redis 前先檢查、先自己序列化,是我一直以來的習慣——這個雷屬於「知道了就永遠不會踩」的類型,現在你也知道了。

WRONGTYPE 錯誤。 一個 key 已經是 list,你用 string 的 GET 去碰它,Redis 直接丟錯誤給你。key 的命名規劃好、什麼 key 放什麼結構想清楚,這個錯基本不會出現。

結構有它的「天性」,逆著用就慢。 list 取中間是 O(N)、大 set 整包撈(SMEMBERS)會卡住單執行緒的 Redis。每個結構順著它天生擅長的操作用,這也回到開頭那個問題——先想操作,再挑結構。


用對結構,你省下的不只是記憶體和延遲,是一整層自己手寫的資料邏輯:排序、去重、併發控制,Redis 都在伺服器端原子地幫你做完了。下次伸手要打 JSON.stringify 之前,先停半秒問一句:我最常對這份資料做什麼操作?

(這些結構怎麼跟 DB 搭配、快取該怎麼失效,是接下來幾篇的事。)

接下來往哪走