資料庫索引的答案不是「最快的搜尋演算法」,而是「最少磁碟 I/O 的資料結構」。

為什麼不用 BST 或 Red-Black Tree?

記憶體裡的操作快到可以忽略差距,但磁碟 I/O 慢了 100,000 倍以上。

一棵 Red-Black Tree 存 1 億筆資料,樹高 ≈ 2 log₂(10⁸) ≈ 54。查一筆資料要 54 次磁碟 I/O。

B-Tree 讓每個節點存 幾百個 key,樹高只有 3–4 層——同樣 1 億筆資料,3–4 次磁碟 I/O 就找到了。

BST/Red-Black Tree:
每個節點 1 個 key → 樹高 ~54 → 54 次磁碟 I/O
 
B-Tree(t=500):
每個節點 ~1000 個 key → 樹高 ~3 → 3 次磁碟 I/O
 
差了 18 倍的磁碟存取次數。

B-Tree 結構

每個節點存多個 key,帶多個子節點(子節點數 = key 數 + 1)。

          [30, 70]
         /    |    \
   [10,20]  [40,50,60]  [80,90]
 
t = 2(minimum degree):
  - 非根節點至少 t-1 = 1 個 key
  - 非根節點最多 2t-1 = 3 個 key
  - 所有葉節點在同一層(完美平衡)

實務上 t 選擇讓節點大小 ≈ 磁碟 block(通常 4KB),一個節點能放幾百個 key。

搜尋:節點內線性掃,節點間往下跳

搜尋跟二元樹很像,只是每一層不再是「左 or 右」二選一,而是在節點的一排 key 裡找對應區間:

[30, 70]  找 50
  ↓ 30 < 50 < 70 → 落在中間那段,進中間子節點
[40, 50, 60]  找到 50 ✓

在節點內找「第一個 ≥ 目標」的 key:命中就回傳、落到葉節點還沒中就是不存在、否則遞迴進對應子節點。節點內那排 key 通常直接線性掃就好——反正一個節點只有幾百個 key、又已經讀進記憶體,記憶體裡掃幾百次的成本跟一次磁碟 I/O 比起來根本是零頭。真正貴的是「往下走一層 = 一次磁碟 I/O」,所以樹高才是效能的命脈。

插入:先預防再插入

往下走時遇到滿節點(有 2t-1 個 key)就先分裂:把中間 key 推到父節點,節點一分為二。

滿節點 [10, 20, 30] 分裂(t=2):
 
        20        ← 中間 key 升上去
       /  \
    [10]  [30]

這樣保證插入的目標節點永遠有空間,不需要分裂完再回頭修復。

寫成 code 就是一路往下遞迴、下探前先看子節點滿沒滿:

// 前提:node 目前保證未滿,往下走前先把「即將進入的滿子節點」分裂掉
private void insertNonFull(Node node, int key) {
    int i = node.n - 1;
    if (node.leaf) {                                 // 到葉節點,直接插
        while (i >= 0 && key < node.keys[i]) {
            node.keys[i + 1] = node.keys[i--];       // 後移騰位
        }
        node.keys[i + 1] = key;
        node.n++;
    } else {
        while (i >= 0 && key < node.keys[i]) i--;
        i++;
        if (node.children[i].n == 2 * t - 1) {       // 子節點滿了
            splitChild(node, i);                     // 先分裂,中間 key 升上來
            if (key > node.keys[i]) i++;             // 升上來的 key 可能改變去向
        }
        insertNonFull(node.children[i], key);
    }
}

「下探前先分裂」是 B-Tree 插入最反直覺、也最關鍵的一手——它讓整個插入是單趟往下(one pass down),不需要像 AVL 那樣插完再回溯修復。

刪除:借 key 或合併

刪除比插入囉嗦,因為要處理反方向的問題:節點 key 太少(低於 t-1 個)就違反了 B-Tree 的下限。修法有兩招——跟兄弟「借」一個 key,或跟兄弟「合併」成一個節點。

情況處理
key 在葉節點直接刪
key 在內部節點用前驅或後繼 key 頂替,再去葉節點刪掉那個頂替者
刪完子節點 key 不足(< t-1)先向左右兄弟借一個;兄弟也不夠借就合併兩個子節點

插入是「怕滿、先分裂」,刪除是「怕空、借或合併」——一脹一縮,兩邊都在維持「每個節點 key 數落在 [t-1, 2t-1]」這條區間,這正是 B-Tree 永遠完美平衡的來源。

B-Tree vs B+Tree

資料庫幾乎都用 B+Tree,差異在資料放哪裡:

B-TreeB+Tree
資料位置每個節點都存資料只有葉節點存資料
葉節點連結有雙向鏈結
範圍查詢需要回溯葉節點鏈結直接掃
適用隨機點查詢資料庫索引(點查 + 範圍查詢)
B+Tree 範圍查詢 SELECT * WHERE id BETWEEN 30 AND 70:
  1. 找到 id=30 的葉節點(3 次磁碟 I/O)
  2. 沿葉節點鏈結往右掃到 70
  → 不需要回到內部節點,非常高效

MySQL InnoDB 用 B+Tree,主鍵索引(Clustered Index)的葉節點直接存行資料;Secondary Index 的葉節點存主鍵值,查到主鍵再回表。

複雜度

操作時間磁碟 I/O
搜尋O(t × log_t n)O(log_t n)
插入O(t × log_t n)O(log_t n)
刪除O(t × log_t n)O(log_t n)

t 越大,樹越矮,I/O 越少——但節點越大,一次讀進記憶體的資料也越多,需要在記憶體和磁碟 block 大小之間找平衡。

🎬 互動視覺化B-Tree 插入與分裂動畫 — 連續插入 key,看滿節點怎麼把中間 key 往上推、一分為二,感受「所有葉節點永遠在同一層」是怎麼被撐出來的。


B-Tree 不是為了讓演算法複雜度更好,是為了讓磁碟少轉幾圈。

接下來往哪走