SQL中ON与WHERE在JOIN里的本质区别:从内连接到外连接的执行逻辑解析
发布时间:2026/9/28 13:19:23来源:尧图网络
刚开始学 SQL 的时候我对 JOIN 后面的 ON 和 WHERE 几乎没怎么区分过。遇到 LEFT JOIN 关联两张表某个字段要过滤随手就写进 WHERE遇到 INNER JOIN更是觉得写哪儿都行。直到某一次给业务方拉用户订单报表LEFT JOIN 订单表后把“订单状态1”写进了 WHERE结果没下过单的用户整行消失报表总数怎么都对不上。从那以后我才彻底搞清楚ON 和 WHERE 在 JOIN 里的执行逻辑根本不是一回事谁在前谁在后不仅影响结果行数还直接影响查询语义。这篇文章想解决的问题很简单把 ON 和 WHERE 在内连接、外连接里各自扮演的角色讲透。适合刚入行的开发、经常写报表的数据分析师以及所有在面试里被这个问题问倒过的人。只要你手头有 MySQL、PostgreSQL、SQL Server 或 SQLite 任意一种数据库都可以跟着文中的例子自己跑一遍跑完你就能秒懂为什么“同一个条件写在 ON 和 WHERE 里结果不一样”。1. 这个知识点为什么是 SQL 新手的第一道坎1.1 表面等价带来的错觉很多人第一次接触 INNER JOIN 时都会做这样的实验-- 写法 A SELECT * FROM users u INNER JOIN orders o ON u.id o.user_id WHERE o.status 1; -- 写法 B SELECT * FROM users u INNER JOIN orders o ON u.id o.user_id AND o.status 1;写完一跑两个查询返回的行数一模一样于是大脑很自然地下结论“ON 和 WHERE 可以互换”。这一步没错但它只在 INNER JOIN 下成立。一旦换成 LEFT JOIN、RIGHT JOIN、FULL OUTER JOIN这个结论立刻崩溃。很多人没意识到自己只是被“内连接下行为一致”这个特例给带偏了等到写外连接报表时才踩坑。理解这个问题的关键不是背“内连接一样、外连接不一样”的结论而是弄明白数据库执行 JOIN 时内部到底分几个阶段。1.2 JOIN 的执行过程其实可以拆成三步不管数据库优化器内部怎么改写一个 JOIN 查询在逻辑上都可以拆成三步第一步做笛卡尔积。把左表每一行和右表每一行配对形成一个巨大的中间集合。当然实际执行时优化器不会真的把笛卡尔积全建出来但逻辑上你可以这么理解。第二步用 ON 条件过滤这个配对集合。ON 里写了什么就相当于在这堆配对行里筛出满足条件的与 ON 条件不匹配的配对会在这一步被丢弃或做特殊处理具体取决于 JOIN 类型。第三步根据 JOIN 类型做行保留。INNER JOIN 直接扔掉不匹配的配对LEFT JOIN 则要把左表里没匹配上的行补回来右表字段填 NULLRIGHT JOIN 反之FULL OUTER JOIN 两边都会补。而 WHERE 呢它发生在“上面三步全部完成之后”。这个执行顺序就是问题的全部本质。ON 参与的阶段是在决定“哪两个表的行能配成一对”WHERE 参与的阶段是在已经配对完成、甚至已经补完 NULL 后的结果集上做最终筛选。位置不同作用对象不同自然结果就不同。2. 核心细节解析ON 与 WHERE 的语义边界2.1 ON 只负责“连接条件”它的作用域是两表配对过程ON 子句的职责非常聚焦描述左表和右表之间的行如何建立关联。它通常写等值条件比如u.id o.user_id但也可以写非等值条件比如u.id o.user_id甚至在支持复杂谓词的数据库里写范围、区间匹配。ON 里出现的条件天然带有“连接上下文”。它的判断对象是“左表某一行 右表某一行”这个组合。所以在 LEFT JOIN 里如果 ON 里加了右表的过滤条件比如LEFT JOIN orders o ON u.id o.user_id AND o.status 1它的含义是把“该用户与有效订单的配对”作为连接结果该用户即使没有有效订单也依然保留右表字段显示为 NULL。这句话非常关键它和下面这句有本质区别LEFT JOIN orders o ON u.id o.user_id WHERE o.status 1后面这个写法是先把所有用户和所有订单按用户 ID 配对配对完以后把右表订单状态不等于 1 的行全部删除。于是“没有订单的用户”会因为右表全是 NULL、NULL 1而被 WHERE 过滤掉彻底消失在结果里。一个是“保留所有用户能匹配到有效订单的带上订单信息”一个是“只保留有有效订单的用户”。语义完全不同行数自然不同。2.2 WHERE 的作用对象是“最终结果集”它不关心你是哪张表的行WHERE 子句的职责是对 JOIN 完成后的最终结果集进行过滤。在这个阶段左右两表的字段已经合成了一行WHERE 只看这一行的最终值。这就带来一个很多人忽视的细节WHERE 条件对 NULL 的判断要格外小心。外连接补出来的右表字段是 NULL如果你在 WHERE 里写o.status 1NULL 是永远不满足这个条件的因为这些行会被过滤掉。但如果你写o.status IS NULL反而能把“没匹配上的用户”筛出来。我在实际工作中经常看到这样的需求“我要查所有没有下过单的用户。”两种写法-- 写法 1LEFT JOIN WHERE IS NULL SELECT u.* FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.id IS NULL; -- 写法 2NOT EXISTS SELECT u.* FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id u.id );第一种写法利用的就是“LEFT JOIN 补 NULL WHERE 过滤”的组合逻辑。很多人能把这句背下来但不知道原理一旦条件写成WHERE o.status IS NULL而不是o.id IS NULL时又会掉进一个新的坑如果一个用户确实下过单但订单状态字段本身就是 NULL那这个用户也会被误判为“没下过单”。所以处理外连接时WHERE 右表主键 IS NULL通常比WHERE 某业务字段 IS NULL更安全因为主键不会因为数据质量问题出现 NULL除非你的表设计允许。2.3 内连接下为什么看起来没区别背后有等价改写逻辑回到文章最开始那个实验。INNER JOIN 下ON 和 WHERE 结果相同本质原因是内连接不涉及“补 NULL 行”这个动作。不匹配的配对行在 ON 阶段直接丢掉和先全部配对再在 WHERE 里过滤最终保留的行集合一致。这个等价关系在数据库优化器里叫“谓词下推Predicate Pushdown”优化器会把 WHERE 里能下推的条件推入 JOIN 的连接阶段从而提前过滤减少中间结果。但这里我要强调一个边界结果等价不等于写法等价更不等于性能等价。有一个常见的性能误区是在 INNER JOIN 里把过滤条件从 WHERE 挪到 ON数据库是不是就能更快这个要看优化器。大多数主流数据库的优化器都会自己下推条件写 WHERE 它一样能提前过滤。你自己手动“帮”优化器做调整有时反而会干扰执行计划。我在实际项目里见过有人为了让查询“更快”而把所有条件都塞进 ON结果执行计划反而变差还难以维护。所以对 INNER JOIN我个人的建议是按语义来写别按性能臆想来写。过滤业务数据的条件放 WHERE连接条件放 ON让优化器去做它该做的事。真正的性能优化要去看执行计划不是靠猜。3. 实操演示三种 JOIN 模式下 ON 与 WHERE 的真实差异3.1 先造一份可复现的测试数据理论说再多不如动手跑一遍。下面我用一组最小数据演示你可以在任何支持标准 SQL 的数据库里执行。这里以 MySQL 语法为例但 PostgreSQL、SQL Server 稍微改改引号类型都能跑。-- 用户表 CREATE TABLE users ( id INT PRIMARY KEY, name VARCHAR(20) ); -- 订单表 CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2), status TINYINT ); INSERT INTO users (id, name) VALUES (1, 张三), (2, 李四), (3, 王五); INSERT INTO orders (id, user_id, amount, status) VALUES (101, 1, 50.00, 1), (102, 1, 30.00, 0), (103, 2, 100.00, 1);数据很直观张三有两条订单一条有效一条无效李四有一条有效订单王五没有任何订单。3.2 场景一LEFT JOINON 里过滤右表先看经典差异-- SQL 1过滤条件写在 ON 里 SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount, o.status FROM users u LEFT JOIN orders o ON u.id o.user_id AND o.status 1 ORDER BY u.id;结果user_id | name | order_id | amount | status --------|------|----------|--------|------- 1 | 张三 | 101 | 50.00 | 1 2 | 李四 | 103 | 100.00 | 1 3 | 王五 | NULL | NULL | NULL注意看张三的那条 status0 的无效订单没有出现在结果里但张三本人还在王五一整行保留因为是 LEFT JOIN。这个结果的意思是所有用户保留符合条件的订单信息带上没有符合条件订单的用户在订单字段上显示 NULL。3.3 场景二LEFT JOIN过滤条件写在 WHERE 里-- SQL 2过滤条件写在 WHERE 里 SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount, o.status FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.status 1 ORDER BY u.id;结果user_id | name | order_id | amount | status --------|------|----------|--------|------- 1 | 张三 | 101 | 50.00 | 1 2 | 李四 | 103 | 100.00 | 1注意王五不见了。因为在 LEFT JOIN 完成之后王五的右表字段全是 NULLNULL 1为假整行被 WHERE 删掉。这个 SQL 的语义已经从“LEFT JOIN”悄悄退化成了“INNER JOIN”你写的是 LEFT JOIN结果跟 INNER JOIN 没有任何区别。这是工作中最隐蔽的一个坑表面看起来是外连接但因为 WHERE 过滤了右表字段行为已经变成了内连接。很多报表数据对不上就是这一行条件放错了位置。3.4 场景三RIGHT JOIN 和 FULL OUTER JOIN 的同理推演RIGHT JOIN 和 LEFT JOIN 对称只需要把“左表”和“右表”互换理解。把过滤条件写在 ON 里右表所有行会保留写在 WHERE 里过滤左表字段时会吃掉右表部分行。FULL OUTER JOIN 更复杂一点。MySQL 原生不支持 FULL OUTER JOIN但 PostgreSQL、SQL Server 支持。SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount, o.status FROM users u FULL OUTER JOIN orders o ON u.id o.user_id AND o.status 1 ORDER BY u.id;结果里会出现三种行匹配上的用户和订单只在左表出现的用户王五右表 NULL只在右表出现的订单记录如果有 user_id 在 users 表里不存在的订单。而如果过滤条件写在 WHERE 里只匹配上的行会留下FULL OUTER JOIN 同样退化成 INNER JOIN。这个推演过程看起来很机械但我建议你亲手把 SQL 1 和 SQL 2 跑完对比一下结果。真正运行一遍之后你对 ON 和 WHERE 的“阶段差”会有肌肉记忆而不是只靠脑内推演。4. 进阶用法多表 JOIN、跨库场景与性能血泪教训4.1 多表 LEFT JOIN 时条件位置会互相影响实际业务里报表很少只有两张表。最常见的模型是用户表 LEFT JOIN 订单表 LEFT JOIN 订单明细表。这时的条件位置就更有讲究了。看一个典型的报表 SQLSELECT u.id, u.name, o.id AS order_id, d.id AS detail_id FROM users u LEFT JOIN orders o ON u.id o.user_id AND o.status 1 LEFT JOIN order_details d ON o.id d.order_id WHERE d.discount_rate 0;这条 SQL 有个隐藏问题WHERE d.discount_rate 0会过滤掉所有右表补出来的 NULL 行。一个用户即使有有效订单但只要这条订单没有满足条件的明细整行还是会消失。换句话说这个 WHERE 一写前面的两个 LEFT JOIN 形同虚设查询又退化成内连接。正确的做法取决于你的业务意图如果你要的是“所有用户以及他们的有效订单的优惠明细”那么 discount_rate 的过滤条件应该一直往 ON 里放LEFT JOIN order_details d ON o.id d.order_id AND d.discount_rate 0如果你要的是“存在优惠明细的订单及其用户”那根本不用 LEFT JOIN 写一大串直接 INNER JOIN 更干净FROM users u JOIN orders o ON u.id o.user_id JOIN order_details d ON o.id d.order_id WHERE d.discount_rate 0;我见过太多人为了“保险起见”主表一张 LEFT JOIN 到底然后在 WHERE 里筛右表条件最后得出结论“数据怎么少了”。其实少不少取决于你到底想要哪种语义。这里有一个从实践中总结的判断口诀非常管用如果某个条件是用来决定“右表行能不能出现”放在 ON 里如果某个条件是用来“在结果集上做最终筛选”放在 WHERE 里。你只要想清楚当右表匹配不到任何行、字段全是 NULL 时这一行到底应不应该留在结果里。应该留条件就放 ON不应该留放 WHERE。4.2 ON 里做“带条件的关联”——比子查询更优雅有些需求表面上是“查用户及其最近一笔有效订单”很多人的第一反应是写窗口函数或者 GROUP BY 子查询。其实用 ON 加子查询也能做而且写法非常干净SELECT u.id, u.name, o.id AS order_id, o.amount FROM users u LEFT JOIN ( SELECT id, user_id, amount FROM orders WHERE status 1 ) o ON u.id o.user_id;这个例子把“过滤有效订单”放进了子查询再在 ON 里只做等值关联。它的执行效果和LEFT JOIN orders o ON u.id o.user_id AND o.status 1在很多数据库里是一样的但可读性更好。特别是当过滤条件很复杂时子查询能让主查询结构更清晰。反过来ON 里也支持非等值连接。比如按订单数区间关联、按日期区间关联这在店铺会员等级、活动区间匹配的业务里很常见。非等值连接时ON 和 WHERE 的区别同样存在判断逻辑跟等值连接完全一致ON 条件仍然只作用于“配对阶段”。4.3 跨库 JOIN、慢 SQL 优化和这题有什么关系不少业务场景会遇到跨库 JOIN比如订单在订单库、用户在主库做报表时要跨库关联。很多数据库支持跨库查询SQL Server 里是跨数据库同实例关联或通过 Linked ServerMySQL 可以用 federated 引擎或干脆把一张表同步过来这本质上也是 JOINON 和 WHERE 的阶段逻辑完全不变。但跨库执行时连接条件放 ON 还是 WHERE 对性能影响更大因为跨库网络传输的中间结果集大小直接决定了响应时间。处理慢 SQL 时我见过一个经典误判有人把一个大表 JOIN 小表的过滤条件写在 WHERE 里执行计划显示小表作为驱动表后需要回表扫描大量行查询要好几秒。这时把右表的过滤条件下推到 ON 里让驱动表在连接阶段直接缩小数据量执行计划立刻变成索引连接毫秒级返回。但这里我必须提醒一句不要无条件把过滤条件都搬进 ON。因为 ON 里的条件会参与连接语义尤其在 LEFT JOIN 下会改变行数。正确的优化姿势是先用 EXPLAIN 看执行计划判断瓶颈在“连接阶段数据量过大”还是“WHERE 过滤太晚”再决定怎么调整。4.4 SQL Server 和 PostgreSQL 里的一些小差异我日常主要用 MySQL 和 PostgreSQL偶尔碰 SQL Server。在这两个数据库里ON 和 WHERE 的核心语义完全一致但有两点值得注意。第一PostgreSQL 和 SQL Server 的优化器在 INNER JOIN 下会做非常激进的谓词下推所以你在 INNER JOIN 里写 ON 过滤条件优化器经常能把它挪到 JOIN 之前反而执行计划会更好看。而在某些早期的 MySQL 版本里优化器对这种下推的支持没那么好手动把条件写进 ON 可能获得更优计划。这就是为什么网上的经验有时候互相矛盾——大家用的数据库版本和统计信息不一样执行计划自然不一样。第二SQL Server 对 ON 子句的支持范围相对严格有些数据库允许在 ON 里写复杂的 CASE 表达式或子查询SQL Server 可能会报错或不推荐。用 SQL Server 写复杂业务时我更倾向于把复杂的过滤逻辑放 WHERE 或子查询里ON 只保留连接键条件减少语法兼容性风险。5. 常见问题与排查技巧实录5.1 最容易踩的 5 个坑每个都是真实案例第一个坑LEFT JOIN 后 WHERE 右表字段导致外连接失效。这个前面已经反复讲过了。特征是查询结果行数少于左表行数而且你以为在“保留全部左表用户”实际却在“只保留有匹配订单的用户”。第二个坑用 WHERE右表业务字段 IS NULL判断“没匹配上”但业务字段本身有空值。比如订单表里 status 列允许 NULL一个用户有一条 status 为 NULL 的订单你用WHERE o.status IS NULL去筛“没下过单的用户”这个用户会被误判断。解决办法是改用右表主键WHERE o.id IS NULL因为主键不会因为业务状态缺失而出现 NULL。第三个坑多表 LEFT JOIN 链过长中间某一张表的过滤条件写到了 WHERE。比如 A LEFT JOIN B LEFT JOIN C你要保留所有 A但 B 的条件被写进 WHERE那么连带着 A 的某些行也会被过滤掉。排查这种问题很痛苦因为结果集的行数时多时少很难一眼看出是哪一环出了问题。我的习惯是在多表外连接里除了最外层的最终过滤所有条件一律往最近的 ON 里放。第四个坑COUNT 统计结果与预期不符。很多人统计用户下单数时这样写SELECT u.id, u.name, COUNT(o.id) AS order_cnt FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.status 1 GROUP BY u.id, u.name;这个查询表面上在统计“每个用户的有效订单数”但实际上没有有效订单的用户根本不会出现在结果里。如果要“所有用户的订单数没有则显示 0”条件必须放 ONSELECT u.id, u.name, COUNT(o.id) AS order_cnt FROM users u LEFT JOIN orders o ON u.id o.user_id AND o.status 1 GROUP BY u.id, u.name;注意这里 COUNT 用的是o.id不是COUNT(*)。COUNT(*)会把 NULL 行也算进去导致没订单的用户数量显示为 1而COUNT(o.id)只统计非 NULL 的订单 ID。这个细节比 ON/WHERE 更隐蔽报表统计时特别容易被坑。第五个坑在 ON 里写“看起来是连接条件实际却引用了第三张表”的字段。多表 JOIN 时如果 ON 里引用了外层表的别名数据库的处理规则会变得很微妙。比如 A LEFT JOIN B ON 条件里出现 C 表的字段这种写法在部分数据库里能跑但结果往往不符合直觉。遇到这种需求先停下来重构思路用子查询或 CTE 拆开别在 JOIN 里玩花活。5.2 排查顺序一张速查表帮你快速定位遇到 JOIN 查询结果行数不对时我建议按照下表快速定位现象可能原因检查点返回行数少于左表LEFT JOIN 退化为 INNER JOIN检查 WHERE 里是否过滤了右表字段右表字段大量为 NULL连接键不匹配或 ON 条件过严检查连接键类型是否一致、是否有隐藏空格/大小写差异行数正确但右表数据多余ON 条件过宽出现一对多膨胀检查 ON 里是否少了字段连接键是否唯一COUNT 结果比预期多一对多连接导致重复计数检查连接键是否唯一必要时先 GROUP BY 去重再 JOIN无订单用户显示订单数为 1COUNT(*) 统计了 NULL 行改用 COUNT(右表主键) 或 COUNT(业务字段)这张表基本覆盖了我在工作中遇到的 90% 的 JOIN 数据异常问题。5.3 ON 和 WHERE 的性能真相别把语义问题和性能问题混为一谈最后再聊一个大家最爱问的把过滤条件放 ON 里是不是一定比放 WHERE 快结论是不一定而且很多场景下没区别。原因在于现代优化器会做谓词下推和逻辑等价改写。在 INNER JOIN 里ON 和 WHERE 的条件大概率会被优化器统一处理执行计划几乎一样你的写法差异不会带来可感知的性能差距。但在 LEFT JOIN 里情况不同。ON 里的右表过滤条件可以提前缩小右侧数据集的参与规模理论上减少中间结果和后续回表次数可能带来更好的性能。而 WHERE 里的过滤必须等 JOIN 完成后才能执行中间结果集可能膨胀。所以如果你确定要保留左表全部行同时只需要右侧部分数据把过滤条件放 ON 在多数情况下是对的无论从语义还是性能角度都合理。不过真正影响性能的大头从来不是条件写在 ON 还是 WHERE而是连接键有没有索引。驱动表选择是否正确。返回的列是不是 SELECT * 带来的巨大回表开销。是否因为一对多关联产生了大量重复中间行导致内存和临时表膨胀。我有一次调优慢 SQL发现用户表和订单表 JOIN 后明明只是一次普通统计结果执行计划显示要扫描几百万行。加不加 ON/WHERE 调整已经没用了真正的问题是连接键在右表没有索引每次都要全表扫描。建好索引后查询从 4 秒降到 0.1 秒。所以说这个知识点还原到业务里ON/WHERE 的语义问题往往只是第一步索引和统计信息才是数据库性能的命门。6. 写在最后一个帮你彻底记住的老办法如果你现在还有点绕我教一个我当年带新人时用的类比。把 ON 当成“两个人能不能牵手”的条件把 WHERE 当成“牵完手之后这一对能不能留在操场上”的条件。LEFT JOIN 的意思是操场左边的人必须全部留下如果右边没人和他牵手他一个人站着也行右手的牌子上写着 NULL。你如果写成LEFT JOIN ... ON 牵手的条件是ID相同 AND 对方状态1那么一个人即使没找到符合条件的牵手对象他本人照样留在操场。你如果写成LEFT JOIN ... ON ID相同WHERE 对方状态1那这个人的 “对方状态”是 NULLNULL 1 不成立他就会被赶出操场。所以 LEFT JOIN 的“LEFT”到底还保不保留就看你把条件写在了哪一步。根据我个人这几年的经验其实只要记住一句人话你希望“即使没匹配上也要留在这行”的数据它的过滤条件就写在 ON 里你希望“匹配完成后不符合要求就删掉整行”的条件写在 WHERE 里。多跑几遍例子多踩几次坑这个判断就会变成下意识反应。最后再分享一个小技巧写复杂报表之前先别急着写大段 SQL。先在纸上把“结果集要保留哪些行、每一张表在里面扮演保底角色还是参与角色”描述清楚再去决定 ON 和 WHERE 的摆放位置。数据团队的很多口径冲突根源都在这一步没想清楚。搞定了 ON 和 WHERE你写的 JOIN 才真正是你想要的那个 JOIN。
网站建设高端定制企业官网