qinghuan
← 数据系统 / 全部笔记

单元 45 / 数据系统

P0 · 必会主线L1 · 基础

SQL 连接与 NULL:让零订单用户也正确出现在结果里

从一个用户多张订单的例子看懂 JOIN 展开,再检查 ON、WHERE 和 NULL 怎样影响漏行与重复统计。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能写出保留零订单用户的查询,并解释每一行的来源。

学习理由、准备要求与知识点

为什么学它

关联查询是后台列表和统计的日常工作,错误的连接条件会静默漏行或重复计数。

先读这些内容

知识点清单

  • INNER JOIN
  • LEFT JOIN
  • 一对多展开
  • 连接键
  • ON 与 WHERE
  • NULL 三值逻辑
  • COUNT 星号与列
  • 未匹配数据

运行前先确认MySQL8.4与Java17。源码固定Spring Framework v6.2.11;哈希连接模型只实现等值LEFT JOIN与ON内的已付款条件,不模拟优化器或所有SQL语义。

01先弄懂它是什么

用户连接订单后为什么变成多行?因为一个用户有三张订单,JOIN 通常就产生三行用户和订单的组合,这不代表用户被重复保存了。若再连上另一组一对多数据,例如评论,还可能出现订单数乘评论数的组合。统计前先检查连接后每行代表什么,不要急着把重复结果归咎于 Mapper。

INNER JOIN 只保留能匹配上的行;LEFT JOIN 还保留没有右侧匹配的左侧行,并把右侧列补成 NULL。NULL 表示缺失或未知,不是 0、空字符串或文字 null。用普通等号和 NULL 比较得不到 TRUE;查空值应写 IS NULL 或 IS NOT NULL。MySQL 另有专门的空值安全等号,使用时要明确它的含义。

把 ON 想成“怎样配对”,把 WHERE 想成“配完后留下谁”。要列出所有用户及其已付款订单,可以把已付款条件写进 ON;没有这类订单的用户仍会保留,订单列填 NULL。若把同样条件写到 WHERE,这些补空行就会被筛掉。关键不在于有没有 WHERE,而在于它会不会排除补空行。

文中这几个词,先认一认

连接基数
左边一行能匹配右边几行、最终展开成多少行,会直接影响重复展示和汇总结果。
补空行
LEFT JOIN 找不到右侧匹配时,保留左侧记录,并用 NULL 填右侧列的结果行。
三值逻辑
SQL 条件有真、假和未知三种结果;WHERE 只留下结果为真的行。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

拿两名用户试一下:用户 1 有两张已付款订单,用户 2 没有订单。按用户做 LEFT JOIN,并在 ON 中限定已付款,结果就是用户 1 两行、用户 2 一行。按用户分组计数时,用户 2 的 COUNT(*) 是 1,因为那一行还在;COUNT(o.id) 是 0,因为补出的订单编号是 NULL,不算真实订单。

把已付款条件从 ON 移到 WHERE 后,用户 2 那一行的订单状态是 NULL,不能通过筛选,于是用户也不见了。只有未付款订单的用户,也可能这样消失。验证时至少准备三个人:没有订单的人、只有未付款单的人、有多张已付款单的人,逐个确认他们该不该出现在结果里。

简单的 RowMapper 通常读一行就创建一个结果对象。它不会看到重复的用户编号,就自动把多行合成“一个用户加一个订单列表”;这种整理需要自己实现,或使用支持对应映射的方案。DISTINCT 也不是通用修复:先想清楚要数用户、订单,还是明细,再决定对什么去重。

正在加载流程图…

ON控制匹配,WHERE处理已经连接的行;计数和映射都应以实际展开粒度为依据。
查看流程图源码(Mermaid)
flowchart TD
 A["左表用户"] --> C["按ON寻找订单匹配"]
 B["右表订单及条件"] --> C
 C --> D{"是否有匹配"}
 D -->|"有"| E["每个匹配产生一行"]
 D -->|"无"| F["产生一行右侧NULL"]
 E --> G["WHERE保留TRUE"]
 F --> G
 G --> H["分组与结果映射"]

按顺序看,每一步做了什么

  1. 明确左侧是否必须保留零匹配记录。
  2. 画出一对一、一对多或多对多展开数量。
  3. 把匹配限制放在正确阶段,再推演NULL结果。
  4. 分别计算COUNT(*)与COUNT(右侧非空键)。
  5. 用边界数据验证SQL和Java映射的最终粒度。

03对照实现代码

RowMapperResultSetExtractor逐行映射 (新标签页)↗

Spring Framework v6.2.11 · RowMapperResultSetExtractor#extractData

原创教学列表适配器,非源码节选。真实方法遍历ResultSet;本模型用列表替代,只展示每行映射一次,不执行连接或自动合并实体。

核心机制代码 · java
import java.util.*;
import java.util.function.BiFunction;
class SourceModel {
    static <R,T> List<T> extract(List<R> rows,BiFunction<R,Integer,T> mapper) {
        List<T> result=new ArrayList<>(rows.size());
        int rowNumber=0;
        for (R row:rows) {
            // 一条结果行对应一次映射,重复用户不会自动折叠。
            T value=mapper.apply(row,rowNumber);
            rowNumber++; // 行号只是结果位置,不是数据库主键。
            result.add(value);
        }
        return result; // 若需要嵌套集合,应另行设计聚合规则。
    }
}

真实方法在rs.next循环中调用mapRow并追加结果,一般是一条结果行对应一个列表元素。

rowsExpected只用于集合容量优化,不是限制返回行数,也不会替SQL添加LIMIT。

rowNum从零增长,不能当作业务id或分页游标;实体身份应来自明确的结果列。

JOIN 展开后的重复用户字段会原样映射,框架不会自行猜测应该合成一个用户。因此 SQL 中每行代表什么,必须和 Java 想返回的对象结构一起设计。

代码先调用谁,再调用谁

  1. JdbcTemplate执行查询
  2. 得到ResultSet
  3. extractData逐行推进
  4. RowMapper.mapRow
  5. 结果List

04动手写一个例子

保留零订单用户的哈希左连接

给出唯一用户id列表与订单列表,只匹配PAID订单。每个匹配输出一行,无匹配用户输出orderId为空的一行。空userId订单不匹配任何用户;输出保持用户顺序和该用户订单原顺序。

输入 1
用户1、2;用户1两张PAID订单
输出 1
用户1两行,用户2一行orderId为空
输入 2
用户2只有CANCELLED订单
输出 2
仍有用户2补空行

先把满足ON内状态条件且userId不为空的订单按用户建立哈希分组。NULL外键不能参与普通等值匹配,直接跳过避免Java Map的null键语义替代SQL。

逐个用户查分组,有订单时输出全部匹配,没有时只输出一行补空。此时结果行数可大于用户数;用非空orderId计数才表示匹配订单数。

把状态过滤前移到右侧索引构建,相当于本题ON匹配条件。若在最终输出中剔除空orderId,效果将丢弃零匹配用户,不能再称为要求中的完整左连接。

为什么这样写是对的每个用户要么输出其所有符合条件订单,要么恰好输出一行右侧为空的记录;不会因为零匹配而消失。

时间 均摊O(U+O+R)额外空间 O(O+R)

U为用户数,O为订单数,R为输出行数;构建右侧分组一次,再按用户输出匹配结果。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 43 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
    record Order(long id,Long userId,String status) {}
    record Row(long userId,Long orderId) {}
    static List<Row> leftJoin(List<Long> users,List<Order> orders) {
        Map<Long,List<Order>> byUser=new HashMap<>();
        for (Order order:orders) {
            // NULL不参与普通等值匹配,状态限制属于ON条件。
            if (order.userId()==null||!"PAID".equals(order.status())) continue;
            byUser.computeIfAbsent(order.userId(),k->new ArrayList<>()).add(order);
        }
        Set<Long> seen=new HashSet<>();
        List<Row> rows=new ArrayList<>();
        for (Long user:users) {
            if (user==null||!seen.add(user)) throw new IllegalArgumentException("user primary key");
            List<Order> matches=byUser.getOrDefault(user,List.of());
            if (matches.isEmpty()) rows.add(new Row(user,null)); // 保留零匹配用户。
            else for (Order order:matches) rows.add(new Row(user,order.id()));
        }
        return List.copyOf(rows);
    }
    static long countOrders(List<Row> rows,long user) {
        // 类比COUNT(o.id),忽略补空行而非统计全部行。
        return rows.stream().filter(r->r.userId()==user&&r.orderId()!=null).count();
    }
    static void eq(Object expected,Object actual) {
        if (!Objects.equals(expected,actual)) throw new AssertionError(actual);
    }
    public static void main(String[] args) {
        List<Row> rows=leftJoin(List.of(1L,2L,3L),List.of(
            new Order(11,1L,"PAID"),new Order(12,2L,"CANCELLED"),
            new Order(13,1L,"PAID"),new Order(14,null,"PAID")));
        eq(List.of(new Row(1,11L),new Row(1,13L),new Row(2,null),new Row(3,null)),rows);
        eq(2L,countOrders(rows,1));
        eq(0L,countOrders(rows,2));
        eq(1L,rows.stream().filter(r->r.userId()==2).count());
        eq(List.of(),leftJoin(List.of(),List.of(new Order(1,1L,"PAID"))));
        eq(List.of(new Row(9,null)),leftJoin(List.of(9L),List.of()));
        try { leftJoin(List.of(1L,1L),List.of()); throw new AssertionError("duplicate"); }
        catch (IllegalArgumentException expected) {}
        System.out.println("PASS 7 left join tests");
    }
}

这些特殊情况也要试一试

  • 没有左侧用户时没有输出;右侧空时每个用户仍输出一行。
  • 右侧NULL关联键不匹配;只有未付款单的用户仍保留。
  • 重复用户主键拒绝,订单自身唯一性作为题目前提。
进阶选读 · 两个一对多连接的乘法膨胀

场景用户有三张订单和四条评论,直接同时连接产生十二行,金额被重复累计。

做法先分别按用户聚合订单和评论,使每个子结果对用户至多一行,再与用户左连接;或使用EXISTS表达只判断存在的需求。

复杂度预聚合通常线性处理各输入并降低中间结果,但数据库实际执行方式要看计划。

好处与代价提前聚合改变可用明细粒度,应先确定报表需要的是汇总还是明细,不能以DISTINCT掩盖错误。

05项目里怎么用

写列表或报表前,先用一句话说明“结果每行代表什么”,再决定怎么连接。关联列的类型、排序规则和索引要合适,但实际怎样执行仍应查看执行计划。外键可以约束引用关系,例如订单引用的用户是否存在;它不会替你决定查询使用哪种 JOIN。

不要见到 NULL 就先 COALESCE 成 0。没填金额、没有订单和订单确实为 0,是不同事实,过早替换后就分不清了。Java 接收允许为空的列时,应使用合适包装类型或显式检查,避免基本类型默认 0 让补空行看起来像真实记录。

先决定一页要展示多少个用户,还是多少条用户与订单的组合。直接在 JOIN 展开的结果上取二十行,可能大部分都属于同一个用户,不能当成二十个用户。若按用户分页,可以先选出这一页的用户编号,再读取他们的订单并组装。两步都要遵守确定的排序;若要求它们看到同一份数据,还需安排相应事务快照,不能忽略中途新增或删除带来的变化。

容易踩的坑

  • 把LEFT JOIN右侧过滤无条件放到WHERE导致零匹配用户消失。
  • 用COUNT(*)统计右侧订单,补空行被算成一单。
  • 同时连接两个一对多集合后直接累计金额。
  • 以为RowMapper会自动按用户id合并多行。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
INNER JOIN只处理确实匹配的记录。丢弃零匹配左侧记录。必须存在关联的明细查询。
LEFT JOIN保留左侧全集。必须正确处理补空和展开。包含零业务量实体的列表。
分步查询组装可先固定实体分页粒度。需控制额外查询和一致性。一对多嵌套响应。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01NULL等于零或空字符串吗?

展开参考答案与推导

不是,它表达缺失或未知,普通比较可能得到UNKNOWN。

02COUNT(*)与COUNT(o.id)何时不同?

展开参考答案与推导

补空行的o.id为NULL,前者计行而后者忽略该NULL。

03用户重复出现在结果中一定是数据重复吗?

展开参考答案与推导

不是,一对多连接本身会展开多行。

换个情况,还会处理吗

01零订单用户从报表消失怎么办?

展开参考答案与推导

检查右侧条件是否放在WHERE并拒绝补空行,按需求移入ON。

02金额扩大成评论数倍怎么办?

展开参考答案与推导

检查两个一对多连接产生的乘法组合,分别预聚合或改查询粒度。

03LIMIT20只返回八个用户怎么办?

展开参考答案与推导

当前分页的是连接行,应先分页用户实体再取明细。

写代码试一试

01如何验证只含未付款订单的用户仍存在?

输入 / 条件
用户2有CANCELLED订单
输出 / 预期
Row(2,null)

提示 状态筛选在匹配阶段。

展开参考答案与推导

建立该初态并断言补空行存在,不能只测完全无订单用户。

02如何测试NULL关联键不匹配?

输入 / 条件
订单userId为空
输出 / 预期
不归入任一用户

提示 SQL普通等值不是Java空键匹配。

展开参考答案与推导

建哈希分组前显式跳过NULL外键。

03怎样区分实体数量和结果行数?

输入 / 条件
用户1两单、用户2零单
输出 / 预期
两用户、三行、两订单

提示 分别计算三种指标。

展开参考答案与推导

用左表数量、结果行数量和非空右键数量独立断言。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能写出保留零订单用户的查询,并解释每一行的来源。

本篇重点加练

连接基数与 NULL 逻辑会联合作用,必须能解释同一查询中数据行数与业务含义的变化。

  • 一对多展开
  • NULL 三值逻辑

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    图书馆要分别输出“曾经借过书的读者”和“全部读者及最近一次借阅”。构造有重复借阅和无借阅读者的小表,比较两类连接的结果粒度。

    提交内容
    小表与逐行推演
    怎样算完成
    • 保留未匹配样本
    • 结果中每行的业务含义明确

    练到的知识点INNER JOIN · LEFT JOIN

  2. 作业 02原理问答

    一本书有多位作者、多条借阅记录。说明直接连接三张表可能生成多少行,并核查以书名连接而非书目 ID 的风险。

    提交内容
    基数计算与连接键评审
    怎样算完成
    • 含同名书和多作者
    • 区别合法展开与错误匹配

    练到的知识点一对多展开 · 连接键

  3. 作业 03原理问答

    一个发票可关联多张订单,一张订单也可拆成多张发票。为该多对多关系定义结果粒度,说明何时重复行是正确明细而非错误。

    提交内容
    关系图与行粒度契约
    怎样算完成
    • 包含桥接表自身重复
    • 计数对象必须明确

    练到的知识点一对多展开

综合应用

  1. 作业 04代码推演

    查询所有读者及本月未归还借阅,分别移动日期条件和归还状态条件在 ON、WHERE 的位置。先推演再本地执行比较。

    提交内容
    SQL 变体与差异说明
    怎样算完成
    • 包含 NULL 日期及无匹配记录
    • 逐项解释三值逻辑影响

    练到的知识点ON 与 WHERE · NULL 三值逻辑

  2. 作业 05面试场景

    统计每位读者借阅次数时还要展示零次读者。设计用于区分“结果行数、借阅行数、非空归还日期数”的诊断查询。

    提交内容
    查询与字段口径
    怎样算完成
    • 三个计数含义明确
    • 未匹配行有专门样本

    练到的知识点COUNT 星号与列 · 未匹配数据

  3. 作业 06编程实践

    建立本地 MySQL 测试库,实现读者借阅报表,并增加“一本书多个标签”关联;提交能揭示重复展开错误的反例。

    提交内容
    DDL、查询和验证脚本
    怎样算完成
    • 统计与明细结果可交叉核对
    • 不只在无重复数据上验证

    练到的知识点INNER JOIN · LEFT JOIN · 一对多展开 · 连接键 · ON 与 WHERE · NULL 三值逻辑 · COUNT 星号与列 · 未匹配数据

  4. 作业 07代码推演

    业务把“未知归属”和“尚未分配”都编码为 NULL。评审相等、否定、分组和连接分别能表达的含义,并指出缺失的状态区分。

    提交内容
    表达式与业务语义表
    怎样算完成
    • 包含两个 NULL 值相遇
    • 不把业务未知直接化为普通值

    练到的知识点NULL 三值逻辑

  5. 作业 08编程实践

    写本地数据生成器制造多对多关联、缺失关系与可空连接字段,再用 MySQL 查询和独立枚举结果核验一条自选报表。

    提交内容
    生成器、SQL 与对照记录
    怎样算完成
    • 固定随机种子
    • 差异能定位到具体连接路径

    练到的知识点一对多展开 · NULL 三值逻辑

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    接口按读者分页且每页需显示其全部当前借阅。评审分页发生在连接前后的语义,并为读者新增借阅时的分页一致性制定契约。

    提交内容
    分页方案与边界测试表
    怎样算完成
    • 区分读者页大小与明细行数
    • 覆盖排序键相同的读者

    练到的知识点LEFT JOIN · 一对多展开 · 连接键 · ON 与 WHERE · 未匹配数据

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 反例构造

    构造一个查询,使用 DISTINCT 后结果行数看似正确,但汇总业务含义仍然错误;说明需要怎样的数据反例才能发现问题。

    提交内容
    最小数据集及语义分析
    怎样算完成
    • 不得仅制造语法错误
    • 区分去重规则和事实身份

    练到的知识点一对多展开 · NULL 三值逻辑

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗