接口从 0.1 秒变成 3 秒,连接池被打满,用户开始投诉。大部分人说「加个索引就好了」,但加完还是慢——问题在细节。
这篇按排查顺序走一遍:先拿证据,再看执行计划,最后动索引和 SQL。
慢查询的根因分布
按出现频率排:
- 缺索引 —— 全表扫描
- 索引失效 —— 索引存在但用不上
- 选错执行计划 —— 优化器判断失误
- 锁等待 —— 被长事务挡住
- 返回数据量过大 —— 网络和内存都是瓶颈
前三条占绝大多数,但第四条最容易被误判——看起来是「这条 SQL 慢」,实际上是「被别的 SQL 锁住了」。
第一步:让慢日志说话
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'slow_query_log_file';
临时开启(生产环境改参数要谨慎,按运维规范来):
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 调试时可临时降到 0.1
汇总分析:
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
-s t 按总耗时排序,-t 10 取前 10 条。这样能快速找出「谁最耗时」。
生产环境也可以开 performance_schema,它能给出更细的等待事件和 SQL 摘要统计,比慢日志更适合持续观察。
第二步:看执行计划
EXPLAIN SELECT * FROM orders
WHERE user_id = 10001
ORDER BY created_at DESC
LIMIT 20;
关键字段:
| 字段 | 看什么 |
|---|---|
type |
访问类型,从好到差:const > eq_ref > ref > range > index > ALL。看到 ALL 就要警惕 |
key |
实际使用的索引。为 NULL 说明没用索引 |
rows |
预估扫描行数。越大成本越高 |
Extra |
Using filesort 和 Using temporary 要特别注意 |
type: ALL 配合 rows: 5000000,意味着 MySQL 可能要扫 500 万行。
MySQL 8 可以用 EXPLAIN ANALYZE 看真实执行耗时,而不是预估值——这一点很重要,优化器估错的时候,只有实际数据能告诉你真相。
第三步:建对索引
先给个基础场景:
CREATE INDEX idx_user_id ON orders(user_id);
再跑 EXPLAIN:
type: ref
key: idx_user_id
rows: 1000
Extra: Using where; Using filesort
扫描行数从 500 万降到 1000,进步很大。但 Using filesort 还在——索引只帮 MySQL 快速找到 user_id = 10001 的记录,这些记录还得按 created_at 排序。
用联合索引消除排序:
CREATE INDEX idx_user_created ON orders(user_id, created_at);
这个索引的含义是:先按 user_id 排序,相同 user_id 内再按 created_at 排序。MySQL 定位到某个用户的订单范围后,可以直接按索引顺序读取,不需要额外排序。
如果 EXPLAIN 里 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 -- 全部用上
不支持:
WHERE status = 1 -- 用不上
因为 status 不是最左列。
理解方式: 把联合索引想成一本多级排序的字典——先按 user_id 排,同 user_id 内按 status 排,同 status 内按 created_at 排。查询如果跳过了第一级,后面就没法高效定位。
索引列顺序怎么定
对这条查询:
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);
查询所需字段全部能从索引拿到,不需要回主键索引查完整行。EXPLAIN 的 Extra 里会出现 Using index。
但覆盖索引不是字段越多越好。 列太多会带来:磁盘占用增加、插入更新成本上升、索引维护成本变高、缓存命中率下降。
适合高频、核心、字段较少的查询。
四种让索引失效的写法
1. 对索引列用函数
-- 索引失效
WHERE DATE(created_at) = '2026-05-22'
-- 改成范围查询
WHERE created_at >= '2026-05-22 00:00:00'
AND created_at < '2026-05-23 00:00:00'
2. 对索引列做运算
-- 失效
WHERE amount + 10 > 100
-- 移项
WHERE amount > 90
3. LIKE 前置通配符
WHERE order_no LIKE '202605%' -- 能用索引
WHERE order_no LIKE '%0001' -- 用不上
B+Tree 索引按前缀有序,前置通配符破坏了这个有序性。真需要全文模糊匹配,考虑全文索引或搜索引擎。
4. 隐式类型转换
-- order_no 是 VARCHAR
WHERE order_no = 202605220001 -- 触发隐式转换,索引失效
WHERE order_no = '202605220001' -- 正确
这类问题特别隐蔽,因为 SQL 语法上没错,只是慢。
大分页的处理
-- 越翻越慢
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
延迟关联:
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders ORDER BY id LIMIT 1000000, 20
) t ON o.id = t.id;
子查询在索引上完成翻页定位(不回表),只对最终的 20 条回表取完整数据。
更好的方案是游标分页:
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 20;
记录上一页最后一条的 id,下一页从这里开始。代价是不能跳页,但性能稳定。
别忽略锁等待
「这条 SQL 平时很快,今天突然 3 秒」——大概率不是 SQL 本身的问题。
SHOW PROCESSLIST;
-- MySQL 8:看当前等待
SELECT * FROM performance_schema.data_lock_waits;
-- 看 InnoDB 状态里的锁等待链
SHOW ENGINE INNODB STATUS;
长事务持有的行锁会让简单查询卡住。定位到阻塞源头后,优先解决事务粒度问题,而不是给被阻塞的查询加索引。
索引不是越多越好
写入会变慢、缓冲池被占用、优化器选择成本上升。读多写少的表可以多建;写多读少的表要克制。
统计信息也会过期。 大量增删改后,优化器基于旧统计信息可能选错计划:
ANALYZE TABLE orders;
force index 要谨慎用——大多数情况应该让优化器自己选,硬指定会把执行计划固化,数据分布变化后反而更糟。
排查流程总结
1. 开慢日志,mysqldumpslow 排序,找到最耗时的 SQL
2. 先查锁等待,排除「被阻塞」的可能
3. EXPLAIN 看 type / key / rows / Extra
4. 按 WHERE + ORDER BY 建联合索引,遵循最左前缀
5. 把排序字段纳入索引,消除 filesort
6. 高频小字段查询做覆盖索引,消除回表
7. 检查 SQL 写法,排除函数、运算、前置通配、隐式转换
8. EXPLAIN ANALYZE 验证真实耗时
9. ANALYZE TABLE 更新统计信息
小结
慢查询优化最忌讳的是「凭感觉猜」。加索引之前先看执行计划,改 SQL 之前先排除锁等待,优化完用 EXPLAIN ANALYZE 验证——这三步做到,大部分问题都跑不掉。
真正的难点不是技术,是在压力下仍然按流程走。线上告警响的时候,直接上手改是最诱人的选择,也是最容易绕远路的选择。