分散式鎖:從 SETNX 到 Redlock,與那場著名的爭議

· tech

#redis#distributed-systems

📑 目錄

多個行程、多台機器要搶同一個資源(同一時間只准一個人扣庫存、跑一個排程),就需要一把分散式鎖。Redis 因為快又原子,常被拿來當這把鎖。但這是個「看起來三行就能寫完、其實坑深到見底」的題目——一路踩下去,還會撞上一場分散式系統界著名的學術爭議。這篇從最天真的寫法,一步步補到 Redlock,再誠實面對那個「Redis 鎖到底安不安全」的問題。

一把正確的單機鎖:SET NX PX + Lua 釋放

先看最常見、單一 Redis 節點上「大致正確」的寫法。它的每個零件,都在補一個天真寫法會踩的洞:

取得鎖:一行原子命令 SET lock:res <token> NX PX 30000 NX只有 key 不存在才設→ 互斥 PX 30000自帶 30 秒 TTL→ 持有者掛了也自動釋放,不死鎖 <token>隨機值→ 標記「這把鎖是我的」 釋放鎖:要驗 owner(Lua,原子) if GET(key)==token then DEL(key)GET 與 DEL 必須原子 → 只刪自己的鎖 直接 DEL 不驗 token→ 誤刪別人續租的鎖 ✗
三個零件各補一個天真寫法的洞:NX 達成互斥(只有第一個設得成功);PX 30000 給鎖一個 TTL,萬一持有者當掉,鎖會自動過期、不會永久死鎖;<token> 是隨機值,讓釋放時能驗證身分。釋放一定要用 Lua 把「比對 token、相符才刪」包成一個原子操作——否則若你先 GET、正要 DEL 時鎖剛好過期又被別人拿走,你就誤刪了別人的鎖

落成命令就這兩段:

# 取得:NX(互斥)+ PX(TTL)一次完成,token 用 UUID 這種隨機值
SET lock:order:42 3f9a...e1 NX PX 30000
-- 釋放:GET 比對 token,相符才 DEL(整段用 EVAL 跑,保證原子)
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
else
  return 0
end

早年還有人用 SETNX + 另一句 EXPIRE 兩步設鎖——千萬別:兩句之間若當掉,鎖就沒了 TTL、變成永久死鎖。SET ... NX PX 把「設鎖」和「設過期」合成一個原子命令,正是為了堵這個洞。

但這樣就安全了嗎?TTL 鎖的致命假設

到這裡看起來很完整了。但 Martin Kleppmann(《DDIA》作者)提出一個殺傷力極大的質疑:只要有 TTL,鎖就無法保證真正的互斥。 問題出在一個誰都躲不掉的東西——行程暫停(GC 的 stop-the-world、OS 排程、甚至機器被 suspend):

一次夠長的 GC 暫停,就打破互斥 A 取得鎖(TTL 30s) STW GC 暫停(> 30s) 醒來,以為還持有 → 寫入 ✗ TTL 到,鎖過期 B 合法取得同一把鎖 → 寫入 A、B 同時寫 → 互斥破功 時間 → 解法:fencing token(單調遞增號碼)資源端只接受比看過的更大的 token → A 拿舊號碼(33)來寫,被擋在資源那一關
致命的一幕:A 拿到鎖後,發生一次比 TTL 還長的 GC 暫停;暫停中鎖過期,B 合法拿到同一把鎖開始工作;A 醒來卻不知道自己的鎖早沒了,也繼續寫——互斥就這樣破了。關鍵洞見是:鎖的 TTL 建立在「時間」的假設上,而分散式系統裡沒有人能保證時間。真正的防護不在鎖,而在資源端:每次取鎖發一個單調遞增的 fencing token,資源只認更大的號碼,A 醒來用舊號碼寫入會被資源這一關擋下

這個 fencing token 的洞見很重要:鎖只能「盡量」互斥,真正的正確性得靠資源端拒絕過期的寫入。 而 fencing token 要單調遞增,本身就需要一個可靠的計數器——那又回到了真共識的地盤。

Redlock,與那場爭議

antirez(Redis 作者)為了讓 Redis 鎖更可靠,提出了 Redlock:別只靠一台 Redis,而是準備 N 台獨立的 master(通常 5 台),取鎖時去跟過半數(≥3 台)在限定時間內都搶到,才算成功;釋放時對全部節點解鎖。用「過半」來抵抗少數節點掛掉——精神上跟 Cluster 的過半故障轉移共識演算法是同一路。

然後就吵起來了。Kleppmann 批評:Redlock 依賴「各節點時鐘走得夠準、行程不會長暫停」這類時序假設,而這些假設在真實系統裡不成立(GC pause、時鐘飄移),所以 Redlock 給人一種它其實給不了的安全感;要正確性,就得用 fencing token + 真共識系統。antirez 反駁:Kleppmann 的攻擊模型太嚴苛,多數實務場景 Redlock 已足夠,且 fencing token 那套一樣有它的假設。這場辯論沒有「誰贏」,但它逼出了一個至今最實用的分野。

那個分野:你要的是「效率鎖」還是「正確性鎖」

  • 效率鎖(efficiency):鎖只是為了避免重複做白工——同一個快取重算兩次、同一封信寄兩遍,偶爾破防只是浪費一點資源,不會出大事。這種鎖,單機 Redis 的 SET NX PX 綽綽有餘,連 Redlock 都未必需要。
  • 正確性鎖(correctness):絕對不能有兩個人同時做——扣款、開發票、轉帳。這種場景下,TTL 鎖的時序假設不夠格,你需要 fencing token + 真共識(ZooKeeper、etcd),或乾脆把互斥交給資料庫的交易與唯一約束。

反思

「看起來三行就能寫完」的東西,往往最深

分散式鎖是我心中「魔鬼藏在細節」的頭號教材。從 SETNX 一路補到 Redlock,每一步都在堵一個你一開始根本沒想到的洞:忘了 TTL 會死鎖、忘了驗 token 會誤刪、忘了 GC pause 會破互斥……每個洞單獨看都「顯而易見」,但沒踩過就是想不到。這給我的長期習慣是:遇到「這不是很簡單嗎」的念頭時,反而要更警覺——越是被當成三行小事的東西,越可能在你沒看的角落埋著坑。真正的資深,不是會寫那三行,而是知道那三行「還漏了什麼」。

效率鎖 vs 正確性鎖,是我做技術決策最常用的一把尺

Kleppmann 那個分野,價值遠超出鎖本身。它教我在用任何「盡力而為」的機制前,先問一句:萬一它偶爾失效,是『浪費一點』還是『出人命』? 是前者(效率),就大方接受簡單方案的偶爾破防,別過度工程;是後者(正確性),就別自欺欺人地用時序假設換安全感,老實上真共識或資料庫交易。這條線讓我不再糾結「Redis 鎖到底安不安全」這種沒有絕對答案的問題,而是先定位「我這把鎖屬於哪一類」——問題分類對了,工具的選擇就不再兩難

那場爭議教我的:工程沒有銀彈,只有假設

antirez 和 Kleppmann 誰對?我的答案是:他們吵的根本不是對錯,是假設。Kleppmann 假設一個會 GC pause、時鐘會飄的殘酷世界;antirez 假設一個大致正常的實務環境。假設不同,結論自然不同——而兩個假設在各自的場景裡都成立。這件事徹底改變了我看技術爭論的方式:與其問「這個方案好不好」,不如問「它建立在什麼假設上、那些假設在我的場景成不成立」。任何號稱安全、可靠、高效的方案,底下都躺著一組假設;把那組假設攤開來看,比聽誰的結論有用一百倍。這是分散式鎖這個小題目,教給我最大的一課。