
2025/12/14 22:13
Vacuum Is a Lie: About Your Indexes
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
PostgreSQL の
VACUUM はテーブルから死んだタプルを削除しますが、空の B‑tree インデックスページはそのままにしておくため、大規模な削除後にはテーブルと主キーインデックスが膨張した状態のまま残ります。約 80 % の行を削除するデモでは、ヒープは約 7 472 kB のままで、主キーインデックスは 2 208 kB に留まりました。pg_stat_index は平均葉密度 (avg_leaf_density) を 86 % と報告しましたが、bloat_ratio が 2.8 であるため、インデックスは期待値のほぼ三倍に膨らんでいることを示しています。 VACUUM はページを再利用可能としてマークするだけで OS に返却しないため、物理ページ数は増え続け、プランナーがそのカウントに基づく推定値でコスト計算を誤り、不要なシーケンシャルスキャンを引き起こすことがあります。
テーブルをオフラインにせずにスペースを回収するには、管理者は
REINDEX CONCURRENTLY でインデックスを再構築できます。これにより demo_pkey のサイズが 2 208 kB から 456 kB(約 79 % 削減)になりました。オンラインツールの pg_squeeze は論理デコードを使ってテーブルとインデックスの両方を縮小します。完全な VACUUM FULL も同様の結果が得られますが、ACCESS EXCLUSIVE ロックとダウンタイムが必要です。
B‑tree インデックスのデフォルト fillfactor が 90 % であっても削除による膨張は防げず、
ANALYZE は統計情報のみを更新し(relpages を変更しない)、bloat_ratio が約 1.8–2.0 を超えると調査のトリガーとして有効です。閾値は環境ごとにチューニングできます。この結果、クエリ計画が高速化され、ストレージコストが削減されつつデータベースをオンラインで維持できます。本文
よくある誤解
「VACUUM(または VACUUM FULL)を実行すれば PostgreSQL が健全に保たれる」という広まった考えがあります。
死んだタプルが除去され、トランザクション ID が再利用され、スペースが回収されると信じられていますが、実際には VACUUM はインデックスの膨張を解消しません。
それはヒープ(テーブル)のデータのみを掃除しており、B‑tree 構造自体はそのままです。
ストレージ構成
| レイヤー | 何を保存するか | 動作 |
|---|---|---|
| Heap(テーブル) | 順序付けられない行を 8 KB ページに格納 | 行は空き領域があればどこでもスロットされる。削除された行は隙間となり、後の挿入で埋められる。 |
| Index | ヒープタプルへのポインタを並べた B‑tree ページ | 並び順を維持しなければならないため、ページ間で自由にエントリを移動できない。 |
VACUUM が異なる理由
- ヒープでは、VACUUM はページ内の行を詰め直し、空きページを回収できる。
- インデックスでは 「死んだインデックスエントリだけを削除」 する。
- 空きページを完全に削除して再利用可能にすることはない。
- 木構造の深さを減らしたり、物理的な B‑tree 配置を変えたりしない。
ヒープはテトリスみたいだが、インデックスは並べ替え済みの本棚のようです。VACUUM は本を取り出すだけで、残ったものをまとめ直せません。
実験
CREATE EXTENSION IF NOT EXISTS pgstattuple; CREATE TABLE demo (id integer PRIMARY KEY, data text); -- 100 000 行 INSERT INTO demo(id, data) SELECT g, 'Row number '||g||' with some extra data' FROM generate_series(1,100000) g; ANALYZE demo;
ベースライン
SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS file_size, pg_size_pretty((pgstattuple(oid)).tuple_len) AS actual_data FROM pg_class WHERE relname IN ('demo', 'demo_pkey');
| relname | file_size | actual_data |
|---|---|---|
| demo | 7 472 kB | 6 434 kB |
| demo_pkey | 2 208 kB | 1 563 kB |
大半の行を削除
DELETE FROM demo WHERE id BETWEEN 10001 AND 90000; VACUUM demo;
VACUUM 後のスナップショット:
| relname | file_size | actual_data |
|---|---|---|
| demo | 7 472 kB | 1 278 kB |
| demo_pkey | 2 208 kB | 1 563 kB |
- テーブルは縮小した。
- インデックスのサイズは変わらない。
- VACUUM は OS にスペースを返さず、ページを再利用可能にしかマークしない。
ページ状態
| 状態 | 密度 | 備考 |
|---|---|---|
| Full (>80 %) | 使い切りに近い | - |
| Partial (40–80 %) | 少し余白がある | エッジや軽い更新時は普通。 |
| Sparse (<40 %) | 主に空き | 8 KB ページを数行だけ読む。 |
| Empty (0 %) | ライブ行なしだがツリー内に残る | オーバーヘッド。 で回収できる。 |
フィルファクター
- 挿入時にページをどれくらい埋め込むかを決定。
- デフォルトは B‑tree の fillfactor が 90 %。
- 低く設定(例:
)すると将来の挿入余地が増えるが、ほとんど行を削除した場合には膨張が 悪化 します。WITH (fillfactor = 70)
プランナーの誤推定
EXPLAIN ANALYZE SELECT * FROM demo WHERE id BETWEEN 10001 AND 90000;
プランナーは 200 行と想定したが、実際は 0 行。
インデックスに空きページが多いためです。
ANALYZE は行数を更新するだけで relpages を縮小しないので、プランナーはまだ 276 ページとみなす。
インデックス統計
SELECT * FROM pgstatindex('demo_pkey');
| フィールド | 値 |
|---|---|
| tree_level | 1 |
| leaf_pages | 57 |
| empty_pages | 0 |
| deleted_pages | 217 |
| avg_leaf_density | 86.37 |
注:86 % の密度は健全に見えるが、インデックスファイルの 79 % が不要である。
膨張率
SELECT c.relname AS index_name, pg_size_pretty(pg_relation_size(c.oid)) AS actual_size, pg_size_pretty((c.reltuples * 40)::bigint) AS expected_size, round(pg_relation_size(c.oid)/NULLIF(c.reltuples*40,0),1) AS bloat_ratio FROM pg_class c JOIN pg_index i ON c.oid = i.indexrelid WHERE c.relkind = 'i' AND c.reltuples > 0 AND c.relname NOT LIKE 'pg_%' AND pg_relation_size(c.oid) > 1_048_576;
| index_name | actual_size | expected_size | bloat_ratio |
|---|---|---|---|
| demo_pkey | 2 208 kB | 781 kB | 2.8 |
約 2.0 を超える比率は調査のサインです。
問題解決策
REINDEX
REINDEX INDEX CONCURRENTLY demo_pkey;
再構築後:
| relname | file_size | actual_data |
|---|---|---|
| demo | 7 472 kB | 1 278 kB |
| demo_pkey | 456 kB | 313 kB |
インデックスは 2.2 MB → 456 KB(約 79 % 削減)。
Tip:
は排他ロックを避けられ、PostgreSQL 12 以降で利用可能です。REINDEX CONCURRENTLY
pg_squeeze
- テーブルとインデックスをオンラインで再構築し、ロックを最小限に抑える。
- ロジカルディコードを使用するため、稼働中のシステムへの影響が少ない。
CREATE EXTENSION pg_squeeze; SELECT squeeze.squeeze_table('public', 'demo');
注意:
はインデックス全体を読み込むので、大きなインデックスでは避けるべきです。pgstatindex()
いつ対処すべきか
| 状況 | 対策 |
|---|---|
| 大量削除(保持ポリシー、GDPR 削除) | 再構築または REINDEX |
> 2.0 が上昇中 | 監視し、メンテナンスを計画 |
| インデックス列で順序付きスキャンが増える | 膨張を調査 |
| 行数に対してインデックスサイズが不釣り合い | REINDEX を検討 |
結論
- VACUUM はヒープの膨張は解消する が、インデックス構造はそのまま。
- インデックスの膨張には明示的な操作(REINDEX、pg_squeeze、あるいは VACUUM FULL)が必要です。
差異を理解し、適切な指標を監視して、VACUUM の静寂がすべて正常であると勘違いしないようにしましょう。