读完这篇,你能做什么
能写出保留零订单用户的查询,并解释每一行的来源。
学习理由、准备要求与知识点
为什么学它
关联查询是后台列表和统计的日常工作,错误的连接条件会静默漏行或重复计数。
先读这些内容
知识点清单
- 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 也不是通用修复:先想清楚要数用户、订单,还是明细,再决定对什么去重。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["左表用户"] --> C["按ON寻找订单匹配"]
B["右表订单及条件"] --> C
C --> D{"是否有匹配"}
D -->|"有"| E["每个匹配产生一行"]
D -->|"无"| F["产生一行右侧NULL"]
E --> G["WHERE保留TRUE"]
F --> G
G --> H["分组与结果映射"]按顺序看,每一步做了什么
- 明确左侧是否必须保留零匹配记录。
- 画出一对一、一对多或多对多展开数量。
- 把匹配限制放在正确阶段,再推演NULL结果。
- 分别计算
COUNT(*)与COUNT(右侧非空键)。 - 用边界数据验证SQL和Java映射的最终粒度。
03对照实现代码
Spring Framework v6.2.11 · RowMapperResultSetExtractor#extractData
原创教学列表适配器,非源码节选。真实方法遍历ResultSet;本模型用列表替代,只展示每行映射一次,不执行连接或自动合并实体。
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 想返回的对象结构一起设计。
代码先调用谁,再调用谁
JdbcTemplate执行查询- 得到
ResultSet extractData逐行推进RowMapper.mapRow- 结果
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,效果将丢弃零匹配用户,不能再称为要求中的完整左连接。
为什么这样写是对的每个用户要么输出其所有符合条件订单,要么恰好输出一行右侧为空的记录;不会因为零匹配而消失。
U为用户数,O为订单数,R为输出行数;构建右侧分组一次,再按用户输出匹配结果。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
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 三值逻辑
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
图书馆要分别输出“曾经借过书的读者”和“全部读者及最近一次借阅”。构造有重复借阅和无借阅读者的小表,比较两类连接的结果粒度。
- 提交内容
- 小表与逐行推演
- 怎样算完成
- 保留未匹配样本
- 结果中每行的业务含义明确
练到的知识点INNER JOIN · LEFT JOIN
- 作业 02原理问答
一本书有多位作者、多条借阅记录。说明直接连接三张表可能生成多少行,并核查以书名连接而非书目 ID 的风险。
- 提交内容
- 基数计算与连接键评审
- 怎样算完成
- 含同名书和多作者
- 区别合法展开与错误匹配
练到的知识点一对多展开 · 连接键
- 作业 03原理问答
一个发票可关联多张订单,一张订单也可拆成多张发票。为该多对多关系定义结果粒度,说明何时重复行是正确明细而非错误。
- 提交内容
- 关系图与行粒度契约
- 怎样算完成
- 包含桥接表自身重复
- 计数对象必须明确
练到的知识点一对多展开
综合应用
- 作业 04代码推演
查询所有读者及本月未归还借阅,分别移动日期条件和归还状态条件在 ON、WHERE 的位置。先推演再本地执行比较。
- 提交内容
- SQL 变体与差异说明
- 怎样算完成
- 包含 NULL 日期及无匹配记录
- 逐项解释三值逻辑影响
练到的知识点ON 与 WHERE · NULL 三值逻辑
- 作业 05面试场景
统计每位读者借阅次数时还要展示零次读者。设计用于区分“结果行数、借阅行数、非空归还日期数”的诊断查询。
- 提交内容
- 查询与字段口径
- 怎样算完成
- 三个计数含义明确
- 未匹配行有专门样本
练到的知识点COUNT 星号与列 · 未匹配数据
- 作业 06编程实践
建立本地
MySQL测试库,实现读者借阅报表,并增加“一本书多个标签”关联;提交能揭示重复展开错误的反例。- 提交内容
- DDL、查询和验证脚本
- 怎样算完成
- 统计与明细结果可交叉核对
- 不只在无重复数据上验证
练到的知识点INNER JOIN · LEFT JOIN · 一对多展开 · 连接键 · ON 与 WHERE · NULL 三值逻辑 · COUNT 星号与列 · 未匹配数据
- 作业 07代码推演
业务把“未知归属”和“尚未分配”都编码为 NULL。评审相等、否定、分组和连接分别能表达的含义,并指出缺失的状态区分。
- 提交内容
- 表达式与业务语义表
- 怎样算完成
- 包含两个 NULL 值相遇
- 不把业务未知直接化为普通值
练到的知识点NULL 三值逻辑
- 作业 08编程实践
写本地数据生成器制造多对多关联、缺失关系与可空连接字段,再用
MySQL查询和独立枚举结果核验一条自选报表。- 提交内容
- 生成器、SQL 与对照记录
- 怎样算完成
- 固定随机种子
- 差异能定位到具体连接路径
练到的知识点一对多展开 · NULL 三值逻辑
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
接口按读者分页且每页需显示其全部当前借阅。评审分页发生在连接前后的语义,并为读者新增借阅时的分页一致性制定契约。
- 提交内容
- 分页方案与边界测试表
- 怎样算完成
- 区分读者页大小与明细行数
- 覆盖排序键相同的读者
练到的知识点LEFT JOIN · 一对多展开 · 连接键 · ON 与 WHERE · 未匹配数据
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 反例构造
构造一个查询,使用 DISTINCT 后结果行数看似正确,但汇总业务含义仍然错误;说明需要怎样的数据反例才能发现问题。
- 提交内容
- 最小数据集及语义分析
- 怎样算完成
- 不得仅制造语法错误
- 区分去重规则和事实身份
练到的知识点一对多展开 · NULL 三值逻辑
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
MySQL 8.4:JOIN (新标签页)↗
阅读外连接、ON 与 USING 的语义,核对关联结果为何多行或漏行。
- 实践参考文档英文
MySQL 8.4:GROUP BY (新标签页)↗
阅读聚合与
ONLY_FULL_GROUP_BY,区分分组键、聚合列与筛选时机。 - 深入参考文档英文
MySQL 8.4:Subqueries (新标签页)↗
阅读 EXISTS、标量及集合子查询,按返回值形态选择查询方式。
- 实践实践指南中文
SQL 查询入门:从表里找出需要的数据 (新标签页)↗
先学 SELECT、筛选排序和 JOIN。小练习:自建两张只有几行的学生与选课表,查询某门课的名单,再检查没有选课的学生是否被漏掉。 公开模块可阅读;示例使用 SQL Server 的 Transact-SQL,部分语法不同于
MySQL。练习环境的账号和费用取决于所选服务。 - 实践实践指南中文
SQLZoo 中文练习:写查询,马上看结果 (新标签页)↗
先做 SELECT 基础,再选 JOIN 和 NULL。每题先写出预计会出现哪些行,再提交查询;做错时比较多了或少了哪些行,不只看对错提示。 练习可直接打开,中文以繁体为主,部分内容仍是英文。页面可选择数据库引擎,使用前核对选择;示例人口等数据仅供 SQL 练习,不作为当前统计资料。
- 实践实践指南中文
Microsoft 中文:COUNT 到底数的是哪些行 (新标签页)↗
先读参数和备注,只比较三种 COUNT,暂跳窗口函数、返回类型细节和大型示例库。用值为 10、10、NULL 的三行小表,先预测再查询;交出三条 SQL、结果和各自数了什么的说明。 正文可公开阅读。运行示例需 SQL Server 兼容环境;文中的
AdventureWorks示例库不是自建小表练习的必需项。T-SQL 的 GO 等写法不应原样复制到MySQL。 - 深入参考文档英文
MySQL NULL (新标签页)↗
核对NULL比较及筛选方式。
- 深入参考文档英文
MySQL聚合函数 (新标签页)↗
核对COUNT、SUM与空值处理。
- 深入参考文档英文
Spring逐行映射源码 (新标签页)↗
查看每行映射与结果列表关系,避免误解自动实体合并。