VACUUM 機制:PostgreSQL 空間回收與防膨脹的關鍵 | PostgreSQL

2026/06/12
VACUUM 機制:PostgreSQL 空間回收與防膨脹的關鍵 | PostgreSQL

VACUUMPostgreSQLMVCC 設計而產生的必要維護機制。由於 UPDATE 和 DELETE 並不立即移除舊版本資料,而是留下 Dead Tuple,必須定期執行 VACUUM 來回收空間、更新統計資訊,並防止 Transaction ID Wraparound 問題。理解 VACUUM 是維運 PostgreSQL 的必備知識。

為什麼需要 VACUUM

MVCC 的實作讓 PostgreSQL 在 Heap Tuple 中保留多個版本,供不同 Transaction 看到不同快照。每次 UPDATE 產生新版本、舊版本變成 Dead Tuple;DELETE 也只是邏輯刪除,物理空間仍然佔用。

MVCC 產生 Dead Tuple 的過程:

INSERT(TX 100):
  ┌──────────────────────────────────────┐
  │ xmin=100  xmax=0   balance=100  id=1 │ ← Active tuple(可見)
  └──────────────────────────────────────┘

UPDATE(TX 200):
  ┌──────────────────────────────────────┐
  │ xmin=100  xmax=200  balance=100  id=1│ ← Dead tuple(等待回收)
  ├──────────────────────────────────────┤
  │ xmin=200  xmax=0    balance=200  id=1│ ← Active tuple(可見)
  └──────────────────────────────────────┘

VACUUM 後:
  ┌──────────────────────────────────────┐
  │           (空閒空間)                │ ← 可供新 INSERT 使用
  ├──────────────────────────────────────┤
  │ xmin=200  xmax=0    balance=200  id=1│ ← Active tuple
  └──────────────────────────────────────┘

不執行 VACUUM 的後果:

  • 表格佔用磁碟空間持續增長(Bloat
  • 查詢需要掃描大量 Dead Tuple,效能下降
  • Transaction ID 耗盡(Wraparound),導致資料庫拒絕服務

Dead Tuple 生命週期

Dead Tuple 並非立即可回收——只有當沒有任何活躍 Transaction 需要看到該版本時,VACUUM 才能安全回收。

時間軸:

  TX 100    TX 200         TX 300 (長 transaction)
  INSERT    UPDATE         進行中              結束
    │         │              │                  │
────●─────────●──────────────●──────────────────●────
              │                                 │
              └─ tuple 變成 dead tuple          └─ 此時沒有任何活躍
                 但 TX 300 仍能看到舊版本          TX 需要看到舊版本
                 VACUUM 不能回收                  → VACUUM 可以安全回收

關鍵概念:OldestXmin(最舊的活躍 Snapshot 中的 xmin)。若 Dead Tuple 的 xmax < OldestXmin,說明沒有任何活躍 Transaction 需要看到此版本,VACUUM 可以安全回收。

這也是為什麼長時間 idle in transaction 的 Session 會導致嚴重 Bloat——OldestXmin 無法推進,大量 Dead Tuple 無法被回收。

Standard VACUUM vs VACUUM FULL

特性Standard VACUUMVACUUM FULL
鎖類型ShareUpdateExclusiveLock(不阻塞讀寫)AccessExclusiveLock(完全鎖表)
空間回收標記為 Free Space,可在表內重用,不釋放給 OS完全重建表,真正釋放磁碟空間
執行速度快(只掃描部分頁面)慢(重建整個表)
索引處理清理索引中的 dead entries重建所有索引
線上可用性否(表不可用)
適用場景定期維護(autovacuum)嚴重 Bloat 後的緊急處理

生產環境優先使用 Standard VACUUM(或 autovacuum),嚴重 Bloat 考慮 pg_repack(不鎖表的 VACUUM FULL 替代方案)。

VACUUM 的四個工作

VACUUM 執行時進行四項主要工作:

工作 1:回收 Dead Tuples

掃描 Heap Pages,識別可回收的 Dead Tuple,將其空間標記為 free,並清理索引中指向 Dead Tuple 的 entries。

工作 2:更新 FSM(Free Space Map)

FSM 記錄每個 Page 有多少可用空間。VACUUM 後更新 FSM,使 INSERT/UPDATE 能找到合適的 Page 寫入新資料。

工作 3:更新 VM(Visibility Map)

VM 為每個 Page 維護兩個 bit:

Bit名稱說明
bit 0all-visible此 Page 的所有 Tuple 對所有 Transaction 可見
bit 1all-frozen此 Page 的所有 Tuple 已 Freeze(PG9.6+)

VM 對效能至關重要:

  • VACUUM 設定 all-visible 後,後續 VACUUM 可跳過此 Page
  • Index-Only Scan 依賴 VM——若 all-visible = 1,可直接從 Index 返回值,不需回表

工作 4:Freeze 舊 Transaction ID

xmin 足夠舊的 Tuple 標記為 Frozen(xmin = FrozenTransactionId = 2),防止 Transaction ID Wraparound。

Freeze 機制

Transaction ID 是 32-bit unsigned integer,空間僅有約 42 億。PostgreSQL 使用模運算比較 TXid 大小——若 TXid 用盡循環,舊資料的 xmin 可能被誤判為「未來」,導致資料消失。

Freeze 執行時機:

  TXid 軸:
  │──────────────────────────│─────────────────│
  0                    current-50M         current

  若 xmin 在 Freeze Zone(< current - vacuum_freeze_min_age):
    VACUUM 執行時將 xmin 設為 FrozenTransactionId (2)
    → Frozen Tuple 永遠可見,不受 Wraparound 影響

Anti-Wraparound VACUUM

每個 Relation 在 pg_class.relfrozenxid 記錄其「最老的未 Freeze TXid」。當 age(relfrozenxid) 超過 autovacuum_freeze_max_age(預設 2 億),PostgreSQL 會強制觸發 Anti-Wraparound VACUUM。

-- 監控 Wraparound 風險
SELECT
    datname,
    age(datfrozenxid) AS db_age,
    round(100.0 * age(datfrozenxid) / 2000000000, 2)
        AS pct_toward_wraparound
FROM pg_database
ORDER BY age(datfrozenxid) DESC;

-- 計算距離強制 VACUUM 還有多少 TXid
SELECT
    datname,
    age(datfrozenxid) AS current_age,
    200000000 - age(datfrozenxid) AS txid_until_forced_vacuum,
    2147483647 - age(datfrozenxid) AS txid_until_shutdown
FROM pg_database
ORDER BY age(datfrozenxid) DESC;

age(datfrozenxid) 接近 2^31(約 21 億),PostgreSQL 會進入 shutdown mode,拒絕所有新 Transaction,只允許超級使用者連線執行 VACUUM FREEZE

Autovacuum 觸發條件

Autovacuum 根據以下公式決定是否觸發:

VACUUM 觸發閾值:
  threshold = autovacuum_vacuum_threshold
            + autovacuum_vacuum_scale_factor × reltuples
  預設:50 + 0.2 × 表行數

  若 n_dead_tup > threshold → 觸發 autovacuum

例子(100 萬行的表):
  threshold = 50 + 0.2 × 1,000,000 = 200,050
  → 超過 20 萬 dead tuple 才觸發

Autovacuum 調校

核心參數:

參數預設值說明
autovacuumon開關(絕對不要關!)
autovacuum_max_workers3同時執行的 autovacuum 數
autovacuum_naptime1min每次巡查間隔
autovacuum_vacuum_scale_factor0.2比例觸發閾值(20%)
autovacuum_vacuum_cost_delay2ms每次 I/O 後的延遲(限速)
autovacuum_vacuum_cost_limit200每輪的 I/O cost 上限
autovacuum_freeze_max_age200000000Freeze 觸發 age

對於大表,預設 20% 的 scale_factor 可能導致 VACUUM 觸發不夠頻繁:

-- 大型交易表(1000 萬行):設定更激進的閾值
ALTER TABLE transactions SET (
    autovacuum_vacuum_scale_factor = 0.01,    -- 1%
    autovacuum_analyze_scale_factor = 0.005,  -- 0.5%
    autovacuum_vacuum_threshold = 1000,
    autovacuum_vacuum_cost_delay = 1          -- ms,更積極
);

-- 高頻更新的熱點表:不限速
ALTER TABLE hot_table SET (
    autovacuum_vacuum_cost_delay = 0,
    autovacuum_vacuum_cost_limit = 800,
    autovacuum_vacuum_scale_factor = 0.01
);

監控 Dead Tuple 與 VACUUM 狀態

-- 查看所有表的 Dead Tuple 狀態
SELECT
    schemaname, relname AS tablename,
    n_live_tup AS live_tuples,
    n_dead_tup AS dead_tuples,
    CASE
        WHEN n_live_tup = 0 THEN NULL
        ELSE round(100.0 * n_dead_tup / n_live_tup, 2)
    END AS dead_ratio_pct,
    last_vacuum, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;

-- 查看 VACUUM 即時進度(PG9.6+)
SELECT
    pid,
    relid::regclass AS table_name,
    phase,
    heap_blks_total,
    heap_blks_scanned,
    heap_blks_vacuumed,
    num_dead_tuples
FROM pg_stat_progress_vacuum;

VACUUM 命令實戰

-- 對指定表執行 Standard VACUUM
VACUUM accounts;

-- 顯示詳細進度
VACUUM VERBOSE accounts;

-- 同時更新統計資訊
VACUUM ANALYZE accounts;

-- 強制 Freeze
VACUUM FREEZE accounts;

-- PG13+:使用並行 worker 加速
VACUUM (PARALLEL 4) accounts;

-- 組合選項
VACUUM (VERBOSE, ANALYZE, PARALLEL 4) accounts;

Cost-Based Throttling

Autovacuum 使用 Cost-Based 機制限速,避免過度佔用 I/O:

Cost 計算規則:
  讀取 Buffer(shared buffer 命中):vacuum_cost_page_hit = 1
  讀取 Buffer(需從磁碟讀取):  vacuum_cost_page_miss = 2
  修改 dirty Buffer:            vacuum_cost_page_dirty = 20

  每次累積 cost 達到 vacuum_cost_limit(預設 200)
  → 休眠 vacuum_cost_delay(autovacuum 預設 2ms)
  → 繼續下一輪

SSD 環境可以降低 vacuum_cost_delay 到 0 或 1ms;HDD 環境可以提高到 20-50ms。

pg_repack:線上重組替代 VACUUM FULL

pg_repack 能在不鎖表的情況下重建表,相當於線上版 VACUUM FULL:

# 重組指定表(不鎖表,全程可讀寫)
pg_repack -d mydb -t schema.big_table

# 只重建索引
pg_repack -d mydb -t schema.big_table --only-indexes

# 試運行
pg_repack -d mydb --dry-run

工作原理:建立新表 → 記錄變更(trigger)→ 複製資料 → 追趕變更 → 短暫鎖定交換表名 → 刪除舊表。

Bloat 估算

-- 使用 pgstattuple 擴展(精確但慢)
CREATE EXTENSION pgstattuple;

SELECT
    relname,
    tuple_count,
    dead_tuple_count,
    round(dead_tuple_percent::numeric, 2) AS dead_pct,
    round(free_percent::numeric, 2) AS free_pct
FROM pgstattuple('big_table');

-- 快速估算(使用統計資訊)
SELECT
    tablename,
    pg_size_pretty(pg_total_relation_size(
        schemaname||'.'||tablename)) AS total_size,
    round(100.0 * n_dead_tup /
        NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio_pct
FROM pg_stat_user_tables
WHERE pg_relation_size(
    schemaname||'.'||tablename) > 104857600  -- > 100MB
ORDER BY pg_relation_size(
    schemaname||'.'||tablename) DESC;

常見陷阱

禁用 autovacuum

常見理由是「autovacuum 影響效能」。正確做法是調整 cost_delay / cost_limit,而非關閉 autovacuum。關閉會導致表持續膨脹,最終可能 Wraparound。

長 Transaction 阻止 Dead Tuple 回收

-- 排查長 transaction
SELECT pid, now() - xact_start AS duration, state, query
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'active')
ORDER BY xact_start;

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

大量 DELETE 後表大小不縮小

Standard VACUUM 標記空間為可重用,但不釋放給 OS。若需要釋放磁碟空間,使用 VACUUM FULLpg_repack

生產環境監控告警

-- 告警 1:Wraparound 風險(age > 5 億)
SELECT datname, age(datfrozenxid)
FROM pg_database
WHERE age(datfrozenxid) > 500000000;

-- 告警 2:Dead Tuple 比例過高(> 10%)
SELECT schemaname || '.' || relname AS table_name,
       round(100.0 * n_dead_tup /
           NULLIF(n_live_tup, 0), 2) AS dead_ratio
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
  AND n_dead_tup > n_live_tup * 0.1;

-- 告警 3:autovacuum 被長 transaction 阻塞
SELECT pid,
       now() - xact_start AS transaction_age,
       state, left(query, 100) AS query_snippet
FROM pg_stat_activity
WHERE state != 'idle'
  AND xact_start IS NOT NULL
  AND now() - xact_start > interval '1 hour'
ORDER BY transaction_age DESC;

總結

VACUUM 是 PostgreSQL MVCC 架構的必要代價,也是維運的核心工作:

  • Dead Tuple 是 MVCC 的副產品,不清理會導致 Bloat 與效能下降
  • Standard VACUUM 標記空間可重用但不縮小檔案,VACUUM FULL 會重建但鎖表
  • Visibility Map 讓 VACUUM 跳過乾淨的 Page,也支援 Index-Only Scan
  • Freeze 機制 防止 Transaction ID Wraparound,是資料庫存活的底線
  • Autovacuum 必須保持開啟,針對大表調整 scale_factorcost_delay
  • 嚴重 Bloat 使用 pg_repack 而非 VACUUM FULL,避免鎖表影響服務
  • 監控 age(datfrozenxid) 與 Dead Tuple 比例是日常維運的必做功課

下一篇,我們將探討 System Catalog——PostgreSQL 如何以 Catalog-driven 架構實現無與倫比的可擴展性。

BenZ Software Developer

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