MVCC 多版本並行控制:PostgreSQL 高效能的核心秘密 | PostgreSQL
MVCC(Multi-Version Concurrency Control) 是 PostgreSQL 最核心的並行機制,其根本原則是:讀取不阻塞寫入,寫入不阻塞讀取。每個 Transaction 看到的是資料在某個時間點的一致性快照(Snapshot),而非即時狀態。理解 MVCC 是掌握 PostgreSQL 所有進階主題的關鍵。
核心原則:空間換時間
傳統資料庫以鎖(Lock)實現隔離,讀者與寫者相互阻塞。MVCC 則採取不同策略——保留資料的多個版本,讓讀者讀取舊版本,寫者建立新版本,兩者互不干擾。
PostgreSQL 的 MVCC 實作與 Oracle、MySQL InnoDB 有根本差異:
| 特性 | PostgreSQL | Oracle / MySQL InnoDB |
|---|---|---|
| 舊版本儲存位置 | Heap Tuple 直接保留 | Undo Log(獨立區域) |
| 讀一致性來源 | 多版本 Heap | Undo 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→ 尚未開始,不可見TXid在xip_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 Read | Non-repeatable Read | Phantom Read | Write 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 可能被誤判為「未來」,導致資料消失!PostgreSQL 用 Freeze 機制 解決:將足夠舊的 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 保障資料持久性與支援複寫的基石。