フルスキャンとインデックス — 障害 1: マイページの表示に 4 秒かかる
同じ 64 行を取得するのに、なぜ 4 秒と 0.2ms という圧倒的な差が生まれるのか。データページ、B+Tree インデックスの構造から紐解きます。
- わずか 64 行の結果を取得するために、なぜ 3,200 万行ものデータを読み込んでしまうのか
- インデックスを作成すると、内部で何が起きて劇的に高速化するのか
- データ行数がどれほど増えても、インデックスを使うと処理時間が遅くならないのはなぜか
準備: インデックスが存在しない状態にする
まずは、order_items テーブルの customer_id にインデックスが貼られていない初期状態から検証を始めます。すでに idx_customer が存在する場合は削除します。もし存在しない場合は「Can't DROP」というエラーメッセージが表示されますが、「すでに削除済みである」という意味ですので、そのまま次のステップへ進んで問題ありません。
問題のクエリを実行してみる: 4秒の遅延を体感する
マイページの処理で実際に発行されているクエリを実行してみましょう。ボタンを押す前に、処理にかかる時間と返ってくる行数を予測してみてください。
数秒の時間がかかり、読み込んだ行数は 3,200 万行、返ってきた結果は集計値の 1 行だけでした。 実行計画(実測プラン)の最下行を確認すると、Table scan on order_items と表示されているはずです。 これは「order_items テーブルの全データを先頭から末尾まで端から端まですべて読み込んだ(フルテーブルスキャン)」ことを示しています。その上の Filter: (customer_id = 123456) は、「読み込んだ全データの中から、customer_id が 123456 に合致するものだけを残した」という意味です。 顧客 123456 の購入明細は実際には 64 行しか存在しないため、読み込んだ全データの実に 99.9998 % を無駄に読み取ってから捨てていたことになります。
なぜ全件を走査するしかなかったのか
リレーショナルデータベースは、レコードを行単位でバラバラに保存しているわけではありません。ページと呼ばれる 16KB 単位のデータブロック(箱)に複数の行を詰め込んで保存しています。order_items のデータは 1 行あたり約 60 バイトですので、1 つのページに約 250 行が収まります。3,200 万行のテーブル全体では、約 13 万ページに達します。ストレージ(ディスク)からの読み書きは、すべてこのページ単位で行われます。 この規模感は、テーブル本体のデータサイズを 16KB で割ることで実際に確認できます。
pages の値が約 13 万となっているはずです。data_length がテーブル本体のデータ容量(バイト数)であり、これを 16KB(16,384 バイト)で割った値が、フルテーブルスキャン時に読み出さなければならない総ページ数となります。
各ページの中では、データは主キー(id)の昇順で綺麗に並んで保存されています。1ページ目には id 1〜250、2ページ目には id 251〜500、という構造です。 そのため、WHERE id = 12345678 のような主キーによる検索であれば、「id 12345678 が何ページ目に存在するか」を瞬時に計算できるため、その1ページだけをピンポイントで読み出せば完了します(詳細は第5章で解説します)。
しかし、今回の検索条件である customer_id は主キーではありません。顧客 123456 の購入レコードは、id 104、id 77120、id 9930811 のように、13 万ページ全体の中にバラバラに散らばって存在しています。 データベースには「customer_id が 123456 のデータがどのページに含まれているか」を知る手がかりがありません。そのため、すべてのページをストレージから開き、1行ずつ customer_id を突き合わせるしかなかったのです。 これがフルテーブルスキャン(Table scan)であり、その処理コストはテーブルの総行数に正比例して増大します。1年前の 200 行であれば一瞬で終わっていたクエリが、3,200 万行になった今 4 秒もかかるようになった根本原因がここにあります。
データの物理的な並び順(id 順)と、検索したい列(customer_id)が一致していません。このギャップを埋める仕組みこそが、次に作成する「インデックス」です。
インデックスを作成する
「customer_id が 123456 のレコードがどのページにあるか」を瞬時に特定するための専用の索引を作成します。内部構造の仕組みは後ほど詳しく解説しますので、まずはインデックスを作成し、その圧倒的な効果を体感してみましょう。 作成に要する時間にも注目してください。
作成におよそ1分前後の時間がかかったはずです。3,200 万行で1分かかるということは、本番の数十億行規模であれば数時間におよぶ処理になります。その間、適切な手順を踏まなければテーブルへの書き込みが完全にブロックされてしまう恐れがあります。 「サービス稼働中のテーブルに対して、安全にインデックスを追加するにはどうすればよいか」は第8章の重要テーマですので、この作成時間を心に留めておいてください。
インデックスの効果を確認: 再度クエリを実行する
わずかミリ秒(0.2ms)単位で結果が返り、読み込んだ行数も対象の 64 行だけになりました。 実行計画の最下行を確認すると、Index lookup on order_items using idx_customer に変化しています。 これは「idx_customer というインデックスを参照し、該当する行だけをピンポイントで読み取った」という意味です。先ほど存在していた Filter: の行も消えています。読み込んだ行すべてが必要なデータであるため、後から選別して捨てる処理が不要になったからです。 まったく同じ SQL クエリであり、返ってくる結果も同一です。変わったのは、テーブルの横に「customer_id ごとの格納場所を記録した目次」が作られたことだけです。これで障害1のトラブルシューティングは解決しました。
インデックスの内部構造(仕組み)
第一部の基礎編では「書籍の巻末にある索引」という比喩で説明しました。ここでは、その内部構造をより技術的に踏み込んだ 3 つの要素で解き明かします。それが キー、ページ、木(B-tree) です。
キー: 「値 → 格納場所」の対応ペア
CREATE INDEX idx_customer ON order_items (customer_id) を実行すると、全レコードから「customer_id の値」と「その行の主キー(id)」のペアを抽出し、customer_id の昇順で並べ替えて別の領域に保存します。このペアをキー(エントリ)と呼びます。以下のような並び順になります。
customer_id → 主キー(id) 123455 → 8812 123456 → 104 123456 → 77120 123456 → 9930811 123457 → 551 ...
customer_id 順に整列しているため、123456 のデータを探すには「123456 が並んでいるエリア」を見つけ、連続して並んでいる 64 件のエントリだけを読み取れば済みます。 そして、そこに記録されている主キー id を使ってテーブル本体のレコードを取得します。これが Index lookup(インデックス参照)の正体です(本体を取得しにいく二度手間の影響と対策は第5章で詳しく扱います)。
ページ: 16KB の箱に何件のキーが収まるか
インデックスもテーブル本体と同様に、16KB ごとのページに格納されます。インデックスのエントリ 1 つのサイズは、customer_id(4 バイト)+ id(8 バイト)+ 管理情報で約 20 バイト弱です。 16KB ÷ 20 バイト ≒ 1 ページあたり約 800〜1,000 件のキーが入ります。3,200 万件のインデックス全体では、約 3〜4 万ページが必要になります。
しかし、ここで新たな疑問が生じます。「123456 が並んでいるエリアを開く」と言っても、3〜4 万ページあるうちの何ページ目に 123456 が格納されているのかを、どうやって特定するのでしょうか。 いくら整列されていても、1ページ目から順番にめくっていたら、結局また全ページをスキャンすることになってしまいます。
木(B-tree): 目次のための目次
その解決策が「ページを束ねる目次(インデックスのインデックス)を作る」ことです。3 万ページそれぞれの一番先頭にあるキー(1ページ目は 1、2ページ目は 17、3ページ目は 33……)を集めた「目次ページ」を作成します。 目次のエントリも 1 ページに約 1,000 件収まるため、3 万ページの目次は約 30 ページ程度にまとまります。さらにその 30 ページの目次を作ると、わずか 1 ページの中にすべての範囲が収まります。
この階層構造を B-tree(B木) と呼びます。一番上の階層を「根(ルートノード)」、一番下のデータが並ぶ階層を「葉(リーフノード)」と呼びます。顧客 123456 を探索する手順は以下の通りです。
- 根(ルート)の 1 ページを読む → 123456 がどの「中間ページ」に含まれるかがわかる。
- 指定された中間ページを読む → 123456 がどの「葉ページ」に含まれるかがわかる。
- 指定された葉ページを開く → 123456 のエントリ 64 件が一瞬で見つかる。
読み込んだページ数は、わずか 3 枚だけです。 3,200 万件もの膨大なデータが存在していても、たった 3 ページの読み込みで目的のデータにたどり着けます。これが、インデックス検索が桁違いに高速である理由のすべてです。
データが 1,000 倍になっても、探索の手間が 1 段しか増えない理由
1 つのページに約 1,000 件のエントリが収まるため、木の階層(段数)が 1 つ増えるごとに、扱えるデータ件数は 1,000 倍へと跳ね上がります。
| 総データ件数 | 葉(リーフ)ページ数 | 木の段数(深さ) | 検索時に読むページ数 |
|---|---|---|---|
| 1,000 件 | 1 ページ | 1 段 | 1 枚 |
| 100 万件 | 1,000 ページ | 2 段 | 2 枚 |
| 10 億件 | 100 万ページ | 3 段 | 3 枚 |
| 1 兆件 | 10 億ページ | 4 段 | 4 枚 |
テーブルの規模が 3,200 万行から 320 億行(1,000 倍)に爆発的に増大したとしても、インデックス検索で読み出すページ数は 3 枚から 4 枚へと 1 枚増えるだけです。一方、フルテーブルスキャンの場合は読み込むページ数が 13 万枚から 1.3 億枚へと 1,000 倍に膨れ上がります。 「フルスキャンは掛け算で時間が増え、インデックス検索は足し算(わずか1枚ずつ)でしか増えない」。これが、データ量が増えれば増えるほど両者の性能差が圧倒的に開いていく数学的な理由です。
範囲検索が高速に行える理由
インデックスの最下層(葉ページ)はキー順にソートされており、前後の隣接ページへの双方向リンクを持っています。 そのため、WHERE customer_id BETWEEN 100000 AND 101000 のような範囲検索を行う場合でも、B-tree を上から降りて開始点(100000)を見つけた後は、葉ノードを右方向へ順番にスキャンしていくだけで済みます。>、<、BETWEEN、および LIKE 'abc%'(前方一致検索)が高速に処理できるのは、すべてこの整列された構造のおかげです。
インデックスが効かなくなる NG なクエリの書き方
インデックスは「指定された列の値そのもの」の順番で整列されています。決して「列の値を加工した後の結果」で並んでいるわけではありません。 そのため、WHERE 句の左辺で列に関数や計算を適用してしまうと、B-tree を下るための手掛かりが失われ、データベースはインデックスを諦めてフルテーブルスキャンに戻ってしまいます。
-- 【NG】ordered_at の値ではなく YEAR() の計算結果で比較しているためインデックスが使えない WHERE YEAR(ordered_at) = 2026 -- 【OK】ordered_at 列そのものの値の範囲として比較しているためインデックスが有効 WHERE ordered_at >= '2026-01-01' AND ordered_at < '2027-01-01' -- 【NG】customer_id + 0 という計算後の値で比較している WHERE customer_id + 0 = 123456 -- 【NG】前方一致ではなく後方一致(先頭がワイルドカード)だと並び順を活用できない WHERE name LIKE '%洗剤'
テーブルに設定されているインデックスを確認する
2 行の結果が表示されます。実務で特に注視すべきは次の 4 つの列です。
| 列名 | 意味と重要性 |
|---|---|
Key_name | インデックスの名称。PRIMARY は主キーを表します。主キーも(最も強力な)特別なインデックスであることがわかります。 |
Column_name | インデックスの対象となっている列名。 |
Seq_in_index | 複合インデックスにおける列の定義順序(第4章で詳しく学びます)。 |
Cardinality | その列に含まれるユニークな値の種類の推定数。idx_customer なら約50万(ユニークな顧客数)、PRIMARY なら約3,200万(全行で一意)となります。 |
特に Cardinality(カーディナリティ)は、次章以降を読み解く上で決定的に重要な指標です。データベースは「customer_id = X を実行したときに何行がヒットするか」を、3,200 万行 ÷ カーディナリティ(50 万)≒ 約 64 行、という計算で事前に見積もります。 この見積もり結果をベースに、「インデックスを使った方が速いのか、それともフルテーブルスキャンをした方が手っ取り早いのか」を賢く判断しています。もしこの見積もりが狂ってしまうと、オプティマイザの判断も狂ってしまいます。それが、まさに第3章で対峙する次の障害です。
テーブルデータは 16KB のページ単位で主キー順に格納されている。インデックスは「列の値 → 主キー」を整列させた B-tree 構造の目次であり、その探索コストは木の段数と該当行数だけで決まる。
理解度の確認
order_items のデータ量が10倍の 3.2 億行に増加した場合、インデックスなしのクエリとありのクエリの実行時間はそれぞれ何倍になりますか?
ordered_at 列にインデックスが存在する場合、WHERE YEAR(ordered_at) = 2026 という条件でインデックスは有効に機能しますか?
1ページに 1,000 件のエントリが収まると仮定した場合、1 億件のキーを管理する B-tree インデックスは何段になりますか?
次の章へ
この章では、実行計画の一番下にあるアクセス方式(Table scan や Index lookup)に注目しました。第2章では、実行計画全体の構造を読み解く方法を習得します。cost、rows、actual time、loops という4つの指標を読み解けるようになると、どんなに複雑な遅いクエリであっても「どこで処理時間が奪われているのか」を自分自身の手で特定できるようになります。