PostgreSQL の Index Only Scan がテーブルを読みに行く理由:可視性マップと Heap Fetches
PostgreSQL のインデックスオンリースキャンが成り立つ2つの条件、MVCC の可視性確認を減らす可視性マップ、INCLUDE によるカバリングインデックスの得失を PostgreSQL 18 のドキュメントで整理し、Heap Fetches と relallvisible で原因を見る方法を扱います。
EXPLAIN ANALYZE の結果に Index Only Scan と出ているのに、そのすぐ下の Heap Fetches が小さくないことがあります。名前からはインデックスだけを読みそうに見えますが、PostgreSQL のインデックスオンリースキャン(index-only scan)は必要ならテーブル本体であるヒープ(heap)を読みに行きます。ヒープを飛ばせるかどうかは、インデックスの定義よりもテーブルの可視性マップ(visibility map)の状態で決まります。
この記事は2026年10月27日時点の最新の正式版である PostgreSQL 18(マイナー 18.6)のドキュメントをもとにしています。同じ日の時点で PostgreSQL 19 は RC 1 の段階で、正式リリースの表にはまだ載っていませんでした。
まず必要な2つの条件
ドキュメントはインデックスオンリースキャンの基本条件を2つ挙げています。
- インデックスの種類がインデックスオンリースキャンに対応していること。B-tree は常に対応し、GiST と SP-GiST は一部の演算子クラスだけが対応します。それ以外のインデックスの種類は対応していません。
- クエリがインデックスに格納された列だけを参照していること。
2つ目の条件の「参照」には、出力列だけでなく WHERE 句も含まれます。ドキュメントの例をそのまま借りると、列 x、y、z を持つテーブルに (x, y) のインデックスがある場合、次の2つのクエリはインデックスオンリースキャンが可能です。
SELECT x, y FROM tab WHERE x = 'key';
SELECT x FROM tab WHERE x = 'key' AND y < 42;
一方、次の2つは z がインデックスにないため使えません。
SELECT x, z FROM tab WHERE x = 'key';
SELECT x FROM tab WHERE x = 'key' AND z < 42;
インデックスには可視性の情報がない
2つの条件を満たしても、それで終わりではありません。PostgreSQL のテーブルスキャンはどれも、取り出した行が現在のクエリの MVCC スナップショットから見える行かどうかを確かめる必要があります。ところがこの可視性の情報はインデックスエントリにはなく、ヒープエントリにしかありません。ドキュメントもこの点に触れ、一見すると行ごとにヒープアクセスが必要に思えるし、最近更新された行なら実際にそのとおりだと説明しています。スナップショットがどの行バージョンを見るかは READ COMMITTED とスナップショット の記事で扱いました。
この確認を減らす仕組みが可視性マップです。PostgreSQL はヒープページごとに、そのページのすべての行が現在と将来のすべてのトランザクションから見えるほど古いかどうかを1ビットで記録します。インデックスオンリースキャンは候補のインデックスエントリを見つけた後、対応するヒープページのビットを確認します。
| 可視性マップのビット | インデックスオンリースキャンの動作 |
|---|---|
| 立っている(all-visible) | 行が見えることが確定しているので、ヒープを読まずにインデックスの値を返す |
| 立っていない | 可視性を確かめるためにヒープエントリを読みに行く。ドキュメントの表現では、この場合は通常のインデックススキャンに比べて性能上の利点はない |
ビットが立っている場合も、ただではありません。ヒープへのアクセスを可視性マップへのアクセスに置き換えているわけです。ドキュメントによると、可視性マップは対象のヒープより4桁(1万分の1程度)小さいので物理 I/O がずっと少なく、たいていはメモリにキャッシュされたままになります。ドキュメントの結論はこうです。インデックスオンリースキャンは2つの基本条件を満たせば可能だが、得になるのはテーブルのヒープページのうちかなりの割合で all-visible ビットが立っているときだけである。
ビットを立てるのは VACUUM、消すのはデータ変更
可視性マップはヒープページごとに2つのビットを持ちます。1つ目はページが all-visible であること、つまり vacuum が必要なタプルを含まないことを表し、インデックスオンリースキャンが使うのはこのビットです。2つ目はページ上のすべてのタプルが凍結(frozen)済みであることを表します。可視性マップはテーブルファイルの隣に _vm という接尾辞の付いた別フォークとして保存され、インデックスには可視性マップがありません。
ビットの更新規則について、ドキュメント(66.4節)は1文でまとめています。可視性マップのビットを立てるのは vacuum だけで、ページを変更するあらゆるデータ変更操作がビットを消します。またこのマップは保守的です。ビットが立っていれば条件が真であることは保証されますが、立っていないからといって条件が偽だとは限りません。
この規則からいくつかのことが導けます。以下はドキュメントの規則から引き出した推論で、ドキュメントにそのまま書かれた文ではありません。
- 大量ロードした直後のテーブルは、vacuum が一度通るまで all-visible のページが少ないことがあり、その間インデックスオンリースキャンはヒープを頻繁に読みに行く可能性があります。
- 更新がテーブルのあちこちに散らばって起きると、ビットが消えたページもあちこちにできます。ページ内の行が1つ変わるだけで、そのページ全体が対象になります。
- HOT 更新 で新しいインデックスエントリを避けても、ヒープページを変更する操作であることに変わりはないので、ドキュメントの文言どおりならそのページのビットは消えます。
挿入だけが起きるテーブルも vacuum の対象になります。定常的なバキューム作業のドキュメント(24.1.6節)によると、前回の vacuum 以降に挿入されたタプル数が挿入しきい値を超えると autovacuum が動き、しきい値は次のように定義されます。
vacuum insert threshold = vacuum base insert threshold
+ vacuum insert scale factor * number of tuples * percent of table not frozen
基本値の部分は autovacuum_vacuum_insert_threshold、係数の部分は autovacuum_vacuum_insert_scale_factor で、タプル数は pg_class.reltuples、凍結されていない割合は 1 - pg_class.relallfrozen / pg_class.relpages です。ドキュメントは、こうした vacuum によってテーブルの一部が all-visible とマークされうると書いています。ただし、この設定がインデックスオンリースキャンのためにあるとは書いていないので、両者を結びつけるのは上の2つの規則を合わせた解釈です。
INCLUDE で作るカバリングインデックス
よく実行するクエリが必要とする列をすべて持つように設計したインデックスを、カバリングインデックスと呼びます。検索キーではない列を「載せておくだけ」にするには INCLUDE 句を使います。注文テーブルを例にするとこうなります。
-- よく実行するクエリ
SELECT created_at, total FROM orders WHERE customer_id = 42;
-- 検索キーは customer_id だけで、created_at と total はペイロード
CREATE INDEX orders_customer_id_inc
ON orders (customer_id) INCLUDE (created_at, total);
CREATE INDEX のドキュメントに書かれた INCLUDE 列(非キー列)の性質は次のとおりです。
- インデックススキャンの検索条件には使えません。上のインデックスは
WHERE created_at > ...だけで検索する場合には役に立ちません。 - インデックスが強制する一意性制約や排他制約では無視されます。
CREATE UNIQUE INDEX ... (x) INCLUDE (y)なら、一意性はxだけに適用され、(x, y)の組み合わせには適用されません。UNIQUE制約とPRIMARY KEY制約にもINCLUDEを付けられます。 - 検索キーではないので、そのインデックスが扱えないデータ型でもかまいません。
- 現在対応しているのは B-tree、GiST、SP-GiST だけです。
- 式は入れられません。インデックスオンリースキャンで使えないためです。
- インデックスの列は
INCLUDE列を含めて最大32個です(ビルド時に変更可能)。
INCLUDE ができる前は、(x, y) のようにペイロード列を通常のキー列の後ろに付けていました。この方法ではキー列だけの一意性を強制できません。ドキュメントは B-tree のサフィックス切り詰め(suffix truncation)が上位レベルから非キー列を取り除くことにも触れつつ、ペイロード列を明示的に非キー列として定義するほうが上位レベルのタプルを確実に小さく保てると説明しています。
ドキュメントが挙げるコスト
ドキュメントは、非キーのペイロード列、特に幅の広い列の追加には慎重になるよう勧めています。理由は2つです。
- インデックスタプルがインデックスの種類の最大サイズを超えると、データの挿入が失敗します。
- 非キー列はテーブルのデータを複製するのでインデックスが大きくなり、その結果検索が遅くなることがあります。
そして、先ほどの可視性マップの話につながる一文があります。インデックスオンリースキャンがヒープにアクセスしなくて済む程度にテーブルの変化がゆっくりでなければ、ペイロード列を含める意味はあまりない、というものです。行が更新されると新しい行バージョンができ、インデックスエントリも書き直しが必要になりうるので、頻繁に変わるテーブルに幅の広い INCLUDE を付けるとコストだけが増えかねない、という意味に読めます。
稼働中のテーブルにこうしたインデックスを新しく作るなら、書き込みを止めない方法とその制約を CREATE INDEX CONCURRENTLY の記事で確認できます。
式インデックスと部分インデックス
式インデックスも原理的にはインデックスオンリースキャンに使えますが、ドキュメントは現在のプランナがこのケースにあまり賢くないと書いています。プランナは、クエリが必要とする列がすべてインデックスにあるときだけ、インデックスオンリースキャンの候補として扱います。SELECT f(x) FROM tab WHERE f(x) < 1; では列 x 自体はインデックスにないので候補になりません。ドキュメントが示す回避策は、元の列を INCLUDE で入れることです。
CREATE INDEX tab_f_x ON tab (f(x)) INCLUDE (x);
部分インデックスには逆向きの例外があります。WHERE success という条件で作った部分インデックスなら、success 列がインデックスになくても、インデックス内のエントリはすべて success = true なので、WHERE subject = ... AND success のクエリにインデックスオンリースキャンを使えます。ドキュメントによると、PostgreSQL 9.6 以降はこのケースを認識します。
診断:プランナの選択と実行時のヒープアクセスは分けて見る
ここで大事なのは、2つの問いを混ぜないことです。プランナがインデックスオンリースキャンを選ぶかどうかと、実際の実行でヒープを何回読みに行ったかは、別の証拠で確かめます。
1) プランナは選んだか:EXPLAIN
EXPLAIN だけを実行するとクエリは実行されず、計画だけが出ます。計画に Index Only Scan using ... ノードがあれば、プランナがその方式を選んだというだけで、実行時にヒープを飛ばせた証拠にはなりません。プランナは pg_class.relallvisible のような推定値を使っています。カバリングインデックスがあっても、プランナが別の計画を選ぶことはあります。
2) 実行時にヒープを読んだか:EXPLAIN ANALYZE
EXPLAIN ANALYZE はクエリを実際に実行します。ドキュメントは、出力は捨てられても文のほかの副作用は通常どおり起きると警告しており、データを変更する文なら BEGIN と ROLLBACK の間で実行する方法を紹介しています。PostgreSQL 18 では ANALYZE が BUFFERS を自動的に有効にします。以下は EXPLAIN の利用に関するドキュメント(14.1節)に載っている出力です。
Index Only Scan using tenk1_four_unique1_idx on tenk1 (cost=0.29..6.90 rows=1 width=8) (actual time=0.006..0.007 rows=1.00 loops=1)
Index Cond: ((four >= 1) AND (four <= 3) AND (unique1 = 42))
Heap Fetches: 0
Index Searches: 3
Buffers: shared hit=7
Heap Fetches は、この実行でインデックスオンリースキャンがヒープを読みに行った回数と読むのが自然です。ただし、この記事で確認した EXPLAIN 関連のドキュメント2ページは、この行を例として示しているだけで定義は書いていないので、この解釈はノード名と可視性マップの説明から導いた推論です。返した行数に比べてこの値が大きければ、all-visible ではないページに多く当たったと考えられます。実行のたびに値は変わりうるので、1回の結果だけで結論を出さないほうが安全です。
3) テーブルの状態:pg_class と統計ビュー
SELECT relname,
relpages,
relallvisible,
round(100.0 * relallvisible / nullif(relpages, 0), 1) AS all_visible_pct
FROM pg_class
WHERE oid = 'orders'::regclass;
relpages と relallvisible はドキュメントの表現で「プランナが使う推定値にすぎない」もので、VACUUM、ANALYZE、CREATE INDEX などの一部の DDL が更新します。最後の更新以降の変更は反映されていません。
SELECT relname,
n_ins_since_vacuum,
n_dead_tup,
last_vacuum,
last_autovacuum
FROM pg_stat_all_tables
WHERE relid = 'orders'::regclass;
n_ins_since_vacuum は前回の vacuum 以降に挿入された行数の推定値、last_autovacuum は autovacuum デーモンがこのテーブルを最後に vacuum した時刻です。インデックス単位の統計では、pg_stat_all_indexes の idx_tup_read(インデックスから取り出したエントリ数)と idx_tup_fetch(単純なインデックススキャンがテーブルから取得した生きている行の数)を見られます。ドキュメントは、インデックスオンリースキャンでヒープアクセスを避けると後者が小さくなりうると説明しています。ただし、不要になった行やまだコミットされていない行、ビットマップスキャンもこの差に影響するので、この数字だけで原因を断定することはできません。
4) 今の可視性マップそのもの:pg_visibility
推定値ではなく現在の可視性マップを直接見るには、付属モジュールの pg_visibility を使います。
CREATE EXTENSION pg_visibility;
SELECT * FROM pg_visibility_map_summary('orders');
pg_visibility_map_summary は、可視性マップ上の all-visible ページ数と all-frozen ページ数を返します。ブロックごとに見るなら pg_visibility_map('orders') を使います。デフォルトでは、これらの関数を実行できるのはスーパーユーザと pg_stat_scan_tables ロールの権限を持つロールだけです。同じモジュールの pg_truncate_visibility_map は破損が疑われるときにマップを空にする関数で、次の VACUUM が全ページを再スキャンするまで、クエリはマップをすべて0として扱います。診断の目的で使うことはありません。
限界と注意点
- カバリングインデックスがあってもインデックスオンリースキャンが保証されるわけではありません。プランナは別の計画を選ぶことがあり、選んだとしても all-visible ではないページではヒープを読みます。
- 結果はテーブルの変更パターンと vacuum のタイミングで変わります。同じクエリでも、vacuum 直後と大量更新の直後では
Heap Fetchesが違って見えることがあります。 relallvisibleと統計ビューの値は推定値で、pg_visibilityは実行した時点の状態しか示しません。- 手動の
VACUUM、autovacuum 設定の変更、幅の広いINCLUDE列の追加は、どれも I/O、ストレージ、書き込みコストに影響します。この記事は特定の変更を勧めるものではありません。変更する前に、実際のワークロードと使っているバージョンのドキュメントで確かめてください。 - この記事は PostgreSQL 18 のドキュメントにもとづいています。たとえば
ANALYZEがBUFFERSを自動で有効にする動作やrelallfrozen列はこのバージョンのドキュメントで確認した内容なので、それより前のバージョンを使っている場合はそのバージョンのドキュメントを確認してください。
参考にしたドキュメント
- PostgreSQL 18 — 11.9 Index-Only Scans and Covering Indexes:2つの基本条件、可視性マップの確認、カバリングインデックス、
INCLUDEのコスト、式インデックスと部分インデックス - PostgreSQL 18 — 66.4 Visibility Map:ページあたり2ビット、ビットを立てる規則と消す規則
- PostgreSQL 18 — 24.1 Routine Vacuuming:24.1.4 可視性マップの更新、24.1.6 挿入しきい値
- PostgreSQL 18 — CREATE INDEX:
INCLUDE句、列数の上限 - PostgreSQL 18 — 14.1 Using EXPLAIN:
Heap Fetchesの出力例、EXPLAIN ANALYZEの副作用 - PostgreSQL 18 — EXPLAIN:
ANALYZEとBUFFERSオプション - PostgreSQL 18 — 52.11 pg_class:
relpages、reltuples、relallvisible、relallfrozen - PostgreSQL 18 — 27.2 The Cumulative Statistics System:
pg_stat_all_tables、pg_stat_all_indexes - PostgreSQL 18 — F.36 pg_visibility:可視性マップを調べる関数と権限
- PostgreSQL Versioning Policy:サポート中のバージョンと最新のマイナーバージョン
リンクと内容は2026年10月27日に確認しました。