Files
cs-note/hhs/MySQL/02-SQL核心/10-子查询与派生表.md
T
2026-05-24 11:42:38 +08:00

498 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, 子查询, EXISTS, IN, 派生表, CTE]
create time: 2026-05-16 00:00
---
# 子查询与派生表
## 概述
子查询是嵌套在另一个查询中的 SELECT 语句。它可以在 WHERE、FROM、SELECT 等多个位置出现,每种位置的语义和执行方式不同。
## 分类
```mermaid
graph BT
subgraph "标量子查询"
S1["返回一行一列<br/>用在 SELECT / WHERE"]
end
subgraph "行子查询"
S2["返回一行多列<br/>用在 ROW() 比较"]
end
subgraph "列子查询"
S3["返回多行一列<br/>用在 IN / ANY / ALL"]
end
subgraph "表子查询(派生表)"
S4["返回多行多列<br/>用在 FROM 子句"]
end
subgraph "EXISTS 子查询"
S5["返回布尔值<br/>用于 EXISTS / NOT EXISTS"]
end
```
## 标量子查询
### 基本用法
```sql
-- 用法 1:在 SELECT 中调用
SELECT
username,
(SELECT COUNT(*) FROM orders WHERE user_id = users.id) AS order_count
FROM users;
-- 副作用:这个查询其实也是"相关子查询"的典型例子
-- 因为内层的 user_id = users.id 引用了外层表的列
-- 每一行 users 的记录都会触发一次内层查询
-- 用法 2:在 WHERE 中等价于常量
SELECT * FROM products
WHERE price > (SELECT AVG(price) FROM products);
-- 用法 3:在 INSERT 中赋值
INSERT INTO reports (month, order_total)
VALUES ('2026-05', (SELECT SUM(amount) FROM orders WHERE MONTH(created_at) = 5));
```
> [!QUESTION] 上面用法 1 的子查询,每一行都要重新执行吗?
> 是的——但不是因为"放在 SELECT 里",而是因为它引用了外层的 `users.id`。
> 这就引出了子查询最重要的分类维度:**相关 vs 无关**。
### 相关 vs 无关子查询
这是理解子查询性能的基石:**子查询是否引用了外层查询的列?**
```mermaid
flowchart LR
UC["子查询"] --> UCX{"是否引用外层表的列?"}
UCX -->|否:无关子查询<br/>Uncorrelated| UCN["先执行一次<br/>结果供外层复用 ✅"]
UCX -->|是:相关子查询<br/>Correlated| UCR["对每一行外层记录<br/>都要重新执行 ⚠️"]
style UCN fill:#00B6BC,color:#fff
style UCR fill:#FF9F43,color:#000
```
> [!EXAMPLE] 对比示例
> ```sql
> -- 🟢 无关子查询:只执行一次
> -- 子查询完全不依赖 users 表
> SELECT * FROM products
> WHERE price > (SELECT AVG(price) FROM products);
>
> -- 🔴 相关子查询:执行 N 次(N = users 行数)
> -- 子查询引用了外层 u.id
> SELECT u.username,
> (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt
> FROM users u;
> -- 10 万用户 → 子查询执行 10 万次
> ```
> [!WARNING] 标量子查询的性能隐患
> MySQL 8.0.21 之前,标量子查询**无法被物化**,会导致每次执行都重新计算(N+1 问题)。
> ```sql
> -- ❌ 慢:每个用户都要查一次 orders 表(相关子查询)
> SELECT u.username,
> (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt
> FROM users u;
> -- 如果有 10 万用户,就要查 10 万次 orders 表
>
> -- ✅ 换成 JOIN 或窗口函数
> SELECT u.username, COALESCE(SUB.cnt, 0) AS cnt
> FROM users u
> LEFT JOIN (
> SELECT user_id, COUNT(*) AS cnt
> FROM orders GROUP BY user_id
> ) SUB ON u.id = SUB.user_id;
> ```
## IN vs EXISTS
这是面试经典题,也是实际中最纠结的选择。
```sql
-- IN 子查询
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders);
-- EXISTS 子查询
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
```
```mermaid
flowchart TD
A["Optimizer 选择策略"] --> B{"哪张表更小?"}
B -->|"子查询结果小"<| C["转换 IN → EXISTS<br/>先跑子查询,外层仅匹配结果"]
B -->|"子查询结果大"<| D["转换 EXISTS → IN<br/>外层先行过滤,减少子查询次数"]
C --> E["NOT IN 永远转不成 EXISTS!<br/>NULL 值会导致语义错误"]
D --> F["NOT EXISTS 是最安全的反查写法"]
style E fill:#EE5A24,color:#fff
style F fill:#00D866,color:#fff
```
> [!QUESTION] NOT IN 和 NOT EXISTS 有什么区别?
> ```sql
> -- ⚠️ 致命陷阱
> SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);
>
> -- 如果子查询返回任何 NULL 值,整个 NOT IN 结果为 UNKNOWN
> -- 最终返回空结果集!
>
> -- ✅ 正确写法
> SELECT * FROM users
> WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL);
>
> -- 或者直接用 NOT EXISTS(更安全)
> SELECT * FROM users u
> WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
> ```
### 选型决策:IN vs EXISTS vs JOIN
实际开发中三者往往能写出等价逻辑,选法参考下表:
| 场景 | 推荐写法 | 理由 |
|------|----------|------|
| 外大内小(外层百万、子查询几百) | `EXISTS` | 外层每行只需匹配一条即可停止,短路效应明显 |
| 外小内大 | `IN` | Optimizer 会先物化子查询结果再匹配,效率高 |
| 子查询含 `NULL` 值可能 | `EXISTS` / `NOT EXISTS` | `NOT IN` 遇到 NULL 直接返回空集(见上文陷阱) |
| 需要返回外键表的完整列 | `JOIN` + `DISTINCT` | EXISTS 只能判断存在性,无法获取关联表字段 |
| 只是判断"有没有" | `EXISTS` | 语义最清晰,性能也最优 |
> [!TIP] 经验法则
> **不确定时优先用 EXISTS**——它的语义是"是否存在"而非"值是否匹配",即使 MySQL Optimizer 最终把 IN 和 EXISTS 优化成执行计划也一样。用 EXISTS 能让读代码的人一眼看懂意图,同时给 Optimizer 留下优化空间。
## 派生表(Derived Table / Subquery in FROM)
```sql
-- 派生表:子查询作为一个虚拟表出现在 FROM 位置
SELECT dept, avg_salary
FROM (
SELECT department AS dept, AVG(salary) AS avg_salary
FROM employees
WHERE status = 'active'
GROUP BY department
) AS dept_stats
WHERE avg_salary > 15000
ORDER BY avg_salary DESC;
```
### 物化(Materialization)
MySQL 是否将子查询物化为临时表,取决于查询复杂度:
```mermaid
flowchart LR
A["子查询"] --> B{"能否被推入外层?"}
B -->|能| C["Flattening<br/>展开为 JOIN,零额外开销 ✅"]
B -->|不能| D{"是否需要聚合?"}
D -->|是| E["Materialization<br/>物化为临时表 ⚠️"]
D -->|否| F["Semi-join 优化<br/>半连接优化 ✅"]
style C fill:#00D866,color:#fff
style F fill:#00B6BC,color:#fff
style E fill:#FF9F43,color:#000
```
```sql
-- ✅ 可以被 Flattening 优化(无额外开销)
SELECT u.id, o.amount
FROM users u
JOIN (SELECT user_id, amount FROM orders) AS o ON u.id = o.user_id;
-- Optimizer 将其转换为普通的 JOIN
-- ❌ 必须 Materialization(有临时表开销)
SELECT u.id, SUB.total
FROM users u
JOIN (
SELECT user_id, SUM(amount) AS total
FROM orders GROUP BY user_id
) AS SUB ON u.id = SUB.user_id;
-- 子查询因为有 GROUP BY,必须先物化成临时表
```
> [!NOTE] 如何判断是否物化?
> 使用 `EXPLAIN FORMAT=JSON`:
> ```json
> {
> "select_type": "DERIVED",
> "materialized_from_subquery": { ... }
> }
> ```
> 如果出现 `"using_temporary_table": true`,说明使用了临时表。
实战中更常用的方式是 `EXPLAIN` + 观察 type 和 Extra:
```sql
-- ❌ 物化型派生表 → Extra 出现 "Using temporary"
EXPLAIN SELECT u.id, SUB.total
FROM users u
JOIN (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) AS SUB ON u.id = SUB.user_id;
-- ✅ 可 Flattening → 无临时表,Extra 干净
EXPLAIN SELECT u.id, o.amount
FROM users u JOIN (SELECT user_id, amount FROM orders) AS o ON u.id = o.user_id;
```
对比两种情况的执行计划:
```mermaid
graph LR
subgraph "❌ 物化型派生表"
T1["select_type: DERIVED"] -->|orders | T2["type: ALL<br/>Extra: Using temporary, Using filesort"]
P1["PRIMARY → <derived2>"] -->|全表扫描| P2["type: ALL"]
P3["PRIMARY → u"] -->|全表扫描| P4["Extra: Using where"]
end
subgraph "✅ 可展开型派生表"
F1["select_type: SIMPLE"] -->|orders | F2["type: ALL<br/>Extra: NULL(无额外开销)"]
F3["SIMPLE → u"] -->|索引扫描| F4["type: index<br/>Extra: Using index"]
end
style T2 fill:#FF9F43,color:#000
style P2 fill:#EE5A24,color:#fff
style F2 fill:#00D866,color:#fff
style F4 fill:#00D866,color:#fff
```
> [!TIP] 性能优化技巧
> - 当发现派生表产生临时表时,考虑**手动提升为 CTE**(MySQL 8.0+),有时能改变 Optimizer 行为
> - `optimizer_switch='derived_merge=on'`(默认开启)控制是否允许 Flattening
> - 对于超大结果集物化,注意 `tmp_table_size` / `max_heap_table_size` 限制
### 常见陷阱与最佳实践
| 坑 | 症状 | 解法 |
|----|------|------|
| **缺少别名** | `Every derived table must have its own alias` | 派生表必须起别名:`FROM (...) AS t` |
| **自引用冲突** | `Table 'xxx' is specified twice` | UPDATE/DELETE 中的派生表需用嵌套包裹 |
| **NULL 传播** | NOT IN 返回空集 | 改用 `NOT EXISTS` 或加 `IS NOT NULL` 过滤 |
| **隐式类型转换** | 索引失效,全表扫描 | 确保比较列类型一致(如 `VARCHAR` vs `INT`)|
| **大结果集物化** | `Using temporary; Using filesort` | 用 JOIN 重写或拆分为多次查询 |
## 实战:性能排查 Checklist
遇到慢的子查询,按以下顺序逐项检查:
```mermaid
flowchart TD
S["慢查询:含子查询"] --> C1{"EXPLAIN 看了吗?"}
C1 -->|没看| STOP["🛑 先跑 EXPLAIN<br/>别盲猜,数据说话"]
C1 -->|看了| C2{"Extra 有 Using temporary 吗?"}
C2 -->|"是:有临时表"| D1["派生表物化了 → 考虑改 JOIN 或提升为 CTE"]
C2 -->|"否"| C3{"有 Using filesort 吗?"}
C3 -->|"是"| D2["排序开销大 → 检查是否有可用索引"]
C3 -->|"否"| C4{"子查询是否在 SELECT 列表中?"}
C4 -->|"是:标量子查询"| D3["N+1 问题 → 改写为 LEFT JOIN + GROUP BY"]
C4 -->|"否"| C5{"用了 NOT IN 吗?"}
C5 -->|"是"| D4["NULL 陷阱 → 换 NOT EXISTS"]
C5 -->|"否"| C6{"关联列类型一致吗?"}
C6 -->|"不一致"| D5["隐式类型转换导致索引失效 → 统一类型"]
C6 -->|"一致"| DONE["✅ 执行计划合理,可能是业务逻辑本身复杂"]
style STOP fill:#EE5A24,color:#fff
style D1 fill:#FF9F43,color:#000
style D2 fill:#FF9F43,color:#000
style D3 fill:#FF9F43,color:#000
style D4 fill:#EE5A24,color:#fff
style D5 fill:#FF9F43,color:#000
style DONE fill:#00D866,color:#fff
```
> 核心原则:**先看 EXPLAIN,再动手改**。很多情况下不是子查询的问题,而是缺了索引或类型不匹配。
## ANY / ALL / SOME
```sql
-- ANY:子查询返回的值中,任意一个满足即可
SELECT product_name, price
FROM products
WHERE price > ANY (SELECT price FROM products WHERE category = 'premium');
-- 只要比任意一件 premium 产品便宜就行
-- ALL:必须大于子查询返回的所有值
SELECT product_name, price
FROM products
WHERE price > ALL (SELECT price FROM products WHERE category = 'premium');
-- 必须比所有 premium 产品都贵(即最大值之上)
-- SOME 等价于 ANY(同义词)
```
```mermaid
graph TB
P["Products: 10, 50, 100, 200"]
ANY -->|"price > ANY(...)"<| A1["> 10 OR > 50 OR > 100 OR > 200"]
A1 --> R1["结果: 所有 > 10 的商品"]
ALL -->|"price > ALL(...)"<| A2["> 10 AND > 50 AND > 100 AND > 200"]
A2 --> R2["结果: 只有 > 200 的商品"]
style R1 fill:#00B6BC,color:#fff
style R2 fill:#C44569,color:#fff
```
## UPDATE / DELETE 中的子查询
子查询不仅限于 SELECT,UPDATE 和 DELETE 同样可以使用。
```sql
-- UPDATE:用子查询结果更新列
UPDATE employees e
SET salary = (
SELECT avg_salary FROM (
SELECT AVG(salary) AS avg_salary
FROM employees WHERE department = e.department
) tmp
)
WHERE dept_level = 'manager';
-- ⚠️ MySQL 要求嵌套一层:不能直接在 UPDATE 中引用被更新的表
-- 所以要用派生表包裹一下(tmp)来绕过这个限制
-- DELETE:条件过滤删除记录
DELETE FROM sessions
WHERE user_id IN (
SELECT id FROM users WHERE status = 'banned'
);
-- 关联删除:保留每个分组中最新的一条
DELETE t1 FROM logs t1
INNER JOIN logs t2
ON t1.category = t2.category
AND t1.created_at < t2.created_at;
-- 虽然这不是子查询写法,但效果等价于上面的子查询逻辑
-- 实际场景中更推荐 JOIN 写法,性能更好
```
## CTE:子查询的现代写法(MySQL 8.0+)
`WITH` 子句(Common Table Expression)是 MySQL 8.0 引入的新语法,本质上是给派生表起了一个「有名字的、可复用的」外壳。
```sql
-- 传统派生表写法
SELECT dept, avg_salary
FROM (
SELECT department AS dept, AVG(salary) AS avg_salary
FROM employees WHERE status = 'active'
GROUP BY department
) AS dt
WHERE avg_salary > 15000;
-- ✅ 等价 CTE 写法
WITH dept_stats AS (
SELECT department AS dept, AVG(salary) AS avg_salary
FROM employees WHERE status = 'active'
GROUP BY department
)
SELECT dept, avg_salary
FROM dept_stats
WHERE avg_salary > 15000;
```
```mermaid
flowchart LR
subgraph "传统派生表"
D1["FROM (...)"] -->|可读性差| D2["重复写的逻辑难以复用"]
D3["嵌套深时层级混乱 😵"]
end
subgraph "CTE 写法"
C1["WITH name AS (...)"] -->|语义清晰| C2["可在主查询中多次引用"]
C3["链式 CTE 层层递进 🧩"]
end
D1 -.->|"功能等价"| C1
D2 -.->|"可读性提升"| C2
D3 -.-> "|简化复杂查询|" C3
style C1 fill:#00D866,color:#fff
style C2 fill:#00B6BC,color:#fff
style C3 fill:#00B6BC,color:#fff
```
### 递归 CTE
这是子查询体系无法做到的能力——自我引用的 CTE 可以遍历树形结构。
```sql
-- 递归 CTE:生成从 1 到 10 的序列
WITH RECURSIVE nums AS (
SELECT 1 AS n -- 锚点成员(递归起点)
UNION ALL
SELECT n + 1 FROM nums WHERE n < 10 -- 递归成员
)
SELECT * FROM nums;
-- 实战:查询组织的完整汇报线
WITH RECURSIVE org_chain AS (
SELECT id, name, manager_id, 1 AS level
FROM employees WHERE manager_id IS NULL -- 根节点:CEO
UNION ALL
SELECT e.id, e.name, e.manager_id, oc.level + 1
FROM employees e
INNER JOIN org_chain oc ON e.manager_id = oc.id -- 递归:逐级下钻
)
SELECT * FROM org_chain ORDER BY level, name;
```
> [!QUESTION] 为什么叫「自我引用」?递归 CTE 到底怎么执行的?
> `org_chain` 这个名字在定义内部就被引用了——`FROM employees e INNER JOIN org_chain oc`。
> 第一轮:只执行锚点成员,得到根节点(CEO);
> 第二轮:用第一轮的结果去 JOIN employees,得到 CEO 的直接下属;
> 第三轮:用第二轮的结果去 JOIN,得到下属的下属……
> 直到某一轮 JOIN 结果为空,递归自动终止。
> **关键:没有终止条件 → 无限循环 → 触发 `cte_max_recursion_depth` 报错。**
> [!WARNING] 递归 CTE 的注意事项
> - MySQL 默认递归深度上限为 **1000**(`cte_max_recursion_depth`),超出会报错
> - 务必设置合理的终止条件,否则会无限递归直到达到上限
> - 递归部分不能直接用 `LIMIT` 来截断结果
> [!TIP] 何时用 CTE vs 派生表?
> | 场景 | 推荐 |
> |------|------|
> | 只需引用一次,逻辑简单 | 派生表(更轻量) |
> | 需要多次引用同一段逻辑 | CTE(DRY 原则) |
> | 需要嵌套多层 | CTE(可读性碾压) |
> | 需要递归查询(树形结构) | 只能 CTE |
> | 兼容 MySQL 5.7 | 只能派生表 |
## 总结:核心要点回顾
| 主题 | 一句话 |
|------|--------|
| 标量子查询 | MySQL 8.0.21 之前不可物化,百万行数据必踩 N+1 陷阱,优先改写为 JOIN |
| 相关 vs 无关 | 相关子查询引用了外层列,每行都要重新执行——性能杀手 |
| IN vs EXISTS | 语义上 IN 看值、EXISTS 看存在性;不确定时选 EXISTS,更安全的默认选项 |
| NOT IN 陷阱 | 子查询出现 NULL 即返回空集,生产中几乎永远该用 `NOT EXISTS` 替代 |
| 派生表物化 | 有 GROUP BY / LIMIT / UNION 等操作的子查询无法被展开,必然产生临时表开销 |
| ANY / ALL | 对应 SQL 的 OR / AND 累加,ALL 在空子查询结果时恒返回 TRUE(反直觉,需注意) |
| CTE | MySQL 8.0+ 现代写法,可读性和可维护性优于匿名派生表,唯一支持递归的子查询形式 |
> [!TIP] 核心心法
> **子查询不是万能的——它首先是为了表达清晰,其次才是性能。**
> 写完后务必跑 `EXPLAIN`,确认没有意料之外的临时表或多表扫描。当数据量上去后,能改写成 JOIN 的子查询就尽量改写,因为 JOIN 的执行路径对 Optimizer 更加透明。
> [!TIP] 学习路径建议
> 如果你刚接触子查询,建议按这个顺序学习:
> 1. **标量子查询** → 先理解「子查询就是一次独立的 SELECT」
> 2. **相关 vs 无关** → 理解执行次数差异(本文核心概念)
> 3. **IN vs EXISTS** → 掌握两种过滤范式,重点理解 NOT IN 陷阱
> 4. **派生表** → 子查询作为虚拟表,理解物化与 Flattening
> 5. **CTE** → 用现代语法重写派生表,最终挑战递归 CTE
>
> 每一步都要跑 `EXPLAIN` 看执行计划——**看懂 EXPLAIN 比背语法重要十倍**。
## 关联笔记
- [[hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析]] — SELECT 执行顺序与子查询的关系
- [[hhs/MySQL/02-SQL核心/09-JOIN 原理与优化]] — EXISTS 子查询常可重写为 JOIN