分散式鎖 (Distributed Lock)

一句話定位

分散式鎖是讓多台機器/多個服務實例對同一份共享資源達成「同一時間只有一個人能操作」的協調機制 —— 等同於把單機的 mutex 拉到網路上。
它的本質是一個取捨而非萬用解:核心難點不是「怎麼上鎖」(一行 SET NX EX 就能拿鎖),而是「持鎖節點當機時怎麼安全釋放」與「GC pause / 網路延遲讓鎖過期但節點以為自己還握著鎖」。面試最有說服力的回答永遠是先問「真的需要鎖嗎?」。

為什麼需要分散式鎖

單機程式用 synchronized / mutex 就能保證互斥;一旦系統擴展到多台機器、多個微服務,記憶體鎖跨不了 process,共享資源(DB row、檔案、限流 API、外部資源)就會被多個實例同時讀寫:

無鎖(race condition):
  Instance A  讀 stock=1 ──┐
  Instance B  讀 stock=1 ──┤  兩者都看到「還有 1 雙鞋」
  Instance A  賣出, stock=0 │
  Instance B  賣出, stock=0 │  ← 超賣!同一雙鞋賣給兩個人
沒有協調會發生 後果
多個 Writer 同時改同一行 Lost Update(一個覆蓋另一個的變更)
Consumer 收到重複訊息 重複處理(double processing)
多台節點同時跑同一個 cron 重複扣款 / 重複發報表,浪費資源
分散式鎖 vs Leader Election

分散式鎖可以看成「簡易版 Leader Election」:如果你只是要保證「同一時間只有一個實例跑某個任務」,鎖比完整的 leader election(Raft/Paxos 選主)輕量得多。但若要做進階叢集協調(成員管理、配置分發、watch 變更),就該用真正的 leader election(見 09-Messaging-Coord/05-Zookeeper)。


常見面試場景

場景 鎖保護什麼 鎖持續時間
電商結帳 結帳期間保留限量商品(球鞋) 結帳視窗(~10 分鐘)或付款完成
叫車媒合 鎖定一位司機,避免被配給多名乘客 直到司機接受/拒絕或逾時
分散式 Cron 每日彙總報表只由一台機器跑 任務執行期間
線上競標 最後幾秒鎖商品處理出價、更新最高價 單次出價處理(極短)

觀察共通點:鎖都是短暫、有界、必須有自動釋放。沒有一個場景讓鎖「永久持有」。


基本運作流程

核心邏輯其實很簡單,難的在第 5 步:

1. 請求鎖    節點判斷需要獨佔某資源 → 向鎖管理者(Redis / ZooKeeper / DB)請求
2. 嘗試取得  沒人持鎖 → 建立鎖、擁有資源
             已有人持鎖 → 依策略「等待 / 失敗立即返回 / 輪詢重試」
3. 臨界區    取得鎖後做必須獨佔的操作(改 DB row、寫檔、呼叫限流 API)
4. 正常釋放  完成後刪鎖(DEL key),讓下一個候選者能拿
5. 異常釋放  ★關鍵★ 節點當機/Session 斷 → TTL 過期或臨時節點機制「自動釋放」
                                              避免「殭屍鎖(zombie lock)」永久阻塞
取鎖成功路徑                          取鎖失敗路徑
─────────────                        ─────────────
  Client                              Client
    │ acquire(key, ttl)                 │ acquire(key, ttl)
    ▼                                   ▼
 [Lock Manager] key 不存在?  ── 是 ──> 建立 key+TTL, 回 OK   key 已存在 ── 回 FAIL
    │                                   │
    │ 進入臨界區                         ├─► 等待 / 重試 (backoff)
    │ ...做事...                         └─► 或直接放棄
    │
    │ release(key)  (只刪自己的鎖!)
    ▼
  完成 / 或 TTL 到期自動釋放
釋放鎖必須「驗證擁有者」

第 4 步直接 DEL lockKey 有嚴重 bug:若 A 的鎖已 TTL 過期、B 已拿到鎖,A 才慢吞吞跑來 DEL刪掉了 B 的鎖。正解:拿鎖時寫入唯一 value(如 UUID / token),釋放時用 Lua script 原子地「比對 value 相符才刪」(check-and-delete),否則不刪。


四種實作方式對照

方式 一致性保證 自動釋放機制 額外基礎設施 適用
Redis(TTL) 弱(AP,可能丟鎖) TTL 到期 需要 Redis 已有 Redis、可容忍極端情況失誤
ZooKeeper / etcd(臨時節點) 強(CP,多數派 commit) Session 斷則節點消失 需要 ZK/etcd 叢集 需強一致、叢集協調
資料庫鎖(row / advisory) 強(交易語意) Commit/Rollback 或連線斷 無(用現有 DB) 單一 DB、單 region
K8s replicas: 1 N/A(根本沒競爭) N/A 簡單、不追求高可用

1. Redis 鎖(基於 TTL)

最常見、整合成本最低。靠 Redis 的原子操作 + TTL

# 取鎖:NX = 只有 key 不存在才設;EX = 設 TTL 秒;value 寫唯一 token
SET lockKey <uuid> NX EX 30        # 回 OK → 取得;回 nil → 已被持有

# 釋放:用 Lua 原子比對 token 才刪(不能裸 DEL)
if redis.call("GET", KEYS[1]) == ARGV[1]
then return redis.call("DEL", KEYS[1]) else return 0 end

2. ZooKeeper / etcd(臨時節點)

設計為強一致(CP)的分散式 key-value,支援臨時節點(Ephemeral Node)

取鎖 = 建立臨時 znode /lock/resource-123
  ├─ 節點存在 → 有人持鎖
  ├─ Client 離線 / Session 失效 / 當機 → znode 自動被清除(鎖自動釋放)
  └─ 其他候選者 watch 該 znode,消失時被通知 → 嘗試重取

進階:sequential ephemeral node 排隊(避免 herd effect)
  /lock/req-0001 (持鎖) ← /lock/req-0002 (watch 0001) ← /lock/req-0003 (watch 0002)

因為資料在多節點間 replicate 並走共識協定,網路分割 / 節點失效時提供更強一致性。代價是要維運 ZK/etcd 叢集,且延遲比 Redis 高。詳見 09-Messaging-Coord/05-Zookeeper

3. 資料庫鎖(Advisory / Row Locks)

系統若已有單一 DB(Postgres / MySQL),可直接用 DB 的鎖機制,不必引入新基礎設施

類型 語法 行為
Row-Level Lock SELECT ... FOR UPDATE 交易內鎖住該行,直到 Commit/Rollback
Advisory Lock Postgres pg_advisory_lock() / MySQL GET_LOCK() 命名鎖,用自訂識別符管理,與資料行解耦

缺點:只適合單一 DB 範圍,跨 DB / 多 region 擴展性差;長交易持鎖會傷併發。詳見 05-Database-Advanced/01-Transactions12-Ops-and-Reliability/01-Dealing-With-Contention

4. Kubernetes 單實例(避免鎖)

有時根本不需要鎖 —— 只要確保沒有多個實例在競爭

spec:
  replicas: 1 # 只有一個 Pod 在跑 → 沒有競爭,問題消失
這不是分散式鎖

這只是營運層級的限制,而且你放棄了水平擴展與高可用(Pod 重啟期間任務中斷)。適合簡單、低可用要求的場景,不適合需要高可用的系統。


常見陷阱與最佳實踐

陷阱 說明 對策
死鎖(lock ordering) A 持鎖1 等鎖2,B 持鎖2 等鎖1 → 互卡 全系統統一順序取多個鎖;設計交易邊界
鎖競爭(contention) 太多服務搶同一鎖 → 整體變慢 臨界區縮到最小、分片 / 細粒度鎖
殭屍鎖(zombie lock) 持鎖節點當機沒釋放 → 永久阻塞 一律用 TTL 或臨時節點,合理 timeout
鎖管理者單點故障 單一 Redis 掛 → 鎖全失效 Redis Cluster / Sentinel、多節點 ZK/etcd
時鐘偏差 / 網路分區 Clock skew + partition 破壞鎖安全(CAP) Fencing token(見下);接受極端下的不一致
「持鎖節點崩潰是常態,不是例外」

你的設計必須假設失敗會發生ephemeral(隨 session 消失)或 time-based(TTL 到期)的自動釋放是標準設計,不是選配。沒有自動釋放的鎖 = 一個遲早會卡死系統的定時炸彈。


Fencing Token(最常被深挖、源碼沒講清楚的點)

光有 TTL 不能保證互斥。經典反例(Martin Kleppmann 對 Redlock 的批評):

時間軸 ─────────────────────────────────────────────►
Client 1: 取得鎖 ──[長 GC pause / stop-the-world]── 醒來, 以為還握著鎖 → 寫入!
                              │
鎖 TTL 到期 ──────────────────┘
Client 2:                     取得鎖 → 寫入
                              ▲
                       此刻「兩個 client 都認為自己持鎖」→ 資料毀損

問題核心:鎖過期是鎖管理者那邊的事,但 Client 1 的程式在 pause 後完全不知情。GC pause、CPU 飢餓、網路延遲都會造成這個窗口。

解法 = Fencing Token:鎖管理者每次發鎖附帶一個單調遞增的 token,被保護的資源(DB / 儲存)拒絕比已見過 token 更小的寫入

Client 1 拿到 token=33 ─ pause ─ 醒來 write(token=33)  → 資源已見過 34,拒絕! ✓
Client 2 拿到 token=34 ─ write(token=34)               → 接受

面試話術

最有說服力的回答不是「我加一個 Redis 鎖」

而是分三層展開:①「我先問這裡真的需要鎖嗎,能不能用單一寫入者 / 冪等設計避開」②「需要的話,這是效率鎖還是正確性鎖?效率鎖用 Redis TTL,正確性鎖要 fencing token 或 ZooKeeper」③「失敗怎麼處理 —— 一律配 TTL / ephemeral 自動釋放,釋放走原子 check-and-delete」。

鎖往往是「設計警訊」

看到需要分散式鎖時,先反問:為什麼會走到要鎖?能否用單寫入者模型(用 05-Database-Advanced/02-Sharding 把同一資源永遠路由到同一節點)或事件冪等設計12-Ops-and-Reliability/03-Reliable-Delivery)來消除競爭?鎖該是審慎選擇,不是直覺反應。


常見 Deep Dive


自我測驗重點

問題 重點
為什麼需要分散式鎖 跨機器/微服務的共享資源互斥;單機 mutex 跨不了 process
取鎖一行指令 SET lockKey <uuid> NX EX <ttl>(NX=不存在才設、EX=TTL)
釋放鎖的坑 不能裸 DEL;Lua 原子 check-and-delete(比對 token)
TTL 的作用 持鎖節點當機時自動釋放,避免殭屍鎖
TTL 仍不夠的原因 GC pause 讓鎖過期但節點以為還持鎖 → 需 fencing token
Fencing token 單調遞增 token,資源拒絕較舊 token 的寫入
四種實作取捨 Redis(AP/簡單) / ZK·etcd(CP/強一致) / DB鎖(單DB) / replicas:1(避開)
K8s replicas:1 不是真鎖,是營運限制,放棄水平擴展
效率鎖 vs 正確性鎖 效率→Redis 夠;正確性→fencing + 共識儲存
第一個該問的問題 「真的需要鎖嗎?」能否用單寫入者 / 冪等避開