找出某月没下过单的所有用户。(LEFT JOIN + IS NULL / NOT EXISTS)
Find all users who did not place any orders in a given month. (using LEFT JOIN
考察要点
这道题面试官想看你是否掌握 SQL 中查找不存在关系(Anti-Join)的两种核心实现方式:LEFT JOIN ... IS NULL 和 NOT EXISTS。同时,考察你对 SQL 执行顺序、NULL 值处理、索引优化,以及不同方案在性能上的细微差异是否有深刻理解。
核心概念
本题的核心是实现 "反连接"(Anti-Join),即从一个集合(用户表)中,排除掉在另一个集合(某月的订单表)中存在的元素。
1. LEFT JOIN ... IS NULL
这是一种非常直观的实现方式。它通过左连接将两个表关联,对于左表(users)中存在、但右表(orders)中没有匹配记录的行,右表的所有列都会被填充为 NULL。我们利用这个特性来筛选目标用户。
数据示例:
假设我们有 users 表和 orders 表:
users 表
| user_id | user_name |
|---|---|
| 1 | Alice |
| 2 | Bob |
| 3 | Carol |
orders 表
| order_id | user_id | order_time |
|---|---|---|
| 101 | 1 | 2023-10-05 10:00:00 |
| 102 | 3 | 2023-10-15 12:30:00 |
| 103 | 1 | 2023-11-02 09:00:00 |
目标: 找出在 2023 年 10 月没有下过单的用户。预期结果:Bob (user_id: 2)。
LEFT JOIN 的中间结果(ON 条件中包含日期过滤):
FROM users u LEFT JOIN orders o ON u.user_id = o.user_id AND o.order_time >= '2023-10-01' AND o.order_time < '2023-11-01'
| u.user_id | u.user_name | o.order_id | o.user_id | o.order_time |
|---|---|---|---|---|
| 1 | Alice | 101 | 1 | 2023-10-05 10:00:00 |
| 2 | Bob | NULL | NULL | NULL |
| 3 | Carol | 102 | 3 | 2023-10-15 12:30:00 |
最后通过 WHERE o.order_id IS NULL 筛选,即可得到 user_id 为 2 的 Bob。
2. NOT EXISTS
NOT EXISTS 是一种基于子查询的实现方式,语义上更贴近“不存在”。它遍历外层查询的每一行,然后检查该行数据是否满足 NOT EXISTS 内部子查询的条件。
工作原理:
对于 users 表中的每个用户,执行 NOT EXISTS 中的子查询。
- 如果子查询能找到至少一条记录(即该用户在指定月份有订单),
EXISTS返回true,NOT EXISTS返回false,该用户被排除。 - 如果子查询返回空集(即该用户在指定月份无订单),
EXISTS返回false,NOT EXISTS返回true,该用户被选中。
NOT EXISTS 的一个重要优点是,子查询一旦找到第一条匹配记录就会立即停止并返回,无需扫描所有匹配项,这在某些场景下效率更高。
3. NOT IN
NOT IN 是另一种选择,但存在严重陷阱。col NOT IN (subquery) 在子查询结果集中包含 NULL 值时,整个 NOT IN 条件会返回 UNKNOWN(在 WHERE 子句中等同于 false),导致查询结果为空。除非能确保子查询的 user_id 列永不为 NULL(例如该列是主键或有 NOT NULL 约束),否则不推荐使用。
多种解法
假设表结构如下:
1CREATE TABLE users (2 user_id INT PRIMARY KEY,3 user_name VARCHAR(50)4);56CREATE TABLE orders (7 order_id INT PRIMARY KEY,8 user_id INT,9 order_time DATETIME,10 -- 在 user_id 和 order_time 上建立复合索引以优化查询11 INDEX idx_user_time (user_id, order_time)12);
解法 1:LEFT JOIN ... IS NULL
这是最通用、最直观的解法。
- 版本兼容性: ✅ 所有 SQL 数据库版本均支持。
- SQL 实现:
1-- 查找 2023 年 10 月没有下过单的用户2SELECT3 u.user_id,4 u.user_name5FROM6 users u7LEFT JOIN8 orders o ON u.user_id = o.user_id9 AND o.order_time >= '2023-10-01'10 AND o.order_time < '2023-11-01' -- 日期范围条件必须在 ON 子句中11WHERE12 o.order_id IS NULL; -- 或者 o.user_id IS NULL
- 复杂度:
- 时间复杂度:若有索引,通常为 或 ,取决于优化器选择的连接算法(如 Nested Loop Join 或 Hash Join)。
- 空间复杂度:,需要存储左表数据和部分匹配结果。
- 索引依赖:
users(user_id)主键索引。orders(user_id, order_time)复合索引至关重要。它能高效地在ON子句中完成user_id匹配和order_time范围过滤。
解法 2:NOT EXISTS
这是在很多数据库(尤其是 MySQL)中性能更优、语义更清晰的解法。
- 版本兼容性: ✅ 所有 SQL 数据库版本均支持。
- SQL 实现:
1-- 查找 2023 年 10 月没有下过单的用户2SELECT3 u.user_id,4 u.user_name5FROM6 users u7WHERE8 NOT EXISTS (9 SELECT 110 FROM orders o11 WHERE o.user_id = u.user_id12 AND o.order_time >= '2023-10-01'13 AND o.order_time < '2023-11-01'14 );
- 复杂度:
- 时间复杂度:与
LEFT JOIN类似,但由于“找到即停”的特性,在orders表中一个用户有多条记录时,通常比LEFT JOIN更快。 - 空间复杂度: 或很小,因为它不需要在内存中构建大的中间连接结果集。
- 时间复杂度:与
- 索引依赖:
orders(user_id, order_time)复合索引是性能的关键。对于users表的每一行,子查询都能通过索引快速判断是否存在匹配的订单。
常见低分回答
-
LEFT JOIN中将过滤条件误放在WHERE子句:这是最经典的错误。sql1-- 错误示例2SELECT u.user_id FROM users u3LEFT JOIN orders o ON u.user_id = o.user_id4WHERE o.order_id IS NULL5 AND o.order_time >= '2023-10-01'; -- 错误!原因:
WHERE子句在LEFT JOIN生成中间结果之后执行。o.order_time >= ...会将所有o.order_time为NULL的行(即我们想找的目标用户)直接过滤掉,导致结果集永远为空。 -
使用
NOT IN却不处理NULL值:如果orders.user_id列允许为NULL,这个查询会出错。sql1-- 潜在风险示例2SELECT user_id FROM users3WHERE user_id NOT IN (SELECT user_id FROM orders WHERE ...);原因:如果子查询返回的
user_id列表中包含NULL,user_id NOT IN (..., NULL)的结果永远是UNKNOWN,导致外层查询无任何返回。 -
在
WHERE子句的列上使用函数:这会导致索引失效,引发全表扫描。sql1-- 性能低下示例2SELECT ... FROM orders WHERE DATE_FORMAT(order_time, '%Y-%m') = '2023-10';原因:数据库无法使用
order_time上的索引来定位数据,必须对每一行的order_time计算DATE_FORMAT函数,然后才进行比较。正确做法是使用范围查询col >= start AND col < end。 -
使用
COUNT进行判断:虽然逻辑上可行,但通常比LEFT JOIN或NOT EXISTS效率低。sql1-- 效率较低示例2SELECT u.user_id3FROM users u4WHERE (5 SELECT COUNT(*) FROM orders o6 WHERE o.user_id = u.user_id AND o.order_time >= '2023-10-01' AND o.order_time < '2023-11-01'7) = 0;原因:
COUNT(*)需要统计出所有匹配的行数,而EXISTS只需要找到一行即可。对于一个用户有大量订单的场景,COUNT的开销远大于EXISTS。
工程实践与优化
- 索引选择:对于此问题,
orders表的(user_id, order_time)复合索引是性能的命脉。user_id在前,可以快速关联用户;order_time在后,可以在user_id确定的 B+ 树子树上进行高效的范围扫描。 - 执行计划分析 (
EXPLAIN):LEFT JOIN解法:理想的EXPLAIN结果中,Extra字段会显示Using where; Using not exists。这表明 MySQL 优化器已经将LEFT JOIN ... IS NULL智能地转换为了一个高效的 Anti-Join 操作。NOT EXISTS解法:EXPLAIN会显示这是一个DEPENDENT SUBQUERY,但如果索引idx_user_time被有效利用,子查询的成本会非常低(通常是index lookup)。
- 数据倾斜:如果
orders表极大(百亿级),而users表相对较小(百万级),NOT EXISTS通常表现更稳定。因为它对每个用户独立执行子查询,避免了LEFT JOIN可能因少数“超级用户”(订单极多)而产生巨大中间结果集的风险。 - 日期范围处理:始终使用
column >= start_date AND column < end_date_plus_one_day的半开半闭区间形式。这对于DATETIME和TIMESTAMP类型是精确且索引友好的,避免了BETWEEN在处理DATETIME时可能遗漏当天时间部分的问题。
常见追问与延伸题目
-
追问:如果
orders表的数据量增长 100 倍(达到百亿级别),这条 SQL 性能会怎样?怎么优化?- 回答要点 1 (性能瓶颈):两条 SQL 的性能都会显著下降,主要瓶颈在于对
orders表的访问。即使有索引,百亿级别的数据也会让索引树变得非常深,I/O 成本急剧增加。LEFT JOIN可能会因内存压力过大而将中间结果写入磁盘,导致性能雪崩。 - 回答要点 2 (优化方案):
- 分区表 (Partitioning):这是应对海量数据的首选方案。在
orders表上按order_time进行范围分区(如按月分区)。这样,查询2023-10的数据时,数据库只需扫描p_202310这一个分区,数据量从百亿骤降到当月数据(可能几亿),查询性能得到数量级的提升。 - 确保最优索引:再次确认
(user_id, order_time)复合索引在每个分区上都存在。 - 优先
NOT EXISTS:在大数据量下,NOT EXISTS的“找到即停”和低内存占用的优势会更加明显,是更稳健的选择。
- 分区表 (Partitioning):这是应对海量数据的首选方案。在
- 回答要点 1 (性能瓶颈):两条 SQL 的性能都会显著下降,主要瓶颈在于对
-
追问:这个问题反过来,如何找出“只在 2023 年 10 月下过单”的用户?
- 回答要点: 这个问题更复杂,需要满足两个条件:1) 在 10 月有订单;2) 在其他月份没有订单。这通常使用
GROUP BY和HAVING来解决。
sql1SELECT2 user_id3FROM4 orders5GROUP BY6 user_id7HAVING8 -- 订单的最小时间大于等于10月1日9 MIN(order_time) >= '2023-10-01'10 -- 并且订单的最大时间小于11月1日11AND MAX(order_time) < '2023-11-01';或者使用
CASE表达式计数:sql1SELECT2 user_id3FROM4 orders5GROUP BY6 user_id7HAVING8 -- 统计10月份的订单数 > 09 COUNT(CASE WHEN order_time >= '2023-10-01' AND order_time < '2023-11-01' THEN order_id END) > 010 -- 并且统计非10月份的订单数 = 011AND COUNT(CASE WHEN order_time < '2023-10-01' OR order_time >= '2023-11-01' THEN order_id END) = 0; - 回答要点: 这个问题更复杂,需要满足两个条件:1) 在 10 月有订单;2) 在其他月份没有订单。这通常使用
-
追问:如果换成 PostgreSQL 或分布式数据库(如 TiDB),答案有什么不同?
- 回答要点 1 (PostgreSQL):PostgreSQL 的优化器非常成熟,
LEFT JOIN ... IS NULL和NOT EXISTS两种写法通常会被优化成相同的执行计划(如Hash Anti Join),性能差异不大。开发者可以根据语义清晰度自由选择,NOT EXISTS的可读性可能稍好。 - 回答要点 2 (TiDB/分布式数据库):在分布式环境下,Join 的执行策略是关键。数据库会根据表的大小、数据分布和统计信息选择不同的 Join 算法(如
Hash Join,Index Nested Loop Join)。LEFT JOIN可能会触发大规模的数据混洗(Shuffle),网络开销大。NOT EXISTS(或NOT IN)在 TiDB 中可以被优化为Anti Semi Join,如果users表是小表,可以将其广播(Broadcast)到存储orders表的各个节点上进行计算,避免orders表的大量数据移动,性能可能更优。因此,在分布式数据库中,NOT EXISTS的优势可能更明显。
- 回答要点 1 (PostgreSQL):PostgreSQL 的优化器非常成熟,