大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。
有段时间我写 SQL,总以为数据库是按我写的顺序从上往下跑的。写什么,它就按什么顺序执行。结果一个 LEFT JOIN 的过滤条件,被我写进了 WHERE。跑完一看,左边表里没配对的行全没了。看着像 LEFT JOIN,结果出来的跟 INNER JOIN 一模一样。更气人的是,它不报错。那次之后我才搞清楚,SQL 的书写顺序和执行顺序,是两码事。搞混了,语法上挑不出毛病,结果就是不对。这种错最难查,只能拿数据一行行对。
真实的执行顺序
数据库跑一条 SQL,不是从上往下读。它按一个固定的逻辑顺序走,分阶段处理。
FROM → ON → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
每一步的输出,是下一步的输入。这个顺序决定了你能在哪个阶段用什么东西,也决定了条件放哪个位置。最关键的几个分水岭。ON 和 JOIN 在 WHERE 之前,所以写在 ON 里的条件影响的是连接时的保留规则,而写在 WHERE 里的条件,是在连接完成后对结果再做过滤。SELECT 在 WHERE 之后,所以别名在 WHERE 里还不存在。HAVING 在 GROUP BY 之后,过滤的是聚合后的结果。顺序不用死记。后面两个翻车现场,一看就知道错在哪一步。
翻车一:LEFT JOIN 变成 INNER JOIN
来看我踩的那个坑。一张用户表,一张订单表。我想查所有用户以及他们状态为 1 的订单,没订单的也要留着。
SELECT u.user_name, o.order_no, o.amount
FROM t_user u
LEFT JOIN t_order o ON u.user_id = o.user_id
WHERE o.status = 1;
看着没问题。但跑出来的结果里,那些还没下过单的用户,一条都没了。为什么。LEFT JOIN 的语义是:左边表的所有行都保留,右边表匹配不到的行补 NULL。这一步做完,新用户的订单字段全是 NULL。然后轮到 WHERE,o.status = 1 这个条件一过滤,NULL = 1 为假,新用户的行就被扔掉了。连接阶段保住的行,在 WHERE 阶段又丢了。结果就是 LEFT JOIN 被 WHERE 条件吃掉了外层,退化成 INNER JOIN。
正确的写法,是把右边表的过滤移到 ON 里。
SELECT u.user_name, o.order_no, o.amount
FROM t_user u
LEFT JOIN t_order o ON u.user_id = o.user_id AND o.status = 1;
ON 里的条件在连接时就生效,o.status = 1 作为连接条件的一部分,不会把左表未匹配的行筛掉。来对比一下两种写法的结果差异,数值是示意,实机跑一遍更准。
CREATE TABLE t_user (user_id INT PRIMARY KEY, user_name VARCHAR(20));
CREATE TABLE t_order (order_id INT PRIMARY KEY, user_id INT, status INT, order_no VARCHAR(20), amount DECIMAL(10,2));
INSERT INTO t_user VALUES (1,'Alice'),(2,'Bob'),(3,'Carol');
INSERT INTO t_order VALUES (101,1,1,'ORD001',50.00),(102,1,2,'ORD002',30.00);
-- Carol 没有订单
| 写法 | 返回行数 | Carol 在结果里吗 |
|---|---|---|
| WHERE o.status = 1 | 1 行(Alice 的 ORD001) | 不在 |
| ON ... AND o.status = 1 | 3 行(Alice 一条、Bob 一条补 NULL、Carol 一条补 NULL) | 在 |
翻车二:WHERE 里用不了别名
另一个坑,出在别名上。我想查总金额大于 100 的明细。
SELECT user_id, amount * quantity AS total
FROM t_item
WHERE total > 100;
-- ERROR 1054: Unknown column 'total' in 'WHERE'
报错说列不存在。但 total 明明在 SELECT 里定义了。原因在顺序。WHERE 在 SELECT 之前执行。数据库跑到 WHERE 那一步时,total 这个别名还没诞生,它不知道你在指什么。解决办法有三个,一是重复表达式。
SELECT user_id, amount * quantity AS total
FROM t_item
WHERE amount * quantity > 100;
二是用子查询,把别名提出来。
SELECT * FROM (
SELECT user_id, amount * quantity AS total
FROM t_item
) t WHERE t.total > 100;
三,在 MySQL 里可以用 HAVING 代替 WHERE 来过滤别名,因为 HAVING 在 SELECT 之后。
SELECT user_id, amount * quantity AS total
FROM t_item
HAVING total > 100;
但要注意,HAVING 是过滤聚合结果的,拿来当 WHERE 用虽然 MySQL 能跑,但可读性差,而且可能影响优化器的走法。能用 WHERE 就用 WHERE,HAVING 留给聚合场景。
逻辑顺序与执行计划,不是一回事
上面说的是逻辑顺序,SQL 语义上的阶段顺序。但优化器在真正执行时,可以重排。比如一个 WHERE 过滤条件,优化器可能把它"下推"到表扫描阶段,先在扫描时就过滤掉大量不匹配的行,再做 JOIN。比如 EXPLAIN 里看到 rows 很少,说明下推生效了。比如多个表的 JOIN 顺序,优化器可能把小表当驱动表,而不是你 SQL 里写的顺序。
这些重排是性能层面的,不影响语义结果。优化器保证重排前后的结果一样,只是在更快。所以 EXPLAIN 看的不是逻辑顺序,是优化器优化后的物理执行路径。两者都对:逻辑顺序决定你的语句对不对,执行计划决定它快不快。写 SQL 时想的是逻辑顺序,调性能时才去看执行计划。
写 SQL 时,条件到底该放哪
摸清规律,就几条判定规则。OUTER JOIN(LEFT / RIGHT)时,右边表的过滤条件,优先放 ON。放在 WHERE 里,会把外层吃掉。除非你的本意就是只保留匹配的行——那直接写 INNER JOIN 就行,别用 LEFT JOIN 再在 WHERE 里过滤。INNER JOIN 时,ON 和 WHERE 放哪都行,结果一样。但从可读性出发,连接条件放 ON,结果过滤放 WHERE,分工清楚。
聚合过滤,WHERE 过滤原始行,HAVING 过滤聚合结果。别把 SUM()、COUNT() 塞进 WHERE。
别名只在 SELECT 之后可用。ORDER BY 和 HAVING 里可以用别名,WHERE 里不行。
判断力
SQL 最难的地方,不是语法多复杂,是写的人不知道数据库在后台按什么顺序跑。书写顺序是给人看的,执行顺序是给机器跑的。把两者搞混,就会写出"看着对、结果错"的语句。最可怕的是它不报错,你只能靠对照数据才能发现不对。
避坑清单
OUTER JOIN 的过滤条件放 ON,别放 WHERE。放 WHERE 里会把外层吃掉,LEFT JOIN 变 INNER JOIN,不报错,结果直接错。
别名只属于 SELECT 之后的阶段。WHERE 里引用别名会报错,HAVING 里能用但别当 WHERE 替代方案用。
最后一条是我自己踩的。我一直以为 SQL 是按书写顺序跑的,直到 LEFT JOIN 的空行全没,才去查了这个顺序。现在每写一条带 JOIN 的 SQL,我都会问自己一遍:这个条件,是在连接之前还是之后生效?问完再落笔,比跑完再看结果省事得多。
写在最后
写 SQL 久了会发现,语法只是表层。真正决定结果对不对的,是你脑子里那条流水线。数据从 FROM 进来,经过 ON、WHERE、GROUP BY,每一步之后数据长什么样,别名到哪一步才存在,你得心里有数。数据库只负责跑,不会提醒你顺序想错了。下笔前先在脑子里过一遍,很多错根本不会发生。
你写 SQL 时,条件放 ON 还是 WHERE 是怎么判断的?有没有因为放错位置翻过车?评论区聊聊。
我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋