备考计算机三级(数据库技术)的同学,十有八九会在交出一套 SELECT 语句后被批错。不是你不会写查询,而是高级数据库查询考的不只是语法,它考的是你对关系模型、分组逻辑和查询优化器的理解。很多人在这一步掉链子,不是输在语句背得少,而是输在一碰到“嵌套”“连接”“分组”就不知道哪个在前、哪个在后。
这篇备考记录,我按三级数据库考试里最常见的出题方式,把多表连接、嵌套子查询、集合运算、分组统计、窗口函数这些内容拆开讲,顺便把我自己刷题时踩过的坑和总结的速查模板一起放出来。适合正在备考三级数据库的同学,也适合工作里想补一补 SQL 基本功的人。文章里的例题我尽量统一在一套“学生选课”表结构上,这样你照着跑一遍,比干看书有效得多。
1. 高级数据库查询到底考什么
1.1 考试不是让你默写,是让你做取舍
很多备考资料把“高级查询”定义为“会写子查询、会 join、会 group by”,但这只是表面。三级数据库的题目往往不长,可挖的坑不少。真正的难点不是某个函数记不住,而是你能不能在一段模糊的业务描述里,快速判断该用哪张表、该用什么连接、该放在 WHERE 还是 HAVING。
我见过不少同学,天天背各种语句模板,一碰到“查询没有选修任何课程的学生”这种经典题就懵。原因很简单:他们背的是固定句式,而不是分析思路。高级查询题本质上考的是一种数据流转的顺序:先确定输出的列来自哪张表,再确定表与表之间怎么建立连接,接着确定过滤条件在哪一层做,最后才考虑要不要分组、要不要排序。
举个例子,题目问“查询选修了课程号 C001 且成绩不低于 90 分的学生姓名”。很多人拿到就写子查询,其实这里只需要一次简单的连接加过滤。真正需要做取舍的是“至少”“全部”“不存在”这类词,它们才对应 EXISTS、NOT EXISTS、集合运算等高级写法。所以第一步不是背语法,而是给题目里的关键词翻译成对应的 SQL 语义。
1.2 一张表看清各考点的比重
三级数据库的查询部分,考点其实是固定的。我按刷题和带教经验,把常见考点和出现频率整理成一张表,你可以拿它当复习清单。
| 考点类型 | 典型问法 | 考试出现频率 | 推荐掌握程度 |
|---|---|---|---|
| 单表查询 | 条件过滤、模糊匹配、排序 | 必考 | 必须熟练 |
| 多表连接 | 两表或三表查询、左右外连接 | 必考 | 必须熟练 |
| 子查询 | IN、ANY、ALL、EXISTS、标量子查询 | 高频 | 必须熟练 |
| 集合运算 | UNION、INTERSECT、EXCEPT | 中频 | 重点掌握 |
| 分组统计 | GROUP BY、HAVING、聚合函数 | 高频 | 必须熟练 |
| 窗口函数 | ROW_NUMBER、RANK、DENSE_RANK | 中低频 | 扩展掌握 |
| 查询优化 | 索引、执行计划、避免全表扫描 | 中频 | 理解原理 |
这张表的作用是帮你把精力分配得明明白白。单表查询和连接查询是地基,子查询和分组统计是拉开差距的地方,窗口函数则属于“别人不会你会”的加分项。备考时不要一上来就扎进冷门函数,先把高频考点做到闭着眼睛都能写。
2. 拿下多表连接:理解笛卡尔积才是赢家
2.1 连接查询到底在做什么操作
我在教学时经常问一个问题:当你写出一个 join 语句,数据库后台到底做了什么?很多人的回答是“把两张表合并起来”,这个说法太模糊。更准确的理解是,多表连接先对参与的表做一次笛卡尔积,再根据连接条件筛选出有效行。
笛卡尔积听起来吓人,其实就是“甲表的每一行去配对乙表的每一行”。比如 Student 表有 5 个学生,SC 表有 10 条选课记录,连接后数据量最多就是 5 × 10 = 50 行。如果三表连接,就是三张表行数的乘积。计算量很大,所以连接条件至关重要。
内连接之所以叫 inner,是因为它只保留连接条件成立的行。比如查学生姓名和选课成绩:
SELECT st.Sname, sc.Score FROM Student st JOIN SC sc ON st.Sno = sc.Sno;这里的 ON 条件把师生信息和选课记录按学号对齐。写着简单,可很多人会在三表连接时翻车:
SELECT st.Sname, c.Cname, sc.Score FROM Student st JOIN SC sc ON st.Sno = sc.Sno JOIN Course c ON c.Cno = sc.Cno WHERE st.Dept = '计算机';如果不写第二个 JOIN 的 ON,数据库会再次做笛卡尔积,结果立刻变成几十上百行。所以写多表连接时,我给自己定了一条死规矩:每写一个 JOIN,必须紧跟着写 ON,写完先数表间关系对不对,再往下写过滤条件。
2.2 外连接和自连接的实战姿势
内连接只是连接的一部分。题目里经常出现“不管有没有选课,都要把学生列出来”这种描述,这时候要用左外连接。左外连接以左表为主,右表没有匹配时,用 NULL 补齐。
SELECT st.Sname, sc.Score FROM Student st LEFT JOIN SC sc ON st.Sno = sc.Sno;这句话能查出所有学生,没选课的学生成绩显示 NULL。它解决了一个很常见的需求:统计每个学生的选课门数,就算 dept 是 0,也不能把人丢掉。
这里隐藏着一个大考点:如果把过滤条件写在 WHERE 里,左连接就悄悄退化成内连接。比如:
SELECT st.Sname, sc.Score FROM Student st LEFT JOIN SC sc ON st.Sno = sc.Sno WHERE sc.Score > 60;想象一下,一个学生没选任何课,SC 里根本没有对应记录,sc.Score 是 NULL。NULL > 60 的结果既不为真也不为假,WHERE 会把这一行过滤掉。结果就是没选课的学生又消失了。想保留左表的全部信息,过滤条件得写在 ON 里:
SELECT st.Sname, sc.Score FROM Student st LEFT JOIN SC sc ON st.Sno = sc.Sno AND sc.Score > 60;外连接和 WHERE 的这层关系,是上机题里最容易丢分的点。我刷题时专门做过对比实验,同一个需求,过滤条件放 ON 和放 WHERE,结果完全不一样。
自连接也劝你别只背概念。最经典的例子是“同一张表里找年龄差距”。比如查询和“张小明”同系的学生,你可以用自连接,把一张表当成两张表用:
SELECT b.Sname FROM Student a JOIN Student b ON a.Dept = b.Dept WHERE a.Sname = '张小明' AND b.Sname <> '张小明';自连接的精髓是给同一张表取不同的别名,让它在逻辑上变成两张表。这个技巧很多教材只是提个名字,但考试真考过。遇到“同一个表里的对比关系”,先想想能不能用自连接解决。
3. 子查询与集合运算:嵌套逻辑的拆解套路
3.1 单行子查询和多行子查询怎么区分
子查询本质上是把一条查询结果当作另一条查询的条件值。如果子查询只返回一个值,比如一个数字或一个字符串,它叫标量子查询:
SELECT Sname, Age FROM Student WHERE Age > (SELECT AVG(Age) FROM Student);这个语句的含义是查所有年龄大于全校平均年龄的学生。子查询先算出平均年龄,比如 20.5,然后外层查询就用这个值去做比较。标量子查询结果唯一,判断关系可以用 =、>、< 这些普通运算符。
如果子查询返回多行,那就要用 IN、ANY、ALL 这些专门的运算符。它们的作用是让外层查询的某列值和子查询返回的一堆值逐一比较。比如查选修了 C001 或 C002 课程的学生学号:
SELECT Sname FROM Student WHERE Sno IN ( SELECT Sno FROM SC WHERE Cno IN ('C001', 'C002') );这里子查询返回了多个学号,不能用等号直接比较。把多行结果和一个列做等值匹配时,IN 是最直观的选择。ANY 和 ALL 则不常用,但考试偶尔会考判断题。ANY 表示“至少满足其中一个”,ALL 表示“满足全部”。例如Score > ALL (子查询)意思是比子查询返回的所有分数都高,也就是比最大分数还高。
3.2 EXISTS 和 NOT EXISTS:真正的进阶分水岭
如果说 IN 是子查询的基础,EXISTS 就是高级查询的分水岭。EXISTS 特别适合“是否存在”这种判断,最典型的就是“查询没有选修任何课程的学生”。
SELECT st.Sname FROM Student st WHERE NOT EXISTS ( SELECT 1 FROM SC sc WHERE sc.Sno = st.Sno );这段代码读起来有点绕,但核心逻辑很简单:对 Student 表的每一个学生,去 SC 表里找有没有他的选课记录。找到至少一条,EXISTS 就为真;一条都找不到,NOT EXISTS 就为真。
写 NOT EXISTS 时要注意一个细节:子查询里用了外层表的列,才能建立内外联系。这种写法叫相关子查询,子查询的执行依赖于外层当前行。很多人把子查询写成独立查询,结果发现查出来的数据莫名其妙,就是因为没有加WHERE sc.Sno = st.Sno。
我为什么更推荐 EXISTS 而不是 NOT IN?“查询没选课的学生”用 NOT IN 也能写:
SELECT Sname FROM Student WHERE Sno NOT IN (SELECT Sno FROM SC);可这段代码有一个致命陷阱:如果 SC 表里的 Sno 存在 NULL 值,NOT IN 的结果会变成空。原因很绕,你可以简单理解成 NULL 不是任何值的“不等于”。考试专门喜欢出这种反直觉题,所以我的建议是:看到“没有”“不存在”“全部”这类词,优先用 NOT EXISTS。
3.3 集合运算:把查询结果当成集合
集合运算在三级考试里不考特别深,但 UION、INTERSECT、EXCEPT 这三兄弟经常出现在选择题里。它们的作用是把两个查询结果按行做合并、交集、差集。
查询“选修了 C001 或 C002 课程的学生”:
SELECT Sno FROM SC WHERE Cno = 'C001' UNION SELECT Sno FROM SC WHERE Cno = 'C002';这句话和用 OR 写在同一个条件里的效果类似,但 UNION 会把重复行去掉。如果明确只想保留全部重复行,用UNION ALL,通常 UNION ALL 比 UNION 快,因为数据库不用专门去重。
INTERSECT 取两个结果的交集,适合“既选修了 C001,又选修了 C002”这类需求。EXCEPT 取差集,适合“选修了 C001,但没有选修 C002”这类需求。写集合运算时有一个规矩:每个 SELECT 语句输出的列数必须相同,数据类型尽量一致。仔细看题目给的选项,经常有人在 INTERSECT 后加 ORDER BY 却放错位置。ORDER BY 必须写在整套集合运算的最后,不是写在其中某一段后面。
4. 分组统计与 HAVING:别让 GROUP BY 毁在你手里
4.1 聚合函数为什么不能单独用在普通列上
分组统计是高级查询的常客,也是新手翻车重灾区。先记住一个原则:聚合函数是对一组值计算后返回单个值,比如 COUNT、SUM、AVG、MAX、MIN。当你使用了聚合函数,SELECT 里出现的普通列必须出现在 GROUP BY 子句里,否则数据库不知道怎么把这个普通列的值放到哪一行。
举个例子,统计每个系的学生人数:
SELECT Dept, COUNT(*) AS 人数 FROM Student GROUP BY Dept;这里的 Dept 是分组列,COUNT(*) 是每一组的统计值。两相匹配,没问题。但如果有人写出SELECT Sname, COUNT(*) FROM Student GROUP BY Dept,这个语句在很多数据库里会直接报错,或者返回一个让人摸不着头脑的值。因为一个系有多个学生姓名,数据库不知道该取哪一个。考试题目常问“下面哪个语句是正确的”,考点往往就在这里。
聚合函数的使用也要留心,COUNT()、COUNT(列名)、COUNT(DISTINCT 列名) 语义完全不同。COUNT() 统计所有行,COUNT(列名) 只统计该列非 NULL 的行,COUNT(DISTINCT 列名) 统计去重后的非 NULL 值数量。选择题专门喜欢混淆这三者。
4.2 WHERE、GROUP BY、HAVING 的执行顺序
这是我反复讲过的一个点。SQL 的书写顺序和执行顺序并不一样,很多人不看执行顺序,只看书写顺序,结果在 WHERE 和 HAVING 上栽跟头。标准的逻辑执行顺序大致是:
- 先 FROM,确定数据来源。
- 再 WHERE,过滤原始行。
- 然后 GROUP BY,把过滤后的行分组。
- 接着 HAVING,过滤分组后的组。
- 再 SELECT,计算输出列。
- 最后 ORDER BY,对结果排序。
这个顺序解释了为什么“筛选分组前记录”用 WHERE,“筛选分组后结果”用 HAVING。比如统计选课门数大于 2 的学生:
SELECT Sno, COUNT(*) AS 选课数 FROM SC GROUP BY Sno HAVING COUNT(*) > 2;这里 WHERE 已经没用了,因为选课数这个指标是分组之后才产生的。HAVING 针对的是“组”的过滤条件,它可以写聚合函数。WHERE 不行,因为 WHERE 执行时组还没形成。
一个常见的错误写法是:
SELECT Sno, COUNT(*) FROM SC WHERE COUNT(*) > 2 GROUP BY Sno;这个语句逻辑上就是错的,聚合函数不能放在 WHERE 里做过滤。道理不难理解:WHERE 是在“逐行检查”的阶段执行,COUNT(*) 要等整组数据都到位才能算出来。
4.3 分组统计典型题目拆解
我拿一道近乎必考的题来练手:查询平均成绩高于 80 分且选修了至少两门课程的学生学号和平均成绩。
先不看答案,拆一下步骤:平均成绩和选课门数都是按学生分组后计算出来的,所以 GROUP BY Sno;每个组要被 HAVING 过滤,条件是 AVG(Score) > 80 且 COUNT(*) >= 2。
SELECT Sno, AVG(Score) AS 平均成绩, COUNT(*) AS 选课门数 FROM SC GROUP BY Sno HAVING AVG(Score) > 80 AND COUNT(*) >= 2;这道题把分组的两个核心操作全考到了。如果题目要求再关联出学生姓名,就把查出来的学号再去 JOIN Student 表。注意 JOIN 的顺序:一般先过滤分组出符合条件的小结果集,再关联其他表,效率更高。不过考试里更看重逻辑,JOIN 写在前写在后结果一样。
5. 窗口函数:考场加分项,也是理解难点
5.1 PARTITION BY 和 ORDER BY 各自的工作
窗口函数是后来在数据库考试里逐渐出现的内容。它不是必须用 GROUP BY 压缩行数,而是在保留每一行原始数据的同时,对一组行做计算。最直观的需求是“按系排名”。
SELECT st.Dept, st.Sname, sc.Score, RANK() OVER (PARTITION BY st.Dept ORDER BY sc.Score DESC) AS 排名 FROM Student st JOIN SC sc ON st.Sno = sc.Sno;PARTITION BY 负责分区,相当于对每个系单独起一个排行榜。ORDER BY 负责在每个分区内排序。这个查询不会减少行数,每个学生的原始记录还都在,只是多了一列排名。好多同学第一次看到结果时很惊讶:为什么行数没变少?这就是窗口函数和 GROUP BY 最大的不同。
GROUP BY 会把多行压成一行,窗口函数不会。所以窗口函数特别适合“既要明细数据,又要排名或累计值”的场景。
5.2 RANK、DENSE_RANK、ROW_NUMBER 的区别
这三个排名函数长得像,选择题最喜欢拿它们互相挖坑。ROW_NUMBER 就是单纯给每一行编一个连续序号,不管分数是否相同,序号从 1、2、3 依次往下排。RANK 遇到并列会跳号,比如两个人并列第一,那下一个人的名次是 3,不是 2。DENSE_RANK 遇到并列不跳号,下一个人还是 2。
| 函数 | 相同值的处理方式 | 典型结果示例 |
|---|---|---|
| ROW_NUMBER | 强制给每行一个唯一序号 | 1、2、3、4 |
| RANK | 并列同号,且后续跳号 | 1、1、3、4 |
| DENSE_RANK | 并列同号,后续不跳号 | 1、1、2、3 |
考试里“取出每个系成绩前 3 名”这类题,就需要想清楚用哪个函数。如果题目字面意思只是排名,通常 RANK 或 DENSE_RANK 更合理;如果只是按顺序取前 N 条,ROW_NUMBER 更直接。实际工作中,我经常先写 ROW_NUMBER 外层套一层再过滤,这样能精确定位某一档的记录。
5.3 窗口函数和 GROUP BY 的配合边界
窗口函数还能和 GROUP BY 一起出现,但要注意逻辑。比如先按学生分组算出平均分,再对这个平均分排名:
SELECT Sno, AVG(Score) AS 均分, RANK() OVER (ORDER BY AVG(Score) DESC) AS 均分排名 FROM SC GROUP BY Sno;这里的 AVG(Score) 既是分组统计值,也是窗口函数里排序依据。窗口函数在所有分组、HAVING、聚合计算完成后才执行,所以能直接用 AVG(Score)。这道题我建议你亲手在 SQL Server 或 MySQL 8.0 以上跑一遍,跑一次比背十遍更管用。
6. 真题风格的查询设计:从题目到 SQL 的转化流程
6.1 三步读题法,把长题目翻译成执行计划
很多人看到大段文字描述就慌。我总结了一个“三步读题法”,应对三级数据库的查询大题基本够用。
第一步,看输出。题目要求显示哪些列,先写上 SELECT。比如“显示系名和人数”,SELECT 里一定先写这两列。第二步,看来源。输出列来自哪几张表,确定 FROM 和 JOIN。第三步,看条件。条件里如果有数字范围,通常是 WHERE;如果有“每个系”“每门课”这种词,通常是 GROUP BY;如果有“人数”“平均值”这种统计词,再决定 HAVING。
拿一个典型问题练手:查询每个系里选修了 C001 课程且成绩不低于 90 分的学生人数,显示系名和人数。
输出列是系名和人数。系名来自 Student 表,成绩来自 SC 表,所以两表连接。条件有两个,一个是 C001,一个是成绩不低于 90,都在分组之前过滤原始行,所以放 WHERE。最后按系分组。
SELECT st.Dept, COUNT(*) AS 人数 FROM Student st JOIN SC sc ON st.Sno = sc.Sno WHERE sc.Cno = 'C001' AND sc.Score >= 90 GROUP BY st.Dept;很多人会把成绩条件放到 HAVING 里,因为句子里有“人数”。但这里过滤的是普通列 sc.Score,不是聚合结果,放 HAVING 反而逻辑不对。判断标准很简单:条件里出现的是原始列还是聚合函数,前者去 WHERE,后者去 HAVING。
6.2 另一个高频案例:没有选修任何课程的学生
这道题在各类题库里至少出现过十几次。业务描述是“查询没有选修任何课程的学生姓名”,考点是 NOT EXISTS 或 NOT IN 的取舍。
SELECT st.Sname FROM Student st WHERE NOT EXISTS ( SELECT 1 FROM SC sc WHERE sc.Sno = st.Sno );这种题难就难在学生表里有些 Sno 可能在 SC 表里不存在,NOT EXISTS 对这种空值情况天然免疫。如果题目选项里出现用 NOT IN 的写法,你还要额外判断 SC 表的 Sno 会不会有 NULL。只要不能确定没有 NULL,NOT EXISTS 就是更稳的选择。
6.3 查询结果的验证习惯
写完 SQL 只成功了一半,另一半是验证。我备考时给自己定了一个规矩:先跑出结果,再手动核对几条数据。比如上面那道“每个系人数”的题,我会把 SC 表先按 C001 筛选,手工数一数计算机系有几个 90 分以上的人,再和查询结果对比。
这一步看着慢,实则是培养“数据敏感度”。考试上机环境里,你完全靠眼睛看不出结果对不对,但平时养成的核对习惯能帮你快速发现条件写错、连接条数不对这类低级错误。很多考生说自己“明明会写,就是考不出来”,其实就是验证这一步缺了。
7. 高频错误与排错经验:这些都是我踩过的坑
7.1 常见的六类查询翻车现场
我把平时刷题、带学生时遇到的典型错误整理成一张速查表,你可以把它贴在旁边备查。
| 错误类型 | 错误示例 | 原因分析 | 正确思路 |
|---|---|---|---|
| 多表连接少写条件 | JOIN SC ON st.Sno = sc.Sno,少了 ON | 笛卡尔积导致结果爆炸 | 每个 JOIN 后必须配 ON |
| 左连接条件放错位置 | LEFT JOIN + WHERE 过滤右表列 | 外连接退化成内连接 | 保留左表所有行时放 ON |
| 聚合函数放 WHERE | WHERE COUNT(*) > 2 | WHERE 在分组前执行 | 用 HAVING 过滤组 |
| NOT IN 含 NULL | Sno NOT IN (子查询含 NULL) | NULL 参与比较结果未定义 | 优先用 NOT EXISTS |
| COUNT 列名统计 | COUNT(Score) 当 COUNT(*) 用 | NULL 不计入统计 | 分清 COUNT 的语义 |
| GROUP BY 后选普通列 | SELECT Sname, COUNT(*) | 普通列不在分组中 | 只输出分组列或聚合值 |
这个表里的每一个坑,我都亲眼见过考生在模拟题里踩过。尤其左连接条件位置和 NOT IN 的 NULL 问题,出错概率非常高。
7.2 NULL 是隐藏的炸弹
NULL 在 SQL 里不是空字符串,也不是 0,它代表“未知”。凡是和 NULL 做比较的表达式,结果都是 UNKNOWN,既不是 TRUE 也不是 FALSE。所以WHERE Score > 60会把成绩为 NULL 的行过滤掉,WHERE Score = NULL也查不出任何行,必须用IS NULL来判断。
NULL 另一个隐蔽地点是聚合函数。COUNT(Score) 不统计 NULL,但 SUM、AVG 遇到 NULL 又会忽略它。比如统计某门课的平均分,如果某些学生没成绩被记成 NULL,直接 AVG(Score) 会把它们跳过,结果可能和学生人数对不上。这不算错,但你要知道这个规则,才能解释为什么查出来的平均值“偏高”。
7.3 书写顺序和执行顺序要分开记
SELECT、FROM、WHERE、GROUP BY、HAVING、ORDER BY 这个书写顺序大家都熟,但数据库真正执行时是反着来的:FROM 最先确定数据源,然后 WHERE 过滤,再 GROUP BY 分组,再 HAVING 过滤组,然后 SELECT 计算输出,最后 ORDER BY 排序。我一直强调这个逻辑,是因为很多语句报错或结果不符合预期,都是因为把执行顺序和书写顺序搞混。
比如有人想过滤掉分组后人数小于 2 的组,却把条件写在 WHERE 里;又比如 SELECT 里给某列取了别名,却在 WHERE 里直接用别名。WHERE 执行时,SELECT 里的别名还没生成,当然会报“列名无效”。理解执行顺序之前,这些问题只能用死记硬背应付;理解之后,你自然就知道该往哪儿写。
7.4 一个值得养成的刷题习惯
备考后期,我给自己做了一个小调整:不再对着答案看题,而是把每道选择题都当成“手写 SQL 题”来推演。先看题目问什么,自己脑子里写一遍 SQL,再看选项里哪个和自己的思路一致。这样做的好处是,选择题里的很多“错误答案”其实都是某些同学的典型错法,你能看出它错在哪,就和出题人站在同一位置了。
我还建议你把每个经典例题都准备一个测试脚本,重复跑、刻意拆解。比如今天我写的 Student、Course、SC 三张表,你可以往 SC 表里插入几个空学号记录,试试 NOT EXISTS 和 NOT IN 的差异;也可以把 LEFT JOIN 的 ON 和 WHERE 各写一遍,对比结果行数。亲手改动一次,比看十遍文章印象深得多。三级数据库的高级查询并不玄学,说到底就是“连接、子查询、分组、集合、窗口”这几件事的组合。你只要把每个模块的底层逻辑打通,再配合适量题目训练,考场上看到再长的描述,也能很快拆成可执行的一步一步。