☰
MySQL慢SQL优化实战:从Explain分析到索引设计与SQL改写
2026/10/2 9:10:43 网站建设 项目流程

慢 SQL 这个问题,基本每个用 MySQL 的团队都会碰上。索引没建对、查询写得太随意、数据量一上来,原来秒出的接口直接卡到超时。MySQL 本身不复杂,但“快”和“慢”之间往往就差一个索引或者一条 SQL 的写法。这篇文章我会从诊断慢查询开始,把 explain 怎么读、索引怎么设计、SQL 怎么改写、事务和锁对查询的影响这些事串起来,配合实际案例讲清楚。适合刚接手 MySQL 优化的人,也适合写了几年 SQL 但从来没认真看过执行计划的开发同学。

1. 优化之前,先把“慢”量化出来

很多人找我帮忙看慢 SQL,第一句话就是“这个查询好慢,帮我看看”。但到底多慢?一天执行多少次?慢的时候 CPU、IO 是什么状态?全都没概念。没有量化数据,优化就是盲人摸象。所以第一步永远是先让 MySQL 自己告诉你哪些 SQL 慢。

1.1 慢查询日志怎么开最省事

MySQL 默认是关闭慢查询日志的,生产环境直接开也没什么心理负担,因为这个功能本身很轻。关键是阈值要设置好,我一般用 1 秒作为初始标准,业务本身就慢的可以放宽到 2 秒。

-- 查看当前状态 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';

log_queries_not_using_indexes这个参数容易被忽略,但它非常有用。它会额外记录所有没有走索引的查询,哪怕执行时间只有几十毫秒。这类 SQL 往往是潜在的地雷,数据量一涨就直接炸。生产环境可以开着,不过日志量会大不少,记得配合日志切割。

慢查询日志落在文件里,格式大概是这样的:

# Query_time: 3.207795 Lock_time: 0.000181 # Rows_sent: 215 Rows_examined: 482914 SELECT ... FROM orders WHERE customer_id = 8873 ORDER BY create_time DESC;

注意Rows_examined和Rows_sent的对比。482914 行扫描出来最后只返回 215 行,这种查询多来几次,再好的磁盘也扛不住。这就是优化的第一手证据。

1.2 先会用 explain 再说优化

拿到慢 SQL,别急着改,先看看执行计划。MySQL 的EXPLAIN会告诉你这条 SQL 是怎么执行的,是走索引还是全表扫,预估扫多少行,有没有临时文件和文件排序。

EXPLAIN SELECT * FROM orders WHERE customer_id = 8873 ORDER BY create_time DESC;

输出里重点看这几个字段:

  • type:访问类型。const、eq_ref最好,ref和range也算健康,ALL就是全表扫描,基本是重点怀疑对象。
  • key:实际用到的索引。如果是 NULL,说明没走任何索引。
  • rows:预估扫描行数。这个数字太大,比如几十万上百万,后续要优化空间就很大。
  • Extra:这里经常藏着问题。Using filesort说明排序没走索引,Using temporary说明用了临时表,Using where说明虽然走了索引但还有条件在引擎层过滤,Using index是最理想的状态,覆盖索引扫描,连回表都省了。

注意:rows是预估值,不是精确值,而且它基于统计信息和采样,有误差正常。但量级很有参考价值——预估 50 万行和预估 500 行,代表了完全不同的执行路径。

我曾经排过一个线上问题,一张用户表才 20 万行数据,一条查询跑了 8 秒。explain 一开,type 是 ALL,rows 是 19.8 万,Extra 里躺着Using filesort。表确实小,但查询条件里的字段一个索引都没建,每次请求全表扫完还要内存排序。这属于最典型也最好解决的慢查询:建对索引,八秒变五毫秒。执行计划就是帮你定位这类问题的地图,一定要养成习惯。

2. 索引设计和使用的几个核心细节

索引是 MySQL 查询优化的基石。很多开发同学知道“查询慢要加索引”,但加到什么程度、什么字段顺序、什么时候索引会失效,心里没数。这一节把最核心的几个原则讲透,都是可以直接拿去用的。

2.1 联合索引的最左前缀到底是什么

联合索引可能是被误解最多的概念。书上总说“最左前缀原则”,听起来很玄,其实一句话就能说清:MySQL 把联合索引的多个字段按顺序拼成一个“字典序”的复合键,查询条件里必须从最左边的字段开始连续匹配,才能用上这个索引。

举例,有一个索引(a, b, c):

  • WHERE a = 1,走索引。
  • WHERE a = 1 AND b = 2,走索引。
  • WHERE b = 2 AND c = 3,不走索引,因为跳过了a。
  • WHERE a = 1 AND c = 3,只用到a这一列,c用不上。

为什么?因为索引是有序排列的,先按a排,a相同再按b排,再按c排。跳过了b直接拿c去匹配,相当于在一本按“姓氏-名字”排序的电话簿里,只知道名不知道姓,没法二分查找,只能把整个电话簿翻一遍。

所以设计联合索引时,字段顺序一定要把区分度高、查询最频繁的放在最前面。比如订单表最常见查询是“某个用户的订单按时间倒序”,那么(customer_id, create_time)就是一个很自然的联合索引。

2.2 覆盖索引回了多少表

覆盖索引的意思是:查询需要的所有字段都包含在索引里,MySQL 只需要扫索引页,不需要再回到聚簇索引(主键索引)里取整行数据。Extra里的Using index就是标记。

这是优化回表开销最直接的手段。尤其对于那些查询频繁、行宽大(字段很多)的表,覆盖索引能把 IO 成本压得很低。比如你要统计某段时间内的订单数量:

SELECT COUNT(*) FROM orders WHERE status = 1 AND create_time BETWEEN '2024-01-01' AND '2024-01-31';

如果只建了(status, create_time)联合索引,InnoDB 的二级索引页里就包含了这两个字段,COUNT 直接扫索引就能算出来,不用回表查整行。但如果 SELECT 里带上了amount、customer_name这种不在索引里的字段,每命中一条记录都要回一次表,IO 多好几倍。

经验:对于大表高频查询,不要轻易写SELECT *。不是说我反对写星号,而是当你想利用覆盖索引时,多出来的字段可能让整条 SQL 从“索引扫描”退化成“索引扫描+回表”。你只需要把 SELECT 的字段列表精简到真正需要的那几个,覆盖索引就有机会生效。

2.3 函数操作和隐式转换是索引杀手

索引列上做函数操作,这是最常见、也最隐蔽的索引失效原因。最典型的是对日期字段做格式化比较:

-- 这种写法,create_time 上的索引完全失效 SELECT * FROM orders WHERE DATE(create_time) = '2024-06-01'; -- 改成范围查询,索引正常 SELECT * FROM orders WHERE create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00';

同理,LEFT(name, 3) = 'abc'、YEAR(create_time) = 2024这类写法都会让 MySQL 对每一条记录先做运算再比较,索引自然就没有用武之地。优化思路是改写成“无函数的范围条件”,或者把计算列拆出来建生成列索引。

隐式类型转换也经常坑人。最常见的是字符串和数字比较:

-- phone 字段是 varchar 类型,传入数字 13800138000 SELECT * FROM users WHERE phone = 13800138000;

MySQL 会把 phone 列转成数字再比较,索引直接失效。解决办法是代码里规范参数类型,或者写 SQL 时老老实实加引号:

SELECT * FROM users WHERE phone = '13800138000';

判断索引是否失效,最好的办法还是 explain 看一眼。key 字段为空,type 是 ALL,那八成就是踩了这两种情况之一。

3. SQL 改写的实战技巧

索引和 SQL 是共同作用的。索引建得好,SQL 写得稀烂一样快不起来;SQL 写得聪明,有些时候还能弥补索引的不足。改写不是炫技,每一处改写背后都有明确的代价考量。

3.1 大表 JOIN 和子查询怎么处理

以前我见过不少人喜欢把关联查询写得特别“优雅”,一层子查询套一层,看起来短,执行起来惨不忍睹。MySQL 处理子查询的方式并不总是高效的,尤其是IN (SELECT ...)这种,有时候会被改写成相关子查询,外部每一行都要去执行一次内部查询。

比如:

-- 这写法数据量一大就容易慢 SELECT * FROM orders WHERE customer_id IN ( SELECT id FROM customers WHERE level = 3 );

如果customers表 level = 3 的用户有几千个,orders表几百万行,这种子查询很容易扫得很痛苦。改写方案是拆出来用 JOIN:

SELECT o.* FROM orders o INNER JOIN customers c ON o.customer_id = c.id WHERE c.level = 3;

这里还有一个容易被忽略的点:JOIN 写得好不好,取决于驱动表的顺序。MySQL 优化器一般会自己选,但如果你发现执行计划里驱动表选错了,可以用STRAIGHT_JOIN强制指定顺序。驱动表的原则是:用小表驱动大表,外层扫描少,内层走索引,整体成本就低。

3.2 分页深度越翻越慢的问题

分页慢是业务系统里的高频痛点。LIMIT 1000000, 20这种写法,MySQL 不是只读 20 条,而是把前 100 万条全扫出来丢掉,再取最后 20 条。越往后面翻,扫的数据越多,自然越来越慢。

常用优化手段是把“偏移量”变成“基于索引位置的过滤”:

-- 传统写法,深度分页很慢 SELECT * FROM orders ORDER BY id LIMIT 1000000, 20; -- 改写:记住上一页最后一条的位置 SELECT * FROM orders WHERE id > 1000010 ORDER BY id LIMIT 20;

这种“游标式”分页需要业务接口配合,把上一页的最后一个 id 传到下一页。它有一个前提:id 是连续自增的,或者你总能用某个唯一字段做排序。实际项目里很多表的主键是自增整数,这个方案实现成本很低,但收益巨大。如果你是业务开发,建议优先把列表接口改成这种模式,尤其数据表超过百万行以后。

3.3 OR、IN 和 UNION 的选择

OR的常见问题是:条件里的几个字段如果不在同一个索引里,MySQL 可能被逼着做全表扫。比如:

SELECT * FROM users WHERE name = '张三' OR phone = '13800138000';

如果 name 和 phone 各有一个单独索引,MySQL 理论上可以用index_merge把两个索引结果合并,但这是优化器行为,不受你控制。更稳定、也更容易预估的写法是把 OR 拆成 UNION ALL:

SELECT * FROM users WHERE name = '张三' UNION ALL SELECT * FROM users WHERE phone = '13800138000';

注意这里用UNION ALL而不是UNION。UNION 会做去重,必然引入排序或哈希操作;UNION ALL 只是简单拼接。你本来就不需要去重的时候,用 UNION 纯粹是浪费。

对于IN,只要列表长度可控(几百上千以内),MySQL 走索引没问题。真正要小心的是 IN 列表特别大,比如上万甚至十万个 id,这时候生成的索引范围访问成本会很高。我踩过这类坑:一个批量查询接口,前端传来上万条 id,一条 SQL 把 InnoDB 的 range 优化征信都查崩了。这种场景更适合分批查,比如每批 1000 个,多查几次,或者干脆落到临时表里 JOIN。分批不是退步,是对数据库的保护。

4. 事务和锁也会影响查询效率

查询慢不全是索引和 SQL 的问题。很多“间歇性变慢”的故障,最后查到根因根本不在查询本身,而是事务和锁在捣乱。这个维度经常被忽略,但它在高并发场景里非常致命。

4.1 长事务一个,阻塞半边天

长事务的意思是一个事务长时间不提交,持有锁不放。InnoDB 是行级锁,听起来很乐观,但一个事务更新 100 万行,行锁要扩大到表锁(MySQL 内部有锁升级和间隙锁机制),期间其他事务的写入全部排队。

更麻烦的是 MVCC 的多版本链。长事务存在时,undo log 里要保留大量历史版本,其他 session 的普通 SELECT 虽然不会真的被阻塞,但需要顺着版本链找到自己可见的版本,这个过程如果要遍历很多版本,CPU 消耗会明显上升。所以长事务不只是影响写,读也会跟着变慢。

确认长事务很简单:

SELECT * FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60;

看到长时间未结束的事务,去代码里找对应的连接是不是忘了 COMMIT。常见原因包括:代码里手动开了事务但异常分支没回滚、一个事务里做了太多外部 RPC 调用、连接池里连接复用时事务状态没清理干净。

4.2 行锁等待和间隙锁

锁等待导致的慢查询往往表现为“时而快时而慢”,而且慢 SQL 日志里Lock_time很大。比如 A 事务更新了某行没提交,B 事务去更新同一行,就要等 A 提交,等多久 Lock_time 就是多久。

排查锁等待有两个常用办法。一个是看当前有哪些锁:

SELECT * FROM performance_schema.data_lock_waits;

另一个是看有哪些事务在跑:

SELECT * FROM sys.innodb_lock_waits;

sys.innodb_lock_waits已经把阻塞关系整理好了,能直接看到是谁阻塞了谁,哪个事务持有锁,哪个事务在等待。这个输出会告诉你“阻塞者”和“等待者”,顺着去业务侧定位代码就行。

间隙锁的问题更隐蔽。在 RR 隔离级别下,InnoDB 做范围查询时会锁住不存在的间隙。比如UPDATE orders SET status = 2 WHERE amount > 1000,如果这个条件命中范围很大,间隙锁覆盖的范围可能很广,导致其他插入操作被阻塞。处理思路是:尽量让更新条件更精确、能走唯一索引就走唯一索引、适当情况下把隔离级别降到 RC(但要注意业务是否依赖 RR 的可重复读语义,不能盲降)。

经验:凡是发现 SQL 本身执行计划没问题、索引也走了,但日志里 Lock_time 异常高,就要优先查锁等待。先看 innodb_trx,再看 lock_waits,基本能定位。这个排查路径我走了很多年,屡试不爽。

5. 常见问题速查和优化案例实录

这一节把实际工作中碰到的高频问题和对应的优化手段整理成表,再拆两个完整的优化案例,给大家一个从现象到方案的完整思路。遇到类似问题可以直接对着查。

5.1 高频问题速查表

现象可能原因优先排查点常用优化方案
查询突然变慢,explain 走 ALL索引缺失或索引失效explain 的 type、key建索引或改写索引失效写法
列表接口越翻越慢深度分页分页 SQL 的 LIMIT 偏移量游标式分页
排序慢,Extra 有 Using filesort排序字段没索引EXPLAIN 的 Extra排序字段建索引或改排序方式
COUNT 很慢InnoDB 不像 MyISAM 存计数表行数过大用单独计数表或走二级索引 COUNT
偶尔卡顿,Lock_time 高事务锁等待查 innodb_lock_waits缩短事务,减小锁范围
表数据很多,查询基数不高统计信息不准确EXPLAIN rows 与真实数据差异ANALYZE TABLE 刷新统计信息
JOIN 很慢驱动表选错explain 第一行的表STRAIGHT_JOIN 或改写 JOIN 条件
大 IN 列表查询慢索引 range 范围过大慢日志里 IN 长度分批查询或临时表 JOIN
时快时慢不稳定缓存失效或锁阻塞看 Query_time 和 Lock_time 分别定位阻塞,或调整缓存策略

这张表不是标准答案,但它覆盖了我在生产环境里见到的绝大多数慢查询形态。核心思路是先分清时间是花在“扫描”还是“等待”上——扫描靠索引和 SQL 改写解决,等待靠事务和锁解决。

5.2 案例一:一条带 JOIN 和排序的报表 SQL

背景是一个订单报表接口,每天定时跑一次,跑完生成 Excel。随着订单量突破 300 万,这条 SQL 从最初的 3 秒恶化到 47 秒。

原始 SQL 大概长这样:

SELECT u.name, u.phone, o.amount, o.create_time FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE o.status = 1 AND o.create_time >= '2024-01-01' ORDER BY o.create_time DESC LIMIT 2000;

explain 看下来,orders 表走了(status, create_time)联合索引,问题出在ORDER BY和LIMIT的组合上。如果你想彻底搞懂 LIMIT 2000 对这个排序的影响,要看一个细节:因为 LEFT JOIN users,orders 每一条匹配后都要去 users 表回查 profile 字段,然后全部排完序再取前 2000 条。300 万行的排序是内存放不下的,直接落临时文件。

优化思路分两步。第一步,把排序和取数的条件尽量压在前半段完成,先只拿主键或排序字段:

SELECT o.id, o.amount, o.create_time FROM orders o WHERE o.status = 1 AND o.create_time >= '2024-01-01' ORDER BY o.create_time DESC LIMIT 2000;

第二步,拿到这 2000 个主键后再去 JOIN users 补全用户信息:

SELECT u.name, u.phone, t.amount, t.create_time FROM ( SELECT o.id, o.amount, o.create_time FROM orders o WHERE o.status = 1 AND o.create_time >= '2024-01-01' ORDER BY o.create_time DESC LIMIT 2000 ) t LEFT JOIN users u ON t.user_id = u.id -- 注意 t 里要带上 user_id ORDER BY t.create_time DESC;

初看 SQL 变长了,但它把“大表排序”和“大表 JOIN”分开了。排序只走 orders 的二级索引,JOIN 只针对 2000 条结果回表,临时文件排序整个被跳过了。优化后整体耗时降到 4 秒以内。这个案例的核心思想是:能推迟的关联,不要在排序前做。

5.3 案例二:COUNT(*) 在千万级大表上的优化

业务需求是统计某个状态下用户数,用于管理后台展示。表 2000 万行,原来的查询:

SELECT COUNT(*) FROM orders WHERE status = 4;

status 上是有索引的,但每次都实时扫描,2000 万行就算走二级索引也要秒级返回,而且会顶住大量 IO。更要命的是这个数字被好几个页面实时调用,频率非常高。

我的处理思路是:先问业务方这个数字的实时性要求有多高。对方表示误差一分钟之内完全可接受。好,那这就不是查询优化问题,是计数策略问题了。方案是建立一个统计表:

CREATE TABLE orders_status_count ( status TINYINT PRIMARY KEY, cnt BIGINT NOT NULL ); -- 每新增、变更一条订单时维护 cnt,或者在应用层定期汇总

然后统计页面直接读这个表。更新维护的成本远低于每次都扫 2000 万行。如果数据量更大、更新频率更高,可以把“计数”改成“定时任务每 5 分钟统计一次刷进统计表”,这个方案我实际用了很多年,从来没被投诉过。

这里我想多说一句:很多慢查询的根因,其实是用“对实时性要求很高的复杂查询”去实现“其实可以接受延迟的业务需求”。优化不是只能死磕 SQL 和索引,有时候重新审视业务需求、调整数据产出方式,才是成本最低收益最大的方案。

6. 我的实操心得和几条铁律

做了这么多年 MySQL 优化,踩过的坑不少,有几个心得想分享给后来者。它们不深奥,但每一条都是真金白银换来的。

第一,永远不要凭感觉改 SQL。先开慢查询日志,先 explain,先看锁等待,把问题的“物理形状”摸清楚再动手。我见过有人在完全没看执行计划的情况下,给一张表连续加了 8 个索引,结果查询还是慢,因为慢的原因根本不在索引。优化之前先诊断,这是一切的前提。

第二,一次只能改一个变量。改一条 SQL、加一个索引、调整一个事务,改一个就压测验证一个。同时改三个东西,出问题时你根本不知道是谁的锅。这个原则适用于所有性能优化的现场,MySQL 尤其如此。

第三,索引宁缺毋滥。很多开发同学觉得“索引多不坏事”,但每个索引都会拖慢写入、占磁盘空间、增加优化器选择成本。一张 500 万行的表,如果上面挂了 15 个索引,写入性能一定会受影响。我在实际项目中习惯于先删除从未被使用的冗余索引,比如排查performance_schema或sys.schema_unused_indexes,你会发现很多索引根本没人用。删掉之后写入和查询都更稳。

第四,优化是一个循环,不是一锤子买卖。数据量在涨、业务模式在变,今天的最优写法半年后可能就是瓶颈。建议每隔一段时间重复这个循环:慢日志收集一次、把 Top N 慢 SQL 拿出来 explain 一遍、对明显异常的做索引或改写调整。这是个常态运维动作,不复杂,但坚持下来的团队很少遇到真正的数据库事故。

最后再分享一个小技巧。很多团队把“SQL 优化”当成 DBA 的活,但我建议让一线开发同学都熟练使用 explain。门槛真的不高,花一个下午把执行计划看懂,之后写 SQL 的思维方式都会不一样——你会开始考虑表之间的关联成本、索引的匹配方式、排序要不要落地。这个意识建立起来之后,很多慢查询在代码评审阶段就被拦掉了,根本不会带到生产环境里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询