解释大名鼎鼎的 B+ Tree
1. 节点 键值 指针 是什么#
好奇 B+ Tree 接近 O(1) 是怎么做到的, 不应该是 像二叉搜索树那样的 就是 lgn
吗, 如果高度很低, 横向数据就很多吧, 不也是需要遍历, 单是横向数据这不就是 O(n)
了吗?
想要了解这些问题, 还是需要了解一下 B+ Tree 的结构
术语 | 含义 |
---|---|
节点 (Node) | 磁盘页 (默认 16 KB),在 B+ 树层次结构中的一个元素 |
非叶子节点 (Internal / Branch page) | 存放键值 + 子页指针,负责导航 |
叶子节点 (Leaf page) | 1) 聚簇索引:存放键值 + 整行数据 2) 二级索引:存放键值 + 主键值 |
指针 | 在磁盘实现里是“页号 + 槽号”或 RowID;在逻辑图中可视作“去哪个子节点” |
键值 (Key) | 用来比较、决定走哪条路径的字段值 |
一个节点可以存多个数据, 并不是一个节点只能存一个数据, 非叶子节点, 叶子节点都是这样, 比如下面的描述:
- 一个聚簇索引的叶子节点可以存 200 行数据
- 一个二级索引的叶子节点可以存 200 个主键
也就是说一个节点可以存多个数据, 只是节点种类不同, 索引种类不同(聚簇索引, 二级索引), 存储的数据是不一样的:
- 无论聚簇索引还是二级索引, 非叶子节点存的都是 多个指针 + 多个键值
- 键值用来与要查询的条件 (比如 id, name) 比较接下来 (下一层) 去哪个指针找下一个节点
- 指针用来指向子节点 (子页) 的位置
- 叶子节点就有所不同了
- 聚簇索引的叶子节点存储的是 键值 + 整行数据
- 二级索引存放键值 + 主键值
注意哪个列的索引, 对应的 B+ Tree 的节点的键值类型, 就是那个列的类型, 比如
name
对应的索引 B+ Tree 每个节点的多个键值的类型肯定是name
, 而聚簇索引是根据 id 建立的 B+ Tree, 所以每个非叶子节点 存的是很多个 id + 指针 这样的数据, 此时 id 是键值:所以上面我们说: 键值用来与要查询的条件 (比如 id, name) 比较接下来 (下一层) 去哪个指针找下一个节点, 现在可以理解了吧
2. 详细结构介绍#
叶子节点:存储所有键值和数据, 并通过双向链表连接, 便于范围查询
非叶子节点:由"键值"和"指针"组成, 用于导航, 不存储实际数据
- 键值(Key): 用于比较和决策的值
- 指针(Pointer): 指向下一层节点的地址
在一个非叶子节点中, 指针的数量总是比键值的数量多1个, 每个指针指向下一层的一个节点, 非叶子节点在物理结构上是一个连续的数据区域, 其中键值和指针是按特定顺序交替排列的:
[指针P1] [键值K1] [指针P2] [键值K2] [指针P3]
其实指针笔键值多一个很好理解, 比如一个节点有 3 个键值 010, 020, 030, 那数据就有四个范围:
- x < 010
- 010 <= x < 020
- 020 <= x < 030
- 030 <= x
所以需要四个指针来指明 x 在哪个节点上, 通过实际例子解释一下 聚簇索引 B+ Tree 的结构
2.2. PRIMARY KEY 聚簇索引结构#
id | name | age |
---|---|---|
1 | Alice | 23 |
2 | Bob | 31 |
3 | Cindy | 27 |
4 | David | 35 |
5 | Emma | 22 |
6 | Frank | 28 |
7 | Grace | 30 |
8 | Helen | 26 |
9 | Iris | 24 |
10 | Jack | 29 |
假设每个页能装 4 行记录(仅用于示意):
(Root, non-leaf page)
+------------------+
| key=5 | ptr(P2) |
ptr(P1)----------| key=9 | ptr(P3) |
+------------------+
/ \
+--------------+ +--------------+
| leaf page P1 | | leaf page P2 | ……
+--------------+ +--------------+
页号 | 类型 | 内容 (简化后的顺序存储) | 说明 |
---|---|---|---|
P1 | 叶子 | (1,Alice,23) (2,Bob,31) (3,Cindy,27) (4,David,35) | 键≤5 |
P2 | 叶子 | (5,Emma,22) (6,Frank,28) (7,Grace,30) (8,Helen,26) | 5<键≤9 |
P3 | 叶子 | (9,Iris,24) (10,Jack,29) | 键>9 |
Root | 非叶子 | key=5 →P2, key=9 →P3 | P1 通过最左指针隐式链接 |
解释
-
非叶子节点 Root 里只保存分隔点 key=5、key=9 以及指向子页的指针
-
叶子节点 P1/P2/P3 按主键顺序链表相连,保存 “键值 + 整行”
查询 SELECT * FROM users WHERE id = 7;
-
从 Root 读取, 7 > 5 走 ptr(P2)
-
P2 里二分查找, 定位记录 (7,Grace,30)
-
直接返回整行, 因为行数据就在叶子
聚簇索引和非聚簇索引的结构一样, 只是叶子节点存储的内容不一样, 前者存 键值 + 对应的一整行数据, 后者存 键值 + 主键值
2.2. 二级索引 idx_name 结构#
假设每个页能装 4 行记录(仅用于示意):
(Root, non-leaf)
+--------------------+
| key=Bob | ptr(S2) |
ptr(S1)------| key=Helen| ptr(S3) |
+--------------------+
页号 | 类型 | 内容 | 说明 |
---|---|---|---|
S1 | 叶子 | (Alice,1) (Bob,2) (Cindy,3) (David,4) | 键 < Bob |
S2 | 叶子 | (Emma,5) (Frank,6) (Grace,7) (Helen,8) | Bob ≤ 键 < Helen |
S3 | 叶子 | (Iris,9) (Jack,10) | 键 ≥ Helen |
Root | 非叶子 | key=Bob →S2, key=Helen →S3 | S1 通过最左指针隐式链接 |
查询 SELECT age FROM users WHERE name = 'Grace';
步骤
- 从 idx_name.Root 读,‘Grace’ > ‘Bob’ 且 < ‘Helen’ 走 S2
- 在 S2 找到条目 (‘Grace’, 7)
- 拿到主键 id=7,用它去聚簇索引 Root,再走 P2,然后定位 (7,Grace,30)
- 取出 age=30
2.3. 决策流程总结#
优先检查能否用索引:
- 查询包含 WHERE id = ? → 使用聚簇索引
- 查询 WHERE name = ? → 使用 idx_name,然后回表
执行器根据统计信息估算成本,再选择走哪个索引(或全表扫), 一旦选定索引,引擎从 Root 开始,逐层比较键值直到叶子, 如果是二级索引,还需拿到主键,按同样 B+ 树规则走聚簇树
3. 叶子节点是双向连表的优势#
假设我们有一个订单表orders
,包含订单ID、客户名、订单日期和金额:
orders(order_id, customer_name, order_date, amount)
其中 order_id
是主键,在 order_date
上创建了索引:
(1, '张三', '2023-01-15', 100)
(2, '李四', '2023-02-20', 200)
(3, '王五', '2023-03-10', 150)
(4, '赵六', '2023-03-15', 300)
(5, '钱七', '2023-04-05', 250)
在B+ Tree索引结构中,order_date
索引的叶子节点可能如下所示:
[2023-01-15, ID=1] <-> [2023-02-20, ID=2] <-> [2023-03-10, ID=3] <-> [2023-03-15, ID=4] <-> [2023-04-05, ID=5]
这些节点通过双向链表连接, 每个节点都有指向前一个和后一个节点的指针,
范围查询 假设我们需要查询3月份的所有订单:
SELECT * FROM orders WHERE order_date BETWEEN '2023-03-01' AND '2023-03-31';
数据库会:
- 通过B+树索引结构找到第一个满足条件的叶子节点(‘2023-03-10’)
- 然后沿着链表顺序遍历,直到找到超出范围的节点
- 无需回到树的上层节点再向下查找
如果没有双向链表, 每找到一个节点都需要从根节点重新遍历, 效率会大大降低
排序查询 执行如下排序查询
SELECT * FROM orders ORDER BY order_date;
由于叶子节点已经按 order_date
排序并通过链表连接, 数据库只需按链表顺序读取即可, 无需额外排序步骤
分页查询 当执行分页查询时:
SELECT * FROM orders ORDER BY order_date LIMIT 2, 2;
数据库可以:
- 找到排序起始位置
- 跳过前2条记录
- 沿着链表读取接下来的2条记录
全表扫描 如果需要全表扫描,可以从第一个叶子节点开始,沿着链表顺序读取所有数据,这种顺序访问比随机访问磁盘更高效
4. 为什么索引查询速度接近 O(1)#
有人分析说 因为 B+ Tree 树的高度很低, 一般都是 3~4, 所以 I/O 次数 只有 3 ~ 4 次, 接近常数 1, 可是我认为, B+ Tree 不像 二叉搜索树那样, 只有每个节点只有两个子节点, 有可能会有多个节点, 比如 100 个, 这没关系, 因为每个节点页都由上层 的节点 通过指针指出, 可是每个节点也就是数据页可能会有多个数据啊, 比如 100 个, 这个时候 定位数据, 复杂度不应该是 O(n) 吗?
4.2. 公式推导#
一次查找包含两个独立成本
步骤 | 在 B⁺Tree 中做什么 | 理论开销 |
---|---|---|
A. 自根向叶走指针 | 需要读取「树高 h」个节点页 | O(h) = O(logₘ N) |
B. 在单个节点里找分隔键 | 对 m-1 个键比较,可做线性查找或二分查找 | O(m) 或 O(log m) |
记号
- m = 分叉因子(一个非叶子能容纳的最大子页数)
- N = 总记录行数
假设
-
页大小 = 16 KB
-
非叶子节点:每条索引条目 24 B(键 + 子页号)
- 每页可容纳 ⌊16384 / 24⌋ ≈ 680 个分叉,即 m ≈ 680
-
叶子节点:每条索引项 40 B(键 + 主键)
- 每页 ≈ 400 条记录
计算树高 h $$ h = \lceil \log_{m}(N) \rceil $$
-
N (行数)
-
h (向上取整)
N (行数) | h (向上取整) | 需要随机 I/O |
---|---|---|
100 万 | ⌈log₆₈₀(10⁶)⌉ = 2.4 → 3 | 3 次 |
1 亿 | ⌈log₆₈₀(10⁸)⌉ = 3.2 → 4 | 4 次 |
100 亿 | ⌈log₆₈₀(10¹⁰)⌉ = 4.0 → 4 | 4 次 |
- N 从 10⁶ ➜ 10¹⁰ 扩大一万倍,h 只从 3 ➜ 4
- 若页缓存命中,步骤 A 甚至是 0 次磁盘 I/O,仅 CPU 比较
节点内比较成本
-
线性查找:≤ 680 次比较
-
二分查找:⌈log₂ 680⌉ ≈ 10 次比较
在现代 CPU 上比较 10~680 个整数几乎可忽略, 于是总体壁钟时间看起来几乎不随 N 增长,这就是「接近 O(1)」的直观感受
壁钟时间是程序运行的“真实世界时间”, 而这里强调它几乎不受 N 影响, 直观上接近常数时间复杂度,
4.2. 实际数据比较#
我们来比较一下 做一次随机磁盘 IO + 把数据页拷贝进内存 的时间 和 CPU 比较一次的耗时做对比:
存储介质 | 一次随机 I/O 大概耗时(包括拷贝进内存的时间) | CPU 比较一次耗时 | 数量级差距 |
---|---|---|---|
机械盘 | ≈ 5 ms | ≈ 5 ns | ≈ 1 000 000 : 1 |
SATA SSD | ≈ 100 µs | ≈ 5 ns | ≈ 20 000 : 1 |
NVMe SSD | ≈ 20 µs | ≈ 5 ns | ≈ 4 000 : 1 |
- 即使换成 NVMe,随机 I/O 仍比 CPU 比较慢四个数量级
- 因此决定查询延迟的第一因素仍是“要做几次随机 I/O”
- B⁺Tree 通过把分叉数 m 做到几百,使树高 h≤3–4,从而把“随机 I/O 次数”限制到 3–4 次——这远比 “页内要比较多少键” 重要
为什么复杂度推导 没有包括 叶子页找目标记录的开销
在
O(log N)
级别推导中, 页内比较最多几百次, 比一次 I/O 快万倍, 可忽略不计
有人可能会问 你把 在存储介质 一次随机 I/O 耗时 与 CPU 比较一次耗时 进行比较准确吗?
一次磁盘随机 I/O 耗时 不应该加上从内存读取数据的时间吗, 因为数据加载的过程, 是把该节点的整个数据页加载进内存, 然后再比较数据,
下面把 一次磁盘随机 I/O 到真正完成键值比较 所经历的全部路径拆开, 给出各阶段的量级, 看看 磁盘→内存→CPU 各环节到底谁是瓶颈, 为便于对比, 以一次读取 16 KB InnoDB 页为例:
阶段 | 机械盘 | SATA SSD | NVMe SSD | 仅 DRAM (页已在 Buffer Pool) | 说明 |
---|---|---|---|---|---|
A. 设备随机寻道/寻址 | ≈ 5 ms | ≈ 50 µs | ≈ 10 µs | 0 | 硬盘最耗时;SSD 仍需闪存地址译码+控制器排队 |
B. 媒体读出到控制器缓存 | ≈ 0.2 ms | ≈ 40 µs | ≈ 5 µs | 0 | 顺序传输 16 KB 原始位流 |
C. PCIe/SATA DMA 传到主机 DRAM | ≈ 50 µs | ≈ 10 µs | ≈ 3 µs | 0 | 含协议栈、DMA 成本 |
D. OS/Buffer Pool 拷贝 + 校验 | ≈ 5 µs | ≈ 5 µs | ≈ 5 µs | ≈ 5 µs | memcpy + CRC,一次 copy≈0.8 µs,算上校验约 5 µs |
E. InnoDB 页目录二分 + 线性扫 | ≈ 0.05 µs | ≈ 0.05 µs | ≈ 0.05 µs | ≈ 0.05 µs | 几十次 CPU 指令:10–100 ns |
总耗时 | ≈ 5.26 ms | ≈ 115 µs | ≈ 18 µs | ≈ 5 µs | A+B+C 占主导,D、E 几乎可忽略 |
数值按公开硬件数据取近似中位数, 偏差 1–2 倍不影响量级结论, 结论
-
随机 I/O 的主耗时就是 设备端寻址 + 媒体读出(A+B)
-
对机械盘:> 99 % 的时间耗在磁头定位和旋转等待(A)
-
对 SSD:A 仍是大头,只是毫秒级→微秒级
-
DMA + 内存拷贝(C+D)加起来只是几个微秒,机械盘情况下占比 < 0.1 %
- 因此前面的表格 磁盘 IO 时间
5 ms / 100 µs / 20 µs
这种 “磁盘随机 I/O 耗时” 通常已经把拷贝进内存的时间统合在内;再细分也不改变大局——I/O 仍比内存-CPU 操作慢三个以上数量级
- 因此前面的表格 磁盘 IO 时间
-
页内比较(E)只有几十纳秒;即便连带一次 L1→L2 缓存失效也就是百纳秒量级, 和微秒~毫秒的 I/O 相比同样可以忽略
-
如果页已在 Buffer Pool(只需 D+E):总耗时约 5 µs,比 NVMe 随机 I/O 快 3~4 倍,比机械盘快千倍;此时查找延迟主要是 CPU-DRAM 以及锁、版本检查等开销
所以说 “数据库查找最贵的是随机 I/O” 是对的: 只要页不在内存,I/O 时间会压倒后续所有 CPU 计算
在数据库查询中, 磁盘随机 I/O 操作是最耗时的部分, 而不是单纯的计算次数, B-Tree 的每一层对应一次磁盘 I/O, 而高度低意味着 I/O 次数少, 只要页不在内存, I/O 时间会压倒后续所有 CPU 计算, 因为 CPU 比较数据 & 内存拷贝视为“几乎免费”在数量级上是成立的, B⁺ Tree 之所以追求低树高, 就是为了把这个昂贵的随机 I/O 次数从
O(log N)
压到 3–4 次, 之后页内二分或线性扫描的优化相对只是“锦上添花”
5. 页的概念是什么意思#
5.1. 什么是页(Page)#
在 InnoDB(MySQL 8.x 默认存储引擎)里, 页是磁盘与内存之间读写的最小物理单元
- 任何数据(表、索引、Undo、字典等)都被切成若干页来管理
- 数据库一次随机 I/O 最多只把一个完整页搬到 Buffer Pool,随后对该页里的记录做 CPU 运算
页的大小
参数 | 默认值 | 可否修改 | 作用 |
---|---|---|---|
innodb_page_size | 16 KB (16 384 字节) | 初始化实例时可改为 4 KB / 8 KB / 32 KB / 64 KB | 决定所有 InnoDB 页大小 |
5.2. 页里存放什么?#
以一页 “索引页(INDEX PAGE,类型 0x45BF)” 为例
+----------------------+ 0 ← file offset
| File Header (38 B) |
| Page Header (56 B) |
| Infimum / Supremum | 伪记录做边界
| User Records ... | 按主键或二级索引键有序
| Free Space | 插入时向中间填充
| Page Directory | 每 128 条插一槽,向下增长
| File Trailer (8 B) |
+----------------------+ 16384 ← 16 KB
- User Records:真正的行记录或索引条目
- Page Directory:偏移量数组,用来做二分搜索
- Header 包括页号、上一页/下一页指针、校验值、空闲链等元数据
5.3. 页与 B⁺Tree节点的关系#
B⁺Tree 术语 | InnoDB 对应 | 说明 |
---|---|---|
Node (节点) | Page (16 KB) | 一页就是一个节点 |
Child Pointer | 在非叶子记录中的 page_no + slot | 指向子页 |
Sibling Pointer | 页头的 prev / next | 把叶子页串成双向链表 |
所以常听到“树高 = 3~4 页深”本质上是“从根页经 3~4 次指针跳到目标叶子页”