为什么所有数据库都在用B树?5分钟让你豁然开朗

想象一下,你的数据库里有1000万条用户记录。你只想查其中一个人的信息。数据库只用了3毫秒就给出了结果。它是怎么做到的?

如果它傻乎乎地一条一条扫描过去,那可能需要好几秒甚至几分钟。但数据库不会这么干,它会用一个叫"索引"的东西。而这个索引,十有八九就是 B树。

MySQL、PostgreSQL、SQLite、MongoDB......几乎所有主流数据库的核心引擎都在用B树。这绝不是巧合。

今天,我们就来聊聊:为什么二叉查找树在磁盘上会"扑街",B树又是如何解决这个问题的,以及为什么50年过去了,我们还在用它。

先看看反面教材:二叉查找树(BST)

在内存里,二叉查找树表现非常出色。每个节点存一个键,有两个孩子(左小右大)。找数据只需要 O(log₂ n) 次比较。

比如100万条记录,一棵平衡的二叉查找树高度大约是20。这意味着最多20次比较就能找到任何一条数据。在内存里,这快得像闪电一样,大约0.002毫秒。

但是,一旦放到磁盘上,这就变成了灾难。

原因很简单:磁盘读取的最小单位是一个"块"(通常是4KB到16KB)。你想读一个字节?对不起,你得把整个块都读进来。

磁盘访问时间有多慢呢?

  • 机械硬盘(HDD):约10毫秒一次
  • 固态硬盘(SSD):约0.1毫秒一次
  • 而内存:只要0.0001毫秒

差距是100倍到10万倍!

所以,如果用二叉查找树:

  • 100万条记录:树高20层 → 需要20次磁盘寻道 → 机械硬盘上要花 200毫秒
  • 10亿条记录:树高30层 → 需要30次磁盘寻道 → 机械硬盘上要花 300毫秒

问题出在哪?二叉树的"扇出"太低了------每个节点只有2个孩子。我们需要更多孩子,才能把树的高度降下来。

B树登场:为磁盘而生的数据结构

B树的核心思想非常简单:

既然每次读磁盘都要读一整个块,那就把这个块塞得满满当当的!

一个B树节点可以存几百上千个键。每个节点就是一个磁盘块。这样一来,树的高度就被大大压缩了。

对比一下效果

扇出 100万条数据树高 10亿条数据树高 磁盘寻道次数 机械硬盘耗时
2(二叉查找树) 20 30 20 200ms
100 3 5 3 30ms
1000 2 3 2 20ms

看到了吗?扇出1000的时候,10亿条数据也只需要3次磁盘读取!这就是B树的神奇之处。

B树长什么样?

B树有三种节点:

  • 根节点:树的顶端,只有一个
  • 内部节点:中间层,只存"路标"(分隔键),不存实际数据
  • 叶子节点:最底层,存真正的数据(键值对)

所有叶子节点都在同一深度。这也是为什么查询任何数据的时间都差不多------稳定。

小知识:现在各大数据库实际用的是B+树,它只在叶子节点存数据,内部节点只做导航。但大家习惯上都叫它B树。

动手实现一个简单的B树

下面是一个简化版但能正常工作的Python B树实现:

复制代码
from typing import List, Optional
from dataclasses import dataclass, field

@dataclass
class BTreeNode:
    """B树节点"""
    keys: List[int] = field(default_factory=list)
    children: List['BTreeNode'] = field(default_factory=list)
    is_leaf: bool = True

class BTree:
    """B树实现"""
    
    def __init__(self, order: int = 100):
        if order < 3:
            raise ValueError("阶数至少为3")
        self.order = order
        self.root = BTreeNode()
    
    def search(self, key: int) -> Optional[int]:
        """搜索键"""
        return self._search_recursive(self.root, key)
    
    def _search_recursive(self, node: BTreeNode, key: int) -> Optional[int]:
        # 在当前节点二分查找
        i = 0
        while i < len(node.keys) and key > node.keys[i]:
            i += 1
        
        # 找到了!
        if i < len(node.keys) and node.keys[i] == key:
            return key
        
        # 如果是叶子节点还没找到,那就是不存在
        if node.is_leaf:
            return None
        
        # 继续去子节点找
        return self._search_recursive(node.children[i], key)
    
    def insert(self, key: int):
        """插入键"""
        root = self.root
        
        # 如果根节点满了,先分裂
        if len(root.keys) >= self.order - 1:
            new_root = BTreeNode(is_leaf=False)
            new_root.children.append(self.root)
            self._split_child(new_root, 0)
            self.root = new_root
        
        self._insert_non_full(self.root, key)
    
    def _insert_non_full(self, node: BTreeNode, key: int):
        """向非满节点插入"""
        i = len(node.keys) - 1
        
        if node.is_leaf:
            # 叶子节点,直接插入
            node.keys.append(None)  # 占位
            while i >= 0 and key < node.keys[i]:
                node.keys[i + 1] = node.keys[i]
                i -= 1
            node.keys[i + 1] = key
        else:
            # 内部节点,找到合适的子节点
            while i >= 0 and key < node.keys[i]:
                i -= 1
            i += 1
            
            # 如果子节点满了,先分裂
            if len(node.children[i].keys) >= self.order - 1:
                self._split_child(node, i)
                if key > node.keys[i]:
                    i += 1
            
            self._insert_non_full(node.children[i], key)
    
    def _split_child(self, parent: BTreeNode, child_index: int):
        """分裂子节点"""
        full_child = parent.children[child_index]
        new_sibling = BTreeNode(is_leaf=full_child.is_leaf)
        
        mid = (self.order - 1) // 2
        
        # 分一半键给新兄弟
        new_sibling.keys = full_child.keys[mid + 1:]
        full_child.keys = full_child.keys[:mid]
        
        # 如果不是叶子节点,还要分孩子
        if not full_child.is_leaf:
            new_sibling.children = full_child.children[mid + 1:]
            full_child.children = full_child.children[:mid + 1]
        
        # 把中间键提升到父节点
        promoted_key = full_child.keys[mid]
        parent.keys.insert(child_index, promoted_key)
        parent.children.insert(child_index + 1, new_sibling)


# 试试看
if __name__ == "__main__":
    btree = BTree(order=5)
    keys = [10, 20, 5, 6, 12, 30, 7, 17, 3, 16, 21, 24, 25, 26, 27]
    
    print("插入键:", keys)
    for key in keys:
        btree.insert(key)
    
    print("\n搜索测试:")
    for key in [6, 16, 21, 100]:
        result = btree.search(key)
        print(f"键 {key}: {'找到了' if result else '没找到'}")

B树是怎么工作的?

查找

从根节点出发,在每个节点内部做二分查找,找到对应的子节点指针往下走,直到叶子节点。就这么简单。

插入

  1. 找到应该插入的叶子节点

  2. 如果叶子节点没满,直接插入

  3. 如果叶子节点满了

    ,就要"分裂":

    • 把节点从中间一分为二
    • 把中间的那个键"提拔"到父节点
    • 如果父节点也满了,继续往上分裂......
    • 一直裂到根节点为止

删除

  1. 找到要删除的键
  2. 如果删除后节点太空了(低于半满),就和旁边的兄弟节点合并
  3. 合并可能一路向上传播

真实世界的B树

MySQL InnoDB

  • 默认页面大小:16KB
  • 扇出:约100-200(取决于键的大小)
  • 100万行数据:树高只有3-4层

当你执行 SELECT * FROM users WHERE id = 12345 时,数据库只需要读3-4个磁盘块就能找到数据。

PostgreSQL

  • 默认页面大小:8KB
  • 扇出:约50-100
  • B树是默认索引类型

SQLite

  • 页面大小:4KB(可配置到64KB)
  • 连表本身都是用B树存的,没有单独的堆存储

B树的局限性

当然,B树也不是万能的:

  1. 写放大:每次插入都可能触发分裂,一次逻辑写入可能变成多次物理写入
  2. 空间浪费:频繁增删后,节点可能只有50%-60%满
  3. 并发瓶颈:分裂和合并时需要加锁

什么时候不用B树?

  • 写多读少的场景:考虑LSM树(RocksDB、Cassandra在用)
  • 全内存数据:哈希表或跳表更简单更快
  • 大数据分析:列式存储(Parquet、ORC)更适合

总结

B树之所以统治数据库世界50年,核心原因只有一个:

它完美匹配了磁盘的物理特性。

磁盘读写的最小单位是块,B树就把一个块塞满数据。扇出越大,树越矮,磁盘I/O越少。

下次你查数据库只花了3毫秒,记得在心里感谢一下B树。