INTERSECT 和 EXCEPT 的语义?MySQL 8.0 之前不支持,应该如何用 JOIN/EXISTS 模拟?
What are the semantics of INTERSECT and EXCEPT? How to simulate them using JOIN/EXISTS since
考察要点
这道题面试官想看你是否懂 SQL 标准中的集合运算法则,以及在特定数据库(如 MySQL 8.0 之前版本)不支持这些标准语法时,你是否具备逻辑等价转换的能力。核心考察点包括:
- 集合运算语义:对
INTERSECT(交集) 和EXCEPT(差集) 的精确理解,特别是它们如何处理NULL值和重复行。 - SQL 模拟能力:能否熟练运用
JOIN、[NOT] EXISTS、[NOT] IN等基础构件,实现复杂的逻辑。 - 细节与陷阱:是否清楚
NOT IN与NULL结合时的致命陷阱,以及JOIN在处理NULL时的行为,并给出正确、健壮的解决方案。
核心概念
INTERSECT 和 EXCEPT 是 SQL 标准中用于处理两个查询结果集的集合运算符。
1. INTERSECT (交集)
INTERSECT 返回同时存在于两个查询结果集中的所有唯一行。
- 语义:
SELECT ... FROM t1 INTERSECT SELECT ... FROM t2 - 关键特性:
- 去重:结果集会自动去重(等价于对最终结果执行
DISTINCT)。 - NULL 处理:在集合运算中,两个
NULL值被视为相等。这与WHERE a = b中NULL = NULL返回UNKNOWN(实际为 false) 的行为完全不同。
- 去重:结果集会自动去重(等价于对最终结果执行
数据示例: | table_a | | table_b | |:-------:| |:-------:| | id | | id | | 1 | | 1 | | 2 | | 3 | | 3 | | 3 | | NULL | | NULL |
1SELECT id FROM table_a2INTERSECT3SELECT id FROM table_b;
输出结果:
| id |
|---|
| 1 |
| 3 |
| NULL |
2. EXCEPT (差集)
EXCEPT 返回存在于第一个查询结果集中,但不存在于第二个查询结果集中的所有唯一行。
(注意:在 Oracle 中,对应的关键字是 MINUS)
- 语义:
SELECT ... FROM t1 EXCEPT SELECT ... FROM t2 - 关键特性:
- 去重:结果集同样会自动去重。
- NULL 处理:同样地,两个
NULL值被视为相等。如果t1和t2中都有NULL,EXCEPT运算会将其抵消。
数据示例 (同上):
1SELECT id FROM table_a2EXCEPT3SELECT id FROM table_b;
输出结果:
| id |
|---|
| 2 |
版本差异:
- MySQL: 8.0.31+ 才完整支持
INTERSECT和EXCEPT。之前版本需要手动模拟。 - PostgreSQL, SQL Server, Oracle (MINUS): 长期支持这些标准集合运算符。
多种解法
假设我们有 table_a 和 table_b,列结构相同(例如 c1, c2),我们需要在不支持 INTERSECT/EXCEPT 的 MySQL 5.7 中模拟它们。
模拟 INTERSECT
解法 1:使用 INNER JOIN 和空值安全比较运算符 <=>
这是最直观且最健壮的模拟方式。
- 标注:✅ 所有 MySQL 版本
- SQL 代码:
1-- 假设比较 c1, c2 两列2SELECT DISTINCT a.c1, a.c23FROM table_a AS a4INNER JOIN table_b AS b5 ON a.c1 <=> b.c1 AND a.c2 <=> b.c2;
- 复杂度:时间复杂度取决于 Join 策略。若
b表的连接列上有索引,则接近 或 (Hash Join)。空间复杂度取决于结果集大小。 - 索引依赖:在
table_b(c1, c2)上建立复合索引可以极大提升性能。
说明:必须使用 MySQL 特有的空值安全比较运算符 <=> 来正确处理 NULL 值。如果使用 =,NULL = NULL 会返回 false,导致 NULL 的交集丢失。如果确定列不会为 NULL,则可以使用 =。DISTINCT 是必需的,因为 INTERSECT 的语义包含去重。
解法 2:使用 EXISTS 子查询
EXISTS 在处理 NULL 时也需要 <=> 配合,逻辑清晰。
- 标注:✅ 所有 MySQL 版本
- SQL 代码:
1SELECT DISTINCT a.c1, a.c22FROM table_a AS a3WHERE EXISTS (4 SELECT 15 FROM table_b AS b6 WHERE a.c1 <=> b.c1 AND a.c2 <=> b.c27);
- 复杂度:
EXISTS通常被优化为 Semi-Join,性能很高。若b表相关列有索引,对a表的每一行,在b表的查找都是对数或常数时间。 - 索引依赖:
table_b(c1, c2)上的复合索引至关重要。
模拟 EXCEPT
解法 1:使用 LEFT JOIN ... IS NULL
这是模拟差集的经典模式,同样需要注意 NULL 值。
- 标注:✅ 所有 MySQL 版本
- SQL 代码:
1SELECT DISTINCT a.c1, a.c22FROM table_a AS a3LEFT JOIN table_b AS b4 ON a.c1 <=> b.c1 AND a.c2 <=> b.c25WHERE b.c1 IS NULL; -- 或任何 b 表中在 ON 条件里用到的非空列
- 复杂度:与
INNER JOIN类似。 - 索引依赖:
table_b(c1, c2)上的复合索引是性能的关键。
说明:LEFT JOIN 会返回 a 表的所有行,对于 a 表中能在 b 表找到匹配的行,b 的列会有值;找不到的,b 的列则为 NULL。我们正是利用 b.c1 IS NULL 来筛选出那些在 a 中存在但在 b 中不存在的行。
解法 2:使用 NOT EXISTS 子查询
这是最安全、最推荐的模拟 EXCEPT 的方式,因为它完美避开了 NOT IN 的陷阱。
- 标注:✅ 所有 MySQL 版本
- SQL 代码:
1SELECT DISTINCT a.c1, a.c22FROM table_a AS a3WHERE NOT EXISTS (4 SELECT 15 FROM table_b AS b6 WHERE a.c1 <=> b.c1 AND a.c2 <=> b.c27);
- 复杂度:
NOT EXISTS通常被优化为 Anti-Join,性能优秀。 - 索引依赖:
table_b(c1, c2)上的复合索引是必须的。
常见低分回答
-
模拟
INTERSECT时使用=代替<=>:sql1-- 错误:当 c1 或 c2 为 NULL 时,无法正确匹配2SELECT DISTINCT a.c1, a.c2 FROM table_a a JOIN table_b b ON a.c1 = b.c1;这是最常见的错误,忽略了
NULL在JOIN条件中的特殊行为。 -
模拟
EXCEPT时使用NOT IN且未处理NULL:sql1-- 极度危险:如果 table_b 的 c1 列中存在任何 NULL 值,整个查询将返回空集!2SELECT c1, c2 FROM table_a WHERE (c1, c2) NOT IN (SELECT c1, c2 FROM table_b);这是面试中的一个巨大减分项。因为
val NOT IN (..., NULL, ...)的逻辑结果是UNKNOWN,在WHERE子句中被当作FALSE处理,导致所有行都被过滤掉。 -
模拟时忘记
DISTINCT:sql1-- 错误:结果可能包含重复行,不符合 INTERSECT/EXCEPT 的去重语义2SELECT a.c1, a.c2 FROM table_a a JOIN table_b b ON a.c1 <=> b.c1 AND a.c2 <=> b.c2;这表明候选人对
INTERSECT/EXCEPT的定义理解不完全。 -
混淆
EXCEPT和NOT EXISTS的去重行为:EXCEPT会对table_a的结果先去重,再执行差集运算。而NOT EXISTS是对table_a的每一行进行判断。因此,模拟EXCEPT时,SELECT DISTINCT a.*中的DISTINCT是必不可少的。
工程实践与优化
-
优先使用原生语法:在 MySQL 8.0+ 或其他支持的数据库中,应始终优先使用
INTERSECT和EXCEPT。代码更简洁、意图更明确,且数据库优化器能选择最高效的内部算法(如 Hash Aggregate 或 Sort-Merge Anti Join)。 -
EXISTSvsIN:在模拟场景中,EXISTS/NOT EXISTS通常比IN/NOT IN更优。- 性能:
EXISTS遇到第一个匹配即返回,无需扫描完子查询的全部结果。优化器也更容易将其转换为高效的 Semi-Join / Anti-Join。 - 正确性:
NOT EXISTS能完美处理NULL,而NOT IN存在致命的NULL陷阱。
- 性能:
-
执行计划分析:
- 使用
EXPLAIN分析模拟查询。对于LEFT JOIN ... IS NULL的EXCEPT模拟,你应该在Extra列看到Using where,并且 Join 类型是left join。 - 对于
NOT EXISTS,你可能会在Extra列看到Using anti-join(如Not-exists或Materialize-not-exists),这表示优化器采用了高效的反连接策略。 - 关键是确保对
table_b的访问类型(type)是ref或eq_ref,而不是ALL(全表扫描),这依赖于table_b上的索引。
- 使用
-
数据量级影响:当
table_a和table_b都非常大时,JOIN可能会产生巨大的中间结果集。此时EXISTS的优势(找到即停)更为明显。数据库原生INTERSECT/EXCEPT可能会使用基于哈希的算法,在内存充足的情况下,性能通常优于基于排序或嵌套循环的JOIN模拟。
常见追问与延伸题目
-
追问:如果要模拟保留重复行的
INTERSECT ALL和EXCEPT ALL,该怎么做?- 回答要点:这需要精确匹配每一行的出现次数。最佳方法是使用窗口函数
ROW_NUMBER()。- 模拟
INTERSECT ALL:分别为table_a和table_b的结果集,使用ROW_NUMBER() OVER(PARTITION BY c1, c2 ORDER BY c1)生成一个在每个重复组内唯一的序号。然后INNER JOIN这两个带有序号的结果集,连接条件为所有列相等且行号相等。 - 模拟
EXCEPT ALL:同样使用ROW_NUMBER()生成序号。然后LEFT JOIN两个结果集,筛选出b表匹配不上的行(b.row_num IS NULL)。
- 模拟
- 回答要点:这需要精确匹配每一行的出现次数。最佳方法是使用窗口函数
-
追问:你提到的
<=>是 MySQL 特有的,如果要在严格遵循 SQL 标准的数据库(但假设它也不支持INTERSECT)里模拟,该如何处理 NULL?- 回答要点:需要用更通用的
OR和IS NULL组合来重写ON条件。a.c1 <=> b.c1的标准等价写法是(a.c1 = b.c1 OR (a.c1 IS NULL AND b.c1 IS NULL))。- 这种写法更冗长,但跨数据库兼容性好。现代数据库优化器通常能识别这种模式并进行优化,但其性能可能略逊于专用的空值安全比较运算符。
- 回答要点:需要用更通用的
-
追问:为什么
NOT IN在子查询有NULL时会“失效”?请从三值逻辑(Three-Valued Logic)的角度解释。- 回答要点:SQL 的逻辑运算遵循三值逻辑:
TRUE,FALSE,UNKNOWN。value IN (set)的意思是value = v1 OR value = v2 OR ...。value NOT IN (set)的意思是value <> v1 AND value <> v2 AND ...。- 当集合中有一个值为
NULL时,比如val NOT IN (1, 2, NULL),它等价于val <> 1 AND val <> 2 AND val <> NULL。 - 任何值与
NULL的比较(=、<>)结果都是UNKNOWN。 - 逻辑表达式
... AND UNKNOWN的最终结果是UNKNOWN。 - 在
WHERE子句中,只有结果为TRUE的行才会被保留,FALSE和UNKNOWN都会被丢弃。因此,整个NOT IN条件永远无法为TRUE,导致查询返回空集。
- 回答要点:SQL 的逻辑运算遵循三值逻辑: