写过几年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 id=1,会被拆成select、*、from、t、where、id、1这些Token,然后按语法规则生成语法树。如果语法有错,这里就直接报错了。 - 优化器:这是最关键的阶段。优化器会决定使用哪个索引、多表连接用哪个表作为驱动表、子查询怎么改写、Join怎么做。它内部有成本模型,会估算各种执行计划的代价,然后选一个它认为最低的。
- 执行器:真正调用存储引擎接口,逐行读取、判断、返回结果。执行器的操作是严格按照优化器生成的执行计划来的,而不是你写的SQL顺序。
可以这么类比:SQL是你下的菜单,写了“宫保鸡丁”,厨房里怎么切丁、先大火还是小火,是优化器决定的。有时候你写的字句顺序和厨房工艺完全不同,但最终上桌的菜得符合你的需求。
1.3 为什么搞清楚执行顺序比背口诀有用
背口诀只能应付最简单的面试题,真正有价值的是用执行顺序的思维去诊断问题。举一个我踩过的例子:某次线上一条SQL,WHERE条件里写了YEAR(create_time) = 2024,create_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-join,EXISTS也可能被优化成其他连接方式。所以别一口咬定“子查询一定先执行”,优化器比你想象的会变通。
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:聚合后的过滤,不能替代WHERE
HAVING的逻辑位置在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就代表走了排序。我之前优化过一条SQL,GROUP 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是ALL,rows是几百万,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的结果:
| type | key | rows | Extra |
|---|---|---|---|
| ALL | NULL | 5000000 | Using where |
500万行全表扫描。原因就是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变成:
| type | key | rows | Extra |
|---|---|---|---|
| range | idx_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表这一侧是type=ALL,rows=5000000。因为LEFT JOIN的语义决定了左表users是驱动方向,连接时要用user_id去右表orders里找匹配,右表没有索引就只能全表扫。
处理方式分两步:第一给orders.user_id加索引KEY idx_user_id (user_id),让右表连接时变成ref查找;第二是检查逻辑,如果业务上确实只需要有效用户有订单的统计,完全可以把LEFT JOIN改成INNER JOIN,这样优化器可以自由选择驱动表,尽量减少扫描量。
加索引之后,右表的访问类型从ALL变成了ref,rows从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 column |
| HAVING和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,特别容易发现多余的分组、重复的排序和那些本可以提前的过滤条件。这个习惯不大起眼,但确实帮我挡掉过不少线上事故。