MVCC 多版本並行控制:PostgreSQL 高效能的核心秘密 | PostgreSQL

2026/06/09
MVCC 多版本並行控制:PostgreSQL 高效能的核心秘密 | PostgreSQL

MVCC(Multi-Version Concurrency Control)PostgreSQL 最核心的並行機制,其根本原則是:讀取不阻塞寫入,寫入不阻塞讀取。每個 Transaction 看到的是資料在某個時間點的一致性快照(Snapshot),而非即時狀態。理解 MVCC 是掌握 PostgreSQL 所有進階主題的關鍵。

核心原則:空間換時間

傳統資料庫以鎖(Lock)實現隔離,讀者與寫者相互阻塞。MVCC 則採取不同策略——保留資料的多個版本,讓讀者讀取舊版本,寫者建立新版本,兩者互不干擾。

PostgreSQL 的 MVCC 實作與 Oracle、MySQL InnoDB 有根本差異:

特性PostgreSQLOracle / MySQL InnoDB
舊版本儲存位置Heap Tuple 直接保留Undo Log(獨立區域)
讀一致性來源多版本 HeapUndo Log 重建
Dead Tuple 清理VACUUM自動從 Undo Log 清除
版本讀取成本直接讀取 Heap可能需要多次 Undo Log 重建

PostgreSQL 的做法更直觀——舊版本就在原地,不需要從 Undo Log 重建。代價是需要 VACUUM 定期清理不再需要的舊版本(Dead Tuple)。

xmin 與 xmax:Tuple 的版本標記

PostgreSQL 在每個 Heap Tuple 的 Header 中保存兩個關鍵的 Transaction ID:

  • xmin:建立此版本的 Transaction ID(INSERT 或 UPDATE 產生新版本時設定)
  • xmax:刪除或更新此版本的 Transaction ID(DELETE 或 UPDATE 舊版本時設定)
Heap Tuple Header 結構:

┌─────────────────────────────────────────────────────────────────┐
│  xmin  │  xmax  │  ctid  │  infomask  │  natts  │  data...     │
│ (建立TX)│(刪除TX)│(物理位址)│(hint bits)│(欄位數) │              │
└─────────────────────────────────────────────────────────────────┘

我們可以直接查詢這些隱藏欄位:

-- 查看 tuple 的 MVCC 相關欄位
SELECT
    ctid,           -- 物理位址:(page號, tuple號)
    xmin,           -- 建立此版本的 TXid
    xmax,           -- 刪除/更新此版本的 TXid(0 表示未刪除)
    *
FROM accounts;

-- 輸出範例:
-- ctid  | xmin | xmax | id | balance
-- ------+------+------+----+---------
-- (0,1) |  100 |    0 |  1 |     200
-- (0,2) |  102 |  105 |  2 |     500

-- 查看當前 Transaction ID
SELECT txid_current();

-- 查看當前 Snapshot
SELECT txid_current_snapshot();
-- 輸出格式:xmin:xmax:xip_list
-- 例如:100:105:101,103

Tuple 可見性規則

當讀取一個 Tuple 時,PostgreSQL 會依據以下規則判斷該 Tuple 是否「可見」:

讀取 tuple
    │
    ▼
xmin 已 commit? ──否──→ 不可見(INSERT 未完成)
    │是
    ▼
xmin 在 snapshot 之前? ──否──→ 不可見(INSERT 在我之後)
    │是
    ▼
xmax 已設定? ──否──→ 可見(未被刪除)
    │是
    ▼
xmax 已 commit? ──否──→ 可見(DELETE/UPDATE 未完成)
    │是
    ▼
xmax 在 snapshot 之後? ──是──→ 可見(刪除在我之後)
    │否
    ▼
  不可見(已被刪除)

判斷 Transaction 是否已 commit,需要查詢 CLOG(Commit Log),位於 pg_xact/ 目錄,以 2 bits 記錄每個 Transaction ID 的狀態:

CLOG 狀態碼:
  00 = IN_PROGRESS(進行中)
  01 = COMMITTED(已提交)
  10 = ABORTED(已回滾)
  11 = SUB_COMMITTED(子 transaction 已提交)

UPDATE 與 DELETE 的內部實作

UPDATE:建立新版本

UPDATE 並非直接修改 Tuple,而是建立一個新版本:

UPDATE accounts SET balance = 200 WHERE id = 1;
執行前(TX 100 已提交):
  tuple v1: xmin=100, xmax=0, balance=100  ← 可見

執行後(TX 200 正在進行):
  tuple v1: xmin=100, xmax=200, balance=100  ← 舊版本,xmax 已設
  tuple v2: xmin=200, xmax=0,   balance=200  ← 新版本,尚未提交

TX 200 COMMIT 後:
  tuple v1: xmin=100, xmax=200, balance=100  ← Dead tuple
  tuple v2: xmin=200, xmax=0,   balance=200  ← 唯一可見版本

DELETE:邏輯刪除

DELETE 只標記 xmax,不立即從磁碟移除資料:

DELETE FROM accounts WHERE id = 1;
執行後:
  tuple: xmin=100, xmax=201, balance=100  ← 邏輯刪除,物理仍存在

VACUUM 執行後:
  (空間釋放,tuple 從 page 中移除)

Dead Tuple 會持續佔用空間,需要 VACUUM 定期清理。

Snapshot Isolation

每個 Transaction 開始時會獲取一個 Snapshot。Snapshot 記錄三個關鍵資訊:

欄位說明
xmin獲取 Snapshot 時最小的活躍 TXid(此 TXid 之前全部已提交)
xmax獲取 Snapshot 時下一個尚未分配的 TXid(此 TXid 之後全未開始)
xip_list獲取 Snapshot 時所有活躍 TXid 的列表

可見性簡化規則:

  • TXid < xmin → 已提交,可見
  • TXid >= xmax → 尚未開始,不可見
  • TXidxip_list 中 → 活躍中,不可見
  • TXid 不在 xip_list 且在 [xmin, xmax) 範圍內 → 已提交,可見
-- 查看完整 Snapshot 資訊
SELECT
    txid_snapshot_xmin(txid_current_snapshot()) AS snap_xmin,
    txid_snapshot_xmax(txid_current_snapshot()) AS snap_xmax,
    txid_snapshot_xip(txid_current_snapshot()) AS in_progress_txids;

-- PG14+ 的新函數
SELECT pg_current_snapshot();

Transaction Isolation Level

PostgreSQL 支援四個隔離等級,但實際只有三種行為:

隔離等級Dirty ReadNon-repeatable ReadPhantom ReadWrite Skew
Read Uncommitted*不可能可能可能可能
Read Committed(預設)不可能可能可能可能
Repeatable Read不可能不可能不可能**可能
Serializable (SSI)不可能不可能不可能不可能

*Read Uncommitted 在 PostgreSQL 中實際等同 Read Committed **PostgreSQL 的 Repeatable Read 實際是 Snapshot Isolation,比 SQL 標準更強

Read Committed(預設)

每條 SQL 執行前獲取新的 Snapshot:

-- Session 1
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 看到 100

-- Session 2(同時)
UPDATE accounts SET balance = 200 WHERE id = 1;
COMMIT;

-- Session 1(繼續)
SELECT balance FROM accounts WHERE id = 1;  -- 看到 200(新 snapshot)
COMMIT;

Repeatable Read

整個 Transaction 使用同一個 Snapshot:

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;  -- 看到 100

-- 即使其他 Session 修改並 commit,此 Transaction 仍看到 100
SELECT balance FROM accounts WHERE id = 1;  -- 仍然看到 100
COMMIT;

Serializable(SSI)

在 Snapshot Isolation 基礎上加入 Serializable Snapshot Isolation(SSI),透過追蹤 read-write dependency 偵測 Write Skew:

-- 醫生值班問題(Write Skew 範例)
-- TX1 和 TX2 同時讀取醫生列表,各自讓不同醫生休假
-- 在 Serializable 下,其中一個會被偵測到 rw-conflict 並回滾
START TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call = true;
-- 確認至少有 2 人值班後...
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;  -- 若偵測到 Write Skew,此 COMMIT 會失敗

Transaction ID Wraparound 防護

Transaction ID 是 32-bit unsigned integer,空間僅有約 42 億。PostgreSQL 使用模運算比較 TXid 大小,形成環形空間:

TXid 空間(環形):

              0 (frozen)
              │
    ┌─────────┴──────────┐
    │                    │
 past ◄──────────────────► future
    │    ≈ 2^31 ≈ 21億    │
    └────────────────────┘

若 TXid 用盡循環,舊資料的 xmin 可能被誤判為「未來」,導致資料消失!PostgreSQLFreeze 機制 解決:將足夠舊的 Tuple 的 xmin 替換為特殊值 FrozenTransactionId (2),使其永遠「可見」。

監控 Wraparound 風險:

-- 監控各資料庫的 Wraparound 風險
SELECT
    datname,
    age(datfrozenxid) AS xid_age,
    2^31 - age(datfrozenxid) AS xid_remaining
FROM pg_database
ORDER BY xid_age DESC;

-- 監控表級別的 Freeze 狀態
SELECT
    schemaname,
    tablename,
    age(relfrozenxid) AS table_age
FROM pg_stat_user_tables
ORDER BY table_age DESC
LIMIT 20;

Hint Bits:加速可見性判斷

每次讀取 Tuple 都查詢 CLOG 會有效能開銷。PostgreSQL 引入 Hint Bits 機制,在 Tuple 的 infomask 中快取 Transaction 的 commit/abort 狀態:

infomask 中的 Hint Bits:
  HEAP_XMIN_COMMITTED  (0x0100):xmin 已提交
  HEAP_XMIN_INVALID    (0x0200):xmin 已回滾
  HEAP_XMAX_COMMITTED  (0x0400):xmax 已提交
  HEAP_XMAX_INVALID    (0x0800):xmax 已回滾

一旦 Hint Bits 被設定,後續讀取就不需要再查詢 CLOG,大幅提升效能。

值得注意的是,設定 Hint Bits 需要修改 Tuple,即使是純讀取操作也會產生 dirty page。

觀察 MVCC 行為

透過以下實驗,可以親眼看到 MVCC 的運作方式:

-- Session 1:開始 transaction 但不提交
BEGIN;
UPDATE accounts SET balance = 999 WHERE id = 1;
-- 此時不 COMMIT

-- Session 2:查看 dead tuple 和活躍 transaction
SELECT
    pid,
    query,
    state,
    backend_xid,
    backend_xmin
FROM pg_stat_activity
WHERE state != 'idle';

最佳實踐

選擇合適的隔離等級

  • Read Committed:大多數 OLTP 場景的最佳選擇,效能最好
  • Repeatable Read:需要一致性報表查詢時使用
  • Serializable:需要嚴格正確性(如金融交易)時考慮,但有效能代價

避免 Lost Update

Read Committed 下可能發生 Lost Update:

-- 錯誤做法:先讀再寫,中間可能被其他 TX 覆蓋
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 讀到 100
-- 其他 TX 同時修改為 50 並 COMMIT
UPDATE accounts SET balance = 100 + 100 WHERE id = 1;  -- 實際變成 150
COMMIT;

-- 正確做法:使用 SELECT FOR UPDATE 取得行鎖
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- 取得行鎖
-- 其他 TX 會等待
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;

避免長時間 idle in transaction

長時間處於 idle in transaction 狀態的 Session 會阻止 VACUUM 清理 Dead Tuple:

-- 查找問題 session
SELECT
    pid,
    now() - query_start AS duration,
    query,
    state
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY duration DESC;

-- 設定自動超時
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
SELECT pg_reload_conf();

總結

MVCC 是 PostgreSQL 的靈魂機制,它的影響遍及所有層面:

  • 影響儲存:每個 Tuple 都有 xmin/xmax 標記,舊版本不立即刪除,需要 VACUUM 清理
  • 影響交易:隔離等級透過 Snapshot 實現,不同等級看到的資料一致性不同
  • 影響並行:讀操作完全不需要鎖定,與寫操作互不阻塞
  • 影響備份pg_dump 利用 MVCC 特性實現一致性備份,無需鎖定整個資料庫

下一篇,我們將深入探討 WAL(Write-Ahead Log)——PostgreSQL 保障資料持久性與支援複寫的基石。

BenZ Software Developer

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