2PL 用「先把鎖收齊、再一次放完」這條紀律達成 serializable。它正確、悲觀,但代價是死鎖與低吞吐——這正是現代系統轉向樂觀 SSI 的理由。
🧠 Intuition
TIP
2PL 的「兩階段」不是指交易有兩段,而是指鎖只能先一路拿、再一路放,中間不准回頭再拿。一旦放了第一把鎖就進入收縮期,之後不能再取任何鎖——這條單向紀律才是它能保證序列化的原因。
- 成長期 (Growing phase):交易只取鎖、不放鎖,逐步累積它要碰的所有資料的鎖。
- 收縮期 (Shrinking phase):開始放鎖後就不能再取鎖;嚴格 2PL (strict 2PL) 更進一步——所有鎖一律持有到 commit/abort 才放。
- 與 MVCC 相反,2PL 是悲觀的:先擋住衝突再做事,而非樂觀地做完再檢查。
- 它擋掉的不只是寫寫衝突,連讀寫衝突都靠鎖序列化,因此能達成最強的 serializable。
⚖️ Tradeoffs
共享鎖 vs 獨佔鎖 — 讀寫互斥的基本盤
- 共享鎖 (S lock):讀取時取得,多個讀者可同時持有同一資源的 S 鎖。
- 獨佔鎖 (X lock):寫入時取得,與任何其他鎖互斥;要寫就得獨佔。
- 因此 2PL 下讀會擋寫、寫會擋讀——這正是它與 MVCC 最大的體感差異(MVCC 讀寫不互相阻塞)。
- 讀者升級成寫者時需把 S 鎖升級為 X 鎖,這一步是常見的死鎖來源。
Predicate lock 與 index-range lock — 擋幻讀與寫偏斜
- 光鎖「已存在的列」擋不住幻讀:別人可以插入一筆符合條件的新列。要擋住得鎖住「條件」本身。
- Predicate lock:鎖住一個查詢條件(如「room=123 且時段重疊」),任何想插入/修改符合此條件的列都被擋住——概念正確但難高效實作。
- Index-range lock(next-key lock):實務近似做法,鎖住索引上的一段範圍(如某索引值區間),比 predicate lock 粗但快得多,是 MySQL/InnoDB 等的實作方式。
- 這層鎖正是 2PL 能擋住寫偏斜與幻讀、達成真正 serializable 的關鍵。
達成 serializable 的代價 — 死鎖與低吞吐
- 死鎖:交易互相等對方的鎖形成環;資料庫得偵測(等待圖)並犧牲一個交易 abort 重試,重試成本高。
- 吞吐低、延遲尾巴長:鎖讓衝突的交易排隊,高競爭下 throughput 直接塌;一個慢交易持鎖會拖垮一整串等它的交易。
- 嚴格 2PL 把鎖持有到 commit,鎖持有時間長,進一步放大上述問題。
- 結論:2PL 正確性無可挑剔,但在高並發 OLTP 下常是效能瓶頸。
對比 SSI — 悲觀 vs 樂觀
- 2PL(悲觀):假設一定會衝突,先上鎖擋住,代價是阻塞與死鎖。
- SSI(樂觀,可序列化快照隔離):建在 MVCC 上,讓交易先在快照上跑、不上讀鎖,commit 時才檢查是否出現危險的讀寫相依,有才 abort 重試。
- 低競爭時 SSI 幾乎零阻塞、吞吐遠勝 2PL;高競爭時 SSI 的 abort 重試會變多,但通常仍優於鎖排隊。
- 現代追求 serializable 的系統(如 PostgreSQL 的 Serializable 級別)多選 SSI 而非 2PL。
🔑 Takeaways
- 2PL 的紀律是先全程取鎖(成長期)、再全程放鎖(收縮期),中途不回頭;strict 2PL 把鎖留到 commit。
- 共享鎖/獨佔鎖讓讀寫互斥,所以 2PL 下讀擋寫、寫擋讀——與 MVCC 讀寫不阻塞正相反。
- 擋幻讀與寫偏斜要靠 predicate lock / index-range lock 鎖住「條件」而非只鎖既有列。
- 2PL 正確但悲觀,代價是死鎖與低吞吐;現代 serializable 多改用樂觀的 SSI,依「衝突多寡」二選一。
2026-07-01
- Q: 既然 SSI 通常更快,2PL 是不是已經沒用了?
- A: 不是。2PL 在高競爭、衝突幾乎必然發生的工作負載下反而更穩——因為樂觀法在這種情況下會一直 abort 重試,做白工的成本可能超過直接排隊等鎖。2PL 的行為也更可預測(阻塞而非重試),對需要穩定延遲、或交易很長很貴重試代價高的場景更合適。選 2PL 還是 SSI,本質是「你預期衝突多不多」:衝突少選樂觀 (SSI),衝突多且重試貴選悲觀 (2PL)。