分散式鎖的正確實作:Redis 鎖與我看過的那些災難
Louis Wu,台灣資深後端工程師,主力 Go,做過交易所撮合、金流與高併發系統,專注於把實戰踩過的坑寫成可複用的工程判斷。
做後端做久了,你會發現有一類 bug 特別陰險:平常都好好的,壓力測試也過了,上線跑了三個月相安無事,結果某天半夜流量一尖峰,帳就對不上了。客訴進來說「我明明只點了一次提現,怎麼被扣兩次」,或是行銷活動的限量券被同一個人搶了五張。你翻 log 翻到天亮,最後發現問題出在一個你以為早就處理好的地方——並發控制。
在單機時代,這種問題用一把行程內的鎖就解決了。Go 裡面一個 sync.Mutex,Java 裡面一個 synchronized,大家排隊進臨界區,乾乾淨淨。但現在誰還單機跑服務?你的 API 後面掛了八個 Pod,每個 Pod 都是獨立的行程,行程內的鎖只能鎖住自己這台機器上的 goroutine,根本管不到隔壁 Pod。這時候你需要的是一個所有實例都看得到、都認得的鎖——分散式鎖。
這篇文章我想把分散式鎖這件事從頭講清楚。不是貼一段 Redis 指令叫你照抄,而是帶你走一遍我這些年踩過的坑:從最陽春的寫法為什麼會出事,到每一個修補又會引出什麼新問題,最後聊聊 Redlock 的爭議,以及一個更重要的問題——你到底是不是真的需要分散式鎖。
為什麼多實例下一定要互斥
先把場景講具體。假設我們在做一個錢包服務的提現功能。使用者按下提現,後端要做的事情大概是:讀出餘額、檢查餘額夠不夠、扣掉金額、寫一筆提現紀錄、呼叫銀行的代付介面。
單一請求看起來沒問題。但如果同一個使用者在極短時間內送了兩次提現請求呢?可能是他手抖點了兩下,可能是前端按鈕沒做防抖,也可能是網路逾時後客戶端自動重試。這兩個請求很可能落在不同的 Pod 上,於是兩個行程幾乎同時開始跑上面那段邏輯。
兩邊都讀到餘額是一千,兩邊都檢查「一千大於等於五百,夠」,兩邊都扣了五百、都寫了紀錄、都呼叫了銀行。結果這個人餘額一千卻提走了一千,等於我們的系統憑空送了他五百。這就是經典的競態條件(race condition),在金流系統裡這種事不叫 bug,叫事故。
要避免它,核心就是讓「讀餘額到扣餘額」這整段邏輯,在同一時間只有一個請求能執行。這個「同一時間只有一個」的保證,跨越多個進程、多台機器,就是分散式鎖要解決的問題。
最陽春的 SETNX 為什麼有問題
很多人第一次寫分散式鎖,會用 Redis 的 SETNX。SETNX 是 SET if Not eXists 的縮寫,語意很乾淨:如果這個 key 不存在,就設值並回傳成功;如果已經存在,就什麼都不做、回傳失敗。
於是直覺的寫法是這樣:搶鎖的時候對某個 key 做 SETNX,成功的人就拿到鎖、可以進臨界區做事;失敗的人就代表鎖被別人拿走了,要嘛等待要嘛放棄。做完事之後,把這個 key 刪掉,鎖就釋放了。
邏輯上沒錯,但這個版本有一個致命缺陷:如果拿到鎖的那個行程,在做事做到一半的時候掛掉了呢?
可能是 Pod 被 OOM kill,可能是程式 panic 了,可能是那台機器直接斷電。不管是哪種,結果都一樣——那個負責刪 key 的步驟永遠不會執行。於是這把鎖就一直留在 Redis 裡,誰也拿不到。後面所有想做提現的請求全部卡死,因為鎖永遠不會被釋放。這就是死鎖(deadlock),而且是最難救的那種,因為 Redis 不會告訴你這把鎖的主人早就不在了。
一定要設過期時間,但問題沒這麼簡單
死鎖的解法很直觀:給鎖設一個過期時間。就算拿鎖的人掛了沒來得及釋放,過了這個時間,Redis 也會自動把 key 清掉,鎖就解開了,後面的人還有機會。
這裡有個關鍵的實作細節必須講清楚。早期很多教學會教你先 SETNX,成功之後再用 EXPIRE 設過期時間,分成兩步。千萬不要這樣寫。因為這兩個指令不是原子的,如果你在 SETNX 成功之後、EXPIRE 還沒執行之前,行程剛好掛了,那這把鎖又變成沒有過期時間的鎖,死鎖問題原封不動回來了。
正確的做法是用一條指令把「設值」和「設過期」一起做掉。現在的 Redis SET 指令支援這樣的參數,可以在 SET 的同時帶上 NX(key 不存在才設)和 PX(過期毫秒數)。一條指令搞定,天然原子,沒有中間狀態的空窗。
那 setnx 加過期時間是不是就完美了?並沒有。我們只是把一個問題(死鎖)換成了另一個更隱蔽的問題。
鎖提前過期:A 還沒做完,鎖被 B 拿走了
過期時間這個東西很尷尬:設多久才對?
設太短,業務還沒跑完鎖就過期了。設太長,萬一真的有人掛了,後面的人要乾等很久。而麻煩的是,業務執行時間根本不是固定的。平常提現邏輯跑兩秒就完了,但偏偏某次銀行介面卡住、GC 停頓、或是資料庫慢查詢,這次跑了十二秒。如果你過期時間設十秒,那在第十秒的時候,A 的活還在做,但 Redis 已經默默把鎖的 key 清掉了。
接著 B 來搶鎖,發現 key 不在,SETNX 成功,B 也進了臨界區。現在 A 和 B 同時在動同一筆帳——我們繞了一大圈,又回到了一開始那個雙重扣款的災難。鎖等於沒鎖。
更恐怖的是接下來這一幕。A 終於把活做完了,照著流程要釋放鎖,於是它執行刪除 key 的指令。但此時這把鎖的主人已經是 B 了。A 一刪,把 B 的鎖刪掉了。然後 C 又進來搶鎖、成功,C 也進臨界區。現在 B 和 C 又同時在動……鎖的互斥性徹底崩潰,而且是連鎖崩潰。
我看過一個線上事故就是這個型態。當時是一個批次對帳的任務,平常跑得很快,鎖設了三十秒。某天上游資料量暴增,任務跑了快兩分鐘,期間鎖過期了好幾輪,於是同一份對帳邏輯被好幾個實例重複跑,產生一堆重複的調整分錄,財務那邊對帳對到崩潰。事後追查才發現,問題的根不在對帳邏輯,而在那把「不該被別人刪掉」的鎖。
唯一 token:釋放時要比對,確認鎖還是我的
從上面那個 A 刪掉 B 的鎖的災難,我們學到一件事:釋放鎖的時候,不能無腦地刪 key,而是要先確認「這把鎖現在還是不是我的」。
做法是每次搶鎖的時候,產生一個唯一的識別值,我習慣叫它 token。可以用 UUID,也可以用「機器 ID 加上時間戳加上隨機數」之類的組合,重點是全域唯一、不會撞。搶鎖的時候,把這個 token 當作 value 寫進 Redis,而不是隨便寫個固定值。
釋放鎖的時候,先去讀那個 key 的 value,比對一下是不是我自己當初寫進去的那個 token。如果是,代表這把鎖確實還是我的,我可以放心刪。如果不是——比如說已經是 B 的 token 了——那代表我的鎖早就過期、而且已經被別人重新搶走了,這時候我絕對不能刪,刪了就是刪別人的鎖。我什麼都不做,默默退場就好。
這個「比對 token 再刪」的思路是對的,它擋住了 A 誤刪 B 的鎖這條路。但這裡又埋了一個新的雷,而且很多人沒意識到。
為什麼非得用 Lua:原子性才是關鍵
「先讀 value 比對,相等才刪」這個動作,如果你拆成兩步用程式碼做——先一個 GET 把 value 撈回來、在應用程式裡比對、相等再發一個 DEL——那你又掉進了非原子的陷阱。
想像這個時序:A 執行 GET,讀到的 value 確實是自己的 token,比對通過,準備刪。但就在 A 發出 DEL 指令之前的那一瞬間,A 的鎖剛好過期了,B 立刻搶到鎖、寫入了 B 的 token。然後 A 那個延遲的 DEL 才送達 Redis,把 B 剛搶到的鎖給刪了。你看,繞了一圈,誤刪別人鎖的問題又回來了,只是觸發的時間窗變窄了而已,沒有真正解決。
問題的本質是「比對」和「刪除」之間有空隙。要根除它,這兩個動作必須是一個不可分割的整體,中間不能插入任何其他操作。在 Redis 裡,保證這種原子性的標準手段就是 Lua 腳本。Redis 執行 Lua 腳本時是單執行緒、不會被打斷的,整段腳本要嘛全做完、要嘛不做,中間不會有別的指令插進來。
所以釋放鎖的正確姿勢,是寫一小段 Lua 腳本:在腳本裡先判斷 key 的 value 是不是等於我傳進去的 token,相等才執行刪除,不相等就回傳零什麼都不做。把這整段交給 Redis 一次執行。這樣「比對加刪除」就是一個原子操作,徹底杜絕了延遲 DEL 誤刪別人鎖的可能。
同樣的道理,後面要講的續租也是用 Lua:先比對 token 確認鎖還是我的,是才延長過期時間。所有對鎖的「有條件修改」,都要靠 Lua 把判斷和動作綁成原子。
續租 watchdog:讓鎖跟著業務一起活著
回到那個「過期時間到底要設多久」的兩難。token 加 Lua 解決了「誤刪別人的鎖」,但沒解決「鎖在業務還沒做完時就提前過期」這個根本問題。鎖過期了,互斥性還是會破。
比較成熟的解法是引入續租機制,也就是大家常說的 watchdog(看門狗)。
想法是這樣:搶到鎖之後,不要把過期時間設成一個你猜測的業務總時長,而是設一個相對短的值,比如十秒或三十秒。然後在背景開一個任務,每隔過期時間的三分之一左右,就去幫這把鎖續一次命——只要我的業務還在跑,watchdog 就週期性地把過期時間往後延,讓鎖一直保持有效。等到業務真正做完、正常釋放鎖的時候,再把 watchdog 停掉。
這樣一來,過期時間就不再需要去賭業務要跑多久了。業務跑得快,做完就釋放;業務跑得慢,watchdog 會一直幫它撐著鎖不過期。而萬一行程掛了——這正是過期時間存在的意義——watchdog 也跟著行程一起死了,沒人再來續租,於是鎖會在最後一次續租的過期時間到了之後自動釋放,死鎖一樣不會發生。兩邊的好處都拿到了。
知名的 Redisson(Java 生態的 Redis 客戶端)預設就帶 watchdog,這也是它被廣泛使用的原因之一。Go 生態裡 redsync 之類的函式庫也有類似機制,或者你自己用 goroutine 加 ticker 也能實作。
但續租也不是沒有代價。它讓鎖的邏輯複雜了不少,你得管理背景任務的生命週期、處理續租失敗的情況(續租的時候發現 token 已經不是自己的了,代表鎖已經被搶走,這時候業務其實應該中止而不是繼續做下去)。而且更根本的是,watchdog 解決的是「單一 Redis 節點」場景下鎖提前過期的問題,它並沒有解決 Redis 本身掛掉的問題。這就帶出下一個更大的爭議。
Redlock 的爭議:它真的安全嗎
前面講的都建立在一個前提上:你有一個可靠的 Redis。但實務上 Redis 通常是主從架構,主節點掛了會故障轉移到從節點。問題來了:Redis 的主從複製是非同步的。
設想這個時序:A 在主節點上搶到了鎖,主節點還來不及把這把鎖複製給從節點,主節點就掛了。從節點被提升為新的主節點,但它身上根本沒有 A 那把鎖的資料。於是 B 來搶鎖,在新主節點上輕鬆成功。現在 A 和 B 都以為自己持有鎖——互斥性在故障轉移的瞬間被打破了。
Redis 作者 antirez 為了解決單點問題,提出了 Redlock 演算法。它的想法是不要只依賴一個 Redis 節點,而是部署多個(通常五個)互相獨立的 Redis 實例,搶鎖的時候要去跟過半數(五個裡面至少三個)的節點都搶成功、而且整個搶鎖過程花的時間要明顯小於鎖的有效期,才算真正拿到鎖。釋放的時候則對所有節點都發釋放指令。
聽起來很嚴謹,但 Redlock 一發表就引來一場著名的論戰。分散式系統領域的知名學者 Martin Kleppmann(就是寫《Designing Data-Intensive Applications》那本書的作者)寫了一篇文章質疑 Redlock 的安全性,antirez 也寫文回應,兩邊來回交鋒。
Kleppmann 的核心質疑有兩點。第一是時鐘問題。Redlock 的正確性依賴各個節點對時間流逝的判斷,但實際的伺服器時鐘並不可靠——可能因為 NTP 校時而突然跳動,可能因為虛擬機暫停而停滯。一旦某個節點的時鐘出現大幅跳變,鎖可能在它自己以為還有效的時候就已經實際過期了,過半數的保證就被破壞。
第二是行程暫停問題,這個更根本。想像 A 拿到了鎖,正準備去操作共享資源,但就在這個節骨眼上,A 這個行程被卡住了——可能是一次很長的 GC stop-the-world,可能是作業系統把它換出去調度別的任務,可能是虛擬機被暫停。這個暫停可能長達好幾秒甚至更久。等 A 醒過來,它的鎖其實早就過期了,鎖已經被 B 拿走,但 A 自己渾然不覺,還以為自己持有鎖,於是大搖大擺地去寫共享資源。Redlock 對這種情況同樣無能為力——任何「先檢查鎖、再操作資源」的兩階段流程,都躲不過在這兩個階段之間被暫停的命運。
Kleppmann 給的建議是:如果你需要的只是效率(避免重複做同一件事、省點資源),那單節點 Redis 鎖就夠用了,偶爾出錯也只是多做一次工,沒什麼大不了,犯不著上 Redlock 的複雜度。但如果你需要的是正確性(絕對不能有兩個人同時持有鎖去操作資源,錯了會出大事,比如金流),那你不該把賭注押在任何基於過期時間的鎖上,而應該在最終操作資源的地方,用一個單調遞增的 fencing token 來保護——每次發鎖都附一個遞增的編號,資源端記住見過的最大編號,拒絕任何比它小的請求。這樣就算 A 暫停後醒來拿著過期的舊鎖去寫,它的編號比 B 的小,會被資源端直接擋下。
我的立場偏向 Kleppmann 這一邊,但我想說得更實際一點:Redlock 對絕大多數團隊來說,是一個成本和收益不成比例的選擇。你要為它多維護五個 Redis 節點,多寫一堆處理過半數邏輯的程式碼,換來的卻仍然不是真正的安全保證。如果你的業務真的脆弱到必須有完美的互斥,那答案往往不是更花俏的鎖,而是回到資料層面去做防護。
鎖不是萬靈丹:能用 DB 約束就別上分散式鎖
講了這麼多分散式鎖的坑,我最想傳達的其實是這句話:分散式鎖是一個很重的工具,能不用就不用。
很多人遇到並發問題,反射動作就是上一把分散式鎖。但鎖的本質是把並發變成序列化,它必然犧牲吞吐量,而且引入了上面講的那一大堆複雜性和邊界情況。很多時候,你根本不需要它。
回到開頭那個提現雙重扣款的例子。與其用分散式鎖把整段邏輯鎖起來,不如換個角度想:問題的本質是「同一筆扣款不能執行兩次」。那我們可以給每一次扣款操作一個唯一的業務識別碼(比如客戶端產生的請求 ID,或是訂單號),然後在資料庫的扣款流水表上對這個識別碼建一個唯一索引。這樣即使兩個請求同時進來,兩邊都想插入同一個識別碼的流水,資料庫的唯一約束會保證只有一筆能成功,另一筆會因為違反唯一約束而失敗。失敗的那一筆,直接當作重複請求忽略或回傳冪等結果就好。
這就是用資料庫的唯一約束來做防重,它比分散式鎖簡單太多了:沒有過期時間要調、沒有 token 要比對、沒有 watchdog 要維護、沒有 Redis 掛掉的問題。資料庫的約束是強一致的,它說只能有一筆,就真的只能有一筆。防重複下單也是同一招——對訂單的冪等鍵建唯一索引,同一個冪等鍵只會產生一筆訂單。
如果你的場景是「更新一筆資料,怕被別人同時改」,那樂觀鎖往往就夠了。在資料表上加一個版本號欄位,讀的時候把版本號一起讀出來,更新的時候在 WHERE 條件裡帶上「版本號等於我剛才讀到的值」,並且把版本號加一。如果這中間有別人改過,版本號已經變了,你的更新會影響零筆,你就知道發生了衝突,重試即可。整個過程沒有任何鎖,靠的是資料庫單行更新的原子性。
扣餘額其實也能用類似的條件更新來做:直接下一條「餘額減去金額,條件是餘額大於等於金額」的更新語句,靠資料庫保證這條更新的原子性,更新影響筆數為零就代表餘額不足。連版本號都不用,一條 SQL 就把檢查和扣款原子地做完了,比鎖優雅得多。
那到底什麼時候真的需要分散式鎖
把上面的替代方案都列完,你可能會問:那分散式鎖是不是根本沒用?倒也不是。它在某些場景確實是最自然的選擇。
第一種是純粹的互斥執行,而且這個臨界區的操作沒有一個天然的資料庫實體可以掛唯一約束。典型例子是排程任務的防重複執行:你有一個定時跑的批次任務,部署了多個實例,但你只希望同一個時間點只有一個實例真的去跑這個任務。這種情境沒有什麼「一筆訂單」可以建唯一索引,用一把分散式鎖讓搶到的實例去跑、搶不到的跳過,是最直接的做法。注意這正是 Kleppmann 說的「效率」場景——就算偶爾因為故障轉移導致兩個實例都跑了,頂多是任務重複執行一次,只要任務本身做成冪等的,就沒什麼大不了。
第二種是操作的對象不在你的資料庫裡,沒有約束可以借力。比如你要協調對某個外部資源的存取——一個第三方 API 有嚴格的呼叫頻率限制、一個共享的檔案、一段需要序列化存取的硬體資源。這些東西沒有資料庫的唯一約束能用,分散式鎖是少數能跨實例協調的手段。
第三種是你需要的是「盡量互斥」而不是「絕對互斥」,可以容忍極小機率的失效。前面說了,基於過期時間的鎖在極端情況(行程暫停、時鐘跳變)下並不能保證絕對安全,但這些極端情況發生的機率很低。如果你的業務在那個小機率失效時,後果是可以承受的——比如多發了一封通知信、多生成了一份報表——那麼一把簡單的單節點 Redis 鎖,用 SET NX PX 搶、用唯一 token 加 Lua 釋放、視情況加上 watchdog 續租,就是一個性價比很高的方案。
但我要再強調一次那條紅線:如果失效的後果是金錢、是不可逆的操作、是會讓帳對不上的事情,那就不要把唯一的防線壓在鎖上。鎖可以當作第一層的並發控制、減少衝突、降低資料庫壓力,但最終那道一定要守住的關卡,要放在資料層——用唯一約束、用條件更新、用 fencing token。鎖負責讓大部分情況跑得順,資料層負責讓壞情況不會釀成災難。這種分層防護的思路,比起糾結於把鎖做得多完美,務實得多。
收尾
我把分散式鎖這條路上的坑大致走了一遍:陽春的 SETNX 會死鎖,加過期時間會提前釋放,token 加 Lua 防誤刪,watchdog 解決續租,Redlock 想解決單點卻換來爭議,而真正的答案常常是退回資料層用更簡單的工具。
如果只能讓你記住一句話,我希望是這句:先問自己「我是不是真的需要一把鎖」,再問「我需要的是效率還是正確性」。把這兩個問題想清楚,你十有八九會發現,你要的根本不是一把更聰明的分散式鎖,而是一個建在唯一索引上的冪等設計。我這些年看過的那些災難,幾乎沒有一個是因為鎖不夠高級,而幾乎全都是因為——本來就不該只靠鎖。