未支付订单从月报里消失了:LEFT JOIN 陷阱

月报初稿被老板打回:「那些还没支付的订单呢?」你用了 INNER JOIN,把没有匹配行的数据全丢了。今天专门搞懂 LEFT JOIN 的坑。

学 45 min
练 60 min
盘 15 min
共 120 分钟

学 · 45 min

  1. 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)能工作的前提。

  2. 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。

  3. 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 是最常见的隐性退化写法--除非你就是要反连接,否则别这么过滤。

  4. 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 会冒充「没匹配上」。

  5. 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

  1. 造两张 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;   -- 下面几题还要用,全部实验做完再收走
  2. 同一个 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;
  3. 查所有用户及其订单数,没下过单的显示 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;
  4. 用 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;
  5. 构造一个 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;
过关标准 用小表结果解释为什么 LEFT JOIN 把条件放 WHERE 会退化成 INNER JOIN。