返回首页

08|子查询与集合操作

适用版本:MySQL 8.0.x

当普通的单表查询和基础 JOIN 还不够表达业务需求时,子查询通常就会登场。

例如:

  • 查询工资高于公司平均工资的员工
  • 查询下过单的客户
  • 查询从未下过单的客户
  • 查询销量排名前 10 的商品,再从中筛出毛利率高于某阈值的记录
  • 把两个来源相近但结构一致的数据集合并起来

这些问题的共同点是:一个查询的判断,依赖另一个查询的结果。这正是子查询最擅长解决的事情。

与此同时,业务里还有一类问题并不强调“关联”,而是强调“集合之间如何合并、求交、求差”。这时就会用到集合操作,比如 UNIONUNION ALLINTERSECTEXCEPT

很多人第一次接触这部分内容时,容易陷入两个误区:

  1. 只会机械套语法,却不理解每种写法背后的集合语义
  2. 遇到性能问题时,不知道子查询、JOIN、CTE、集合操作该如何取舍

这篇文章就围绕这两个主题展开,尽量讲清楚:

  • 子查询有哪些常见类型
  • 相关子查询和非相关子查询的区别
  • INEXISTSANYALL 的语义差别
  • 如何把子查询写得更稳、更容易维护
  • MySQL 8.0 下集合操作的实际用法与版本注意事项

一、什么是子查询

子查询,简单说就是:

在一条 SQL 中,嵌套另一条 SQL,并把内层查询的结果作为外层查询的一部分来使用。

看一个最典型的例子:查询工资高于平均工资的员工。

SELECT id, name, salary
FROM employees
WHERE salary > (
    SELECT AVG(salary)
    FROM employees
);

这条 SQL 的执行思路可以理解为:

  1. 先执行内层查询,得到平均工资
  2. 再执行外层查询,找出工资高于该值的员工

这就是最基础的子查询模式。


二、子查询常见分类

按返回结果形态来看,子查询大致可以分为几类。

类型 返回结果 常见位置 示例
标量子查询 单行单列 SELECTWHEREHAVING 平均工资、最大时间
列子查询 多行单列 INANYALL 一组客户 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 可能更清晰
  • 如果外层数据量巨大,相关子查询要特别关注执行计划

七、ANYALL:不常见,但值得认识

这两个关键字没有 INEXISTS 常用,但在某些比较场景下很有表达力。

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. 什么时候值得用

如果团队成员对 ANYALL 不够熟悉,实际项目里更常见的写法还是先聚合再比较,例如:

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;

这类写法适合把复杂逻辑拆成两层:

  1. 先得到一个中间结果集
  2. 再在外层做过滤、排序或继续关联

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 取左结果集减右结果集

说明:INTERSECTEXCEPTMySQL 8.0.31 及以上版本 可用;若你的 8.0 小版本较早,需要使用 EXISTSJOINNOT EXISTS 等方式模拟。


十一、UNIONUNION 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 时,除了“能跑”,还要看“像不像同一种结果集”。


十二、INTERSECTEXCEPT

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 小版本不支持 INTERSECTEXCEPT,可以这样理解替代关系:

集合操作 可替代思路
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. UNIONUNION ALL 混淆

如果你本来就需要保留所有记录,却误用了 UNION,结果会被去重;如果你本来想得到并集,却用了 UNION ALL,结果又可能出现重复。

5. 集合操作的列语义不一致

虽然某些情况下列类型兼容就能执行,但如果左边查的是客户、右边查的是商品,结果集就会在语义上变得很奇怪。


十四、实践建议:如何选择子查询、JOIN 与集合操作

1. 判断你是在做“关系匹配”还是“集合判断”

  • 如果你要把不同表的字段拼起来展示,通常优先想到 JOIN
  • 如果你要判断“某行是否满足另一组查询结果”,通常可以考虑 子查询
  • 如果你要处理两个结果集的并、交、差关系,通常考虑 集合操作

2. 存在性判断优先考虑 EXISTS

例如“是否下过单”“是否有可用优惠券”“是否命中过某类规则”,EXISTS 的语义通常非常自然。

3. 多层复杂逻辑优先考虑 CTE

一条 SQL 套三层子查询虽然能写,但未必好读。MySQL 8.0 已经支持 CTE,适合把复杂逻辑分步表达。

4. 任何复杂查询都要关注结果粒度

特别是“先子查询、再 JOIN、再聚合”这种链式 SQL,一定要想清楚每一层返回的粒度是什么,否则最终结果很容易偏。

5. 遇到性能问题别只盯语法,先看执行计划

“子查询慢”不一定真的是子查询本身的问题,也可能是:

  • 没有索引
  • 过滤条件不够早
  • 结果集过大
  • 外层扫描范围过宽

语法选择重要,但执行计划和索引往往更关键。


十五、小结

子查询和集合操作,都是 SQL 里非常重要的表达能力,只是它们解决的问题角度不同:

  • 子查询强调“一个查询依赖另一个查询的结果”
  • 集合操作强调“多个结果集如何做并、交、差”

这篇文章重点讲了:

  • 子查询的基本概念与分类
  • 标量子查询、INEXISTS、相关子查询的常见用法
  • ANYALL 的语义区别
  • 派生表与 CTE 的实践价值
  • UNIONUNION ALLINTERSECTEXCEPT 的使用方式与版本注意事项
  • NOT INNULL、多行返回、性能问题等高频陷阱

当你不再把子查询理解成“SQL 里套 SQL”,而是理解成“用另一个结果集参与当前判断”;也不再把集合操作理解成“特殊语法”,而是理解成“结果集之间的并交差”,这部分知识就真正吃透了。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

07|多表查询与 JOIN 详解

下一篇

09|聚合函数与分组统计