MySQL 慢查询优化实战:从 EXPLAIN 分析到索引设计
在后端开发中,接口响应慢并不一定是代码逻辑的问题。很多时候,真正拖慢系统的是数据库查询。
一个接口可能只写了几行代码,但背后执行的 SQL 如果没有走索引,或者索引设计不合理,就可能让数据库扫描大量数据,最终导致接口响应时间从几十毫秒变成几秒甚至十几秒。
慢查询通常会带来以下问题:
-
接口响应时间变长;
-
数据库 CPU 占用升高;
-
连接池被占满;
-
业务高峰期请求堆积;
-
用户体验明显下降;
-
严重时可能拖垮整个服务。
本文将从实战角度出发,围绕 MySQL 慢查询优化展开,重点讲清楚:
-
什么是慢查询;
-
如何定位慢 SQL;
-
如何使用 EXPLAIN 分析执行计划;
-
索引为什么能提升查询性能;
-
联合索引如何设计;
-
哪些写法会导致索引失效;
-
慢查询优化的常见思路。
一、什么是慢查询?
慢查询,简单来说,就是执行时间超过预期的 SQL。
在 MySQL 中,可以通过慢查询日志记录执行较慢的 SQL。
比如系统中有一个查询用户订单的接口:
SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;
如果 orders 表只有几千条数据,查询可能非常快。
但如果 orders 表已经有几千万条数据,并且没有合适的索引,这条 SQL 就可能变成慢查询。
数据库需要扫描大量记录,再进行排序,最后取出前 20 条数据。
对于线上系统来说,一条慢 SQL 的影响不只是“这一次查询慢”,更严重的是它会占用数据库资源,影响其他正常请求。
二、开启慢查询日志
在 MySQL 中,可以通过以下命令查看慢查询日志是否开启:
SHOW VARIABLES LIKE 'slow_query_log';
查看慢查询时间阈值:
SHOW VARIABLES LIKE 'long_query_time';
如果 long_query_time 设置为 1,表示执行时间超过 1 秒的 SQL 会被记录为慢查询。
可以临时开启慢查询日志:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
查看慢查询日志文件位置:
SHOW VARIABLES LIKE 'slow_query_log_file';
需要注意的是,线上环境修改这些参数要谨慎,最好结合实际运维规范进行配置。
三、准备测试表
为了方便说明,假设有一张订单表:
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
order_no VARCHAR(64) NOT NULL,
status TINYINT NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
常见查询场景包括:
-- 查询某个用户的订单列表
SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;
-- 查询某个状态下的订单
SELECT *
FROM orders
WHERE status = 1
ORDER BY created_at DESC
LIMIT 20;
-- 根据订单号查询订单详情
SELECT *
FROM orders
WHERE order_no = '202605220001';
-- 查询某个用户某种状态的订单
SELECT *
FROM orders
WHERE user_id = 10001
AND status = 1
ORDER BY created_at DESC
LIMIT 20;
这些 SQL 看起来都很简单,但如果索引设计不合理,数据量变大后都会变慢。
四、使用 EXPLAIN 分析执行计划
优化 SQL 前,不要凭感觉猜测。
正确做法是先使用 EXPLAIN 查看执行计划。
例如:
EXPLAIN
SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;
执行结果中常见字段包括:
id
select_type
table
type
possible_keys
key
key_len
ref
rows
Extra
其中最关键的是以下几个字段。
1. type
type 表示访问类型,常见值从好到差大致如下:
const
eq_ref
ref
range
index
ALL
如果看到 ALL,通常表示全表扫描,需要重点关注。
例如:
type: ALL
rows: 5000000
这说明 MySQL 可能要扫描 500 万行数据,查询性能通常不会好。
2. key
key 表示实际使用的索引。
如果 key 为 NULL,说明没有使用索引。
3. rows
rows 表示 MySQL 预计需要扫描的行数。
这个值越大,查询成本通常越高。
4. Extra
Extra 中可能出现一些重要信息。
常见内容包括:
Using where
Using index
Using filesort
Using temporary
其中需要特别注意:
Using filesort
Using temporary
这通常意味着排序或临时表成本较高,可能需要优化索引或 SQL。
五、给 user_id 添加索引
对于下面这条 SQL:
SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;
如果没有索引,数据库需要扫描整张表。
可以先添加一个普通索引:
CREATE INDEX idx_user_id ON orders(user_id);
再次执行:
EXPLAIN
SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;
此时可能看到:
type: ref
key: idx_user_id
rows: 1000
Extra: Using where; Using filesort
这说明 MySQL 使用了 user_id 索引,扫描行数明显减少。
但仍然可能出现 Using filesort。
原因是索引只帮助数据库快速找到了 user_id = 10001 的数据,但这些数据仍然需要按照 created_at 排序。
如果某个用户有大量订单,排序成本仍然可能比较高。
六、使用联合索引优化排序
针对这个查询:
SELECT *
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;
更合适的索引是:
CREATE INDEX idx_user_created ON orders(user_id, created_at);
这个联合索引的含义是:
先按 user_id 排序
在相同 user_id 内,再按 created_at 排序
这样,MySQL 可以先定位到某个用户的订单范围,然后直接按照索引顺序读取数据。
如果执行计划中不再出现 Using filesort,说明排序成本得到了优化。
更进一步,如果业务明确要求倒序查询,也可以创建降序索引:
CREATE INDEX idx_user_created_desc ON orders(user_id, created_at DESC);
不过是否需要显式创建降序索引,要结合 MySQL 版本和实际执行计划判断。
七、联合索引的最左前缀原则
联合索引有一个非常重要的规则:最左前缀原则。
例如创建索引:
CREATE INDEX idx_user_status_created
ON orders(user_id, status, created_at);
这个索引可以支持以下查询:
WHERE user_id = 10001
WHERE user_id = 10001 AND status = 1
WHERE user_id = 10001 AND status = 1
ORDER BY created_at DESC
但不能很好支持:
WHERE status = 1
因为 status 不是联合索引的最左列。
联合索引可以理解为一本多级排序的字典:
先按 user_id 排
user_id 相同,再按 status 排
status 相同,再按 created_at 排
如果查询条件跳过了最左边的 user_id,后面的索引列就很难被高效利用。
八、索引列顺序如何设计?
联合索引不是把查询字段随便放进去就可以。
索引列顺序通常要结合以下因素设计:
等值条件
范围条件
排序字段
字段区分度
查询频率
例如有一条常见 SQL:
SELECT *
FROM orders
WHERE user_id = 10001
AND status = 1
ORDER BY created_at DESC
LIMIT 20;
可以考虑索引:
CREATE INDEX idx_user_status_created
ON orders(user_id, status, created_at);
原因是:
-
user_id是等值条件; -
status是等值条件; -
created_at用于排序; -
查询需要按照时间取最新记录。
如果换成:
CREATE INDEX idx_created_user_status
ON orders(created_at, user_id, status);
通常效果就不如前者。
因为查询首先要根据 user_id 和 status 过滤,如果索引以 created_at 开头,可能无法高效定位目标数据范围。
九、范围查询对联合索引的影响
假设有如下索引:
CREATE INDEX idx_user_created_status
ON orders(user_id, created_at, status);
查询:
SELECT *
FROM orders
WHERE user_id = 10001
AND created_at >= '2026-05-01'
AND status = 1;
这里 created_at 是范围查询。
在联合索引中,范围查询之后的字段,通常无法继续充分利用索引进行精确过滤。
也就是说,status 的索引利用效果可能受到影响。
所以如果查询更常见的是:
WHERE user_id = ?
AND status = ?
AND created_at >= ?
那么索引顺序更适合设计为:
CREATE INDEX idx_user_status_created
ON orders(user_id, status, created_at);
一般经验是:
等值条件字段尽量放前面
范围查询字段尽量放后面
排序字段结合查询条件设计
十、覆盖索引:减少回表
假设有查询:
SELECT id, order_no, created_at
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;
如果创建索引:
CREATE INDEX idx_user_created_order
ON orders(user_id, created_at, order_no);
查询所需字段都能从索引中拿到,不需要再回到主键索引查询完整行数据。
这种情况称为覆盖索引。
执行计划的 Extra 中可能会出现:
Using index
覆盖索引的优点是减少回表,提高查询性能。
但需要注意,覆盖索引不是字段越多越好。
如果索引包含太多列,会带来以下问题:
-
占用更多磁盘空间;
-
插入和更新成本上升;
-
索引维护成本变高;
-
缓存命中率可能下降。
所以覆盖索引适合高频、核心、字段较少的查询场景。
十一、哪些写法会导致索引失效?
1. 对索引列使用函数
错误示例:
SELECT *
FROM orders
WHERE DATE(created_at) = '2026-05-22';
这里对 created_at 使用了 DATE() 函数,可能导致索引无法正常使用。
更推荐写成范围查询:
SELECT *
FROM orders
WHERE created_at >= '2026-05-22 00:00:00'
AND created_at < '2026-05-23 00:00:00';
2. 对索引列进行计算
错误示例:
SELECT *
FROM orders
WHERE amount + 10 > 100;
更推荐写成:
SELECT *
FROM orders
WHERE amount > 90;
3. LIKE 前缀使用通配符
可以使用索引的情况:
SELECT *
FROM orders
WHERE order_no LIKE '202605%';
通常难以使用普通 B+Tree 索引的情况:
SELECT *
FROM orders
WHERE order_no LIKE '%0001';
因为 B+Tree 索引是按前缀有序的,前置通配符会破坏索引的有序查找能力。
4. 隐式类型转换
假设 order_no 是字符串类型:
order_no VARCHAR(64)
错误示例:
SELECT *
FROM orders
WHERE order_no = 202605220001;
更推荐:
SELECT *
FROM orders
WHERE order_no = '202605220001';
字段类型和查询值类型不一致时,可能发生隐式类型转换,影响索引使用。
5. OR 条件使用不当
例如:
SELECT *
FROM orders
WHERE user_id = 10001
OR status = 1;
如果两个字段没有合适索引,或者优化器判断成本较高,可能导致全表扫描。
这类 SQL 可以根据场景改写为 UNION:
SELECT *
FROM orders
WHERE user_id = 10001
UNION
SELECT *
FROM orders
WHERE status = 1;
但是否一定更快,需要结合执行计划和数据量测试。
十二、不要滥用 SELECT *
很多慢查询中都存在一个问题:
SELECT *
SELECT * 会带来几个问题:
-
读取不必要的字段;
-
增加网络传输量;
-
更容易触发回表;
-
表结构变化可能影响接口稳定性;
-
不利于覆盖索引优化。
更推荐只查询需要的字段:
SELECT id, order_no, status, amount, created_at
FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;
尤其是表中包含大字段时,例如:
TEXT
LONGTEXT
BLOB
JSON
更应该避免在列表查询中直接 SELECT *。
十三、分页查询优化
很多系统中会使用如下分页:
SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 100000, 20;
这种深分页查询非常容易变慢。
原因是 MySQL 需要先扫描并跳过前 100000 条数据,然后再取 20 条。
更好的方式是使用游标分页。
例如第一页:
SELECT id, order_no, created_at
FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20;
下一页带上上一页最后一条记录的时间和 ID:
SELECT id, order_no, created_at
FROM orders
WHERE (created_at < '2026-05-22 10:00:00')
OR (created_at = '2026-05-22 10:00:00' AND id < 123456)
ORDER BY created_at DESC, id DESC
LIMIT 20;
同时创建索引:
CREATE INDEX idx_created_id
ON orders(created_at, id);
游标分页的优点是性能更稳定,适合信息流、订单列表、日志列表等场景。
缺点是不适合直接跳转到第 N 页。
十四、ORDER BY 优化
排序是慢查询中很常见的性能消耗点。
例如:
SELECT *
FROM orders
WHERE status = 1
ORDER BY created_at DESC
LIMIT 20;
可以考虑索引:
CREATE INDEX idx_status_created
ON orders(status, created_at);
这样 MySQL 可以先定位到 status = 1 的数据,再按 created_at 顺序读取。
如果没有合适索引,执行计划中可能出现:
Using filesort
这并不一定代表一定很慢,但当数据量较大时,需要重点关注。
常见优化方向包括:
让 WHERE 条件和 ORDER BY 字段组成联合索引
减少排序数据量
避免对大结果集排序
分页时尽量使用索引顺序读取
十五、COUNT 查询优化
很多列表接口会同时查询数据总数:
SELECT COUNT(*)
FROM orders
WHERE status = 1;
当数据量很大时,COUNT(*) 也可能变慢。
优化思路包括:
1. 给过滤字段加索引
CREATE INDEX idx_status ON orders(status);
如果查询条件能走索引,统计成本通常会降低。
2. 避免高频实时精确统计
对于一些列表页,不一定每次都需要实时精确总数。
可以考虑:
缓存统计结果
异步更新计数
只展示是否有下一页
限制最大可翻页范围
3. 业务上接受近似值
在一些非强一致场景中,可以使用近似统计,而不是每次执行精确 COUNT(*)。
十六、索引不是越多越好
索引可以提升查询性能,但也会带来成本。
每增加一个索引,都会带来以下影响:
-
插入数据时需要维护索引;
-
更新索引列时需要调整索引结构;
-
删除数据时也要维护索引;
-
索引会占用额外磁盘空间;
-
优化器需要在多个索引中选择执行路径。
所以索引设计需要克制。
不建议看到查询慢就盲目加索引。
更合理的流程是:
定位慢 SQL
分析执行计划
确认扫描行数和索引使用情况
结合业务查询频率设计索引
上线前测试效果
持续观察慢查询日志
十七、慢查询优化的一般流程
实际项目中,可以按照以下步骤排查慢查询:
1. 从慢查询日志中找到高频慢 SQL
2. 查看 SQL 执行时间和扫描行数
3. 使用 EXPLAIN 分析执行计划
4. 判断是否使用了合适索引
5. 检查是否出现 Using filesort 或 Using temporary
6. 分析 WHERE、ORDER BY、GROUP BY 字段
7. 设计或调整联合索引
8. 改写不合理 SQL
9. 使用真实数据量测试
10. 上线后持续观察
这个流程比单纯“加索引”更可靠。
因为慢查询的原因可能不止一个,可能是索引问题,也可能是 SQL 写法、数据分布、分页方式、排序方式、表结构设计等问题。
十八、一个完整优化案例
假设慢 SQL 如下:
SELECT *
FROM orders
WHERE user_id = 10001
AND status = 1
ORDER BY created_at DESC
LIMIT 20;
执行计划显示:
type: ALL
key: NULL
rows: 8000000
Extra: Using where; Using filesort
这说明:
没有使用索引
扫描了大量数据
还进行了额外排序
第一步,避免 SELECT *:
SELECT id, order_no, amount, status, created_at
FROM orders
WHERE user_id = 10001
AND status = 1
ORDER BY created_at DESC
LIMIT 20;
第二步,创建联合索引:
CREATE INDEX idx_user_status_created
ON orders(user_id, status, created_at);
再次查看执行计划,理想情况下可能变成:
type: ref
key: idx_user_status_created
rows: 50
Extra: Using where
如果查询字段能被索引覆盖,还可以进一步调整为:
CREATE INDEX idx_user_status_created_cover
ON orders(user_id, status, created_at, order_no, amount);
但是否需要覆盖索引,要看查询频率和写入成本,不能盲目增加。
十九、总结
MySQL 慢查询优化不是简单地给字段加索引,而是一个系统性的分析过程。
核心思路可以概括为:
先定位慢 SQL
再分析执行计划
再判断索引是否合理
最后结合业务场景优化 SQL 和表结构
在实际开发中,最常见的优化点包括:
避免全表扫描
合理设计联合索引
遵守最左前缀原则
避免索引列上使用函数或计算
减少 SELECT *
优化深分页
减少不必要的排序
谨慎使用 COUNT
避免滥加索引
对于大多数业务系统来说,数据库性能问题往往不是一开始就出现的,而是在数据量增长后逐渐暴露出来。
因此,写 SQL 时不能只考虑“功能能不能跑”,还要考虑:
数据量变大后还能不能跑得快
查询是否能走索引
排序是否可控
分页是否稳定
索引维护成本是否合理
一个优秀的后端开发者,不仅要会写业务代码,也要能看懂执行计划,理解索引结构,并根据真实业务场景设计可持续的数据库查询方案。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)