做后端开发的,十有八九都在LEFT JOIN上栽过跟头。尤其是LEFT JOIN里ON和WHERE的区别,看着只是条件放哪的问题,运行结果却能差出十万八千里。我印象最深的一次,是排查一张店铺销售报表,数据怎么对都对不上,最后发现只是把右表的过滤条件写到了WHERE里——明明想保留所有店铺,结果没销量的店铺全被过滤掉了。今天就把这个经典问题彻底捋清楚。这篇文章适合刚接触多表查询的新手,也适合时不时在SQL边缘翻车的开发,我会从执行顺序、结果集差异、执行计划三个层面拆解,再配上可直接运行的例子和踩坑清单,尽量让你以后再遇到JOIN的条件,一眼就能判断该放哪。
1. 先搞清楚一段SQL到底怎么执行的
1.1 逻辑执行顺序:ON比WHERE“先行一步”
SQL的“执行顺序”很多人理解得不对。这里说的不是物理执行顺序,而是逻辑执行顺序——也就是SQL语义上各个子句生效的先后次序。你可以把一条查询想象成流水线:
| 顺序 | 子句阶段 | 作用 |
|---|---|---|
| 1 | FROM | 确定数据源,也就是左表右表 |
| 2 | ON | 执行两表连接,逐行判断连接条件 |
| 3 | JOIN | 根据ON的结果生成连接结果集 |
| 4 | WHERE | 对连接后的整张结果集做过滤 |
| 5 | GROUP BY | 分组 |
| 6 | HAVING | 对分组结果过滤 |
| 7 | SELECT | 投影需要的列 |
| 8 | ORDER BY | 排序 |
| 9 | LIMIT | 分页 |
关键点就在第2步和第4步:ON是连接阶段的条件,WHERE是连接完成之后的过滤条件。对于INNER JOIN来说,这两个阶段做完之后的结果往往一致,所以很多人没察觉出区别。但在LEFT JOIN里,ON发生在“保留左表行”这个语义之前吗?不是,ON作为连接条件,直接影响的是“右表能不能匹配上”,而LEFT JOIN的逻辑是先取左表全部行,再尝试按ON条件去右表找配对的记录——找到就拼接,找不到就把右表列置成NULL。
所以ON和WHERE本质上是两个不同时间点、两个不同作用对象、两种不同语义的操作。理解了这个先后顺序,再看下面所有案例都会很顺。
1.2 LEFT JOIN的保留行语义:理解NULL是钥匙
LEFT JOIN的全称是LEFT OUTER JOIN,核心语义是“左表的每一行都必须出现在结果集里”。哪怕右表一条匹配记录也没有,左表的行也要留下,右表列用NULL填充。
我把这个语义看成“关联查询的底气”:左表是主角,右表是配角。ON条件决定配角以什么标准参与进来,WHERE条件决定最终出镜的人选。
举个例子,假设有一张学生表和一张成绩表。你想知道“每个学生的所有成绩”,这典型是LEFT JOIN场景。学生表里有个学生叫张三,成绩表里一条记录都没有。LEFT JOIN能让你看到张三这行,成绩列显示NULL。这个NULL恰恰是最有价值的信息——它告诉你“张三没考过试”。
但如果同样的过滤条件放到WHERE里,比如WHERE score IS NOT NULL,或者WHERE score > 60,那张三这一行就会被直接删掉。从结果看,LEFT JOIN就被“打回原形”,变成了INNER JOIN。这不是MySQL的Bug,而是SQL语义本来就如此:WHERE是对整个结果集出手,它不管你是左表行还是右表列,不满足条件就整行剔除。
我见过不少人把LEFT JOIN的结果凭空少了几行,第一反应是“JOIN写错了”或者“数据丢了”,其实都不是,就是WHERE把左表的保留行给过滤了。理解NULL的语义,是理解今天所有内容的基础。
2. 一个例子让ON和WHERE的差异无处可藏
2.1 准备数据:店铺表和销售表
光讲理论太空了,我们用一个业务场景把两种写法跑一遍。假设现在有两张表:
CREATE TABLE shop ( id INT PRIMARY KEY, name VARCHAR(50) ); CREATE TABLE sale ( id INT PRIMARY KEY, shop_id INT, amount DECIMAL(10,2), status TINYINT COMMENT '1=正常 2=已撤销' ); INSERT INTO shop (id, name) VALUES (1, '街角咖啡店'), (2, '中心书店'), (3, '周末花店'); INSERT INTO sale (id, shop_id, amount, status) VALUES (1, 1, 120.00, 1), (2, 1, 80.00, 2), (3, 2, 300.00, 1);注意:第3家店铺“周末花店”在sale表里一条记录都没有。这个数据设计是有意的,就是为了让你看清LEFT JOIN在右表无匹配时的表现。
需求是什么?“列出每家店铺的销售记录,但要过滤掉已撤销的单子”。看起来很简单的需求,ON和WHERE两种写法结果却完全不同。
2.2 条件放ON里:左表一个不少
第一种写法,把过滤条件放在ON后面:
SELECT s.name, sa.amount, sa.status FROM shop s LEFT JOIN sale sa ON sa.shop_id = s.id AND sa.status = 1;执行结果如下:
| name | amount | status |
|---|---|---|
| 街角咖啡店 | 120.00 | 1 |
| 街角咖啡店 | NULL | NULL |
| 中心书店 | 300.00 | 1 |
| 周末花店 | NULL | NULL |
仔细看,结果集里出现了两行“街角咖啡店”。因为咖啡馆有两笔销售,一笔status=1正常,一笔status=2已撤销。ON条件sa.status = 1在连接阶段就把撤销的那笔排除掉了,但“街角咖啡店”这个左表行依然保留了,所以撤销的那笔就变成了右表字段为NULL的一行。
“周末花店”也保留了,同样右表字段为NULL。
这就是ON的行为:它控制的是“右表以什么标准来配对”,不控制“左表是否出现”。
2.3 条件放WHERE里:LEFT JOIN悄悄变成INNER JOIN
第二种写法,把同样的过滤条件放到WHERE:
SELECT s.name, sa.amount, sa.status FROM shop s LEFT JOIN sale sa ON sa.shop_id = s.id WHERE sa.status = 1;执行结果如下:
| name | amount | status |
|---|---|---|
| 街角咖啡店 | 120.00 | 1 |
| 中心书店 | 300.00 | 1 |
看到了吗?“周末花店”没了,“街角咖啡店”的撤销记录也没了。为什么会这样?
LEFT JOIN先执行,把所有店铺跟销售记录连接,得到一张含NULL的临时表,然后WHEREsa.status = 1对这张临时表做过滤。只要不满足条件,整行删除。
“周末花店”因为没有销售记录,连接后sa.status是NULL,NULL = 1 的结果是“不是真”,所以被删了。“街角咖啡店”的撤销记录 status = 2,不等于1,也被删了。
表面上看,WHERE写法得到的结果更像我们日常想要的“有效销售记录”,但它已经违背了“列出每家店铺”的初衷。如果业务报表要的是“每个店铺都有记录,没销售就显示0”,这个写法就彻底错了。
这两种写法最大的差别就一句话:ON不删左表行,WHERE会删左表行。
2.4 那ON里过滤左表字段呢
有人会问:“如果我在ON里写左表的条件,会怎样?比如ON sa.shop_id = s.id AND s.id = 2。”这也是一个经常试出来的疑问。
答案可能出乎意料:左表行依然会全部保留。看这个查询:
SELECT s.name, sa.amount FROM shop s LEFT JOIN sale sa ON sa.shop_id = s.id AND s.id = 2;结果是:
| name | amount |
|---|---|
| 街角咖啡店 | NULL |
| 中心书店 | 300.00 |
| 周末花店 | NULL |
这说明了ON条件虽然在连接阶段生效,但它并不能把左表行“过滤掉”。对左表来说,ON里的条件只影响“这一行左表数据能不能找到匹配的右表记录”,即使不满足连接条件,左表行还是会在结果集中,只是右表列变成NULL。
如果你真的只想保留s.id = 2这家店,正确做法是把条件放到WHERE里:
SELECT s.name, sa.amount FROM shop s LEFT JOIN sale sa ON sa.shop_id = s.id WHERE s.id = 2;这里又有一个小坑留在后面讲——对左表字段的条件放ON里和WHERE里,一个保留全表,一个过滤行,差别同样巨大。
3. 深入到执行细节:优化器到底在干嘛
3.1 EXPLAIN看执行计划
光看结果集就够写出正确答案了,但想真正理解“为什么MySQL会这样做”,还得看执行计划。用EXPLAIN能看到优化器如何处理JOIN:
EXPLAIN SELECT s.name, sa.amount, sa.status FROM shop s LEFT JOIN sale sa ON sa.shop_id = s.id AND sa.status = 1; EXPLAIN SELECT s.name, sa.amount, sa.status FROM shop s LEFT JOIN sale sa ON sa.shop_id = s.id WHERE sa.status = 1;两种写法的执行计划大概率不一样。区别通常出现在驱动表和访问类型上:
- ON条件版本:shop是驱动表,sale是被驱动表,sale的访问方式通常通过
shop_id走索引,过滤条件 status 可以作为连接过程中的附加条件。 - WHERE条件版本:优化器看到WHERE里对右表字段
sa.status有过滤,会判断“这个过滤能剔除所有右表为NULL的行吗?如果能,LEFT JOIN就可以安全地改写成INNER JOIN”。改写后驱动表可能就不再是shop了,优化器会基于估算成本重新选择驱动表和连接顺序。
从执行计划看,WHERE版本常常出现“shop表不再是驱动表”或者“sale表反客为主”的情况。这对性能不一定是坏事,但意味着结果集语义已经变了。
不要指望“物理执行顺序和逻辑顺序完全一致”——优化器会做各种改写,比如把条件下的推到表连接前、把子查询物化、把ORDER BY优化掉。但有一条铁律:优化器的任何改写都不能改变SQL语义。WHERE过滤右表列会导致LEFT JOIN变成INNER JOIN,这是SQL语义本身推导出来的,不是优化器的“私有行为”。既然语义上结果就应该等同于INNER JOIN,优化器自然可以放心改写。
3.2 左连接被改写成内连接的真相
数据库优化器有一条“外连接消除”规则,专业说法叫Outer Join Elimination。它适用的条件是:外连接的结果集可以证明“不会产生NULL扩展行”,此时外连接和内连接在语义上等价。
放到LEFT JOIN场景里,如果在WHERE条件中出现了被驱动表(右表)的列上有“非NULL且能筛选掉NULL行”的条件,比如WHERE sa.status = 1、WHERE sa.id IS NOT NULL,优化器就会认为结果集里不可能出现右表为NULL的行,于是把LEFT JOIN改写成INNER JOIN。
这解释了为什么很多人写出的SQL感觉“JOIN没生效”:
SELECT ... FROM shop s LEFT JOIN sale sa ON sa.shop_id = s.id WHERE sa.amount > 100;表面上是LEFT JOIN,实际执行计划查出来是INNER JOIN。一旦优化器做了这个改写,shop表里那些没有金额>100记录的行自然就不会在结果集里了。
理解了这条规则,你就掌握了一种预判能力:只要WHERE里写了右表列的条件,就要带着警惕心去看结果。你心里应该马上冒出一个念头——我这个LEFT JOIN还保得住左表全量吗?
3.3 用子查询兜底的特殊场景
业务上经常碰到“要左表全量,但右表只取满足条件的记录”的需求。前面说了,把条件放ON里能满足。但还有一种常见写法是把右表处理成子查询:
SELECT s.name, t.amount, t.status FROM shop s LEFT JOIN ( SELECT id, shop_id, amount, status FROM sale WHERE status = 1 ) t ON t.shop_id = s.id;这种写法和ON条件版本的结果是等价的:左表全保留,右表只有status=1的记录参与连接。
两种写法怎么选?我的经验是:
- 条件简单、就一两个过滤项,直接写在ON里,清晰直接。
- 条件复杂,比如右表要聚合、要DISTINCT、要按子查询逻辑多步处理,那就把子查询拆出来,可读性更好,也方便复用。
- 子查询有一个取舍:MySQL会物化子查询,在一些版本中物化后的表无法有效利用索引连接(5.7、8.0的优化有不同表现),如果右表数据量大又频繁查询,ON写法通常更友好。但整体来说维护可读性优先,性能瓶颈真正出现时再做改造也不迟。
子查询不是银弹,但它是一个很好的“语义显式化”手段——你可以把过滤、聚合逻辑从JOIN条件里剥出去,让主查询的连接条件回归到“纯外键关联”这一件事上。
4. 工作中的常见坑与排错清单
4.1 坑一:WHERE里对右表字段做 IS NULL 判断
这是LEFT JOIN最经典的进阶操作:右表没匹配上的行,右表列会是NULL,所以想“找出没有销售记录的店铺”,常常有人直接写WHERE sa.id IS NULL:
SELECT s.name FROM shop s LEFT JOIN sale sa ON sa.shop_id = s.id WHERE sa.id IS NULL;这个写法能查出“周末花店”,因为它没有销售记录,连接后sa.id为NULL。这是合理的用法,也是IS NULL在LEFT JOIN中少数的“合法场景”。
但坑在于,如果你同时过滤了右表的其他字段,结果就会变得非常绕。比如:
SELECT s.name FROM shop s LEFT JOIN sale sa ON sa.shop_id = s.id WHERE sa.status = 2 OR sa.id IS NULL;这个逻辑是想找“有撤销单或无单的店铺”。看起来没问题,实际上OR条件导致结果里出现了大量右表为NULL的行。因为sa.id IS NULL对每一行做判断时,会把所有右表没有匹配上的店铺都捞出来,可能把正常有销售的店铺也带进来了(比如某店铺有一条正常单,也有一条撤销单,正常单那行因为OR条件也被选中)。
处理这种复杂逻辑,最好的办法是先想清楚“我要的是哪些行”,写成精确的集合逻辑,而不是把条件堆在一起。比如用NOT EXISTS写“未匹配”逻辑,往往比LEFT JOIN + IS NULL更直白:
SELECT s.name FROM shop s WHERE NOT EXISTS ( SELECT 1 FROM sale sa WHERE sa.shop_id = s.id );两种方式执行计划可能不同,但语义上NOT EXISTS的意图更明确,不容易踩到OR条件扩展结果集这种坑。
4.2 坑二:连接字段或过滤字段类型不一致导致索引失效
这个坑和ON、WHERE的核心问题不直接相关,但排查LEFT JOIN慢查询时经常遇到——连接字段类型不一致。比如shop.id是INT,sale.shop_id是VARCHAR,MySQL会做隐式转换,索引大概率用不上。我们做排查时,EXPLAIN一看type=ALL就明白怎么回事了。
还有过滤字段在WHERE里被函数包裹的情况,比如WHERE DATE(sa.create_time) = '2024-01-01',也会导致索引失效。这不是ON、WHERE的语义问题,但会直接影响性能表现。
排查LEFT JOIN慢查询,我的顺序一般是:
- EXPLAIN看连接顺序和type字段,找ALL、index全扫。
- 看连接字段和过滤字段是否有索引、类型是否一致。
- 看是否因为WHERE过滤右表列导致优化器改写了LEFT JOIN,意外改变了执行计划。
- 如果排除了前三条,再考虑数据分布问题和统计信息是否需要更新。
4.3 快速决策:什么时候放ON,什么时候放WHERE
写多了以后,我总结了一个足够应付绝大多数场景的决策流程:
- 想保留左表所有行,用LEFT JOIN,凡是针对右表记录的过滤条件(如只统计状态正常的、只统计今年创建的),统一放ON。
- 想对连接结果整体做筛选,比如“只输出有销售记录的店铺”“只要今天下过单的用户”,把过滤条件放WHERE。
- 想找右表不存在匹配记录的行,优先用NOT EXISTS,或者LEFT JOIN + 右表主键IS NULL。
- 主表自己字段的单表过滤,放WHERE,不要放ON——放ON里虽然左表行还会留下,但很容易造成“明明写了条件却感觉没生效”的困惑。
- 右表有多个过滤条件,且条件之间是AND关系,放ON里;如果条件之间是OR,放在ON里可能导致右表匹配扩大,要重新审视有没有过度匹配。
上面第5条值得展开说。ON条件里WHERE t.A = 1 OR t.B = 2这类写法很危险,因为OR条件一旦成立,右表就会以“不是预期的方式”参与连接,产生多对多匹配,结果集膨胀得很离谱。碰到OR,一般建议拆成两条SQL,或者用UNION合并,别硬塞在ON后面。
4.4 一张速查表:ON和WHERE的语义对照
| 对比项 | ON条件 | WHERE条件 |
|---|---|---|
| 作用时机 | 连接阶段 | 连接完成后 |
| 对左表行的影响 | 保留左表全量,即使不匹配也补NULL | 不满足条件整行删除 |
| 对右表行的影响 | 决定哪些行可以匹配;不满足则左表补NULL | 直接过滤连接结果中的行 |
| 语义关键词 | 连接配对条件 | 最终过滤条件 |
| 左表无匹配时 | 左表照常出现,右表列NULL | 如果右表列条件参与判断,可能删除该行 |
| INNER JOIN下 | 和WHERE结果一样 | 和ON结果一样 |
| LEFT JOIN下 | 保留左表全量 | 可能把LEFT JOIN变成INNER JOIN |
| 典型场景 | 清单列表、主表明细、聚合前过滤明细 | 报表筛选、只显示有业务数据的行 |
这张表可以直接拿给新人看,比空讲理论容易懂。但请注意,这些结论都围绕“同一张表、同一字段”的前提下讨论,如果条件设计特别复杂,比如ON里同时涉及左表和右表字段的多个逻辑判断、WHERE里又对多个表字段做组合,还是要靠执行计划验证结果。
5. 我踩过之后的几条实操建议
5.1 写SQL前先分清“左表保留”还是“结果集过滤”
我后来养成了一个习惯,动手写SQL之前先问自己一个问题:这个查询的“主语”是谁?
如果主语是“店铺”“用户”“订单”这种主表主体,要求的是“每个XX都出现”,多半要用LEFT JOIN,并且右表的过滤条件要放在ON里,或者放进子查询。
如果主语是“销售记录”“行为数据”这种从明细视角出发的查询,本身就不存在“保留主体”的诉求,那直接用INNER JOIN更合适,也不必纠结ON和WHERE的差别。
很多写错ON、WHERE的案例,根源不是没理解语法,而是没想清楚查询的视角。视角错了,语法再对都没有意义。
5.2 复杂查询写成中间步骤,别一把梭
我见过有人一上来就写一个四表JOIN,七个条件全塞在ON和WHERE里,出错后完全没法排查。
正确做法是先拆:
- 先把需要保留的主表查出来,确认主表行数和关键字段。
- 把明细表的过滤条件处理好,无论是放子查询还是物化临时结果。
- 再JOIN,并对结果集数量级做预估。
- 最后把条件逐步加回去,每加一层条件,都检查一次行数变化。
这个过程听起来慢,但实际排查效率比“一把梭”高太多。尤其当你面对的是线上慢查询、数据对不齐这种问题,拆开之后往往一眼就能看到是哪个条件导致的过滤。
5.3 多表关联时给表起短别名,SQL靠可读性吃饭
这是一个小细节,但真的很影响排查效率。我见过t1.id = t2.user_id AND t2.status = 1 AND t3.deleted = 0这种SQL,表名还是超过20个字母的长名,一眼根本看不出谁是谁。
给表起业务相关的短别名,比如shop用s,sale用sa,user用u,order_item用oi。别用t1、t2、t3这种无意义别名。排查ON和WHERE条件时,能快速定位条件属于哪个表,会节省大量时间。
5.4 定期给团队做一次SQL Review,把典型问题固化到规范里
团队里每个写SQL的人都有概率掉进ON和WHERE的坑,而且每个人掉的姿势还不一样。我发现最有效的办法不是靠个人记忆,而是把典型问题写进团队的SQL开发规范里,配一两个正反例子。比如:
规范条目:LEFT JOIN场景下,右表字段的过滤条件必须放在ON中,除非业务明确要求过滤掉左表中右表无匹配的行。
配例子:
-- 错误:过滤右表status导致左表店铺丢失 SELECT s.* FROM shop s LEFT JOIN sale sa ON sa.shop_id = s.id WHERE sa.status = 1; -- 正确:右表过滤放ON,保留所有店铺 SELECT s.* FROM shop s LEFT JOIN sale sa ON sa.shop_id = s.id AND sa.status = 1;这种规范,新同学看一遍就能避开最典型的坑。老同学在排查时也可以直接对照规范,减少无谓的争论。
结尾
自己跟SQL打了这么多年交道,最大的感受是:ON和WHERE的区别其实一句话就能讲完——ON是连接条件,WHERE是结果集过滤条件。但在LEFT JOIN面前,这句话被放大成了一连串的语义差异、执行计划差异和数据结果差异。
我个人写SQL的习惯是:哪怕已经很清楚规则,写完JOIN之后还是会跑一下EXPLAIN,确认执行计划和我预想的一致。特别是当WHERE里出现右表字段条件时,会强制自己多看一眼结果集数量。另外,如果某条SQL的结果集行数跟我预期差了很多,优先怀疑的条件就是“过滤条件是不是放错了位置”,而不是先去怀疑数据有问题。
最后送一个我很早学到的小技巧:调试JOIN问题时,把SELECT *放到最前面,去掉所有WHERE和GROUP BY,先看“未过滤的全貌”是什么——左表全量在不在、右表NULL行长什么样,确认了这两个关键点,再决定条件放ON还是WHERE。这条经验帮我省下了很多排查时间,希望对你有用。