凌晨兩點四十分,我被 PagerDuty 吵醒。值班手機上是一行字:訂單 settlement 重複入帳,金額不符,差了七萬八千多塊。我盯著天花板三秒,腦袋裡只有一個念頭:鎖,那把我以為鎖得很好的 Redis 分散式鎖,沒鎖住。
這篇不是 Redis 教學文。教學文網路上一抓一大把,告訴你 Redis 是「一個很快的快取」,然後貼幾段指令就結束了。我想講的是我在交易所撮合、金流、搶購這些高併發系統裡,真的拿 Redis 去扛流量、然後被它咬過的那些細節。每一種用法我都會講一個真實的場景,以及那個讓我半夜起來改 code 的教訓。
一、快取:存很簡單,難的是讓它失效
先講最常見的快取。剛入行的時候我以為快取就是 cache-aside:讀的時候先看 Redis 有沒有,有就回,沒有就查資料庫、寫回 Redis、再回給呼叫端。寫起來十行不到,看起來無懈可擊。
真正讓我學到東西的是一次後台報表頁面。那個頁面查的是當日交易彙總,SQL 跑起來大概要一秒半,所以我加了五分鐘的快取。上線那天運氣不好,剛好碰到一個對帳活動,凌晨整點有一批排程同時去刷那個頁面。問題是,我給那批 key 設的過期時間全都是整整三百秒,而且是同一時間寫進去的。三百秒後,它們在同一秒一起過期。
那一瞬間,幾百個請求同時發現快取沒了,全部一起去查那條一秒半的 SQL,資料庫連線池瞬間被吃光,連帶把其他正常查詢也拖垮。這就是快取雪崩。資料庫的 CPU 從平常的百分之二十直接飆到滿載,撐了大概四十秒才緩過來。
後來我做了兩件事。第一,過期時間加抖動(jitter):本來是固定三百秒,我改成三百秒上下隨機加減六十秒,讓 key 的失效時間散開,不要擠在同一秒。第二,對那種「重建成本很高」的熱點 key,我加了一層邏輯,讓只有第一個發現快取失效的請求去重建,其他請求短暫等一下拿重建後的結果,而不是大家一起衝進資料庫。這招在業界叫快取擊穿(cache stampede)的防護,核心就是不要讓同一個熱點 key 的重建被併發放大成 N 倍 DB 壓力。
快取還有兩個坑也很經典。一個是穿透:有人一直拿一個根本不存在的 ID 來查,每次都繞過快取直接打 DB。我們真的遇過有爬蟲拿遞增的訂單號掃,掃到一堆不存在的單。解法是連「查無此筆」這件事也快取起來,存一個空值、給它一個比較短的過期時間,讓那種惡意或無意義的查詢也被擋在 Redis 這層。另一個是一致性:資料更新了,快取還是舊的。我的原則很簡單,更新資料庫後主動把對應的快取 key 刪掉(而不是去更新它),下次讀的時候自然會重建。為什麼是刪不是更新?因為「更新快取」這個動作本身在併發下也會有先後順序錯亂的問題,直接刪掉、讓讀路徑去重建,反而是最不容易出錯的做法。
二、分散式鎖:那把沒鎖住的鎖
回到開頭那筆七萬八。事情是這樣的。
我們有個 settlement worker,負責把待結算的訂單做最後入帳。為了水平擴展,這個 worker 同時跑了三個 instance。同一筆訂單顯然不能被兩個 instance 同時處理,所以我用 Redis 做分散式鎖:處理某筆訂單前,先用那筆訂單的 ID 當 key 去搶鎖,搶到才處理,處理完釋放。
我第一版的鎖是這樣寫的:先用 SETNX 設一個 key,如果成功就代表搶到鎖;然後另外發一個指令給這個 key 設過期時間,免得 worker 掛掉鎖死;處理完直接 DEL 把 key 刪掉。看起來合情合理對吧?三個地方都錯了。
第一個錯:SETNX 和設過期時間是兩個指令。如果 worker 剛 SETNX 成功、還沒來得及設過期時間就掛了,這把鎖就永遠不會釋放,那筆訂單就卡死了。正確做法是用單一個原子指令:SET key value NX PX 30000,一次把「不存在才設」跟「三十秒後過期」做完,中間沒有空隙。
第二個錯,也是釀成那七萬八的元兇:解鎖時我直接 DEL。想像這個時序:instance A 搶到鎖,過期時間設三十秒。結果 A 處理那筆訂單花了三十五秒(那天剛好 DB 有點慢)。三十秒一到,鎖自動過期,instance B 立刻搶到同一把鎖開始處理同一筆訂單。然後 A 終於做完了,它不知道自己的鎖早就過期、現在這把鎖是 B 的,它大喇喇地 DEL 下去,把 B 的鎖刪了。於是 instance C 又搶到鎖進來處理。同一筆訂單,在那個時間窗口裡被處理了不只一次,於是重複入帳。
修法是:上鎖時帶一個唯一的 value(我用 UUID),解鎖前先確認這把鎖的 value 是不是自己當初設的那個,是自己的才刪。但這裡又有個陷阱,「先 GET 比對、再 DEL」這兩步本身也不是原子的,比對完到 DEL 之間鎖一樣可能過期被別人搶走。所以解鎖必須用 Lua script,把「比對 value、相同才刪」包成一個在 Redis 裡一次跑完的原子操作。這是我第一次真正理解為什麼 Redis 的 Lua script 這麼重要:它讓你把多個指令的判斷與動作綁成不可分割的一步。
第三個錯比較隱晦,是過期時間本身。設太短,工作還沒做完鎖就過期,等於沒鎖(上面那個場景);設太長,worker 真的掛了,別人要乾等很久才能接手。後來我的做法是,過期時間設得比「絕大多數情況下的處理時間」寬裕一些,並且對那種真的可能跑很久的任務,額外做一個續租(watchdog)機制:背景定時去延長自己持有的鎖的過期時間,只要任務還活著就一直續,任務結束或進程死掉就停止續租讓它自然過期。
那這樣就萬無一失了嗎?沒有。Redis 單節點的鎖,在主從架構下還有一個更深的問題。如果 master 設了鎖、還沒同步到 replica 就掛了,replica 升主之後那把鎖就憑空消失了,兩個人會同時拿到鎖。Redlock 演算法就是為了解決這個,概念是同時去多個獨立的 Redis 節點搶鎖、超過半數才算成功。但 Redlock 本身在分散式社群裡是有爭議的,有人(包括 Martin Kleppmann 那篇有名的文章)質疑它在時鐘漂移、GC stop-the-world 暫停這些情況下的安全性。
我自己的結論是:如果你的鎖是用來「保護正確性」(像金流入帳,鎖沒鎖住就是賠錢),那光靠鎖過期時間是不夠的,你需要 fencing token。做法是每次發鎖時遞增一個單調遞增的版本號(token)給持鎖者,持鎖者去做寫入時把這個 token 一起帶給下游的儲存層,儲存層只接受 token 大於它見過的最大值的寫入。這樣就算 A 的鎖過期、B 拿到新鎖、A 又突然醒過來想寫,A 的 token 比 B 小,會被儲存層拒絕。鎖只是性能優化和減少衝突,真正的正確性防線是 fencing token。如果你的鎖只是用來「避免重複做白工」(像快取重建),那單節點 Redis 鎖通常就夠了,不用搞得這麼重。
三、原子計數與庫存扣減:搶購系統的命脈
講一個比較開心的場景,搶購。我做過一個限量商品的搶購,庫存兩千件,開賣瞬間湧進來的併發大概是每秒一萬多個請求。如果每個請求都去 DB 做「查庫存、判斷大於零、扣一」,DB 當場就躺了,而且在併發下「查」跟「扣」之間有空檔,一定超賣。
Redis 在這裡的價值是它的單執行緒模型,讓單一指令天然就是原子的。但「判斷大於零才扣」是兩個邏輯,光靠單一個 DECR 指令不夠,因為 DECR 會把庫存扣成負的。我的解法還是 Lua script:寫一段腳本,在 Redis 裡一次做完「讀目前庫存、如果大於零就減一並回傳成功、否則回傳失敗」。因為 Lua script 在 Redis 裡是序列化執行、不會被其他指令插隊的,所以這整段判斷加扣減是不可分割的原子操作,併發再高也不會超賣。
那場搶購,兩千件庫存在 Redis 裡精準扣完,沒有超賣一件,DB 在整個開賣過程完全沒被庫存查詢碰到。真正的扣款與訂單落地是後面非同步慢慢補的,Redis 只負責守住「庫存這個數字不能錯」這道閘門。
四、排行榜:sorted set 的主場
即時排行榜這種東西,用關聯式資料庫做會很痛。我一開始天真地用 SQL 的 ORDER BY 加 LIMIT 去算積分榜前一百名,資料量小的時候沒事,等到參與人數上到幾十萬、而且要求「即時」反映排名變動,每次刷新都全表排序,DB 直接哀號。
Redis 的 sorted set 根本就是為這個生的。每個成員帶一個分數,Redis 內部用跳表幫你維持有序,加分、查前 N 名、查某個人現在排第幾,都是對數時間複雜度的高效操作。我把積分用 sorted set 存,使用者加分就對他的分數做原子遞增,查排行榜直接拿區間,查個人排名直接問 Redis 那個人的 rank。原本要全表掃描的東西變成毫秒級回應。
這裡也有個併發的細節我踩過。早期我更新分數是「先讀出舊分數、在程式裡加上新增的分、再寫回去」,結果同一個人在短時間內連續得分時,兩個請求各自讀到同一個舊分數、各自加、各自寫回,後寫的覆蓋先寫的,分數就漂掉了,榜單對不上。解法很簡單,用 sorted set 內建的「對分數做增量」那個操作,把加分這件事交給 Redis 原子地完成,不要在應用層做讀改寫。又是同一個教訓:能讓 Redis 原子地做,就不要自己讀出來改完再寫回去。
五、限流:集中式才準
限流我也大量用 Redis。最樸素的做法是固定視窗計數:用「使用者 ID 加上時間視窗」當 key,每次請求對它原子遞增、第一次設過期時間,超過閾值就擋。重點是為什麼要用 Redis 而不是在每台機器各自記:因為你有很多台機器,如果各記各的,使用者的請求被負載平衡打散到不同機器,每台都只看到一部分流量,限流就形同虛設。必須有一份集中的計數,大家共用,限流才準。固定視窗有臨界點突刺的問題(視窗交界處可能瞬間放行兩倍),要更精準可以做滑動視窗或令牌桶,一樣是靠 Redis 的原子指令加 Lua script 來保證計數的正確,這個展開又是一篇,這裡先按下不表。
別忘了:Redis 是記憶體,資料是會掉的
最後講一個心態。前面講了這麼多 Redis 的好,但我必須提醒:Redis 主要活在記憶體。它有 RDB 快照和 AOF 持久化,但它的定位從來不是「絕對不能掉一筆的主資料庫」。主從切換、節點重啟、持久化的時間差,都可能讓你損失最近一小段時間的寫入。
我的原則是:真正的事實來源(source of truth)永遠放在資料庫,Redis 放的是可以重建的東西。快取掉了,從 DB 重建就好;排行榜掉了,可以從交易紀錄重算;限流計數掉了,大不了短時間放鬆一點。唯一要特別小心的是分散式鎖和搶購庫存這種「Redis 就是事實」的場景,那裡你才需要認真考慮持久化等級、主從同步、甚至上面講的 fencing token。
寫到這裡,回頭看那筆七萬八的事故,我其實滿感激它的。它逼我真正搞懂 Redis 每一種用法背後那些「平常不會出事、但併發一上來就會咬你」的細節:原子性不是理所當然的、過期時間是一把雙面刃、單一指令的原子不等於多指令組合的原子、而記憶體裡的東西終究是會掉的。Redis 很快、很強,但它不會替你思考併發。它只是忠實地、飛快地執行你給它的指令,包括你寫錯的那些。把這些細節摸透之後,我才敢說 Redis 是我高併發系統裡最信任的工具之一,而那份信任,是用一次次半夜的告警換來的。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。