上周一条慢查询把我整得挺难受:十几万行的订单表,一个统计用户累计消费的报表SQL,跑完要三秒多,把CPU打到一个核的百分之百。EXPLAIN拉出来,子查询那一行赫然写着 DEPENDENT SUBQUERY。光是看到这七个字母,我基本就能断定问题出在哪——子查询被当成“相关子查询”在执行,外层表每扫出一行,MySQL就重新执行一遍子查询,十几万行就是十几万次查询,不慢才怪。
“MySQL不使用子查询的原因”这条规范,在很多团队的开发规范里都出现过。但我一直觉得,光记住“子查询慢、JOIN快”这个结论远远不够。要知道MySQL这些年版本差异非常大,5.5、5.6、5.7、8.0对子查询的处理逻辑完全不同,一刀切地“禁用子查询”会错失很多优雅写法,而盲目“全部用JOIN”也可能写出更差的执行计划。这篇我打算把子查询慢的根源、各种版本差异、实际排查方法、以及替换方案一次讲透。
1. 最根子的问题:相关子查询的逐行执行模式
1.1 先分清“相关子查询”和“普通子查询”
很多人在讨论子查询性能时,第一句话就问错了问题。子查询本身快不快,其实要看它是不是“相关子查询”。
所谓相关子查询,就是子查询内部引用了外层查询的列,内外层之间存在关联关系。比如:
sql复制SELECT *
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.user_id
);
子查询里的 o.user_id = u.user_id 引用了外层 u 表,这就是相关子查询。而普通子查询(非相关)是这样的:
sql复制SELECT *
FROM users
WHERE user_id IN (
SELECT DISTINCT user_id
FROM orders
);
子查询内部不引用外层任何东西,它是独立的,可以在执行时只计算一次,然后物化成临时结果供外层使用。这两种执行方式完全不同。相关子查询的坑,恰恰在于它没法“只算一次”。
1.2 相关子查询为什么慢:N+1查询风暴
MySQL从最早期版本一直到目前8.0,对相关子查询的默认处理方式都极其朴素:外层每取得一行,就把这一行的值代入子查询,重新执行一次子查询。这个动作专业点叫“逐行执行”(row-by-row execution),说人话就是——表里有10万行,子查询就跑了10万次。
举个例子,我们要找“下单次数超过2次的用户信息”:
sql复制SELECT *
FROM users u
WHERE (
SELECT COUNT(*)
FROM orders o
WHERE o.user_id = u.user_id
) > 2;
执行过程是这样的:
- 从
users表取出第一行(假设走了全表扫描); - 把这一行的
user_id代入子查询,去orders表做一次WHERE user_id = ?查询; - 统计子查询返回的
COUNT(*),判断是否大于2; - 从
users表取第二行,重复上述操作; - 直到扫完
users表所有行。
假设 users 表10万行,这个SQL实际会触发:1次全表扫描 + 10万次带索引的查询。即便每次查询只花0.1毫秒,累计也要10秒以上。这就是所谓的N+1查询问题,是关系型数据库性能优化里最经典、最容易被踩的坑之一。
N+1问题的可怕之处在于,它的问题规模是相乘的。单看单次子查询,执行计划挑不出任何毛病,走了索引、扫描行数很少、用上了最优的访问路径。但是把单次性能乘上外层行数,结果就是灾难。
1.3 EXPLAIN现场:DEPENDENT SUBQUERY长什么样
判断一个子查询是不是相关子查询,最直接的方法就是看执行计划。
sql复制EXPLAIN
SELECT *
FROM users u
WHERE (
SELECT COUNT(*)
FROM orders o
WHERE o.user_id = u.user_id
) > 2;
输出是这样的:
| id | select_type | table | type | key | key_len | ref | rows | Extra |
|---|---|---|---|---|---|---|---|---|
| 1 | PRIMARY | u | ALL | NULL | NULL | NULL | 100000 | Using where |
| 2 | DEPENDENT SUBQUERY | o | ref | idx_user | 4 | func | 3 | Using index |
注意第二行的 select_type,只要看到 DEPENDENT SUBQUERY,就意味着这个子查询是相关的,外层每扫一行,它就会执行一次。同时第一行里 type 是 ALL,说明外层走了全表扫描;rows=100000,那么子查询大概会被触发10万次。
一个健康的执行计划里,你不应该在主查询行和子查询行之间看到这种“外层全表 + 内层逐行”的组合,除非外层本身返回的结果集很小(比如只有几百行)。
注意:
DEPENDENT SUBQUERY并不一
