聊起SQL调优,很多一线研发团队其实早就形成了一套标准动作:先把慢查询日志打开,把那些跑了几秒甚至几十秒的SQL捞出来,再用EXPLAIN看执行计划,最后针对计划里的全表扫描、文件排序去补索引、改SQL。三步走完,一条慢SQL从定位到优化落地,往往不超过半小时。这中间最核心、也最值得花时间掌握的,就是执行计划分析——它能让你在几秒内判断一条SQL到底是缺索引、关联方式选错,还是查询逻辑本身就有问题。我见过不少业务同事刚开始接触调优时,习惯把慢SQL甩给DBA或者到处找人问,其实只要你学会看执行计划里的几列关键信息,很多问题的答案自己就能读出来,问人的时候也能问到点子上。
这篇文章会围绕执行计划分析展开,用一个非常典型的电商查询场景从头到尾走一遍完整调优流程:从开启慢日志、定位问题SQL,到EXPLAIN分析、设计索引,再到优化前后的执行计划对比。内容面向有一定SQL基础、但还没系统接触过执行计划的开发者,也适合那些已经会用EXPLAIN但停留在"看到type=ALL就知道要加索引"层面的朋友——你会在后面的避坑章节里看到不少执行计划不会直接告诉你、但线上实战里非常要命的细节。
1. 为什么慢SQL看了执行计划就能定位?
很多人第一次接触EXPLAIN,下意识会觉得这是某个高深工具。其实它的本质特别朴素:一条SQL交给MySQL之后,优化器要先决定"按什么顺序访问哪些表、用哪个索引、大概读多少行数据",这个决策过程的结果以一张表的形式返回给你,就是执行计划。换句话说,执行计划是MySQL对这条SQL怎么做的一个自白书,分析它,你看到的不是"结果对不对",而是"过程慢不慢、慢在哪"。
1.1 执行计划到底在表达什么
我把执行计划理解成一份"开车导航路线图"。SQL是目的地,优化器是导航引擎,执行计划就是它给出的路线方案。同一段路可能有不同走法:走高速(索引扫描)快但有限制条件,走国道(全表扫描)什么车都能走但慢,中途可能还要绕道(文件排序、临时表)。EXPLAIN把路线摊开给你看,哪一段是高速、哪一段是土路、哪一段在绕圈子,全都能在计划里找到对应标记。
所以分析执行计划的第一步不是背字段,而是建立一种直觉:这条SQL当前是"有索引可用且用上了",还是"明明有索引却没用",还是"压根没有可用索引只能全表扫"。这三种情况对应的优化动作完全不同,而判断依据就是type、key、rows、Extra这几列。把这个直觉训练出来,你会发现自己看慢SQL的速度快得惊人。
1.2 一线团队为什么都推崇这条调优链路
"大厂都在用"这个说法,核心指的并不是某个神秘商业工具,而是一套被反复验证过的协作方法:慢日志负责"发现问题",EXPLAIN负责"定位病灶",索引设计负责"解决问题",再用EXPLAIN验证"问题是否真的解决"。四个环节形成一个闭环,任何人都能按照这个流程独立完成调优,不需要碰运气。
我之前在一个项目里带过几个刚转岗过来的同事,他们没有专职DBA支持,靠的就是这套流程。第一次遇到线上接口超时,按流程打开慢日志,发现一条订单列表查询跑了3秒钟,EXPLAIN一看type=ALL、rows=二十多万,Extra里还有Using filesort和Using temporary。顺着执行计划的信息,给查询条件补了一个联合索引,2分钟改完,线上耗时从3秒降到200毫秒。整个过程没有用到任何商业工具,也没有"经验玄学",就是老老实实看执行计划、对症下药。这也是为什么我会把"看懂执行计划"列为SQL调优第一优先级技能——它是所有后续操作的基础。
2. 执行计划5个关键列:一眼看穿SQL真实成本
EXPLAIN返回的结果集一共有几十个字段,但日常调优真正高频使用、而且信息量最大的,基本集中在5列:type、key、rows、filtered、Extra。看懂这5列,就能覆盖绝大多数慢SQL的初始判断。
2.1 最该先看的type列:访问方式决定性能级别
type列描述的是MySQL如何查找数据行,从好到差大致是:system > const > eq_ref > ref > range > index > ALL。实际业务中你看到最多的是ref、range、index、ALL这四个。
- ref:使用非唯一索引或前缀索引查找,一次索引查找能命中多行,这是"索引用对了"的典型标志。
- range:索引范围扫描,比如WHERE里带BETWEEN、>、<、IN这类条件,用索引去圈定一个范围。
- index:全索引扫描,从索引头扫到尾,相当于把整棵索引树遍历一遍。这虽然用了索引,但成本往往不低。
- ALL:全表扫描,这是最需要警惕的级别。执行计划里出现ALL,意味着MySQL打算把整张表的数据一行一行读出来过滤,数据量一大,慢是必然的。
我自己看执行计划的习惯是:第一眼先扫type列,如果看到ALL,先别管别的列,这条SQL大概率就是"缺索引"或者"索引失效"的问题;如果看到index,需要注意它是不是在扫描一棵很大的辅助索引;如果看到ref或range,说明访问路径本身是健康的,问题可能要往排序、临时表、或者返回行数太多的方向排查。
2.2 索引利用度与成本估算:key、rows、filtered
key列告诉你"这条SQL最终选了哪个索引",没有走索引时这一列是NULL。它和type是配套的:type=ref但key=idx_name,说明这个索引确实被用上了;type=ALL且key为NULL,说明优化器找不到可用索引,或者找到了但觉得用了不如不用。
rows列表示优化器估算的需要扫描的行数,注意是估算值,不是实际扫描行数。它体现的是"这条SQL大概要摸多少行数据",数值越小通常越好。filtered表示经过WHERE条件过滤后,剩余行数占扫描行数的百分比。filtered=100说明过滤前后行数没变化,filtered=10说明扫描100行最终只留下10行。
我举一个真实观察:某条SQL的rows是20万,filtered却是5%,说明它扫描了20万行,但最终有用的只有1万行。这种情况往往是索引选择不合适,让MySQL在一个过大的范围内做过滤,优化方向是让rows降下来、filtered升上去。rows和filtered结合起来看,能判断"扫描的代价"和"筛选的效率"是否匹配。
注意:rows是CBO(基于成本的优化器)拍脑袋估出来的,它依赖统计信息,而统计信息不一定实时准确。千万不要把rows当成实际行数去写监控告警,它只适合做趋势判断和方案对比。
2.3 Extra列里的"隐藏信号"
Extra列是执行计划里信息最丰富、但也最容易被忽略的一列。它不会说"你错了",而是用几个固定短语暗示执行过程中额外做了什么。我最关注三个信号。
Using filesort表示MySQL需要额外做一次文件排序来满足ORDER BY。它不代表真的在磁盘上建了文件,而是说"没法直接利用索引顺序取数,得自己排一遍"。一条SQL如果出现filesort,且数据量大,排序就是主要瓶颈。优化方式通常是让排序字段进入索引,由索引天然保证顺序。
Using temporary表示执行过程中创建了临时表,常见于GROUP BY、DISTINCT、多表关联去重等场景。临时表可能落在内存也可能落到磁盘,落到磁盘就会明显变慢。出现这个信号时,我会重点检查是不是可以改写SQL减少去重和分组,或者让分组字段进入索引。
Using index是最好的一种信号,表示"覆盖索引"生效,查询需要的字段都在索引里,不需要回表取数据行。覆盖索引是SQL调优里性价比非常高的优化手段,后面我会专门展开讲一次实际案例。
3. 完整实操:从慢日志到索引优化的全流程
说再多不如跑一遍。这一节我用一个典型的商户订单查询场景,分别演示优化前和优化后的执行计划,让你直观看到一条SQL是怎么从全表扫描一步一步变成索引高效命中的。
3.1 场景建模与慢SQL复现
模拟项目X里有一张订单表,结构大致如下:
CREATE TABLE `order_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` bigint NOT NULL, `seller_id` bigint NOT NULL, `amount` decimal(12,2) NOT NULL, `order_status` tinyint NOT NULL, `create_time` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB;表中有一百多万条测试数据。业务反馈说某个"商家订单查询"接口在晚高峰超时,定位到一条慢SQL,需求是查某个店铺最近一周的订单列表,按下单时间倒序分页返回。
SELECT order_no, amount, order_status, create_time FROM order_info WHERE seller_id = 10086 AND create_time >= '2024-11-01 00:00:00' AND create_time < '2024-11-08 00:00:00' ORDER BY create_time DESC LIMIT 20;这条SQL表面看没有任何问题,条件清晰、字段干净。第一次EXPLAIN之前,我甚至怀疑是不是分页深翻页的问题,结果一分析,问题比想象的直接。
3.2 第一次EXPLAIN:看问题出在哪
在SQL前面加上EXPLAIN关键字直接执行:
EXPLAIN SELECT order_no, amount, order_status, create_time FROM order_info WHERE seller_id = 10086 AND create_time >= '2024-11-01 00:00:00' AND create_time < '2024-11-08 00:00:00' ORDER BY create_time DESC LIMIT 20;执行计划结果如下:
| id | select_type | table | type | key | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | order_info | ALL | NULL | 1200000 | 0.01 | Using where; Using filesort |
看到这张表,我的判断流程是这样的:type=ALL说明走了全表扫描,意味着这张表目前只有主键索引,WHERE条件里的seller_id和create_time都没有索引可用;key为NULL印证了这个判断;rows是120万,意味着MySQL计划把整张表扫一遍,虽然filtered=0.01看起来过滤效果好,但这是在"扫了120万行之后"才过滤出来的结果;Extra里还带着Using filesort,说明ORDER BY create_time还得额外排序。
全表扫描加上文件排序,两个慢的因素叠加,这条SQL在百万级数据量下跑出好几秒完全正常。优化方向也就明确了:给seller_id和create_time建一个联合索引,让过滤条件走索引,同时让排序也能利用索引顺序。
3.3 索引设计与前后对比:从全表扫描到ref/range的蜕变
这里有一个非常关键的判断点:联合索引的字段顺序怎么排。WHERE条件是seller_id等值,加create_time范围,ORDER BY是create_time倒序。按照最左前缀原则,我把等值条件字段seller_id放前面,把排序字段create_time放后面,建立索引:
ALTER TABLE order_info ADD INDEX idx_seller_create (seller_id, create_time);建完后重新执行EXPLAIN:
| id | select_type | table | type | key | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | order_info | range | idx_seller_create | 3200 | 100.00 | Using index condition |
type从ALL变成了range,说明这次不是全表扫描了,而是通过索引去圈定范围;rows从120万直接降到3200,扫描量缩小了将近400倍,这是性能提升最核心的来源;Extra里的Using filesort也消失了,因为ORDER BY create_time正好命中索引的第二个字段,MySQL可以顺着索引顺序直接取数,排序被索引天然解决。
这条SQL的真实耗时从优化前的2.8秒降到了220毫秒,虽然没有夸张到"数10倍"那么悬殊,但也已经是超过一个数量级的提升。如果数据量到千万级,这个差距还会进一步拉开。执行计划前后对比清楚地说明:一切的优化效果,都体现在type、rows、Extra这三个字段的变化上。
注意:索引不是越多越好。联合索引idx_seller_create虽然把查询和排序都覆盖了,但如果业务里还有大量按user_id单独查询的场景,就得额外评估是否单独建user_id索引。加索引前先看这个索引到底服务哪些高频SQL,避免出现"为一条SQL建一个索引"的过度设计。
4. 避坑实录:执行计划不会告诉你的5个细节
执行计划能帮你定位80%的问题,但剩下20%的坑,它不会直接告诉你。这些细节来自我在多个项目里的真实排障记录,每条都对应过线上事故或者反复调优的教训。
4.1 联合索引的最左前缀与顺序选择
很多初学者加索引的时候完全不思考字段顺序,今天看这个查询慢就建一个含这个字段的索引,明天看那个查询慢再建一个。结果就是一张表五六个索引,每个索引还有三四列,写入性能被拖累,查询也没快到哪里去。
最左前缀原则其实很好理解:联合索引(a, b, c),只能用来加速"a"、"a,b"、"a,b,c"这三种条件的查询,想从b或者c开条件,索引就用不上。所以设计联合索引时,第一要务是找到"出现频率最高的等值条件字段"放最前面,范围条件放后面。如果排序字段也想靠索引,最好放在等值条件之后、范围条件之前或者之后,具体要看排序方向和数据分布。
我之前遇到一个把范围字段放最前面的案例:索引是(create_time, seller_id),查询条件是seller_id=xxx AND create_time>=xxx,执行计划显示type=index,走了全索引扫描。原因就是create_time作为第一列只能命中范围,无法直接定位seller_id,MySQL只好把整棵索引树扫一遍再过滤。调整顺序重建成(seller_id, create_time)后,type立刻变成了range。
4.2 rows是估算值,别当实数用
执行计划里的rows是优化器根据统计信息估算出来的,不是真实的扫描行数。统计信息基于采样,采样过时了,rows就会失真。我见过一条SQL,执行计划里rows显示8000,实际却扫了80万行——原因是表数据发生了大量增删,统计信息没有及时更新。
真正要看准确行数,可以在分析前先执行ANALYZE TABLE刷新统计信息,或者在EXPLAIN后加FORMAT=TREE看更细节的信息。做方案对比时,看rows的变化趋势完全够用,但不要基于rows去做精确的容量评估。
4.3 索引失效的场景远比想象的多
有些SQL看起来有索引可用,执行计划里type却是ALL,这种情况基本可以归为"索引没被用上"。最常见的三种失效原因,我都踩过。
第一种是函数包裹。WHERE YEAR(create_time) = 2024,create_time列被函数包住,索引自然失效。解决办法是改写条件,让列独立在表达式一侧:create_time >= '2024-01-01' AND create_time < '2025-01-01'。
第二种是隐式类型转换。字段是varchar类型,SQL里却传了数字,或者字段是int,SQL里却传了字符串。MySQL会统一转类型再比较,导致索引失效,而且这种问题很难从肉眼发现。
第三种是LIKE查询以通配符开头。WHERE name LIKE '%张%',最前面的%让MySQL无法从索引树的某个锚点开始找,只能全部遍历。如果业务确实需要这种模糊匹配,通常只能走全文索引或者搜索引擎,靠普通B+树索引解决不了。
4.4 深分页的优化:LIMIT并不总是很贵
执行计划显示索引都用上了,type也在range级别,但SQL还是慢,这时候多半是分页太深了。LIMIT 100000, 20的意思是:先扫描前100020行,丢掉前100000行,只返回最后20行。扫描的行数不少,但执行计划的rows可能显示得很小,因为优化器是按理想情况下估算的。
深分页的常见优化方案有两种:第一种是延迟关联,先查出主键或唯一键,再回表拿完整数据。原理是利用"索引树上的主键定位"比"直接扫大范围数据行"快得多,把代价大的回表推迟到最后20行才做。第二种是记录上一页最后一条数据的某个有序字段值,下一页用WHERE create_time < 上页最小值来取,跳过已经翻过的数据,这个方案实际效果最好,但需要业务配合改造。
4.5 覆盖索引:让查询彻底不回表
Extra里的Using index,指的是查询所需的所有字段都包含在索引中,MySQL只需要扫描索引树就能返回结果,完全不需要回表读取聚簇索引里的数据行。这个优化空间在"大字段回表"场景下特别明显。
举个例子:查询只需要order_no和amount两个小字段,而创建的这个联合索引恰好包含这两个字段,那么执行计划里就会看到Using index。回表开销虽然单次不大,但百万行级别的扫描下,每一次回表都可能触发额外的随机I/O,累积起来就是秒级差距。所以在设计高频查询的索引时,我会尽量把SELECT里出现的字段(在不影响索引体积的前提下)追加到索引末尾。这就是常说的"覆盖索引"。
个人习惯:每完成一个联合索引设计,我都会检查一下这个索引能否覆盖线上Top 10高频查询的SELECT字段。能覆盖就尽量覆盖,这是成本最低、收益最稳定的提速手段。
5. 顺手整理一份执行计划排查速查表
如果你刚接触执行计划,记不住各种字段的优先级,可以把下面这张表收藏起来。它是我在实际项目中反复用到的一张判断清单,基本覆盖了慢SQL初始定位的常见路径。
| 执行计划证据 | 初步判断 | 首选优化动作 |
|---|---|---|
| type=ALL, key=NULL | 缺索引或索引失效 | 检查WHERE条件字段是否有合适索引 |
| type=range/index | 用上了索引但扫描范围偏大 | 收紧条件、调整联合索引字段顺序 |
| Extra=Using filesort | 排序无法利用索引 | 把ORDER BY字段加入索引 |
| Extra=Using temporary | 分组去重产生临时表 | 改写SQL、让分组字段进索引 |
| Extra=Using index | 覆盖索引生效 | 保持,考虑是否可推广到更多高频SQL |
| key列与实际预期不符 | 优化器选了另一个索引 | 检查统计信息,必要时用FORCE INDEX做对比 |
这张表不解决所有问题,但它能帮你把90%的慢SQL在几分钟内给出一个正确方向的判断。
这几次调优经验下来,我最深的体会是:SQL优化的核心并不在于会背多少种"奇技淫巧",而在于你能不能把一个看起来玄乎的性能问题,拆解成"扫描方式、扫描范围、额外操作"三个可以量化的点,然后针对那个最痛的点下手。执行计划正是把这些信息摆在你面前的工具,用熟了之后,你会发现自己看慢SQL的心态会从"慌"变成"稳"。最后再分享一个小建议:如果你所在的项目还完全没有建立SQL review机制,不妨从今天开始,每次上线前把最近一周的慢日志SQL批量跑一遍EXPLAIN,把新出现的全表扫描和filesort提前拦在发布之前。这个习惯坚持三个月,你会回来感谢它。