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

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

一、题目背景

题目要求

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

  • LEFT JOIN + IS NULL
  • NOT 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

所以这条语句的逻辑是:

  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_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 是

所以最后留下来的就是:

  • Bob
  • Dave

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


七、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 表里完全没有匹配记录的用户。

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

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

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

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

相关推荐
IvorySQL2 小时前
PostgreSQL 日报|修复截断 zstd 备份检测(10 月 9 日)
数据库·postgresql
刘胡子大叔2 小时前
SQL 脚本的导入顺序
数据库·sql
代码什么用3 小时前
Spring对IoC的实现
数据库·spring
hz567895 小时前
涉密视频会议设备配置指南:终端、音视频采集与配套设施选型
服务器·网络·数据库·安全·实时音视频·信息与通信·智能硬件
广州浮点FLOATLIC5 小时前
许可证服务器迁移后软件打不开:研发 IT 怎样定位连接问题
linux·服务器·数据库
程序员Sunday5 小时前
MySQL 为什么使用 B+ 树索引?把范围查询、回表和覆盖索引连起来
数据库·mysql
半杯咖啡半行码5 小时前
Qt开发实战:数据库、MV 模式、QProcess与串口通信全攻略
数据库·qt
小米里的大麦5 小时前
16 MySQL 事务
数据库·mysql
西柚小萌新5 小时前
【LLM&&AI应用开发 八股文】--4.3.Agent智能体(下)
java·开发语言·数据库
邓工说电7 小时前
智慧断路器安全吗?数据加密、离线保护与合规认证全解读
大数据·数据库·人工智能·智能断路器·炜晔科技