MySQL 子查询、派生表与 CTE 优化:半连接、物化和递归实战

个人主页: > for_ever_love__ <(欢迎各位大佬莅临😊)
其他栏目: > 大模型开发从0到1 <
其他栏目: > iOS项目总结大全 <
其他栏目: > 我想学python了 <
其他栏目: > iOS UI <

文章目录

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. 锚点生成初始结果 1;
  2. 本轮新增行作为下一轮输入;
  3. 递归成员生成 2、3、4、5;
  4. 当递归成员不再产生行时结束。
    优先使用 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 的真实行数、循环次数、条件下推和物化规模。
相关推荐
DBA_G1 小时前
GBase 8c分布式数据库客户端工具 gsql
数据库·分布式
用户5611610232732 小时前
量化笔记程序代码分析,逐笔数据、五档快照和分钟行情代码分析
数据库
房开民2 小时前
通俗总结 Octree(八叉树)
java·服务器·数据库
星云低代码开发平台2 小时前
AI 连接器运行健康度:指标、异常恢复和联调清单
大数据·运维·网络·数据库·人工智能·mcp
鱼宵2 小时前
LangChain4j 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢
数据库·redis·elasticsearch·langchain4j
星云API技术笔记2 小时前
企业微信外部群机器人实战:多人同时咨询时,如何准确匹配消息与回复对象
数据库
ClouGence2 小时前
开源数据库管理工具 CloudDM 4.4.0 发布,新增 Kafka、SAP HANA 数据源
数据库·kafka
瀚高PG实验室3 小时前
业务无法通过hgproxy访问数据库
数据库·postgresql·瀚高数据库
鱼很腾apoc3 小时前
【MySQL】 第1期 从核心概念到表约束全解析
linux·c语言·数据库·学习·mysql