コンテンツへスキップ

データベースインデックスの基礎 — なぜ同じWHERE句でも読む行数が大きく変わるのか

データベースの検索が重いという相談を受けたとき、最初に確認されることが多いのが「その列にインデックスは張ってありますか」という質問だ。インデックスはデータベースの性能を語るうえで欠かせない仕組みだが、「張れば軽くなる魔法」のように扱われがちで、なぜ効くのか、なぜ張ってあるのに効かないことがあるのかは意外と説明されない。

今回は、このブログ自身が動いているWordPressのデータベース(MariaDB)で実際に EXPLAIN を実行した結果を材料に、インデックスの基本的な仕組みと、「同じような WHERE 句なのに、インデックスが使われたり使われなかったりする」理由を整理する。

インデックスは「本の索引」である

インデックスの説明で必ず使われるのが、本の巻末にある索引のたとえだ。

分厚い技術書で「トランザクション」という言葉が出てくるページを探したいとする。索引がなければ、1ページ目から最後のページまで順に目を通すしかない。索引があれば、五十音順に並んだ見出し語から「トランザクション」を探し、そこに書かれたページ番号へ直接飛べる。

データベースのテーブルも同じである。インデックスがない列を条件に検索すると、データベースはテーブルの全行を1行ずつ読んで条件に合うかを確かめる。これをフルテーブルスキャンと呼ぶ。インデックスがあれば、並べ替え済みの索引から該当する値を探し、その行の位置へ直接たどり着ける。

ここで大事なのは、索引が役に立つのは見出し語が順番に並んでいるからだという点だ。バラバラに並んだ索引では、結局すべてに目を通すことになる。データベースのインデックスも「値が並べ替えられた状態で保持されている」ことが本質であり、後で説明する「効く・効かない」の違いのほとんどは、この一点から説明できる。

B-treeという構造

MySQL/MariaDBのInnoDBをはじめ、多くのデータベースで標準的に使われるインデックスの構造がB-tree(正確にはその変形のB+tree)である。

ざっくりいえば、B-treeは「目次の目次」を何段か重ねた木構造だ。

                 [ 根: A〜M | N〜Z ]
                  /                \
     [ A〜F | G〜M ]              [ N〜S | T〜Z ]
       /       \                    /       \
 [葉: 実データへの位置]  ...   ...   [葉: 実データへの位置]

一番上(根)から「探している値はどの範囲か」を比べながら下へたどり、一番下の葉にたどり着くと、そこに実際の行の位置が書かれている。1段ごとに候補がまとめて絞り込まれるため、100万行のテーブルでも、たどる段数は数段で済む。全行を読むフルテーブルスキャンとの差は、行数が増えるほど大きく開いていく。

もう1つの特徴は、葉の部分が値の順番に並び、隣同士がつながっていることだ。このため、「ある値に一致する行」だけでなく、「ある範囲の値を持つ行」(BETWEEN、>、<、前方一致の LIKE 'abc%' など)や、「並べ替えた結果」(ORDER BY)もインデックスをたどるだけで取り出せる。

このブログのwp_postsに張られているインデックス

実際に、このブログのWordPressが使っている wp_posts テーブルのインデックスを SHOW INDEX で確認してみた(WP-CLIの wp db query 経由・出力を整理)。

インデックス名 列(左から順) 用途の例
PRIMARY ID 投稿IDでの1件取得
post_name post_name(先頭191文字) スラッグからの記事特定(パーマリンク解決)
type_status_date post_type, post_status, post_date, ID 「公開済みの投稿を日付順に」という一覧取得
post_parent post_parent 固定ページの親子関係・添付ファイル
post_author post_author 著者別の一覧
type_status_author post_type, post_status, post_author 著者別の公開済み投稿

これらはテーマやプラグインが追加したものではなく、WordPressコアがインストール時に作成する標準のインデックスである。どれも「WordPressが実際によく発行するクエリ」に合わせて設計されている。たとえば type_status_date は、トップページやアーカイブで毎回発行される「post_type='post' かつ post_status='publish' を post_date の新しい順に」というクエリのためにある。

post_name の「先頭191文字」は、utf8mb4(1文字最大4バイト)でインデックスのキー長の上限に収めるための指定で、長い文字列の列では先頭部分だけを索引にすることがある、という例にもなっている。

EXPLAINで「どう探すつもりか」を見る

データベースが実際にインデックスを使うかどうかは、クエリの先頭に EXPLAIN を付けると確認できる。クエリを実行するのではなく、どういう手順で探す予定か(実行計画)を表示してくれる。

まず、トップページの一覧取得に近いクエリを見てみる。

EXPLAIN SELECT ID FROM wp_posts
WHERE post_type = 'post' AND post_status = 'publish'
ORDER BY post_date DESC LIMIT 10;

実行結果(主要な列だけ抜粋):

type possible_keys key rows Extra
ref type_status_date, type_status_author type_status_date 124 Using where; Using index

読み方は次のとおりだ。

  • key: 実際に使うインデックス。type_status_date が選ばれている
  • type: 探し方の種類。ref はインデックスで値が一致する行をたどる方式
  • rows: 読む必要があると見積もった行数(あくまで推定値)
  • Using index: 必要な列(ここでは ID)がインデックスの中だけで揃うため、テーブル本体を読みに行かなくて済むことを示す(カバリングインデックスと呼ぶ)

ORDER BY post_date についても、インデックスの中ですでに post_type → post_status → post_date の順に並んでいるため、改めて並べ替える処理(Extra に出る Using filesort)が発生していない。インデックスが「絞り込み」と「並べ替え」の両方を担っている例である。

次に、スラッグで1件を探すクエリ。WordPressがパーマリンクから記事を特定するときの処理に近い。

EXPLAIN SELECT ID FROM wp_posts
WHERE post_name = 'meta-description-length-html-entity-trap';
type key rows
ref post_name 1

post_name インデックスを使い、見積もり行数は1行。索引から目的のページへ直接飛んでいる状態だ。

同じようなWHERE句なのにインデックスが使われない例

ここからが本題である。インデックスがある列を条件にしていても、書き方しだいでインデックスが使われなくなる。

1. LIKEの「中間一致」

-- 前方一致
EXPLAIN SELECT ID FROM wp_posts WHERE post_name LIKE 'robots%';
-- 中間一致
EXPLAIN SELECT ID FROM wp_posts WHERE post_name LIKE '%robots%';
条件 type key rows
LIKE 'robots%'(前方一致) range post_name 1
LIKE '%robots%'(中間一致) ALL NULL 138

どちらも同じ post_name 列に対する LIKE だが、結果はまったく違う。type=ALL はフルテーブルスキャン、つまり全行を読むという意味だ。

理由は本の索引で考えるとすぐ分かる。「robotsで始まる語」なら、五十音順(アルファベット順)の索引で「r」のあたりを開けば見つかる。しかし「robotsを含む語」は、索引の並び順がまったく手がかりにならない。先頭が何の文字か分からない以上、索引のすべての見出しを確かめるしかない。

WordPressの管理画面の投稿検索や、サイト内検索(?s=キーワード)は、タイトルや本文に対して LIKE '%キーワード%' 形式のクエリを発行する。記事数が増えるとサイト内検索が重くなりやすいのはこのためで、大規模サイトで全文検索エンジンやプラグインを別途導入するのは、この構造的な限界を避けるためである。

2. 複合インデックスの「左端」を使っていない

EXPLAIN SELECT ID FROM wp_posts WHERE post_status = 'publish';
type possible_keys key rows
index NULL type_status_author 138

post_status は type_status_date にも type_status_author にも含まれている列なのに、possible_keys は NULL、つまり「絞り込みに使えるインデックスはない」と判断されている。

これは複合インデックスは左の列から順にしか使えない(左端一致・leftmost prefix)という性質による。type_status_date は post_type → post_status → post_date の順に並べ替えられている。電話帳が「姓 → 名」の順に並んでいるとき、名だけを手がかりに探せないのと同じで、先頭の post_type を指定しないまま2番目の post_status だけで探すことはできない。

なお、type=index・key=type_status_author と表示されているのは、インデックスで絞り込んでいるのではなく、「テーブル本体より小さいインデックスを頭から全部読む」という別の手段を選んだ結果である(必要な列 ID がインデックスに含まれているため)。読む行数は全件のままで、絞り込みの効果はない。key 列に名前が出ていても「インデックスを活かせている」とは限らない、という点は EXPLAIN を読むときの注意点だ。

この性質から、複合インデックスの列の順番は、よく単独で条件に使われる列・等号(=)で絞る列を左に置くのが基本になる。範囲条件(> や BETWEEN)に使う列は、それより右の列の絞り込みを止めてしまうため、右寄りに置くことが多い。

3. 列に関数や計算をかけている

-- インデックスが使われない書き方
SELECT ID FROM wp_posts WHERE YEAR(post_date) = 2026;
-- インデックスを使える書き方
SELECT ID FROM wp_posts
WHERE post_date >= '2026-01-01' AND post_date < '2027-01-01';

インデックスに並んでいるのは post_date の値そのものであって、YEAR(post_date) の結果ではない。列の側に関数をかけると、データベースは全行について関数を計算してから比べるしかなくなる。「列はそのまま、比較する値の側を加工する」と覚えておくとよい。LOWER(列) = '...' や 列 + 1 = 10 なども同じ理由で索引が使えない形になる。

4. インデックスがそもそも無い列

EXPLAIN SELECT post_id FROM wp_postmeta WHERE meta_value = 'x';
type key rows
ALL NULL 5

wp_postmeta には post_id と meta_key(先頭191文字)のインデックスはあるが、meta_value にはない。値の長さも内容も予測できない列だからだ。カスタムフィールドの値で絞り込む meta_query を多用するサイトで一覧ページが重くなりやすいのは、この構造による。このブログの wp_postmeta はまだ数行しかないので問題にならないが、カスタムフィールドを大量に使うサイトでは、同じクエリが数十万行のフルスキャンになりうる。

行数が少ないうちは違いが見えない

ここまでの EXPLAIN を見て、「rows が124と138では大差ないのでは」と思った人もいるだろう。その通りで、このブログの wp_posts は全体で百数十行しかない。この規模なら、フルテーブルスキャンでも一瞬で終わり、体感上の差はほとんど出ない。

データベースのオプティマイザ(実行計画を決める仕組み)は、インデックスがあるからといって必ず使うわけではない。テーブルが小さい場合や、条件に一致する行が全体の大部分を占める場合は、索引をたどって1行ずつ本体を読みに行くより、本体を頭から読んだほうが手間が少ないと判断して、あえてフルスキャンを選ぶこともある。

インデックスの効果が問題になるのは、行数が増えてからだ。開発環境では気にならなかったクエリが、本番でデータが溜まったあとに急に重くなる——というのは典型的なパターンである。小さなデータで遅くないことは、大きなデータでも遅くないことの証明にならない。だからこそ、行数が少ないうちから EXPLAIN で type=ALL になっていないかを確認しておく価値がある。

インデックスは「張るほど良い」わけではない

ここまで読むと、「よく使う列には全部インデックスを張ればよい」と思えるかもしれない。しかしインデックスにはコストがある。

  • 書き込みのたびに更新が必要: 行を追加・更新・削除するたびに、関係するすべてのインデックスも並べ替え済みの状態に保つよう更新される。インデックスが多いほど、INSERT/UPDATE の負荷は増える
  • ディスク容量を使う: インデックスはテーブル本体とは別に保存される。大きなテーブルでは、インデックスの合計サイズが本体に迫ることもある
  • 選ばれないインデックスは負債になる: 値の種類が少ない列(たとえば「はい/いいえ」の2値しかない列)は、索引で絞り込んでも全体の半分が残るため、オプティマイザに使われないことが多い。使われないのに書き込みコストだけがかかる

WordPressの wp_posts に張られているインデックスが6つに絞られているのも、「読み込みでよく使うパターン」と「書き込みのコスト」のバランスを取った結果だと読める。

WordPressでインデックスを追加するときの注意

WordPressの標準テーブルに独自のインデックスを追加すること自体は技術的に可能で、大規模サイトでは実際に行われることもある。ただし、保守の観点からは次の点に注意が必要だ。

  • コアのアップデートでテーブル定義が見直されることがある: WordPressはデータベース更新の際にテーブル定義を比較・調整する処理を持っている。独自に追加したインデックスが意図どおり残るか、更新後に確認する手順を持っておきたい
  • 大きなテーブルへのインデックス追加は時間がかかる: 本番の大きなテーブルに ALTER TABLE ... ADD INDEX を実行すると、その間の書き込みに影響が出る場合がある。バックアップを取り、アクセスの少ない時間帯に行うのが原則
  • まず原因のクエリを特定する: 「何となく遅いからインデックスを足す」ではなく、スロークエリログや EXPLAIN で原因のクエリを特定してから、そのクエリに合わせて設計する

なお、wp db optimize はテーブルの断片化を整理するコマンドで、インデックスを新たに作るものではない。この違いは以前の記事「wp db check / wp db optimize — 見落とされがちなDB健全性コマンド」でも触れた。また、クエリに外部からの値を埋め込む際の注意は「$wpdb->prepare() とSQLインジェクション対策の基礎」を参照してほしい。

よくある落とし穴

  • LIKE '%語%' で検索している: 中間一致は索引の並び順が使えず、全行を読む。大量データで部分一致検索が必要なら全文検索の仕組みを検討する
  • 複合インデックスの2番目以降の列だけで絞り込んでいる: 左端の列を条件に含めないと、絞り込みには使われない
  • 列に関数をかけて比較している: YEAR(列)・LOWER(列)・DATE(列) などは索引が使えない形になる。比較する値の側を加工する
  • 型の違う値と比較している: 文字列の列に数値を渡すなど、型の変換が必要な比較はインデックスが使えなくなる場合がある
  • EXPLAIN の key に名前があるだけで安心する: type=index は「索引を頭から全部読む」という意味で、絞り込みにはなっていない
  • 開発環境の小さなデータで性能を判断する: 行数が少ないとフルスキャンでも一瞬で終わる。本番と同程度のデータで確かめるまで結論を出さない
  • インデックスを張りすぎる: 書き込みの負荷とディスク容量が増える。使われていないインデックスは定期的に見直す

まとめ

インデックスは、値を並べ替えた状態で保持しておくことで、全行を読まずに目的の行へたどり着くための仕組みである。そして「効く・効かない」の違いは、ほぼすべて「その条件で、並び順を手がかりに探せるか」という一点で説明できる。前方一致は探せるが中間一致は探せない。複合インデックスは左の列から順にしか探せない。関数をかけた値は並び順と一致しない。

EXPLAIN はそれを確かめる最も手軽な道具だ。type が ALL になっていないか、key に意図したインデックスが出ているか、rows の見積もりが妥当か——この3点を見る習慣があるだけで、データが増えてから慌てる場面はずっと少なくなる。次回は、同じWordPressのデータベースで「毎回のページ表示で必ず読まれる」設定値、wp_options の autoload を取り上げる予定だ。