索引深度原理
一、B+ 树结构
1.1 B+ 树特征
MySQL InnoDB 引擎使用 B+ 树作为索引结构,其核心特征:
- 非叶子节点只存储键值(索引列)和指向子节点的指针,不存数据
- 叶子节点存储完整的主键值或数据行(取决于聚簇还是非聚簇)
- 叶子节点之间通过双向链表连接,支持范围查询
- 同一层节点通过单向链表连接
[50]
/ \
[20,30] [70,80]
/ | \ / | \
[1~19] [21~29] [31~49] [51~69] [71~79] [81~99]
<-- 叶子节点之间双向链表连接 -->
1.2 B+ 树 vs B 树 vs 哈希索引
| 特性 | B+ 树 | B 树 | Hash 索引 |
|---|---|---|---|
| 非叶子节点存数据 | 否 | 是 | / |
| 范围查询 | 高效(链表) | 需要中序遍历 | 不支持 |
| 等值查询 | O(log n) | O(log n) | O(1) |
| 排序支持 | 直接支持 | 需额外排序 | 不支持 |
| 最左前缀匹配 | 支持 | 支持 | 不支持 |
| 适用场景 | 绝大多数场景 | 已淘汰 | Memory 引擎、自适应哈希 |
为什么 MySQL 选择 B+ 树而不是 B 树?
- IO 次数更少:非叶子节点不存数据,同一节点能存储更多键值,树高度更低(一般 3~4 层)
- 范围查询高效:叶子节点双向链表,直接遍历即可
- 查询稳定:任何查询都要走到叶子节点,IO 次数相对稳定
1.3 B+ 树层高计算
sql
-- 假设:
-- 页大小:16KB
-- 主键 BIGINT:8 字节
-- 指针:6 字节
-- 每个非叶子节点可存:16KB / (8+6) ≈ 1170 条
-- 叶子节点每条数据约 1KB,每页存 16 条
-- 层高 3 的 B+ 树可存:1170 * 1170 * 16 ≈ 2190 万条数据
-- 层高 4 的 B+ 树可存:1170 * 1170 * 1170 * 16 ≈ 256 亿条数据
-- 结论:3~4 层 B+ 树就能支撑绝大多数业务表
二、聚簇索引与非聚簇索引
2.1 聚簇索引(Clustered Index)
InnoDB 中,聚簇索引就是表数据的物理存储顺序。
| 情况 | 聚簇索引选择 |
|---|---|
| 有主键 | 主键作为聚簇索引 |
| 无主键但有唯一非空索引 | 第一个唯一非空索引作为聚簇索引 |
| 都没有 | InnoDB 自动生成 6 字节的 ROWID |
聚簇索引的特点:
- 叶子节点存储完整的数据行
- 一张表只能有一个聚簇索引
- 数据按主键顺序物理存储
sql
-- InnoDB 表结构
CREATE TABLE users (
id INT PRIMARY KEY, -- 聚簇索引:叶子节点存储 id,name,email 等完整数据
name VARCHAR(50),
email VARCHAR(100)
);
聚簇索引结构:
Non-leaf: [1] -> [10] -> [20]
Leaf: [1, '张三', 'zhang@...'] <-> [2, '李四', 'li@...'] <-> ...
2.2 非聚簇索引(Secondary Index / 二级索引)
非聚簇索引的叶子节点存储的是主键值,不是数据行。
sql
-- 创建二级索引
CREATE INDEX idx_name ON users(name);
非聚簇索引结构(idx_name):
Non-leaf: ['李'] -> ['王'] -> ['张']
Leaf: ['李四', 2] <-> ['张三', 1] <-> ['王五', 3]
↑ ↑
name 主键id
2.3 回表查询(Bookmark Lookup)
sql
-- 通过二级索引查询
SELECT * FROM users WHERE name = '张三';
-- 执行步骤:
-- 1. 从 idx_name 索引树找到 '张三' 对应的主键值 id=1
-- 2. 用主键 id=1 到聚簇索引树查找完整数据行
-- 这就是"回表"
查询流程:
idx_name树 聚簇索引树
'张三' -> id=1 → id=1 -> 完整数据行
↑
回表查询
三、联合索引的最左前缀原则
3.1 原理
联合索引 (a, b, c) 的 B+ 树按 (a, b, c) 的顺序排序:
- 先按 a 排序
- a 相同的情况下按 b 排序
- a、b 都相同的情况下按 c 排序
sql
CREATE INDEX idx_a_b_c ON t (a, b, c);
索引排序逻辑:
(1, 1, 1) -> (1, 1, 2) -> (1, 2, 1) -> (1, 2, 2) ->
(2, 1, 1) -> (2, 1, 2) -> (2, 2, 1) -> (2, 2, 2)
3.2 匹配规则
sql
-- 能用到索引的场景:
WHERE a = 1 -- 用到 a
WHERE a = 1 AND b = 2 -- 用到 a,b
WHERE a = 1 AND b = 2 AND c = 3 -- 用到 a,b,c
WHERE a = 1 ORDER BY b -- 用到 a(排序用到 b)
WHERE a IN (1,2) AND b = 1 -- 用到 a,b(IN 会优化)
-- 不能用到索引的场景:
WHERE b = 2 -- 跳过了 a
WHERE c = 3 -- 跳过了 a,b
WHERE b = 2 AND c = 3 -- 跳过了 a
-- 部分用到索引的场景:
WHERE a = 1 AND c = 3 -- 只用到 a(b 被跳过,c 用不到)
WHERE a > 1 AND b = 2 -- 只用到 a(范围查询后失效)
3.3 实战:索引设计顺序
sql
-- 业务查询模式
-- Q1: SELECT * FROM orders WHERE user_id = ? AND status = ?
-- Q2: SELECT * FROM orders WHERE user_id = ? ORDER BY create_time DESC
-- Q3: SELECT * FROM orders WHERE status = ? AND create_time > ?
-- 索引设计方案
-- 联合索引 A: idx_user_status (user_id, status) -- 覆盖 Q1
-- 联合索引 B: idx_user_create (user_id, create_time) -- 覆盖 Q2
-- 联合索引 C: idx_status_create (status, create_time) -- 覆盖 Q3
口诀:等值在前、排序在后、区分度高在前、范围查询放最后。
四、覆盖索引与索引下推
4.1 覆盖索引(Covering Index)
当查询所需的所有字段都在索引中时,无需回表。
sql
-- 索引:idx_name_age (name, age)
-- 需要回表(因为 * 包含未索引字段)
SELECT * FROM users WHERE name = '张三';
-- 覆盖索引(无需回表,所有字段都在索引中)
SELECT name, age FROM users WHERE name = '张三';
覆盖索引流程:
idx_name_age树
'张三' -> (name='张三', age=25, id=1)
直接返回,无需访问聚簇索引
非覆盖索引流程:
idx_name_age树 -> 找到 id=1
-> 再到聚簇索引树查找完整数据 -> 回表
4.2 索引下推(Index Condition Pushdown, ICP)
MySQL 5.6+ 优化:在索引遍历过程中,提前过滤不满足条件的记录,减少回表次数。
sql
-- 联合索引 idx_name_age (name, age)
-- 没有ICP时:
SELECT * FROM users WHERE name LIKE '张%' AND age = 25;
-- 1. 通过 name LIKE '张%' 找到所有满足条件的索引记录
-- 2. 回表,拿到完整数据行
-- 3. 在服务层过滤 age = 25
-- 启用ICP后:
-- 1. 通过 name LIKE '张%' 遍历索引
-- 2. 在索引遍历过程中,直接判断 age = 25 是否满足
-- 3. 只回表 age=25 的记录
ICP 适用条件:
- 只能用于二级索引
- 条件中只能用索引包含的字段做过滤
- 不支持下推条件中包含子查询
4.3 自适应哈希索引(Adaptive Hash Index, AHI)
InnoDB 自动监控索引查询,如果发现某些索引值频繁被等值查询访问,会在 B+ 树索引之上自动建立哈希索引。
sql
-- 查看 AHI 状态
SHOW ENGINE INNODB STATUS \G; -- 查看 Hash searches/s 等
SHOW VARIABLES LIKE 'innodb_adaptive_hash_index'; -- 默认 ON
AHI 的优缺点:
| 优点 | 缺点 |
|---|---|
| 等值查询 O(1) 快速定位 | 占用缓冲池内存 |
| 减少 B+ 树寻路次数 | 写操作时需要维护哈希索引 |
| 对热点数据自动优化 | 不可控,偶发性能抖动 |
五、索引失效场景
5.1 常见失效场景及示例
sql
-- 1. 对索引列使用函数
CREATE INDEX idx_create_time ON orders(create_time);
WHERE DATE(create_time) = '2024-01-01' -- 索引失效
-- 优化:
WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02'
-- 2. 隐式类型转换
CREATE INDEX idx_phone ON users(phone); -- phone 是 VARCHAR
WHERE phone = 13812345678 -- 索引失效(数字转字符串)
-- 优化:
WHERE phone = '13812345678'
-- 3. LIKE 以通配符开头
CREATE INDEX idx_name ON users(name);
WHERE name LIKE '%张三%' -- 索引失效
-- 优化:
WHERE name LIKE '张三%' -- 可以用到索引
-- 4. 联合索引违反最左前缀
CREATE INDEX idx_a_b_c ON t (a, b, c);
WHERE b = 1 AND c = 2 -- 跳过了 a,索引失效
-- 5. 使用 != 或 <>
WHERE status != 1 -- 索引失效(除外特殊情况)
-- 优化:
WHERE status IN (0, 2, 3)
-- 6. IS NOT NULL 可能导致失效
CREATE INDEX idx_email ON users(email);
WHERE email IS NOT NULL -- 可能索引失效
-- 优化:根据实际业务区分度判断
-- 7. OR 条件中有一个字段无索引
CREATE INDEX idx_name ON users(name);
WHERE name = '张三' OR age = 25 -- age 无索引,整个 OR 条件索引失效
-- 优化:给 age 也建索引,或用 UNION
WHERE name = '张三' UNION ALL SELECT * FROM users WHERE age = 25
-- 8. NOT IN / NOT EXISTS
WHERE id NOT IN (1, 2, 3) -- 索引失效
-- 优化:用 NOT EXISTS 或业务绕过
5.2 索引失效避坑自查表
| 操作 | 是否导致索引失效 | 正确写法 |
|---|---|---|
DATE(col) = '2024-01-01' |
✔ | 范围查询 |
col = 123(varchar 字段) |
✔ | 带引号 |
LIKE '%abc' |
✔ | 前缀匹配 |
| 联合索引跳过前置列 | ✔ | 调整查询或索引 |
IS NULL |
视情况 | 取决于数据分布 |
OR 中混用无索引列 |
✔ | 建索引或用 UNION |
IN / BETWEEN |
通常有效 | 优化器会优化 |
ORDER BY 字段不在索引中 |
排序失效 | 建合适索引 |
| 数据量极小(< 1000) | 优化器可能放弃索引 | 正常现象 |
六、EXPLAIN 执行计划解析
通过 EXPLAIN 可以查看 SQL 的索引使用情况,是分析索引是否生效的核心工具。
6.1 基本用法
sql
-- 查看执行计划
EXPLAIN SELECT * FROM users WHERE id = 1;
-- 详细格式(JSON)
EXPLAIN FORMAT=JSON SELECT * FROM users WHERE id = 1\G
-- MySQL 8.0.16+ 实际执行分析
EXPLAIN ANALYZE SELECT * FROM users WHERE id = 1;
-- 查看已执行的 SQL 执行计划(performance_schema)
SELECT * FROM performance_schema.events_statements_history
WHERE digest_text LIKE '%SELECT%users%'\G
6.2 各字段含义(索引视角)
以查询 EXPLAIN SELECT * FROM t WHERE a = 1 AND b > 2 ORDER BY c 为例,输出如下:
| 字段 | 示例值 | 索引相关含义 |
|---|---|---|
| type | range |
索引访问方式,从好到差:const > eq_ref > ref > range > index > ALL |
| possible_keys | idx_a_b_c |
可能用到的索引,如果为 NULL 表示没有可用索引 |
| key | idx_a_b_c |
实际使用的索引,为 NULL 表示全表扫描 |
| key_len | 10 |
索引使用的字节数,可判断联合索引用了多少列 |
| ref | const |
索引匹配的列或常量,显示哪些列被用于索引查找 |
| rows | 100 |
InnoDB 预估扫描行数,值越大说明过滤效果越差 |
| filtered | 33.33 |
按表条件过滤后剩余行数百分比 |
| Extra | Using index condition |
额外信息,包含大量索引使用线索 |
6.3 type 详解(从索引视角)
sql
-- const:主键或唯一索引等值查询,最多返回一行
-- 说明索引利用最充分,性能最优
EXPLAIN SELECT * FROM users WHERE id = 1;
-- type: const, key: PRIMARY
-- eq_ref:JOIN 时使用主键或唯一索引关联
-- 说明关联命中索引
EXPLAIN SELECT * FROM orders o
JOIN users u ON o.user_id = u.id;
-- 如果 u.id 是主键,type = eq_ref
-- ref:普通索引等值查询
-- 说明索引正确命中,但可能返回多行
EXPLAIN SELECT * FROM employee WHERE dept_id = 1;
-- type: ref, key: idx_dept_id
-- range:索引范围查询(>、<、BETWEEN、IN)
-- 说明命中索引做范围扫描
EXPLAIN SELECT * FROM employee WHERE salary BETWEEN 10000 AND 20000;
-- type: range, key: idx_salary
-- index:全索引扫描
-- 说明遍历了整个索引树,通常比全表扫描稍好
EXPLAIN SELECT COUNT(id) FROM employee;
-- type: index, key: PRIMARY, Extra: Using index
-- ALL:全表扫描(最差)
-- 说明没有使用索引,需要优化
EXPLAIN SELECT * FROM employee WHERE name = '张三';
-- type: ALL(name 无索引时)
type 性能排序:ALL < index < range < ref < eq_ref < const < system
sql
-- 实战:通过 type 判断索引质量
-- 优秀:const, eq_ref, ref
-- 一般:range
-- 需优化:index, ALL
-- 例:如果 type 从 ref 降到 ALL,说明索引失效或缺失
6.4 key_len 计算(判断联合索引用了多少列)
key_len 表示 MySQL 在索引中使用的字节数,可以精确判断联合索引实际用了几个字段。
sql
-- 创建测试表
CREATE TABLE t_keylen (
id INT PRIMARY KEY AUTO_INCREMENT,
a INT, -- 4字节 + NULL标记1字节
b VARCHAR(100), -- 变长 + NULL标记1字节 + 长度2字节
c DATETIME, -- 5字节 + NULL标记1字节
INDEX idx_a_b_c (a, b, c)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
key_len 计算规则:
| 字段类型 | 占用字节 | 说明 |
|---|---|---|
| INT | 4 | 无符号 +1(NULL) |
| BIGINT | 8 | 无符号 +1(NULL) |
| VARCHAR(100) | 100 × 字符集字节数 + 2(长度) + 1(NULL) | |
| CHAR(100) | 100 × 字符集字节数 + 1(NULL) | |
| DATETIME | 5 | +1(NULL) |
| TINYINT | 1 | +1(NULL) |
sql
-- 实战分析 key_len
-- 假设 utf8mb4 字符集(每个字符最多4字节)
-- 场景1:只用到了 a 列
EXPLAIN SELECT * FROM t_keylen WHERE a = 1;
-- key_len: 5(INT 4字节 + NULL标记1字节)
-- 场景2:用到了 a, b 列
EXPLAIN SELECT * FROM t_keylen WHERE a = 1 AND b = 'hello';
-- key_len: 410(INT 5 + VARCHAR(100)×4+2+1 = 408 + 5 = ?)
-- 实际:a=5 + (b: 100×4+2+1=403) = 408
-- 场景3:用到了 a, b, c 列
EXPLAIN SELECT * FROM t_keylen WHERE a = 1 AND b = 'hello' AND c = '2024-01-01';
-- key_len: 414(a=5 + b=403 + c=6 = 414)
-- 场景4:a 范围查询后,b、c 用不到
EXPLAIN SELECT * FROM t_keylen WHERE a > 1 AND b = 'hello';
-- key_len: 5(只用了 a 列)
关键判断:
sql
-- 如果 key_len 明显小于联合索引的总长度
-- 说明有部分索引列没有用上(范围查询或跳过了中间列)
-- 例:联合索引 idx_a_b_c 总长假设为 414
EXPLAIN SELECT * FROM t_keylen WHERE a = 1 AND c = '2024-01-01';
-- key_len: 5(只用到了 a,c 因为跳过了 b 而用不到)
6.5 Extra 字段详解(索引线索)
| Extra 值 | 含义 | 对索引的指示 |
|---|---|---|
| Using index | 覆盖索引,无需回表 | 索引设计好,所有查询字段在索引中 |
| Using index condition | 索引下推(ICP) | 索引层提前过滤,减少回表次数 |
| Using where | 从存储引擎层取出数据后在 Server 层过滤 | 可能索引不完全覆盖条件 |
| Using filesort | 需要额外排序,未用到索引排序 | ORDER BY 字段不在索引中或顺序不对 |
| Using temporary | 需要临时表 | 常见于 GROUP BY 无索引 |
| Using join buffer | JOIN 未用到索引 | 关联字段缺少索引 |
| Using MRR | 多范围读取优化 | 对二级索引的范围查询进行排序后再回表 |
sql
-- 1. Using index(最优)
-- 查询的所有字段都在索引中,无需回表
EXPLAIN SELECT id, a, b FROM t_keylen WHERE a = 1;
-- Extra: Using index
-- 2. Using filesort(需优化)
-- ORDER BY 字段不在索引中,MySQL 需要额外排序
EXPLAIN SELECT * FROM t_keylen WHERE a = 1 ORDER BY c;
-- Extra: Using filesort(联合索引是 a,b,c,跳过了 b 导致排序用不到索引)
-- 3. Using temporary(需优化)
-- GROUP BY 没有可用索引时使用临时表
EXPLAIN SELECT dept_id, COUNT(*) FROM employee GROUP BY dept_id;
-- 如果 dept_id 无索引:Extra: Using temporary; Using filesort
-- 4. Using join buffer(需优化)
-- JOIN 关联字段没有索引
EXPLAIN SELECT * FROM orders o LEFT JOIN users u ON o.user_id = u.id;
-- 如果 o.user_id 无索引:Extra: Using join buffer
常见 Extra 组合解读:
Using index → 覆盖索引,很好
Using index condition → 索引下推,不错
Using where; Using index → 覆盖索引 + Server 层过滤,不错
Using index; Using filesort → 覆盖索引但排序不行,需优化索引
Using where; Using filesort → 无覆盖索引 + 文件排序,需优化
Using temporary; Using filesort → 最差,全表扫描 + 临时表,急需优化
Using join buffer → JOIN 缺索引,需加索引
6.6 通过 EXPLAIN 定位索引问题
sql
-- 准备测试数据
CREATE TABLE emp (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50),
dept_id INT,
salary DECIMAL(10,2),
create_time DATETIME,
INDEX idx_dept_salary (dept_id, salary)
);
-- 问题1:索引完全未使用(type = ALL)
EXPLAIN SELECT * FROM emp WHERE name = '张三';
-- type: ALL, possible_keys: NULL, key: NULL
-- 诊断:name 字段没有索引,需要加索引
-- 问题2:联合索引只用到了部分列(key_len 偏小)
EXPLAIN SELECT * FROM emp WHERE dept_id = 1 ORDER BY salary;
-- type: ref, key: idx_dept_salary, key_len: 5
-- 诊断:WHERE 用到 dept_id,ORDER BY 用了 salary 排序(索引有序性)
-- key_len=5 只用了 dept_id(INT + NULL),排序不增加 key_len
-- 问题3:索引失效(隐式类型转换)
CREATE INDEX idx_name ON emp(name);
EXPLAIN SELECT * FROM emp WHERE name = 123;
-- type: ALL, key: NULL
-- 诊断:name 是 VARCHAR,传入数字导致隐式类型转换,索引失效
-- 问题4:范围查询导致后续列索引失效
EXPLAIN SELECT * FROM emp WHERE dept_id > 1 AND salary = 10000;
-- type: range, key: idx_dept_salary, key_len: 5
-- 诊断:key_len=5 只用了 dept_id,range 后的 salary 用不到索引
-- 问题5:filesort 需要优化
EXPLAIN SELECT * FROM emp WHERE dept_id = 1 ORDER BY create_time;
-- Extra: Using filesort
-- 诊断:ORDER BY 字段不在索引 idx_dept_salary 中,需要加索引
-- 问题6:覆盖索引效果
EXPLAIN SELECT dept_id, salary FROM emp WHERE dept_id = 1;
-- Extra: Using index
-- 诊断:查询字段都在 idx_dept_salary 中,无需回表,性能好
-- 问题7:索引下推效果
EXPLAIN SELECT * FROM emp WHERE dept_id = 1 AND salary > 8000;
-- Extra: Using index condition
-- 诊断:dept_id 用于索引查找,salary > 8000 在索引层过滤,减少回表
6.7 分析流程总结
拿到一条慢 SQL 后,用 EXPLAIN 分析的步骤:
步骤1:看 type
→ ALL:需要加索引或改 SQL
→ index/range:可以接受
→ ref/eq_ref/const:不错
步骤2:看 key
→ NULL:SQL 没有使用索引,检查 WHERE 条件
→ 有值:确认是否是你期望的索引
步骤3:看 key_len
→ 联合索引时判断实际用了多少列
→ 如果比预期小,检查是否有列被跳过或范围查询
步骤4:看 rows
→ 预估扫描行数,越大越慢
→ 对比实际数据量,判断过滤效率
步骤5:看 Extra
→ Using filesort:ORDER BY 需优化
→ Using temporary:GROUP BY 需优化
→ Using index:覆盖索引,很好
→ Using join buffer:JOIN 缺索引
七、实战案例
sql
-- 业务表:订单表
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32),
user_id BIGINT,
status TINYINT,
amount DECIMAL(12,2),
create_time DATETIME,
pay_time DATETIME,
INDEX idx_order_no (order_no)
);
-- 常见查询模式分析:
-- Q1: 用户查询自己的订单列表
SELECT * FROM orders WHERE user_id = ? ORDER BY create_time DESC;
-- 索引建议:idx_user_create (user_id, create_time)
-- Q2: 按状态查询订单(运营后台)
SELECT * FROM orders WHERE status = ? ORDER BY create_time DESC;
-- 索引建议:idx_status_create (status, create_time)
-- Q3: 按时间范围查询订单
SELECT * FROM orders WHERE create_time BETWEEN ? AND ?;
-- 索引建议:idx_create_time (create_time)
-- Q4: 查询某个用户某段时间的订单
SELECT * FROM orders
WHERE user_id = ? AND create_time BETWEEN ? AND ?;
-- 用了 idx_user_create 即可满足
-- 最终索引方案(避免冗余):
-- 1. idx_user_create (user_id, create_time) -- 覆盖 Q1, Q4
-- 2. idx_status_create (status, create_time) -- 覆盖 Q2
-- 3. idx_order_no (order_no) -- 唯一订单号查询
-- 不需要单独的 idx_create_time,因为前两个联合索引已覆盖
案例3:大分页优化
sql
-- 传统分页(深度分页时越来越慢)
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;
-- MySQL 会查到 100020 条,丢弃前 100000 条
-- 优化1:子查询延迟关联
SELECT * FROM orders
WHERE id > (
SELECT id FROM orders ORDER BY id LIMIT 100000, 1
)
LIMIT 20;
-- 优化2:覆盖索引 + JOIN
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders ORDER BY id LIMIT 100000, 20
) tmp ON o.id = tmp.id;
总结
| 知识点 | 核心要点 |
|---|---|
| B+ 树 | 非叶子节点只存键值、叶子双向链表、3~4 层支撑千万级 |
| 聚簇索引 | 叶子存数据行、一张表一个、主键即数据 |
| 二级索引 | 叶子存主键值、查询需要回表 |
| 最左前缀 | 等值在前、排序在后、范围放最后 |
| 覆盖索引 | 所有字段在索引中,无需回表 |
| 索引下推 | 在索引层提前过滤 |
| 索引失效 | 函数、隐式转换、LIKE 前缀%、违反最左前缀 |