ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL子查询性能优化实战:从慢查询到秒回的完整指南

MySQL子查询性能优化实战:从慢查询到秒回的完整指南

1. 从一段真实业务需求说起:子查询到底解决什么问题

前几天排查一个线上慢查询,业务方丢过来一段SQL,说页面加载要七八秒,用户快把客服电话打爆了。我先看了一眼表结构和数据量,又跑了一遍EXPLAIN,问题很快就定位到了——三层嵌套子查询,最内层扫了全表,中间层又做了文件排序,外层再逐个匹配。这大概是MySQL子查询最典型也最容易被写坏的场景之一。

先给不太熟的朋友补个基础定义:子查询就是嵌套在另一个SQL(父查询)内部的SELECT语句,它可以出现在WHERE、FROM、HAVING、SELECT列表、甚至UPDATE/DELETE语句中。你可能会问,明明用JOIN也能实现大部分需求,为什么还要用子查询?因为有些逻辑用子查询表达更直观,比如“找出比部门平均工资高的员工”“查询最近一单金额超过500的用户”——这类需求如果硬改成JOIN,要么需要先聚合生成临时表,要么得用窗口函数绕一大圈,代码可读性反而更差。

子查询这个语法从MySQL 4.1就开始支持了,经历过好几个版本的优化器改写策略迭代。5.6之前性能经常被吐槽,5.7开始引入了半连接优化和物化策略,8.0又对派生表做了Lateral Derived优化、窗口函数配合子查询也更好用了。所以这篇文章不是简单罗列语法,而是结合我实际排查和优化过的案例,把子查询的分类、执行原理、性能陷阱、改写技巧一次讲透。

2. 子查询的七种形态与实战拆解

2.1 标量子查询:最灵活但最容易忽视NULL

标量子查询返回的是一行一列的单个值,可以用在几乎所有需要单个值的地方。举几个最常见的场景:

-- 查询每个员工的工资与部门平均工资的差额 SELECT emp_name, salary, salary - (SELECT AVG(salary) FROM dept_emp WHERE dept_id = e.dept_id) AS diff FROM employee e;

这段SQL中,SELECT AVG(salary)返回的是一个数值,这就是标量子查询。

实际开发中,标量子查询最常见的问题不是性能,而是NULL参与运算导致结果变成NULL。如果某个部门还没有员工,内层AVG返回NULL,外层salary - NULL的结果就是NULL。很多报表因此出现大面积的空值,排查半天才发现是子查询返回了NULL。我的习惯是,凡是参与数值运算的标量子查询,外层一定要加IFNULL或者用COALESCE兜底:

SELECT emp_name, salary, salary - COALESCE((SELECT AVG(salary) FROM dept_emp WHERE dept_id = e.dept_id), 0) AS diff FROM employee e;

标量子查询还有个隐蔽的性能问题:如果它在SELECT列表里,并且是相关子查询(引用了外层表的字段),MySQL会对每一行外层记录都执行一次内层查询。也就是说,外层有10万行,内层就执行10万次。这在数据量小的时候没感觉,一旦上了规模就是灾难性的,后面我会专门讲相关子查询的优化方案。

2.2 列子查询与行子查询:语法糖背后的逻辑

列子查询返回一列多行,通常配合IN、ANY、ALL使用:

-- 找出所有在“销售部”待过的员工 SELECT emp_name FROM employee WHERE emp_id IN ( SELECT emp_id FROM dept_history WHERE dept_name = '销售部' );

行子查询返回一行多列,可以同时比较多个字段:

-- 找出与张三在同一个部门且薪资相同的员工 SELECT emp_name FROM employee WHERE (dept_id, salary) = ( SELECT dept_id, salary FROM employee WHERE emp_name = '张三' );

这里有个需要留意的点:MySQL对行子查询的等值比较支持得不错,但如果比较的是多个字段且其中某个字段允许NULL,行为可能和直觉不一致。比如(dept_id, salary) = (NULL, 5000)这种比较,在SQL的三值逻辑下永远不是TRUE,查询结果会静默丢失。

我建议能用列子查询加IN解决的,就不要用行子查询组合比较,因为可读性更好,优化器处理IN的改写策略也成熟很多。

2.3 表子查询与派生表:FROM子句里的临时视图

表子查询出现在FROM子句中,它的结果集被当作一张派生表使用,必须起一个别名:

SELECT d.dept_name, tmp.avg_salary FROM department d JOIN ( SELECT dept_id, AVG(salary) AS avg_salary FROM employee GROUP BY dept_id ) tmp ON d.dept_id = tmp.dept_id;

这里tmp就是派生表,MySQL8.0之前,派生表默认是物化的,也就是说MySQL会先把内层查询的结果存在一张内部临时表里,再做外层JOIN。8.0.14之后,优化器支持了Lateral Derived Optimization,允许派生表引用前面表的数据,配合LATERAL关键字使用:

SELECT d.dept_name, tmp.emp_name FROM department d LEFT JOIN LATERAL ( SELECT emp_name FROM employee WHERE dept_id = d.dept_id ORDER BY salary DESC LIMIT 1 ) tmp ON TRUE;

这个写法可以很方便地实现“取每个部门工资最高的员工”,以前需要用窗口函数或者反连接技巧,现在一条LATERAL派生表就搞定了。我实测过,在分层汇总类需求中,LATERAL派生表比传统相关子查询的性能好很多,因为它可以被优化器识别并采用更合理的连接顺序。

2.4 EXISTS与IN:谓词子查询的抉择

EXISTS和IN是日常开发中用的最多的两种谓词子查询,但很多人并不清楚它们在MySQL内部的不同处理路径。

先说结论:半连接场景下,MySQL 5.7+ 对IN和EXISTS的改写策略已经非常接近,并没有绝对的好坏之分,关键取决于数据分布和索引情况。简单记忆:

  • 外层表小、内层表大,且内层查询能命中索引时,IN通常表现更好;
  • 外层表大、内层表小,且需要快速短路判断时,EXISTS配合索引通常更稳;
  • NOT IN一定要小心NULL值,一旦子查询结果中有NULL,NOT IN的判定会变成“不是UNKNOWN”,导致整个查询返回空集。相比之下,NOT EXISTS不会踩这个坑。

举个例子,公司有个接口要查询“从未下单的用户”,新手往往写:

SELECT user_id, user_name FROM users WHERE user_id NOT IN ( SELECT user_id FROM orders );

只要orders表里有任何一条user_id为NULL的记录,这条查询就永远返回空。这不是MySQL的bug,而是SQL标准中NULL的逻辑语义。正确写法应该用NOT EXISTS:

SELECT u.user_id, u.user_name FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id );

我处理过好几次生产事故,都是NOT IN遇到NULL导致报表数据清零,排查起来还很绕。所以我的铁律是:遇到NOT IN先问一句,子查询列允许NULL吗?允许就立刻改成NOT EXISTS。

3. 子查询的执行计划与性能优化深挖

3.1 看懂EXPLAIN中的子查询线索

优化子查询的第一件事,是学会看执行计划。MySQL的EXPLAIN输出中,和子查询直接相关的字段主要在select_type列,常见取值有:

select_type 取值含义常见场景
PRIMARY最外层查询所有SQL都有
SUBQUERY非相关子查询,会被物化WHERE中独立的IN子查询
DEPENDENT SUBQUERY相关子查询,每行执行一次EXISTS、相关标量子查询
MATERIALIZED子查询被物化为临时表IN子查询被优化为物化
DERIVEDFROM子句中的派生表派生表查询
UNIONUNION语句中的子查询联合查询

看到DEPENDENT SUBQUERY就要警觉,它意味着内层查询会依赖外层行数据逐行执行。DEPENDENT并不是优化器给出的“相关性描述”,而是执行方式的声明,对应的是每行重复执行的嵌套循环策略。

另一种值得留意的是MATERIALIZED,它说明子查询结果被提前物化成了一张内部临时表。物化本身不是坏事,它避免了重复执行,但如果物化出来的临时表很大且没有合适的索引,后续Join时可能出现大表驱动小表的尴尬,这个时候需要检查临时表的访问路径。

3.2 半连接与物化:MySQL如何悄悄改写你的SQL

MySQL优化器中有一个非常重要的概念叫半连接(Semi-join),专门处理IN、EXISTS、ANY等形式的子查询。

半连接的含义是:不关心子查询匹配了多少行,只关心“是否存在至少一行匹配”。优化器会把这类子查询改写为半连接执行计划,可选的策略主要包括:

  • FirstMatch:对外层每一行,只要在内层找到第一条匹配就停止扫描。这是最朴素也最常用的策略,等价于EXISTS的短路逻辑。
  • DuplicateWeedout:把内层匹配结果去重后,再做普通连接。适用于子查询内部本身会产生重复行的场景。
  • LooseScan:利用内层表的索引,对每个分组只取第一条匹配值,避免重复扫描。
  • Materialization:先把子查询结果物化成临时表,在临时表上建索引,再做连接。

这些策略不是DBA手动指定的,是优化器基于统计信息自动选择的。但在某些情况下,优化器会失明。比如内外层表的数据分布严重倾斜、统计信息过期、或者WHERE条件里写了函数导致索引失效,优化器就可能选错策略。

这时候可以手动改写成JOIN形式来绕过错误执行计划,也可以调整optimizer_switch中的半连接开关。我在故障排查时见过最典型的案例:一个IN子查询被优化器选成了FirstMatch,但内层表过滤后选择性太差,导致外层每一行都要扫描大量内层记录。把optimizer_switch里的firstmatch临时关闭,强制走物化,查询时间从3秒降到0.1秒。

3.3 子查询改JOIN的黄金法则

很多优化文章提倡“子查询一律改JOIN”,这个说法过于绝对。JOIN改写后,由于普通JOIN会产生重复行,往往还要额外加DISTINCT去重,反而可能引入新的性能问题。

我的黄金法则是:

  1. 能保持语义不变的前提下,才改JOIN。IN子查询改JOIN一般要加DISTINCT,EXISTS改成JOIN则不需要去重但要注意多表连接会放大行数。
  2. 优先检查索引,而不是优先改写法。子查询慢,很多情况下不是子查询本身的问题,而是缺少合适的索引。
  3. 相关子查询优先考虑改写为派生表或EXISTS,而不是盲目改JOIN,因为相关子查询意味着N+1模式,改成JOIN可能直接用哈希连接或索引嵌套循环解决。
  4. 聚合类子查询改成JOIN+GROUP BY,往往能利用索引和优化器的排序优化。

用一句话总结:子查询和JOIN只是同一逻辑的两种不同表达,最终执行性能取决于优化器能不能把SQL翻译成高效的执行计划。我们的目标是给优化器更多选择,而不是替代它做决策。

4. 实战案例:从慢查询到秒回的完整优化

4.1 场景构建与初始SQL

为了讲清楚,我构造一个贴近真实业务的模拟场景。某电商平台有两张表:

  • orders(订单表):约200万行,order_id、user_id、amount、status、order_time
  • users(用户表):约50万行,user_id、user_name、user_level

业务需求是:找出所有“黄金会员”中,最近30天内有下单但订单金额低于100元的用户。

新手第一版SQL通常写成相关子查询:

SELECT u.user_id, u.user_name FROM users u WHERE u.user_level = 'gold' AND EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id AND o.amount < 100 AND o.order_time >= NOW() - INTERVAL 30 DAY );

单独看这条SQL的执行计划还行,但如果users表中gold会员有20万,EXISTS子查询每行执行一次,orders表上又没有(user_id, amount, order_time)的联合索引,后果就是20万次全表扫描,耗时轻松超过10秒。

4.2 逐步拆解与改写

第一步,我不急着改SQL,先检查索引。orders表上原来只有主键和user_id单列索引,user_id过滤后还要回表判断amount和order_time,这是一个典型的索引覆盖不足问题。我先加上联合索引:

ALTER TABLE orders ADD INDEX idx_user_amount_time (user_id, amount, order_time);

加完索引后再跑一遍EXISTS版本,性能提升已经很明显,因为内层查询可以直接走索引覆盖扫描。

第二步,考虑把相关子查询改成半连接JOIN。由于EXISTS只关心存在性,JOIN时不会产生重复行,无需额外DISTINCT:

SELECT DISTINCT u.user_id, u.user_name FROM users u JOIN orders o ON o.user_id = u.user_id WHERE u.user_level = 'gold' AND o.amount < 100 AND o.order_time >= NOW() - INTERVAL 30 DAY;

注意这里我加了DISTINCT,原因是理论上一个用户可能有多条符合条件的订单,不加DISTINCT用户会重复出现。虽然在这个业务场景下一对多的可能性不小,所以保留DISTINCT更稳妥。

第三步,进一步考虑能不能减少外层扫描的行数。如果users表上user_level没有索引,那每次都要全表扫描50万行去过滤gold会员,这也是一个优化点:

ALTER TABLE users ADD INDEX idx_level (user_level);

4.3 优化效果对比

我在模拟环境实测了三组数据(均为本地测试数据,结果供参考):

优化阶段执行耗时扫描行数备注
原始EXISTS,无联合索引8.6秒订单表多次全表扫描相关子查询20万次+全表扫描
加联合索引后的EXISTS0.45秒订单表索引范围扫描索引覆盖生效
改JOIN并保留DISTINCT0.32秒两张表均走索引优化器选择半连接策略
最终版本再加大缓冲池0.28秒基本命中缓存冷启动场景接近

这个案例的核心结论是:第一步永远是索引,第二步才是SQL改写。很多开发同学一上来就改写SQL,结果索引没建,换什么写法都白搭。

5. 常见问题与避坑实录

5.1 子查询的NULL陷阱

前面提过NOT IN遇到NULL的问题,这里再补两个容易被坑的场景。

第一个是NOT IN子查询中包含NULL。看这个例子:

SELECT user_name FROM users WHERE user_id NOT IN (1, 2, NULL);

直觉上应该返回除了1、2之外的所有用户,实际结果却是一个空集。原因:user_id <> NULL的结果是UNKNOWN,NOT IN等价于user_id <> 1 AND user_id <> 2 AND user_id <> NULL,只要有一个AND条件是UNKNOWN,整条过滤条件就是UNKNOWN,行不会被选中。

第二个是聚合函数内部的NULL。COUNT(col)会跳过NULL,AVG(col)也会忽略NULL参与计算。如果你在子查询里聚合,而目标列本身有NULL值,统计结果会和预期不一致。常见解决方式是提前用WHERE col IS NOT NULL过滤,或者在聚合函数里显式处理。

5.2 相关子查询的隐式性能杀手

在开发环境测试相关子查询,数据量只有几千行时效果很好,一上线到生产环境几百万行就瞬间卡死。这类问题最隐蔽,因为单看SQL完全找不出问题。

我的排查习惯是,遇到DEPENDENT SUBQUERY,第一反应是确认外层驱动表的行数。如果外层只有几十行,相关子查询完全没问题;如果外层几十万行,每条内层查询哪怕只有0.01秒,总耗时也要几十分钟。

优化手段按优先级排序:

  1. 给内层子查询的关联字段加索引;
  2. 把相关子查询改写成非相关的IN子查询;
  3. 改写成JOIN,交优化器决定连接顺序;
  4. 如果逻辑复杂,拆成多条SQL,在应用层做内存关联。

我个人实际经验是:可读性优先保留子查询,性能敏感路径一律改JOIN或拆SQL。生产环境稳定性比代码优雅重要得多。

5.3 子查询与索引失效的边界

子查询中经常包含函数或隐式类型转换,这会让索引失效。比如WHERE DATE(order_time) = '2024-01-01',即使order_time上有索引,因为套了DATE函数,MySQL无法使用索引范围扫描,只能全表扫描。

更隐蔽的是隐式类型转换。如果user_id是VARCHAR类型,而子查询传入的是INT,MySQL会把字符串列转成数字再比较,索引也可能失效。排查这类问题,可以直接用EXPLAIN看type列,如果从ref降级成ALL,优先考虑函数或类型转换的影响。

常见的保留索引写法:

-- 不推荐:对索引列使用函数 WHERE DATE(order_time) = '2024-01-01' -- 推荐:使用范围条件 WHERE order_time >= '2024-01-01' AND order_time < '2024-01-02'

5.4 子查询的常见执行计划速查

给一张速查表,方便日常排查:

执行计划特征可能原因优先处理方式
DEPENDENT SUBQUERY + 全表扫描相关列缺索引加索引或改JOIN
MATERIALIZED + 大临时表子查询结果集过大检查子查询过滤条件,考虑改EXISTS
SUBQUERY + DERIVEDFROM子查询全表扫描给派生表关联字段加索引
Impossible WHERE子查询条件恒为假检查NULL和常量条件
Using temporary / Using filesort排序和去重过大优化索引,减少扫描行数

这套速查表是我自己排查慢查询时经常对照的清单,遇到没见过的执行计划形态,先按表格分类,再顺着方向排查,效率会高很多。

6. 几个值得记住的子查询扩展用法

6.1 子查询与窗口函数配合

MySQL 8.0开始支持窗口函数,子查询和它的配合可以解决很多“取每组TopN”的问题。以前实现“每个部门工资最高的前两人”,需要用相关子查询或者复杂的自连接,现在可以这样写:

SELECT dept_id, emp_name, salary FROM ( SELECT dept_id, emp_name, salary, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn FROM employee ) tmp WHERE rn <= 2;

这里的派生表子查询把窗口函数的计算结果当作一列,外层再基于rn过滤,逻辑简洁清晰,性能也远好于同逻辑的相关子查询版本。

6.2 子查询直接驱动UPDATE和DELETE

子查询还可以用在UPDATE和DELETE中实现精确的批量操作:

-- 批量删除“近一年未下单”的用户 DELETE FROM users WHERE user_id IN ( SELECT user_id FROM ( SELECT u.user_id FROM users u LEFT JOIN orders o ON o.user_id = u.user_id WHERE o.order_id IS NULL AND u.register_time < NOW() - INTERVAL 1 YEAR ) t );

这里需要特别注意的是,MySQL不允许直接在同一张表上同时进行SELECT和DELETE,所以要包一层派生表来绕开限制。这种写法有坑,如果子查询结果非常大,DELETE操作会长时间持有行锁,建议分批删除:

DELETE FROM users WHERE user_id IN ( SELECT user_id FROM ( SELECT u.user_id FROM users u LEFT JOIN orders o ON o.user_id = u.user_id WHERE o.order_id IS NULL AND u.register_time < NOW() - INTERVAL 1 YEAR LIMIT 5000 ) t );

一批删除5000行,循环执行,避免长时间锁表。我处理过不止一次因为一次性DELETE数据量过大导致主从延迟飙升的事故,都是靠分批删除解决的。

6.3 子查询优化器的版本差异

最后提一个版本相关的问题。不同MySQL版本对子查询的处理差异很大,如果要做版本升级,一定要重新压测子查询相关SQL。

以我的经历来说,5.6到5.7的升级中,IN子查询的执行计划从单纯的SUBQUERY变成了支持Materialization和Semi-join,很多原本慢的查询肉眼可见地变快了。而5.7到8.0的升级中,派生表的Merge和Lateral能力增强,但同时也可能因为哈希连接、窗口函数等新特性的引入,导致一些子查询的代价估算发生改变。

升级前跑一遍全量慢查询回归,是我见过最有效的防坑手段。不要光看兼容性文档,要用真实数据和真实SQL测试执行计划的变化。

写在最后的实操心得

做了这么多年数据库优化,我的体会是:子查询不是洪水猛兽,真正危险的是对执行机制的无知。该用子查询的时候就大胆用,但写完务必做三件事:第一,跑EXPLAIN确认select_type和执行策略;第二,检查相关子查询的嵌套层级,尽量控制在两层以内;第三,对该子查询涉及的所有关联列确认有索引。

最后再分享一个小技巧:如果查询中用了IN子查询且怀疑优化器选错策略,可以在SQL前面加EXPLAIN FORMAT=JSON,输出里会有optimized_away、semijoin_strategy、materialized_from_subquery这些详细信息,它们比单看EXPLAIN表格更直观地展示优化器到底做了什么决策。遇到玄学性能问题,把JSON执行计划拉出来对照着看,比盲目改SQL靠谱得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进