适用版本:MySQL 8.0.x
当普通的单表查询和基础 JOIN 还不够表达业务需求时,子查询通常就会登场。
例如:
- 查询工资高于公司平均工资的员工
- 查询下过单的客户
- 查询从未下过单的客户
- 查询销量排名前 10 的商品,再从中筛出毛利率高于某阈值的记录
- 把两个来源相近但结构一致的数据集合并起来
这些问题的共同点是:一个查询的判断,依赖另一个查询的结果。这正是子查询最擅长解决的事情。
与此同时,业务里还有一类问题并不强调“关联”,而是强调“集合之间如何合并、求交、求差”。这时就会用到集合操作,比如 UNION、UNION ALL、INTERSECT、EXCEPT。
很多人第一次接触这部分内容时,容易陷入两个误区:
- 只会机械套语法,却不理解每种写法背后的集合语义
- 遇到性能问题时,不知道子查询、JOIN、CTE、集合操作该如何取舍
这篇文章就围绕这两个主题展开,尽量讲清楚:
- 子查询有哪些常见类型
- 相关子查询和非相关子查询的区别
IN、EXISTS、ANY、ALL的语义差别- 如何把子查询写得更稳、更容易维护
- MySQL 8.0 下集合操作的实际用法与版本注意事项
一、什么是子查询
子查询,简单说就是:
在一条 SQL 中,嵌套另一条 SQL,并把内层查询的结果作为外层查询的一部分来使用。
看一个最典型的例子:查询工资高于平均工资的员工。
SELECT id, name, salary
FROM employees
WHERE salary > (
SELECT AVG(salary)
FROM employees
);
这条 SQL 的执行思路可以理解为:
- 先执行内层查询,得到平均工资
- 再执行外层查询,找出工资高于该值的员工
这就是最基础的子查询模式。
二、子查询常见分类
按返回结果形态来看,子查询大致可以分为几类。
| 类型 | 返回结果 | 常见位置 | 示例 |
|---|---|---|---|
| 标量子查询 | 单行单列 | SELECT、WHERE、HAVING |
平均工资、最大时间 |
| 列子查询 | 多行单列 | IN、ANY、ALL |
一组客户 ID |
| 行子查询 | 单行多列 | 多列比较 | 复合条件匹配 |
| 表子查询 | 多行多列 | FROM |
派生表、临时结果集 |
理解这个分类很有帮助,因为它决定了:
- 外层语句能如何使用子查询结果
- 子查询能不能返回多行
- 一旦返回行数超预期,会报什么错
三、标量子查询:返回一个值
1. 最经典的场景:和聚合结果比较
SELECT id, name, salary
FROM employees
WHERE salary > (
SELECT AVG(salary)
FROM employees
);
这里内层子查询只返回一个数值,因此叫标量子查询。
2. 也可以出现在 SELECT 列表中
例如:查询每个客户的最后下单时间。
SELECT c.id,
c.customer_name,
(
SELECT MAX(o.created_at)
FROM orders AS o
WHERE o.customer_id = c.id
) AS last_order_time
FROM customers AS c;
不过这类写法虽然直观,但在数据量较大时,可能会因为对外层每一行都执行一次相关子查询而变慢。后面会讲相关子查询。
3. 常见报错:子查询返回多行
例如:
SELECT id, name
FROM employees
WHERE department_id = (
SELECT department_id
FROM employees
WHERE salary > 20000
);
如果内层查询返回多行,外层又用 = 去接,那就会报错:
- Subquery returns more than 1 row
这说明:
=期待的是单个值- 你的子查询却返回了多个值
这时通常应该改成 IN,或者重新审视业务条件。
四、IN 子查询:外层值是否属于内层结果集
1. 基本用法
查询有过订单的客户:
SELECT id, customer_name
FROM customers
WHERE id IN (
SELECT customer_id
FROM orders
);
这个查询的语义很清晰:
- 先查出所有下过单的
customer_id - 再看客户表中的
id是否在这组结果里
2. 去重不是必须,但可以增强语义明确性
SELECT id, customer_name
FROM customers
WHERE id IN (
SELECT DISTINCT customer_id
FROM orders
);
即使不写 DISTINCT,逻辑结果通常也能成立,但写了会更符合“集合判断”的直觉。
3. NOT IN 要特别小心 NULL
查询从未下过单的客户,很多人会写:
SELECT id, customer_name
FROM customers
WHERE id NOT IN (
SELECT customer_id
FROM orders
);
如果内层结果里出现了 NULL,整个判断逻辑就可能变得不符合预期。更稳妥的做法往往是改用 NOT EXISTS。
这个坑非常经典,后文会专门讲。
五、EXISTS 子查询:关注“是否存在”,不关心具体返回什么
1. 基本语义
查询有过订单的客户,也可以写成:
SELECT c.id, c.customer_name
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.id
);
这里的意思是:
- 对客户表的每一行
- 去订单表中看是否存在至少一条满足
o.customer_id = c.id的记录 - 只要存在,就保留该客户
2. EXISTS 为什么常常比 IN 更适合表达“存在性”
因为它的语义非常直接:
- 不是拿一个值去比对一大堆结果
- 而是判断“关联记录是否存在”
特别是写相关子查询时,EXISTS 往往更贴近业务表达。
3. 查询从未下单的客户
SELECT c.id, c.customer_name
FROM customers AS c
WHERE NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.id
);
相比 NOT IN,这种写法通常更稳,尤其是在可能出现 NULL 的情况下。
六、相关子查询与非相关子查询
这是理解子查询时非常关键的一步。
1. 非相关子查询
内层查询不依赖外层查询,可以独立执行。
SELECT id, name, salary
FROM employees
WHERE salary > (
SELECT AVG(salary)
FROM employees
);
这里内层查询不需要知道外层当前是哪一行,因此它是非相关子查询。
2. 相关子查询
内层查询依赖外层当前行的值。
SELECT c.id, c.customer_name
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.id
);
这里子查询中的 c.id 来自外层客户表,因此内层需要随着外层每一行变化而重新判断,这就是相关子查询。
3. 如何理解它们的差异
| 类型 | 是否依赖外层当前行 | 常见特点 |
|---|---|---|
| 非相关子查询 | 否 | 可先单独求值,再供外层使用 |
| 相关子查询 | 是 | 需要针对外层每一行做存在性或条件判断 |
4. 相关子查询不等于一定慢
很多人一看到相关子查询就觉得一定性能差。实际上,MySQL 优化器会做一定程度的改写和优化。
但从经验上说:
- 如果是简单存在性判断,
EXISTS常常写法自然 - 如果是需要汇总后再关联,派生表或 CTE 可能更清晰
- 如果外层数据量巨大,相关子查询要特别关注执行计划
七、ANY 和 ALL:不常见,但值得认识
这两个关键字没有 IN、EXISTS 常用,但在某些比较场景下很有表达力。
1. > ANY
表示“大于子查询结果中的任意一个值”,等价于“大于最小值”。
SELECT id, name, salary
FROM employees
WHERE salary > ANY (
SELECT salary
FROM employees
WHERE department_id = 10
);
如果部门 10 的工资集合是 [8000, 10000, 15000],那么 > ANY 实际只要大于其中某个值即可,也就等价于大于 8000。
2. > ALL
表示“大于子查询结果中的所有值”,等价于“大于最大值”。
SELECT id, name, salary
FROM employees
WHERE salary > ALL (
SELECT salary
FROM employees
WHERE department_id = 10
);
这就表示:工资要高于部门 10 中所有人的工资。
3. 什么时候值得用
如果团队成员对 ANY、ALL 不够熟悉,实际项目里更常见的写法还是先聚合再比较,例如:
SELECT id, name, salary
FROM employees
WHERE salary > (
SELECT MAX(salary)
FROM employees
WHERE department_id = 10
);
可读性通常更高。
八、FROM 子查询:把查询结果当成临时表
除了在 WHERE 中嵌套子查询,另一类常见写法是把子查询放在 FROM 中,这时它通常被称为派生表。
1. 示例:先统计每个客户的订单金额,再筛选大客户
SELECT t.customer_id,
t.total_paid
FROM (
SELECT customer_id,
SUM(total_amount) AS total_paid
FROM orders
WHERE order_status = 'PAID'
GROUP BY customer_id
) AS t
WHERE t.total_paid >= 50000;
这类写法适合把复杂逻辑拆成两层:
- 先得到一个中间结果集
- 再在外层做过滤、排序或继续关联
2. 使用派生表时别忘了别名
在 MySQL 中,FROM 子查询必须起别名,否则会报错。
九、CTE:让复杂子查询更容易读
MySQL 8.0 开始支持 CTE(Common Table Expression,公用表表达式),也就是 WITH 语法。
从能力上看,它和很多 FROM 子查询可以互相替代,但在可读性上通常更好。
1. 用 CTE 改写刚才的例子
WITH customer_paid_amount AS (
SELECT customer_id,
SUM(total_amount) AS total_paid
FROM orders
WHERE order_status = 'PAID'
GROUP BY customer_id
)
SELECT customer_id, total_paid
FROM customer_paid_amount
WHERE total_paid >= 50000;
2. 为什么 CTE 对复杂 SQL 很有帮助
因为它能把 SQL 写成“先定义,再使用”的结构,特别适合:
- 多层统计
- 多次复用同一中间结果
- 需要逐步拆解逻辑的复杂报表查询
虽然本篇重点是子查询和集合操作,但在 MySQL 8.0 的语境下,CTE 已经是非常重要的配套能力,值得一起掌握。
十、集合操作:从“关联思维”切换到“集合思维”
JOIN 强调的是“根据关联关系拼接列”;集合操作强调的是“如何处理多个结果集之间的并、交、差关系”。
常见集合操作概览
| 操作 | 含义 | 是否去重 |
|---|---|---|
UNION |
合并两个结果集 | 是 |
UNION ALL |
合并两个结果集 | 否 |
INTERSECT |
取两个结果集交集 | 是 |
EXCEPT |
取左结果集减右结果集 | 是 |
说明:
INTERSECT与EXCEPT在 MySQL 8.0.31 及以上版本 可用;若你的 8.0 小版本较早,需要使用EXISTS、JOIN、NOT EXISTS等方式模拟。
十一、UNION 与 UNION ALL
1. UNION:合并并去重
例如:查询“来自北京的客户”和“VIP 客户”的客户名列表。
SELECT customer_name
FROM customers
WHERE city = '北京'
UNION
SELECT customer_name
FROM customers
WHERE level = 'VIP';
如果某位客户同时满足两个条件,最终结果中只出现一次。
2. UNION ALL:合并但不去重
SELECT customer_name
FROM customers
WHERE city = '北京'
UNION ALL
SELECT customer_name
FROM customers
WHERE level = 'VIP';
如果某位客户同时满足两个条件,就会出现两次。
3. 应该怎么选
| 场景 | 更适合的写法 |
|---|---|
| 明确需要去重后的并集 | UNION |
| 明确保留全部来源记录 | UNION ALL |
| 更关注性能,且上层允许重复 | UNION ALL |
由于 UNION 要做去重,通常比 UNION ALL 更重一些。
4. 集合操作的列要求
参与 UNION 的多个查询,必须满足:
- 列数一致
- 对应列类型兼容
- 一般建议语义也保持一致
例如下面就是合法的:
SELECT id, customer_name
FROM customers
UNION ALL
SELECT id, product_name
FROM products;
语法上可以成立,但业务语义未必合理。实际写 SQL 时,除了“能跑”,还要看“像不像同一种结果集”。
十二、INTERSECT 与 EXCEPT
1. INTERSECT:求交集
查询既是北京客户,又是 VIP 客户的客户名:
SELECT customer_name
FROM customers
WHERE city = '北京'
INTERSECT
SELECT customer_name
FROM customers
WHERE level = 'VIP';
这类写法很适合表达“同时属于两个集合”。
2. EXCEPT:求差集
查询北京客户中,剔除 VIP 客户后的客户名:
SELECT customer_name
FROM customers
WHERE city = '北京'
EXCEPT
SELECT customer_name
FROM customers
WHERE level = 'VIP';
它的语义是:
- 取左边结果集
- 去掉右边也出现的部分
3. 低版本替代思路
如果你的 MySQL 8.0 小版本不支持 INTERSECT 或 EXCEPT,可以这样理解替代关系:
| 集合操作 | 可替代思路 |
|---|---|
INTERSECT |
INNER JOIN / EXISTS |
EXCEPT |
NOT EXISTS / LEFT JOIN ... IS NULL |
例如把“同时在两个集合中”的判断改写为:
SELECT c1.customer_name
FROM customers AS c1
WHERE c1.city = '北京'
AND EXISTS (
SELECT 1
FROM customers AS c2
WHERE c2.customer_name = c1.customer_name
AND c2.level = 'VIP'
);
当然,在单表场景下,很多时候直接一个 WHERE city = '北京' AND level = 'VIP' 就够了。这里主要是帮助你理解集合语义。
十三、常见坑:子查询和集合操作里最容易踩的点
1. NOT IN 遇到 NULL
这是最经典的一类坑。
如果你写:
SELECT id, customer_name
FROM customers
WHERE id NOT IN (
SELECT customer_id
FROM orders
);
而 orders.customer_id 中恰好存在 NULL,结果可能和你想象的不一样。更稳妥的做法通常是:
SELECT c.id, c.customer_name
FROM customers AS c
WHERE NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.id
);
2. 标量子查询却返回多行
外层用 =、> 这类单值比较时,一定要确认子查询只会返回一个值。
3. 相关子查询写得过重
如果外层 100 万行,内层子查询又很复杂,那么即使优化器会做处理,也要非常警惕执行成本。此时可以考虑:
- 改写为 JOIN
- 先汇总成派生表或 CTE
- 为关联字段补充索引
4. UNION 与 UNION ALL 混淆
如果你本来就需要保留所有记录,却误用了 UNION,结果会被去重;如果你本来想得到并集,却用了 UNION ALL,结果又可能出现重复。
5. 集合操作的列语义不一致
虽然某些情况下列类型兼容就能执行,但如果左边查的是客户、右边查的是商品,结果集就会在语义上变得很奇怪。
十四、实践建议:如何选择子查询、JOIN 与集合操作
1. 判断你是在做“关系匹配”还是“集合判断”
- 如果你要把不同表的字段拼起来展示,通常优先想到 JOIN
- 如果你要判断“某行是否满足另一组查询结果”,通常可以考虑 子查询
- 如果你要处理两个结果集的并、交、差关系,通常考虑 集合操作
2. 存在性判断优先考虑 EXISTS
例如“是否下过单”“是否有可用优惠券”“是否命中过某类规则”,EXISTS 的语义通常非常自然。
3. 多层复杂逻辑优先考虑 CTE
一条 SQL 套三层子查询虽然能写,但未必好读。MySQL 8.0 已经支持 CTE,适合把复杂逻辑分步表达。
4. 任何复杂查询都要关注结果粒度
特别是“先子查询、再 JOIN、再聚合”这种链式 SQL,一定要想清楚每一层返回的粒度是什么,否则最终结果很容易偏。
5. 遇到性能问题别只盯语法,先看执行计划
“子查询慢”不一定真的是子查询本身的问题,也可能是:
- 没有索引
- 过滤条件不够早
- 结果集过大
- 外层扫描范围过宽
语法选择重要,但执行计划和索引往往更关键。
十五、小结
子查询和集合操作,都是 SQL 里非常重要的表达能力,只是它们解决的问题角度不同:
- 子查询强调“一个查询依赖另一个查询的结果”
- 集合操作强调“多个结果集如何做并、交、差”
这篇文章重点讲了:
- 子查询的基本概念与分类
- 标量子查询、
IN、EXISTS、相关子查询的常见用法 ANY、ALL的语义区别- 派生表与 CTE 的实践价值
UNION、UNION ALL、INTERSECT、EXCEPT的使用方式与版本注意事项NOT IN和NULL、多行返回、性能问题等高频陷阱
当你不再把子查询理解成“SQL 里套 SQL”,而是理解成“用另一个结果集参与当前判断”;也不再把集合操作理解成“特殊语法”,而是理解成“结果集之间的并交差”,这部分知识就真正吃透了。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!