インデックスを貼ったのにクエリが速くならない。そんなときは、SQLの書き方がインデックスを使えない形になっていることがほとんどです。この記事では、MySQLでインデックスが効かなくなる代表的な7つのパターンを取り上げます。それぞれについて、EXPLAIN(実行計画の表示)で何が起きているかを確かめ、インデックスが効く形への直し方を紹介します。どれも実務でよく出会う書き方なので、チェックリストとして使ってください。
このシナリオで考える
題材は、100万件ほどの注文データを持つ orders テーブルです。インデックスは次の4つを貼ってあります。
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_code VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
amount INT NOT NULL,
created_at DATETIME NOT NULL,
INDEX idx_user_code (user_code),
INDEX idx_created_at (created_at),
INDEX idx_status_amount (status, amount)
) DEFAULT CHARSET = utf8mb4;
INSERT INTO orders (user_code, status, amount, created_at) VALUES
('1001', 'shipped', 3200, '2026-01-05 10:00:00'),
('1002', 'cancelled', 8800, '2026-02-11 15:30:00'),
('1003', 'shipped', 56000, '2026-03-20 09:15:00');
確認には EXPLAIN を使います。見るのは主に次の3列です。
| 列 | 見るポイント |
|---|---|
| type | ALL はフルスキャン。ref や range ならインデックスを使えています |
| key | 実際に使われたインデックス名。NULL なら未使用です |
| rows | 読むと見込まれる行数。少ないほど効率的です |
以降の rows の値は説明用の目安です。実際の数字はデータ量や統計情報で変わります。
パターン1:列に関数や計算をかけている
WHERE句でインデックス列を関数で包むと、そのインデックスは使えなくなります。インデックスに入っているのは列の値そのものです。YEAR(created_at) のような加工後の値は入っていません。
-- NG: created_at を関数で包んでいる
EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2026;
+------+------+---------+
| type | key | rows |
+------+------+---------+
| ALL | NULL | 1000000 |
+------+------+---------+
直し方は、列をそのまま残し、条件を範囲の形に書き換えることです。
-- OK: 列はそのまま、値の側で範囲を作る
EXPLAIN SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
+-------+----------------+--------+
| type | key | rows |
+-------+----------------+--------+
| range | idx_created_at | 120000 |
+-------+----------------+--------+
amount * 1.1 > 10000 のような計算も同じです。amount > 10000 / 1.1 と、計算を値の側へ移してください。どうしても関数のまま検索したいときは、MySQL 8.0.13以降の関数インデックスが使えます。CREATE INDEX idx_year ON orders ((YEAR(created_at))); のように式を二重のカッコで囲んで作り、クエリ側も同じ式で書く必要があります。
パターン2:暗黙の型変換が起きている
文字列の列を数値と比べると、インデックスが使えなくなります。今回の user_code は VARCHAR です。そこに数値の 1001 を渡すと、MySQLは各行の文字列を数値に変換して比べます。'1001' も ' 1001' も '01001' も数値の1001に等しくなるため、インデックスの並び順を頼りにできないのです。
-- NG: VARCHAR 列に数値を渡している
EXPLAIN SELECT * FROM orders WHERE user_code = 1001;
-- OK: 列の型に合わせて文字列で渡す
EXPLAIN SELECT * FROM orders WHERE user_code = '1001';
NG: type=ALL key=NULL rows=1000000
OK: type=ref key=idx_user_code rows=12
逆向き、つまり INT の列に文字列 '1001' を渡す場合は、定数の側が変換されるのでインデックスは効きます。気をつけるのは「文字列の列に数値」の組み合わせだと覚えてください。アプリのコードで値を数値型のまま渡しているケースが多いので、プレースホルダに渡す型も確認しましょう。
パターン3:LIKE の中間一致・後方一致
LIKE は、先頭が固定された前方一致だけがインデックスを使えます。辞書で「さ」から始まる単語は探せても、「〜さ」で終わる単語は最初から全部めくるしかないのと同じです。
| 書き方 | 種類 | インデックス |
|---|---|---|
LIKE '10%' |
前方一致 | 使える(range) |
LIKE '%01' |
後方一致 | 使えない |
LIKE '%00%' |
中間一致 | 使えない |
EXPLAIN SELECT * FROM orders WHERE user_code LIKE '10%'; -- range
EXPLAIN SELECT * FROM orders WHERE user_code LIKE '%00%'; -- ALL
中間一致が必要な検索なら、FULLTEXTインデックス(全文検索用のインデックス)を検討します。後方一致だけなら、文字列を反転した列を別に持ち、前方一致で探す方法もあります。
パターン4:OR で片方の列にインデックスがない
OR でつないだ条件のうち、1つでもインデックスのない列があるとフルスキャンになりがちです。片方だけインデックスで絞っても、もう片方の条件を満たす行を探すには結局全件を読む必要があるからです。
-- NG: amount 単独のインデックスはない
EXPLAIN SELECT * FROM orders
WHERE user_code = '1001' OR amount > 50000;
直し方は、OR の両側の列にインデックスを用意することです。両方にインデックスがあれば、MySQLはインデックスマージという仕組みで2つの結果を合わせられます。このとき EXPLAIN の type は index_merge、Extra には Using union(...) と出ます。
CREATE INDEX idx_amount ON orders (amount);
-- インデックスマージが選ばれない場合は UNION で分ける
SELECT * FROM orders WHERE user_code = '1001'
UNION
SELECT * FROM orders WHERE amount > 50000;
UNION で分けると、それぞれのクエリが自分のインデックスを使えます。UNION は重複を取り除くので、両方の条件に当てはまる行も1回だけ返ります。
パターン5:複合インデックスの先頭列を使っていない
複合インデックスは、左端の列から順に使われます。idx_status_amount (status, amount) は「status で並べ、同じ status の中で amount で並べた」索引です。そのため、status を指定せずに amount だけで探すと、並び順を活かせません。
| WHERE句 | idx_status_amount |
|---|---|
status = 'shipped' |
使える |
status = 'shipped' AND amount > 10000 |
使える(両方の列) |
amount > 10000 |
基本は使えない |
amount 単独の検索が多いなら、INDEX (amount) を別に作るか、列の順番を見直します。なお MySQL 8.0.13以降には、先頭列を飛ばして読むスキップスキャンもあります。ただしインデックスの列だけで答えられるクエリなど条件が限られるため、頼りにはしない方が安全です。
パターン6:否定条件や、該当する行が多すぎる条件
インデックスは「少ない行を素早く探す」ための仕組みです。status != 'cancelled' のような否定条件は、大半の行が当てはまります。この場合、インデックスを経由して1行ずつ読むより、テーブルを頭から読む方が速いとオプティマイザ(実行計画を決める仕組み)が判断します。
-- 9割の行が該当するので、フルスキャンが選ばれやすい
EXPLAIN SELECT * FROM orders WHERE status != 'cancelled';
これは書き方の誤りというより、インデックスが向かない検索です。取りたい値が決まっているなら status IN ('shipped', 'paid') と肯定形で書くと、範囲で探せる場合があります。NOT IN や IS NOT NULL も同じ考え方で、該当する割合が大きいほどインデックスは使われにくくなります。
パターン7:JOIN する列の型や文字コードが違う
結合(JOIN)する列どうしの型や文字コードがそろっていないと、結合のためのインデックスが使えません。たとえば users.code が latin1、orders.user_code が utf8mb4 だと、比べるたびに変換が入ります。公式リファレンスでも、文字コードが違う列どうしの比較はインデックスを使えないと説明されています。
-- 型と文字コードが一致しているか確認する
SELECT table_name, column_name, data_type, character_set_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND column_name IN ('code', 'user_code');
結合に使う列は、型・長さ・文字コード・照合順序をそろえて設計してください。古いテーブルと新しいテーブルを結合したときにだけ遅い場合は、まずここを疑うとよいでしょう。
よくある落とし穴 / 注意点
- 小さいテーブルではフルスキャンが正解のこともある:数百行程度なら、インデックスを使わない方が速い場合があります。type=ALL を見ても、行数が少なければ慌てなくて大丈夫です
- 統計情報が古いと判断を誤る:大量のデータを入れ替えた直後は
ANALYZE TABLE orders;で統計情報を更新すると、計画が改善することがあります - FORCE INDEX は最後の手段:ヒントで無理に使わせると、データの分布が変わったときに逆に遅くなります。まずは書き方を直しましょう
- 確認は必ず EXPLAIN で:「効いているはず」という思い込みが一番危険です。直したら、type と key が変わったかを毎回確かめてください
まとめ
インデックスが効かない原因は、大きく2つに分けられます。1つは、関数・型変換・中間一致のLIKE・先頭列の飛ばしなど、インデックスの並び順を使えない書き方です。もう1つは、否定条件のように該当する行が多すぎるケースです。前者は書き方を直せば解決します。後者はインデックスの設計から見直すことになります。どちらも EXPLAIN の type・key・rows を見れば見分けられるので、遅いクエリを見つけたらまず実行計画を確認しましょう。
参考リンク
- MySQL 8.0 リファレンス: How MySQL Uses Indexes
- MySQL 8.0 リファレンス: CREATE INDEX(関数インデックス)
- MySQL 8.0 リファレンス: Index Merge Optimization
- MySQL 8.0 リファレンス: EXPLAIN Output Format
アイキャッチ画像: Photo by Jantine Doornbos on Unsplash
