前言
在面试中,有一个问题几乎必考:
“MySQL为什么用B+树做索引?为什么不用哈希表?为什么不用二叉树?为什么不用B树?”
这个问题看似简单,但追问到第三层时,很多人就答不上来了。而一旦你能把"B+树的范围查询优势"“页分裂代价”"为什么主键推荐自增"这几个点串起来讲清楚,面试官就知道:这个人是真的理解索引,不是背的。
本文带你从数据结构的角度,彻底搞懂MySQL索引的底层原理。
本文核心问题:
- 索引的本质是什么?为什么能加速查询?
- 哈希索引、二叉树、B树、B+树各自的优劣?为什么MySQL默认用B+树?
- B+树的叶子节点为什么用双向链表连接?
- 聚簇索引和二级索引有什么区别?回表是什么?
- 为什么DBA建议主键自增?页分裂有什么代价?
- InnoDB的一次索引查找经历了什么?从根节点到数据页的完整路径
count(*)为什么慢?索引统计是怎么工作的?
读完本文,你将对MySQL索引拥有从数据结构到存储引擎的完整理解。
一、索引的本质——从全表扫描到快速定位
疑问:索引为什么能加速查询?它的本质是什么?
回答:索引的本质是"预排序的数据结构",它让数据库从"全表扫描"变成"快速定位"。
1.1 没有索引时
SELECT*FROMuserWHEREid=1000;没有索引时,数据库只能一行一行地找——从第一行开始,逐行对比id是否为1000。如果在磁盘上存了100万行,平均要扫描50万行才能找到目标。
这就是全表扫描——时间复杂度为O(n),数据量越大越慢。
1.2 有索引时
如果在id列上建了索引,数据库就有了一个"目录"。这个目录按id排序,并且有指针指向实际数据的位置。
查找id=1000时,不需要遍历所有行,而是先在目录中快速定位到"1000附近",再精准读取那几行数据。
索引的本质就是"用空间换时间"——额外存储一份排序好的数据结构,让查找从O(n)降到O(log n)甚至O(1)。
1.3 索引的实际存储
索引在磁盘上以"页"为单位存储。InnoDB默认每页16KB,每个页里存多条索引记录。当数据量大到一个页装不下时,就需要分成多个页,并在页之间建立父子关系——这就形成了树形结构。
一个页(16KB)的内部结构: ┌─────────────────────────────────┐ │ 页头(页号、页类型、前后页指针) │ ├─────────────────────────────────┤ │ 索引记录1(键值 + 行指针) │ │ 索引记录2(键值 + 行指针) │ │ 索引记录3(键值 + 行指针) │ │ ... │ ├─────────────────────────────────┤ │ 页尾(校验和) │ └─────────────────────────────────┘所以,索引优化本质上就是让数据库尽可能少地读磁盘页。每一次"减少回表""使用覆盖索引"的优化,最终目的都是省掉不必要的页面读取。
二、常见的索引数据结构——为什么B+树胜出?
疑问:能做索引的数据结构那么多,为什么MySQL选了B+树?
回答:关键衡量标准是"磁盘IO次数"。内存访问是纳秒级,磁盘访问是毫秒级——差了几十万倍。一个好的索引结构,必须尽可能地减少磁盘IO。
2.1 哈希表
查找过程:key → hash(key) → 桶位置 → 取出数据 时间复杂度:O(1)(理想情况)MySQL其实用了哈希索引——Memory引擎的默认索引就是哈希表,InnoDB的"自适应哈希索引"功能也会自动为频繁访问的页建立哈希索引。
但哈希表有一个致命缺陷——不支持范围查询。
SELECT*FROMuserWHEREid>100ANDid<200;哈希表中,id=101和id=102存储在不同的桶中,它们之间没有顺序关系。要查范围,只能扫描所有桶,退化为O(n)。
2.2 二叉搜索树
50 / \ 30 70 / \ / \ 20 40 60 80查找id=40:50→30→40,三次比较。查找速度随树的高度线性增长。平衡二叉树的高度大约是log₂(n)——100万条数据,树高约20层。每层是一个独立的磁盘页,最坏情况需要20次磁盘IO。可数据库查询的底线通常是"2-3次磁盘IO完成一次查找"。
二叉树的"分叉太少",树太高,磁盘IO次数太多。这个结构在内存中效率极高(如Java的TreeMap),但迁移到磁盘上就不行了。
2.3 B树——让树"变矮变胖"
B树节点(包含键 + 数据 + 子节点指针): ┌────┬────┬────┬────┬────┐ │ P1 │ K1 │ P2 │ K2 │ P3 │ ← 每个节点存多个键和子指针 └────┴────┴────┴────┴────┘ ↓ ↓ ↓ 子1 数据1 子2 ... 关键特征: - 非叶子节点也存储完整数据 - 每个节点可以有多个子节点(分叉数 > 2) - 所有叶子节点在同一层(平衡)B树的优势:16KB的一页可以存几百个键和指针。假设一个节点存500个键,分叉数为501——100万条数据,树高只需要3层,3次磁盘IO即可完成查询。分叉越多,树越矮,磁盘IO越少。
但B树还有一个问题:非叶子节点也存储数据。每个16KB的页空间是固定的,如果每个非叶子节点都存一份数据的完整行记录,能存的键和指针数量就变少了。同样存100万条数据,如果每个内部节点都带着完整行记录,分叉数会从500降到几十,树高就会从3层涨到4-5层——多一次磁盘IO。
2.4 B+树——在B树的基础上进一步优化
B+树节点(非叶子节点只存键+指针,叶子节点存完整数据): ┌─────┬─────┬─────┐ 非叶子节点 │ P1 │ K1 │ P2 │ ← 只存键,不存数据 └──┬──┴──┬──┴──┬──┘ ↓ ↓ ↓ ┌──────────────────┐ 叶子节点 │ K1+Data │ K2+Data │ K3+Data │ ← 所有数据在叶子层 └──────────────────┘ ← → 双向链表连接 ← →B+树在B树的基础上做了两个关键改进:
其一,非叶子节点只存键,不存数据。每个16KB的页可以存更多键——分叉数更大,树更矮。100万行数据,树高只需要2-3层,一次查询最多2-3次磁盘IO。
其二,所有叶子节点用双向链表串联。范围查询时,找到第一个满足条件的数据后,顺着链表向右遍历即可,不需要再回到上层节点。而B树需要回到父节点找"下一个"键,走回头路,可能重复读取已经访问过的页。
所以B+树最终胜出,在于它在磁盘场景下的三项关键优势:更矮的树高、范围查询直接链表遍历无需回溯、每个节点空间利用率更高。
三、B+树的范围查询优势——双向链表的妙用
疑问:B树也能范围查询,B+树的优势到底在哪?
回答:B+树的叶子节点链表,让范围查询从"树上反复横跳"变成"一次定位+链表顺读"。
3.1 B树的范围查询
B树结构: ┌────┐ │ 50 │(存数据) └┬──┬┘ ┌────┘ └────┐ ┌──┴──┐ ┌──┴──┐ │ 30 │ │ 70 │(都存数据) └┬──┬┘ └┬──┬┘ ... ... 查 30 < id < 80: 1. 从根50 → 找到30 2. 从30回到父节点50 3. 从50下到70 4. 从70再回父节点 → 才发现要回根 → 在树上反复上下移动3.2 B+树的范围查询
B+树范围查询: 非叶子层: ┌─────┐ │ 50 │ ← 只存键 └┬──┬┘ ┌───┘ └───┐ ┌─────┐ ┌─────┐ │ 30 │ │ 70 │ ← 只存键 └┬──┬┘ └┬──┬┘ ↓ ↓ ↓ ↓ 叶子层: [30]→[35]→[50]→[65]→[70]→[80]→... ↑ ↑ 找到起点 沿着链表 → 一直读到80 查 30 < id < 80: 1. 在非叶子层找到30的位置 2. 到叶子层,定位到30 3. 顺着链表一直往右读,直到80 → 一次寻路,顺序读取范围查询是数据库中最常见的查询模式之一(如WHERE id BETWEEN 1 AND 100和ORDER BY id LIMIT 10),B+树对这个场景做了针对性优化。
四、聚簇索引与二级索引——回表的根源
疑问:为什么SELECT *性能差?为什么查询非主键列之后还要再查一次?
回答:这涉及到InnoDB中最核心的两个概念——聚簇索引和二级索引。
4.1 聚簇索引
聚簇索引的叶子节点直接存整行数据。InnoDB默认用主键作为聚簇索引。
聚簇索引(以主键id构建的B+树): 非叶子节点: [id范围 + 子页指针] 叶子节点: [id=1, name='张三', age=25, email='...'] ← 完整行数据 [id=2, name='李四', age=30, email='...'] [id=3, name='王五', age=28, email='...']"聚簇"的含义:数据本身的物理存储顺序和主键的顺序一致。这解释了为什么插入随机主键会导致页分裂——新数据的主键在已有页的中间,迫使一个页拆成两个,插入成本远高于追加到最后的自增主键。
4.2 二级索引(辅助索引)
二级索引的叶子节点存的不是完整行数据,而是主键值。
二级索引(以name列构建的B+树): 非叶子节点: [name范围 + 子页指针] 叶子节点: [name='李四', id=2] ← 只存键+主键 [name='王五', id=3] [name='张三', id=1]4.3 回表
SELECT*FROMuserWHEREname='张三';执行过程:
- 在name的二级索引上找到"张三" → 拿到主键
id=1 - 拿
id=1去聚簇索引上查找完整行数据 → 拿到name='张三', age=25, email='...' - 返回完整结果
第二步就是"回表"——从二级索引回到聚簇索引,取出完整行。每次回表都是一次独立的磁盘IO。如果查询返回100行,且每行的主键都不在同一数据页中,最坏情况可能需要100次额外的磁盘IO。
这解释了为什么大厂强制不准用SELECT *:不是因为传输带宽,而是因为SELECT *会强迫MySQL走回表路径。如果你只需要name和id,直接建一个(name, id)联合索引,索引叶子节点本身就有所有需要的数据,根本不需要回表。涉及几个字段就建几个字段的联合索引,让查询只在一个索引里完成,性能和主要成本都在磁盘IO。
五、为什么推荐自增主键?——页分裂的代价
疑问:UUID和自增ID做主键有什么区别?为什么DBA推荐自增?
回答:核心原因是"页分裂"的代价。自增主键是在原有数据之后追加,UUID是往已有数据中插入——后者会导致频繁的页分裂。
5.1 自增主键的插入
已有叶子页: [1] [2] [3] [4] [5](页已满) 插入 id=6: 新叶子页: [1] [2] [3] [4] [5] [6] ← 追加到末尾只在最后一页追加,偶尔需要分配新页。插入成本稳定且低,且主键连续意味着物理上相近的行数据也存储在相近的磁盘位置,空间局部性好。
5.2 随机主键的插入
已有叶子页A: [1] [3] [5] [7] [9](页已满) 插入 id=4: 需要: 叶子页A:[1] [3] [4] ← 页A只保留前一半 叶子页B:[5] [7] [9] ← 后一半移到新页B 父节点:需要新增指向页B的指针 ← 父节点也可能需要分裂 这是"页分裂"——一个页变成两个页,父节点也要更新。5.3 页分裂的代价
| 影响 | 自增主键 | 随机主键 |
|---|---|---|
| 插入性能 | 稳定 | 随机的,可能触发分裂 |
| 页空间利用率 | 高(页填充率高) | 低(分裂后各页只有半满) |
| 磁盘碎片 | 少 | 多 |
| 二级索引大小 | 较小 | 较大(二级索引叶子节点存主键,长主键意味着更大的索引文件) |
自增主键还影响二级索引的大小——二级索引的叶子节点存的是主键值。UUID是32字节字符串,INT是4字节。1亿条记录,光是主键大小就差2.8GB的存储空间,对应的二级索引文件也会等比例膨胀。
六、InnoDB一次索引查找的完整路径
疑问:SELECT * FROM user WHERE id = 100,InnoDB到底做了什么?
回答:SQL语句的执行分为Server层和引擎层分工协作。Server层负责解析和优化,引擎层负责数据存取。
6.1 整体流程
┌─────────────────────────────────────────────────────────┐ │ Server层 │ │ │ │ SQL → 连接器 → 分析器 → 优化器 → 执行器 │ │ │ │ │ 选择索引,生成执行计划 │ └─────────────────────────────────┼───────────────────────┘ │ 调用引擎接口 ▼ ┌─────────────────────────────────────────────────────────┐ │ InnoDB引擎层 │ │ │ │ Buffer Pool(内存缓冲池) │ │ ┌─────────────────────────────────────┐ │ │ │ 数据页1 数据页2 ... 索引页1 ... │ │ │ └─────────────────────────────────────┘ │ │ ↑ 命中返回 ↓ 未命中则读磁盘 │ │ 磁盘(.ibd文件) │ └─────────────────────────────────────────────────────────┘6.2 查找id=100的完整路径
1. Server层解析SQL → 优化器选择主键索引 → 调用引擎的ha_innobase::index_read() 2. InnoDB从根节点开始,以页为单位逐层定位: 根节点页(常驻Buffer Pool): 判断100在哪个区间 → 找到子节点指针 → 进入下一层 非叶子节点页: 判断100在哪个区间 → 找到叶子节点指针 → 进入叶子层 叶子节点页: 在页内通过二分查找定位到id=100 → 此行数据所在位置 3. 如果页在Buffer Pool中 → 直接返回(内存速度) 如果页不在Buffer Pool中 → 从磁盘读取(一次随机IO,约5-10ms) 4. 返回完整行数据给Server层 → 返回客户端关键认知:一次主键查找,在100万行数据下只需要2-3次磁盘IO。索引优化的目标就是让树更矮、节点更紧凑、尽量减少磁盘访问次数。
总结
- 索引的本质是预排序的数据结构,让查找从O(n)变成O(log n)。用空间换时间
- B+树胜出的原因:树更矮(非叶子节点不存数据,分叉更多)、范围查询更高效(叶子层双向链表)、节点空间利用率更高
- 聚簇索引叶子存完整行数据,二级索引叶子存主键值。
SELECT *导致回表——在二级索引和聚簇索引之间额外走一个来回,多一次磁盘IO - 自增主键减少页分裂——新数据总是追加在最后,插入稳定;UUID往中间插,页分裂导致碎片和空间浪费,且长主键让二级索引文件成比例膨胀
- 一次索引查找经历Server层(解析→优化→执行)和引擎层(Buffer Pool→磁盘页定位→二分查找→返回数据)两步
- 索引优化的最终目标:减少磁盘IO次数。覆盖索引、页分裂预防、前缀索引等技巧,本质都是"少读几页"
下一篇预告:MySQL索引原理(二)——联合索引与最左前缀原则:从Explain看索引命中。拆解联合索引的存储结构、Explain输出项的含义、以及索引失效的常见场景和原因。