題幹與適用情境
轉帳服務偶爾卡住:執行緒 A 持有帳戶 42 的鎖並等待帳戶 84,執行緒 B 持有帳戶 84 的鎖並等待帳戶 42。兩個執行緒都不釋放現有鎖,請判斷是否為死結,解釋四個必要條件,並設計預防、偵測、復原與驗證方案。
這是一道通用軟體工程與作業系統基礎題,適用於後端、系統、基礎設施、SRE,以及需要撰寫並行程式的職位。2026 年中英文公開面試資料仍分別考察死結定義、四個必要條件和處理策略。本文不聲稱特定公司歸屬,也不使用無法驗證的題目頻率。
情境中的帳戶 42 和 84 是虛構識別碼。核心問題不是背出四個名詞,而是區分「等待很久」和「形成無法自行解除的等待環」,再把理論落到鎖定協定、執行期證據、故障復原與測試。文章主要討論單一實例互斥鎖和資料庫資料列鎖;分散式租約、網路分割與共識不在第一輪範圍。
面試官考察點
第一項是診斷是否以等待關係為依據。CPU 很低、請求逾時或兩個執行緒都處於阻塞狀態,只能說明系統沒有進展,不能單獨證明死結。好的回答會畫等待圖:節點是執行緒或交易,邊表示「正在等待對方持有的資源」,再尋找環。
第二項是能否準確使用四個必要條件:互斥、持有並等待、不可搶占和循環等待。它們解釋死結為什麼可能發生;把四項逐字列出,卻不能對應目前轉帳路徑,仍只是記憶答案。
第三項是預防策略是否具有可證明的全域不變量。最實用的方案通常是為所有鎖定義穩定順序,並要求所有入口一致遵守。只在目前函式交換兩行程式碼不夠,批次處理、退款、背景修復和未來程式路徑若反向取鎖,等待環仍會回來。
最後看復原邊界。資料庫可以偵測環並回滾一個交易;行程內互斥鎖通常不能安全地「搶走」後繼續執行,因為被中斷的程式可能只修改了一半狀態。好的回答會區分預防、避免、偵測和復原,說明逾時代價,並用可控交錯重現問題,而不是依賴壓力測試碰運氣。
回答前需要釐清的問題
- 資源是否都是單一實例互斥鎖? 單一實例資源的等待圖出現環,就能證明這組等待無法推進;一種資源有多個實例時,資源配置圖中的環只表示可能死結,還要檢查可用實例和剩餘需求。
- 鎖是可重入還是不可重入? 同一執行緒再次取得不可重入鎖可能形成自我死結。若轉出帳戶和轉入帳戶相同,必須先去重,不能假設排序會自動解決重複加鎖。
- 兩個帳戶的修改是否位於同一個可回滾交易? 可回滾交易允許偵測後放棄一個受害者並重試;已經送出外部付款或 Email 的流程,需要冪等鍵或提交後寄件匣,不能盲目重播。
- 所有取鎖入口能否共用同一排序規則? 能統一時優先使用全域鎖順序;第三方元件或跨服務資源無法納入同一協定時,應減少同時持有、重構所有權,或選擇可偵測和回滾的邊界。
- 允許等待多久,錯誤語意是什麼? 鎖逾時可以限制尾端延遲,卻可能終止只是很慢的合法請求。呼叫端必須知道這是可重試、不可重試,還是結果未知。
- 執行平台提供什麼診斷能力? JVM 執行緒監控、資料庫等待檢視和核心鎖驗證器涵蓋的鎖類型不同。工具沒有回報環,不代表所有非同步等待、虛擬執行緒或外部資源都沒有死結。
- 持鎖期間是否呼叫網路、磁碟或等待使用者輸入? 不可控等待會延長鎖持有時間並放大阻塞。應把這些操作移出臨界區,除非一致性協定明確要求且能處理失敗。
30 秒回答框架
「這是死結:A 等 B 釋放 84,B 又等 A 釋放 42,等待圖形成 A → B → A。情境同時具備互斥、持有並等待、不可搶占和循環等待。我會把帳戶鎖依穩定帳戶 ID 排序、去重後統一升冪取得,反向釋放,從協定上破壞循環等待。線上用執行緒或資料庫等待資訊確認環;資料庫死結讓一個交易回滾並有限重試,行程內死結先保留診斷證據,再依狀態安全邊界恢復服務。最後用屏障固定兩個執行緒的交錯,證明舊實作必定重現、排序實作維持餘額不變量。」
完整回答應補充:為什麼排序能排除環、逾時為什麼不是證明、偵測工具涵蓋什麼,以及復原是否會重複外部副作用。
分步深入解答
第一步:先證明是等待環,不用症狀取代結論
把執行狀態寫成等待圖。執行緒 A 已持有鎖 42,申請鎖 84,而 84 由 B 持有,因此有邊 A → B。執行緒 B 已持有 84,申請 42,而 42 由 A 持有,因此有邊 B → A。兩個執行緒只有取得第二把鎖後才會釋放第一把鎖,環內沒有能先完成的節點,所以它們不會自行推進。
線上診斷需要取得同一時間附近的持有者、等待者和呼叫堆疊。一次執行緒快照可能剛好碰到短暫競爭;連續快照中相同呼叫堆疊與相同等待邊持續存在,證據更強。一般長等待沒有回邊:A 可能等 B,但 B 正在執行並最終釋放資源。把所有慢鎖都標成死結,會把容量或外部相依問題誤診為鎖定協定錯誤。
圖的結論也有模型邊界。每把互斥鎖只有一個持有者時,等待環足以證明相關執行緒死結。資源類別有多個實例時,資源配置圖出現環只表示風險:環外實例釋放後仍可能讓某個節點繼續。此時要結合可用數量、已配置數量和最大剩餘需求判斷,不能套用單一實例結論。
第二步:把四個必要條件映射到目前程式
四個條件在轉帳情境中分別是:
- 互斥: 同一帳戶同一時間只能由一個執行緒持有寫入鎖。
- 持有並等待: A 持有 42 時等待 84;B 持有 84 時等待 42。
- 不可搶占: 執行期不會從執行緒手中強行奪走帳戶鎖,只能由持有者釋放。
- 循環等待: A 等 B,B 又等 A。
死結發生時四項都存在,破壞其中任何一項可以預防這一類死結。這裡不應把「系統允許互斥」或「程式可能巢狀加鎖」直接當成已經死結;必要條件描述可能性,具體執行狀態還要看是否形成無法推進的環。
互斥通常保護餘額不變量,不能為了消除死結而無鎖寫入共享狀態。不可搶占對一般記憶體臨界區也很難安全破壞。工程上更常選擇消除循環等待,或在能回滾的系統中偵測後放棄一個交易。
第三步:用全域鎖順序消除循環等待
為可同時持有的鎖定義全序,例如「資源類型等級,接著是資源 ID」。轉帳只涉及帳戶鎖時,依帳戶 ID 升冪。輸入先去重,處理同一帳戶轉給自己的情況;再依序取得所有鎖,在臨界區只做餘額驗證和記憶體或資料庫變更,最後反向釋放。
以下是與語言無關的虛擬碼:
transfer(fromid, toid, amount): ids = unique(sortascending([fromid, to_id])) acquired = [] try: for id in ids: acquired.append(lock_account(id)) validateandapplytransfer(fromid, to_id, amount) finally: for lock in reverse(acquired): unlock(lock)
排序正確性的證明很短。假設每個執行緒持有低等級鎖時只能等待更高等級鎖,那麼等待環會要求等級嚴格上升:
r1 < r2 < … < rn < r1
嚴格小於關係不可能回到起點,因此循環等待無法形成。這個證明依賴所有路徑遵守同一規則;任何依請求順序、清單順序或資料庫回傳順序取鎖的旁路,都會破壞不變量。
反向釋放主要讓資源生命週期與巢狀結構清楚,不是排除環的必要條件。更重要的是不要在持鎖期間做遠端呼叫、等待使用者輸入或執行無界 I/O。臨界區越長,雖然不一定死結,鎖競爭、逾時和故障復原成本都會提高。
第四步:知道何時選擇其他策略
一次申請全部資源可以破壞「持有並等待」,但呼叫端必須預先知道完整集合;資源很多時還會延長占用、降低並行度。適合小而明確的鎖集合,不適合一邊走訪一邊發現資源的流程。
死結避免會在每次配置前判斷配置後是否仍處於安全狀態。銀行家演算法要求行程預先宣告最大資源需求,並維護可用、最大、已配置和剩餘需求。動態 Web 服務通常不知道請求未來會存取哪些物件,因此它更適合解釋安全狀態,不應機械式搬進一般業務程式。安全狀態保證存在完成順序;不安全狀態只是可能走向死結,不代表目前已經死結。
減少共享可變狀態、單一所有者佇列或成熟並行容器,可以減少手寫鎖定協定,但不能聲稱「用了訊息就不會死結」。兩個有界佇列互相等待空間、任務在同一執行緒池中互相等待結果,仍可能形成等待環。替代方案也要畫出實際等待邊。
嘗試加鎖與逾時屬於有損退出機制,不是無環證明。若逾時路徑保證撤銷局部狀態並釋放所有已持有鎖,它會在門檻到達後破壞持有並等待,解除本次循環;在門檻前請求仍會停頓,門檻過短還會誤傷合法慢請求。雙方同時逾時、同時重試,又可能進入活鎖。使用時需要最大嘗試次數、隨機退避、冪等語意和最終失敗出口。
第五步:把偵測工具的涵蓋範圍說清楚
JVM 的 ThreadMXBean 可以尋找等待物件監視器或可擁有同步器的平台執行緒環,並回傳執行緒 ID;Java SE 25 文件明確說明,包含虛擬執行緒的環不會被這個方法找到,而且此方法用於故障排查,不是同步控制。工具回傳空值,只代表其涵蓋範圍內沒有發現環。
Linux 核心的 lockdep 記錄鎖類別之間的取得相依。例如執行中觀察到 L1 → L2 和 L2 → L1,就能回報潛在鎖反轉,即使這次執行尚未剛好卡住。它說明開發與測試階段可以驗證鎖順序協定,但應用層不能假設所有執行環境都有相同驗證器。
PostgreSQL 會自動偵測交易死結並中止其中一個交易,讓其他交易繼續。官方文件不保證哪個交易被選中,因此業務不能依賴「較新的請求總會失敗」。文件也建議所有應用程式以一致順序鎖定多個物件;無法完全預防時,再處理死結錯誤並重試。
生產觀測至少記錄鎖等待時間、持鎖時間、死結次數、被回滾交易、重試次數和最終失敗率。診斷事件應能關聯等待者、持有者、鎖類別與呼叫堆疊,但不能把完整帳戶、查詢參數或敏感內容寫入日誌。
第六步:依資源邊界設計復原
資料庫交易被選為受害者後,必須回滾完整交易,重新讀取狀態並重新執行,不能從「已經取得第一把鎖」之後接著跑。重試要有上限和隨機退避;若請求可能重複抵達,使用業務冪等鍵。Email、訊息或外部付款應在提交後透過寄件匣等冪等鏈路觸發,否則資料庫回滾無法撤銷已經發生的副作用。
行程內執行緒卡在互斥鎖上時,強制終止一個執行緒可能讓記憶體不變量停在半完成狀態。常見復原路徑是先保留執行緒傾印和關鍵指標,再由監督系統重新啟動可安全重建狀態的行程或執行個體。若行程內保存不可復原的唯一狀態,重新啟動也不是安全方案,設計階段就應補齊持久化與復原邊界。
受害者選擇可以考慮已完成工作量、回滾成本、優先順序和重試次數,但正確性優先。復原讓服務重新前進,不代表根因已解決;若不修正鎖順序,流量恢復後仍會重複死結。
第七步:用確定性交錯驗證修正
不要只跑高並行壓測等待偶爾重現。為舊實作加入測試屏障:A 取得 42 後到達屏障,B 取得 84 後到達同一屏障;兩邊同時繼續並申請第二把鎖。看門狗應在限定時間內捕捉兩個等待邊,證明案例確實製造 A → B → A,而不是因為測試機太慢。
修正後用相同的反向輸入執行。A 和 B 都先嘗試較小 ID 42,其中一個等待時尚未持有 84;先取得 42 的執行緒再取得 84、完成並釋放,另一個隨後繼續。驗證兩筆請求都得到契約允許的結果、總餘額不變、單筆轉帳不重複。
還要涵蓋:
- 轉出與轉入 ID 相同,確認去重後不會重複取得不可重入鎖;
- 三個以上帳戶與不同資源類型,確認排序鍵在所有路徑一致;
- 餘額不足、例外、取消和部分加鎖失敗,確認已取得鎖都在 finally 路徑釋放;
- 批次處理、退款和背景修復與線上轉帳並行,確認沒有旁路逆序;
- 資料庫偵測並回滾一個交易,確認完整重試、冪等副作用和重試上限;
- 隨機高競爭長測,持續檢查餘額、鎖等待、死結與飢餓指標。
確定性案例證明已知反例得到修正,長測尋找未建模路徑。兩者都通過,才有資格聲稱協定已驗證。
高品質示範回答
「我會先確認這不是一般慢等待。A 持有 42 等待 B 的 84,因此有 A → B;B 持有 84 又等待 A 的 42,因此有 B → A。兩把鎖都是單一實例互斥鎖,兩個持有者都只有取得第二把鎖才會釋放第一把,所以這個等待環就是死結。
四個必要條件在這裡全部成立:帳戶寫入鎖互斥;兩邊都持有一把再等另一把;鎖不能被安全搶走;等待關係形成環。我不會移除互斥,因為餘額需要保護。首選是破壞循環等待:對所有帳戶鎖定義同一全序,先對帳戶 ID 去重,再統一依升冪取得、反向釋放。若所有路徑都只從低等級鎖等待高等級鎖,環就要求等級一路上升後又回到更低起點,這不可能發生。
這個規則必須涵蓋線上轉帳、退款、批次任務和背景工具。持鎖期間只做驗證與狀態變更,不呼叫遠端服務。逾時可以在完整釋放後解除本次等待,但不能證明鎖定協定無環;兩個請求同時逾時重試還可能活鎖,所以若採用逾時,我會設定有限次數、隨機退避和冪等鍵。
線上我會看執行緒傾印或資料庫等待資訊,建立等待者到持有者的圖。JVM 工具和資料庫偵測各有涵蓋邊界。PostgreSQL 偵測到交易死結時會中止一個交易,我會回滾並有限重試完整交易,不依賴哪個請求成為受害者;外部訊息放在提交後的冪等鏈路。
驗證時用屏障讓 A 先取得 42、B 先取得 84,再同時申請第二把鎖,穩定重現舊實作。修正版使用相同反向輸入,應只出現無環等待並最終完成。我還會涵蓋相同帳戶、三個帳戶、例外釋放、旁路任務和資料庫回滾,檢查總餘額、重複副作用、死結數與最終失敗率。」
這段回答把定義、證明、工程選擇、復原和測試連在一起。若題目只要求四個條件,可以在第二段結束;若面試官繼續追問生產處置,再展開工具涵蓋與重試邊界。
常見錯誤
- 看到執行緒阻塞就宣布死結 → 長鎖等待可能仍有持有者正在推進 → 畫等待者到持有者的邊,確認環和資源實例模型。
- 只背四個條件 → 沒有說明目前程式如何符合條件,也沒有解法 → 逐項映射情境,並明確選擇破壞哪一項。
- 每個函式各自排序 → 不同模組可能使用不同鍵或資源類型順序 → 定義程式庫層級的鎖等級與穩定 ID,並檢查所有入口。
- 忽略相同資源 ID → 不可重入鎖可能被同一執行緒取得兩次 → 排序前去重,並為同帳戶轉帳定義業務語意。
- 把設定逾時當成無環設計 → 反向鎖序仍存在,而且逾時會誤殺慢請求 → 完整釋放已持有鎖,並加入有限退避、冪等與最終失敗。
- 偵測到環後從中間繼續 → 局部狀態可能已失效,外部副作用可能重複 → 回滾並重跑完整交易,副作用使用冪等提交後鏈路。
- 假設等待圖有環總能證明死結 → 多實例資源可能由環外實例解除等待 → 區分單一實例等待圖和多實例資源配置模型。
- 強制終止持鎖執行緒 → 記憶體不變量可能停在半更新狀態 → 先保留證據,只在狀態可重建邊界重新啟動行程。
- 只做隨機壓力測試 → 沒重現不代表沒有反向鎖序 → 用屏障固定交錯,再用長測補充未知路徑。
追問及應對
追問一:一次轉帳需要鎖三個帳戶,排序方案仍成立嗎?
成立,前提是先取得完整且去重的鎖集合,再使用同一穩定鍵排序。若帳戶在執行中動態發現,就先做無鎖讀取建立候選集合,重新驗證版本後一次加鎖;無法預先確定時,考慮拆分交易或使用可回滾的偵測策略,不能一邊發現一邊依局部順序加鎖。
追問二:不同資源類型如何排序?
建立組合等級,例如先依資源類型固定「客戶 → 帳戶 → 帳本分片」,同類型內再依穩定 ID。規則必須由共用鎖定協定維護,程式碼審查和測試驗證跨類型邊。若業務必須反向進入,重構呼叫方向或在進入前釋放低層資源,不能為單一路徑增加例外。
追問三:使用 tryLock 和 100 毫秒逾時是否已經解決死結?
它保證這次等待受到設定視窗限制,但沒有證明鎖定協定無環。100 毫秒也可能小於合法臨界區尾端延遲,導致假失敗。還要定義釋放已持有鎖、回滾部分狀態、有限隨機退避、冪等和最終錯誤;否則系統可能從死結變成持續重試的活鎖。
追問四:讀取鎖升級為寫入鎖為什麼危險?
兩個執行緒都持有共享讀取鎖,再同時等待獨占寫入鎖時,彼此的讀取鎖都會阻止升級,形成等待環。優先使用程式庫提供的明確升級協定;沒有協定時釋放讀取鎖、競爭寫入鎖,並在取得寫入鎖後重新驗證讀取條件。重新驗證不可省略,因為釋放視窗內狀態可能改變。
追問五:PostgreSQL 已經自動偵測,應用程式還需要做什麼?
需要統一多物件鎖定順序來降低死結發生率,並把死結錯誤視為整個交易失敗。應用程式重新讀取後有限重試,保證請求冪等,記錄重試和最終失敗;不能依賴特定交易總被中止,也不能在資料庫回滾後重複送出不可撤銷的外部動作。
追問六:死結、活鎖和飢餓如何區分?
死結中的參與者因等待環都無法推進;活鎖中的參與者仍在執行和改變狀態,卻不斷互相退讓或重試而沒有完成;飢餓表示某個參與者長期拿不到資源,但其他參與者仍可能完成。三者都影響活性,證據不同:等待環、持續狀態變化但無完成,以及長期不公平等待。