优化SQL跑得慢,DBA让你看执行计划,开发同学甩过来一条EXPLAIN结果让你解释——这大概是MySQL日常运维里最常见的场景之一。EXPLAIN这个词,说起来大家都不陌生,但真到排查问题时,能一眼从输出里看出门道的同学并不多。很多人卡在“会执行EXPLAIN但不会读结果”,更不用说基于执行计划反推索引设计。这篇内容我打算把EXPLAIN的核心输出字段、索引优化的判断逻辑、以及实际排障时的一些套路,从头到尾梳理一遍。内容偏实战,适合正在学MySQL调优的开发者,也适合被慢查询折磨的运维同学对照参考。
在MySQL里,EXPLAIN只是第一步,真正值钱的是你读到执行计划之后,能不能快速回答三个问题:这条SQL为什么慢?慢在哪一步?换成什么样的索引能让它快起来?搞清楚这三件事,索引优化就成功了一大半。接下来我按实际排查时的思路来展开,不按官方文档顺序讲,那样太枯燥,也不容易记住。
1. 先搞清楚EXPLAIN到底在做什么
1.1 一条慢SQL引发的排查链路
假设业务反馈某个列表页打开要三秒,后台一看慢查询日志,定位到一条涉及三张表关联的查询。这时候你会怎么查?我的习惯是先看表数据量,再用EXPLAIN跑一遍执行计划,最后根据执行计划决定是加索引、改SQL还是拆查询。EXPLAIN在这个链路里扮演的角色,就是把MySQL优化器“心里想的那条路”摊开给你看——它选择先读哪张表、用哪个索引、预估扫多少行、需不需要回表、要不要排序。
这一点特别关键:EXPLAIN输出的不是SQL真实执行结果,而是优化器基于成本模型推算出来的执行方案。既然是推算,就存在“优化器判断失误”的可能。比如统计信息过期、索引选择性判断偏差、或者SQL写法导致优化器压根没往索引上想,这些都会让执行计划不是最优的。所以读EXPLAIN的核心能力,不是背字段含义,而是能判断“这个计划合不合理”,以及“如果不对,怎么引导优化器走我们想要的路”。
1.2 EXPLAIN输出字段概览与使用姿势
先说说最基础的用法。MySQL 5.6以上版本建议直接看扩展信息,也就是在EXPLAIN后面加FORMAT=JSON,或者直接在EXPLAIN后执行SHOW WARNINGS看优化器改写后的SQL。这两种方式能看到比表格输出更细的成本估算,排查复杂问题时更有用。日常快速排查,传统表格输出就够用了。
EXPLAIN表格输出核心字段大致有这些:id、select_type、table、type、possible_keys、key、key_len、ref、rows、filtered、Extra。其中type描述访问类型,key描述实际选中的索引,rows是优化器估算的需要读取行数,Extra包含了很多附加信息比如是否文件排序、是否用到覆盖索引。我自己看执行计划有个固定顺序:先看type判断访问级别,再看key确认索引有没有用上,然后看rows和filtered估算扫描量,最后看Extra找有没有隐性问题。这套顺序在同一条SQL的多个执行计划对比时特别好用,能快速定位差异点。
另外提醒一句,EXPLAIN在MySQL 8.0里还支持EXPLAIN ANALYZE,这是真正执行SQL并返回实际耗时和行数的工具,比传统EXPLAIN更进一步。但EXPLAIN ANALYZE真的会跑SQL,只建议在测试库或者低峰期使用,生产环境谨慎。
2. type列是访问类型的“体检报告”
2.1 从system到ALL,性能逐级递减
type列是EXPLAIN结果里我最先看的一列,它直接告诉你MySQL是怎么在表里找数据的。打个比方:type是const就好比你直接翻字典按拼音找到了字,type是ALL就相当于从第一页翻到最后一页。性能好坏一眼就能判断。
从好到差,常见值大概这么排:system > const > eq_ref > ref > range > index > ALL。system和const属于极少数情况,基本是主键或唯一索引精确定位命中一行。eq_ref出现在多表join时,被驱动表通过主键或唯一索引关联,每行只匹配一条。ref是普通索引等值匹配,可能命中多行,这是很常见的健康状态。range就是索引范围扫描,比如between、in、大于小于这类条件。index听起来带“索引”两个字,很多人误以为很快,其实它是全索引扫描,相当于把整棵索引树从头到尾读一遍,比ALL好一点但依然是扫描全量。ALL就是全表扫描,这是优化的大敌。
看到一个SQL的type从ALL变成range或者ref,基本可以断定加索引起效了。比如之前排查过一个订单查询,WHERE条件里有user_id和status,没索引时type是ALL,加上联合索引后变成ref,查询时间从800ms降到了20ms。这种效果是实打实的。
2.2 站在优化器视角理解访问类型选择
你可能会问,为什么有时候明明有索引,优化器还是选ALL?这就是理解执行计划的进阶点——优化器不是“有索引就用”,而是综合评估代价。如果一条SQL要查表中大部分行,比如status字段区分度很低,90%的数据都是这个状态值,优化器会判断走索引还要大量回表,不如直接全表扫描便宜。这种情况下你强行加索引,执行计划可能纹丝不动。
所以在看type时,不要孤立地看一个字段,要结合rows估算。如果type是ALL但rows只有几百行,小表全扫描根本不算问题。反过来type是ref但rows估算几十万,那这个索引的选择性就很差,需要重新考虑索引设计。实践中有个经验:type和rows要一起看,单看type容易误判。
2.3 一个糟糕执行计划的真实案例
之前有朋友给我看一条他引以为傲的“优化后SQL”,说是加了索引后快多了。我跑了下EXPLAIN,type是ALL,rows显示12万。问他这表一共多少行,他说14万。也就是说这条SQL虽然用了索引,但实际查询还是全表扫了。为什么?因为WHERE条件里对索引列做了函数操作,比如DATE(create_time) = '2024-01-01',索引就失效了。他加索引时是加在create_time上的,但函数包裹让优化器没法用索引树做范围匹配,只能老老实实全表扫。
这种案例特别典型,也说明一个问题:加索引之前先看执行计划,加完索引再看执行计划,两次EXPLAIN对比才是验证索引有效性的唯一标准,不能靠感觉。
3. key列与索引选择的底层逻辑
3.1 possible_keys、key、key_len怎么组合读
possible_keys列出的是这条SQL可能用到的索引,key是优化器实际选中的那个。这里有个常见的坑:possible_keys里明明有索引,但key是NULL,说明优化器评估后认为索引帮不上忙。遇到这种情况优先考虑两个方向,一是SQL写法导致索引无法使用,二是统计信息不准确导致优化器误判。前者改SQL,后者执行ANALYZE TABLE更新统计信息。
key_len这个字段很多人忽略,其实它信息量很大。key_len表示MySQL在索引里使用的字节数,通过它可以反推联合索引到底用到了哪几列。比如一张表有联合索引(a, b, c),a是INT占4字节,b是VARCHAR(100)按utf8mb4算占400字节,c也是INT占4字节。如果key_len是4,说明只用到了a列;如果是408,就用到了a和b;412就是三列全用上。这个特性在排查“为什么联合索引没完全生效”时特别管用。
3.2 联合索引的最左前缀原则
联合索引是MySQL索引优化的重头戏,核心规则就是最左前缀原则:查询条件里必须包含联合索引的最左列,索引才能生效。这和字典的目录结构很像,先按首字母、再按第二个字母排,跳过首字母直接查第二个字母,目录就用不上。
举个例子,索引(a, b, c),WHERE a = 1 AND c = 3,这时候只能用a列,c列的条件索引帮不上忙;WHERE b = 2,直接跟索引无关;WHERE a = 1 AND b = 2 AND c = 3,这才是最理想的全值匹配。理解这个原则后,设计联合索引时要问自己一个问题:业务查询里哪个字段出现频率最高、区分度最好?通常把最常作为等值条件的字段放最左,把范围查询字段放后面,因为范围条件之后的索引列会失效。
这里有个小技巧:需要范围查询又想让它后面的列继续走索引,可以尝试把范围查询改写为等值IN列表。比如WHERE a = 1 AND b > 100改成WHERE a = 1 AND b IN (101, 102, 103),在某些版本和优化器版本下能多用到一列索引。但这招不一定每次都好使,需要结合EXPLAIN验证。
3.3 索引失效的典型场景自查
我自己维护过不少业务库,总结了一份索引失效自查清单,每次排查SQL都会对着过一遍:
- 对索引列使用了函数或运算,比如WHERE YEAR(create_time) = 2024、WHERE price + 10 = 100。
- 隐式类型转换导致索引失效,比如phone列是VARCHAR,查询用WHERE phone = 13800001111,数字类型会被转换成字符串,很可能索引用不上。
- LIKE模糊查询以通配符开头,WHERE name LIKE '%张',索引失效;WHERE name LIKE '张%'则可以走索引。
- OR条件中只要有一个字段没索引,整个条件可能都走不了索引。
- 联合索引不满足最左前缀。
- 优化器判断全表扫描比索引扫描成本更低时,自动放弃索引。
这份清单看起来简单,但每条背后都有真实的线上事故。最典型的是隐式类型转换,曾经排查过一个用户表查询突然慢十倍的问题,最终定位就是某个字段类型定义不规范,VARCHAR字段存了手机号,代码里却用数字类型传参。改查SQL为字符串传参后,type从ALL变回ref,问题秒解。
4. rows与Extra列里的额外信息价值
4.1 rows估算为什么不完全可信
rows字段是优化器估算的需要扫描的行数,不是精确值。它基于统计信息和采样估算,误差在所难免。所以判断执行计划好坏时,rows只作为参考量级。比如估算几千行但实际跑出来几十毫秒,完全正常。估算几十万行但实际数据只有几百行,那可能是统计信息太陈旧了,执行ANALYZE TABLE可以解决。
但rows在对比场景下价值很高。比如同一条SQL,修改前后各跑一次EXPLAIN,rows从10万降到100,这个变化比任何解释都有说服力。我自己做优化汇报时,就喜欢用这种对比数据,直观、可信、容易复盘。
4.2 Extra列里值得警惕的几类信息
Extra列是执行计划的“备注栏”,里面藏了很多细节。常见有价值的信息按风险排序:
Using filesort是文件排序,意味着MySQL需要额外的排序操作,可能存在性能隐患。值得强调的是,filesort不一定真的在磁盘上排序,数据量小时可能在内存里完成,但名字带file总归让人不放心。看到这个字段,优先排查ORDER BY的字段是否在索引里,联合索引能覆盖ORDER BY的话,排序可以直接走索引顺序,Extra就不会出现Using filesort。
Using temporary表示查询用到了临时表,常见于GROUP BY、DISTINCT、UNION这类操作。临时表可能落在磁盘上,性能开销很大。优化方向通常是重写SQL或者调整索引,让分组操作能走索引有序扫描。
Using index是好事,表示查询用到了覆盖索引,不需要回表。覆盖索引是优化利器,尤其对于统计类查询,能在索引里拿到全部需要的数据,速度极快。
Using where表示MySQL在存储引擎层拿到数据后又做了条件过滤,这种情况出现在无法完全通过索引下推完成过滤的场景。从MySQL 5.6开始有索引下推优化,部分WHERE条件会下推到存储引擎层提前过滤,减少回表,这里涉及ICP特性,后续细说。
4.3 三行Extra信息优化实战复盘
有一次排查报表导出慢的问题,原始SQL大致长这样:
SELECT product_id, SUM(amount) FROM orders WHERE create_time BETWEEN '2024-01-01' AND '2024-01-31' GROUP BY product_id ORDER BY product_id;EXPLAIN结果里出现了Using temporary和Using filesort,这在GROUP BY + ORDER BY组合下很常见。优化方式是改变索引设计,新建联合索引(create_time, product_id, amount),让WHERE过滤和GROUP BY排序都能走索引顺序。建完索引再看执行计划,Using temporary和Using filesort都消失了,rows也大幅下降,导出从原来的30多秒降到了3秒以内。
这个案例想说明的是:Extra里的每个词都对应一条可执行的优化路径,学会读Extra等于拿到了一张问题清单。
5. 索引优化的完整实操思路
5.1 从业务SQL反推索引设计
优化索引之前,先收集业务的高频SQL,这是最基本的一步。我有一次接手一个电商后台系统,把慢查询日志、业务代码里的SQL、DBA统计的高频查询全拉出来,光分析就花了两天。但这一步省不了,因为索引设计必须贴近真实查询模式,不能拍脑袋。
拿到SQL清单后,按这几步设计索引:
- 找出WHERE条件里的等值字段,这些字段适合放在联合索引左侧。
- 找出范围查询字段,放在等值字段后面。
- 找出ORDER BY、GROUP BY字段,尽量让它们也走索引顺序,避免filesort和临时表。
- 考虑SELECT的字段能否全部包含在索引里,能则实现覆盖索引,直接跳过回表。
这套思路做下来,80%的查询都能设计出合理索引。剩下的复杂查询,比如多表join、子查询、动态条件,就需要单独用EXPLAIN验证了。
5.2 一个从全表扫描到覆盖索引的完整优化案例
说一个典型的案例。业务有个订单列表页,筛选条件包括用户ID、订单状态、下单时间范围,同时要按下单时间倒序排列。原始表结构里只有主键id,查询全是全表扫描。线上数据量300万,接口超时严重。
第一步,根据等值条件设计联合索引(user_id, status, create_time)。这条索引让WHERE三个条件都能走索引,type从ALL变成range。但由于SELECT返回的字段还包括订单金额、收货地址等,不在索引里,每次命中都要回表,整体响应在几百毫秒到一秒之间波动。
第二步优化做覆盖索引。创建一个更宽的联合索引(user_id, status, create_time, amount),核心查询字段都能从索引拿到,Extra出现Using index,回表彻底消失。同样的查询从800ms降到50ms以内。
这个案例想强调两点:一是索引设计不是一步到位,先解决访问类型再从回表角度二次优化;二是覆盖索引不是越宽越好,要权衡写入性能和存储空间,尤其对于更新频繁的表,索引列太多会拖累写操作。
5.3 索引维护的日常操作清单
索引设计完了,后面还有很长的维护路。分享几个日常运维动作:
- 定期用SHOW INDEX FROM table查看索引分布,检查是否有冗余索引。联合索引(a, b)和单独索引(a)同时存在时,后者就是冗余的,建议删除。
- 关注索引使用率,可以通过performance_schema里的统计信息分析哪些索引从来没被用过,长期不用的索引果断清理。
- 大表加索引要选低峰期,用在线DDL工具或者MySQL 8.0的原生在线DDL能力,减少锁表影响。
- 表数据频繁增删改时,定期执行ANALYZE TABLE更新统计信息,避免优化器基于过期的统计信息做出错误判断。
6. 常见问题与排查技巧实录
6.1 排查问题SQL的标准化步骤
遇到过太多乱糟糟的排查场景,很多人一上来就各种猜,改来改去没有章法。我自己沉淀了一套标准步骤,分享出来:
- 拿到慢SQL后先看表结构和索引现状,SHOW CREATE TABLE。
- 执行EXPLAIN看执行计划,重点关注type、key、rows、Extra四列。
- 用FORMAT=JSON看更精确的cost估算,定位最耗时的操作。
- 如果怀疑统计信息有问题,执行ANALYZE TABLE刷新后再看执行计划。
- 修改SQL或者索引后,重跑EXPLAIN对比,观察type和rows的变化。
- 在生产环境小流量验证真实性能提升。
这套步骤看着简单,但能避免90%的“瞎调优”。我见过太多人上来就直接加索引,加完发现没效果又删掉,来回折腾,就是少了第2步和第5步的对比验证。
6.2 高频问题速查表
根据多年经验,整理下面这份速查表,基本覆盖了日常最容易踩的坑:
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| type=ALL全表扫描 | 无索引或索引失效 | 查看key列是否为空 | 根据WHERE条件创建合适索引 |
| key_len远小于预期 | 联合索引未完全生效 | 对照索引列顺序反推key_len | 调整条件顺序满足最左前缀 |
| Extra出现Using filesort | ORDER BY未走索引 | 检查排序字段是否在索引内 | 调整联合索引或改写SQL |
| Extra出现Using temporary | 分组/去重操作走临时表 | 检查GROUP BY、DISTINCT字段 | 通过索引消除临时表 |
| 有索引但不走 | 统计信息过期或区分度低 | 查看rows估算 | ANALYZE TABLE或SQL改写 |
| 索引列用了函数 | 索引失效 | 查看WHERE条件写法 | 函数改写为范围条件 |
| 隐式类型转换 | 字段类型与参数不匹配 | 比较字段定义和传参类型 | 统一类型,避免转换 |
| 数据量小全表扫描 | 优化器认为ABC成本更低 | 看rows是否很小 | 数据量增长后再验证 |
这张表我贴在公司内部技术文档里,团队新同学排查慢SQL时直接对照,效率提升很明显。
6.3 索引优化中容易被忽略的细节
几个细节问题,虽然不起眼但实际影响很大:
字符集和排序规则必须统一。多表关联时的JOIN字段如果字符集不同,MySQL可能无法使用索引,导致关联查询变慢。排查时留意SHOW CREATE TABLE里各表的CHARSET是否一致。
字段类型设计要克制。能用INT就别用VARCHAR存数字,能用短VARCHAR就别预留太长。索引列越短,每页能存放的索引条目越多,查询性能越好。
NULL值对索引有影响,但不是网上传的“索引完全失效”。MySQL的索引本身不拒绝NULL值,只是IS NULL和IS NOT NULL的优化方式有区别。设计表结构时尽量给字段设置NOT NULL DEFAULT,能让索引更紧凑,避免后续踩坑。
6.4 大表深分页优化案例
最后一个实战案例。有张日志表,业务上经常要做“上一页/下一页”翻页,越往后越慢。排查时发现LIMIT 100000, 20配合ORDER BY create_time,MySQL要扫描前10万行然后丢弃,代价很大。
传统优化方式是延迟关联,也就是先只查出主键ID,再用主键关联回原表获取完整数据。SQL大致长这样:
SELECT t.* FROM log_table t INNER JOIN ( SELECT id FROM log_table ORDER BY create_time DESC LIMIT 100000, 20 ) tmp ON t.id = tmp.id;这个方案能让内层查询只扫描覆盖索引,大幅减少回表次数。更进一步的方案是记录上一页最后一条数据的主键或时间戳,用WHERE条件定位而不是LIMIT偏移量,这叫游标分页,性能最好但会改变接口语义,需要业务配合。
6.5 一条SQL改写解决索引失效的实例
之前遇到一个案例,原始SQL用了OR连接多个条件,导致索引全失效:
SELECT * FROM users WHERE phone = '13800001111' OR email = 'test@example.com';phone和email字段各有单独索引,但OR条件下优化器没法同时用两个索引,只能全表扫描。改写方式是拆成两个查询用UNION连接:
SELECT * FROM users WHERE phone = '13800001111' UNION SELECT * FROM users WHERE email = 'test@example.com';改写后两个分支各自走索引,EXPLAIN里type都是ref,查询耗时从百毫秒级降到个位数毫秒。这个案例很适合用来理解优化器的局限性——有时候不是MySQL不行,是SQL写法没给它发挥空间。
7. 从EXPLAIN出发建立索引优化的全局思维
搞了这么久的MySQL性能优化,我最大的感受是:EXPLAIN不是终点,而是一个起点。它就像医生手里的X光片,能看到骨骼结构,但真正治病的还是后续的诊断和治疗方案。索引优化也一样,EXPLAIN帮你看到扫描方式、扫描行数、回表情况,但你要做的决策——加什么索引、怎么写SQL、怎么改表结构——都建立在对业务查询模式的深刻理解之上。
所以给刚接触这块的同行一个建议:别急着背命令、背参数,先培养一种“漫游执行计划”的习惯。拿到任何一条SQL,脑子里自动过一遍:它会怎么扫表?会不会回表?排序能不能走索引?有没有隐式的类型转换?这种直觉一旦建立,再看EXPLAIN的输出,每条信息都会变得立体起来。
我自己在实际操作里的体会是,索引优化不是一招鲜的事。同一个方案在今天可能是最优的,等数据量翻十倍、业务查询模式变了,可能又需要重新设计。保持对执行计划的敏感,定期回顾慢查询日志,顺手用EXPLAIN检查一下有没有新的问题SQL冒头,这套动作做下来,数据库才不会成为业务发展的瓶颈。