分散式鎖 (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 | 重複扣款 / 重複發報表,浪費資源 |
分散式鎖可以看成「簡易版 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
- TTL 防死鎖:持鎖節點當機,鎖會自動過期,不會永久卡住。
- 需要更多時間 → 在 TTL 內「續租(extend TTL)」,但必須確認自己仍是擁有者。
- 缺點:單 Redis 是單點;在網路分裂 / Redis Cluster failover(master 掛、replica 升主但鎖還沒同步過去)時可能同時兩個節點都認為自己持鎖。
- Redlock:跨多個獨立 Redis 節點取鎖、要過半數才算成功,企圖加強保證 —— 但本身有爭議(見下方 Deep Dive)。
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-Transactions、12-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) → 接受
Kleppmann 指出:Redlock 依賴時鐘與 timeout 的假設,在 GC pause / 時鐘跳動 / 網路延遲下無法保證互斥,且 Redlock 不天然提供 fencing token。Redis 作者 Salvatore 反駁稱實務上夠用、可用 Redis 的遞增值當 token。面試結論:
- 鎖只用來做「效率最佳化」(避免重複做事、省資源)→ Redis TTL 鎖夠用,偶爾失誤可接受。
- 鎖用來保證「正確性」(絕不能兩個人同時寫)→ 必須有 fencing token,且最好搭配能驗證 token 的儲存層,或用提供共識的 ZooKeeper/etcd(它的
zxid/ etcdrevision天然單調遞增可當 fence)。
面試話術
而是分三層展開:①「我先問這裡真的需要鎖嗎,能不能用單一寫入者 / 冪等設計避開」②「需要的話,這是效率鎖還是正確性鎖?效率鎖用 Redis TTL,正確性鎖要 fencing token 或 ZooKeeper」③「失敗怎麼處理 —— 一律配 TTL / ephemeral 自動釋放,釋放走原子 check-and-delete」。
- 電商結帳:Redis
SET NX EX鎖住 SKU 10 分鐘,付款完成或逾時釋放;這是效率/體驗鎖,超賣的最終防線仍是 DB 的SELECT ... FOR UPDATE庫存扣減。 - 分散式 cron:Redis 鎖讓每日報表只一台機器跑;偶爾沒跑到下次補即可 → 效率鎖足夠。
- 金流唯一寫入:要正確性 → fencing token + DB 拒絕舊 token,或乾脆用冪等鍵讓重複寫安全。
看到需要分散式鎖時,先反問:為什麼會走到要鎖?能否用單寫入者模型(用 05-Database-Advanced/02-Sharding 把同一資源永遠路由到同一節點)或事件冪等設計(12-Ops-and-Reliability/03-Reliable-Delivery)來消除競爭?鎖該是審慎選擇,不是直覺反應。
常見 Deep Dive
不能裸 DEL。取鎖時把 value 設成唯一 token(UUID),釋放時用 Lua script 原子地「GET 比對 == 我的 token 才 DEL」。否則 A 的鎖過期、B 拿到後,A 的 DEL 會誤刪 B 的鎖,連鎖崩潰。
兩條路:① 續租(lease extension)——背景 watchdog 在 TTL 過半時延長 TTL(須確認仍是擁有者),Redisson 的 watchdog 就是這樣;缺點是若 client 卡死 watchdog 也卡死,反而幫倒忙。② 把 TTL 設成「安全上界 + fencing token」,寧可讓鎖過期被別人接手,靠 fencing 保證舊持有者寫不進去。正確性場景永遠選 ②。
Redlock 跨 N 個獨立 Redis master 取鎖、要 ⌈N/2⌉+1 過半才算成功,比單 Redis 更耐單點故障。但 Kleppmann 指出它在 GC pause / 時鐘問題下仍無法保證正確性,且增加複雜度與延遲。實務建議:多數場景單 Redis + Sentinel 已足夠(效率鎖);要正確性就上 ZooKeeper/etcd 並用 fencing。不要為了「技術純度」硬上 Redlock。
單點 Redis = 單點故障。緩解:Redis Sentinel / Cluster(但 failover 有丟鎖風險)、或本身就 HA 的 ZooKeeper/etcd(多節點共識,少數節點掛仍可服務)。同時 client 端要有取鎖逾時 + 降級策略(拿不到鎖時 fail fast 或排隊,別無限等)。
同一個 DB 內的競爭,優先用 DB 自己的機制:悲觀鎖 SELECT ... FOR UPDATE、或樂觀鎖(version 欄位 + CAS),比外部分散式鎖簡單可靠。只有當共享資源跨多個服務 / 不在同一 DB(例如協調外部 API、跨 service 的 cron)時,才需要外部分散式鎖。見 12-Ops-and-Reliability/01-Dealing-With-Contention。
鎖管理者要在「安全(絕不兩人同時持鎖)」與「活性(節點掛了還能拿鎖)」之間選。ZooKeeper/etcd 偏 CP(partition 時少數派拿不到鎖,保安全);Redis 偏 AP(高可用但 failover 可能短暫兩人持鎖)。沒有魔法 —— 選哪邊取決於你要效率還是正確性。見 02-Distributed-Systems/02-CAP-Theorem。
自我測驗重點
| 問題 | 重點 |
|---|---|
| 為什麼需要分散式鎖 | 跨機器/微服務的共享資源互斥;單機 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 + 共識儲存 |
| 第一個該問的問題 | 「真的需要鎖嗎?」能否用單寫入者 / 冪等避開 |
Related Notes
- 09-Messaging-Coord/05-Zookeeper — 臨時節點 + 共識,強一致鎖與 leader election 的標準工具,
zxid天然可當 fencing token - 12-Ops-and-Reliability/01-Dealing-With-Contention — 鎖是處理競爭的手段之一;悲觀/樂觀鎖、縮小臨界區的取捨
- 05-Database-Advanced/01-Transactions — 同一 DB 內優先用交易 /
SELECT ... FOR UPDATE,比外部鎖簡單 - 05-Database-Advanced/03-Replication — Redis failover / 多數派 commit 與 fencing token 的關聯
- 05-Database-Advanced/02-Sharding — 單寫入者模型:把同一資源路由到同一節點即可免鎖
- 07-Caching-Storage/04-Redis — Redis 原子操作 + TTL 是最常見的鎖實作載體
- 02-Distributed-Systems/02-CAP-Theorem — 鎖管理者在安全(CP)與活性(AP)之間的本質取捨
- 12-Ops-and-Reliability/03-Reliable-Delivery — 冪等設計常可取代鎖,避免重複處理