最近帮人审一批数据库课程的练习时,碰到一个特别经典的题目变化:原题是“查询同时选修了数学和英语课程的学生姓名”,后来题目改成了“检索选修了‘数学’或‘英语’课程的学生姓名”。别看只是把“且(AND)”换成了“或(OR)”,相当一部分人在改代码时直接就把AND换成了OR,交上来的结果要么出现大量重复姓名,要么干脆是空集。问题就出在一个非常容易被忽略的底层差异上:这两个条件真正作用的范围,一个属于“行内判断”,另一个属于“集合判断”。
如果你也在学多表查询,或者日常写SQL时经常被这种一改就出错的问题搞得烦躁,这篇文章值得完整看完。我会用最常用的学生选课模型,把表结构、测试数据、错误写法、正确写法、运行结果、排查思路全部铺开,你完全可以照着建表跑一遍,亲手体会两种查询背后的差异。
1. 题目拆解:一个连接词的改动为什么难倒一片
1.1 三张表的标准模型
先看最典型的学生选课场景。学生、课程、选课关系是三张表:students存学生基本信息,courses存课程信息,student_courses存“哪个学生选了哪门课”的关联记录。
CREATE TABLE students ( student_id INT PRIMARY KEY, student_name VARCHAR(50) NOT NULL ); CREATE TABLE courses ( course_id INT PRIMARY KEY, course_name VARCHAR(50) NOT NULL UNIQUE ); CREATE TABLE student_courses ( student_id INT NOT NULL, course_id INT NOT NULL, PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES students(student_id), FOREIGN KEY (course_id) REFERENCES courses(course_id) );为什么要拆成三张表,而不是把课程名直接填在学生表里?因为一个学生能选多门课,一门课也能被多个学生选,这是典型的多对多关系。如果按“一个学生一行”来设计,加一门课就要加一个字段,模型会非常僵硬。现在这种设计里,student_courses的每一行只代表一个事实:某个学生选了一门课。
我准备了一组测试数据,方便后面所有SQL直接运行验证:
INSERT INTO courses (course_id, course_name) VALUES (1, '数学'), (2, '英语'), (3, '物理'); INSERT INTO students (student_id, student_name) VALUES (1, '学生A'), (2, '学生B'), (3, '学生C'), (4, '学生D'); INSERT INTO student_courses (student_id, course_id) VALUES (1, 1), (1, 2), (2, 1), (3, 2), (4, 3);数据含义很清楚:学生A同时选修了数学和英语;学生B只选了数学;学生C只选了英语;学生D只选了物理。所以两个查询的期望结果分别是:
- 检索选修了“数学”或“英语”课程的学生姓名:学生A、学生B、学生C。
- 检索同时选修了“数学”和“英语”课程的学生姓名:只有学生A。
1.2 集合视角下的“且”与“或”
把选数学的学生看成一个集合M,选英语的学生看成一个集合E,问题就变成了非常朴素的集合关系:
- 数学或英语:M ∪ E,取并集。
- 数学且英语:M ∩ E,取交集。
这个角度看,真的不难。但难就难在SQL的执行方式并不是先算出两个集合再做交并运算,而是逐行扫描、逐行连接、逐行过滤。大多数人在WHERE或ON里写条件时,脑子里的模型是“每一行数据同时满足几个条件”,于是把集合层面的“且”和“或”误当成行层面的逻辑运算。
可以这么理解:一条SQL里的普通WHERE条件,作用范围是“当前这一行”。但“一个学生同时选了数学和英语”这个判断,作用范围是“这个学生所有选课记录构成的行集合”。课程名并不存在于某个学生的单一行里,而是分散在多行记录里。要把跨行的判断做出来,必须借助聚合、子查询、自连接或集合运算,不能指望在单行条件下把两个课程名凑齐。
1.3 行内作用域和集合作用域的差别
我想用一个对比把这个差别讲透。
假设有一张人物表,字段包括年龄和性别,那么:
WHERE age > 18 AND gender = '女'这是标准的行内逻辑,因为判断需要的两个值都在同一行里,直接读出来做布尔运算即可。
但是“学生同时选了数学和英语”完全不是这个模型。学生A的选课记录有两行,一行是“数学”,一行是“英语”,没有哪一行同时包含这两个课程名。你如果用course_name = '数学' AND course_name = '英语'去过滤,每一行都必然失败,因为当前行的course_name不可能同时等于两个值。
这就是行内作用域和集合作用域的区别。普通的WHERE、JOIN ON条件天然工作在单行世界;而选课这类明细表场景,一个主体对应多行记录,很多查询要求你跳到集合世界去思考:先把这个主体关联的所有行聚合起来,再看聚合结果是否满足条件。这个认知一旦建立,这道题就解决了一半。
2. 三种常见错误写法,照着改就踩坑
2.1 同一字段不可能同时等于两个值
最典型的错误写法是下面这样,看起来逻辑无懈可击,实际上必返空集:
SELECT DISTINCT s.student_name FROM students s JOIN student_courses sc ON s.student_id = sc.student_id JOIN courses c ON sc.course_id = c.course_id WHERE c.course_name = '数学' AND c.course_name = '英语';问题就出在WHERE条件上。连接完成之后,每一行数据代表“某个学生选了一门具体课程”,course_name在这一行里只有一个确定值,比如学生A的第一条选课记录是“数学”,第二条是“英语”。WHERE会对每一行独立判断:course_name等于‘数学’且course_name等于‘英语’。没有任何一行的course_name能同时满足这两个条件,所以结果永远为空。
这种错误最隐蔽的地方在于:很多人觉得“我已经JOIN了课程表,数学记录和英语记录应该都在表里,为什么查不到”。实际上它们确实都在结果集里,但分处不同的行,而普通WHERE无法跨行判断。要跨行比较,必须换用GROUP BY、EXISTS、自连接或INTERSECT这类能突破单行边界的写法。
2.2 把OR写进JOIN的ON条件
题目改成“或”之后,很多人会把原来的AND直接搬到ON条件里变成OR,写成这样:
SELECT s.student_name FROM students s JOIN student_courses sc ON s.student_id = sc.student_id JOIN courses c ON sc.course_id = c.course_id AND (c.course_name = '数学' OR c.course_name = '英语');这条SQL能返回学生A、学生B、学生C,但注意返回值里,学生A出现了两次。因为学生A选了数学和英语,JOIN之后产生了两行记录,每一行都满足“课程名是数学或英语”,于是同一姓名被带出两次。如果不加任何去重手段,结果就是 A、A、B、C。
你可能会说“这好办,外面加个DISTINCT就行了”。确实,从结果上看加DISTINCT能救回来,但我要提醒一句:这次能救,是因为“或”本身就是单行就能满足的条件,一行课程是数学,或一行课程是英语,逐行判断天然成立。可如果你把同样的思路用到“且”上,把AND写进ON条件,结果就是空集。也就是说,这种写法并没有真正理解集合语义,只是碰巧在“或”的场景下歪打正着。
2.3 WHERE 里 AND 与 OR 混写引发的优先级问题
还有一种错误更隐蔽:当题目里除了课程条件还有其他条件时,AND和OR混写在同一个WHERE里,很容易被运算符优先级坑到。
SELECT DISTINCT s.student_name FROM students s JOIN student_courses sc ON s.student_id = sc.student_id JOIN courses c ON sc.course_id = c.course_id WHERE s.gender = '女' AND c.course_name = '数学' OR c.course_name = '英语';SQL里AND的优先级高于OR,所以这个WHERE实际被解析成:
WHERE (s.gender = '女' AND c.course_name = '数学') OR c.course_name = '英语'结果就是:无论是男是女,只要选了英语就会被带出来。如果你想表达“女生中选了数学或英语的”,必须明确加括号:
WHERE s.gender = '女' AND (c.course_name = '数学' OR c.course_name = '英语');这道题本身没有性别字段,但实际业务里筛选条件一多,这个优先级问题几乎必然出现。我的建议是:只要WHERE里同时出现AND和OR,一律给OR条件套一对括号,不要考验自己和同事的阅读记忆。
3. 正确实现:两条查询的完整落地
3.1 检索“数学或英语”的三种写法
“或”查询其实有很多实现路径,我按易理解程度从高到低依次推荐。
第一种:JOIN + IN + DISTINCT,最直白,适合绝大多数场景。
SELECT DISTINCT s.student_name FROM students s JOIN student_courses sc ON s.student_id = sc.student_id JOIN courses c ON sc.course_id = c.course_id WHERE c.course_name IN ('数学', '英语');执行逻辑很好懂:先把学生表、选课表、课程表连接起来,得到“每个学生选的每门课”的明细;然后用IN把课程名限定到数学或英语;最后DISTINCT去掉重复姓名。因为一个学生可能同时选了数学和英语,连接后会产生两行,所以这里的DISTINCT是必须的。
第二种:EXISTS,思路更接近集合判断,也天然不会产生重复。
SELECT s.student_name FROM students s WHERE EXISTS ( SELECT 1 FROM student_courses sc JOIN courses c ON sc.course_id = c.course_id WHERE sc.student_id = s.student_id AND c.course_name IN ('数学', '英语') );EXISTS是典型的半连接:它只关心子查询“有没有返回结果”,不关心返回几行。只要某个学生的选课记录中存在数学或英语的任何一门,外层就把这个学生选出来。因为外层查询每行只判断一次,学生不会因为选了两门课而出现两遍,所以不需要DISTINCT。
第三种:UNION,用两个查询做并集,语义最接近集合论。
SELECT s.student_name FROM students s JOIN student_courses sc ON s.student_id = sc.student_id JOIN courses c ON sc.course_id = c.course_id WHERE c.course_name = '数学' UNION SELECT s.student_name FROM students s JOIN student_courses sc ON s.student_id = sc.student_id JOIN courses c ON sc.course_id = c.course_id WHERE c.course_name = '英语';UNION本身会去重,所以即使学生A同时出现在前半段和后半段结果里,最终也只会输出一次。这个写法最贴近“选数学的人加上选英语的人”的人话表达,缺点是SQL重复代码多,可读性一般。
3.2 检索“数学且英语”的三种写法
“且”的查询比“或”复杂,因为它天然跨行。第一种也是最推荐的一种:GROUP BY + HAVING 聚合判断。
SELECT s.student_name FROM students s JOIN student_courses sc ON s.student_id = sc.student_id JOIN courses c ON sc.course_id = c.course_id WHERE c.course_name IN ('数学', '英语') GROUP BY s.student_id, s.student_name HAVING COUNT(DISTINCT c.course_id) = 2;思路分三步。先用IN ('数学', '英语')把相关选课记录全部筛出来;再按学生分组,把同一个学生选的这两门课记录聚到一个组里;最后用HAVING COUNT(DISTINCT c.course_id) = 2判断组内是否真的包含两门不同课程。DISTINCT是保险写法,因为虽然student_courses主键保证了同一学生同一课程不会重复,但一旦表里出现冗余数据,不加DISTINCT就可能出现“两门成绩记录但同属一门课”的误判。
第二种:两次JOIN,把一个“同时选两门课”拆成两条选课路径的交汇。
SELECT DISTINCT s.student_name FROM students s JOIN student_courses sc1 ON s.student_id = sc1.student_id JOIN courses c1 ON sc1.course_id = c1.course_id AND c1.course_name = '数学' JOIN student_courses sc2 ON s.student_id = sc2.student_id JOIN courses c2 ON sc2.course_id = c2.course_id AND c2.course_name = '英语';意思是:要找到这样的学生,他既通过第一条连接路径匹配到数学课,又通过第二条连接路径匹配到英语课。JOIN会把这学生在数学课上的记录和英语课上的记录组合在一起,形成交叉乘积,所以可能出现多行,最后同样需要DISTINCT。这种写法在数据量不大时没问题,但连接次数多,执行计划更复杂,不如聚合写法清晰。
第三种:INTERSECT,直接使用集合交集,一眼就能看出意图。
SELECT student_name FROM students s WHERE s.student_id IN ( SELECT sc.student_id FROM student_courses sc JOIN courses c ON sc.course_id = c.course_id WHERE c.course_name = '数学' ) INTERSECT SELECT student_name FROM students s WHERE s.student_id IN ( SELECT sc.student_id FROM student_courses sc JOIN courses c ON sc.course_id = c.course_id WHERE c.course_name = '英语' );如果你用的数据库支持INTERSECT,这几乎就是“且”的数学本义:选数学的人集合和选英语的人集合,取交集。缺点是不同的数据库方言支持程度不同,不象IN和EXISTS那样人人都能用。
3.3 两种查询的对照表
| 对比维度 | 检索“数学或英语” | 检索“数学且英语” |
|---|---|---|
| 集合含义 | 并集 M∪E | 交集 M∩E |
| 判断层级 | 单行判断即可 | 必须跨行判断 |
| 推荐主写法 | JOIN + IN + DISTINCT | GROUP BY + HAVING |
| 是否天然去重 | EXISTS、UNION可以 | GROUP BY天然去重 |
| 最容易踩的坑 | 忘记DISTINCT导致重复 | WHERE里AND同一字段导致空集 |
| 适用的思维习惯 | 找“任意一条记录满足” | 找“多条记录共同满足” |
这张表可以当备忘录用。以后遇到类似“用户名下的多个标签”“订单里的多个商品”“文章的多个分类”,先判断是并集还是交集,再决定主写法,思路会清晰很多。
4. 实际运行中的问题与排查技巧
4.1 为什么查询结果出现重复姓名
重复姓名的来源通常是JOIN导致的“一对多”放大了记录数。一个学生选了两门符合条件的课,连接后就会产生两行,如果SELECT直接取姓名而不去重,同名记录就会重复输出。
处理重复有几种方式:
SELECT DISTINCT:适用于“只要名单、不要明细”的查询。GROUP BY:分组后选择姓名,同样可以去重,而且后面还能接聚合条件。EXISTS:半连接天然不重复,是避免重复的更优解。UNION而不是UNION ALL:UNION自带去重,UNION ALL会保留所有重复行。
实际工作中如果你既要学生姓名,又要看他具体选了哪几门课,那就不该去重,重复是明细查询的正常表现。先想清楚“我要的是主体名单还是明细流水”,再决定处理方式。
4.2 为什么查询结果为空
空结果是最让人头疼的,我建议按下面顺序排查。
- 确认数据真的存在。先用最简单的查询确认是否有学生选修了相关课程。
- 检查课程名是否完全匹配。很多坑都来自肉眼看不见的空格、全角半角差异,比如
'英语 '和'英语'不是同一个值。 - 检查大小写与排序规则。某些数据库的默认排序规则是大小写敏感,
'Math'和'math'会匹配不上。如果生产环境已经出现这类问题,可以显式指定排序规则或统一数据写入口径。 - 检查连接字段是否选错。比如把
student_courses.student_id错误地连到了courses.course_id,结果必然异常。 - 检查WHERE条件是不是作用在了错误的层级。像前文那个
course_name = '数学' AND course_name = '英语',条件本身就不可能为真,SQL写得再漂亮也没用。
排查空结果时,我习惯把SQL一层层拆开:先单表查学生,再单表查课程,再单独把JOIN结果跑出来看,一步步缩小问题范围。直接看一行复杂SQL的结果,很难定位问题。
4.3 运算符优先级排查技巧
前面提到过A AND B OR C会被解析成(A AND B) OR C。这条规则在复杂查询里影响非常大,尤其是条件混合多个状态值的时候。
举例,假如题目变成“检索性别为女且选修了数学或英语课程的学生姓名”,下面的写法是错误的:
WHERE s.gender = '女' AND c.course_name = '数学' OR c.course_name = '英语';它会变成“选了数学的女生,以及所有选了英语的人”。正确写法务必加括号:
WHERE s.gender = '女' AND (c.course_name = '数学' OR c.course_name = '英语');我的经验是:在WHERE里,只要AND和OR同时出现,就给每一组OR套上括号。这不只是给数据库看的,更是给三个月后的自己看的,避免读代码时反复心算优先级。
4.4 大数据量下的索引与执行计划
小数据量下,这几种写法性能差异可以忽略。但数据量上到几十万、上百万行时,执行计划就很重要。
student_courses表的主键是(student_id, course_id),这个索引已经能很好支撑按学生查找选课记录的查询。courses.course_name因为加了UNIQUE约束,也会有唯一索引,按课程名过滤是快的。
使用EXISTS写法的子查询中,内层通过sc.student_id = s.student_id关联,能直接利用student_courses主键索引的前缀列;再通过course_id关联courses表,走主键索引,性能通常不错。JOIN + IN的写法,在连接列有索引的情况下也能正常走索引嵌套循环。真正需要注意的反而是那种用OR连接多个非索引条件的写法,数据库有时无法很好利用多个索引,只能全表扫描。
遇到慢查询,不要靠猜,直接执行EXPLAIN看执行计划。重点看访问类型(type)、扫描行数(rows)、是否出现全表扫描(ALL)或临时文件排序(Using temporary; Using filesort)。如果某张表扫描行数异常大,就要考虑补充联合索引或改写查询方式。
5. 一个连接词之外的进阶思考
5.1 变体一:至少选两门、恰好两门、不选某门
这类题目一旦理解了,就可以玩出很多变体。
“检索至少选修了两门课程的学生姓名”:
SELECT s.student_name FROM students s JOIN student_courses sc ON s.student_id = sc.student_id GROUP BY s.student_id, s.student_name HAVING COUNT(DISTINCT sc.course_id) >= 2;“检索恰好选修了两门课程的学生姓名”,把>= 2改成= 2即可。但要注意,HAVING COUNT(...) = 2是“统计结果恰好等于2”,如果学生选修了三门,不会出现在结果里。
“检索选修了数学但没有选修英语的学生姓名”:
SELECT s.student_name FROM students s JOIN student_courses sc ON s.student_id = sc.student_id JOIN courses c ON sc.course_id = c.course_id WHERE c.course_name = '数学' AND NOT EXISTS ( SELECT 1 FROM student_courses sc2 JOIN courses c2 ON sc2.course_id = c2.course_id WHERE sc2.student_id = s.student_id AND c2.course_name = '英语' );这种“同时满足某条件,又排除另一条件”的组合,在权限系统、规则引擎里非常常见。核心思维仍然是:满足类判断用IN/EXISTS/JOIN,排除类判断用NOT EXISTS或NOT IN,但留意NULL陷阱。
5.2 变体二:NOT IN 的 NULL 陷阱
很多人喜欢用NOT IN,但它在某些情况下会返回完全错误的结果。假设有一条SQL要查“没选英语课的学生”:
SELECT s.student_name FROM students s WHERE s.student_id NOT IN ( SELECT sc.student_id FROM student_courses sc JOIN courses c ON sc.course_id = c.course_id WHERE c.course_name = '英语' );这个例子里子查询不会返回NULL,所以结果是对的。但如果你把子查询换成某个可能包含NULL的列,比如直接在student_courses里查student_id,而表结构允许该列为NULL,那么NOT IN的结果会变成空集。因为SQL里NOT IN遇到NULL时,既无法判断“真”,也无法判断“假”,整个条件退化为UNKNOWN,数据库把所有行都过滤掉了。
为了避免这类问题,我几乎在确认为“排除类”查询时优先使用NOT EXISTS,它不会受到子查询结果里NULL的影响,语义也更直观:
SELECT s.student_name FROM students s WHERE NOT EXISTS ( SELECT 1 FROM student_courses sc JOIN courses c ON sc.course_id = c.course_id WHERE sc.student_id = s.student_id AND c.course_name = '英语' );5.3 从课程表延伸到真实业务
别小看这道“数学或英语”的小题,它几乎是很多复杂业务查询的缩影。
比如一个内容平台的文章带标签,articles、tags、article_tags三张表和这里的选课模型一模一样。“检索同时带有‘数据库’和‘Python’标签的文章”,就是“数学且英语”的翻版;“检索带‘数据库’或‘Python’标签的文章”,就是“数学或英语”的翻版。
再比如权限系统里,一个用户有多个角色,每个角色有不同权限点。“找出同时拥有‘审批’和‘导出’权限的用户”,本质也是同一张关联表上的集合运算。你在学生选课模型里建立的直觉,遇到这些场景可以直接复用。
我个人排查这类问题时,习惯先把条件拆成集合图:哪些人是集合M,哪些人是集合E,题目要交还是要并。确定之后,再选主写法:偏并集的“或”优先考虑IN + DISTINCT或EXISTS;偏交集的“且”优先考虑GROUP BY + HAVING或两次JOIN;排除类的优先使用NOT EXISTS。按这个套路走,基本能绕开大多数坑。
回到最初那个题目变化,把“且”改成“或”,真正改的不是一个连接词,而是你处理数据时的层级视角:是停留在每一行记录里做布尔判断,还是跳到集合层面去计算交集和并集。把这个想通,后面再碰到类似的进阶题,你大概率就不会再一改就翻车了。