実行計画とクエリチューニング
実行計画とクエリチューニングとは
[[SQL]] は「何が欲しいか」を書く宣言型の言語で、実際にどうやってデータを取り出すかはデータベース内部のオプティマイザ(最適化器)が決めます。その決定結果 — 「どのテーブルを、どの順番で、どの方法で読むか」の手順書 — が実行計画(execution plan)です。
実行計画は EXPLAIN で表示できます。[[データベースインデックス]] で触れた「遅いSQLがあったら、まず EXPLAIN」のその先、つまり出力をどう読み、何を直すのかがこの記事の範囲です。
まず「どのクエリが遅いか」を特定する
チューニングの失敗でいちばん多いのは「遅くないクエリを直してしまう」ことです。推測ではなく、DBが残す記録から探します。
| 入口 | 内容 |
|---|---|
| スロークエリログ | 実行時間が閾値(例: 1秒)を超えたSQLをログに記録する設定。まず有効にする |
| 統計ビュー | 「クエリごとの累計実行時間・実行回数」をDBが集計したもの。PostgreSQL の pg_stat_statements が代表 |
| アプリ側のトレース | 1リクエスト内でどのSQLに何ms使ったかを見る。[[オブザーバビリティ]] の道具立て |
ここで見るべきは1回の実行時間ではなく総時間、つまり「1回の時間 × 実行回数」です。
A: 1回 3,000ms × 1日 10回 = 30秒/日
B: 1回 30ms × 1日 100万回 = 30,000秒/日 ← 実はこちらが主犯
B のような「速いクエリが大量に飛ぶ」形は [[ORM]] の N+1問題で起きがちで、1本ずつ眺めていても気づけません。
EXPLAIN の読み方
EXPLAIN は見積もりだけを表示し、EXPLAIN ANALYZE は実際にクエリを実行して、実測値もあわせて出します。ANALYZE は本当に書き込みが起きるため、UPDATE / DELETE に使うときはトランザクションで囲んで最後にロールバックします。
出力は木構造です。読む向きは、インデントが深い内側から外側へ。
Seq Scan on orders (cost=0.00..18334.00 rows=1 width=64)
(actual time=0.021..142.305 rows=1 loops=1)
Filter: (user_id = 42)
Rows Removed by Filter: 999999
100万行を読んで999,999行を捨て、残ったのは1行。インデックスの出番だと分かります。
読み分けの基本は「走査方法」と「結合方法」です。
| 走査方法 | 意味 |
|---|---|
| Seq Scan | テーブルを先頭から全部読む(フルスキャン) |
| Index Scan | 索引をたどって該当行だけをテーブルから取る |
| Index Only Scan | 欲しい列がすべて索引にあり、テーブル本体を読まずに済む(最速) |
| Bitmap Heap Scan | 該当行がやや多いとき、索引で位置を集めてまとめて読む |
| 結合方法 | 向いている場面 |
|---|---|
| Nested Loop | 外側の行数が少ない。外側1行ごとに内側を索引で引く |
| Hash Join | 大きいテーブル同士。片方をハッシュ表に載せて突き合わせる |
| Merge Join | 両方がソート済み、または結合列に索引がある |
結合そのものの意味は [[sql-join]] を参照。重要なのは、Nested Loop の内側が Seq Scan になっていたら赤信号、という点です。外側1万行 × 内側フルスキャンは、そのまま1万回の全表走査です。
そして最も情報量が多いのが、見積もりの rows と実測の actual rows との乖離。見積もり1行に対して実測10万行なら、オプティマイザは間違った前提で計画を立てています。原因の多くは統計情報の古さで、統計を更新するコマンドの ANALYZE(EXPLAIN ANALYZE とは別物)を実行すると計画ごと変わります。
インデックスを貼ったのに効かない
初学者が必ず詰まる場面です。原因はだいたい次のどれかです。
- 列に関数や演算を掛けている —
WHERE DATE(created_at) = '2026-07-31'では created_at の索引が使えません。created_at >= '2026-07-31' AND created_at < '2026-08-01'のような範囲条件へ書き換えます - 複合インデックスの先頭列を条件にしていない —
(user_id, created_at)の索引は、電話帳が姓→名の順に並んでいるのと同じです。名(created_at)だけでは引けません - 型が合っていない — 文字列の列に数値を渡すと暗黙の型変換が入り、索引が使われないことがあります
- 選択率が低い — 全体の3割がヒットするなら、索引経由で散らばった行を拾うより全部読んだ方が速い。この場合の Seq Scan は誤りではなく、オプティマイザの正しい判断
- 統計情報が古い — 大量データを投入した直後は実態とズレます
直す順序
- 計測して対象を決める(推測で直さない)
- SQL を直す —
SELECT *をやめる、N+1 を1本にまとめる、不要なORDER BYを外す、LIMITを付ける - インデックスを足す(貼りすぎの副作用は [[データベースインデックス]] を参照)
- それでも足りなければ設計を疑う — 重い集計は [[OLTPとOLAP]] のとおり分析側へ逃がす
初学者向けポイント
- [[ORM]] を使っていても発行された SQL は必ず見られます。開発環境でクエリログを常時オンにしておくと、N+1 を書いた直後に気づけます
- 開発DBの100行と本番の1000万行では実行計画そのものが変わります。100行なら Seq Scan が最速なので、開発環境では問題がまったく見えません。件数の近いデータで確認します
- 遅い原因が実行計画とは限りません。計画がきれいなのに遅いなら、[[ロックとデッドロック]] の待ちか、[[コネクションプーリング]] 枯渇による接続待ちを疑います
- 出力の形式や名称はDB製品ごとに違います。名前を覚えるより「全部読んでいないか・索引を使っているか・見積もりと実測がズレていないか」を見る癖をつけます
関連技術とのつながり
- [[SQL]] — 実行計画は、書いたSQLに対してDBが立てる手順書
- [[データベースインデックス]] — 実行計画を Seq Scan から Index Scan へ変えるための主要な手段
- [[ORM]] — 発行されるSQLが見えにくく、N+1のような「総時間」の問題を生みやすい
- [[ロックとデッドロック]] — 実行計画では説明できない「遅い」の代表的な原因
- [[OLTPとOLAP]] — 重い集計はチューニングより、置き場所の分離で解決することがある
Q: `EXPLAIN` と `EXPLAIN ANALYZE` の違いとして正しいのはどれ?
- [ ] ANALYZE はインデックスを自動で作成してくれる
- [x] ANALYZE は実際にクエリを実行して、実測値もあわせて表示する
- [ ] ANALYZE は結果の行数だけを表示し、実行計画は表示しない
解説: EXPLAIN は見積もりだけですが、EXPLAIN ANALYZE は本当に実行します。UPDATE / DELETE に使うときはトランザクションで囲んでロールバックします。
Q: インデックスを貼ったのに効かない原因として本文が挙げたのはどれ?
- [x] WHERE 句で列に関数を掛けている(例: `DATE(created_at) = ...`)
- [ ] テーブルの行数が100万行を超えている
- [ ] SELECT する列が2つ以上ある
解説: 列に関数や演算を掛けるとその列の索引は使えません。範囲条件へ書き換えるのが定番の対処です。
Q: 遅いクエリを探すときに本文が「見るべき」としている指標はどれ?
- [ ] 1回あたりの実行時間だけ
- [x] 1回の実行時間 × 実行回数で求まる総時間
- [ ] SQL文の文字数
解説: 1回30msでも1日100万回なら総時間は膨大になります。N+1問題はこの形で現れるため、1本ずつ見ても気づけません。