MySQL 实战精通系列 · 第4篇:索引原理与执行计划实战
本篇目标:搞懂索引为什么快、什么时候失效、怎么设计,并能用 EXPLAIN 独立分析慢 SQL。
文章目录
- [MySQL 实战精通系列 · 第4篇:索引原理与执行计划实战](#MySQL 实战精通系列 · 第4篇:索引原理与执行计划实战)
-
- 一、为什么需要索引?
-
- [1.1 没有索引会怎样?](#1.1 没有索引会怎样?)
- [1.2 有索引会怎样?](#1.2 有索引会怎样?)
- 二、B+树索引原理
-
- [2.1 为什么是 B+树?](#2.1 为什么是 B+树?)
- [2.2 B+树特点](#2.2 B+树特点)
- [2.3 一棵 B+树能存多少行?](#2.3 一棵 B+树能存多少行?)
- [三、聚簇索引 vs 二级索引](#三、聚簇索引 vs 二级索引)
-
- [3.1 聚簇索引(Clustered Index)](#3.1 聚簇索引(Clustered Index))
- [3.2 二级索引(Secondary Index)](#3.2 二级索引(Secondary Index))
- [3.3 回表](#3.3 回表)
- 四、联合索引与最左前缀
-
- [4.1 联合索引结构](#4.1 联合索引结构)
- [4.2 最左前缀原则](#4.2 最左前缀原则)
- [4.3 实战:设计联合索引](#4.3 实战:设计联合索引)
- 五、覆盖索引、回表、索引下推
-
- [5.1 覆盖索引](#5.1 覆盖索引)
- [5.2 索引下推(ICP)](#5.2 索引下推(ICP))
- [5.3 三种扫描方式对比](#5.3 三种扫描方式对比)
- [六、EXPLAIN 实战](#六、EXPLAIN 实战)
-
- [6.1 EXPLAIN 输出字段](#6.1 EXPLAIN 输出字段)
- [6.2 type 访问类型(从好到差)](#6.2 type 访问类型(从好到差))
- [6.3 Extra 常见值](#6.3 Extra 常见值)
- [6.4 实战分析](#6.4 实战分析)
- [七、索引失效的 7 种场景](#七、索引失效的 7 种场景)
-
- [7.1 函数操作](#7.1 函数操作)
- [7.2 隐式类型转换](#7.2 隐式类型转换)
- [7.3 LIKE 以 % 开头](#7.3 LIKE 以 % 开头)
- [7.4 OR 使用不当](#7.4 OR 使用不当)
- [7.5 联合索引不满足最左前缀](#7.5 联合索引不满足最左前缀)
- [7.6 范围查询后的列](#7.6 范围查询后的列)
- [7.7 不等于、NOT IN、IS NOT NULL](#7.7 不等于、NOT IN、IS NOT NULL)
- [7.8 索引失效速查表](#7.8 索引失效速查表)
- 八、索引设计原则
-
- [8.1 什么时候该加索引?](#8.1 什么时候该加索引?)
- [8.2 联合索引字段顺序](#8.2 联合索引字段顺序)
- [8.3 索引设计检查清单](#8.3 索引设计检查清单)
- [九、实战:100 万数据压测](#九、实战:100 万数据压测)
-
- [9.1 生成 100 万订单数据](#9.1 生成 100 万订单数据)
- [9.2 对比有无索引](#9.2 对比有无索引)
- [9.3 对比联合索引](#9.3 对比联合索引)
- 十、本篇实战任务
- 十一、本篇小结
一、为什么需要索引?
1.1 没有索引会怎样?
假设 orders 表有 100 万行,执行:
sql
SELECT * FROM orders WHERE user_id = 12345;
没有索引时,MySQL 只能全表扫描:
磁盘上的数据页
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│ 1 │ │ 2 │ │ 3 │ │... │ │100w│
└────┘ └────┘ └────┘ └────┘ └────┘
↑
逐个检查 user_id 是否等于 12345
100 万行 → 可能要读几千个数据页 → 几秒甚至几十秒。
1.2 有索引会怎样?
┌─────────┐
│ 根节点 │
└────┬────┘
│
┌──────────┼──────────┐
↓ ↓ ↓
┌───────┐ ┌───────┐ ┌───────┐
│ 中间节点│ │ 中间节点│ │ 中间节点│
└───┬───┘ └───┬───┘ └───┬───┘
│ │ │
┌──┴──┐ ┌──┴──┐ ┌──┴──┐
↓ ↓ ↓ ↓ ↓ ↓
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│叶 │ │叶 │ │叶 │ │叶 │ │叶 │ │叶 │
└───┘ └───┘ └───┘ └───┘ └───┘ └───┘
B+树高度通常 3~4 层,100 万行只需 3~4 次磁盘 IO,毫秒级返回。
索引的本质:用空间换时间,把 O(n) 变成 O(log n)。
二、B+树索引原理
2.1 为什么是 B+树?
| 数据结构 | 范围查询 | 磁盘 IO | 是否适合 |
|---|---|---|---|
| 哈希表 | 不支持 | 1 次 | 等值查询快,范围查询差 |
| 二叉搜索树 | 支持 | 高(树太高) | 不适合磁盘 |
| 红黑树 | 支持 | 高 | 不适合磁盘 |
| B 树 | 支持 | 较低 | 数据分散在各层 |
| B+树 | 支持 | 低 | 最适合磁盘 |
2.2 B+树特点
B+树结构
│
├── 非叶子节点只存索引键,不存数据
├── 数据全部存在叶子节点
├── 叶子节点之间用链表连接
└── 树高度低(3~4层),磁盘 IO 少
┌─────────────────────┐
│ 非叶子节点(只存键) │
│ 10 | 20 | 30 │
└──┬──────┬──────┬────┘
│ │ │
┌─────┘ │ └─────┐
↓ ↓ ↓
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 叶子节点 │→ │ 叶子节点 │→ │ 叶子节点 │
│ 5,8,10 │ │ 15,18,20│ │ 25,28,30│
│ 数据指针 │ │ 数据指针 │ │ 数据指针 │
└─────────┘ └─────────┘ └─────────┘
↑ 链表连接,支持范围查询
2.3 一棵 B+树能存多少行?
假设:
- 主键 BIGINT:8 字节
- 页大小 16KB
- 非叶子节点存 键+指针 ≈ 8+6 = 14 字节
- 叶子节点存 一行数据 ≈ 1KB
计算:
非叶子节点:16KB / 14B ≈ 1170 个键
叶子节点:16KB / 1KB ≈ 16 行
3 层 B+树:
1170 × 1170 × 16 ≈ 2190 万行
结论:3 层 B+树能存约 2000 万行,只需 3 次磁盘 IO。
三、聚簇索引 vs 二级索引
3.1 聚簇索引(Clustered Index)
InnoDB 的主键索引就是聚簇索引,叶子节点存整行数据。
聚簇索引(主键 id)
┌─────────────┐
│ 根节点 │
│ 10 | 20 │
└──┬───────┬──┘
↓ ↓
┌─────────┐ ┌─────────┐
│ 叶子节点 │ │ 叶子节点 │
│ id=10 │ │ id=20 │
│ 整行数据 │ │ 整行数据 │
└─────────┘ └─────────┘
特点:
- 一张表只有一个聚簇索引
- 按主键顺序存储
- 主键查询最快
3.2 二级索引(Secondary Index)
非主键索引都是二级索引,叶子节点存主键值。
二级索引(user_id)
┌─────────────┐
│ 根节点 │
│ 100 | 200 │
└──┬───────┬──┘
↓ ↓
┌─────────┐ ┌─────────┐
│ 叶子节点 │ │ 叶子节点 │
│user_id=100│ │user_id=200│
│ 主键id=5 │ │ 主键id=8 │
└─────────┘ └─────────┘
3.3 回表
用二级索引查询时,需要再回聚簇索引查整行,这叫回表。
sql
SELECT * FROM orders WHERE user_id = 100;
执行过程:
① 在 user_id 二级索引找到 user_id=100 的记录
→ 拿到主键 id = 5
② 回聚簇索引,用 id = 5 查整行数据
→ 这就是回表
二级索引 聚簇索引
┌─────────┐ ┌─────────┐
│user_id=100│ │ id=5 │
│ id=5 │──────────>│ 整行数据 │
└─────────┘ 回表 └─────────┘
回表次数越多,性能越差。
四、联合索引与最左前缀
4.1 联合索引结构
sql
CREATE INDEX idx_user_status_created ON orders (user_id, status, created_at);
联合索引按字段顺序排序:
先按 user_id 排序
user_id 相同,再按 status 排序
status 相同,再按 created_at 排序
┌─────────────────────────────────────┐
│ user_id │ status │ created_at │
├─────────┼────────┼──────────────────┤
│ 1 │ 0 │ 2024-01-01 │
│ 1 │ 0 │ 2024-01-02 │
│ 1 │ 1 │ 2024-01-01 │
│ 2 │ 0 │ 2024-01-01 │
│ 2 │ 1 │ 2024-01-03 │
└─────────────────────────────────────┘
4.2 最左前缀原则
索引 (user_id, status, created_at)
能用上索引的查询:
WHERE user_id = 1 ✅
WHERE user_id = 1 AND status = 1 ✅
WHERE user_id = 1 AND status = 1 AND created_at > '2024-01-01' ✅
WHERE user_id = 1 AND created_at > '2024-01-01' ⚠️ 只用 user_id
WHERE status = 1 ❌ 用不上
WHERE created_at > '2024-01-01' ❌ 用不上
记忆口诀:从左到右,遇到范围就停。
(user_id, status, created_at)
① ② ③
① 等值 → 继续
② 等值 → 继续
③ 范围 → 停止,后面的列用不上
4.3 实战:设计联合索引
sql
-- 查询:某用户已支付订单,按时间倒序
SELECT * FROM orders
WHERE user_id = 1 AND status = 1
ORDER BY created_at DESC;
-- 推荐索引
CREATE INDEX idx_user_status_created ON orders (user_id, status, created_at);
五、覆盖索引、回表、索引下推
5.1 覆盖索引
查询的列全在索引里,不需要回表。
sql
-- 需要回表
SELECT * FROM orders WHERE user_id = 1;
-- 覆盖索引,不用回表
SELECT user_id, status FROM orders WHERE user_id = 1;
-- 如果索引是 (user_id, status)
EXPLAIN 中显示 Using index 就是覆盖索引。
5.2 索引下推(ICP)
MySQL 5.6+ 支持,在索引遍历时就过滤,减少回表。
sql
SELECT * FROM orders
WHERE user_id = 1 AND status = 1;
没有 ICP:
① 用 user_id = 1 从索引找到所有记录
② 回表查整行
③ 再过滤 status = 1
有 ICP:
① 用 user_id = 1 从索引找到记录
② 在索引里直接过滤 status = 1
③ 只对满足条件的回表
EXPLAIN 中显示 Using index condition。
5.3 三种扫描方式对比
| 方式 | EXPLAIN Extra | 是否回表 | 性能 |
|---|---|---|---|
| 全表扫描 | Using where | 否 | 最差 |
| 索引 + 回表 | Using index condition | 是 | 一般 |
| 覆盖索引 | Using index | 否 | 最好 |
六、EXPLAIN 实战
6.1 EXPLAIN 输出字段
sql
EXPLAIN SELECT * FROM orders WHERE user_id = 1;
| 字段 | 含义 | 关注点 |
|---|---|---|
| id | 查询序号 | 越大越先执行 |
| select_type | 查询类型 | SIMPLE / PRIMARY / SUBQUERY |
| table | 表名 | --- |
| type | 访问类型 | 最重要 |
| possible_keys | 可能用的索引 | --- |
| key | 实际用的索引 | 是否用了预期索引 |
| key_len | 索引长度 | 用了联合索引几列 |
| ref | 索引比较的列 | --- |
| rows | 预估扫描行数 | 越小越好 |
| filtered | 过滤百分比 | --- |
| Extra | 额外信息 | 很重要 |
6.2 type 访问类型(从好到差)
system → const → eq_ref → ref → range → index → ALL
最好 最差
| type | 含义 | 场景 |
|---|---|---|
| system | 系统表,只有一行 | --- |
| const | 主键或唯一索引等值查询 | WHERE id = 1 |
| eq_ref | JOIN 时主键关联 | JOIN ON a.id = b.id |
| ref | 普通索引等值查询 | WHERE user_id = 1 |
| range | 索引范围查询 | WHERE id > 100 |
| index | 全索引扫描 | 扫描整个索引树 |
| ALL | 全表扫描 | 最差,必须优化 |
6.3 Extra 常见值
| Extra | 含义 | 好坏 |
|---|---|---|
| Using index | 覆盖索引 | 好 |
| Using index condition | 索引下推 | 好 |
| Using where | 需要过滤 | 一般 |
| Using filesort | 文件排序 | 差 |
| Using temporary | 临时表 | 差 |
| Using join buffer | JOIN 缓冲 | 一般 |
6.4 实战分析
sql
-- 案例1:主键查询
EXPLAIN SELECT * FROM orders WHERE id = 1;
-- type: const, key: PRIMARY, rows: 1
-- 案例2:普通索引查询
EXPLAIN SELECT * FROM orders WHERE user_id = 1;
-- type: ref, key: idx_user_created, rows: 预估
-- 案例3:范围查询
EXPLAIN SELECT * FROM orders WHERE id > 100;
-- type: range, key: PRIMARY
-- 案例4:全表扫描
EXPLAIN SELECT * FROM orders WHERE total_amount > 100;
-- type: ALL, key: NULL, rows: 1000000 ← 需要优化
-- 案例5:覆盖索引
EXPLAIN SELECT user_id, status FROM orders WHERE user_id = 1;
-- Extra: Using index
-- 案例6:文件排序
EXPLAIN SELECT * FROM orders ORDER BY total_amount;
-- Extra: Using filesort ← 需要优化
七、索引失效的 7 种场景
7.1 函数操作
sql
-- 失效
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-01';
-- 优化
SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02';
7.2 隐式类型转换
sql
-- phone 是 VARCHAR,传数字会失效
SELECT * FROM users WHERE phone = 13800000001;
-- 正确
SELECT * FROM users WHERE phone = '13800000001';
7.3 LIKE 以 % 开头
sql
-- 失效
SELECT * FROM products WHERE name LIKE '%iPhone%';
-- 有效
SELECT * FROM products WHERE name LIKE 'iPhone%';
7.4 OR 使用不当
sql
-- 如果 status 没索引,整体失效
SELECT * FROM orders WHERE user_id = 1 OR status = 1;
-- 优化:两个字段都加索引,或用 UNION
SELECT * FROM orders WHERE user_id = 1
UNION
SELECT * FROM orders WHERE status = 1;
7.5 联合索引不满足最左前缀
sql
-- 索引 (user_id, status, created_at)
SELECT * FROM orders WHERE status = 1; -- 失效
7.6 范围查询后的列
sql
-- 索引 (user_id, status, created_at)
SELECT * FROM orders
WHERE user_id = 1 AND status > 0 AND created_at = '2024-01-01';
-- created_at 用不上索引
7.7 不等于、NOT IN、IS NOT NULL
sql
-- 可能失效
SELECT * FROM orders WHERE status != 1;
SELECT * FROM orders WHERE status NOT IN (1, 2);
SELECT * FROM orders WHERE total_amount IS NOT NULL;
7.8 索引失效速查表
| 场景 | 是否失效 | 优化方式 |
|---|---|---|
| 函数操作 | 失效 | 改写条件 |
| 隐式类型转换 | 失效 | 类型一致 |
| LIKE '%x' | 失效 | 改前缀匹配 |
| OR 一边无索引 | 失效 | UNION |
| 不满足最左前缀 | 失效 | 调整索引 |
| 范围后列 | 失效 | 调整索引顺序 |
| != / NOT IN | 可能失效 | 改写 |
八、索引设计原则
8.1 什么时候该加索引?
该加索引
│
├── 频繁出现在 WHERE 的列
├── 频繁出现在 ORDER BY 的列
├── 频繁出现在 JOIN ON 的列
├── 区分度高的列
└── 覆盖索引能覆盖的列
不该加索引
│
├── 区分度低的列(性别、状态)
├── 频繁更新的列
├── 很少查询的列
├── 表数据量很小
└── 索引已经很多了
8.2 联合索引字段顺序
原则:
① 等值查询字段放前面
② 范围查询字段放后面
③ 区分度高的放前面
④ 考虑覆盖索引
示例:
查询:WHERE user_id = ? AND status = ? ORDER BY created_at DESC
索引:(user_id, status, created_at)
查询:WHERE status = ? AND user_id = ?
索引:(user_id, status) -- 等值查询顺序可互换
8.3 索引设计检查清单
索引设计检查
│
├── 主键是否用 BIGINT UNSIGNED?
├── 外键字段是否加了索引?
├── 高频查询字段是否加了索引?
├── 联合索引顺序是否合理?
├── 是否有冗余索引?
├── 是否有未使用的索引?
└── 区分度是否足够?
九、实战:100 万数据压测
9.1 生成 100 万订单数据
sql
DELIMITER $$
CREATE PROCEDURE gen_orders_big(IN cnt INT)
BEGIN
DECLARE i INT DEFAULT 0;
WHILE i < cnt DO
INSERT INTO orders (user_id, total_amount, status, created_at)
VALUES (
FLOOR(RAND() * 100000) + 1,
ROUND(RAND() * 10000, 2),
FLOOR(RAND() * 5),
DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY)
);
SET i = i + 1;
END WHILE;
END$$
DELIMITER ;
CALL gen_orders_big(1000000);
9.2 对比有无索引
sql
-- 无索引:全表扫描
EXPLAIN SELECT * FROM orders WHERE user_id = 50000;
-- type: ALL, rows: 1000000
-- 加索引
CREATE INDEX idx_user ON orders (user_id);
-- 有索引
EXPLAIN SELECT * FROM orders WHERE user_id = 50000;
-- type: ref, key: idx_user, rows: 约10
9.3 对比联合索引
sql
-- 单列索引
CREATE INDEX idx_user ON orders (user_id);
-- 联合索引
CREATE INDEX idx_user_status_created ON orders (user_id, status, created_at);
-- 查询:某用户已支付订单,按时间倒序
EXPLAIN SELECT * FROM orders
WHERE user_id = 1 AND status = 1
ORDER BY created_at DESC;
-- 联合索引:type: ref, key: idx_user_status_created, Extra: Using index condition
十、本篇实战任务
任务清单
- 生成 100 万订单数据
- 用 EXPLAIN 分析 5 条 SQL
- 复现 7 种索引失效场景
- 设计 3 个联合索引并验证
- 对比有无索引的查询耗时
- 找出冗余索引
自检问题
- B+树为什么适合磁盘存储?
- 聚簇索引和二级索引的区别?
- 什么是回表?怎么避免?
- 最左前缀原则是什么?
- EXPLAIN 中 type 有哪些值?哪个最差?
- Using index、Using index condition、Using filesort 分别什么意思?
- 索引失效的常见场景有哪些?
十一、本篇小结
第4篇 核心收获
│
├── 索引原理
│ ├── B+树:3层存2000万行
│ ├── 聚簇索引:叶子存整行
│ └── 二级索引:叶子存主键
│
├── 回表与覆盖索引
│ ├── 回表:二级索引 → 聚簇索引
│ └── 覆盖索引:Using index
│
├── 联合索引
│ ├── 最左前缀原则
│ └── 等值在前,范围在后
│
├── EXPLAIN
│ ├── type:system > const > eq_ref > ref > range > index > ALL
│ ├── key:实际用的索引
│ ├── rows:预估扫描行数
│ └── Extra:Using index / filesort / temporary
│
├── 索引失效
│ ├── 函数操作
│ ├── 隐式转换
│ ├── LIKE '%x'
│ ├── OR 无索引
│ ├── 不满足最左前缀
│ ├── 范围后列
│ └── != / NOT IN
│
└── 索引设计
├── 该加:WHERE / ORDER BY / JOIN
├── 不该加:低区分度、频繁更新
└── 联合索引:等值在前,范围在后
下一篇:第5篇《事务、隔离级别与锁实战》