"SELECT * FROM users WHERE name = '张三' 用了 0.5 秒?你该了解一下索引了。"
一、开场白:为什么你的查询比蜗牛还慢?
想象一下这个场景:你在一个有一亿条记录的表里查一条数据,没有索引。MySQL 的表情大概是这样的:
"好的,我从第一行开始看……第二行……第三行……第 87,432,651 行……找到了!"
这就是传说中的全表扫描(Full Table Scan)——像在没有目录的字典里找一个字,一页一页翻。效率感人,老板流泪。
那么索引是什么?索引就是书的目录。想翻到第 5 章?不用从第 1 页开始翻,直接看目录,跳过去,搞定。
但问题来了:MySQL 的索引用的是什么数据结构?为什么不用二叉树?为什么不用 Hash?为什么偏偏是B+ 树?
坐好,我们从头讲起。
二、先认识几个"候选人"
在 MySQL 选择索引数据结构的路上,有好几个候选人来面试。让我们看看它们各自的表现。
2.1 二叉搜索树(BST):第一个被淘汰的选手
二叉搜索树的逻辑很简单:左子树所有值 < 根节点 < 右子树所有值。查找时间复杂度是 O(log n),看起来不错对吧?
但问题在于:它太"瘦高"了。
一棵存了 100 万条记录的二叉树,高度大约是 20 层。每读一个节点就是一次磁盘 I/O(后面会详细讲),20 次磁盘 I/O……你的查询还没返回,咖啡都凉了。
更可怕的是,如果插入的数据是有序的(比如自增主键),二叉搜索树会退化成一条链表,查找变成 O(n)——比全表扫描好不了多少。
二叉搜索树面试时说:"我能 O(log n)!" DBA 问:"你有多少层?" "20 层。" "下一位。"
2.2 红黑树 / AVL 树:矮是矮了,但还是太"瘦"
红黑树和 AVL 树是自平衡的二叉树,保证了树的高度不会太高。Java 的TreeMap、C++ 的std::map底层就是它们。
但问题是:它们每个节点只有两个子节点(二叉嘛)。即使平衡了,100 万条数据仍然需要大约 20 层。
20 层 = 20 次磁盘 I/O = 20 次"去磁盘上找一下" = 20 次灵魂拷问。
红黑树:"我有颜色!我自平衡!" DBA:"你有几个孩子?" "两个。" "太少了。下一位。"
2.3 Hash 表:速度飞快,但"偏科生"
Hash 表的查找速度是O(1)——常数时间!不管你有多少数据,一次哈希计算直接定位。听起来完美?
但 Hash 表是个严重的"偏科生":
❌不支持范围查询:
WHERE age > 18?Hash 表一脸懵逼:"我只知道等于,大于是什么?"❌不支持排序:
ORDER BY name?Hash 表:"顺序是什么?能吃吗?"❌不支持最左前缀匹配:联合索引?Hash 表:"我只认完整的 key。"
❌存在哈希冲突:数据量大了以后,不同的 key 可能映射到同一个位置,需要遍历链表。
MySQL 中只有Memory 存储引擎默认使用 Hash 索引,InnoDB 有一个自适应哈希索引(Adaptive Hash Index),但那是自动的,你没法手动控制。
Hash 表面试时说:"我 O(1)!" DBA:"范围查询呢?" "不会。" "排序呢?" "不会。" "那你来干嘛?" "……我快。" "快有什么用,功能不全啊。下一位。"
2.4 B 树(B-Tree):优秀,但可以更好
B 树是一种多路平衡查找树,每个节点可以有多个子节点(不只是两个)。这意味着同样数据量下,B 树比二叉树矮得多。
B 树的特点:
每个节点既存索引 key也存数据 data
所有叶子节点都在同一层
一个节点可以有多个子节点(比如 100 个)
100 万条数据,B 树可能只需要3-4 层。3-4 次磁盘 I/O 就能定位数据,比二叉树的 20 次好太多了!
但 B 树有一个问题:每个节点都存了数据 data,导致每个节点能存的 key 数量变少了,树就相对变高了。
而且 B 树的叶子节点之间没有指针相连,范围查询需要中序遍历整棵树,效率不够好。
B 树面试时说:"我矮!我多路!我平衡!" DBA:"不错。但你每个节点都塞数据,太胖了。有没有更精简的方案?" B 树看了一眼旁边的弟弟:"……我弟可以。"
三、B+ 树:最终赢家登场!
B+ 树是 B 树的改良版,也是 MySQL InnoDB 存储引擎的默认索引结构。如果说 B 树是一个人既当厨师又当服务员,那 B+ 树就是专人专事——非叶子节点只负责索引,叶子节点才存数据。
3.1 B+ 树 vs B 树:核心区别
| 特性 | B 树 | B+ 树 |
|---|---|---|
| 非叶子节点存数据 | ✅ 存 | ❌ 不存,只存 key |
| 叶子节点链表 | ❌ 没有 | ✅ 有双向链表 |
| 范围查询 | 需要中序遍历 | 沿链表顺序扫描即可 |
| 查询稳定性 | 不稳定(可能在任意层找到) | 稳定(一定走到叶子节点) |
| 每个节点能存的 key 数 | 较少(因为存了 data) | 较多(只存 key) |
3.2 为什么 B+ 树这么"矮胖"?
这是 B+ 树最大的优势:矮胖。
来算一笔账。InnoDB 的一个页(Page)大小是16KB。假设:
一个索引 key 是 8 字节(比如 bigint)
一个指针是 6 字节
那么一个页大约能存:
16384 / (8 + 6) ≈ 1170个 key
两层 B+ 树:根节点 1170 个 key,每个指向一个叶子节点,每个叶子节点假设存 100 条数据: → 可以存储1170 × 100 = 117,000条数据
三层 B+ 树:1170 × 1170 × 100 ≈ 1.37 亿条数据
也就是说:1.37 亿条数据,只需要 3 次磁盘 I/O 就能定位!
B+ 树:"我 3 层楼就能装 1.37 亿条数据。" 红黑树:"我需要 20 多层。" B+ 树:"哦。" (凡尔赛本赛)
3.3 叶子节点的链表:范围查询的神器
B+ 树的叶子节点之间通过双向链表连接。这意味着:
范围查询:
WHERE age BETWEEN 18 AND 30,先定位到 18,然后沿着链表往后扫到 30,完事。ORDER BY:链表本身就是有序的,排序零成本。
全表扫描:只需要遍历叶子节点链表,不需要遍历整棵树。
这就是为什么 B+ 树在数据库场景下吊打其他数据结构的原因。
四、InnoDB 中的索引:聚簇索引 vs 二级索引
讲完了数据结构,我们来看 MySQL InnoDB 中索引的具体实现。这里有两个核心概念:聚簇索引(Clustered Index)和二级索引(Secondary Index)。
4.1 聚簇索引:数据本身就是索引
InnoDB 的聚簇索引有一个非常特别的设计:叶子节点存储的是完整的行数据。
也就是说,聚簇索引的叶子节点 = 数据页。数据和主键索引是一体的,表数据本身就是一棵 B+ 树。
这就像一本字典,目录和正文是合在一起的——找到目录项,就直接看到了释义。
InnoDB 选择主键作为聚簇索引的 key。如果没有定义主键,InnoDB 会选一个唯一的非空索引;如果还没有,InnoDB 会自动生成一个隐藏的 6 字节 ROW_ID 作为主键。
4.2 二级索引:回表的代价
除了主键索引,你创建的其他索引(比如INDEX idx_name (name))都是二级索引。
二级索引的叶子节点存储的不是完整行数据,而是主键值。
查找流程:
在二级索引的 B+ 树中找到
name = '张三',得到主键值id = 42拿着
id = 42去聚簇索引(主键索引)的 B+ 树中再查一次,得到完整行数据
这个过程叫回表(Back to Table)——就像你先在作者索引里找到了书名,再去书架上找那本书。
二级索引:"我找到了张三的 id 是 42。" DBA:"然后呢?" 二级索引:"然后你自己去主键索引里找完整数据吧。" DBA:"……你就不能一次性给我?" 二级索引:"不能,我只存了主键。告辞。"
4.3 覆盖索引:避免回表的优化技巧
如果你查询的字段刚好都在二级索引里,就不需要回表了。这就是覆盖索引(Covering Index)。
-- 假设有索引 INDEX idx_name_age (name, age) -- 需要回表(SELECT * 需要所有字段) SELECT * FROM users WHERE name = '张三'; -- 不需要回表!name 和 age 都在索引里 SELECT name, age FROM users WHERE name = '张三';
在EXPLAIN中,如果看到Using index,说明用到了覆盖索引,没有回表。
覆盖索引:"你要的字段我都有,别回表了。" DBA:"你真是个好索引。" 覆盖索引:"那当然。我可是精心设计的。"
五、联合索引与最左前缀原则
5.1 联合索引的 B+ 树长什么样?
假设你有一个联合索引INDEX idx_abc (a, b, c),B+ 树的排序规则是:
先按 a 排序,a 相同的按 b 排序,b 相同的按 c 排序。
就像字典先按拼音排序,拼音相同按声调排序,声调相同按笔画排序。
5.2 最左前缀原则
这是面试高频考点,也是日常开发最容易踩坑的地方。
-- 有索引 INDEX idx_abc (a, b, c) -- ✅ 能用到索引 WHERE a = 1 WHERE a = 1 AND b = 2 WHERE a = 1 AND b = 2 AND c = 3 -- ✅ 能用到部分索引(只用到 a) WHERE a = 1 AND c = 3 -- ❌ 用不到索引 WHERE b = 2 WHERE b = 2 AND c = 3 WHERE c = 3
为什么?因为 B+ 树是按(a, b, c)的顺序排序的。如果你不给 a,直接查 b,就像字典里没有拼音直接找声调——完全无从下手。
最左前缀原则:"你得先告诉我 a 是什么,我才能帮你找 b。" 开发者:"那我直接查 b 呢?" 最左前缀原则:"那你自己全表扫描吧,我帮不了你。" 开发者:"……"
5.3 索引下推(Index Condition Pushdown, ICP)
MySQL 5.6 引入了索引下推(ICP)优化。对于联合索引(a, b, c):
WHERE a = 1 AND c = 3
在没有 ICP 的时候:MySQL 在索引中找到a = 1的所有记录,然后全部回表,再在 server 层过滤c = 3。
有了 ICP 之后:MySQL 在索引中找到a = 1的记录后,直接在存储引擎层检查c = 3,不满足条件的就不回表了。减少了回表次数。
ICP:"虽然你没按最左前缀查 c,但我在索引里帮你先过滤一波,少回几次表。" DBA:"你真是个贴心的优化器。"
六、磁盘 I/O:为什么"少读几次"这么重要?
6.1 磁盘 vs 内存:速度差了 10 万倍
这里要科普一个关键知识点:磁盘 I/O 是数据库最大的性能瓶颈。
| 存储类型 | 访问时间 |
|---|---|
| L1 缓存 | ~1ns |
| L2 缓存 | ~4ns |
| 内存(RAM) | ~100ns |
| SSD | ~100μs(100,000ns) |
| HDD 机械硬盘 | ~10ms(10,000,000ns) |
HDD 比内存慢10 万倍。每一次磁盘读取,CPU 都要等很久。
这就是为什么 B+ 树的"矮胖"特性如此重要——树矮一层,就少一次磁盘 I/O。从 20 层降到 3 层,意味着从 20 次磁盘 I/O 降到 3 次,查询速度提升了一个数量级。
6.2 局部性原理与预读
操作系统和磁盘有一个优化机制叫局部性原理(Principle of Locality):
时间局部性:最近访问的数据,很可能马上又会被访问
空间局部性:访问了某个地址附近的数据,很可能也会被访问
基于这个原理,操作系统在读取磁盘时不会只读一个字节,而是预读(Read-ahead)一整个页(通常 4KB)。
InnoDB 正是利用了这一点:它的页大小是16KB(默认),每次读取一个完整的页。B+ 树的一个节点就是一个页,所以读一个节点 = 一次磁盘 I/O = 读 16KB 数据。
操作系统:"你要读一个字节?我给你读 4KB,反正你大概率会用到旁边的数据。" InnoDB:"那我要 16KB。" 操作系统:"你是有多贪?" InnoDB:"我一个节点就这么大。"
七、实战:索引设计的"军规"
理论讲完了,来点实战建议。
7.1 选择性(Cardinality)是关键
选择性 = 不重复的索引值 / 数据总记录数
选择性越高,索引效果越好。比如:
gender(性别)字段只有 M/F 两个值,选择性 ≈ 2/N ≈ 0,建索引基本没用email字段几乎每个值都不同,选择性 ≈ 1,非常适合建索引
-- 查看索引选择性 SHOW INDEX FROM your_table; -- 看 Cardinality 列
7.2 覆盖索引优先
尽量让查询的字段都在索引里,避免回表:
-- 差:SELECT * 无法覆盖索引 SELECT * FROM orders WHERE user_id = 100; -- 好:只查需要的字段,且都在索引里 SELECT order_id, amount FROM orders WHERE user_id = 100;
7.3 不要在索引列上做函数操作
-- ❌ 索引失效! SELECT * FROM users WHERE YEAR(create_time) = 2024; -- ✅ 改成范围查询 SELECT * FROM users WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';
在索引列上使用函数、表达式、隐式类型转换,都会导致索引失效,MySQL 只能走全表扫描。
开发者:"我在索引列上加了个函数,索引怎么不生效了?" MySQL:"你在我精心排好序的 B+ 树上做了变换,原来的顺序没了,我怎么用索引?" 开发者:"……好吧。"
7.4 前缀索引:对付长字符串
对于VARCHAR(255)这种长字符串字段,可以只索引前 N 个字符:
ALTER TABLE users ADD INDEX idx_email_prefix (email(10));
但前缀索引不能用于 ORDER BY 和覆盖索引,需要权衡。
7.5 索引不是越多越好
每个索引都是一棵独立的 B+ 树,需要额外的存储空间和维护成本:
每次
INSERT、UPDATE、DELETE都要同时更新所有相关的索引索引越多,写操作越慢
一般建议单表索引不超过 5-6 个
DBA:"这个表有 12 个索引。" 开发者:"多索引多保险嘛。" DBA:"你每次插入一条数据,要同时维护 12 棵 B+ 树。你的 INSERT 语句在哭。"
八、总结:一张图看懂索引选型
| 数据结构 | 查找时间 | 范围查询 | 排序 | 适用场景 |
|---|---|---|---|---|
| 二叉搜索树 | O(log n) | 需遍历 | 需遍历 | 内存数据结构 |
| 红黑树/AVL | O(log n) | 需遍历 | 需遍历 | 内存数据结构 |
| Hash 表 | O(1) | ❌ 不支持 | ❌ 不支持 | 等值查询 |
| B 树 | O(log n) | 中序遍历 | 中序遍历 | 文件系统 |
| B+ 树 | O(log n) | 链表顺序扫描 | 天然有序 | 数据库索引 |
B+ 树胜出的原因总结:
🏔️矮胖:3-4 层就能存储上亿数据,磁盘 I/O 次数最少
🔗叶子节点链表:范围查询、排序、分组都是天然支持
📦非叶子节点不存数据:每个节点能存更多 key,树更矮
🎯查询稳定:每次查询都要走到叶子节点,性能可预测
💾磁盘友好:节点大小 = 页大小,完美利用预读机制
九、后记
下次当你的 SQL 查询飞快返回时,别忘了感谢那棵默默无闻的 B+ 树。它可能只有 3 层楼高,但每一层都是精心设计的。
当你的查询慢了,先看看EXPLAIN,看看索引有没有用上。大部分性能问题,都是索引没设计好。
记住:索引不是万能的,但没有索引是万万不能的。
DBA 的终极忠告: "先看 EXPLAIN,再看索引,最后才考虑加机器。" "如果你的 SQL 需要全表扫描,请先反思一下自己的人生选择。"
参考资源:
MySQL 官方文档 - B-Tree and Hash Indexes
Wikipedia - B+ Tree
Wikipedia - B-tree
InnoDB Diagrams Project (GitHub)