実行計画の基礎 — EXPLAIN ANALYZE で Seq Scan と Index Scan を見分ける
SQL を速くする第一歩は実行計画(プラン)を読むことです。EXPLAIN は DB が「どう実行するつもりか」を、EXPLAIN ANALYZE は実際に実行して実測値も返します。代表的なノードは Seq Scan(全行スキャン)と Index Scan(インデックス経由)で、選択率の低いクエリでは Index Scan が圧倒的に高速です。
-- EXPLAIN: 計画のみ / EXPLAIN ANALYZE: 計画+実測(クエリは実行される) EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE order_id = 50000; -- 計画ノードの読み方: -- Index Scan using orders_pkey on orders -- (cost=0.29..8.31 rows=1 width=64) -- (actual time=0.018..0.020 rows=1 loops=1)
WHERE 主キー = 値 のように1行だけ取るクエリでは Index Scan が選ばれます。orders テーブル(10万行・order_id は主キー)から order_id = 50000 の1行を取得します。EXPLAIN ANALYZE で実行計画を出力する SQL を書き、Seq Scan と Index Scan の違いを読み取ってください。
| order_id (PK) | customer_id | amount | status |
|---|---|---|---|
| 1 | 101 | 1200 | completed |
| 2 | 102 | 800 | completed |
| 3 | 103 | 2000 | cancelled |
| 4 * | 104 | 3000 | completed |
| 5 | 105 | 1500 | completed |
| 6 | 106 | 500 | completed |
| シナリオ | ノード | cost | actual time | 読込行数 |
|---|---|---|---|---|
| 主キー(PK)あり | Index Scan | 0.29..8.31 | 0.02 ms | 1 行 |
| インデックスなし | Seq Scan | 0..1834 | 12.4 ms | 100,000 行 |
EXPLAIN (ANALYZE, BUFFERS) -- 計画と実測値、バッファ使用量も併せて取得 SELECT * FROM orders WHERE order_id = 50000; -- 主キー等値条件 → Index Scan が選択される /* 読み取りの流れ: 1. プランナがインデックス(orders_pkey)の存在を確認 2. 等値かつ選択率が極めて低い(1/100000)ためIndex Scanを選択 3. B-tree を log2(100000) ≈ 17 ステップで辿り目的行へ 4. 1ブロック取得して完了(Seq Scan の数百倍速い) 出力例の読み方: Index Scan using orders_pkey on orders (cost=0.29..8.31 rows=1 width=64) ← 推定値 (actual time=0.018..0.020 rows=1 loops=1) ← 実測値 Planning Time: 0.10 ms Execution Time: 0.04 ms */
LEGEND
1. 対象テーブル — orders(10万行・例として6行)
FROM ordersorders テーブル全体を読み込む前の状態です。order_id は主キーで、自動的に B-tree インデックスが作られています。目的の行は order_id = 4(実テーブルでは50000)の1行だけです。| order_id | customer_id | amount | status |
|---|---|---|---|
| 1 | 101 | 1200 | completed |
| 2 | 102 | 800 | completed |
| 3 | 103 | 2000 | cancelled |
| 4 | 104 | 3000 | completed |
| 5 | 105 | 1500 | completed |
| 6 | 106 | 500 | completed |
EXPLAIN は統計情報から推定したコストを表示するだけ。EXPLAIN ANALYZE は実際に実行しactual time と actual rows を返します。「予測 rows と実測 rows が大きく乖離」しているなら統計の更新(ANALYZE テーブル名)が必要というサインです。EXPLAIN (ANALYZE, BUFFERS) は shared hit / read を表示し、キャッシュヒット数と実ディスク読込数が分かります。実行時間だけでなく I/O 量で比較すると、キャッシュに乗っただけの「見せかけの高速化」に騙されません。EXPLAIN ANALYZE UPDATE ... は実際に更新を実行します。本番でうっかり叩くとデータが書き換わります。書き込み系で計画だけ見たいなら EXPLAIN(ANALYZE 無し)、もしくは BEGIN; EXPLAIN ANALYZE ...; ROLLBACK; で安全に確認します。Sargable な WHERE 句 — 関数で列を包まず、インデックスを活かす
インデックスを使える条件を Sargable(Search ARGument-able)と呼びます。逆に、左辺の列を関数や演算で包むとインデックスは無効化され、Seq Scan に転落します。LIKE も前方一致 ('abc%') だけがインデックス利用可で、両端ワイルドカード ('%abc%') は使えません。
-- ✗ 非Sargable: 列に関数を適用 → インデックス無効 WHERE EXTRACT(YEAR FROM created_at) = 2024 -- ✓ Sargable: 範囲条件に書き換え → Index Range Scan WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'
orders テーブルの created_at 列に B-tree インデックスがあります。「2024年中に作成された注文」を取得する SQL を、インデックスが効く形(Sargable)で書いてください。
| order_id | created_at | amount |
|---|---|---|
| 1 | 2023-11-20 | 1200 |
| 2 | 2024-01-15 | 800 |
| 3 | 2024-05-03 | 2000 |
| 4 | 2024-09-30 | 3000 |
| 5 | 2024-12-31 | 1500 |
| 6 | 2025-01-02 | 500 |
| 7 | 2025-03-10 | 700 |
| order_id | created_at | amount |
|---|---|---|
| 2 | 2024-01-15 | 800 |
| 3 | 2024-05-03 | 2000 |
| 4 | 2024-09-30 | 3000 |
| 5 | 2024-12-31 | 1500 |
SELECT order_id, created_at, amount FROM orders WHERE created_at >= '2024-01-01' -- 列を裸のまま使い、定数と比較(Sargable) AND created_at < '2025-01-01' -- 「翌年元日未満」で境界を表現(半開区間が定石) ORDER BY order_id; /* 実行順序: 1. インデックスから range の開始位置を二分探索 2. リーフを右に辿りながら、終了境界まで連続スキャン 3. テーブル参照(または Index Only Scan)で SELECT 列を取得 4. ORDER BY order_id で並べ替え */
LEGEND
1. FROM orders(created_at に B-tree index)
FROM orders7行のうち、2024年の行は id=2,3,4,5 の4行。created_at にはインデックスがあり、書き方次第で使う/使わないが決まります。| order_id | created_at | amount |
|---|---|---|
| 1 | 2023-11-20 | 1200 |
| 2 | 2024-01-15 | 800 |
| 3 | 2024-05-03 | 2000 |
| 4 | 2024-09-30 | 3000 |
| 5 | 2024-12-31 | 1500 |
| 6 | 2025-01-02 | 500 |
| 7 | 2025-03-10 | 700 |
EXTRACT(YEAR FROM created_at)、UPPER(name)、LOWER(email)、CAST(col AS ...) — どれも左辺の列を変形するため、B-tree から「その値」は引けません。列は裸のまま、右辺を定数化するのが Sargable の鉄則です。BETWEEN '2024-01-01' AND '2024-12-31' は12月31日の時刻部分(23:59:59.999...)が漏れる罠があります。>= '2024-01-01' AND < '2025-01-01' なら時刻・タイムゾーン・うるう秒に左右されず、Index Range Scan も自然に効きます。name LIKE 'Tan%' は B-tree 範囲スキャンが可能ですが、name LIKE '%Tan%' や '%Tan' は不可。中間・後方一致が必要なら全文検索インデックス(GIN/pg_trgm 等)を別途用意するのが本来の解法です。WHERE varchar_col = 12345 のように型がズレる比較は、DB が片側に CAST を挟むためインデックスが効かなくなります。アプリ側で型を合わせて渡すか、リテラルを '12345' と書くだけで Index Scan に戻ります。WHERE a = 1 OR UPPER(b) = 'X' は OR の片側が非Sargable のため、全体として Seq Scanになりがちです。OR の両側を Sargable に直すか、UNION ALL で2クエリに分けるのが正攻法です。CREATE INDEX ON users (LOWER(email)); としておけば WHERE LOWER(email) = ... でもインデックスが効きます。とはいえ、まずは「クエリ側を Sargable に書き直せないか」を最初に検討するのが原則です。式インデックスは保守コストも書き込み負荷も増えるため、クエリ書き換え>式インデックス>全文検索インデックスの順で検討するのが実務の判断軸になります。SELECT * の罠と LIMIT — 列指定と行数制限で読込量を最小化する
クエリのコストは「読む列 × 読む行」に比例します。SELECT * は全列読込でディスクI/Oとネットワーク転送が増え、カバリングインデックスや Index Only Scan も無効化します。LIMIT は必要な行数だけを最終出力し、順序付きインデックスがあれば早期終了できます。ORDER BY と組み合わせ、順序付きインデックスがない場合でもTop-N ソートという効率の良い専用アルゴリズムが選ばれます。
-- ✗ 全列・全行: 不要なI/Oとソートが発生 SELECT * FROM orders ORDER BY amount DESC; -- ✓ 必要な列だけ + LIMIT: I/O削減 + Top-Nソート SELECT order_id, amount FROM orders ORDER BY amount DESC LIMIT 3;
ORDER BY ... LIMIT K を指定すると、プランナは全件ソートではなく Top-N ヒープソート(O(N log K))を選択します。メモリは K 件分しか使わず、N が大きいほど効果が劇的です。「必要分しか取らない」と宣言するだけで、計画自体が変わる典型例です。orders テーブルから注文金額の高い順に上位3件の order_id と amountを取得してください。読込列を最小にし、LIMIT で行数も絞り込んでください。
| order_id | customer_id | amount | status | created_at | note |
|---|---|---|---|---|---|
| 1 | 101 | 1200 | completed | 2024-01-15 | ... |
| 2 | 102 | 800 | completed | 2024-02-20 | ... |
| 3 | 103 | 5000 | completed | 2024-03-10 | ... |
| 4 | 104 | 3000 | cancelled | 2024-04-05 | ... |
| 5 | 105 | 1500 | completed | 2024-05-12 | ... |
| 6 | 106 | 4200 | completed | 2024-06-30 | ... |
| order_id | amount |
|---|---|
| 3 | 5000 |
| 6 | 4200 |
| 4 | 3000 |
SELECT order_id, amount -- 【列指定】必要な2列だけ。I/O・ネット転送・Index Only Scanの余地を確保 FROM orders ORDER BY amount DESC -- 金額の降順で並べ替え LIMIT 3; -- 【Top-N】上位3件だけを保持。全件分を保持せずヒープソートになる /* 実行順序(SQLの論理的な評価順): 1. FROM orders → 6行をスキャン 2. ORDER BY amount DESC → Top-N ソート(上位のみ保持) 3. LIMIT 3 → 保持した上位3件を確定(順序付きインデックスがあれば早期終了可能) 4. SELECT order_id, amount → 必要な2列だけ返却 プラン比較(概念): ✗ SELECT * ORDER BY amount DESC; → 全6列を読込 → 6行を全件ソート → 全6行返却 ✓ SELECT order_id, amount ORDER BY amount DESC LIMIT 3; → 2列だけ読込 → ヒープサイズ3でTop-Nソート → 3行返却 */
LEGEND
1. FROM orders — 元テーブル(6行 × 6列)
FROM orders6列のうち、今回必要なのは order_id と amount の2列だけ。note は長文 TEXT を想定しており、SELECT * だと無駄な転送量が大きくなります。| order_id | customer_id | amount | status | created_at | note |
|---|---|---|---|---|---|
| 1 | 101 | 1200 | completed | 2024-01-15 | ... |
| 2 | 102 | 800 | completed | 2024-02-20 | ... |
| 3 | 103 | 5000 | completed | 2024-03-10 | ... |
| 4 | 104 | 3000 | cancelled | 2024-04-05 | ... |
| 5 | 105 | 1500 | completed | 2024-05-12 | ... |
| 6 | 106 | 4200 | completed | 2024-06-30 | ... |
(amount DESC, order_id) INCLUDE (...) のように検索キーと SELECT 列を覆うインデックスを作ると、テーブル本体への参照が不要な Index Only Scan が選ばれます。SELECT * だとこの恩恵は完全に失われるため、列指定と相性が良い最適化です。SELECT * FROM huge_table を流す事故は本当に多いです。テーブルが小さいうちは動くが、データが増えた瞬間にタイムアウトやメモリ不足で停止します。「ページング = LIMIT + OFFSET(または key-set pagination)」を最初から仕込んでおきましょう。LIMIT 20 OFFSET 1000000 は、内部的に 100万行を読み捨ててから20行を返す 重い処理です。深いページネーションではキー範囲指定(WHERE id > 直前の最後のid LIMIT 20)に切り替えるのが定石。OFFSET は浅いページだけで使ってください。EXISTS と IN の使い分け — 存在チェックは EXISTS で早期終了
「あるテーブルに存在するか」を調べる述語には IN と EXISTS があります。EXISTS は1件見つかった瞬間に評価を打ち切るのに対し、IN はサブクエリ結果を集めてから比較するイメージです(実際は最適化される場合もあります)。さらに NOT IN はサブクエリに NULL が混じると全件 FALSE という有名な罠があり、NOT EXISTS の方が安全です。
-- ✓ EXISTS: 1件見つかれば打ち切り。NULLにも強い SELECT c.* FROM customers c WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id );
SELECT 1 が定石、(2) NULL に左右されない真偽判定、(3) 相関条件でインデックスが効きやすい。「ある/ない」を聞きたいだけなら EXISTS が第一選択です。customers と orders から、「1件以上注文している顧客」を取得してください。EXISTS を使った相関サブクエリで、NULL に強い形で書いてください。出力列は customer_id, name、customer_id 昇順で。
| customer_id | name |
|---|---|
| 101 | Alice |
| 102 | Bob |
| 103 | Carol |
| 104 | Dan |
| order_id | customer_id | amount |
|---|---|---|
| 1 | 101 | 1200 |
| 2 | 101 | 800 |
| 3 | 103 | 2000 |
| 4 | NULL | 500 |
| 5 | 103 | 1500 |
| customer_id | name |
|---|---|
| 101 | Alice |
| 103 | Carol |
SELECT c.customer_id, c.name FROM customers c WHERE EXISTS ( -- 【存在チェック】対応する orders 行が1件でもあれば TRUE SELECT 1 -- 中身は何でもよい。慣習的に「1」を使う(最小限) FROM orders o WHERE o.customer_id = c.customer_id -- 【相関条件】外側 c の customer_id と結合(NULL は = で一致しない) ) ORDER BY c.customer_id; -- customer_id 昇順で並べ替え /* 実行順序(SQLの論理的な評価順): 1. FROM customers c → 4行スキャン(外側) 2. 各 c に対して EXISTS を評価 → HIT した時点で打ち切り 3. ORDER BY customer_id → 昇順に整列 なぜ NOT IN ではダメか: WHERE customer_id NOT IN (SELECT customer_id FROM orders) → サブクエリ結果が {101,101,103,NULL,103} → 「NOT IN にNULLが含まれる」と全行 UNKNOWN(≠TRUE)になり結果0件。 → NOT EXISTS なら NULL は相関条件で一致せず、安全。 */
LEGEND
1. 2テーブル読込 — customers と orders
FROM customers c, orders ocustomers 4行と orders 5行。orders には customer_id が NULL の行(id=4)がある点に注目。これが NOT IN との挙動差を生みます。| src | key1 | key2 | val |
|---|---|---|---|
| customers | 101 | Alice | — |
| customers | 102 | Bob | — |
| customers | 103 | Carol | — |
| customers | 104 | Dan | — |
| orders | o.id=1 | cust=101 | 1200 |
| orders | o.id=2 | cust=101 | 800 |
| orders | o.id=3 | cust=103 | 2000 |
| orders | o.id=4 | cust=NULL | 500 |
| orders | o.id=5 | cust=103 | 1500 |
SELECT 1 を使います(SELECT * でも結果は同じですがレビューで指摘されがち)。「ある/ない」だけを聞きたい場面では EXISTS が最も素直で速い書き方です。x NOT IN (1, 2, NULL) は SQL の3値論理で UNKNOWN となり、WHERE は TRUE を要求するため常に除外されます。「注文のない顧客」を NOT IN で取ると、orders に NULL が1行でも混じれば結果が突然ゼロ件になるのが典型事故です。NOT EXISTS なら NULL は相関条件で一致せず、無関係なので安全です。o.customer_id = c.customer_id は外側ループの各値で実行されるため、orders(customer_id) にインデックスがあれば1ループあたり Index Scan ですぐ判定できます。JOIN + DISTINCT で同等表現も可能ですが、結合後に重複排除する分コストが高くなりがちです。WHERE customer_id NOT IN (SELECT customer_id FROM orders) と書くこと。orders に NULL が1行でも入ると結果が常に0件になります。差集合を取りたいなら NOT EXISTS か LEFT JOIN ... WHERE o.id IS NULL を使ってください。WHERE EXISTS (SELECT * FROM orders ORDER BY ...) のような書き方は無駄な仕事を増やします。EXISTS は1件あれば打ち切るので ORDER BY は意味がなく、SELECT 句の中身も評価に影響しません。SELECT 1 に統一し、内側に無駄な処理を持ち込まないことが大切です。IN ('A','B','C') が最も明快。2. サブクエリが返す集合を判定するなら EXISTS(NULL に強く、早期終了)。3. 子テーブルの列も同時に欲しいなら JOIN(ただし重複が出るので必要に応じて DISTINCT や GROUP BY)。実務では「存在チェックだけなら EXISTS、列も取るなら JOIN」と機械的に振り分けるだけで、性能も読みやすさも安定します。NOT 系の差集合では NOT EXISTS を第一選択にしてください。NOT IN は NULL の地雷が常に潜んでいます。集約と LIMIT の総合 — Top-N、DISTINCT vs GROUP BY、COUNT の使い分け
パフォーマンス基礎編の総仕上げとして、集約と Top-N を組み合わせた頻出パターンを扱います。GROUP BY で粒度を作り、ORDER BY ... LIMIT で上位だけを抜く——この2段構えはレポート系クエリの王道で、書き方ひとつで I/O・メモリ・実行時間がすべて変わります。さらに DISTINCT と GROUP BY、COUNT(*) と COUNT(列) の違いも整理します。
-- 王道: GROUP BY で集約し、ORDER BY + LIMIT で上位だけ取る SELECT category, SUM(amount) AS total FROM orders GROUP BY category ORDER BY total DESC LIMIT 3;
COUNT(*)=全行 vs COUNT(列)=NULL除く vs COUNT(DISTINCT 列)=ユニーク。(2) DISTINCT と GROUP BY は内部実装が同等のことが多いが、集約関数を足すなら GROUP BY が自然。(3) ORDER BY ... LIMIT K は全件ソートではなくTop-N ヒープソートに化ける。orders テーブルから、カテゴリ別の合計売上 TOP3 を取得してください。同時に、各カテゴリの注文件数も出してください。出力列は category, order_count, total_amount。total_amount 降順、同数なら category 昇順で。
| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Toys | 5000 |
| 4 | Toys | 3000 |
| 5 | Food | 500 |
| 6 | Games | 2500 |
| 7 | Games | 1500 |
| 8 | Music | 700 |
| category | order_count | total_amount |
|---|---|---|
| Toys | 2 | 8000 |
| Games | 2 | 4000 |
| Books | 2 | 2000 |
SELECT category, COUNT(*) AS order_count, -- グループ内の全行数(NULLも含む) SUM(amount) AS total_amount -- グループ内の合計金額 FROM orders GROUP BY category -- カテゴリ単位で集約 ORDER BY total_amount DESC, category -- 合計降順、同数なら category 昇順で安定化 LIMIT 3; -- 【Top-N】上位3件だけを保持(全件の完全なソートは不要) /* 実行順序(SQLの論理的な評価順): 1. FROM orders → 8行読込 2. GROUP BY category → カテゴリで集約 3. ORDER BY total_amount DESC, category → Top-N ソート(上位のみ保持) 4. LIMIT 3 → 保持した上位3件を確定 COUNT の使い分け(参考): COUNT(*) … 全行数(NULL含む、最も速いことが多い) COUNT(amount) … amount が NULL でない行数 COUNT(DISTINCT customer_id) … ユニーク顧客数(ハッシュ重複排除が要る) */
LEGEND
1. FROM orders — 8行読込
FROM orders8行・5カテゴリのデータを読み込みます。「上位3カテゴリ」を最終的に欲しいだけなのに、まずは全カテゴリを集計する必要がある点が出発点です。| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Toys | 5000 |
| 4 | Toys | 3000 |
| 5 | Food | 500 |
| 6 | Games | 2500 |
| 7 | Games | 1500 |
| 8 | Music | 700 |
SELECT DISTINCT category FROM orders と SELECT category FROM orders GROUP BY category は多くの DB で同じ実行計画になります。違いは「集約関数を足したいかどうか」だけ。集計を伴うなら GROUP BY、ユニーク取得だけなら DISTINCT と意図に合わせて使い分けます。COUNT(*) は単純な行数カウントで最も軽く、COUNT(amount) は amount が NULL の行を除いた件数になります。COUNT(1) はオプティマイザに COUNT(*) と同等扱いされるため性能差はありません。「数えたい対象が NULL を含む列か」で機械的に選ぶのが鉄則です。SELECT ... ORDER BY total DESC を全件返してから言語側で list[:3] するのは最悪のパターン。DB は全件をソートしきり、ネットワーク転送も全件分発生します。LIMIT 句があるかないかでプランごと変わることを忘れずに。SELECT DISTINCT で消す、というのは原因(JOIN条件不足)から目を逸らす対症療法です。本来は GROUP BY か EXISTS で膨らみを起こさず取得するのが正解。DISTINCT は内部でハッシュ重複排除が走るため、闇雲に使うと隠れた性能劣化を招きます。GROUP BY で集約し、ORDER BY + LIMIT で上位を取る」形は、ダッシュボードやレポート機能で最も頻出する王道パターンです。ここで重要なのは、DB側でデータを限界まで小さく畳み(集約)、必要な件数だけ(Top-N)アプリに返すという設計思想です。全データをアプリ側で引き取ってからプログラム言語で集計・ソートすると、データ量が増えた途端にネットワークとメモリが破綻します。また、とりあえず重複を消すための DISTINCT を乱用せず、意味を持たせた GROUP BY を使うことでクエリの意図も明確になります。DBの強力な集計エンジンを最大限使い倒すことが、実務におけるパフォーマンス最適化の要です。