3. 索引基础:B+树与索引结构

聊查询优化,绕不开索引。但索引到底是个什么东西?

很多人背过“索引是一种数据结构”,可真问起来,脑子里还是模模糊糊的。我个人的习惯是——**把索引理解成书的目录**。你想想,没有目录的书,要找“B+树”这个知识点,得从第一页翻到最后一页,这叫全表扫描。有了目录,直接在目录里定位到第 300 页,翻过去就是。效率差异就在这里。

3.1 为什么是 B+树

数据库底层的数据结构不止一种,哈希表、二叉树、B 树、B+树,选择很多。但主流关系型数据库的索引,几乎都选了 B+树。为什么?

我来简单对比下。哈希表快,O(1) 的查找速度,但只适合等值查询。你做个范围查询,比如 WHERE age BETWEEN 20 AND 30,哈希表就傻了,得全扫。二叉树呢?数据量一大,树的高度就失控了,磁盘 I/O 次数直线上升。B 树虽然做了改良,每个节点存多个值,但它的非叶子节点也存数据,同样一个节点能存的索引项就少了。

B+树不一样。它做了两件事:

  • 非叶子节点只存索引键,不存数据,所以一个节点能容纳的索引项会更多,树的高度更低。
  • 叶子节点存完整数据(或者说指向数据的指针),并且用双向链表串起来。这样范围查询就变成了链表遍历,效率极高。

我在项目中遇到过类似情况。一张表几千万数据,用 SELECT * FROM orders WHERE create_time >= '2024-01-01' AND create_time < '2024-02-01' 查出来上万条记录。如果是 B 树,你要在多个节点间来回跳。换成 B+树,定位到第一个满足条件的叶子节点之后,顺着链表往后读就行了。这个差异,在真实的大表查询里非常明显。

一句话总结:B+树就是用空间换时间,同时把磁盘 I/O 降到了最低。

3.2 B+树的结构拆解

我从两个角度来拆解 B+树的结构:从上到下,从左到右。

3.2.1 根节点、内部节点、叶子节点

一棵 B+树通常有三层结构。我画个简单的示意图帮你理解。

小提示: 实际生产环境中,三层 B+树就能支撑几千万甚至上亿条数据。树的高度基本都在 3-4 层。
B+树索引结构示意 根节点 内部节点 内部节点 内部节点 叶子节点 叶子节点 叶子节点 叶子节点 ❖ 非叶子节点只存索引键,不存数据,让树更矮 ❖ 叶子节点存完整数据,并用双向链表连接,支持高效范围查询

3.2.2 一个关键细节:Page(页)

数据库的最小存储单位不是行,而是 Page(页)。MySQL InnoDB 默认的页大小是 16KB。一个 Page 里能装多少行数据,决定了你的索引效率。

来个简单的计算。假设你的索引键是 bigint(8 字节),加上指向儿子的指针(6 字节),一共 14 字节。那么一个 16KB 的 Page 能放多少个索引项?

16 * 1024 / 14 ≈ 1170 个

三层 B+树,第二层和第三层还是同样的结构。

1170 * 1170 * 16 ≈ 2190 万行

看懂了吗?只需要三次磁盘 I/O,就能从 2000 万行数据中找到你要的那条记录。这就是 B+树的魔力。

3.3 聚簇索引与非聚簇索引

聊 B+树,不能不提聚簇索引和非聚簇索引。很多新手在这里栽过跟头。

3.3.1 聚簇索引

InnoDB 中,聚簇索引就是主键索引。它的叶子节点直接存了一整行数据。换句话说,数据和索引是“捆在一起”的。所以当你通过主键去查数据时,一次索引就能拿到所有字段,不需要回表。

我个人建议:

  • 建表一定要显式指定主键,而且要自增。否则 InnoDB 会帮你偷偷建一个隐藏的 ROW_ID。
  • 主键不要用 UUID。UUID 是随机的,插入时会导致页分裂和索引重排,性能会断崖式下降。

3.3.2 非聚簇索引(二级索引)

非聚簇索引的叶子节点存的不再是完整数据,而是 主键值。所以当你通过二级索引查询时,实际上有两步:

  1. 先扫描二级索引的 B+树,找到对应的主键。
  2. 再拿着主键去聚簇索引里回表查完整数据。

这一步如果走多了,就叫“回表开销”。

避坑指南: 我曾经碰到过一个案例,开发者在 user_name 字段上建了索引,但 SELECT 选了所有列(SELECT *)。每次查询都要回表。用户表有 500 万行,一个简单的等值查询就要几十毫秒。后来我建议改成覆盖索引,把需要查询的字段都塞进索引里,延迟直接降到了 2-3 毫秒。

3.4 索引的优缺点一览

维度 优点 缺点/代价
查询速度 大幅减少磁盘 I/O,范围查询极快 索引本身占用磁盘空间
写操作 合适的索引能加速 UPDATE 的 WHERE 条件 每次 INSERT/UPDATE/DELETE 都要维护索引树,降低写入性能
排序 索引本身就是有序的,可以避免文件排序(Using filesort) ——
内存 常被访问的索引会缓存在 Buffer Pool 中 索引过多会挤占数据缓存空间

3.5 讲点实在的:怎么用好 B+树

理论讲完了,落地才是关键。我个人总结了三条原则:

  • 最左前缀原则。联合索引 (a, b, c) 只有在查询条件里带上 a 的时候才会生效。跳过了 a,只查 b 和 c,索引就废了。
  • 尽量用覆盖索引。SELECT 的字段如果能全部命中索引,就不用回表,IO 少一半。
  • 注意索引列上的函数操作。比如 WHERE DATE(create_time) = '2024-01-01',这种情况下索引会失效,因为函数把原始值改了。
一点小经验: 我习惯在写完一条 SQL 之后,用 EXPLAIN 看一眼,确认 type 列是不是 ref 或 range,Extra 列有没有 Using index。看到 Using filesort 或 Using temporary 就要警惕了,十有八九是索引没用好。

B+树这个东西,初看觉得抽象,用多了就发现它其实很“直觉”。你把一棵树的高度压到 3 层,把查询从全表扫描降成三次 IO,剩下的优化空间就出来了。