• 區分 lock(交易層級、邏輯物件)與 latch(執行緒層級、記憶體結構),以及 PostgreSQL 的三層鎖:heavyweight lock、LWLock、spinlock。

  • 寫出 S/X/IS/IX/SIX 的相容矩陣,解釋多粒度鎖與意向鎖的目的。

  • 證明兩階段鎖(2PL)保證衝突可序列化,並說明為何實務使用 strict 2PL。

  • 以等待圖偵測死結、以 wait-die/wound-wait 預防死結,並知道 PostgreSQL 的 機制。

  • 熟記 PostgreSQL 的 8 種表級鎖與 4 種列級鎖的衝突關係,避免 DDL 鎖住整個系統的事故。

  • 用 、 診斷鎖等待。

Lock 與 Latch

lP5.2cmP5.2cm & Lock(交易鎖) & Latch(閂鎖)
保護對象 & 邏輯物件:表、列、索引鍵範圍、述詞 & 記憶體結構:buffer frame、雜湊表桶、B+Tree 頁
持有者 & 交易 & 執行緒/程序,一小段程式碼
持有時間 & 到交易結束(2PL) & 關鍵區段(微秒)
模式 & 多種(S、X、意向鎖……) & 通常只有 read/write
死結 & 偵測或預防 & 以編碼紀律避免(固定順序、不等待)
PostgreSQL 名稱 & heavyweight lock( 可見) & LWLock、spinlock、buffer pin

第 3、4 章的 latch crabbing、buffer header 鎖都屬於 latch;本章的主角是交易鎖。

鎖模式與多粒度鎖

最基本的兩種模式:共享鎖 S(讀)與排他鎖 X(寫)。若鎖的粒度是整張表,並行度太低;若只有列鎖, 一個全表掃描要取百萬個鎖,鎖表爆炸。Gray 等人(1976)提出多粒度鎖與意向鎖(intention lock): 在上層節點(資料庫 \(\to\) 表 \(\to\) 頁 \(\to\) 列)宣告「我打算在下層取某種鎖」。

已持有 \(\backslash\) 請求 IS IX S SIX X
IS
IX
S
SIX
X
  • IS / IX:打算在下層取 S / X。要鎖某列 X 之前,先在表上取 IX、在頁上取 IX。

  • SIX = S + IX:讀整張表,同時更新其中少數列。

  • 全表掃描在表上取 S,就與任何 IX(有人在改某列)衝突——正確地阻止了讀到半更新狀態;而兩個 IX 相容,改不同列的交易互不干擾(列層級再判斷)。

  • 鎖升級(escalation):列鎖太多時換成表鎖以省資源(SQL Server、DB2 會;PostgreSQL 不會,因為列鎖存在 tuple 裡不佔鎖表)。

兩階段鎖

兩階段鎖(2PL):每筆交易分為成長期(只取鎖、不放鎖)與縮減期(只放鎖、不取鎖)。 一旦釋放任何鎖,就不能再取得新鎖。

定理:2PL 產生的排程都是衝突可序列化的。證明概要:令每筆交易的 lock point 為它取得最後一個鎖的時刻; 若 \(T_i \to T_j\) 有衝突邊,則 \(T_i\) 必須先釋放該資料的鎖、\(T_j\) 才能取得,故 \(\text{lockpoint}(T_i) < \text{lockpoint}(T_j)\)。 若優先圖有環,會推出 lock point 的嚴格遞增環,矛盾。

但基本 2PL 允許在縮減期釋放 X 鎖後被別人讀到尚未 commit 的值 \(\Rightarrow\) 連鎖中止。 Strict 2PL:X 鎖持有到 commit/abort;Rigorous(strong strict)2PL:所有鎖持有到結束。 實務系統都用 strict/rigorous 2PL,代價是並行度降低與死結。

死結

偵測

維護等待圖(wait-for graph):\(T_i \to T_j\) 表示 \(T_i\) 在等 \(T_j\) 持有的鎖。有環即死結,選一個受害者中止 (依據:已完成的工作量、持有的鎖數、重啟次數避免餓死)。

PostgreSQL 不常態性地檢查等待圖:一個 backend 等鎖超過 (預設 1 s)後,才由它自己跑一次等待圖檢查 ();若發現環,發現者自己成為受害者,收到 (SQLSTATE ), 訊息會列出涉及的程序與各自等待的鎖。設 可把等待超過 的事件(即使不是死結)寫進日誌。

預防

以交易時間戳(越老優先權越高)決定誰等、誰死,保證等待圖無環:

  • Wait-Die(非搶占):老交易等年輕交易;年輕交易要等老交易時直接中止(die)。

  • Wound-Wait(搶占):老交易要鎖時直接把年輕持有者中止(wound);年輕交易等老交易。

中止的交易重啟時保留原時間戳,否則會餓死。分散式系統(如早期 Spanner、CockroachDB 的某些路徑)偏好預防,因為跨節點建等待圖代價高。

  • 固定的存取順序:多筆更新依主鍵排序後再執行( 不保證順序——用 先鎖)。

  • 交易越短越好;把使用者互動、外部 API 呼叫移出交易。

  • 設 讓等待快速失敗並重試,而不是堆積。

  • 外鍵:更新父表主鍵或刪父列會對子表取鎖;子表的外鍵欄位沒索引時會全表掃描並長時間持鎖。

Phantom 與述詞鎖

Strict 2PL 鎖住的是已存在的列。\(T_1\) 執行 鎖了符合的 10 列, \(T_2\) 插入一列 不會與任何鎖衝突——phantom 出現。解法:

  • 述詞鎖(predicate lock):鎖住條件 本身,與任何可能符合的插入衝突;判定述詞相交代價高,少有系統直接實作。

  • 索引範圍鎖/gap lock:鎖住索引上 的區間。InnoDB 的 next-key lock(記錄鎖 + 前方的 gap 鎖)就是這個, 這也是 InnoDB 的 RR 沒有 phantom(對鎖定讀而言)的原因,但也帶來不少「為什麼插入會被鎖住」的困惑。

  • SSI 的 SIREAD lock:PostgreSQL 的做法——不阻塞,只記錄讀過的範圍(tuple/頁/表),事後偵測衝突(第 9 章)。

PostgreSQL 的表級鎖

PostgreSQL 有 8 種表級鎖模式,名稱源自歷史,不要望文生義( 是表級鎖,表示「我要改列」)。 每個 SQL 命令自動取得對應模式,衝突時排隊等待:

已持有 \(\backslash\) 請求 AS RS RX SUX S SRX X AX
ACCESS SHARE (AS)
ROW SHARE (RS)
ROW EXCLUSIVE (RX)
SHARE UPDATE EXCLUSIVE (SUX)
SHARE (S)
SHARE ROW EXCLUSIVE (SRX)
EXCLUSIVE (X)
ACCESS EXCLUSIVE (AX)

lP9.4cm 模式 & 誰取得它
ACCESS SHARE &
ROW SHARE &
ROW EXCLUSIVE & 、、、
SHARE UPDATE EXCLUSIVE & (非 FULL)、、、、多數
SHARE & (非 CONCURRENTLY)——阻擋寫入但允許讀
SHARE ROW EXCLUSIVE & 、部分 (如 )
EXCLUSIVE &
ACCESS EXCLUSIVE & 、、、、、多數 (加欄、改型別、加 NOT NULL……)、 預設

一個報表查詢在表 上持有 ACCESS SHARE 已跑了 10 分鐘。工程師執行 , 它需要 ACCESS EXCLUSIVE,於是排隊等待。問題是:鎖的等待是先進先出的——之後所有新的 (只需要 ACCESS SHARE) 都排在 ALTER 後面,因為它們與排隊中的 ACCESS EXCLUSIVE 衝突。10 秒內連線池耗盡,整個服務停擺。 對策:遷移腳本一律先 ,拿不到鎖就失敗重試; 用 、 再 、PostgreSQL 11+ 的 (常數預設不重寫表)等低鎖手段。

PostgreSQL 的列級鎖

已持有 \(\backslash\) 請求 KEY SHARE SHARE NO KEY UPDATE UPDATE
FOR KEY SHARE
FOR SHARE
FOR NO KEY UPDATE
FOR UPDATE
  • 不改主鍵/唯一鍵欄位時取 FOR NO KEY UPDATE;改鍵或 取 FOR UPDATE。

  • 外鍵檢查對被參照的父列取 FOR KEY SHARE——所以更新父列的非鍵欄位(NO KEY UPDATE)不會與子表的插入互相阻塞。這是 9.3 引入這兩種弱鎖的原因。

  • 列鎖記錄在 tuple 的 (多個持有者時用 MultiXact),不佔鎖表,所以鎖百萬列也不會耗盡 ,但會產生 WAL 與髒頁。

  • :拿不到立即失敗;:跳過被鎖的列——實作工作佇列的標準寫法。

WITH next_job AS (
  SELECT id FROM jobs
  WHERE  status = 'pending'
  ORDER  BY created_at
  FOR UPDATE SKIP LOCKED
  LIMIT  1
)
UPDATE jobs SET status = 'running', worker = :me
FROM   next_job WHERE jobs.id = next_job.id
RETURNING jobs.*;

Advisory lock(、)讓應用程式借用資料庫的鎖管理器協調任意資源(如「同一時間只跑一個排程器」)。

診斷鎖等待

SELECT a.pid, a.state, a.wait_event_type, a.wait_event,
       pg_blocking_pids(a.pid) AS blocked_by,
       now() - a.xact_start AS xact_age,
       left(a.query, 60) AS query
FROM   pg_stat_activity a
WHERE  cardinality(pg_blocking_pids(a.pid)) > 0 OR a.pid IN (SELECT unnest(pg_blocking_pids(pid)) FROM pg_stat_activity)
ORDER  BY a.xact_start;

列出所有鎖( 為等待中); 表示在等交易鎖, 則是內部閂鎖爭用(如 、、)——那是另一類問題(I/O 或 CPU 瓶頸),不是應用程式的鎖。 找到源頭後可 (取消查詢)或 (斷線)。

樂觀並行控制

Kung 與 Robinson(1981)的 OCC:交易在私有工作區讀寫(讀取階段),commit 時驗證讀集沒有被已 commit 的並行交易改過 (backward validation:與所有在我開始後 commit 的交易比對),通過才把寫集寫入(寫入階段,通常在一個短暫的全域臨界區內)。 衝突罕見時沒有任何鎖開銷;衝突多時重做成本高,且長交易容易一直被年輕交易「驗證失敗」。 記憶體資料庫(Hekaton、Silo、TicToc)與 FoundationDB 採用 OCC 變體。對照:MVCC + first-updater-wins 其實也是一種「對寫入樂觀」的策略。

v4 實作 :、、, 支援 IS/IX/S/SIX/X 與本章的相容矩陣,strict 2PL(鎖到 commit/abort 才釋放),背景執行緒每 50 ms 建等待圖找環並中止最年輕的交易。 測試:兩個執行緒交叉更新兩列必須有一方收到 deadlock 錯誤且另一方成功;壓測中不得有鎖洩漏。 這正是 CMU 15-445 Project #4 的核心。

  1. 交易 \(T_1\) 在表 \(R\) 上取 SIX,\(T_2\) 想在 \(R\) 的某列取 X:\(T_2\) 需要先在 \(R\) 上取什麼鎖?能否取得?若 \(T_2\) 只想讀某列呢?

  2. 給出一個遵守 2PL 但發生連鎖中止的排程,再說明 strict 2PL 如何避免。

  3. 三筆交易 \(T_1(ts{=}10)\)、\(T_2(ts{=}20)\)、\(T_3(ts{=}30)\),\(T_1\) 等 \(T_3\)、\(T_3\) 等 \(T_2\)、\(T_2\) 想要 \(T_1\) 持有的鎖。在 wait-die 與 wound-wait 下分別會發生什麼?

  4. 為什麼 要掃描表兩次並等待所有既有交易結束?它取的 SHARE UPDATE EXCLUSIVE 與哪些操作衝突?

  5. 設計一個遷移腳本把 加上 NOT NULL 與唯一約束,要求全程不阻塞讀寫超過 1 秒。列出每一步取得的鎖。

  6. 用 的佇列在消費者當機時工作會不會遺失?與「先 UPDATE 標記再處理」相比,兩者的失敗模式有何不同?

  7. (動手)開三個 重現「DDL 排隊導致 SELECT 全部阻塞」,用本章的診斷查詢觀察,然後加入 再試一次。