- データベース
- 技術 💻
DBインデックス8選|複合の列順・カバリング・ビットマップ・リビルド判断
インデックスって、「検索する列に貼る」までは誰でも習うんですよね。でもその先が、あまり語られない。貼ったのに使われない。貼りすぎて遅くなる。DBによって挙動が違う。
今回は、知っていると設計が変わるものを8つ集めました。「貼れば速くなる」の先にある話です。1つでも知らなかったら儲けものです。異論・追加も大歓迎です。
① テーブルを、そもそも読みに行かない
インデックスの使われ方には、2段階あるんです。普通は「インデックスで場所を特定 → テーブル本体を読みに行く」という2ステップですよね。ところが、必要な列がぜんぶインデックスの中に入っていれば、テーブルを読みに行く必要がありません。
-- user_id で検索して、status と updated_at だけ欲しい
CREATE INDEX idx_orders ON orders (user_id, status, updated_at);
SELECT status, updated_at FROM orders WHERE user_id = 42;
このとき、テーブル本体へのアクセスがゼロになります。カバリングインデックスと呼ばれる状態ですね。PostgreSQLやSQL Serverなら、検索には使わない列を INCLUDE で足すこともできます。
CREATE INDEX idx_orders ON orders (user_id) INCLUDE (status, updated_at);
検索条件に使う列と、返したいだけの列を分けられるんですよね。インデックスのサイズを抑えつつ、テーブルアクセスを消せます。「SELECT * をやめると速くなる」と言われる理由の、けっこう大きな部分がこれです。
② 複合インデックスの列順には、法則があります
複数列のインデックス、どの順番で並べていますか。等価条件 → 範囲条件 → ソート。この順が基本です。
SELECT * FROM logs
WHERE tenant_id = 10 -- 等価
AND created_at >= '2026-08-01' -- 範囲
ORDER BY created_at DESC; -- ソート(範囲列と同じ)
この場合、(tenant_id, created_at) の順ですね。等価で絞ってから、範囲の絞り込みとソートを同じ列で受ける形です。
そして、ここがいちばんの「ほぉー」なんですが。範囲条件を使った列より後ろの列は、ソートには使えません。範囲でヒットした行は、後ろの列については順番がバラバラだからなんですよね。
だからもしORDER BYが別の列だったら——たとえば ORDER BY id なら——(tenant_id, created_at, id) と並べても、idのソートには効きません。その場合は「範囲を捨ててソートを取る」(tenant_id, id) という逆転の判断もあり得ます。範囲とソートが別の列のときは、どちらを優先するか選ぶ。列順ひとつで実行計画がまるごと変わる、設計の分かれ目です。
③ ヒットしすぎると、インデックスは使われません
「インデックスを貼ったのに、使われないんですけど」。これ、正しい挙動であることが多いんです。
インデックス経由の読み取りは、1件ずつテーブルを見に行く動きになります。件数が少ないうちは速いんですが、対象が全体の何割にもなると、最初から全部順番に読んだ方が速いんですよね。目安としてよく言われるのが、全体の2〜3割を超えるとフルスキャンが選ばれやすいというライン。つまり「男女」のような、値の種類が少ない列に貼っても効きにくいわけです。
DBや統計情報の状態によって判断は変わるので、EXPLAINで実際の選択を確認するのが確実ですね。
補足:「一意性が高いと無意味」という誤解について
③を読むと、こう思った方がいるかもしれません。「じゃあ逆に、一意性が高い列に貼っても意味がないのでは?」
実は逆で、B-treeインデックスは一意性が高いほど効果的なんです。1回の検索で絞り込める行数が少ないほど、テーブルへのアクセスが減るので。では、なぜ「一意性が高いと無意味」という話を聞くのか。心当たりが3つあります。
理由1:すでにインデックスが存在している
主キーやUNIQUE制約を付けた列には、自動的にインデックスが作られます。なので「一意な列にインデックスを貼るのは無意味」は、正しいんです。ただし意味は「効かないから」ではなく、「もうあるから重複」なんですよね。ここが混ざると、まったく違う話に聞こえてしまいます。
理由2:ビットマップインデックスと混同している
ここは、本当に逆になります。次の章で詳しく書きますね。
理由3:そもそも検索条件に使っていない
どんなに一意でも、WHERE句で使わない列に貼れば無意味です。これは一意性の問題ではないんですよね。
ただし、代償はあります
「効果的」と「無条件で貼るべき」は別なので、そこは正確に。値の種類が多いぶん、インデックスのサイズは大きくなります。更新コストもその分かかりますね。そして範囲検索なら、一意な列でも BETWEEN で広く取れば大量にヒットします。結局は選択率次第なんです。
つまり効くかどうかを決めているのは、一意性そのものではなく「その検索で何行に絞れるか」なんですよね。
補足:ビットマップインデックスだけは、逆です
理由2で触れた話です。ここが混乱の元になっていることが多いかなと思います。
仕組みが、B-treeとまったく違います
ビットマップインデックスは、値ごとにビット列を持ちます。たとえば status 列に「有効・停止・削除」の3種類があるとしますよね。
有効: 1 0 1 1 0 0 1 ...
停止: 0 1 0 0 1 0 0 ...
削除: 0 0 0 0 0 1 0 ...
1行目は有効、2行目は停止、という具合に、行の位置とビットが対応しています。
何が強いのか
複数条件の組み合わせが、ビット演算だけで処理できるんです。「有効 かつ 東京 かつ 法人」のような条件でも、それぞれのビット列をANDするだけ。CPUにとって最も得意な計算ですよね。条件を組み合わせるほど強くなるので、分析系のクエリと相性が良いんです。
なぜ低カーディナリティ向けなのか
値の種類が増えるほど、ビット列の本数が増えます。「性別」なら2本で済みますが、「メールアドレス」なら行数とほぼ同じ本数になってしまう。しかも、そのほとんどが0で埋まった無駄な列です。だから一意性が高い列に貼ると、サイズが膨れ上がって機能しないんですよね。
そして、最大の弱点
更新に非常に弱いんです。ビットマップは複数行の情報をまとめて持っているので、1行を更新するときにその塊ごとロックされます。つまり、同じ塊に含まれる他の行の更新まで待たされる。同時更新が多いシステムでは使えないということですね。だから用途は、更新が少なく、集計が多い分析系のデータベースに限られます。Oracleでよく使われるのは、そういう文脈なんですよね。
紛らわしい名前にも注意です
PostgreSQLには「ビットマップインデックス」という種類はありません。ただしビットマップスキャンという実行方法があります。複数のB-treeインデックスの結果を一時的にビットマップへ変換して、それを組み合わせる仕組みですね。名前は似ていますが、インデックスの種類ではなく、実行時の処理方法です。EXPLAINで「Bitmap Index Scan」と出てきても、ビットマップインデックスが使われているわけではないんです。
整理すると
B-treeは、一意性が高いほど効果的。更新にも強い。ビットマップは、一意性が低いほど効果的。更新には弱い。「どちらのインデックスの話をしているか」で、答えが正反対になる。ここを揃えないと、議論が噛み合わないんですよね。
④ 主キーの選び方が、全インデックスのサイズを決めます
これはMySQLのInnoDBの話ですが、知っておく価値があると思います。InnoDBでは、主キーの順序でデータが物理的に並びます(クラスタ化インデックス)。そして重要なのがここで。セカンダリインデックスは、内部に主キーの値を持っています。行の場所を特定するために、主キーを経由するからですね。つまり主キーが太いと、すべてのセカンダリインデックスが太るということなんです。
もうひとつ、ランダムな値を主キーにすると問題が起きます。物理的な並び順がランダムになるので、挿入のたびにページの途中に割り込むことになる。ページ分割が頻発して、書き込みが遅くなり、断片化も進みます。だから時系列で単調に増える値を主キーにするか、UUIDを使うなら時刻順に並ぶ形式(UUIDv7やULIDなど)を選ぶ、という判断になるんですよね。
⑤ NULLの扱いは、DBによって違います
これは踏むと原因が分からなくて苦しみます。
Oracleでは、単一列のB-treeインデックスにNULLが格納されません。なので WHERE col IS NULL という検索で、そのインデックスが使えないんです。一方、PostgreSQLはNULLもインデックスに入れるので、IS NULL でも使えます。「同じSQLなのに、環境を変えたら遅くなった」の原因が、これだったりするんですよね。
Oracleで NULL を検索したいなら、複合インデックスにして必ず値が入る列を混ぜる、といった工夫が要ります。DBが違えば、インデックスの前提も違う。ここは移行時に効いてきます。
⑥ 条件付きのインデックスで、無駄を削れます
③で「値の種類が少ない列は効きにくい」と書きました。でも、偏りがある場合は話が別なんですよね。たとえば is_deleted フラグ。99%が false で、検索するのはいつも false の行だけ。こういうとき、条件を付けたインデックスが効きます。
-- PostgreSQL
CREATE INDEX idx_active ON users (email) WHERE is_deleted = false;
削除済みの行がインデックスに入らないので、サイズが小さくなり、更新コストも下がります。SQL Serverでは「フィルター選択されたインデックス」という名前で同じことができますね。ただしMySQLにはこの機能がありません。使えるDBが限られる点は注意です。
⑦ 関数を使った検索も、インデックスを効かせられます
「列に関数をかけると、インデックスが効かない」。これはよく知られていますよね。でも、関数をかけた結果そのものにインデックスを貼るという手があるんです。
-- 大文字小文字を無視して検索したい
CREATE INDEX idx_email_lower ON users (LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'taro@example.com';
これで、関数を使った検索でもインデックスが効きます。PostgreSQLやOracleでは以前から使えて、MySQLも8.0から対応しました。日付から年月だけを取り出す検索、JSONの特定のキーでの検索。こういう場面でも使えるんですよね。
⑧ インデックスは、書き込みを確実に遅くします
最後は、忘れられがちな代償の話です。インデックスを1つ追加すると、そのテーブルへのINSERT・UPDATE・DELETEすべてに、更新コストが乗ります。10個インデックスがあれば、1行の挿入で10個を更新することになるんですよね。読み取りを速くした分、書き込みが遅くなる。トレードオフです。
なので定期的に確認したいのが、この2つ。
使われていないインデックスがないか。多くのDBには、インデックスごとの使用回数を記録する仕組みがあります。ゼロのまま放置されているものは、コストだけ払っている状態ですね。
統計情報が古くなっていないか。DBは統計をもとに実行計画を決めるので、実際のデータ分布とズレると、最適でない計画を選びます。「昨日まで速かったのに」の原因が、これだったりするんですよね。
補足:インデックスのリビルド、いつやるべきか
⑧で「使われていないか」「統計が古くないか」を書きましたが、もうひとつ定番の運用があります。リビルド(再構築)ですね。ここ、誤解が多いところなので詳しく書きます。
そもそも、何が起きているのか
インデックスは使っているうちに断片化します。原因は2つあります。
ページ分割。インデックスのページが満杯のときに新しい値が入ると、ページを2つに割って半分ずつ入れ直します。このとき、論理的な順番と物理的な配置がズレるんですよね。
削除による隙間。行を削除しても、ページ自体はすぐに詰められません。中身がスカスカのページが残ります。
どちらも、同じデータを読むのに必要なページ数が増える状態です。
リビルドと再編成は、別物です
多くのDBに2種類の操作があります。
リビルド(再構築)。インデックスを作り直します。断片化は完全に解消されますが、リソースを大量に消費します。ログも大きく増えるので、レプリケーションの遅延を招くこともありますね。
再編成(再構成)。リーフページを並べ替えて詰め直すだけの、軽い操作です。オンラインで実行でき、途中で中断もできます。ただし解消の度合いは限定的なんですよね。
古典的な目安として、断片化30%を境に使い分けるという基準が知られています。5%未満なら何もしない、30%までは再編成、それ以上ならリビルド、という形ですね。ただしこの数値は万能ではなく、ワークロード次第という位置づけに変わってきています。
ここが本当の「ほぉー」です
SQL ServerやOracleなど多くのDBでは、リビルドすると統計情報も一緒に更新されます(PostgreSQLのREINDEXは統計を更新しないので、別途ANALYZEが要ります)。そして多くの場合、速くなった本当の理由は、断片化の解消ではなく統計の更新の方なんですよね。
「リビルドしたら劇的に速くなった」。このとき実際に起きていたのは、古い統計で誤った実行計画を選んでいたのが、正しい計画に戻っただけ——ということがよくあります。だとすると、統計情報の更新だけで済んだということなんです。コストは桁違いに軽いですよね。
なので順番としては、まず統計を更新して様子を見る。それでも遅ければリビルドを検討する。これが効率的かなと思います。
SSDの時代で、前提も変わりました
断片化が重大だったのは、物理的なディスクのヘッドが移動していた時代の話でもあります。飛び飛びの配置を読むと、それだけ待ち時間が発生していたんですよね。SSDではランダムアクセスが高速なので、外部断片化の影響は昔ほど大きくありません。
一方で、ページの中身がスカスカな状態は、いまも効きます。読み込むページ数そのものが増えるので、メモリ効率が落ちるんですよね。「断片化=即リビルド」ではなく、どちらの断片化が起きているかで判断が変わります。
ケース1:大量にDELETEした後
月次バッチで古いデータを大量削除した。
判断:ページに隙間が大量にできている状態ですね。ここは再編成、もしくはリビルドが効きます。
注意点:削除が定期的に発生するなら、そのたびに直すのではなくパーティション化を検討する方が本筋かもしれません。パーティション単位で切り離せば、削除自体が一瞬で終わりますから。
ケース2:昨日まで速かったのに、急に遅くなった
判断:まず疑うのは断片化ではなく、統計情報です。データ量が急増した、分布が偏った。こういうときに実行計画が変わります。
手順:統計を更新して、EXPLAINで実行計画が変わるかを見る。それで解決するなら、リビルドは不要です。
ケース3:ランダムなUUIDを主キーにしている
挿入のたびにページ分割が起きて、すぐ断片化する。
判断:リビルドしても、また同じ速度で断片化します。これは運用で直す問題ではなく、設計の問題なんですよね。④で書いたとおり、時刻順に並ぶ形式の採用を検討する場面かなと思います。対症療法を繰り返すより、原因を消す方が結局早いです。
ケース4:大量データを一括投入する
数百万行のデータを、これから流し込む。
インデックス側の判断:インデックスを付けたまま入れると、1行ごとに全インデックスが更新されます。先に無効化するか削除して、投入後に作り直す方が速いことが多いんですよね。まとめて構築する方が、1行ずつ更新するより効率的なので。作り直している間はそのインデックスが存在しないので、検索が走る環境では慎重に判断してください。
ここからが、見落とされやすい話です。高速に投入する方法として、Oracleではダイレクトパス(直接書き込み)を選ぶことがありますよね。これ、速い理由が「既存の空き領域を一切使わないから」なんです。通常の挿入なら、削除でできた隙間を探して詰めてくれます。ダイレクトパスはその探索を飛ばして、使用済み領域の最後尾より後ろに書き足していく。だから速い。
問題は、その後です。使用済み領域の境界線(ハイウォーターマーク)は、行を削除しても下がりません。そしてフルスキャンは、この境界線まで全部読みます。つまり、実データが減っても読み取り量は減らないんですよね。「削除したのに速くならない」「行数の割にスキャンが重い」。投入と削除を繰り返す運用だと、中身がスカスカのまま膨らみ続けることになります。
対処は、分けて考えます。インデックスは、作り直せば直ります。でもテーブル側は、インデックスのリビルドでは直りません。境界線を下げる操作、つまり領域の縮小や、テーブル自体の作り直しが必要になるんですよね。ここを混同すると、「リビルドしたのに直らない」という状況になります。そして統計情報の更新も忘れずに。データ量が大きく変わっているので、古い統計のままだと実行計画が実態と合わなくなります。
ケース5:そもそも使われていない
判断:リビルドではなく、削除です。使われていないインデックスを綺麗に保っても、書き込みコストを払い続けるだけなんですよね。まず使用状況を確認する。ここが出発点かなと思います。
実行時のロックにも注意です
リビルドは、DBによってロックの挙動が違います。オンラインでの実行に対応しているものもあれば、実行中は読み書きが止まるものもあるんですよね。PostgreSQLのREINDEXのように、通常は排他ロックがかかるけれど、同時実行に対応したオプションが用意されている場合もあります。本番で実行する前に、自分のDBとバージョンでの挙動を必ず確認する。ここは事故に直結するところです。
番外編:まず見るべきは、EXPLAINです
今日の話、ぜんぶEXPLAINで確認できます。どのインデックスが選ばれたか。何行読んだか。テーブルにアクセスしたか、インデックスだけで済んだか。推測で貼ったり消したりする前に、まず実行計画を見る。
「速くなった気がする」から「なぜ速くなったか説明できる」へ。この差は、けっこう大きいと思います。
よくいただくご質問
Q. どれから知っておくといいですか?
A. ②の列順かなと思います。設計の段階で決まってしまうので、後から直すのがいちばん大変なんですよね。
Q. インデックスは、何個までなら貼っていいですか?
A. 数の上限より、使われているかどうかで判断する方が確実です。使用回数を見て、ゼロのものから消していく形ですね。
Q. DBによって違う話が多くて、混乱します…
A. わかります。ただ「違いがある」と知っているだけでも、移行時の事故はかなり防げるんですよね。
Q. 未経験からでも、ここまで分かるようになりますか?
A. なります。仕組み自体はシンプルなので、EXPLAINを見る習慣がつくと一気に見えてきます。
貼るより、消す方が難しいです
今日の8つを並べてみて思ったのが、これでした。インデックスは、貼るのは簡単なんですよね。効果もすぐ出る。でも、貼った後のコストは見えにくい。書き込みが少しずつ遅くなり、サイズが増え、使われないまま残り続ける。
だから設計するときは、「何を速くしたいか」と同じくらい「何を犠牲にするか」を意識できると強いのかなと思います。
みなさんの「これで痛い目を見た」も、よかったら教えてください。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。