分散式鎖、租約與 fencing token
好幾台機器搶同一個資源時要一把鎖,但持鎖的那台可能暫停、斷線或時鐘跳掉:租約讓鎖會自動過期,fencing token 則讓舊的持鎖者回來時,它的寫入會被拒絕。
5 s
8 s
寫入被接受過期寫入被接受:資料被蓋壞被防護令牌擋下
A 失去租約
是
從 A 停下到 B 接手
4.5 s
被接受的過期寫入
1
被令牌擋下
0
A 在 4 s 停住。租約在 8.33 s 到期;B 在 8.5 s 拿到鎖、令牌 35,開始寫入。12 s A 醒來,仍然以為自己拿著鎖——停住之前確認過——把做到一半的寫入送出去,帶著令牌 34。 儲存端分辨不出來,照單全收、蓋掉了 B 的資料:1 筆過期寫入成功落地。鎖本身一直運作正確;出問題的是一個不知道自己已經失去鎖的持有者。
模型:鎖服務本身不會壞、時鐘準確;用戶端每三分之一個租約續約一次、每 1 s 寫一次;B 每 500 ms 試著拿鎖。訊息瞬間送達。停住時 A 完全停擺,察覺不到時間流逝。
亮起來的是這一步執行的程式碼
class LockService { holder: string | null = null; expiresAt = 0; private nextToken = 33; acquire(client: string, now: number, leaseMs: number) { if (this.holder !== null && now >= this.expiresAt) { this.holder = null; } if (this.holder !== null) return null; // someone holds it this.holder = client; this.expiresAt = now + leaseMs; this.nextToken += 1; return { token: this.nextToken, expiresAt: this.expiresAt }; } renew(client: string, now: number, leaseMs: number): boolean { if (this.holder !== client || now >= this.expiresAt) { if (this.holder === client) this.holder = null; return false; } this.expiresAt = now + leaseMs; return true; }} class FencedStorage { maxToken = 0; value = ""; write(token: number, value: string): boolean { if (token < this.maxToken) return false; this.maxToken = token; this.value = value; return true; }}模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 鎖服務不故障且時鐘準確,訊息瞬間到達;A 停住時不能續約。Fencing 只在儲存端檢查已見的最大 token,未模擬共識、複寫與跨儲存服務的原子副作用。
小挑戰
租約固定 5 秒,A 停住 8 秒。讓 B 接手,同時阻擋 A 醒來後的舊令牌寫入。
過期寫入被接受
1
調整選項,再檢查結果。
提示
A 停住時不能續約。取得鎖與儲存端接受寫入,是兩個不同的判斷。
查看解答
在儲存端啟用 fencing token 檢查,拒絕小於已見令牌的寫入。
什麼時候用
- 為了效率:避免兩台機器重複做同一件昂貴但無害的事(重算報表、寄同一封信)。偶爾重複也沒關係,一個簡單的租約鎖就夠。
- 為了正確性:兩個持有者同時寫會毀掉資料的時候。這時光有鎖不夠——鎖要發遞增的防護令牌(fencing token),被保護的資源要拒絕比看過的還舊的令牌。
- 能不用鎖就不用:資料庫的條件更新(compare-and-set)、唯一索引、單一寫入者的佇列,往往比分散式鎖簡單又安全。
和其他主題的關係
- 由這些組成
- Raft 共識與領導者選舉
出現在這些架構裡
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 取得鎖 鎖服務若靠共識,還要加上它的一次提交 | 1 RTT | 1 RTT |
| 續約 每個租約週期做幾次 | 1 RTT | 1 RTT |
| 儲存端檢查令牌 比較一個數字 | O(1) | O(1) |
| 持有者當機後換手 沒人能替死掉的持有者解鎖,只能等租約到期 | ≈ lease | lease |
空間:O(1),每把鎖:持有者、到期時間、下一個令牌;儲存端每份資源記一個最大令牌
和其他做法比
| 持有者當機後的等待 | 停頓到失去租約 | 沒有令牌:資料被蓋壞 | 有令牌:資料被蓋壞 | |
|---|---|---|---|---|
| 租約 2 s | 2.06 s | 24 / 600 | 21 | 0 |
| 租約 5 s | 4.47 s | 19 / 600 | 16 | 0 |
| 租約 10 s | 8.69 s | 9 / 600 | 8 | 0 |
| 租約 30 s | 25.17 s | 0 / 600 | 0 | 0 |
當機的等待是在一個續約週期內六個不同時間點當機的平均。停頓抽自 600 次:95% 是平均 30 ms 的短停頓,5% 是 0.5–20 秒的長停頓(記憶體回收、換頁、虛擬機器搬移)。失去租約的停頓不一定蓋壞資料——B 還沒寫過,舊寫入就沒有東西可蓋。租約只能在「當機時等多久」和「長停頓時出錯幾次」之間取捨,令牌才讓出錯變成 0。
真實世界裡的它
- ZooKeeper 的臨時節點+序號、etcd 的 lease 與 revision:revision 單調遞增,可以直接當防護令牌。
- Google 的 Chubby 鎖服務發「sequencer」給持有者,下游伺服器據此拒絕過期的請求。
- Redis 的 SET key value NX PX 常被拿來當鎖;Redlock 演算法的爭議就在於它不提供防護令牌、又依賴時鐘。
取捨與陷阱
- 持有者不知道自己已經失去鎖:記憶體回收、虛擬機器暫停、網路延遲都能讓它在「確認持有」和「寫入」之間停住。不管程式裡檢查得多仔細,這段空隙都在。
- 令牌要被資源本身檢查:只在用戶端比較令牌沒用;必須是儲存端(或下游服務)記住看過的最大令牌、拒絕更小的。
- 租約只靠時間:鎖服務和持有者的時鐘走速不同,持有者以為還有 2 秒,服務端可能已經判定到期。續約要留足餘裕。
- 鎖服務本身也得高可用又不分裂:單一 Redis 掛了,鎖就沒了;主從切換時還可能同時發出兩把鎖。正確性用途要選基於共識的服務。