ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

完整T-SQL实验脚本:从建表到多表连接与集合运算排错

完整T-SQL实验脚本:从建表到多表连接与集合运算排错

简介:南华大学《数据库原理》课程实验报告,面向大数据技术等专业学生及数据库入门学习者,围绕SQL Server环境下的数据库设计与SQL应用展开,可用于课程实验复盘、期末复习和上机操作对照。压缩包共1个doc文件,约5.38MB,以图文实验报告形式组织,配合截图与SQL代码,完整记录实验题目、要求、执行结果和分析过程。报告按模块推进:实验1认识DBMS,涵盖图形界面与SQL语句创建“学生选课”数据库、学生/课程/选修三表及主外码和默认值;实验2简单SQL查询,包含单表筛选、排序、分组及多表连接;实验3高级SQL查询、实验4数据更新与实验总结,便于理解实体完整性与参照完整性,并掌握插入、更新、删除等基本操作。已有377人学习,适合希望对照完整实验过程、补做上机练习或整理课程报告的学习者参考。

1. 从一份实验报告里拆出能跑的 T-SQL:它解决的是“写不出”而不是“看不懂”

数据库原理实验报告这类资料,网上一搜一大把 PDF,但真能照着跑通的不到一半——多数只剩截图和题目,SQL 语句要么被截断,要么缺建表脚本,拿到手第一件事反而是猜表结构。我手上这份是某高校大数据技术专业 2022 年春季学期的完整版,四个实验从 SQL Server Management Studio 建库建表,一路做到单表查询、多表连接、嵌套查询和集合运算,题目、要求、代码四件套齐全,数据库名、表名、字段名前后一致,不用自己补设计。

它解决的是一件事:把手写 T-SQL 从“看得懂”推到“写得出、跑得通、改得动”。适合三类人——正在赶数据库原理实验的学生,准备后端面试想捡回 SQL 基本功的求职者,以及太久没碰 SQL Server、连GROUP BY和HAVING的先后都开始犹豫的开发者。下面按建表、单表、多表、子查询、排错、验证六段拆,脚本都能直接粘进 SSMS 跑。

2. 建库建表与三张表的关系:主码、外码、默认值到底怎么落

2.1 先想清楚实体和联系,再动手写 CREATE

实验 1 的题目本身给得很清楚:学生、课程、选修三张表。但很多人拿到就直接开写,写到一半发现选修表不知道主码放什么。这里的关键是先把联系类型定下来——一个学生能选多门课,一门课能被多个学生选,学生和课程之间是多对多,多对多必须落一张中间表,也就是选修表。选修表的主码不能是单一字段,得是(学号,课程号)的联合主码,因为同一个学生选同一门课只应该有一条记录,用联合主码天然把这条规则约束住。

有人喜欢给选修表加一个自增 ID 当主码,把(学号,课程号)做成唯一索引。这不算错,但在教学和考试语境下,联合主码更贴近教材对“主码”的定义,也少一个跟业务无关的字段。我一般会跟学生强调:主码选错,后面所有连接查询的写法都会跟着别扭。学生表的主码是学号,课程表的主码是课程号,选修表引用这两个,外码关系自然就出来了。

属性类型也要在这一步定。学号固定长度,用CHAR(8);姓名长度不定,用VARCHAR(20);性别只有两个字,CHAR(2)就够;出生日期用DATE,别用DATETIME,否则查询 1999—2000 年出生的学生时会多出一堆时间部分的麻烦。成绩用DECIMAL(5,1),一位小数,够表示 0 到 100 分。这些选择单看都是小事,凑在一起决定了后面查询能不能少写一堆CAST。

提示:先定主码和外码,再定字段类型,最后才考虑默认值和可空性。顺序反过来,改表结构时会被外码约束卡住。

2.2 一段能直接跑的建库建表脚本

图形界面点得再熟,最后还是要落到脚本。下面这段按“建库 → 建表 → 加外码 → 灌数据”的顺序写,可以直接整段粘进 SSMS 执行。注意GO是 SSMS 的批处理分隔符,不是 T-SQL 语句,它让CREATE DATABASE和后面的USE分批提交,否则同一批里引用刚建的库会报错。

-- 若已存在同名库则先删除,方便反复重跑 IF DB_ID('StuCou') IS NOT NULL DROP DATABASE StuCou; GO CREATE DATABASE StuCou; GO USE StuCou; GO -- 学生表:学号为主码,性别给默认值,备注允许为空 CREATE TABLE Stu ( Sno CHAR(8) NOT NULL, Sname VARCHAR(20) NOT NULL, Ssex CHAR(2) NOT NULL CONSTRAINT DF_Stu_Ssex DEFAULT '男', Sbirth DATE NULL, Sdept VARCHAR(30) NULL, Smemo VARCHAR(100) NULL, CONSTRAINT PK_Stu PRIMARY KEY (Sno) ); -- 课程表:Cpno 是先行课,自引用外码,必须允许为空 CREATE TABLE Cou ( Cno CHAR(4) NOT NULL, Cname VARCHAR(40) NOT NULL, Cpno CHAR(4) NULL, Ccredit SMALLINT NOT NULL CONSTRAINT DF_Cou_Ccredit DEFAULT 4, CONSTRAINT PK_Cou PRIMARY KEY (Cno), CONSTRAINT FK_Cou_Cpno FOREIGN KEY (Cpno) REFERENCES Cou(Cno) ); -- 选修表:联合主码,两个外码分别指向学生和课程 CREATE TABLE SC ( Sno CHAR(8) NOT NULL, Cno CHAR(4) NOT NULL, Grade DECIMAL(5,1) NULL, CONSTRAINT PK_SC PRIMARY KEY (Sno, Cno), CONSTRAINT FK_SC_Sno FOREIGN KEY (Sno) REFERENCES Stu(Sno), CONSTRAINT FK_SC_Cno FOREIGN KEY (Cno) REFERENCES Cou(Cno) ); GO

这段脚本里有几处值得单独说。CONSTRAINT DF_Stu_Ssex DEFAULT '男'是给默认值显式命名,不命名 SQL Server 会生成DF__Stu__Ssex__1A14E395这类随机名,后面想删默认值就得先去系统视图里查名字,实验报告里写出来也不好看。Cpno必须允许为空,因为第一门课没有先行课,写成NOT NULL之后那条记录永远插不进去。FK_Cou_Cpno是自引用外码,引用的是同一张表的Cno,这在 SQL Server 里允许,但插入顺序有讲究,下一节细说。

SC表的Grade允许为空,是为了表示“选了但还没出成绩”。这个可空性后面会直接影响COUNT(*)和COUNT(Grade)的差别,是实验 2 里最容易算错的地方之一。

2.3 三个容易写错的约束细节

第一个是默认值到底什么时候生效。DEFAULT '男'只在INSERT语句完全省略这一列时才起作用;如果写成INSERT INTO Stu (Sno, Sname, Ssex) VALUES ('0602005','陈明', NULL),显式传了NULL,默认值不会兜底,插入的就是空值。想让它兜底,要么省略列,要么在列上加NOT NULL把空值挡回去。这个行为和很多人直觉里的“默认值等于自动填充”不一样。

第二个是自引用外码的插入顺序。课程表里 C02 的先行课是 C01,C03 的先行课是 C02,如果按 C01、C02、C03 的顺序插,没有问题;一旦顺序打乱先插 C02,会因为 C01 还不存在而报外码冲突。稳妥的写法是先把所有课程的Cpno插成NULL,再统一UPDATE补先行课关系。

-- 先插入不带先行课的课程记录 INSERT INTO Cou (Cno, Cname, Cpno, Ccredit) VALUES ('C01','数据库原理', NULL, 4), ('C02','数据结构', NULL, 4), ('C03','操作系统', NULL, 4), ('C04','离散数学', NULL, 3); -- 再补先行课关系,此时被引用的课程都已存在 UPDATE Cou SET Cpno = 'C01' WHERE Cno = 'C02'; UPDATE Cou SET Cpno = 'C02' WHERE Cno = 'C03';

第三个是可空性和查询结果的隐性耦合。Sdept允许为空,WHERE Sdept = '计算机系'会把这些空值行过滤掉,看起来正常;但如果写成WHERE Sdept <> '计算机系',空值行同样不会出现,因为NULL参与比较得到的是UNKNOWN而不是TRUE。实验 3 里“查询其他系中比计算机系学生年龄都小的学生”就踩在这个点上,后面第 5 章展开。

2.4 图形界面和脚本两条路,结果差在哪

实验 1 要求两种方式各做一遍:先用 SSMS 图形界面建库建表建关系,再用 SQL 语句建一套。很多人做完觉得两边一样,其实差异藏在细节里。图形界面建表时,SSMS 设计器默认把表建成“保存时重建”,如果表里已经有数据,改一个字段类型可能触发删表重建,数据直接没了,而且它不会明显提醒你。脚本改表虽然也有限制,但至少你能看见自己写的是ALTER还是DROP。

再就是约束命名。图形界面加的默认值、检查约束,名字都是系统生成的,脚本里引用不到。实验报告如果要求写“删除某某约束”,图形界面那条路基本交不出可复现的语句。所以我的习惯是:图形界面只用来快速看一眼表结构,最终交付一律用脚本,命名规则统一成PK_表名、FK_子表_列、DF_表名_列,谁看都知道指什么。

数据灌入也是一样。下面这段是学生表、课程表、选修表的最小数据集,学号用 0602 开头,课程用 C01 到 C04,成绩里故意留一条空值,方便后面验证聚合函数的差异。

INSERT INTO Stu (Sno, Sname, Ssex, Sbirth, Sdept, Smemo) VALUES ('0602001','李勇','男','1999-03-12','计算机系', NULL), ('0602002','刘晨','女','2000-07-25','计算机系', NULL), ('0602003','王敏','女','1999-11-02','计算机系', NULL), ('0602004','张立','男','1998-05-18','数学系', NULL), ('0602005','陈明','男','2000-01-30','数学系', NULL); INSERT INTO SC (Sno, Cno, Grade) VALUES ('0602001','C01', 88.0), ('0602001','C02', 76.5), ('0602001','C03', 91.0), ('0602002','C01', 59.0), ('0602002','C02', 82.0), ('0602003','C01', 95.0), ('0602003','C04', NULL);

数据量不大,但对验证查询结果足够了。特别是0602003选 C04 没成绩这一条,后面统计平均分和门数时会立刻暴露写法的差别。

3. 单表查询那十道题:WHERE、聚合与排序的真实写法

3.1 条件筛选:比较、逻辑与模糊匹配

实验 2 的第一组题全部落在单表上,看似简单,但十道题基本把WHERE的常用形态过了一遍。最基础的一道是查计算机系全体学生,写法就是把系名当等值条件。这类查询的坑不在语法,在于字符串里的空格——'计算机系 '和'计算机系'在=比较下不相等,从 Excel 粘数据进来时尾部空格很常见,查不出结果先怀疑这个。

-- 查询计算机系全体学生的完整信息 SELECT Sno, Sname, Ssex, Sbirth, Sdept, Smemo FROM Stu WHERE Sdept = '计算机系';

查姓“李”的学生用LIKE '李%',百分号在后表示前缀匹配。这里要注意排序规则的影响:默认排序规则下LIKE '李%'能命中“李勇”,但如果库的排序规则是二进制或某些区分大小写的配置,中文匹配行为会跟着变。实验环境一般用默认的Chinese_PRC_CI_AS,问题不大,换到别的库上跑不通先查这一项。

查询没有先行课的课程,考的是IS NULL而不是= NULL。Cpno = NULL永远返回空结果,因为NULL表示未知,未知和任何值比较都是未知。这条是 SQL 入门最经典的翻车点,实验报告里专门列出来是有道理的。

-- 先行课为空,说明这门课没有先修要求 SELECT Cname FROM Cou WHERE Cpno IS NULL;

查有不及格成绩的学生学号,要加DISTINCT。一个学生可能挂了两门,不去重会拿到重复学号,和题目要的“学生的学号”对不上。这一步很多人漏掉,因为样例数据里恰好只有一个学生挂科,跑出来看不出问题,换成真实数据就露馅。

-- DISTINCT 去掉同一学生多门挂科造成的重复 SELECT DISTINCT Sno FROM SC WHERE Grade < 60;

选 C01 或 C02 这道题,IN和OR等价,但IN更好读,字段多的时候不用重复写列名。要注意的是如果成绩为空,Grade列会带出NULL,题目只要求学号和成绩,空值照常显示,这不算错,但如果后续要对成绩做运算就得先处理。

3.2 日期与年龄:DATEDIFF 的两个边界

“查询全体计算机系学生的姓名及其年龄”这道题,最常见的写法是YEAR(GETDATE()) - YEAR(Sbirth)。这个写法在大多数情况下对,但有两个边界:一是生日还没到的学生会被多算一岁,二是Sbirth为空时整个表达式返回NULL。实验数据里恰好没有这两种情况,所以跑出来一切正常,换一批数据就开始对不上。

更稳的写法是用DATEDIFF加一次修正,把“今年生日是否已过”判断进去:

-- 精确到岁的年龄:先算年份差,生日未到则减 1 SELECT Sname, DATEDIFF(YEAR, Sbirth, GETDATE()) - CASE WHEN DATEADD(YEAR, DATEDIFF(YEAR, Sbirth, GETDATE()), Sbirth) > GETDATE() THEN 1 ELSE 0 END AS Age FROM Stu WHERE Sdept = '计算机系';

DATEDIFF(YEAR, ...)只比较年份部分,不看到底过没过生日;DATEADD把出生日期加上同样的年数,得到“今年的生日”,再和今天比。这个套路在处理工龄、账龄、会员年限时通用,值得记住。如果只是想粗略估计,用YEAR相减也无可厚非,但实验报告里最好注明这是近似值,别让人拿去当考勤依据。

1999—2000 年出生的学生那道题,坑在区间边界。写成BETWEEN '1999-01-01' AND '2000-12-31',在DATE类型下没问题,但如果字段是DATETIME且存了2000-12-31 10:30:00,BETWEEN的右端就到不了,这条记录会被漏掉。半开区间写法>= '1999-01-01' AND < '2001-01-01'能避开这个问题,也是我在生产环境里一律采用的写法。

-- 半开区间,避免右端点的时间部分把当天数据漏掉 SELECT Sname FROM Stu WHERE Sdept = '计算机系' AND Sbirth >= '1999-01-01' AND Sbirth < '2001-01-01';

3.3 分组聚合:COUNT 数谁、HAVING 放哪

“查询选修了两门以上课程的学生学号与课程数”和“查询选修课程数大于等于 2 的学生的学号、平均成绩和选课门数,并按平均成绩降序排列”这两道题,是实验 2 里唯一需要分组的两道,也是错得最多的两道。错误集中在三个地方:COUNT(*)和COUNT(列)混用、WHERE和HAVING放错位置、ORDER BY里直接用聚合函数还是别名。

-- 选修两门以上(严格大于 2)的学生及课程数 SELECT Sno, COUNT(*) AS CouCnt FROM SC GROUP BY Sno HAVING COUNT(*) > 2; -- 选修两门及以上,带平均分,按平均分降序 SELECT Sno, AVG(Grade) AS AvgGrade, COUNT(*) AS CouCnt FROM SC GROUP BY Sno HAVING COUNT(*) >= 2 ORDER BY AVG(Grade) DESC;

COUNT(*)数的是行数,COUNT(Grade)数的是成绩非空的行数。样例数据里0602003选了 C04 但成绩为空,如果第二道题写成COUNT(Grade),这个学生的选课门数会被算成 1,直接被HAVING >= 2过滤掉,结果里少一个人。题目问的是“选课门数”,选课行为已经发生,成绩有没有出是另一回事,所以应该用COUNT(*)。这个差别在成绩表、订单表、日志表上完全一样,是很通用的一条经验。

WHERE在分组前过滤行,HAVING在分组后过滤组,两者不能互换。想筛“计算机系的学生选课门数”,系名条件放WHERE,门数条件放HAVING;要是把Sdept = '计算机系'写进HAVING,SQL Server 会直接报错说该列不在聚合或分组里。另外,HAVING COUNT(*) > 2和HAVING CouCnt > 2在 SQL Server 里后者不行,因为SELECT里的别名在这一步还不能引用,ORDER BY才可以。

至于“两门以上”到底含不含两门,这是中文表述的歧义,不是 SQL 的问题。题目第 9 题写“两门以上”,第 10 题写“大于等于 2”,出题人显然把前者当成严格大于。实验报告里把>和>=各写一遍、注明理由,比纠结哪个对更有价值。

3.4 排序与前 N 行:TOP 与字典序

“查询姓李的学生中姓名按字典顺序排序的前两个”这道题,考的是TOP加ORDER BY。写法很短,但顺序不能反:TOP必须和ORDER BY一起用才有确定结果,否则拿到的是哪两行全看存储引擎心情,这在 SQL Server 里属于“能跑但不可复现”的典型。

-- TOP 必须配合 ORDER BY,否则结果不保证 SELECT TOP 2 Sno, Sname FROM Stu WHERE Sname LIKE '李%' ORDER BY Sname;

字典序在这里按数据库的排序规则来,中文一般按拼音,所以“李”姓学生里“李勇”排在“李娜”后面取决于具体规则。如果实验要求严格按拼音,可以在ORDER BY后面加COLLATE Chinese_PRC_CI_AS显式指定;如果只是交实验报告,默认规则足够。这一点在跨库迁移时会被放大——同一句 SQL 在两个排序规则不同的库上跑,TOP 2拿到的可能是两个人。

分页场景下TOP的现代替代是OFFSET ... FETCH,SQL Server 2012 之后都支持。实验报告里没要求,但既然要练手,顺手写一遍对比不难:

-- 等价的分页写法,跳过前 2 行取 2 行 SELECT Sno, Sname FROM Stu WHERE Sname LIKE '李%' ORDER BY Sname OFFSET 2 ROWS FETCH NEXT 2 ROWS ONLY;

单表这十道题看着零散,其实覆盖了WHERE的比较与逻辑、LIKE模糊匹配、IS NULL判断、DISTINCT去重、日期函数、GROUP BY与HAVING、ORDER BY与TOP八个点。把这八个点各自写对一遍,再组合起来,多表查询的难度就只剩连接条件了。

4. 多表连接查询:内连接、外连接与自连接的八个场景

4.1 内连接:JOIN 写法和 WHERE 隐式连接等价吗

多表查询的第一组题是“查询选修了数据库原理的计算机系学生学号和姓名”,需要三张表串起来:学生表拿学号和姓名,选修表拿选课记录,课程表拿课程名做筛选。写法有两种,显式JOIN和WHERE里堆连接条件,结果等价,但可读性差很多。

-- 显式内连接:连接条件和过滤条件分开写 SELECT S.Sno, S.Sname FROM Stu AS S JOIN SC AS SC ON S.Sno = SC.Sno JOIN Cou AS C ON C.Cno = SC.Cno WHERE S.Sdept = '计算机系' AND C.Cname = '数据库原理'; -- 隐式连接:连接条件混在 WHERE 里,漏写一个就变成笛卡尔积 SELECT S.Sno, S.Sname FROM Stu AS S, SC AS SC, Cou AS C WHERE S.Sno = SC.Sno AND C.Cno = SC.Cno AND S.Sdept = '计算机系' AND C.Cname = '数据库原理';

两种写法在 SQL Server 里都会被查询优化器改写成同样的执行计划,性能上没有差别。真正的差别在维护:三张表还好,五张表以上的隐式连接,只要漏写一个连接条件,结果集行数立刻爆炸,而且不会报错,你只会觉得“怎么查出来这么多”。显式JOIN把连接条件锁在ON里,漏写会直接语法错误或者结果明显不对,容易发现得多。所以我带人写 SQL 时一律要求用显式JOIN,WHERE里只放过滤条件。

给表起别名这件事也值得说一句。Stu AS S里的AS在 SQL Server 中写不写都行,但列名有歧义时别名是必须的——三张表都有Sno或Cno,不写别名就报“列名不明确”。别名起得短一点(S、SC、C)能让长查询保持可读,起成a、b、c则过两周自己都看不懂。

4.2 外连接:把“没选课的学生”捞出来

内连接只保留两边都匹配上的行,这个特性在“查询所有学生的信息和他们的选课课程号”这类题上就不够用了——没选课的学生会被直接丢掉,而题目要的是“所有学生”。这时候得用左外连接,以学生表为左表,保证每个学生至少出现一次,没选课的课程号显示为NULL。

-- 左外连接:左表全保留,右表没匹配上就补 NULL SELECT S.Sno, S.Sname, S.Ssex, S.Sbirth, S.Sdept, S.Smemo, SC.Cno FROM Stu AS S LEFT JOIN SC AS SC ON S.Sno = SC.Sno;

判断“哪些学生一门课都没选”,可以直接在左外连接后面加WHERE SC.Sno IS NULL。这个套路在排查孤儿数据时非常常用:找出没有订单的用户、没有登录记录的账号、没有关联明细的主表行,写法都一样。反过来,如果题目问“所有课程的被选情况”,左表就要换成课程表,因为要保留的是课程而不是学生。

-- 找出没有任何选课记录的学生 SELECT S.Sno, S.Sname FROM Stu AS S LEFT JOIN SC AS SC ON S.Sno = SC.Sno WHERE SC.Sno IS NULL; -- 所有课程及其被选情况:课程表做左表 SELECT C.Cname, SC.Sno, SC.Cno, SC.Grade FROM Cou AS C LEFT JOIN SC AS SC ON C.Cno = SC.Cno;

一个常见误用是把左外连接写成LEFT JOIN之后又在WHERE里加右表的过滤条件,比如WHERE SC.Grade > 80。这个条件会把Grade为NULL的行全部干掉,左外连接退化成内连接,结果和预期差很远。如果确实要过滤右表又保留左表全部行,条件得挪到ON里,或者用OR SC.Grade IS NULL兜一下。这个区别在实验报告里不一定考,但工作中天天遇到。

还有一道“列出学生所有可能的选修情况”,考的是笛卡尔积,用CROSS JOIN或者省略连接条件都行。行数等于学生数乘课程数,五乘四得二十行,看着像错误结果,其实题目要的就是这个。写的时候心里要有数,别看到行数变多就以为写错了。

4.3 自连接:一门课的间接先行课怎么找

“查询每一门课的间接先行课”是实验 2 里最绕的一道,绕在它需要把课程表和自己连接一次。所谓间接先行课,就是先行课的先行课:C03 的直接先行课是 C02,C02 的直接先行课是 C01,那么 C03 的间接先行课就是 C01。用一次自连接就能拿到。

-- cur 是当前课程,pre 是它的直接先行课 -- pre 自己还有先行课,那个才是间接先行课 SELECT cur.Cno AS CourseNo, pre.Cpno AS IndirectPre FROM Cou AS cur JOIN Cou AS pre ON cur.Cpno = pre.Cno WHERE pre.Cpno IS NOT NULL;

理解这段的关键是分清两个别名各自的角色。cur代表“我要查的那门课”,cur.Cpno是它的直接先行课编号;拿这个编号去匹配pre.Cno,pre就是那门直接先行课;最后取pre.Cpno,也就是直接先行课自己的先行课。WHERE pre.Cpno IS NOT NULL把没有间接先行课的课程过滤掉,符合题目“有间接先行课才算”的要求。

自连接最容易翻车的地方是别名写混,比如ON cur.Cpno = pre.Cpno,语义变成“两门课有同一个先行课”,结果完全不对而且不报错。判断自连接写对没写对,最快的办法是拿两三行已知数据手推一遍,看输出是不是预期的那几条。用样例数据推:C01 无先行课,不出现;C02 的先行课是 C01,C01 无先行课,不出现;C03 的先行课是 C02,C02 的先行课是 C01,所以输出 C03 对应 C01。对上了就说明逻辑没问题。

4.4 三表连接的中间结果与行数核对

三表连接写多了会发现一个规律:结果行数主要由最细粒度的那张表决定。学号、课程名、成绩这道题,粒度是“某个学生选某门课”,也就是选修表的行数,连接只是把姓名和课程名补上去,不会增加行数。反过来,如果连接条件写错导致一对多变成多对多,行数会翻倍,而 SQL 不会给你任何提示。

-- 学号、姓名、课程名、成绩:行数应与 SC 表一致 SELECT S.Sno, S.Sname, C.Cname, SC.Grade FROM Stu AS S JOIN SC AS SC ON S.Sno = SC.Sno JOIN Cou AS C ON C.Cno = SC.Cno; -- 核对:两条语句的行数应该相等 SELECT COUNT(*) FROM SC;

我一般会在写完一条多表查询后,立刻用COUNT(*)和基准表对一下行数。对不上就是连接条件有问题,比一行行看结果快得多。如果查询里带了WHERE过滤,就先去掉过滤条件对总数,再加回过滤条件看比例是否合理。

“查询选修了课程的学生姓名”这道题还有个细节:同一个学生选了三门课,连接之后会出现三行,题目要的是姓名列表,所以要么加DISTINCT,要么用EXISTS半连接。两种写法的结果一样,执行计划通常也一样,选哪个看个人习惯。

-- 写法一:DISTINCT 去重 SELECT DISTINCT S.Sname FROM Stu AS S JOIN SC AS SC ON S.Sno = SC.Sno; -- 写法二:EXISTS 半连接,不产生重复行 SELECT S.Sname FROM Stu AS S WHERE EXISTS (SELECT 1 FROM SC WHERE SC.Sno = S.Sno);

后者在数据量大时通常更稳,因为它一旦匹配到就停,不需要把选修表的所有行都连出来再去重。实验数据量小,两种写法看不出差别,但习惯从实验阶段就养好,后面处理大表能省不少事。

5. 嵌套查询与集合运算排查:五类报错的现象、成因与改法

5.1 相关子查询与非相关子查询:执行顺序差在哪

实验 3 的六道题全部落在子查询和集合运算上,难度比实验 2 高一档。先分清两类子查询:非相关子查询可以单独拿出来跑,结果是一个固定集合,外层查询拿它当条件用;相关子查询引用了外层的列,必须跟着外层每一行跑一次。

“统计选修了数据库原理课程的学生人数”是非相关子查询的典型,甚至可以直接写成连接加聚合,不需要子查询。 “查询没有选修数据库原理课程的学生信息”就必须用相关子查询或者集合差,因为要针对每个学生判断“他有没有选”,外层每一行都要跑一次内层。

-- 非相关子查询:内层先算出课程号,外层拿来做条件 SELECT COUNT(*) AS StuCnt FROM SC WHERE Cno = (SELECT Cno FROM Cou WHERE Cname = '数据库原理'); -- 相关子查询:内层引用外层的 S.Sno,逐行判断 SELECT S.Sno, S.Sname, S.Ssex, S.Sbirth, S.Sdept, S.Smemo FROM Stu AS S WHERE NOT EXISTS ( SELECT 1 FROM SC JOIN Cou AS C ON C.Cno = SC.Cno WHERE SC.Sno = S.Sno AND C.Cname = '数据库原理' );

第一段里内层子查询只返回一个值,所以可以用=;如果课程表里有同名课程,内层返回多行就会报“子查询返回的值不止一个”。这种报错在实际项目里很常见,原因是写的时候假设了唯一性,数据一变就炸。稳妥做法是用IN或者加上TOP 1并明确排序规则。

“查询其他系中比计算机系学生年龄都小的学生”考的是ALL或者MAX。年龄小意味着出生日期晚,所以条件应该是出生日期大于计算机系所有学生的最大出生日期。写成< ALL (...)也能得到同样结果,但可读性差一些,而且ALL遇到空集合时返回真,这个语义边界要心里有数。

-- 用 MAX 表达“比计算机系所有学生都晚出生” SELECT Sno, Sname, Ssex, Sbirth, Sdept, Smemo FROM Stu WHERE Sdept <> '计算机系' AND Sbirth > (SELECT MAX(Sbirth) FROM Stu WHERE Sdept = '计算机系');

这里Sdept <> '计算机系'会把系名为空的学生过滤掉,如果数据里有这种记录且业务上算“其他系”,条件得改成Sdept <> '计算机系' OR Sdept IS NULL。实验数据里没有空系名,所以看不出来,真实数据里这属于必须处理的一类边界。

5.2 UNION、INTERSECT、EXCEPT:语义、去重与列顺序

三道集合运算的题目各要求两种写法,一种用集合运算符,一种用IN或EXISTS子查询,摆在一起对比着看最清楚。

UNION是并集,默认去重;UNION ALL保留重复。查询“被 0602001 或 0602002 选修的课程号”,用UNION正好,两个学生都选的课只出现一次,符合“或”的语义。

-- 并集:两个学生选过的课,重复的只留一条 SELECT Cno FROM SC WHERE Sno = '0602001' UNION SELECT Cno FROM SC WHERE Sno = '0602002'; -- 等价写法:IN 加 DISTINCT SELECT DISTINCT Cno FROM SC WHERE Sno IN ('0602001', '0602002');

INTERSECT是交集,“两个学生同时选修的课程号”。EXCEPT是差集,“0602001 选了但 0602002 没选的课程号”。这两个运算符都隐含去重,结果里不会有重复行,所以外层不用再加DISTINCT。

-- 交集:两人都选的课 SELECT Cno FROM SC WHERE Sno = '0602001' INTERSECT SELECT Cno FROM SC WHERE Sno = '0602002'; -- 差集:前者选了、后者没选的课 SELECT Cno FROM SC WHERE Sno = '0602001' EXCEPT SELECT Cno FROM SC WHERE Sno = '0602002';

用集合运算符写,语句短、语义直白,但有两个硬约束:两边的列数必须一致,对应列的数据类型必须兼容。列数不一致时报“使用 UNION、INTERSECT 和 EXCEPT 运算符组合的所有查询都必须具有相同数量的表达式”,这个报错信息很长,但问题往往就是某个SELECT多写了一列。另外,EXCEPT和INTERSECT的优先级高于UNION,混用的时候不加括号容易算错顺序。

对应的子查询写法分别是IN、EXISTS和NOT EXISTS。以交集为例,用EXISTS写出来是“0602001 选的每一门课,去选修表里找有没有 0602002 选它的记录”,语义和INTERSECT一致,但执行计划可能不同,数据量大时通常EXISTS更早停止。

-- 交集等价写法:对 0602001 的每门课,判断 0602002 是否也选了 SELECT DISTINCT A.Cno FROM SC AS A WHERE A.Sno = '0602001' AND EXISTS (SELECT 1 FROM SC AS B WHERE B.Sno = '0602002' AND B.Cno = A.Cno);

5.3 五个高频报错:现象、原因、改法

现象一:NOT IN的查询返回空结果,而数据明明存在。原因是被比较的子查询结果里含NULL。x NOT IN (1, 2, NULL)等价于x <> 1 AND x <> 2 AND x <> NULL,最后一项是UNKNOWN,整个条件永远不成立。改法有两种:子查询里加WHERE 列 IS NOT NULL,或者干脆换成NOT EXISTS,后者对NULL天然免疫。我现在的习惯是只要子查询可能返回空值,一律用NOT EXISTS。

现象二:先插课程 C02 再插 C01,报外码冲突 547。原因是课程表的Cpno是自引用外码,插入 C02 时它引用的 C01 还不存在。改法是先把Cpno插成NULL,全部课程插完后再用UPDATE补关系;或者临时ALTER TABLE Cou NOCHECK CONSTRAINT FK_Cou_Cpno,插完再启用,但这种方式在生产环境要慎重,容易留下脏数据。

现象三:统计选课门数时结果比预期少人。原因是用了COUNT(Grade),成绩为空的行没被计入。改法是换COUNT(*),它数的是行不是非空值。这个坑在订单金额、评论数、登录次数上完全一样,凡是“统计发生了多少次”都应该用COUNT(*),只有“统计有多少个非空值”才用COUNT(列)。

现象四:算出来的年龄偶尔多一岁。原因是DATEDIFF(YEAR, Sbirth, GETDATE())只比较年份部分,不管今年生日过没过。改法是加一次DATEADD修正,把“今年的生日”和今天比较,没到就减一。实验数据里生日分散,很容易撞上这个情况,看着像随机错误,其实有确定规律。

现象五:三表连接后行数暴涨。原因是漏写连接条件或者连接条件写错,产生了笛卡尔积。这种现象不会报错,只会让结果看起来“多了很多重复行”。改法是先单独COUNT(*)每张基表,再COUNT(*)连接结果,行数应该等于最细粒度那张表的行数;对不上就从连接条件逐个排查。多表查询写完先对行数,这个习惯能挡掉大部分低级错误。

6. 用双向 EXCEPT 验证两种写法等价,再谈索引

实验 3 的每道题都要求两种实现,最容易出问题的地方不是写不出来,而是写完不知道两种写法结果到底一不一致。肉眼看几十行数据不现实,我一般用双向EXCEPT做等价验证:把写法 A 的结果和写法 B 的结果互相做差集,两边都为空,就说明结果集完全一致。

-- 验证 UNION 写法与 IN 写法结果等价 -- 两个查询都只返回 Cno 一列,才能做集合运算 SELECT Cno FROM SC WHERE Sno = '0602001' UNION SELECT Cno FROM SC WHERE Sno = '0602002' EXCEPT SELECT DISTINCT Cno FROM SC WHERE Sno IN ('0602001', '0602002'); -- 反向再差一次 SELECT DISTINCT Cno FROM SC WHERE Sno IN ('0602001', '0602002') EXCEPT SELECT Cno FROM SC WHERE Sno = '0602001' UNION SELECT Cno FROM SC WHERE Sno = '0602002';

两段都返回空结果,才敢在实验报告里写“两种方法结果相同”。只做单向差集是不够的,A 减 B 为空只能说明 A 是 B 的子集,反过来可能漏行。这个手法在迁移 SQL、改写查询、对比新旧存储过程结果时都通用,比人工对眼睛可靠得多。

第二个值得顺手做的事是看执行计划。实验数据量小,加不加索引都是毫秒级,看不出差别,但把选修表的数据复制到几万行再跑一次,差异就出来了。选修表上按学号查是最常见的访问路径,PK_SC的联合主码已经把Sno放在第一列,等于自带一个可用索引;如果查询条件经常是单独按课程号过滤,那就需要额外建一个Cno上的索引。

验证手法适用场景判断标准
双向 EXCEPT两种写法结果比对两个方向都返回 0 行
COUNT 对行数多表连接是否产生笛卡尔积连接结果行数等于最细粒度基表行数
执行计划判断是否走索引出现 Index Seek 而非 Table Scan
加索引前后计时验证索引收益同一查询耗时明显下降

建索引的语句本身很简单,关键是别乱建。SC表上已经有联合主码,再给Sno单独建索引就是冗余;给Grade建索引收益也很低,因为成绩的选择性差,查 60 分以下和查 90 分以上可能各占一半数据,优化器宁愿全表扫。真正值得建的是外码列和高选择性过滤列,比如经常按课程号统计选课人数,Cno上加索引就有意义。

-- 按课程号统计时给外码列补索引;IF NOT EXISTS 避免重复建 IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SC_Cno' AND object_id = OBJECT_ID('SC')) CREATE NONCLUSTERED INDEX IX_SC_Cno ON SC(Cno);

验证索引有没有被用上,不要只看“变快了”,要看执行计划里是不是出现了Index Seek。如果还是Table Scan,可能是统计信息没更新,跑一句UPDATE STATISTICS SC再看;也可能是查询本身写成了SELECT *或者对列做了函数运算,导致索引失效,比如WHERE YEAR(Sbirth) = 1999就走不了Sbirth上的索引,得改成范围比较。

写完这套东西之后我养成了一个习惯:任何一条多表查询或者子查询,在交给别人之前,先用COUNT(*)对一遍行数,再用双向EXCEPT和自己想当然的另一种写法比一遍,两个都过了才敢说“这条 SQL 是对的”。有次偷懒跳过这一步,交上去的统计结果里少了一个没出成绩的学生,被追问了半天才定位到COUNT(Grade)上。从那以后我每次写聚合查询都强制走一遍行数核对,宁可多花两分钟,也不想再解释一遍为什么数字对不上。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进