跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 分散式鎖、租約與 fencing token

分散式鎖、租約與 fencing token

好幾台機器搶同一個資源時要一把鎖,但持鎖的那台可能暫停、斷線或時鐘跳掉:租約讓鎖會自動過期,fencing token 則讓舊的持鎖者回來時,它的寫入會被拒絕。

5 s
8 s
鎖在誰手上用戶端 A用戶端 B儲存端A · 34B · 35停住0.5 s · A 帶令牌寫入 34 · 接受1.5 s · A 帶令牌寫入 34 · 接受2.5 s · A 帶令牌寫入 34 · 接受3.5 s · A 帶令牌寫入 34 · 接受8.7 s · B 帶令牌寫入 35 · 接受9.7 s · B 帶令牌寫入 35 · 接受10.7 s · B 帶令牌寫入 35 · 接受11.7 s · B 帶令牌寫入 35 · 接受12 s · A 帶令牌寫入 34 · 接受12.7 s · B 帶令牌寫入 35 · 接受13.7 s · B 帶令牌寫入 35 · 接受14.7 s · B 帶令牌寫入 35 · 接受15.7 s · B 帶令牌寫入 35 · 接受0s2s4s6s8s10s12s14s16s
寫入被接受過期寫入被接受:資料被蓋壞被防護令牌擋下
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)、唯一索引、單一寫入者的佇列,往往比分散式鎖簡單又安全。

和其他主題的關係

出現在這些架構裡

時間與空間複雜度(Big O)

操作平均最差
取得鎖
鎖服務若靠共識,還要加上它的一次提交
1 RTT1 RTT
續約
每個租約週期做幾次
1 RTT1 RTT
儲存端檢查令牌
比較一個數字
O(1)O(1)
持有者當機後換手
沒人能替死掉的持有者解鎖,只能等租約到期
≈ leaselease

空間:O(1),每把鎖:持有者、到期時間、下一個令牌;儲存端每份資源記一個最大令牌

和其他做法比

持有者當機後的等待停頓到失去租約沒有令牌:資料被蓋壞有令牌:資料被蓋壞
租約 2 s2.06 s24 / 600210
租約 5 s4.47 s19 / 600160
租約 10 s8.69 s9 / 60080
租約 30 s25.17 s0 / 60000

當機的等待是在一個續約週期內六個不同時間點當機的平均。停頓抽自 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 掛了,鎖就沒了;主從切換時還可能同時發出兩把鎖。正確性用途要選基於共識的服務。