インデックスと B-⁠tree

回 5-2 道 5 データベースと分散

B-tree は、1 つの頁 (ページ、まとめて読み書きする単位) にたくさんの鍵を小さい順に詰め、頁どうしを木の形につないだ索引の形です。探すときは根の頁から葉の頁まで段を 1 つずつ降りるだけなので、1 頁に鍵が 100 個入るなら、1 億行でも読む頁は 4 枚で済みます。索引が無ければ全部の行を読むことになり、10 万行で最悪 10 万行を読みます。

索引が無いと全部を読むしかない理由と、B-tree が段の数だけ読めば済む理由を、読んだ頁の数で切り分けられるようになります。

10 万行から 1 行を、全部読まずに探せるか

探せます。行を探す列の値を小さい順に並べた別の表 (索引、インデックス) を作っておけば、その並びをたどって目当ての行の場所だけを読めます。データベースの索引で最もよく使われる形が B-tree (ビーツリー、B 木) です。

索引が無いと、データベースは表を先頭から 1 行ずつ読み、条件に合うかを確かめるしかありません。PostgreSQL の公式文書も、索引が無ければ表全体を読み、索引があれば探索木を数段たどるだけで済み得ると書いています。

本の巻末の索引のように、全部の頁をめくらずに目当ての頁へ直接行ける様子と、頁を足すと索引も書き足す様子を描いた図

この回では、全件読みと B-tree で読む数を同じ鍵で数えて比べ、索引が損になる 3 つの場面も確かめます。

B-tree はどう動くのか

B-tree は、鍵を小さい順に詰めた頁を何段かに積み、上の段の頁が「どの鍵はどの下の頁にあるか」を案内する木です。探すときは根の頁から始めて、案内に従って 1 段ずつ降り、葉の頁で目当ての鍵を見つけます。

頁に鍵をたくさん詰める

B-tree の節 (ふし、値を入れる箱) は頁とも呼ばれ、1 つの頁に鍵をいくつも小さい順に入れます。いちばん上の頁を根、いちばん下の頁を葉、その間を内部の頁と呼びます。

1 つの頁から出る枝の数の上限をオーダーと呼び、オーダー m の B-tree の頁は最大 m 本の枝と m − 1 個の鍵を持ちます。頁の中の鍵が、枝の先の頁に入る鍵の範囲を区切ります。

言葉意味
根いちばん上の頁。探すときはここから始める
内部の頁根と葉の間。下の頁への案内だけを持つことが多い
葉いちばん下の頁。行の場所への手がかりを持つ
段の数根から葉までの頁の数。探すときに読む頁の数

探すときは、根の頁の鍵と探す値を比べ、どの枝へ降りるかを決めます。これを葉まで繰り返すので、読む頁の数は段の数と同じです。

頁の中は小さい順に並んでいるので、どの枝へ行くかは頁の中で二分探索をすれば決まります。

道 2 の線形探索と二分探索では、並んだ配列の真ん中を見て候補を半分ずつ捨てました。木と二分探索木では、1 つの節に鍵 1 個、枝 2 本の木を根から降りました。B-tree は、1 つの頁に鍵を何十、何百と詰めて、1 段降りるたびに候補を枝の数の分の 1 に絞ります。二分探索木が「1 回の比べで半分」なら、B-tree は「1 頁を読むたびに 100 分の 1」です。

行を 10 倍にしても段が 1 つ増えない

なぜ頁の単位なのか

ディスクから読むときは、1 個の鍵ではなく決まった大きさのかたまり (ブロック) ごとに読むからです。Bayer と McCreight の 1972 年の論文は、ディスクやドラムに置いた索引で、鍵を探す、入れる、消す手間が log_k I (I は索引の大きさ、k は装置で決まる自然数) に比例する仕組みを示し、その頁の組み方を B-tree と名付けました。

入れるときは分け、消すときは寄せる

B-tree は、どの葉も根から同じ段の数のところにあるように保たれます。そのため、どの鍵を探しても読む頁の数は同じです。

鍵を入れて頁があふれたら、頁を 2 つに分け、真ん中の鍵を親の頁へ上げます。親もあふれたら同じことを根まで繰り返し、根が分かれたときだけ木の背が 1 段伸びます。消して頁が少なくなりすぎたら、隣の頁から鍵を借りるか、隣と 1 つに寄せます。

論文は、頁の使われ方が少なくとも 50% に保たれると書いています。二分探索木のように、入れる順で 1 本の線に片寄ることがありません。

動かして確かめる

1 本目は、0 から 199998 までの偶数 10 万個を鍵にして、1 頁に 100 個ずつ詰めた B-tree を作ります。同じ鍵を、索引の無い全件読み (先頭から 1 行ずつ) と B-tree で探し、全件読みは読んだ行の数、B-tree は読んだ頁の数を出します。

動かす 全件読みと B-tree

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

段 3
頁数 1000 10 1
0 (True, 1) (True, 3)
77776 (True, 38889) (True, 3)
199998 (True, 100000) (True, 3)
77777 (False, 100000) (False, 3)

10 万個の鍵は、葉の段が 1000 頁、その上が 10 頁、根が 1 頁の 3 段になりました。

全件読みは、0 なら 1 行、77776 は 38889 行、最後の 199998 は 100000 行を読み、無い鍵 77777 も 100000 行を読みました。B-tree は、どの鍵でも、無い鍵でも、読んだ頁は 3 枚でした。読む数が鍵の位置によらず段の数で決まることが、B-tree の強みです。

2 本目は、行数と 1 頁の枝の数を変えて、段の数を数えます。b 本ずつ枝分かれする木は、段が 1 つ増えるたびに届く数が b 倍になるので、届く数が行数以上になるまで b を掛けた回数が段の数です。

動かす 枝の数と段の数

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

行数 枝2 枝10 枝100 枝500
1000 10 3 2 2
1000000 20 6 3 3
100000000 27 8 4 3
3

枝が 2 本 (二分探索木と同じ) なら、1 億行で 27 段です。枝が 100 本なら 4 段、500 本なら 3 段で済みます。最後の行の 3 は、1 本目で作った 10 万行、枝 100 本の木の段の数と一致しています。

行数を 1000 倍にしても、枝 100 本の段は 2 から 3 へ 1 つ増えるだけです。段の数は底が枝の数の log で、見積もるときは「行数が枝の数の何乗か」を数えれば足ります。

索引が効かないのはどんなときか

索引は、探す列の値の並びをたどれるときにだけ効きます。並びを使えない探し方、選ぶ行が多い問い合わせ、書き込みの多い表では、索引は効かないか、かえって損になります。

先頭の列を使わない検索

2 つの列をまとめた複合索引は、1 つ目の列の順に並び、同じ値の中で 2 つ目の列の順に並びます。電話帳が姓の順、同じ姓の中で名の順に並ぶのと同じです。

姓、名の順に並んだ電話帳で、姓で引けば直行できるが、名だけで引くと全部をめくる様子を描いた図

MySQL の公式文書は、(col1, col2, col3) の索引で引けるのは、col1、col1 と col2、3 つ全部のように左端から続く組だけだと書いています。PostgreSQL 18 の公式文書は、先頭の列の値の種類がとても少ないときに限り、先頭の値ごとに引き直す「スキップスキャン」を使うことがあると書いています。種類が多ければ、たいていは表を全部読むほうを選びます。

次のコードは、(店, 日) の順に並べた索引で、店 3 だけを探す場合と、日 7 だけを探す場合を比べます。出力は左から、行数、店で引いて読んだ行、日で引くために並びを使わず読んだ行、店ごとに引き直した回数、当たった行の数です。

動かす 先頭の列を使わない検索

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

(10000, 1000, 10000, 10, 10)
(10000, 10, 10000, 1000, 1000)

店が 10 軒なら、店 3 は当たる 1000 行だけを読み、日 7 は店ごとに 10 回引き直せば 10 行が見つかります。店が 1000 軒になると引き直しは 1000 回で、当たる行も 1000 行です。1 万行の中で 1000 回も木を降りるなら、全部を読むのと大きく変わりません。

列に関数をかけた条件も、並びを使えません。PostgreSQL の公式文書は、lower(col1) で比べたいなら lower(col1) そのものに索引を作れば使えると書き、その索引は行を入れるたびに式を計算するので保つ手間が大きいとも書いています。

選ぶ行が多い問い合わせ

選ぶ行が多いと、索引をたどるより表を全部読むほうが速くなります。索引で見つけた行は表のあちこちの頁に散っていて、1 行ごとに別の頁を読むことになるからです。

次のコードは、100 万行、1 頁に 100 行の表で、当たる行の割合を変えて、読む頁の数を比べます。当たる行はどれも別の頁にある、いちばん悪い場合で数えます。最後の 4 行は、1000 行を足すときに書く頁の数で、行を入れる頁 1 枚に、索引ごとの葉の頁 1 枚が足されると数えます。

動かす 全件読みが勝つ境目

python実行可能
出力

▶ 実行ボタンを押すと結果がここに表示されます

実行すると、次の通りに出ます

全件 10000
0.01 100 103 索引
0.1 1000 1003 索引
1 10000 10003 全件
2 20000 20003 全件
10 100000 100003 全件
索引 0 本 1000
索引 1 本 2000
索引 3 本 4000
索引 5 本 6000

表を全部読むと 10000 頁です。当たる行が 0.1% (1000 行) なら索引で 1003 頁と少なく済みますが、1% (1 万行) で 10003 頁になり、全件読みが勝ちます。MySQL の公式文書も、ほとんどの行を読む問い合わせでは、索引をたどるより順に読むほうが速いと書いています。

実際のデータベースは行の置かれ方や統計も見て決めるので、境目は 1% ちょうどではありません。分かるのは、選ぶ行が増えるほど索引の得が消えることです。

書き込みが遅くなる

索引を足すと、行を入れたり直したりするたびに索引も直すので、書き込みが重くなります。上のコードでは、索引が 0 本なら 1000 頁、5 本なら 6000 頁を書きました。

PostgreSQL の公式文書は、索引を作ったら表と合わせ続ける手間がかかるので、ほとんど使われない索引は消すべきだと書いています。

索引は仕事でどう確かめるのか

仕事では、遅い問い合わせに索引が使われたかを実行計画 (データベースが選んだ読み方) で確かめます。

  1. よく絞る列と並べる列を見て、索引を足す候補を決める
  2. CREATE INDEX で作る。PostgreSQL は種類を書かなければ B-tree になり、MySQL もふつうの索引の多くを B-tree に置く
  3. EXPLAIN で、Index Scan などの索引を使う読み方か、Seq Scan (全件読み) かを見る
  4. 行数と 1 頁の鍵の数から段の数を見積もり、遅さの原因が索引の有無か、選ぶ行の多さかを分ける

索引を作って確かめる

CREATE INDEX orders_store_idx
  ON orders (store_id);

EXPLAIN SELECT *
  FROM orders
  WHERE store_id = 3;

PostgreSQL の公式文書の例では、1 万行の表で unique1 < 7000 と多くの行を選ぶと Seq Scan になり、unique1 < 100 に絞ると索引で行の場所を見つけてから表を読む計画に変わります。

関わる実例

並びの鍵を使えず全部の区画に問い合わせた Slack の障害 (2022年2月22日)

2022年2月22日、Slack で多くの人がつながらなくなる障害が起きました。2022年4月26日の事後報告によると、キャッシュ (よく読むデータの写し) の多くが使えなくなり、その読み込みがデータベースに押し寄せました。

負荷の中心は、グループのダイレクトメッセージ (GDM) に入っている人の一覧を引く問い合わせでした。そのデータは利用者ごとに区画 (シャード) を分けて置かれていたので、「ある利用者が入っている会話」は 1 つの区画で引けます。一方、「ある会話に入っている人」は区画の分け方の鍵を使わない問い合わせなので、全部の区画に問い合わせるしかありませんでした。

キャッシュが効かなくなると、ほとんどの利用者が全部の区画に問い合わせ、データベースが耐えられなくなりました。報告によると、この問い合わせは、キャッシュに無かった分だけを読み、複製からも読むように直されました。

B-tree の話ではありませんが、崩れた前提は同じです。並びや分け方の鍵を使わない問い合わせは全部を読み、複合索引の先頭の列を使わない検索と同じ形です。

出典は Slack's Incident on 2-22-22 (Slack Engineering) です。

よくある問い

B-tree と B+tree は違うものですか

B+tree は B-tree の仲間で、行の手がかりを葉にだけ置き、内部の頁には案内の鍵だけを置く形です。1 頁により多くの枝が入り、葉を横につなげば範囲を順に読めます。PostgreSQL の公式文書は、自分の B-tree の索引について、葉の頁が表の行を指し、内部の頁は 1 つ下の段を指し、各段の頁は前後につながっていると書いています。この回のコードも、葉にだけ鍵を置く形で作りました。

ハッシュの索引と B-tree はどう使い分けるのですか

PostgreSQL の公式文書によると、B-tree は並べられる値の「等しい」と「範囲」の両方を扱え、BETWEEN や、先頭を決めた LIKE 'foo%' にも使えます。ハッシュの索引は「等しい」の比べだけを扱います。範囲や並べ替えに使うなら B-tree を選びます。

主キーにも索引を作る必要がありますか

PostgreSQL では要りません。公式文書は、主キーや一意の制約を決めると、その列に一意の索引が自動で作られ、それが制約を守る仕組みになると書いています。

「等しくない」で探すときも索引は効きますか

ほとんど効きません。「3 ではない」は 3 以外の全部が当たりなので、選ぶ行が多い問い合わせになり、上の境目のコードのとおり全件読みのほうが速くなります。

確認クイズ

答えだと思う選択肢を押すと、その場で正誤と理由が開きます。4 問すべてに正しく答えると、この回は修了で、石が緑に変わります (この端末のブラウザに残ります)。間違えた問いは、あとから解き直せます。

1 問目

索引の無い表から、ある値の行を探すとき、データベースはふつうどう読みますか。

2 問目

このコードを動かすと何が出ますか。

1 頁に 50 本の枝

cap, h = 50, 1
while cap < 10**5:
    h += 1
    cap *= 50
print(h)

3 問目

(店, 日) の順に並べた複合索引があります。索引の並びを最も生かせる条件はどれですか。

4 問目

100 万行、1 頁に 100 行の表で、当たる行がどれも別の頁にあるとします。上のコードで、全件読みのほうが少ない頁で済んだのは、当たる行が何 % のときからでしたか。

説明課題

10 万行の表に B-tree の索引を作ると、1 行を探すときに読む数がどれだけ減るかを、「全件読み」「段の数」「1 頁の鍵の数」という言葉を使って、3〜4 文で説明してください。書いたら、下の模範解答を開いて比べてください。

模範解答

自分の説明を書いてから開いて、比べてください。

この回の先の一歩

遅いクエリを直す場面で、行数と 1 頁の鍵の数から段の数を見積もれると、索引を足すべきか、全件読みのほうが速いかを数で話せます。

もっと深く

この回の続きを、ゆめさくのコースの章や道具で深められます。頁の中の確認クイズに答えたあと、手を動かす量を増やしたいときに使ってください。

一次資料

どれも 2026年10月27日に開いて、題と中身を確かめました。

次へ

道 5 データベースと分散の回

  1. 5-1正規化重なりを無くす頁で読む
  2. 5-2インデックスと B-tree索引が速い理由この回
  3. 5-3トランザクションACID、分離の度合い頁で読む
  4. 5-4分けて置くスケーリング、複製、CAP頁で読む

道 5 の頁へ戻るほかの道を見る