SQLのパフォーマンス改善で押さえておくべき考え方と具体的な手法をまとめたガイドです。特定のDBMSに依存しない一般原則を中心にし、製品ごとの差異は都度注記しています(主に PostgreSQL / MySQL / Oracle / SQL Server を想定)。
目次
- 基本原則
- 実行計画を読む
- インデックス設計
- クエリの書き方
- JOINの最適化
- サブクエリ・CTE・ウィンドウ関数
- 集計・ソート・ページング
- 更新系(INSERT / UPDATE / DELETE)
- 統計情報とオプティマイザ
- スキーマ設計
- アプリケーション側の対策
- ロックと同時実行
- チューニングの進め方(手順)
- チェックリスト
1. 基本原則
- 推測するな、計測せよ:改善前後で必ず実行時間・実行計画・I/O量を計測して比較する。
- ボトルネックから潰す:スロークエリログや統計ビューで「合計実行時間(= 平均時間 × 実行回数)」が大きいものから着手する。1回10秒の月次バッチより、1回50msで毎秒100回呼ばれるクエリの方が影響が大きいことも多い。
- 読むデータ量を減らすのが本質:チューニングの大半は「不要な行・列・ページを読まない」ことに帰着する。
- 本番相当のデータで検証する:データ量・分布が違うと実行計画が変わる。開発環境の数百行で速くても意味がない。
- 一度に一つずつ変更する:何が効いたのかを切り分けられるようにする。
2. 実行計画を読む
取得方法
| DBMS | 推定計画 | 実測付き |
|---|---|---|
| PostgreSQL | EXPLAIN | EXPLAIN (ANALYZE, BUFFERS) |
| MySQL | EXPLAIN / EXPLAIN FORMAT=TREE | EXPLAIN ANALYZE(8.0.18+) |
| Oracle | EXPLAIN PLAN FOR + DBMS_XPLAN.DISPLAY | DBMS_XPLAN.DISPLAY_CURSOR |
| SQL Server | 推定実行プラン | 実際の実行プラン / SET STATISTICS IO, TIME ON |
⚠️
EXPLAIN ANALYZEは実際にクエリを実行する。更新系はトランザクション内で実行してロールバックすること。
注目ポイント
- フルスキャン(Seq Scan / ALL / TABLE ACCESS FULL):大きなテーブルで発生していないか。ただし小さなテーブルや大部分の行を読む場合はフルスキャンの方が速いこともある。
- 推定行数と実際の行数の乖離:大きくずれていれば統計情報が古い、または相関のある条件をオプティマイザが見積もれていない。誤った結合方式の選択の主因。
- 結合方式:Nested Loop / Hash Join / Merge Join が適切か。大量行同士の Nested Loop は要注意。
- ソート・一時領域:
Sort、Using filesort、Using temporary、ディスクへのスピル(external merge)が発生していないか。 - 最も時間・I/Oを消費しているノード:計画全体ではなく、コストの大部分を占める箇所に集中する。
3. インデックス設計
基本
WHERE、JOIN ... ON、ORDER BY、GROUP BYで使われる列が候補。- 選択性(カーディナリティ)の高い列ほど効果的。性別やフラグのように値の種類が少ない列単独のインデックスは効きにくい。
- インデックスは更新コスト・ストレージを増やす。「とりあえず全部に貼る」は避け、未使用インデックスは定期的に棚卸しする。
複合インデックス
- 列の順序が重要。基本は「等価条件(
=)の列 → 範囲条件(<,BETWEEN,LIKE 'abc%')の列 → ソート列」。 - 左端一致の原則:
(a, b, c)のインデックスはa、a, b、a, b, cの条件には使えるが、b単独やc単独には基本的に使えない(Oracleのスキップスキャン等の例外あり)。 - 範囲条件より右側の列は絞り込みに使われにくい。
-- 条件: WHERE status = 'active' AND created_at >= '2026-01-01' ORDER BY created_at
CREATE INDEX idx_orders_status_created ON orders (status, created_at);カバリングインデックス
- クエリが必要とする列をすべてインデックスに含めると、テーブル本体にアクセスせずに済む(Index Only Scan)。
- PostgreSQL / SQL Server は
INCLUDE句で非キー列を追加できる。
CREATE INDEX idx_orders_user ON orders (user_id) INCLUDE (total_amount, status);その他のインデックス
- 部分インデックス / フィルタ付きインデックス:特定条件の行だけを対象にする(例:
WHERE deleted_at IS NULL)。サイズが小さく効率的。 - 関数インデックス / 式インデックス:
LOWER(email)など式で検索する場合に使う。 - 用途特化型:全文検索(GIN、FULLTEXT)、地理空間(GiST、R-tree)、JSON(GIN)など。
インデックスが使われなくなる典型パターン
| パターン | 例 | 対策 |
|---|---|---|
| 列に関数・演算を適用 | WHERE YEAR(created_at) = 2026 | WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01' |
| 暗黙の型変換 | 文字列型の列に WHERE code = 123 | 型を合わせる WHERE code = '123' |
| 前方一致でないLIKE | WHERE name LIKE '%田中' | 全文検索 / トライグラムインデックス |
| 否定条件 | WHERE status <> 'done' | 肯定条件に書き換え、部分インデックス |
| OR条件 | WHERE a = 1 OR b = 2 | UNION ALL に分割、または各列にインデックス |
| NULL判定(DBMSによる) | WHERE col IS NULL | OracleはNULLのみの行をB-treeに格納しない点に注意 |
| 照合順序・文字コードの不一致 | JOIN列の collation が異なる | 定義を揃える |
4. クエリの書き方
必要な列だけを取得する
-- NG
SELECT * FROM users WHERE id = 1;
-- OK
SELECT id, name, email FROM users WHERE id = 1;SELECT * は転送量の増加、カバリングインデックスの阻害、スキーマ変更時の不具合の原因になる。
SARGable(インデックスが使える)な条件にする
検索条件の列側を加工しない。値側を加工する。
-- NG
WHERE price * 1.1 > 1000
WHERE DATE(created_at) = '2026-11-05'
-- OK
WHERE price > 1000 / 1.1
WHERE created_at >= '2026-11-05' AND created_at < '2026-11-06'存在確認は EXISTS を使う
-- NG: 全件数えてしまう
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- アプリ側で > 0 を判定
-- OK: 1件見つかった時点で終了
SELECT EXISTS (SELECT 1 FROM orders WHERE user_id = 1);IN / NOT IN / EXISTS / NOT EXISTS
NOT INはサブクエリ側に NULL が含まれると結果が空になる(意図しない動作)。NOT EXISTSを優先する。- 大量の値を
IN (...)に直接並べるのは避け、一時テーブルとのJOINや配列パラメータを検討する。
UNION と UNION ALL
重複排除が不要なら UNION ALL を使う。UNION はソートまたはハッシュによる重複排除のコストがかかる。
DISTINCT の乱用を避ける
DISTINCT で重複を消しているなら、JOINの条件ミスで行が増えていないかをまず疑う。
OR を UNION ALL に分割する
-- 異なる列へのOR条件はインデックスを活かしにくい
SELECT * FROM t WHERE a = 1 OR b = 2;
-- 分割すると各インデックスが使える
SELECT * FROM t WHERE a = 1
UNION ALL
SELECT * FROM t WHERE b = 2 AND (a <> 1 OR a IS NULL);5. JOINの最適化
- 結合キーにインデックスを貼る(特に Nested Loop の内側になるテーブル)。外部キー列にはインデックスがないことが多いので注意(PostgreSQLは外部キーに自動でインデックスを作らない)。
- 結合キーの型・照合順序を揃える。不一致だと型変換が入りインデックスが使えない。
- 早い段階で行数を絞る:絞り込み条件はなるべく結合前に適用されるよう書く(多くの場合オプティマイザが自動で押し下げるが、ビューやサブクエリを挟むと効かないことがある)。
- 不要なJOINを削除:結果に使わないテーブルのJOINが残っていないか確認する。
- 結合方式の目安
| 方式 | 向いているケース |
|---|---|
| Nested Loop | 外側が少数行、内側にインデックスあり |
| Hash Join | 大量行同士の等価結合、インデックスなし |
| Merge Join | 両方が結合キーでソート済み(インデックス順) |
- 推定行数のずれで不適切な方式が選ばれている場合は、まず統計情報の更新を試す。ヒント句での強制は最後の手段。
6. サブクエリ・CTE・ウィンドウ関数
相関サブクエリ
外側の行ごとに実行される相関サブクエリは、JOIN や集計済みサブクエリへの書き換えで大きく改善することがある。
-- NG: 行ごとにサブクエリ実行(オプティマイザが変換しない場合)
SELECT u.id,
(SELECT MAX(o.created_at) FROM orders o WHERE o.user_id = u.id) AS last_order
FROM users u;
-- OK: 一度だけ集計してJOIN
SELECT u.id, o.last_order
FROM users u
LEFT JOIN (
SELECT user_id, MAX(created_at) AS last_order
FROM orders GROUP BY user_id
) o ON o.user_id = u.id;CTE(WITH句)
- 可読性向上に有効。ただしDBMSやバージョンによって実体化(マテリアライズ)されて最適化の壁になることがある。
- PostgreSQL 12以降は既定でインライン展開(
MATERIALIZED/NOT MATERIALIZEDで制御可能)。11以前は常に実体化。
- PostgreSQL 12以降は既定でインライン展開(
- 同じ結果を何度も参照するなら実体化が有利、1回だけならインライン展開が有利。
ウィンドウ関数
自己結合や相関サブクエリで書いていた「グループ内順位」「前行との差分」「累計」はウィンドウ関数で1回のスキャンにまとめられる。
-- ユーザーごとの最新注文
SELECT *
FROM (
SELECT o.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders o
) t
WHERE rn = 1;PARTITION BY + ORDER BY の列に合ったインデックスがあるとソートを省略できる。
7. 集計・ソート・ページング
ORDER BY
- インデックスの並び順と一致させるとソート処理を省略できる。
ORDER BY ... LIMIT nは、インデックスが使えれば先頭n件だけ読んで終わる。- ソート用メモリ(
work_mem、sort_buffer_size等)不足によるディスクスピルに注意。
GROUP BY / 集計
WHEREで絞ってから集計する。HAVINGは集計後の条件にだけ使う。- 頻繁に参照される重い集計は、集計テーブル・マテリアライズドビューの導入を検討する。
COUNT(*)
- 大規模テーブルの正確な
COUNT(*)は高コスト(特にPostgreSQL / InnoDB)。 - 概数で良ければ統計情報(
pg_class.reltuples、information_schema.TABLES.TABLE_ROWS)を使う。
ページング:OFFSET を避けてキーセット方式に
-- NG: OFFSETが大きいほど遅くなる(読み飛ばす行も全部読む)
SELECT * FROM posts ORDER BY id DESC LIMIT 20 OFFSET 100000;
-- OK: キーセットページネーション(シーク法)
SELECT * FROM posts
WHERE id < :last_seen_id
ORDER BY id DESC
LIMIT 20;複数列でソートする場合は行値比較 WHERE (created_at, id) < (:c, :i) を使う(対応DBMSのみ)。
8. 更新系(INSERT / UPDATE / DELETE)
- バルク処理:1行ずつのINSERTではなく、複数行INSERT・
COPY(PostgreSQL)・LOAD DATA(MySQL)・バルクインサートAPIを使う。 - 適切な単位でコミット:巨大な1トランザクションはロック長期化・ログ肥大・ロールバック時間増大を招く。数千〜数万行単位で分割する。
- 大量DELETE
- 分割して少しずつ削除する(
DELETE ... LIMITやキー範囲指定)。 - 全件削除なら
TRUNCATE。 - 定期的に古いデータを消すならパーティションのDROPが最速。
- 分割して少しずつ削除する(
- 大量ロード時:インデックス・制約・トリガーを一時的に無効化/削除し、ロード後に再作成すると速い場合がある(整合性に注意)。
- UPSERT は
INSERT ... ON CONFLICT(PostgreSQL)、INSERT ... ON DUPLICATE KEY UPDATE(MySQL)、MERGEを使い、SELECT→INSERT/UPDATE の往復を減らす。 - UPDATEの対象列に注意:インデックス列の更新はインデックスの更新コストもかかる。値が変わらない行を無駄に更新していないか確認する(
WHERE col IS DISTINCT FROM :new)。
9. 統計情報とオプティマイザ
- 統計情報を最新に保つ:大量更新・ロード後は手動で更新する。
- PostgreSQL:
ANALYZE - MySQL:
ANALYZE TABLE - Oracle:
DBMS_STATS.GATHER_TABLE_STATS - SQL Server:
UPDATE STATISTICS
- PostgreSQL:
- 偏りのあるデータにはヒストグラムを、相関のある複数列には拡張統計(PostgreSQL
CREATE STATISTICS、Oracle 列グループ統計)を検討する。 - バインド変数とプラン:パラメータ化クエリは解析コストを減らすが、データの偏りが大きい列では1つのプランが全値に合わないことがある(パラメータスニッフィング問題)。
- ヒント句は最後の手段:データ量の変化で逆効果になりうるため、統計情報・インデックス・クエリ書き換えで解決できないか先に検討する。使う場合は理由をコメントで残す。
- 定期メンテナンス:PostgreSQLの
VACUUM(autovacuum設定の確認)、断片化したインデックスの再構築など。
10. スキーマ設計
- 適切なデータ型:必要最小限のサイズの型を選ぶ。日付は文字列ではなく日付型、数値は数値型で持つ。主キーは小さく単調増加する型が有利(ランダムUUIDはB-treeの断片化を招きやすい。UUIDv7などの時系列順UUIDを検討)。
- 正規化と非正規化のバランス:基本は正規化し、読み取り性能が要件を満たさない箇所だけ計測に基づいて非正規化する。
- パーティショニング:時系列データなど巨大テーブルで、パーティションプルーニングが効く検索条件があれば効果的。パーティションキーを条件に含まないクエリはむしろ遅くなる。
- NOT NULL制約・CHECK制約:オプティマイザが活用できる情報になる。
- 巨大な列の分離:大きなテキストやJSONを頻繁に読まない場合は別テーブルに分けると、メインテーブルのスキャンが軽くなる。
11. アプリケーション側の対策
- N+1問題を解消する:ループ内で1件ずつクエリを発行せず、
INやJOINでまとめて取得する。ORMでは Eager Loading(includes、select_related、JOIN FETCH等)を使う。 - ORMが生成するSQLを確認する:ログ出力して、不要な列取得・余分なクエリがないか確認する。
- プリペアドステートメント:解析コストの削減とSQLインジェクション対策を兼ねる。
- コネクションプール:接続確立コストを削減する。プールサイズは大きすぎても逆効果(DB側の競合増加)。
- キャッシュ:変更頻度の低いデータはアプリ層・Redis等でキャッシュし、DBへの問い合わせ自体を減らす。
- 読み取りのスケールアウト:リードレプリカに参照系を振り分ける(レプリケーション遅延を考慮)。
12. ロックと同時実行
- トランザクションは短く:トランザクション内で外部API呼び出しやユーザー入力待ちをしない。
- ロック順序を統一してデッドロックを防ぐ。
- 更新条件にインデックス:インデックスがないとUPDATE/DELETEが広範囲の行(またはギャップ)をロックすることがある(特にInnoDB)。
- 分離レベルを理解する:必要以上に高い分離レベルはロック競合や再試行を増やす。
- キュー処理には
SELECT ... FOR UPDATE SKIP LOCKEDが有効(PostgreSQL / MySQL 8.0+ / Oracle)。 - 長時間実行クエリの監視:
pg_stat_activity、SHOW PROCESSLIST、sys.dm_exec_requestsなどでブロッキングを確認する。
13. チューニングの進め方(手順)
- 問題の特定
- スロークエリログ(MySQL
slow_query_log、PostgreSQLlog_min_duration_statement) - 統計ビュー(
pg_stat_statements、performance_schema、Oracle AWR、SQL Server Query Store) - 「合計時間」「呼び出し回数」「平均時間」でランク付けする
- スロークエリログ(MySQL
- 現状の計測:実行時間・実行計画・読み取りブロック数を記録する
- 原因の分析:実行計画のどこが重いかを特定する
- 対策の立案:影響の小さい順に検討する
- クエリの書き換え
- 統計情報の更新
- インデックスの追加・変更
- 設定パラメータの調整
- スキーマ変更・アーキテクチャ変更
- 検証:本番相当データで改善を確認し、他のクエリへの悪影響(特に更新性能)がないかも確認する
- 適用と監視:本番適用後も一定期間モニタリングする
14. チェックリスト
クエリ
-
SELECT *を使っていない - 検索条件の列に関数・演算・型変換が入っていない
- 存在確認に
COUNT(*)ではなくEXISTSを使っている -
NOT INではなくNOT EXISTSを使っている - 重複排除不要なら
UNION ALLを使っている - 不要な
DISTINCT・ORDER BY・JOINがない - 深いページングで
OFFSETを使っていない
インデックス
- WHERE / JOIN / ORDER BY の列に適切なインデックスがある
- 複合インデックスの列順が条件に合っている
- 外部キー列にインデックスがある
- 未使用・重複インデックスがない
実行計画・統計
- 大きなテーブルで意図しないフルスキャンが起きていない
- 推定行数と実際の行数が大きくずれていない
- ソートや集計がディスクにスピルしていない
- 統計情報が最新
アプリケーション
- N+1クエリが発生していない
- プリペアドステートメントを使っている
- トランザクションが短い
- 大量更新をバッチ分割している