SQL Lab
第二部 パフォーマンス編 · 第 1 章 · 約 25

フルスキャンとインデックス — 障害 1: マイページが 4 秒かかる

同じ 64 行を返すのに 4 秒と 0.2ms。ページ・キー・木の順で、インデックスの中身を開ける。

この章で答える問い
  1. なぜ 64 行を返すのに 3,200 万行を読むのか
  2. 索引を張ると、何が起きて速くなるのか
  3. 索引の中身はどうなっていて、なぜ行数が増えても遅くならないのか

準備: 索引のない状態にする

order_items の customer_id に索引がない状態から始める。すでに idx_customer があれば落とす。なければ「Can't DROP」のエラーになるが、それは「もうない」という意味なので次へ進んでいい。

idx_customer を落とす
存在しない場合は Can't DROP のエラー。そのまま次へ。

実物: 障害のクエリ

マイページのクエリそのものを叩く。実行時間と返る行数を予測してから。

顧客 123456 の購入明細(索引なし)
見る場所: 実行時間、読んだ行数、実測プランの一番下の行
実行する前に予測する

数秒かかり、読んだ行数は 3,200 万、返した行数は 1(集計結果)。実測プランの一番下の行は Table scan on order_items。 「order_items を端から端まで読んだ」という意味だ。その上の Filter: (customer_id = 123456) は「読んだ行のうち、customer_id が 123456 のものだけ残した」。 顧客 123456 の明細は 64 行しかないので、読んだ行の 99.9998 % を捨てている。

なぜ全部読むしかなかったのか

DB は行を 1 行ずつバラバラに保存しているのではなく、ページという 16KB の箱に詰めて保存している。 order_items の 1 行は 60 バイトほどなので、1 ページに 250 行前後入る。3,200 万行なら約 13 万ページ。ディスクからの読み書きは、このページ単位で行われる。 この見積もりが合っているかは、テーブル本体のバイト数を 16KB で割れば確かめられる。

order_items は何ページか
見る場所: pages 列
期待: 約 13 万

pages は約 13 万のはずだ。data_length が本体のバイト数で、これを 16KB(16,384 バイト)で割った数が、Table scan で開けるページの数になる。

ページの中で、行は主キー(id)の順に並んでいる。1 ページ目に id 1〜250、2 ページ目に id 251〜500、という具合だ。 だから WHERE id = 12345678 なら「id 12345678 は何ページ目か」を計算して、そのページだけ読めばいい(第 5 章)。

ところが customer_id は主キーではない。customer_id = 123456 の行は、id 104 と id 77120 と id 9930811 のように、13 万ページの中に散らばっている。 「customer_id が 123456 の行はどのページにあるか」を知る手段がない。だから全ページを開けて、1 行ずつ customer_id を確かめるしかない。 これが Table scan で、コストはテーブルの行数に比例する。1 年前の 200 行なら一瞬だった理由も、今 4 秒かかる理由も、これだ。

ページ 1id 1〜250id 104ページ 2id 251〜500ページ 3id 501〜750数百ページ目id 77001〜id 77120数万ページ目id 9930751〜id 9930811約 13 万ページ目端から端まで読む開けたページ: 全部(約 13 万)。読んだ行: 3,200 万残った行: customer_id = 123456 の 64 行(このうち 3 行を描いた)customer_id の値からページを知る手段がない → 全ページを開けて 1 行ずつ確かめる = Table scan
本体の行は id 順にページへ詰まっているが、customer_id = 123456 の行は散らばっていて、どのページにあるか知る手段がない。

本体の並び順(id)と探したい列(customer_id)が食い違っている。これを埋めるのが次の索引だ。

索引を張る

「customer_id が 123456 の行はどこにあるか」を知る手段を作る。それが索引だ。中身の仕組みは後で開けるので、まず作って効果を見る。 作るのにかかる時間も記録しておく。

customer_id に索引を作る
見る場所: 実行時間(構築コスト)
1〜2 分かかる。その間 DB は CPU とディスクを使い切る。

1 分前後かかったはずだ。3,200 万行で 1 分なら、本番の数十億行では数時間になる。その間、素朴にやるとテーブルへの書き込みが止まる。 「稼働中のテーブルにどうやって索引を足すか」は第 8 章の主題で、今の 1 分を覚えておくと腹落ちする。

同じクエリをもう一度

顧客 123456 の購入明細(索引あり)
見る場所: 実行時間、読んだ行数、実測プランの一番下の行
実行する前に予測する

ミリ秒で返り、読んだ行数は 64。実測プランの一番下は Index lookup on order_items using idx_customer。 「idx_customer という索引を引いて、該当行だけ読んだ」。Filter の行も消えている。読んだ行がすべて正解なので、選別する必要がなかった。 同じ SQL、同じ結果。変わったのは、テーブルの横に「customer_id → 行の場所」の一覧ができたことだけだ。障害 1 はこれで閉じる。

索引の中身を開ける

第一部 8 章では「辞書の巻末索引」という比喩で止めた。ここでは中身を 3 つの言葉で開ける: キーページ

キー: 「値 → 行の場所」の組

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 万ページのうち何ページ目に 123456 があるかは、どうやって知るのか。 並んでいるとはいえ、端から開けていたらまた全部読むことになる。

木: 目次の目次

答えは「ページの目次を作る」だ。3 万ページそれぞれの先頭のキー(1 ページ目は 1、2 ページ目は 17、3 ページ目は 33、…)を並べた目次ページを作る。 目次のエントリも 1 ページに約 1,000 個入るので、3 万ページの目次は約 30 ページ。さらにその 30 ページの目次を作ると、1 ページに収まる。

この構造が B-tree で、いちばん上を根(root)、いちばん下を葉(leaf)と呼ぶ。123456 を探す手順はこうなる。

  1. 根のページを読む。123456 を含む中間ページがどれかわかる。
  2. その中間ページを読む。123456 を含む葉ページがどれかわかる。
  3. その葉ページを読む。123456 のエントリが 64 個見つかる。

読んだページは 3 枚。3,200 万キーあっても、3 枚で目的の場所に着く。これが索引検索が速い理由のすべてだ。

9.6 万11.2 万12.8 万14.4 万16 万根(1 ページ)中間9.6 万〜11.2 万〜12.8 万〜14.4 万〜16 万〜123456 → 104, 77120, 9930811 …(64 個)図は縮めてある。実際は中間が約 30 ページ、葉が約 3 万ページ(本文の見積もり)
123456 を探すのに開くページは、根 → 中間 → 葉 の 3 枚だけ。

なぜ「1,000 倍になっても 1 段しか増えない」のか

1 ページに約 1,000 エントリ入るので、段が 1 つ増えるごとに扱えるキーの数が 1,000 倍になる。

キーの数葉ページ段数検索で読むページ
1,000111
100 万1,00022
10 億100 万33
1 兆10 億44

テーブルが 3,200 万行から 320 億行に(1,000 倍に)育っても、検索で読むページは 3 枚から 4 枚になるだけ。一方 Table scan は 13 万ページから 1.3 億ページになる。 「片方は足し算で増え、もう片方は掛け算で増える」のが、行数が育つほど差が開く理由だ。

3,200 万行Table scan: 約 13 万ページ索引: 3 枚(根・中間・葉)320 億行(× 1,000)Table scan: 約 1.3 億ページ(× 1,000)索引: 4 枚(+1)掛け算: 行数に比例足し算: 段数が 1 増えるだけ
行数が 1,000 倍になると、Table scan が開くページは 1,000 倍に、索引が開くページは 1 枚増えるだけ。

範囲検索も速い理由

葉ページはキー順に並んでいて、隣のページへのリンクを持っている。だから WHERE customer_id BETWEEN 100000 AND 101000 のような範囲検索は、 木を降りて 100000 の位置に着いたあと、葉を右へ辿っていくだけで済む。><BETWEENLIKE 'abc%'(前方一致)はこの仕組みで速い。

索引が使えない書き方

索引は「列の値」の順に並んでいる。列の値を加工した結果では並んでいない。だから WHERE の左辺に関数や計算を書くと、木を降りる手がかりがなくなり、Table scan に戻る。

-- 効かない: ordered_at の値ではなく YEAR(ordered_at) の値で比べている
WHERE YEAR(ordered_at) = 2026

-- 効く: ordered_at の値そのものの範囲
WHERE ordered_at >= '2026-01-01' AND ordered_at < '2027-01-01'

-- 効かない: customer_id + 0 という別の値で比べている
WHERE customer_id + 0 = 123456

-- 効かない: 前方が不定だと並び順を使えない
WHERE name LIKE '%洗剤'
関数を掛けた条件
見る場所: アクセス方法が Table scan に戻る
フルスキャン。数秒。
実行する前に予測する

索引の一覧を見る

order_items の索引一覧
見る場所: 結果タブ。Key_name、Column_name、Cardinality

2 行出る。読むべき列はこの 4 つ。

意味
Key_name索引の名前。PRIMARY は主キー。主キーも(特別な)索引であることがわかる。
Column_nameどの列で並べているか。
Seq_in_index複数列の索引での列の順番(第 4 章)。
Cardinalityその列に何種類の値があるかの推定。idx_customer なら 50 万前後(顧客数)、PRIMARY なら 3,200 万(全部違う)。

Cardinality は次章以降で重要になる。DB は「customer_id = X で何行ヒットするか」を、3,200 万 ÷ Cardinality(50 万)≒ 64 行、と見積もる。 この見積もりを使って「索引を使うべきか、Table scan のほうが速いか」を判断する。見積もりが外れると判断も外れる。それが第 3 章の障害だ。

テーブルは 16KB のページに主キー順で置かれている。索引は「列の値 → 主キー」を値の順に並べ、目次の目次(B-tree)を作ったもので、検索コストは木の段数 + ヒット行数。

確認

order_items が 10 倍の 3.2 億行になったとき、索引なしのクエリとありのクエリはそれぞれ何倍遅くなるか。
なしは約 10 倍(読むページが 10 倍)。ありはほぼ変わらない(段数は 3 のまま、ヒット行数も同じ)。差はさらに 10 倍開く。
ordered_at に索引がある。WHERE YEAR(ordered_at) = 2026 は効くか。
効かない。索引は ordered_at の値の順で並んでいて、YEAR(ordered_at) の値では並んでいない。範囲条件に書き換える。
1 ページに 1,000 エントリ入るとして、1 億キーの索引は何段か。
葉が 10 万ページ、その目次が 100 ページ、その目次が 1 ページ。3 段。検索で読むのは 3 枚 + ヒット行の本体。

次の章へ

この章では実行計画の「一番下の行」だけ見た。次の章で、計画全体の読み方を覚える。cost、rows、actual time、loops の 4 つの数字が読めると、遅いクエリのどこで時間が消えたかが自分でわかる。