Skip to main content

b-tree

📅 2026-04-10 ✏️ 2026-04-10 CS
No related notes

1 · B-tree#

1.1 · S - 场景#

需要频繁随机读取且支持范围查询的场景:

  • 传统数据库(MySQL InnoDB、PostgreSQL)
  • 文件系统(NTFS、ext4、HFS+)
  • 内存数据库(Redis Sorted Sets 基础)
  • 分布式系统的索引(Elasticsearch)

1.2 · C - 核心权衡#

维度LSM-treeB-tree
写入顺序追加(快)原地更新(慢)
读取多层查询(多I/O)直接定位(少I/O)
空间多版本冗余紧凑高效
范围查询需要多个SSTable直接叶子链表
CPUCompaction 高负载读写均衡

1.3 · Q - 核心问题与解决#

1.3.1 · Q1. 数据随机分散问题#

问题:如果直接把 key-value 放在文件中,随机读需要全表扫描。

解决方案:树形索引结构

  • 数据按 key 有序排列
  • 通过多层索引指针快速定位
  • 树高通常只有 3-4 层(百万级数据)

1.3.2 · Q2. 更新不能原地改写的问题#

问题:如果数据长度变化(如字符串扩展),原地更新会导致后续数据移位。

解决方案

  • 将数据组织成固定大小的页(page/block)
  • 更新时只改写涉及的页,不影响其他页
  • 利用 CPU 缓存和磁盘分页的天然匹配

1.3.3 · Q3. 插入删除后的树结构失衡问题#

问题:频繁插入删除后,树可能变得不平衡,导致查询性能下降。

解决方案:自平衡机制

  • 分裂(Split):节点过满时分成两个节点
  • 合并(Merge):节点过空时和相邻节点合并
  • 旋转(Rotation):调整树的形状保持平衡

1.4 · A - B-tree 架构#

1.4.1 · 核心特性

Properties of B-tree (order m):
  1. 每个节点最多有 m 个孩子(m-1 个 key)
  2. 除根节点外,每个节点至少有 ⌈m/2⌉ 个孩子
  3. 根节点至少有 2 个孩子(除非树只有根)
  4. 所有叶子节点在同一层
  5. 节点内 key 有序,key 分隔子树的范围

1.4.2 · B-tree 的结构图#

                     ┌─────────┐
                     │  30 60  │  (根节点)
                     └────┬────┴─────┬────┘
                         /          \
                   ┌─────────┐  ┌─────────┐
                   │ 10 20   │  │ 40 50   │  (内部节点)
                   └──┬──┬───┴──┬──┬───┘
                   /  |  \    /  |  \
              ┌──┐ ┌─┐ ┌──┐┌──┐ ┌──┐┌────┐
              │10│ │15│ │20│ │40│ │50│ │70 80│  (叶子节点)
              └──┘ └─┘ └──┘└──┘ └──┘└────┘
             (实际存储 data pointers)

特点

  • 根到叶子的路径长度相同
  • 内部节点和叶子节点都存储 key
  • key 用于指导查询路由(路由 key 和数据 key 相同)

1.4.3 · B+tree 的改进(更常用于数据库)#

B+tree 在 B-tree 基础上的改进:
  1. 只有叶子存储数据,内部节点只存 key
  2. 叶子节点用链表连接,支持高效范围查询
  3. 内部节点 key 可以重复出现在叶子

B+tree 结构

                     ┌─────────┐
                     │  30 60  │  (索引层:只有 key,无数据)
                     └────┬────┴─────┬────┘
                        /            \
                   ┌─────────┐   ┌─────────┐
                   │ 10 20   │   │ 40 50   │
                   └──┬──┬───┴───┬──┬───┘
                     /  |  \    /  |  \
           ┌──────┬────┬──────┬──────┬──────┐
           │  10  │ 20 │ 30   │ 50   │ 70   │  (数据层:存储实际数据)
           └──────┴────┴──────┴──────┴──────┘
              ↓         ↓       ↓       ↓    (叶子链表,支持范围查询)
           [data1] [data2] [data3] [data4]

1.5 · 关键操作

search(key, node):
  # 在节点中找到 >= key 的第一个 key
  i = binary_search(node.keys, key)
  
  if key 在 node.keys 中:
    return node.data[i]  # 找到
  
  if node 是叶子:
    return NOT_FOUND     # 不存在
  
  return search(key, node.children[i])  # 递归搜索

时间复杂度:O(log n)

  • 树高度:h = log_m(n),m 通常为 100-1000
  • 每层二分查找:O(log m)
  • 总复杂度:O(log_m(n) × log m)

1.5.2 · 2. 插入(Insert)#

insert(key, value):
  # 1. 找到叶子节点
  leaf = find_leaf(key)
  
  # 2. 在叶子插入 key-value
  leaf.insert(key, value)
  
  # 3. 如果叶子过满(超过 m-1 个 key)
  if leaf.is_full():
    # 3a. 分裂:取中间 key 上升
    mid_key = leaf.keys[m/2]
    left, right = leaf.split()
    
    # 3b. 递归处理父节点
    parent.insert(mid_key)
    if parent.is_full():
      parent.split()  # 可能导致级联分裂

分裂过程(order=3)

Before (过满):       [10, 20, 30]

                        ↓ split
                        
After:              [20]           (上升到父节点)
                   /  \
            [10]  [30]  (分成两个节点)

时间复杂度:O(log n)

  • 查找叶子:O(log n)
  • 分裂(最坏级联到根):O(log n)

1.5.3 · 3. 删除(Delete)#

delete(key):
  # 1. 找到包含 key 的节点
  node = search_node(key)
  
  if node 是叶子:
    node.remove(key)
  else:
    # 用前驱或后继替换,然后删除
    predecessor = get_predecessor(key)
    node.replace(key, predecessor)
    delete(predecessor)
  
  # 2. 如果节点过空(< ⌈m/2⌉ 个 key)
  if node.underflow():
    # 尝试从兄弟节点借
    if sibling.has_extra():
      borrow_from_sibling(node, sibling)
    else:
      # 否则与兄弟合并
      merge(node, sibling)

合并过程

Before (过空):     [20]              [40]
                  /  \              /  \
            [10]  []  [30]    [35]  [50]

                └─ 与兄弟合并,从父节点借 key
                
After:            [20, 40]
                 /   |   \
            [10] [30,35] [50]

时间复杂度:O(log n)

  • 查找 + 删除 + 合并/借位:O(log n)

1.6 · 性能分析

1.6.1 · 时间复杂度对比

操作平衡二叉树B-tree (m=100)
SearchO(log n)O(log n) 但常数小
InsertO(log n)O(log n)
DeleteO(log n)O(log n)
RangeO(k + log n)O(k + log n)

1.6.2 · 空间和 I/O 特性#

数据量 N = 1 千万(10^7)

二叉搜索树:
  树高:h ≈ log₂(10^7) ≈ 24 层
  最坏情况需要 24 次磁盘 I/O

B-tree (m=100, 内部节点存 99 个 key):
  树高:h ≈ log₁₀₀(10^7) ≈ 3-4 层
  最坏情况只需 3-4 次磁盘 I/O
  
性能差异:
  ├─ 二叉树:24 次 I/O × 10ms/IO = 240ms
  └─ B-tree:3 次 I/O × 10ms/IO = 30ms
  └─ 快 8 倍!

1.6.3 · 为什么选择 B-tree?#

  1. 树高低:3-4 层可处理百万级数据

    • 每层一次磁盘 I/O
    • 减少 I/O 次数是关键
  2. 按页对齐

    • 操作系统和磁盘都是按页(4KB-16KB)处理
    • B-tree 节点大小恰好匹配页大小
    • 无需额外转换或分页
  3. 支持范围查询

    • 叶子有序连接(B+tree)
    • 可高效扫描范围 [a, b]
    • LSM 需要查多个 SSTable
  4. 更新高效

    • 原地改写,无多版本开销
    • 无需后台 compaction

1.7 · 工业实现

1.7.1 · MySQL InnoDB B+tree 索引#

结构:
  聚集索引:主键 + 完整行数据
  辅助索引:索引键 + 主键引用
  
  Clustered Index (按主键):
    内部节点:[key1] [key2] [key3]
    叶子节点:[pk1, data1] [pk2, data2] ...
    
  Secondary Index (非主键索引):
    内部节点:[key1] [key2]
    叶子节点:[key1, pk1] [key2, pk2] ...
    
查询流程:
  SELECT * FROM users WHERE name = 'Alice'
    ├─ 在 name 索引上二分查找
    ├─ 获得主键 pk = 123
    ├─ 在聚集索引上查询 pk = 123
    └─ 获得完整行数据

1.7.2 · SQLite B-tree 实现#

特点:
  1. 使用变长 B-tree
  2. 页大小可配置(默认 4KB)
  3. 支持 VACUUM 重组
  4. 数据紧凑存储在叶子

1.8 · B-tree vs LSM-tree#

1.8.1 · 权衡决策表

特性B-treeLSM-tree
写吞吐中等(随机I/O)高(顺序I/O)
读延迟低(直接定位)中等(多层查)
空间占用紧凑冗余(多版本)
范围查询高效(叶子链表)需多SSTable
CPU开销高(compaction)
适用场景通用OLTP写密集(日志、TS)

1.8.2 · 选择建议

选 B-tree 如果:
  ✓ 读写比例均衡
  ✓ 需要高效范围查询
  ✓ 磁盘空间有限
  ✓ CPU 资源受限
  例:传统数据库、文件系统

选 LSM-tree 如果:
  ✓ 写操作远多于读
  ✓ 可接受读放大
  ✓ 有充足 CPU 做 compaction
  ✓ 可接受高空间占用
  例:日志、时序数据库、NoSQL

1.9 · 实现要点(伪代码)

class BTree:
    def __init__(self, order=3):
        self.order = order  # m
        self.root = BNode(order)
    
    def search(self, key):
        return self._search_node(self.root, key)
    
    def _search_node(self, node, key):
        # 二分查找找到 >= key 的位置
        idx = bisect.bisect_left(node.keys, key)
        
        if idx < len(node.keys) and node.keys[idx] == key:
            return node.data[idx]  # 找到
        
        if node.is_leaf:
            return None  # 叶子中未找到
        
        return self._search_node(node.children[idx], key)
    
    def insert(self, key, value):
        if self.root.is_full():
            # 根满了,需要创建新根
            old_root = self.root
            self.root = BNode(self.order)
            self.root.children.append(old_root)
            self._split_child(self.root, 0)
        
        self._insert_non_full(self.root, key, value)
    
    def _insert_non_full(self, node, key, value):
        idx = bisect.bisect_left(node.keys, key)
        
        if node.is_leaf:
            # 直接插入
            node.keys.insert(idx, key)
            node.data.insert(idx, value)
        else:
            # 找到子节点
            child = node.children[idx]
            if child.is_full():
                self._split_child(node, idx)
                # 分裂后重新确定方向
                if key > node.keys[idx]:
                    idx += 1
                child = node.children[idx]
            
            self._insert_non_full(child, key, value)
    
    def _split_child(self, parent, child_idx):
        # 分裂第 child_idx 个子节点
        child = parent.children[child_idx]
        mid_idx = (self.order - 1) // 2
        
        # 创建新节点
        new_child = BNode(self.order)
        new_child.keys = child.keys[mid_idx + 1:]
        new_child.data = child.data[mid_idx + 1:]
        
        if not child.is_leaf:
            new_child.children = child.children[mid_idx + 1:]
        
        # 更新原节点
        child.keys = child.keys[:mid_idx]
        child.data = child.data[:mid_idx]
        
        # 中间 key 上升到父节点
        parent.keys.insert(child_idx, child.keys[mid_idx])
        parent.data.insert(child_idx, child.data[mid_idx])
        parent.children.insert(child_idx + 1, new_child)

1.10 · 总结

B-tree 是生产数据库的标准选择,原因是:

  1. 低树高:3-4 层处理亿级数据,减少磁盘 I/O
  2. 高效性:查询、插入、删除都是 O(log n)
  3. 范围查询:叶子链表支持高效扫描
  4. 硬件友好:节点大小匹配磁盘页,CPU 缓存友好
  5. 空间高效:无多版本开销,紧凑存储

而 LSM-tree 则专注于写优化,通过顺序 I/O 换取读放大和空间放大。