PostgreSQL 日常維護:REINDEX、pg_repack、Bloat 偵測與健檢腳本 | PostgreSQL

2026/07/17
PostgreSQL 日常維護:REINDEX、pg_repack、Bloat 偵測與健檢腳本 | PostgreSQL

PostgreSQL 的 MVCC 設計讓讀寫不互相阻塞,但也帶來了 Bloat(膨脹)、統計資訊失準與索引碎片化等維護需求。本文涵蓋 REINDEX CONCURRENTLYpg_repack 線上重組、Bloat 偵測、ANALYZE 統計更新、日誌輪替與自動化健檢腳本的完整維運手冊。

為什麼需要日常維護

PostgreSQL 的 MVCC(Multi-Version Concurrency Control)設計是大多數維護需求的根源:

維護需求的三大來源:

1. Bloat(膨脹)
   UPDATE / DELETE 不直接移除舊版本,留下 dead tuple
   → 表文件變大 → Sequential Scan 變慢
   → 索引頁碎片化 → Index Scan 效率降低

2. 統計資訊失準(Stale Statistics)
   Query Planner 依靠 pg_statistic 選擇執行計畫
   → 錯估 row 數 → 選錯 Join 方法 → 選錯是否使用索引

3. 索引碎片化(Index Fragmentation)
   B-Tree 索引在大量 INSERT/UPDATE/DELETE 後
   → 頁填充率下降 → 半空頁增加 → 索引深度增加

維護工作分類

依照執行頻率分為四個層次:

頻率工作項目
日常確認 autovacuum 正常運作、replication lag、long-running query、磁碟用量、錯誤日誌
週度檢查 Bloat 報表、vacuum/analyze 時間戳、備份完整性、Transaction ID 消耗速度
月度pg_repack 重組 Bloat 嚴重的表、REINDEX 碎片化索引、審視 slow query log、移除 unused index
季度完整健康檢查、審視 postgresql.conf、版本升級計畫、備份還原演練

REINDEX:索引重建

REINDEX 重建索引的物理結構,回收碎片空間。

偵測索引碎片

-- 安裝 pgstattuple 擴充
CREATE EXTENSION IF NOT EXISTS pgstattuple;

-- 查詢索引碎片狀況
SELECT
  indexrelid::regclass AS index_name,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
  (pgstatindex(indexrelid::regclass)).avg_leaf_density,
  (pgstatindex(indexrelid::regclass)).leaf_fragmentation,
  (pgstatindex(indexrelid::regclass)).tree_level
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;

-- 判斷標準:
-- leaf_fragmentation > 30% → 考慮 REINDEX
-- avg_leaf_density < 50%   → 頁面使用率過低
-- tree_level >= 4          → 索引過深

REINDEX CONCURRENTLY(不鎖定讀寫)

PostgreSQL 12+ 支援 REINDEX CONCURRENTLY,重建期間不阻塞 DML 操作:

-- 重建單一索引(CONCURRENTLY 不鎖表)
REINDEX INDEX CONCURRENTLY idx_orders_user_id;

-- 重建表上所有索引
REINDEX TABLE CONCURRENTLY orders;

-- 重建整個 Schema 的所有索引(PG14+)
REINDEX SCHEMA CONCURRENTLY public;

注意事項:

  • CONCURRENTLY 需要約 2 倍空間(臨時索引 + 舊索引)
  • 若中斷,會留下 INVALID 索引,需手動刪除後重試
  • 不能在 Transaction Block 內執行
-- 確認 INVALID 索引(失敗後需清理)
SELECT schemaname, tablename, indexname
FROM pg_indexes
WHERE indexname IN (
  SELECT relname FROM pg_class
  JOIN pg_index ON pg_class.oid = pg_index.indexrelid
  WHERE NOT pg_index.indisvalid
);

pg_repack:線上表重組

pg_repack 能在不持有 AccessExclusiveLock 的情況下重組整張表或索引,是處理嚴重 Bloat 的最佳工具。

安裝

# Ubuntu / Debian
apt-get install postgresql-17-repack

# macOS
brew install pg_repack

# 在目標資料庫建立擴充
psql -d mydb -c "CREATE EXTENSION pg_repack;"

工作原理

pg_repack 工作流程:

1. 建立臨時表(_pgrepack_xxx)
2. 複製原表資料到臨時表
3. 透過 Trigger 記錄複製期間的 INSERT/UPDATE/DELETE
4. 套用 Trigger 記錄到臨時表(追趕)
5. 取得短暫的 AccessExclusiveLock(通常 < 1 秒)
6. 切換系統目錄,臨時表成為新的原表
7. 刪除舊表

只有步驟 5-6 需要鎖定,其餘時間完全不阻塞讀寫。
注意:表必須有 PRIMARY KEY(無 PK 的表無法重組)。

使用範例

# 重組單張表(線上,不鎖定)
pg_repack --host=localhost --dbname=mydb \
  --username=postgres --table=orders

# 重組整個資料庫
pg_repack --host=localhost --dbname=mydb --username=postgres

# 只重組索引(不重組表本身)
pg_repack --host=localhost --dbname=mydb \
  --username=postgres --table=orders --only-indexes

# 搭配鎖等待超時
pg_repack --host=localhost --dbname=mydb \
  --username=postgres --table=orders --wait-timeout=60

# 乾跑模式(只顯示計畫)
pg_repack --host=localhost --dbname=mydb \
  --username=postgres --table=orders --dry-run

Bloat 偵測

快速近似偵測(pgstattuple_approx)

CREATE EXTENSION IF NOT EXISTS pgstattuple;

-- 快速近似 Bloat(大表推薦,PG9.5+)
SELECT
  relname,
  pg_size_pretty(pg_relation_size(oid)) AS table_size,
  ROUND((dead_tuple_percent + free_percent)::numeric, 2) AS bloat_pct
FROM pgstattuple_approx(oid)
JOIN pg_class ON true
WHERE relname = 'orders';

不依賴 pgstattuple 的輕量估算

-- 基於系統目錄估算表 Bloat(速度快,不需擴充)
SELECT
  schemaname,
  tablename,
  pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
  n_dead_tup,
  n_live_tup,
  ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct,
  last_autovacuum
FROM pg_stat_user_tables
WHERE schemaname = 'public'
  AND n_dead_tup > 1000
ORDER BY dead_pct DESC
LIMIT 15;

索引 Bloat 偵測

-- 使用 pgstatindex 偵測索引 Bloat
SELECT
  schemaname,
  indexrelname AS index_name,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
  ROUND((pgstatindex(indexrelid)).leaf_fragmentation::numeric, 1) AS frag_pct,
  ROUND((pgstatindex(indexrelid)).avg_leaf_density::numeric, 1) AS fill_pct
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
  AND pg_relation_size(indexrelid) > 10 * 1024 * 1024
ORDER BY frag_pct DESC
LIMIT 20;

ANALYZE:統計資訊更新

ANALYZE 收集表的欄位統計資訊,讓 Query Planner 做出更準確的決策。

何時需要手動 ANALYZE

-- 確認表的最後 ANALYZE 時間
SELECT
  schemaname, tablename,
  n_live_tup, n_dead_tup,
  n_mod_since_analyze,
  last_autoanalyze, last_analyze,
  ROUND(n_mod_since_analyze::numeric / NULLIF(n_live_tup, 0) * 100, 1) AS mod_pct
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY n_mod_since_analyze DESC;

-- 需要手動 ANALYZE 的情況:
-- 1. 大量載入資料後(COPY、批次 INSERT)
-- 2. autovacuum 來不及追趕變動速率
-- 3. 執行計畫突然變差

-- 手動 ANALYZE(不鎖表)
ANALYZE orders;

-- ANALYZE 特定欄位
ANALYZE orders (user_id, created_at);

統計目標(statistics_target)

-- 調高特定欄位的統計目標(用於基數很高的欄位)
ALTER TABLE orders ALTER COLUMN user_id SET STATISTICS 500;
ANALYZE orders (user_id);

-- 降低統計目標(用於固定值域的欄位)
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 50;

-- 查看統計資訊的實際內容
SELECT attname, n_distinct, correlation,
  most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';

大表維護策略

分割表維護

分割表(Partitioned Table)可以針對特定分割區執行維護,大幅降低鎖定範圍:

-- 查看分割區列表與大小
SELECT
  parent.relname AS parent_table,
  child.relname AS partition_name,
  pg_size_pretty(pg_relation_size(child.oid)) AS partition_size
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'orders'
ORDER BY child.relname;

-- 對特定分割區執行 VACUUM ANALYZE
VACUUM ANALYZE orders_2024_01;

-- 對特定分割區執行 REINDEX
REINDEX TABLE CONCURRENTLY orders_2024_01;

-- 舊分割區可以直接 DETACH,消除 Bloat 問題
ALTER TABLE orders DETACH PARTITION orders_2022;
DROP TABLE orders_2022;

歸檔策略(批次移動)

-- 將舊資料批次移至歸檔表
DO $$
DECLARE
  v_batch_size INT := 10000;
  v_deleted INT;
BEGIN
  LOOP
    WITH moved AS (
      DELETE FROM orders
      WHERE created_at < NOW() - INTERVAL '2 years'
        AND id IN (
          SELECT id FROM orders
          WHERE created_at < NOW() - INTERVAL '2 years'
          LIMIT v_batch_size
          FOR UPDATE SKIP LOCKED
        )
      RETURNING *
    )
    INSERT INTO orders_archive SELECT * FROM moved;

    GET DIAGNOSTICS v_deleted = ROW_COUNT;
    EXIT WHEN v_deleted = 0;
    PERFORM pg_sleep(0.1);  -- 避免過度佔用 I/O
  END LOOP;
END $$;

日誌輪替

PostgreSQL 內建日誌輪替

# postgresql.conf — 日誌收集器設定
logging_collector = on
log_directory = 'log'

# 每天一個檔案,7 個檔案循環
log_filename = 'postgresql-%a.log'  -- %a = 星期縮寫
log_rotation_age = 1d
log_rotation_size = 500MB           -- 超過 500MB 立即輪替
log_truncate_on_rotation = on       -- 覆寫 7 天前的同名檔案

Linux logrotate 搭配

# /etc/logrotate.d/postgresql
/var/log/postgresql/*.log {
    daily
    rotate 14          # 保留 14 天
    compress           # gzip 壓縮舊日誌
    delaycompress      # 最新的舊日誌不壓縮
    missingok
    notifempty
    create 0640 postgres postgres
    sharedscripts
    postrotate
        /usr/bin/pg_ctlcluster 17 main reload
    endscript
}

連線管理

逾時設定

# postgresql.conf
idle_in_transaction_session_timeout = '10min'  -- 閒置 Transaction 逾時
idle_session_timeout = '30min'                 -- 閒置連線逾時(PG14+)
statement_timeout = '5min'                     -- 查詢執行逾時
lock_timeout = '30s'                           -- 鎖等待逾時

連線狀態與逾時對照:

連線狀態對應逾時設定危險度
activestatement_timeout
idleidle_session_timeout
idle in transactionidle_in_transaction_session_timeout
idle in transaction (aborted)同上最高

殭屍連線清理

-- 找出閒置超過 30 分鐘的連線
SELECT pid, usename, state, state_change, LEFT(query, 100)
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'idle in transaction (aborted)')
  AND state_change < NOW() - INTERVAL '30 minutes'
  AND pid <> pg_backend_pid();

-- 終止殭屍連線
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND state_change < NOW() - INTERVAL '30 minutes'
  AND pid <> pg_backend_pid();

-- pg_cancel_backend  → 取消查詢但保留連線(SIGINT)
-- pg_terminate_backend → 終止整個後端程序(SIGTERM)

定期健檢腳本

日檢

-- 1. 確認 autovacuum 運作
SELECT schemaname, tablename, last_autovacuum, n_dead_tup
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC LIMIT 10;

-- 2. 確認複製延遲
SELECT client_addr, application_name,
  ROUND(EXTRACT(EPOCH FROM replay_lag), 1) AS replay_lag_seconds
FROM pg_stat_replication;

-- 3. Long-running Query(超過 5 分鐘)
SELECT pid, usename,
  ROUND(EXTRACT(EPOCH FROM (NOW() - query_start)) / 60, 1) AS run_minutes,
  LEFT(query, 120) AS query
FROM pg_stat_activity
WHERE state = 'active'
  AND query_start < NOW() - INTERVAL '5 minutes'
  AND pid <> pg_backend_pid();

-- 4. Transaction ID Wraparound 風險
SELECT datname,
  age(datfrozenxid) AS xid_age,
  2000000000 - age(datfrozenxid) AS xid_remaining
FROM pg_database WHERE datallowconn
ORDER BY xid_age DESC;
-- 警告閾值:xid_age > 1,500,000,000

自動化維護腳本(cron)

#!/bin/bash
# /opt/scripts/pg_daily_maintenance.sh
# crontab: 0 2 * * * /opt/scripts/pg_daily_maintenance.sh

set -euo pipefail
DB="${PG_DB:-mydb}"
HOST="${PG_HOST:-localhost}"
USER="${PG_USER:-postgres}"
LOG="/var/log/pg_maintenance/$(date '+%Y-%m-%d').log"

mkdir -p /var/log/pg_maintenance

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }

log "=== 開始日常維護 ==="

# 1. ANALYZE 變動量大的表
TABLES=$(psql -h "$HOST" -U "$USER" -d "$DB" -t -A -c "
  SELECT schemaname || '.' || tablename
  FROM pg_stat_user_tables
  WHERE n_mod_since_analyze > 100000
    AND (last_autoanalyze IS NULL OR last_autoanalyze < NOW() - INTERVAL '2 hours')
  LIMIT 10;
")

for T in $TABLES; do
  log "ANALYZE $T"
  psql -h "$HOST" -U "$USER" -d "$DB" -c "ANALYZE $T;" || log "WARNING: ANALYZE $T 失敗"
done

# 2. 清理殭屍連線
KILLED=$(psql -h "$HOST" -U "$USER" -d "$DB" -t -A -c "
  SELECT COUNT(*) FROM (
    SELECT pg_terminate_backend(pid)
    FROM pg_stat_activity
    WHERE state IN ('idle in transaction', 'idle in transaction (aborted)')
      AND state_change < NOW() - INTERVAL '30 minutes'
      AND pid <> pg_backend_pid()
  ) t;
")
log "終止殭屍連線數: $KILLED"

log "=== 日常維護完成 ==="

週度 pg_repack 腳本

#!/bin/bash
# /opt/scripts/pg_weekly_repack.sh
# crontab: 0 3 * * 0 /opt/scripts/pg_weekly_repack.sh

DB="${PG_DB:-mydb}"
HOST="${PG_HOST:-localhost}"

BLOAT_TABLES=$(psql -h "$HOST" -U postgres -d "$DB" -t -A -c "
  SELECT tablename FROM pg_stat_user_tables
  WHERE schemaname = 'public'
    AND pg_total_relation_size(schemaname||'.'||tablename) > 500 * 1024 * 1024
    AND ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 1) > 30
  ORDER BY n_dead_tup DESC;
" 2>/dev/null)

for TABLE in $BLOAT_TABLES; do
  echo "重組表: $TABLE"
  pg_repack --host="$HOST" --dbname="$DB" --username=postgres \
    --table="$TABLE" --wait-timeout=120 || echo "WARNING: $TABLE 重組失敗"
done

常見陷阱

陷阱問題解法
VACUUM FULL 代替 pg_repackAccessExclusiveLock 完全鎖表,可能數小時使用 pg_repack(只有最後 < 1 秒鎖定)
REINDEX 沒加 CONCURRENTLY完全鎖表REINDEX TABLE CONCURRENTLY(PG12+)
在事務中執行 REINDEX CONCURRENTLY不能在 Transaction Block 中執行直接在 psql 中執行,不包在 BEGIN/COMMIT
忽略 autovacuum_freeze_max_age觸發 Emergency Vacuum 影響效能監控 age(relfrozenxid)
對無 PK 的表使用 pg_repackpg_repack 需要 PRIMARY KEY先新增 PK 再重組
statement_timeout 影響維護長時間維護被超時終止維護前 SET statement_timeout = 0
日誌磁碟滿PostgreSQL 無法寫入日誌,程序崩潰日誌放獨立掛載點 + 設定 log_rotation

版本演進

版本年份維護相關特性
PG 9.62016idle_in_transaction_session_timeout
PG 122019REINDEX CONCURRENTLYVACUUM SKIP_LOCKED
PG 132020VACUUM PARALLEL、插入觸發 autovacuum
PG 142021idle_session_timeoutREINDEX SCHEMA CONCURRENTLY
PG 152022Stats Collector 改為 Shared Memory
PG 162023pg_stat_io 視圖
PG 172024更積極的 Dead Tuple 清理策略

總結

PostgreSQL 日常維護的核心工作:

  1. REINDEX CONCURRENTLY:定期重建碎片化索引,不鎖表
  2. pg_repack:線上重組 Bloat 嚴重的表,幾乎零停機
  3. Bloat 偵測:pgstattuple / 系統目錄估算,定期監控
  4. ANALYZE:確保統計資訊即時,Query Planner 做出正確決策
  5. 日誌輪替:logging_collector + logrotate 避免磁碟爆滿
  6. 連線管理:逾時設定 + 殭屍連線清理
  7. 自動化健檢腳本:cron 排程執行日檢 / 週檢 / 月檢

下一篇,我們將探討 PostgreSQL 的版本升級——從 pg_upgrade 原地升級到邏輯複製滾動升級的完整策略與實戰步驟。

BenZ Software Developer

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