摘要:从一次"为什么我建了索引查询还是慢"的困惑出发,深度剖析InnoDB的B+树索引原理。通过数据页结构图解、页分裂与页合并的动画演示、以及一棵3层B+树能存多少数据的完整计算,揭秘为什么选B+树而不是B树、哈希、红黑树,以及为什么VARCHAR(255)这么常见。配合时序图展示查询流程,给出索引设计的最佳实践。
💥 翻车现场
周一早上,哈吉米收到了DBA的邮件。
主题:user表索引建议
内容:
你的user表已经5000万数据了,但只有主键索引。
建议在以下字段建索引:
- phone(查询频繁)
- email(查询频繁)
- create_time(排序用)
哈吉米:"好的!马上建!"
建完索引后,哈吉米测试查询:
-- 查询
SELECT * FROM user WHERE phone = '13800138000';
-- 执行时间:0.02秒 ✅
-- 范围查询
SELECT * FROM user WHERE phone > '138' AND phone < '139' LIMIT 1000;
-- 执行时间:0.5秒 ✅
哈吉米:"索引真快!但我有个疑问……"
哈吉米(在群里问):为什么5000万数据,查询只需要几毫秒?索引是怎么存的?一棵B+树能存多少数据?
南北绿豆:"好问题!这涉及B+树的存储原理,今晚给你讲讲。"
晚上,南北绿豆和阿西噶阿西来了。
南北绿豆:"先问你一个问题:为什么MySQL用B+树,而不是哈希、红黑树、B树?"
哈吉米:"呃……因为……B+树好?"
阿西噶阿西:"来,我从头给你讲。"
🤔 为什么不用其他数据结构?
为什么不用哈希?
哈希表的特点:
查询:
user_id=10086 → hash(10086) = 1234 → 一次定位 ✅
性能:O(1),极快
问题:不支持范围查询
-- 等值查询(快)
SELECT * FROM user WHERE user_id = 10086; -- O(1) ✅
-- 范围查询(慢)
SELECT * FROM user WHERE user_id > 10000 AND user_id < 20000;
-- 需要遍历所有数据 ❌
MySQL的查询场景:
| 查询类型 | 占比 | 哈希是否适合 |
|---|---|---|
| 等值查询 | 30% | ✅ |
| 范围查询 | 50% | ❌ |
| 排序 | 20% | ❌ |
结论:MySQL有大量范围查询(>、<、BETWEEN、ORDER BY),哈希不适合。
为什么不用红黑树/AVL树?
红黑树的特点:
查询:O(log n)
插入:O(log n)
平衡性:好
问题:树太高,IO次数多
1000万数据:
红黑树高度:log₂(1000万) ≈ 23层
查询流程:
根节点 → 第2层 → 第3层 → ... → 第23层
需要23次IO ❌
假设每次IO需要5ms:
总耗时:23 × 5ms = 115ms(太慢)
对比B+树:
1000万数据:
B+树高度:3-4层
查询流程:
根节点 → 第2层 → 第3层(叶子节点)
需要3次IO ✅
总耗时:3 × 5ms = 15ms(快)
性能对比:
| 数据结构 | 树高 | IO次数 | 查询时间 |
|---|---|---|---|
| 红黑树 | 23层 | 23次 | 115ms |
| AVL树 | 23层 | 23次 | 115ms |
| B+树 | 3-4层 | 3-4次 | 15-20ms |
阿西噶阿西:"看到了吗?树越矮,IO越少,查询越快!"
为什么不用B树?
B树的特点:
B树(B-tree):
- 非叶子节点也存数据
- 所有节点都可能存数据
问题1:扇出小
B树的非叶子节点:
存储:[索引键1, 数据1, 索引键2, 数据2, ...]
每页16KB:
16KB / (8B索引键 + 1KB数据) ≈ 16个键
B+树的非叶子节点:
存储:[索引键1, 指针1, 索引键2, 指针2, ...]
每页16KB:
16KB / (8B索引键 + 6B指针) ≈ 1170个键
扇出:1170 / 16 = 73倍
问题2:范围查询慢
B树范围查询:
需要中序遍历整棵树(跨层级)
B+树范围查询:
叶子节点是链表,顺序扫描即可
对比:
| 特性 | B树 | B+树 |
|---|---|---|
| 数据存储 | 所有节点 | 只有叶子节点 |
| 扇出 | 小 | 大(73倍) |
| 树高 | 高 | 低 |
| 范围查询 | 慢(中序遍历) | 快(链表扫描) |
南北绿豆:"所以MySQL选B+树,是因为:树矮(IO少)+ 范围查询快!"
🎯 核心问题:一棵3层B+树能存多少数据?
哈吉米:"具体怎么计算?"
阿西噶阿西:"来,我们一步步算。"
前置条件
InnoDB的配置:
- 数据页大小:16KB(固定)
- 主键:BIGINT(8字节)
- 指针:6字节
- 平均每行数据:1KB(假设)
计算非叶子节点
非叶子节点存储:
[索引键, 指针, 索引键, 指针, ...]
每个索引项:8B(索引键) + 6B(指针) = 14B
每页能存:16KB / 14B = 16384 / 14 ≈ 1170个索引键
计算叶子节点
叶子节点存储:
完整的数据行
每行数据:1KB(假设)
每页能存:16KB / 1KB = 16行数据
计算3层B+树
第1层(根节点):
1个节点 × 1170个指针 = 1170个子节点
第2层(中间节点):
1170个节点 × 1170个指针 = 1,368,900个子节点
第3层(叶子节点):
1,368,900个节点 × 16行数据 = 21,902,400行数据
结论:约2190万行数据
图解:
第1层(根):
1个节点
1170个指针
↓
第2层(中间):
1170个节点
每个1170个指针
总共:1170 × 1170 = 1,368,900个指针
↓
第3层(叶子):
1,368,900个节点
每个16行数据
总共:1,368,900 × 16 = 21,902,400行
南北绿豆:"所以一棵3层B+树能存约2200万数据!"
哈吉米:"卧槽,3次IO就能从2200万数据中找到一条,太快了!"
如果是4层B+树?
第1层:1个节点
第2层:1170个节点
第3层:1,368,900个节点
第4层:1,368,900 × 1170 = 1,601,613,000个节点
每个节点16行:
1,601,613,000 × 16 ≈ 256亿行
结论:4层B+树能存256亿数据
阿西噶阿西:"所以实际生产环境,B+树高度基本不超过4层!"
🎯 页分裂与页合并
页分裂(插入时)
场景:插入无序的数据(如UUID)
原始状态:
页1 [10, 25, 40](已满,只能存3个)
插入id=30:
30应该插入到25和40之间,但页1已满
触发页分裂:
页1 [10, 25] ← 分裂成两页
页1_new [30, 40]
时序图:
graph TD
A[插入id=30] --> B{页是否已满?}
B -->|否| C[直接插入]
B -->|是| D[页分裂]
D --> E[分配新页]
E --> F[复制部分数据到新页]
F --> G[插入新数据]
G --> H[更新父节点指针]
H --> I[写磁盘 IO慢]
C --> J[写磁盘 IO快]
style I fill:#FFB6C1
style J fill:#90EE90
代价:
- 分配新页(内存操作)
- 复制数据(内存拷贝)
- 更新父节点(修改索引)
- 写磁盘(随机IO,10-20ms)
性能影响:
| 主键类型 | 页分裂频率 | 插入性能 |
|---|---|---|
| 自增ID | 几乎无 | ⭐⭐⭐⭐⭐ |
| UUID | 频繁 | ⭐ |
页合并(删除时)
场景:删除大量数据
原始状态:
页1 [10, 25, 40]
页2 [50, 65, 80]
删除:25, 40, 50, 65
结果:
页1 [10]
页2 [80]
触发页合并:
页1 [10, 80] ← 合并
页2 被释放
南北绿豆:"页合并能释放空间,但如果DELETE后不做OPTIMIZE TABLE,空间不会真正释放给操作系统。"
🎯 为什么VARCHAR(255)这么常见?
哈吉米:"经常看到VARCHAR(255),为什么是255?"
阿西噶阿西:"这跟InnoDB的索引长度限制有关。"
索引长度限制
MySQL 5.6-:
最大索引长度:767字节
MySQL 5.7+(innodb_large_prefix=ON):
最大索引长度:3072字节
utf8字符集
utf8(3字节/字符):
VARCHAR(255) × 3 = 765字节 < 767字节 ✅
VARCHAR(256) × 3 = 768字节 > 767字节 ❌
所以VARCHAR(255)是utf8下能建索引的最大长度
utf8mb4字符集
utf8mb4(4字节/字符):
VARCHAR(191) × 4 = 764字节 < 767字节 ✅
VARCHAR(192) × 4 = 768字节 > 767字节 ❌
所以utf8mb4下,能建索引的最大长度是VARCHAR(191)
南北绿豆:"这就是VARCHAR(255)这么常见的原因——正好是utf8下能建索引的最大长度!"
🎓 面试标准答案
题目:为什么用B+树而不是B树、哈希、红黑树?
答案:
对比表:
| 数据结构 | 优点 | 缺点 | 为什么不用 |
|---|---|---|---|
| 哈希 | 等值查询O(1) | 不支持范围查询 | MySQL大量范围查询 |
| 红黑树 | 平衡性好 | 树高23层,IO多 | 1000万数据需要23次IO |
| B树 | 支持范围查询 | 扇出小,树高 | 非叶子节点存数据,浪费空间 |
| B+树 | 树矮、范围查询快 | 插入可能分裂 | ✅ 最优选择 |
B+树的优势:
- 树矮胖(3-4层存千万数据)
- 非叶子节点不存数据,扇出大73倍
- 叶子节点是双向链表,范围查询快
- IO次数少(3-4次)
题目:一棵3层B+树能存多少数据?
答案:
计算过程:
假设:
- 页大小:16KB
- 主键:BIGINT(8字节)
- 指针:6字节
- 每行数据:1KB
非叶子节点:
每页存:16KB / (8B + 6B) ≈ 1170个索引键
叶子节点:
每页存:16KB / 1KB = 16行数据
3层B+树:
第1层:1个节点
第2层:1170个节点
第3层:1,368,900个节点
总数据:1,368,900 × 16 ≈ 2200万行
结论:约2200万数据,3次IO就能查到。
题目:为什么VARCHAR(255)这么常见?
答案:
因为InnoDB索引长度限制:
- MySQL 5.6-:最大767字节
- utf8字符集:255 × 3 = 765字节 < 767字节 ✅
所以VARCHAR(255)是utf8下能建索引的最大长度。
utf8mb4下:
- 最大长度:191(191 × 4 = 764字节)
🎉 结束语
晚上11点,哈吉米终于搞懂了B+树的原理。
哈吉米:"原来B+树这么巧妙!树矮、扇出大、支持范围查询!"
南北绿豆:"对,这就是为什么InnoDB能支持千万级数据高速查询。"
阿西噶阿西:"记住:3层B+树存2200万,4层存256亿,所以树高基本不超过4层。"
哈吉米:"还有VARCHAR(255)是因为索引长度限制,涨姿势了!"
南北绿豆:"对,理解了原理,就知道为什么这样设计了。"
记忆口诀:
B+树矮胖IO少,非叶不存扇出大
叶子双向链表连,范围查询顺序扫
三层B+存两千万,四层能存两百亿
哈希不支持范围,红黑树高IO多
255是索引限,utf8三字节算
希望这篇文章能帮你彻底理解B+树索引的原理!把这些讲清楚,面试官一定给你加分!💪