Skip to content

索引原理与优化深度解析

面试官:MySQL 的索引是什么数据结构?

:MySQL InnoDB 使用 B+树作为索引的底层数据结构…

面试官:为什么用 B+树而不是 B树或者红黑树?

这个追问把很多人问住了。能说清「B+树的高扇出、范围查询优势、磁盘 IO 次数」的候选人,才能真正脱颖而出。


Q1:为什么 MySQL 选择 B+树作为索引结构?

Section titled “Q1:为什么 MySQL 选择 B+树作为索引结构?”
必考

核心原因:B+树在磁盘存储场景下具有压倒性优势。

数据结构树高(亿级数据)磁盘 IO 次数范围查询适用场景
哈希表-O(1)❌ 不支持内存数据库、等值查询
二叉搜索树~30 层~30 次✅ 支持内存结构
红黑树~30 层~30 次✅ 支持内存结构
B树3-4 层3-4 次⚠️ 需中序遍历文件系统
B+树3-4 层3-4 次✅ 极快数据库索引

B+树的三大优势

[30 | 60] ← 根节点(只存key)
/ | \
[10|20] [40|50] [70|80] ← 内部节点(只存key)
/ | \ / | \ / | \
[10] [20] [30][40][50][60][70][80][90] ← 叶节点(存key+data,双向链表)
  1. 非叶节点只存 key → 扇出更大 → 树更矮

    • B树非叶节点:16KB 页存 key(8B) + data(1KB) ≈ 15 个指针
    • B+树非叶节点:16KB 页存 key(8B) + 指针(6B) ≈ 1170 个指针
    • 结果:B+树高度更低,磁盘 IO 次数更少
  2. 叶节点双向链表 → 范围查询极快

    -- 查询 WHERE id > 100 AND id < 200
    1. 定位到 id=100 的叶节点(3次IO)
    2. 沿链表顺序扫描到 id=200(无需回溯)
    3. 只需一次遍历,无需重复定位
  3. 查询性能稳定

    • B树:可能在根节点就找到(快),也可能要到叶节点(慢)
    • B+树:所有查询都要到叶节点,性能可预测

本质一句话:B+树用「非叶节点只存 key」换取更高的扇出和更矮的树,用「叶节点链表」换取高效的范围查询。


高频

计算过程(以 InnoDB 默认配置为例):

假设:
- 数据页大小:16KB
- 主键 BIGINT:8 字节
- 指针:6 字节
- 一行数据:1KB
计算:
- 非叶节点指针数:16KB / (8B + 6B) ≈ 1170 个
- 叶节点记录数:16KB / 1KB = 16 条
树高与容量:
- 高度 2:1170 × 16 ≈ 1.8 万条
- 高度 3:1170 × 1170 × 16 ≈ 2190 万条
- 高度 4:1170 × 1170 × 1170 × 16 ≈ 256 亿条

结论

  • 绝大多数业务表(< 2 亿行)的主键索引树高度仅为 3 层
  • 查询一条记录最多 3 次磁盘 IO(约 10ms)

Q3:聚簇索引和二级索引有什么区别?

Section titled “Q3:聚簇索引和二级索引有什么区别?”
必考

核心差异:叶节点存储的内容不同。

聚簇索引(主键索引):
┌────────────────────────────────────┐
│ id=1 | name='Alice' | age=25 | ... │ ← 叶节点存整行数据
└────────────────────────────────────┘
二级索引(辅助索引):
┌─────────────────────────────┐
│ name='Alice' | id=1 │ ← 叶节点只存索引列 + 主键值
└─────────────────────────────┘
对比维度聚簇索引二级索引
叶节点存储整行数据索引列值 + 主键值
数量每表仅一棵可建多棵
查询方式直接返回数据需回表查询
存储顺序数据按主键顺序存储独立的 B+树
适用场景主键查询非主键字段查询

回表查询过程

-- 假设在 name 字段上建立了二级索引
SELECT * FROM users WHERE name = 'Alice';
执行过程:
1. 在 name 索引树中找到 'Alice' → 得到主键 id=1
2. 在聚簇索引树中根据 id=1 找到整行数据
3. 返回结果
代价:需要 2 次索引树查找(回表)

Q4:什么是索引覆盖?如何避免回表?

Section titled “Q4:什么是索引覆盖?如何避免回表?”
高频

索引覆盖:查询所需的所有字段都在索引树上,无需回表。

-- 假设在 (name, age) 上建立了联合索引
-- ❌ 需要回表(SELECT * 包含了索引中没有的字段)
SELECT * FROM users WHERE name = 'Alice';
-- ✅ 索引覆盖(只查询 name 和 age)
SELECT name, age FROM users WHERE name = 'Alice';
-- 执行计划中的 Extra 字段会显示:
-- Using index(表示索引覆盖)

性能对比

查询方式IO 次数性能
回表查询二级索引树 + 聚簇索引树
索引覆盖仅二级索引树快 2-3 倍

实战建议

  1. 只 SELECT 需要的字段,避免 SELECT *
  2. 为高频查询建立覆盖索引
  3. 使用 EXPLAIN 检查是否出现 Using index

必考

联合索引的匹配规则:索引 (a, b, c) 可以支持以下查询:

-- ✅ 可以用到索引
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
-- ❌ 用不到索引
WHERE b = 2 -- 跳过了 a
WHERE c = 3 -- 跳过了 a 和 b
WHERE a = 1 AND c = 3 -- 跳过了 b,只能用到 a

原理:联合索引是按照字段顺序组织的。

索引 (name, age) 的 B+树结构:
叶节点排序规则:
1. 先按 name 排序
2. name 相同时再按 age 排序
('Alice', 20) → ('Alice', 25) → ('Bob', 18) → ('Bob', 30)

优化技巧

-- 假设索引 (a, b, c)
-- ❌ 范围查询会中断后续字段的使用
WHERE a = 1 AND b > 2 AND c = 3
-- 只能用到的索引:(a, b)
-- ✅ 调整顺序,将范围查询放最后
WHERE a = 1 AND c = 3 AND b > 2
-- 可以用到的索引:(a, b, c)

高频

常见索引失效场景

-- 假设在 name 字段上有索引
-- ❌ 1. 对索引字段做函数运算
WHERE UPPER(name) = 'ALICE'
-- ❌ 2. 隐式类型转换
WHERE name = 123 -- name 是 VARCHAR,123 是 INT
-- ❌ 3. LIKE 以 % 开头
WHERE name LIKE '%Alice'
-- ❌ 4. OR 连接非索引字段
WHERE name = 'Alice' OR age = 25 -- age 没有索引
-- ❌ 5. NOT IN、NOT EXISTS、<>、!=
WHERE name NOT IN ('Alice', 'Bob')
-- ✅ 正确用法
WHERE name = 'Alice'
WHERE name LIKE 'Alice%'
WHERE name IN ('Alice', 'Bob')

索引失效的判断方法

EXPLAIN SELECT * FROM users WHERE UPPER(name) = 'ALICE';
-- 查看 type 字段:
-- ALL:全表扫描(索引失效)
-- ref、range、index:使用了索引

Q7:如何设计索引?有什么最佳实践?

Section titled “Q7:如何设计索引?有什么最佳实践?”
实战

索引设计原则

  1. 区分度高的字段优先

    -- ❌ 区分度低,不适合建索引
    gender(只有男/女)
    status(只有 0/1/2
    -- ✅ 区分度高,适合建索引
    user_id、email、phone

    区分度计算

    SELECT COUNT(DISTINCT column) / COUNT(*) FROM table;
    -- 区分度 > 0.7 才适合建索引
  2. 为排序和分组建立索引

    -- 经常 ORDER BY create_time
    CREATE INDEX idx_create_time ON orders(create_time);
    -- 经常 GROUP BY user_id
    CREATE INDEX idx_user_id ON orders(user_id);
  3. 控制索引数量

    • 单表索引数量建议不超过 5 个
    • 单个索引字段数不超过 5 个
    • 索引不是越多越好,会影响写入性能
  4. 前缀索引优化长字符串

    -- 对 VARCHAR(255) 的字段,只索引前 N 个字符
    CREATE INDEX idx_email ON users(email(20));
    -- 前缀长度选择:区分度 > 0.9
    SELECT COUNT(DISTINCT LEFT(email, 20)) / COUNT(*) FROM users;

实战案例

-- 场景:订单表查询
-- 高频查询:WHERE user_id = ? AND status = ? ORDER BY create_time DESC
-- ❌ 错误设计:三个单列索引
CREATE INDEX idx_user_id ON orders(user_id);
CREATE INDEX idx_status ON orders(status);
CREATE INDEX idx_create_time ON orders(create_time);
-- ✅ 正确设计:一个联合索引
CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);
-- 原因:
-- 1. user_id 区分度高,放在最左
-- 2. status 是等值查询,放在中间
-- 3. create_time 用于排序,放在最后

性能对比

索引设计索引数量查询性能写入性能
三个单列索引3 棵 B+树慢(只能用到一个索引)慢(需维护 3 棵树)
一个联合索引1 棵 B+树快(索引覆盖 + 排序优化)快(只需维护 1 棵树)

Q8:唯一索引和普通索引如何选择?

Section titled “Q8:唯一索引和普通索引如何选择?”
加分

性能差异

操作普通索引唯一索引
查询找到第一条记录后继续查找找到第一条记录后停止
插入直接插入需检查唯一性
Change Buffer✅ 可以使用❌ 不能使用

Change Buffer 机制

普通索引插入流程:
1. 数据页在内存中 → 直接插入
2. 数据页不在内存中 → 写入 Change Buffer(内存)
3. 后续读取该页时 → 合并 Change Buffer 到数据页
优势:减少磁盘 IO,提升插入性能

选择建议

  1. 业务要求唯一性 → 必须用唯一索引
  2. 仅用于查询优化 → 优先用普通索引
  3. 写入频繁的表 → 优先用普通索引(利用 Change Buffer)

索引设计三原则:
1. 选择合适的字段(区分度高、查询频繁)
2. 建立合适的索引类型(单列/联合/唯一/前缀)
3. 避免索引失效(函数、类型转换、%开头)
索引优化三步骤:
1. 用 EXPLAIN 分析执行计划
2. 检查是否用到了索引(type、key、Extra)
3. 根据慢查询日志持续优化