做为一个天天跟SQL打交道的人,我太清楚子查询在面试题和实际开发里出现的频率了,几乎每个MySQL教程里都有它,但真正能把它用明白、用顺手的却没几个。很多人停留在“会写”的阶段,一遇到性能问题或者稍微复杂点的业务场景就懵。这篇东西我不打算给你念教科书,就从一个实际开发者的角度,把MySQL子查询这点事掰开了讲清楚,包括它是什么、能解决什么问题、怎么写才高效,以及那些文档里不会告诉你的坑。
不管是刚入门的学生,还是写了两三年SQL但一直靠拼凑逻辑过日子的人,这篇文章应该都能让你对子查询有个全新的认识。我会用经典的学生-课程-成绩表来贯穿全文,这套例子网上到处都是,但我会把每个场景背后的设计逻辑和优化思路讲透,让你看完之后能直接用到自己的项目里。
1. 子查询的核心逻辑:你为什么需要它
子查询本质上是“把查询嵌套在查询里”,也就是一条SELECT语句里再包一条SELECT。这种结构之所以存在,是因为很多业务问题天然就是分层的:我想知道“成绩高于平均分的学生有哪些”,你得先算出平均分,再拿每个学生的成绩去比较。这个“先算平均分”的动作,就是一个完整的查询,而你最终要的结果,又是基于这个查询结果再做一次查询。
没有子查询的时候,你通常要分两步:先查平均分,记下来,再写第二条SQL把那个数字填进去。这样既麻烦,又容易出错,而且没法在一个语句里完成。子查询的价值就是让你把两步合成一步,用一条SQL表达一个完整的分层逻辑。
我见过很多初学者觉得子查询难,难就难在“嵌套”这两个字上。人脑天生不太擅长处理递归和嵌套的结构,所以你写子查询的时候,最重要的技巧不是记住语法,而是学会拆解:先看内层查询返回的是什么,是一个值、一列,还是一张表?搞清楚这个,外层该怎么用就一目了然了。
从类型上讲,子查询可以按三个维度划分。按位置分,可以出现在WHERE、FROM、SELECT、HAVING等不同的子句里;按返回结果分,可以是标量(单个值)、一行、一列,或者一张临时表;按是否涉及外层查询分,又分为不依赖外层的非相关子查询,和需要外层数据驱动的相关子查询。这三个维度组合起来,就是网上那些“9种组合鬼斧神工”的标题党文章的由头,实际用的时候不用记那么多,你只要理解每一层查询的输入输出就够了。
1.1 一个子查询从被写到执行,MySQL做了什么
理解执行过程很重要,因为很多性能问题就出在这里。MySQL拿到一条带子查询的SQL,会先做语法解析和语义分析,然后交给优化器。优化器会尝试把子查询转换成更高效的执行计划,比如把子查询改成semi-join(半连接),或者物化成一张临时表,也可能把子查询直接作为依赖的子计划来执行。
从用户视角看,你不需要管底层到底怎么优化,但你必须有一个意识:你写的子查询,和MySQL实际执行的,可能不是一回事。所以你在网上看到的一些“子查询性能差”的说法,很多时候是针对老版本的结论,MySQL 5.7和8.0的优化器已经比早期版本聪明太多了。这也就意味着,你用EXPLAIN看执行计划,比道听途说某个写法快慢要靠谱得多,这点后面我会专门展开。
1.2 什么时候该用子查询,什么时候该用JOIN
这是一个被问了无数次的问题。我的建议比较简单:先看语义是否匹配,再看性能是否可接受。
如果子查询表达问题的思路更清晰,那就用子查询,别硬转JOIN。举个例子,查询“选了课程编号为2的学生名单”,你当然可以用JOIN把选课表和课程表连起来,但我个人觉得用EXISTS或者IN的子查询语义更直接:先找出选了2号课的学生ID,再回学生表取姓名。尤其当你只需要学生信息,不需要课程信息时,子查询能避免JOIN带来的重复行和多余的列扫描。
反过来,如果业务逻辑确实需要同时展示两张表的数据,比如“每个学生的姓名和他选的所有课程名称”,这种就必须JOIN了,子查询干不了这活。原则就一句话:子查询解决的是“符合某种条件的集合”问题,JOIN解决的是“多表横向拼接”的问题。方向上不要搞反就好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 五类核心场景:从入门到进阶的实操复盘
我接下来把这几个场景依次过一遍,每一段我都会给你建表SQL、示例数据和完整的查询语句,把结果也列出来,这样你可以直接跑一遍验证我的说法。
先建一个最经典的教学库结构:
sql复制-- 学生表
CREATE TABLE student (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
age INT,
class_name VARCHAR(50)
);
-- 课程表
CREATE TABLE course (
id INT PRIMARY KEY AUTO_INCREMENT,
course_name VARCHAR(100) NOT NULL
);
-- 成绩表
CREATE TABLE score (
id INT PRIMARY KEY AUTO_INCREMENT,
student_id INT NOT NULL,
course_id INT NOT NULL,
score DECIMAL(5,2),
FOREIGN KEY (student_id) REFERENCES student(id),
FOREIGN KEY (course_id) REFERENCES course(id)
);
sql复制-- 插入学生
INSERT INTO student (name, age, class_name) VALUES
('张三', 20, '计算机2301'),
('李四', 21, '计算机2301'),
('王五', 22, '计算机2302'),
('赵六', 20, '计算机2302'),
('孙七', 23, '计算机2301');
-- 插入课程
INSERT INTO course (course_name) VALUES
('数据库原理'),
('数据结构'),
('操作系统'),
('计算机网络');
-- 插入成绩
INSERT INTO score (student_id, course_id, score) VALUES
(1, 1, 88.00), (1, 2, 92.00), (1, 3, 75.00),
(2, 1, 60.00), (2, 2, 85.00), (2, 4, 90.00),
(3, 1, 95.00), (3, 3, 88.00),
(4, 1, 70.00), (4, 2, 65.00), (4, 4, 78.00),
(5, 2, 55.00), (5, 3, 82.00), (5, 4, 91.00);
这套表结构很经典,涵盖了多对多关系,子查询能玩出的花样基本都能在这个结构上演示。表建好后,下面这几种场景你工作中大概率都会碰到。
2.1 WHERE子句中的子查询:最常用的过滤手段
WHERE子句里的子查询是最常见的形态,通常和IN、NOT IN、EXISTS、NOT EXISTS以及各种比较运算符搭配。
先说IN。IN后面跟一个子查询,表示“外层记录的某个字段,落在子查询返回的集合里”。比如查“选了数据库原理这门课的学生名字”:
sql复制SELECT name FROM student
WHERE id IN (
SELECT student_id FROM score
WHERE course_id = (SELECT id FROM course WHERE course_name = '数据库原理')
);
注意我这条SQL里其实嵌套了两层子查询:内层的子查询先查出数据库原理的课程ID,中间层的子查询再用这个ID去成绩表里找出选课的学生ID,最外层才用这些ID回学生表取名字。这就是分层思维的典型体现。写的时候从里往外写,先写最简单的,再一层层套。
EXISTS和IN不一样。IN是把子查询结果集一次性算出来,然后拿外层值去匹配;EXISTS是“存在性判断”,每检查一条外层记录,就去内层查询看能不能找到匹配的行,找到就返回TRUE,停止继续找。严格说,EXISTS是依赖外层查询的相关子查询,因为内层SQL里通常要引用外层的列:
sql复制SELECT s.name FROM student s
WHERE EXISTS (
SELECT 1 FROM score sc
WHERE sc.student_id = s.id AND sc.score > 90
);
这个查询找的是“至少有一门课成绩超过90分的学生”。我习惯在EXISTS里写SELECT 1而不是SELECT *,因为EXISTS只关心有没有行,不关心选什么列,写1可以少传达一些无用信息,执行计划也更清晰。
2.2 使用FROM子查询:把子查询当临时表用
FROM子句后面的子查询,在MySQL里叫“派生表”。它的核心价值是:把复杂的聚合逻辑先处理成一张“简化后的表”,外层再基于这张表做进一步查询。
举个例子,我想统计每个班有多少学生,这本身不需要子查询,但我想看“每个班的平均年龄和这个班的人数”,用一条GROUP BY就完成了。但如果你要基于这个结果再做筛选,比如“找出平均年龄小于21岁的班级”,直接在外层套HAVING就行。可是,一旦你想对聚合结果执行更复杂的逻辑,比如把多个聚合结果合并,FROM子查询就派上用场了。
比如找“选了2门以上课程的学生及其选课数”:
sql复制SELECT t.student_id, t.cnt
FROM (
SELECT student_id, COUNT(*) AS cnt
FROM score
GROUP BY student_id
HAVING COUNT(*) >= 2
) AS t;
这看起来像是在绕弯子,但它演示了一个非常重要的能力:你可以把GROUP BY的聚合结果当作一张普通表,继续参与JOIN、WHERE等其他操作。实际开发中,我做报表时经常先写一个复杂的聚合子查询作为基础数据集,然后再把多张这样的“表”JOIN在一起,逻辑清楚,维护也方便。
FROM子查询在MySQL里必须有个别名,哪怕你根本不引用它。不写别名会直接报错,这是新手很容易踩的坑。
2.3 SELECT子句里的标量子查询:让查询结果多一列
SELECT后面也可以放子查询,但只能放标量子查询,也就是返回单个值的子查询。它的作用相当于在结果集里“加一列”,这列的值是根据当前行的数据去其他表里查出来的。
比如我想查每个学生的名字,以及他数据库原理课的成绩:
sql复制SELECT
s.name,
(SELECT sc.score FROM score sc
WHERE sc.student_id = s.id
AND sc.course_id = (SELECT id FROM course WHERE course_name = '数据库原理')
) AS db_score
FROM student s;
这种写法最大的好处是,它不需要JOIN,也不会因为一对多关系导致数据行变多。但它有一个明显的坏处:每一行都要执行一次子查询,如果外层表很大,性能会非常差。所以标量子查询适合小数据量或者外层表经过WHERE过滤后的场景,大量数据时优先考虑JOIN。
2.4 HAVING子句中使用子查询:让分组条件更灵活
HAVING是用来过滤“分组后”的结果的。它后面同样可以跟子查询,用于做一些和全局聚合值比较的过滤。
最简单的例子,查“成绩表中所有学生的平均分大于全体平均分的学生ID”。你可能会觉得平均分大于平均分这不废话吗?换个场景,查“每个学生的平均分大于全年级平均分的学生”:
sql复制SELECT student_id, AVG(score) AS avg_score
FROM score
GROUP BY student_id
HAVING AVG(score) > (
SELECT AVG(score) FROM score
);
这个场景其实很实用,比如筛选出高于整体水平的用户、产品、门店等。你看,这个子查询返回的是一个标量(全体平均分),所以它可以放在HAVING后面跟比较运算符配合。如果子查询返回的是一个集合,那就得结合IN之类来使用了。
2.5 WITH AS临时表(CTE):把子查询从嵌套地狱里救出来
上面这些例子还比较简单,一旦业务复杂起来,三层四层的嵌套子查询绝对会让人写到怀疑人生。MySQL 8.0开始支持的公共表表达式(Common Table Expression,简称CTE)就是用来解决这个问题的。
CTE的语法是WITH 临时表名 AS (子查询),你可以把它理解成“给一段子查询起个名字”,然后在后面的SQL里反复引用它。上面的选课统计我可以改写成这样:
sql复制WITH course_count AS (
SELECT student_id, COUNT(*) AS cnt
FROM score
GROUP BY student_id
HAVING COUNT(*) >= 2
)
SELECT s.name, cc.cnt
FROM student s
JOIN course_count cc ON s.id = cc.student_id;
这段SQL干了什么事?先把选课数大于等于2的学生ID和选课数放进一个叫course_count的“临时表”里,然后外层直接JOIN这张临时表。是不是可读性好很多?
CTE最大的优势有三个:一是避免多层嵌套,让SQL从纵向堆积变成横向展开;二是可以在一句话里多次引用同一个CTE,避免重复写同一段子查询;三是递归CTE能处理树形结构数据,比如查询部门的层级关系,这个用普通子查询写起来非常痛苦,但用CTE就很自然。
热搜词里专门有一条“mysql with as 子查询使用临时表”,说明这是现在非常主流的写法,尤其是8.0普及以后,我强烈建议你把CTE用起来。有一点要提醒你:CTE里的SELECT结果会被物化成临时表,如果数据量特别大,频繁引用CTE可能会有额外的内存和磁盘开销,你需要结合EXPLAIN去评估。
3. 性能优化:为什么你的子查询慢,又该怎么修
上面的内容解决的是“怎么写对”的问题,接下来聊聊“怎么写快”。子查询用不好确实会慢,但慢的原因往往不是子查询本身,而是写法触发了优化器的某些“不擅长”分支。
3.1 先用EXPLAIN看执行计划,别猜
我见过太多人,SQL跑得慢就开始瞎猜,一会儿加索引一会儿换写法,最后也没解决问题。正确做法是先看执行计划。在MySQL里执行EXPLAIN SELECT ...,能看到这个查询用了哪些索引、扫描了多少行、有没有临时表、有没有文件排序。
sql复制EXPLAIN SELECT s.name FROM student s
WHERE s.id IN (
SELECT student_id FROM score WHERE score > 90
);
看执行计划的时候,重点关注几个字段:type(访问类型,从好到差依次是const、eq_ref、ref、range、index、ALL)、key(实际用的索引)、rows(预估扫描行数)、Extra(有没有Using temporary、Using filesort这类字样)。如果你的子查询在EXPLAIN里出现了DEPENDENT SUBQUERY,这意味着它是相关子查询,外层每查一行,内层就要执行一次,这种往往就是性能瓶颈。
顺手说一句,如果你把子查询改写成JOIN之后执行计划变得更好,那就用JOIN;如果差不多,那就按可读性选择。不要迷信某种写法一定比另一种快,要看数据量和索引情况。
3.2 哪些子查询应该改成JOIN
有几种典型的场景,改写成JOIN以后性能会有明显提升。
第一种是IN子查询在大表上的情况。WHERE id IN (SELECT student_id FROM big_table)这种,如果big_table很大,MySQL可能会把子查询物化成临时表,然后再做连接。但如果你这个查询的逻辑是“学生表里id能在大表里找到对应的student_id”,那本质就是JOIN,直接写成:
sql复制SELECT DISTINCT s.name
FROM student s
JOIN big_table bt ON s.id = bt.student_id;
这里要加DISTINCT,因为JOIN可能会让同一名学生出现多次,而IN语义本身是去重的。小心别把结果弄多了。
第二种是NOT IN子查询,它极容易踩NULL的坑,我们后面详细说,但性能上NOT IN往往也不如NOT EXISTS或者LEFT JOIN加IS NULL的组合。
第三种是EXISTS已经做得很好,不需要改JOIN。在MySQL里,EXISTS通常被优化成semi-join,效率本身就不错,如果你的EXISTS写法配合了正确的索引,就不需要多此一举。
3.3 索引对子查询性能的影响,比你想象的更大
子查询慢,十有八九是索引问题。无论是内层子查询还是外层查询,都可能需要索引来加速。
比如SELECT * FROM student WHERE id IN (SELECT student_id FROM score WHERE course_id = 1),这里内层的WHERE course_id = 1,如果没有course_id上的索引,那就全表扫描。外层取student_id去匹配student主键,因为主键有索引,所以外层大概率不回表太多行。
如果你的核心业务经常按course_id查成绩,那给score表的course_id加一个索引是非常值得的:
sql复制ALTER TABLE score ADD INDEX idx_course_id (course_id);
同样,如果经常按student_id去查选课记录,也应该给score表的student_id加索引。我在实际项目里见过很多次,子查询慢并不是MySQL不行,而是压根没建索引。索引不是越多越好,但针对高频查询条件的单列索引或者联合索引,该建必须建。
4. 高频问题与排坑实录:这些坑我全踩过
这部分算是我个人经验的精华。下面这些问题,要么是在面试题里反复出现,要么是在实际生产中让我吃过亏。
4.1 NOT IN遇上NULL,结果直接空了
这是一个经典的不能再经典的坑。NOT IN后面如果子查询结果里有NULL,那么整个NOT IN会返回空集。为什么?因为x NOT IN (a, b, NULL)的语义等价于x != a AND x != b AND x != NULL,而x != NULL的结果是UNKNOWN,不是TRUE,所以整个条件永远不可能为TRUE。
举个例子,如果成绩表里有一个NULL成绩,你写:
sql复制SELECT name FROM student
WHERE id NOT IN (
SELECT DISTINCT student_id FROM score WHERE score < 90
);
如果子查询结果里有NULL值,这个查询就什么也查不出来。解决方法是:要么在子查询里加WHERE student_id IS NOT NULL,要么改用NOT EXISTS:
sql复制SELECT name FROM student s
WHERE NOT EXISTS (
SELECT 1 FROM score sc
WHERE sc.student_id = s.id AND sc.score < 90
);
我个人的习惯是:遇到NOT IN的业务,直接想NOT EXISTS,少踩一次坑。
4.2 IN和EXISTS到底怎么选
很多人有一个印象:IN适合子查询表小的情况,EXISTS适合子查询表大的情况。这个说法在旧版本的MySQL里有一定道理,但在5.7和8.0里,优化器已经能把IN改写成semi-join,所以这种经验法则越来越不灵了。
我的建议是:优先看语义,而不是性能。如果你判断“外层记录是否存在于某个集合”,用IN;如果你要判断“是否满足一个依赖于外行数据的存在性条件”,用EXISTS。代码写出来让下一个读的人能看懂才是最重要的,性能有问题时用EXPLAIN验证再优化。
4.3 子查询里的ORDER BY,经常被忽略
子查询中写入ORDER BY在很多场景下没有意义,因为外层查询不保证保存内层的排序。比如FROM (SELECT ... ORDER BY score DESC) AS t,你期望t表里是排好序的,但外层如果再加WHERE或JOIN,排序可能就被打乱了。
如果你想实现“按每个学生的最高分排序输出”,正确的做法是在外层ORDER BY,或者用窗口函数(MySQL 8.0支持)。窗口函数ROW_NUMBER()可以取每个分组的某条记录,这是子查询很难优雅实现的场景。
4.4 子查询与UPDATE、DELETE结合时的锁和一致性
热搜词里有“mysql update语法”、“mysql锁表”,很多人没意识到,子查询也可以出现在UPDATE和DELETE的WHERE条件里。但要注意,UPDATE时子查询如果访问了目标表本身,可能会触发限制:不允许在同一个表上一边更新一边查询。比如:
sql复制-- 这种在MySQL里会报错,不能先SELECT同一张表再UPDATE同一张表
UPDATE score SET score = score + 5
WHERE student_id IN (SELECT student_id FROM score WHERE score < 60);
解决办法是用多表UPDATE语法,或者包一层派生表:
sql复制UPDATE score SET score = score + 5
WHERE student_id IN (
SELECT student_id FROM (SELECT student_id FROM score WHERE score < 60) AS t
);
这个包一层派生表的关键在于:它让MySQL认为子查询已经物化成了临时表,绕过了“不能同时查询和更新同一张表”的限制。但也要小心,大量行的UPDATE本身会加行锁和间隙锁,子查询再复杂一点,锁的范围可能会扩大,并发高的时候容易出问题。所以处理批量更新时,建议分批做,别一条SQL想干完所有的事。
4.5 隐式类型转换和字符集:慢和错都从这里来
int和varchar比较时,MySQL会把字符串转成数字,这个热搜词“mysql中int+5”其实讲的就是数值计算,但更隐蔽的是子查询里的隐式转换。比如score表中的student_id是INT,子查询返回的是VARCHAR,看起来也能比较,但会导致索引失效。字符集不一致也一样,JOIN或子查询时两个字段的collation不同,会导致无法使用索引,甚至结果可能有偏差。
排查方法是在EXPLAIN里看key有没有用上,如果明明有索引但没走,大概率就是类型或者字符集的锅。修法也很简单,用CAST显式转换,或者保持关联字段的类型和字符集一致。
4.6 存储过程中使用子查询的一个小坑
存储过程里写子查询没什么特殊限制,但如果你在存储过程中用了动态SQL拼接,子查询里的引号转义很容易出问题。我建议在存储过程里能用CTE或临时表就先算好,不要在一条动态SQL里塞太多层子查询,排查问题会让你怀疑人生。
4.7 一个容易被忽略的前提:子查询在8.0和5.7里的行为差异
虽然5.7已经比较老了,但很多生产环境还在用。8.0在子查询优化上引入了新的执行器,对派生表的合并、延迟物化等都做了改进,导致有些在5.7里慢得要死的SQL在8.0里跑得飞快。所以如果你要优化一条带子查询的SQL,先确认一下版本,别拿5.7的执行经验去套8.0,也别拿8.0的结果去要求5.7。
如果你的线上还是5.7,优先考虑把复杂子查询拆成临时表或视图;如果是8.0,可以放心用CTE和窗口函数,写法能简洁很多。
5. 结合热搜词聊几招实战经验
前面把基础内容和坑讲完了,这里结合一些热点问题再补充几句我个人的实战心得。
5.1 “mysql的or能去重吗”与子查询的连带关系
有人问OR是不是能去重,这说明对去重机制还不够清晰。去重靠的是DISTINCT或者GROUP BY,跟OR没关系。但OR和子查询结合时有一个常见误区:WHERE a = 1 OR a = 2可以用IN (1,2)来表达,而WHERE a IN (SELECT ...)的子查询天然就是集合语义,写起来更清晰。能写IN就别写多个OR,既省心又能让优化器更容易走索引。
5.2 用“学生课程成绩信息实体表设计mysql”这个场景再总结一下
这套学生-课程-成绩的三表结构是关系型数据库最经典的例子,子查询的绝大多数场景都可以在这个模型上演示。如果你是一个学生,建议把这篇里的SQL全部跑一遍,然后用EXPLAIN看执行计划,再尝试用JOIN改写,对比一下在什么情况下两种写法等价、什么时候不等价。这个过程比背一百道面试题都管用。
5.3 子查询与数据库连接池:为什么总是超时
数据库连接池和子查询本身没有直接关系,但一个慢查询会长时间占用连接池里的连接,导致其他请求拿不到连接,最终整体超时。我在实际运维中遇到过不止一次:一条带复杂子查询的统计SQL,跑了几十秒没结束,连接池被占满,整个应用不可用。所以写子查询的时候,一定要有意识控制数据量和执行时间,尤其是报表类的查询,尽量放到从库或者独立的分析实例上去跑。
5.4 安装完MySQL之后的第一件事:用一条子查询练手
热搜词里一堆mysql安装教程、mysql下载相关的,其实安装完之后,很多人不知道下一步该干什么。我的建议是,装好MySQL 8.0之后,把上面这套学生成绩表建一遍,把所有子查询的例子跑一遍,既能验证环境没问题,又能在实践中学会子查询。如果条件允许,用Docker装MySQL也是一个很好的选择,快速起一个测试实例,跑完停掉不污染本机环境,非常轻量。
6. 一条经验:子查询最大的门槛不是语法,是思维
说回子查询本身。写了这么多年SQL,我的体会是,子查询真正的门槛不在于记住那个括号应该放在哪,而在于怎么把一个模糊的业务问题拆成“先查什么、再查什么、最后要什么”的执行链条。这种拆解能力只能靠多练、多调试来培养。
我见过太多人,一碰到嵌套三层以上的查询就开始头疼,跑去问AI或者到处搜,最后抄来一段看不懂的SQL。我的建议是,遇到复杂问题,先别急着写SQL,先在纸上用一句话描述你要的结果,再把“为了得到这个结果,我还需要知道什么中间数据”一步步列出来。这个中间数据,往往就是子查询的天然分界点。
从IN、EXISTS到派生表、CTE,再到窗口函数,工具在不停进化,但你脑子里的分层思维不会过时。把最基础的几种形态吃透了,后面的路自然会越走越顺。
最后再分享一个小技巧:写完一条带子查询的SQL后,习惯性地用EXPLAIN看一眼执行计划,然后顺手思考一个问题——“如果这张表的数据量再涨十倍,这条SQL还能不能撑住?”有这个意识,你写出来的SQL会跟大多数人拉开明显差距。
