01

總覽與定位

共識被產品化之後,對外提供什麼原語,以及應用端要怎麼正確使用它們——這是本篇唯一的主軸。

ZooKeeper、etcd、Consul 這類協調服務,底層就是 Leader Election 與 Raft 那篇講的 replicated log:寫入要付多數派往返的代價,集群通常只有三到五台。產品化之後,應用端看到的不是 term 與 AppendEntries,而是一組小而強一致的原語:階層或扁平的 KV、Watch、Session / Lease、以及用這些積木拼出來的成員探活、設定下發、Leader 選舉與分散式鎖。

Leader Election 與 Raft 共識負責協定本身(誰編號、何時可提交、fencing token 為什麼必須是單調世代號)。本篇站在協定上面一層:原語的語意、以及用錯原語時會壞在哪。Redis 的複寫、持久化與 Cluster slot 是 Redis 架構與運維 的事,這裡只借 Redlock 對照鎖語意。2PC / TCC / Saga 的補償理論交給 分散式交易;長連線叢集的 Connection Registry 交給 即時連線擴展;DNS 解析與快取交給 DNS 深入;分片路由表本身交給 資料分片實務。

1 · 原語層
KV · Watch · Session / Lease
線性一致但容量極小。znode 對照 Lease 與 revision。心跳定義「這個成員還活著」,不是牆鐘。
2 · 典型用途
Discovery · Config · Campaign
註冊、租約續期、設定 Watch、用最小 sequential node 或 etcd campaign 當選。都是元資料,不是業務資料面。
3 · 鎖的正確性
Lease + Fencing Token
GC pause 會讓過期持有者醒來寫入。唯一防線是被保護資源自己檢查單調 token。Redlock 過不了這關。
座標協調服務是控制面,不是資料面

把使用者訂單、訊息、向量索引放進 etcd,等於用三台共識節點扛資料平面的 QPS——延遲被多數派往返綁死,compaction 與 Watch 風暴隨資料量一起爆炸。正確的用法是:業務資料放在為它設計的儲存裡,協調服務只保存「誰是主、配置版本、鎖、成員名單」這種必須線性一致、且變更頻率低的元資料。

一句話答法協調服務把共識做成小容量線性一致 KV、Watch 與租約;應用拿這些原語做成員、設定與鎖,不要拿它當資料庫。
02

線性一致的小 KV:為什麼只放元資料

每次寫入都是一筆共識 log。容量小、延遲高、正確性強——這三件事綁在一起。

線性一致(linearizable)的意思是:寫入一旦成功回覆,之後任何客戶端的讀都看得到它,且所有操作有一個與真實時間先後順序(real-time order)相容的全序。這份保證來自底層那條 replicated log,所以每一次寫入都要多數派持久化。讀若也要線性一致,多數實作會走 Leader(或 quorum 讀),一樣付往返代價。這就是「為什麼它很慢、也很貴」的全部原因,不必再展開投票與 log 複寫。

協調服務 KV 業務資料庫 / 快取
一致性 線性一致(寫入走共識) 依引擎:從最終一致到單分片線性
容量與 value 大小 集群總量通常 GB 級;單 key 建議 KB、硬上限約 1–1.5 MB TB–PB;value 可以是列、文件、blob
寫入成本 多數派往返;集群 3–5 台 可分片、可異步、可本地提交
適合放什麼 成員名單、配置、鎖、選舉、schema 版本 使用者資料、事件、快取、搜尋索引

ZooKeeper 預設 znode 上限約 1 MB;etcd 請求上限約 1.5 MB,官方建議 value 遠小於這個數字。硬上限不是設計目標,是保險絲。

把熱路徑寫進協調服務有兩種常見走法,都會在流量上來時爆:

  • 每個請求改一個 key:QPS 直接等於共識提交 QPS,延遲下限是多數派 RTT。
  • 把大 blob 塞進單一 znode / key:每次 Watch 觸發都把整份 blob 拉回,序列化與網路被放大;etcd 還要為每次寫入保留 MVCC 歷史,直到 compaction。
陷阱「反正它強一致,當主庫用更省事」

強一致換來的是全域單一 log 的提交延遲,不是免費的正確性。業務表有自己的分片與交易模型;需要跨列原子性時走資料庫約束或分散式交易,不要把協調服務當成「比較強的 Redis」。面試把 etcd 講成「有持久化的快取」會直接失分。

一句話答法協調服務的 KV 線性一致,是因為每次寫入都進共識 log。所以只放必須全序的元資料,容量與 QPS 都要當控制面來預算。
03

原語:znode、Lease、revision、Session、Watch

兩套產品、同一組語意:一個有生命週期的節點、一個單調版本、一次一次性的通知。

1 · ZooKeeper 的 znode

ZooKeeper 把命名空間做成檔案系統形狀的樹。每個 znode 可帶一小段資料,並有三種生命週期修飾:

  • Persistent:客戶端斷線後仍在。適合配置、schema。
  • Ephemeral:綁定建立它的 Session;Session 過期,節點消失。適合「我還活著」的註冊。
  • Sequential:建立時由集群附加單調序號(/lock/guid-0000000042)。適合排隊與選舉——序號是集群給的全序,不是客戶端時鐘。

Ephemeral 與 Sequential 可疊加:臨時順序節點是鎖與選舉最常用的積木。

2 · etcd 的 Lease、revision、MVCC

etcd 是扁平 KV(用前綴模擬目錄)。每次寫入讓全域 revision 加一,每個 key 記住自己的 create_revision 與 mod_revision。這份 MVCC 歷史讓你用「revision 當版本號」做 CAS,不必另發明樂觀鎖:

txn: if mod_revision(key) = 期望 then put(key, val) else 失敗

Lease 是 etcd 的生命週期原語:key 可以掛在一張租約上,客戶端定期 keepalive;租約過期,掛上的 key 被刪除。這對應 ZooKeeper 的 Ephemeral,但把「生命週期」從節點類型抽成可複用的租約物件——一張 lease 可以綁多個 key。

歷史不會無限留:compaction 丟掉小於某 revision 的舊版本。Watch 若從一個已被壓縮的 revision 追,會收到 compact 錯誤,必須從目前 revision 重訂閱。把 etcd 當「無限時間旅行」會在 compaction 週期一到就破功。

ZooKeeper · 階層 znode 樹
生命週期是「節點的一種型別」
/
config/persistent
feature-flags
services/search/
inst-aephemeral
inst-bephemeral
lock/
guid-0000000041seq
guid-0000000042seq
Session 一過期,掛在它底下的 ephemeral 節點整批消失;seq 的後綴由集群分配,天然是一條排隊序。注意序號計數器存在父節點上,父節點被刪除重建後會歸零。
etcd · 扁平 KV + 可共用的 Lease
生命週期被抽成一個獨立物件
/config/feature-flagsmod_rev 108
/services/search/inst-amod_rev 233lease 7f3
/services/search/inst-bmod_rev 240lease 7f3
/lock/guidcreate_rev 241lease a01
Lease 7f3 · TTL 10s keepalive 續約 → 綁在它身上的兩個註冊 key 一起存活;停止續約 → 兩個一起被刪除。鎖用的是另一張 lease a01——鎖的生命週期不該跟服務註冊綁死(否則註冊掉線就丟鎖,反之亦然),etcd 的 concurrency package 也是對每把鎖開獨立 session。
沒有目錄,只有前綴慣例。排隊序不靠後綴而靠全域 revision:每次寫入讓它加一,create_revision 最小的就是隊首。CAS 直接用 mod_revision 做條件交易,不必另外發明樂觀鎖。

左邊的縮排就是真實的路徑階層,右邊沒有目錄、只有前綴慣例。兩邊的 ephemeral / lease 標記指向同一件事:這個 key 的壽命綁在一個會停止心跳的 Session 上。

語意 ZooKeeper etcd
資料模型 階層 znode 樹 扁平 KV + 前綴
生命週期 Ephemeral znode 綁 Session Key 綁 Lease,keepalive 續約
排隊 / 全序號 Sequential node 的後綴 寫入時的全域 revision
CAS version 欄位 + 條件 set mod_revision 條件交易
歷史 幾乎不保留舊值 MVCC 直到 compaction

面試不必背 API 名稱。要能對上「生命週期綁在誰身上」與「誰發給你一個單調號」。

3 · Session、心跳,以及「還活著」的定義

協調服務不靠牆鐘判斷成員存活。客戶端與集群之間維持 Session:在超時窗口內收到心跳(ZooKeeper ping / etcd keepalive),Session 有效;超時則 Session 過期,所有 ephemeral 節點或掛在該 lease 上的 key 被刪。這個定義有兩個工程後果:

  • 超時是集群側的契約,不是客戶端本地計時器。客戶端 GC pause、長 IO、被 SIGSTOP,本地以為自己還活著,集群已經宣告死亡。
  • 網路抖動會誤殺。心跳間隔必須遠短於 Session timeout,timeout 又必須長到能吸收 GC 與短暫停滯——這組數字和 Raft 心跳/選舉超時是同一類權衡,只是現在暴露成客戶端 API。
關鍵Watch 是一次性通知,不是變更日誌

ZooKeeper 的 Watch 觸發一次就失效,必須在處理事件後重新註冊。事件 payload 往往只有「發生了什麼類型的變更」,不含完整新值。正確流程永遠是:收到通知 → 重讀目前狀態 → 再掛 Watch。中間有 race:重讀與掛 Watch 之間可能再變一次,所以要接受「多讀一次」或用 etcd 那種從指定 revision 追的 Watch。把事件內容當成真相來源,是生產事故的固定劇本。

一句話答法znode / Lease 綁生命週期,revision / sequential 給單調號,Session 心跳定義存活。Watch 觸發後必須重讀狀態,不能信事件內容。
04

典型用途:發現、設定、選舉原語

三件事共用同一套積木:寫一個會過期的節點,Watch 它的消失或內容變更。

1 · 服務發現與成員探活

實例啟動時在 /services/search/<id> 建 ephemeral znode(或 etcd key + lease),內容是位址與權重;定期續約。掛掉或被殺,Session 過期,節點消失,Watch 的負載均衡器把它從名單拿掉。這就是探活原語的全部:註冊、租約續期、逾時刪除、通知。

到此停住。長連線叢集裡「這個連線現在在哪台機器」的 Connection Registry、以及跨節點 fan-out,是流量面問題,交給 即時連線擴展。用 DNS 做發現時的 TTL、快取與負快取,交給 DNS 深入。分片路由表要怎麼切、怎麼遷移,交給 資料分片實務——協調服務最多保存「目前路由表的版本號與指向」,不負責表本身的設計。

2 · 設定管理與動態下發

配置放 persistent znode / 一般 key,變更走一次線性一致寫入。客戶端 Watch 該 key,被通知後重讀、熱載入。因為寫入 QPS 極低、讀者可以很多,這是協調服務最舒服的負載。陷阱是把「每台機器的功能開關」做成每個實例一個 znode 再讓所有人 Watch 根節點——讀者數 × 變更頻率會變成通知風暴,第 7 節談羊群效應時會再展開。

3 · Leader 選舉原語

協定層的選舉(投票、term、log 新舊)已經由集群自己做完,應用端要的是另一件事:在一組對等行程裡選出一個角色(誰跑 compaction、誰寫外站、誰當排程器)。標準寫法:

  1. 1
    建立 ephemeral sequential 節點
    節點綁在自己的 Session/Lease 上,後綴序號由集群分配(/election/guid-0000000042)。序號是全序、單調、客戶端偽造不了——這正是後面 fencing token 的來源。
  2. 2
    列出同一前綴下的所有兄弟節點
    拿到目前所有競爭者的序號清單。這是一次讀取,不是訂閱——真相永遠來自重讀,不是來自事件內容。
  3. 3
    自己的序號最小 → 當選,進入角色
    當選這件事本身不產生任何保護,它只是讓你取得一個可以對外出示的世代號。
  4. 4
    否則 → 只 Watch 序號剛好比自己小的那一個
    不是 Watch 父節點、也不是 Watch 目前的當選者。只盯前一位,才能讓一次成員變更的通知量維持在 1——第 7 章的羊群效應會展開這件事。
  5. 5
    被叫醒 → 回到步驟 2 重讀,不要直接假設輪到自己
    前一位消失只代表「有人走了」。中間可能有人插隊,或你自己的 Session 也已經過期。標準迴圈永遠是「重讀 → 判斷 → 必要時重掛 Watch」。

etcd 的 campaign / election API 是同一件事的封裝:把一個 key 搶下來掛在自己的 lease 上,其他人 Watch 該 key 的刪除。當選者拿到的 mod_revision(或 ZooKeeper sequential 後綴 / zxid)就是後面談鎖時要用的 fencing token 來源——它單調、由集群分配,客戶端無法偽造一個「比較新」的號。

陷阱「我 Watch 到自己當選」不是副作用的許可證

當選事件只證明「在那個 revision,集群認為你是主」。它不證明你之後寫出的每一筆外部副作用都還有效。Session 可能在你做完一半時過期,繼任者已經上任。角色切換與鎖是同一條正確性鏈:外部資源必須檢查 token,行程在 Session 失效時必須停手。

一句話答法發現 = ephemeral 註冊加續約;設定 = persistent key 加 Watch 重讀;選舉 = 搶最小順序節點或 etcd campaign。都是元資料原語,不是業務路由本體。
05

分散式鎖是正確性問題

鎖 = Lease + Fencing Token。少了後半,GC pause 就能讓過期持有者寫壞被保護資源。

面試把分散式鎖講成「SET NX 加過期時間」會停在 API 使用說明。正確的提問是:持有者在租約已經被集群回收之後,仍可能發出寫入,誰來拒絕它? 答案不能是鎖服務——鎖服務在過期當下已經把鎖給了下一個人;過期持有者的資料包還在網路上或還在 GC 結束後的執行緒裡。

1 · 失敗劇本:凍結,而不是崩潰

崩潰是友善的:行程沒了,心跳停,租約過期,下一個人拿鎖,舊行程不會再寫。危險的是凍結:JVM GC pause、Stop-the-world、長時間 IO、容器被 cgroup 卡住、虛擬機 live migration、debug 附加。本地執行緒醒來後,變數裡還寫著「我持有鎖」,然後對資料庫、物件儲存、檔案系統發出它在凍結前就組好的請求。

凍結持有者的時間軸:A 自認持有鎖的區間超出集群認定的 lease 區間 Client A 鎖持有者 協調服務 etcd / ZK Client B 接任者 被保護資源 外部副作用 t1 A 取得鎖 t2 keepalive t3 GC pause t4 lease 過期 t5 B 取得鎖 t6 B 寫入 t7 A 醒來 t8 判定 臨界區 · keepalive 中 GC pause — 凍結,本地仍寫著「我持有鎖」 醒來寫入 /lock = A · lease 有效 ∅ /lock = B · lease 有效 臨界區 · token=2 lastSeenToken = 0 lastSeenToken = 2 A 已經失去鎖,而且沒有任何辦法知道 token=2 ACCEPT token=1 REJECT

整張圖只講一件事:A 自認持有的區間(上排)比集群認定的 lease 區間(第二排)長,那段差額就是 GC pause。鎖服務在 t4 已經把鎖交給別人,它沒有任何辦法通知一個凍結中的行程。到了 t7 還能對 A 說「不」的,只剩被保護資源自己手上的 lastSeenToken。

2 · Fencing Token 必須由資源檢查

集群在授予鎖時發給一個單調遞增的 token:etcd 用鎖 key 的 mod_revision,ZooKeeper 用該 znode 的 zxid,Raft 當選時用 term(來源在 Leader Election 與 Raft 篇已說明,這裡不重講投票)。ZooKeeper 的 sequential 後綴也常被拿來當 token,但它的計數器存在父節點的 cversion 上,父節點一旦被刪除重建就會歸零——要絕對的單調性,用 zxid 才安全。持有者每次對被保護資源寫入都帶上這個 token。資源記住見過的最大值,拒絕較小者:

accept(write, token) ⟺ token ≥ lastSeenToken;然後 lastSeenToken ← token

拒絕過期寫入是保護成功,不是系統異常。資源把狀態標成錯誤,只該留給「fencing 關掉、過期寫入真的寫進去」那種資料損壞。

只有 Lease / TTL
時鐘與凍結會說謊
  • 持有者本地以為租約還在
  • 集群已經把鎖給下一個人
  • 寫入請求沒有欄位證明「我比較舊」
  • 資源照單全收 → 靜默損壞
Lease + Fencing Token
單調號由資源把關
  • 授予鎖時發給 token,只增不減
  • 資源拒絕 token < lastSeen
  • A 醒來後的寫入進不去
  • 即使 A 還沒意識到自己丟鎖

看兩欄的最後一列:左邊的損壞是靜默的(沒有任何一方收到錯誤),右邊的拒絕則發生在資源端而不是鎖服務——差別只有「寫入請求裡有沒有一個可比大小的單調號」。

關鍵鎖服務無法替資源做 fencing

etcd / ZooKeeper 可以保證「同一時刻只有一個 Session 持有這個 key」,它們看不見你送給 S3、MySQL、HDFS 的請求。token 必須成為那些系統上的一等欄位:條件更新、物件 metadata、或寫入路徑上的顯式檢查。做不到這一點,就不要宣稱你有互斥正確性。

一句話答法分散式鎖的正確性 = 租約界定持有窗口 + 被保護資源拒絕過期的單調 token。少掉後半,凍結行程就能寫壞資料。
06

Redlock,以及鎖丟失後必須停手

多數派獨立 Redis 節點不是一份共識日誌。客戶端偵測到 Session 失效,副作用必須停,不可假設自己仍持有。

1 · Redlock 在對什麼做多數派

Redlock 的流程是:向 N 個彼此獨立的 Redis 實例做 SET key unique NX PX ttl(常見 N = 5),在「過半成功且耗時遠小於 TTL」時視為拿到鎖;釋放時對每台用 Lua 核對 unique 值再刪。它借用了多數派這個詞,但多數派的對象是沒有共用 log 的獨立節點。

Redis 架構與運維已經說明單機原子命令、複寫、Sentinel 與 Cluster slot——那些是資料面。Redlock 刻意把節點當獨立計時器來投票,所以:

  • 沒有 fencing token。各節點同意的只是「我這邊這把鎖還沒過期」,沒有一個單調的集群世代號可以交給資源檢查。
  • 依賴時鐘與逾時假設。任一實例時鐘前跳,TTL 提早到期,另一個客戶端就能在這台上拿到鎖;再湊過半就出現雙持有。
  • 節點崩潰重啟就足以製造雙持有。客戶端 A 從 1、2、3 號拿到鎖;3 號崩潰重啟,而它的持久化是 appendfsync everysec,那把鎖還沒落盤——重啟後它完全不記得發過鎖。此時客戶端 B 可以從 3、4、5 號湊出另一個多數派,兩人同時持有。antirez 的回應是「延遲重啟」(節點重啟後先靜置一個 TTL 再服務),但那等於把正確性再一次押回時鐘假設上。
  • GC pause 同樣破功。過半拿到之後凍結,TTL 在 Redis 側過期,下一個人拿鎖;凍結結束後舊持有者沒有 token 可被拒絕。
陷阱「過半 Redis 同意」不是共識

共識多數派相交的是同一份 log 的副本,所以已提交的決策有 Leader Completeness。五台獨立 Redis 各自維護自己的 TTL,它們的「是」沒有全序、也沒有後續 Leader 必帶的保證。Kleppmann 的兩個核心論點——Redlock 依賴時序假設(時鐘、GC、網路延遲),以及它拿不出 fencing token——在這個結構下都無解;問題從來不是「Redis 不夠快」。需要互斥正確性時,用 etcd / ZooKeeper 的鎖原語並把 token 送到資源;Redis 鎖留給「重複做一次可以接受」的效率問題。

2 · 鎖丟失時客戶端的正確行為

協調服務的 SDK 會在 Session 過期、lease keepalive 失敗、或自己的 ephemeral 被刪時發出連線失效事件。收到這件事之後:

  • 立刻停止副作用。不要把「手上這批寫入做完再停」當成友善收尾——那批寫入就是過期持有者的寫入。
  • 讓整個操作失敗,交由上層重試或補償。不要在本地重搶同一把鎖並「接著上次的 offset 寫」:中間可能已經有人寫過。
  • fencing 是最後防線,不是許可證。資源拒掉過期 token 能保住正確性;客戶端仍應在失效當下停手,否則會製造重試風暴與部分完成的中間態。
錯誤:假設自己仍持有
失效事件被當成可恢復抖動
  • 繼續對資料庫 / 物件儲存寫
  • 本地重試搶鎖,從舊 offset 續寫
  • 把 fencing 拒回當成暫時網路錯誤再送一次
正確:失效 = 操作終止
角色與鎖一併作廢
  • 中止臨界區,回報失敗
  • 需要的話用新 Session 從頭搶鎖
  • 資源側 token 檢查仍開著當保險

看兩欄的第一列就分勝負:左邊把失效事件當成「等一下就好」,右邊把它當成操作終止。右欄最後一列是保險,不是許可證——停手與 fencing 是兩道各自獨立的防線。

一句話答法Redlock 的多數派是獨立 TTL,沒有共識 log 也沒有 fencing token,不能當正確性互斥。Session 失效必須停掉副作用,不可假設自己仍持有鎖。
07

羊群效應與 Watch 設計

全體 Watch 同一節點,一次成員變更就通知風暴。排隊只 Watch 前一位,通知量與等待人數無關。

N 個客戶端都想拿同一把鎖或接任同一個 Leader。最直覺的寫法是所有人 Watch /lock 這個父節點:持有者的 ephemeral 一消失,N 個 Watch 同時觸發,N 個客戶端同時 create,一個成功、N−1 個失敗,失敗者再掛 Watch——下一次還是 N 人一起醒。這就是羊群(herd):通知量與競爭者人數成正比,協調服務的 Watch 推送與寫入 QPS 在故障切換當下被放大 N 倍。

全體 Watch 同一點
通知 = 等待人數
  • 持有者消失 → N 個事件
  • N 次 create,N−1 次衝突
  • 切換延遲與集群負載隨 N 上升
Sequential 排隊
每人只 Watch 前一位
  • 建立自己的 sequential child
  • 列出兄弟,Watch 序號剛好比自己小的那個
  • 前一位消失 → 只有下一個人被叫醒
  • 通知量 = 1,與隊伍長度無關

先看兩欄的最後一列:差別不在穩定狀態,而在通知數對等待人數 N 的關係——左邊是 O(N)(一次切換叫醒所有人),右邊是 O(1)(只叫醒下一位)。

排隊不是免費的。每個競爭者都要建一個 znode / key,成員數很大時仍有建立與刪除成本;但那是 O(1) 每客戶端的固定開銷,不是故障瞬間的 O(N) 驚群。etcd 沒有 Sequential 後綴時,用 create_revision 排序前綴下的 key,只 Watch 前一個 revision,效果相同。

關鍵被叫醒之後仍要重讀,不要信「輪到我了」

Watch 是一次性的。前一位消失,你被叫醒,此時可能已經有人插隊、或你的 Session 自己也過期。標準迴圈是:列出目前兄弟 → 自己是最小的才進入臨界區,否則重新掛 Watch 到新的前一位。事件說「child deleted」只代表有人走了,不代表你現在是 Leader。

互動 · 一次故障切換要付多少通知 扇出度 1 是排隊、等於人數是羊群,中間是分層 fan-out
等待者 N 50 人
喚醒扇出度 k k = 50
每次搶鎖成本 2.0 ms
臨界區持有 100 ms
該輪搶到鎖的人(實心左端) 被白叫醒、create 失敗的人 還沒被通知到的人

一列 = 一次鎖釋放,列寬 = 那一輪被叫醒的人數。整片色塊的面積就是這場切換的總通知數:k = N 時它是一個直角三角形(N(N+1)/2),k = 1 時塌成一條細直柱(N)。羊群的代價不在穩定狀態,而在這張圖的面積。

同一原則用在設定下發:不要讓一萬個實例 Watch 同一個會被頻繁改寫的 key。能接受秒級延遲就改成輪詢或分層 fan-out(中間層 Watch 一次,再對下游做有界推送)。Watch 是為「變更稀少、一致性要求高」準備的,不是通用 pub/sub。

一句話答法不要讓所有等待者 Watch 同一個節點。全體 Watch 的總通知量是 N(N+1)/2,sequential 排隊是 N;每人只 Watch 前一位,單次切換的通知量就是 1。被叫醒仍須重讀狀態。
08

什麼情況根本不該用分散式鎖

先問「沒有鎖會壞什麼」。能靠單一分區、樂觀 CAS、冪等或唯一鍵解決的,不要引入外部鎖服務。

分散式鎖把可用性對準協調服務:它分區、它選舉、它過載,你的臨界區就停。每一次加鎖還多一輪多數派往返。所以預設答案是不用,直到你能說清楚這把鎖是為了效率還是為了正確性。

先考慮 它已經給你什麼 還要鎖嗎
單一分區序列化 Kafka 同一 partition、單一 DB primary、單一 Raft group 內的寫入本就是全序 不要。把互斥推回分區鍵
樂觀併發 列上的 version、etcd mod_revision CAS、條件 PUT 衝突時重讀重試即可
唯一鍵 / 冪等 資料庫 UNIQUE、Idempotency-Key、exactly-once 產出路徑 重複執行不會寫壞,鎖最多省工
單機臨界區 行程內 mutex 就夠 不要為了「看起來分散式」跨出行程

能把衝突變成「一次條件寫入失敗」的,通常比「先搶鎖再寫」更短、更好測、也沒有凍結持有者問題。

效率鎖 vs 正確性鎖

為了效率
偶發雙執行可以接受
  • 快取擊穿時只讓一個人回源
  • 重複算一次報表、多發一次非關鍵通知
  • 可以用 Redis SET NX + TTL,不必 fencing
  • 丟鎖就當沒擋到,系統仍正確
為了正確性
雙執行 = 資料損壞
  • 同一列的資金操作、唯一編號發放
  • 對外部系統發出不可冪等的副作用
  • 必須 Lease + fencing,資源側檢查 token
  • 做不到 fencing 就不要用鎖硬上,改 CAS / 唯一鍵

先問自己落在哪一欄:判準是「雙執行會怎樣」——左邊只是多做一次工,右邊是資料損壞。落在右邊而給不出 fencing token,就該換 CAS 或唯一鍵,不是換一把更快的鎖。

陷阱用鎖補一個沒有唯一約束的資料模型

「先鎖 userId,再 insert 訂單」把正確性寄託在所有寫入路徑都記得搶同一把鎖。漏掉一條管理後台、一條 migration、一個忘了加鎖的新服務,靜默雙寫。唯一鍵與條件更新是資料自己的不變量,不依賴呼叫端的記憶力。鎖可以當快路徑,不能當唯一正確性來源。

一句話答法能靠單一分區、version CAS、冪等或唯一鍵解決的不要引入分散式鎖。效率鎖可容忍雙執行;正確性鎖必須帶 fencing,否則寧願不用。
09

互動 · 把鎖競態時間軸跑起來

第 5 節那張圖是靜態的;這裡把它的每一個時刻交給你的參數去算——租約什麼時候到期、B 什麼時候接手、A 醒來那一筆寫得進去嗎。

互動 · 凍結持有者與 fencing 設好參數後拖時間游標;每一刻的狀態都由規則算出
lease TTL 10 s
keepalive 間隔 3 s
GC pause 起點 t = 12 s
GC pause 長度 14 s
單程網路延遲 0 s
時間游標 t = 0 s

固定前提:A 的臨界區工作需時 24 s;B 每秒重試一次;B 取得鎖後 2 s 送出寫入;模擬總長 40 s。fencing 開關只改資源端規則,不會改動任何時刻——走到最後一秒再來回切,就是同一個時間線的兩種結局。

持有中 / 租約有效 / 寫入被接受 凍結中(本地仍認為自己持有) 租約已過期 / 過期寫入 游標尚未到達的區間
被保護資源(不是協調服務上的那個 key)
副作用通道 · fencing token 在這裡檢查
value — lastSeenToken 0 最後寫入者 — 判定 尚未寫入

兩條公式決定整張圖:危險窗 = max(0, A 的寫入抵達時刻 − 租約到期時刻)、交接延遲 = B 取得鎖的時刻 − 租約到期時刻。斜線框標的就是危險窗——A 自認持有、集群卻已改判的那一段。拒絕過期 token 請看成保護成功,紅色只留給「資源沒檢查、過期寫入真的蓋掉新值」那種損壞。

一句話答法凍結持有者醒來後的寫入,只有資源側的 token 檢查擋得住;而 token 檢查保證的是單調,不保證「過期持有者一定被擋」——它在無鎖狀態下寫出去的副作用,早一步到就進得去。
10

面試折衷與邊界

先答原語與正確性,再談產品名。協定細節留給 Raft 那篇,資料面留給 Redis 與分片。

面試常問 先答這句 不要滑去
etcd / ZK 是什麼? 把共識做成小容量線性一致 KV、Watch、租約的控制面 當快取、當主庫、重講 Raft 投票
為什麼不能放業務資料? 每次寫入都是多數派 log;容量與 QPS 按控制面預算 「它有持久化所以當 DB 更安全」
Ephemeral 跟 Lease 差在哪? 都是用心跳界定生命週期;ZK 綁在 znode,etcd 抽成可共用的租約 背 API 方法名或預設 timeout 數字
分散式鎖怎麼做才正確? Lease 加單調 fencing token,由被保護資源拒絕過期寫入 SET NX PX、Redlock、「等 TTL 到了就安全」
Redlock 能不能用? 獨立節點多數派 ≠ 共識 log,沒有 token;只適合可容忍雙執行的效率鎖 Redis 複寫、AOF、Cluster slot 細節
Session 丟了怎麼辦? 立刻停止副作用,整個操作失敗;fencing 是保險不是繼續寫的許可 本地重試把剩餘批次寫完
如何避免羊群? sequential 排隊,每人只 Watch 前一位;事件觸發後重讀 加強集群硬體、改用訊息佇列當鎖
什麼時候不要用鎖? 單一分區、version CAS、唯一鍵、冪等路徑已經能保證不變量 2PC / Saga 補償理論,或為了「看起來專業」加一把

本篇停在原語與正確用法。Raft 的 term、投票與 log 複寫見 Leader Election 與 Raft 共識;訊息骨幹從 訊息佇列基礎 進;跨分片原子性見 分散式交易。

關鍵協調服務的成本是多數派往返,範圍選錯任何 SSD 都救不了

三到五台的共識組,適合秒級變更的元資料,不適合每秒萬次的業務寫入。把鎖粒度切到「整個使用者表」、把路由表整份塞進一個 znode、讓所有實例 Watch 同一個熱 key,故障切換當下最先倒的是協調服務自己。控制面保持無聊:小、穩、少 Watch、token 外送到真正寫資料的地方。

一句話答法協調服務面試分數來自三句:只放元資料、鎖必須帶 fencing、Watch 要排隊並重讀。Redlock 與 TTL 不能代替這三句。