想象一下,你的数据库里有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树是怎么工作的?
查找
从根节点出发,在每个节点内部做二分查找,找到对应的子节点指针往下走,直到叶子节点。就这么简单。
插入
-
找到应该插入的叶子节点
-
如果叶子节点没满,直接插入
-
如果叶子节点满了
,就要"分裂":
- 把节点从中间一分为二
- 把中间的那个键"提拔"到父节点
- 如果父节点也满了,继续往上分裂......
- 一直裂到根节点为止
删除
- 找到要删除的键
- 如果删除后节点太空了(低于半满),就和旁边的兄弟节点合并
- 合并可能一路向上传播
真实世界的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树也不是万能的:
- 写放大:每次插入都可能触发分裂,一次逻辑写入可能变成多次物理写入
- 空间浪费:频繁增删后,节点可能只有50%-60%满
- 并发瓶颈:分裂和合并时需要加锁
什么时候不用B树?
- 写多读少的场景:考虑LSM树(RocksDB、Cassandra在用)
- 全内存数据:哈希表或跳表更简单更快
- 大数据分析:列式存储(Parquet、ORC)更适合
总结
B树之所以统治数据库世界50年,核心原因只有一个:
它完美匹配了磁盘的物理特性。
磁盘读写的最小单位是块,B树就把一个块塞满数据。扇出越大,树越矮,磁盘I/O越少。
下次你查数据库只花了3毫秒,记得在心里感谢一下B树。