InnoDB的B+树索引:为什么一棵树能存2000万数据?

摘要:从一次"为什么我建了索引查询还是慢"的困惑出发,深度剖析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=10086hash(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 / 141170个索引键

计算叶子节点

叶子节点存储:
完整的数据行

每行数据: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+树的优势

  1. 树矮胖(3-4层存千万数据)
  2. 非叶子节点不存数据,扇出大73倍
  3. 叶子节点是双向链表,范围查询快
  4. 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+树索引的原理!把这些讲清楚,面试官一定给你加分!💪