SQL 进阶4:查询从未下单的用户与 NOT EXISTS 完整解析

SQL 学习笔记:查询从未下单的用户与 NOT EXISTS 完整解析

一、题目背景

题目要求

找出所有从未下过单的用户,并分别使用两种写法实现:

  • LEFT JOIN + IS NULL
  • NOT EXISTS

这道题考察什么

  • 外连接的基本语义
  • 相关子查询的执行逻辑
  • "差集查询"或"反连接查询"的常见写法
  • LEFT JOINNOT EXISTSNOT IN 三者的差异
  • 真实工作中对性能、索引和易错点的判断能力

二、建表与测试数据

mysql 复制代码
CREATE TABLE users (
  user_id  INT PRIMARY KEY,
  username VARCHAR(50)
);

CREATE TABLE orders (
  order_id INT PRIMARY KEY,
  user_id  INT,
  amount   DECIMAL(10,2),
  order_dt DATE
);

INSERT INTO users VALUES
(1,'Alice'),(2,'Bob'),(3,'Carol'),(4,'Dave');

INSERT INTO orders VALUES
(101,1,200,'2024-03-01'),
(102,1,350,'2024-03-05'),
(103,3,120,'2024-03-10');

先看数据关系

  • Alice 有 2 笔订单
  • Bob 没有订单
  • Carol 有 1 笔订单
  • Dave 没有订单

所以最终应该找出的用户是:BobDave


三、题目目标与预期结果

这道题并不是要查"有多少订单",也不是要查"最后一笔订单",而是要找出:

orders 表中完全找不到对应记录的用户。

预期结果

user_id username
2 Bob
4 Dave

如果使用 LEFT JOIN 时把订单字段也一起 SELECT 出来,那么这些保留下来的用户,其订单字段会全部是 NULL。但在实际写题或面试中,通常只需要输出用户字段即可。


四、解法一:LEFT JOIN + IS NULL

mysql 复制代码
SELECT u.user_id, u.username
FROM users u
LEFT JOIN orders o
       ON u.user_id = o.user_id
WHERE o.order_id IS NULL;

这段 SQL 是怎么工作的

LEFT JOIN 的含义是:先保留左表 users 的所有记录,再尝试去右表 orders 里做匹配。

匹配成功时:

  • 右表字段会带上订单数据

匹配失败时:

  • 右表字段会被填成 NULL

所以这条语句的逻辑是:

  1. 先把所有用户都保留下来
  2. 能匹配到订单的用户,会带上订单信息
  3. 匹配不到订单的用户,o.order_id 会变成 NULL
  4. 再通过 WHERE o.order_id IS NULL 把这些"没匹配上的用户"筛出来

为什么判断 o.order_id IS NULL

这里最稳妥的做法,是判断右表中的主键列 或业务上明确非空 的列,比如 order_id

原因是如果你写成下面这样:

mysql 复制代码
WHERE o.amount IS NULL

就会有风险。因为 amount 在某些业务里本身可能允许为空,这样"没有订单的用户"和"有订单但金额为空的用户"就可能混在一起,结果静默出错。


五、解法二:NOT EXISTS

mysql 复制代码
SELECT u.user_id, u.username
FROM users u
WHERE NOT EXISTS (
  SELECT 1
  FROM orders o
  WHERE o.user_id = u.user_id
);

这条写法在语义上更直接,意思就是:

users 表中找用户,只保留那些在 orders 表中"不存在任何匹配记录"的行。

很多数据库开发者在生产环境中更偏向这类写法,因为它表达的就是"判断是否存在",没有额外的拼表动作,语义更清晰。


六、NOT EXISTS 的逐层执行逻辑

这是这道题最值得真正吃透的部分。

1. 外层查询在做什么

mysql 复制代码
SELECT u.user_id, u.username
FROM users u
WHERE ...

外层查询只是从 users 表中逐行读取用户,真正的筛选逻辑藏在 WHERE NOT EXISTS (...) 里。

2. 内层子查询在做什么

mysql 复制代码
SELECT 1
FROM orders o
WHERE o.user_id = u.user_id

这里的重点不是返回了什么字段,而是:

  • 子查询里引用了外层的 u.user_id
  • 它不是独立执行一次,而是会跟着外层用户一行一行地检查

这种写法叫做相关子查询

你可以把它理解成:

每处理一个用户,就拿着这个用户的 user_idorders 表里查一下,看能不能找到对应订单。

3. SELECT 1 到底是什么意思

EXISTS / NOT EXISTS 里,SELECT 1 是一种很常见的写法。

它表达的意思不是"我要返回数字 1",而是:

我根本不关心返回的列值,我只关心这条子查询有没有返回行。

所以这里写 1*'x',在逻辑上都可以,但业内通常写 SELECT 1,因为最能体现"只检查存在性"。

4. NOT EXISTS 的判定规则

子查询是否返回行 NOT EXISTS 的结果 当前用户是否保留
返回至少一行 FALSE 不保留
一行都没返回 TRUE 保留

5. 代入测试数据逐行模拟

当前用户 orders 中能否找到匹配记录 NOT EXISTS 结果 是否进入最终结果
Alice FALSE
Bob 不能 TRUE
Carol FALSE
Dave 不能 TRUE

所以最后留下来的就是:

  • Bob
  • Dave

这也和前面的预期结果完全一致。


七、JOIN 与 NOT EXISTS 的本质区别和心智模型

很多初学者会觉得这两种写法看起来很像,因为都出现了:

mysql 复制代码
o.user_id = u.user_id

但它们的本质并不一样。

对比一:它们分别想解决什么问题

维度 LEFT JOIN NOT EXISTS
目的 合并两张表的数据 检查另一张表里是否存在匹配行
是否真的使用右表字段 不会
返回结果 一张合并后的逻辑结果集 一个真假判断
重复行风险 基本没有

LEFT JOIN 的心智模型

你可以把它理解成:

先把 usersorders 逻辑上拼成一张更宽的结果表,再去过滤。

例如某个用户有多笔订单时,JOIN 后会出现多行:

text 复制代码
user_id | username | order_id
1       | Alice    | 101
1       | Alice    | 102
2       | Bob      | NULL

所以 LEFT JOIN 的特点是:

  • 它真的会把匹配行带进结果集中
  • 一对多关系下可能产生重复行
  • 后续 WHERESELECT、聚合都是在这个逻辑结果集上继续处理

NOT EXISTS 的心智模型

你可以把它理解成:

不拼表,只做一次"侦察"。对每个用户去 orders 里问一句:有没有对应订单?

如果有,就返回"存在",当前用户丢弃。

如果没有,就返回"不存在",当前用户保留。

这里右表的数据本身并不会被真正拿回来,你最终得到的只是一个 TRUE / FALSE 的判定结果。

一句话抓住本质

  • JOIN 更像"把两张表拼起来再处理"
  • NOT EXISTS 更像"拿着条件去另一张表确认一下有没有"

所以虽然两者都写了 o.user_id = u.user_id,但:

  • JOIN 里,它是连接条件
  • NOT EXISTS 里,它是相关子查询的过滤条件

八、性能、索引与适用场景对比

先说结论

在现代数据库里,LEFT JOIN + IS NULLNOT EXISTS 往往性能差异很小,不能简单粗暴地说谁一定更快。

常见场景对比

场景 更常见的结论
MySQL 8+、PostgreSQL 等现代数据库 两者通常差异很小,优化器可能生成相同执行计划
orders.user_id 上有索引 两者都能很快,NOT EXISTS 在语义和短路上略有优势
没有索引 两者都可能慢,写法差异通常不如索引影响大
orders 数据量很大 NOT EXISTS 在某些情况下更有优势
老版本 MySQL 5.x NOT EXISTS 可能被优化器处理得不够好,LEFT JOIN 有时更稳定

为什么有人会说 NOT EXISTS 更快

一个常见原因是它有**短路退出**的特性。

也就是说,当数据库在 orders 表中找到当前用户的第一条匹配记录时,就已经可以判定:

  • 这个用户"存在订单"
  • NOT EXISTSFALSE
  • 当前用户应被过滤掉

此时数据库就没有必要继续看这个用户后面的剩余订单了。

如果一个用户有很多订单,这种"找到第一条就停止"的特性,在某些场景下会有优势。

为什么很多时候又看不出差别

因为现代数据库优化器很聪明。

它不会死板地按你写出来的字面顺序执行,而是会把 SQL 改写成它认为更优的执行计划。很多时候:

  • 你写的是 NOT EXISTS
  • 优化器内部把它识别成反连接语义
  • 最终执行计划和 LEFT JOIN + IS NULL 非常接近,甚至一样

所以实际工作中,如果你用 EXPLAIN 看两条语句,执行计划可能几乎一致。

真正决定性能的关键:索引

比起纠结写法,真正更重要的是:

orders.user_id 上有没有索引。

mysql 复制代码
CREATE INDEX idx_orders_user_id ON orders(user_id);

通常可以这样理解:

  • 有索引:两种写法都可能很快
  • 没索引:两种写法都可能很慢

索引对性能的影响,通常比 LEFT JOINNOT EXISTS 的语法差异更大。

工程上的正确做法

不要靠感觉判断性能,要直接看执行计划:

mysql 复制代码
EXPLAIN SELECT ...

如果两条语句的执行计划几乎一样,那它们的实际性能通常也不会有本质差别。


九、NOT IN 的 NULL 陷阱

同样的需求,有人还会写成这样:

mysql 复制代码
SELECT user_id, username
FROM users
WHERE user_id NOT IN (
  SELECT user_id FROM orders
);

这条语句最大的问题是:

如果子查询结果里出现了 NULL,整个 NOT IN 的判断就可能失效。

例如子查询结果变成:

text 复制代码
(1, 3, NULL)

那么条件就会变成类似:

text 复制代码
user_id != 1
AND user_id != 3
AND user_id != NULL

而任何值和 NULL 比较,结果都不是 TRUE,而是 UNKNOWN。最后 WHERE 条件整体不成立,就可能返回空结果。

所以在这类"找不存在"的题目里:

  • NOT EXISTS 更安全
  • LEFT JOIN + IS NULL 也安全
  • NOT IN 需要格外小心,尤其是子查询列允许为 NULL

十、常见坑点与面试表达

1. LEFT JOIN + IS NULL 的常见坑

坑一:IS NULL 判断的列选错了

mysql 复制代码
-- 不推荐
WHERE o.amount IS NULL

-- 更稳妥
WHERE o.order_id IS NULL

如果判断的是业务上允许为空的列,就可能把"有订单但字段为空"的记录误判成"没订单"。

坑二:一对多场景容易产生重复行

如果用户有多笔订单,JOIN 后会出现多行。虽然这道题最终通过 IS NULL 过滤时不会保留这些匹配行,但一旦你把这段 JOIN 用到更复杂的统计里,重复行就很容易污染结果。

坑三:多表 JOIN 时逻辑容易变复杂

当你在 orders 后面继续 JOIN 更多表时,NULL 的传播会让查询变得更难推理,维护成本会上升。

2. NOT EXISTS 的常见坑

坑一:忘了写关联条件

mysql 复制代码
-- 错误示例
WHERE NOT EXISTS (
  SELECT 1
  FROM orders o
)

如果 orders 表里本来就有数据,这个子查询永远都能返回行,那么 NOT EXISTS 永远是 FALSE,最终结果就会变成空表。

这是最危险的一种错误,因为:

  • 语法没错
  • 查询能跑
  • 结果却悄悄错了

坑二:在旧版本数据库上可能退化

在某些旧版本数据库里,相关子查询不一定能被很好地优化,可能出现逐行嵌套执行,性能会明显下滑。

3. 面试时可以怎么回答

如果面试官问:

LEFT JOIN + IS NULLNOT EXISTS 哪个性能更好?

比较稳的回答方式是:

理论上 NOT EXISTS 有短路退出的优势,不需要把匹配行全部带进逻辑结果集;但在 MySQL 8+、PostgreSQL 等现代数据库里,优化器往往会把两者改写成相近甚至相同的执行计划,所以实际差异通常不大。真正更关键的是 orders.user_id 上有没有索引。如果是旧版本 MySQL,NOT EXISTS 有时反而可能因为优化器较弱而退化,这时 LEFT JOIN 的执行表现可能更稳定。

这类回答的优点是:

  • 不绝对化
  • 讲到了短路优势
  • 讲到了现代优化器
  • 讲到了索引才是关键因素

十一、总结

这道题本质上是在做一件事:

users 表中找出那些在 orders 表里完全没有匹配记录的用户。

最需要记住的结论有四个:

  1. LEFT JOIN + IS NULLNOT EXISTS 都能正确解决这类"查不存在"的问题。
  2. NOT EXISTS 的语义通常更直接,因为它表达的就是"另一张表里不存在匹配行"。
  3. 性能不能脱离数据库版本和索引讨论,orders.user_id 上是否有索引,比单纯换写法更重要。
  4. NOT IN 在子查询结果可能出现 NULL 时有明显陷阱,实际工作中要谨慎使用。

如果只用一句话来概括这道题,可以记成:

LEFT JOIN 是"先拼表再筛空",NOT EXISTS 是"逐行检查是否存在",而生产环境里真正决定性能的往往是索引和执行计划。

相关推荐
光泽雨2 小时前
数据库中的DCL
数据库
星辰_mya2 小时前
【无标题】
数据库·后端·面试·架构师
Yvonne爱编码3 小时前
数据库---Day6 数据库约束
数据库
空太Jun3 小时前
Spring Security 自定义数据库认证(初尝试)
java·数据库·spring
sinat_255487813 小时前
泛型·学习笔记
java·jvm·数据库·windows·python
wregjru3 小时前
【MySQL】4. 数据约束详解
数据库·sql·oracle
枕书3 小时前
Oracle 19c RAC 双机高可用底座部署手册(PVE 架构版)
数据库·oracle·pve
一个有温度的技术博主3 小时前
Redis RDB持久化原理:一次快照背后的“分身术”与“读心术”
数据库·redis·缓存