SQL 学习笔记:查询从未下单的用户与 NOT EXISTS 完整解析
一、题目背景
题目要求
找出所有从未下过单的用户,并分别使用两种写法实现:
LEFT JOIN + IS NULLNOT EXISTS
这道题考察什么
- 外连接的基本语义
- 相关子查询的执行逻辑
- "差集查询"或"反连接查询"的常见写法
LEFT JOIN、NOT EXISTS、NOT 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没有订单
所以最终应该找出的用户是:Bob 和 Dave。
三、题目目标与预期结果
这道题并不是要查"有多少订单",也不是要查"最后一笔订单",而是要找出:
在 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
所以这条语句的逻辑是:
- 先把所有用户都保留下来
- 能匹配到订单的用户,会带上订单信息
- 匹配不到订单的用户,
o.order_id会变成NULL - 再通过
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_id去orders表里查一下,看能不能找到对应订单。
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 |
是 |
所以最后留下来的就是:
BobDave
这也和前面的预期结果完全一致。
七、JOIN 与 NOT EXISTS 的本质区别和心智模型
很多初学者会觉得这两种写法看起来很像,因为都出现了:
mysql
o.user_id = u.user_id
但它们的本质并不一样。
对比一:它们分别想解决什么问题
| 维度 | LEFT JOIN |
NOT EXISTS |
|---|---|---|
| 目的 | 合并两张表的数据 | 检查另一张表里是否存在匹配行 |
| 是否真的使用右表字段 | 会 | 不会 |
| 返回结果 | 一张合并后的逻辑结果集 | 一个真假判断 |
| 重复行风险 | 有 | 基本没有 |
LEFT JOIN 的心智模型
你可以把它理解成:
先把
users和orders逻辑上拼成一张更宽的结果表,再去过滤。
例如某个用户有多笔订单时,JOIN 后会出现多行:
text
user_id | username | order_id
1 | Alice | 101
1 | Alice | 102
2 | Bob | NULL
所以 LEFT JOIN 的特点是:
- 它真的会把匹配行带进结果集中
- 一对多关系下可能产生重复行
- 后续
WHERE、SELECT、聚合都是在这个逻辑结果集上继续处理
NOT EXISTS 的心智模型
你可以把它理解成:
不拼表,只做一次"侦察"。对每个用户去
orders里问一句:有没有对应订单?
如果有,就返回"存在",当前用户丢弃。
如果没有,就返回"不存在",当前用户保留。
这里右表的数据本身并不会被真正拿回来,你最终得到的只是一个 TRUE / FALSE 的判定结果。
一句话抓住本质
JOIN更像"把两张表拼起来再处理"NOT EXISTS更像"拿着条件去另一张表确认一下有没有"
所以虽然两者都写了 o.user_id = u.user_id,但:
- 在
JOIN里,它是连接条件 - 在
NOT EXISTS里,它是相关子查询的过滤条件
八、性能、索引与适用场景对比
先说结论
在现代数据库里,LEFT JOIN + IS NULL 和 NOT EXISTS 往往性能差异很小,不能简单粗暴地说谁一定更快。
常见场景对比
| 场景 | 更常见的结论 |
|---|---|
| MySQL 8+、PostgreSQL 等现代数据库 | 两者通常差异很小,优化器可能生成相同执行计划 |
orders.user_id 上有索引 |
两者都能很快,NOT EXISTS 在语义和短路上略有优势 |
| 没有索引 | 两者都可能慢,写法差异通常不如索引影响大 |
orders 数据量很大 |
NOT EXISTS 在某些情况下更有优势 |
| 老版本 MySQL 5.x | NOT EXISTS 可能被优化器处理得不够好,LEFT JOIN 有时更稳定 |
为什么有人会说 NOT EXISTS 更快
一个常见原因是它有**短路退出**的特性。
也就是说,当数据库在 orders 表中找到当前用户的第一条匹配记录时,就已经可以判定:
- 这个用户"存在订单"
NOT EXISTS为FALSE- 当前用户应被过滤掉
此时数据库就没有必要继续看这个用户后面的剩余订单了。
如果一个用户有很多订单,这种"找到第一条就停止"的特性,在某些场景下会有优势。
为什么很多时候又看不出差别
因为现代数据库优化器很聪明。
它不会死板地按你写出来的字面顺序执行,而是会把 SQL 改写成它认为更优的执行计划。很多时候:
- 你写的是
NOT EXISTS - 优化器内部把它识别成反连接语义
- 最终执行计划和
LEFT JOIN + IS NULL非常接近,甚至一样
所以实际工作中,如果你用 EXPLAIN 看两条语句,执行计划可能几乎一致。
真正决定性能的关键:索引
比起纠结写法,真正更重要的是:
orders.user_id 上有没有索引。
mysql
CREATE INDEX idx_orders_user_id ON orders(user_id);
通常可以这样理解:
- 有索引:两种写法都可能很快
- 没索引:两种写法都可能很慢
索引对性能的影响,通常比 LEFT JOIN 和 NOT 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 NULL和NOT EXISTS哪个性能更好?
比较稳的回答方式是:
理论上
NOT EXISTS有短路退出的优势,不需要把匹配行全部带进逻辑结果集;但在 MySQL 8+、PostgreSQL 等现代数据库里,优化器往往会把两者改写成相近甚至相同的执行计划,所以实际差异通常不大。真正更关键的是orders.user_id上有没有索引。如果是旧版本 MySQL,NOT EXISTS有时反而可能因为优化器较弱而退化,这时LEFT JOIN的执行表现可能更稳定。
这类回答的优点是:
- 不绝对化
- 讲到了短路优势
- 讲到了现代优化器
- 讲到了索引才是关键因素
十一、总结
这道题本质上是在做一件事:
从 users 表中找出那些在 orders 表里完全没有匹配记录的用户。
最需要记住的结论有四个:
LEFT JOIN + IS NULL和NOT EXISTS都能正确解决这类"查不存在"的问题。NOT EXISTS的语义通常更直接,因为它表达的就是"另一张表里不存在匹配行"。- 性能不能脱离数据库版本和索引讨论,
orders.user_id上是否有索引,比单纯换写法更重要。 NOT IN在子查询结果可能出现NULL时有明显陷阱,实际工作中要谨慎使用。
如果只用一句话来概括这道题,可以记成:
LEFT JOIN是"先拼表再筛空",NOT EXISTS是"逐行检查是否存在",而生产环境里真正决定性能的往往是索引和执行计划。