1. 先从“复合查询”到底在说什么聊起
标题里的“复合查询”,放到MySQL里,其实并不是一个官方术语,更像是一个宽泛的口语说法。我见过不少刚入门的同学,一听到“复合查询”就以为是什么特别高深的关键字、某个隐藏系统函数,或者是单独的某种查询模式。实际上它背后覆盖的是三类常见能力:多层嵌套的子查询、多个查询结果之间的集合操作(比如UNION)、以及把表和表之间的关联关系用在查询里做组合筛选。
换句话说,“复合”指的是:用多条SELECT、多种查询手段、多层逻辑组合在一起,解决一条简单查询搞不定的需求。比如你手里有学生表、班级表、成绩表,想查“每个班级里分数超过平均分的学生名单”,这种需求已经不适合“单表加个WHERE”来完成了,它天然需要多张表的数据拼在一起,还需要先算出一个中间统计结果,再拿这个结果去过滤原始记录。
这篇文章适合谁看?一类是刚从单表增删改查里走出来、想在真正业务SQL上进阶的同学;另一类是准备面试、需要把子查询、JOIN、EXISTS、UNION之间的关系彻底厘清的开发。我会把每个知识点都拆开,带上能直接跑起来的SQL示例,并把我实际踩过的坑也一并说出来。
我自己带项目的经验是:大部分线上慢查询,根因不是数据库配置不行,而是查询本身把复合逻辑写拧了。有人用了一长串子查询,明明一次JOIN就能解决;有人用EXISTS,却对外层和内层的关联字段理解错了,导致结果集翻倍。所以,掌握底层思路,比背几个关键字重要得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一套可以直接复现的演示数据环境
为了后面的每个例子都能“抄了就能跑”,我们先搭建一套大家都熟悉的学生-班级-课程-成绩模型。这套模型小而完整,覆盖了一对多、多对多、聚合统计、子查询对比等几乎所有复合查询会用到的场景。
2.1 建表语句设计
sql复制CREATE DATABASE IF NOT EXISTS school DEFAULT CHARSET utf8mb4;
USE school;
CREATE TABLE class (
id INT PRIMARY KEY AUTO_INCREMENT,
class_name VARCHAR(30) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE student (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20) NOT NULL,
class_id INT NOT NULL,
score INT DEFAULT NULL,
KEY idx_class (class_id)
) ENGINE=InnoDB;
CREATE TABLE course (
id INT PRIMARY KEY AUTO_INCREMENT,
course_name VARCHAR(30) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE score (
id INT PRIMARY KEY AUTO_INCREMENT,
student_id INT NOT NULL,
course_id INT NOT NULL,
score_value INT NOT NULL,
UNIQUE KEY uk_stu_course (student_id, course_id)
) ENGINE=InnoDB;
这个设计里藏着两个平时容易忽视的点:
第一,学生表里我直接放了一个score字段,又建了一张score分数表,这看起来有点冗余,但其实是有意的。学生表的score字段用来模拟“只有一条总评成绩”的简单场景,适合做单表自身的子查询对比;而score明细表用来模拟“一个学生考了多门课”的多行场景,适合做行列转换、多行聚合、连接查询。
第二,score表里的UNIQUE KEY uk_stu_course (student_id, course_id)相当重要。业务上本来是“一个学生一门课只能有一条成绩”,不建唯一键,脏数据迟早会钻进来,到时候复合查询算出来的平均数、排名全是错的。别省这一步。
2.2 演示数据
sql复制INSERT INTO class (class_name) VALUES
('一班'), ('二班');
INSERT INTO student (name, class_id, score) VALUES
('张伟', 1, 88),
('王芳', 1, 72),
('李娜', 1, 95),
('赵磊', 2, 66),
('陈静', 2, 90),
('刘强', 2, 58);
INSERT INTO course (course_name) VALUES
('语文'), ('数学'), ('英语');
INSERT INTO score (student_id, course_id, score_value) VALUES
(1, 1, 86), (1, 2, 91), (1, 3, 88),
(2, 1, 77), (2, 2, 80), (2, 3, 65),
(3, 1, 98), (3, 2, 93), (3, 3, 95),
(4, 1, 66), (4, 2, 58), (4, 3, 72),
(5, 1, 89), (5, 2, 92), (5, 3, 90),
(6, 1, 55), (6, 2, 60), (6, 3, 52);
数据量不大,但足够说明问题。六名学生分布在两个班级,总评分数有高有低;中间那个学生李娜和我自己当年一样,属于典型的偏科型选手,既有满分似的语文成绩,也有拖后腿的数学成绩。这种数据天然适合演示聚合和对比。
我建议你实际练习时不要只看结果对不对,而要在每跑一条SQL之前,先动手算一遍预期返回几行、每行大概什么值。有了“先算后跑”的习惯,你对SQL语义的理解会比只复制粘贴快得多。
3. 子查询是复合查询的基本骨架
复合查询最核心的组成部分是子查询。所谓子查询,就是嵌套在SELECT、FROM、WHERE、HAVING或SELECT列表里的另一条完整SELECT。它先被MySQL执行,产生一个中间结果,再把这个结果交给外层查询继续使用。
3.1 WHERE子句里的标量子查询:完全匹配某个值
最常见的形态是“外层每一行都在和一个子查询的结果做比较”。举例:查“总评分数高于全校平均分的学生”。
sql复制SELECT name, score
FROM student
WHERE score > (SELECT AVG(score) FROM student);
这里内层SELECT AVG(score) FROM student返回的是一个单行单列的值,我们管它叫标量子查询。MySQL会先算出全校平均分,再用它去过滤外层的学生记录。
注意:标量子查询必须保证只返回一行一列。如果内层返回多行,MySQL会在运行时报错“Subquery returns more than 1 row”。这是新手常犯的问题。
这类子查询逻辑简单,但存在一个细节:如果外层表的数据量很大,而内层子查询每次都要重新执行,性能会受影响。不过现代MySQL优化器对于不相关子查询通常会做缓存,同一SQL里多次执行只会真正计算一次。因此,只要你写得清晰,这种程度的嵌套在常规业务里完全没有问题。
实际开发里,这种写法经常出现在报表统计中,比如“查达到平均线的订单”“查超过部门人均产出的员工”。我给你的建议是:先把内层子查询单独跑一遍,确认它是一个值,再做整体查询,能省掉不少排查时间。
3.2 FROM子句里的派生表:把一个查询结果当成临时表
有时候,你需要的不是某一个单独的值,而是一张“中间结果表”。比如:查“每个班级里分数高于本班平均分的学生,并需要显示该生所属班级名称”。
正确的思路是先把“班级平均分”这个中间结果用子查询算出来,再和学生表关联。
sql复制SELECT c.class_name, s.name, s.score, tmp.avg_score
FROM student s
JOIN class c ON s.class_id = c.id
JOIN (
SELECT class_id, AVG(score) AS avg_score
FROM student
GROUP BY class_id
) tmp ON s.class_id = tmp.class_id
WHERE s.score > tmp.avg_score
ORDER BY c.class_name, s.score DESC;
在这个SQL里,FROM后括号内的子查询结果会被当作一张物理上不存在的临时表,官方叫法叫“派生表”。MySQL会把这段子查询先执行完,再跟其他表做JOIN。这样写的好处是思路直观——先算班级平均水平,再逐行比对;坏处是如果子查询本身特别大,比如筛出几十万行,临时表的成本会让人头疼。
我在做这类查询时喜欢养成的习惯是:无论子查询还是主查询,先单独执行一遍看速度和结果,再用EXPLAIN看执行计划。一旦发现派生表被反复扫描,就考虑改成临时表或者改成JOIN聚合的方式。
3.3 列子查询与IN/NOT IN的取舍
除了返回单个值,子查询还可以返回一列数据。比如:查“参加过数学考试的学生名单”。
sql复制SELECT name
FROM student
WHERE id IN (
SELECT student_id
FROM score
WHERE course_id = (
SELECT id FROM course WHERE course_name = '数学'
)
);
这里内层先根据course_name查出“数学”对应的course_id,再在score表里查出所有考过数学的student_id,最终外层用IN去匹配这一列。
用IN写起来很直观,但我得给你提个醒:当子查询返回的列里包含NULL时,NOT IN的结果会出现不符合直觉的情况。假设子查询返回(1, 2, NULL),外层有个学生的id是3,你用NOT IN判断,结果不会包含这行,因为3 <> NULL的结果是UNKNOWN,WHERE只保留TRUE。这是个极其隐蔽的坑。
遇到可能包含NULL的数据,我一般优先用NOT EXISTS代替NOT IN,或者先在内层查询里显式过滤掉NULL。这个差异,面试里也经常被拿来当考点,后面EXISTS部分我还会展开。
3.4 行子查询:把整行当作比较单位
MySQL还支持一次返回一行多列的子查询。比如查“学生表中存在一行,其姓名和李娜相同,且总评分数也和李娜相同”。
sql复制SELECT name, score
FROM student
WHERE (name, score) = (
SELECT name, score
FROM student
WHERE name = '李娜'
LIMIT 1
);
这种写法叫行子查询,它把(name, score)当成一个整体和子查询返回的整行比较。业务中用到的不多,但适合做“除主键外多个字段完全一致”的重复数据检测。比如排查“订单表里是否存在相同用户、相同商品、相同价格的重复订单”,就可以写:
sql复制SELECT *
FROM order_detail
WHERE (user_id, product_id, amount) IN (
SELECT user_id, product_id, amount
FROM order_detail
GROUP BY user_id, product_id, amount
HAVING COUNT(*) > 1
);
需要注意,行子查询对字段顺序极其敏感。括号里谁在前谁在后,必须和子查询字段顺序逐一对应,否则比较逻辑就变了。我在实际中把这类查询多数用于数据质检脚本里,一般不会放在高频业务接口上。
4. JOIN与子查询的组合使用
复合查询的另一个关键词是“各种查询手段混着用”。子查询擅长算中间结果,JOIN擅长把多张表横向连接,两者搭配是日常业务里最顺手的一套组合。
4.1 用JOIN替代部分子查询,逻辑更顺
很多场景下,同一个需求既能用子查询写,也能用JOIN写。比如前面那个“查每个班里高于班级平均分的学生”,我用了JOIN加派生表。如果把它改成纯子查询,写法就是相关子查询,一层层套进来:
sql复制SELECT s.name, s.score
FROM student s
WHERE s.score > (
SELECT AVG(score)
FROM student
WHERE class_id = s.class_id
);
这种写法的特点是:内层子查询引用外层表的class_id,每处理外层一行,内层都要执行一次。它叫相关子查询,理解起来直观,但在数据量大的时候,可能会造成大量重复计算。相比之下,先GROUP BY算出班级平均分生成一张派生表,再做JOIN,往往执行起来更可控。
我自己写业务SQL时有个原则:如果一个查询用JOIN加GROUP BY能写,就不要硬套相关子查询;如果相关子查询让人一眼看清业务含义,而数据量又不大,那也别为了追求某种写法强行改造成JOIN。SQL重要的是可读性和可控性,不是炫技。
4.2 三表连接时的连接顺序与结果基数控制
学生表、课程表、分数表连在一起查“每个学生的每门课成绩”时,新手最容易翻车的地方是忘记加连接条件,形成笛卡尔积。比如:
sql复制SELECT s.name, c.course_name, sc.score_value
FROM student s
JOIN score sc ON s.id = sc.student_id
JOIN course c ON sc.course_id = c.id
ORDER BY s.name, c.course_name;
这里JOIN顺序是先连接student和score,再连接course。第二条JOIN如果不写ON sc.course_id = c.id,结果会直接膨胀成18行乘3门课,也就是54行。很多线上事故就是少个连接条件,却没人发现。
判断结果行数有没有问题有个土办法:先分别看student和score连接后应该有几行,再确认course里没有重复的课程名,最后验证最终结果行数是否等于score表的业务行数(在这个案例里是18行)。如果不等于,大概率连错了。
4.3 内连接与外连接在复合查询里的方向感
业务里常遇到“要找出那些一门课都没选的学生”。这个需求如果拿内连接来写,根本查不出来,必须用外连接。
sql复制SELECT s.name
FROM student s
LEFT JOIN score sc ON s.id = sc.student_id
WHERE sc.id IS NULL;
核心思路是:先保留左边所有学生,右表没有匹配上时右表字段会是NULL,再通过WHERE sc.id IS NULL把“没匹上”的学生筛出来。
我见过不少人用WHERE sc.score_value IS NULL去判断,如果分数本身可能是NULL,就会误伤。更好的做法是选择右表主键字段来判断,比如sc.id,因为只要没匹配上,主键一定是NULL,不会受业务字段内容干扰。
这个“找无匹配项”的方向感特别重要,因为后续我们讲EXISTS时,也会遇到同类需求,不同的写法会在性能上拉开差距。
5. EXISTS与相关子查询的进阶细节
EXISTS是复合查询里极容易让人困惑,但也极能解决实际问题的关键字。它不关心子查询返回什么内容,只关心子查询是否有结果。有结果就是TRUE,没结果就是FALSE。
5.1 EXISTS的核心判断逻辑
查“参加过语文考试的学生”。用EXISTS可以写成:
sql复制SELECT s.name
FROM student s
WHERE EXISTS (
SELECT 1
FROM score sc
JOIN course c ON sc.course_id = c.id
WHERE sc.student_id = s.id
AND c.course_name = '语文'
);
外层每拿到一个学生,就去子查询里查“有没有这个学生考语文的记录”。有则保留外层这一行;没有则丢弃。
为什么子查询里写SELECT 1而不是SELECT *?因为在EXISTS语义下,MySQL只关心行是否存在,不关心列值。SELECT 1写起来更轻,语义也更明确。这是一种习惯,并不是说写*就一定错。
5.2 EXISTS与IN在业务场景里的替换方案
之前提过,NOT IN存在NULL陷阱,而NOT EXISTS没有这个问题。看一道经典面试题:查“没有参加过任何课程考试的学生”。
- 推荐写法:
sql复制SELECT name
FROM student s
WHERE NOT EXISTS (
SELECT 1
FROM score sc
WHERE sc.student_id = s.id
);
- 容易翻车的写法:
sql复制SELECT name
FROM student s
WHERE s.id NOT IN (
SELECT student_id FROM score
);
当score.student_id列里没有NULL时,上面两种写法结果一致。一旦score表出现一条student_id为NULL的异常数据,第二种写法的结果就会缺行。
所以我在实际项目里定过一个团队约定:只要子查询结果列来自可空字段,禁止用NOT IN,一律用NOT EXISTS。这个约定帮我们挡掉过不止一次数据问题引发的接口返回缺失。
5.3 用EXISTS模拟“分组TOP N”的取数场景
EXISTS还有一种巧妙的用法:不查“有没有”,而是结合比较条件,找出“比自己更符合条件的记录不多于N条”的行。比如查“每门课分数最高的前两名学生”。经典的思路是找同一门课里,分数不低于当前行且学生id不重复的行数小于2的记录。
sql复制SELECT sc.course_id, sc.student_id, sc.score_value
FROM score sc
WHERE (
SELECT COUNT(*)
FROM score sc2
WHERE sc2.course_id = sc.course_id
AND (
sc2.score_value > sc.score_value
OR (sc2.score_value = sc.score_value AND sc2.student_id < sc.student_id)
)
) < 2
ORDER BY sc.course_id, sc.score_value DESC;
这个查询的逻辑有点像“同分数更靠前的行数”。student_id < sc.student_id这个条件是为了处理并列分数时还能稳定排序,避免同名次下COUNT重复统计。写法虽然绕,但它是理解“窗口函数普及之前,开发如何手动算排名”的绝佳教材。
如果你公司用的MySQL 8.0以上版本,我会劝你直接改用ROW_NUMBER()窗口函数,别再用这种EXISTS变体自虐。但在5.7及更老的版本上,这段查询依然是有效方案。
提醒:窗口函数的执行效率和可读性全面优于这种手写排名写法。若生产环境支持MySQL 8.0,优先考虑
ROW_NUMBER()。
6. UNION与UNION ALL:把多个结果纵向拼起来
复合查询不仅是嵌套,还包含集合层面的纵向合并。两个SELECT查询结果上下拼接,这就用到UNION或UNION ALL。
6.1 UNION与UNION ALL的核心区别
UNION会对合并后的结果做去重,而UNION ALL原样保留所有行。举例:查“一班的总评高分学生”和“二班的总评高分学生”,但凡是过90分的人都要出现在最终列表里,班级信息用文字标注一下。
sql复制SELECT name, score, '一班' AS tag
FROM student
WHERE class_id = 1 AND score > 90
UNION ALL
SELECT name, score, '二班' AS tag
FROM student
WHERE class_id = 2 AND score > 90;
在这个业务里,一名学生不可能既在一班又在二班,所以不会重复,用UNION ALL更高效,因为它不做去重排序。如果去掉tag改查“所有分数大于90的学生”,两个班的分段查询合并后,人本身不会重复,UNION和UNION ALL结果一致,但UNION会额外多一层去重比较,代价白付。
6.2 使用UNION必须遵守的字段规则
MySQL要求每个SELECT的列数量一致,且对应位置的数据类型要能兼容。比如第一个SELECT查name, score,第二个SELECT就不能查成name, score, class_id,否则报错。
列名是以第一个SELECT的列名为准的。如果想给结果列起别名,写在第一个SELECT里即可。ORDER BY、LIMIT如果用在整个UNION结果集上,要放在最后一个SELECT的末尾,且ORDER BY引用的是第一个SELECT的列名。比如:
sql复制SELECT name, score
FROM student
WHERE class_id = 1
UNION ALL
SELECT name, score
FROM student
WHERE class_id = 2
ORDER BY score DESC
LIMIT 3;
这条SQL返回的是两班合并后的前三名。
6.3 用UNION实现“行转列”的另类思路
热搜词里有“mysql行转列”,大家最熟的是用CASE WHEN配合GROUP BY。但还有一种思路是:如果行数固定,而且数据来源是不同查询,UNION也可以把多行结果拼成多列展示。
比如想呈现一张宽表,按每名学生的“语文成绩、数学成绩、英语成绩”三列展示,用CASE WHEN是最直接的:
sql复制SELECT
s.name,
MAX(CASE WHEN c.course_name = '语文' THEN sc.score_value END) AS chinese_score,
MAX(CASE WHEN c.course_name = '数学' THEN sc.score_value END) AS math_score,
MAX(CASE WHEN c.course_name = '英语' THEN sc.score_value END) AS english_score
FROM student s
JOIN score sc ON s.id = sc.student_id
JOIN course c ON sc.course_id = c.id
GROUP BY s.name
ORDER BY s.name;
这里配合GROUP BY,因为一名学生对一门课只会有一条成绩,所以MAX只是把固定值取出。如果不加MAX,单独依赖CASE WHEN配合GROUP BY时,MySQL会按非聚合列报错或产生随机值。
“行转列”的本质就是“把行里的某个分类值转换成列名”,核心组件是CASE WHEN,语法本身不复杂,难点在于确定转成几列、哪些分类是固定的。如果分类是动态增加的,比如课程表可以随意添加新课,这种SQL就必须改成动态拼接,MyBatis或者应用层先查课程列表,再动态生成SELECT片段。
6.4 什么时候用JOIN,什么时候用UNION
这是很多初学者容易搞混的地方。记住一句话:JOIN是横向扩展列,UNION是纵向扩展行。
同样是查两个班学生,JOIN的语义是“一张表左拼接另一张表的额外字段”,UNION的语义是“两条独立的查询结果放入同一个结果集”。在报表场景里,比如按月统计订单,每个月是一条SQL,要汇总成全年报表时必然用到UNION ALL;而订单表和用户表关联查询,几乎永远用JOIN。
7. EXISTS、IN、JOIN的性能差异笔记
复合查询写多了,一定会被问到这几个写法的性能差异,面试题里也常出现“为什么这里用EXISTS比IN快”这类问题。
7.1 小表驱动大表还是大表驱动小表
早年MySQL优化器能力有限时,EXISTS和IN的性能差异非常明显。对相关子查询而言,外层每处理一行,就要执行一次子查询,所以传统经验是“小表驱动大表”:用行数少的表做外层循环,行数多的表放子查询里走索引匹配。
现在的MySQL 5.7以上版本,优化器会自动做半连接优化,很多场景下EXISTS和IN的执行计划最终是等价的。我的建议是别盲目相信网上的经验,直接在目标数据规模上跑EXPLAIN看结果,才是正确姿势。
7.2 用EXPLAIN判断复合查询是否走了索引
花30秒养成看执行计划的好习惯。例如:
sql复制EXPLAIN SELECT s.name
FROM student s
WHERE EXISTS (
SELECT 1
FROM score sc
WHERE sc.student_id = s.id
);
如果执行计划里,score表的key列出现uk_stu_course或相关索引,说明子查询能通过索引快速定位;如果出现Using where且type是ALL,说明它可能在全表扫。
在我的经验里,90%的复合查询性能问题都能通过补索引解决。比如基于score(student_id)这个场景,我们设计表时已经建了联合唯一索引,EXISTS查询就能直接用到。反过来,如果WHERE条件里的字段没有索引,内层和外层表一大,查询耗时立刻会暴露。
8. 容易踩的坑与排查经验
最后这部分,是我自己在实际项目和带新人过程中经常碰到的高频问题。整理成一个小手册,比背概念管用得多。
8.1 子查询里误用LIMIT导致语义出错
子查询里写LIMIT 1往往是为了防止“返回多行”报错,但有时候这会掩盖业务逻辑问题。比如你想查“每个班最高分学生”,写:
sql复制SELECT name, score
FROM student
WHERE score IN (
SELECT MAX(score) FROM student GROUP BY class_id
);
整体没问题。但如果把内层改成SELECT score FROM student GROUP BY class_id LIMIT 1,就只取了任意一个班的最高分,结果完全变味。写的时候一定想清楚:LIMIT到底是为了应对“业务上保证只有一行”,还是在掩盖一条本身有问题的查询。
8.2 比较运算与NULL的无声陷阱
在SQL里,NULL和任何值做比较,结果都不是TRUE,而是UNKNOWN。WHERE score = NULL永远筛选不到数据,必须用IS NULL。这在复合查询里尤其明显:外层条件表达式的某一部分算出NULL时,整个条件可能不成立,行就被悄悄过滤掉了。
比如想查“总评不是90分以上的学生”,写了WHERE NOT (score > 90),遇到score为NULL的行,NOT (NULL)还是NULL,这行不会出现在结果里。处理这类数据时,先确认列是否可空,再决定要不要加OR score IS NULL。
8.3 GROUP BY和HAVING的位置不能记反
筛选分组前的记录,用WHERE;筛选分组后的统计结果,用HAVING。比如查“平均分超过80的班级”,必须在GROUP BY之后用HAVING:
sql复制SELECT class_id, AVG(score) AS avg_score
FROM student
GROUP BY class_id
HAVING AVG(score) > 80;
如果把HAVING错写成WHERE AVG(score) > 80,MySQL会直接报错,因为WHERE执行时聚合函数还没算出来。这个报错信息其实很友好,能帮你快速定位问题。
8.4 复合查询导致临时表的资源消耗
有些复合查询会隐式创建临时表,比如UNION去重、ORDER BY和GROUP BY字段不一致、含有TEXT字段的排序等。当结果集特别大时,临时表会占内存,内存不够就转成磁盘临时表,于是SQL突然变慢。
排查办法还是EXPLAIN,关注Extra列。如果出现Using temporary,就要想想能不能优化语句结构,比如把UNION改成UNION ALL,或者把排序字段和分组字段调整一致。我见过一个报表SQL,仅仅因为多了一个不必要的DISTINCT,临时表从内存转磁盘,查询时间从200毫秒涨到8秒。
9. 几个高频面试场景的速答思路
复合查询相关的面试题,问法翻来覆去就那么几种,把下面的思路理清,基本能应对大多数技术面。
第一,IN和EXISTS怎么选。先说结论:在实际版本里两者会互相优化,不一定有绝对优劣。但你可以回答老版本MySQL背景下,如果外表小,EXISTS利用索引更高效;如果内表小,IN可能更合适。重点不是背结论,而是说要通过EXPLAIN确认。
第二,UNION和UNION ALL区别。UNION会对结果去重,可能额外消耗排序或临时表资源;UNION ALL直接合并,速度快。能用UNION ALL就不用UNION,是性能实践里的默认选择。
第三,一条SQL里既有WHERE又有HAVING,执行顺序是什么。先FROM和JOIN确定数据范围,再WHERE过滤原始行,然后GROUP BY分组,接着聚合函数计算,再HAVING过滤分组,最后SELECT、ORDER BY、LIMIT登场。懂这条顺序,90%的SQL逻辑错误都能自己排查出来。
第四,为什么CAST函数在子查询里会造成隐式转换开销。当字段是字符串类型,却和整型做比较,MySQL可能放弃索引。所以相关子查询和JOIN的关联字段,务必保证类型一致。曾有人用VARCHAR存学号,再用INT去关联,导致整个关联查询全表扫,加了CAST以后看起来修好了,本质上只是硬生生免掉了隐式转换,但已经在数据层面埋了雷,更好的做法是改表结构字段类型。
10. 实际项目中我沉淀下来的几条复合查询规范
代码跑得通和跑得好是两回事。给团队定一些查询规范,比每天帮人救火强得多。
第一,子查询能放在FROM里用派生表解决的,尽量不写相关子查询;相关子查询能改写为JOIN的,优先JOIN。虽然优化器越来越聪明,但清晰的结构对后续维护者更友好。
第二,判断“是否存在”用EXISTS而不是IN,尤其子查询结果可能含NULL时,这条严格执行。判断“不存在”同理,用NOT EXISTS。
第三,业务字段中只要出现“姓名”“课程名”这类本来有唯一含义的列,要在表结构上加上唯一索引或普通索引。很多复合查询慢,不是查法问题,是压根没索引可用。
第四,任何包含子查询、JOIN、GROUP BY、UNION的SQL,上线前至少跑一次EXPLAIN,并人工估算结果行数。这一步只要30秒,却能避免无数个线上事故。
第五,复合查询读起来超过15行时,停下来想想是不是拆成两条SQL更合理。在应用层先查出中间结果,再传给第二条SQL,有时候性能更好,代码也更易维护。SQL不是越短越好,也不是越“一条式”越高明,业务清晰才是第一目标。
我自己在实际项目里的体会是:大多数人遇到的复合查询问题,最后查出来都不是某个关键字理解错了,而是对“这个查询期望返回的是什么样的表结构”想得不清楚。先弄清手上有哪些表、每张表主键是什么、需要哪些过滤条件、结果集行数和列数长什么样,再动手写SQL,你会发现所谓复合查询其实只是把这些基础动作按顺序组合起来罢了。
