简介:这是一份数据库系统概论课程的SQL练习表资源,面向正在学习数据库原理与SQL操作的高校学生。文档围绕学生选课经典场景,提供了创建sql_test数据库,以及student、course、sc三张表的完整建表代码,分别定义了学号、姓名、性别、年龄、系别和课程号、课程名、预修课程、学分以及选课成绩等字段,涉及主键、外键、唯一约束、默认值等设计,并按参照完整性要求给出了数据插入与更新的顺序说明。包体为单个PDF,大小48KB,内容紧凑,目前已获1907人浏览学习。借助该文档,读者可直接复制运行建表及示例数据语句,快速搭建练习环境;也可通过course自关联外键和sc双外键的示例,理解关系模型中的完整性约束对数据操作顺序的影响。该PDF适合数据库课程上机练习、期末复习或自学入门使用。
1. 数据库系统概论的经典练习表:student、sc、course 到底能练什么
刚学数据库系统概论的人,最容易卡在找练习数据这一步:网上的建表脚本五花八门,不是缺外键就是数据对不上书上的例子。这份 PDF 给的是教材最经典的 student、sc、course 三张表,配套《数据库系统概论》里几乎所有 SQL 练习场景。别小看这三张表,它同时覆盖了主键、外键、自引用外键、联合主键、唯一约束,还有一个很多人第一次会翻车的插入顺序问题。适合考前突击、课设前练手,以及想快速搭一套干净练习数据的从业者。
2. 建表语句拆开看:主键、外键与唯一约束是怎么互相咬合的
很多人在网上复制建表语句,复制完就建,建完就插数据,从没想过每一行约束在干什么。这套表的好处是:三张表把数据库系统概论里最核心的完整性约束全演示了一遍。先别急着执行,把表结构读明白,后面插入数据时你会少踩一半的坑。
2.1 student 表:学号用 char(20) 而不是 int,主键和唯一键各管一件事
CREATE TABLE `student` ( `Sno` char(20) NOT NULL, `Sname` char(20) DEFAULT NULL, `Ssex` char(2) DEFAULT NULL, `Sage` smallint DEFAULT NULL, `Sdept` char(20) DEFAULT NULL, PRIMARY KEY (`Sno`), UNIQUE KEY `Sname` (`Sname`) );这段建表语句里,最值得琢磨的是 Sno 为什么用 char(20) 而不是 int。学号不参与任何算术运算,还可能带前导零,比如 '021501' 这种编号,用 int 存会把前导零吃掉。char 是定长类型,等值查询时比 varchar 略快,代价是固定占满 20 个字符的空间,这是教材为了演示常见工程写法给的余量,不是随便拍的。
Sage 用 smallint,范围是 -32768 到 32767,存年龄绰绰有余。真正有意思的是 UNIQUE KEYSname这一行——姓名在真实业务里根本不可能唯一,重名是常态,这里纯粹是为了演示“除了主键之外,还能给普通列加唯一约束”。你可以在插入数据时故意再插一个李勇,会收到 duplicate entry 报错,那一下你对唯一约束的记忆会非常深刻。
2.2 course 表:Cpno 自引用外键,把“先修课”关系画成一棵树
CREATE TABLE `course` ( `Cno` char(4) NOT NULL, `Cname` char(40) NOT NULL, `Cpno` char(4) DEFAULT NULL, `Ccredit` smallint DEFAULT NULL, PRIMARY KEY (`Cno`), KEY `Cpno` (`Cpno`), CONSTRAINT `course_ibfk_1` FOREIGN KEY (`Cpno`) REFERENCES `course` (`Cno`) );course 表里最容易看懵的是最后一行外键约束:Cpno 引用的是 course 自己的 Cno。这叫做自引用外键,语义是“先修课”。比如课程 1 是数据库,它的 Cpno 指向 5(数据结构),意思就是“学数据库之前必须先修数据结构”。课程和先修课的关系像一棵树,根节点是那些没有先修课的课程,Cpno 为 NULL。
MySQL 的 InnoDB 引擎允许外键引用同一个表的被索引列,主键自带索引,所以这个自引用约束能成立。但这里藏着一个大坑:插入数据时,如果你急着把 Cpno 一起写进去,比如直接 INSERT 一条 ('1', '数据库', 5, 4),而表里还没有 Cno='5' 的行,外键检查会直接拒绝。这正是很多人在 PDF 配套练习里第一次报错的地方。
注意:这份建表语句没有写 ENGINE=InnoDB。MySQL 8 默认就是 InnoDB,没问题;但如果你用的是旧版本且默认引擎是 MyISAM,外键约束会被静默忽略甚至直接报错。建议建表时显式补上 ENGINE=InnoDB。
2.3 sc 表:联合主键 (Sno, Cno) 挡住重复选课,两个外键维持引用完整性
CREATE TABLE `sc` ( `Sno` char(20) NOT NULL, `Cno` char(4) NOT NULL, `Grade` smallint DEFAULT NULL, PRIMARY KEY (`Sno`,`Cno`), KEY `Cno` (`Cno`), CONSTRAINT `sc_ibfk_1` FOREIGN KEY (`Sno`) REFERENCES `student` (`Sno`), CONSTRAINT `sc_ibfk_2` FOREIGN KEY (`Cno`) REFERENCES `course` (`Cno`) );sc 是成绩表,联合主键 (Sno, Cno) 的意思很直白:同一个学生同一门课只能有一条成绩记录,你想插两条一模一样的选课记录进不来。这是数据完整性的典型设计,防止业务层重复提交。
两个外键约束分别指向 student 和 course,也就是说 sc 表的数据依赖两张父表的数据。外键列上各加了一个 KEY,这是 MySQL 的硬性要求——被外键引用的列和引用别人的列都必须有索引,否则建表会报错。Grade 允许为 NULL,这也合理:学生可能选了课但还没考试,或者缓考了,Sno 和 Cno 先占位。
到这里三张表的关系已经清楚了:student 和 course 是父表,sc 是子表;course 自己引用自己。理解了这一层,下一章的数据插入顺序就不是靠背,而是靠推。
3. 数据插入的两段式操作:course 自引用外键的插入顺序与参数细节
数据插入顺序是有依赖关系的,不能想当然地从上往下执行。sc 引用 student 和 course,course 又引用自己的 Cno,所以正确顺序只能是:先建库建表,再插 student,然后 course 先插基础行再补外键列,最后才轮到 sc。这个顺序理不清,报错就是连环的。
3.1 依赖顺序梳理:student 先行、course 分两段、sc 收尾
-- 第一步:创建数据库,指定字符集 CREATE DATABASE IF NOT EXISTS sql_test DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE sql_test;先创建名为 sql_test 的数据库。IF NOT EXISTS 是个后悔药:脚本重复执行不会报错,适合学生反复练习的场景。字符集指定 utf8mb4 是为了让中文能正常存储,如果你用的是这个语句却没指定字符集,在部分 MySQL 版本上中文插入后可能直接变成问号。
数据库创建完就执行第 2 章的三段 CREATE TABLE。全部建成功后,插入顺序是:先插 student 的四行数据,再插 course 的基础行,然后执行 course 的批量 UPDATE,最后插入 sc 的成绩数据。course 必须拆成两步,这一步卡住的人最多,下面单独讲。
3.2 course 两段式插入:先造父行,再补自引用外键列
course 的插入在原文里被拆成了两段,分段原因不是语句太长,而是自引用外键的约束。第一段只插入 Cno 和 Cname,让七门课的“父行”全部存在:
INSERT INTO course(Cno, Cname) VALUES ('1', '数据库'), ('2', '数学'), ('3', '信息系统'), ('4', '操作系统'), ('5', '数据结构'), ('6', '数据处理'), ('7', 'PASCAL语言');这段执行后,course 表里有七行,但 Cpno 和 Ccredit 全是 NULL,外键列是空的不参与约束检查,所以不会报错。第二段再用 UPDATE 逐行把先修课编号和学分补上:
UPDATE course SET Cpno = '5', Ccredit = 4 WHERE Cno = '1'; UPDATE course SET Cpno = '4', Ccredit = 2 WHERE Cno = '2'; UPDATE course SET Cpno = '1', Ccredit = 4 WHERE Cno = '3'; UPDATE course SET Cpno = '6', Ccredit = 3 WHERE Cno = '4'; UPDATE course SET Cpno = '7', Ccredit = 4 WHERE Cno = '5'; UPDATE course SET Cpno = '5', Ccredit = 2 WHERE Cno = '6'; UPDATE course SET Cpno = '6', Ccredit = 4 WHERE Cno = '7';为什么不能用一条 INSERT 带全字段搞定?因为自引用外键要求被引用的行必须已存在。假设你第一条 INSERT 就写 ('1', '数据库', 5, 4),此时表里还没有 Cno='5' 的课程 5,外键检查当场失败。血泪经验是:凡是自引用表,先插不带外键列的基础行,再回头 UPDATE 外键列,这个思路可以通吃所有自引用场景。
提示:原文注释里说“此表含有 student 表的外键”,这是笔误。course 表里没有 student 的任何字段,它的外键是自引用,指向 course.Cno。核对脚本时要以约束定义为准,别被注释带偏。
3.3 student 与 sc 的插入细节:VALUES 列表语法、全角字符与字符集
student 的数据原文用了四行单独的 INSERT,可以合并成一条多值插入:
INSERT INTO `student` VALUES ('201215121', '李勇', '男', 20, 'CS'), ('201215122', '刘晨', '女', 19, 'CS'), ('201215123', '王敏', '女', 19, 'MA'), ('201215125', '张立', '男', 19, 'IS');多值 INSERT 比逐行执行快,也更适合放在脚本里一次性跑。注意性别和第二行“刘晨”的“女”字,如果从 PDF 直接复制,这两个字符可能是全角或异体字,跟普通输入法打出来的“女”看起来一模一样,但 MySQL 可能不认,我在第 5 章会专门讲这个坑。
sc 表的插入要放在最后,因为它的外键依赖 student 和 course 的现有数据:
INSERT INTO sc(Sno, Cno, Grade) VALUES ('201215121', '1', 92), ('201215121', '2', 85), ('201215121', '3', 88), ('201215122', '2', 90), ('201215122', '3', 80);这里五条记录里的学号和课号都必须真实存在。尤其是课号,course 表里是 '1'、'2' 这种单字符字符串,如果你写成 '01',MySQL 会认为这是一个不存在的课程,外键检查直接报 1452 错误。char 类型存的是定长字符串,比较时补空格逻辑容易让人产生“可能匹配上”的错觉,实际一点余地都不给。
4. 基于三张表的 SQL 查询练习:从单表筛选到多表连接的常用题型
三张表的数据量不大,学生 4 人、课程 7 门、成绩 5 条,但能练的查询一个都不少。我把练习按难度拆成三组,每组都是先给 SQL 再讲逻辑。建议你自己先敲一遍,再对照下面的说明,真卡住了再来看答案。
4.1 单表查询:筛选、排序与模糊匹配
-- 查询计算机系(CS)全体学生的学号、姓名和年龄 SELECT Sno, Sname, Sage FROM student WHERE Sdept = 'CS';WHERE 子句筛选是 SQL 最基础的能力。Sdept 是 char(20),等值比较时 MySQL 会自动处理尾部空格,所以 'CS' 能正确匹配。注意这里查询列只挑了三列,Ssex 没查,select 指定列的习惯值得从入门就养成,少用 SELECT *。
-- 查询年龄小于 20 岁的学生,按年龄升序排列 SELECT Sname, Sage FROM student WHERE Sage < 20 ORDER BY Sage ASC;ORDER BY 默认就是升序,ASC 可写可不写,但写出来能让别人一眼看懂你的意图。排序发生在筛选之后,先 WHERE 取出满足条件的行,再对结果排序,这个执行顺序理解清楚,写复杂查询时不会逻辑混乱。
-- 查询姓“刘”的学生 SELECT Sno, Sname, Sdept FROM student WHERE Sname LIKE '刘%';LIKE 配合通配符 % 是模糊查询的入门操作。% 代表任意长度的任意字符,'刘%' 匹配所有以刘开头的名字。如果换成 '_刘%' 表示第二个字是刘。这类模糊查询在刷题时出现频率很高,值得多写几遍。
4.2 多表连接:把三张表串起来查成绩
-- 查询每个学生的选课成绩,显示学号、姓名、课程号和成绩 SELECT student.Sno, student.Sname, sc.Cno, sc.Grade FROM student JOIN sc ON student.Sno = sc.Sno;JOIN 是多表查询的骨架。INNER JOIN 只返回两边都匹配的行,这里 student 里的张立没有选课记录,连接后不会出现他的行——这个细节是理解内连接和外连接的关键差异。我在第 6 章会给一条 LEFT JOIN 的版本,你可以对比着看。
-- 三表连接:查询学号、姓名、课程名、成绩 SELECT student.Sno, student.Sname, course.Cname, sc.Grade FROM student JOIN sc ON student.Sno = sc.Sno JOIN course ON sc.Cno = course.Cno;三表连接是两个两表连接串起来的:student 先和 sc 连接,得到有成绩的学生;中间结果再和 course 连接,把课号换成课程名。写多表连接时,JOIN 顺序会影响执行计划,但对这套小数据量来说,先把逻辑写对比优化更重要。
-- 查询选修了“数据库”课程的学生姓名和成绩 SELECT student.Sname, sc.Grade FROM student JOIN sc ON student.Sno = sc.Sno JOIN course ON sc.Cno = course.Cno WHERE course.Cname = '数据库';这个题型是考试高频:先连接,再 WHERE 过滤。执行顺序是先拿三张表做连接,连接结果里筛选课程名为数据库的行,最后 SELECT 出姓名和成绩。理解“连接在前、过滤在后”的执行逻辑,比死记 SQL 顺序有用得多。
4.3 分组聚合与子查询:去重、计数和限定条件
-- 统计每门课的选课人数和平均成绩 SELECT Cno, COUNT(*) AS 选课人数, AVG(Grade) AS 平均分 FROM sc GROUP BY Cno;GROUP BY 把 sc 表按课程分组,COUNT(*) 统计每组行数,AVG 算平均分。需要注意 AVG 会忽略 NULL 成绩,如果一门课有学生没考试成绩,平均分只算有成绩的人,这个陷阱在面试里经常被拿来挖坑。
-- 查询选课超过 1 门的学生学号和选课门数 SELECT Sno, COUNT(*) AS 选课数 FROM sc GROUP BY Sno HAVING COUNT(*) > 1;HAVING 和 WHERE 的区别是常见考点:WHERE 在分组前过滤行,HAVING 在分组后过滤组。这里 COUNT(*) > 1 选出选了不止一门课的学生,结果里有 201215121 和 201215122 两个人。刷题的时候记得把 WHERE 和 HAVING 的执行顺序想清楚,能少走很多弯路。
-- 查询没有选课的学生姓名(子查询 + NOT IN) SELECT Sname FROM student WHERE Sno NOT IN (SELECT Sno FROM sc);子查询先查出所有选过课的学生学号,外层再查不在这个集合里的学生。这个写法在数据量小的时候没问题,但子查询结果集很大时性能不理想,可以用 NOT EXISTS 改写,两者语义略有差异,值得对比练习。
-- 查询每门课程的课程名及其先修课名(自连接) SELECT a.Cname AS 课程, b.Cname AS 先修课 FROM course a JOIN course b ON a.Cpno = b.Cno;自连接是这套表最有价值的练习点。course 表自己跟自己连接,a 是“当前课程”,b 是“先修课”,ON 条件把 a.Cpno 指向 b.Cno,就查出了每门课的先修课。没有先修课的课程(Cpno 为 NULL)不会出现在内连接结果里,想看完整列表得把 JOIN 换成 LEFT JOIN,下一章会用到这个技巧。
5. 常见问题排查:外键失败、唯一键冲突与 PDF 复制语句的坑
这一章是踩坑记录,每条都是照着这份 PDF 练习时真会遇到的报错。我按现象、原因、解决三段式写,你执行时报什么错,直接对号入座。
5.1 外键约束失败:course 表带 Cpno 一起插入直接报错
现象:执行INSERT INTO course VALUES ('1', '数据库', '5', 4),MySQL 报Cannot add or update a child row: a foreign key constraint fails。
原因:course 表的 Cpno 是自引用外键,指向 course.Cno。插入这条记录时,表里还没有 Cno='5' 的行,外键检查认为你引用了一个不存在的父行,直接拒绝。
解决:拆成两段。先用INSERT INTO course(Cno, Cname)把七门课的基础行都插进去,再逐条UPDATE course SET Cpno = ..., Ccredit = ... WHERE Cno = ...。UPDATE 时所有 Cno 都已存在,外键检查必然通过。
5.2 外键检查失败:sc 表插不进去,先查父表数据
现象:插入 sc 成绩时报 1452 错误,提示a foreign key constraint fails,但表里明明有学生也有课程。
原因:sc 的外键引用 student.Sno 和 course.Cno,只要你插入的学号或课号在父表里不存在,哪怕只差一个字符,也会报错。常见诱因:学号复制时带了不可见空格,或者课号把 '1' 写成了 '01'。
解决:先执行SELECT Sno FROM student和SELECT Cno FROM course核对现有数据,再逐条检查待插入的 sc 记录。用 TRIM(Sno) 对比能排除不可见空格干扰。养成插入前先查父表的习惯,能省掉一大半外键报错。
5.3 唯一键冲突:student 表的 Sname 重复
现象:再次执行INSERT INTO student VALUES ('201215126', '李勇', '男', 20, 'CS'),报Duplicate entry '李勇' for key 'student.Sname'。
原因:建表语句里写了UNIQUE KEY Sname (Sname),姓名字段被加了唯一约束,重复值插不进去。
解决:确认业务上是否需要这个唯一约束。教材里加它是为了演示唯一键的用法,实际生产环境学生表绝不应该对姓名做唯一约束。练习时要么换一个名字再插,要么用ALTER TABLE student DROP INDEX Sname把约束去掉。
5.4 中文乱码:建库没指定字符集
现象:插入李勇、刘晨这些中文后,查询结果显示???或者直接报Incorrect string value: '\xC0\xEE...'。
原因:数据库或表的字符集不是 utf8mb4,MySQL 无法正确存储中文字符。旧版本 MySQL 的默认字符集可能还是 latin1,中文根本存不进去。
解决:建库时显式指定字符集:
CREATE DATABASE IF NOT EXISTS sql_test DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;如果库已经建了,可以ALTER DATABASE sql_test CHARACTER SET utf8mb4,再把表也转换一下。连接层面执行SET NAMES utf8mb4能避免客户端写入和库端编码不一致的问题。
5.5 从 PDF 复制语句:全角逗号、全角括号和异体字
现象:从 PDF 里复制建表语句到 MySQL 执行,报错位置莫名其妙,比如在某个逗号或括号附近syntax error。
原因:PDF 里的标点经常被排版成全角字符——全角逗号“,”、全角括号“()”、智能引号换成左右单引号,还有原文里那个“⼥”(康熙部首)并不是真正的“女”。这些字符肉眼几乎分辨不出来,但 MySQL 的语法解析器不认。
解决:复制后先做字符清洗。我在本地一般用一段简短的 Python 脚本处理:
import re with open('raw.sql', encoding='utf-8') as f: sql_text = f.read() sql_text = sql_text.replace(',', ',').replace(';', ';') sql_text = sql_text.replace('(', '(').replace(')', ')') sql_text = re.sub(r'[“”]', '"', sql_text) sql_text = re.sub(r"[‘’]", "'", sql_text) sql_text = sql_text.replace('⼥', '女') with open('clean.sql', 'w', encoding='utf-8') as f: f.write(sql_text)清洗完再打开编辑器看一眼,重点检查字符串字面量里的引号是否成对。这个脚本花不了两分钟,但能把一整天的排查时间省下来。从那以后我每次拿到 PDF 里的 SQL,都先强制走一遍清洗再执行。
6. 把练习表用得更顺:批量造数、自连接自测与一个排错习惯
6.1 批量生成选课数据:用笛卡尔积把 sc 表补全
这套练习表初始只有 5 条选课记录,练聚合和连接勉强够用,但想体验慢 SQL 优化或者测索引效果,数据量根本不够看。常见的做法是用笛卡尔积批量造数据:
INSERT INTO sc(Sno, Cno, Grade) SELECT s.Sno, c.Cno, 60 + FLOOR(RAND() * 40) FROM student s CROSS JOIN course c WHERE NOT EXISTS ( SELECT 1 FROM sc WHERE sc.Sno = s.Sno AND sc.Cno = c.Cno );CROSS JOIN 把 4 个学生和 7 门课两两组合出 28 种选课可能,NOT EXISTS 过滤掉已经存在的 5 条,只补缺失的组合。成绩用60 + FLOOR(RAND() * 40)生成 60 到 99 之间的随机整数。注意 MySQL 的 RAND() 是按行计算的,每次执行结果不同,想固定成绩可以先把随机值写入临时表再插入。如果你用的是 SQL Server,语法要换成ABS(CHECKSUM(NEWID())) % 40 + 60,这类随机函数是各数据库差异最明显的地方。
6.2 一条自连接 SQL 验证你对表结构的理解
第 4 章给了自连接的内连接版本,这里用 LEFT JOIN 再验证一次:
SELECT a.Cname AS 课程, b.Cname AS 先修课 FROM course a LEFT JOIN course b ON a.Cpno = b.Cno;LEFT JOIN 以左表 course a 为基准,即使某门课没有先修课(Cpno 为 NULL),也会显示出来,只是右边先修课列是 NULL。执行后对照一下:数据库的先修课是数据结构,操作系统的先修课是数据处理,PASCAL 语言没有先修课,右边是 NULL。这跟建表时设置的 Cpno 完全对应,说明你对自引用外键的理解到位了。如果结果跟预期对不上,别急着改 SQL,先回过头查 Cpno 的 UPDATE 是不是漏了行——这类问题 90% 出在数据上,不是 SQL 上。
从拆这份 PDF 到今天,我每次拿到别人给的 SQL 脚本,都强制自己先理一遍表间外键依赖再动手执行,这个习惯帮我挡掉了不少插入顺序的坑。三张表看起来简单,真能吃透它们,数据库系统概论的完整性约束和连接查询这两块基本功基本就过关了。希望帮到你。
本文还有配套的精品资源,点击获取