
写过几年SQL的人大概都背过一条口诀FROM、WHERE、GROUP BY、HAVING、SELECT、DISTINCT、ORDER BY、LIMIT。可真上了线一条看似平平无奇的查询把数据库拖到CPU爆满走进面试间一句“WHERE里为什么不能用SELECT别名ORDER BY里却可以”也常常让老开发当场愣住。这其实都指向同一个问题你背的这条顺序到底是谁的顺序它和MySQL真正执行的那条流水线完全是两回事。这篇文章想把这个事彻底讲透。它能帮你解决三类问题一是写SQL时理解为什么某些写法报错、某些写法慢二是排查线上慢查询时知道该从哪一步下手三是面试时遇到执行顺序相关的问题不再心虚。我尽量用实际案例说话少讲空理论适合后端开发、数据分析师以及正在准备数据库面试的同学阅读。1. 先分清楚逻辑执行顺序和MySQL的真实工作流程1.1 你背的那条顺序其实是关系代数的运算顺序很多人不知道教科书上那条“SQL执行顺序”并不是MySQL在物理上做事的顺序而是逻辑运算顺序它来自关系代数理论。SQL是一种声明式语言你告诉数据库“我要什么数据”至于怎么取、先做哪一步再做哪一步是优化器说了算的。这条逻辑顺序描述的是数据从“原始表集合”到“最终结果集”的变换过程先确定数据源再逐层过滤、聚合、投影、排序、截断。每一步都建立在前一步的结果之上像一个流水线。理解这点很重要因为后面很多“为什么”都能从这个流水线的位置找到答案。比如WHERE字句在SELECT之前执行所以SELECT里定义的别名WHERE中用不了。比如GROUP BY在HAVING之前执行所以HAVING可以对聚合结果进行过滤。这些都不是MySQL拍脑袋定的规则而是关系代数运算的自然结果。1.2 MySQL内部把一条SQL拆成四步MySQL处理一条SQL大致经过四个阶段。这是Server层的逻辑分工和上面的逻辑执行顺序不是一个维度但同样重要。连接器负责鉴权、建立连接、管理连接状态。你执行mysql -u root -p登录的那一刻就进入了这个阶段。分析器先做词法分析把SQL语句拆成一个个Token再做语法分析检查SQL是否符合语法规则。比如select * from t where id1会被拆成select、*、from、t、where、id、1这些Token然后按语法规则生成语法树。如果语法有错这里就直接报错了。优化器这是最关键的阶段。优化器会决定使用哪个索引、多表连接用哪个表作为驱动表、子查询怎么改写、Join怎么做。它内部有成本模型会估算各种执行计划的代价然后选一个它认为最低的。执行器真正调用存储引擎接口逐行读取、判断、返回结果。执行器的操作是严格按照优化器生成的执行计划来的而不是你写的SQL顺序。可以这么类比SQL是你下的菜单写了“宫保鸡丁”厨房里怎么切丁、先大火还是小火是优化器决定的。有时候你写的字句顺序和厨房工艺完全不同但最终上桌的菜得符合你的需求。1.3 为什么搞清楚执行顺序比背口诀有用背口诀只能应付最简单的面试题真正有价值的是用执行顺序的思维去诊断问题。举一个我踩过的例子某次线上一条SQLWHERE条件里写了YEAR(create_time) 2024create_time上明明建有索引但EXPLAIN一看走的是全表扫描rows显示500万行。原因就在于WHERE阶段要对每一行先计算YEAR函数再和2024比较索引无法加速这种计算后的比较。如果只背口诀你只知道WHERE在GROUP BY前面解释不了这个现象。而当你把“顺序”和“每一阶段如何利用索引”结合起来看很多慢查询的根因就浮出水面了。这也是我在正文里反复强调的执行顺序不只是死板的排列它是SQL性能问题的定位坐标系。2. 从FROM到JOIN数据源是怎么被组织的2.1 FROM子句不是简单读表逻辑顺序的第一步是FROM它的语义是确定数据源。但物理上MySQL要怎么读取这些表却大有讲究。单表查询相对简单难的是多表JOIN时谁先谁后。多表JOIN的物理执行MySQL最核心的是Nested-Loop Join嵌套循环连接及其变体。本质上是两层循环外层循环遍历驱动表的每一行内层循环到被驱动表里去匹配连接条件。MySQL 8.0之后等值连接场景还引入了Hash Join也就是把驱动表的连接字段构造成哈希表再遍历被驱动表做哈希匹配这种方式在大表等值连接时比嵌套循环快很多。无论哪种方式都有一个实战原则小表驱动大表。因为外层循环的次数越少整体扫描和匹配的成本越低。假设a表1000行b表100万行JOIN条件是a.id b.id。如果a做驱动表外层循环1000次每次去b表匹配一次反过来b做驱动表外层循环100万次成本天差地别。优化器一般会根据表统计信息和索引情况自动选择驱动顺序但统计信息不准或者SQL本身写得太复杂时它也会选错。这时候可以用STRAIGHT_JOIN强制指定驱动顺序但不建议随便用只有在明确知道优化器选错时才动手。2.2 ON条件与WHERE条件的执行差异这是执行顺序里最隐蔽也最容易出错的点。逻辑顺序上ON在JOIN时执行WHERE在JOIN完成之后执行。对于INNER JOIN两种写法的结果一致所以很多人没意识到它们有区别。但换成LEFT JOIN区别就大了。看一个经典例子-- 左连接右表的条件放在ON里 SELECT u.id, o.total FROM users u LEFT JOIN orders o ON o.user_id u.id AND o.total 100; -- 左连接右表的条件放在WHERE里 SELECT u.id, o.total FROM users u LEFT JOIN orders o ON o.user_id u.id WHERE o.total 100;第一条SQL的意思是先按o.user_id u.id AND o.total 100去匹配匹配不上的左表行保留右表字段填NULL。所以每个用户都会出现只是没有达标订单的用户total显示为NULL。第二条SQL的逻辑顺序是先做LEFT JOIN所有用户和订单连接后形成中间结果然后WHERE把o.total 100的行留下。这一步的副作用是那些没有订单、total为NULL的用户也被过滤掉了LEFT JOIN实际上变成了INNER JOIN的效果。很多人踩过这个坑明明写了LEFT JOIN结果某些用户“凭空消失”。排查半天发现是WHERE条件把右表字段过滤了。理解了执行顺序这类问题一眼就能定位。2.3 子查询也是FROM/JOIN的变体写成FROM子查询时MySQL在逻辑上把子查询结果当作一个派生表。但物理上它不一定会先物化整张派生表MySQL 8.0的派生表合并优化derived_merge会把符合条件的子查询直接合并到外层查询里去。同样IN子查询可能被改写为semi-joinEXISTS也可能被优化成其他连接方式。所以别一口咬定“子查询一定先执行”优化器比你想象的会变通。3. WHERE、GROUP BY、HAVING过滤、分组与再过滤的边界3.1 WHERE先把数据规模压到最小逻辑顺序里WHERE在GROUP BY之前这个看似简单的先后关系实际上决定了整条SQL的性能天花板。WHERE的作用是逐行过滤条件不满足的行直接丢弃不进入后续任何阶段。所以写SQL的第一直觉应该是能在WHERE里过滤掉的绝不放后面处理。比如要统计2024年每个用户的下单金额你会先写WHERE create_time 2024-01-01 AND create_time 2025-01-01把数据先缩到2024年的订单再去GROUP BY。如果反着来先GROUP BY全表再在HAVING里过滤年份那意味着要把所有历史订单全部分组一次浪费巨大。WHERE阶段的另一个关键点是索引。WHERE条件能不能命中索引直接决定过滤是走索引快速定位还是全表扫描逐行判断。常见的索引失效场景包括对字段使用函数、隐式类型转换、LIKE前置通配符、OR连接非索引列。这些写法会让优化器在过滤阶段“使不上劲”只能退化为全表扫描。3.2 GROUP BY分组后每组只留一个代表GROUP BY的语义是把同一个分组键的行合并成一组每组在结果里只占一行。它和聚合函数是天生一对COUNT、SUM、AVG、MIN、MAX这些函数本质上都是对组内多行做汇总计算。这里有个特别容易让新手懵的点分组之后一个组里可能有多行但你只能看到这一组的“代表行”组内其他行的非聚合字段信息是不稳定的。MySQL 5.7之后默认开启ONLY_FULL_GROUP_BY模式SELECT里的非聚合列必须出现在GROUP BY子句中否则直接报错。这是一种保护机制防止你拿到无意义的数据。举个例子SELECT user_id, order_id, COUNT(*) FROM orders GROUP BY user_id;在ONLY_FULL_GROUP_BY模式下这条SQL会因为order_id既不在GROUP BY里又不是聚合函数而报错。这是合理的每个用户可能有多个订单order_id该显示哪一个MySQL并不知道。要么把order_id加入GROUP BY要么把它改成GROUP_CONCAT(order_id)或MAX(order_id)这类聚合表达式。3.3 HAVING聚合后的过滤不能替代WHEREHAVING的逻辑位置在GROUP BY之后它面对的不是“行”而是“组”。所以HAVING里可以使用聚合函数比如筛选下单次数超过5次的用户SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id HAVING cnt 5;而WHERE在GROUP BY之前面对的是原始行根本不知道聚合结果所以WHERE里不能写COUNT(*) 5这样的条件。这就是“WHERE过滤行、HAVING过滤组”的执行顺序含义。但要注意HAVING能做的事不等于该用HAVING做。很多性能问题恰恰出在滥用HAVING上把能在WHERE里做的条件拿到HAVING里做。比如“只统计2024年的订单”如果在WHERE里过滤分组前数据就大幅缩减放在HAVING里过滤等于先对全表分组再丢组白白浪费一轮聚合。正确姿势是先用WHERE把行级条件解决掉再用HAVING处理组级条件。顺带提一句MySQL在GROUP BY和HAVING阶段还支持使用SELECT里的别名这是官方文档明确提到的扩展行为标准SQL是不允许的。这个特性方便归方便但换到其他数据库可能会报错我不建议把它当常规写法。3.4 别名的秘密WHERE不能用ORDER BY能用这个问题的答案现在可以完整说清楚了。逻辑执行顺序里SELECT在WHERE之后、在ORDER BY之前所以WHERE执行的时候SELECT里定义的别名还不存在你拿一个不存在的别名叫MySQL去过滤它只能报Unknown column。而ORDER BY在SELECT之后执行别名已经生成所以可以用。这也是面试里最高频的执行顺序考点。用SQL验证一下-- 这条会报错Unknown column n in where clause SELECT name AS n FROM users WHERE n 张三; -- 这条正常执行 SELECT name AS n FROM users ORDER BY n;MySQL在GROUP BY和HAVING里也允许用SELECT别名这是它的非标准扩展。知道这个特性可以但写生产SQL时尽量别依赖否则以后迁移到PostgreSQL或Oracle同样的SQL直接崩溃。我见过不止一次代码从MySQL迁到其他库时整批SQL因为别名问题要重写。4. SELECT、DISTINCT、ORDER BY、LIMIT收尾阶段的四个关键动作4.1 SELECT投影决定你最终看到哪些列SELECT在逻辑顺序里排在GROUP BY和HAVING之后它负责从前面处理完的数据里挑选列、生成别名、计算表达式。这个阶段看起来只是“选列”但它对性能的影响往往被低估。SELECT *是最典型的反面教材。它会把表中所有列都取出来不仅加大网络传输量还会让优化器无法使用覆盖索引被迫回到主键索引二次回表。尤其在执行顺序靠后的阶段前面明明过滤得很小最后因为SELECT *把全行数据捞出来性价比极低。正确做法是只SELECT需要的列必要时候利用覆盖索引让整个查询走索引就结束连回表都省了。另外如果SQL里涉及窗口函数它在这个阶段的执行位置也很特殊窗口函数在HAVING之后、SELECT投影之前执行。所以你可以对窗口函数的结果再在外层做过滤或排序但不能在WHERE或HAVING里直接引用窗口函数计算后的列。很多人写“筛选每个分组第一名”时习惯用窗口函数再用外层查询包一层过滤原因就在这里。4.2 DISTINCT一种全局去重本质是分组DISTINCT的语义是去除结果集中的重复行它在逻辑上排在SELECT之后。从关系运算的角度看它本质上就是一种分组——对SELECT出来的全部列做分组每组取一行。所以SELECT DISTINCT a, b FROM t和SELECT a, b FROM t GROUP BY a, b的结果是等价的只是前者不能配合聚合函数使用。性能方面DISTINCT通常需要借助临时表或者索引扫描来做去重。如果去重列上有索引MySQL可以借助索引有序性直接跳过重复值效率高很多如果没有索引只能产生临时表去重代价不小。所以如果你的业务里经常出现SELECT DISTINCT值得考虑给相关列加索引或者评估一下是否真的需要去重。4.3 ORDER BY排序的分水岭ORDER BY是执行顺序里靠后的步骤但它的物理成本一点都不低。MySQL排序有两种路径一是利用索引的有序性直接按顺序读取连排序都不用做二是无法借助索引时产生一次filesort把数据放到内存排序缓冲区装不下了就落盘到临时文件用外部排序完成。从EXPLAIN的Extra列能看到端倪出现Using filesort就代表走了排序。我之前优化过一条SQLGROUP BY和ORDER BY用的字段不一致导致分组后还要对整个结果重新排序Extra里同时出现Using temporary; Using filesort数据量一大直接跑十几秒。后来调整了索引设计让GROUP BY和ORDER BY都命中最左前缀直接变成Using index性能提升非常明显。还有两个细节值得记一下。第一MySQL 8.0移除了GROUP BY的隐式排序这在老版本里是默认行为。很多老博客让你加ORDER BY NULL消除隐式排序的开销8.0之后完全没必要这么做了。第二ORDER BY RAND()这种写法会让优化器对全表每一行生成随机数再排序数据量稍大就是灾难慎用。4.4 LIMIT截取最后一段但陷阱在偏移量逻辑顺序上LIMIT是最后一步它从排序后的结果中取指定行数。但物理上MySQL不一定会老老实实等到最后才动手比如ORDER BY ... LIMIT 10这种场景MySQL可以用优先队列只保留前10条省去全量排序的成本这也是ORDER BY加LIMIT时经常比不加LIMIT快的原因。真正容易出问题的是大偏移量翻页比如LIMIT 1000000, 20。逻辑上它要先跳过前100万行再取20行。但物理上如果没办法利用索引直接定位MySQL就得把前面100万行全部读出来然后扔掉代价极高。我见过很多后台列表页翻到后面越来越慢就是这个原因。优化手段常用延迟关联先只查主键ID的限额范围再用主键回表查完整行。-- 优化前扫到第1000020行才停 SELECT * FROM orders ORDER BY id LIMIT 1000000, 20; -- 优化后子查询先走索引拿到20个ID再关联回原表 SELECT o.* FROM orders o JOIN ( SELECT id FROM orders ORDER BY id LIMIT 1000000, 20 ) t ON o.id t.id;第一条SQL在数据量大的时候可能要扫全表大部分数据第二条的临时表部分可以完全走二级索引回表只发生在20行上差距往往是几十倍。5. 从执行顺序到慢SQL优化一次真实的排查记录5.1 先学会看EXPLAIN输出我会把EXPLAIN当作执行顺序的“物理快照”它展示的正是优化器最终决定的行事路线。理解它比理解逻辑顺序更贴近实战因为你要优化的永远是物理执行计划。EXPLAIN里有几个关键字段字段含义重点关注type访问类型从好到差大致是system、const、eq_ref、ref、range、index、ALL看到ALL基本就是全表扫key实际选用的索引NULL代表没用索引rows预估扫描行数数量级越大代价越高Extra附加信息Using index代表覆盖索引Using where代表过滤Using filesort代表排序Using temporary代表临时表如果一条SQL的EXPLAIN里type是ALLrows是几百万Extra还挂着filesort和temporary那这个查询基本可以断定有问题。接下来要做的是按逻辑执行顺序逐环节找原因。5.2 案例一WHERE里用函数导致索引失效那是我在订单表上排查的真实场景。表结构简化如下CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id INT, total DECIMAL(10,2), create_time DATETIME, KEY idx_create_time (create_time) );线上慢查询日志捞出来一条SELECT id, total FROM orders WHERE YEAR(create_time) 2024;EXPLAIN的结果typekeyrowsExtraALLNULL5000000Using where500万行全表扫描。原因就是YEAR函数套在索引列上优化器无法对函数处理后的值使用B树索引只能在WHERE阶段逐行计算函数再比较。理想情况是让条件变成对原始列的范围比较SELECT id, total FROM orders WHERE create_time 2024-01-01 00:00:00 AND create_time 2025-01-01 00:00:00;优化后的EXPLAIN变成typekeyrowsExtrarangeidx_create_time约80万Using index condition虽然还需要回表但扫描范围从500万锐减到几十万而且走的是索引范围扫描实际响应时间从4秒多降到0.2秒。这个案例的底层逻辑就是执行顺序里WHERE阶段能否高效利用索引决定了整条SQL的命运。5.3 案例二LEFT JOIN驱动表选错另一个案例和JOIN顺序有关。用户表users有10万行订单表orders有500万行。需求是查出所有有效用户的订单总数SELECT u.id, COUNT(o.id) AS order_cnt FROM users u LEFT JOIN orders o ON o.user_id u.id WHERE u.status 1 GROUP BY u.id;初始状态下orders.user_id没有索引EXPLAIN显示orders表这一侧是typeALLrows5000000。因为LEFT JOIN的语义决定了左表users是驱动方向连接时要用user_id去右表orders里找匹配右表没有索引就只能全表扫。处理方式分两步第一给orders.user_id加索引KEY idx_user_id (user_id)让右表连接时变成ref查找第二是检查逻辑如果业务上确实只需要有效用户有订单的统计完全可以把LEFT JOIN改成INNER JOIN这样优化器可以自由选择驱动表尽量减少扫描量。加索引之后右表的访问类型从ALL变成了refrows从500万降到几百整条SQL从分钟级降到秒级。这里的关键是执行顺序告诉你ON发生在JOIN过程中所以ON字段的索引状态直接影响JOIN阶段的代价而不是等到WHERE再去弥补。5.4 案例三GROUP BY的隐式排序陷阱再说一个MySQL版本变更带来的坑。老版本里GROUP BY user_id除了分组还会默认对分组列做一次排序。很多老开发为了避免这次排序习惯写GROUP BY user_id ORDER BY NULL。这个习惯到了MySQL 8.0反而成了坏味道因为8.0已经移除了GROUP BY隐式排序你再写ORDER BY NULL语义上等于什么都没做但代码却给后人埋下疑惑。我在8.0上做过验证对一张500万行的订单表执行SELECT user_id, COUNT(*) FROM orders GROUP BY user_id;在user_id有索引的前提下EXPLAIN里走的是松散索引扫描或者全索引扫描不再出现额外的filesort。如果你在8.0上看到GROUP BY后面带着filesort通常不是隐式排序导致的而是因为你GROUP BY的列与SELECT里的其他函数、表达式混用或者在GROUP BY之外还写了ORDER BY不同的列迫使MySQL额外排序。这也提醒我网上很多SQL优化的老经验一定要结合当前MySQL版本来验证不能盲目照抄。6. 高频面试题与常见误区速查6.1 面试官爱问的几个执行顺序问题我在面试后辈时几乎必问这几个问题每个都和这条执行顺序主线相关。问题一请简述一条SQL的完整处理过程。好的回答分两层先说逻辑执行顺序FROM、WHERE、GROUP BY、HAVING、SELECT、DISTINCT、ORDER BY、LIMIT再说MySQL物理上的四步流程连接管理、分析、优化、执行。能说出“逻辑顺序是关系代数的运算顺序物理顺序由优化器决定”的人基本可以过关。问题二WHERE和HAVING的区别是什么从执行顺序角度WHERE在GROUP BY之前对原始行过滤不能使用聚合函数HAVING在GROUP BY之后对分组后的组过滤可以使用聚合函数。性能上能用WHERE过滤的条件不要放在HAVING里。问题三为什么WHERE里不能用SELECT别名ORDER BY里可以因为逻辑顺序里SELECT在WHERE之后别名还没生成而ORDER BY在SELECT之后别名已经可用。这是MySQL官方文档也确认的行为。问题四LEFT JOIN中ON和WHERE有什么区别ON在JOIN过程中执行决定怎么匹配WHERE在JOIN完成后执行决定保留哪些行。把右表的过滤条件写在WHERE里会导致LEFT JOIN退化成内连接的语义左表中未匹配的行被过滤掉。问题五大偏移量LIMIT为什么慢怎么优化因为要扫描并丢弃前面的大量行。优化常用的有延迟关联、记住上次翻页位置、或者用ID范围条件替换偏移量。问题六设计联合索引时字段顺序怎么排执行顺序思维在这里也有效先考虑WHERE里的等值列再考虑GROUP BY和ORDER BY的字段最后才是范围查询列。等值列放最前面排序、分组的字段尽量靠前因为索引结构天然支持这些场景的有序扫描。6.2 常见误区清单误区实际情况典型后果SQL从左到右执行逻辑顺序从FROM开始物理顺序由优化器决定读SQL时判断错性能瓶颈WHERE里能用SELECT别名逻辑顺序中SELECT在WHERE之后直接报unknown columnHAVING和WHERE可以互换作用时机和对象完全不同结果错误或性能下降GROUP BY就是去重分组语义结合聚合函数不只是去重对SELECT列的限制理解出错LIMIT永远是最后一步逻辑上靠后物理上优化器可能提前终止排序或扫描低估或高估查询代价加索引就一定能被用到优化器会评估成本有时候全表扫反而被选中索引加了不生效还占空间6.3 写SQL时的几个自查习惯这些习惯是我这几年排查慢SQL逐步养成的分享给大家。第一写SQL先想FROM。从数据源出发先想清楚要处理的是哪几张表、连接条件是什么再想过滤。很多新手从SELECT出发列了一堆字段最后才想到表还没选明白那样写出来的SQL往往逻辑混乱。第二能提前收窄的绝不拖后。WHERE能过滤掉的、JOIN的ON能收窄的全部往前放不要让大数据量一路带着跑到最后。第三SELECT列要“扛得住分组”。走GROUP BY时SELECT里的非聚合列必须要么在GROUP BY里要么是聚合函数否则要么报错要么结果无意义。第四用EXPLAIN验证而不是猜。SQL写得再漂亮EXPLAIN出来是ALL全表扫一样得改。所有优化结论以执行计划为准。第五复杂SQL宁可拆开别图一气呵成。拆成几步临时结果每一步都用执行计划验证排查时也容易定位是哪个环节慢了。结尾一点个人体会这几年排查慢SQL我的体感是大部分问题都能在“执行顺序”这四个字里找到答案。加了索引不走多半是WHERE阶段对字段动了手脚LEFT JOIN丢数据多半是WHERE把右表条件过滤掉了翻页越来越慢多半是LIMIT偏移量太大又没有延迟关联。很多问题表面看是索引、是统计信息、是版本差异往深了想都是某一阶段的执行逻辑没理解透。个人建议新同学看完这篇文章后拿一条自己线上真正慢的SQL出来按逻辑执行顺序一步一步走一遍每走一步问自己这个阶段的数据量是多大能不能提前收窄能不能用索引这样练上几条比背十遍口诀都管用。另外我还有个习惯写完SQL倒着读一遍从LIMIT往前看到FROM特别容易发现多余的分组、重复的排序和那些本可以提前的过滤条件。这个习惯不大起眼但确实帮我挡掉过不少线上事故。