尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SQL执行顺序决定MySQL查询性能:慢SQL排查与索引优化实战

SQL执行顺序决定MySQL查询性能:慢SQL排查与索引优化实战 上个月帮朋友排查一条慢SQL他特别委屈明明字段都加了索引条件也不复杂为什么查询还是慢等他把完整语句贴出来我一看问题根本不在索引而是他把一个ORDER BY放在了子查询外面MySQL先得从几百万行里筛出一大堆中间结果再全部排一遍序。这种问题我见得太多了十次有八次都出在对SQL执行顺序的理解上。今天就把这条SQL背后的秘密彻底讲清楚从你手写的SQL语法顺序到MySQL内部真正的执行顺序再到执行顺序如何决定索引、锁和慢SQL的走向。无论你是刚学SQL的新手还是写了好几年业务代码但没系统梳理过的老开发这篇文章都能帮你省下大把排查时间。1. 执行顺序的真相SELECT语句并不是最先执行的1.1 语法顺序和执行顺序不是一回事几乎所有SQL入门教材都会告诉你一条SQL长这样SELECT column_a, COUNT(*) AS cnt FROM table_name WHERE column_b 100 GROUP BY column_a HAVING cnt 1 ORDER BY cnt DESC LIMIT 10;你看到的第一眼反应肯定是先SELECT列再FROM表然后WHERE过滤接着GROUP BY分组HAVING过滤分组最后排序、限量。这完全符合人的阅读理解习惯所以大家默认MySQL也是这么干的。但实际上MySQL收到这条SQL之后真正的执行顺序是颠倒过来的或者说至少和语法顺序完全不同。清掉你脑子里的“从上往下”的惯性改成这样看1. FROM table_name 2. ON / JOIN 3. WHERE 4. GROUP BY 5. HAVING 6. SELECT 7. DISTINCT 8. ORDER BY 9. LIMIT拿上面那条SQL举例MySQL先打开table_name这张表圈定操作范围然后才做WHERE过滤把column_b大于100的行挑出来接着按column_a分组分组之后用HAVING过滤掉cnt不大于1的分组等到这个时候才开始计算SELECT里的列包括别名cnt最后才排序、限量。这个逻辑顺序是SQL标准在几十年前就定下来的MySQL、PostgreSQL、Oracle大体都遵循只是物理执行时优化器会做一些调整。这里你要理解一个核心概念语法顺序是给人看的执行顺序才是给MySQL用的。别名去哪了GROUP BY为什么能用WHERE为什么不能写聚合函数全部都能从这个顺序中找到答案。1.2 为什么说这个顺序决定了你的SQL是快还是慢很多人觉得执行顺序就是个面试题背下来就完了。实际上它直接影响你对“慢SQL”的判断力。我举一个最简单的例子WHERE在GROUP BY之前执行说明WHERE过滤掉的每一行都不会进入后续的分组和聚合运算。反过来说如果你把应该放在WHERE里的条件误写进HAVINGMySQL就得先把所有原始行做完全部分组和聚合计算再最后过滤掉不要的分组。数据量小的时候看不出问题一旦上千万行性能差距就是几十倍。同样SELECT在ORDER BY之前执行说明ORDER BY里可以使用SELECT子句定义的别名而WHERE在SELECT之前执行所以WHERE里绝对不能直接用别名。这个时差就是很多“为什么报错”和“为什么没走索引”问题的根源。我后面会在第3章用一个实际案例把整个过程跑一遍现在先把这个执行顺序刻在脑子里。2. 每个阶段在做什么解码执行顺序中的关键细节2.1 FROM和JOIN先把数据“圈”出来从FROM开始MySQL要做的第一件事是确定“我要处理哪些表”。如果是单表查询这一步就是把表加载到执行上下文中。如果是多表JOIN情况会复杂一点。逻辑顺序是这样的先做FROM t1然后执行JOIN t2 ON条件再执行LEFT JOIN t3 ON条件等等。但你要注意逻辑上的顺序和优化器实际选择的连接顺序可能完全不一样。MySQL优化器会根据表的行数、索引情况、关联字段的基数重新决定谁先谁后目的就是用小结果集去驱动大结果集减少中间过程的处理量。这个叫“驱动表”的选择。这里有一个关键细节ON条件是在JOIN过程中逐行匹配的WHERE条件是在JOIN全部完成之后才过滤的。对于INNER JOIN优化器可以把ON和WHERE合并影响不大。但对于LEFT JOINON里的条件决定右表是否补齐NULL行而WHERE里的条件如果作用于右表会在连接后过滤掉那些NULL行等于把LEFT JOIN变成了INNER JOIN。这个差异不看执行顺序很容易写出“结果不对”的SQL。再说子查询。MySQL 5.6之后有了子查询优化但很多情况下FROM子句里的子查询派生表还是会把结果物化成临时表MySQL 8.0会使用派生表合并优化不一定真实物化。但逻辑上子查询一定是在外层查询的WHERE和SELECT之前先执行完的因为FROM阶段要先把数据源准备好。理解这一点你就知道为什么“不要在FROM子查询里做太多多余操作”了它是你整个查询的基础数据源。2.2 WHERE到HAVING两次过滤完全不同的身份WHERE的执行位置在GROUP BY之前所以它只能对原始行做过滤。MySQL在这个阶段会尽可能利用索引把大量数据提前筛掉这也是执行计划里访问方法type列成为重点的原因。比如你写WHERE status 1 AND create_time 2024-01-01如果status和create_time上有合适的复合索引MySQL可以直接通过索引定位到满足条件的记录然后再根据SELECT的列决定是否回表。GROUP BY执行的是分组操作。它的作用是把前面WHERE过滤出来的结果集按指定列拆成若干组。在这个阶段MySQL会对分组字段排序或者建立哈希结构所以如果分组字段有索引能省掉一个大麻烦临时表和文件排序。分组之后是HAVING。HAVING是针对“分组”做过滤而不是针对原始行。也就是说每一组数据经过聚合函数计算之后HAVING才判断这组是否应该保留。这就是为什么聚合函数只能出现在HAVING和SELECT里不能出现在WHERE里WHERE执行的时候还没有“组”的概念。很多新手问我“那是不是GROUP BY之前能用WHERE过滤的就不要放在HAVING里”是但要分场景。比如查“每个用户订单金额大于100的用户”如果你写成HAVING SUM(amount) 100这是没法改的因为SUM是在分组后算出来的。但如果你有额外条件比如“只看2024年订单”那就应该把时间条件放在WHERE里先过滤掉历史数据再做分组和聚合这样临时表小得多速度也快得多。用执行顺序来解释就是WHERE过滤掉的行越多GROUP BY和HAVING面对的中间结果就越小聚合计算量就越少。2.3 SELECT阶段列的计算、别名和DISTINCT进入SELECT阶段MySQL才开始处理你要“投影”的列。这里最容易被忽略的是SELECT里的表达式比如函数计算、CASE WHEN、字符串拼接都是在这个阶段才逐个计算的。所以在SELECT里对索引列套函数例如SELECT DATE_FORMAT(create_time, %Y-%m-%d)往往会导致该列无法继续使用索引因为索引里存的是原始值不是处理后的值。别名在这里也很微妙。由于SQL的执行顺序是先FROM、WHERE、GROUP BY最后才是SELECT所以你无法在WHERE里直接用别名。看这个例子SELECT order_id, amount * 0.9 AS discounted_amount FROM orders WHERE discounted_amount 100;MySQL执行到WHERE时discounted_amount这个别名根本还不存在所以会直接报错。正确写法是SELECT order_id, amount * 0.9 AS discounted_amount FROM orders WHERE amount * 0.9 100;但ORDER BY又不同因为ORDER BY排在SELECT后面所以ORDER BY可以直接用别名。大多数数据库都支持ORDER BY discounted_amount。GROUP BY在MySQL里也能用别名但那是MySQL扩展行为标准SQL并不保证我不建议在主从分离或者跨数据库迁移的场景里依赖它。HAVING使用别名一样要谨慎MySQL默认允许但某些sql_mode下会变严格最好还是写完整的聚合表达式或者原始列。DISTINCT的执行位置在SELECT之后。你可能会问DISTINCT和GROUP BY去重有什么区别从执行顺序看DISTINCT是在所有列都计算完之后对结果集做去重GROUP BY是在SELECT之前先分组然后每个组只输出一行。两者最终结果可能一致但实现的路径完全不同。分组通常伴随聚合函数DISTINCT只是去重。如果只想去重不要用GROUP BY因为GROUP BY在MySQL里往往需要临时表和文件排序性能更差。当然如果分组字段正好有索引GROUP BY也会被优化但那是另一回事。2.4 ORDER BY和LIMIT排序和限量为什么总在最后ORDER BY排在倒数第二步意味着它默认要对前面所有阶段得到的完整结果集排序。就算你最后LIMIT 10MySQL在逻辑顺序里也是先排好序再取前10行。所以如果查询涉及几十万行排序字段又没有索引就会出现Using filesort这就是慢SQL的最大来源之一。为什么说“逻辑上”因为MySQL优化器会对ORDER BY LIMIT做特殊优化如果排序字段和WHERE条件能配合索引它可以按照索引顺序从头扫描找到LIMIT数量的行就立刻停止不再排全部数据。这就是为什么ORDER BY create_time LIMIT 10加上WHERE user_id ?之后复合索引(user_id, create_time)可以避免排序。索引本身就是排好的MySQL按索引顺序扫过去就已经有序了。但这里有个大坑如果ORDER BY的字段和GROUP BY不同或者ORDER BY引用了非分组字段MySQL通常需要把分组结果先写入临时表再对临时表排序。执行计划里出现Using temporary; Using filesort往往就是这一系列顺序没有利用好导致的。LIMIT是最后一步它的作用最直观切掉结果集的前N条。但要注意LIMIT配合不合适的ORDER BY会带来严重的一致性错觉。比如ORDER BY amount DESC LIMIT 10amount相同的行排序不稳定每次查询结果可能不同。这个不稳定不是MySQL的问题而是你没意识到在没有唯一性排序键的情况下排序结果是不确定性的。2.5 MySQL 8.0窗口函数插在哪一步MySQL 8.0引入了窗口函数比如ROW_NUMBER()、RANK()、SUM() OVER(PARTITION BY ...)。窗口函数很强大但很多人在写窗口函数时仍然拿不准它到底在执行顺序的哪个位置。窗口函数的逻辑执行位置在HAVING之后在ORDER BY之前更准确地说是在SELECT阶段对结果集做计算但它的计算不改变行数只是给每一行附加一个值。这个位置解释了为什么窗口函数里可以使用WHERE过滤后的数据但不能再使用WHERE也解释了为什么窗口函数的结果不能直接用于WHERE因为WHERE执行时还没有生成窗口值。同时窗口函数的PARTITION BY和ORDER BY是窗口内部的排序和SQL最外层的ORDER BY无关。举个例子SELECT user_id, amount, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY amount DESC) AS rn FROM orders WHERE amount 0 ORDER BY user_id;这里窗口函数先按user_id分组再按amount排序生成序号最后外层ORDER BY再按user_id排。如果你在窗口函数里已经把数据按金额排好了想直接在外层ORDER BY里使用rn没问题因为rn在SELECT阶段生成了ORDER BY阶段可以引用。但如果想在WHERE里过滤rn1的行就会报错只能再套一层子查询。理解了执行顺序这种“为什么报错”的问题根本不需要死记硬背。3. 执行顺序如何影响索引、锁和慢SQL3.1 索引真正发挥作用的地方只有那么几个节点从执行顺序可以看出索引能起作用的节点主要集中在前半段FROM的定位表、JOIN的关联匹配、WHERE的过滤、GROUP BY的分组、ORDER BY的排序。一旦查询进入到SELECT阶段索引就很难再帮上忙了。所以判断一条SQL能否走索引要看WHERE条件里的列、GROUP BY的列、ORDER BY的列而不是SELECT里的列。最典型的一个原则是“最左前缀法则”联合索引(a, b, c)在WHERE里同时使用a和b时索引能发挥作用跳过b直接用c就无法用前缀匹配。这个原则本质上是索引叶子节点的排序规则决定的但理解执行顺序后你会发现如果WHERE里用了aGROUP BY里用了b那么联合索引(a, b)不仅帮上WHERE过滤还能帮GROUP BY避免临时分组。这是一种更高级的索引设计思路让WHERE、GROUP BY、ORDER BY用同一个索引的连续列一路顺到底。反过来如果你写了一个WHERE YEAR(create_time) 2024因为执行顺序里WHERE过滤在前MySQL需要对每一行调用YEAR函数索引失效索引列丧失原有的有序性。这就是我们常说的“不要把索引列包在函数里”。我曾经遇到一个报表查询只是把YEAR(create_time)改成了create_time 2024-01-01 AND create_time 2025-01-01耗时从4秒降到50毫秒堪称最实在的一次优化。3.2 一条慢SQL的排查过程复盘我用一个真实例子把执行顺序和执行计划串联起来。假设有一张用户订单表orders包含user_id、order_id、amount、create_time数据量约500万。业务需求是查“每个用户金额最高的前3笔订单”。很多人第一反应是写子查询SELECT * FROM orders o WHERE ( SELECT COUNT(*) FROM orders o2 WHERE o2.user_id o.user_id AND o2.amount o.amount ) 3;这条SQL的逻辑顺序是先扫描外层orders表的每一行然后对每一行执行相关子查询子查询又要去扫描内层orders表。在500万行数据下这个嵌套循环几乎是灾难级的跑了半天出不来。用执行顺序的思路重新设计先用窗口函数ROW_NUMBER()按user_id分组、按amount排序生成每组内的序号。然后再把序号小于等于3的行过滤出来。SQL是SELECT user_id, order_id, amount, create_time FROM ( SELECT user_id, order_id, amount, create_time, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY amount DESC) AS rn FROM orders ) t WHERE t.rn 3;这里为什么快窗口函数在HAVING之后、SELECT阶段一次性计算所有窗口值只需要扫描一次表然后在外层WHERE过滤rn。如果(user_id)上有索引内层PARTITION BY还能利用索引排序连临时表都省了。我加了索引后这条SQL大概200毫秒跑完。这就是把执行顺序转化成优化思维之后的收益。排查慢SQL时的查看技巧也很固定用EXPLAIN分析重点看type、key、rows、Extra四列。type如果是ALL说明全表扫描Extra出现Using filesort说明排序没用上索引出现Using temporary说明分组或去重需要临时表。这些信息可以直接反推执行顺序在哪一步出了问题。3.3 更新语句的加锁顺序也与“先定位再锁定”有关别以为只有SELECT讲执行顺序UPDATE和DELETE同样要经历定位阶段。MySQL执行一条UPDATE时会先通过WHERE条件定位到目标行再对这些行加锁并更新。如果WHERE条件能用索引InnoDB只需要锁住索引扫到的几行如果WHERE条件没法走索引它就会全表扫描把扫描过程中碰到的所有行都加上锁这就是“锁范围扩大”的原因。相关热词里总有人问“MySQL锁的分类”和“事务处理”这里一并说清InnoDB的锁分为行锁、间隙锁、临键锁和表锁。行锁可以理解为只锁住索引记录间隙锁锁的是两个索引记录之间的区间临键锁是两者的组合。在可重复读隔离级别下执行UPDATE ... WHERE id BETWEEN 100 AND 200MySQL为了阻止幻读不仅会锁住这101行还可能会锁住这个区间对应的间隙防止其他事务插入新记录。这个行为和执行顺序的关系在于加锁发生在“定位阶段”也就是WHERE条件匹配的那一瞬间而不是事务提交时。所以一个事务执行UPDATE之后未提交另一个事务想要更新同一行就会被阻塞因为锁在第一条语句执行时已经拿住了。理解了“先定位、后加锁、再更新”的顺序再去理解死锁问题就容易多了两个事务各自经过WHERE定位拿到了不同的锁然后继续要对方的锁互相等待形成死锁。常见的解决思路包括保持一致的加锁顺序、缩小WHERE范围、让关键条件都走索引。3.4 小表驱动大表优化器是怎么重新编排JOIN的JOIN的执行顺序一直在优化器手里。你写了FROM A INNER JOIN BMySQL不一定先读A再读B。它会根据统计信息估算两种连接方式的花费选择“小表驱动大表”的方案。这里的小和大不是看表的总行数而是看WHERE过滤之后进入连接的数据量。举个例子A表500万行B表5000行。如果查询条件是B表上过滤后剩下100行那么用100行去驱动A表在A表上建立索引查找每次只匹配少量记录反过来用A表驱动B表就要循环500万次。所以优化器往往会选择B做驱动表。你可以在EXPLAIN里看第一行那就是驱动表。但优化器也有“犯错”的时候比如统计信息过期、没有索引、或者使用了函数导致无法估算它可能选错驱动表。这时候你可以在JOIN后面用STRAIGHT_JOIN强制指定连接顺序但这是最后的手段因为强制顺序会固定执行计划一旦数据分布变化反而可能更差。更稳妥的做法是保持两张表的连接字段都有索引并且优化WHERE条件让统计信息更准确。理解了JOIN顺序再回头看执行顺序那个表格你会发现FROM和JOIN其实是最核心的起点。很多查询慢不是死在后半段的排序和分组而是死在起点就把数据圈太大了。所以写SQL的时候先把过滤条件想清楚再想关联最后才是输出列和排序。4. 常见问题与面试实战速查4.1 别名、去重、排序的经典误区结合执行顺序下面几个问题是我在面试里最爱问的也是日常开发中反复出现的坑问题结论原因WHERE里能不能用SELECT别名不能WHERE在SELECT之前执行GROUP BY里能不能用SELECT别名MySQL默认可以但不可移植GROUP BY在SELECT前依赖MySQL扩展HAVING里能不能用SELECT别名MySQL默认可以建议避免依赖HAVING在SELECT前扩展行为ORDER BY里能不能用SELECT别名可以ORDER BY在SELECT之后执行WHERE里能不能用聚合函数不能聚合函数需要分组后才能计算HAVING里能不能用非聚合列可以但会按分组条件随机取值每个分组只输出一行非聚合列值不确定DISTINCT和ORDER BY同时用排序列必须包含在SELECT里吗在MySQL旧版本会报错8.0已优化DISTINCT去掉重复行后若排序列不在结果集中可能出现多行对应一个排序键最后一条值得多解释。MySQL早期版本中SELECT DISTINCT name FROM users ORDER BY age会直接报错因为DISTINCT处理完结果集后只剩下name列而ORDER BY却需要age。逻辑顺序上这是DISTINCT在SELECT后面做去重把多行合成了一行age信息已经消失了。8.0之后优化器能兼容这种情况但为了稳妥我建议DISTINCT和ORDER BY的列保持在同一层级最好排序列也出现在SELECT中。4.2 执行计划EXPLAIN先看这五列遇到慢SQL第一步不是改SQL而是先跑EXPLAIN。我常用的最小检查清单是五列type、possible_keys、key、rows、Extra。type是访问类型从好到差大致是system const eq_ref ref range index ALL。system和const最常见于主键或唯一索引等值查询ref是普通二级索引等值匹配ref_or_null是类似情况外加IS NULLrange是索引范围扫描比如、BETWEEN、INindex是扫描了整棵索引树但不用回表通常比ALL好一点ALL就是全表扫描慢SQL头号嫌疑犯。key说明实际用了哪个索引。rows是优化器估算的需要扫描的行数这个值越大不代表最终耗时一定高但直接反映执行顺序里WHERE的过滤效率。Extra是重头戏Using where表示在存储引擎层拿到记录后又做了条件过滤Using index表示查询直接用覆盖索引避免回表属于非常理想的状况Using temporary表示用了临时表常见于GROUP BY、DISTINCT、ORDER BY列不一致的场景Using filesort表示发生了文件排序排序字段没能利用索引。看到后面两种基本就能判断问题出在执行顺序的中后段。比如这条SQLSELECT user_id, COUNT(*) FROM orders WHERE create_time 2024-06-01 GROUP BY user_id ORDER BY COUNT(*) DESC;如果EXPLAIN的Extra列出现了Using temporary; Using filesort就要考虑(user_id, create_time)复合索引是否存在能不能让GROUP BY user_id直接走索引ORDER BY COUNT(*)是聚合结果排序很难用索引解决这时临时表和文件排序几乎是必然的。优化思路是把数据量先缩小或者接受这种统计型查询的代价再或者考虑用窗口函数改写把排序压力分散。4.3 高频面试题一览与回答逻辑我这里整理一组面试里出现频率极高的SQL执行顺序题你可以用来检验自己第一题写出SQL执行顺序的标准顺序。回答思路是背出FROM - ON/JOIN - WHERE - GROUP BY - HAVING - SELECT - DISTINCT - ORDER BY - LIMIT并额外说明窗口函数位于HAVING之后、ORDER BY之前。第二题WHERE和HAVING有什么区别回答的核心是执行时机不同WHERE在GROUP BY之前过滤原始行HAVING在GROUP BY之后过滤分组。WHERE不能用聚合函数HAVING可以用。能用WHERE就用WHERE因为先过滤能减少后续计算量。第三题为什么SELECT别名不能用在WHERE却可以用在ORDER BY回答时要强调执行顺序WHERE先于SELECT执行别名还没生成ORDER BY在SELECT之后别名已经存在。第四题一条SQL能否在GROUP BY之后利用HAVING里的别名很多数据库对HAVING的别名支持不一致要从执行顺序角度解释HAVING严格来说也在SELECT之前所以标准SQL里HAVING不应依赖SELECT别名但很多数据库做了扩展。面试时答到“标准不支持MySQL默认支持但不建议”已经是加分项。第五题子查询的执行顺序是什么如果子查询在WHERE里逻辑上是先执行子查询再用结果去过滤外层表但优化器可能会改写为关联查询或半连接不一定真的“先执行子查询”。理解逻辑顺序和物理优化的区别是区分资深开发和新手的分水岭。4.4 安全提醒别让执行顺序变成SQL注入的突破口聊执行顺序时有个话题必须单独提一下SQL注入。尽管现在ORM框架很普及但仍然有大量手写SQL拼接的场景。如果你把用户输入直接拼进WHERE条件攻击者完全可以利用SQL的语法和逻辑顺序改变整个查询的语义。比如后端代码里这样写SELECT * FROM users WHERE username input AND password pwd 用户在输入框里填入类似 OR 11的内容拼出来就是这个效果SELECT * FROM users WHERE username OR 11 AND password 任意执行顺序里AND优先级高于OR最终WHERE条件在部分逻辑下可能直接为真防护形同虚设。这就是为什么我反复强调动态SQL一定用参数化查询或预编译语句让数据库把SQL模板和执行参数区分开而不是把参数拼进SQL字符串里。任何执行顺序文章如果只讲快慢不讲安全都是不完整的。这一条属于底线问题写代码时永远不要图省事。结尾把这个顺序变成你的“肌肉记忆”我个人在实际排查中最大的体会是SQL执行顺序不是背一遍就完了而是要变成一种反射。看到一条SQL先在脑子里快速跑一遍逻辑顺序想想WHERE过滤得够不够早GROUP BY和ORDER BY能不能吃到同一个索引SELECT里的表达式会不会破坏索引DISTINCT和ORDER BY的列是否冲突。这个习惯养成之后很多慢SQL一眼就能看出问题根本不用等EXPLAIN出来再分析。最后再分享一个小技巧你可以把执行顺序这个表格贴在工位上每次写SQL前扫一眼。坚持一个月你自然会发现写出来的语句少了很多隐性问题。如果再遇到那种“明明加了索引还是很慢”的诡异SQL先别怀疑MySQL回到执行顺序从头捋一遍八成问题就出在你以为SQL按你写的方式执行了。真正的优化永远从“放弃直觉、尊重顺序”开始。
返回列表