「インデックス貼ったんですけど、全然速くならないんですよね」——この相談、受託の現場に入っていると本当によく受けます。
直近でも、社内で引き受けた既存システムの改修中に、一覧画面の表示が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: ALL で key: NULL。created_at にインデックスは貼ってあるのに、まったく使われず320万行を全部読んでいます。実測で2.4秒でした。
ここまで分かれば、原因は「インデックスが無い」ではなく「インデックスを使えないSQLになっている」ことだと切り分けられます。
インデックスが効かないSQLの典型パターン
key: NULL になる原因は、経験上ほぼ次のどれかに当てはまります。社内のチェックリストとして使っているものを表にしておきます。
| パターン | 効かない書き方 | 書き換え |
|---|---|---|
| インデックス列に関数・演算 | DATE(created_at) = ‘2026-06-01’ | 範囲条件にする(後述) |
| 中間・後方一致のLIKE | name 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';
これで EXPLAIN の type は range、key は idx_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 だろう、と。
ところがこれが効かない。EXPLAIN の Extra に Using 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);
EXPLAIN の Extra に Using index が出れば成功で、インデックスの中だけで結果を組み立てられている合図です。テーブル本体への二度目のアクセス(いわゆるランダムI/O)が消えるので、行数が多いほど効きます。今回の検証では38ミリ秒がさらに11ミリ秒まで縮みました。
ただ、これは何でもかんでも列を足せばいい、という話ではありません。インデックスに列を詰め込むほどサイズが膨らみ、次の節の問題に直結します。
貼りすぎると、今度は書き込みが遅くなる
ここまで「インデックスを足して速くする」話をしてきましたが、インデックスはタダではありません。検索が速くなる代わりに、INSERT / UPDATE / DELETE のたびに索引も更新する必要があるので、書き込みは確実に遅くなります。
これは理屈の話ではなく、実際に痛い目を見たことがあります。あるバッチで夜間に数十万件を一括投入していたのですが、検索を速くしたい一心でインデックスを足していった結果、テーブルに8本のインデックスがぶら下がった状態になっていました。
その状態でバッチの投入時間を計ったら、インデックスが2本だった頃の約3倍に膨れ上がっていました。読み取りのために貼ったものが、書き込みのボトルネックになっていたわけです。
| 観点 | インデックスを足すと |
|---|---|
| SELECT(検索) | 速くなる(狙った条件に効けば) |
| INSERT / UPDATE / DELETE | 遅くなる(索引も毎回更新するため) |
| ストレージ | 増える(カバリングは特に) |
なので私は、インデックスを足すかどうか迷ったら「このテーブルは読み中心か、書き込み中心か」を先に確認するようにしています。マスタ系のように更新が少なく検索が多いテーブルは積極的に貼っていい。逆に、ログやトランザクションのように書き込みが激しいテーブルは、本当に必要な索引だけに絞る。実際にこのバッチのテーブルでは、使われていなかったインデックスを EXPLAIN で洗い出して3本に削り、投入時間を元の水準近くまで戻しました。
ライブラリ選定でも「なぜそれを選ばなかったか」を書くようにしているのと同じで、インデックスも「なぜ貼らなかったか」を説明できる状態にしておくのが、後で効いてきます。
振り返って
結局のところ、スロークエリ対応でやっていることは派手なことではなくて、「EXPLAIN で type と key を見る → 効かない書き方を直す → 必要なら複合インデックスを列順を意識して貼る → 貼りすぎを定期的に見直す」という地味な往復です。
ぶっちゃけ、ここに書いたことの大半は新しいテクニックではありません。それでも現場で繰り返し問題になるのは、「インデックスを貼った」という事実だけで安心して、実際に効いているかを確認していないケースが多いからだと思います。
今後は、こういう切り分けを自分の頭の中だけで持っておくのではなく、チームのレビュー観点として「重いクエリを足すPRには EXPLAIN の結果を添える」くらいまでルール化していきたいと考えています。測って、確認してから貼る。当たり前のようでいて、これが一番効きます。