MySQLでインデックスが効かない理由
2026/08/18

インデックスが効く条件

InnoDBのセカンダリインデックスは、インデックスに定義した列の値、または関数インデックスなら式の計算結果を、ソート順に並べたB-treeです。オプティマイザがこれを使えるのは、次の条件が揃ったときです。

  • WHERE句で比較している対象が、インデックスに格納されている値そのもの、つまり列の生の値、または関数インデックスが定義した式の計算結果であり、それを加工した別の値ではない
  • 比較する値の型・文字セット・照合順序が、インデックスの並び順と食い違っていない
  • その条件から、ソート順のどこを起点に読み始めればよいかが決まる

この3つを満たしても、インデックスが効くとは限りません。開始位置が決まっても読む件数がテーブル全体と大差ないと見積もられる場合や、関数インデックスをJOINの結合条件に使う場合など、効かないケースはほかにもあります。

インデックスを張った約10万行の注文テーブルordersで実際に検証した上で、どんなケースでインデックスが効かないのかを整理しました。MySQLのバージョンは8.4.11です。

1. 格納されている値と比較する値の形が違う

列に関数をかける

created_at列そのものではなくDATE(created_at)という計算結果を比較すると、インデックスに格納されているのは生のcreated_atの値なのでインデックスを使った効率的な検索ができなくなります。

EXPLAIN SELECT * FROM orders WHERE DATE(created_at) = '2024-06-15'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 96849
     filtered: 100.00
        Extra: Using where

possible_keysがNULLになっており、候補にすらなっていません。列に関数をかけるのをやめ、同じ日付の範囲を生のcreated_atに対する範囲条件で表せば、idx_created_usercreated_at, user_id)が使えます。

EXPLAIN SELECT * FROM orders
  WHERE created_at >= '2024-06-15 00:00:00' AND created_at < '2024-06-16 00:00:00'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: range
possible_keys: idx_created_user
          key: idx_created_user
      key_len: 5
          ref: NULL
         rows: 143
     filtered: 100.00
        Extra: Using index condition

JSON列をその場で抽出する

JSON型のpayload列から->>演算子でその場で値を取り出して比較すると、インデックスが効きません。->>JSON_UNQUOTE(JSON_EXTRACT(...))の短縮記法なので、列に関数をかけている点でDATE(created_at)のケースと同じです。

EXPLAIN SELECT id FROM orders WHERE payload->>'$.order_no' = 'A-000001'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 96849
     filtered: 100.00
        Extra: Using where

possible_keysがNULLになっており、候補にすらなっていません。

暗黙の型変換で列側が変換される

order_noはVARCHARですが、これを数値の1と比較すると、インデックスに格納されている文字列の値を1件ずつ数値に変換してから比較することになります。

EXPLAIN SELECT id FROM orders WHERE order_no = 1\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: index
possible_keys: idx_order_no
          key: idx_order_no
      key_len: 82
          ref: NULL
         rows: 96849
     filtered: 10.00
        Extra: Using where; Using index

typeindexになっており、インデックスは使っているものの、先頭から末尾まで全件を読んでいます。比較時の型変換により、文字列列と数値定数の比較は浮動小数点として行われます。'1'' 1''1a'のように同じ数値に変換される文字列が複数あるため、変換後の値から元の文字列の位置を逆算できず、インデックスで開始位置を決められません。文字列のまま比較すれば一発で位置が決まります。

EXPLAIN SELECT id FROM orders WHERE order_no = 'A-000001'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: idx_order_no
          key: idx_order_no
      key_len: 82
          ref: const
         rows: 1
     filtered: 100.00
        Extra: Using index

一方、shop_idのような数値列を文字列定数と比較する場合は、列側ではなく定数側が変換されます。定数畳み込み最適化により、'3'は先に整数の3として読み替えられ、列の値はそのまま整数として比較されます。

EXPLAIN SELECT * FROM orders WHERE shop_id = '3'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: idx_shop_user_created
          key: idx_shop_user_created
      key_len: 4
          ref: const
         rows: 9234
     filtered: 100.00
        Extra: NULL

列側が変換されるか、定数側が変換されるかは、列の型と定数の型の組み合わせによってMySQLの規則で決まります。ここで押さえておきたいのは、列側が変換されるとインデックスに格納されている値の並びと比較後の値の並びが噛み合わなくなり、位置を決められなくなることです。定数側だけが変換される場合は、列の値の並びもインデックスの並びも変わらないため、そのまま使えます。

文字セット・照合順序の不一致

同じ文字セットでも照合順序が違う列同士を比較すると、変換ではなくエラーになります。

EXPLAIN SELECT u.user_id FROM users_utf8 u JOIN users_ci c ON u.user_id = c.user_id\G
ERROR 1267 (HY000): Illegal mix of collations (utf8mb4_0900_ai_ci,IMPLICIT) and (utf8mb4_general_ci,IMPLICIT) for operation '='

utf8mb4_0900_ai_ciutf8mb4_general_ciは同じutf8mb4ですが照合順序が異なり、比較そのものが拒否されます。

latin1utf8mb4など、文字セット自体が違う列同士を比較するときは、暗黙の変換が入ります。照合順序の変換可能性により、片方がUnicode、もう片方が非UnicodeならUnicode側が優先され、非Unicode側が変換されます。次の例では、latin1orders_latin1を先に読み、各行のuser_idutf8mb4に変換した値でordersを検索しています。検索される側のordersutf8mb4のままインデックスを使えるためrefが残ります。先に読むorders_latin1側にWHERE条件はなく全件を読む必要があるため、type: indexになっているのは変換の影響ではなく、インデックスを先頭から末尾までたどる方がテーブル本体を読むよりコストが低いと判断されたからです。

EXPLAIN SELECT o.id FROM orders o JOIN orders_latin1 l ON o.user_id = l.user_id\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: l
   partitions: NULL
         type: index
possible_keys: idx_user_id
          key: idx_user_id
      key_len: 18
          ref: NULL
         rows: 9859
     filtered: 100.00
        Extra: Using index
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: o
   partitions: NULL
         type: ref
possible_keys: idx_user_created
          key: idx_user_created
      key_len: 66
          ref: innodb_verify.l.user_id
         rows: 2152
     filtered: 100.00
        Extra: Using where; Using index

文字セット違いが常にインデックスを無効にするとは限らず、データ分布や結合順序によって結果が変わります。

関数インデックスは式の結果しか持たない

関数インデックスは、列そのものではなく、定義した式の計算結果を格納するインデックスです。WHERE句で式が一致する形で書かれていれば、通常の列と同じように使えます。

EXPLAIN SELECT id FROM t_func_only WHERE (CAST(created_at AS DATE)) = '2024-06-15'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: t_func_only
   partitions: NULL
         type: ref
possible_keys: idx_func
          key: idx_func
      key_len: 4
          ref: const
         rows: 143
     filtered: 100.00
        Extra: NULL

このインデックスは式の結果しか持たないため、範囲条件に書き換えても、生のcreated_at列自体にインデックスがなければ使えません。インデックスに格納されているのはCAST(created_at AS DATE)の計算結果であり、生のcreated_atとは値の形が違うからです。

EXPLAIN SELECT id FROM t_func_only
  WHERE created_at >= '2024-06-15 00:00:00' AND created_at < '2024-06-16 00:00:00'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: t_func_only
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 99750
     filtered: 11.11
        Extra: Using where

2. インデックスを途中から読もうとする

先頭ワイルドカードのLIKE

LIKE '%Alice%'のように先頭にワイルドカードがあると、name列のどの位置から一致し得るかがソート順から決まりません。

EXPLAIN SELECT * FROM orders WHERE name LIKE '%Alice%'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 96849
     filtered: 11.11
        Extra: Using where

前方一致であれば、先頭からの並びがそのまま使えます。

EXPLAIN SELECT id FROM orders WHERE order_no LIKE 'A-0000%'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: range
possible_keys: idx_order_no
          key: idx_order_no
      key_len: 82
          ref: NULL
         rows: 99
     filtered: 100.00
        Extra: Using where; Using index

複合インデックスの左側の列を使用しない

複合インデックスは、複数の列をつなげた1本のキーとしてソート順に並びます。(shop_id, user_id, created_at)なら、shop_idが同じ範囲の中でuser_idが並び、さらにその中でcreated_atが並ぶ、という入れ子の順序です。実際の並びは次のようになります。

shop_id  user_id   created_at
1        alice01   2024-06-01
1        alice01   2024-06-03
1        bob02     2024-06-02
2        alice01   2024-06-01
2        carol03   2024-06-04

shop_idが1の範囲の中だけを見るとuser_idalice01bob02の順に並び、さらにalice01の中ではcreated_atが日付順に並んでいます。shop_idが2に変わると、user_idcreated_atの大小関係はいったんリセットされ、また先頭から並び直しです。この構造上、先頭列であるshop_idの条件がないと、ソート順のどこから読み始めればよいか決まりません。

(created_at, user_id)という並びのインデックスを持つt_created_userテーブルに対して、先頭列created_atを指定せずuser_idだけで検索した場合は次のようになります。

EXPLAIN SELECT id, created_at FROM t_created_user WHERE user_id = 'alice01'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: t_created_user
   partitions: NULL
         type: index
possible_keys: idx
          key: idx
      key_len: 71
          ref: NULL
         rows: 98481
     filtered: 10.00
        Extra: Using where; Using index

keyにはidxが入っており、インデックスそのものは使われています。しかしtypeindexで、rowsはテーブルの全10万件に近い98481件です。先頭列created_atに条件がないため、どこから読み始めればよいかが決まらず、インデックスを先頭から末尾まで丸ごとたどりながらuser_idで1件ずつふるいにかけています。読む件数だけで見ればテーブル全体を読むのとほぼ変わらず、位置決めの役には立っていません。

ただし、最適化によってインデックスが効くケースもあります。(user_id, created_at)のインデックスに対してuser_idを指定せずcreated_atだけで検索すると、直前の例と同じく開始位置が決まらないはずですが、MySQL 8.0.13以降のSkip Scanが、先頭列の取り得る値ごとに区切って読むことでこのインデックスを使えるようにします。

EXPLAIN SELECT id, user_id FROM t_user_created
  WHERE created_at BETWEEN '2024-06-10 00:00:00' AND '2024-06-12 00:00:00'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: t_user_created
   partitions: NULL
         type: range
possible_keys: idx
          key: idx
      key_len: 71
          ref: NULL
         rows: 11777
     filtered: 100.00
        Extra: Using where; Using index for skip scan

ExtraUsing index for skip scanとあります。Skip Scanは先頭列の取り得る値の種類が少ないときに有効な最適化で、常に使えるわけではありません。左端列を条件に含められるなら、それが最も確実です。

複合インデックスの途中列を飛ばす

(shop_id, user_id, created_at)user_idを飛ばしてshop_idcreated_atだけを条件にすると、連結キーの途中に穴が開くため、created_atは開始位置の決定には使えません。

EXPLAIN SELECT * FROM orders
  WHERE shop_id = 3 AND created_at >= '2024-06-10 00:00:00'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: idx_shop_user_created,idx_created_user
          key: idx_shop_user_created
      key_len: 4
          ref: const
         rows: 9234
     filtered: 50.00
        Extra: Using index condition

typerefなので効いているように見えますが、refが示しているのは先頭列shop_idだけで開始位置が決まったことです。key_lenshop_idのINT1列分、4バイトしかなく、created_atは位置決めには使われていません。shop_idで絞った範囲の行をすべて読みながら、created_atの条件はUsing index conditionでフィルタしているだけの状態です。

範囲条件を複合インデックスの先頭に置く

(created_at, user_id)のように範囲条件になりがちな列を左端に置く場合と(user_id, created_at)のように等価条件になりがちな列を左端に置く場合では、パフォーマンスに大きな差が生まれます。

EXPLAIN ANALYZE SELECT * FROM orders FORCE INDEX (idx_created_user)
  WHERE created_at >= '2024-06-10 00:00:00' AND user_id = 'alice01'\G
-> Index range scan on orders using idx_created_user over ('2024-06-10 00:00:00' <= created_at AND 'alice01' <= user_id), with index condition: ((orders.user_id = 'alice01') and (orders.created_at >= TIMESTAMP'2024-06-10 00:00:00'))  (cost=0.71 rows=1) (actual time=0.801..17.6 rows=1428 loops=1)
EXPLAIN ANALYZE SELECT * FROM orders FORCE INDEX (idx_user_created)
  WHERE created_at >= '2024-06-10 00:00:00' AND user_id = 'alice01'\G
-> Index range scan on orders using idx_user_created over (user_id = 'alice01' AND '2024-06-10 00:00:00' <= created_at), with index condition: ((orders.user_id = 'alice01') and (orders.created_at >= TIMESTAMP'2024-06-10 00:00:00'))  (cost=0.71 rows=1) (actual time=0.0153..1.78 rows=1428 loops=1)

クエリの結果として同じ1428件を返すのに、created_atが先頭のインデックスはactual timeが17.6ms、user_idが先頭のインデックスは1.78msでした。(created_at, user_id)ではcreated_at >= ?という条件が全ユーザー分の連続領域になるため、その範囲すべてを読みながらuser_idの条件をインデックスの中で判定してふるいにかけています。(user_id, created_at)ではuser_id = 'alice01'でまず該当ユーザーの区画まで絞り込み、そのわずかな区画の中だけを範囲で読むため、読む量そのものが桁違いに少なくなります。等価条件を先頭、範囲条件を後ろに置くのが定石とされるのはこのためです。

3. 条件を満たしても、それ以外の理由で選ばれない

ここまでは探索できるかどうかでしたが、ここからは探索はできるが、それでも選ばれない場合です。B-treeの構造上の制約ではなく、読む件数の見積もりやJOINの結合方式など、オプティマイザ側の判断によるものです。

低選択性の等価条件

deletedstatusのようなカーディナリティが低い列でも、InnoDBは条件に一致する分だけをインデックス経由で読みます。ヒットする行の割合が大きくても、いきなりテーブル全体の走査に切り替わるわけではありません。

EXPLAIN SELECT * FROM orders WHERE deleted = 0\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: idx_deleted
          key: idx_deleted
      key_len: 1
          ref: const
         rows: 48424
     filtered: 100.00
        Extra: NULL

このテーブルではdeleted = 0が全体の9割を占めますが、typerefのままです。一方、否定条件status <> 'canceled'は状況が違います。

EXPLAIN SELECT * FROM orders WHERE status <> 'canceled'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: idx_status
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 96849
     filtered: 50.00
        Extra: Using where

possible_keysidx_statusは挙がりますが、keyはNULLです。等価条件は特定の値へのパスを直接たどれるのに対し、否定条件は「その値以外すべて」という範囲になり、結局大半を読むと見積もられてテーブル走査に切り替わっています。同じ低選択性でも、等価か否定かでオプティマイザの判断が変わります。

別の列をまたぐOR

user_id = 'alice01' OR shop_id = 3のように別々の列にまたがるORは、それぞれの条件が十分に絞り込めていても、まとめて1回のインデックスアクセスにはなりません。

EXPLAIN SELECT * FROM orders WHERE user_id = 'alice01' OR shop_id = 3\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: idx_user_created,idx_shop_user_created
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 96849
     filtered: 7.65
        Extra: Using where

両方とも候補には挙がっていますが、Index Mergeによる合算よりテーブル走査の方がコストが低いと判断されています。UNION ALLに分割すると、それぞれの枝で個別にインデックスが使われます。

EXPLAIN SELECT * FROM orders WHERE user_id = 'alice01'
UNION ALL
SELECT * FROM orders WHERE shop_id = 3\G
*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: orders
   partitions: NULL
         type: ref
possible_keys: idx_user_created
          key: idx_user_created
      key_len: 66
          ref: const
         rows: 2000
     filtered: 100.00
        Extra: NULL
*************************** 2. row ***************************
           id: 2
  select_type: UNION
        table: orders
   partitions: NULL
         type: ref
possible_keys: idx_shop_user_created
          key: idx_shop_user_created
      key_len: 4
          ref: const
         rows: 9234
     filtered: 100.00
        Extra: NULL

同一列に対するINORはこの問題を起こしません。別列をまたぐときだけ注意します。

プレフィックスインデックスが短すぎる

order_no(3)のように先頭数文字だけをインデックス化すると、開始位置は決まりますが、その数文字が一致する行すべてを読むことになります。

EXPLAIN SELECT * FROM orders_prefix WHERE order_no = 'A-000001'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders_prefix
   partitions: NULL
         type: ref
possible_keys: idx_order_no_prefix
          key: idx_order_no_prefix
      key_len: 14
          ref: const
         rows: 50172
     filtered: 100.00
        Extra: Using where

typerefで開始位置は決まっていますが、このシードのorder_noA-000001からA-100000までなので、先頭3文字A-0A-099999までのほぼ全行に一致します。プレフィックス長は、実際のデータでどこまで絞り込めるかを見て決める必要があります。

ORDER BYがインデックスの並びと一致しない

WHERE句の絞り込みに使ったインデックスが、ORDER BYの並び順まで満たしているとは限りません。

EXPLAIN SELECT * FROM orders WHERE user_id = 'alice01' ORDER BY name\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: idx_user_created
          key: idx_user_created
      key_len: 66
          ref: const
         rows: 2000
     filtered: 100.00
        Extra: Using filesort

typerefで絞り込み自体はうまくいっていますが、ExtraUsing filesortが出ています。idx_user_createduser_idのあとにcreated_atが並ぶインデックスで、nameの順序とは無関係だからです。ORDER BYまでインデックスで満たしたい場合は、(user_id, name)のような並びのインデックスが別途必要です。

関数インデックスをJOINの結合条件に使う

前の節で見たとおり、関数インデックスはWHERE句の定数比較で使えます。ですが、JOINの結合条件では、同じ式であっても関数インデックスは候補になりません。

EXPLAIN SELECT o.id FROM report_days d JOIN t_func_only o
  ON (CAST(o.created_at AS DATE)) = d.d\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: d
   partitions: NULL
         type: index
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 3
          ref: NULL
         rows: 3
     filtered: 100.00
        Extra: Using index
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: o
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 99750
     filtered: 100.00
        Extra: Using where; Using join buffer (hash join)

o側はidx_funcを持っていますが、possible_keysはNULLです。ハッシュ結合を切ってもFORCE INDEXを付けても、候補にはなりません。ハッシュ結合は結合条件に使えるインデックスがないあとの代替であり、インデックスを無視して選ばれているわけではありません。

関数インデックスは式の計算結果をインデックスの中だけに持ちますが、生成カラムは計算結果を実際の列として持ちます。生成カラムとは、GENERATED ALWAYS AS (式) STOREDで定義する、値が自動計算される通常の列です。列として存在するため、生成カラムには関数インデックスのようなJOINでの制限はありません。同じCAST(created_at AS DATE)でも、結果を生成カラムとして持ち、そこに通常のインデックスを張って列同士で結合するとrefになります。

EXPLAIN SELECT o.id FROM orders_func o JOIN report_days d
  ON o.created_date = d.d\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: d
   partitions: NULL
         type: index
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 3
          ref: NULL
         rows: 3
     filtered: 100.00
        Extra: Using index
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: o
   partitions: NULL
         type: ref
possible_keys: idx_created_date
          key: idx_created_date
      key_len: 4
          ref: innodb_verify.d.d
         rows: 145
     filtered: 100.00
        Extra: Using index

JOINでインデックスを使いたいなら、関数インデックスではなく生成カラムのインデックスを使う必要があります。

補足: IS NULLだからインデックスが効かないわけではない

ここまでは、インデックスが効かなくなる条件を見てきました。最後に、よくある誤解を1つ検証します。NULLは値がない状態ですが、InnoDBのセカンダリインデックスにはNULLの行もエントリとして載ります。IS NULLだからインデックスが使えない、という決めつけは実測と合いません。

EXPLAIN SELECT * FROM orders WHERE shipped_at IS NULL\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: idx_shipped_at
          key: idx_shipped_at
      key_len: 6
          ref: const
         rows: 5000
     filtered: 100.00
        Extra: Using index condition

shipped_atは全体の5%がNULLですが、typerefです。NULLもソート順の中の1つの値として扱われ、他の値と同じように位置が決まります。効かないのはNULLだからではなく、これまでの節で見た、比較する値の形や開始位置の決め方に問題があるときです。