MySQL 底层存储 B-Tree 和 B+Tree 原理分析

为什么 MySQL 查询能快速找到数据?为什么索引能显著提升查询性能?答案就在于底层的索引结构——B+Tree。B+Tree 是 MySQL(特别是 InnoDB 引擎)最核心的数据结构,理解它的工作原理对于设计高性能数据库应用至关重要。本文将从基础概念出发,系统性地介绍 B-Tree 和 B+Tree 的原理、结构特点、操作复杂度以及在 MySQL 中的实际应用。

简介

什么是 B-Tree

B-Tree(Balanced Tree)是一种自平衡的多路搜索树,由 Rudolf Bayer 和 Ed McCreight 于 1972 年提出。B-Tree 中的 "B" 代表 "Balanced"(平衡),而不是 "Binary"(二叉)。

B-Tree 的设计目标是:最小化磁盘 I/O 次数。在数据库场景中,数据量大无法全部加载到内存,需要频繁进行磁盘读写,B-Tree 通过减少树的高度来降低磁盘访问次数。

什么是 B+Tree

B+Tree 是 B-Tree 的变体,也是目前数据库索引最常用的数据结构。MySQL 的 InnoDB 和 MyISAM 引擎都使用 B+Tree 作为索引结构。

B+Tree 相比 B-Tree 的主要改进:

  • 所有数据记录都存储在叶子节点
  • 非叶子节点只存储索引键值,不存储实际数据
  • 叶子节点之间通过双向链表连接

为什么选择 B+Tree

数据结构查找复杂度磁盘 I/O适用场景
二叉树O(log n)较高内存数据
红黑树O(log n)较高内存数据
哈希表O(1)不支持范围查询精确查找
B-TreeO(log n)文件系统
B+TreeO(log n)最低数据库索引

MySQL 选择 B+Tree 的原因:

  1. 树的高度低:减少磁盘 I/O 次数
  2. 范围查询高效:叶子节点有序连接,适合范围扫描
  3. 节点利用率高:非叶子节点只存键值,能容纳更多索引项
  4. 查询性能稳定:所有查询都需要走到叶子节点

B-Tree 原理

B-Tree 的结构

B-Tree 是一棵多路平衡查找树,其结构特点如下:

B-Tree结构

B-Tree 的性质

一棵 m 阶 B-Tree 必须满足以下性质:

  1. 节点键值数量限制

    • 根节点:至少有 1 个键,最多有 2m - 1 个键
    • 非根节点:至少有 m - 1 个键,最多有 2m - 1 个键
  2. 子树数量限制

    • 每个节点最多有 2m 个子节点
    • 每个非叶子节点(除根外)至少有 m 个子节点
  3. 键值有序

    • 所有节点的键值按升序排列
    • 每个节点的键值将子树分为若干区间
  4. 叶子节点在同一层

    • 所有叶子节点都在同一层,树的高度平衡
  5. 键值与子树的关系

    • 对于节点中的任意键值 K[i]:
      • K[0] < K[1] < K[2] < ... < K[n-1]
      • 子树 P[0] 中所有键 < K[0]
      • 子树 P[i] 中所有键:K[i-1] < 键值 < K[i]
      • 子树 P[n] 中所有键 > K[n-1]

B-Tree 节点结构

1class BTreeNode:
2    def __init__(self, m: int, is_leaf: bool = False):
3        self.num_keys: int = 0              # 当前键值数量
4        self.is_leaf: bool = is_leaf        # 是否为叶子节点
5        self.keys: List[int] = [0] * (2 * m - 1)      # 键值数组
6        self.children: List['BTreeNode'] = [None] * (2 * m)  # 子节点指针数组

B-Tree 查找操作

算法描述:

  1. 从根节点开始
  2. 在当前节点中二分查找目标键
  3. 如果找到,返回
  4. 如果没找到且当前节点不是叶子节点,根据键值大小选择合适的子节点继续查找
  5. 重复步骤 2-4,直到找到或到达叶子节点

时间复杂度: O(log n)

查找示例:

假设要查找键值 25:

B-Tree查找示例

查找过程:

  1. 在根节点 [15, 30, 45] 中查找,25 > 15 且 25 < 30
  2. 转到第二个子节点 [20, 25]
  3. 在该节点中找到 25,查找成功

B-Tree 插入操作

插入算法:

  1. 查找插入位置,定位到叶子节点
  2. 如果叶子节点未满,直接插入
  3. 如果叶子节点已满(键值数达到 2m - 1):
    • 将节点分裂为两个节点
    • 中间键值上移到父节点
    • 如果父节点也满,递归向上分裂

插入示例(m = 2):

B-Tree插入示例

B-Tree 删除操作

删除算法:

  1. 查找要删除的键
  2. 情况1:在叶子节点删除
    • 删除后节点键值数 >= m - 1,直接删除
    • 删除后节点键值数 < m - 1(下溢):
      • 从兄弟节点借一个键
      • 兄弟节点也不足,合并节点
  3. 情况2:在内部节点删除
    • 找到前驱或后继键替换
    • 递归删除前驱或后继

删除示例:

B-Tree删除示例

B+Tree 原理

B+Tree 的结构

B+Tree 是 B-Tree 的改进版本,其结构特点如下:

B+Tree结构

B+Tree 的性质

一棵 m 阶 B+Tree 必须满足以下性质:

  1. 节点键值数量限制

    • 根节点:至少有 1 个键,最多有 m 个键
    • 非根节点:至少有 ⌈m/2⌉ 个键,最多有 m 个键
  2. 子树数量限制

    • 每个节点最多有 m 个子节点
    • 每个非叶子节点(除根外)至少有 ⌈m/2⌉ 个子节点
  3. 数据存储位置

    • 所有的数据记录都存储在叶子节点
    • 非叶子节点只存储索引键值和子树指针
  4. 叶子节点连接

    • 叶子节点之间通过双向链表连接
    • 所有叶子节点按键值升序排列
  5. 键值重复

    • 非叶子节点的键值会在子节点中重复出现
    • 这是与 B-Tree 的重要区别

B+Tree 节点结构

 1# 内部节点(非叶子节点)
 2class BPlusTreeInternalNode:
 3    def __init__(self, m: int):
 4        self.num_keys: int = 0              # 键值数量
 5        self.keys: List[int] = [0] * m     # 键值数组
 6        self.children: List[BPlusTreeNode] = [None] * (m + 1)  # 子节点指针
 7
 8# 叶子节点
 9class BPlusTreeLeafNode:
10    def __init__(self, m: int):
11        self.num_keys: int = 0              # 键值数量
12        self.keys: List[int] = [0] * m     # 键值数组
13        self.data: List[Any] = [None] * m  # 数据指针(指向实际数据)
14        self.prev: BPlusTreeLeafNode = None  # 前驱节点指针
15        self.next: BPlusTreeLeafNode = None  # 后继节点指针

B+Tree 查找操作

算法描述:

  1. 从根节点开始
  2. 在当前节点中查找目标键
  3. 如果当前节点是叶子节点,检查键是否存在
  4. 如果当前节点不是叶子节点,根据键值选择子节点继续查找
  5. 重复步骤 2-4,直到到达叶子节点

重要特点: 无论是否找到数据,都必须走到叶子节点,因为数据只存储在叶子节点中。

时间复杂度: O(log n)

B+Tree 插入操作

插入算法:

  1. 从根节点开始,找到合适的叶子节点
  2. 在叶子节点中插入键和数据
  3. 如果叶子节点满了(键值数达到 m):
    • 将叶子节点分裂为两个
    • 中间键值(分裂后的第一个键)上移到父节点
    • 更新叶子节点的链表指针
  4. 如果父节点也满了,递归向上分裂

插入示例(m = 3):

B+Tree插入示例

B+Tree 删除操作

删除算法:

  1. 找到目标键所在的叶子节点
  2. 删除键和数据
  3. 检查叶子节点状态
    • 键值数 >= ⌈m/2⌉:删除完成
    • 键值数 < ⌈m/2⌉(下溢):
      • 尝试从兄弟节点借一个键
      • 兄弟节点也不足,与兄弟节点合并
      • 合并后可能导致父节点键值不足,递归处理

删除示例:

B+Tree删除示例

B-Tree vs B+Tree

结构对比

特性B-TreeB+Tree
数据存储所有节点都可存储数据数据只在叶子节点
非叶子节点存储键和数据只存储键
叶子节点无特殊连接双向链表连接
范围查询需要中序遍历直接遍历叶子链表
树的高度相对较高相对较低

性能对比

查询性能

场景B-TreeB+Tree
精确查询可能更早找到必须到叶子节点
范围查询需要回溯直接遍历链表
查询稳定性不稳定稳定

为什么 B+Tree 在数据库中表现更好?

  1. 磁盘 I/O 更少:非叶子节点不存数据,能容纳更多键,树更矮
  2. 范围查询高效:叶子节点有序连接,一次扫描即可
  3. 查询性能稳定:所有查询都走相同路径

示例:范围查询

查找键值在 [10, 30] 之间的数据:

B-Tree:

B-Tree查找

B+Tree:

B+Tree查找

存储空间对比

假设每个节点能存储的键数量相同:

数据结构非叶子节点叶子节点总数据量
B-Tree键 + 数据键 + 数据相同
B+Tree只存键键 + 数据相同

虽然存储的数据总量相同,但 B+Tree 的非叶子节点不存数据,能存储更多键,从而降低树的高度。

实际影响:

假设每页 16KB,每条记录 100 字节:

  • B-Tree 非叶子节点:约 160 条(键+指针)
  • B+Tree 非叶子节点:约 320 条(只有键+指针)

存储 1 亿条数据:

  • B-Tree 高度:约 4-5 层
  • B+Tree 高度:约 3-4 层

MySQL InnoDB 中的 B+Tree

InnoDB 索引结构

InnoDB 引擎使用 B+Tree 作为索引结构,分为两种类型:

B+Tree类型

聚簇索引

定义: 聚簇索引是根据主键构建的 B+Tree,叶子节点存储完整的行数据。

特点:

  • 每个表只能有一个聚簇索引
  • 主键索引就是聚簇索引
  • 数据文件本身就是索引文件
  • 范围查询非常高效

查询示例:

1-- 使用聚簇索引查询
2SELECT * FROM user WHERE id = 100;
3
4-- 查询路径:根节点 → 内部节点 → 叶子节点(找到完整数据)

二级索引

定义: 二级索引是除主键索引外的其他索引,叶子节点存储索引键值和主键值。

特点:

  • 一个表可以有多个二级索引
  • 查询时需要先查二级索引,再通过主键回表
  • 联合索引也是二级索引的一种

回表查询:

1-- 假设 name 有二级索引
2SELECT * FROM user WHERE name = 'Tom';
3
4-- 查询路径:
5-- 1. 在 name 的二级索引 B+Tree 中找到 'Tom'
6-- 2. 获取对应的主键 id = 100
7-- 3. 通过 id 在聚簇索引中查询完整数据(回表)

覆盖索引优化:

如果查询的列都包含在索引中,可以避免回表:

1-- 假设有联合索引 (name, age)
2SELECT name, age FROM user WHERE name = 'Tom';
3
4-- 无需回表,直接从二级索引获取数据

InnoDB B+Tree 参数

InnoDB 的 B+Tree 结构由以下参数控制:

参数默认值说明
innodb_page_size16KB数据页大小
innodb_buffer_pool_size128MB缓冲池大小
innodb_fill_factor100页填充因子

页面大小的影响:

1-- 查看页面大小
2SHOW VARIABLES LIKE 'innodb_page_size';
  • 页面越大:每页能存储更多索引项,树的高度更低
  • 页面越小:并发写入冲突更少,但树的高度更高

InnoDB B+Tree 实际结构

1-- 创建测试表
2CREATE TABLE user (
3    id INT PRIMARY KEY,
4    name VARCHAR(50),
5    age INT,
6    email VARCHAR(100)
7);

索引结构

B+Tree 的应用场景

1. 主键索引

 1-- 最常见的应用
 2CREATE TABLE orders (
 3    id BIGINT PRIMARY KEY,
 4    order_no VARCHAR(32),
 5    user_id BIGINT,
 6    amount DECIMAL(10,2),
 7    created_at TIMESTAMP
 8);
 9
10-- 查询利用聚簇索引
11SELECT * FROM orders WHERE id = 123456;

2. 二级索引

1-- 为常用查询条件创建索引
2CREATE INDEX idx_user_id ON orders(user_id);
3CREATE INDEX idx_created_at ON orders(created_at);
4
5-- 联合索引(最左前缀原则)
6CREATE INDEX idx_user_created ON orders(user_id, created_at);

3. 联合索引

联合索引的 B+Tree 按照索引定义的顺序组织:

1-- 联合索引 (user_id, created_at)
2CREATE INDEX idx_user_created ON orders(user_id, created_at);
3
4-- ✅ 可以使用索引
5SELECT * FROM orders WHERE user_id = 100;
6SELECT * FROM orders WHERE user_id = 100 AND created_at > '2024-01-01';
7
8-- ❌ 不能使用索引(违反最左前缀)
9SELECT * FROM orders WHERE created_at > '2024-01-01';

联合索引结构:

联合索引结构

4. 前缀索引

对于长字符串字段,可以使用前缀索引节省空间:

1-- 对 email 创建前缀索引
2CREATE INDEX idx_email_prefix ON orders(email(20));
3
4-- 对备注字段创建前缀索引
5CREATE INDEX idx_comment_prefix ON article(comment(100));

注意: 前缀索引无法用于覆盖索引和排序。

性能优化

1. 选择合适的索引

索引不是越多越好,每个索引都会增加写入开销。根据查询模式选择合适的索引。
1-- ✅ 好的索引:高频查询条件
2CREATE INDEX idx_user_status ON user(user_id, status);
3
4-- ❌ 不好的索引:低区分度的字段
5CREATE INDEX idx_gender ON user(gender);  -- gender 只有男/女,区分度低
6
7-- ❌ 冗余索引
8CREATE INDEX idx_a ON table(a);
9CREATE INDEX idx_a_b ON table(a, b);  -- idx_a 可以被 idx_a_b 覆盖

2. 利用覆盖索引

1-- 假设有联合索引 (user_id, status, created_at)
2
3-- ✅ 覆盖索引,无需回表
4SELECT user_id, status, created_at
5FROM orders
6WHERE user_id = 100;
7
8-- ❌ 需要回表
9SELECT * FROM orders WHERE user_id = 100;

3. 避免索引失效

 1-- ✅ 可以使用索引
 2SELECT * FROM orders WHERE user_id = 100;
 3SELECT * FROM orders WHERE user_id > 100;
 4
 5-- ❌ 索引失效:对字段进行函数运算
 6SELECT * FROM orders WHERE UPPER(user_name) = 'TOM';
 7
 8-- ❌ 索引失效:类型转换
 9SELECT * FROM orders WHERE user_id = '100';  -- 字符串转数字
10
11-- ✅ 解决方案:使用函数索引或正确类型
12SELECT * FROM orders WHERE user_id = 100;

4. 索引优化技巧

避免在索引列上使用运算:

1-- ❌ 索引失效
2SELECT * FROM orders WHERE user_id + 1 = 101;
3
4-- ✅ 优化后
5SELECT * FROM orders WHERE user_id = 100;

使用正确的比较运算符:

1-- ✅ 使用索引
2WHERE user_id IN (1, 2, 3)
3
4-- ❌ 可能不使用索引(取决于优化器)
5WHERE user_id NOT IN (1, 2, 3)

常见问题

Q1: 为什么不用 Hash 索引?

Hash 索引只支持精确查找,不支持范围查询:

1-- ✅ Hash 索引支持
2SELECT * FROM user WHERE id = 100;
3
4-- ❌ Hash 索引不支持
5SELECT * FROM user WHERE id BETWEEN 100 AND 200;
6SELECT * FROM user WHERE id > 100;
7SELECT * FROM user ORDER BY id;

Q2: 为什么不用二叉树?

二叉树的问题:

  1. 树的高度高:n 个数据需要 log₂n 层
  2. 每层访问一次磁盘:磁盘 I/O 次数多
  3. 缓存局部性差:节点分散在磁盘不同位置

对比:

数据结构1亿条数据的高度
二叉树约 27 层
B+Tree约 3-4 层

Q3: 主键选择对性能的影响

 1-- ❌ 不好的主键:长字符串
 2CREATE TABLE order_log (
 3    order_no VARCHAR(32) PRIMARY KEY,  -- 主键太长,B+Tree 深度高
 4    content TEXT
 5);
 6
 7-- ✅ 好的主键:自增整数
 8CREATE TABLE order_log (
 9    id BIGINT AUTO_INCREMENT PRIMARY KEY,
10    order_no VARCHAR(32),
11    content TEXT
12);

主键选择原则:

  1. 长度小:减少 B+Tree 深度
  2. 自增:减少页分裂
  3. 稳定:避免频繁更新

Q4: 如何查看索引使用情况?

1-- 查看表的索引
2SHOW INDEX FROM user;
3
4-- 使用 EXPLAIN 分析查询执行计划
5EXPLAIN SELECT * FROM user WHERE id = 100;
6
7-- 查看索引使用统计(MySQL 8.0+)
8SELECT * FROM performance_schema.table_io_waits_summary_by_index_usage
9WHERE OBJECT_SCHEMA = 'your_database';

Q5: 什么情况下索引会失效?

场景索引是否使用
WHERE id = 1✅ 使用
WHERE id + 1 = 2❌ 失效
WHERE UPPER(name) = 'A'❌ 失效
WHERE name LIKE 'A%'✅ 使用(前缀匹配)
WHERE name LIKE '%A'❌ 失效(后缀匹配)
WHERE name LIKE '%A%'❌ 失效(全匹配)
WHERE id IS NULL✅ 使用
WHERE id IN (1, 2)✅ 使用
ORDER BY id✅ 使用

总结

B+Tree 是 MySQL 数据库索引的核心数据结构,理解它的工作原理对于数据库性能优化至关重要。

核心要点:

B-Tree 特点:

  • 多路平衡查找树
  • 所有节点都可存储数据
  • 非叶子节点既有键也有数据

B+Tree 特点:

  • 数据只在叶子节点
  • 叶子节点通过双向链表连接
  • 非叶子节点只存键
  • 范围查询高效

MySQL InnoDB 索引:

  • 聚簇索引:主键索引,叶子节点存储完整行数据
  • 二级索引:非主键索引,叶子节点存储键和主键
  • 查询时可能需要回表

优化建议:

  1. 合理选择索引字段,避免冗余
  2. 利用覆盖索引减少回表
  3. 避免在索引列上进行函数运算
  4. 选择合适的主键类型和长度
  5. 定期分析索引使用情况,删除无用索引

理解 B+Tree 的工作原理,可以帮助我们更好地设计数据库结构,优化查询性能,构建高效的应用系统。

参考资源