一、为什么数据库索引选择 B+ 树?

数据库索引的核心矛盾在于:数据在磁盘上,查询在内存中。磁盘 IO 是数据库最大的性能瓶颈,一次磁盘随机读取耗时约 10ms,而内存访问仅需 100ns,相差 10 万倍。因此,数据库索引数据结构的首要目标是最小化磁盘 IO 次数。

1.1 二叉搜索树的致命缺陷

在内存中,二叉搜索树(BST)查询复杂度为 O(log n),表现优秀。但当数据存储在磁盘上时:

  • 每个节点只存一个 key,树的高度为 log₂(n),在 100 万条记录时约为 20 层
  • 但每次节点访问都可能触发一次磁盘 IO(因为节点大小远小于磁盘页)
  • 最坏情况下退化为链表,查询复杂度 O(n)

因此需要一种降低树高 + 匹配磁盘存取单位的数据结构,B+ 树完美解决了这个问题。

1.2 B 树 vs B+ 树:MySQL 的选择

特性B 树B+ 树
数据存储位置所有节点均可存储数据仅叶子节点存储数据
叶子节点连接无双向链表
范围查询需要中序遍历整棵树只需定位起点后顺序扫描
单点查询复杂度不稳定(可能在非叶子节点命中)稳定 O(log n)(必须到叶子)
每层容纳 key 数较少(节点内包含数据指针)较多(节点内仅存 key)

InnoDB 选择 B+ 树的核心原因:范围查询友好 + 树高更低 + 查询性能稳定。

二、B+ 树结构详解

2.1 核心定义

B+ 树的阶数为 m(即每个节点最多 m 个子节点),满足以下约束:

  • 根节点至少有 2 个子节点(除非整棵树只有一个节点)
  • 非根非叶子节点至少有 ⌈m/2⌉ 个子节点
  • 所有叶子节点在同一层(B+ 树是平衡树)
  • 非叶子节点仅存储索引 key,不存储数据行
  • 叶子节点存储完整的数据行(聚簇索引)或主键值(辅助索引)
  • 叶子节点之间通过双向链表串联

2.2 InnoDB 的页结构

InnoDB 以 页(Page) 为磁盘交互的基本单位,默认页大小为 16KB。B+ 树的每个节点对应一页。一页的空间分配:

+-----------------------+
| File Header (38B)     | ← 页的元数据信息
+-----------------------+
| Page Header (56B)     | ← 页内记录数、空闲空间指针等
+-----------------------+
| Infimum + Supremum    | ← 虚拟最小/最大边界记录
+-----------------------+
| User Records          | ← 实际存储的数据行
+-----------------------+
| Free Space            | ← 剩余空闲空间
+-----------------------+
| Page Directory        | ← 页目录(稀疏索引加速页内查找)
+-----------------------+
| File Trailer (8B)     | ← 校验和,保证页完整性
+-----------------------+

2.3 一个 B+ 树节点能存多少 key?

估算 InnoDB B+ 树扇出(fan-out):

  • 假设主键为 BIGINT(8B),指针 6B,每页 16KB
  • 一个非叶子节点能存储的 key 数 = 16384 / (8 + 6) ≈ 1170 个
  • 两层 B+ 树:1170 × 1170 ≈ 136 万条记录(仅索引层)
  • 三层 B+ 树:1170³ ≈ 16 亿条记录

结论:单表 10 亿条记录,B+ 树高度仅为 3,最多 3 次磁盘 IO 即可定位任意行。这也是 MySQL 建议单表不超过 2000 万行的根本原因之一——4 层 B+ 树意味着 4 次 IO,性能开始显著下降。

三、聚簇索引 vs 辅助索引

3.1 聚簇索引(Clustered Index)

InnoDB 表是索引组织表(Index Organized Table),数据行直接存储在 B+ 树的叶子节点中:

                [10]        [30]        [50]
               /    \      /    \      /    \
     [3][7][10]  [15][20][30] [35][40][50] [60][70][80]
     
     ↑ 非叶子节点:仅存 key,用于路由
     ↑ 叶子节点:存储完整数据行(id, name, age, email, ...)
     → 叶子节点之间有双向链表连接
  • 每个表只能有一个聚簇索引(因为数据只能按一种物理顺序排列)
  • 通常是主键(PRIMARY KEY),若无主键则用第一个 UNIQUE NOT NULL 索引
  • 若都没有,InnoDB 会创建隐藏的 rowid 列作为聚簇索引
  • 通过聚簇索引查找:B+ 树搜索后直接拿到完整行数据,一次 IO

3.2 辅助索引(Secondary Index)

辅助索引(也称非聚簇索引)的叶子节点存储的不是完整数据行,而是主键值:

-- 创建辅助索引
ALTER TABLE users ADD INDEX idx_age (age);

-- 辅助索引 B+ 树结构(age 列):
                [25]         [50]
               /    \       /    \
     [18][22][25] [30][35][50] [60][70]
     
     叶子节点存储: (age=18, primary_key=5)
                   (age=22, primary_key=12)
                   (age=25, primary_key=3)
                   ...

通过辅助索引查询时,过程为:

  1. 扫描辅助索引 B+ 树,找到对应叶子节点,获取主键值(如 id=12)
  2. 用主键值再去聚簇索引 B+ 树中查找完整行数据

这个过程叫做回表(Bookmark Lookup),额外增加了一次 B+ 树搜索。如果查询所需的列全部在辅助索引中(覆盖索引),则无需回表。

四、B+ 树操作算法

4.1 查找操作


算法:BPlusTreeSearch(node, key)
1. 从根节点开始
2. 在当前节点中对 key 进行二分查找,找到第一个大于等于目标 key 的位置
3. 如果当前节点是叶子节点:
     - 在该位置匹配到 → 返回记录
     - 未匹配到 → 返回 NULL
4. 如果当前节点是非叶子节点:
     - 进入对应子节点,重复步骤 2

时间复杂度: O(log_m N),其中 m 为阶数,N 为总记录数
IO 复杂度: O(h),h 为树高

4.2 插入操作


算法:BPlusTreeInsert(key, value)
1. 从根节点出发,找到 key 应插入的叶子节点 L
2. 将 (key, value) 插入到 L 中的正确位置(保持有序)
3. 如果 L 的 key 数量未超过 m-1 → 完成。O(1) 额外开销。
4. 如果 L 溢出(key 数量 = m):
     a. 将 L 分裂为 L 和 L2
     b. 将 L 的后半部分 key 移到 L2
     c. 将 L2 的中间 key 提升到父节点
     d. 将 L2 插入到父节点的子节点列表中
     e. 如果父节点也溢出 → 递归向上分裂
     f. 如果根节点溢出 → 创建新根,树高+1

注意:InnoDB 对顺序插入做了优化,使用插入缓冲(Change Buffer)减少辅助索引的分裂 IO 开销。

4.3 删除操作


算法:BPlusTreeDelete(key)
1. 从根节点出发,找到 key 所在的叶子节点 L
2. 从 L 中删除该记录
3. 如果 L 的 key 数量 >= ⌈m/2⌉ - 1 → 完成。NODE 仍满足最小填充度。
4. 如果 L 下溢(key 数量 < ⌈m/2⌉ - 1):
     a. 尝试从兄弟节点借一个记录(再分配)
     b. 如果兄弟节点也不足 → 与兄弟节点合并
     c. 合并后父节点相应 key 删除,可能递归下溢
     d. 如果根节点变空 → 树高-1

五、B+ 树的 SQL 优化实践

5.1 覆盖索引(Covering Index)

如果查询所需的所有列都包含在索引中,InnoDB 无需回表即可返回结果:

-- 索引: idx_age_name(age, name)
-- 查询只需 age 和 name → 覆盖索引,无需回表
EXPLAIN SELECT age, name FROM users WHERE age BETWEEN 20 AND 30;

-- Extra 列显示 "Using index" 表示使用了覆盖索引

实战技巧:对高频查询,即使查询列较多,也可以为 select 列建立联合索引避免回表。但需注意,索引列越多,维护成本越高,占用空间越大。

5.2 最左前缀匹配(Leftmost Prefix)

联合索引 (a, b, c) 遵循最左前缀原则:

-- 索引: idx_abc(a, b, c)

-- ✅ 可以使用索引:
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
WHERE a = 1 AND c = 3        -- 仅使用 a 列部分

-- ❌ 无法使用索引:
WHERE b = 2                   -- 不满足最左前缀
WHERE c = 3                   -- 不满足最左前缀
WHERE b = 2 AND c = 3          -- 不满足最左前缀

-- ⚠️ 范围查询右侧列失效:
WHERE a = 1 AND b > 2 AND c = 3
-- a 精确匹配,b 范围查询可以走索引,但 c 无法使用索引(因为 b 是非等值匹配后无序)

5.3 索引下推(Index Condition Pushdown, ICP)

MySQL 5.6 引入 ICP 优化,将 WHERE 条件下推到存储引擎层过滤,减少回表次数:

-- 索引: idx_name_age(name, age)
SELECT * FROM users WHERE name LIKE '张%' AND age = 25;

-- 无 ICP:找到所有 name LIKE '张%' 的记录,全部回表,再过滤 age=25
-- 有 ICP:在索引层面直接过滤 age=25,只对匹配的记录回表

5.4 EXPLAIN 执行计划解读

EXPLAIN SELECT u.name, o.amount 
FROM users u 
JOIN orders o ON u.id = o.user_id 
WHERE u.age > 25 AND o.status = 1
ORDER BY o.create_time DESC 
LIMIT 10;

关键字段解析:

字段含义调优关注点
type访问类型目标至少 range,避免 ALL(全表扫描)
key实际使用的索引确认走了最优索引
key_len索引使用的字节数判断联合索引使用了多少列
rows预估扫描行数越少越好
Extra额外信息警惕 Using filesort、Using temporary

六、B+ 树的工程陷阱

6.1 隐式类型转换导致索引失效

-- phone 列为 VARCHAR 类型,建了索引
-- 传入整数触发隐式转换,索引失效!
SELECT * FROM users WHERE phone = 13800138000;  -- ❌ 全表扫描
SELECT * FROM users WHERE phone = '13800138000'; -- ✅ 走索引

6.2 函数操作导致索引失效

-- 对索引列使用函数,B+ 树无法匹配
SELECT * FROM users WHERE YEAR(create_time) = 2024;  -- ❌
-- 改写为范围查询
SELECT * FROM users WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'; -- ✅

6.3 页分裂与碎片化

当向已满的 B+ 页插入数据时发生页分裂(Page Split),对于自增主键,新数据总在右侧追加,分裂代价小;而对于 UUID 等随机主键,插入位置随机,频繁分裂导致页填充率低、碎片多。这也是 推荐使用自增主键 的根本原因。

-- 查看表的碎片情况
SELECT TABLE_NAME, DATA_LENGTH, INDEX_LENGTH, DATA_FREE 
FROM information_schema.TABLES 
WHERE TABLE_SCHEMA = 'your_db' AND TABLE_NAME = 'your_table';
-- DATA_FREE 不为 0 表示存在碎片

-- 回收碎片
OPTIMIZE TABLE your_table;

6.4 Count(*) 的性能迷思

-- MyISAM 直接返回(存在元数据中),O(1)
-- InnoDB 逐行扫描计数,O(n) -- 因为 MVCC 需要判断可见性

-- 替代方案:近似值(误差约 3%-10%)
SELECT TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_NAME = 'orders';

-- 或维护计数表(触发器或应用层维护)

七、高级话题

7.1 Change Buffer:减少辅助索引的随机 IO

InnoDB 的 Change Buffer(类似 MyISAM 的 Key Buffer)缓存对辅助页的写操作。当辅助页不在内存中时,写操作先记入 Change Buffer,等页被读入内存后再合并(Merge),将多次随机 IO 合并为一次顺序 IO。对于写多读少场景,这个优化效果显著。

7.2 自适应哈希索引(Adaptive Hash Index, AHI)

InnoDB 会自动为频繁访问的热点页建立哈希索引,将 B+ 树的 O(log n) 查询降为 O(1)。不需要 DBA 手动维护,完全由 InnoDB 引擎层控制。但极高并发下 AHI 的互斥锁可能成为瓶颈,可通过 innodb_adaptive_hash_index=OFF 关闭。

7.3 Buffer Pool 与 B+ 树的协同

InnoDB 的 Buffer Pool 是 B+ 树性能的基石:

  • 热点数据页(B+ 树节点)常驻内存,IO 降为 0
  • 采用 LRU-K 变体算法(年轻的 sublist + 成熟的 sublist)避免全表扫描污染缓存
  • Checkpoint 机制保证脏页刷盘,崩溃恢复时通过 Redo Log 重建
  • 合理的 innodb_buffer_pool_size 设置(通常为物理内存的 70-80%)对性能至关重要

八、总结

要点关键认知
B+ 树为什么快高扇出 → 低树高 → 少 IO(3 次 IO 查 10 亿条记录)
聚簇索引主键即数据排列顺序,表即索引,索引即表
辅助索引存主键值,需要回表,覆盖索引可消除回表
索引设计原则区分度高的列建联合索引,遵循最左前缀,避免函数操作
主键选择自增 BIGINT > UUID(避免页分裂 & 碎片)
查询优化Explain 看懂 type/key/Extra,消除全表扫描和文件排序
写入优化Change Buffer + Buffer Pool 是写入性能的隐形加速器

B+ 树作为数据库索引的基石数据结构,理解其原理不仅有助于写出高性能 SQL,更是深入理解 MySQL InnoDB 存储引擎的一把钥匙。从磁盘页结构到节点分裂合并,从聚簇索引到 Change Buffer,每一层优化都在为同一个目标服务:让尽可能多的查询在内存中命中,尽可能少的查询触及磁盘。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部