做后端开发这几年,和 MySQL 打交道的次数实在太多,但真正让我意识到 SQL 调优有多重要的,还是几次线上慢查询把人折腾到焦头烂额的经历。一个看起来人畜无害的 SELECT,数据量一上来,能把接口响应时间从 50ms 拖到 5 秒,数据库 CPU 直接飙高。这套东西说难不难,说简单也不简单,核心就两件事:先找到慢的 SQL,再搞清楚它为什么慢。这篇文章就把我实际调优过程中常用的思路、工具和坑都梳理一遍,适合刚接触 MySQL 性能优化、被慢查询困扰的开发者参考。花十分钟读一遍,能帮你少走不少弯路。
1. 先搞清楚 SQL 慢在哪:定位问题的基础姿势
调优最忌讳的就是上来就凭感觉改 SQL、加索引。没有数据支撑的调优,就像蒙着眼睛修电路,运气好能碰上,运气不好越搞越乱。我惯用的第一步永远是先定位,把那些真正拖垮系统的 SQL 从茫茫多的请求里捞出来。
1.1 打开慢查询日志,把“嫌疑犯”抓出来
MySQL 提供了现成的慢查询日志功能,专门记录执行时间超过阈值的 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; SET GLOBAL log_queries_not_using_indexes = ON;这里有个细节容易踩坑:long_query_time默认值是 10 秒,意思是 MySQL 认为超过 10 秒才算“慢”。这个阈值对线上大多数接口来说太宽松了,一个查询跑到 5 秒可能已经把服务拖垮了,日志里却什么都没记。我一般会设成 1 秒甚至更小,先观察一阵子,把肉眼看不出问题、实际上很慢的查询都暴露出来。
另外,log_queries_not_using_indexes这个参数值得打开,它会记录所有没有走索引的查询。哪怕查询本身执行得不算慢,只要没走索引,在高并发下就是隐患。这个开关在排查问题时特别好使,能一次性把“漏网之鱼”全部捞出来。
慢查询日志是文本格式,如果量大,直接看会很痛苦。这时候可以用 Percona Toolkit 里的pt-query-digest做汇总分析,它会按照查询指纹把同类 SQL 聚合在一起,按执行总耗时、平均耗时、出现次数排序。我通常在下面这个级别去用:
pt-query-digest /var/log/mysql/mysql-slow.log它会输出一份类似报表的结果,排在最前面的就是最值得处理的 SQL。有一次我就是在报表里发现一个平时根本没人注意的批量查询占了 70% 的慢查询总量,顺着这个线索才找到缺失的联合索引。
1.2 用 EXPLAIN 给 SQL 做一次“体检”
拿到具体某条慢 SQL 之后,下一步就是用EXPLAIN看它的执行计划。EXPLAIN不会真的执行那条 SQL,只是模拟优化器给出的执行路径,所以线上直接跑也没压力。
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 1 ORDER BY created_at DESC;输出结果里会有一堆字段,比如 id、select_type、table、type、possible_keys、key、key_len、ref、rows、filtered、Extra。刚接触时最容易犯的错误是只盯着type字段看是不是ALL,一旦看到ALL就觉得完了,看到index就以为万事大吉。实际上,这些字段需要组合起来才能下结论。
如果要评估一个查询是否健康,我一般按优先级这样看:
- 先看
type,它是访问类型的核心标识。从好到差大致是const、eq_ref、ref、range、index、ALL。看到ALL就是全表扫描,基本可以判定这条 SQL 欠优化。 - 再看
key,确认实际用到的索引是哪个。如果possible_keys里有候选索引,但key是 NULL,说明优化器没有选它,这里可能有蹊跷。 - 接着看
rows,它是优化器预估的需要扫描的行数。rows越小越好,但要注意它只是预估值,不是精确值。 - 最后看
Extra,这里藏着很多关键线索。比如Using filesort代表排序没有走索引,Using temporary代表使用了临时表,Using index表示覆盖索引生效。
EXPLAIN还可以加参数看更细的信息,比如EXPLAIN ANALYZE在某些版本里能给出实际执行时间和各阶段耗时。在 MySQL 8.0 之前的版本里没有这个能力,只能靠EXPLAIN加肉眼分析,但这已经足够解决绝大多数问题。
2. 执行计划里的关键信号:如何准确读懂 EXPLAIN
很多人看完 EXPLAIN 就只会确认“有没有走索引”,其实里面的信息密度远比想象的高。读懂执行计划是 SQL 调优的底层能力,分几个维度展开讲。
2.1 type 字段:访问类型决定查询的下限
type字段是 EXPLAIN 输出中最直观的指标,它表示 MySQL 找到所需行的方式。我把常用的类型按性能从高到低排一下:
| type 值 | 含义 | 效率 |
|---|---|---|
| system | 表中只有一行数据,属于 const 的特例 | 极快 |
| const | 主键或唯一索引等值查询,最多返回一行 | 极快 |
| eq_ref | 使用唯一索引做关联查询,每次匹配一行 | 很快 |
| ref | 使用普通索引等值查询,可能匹配多行 | 快 |
| range | 索引范围扫描,比如 BETWEEN、>、<、IN | 中等 |
| index | 全索引扫描,不读数据行但扫了整个索引树 | 较差 |
| ALL | 全表扫描,从头到尾扫聚集索引 | 最差 |
这里要注意一个容易混淆的地方:index和ALL虽然名字看起来差不多,但index是扫索引树,ALL是扫整张表的数据。index不一定会读所有行,性能通常比ALL好一点,但依然不理想。真正需要追求的是让核心业务的查询落在ref往上。
我见过不少人看到type = ALL就急着加索引,其实应该先想想业务能不能接受。比如一张只有几千行的配置表,全表扫描也就 0.1ms,为了这 0.1ms 加索引徒增写操作负担,完全没必要。调优要综合考虑数据量级,不是为了调优而调优。
2.2 联合 key_len 和 rows 判断索引的真实效果
key告诉你了用哪个索引,但索引用得好不好,要看key_len和rows。
key_len表示 MySQL 在索引里实际使用的字节数。这个数字很有参考价值:联合索引中,如果key_len只等于第一个字段的长度,说明只用了联合索引的最左前缀,后面的字段没能参与索引查找。
举个例子,一张订单表有联合索引(user_id, status, created_at),三个字段分别是 int(4字节)、int(4字节)、datetime(8字节,可空再加1字节)。如果某次查询只用了 user_id 字段,key_len大约是 5 左右;如果三个字段都用上了,key_len会明显变大。通过key_len可以反推联合索引的实际利用率,这个技巧在排查索引设计问题时特别好用。
rows是优化器预估扫描的行数。有一次调优经历让我印象很深:一条 SQL 明明走了索引,type 是 ref,但 rows 显示 80 万。问题出在索引区分度太低——某个状态字段只有 0 和 1 两个值,散落在一张百万级表里,每个值都对应几十万行。走这个索引还不如全表扫描呢,优化器有时候也会因此放弃索引。所以看 rows 不能只看绝对值,要结合数据分布判断。
2.3 Extra 字段中的性能警报
Extra字段是执行计划里的“剧情补充”,很多潜在问题都藏在这里。我重点提醒几个经常出现的关键字:
Using filesort:排序没走索引,MySQL 需要额外排序。如果数据量大,这个代价很高。通常意味着 ORDER BY 的字段和索引顺序不匹配。Using temporary:查询过程创建了临时表,常见于 GROUP BY 和 DISTINCT 没走索引的情况。临时表可能落到磁盘上,性能非常差。Using index:这是好消息,表示覆盖索引,查询所需字段全部包含在索引里,不用回表,速度很快。Using where:在存储引擎返回记录后再过滤,本身不算有问题的信号,但配合ALL就要引起注意。
有一次我排查一个报表统计 SQL,EXPLAIN 里同时出现Using temporary和Using filesort,两个警报叠加,执行时间自然惨不忍睹。后来通过调整 GROUP BY 字段顺序,让分组和排序都能复用同一个联合索引,这两个关键字才从执行计划里消失。所以 Extra 字段里面一旦出现不良信号,几乎可以直接定位到下一步优化方向。
3. 索引优化:SQL 调优的核心弹药库
定位到问题 SQL 之后,大部分工作都落在索引上。索引设计得好不好,直接决定查询性能的天花板。这一节讲我在实际项目中反复用到的索引优化方法论。
3.1 联合索引设计与最左前缀法则
联合索引是应对多条件查询最常用的手段,但设计不好反而没什么用。它的核心规则是“最左前缀”:MySQL 只能从联合索引的最左列开始逐列匹配,跳过了第一列,后面的索引就全派不上用场。
比如有一个联合索引(a, b, c),下面这些情况会怎么走:
WHERE a = 1:走索引,a 用上了WHERE a = 1 AND b = 2:走索引,a、b 用上了WHERE b = 2:走不了这个索引,因为跳过了 aWHERE a = 1 AND c = 3:a 用上了,c 用不上
所以设计联合索引时,字段顺序的排列是一个需要反复权衡的事。我的排列思路是这样的:等值查询的字段放前面,范围查询的字段放后面。因为等值条件可以被索引精确定位,而范围条件(比如 >、BETWEEN)用完之后,后面的索引列就没法继续参与匹配了。
举一个电商订单查询的案例,业务场景是按用户查订单、按状态过滤、按时间排序:
SELECT * FROM orders WHERE user_id = 123 AND status = 1 ORDER BY created_at DESC;那联合索引设计成(user_id, status, created_at)就比较合理。user_id 是等值筛选,status 能进一步过滤,created_at 既用于查询又用于排序。这样排序就不需要额外做 filesort,查询和排序一次搞定。
3.2 覆盖索引:让查询少回一次表
覆盖索引指的是索引中已经包含了查询需要的所有字段,MySQL 直接从索引树取数据,不需要回表查完整数据行。判断标准就是 EXPLAIN 的 Extra 字段里出现Using index。
覆盖索引的效果比普通索引好很多,因为索引树通常比数据行小得多,扫索引树的 IO 成本远低于扫数据行。如果业务里高频出现某个查询,又是只需要少数几个字段的场景,设计覆盖索引是非常划算的。
举个例子,一个小程序后台列表页需要展示订单号和订单金额,SQL 是:
SELECT order_no, amount FROM orders WHERE user_id = 123;如果有索引(user_id, order_no, amount),这个查询从索引树里就能拿到全部所需数据,连数据行都不用碰。如果只建了(user_id)索引,MySQL 还得根据主键回表拿 order_no 和 amount,多一次随机 IO。
我一般建议把 SELECT 的字段数量和覆盖索引的字段数量一起核对,能用覆盖索引解决的查询尽量不额外加字段。但也不是字段越多越好,索引字段太多会占空间、影响写入性能,核心是“高频查询的字段尽量覆盖”。
3.3 索引失效的经典场景盘点
索引建了不代表就一劳永逸,很多 SQL 写法会让索引悄悄失效。这些坑我几乎全都踩过,整理出来给大家避雷。
函数包裹索引列。只要对索引列用了函数,这个列的索引基本就废了。比如:
SELECT * FROM users WHERE DATE(create_time) = '2024-01-01';这里create_time虽然可能是索引列,但被DATE()包了一层,MySQL 没法直接利用索引。正确写法是改成范围条件:
SELECT * FROM users WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00';隐式类型转换。这一条是隐藏最深的坑。如果字段是字符串类型,但查询条件传了数字,MySQL 会做隐式类型转换,索引很可能失效。比如:
SELECT * FROM users WHERE phone = 13800138000;如果 phone 字段是 VARCHAR 类型,这个查询会让 MySQL 把 phone 转成数字和传入值比较,一旦转型函数套在索引列上,索引就失效了。正确做法是给参数加上引号:
SELECT * FROM users WHERE phone = '13800138000';左模糊查询。LIKE '%abc'这种写法,索引无法利用,因为不知道要从哪个字符开始匹配。但LIKE 'abc%'是可以走索引的,这一点常常被忽略。
OR 连接条件。如果一个 OR 条件里只有一个字段有索引,MySQL 也可能会放弃索引。比如WHERE name = '张三' OR age = 18,只有 name 有索引,这条查询可能退化成全表扫描。可以考虑用 UNION ALL 拆分,或者保证 OR 两边的字段都有索引。
NOT IN、!=、NOT LIKE。这类否定条件往往会导致索引失效,优化器认为扫全表可能更快。如果这种查询很频繁,需要重新审视业务设计,比如用状态位代替 NOT IN 的逻辑。
这些场景在实际工作中出现的频率非常高,我建议把上面几类情况当成“索引失效红黑榜”记住。排查时碰到查询不走索引,优先检查是不是踩了这几个典型坑。
4. 业务场景中的 SQL 调优实战
讲完原理,接下来是实战环节。我会用几个真实的业务场景来演示调优思路,包括深分页、JOIN、排序分组和写入优化,每个场景都给出具体的改动方式和效果对比。
4.1 深分页优化:LIMIT 翻页越翻越慢的问题
分页查询可能是业务中出现最多的场景,也是最容易被人忽略的深坑。普通分页写法长这样:
SELECT * FROM orders ORDER BY created_at DESC LIMIT 100000, 20;表面上看只取 20 条数据,但 MySQL 需要先把前 100000 条全部查出来,然后再丢弃。越往后翻页,OFFSET 越大,查询越慢。这个问题的根源在于 MySQL 的 LIMIT 实现机制,它必须扫描到指定的偏移量,才能往后取数据。
解决方案有两个主流思路。
第一个思路是“延迟关联”,也叫“先查索引再回表”。先通过覆盖索引查出目标页的主键,再用主键去关联原表取完整数据:
SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY created_at DESC LIMIT 100000, 20 ) t ON o.id = t.id;这个写法乍看多了一层子查询,但内层查询可以只扫索引树,不用回表,速度快很多。外层再用主键关联,走 eq_ref,整体性能提升明显。
第二个思路是“游标分页”,也叫“键集分页”。在翻页时记录上一页最后一条记录的某个索引字段值,下一页直接用这个值作为过滤条件:
SELECT * FROM orders WHERE created_at < '2024-01-15 10:00:00' ORDER BY created_at DESC LIMIT 20;这个方式彻底跳过了 OFFSET,因为数据库直接定位到指定位置往后取固定条数。前提是业务允许不跳跃式翻页,只允许“上一页/下一页”的浏览方式。这种场景在移动端内容流和后台管理列表里非常常见,可以用。
我亲测过一个后台订单列表,翻到第 100 页时,普通 LIMIT 查询耗时 2.8 秒,改成延迟关联后降到 0.3 秒,改成游标分页后更是降到五十毫秒以内。可以说深分页问题一旦解决,效果立竿见影。
4.2 JOIN 查询:小表驱动大表是基本原则
JOIN 场景的调优,有一个经常被提起的原则:小结果集驱动大结果集。也就是说,尽可能让小表作为驱动表,大表作为被驱动表,因为被驱动表如果对应索引,每次查找都很便宜,但扫描次数是由驱动表的行数决定的。
模拟一个场景:查询每个用户的最近订单信息。
SELECT u.name, o.order_no FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE u.level = 1;如果 users 表是一张 1 万行的小表,orders 是有 100 万行的大表,那么 users 作为驱动表扫描 1 万次,每次在 orders 的user_id索引上查找,成本是可控的。反过来,如果让 orders 作为驱动表,扫描 100 万行,即便每次也走索引,总开销就完全不一样了。
优化器通常会自己选择合理的执行顺序,但有时候统计信息不准确,或者 SQL 写得有误导性,优化器选错了会导致性能非常差。这时可以关注几个点:
- 关联字段必须建索引。被驱动表的关联字段尤其重要,如果缺索引,每一条驱动表记录都要全表扫描被驱动表,这种情境下基本是灾难。
- 用 EXPLAIN 查看谁是驱动表。EXPLAIN 输出里排在最前面的表通常是驱动表,确认实际执行顺序是否符合预期。
- 如果明明小表驱动大表但依然慢,检查被驱动表的关联字段是否存在类型不一致导致的隐式转换。
另外多说一句,一般业务里能避免 JOIN 就尽量避免。特别是 OLTP 系统的核心接口,宁可拆成两次查询在应用层组装,也不要让数据库做复杂的多表关联。数据库的 CPU 和 IO 资源很宝贵,留给查询更简单直接的操作。
4.3 排序与分组优化:让索引替你做排序
ORDER BY和GROUP BY的优化思路类似,核心都是让排序或分组能直接用上索引,避免 MySQL 自己额外处理。
先看ORDER BY。如果 SELECT 语句里需要对某个字段排序,而这个字段正好是索引中的一列,MySQL 可以直接按索引顺序读取,Extra 里就不会出现Using filesort。
前面订单查询的例子其实已经讲过这个问题:联合索引(user_id, status, created_at)让ORDER BY created_at直接走了索引排序。这里要特别注意一点:ORDER BY的字段顺序要和联合索引的顺序严格一致,如果有多个排序字段,方向也要一致。比如索引是(a ASC, b ASC),那ORDER BY a DESC, b DESC可以走索引,但ORDER BY a ASC, b DESC就会导致 filesort。
再看GROUP BY。GROUP BY通常伴随聚合函数,比如统计每天的用户量:
SELECT DATE(create_time) AS day, COUNT(*) FROM users GROUP BY DATE(create_time);这个 SQL 在 create_time 上的索引基本用不上,因为 DATE() 函数包裹了字段。可以改成范围查询加分组的方式,或者直接在应用层做预处理。如果必须保留这个写法,可以考虑生成一个冗余的日期列并建立索引,让分组能直接走索引。
还有一个容易踩坑的地方是DISTINCT和GROUP BY一起用。我曾经碰过一条 SQL,同时用了 DISTINCT 和 GROUP BY,结果执行计划里出现Using temporary,性能惨不忍睹。删掉其中一个之后,临时表消失,速度大幅提升。这种小细节排查起来很费时间,但解决起来往往只是一行改动。
4.4 批量写入优化:不只是查询需要调优
SQL 调优经常被误以为只针对查询,其实写入语句同样会影响数据库整体性能。最典型的场景是批量 INSERT。
分批插入有两种常见做法。一种是一条 INSERT 语句带多组 values:
INSERT INTO orders (user_id, order_no, amount) VALUES (1, 'A001', 100), (2, 'A002', 200), (3, 'A003', 300);另一种是循环执行单条 INSERT,这个做法在应用代码里常见,但在数据量大的时候非常低效。每一条 INSERT 都涉及一次 SQL 解析、一次事务提交(如果是自动提交)、一次网络往返。性能差距非常明显。
实测下来,批量插入 10 万条数据,单条循环插入耗时数分钟,改成一条 SQL 多个 values 后,耗时会缩短到几秒。如果用的是 JDBC,还可以在连接参数里开启rewriteBatchedStatements=true,让程序在底层自动把批量操作合并成一条 SQL 发送,效果非常明显。
还有一点是关于大批量写入时的索引维护。索引不是越多越好,每次写入都要同步更新索引树。如果一张表有 6 个索引,批量插入的性能会比只有 2 个索引时差很多。在数据导入场景,可以先删掉不用的非核心索引,导入完成后再重建,整体耗时反而更短。这个技巧在初期数据迁移时经常用到。
5. 常见问题与排查技巧实录
调优是一个需要积累经验的活儿,碰到的问题多了,后面速度就快了。这一节整理一份我反复用到的问题速查和排查步骤,算是实践笔记。
5.1 从慢 SQL 到索引调整的完整排查流程
我自己的排查流程基本是固定的,给团队做培训时也这样讲:
- 开慢查询日志,收集一段时间的数据,用 pt-query-digest 汇总,锁定 TOP N 慢 SQL。
- 对每一条慢 SQL 执行 EXPLAIN,记录下 type、key、rows、Extra 四个关键字段。
- 对照索引情况,分析为什么慢。常见原因包括:没建索引、索引失效、数据量太大、SQL 本身写法有问题。
- 针对原因设计索引或改写 SQL。这里要结合业务,谨慎改动,尤其是线上索引变更。
- 改完后用 EXPLAIN 复查执行计划,观察 type 和 rows 是否优化。
- 最后用真实环境或者压测验证查询耗时,确认收益稳定。
这个流程每一步都不能跳。很多人改完 SQL 就直接上线,没做前后对比,结果优化了个寂寞或者误伤了其他查询。至少要有优化前后的耗时数据,才能证明改动的价值。
5.2 高频问题速查表
平时群里问得最多的问题,我整理成一个速查表格,方便自查:
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| EXPLAIN 显示 type = ALL | 无索引或索引失效 | 检查 WHERE 条件列有没有索引,排查函数包裹、隐式转换等问题 |
| Extra 显示 Using filesort | 排序字段和索引顺序不匹配 | 调整 ORDER BY 字段顺序,或设计包含排序字段的联合索引 |
| Extra 显示 Using temporary | GROUP BY、DISTINCT 未走索引 | 给分组字段加索引,改造 SQL 去掉不必要的 DISTINCT |
| 联合索引生效但 key_len 很短 | 联合索引只用了前缀字段 | 确认查询条件是否缺少中间字段,调整索引字段顺序 |
| rows 很大但 type 是 ref | 索引区分度低 | 考虑换字段建索引,或增加过滤条件 |
| 深分页越来越慢 | OFFSET 过大导致扫描大量数据 | 改延迟关联或游标分页 |
| 明明加了索引却没有走 | 统计信息过期或优化器选错 | ANALYZE TABLE 更新统计信息,必要时使用 FORCE INDEX |
这张表基本上覆盖了我这几年遇到的大部分问题。有些场景其实不是索引的问题,而是统计信息太久没更新,导致优化器做了错误判断。执行一下ANALYZE TABLE刷新统计信息,可能就把问题解决了。
5.3 调优效果验证:用数据说话
调完 SQL 之后怎么验证?我一般分三个层次。
第一个层次是 EXPLAIN 级别。看执行计划是否从 ALL 变成了 ref 或 range,key_len 是否变长,rows 是否下降,Extra 里是否还出现 filesort 或 temporary。这个层次最直观,哪怕不上线也能立刻看到变化。
第二个层次是单条 SQL 的耗时。在真实数据量下执行优化前后的 SQL,对比耗时。注意要多次执行取平均值,避免缓存带来的误差。MySQL 的 buffer pool 对热数据有缓存,第一次查冷数据慢、第二次快,是正常的,对比时要把这个因素考虑进去。
第三个层次是压测。用模拟流量压一下整个接口,观察数据库 CPU、IO、慢查询数量、接口响应时间。这个层次最能反映真实效果,同时也是回归测试,防止改动影响其他查询。
我在实际项目中见过好多次这种情况:单条 SQL 从 2 秒降到 0.1 秒,看起来成果惊人,但接口整体的瓶颈根本不在这一条 SQL,而在一个上游调用。所以调优要有全局视野,不要只盯着一条 SQL 的局部最优。先看接口链路里哪一段耗时占比最高,再去调那一段的 SQL,这才是真正的效率最大化。
写在最后的一点个人经验
做 SQL 调优这几年,最大的体会是“先测量,再优化”。不要因为某个查询看着不顺眼就动手改,也不要因为数据库 CPU 涨了就慌着加索引。慢查询日志、EXPLAIN、压测对比,这些工具和数据才是决策的依据。优化完一条 SQL 之后,记得把前后的执行计划和耗时记录整理保存下来,下次排查类似问题时直接翻笔记,效率会高很多。最后分享一个小技巧:每次上线 SQL 改动前,先在测试环境把执行计划打出来存档,和线上出问题时的执行计划做对比,往往能更快找到差异点。这套方法看起来简单,坚持做下来,你会发现大部分 SQL 性能问题都能在自己手里快速解决。