shibomb

EXPLAINでMySQLスロークエリを解消!無効化を防ぐ複合インデックス設計

データが増えるにつれて一覧画面が重くなり、慌ててインデックスを貼ったのに速度が変わらなかった。そんな経験はありませんか。原因の多くは、貼る列の選び方ではなく、クエリの書き方 や 複合インデックスの列順 にあります。この記事では MySQL を例に、スロークエリを EXPLAIN で読み解く手順、インデックスが効かないNG構文4つ、複合インデックス設計の基本、本番環境で安全に作る手順を、手を動かしながら解説します。

なぜデータが増えると検索が遅くなるのか?フルスキャンとB-Treeインデックスの仕組み

インデックスがない列で検索すると、データベースは先頭から全行を読んで条件に合うか確かめます。これが フルスキャン です。処理量は行数に比例するため、1万行では気づかなかった遅さが、500万行になると秒単位の待ち時間として表面化します。

インデックスは、列の値を並べ替えて保持する「索引」です。MySQL の InnoDB では、主に B-Tree(バランス木)というデータ構造を使います(MySQL 公式ドキュメント「How MySQL Uses Indexes」参照)。木の根から枝をたどって目的の値に到達するため、行数が増えても探索回数は緩やかにしか増えません。本の巻末の索引から該当ページを引くイメージです。

ただしインデックスは無料ではありません。INSERT や UPDATE のたびに索引も更新するため、貼りすぎると書き込みが遅くなり、ディスク容量も消費します。「とりあえず全列に貼る」はNGです。検索条件・並び替えに実際に使われる列へ、必要な分だけ貼るのが基本です。

EXPLAINで実行計画を可視化!最初に見るべき重要カラム(type・possible_keys・rows)

どのクエリが遅いかは、まず スロークエリ ログで特定します。MySQL なら slow_query_log と long_query_time を設定すると、しきい値を超えたクエリが記録されます。遅いクエリを見つけたら、先頭に EXPLAIN を付けて実行計画を確認します。

EXPLAIN
SELECT * FROM orders
WHERE user_id = 1234 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;

出力のうち、最初に見る列は次の3つです。

  • type:テーブルへのアクセス方法です。ALL はフルスキャンで、要注意のサインです。index(索引全体の走査)、range(範囲検索)、ref(等価検索)、eq_ref、const の順に効率が良くなるのが一般的です。
  • possible_keys と key:使える候補のインデックスと、実際に選ばれたインデックスです。possible_keys に候補があるのに key が NULL なら、インデックスが無視されています。
  • rows:読み込むと見積もられた行数です。テーブル全体の行数に近ければ、ほぼ全件を読んでいます。

あわせて Extra 列も確認してください。Using filesort は並び替えにインデックスを使えていない合図で、Using index は索引だけで結果を返せている良い状態です。なお rows は統計情報に基づく 推定値 です。MySQL 8.0.18 以降なら EXPLAIN ANALYZE で実際の実行時間と処理行数も確認でき、見積もりとの差を検証できます。

貼ったはずなのに効いていない?インデックスを無効化してしまうNGクエリ4パターン

インデックスがあっても、書き方次第でオプティマイザは使えなくなります。典型的な4パターンを見ていきましょう。

  1. 列に関数や演算を適用している:インデックスは列の「生の値」で並んでいるため、加工すると索引を引けません。
  2. LIKE の前方が不定(後方一致・部分一致):先頭が決まっていないと、B-Tree の並び順を利用できません。
  3. 暗黙の型変換が起きている:文字列型の列に数値を比較させると、列側が変換されて索引が使えなくなることがあります。
  4. OR で索引のない列とつないでいる:片方の条件が索引を使えないと、結局テーブル全体を読むことになりがちです。

それぞれ、NG例と改善例を並べます。

-- NG: 列に関数を使っている
SELECT * FROM orders WHERE DATE(created_at) = '2026-10-01';
-- 改善: 範囲条件に書き換える
SELECT * FROM orders
WHERE created_at >= '2026-10-01' AND created_at < '2026-10-02';

-- NG: 部分一致(先頭が不定)
SELECT * FROM users WHERE email LIKE '%@example.com';
-- 改善: 前方一致にする(設計上可能な場合)
SELECT * FROM users WHERE email LIKE 'taro%';

-- NG: phone が VARCHAR なのに数値で比較
SELECT * FROM users WHERE phone = 09012345678;
-- 改善: 文字列として比較する
SELECT * FROM users WHERE phone = '09012345678';

OR の場合は、両方の列にインデックスを貼れば、MySQL が index_merge で対処することもあります。ただし常に選ばれるわけではないため、UNION での書き換えも含めて EXPLAIN で比較してください。

補足として、status のように値の種類が少ない列(低選択性の列)は、単独で貼ってもオプティマイザがフルスキャンを選ぶことがあります。インデックスは「絞り込める度合い」が大きい列ほど効果的です。

複数条件の絞り込みを最速にする!複合インデックスの「左側原則(最左プレフィックス)」

複数の列を組み合わせた 複合インデックス は、列の並び順が重要です。B-Tree は定義した列順に左から並べ替えられるため、左の列から連続して使われる条件だけ が索引を利用できます。これが最左プレフィックスの原則です(MySQL 公式ドキュメント「Multiple-Column Indexes」参照)。

たとえば (user_id, status, created_at) の複合インデックスを作ったとします。

CREATE INDEX idx_orders_user_status_created
  ON orders (user_id, status, created_at);

このインデックスは、user_id だけ、user_id と status、3列すべてを条件にしたクエリで利用できます。一方、status だけ、created_at だけを条件にしたクエリでは、先頭の user_id を飛ばしているため基本的に使えません。電話帳が「姓→名」の順に並んでいて、名だけでは引けないのと同じ理屈です。

列順を決める目安は次のとおりです。

  1. 等価条件(=)の列を先頭側に置く
  2. 範囲条件(>、<、BETWEEN)の列は後ろに置く(範囲条件より右の列は絞り込みに使えなくなるため)
  3. ORDER BY の列を末尾に含めると、並び替えを索引に任せられる場合がある

先ほどの WHERE user_id = ? AND status = ? ORDER BY created_at DESC LIMIT 20 は、等価条件2つの後に並び替え列が続くため、上のインデックスと相性が良い形です。EXPLAIN で Using filesort が消え、rows が大きく減っているかを確認してください。

本番環境を止めないために!安全なインデックス作成手順とパフォーマンス計測チェックリスト

大きなテーブルへのインデックス追加は、本番で慎重に行います。InnoDB はオンライン DDL に対応しており、条件が合えば読み書きを止めずに作成できます。ALGORITHM と LOCK を明示すると、対応できない場合に実行前にエラーとなるため、意図しないテーブルロックを避けられます。

ALTER TABLE orders
  ADD INDEX idx_orders_user_status_created (user_id, status, created_at),
  ALGORITHM=INPLACE, LOCK=NONE;

ただし、作成中は負荷が上がります。アクセスの少ない時間帯を選び、まずステージング環境で本番相当のデータ量を使って検証しましょう。さらに大規模なテーブルでは、Percona Toolkit の pt-online-schema-change や GitHub 製の gh-ost など、外部ツールで段階的に変更する選択肢もあります。どれを選ぶかは、運用体制やテーブルサイズで判断してください。

最後に、作業時のチェックリストです。

  1. スロークエリログで対象クエリを特定し、実行時間を記録した
  2. EXPLAIN で type・key・rows・Extra を確認した
  3. NG構文(関数・先頭不定の LIKE・型不一致・OR)を書き換えた
  4. 複合インデックスの列順(等価→範囲→並び替え)を検討した
  5. ステージングで作成時間と負荷を確認した
  6. 作成後に再度 EXPLAIN と実行時間を比較した
  7. 既存インデックスと重複していないか確認した

不要になりそうなインデックスは、MySQL 8.0 以降なら INVISIBLE に設定して、削除前に影響を確認できます。チーム開発では、インデックスを追加した理由と対象クエリをマイグレーションのコメントや PR に残しておくと、後の保守で「なぜこれがあるのか」に迷いません。

関連記事