个人主页: > for_ever_love__ <(欢迎各位大佬莅临😊)
其他栏目: > 大模型开发从0到1 <
其他栏目: > iOS项目总结大全 <
其他栏目: > 我想学python了 <
其他栏目: > iOS UI <
文章目录
- [MySQL 子查询、派生表与 CTE 优化:半连接、物化和递归实战](#MySQL 子查询、派生表与 CTE 优化:半连接、物化和递归实战)
-
- 一、先建立三层概念
- 二、准备实验表
- 三、子查询的四种结果形态
- 四、标量子查询:先保证"最多一行"
- [五、IN 与 EXISTS:先看语义,再看计划](#五、IN 与 EXISTS:先看语义,再看计划)
-
- [5.1 为什么不能随便改成普通 JOIN](#5.1 为什么不能随便改成普通 JOIN)
- [六、半连接:MySQL 如何优化存在性判断](#六、半连接:MySQL 如何优化存在性判断)
- [七、NOT IN 的 NULL 陷阱](#七、NOT IN 的 NULL 陷阱)
- 八、派生表:合并还是物化
-
- [8.1 延迟物化](#8.1 延迟物化)
- [8.2 物化结果可能获得自动索引](#8.2 物化结果可能获得自动索引)
- 九、CTE:主要价值是命名和组织
-
- [9.1 CTE 不等于缓存](#9.1 CTE 不等于缓存)
- [9.2 必要时用 Hint 做对照实验](#9.2 必要时用 Hint 做对照实验)
- [十、递归 CTE 的执行模型](#十、递归 CTE 的执行模型)
- 十一、层级数据遍历实战
-
- [12.1 锚点决定递归列类型](#12.1 锚点决定递归列类型)
- 十二、递归安全:深度、超时与终止条件
- [十三、MySQL 5.7 怎么办](#十三、MySQL 5.7 怎么办)
- 十四、用执行计划验证改写
- 十五、常见误区
- 十六、小结
MySQL 子查询、派生表与 CTE 优化:半连接、物化和递归实战
把子查询改成 JOIN,不一定更快;把重复 SQL 抽成 CTE,也不等于结果会自动缓存。
MySQL 优化器可能把子查询转成半连接,把派生表合并进外层,也可能选择物化成内部临时表。
真正要判断的是:语义是否等价、结果是否重复、相关查询执行多少次,以及最终计划选择了合并还是物化。
一、先建立三层概念
| 写法 | 出现位置 | 作用域 | MySQL 版本 |
|---|---|---|---|
| 子查询 | SELECT/WHERE/ON/FROM 等 |
所在表达式或查询块 | 5.7、8.0 |
| 派生表 | FROM (SELECT ...) AS dt |
当前语句 | 5.7、8.0 |
| CTE | WITH name AS (...) |
紧随其后的单条语句 | 8.0+ |
| 递归 CTE | WITH RECURSIVE |
单条语句,可引用自身 | 8.0+ |
| CTE 可以提高可读性、复用命名结果,还能表达层级递归;但它只是 SQL 结构,不保证更快。 | |||
| 优化器仍会根据成本选择合并(merge)或物化(materialize)。 |
二、准备实验表
sql
CREATE DATABASE IF NOT EXISTS subquery_demo
DEFAULT CHARACTER SET utf8mb4;
USE subquery_demo;
CREATE TABLE customer (
id BIGINT UNSIGNED PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(50) NOT NULL,
parent_id BIGINT UNSIGNED NULL,
KEY idx_tenant_parent (tenant_id, parent_id, id)
) ENGINE = InnoDB;
CREATE TABLE orders (
id BIGINT UNSIGNED PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
status TINYINT UNSIGNED NOT NULL,
amount DECIMAL(12,2) NOT NULL,
created_at DATETIME NOT NULL,
KEY idx_tenant_customer_status (tenant_id, customer_id, status),
KEY idx_tenant_status_time (tenant_id, status, created_at)
) ENGINE = InnoDB;
三、子查询的四种结果形态
| 类型 | 返回形态 | 常见用法 |
|---|---|---|
| 标量子查询 | 一行一列 | amount > (SELECT AVG(...)) |
| 行子查询 | 一行多列 | (a,b) = (SELECT x,y ...) |
| 列子查询 | 多行一列 | id IN (SELECT customer_id ...) |
| 表子查询 | 多行多列 | FROM (SELECT ...) AS dt |
| 还可以按依赖关系分为: |
- 不相关子查询:可脱离外层独立执行;
- 相关子查询 :引用外层列,逻辑上随外层行变化。
相关不等于一定逐行执行。MySQL 可能做半连接转换、物化或其他改写;是否成功必须看执行计划。
四、标量子查询:先保证"最多一行"
sql
SELECT id, amount
FROM orders
WHERE amount > (
SELECT AVG(amount)
FROM orders
WHERE tenant_id = 1001
);
聚合查询天然返回一个标量,语义明确。
如果子查询返回多行,MySQL 会报错:
sql
SELECT id
FROM orders
WHERE amount = (
SELECT amount
FROM orders
WHERE tenant_id = 1001
);
不要随手加 LIMIT 1 掩盖问题:
sql
-- 没有 ORDER BY,取哪一行都不确定
SELECT amount
FROM orders
WHERE tenant_id = 1001
LIMIT 1;
正确做法是确认业务需要 MAX/MIN、唯一约束,还是本来就应该使用 IN。
五、IN 与 EXISTS:先看语义,再看计划
需求:找出至少有一笔已支付订单的客户。
sql
SELECT c.id, c.name
FROM customer AS c
WHERE c.tenant_id = 1001
AND c.id IN (
SELECT o.customer_id
FROM orders AS o
WHERE o.tenant_id = 1001
AND o.status = 1
);
等价的 EXISTS 写法:
sql
SELECT c.id, c.name
FROM customer AS c
WHERE c.tenant_id = 1001
AND EXISTS (
SELECT 1
FROM orders AS o
WHERE o.tenant_id = c.tenant_id
AND o.customer_id = c.id
AND o.status = 1
);
现代 MySQL 能把许多 IN/EXISTS 优化为半连接,不应机械相信"EXISTS 永远比 IN 快"。
应选择语义清晰的写法,再比较计划。
5.1 为什么不能随便改成普通 JOIN
sql
SELECT c.id, c.name
FROM customer AS c
JOIN orders AS o
ON o.tenant_id = c.tenant_id
AND o.customer_id = c.id
WHERE c.tenant_id = 1001
AND o.status = 1;
一个客户有三笔已支付订单,就会返回三行客户。
EXISTS 只关心是否至少匹配一次,外层客户最多返回一次。
| 语义 | 合适表达 |
|---|---|
| 至少存在一条匹配 | EXISTS / IN |
| 返回每一条匹配组合 | JOIN |
| 不存在任何匹配 | NOT EXISTS |
| 需要内表聚合值 | 预聚合后 JOIN 或标量聚合子查询 |
用 JOIN + DISTINCT 修复重复,可能引入额外去重,且暴露出原本就选错了语义。 |
六、半连接:MySQL 如何优化存在性判断
半连接只输出外层行,不因内层多次匹配而重复外层结果。
MySQL SQL 语法没有 SEMI JOIN 关键字,但优化器内部可以使用多种策略。
| 策略 | 核心思想 | 适用方向 |
|---|---|---|
| Table Pullout | 把满足唯一性条件的内表上拉 | 内层连接键唯一 |
| FirstMatch | 找到首条匹配后停止内层搜索 | 存在性判断 |
| LooseScan | 按内层索引跳过重复键 | 内层值重复且有序 |
| Duplicate Weedout | 用临时结构消除重复外层行 | 一般半连接 |
| Materialization | 子查询去重物化,再参与连接 | 不相关子查询结果可复用 |
| 查看策略: |
sql
EXPLAIN FORMAT=JSON
SELECT c.id
FROM customer AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.tenant_id = c.tenant_id
AND o.customer_id = c.id
AND o.status = 1
);
传统 EXPLAIN 可能在 Extra 中出现 FirstMatch、LooseScan、Start temporary/End temporary 等线索。
MySQL 8.0 优先结合树形计划和 EXPLAIN ANALYZE 判断真实循环次数。
七、NOT IN 的 NULL 陷阱
sql
SELECT 1
WHERE 1 NOT IN (2, NULL);
结果不是 TRUE,而是 UNKNOWN,因此不会返回行。
只要子查询结果可能包含 NULL,NOT IN 就可能把结果"全部吃掉"。
推荐使用 NOT EXISTS:
sql
SELECT c.id, c.name
FROM customer AS c
WHERE c.tenant_id = 1001
AND NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.tenant_id = c.tenant_id
AND o.customer_id = c.id
);
如果坚持使用 NOT IN,至少要证明内层列为 NOT NULL,或显式排除 NULL。
NOT EXISTS 更贴近反连接语义,也不依赖三值逻辑的隐含前提。
八、派生表:合并还是物化
派生表是 FROM 中的子查询:
sql
SELECT x.customer_id, x.paid_amount
FROM (
SELECT customer_id, SUM(amount) AS paid_amount
FROM orders
WHERE tenant_id = 1001 AND status = 1
GROUP BY customer_id
) AS x
WHERE x.paid_amount >= 1000;
优化器有两条主要路线。
| 路线 | 含义 | 优点 | 代价 |
|---|---|---|---|
| Merge | 把派生查询合并到外层查询块 | 条件下推、连接重排空间大 | 并非所有结构可合并 |
| Materialize | 先把结果写入内部临时表 | 隔离复杂逻辑,可多次读取 | 建表、写入、扫描成本 |
含聚合、DISTINCT、GROUP BY、HAVING、LIMIT、窗口函数、UNION 等结构时,通常会阻止简单合并。 |
|||
上例含 GROUP BY,因此更可能物化。 |
8.1 延迟物化
MySQL 不一定在查询一开始就生成派生表。
如果先访问其他表便发现结果为空,可能完全跳过派生表构建,这称为延迟物化。
sql
SELECT d.*
FROM empty_filter AS f
JOIN (
SELECT customer_id, SUM(amount) AS total_amount
FROM orders
GROUP BY customer_id
) AS d ON d.customer_id = f.customer_id
WHERE f.batch_id = -1;
如果 f.batch_id = -1 没有行,构建完整聚合结果毫无意义。
8.2 物化结果可能获得自动索引
物化并不等于"一张无索引临时表硬扫到底"。
若成本合适,MySQL 可以为物化结果创建临时索引,以降低后续连接成本。
因此看到 <derivedN> 后还要继续查看其访问方式。
九、CTE:主要价值是命名和组织
MySQL 8.0 支持普通 CTE:
sql
WITH paid_order AS (
SELECT tenant_id, customer_id, amount
FROM orders
WHERE status = 1
),
customer_total AS (
SELECT tenant_id, customer_id, SUM(amount) AS paid_amount
FROM paid_order
GROUP BY tenant_id, customer_id
)
SELECT c.id, c.name, t.paid_amount
FROM customer AS c
JOIN customer_total AS t
ON t.tenant_id = c.tenant_id
AND t.customer_id = c.id
WHERE c.tenant_id = 1001;
后定义的 CTE 可以引用前面定义的 CTE。
同一级 WITH 只写一次,多个定义用逗号分隔;CTE 作用域只覆盖后面的单条语句。
9.1 CTE 不等于缓存
以下认识都不可靠:
- "WITH 一定先执行";
- "CTE 一定只扫描基础表一次";
- "CTE 一定生成内存临时表";
- "把子查询改成 CTE 就一定更快"。
非递归 CTE 与派生表类似,优化器可选择合并或物化。
如果物化并被多次引用,物化结果可复用;如果被合并,定义会进入外层计划,不存在独立缓存对象。
sql
EXPLAIN FORMAT=TREE
WITH paid AS (
SELECT customer_id, amount
FROM orders
WHERE tenant_id = 1001 AND status = 1
)
SELECT a.customer_id
FROM paid AS a
JOIN paid AS b ON b.customer_id = a.customer_id;
只有执行计划才能回答 CTE 被合并、物化以及读取了几次。
9.2 必要时用 Hint 做对照实验
MySQL 8.0 可在适用场景使用 MERGE / NO_MERGE 优化器 Hint 做实验:
sql
WITH cte AS (
SELECT customer_id, amount
FROM orders
WHERE tenant_id = 1001
)
SELECT /*+ NO_MERGE(cte) */ *
FROM cte
WHERE amount >= 1000;
Hint 不应作为第一选择。统计信息、版本变化和数据分布改变后,曾经更快的固定策略可能变差。
十、递归 CTE 的执行模型
递归 CTE 包含锚点成员和递归成员:
sql
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1
FROM seq
WHERE n < 5
)
SELECT n FROM seq;
执行过程:
- 锚点生成初始结果
1; - 本轮新增行作为下一轮输入;
- 递归成员生成
2、3、4、5; - 当递归成员不再产生行时结束。
优先使用UNION ALL,因为UNION DISTINCT还要去重。
只有去重本身承担防重复语义时,才应使用UNION DISTINCT。
十一、层级数据遍历实战
查询客户节点 1 的全部后代,并输出层级和路径:
sql
WITH RECURSIVE customer_tree AS (
SELECT
id,
parent_id,
name,
0 AS depth,
CAST(id AS CHAR(500)) AS path
FROM customer
WHERE tenant_id = 1001
AND id = 1
UNION ALL
SELECT
c.id,
c.parent_id,
c.name,
t.depth + 1,
CONCAT(t.path, ',', c.id)
FROM customer AS c
JOIN customer_tree AS t ON c.parent_id = t.id
WHERE c.tenant_id = 1001
AND FIND_IN_SET(c.id, t.path) = 0
)
SELECT id, parent_id, name, depth, path
FROM customer_tree
ORDER BY depth, id;
idx_tenant_parent (tenant_id, parent_id, id) 支持每轮按租户和父节点寻找子节点。
路径判断用于示例级防环;超大层级图不适合用字符串路径做高性能遍历,应从数据约束和专用模型解决。
12.1 锚点决定递归列类型
递归 CTE 的列类型主要由非递归锚点推导。
下面若不断拼接字符串,长度可能超过锚点推导值:
sql
CAST(id AS CHAR(500)) AS path
提前 CAST 到合理长度可避免严格模式下报"Data too long",也避免非严格模式静默截断。
十二、递归安全:深度、超时与终止条件
无限递归通常来自脏数据成环或缺少终止条件。
sql
SELECT @@cte_max_recursion_depth;
SET SESSION cte_max_recursion_depth = 200;
还可对查询设置执行时间上限:
sql
WITH RECURSIVE customer_tree AS (...)
SELECT /*+ MAX_EXECUTION_TIME(3000) */ *
FROM customer_tree;
| 防线 | 作用 | 局限 |
|---|---|---|
| 递归 WHERE 条件 | 正常业务终止 | 数据成环时可能无效 |
| 路径防环 | 阻止重复访问节点 | 字符串路径成本随深度增长 |
cte_max_recursion_depth |
限制递归层数 | 过小会截断合法深树并报错 |
MAX_EXECUTION_TIME |
限制执行时间 | 不是结果正确性保证 |
| 数据约束与巡检 | 从源头阻止坏关系 | 需要额外治理流程 |
| 递归 CTE 应先在受控数据量上验证,再用于在线接口。 |
十三、MySQL 5.7 怎么办
MySQL 5.7 不支持 WITH 和递归 CTE。
| 8.0 写法 | 5.7 替代方案 |
|---|---|
| 单次普通 CTE | 派生表 |
| 多次复用的物化结果 | 显式临时表 |
| 递归层级查询 | 应用层迭代、存储过程或闭包表 |
| 生成日期序列 | 日历维表 |
| 显式临时表适合跨多个查询步骤复用,但要考虑连接级作用域、索引、清理和事务边界。 | |
| 层级查询若是高频核心功能,闭包表或路径编码往往比每次递归遍历更可控。 |
十四、用执行计划验证改写
sql
EXPLAIN ANALYZE
WITH paid AS (
SELECT tenant_id, customer_id, SUM(amount) AS amount
FROM orders
WHERE status = 1
GROUP BY tenant_id, customer_id
)
SELECT c.id, p.amount
FROM customer AS c
JOIN paid AS p
ON p.tenant_id = c.tenant_id
AND p.customer_id = c.id
WHERE c.tenant_id = 1001;
重点检查子查询是否相关执行或转成半连接、CTE 是否合并或物化、物化行数与临时索引,以及内层节点的真实 loops。
还要确认条件是否下推到基础表,以及 JOIN 前是否已经形成巨大聚合结果。
必要时结合 EXPLAIN FORMAT=JSON;最终以实际行数、循环次数和耗时为准。
十五、常见误区
| 误区 | 正确认识 |
|---|---|
| 子查询一定比 JOIN 慢 | 优化器可能改写,语义和计划更重要 |
| EXISTS 永远比 IN 快 | 二者都可能转半连接,应实测 |
| CTE 会自动缓存 | 可能合并,也可能物化 |
| 多次引用 CTE 一定只扫一次基础表 | 取决于最终优化策略 |
LIMIT 1 能修复标量子查询 |
它可能掩盖数据不唯一并返回随机行 |
| 派生表一定生成磁盘临时表 | 可能被合并,也可能内存物化 |
| JOIN 可直接替换 EXISTS | JOIN 可能重复外层行 |
| NOT IN 与 NOT EXISTS 完全等价 | NULL 会改变 NOT IN 结果 |
| 递归深度设得越大越好 | 会放大坏数据和无限递归风险 |
十六、小结
- 子查询应先按返回形态和是否相关分类,再判断优化方式。
- 标量子查询必须保证最多一行,不要用无序
LIMIT 1掩盖数据问题。 IN/EXISTS常可转为半连接,策略包括 FirstMatch、LooseScan、Duplicate Weedout 和物化。- 普通 JOIN 会输出所有匹配组合,不能无条件替换只判断存在性的子查询。
NOT IN受 NULL 三值逻辑影响,反连接通常优先使用NOT EXISTS。- 派生表和普通 CTE 都可能被合并或物化,物化还可能延迟并自动创建临时索引。
- CTE 的确定收益是命名、复用表达和可读性,性能收益必须由计划验证。
- MySQL 8.0 支持 CTE;5.7 需使用派生表、临时表或应用层迭代。
- 递归 CTE 要同时设计终止条件、防环、类型长度、深度和执行时间上限。
- 优化时重点观察
EXPLAIN ANALYZE的真实行数、循环次数、条件下推和物化规模。