作为一名后端开发,MySQL是绕不开的必修课。在日常工作中,慢查询往往是系统性能的头号杀手,而索引则是解决这一问题的核心利器。本文将带你从索引的本质出发,深入B+树原理,结合Explain工具分析慢SQL,并总结一套可落地的查询优化方法论。
一、索引的本质:为什么数据量一大就慢?
没有索引时,MySQL只能进行全表扫描 ,复杂度O(n)。以一张1000万行的用户表为例,查找某条记录平均需要扫描500万行,耗时可能达到秒级。索引的本质是用空间换时间,通过维护一种有序的数据结构(B+树),将查找复杂度降低到O(log n)。
1.1 常见的索引数据结构对比
| 数据结构 | 磁盘I/O次数 | 适用场景 | MySQL为何不用? |
|---|---|---|---|
| 哈希表 | O(1) | 等值查询 | 不支持范围查询 |
| 二叉树 | O(log n) | 通用 | 易退化成链表 |
| AVL/红黑树 | O(log n) | 通用 | 树高过高,I/O次数多 |
| B树 | O(log_m n) | 范围查询 | 非叶子节点也存数据,空间浪费 |
| B+树 | O(log_m n) | 范围查询+扫库 | 叶子节点形成链表,完美适配磁盘预读 |
结论 :InnoDB采用B+树作为索引结构,关键在于其矮胖 (扇出系数高)和叶子节点有序链表的设计。
二、B+树索引是如何工作的?
2.1 一张图看懂B+树结构
text
[根节点] (页20)
|----> [内节点] (页10) (存储键值+指针)
|----> [内节点] (页11)
|----> [叶子节点] (页1) (1,2,3,4,5) -> next指针 ->
|----> [叶子节点] (页2) (6,7,8,9,10) -> next指针 ->
|----> [叶子节点] (页3) (11,12,13,14,15)
-
非叶子节点:只存索引键 + 子页指针,不存真实数据行
-
叶子节点:存储完整的索引键 + 行数据(或主键值,取决于聚簇/二级索引)
2.2 聚簇索引 vs 二级索引
聚簇索引:叶子节点直接存储整行数据。InnoDB表中,主键就是聚簇索引。如果没有显式主键,则会选择第一个NOT NULL UNIQUE列,否则自动生成隐藏的ROW_ID。
二级索引(辅助索引) :叶子节点存储索引列 + 主键值 。因此,通过二级索引查询数据需要回表(先查到主键,再回聚簇索引查完整行)。
sql
-- 示例:假设 user 表有主键 id 和二级索引 name
CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(32),
age INT,
INDEX idx_name (name)
);
-- 以下查询只需扫描二级索引(覆盖索引)
SELECT id, name FROM user WHERE name = 'Tom';
-- 以下查询需要回表:二级索引查到 id,再回聚簇索引取 age
SELECT age FROM user WHERE name = 'Tom';
小贴士 :尽量让查询只走二级索引就拿到所有要的字段,这就是覆盖索引优化。
三、索引使用的最佳实践
3.1 最左前缀原则
复合索引 (a, b, c) 相当于创建了 (a)、(a,b)、(a,b,c) 三个索引。查询条件必须从索引最左列开始,不能跳过中间的列。
sql
-- 能用到索引 idx_abc 的情况:
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
WHERE a = 1 AND c = 3 -- 只用到 a,c 部分用不到
-- 用不到索引的情况:
WHERE b = 2
WHERE c = 3
WHERE a > 1 AND b = 2 -- 范围之后失效(a用了范围,b就失效)
3.2 索引失效的场景(切记!)
| 写法 | 是否失效 | 原因 |
|---|---|---|
WHERE name LIKE '%张' |
✅ 失效 | 通配符在前,无法比较索引树 |
WHERE age + 1 = 20 |
✅ 失效 | 对索引列做了计算/函数 |
WHERE LEFT(name,2) = '张三' |
✅ 失效 | 函数破坏了索引 |
WHERE a = 1 OR b = 2 |
⚠️ 部分失效 | 除非两个列都有索引,否则全表扫描 |
WHERE id IN (1,2,3) |
❌ 有效 | IN 在MySQL5.7+会被优化成多个等值 |
WHERE name IS NULL |
❌ 有效 | IS NULL 也能走索引 |
3.3 索引选择性
选择性 = 不同值数量 / 总行数,比值越接近1,索引效果越好。比如性别的选择性只有0.5,而身份证号接近1。
sql
-- 查看列选择性
SELECT
COUNT(DISTINCT gender)/COUNT(*) AS gender_sel,
COUNT(DISTINCT email)/COUNT(*) AS email_sel
FROM user;
当选择性低于20%时,优化器可能认为全表扫描更划算(因为大量回表成本高)。
四、定位慢查询:Explain 你真的会用吗?
4.1 开启慢查询日志
sql
-- 查看当前设置
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';
-- 临时开启(生产谨慎)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过1秒记录
4.2 读懂Explain输出
拿一条实际SQL来分析:
sql
EXPLAIN SELECT u.id, u.name, o.amount
FROM user u
INNER JOIN order o ON u.id = o.user_id
WHERE u.age BETWEEN 20 AND 30
ORDER BY o.create_time DESC
LIMIT 10;
关键字段解读:
| 列名 | 值示例 | 含义 |
|---|---|---|
| type | ref/range/index/ALL |
访问类型,性能从好到差:system > const > eq_ref > ref > range > index > ALL |
| possible_keys | idx_age,PRIMARY |
可能用到的索引 |
| key | idx_age |
实际使用的索引 |
| key_len | 5 |
索引使用字节数,可推断用了哪几列 |
| rows | 10000 |
估计扫描的行数(越少越好) |
| Extra | Using index condition; Using filesort; Using temporary |
Using filesort 和Using temporary通常是优化的信号 |
关键点 :
Using filesort表示MySQL需要额外一次排序,而不是直接利用索引顺序。如果order by的列有索引,则不会出现这个提示。
五、实战:一个慢查询的优化全过程
5.1 问题场景
我们有一张订单表 order,记录数 2000万,业务方反馈一个后台查询页面打开极慢(>8秒)。
sql
SELECT order_id, user_name, amount, status, create_time
FROM `order`
WHERE status = 1
AND create_time BETWEEN '2025-01-01' AND '2025-01-31'
ORDER BY create_time DESC
LIMIT 20;
5.2 分析步骤
Step 1: 查看表结构
sql
SHOW CREATE TABLE `order`;
-- 发现只有主键索引 `PRIMARY KEY (order_id)`,没有其他索引
Step 2: Explain 分析
text
+----+-------------+-------+------+---------------+------+---------+------+----------+-----------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+----------+-----------------------------+
| 1 | SIMPLE | order | ALL | NULL | NULL | NULL | NULL | 20,000,000| Using where; Using filesort |
+----+-------------+-------+------+---------------+------+---------+------+----------+-----------------------------+
type=ALL 全表扫描,rows=2000万,外加 Using filesort(结果集排序),不慢才怪。
Step 3: 尝试创建复合索引
根据 等值查询在前,范围查询在后 的原则,status = 1 是等值,create_time 是范围 + 排序,所以索引应为 (status, create_time)。
sql
ALTER TABLE `order` ADD INDEX idx_status_ctime (status, create_time);
Step 4: 再次 Explain
text
+----+-------------+-------+-------+------------------+------------------+---------+------+------+-----------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+-------+------------------+------------------+---------+------+------+-----------------------+
| 1 | SIMPLE | order | range | idx_status_ctime | idx_status_ctime | 11 | NULL | 8540 | Using index condition |
+----+-------------+-------+-------+------------------+------------------+---------+------+------+-----------------------+
-
type变为range,扫描行数从 2000万 降到 8540。 -
Extra中不再有Using filesort,因为create_time已经在索引中且排序方向一致(索引默认升序,我们ORDER BY DESC,InnoDB支持反向扫描,效率接近)。
Step 5: 验证性能
查询时间从 8秒 降到 18ms,完美解决。
六、索引维护与常见误区
6.1 索引不是越多越好
-
每个索引都需要占用磁盘空间(一颗B+树)
-
写操作(INSERT/UPDATE/DELETE)要同时维护所有索引,导致性能下降
-
建议单表索引数量不超过 5~6 个
6.2 冗余索引与重复索引
sql
-- 重复索引
INDEX (a) 和 PRIMARY KEY (a) # 主键已经是唯一索引了
-- 冗余索引
INDEX (a,b) 和 INDEX (a) # (a,b) 已经能覆盖 (a) 的查询
可以使用 sys.schema_redundant_indexes 视图来检查冗余索引(MySQL 5.7+)。
6.3 索引下推(ICP)
MySQL 5.6 引入了 Index Condition Pushdown,可以在索引遍历时就直接过滤掉不满足条件的记录,减少回表次数。
sql
-- 例如复合索引 (name, age)
SELECT * FROM user WHERE name LIKE '张%' AND age = 20;
没有ICP时:先通过 name 找到主键,再回表取 age 判断。
有ICP时:在索引树上同时判断 age = 20,满足条件的才回表,大大减少I/O。
七、总结:索引优化的核心心法
-
慢查询第一现场:开启慢查询日志,定期分析。
-
Explain 是你的火眼金睛 :重点关注
type、key、rows、Extra。 -
复合索引遵循最左前缀:把区分度高的列放在左边,等值查询放前,范围查询放后。
-
避免索引失效 :不在索引列上做任何计算、函数、类型隐式转换;杜绝
%开头的LIKE。 -
覆盖索引是王道:查询只走二级索引,避免回表。
-
写操作多的表,索引适度:不是所有WHERE列都要建索引,优先优化高频慢查询。
最后送大家一句话:索引犹如书的目录,设计得好,查找飞快;设计得不好,不如没有。 希望本文能帮你在MySQL优化的道路上少踩坑,多提效。