深色模式
01 · 索引与 B+ 树
数据库最核心的一篇。懂了这篇,80% 的 SQL 优化题都能推出来。
一句话本质
索引是为了减少磁盘 IO 次数而额外维护的一份有序结构——用"多占空间 + 写变慢",换"读变快"。
生活类比:图书馆的索引柜
没有索引:你要找"作者是余华的书",只能从第一个书架翻到最后一个。100 万本书,翻到天黑。
有索引:索引柜里有一叠卡片,按作者名排好序,每张卡片写着"余华 → 3 排 2 号架"。
- 找"余华":直接在卡片里二分,几秒定位 → 快
- 但每次进新书,都要多做一张卡片 → 写变慢
- 卡片柜本身占地方 → 多占空间
这就是索引的全部本质。所有细节都是这句话的展开。
核心机制
一、为什么是 B+ 树(把三个候选都比一遍)
核心约束:磁盘 IO 次数 = 树高。一次 IO 约 10ms(机械盘),必须把树高压到最低。
候选 1:哈希表
✅ 等值查询 O(1)
❌ 不支持范围查询(age > 20 得全扫)
❌ 不支持排序
❌ 不支持最左前缀
> 淘汰(只能做辅助索引)
候选 2:红黑树(二叉)
1000 万行 > 树高 ≈ log₂(1000万) ≈ 24 层
> 24 次磁盘 IO × 10ms = 240ms
> 淘汰
候选 3:B 树(多叉)
一个节点存几百个键 > 树高 3~4 层
✅ 树高低
❌ 数据存在所有节点上(包括非叶子),一个节点能存的键变少
❌ 范围查询要在树上反复上下跳
候选 4:B+ 树
✅ 数据只在叶子节点,非叶子只存索引 > 一个节点能存更多键 > 树高更低
✅ 叶子节点用双向链表串起来 > 范围查询直接顺序扫,不用回到根
✅ 每次查询都要走到叶子 > IO 次数稳定(B 树可能提前命中,也可能不)
> 胜出二、B+ 树的结构(InnoDB,页大小 16KB)
一个节点就是一页(16KB)。根和分支只存「键 + 子节点指针」,一页能放约 1170 个键,所以树很矮;真正存整行数据的只有叶子,叶子之间用双向链表相连,范围查询顺着链表扫就行,不用回到根。
树高能压到多低(算一下,面试常问):
假设:
页大小 16KB
主键 bigint(8 字节)+ 页指针(6 字节)= 14 字节
一个非叶子节点能存:16KB / 14B ≈ 1170 个键
一行数据约 1KB > 一个叶子节点能存约 16 行
三层 B+ 树能存多少行?
第 1 层(根):1 个节点
第 2 层:1170 个节点
第 3 层(叶子):1170 × 1170 ≈ 137 万个节点
总行数:137 万 × 16 ≈ 2200 万行
结论:2000 万行的表,树高只有 3 层 > 3 次 IO这就是 B+ 树的核心价值:把 24 次 IO 压到 3 次。
三、聚簇索引 vs 二级索引(必考)
聚簇索引(主键索引):
叶子节点直接存"整行数据"
[主键=1 | 整行数据] > [主键=2 | 整行数据] > ...
一张表只有一个聚簇索引(因为数据只能按一种方式物理组织)二级索引(辅助索引):
叶子节点存"索引列 + 主键值"
[name='张三' | 主键=5] > [name='李四' | 主键=8] > ...
可以有多个回表:用二级索引查到主键值后,还要再去聚簇索引查一次完整数据。
SELECT * FROM user WHERE name = '张三';
① 走 name 索引,找到 name='张三' 的记录,拿到主键 id=5
② 拿 id=5 去聚簇索引查,拿到整行
> 两次 B+ 树查找 = 两次(可能更多)IO
这就是"回表",也是"为什么 SELECT * 比 SELECT 具体列慢"的原因之一覆盖索引:如果查询的列全都在索引里,就不用回表。
索引 (name, age)
SELECT name, age FROM user WHERE name = '张三';
> name 和 age 都在索引里,直接返回,不回表 ✅
SELECT name, age, email FROM user WHERE name = '张三';
> email 不在索引里,必须回表 ❌这是最重要的优化手段之一:把常用查询的列做成联合索引,实现"索引覆盖"。
四、最左前缀原则
联合索引 (a, b, c) 的排序方式是:先按 a 排,a 相同再按 b 排,b 相同再按 c 排。
索引里的顺序:
(1,1,1) (1,1,2) (1,2,1) (1,2,2) (2,1,1) (2,1,2) ...
^a ^b ^c所以能用的查询:
| 查询条件 | 能用索引吗 | 用到几列 |
|---|---|---|
a=1 | ✅ | a |
a=1 AND b=2 | ✅ | a, b |
a=1 AND b=2 AND c=3 | ✅ | a, b, c |
b=2 | ❌ | 完全用不了(不知道 a 就没法定位) |
a=1 AND c=3 | ⚠️ | 只用 a,c 用不上(b 断了) |
a>1 AND b=2 | ⚠️ | 只用 a(范围查询后面的列失效) |
**"范围查询后面的列失效"**是高频考点:因为 a > 1 之后,a 有多个值,b 在每个 a 内部有序但整体无序,无法再用 b 做定位。
例外:MySQL 5.6+ 的"索引下推"(ICP)可以在索引上过滤 b,减少回表次数,但仍不能用于定位。
五、索引失效的常见原因
① 对索引列做函数/运算
WHERE YEAR(create_time) = 2026 ❌
WHERE create_time >= '2026-01-01' ✅
② 隐式类型转换
索引是 varchar,却写 WHERE phone = 13800138000(数字) ❌
> 数据库会把每行的 phone 转成数字比较,等于对列做函数
③ 前导模糊匹配
WHERE name LIKE '%三' ❌(无法二分,因为不知道从哪开始)
WHERE name LIKE '张%' ✅
④ OR 连接的列没有都建索引
WHERE a=1 OR b=2 > 如果 b 没索引,全表扫
⑤ 区分度太低
性别字段只有 2 个值 > 索引扫描的行数接近全表,优化器会放弃索引
⑥ 使用了 != 、NOT IN、IS NOT NULL(部分情况)
> 因为要扫描的行太多,优化器判断不如全表扫六、什么时候不该建索引
① 表很小(几百行)> 全表扫更快
② 写多读少 > 每次写都要维护索引
③ 区分度极低 > 索引用不上
④ 频繁更新的列 > 索引维护成本高
⑤ 已经有更合适的联合索引(避免冗余索引)为什么这么设计
反事实 1:如果页大小是 4KB 而不是 16KB
一个节点能存的键少了 4 倍,树高增加。按上面的计算,2000 万行可能要 4 层而不是 3 层——多一次 IO,性能下降 30%+。
16KB 是权衡:太小树高增加,太大一次 IO 读的数据用不完(浪费带宽)、且 Buffer Pool 管理粒度变粗。
反事实 2:如果数据也存在非叶子节点(B 树)
一个非叶子节点原本能存 1170 个键,现在要存"键 + 数据",假设数据 1KB,那一个节点只能存 16 个键——树高会暴增。
B+ 树把数据全部下沉到叶子,正是为了让非叶子节点"尽可能多地存键",从而压低树高。
反事实 3:如果没有索引
1000 万行的表,WHERE id = 123 要全表扫描:读 1000 万行 × 1KB = 10GB 数据。即使顺序读,也要几秒到几十秒。
有了索引:3 次 IO,约 30ms。差了 1000 倍。
面试怎么问
Q:为什么用 B+ 树而不是 B 树?
三点:① 数据只在叶子 > 非叶子能存更多键 > 树高更低;② 叶子有链表 > 范围查询直接顺序扫;③ 每次查询都到叶子 > IO 次数稳定可预测。
Q:为什么不用红黑树/哈希?
红黑树树高太高(24 层 = 24 次 IO);哈希不支持范围查询、排序、最左前缀。
Q:什么是回表?怎么避免?
二级索引叶子只存主键,查其他列要再回聚簇索引查一次。避免方式:覆盖索引(把要查的列都放进索引)。
Q:什么是聚簇索引?
叶子节点存整行数据的索引。一张表只有一个(通常是主键)。InnoDB 必须有聚簇索引——没主键就用唯一非空索引,再没有就生成隐藏的 row_id。
Q:联合索引的最左前缀原则?
索引按 (a,b,c) 顺序排列,所以必须从 a 开始用。跳过 a 直接用 b 用不了;a 用了范围查询后,b、c 只能用于过滤(索引下推),不能用于定位。
Q:索引越多越好吗?
不是。每个索引都要占空间,且每次 INSERT/UPDATE/DELETE 都要维护所有索引。写多的表,索引会显著拖慢写入。
追问链:
索引为什么快?
> 因为减少了磁盘 IO
> 为什么 B+ 树 IO 少?(树高低,3 层)
> 为什么树高低?(多叉 + 数据只在叶子)
> 为什么不用 B 树?(数据占空间,节点存键少)
> 为什么不用哈希?(不支持范围)
> 二级索引查到主键后还要做什么?(回表)
> 怎么避免回表?(覆盖索引)和其他知识的连接
- < 来自 树与堆:B+ 树是多叉平衡树 + 叶子链表
- < 来自 复杂度与数组、链表:叶子链表让范围查询变成"顺序扫描",这是链表特性
- < 来自 虚拟内存:让每个程序都以为自己独占一台机器:页大小 16KB、Buffer Pool 缓存页,和虚拟内存的"页"是同一个思路
- > 影响 事务与 ACID:写操作要更新索引,索引页的修改也要写 redo log
- > 影响 锁与死锁:索引决定了加锁的范围(没走索引 > 锁全表)
- > 影响 SQL 执行与优化:优化器的核心工作就是"选哪个索引"
自测 3 问
Q1:2000 万行的表,为什么 B+ 树只有 3 层?
点开看答案
按 16KB 页算:
- 非叶子节点:每个键 + 指针约 14 字节 > 一页能放
16384/14 ≈ 1170个键。 - 叶子节点:一行约 1KB > 一页能放约 16 行。
三层结构:
- 第 1 层(根):1 个节点,1170 个分支
- 第 2 层:1170 个节点,共
1170 × 1170 ≈ 137 万个分支 - 第 3 层(叶子):137 万个叶子节点 × 16 行 ≈ 2200 万行
所以 2000 万行确实只要 3 层,查任意一行最多 3 次 IO。
注意:这是"主键 bigint + 行 1KB"的估算。行越大、主键越长,能存的行数越少。
Q2:为什么 WHERE a > 1 AND b = 2 用不上联合索引 (a,b) 的 b?
点开看答案
因为索引的排序是"先按 a,再按 b"。
a > 1 会命中多个 a 值(比如 a=2,3,4...)。在每个 a 内部,b 是有序的;但跨 a 的时候,b 整体无序。
a=2: b=1,2,3
a=3: b=1,2,3要找"b=2",在 a=2 里有一个,a=3 里有一个,a=4 里还有一个……必须把每个 a 都扫一遍,无法像等值查询那样一次定位。
所以 b 只能用于"过滤"(索引下推,减少回表),不能用于"定位"(缩小扫描范围)。
对比:a = 1 AND b = 2 能精确定位到唯一位置,因为 a 是等值,b 在 a=1 内部有序。
Q3:为什么 WHERE phone = 13800138000 会让 varchar 索引失效?
点开看答案
因为隐式类型转换。
MySQL 在比较 varchar 和数字时,会把字符串转成数字(而不是把数字转成字符串)。这个转换作用在每一行上:
等价于:WHERE CAST(phone AS SIGNED) = 13800138000一旦对索引列用了函数,B+ 树的有序性就被破坏了——数据库不知道 CAST(phone) 的结果在索引里是什么顺序,只能全表扫描。
正确写法:WHERE phone = '13800138000'(加引号)。
这个坑非常常见,因为很多 ORM 或前端传参时类型不对,表面看不出来。
类比失效处 / 易错点
- "卡片柜"类比会让人以为索引和数据是两份独立的拷贝。聚簇索引的叶子就是数据本身,只有二级索引才是"另存一份"。
- "索引让查询变快"不绝对。区分度低(如性别)或要返回大量行时,优化器会主动放弃索引——全表扫可能更快。
- B+ 树的"+"不是"加强版"的意思,是指"数据都在叶子层"这个结构差异。
- 不是所有引擎都有聚簇索引。MyISAM 的索引和数据完全分离(叶子存的是数据地址),所以它没有聚簇索引。
- 索引下推(ICP)不是"能用于定位"。它只是把过滤条件下推到索引层,减少回表次数,不改变"范围查询后列失效"这个事实。
LIKE '张%'能用索引,LIKE '%张'不能——因为前者知道从哪开始找,后者不知道。
✅ 验收清单
- 能算出"2000 万行的表 B+ 树只有 3 层",并说清计算过程
- 能说出 B+ 树相比 B 树的三个优势(数据下沉、叶子链表、IO 稳定)
- 能说出为什么不用红黑树(树高)和哈希(范围查询)
- 能解释聚簇索引和二级索引的区别,以及"回表"是什么
- 能用"索引覆盖"举一个具体例子(写出索引和 SQL)
- 能默写最左前缀原则的 5 种查询场景,并解释"范围查询后列失效"
- 能说出至少 4 种索引失效的原因,并给出正确写法
- 能说出"什么时候不该建索引"(至少 3 种)
- 合上书,用自己的话讲 3 分钟,中途不卡壳