1. 子查询到底在查什么:从一个跑数场景说起
先讲一个我前阵子遇到的真实场景。运营同事丢过来一句需求:“把每个部门里工资最高的员工名单拉出来。”听起来很普通,我下意识就想到了子查询——用一条 SQL 嵌套出结果,代码清爽,逻辑直观。可等数据量跑到百万级,那条 SQL 的响应时间直接从 0.3 秒飙到 8 秒。这个反差是很多 MySQL 初学者没意识到的:子查询写起来简单,不代表执行起来也简单。
子查询的定义一句话就能说清,就是“嵌套在其他 SQL 语句里的 SELECT 查询”。它和普通查询最大的区别在于执行顺序和依赖关系:有的子查询先执行完,把结果当成“常量表”送给外层;有的子查询则反过来,外层每处理一行,子查询就要跟着执行一次。理解这层差异,才算真正摸到 MySQL 子查询的门道。
围绕子查询,很多人会问:它到底适合谁用?我的答案是,它适合用来“快速表达复杂逻辑”,但未必适合“长期跑大表”。如果你在写报表、做数据分析、处理中小规模业务表,子查询能让你少写很多 JOIN 和临时表;如果你在做千万级大表的线上查询,子查询往往需要经过严格审查和改写。下面我把子查询的分类、执行原理、优化手段和易错点一次性讲透。
1.1 子查询的分类,先记住这一个维度
网上的分类方法五花八门,什么“嵌套子查询”“关联子查询”“行子查询”。我这里只按最实用的一个维度分:返回结果长什么样。按照这个维度,子查询可以被分成四类:
| 类型 | 返回结果 | 出现位置 | 典型示例 |
|---|---|---|---|
| 标量子查询 | 单个值 | SELECT、WHERE、HAVING | SELECT (SELECT MAX(salary) FROM emp) |
| 单列子查询 | 一列多行 | WHERE 配合 IN / ANY / ALL | WHERE dept_id IN (SELECT id FROM dept) |
| 行子查询 | 一行多列 | WHERE 配合行构造器 | WHERE (dept_id, salary) = (SELECT ...) |
| 表子查询(派生表) | 多行多列 | FROM 子句、JOIN 子句 | FROM (SELECT ...) AS t |
这个分类决定了你怎么写、怎么写更合理。比如 FROM 子句里的表子查询,MySQL 会把它当成一张临时表来用,有时还需要给它做索引,这类子查询和 WHERE 里的子查询在优化思路上完全不是一回事。
1.2 相关子查询和非相关子查询的执行差异
紧接着必须分清另一对概念:相关子查询和非相关子查询。非相关子查询是“我先算出结果,再给你外层用”,执行一次就够了;相关子查询是“外层每行数据都要带到子查询里判断一次”,执行 N 次。
举个例子。
sql复制-- 非相关:子查询只计算一次
SELECT name, salary
FROM emp
WHERE dept_id IN (SELECT id FROM dept WHERE location = '北京');
-- 相关:每一行 emp 都会拿 dept_id 去子查询里匹配一次
SELECT e1.name, e1.salary
FROM emp e1
WHERE e1.salary = (SELECT MAX(e2.salary)
FROM emp e2
WHERE e2.dept_id = e1.dept_id);
第一段 SQL 里,子查询的结果是固定的,MySQL 大概率会先物化出北京的部门 id 列表,然后外层做索引匹配;第二段 SQL 里,每个员工的部门都要跑到 emp 表里重新找一次最高工资。表一大,第二种写法会慢到让你怀疑机器配置。MySQL 优化器虽然会对相关子查询做一定改写,但改写条件很苛刻,很多时候它还是老老实实地逐行执行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 必须掌握的四种子查询写法与适用场景
有人总觉得子查询是“高级”的,只有老手才敢用。其实它就是个普通语法,把逻辑封装在了 SQL 里。我见过不少新人一上来就用 JOIN,结果 join 条件写错、数据重复,排错大半天;也有些新人只会无脑 IN,明明应该用 EXISTS 或标量查询,结果性能烂到没法看。所以下面这四种写法,我希望你都能做到信手拈来。
2.1 标量子查询:只返回一个值时的第一选择
标量子查询返回的是一个值,可以出现在 SELECT 列表、WHERE 条件、HAVING 条件里。它适合用来做“附带字段”式的查询,比如报表里经常要显示“该员工所在部门的平均工资”。
sql复制SELECT
emp_name,
salary,
(SELECT AVG(salary)
FROM emp e2
WHERE e2.dept_id = e1.dept_id) AS dept_avg_salary
FROM emp e1;
这条 SQL 的外层每读一行员工,子查询就按部门去聚合一次。如果部门表不大、emp 表有 dept_id 索引,写法很方便。但它也有一个容易被忽略的问题:标量子查询返回 NULL 时不报错,返回多行时会直接报 Subquery returns more than 1 row。所以写标量子查询时,一定要确认里面的聚合或者唯一键约束能保证“最多一行”。
2.2 IN 子查询:适合小结果集的集合判断
IN 子查询是最好懂的:右边先算出一组值,左边判断在不在里面。业务里常见的“哪些员工所在部门被裁撤了”“哪些订单属于黑名单用户”都属于这类。
sql复制SELECT order_id, user_id, amount
FROM orders
WHERE user_id IN (SELECT user_id FROM users WHERE status = 0);
IN 的优点是阅读体验好,缺点是当子查询结果集很大时可能不理想。MySQL 8.0 对 IN 做了物化优化,会把子查询结果存进临时表并建自动索引,但临时表本身有成本。当 IN 后面的集合可能超过几千上万行时,我会认真考虑要不要用 JOIN 来代替。
2.3 EXISTS 和相关子查询:逐行关联,语义更精确
EXISTS 只关心“子查询有没有返回行”,不关心返回了什么列。它天然适合相关子查询场景,比如“找出所有存在订单的用户”,用存在性判断表达最自然。
sql复制SELECT u.user_id, u.name
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.user_id
AND o.amount > 100
);
这里有个新手很容易踩的坑:SELECT 1 不是必须写 1,写 SELECT * 也行,因为 EXISTS 完全不看列内容。真正工作的条件是 o.user_id = u.user_id。这个关联条件一旦漏写,子查询就会变成“只要订单表非空就成立”,结果全表返回,而且写法上还不会报错,排查起来特别痛苦。
对比 IN 和 EXISTS 有一个经典结论:外层表小、子查询表大的时候,EXISTS 往往表现更好;外层表大、子查询结果小的时候,IN 往往表现更好。MySQL 优化器会尝试做半连接优化,但它不一定总能选对,所以关键查询我还是会用 EXPLAIN 验证。
2.4 WITH AS 临时表思路:把复杂查询拆成可读步骤
如果你对子查询的印象还停留在“括号套括号”,那 MySQL 8.0 的公共表表达式(CTE)会让你舒服很多。它的核心作用是先把一段复杂查询的结果命名成临时表,然后供后面的查询反复引用。这也是热搜里“mysql with as 子查询使用临时表”想表达的东西。
sql复制WITH dept_avg AS (
SELECT dept_id, AVG(salary) AS avg_salary
FROM emp
GROUP BY dept_id
)
SELECT e.emp_name, e.salary, d.avg_salary
FROM emp e
JOIN dept_avg d ON e.dept_id = d.dept_id
WHERE e.salary > d.avg_salary;
CTE 最大的价值不是性能提升,而是可读性和复用性。性能上,MySQL 可能把它物化为临时表,也可能直接把 SQL 展开进主查询,取决于优化器的成本估算。真正的性能收益来自两件事:第一,临时结果可以被多个引用共用,不用重复写相同子查询;第二,你可以在临时表的关联字段上建索引,或者让优化器选择更合理的连接顺序。我在处理超过三个 JOIN 的复杂报表时,都会先考虑用 CTE 把中间结果抽出来,不然 SQL 糊成一团,后续维护成本极高。
3. 执行计划视角:子查询为什么慢,慢在哪
说实话,我遇到的大部分“子查询性能问题”,根源不是子查询语法本身,而是对执行机制不了解。你如果想系统掌握子查询的优化,请记住这一章的核心视角:不要用“从上到下”的书写顺序去看 SQL,要用执行计划去看它。
3.1 相关子查询的迭代执行:最隐蔽的性能黑洞
非相关子查询执行一次,相关子查询执行 N 次,这个道理前面已经说了。问题在于,很多新手写的 SQL 表面上“长得像非相关”,实际上被优化器判定为相关。
比如这个错误版本:
sql复制SELECT emp_name, salary
FROM emp
WHERE salary > (SELECT AVG(salary) FROM emp);
如果 emp 表有 10 万行,你觉得子查询会被执行多少次?如果 MySQL 判断这个子查询是非相关的,它应该只算一次。但如果你在子查询里不小心引用了外层表的字段,或者优化器发现数据分布特殊,执行次数就可能变成 10 万次。哪怕单次子查询只要 0.01 毫秒,10 万次叠加起来也很可观。
判断方法很简单:用 EXPLAIN 看 Extra 里的 Using where、Using index、Using temporary 这些标记,或者直接把 SQL 拆出来单独执行,比较单独执行耗时和整体查询耗时的差异。如果你的整体耗时远大于“外层行数 × 单次子查询耗时”,那就要考虑优化器是否走了意外路径了。
3.2 用 Workbench 快速定位问题查询
如果你用 MySQL Workbench 跑慢查询,不要只盯着结果网格发呆。在查询结果上方有一个“Execution Plan”小图标,点开之后能看到图形化执行计划。我一般这样操作:
- 在查询编辑器里先输入
EXPLAIN,放在原 SQL 前面; - 执行后切换到 Execution Plan 视图;
- 看每一行的
type、key、rows、Extra四列。
type 列从好到差大概是 system、const、eq_ref、ref、range、index、ALL。如果你在子查询对应的表上看到 ALL,说明它在全表扫描,这是最常见的性能瓶颈。rows 是预估扫描行数,叠加起来能看出一条查询到底扫了多少行。Extra 里出现 Using temporary 和 Using filesort 时就要警惕,说明 MySQL 可能在偷偷建临时表或把数据放磁盘排序。
顺带说一句连接问题。如果你用 Workbench 连 MySQL 8 时报类似 client does not support authentication protocol requested 的错误,那和子查询一点关系都没有。这是 MySQL 8 默认加密方式 caching_sha2_password 和旧版客户端不兼容导致的身份认证问题,需要升级客户端驱动,或把用户改为 mysql_native_password。别在排 SQL 的时候被这类环境问题带偏了方向。
3.3 一个被 int+5 坑过的索引失效经历
有一次我排查一条子查询慢 SQL,条件写得逻辑上没问题,对应的字段也有索引,可执行计划就是走全表扫描。后来把条件打印出来才发现,代码里为了“临时修正数据”写出了这样的 WHERE:
sql复制WHERE DATEDIFF(NOW(), create_time) > 30
DATEDIFF 函数把 create_time 包起来了,索引自然失效。同一类问题还有:
sql复制WHERE salary + 5 > 10000; -- salary 列参与了运算
WHERE YEAR(create_time) = 2024; -- 列被函数包裹
这就是热搜词里“mysql中int+5”真正要表达的坑:你写 salary + 5 的时候,MySQL 无法直接用 salary 上的索引,因为它必须对每一行都做一次加法再去比较。正确写法是把计算移到常量一侧:
sql复制WHERE salary > 10000 - 5;
在子查询场景里,这类问题更隐蔽,因为子查询内部的列参与计算时,优化器往往也会放弃索引。排查慢查询时,如果发现执行计划里明明有索引却不走,第一反应就去看是不是对索引列做了函数运算或隐式类型转换。
3.4 ORDER BY、LIMIT 与派生表:临时表引发的排序问题
子查询在 FROM 子句里生成的派生表,MySQL 经常需要物化。加上 ORDER BY 和 LIMIT 之后,很容易出现“明明只取 10 条,却把整表排了序”的场景。最典型的坏写法是这样:
sql复制SELECT t.user_id, t.total_amount
FROM (
SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
ORDER BY total_amount DESC
LIMIT 10
) t;
看起来是先排序取前 10,再套一层查询,逻辑上没毛病。可问题是,内层 GROUP BY 和 ORDER BY 处理的是 orders 大表,物化临时表可能很大,排序成本不低。而且如果外层还要关联用户表,优化器未必能把 LIMIT 条件下推给内层,结果就是事倍功半。
我遇到这种情况时,会先检查 EXPLAIN 里有没有 Using temporary。如果确认是派生表排序问题,我通常会先确认这个子查询能不能改写为“先关联再聚合”,或者在业务允许的情况下,把排序和取前 N 的逻辑完全拆出来,分两步在应用层处理。另外提醒一句,MySQL 8.0 对派生表默认会做 MERGE 或 MATERIALIZE 两种处理方式的选择,不是所有派生表都物化,但选择和成本估算有关,明确数据量后再下结论才是稳妥做法。
4. 三个真实优化案例:把子查询改写得又快又稳
聊原理聊了一大堆,这一章我直接上三个我实际优化过的案例。这几个案例有一个共同点:它们都不是某条 SQL 单点爆慢,而是整个页面报表慢,用 Workbench 抓到“元凶”之后,逐条改写验证。我把完整链路写在这里,给你做参考。
4.1 案例一:IN 改成 JOIN 后,扫描行数从一万降到一百
原 SQL 是这样的:
sql复制SELECT *
FROM product
WHERE category_id IN (
SELECT category_id
FROM category
WHERE status = 1
);
category 表不大,只有 2000 多行,但 product 表有 300 万行。执行计划显示,product 表走了全表扫描。原因很简单:子查询返回了 800 多个 category_id,优化器没有选择把物化临时表转换成带索引的半连接,而是直接对 product 全表扫了一遍。
改写方案:
sql复制SELECT p.*
FROM product p
JOIN category c ON p.category_id = c.category_id
WHERE c.status = 1;
改成 JOIN 之后,执行计划里 product 表走了 category_id 索引,扫描行数从 300 万降到 100 多行,查询时间从 2.1 秒降到 0.08 秒。这里有个前提:category 的主键约束保证了 JOIN 不会产生重复的 product 行。如果多表 JOIN 会产生重复行,那就需要 SELECT DISTINCT,但 DISTINCT 本身也有成本,要结合数据特征权衡。
4.2 案例二:相关子查询改成 JOIN + GROUP BY
这是“每个部门最高工资员工”的典型翻版,原 SQL 如下:
sql复制SELECT e1.emp_name, e1.salary, e1.dept_id
FROM emp e1
WHERE e1.salary = (
SELECT MAX(e2.salary)
FROM emp e2
WHERE e2.dept_id = e1.dept_id
);
emp 表 50 万行,dept_id 上有索引。理论上每次子查询走索引应该不差,但实测要 4.6 秒。原因是外层每一行都执行一次子查询,50 万次开销累加起来非常可观。
改写思路是先算每个部门的最高工资,再 JOIN 回去匹配员工:
sql复制SELECT e1.emp_name, e1.salary, e1.dept_id
FROM emp e1
JOIN (
SELECT dept_id, MAX(salary) AS max_salary
FROM emp
GROUP BY dept_id
) t ON e1.dept_id = t.dept_id
AND e1.salary = t.max_salary;
改写后,内层 GROUP BY 对 50 万行做一次分组聚合,外层再用部门索引匹配,整体耗时降到 0.5 秒。如果你的部门里有两个人工资恰好相同且都是最高,这种方法会把两个人都查出来,和原 SQL 的语义一致。如果业务只想要其中一个人,那还得靠窗口函数或加去重规则。
4.3 案例三:用 WITH AS 临时表分段处理大查询
上个月处理过一个统计报表需求,要算“每个城市的销售额 TOP3 品类”。用子查询直接写会非常痛苦,既有排名逻辑,又有分组逻辑,直接套一个大子查询执行计划直接乱掉。我最后用 CTE 拆成三段:
sql复制WITH city_sales AS (
SELECT city_id, category_id, SUM(amount) AS sales
FROM orders
GROUP BY city_id, category_id
),
ranked AS (
SELECT city_id, category_id, sales,
ROW_NUMBER() OVER (PARTITION BY city_id ORDER BY sales DESC) AS rn
FROM city_sales
)
SELECT *
FROM ranked
WHERE rn <= 3;
这段 SQL 在 orders 表只有几百万行时跑得不错,因为它把“关联聚合”和“排名计算”分开了。第一个 CTE 先把原始订单压缩成城市 x 品类的汇总结果,第二个 CTE 基于汇总结果做窗口函数排序,最后一个 SELECT 再过滤排名。每一步都只处理相对小的中间结果,执行计划不混乱,排错也好排。
如果中间结果还是大,我会给 city_sales 里的 city_id 字段建索引,或者在第三段里再 JOIN 到城市表取出城市名称。CTE 的临时表是否物化,可以用 EXPLAIN 确认;如果你发现它被多次引用,MySQL 可能会在内存或磁盘上做临时表,这时候临时表的大小限制、磁盘 IO 就会成为新的瓶颈。
4.4 改写验证:结果一致性检查不能省
改写 SQL 最怕得到一个“看似正确”的新结果。每次改写完,我不会直接上生产,而是先做两件事:第一,把新旧两条 SQL 各跑一遍,对比行数和关键字段的汇总值;第二,造几条边界数据,专门测试重复记录、NULL 值、空表这三种场景。
sql复制-- 对比行数
SELECT COUNT(*) FROM (旧SQL) AS t;
SELECT COUNT(*) FROM (新SQL) AS t;
空表场景很多人忽略。子查询改写为 JOIN 后,如果子查询结果为空,JOIN 会导致外层结果也为空,这在语义上和 IN 空集合是一致的;但如果是 LEFT JOIN,结果可能就不同了。所以如果你的业务需要“即使子查询没结果也要返回外层数据”,那就要特别注意 JOIN 类型的选择,不能用内连接直接替换。
5. 子查询的易错点:NULL、IN 与 EXISTS 的三角关系
子查询的坑,很多都集中在 NULL 上面。MySQL 里的 NULL 参与比较时,结果永远是“未知”,既不是真也不是假。这个特性放到子查询里,会产生一些非常反直觉的结果。
5.1 IN 遇到 NULL,为什么查不出数据
看这个例子:
sql复制SELECT *
FROM emp
WHERE dept_id IN (SELECT dept_id FROM dept WHERE status = 0);
如果子查询结果里包含一个 NULL,比如 dept 表中有一条 status=0 的记录,但 dept_id 是 NULL,那这个 IN 的语义会变成:只要 emp.dept_id 和 NULL 比较不了,这一行就会被过滤掉。最终结果是:即使 emp 里有匹配的 dept_id,只要子查询结果里存在 NULL,整条 SQL 都可能查不出所有行。
要理解这一点,可以把 IN 展开成多组 OR 条件:
sql复制WHERE emp.dept_id = 1 OR emp.dept_id = 2 OR emp.dept_id = NULL
dept_id = NULL 的结果是未知,所以只要前面条件不成立,这一行就进不了结果集。正确做法是在子查询里主动过滤:
sql复制WHERE dept_id IN (SELECT dept_id FROM dept WHERE status = 0 AND dept_id IS NOT NULL)
同时也建议大家设计表时给外键列加 NOT NULL 约束,这是釜底抽薪的办法。
5.2 NOT IN 与 NOT EXISTS,别混着用
NOT IN 遇到 NULL 的问题更严重。如果你写:
sql复制SELECT *
FROM emp
WHERE dept_id NOT IN (SELECT dept_id FROM dept WHERE status = 0);
只要子查询结果中出现哪怕一个 NULL,整个查询结果就会为空。我在刚做开发那两年,因为这个问题被线上数据狠狠教育过。原因依然是逻辑展开:子查询结果只要有一个 NULL,所有的 dept_id != NULL 比较结果都是未知,NOT IN 自然一条记录都返回不了。
正确姿势是在排他查询里优先使用 NOT EXISTS:
sql复制SELECT *
FROM emp e
WHERE NOT EXISTS (
SELECT 1
FROM dept d
WHERE d.status = 0 AND d.dept_id = e.dept_id
);
NOT EXISTS 走的是关联判断,NULL 不会破坏整个结果集。如果你的数据模型里相关列都有 NOT NULL 约束,用 NOT IN 倒也不会出问题,但为了稳健,我个人的代码规范是:排他查询一律用 NOT EXISTS,少让自己操心 NULL 的坑。
5.3 行子查询与多字段比较
WHERE 条件里还可以用“行构造器”和行子查询配合,一次性比较多个字段。比如“找出和员工编号 1001 同部门同职级的其他员工”:
sql复制SELECT *
FROM emp
WHERE (dept_id, job_level) = (
SELECT dept_id, job_level
FROM emp
WHERE emp_id = 1001
);
这个写法对“多字段等值匹配”场景非常简洁。但要注意两点:第一,行构造器在 MySQL 里通常能利用到组合索引,如果表上有 (dept_id, job_level) 的组合索引,执行计划会好看很多;第二,子查询如果返回多行,照样报错。所以这种写法适合唯一键查询的子查询,不适合聚合类的。
5.4 和 ONLY_FULL_GROUP_BY 的冲突
MySQL 5.7 以后默认开启 ONLY_FULL_GROUP_BY,这个模式对子查询也有影响。在子查询里如果用了 GROUP BY,查询列表只能出现分组列和聚合函数,否则会报错。
sql复制-- 这个写法在 ONLY_FULL_GROUP_BY 下会报错
SELECT dept_id, emp_name, MAX(salary)
FROM emp
GROUP BY dept_id;
很多报表 SQL 以前在 MySQL 5.6 能跑,升级到 5.7 之后突然报错,往往就是这个原因。处理方式不是关掉这个模式,而是把非聚合字段移到子查询外再去关联。
6. 面试真题和实战经验:子查询考到烂熟的那些点
子查询几乎是我面试 MySQL 岗位时的必问话题。倒不是因为语法多难,而是它能把“会用”和“理解”区分开。下面这些高频问题,不只是背答案,我建议你动手在数据库里验证一遍。
6.1 高频面试题清单
| 问题 | 考查点 | 回答要点 |
|---|---|---|
| IN 和 EXISTS 哪个快 | 优化器与场景理解 | 小表驱动大表时 EXISTS 倾向更优,但 MySQL 8 半连接优化下需要 EXPLAIN 判断 |
| 相关子查询为什么慢 | 执行原理 | 外层每行执行一次子查询,扫描行数相乘 |
| NOT IN 为什么查不到数据 | NULL 语义 | 子查询结果含 NULL 时 NOT IN 整体为空,应改 NOT EXISTS |
| 子查询和 JOIN 怎么选 | 可读性与性能 | JOIN 常能复用索引、减少逐行执行,但要注意去重 |
| 标量子查询返回多行怎么办 | 报错处理 | 用聚合或唯一键保证子查询最多返回一行 |
| 用子查询实现排名 | 窗口函数与 CTE | ROW_NUMBER() 配 CTE,比自连接更清晰 |
如果你想在面试里加分,可以主动提一句:“MySQL 8.0 对 IN 子查询会做半连接优化和物化优化,所以早期版本里 IN 一定比 EXISTS 慢的说法已经不太准确。”这句话能让面试官知道你关注过版本差异,而不是只会背八股。
6.2 存储过程中使用子查询的注意点
在存储过程里写子查询,常见的问题是临时表与循环的交互。MySQL 存储过程中的循环一次只能处理一行,如果循环内部再写一个相关子查询,性能会成倍恶化。我的经验是:能先聚合出结果再循环的,绝不在循环里查子查询。
sql复制-- 不建议:循环内做子查询
WHILE ... DO
SELECT COUNT(*) INTO cnt FROM orders WHERE user_id = cur_id;
END WHILE;
-- 建议:先批量聚合到临时表,再循环读取
CREATE TEMPORARY TABLE tmp_stats AS
SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id;
存储过程调试也麻烦,子查询一旦写错,很难像普通 SQL 那样即时定位。我一般会先把子查询单独验证好,再封装进过程。
6.3 排序、去重与子查询的配合
子查询里做 ORDER BY 很容易被忽略的问题,就是 LIMIT 和 ORDER BY 的顺序。很多人写完 LIMIT 10 才发现 SQL 不是先排序再取前 10,而是先取值再排序,原因是没有把排序写在子查询里。需要实现“取每组前 N 条”时,我的首选方案是用窗口函数而不是子查询加自连接。
sql复制-- 取每个部门工资前 3 名
SELECT emp_name, dept_id, salary
FROM (
SELECT emp_name, dept_id, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn
FROM emp
) t
WHERE rn <= 3;
这段 SQL 的执行计划在我的实践里通常比“原表自连接 + 子查询计数”的方式要稳定。如果你的 MySQL 版本低于 8.0,不支持窗口函数,那再用老办法也不迟。版本升级到 8.0 之后,我处理排名类需求基本都切换到窗口函数,可读性和性能都更好。
6.4 我踩过几次坑之后的个人规范
最后分享几条我给自己定的规矩,算是多年踩坑换来的经验备忘。
第一,线上查询默认不加 WHERE (a, b) IN (SELECT ...) 这类多字段 IN,MySQL 对它的优化支持参差不齐,容易出意外。
第二,看到子查询里出现对索引列的函数运算或四则运算,先停下来改写,再谈索引优化。
第三,子查询结果含 NULL 是常态,宁可多写一个 IS NOT NULL 过滤,也不要赌数据不会为空。
第四,每次改写 SQL 都要做结果对比,行数对上了不算完,抽样看几行明细,确认业务口径没有变化。
第五,在存储过程和复杂报表里,优先用 WITH AS 把查询拆成有名字的中间步骤。这不只是为了性能,更是为了让三个月后的自己还能一眼看懂这 SQL 在算什么。
子查询不是洪水猛兽,也不是银弹。它是一把很好用的刀,但你必须知道它在什么场景下会钝、会砍偏。我现在的习惯是:先写出正确、可读的 SQL,再根据执行计划和数据量决定是否改写。你如果能沿着这条路线走,子查询相关的开发、排错、面试题目,基本都能稳住了。
