Merge还是Materialize?一文讲透MySQL 8.0 CTE底层的执行计划博弈与性能陷阱

大家好,我是小耶,写功课只是为了我踩过的坑,你们别再踩了!

刚转行做 DBA 那阵子,我写 SQL 有个毛病:过度追求"优雅"。

上个月重构一个复杂的财务对账报表,为了消灭那一坨嵌套极深的子查询,我用 WITH ... AS (CTE,公共表表达式) 写了一长串极其清晰的逻辑。代码层层递进,看着简直像诗一样。我当时心想:这代码,优雅,太优雅了。

结果一上线,生产环境的监控直接炸了:数据库内存使用率瞬间飙到 100%,触发了 OOM Killer 干掉了主进程;同时,磁盘上的 tmpdir 目录被瞬间塞满,整个实例假死。

查了半天,全是我那串"优雅"的 CTE 惹的祸。

今天,咱们就抛开语法糖的滤镜,从 MySQL 底层优化器的视角,扒一扒这背后极其凶险的**"物化(Materialize)"陷阱**。

一、底层博弈:Merge(合并)与 Materialize(物化)

在 MySQL 8.0 中,派生表(Derived Table)和 CTE 的处理机制其实有两条完全相反的路。

1. 合并(Merge)------ 最佳方案

如果 CTE 的逻辑足够简单(比如只有 SELECT 和 WHERE),优化器会认为它只是个"语法糖"。它会将外部的查询条件直接"合并(Merge)"到内部,把整个 SQL 拉平。相当于把代码折叠起来执行。

这种情况下,不会产生任何额外的存储开销,性能最优。

2. 物化(Materialize)------ 性能杀手

当优化器发现 CTE "不好惹" 的时候,它就会选择物化。

所谓的物化,就是**"先在内存里建一张内部临时表,把子查询的结果集存进去,外层再去查这张临时表"**。

优化器在什么情况下会被迫选择物化?

  • CTE 里面包含了 GROUP BY、聚合函数(SUM, MAX)、LIMIT 或 UNION。

  • 最致命的:你在同一个 SQL 里,引用了这个 CTE 两次以上。

想象一下那个画面:你在 SQL 里写了个 WITH A AS (...),然后 JOIN A 了一次,又 JOIN A 了一次。

优化器一看:"你都要用两次,我总不能算两遍吧?算了我先算出来存着吧。"于是,一张包含百万行数据的内部临时表,就在后台悄悄生成了。

二、灾难链路:从内存撑爆到磁盘 I/O 雪崩

物化听起来是个好机制,为什么会把数据库搞死?这就要看 MySQL 是怎么管内部临时表的。

  1. 首选内存: MySQL 优先在内存里创建临时表(使用 MEMORY 存储引擎)。其大小受限于 tmp_table_size 和 max_heap_table_size。

  2. 退化为磁盘表: 一旦你的结果集太大,超过这俩参数的阈值,MySQL 会毫不留情地把它转存为磁盘临时表(On-Disk Temporary Table)。

  3. 灾难降临: 几百万行的物化数据被疯狂写入磁盘,引发了密集的磁盘随机 I/O;与此同时,如果表里包含 TEXT、BLOB 等大字段,内存临时表根本无法处理,只能直接落盘。

  4. 连锁反应: 磁盘 I/O 被打满 -> 其他业务查询开始排队 -> 连接池被耗尽 -> 业务彻底雪崩。

三、最深的坑:递归 CTE 的"无限套娃"

还有一种更隐蔽的物化陷阱,叫 递归 CTE (Recursive CTE),常用来查组织架构树、商品分类、或者权限继承。

sql

bash 复制代码
WITH RECURSIVE cte_name AS (
    -- 初始查询
    SELECT ... 
    UNION ALL
    -- 递归部分
    SELECT ... FROM table JOIN cte_name ON ...
)
SELECT * FROM cte_name;

如果底层数据出现了**"环形引用"**(比如:A 的上级是 B,B 的上级是 C,C 的上级又填成了 A),这个 SQL 就会无限循环地执行 UNION ALL。每一层递归都在申请内存,直到 cte_max_recursion_depth(默认1000)达到上限报错,或者直接把你的数据库内存抽干,触发宿主机假死。

四、怎么排查?用对工具抓现形

遇到数据库突然变慢或 OOM,普通的 EXPLAIN 根本看不出端倪。你必须掌握这两招:

1. 神器:EXPLAIN FORMAT=TREE

MySQL 8.0.16 之后引入了树状执行计划,它可以精确显示优化器到底有没有走物化:

bash 复制代码
EXPLAIN FORMAT=TREE 
WITH big_cte AS (
    SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id
)
SELECT * FROM big_cte a JOIN users u ON a.user_id = u.id;

注意看输出结果。如果你看到类似这样的字样:

-> Materialize

-> Table scan on <temporary>

马上警铃大作! 这就意味着优化器已经帮你建了临时表,如果外面再套个大表 JOIN,你的内存随时可能爆炸。

2. 现场抓包:查 performance_schema

去数据库里查,哪个 SQL 在疯狂搞临时表:

bash 复制代码
SELECT * FROM sys.statements_with_temp_tables 
ORDER BY memory_tmp_tables DESC LIMIT 5;

如果发现某个 SQL 的 disk_tmp_tables(磁盘临时表)数量极其庞大,而且执行时间很长,那就是它了。

五、实操方案:如何避免物化陷阱?

方案一:复杂逻辑"物理落地"

如果这个 CTE 的结果集有几十万上百万行,且你要反复查。立刻放弃 CTE !

使用定时任务(Event 或外部调度),将其固化成一张物理中间表。物理表可以建索引,可以走独立表空间,最重要的是------它不会在业务高峰查询时,突然消耗大量 CPU 和 I/O 去建临时表。

方案二:精简 CTE,杜绝重复引用

如果只是为了代码好看,保持 CTE 简单(只有 WHERE 过滤)。如果一定要多次引用同一个结果集,不要写两遍 JOIN cte_name,考虑用程序代码分步查询,或者用临时表(CREATE TEMPORARY TABLE)显式管理。

方案三:调整临时表参数(治标)

如果确实需要物化,且结果集较大,一定要检查 tmp_table_size 和 max_heap_table_size(两个参数必须同步调大,取较小值生效)。同时确保 internal_tmp_disk_storage_engine=InnoDB,而不是默认的 MyISAM。

方案四:给递归加锁

对于递归 CTE,务必在业务代码里加上最大层级限制(比如 WHERE level <= 5),或者通过 cte_max_recursion_depth 参数严格控制,防止环形数据导致的无限死循环。

六、小结

CTE (WITH 语法) 确实让 SQL 具备了过程化的表达力,看起来更美了。但在海量数据和高并发面前,任何无法预判底层执行计划的美,都是需要付出代价的。

不要过度迷信优化器的智能。当你写下复杂的 CTE 时,务必习惯性敲一下 EXPLAIN FORMAT=TREE。看到 Materialize,就要想到内存和磁盘的 I/O 危机。代码是写给人看的,更是要跑在机器上的。

小耶在手,SQL 不愁

还有什么想了解的,欢迎留言!小耶一定知无不言言无不尽......我们下次见~

相关推荐
一条Go1 小时前
四个进程,一把锁:Alioth 的告警评测为什么不需要消息队列
后端·设计模式·架构
Thanks_ks1 小时前
MySQL 的 REPEATABLE READ 到底能不能防幻读——从快照读、当前读到 Next-Key Lock
数据库·mysql·innodb·mvcc·锁机制·事务隔离级别·间隙锁
得物技术1 小时前
多 Agent 架构的三次反转:从中心调度到自驱协作|得物技术
程序员·架构·llm
千桐科技2 小时前
qKnow智能体构建平台专业版 v3.1.5 发布:扩容工作流编排调用能力,上线Text2SQL插件,拓宽数据类智能应用构建边界
服务器·数据库·oracle·qknow
宇灬宇2 小时前
oracle直方图测试
数据库·oracle
Henry-SAP2 小时前
SAP PP中的核心业务流程——离散制造、重复制造、流程制造三大模式解析
前端·数据库·人工智能·制造
Pniubi2 小时前
mysql 一张表可以有多少列
数据库·mysql
阡陌数智3 小时前
Extended Thinking 深度拆解:Claude Opus 5.5 推理范式、Token 计费陷阱与 Agent 工程落地
数据库