【DB】インデックスがあるのにクエリが遅い?よくある落とし穴と対策

データベースのパフォーマンスチューニングといえば、まず思い浮かぶのが「インデックス」。正しく使えば劇的にクエリを高速化してくれる、まさに魔法のような仕組みですよね。

でも、「インデックスを貼ったはずなのに、なぜかクエリが遅い…」「むしろインデックスがない方が速かったりする…?」なんて経験はありませんか?

実は、インデックスは万能薬ではなく、使い方を間違えるとパフォーマンスの足を引っ張ってしまうことがあるんです。この記事では、そんな「インデックスの落とし穴」にハマってしまう典型的なパターンと、その対策について、少しラフな感じで解説していきます。

なぜインデックスで遅くなることがあるの?

インデックスは、分厚い本の巻末にある「索引」のようなものです。索引があれば、探したい単語がどのページにあるかすぐに見つけられますよね。DBも同じで、インデックスをスキャンして目的のデータがある場所(アドレス)を特定し、テーブル本体へ効率的にアクセスします。

しかし、DBの中には「オプティマイザ」という司令官がいて、「このクエリはインデックスを使った方が速いか、それともテーブルを全部読んだ(フルテーブルスキャンした)方が速いか」を都度判断しています。

そして、「インデックスを使う方がかえって手間がかかる」と判断されると、インデックスは無視され、フルテーブルスキャンが選択されます。さらに悪いケースでは、非効率だとわかっていながらインデックスが使われてしまい、結果的に遅くなるということも起こり得ます。

では、具体的にどんなパターンでそうした現象が起こるのか見ていきましょう。

パターン1:絞り込みの意味があまりないカラムへのインデックス(カーディナリティが低い)

「カーディナリティ」とは、ざっくり言うと「値の種類の数」のことです。例えば、「性別」カラム(男、女、その他など)や、「フラグ」カラム(0, 1)のように、値の種類が極端に少ないカラムがこれにあたります。

こういうカラムにインデックスを貼っても、あまり効果はありません。

  • クエリ例: SELECT * FROM users WHERE gender = '男性';

なぜなら、索引を引いてみても「男性:1, 2, 3, 5, 8, 9, 10, …ページ」というように、結局ほとんどのページを参照しなければならず、「もう最初から全部読んだ方が速いじゃん!」とオプティマイザが判断しやすいためです。インデックスをスキャンして、さらにテーブル本体にアクセスする、という二度手間が発生する分、むしろ遅くなる可能性すらあります。

【対策】

  • 値の種類が多い(カーディナリティが高い)カラムをインデックスの対象にしましょう。
  • もし使うのであれば、他のカーディナリティが高いカラムと組み合わせた「複合インデックス」を検討するのが有効です。

パターン2:NULLを条件に指定している

これは非常にハマりやすい落とし穴です。特に、論理削除フラグとして deleted_at のような日付カラムを用意し、NULL の場合に「有効なデータ」と扱う設計でよく発生します。

  • クエリ例: SELECT * FROM products WHERE deleted_at IS NULL;

このクエリがなぜ遅くなる可能性があるのか。理由は2つ考えられます。

  1. DBの仕様: 多くのDB(特にMySQLの古いバージョンなど)では、インデックスに NULL 値そのものが格納されません。そのため、IS NULL で検索しようとするとインデックスが使えず、フルテーブルスキャンになってしまいます。
  2. データの大半がNULLの場合: こちらが本質的な問題です。仮にインデックスが NULL を扱えたとしても、テーブルの99%のデータが deleted_atNULL (未削除)だとしたらどうでしょう?オプティマイザは、「インデックスを読んで、結局テーブルの99%の行にアクセスするくらいなら、最初からテーブルを全部読んだ方がマシ」と判断しやすくなります。この判断が裏目に出て、非効率なインデックススキャンが走ってしまうと、悲劇が起こります。

【対策】

  • 複合インデックスを検討する: deleted_at だけでなく、WHERE句で一緒に指定されやすいカラム(例:status)と組み合わせて (status, deleted_at) のような複合インデックスを作成すると、効率が改善される場合があります。
  • 部分インデックスを使う (PostgreSQLなど): CREATE INDEX idx_products_not_deleted ON products (id) WHERE deleted_at IS NULL; のように、条件に合う行だけをインデックス化する機能です。これにより、NULL のデータだけを効率的に探しにいけます。
  • 0 や特定の日付で代替する: NULL の代わりに、09999-12-31 のような特定の値で「未削除」を表現する設計も一案です。ただし、これはアプリケーションのロジックが少し複雑になる可能性があります。

パターン3:インデックスを貼ったカラムで計算や関数を使っている

これもよくあるミスです。インデックスは「加工されていない、そのままの値」で作成されています。そのため、WHERE句でカラムの値を加工してしまうと、インデックスが使えなくなります。

  • ダメな例: SELECT * FROM sales WHERE YEAR(sales_date) = 2023;
    • sales_date カラムにインデックスがあっても、YEAR() 関数で加工してしまっているためインデックスが使えません。

【対策】

  • カラム側を加工せず、条件値側を加工する: この考え方が非常に重要です。
  • 良い例: SELECT * FROM sales WHERE sales_date >= '2023-01-01' AND sales_date < '2024-01-01';
    • こう書けば、sales_date カラムのインデックスをそのまま使って範囲検索(レンジスキャン)ができるため、非常に効率的です。このような「インデックスが使える書き方」を SARGable と言ったりします。

パターン4:複合インデックスで、列の順番が適切でない

複数のカラムをまとめた「複合インデックス」。これも非常に強力ですが、列の順番が命です。

例えば、(prefecture, city) の順番でインデックスを作成したとします。

  • OKな例: WHERE prefecture = '東京都'
  • OKな例: WHERE prefecture = '東京都' AND city = '渋谷区'
  • NGな例: WHERE city = '渋谷区'

複合インデックスは「左側の列から順に」効いてきます。電話帳が「あいうえお順」→「名前順」になっているのと同じで、いきなり名前で探せないのと同じ理屈です。prefecture を指定しない限り、この複合インデックスは効率的に使われません。

【対策】

  • WHERE句で指定される頻度が高いカラム、そしてよりデータを絞り込める(カーディナリティが高い)カラムを、複合インデックスの左側に置くのが基本セオリーです。

まとめ:インデックスと上手に付き合おう

見てきたように、インデックスはただ貼れば良いというものではありません。

  • カーディナリティを意識する
  • NULL の扱いに注意する
  • カラムを加工しない
  • 複合インデックスの順番は超重要

これらのポイントを押さえるだけでも、無用なトラブルはかなり防げるはずです。

そして何より大切なのは、「実行計画(EXPLAIN)を確認する癖をつけること」です。オプティマイザがあなたの意図通りにインデックスを使ってくれているか、EXPLAIN を使って答え合わせをする。この地道な作業こそが、パフォーマンスチューニングの王道と言えるでしょう。

インデックスの特性をしっかり理解して、快適なデータベースライフを送りたいものですね。