简介:这份南华大学数据库原理实验报告面向大数据技术及相关专业学生,围绕数据库课程实验整理,适合用于实验预习、课堂对照与期末复盘。内容以SQL Server Management Studio为操作环境,覆盖认识DBMS、创建“学生选课”数据库与三张数据表、定义主码外码和默认值,以及单表查询、多表连接查询、高级SQL查询和数据更新等实验环节;同时保留实验题目、实验要求、实验代码与总结,便于理解学生、课程、选修表之间的一对多和多对多关系,并练习SELECT语句在条件筛选、排序、分组和连接操作中的写法。资源包共1个doc文档,压缩后约5.38MB,结构完整,可直接查阅或打印。已有377人学习,适合需要跟随实验步骤完成建库建表、查询练习和报告整理的数据库原理学习者参考。
1. 数据库原理实验报告,为什么值得当成一个可跑的项目来拆解
某高校的数据库课,实验指导书写得规规矩矩,真到自己上手,第一道坎往往不是 SQL 语法,而是环境:SSMS 里新建库排序规则选错,中文存进去变问号;先建了子表,插入第一条选课记录就报 547;GROUP BY和SELECT的列对不上,报错信息绕三圈才看懂。这份数据库原理实验报告的价值就在这里——它把「认识 DBMS → 建库建表 → 单表查询 → 多表连接 → 嵌套与集合查询 → 数据更新」整条链路走了一遍,落到具体的库名、表名、字段和约束上。刚上手的学生可以照着复现,SQL 只停留在SELECT *的开发者也能拿它当练习清单。
2. 建库建表:从 SSMS 图形界面到 T-SQL 脚本的两条路
实验一在报告里占了最前面一截,做的事情其实就三件:建一个「学生选课」库、在里面建三张表、把表之间的关系连起来。报告把它拆成两条路走,一条用 SQL Server Management Studio 的图形界面点出来,另一条用纯 SQL 语句写出来。两条路都走一遍是有道理的,图形界面让你直观看到「主码在哪一栏填」「外键关系图长什么样」,脚本则让你能把这套结构存进版本库、换台机器一条命令重建。
2.1 图形化建库与脚本建库的取舍
图形化建库的入口是对象资源管理器里右键「数据库 → 新建数据库」,弹窗里填库名、设初始大小和文件增长方式。这套流程对第一次接触 DBMS 的人很友好,能看清数据库在物理上就是一对.mdf加.ldf文件。但它的代价是过程不可复现——你在弹窗里点了什么、改了什么排序规则,半年后没人记得,换台机器就得重来一遍。
脚本建库把同样的动作写成CREATE DATABASE,好处是这份脚本本身就是文档。我一般会让学生先把图形界面做一遍,然后右键数据库选「编写数据库脚本为 → CREATE」,把 SSMS 自己生成的脚本拷出来对照着看,这一招比看教材管用得多,因为它把界面上的每个选项翻译成了看得见的参数。
| 对比项 | SSMS 图形化 | T-SQL 脚本 |
|---|---|---|
| 上手难度 | 低,点选即可 | 需要记语法 |
| 可复现性 | 差,操作不留痕 | 好,脚本可入库 |
| 适合场景 | 第一次认识 DBMS | 重建、交付、多人协作 |
| 出错时的排查 | 只能看错误弹窗 | 能逐句注释掉重跑 |
两种方式在报告里并列出现,本质上是在教同一件事的两种表达,不是让你二选一。
2.2 三张表的主码、外码、默认值怎么定
报告给出的结构很经典:学生200905、课程200905、选修200905,前两张是实体表,第三张是联系表。落地成 SQL Server 能执行的版本,我一般会把库名统一成StuCou200905,表名用Stu200905、Cou200905、SC200905,避免中文表名在某些客户端工具里被转义得乱七八糟。
字段设计上有几个决定要提前想清楚:
- 学号用
CHAR(7),因为题目里是0602001这种定长编号;课程号用CHAR(3),对应C01。定长类型在索引里比较紧凑,等值查询也快。 - 姓名、院系名称这类中文列必须用
NVARCHAR,不能用VARCHAR,这是后面中文乱码坑的根源。 - 性别用
NCHAR(2)并加CHECK约束,把取值范围锁死在「男/女」,同时给一个默认值N'男',这样插入时不写这一列也有值。 - 学分给默认值
2,省得每门课都手填。 - 分数用
DECIMAL(5,1),允许为NULL,因为「选了课但还没出成绩」是真实存在的状态,用 0 分表示反而会污染平均分。
主码和外码的划分如下:
| 表名 | 主码 | 外码 | 默认值 |
|---|---|---|---|
Stu200905 | 学号 | 无 | 性别默认男 |
Cou200905 | 课程号 | 先行课 → 本表课程号(自引用) | 学分默认2 |
SC200905 | 学号 + 课程号(复合主码) | 学号、课程号分别引用父表 | 无 |
选修表用复合主码而不是另加自增 ID,是因为「同一个学生同一门课只能有一条成绩」这个业务规则,复合主码天然就能表达,不用再写额外的唯一约束。这一点在做二次开发时很关键,后面讲自动判分还会用到。
2.3 建表脚本逐段拆解与灌数据
下面是整理后的建库建表脚本,可以直接在 SSMS 查询窗口里整段执行。GO是 SSMS 的批处理分隔符,不是 T-SQL 语句,遇到它会把前面的语句作为一个批次提交。
-- 建库:显式指定中文排序规则,避免后续比较中文时出现奇怪结果 CREATE DATABASE StuCou200905 ON PRIMARY ( NAME = N'StuCou200905_data', FILENAME = N'D:\SQLData\StuCou200905.mdf', SIZE = 8MB, FILEGROWTH = 8MB ) LOG ON ( NAME = N'StuCou200905_log', FILENAME = N'D:\SQLData\StuCou200905_log.ldf', SIZE = 8MB, FILEGROWTH = 8MB ); GO USE StuCou200905; GO -- 学生表:主码学号,性别加 CHECK 和默认值 CREATE TABLE Stu200905 ( 学号 CHAR(7) NOT NULL, 姓名 NVARCHAR(20) NOT NULL, 性别 NCHAR(2) NOT NULL CONSTRAINT DF_Stu_sex DEFAULT N'男', 出生日期 DATE NULL, 院系名称 NVARCHAR(30) NULL, 备注 NVARCHAR(50) NULL, CONSTRAINT PK_Stu200905 PRIMARY KEY (学号), CONSTRAINT CK_Stu_sex CHECK (性别 IN (N'男', N'女')) ); GO -- 课程表:先行课自引用本表课程号,学分默认 2 CREATE TABLE Cou200905 ( 课程号 CHAR(3) NOT NULL, 课程名 NVARCHAR(30) NOT NULL, 先行课 CHAR(3) NULL, 学分 TINYINT NOT NULL CONSTRAINT DF_Cou_credit DEFAULT 2, CONSTRAINT PK_Cou200905 PRIMARY KEY (课程号), CONSTRAINT FK_Cou_pre FOREIGN KEY (先行课) REFERENCES Cou200905(课程号) ); GO -- 选修表:复合主码,两个外码都指向父表 CREATE TABLE SC200905 ( 学号 CHAR(7) NOT NULL, 课程号 CHAR(3) NOT NULL, 分数 DECIMAL(5,1) NULL CONSTRAINT CK_SC_score CHECK (分数 BETWEEN 0 AND 100), CONSTRAINT PK_SC200905 PRIMARY KEY (学号, 课程号), CONSTRAINT FK_SC_stu FOREIGN KEY (学号) REFERENCES Stu200905(学号), CONSTRAINT FK_SC_cou FOREIGN KEY (课程号) REFERENCES Cou200905(课程号) ); GO这段脚本里有几个点值得单独说。第一,FILENAME里的目录必须提前存在,SQL Server 不会帮你创建文件夹,路径不存在会直接报错。第二,课程表的FK_Cou_pre是自引用外码,SQL Server 允许在同一句CREATE TABLE里定义,但插入数据时顺序有讲究:必须先把没有先行课的课程插进去,再插依赖它的课程,否则外码校验过不去。第三,CHECK (分数 BETWEEN 0 AND 100)允许NULL通过,因为CHECK对NULL的结果是「未知」,视为通过,这正好符合「成绩未出」的业务场景。
数据灌入的顺序必须是学生 → 课程 → 选修。课程这边因为有自引用,还要再排一次序:
-- 学生数据:学号、姓名、性别、出生日期、院系 INSERT INTO Stu200905 (学号, 姓名, 性别, 出生日期, 院系名称) VALUES ('0602001', N'李明', N'男', '2000-03-12', N'计算机系'), ('0602002', N'王芳', N'女', '1999-11-05', N'计算机系'), ('0602003', N'张伟', N'男', '2001-06-21', N'计算机系'), ('0602004', N'李娜', N'女', '2000-01-30', N'数学系'), ('0602005', N'陈强', N'男', '1999-08-14', N'数学系'); -- 课程数据:先插没有先行课的课程,再插有先行课的 INSERT INTO Cou200905 (课程号, 课程名, 先行课, 学分) VALUES ('C01', N'数据库原理', NULL, 3), ('C02', N'数据结构', NULL, 3), ('C03', N'操作系统', 'C02', 4), ('C04', N'数据库设计', 'C01', 2); -- 选修数据:学号和课程号都必须在父表里存在 INSERT INTO SC200905 (学号, 课程号, 分数) VALUES ('0602001', 'C01', 88.5), ('0602001', 'C02', 76.0), ('0602002', 'C01', 92.0), ('0602002', 'C03', 55.0), ('0602003', 'C01', 45.0), ('0602003', 'C02', 61.5), ('0602004', 'C02', 70.0);灌完数据可以拿一句SELECT * FROM SC200905验证,如果中文显示成问号,问题一定出在字符串字面量没加N前缀或者列类型用了VARCHAR,这个坑后面第 5 章还会细讲。
2.4 数据更新:INSERT、UPDATE、DELETE 的边界
实验四讲的是数据更新,这部分内容在报告里被截掉了,但从整个实验链路看,它要练的无非是增删改三件事,以及这三件事和外键约束的配合。
INSERT的坑集中在外键顺序,前面已经踩过一遍。UPDATE的坑在于忘写WHERE——一句UPDATE SC200905 SET 分数 = 60会把全表成绩抹平,而且默认自动提交,没有后悔药。DELETE同理,DELETE FROM Stu200905会先撞上外码约束报错,因为选修表里还有引用;如果先删了选修表再删学生表,数据就真没了。
所以实操时我习惯先开一个显式事务,确认影响行数对了再提交:
BEGIN TRANSACTION; -- 只改学号 0602003 的数据库原理成绩 UPDATE SC200905 SET 分数 = 58.0 WHERE 学号 = '0602003' AND 课程号 = 'C01'; -- 确认影响行数:应该是 1 行,如果显示 7 行说明 WHERE 写漏了 SELECT @@ROWCOUNT AS 影响行数; -- 数字对得上再提交,对不上就 ROLLBACK COMMIT TRANSACTION; -- ROLLBACK TRANSACTION;@@ROWCOUNT返回上一条语句影响的行数,这是我在做批量更新时最依赖的一个内置变量,比事后翻日志快得多。删除操作也一样,先把DELETE换成同条件的SELECT跑一遍,看清楚要删的是哪几行,再改写成DELETE,这个习惯能省掉大量数据恢复的麻烦。
3. 单表查询:WHERE、聚合函数和日期处理的十道题
实验二上半部分是十道单表查询题,题目本身不难,难在每个条件都在考一个具体的语法点:LIKE、IS NULL、IN、BETWEEN、GROUP BY、HAVING、TOP、ORDER BY。把这十道题按考点归个类,比一道一道抄答案学得快。
3.1 条件筛选:系别、姓氏、先行课、不及格
前五道题基本都在WHERE子句上做文章:
-- 1. 计算机系全体学生,列全字段 SELECT 学号, 姓名, 性别, 出生日期, 院系名称, 备注 FROM Stu200905 WHERE 院系名称 = N'计算机系'; -- 2. 姓李的学生:LIKE 匹配前缀,% 表示任意长度 SELECT 学号, 姓名 FROM Stu200905 WHERE 姓名 LIKE N'李%'; -- 3. 没有先行课的课程:NULL 不能用 = 比较,必须用 IS NULL SELECT 课程名 FROM Cou200905 WHERE 先行课 IS NULL; -- 4. 有不及格成绩的学生学号:DISTINCT 去重,因为一个学生可能挂多门 SELECT DISTINCT 学号 FROM SC200905 WHERE 分数 < 60; -- 5. 选了 C01 或 C02 的学号及成绩:IN 比 OR 好读 SELECT 学号, 课程号, 分数 FROM SC200905 WHERE 课程号 IN ('C01', 'C02');这里最值得记的是第 3 题。先行课 = NULL永远返回未知,结果集是空的,这不是玄学,是 SQL 的三值逻辑:NULL表示「不知道」,任何和它做的比较都得不到真值,只能用IS NULL或IS NOT NULL判断。第 4 题的DISTINCT也容易漏,一个学生挂了两门,不去重就会在结果里出现两次同一个学号。
第 2 题的N'李%'前缀不能丢。姓名列是NVARCHAR,如果字面量写成'李%',SQL Server 会先把它当成非 Unicode 字符串处理再隐式转换,在某些排序规则下会匹配不到,或者排序结果和预期不一致。
3.2 GROUP BY + HAVING:选修门数与平均分
第 9、10 两题是单表查询里唯一涉及聚合的,也是最容易写错的:
-- 9. 选修两门以上课程的学生学号与课程数 SELECT 学号, COUNT(*) AS 课程数 FROM SC200905 GROUP BY 学号 HAVING COUNT(*) > 2; -- 10. 选修门数 >= 2 的学生,列出学号、平均分、门数,按平均分降序 SELECT 学号, AVG(分数) AS 平均成绩, COUNT(*) AS 选课门数 FROM SC200905 GROUP BY 学号 HAVING COUNT(*) >= 2 ORDER BY AVG(分数) DESC;这两题的考点其实有两层。第一层是WHERE和HAVING的分工:WHERE在分组之前过滤行,HAVING在分组之后过滤组,第 10 题里「门数大于等于 2」是对分组结果的条件,只能写在HAVING里。第二层是COUNT(*)和COUNT(分数)的区别——COUNT(*)数行数,COUNT(分数)只数分数非空的行。如果某个学生选了课但成绩还没录,分数是NULL,这两个函数算出来的数字是不一样的。
第 9 题题目写的是「两门以上」,中文里这个词有歧义。写成> 2是「三门及以上」,写成>= 2是「两门及以上」。第 10 题明确写了「大于等于 2」,用的是>=;第 9 题如果按字面理解应该用>。遇到这种题目,别硬猜,把两种写法都在自己库上跑一遍,看看哪个结果更符合出题人想要的数量级,这比纠结措辞有效。
AVG(分数)在算平均时同样会跳过NULL行,分母是「有成绩的门数」而不是「选课门数」。如果业务上希望把没成绩的按 0 算,得写成AVG(ISNULL(分数, 0)),这两种口径在成绩分析系统里差别很大,值得在实验阶段就分清楚。
3.3 日期与年龄:DATEDIFF、YEAR 与 BETWEEN 的写法
第 6、7 两题把日期类型拉进来了:
-- 6. 计算机系学生的姓名与年龄 SELECT 姓名, YEAR(GETDATE()) - YEAR(出生日期) AS 年龄 FROM Stu200905 WHERE 院系名称 = N'计算机系'; -- 7. 计算机系 1999—2000 年出生的学生姓名 SELECT 姓名 FROM Stu200905 WHERE 院系名称 = N'计算机系' AND 出生日期 >= '1999-01-01' AND 出生日期 < '2001-01-01';第 6 题用YEAR相减算年龄,结果是不精确的——一个 2000 年 12 月出生的人,在 2022 年 1 月查出来的「年龄」是 22,实际才 21 岁。精确算法要用DATEDIFF(YEAR, 出生日期, GETDATE())再减去一个修正项,或者直接用DATEDIFF加上生日是否已过的判断。实验阶段用粗略算法可以理解,但要知道它不精确,别在正式项目里照抄。
第 7 题的写法有个细节:没有用BETWEEN '1999-01-01' AND '2000-12-31',而是用了>=加<的开闭区间。原因是如果出生日期列的类型是DATETIME而不是DATE,2000-12-31等价于2000-12-31 00:00:00,当天中午 12 点出生的人会被漏掉。用「左闭右开」加下一天零点,能规避掉时间部分的干扰,这是处理日期区间时最稳的写法,我在生产环境里基本不用BETWEEN包日期。
3.4 TOP 与排序:字典序前两名怎么取
第 8 题看起来最简单,其实藏着一个常被忽略的前提:
-- 8. 姓李的学生按姓名字典序排序,取前两个 SELECT TOP 2 学号, 姓名 FROM Stu200905 WHERE 姓名 LIKE N'李%' ORDER BY 姓名 ASC;TOP必须和ORDER BY一起才有意义。不写ORDER BY时,SQL Server 返回的是「它认为方便的顺序」,可能按聚集索引,也可能按执行计划里的扫描顺序,同一句 SQL 跑两次结果不一样是常事。这属于典型的「本地能跑通、上线就翻车」场景——小表上看不出问题,数据量一上来或者索引一改,取出来的两条就换人了。
另外,「字典序」在中文环境下的排序结果取决于数据库的排序规则。用Chinese_PRC_CI_AS这类规则时,中文姓名按拼音排序;如果库的排序规则是Latin1_General,中文可能按 Unicode 码点排,结果和拼音顺序对不上。实验里不容易察觉,但换成真实姓名数据就会暴露,建库时把排序规则定下来,比事后改要省事得多。
4. 多表与嵌套:JOIN、自连接、EXISTS 与集合运算
实验二下半部分和实验三整块,是这个报告里分量最重的部分。前面单表查询的题,写错了顶多结果不对;这里写错了,要么结果翻倍,要么直接空集,而且报错信息往往指不到真正的问题所在。
4.1 三表内连接:别名与连接条件的书写顺序
最典型的一道是「查询选修了数据库原理的计算机系学生」:
-- 学生 → 选修 → 课程,三表内连接,连接条件写清谁对谁 SELECT s.学号, s.姓名 FROM Stu200905 s JOIN SC200905 sc ON s.学号 = sc.学号 JOIN Cou200905 c ON sc.课程号 = c.课程号 WHERE c.课程名 = N'数据库原理' AND s.院系名称 = N'计算机系';这里给每张表起了别名s、sc、c,不是图省事,而是必须——三张表里有两张都有「学号」列,不加前缀 SQL Server 会直接报「列名不明确」。别名的另一个作用是让连接条件的意图清楚:ON s.学号 = sc.学号一眼能看出是从学生表连到选修表。
连接条件和过滤条件的分工也要留意。ON里写的是「两张表怎么对上」,WHERE里写的是「对结果再怎么筛」。在INNER JOIN里这两者写混了结果一样,但在LEFT JOIN里写混了结果天差地别,第 4.3 节会看到。
「查询学生的学号、姓名、选修课程的名称和成绩」是同一套三表连接,只是把WHERE换成不筛选,把所有选修记录都列出来:
SELECT s.学号, s.姓名, c.课程名, sc.分数 FROM Stu200905 s JOIN SC200905 sc ON s.学号 = sc.学号 JOIN Cou200905 c ON sc.课程号 = c.课程号;「查询选修了课程的学生姓名」则只需要两表,并且要去重:
SELECT DISTINCT s.姓名 FROM Stu200905 s JOIN SC200905 sc ON s.学号 = sc.学号;不去重的后果是,一个选了四门课的学生会在结果里出现四次。很多同学第一次跑出重复姓名,会怀疑是连接写错了,其实是忘了DISTINCT。
4.2 自连接:间接先行课这种题怎么想
「查询每一门课的间接先行课」是整份报告里最有意思的一道题,因为它要求把同一张表当成两张表来用:
-- c1 代表当前课程,c2 代表它的先行课,两者的先行课字段再对一次 SELECT c1.课程号 AS 课程号, c2.先行课 AS 间接先行课 FROM Cou200905 c1 JOIN Cou200905 c2 ON c1.先行课 = c2.课程号 WHERE c2.先行课 IS NOT NULL;思路是这样的:一门课的先行课是X,X自己还有先行课Y,那Y就是这门课的间接先行课。第一步把c1和c2按「c1的先行课 =c2的课程号」连起来,此时c2就是c1的直接先行课;第二步再加一个c2.先行课 IS NOT NULL,把「直接先行课自己还有先行课」的那些挑出来,取c2.先行课就是答案。
自连接的关键是别名。没有别名,Cou200905出现两次,SQL Server 根本不知道你指的是哪一份。另一个容易翻车的点是WHERE条件漏写——漏了之后,所有没有间接先行课的课程也会被连出来,间接先行课列显示NULL,看起来像是多了一堆「错误数据」,其实是内连接把c2.先行课 = NULL的那些行按未知处理掉了,只剩下一部分,结果既不全也不对。想要「有间接先行课才算」,WHERE这一句必须留着。
4.3 外连接:把没选课的学生和没人选的课捞出来
「查询所有学生的信息和他们的选课课程编号」和「查询所有课程的名字以及课程被选修的情况」,这两道题的关键词是「所有」。内连接只返回两边都匹配上的行,没选课的学生会整个消失,题目要的恰恰是这些人也要出现,课程号显示为空。这时候必须换成LEFT JOIN:
-- 所有学生,哪怕一门课都没选也要出现 SELECT s.学号, s.姓名, s.性别, s.出生日期, s.院系名称, s.备注, sc.课程号 FROM Stu200905 s LEFT JOIN SC200905 sc ON s.学号 = sc.学号; -- 所有课程,哪怕没人选也要出现 SELECT c.课程名, sc.学号, c.课程号, sc.分数 FROM Cou200905 c LEFT JOIN SC200905 sc ON c.课程号 = sc.课程号;LEFT JOIN保留左表全部行,右表匹配不上就填NULL。第一句里左表是学生表,所以没选课的学生会带着一串NULL出现在结果末尾;第二句左表换成课程表,没人选的课程就冒出来了。
这里最容易犯的错是把过滤条件写进ON而不是WHERE。比如想查「所有学生以及他们数据库原理这门课的成绩」,写成LEFT JOIN SC200905 sc ON s.学号 = sc.学号 AND sc.课程号 = 'C01',没选这门课的学生仍然会保留,只是成绩列为空;如果写成WHERE sc.课程号 = 'C01',LEFT JOIN就退化成了内连接,没选这门课的学生全被WHERE过滤掉了。这两个写法的结果差异,是外连接题里最常见的失分点。
还有一道「列出学生所有可能的选修情况」,用的是笛卡尔积:
SELECT s.学号, s.姓名, c.课程号, c.课程名 FROM Stu200905 s CROSS JOIN Cou200905 c;结果行数等于学生数乘课程数。这道题的目的是让你亲眼看到「不加连接条件会炸出多少行」——这也是后面排查「结果突然翻了几十倍」时的第一直觉:是不是少写了一行ON。
4.4 嵌套与集合运算:UNION、INTERSECT、EXCEPT 与子查询互译
实验三整块都在做嵌套查询和组合查询,六道题几乎两两成对,每一对都在用两种写法解同一个问题。这种「双解对照」的设计,比单做一遍有效得多。
先看统计类:
-- 统计选修了数据库原理的学生人数:子查询当条件用 SELECT COUNT(*) AS 人数 FROM SC200905 WHERE 课程号 = ( SELECT 课程号 FROM Cou200905 WHERE 课程名 = N'数据库原理' );「没有选修数据库原理的学生信息」这道题,报告用的是NOT IN或NOT EXISTS。这里有个必须知道的坑:如果子查询结果里含NULL,NOT IN会返回空集。原因是x NOT IN (a, b, NULL)等价于x <> a AND x <> b AND x <> NULL,最后一项永远是「未知」,整个条件就永远不成立。稳妥的写法是NOT EXISTS:
-- 没选修数据库原理的学生:用 NOT EXISTS,避开 NOT IN 的 NULL 陷阱 SELECT s.学号, s.姓名, s.性别, s.出生日期, s.院系名称, s.备注 FROM Stu200905 s WHERE NOT EXISTS ( SELECT 1 FROM SC200905 sc WHERE sc.学号 = s.学号 AND sc.课程号 = ( SELECT 课程号 FROM Cou200905 WHERE 课程名 = N'数据库原理' ) );EXISTS只关心子查询有没有返回行,不关心返回什么,所以子查询里写SELECT 1就够了,不需要SELECT *。这是个小细节,但能省掉一次没必要的列解析。
「其他系中比计算机系学生年龄都小」用的是ALL:
SELECT 学号, 姓名, 性别, 出生日期, 院系名称, 备注 FROM Stu200905 WHERE 院系名称 <> N'计算机系' AND 出生日期 > ALL ( SELECT 出生日期 FROM Stu200905 WHERE 院系名称 = N'计算机系' );方向别搞反:年龄小等于出生日期晚,日期数值更大,所以是>而不是<。另外ALL在子查询返回空集时结果为真,整个条件退化成只剩系别筛选,这一点和NOT IN的行为正好相反,写的时候心里要有数。
再看三组成对的集合运算。UNION 和 IN 那组:
-- 方法一:UNION 组合查询,自动去重 SELECT 课程号 FROM SC200905 WHERE 学号 = '0602001' UNION SELECT 课程号 FROM SC200905 WHERE 学号 = '0602002'; -- 方法二:IN 条件查询 SELECT DISTINCT 课程号 FROM SC200905 WHERE 学号 IN ('0602001', '0602002');UNION会对两个结果集做去重,UNION ALL不去重。题目要的是「被这两人中任意一个选修的课程号」,重复的课程号只该出现一次,所以用UNION;如果换成UNION ALL,两人都选的课会出现两次。
INTERSECT 和 EXISTS 那组:
-- 两人同时选修的课程号:INTERSECT 求交集,天然去重 SELECT 课程号 FROM SC200905 WHERE 学号 = '0602001' INTERSECT SELECT 课程号 FROM SC200905 WHERE 学号 = '0602002'; -- 等价写法:EXISTS 相关子查询 SELECT DISTINCT a.课程号 FROM SC200905 a WHERE a.学号 = '0602001' AND EXISTS ( SELECT 1 FROM SC200905 b WHERE b.学号 = '0602002' AND b.课程号 = a.课程号 );EXCEPT 和 NOT EXISTS 那组:
-- 0602001 选了但 0602002 没选的课程号 SELECT 课程号 FROM SC200905 WHERE 学号 = '0602001' EXCEPT SELECT 课程号 FROM SC200905 WHERE 学号 = '0602002'; -- 等价写法:NOT EXISTS SELECT DISTINCT a.课程号 FROM SC200905 a WHERE a.学号 = '0602001' AND NOT EXISTS ( SELECT 1 FROM SC200905 b WHERE b.学号 = '0602002' AND b.课程号 = a.课程号 );三组对照下来能看出一个规律:集合运算写起来短、语义直接,适合「两个独立结果集做交并差」的场景;子查询写法更啰嗦,但能嵌进更复杂的条件里,也能配合JOIN拿到其他列的数据。实际项目里我一般先用集合运算把逻辑跑对,确认结果没问题后再看执行计划决定要不要改写成EXISTS——SQL Server 对这两种写法的优化路径不完全一样,数据量大的时候差异会体现出来。
5. 避坑排查:跑这份报告时最容易翻车的五个点
实验报告的参考答案是静态的,真在自己机器上敲,报错会以各种各样的形式冒出来。下面这五条是我带人复现这份报告时见得最多的,每条都按「现象 → 原因 → 解决」写清楚。
5.1 建表与灌数阶段的坑
坑一:中文全部变成问号,或者插入中文直接报错。
现象是INSERT语句里写N'李明'没问题,写'李明'也不报错,但SELECT出来全是???。原因有两层:一是字符串字面量没加N前缀,SQL Server 会按当前数据库的非 Unicode 代码页去解释这个字符串,中文超出了代码页范围就被替换成问号;二是列本身用了VARCHAR而不是NVARCHAR,即使字面量带了N,存进去也会被转换。
解决办法是两条一起改:所有存中文的列一律用NVARCHAR(n)或NCHAR(n),所有中文字符串字面量一律加N前缀。改完如果还有问题,查一下库的排序规则,SELECT DATABASEPROPERTYEX('StuCou200905', 'Collation')能看到当前值,中文环境一般应该是Chinese_PRC_CI_AS这类。
坑二:插入选修记录时报 547,提示与外键约束冲突。
现象是父表数据明明插进去了,INSERT INTO SC200905还是报「INSERT 语句与 FOREIGN KEY 约束冲突」。原因基本只有两种:学号或课程号在父表里不存在(比如多打了一个空格,CHAR类型会补尾随空格,肉眼看不出来),或者插入顺序反了,先插了选修再插学生。
解决方式是先跑一句SELECT 学号 FROM Stu200905 WHERE 学号 = '0602001'确认父表里确实有这条,再检查插入脚本的顺序。课程表因为有自引用外码,还要额外确认「先行课指向的那门课已经插进去了」,否则同样是 547。排查这类问题时,CHAR类型的尾随空格特别阴险,LEN()会忽略尾部空格而DATALENGTH()不会,用后者能看出差异。
5.2 查询阶段的坑
坑三:NOT IN的子查询里有NULL,结果直接空集。
现象是「查询没有选修数据库原理的学生」,用NOT IN写出来的语句一条结果都没有,但库里明明有没选这门课的学生。原因前面提过:子查询返回的集合里如果混进了NULL,NOT IN的整个条件恒为未知,一行都匹配不上。课程表里「先行课」这一列就天然带NULL,一旦子查询牵扯到它,问题立刻出现。
解决办法是换成NOT EXISTS,或者给子查询加WHERE 列 IS NOT NULL把空值排掉。我现在的习惯是只要写否定条件,优先用NOT EXISTS,不去赌子查询里有没有NULL。
坑四:GROUP BY报「选择列表中的列无效」。
现象是SELECT 学号, 姓名, COUNT(*) FROM SC200905 GROUP BY 学号报错,提示姓名不在聚合函数或GROUP BY子句中。原因是 SQL Server 要求SELECT列表里的每一列,要么出现在GROUP BY里,要么被聚合函数包住,否则它不知道该取组里哪一行的值。
解决办法是把姓名也加进GROUP BY:GROUP BY 学号, 姓名。因为学号是学生表的主码,姓名由学号唯一决定,加进去不会改变分组粒度。如果不想改GROUP BY,也可以写MAX(姓名),但那是绕过去而不是解决问题,语义上也别扭。
坑五:日期区间用BETWEEN漏掉了边界当天的数据。
现象是查「1999—2000 年出生的学生」,用BETWEEN '1999-01-01' AND '2000-12-31',结果少了 2000 年 12 月 31 日当天出生的人。原因是列类型如果是DATETIME,'2000-12-31'会被解释成当天零点,零点之后出生的全部落在区间外。
解决办法是改成左闭右开:>= '1999-01-01' AND < '2001-01-01'。这个写法对DATE和DATETIME都成立,不用关心列的具体类型。同理,TOP不带ORDER BY也是同一类问题——看起来能跑,结果不稳定,只是它不报错,更难发现。
6. 二次开发:把这份报告改成一个可复用的 SQL 练习题库
这份报告最有价值的不是那几十行参考答案,而是它把题目按「单表 → 多表 → 嵌套 → 集合运算」排了个序。把这个顺序固化下来,再配上一套自动比对机制,它就能变成一个可以反复练的题库,而不是一份看完就扔的 PDF。
6.1 把题目抽成数据
第一件事是把题干、知识点、参考答案从文档里拆出来,落到一张表里:
CREATE TABLE SqlQuestion ( Id INT IDENTITY PRIMARY KEY <p> <a href="https://download.csdn.net/download/m0_52622059/86514956" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>