一、开篇故事:图书馆的三种索引方式 📚
想象你在一个超大图书馆找书,有三种查找方式:
方式1:Hash索引(哈希表)🔢
你:"我要《编号12345》这本书。"
管理员:"好的!" (立即通过哈希表定位)→ 0.01秒找到 ✅
你:"我要所有编号在12000-13000的书。"
管理员:"这个...我得一本本查..." → 10分钟找到 ❌
特点:精确查找超快,范围查找很慢
方式2:B树 🌲
每层索引:
第3层:所有书都在这里(包含索引节点)
第2层:部分书
第1层:少量书
你:"我要编号12345的书。"
管理员:第1层→第2层→第3层 → 0.1秒找到 ✅
缺点:每层都存书,空间浪费,IO次数多
方式3:B+树 🌳
叶子层:所有书都在这里,还串成链表
非叶子层:只有索引目录(不存书)
你:"我要编号12345的书。"
管理员:索引→索引→叶子节点 → 0.05秒找到 ✅
你:"我要12000-13000的书。"
管理员:找到12000,沿着链表往后走 → 1秒找到 ✅✅
特点:单条查询快,范围查询更快!
MySQL的选择:B+树! 💖
二、数据结构大比拼 ⚔️
2.1 候选选手
候选1:数组 📦
候选2:链表 🔗
候选3:二叉搜索树 🌲
候选4:平衡二叉树(AVL树)⚖️
候选5:红黑树 🔴⚫
候选6:Hash表 #️⃣
候选7:B树 🌲
候选8:B+树 🌳 ← 最终赢家!
2.2 为什么不用数组?
数组存储:[1, 3, 5, 7, 9, 11, 13, ...]
查找ID=7的记录:
→ 二分查找:O(log n) ✅
插入ID=6的记录:
→ 需要移动后面所有元素:O(n) ❌
删除ID=5的记录:
→ 需要移动后面所有元素:O(n) ❌
总结:查询可以,增删太慢!
2.3 为什么不用链表?
链表存储:1 → 3 → 5 → 7 → 9 → ...
查找ID=7的记录:
→ 只能顺序查找:O(n) ❌
插入ID=6的记录:
→ 找到位置后插入:O(1),但找位置要O(n) ❌
总结:增删方便,查询太慢!
2.4 为什么不用二叉搜索树(BST)?
理想情况:
7
/ \
3 11
/ \ / \
1 5 9 13
查找:O(log n) ✅
最坏情况(退化成链表):
1
\
3
\
5
\
7
\
9
查找:O(n) ❌
总结:不稳定,可能退化!
2.5 为什么不用AVL树/红黑树?
优点:
✅ 自平衡,不会退化
✅ 查找:O(log n)
缺点:
❌ 树太高(二叉树)
❌ 100万数据 → 高度约20层
❌ 每层一次IO → 20次IO!
❌ 磁盘IO太多,性能差
生活类比:
找100万本书,要爬20层楼梯 😫
三、Hash索引:单恋症患者 💔
3.1 Hash索引的工作原理
Hash函数:
key=12345 → hash(12345) → 哈希值789 → 数据位置
查找过程:
1. 计算哈希值:O(1)
2. 定位到桶:O(1)
3. 找到数据:O(1)
总时间:O(1) ✅✅✅
3.2 Hash索引的优点
1. 等值查询极快
SELECT * FROM users WHERE id = 12345;
→ 一次哈希定位,秒查!⚡
2. 时间复杂度:O(1)
比任何树都快!
3.3 Hash索引的致命缺陷
缺陷1:不支持范围查询
-- ❌ Hash索引无法优化
SELECT * FROM users WHERE id > 10000 AND id < 20000;
原因:
hash(10001) = 5432
hash(10002) = 8791 ← 哈希值完全不连续
hash(10003) = 1234
...
无法通过哈希值找范围!
缺陷2:不支持排序
-- ❌ Hash索引无法优化
SELECT * FROM users ORDER BY id;
原因:哈希值是乱序的,无法利用索引排序
缺陷3:不支持最左前缀匹配
-- ❌ Hash索引无法优化
SELECT * FROM users WHERE name LIKE '张%';
原因:hash('张三') 和 hash('张四') 完全不同
缺陷4:哈希冲突
hash(10001) = 789
hash(20002) = 789 ← 冲突!
解决方法:链表法
桶789: → [10001] → [20002]
但冲突多了,退化成O(n)
3.4 适用场景
✅ 适合:等值查询(=)
SELECT * FROM cache WHERE key = 'user_123';
❌ 不适合:范围查询、排序、模糊查询
3.5 生活类比
Hash索引像通讯录:
找张三的电话:
→ 直接翻到"张"这一页(哈希定位)→ 秒找 ✅
找所有姓张的人:
→ 得一页页翻,无法利用通讯录的排序 ❌
四、B树:优秀但不够完美 🌲
4.1 B树的结构
B树定义: 一棵m阶B树满足:
- 每个节点最多m个子节点
- 非叶子节点至少⌈m/2⌉个子节点
- 所有叶子节点在同一层
- 节点内的key有序
4.2 B树示例(3阶B树)
[17, 35]
/ | \
[8,12] [25] [40,50]
/ | \ / \ / | \
[3] [10] [15] [20] [30] [38] [45] [60]
每个节点:
- 存储key和data
- key有序
- 可以有多个key
4.3 B树查找过程
查找key=25:
1. 从根节点开始
[17, 35]
25在17和35之间 → 走中间分支
2. 到达子节点
[25]
找到25!→ 返回数据
总共:2次IO(2层)
4.4 B树的优点
✅ 1. 矮胖结构
相同数据量,B树比AVL树矮很多
100万数据:
AVL树高度:20层(20次IO)
B树高度:3层(3次IO)⚡
✅ 2. 支持范围查询
key有序,可以范围扫描
✅ 3. 支持排序
中序遍历即为有序
4.5 B树的缺点
❌ 1. 非叶子节点存数据
[17:data17, 35:data35]
→ 导致每个节点能存的key变少
→ 树变高 → IO次数增加
❌ 2. 范围查询效率低
查找10-30:
找到10 → 回到根节点 → 找到15 → 回到根节点 → ...
需要多次回溯
❌ 3. 遍历效率低
中序遍历需要递归所有节点
4.6 图解B树缺陷
B树节点(存数据):
┌─────────────────────────┐
│ key1:data1 | key2:data2 │
└─────────────────────────┘
↑ ↑
数据占空间 数据占空间
假设:
- 页大小:16KB
- key大小:8B
- data大小:1KB
能存多少key?
16KB / (8B + 1KB) ≈ 15个
B+树节点(不存数据):
┌─────────────────────────┐
│ key1 | key2 | key3 | ...│
└─────────────────────────┘
能存多少key?
16KB / 8B = 2048个 ✅✅✅
结论:B+树更矮,IO次数更少!
五、B+树:完美恋人 💖🌳
5.1 B+树的结构
非叶子节点:只存索引(key + 指针)
[17, 35]
/ | \
[8,12] [25] [40,50]
/ | \
叶子节点:存所有数据(key + data),还有链表!
[3,8,10,12,15] ⇄ [17,20,25,30] ⇄ [35,38,40,45,50,60]
↑ ↑ ↑
双向链表 双向链表 双向链表
5.2 B+树的核心特点
特点1:非叶子节点不存数据
只存key和指针,不存实际数据
→ 每个节点能存更多key
→ 树更矮
→ IO次数更少
特点2:所有数据在叶子节点
查询路径长度一致
→ 性能稳定可预测
特点3:叶子节点有链表
叶子节点用双向链表连接
→ 范围查询直接遍历链表
→ 不需要回溯
5.3 B+树查找过程
示例: 查找id=25
[17, 35] ← 非叶子节点(只有索引)
/ | \
[8,12] [25] [40,50] ← 非叶子节点
/ | \
[3,8...] [17,20,25...] ⇄ [35,38...] ← 叶子节点(有数据)
↑
找到25的数据!
步骤:
1. 根节点:25 > 17 且 25 < 35 → 走中间分支(1次IO)
2. 子节点:25 = 25 → 走右边分支(1次IO)
3. 叶子节点:找到25的数据(1次IO)
总计:3次IO
5.4 B+树范围查询
示例: 查找id在20-40之间的数据
叶子节点链表:
[3,8,10,12,15] ⇄ [17,20,25,30] ⇄ [35,38,40,45,50,60]
↑↑↑↑↑↑↑↑↑↑↑
符合范围的数据
步骤:
1. 找到起点20(3次IO)
2. 沿着链表向右遍历(1次IO)
3. 直到40结束
总计:4次IO(B树需要多次回溯,IO次数更多!)
六、三者终极对比 📊
6.1 对比表
| 特性 | Hash索引 | B树 | B+树 |
|---|---|---|---|
| 等值查询 | ⭐⭐⭐⭐⭐ O(1) | ⭐⭐⭐⭐ O(log n) | ⭐⭐⭐⭐ O(log n) |
| 范围查询 | ❌ 不支持 | ⭐⭐⭐ 需回溯 | ⭐⭐⭐⭐⭐ 链表遍历 |
| 排序 | ❌ 不支持 | ⭐⭐⭐ 中序遍历 | ⭐⭐⭐⭐⭐ 叶子链表 |
| 模糊查询 | ❌ 不支持 | ⭐⭐⭐⭐ 支持 | ⭐⭐⭐⭐⭐ 支持 |
| IO次数 | ⭐⭐⭐⭐⭐ 1次 | ⭐⭐⭐ 较多 | ⭐⭐⭐⭐⭐ 较少 |
| 树高度 | - | ⭐⭐⭐ 中等 | ⭐⭐⭐⭐⭐ 最矮 |
| 稳定性 | ⭐⭐ 有冲突 | ⭐⭐⭐⭐ 稳定 | ⭐⭐⭐⭐⭐ 非常稳定 |
6.2 性能测试
数据量:100万条记录
Hash索引:
等值查询:0.0001秒 ✅✅✅
范围查询:无法使用索引 ❌
B树(1000阶):
树高度:3层
等值查询:3次IO ≈ 0.03秒 ✅
范围查询:需要多次回溯 ≈ 0.1秒 ⭐⭐⭐
B+树(1000阶):
树高度:3层(比B树矮)
等值查询:3次IO ≈ 0.03秒 ✅
范围查询:链表遍历 ≈ 0.05秒 ✅✅
6.3 空间利用率
假设页大小16KB:
Hash索引:
不适用(不是树结构)
B树节点:
key(8B) + data(1KB) + pointer(6B)
16KB / 1014B ≈ 16个key
树高度:log₁₆(100万) ≈ 5层
B+树非叶子节点:
key(8B) + pointer(6B)
16KB / 14B ≈ 1170个key
树高度:log₁₁₇₀(100万) ≈ 2层 ✅✅
结论:B+树最矮,IO最少!
七、为什么MySQL选择B+树?💖
原因1:减少IO次数
磁盘IO是性能瓶颈:
- 内存访问:纳秒级
- SSD访问:微秒级(慢1000倍)
- HDD访问:毫秒级(慢100万倍)
B+树更矮 → IO次数更少 → 性能更好
原因2:范围查询高效
-- 这类查询在数据库中超级常见!
SELECT * FROM users WHERE age BETWEEN 20 AND 30;
SELECT * FROM orders WHERE create_time > '2024-01-01';
SELECT * FROM products ORDER BY price LIMIT 10;
B+树的链表结构完美适配!
原因3:支持全表扫描
B+树:遍历叶子节点链表 → O(n),一次性扫描
B树:中序遍历所有节点 → 需要频繁跳跃
原因4:稳定可预测
B+树所有查询都到叶子节点
→ 查询路径长度一致
→ 性能可预测
→ 适合生产环境
原因5:适合磁盘存储
InnoDB的页大小:16KB
B+树节点 = 一个页
→ 一次IO读取一个节点
→ 完美匹配!
八、实际案例:百万数据查询 🎯
8.1 表结构
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
city VARCHAR(50),
INDEX idx_age (age)
) ENGINE=InnoDB;
-- 100万条数据
8.2 B+树索引结构
页大小:16KB
key大小:4B(INT)
指针大小:6B
每个非叶子节点能存:16KB / 10B ≈ 1600个key
树的高度计算:
第1层(根):1个节点,1600个key
第2层:1600个节点,1600×1600=256万个key
第3层(叶子):256万个节点,存储所有数据
结论:100万数据只需3层!
8.3 查询性能
-- 场景1:等值查询
SELECT * FROM users WHERE id = 500000;
执行过程:
1. 读取根节点(第1次IO)
2. 读取中间节点(第2次IO)
3. 读取叶子节点(第3次IO)
总计:3次IO ≈ 30ms
-- 场景2:范围查询
SELECT * FROM users WHERE id BETWEEN 100000 AND 110000;
执行过程:
1. 定位到100000(3次IO)
2. 沿链表读取10000条数据(约10次IO)
总计:13次IO ≈ 130ms
-- 场景3:排序查询
SELECT * FROM users ORDER BY id LIMIT 100;
执行过程:
1. 直接从叶子链表头部读取100条
总计:1次IO ≈ 10ms
九、InnoDB的B+树实现细节 🔍
9.1 聚簇索引
主键索引:
叶子节点存储完整行数据
[100, 500, 1000] ← 非叶子节点
/ | \
[1,50,99] [...] [1001,...] ← 叶子节点
↓
完整行数据:
id=1, name='张三', age=25, city='北京'
9.2 辅助索引
辅助索引(如age索引):
叶子节点存储主键值
[20, 30, 40] ← 非叶子节点
/ | \
[18,19] [25,26] [35,36] ← 叶子节点
↓
age=18 → 主键id=100
age=19 → 主键id=200
查询过程(回表):
1. 在age索引找到主键id
2. 再去主键索引找完整数据
9.3 页分裂与合并
页分裂: 节点满了,一分为二
插入数据导致页满:
[1,3,5,7,9,11,13,15,17] ← 页满了,插入10
分裂后:
[1,3,5,7,9] | [10,11,13,15,17]
↑ ↑
左页 右页
页合并: 节点太空,二合一
删除数据导致页空:
[1,3] | [5,7,9,11,13,15,17]
↓
合并后:
[1,3,5,7,9,11,13,15,17]
十、面试高频问题 🎤
Q1: 为什么MySQL使用B+树而不是B树?
答:
- B+树更矮:非叶子节点不存数据,能存更多key,减少IO
- 范围查询快:叶子节点有链表,直接遍历
- 全表扫描快:遍历叶子链表即可
- 性能稳定:所有查询都到叶子节点,路径长度一致
Q2: 为什么不用Hash索引?
答: Hash索引虽然等值查询O(1),但:
- 不支持范围查询
- 不支持排序
- 不支持最左前缀匹配
- 有哈希冲突
数据库中范围查询、排序非常常见,所以不适合Hash。
Q3: B+树的高度一般是多少?
答: 通常2-4层。
- 100万数据:3层
- 1亿数据:4层 每层一次IO,所以查询非常快。
Q4: 为什么B+树叶子节点要用链表连接?
答: 为了优化范围查询和排序。
- 范围查询:找到起点后沿链表遍历
- 排序:链表本身有序,直接遍历
- 全表扫描:遍历链表即可
Q5: MyISAM和InnoDB的索引有什么区别?
答:
- InnoDB:聚簇索引,主键索引叶子节点存数据
- MyISAM:非聚簇索引,所有索引叶子节点都存数据指针
十一、最佳实践 💡
1. 选择合适的主键
-- ✅ 好:自增主键
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
...
);
-- 顺序插入,不会频繁页分裂
-- ❌ 不好:UUID主键
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY,
...
);
-- 无序插入,频繁页分裂,性能差
2. 避免索引过多
-- ❌ 不好:索引太多
CREATE TABLE users (
...
INDEX idx1 (col1),
INDEX idx2 (col2),
INDEX idx3 (col3),
INDEX idx4 (col4),
INDEX idx5 (col5) -- 每个索引都是一棵B+树!
);
-- 插入、更新慢,空间占用大
-- ✅ 好:合理建索引
CREATE TABLE users (
...
INDEX idx_col1_col2 (col1, col2), -- 联合索引
INDEX idx_col3 (col3)
);
3. 利用索引覆盖
-- 索引:idx_age_name (age, name)
-- ✅ 好:索引覆盖,不需要回表
SELECT age, name FROM users WHERE age = 25;
-- ⚠️ 不好:需要回表
SELECT age, name, address FROM users WHERE age = 25;
十二、总结口诀 📝
MySQL索引选B+树,
原因咱们细细数。
非叶节点不存数,
树更矮来IO少。
叶子节点有链表,
范围查询跑得快。
Hash虽快有局限,
只能等值不能范围。
B树虽好有缺陷,
节点存数占空间。
红黑AVL太高了,
IO次数降不了。
B+树才是真完美,
读写查询都不累。
理解原理选对树,
数据库性能不用愁!
十三、扩展知识 🎓
为什么MySQL页大小是16KB?
考虑因素:
1. 操作系统页通常4KB
2. 16KB = 4 × 4KB,对齐友好
3. 太小:树高增加,IO次数多
4. 太大:单次IO传输慢,缓存效率低
5. 16KB是综合平衡的结果
InnoDB Buffer Pool
内存缓冲池:
- 缓存热点数据页
- 减少磁盘IO
- LRU算法淘汰
- 命中率通常 > 90%
B+树配合Buffer Pool:
- 根节点常驻内存
- 热点路径缓存
- 实际IO次数更少
参考资料 📚
下期预告: 138-聚簇索引和非聚簇索引的区别,回表查询是什么?🔎
编写时间:2025年
作者:技术文档小助手 ✍️
版本:v1.0
愿你的索引永远高效! 🚀📈