SQLが遅い原因をEXPLAINで突き止めて直す手順
遅いクエリの特定からEXPLAINの読み方、インデックスが効かない原因、N+1の潰し方まで。Claude Codeと一緒にSQLを速くする実践手順を、コピペで動くコード付きで解説。
「一覧画面が、なんか重いんだよね」
そう相談されて開いた管理画面、注文一覧の2ページ目が出るまで4秒かかっていました。1ページ目は一瞬。でも奥のページに行くほど、じわじわ遅くなる。
僕は最初、データ量のせいだと思い込んでいました。「100万件もあれば、そりゃ遅いよね」と。だから索引を1本足してみた。変わらない。もう1本足した。やっぱり変わらない。索引を増やすほど書き込みが重くなって、状況はむしろ悪化しました。
行き詰まって EXPLAIN を流したら、原因は一発で見えました。クエリは索引をまったく使っておらず、毎回テーブル全体を読んでいたんです。理由はWHERE句の書き方ひとつ。索引を足す前に、これを見ればよかった。
SQLを速くする作業は、勘で索引を足すゲームじゃありません。遅い犯人を特定して、実行計画という証拠を見て、効く一手だけ打つ作業です。今日はその手順を、Claude Codeと一緒に進める形で書きます。
この記事の要点
- 遅いSQLは「勘」で探さない。slow query logや
pg_stat_statementsで実際に重いクエリを名指ししてから動く。 EXPLAINは予定、EXPLAIN ANALYZEは実測。推定行数と実行行数のズレとSeq Scanの出どころを見れば、犯人はだいたい分かる。- 索引が効かないのには理由がある。列を関数で包む・先頭一致でない
LIKE・型の不一致が三大原因。 - N+1は一覧で100倍遅くなる事故。1本のJOINに畳むだけで効く。
- 索引は多いほど良いわけではない。書き込みが重くなるので、効いていない索引は消すのも最適化。
この記事はPostgreSQLを中心に、MySQLにも触れます。スキーマそのものの設計を見直したいならClaude Codeでデータベース設計、PrismaなどORM経由の改善はPrisma ORM活用、索引を安全に本番投入する手順はデータベースマイグレーション、アプリ全体の速度はパフォーマンス最適化が担当です。この記事は「すでにあるクエリを速くする」ことだけに集中します。
まず遅い犯人を名指しする(勘で索引を足さない)
最初にやってはいけないのが、僕がやった「とりあえず索引」です。どのクエリが、何回呼ばれて、合計でどれだけ時間を食っているか。これを見ないと、効かない場所を一生懸命磨くことになります。
PostgreSQLなら pg_stat_statements が一番手っ取り早い。クエリを実行回数と合計時間で並べてくれます。「1回は速いけど100万回呼ばれている軽いクエリ」みたいな、体感では気づけない犯人もここで浮かびます。
-- 拡張を有効化(本番で入れる場合はDBA・運用と相談)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- 合計実行時間が長い順に、重いクエリTOP10を出す
SELECT
query,
calls,
ROUND(total_exec_time::numeric, 2) AS total_ms,
ROUND(mean_exec_time::numeric, 2) AS mean_ms,
rows
FROM pg_stat_statements
WHERE query NOT ILIKE '%pg_stat_statements%'
ORDER BY total_exec_time DESC
LIMIT 10;
MySQLなら、まずslow query logを点けます。「何秒以上かかったクエリをログに残すか」をしきい値で決めるだけです。
-- 0.5秒以上かかったクエリを記録(セッション単位の検証向け)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
SET GLOBAL log_output = 'TABLE';
-- たまったログを件数・平均時間で確認
SELECT
start_time,
query_time,
rows_examined,
rows_sent,
LEFT(sql_text, 120) AS sql_head
FROM mysql.slow_log
ORDER BY query_time DESC
LIMIT 10;
ここでClaude Codeの出番です。ログをそのまま貼って、「同じパターンのクエリをまとめて、呼び出し元のファイル、平均時間、回数、推定原因を表にして」と頼む。手で似たクエリをグルーピングするのは地味につらい作業なので、ここは任せると速い。
ひとつだけ注意。ログにはメールアドレスやトークンが値として混ざります。貼る前にマスクする。これだけは自動化に任せず、自分で確認してください。
EXPLAINを読む:犯人を「証拠」で確定する
遅いクエリを名指しできたら、次は実行計画です。EXPLAIN はDBが「こう実行するつもり」という予定表、EXPLAIN ANALYZE は実際に走らせた実測値。直すなら実測のほうを見ます。
ただし ANALYZE は本当にクエリを実行します。SELECT なら問題ないですが、UPDATE や DELETE に何も考えず付けるとデータが変わります。気をつけてください。
EXPLAIN (ANALYZE, BUFFERS)
SELECT
o.id,
o.created_at,
o.status,
c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
AND o.created_at >= DATE '2026-01-01'
ORDER BY o.created_at DESC, o.id DESC
LIMIT 50;
長い出力に圧倒されがちですが、最初に見るのは3か所だけで十分です。
| 見る場所 | 何が分かるか | 危険なサイン |
|---|---|---|
rows= の推定 vs actual rows= | DBの見込みが現実とどれだけズレているか | 推定100・実測50万のような桁違いのズレ。統計が古いか、条件が読めていない |
Seq Scan / Index Scan | 索引を使ったか、全表を読んだか | 大きいテーブルで Seq Scan。索引が無いか効いていない |
Sort と Buffers | 並び替えのコストと、読んだブロック量 | Sort Method: external merge は索引で消せる並び替え。Buffers が巨大なら読みすぎ |
ここで一番大事なのは、「Seq Scanは悪」と決めつけないことです。数百行の小さいテーブルなら、索引をたどるより全部読んだほうが速い。DBはそれを分かっていて、わざと全表走査を選びます。問題なのは、何万行もあるテーブルでの Seq Scan です。
推定と実測のズレも見逃せません。DBは統計情報をもとに計画を立てるので、統計が古いと「100行しかないはず」と思って雑な計画を選び、実際には50万行読んで撃沈する、ということが起きます。ANALYZE テーブル名; で統計を更新すると直ることがあります。
Claude Codeには、計画をそのまま貼ってこう聞きます。
このEXPLAIN ANALYZEを、(1)ボトルネックの箇所、(2)推定行数と実測のズレ、
(3)索引で消せる並び替えがあるか、(4)索引候補、に分けてレビューして。
返却結果(列・行)は変えない前提で。
「直して」ではなく「分解してレビューして」と頼むのがコツです。いきなり書き換えさせると、結果まで変わる雑な提案が混ざります。
インデックスが効かない3大原因
「索引は貼ったのに Seq Scan のまま」。これ、相談で一番多いやつです。原因はだいたい次の3つに収まります。冒頭で僕が4秒待たされたのも、1つ目でした。
1. WHERE句で列を関数や演算で包んでいる
これが圧倒的に多い。created_at に索引を貼っても、DATE(created_at) = '2026-06-07' のように関数で包むと、索引は使われません。索引は「列の生の値」で並んでいるので、加工した値では引けないんです。
-- 効かない: created_at を関数で包んでいる
SELECT * FROM orders
WHERE DATE(created_at) = '2026-06-07';
-- 効く: 列はそのまま、範囲で書く
SELECT * FROM orders
WHERE created_at >= '2026-06-07'
AND created_at < '2026-06-08';
status = 'paid' のつもりで LOWER(status) = 'paid' と書いてしまうのも同じ罠です。どうしても加工した値で引きたいなら、その式に対する索引(PostgreSQLの式インデックス)を別途作ります。
2. LIKE '%keyword%' で前後にワイルドカード
LIKE 'tanaka%' は前方一致なので索引が効きます。でも LIKE '%tanaka%' のように前にも % を付けると、索引は使えません。電話帳を「た」で始まる人から探すのは速いけど、「名前のどこかに『た』が入る人」を探すには全ページめくるしかない。あれと同じです。部分一致が必要なら、全文検索や pg_trgm のような専用索引を検討します。
3. 比較する型が一致していない
文字列カラムに数値を渡す、あるいはその逆。WHERE user_id = '123'(user_idは整数)のように型がズレると、DBが暗黙の型変換を挟んで索引を諦めることがあります。アプリ側から渡す値の型を、カラムに合わせます。
複合索引の「列の順番」も効き方を左右します。基本は 等価条件 → 範囲条件 → 並び替え の順に列を並べる。さっきの注文一覧(status で等価、created_at で範囲&並び替え)なら、こうです。
-- status で絞り、created_at の新しい順に取る一覧に効く
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_status_created_id
ON orders (status, created_at DESC, id DESC);
CONCURRENTLY を付けると、索引を作っている間も書き込みをブロックしません。ただし通常のトランザクション内では実行できないので、マイグレーションの書き方に注意します(詳しい手順はデータベースマイグレーションに寄せます)。
そして、Claude Codeに索引を提案させるときは 必ず既存の索引一覧を渡す。渡さないと、似たような索引を重複して提案してきます。
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'orders'
ORDER BY indexname;
N+1:一覧で100倍遅くなる事故を1本に畳む
EXPLAINを読んでも「個々のクエリは速いのに、画面全体は遅い」ことがあります。犯人はたいていN+1です。
N+1は、一覧を1本取ったあと、各行ごとに関連データを取りに戻る問題です。注文100件を出すのに、顧客名を1件ずつ100回引きに行く。1回あたり1ミリ秒でも、100回+ネットワーク往復で一気に膨らみます。SQL単体は速いから、slow query logには載らない。だから気づきにくい。
直し方はシンプルで、1本のJOINに畳むだけです。
-- N+1の正体: これを行の数だけ繰り返している
-- SELECT name FROM customers WHERE id = $1; ← 100回
-- 改善: 最初から1本のJOINでまとめて取る
SELECT
o.id,
o.created_at,
o.status,
c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
ORDER BY o.created_at DESC, o.id DESC
LIMIT 100;
ORM経由で起きているN+1の潰し方(PrismaやActive Recordのeager loading)はPrisma ORM活用に詳しく書いたので、そちらへ。ここでは生SQLの観点で1つだけ。SELECT * をやめることです。一覧に本文やJSONや画像メタデータみたいな重い列まで引っ張ると、JOINした瞬間に転送量が跳ねます。画面に出す列だけ名指しで取る。地味ですが、これだけで体感が変わります。
Claude Codeには「この一覧APIでN+1が起きていないか、発行SQLの本数で確認して」と頼むのが効きます。「速くして」より「SQLの本数を数えて」のほうが、機械的に検証できる分だけ確実です。
JOINとサブクエリ、どっちが速い?
「サブクエリは遅いからJOINにしろ」とよく言われます。半分正しくて、半分は古い話です。
相関サブクエリ(外側の行ごとに内側を実行するタイプ)は、たしかに行の数だけ実行されて遅くなりがちです。一方で、最近のPostgreSQLやMySQLのオプティマイザはサブクエリをJOINに書き換えてくれることも多い。だから「どっちが速いか」は、結局 EXPLAIN を見て決めるのが正解です。
判断の目安はこれくらいで足ります。
| 書き方 | 向いている場面 | 注意 |
|---|---|---|
| JOIN | 関連テーブルの列を結果に並べたい | 1対多をJOINすると行が増えて重複する |
IN (サブクエリ) | 「この条件に合うIDだけ絞りたい」 | サブクエリ側が巨大だと重い |
EXISTS | 「関連が1件でもあるか」だけ知りたい | 件数を数えないので COUNT より速いことが多い |
たとえば「一度でも支払い済み注文がある顧客」を出すとき、COUNT で数えてから0より大きいか見るより、EXISTS で「1件でもあるか」を聞くほうが速い。見つけた瞬間に探索を打ち切れるからです。
-- 重くなりがち: 全部数えてから 0 件かどうか判定
SELECT c.id, c.name
FROM customers c
WHERE (SELECT COUNT(*) FROM orders o
WHERE o.customer_id = c.id AND o.status = 'paid') > 0;
-- 速い: 1件でもあれば即打ち切る
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o
WHERE o.customer_id = c.id AND o.status = 'paid');
迷ったら両方書いて EXPLAIN ANALYZE で比べる。これが一番はやい。Claude Codeに「この相関サブクエリを、JOIN版とEXISTS版に書き換えて、それぞれEXPLAINで比較できる形にして」と頼むと、3パターンまとめて出してくれます。
コピペで試せる:遅い→速いを自分の手で再現する
ここまでの話を、手元で1分で再現できるサンプルを置いておきます。PostgreSQLがあれば動きます。10万行のダミーデータを入れて、索引が効かない書き方と効く書き方の差を EXPLAIN ANALYZE で見比べる、というものです。
-- 1) 検証用テーブルと10万行のダミーデータ
DROP TABLE IF EXISTS demo_orders;
CREATE TABLE demo_orders (
id bigserial PRIMARY KEY,
customer_id integer NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL
);
INSERT INTO demo_orders (customer_id, status, created_at)
SELECT
(random() * 5000)::int,
(ARRAY['paid','pending','canceled'])[(random() * 2)::int + 1],
now() - (random() * 365) * INTERVAL '1 day'
FROM generate_series(1, 100000);
-- 統計を最新化(EXPLAINの見込みを正確にする)
ANALYZE demo_orders;
-- 2) 索引を作る(status=等価, created_at=範囲&並び替え の順)
CREATE INDEX idx_demo_status_created
ON demo_orders (status, created_at DESC);
-- 3) 効かない書き方: created_at を関数で包む → Seq Scan のはず
EXPLAIN ANALYZE
SELECT id FROM demo_orders
WHERE status = 'paid'
AND DATE(created_at) = CURRENT_DATE;
-- 4) 効く書き方: 範囲で書く → Index Scan に変わるはず
EXPLAIN ANALYZE
SELECT id FROM demo_orders
WHERE status = 'paid'
AND created_at >= CURRENT_DATE
AND created_at < CURRENT_DATE + INTERVAL '1 day'
ORDER BY created_at DESC
LIMIT 50;
3番と4番の出力を見比べてください。3番は Seq Scan on demo_orders、4番は Index Scan using idx_demo_status_created に変わるはずです。同じ「今日の支払い済み注文」を取っているのに、書き方ひとつで全表走査か索引利用かが分かれる。これが、僕が最初の4秒で踏んでいた地雷の正体です。
実行計画の読み方をもっと深く知りたくなったら、公式が一番正確です。PostgreSQL: Using EXPLAIN と MySQL: Using EXPLAIN を手元に置いておくと、出力の各項目で迷わなくなります。
索引は「増やすほど速い」ではない
最後に、見落とされがちな話を。索引は読み取りを速くしますが、書き込みを遅くします。行を1つ挿入するたびに、関連する索引もぜんぶ更新しないといけないからです。
僕は一覧を速くしたい一心で、あるテーブルに索引を6本貼ったことがあります。読み取りは速くなった。でも、そのテーブルへの登録処理が目に見えて重くなり、バッチ取り込みがタイムアウトし始めました。本末転倒です。
だから「使われていない索引を消す」のも、れっきとした最適化です。PostgreSQLなら、どの索引が使われているか統計で分かります。
-- スキャン回数が0に近い索引は、消す候補
SELECT
relname AS table_name,
indexrelname AS index_name,
idx_scan AS times_used,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC
LIMIT 20;
times_used がずっと0のまま、かつサイズが大きい索引は、ディスクと書き込み速度を無駄に食っているだけかもしれません。消す前に、夜間バッチや月末処理だけで使われていないか確認するのは忘れずに。
よくある質問
Q. EXPLAINとEXPLAIN ANALYZEは、どう使い分けますか?
A. EXPLAIN は実行せず予定だけ見るので安全、まず計画の形を確認したいときに。EXPLAIN ANALYZE は実際に走らせて実測値を出すので、本気で直すときはこちら。ただし UPDATE/DELETE に ANALYZE を付けるとデータが変わるので、その場合はトランザクションで囲んでロールバックします。
Q. 索引を貼ったのに Seq Scan のままです。なぜ?
A. 多いのは、WHERE句で列を関数や演算で包んでいるパターンです(DATE(created_at) = ... など)。あとは LIKE '%word%' の前方ワイルドカード、比較する型の不一致。それでも効かないなら、テーブルが小さくてDBがあえて全表走査を選んでいる可能性もあります。
Q. 遅いクエリは、どうやって見つければいいですか?
A. PostgreSQLは pg_stat_statements、MySQLはslow query log(long_query_time でしきい値を設定)が入口です。合計実行時間の長い順に並べると、1回は軽いけど大量に呼ばれている隠れた犯人も見つかります。
Q. サブクエリは必ずJOINに書き換えるべき?
A. いいえ。相関サブクエリは遅くなりがちですが、最近のオプティマイザはサブクエリをJOINに書き換えることも多いです。「あるか無いか」を見るだけなら EXISTS が速いことも。最終的には EXPLAIN ANALYZE で両方比べて決めます。
Q. Claude Codeにどこまで任せられますか? A. 遅いクエリのグルーピング、EXPLAINの分解レビュー、索引候補の列挙、N+1の検出(発行SQL本数のチェック)までは安心して任せられます。一方で、本番への索引投入の可否や許容できる集計の遅れは、実データと業務要件を見た人間が決める領域です。
実際に試した結果
冒頭の「2ページ目で4秒」の注文一覧は、結局、索引を1本も足さずに直りました。やったのは、WHERE句の DATE(created_at) = ... を範囲条件に書き換えただけ。すでにあった (status, created_at) の索引が、それだけで効くようになったんです。EXPLAIN ANALYZE の Seq Scan が Index Scan に変わった瞬間、4秒が60ミリ秒になりました。
このとき痛感したのは、最初に EXPLAIN を見ていれば、無駄な索引2本を足す回り道は要らなかったということです。今は、SQLが遅いと言われたら反射的に索引を足すのをやめて、まず犯人を名指しして、計画という証拠を見るようにしています。検証用のテーブルでも、N+1除去と書き方の修正だけで発行SQLが101本から1本に減り、深いページの待ち時間も体感で消えました。一方、別のテーブルでは索引の貼りすぎで書き込みが重くなり、使われていない索引を3本消したら登録処理が元に戻りました。速くする最適化と、軽くする最適化は両輪だと再確認しています。
クエリを速くする型番のような手順や、PR用のEXPLAINレビュー観点、研修向けの演習は教材一覧で整理しています。「DBが遅いのに原因が見えない」状態を、実際のログとコードを見ながら一緒にほどきたいときは研修・相談もどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。