MySQL索引为何钟情B+树的爱情故事 💕🌳

50 阅读14分钟

一、开篇故事:图书馆的三种索引方式 📚

想象你在一个超大图书馆找书,有三种查找方式:

方式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表 #️⃣
候选7B树 🌲
候选8B+树 🌳 ← 最终赢家!

2.2 为什么不用数组?

数组存储:[1, 3, 5, 7, 9, 11, 13, ...]

查找ID=7的记录:
  → 二分查找:O(log n) ✅

插入ID=6的记录:
  → 需要移动后面所有元素:O(n) ❌

删除ID=5的记录:
  → 需要移动后面所有元素:O(n) ❌

总结:查询可以,增删太慢!

2.3 为什么不用链表?

链表存储:13579 → ...

查找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=12345hash(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
  
能存多少key16KB / (8B + 1KB) ≈ 15B+树节点(不存数据):
┌─────────────────────────┐
│ key1 | key2 | key3 | ...│
└─────────────────────────┘
  
能存多少key16KB / 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. 找到起点203次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万) ≈ 5B+树非叶子节点:
  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 ≈ 1600key

树的高度计算:
  第1层(根):1个节点,1600key2层:1600个节点,1600×1600=256万个key3层(叶子):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. 定位到1000003次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树?

答:

  1. B+树更矮:非叶子节点不存数据,能存更多key,减少IO
  2. 范围查询快:叶子节点有链表,直接遍历
  3. 全表扫描快:遍历叶子链表即可
  4. 性能稳定:所有查询都到叶子节点,路径长度一致

Q2: 为什么不用Hash索引?

答: Hash索引虽然等值查询O(1),但:

  1. 不支持范围查询
  2. 不支持排序
  3. 不支持最左前缀匹配
  4. 有哈希冲突

数据库中范围查询、排序非常常见,所以不适合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

愿你的索引永远高效! 🚀📈