暗黙の型変換の罠 — 列と値のデータ型を揃えて index を守る
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
'A201' のような数値化できない値が1行でもあると、キャストした瞬間にクエリ全体がエラーで落ちます。「昨日まで動いていたのにデータが増えたら落ちた」の典型原因です。employees テーブル(実体100万行)の emp_code 列は VARCHAR(10) で、インデックス idx_emp_code が貼られています。社員コード 1042 の社員を、インデックスが正しく使われる形で1行取得してください。出力列は emp_id, emp_code, name。
| emp_id | emp_code (VARCHAR) | name |
|---|---|---|
| 1 | '0958' | Sato |
| 2 | '1042' | Suzuki |
| 3 | '1107' | Tanaka |
| 4 | 'A201' | Takahashi |
| 5 | '2210' | Ito |
| 6 | '0042' | Watanabe |
| emp_id | emp_code | name |
|---|---|---|
| 2 | 1042 | Suzuki |
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列を取得 */
LEGEND
1. 対象テーブル — emp_code は VARCHAR 型
FROM employeesemp_code は文字列型で、B-tree index は文字列の辞書順で並んでいます。注目すべきは emp_id=4 の 'A201':数値に変換できない値が混ざっているのが現実のデータです。| emp_id | emp_code (VARCHAR) | name |
|---|---|---|
| 1 | '0958' | Sato |
| 2 | '1042' | Suzuki |
| 3 | '1107' | Tanaka |
| 4 | 'A201' | Takahashi |
| 5 | '2210' | Ito |
| 6 | '0042' | Watanabe |
VARCHAR列 = 数値 は CAST(列) = 数値 と同義になり、関数を書いていないのに Sargable 違反が成立してしまいます。EXPLAIN で「index があるのに Seq Scan」を見たら、まず型の不一致を疑ってください。整数列 = '1042' のようにリテラル側だけがキャストされるケースでは、定数を1回変換するだけなので index は生きます。ポイントは「キャストがどちらに掛かるか」。判断に迷ったら、常に列の型と同じ型のリテラル/バインド変数を渡すようにすれば迷う余地がなくなります。'0042' と 42 の関係のように、キャスト経由の比較は前ゼロを無視して意図しない一致を生むこともあります。型を揃えることは性能チューニングであると同時にバグ予防です。EXPLAIN (ANALYZE) を実パラメータ付きで取るか、ログの実行計画を確認しないと気づけません。user_id が INT、B テーブルの user_id が VARCHAR という「歴史的事情」のあるスキーマでは、結合のたびに片側の index が無効化されます。対策は (1) スキーマ定義の型を揃えるマイグレーションが本筋、(2) 暫定対応ならリテラル・変数側を列に合わせる、(3) どうしても列を変換するなら式インデックス(CREATE INDEX ON b ((user_id::int)))。Q2 では、型が合っていても「比較の形」で index が死ぬもう1つの代表例、LIKE のワイルドカード位置を見ていきます。LIKE と前方一致 — ワイルドカードの位置が index の生死を分ける
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 等の出番)
products テーブル(実体50万行)の sku 列にはインデックス idx_products_sku があります。SKU が 'KB-' で始まるキーボード製品を、Index Range Scan が効く形で sku 昇順に取得してください。出力列は product_id, sku, name。
| product_id | sku | name |
|---|---|---|
| 1 | KB-100 | メカニカルキーボード |
| 2 | MS-200 | ワイヤレスマウス |
| 3 | KB-205 | テンキーレスキーボード |
| 4 | HS-310 | ヘッドセット |
| 5 | KB-330 | 静音キーボード |
| 6 | MN-440 | モニター27型 |
| 7 | CB-001 | USB-Cケーブル |
| product_id | sku | name |
|---|---|---|
| 1 | KB-100 | メカニカルキーボード |
| 3 | KB-205 | テンキーレスキーボード |
| 5 | KB-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 順がそのまま昇順 → ソート工程なしで返却 */
LEGEND
1. 対象テーブル — products(sku に index)
FROM productssku は商品種別の接頭辞(KB=キーボード、MS=マウス…)+ 連番という、実務でよくある体系です。この「先頭に意味がある」設計が前方一致検索と相性抜群です。| product_id | sku | name |
|---|---|---|
| 1 | KB-100 | メカニカルキーボード |
| 2 | MS-200 | ワイヤレスマウス |
| 3 | KB-205 | テンキーレスキーボード |
| 4 | HS-310 | ヘッドセット |
| 5 | KB-330 | 静音キーボード |
| 6 | MN-440 | モニター27型 |
| 7 | CB-001 | USB-Cケーブル |
LIKE 'KB-%' はプランナによって「'KB-' 以上、次の文字列未満」の範囲条件へ書き換えられます。B-tree 上では該当行が連続区間に固まっているため、「開始点へ二分探索 → 区間を連続読み → 接頭辞が切れたら終了」という最小コストの走査になります。'%xxx' や '%xxx%' は走査開始点を決められないため全行評価です。中間一致検索が要件なら、PostgreSQL では pg_trgm 拡張 + GIN index(3文字単位の転置索引)、後方一致だけなら reverse() の式インデックスで前方一致に変換するのが定石です。text_pattern_ops 演算子クラス付きの index(CREATE INDEX ... (sku text_pattern_ops))を貼ります。「前方一致なのに Seq Scan」のときに思い出してください。'%' || :kw || '%' を組み立てるのは、データ増加とともに確実に遅くなる時限爆弾です。要件を分解し、前方一致で足りる項目(コード・ID系)は前方一致に、本当に部分一致が要る項目は trgm/全文検索に振り分けます。UPPER(sku) LIKE 'KB-%' は列を関数で包むため index が死にます(同じ構図)。大文字小文字を無視したいなら ILIKE + 式インデックスか、citext 型、あるいは格納時に正規化しておくのが正解です。OR 条件と UNION — 別列の OR を index 2本に分解する
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 は排除しない代わりに高速。OR の分解では同じ行が両ブランチにヒットしうるため、原則 UNION(または2本目に除外条件を付けた UNION ALL)を使います。orders テーブル(実体100万行)には customer_id と coupon_id それぞれに単一インデックスがあります。「顧客101の注文、またはクーポン7が使われた注文」を、各 index が活きる UNION 形式で order_id 昇順に取得してください。出力列は order_id, customer_id, coupon_id, amount。
| order_id | customer_id | coupon_id | amount |
|---|---|---|---|
| 1 | 101 | NULL | 1200 |
| 2 | 102 | 3 | 800 |
| 3 | 103 | NULL | 2000 |
| 4 | 101 | 7 | 3000 |
| 5 | 104 | 7 | 500 |
| 6 | 102 | NULL | 1500 |
| 7 | 105 | 2 | 700 |
| 8 | 103 | 7 | 900 |
| order_id | customer_id | coupon_id | amount |
|---|---|---|---|
| 1 | 101 | NULL | 1200 |
| 4 | 101 | 7 | 3000 |
| 5 | 104 | 7 | 500 |
| 8 | 103 | 7 | 900 |
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 → 並べ替えて返却 */
LEGEND
1. 対象テーブル — 別列に index が1本ずつ
FROM orderscustomer_id と coupon_id にはそれぞれ単一 index があります。しかし customer_id = 101 OR coupon_id = 7 という別列の OR は、どちらか1本の index だけでは走査範囲を確定できません。| order_id | customer_id | coupon_id | amount |
|---|---|---|---|
| 1 | 101 | NULL | 1200 |
| 2 | 102 | 3 | 800 |
| 3 | 103 | NULL | 2000 |
| 4 | 101 | 7 | 3000 |
| 5 | 104 | 7 | 500 |
| 6 | 102 | NULL | 1500 |
| 7 | 105 | 2 | 700 |
| 8 | 103 | 7 | 900 |
IN に畳めば index 1本の複数点ルックアップで済みます。別列の OR は1本の index では処理できないため、(1) プランナの BitmapOr に任せる、(2) UNION で明示分解する、の二択。EXPLAIN に Seq Scan + Filter (a OR b) が出ていたら分解のサインです。AND customer_id <> 101 を付ける)、UNION ALL に切り替えて重複排除コストを丸ごと消せます。(:a IS NULL OR col_a = :a) OR (:b IS NULL OR col_b = :b) ... と巨大な OR を組み立てると、プランナはどの index も選べず Seq Scan に落ちます。入力された条件だけで SQL を動的に組み立てる(または条件の組み合わせごとにクエリを分ける)のが正攻法です。ON a.x = b.x OR a.y = b.y は Hash Join / Merge Join が使えず、Nested Loop の全組み合わせ評価に落ちる代表的な事故です。これも UNION で「x で結合するクエリ」と「y で結合するクエリ」に分解するのが定石です。EXPLAIN ANALYZE し、BitmapOr が効いているか確認、(2) 効いていなければ UNION 版を書いて両者の実行計画と実時間を比較、(3) 速い方を採用。「書き換え案はEXPLAINで裏取りしてから本番へ」という習慣が、思い込みチューニングによる劣化を防ぎます。Q4 では、絞り込みを行う「場所」——WHERE と HAVING の違いに進みます。WHERE と HAVING — 集約の前に絞るか、後に絞るか
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. グループフィルタ(集約後のみ可能な条件)
sales テーブル(実体500万行)から、カテゴリ 'food' の売上だけを対象に店舗別の合計金額を集計し、合計が3000以上の店舗を合計の降順で取得してください。カテゴリの絞り込みと合計の絞り込みを、それぞれ正しい場所に書くこと。出力列は store_id, total。
| sale_id | store_id | category | amount |
|---|---|---|---|
| 1 | S1 | food | 1800 |
| 2 | S1 | drink | 1200 |
| 3 | S2 | food | 2500 |
| 4 | S2 | food | 900 |
| 5 | S3 | food | 800 |
| 6 | S3 | drink | 2000 |
| 7 | S1 | food | 1500 |
| 8 | S3 | food | 1000 |
| store_id | total |
|---|---|
| S2 | 3400 |
| S1 | 3300 |
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 → 並べ替え */
LEGEND
1. FROM sales — 全8行(food 6行 + drink 2行)
FROM sales500万行クラスのテーブルでは「何行を集約に入れるか」がそのまま実行時間になります。drink の行を集約に入れる前に落とせるかがこの問題のテーマです。| sale_id | store_id | category | amount |
|---|---|---|---|
| 1 | S1 | food | 1800 |
| 2 | S1 | drink | 1200 |
| 3 | S2 | food | 2500 |
| 4 | S2 | food | 900 |
| 5 | S3 | food | 800 |
| 6 | S3 | drink | 2000 |
| 7 | S1 | food | 1500 |
| 8 | S3 | food | 1000 |
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY の順序を体に入れると、「この条件はどの段階で判定できるか?」で置き場所が機械的に決まります。行を見れば決まる条件 = WHERE、グループを集約しないと決まらない条件 = HAVING。SELECT の別名(total)が WHERE で使えないのも、この順序が理由です。SUM(...) >= 3000 のような集約関数を含む条件だけ、と決めてしまうのが安全です。多くのDBは行条件を HAVING に書いてもプランナが WHERE へ押し下げて救済しますが、最適化頼みのコードは可読性も移植性も落ちます。GROUP BY store_id, category HAVING category = 'food' は同じ結果を返しますが、全カテゴリ分のグループを作ってから捨てる二重のムダです。グループ数が増えるとハッシュ集約のメモリも膨らみます。行条件は WHERE へ。HAVING SUM(amount) >= 3000 AND SUM(amount) < 10000 程度は問題ありませんが、同じ集約を SELECT・HAVING・ORDER BY に何度も書くなら、サブクエリや CTE で一度集計して別名で参照する方が読みやすく、意図しない集約の重複も防げます。カバリングインデックス — Index Only Scan でテーブル本体を読まない
通常の 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
EXPLAIN ANALYZE の Heap Fetches: 0 を確認し、必要なら VACUUM を実行します。orders テーブル(実体100万行)には巨大な note 列(備考テキスト)があり、行サイズが大きいテーブルです。頻出クエリ「特定顧客の注文金額一覧(customer_id, amount のみ)」を、テーブル本体を一切読まない Index Only Scan で実行できるインデックスを設計し、SELECT 文と合わせて書いてください。
| order_id | customer_id | amount | note (大きい列) |
|---|---|---|---|
| 1 | 101 | 1200 | ギフト包装希望… |
| 2 | 102 | 800 | 置き配指定… |
| 3 | 101 | 2000 | 領収書宛名… |
| 4 | 103 | 500 | 不在時は… |
| 5 | 101 | 700 | 分割配送… |
| 6 | 102 | 1500 | 時間指定… |
| 7 | 101 | 900 | 請求書同梱… |
| customer_id | amount |
|---|---|
| 101 | 1200 |
| 101 | 2000 |
| 101 | 700 |
| 101 | 900 |
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 で可視性確認 → ヒープ訪問なし */
LEGEND
1. 対象テーブル — note 列が重く、行サイズが大きい
FROM ordersクエリが欲しいのは customer_id と amount の2列だけ。しかし行には巨大な note が同居しており、ヒープを読む = 不要な note ごとページを読むことになります。| order_id | customer_id | amount | note (大) |
|---|---|---|---|
| 1 | 101 | 1200 | ギフト包装… |
| 2 | 102 | 800 | 置き配… |
| 3 | 101 | 2000 | 領収書… |
| 4 | 103 | 500 | 不在時… |
| 5 | 101 | 700 | 分割配送… |
| 6 | 102 | 1500 | 時間指定… |
| 7 | 101 | 900 | 請求書… |
(customer_id) INCLUDE (amount) の amount は並び順に関与せず、葉エントリに値だけが格納されます。キー列に足す((customer_id, amount))方法でもカバーは成立しますが、INCLUDE には「キーが短く保てる」「UNIQUE 制約のキーを汚さない」という利点があります。WHERE / ORDER BY で使う列はキーへ、SELECT で返すだけの列は INCLUDE へが使い分けです。Index Only Scan と出ていても、Heap Fetches が大きければ実態は通常の Index Scan と変わりません。visibility map が育っていない(VACUUM 不足・更新直後)のが典型原因です。「プラン名」でなく「Heap Fetches の実数」で判定する癖をつけましょう。pg_stat_user_indexes で使われていない index を定期的に棚卸しします。