題目與適用情境
設計一個部署在單一區域三個可用區的分散式鎖服務。系統管理 100,000 個可加鎖資源,通常維持 20,000 把 活躍鎖,尖峰每秒處理 2,000 次加鎖。預設租期為 30 秒,用戶端每 10 秒續期;無競爭加鎖的 p99 延遲目標 是 200 毫秒。受保護的儲存系統或服務支援原子比較 fencing token,並拒絕舊持有者的請求。
題目中的規模、租期和延遲都是面試設計輸入,不代表 etcd、ZooKeeper 或其他產品的效能承諾。範圍包括 獨占鎖、等待、續期、釋放、故障轉移和可觀測性;細粒度資料庫資料列鎖、完整交易協調器、從零實作共識演算法 和跨區域主動多主不在主問題範圍內。
這是一道典型的資深後端、基礎設施和系統設計題。Amazon 目前的 SDE II 面試指引明確會考察系統設計, 並關注方案的實用性、正確性、效率、可靠性、最佳化與擴展能力。它的難點也正好涵蓋這些維度:服務既要在 用戶端故障後釋放資源,又不能讓恢復執行的舊用戶端破壞新持有者的資料。
面試官考察重點
第一,看候選人能否把互斥安全性和故障回收分開。租約解決「持有者失聯後多久允許別人接管」,卻不能阻止 暫停很久的舊程序恢復後繼續寫入。嚴格方案還需要 fencing token,並要求受保護資源參與驗證。
第二,看一致性邊界是否明確。加鎖、續期和釋放必須經過同一個強一致狀態機;少數派分割不能繼續發鎖。 如果三個可用區各自獨立決定,網路分割時同一個資源就可能出現兩個持有者。
第三,看容量估算是否涵蓋續期流量。20,000 把活躍鎖每 10 秒續期,單是續期就約 2,000 次每秒。再假設 尖峰每秒 2,000 次加鎖並有相近數量的釋放,狀態機約需承受 6,000 次提交每秒,不能只計算加鎖 API。
第四,看故障語意是否落到請求層級:加鎖已經提交但回應遺失怎麼辦,用戶端暫停超過租期怎麼辦,錯誤的 用戶端能否釋放別人的鎖,多數派不可用時是繼續服務還是拒絕,以及新主節點如何保持 token 單調。
最後,看候選人是否知道分散式鎖的適用邊界。Google Chubby 以粗粒度協調為目標;若資料庫唯一約束、 條件更新、佇列單一消費者或業務冪等鍵已經能解決問題,引入遠端鎖會增加新的同步故障點。
回答前需要釐清的問題
- 需要獨占鎖還是讀寫鎖? 本題先做獨占鎖。讀寫鎖會增加狀態、升級和飢餓語意,不能順便承諾。
- 鎖保護的資源是否支援 fencing token? 本題假設支援原子比較並保存最新 token。若不支援,租約
只能縮小衝突時間窗,不能嚴格排除殭屍用戶端。
- 等待者需要多強的公平性? 預設提供近似 FIFO;絕對公平會降低故障恢復和吞吐彈性。
- 用戶端可以等待多久?
waitTimeout必須有上限並允許取消,避免無限等待占滿伺服器狀態。 - 租期能否依業務調整? 預設 30 秒、每 10 秒續期;長任務可以申請受限範圍內的租期,但不能無限續期。
- 加鎖是否允許自動重試? 只有攜帶穩定
requestId的請求才能安全重試,否則回應遺失會產生未知結果。 - 多數派不可用時偏向什麼? 互斥安全優先,拒絕新加鎖和續期;不能讓每個分割自行發鎖。
- 受保護操作是否仍需冪等? 需要。fencing token 排除舊時代請求,業務重試和重複提交仍由業務
冪等鍵或交易處理。
- 是否要求跨區域低延遲? 主設計是單區域多可用區。跨區域硬性全域鎖需要接受更高延遲或分割時不可用,
放到追問討論。
30 秒回答框架
「我會把鎖狀態放進跨三個可用區複寫的強一致狀態機,所有加鎖、續期和釋放都由多數派提交。加鎖成功回傳 leaseId、30 秒到期時間和單調遞增的 fencingToken;用戶端每 10 秒續期,並在續期結果不確定時停止 工作。每次存取受保護資源都攜帶 token,資源端保存已接受的最高時代並拒絕更舊的 token。這樣租約負責 重新選出持有者,fencing 負責拒絕恢復執行的舊持有者。requestId 去重解決提交成功但回應遺失,等待者用 順序節點加前驅監聽避免驚群。20,000 把鎖帶來約 2,000 次每秒續期,加上加鎖和釋放約 6,000 次狀態提交 每秒,需要按真實持久化和故障轉移負載壓測。」
分步深入解答
先定義不變量,再畫元件圖:
- 對同一資源,強一致狀態機在任一時刻最多記錄一個有效
leaseId。 - 每次新的成功加鎖都得到更大的 fencing token。
- 受保護資源拒絕低於已接受最高 token 的請求。
- 只有符合目前
leaseId的請求才能續期或釋放。 - 無法聯絡多數派時不建立新鎖,也不宣稱續期成功。
這組不變量把「誰被伺服器視為持有者」和「誰的工作仍被資源接受」分開。前者由鎖狀態機決定,後者由 fencing token 在資料寫入點決定。
第一步:定義介面與狀態。
Acquire(resource, requestId, leaseTtl, waitTimeout)
-> { leaseId, fencingToken, expiresAt }
Renew(resource, leaseId)
-> { expiresAt }
Release(resource, leaseId)
-> { released }resource 是正規化後的業務資源名稱,不直接接受無界的使用者輸入。leaseId 是本次持有關係的不可猜測 識別碼;fencingToken 使用共識日誌已提交的全域單調 revision,資源端依自己的資源保存最高已接受 token。 requestId 的去重結果至少保留到呼叫方重試時間窗結束。釋放是冪等的,但攜帶舊 leaseId 的釋放不能 刪除新持有者的鎖。
鎖記錄包含資源名稱、leaseId、持有者、fencing token、到期時間和可選的等待佇列參照。閒置鎖記錄可以 清理,但不能讓 token 倒退。使用全域單調 revision 可以避免刪除某個資源記錄後把它的時代重新從 1 開始。
第二步:選擇強一致複寫邊界。
使用成熟的共識儲存或協調系統承載狀態機,不在業務服務裡重新實作 Raft 或 Paxos。三個可用區各有一個 副本,主節點把命令複寫到至少兩個副本後才回傳成功。讀取鎖狀態需要線性一致讀取,或直接由主節點處理; 落後的副本不能發出「資源閒置」的結論。
正常加鎖流程是:驗證參數和去重鍵,確認目前鎖不存在或租約已由狀態機判定過期,分配新的 leaseId 與 revision,提交到多數派,再回傳結果。主節點在提交後、回應前故障時,重試攜帶相同 requestId,新主節點 回傳原結果,不能再建立第二個租約。
三個副本中只剩一個時,服務拒絕新加鎖和續期。這會降低可用性,但保住互斥語意。允許少數派續期看似能讓 舊任務繼續,恢復連線後卻無法判斷兩個分割中的持有者誰有效。
第三步:用租約回收失聯持有者。
預設租期 30 秒,用戶端每 10 秒續期,留下兩個續期間隔的抖動空間。租約的權威時間由伺服器協調狀態機 管理;用戶端本機時鐘只能決定「何時嘗試續期」,不能證明鎖仍有效。用戶端連續續期失敗或回應不確定時, 應立即進入靜默狀態,停止啟動新工作並儘快終止進行中的可中斷操作。
主節點切換時要保守處理剩餘租期,不能因為新主節點時鐘較快而過早把鎖交給別人。生產上應重用經過驗證的 租約實作,並把最大時鐘偏移、選主時間和網路重試納入租期預算。把租期縮到幾百毫秒會提高故障回收速度, 也會讓正常抖動頻繁造成失鎖。
第四步:用 fencing token 阻止殭屍用戶端。
考慮舊用戶端 A 取得 token 41,隨後發生長時間垃圾回收暫停。它的 30 秒租約到期,用戶端 B 取得 token 42 並開始工作。A 恢復後不知道自己已經失鎖,又向儲存系統送出一個延遲寫入。若儲存系統只檢查 A 曾經 取得過鎖,這次舊寫入仍可能覆蓋 B 的新結果。
正確做法是每次受保護請求攜帶 token。儲存系統對每個資源原子保存已見過的最高時代:token 42 首次被 接受後,任何 token 41 的請求都被拒絕。相同 token 下的多個業務操作仍可執行,具體順序、冪等和版本衝突 由業務協議負責,因此判斷條件是拒絕「小於最高時代」的 token,不能機械拒絕相等 token。
etcd 的官方說明也強調,租約本身不能保證對外部資源的互斥,外部資源必須能驗證版本。如果目標系統無法 保存或比較 token,就不能承諾嚴格安全。可改用資料庫條件更新、唯一約束、原生交易鎖、單寫者佇列,或把 關鍵寫入代理到能執行 token 驗證的服務。
第五步:處理等待、公平與驚群。
低競爭資源可以讓失敗呼叫方按指數退避並加入隨機抖動。熱門資源若需要等待,則為請求分配遞增順序號, 每個等待者只監聽緊鄰自己的前驅。前驅釋放或過期時,只喚醒下一個等待者,避免所有用戶端同時搶鎖。 ZooKeeper 的鎖配方採用臨時順序節點和前驅監聽,目的正是避免羊群效應。
等待佇列提供近似 FIFO,不提供絕對公平。取消、逾時和工作階段失效必須刪除等待節點;用戶端建立節點後 回應遺失時,使用 requestId 找回原節點,不能重複排隊。還要限制每個資源和每個租戶的等待者數量, 防止一個熱門鎖耗盡記憶體。
第六步:估算容量與分片。
20,000 把活躍鎖每 10 秒續期,產生約 2,000 次續期每秒。尖峰每秒 2,000 次加鎖;若平均釋放量與加鎖量 接近,則共識狀態機約處理 2,000 + 2,000 + 2,000 = 6,000 次寫入提交每秒。等待入列、取消、逾時與 去重記錄還會增加寫入放大,容量測試至少要涵蓋尖峰、一個副本故障和日誌壓縮同時發生的情況。
假設 100,000 個資源各保留約 500 位元組邏輯狀態,總量約 50 MB;副本、日誌、索引、等待者和儲存引擎 開銷會顯著放大實際占用。這裡的主要瓶頸更可能是同步持久化、熱門資源和大量續期,而不是靜態記錄大小。
先用一個共識群組滿足給定規模,實測達不到目標再按資源雜湊分成多個獨立群組。分片能提高總吞吐,卻不能 拆開一個極熱門資源,而且讓跨資源原子加鎖變複雜。本題不承諾多資源交易鎖;若必須一次鎖多個資源,應固定 排序並設定整體逾時,或重新設計為單一更高層資源。
第七步:關閉故障與維運迴路。
- 主節點故障:新主從已提交日誌恢復狀態,未提交請求由
requestId安全重試。 - 網路分割:只有多數派繼續服務,少數派拒絕;舊持有者的寫入最終由 token 拒絕。
- 用戶端暫停:租約到期後可重新發鎖,恢復的用戶端不能通過資源端 token 驗證。
- 熱門鎖:記錄等待長度、取得延遲和持有時間,限制佇列並檢查是否應改成佇列或分區任務。
- 續期風暴:用戶端續期間隔加入抖動,服務依租約到期風險排優先級,避免所有鎖同時續期。
- 儲存系統不可驗證 token:明確降級為盡力互斥,或禁止用於資金、庫存等要求嚴格正確性的寫入。
核心指標包括加鎖、續期和釋放的 p50/p95/p99;成功、逾時、衝突與未知結果數量;活躍鎖、等待佇列、 租約到期、續期失敗、主節點切換時間、共識提交延遲、token 拒絕數以及按資源統計的熱點。稽核日誌記錄 資源、leaseId、token、呼叫方和結果,但不記錄敏感承載內容。
驗證不能停在單元測試。使用狀態機模型測試驗證單一持有者和 token 單調;故障注入涵蓋提交後遺失回應、 暫停用戶端超過 30 秒、延遲舊寫入、隔離一個副本、隔離多數派、主節點切換和等待者取消。最關鍵的端到端 斷言是:B 的 token 42 已被資源接受後,A 的 token 41 永遠不能再改變該資源。
高品質示範回答
「我先定義安全目標:同一資源在強一致鎖狀態中最多有一個有效租約,而且受保護資源不會接受舊持有者的寫入。 系統部署在三個可用區,用成熟共識儲存複寫一份邏輯狀態機。加鎖、續期和釋放必須由多數派提交;只剩少數派 時拒絕服務,不能用可用性換兩個持有者。
介面回傳 leaseId、到期時間和單調遞增的 fencingToken。預設租期 30 秒,用戶端每 10 秒續期;本機 時鐘只負責發起續期,續期結果不確定時用戶端停止工作。租約允許舊持有者失聯後重新發鎖,但真正的安全邊界 在資源端:每個受保護請求攜帶 token,資源保存已接受的最高時代並拒絕更舊的請求。即使 token 41 的用戶端 暫停後恢復,而 token 42 已接管,舊寫入也無法落地。
加鎖請求必須攜帶 requestId。如果服務已提交但回應遺失,呼叫方用相同 ID 重試並取回原租約,避免建立 第二把鎖。續期和釋放必須符合目前 leaseId,舊用戶端不能釋放新持有者。熱門鎖需要等待時,我會給等待者 分配順序號並只監聽前驅,近似 FIFO 且避免驚群。
容量上,20,000 把鎖每 10 秒續期就是約 2,000 次寫入每秒;再加 2,000 次加鎖和相近的釋放,基礎負載約 6,000 次共識提交每秒。我要在一個副本故障和日誌壓縮同時發生時驗證 200 毫秒 p99,不引用產品宣傳值。 先用一個共識群組,只有壓測證明不足才按資源分片。
最後,我會用模型測試和故障注入驗證提交後遺失回應、用戶端暫停、舊封包延遲、網路分割、選主和等待取消。 監控取得與續期延遲、衝突、到期、佇列長度、主節點切換和 token 拒絕。若受保護系統不能原子比較 token, 我會明確說明無法提供嚴格互斥,並優先改用資料庫條件更新、唯一約束或單寫者佇列。」
常見錯誤
- 只設計一個帶到期時間的鍵 → 暫停的舊用戶端恢復後仍可能寫入 → 為每次加鎖簽發單調 token,並在資源端驗證。
- 讓三個可用區各自發鎖 → 分割時同一資源出現多個持有者 → 所有狀態變更經過同一個多數派狀態機。
- 把用戶端本機時間當租約真相 → 時鐘偏移和程序暫停會誤判 → 由伺服器管理租約,用戶端不確定時停止工作。
- 失敗後換一個請求 ID 重試加鎖 → 原請求可能已提交,形成重複持有關係 → 使用穩定
requestId查詢原結果。 - 任何用戶端都能按資源名釋放 → 舊請求可能刪除新持有者 → 續期和釋放必須符合目前
leaseId。 - 只估算每秒 2,000 次加鎖 → 忽略 2,000 次續期和相近釋放 → 按約 6,000 次基礎狀態提交做容量壓測。
- 所有等待者監聽鎖根節點 → 每次釋放都會喚醒全體 → 讓等待者只監聽直接前驅。
- 為了吞吐立即分很多共識群組 → 維運和跨資源語意先變複雜 → 先壓測單組,再依證據分片。
- 把分散式鎖用於每筆資料 → 協調服務成為高頻交易路徑和單點瓶頸 → 優先用資料庫原子約束處理細粒度並行。
- 目標系統不能驗證 token 仍宣稱嚴格安全 → 租約無法阻止延遲舊寫入 → 降級承諾或改變寫入邊界。
追問與應對
追問一:為什麼有租約還必須使用 fencing token?
租約只讓鎖服務在 30 秒後有權把鎖發給 B,不能刪除 A 已經送往外部系統、但仍在網路或程序佇列中的操作。 A 也可能因長時間暫停而完全不知道租約已經到期。token 42 被資源接受後,資源拒絕 token 41,才能在真正 產生副作用的位置阻止舊時代工作。可重用的判斷是:租約決定何時可以選出新持有者,fencing 決定舊持有者 的工作是否仍會被接受。
追問二:受保護的資料庫不能存 fencing token 怎麼辦?
先尋找等價的原子版本條件,例如 UPDATE ... WHERE version = expected、唯一約束、交易鎖或資料庫 advisory lock。也可以把所有寫入送到一個能驗證 token 的代理或單一消費者佇列。若這些都做不到,只能把保證降為 盡力互斥,並說明程序暫停與延遲訊息仍可能破壞正確性;資金和庫存等嚴格情境不應接受這種模糊承諾。
追問三:如何設計跨區域的全域鎖?
最直接的是為每個資源指定一個 home region,並讓所有區域存取同一個跨區域多數派,代價是遠距離寫入延遲, 而網路分割時少數派區域無法加鎖。不能讓每個區域獨立發鎖再非同步合併,因為互斥衝突無法事後撤銷。若業務 更重視區域可用性,可重新劃分資源所有權,讓一個資源只在一個區域寫入,避免製造主動多主全域鎖。
追問四:30 秒租期和 10 秒續期如何決定?
它們是題目輸入,真實值應由最長正常暫停、網路 p99、選主時間、時鐘誤差和可接受故障回收時間共同決定。 租期太短會把正常抖動當成失鎖,太長會讓故障持有者占用資源更久。續期間隔 10 秒為 30 秒租期留下兩次 補救機會,還應加入隨機抖動,避免 20,000 個用戶端形成整點續期尖峰。
追問五:如何支援一次取得多把鎖?
最簡單的邊界是本服務不提供跨資源原子鎖。呼叫方必須按正規化資源名固定排序取得,並為整組操作設定短的 總逾時;失敗時按相反順序釋放,減少死鎖但不提供交易原子性。若業務真的需要「全部取得或全部不取得」, 應把它建模為一個高層資源,或使用能原子提交多鍵狀態的單一共識群組,並接受吞吐與複雜度成本。
追問六:你會怎樣證明實作沒有雙持有者?
先用狀態機模型產生加鎖、續期、釋放、到期和重試序列,檢查任一日誌位置最多一個有效 leaseId,且 token 嚴格遞增。再做故障注入:提交後中斷回應、暫停 A 超過租期、讓 B 取得新鎖並寫入,再恢復 A 傳送舊寫入; 斷言資源拒絕舊 token。還要隔離少數派和多數派、反覆切主、取消等待者,並在高並行壓測中檢查所有歷史記錄 是否滿足不變量。