InnoDB B+Tree の内部構造を徹底解説:Clustered Index、Page 構造、ノード分割からロックまで

はじめに

この記事では、MySQL InnoDBのB+Treeインデックスの内部実装を深掘りします。

以前の記事(InnoDB B+Treeの仕組みを日本語でわかりやすく解説、記事ID:370)では概要を紹介しました。今回はMySQL 8.0のソースコード(storage/innobase/btr/)を追いながら、以下を解説します:

  • Clustered IndexとSecondary Indexの違い
  • B+Treeのページ(ノード)構造
  • 検索・挿入・削除の内部動作
  • ノード分割とマージ
  • ロック(Latch)戦略

元ネタは公式ドキュメントとソースコードです。

そもそもB+Treeって何?

B+Treeは「大量のデータから目的のレコードを高速に見つける」ためのデータ構造です。

たとえるなら辞書です: – 辞書の「あ行」「か行」…のインデックスタブ = 内部ノード(Internal Node) – 実際の単語が載っているページ = リーフノード(Leaf Node)

B+Treeの特徴: – 全データはリーフノードにある(内部ノードはキーとポインタだけ) – リーフノード同士がリンクリストで繋がっている(範囲検索が高速) – 木の高さが均一(どのキーも同じ回数のI/Oでたどり着ける)

              [Internal Node]
              | 10 | 20 | 30 |
             /    |     |     \
    [Leaf]      [Leaf]   [Leaf]    [Leaf]
    1,3,5,8    10,12,15  20,22,25  30,35,40
      ↔          ↔          ↔
    (リーフ同士が双方向リンクリストで接続)

InnoDBのデフォルトページサイズは16KB。1ページに数百〜数千のキーが入るため、数百万行のテーブルでも木の高さは通常3〜4段で済みます。

Clustered IndexとSecondary Index

InnoDBのインデックスには2種類あります。これがInnoDBの最大の特徴です。

Clustered Index(クラスタードインデックス)

テーブルのデータそのものがB+Treeのリーフノードに格納されます。テーブル = Clustered Indexです。

Clustered Index(PRIMARY KEY = id):
              [id: 10 | 20]
             /              \
    [id:1, name:"Alice", age:25]    [id:10, name:"Bob", age:30]
    [id:3, name:"Carol", age:22]    [id:12, name:"Dave", age:28]
     ↑ リーフに行データ全体が入る
  • PRIMARY KEYがあればそれがClustered Indexになる
  • PRIMARY KEYがなければ、最初のUNIQUE NOT NULLインデックスが使われる
  • どちらもなければ、InnoDBが内部的に6バイトの隠しROW_IDを生成する

Secondary Index(セカンダリインデックス)

Clustered Index以外のインデックスです。リーフノードにはPRIMARY KEYの値が格納されます。

Secondary Index(KEY = name):
              [name: "Carol" | "Dave"]
             /                       \
    [name:"Alice", PK:1]    [name:"Carol", PK:3]
    [name:"Bob",   PK:10]   [name:"Dave",  PK:12]
     ↑ リーフにはPK値だけ(行データはない)

Secondary Indexで検索すると、まずPK値を取得し、次にClustered Indexを引いて行データを取得します。この2段階の検索をブックマークルックアップ(または「テーブルに戻る」)と呼びます。

PostgreSQLとの違い

PostgreSQLにはClustered Indexの概念がありません。テーブル(Heap)とインデックスは完全に分離しています。

InnoDB PostgreSQL
テーブル構造 Clustered Index(B+Tree) Heap(順序なし)
セカンダリインデックスのリーフ PK値 ctid(物理位置)
テーブルへの戻り PK→Clustered Index検索 ctid→Heap直接アクセス

PostgreSQLのctid(物理位置)による直接アクセスはInnoDBのPK検索より高速ですが、VACUUMでctidが変わるとインデックスの更新が必要になるトレードオフがあります。

ページ(ノード)の内部構造

B+Treeの各ノードは1つのページ(16KB)です。ページの内部構造を見てみましょう。

ページレイアウト

  • FIL Header (38B) ファイル管理情報
  • Page Header (56B) ページ管理情報
  • Infimum Record 最小の仮想レコード
  • Supremum Record 最大の仮想レコード
  • User Records 実際のレコード(↓方向に成長)
  • Record 1 → Record 2 → Record 3 → …
  • (Free Space)
  • Page Directory 検索用スロット(↑方向に成長)
  • FIL Trailer (8B) チェックサム

重要なポイント:

  • Infimum / Supremum:ページ内の最小・最大を表す仮想レコード。レコードの単方向リンクリストの始点と終点
  • User Records:キー順にリンクリストで繋がっている(物理的な並び順ではない)
  • Page Directory:ページ内の二分探索用。4〜8レコードごとに1つのスロットを持つ
  • Free Space:User Recordsは上から下へ、Page Directoryは下から上へ成長し、中央で出会う

レコードの検索(ページ内)

ページ内のレコード検索は2段階です:

1. Page Directoryで二分探索 → 大まかな位置を特定
2. レコードのリンクリストを線形探索 → 正確なレコードを特定

Page Directoryのスロットは4〜8レコードごとなので、線形探索は最大8レコード分で済みます。

B+Treeの検索(ルートからリーフまで)

/* btr_cur_search_to_nth_level の簡略版 */
page = root_page;
for (level = tree_height; level > 0; level--) {
    /* ページ内でキーを検索 */
    slot = page_directory_binary_search(page, key);
    record = linear_search_from_slot(slot, key);
    
    /* 内部ノード: レコードが指す子ページへ移動 */
    child_page_no = record->child_page_no;
    page = buf_page_get(child_page_no);  // Buffer Poolから取得
}
/* level == 0: リーフノードに到達 */

挿入とノード分割

通常の挿入

リーフページに空きがあれば、キー順の正しい位置にレコードを挿入するだけです。

挿入前: [1] → [3] → [5] → [8]
INSERT key=4
挿入後: [1] → [3] → [4] → [5] → [8]

物理的にはページ内のFree Space領域にレコードを書き、リンクリストのポインタを更新します。

ページ分割(Page Split)

ページが満杯のとき、新しいページを確保して分割します。

分割前:
  Page A: [1, 3, 5, 8, 10, 12, 15]  ← 満杯

INSERT key=7

分割後:
  Page A: [1, 3, 5, 7, 8]
  Page B: [10, 12, 15]        ← 新しいページ
  親ノード: [..., 10, ...]    ← 分割点のキーを親に挿入

InnoDBの分割ポイントは単純な中央分割ではありません。挿入パターンを考慮した最適化があります:

  • 順次挿入(AUTO_INCREMENT等):最後のレコードの直後で分割(新ページにほぼ空きが残る)
  • ランダム挿入:ページの中央付近で分割
/* btr_page_get_split_rec の簡略版 */
if (insert_point == page_get_supremum_rec(page)) {
    /* 末尾への挿入 → 挿入レコード自体を分割点にする */
    split_rec = insert_rec;
} else {
    /* それ以外 → ページの中央付近で分割 */
    split_rec = page_get_middle_rec(page);
}

分割のコスト

ページ分割は以下の操作を伴うため、コストが高いです:

  1. 新ページの確保
  2. レコードの移動
  3. 親ノードへのキー挿入(親も満杯なら連鎖分割)
  4. リーフのリンクリスト更新
  5. これら全体が1つのMTR(Mini-Transaction)としてアトミックに実行

削除とマージ

レコードの削除

InnoDBの削除は2段階です:

1. DELETE Mark: レコードに削除フラグを立てる(論理削除)
   → この時点ではレコードはページに残っている(MVCC用)

2. Purge: 不要になったレコードを物理的に削除
   → バックグラウンドのPurgeスレッドが実行

ページマージ

Purge後にページの使用率が低くなると、隣接ページとのマージが検討されます。

マージ前:
  Page A: [1, 3]          ← 使用率が低い
  Page B: [10, 12, 15]

マージ後:
  Page A: [1, 3, 10, 12, 15]  ← Page Bの内容を吸収
  Page B: (解放)
  親ノード: キー10を削除

マージの閾値はMERGE_THRESHOLD(デフォルト50%)で制御できます:

-- テーブル単位で設定
CREATE TABLE t1 (...) COMMENT='MERGE_THRESHOLD=40';

-- インデックス単位で設定
CREATE INDEX idx1 ON t1(col1) COMMENT 'MERGE_THRESHOLD=30';

ロック(Latch)戦略

B+Treeは複数スレッドが同時にアクセスします。ページの分割や削除が起きている最中に別のスレッドが検索すると、不整合が起きます。InnoDBはどうやって安全性と並列性を両立しているのでしょうか?

基本戦略:楽観的ロック

InnoDBはまず楽観的(Optimistic)にロックを取ります:

楽観的な読み取り:
  1. ルートからリーフまでS-latch(共有ロック)で降りる
  2. 各ページのS-latchは次のページに移る前に解放
  → 同時に複数スレッドが読み取り可能

楽観的な書き込み:
  1. ルートからリーフまでS-latchで降りる
  2. リーフページだけX-latch(排他ロック)に昇格
  → ページ分割が不要な場合はこれで十分

悲観的ロック(ページ分割が必要な場合)

楽観的な書き込みでページが満杯だった場合、悲観的(Pessimistic)にやり直します:

悲観的な書き込み:
  1. B+Treeのインデックス全体にSX-latch(準排他ロック)
  2. ルートからリーフまで降りながら、分割に関わるページにX-latch
  3. ページ分割を実行
  4. ロック解放

SX-latch(MySQL 5.7〜)は「読み取りは許可するが、他の書き込みはブロックする」ロックです。これにより、ページ分割中でも検索は継続できます。

ロックの種類

ロック 用途 他スレッドの読み取り 他スレッドの書き込み
S-latch 検索 ✅ 許可 ❌ ブロック
X-latch ページ変更 ❌ ブロック ❌ ブロック
SX-latch ツリー構造変更 ✅ 許可 ❌ ブロック

まとめ

トピック ポイント
テーブル構造 テーブル自体がClustered Index(B+Tree)
Secondary Index リーフにPK値を格納。検索時はClustered Indexへの2段階アクセス
ページ構造 16KB。レコードはリンクリスト、Page Directoryで二分探索
ノード分割 挿入パターンに応じた分割点の最適化
削除 2段階(DELETE Mark → Purge)。使用率低下でマージ
ロック 楽観的→悲観的のエスカレーション。SX-latchで読み取りと構造変更を両立

次回はPostgreSQLのB-tree / GiST / GINを同じ切り口で解説し、InnoDBとの設計思想の違いを比較していきます。

参考文献

コメントする