SQL パフォーマンス最適化 — 型変換・LIKE・OR/UNIONの基礎

基礎暗黙の型変換LIKE前方一致OR と UNIONカバリングINDEX / Index Only ScanPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

暗黙の型変換の罠 — 列と値のデータ型を揃えて index を守る

暗黙キャストデータ型SargableIndex Scan
前提知識

WHERE 句の列と比較値のデータ型が一致しないと、DBは自動で型を揃えようとします(暗黙の型変換)。このとき多くのケースで列の側がキャストされ、実質 CAST(col) = 値 という「関数で列を包んだ」形になってインデックスが使えなくなります。コードに関数を1文字も書いていないのに index が効かない、最も発見しにくい罠です。

-- ✗ emp_code は VARCHAR。数値リテラルと比較すると列側がキャストされる
WHERE emp_code = 1042      -- 内部的に CAST(emp_code AS int) = 1042 → Seq Scan

-- ✓ 列の型に合わせた文字列リテラルで比較 → B-tree がそのまま使える
WHERE emp_code = '1042'   -- Index Scan
もう1つの危険:暗黙キャストは遅いだけでなく、実行時エラーの地雷でもあります。VARCHAR 列に 'A201' のような数値化できない値が1行でもあると、キャストした瞬間にクエリ全体がエラーで落ちます。「昨日まで動いていたのにデータが増えたら落ちた」の典型原因です。
問題

employees テーブル(実体100万行)の emp_code 列は VARCHAR(10) で、インデックス idx_emp_code が貼られています。社員コード 1042 の社員を、インデックスが正しく使われる形で1行取得してください。出力列は emp_id, emp_code, name

使用テーブル
- employees(emp_code は VARCHAR / index あり)
emp_idemp_code (VARCHAR)name
1'0958'Sato
2'1042'Suzuki
3'1107'Tanaka
4'A201'Takahashi
5'2210'Ito
6'0042'Watanabe
期待出力
emp_idemp_codename
21042Suzuki
模範解答コード
SELECT   emp_id, emp_code, name
FROM     employees
WHERE    emp_code = '1042';   -- 列の型 (VARCHAR) に合わせた文字列リテラルで比較

/*
  実行順序:
  1. idx_emp_code の B-tree を文字列 '1042' の辞書順比較で二分探索
  2. ヒットした葉エントリから行位置 (TID) を取得
  3. テーブル本体から emp_id, emp_code, name の3列を取得
  */
解説(テーブル変化・ポイント)
SELECT emp_id, emp_code, name FROM employees WHERE emp_code = '1042';
LEGEND
データ取得・読込対象
1. 対象テーブル — emp_code は VARCHAR 型
FROM employeesemp_code は文字列型で、B-tree index は文字列の辞書順で並んでいます。注目すべきは emp_id=4 の 'A201':数値に変換できない値が混ざっているのが現実のデータです。
1 / 5
emp_idemp_code (VARCHAR)name
1'0958'Sato
2'1042'Suzuki
3'1107'Tanaka
4'A201'Takahashi
5'2210'Ito
6'0042'Watanabe
6行(実テーブルは100万行)
学習ポイント
暗黙キャストは「列側」に掛かると index が死ぬ:型が違う比較では、多くのDBが精度の低い側(文字列)を高い側(数値)へキャストします。つまり VARCHAR列 = 数値CAST(列) = 数値 と同義になり、関数を書いていないのに Sargable 違反が成立してしまいます。EXPLAIN で「index があるのに Seq Scan」を見たら、まず型の不一致を疑ってください。
逆向き(リテラル側のキャスト)は無害:整数列 = '1042' のようにリテラル側だけがキャストされるケースでは、定数を1回変換するだけなので index は生きます。ポイントは「キャストがどちらに掛かるか」。判断に迷ったら、常に列の型と同じ型のリテラル/バインド変数を渡すようにすれば迷う余地がなくなります。
性能問題は「正しさ問題」を連れてくる:数値化できない値が混ざる VARCHAR 列への数値比較は、データ次第で実行時エラーになります。さらに '0042'42 の関係のように、キャスト経由の比較は前ゼロを無視して意図しない一致を生むこともあります。型を揃えることは性能チューニングであると同時にバグ予防です。
アンチパターン
アプリ側のバインド変数の型を放置する:ORM やドライバが文字列カラムに数値型のパラメータを渡している(またはその逆)ケースは頻出です。SQL文上は見えないため、EXPLAIN (ANALYZE) を実パラメータ付きで取るか、ログの実行計画を確認しないと気づけません。
「数字っぽいコード」を数値型で設計する:社員コード・郵便番号・電話番号のように前ゼロや英字が混ざりうる識別子を整数列にすると、'0042' の前ゼロが消える・'A201' が入らないなど別の事故を招きます。識別子は文字列で持ち、比較も常に文字列でが原則です。
実務コラム:型不一致はスキーマ境界で起きる
暗黙キャスト事故が最も起きやすいのはJOIN の結合キーです。たとえば A テーブルの user_id が INT、B テーブルの user_id が VARCHAR という「歴史的事情」のあるスキーマでは、結合のたびに片側の index が無効化されます。対策は (1) スキーマ定義の型を揃えるマイグレーションが本筋、(2) 暫定対応ならリテラル・変数側を列に合わせる、(3) どうしても列を変換するなら式インデックスCREATE INDEX ON b ((user_id::int)))。Q2 では、型が合っていても「比較の形」で index が死ぬもう1つの代表例、LIKE のワイルドカード位置を見ていきます。
QUESTION 2

LIKE と前方一致 — ワイルドカードの位置が index の生死を分ける

LIKE前方一致B-treeRange Scan
前提知識

B-tree インデックスは「左の文字から辞書順」で並んでいます。そのため LIKE 'KB-%' のような前方一致は、内部的に範囲条件 sku >= 'KB-' AND sku < 'KB.' に変換でき、Index Range Scan が使えます。一方、LIKE '%KB'(後方一致)や LIKE '%KB%'(中間一致)は走査の開始点が決められないため、index は使えず全行評価になります。

-- ✓ 前方一致:範囲スキャンに変換できる
WHERE sku LIKE 'KB-%'     -- → sku >= 'KB-' AND sku < 'KB.' (Index Range Scan)

-- ✗ 中間・後方一致:開始点不明 → 全行評価
WHERE name LIKE '%ボード%'  -- Seq Scan(pg_trgm 等の出番)
覚え方:電話帳で「キ」から始まる名前は一瞬で引けるが、「キ」で終わる名前は全ページめくるしかない——B-tree は電話帳と同じです。先頭が固定されているパターンだけが index に乗ると覚えてください。
問題

products テーブル(実体50万行)の sku 列にはインデックス idx_products_sku があります。SKU が 'KB-' で始まるキーボード製品を、Index Range Scan が効く形sku 昇順に取得してください。出力列は product_id, sku, name

使用テーブル
- products(sku に index あり)
product_idskuname
1KB-100メカニカルキーボード
2MS-200ワイヤレスマウス
3KB-205テンキーレスキーボード
4HS-310ヘッドセット
5KB-330静音キーボード
6MN-440モニター27型
7CB-001USB-Cケーブル
期待出力
product_idskuname
1KB-100メカニカルキーボード
3KB-205テンキーレスキーボード
5KB-330静音キーボード
模範解答コード
SELECT   product_id, sku, name
FROM     products
WHERE    sku LIKE 'KB-%'      -- 前方一致 → 範囲条件に内部変換され index が効く
ORDER BY sku;                   -- index の並びと同じ → 追加ソート不要

/*
  実行順序:
  1. LIKE 'KB-%' を範囲条件に内部変換  → 範囲スキャンへ変換
  2. B-tree を二分探索            → 区間の先頭に到達
  3. 接頭辞が切れたら終了              → 該当区間のみ走査
  4. index 順がそのまま昇順          → ソート工程なしで返却
  */
解説(テーブル変化・ポイント)
SELECT product_id, sku, name FROM products WHERE sku LIKE 'KB-%' ORDER BY sku;
LEGEND
データ取得・読込対象
1. 対象テーブル — products(sku に index)
FROM productssku は商品種別の接頭辞(KB=キーボード、MS=マウス…)+ 連番という、実務でよくある体系です。この「先頭に意味がある」設計が前方一致検索と相性抜群です。
1 / 5
product_idskuname
1KB-100メカニカルキーボード
2MS-200ワイヤレスマウス
3KB-205テンキーレスキーボード
4HS-310ヘッドセット
5KB-330静音キーボード
6MN-440モニター27型
7CB-001USB-Cケーブル
7行(実テーブルは50万行)
学習ポイント
前方一致 = 範囲条件の糖衣構文:LIKE 'KB-%' はプランナによって「'KB-' 以上、次の文字列未満」の範囲条件へ書き換えられます。B-tree 上では該当行が連続区間に固まっているため、「開始点へ二分探索 → 区間を連続読み → 接頭辞が切れたら終了」という最小コストの走査になります。
% が先頭に来た瞬間 index は無力:'%xxx''%xxx%' は走査開始点を決められないため全行評価です。中間一致検索が要件なら、PostgreSQL では pg_trgm 拡張 + GIN index(3文字単位の転置索引)、後方一致だけなら reverse() の式インデックスで前方一致に変換するのが定石です。
照合順序(collation)に注意:PostgreSQL では C ロケール以外の index は LIKE の範囲変換に使えないことがあり、その場合 text_pattern_ops 演算子クラス付きの index(CREATE INDEX ... (sku text_pattern_ops))を貼ります。「前方一致なのに Seq Scan」のときに思い出してください。
アンチパターン
「とりあえず両側 %」で検索機能を作る:検索フォームの実装で無条件に '%' || :kw || '%' を組み立てるのは、データ増加とともに確実に遅くなる時限爆弾です。要件を分解し、前方一致で足りる項目(コード・ID系)は前方一致に、本当に部分一致が要る項目は trgm/全文検索に振り分けます。
UPPER(col) LIKE で大文字小文字を吸収する:UPPER(sku) LIKE 'KB-%' は列を関数で包むため index が死にます(同じ構図)。大文字小文字を無視したいなら ILIKE + 式インデックスか、citext 型、あるいは格納時に正規化しておくのが正解です。
実務コラム:検索要件は「一致の種類」で見積もる
検索機能の工数・性能見積もりは、画面数ではなく「一致の種類」で決まります。完全一致・前方一致は B-tree だけで済む「無料枠」。中間一致・あいまい一致は pg_trgm や全文検索(tsvector)という「専用設備」が要る領域。表記ゆれ・同義語まで踏み込むなら検索エンジン(Elasticsearch 等)の世界です。要件定義の段階で「この検索ボックスはどの一致か」を1つずつ確定させると、後から「%を先頭に付けたら遅くなった」という手戻りを防げます。次の Q3 では、index が複数あっても1本に絞れない「OR 条件」の扱いを見ます。
QUESTION 3

OR 条件と UNION — 別列の OR を index 2本に分解する

ORUNIONBitmap Index Scan書き換え
前提知識

WHERE a = 1 OR a = 2 のような同一列の OR は IN (1, 2) に畳めて index 1本で処理できます。問題は WHERE a = 1 OR b = 2 のような別々の列にまたがる OR。単一の index では「どの区間を読めばよいか」を決められないため、素朴には全行評価になりがちです。対策は2つ:プランナに任せて Bitmap Index Scan(BitmapOr)で2本の index を合成させるか、UNION で2本の index クエリに明示的に分解するかです。

-- 別列の OR:1本の index では走査範囲を決められない
WHERE customer_id = 101 OR coupon_id = 7

-- UNION 分解:各ブランチが自分の index を使える
SELECT ... WHERE customer_id = 101
UNION                          -- 両方に該当する行の重複を排除
SELECT ... WHERE coupon_id = 7
UNION と UNION ALL:UNION重複行を排除します(両方の条件を満たす行が二重に出ない)。UNION ALL は排除しない代わりに高速。OR の分解では同じ行が両ブランチにヒットしうるため、原則 UNION(または2本目に除外条件を付けた UNION ALL)を使います。
問題

orders テーブル(実体100万行)には customer_idcoupon_id それぞれに単一インデックスがあります。「顧客101の注文、またはクーポン7が使われた注文」を、各 index が活きる UNION 形式order_id 昇順に取得してください。出力列は order_id, customer_id, coupon_id, amount

使用テーブル
- orders(customer_id / coupon_id に各 index)
order_idcustomer_idcoupon_idamount
1101NULL1200
21023800
3103NULL2000
410173000
51047500
6102NULL1500
71052700
81037900
期待出力
order_idcustomer_idcoupon_idamount
1101NULL1200
410173000
51047500
81037900
模範解答コード
SELECT order_id, customer_id, coupon_id, amount
FROM   orders
WHERE  customer_id = 101        -- ブランチA:idx(customer_id) で Index Scan
UNION                            -- 両条件に該当する行(id=4)の重複を排除
SELECT order_id, customer_id, coupon_id, amount
FROM   orders
WHERE  coupon_id = 7             -- ブランチB:idx(coupon_id) で Index Scan
ORDER BY order_id;               -- UNION 全体の結果に対するソート

/*
  実行順序:
  1. ブランチA: idx_customer_id で Index Scan  → customer_id を取得
  2. ブランチB: idx_coupon_id で Index Scan    → coupon_id を取得
  3. UNION で重複行を排除                        → 和集合をとる
  4. ORDER BY order_id                    → 並べ替えて返却
  */
解説(テーブル変化・ポイント)
SELECT order_id, customer_id, coupon_id, amount FROM orders WHERE customer_id = 101 UNION SELECT order_id, customer_id, coupon_id, amount FROM orders WHERE coupon_id = 7 ORDER BY order_id;
LEGEND
データ取得・読込対象
1. 対象テーブル — 別列に index が1本ずつ
FROM orderscustomer_id と coupon_id にはそれぞれ単一 index があります。しかし customer_id = 101 OR coupon_id = 7 という別列の OR は、どちらか1本の index だけでは走査範囲を確定できません。
1 / 5
order_idcustomer_idcoupon_idamount
1101NULL1200
21023800
3103NULL2000
410173000
51047500
6102NULL1500
71052700
81037900
8行(実テーブルは100万行)
学習ポイント
OR は「同一列なら IN、別列なら分解」:同一列の OR は IN に畳めば index 1本の複数点ルックアップで済みます。別列の OR は1本の index では処理できないため、(1) プランナの BitmapOr に任せる、(2) UNION で明示分解する、の二択。EXPLAIN に Seq Scan + Filter (a OR b) が出ていたら分解のサインです。
Bitmap Index Scan は「index の合成装置」:PostgreSQL は複数 index の結果を行位置のビットマップに変換し、BitmapOr / BitmapAnd で合成してからテーブルを読みます。OR でも index が活きる優秀な仕組みですが、ヒット行が多いと効率が落ちる結果が物理順になるので別途ソートが要るといった特性があり、万能ではありません。
UNION の重複排除はタダではない:UNION は内部で全列のソートまたはハッシュによる重複判定を行います。両ブランチが重ならないことが論理的に保証できるなら(例:2本目に AND customer_id <> 101 を付ける)、UNION ALL に切り替えて重複排除コストを丸ごと消せます。
アンチパターン
検索画面の「すべての条件を OR で連結」:任意入力の検索フォームで (:a IS NULL OR col_a = :a) OR (:b IS NULL OR col_b = :b) ... と巨大な OR を組み立てると、プランナはどの index も選べず Seq Scan に落ちます。入力された条件だけで SQL を動的に組み立てる(または条件の組み合わせごとにクエリを分ける)のが正攻法です。
JOIN の結合条件に OR を書く:ON a.x = b.x OR a.y = b.y は Hash Join / Merge Join が使えず、Nested Loop の全組み合わせ評価に落ちる代表的な事故です。これも UNION で「x で結合するクエリ」と「y で結合するクエリ」に分解するのが定石です。
実務コラム:書き換えの前に EXPLAIN、書き換えの後にも EXPLAIN
OR の UNION 分解は強力ですが、常に速くなるとは限りません。ヒット行が全体の数十%を占めるなら、最初から Seq Scan の方が速いこともあります(index の意味があるのは絞り込み率が高いとき)。手順としては (1) まず元の OR クエリを EXPLAIN ANALYZE し、BitmapOr が効いているか確認、(2) 効いていなければ UNION 版を書いて両者の実行計画と実時間を比較、(3) 速い方を採用。「書き換え案はEXPLAINで裏取りしてから本番へ」という習慣が、思い込みチューニングによる劣化を防ぎます。Q4 では、絞り込みを行う「場所」——WHERE と HAVING の違いに進みます。
QUESTION 4

WHERE と HAVING — 集約の前に絞るか、後に絞るか

WHEREHAVINGGROUP BY論理実行順序
前提知識

SQL の論理的な実行順序は FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY です。WHERE は集約の「前」に行単位で絞るフィルタで、index が使え、集約対象の行数そのものを減らせます。HAVING は集約の「後」にグループ単位で絞るフィルタで、SUM や COUNT など集約結果への条件にしか使えない場所です。行で判定できる条件を HAVING に書くと、「全行を集約してから捨てる」という二重のムダが生じます。

-- 実行順序と絞り込みの位置
FROM sales                    -- 1. データ読込
WHERE category = 'food'      -- 2. 行フィルタ(集約前・index 可)
GROUP BY store_id             -- 3. グループ化
HAVING SUM(amount) >= 3000   -- 4. グループフィルタ(集約後のみ可能な条件)
使い分けの一行ルール:「集約関数を使わない条件は、必ず WHERE へ」。HAVING に残ってよいのは SUM / COUNT / AVG など集約しないと決まらない条件だけです。これだけで集約量とメモリ使用量が最小化されます。
問題

sales テーブル(実体500万行)から、カテゴリ 'food' の売上だけを対象に店舗別の合計金額を集計し、合計が3000以上の店舗を合計の降順で取得してください。カテゴリの絞り込みと合計の絞り込みを、それぞれ正しい場所に書くこと。出力列は store_id, total

使用テーブル
- sales(category に index あり)
sale_idstore_idcategoryamount
1S1food1800
2S1drink1200
3S2food2500
4S2food900
5S3food800
6S3drink2000
7S1food1500
8S3food1000
期待出力
store_idtotal
S23400
S13300
模範解答コード
SELECT   store_id,
         SUM(amount) AS total
FROM     sales
WHERE    category = 'food'     -- 行フィルタ:集約「前」に index で除外
GROUP BY store_id              -- 残った行だけをグループ化
HAVING   SUM(amount) >= 3000   -- グループフィルタ:集約しないと決まらない条件のみ
ORDER BY total DESC;           -- 集計結果の並べ替え

/*
  実行順序:
  1. FROM sales                → 対象テーブルを特定
  2. WHERE category = 'food'   → idx で food 行に絞る
  3. GROUP BY store_id         → 店舗でグループ化し SUM
  4. HAVING SUM(amount) のしきい値  → 条件未満を除外
  5. SELECT                    → store_id と total を取り出す
  6. ORDER BY total DESC       → 並べ替え
  */
解説(テーブル変化・ポイント)
SELECT store_id, SUM(amount) AS total FROM sales WHERE category = 'food' GROUP BY store_id HAVING SUM(amount) >= 3000 ORDER BY total DESC;
LEGEND
データ取得・読込対象
1. FROM sales — 全8行(food 6行 + drink 2行)
FROM sales500万行クラスのテーブルでは「何行を集約に入れるか」がそのまま実行時間になります。drink の行を集約に入れる前に落とせるかがこの問題のテーマです。
1 / 5
sale_idstore_idcategoryamount
1S1food1800
2S1drink1200
3S2food2500
4S2food900
5S3food800
6S3drink2000
7S1food1500
8S3food1000
8行(実テーブルは500万行)
学習ポイント
論理実行順序が置き場所を決める:FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY の順序を体に入れると、「この条件はどの段階で判定できるか?」で置き場所が機械的に決まります。行を見れば決まる条件 = WHERE、グループを集約しないと決まらない条件 = HAVING。SELECT の別名(total)が WHERE で使えないのも、この順序が理由です。
WHERE で絞るほど後段すべてが軽くなる:WHERE のフィルタは集約・ソート・ハッシュ表のサイズを連鎖的に小さくします。500万行のうち food が100万行なら、WHERE に置くだけで集約量が1/5に。さらに category に index があれば読込自体も激減します。「早く・少なく」の原則の最重要適用点です。
HAVING は「集約値の WHERE」と割り切る:HAVING に書いてよいのは SUM(...) >= 3000 のような集約関数を含む条件だけ、と決めてしまうのが安全です。多くのDBは行条件を HAVING に書いてもプランナが WHERE へ押し下げて救済しますが、最適化頼みのコードは可読性も移植性も落ちます
アンチパターン
GROUP BY に列を足して HAVING で絞る:GROUP BY store_id, category HAVING category = 'food' は同じ結果を返しますが、全カテゴリ分のグループを作ってから捨てる二重のムダです。グループ数が増えるとハッシュ集約のメモリも膨らみます。行条件は WHERE へ。
HAVING で集約関数を二重に書き散らす:HAVING SUM(amount) >= 3000 AND SUM(amount) < 10000 程度は問題ありませんが、同じ集約を SELECT・HAVING・ORDER BY に何度も書くなら、サブクエリや CTE で一度集計して別名で参照する方が読みやすく、意図しない集約の重複も防げます。
実務コラム:「絞り込みは上流へ」をクエリ全体の習慣に
WHERE vs HAVING は、より一般的な原則「フィルタはできるだけ上流(データ読込に近い側)へ押し込む」の最も基本的なケースです。この原則は JOIN(結合前に絞る)、サブクエリ(内側で絞る)、ビュー(ビュー定義に絞り込みを織り込む)でも同じ形で現れます。プランナも「述語のプッシュダウン」として自動で行いますが、集約・DISTINCT・ウィンドウ関数を跨ぐ押し下げは自動化できないことが多く、人間がクエリの形で示す必要があります。EXPLAIN で Filter がプランのどの深さに現れるかを見る習慣をつけると、押し下げ漏れに気づけるようになります。最後の Q5 では、読む量を減らす最終兵器「テーブル本体を読まない」Index Only Scan へ進みます。
QUESTION 5

カバリングインデックス — Index Only Scan でテーブル本体を読まない

カバリングINDEXIndex Only ScanINCLUDEI/O削減
前提知識

通常の Index Scan は2段階です:(1) index で行位置(TID)を特定 → (2) テーブル本体(ヒープ)へ飛んで列の値を取得。この (2) はヒット行ごとのランダムI/Oで、行数が多いと意外なコストになります。そこで、クエリが必要とする列をすべて index 自体に持たせると、ヒープ訪問が丸ごと消えて Index Only Scan になります。これがカバリングインデックス(クエリを index だけで「カバー」する)です。

-- PostgreSQL 11+ : INCLUDE 句で「検索キーでない列」を葉に同梱できる
CREATE INDEX idx_orders_cust_amt
  ON orders (customer_id) INCLUDE (amount);
       --  └ 検索キー        └ 取得用に同梱(ソート対象にはならない)

SELECT customer_id, amount    -- 必要列が index 内で完結
FROM orders WHERE customer_id = 101;   -- → Index Only Scan
visibility map:PostgreSQL の Index Only Scan は、行の可視性確認のため visibility map を参照します。更新が激しいテーブルでは「全ページ可視」フラグが立っておらず結局ヒープを読む(Heap Fetches)ことがあるため、EXPLAIN ANALYZEHeap Fetches: 0 を確認し、必要なら VACUUM を実行します。
問題

orders テーブル(実体100万行)には巨大な note 列(備考テキスト)があり、行サイズが大きいテーブルです。頻出クエリ「特定顧客の注文金額一覧(customer_id, amount のみ)」を、テーブル本体を一切読まない Index Only Scan で実行できるインデックスを設計し、SELECT 文と合わせて書いてください。

使用テーブル
- orders(note 列が大きく、行が重い)
order_idcustomer_idamountnote (大きい列)
11011200ギフト包装希望…
2102800置き配指定…
31012000領収書宛名…
4103500不在時は…
5101700分割配送…
61021500時間指定…
7101900請求書同梱…
期待出力
customer_idamount
1011200
1012000
101700
101900
模範解答コード
CREATE INDEX idx_orders_cust_amt             -- 検索キー (customer_id) + 取得列 (amount) を index だけで完結させる
  ON orders (customer_id) INCLUDE (amount);

SELECT   customer_id, amount  -- SELECT 列がすべて index 内にある
FROM     orders
WHERE    customer_id = 101;   -- 検索キーも index 内 → Index Only Scan

/*
  実行順序:
  1. idx_orders_cust_amt を二分探索  → customer_id で到達
  2. 葉に同梱の amount を読む           → Index Only Scan
  3. visibility map で可視性確認      → ヒープ訪問なし
  */
解説(テーブル変化・ポイント)
CREATE INDEX idx_orders_cust_amt ON orders (customer_id) INCLUDE (amount); SELECT customer_id, amount FROM orders WHERE customer_id = 101;
LEGEND
データ取得・読込対象
1. 対象テーブル — note 列が重く、行サイズが大きい
FROM ordersクエリが欲しいのは customer_id と amount の2列だけ。しかし行には巨大な note が同居しており、ヒープを読む = 不要な note ごとページを読むことになります。
1 / 5
order_idcustomer_idamountnote (大)
11011200ギフト包装…
2102800置き配…
31012000領収書…
4103500不在時…
5101700分割配送…
61021500時間指定…
7101900請求書…
7行(実テーブルは100万行・行が重い)
学習ポイント
Index Scan の隠れコストは「ヒープ訪問」:index で行を特定しても、SELECT 列を取るにはヒット行ごとにヒープページへのランダムアクセスが発生します。ヒット行が多い・行が重い(巨大なテキスト列が同居している)ほどこのコストは膨らみます。必要列を index で覆えばこの工程ごと消えるのが Index Only Scan です。
INCLUDE は「キーにしない同梱」:(customer_id) INCLUDE (amount) の amount は並び順に関与せず、葉エントリに値だけが格納されます。キー列に足す((customer_id, amount))方法でもカバーは成立しますが、INCLUDE には「キーが短く保てる」「UNIQUE 制約のキーを汚さない」という利点があります。WHERE / ORDER BY で使う列はキーへ、SELECT で返すだけの列は INCLUDE へが使い分けです。
Heap Fetches: 0 を確認して初めて完成:EXPLAIN ANALYZE で Index Only Scan と出ていても、Heap Fetches が大きければ実態は通常の Index Scan と変わりません。visibility map が育っていない(VACUUM 不足・更新直後)のが典型原因です。「プラン名」でなく「Heap Fetches の実数」で判定する癖をつけましょう。
アンチパターン
SELECT * のままカバリングを目指す:全列を INCLUDE すれば理論上はカバーできますが、それはテーブルの複製を index としてもう1つ持つのと同じです。書込みは全 UPDATE で index も更新され、サイズも倍増。カバリングは「列を絞った頻出クエリ」のための道具で、SELECT * とは根本的に相容れません。
クエリごとにカバリング index を乱立させる:似た index((a) INCLUDE (b)、(a) INCLUDE (c)、(a, b)…)を要望のたびに足していくと、書込み性能とストレージが静かに劣化します。まず既存 index の INCLUDE 列を拡張して統合できないかを検討し、pg_stat_user_indexes で使われていない index を定期的に棚卸しします。
実務コラム:基礎編2 前半の総まとめ — 速いSELECT文の5原則
Q1〜「速い SELECT 文の5原則」に整理すると以下になります。1. 列と値のデータ型を揃える(暗黙キャストで index を殺さない)2. LIKE は前方一致に寄せる(% の位置が index の生死)3. 別列の OR は BitmapOr か UNION に分解4. 行で決まる条件は WHERE、集約値の条件だけ HAVING5. 頻出の軽量クエリはカバリング index でヒープを断つ。基礎編1の10原則(計画を読む / Sargable / 列指定+LIMIT / EXISTS / Top-N / 複合index / JOIN / 事前集約 / ウィンドウ関数 / Keyset)と合わせれば、実務 SELECT 文のチューニング場面の大半をカバーするチェックリストになります。続く基礎編2 後半(Q6〜Q10)では、サブクエリの形と実行回数、NULL と論理演算、ソートとメモリといった「クエリの内側」へさらに踏み込みます。