前言

MySQL 基础篇讲了增删查改。实际工作中,这些操作做到”对”不难,做到”快”才见功力。

当数据量从几千行涨到几百万行,一条 SQL 从 10ms 变成 10s,这时候就需要索引和慢查询优化了。这篇文章聚焦于日常开发中最常用的优化手段。


一、B+Tree 索引原理(理解这个,索引就懂了一半)

MySQL InnoDB 引擎使用 B+Tree 作为索引的底层数据结构。

和二叉搜索树不同,B+Tree 一个节点可以存很多个键值(比如几十到几百个),这使得树的高度很低——百万级别的数据,B+Tree 高度通常只有 3 到 4 层。

关键特征:

  • 所有数据都存在叶子节点
  • 叶子节点之间用双向链表连接,支持范围查询
  • 非叶子节点只存键值(不存数据),用于路由

这意味着从百万数据中查一条记录,只需要 3-4 次磁盘 IO(每次读一个节点进内存)。


二、聚簇索引与二级索引

聚簇索引(Clustered Index)

InnoDB 的主键索引就是聚簇索引。它的叶子节点存的是整行数据

1
2
3
4
5
6
7
主键索引(聚簇索引):
[1..1000]
/ \
[1..500] [501..1000]
/ \ / \
[1,张三] [2,李四] [3,王五] [4,赵六] ...
↑ 叶子节点直接存整行数据

二级索引(Secondary Index)

你手动创建的普通索引。它的叶子节点存的是主键值,而不是整行数据。

1
2
3
4
5
6
7
二级索引(name 列):
[A..Z]
/ \
[A..M] [N..Z]
/ \ / \
[1,张三] [3,李四] [2,王五] [4,赵六]
↑ 叶子节点存的是主键 ID,不是整行

回表:通过二级索引查到主键后,还要拿着主键再去聚簇索引查一次完整数据。这个过程叫”回表”,是额外的性能开销。

覆盖索引:如果查询的列都在索引里(不需要回表),性能会好很多。

1
2
3
4
5
6
7
-- 假设有索引 idx_name_email (name, email)

-- 需要回表(因为 age 不在索引里)
SELECT name, email, age FROM users WHERE name = 'July';

-- 覆盖索引,不需要回表(name 和 email 都在索引里)
SELECT name, email FROM users WHERE name = 'July';

三、联合索引与最左前缀法则

联合索引是把多个列组合成一个索引。理解它的关键是最左前缀法则

1
CREATE INDEX idx_a_b_c ON orders (user_id, status, create_time);

这个索引相当于同时创建了三个索引:

  • user_id(最左列,单独用也有效)
  • user_id, status(前两列,有效)
  • user_id, status, create_time(三列都用,有效)

以下查询能用到索引:

1
2
3
4
WHERE user_id = 1                                              -- 匹配最左列
WHERE user_id = 1 AND status = 'paid' -- 匹配前两列
WHERE user_id = 1 AND status = 'paid' AND create_time > '2025' -- 全部匹配
WHERE user_id = 1 AND create_time > '2025' -- 匹配最左列(跳过 status,只能用到 user_id)

以下查询用不到索引:

1
2
3
WHERE status = 'paid'                                          -- 跳过最左列,全表扫描
WHERE create_time > '2025' -- 跳过最左列
WHERE user_id > 1 AND status = 'paid' -- 范围查询 users 之后,status 无法用上索引

联合索引的列顺序原则:

  1. 区分度高的放前面(能快速缩小范围)
  2. 等值查询的列放前面,范围查询的列放后面
  3. 最常用的查询条件放最左边

四、EXPLAIN 执行计划

EXPLAIN 是分析 SQL 性能最重要的工具。它能告诉你 MySQL 是怎么执行这条 SQL 的。

1
EXPLAIN SELECT * FROM orders WHERE user_id = 42;

输出解读(只看最关键的几列):

说明
type 访问类型 const, eq_ref, ref, range ALL(全表扫描)
key 实际使用的索引 有值 NULL(没走索引)
rows 预估扫描行数 越小越好 接近表总行数
Extra 额外信息 Using index(覆盖索引) Using filesort, Using temporary

type 从好到坏排序:

1
2
3
4
system > const > eq_ref > ref > range > index > ALL
↑ ↑
索引全扫描 全表扫描
(也算慢) (最差)

常见优化判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- type = ALL,全表扫描,需要建索引
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
-- → 给 email 加索引

-- Extra = Using filesort,排序没走索引
EXPLAIN SELECT * FROM orders WHERE user_id = 1 ORDER BY create_time DESC;
-- → 建联合索引 (user_id, create_time)

-- Extra = Using temporary,用了临时表
EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status;
-- → 给 status 加索引

-- Extra = Using index,完美,覆盖索引
EXPLAIN SELECT user_id, status FROM orders WHERE user_id = 1;
-- → 查询的列都在索引里,不需要回表

常见 EXPLAIN 输出及对策

type Extra 问题 对策
ALL NULL 全表扫描 加索引
ref NULL 正常
range NULL 范围扫描 如果 rows 很大考虑加联合索引
ref Using filesort 排序没走索引 调整联合索引,把排序列也加进索引
ref Using temporary 用了临时表 GROUP BY/DISTINCT 列加索引
index Using index 索引覆盖 不错,但可能扫描了太多行

五、慢查询定位与优化

5.1 开启慢查询日志

1
2
3
4
5
6
7
8
9
10
11
-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;

-- 超过 1 秒的查询记入日志
SET GLOBAL long_query_time = 1;

-- 查看慢查询日志位置
SHOW VARIABLES LIKE 'slow_query_log_file';

-- 查看有多少条慢查询
SHOW GLOBAL STATUS LIKE 'Slow_queries';

5.2 定位问题 SQL

拿到慢查询日志后,按以下优先级排查:

  1. 执行次数最多的 SQL——一条执行 10000 次的慢 SQL,优化收益远大于一条执行 1 次的超慢 SQL
  2. 执行时间最长的 SQL——找出 TOP 10,逐个过 EXPLAIN
  3. 锁等待的 SQL——事务阻塞其他操作

5.3 优化实战:一个典型例子

场景:分页查询越来越慢。

1
2
-- 第 1 页很快,第 10000 页很慢
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;

问题在于 LIMIT offset, count 并不是直接跳过 offset 行,而是逐行计数到 offset 再取 count 行。offset 越大越慢。

优化方案(游标分页):

1
2
-- 记住上一页最后一条的 ID,下次从这个 ID 往后查
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 20;

这种写法不管 offset 多大,速度都是恒定的。但要保证 ID 是连续递增的,如果中间有删除,可能漏数据。具体场景具体分析。

5.4 索引失效的常见陷阱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 1. 对索引列做函数操作
SELECT * FROM users WHERE DATE(create_time) = '2025-01-01'; -- 全表扫描
SELECT * FROM users WHERE create_time >= '2025-01-01'
AND create_time < '2025-01-02'; -- 走索引

-- 2. 隐式类型转换
SELECT * FROM users WHERE phone = 13800138000; -- phone 是 VARCHAR,全表扫描
SELECT * FROM users WHERE phone = '13800138000'; -- 加引号,走索引

-- 3. LIKE 以 % 开头
SELECT * FROM articles WHERE title LIKE '%MySQL%'; -- 全表扫描
-- 解决方案:用全文索引(FULLTEXT)或 Elasticsearch

-- 4. OR 条件中有非索引列
SELECT * FROM users WHERE id = 1 OR nickname = 'test';
-- 改成:
SELECT * FROM users WHERE id = 1
UNION ALL
SELECT * FROM users WHERE nickname = 'test';

六、索引设计实操原则

  1. 先分析查询,再建索引:不要提前给所有列都建索引。把项目里实际跑的 SQL 收集起来,分析哪些最频繁、最慢,针对性地建。

  2. 选择性高的列优先建索引:选择性 = 不重复值的数量 / 总行数。选择性 0.9 以上的列(如 user_id)建索引效果好,选择性接近 0 的列(如 gender 只有男/女)建了也没什么用。

  3. 一个查询最多走一个索引:MySQL 对一个 SELECT 通常只用一个索引。如果 WHERE 里有多个条件,建联合索引比建多个单列索引有效。

  4. 不要过度建索引:每个索引都需要维护(INSERT/UPDATE/DELETE 时同时更新索引),索引太多会拖累写入性能。

  5. 定期清理无效索引:用 SHOW INDEX FROM table_name 查看表中的索引,配合 performance_schema 分析哪些索引从未被使用过(sys.schema_unused_indexes)。


索引优化的核心就三句话:建对索引(联合索引、最左匹配)、用对索引(避免失效场景)、看懂 EXPLAIN(知道它实际走了什么)。其他更复杂的优化(分库分表、读写分离)是在这三步做完后仍有性能问题才考虑的。