手里的接口突然变慢,慢查询日志里躺着一条执行了两秒多的SQL,这种事情我碰过不止一次。排查到最后,绝大多数问题都不是数据库服务器扛不住,而是SQL本身写得有优化空间。MySQL的SQL优化其实是有套路可循的,翻来覆去核心就那几板斧。今天把这几年最常用的5种优化方式整理出来,覆盖了从定位问题、设计索引、改写语句到处理排序分组的完整链路。无论你是刚接触数据库的开发者,还是已经被慢SQL折磨过一阵子的后端,这5个方向都能直接拿来用。
1. 第一种方式:用EXPLAIN读懂执行计划
1.1 为什么优化前必须先看执行计划
很多人的优化方式是这样的:觉得SQL慢,就随手加个索引试试,不行再换个写法。这种碰运气式的优化,在数据量小的时候可能看不出问题,一旦表里数据上了百万级,就完全行不通了。
SQL是一种声明式语言,你告诉MySQL你想要什么结果,但具体怎么执行,是优化器决定的。同样是SELECT * FROM orders WHERE user_id = 123,有没有索引、索引建得对不对、数据分布怎么样,执行路径可能完全不一样。不看执行计划,你根本不知道这条SQL是全表扫描、走了索引但只走了部分索引、还是触发了排序。
所以我的习惯是:拿到一条慢SQL,第一步永远是在前面加EXPLAIN,先看清楚MySQL打算怎么执行,再决定怎么改。这一步能帮你把问题定性——到底是索引没建,还是建了没用上,还是查询本身就太重。
1.2 EXPLAIN关键列到底怎么看
EXPLAIN的输出列很多,但平时排查慢SQL,真正需要盯紧的就几个:type、key、rows、Extra。
type表示访问类型,性能从好到差大致是system > const > eq_ref > ref > range > index > ALL。实际项目中,能到ref或者range就算不错了,如果看到ALL,意思就是全表扫描,这通常是性能瓶颈的核心信号。key列显示实际用到的索引,如果为NULL,说明这条SQL没有走任何索引。rows是优化器估算的需要扫描的行数,这个数字越大,SQL执行越慢。Extra里面藏着更多线索,看到Using filesort说明文件排序了,看到Using temporary说明用了临时表,这俩都是性能杀手。
举一个实际例子。某订单表有300万行数据,一条查询长这样:
EXPLAIN SELECT * FROM orders WHERE user_id = 123 ORDER BY create_time DESC LIMIT 10;执行计划显示type = ALL,rows = 3000000,Extra = Using filesort。这就很清楚了:全表扫描 + 文件排序,不慢才怪。此时再加一个复合索引(user_id, create_time),再执行EXPLAIN,type变成了ref,rows降到了几百行,Extra里的Using filesort也消失了。
1.3 一个慢SQL的完整排查实战
我把上面那个例子展开成完整的排查流程,你可以直接照着操作。
第一步,先确认SQL对应的表结构和数据量。第二步执行EXPLAIN,把上面说的几个关键列记录下来。第三步根据执行计划判断问题类型:是过滤条件没索引,还是排序字段没索引,还是两者叠加。第四步设计索引,这里要同时考虑WHERE条件和ORDER BY字段。第五步创建索引后再执行EXPLAIN对比,同时看真实执行耗时。
优化完成后不要立刻下结论,建议把这条SQL再跑几次取平均耗时,然后放到慢查询日志里观察一段时间。索引是否被使用、执行计划是否稳定,需要真实业务流量来验证,不能只看手工执行的一两次结果。
2. 第二种方式:让索引真正生效
2.1 索引加速的原理和代价
索引之所以快,核心在于B+树的有序性。这就好比查字典,你可以按拼音快速定位,而不需要从第一页翻到最后一页。MySQL的索引分两大类:聚簇索引,也就是主键索引,叶子节点存的是整行数据;二级索引,叶子节点存的是主键值。走二级索引查询时,如果需要的字段不在索引里,就要拿着主键回表查一次完整数据。
用索引是有代价的。每次写入(INSERT、UPDATE、DELETE)都要同步维护索引结构,所以索引不是越多越好。这也是为什么优化SQL的同时,还要检查是不是有人把索引建重复了、建了又用不上。二级索引本身占磁盘空间,太多还会拉低写入性能。
2.2 索引失效的六个经典场景
建了索引但没走索引,是SQL优化里最让人抓狂的情况。以下六个场景,我基本都在线上见过,分享出来帮你避坑:
- 复合索引不满足最左前缀原则。比如索引是
(a, b, c),但查询条件只有b = 1和c = 2,索引就用不上。最左前缀的意思是:必须从最左边的列开始连续匹配,跳着用就不行。 - 对索引列使用函数或表达式。
WHERE DATE(create_time) = '2024-01-01'这种写法,索引会失效,MySQL没法对函数运算后的结果做B+树查找。 - 隐式类型转换。字段是
VARCHAR类型,查询条件却用了数字,比如WHERE phone = 13800138000,MySQL会把字符列转成数字再比较,索引直接失效。 - LIKE查询前置通配符。
WHERE name LIKE '%keyword',因为通配符在开头,无法利用B+树的有序前缀匹配。 - OR条件连接非索引列。即使其中一个条件是索引列,OR连接的另一个非索引列也会导致整个条件无法走索引。
- 用
<>、NOT IN等否定条件。优化器可能认为全表扫描比走索引更快,尤其是数据量不大或区分度不高的时候。
2.3 复合索引设计的两个原则
复合索引的设计顺序很有讲究。第一个原则:把等值查询的列放在前面。比如WHERE user_id = 123 AND create_time BETWEEN '2024-01-01' AND '2024-02-01',索引应该建(user_id, create_time),因为user_id是等值条件,create_time是范围条件。第二个原则:区分度高的列放前面。区分度就是字段值的唯一程度,性别这种只有两个值的字段区分度很低,放前面会浪费索引的筛选能力。
我实际优化过这样一个场景。某系统需要统计一个用户在某个时间段内的订单量,表里300万条数据,原始SQL执行要好几秒。分析后发现WHERE条件是user_id等值 +create_time范围查询,于是建立复合索引(user_id, create_time),执行时间从几秒直接降到几十毫秒。这个案例里,索引设计顺序就是完全贴合查询条件的。
2.4 覆盖索引和索引下推
覆盖索引值得单独说。如果查询需要的字段都包含在索引里,MySQL就不需要回表了,直接遍历索引就拿到了所有数据。比如查询SELECT user_id, status FROM orders WHERE status = 1,建了(status, user_id)联合索引后,Extra里会出现Using index,这就是覆盖索引在生效。
索引下推是另一个容易被忽略的特性。MySQL 5.6以后,可以在索引遍历过程中直接过滤掉不满足条件的记录,减少回表次数。对于复合索引,如果查询条件里包含索引中后置字段的过滤条件,下推机制能显著减少IO。
提示:写SQL前先想清楚需要哪些字段,尽量让SELECT的字段列表包含在某个索引中,覆盖索引能省掉大量回表操作,性能提升非常明显。
3. 第三种方式:改写查询语句,丢掉坏习惯
3.1 为什么不要轻易写SELECT *
很多从ORM框架入门的朋友写SQL时习惯直接SELECT *,图省事。但在大表上,这可能是慢SQL的重要来源。
第一个问题是回表。如果索引没有覆盖所有字段,SELECT *意味着查出来的每一行都可能需要回表,回表次数直接翻好几倍。第二个问题是网络传输。一个表有几十个字段,其中大部分是业务用不到的TEXT、BLOB这种大字段,全查出来会白占大量带宽和内存。第三个问题会让覆盖索引失去意义——本来二级索引就能搞定的事情,硬生生变成全表回表。
正确的姿势是只查询业务需要的字段。比如订单表只需要查order_no和amount,就写SELECT order_no, amount,配合合适的索引,执行效率会高很多。尤其是返回大量行的统计类查询,字段精简带来的性能提升非常可观。
3.2 隐式类型转换和函数操作
这两个问题在业务代码里经常出现,而且很隐蔽。隐式类型转换最典型的例子就是手机号字段。表里的user_phone是VARCHAR(20),代码里写的是WHERE user_phone = 13800138000,这里的数字会被转换成字符串,导致无法使用索引。正确写法是用引号包起来:
-- 错误但常见的写法 SELECT * FROM users WHERE user_phone = 13800138000; -- 正确写法 SELECT * FROM users WHERE user_phone = '13800138000';函数操作的典型例子是日期查询:
-- 索引失效的写法 SELECT * FROM orders WHERE DATE(create_time) = '2024-01-01'; -- 改写为范围查询 SELECT * FROM orders WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-02-01 00:00:00';第二种写法的好处是:范围查询可以利用索引,而且语义完全一样。遇到索引列被套上函数的场景,核心思路是"把函数从列上拿掉",改写成对列的原始值进行范围比较。
3.3 深分页查询的优化套路
分页查询在业务里跑不掉,但深分页是最折磨数据库的场景之一。LIMIT 200000, 20这种写法,MySQL会先扫描前20万行,然后丢弃,只返回最后20行。前面20万行的扫描代价是实实在在花掉的,越往后翻页,性能越差。
优化深分页有两种常用方案。第一种是记录上一页的最大ID,用主键位置代替OFFSET:
-- 上一页最后一条记录的ID是200019 SELECT * FROM orders WHERE id > 200019 ORDER BY id LIMIT 20;这种写法可以利用主键索引直接跳过前面所有行,性能非常稳定,适合业务上能接受"下一页基于上一页位置"的场景。
第二种是延迟关联,先通过子查询拿到目标ID集合,再回表取完整数据:
SELECT * FROM orders INNER JOIN ( SELECT id FROM orders ORDER BY id LIMIT 200000, 20 ) AS tmp ON orders.id = tmp.id;这个方案的核心思路是:子查询只需要扫描二级索引,不需要回表,等拿到20个ID后再回表取数据。回表次数从20万次降到了20次。
注意:延迟关联虽然好,但只适用于有明确排序和过滤条件的场景。如果业务上无法避免深分页,也可以考虑更换产品方案,比如"加载更多"代替页码翻页,这是最彻底的解法。
4. 第四种方式:多表关联的正确姿势
4.1 JOIN字段一定要有索引
多表关联的SQL,如果关联字段没有索引,性能会断崖式下跌。比如订单表关联用户表,订单表有几百万行,用户表有几十万行,如果users.id上没有主键索引(当然主键必然有索引),或者orders.user_id上没有索引,MySQL就要做嵌套循环,每一行都去另一张表做全表扫描,这个开销是灾难级的。
很多系统里,订单表的user_id字段是很自然地会建索引的,但有些关联字段很容易被漏掉。比如orders.coupon_id关联优惠券表,如果这个外键列没建索引,每次关联都会扫全表。经验法则是:所有JOIN的ON条件中,被驱动表的关联字段必须有索引。
4.2 小表驱动大表,IN和EXISTS怎么选
多表关联另一个核心概念是"小表驱动大表"。也就是说,外层表的数据量小,内层表的数据量大,这样内层循环的扫描次数就少。大多数情况下MySQL优化器会自动选择正确的驱动表,但遇到统计信息不准或者SQL太复杂时,它也可能选错。
关于IN和EXISTS的选择,有个简单实用的原则:外层结果集小、内层表大时用IN;外层结果集大、内层表小时用EXISTS。原因很直接:IN先执行子查询,把结果集缓存起来再和外层匹配;EXISTS是逐行判断外层记录是否满足条件,依赖内层子查询能否快速命中。MySQL高版本优化器已经很聪明,会做等价改写,但理解原理能帮你写出更稳的SQL。
4.3 关联查询的三个隐藏坑
第一个坑是关联字段类型不一致。orders.user_id是INT,关联的users.id也是INT,没问题。但如果一边是INT,一边是VARCHAR,隐式转换会让索引失效。
第二个坑是字符集不一致。两张表的字段分别是utf8和utf8mb4,关联时MySQL需要做字符集转换,转换后就无法使用索引了。这个问题在历史表和新表关联时特别常见。
第三个坑是驱动表选错。有时候优化器会因为统计信息过期而选择错误的驱动表,导致性能骤降。遇到这种情况可以用STRAIGHT_JOIN强制指定关联顺序,但要注意,这属于应急预案,应该先查明优化器为什么选错,而不是长期依赖强制指定。
5. 第五种方式:排序和分组的优化细节
5.1 ORDER BY如何避免Filesort
ORDER BY排序有两种实现方式:一种是利用索引天然的有序性,直接按索引顺序读取,不需要额外排序;另一种是通过filesort在内存或磁盘上排序。后者成本很高,数据量超过sort_buffer_size时还会使用磁盘临时文件,性能断崖式下降。
要让排序走索引,核心是把排序字段和过滤字段设计成同一个复合索引。举个例子:
SELECT * FROM orders WHERE status = 1 ORDER BY create_time DESC LIMIT 50;这种SQL,如果索引是(status, create_time),那么MySQL可以先用status过滤,然后直接按create_time的索引顺序读取,完全避免filesort。
但这里要提醒一句:不要让这条规则变成教条。有时候,如果过滤后需要排序的数据量本来就不大,filesort的成本并不高,而多加一个索引反而会增加写入负担。所以判断依据是EXPLAIN里的Extra列和真实执行耗时,不是绝对地说"看到filesort就一定要消除"。
5.2 深分页加排序的联合优化
当分页和排序组合在一起时,性能问题会被放大好几倍。ORDER BY create_time DESC LIMIT 200000, 20,MySQL需要先把所有符合条件的记录按create_time排序,然后丢弃前面20万条,这个排序的开销很大。
优化的思路是先在二级索引上完成排序和筛选,再回表取完整数据:
SELECT * FROM orders INNER JOIN ( SELECT id FROM orders ORDER BY create_time DESC LIMIT 200000, 20 ) AS tmp ON orders.id = tmp.id;子查询的排序最好能被索引覆盖,这样整个过程都不需要回表,等到最后拿20条完整记录时才回表,性能会好很多。
5.3 GROUP BY避免临时表的几个方法
GROUP BY常见的性能问题是Using temporary和Using filesort同时出现。分组操作需要把相同字段值的行聚到一起,如果字段没有索引,MySQL就要建临时表。优化方向有两个。
第一,给分组字段加索引。索引本身是有序的,相同值的行在物理上是连续存储的,这样分组就不需要建临时表了。第二,把HAVING中的条件尽量挪到WHERE里。HAVING是在分组之后再做过滤,如果过滤条件不涉及聚合函数,完全可以在WHERE阶段提前把数据过滤掉,减少分组的数据量。
还有一点是关于DISTINCT的。有些人在GROUP BY之后又加DISTINCT,这纯属多余,而且会额外增加一次去重操作。分组本身已经去重了,再加DISTINCT只会白白消耗性能。
最后分享一个我自己的习惯。每次优化完SQL,我都会把改前和改后的EXPLAIN结果、真实执行时间、索引变更记录下来。这样下次再遇到类似问题,直接翻记录对比,排查效率高很多。SQL优化不是一步到位的活儿,它是在"让数据库少干活"这个核心思路下,不断逼近最优解的过程。上面这5种方式,是我在实际项目中用下来最高频、最见效的优化方向,希望对你有帮助。