⚡ Step 18: クエリ最適化テクニック
遅いクエリを高速化するテクニック集!
- 遅いクエリの診断手順(5ステップ)
- N+1問題とその解決方法
- 不要なDISTINCTの削除
- EXISTS vs INの使い分け
- サブクエリ vs JOINの選択
- 大量データ処理の工夫
🔍 1. 遅いクエリの診断手順
1-1. 体系的なアプローチが大切
クエリが遅いとき、闇雲にインデックスを作るのは良くありません。体系的に診断して、本当のボトルネックを見つけましょう!
「お腹が痛い」という症状に対して、いきなり手術はしませんよね?
まず検査をして、原因を特定してから、適切な治療をします。
クエリの最適化も同じです!
1-2. 診断の5ステップ
- 実行時間を計測する(現状把握)
- 実行計画を確認する(原因分析)
- ボトルネックを特定する(問題箇所の特定)
- 改善策を実施する(治療)
- 効果を検証する(再検査)
1-3. ステップ1:実行時間を計測する
まず、現在のクエリがどのくらい時間がかかっているか計測します。
※横にスクロールできます
customer_id | customer_name | prefecture | ...
------------+---------------+------------+----
101 | 田中太郎 | 東京都 | ...
102 | 佐藤花子 | 東京都 | ...
...
Run Time: real 2.345 user 2.100 sys 0.234
real 2.345 → 2.345秒かかっている(遅い!)
1-4. ステップ2:実行計画を確認する
次に、EXPLAIN QUERY PLANでクエリがどのように実行されているか確認します。
※横にスクロールできます
QUERY PLAN
`--SCAN customers
SCAN customers → 全件スキャンが原因!
1-5. ステップ3:ボトルネックを特定する
実行計画から、遅い原因を特定します。
| 症状 | 原因 | 対策 |
|---|---|---|
| SCAN テーブル名 | インデックスがない | インデックスを作成 |
| USE TEMP B-TREE | ソート処理が発生 | ORDER BY列にインデックス |
| CORRELATED SUBQUERY | 相関サブクエリが重い | JOINに書き換え |
| 多数のJOIN | 結合が多すぎる | 結合キーにインデックス |
1-6. ステップ4:改善策を実施する
ボトルネックが分かったら、改善策を実施します。今回は「SCAN」が原因なので、インデックスを作成します。
※横にスクロールできます
1-7. ステップ5:効果を検証する
改善後、再度計測して効果を確認します。
※横にスクロールできます
Run Time: real 0.023 user 0.020 sys 0.003
2.345秒 → 0.023秒(約100倍高速化!)
実行計画も確認します。
※横にスクロールできます
QUERY PLAN
`--SEARCH customers USING INDEX idx_prefecture (prefecture=?)
SCAN → SEARCH USING INDEX に変わりました!
- 測定してから最適化する(感覚ではなく数値で判断)
- 1つずつ改善を試す(複数同時だと効果が分からない)
- 効果を数値で確認する(○秒→○秒と記録)
- 本番と同じデータ量でテストする
🔄 2. N+1問題とその解決
2-1. N+1問題とは?
N+1問題は、パフォーマンス問題の中で最も多い原因の1つです。ループの中でクエリを実行することで、大量のクエリが発生してしまう問題です。
1回目:リストを取得するクエリ
N回:リストの各要素に対するクエリ
合計:1 + N = N+1回のクエリが実行される → N+1問題
2-2. N+1問題の具体例
「全顧客とその注文件数を表示したい」という要件を考えてみましょう。
※横にスクロールできます
2-3. なぜN+1問題は遅いのか
- ネットワーク往復:1001回のクエリ = 1001回のネットワーク通信
- クエリ解析:1001回のSQLパース処理
- オーバーヘッド:各クエリの接続・切断処理
1回のクエリが0.005秒でも、1001回なら5秒以上かかります!
2-4. アプリケーションコードでのN+1問題
N+1問題は、アプリケーションのコードで発生することが多いです。
※横にスクロールできます
2-5. 解決策1:JOINで1回で取得
最も効果的な解決策は、JOINを使って1回のクエリで全てのデータを取得することです。
※横にスクロールできます
LEFT JOIN→ 注文がない顧客も含めるCOUNT(o.order_id)→ 注文件数をカウントGROUP BY→ 顧客ごとに集計
アプリケーションコードも改善されます。
※横にスクロールできます
2-6. 解決策2:IN句で一括取得
JOINが使いにくい場合は、IN句で一括取得する方法もあります。
※横にスクロールできます
2-7. N+1問題解決の効果
| 項目 | 改善前 | 改善後 |
|---|---|---|
| クエリ回数 | 1001回 | 1回 |
| 実行時間 | 5秒 | 0.05秒 |
| 速度向上 | – | 100倍! |
2-8. N+1問題を防ぐコツ
- ループの中でクエリを実行しない
- JOINで一度に取得する
- IN句で一括取得する
- ORMを使っている場合はEager Loadingを使う
- SQLログを確認して、クエリ数を監視する
- SQLログを有効にして、クエリ数を確認する
- 1つの処理で数百〜数千のクエリが実行されていたら、N+1問題の可能性大
- 同じような形のクエリが大量に並んでいたら要注意
🔍 3. 不要なDISTINCTを削除
3-1. DISTINCTは重い処理
DISTINCTは重複を削除するために、全データをソートする必要があります。何も考えずに「とりあえずDISTINCT」と書くと、パフォーマンスが大きく低下します。
- 全データをメモリにロードする必要がある
- 重複チェックのためにソートが必要
- データ量が多いと劇的に遅くなる
- 実行計画に
USE TEMP B-TREEが出ることも
3-2. 不要なDISTINCTの例1:主キーで検索
主キーは元々重複しないので、DISTINCTは不要です。
※横にスクロールできます
主キー(PRIMARY KEY)やユニーク制約(UNIQUE)がある列を含むSELECTでは、DISTINCTは不要です。
3-3. 不要なDISTINCTの例2:JOINで重複が発生
JOINで重複が発生する場合、DISTINCTで解決するのは効率が悪いです。
※横にスクロールできます
このクエリは「注文がある顧客を取得したい」という意図ですが、1人の顧客が複数回注文していると、JOINで重複が発生します。DISTINCTで削除するのは非効率です。
3-4. DISTINCTの代替案1:GROUP BY
※横にスクロールできます
- 意図が明確(「グループ化する」という意味)
- 集計関数を同時に使える(COUNT、SUM等)
- インデックスを使える場合がある
3-5. DISTINCTの代替案2:EXISTS
「存在チェックだけ」なら、EXISTSが最も効率的です。
※横にスクロールできます
- JOINしないので重複が発生しない
- 短絡評価:1件見つかれば即終了
- DISTINCTやGROUP BYのソート処理が不要
3-6. DISTINCTが必要な場合
もちろん、DISTINCTが必要な場合もあります。
※横にスクロールできます
3-7. DISTINCTの代替手段まとめ
| 目的 | 推奨方法 |
|---|---|
| 存在チェックのみ | EXISTS(最速) |
| 集計も同時に | GROUP BY + 集計関数 |
| 主キーを含む | DISTINCTを削除 |
| 純粋な重複削除 | DISTINCT(必要な場合のみ) |
⚖️ 4. EXISTS vs INの使い分け
4-1. EXISTSとINの基本的な違い
どちらも「条件に合うデータがあるか」をチェックしますが、動作の仕組みが異なります。
※横にスクロールできます
4-2. 動作の仕組みの違い
- 外側のテーブル(customers)の行を1つ読む
- 内側のサブクエリを実行(条件に合う行を探す)
- 1件でも見つかれば即終了(短絡評価)
- 次の行へ進む
- サブクエリを実行し、全ての値をリストに格納
- 外側のテーブル(customers)の行を読む
- リストの中に値があるかチェック
- 次の行へ進む
4-3. EXISTSが速い場合
外側が小さく、内側が大きい場合は、EXISTSが有利です。
※横にスクロールできます
4-4. INが速い場合
内側が小さく、固定リストの場合は、INが有利です。
※横にスクロールできます
4-5. NOT EXISTS vs NOT IN(重要!)
「〜に含まれない」を検索する場合、NOT INには罠があります。
※横にスクロールできます
サブクエリの結果にNULLが1つでも含まれると、NOT INは何も返しません!
NOT EXISTSを使えば、NULLの問題を回避できます。
※横にスクロールできます
4-6. 使い分けの判断基準
| 状況 | 推奨 | 理由 |
|---|---|---|
| 存在チェックのみ | EXISTS | 短絡評価で速い |
| 小さな固定リスト | IN | シンプルで効率的 |
| NOT条件 | NOT EXISTS | NULL安全 |
| 迷ったら | EXISTS | 安全で効率的 |
- NOT条件では必ずNOT EXISTSを使う
- EXISTSとINで迷ったら、両方試して実行計画を比較
- INの中にNULLが入る可能性がないか確認する
🔀 5. サブクエリ vs JOINの選択
5-1. 基本的な考え方
サブクエリとJOINは、多くの場合で同じ結果を得られますが、パフォーマンスが異なることがあります。どちらを使うべきか、パターン別に見ていきましょう。
5-2. パターン1:存在チェックのみ
「注文がある顧客だけを取得したい」という場合です。
※横にスクロールできます
- DISTINCTが不要(JOINは重複が発生するため必要)
- 短絡評価で速い(1件見つかれば終了)
- 意図が明確(「存在する」という意味)
5-3. パターン2:関連データも取得
「顧客情報と一緒に、注文件数も取得したい」という場合です。
※横にスクロールできます
- ordersテーブルを1回だけスキャン
- サブクエリは顧客数×回実行される(N+1問題と同じ)
- LEFT JOINで注文がない顧客も含められる
5-4. パターン3:複数の集計を同時に
「顧客の注文件数、合計金額、最終注文日を全て取得したい」という場合です。
※横にスクロールできます
このクエリは、顧客1人に対して3回のサブクエリを実行します。顧客が1万人なら、3万回のサブクエリが実行されます!
※横にスクロールできます
LEFT JOIN→ 注文がない顧客も含めるCOUNT(o.order_id)→ 注文件数(NULLはカウントしない)COALESCE(SUM(o.amount), 0)→ 注文がない場合は0MAX(o.order_date)→ 最終注文日
改善効果:
サブクエリ: 顧客数 × 3回 = 3万回のスキャン
JOIN: 1回のスキャン
→ 3万倍の改善!
5-5. パターン4:フィルタリングのみ
「2024年以降に注文された商品だけを取得したい」という場合です。
※横にスクロールできます
- DISTINCTが不要(JOINは重複が発生)
- 意図が明確(「存在する商品」という意味)
- 短絡評価で効率的
5-6. 選択の基準まとめ
| 目的 | 推奨 | 理由 |
|---|---|---|
| 存在チェックのみ | EXISTS | DISTINCTが不要、短絡評価 |
| 関連データも取得 | JOIN | 1回のスキャンで完了 |
| 複数の集計 | JOIN | 1回のスキャンで全て計算 |
| フィルタリング | EXISTS | DISTINCTが不要、明確 |
| 迷ったら | 両方試す | 実行計画を比較 |
💾 6. 大量データ処理の工夫
6-1. 一度に全件処理する危険性
100万件のデータを一度に処理すると、以下の問題が発生します。
- メモリ不足:100万件をメモリに展開できない
- タイムアウト:処理時間が長すぎて接続が切れる
- ロック時間:長時間テーブルがロックされる
- ログ肥大化:大量のトランザクションログが発生
- リカバリ困難:途中で失敗すると最初からやり直し
6-2. 解決策1:バッチ処理で分割
大量データは小さな単位に分割して処理します。
※横にスクロールできます
- ロック時間が短い(他の処理が待たされない)
- 進捗が分かる(何%完了したか確認できる)
- 途中再開が可能(失敗しても続きから再開)
- メモリ効率が良い(少量ずつ処理)
6-3. 解決策2:LIMITとOFFSETで分割取得
大量データを取得する場合、ページング処理で分割します。
※横にスクロールできます
OFFSETが大きくなると遅くなります。
OFFSET 100000 の場合、データベースは100,000行を読み飛ばす必要があります。
100ページ目でも1ページ目でも同じデータを読むので、非効率です。
6-4. 解決策3:Keyset Pagination(推奨)
Keyset Paginationは、前回の最後のIDを使って続きを取得する方法です。OFFSETより圧倒的に速いです!
※横にスクロールできます
- OFFSETより圧倒的に速い
- ページ数に関係なく一定速度(100ページ目でも1ページ目と同じ速度)
- インデックスを効率的に使える
- 無限スクロールに最適
| 方式 | 100ページ目の処理 | 速度 |
|---|---|---|
| OFFSET | 99,000行を読み飛ばしてから取得 | ⚠️ 遅い |
| Keyset | WHERE id > 99000 で直接取得 | ✅ 速い(1ページ目と同じ) |
6-5. 解決策4:一時テーブルで中間結果を保存
複雑な集計結果を何度も使う場合、一時テーブルに保存すると効率的です。
※横にスクロールできます
CREATE TEMP TABLE→ 一時テーブルを作成AS SELECT ...→ SELECTの結果をテーブルに保存- 一時テーブルはセッション終了時に自動削除される
一時テーブルにインデックスを追加して、高速に検索できます。
※横にスクロールできます
- 複雑な集計結果を複数回使う場合
- 中間結果を保存してデバッグしたい場合
- 段階的な処理を行いたい場合
- レポート作成など、同じ計算を何度も使う場合
6-6. 大量データ処理のまとめ
- バッチで分割する(1万〜10万件ずつ)
- Keyset Paginationを使う(OFFSETは避ける)
- 一時テーブルで中間結果を保存
- 進捗を表示する(何%完了したか)
- 再開可能な設計にする(途中失敗に備える)
📝 練習問題
クエリ最適化の実践をしましょう!
問題 1基本
N+1問題の特定
以下のコードにN+1問題はありますか?
customers = SELECT * FROM customers;
FOR EACH customer:
SELECT * FROM orders WHERE customer_id = customer.id;
解答: はい、N+1問題です
理由:
- 1回目: 全顧客を取得
- N回: 各顧客の注文を個別に取得
- 顧客が1000人なら、合計1001回のクエリ!
解決策:
※横にスクロールできます
問題 2基本
不要なDISTINCTの削除
以下のクエリでDISTINCTは必要ですか?
SELECT DISTINCT customer_id, customer_name FROM customers;
解答: 不要です
理由:
- customer_idは主キーなので重複しない
- DISTINCTは不要なソート処理を発生させる
改善:
※横にスクロールできます
問題 3基本
診断の5ステップ
遅いクエリを最適化する際の5ステップを順番に答えてください。
解答:
- 実行時間を計測する(現状把握)
- 実行計画を確認する(原因分析)
- ボトルネックを特定する(問題箇所の特定)
- 改善策を実施する(治療)
- 効果を検証する(再検査)
感覚ではなく、数値で効果を確認することが重要です。
問題 4応用
EXISTS vs INの選択
以下の2つのクエリで、どちらが速い可能性が高いですか?
顧客: 10万人、注文: 1000万件
A) WHERE EXISTS (SELECT 1 FROM orders WHERE ...)
B) WHERE customer_id IN (SELECT customer_id FROM orders WHERE ...)
解答: A(EXISTS)が速い
理由:
- EXISTS:各顧客で1件見つかれば即終了(短絡評価)
- IN:1000万件の注文から全customer_idを取得してから比較
- 外側(顧客)が小さく、内側(注文)が大きい場合はEXISTSが有利
問題 5応用
サブクエリの最適化
以下のクエリを最適化してください。
SELECT c.*,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id),
(SELECT SUM(amount) FROM orders o WHERE o.customer_id = c.customer_id)
FROM customers c;
※横にスクロールできます
解説:
- 元のクエリ: ordersを顧客数×2回スキャン
- 最適化後: ordersを1回だけスキャン
- 顧客が10万人なら、20万回→1回に削減!
問題 6応用
大量データの削除
100万件のデータから、古いデータ50万件を削除したい。
一度に削除するのは危険です。どう改善しますか?
※横にスクロールできます
解説:
バッチで分割することで、ロック時間を短縮し、メモリ不足を防げます。途中で失敗しても、続きから再開できます。
問題 7チャレンジ
N+1問題の解決
以下の処理を1回のクエリで実現してください。
「各顧客の最新注文日と総注文金額を取得する」
元のコード:
FOR EACH customer:
last_order = SELECT MAX(order_date) FROM orders WHERE ...
total = SELECT SUM(amount) FROM orders WHERE ...
※横にスクロールできます
解説:
- 元: 顧客数 × 2回のクエリ
- 改善後: 1回のクエリ
- 10万人なら、20万回→1回に削減!
問題 8チャレンジ
Keyset Paginationの実装
OFFSET方式のページングをKeyset方式に書き換えてください。
SELECT * FROM orders ORDER BY order_id LIMIT 100 OFFSET 10000;
※横にスクロールできます
解説:
- OFFSETは前のページも読む必要がある(10000行を読み飛ばす)
- Keysetは前回の最後のIDから続きを読むだけ
- 100ページ目でも1ページ目と同じ速度!
問題 9チャレンジ
総合最適化
以下のクエリを可能な限り最適化してください。
SELECT DISTINCT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_date >= '2024-01-01'
ORDER BY c.customer_name;
※横にスクロールできます
最適化のポイント:
- DISTINCTをEXISTSに置き換え(ソート削減)
- JOINキーと検索条件にインデックス
- ORDER BY用のインデックス
📝 Step 18 のまとめ
- 診断手順:測定→分析→改善→検証
- N+1問題:JOINで一度に取得
- DISTINCT削除:不要なソートを回避
- EXISTS vs IN:適切な選択
- サブクエリ vs JOIN:用途に応じて使い分け
- 大量データ:バッチ処理とKeyset Pagination
- 測定してから最適化する
- ボトルネックを正確に特定する
- 1つずつ改善を試す
- 効果を検証する
- 過度な最適化はしない
- N+1問題を解消(最重要!)
- インデックスを適切に作成
- 不要なDISTINCTを削除
- EXISTS/INを適切に選択
- バッチ処理で大量データを扱う
❓ よくある質問
Q1: N+1問題はどう見つけますか?
ログでクエリ数を確認してください。1つの処理で数百〜数千のクエリが実行されていたら、N+1問題の可能性が高いです。ORMを使っている場合は、SQLログを有効にしましょう。
Q2: DISTINCTとGROUP BYどちらが速い?
GROUP BYの方が速いことが多いです。GROUP BYはインデックスを使える場合があり、集計も同時にできます。ただし、単に重複削除だけなら大差ありません。迷ったら実行計画を比較しましょう。
Q3: EXISTSとINで結果が違うことはある?
NOT INでNULLがある場合だけ違います。NOT INはサブクエリにNULLがあると、何も返しません。NOT EXISTSはNULL安全です。通常のEXISTS/INでは結果は同じです。
Q4: Keyset PaginationとOFFSETの使い分けは?
Keyset Paginationを推奨します。ただし、「Nページ目にジャンプ」が必要な場合はOFFSETを使います。Keysetは「次へ」「前へ」のみの無限スクロールに最適です。
Q5: 一時テーブルはいつ使うべき?
以下の場合に有効です:
- 複雑な集計結果を複数回使う
- 中間結果を保存してデバッグしたい
- 段階的な処理を行いたい
ただし、作成にもコストがかかるので、1回しか使わないなら不要です。
Q6: 最適化の効果をどう測定する?
実行時間を計測してください。SQLiteなら.timer on、アプリケーション側なら時間計測ライブラリを使います。改善前後で数値で比較することが重要です。「なんとなく速くなった」ではなく、「10秒→0.5秒(20倍改善)」のように定量的に評価しましょう。
Step 19: パフォーマンスチューニング実践では、実際のシナリオを使って、これまで学んだテクニックを総合的に活用する実践演習を行います!
学習メモ
SQL応用・パフォーマンス最適化 - Step 18