慢查询优化实战:从慢日志到索引重构
✦
← 教程与经验

慢查询优化实战:从慢日志到索引重构

楼主:超级管理员 · 2026-09-29 05:04 · 浏览 227

教程 运维 踩坑记录
超级管理员 楼主

接口从 0.1 秒变成 3 秒,连接池被打满,用户开始投诉。大部分人说「加个索引就好了」,但加完还是慢——问题在细节。

这篇按排查顺序走一遍:先拿证据,再看执行计划,最后动索引和 SQL。

慢查询的根因分布

按出现频率排:

  1. 缺索引 —— 全表扫描
  2. 索引失效 —— 索引存在但用不上
  3. 选错执行计划 —— 优化器判断失误
  4. 锁等待 —— 被长事务挡住
  5. 返回数据量过大 —— 网络和内存都是瓶颈

前三条占绝大多数,但第四条最容易被误判——看起来是「这条 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 验证——这三步做到,大部分问题都跑不掉。

真正的难点不是技术,是在压力下仍然按流程走。线上告警响的时候,直接上手改是最诱人的选择,也是最容易绕远路的选择。

严亦凡

锁等待被误判成慢 SQL 太常见了。快速区分的办法:慢日志里看锁时间和扫描行数的比例,锁时间占比高但扫描行数很少,基本就是被挡住了,别急着加索引。我们之前给一条只扫 200 行的 SQL 加了三个联合索引,毫无效果,最后查出来是上游一个未提交的批量更新。

秦强

锁等待最容易误判这点非常认同。补一个排查顺序上的经验:先看慢日志里锁等待时间的占比,占大头就别动索引,直接去查有没有长事务没提交。我们有一次优化加了一堆索引没用,最后发现是一个批处理任务开着事务跑了四十分钟。

冯伟

锁等待那条真是重灾区。我们有次接口变慢,加了三个索引都没用,最后发现是上游定时任务开了长事务没提交,行锁一直挂着。现在排查第一步先查长事务时长,比翻慢日志快得多。索引失效那类用 force index 对照一下就能排除,别急着改表结构。

登录 后参与回复