並行控制與鎖機制:PostgreSQL 如何協調多用戶同時存取 | PostgreSQL

2026/06/16
並行控制與鎖機制:PostgreSQL 如何協調多用戶同時存取 | PostgreSQL

PostgreSQL 的並行控制建立在 MVCC 基礎之上,輔以多層次的鎖機制(Locking),確保在高並行場景下資料的一致性與正確性。理解鎖的分層設計、衝突矩陣、HOT Update 優化,以及 SKIP LOCKED 工作佇列模式,是調校高並行系統的關鍵。

鎖的分層設計

PostgreSQL 的鎖分為三個層次,各自服務不同的並行控制需求:

鎖的分層架構:

┌──────────────────────────────────────────────────┐
│              Table-Level Locks                    │
│  保護整個 table 的結構或大範圍操作                │
│  8 種模式:ACCESS SHARE → ACCESS EXCLUSIVE        │
├──────────────────────────────────────────────────┤
│              Row-Level Locks                      │
│  保護單行資料的修改(存在 tuple header 中)       │
│  4 種模式:FOR KEY SHARE → FOR UPDATE             │
├──────────────────────────────────────────────────┤
│              Page-Level Locks                     │
│  保護 shared buffer 中 page 的並行讀寫            │
│  由 LWLock 實現,極短暫,內部使用                 │
└──────────────────────────────────────────────────┘

Table-Level Lock 衝突矩陣

PostgreSQL 定義了 8 種 Table-Level Lock 模式,按強度由弱到強排列。 表示衝突(需等待), 表示相容(可並行):

衝突矩陣(行=持有,列=請求):

                    AS  RS  RE  SUE SH  SRE EX  AE
ACCESS SHARE      │ ○   ○   ○   ○   ○   ○   ○   ✗
ROW SHARE         │ ○   ○   ○   ○   ○   ○   ✗   ✗
ROW EXCLUSIVE     │ ○   ○   ○   ○   ✗   ✗   ✗   ✗
SHARE UPD EX      │ ○   ○   ○   ✗   ✗   ✗   ✗   ✗
SHARE             │ ○   ○   ✗   ✗   ○   ✗   ✗   ✗
SHR ROW EX        │ ○   ○   ✗   ✗   ✗   ✗   ✗   ✗
EXCLUSIVE         │ ○   ✗   ✗   ✗   ✗   ✗   ✗   ✗
ACCESS EX         │ ✗   ✗   ✗   ✗   ✗   ✗   ✗   ✗

各 Lock 模式的典型場景:

Lock 模式由誰獲取說明
ACCESS SHARESELECT最弱,只與 ACCESS EXCLUSIVE 衝突
ROW SHARESELECT FOR UPDATE/SHARE允許並行 SELECT,與 EX/AE 衝突
ROW EXCLUSIVEINSERT/UPDATE/DELETE允許並行讀,阻止 DDL
SHARE UPDATE EXCLUSIVEVACUUM/ANALYZE/CIC防止並行 schema 變更
SHARECREATE INDEX(非 CONCURRENTLY)允許並行讀,阻止寫
SHARE ROW EXCLUSIVECREATE TRIGGER, 部分 ALTER TABLE較少使用
EXCLUSIVE手動 LOCK TABLE … IN EXCLUSIVE MODE允許 ACCESS SHARE(普通 SELECT)
ACCESS EXCLUSIVEDROP TABLE, TRUNCATE, REINDEX, 大部分 ALTER TABLE最強,阻止所有並行操作

Row-Level Lock 四種強度

SELECT 的 FOR 子句可獲取行級鎖,由強到弱:

FOR UPDATE          ← 最強:阻止所有並行修改和鎖
FOR NO KEY UPDATE   ← 次強:允許 FOR KEY SHARE
FOR SHARE           ← 共享讀鎖:允許多個 FOR SHARE,阻止修改
FOR KEY SHARE       ← 最弱:主要用於 FK 檢查
Row-Level Lock 衝突矩陣:

                   FK SHARE  FOR SHARE  NO KEY UPD  FOR UPDATE
FOR KEY SHARE    │   ○          ○           ○           ✗
FOR SHARE        │   ○          ○           ✗           ✗
NO KEY UPDATE    │   ○          ✗           ✗           ✗
FOR UPDATE       │   ✗          ✗           ✗           ✗

Foreign Key 的隱式鎖:當更新父表的主鍵或刪除父表行時,PostgreSQL 會對子表的相關行加 FOR KEY SHARE 鎖。插入或更新子表的 FK 欄位時,也會對父表的被引用行加 FOR KEY SHARE 鎖。

NOWAIT 與 SKIP LOCKED

-- 預設行為:等待直到鎖可用(可能死鎖)
SELECT * FROM jobs WHERE id = 42 FOR UPDATE;

-- NOWAIT:立即失敗,拋出錯誤
SELECT * FROM jobs WHERE id = 42 FOR UPDATE NOWAIT;
-- ERROR: could not obtain lock on row in relation "jobs"

-- SKIP LOCKED:跳過已鎖定的行,返回可獲取鎖的行子集
SELECT * FROM jobs WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;

SKIP LOCKED 工作佇列模式

SKIP LOCKED 是實現分散式工作佇列的最佳方式,多個 Worker 並行消費任務而不互相阻塞:

-- 建立工作佇列表
CREATE TABLE job_queue (
    id          BIGSERIAL PRIMARY KEY,
    payload     JSONB NOT NULL,
    status      TEXT NOT NULL DEFAULT 'pending',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    started_at  TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    worker_id   TEXT
);

CREATE INDEX idx_job_queue_pending
    ON job_queue (created_at)
    WHERE status = 'pending';

-- Worker 消費任務(關鍵查詢)
BEGIN;

WITH next_job AS (
    SELECT id
    FROM job_queue
    WHERE status = 'pending'
    ORDER BY created_at
    LIMIT 1
    FOR UPDATE SKIP LOCKED  -- 跳過其他 worker 已鎖定的任務
)
UPDATE job_queue
SET status = 'processing',
    started_at = now(),
    worker_id = pg_backend_pid()::text
WHERE id = (SELECT id FROM next_job)
RETURNING *;

-- 執行任務後標記完成
-- UPDATE job_queue SET status = 'completed', completed_at = now() WHERE id = $1;

COMMIT;

HOT Update(Heap-Only Tuple)

HOT Update 是 PostgreSQL 的重要優化。當以下條件同時成立時,UPDATE 不需要更新索引:

  1. 更新的欄位不包含在任何索引中
  2. 新版本 Tuple 與舊版本在同一個 Heap Page 內
一般 UPDATE(非 HOT):

  heap page A                 index
  ┌─────────────┐            ┌─────────────┐
  │ tuple v1    │←───────────│ index entry │
  │ (xmax=TX2)  │            │ (ctid→v1)   │
  └─────────────┘            └─────────────┘
  heap page B
  ┌─────────────┐            ┌─────────────┐
  │ tuple v2    │←───────────│ index entry │(新增!)
  │ (xmin=TX2)  │            │ (ctid→v2)   │
  └─────────────┘            └─────────────┘
  → 索引膨脹,需 VACUUM 清理

HOT Update(同 page,非索引欄位):

  heap page A
  ┌──────────────────────────────┐
  │ tuple v1    → tuple v2      │← HOT chain
  │ (xmax=TX2)    (xmin=TX2)    │
  │                (HEAP_ONLY=1)│
  └──────────────────────────────┘
            index
  ┌─────────────┐
  │ index entry │← 仍指向 v1,透過 HOT chain 找到 v2
  │ (ctid→v1)   │← 索引不需更新!
  └─────────────┘
  → 無索引膨脹,page 內自動清理(mini-vacuum)
-- 監控 HOT Update 比例(越高越好)
SELECT
    schemaname,
    tablename,
    n_tup_upd AS total_updates,
    n_tup_hot_upd AS hot_updates,
    CASE WHEN n_tup_upd > 0
         THEN round(100.0 * n_tup_hot_upd / n_tup_upd, 2)
         ELSE 0
    END AS hot_update_ratio_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY total_updates DESC
LIMIT 20;

-- 若 HOT Update 比例低,調整 fillfactor
ALTER TABLE accounts SET (fillfactor = 70);
-- 預留 30% 空間給 HOT Update
-- 需 VACUUM FULL 或 CLUSTER 才能對既有 page 立即生效

查看鎖狀態

-- 查看所有等待中的鎖
SELECT
    pid,
    pg_blocking_pids(pid) AS blocked_by,
    query,
    state,
    locktype,
    relation::regclass AS table_name,
    mode,
    granted
FROM pg_locks
LEFT JOIN pg_stat_activity USING (pid)
WHERE NOT granted;

-- 更完整的鎖等待鏈查詢
WITH lock_chain AS (
    SELECT
        blocked.pid AS blocked_pid,
        blocked_activity.query AS blocked_query,
        blocking.pid AS blocking_pid,
        blocking_activity.query AS blocking_query,
        blocked.mode AS waiting_for_mode,
        blocked.relation::regclass AS table_name
    FROM pg_locks AS blocked
    JOIN pg_stat_activity AS blocked_activity
        ON blocked.pid = blocked_activity.pid
    JOIN pg_locks AS blocking
        ON blocking.relation = blocked.relation
        AND blocking.locktype = blocked.locktype
        AND blocking.pid != blocked.pid
        AND blocking.granted = true
        AND NOT blocked.granted
    JOIN pg_stat_activity AS blocking_activity
        ON blocking.pid = blocking_activity.pid
)
SELECT
    blocked_pid,
    left(blocked_query, 80) AS blocked_query,
    blocking_pid,
    left(blocking_query, 80) AS blocking_query,
    waiting_for_mode,
    table_name
FROM lock_chain;

-- PG9.6+:快速查詢被阻塞的 session
SELECT pid, pg_blocking_pids(pid) AS blocked_by
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;

Deadlock 預防策略

-- 預防 Deadlock:固定順序存取 rows
-- 錯誤做法(可能 Deadlock):
-- TX1: UPDATE accounts WHERE id = 1; UPDATE accounts WHERE id = 2;
-- TX2: UPDATE accounts WHERE id = 2; UPDATE accounts WHERE id = 1;

-- 正確做法(固定 ORDER BY 確保相同順序):
BEGIN;
SELECT id FROM accounts
WHERE id IN (1, 2)
ORDER BY id  -- 固定順序!
FOR UPDATE;

UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 設定鎖超時保護
SET lock_timeout = '10s';
-- 超過 10 秒無法取得鎖則報錯

-- 啟用 Deadlock 日誌
-- postgresql.conf:
-- log_lock_waits = on
-- deadlock_timeout = 1s

CREATE INDEX CONCURRENTLY

避免建索引時長時間鎖表:

-- 一般建索引:獲取 SHARE 鎖,阻止寫入
CREATE INDEX idx_accounts_email ON accounts (email);

-- CONCURRENTLY:不鎖表,但耗時更長
CREATE INDEX CONCURRENTLY idx_accounts_email ON accounts (email);

-- CONCURRENTLY 的代價:
-- 1. 需要掃描 table 兩次
-- 2. 需要等待所有使用該 table 的 transaction 結束
-- 3. 若過程中失敗,留下 INVALID 索引

-- 檢查 INVALID 索引
SELECT indexrelid::regclass AS index_name,
       indrelid::regclass AS table_name,
       indisvalid
FROM pg_index
WHERE NOT indisvalid;

-- 清理 INVALID 索引後重建
DROP INDEX CONCURRENTLY idx_accounts_email;
CREATE INDEX CONCURRENTLY idx_accounts_email ON accounts (email);

ALTER TABLE 的鎖影響

-- PG11 之前:ALTER TABLE ADD COLUMN DEFAULT value 需要重寫整個 table!
-- PG11+:使用 attmissingval 存儲預設值,只需短暫的 ACCESS EXCLUSIVE 鎖

-- 高風險操作(長時間 ACCESS EXCLUSIVE):
ALTER TABLE big_table ADD CONSTRAINT check_positive CHECK (amount > 0);

-- 安全做法:先加 NOT VALID 約束,再 VALIDATE(可並行)
ALTER TABLE big_table
    ADD CONSTRAINT check_positive CHECK (amount > 0) NOT VALID;

ALTER TABLE big_table VALIDATE CONSTRAINT check_positive;
-- VALIDATE 使用 SHARE UPDATE EXCLUSIVE,允許並行讀寫

Foreign Key 效能注意事項

-- FK 的隱式鎖在高並行下可能成為瓶頸
-- 大量並行 INSERT 子表時,都需要對父表加 FOR KEY SHARE

-- 關鍵:子表上的 FK 欄位必須建索引(PostgreSQL 不自動建立!)
CREATE INDEX idx_order_items_order_id ON order_items (order_id);
-- 否則 DELETE 父表時,需要全表掃描子表檢查 FK

-- 查找缺少索引的外鍵
SELECT
    tc.table_schema,
    tc.table_name,
    kcu.column_name,
    ccu.table_name AS foreign_table_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
    ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage AS ccu
    ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
  AND NOT EXISTS (
    SELECT 1 FROM pg_index pi
    JOIN pg_attribute pa ON pa.attrelid = pi.indrelid
        AND pa.attnum = ANY(pi.indkey)
    WHERE pi.indrelid = (tc.table_schema||'.'||tc.table_name)::regclass
      AND pa.attname = kcu.column_name
  );

LWLock 與 Spinlock

PostgreSQL 內部使用兩種輕量級鎖保護共享記憶體結構:

類型特性用途
Spinlock極短暫 busy-waiting,不能睡眠保護非常短暫的臨界區
LWLock可持有較長時間,可睡眠等待保護 Buffer Pool、CLOG、WAL 等
-- 查看 LWLock 等待(效能瓶頸診斷)
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE wait_event_type IN ('LWLock', 'Lock', 'BufferPin')
GROUP BY wait_event_type, wait_event
ORDER BY count DESC;

Predicate Lock(SSI 用)

Serializable Snapshot Isolation(SSI)使用 Predicate Lock 追蹤 read dependencies:

  • Predicate Lock 不阻塞操作,只記錄讀取範圍
  • 用於偵測 rw-conflict graph 中的 dangerous structure
  • COMMIT 時若偵測到 serialization failure,回滾並報錯
-- 查看 Predicate Locks
SELECT pid, locktype, relation::regclass AS table_name, mode, granted
FROM pg_locks
WHERE mode = 'SIReadLock';

常見陷阱

VACUUM FULL 的鎖危險

-- 普通 VACUUM:使用 SHARE UPDATE EXCLUSIVE,允許並行讀寫
VACUUM ANALYZE accounts;  -- 安全

-- VACUUM FULL:使用 ACCESS EXCLUSIVE,阻止所有並行操作!
VACUUM FULL accounts;  -- 危險:會鎖表

-- 替代方案:pg_repack(不鎖表的表重建)

FOR UPDATE vs FOR NO KEY UPDATE

優先使用 FOR NO KEY UPDATE 而非 FOR UPDATE——前者允許並行的 FOR KEY SHARE(FK 檢查),減少不必要的鎖衝突。只有在需要修改主鍵或唯一鍵時才需要 FOR UPDATE

總結

並行控制與鎖機制 是 PostgreSQL 高並行環境下資料正確性的保障:

  • Table-Level Lock 有 8 種模式,理解衝突矩陣有助於預測 DDL 對線上服務的影響
  • Row-Level Lock 有 4 種強度,優先使用 FOR NO KEY UPDATE 減少鎖衝突
  • SKIP LOCKED 是實現高效工作佇列的最佳方式
  • HOT Update 透過在同一 Page 內更新避免索引維護開銷,搭配 fillfactor 效果更佳
  • Deadlock 可透過固定存取順序預防,搭配 lock_timeoutlog_lock_waits 監控
  • CREATE INDEX CONCURRENTLYNOT VALID + VALIDATE 是不鎖表的 DDL 最佳實踐

下一篇,我們將深入探討 PostgreSQL 的後台進程——Autovacuum、Checkpointer、BGWriter 等背景工作者如何協同運作。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。