SQL パフォーマンス最適化 — EXPLAIN・Index Scanの基礎

基礎EXPLAIN ANALYZEIndex ScanSargableLIMIT / Top-NEXISTSPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

実行計画の基礎 — EXPLAIN ANALYZE で Seq Scan と Index Scan を見分ける

EXPLAINANALYZE実行計画Seq vs Index
前提知識

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)
2つの主要ノード:Seq Scan はテーブル先頭から順に全行を読むため、コストはテーブルサイズに比例します。Index Scan は B-tree を辿って目的行に直接アクセスするので、コストは対数オーダーWHERE 主キー = 値 のように1行だけ取るクエリでは Index Scan が選ばれます。
問題

orders テーブル(10万行・order_id は主キー)から order_id = 50000 の1行を取得します。EXPLAIN ANALYZE で実行計画を出力する SQL を書き、Seq Scan と Index Scan の違いを読み取ってください。

対象テーブル(概念図・6行で表現)
- orders(実体 100,000 行 / 例として6行)
order_id (PK)customer_idamountstatus
11011200completed
2102800completed
31032000cancelled
4 *1043000completed
51051500completed
6106500completed
期待出力
シナリオノードcostactual time読込行数
主キー(PK)ありIndex Scan0.29..8.310.02 ms1 行
インデックスなしSeq Scan0..183412.4 ms100,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
*/
解説(テーブル変化・ポイント)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE order_id = 50000;
LEGEND
データ取得・読込対象
1. 対象テーブル — orders(10万行・例として6行)
FROM ordersorders テーブル全体を読み込む前の状態です。order_id は主キーで、自動的に B-tree インデックスが作られています。目的の行は order_id = 4(実テーブルでは50000)の1行だけです。
1 / 4
order_idcustomer_idamountstatus
11011200completed
2102800completed
31032000cancelled
41043000completed
51051500completed
6106500completed
6行(実テーブルは10万行)
学習ポイント
EXPLAIN は「予測」、ANALYZE は「実測」:EXPLAIN は統計情報から推定したコストを表示するだけ。EXPLAIN ANALYZE は実際に実行しactual time と actual rows を返します。「予測 rows と実測 rows が大きく乖離」しているなら統計の更新(ANALYZE テーブル名)が必要というサインです。
Seq Scan vs Index Scan の判断軸は「選択率」:取り出す行がテーブル全体の数%以下なら Index Scan が有利、数十%を超えるなら Seq Scan の方が速いこともあります。プランナは統計を見て自動選択するため、常にインデックスが速いわけではない点を理解しましょう。
BUFFERS で I/O 量も見える:EXPLAIN (ANALYZE, BUFFERS)shared hit / read を表示し、キャッシュヒット数と実ディスク読込数が分かります。実行時間だけでなく I/O 量で比較すると、キャッシュに乗っただけの「見せかけの高速化」に騙されません。
アンチパターン
EXPLAIN ANALYZE を「更新系」で軽く実行する:EXPLAIN ANALYZE UPDATE ...実際に更新を実行します。本番でうっかり叩くとデータが書き換わります。書き込み系で計画だけ見たいなら EXPLAIN(ANALYZE 無し)、もしくは BEGIN; EXPLAIN ANALYZE ...; ROLLBACK; で安全に確認します。
「Seq Scan=悪」と決めつける:小さなテーブルや、大部分の行を取り出すクエリでは Seq Scan の方が速い場合もあります。プランナの選択を疑う前に、まず選択率とテーブルサイズを確認してください。インデックスを足せば必ず速くなる、わけではありません。
実務コラム:性能チューニングは「測ってから直す」
パフォーマンスの議論で最も多い失敗が「勘でインデックスを足す」「勘で書き換える」です。EXPLAIN ANALYZE はそれを防ぐ最強の道具で、変更前と変更後の cost / actual time を並べて比較するだけで効果を客観的に判定できます。これ以降の Q2〜Q5 で扱う「Sargable な WHERE」「SELECT * の回避」「EXISTS / Top-N」も、必ず EXPLAIN で計画がどう変わるかを確認しながら進めるのが実務の作法です。「測れない最適化は存在しない」と覚えておきましょう。
QUESTION 2

Sargable な WHERE 句 — 関数で列を包まず、インデックスを活かす

SargableINDEXLIKEWHERE最適化
前提知識

インデックスを使える条件を 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'
Sargable の3原則:(1) 列を裸で左辺に置く、(2) 関数・演算・型変換で包まない、(3) LIKE は前方一致のみ。同じ結果を返す条件でも、書き方ひとつでインデックスの利き方が変わります。「結果が同じ」と「計画が同じ」は別物だと覚えておくと、最適化の引き出しが広がります。
問題

orders テーブルの created_at 列に B-tree インデックスがあります。「2024年中に作成された注文」を取得する SQL を、インデックスが効く形(Sargable)で書いてください。

使用テーブル
- orders(7行・created_at に index)
order_idcreated_atamount
12023-11-201200
22024-01-15800
32024-05-032000
42024-09-303000
52024-12-311500
62025-01-02500
72025-03-10700
期待出力
order_idcreated_atamount
22024-01-15800
32024-05-032000
42024-09-303000
52024-12-311500
模範解答コード
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 で並べ替え
  */
解説(テーブル変化・ポイント)
SELECT order_id, created_at, amount FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01' ORDER BY order_id;
LEGEND
データ取得・読込対象
1. FROM orders(created_at に B-tree index)
FROM orders7行のうち、2024年の行は id=2,3,4,5 の4行。created_at にはインデックスがあり、書き方次第で使う/使わないが決まります。
1 / 4
order_idcreated_atamount
12023-11-201200
22024-01-15800
32024-05-032000
42024-09-303000
52024-12-311500
62025-01-02500
72025-03-10700
7行(インデックス活用の余地あり)
学習ポイント
列を関数で包んだ瞬間、インデックスは無効になる: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 も自然に効きます。
LIKE は前方一致だけインデックスが使える:name LIKE 'Tan%' は B-tree 範囲スキャンが可能ですが、name LIKE '%Tan%''%Tan' は不可。中間・後方一致が必要なら全文検索インデックス(GIN/pg_trgm 等)を別途用意するのが本来の解法です。
アンチパターン
暗黙の型変換でインデックスを潰す:WHERE varchar_col = 12345 のように型がズレる比較は、DB が片側に CAST を挟むためインデックスが効かなくなります。アプリ側で型を合わせて渡すか、リテラルを '12345' と書くだけで Index Scan に戻ります。
OR の片側を非Sargable にする: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 に書き直せないか」を最初に検討するのが原則です。式インデックスは保守コストも書き込み負荷も増えるため、クエリ書き換え>式インデックス>全文検索インデックスの順で検討するのが実務の判断軸になります。
QUESTION 3

SELECT * の罠と LIMIT — 列指定と行数制限で読込量を最小化する

SELECT列指定LIMITTop-NIndex Only Scan
前提知識

クエリのコストは「読む列 × 読む行」に比例します。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;
LIMIT が変えるアルゴリズム:巨大テーブルで ORDER BY ... LIMIT K を指定すると、プランナは全件ソートではなく Top-N ヒープソート(O(N log K))を選択します。メモリは K 件分しか使わず、N が大きいほど効果が劇的です。「必要分しか取らない」と宣言するだけで、計画自体が変わる典型例です。
問題

orders テーブルから注文金額の高い順に上位3件の order_idamountを取得してください。読込列を最小にし、LIMIT で行数も絞り込んでください。

使用テーブル
- orders(6行 × 6列)
order_idcustomer_idamountstatuscreated_atnote
11011200completed2024-01-15...
2102800completed2024-02-20...
31035000completed2024-03-10...
41043000cancelled2024-04-05...
51051500completed2024-05-12...
61064200completed2024-06-30...
期待出力
order_idamount
35000
64200
43000
模範解答コード
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行返却
*/
解説(テーブル変化・ポイント)
SELECT order_id, amount FROM orders ORDER BY amount DESC LIMIT 3;
LEGEND
データ取得・読込対象
1. FROM orders — 元テーブル(6行 × 6列)
FROM orders6列のうち、今回必要なのは order_id と amount の2列だけ。note は長文 TEXT を想定しており、SELECT * だと無駄な転送量が大きくなります。
1 / 5
order_idcustomer_idamountstatuscreated_atnote
11011200completed2024-01-15...
2102800completed2024-02-20...
31035000completed2024-03-10...
41043000cancelled2024-04-05...
51051500completed2024-05-12...
61064200completed2024-06-30...
6行 × 6列(全列読込は重い)
学習ポイント
SELECT * は「楽だけど高い」:全列読込は I/O・メモリ・ネット転送のすべてを膨らませ、Index Only Scan(テーブル本体に触らない最速プラン)も使えなくなります。必要な列だけを明示するだけで、計画自体が変わる場合があります。「とりあえず *」は本番で最も静かに性能を落とす書き方です。
ORDER BY + LIMIT は Top-N ソートになる:LIMIT があると、プランナは全件ソートではなくサイズ K のヒープを維持して走査する Top-N ソートを選びます。N=100万・K=10 でも、メモリ使用量は10件分だけ。ランキング系クエリでは LIMIT を付けるかどうかでアルゴリズムが別物になります。
カバリングインデックスで I/O をゼロに近づける:(amount DESC, order_id) INCLUDE (...) のように検索キーと SELECT 列を覆うインデックスを作ると、テーブル本体への参照が不要な Index Only Scan が選ばれます。SELECT * だとこの恩恵は完全に失われるため、列指定と相性が良い最適化です。
アンチパターン
本番に LIMIT 無しの全件取得:管理画面の一覧で SELECT * FROM huge_table を流す事故は本当に多いです。テーブルが小さいうちは動くが、データが増えた瞬間にタイムアウトやメモリ不足で停止します。「ページング = LIMIT + OFFSET(または key-set pagination)」を最初から仕込んでおきましょう。
巨大 OFFSET でページング:LIMIT 20 OFFSET 1000000 は、内部的に 100万行を読み捨ててから20行を返す 重い処理です。深いページネーションではキー範囲指定(WHERE id > 直前の最後のid LIMIT 20)に切り替えるのが定石。OFFSET は浅いページだけで使ってください。
実務コラム:「SELECT *」がレビューで止まる理由
熟練レビュアーが SELECT * を見ると必ず指摘するのは、性能だけでなく「将来の変更耐性」のためです。後からテーブルに巨大な JSON 列や BLOB が追加されると、SELECT * は無関係なコードのレスポンスを突然遅くします。さらに ORM のキャッシュキー、API のレスポンス契約、テストのスナップショットなど、列の追加・削除があらゆる箇所に波及します。列を明示することは「依存関係を明示すること」であり、これは性能チューニングであると同時にコードの保守性チューニングでもあります。Q4 では、この「必要な分だけ取る」考えを JOIN にも広げます。
QUESTION 4

EXISTS と IN の使い分け — 存在チェックは EXISTS で早期終了

EXISTSINNOT EXISTS相関サブクエリ
前提知識

「あるテーブルに存在するか」を調べる述語には INEXISTS があります。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
);
EXISTS の3つの強み:(1) 1件見つかれば打ち切るため、内側の SELECT 列は何でもよく SELECT 1 が定石、(2) NULL に左右されない真偽判定、(3) 相関条件でインデックスが効きやすい。「ある/ない」を聞きたいだけなら EXISTS が第一選択です。
問題

customersorders から、「1件以上注文している顧客」を取得してください。EXISTS を使った相関サブクエリで、NULL に強い形で書いてください。出力列は customer_id, name、customer_id 昇順で。

使用テーブル
- customers(4行)
customer_idname
101Alice
102Bob
103Carol
104Dan
- orders(5行・NULLあり)
order_idcustomer_idamount
11011200
2101800
31032000
4NULL500
51031500
期待出力
customer_idname
101Alice
103Carol
模範解答コード
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 は相関条件で一致せず、安全。
*/
解説(テーブル変化・ポイント)
SELECT c.customer_id, c.name FROM customers c WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id ) ORDER BY c.customer_id;
LEGEND
データ取得・読込対象
1. 2テーブル読込 — customers と orders
FROM customers c, orders ocustomers 4行と orders 5行。orders には customer_id が NULL の行(id=4)がある点に注目。これが NOT IN との挙動差を生みます。
1 / 3
srckey1key2val
customers101Alice
customers102Bob
customers103Carol
customers104Dan
orderso.id=1cust=1011200
orderso.id=2cust=101800
orderso.id=3cust=1032000
orderso.id=4cust=NULL500
orderso.id=5cust=1031500
customers=4, orders=5(NULL混入)
学習ポイント
EXISTS は「1件見つかれば終わり」の早期終了:内側のサブクエリは該当行が1件見つかった時点で評価を打ち切ります。SELECT 句に何を書いても結果は同じなので、慣習的に SELECT 1 を使います(SELECT * でも結果は同じですがレビューで指摘されがち)。「ある/ない」だけを聞きたい場面では EXISTS が最も素直で速い書き方です。
NOT IN は NULL があると全件 FALSE になる罠:x NOT IN (1, 2, NULL) は SQL の3値論理で UNKNOWN となり、WHERE は TRUE を要求するため常に除外されます。「注文のない顧客」を NOT IN で取ると、orders に NULL が1行でも混じれば結果が突然ゼロ件になるのが典型事故です。NOT EXISTS なら NULL は相関条件で一致せず、無関係なので安全です。
EXISTS は相関インデックスと相性が良い:内側の o.customer_id = c.customer_id は外側ループの各値で実行されるため、orders(customer_id) にインデックスがあれば1ループあたり Index Scan ですぐ判定できます。JOIN + DISTINCT で同等表現も可能ですが、結合後に重複排除する分コストが高くなりがちです。
アンチパターン
NOT IN を NULL 入り列に使う:典型的な事故は「注文のない顧客」を WHERE customer_id NOT IN (SELECT customer_id FROM orders) と書くこと。orders に NULL が1行でも入ると結果が常に0件になります。差集合を取りたいなら NOT EXISTSLEFT JOIN ... WHERE o.id IS NULL を使ってください。
EXISTS の内側で SELECT * + ORDER BY:WHERE EXISTS (SELECT * FROM orders ORDER BY ...) のような書き方は無駄な仕事を増やします。EXISTS は1件あれば打ち切るので ORDER BY は意味がなく、SELECT 句の中身も評価に影響しません。SELECT 1 に統一し、内側に無駄な処理を持ち込まないことが大切です。
実務コラム:IN・EXISTS・JOIN+DISTINCT の選び分け
同じ「存在チェック」でも、書き方によって計画と読みやすさが変わります。1. 値リストが固定で短いなら IN ('A','B','C') が最も明快。2. サブクエリが返す集合を判定するなら EXISTS(NULL に強く、早期終了)。3. 子テーブルの列も同時に欲しいなら JOIN(ただし重複が出るので必要に応じて DISTINCTGROUP BY)。実務では「存在チェックだけなら EXISTS、列も取るなら JOIN」と機械的に振り分けるだけで、性能も読みやすさも安定します。NOT 系の差集合では NOT EXISTS を第一選択にしてください。NOT IN は NULL の地雷が常に潜んでいます。
QUESTION 5

集約と LIMIT の総合 — Top-N、DISTINCT vs GROUP BY、COUNT の使い分け

ORDER BY+LIMITGROUP BYDISTINCTTop-N集約
前提知識

パフォーマンス基礎編の総仕上げとして、集約と 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;
3つの「同じに見えて違う」:(1) COUNT(*)=全行 vs COUNT(列)=NULL除く vs COUNT(DISTINCT 列)=ユニーク。(2) DISTINCTGROUP BY は内部実装が同等のことが多いが、集約関数を足すなら GROUP BY が自然。(3) ORDER BY ... LIMIT K は全件ソートではなくTop-N ヒープソートに化ける。
問題

orders テーブルから、カテゴリ別の合計売上 TOP3 を取得してください。同時に、各カテゴリの注文件数も出してください。出力列は category, order_count, total_amount。total_amount 降順、同数なら category 昇順で。

使用テーブル
- orders(8行)
order_idcategoryamount
1Books1200
2Books800
3Toys5000
4Toys3000
5Food500
6Games2500
7Games1500
8Music700
期待出力
categoryorder_counttotal_amount
Toys28000
Games24000
Books22000
模範解答コード
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)    … ユニーク顧客数(ハッシュ重複排除が要る)
*/
解説(テーブル変化・ポイント)
SELECT category, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders GROUP BY category ORDER BY total_amount DESC, category LIMIT 3;
LEGEND
データ取得・読込対象
1. FROM orders — 8行読込
FROM orders8行・5カテゴリのデータを読み込みます。「上位3カテゴリ」を最終的に欲しいだけなのに、まずは全カテゴリを集計する必要がある点が出発点です。
1 / 5
order_idcategoryamount
1Books1200
2Books800
3Toys5000
4Toys3000
5Food500
6Games2500
7Games1500
8Music700
8行(5カテゴリ)
学習ポイント
「GROUP BY → ORDER BY + LIMIT」は集約ランキングの王道:レポート系の「カテゴリ別売上 TOP10」「日別 PV 上位50」などはすべてこの形に収まります。集約で行数を激減させ、その後に Top-N ヒープソートで上位だけを抜く2段構え。集約後の行数(グループ数)が小さければ、ソートは事実上ノーコストです。
DISTINCT と GROUP BY は内部的にほぼ同じ:SELECT DISTINCT category FROM ordersSELECT category FROM orders GROUP BY category多くの DB で同じ実行計画になります。違いは「集約関数を足したいかどうか」だけ。集計を伴うなら GROUP BY、ユニーク取得だけなら DISTINCT と意図に合わせて使い分けます。
COUNT(*) は最速、COUNT(列) は NULL を除く:COUNT(*) は単純な行数カウントで最も軽く、COUNT(amount) は amount が NULL の行を除いた件数になります。COUNT(1) はオプティマイザに COUNT(*) と同等扱いされるため性能差はありません。「数えたい対象が NULL を含む列か」で機械的に選ぶのが鉄則です。
アンチパターン
LIMIT を付けずに全件取得してアプリ側で切り捨てる:SELECT ... ORDER BY total DESC を全件返してから言語側で list[:3] するのは最悪のパターン。DB は全件をソートしきり、ネットワーク転送も全件分発生します。LIMIT 句があるかないかでプランごと変わることを忘れずに。
DISTINCT を「とりあえず重複排除」で振りかける:JOIN で行が膨れたから SELECT DISTINCT で消す、というのは原因(JOIN条件不足)から目を逸らす対症療法です。本来は GROUP BY か EXISTS で膨らみを起こさず取得するのが正解。DISTINCT は内部でハッシュ重複排除が走るため、闇雲に使うと隠れた性能劣化を招きます。
実務コラム:「集計して上位を抜く」王道パターンの威力
Q5で扱った「GROUP BY で集約し、ORDER BY + LIMIT で上位を取る」形は、ダッシュボードやレポート機能で最も頻出する王道パターンです。ここで重要なのは、DB側でデータを限界まで小さく畳み(集約)、必要な件数だけ(Top-N)アプリに返すという設計思想です。全データをアプリ側で引き取ってからプログラム言語で集計・ソートすると、データ量が増えた途端にネットワークとメモリが破綻します。また、とりあえず重複を消すための DISTINCT を乱用せず、意味を持たせた GROUP BY を使うことでクエリの意図も明確になります。DBの強力な集計エンジンを最大限使い倒すことが、実務におけるパフォーマンス最適化の要です。