未支付订单从月报里消失了:LEFT JOIN 陷阱
月报初稿被老板打回:「那些还没支付的订单呢?」你用了 INNER JOIN,把没有匹配行的数据全丢了。今天专门搞懂 LEFT JOIN 的坑。
学 · 45 min
01INNER / LEFT / RIGHT / FULL 四种连接的结果集语义
场景月报里「未支付订单」整段消失。你用的 INNER JOIN:右边没有匹配行时,左边这行整个被丢掉。
一句话记四种:INNER=两边都匹配才留;LEFT=左边全留,右边匹配不上补 NULL;RIGHT 反过来;FULL=两边都全留。业务里 90% 是前两种:只看匹配上的用 INNER,左边的行一行都不能丢用 LEFT。
-- 各 3 行小表,四种 JOIN 各跑一遍,结果抄进 notes.md create table a (id int, label text); insert into a values (1,'一'), (2,'二'), (3,'三'); create table b (id int, val int); insert into b values (2,20), (3,30), (4,40); select a.label, b.val from a join b on a.id=b.id; -- (二,20)(三,30) select a.label, b.val from a left join b on a.id=b.id; -- 多出 (一,NULL) select a.label, b.val from a right join b on a.id=b.id; -- 多出 (NULL,40) select a.label, b.val from a full join b on a.id=b.id; -- 全部 4 行易错LEFT JOIN 补出来的 NULL 行,右边所有列都是 NULL--这正是下面反连接(IS NULL)能工作的前提。
02过滤条件写在 ON 和写在 WHERE 的本质差异(对 LEFT JOIN)
场景同样一个条件,写在 ON 里月报没丢行,写在 WHERE 里未支付订单又消失了。今天最重要的一节。
执行顺序决定的:ON 在连接发生时判断--不合格的右表行不参与连接,但左表这行还在,右边补 NULL;WHERE 在连接完成后过滤--此刻 NULL 行也在候选里,任何对右表列的比较都得 UNKNOWN,行被丢弃。所以对 LEFT JOIN:右表的条件写 ON = 「匹配不上的当 NULL 留着」,写 WHERE = 「必须匹配上才要」。
-- 条件 b.val > 25 放 ON:a 全保留,匹配不上的 b 侧补 NULL select a.label, b.val from a left join b on a.id = b.id and b.val > 25; -- 同一条件放 WHERE:NULL 行被过滤,行数退化成 INNER select a.label, b.val from a left join b on a.id = b.id where b.val > 25;易错右表过滤条件放 WHERE 还是 ON,是 LEFT JOIN 最大的坑、面试最爱问的一条。结论先背下来:右表条件放 ON。
03LEFT JOIN 如何一步步退化成 INNER JOIN
场景代码评审时前辈指着你一句 where 说:这行一加,你的 LEFT JOIN 就白写了。
退化路径:LEFT JOIN 补出的 NULL 行,一到 WHERE 就活不过任何对右表列的判断(NULL 参与比较得 UNKNOWN,被丢弃)。判别法:把 WHERE 里提到右表列的条件挪进 ON,行数变多了,说明原来的写法已经退化。
-- 每个用户及其已支付订单数,没下过单的也要(显示 0): select u.name, count(o.id) as 单数 from users u left join orders o on o.user_id = u.id and o.status = 2 group by u.name; -- 把 and o.status = 2 挪到 where:没下过单的用户整行消失 = 退化易错where b.id is not null 是最常见的隐性退化写法--除非你就是要反连接,否则别这么过滤。
04「反连接」:查 A 里不在 B 中的记录
场景增长同事问:「有多少用户注册了却一单没下?」--本质是集合的减法。
反连接 = LEFT JOIN + 右表主键 IS NULL:左行在右边找不到匹配时右表列全是 NULL,用 IS NULL 精确挑出这批行。它和 NOT IN / NOT EXISTS 语义等价、各有性能适用场景,先把 LEFT JOIN 版练熟(D12 会见另外两种)。
select u.name from users u left join orders o on o.user_id = u.id where o.id is null; -- 从未下过单的用户易错IS NULL 判断的列必须是右表的主键(或必然非空的列),否则右表本身的 NULL 会冒充「没匹配上」。
05连接后聚合:count(b.id) 与 count(*) 的差异
场景统计「每个用户的订单数」,没下过单的用户显示 1 而不是 0--你数的是行,不是订单。
LEFT JOIN 补出来的 NULL 行也被
count(*)数到了;count(o.id)只数非空的 o.id,NULL 行自动跳过,正好实现「没下过单 = 0」。一句话:LEFT JOIN 之后聚合右表,用 count(右表.非空列),别用 count(*)。select u.name, count(*) as 错误_NULL行也数, count(o.id) as 正确_没下单记0 from users u left join orders o on o.user_id = u.id group by u.name;易错sum(o.total_amount) 遇 NULL 行会自动跳过、结果没错,但展示层记得 coalesce 补 0。
练 · 60 min
- 造两张 3 行小表,四种 JOIN 各跑一次,把结果抄进 notes.md
参考答案
LEFT 多出的 (一,NULL) 就是「右边匹配不上补 NULL」的实体证据--第 4 题的反连接全靠它工作。四行结果抄进 notes.md。
create table a (id int, label text); insert into a values (1,'一'), (2,'二'), (3,'三'); create table b (id int, val int); insert into b values (2,20), (3,30), (4,40); select a.label, b.val from a join b on a.id = b.id; -- 2 行:(二,20)(三,30) select a.label, b.val from a left join b on a.id = b.id; -- 3 行:多出 (一,NULL) select a.label, b.val from a right join b on a.id = b.id; -- 3 行:多出 (NULL,40) select a.label, b.val from a full join b on a.id = b.id; -- 4 行 -- drop table a, b; -- 下面几题还要用,全部实验做完再收走 - 同一个 LEFT JOIN,条件分别放 ON 和 WHERE,对比行数并解释
参考答案
ON 在连接发生时判断(不合格的右行不参与连接,但左行还在);WHERE 在连接完成后过滤(NULL 参与比较得 UNKNOWN 被丢弃)。前者 3 行、后者 2 行--右表条件放 WHERE,LEFT JOIN 就白写了。
-- 条件放 ON:a 全保留,匹配不上的 b 侧补 NULL select a.label, b.val from a left join b on a.id = b.id and b.val > 25; -- 同一条件放 WHERE:NULL 行过不了比较,行数退化成 INNER select a.label, b.val from a left join b on a.id = b.id where b.val > 25; - 查所有用户及其订单数,没下过单的显示 0
参考答案
count(o.id) 只数非空:LEFT JOIN 补出的 NULL 行自动记 0;换 count(*) 会把没下过单的用户也数成 1。分组键带上 u.id(name 在本库恰好唯一,但别赌)。
select u.id, u.name, count(o.id) as 单数 from users u left join orders o on o.user_id = u.id group by u.id, u.name order by 单数 limit 10; - 用 LEFT JOIN + IS NULL 查「从未下过单的用户」
参考答案
IS NULL 必须判断右表的主键这类必然非空的列:匹配不上时右表列全 NULL;拿可空列判断,业务 NULL 会冒充「没匹配上」。和 D12 的 EXCEPT 写法语义等价。
select u.id, u.name from users u left join orders o on o.user_id = u.id where o.id is null; - 构造一个
count(b.id)与count(*)结果不同的查询,解释原因参考答案
没下过单的用户那行 o.id 是 NULL:count(*) 数行得 1,count(o.id) 数非空得 0,差值 = 没匹配上的左行数。一句话:LEFT JOIN 之后聚合右表,用 count(右表.非空列)。
select u.name, count(*) as 错误_NULL行也数, count(o.id) as 正确_没下单记0 from users u left join orders o on o.user_id = u.id group by u.id, u.name order by 错误_NULL行也数 limit 10;