你要是问我,SQL里最容易让人“看着会、写起来懵”的知识点是什么,我的答案多半不是各种函数,也不是复杂的 JOIN,而是执行顺序。很多人在面试时能把 select ... from ... where ... group by ... order by 背得滚瓜烂熟,可一旦被问“这条 SQL 到底先执行哪一步”,马上就会卡壳。这其实不怪你,因为 SQL 的书写顺序和执行顺序本来就不是一回事,很多教编程的资料也默认大家知道,结果就成了“好像懂,实际不懂”的重灾区。
这篇 SQL 专题,我就把执行顺序这件事彻底拆开聊透。我会先讲它为什么重要,再按标准逻辑顺序一段一段拆解,然后结合 MySQL、SQL Server 等常见数据库的实际表现,讲清楚它是怎么影响慢 SQL 排查、面试答题和日常写查询的。无论你是刚学 SQL 的新手,还是已经被慢查询磨了很久的开发者,这份内容都可以直接拿来用。
1. 为什么执行顺序比想象中更容易被忽略
1.1 一个常见的误区:SQL 是从 SELECT 开始执行的
我在带人写 SQL 的时候,发现一个很有意思的现象:大多数人看一条查询,习惯从上往下读,于是默认数据库也是从上往下跑。比如:
sql复制select name, count(*)
from user
where status = 1
group by name
having count(*) > 10
order by name;
按书写顺序读,第一行是 select,很多人就会觉得“数据库先把 name 和 count(*) 选出来,再去找表”。实际上完全反了,数据库先看到的是 from user,它要先确定数据从哪张表来,然后才开始后面的过滤、分组、聚合。
这个认知偏差,平时写简单查询没什么影响,但一旦涉及别名、聚合、去重、子查询,或者你想优化慢 SQL,就会处处碰壁。比如新手常问“为什么 WHERE 里不能用 SELECT 里起的别名”“为什么 HAVING 里能用聚合函数,WHERE 里不能用”,这些问题往上追根溯源,都是执行顺序在起作用。
1.2 逻辑执行顺序和物理执行计划不是一回事
这里必须强调一个关键点:SQL 的执行顺序有两种,一种是逻辑执行顺序,另一种是物理执行顺序。
逻辑执行顺序是 SQL 标准定义的一套顺序,它描述的是“这条语句在语义上应该怎样分步计算”。你可以把它理解成做菜时菜谱上的步骤:先洗菜、再切菜、再炒菜。物理执行顺序则是数据库优化器真正干活的顺序,它可能会为了性能调整步骤,比如先把过滤条件下推到扫描阶段,或者把两张表关联的顺序换一下。优化器的目的是在保证结果和逻辑顺序一致的前提下,尽量少干活。
所以,逻辑执行顺序决定的是“这条 SQL 能写什么、不能写什么、结果应该是什么”,物理执行计划决定的是“机器实际上怎么跑的”。我们讨论执行顺序,首先要掌握逻辑顺序,然后才谈得上用执行计划去优化。
1.3 把执行顺序当成一条流水线来理解
我特别喜欢用一个比喻:执行顺序就是一条工厂流水线,每个阶段只处理上一阶段送来的半成品。
- 第一步是拿原料,拿到的是整张表。
- 然后过滤掉不需要的行。
- 再把剩余的行分组、聚合。
- 最后才决定要输出哪些列、按什么排序、取多少行。
如果你把 SQL 想成流水线,很多问题就清晰了。比如 where 阶段的“半成品”还没有聚合,所以它不能使用聚合函数;select 阶段已经做完分组了,所以它能把 count(*) 计算出来,但也意味着它是在整条流水线靠后的位置,别指望 where 能用 select 里生成的别名。
这个视角,就是整个专题的核心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SQL 标准逻辑执行顺序逐段拆解:FROM 到 LIMIT
2.1 完整顺序速览
一条最常见的查询语句,书写顺序和执行顺序的对照大概是这样的:
| 书写顺序 | 逻辑执行顺序 | 对应操作 |
|---|---|---|
| SELECT | 1 | FROM / JOIN / ON |
| FROM | 2 | WHERE |
| WHERE | 3 | GROUP BY |
| GROUP BY | 4 | HAVING |
| HAVING | 5 | SELECT |
| ORDER BY | 6 | DISTINCT |
| LIMIT / OFFSET | 7 | ORDER BY |
| 8 | LIMIT / OFFSET |
注意,这个顺序是逻辑顺序,不是数据库内部真正执行的每一步。接下来我会把这几个阶段一个个拆开,讲清楚它们各自在干什么。
2.2 FROM 和 JOIN:所有数据的最初来源
整个查询的第一步,是确定数据源。如果语句里只有一张表,那就是从这张表里取全部行;如果是多张表关联,则要先做笛卡尔积,然后通过 ON 条件把匹配不上的行丢弃。
比如下面这条:
sql复制select u.name, o.amount
from user u
join `order` o on u.id = o.user_id;
数据库在逻辑上先拿到 user 和 order 两张表的全部组合,然后根据 u.id = o.user_id 进行匹配。虽然优化器在物理上通常不会傻到真的算完笛卡尔积再去过滤,但逻辑上这个阶段就是要先把关联关系确定下来。
这一点非常重要,因为 ON 条件只在这个阶段生效。如果你在 LEFT JOIN 里把右表的过滤条件写在 WHERE 中,那么语义就变了,这个问题我在第 3 部分会专门讲。
2.3 WHERE:行级过滤,一次一行
拿到 FROM/JOIN 产生的“中间表”之后,进入 WHERE 阶段。这个阶段的工作方式是逐行判断条件,把不满足条件的行丢弃。
WHERE 有几个特点:
- 它处理的是“行”,不是“组”,所以不能使用聚合函数。
- 它可以对 FROM/JOIN 产生的任何列做过滤。
- 它是在分组之前执行的,所以 GROUP BY 之前能过滤掉的数据,就别拖到 HAVING 再过滤。
举个例子,下面这条查询用了 between and,它就是在 WHERE 阶段完成区间过滤:
sql复制select id, name, create_time
from user
where create_time between '2025-01-01' and '2025-12-31'
and status = 1;
这些过滤条件一条条作用于原始行。如果你要查的数据量很大,这一阶段能把绝大多数无关行筛掉,所以 WHERE 条件写得准不准,对性能影响是立竿见影的。
2.4 GROUP BY 和 HAVING:先分组,再过滤组
WHERE 过滤完行之后,如果语句里有 GROUP BY,就会进入分组阶段。分组阶段会把“具有相同分组键的行”归到同一个组里,后面 SELECT 中的聚合函数,比如 count(*)、sum(amount)、avg(score),都必须在这个阶段之后才能计算。
HAVING 则是在分组完成之后,对组进行过滤。它和 WHERE 最大的区别就是:WHERE 过滤的是行,HAVING 过滤的是组。
举一个很直观的例子:
sql复制select user_id, sum(amount) as total
from `order`
where status = 'paid'
group by user_id
having sum(amount) > 1000;
这句话的执行顺序是:先从订单表里把状态是已支付的订单挑出来,然后按用户分组,最后只保留总金额超过 1000 的组。如果我把 status = 'paid' 写进 HAVING,语义上也不是完全不行,但会把不该参与分组的行也带进来,导致聚合结果错误。所以千万记住:能用 WHERE 过滤的行,绝对不要放到 HAVING 里。
2.5 SELECT、DISTINCT、ORDER BY、LIMIT:真正“出结果”的阶段
到这里,才轮到 SELECT 登场。SELECT 阶段的任务是:根据前面的中间结果,计算出你要的列,生成最终的输出行。窗口函数、别名、以及最简单的新增计算列,都是在这个阶段产生的。
DISTINCT 紧跟在 SELECT 之后,表示对输出结果做去重。它作用于整行,而不是单列。比如 select distinct user_id, status 和 select distinct user_id 的含义不一样,前者是两列联合起来不重复,后者是只看 user_id 不重复。这也是很多人在“SQL 语句去重”这个问题上容易翻车的地方。
ORDER BY 排序则发生在 DISTINCT 之后。它允许使用 SELECT 里的别名,因为此时列已经算出来了。LIMIT/OFFSET 放在最后,表示取结果集的第几条到第几条。这一步到了 MySQL 里,才会真正决定输出多少行。
把这几步放在一起看,你会发现 SELECT 在整个链路里的位置比大多数人想的靠后。它不决定“有哪些数据可以用”,而只决定“最终展示哪些数据”。
2.6 WITH AS 和子查询的位置
现在很多人在做复杂查询时会用 with as,也就是 CTE(公共表表达式)。它的逻辑位置其实更靠近“数据源”这一侧:CTE 相当于一个命名的临时结果集,主查询可以从它里面读数据。所以从逻辑执行顺序来看,CTE 通常会在 FROM/JOIN 之前先被解析,可以理解成“数据库先算出这个临时结果,再把它当成一张表来用”。
sql复制with paid_order as (
select id, user_id, amount
from order
where status = 'paid'
)
select user_id, sum(amount)
from paid_order
group by user_id;
这里 paid_order 的逻辑执行顺序,就相当于先执行括号里的查询,生成一个结果集,然后再执行外层查询。当然,优化器在物理执行时不一定会物化这个 CTE,它可能会把 CTE 的查询合并到主查询里,但不管怎么合并,逻辑语义是不变的。理解这一点,对排查“为什么我 CTE 里查到的数据和预期不一致”也会很有帮助。
3. 两个极容易踩坑的顺序陷阱:别名和过滤位置
3.1 为什么 WHERE 里不能用 SELECT 别名
这是我在面试和技术群里被问到最多的问题之一。比如:
sql复制select id, name as n
from user
where n like '张%';
在 MySQL 里执行,会直接告诉你 Unknown column 'n' in 'where clause'。原因很简单:WHERE 在 SELECT 之前执行,n 这个别名在 WHERE 阶段还没生成。逻辑顺序决定别名只能在 SELECT 阶段之后被引用。
那为什么 ORDER BY 可以用别名呢?因为 ORDER BY 排在 SELECT 之后,此时别名已经存在了。比如:
sql复制select id, name as n
from user
where status = 1
order by n;
这条就没问题。但要是你非要在 WHERE 里用别名,正确的做法通常是先子查询一层:
sql复制select *
from (
select id, name as n
from user
where status = 1
) t
where n like '张%';
这种写法有点绕,但本质就是把别名的生成提前到一个子查询里,让外层 WHERE 可以引用它。很多人写复杂 SQL 时遇到“找不到列”的报错,第一反应是检查列名拼写,实际上更可能是顺序问题。
3.2 JOIN 的 ON 和 WHERE,真不是换一行这么简单
on 和 where 的差别,是 JOIN 查询里最容易踩的第二个陷阱。先说结论:在 inner join 情况下,on 和 where 写过滤条件的结果通常一样;但在 left join / right join 的情况下,结果可能完全不同。
看这个例子:
sql复制select u.id, o.amount
from user u
left join `order` o on u.id = o.user_id and o.status = 'paid';
这条查询会把所有用户都保留下来,左表 user 的每一行至少会输出一次;如果某个用户没有已支付订单,o.amount 就是 NULL。因为 o.status = 'paid' 写在 ON 里,它只是决定右表怎么匹配,并不会把左表的用户删掉。
但如果换一种写法:
sql复制select u.id, o.amount
from user u
left join `order` o on u.id = o.user_id
where o.status = 'paid';
这条查询的结果就会完全不一样。where o.status = 'paid' 是发生在 JOIN 完成之后的,一旦右表没有匹配行,o.status 就是 NULL,条件不成立,于是整行被过滤掉。原本的 left join 语义也就被破坏成了类似 inner join 的效果。
所以,当你做外连接时,如果希望右表的条件不影响左表行数,过滤一定要写在 ON 里;如果希望它对最终结果做进一步限制,才写进 WHERE。这个差别,执行顺序解释得一清二楚:ON 是在 JOIN 阶段用的,WHERE 是在 JOIN 完成后用的。
3.3 GROUP BY 和 ORDER BY 里的别名,不同数据库态度不一样
前面说了 WHERE 不能用别名,但 GROUP BY 和 ORDER BY 里的情况更复杂。在 MySQL 中,GROUP BY 和 ORDER BY 都允许使用 SELECT 别名,因为 MySQL 对 GROUP BY 做了扩展。但在 SQL Server 中,GROUP BY 通常不允许引用 SELECT 别名,ORDER BY 则允许。
我在本地用 MySQL 8.0 和 SQL Server 2019 分别测试过类似语句:
| 语句 | MySQL 8.0 | SQL Server 2019 |
|---|---|---|
ORDER BY n |
支持 | 支持 |
GROUP BY n |
支持 | 不支持(报错) |
WHERE n = 'xx' |
不支持 | 不支持 |
这个差异很容易让跨数据库开发的人头疼。如果你在一个项目里既要写 MySQL,又要写 SQL Server,统一的做法是别依赖 GROUP BY 别名,老老实实写原列名或表达式,这样最稳。
4. 不同数据库的执行顺序表现:MySQL 与 SQL Server 的“大同小异”
4.1 MySQL 优化器并不完全按照标准顺序执行
MySQL 官方文档里给出的 SELECT 语句执行顺序,大致是:FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> DISTINCT -> ORDER BY -> LIMIT,如果涉及 UNION,UNION 会在 ORDER BY 之前合并结果;如果涉及窗口函数,窗口函数会在 HAVING 之后、SELECT 之前计算。
但这是逻辑顺序,MySQL 的优化器在物理执行时非常喜欢做“条件下推”。比如你写:
sql复制select *
from user u
join `order` o on u.id = o.user_id
where u.status = 1;
MySQL 不一定真的先把两张表 JOIN 完再 WHERE 过滤,它可能先读取 user 表时就把 status = 1 过滤掉,再和订单表关联。这样效率更高,但结果和逻辑顺序完全一致。
换句话说,MySQL 执行计划里显示的顺序,才是你排查性能时要看的顺序。你要学会看 EXPLAIN,而不是只背逻辑顺序。很多慢 SQL 看执行计划才会发现,明明逻辑是先 JOIN 再 WHERE,物理计划里可能在 JOIN 前就过滤了一条大表,导致关联结果集巨大。
4.2 SQL Server 执行计划怎么“从右往左看”
SQL Server 的操作顺序和 MySQL 有些不同,但核心逻辑顺序一致。如果你打开 SQL Server Management Studio 的“显示实际执行计划”,会发现一个很典型的阅读习惯:从右往左看。
执行计划右侧通常是表扫描、索引查找、索引扫描这些访问数据的算子,然后往左是各种关联、聚合、过滤算子,最左侧是 SELECT 结果。这个从右往左的路径,实际上就是 SQL Server 处理数据的物理顺序。
我经常在排查 SQL Server 慢查询时,第一眼看的最左边和最右边距离,如果中间横跨了很长的哈希匹配、排序、表扫描,那大概率是 WHERE 条件没走索引,或者 JOIN 顺序没被优化好。配合逻辑执行顺序想,你能很快定位到“卡在哪个阶段”。
4.3 ORDER BY 在不同数据库里的排序稳定性与 LIMIT 差异
把 ORDER BY 和 LIMIT/TOP 放在一起时,要注意不同数据库的写法完全不同,但执行阶段都一样:先排序,再取前 N 行。
- MySQL 用的是
LIMIT n,或者配合OFFSET。 - SQL Server 传统写法是
SELECT TOP n,或用OFFSET ... FETCH。 - Oracle 12c 之后支持
FETCH FIRST n ROWS ONLY。
这里有一个特别容易误解的点:如果没有 ORDER BY,而直接写 LIMIT 或 TOP,数据库返回的“前 N 行”并不是符合业务语义的第一行,而是数据库按物理存储顺序或执行计划顺序取出来的任意行。比如你在 MySQL 里写 select * from user limit 10,这 10 行不代表 user 表里的“前 10 条记录”,只是优化器觉得最方便返回的 10 条。如果你要稳定结果,一定要加 ORDER BY。
我在做分页查询时吃过一次亏:表数据量不大,当时想偷懒不排序直接 limit,结果第一页和第二页之间出现了重复数据,原因就是底层存储顺序变了。从那以后我给自己立了个规矩:LIMIT 前必 ORDER BY,哪怕排序字段只是一个自增 ID。
4.4 用执行顺序解释“TOP 和 ORDER BY 之间微妙的先后”
很多 SQL Server 开发者在写 select top 10 ... order by create_time desc 时会想:到底是先 TOP 前 10 再排序,还是先排序再 TOP?看逻辑顺序就知道,ORDER BY 在 SELECT 之后,TOP 又是 SELECT 的修饰符,所以逻辑上是先排序,然后再取前 10,这样得到的是真正最大的 10 条。这个顺序在 MySQL 里也一样:LIMIT 一定是在 ORDER BY 之后执行,所以排序后再截断。
如果写反了,比如有些场景想把数据先随机抽几条再排序,那得用子查询把顺序颠倒过来:
sql复制select *
from (
select top 10 *
from user
) t
order by create_time desc;
这个例子也说明了一个通用规律:想让某个阶段提前,就需要用子查询或 CTE 去强制构造中间结果。
5. 把执行顺序用到慢 SQL 优化里:三个实战案例
5.1 案例一:先关联后过滤,还是先过滤再关联?
这是个老生常谈的问题。看下面这条 SQL:
sql复制select u.id, o.order_no
from user u
join `order` o on u.id = o.user_id
where u.create_time > '2025-01-01';
在大多数情况下,MySQL 优化器会把 u.create_time 的过滤条件下推到读取 user 表的时候,SQL Server 通常也会做类似的事。但你要知道,优化器不是万能的,遇到复杂的关联条件、多个子查询、或者写了一些并不常见的 JOIN 条件时,它可能选择先做关联,再整体过滤,结果就是中间结果集爆炸。
我自己遇到过一次比较典型的场景:两张百万级以上的表做关联,其中一个表的过滤条件写得很复杂,用了多个函数。执行计划显示哈希匹配之前把两张表完整扫了一遍。后来我改成先过滤再关联:
sql复制select u.id, o.order_no
from (
select id
from user
where create_time > '2025-01-01'
) u
join `order` o on u.id = o.user_id;
效果立竿见影。为什么?因为逻辑执行顺序中,JOIN 阶段要拿到的“中间表”越小,后面的 WHERE、GROUP BY 压力就越小。虽然优化器不一定需要你手动缩表,但在它失灵的时候,手动按执行顺序重写查询是特别有效的兜底方案。
5.2 案例二:HAVING 里做行级过滤,导致分组结果远超预期
有时候慢查询不是慢在表扫描,而是慢在分组阶段处理了太多本来可以被提前过滤掉的行。
举个例子:
sql复制select user_id, count(*)
from `order`
group by user_id
having status = 'paid' and count(*) > 5;
这条 SQL 问题很大。因为 status 是订单行上的字段,不是分组结果上的聚合值。把它写进 HAVING,逻辑上虽然不会报错,但数据库已经对所有订单做了全量分组,然后才在组级别去检查 status。这种写法在数据量大时非常糟糕。
正确写法是:
sql复制select user_id, count(*)
from `order`
where status = 'paid'
group by user_id
having count(*) > 5;
把 status = 'paid' 放到 WHERE,意味着订单表里大量未支付订单在进入分组之前就被过滤掉了。这两条 SQL 的结果在大多数情况下相同,但执行路径完全不同。这就是执行顺序对性能最直接的影响:你想让哪个阶段先做,就把过滤条件写在哪。
5.3 案例三:DISTINCT 加上 ORDER BY,触发了临时文件排序
DISTINCT 本质上是去重,MySQL 和 SQL Server 都可能会通过分组或排序来实现。如果你在一条查询里既写了 DISTINCT,又写了对一个大字段的 ORDER BY,数据库往往需要把中间结果放到临时文件里排序,性能会急转直下。
比如:
sql复制select distinct user_id, remark
from user_remark
order by remark desc;
这里 remark 是一个很长的文本字段,DISTINCT 要对 (user_id, remark) 做去重,ORDER BY 又要按 remark 排序,几乎不可避免地产生临时表。我遇过一条线上慢查询,就是这种写法导致执行耗时从几十毫秒飙到几秒。
优化思路有几种:
- 减少 SELECT 返回列,别把大字段带进 DISTINCT。
- 如果业务上只需要按
user_id去重,就不要把remark也放进 SELECT 里,改成子查询关联。 - 排序字段尽量走索引,比如
order by id可能会比order by remark好不少。
说到底,DISTINCT 和 ORDER BY 都靠后执行,中间结果越大,瓶颈越明显。你在写 SQL 时,如果知道这一步排在后面,就会下意识去压缩上一步的结果集。
5.4 执行计划顺藤摸瓜:用“每个阶段发生了什么”定位瓶颈
慢 SQL 优化到底怎么结合执行顺序?我一般分三步:
- 看执行计划里哪个算子耗时最高,是扫描、关联、排序还是聚合。
- 对照逻辑顺序,判断这个算子处于哪个阶段。
- 尝试把过滤、投影条件往前移,尽量缩小上一个阶段的输出。
这个方法比单纯背索引规则好用得多。比如 MySQL 的 EXPLAIN 返回结果中,type 是 ALL 表示全表扫描,key 是 NULL 表示没用索引;SQL Server 的图形执行计划里如果出现大量 Table Scan、Sort,也说明卡在靠前的读取阶段或靠后的排序阶段。当你把执行顺序和计划对应起来,很多慢 SQL 的原因一眼就能看出来。
6. 面试与记忆:三分钟把执行顺序刻进脑子
6.1 手写执行顺序,最常见的错误只有一个
面试时我常让候选人在白板上写一条查询的执行顺序。最常出现的错误,就是把 SELECT 放在第一位,或者把 HAVING 写到 GROUP BY 前面。前者是完全没理解逻辑顺序,后者是混淆了 WHERE 和 HAVING。
其实你只要记住一个核心,就能避免 90% 的错误:SELECT 是倒数第三位左右才出场,它的前面是 FROM、WHERE、GROUP BY、HAVING,它的后面才是 DISTINCT、ORDER BY、LIMIT。换一句更直白的话:数据是在前面被过滤和分组的,最后才被“选中”。
6.2 一个可以救急的口诀
比起死记硬背,我更推荐把执行顺序想成一条生活流水线,比如组织一场有限的聚会:
- FROM 是“先看人从哪里来”,先拿到全体参与者名单。
- JOIN 是让两拨人按规则坐在一起。
- WHERE 是门口保安,第一轮筛选,不符合条件直接走人。
- GROUP BY 是大家按喜好站队分组。
- HAVING 是组长统计,整组不符合要求的再淘汰。
- SELECT 是最后站上领奖台,决定每个人手里拿什么。
- DISTINCT 是领奖时不允许重复领相同的奖品。
- ORDER BY 是按高矮排队拍照。
- LIMIT 是摄影师最多只拍几个人。
这个比喻可能不够严谨,但真的很好用。我在培训时用过很多次,几乎没有学员记反过。
6.3 两道可以直接拿去用的面试题
第一题:“请写出 select * from t where a = 1 group by b having count(*) > 2 order by c limit 10 的执行顺序。”
答题思路:先 FROM t 确定表,再 WHERE 过滤 a=1 的行,再 GROUP BY b 分组,再 HAVING 过滤组,然后 SELECT 出需要字段,最后 ORDER BY c 排序,再 LIMIT 10。如果 SELECT 里出现别名,也要说明别名是在 GROUP BY 之后、ORDER BY 之前生成。
第二题:“为什么 WHERE 语句中不能使用聚合函数?”
答题思路:因为聚合函数要基于整组数据计算,而 WHERE 阶段发生在 GROUP BY 之前,此时还没有分组,行也没有合并到一起,所以根本无法计算 count(*) 或 sum(amount)。当你把聚合条件写进 HAVING,它才有机会在分组后对每一组进行判断。
这两道题看着简单,背后其实把执行顺序、分组聚合、过滤时机都串起来了。能把它们讲明白,面试官基本就会认为你对 SQL 有比较扎实的理解。
最后再分享一个我实际排查 SQL 时的小习惯:遇到结果不对、性能可疑的查询,我从来不会盯着整条 SQL 硬看,而是手动把它按逻辑执行顺序拆成几个子查询,一段一段去验证中间结果。先看 FROM 和 JOIN 拼出来的数据量对不对,再看 WHERE 过滤后的行数是否符合预期,接着看分组聚合结果,最后才看排序和 limit。这么做看起来多写了几条临时查询,实际上定位问题比对着一条长 SQL 干想要快得多。执行顺序这个知识点,平时不显山不露水,但它一直在背后决定着你写的每一行 SQL 能不能跑得又快又对。
