2.4 distributed lock 與租約
分散式鎖(distributed lock)的核心責任是協調跨節點互斥,避免同一資源被重複處理。它解的是協調一致性問題;正式狀態一致性仍由交易邊界或版本控制承擔。
鎖與租約
分散式鎖通常採租約語意:持鎖者在租約有效期內擁有操作權,租約到期後鎖自動釋放、需重新競爭。租約的存在是為了處理「持鎖者掛掉但沒釋放鎖」這個分散式系統無法避免的情況——沒有租約,一個 crash 的節點會讓鎖永遠卡住。代價是引入時鐘漂移、網路延遲與續租失敗這幾個新風險。
在 Redis 上,取鎖是一個原子命令:SET lock:order:42 <token> NX PX 30000。NX 保證只有 key 不存在時才寫入,這讓「檢查鎖是否被持有」與「取得鎖」變成單一原子操作,避免兩個節點同時判斷「沒人持鎖」後都寫入。PX 30000 設定 30 秒租約,持鎖者 crash 時鎖會在租約到期後自動消失。<token> 是每個持鎖者產生的唯一隨機值,它的作用在釋放階段才顯現。
釋放鎖不能用單純的 DEL lock:order:42,因為這會誤刪別人的鎖。考慮這個時序:節點 A 取得鎖、處理超過 30 秒、租約到期自動釋放、節點 B 取得同一把鎖;此時 A 終於處理完、執行 DEL,刪掉的是 B 的鎖。正確的釋放是「比對 token 相同才刪」,而這個 check-and-delete 必須原子,用 Lua script 達成:
1if redis.call("GET", KEYS[1]) == ARGV[1] then
2 return redis.call("DEL", KEYS[1])
3else
4 return 0
5endARGV[1] 帶入持鎖者自己的 token,只有 token 吻合才刪。這把「釋放鎖」從一個盲目的刪除,變成「確認我仍是持鎖者後才釋放」的條件操作。
租約長度要對著任務耗時分布校準,而非拍一個固定值:租約要明顯長於正常任務的 P99 耗時,避免工作還沒做完租約就過期、引發雙持鎖;但也不能長到讓 crash 的持鎖者把鎖卡住太久。兩個方向夾出一個區間,長尾工作再用 watchdog 補足。
續租策略要明確:何時續租、續租失敗如何降級。長時間工作會用 watchdog 在租約過半(約 T/2)時用 PEXPIRE 延長租約,讓鎖跟著工作存活;但 watchdog 也意味著鎖可能被無限延長,需要設一個絕對上限(例如業務超時的數倍)避免一個卡住的工作永久佔用鎖。若只依賴「拿到鎖就安全」的假設、不處理續租失敗,異常時容易產生重複副作用。
split brain 與 fencing
split brain 常見於網路分割或 process 暫停(GC stop-the-world、容器被搶占)後恢復。核心問題是租約:節點 A 取得鎖後發生一次長 GC 暫停,暫停期間租約到期、鎖被節點 B 取得,A 從暫停中醒來時仍「以為」自己持有鎖,於是 A 與 B 同時對下游寫入,互斥語意失效。這是基於租約的鎖在自身層無法消除的時序窗口,解法要往下游推——讓擁有正式狀態的那一層成為最終仲裁者,而非期望鎖本身堵住這個窗口。
fencing token 的責任是把這個問題推到下游解決:每次取鎖時發一個單調遞增的 token,持鎖者對下游的每個寫入都帶上這個 token,下游記住「見過的最大 token」並拒絕比它小的寫入。回到上面的時序,A 帶 token 33、B 帶 token 34,當 A 醒來用 token 33 寫入時,下游已經接受過 34,於是拒絕 33。token 的單調遞增可以用 Redis 原子計數器(INCR fence:order:42)或鎖服務自己維護的自增序號實作,關鍵是取鎖動作本身要保證拿到的序號嚴格遞增。fencing token 讓下游成為仲裁者,鎖只負責減少競爭、不再是唯一的正確性保證。
若下游無法驗證 fencing token(例如下游是不支援條件寫入的第三方 API),distributed lock 的保護能力會明顯下降——它只能降低衝突機率,無法消除雙寫。這時更穩定的做法是改成資料版本控制或條件更新(WATCH/MULTI 的樂觀鎖、資料庫的 UPDATE ... WHERE version = ?),把互斥下沉到擁有正式狀態的那一層。
Redlock 與單節點的取捨
單節點 Redis 鎖有一個可用性缺口:持鎖期間 Redis 主節點故障、failover 到還沒同步該鎖的副本時,新主節點上這把鎖不存在,另一個節點能立刻取得,造成雙持鎖。Redis 作者提出的 Redlock 演算法用多個獨立 master(通常 5 個)解這個問題:向所有節點取鎖,取得多數(3/5)且總耗時在租約內才算成功,藉冗餘避免單點 failover 造成的鎖遺失。
Redlock 是否真的更安全有公開爭論。Martin Kleppmann 的批評指出,Redlock 依賴各節點時鐘不發生大幅跳動,而 GC 暫停與時鐘校正這類事件仍會讓持鎖者醒來時鎖已失效;更進一步,若 NTP 時鐘跳躍發生在取鎖過程中,各節點對租約是否有效的判斷本身就可能出錯,Redlock 賴以成立的多數決計數因此無法可靠排除雙持鎖。也就是說,Redlock 提升了「鎖不會因單節點故障而遺失」的可用性,但沒有解決「持鎖者暫停導致的 split brain」,後者仍需 fencing token。判讀因此落在:鎖只是效率優化(偶爾雙跑代價可接受)時,單節點 Redis 鎖足夠且運維簡單;鎖牽涉正確性(雙跑會造成金錢或資料損壞)時,無論單節點還是 Redlock 都不足以單獨成立,必須有 fencing token 或下游條件寫入兜底。
何時使用、何時轉向
distributed lock 在「偶爾失效的代價可控」的場景是一個效率優化工具,降低重複工作的機率而非保證互斥。符合這個特徵的場景包括排程任務避免重複執行、單資源批次工作協調、短期臨界區互斥。以 cron job 為例,偶爾被兩個節點同時觸發時,若任務本身 idempotent,重複執行只是浪費資源而非產生錯誤結果,鎖把這類浪費的機率壓低就足夠。
讀取路徑上避免 cache miss 風暴的 single-flight 互斥也屬這一類,但租約特性不同:熱門 key 失效時用一把短鎖讓單一請求回源重建快取、其餘請求等結果,偶爾多跑一次回源的代價可控。它的鎖租約通常很短(一次回源的時間),競爭集中在少數熱門 key,與批次任務的長租約、低競爭剖面相反,校準時要分開看。
高價值交易資料更新則相反,優先使用資料庫交易與唯一約束,將鎖作為輔助而非核心一致性機制。扣款、出貨、配額扣減這類操作,正確性不能依賴「鎖沒失效」這個無法保證的前提,而要靠資料層的唯一約束或版本檢查讓重複操作在最後一刻被擋下。
當鎖競爭成為常態、租約續租頻繁失敗、鎖持有時間與業務耗時高度耦合時,代表模型需要轉向分片、隊列化或版本檢查。鎖競爭高通常是粒度設計問題:把單一全域鎖換成依資源分片的細粒度鎖(lock:order:42 而非 lock:orders),讓不相關的資源互不阻塞。若工作本身就是序列化處理一批項目,改用 message queue 的單一 consumer 語意,比用鎖模擬序列更穩定。
判讀訊號
| 訊號 | 判讀重點 | 對應動作 |
|---|---|---|
| 鎖等待時間持續拉長 | 臨界區過大或熱點資源集中 | 縮小臨界區、拆分資源粒度 |
| 續租失敗與重入衝突同時上升 | 租約時間與工作耗時不匹配 | 重設租約、加入 fencing token |
| 相同任務重複執行率上升 | 鎖語意失效或持鎖者判定漂移 | 檢查時鐘與網路、補下游去重 |
| 網路抖動時 split brain 事件增加 | 鎖系統與下游防護未對位 | 補下游版本檢查、限制高風險操作 |
| 鎖系統穩定但業務仍不一致 | 問題層級在資料一致性而非協調層 | 回到 transaction/constraint 設計 |
常見誤區
把分散式鎖當作通用一致性解法,會讓錯誤責任落在錯誤層級。最常見的具體形狀是「用鎖保護寫入、但讀取路徑不過鎖」:寫入互斥成立了,讀取卻仍可能讀到未提交或 stale 的值,不一致沒有被鎖擋住。鎖負責互斥協調,資料正確性要由資料模型與交易邊界保護,讀寫兩端要納入同一套一致性設計、而非只鎖寫端。
用單純的 DEL 釋放鎖,是最容易在程式碼裡漏掉的一個錯誤。租約到期後鎖可能已被別人取得,盲目 DEL 會誤刪他人的鎖、讓互斥瓦解。釋放一律要走 token 比對的條件刪除。
把 Redlock 或多節點鎖當成正確性保證,是第二個誤區。多節點冗餘提升的是「鎖不會因單點故障遺失」的可用性,不是「持鎖者暫停不會造成雙寫」的正確性。需要正確性時,fencing token 或下游條件寫入才是真正的防線,鎖只是減少競爭。
把租約時間固定為常數,也會在流量波動下放大風險。租約太短會在正常工作未完成時就過期、引發雙持鎖;太長則讓 crash 的持鎖者長時間卡住鎖。租約策略需要和任務耗時分布與錯誤模型一起校準,長尾工作要靠 watchdog 續租而非把租約一律設大。
情境回寫
分散式鎖失效回寫到真實服務時,最常見的形狀是排程任務的重複執行。一個跨多節點部署的對帳 job 用 distributed lock 確保同一批次只有一個節點處理;當持鎖節點發生長暫停、租約到期被另一節點接手,而暫停節點醒來後仍繼續寫入時,同一批對帳被執行兩次。回寫時先判讀鎖失效來自時序漂移、網路分割還是續租策略,再決定防線往哪裡補。
這個形狀支撐的是「互斥語意在異常下失效」的判讀。若任務本身 idempotent,重複執行只是浪費資源;若會產生重複副作用(重複出帳、重複通知),正確性不能靠鎖,要靠下游的 fencing token 或唯一約束。高風險路徑可接到 6.20 Experiment Safety Boundary 做故障演練。
跨模組路由
- 與 2.2 的交接:鎖搭配失效策略回到 cache aside 與失效策略。
- 與 1.3 的交接:高價值資料一致性回到 transaction 與一致性邊界。
- 與 6.20 的交接:鎖失效演練與停損條件回到 Experiment Safety Boundary。
- 與 8.19 的交接:鎖衝突與回退判斷回到 Incident Decision Log。
- 與 2.6 的交接:split brain 與鎖失效的弱點可從威脅建模角度重新盤點,回到 快取威脅建模。
下一步路由
要看快取層一致性與容量壓力,接著讀 2.3 TTL 與 eviction。要看鎖語意在事故裡的擴散方式,接著讀 2.C9 反例。