SQLチューニング 2026年6月15日

「インデックスを貼ったのに遅い」をEXPLAINで潰す:効かないSQLの見抜き方

XECIN インデックスEXPLAINRDB技術検証

「インデックス貼ったんですけど、全然速くならないんですよね」——この相談、受託の現場に入っていると本当によく受けます。

直近でも、社内で引き受けた既存システムの改修中に、一覧画面の表示が3秒近くかかるという話が出ました。担当の若手は「該当カラムにインデックスは貼ってあります」と言う。確かに貼ってある。でも効いていない。こういうケースは、だいたいパターンが決まっています。

今回は、その「貼ったのに効かない」を切り分けるときに私がいつもやっている手順を、ローカルに用意した検証用テーブル(約320万行)の実測値と一緒に整理しておきます。DBMSはMySQL 8.0を前提にしていますが、考え方はPostgreSQLでもほぼ同じです。


まず疑うべきは「本当にインデックスが使われているか」

速くならないとき、いきなりSQLを書き換えたりインデックスを足したりする人が多いんですが、私は最初に必ず EXPLAIN を見ます。推測で手を動かすと、たいてい遠回りになるからです。

見るべき列は、正直なところ最初は2つだけで十分です。

EXPLAINの列何を見るか危険なサイン
typeアクセス方法。ref / range ならインデックス利用、ALL はフルスキャンALL が出たら全行なめている
key実際に使われたインデックス名NULL なら貼ったインデックスが無視されている

問題の一覧画面のクエリを EXPLAIN にかけたら、こうなっていました。

EXPLAIN
SELECT id, customer_name, status, created_at
FROM orders
WHERE DATE(created_at) = '2026-06-01';
+----+-------------+--------+------+---------------+------+---------+------+---------+-------------+
| id | select_type | table  | type | possible_keys | key  | key_len | ref  | rows    | Extra       |
+----+-------------+--------+------+---------------+------+---------+------+---------+-------------+
|  1 | SIMPLE      | orders | ALL  | NULL          | NULL |    NULL | NULL | 3204891 | Using where |
+----+-------------+--------+------+---------------+------+---------+------+---------+-------------+

type: ALLkey: NULLcreated_at にインデックスは貼ってあるのに、まったく使われず320万行を全部読んでいます。実測で2.4秒でした。

ここまで分かれば、原因は「インデックスが無い」ではなく「インデックスを使えないSQLになっている」ことだと切り分けられます。


インデックスが効かないSQLの典型パターン

key: NULL になる原因は、経験上ほぼ次のどれかに当てはまります。社内のチェックリストとして使っているものを表にしておきます。

パターン効かない書き方書き換え
インデックス列に関数・演算DATE(created_at) = ‘2026-06-01’範囲条件にする(後述)
中間・後方一致のLIKEname LIKE ‘%山田%‘前方一致 ‘山田%‘、無理なら全文検索へ
否定形status != ‘done’対象を肯定形で列挙 status IN (‘open’,‘pending’)
暗黙の型変換文字列カラムに code = 1001型を合わせる code = ‘1001’

今回のクエリは一番上、DATE(created_at) という関数適用です。列を関数でくるんだ瞬間、その列のインデックスは使えなくなります。索引は「生の created_at の値」で並んでいるのに、検索側は「日付に丸めた値」で探そうとするので、噛み合わないわけです。

なので、関数を外して範囲条件に書き換えます。

SELECT id, customer_name, status, created_at
FROM orders
WHERE created_at >= '2026-06-01 00:00:00'
  AND created_at <  '2026-06-02 00:00:00';

これで EXPLAINtyperangekeyidx_created_at に変わり、実測は2.4秒から38ミリ秒になりました。SQLの意味は同じなのに、書き方を変えただけで60倍以上です。

否定形の話も少し補足しておくと、!=<> は「ほとんどの行が該当してしまう」ため、オプティマイザが「だったら全部読んだ方が速い」と判断してインデックスを捨てます。これはバグではなく合理的な判断なので、条件そのものを肯定形で設計し直すしかありません。


複合インデックスは「列の順番」で効きが変わる

単一列でうまくいかないとき、次に複合インデックス(複数列をまとめた索引)を検討します。ここで一度きれいにハマったので、失敗談として書いておきます。

絞り込みと並び替えを両方やるクエリでした。

SELECT id, customer_name, total
FROM orders
WHERE status = 'open'
ORDER BY created_at DESC
LIMIT 50;

最初に私が貼ったのは (created_at, status) の複合インデックスでした。ORDER BY created_at があるから、先頭は created_at だろう、と。

ところがこれが効かない。EXPLAINExtraUsing filesort が出て、結局並び替えのために大量の行をメモリ上でソートしていました。

今思えば当たり前なんですが、複合インデックスは左の列から順に使われるので、先頭が created_at だと、まず status = 'open' での絞り込みに使えない。正しくは「等価条件で絞る列を先、範囲・並び替えの列を後」で、(status, created_at) でした。

-- 改善の余地あり: status の値の種類が少なく偏っている場合
-- (例えば open がテーブルの大半を占める場合)は、
-- この複合インデックスでも絞り込み効果が薄い。
-- その場合は部分インデックス(PostgreSQL)や、
-- アプリ側でのアーカイブ分割も併せて検討する必要がある。
CREATE INDEX idx_status_created_at ON orders (status, created_at);

列順を入れ替えただけで Using filesort が消え、status で絞った範囲をインデックス順にそのまま読むので、LIMIT 50 が一瞬で返るようになりました。複合インデックスは「とりあえず関係する列を全部入れる」ではなく、条件の種類(等価か範囲か)で順番を決めるのがコツだと、この時あらためて体に入りました。


カバリングインデックスで「テーブルを取りに行かない」

もう一段速くしたいときの手として、カバリングインデックスがあります。これは、クエリが必要とする列をすべてインデックスに含めてしまって、テーブル本体を読みに行かせない、という発想です。

先ほどのクエリは id, customer_name, total を返しています。これらをインデックスに含めると、こうなります。

CREATE INDEX idx_orders_cover
  ON orders (status, created_at, customer_name, total);

EXPLAINExtraUsing index が出れば成功で、インデックスの中だけで結果を組み立てられている合図です。テーブル本体への二度目のアクセス(いわゆるランダムI/O)が消えるので、行数が多いほど効きます。今回の検証では38ミリ秒がさらに11ミリ秒まで縮みました。

ただ、これは何でもかんでも列を足せばいい、という話ではありません。インデックスに列を詰め込むほどサイズが膨らみ、次の節の問題に直結します。


貼りすぎると、今度は書き込みが遅くなる

ここまで「インデックスを足して速くする」話をしてきましたが、インデックスはタダではありません。検索が速くなる代わりに、INSERT / UPDATE / DELETE のたびに索引も更新する必要があるので、書き込みは確実に遅くなります。

これは理屈の話ではなく、実際に痛い目を見たことがあります。あるバッチで夜間に数十万件を一括投入していたのですが、検索を速くしたい一心でインデックスを足していった結果、テーブルに8本のインデックスがぶら下がった状態になっていました。

その状態でバッチの投入時間を計ったら、インデックスが2本だった頃の約3倍に膨れ上がっていました。読み取りのために貼ったものが、書き込みのボトルネックになっていたわけです。

観点インデックスを足すと
SELECT(検索)速くなる(狙った条件に効けば)
INSERT / UPDATE / DELETE遅くなる(索引も毎回更新するため)
ストレージ増える(カバリングは特に)

なので私は、インデックスを足すかどうか迷ったら「このテーブルは読み中心か、書き込み中心か」を先に確認するようにしています。マスタ系のように更新が少なく検索が多いテーブルは積極的に貼っていい。逆に、ログやトランザクションのように書き込みが激しいテーブルは、本当に必要な索引だけに絞る。実際にこのバッチのテーブルでは、使われていなかったインデックスを EXPLAIN で洗い出して3本に削り、投入時間を元の水準近くまで戻しました。

ライブラリ選定でも「なぜそれを選ばなかったか」を書くようにしているのと同じで、インデックスも「なぜ貼らなかったか」を説明できる状態にしておくのが、後で効いてきます。


振り返って

結局のところ、スロークエリ対応でやっていることは派手なことではなくて、「EXPLAINtypekey を見る → 効かない書き方を直す → 必要なら複合インデックスを列順を意識して貼る → 貼りすぎを定期的に見直す」という地味な往復です。

ぶっちゃけ、ここに書いたことの大半は新しいテクニックではありません。それでも現場で繰り返し問題になるのは、「インデックスを貼った」という事実だけで安心して、実際に効いているかを確認していないケースが多いからだと思います。

今後は、こういう切り分けを自分の頭の中だけで持っておくのではなく、チームのレビュー観点として「重いクエリを足すPRには EXPLAIN の結果を添える」くらいまでルール化していきたいと考えています。測って、確認してから貼る。当たり前のようでいて、これが一番効きます。