作为DBA和一线后端开发,我这些年排查过的慢SQL没有一千也有八百条。很多朋友一遇到查询慢就习惯性EXPLAIN看执行计划,但看半天只盯住type是不是ALL、key有没有用上索引,结果优化了半天,该慢还是慢。问题出在哪?EXPLAIN的输出远不止你有没有用索引这么简单,它背后是MySQL优化器基于统计信息做的一次成本决策。你看懂了它,就等于看懂了SQL的执行路线图;看不懂,就只能靠猜。这篇文章我就结合自己处理过的真实慢查询案例,把EXPLAIN里面真正决定性能的字段逐个拆开讲透,再带你走一遍从定位问题到优化落地、再到效果验证的全过程。适合被慢查询折磨的Go/PHP/Java后端朋友,也适合刚接手的初级DBA——照着文中的排查链路走,你也能独立处理大部分线上性能问题。
1. EXPLAIN输出里决定SQL生死的几个关键字段
很多教程讲EXPLAIN喜欢从id、select_type一路念到Extra,像读说明书一样。但实际排查性能问题的时候,你根本不需要每个字段都看一遍,优先级最高的就那么几个:type、key、rows、Extra。这四个字段能直接告诉你三件事:索引用没用上、用得好不好、扫描了多少行。先说结论,再逐个解释。
1.1 type:从system到ALL的访问级别
type是MySQL告诉你这次查询是怎么访问数据的。从最优到最差大致是:system > const > eq_ref > ref > range > index > ALL。system和const基本只在主键或唯一索引精确匹配时出现,比如WHERE id = 1,这种查询理论上就是最快的,因为最多匹配一行。eq_ref常在多表连接时出现,表示被驱动表通过主键或唯一索引关联,每次只查一行,这也是很健康的状态。ref表示使用了非唯一索引做等值匹配,可能会返回多行,但MySQL能直接通过索引B+树定位到起点,性能也不错。
range是一个分水岭。表示通过索引做了范围扫描,比如BETWEEN、>、<、IN、LIKE 'abc%'。range本身不算差,但它的扫描行数取决于范围大小,优化空间往往就在这里。再往下是index,意思是你用的这个索引MySQL决定遍历整棵索引树才能拿到结果,虽然也是走索引,但本质跟全表扫描差不了太多。最差的就是ALL,全表扫描。如果你看到type = ALL且table是单表查询,那几乎可以断定这条SQL需要优化。
有个容易误判的点:有时候你把ALL改成了index就以为优化成功了,实际不是。index代表MySQL遍历的是索引,但可能是整个索引,如果你查询的列选择性很差,index比ALL好不了多少,只是数据量更小所以扫描起来更快而已。
1.2 key、rows和Extra:执行计划里的细节信号
key表示MySQL实际选择使用的索引名。注意这里经常出现一个坑:你在WHERE条件里给列建了索引,EXPLAIN一看possible_keys里有这个索引,但key却是NULL。这说明优化器经过成本评估,觉得用这个索引还不如全表扫描快。常见原因包括:WHERE条件里对索引列做了函数运算或隐式类型转换、查询返回的行数占总表比例过高(一般超过20%到30%优化器就会放弃索引)、或者统计信息过期。遇到这种问题别急着加FORCE INDEX,先搞清楚优化器为什么放弃,后面我会详细讲。
rows是优化器预估的需要扫描的行数。它只是一个估算值,基于统计信息和索引基数(cardinality)算出来的,不一定等于实际扫描行数,但你可以把它当作一个相对参考。排查慢查询时,rows的变化方向最直观:优化完之后rows从几百万掉到几百,基本能预期SQL变快。但rows很小也不代表一定快,还要配合Extra看有没有隐形的排序和临时表。
Extra字段最值得关注的有几个值:
Using filesort:排序没有走索引,MySQL需要额外的排序操作。这里要区分,filesort并不代表真的在磁盘文件里排序,可能在内存也可能在磁盘,但这个开销比走索引排序大得多。Using temporary:用到了临时表,常见于GROUP BY、DISTINCT、ORDER BY某个非索引列组合。临时表一旦落到磁盘,性能会急剧下降。Using index:这是一个好信号,代表覆盖索引生效,查询所需的所有列都能从索引中直接获取,不用回表扫描数据页。Using where:表示MySQL在存储引擎层拿到数据后又做了过滤。注意它不等于没用索引,很可能你用了索引定位,但还有部分条件需要回表后逐行过滤。
我见过不少刚入门的朋友一看到Using filesort就慌,其实如果结果集只有几十行,filesort开销完全可以接受。关键要结合rows看,别为了一个几十行排序的查询去建一堆索引,得不偿失。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 隐式类型转换:最常见的索引失效元凶
讲完EXPLAIN的基础,我们直接进入一个真实场景。有一次线上业务反馈:某张订单表的查询从毫秒级变成了秒级,DBA抓出慢查询日志,发现是一条很常规的SQL:
sql复制SELECT * FROM trade_order
WHERE order_no = 202304150001234567
ORDER BY create_time DESC
LIMIT 10;
order_no上明明建了唯一索引,EXPLAIN却显示type = ALL,key = NULL,rows估算几十万。索引为什么突然失效了?我立刻看了一下建表语句,order_no是varchar(32)类型。问题基本清楚了——等值对比的右侧是一个数字字面量。
2.1 字符串列与数值比较时MySQL的转换规则
在MySQL中,如果被比较的两个值类型不一致,优化器会做隐式类型转换。具体规则是:字符串列和数值比较时,会把字符串转换成数值。这是MySQL官方文档明确写的转换规则,但很多人不知道的是,一旦对索引列做了隐式转换,这个列上就等于套了一层函数,索引自然就失效了。
你想想,索引B+树里存的'202304150001234567'这个字符串,和数字202304150001234567从存储上就不是一个东西。优化器为了保证比较结果正确,只能把索引列的值逐个转成数值再比较。既然无法直接通过索引定位,最终只能全表扫描。
还有个更隐蔽的变体:WHERE order_no = '202304150001234567'没问题,但如果写成WHERE order_no = 202304150001234567 + 0,同样会让索引失效。任何对索引列做运算的操作,无论加法、CONCAT、LEFT、DATE_FORMAT,都会破坏B+树的定位能力。这就是为什么SQL调优的第一条铁律:不要在索引列上做任何运算,包括隐式的类型转换。
2.2 一次事故级慢查询的完整排查链路
这次故障的排查过程其实挺典型,我按步骤复盘一遍,供你参考。第一步是确认慢查询现象:用户反馈接口超时,监控系统显示trade_order表的平均查询耗时从原来的20ms飙升到3s,某个数据库实例的CPU使用率持续在80%以上。第二步是抓取具体SQL:通过slow_query_log和pt-query-digest定位到这条order_no查询,发现它每小时被调用几万次,每次都在做几十万行的全表扫描,把数据库负载打满。
第三步就是EXPLAIN分析。我在测试环境复现问题,执行计划显示type = ALL,rows约为30万。我把order_no取出来做了个测试:
sql复制-- 这条走索引,查询正常
EXPLAIN SELECT * FROM trade_order
WHERE order_no = '202304150001234567';
-- 这条走全表扫描
EXPLAIN SELECT * FROM trade_order
WHERE order_no = 202304150001234567;
差异一目了然。加不加引号,两种执行计划天差地别。第四步就是修复:查清楚是哪条业务代码拼的SQL,把参数改成字符串类型传入。这个案例本身并不复杂,但它很好地解释了type = ALL背后可能不是没建索引,而是索引没被正确使用。你学会看EXPLAIN,遇到这类问题基本几分钟就能定位,不需要靠运气反复猜。
3. 深分页优化:从"查第100万条"说起的索引失效真相
第二个案例是我在很多团队里都见过的典型:列表接口越翻越慢。看下面这条SQL:
sql复制SELECT * FROM operation_log
WHERE user_id = 10086
ORDER BY id DESC
LIMIT 100000, 20;
这条语句的EXPLAIN结果通常是type = ref,key命中了idx_user_id,rows显示几百行。乍一看,索引用上了,rows也不大,怎么还会慢?问题出在LIMIT 100000, 20。MySQL执行到这条SQL时,真实流程是:通过user_id索引定位到满足条件的起点,然后从第一行开始,一路往后扫,扫完100000 + 20条,丢掉前面的10万条,只返回最后20条。你写的偏移量越大,它要扫的行数越多。
3.1 延迟关联:把随机回表变成索引扫描
针对这种深分页问题,常用手段是延迟关联(deferred join)。具体做法是先通过覆盖索引拿到目标行的主键ID,再用主键ID去关联原表取完整数据:
sql复制SELECT t.* FROM (
SELECT id FROM operation_log
WHERE user_id = 10086
ORDER BY id DESC
LIMIT 100000, 20
) tmp
JOIN operation_log t ON tmp.id = t.id;
这个写法的第一个子查询只需要扫描辅助索引idx_user_id和主键索引,无需回表。辅助索引的体积比全表小很多,同样的扫描量,开销大幅降低。第二步通过主键id关联原表,由于JOIN的驱动表只有20行,对operation_log表最多做20次主键查找,成本极低。我在实际优化中,这条SQL从原来的1.2s降到了20ms左右,效果非常明显。
但要注意,延迟关联也不是万能的。偏移量到几十万、上百万之后,即便只扫索引,依然要扫过前面所有行。更极端的场景需要考虑基于游标的分页,即不依赖OFFSET,而是把上一页的最后一条记录ID传下来:
sql复制SELECT * FROM operation_log
WHERE user_id = 10086 AND id < 100000
ORDER BY id DESC
LIMIT 20;
这种方式的EXPLAIN中type是range,key会命中idx_user_id或主键,扫描行数稳定在20行左右,完全不受页码影响。代价是API设计上不能直接跳页,只支持上一页/下一页,业务上能接受的话,这是最彻底的做法。
3.2 为什么"只查20条"却扫描了一万行
这个问题我想多说两句,因为很多人优化深分页时理解不深,改来改去还是慢。你表面看SQL写的是LIMIT 20,但MySQL执行时是先取OFFSET + LIMIT的行数,再丢弃前面的。OFFSET越大,丢弃的行越多,扫描和排序成本越高。所以判断分页查询快慢,不要看LIMIT的数字,要看OFFSET有多大。
类似的坑还有ORDER BY非索引字段配合LIMIT。比如ORDER BY create_time DESC, id DESC,如果create_time上没有索引,MySQL就必须先取出满足WHERE条件的所有行,在内存或磁盘完成排序,再做分页。这种场景下EXPLAIN的Extra会明确显示Using filesort。很多开发觉得"排序后的前20条只取20条应该很快",但数据库没法先给你20条再排序,它得先把所有匹配行都排完序,才有资格谈分页。解决这类问题,最好在业务上减少排序参与的数据量,或者建立联合索引让排序直接走索引。我在第5章会给出更详细的索引设计思路。
4. 覆盖索引:让查询连表数据都不用回
如果你用EXPLAIN排查过一条看起来哪儿都正常的SQL,type = ref,key也命中了索引,但响应时间仍然不理想,那问题大概率出在大量回表上。回表是什么意思?MySQL通过辅助索引只能找到主键值,要拿完整行数据,还得拿着主键再去主键索引B+树里查一次。多一次查找还是小事,更重要的是辅助索引的叶子节点只存主键和索引列,而主键索引的叶子节点存储整行数据,涉及随机I/O。数据量一上来,回表开销就会被放大。
4.1 一个统计类慢查询的优化全流程
有一回运营部门提了一个统计需求,要查每个用户最近一个月的订单总数。起初SQL长这样:
sql复制SELECT user_id, COUNT(*)
FROM order_detail
WHERE create_time >= '2024-01-01'
AND create_time < '2024-02-01'
GROUP BY user_id;
由于create_time上有索引,EXPLAIN看起来是range,rows几万行,但实际响应时间到了5秒多。为什么?因为查询既要返回user_id,又要COUNT(*),而user_id不在idx_create_time这个二级索引里。MySQL用idx_create_time定位完create_time范围之后,每一条都要回表去主键索引取user_id,再分组统计。一个月的数据量级在几十万行,几十万次回表,性能自然就崩了。
优化方案是建一个覆盖索引,把查询需要的列都放进去:
sql复制ALTER TABLE order_detail
ADD INDEX idx_create_time_user_id (create_time, user_id);
加了联合索引之后,user_id和create_time全都存在于索引叶子节点中。执行计划里Extra字段显示Using index,说明该查询可以直接从索引中获取所有数据,不需要回表。我把同样一条SQL跑了三次,平均耗时从5.2s降到了0.3s,性能提升非常明显。
这个案例其实暴露了一个通用规律:SELECT出来的列和WHERE条件列一起,构成了联合索引设计的基本输入。你不需要把所有字段都塞进索引,但你要尽量避免让执行计划在"走索引"和"回表取数"之间来回折腾。
4.2 什么时候覆盖索引会带来副作用
覆盖索引不是越宽越好。索引本质上也是B+树,它占用磁盘空间,影响写入性能。每多一个索引列,插入和更新时就要多维护一份索引数据。而且在MySQL 8.0之前,索引还有个最大长度限制(767字节,如果启用innodb_large_prefix可到3072字节),你把几个大字段都塞进索引,还没等到收益,先收到索引创建失败的错误。
我的建议是:覆盖索引优先覆盖高价值、高频查询,尤其报表统计、列表页这种读多写少的场景。写密集的表不要堆索引,靠EXPLAIN确认实际回表次数再决定要不要加。另外,MySQL从8.0.13开始支持INVISIBLE INDEX,你可以先把新索引设为不可见,观察一段时间,确认执行计划没问题再正式启用,这对线上环境非常友好。
5. 慢查询治理的日常动作与配套监控
优化单条SQL是战术层面的工作,真正让系统稳定不滑坡,靠的是把慢查询治理做成一个日常化流程。接下来我把自己团队沉淀下来的几件事分享给你,都是可以直接落地执行的。
5.1 打开慢查询日志并做好阈值设置
MySQL的慢查询日志是最基础的工具。线上建议开启slow_query_log,并设置long_query_time。很多团队把阈值设成1秒,结果日志一开,刷屏刷到没法看。我的经验是先从10秒开始,清理完一波明显的大慢查询之后,再逐步下调到5秒、2秒、1秒。也别追求阈值越小越好,否则DBA会被日志淹没,反而失去置信度。开启方式:
sql复制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默认为OFF,建议打开,它会把没有使用索引的SQL也记录到日志,这对发现隐式类型转换、索引失效问题非常有用。但注意,开启后日志量可能暴涨,你可以配合log_throttle_queries_not_using_indexes做限流,避免写爆磁盘。
除了日志,最好在监控系统里配上慢查询趋势图和TOP SQL排行。Percona Toolkit里的pt-query-digest可以从慢查询日志里自动汇总出执行频率最高、总耗时最长的SQL,并给出指纹和示例,我每次性能巡检都靠它快速圈定问题范围。
5.2 从"发现问题"到"验证效果"的完整闭环
拿到慢查询SQL之后,第一步永远是在测试环境复现,不要直接上生产改。复现之后用EXPLAIN抓执行计划,记录以下基线数据:type、key、rows、Extra,以及真实的响应时间。优化完成后再跑一次EXPLAIN,对比这几个值的变化。注意一个反直觉的现象:优化之后rows可能反而比原来大。比如原来type = ref回表数万次,修改成覆盖索引后type = range且rows变成几十万,但查询更快了。因为range扫描虽然预估行数多,但全程都在索引内部完成,CPU和I/O开销远小于原方案的回表开销。所以优化是否有效,最终要看真实响应时间和数据库侧的负载指标,而不是只看执行计划里的某个字段。
我常用的验证脚本是:
sql复制-- 优化前
SELECT BENCHMARK(10, (
SELECT COUNT(*) FROM order_detail
WHERE create_time >= '2024-01-01'
AND create_time < '2024-02-01'
GROUP BY user_id
));
-- 优化后
SELECT BENCHMARK(10, (
SELECT COUNT(*) FROM order_detail
WHERE create_time >= '2024-01-01'
AND create_time < '2024-02-01'
GROUP BY user_id
));
BENCHMARK函数直接返回执行时间,比手工掐秒表稳定得多。当然生产环境不建议执行BENCHMARK,会多消耗数据库资源。
5.3 日常巡检时最值得留意的三类慢查询特征
根据我的经验,日常巡检落网的基本就三类,你可以按特征快速分类,不必每条都从头分析。第一类是大量type = ALL并且rows很大的SQL,大概率是真正的全表扫描,检查条件列是否漏建索引,或者存在隐式类型转换。第二类是type不为ALL但Extra里有Using temporary的SQL,重点怀疑GROUP BY、DISTINCT和ORDER BY组合是否合理,能不能通过联合索引消除临时表和文件排序。第三类是高频率小慢查询,单条只花几百毫秒,但每分钟调用上千次,总量非常惊人。这类SQL执行计划看起来正常,性能瓶颈不在数据库本身,而在业务层——比如循环里查数据库、N+1查询、接口收到参数后无脑查一次大表。EXPLAIN看不出来这种问题,要靠业务日志和调用链追踪去发现。
我给团队定了一个简单的处理优先级:影响面优先于单条耗时。一条10秒的SQL和一条500ms但每分钟执行10000次的SQL,后者对系统的总消耗可能更大。先处理频率高的,再处理耗时长但低频的,性价比最高。这条规则同样适用于你接手一个遗留系统时的优化顺序。
6. 一次线上联合索引优化实战
前面分模块讲了EXPLAIN、深分页、覆盖索引、监控巡检,这一章我完整走一遍真实案例,把前面的东西串起来。这是某电商后台的一个订单列表查询,线上环境MySQL 8.0,订单量约800万。业务方反馈:按客户查历史订单的接口从秒开变成经常超时,页面直接报错。我看了一眼原始SQL,长这样:
sql复制SELECT order_id, order_no, total_amount, status, create_time
FROM trade_order
WHERE customer_id = 5201314
AND status IN ('PAID', 'SHIPPED', 'COMPLETED')
AND create_time >= '2023-09-01'
ORDER BY create_time DESC
LIMIT 20;
第一次EXPLAIN的结果是:
type:refkey:idx_customer_status(联合索引,包含customer_id和status)rows: 大约2万Extra:Using filesort
表面上索引命中了,但ORDER BY create_time DESC没走索引,出现了文件排序。为什么?因为idx_customer_status的索引顺序是customer_id, status,排序字段create_time不在索引里,MySQL只能把满足条件的2万行全部取出来,排序后再取前20行。这2万行中还包括了大量不满足create_time范围的数据。这是一个典型的"联合索引字段顺序没跟查询条件匹配"的案例。
6.1 联合索引的最左前缀原则在这次优化中的应用
我调整联合索引字段顺序:
sql复制ALTER TABLE trade_order
ADD INDEX idx_customer_time (customer_id, create_time),
DROP INDEX idx_customer_status;
这里要注意,我没有保留原来的idx_customer_status,因为新的idx_customer_time已经覆盖了customer_id这一列,原来的等值查询customer_id = ?和涉及status的过滤完全可以靠新索引过滤掉customer_id和create_time范围后再做。status条件我选择放到查询结果中通过IN条件过滤,它不参与索引定位。
调整后再次EXPLAIN:
type:rangekey:idx_customer_timerows: 约300Extra: 无Using filesort
为什么type从ref变成range?因为create_time >= '2023-09-01'是一个范围条件,MySQL对customer_id等值匹配后,再用create_time做范围扫描。status IN条件在存储引擎层过滤。更重要的是ORDER BY create_time DESC可以直接利用索引的有序性,不再需要filesort,Extra也干净了。线上验证下来,接口从2s降到了30ms,效果非常理想。
6.2 这个案例教会我的三条索引设计铁律
第一条:联合索引的顺序不是按你写的SQL顺序来的,而是按等值条件优先、范围条件其次、排序字段最后的原则排。customer_id是等值条件,应该放最前;create_time既是范围条件又是排序字段,紧随其后;status这种选择性不强的过滤条件,没必要为它单独占一个索引位。第二条:ORDER BY能走索引就尽量走索引,因为filesort在数据量大的时候是隐形性能杀手。判断排序是否走索引,直接看Extra有没有Using filesort。第三条:优化完一定要重新审视rows和Extra的变化,别只看type。有时候type降级了(从ref变range),但整体查询快了一个数量级,这说明优化方向是对的。你用EXPLAIN的目的,不是让每个字段都变得好看,而是让数据库的每一步操作都在做有意义的事。
说到这儿,我也顺便提一下status IN这个条件对idx_customer_time的索引使用没有影响,MySQL拿到customer_id = 5201314 AND create_time >= '2023-09-01'范围内的所有记录后,会在服务层用IN列表做过滤,因为status不在索引中,所以这部分过滤回表后会再执行一次。如果status的选择性特别高(比如只有几个值),建一个(customer_id, status, create_time)的联合索引可以进一步减少回表,但前提是查询的status值能直接命中索引。我之所以最终没加status到索引里,是因为这个字段的区分度太低,百分之六七十的订单都集中在两三个状态上,加了收益不大,反而增加索引维护成本。这就是我刚才强调的"索引不是越多越好",要结合数据分布去做判断。
7. 一些写在地图边缘的经验笔记
文章写到这里,把核心案例和手段都过了一遍。最后我想跳出具体SQL,聊几个日常排查中容易忽略的盲区,算是这几年经验的补充分享。
第一点:不要忽略索引统计信息的时效性。MySQL优化器决定用哪个索引,依赖的是SHOW INDEX FROM table里的Cardinality(基数)估算。当表数据发生大幅变化,比如批量导入、大量删除后,统计信息可能滞后,导致优化器用一个明显错误的执行计划。遇到"索引明明建了,EXPLAIN却不用"的诡异情况,可以先执行ANALYZE TABLE刷新统计信息再测试。这个操作成本很低,却常常能解决看起来毫无头绪的性能问题。
第二点:LIMIT查询要与ORDER BY联合看。同样是LIMIT 20,有索引排序和没索引排序的执行逻辑完全不同。判断标准还是Extra里的Using filesort。如果在高并发列表页发现rows不大但CPU偏高,大概率是filesort在作祟。你可以试着把排序字段调整到索引中,或者减少参与排序的结果集,而不是一味地给WHERE条件加索引。
第三点:EXPLAIN是"预计"不是"实际"。从MySQL 8.0开始提供了EXPLAIN ANALYZE,它会真正执行SQL并返回每个迭代器的实际行数和耗时,对于定位「优化器估计错误」的问题很有帮助。用法很简单:
sql复制EXPLAIN ANALYZE SELECT * FROM trade_order
WHERE customer_id = 5201314
ORDER BY create_time DESC
LIMIT 20;
输出的每一行都会带上actual time、actual rows和actual loops。我每次排查复杂SQL,会用EXPLAIN ANALYZE验证rows是否被优化器严重低估。比如某次发现预估3000行的查询实际扫了90万行,这说明统计信息或索引基数判断出了问题,只优化SQL写法是不够的,还得从数据统计层面入手。
不管是刚接触SQL调优的同事,还是已经写过几年SQL的资深后端,我希望这篇文章能帮你建立一条清晰的排查主线:先用慢查询日志找到目标SQL,再用EXPLAIN看执行计划,根据type、key、rows、Extra判断瓶颈,最后通过索引设计和SQL改写完成优化。整个过程没什么玄学,每一步都有据可查。你在实际开发中如果也遇到那些"加了索引还是很慢"的SQL,不妨回头再审一遍EXPLAIN里的这几个字段,大概率会有新发现。
