Redis 不只是快取:15 個高併發系統最常踩爆的架構陷阱
作者說明:這篇文章不談 Redis 的安裝教學,也不介紹基礎資料結構。我們直接站在 Tech Lead、架構師與面試官的視角,聊聊當流量從 100 QPS 衝到 100,000 QPS 時,那些真正會讓系統崩潰、讓值班工程師半夜接到 PagerDuty 警報的 15 個 Redis 架構陷阱。
導言:從「背誦知識」到「理解邊界」
多數工程師學習 Redis 的順序都是:
String ➔ Hash ➔ List ➔ Set ➔ Sorted Set面試時背誦標準答案:
「Redis 很快,因為它是記憶體資料庫、採用單執行緒(Single-threaded I/O Multiplexing)。」
但當你進入高併發系統設計的面試或實戰時,面試官真正想考你的從來不是指令怎麼用,而是:
- Redis 掛了會怎樣?
- Redis 與 DB 不一致時怎麼辦?
- Redis 的鎖真的安全嗎?
- Redis 裡的資料,到底能不能當作系統真相?
資深工程師與初階工程師最大的差異,不在於記住多少指令,而在於能否精準界定 Redis 的能力邊界與極限。
一、 定位篇:資料真相與快取層
陷阱 1:把 Redis 當作第二個 DB (Source of Truth)
許多團隊最大的災難,源於把 Redis 當作唯一儲存:
- 使用者 Session 與 Profile 全存 Redis
- 訂單狀態、庫存扣減全仰賴 Redis,DB 沒有留底
這會產生極其危險的錯覺:「Redis 裡面有資料,系統就沒問題。」
💡 資深架構師的解法
Redis 本質上是 Cache Layer,不是 Source of Truth。
在進行任何架構設計時,請先捫心自問:
「如果整個 Redis 集群瞬間清空,系統能否透過 DB 逐步重建並維持運作?」
如果答案是「不行」,代表你的資料建模與災難復原(Disaster Recovery)設計已經出現嚴重漏洞。
[使用者請求] ──> [ Nginx / API Gateway ]
│
├──> [ Redis (Cache / 臨時狀態) ] ── (可隨時清空重建)
│
└──> [ PostgreSQL / MySQL (Source of Truth) ] ── (強一致性持久化)
二、 快取三大問題:別只背名詞,要算「維護成本」
面試常問快取穿透、擊穿、雪崩,但 Senior 階層討論的是方案代價與架構複雜度。
陷阱 2:快取穿透盲目上 Bloom Filter
- 情境:大量請求查詢不存在的 Key(如
GET /users/9999999),直接穿透至 DB。 - 初階解答:上 Bloom Filter(布隆過濾器)。
- 資深思考:Bloom Filter 元素怎麼刪除?記憶體膨脹後如何重建?誤判率(False Positive)衝高怎麼辦?
- 💡 實務首選:Null Cache + 短 TTL(例如 NULL 值給 30 秒過期)。極低維護成本,足以擋下 99% 的無效請求或惡意掃描。
陷阱 3:熱點 Key 擊穿(Hot Key Breakdown)直接加鎖
- 情境:超級熱點 Key(如秒殺商品
product:1001)TTL 剛好到期,瞬間數萬請求同步打穿到 DB。 - 初階解答:加分散式鎖(Distributed Lock),只讓一個請求去 DB 載入資料。
- 資深思考:這把鎖會直接變成併發瓶頸,甚至導致請求堆積引發 HTTP Timeout 崩潰。
- 💡 實務首選:
- 邏輯過期(Logical Expire)+ 背景異步更新:資料結構內自訂
expire_at,背景 Worker 發現過期異步刷 DB,前端接受短暫的微秒級舊資料。 - Hot Key 本地化:利用應用層進程內快取(如 Go Ristretto、Java Caffeine),把 Hot Key 擋在 App 記憶體內。
陷阱 4:快取雪崩(Cache Avalanche)設定相同 TTL
- 情境:批量匯入的 100 萬筆商品資料,TTL 全部設為 1 小時。1 小時後 100 萬筆快取同時失效,DB 瞬間扛不住崩潰。
- 💡 實務首選:TTL 設定必須加上隨機抖動值(Random Jitter),例如
base_ttl + rand(1, 300)秒,讓失效時間平滑拉開。
三、 併發與鎖:99% 的情況你不需要「分散式鎖」
這是工程師最常踩的過度設計(Over-engineering)陷阱。
陷阱 5:Check-Then-Act 導致的 Race Condition
PHP
// ❌ 經典錯誤:讀取 -> 判斷 -> 寫入,完全不具備原子性!
$stock = Redis::get('stock:1001');
if ($stock > 0) {
Redis::set('stock:1001', $stock - 1);
}
陷阱 6:動不動就上 Redis 分散式鎖
💡 正確思維一:單純數值扣減 ➔ 原子指令(Atomic Operations)
利用 Redis 單執行緒特性的
DECR / DECRBY 或 INCRBY,保證 Thread-safe:PHP
// ⭕ 原子扣減,性能極高
$remaining = Redis::decrBy('stock:1001', 1);
if ($remaining < 0) {
// 庫存不足,回滾或直接返回失敗
}
💡 正確思維二:複雜業務邏輯 ➔ Lua Script
當需要「驗證資格 + 扣庫存 + 紀錄 User ID」時,將邏輯封裝為 Lua Script 在 Redis 內部單次原子執行:
原則:Lua Script 執行時間必須維持在< 5ms。嚴禁在 Lua 內進行複雜迴圈,否則會直接阻塞 Redis 主執行緒。
四、 分散式鎖的死穴:時間與網路
當你真的需要分散式鎖(例如防重複送單),請意識到
SETNX + EXPIRE 絕非萬靈丹。陷阱 7:鎖過期與 GC Pause / 網路抖動
Node A ──[取得鎖 30s]──> | 發生 GC Pause / 網路抖動 (停頓 35s) | ──> [執行寫入 (髒資料!)]
↓ (30s 鎖自動過期)
Node B ───────────────> [取得同把鎖] ───────────────> [同時執行寫入!]
致命情境:
- Node A 取得鎖,設定 TTL = 30s。
- Node A 遭遇長 Stop-The-World GC 或第三方 API Timeout,任務卡住 35s。
- 鎖在第 30s 自動釋放,Node B 順利取得同把鎖。
- Node A 恢復執行,與 Node B 同時操作共享資源,鎖機制徹底崩潰。
陷阱 8:誤刪別人的鎖
Node A 執行完畢後直接
DEL lock_key,但此時鎖早已過期且被 Node B 取得,Node A 刪掉的是 Node B 的鎖!💡 分散式鎖的防範階梯:
- 基礎防護:利用 UUID 綁定 Value + Lua 原子釋放(確保不誤刪別人的鎖)+ WatchDog(看門狗機制,動態續期)。
- 高一致性需求:引進 Fencing Token(遞增單調序號,DB 寫入時檢查 Version)。
- 極致強一致性:放棄 Redis,改用 ZooKeeper / etcd(CP 系統,Paxos/Raft 共識協定)。
五、 快取不一致(Cache Inconsistency)是必然,而非偶然
業界最常討論的 Cache-Aside Pattern:
- 讀取:先讀快取,Miss 則讀 DB 並回填快取。
- 更新:先更新 DB,再刪除快取(Delete Cache)。
陷阱 9:Direct Update Cache(直接更新快取)
直接
Set Cache 容易因為併發寫入順序錯亂(Race Condition)導致舊資料覆蓋新資料。因此「刪除快取」是相對安全的做法。陷阱 10:Cache-Aside 的極致競態條件
- T1 讀取:Cache Miss,去 DB 讀到舊值
A。 - T2 更新:DB 改為
B,並發起Delete Cache。 - T1 回填:把剛才讀到的舊值
A寫回 Cache。 - 結果:DB 是
B,Cache 是A,資料永不一致,直到 TTL 到期。
T1 (Read): [Cache Miss] ──> [Read DB: A] ──────────────────────> [Set Cache: A (髒資料!)]
T2 (Write): ──────────────────────────> [Update DB: B] ──> [Delete Cache]
💡 解決方案:
- 方案 A:延遲雙刪(Delayed Double Delete)
Delete Cache➔Update DB➔Sleep 500ms➔Delete Cache Again(清除中間產生的髒資料)。 - 方案 B:CDC (Change Data Capture) 異步失效利用 Canal / Debezium 監聽 MySQL Binlog,透過 Kafka 異步發送通知來失效(Invalidate)快取。將快取維護從業務代碼中解耦。
六、 Cluster 集群與運維陷阱
陷阱 11:Redis Cluster 的 CROSSSLOT 錯誤
在 Redis Cluster 架構下,使用
MGET 或 Pipeline 批次操作多個 Key 時,若 Key 落在不同的 16384 Hash Slot,會直接爆出 CROSSSLOT Keys in request don't hash to the same slot。💡 解法:Hash Tag
使用
{} 強制將相關連的 Key 歸類到同一個 Slot:user:{1001}:profileuser:{1001}:orders{1001}括號內的字串會作為 Slot 計算依據,確保落在同一節點。
陷阱 12:Big Key 造成的網路阻塞與 CPU 凍結
- 定義:單一 Hash 包含數萬個欄位,或 List/Set 累積數百萬個元素。
- 危害:
- 網路 IO 阻塞:單次
HGETALL直接塞爆 Gbit 網卡。 - 記憶體釋放阻塞:同步刪除一個 1GB 的 Key,會導致主執行緒卡頓數秒。
💡 最佳實踐:
- 禁止使用
KEYS *與無節制的HGETALL。 - 刪除 Big Key 嚴禁使用
DEL,必須使用UNLINK(Redis 4.0+ 異步背景釋放記憶體),或搭配HSCAN漸進式拆解。
陷阱 13:主從複製失真(Replication Lag)與腦裂(Brain-Split)
- 主從異步複製:Master 寫入後尚未同步到 Replica 即宕機,Slave 被提升為 Master,導致資料丟失。
- 腦裂:網路分區導致 Cluster 出現兩個 Master,網路恢復後其中一個 Master 的寫入會被覆蓋。
💡 防範設定:
配置
min-replicas-to-write 1 與 min-replicas-max-lag 10,當 Slave 數量不足或延遲過高時,主節點拒絕寫入,保障 consistency。七、 容災與降級:系統如何活下來?
架構師不關心 Redis 跑得有多快,架構師關心的是:當 Redis 坍塌時,整個系統如何優雅降級(Graceful Degradation)?
陷阱 14:沒有限流與熔斷,Redis 一掛 DB 直接被沖垮
如果沒有熔斷保護,Redis 掛掉的瞬間,數萬 QPS 會直接沖向 DB,造成連鎖倒塌(Cascading Failure)。
陷阱 15:缺少本地快取(Local Cache)多層級防線
過度依賴遠端 Redis,沒有在 App 進程內建立 Local Cache 防線。
💡 完整高併發防衛陣線對比:
| 故障層級 | 潛在風險 | 架構防禦手段 |
| Hot Key 衝擊 | 單一節點網卡 / CPU 滿載 | App 進程內快取(Caffeine / Ristretto) 擋下 90% 流量 |
| Redis 節點宕機 | 流量直擊資料庫 | 應用端配置 Sentinel / Resilience4j 熔斷器與限流器 |
| 快取雪崩 | 大量 Key 同時到期 | TTL 增加 Jitter 隨機抖動 |
| 主從切換資料遺失 | 異步複製失真 | 關鍵交易不依賴 Redis 快取,直接走 DB / MQ |
結語:架構師視角下的 Redis
能寫出運作正常的 Redis 程式碼,只是工程師的起步。
真正的資深架構思維,在於對極限情況的敬畏:
- 隨時準備好 Redis 會掛掉的降級預案。
- 清晰明白 Redis 在 CAP 定理中偏向 AP,絕不可以拿來做強一致性的金融交易真相庫。
- 永遠在「開發成本」、「系統維護性」與「一致性強度」之間找到最務實的平衡點。
「讓系統在最極端的故障下依然能活下來,才是資深工程師真正的價值。」
沒有留言:
張貼留言