MySQL 8.0 普通 CTE 默认一次性物化,执行一次并缓存结果供多次引用;递归 CTE 则迭代执行,每轮独立计算且无自动防环机制。

MySQL CTE 执行是“一次性物化”还是“多次执行”?
MySQL 8.0 中的普通 CTE(非递归)默认物化:即 WITH 子句中的查询只执行一次,结果存入临时内存结构,后续所有对 CTE 的引用都读这个缓存结果。这和 PostgreSQL 默认“内联展开”完全不同,也意味着你不用担心重复计算开销。
但要注意:WITH 定义的 CTE 仅在当前语句生命周期内存在,不会跨语句复用;且物化行为不可显式关闭(MySQL 没有 MATERIALIZED 或 NOT MATERIALIZED 关键字)。
- 如果 CTE 被主查询多次引用(比如在 JOIN 和 WHERE 中各用一次),MySQL 不会重跑子查询,而是复用已物化的结果
- 若 CTE 查询本身很大,物化可能带来额外内存压力,但换来的是确定性执行次数
- CTE 中不能包含无法物化的操作(如某些含用户变量的表达式),否则会报错
ERROR 3614
递归 CTE 的执行是迭代式逐轮计算
递归CTE(WITH RECURSIVE)是按照迭代模型运行的,而不是走物化路径。它的执行过程是这样的:先执行锚定成员,得到初始结果集R₀;然后,用R₀驱动第一轮递归查询,生成R₁;接着,再用R₁生成R₂……一直持续,直到某一轮的输出为空,才会终止。需要注意的是,每一轮的递归查询都是独立执行的,并且必须能够通过JOIN或者WHERE条件与上一轮的结果进行关联。
关键限制在于:MySQL 不支持在递归分支中使用 GROUP BY、ORDER BY、窗口函数或聚合函数——这些都会导致 ERROR 1054 或 ERROR 3641。
- 递归深度默认上限为
cte_max_recursion_depth = 1000,超限报错ERROR 3642 - 没有自动防环机制,必须靠业务逻辑(如
depth < N或路径字符串查重)避免无限循环 - 每轮迭代都走一遍 JOIN,所以
manager_id这类连接字段必须有索引,否则性能断崖式下跌
CTE 和子查询在执行计划里表现不同
看 EXPLAIN 结果时,普通 CTE 会显示为 materialized 类型的派生表,而等价的子查询可能被优化器内联或重排。这意味着:即使逻辑相同,CTE 写法可能让执行计划更可预测,但也可能阻止某些优化(比如条件下推)。
例如,把过滤条件写在 CTE 外部(SELECT * FROM cte WHERE x=1),不如写在 CTE 内部(SELECT ... FROM t WHERE x=1)高效——因为物化阶段已经把全量数据算出来了。
- 用
EXPLAIN FORMAT=TREE能清晰看到 CTE 是否被物化、是否触发临时表 - 递归 CTE 的
EXPLAIN会显示多行,每行对应一轮迭代的执行结构 - 避免在 CTE 中 SELECT *;显式列出字段可减少物化内存占用和网络传输量
多个 CTE 定义的执行顺序是线性依赖
当一个 WITH 子句里定义了多个 CTE(用逗号分隔),它们按书写顺序依次执行,且后定义的 CTE 可引用前面已定义的 CTE 名称,但反过来不行。这种依赖关系是静态解析的,不是运行时决定的。
比如:WITH a AS (...), b AS (SELECT * FROM a), c AS (SELECT * FROM b) 是合法的;但 c AS (SELECT * FROM a), a AS (...) 会直接报错 ERROR 1146(表不存在)。
- 不能在一个 WITH 块里混用普通 CTE 和递归 CTE 并让后者引用前者——MySQL 语法不允许
- 每个 CTE 独立物化,互不影响;但若 CTE b 依赖 CTE a,那 a 必须先完成物化,b 才能启动
- 这种顺序依赖让调试变直观:出错时,错误一定发生在某个具体 CTE 定义处,而不是主查询
实际写的时候,最易被忽略的是递归 CTE 的终止条件必须落在递归分支的 WHERE 子句里,且不能依赖外部参数或运行时变量——它得是纯粹基于上一轮结果的静态判断。
