☰
SQL多表查询中AND与OR的集合语义:从行内判断到跨行判断的正确写法
2026/10/14 4:52:55 网站建设 项目流程

最近帮人审一批数据库课程的练习时,碰到一个特别经典的题目变化:原题是“查询同时选修了数学和英语课程的学生姓名”,后来题目改成了“检索选修了‘数学’或‘英语’课程的学生姓名”。别看只是把“且(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 + DISTINCTGROUP 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 为什么查询结果为空

空结果是最让人头疼的,我建议按下面顺序排查。

  1. 确认数据真的存在。先用最简单的查询确认是否有学生选修了相关课程。
  2. 检查课程名是否完全匹配。很多坑都来自肉眼看不见的空格、全角半角差异,比如'英语 '和'英语'不是同一个值。
  3. 检查大小写与排序规则。某些数据库的默认排序规则是大小写敏感,'Math'和'math'会匹配不上。如果生产环境已经出现这类问题,可以显式指定排序规则或统一数据写入口径。
  4. 检查连接字段是否选错。比如把student_courses.student_id错误地连到了courses.course_id,结果必然异常。
  5. 检查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。按这个套路走,基本能绕开大多数坑。

回到最初那个题目变化,把“且”改成“或”,真正改的不是一个连接词,而是你处理数据时的层级视角:是停留在每一行记录里做布尔判断,还是跳到集合层面去计算交集和并集。把这个想通,后面再碰到类似的进阶题,你大概率就不会再一改就翻车了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询