目次を表示する

RDB内部構造完全ガイド

VACUUM と Bloat 対策 ── Dead Tuple との戦い

VACUUM と Bloat 対策 ── Dead Tuple との戦い

このレイヤーの役割:MVCC の副産物である Dead Tuple を回収し、テーブルの肥大化(Bloat)を防ぐ。PostgreSQL 運用の最重要トピック。


VACUUMとBloat — Dead Tuple回収・autovacuum・TX ID Wraparound

この章で何ができるようになるか:VACUUM の動作原理と autovacuum の設定を理解し、Bloat の監視と対策ができるようになる。


なぜ VACUUM が必要か

Ch.6(MVCC)で見たように、UPDATE は「旧タプルに削除マーク + 新タプル挿入」として動作する。旧タプル(Dead Tuple)はどのトランザクションからも見えなくなるが、物理的にはヒープに残り続ける

Dead Tuple が蓄積すると:
  1. テーブルサイズが際限なく肥大化
  2. Seq Scan が遅くなる(Dead Tuple も物理的にスキャンする)
  3. インデックスが肥大化(Dead Tuple を指すエントリが残る)
  4. Visibility Map が更新されない → Index Only Scan が効かない

VACUUM の動作

VACUUM(通常):
  1. テーブルの各ページをスキャン
  2. Dead Tuple を特定(どの TX からも見えないタプル)
  3. Dead Tuple が占めていた空間を「再利用可能」としてマーク(FSM に登録)
  4. 対応するインデックスエントリを削除
  5. Visibility Map を更新
  6. ★ テーブルファイルのサイズは縮まらない(空き領域が再利用されるだけ)

VACUUM FULL:
  1. テーブル全体を新しいファイルにコピー(Live Tuple だけ)
  2. 旧ファイルを削除
  3. ★ テーブルサイズが実際に縮む
  4. ★ ACCESS EXCLUSIVE ロック(テーブルが完全にロックされる)
  → 本番環境では基本的に使わない

autovacuum:自動 VACUUM

-- autovacuum の基本パラメータ
SHOW autovacuum;                    -- on(デフォルト)
SHOW autovacuum_vacuum_threshold;   -- 50(最低 Dead Tuple 数)
SHOW autovacuum_vacuum_scale_factor; -- 0.2(テーブルの 20%)
SHOW autovacuum_naptime;            -- 1min(autovacuum の起動間隔)

-- autovacuum が発動する条件:
-- Dead Tuples > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × reltuples
-- 例: 100万行のテーブルでは Dead Tuples > 50 + 0.2 × 1000000 = 200050

テーブルごとのカスタマイズ

-- 更新が激しいテーブルは、より頻繁に VACUUM を走らせる
ALTER TABLE hot_table SET (
    autovacuum_vacuum_scale_factor = 0.01,  -- 1% で発動(デフォルト 20%)
    autovacuum_vacuum_threshold = 100,
    autovacuum_vacuum_cost_delay = 2        -- より積極的に VACUUM
);

-- ほぼ更新されないテーブルは、autovacuum を緩める
ALTER TABLE archive_table SET (
    autovacuum_vacuum_scale_factor = 0.5,  -- 50% まで待つ
    autovacuum_enabled = false              -- 完全に無効化(慎重に)
);

Transaction ID Wraparound:VACUUM を怠ると起きる大惨事

PostgreSQL の Transaction ID は32ビット(約42億)で、周回する

TX ID の空間: 0 〜 4,294,967,295(約42億)

問題:
  TX ID 100 で INSERT されたタプルが、TX ID 2,147,483,748 を超えた TX から
  「未来の TX が INSERT した」と解釈される(周回)
  → データが突然見えなくなる

VACUUM の責務:
  「凍結(Freeze)」— 古いタプルの t_xmin を特殊な値 FrozenXid に書き換える
  → どの TX からも常に「可視」になる
  → TX ID の周回に影響されなくなる

autovacuum_freeze_max_age = 200000000(デフォルト)
  → 2億トランザクション経過したら強制的に VACUUM Freeze が走る
-- テーブルごとの Freeze 状態を確認
SELECT
  relname,
  age(relfrozenxid) AS xid_age,
  pg_size_pretty(pg_relation_size(oid)) AS size
FROM pg_class
WHERE relkind = 'r'
ORDER BY age(relfrozenxid) DESC
LIMIT 10;
-- xid_age が autovacuum_freeze_max_age に近づいたら警告

Bloat の監視

-- pgstattuple 拡張で Bloat を測定
CREATE EXTENSION pgstattuple;

SELECT
  table_len,
  tuple_count,
  dead_tuple_count,
  dead_tuple_percent,
  free_space,
  free_percent
FROM pgstattuple('users');

-- dead_tuple_percent が 20% を超えたら VACUUM が追いついていない
-- テーブルの実サイズ vs 理論的な最小サイズ
SELECT
  relname,
  pg_size_pretty(pg_relation_size(relid)) AS actual_size,
  pg_size_pretty(
    (reltuples * (SELECT avg(pg_column_size(t.*)) FROM users t LIMIT 1000))::bigint
  ) AS estimated_min_size
FROM pg_stat_user_tables
ORDER BY pg_relation_size(relid) DESC;

Bloat を縮小する方法(VACUUM FULL を使わずに)

pg_repack

テーブルを裏でリビルドし、短時間のロックで切り替える(VACUUM FULL の安全な代替)。

# pg_repack の実行(オンラインで実行可能)
pg_repack --table users --no-order --jobs 4 mydb
# → テーブル全体を新しいファイルにコピー
# → 最後に短時間のロック(ミリ秒〜秒)で切り替え
# → インデックスも再構築

CLUSTER

指定したインデックスの順序でテーブルを物理的に並べ替える(ACCESS EXCLUSIVE ロック)。

CLUSTER users USING idx_users_created_at;
-- → created_at 順に物理的にソート → 範囲検索が高速化
-- → ただし ACCESS EXCLUSIVE ロックが必要

autovacuum のトラブルシューティング

-- autovacuum が動いているか確認
SELECT
  relname,
  last_vacuum,
  last_autovacuum,
  n_dead_tup,
  n_live_tup,
  ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

-- autovacuum が遅い原因の確認
SELECT
  pid,
  query,
  state,
  backend_xid,
  backend_xmin  -- ← これが古いと VACUUM が Dead Tuple を回収できない
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC
LIMIT 5;
-- → 長時間実行中の TX が Dead Tuple の回収を妨げている

最大の敵:長時間トランザクション

長時間トランザクション(例: 何時間も開いたまま放置された TX)があると、
その TX の開始時点より後に DELETE された Dead Tuple を VACUUM が回収できない。
→ Dead Tuple が際限なく蓄積 → Bloat → 性能劣化

対策:
  - idle_in_transaction_session_timeout を設定(例: 10分)
  - 長時間バッチ処理は定期的に COMMIT する
  - pg_stat_activity で長時間 TX を監視
SET idle_in_transaction_session_timeout = '10min';
-- → 10分以上 idle in transaction 状態の接続を自動切断

まとめ

概念役割設定ポイント
VACUUMDead Tuple の空間を再利用可能にするautovacuum_vacuum_scale_factor
VACUUM FULLテーブルを物理的に縮小(ロックあり)本番では避ける → pg_repack
autovacuum自動 VACUUM の実行テーブルごとにカスタマイズ
FreezeTX ID Wraparound 防止autovacuum_freeze_max_age
Bloat 監視pgstattuple / dead_tuple_percent20% 超で対策
長時間 TX 対策VACUUM のブロッカー排除idle_in_transaction_session_timeout