B-treeとファイルシステムインデックス構造
巨大ディレクトリでもファイル検索が一瞬で終わる理由を、ext4のHTreeとXFS/BtrfsのB+Tree、ディスク向けノード設計の原理から正確に理解できます。
- 1.ファイルシステムは線形走査を避けるため、ディレクトリ・空き領域・エクステントをB-tree/B+Tree系の多分木で索引する。
- 2.ext4はディレクトリ名のハッシュをキーにしたHTreeを、XFS/BtrfsはキーでソートしたB+Treeを使い、いずれも探索をO(log n)に抑える。
- 3.ノードを1ブロック(4KiB等)に揃え高い分岐数を取ることで木を浅くし、ディスクI/O回数を最小化する設計が核心。
なぜ二分木でなくB-treeなのか
メモリ内の探索なら二分探索木(BST)で十分ですが、ファイルシステムのインデックスは ディスク上 に置かれます。ここで支配的なコストはCPU比較ではなく ブロックI/Oの回数 です。HDDのシーク、SSDでも1ブロック読み出しのレイテンシは、メモリアクセスより桁違いに大きい。木の高さがそのままI/O回数になるため、いかに木を浅くするかが設計の中心になります。
二分木は要素数 n に対し高さが log2(n)、つまり分岐数が2しかありません。これに対し B-tree は1ノードに多数のキーと子ポインタを詰め、分岐数(fanout)を数百に上げます。分岐数を b とすると高さは log_b(n) に縮み、I/O回数が劇的に減ります。
二分木 (fanout 2) B+Tree (fanout 500)
高さ ≈ log2(n) 高さ ≈ log500(n)
n=10^9 → 約30段 n=10^9 → 約4段
→ 30回のブロック読み → 4回のブロック読み
要点は、ノードのサイズを ディスクブロック(典型的に4KiB)にぴったり合わせる ことです。どうせ1ブロックを読むなら、その1ブロックに入るだけのキーを詰めて一度に多くの分岐を得る方が得です。これがB-tree系がストレージ向け索引の標準である理由です。
B-treeは内部ノードにもデータ(値)を持ちますが、B+Tree はキーと値の本体を 葉だけ に置き、内部ノードは経路案内のキーとポインタだけを持ちます。内部ノードが軽くなるぶん分岐数が上がり木が浅くなり、さらに葉同士をリンクして範囲走査(range scan)を高速化できます。多くのファイルシステムが採用するのはこのB+Tree系です。
ext4のHTree:ハッシュ化したディレクトリ索引
ext4 以前の伝統的なUNIXディレクトリは、エントリ(ファイル名とinode番号の対)を 線形リスト で持ちました。open("dir/file") のたびに先頭から名前を1つずつ比較するため、エントリ数 n に対し探索は O(n)。数万ファイルを置くと1回の検索が重くなります。
ext4 は HTree(Hashed Tree) でこれを解決します。ファイル名そのものをキーにすると長さがばらつき木が偏るため、名前を ハッシュ値 に変換し、その固定長ハッシュをキーにした浅い木で索引します。
ディレクトリブロック構造(HTree)
ルートブロック : [ハッシュ範囲 → 子ブロック] のインデックス
│
▼ 名前 "report.txt" を hash() でハッシュ化
リーフブロック : 実際の dirent(名前 + inode番号)が線形に並ぶ
検索はファイル名をハッシュし、ルートでそのハッシュが属する範囲の子をたどり、葉ブロック内だけを線形走査します。HTreeは互換性のため 木の深さを1〜2段に固定 しており、最初のブロックは旧来の線形ディレクトリと同じ形式(先頭エントリがインデックスへのフラグを兼ねる)を装うため、HTree非対応の古いツールからも壊れずに見えます。
HTreeはキーがハッシュ値なので「名前順に並んでいる」わけではありません。完全一致の検索は速い一方、readdir で名前順に列挙したい場合はハッシュ順とのギャップが生じます。ハッシュ衝突時は同一ハッシュのエントリを葉内で線形に探すため、衝突が偏ると劣化し得ます。これがキー順ソートを保つB+Tree(XFS/Btrfs)との設計思想の分かれ目です。
XFSのB+Tree:空き領域・inode・ディレクトリを索引
XFS はほぼすべての管理構造を B+Tree で持つ徹底ぶりが特徴です。ボリュームは Allocation Group(AG) に分割され、各AGが独立したB+Tree群を持ちます。
| 対象 | キー | 用途 |
|---|---|---|
| 空きエクステント(2本) | 開始ブロック順 / サイズ順 | 隣接結合の判定 と 必要サイズの最適探索を両立 |
| inode管理(inobt) | inode番号 | 動的に増えるinodeの所在を索引 |
| ディレクトリ(大規模時) | 名前のハッシュ | エントリ数が増えると専用B+Treeへ昇格 |
空き領域を 開始ブロック順とサイズ順の2本 で持つのが巧妙です。ファイル削除でできた空きを隣接する空きと結合するには開始位置順が、「k ブロック以上の連続空きを探す」最適合わせには大きさ順が効きます。同じ集合を異なるキーで二重に索引することで、両方の操作を O(log n) にしています。
XFS のディレクトリはサイズに応じて形態が 段階的に昇格 します。小さければinode内インライン、中規模は単一ブロックの線形、大きくなると名前ハッシュをキーにしたB+Tree索引付きの形式へ移行します。データ量に対して常に適切なコストを保つ設計です。
BtrfsのB-tree:すべてをひとつの木で表す
Btrfs はさらに抽象化を進め、ファイルシステム全体を コピーオンライト型のB-tree 群で表現します。ファイルツリー、エクステントツリー、チェックサムツリーなど役割別の木があり、それらを上位のルートツリーが束ねます。詳細は CoW型ファイルシステム を参照してください。
Btrfsの妙は キーの汎用設計 にあります。木のキーは (objectid, type, offset) の3要素タプルで、これ1つでファイルのメタデータ、ディレクトリエントリ、エクステント情報を統一的に表します。
Btrfsのキー = (objectid, type, offset)
例: inode item → (inode番号, INODE_ITEM, 0)
ディレクトリエントリ → (親inode, DIR_ITEM, 名前のハッシュ)
ファイルのエクステント → (inode番号, EXTENT_DATA, ファイル内オフセット)
このキーは ソート順 に並ぶため、あるinodeに属する全項目(メタデータ→ディレクトリ→データ)がB-tree上で物理的に近接します。1回の範囲走査でファイルの関連情報をまとめて取得できます。ディレクトリは、名前ハッシュをキーにした DIR_ITEM(名前→inodeの一致検索用)と、連番をキーにした DIR_INDEX(readdir の順次列挙用)の2種類の項目で索引され、用途に応じて木を走査します。ext4のHTreeが「ディレクトリ専用の索引」だったのに対し、Btrfsは「索引の仕組みそのものを全構造で共有」しています。
エクステントの索引:範囲をどう木に載せるか
ファイルの中身は エクステント(連続ブロックを「論理オフセット+長さ+物理位置」で表す範囲)で管理されます。1ファイルが多数のエクステントに断片化したとき、論理オフセットから物理位置を引く写像を高速化するのも、やはり木です。
ext4 は inode 内に エクステントツリー を埋め込みます。inodeには4エントリ分の領域があり、断片化が少なければ木を作らずここに収まります。足りなくなると、葉に実エクステント、内部ノードにインデックスを置くB+Tree状の木へ拡張されます。
エクステントツリーのキー = ファイル内の論理ブロック番号
内部ノード: [logical 0 →子A] [logical 10000 →子B] ...
│
▼ 探したい論理ブロックが属する範囲へ降りる
葉ノード: [logical 0, len 1000, phys 50000]
[logical 1000,len 500, phys 80000]
キーが 論理オフセット順にソート されているため、mmap やシーケンシャル読みで「論理ブロック x はどこか」を O(log n) で引け、さらに葉が連続するので前方読みのプリフェッチも効きます。XFSも同様にエクステントマップをB+Treeで持ち、断片化したファイルでも写像コストを対数に抑えます。エクステント方式の基礎は ジャーナリングファイルシステム でも触れています。
ディスク向けノードサイズ設計
最後に、なぜノードを特定サイズに揃えるのかを定量的に押さえます。1ノードに入るキー数は次で決まります。
fanout ≈ ノードサイズ / (キー長 + ポインタ長)
例: 4KiB ノード, キー16B + ポインタ8B = 24B
→ 約 170 分岐
高さ h で表せる葉数 ≈ fanout^h
170^3 ≈ 490万, 170^4 ≈ 8.4億
分岐数を上げれば木は浅くなりますが、ノードを大きくしすぎると1回のI/Oで読む無駄が増え、ノード内の二分探索コストや、更新時に書き戻すブロックも大きくなります。そこで ノードサイズ=ファイルシステムブロックサイズ(または整数倍) に合わせ、I/O単位とノードを一致させるのが定石です。
B-treeは各ノードを「最大の半分以上は埋める」不変条件で運用します。挿入でノードが満杯になれば中央キーを親へ押し上げて 分割(split)、削除で半分を割れば隣と 結合(merge)または再分配します。この規則により木は常にバランスし、最悪でも高さが O(log n) に保たれ、ディスクの利用率(ノードの充填率)も下限が保証されます。
「なぜディスク索引はB-tree/B+Treeなのか」への正答は 木の高さ=I/O回数を最小化するため、分岐数を上げて木を浅くする です。二分木との比較、B+Treeが葉にデータを集約して範囲走査と分岐数を稼ぐ点、ノードをブロックサイズに合わせる点をセットで押さえると説明が締まります。
まとめ
- ファイルシステムの索引は I/O回数=木の高さ を最小化するため、分岐数の高いB-tree/B+Treeを使い探索を
O(log n)に抑える。 - ext4 は HTree(名前ハッシュをキーにした浅い木)でディレクトリを索引し、XFS/Btrfs は キー順ソートのB+Tree/B-tree で空き領域・inode・ディレクトリ・エクステントを統一的に索引する。
- エクステント は論理オフセットをキーにした木で物理位置へ写像し、断片化しても対数コストを保つ。
- ノードを ブロックサイズに揃えて 分岐数を最大化し、分割・結合の不変条件で木の高さと充填率を保証するのが、ディスク向けノード設計の核心。
- 関連構造は CoW型ファイルシステム と ジャーナリングファイルシステム、基礎は ファイルシステム も参照。
OS Article
B-treeとファイルシステムインデックス構造を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
ファイルシステム
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
ext4はディレクトリ名のハッシュをキーにしたHTreeを、XFS/BtrfsはキーでソートしたB+Treeを使い、いずれも探索をO(log n)に抑える。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「ファイルシステム / B-tree」に近いか確認する。
- 強みである「ファイルシステムは線形走査を避けるため、ディレクトリ・空き領域・エクステントをB-tree/B+Tree系の多分木で索引する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。