SQL基础-子查询与CTE
继上一篇整理了 JOIN 和 UNION 之后,这篇笔记接着复习子查询(Subquery)和 CTE(公用表表达式)。子查询是 SQL 中非常灵活的工具,而 CTE 可以看作子查询的”升级版”,能让复杂查询的可读性大幅提升。两者经常搭配 JOIN、UNION 一起使用,是编写复杂查询的核心技能。
本文沿用上一篇的示例表,这里再列一次建表语句方便独立阅读:
1 | -- 部门表 |
一、子查询概述
子查询(Subquery)就是嵌套在另一个 SQL 语句内部的 SELECT 查询,通常用括号 () 包裹。子查询可以出现在 WHERE、FROM、SELECT、HAVING 等子句中。
1 | -- 子查询的基本形式 |
子查询可以从两个维度来分类:
| 分类维度 | 类型 | 说明 |
|---|---|---|
| 按返回结果 | 标量子查询 | 返回单个值(一行一列) |
| 列子查询 | 返回一列多行 | |
| 行子查询 | 返回一行多列 | |
| 表子查询 | 返回多行多列(一张临时表) | |
| 按关联性 | 非关联子查询 | 子查询独立执行,与外层无关 |
| 关联子查询 | 子查询依赖外层查询的值 |
二、按返回结果分类
1. 标量子查询(返回单个值)
子查询只返回一行一列(一个标量值),常用于比较运算符(=、>、< 等)的右侧。
1 | -- 查询工资高于全公司平均工资的员工 |
| name | salary |
|---|---|
| 张三 | 15000 |
| 李四 | 18000 |
| 周八 | 22000 |
| 吴九 | 15000 |
在 SELECT 中使用标量子查询:
1 | -- 查询每个员工的工资与公司平均工资的差值 |
⚠️ 注意:如果标量子查询返回了多行,SQL 会报错!确保子查询结果只有一行一列。
1 | -- ❌ 错误:子查询返回多行,不能用 = 比较 |
2. 列子查询(返回一列多行)
子查询返回一列多行,常配合 IN、ANY、ALL、EXISTS 使用。
1 | -- 查询有员工的部门名称 |
| dept_name |
|---|
| 技术部 |
| 销售部 |
| 人事部 |
1 | -- 查询参与了项目的员工 |
| name | salary |
|---|---|
| 张三 | 15000 |
| 李四 | 18000 |
| 王五 | 12000 |
| 周八 | 22000 |
| 吴九 | 15000 |
| 郑十 | 13000 |
3. 行子查询(返回一行多列)
子查询返回一行多列,可以与多个列同时比较。
1 | -- 查询与张三在同一部门且工资相同的员工 |
| name | dept_id | salary |
|---|---|---|
| 张三 | 1 | 15000 |
💡 补充:行子查询用的不多,但在需要同时匹配多个列时很有用。注意子查询必须恰好返回一行。
4. 表子查询(派生表 / Derived Table)
子查询返回多行多列,放在 FROM 子句中当作临时表使用,也叫派生表。
1 | -- 查询每个部门工资最高的员工 |
| name | salary | dept_name | max_salary |
|---|---|---|---|
| 周八 | 22000 | 技术部 | 22000 |
| 吴九 | 15000 | 销售部 | 15000 |
| 郑十 | 13000 | 人事部 | 13000 |
⚠️ 注意:MySQL 要求
FROM中的子查询(派生表)必须指定别名,否则会报错。
1 | -- ❌ 错误:派生表没有别名 |
三、⭐ 按关联性分类
1. 非关联子查询(独立子查询)
子查询可以独立执行,不依赖外层查询的任何值。执行顺序:先执行子查询,再执行外层查询。
前面大部分示例都是非关联子查询:
1 | -- 非关联子查询:子查询独立执行,返回平均工资 |
2. ⭐ 关联子查询(相关子查询)
子查询引用了外层查询的列,每处理外层的一行,子查询就要重新执行一次。
1 | -- 查询工资高于其所在部门平均工资的员工 |
执行逻辑:对于外层的每一行,子查询都会根据该行的 dept_id 重新计算对应部门的平均工资。
| name | salary | dept_id |
|---|---|---|
| 周八 | 22000 | 1 |
| 吴九 | 15000 | 2 |
| 郑十 | 13000 | 3 |
💡 解析:
- 技术部平均:(15000 + 18000 + 22000) / 3 ≈ 18333 → 周八(22000) 高于平均
- 销售部平均:(12000 + 9000 + 15000) / 3 = 12000 → 吴九(15000) 高于平均
- 人事部平均:(11000 + 13000) / 2 = 12000 → 郑十(13000) 高于平均
⚠️ 性能提示:关联子查询对外层每一行都要执行一次子查询,当数据量大时性能较差。很多关联子查询可以改写为 JOIN 来优化,后面会对比说明。
四、⭐ 子查询常用关键字
1. IN / NOT IN
判断值是否在子查询的结果集中。
1 | -- 查询有项目的员工 |
⚠️ NOT IN 的 NULL 陷阱:如果子查询结果中包含
NULL,NOT IN会返回空结果!因为任何值与NULL比较的结果都是UNKNOWN。
1 | -- ❌ 危险:如果子查询结果包含 NULL,NOT IN 返回空 |
2. ⭐ EXISTS / NOT EXISTS
判断子查询是否能返回至少一行数据(不关心具体值,只关心”有没有”)。
1 | -- 查询参与了项目的员工 |
| NOT EXISTS 结果 |
|---|
| 赵六 |
| 孙七 |
| 刘一 |
| 陈二 |
💡 补充:
EXISTS中的SELECT 1只是占位符,写SELECT *或SELECT 42效果一样,因为 EXISTS 只判断有无结果行,不关心具体返回什么值。
3. ANY / SOME / ALL
配合比较运算符使用,与列子查询的结果进行批量比较。SOME 是 ANY 的同义词。
1 | -- 查询工资大于销售部任意一人的员工(> ANY = 大于最小值) |
1 | -- 查询工资大于销售部所有人的员工(> ALL = 大于最大值) |
| name | salary |
|---|---|
| 李四 | 18000 |
| 周八 | 22000 |
ANY / ALL 速查:
| 表达式 | 等价于 | 含义 |
|---|---|---|
> ANY (子查询) |
> MIN(子查询结果) |
大于最小值 |
< ANY (子查询) |
< MAX(子查询结果) |
小于最大值 |
> ALL (子查询) |
> MAX(子查询结果) |
大于最大值 |
< ALL (子查询) |
< MIN(子查询结果) |
小于最小值 |
= ANY (子查询) |
IN (子查询结果) |
等于其中之一 |
4. ⭐ IN vs EXISTS 对比
这是最常被讨论的对比,两者在很多场景下可以互换,但有重要区别:
1 | -- IN 写法 |
| 对比维度 | IN | EXISTS |
|---|---|---|
| 执行方式 | 先执行子查询,生成结果集,再逐行比对 | 对外层每一行,执行子查询判断是否有结果 |
| NULL 处理 | NOT IN 遇到 NULL 会返回空结果 |
NOT EXISTS 正确处理 NULL ✅ |
| 适用场景 | 子查询结果集小时效率高 | 子查询结果集大、外层表小时效率高 |
| 可读性 | 较直观 | 稍复杂 |
💡 实用建议:
- 简单场景优先用
IN,代码更直观- 涉及
NOT IN且子查询可能有NULL时,务必用NOT EXISTS来避免陷阱- 大数据量场景,关注执行计划选择最优方案
1 | -- ⭐ NOT IN vs NOT EXISTS 的 NULL 问题演示 |
五、子查询的使用位置
子查询几乎可以出现在 SQL 语句的任何位置,以下是常见的使用位置汇总:
1. WHERE 子句中(最常见)
1 | -- 查询工资最高的员工 |
2. FROM 子句中(派生表)
1 | -- 统计每个部门的平均工资,再筛选高于全公司平均的部门 |
3. SELECT 子句中(标量子查询)
1 | -- 为每个部门附上员工人数 |
| dept_name | location | emp_count |
|---|---|---|
| 技术部 | 北京 | 3 |
| 销售部 | 上海 | 3 |
| 人事部 | 北京 | 2 |
| 财务部 | 深圳 | 0 |
💡 补充:SELECT 中的子查询必须是标量子查询(返回单个值),否则报错。
4. HAVING 子句中
1 | -- 查询平均工资高于全公司平均的部门 |
六、⭐ CTE 公用表表达式(WITH 语法)
CTE(Common Table Expression,公用表表达式)使用 WITH 关键字定义一个临时命名的结果集,可以在后续查询中像表一样引用它。CTE 本质上是子查询的”升级版”,可读性更好、支持复用、还能递归。
⚠️ 版本要求:CTE 需要 MySQL 8.0+ 才支持。
1. 基础 CTE 语法
1 | WITH cte_name AS ( |
1 | -- 用 CTE 查询工资高于部门平均工资的员工 |
| name | salary | avg_salary |
|---|---|---|
| 周八 | 22000 | 18333.33 |
| 吴九 | 15000 | 12000.00 |
| 郑十 | 13000 | 12000.00 |
💡 对比一下,同样的需求如果用子查询写:
1
2
3
4
5
6
7
8 SELECT e.name, e.salary, da.avg_salary
FROM employee e
JOIN (
SELECT dept_id, AVG(salary) AS avg_salary
FROM employee WHERE dept_id IS NOT NULL
GROUP BY dept_id
) da ON e.dept_id = da.dept_id
WHERE e.salary > da.avg_salary;CTE 把子查询提取到了前面并命名,主查询更简洁、意图更清晰。
2. 多个 CTE
多个 CTE 用逗号分隔,后面的 CTE 可以引用前面的 CTE。
1 | WITH |
| name | dept_name | dept_avg | projects |
|---|---|---|---|
| 张三 | 技术部 | 18333.33 | 1 |
| 李四 | 技术部 | 18333.33 | 2 |
| 王五 | 销售部 | 12000.00 | 1 |
| 赵六 | 销售部 | 12000.00 | 0 |
| 孙七 | 人事部 | 12000.00 | 0 |
| 周八 | 技术部 | 18333.33 | 2 |
| 吴九 | 销售部 | 12000.00 | 1 |
| 郑十 | 人事部 | 12000.00 | 1 |
💡 补充:多个 CTE 之间用逗号分隔,只需要写一个
WITH关键字。每个 CTE 都可以引用在它之前定义的其他 CTE。
3. ⭐ 递归 CTE(WITH RECURSIVE)
递归 CTE 是 CTE 最强大的功能,可以实现层级遍历、树形结构查询等子查询做不到的事情。
基本结构:
1 | WITH RECURSIVE cte_name AS ( |
1 | -- 从周八开始,递归查询所有下属(多层级) |
结果:
| id | name | manager_id | level |
|---|---|---|---|
| 6 | 周八 | NULL | 1 |
| 1 | 张三 | 6 | 2 |
| 2 | 李四 | 6 | 2 |
| 7 | 吴九 | 6 | 2 |
| 3 | 王五 | 7 | 3 |
| 4 | 赵六 | 7 | 3 |
💡 执行过程:
- 第 1 轮(锚点):找到周八(level=1)
- 第 2 轮(递归):找 manager_id=6 的员工 → 张三、李四、吴九(level=2)
- 第 3 轮(递归):找 manager_id 为 1、2、7 的员工 → 王五、赵六(level=3,manager_id=7)
- 第 4 轮:找 manager_id 为 3、4 的员工 → 无 → 递归终止
⚠️ 注意事项:
- 递归 CTE 必须包含
UNION ALL(或UNION)连接锚点和递归成员- 递归成员必须引用 CTE 自身,并且有终止条件(否则无限递归)
- MySQL 默认递归深度限制为 1000 次,可通过
SET cte_max_recursion_depth = N调整- 递归 CTE 中推荐使用
UNION ALL(更高效),除非确实需要去重才用UNION
更多递归场景——生成连续日期序列:
1 | -- 生成 2026-02-01 到 2026-02-10 的日期序列 |
💡 实用场景:生成日期序列后,可以 LEFT JOIN 业务表,统计每天的数据(包括没有数据的日子显示为 0),这在报表开发中非常常用。
4. ⭐ CTE vs 子查询 对比
| 对比维度 | CTE(WITH) | 子查询 |
|---|---|---|
| 可读性 | ✅ 好,命名清晰,主查询简洁 | 嵌套深时较差 |
| 可复用 | ✅ 同一语句中可多次引用 | ❌ 每次都要重写 |
| 递归支持 | ✅ 支持 WITH RECURSIVE |
❌ 不支持 |
| MySQL 版本 | 8.0+ | 所有版本 |
| 性能 | 基本等同(优化器可能自动物化) | 基本等同 |
| 作用范围 | 仅在当前语句中有效 | 仅在所在位置有效 |
💡 选择建议:
- 简单的一次性嵌套 → 子查询即可
- 需要复用、嵌套层次深、或需要递归 → 用 CTE
- 需要兼容 MySQL 5.x → 只能用子查询
1 | -- 同一个需求的三种写法对比 |
七、子查询 vs JOIN 改写
很多子查询可以改写为 JOIN,通常 JOIN 的性能更好(优化器更容易优化),也更易读。
1 | -- 子查询写法:查询参与了项目的员工 |
1 | -- 子查询写法:查询没有参与项目的员工 |
💡 改写建议:
IN (子查询)→ 可改写为JOIN(注意去重)NOT IN (子查询)→ 可改写为LEFT JOIN ... WHERE IS NULL(更安全,避免 NULL 问题)EXISTS (关联子查询)→ 可改写为JOIN- 不是所有子查询都适合改写,关联子查询中对分组计算的引用有时用子查询更自然
八、综合速查表
| 需求 | 推荐语法 | 要点 |
|---|---|---|
| 与单个值比较 | 标量子查询 + =/>/< |
子查询必须返回一行一列 |
| 判断是否在集合中 | IN (子查询) |
简单直观 |
| 判断集合中是否有匹配 | EXISTS (关联子查询) |
NOT EXISTS 更安全(无 NULL 陷阱) |
| 大于/小于集合中的某个值 | > ANY / < ANY |
ANY = 任意一个满足即可 |
| 大于/小于集合中的所有值 | > ALL / < ALL |
ALL = 全部满足 |
| 把子查询当临时表 | FROM (子查询) AS 别名 |
派生表必须有别名 |
| 提高复杂查询可读性 | WITH ... AS (...) |
CTE,MySQL 8.0+ |
| 同一查询中复用结果集 | WITH 多个 CTE |
逗号分隔,只写一个 WITH |
| 层级遍历 / 树形结构 | WITH RECURSIVE |
递归 CTE,需有终止条件 |
| 生成序列(日期/数字) | WITH RECURSIVE |
配合 UNION ALL |
| 查”有匹配的记录” | IN 或 EXISTS |
小结果集用 IN,大结果集用 EXISTS |
| 查”无匹配的记录” | NOT EXISTS 或 LEFT JOIN + IS NULL |
避免 NOT IN 的 NULL 陷阱 |
九、LeetCode 相关练习
🔗 后续待补充..
参考资料
📚 本文内容参考以下资料整理: