做后端的朋友可能都遇到过这种场面:线上一个 PHP 接口突然从几百毫秒变成好几秒,翻遍代码没发现逻辑改动,最后拿 EXPLAIN 一看,问题全出在那条子查询上。MySQL 处理子查询的方式五花八门,其中最容易让新手踩坑、也最容易带来质的提升的,就是子查询物化(Subquery Materialization)。今天我想把在真实项目里排查、改写、验证子查询物化的完整思路捋一遍,顺便聊聊 PHP 侧可以配合哪些手段——毕竟最终执行 SQL 的是 MySQL,但 SQL 怎么写、优化器行为怎么控制,还是我们 PHP 开发者的活儿。
尤其在做报表、统计、批量任务这类场景时,慢查询的瓶颈很少在 PHP 本身的算法上,而在“数据库到底把这句子查询执行了几次”。理解了物化机制之后,你会发现有些 SQL 根本不用大改,只是换一种写法、或者调整一个优化器开关,性能就能有数量级变化。这篇文章会从一次真实的慢查询讲起,再拆解 MySQL 物化的内部机制、PHP 侧的配合写法、监控与避坑点,最后给几个可以直接复现的案例。
1. 一次统计接口的慢查询:子查询是怎么把接口拖垮的
1.1 现场还原:SQL 不难,就是慢
我接手过一个电商统计接口,功能很单纯:统计近三个月通过 App 注册、状态正常且有下单记录的用户,产生的订单数和订单金额。PHP 代码里用 PDO 查询,SQL 大概长这样:
SELECT COUNT(*) AS order_cnt, COALESCE(SUM(o.amount), 0) AS total_amount FROM orders o WHERE o.user_id IN ( SELECT u.id FROM users u WHERE u.channel = 'app' AND u.status = 1 AND u.created_at >= '2024-01-01' );users 表两千多万行,orders 表八千万行,两个表都建了该有的索引。接口白天还能扛,一到晚上批量任务跑完,响应时间从 300ms 直接冲到 4 秒往上。我最初的怀疑是channel或者created_at的索引没生效,于是先EXPLAIN,结果发现 MySQL 给出的执行计划根本不是我想象的样子——内层子查询的select_type写的是DEPENDENT SUBQUERY,翻译成人话就是:MySQL 把这条 IN 子查询改成了关联子查询,外层每处理一行订单,内层子查询就要执行一次。
1.2 逐行执行子查询的代价有多大
DEPENDENT SUBQUERY听起来抽象,算一笔账就很直白了。orders 表有八千万行,哪怕经过 WHERE 条件过滤后还剩几百万行需要判断,内层子查询每次都要通过u.id = o.user_id去 users 表做一次主键查找。假设一次内层探测平均 0.05ms,几百万次探测就是几十秒的量级;真实场景里虽然优化器会做一些缓存和短路,但整体趋势不会变——把子查询放在循环里跑,就是数据库版的 N+1 问题。
这个 N+1 问题我们在 PHP 里经常犯,比如在 foreach 里挨个查询数据库。其实旧版 MySQL 处理某些子查询时也会犯同样的错。MySQL 5.5 及更老版本里,IN 子查询基本都会被改写成 EXISTS 语义逐行执行;到了 5.6.5 之后引入了物化策略,才从根本上改变这个局面。
1.3 物化的直观作用:从“循环调用”变成“一次建表 + 索引探测”
物化的思路用快递场景类比特别合适。
- 笨办法:每来一个包裹,就打电话问一遍客户名单,确认这个包裹是不是名单上的客户的。包裹越多,电话打得越多。
- 物化办法:先把客户名单打印出来贴墙上(这就是临时表),每个包裹来了,直接扫一眼墙上有没有这个名字(这就是索引探测)。
那次线上故障的最终定位,就是实例的optimizer_switch里materialization被之前排查问题时顺手关掉了。把materialization=on恢复之后,执行计划从DEPENDENT SUBQUERY变成了MATERIALIZED,接口耗时直接从 4.8 秒回到 260ms。整个过程中 PHP 代码一行没改,变的只是 MySQL 优化器的策略。
2. 子查询物化的底层机制:物化到底“化”了什么
2.1 一条 IN 子查询的完整物化流程
物化不是说 MySQL 把子查询结果“缓存”起来这么简单,它有一套明确的执行步骤:
- 优化器识别出这是非关联子查询(子查询内部没有引用外层查询的任何列),并且可以用物化策略。
- 先执行一次子查询,把完整结果集写入一张内部临时表。临时表的列类型、字符集、排序规则都由子查询结果推导。
- 根据外层查询的需要,为这张临时表建立索引(常见的是哈希索引或 B+ 树索引),用于后续的 IN 探测。
- 外层查询与临时表做半连接(semi-join)关联,每条外层记录通过索引快速判断是否命中。
- 查询结束后,临时表随语句生命周期释放;如果结果集特别大,临时表可能从内存落盘。
这里最关键的一点是“非关联”。如果子查询里面出现了外层表的列,比如WHERE u.id = o.user_id,那它就是一个关联子查询,优化器没有办法把这种子查询预先物化成一张静态快照,因为每一行外层数据都会影响子查询的结果,只能逐行执行。所以“能不能物化”的第一步判断,就是子查询里有没有外层引用。
2.2 适用语法与排除情况
物化不是所有子查询都能用的。我整理了一张常用的对照表,建议直接存在笔记里:
| 写法 | 能否物化 | 说明 |
|---|---|---|
IN (SELECT ...) | 可以 | 非关联且非否定时,优化器按成本选择 |
= ANY (SELECT ...) | 可以 | 语义等价于 IN |
FROM子句派生表 | 可以 | 5.6 起默认可能被合并,也可能物化 |
NOT IN (SELECT ...) | 通常不行 | NULL 语义导致优化器改用 EXISTS 策略 |
NOT EXISTS | 不物化 | 本身是关联语义,逐行判断 |
| 关联子查询 | 不行 | 子查询依赖外层行,无法预执行 |
含LIMIT的派生表 | 通常会物化 | 合并会改变 LIMIT 语义,所以优化器倾向物化 |
需要特别解释一下NOT IN的情况。NOT IN (SELECT ...)只要子查询结果里出现一个 NULL,整个表达式的结果就会变成未知(UNKNOWN),不再是简单的布尔判断。MySQL 为了保证语义正确,不会对否定形式的子查询做物化,而是走 EXISTS 的非匹配策略。这也是为什么很多大厂规范里直接要求:不要写NOT IN,改写NOT EXISTS或LEFT JOIN ... WHERE ... IS NULL。物化机制出现之后,这个规范又多了一条理由。
2.3 优化器的成本决策与 optimizer_switch
MySQL 从 5.6.5 开始默认开启materialization=on,5.7 和 8.0 沿用了这个默认值。但“默认开启”不等于“一定物化”,优化器会对两种执行方式做成本估算:
- 物化成本:执行一次子查询 + 写入临时表 + 可能建索引的代价。
- 逐行执行成本:外层预计扫描行数 × 每次内层子查询的代价。
如果子查询结果集特别大,临时表建索引的成本可能超过逐行探测的成本,优化器就会放弃物化。这个成本估算依赖表统计信息,所以表数据发生大规模变化之后,ANALYZE TABLE常常能改变优化器对物化的选择,这一点在运维里很容易被忽略。
与物化相关的优化器开关主要有三个:
optimizer_switch='materialization=on' optimizer_switch='semijoin=on' optimizer_switch='derived_merge=on'materialization控制子查询物化,semijoin控制半连接优化(物化是半连接策略之一),derived_merge控制派生表合并。日常调优中,我见过太多人一上来就SET GLOBAL optimizer_switch='semijoin=off',结果全库慢查询都变了。正确做法是先用会话级开关做单条 SQL 对照实验,确认收益后再决定要不要动全局。8.0 里甚至可以用SET_VAR优化器提示,把开关作用范围限制在单条语句内,这个后面细说。
2.4 EXPLAIN 里长什么样
物化成功的执行计划,在 MySQL 5.7 的表格输出里会长这样(示意输出):
+----+--------------+-------------+------------+... | id | select_type | table | type | +----+--------------+-------------+------------+ | 1 | PRIMARY | <subquery2> | ALL | | 1 | PRIMARY | o | ref | | 2 | MATERIALIZED | u | range | +----+--------------+-------------+------------+看到select_type = MATERIALIZED,就说明子查询只执行一次;看到<subqueryN>出现在外层表列表里,说明外层查询是在跟物化出的临时表做关联。MySQL 8.0 的旧式 EXPLAIN 输出里,你可能会在Extra列直接看到Materialize或Materialize lookup/Materialize scan之类的字样,这是在标明半连接物化的不同探测方式,行为上大同小异。
3. PHP 侧怎么配合:让物化真正替接口加速
3.1 先把 SQL 送到 MySQL 的方式理清楚:预处理与占位符
很多 PHP 同学会对“PHP 方案”有误解,以为要用 PHP 代码去实现什么物化算法。实际上,PHP 侧能做的最基础的事情,是确保 SQL 以干净、可被优化的形态到达 MySQL。
PDO 的预处理语句(Prepared Statement)不会影响物化。不管是PDO::ATTR_EMULATE_PREPARES开启时的客户端模拟预处理,还是关闭后的服务端预处理,MySQL 优化器看到的都是相同的查询语法树,物化策略照常生效。所以完全不用担心“用了占位符会不会导致物化失效”,放心绑定参数即可。
真正会妨碍物化的,是 PHP 里把逻辑包进函数或者拼接出不规则语法。比如在SELECT列表里写IFNULL((SELECT SUM(...) FROM ...), 0)这种标量子查询,优化器很难把它拆出来做物化,只能逐行执行;又比如在 IN 子查询里加无意义的ORDER BY,既不能优化也不能物化,纯属浪费。PHP 侧的第一原则是:能交给 SQL 集合语义处理的事情,不要做成逐行语义。
3.2 让优化器按你的意图走:Hint 与会话级开关
MySQL 8.0 开始支持优化器 Hint,可以在 SQL 文本层面干预物化决策。比如强制某条子查询使用物化:
SELECT o.id, o.order_no FROM orders o WHERE o.user_id IN ( SELECT /*+ SUBQUERY(MATERIALIZATION) */ u.id FROM users u WHERE u.channel = 'app' );再比如强制某个派生表不要合并、直接物化,用别名引用:
SELECT /*+ NO_MERGE(d) */ d.user_id, COUNT(*) FROM orders o JOIN ( SELECT DISTINCT user_id FROM user_actions WHERE action_type = 'buy' ) d ON d.user_id = o.user_id GROUP BY d.user_id;NO_MERGE(d)的意思是让优化器保留派生表d的物化形态,先把DISTINCT user_id的结果算出来放到临时表,再跟外层做关联。对应的还有MERGE(d),作用是要求合并派生表。
如果用的是 MySQL 5.7,这些 Hint 还不支持(部分 hint 在 8.0.17 之后才陆续加入),只能靠会话级开关:
$pdo->exec("SET SESSION optimizer_switch='materialization=on,semijoin=on'");我的建议是:开关改动只做实验用,生产代码里尽量通过 Hint 或 SQL 改写来控制,不要依赖会话级全局开关。因为会话级开关太容易扩散,别的查询也会跟着变,事故往往就是这么来的。
3.3 把关联子查询拆成“可物化的派生表”
PHP 项目里最常出现的坑,是统计列表页里对每一行做子查询。比如帖子列表要显示评论数:
SELECT p.id, p.title, (SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id) AS comment_count FROM posts p ORDER BY p.id DESC LIMIT 50;这个标量子查询是关联的,无法靠开关变成物化,只能对 posts 的每一行执行一次。更糟的是,如果外层不是 LIMIT 50,而是翻页到第 5000 行,那就是 5000 次内层聚合。改成 LEFT JOIN 派生表之后,子查询变成非关联,优化器就有机会物化:
SELECT p.id, p.title, IFNULL(m.comment_count, 0) AS comment_count FROM posts p LEFT JOIN ( SELECT post_id, COUNT(*) AS comment_count FROM comments GROUP BY post_id ) m ON m.post_id = p.id ORDER BY p.id DESC LIMIT 50;这里有一个非常关键的细节:带GROUP BY的派生表无法被合并,所以 MySQL 会自动走物化。物化结果里有个字段post_id,外层可以通过索引探测关联,整体执行次数从“外层行数 × 子查询次数”变成“一次物化 + 一次关联”。
不过这个改写也不是无脑收益最大。如果外层只取 50 行,而 comments 表有上千万行,物化整张GROUP BY post_id的结果可能比原来的标量子查询更慢。所以改写之前一定要EXPLAIN,看两个方向的行数估算,拿数据说话。关联子查询改 JOIN/派生表,收益核心是“把逐行执行变成一次集合执行”,而不是单纯的语法偏好。
3.4 客户端物化:PHP 自己分两步查询的适用场景
所谓“客户端物化”,就是 PHP 先把子查询的结果查出来,拿到一组 ID,再拿这组 ID 去查外层表。这是很多 ORM 框架内部使用的策略:
$ids = $pdo->query("SELECT id FROM users WHERE channel='app' AND status=1") ->fetchAll(PDO::FETCH_COLUMN); $sql = "SELECT o.user_id, COUNT(*) AS order_cnt, SUM(o.amount) AS total_amount FROM orders o WHERE o.user_id IN (" . implode(',', array_fill(0, count($ids), '?')) . ") GROUP BY o.user_id"; $stmt = $pdo->prepare($sql); $stmt->execute($ids); $rows = $stmt->fetchAll();如果$ids只有几千个,这种写法完全可行,甚至能解决老版本 MySQL 没有物化策略的问题。但要注意几个风险:
- ID 数量过大时不要这么做。几万甚至几十万个 ID 拼进一条 SQL,网络包变大,PHP 拼字符串也费内存,MySQL 解析 IN 列表的成本也很高。这时候数据库内部物化反而更合适。
- 查询语义可能变化。子查询原本可能是大集合运算,拉到 PHP 里做一次
IN判断,会丢失数据库侧的统计优化。 - 缓存策略要跟上。如果这组 ID 更新不频繁,可以把 ID 列表缓存到 Redis/APCu,减少子查询执行频率。
客户端物化本质上是“用 PHP 手动实现物化”,好处是执行计划完全可控,坏处是放弃了数据库优化器的成本决策。我一般只在 MySQL 版本太老、或者 ORM 生成的 SQL 没法控制时才会用。
3.5 字符集与字段类型一致:物化临时表最容易被忽略的坑
物化出的临时表,列的类型和排序规则是从子查询结果推导出来的。如果 IN 两边的字段类型不一致,比如orders.user_id是VARCHAR(20),而users.id是BIGINT,MySQL 做比较时就要做类型转换,临时表里可能存放大体积字段、索引也可能失效,整个物化优势荡然无存。
字符集同理。如果一个表是utf8mb4_unicode_ci,另一个是utf8mb4_general_ci,比较时必须先把两边统一,MySQL 的解决办法之一就是复制/转换数据到临时表,这件事本身就极其昂贵。
我的建议很简单:全库统一字符集和排序规则,关联字段类型严格一致。这听起来像基础设施规范,但在物化场景里,它直接决定临时表能不能走索引探测。很多看起来“物化没生效”的慢查询,其实不是优化器的问题,而是字段类型和字符集不匹配导致的隐性转换。
4. 物化不是免费的:临时表开销、落盘与监控手段
4.1 内存临时表与磁盘临时表的分界线
物化结果集要写临时表,临时表放在内存还是磁盘,性能差别巨大。MySQL 8.0 之前,内存临时表默认使用 MEMORY 引擎,大小同时受tmp_table_size和max_heap_table_size两个参数约束,实际上限约等于两者的较小值。一旦超过,MySQL 会把临时表转换为磁盘临时表:5.6 时代默认 MyISAM,5.7/8.0 默认 InnoDB 临时表。磁盘临时表的读写速度比内存临时表慢一个数量级都不止。
MySQL 8.0 的机制略有变化,内部临时表默认使用 TempTable 引擎,内存上限主要受temptable_max_ram约束,默认在 1GB 上下(具体看版本)。超过上限同样落盘。
对于 PHP 报表类接口,如果发现频繁落盘,可以针对报表实例调大tmp_table_size和max_heap_table_size到 64MB 以上。但千万注意:调大临时表上限意味着单个查询可能吃掉更多内存,内存水位和使用率要一起观察,不要盲目调整。
4.2 物化临时表有没有索引
有一个常见误解:物化临时表一定带索引。实际上不一定。
半连接物化(Materialize lookup策略)为了做 IN 探测,优化器通常会给临时表建索引,可能是哈希索引也可能是 B+ 树索引,这也是它能加速的核心原因。但对于FROM子句的派生表物化,索引是否创建取决于后续关联条件,比如外层是否用ON m.post_id = p.id做等值关联。如果派生表后面只做全表扫描,MySQL 可能就不建索引,物化表纯粹是中间结果。
所以判断一个物化计划是否高效,不能只看有没有MATERIALIZED字样,还要看外层访问临时表时用的type是ALL还是ref/eq_ref。看到ALL就要警惕,可能物化表太大,或者关联条件有问题。
4.3 三个监控入口:状态变量、慢日志、EXPLAIN ANALYZE
日常维护中我会固定看三样东西。
第一个是状态变量。Created_tmp_tables表示创建的临时表总数,Created_tmp_disk_tables表示落到磁盘的数量。两者比值如果长期偏高,说明大量临时表落盘,物化或者其他需要临时表的操作正在拖慢系统。
SHOW GLOBAL STATUS LIKE 'Created_tmp%';第二个是慢日志。开启long_query_time和慢日志后,物化导致的慢查询会直接暴露出来。配合performance_schema的语句摘要表,能快速找出同类 SQL 的聚集情况。
第三个是 MySQL 8.0.18 之后才有的EXPLAIN ANALYZE,它可以显示每一步的实际耗时、实际行数、循环次数,比静态EXPLAIN准确得多。比如:
EXPLAIN ANALYZE SELECT COUNT(*), SUM(o.amount) FROM orders o WHERE o.user_id IN (SELECT u.id FROM users u WHERE u.channel='app');输出会是一棵带真实耗时的执行树,你能直接看到物化临时表建了多少行、外层探测了多少次。这个工具对验证“物化到底有没有帮我提速”非常有用,也适合做改写前后的量化对比。
4.4 什么时候应该主动避开物化
物化不是银弹,有几种情况我反而会主动禁用或者改写:
- 子查询结果集特别大,几十万甚至上百万行,物化临时表大概率落盘,写临时表的开销可能吞掉所有收益。
- 外层行数极少,只有几十行,而子查询可以用到内层表主键索引做逐行探测,
EXISTS改写反而更快。 - 临时表频繁落盘,且状态变量里
Created_tmp_disk_tables暴增,说明物化结果超过内存上限,资源被磁盘 IO 拖住。
遇到这些情况,我常用的做法是会话级关掉物化做对照实验:
SET SESSION optimizer_switch='materialization=off'; EXPLAIN SELECT ...;如果是 MySQL 8.0,可以用更安全的SET_VAR提示,把开关隔离在单条语句内:
SELECT /*+ SET_VAR(optimizer_switch='materialization=off') */ ...对照实验跑完,确认哪个计划更快,再决定长期方案。无论结果如何,都不要全局关闭物化,否则影响的是整个实例的所有查询。
5. 三个可复现的调优案例:从执行计划、改写到实测
下面三个案例我用模拟数据在本地 MySQL 实例上跑过,表结构和数据量级都是常见的业务场景。实际生产的数据分布不同,绝对耗时会有差异,但计划变化和优化方向是稳定的。建议你照着跑一遍,重点看 EXPLAIN 的变化。
5.1 案例一:IN 子查询从逐行执行变成物化探测
场景:统计一段时间内活跃用户的订单量。表结构与开头类似,orders 400 万行,users 100 万行。
原始 SQL:
SELECT COUNT(*), SUM(o.amount) FROM orders o WHERE o.user_id IN ( SELECT u.id FROM users u WHERE u.channel = 'app' AND u.status = 1 );实验步骤:
- 先开
EXPLAIN,大概率看到DEPENDENT SUBQUERY或者MATERIALIZED,取决于你实例的开关和数据分布。 - 执行
SET SESSION optimizer_switch='materialization=off';再看计划,通常退化为逐行执行的关联子查询。 - 执行
SET SESSION optimizer_switch='materialization=on';再看计划,select_type变为MATERIALIZED,外层出现<subqueryN>。 - 用
EXPLAIN ANALYZE对比两侧真实耗时。
在我本地的结果里,关闭物化时子查询执行了 18 万次,总耗时 2.1 秒;开启物化后,子查询只执行一次,结果集约 1.2 万行,配合临时表上的索引探测,总耗时降到 180ms。这就是物化最典型的收益场景:外层行数大、内层结果集可控。
5.2 案例二:标量关联子查询改写为 LEFT JOIN 派生表
场景:帖子列表 50 万行,评论表 800 万行,列表页按时间倒序取最近 100 条帖子,展示评论数。
改写前的 SQL 是每个帖子执行一次评论数子查询:
SELECT p.id, p.title, (SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id) AS comment_count FROM posts p ORDER BY p.created_at DESC LIMIT 100;改写后:
SELECT p.id, p.title, IFNULL(m.comment_count, 0) AS comment_count FROM posts p LEFT JOIN ( SELECT post_id, COUNT(*) AS comment_count FROM comments GROUP BY post_id ) m ON m.post_id = p.id ORDER BY p.created_at DESC LIMIT 100;本例有个值得注意的细节:这个改写不一定总是更快。如果 posts 只取 100 行,而评论表里涉及的post_id只有很小一部分,原方案用post_id主键索引逐行聚合 100 次,可能比物化整张 800 万行的GROUP BY post_id结果更快。
实际情况里,我遇到的表场景是列表页取 100 行、但每条帖子的评论数还要按条件过滤(比如只看未删除评论),原方案每次子查询要扫大量行。改成派生表之后,物化只发生一次,之后 100 次关联都是索引探测,整体耗时从 3.4 秒降到 400ms。结论仍然是:EXPLAIN 验证,不要凭感觉选方案。
5.3 案例三:MySQL 8.0 里用 NO_MERGE 强制派生表物化
场景:查询“购买过商品的用户”最近订单,用户操作表user_actions有 5000 万行。
原始 SQL:
SELECT o.order_no, o.amount FROM orders o JOIN ( SELECT DISTINCT user_id FROM user_actions WHERE action_type = 'buy' ) d ON d.user_id = o.user_id WHERE o.created_at >= '2024-01-01';在 MySQL 8.0 里,优化器可能选择把派生表d合并到外层,结果DISTINCT的消除过程被推迟,整个 join 的执行路径变得异常昂贵,甚至出现对user_actions的重复扫描。这时候EXPLAIN里看不到物化临时表,你会看到一个大表直接参与 join。
强制物化:
SELECT /*+ NO_MERGE(d) */ o.order_no, o.amount FROM orders o JOIN ( SELECT DISTINCT user_id FROM user_actions WHERE action_type = 'buy' ) d ON d.user_id = o.user_id WHERE o.created_at >= '2024-01-01';加上NO_MERGE(d)后,优化器先物化DISTINCT user_id的结果,建好临时表再跟 orders 关联。本地压测中,合并计划的耗时是 1.8 秒,强制物化后降到 450ms。这个案例说明一个反直觉的事实:派生表“被合并”不一定是好事,物化有时候反而是更优的策略,取决于数据分布和后续关联方式。
另外提一句,MySQL 8.0.29 之后新增了derived_condition_pushdown,优化器会把外层 WHERE 条件下推到物化前的派生表里执行,这可能会改变你之前对物化结果的假设。所以每升级一个小版本,关键的报表 SQL 都应该重新EXPLAIN一遍。
6. 长期维护:一份我自己的实践清单
写到最后,把这几年的经验整理成清单,每一条都踩过坑:
先看版本再谈优化。MySQL 5.5、5.7、8.0 对子查询的处理差异很大。materialization是 5.6.5 之后才有的,很多低版本实例根本没有这个策略。在不知道版本的情况下直接套用 8.0 的 Hint 或开关,只会浪费时间。
任何子查询优化前,先 EXPLAIN,再 EXPLAIN ANALYZE。静态计划告诉你优化器打算怎么做,实际执行告诉你到底花了多少时间。两者结合才能判断物化是帮你还是害你。
区分“查集合”和“查是否存在”。你要的是 IN 集合的关联统计,还是逐行判断是否存在?前者适合物化或 JOIN,后者适合 EXISTS。写 SQL 之前先想清楚语义,不要背“IN 一定慢、EXISTS 一定快”这种结论。
PHP 端永远要先消灭 N+1。很多时候慢查询根不在 MySQL 物化,而是 PHP 循环里发了成百上千条 SQL。批量查询、array_chunk分片、应用层缓存,优先级都高于数据库调优。
大面积数据变化后记得ANALYZE TABLE。物化的成本决策依赖统计信息,统计信息过期,优化器就会给出离谱的计划。这个操作成本很低,收益却很直接。
不要全局乱关优化器开关。实验阶段用会话级或SET_VAR,确认收益再考虑是否长期保留。全局开关影响面太大,一次误操作可能让整个实例的查询计划都变形。
我自己最深的体会是,子查询物化不是一条可以无脑套用的规则,而是一个“让优化器有机会做出更好决定”的机制。你真正要做的是把 SQL 写成语义清晰、类型一致、非关联形式,剩下的成本和收益交给数据库去权衡。老项目里那些“祖传慢查询”,很多时候不是缺某个大招,而是缺一次EXPLAIN ANALYZE和一次结构清晰的改写。把这套流程固化下来,比记住任何一句调优口诀都管用。