カバリングインデックスとインデックスオンリースキャン
インデックスを引いたのに本体テーブルまで読みに行く無駄を、必要列を索引に詰め込んで消し去る技法が分かります。可視性マップという落とし穴まで押さえれば、なぜ効かない時があるのかも腑に落ちます。
- 1.クエリが参照する全列を1本のインデックスに含めると、葉ノードだけで答えが完結し、ヒープ(行本体)への二段目アクセスを丸ごと省ける。これがカバリングインデックス。
- 2.PostgreSQL のインデックスオンリースキャンは行の可視性情報を索引に持たないため、可視性マップで「全タプルが可視」と記録されたページだけ本体参照を省ける。VACUUM 不足だと省略できず劣化する。
- 3.MySQL/InnoDB は索引葉に主キーを持ち可視性も undo で判定するため可視性マップは不要。INCLUDE 句や順序の効かない列は絞り込みではなくペイロードとして索引に載せる。
「葉まで来たのに本体を読む」無駄
セカンダリインデックスを引くと、多くの場合インデックスの葉に着いた後でもう一度、行本体(ヒープやクラスタ化インデックス)を参照しに行きます。この二段目こそが クラスタ化インデックスと非クラスタ化インデックス で見た「二段参照」のコストであり、該当行が多いほどランダム I/O となって積み上がります。
カバリングインデックスは、この二段目を 構造的に消す 技法です。クエリが必要とする列がすべてインデックス自身に含まれていれば、葉ノードの上で答えが完結し、行本体を一切読まずに済みます。実行計画でこの状態に入ったものを、PostgreSQL では インデックスオンリースキャン(Index Only Scan)、MySQL では Using index と呼びます。
両者は同じ現象の別の側面です。カバリングインデックスは「クエリを覆える(cover できる)インデックスがある」という設計・構造の話。インデックスオンリースキャンは「そのインデックスだけで実行を完結させた」という実行時の動作の話です。覆えるインデックスがあっても、後述の可視性の条件を満たさなければオンリースキャンにはなりません。
何を「含める」のか —— 述語列とペイロード列
クエリが触る列は役割が二つに分かれます。一つは WHERE や JOIN で絞り込みに使う 述語列、もう一つは SELECT で取り出すだけの 出力(ペイロード)列 です。インデックスがクエリを覆うには、この 両方 が索引に載っている必要があります。
-- email で絞り、name と signup_date を返したい
SELECT name, signup_date FROM users WHERE email = 'x@example.com';
-- email だけの索引では name・signup_date 取得で本体参照が必要
-- 3列すべてを含めれば葉だけで完結(カバリング)
CREATE INDEX idx_users_cover ON users (email, name, signup_date);
ここで重要なのは、述語列と出力列で 索引に載せる目的が違う ことです。述語列(email)は葉を並べるキーとして使われ、絞り込みの起点になります。出力列(name, signup_date)は絞り込みには関与せず、ただ葉に値が同居していればよい。この区別が、次の INCLUDE 句の意味につながります。
キー列とINCLUDE列 —— 並べる列か、載せるだけの列か
複合インデックスでは、列順序が絞り込みとソートを決めます(複合インデックスの列順序 を参照)。しかし出力列まで全部キー列にすると、無駄に大きなキーで木が深くなり、本来不要な整列コストも背負います。
そこで PostgreSQL(11+)や SQL Server は INCLUDE 句を用意しています。INCLUDE で指定した列は 葉ノードにのみ格納され、キーの並び順には参加しません。絞り込みやソートには使えませんが、カバリングのためのペイロードとして同居します。
| 観点 | キー列(CREATE INDEX ... (col)) | INCLUDE 列(... INCLUDE (col)) |
|---|---|---|
| 格納される場所 | 内部ノード+葉ノード | 葉ノードのみ |
| 葉の並び順への関与 | する(辞書順を構成) | しない |
| WHERE での絞り込み | 使える | 使えない(フィルタのみ) |
| ORDER BY のソート省略 | 使える | 使えない |
| 主用途 | 述語・整列キー | 出力をカバーするペイロード |
-- email で絞り、name は返すだけ。name は並び順に不要なので INCLUDE が最適
CREATE INDEX idx_users_email_inc ON users (email) INCLUDE (name, signup_date);
INCLUDE を使うと、内部ノードに余分な列が乗らないぶん 扇出し(fan-out)が保たれ木が低いまま、かつ一意制約付き索引(UNIQUE)にも出力列だけ足せる、という利点があります。InnoDB には INCLUDE 構文はありませんが、セカンダリ索引の葉は常に主キー値を含むため、主キー列は自動的にカバー対象に入ります。
PostgreSQL の関門 —— 可視性マップ
ここが本トピックの核心です。PostgreSQL では「覆えるインデックスがある」だけではインデックスオンリースキャンになりません。行が見えるかどうか(可視性)の判定が、索引だけでは完結しない からです。
PostgreSQL は追記型 MVCC を採り、各タプルの可視性は xmin/xmax などのトランザクション情報で判定します(仕組みは MVCC の内部実装 を参照)。ところが この可視性情報は索引タプルには持たせていません。索引の葉には「このキーの行がヒープのどこかにある」という情報しかなく、その行が今のスナップショットで可視か(コミット済みか、削除済みでないか)は、本体タプルを見ないと分からないのです。
これでは本体を読まざるを得ず、オンリースキャンの意味が消えます。そこで PostgreSQL は 可視性マップ(Visibility Map, VM) を使います。VM はヒープページ1枚につき数ビットを持つ補助構造で、「このページ上の全タプルが、すべての実行中トランザクションから可視」になったページに all-visible ビットを立てます。
インデックスオンリースキャンの判定(PostgreSQL)
索引の葉でキー一致 → そのタプルが載るヒープページ番号を得る
├ VM の該当ページが all-visible → 本体を読まず索引の値だけで返す(高速)
└ all-visible でない → ヒープを参照して可視性を確認(heap fetch 発生)
つまりオンリースキャンが成立するのは、該当ページが VM で all-visible と記録されている場合だけ です。all-visible ビットを立てるのは VACUUM(自動 VACUUM 含む)です。更新・削除・挿入の直後はビットが落ちており、VACUUM が追い付くまでは「覆えるのに本体を読む」状態が続きます。
更新が多い表は all-visible ページが減り、インデックスオンリースキャンが期待した本体参照スキップ(heap fetch ゼロ)になりません。EXPLAIN ANALYZE の出力に出る Heap Fetches: がその指標で、ここが大きいほど本体参照が起きています。対策は autovacuum を十分に効かせること。追記専用に近い表ほどオンリースキャンが安定して効き、更新の激しい表ほど効きにくい、という非対称があります。
なぜ InnoDB は可視性マップが要らないのか
同じカバリングでも、MySQL/InnoDB には可視性マップに相当する仕組みがありません。理由は MVCC の実装方式の違いにあります。InnoDB のセカンダリ索引の葉は 主キー値 を持ち、行の可視性は undo ログ から旧バージョンを再構成して判定します。
ここで効くのが、InnoDB が索引レコードに 削除マーク(delete-mark) とページ単位の max trx id を持つ点です。索引レコードが現スナップショットから明らかに可視(直近に変更されておらず、削除マークも無い)と判定できれば、本体(クラスタ化インデックス)まで下りずに索引の値だけで応答できます。MVCC の判定材料を索引側で部分的に賄えるため、PostgreSQL のような別構造(VM)を必要としないわけです。
| 観点 | PostgreSQL | MySQL / InnoDB |
|---|---|---|
| 実行計画の表示 | Index Only Scan | Using index |
| 可視性の判定材料 | 可視性マップ(VM)+必要時ヒープ | 索引の delete-mark/undo ログ |
| VACUUM/パージ依存 | VM 更新は VACUUM 依存で効きが揺れる | VM 不要(パージは別目的) |
| 出力列の足し方 | INCLUDE 句 | 複合キー末尾に列を追加 |
| 主キーのカバー | 明示的に含める必要 | 葉に主キーを常に内包 |
成立条件のまとめと設計指針
インデックスオンリースキャン(カバリングの実行時形)が成立する条件を整理します。
- クエリの参照列がすべて索引に含まれる。 述語列だけでなく
SELECTの出力列、ORDER BYの列も含めて1本に収まること。一つでも欠ければ本体参照が復活します。 - PostgreSQL では対象ヒープページが all-visible である。 これは VACUUM の鮮度に依存し、設計だけでは保証できない実行時条件です。
- 覆える前提でオプティマイザがそのプランを選ぶ。 該当行が表の大半に及ぶとオンリースキャンより全件走査が安く、選ばれないことがあります(この採択判断は クエリ最適化 の領域)。
カバリングを狙って出力列を片端から索引に足すと、索引が肥大して葉ページ数が増え、キャッシュ(バッファプール)の専有が大きくなり、更新時の索引メンテナンスコストも上がります。とくにキー列を増やすと木が深くなります。「頻出かつホットな1〜2クエリだけを、INCLUDE 中心に最小限カバーする」のが費用対効果の高い使い方です。
「PostgreSQL でカバリングインデックスを作ったのに Index Only Scan で Heap Fetches が多い。なぜか」——答えは「対象ページが可視性マップで all-visible になっておらず、行の可視性を確認するためにヒープを参照しているから。VACUUM が追い付けば改善する」。索引に列が揃っているか(設計)と、可視性が確定しているか(運用)は別問題だ、という点が問われます。
カバリングインデックスは「索引を引いた後の二段目を消す」最も直接的な最適化ですが、PostgreSQL ではそれが 可視性という運用条件付き で成立する、という非対称を理解しておくことが、実行計画を読み解く鍵になります。
データベース Article
カバリングインデックスとインデックスオンリースキャンを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
インデックス
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 6
導入後に効く点
PostgreSQL のインデックスオンリースキャンは行の可視性情報を索引に持たないため、可視性マップで「全タプルが可視」と記録されたページだけ本体参照を省ける。VACUUM 不足だと省略できず劣化する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 6
判断チェックリスト
- 自社の用途が「インデックス / カバリングインデックス」に近いか確認する。
- 強みである「クエリが参照する全列を1本のインデックスに含めると、葉ノードだけで答えが完結し、ヒープ(行本体)への二段目アクセスを丸ごと省ける。これがカバリングインデックス。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。