简介:这是一份北邮研一数据库大作业详解,以学生成绩管理系统为项目背景,完整呈现从需求分析、数据库设计、ER图设计到逻辑结构设计的全过程。资源适合正在准备数据库课程设计或大作业的高校学生,尤其是需要参考成绩管理、教师信息管理、不及格统计等典型功能模块设计思路的读者。压缩包共1个文件,为docx格式,整体约503KB,内容以文档形式系统整理,便于直接阅读、对照修改和补充实验报告。文中包含Course、Student、Sc、Teacher四张表的字段定义、主外键约束与一对多关系说明,并给出局部/全局ER图、数据字典及建表SQL语句,可帮助读者快速理解学生成绩管理系统的数据建模方法。目前已有607人学习下载,适合用于数据库设计复习、期末项目参考或作业模板。
1. 一份数据库大作业,为什么值得认真跑一遍
如果你以为数据库大作业就是建几张表、插入几条数据、交一份报告,那这份学生成绩管理系统可能会颠覆你的预期。它不是一个只讲操作的CRUD Demo,而是把关系数据库设计里最常见也最容易忽略的环节完整走了一遍:从ER图抽象、函数依赖分析、第三范式校验,到视图、存储过程、触发器、复杂统计查询,全部用SQL落实。某高校研一数据库课程里,这份作业的核心价值在于——它真实模拟了教务系统最需要的三件事:成绩录入与查询、不及格名单统计、无教学任务教师筛选。对正在准备数据库课设的学生,或者想快速复习MySQL核心语法的开发者,顺着这份资源跑一遍,比对着教材背一百条语法更有用。它不炫技,但每段代码都能在真实教学场景里找到对应的问题。
2. 系统全貌与四表模型:从需求到ER图的关键决策
2.1 功能边界:这个系统到底管理了什么
先别急着看SQL,得先搞清楚这个系统要解决什么问题。根据原作业的需求分析,系统的服务对象是三类人:授课教师负责录入成绩,学生自行查询成绩,教务处需要统计不及格名单和无教学任务的教师。这个边界很重要,因为它直接决定了数据表的粒度和关系。
如果只做成绩维护,一张成绩表加一张学生表就够了。但加上“统计没有教学任务的老师名单”这个需求后,就必须引入教师表和课程表,并在课程表上维护任课教师编号。同样,“统计各科不及格学生名单”要求成绩表既能按课程维度聚合,也能按学生维度关联,所以成绩表必须以学号和课程编号作为联合主键。需求里的每一个动词,最后都会落在表结构上。
2.2 四张表的职责与关系
这个系统一共四张核心表:course(课程)、student(学生)、sc(成绩)、teacher(教师)。表不多,但关系设计得很典型——学生与课程是多对多,中间通过sc表解除;教师与课程是一对多,一个老师可以教多门课。
这里有一个细节容易被忽略:原作业文档里提到“教务处需要查询没有教学任务的老师”,如果直接用teacher表和course表做LEFT JOIN,会发现course表里存的是任课教师号tno,而不是直接存教师名。这意味着查询时要么通过course表关联,要么在course表设计时就保证tno是教师表的外键。我在跑这个作业时,一开始没注意course表里tno的语义,直接拿teacher表去JOIN sc表,结果查出来的“无教学任务老师”名单全是错的。后面才意识到,判断老师有没有教学任务,要看的是course表里是否存在该教师的tno记录,而不是看成绩表。
2.3 范式检查:为什么说这组表是合格的3NF设计
原作业在逻辑结构设计部分专门做了函数依赖分析,这也是很多人交作业时会抄但不理解的部分。以student表为例,它的属性是sno、sname、ssex、smajor、sclass,主键是sno。在这个关系模式里,sno决定了sname,也决定了smajor和sclass,不存在部分依赖,也不存在传递依赖,所以满足第三范式。
再看sc表,它的主键是(sno, cno)联合主键,成绩degree只依赖于完整的联合主键,而不是其中某一个字段,所以也是3NF。这里有一个非常容易踩的坑:很多人在设计成绩表时,会单独设置一个自增id作为主键,然后把sno和cno当作普通字段。从功能上没问题,但从关系数据库的约束角度,这就破坏了“学生选修课程”这个语义约束——同一个学生同一门课理论上只能有一条成绩,用联合主键才能从数据库层面强制唯一。原作业里把sno和cno设为组合主键,同时对sc表设置检查约束degree在0到100之间,这个设计思路是合格的。
ER图部分原作业手绘了局部和全局ER图,我没法在本篇中画图,但可以用文字还原核心实体联系:学生和课程之间存在“选修”联系,成绩是选修的属性而非独立实体;教师和课程之间存在“讲授”联系;教师和学生之间没有直接关系,所有关联都通过课程和成绩间接建立。这个模型的好处是职责清晰,坏处是如果要统计“某老师教过的所有学生的平均成绩”,需要三表JOIN,这对SQL编写能力是个考验。
3. 核心实现拆解:建库、约束、视图与统计查询
3.1 建库建表语句逐段拆解
原作业的清单一开头就是建库和建表,代码不多,但每一行都有讲究。先看建库语句:
CREATE DATABASE test;这里的库名直接用了test,在课程作业里没问题,但如果要二次开发,我一般会改成更语义化的名字,比如grade_management。库名这个细节不影响功能,但会影响后续维护时的可读性。
接下来是course表的创建语句:
CREATE TABLE course ( cno char(5) NOT NULL, cname varchar(20) NOT NULL, tno char(3) NOT NULL, CONSTRAINT C1 PRIMARY KEY(cno) );课程编号cno用了定长char(5),是因为课程编号格式固定,用变长varchar反而浪费存储。cname课程名用varchar(20),因为不同课程名称长短不一。tno是任课教师编号,char(3)对应教师表的教师编号长度。这里有个隐藏问题:course表里的tno并没有声明FOREIGN KEY指向teacher表,原作业只写了约束C1是主键,外键关系全靠逻辑层面保证。在MySQL的InnoDB引擎下,如果不显式声明外键,数据库不会阻止你插入一个不存在的教师编号。课程作业可以这样,但生产系统我会建议加上外键约束,或者至少在应用层做校验。
再看student表:
CREATE TABLE student ( sno char(9) PRIMARY KEY, sname char(8), ssex char(2), smajor char(20), sclass char(10) );学生表的主键是sno,但请注意字段的可空性——除了sno,其他字段都允许为空,性别ssex的注释里还约束了取值为男或女,但表定义里没有加CHECK约束。这意味着数据库层面无法阻止你插入一个性别为“未知”的学生。如果在真实教务系统里,这种字段应该用ENUM或者CHECK约束。
最关键的sc表在这里:
CREATE TABLE sc ( sno char(10) NOT NULL, degree decimal(4,1), cno char(5) NOT NULL, CONSTRAINT A1 PRIMARY KEY(sno, cno), CONSTRAINT A2 CHECK(degree >= 0 AND degree <= 100) );注意sc表里的sno是char(10),但student表里的sno是char(9),两个字段类型不一致。如果后续要在这两表之间建立外键约束,MySQL会直接报错,因为外键要求数据类型和长度完全一致。原作业可能是笔误,但这是一个非常典型的“作业能跑、上生产就翻车”的例子。跑通这个作业之后,我建议把sc表的sno改成char(9),和student表对齐。
还有degree字段用了decimal(4,1),表示最长4位,小数点后1位,也就是最大能存999.9。配合CHECK约束0到100,实际能存的范围就是0.0到100.0。这里有一个MySQL的玄学问题——MySQL 5.5对CHECK约束只做语法解析,不实际校验,因此即使degree写成999,也能插入成功。真正要保证数据合法性,要么升级到MySQL 8.0.16以上版本,要么在触发器或应用层做判断。
3.2 视图如何支撑“科学导论成绩查询”
视图是这份作业里比较出彩的部分。原作业要求查询所有选修“科学导论”课程的学生学号、姓名和成绩,SQL写成这样:
CREATE VIEW v_student AS SELECT A.sno, sname, degree FROM student A, course B, sc C WHERE A.sno = C.sno AND B.cno = C.cno AND cname = '科学导论';这里用了隐式内连接,把三张表用逗号连接,在WHERE里写关联条件。在MySQL里这样写没问题,但如果你去面试,面试官可能会问你为什么不写成JOIN。新手能看懂这个语法就行,但要理解它的执行逻辑:先做三张表的笛卡尔积,再用WHERE过滤出有效记录。数据量小的时候性能差别不明显,数据上了十万行,这种写法会把性能拖垮。
视图建好之后,查询就变得很简单:
SELECT * FROM v_student;视图的意义在于把复杂的多表关联封装成一个虚拟表,应用层不需要关心底层表结构。在这个系统里,如果教务处要频繁查看某门课的成绩单,建一个视图是合理的。
不过原作业里还演示了一句INSERT INTO view_sc VALUES('140210321','88','C01'),向视图插入数据。这一步其实是在演示“可更新视图”的概念——当视图的基础表结构简单且能唯一映射时,视图可以接受INSERT操作。但在实际项目中,我不建议通过视图写数据,因为一旦视图涉及多表JOIN,INSERT的语义会变得模糊,MySQL会直接拒绝执行。这里能插进去,是因为view_sc是基于sc表的简单投影。
3.3 三个管理场景对应的SQL写法
这份作业里最有价值的是那组复杂查询SQL,因为它们直接对应教务管理场景。第一个场景是查询所有学生信息,这个没什么好说,SELECT * FROM student就够。第二个场景是计算某门课的平均成绩:
SELECT AVG(degree) FROM sc WHERE cno = 'C01';AVG函数自动忽略NULL值,如果某门课没人选,返回NULL而不是0,这个逻辑差异在统计时要特别小心。
第三个场景是查询选修“高等数学”的学生学号和姓名,原作业用了显式JOIN:
SELECT A.sno, sname FROM student A INNER JOIN sc B ON A.sno = B.sno INNER JOIN course C ON B.cno = C.cno WHERE C.cname = '高等数学';这段代码比前面的隐式连接专业得多,把关联关系写在ON子句里,过滤条件写在WHERE里,清晰且性能更好。第四个场景是不及格名单,思路一样,只是WHERE条件从课程名换成课程编号和分数阈值:
SELECT A.sno, sname, ssex, smajor, sclass FROM student A INNER JOIN sc B ON A.sno = B.sno INNER JOIN course C ON B.cno = C.cno WHERE C.cno = 'C03' AND B.degree < 60;注意我把WHERE B.degree < 60写成了明确的小于60,这个阈值是需求里隐含的。真实教务系统里,及格线可能不是60分,可能是40分,甚至不同课程不同线,所以这个值我一般会抽出来做成参数。
“没有教学任务的老师名单”这个需求原作业没写具体SQL,我补一个常见做法:
SELECT tno, tname FROM teacher WHERE tno NOT IN ( SELECT DISTINCT tno FROM course );子查询先把所有有教学任务的教师编号列出来,外层NOT IN把没课的老师筛出来。这里要提醒一点:如果course表里tno允许NULL,NOT IN的结果会出问题,因为NULL参与比较会让整个子查询返回空集。所以这个SQL能成立的前提是course.tno加了NOT NULL约束,原作业建表时确实是这么写的。
4. 避坑记录:跑这份作业时最容易翻车的5个细节
4.1 sc表字段顺序带来的插入陷阱
原作业里有一个插入语句是INSERT INTO sc VALUES('120210332','86','C01'),但sc表的字段定义顺序是sno、degree、cno。所以这行数据插入后,实际上是把'86'存进了degree(转换为86.0),'C01'存进了cno。如果你按直觉把它理解成学号、课程号、成绩,插入就会完全错位,成绩变成课程号,课程号变成成绩,后续统计全错。
原因:VALUES方式插入依赖字段定义顺序,不按名称匹配。解决:写INSERT时永远指定字段名,比如INSERT INTO sc(sno, cno, degree) VALUES('120210332', 'C01', 86),这样即使表结构调整,插入语义也不会错。
4.2 两表主键类型不一致导致外键建不上
student表sno是char(9),sc表sno是char(10),长度差了1位。如果试图在sc表上添加外键约束指向student表,MySQL会报“列类型不兼容”,因为char(9)和char(10)不是同一类型。如果你的版本允许建立,JOIN时也会因为字符比较时的隐式转换导致索引失效,全表扫描。
原因:设计时没有统一关联字段的规格,可能是不同人分别设计了两张表。解决:统一改成char(9),或者更推荐直接用数值型BIGINT,省去定长字符的包袱。这个坑在学生选课系统里极其常见,我见过不止一次因为学号长度不统一导致后续所有关联查询都慢半拍。
4.3 CHECK约束在MySQL 5.5里不生效
sc表里写了CHECK(degree >= 0 AND degree <= 100),但在MySQL 5.5和8.0.16之前的所有版本里,CHECK约束都是只解析不执行的。也就是说插入一个成绩999,数据库不会报错,数据照样进去。
原因:MySQL在8.0.16之前不支持真正的CHECK约束这个“黑匣子”。解决:要么升级MySQL版本,要么用触发器代替CHECK,要么在Navicat的图形界面里用枚举或应用层拦截。对于这门课来说,知道“约束可能不生效”这个点,比背过CHECK语法重要得多。
4.4 触发器记录删除数据时表结构不匹配
原作业里创建delstudent表时用的是CREATE TABLE delstudent SELECT sno,sname FROM student WHERE 1=0,这行代码建了一张只有sno和sname两列的空表。但后面的触发器只往这两个字段里写数据,如果学生表里还有其他关键字段(比如专业、班级),删除记录后这些信息就永久丢失了。
原因:临时表结构拍脑袋决定,没有考虑审计数据需要保留的字段范围。解决:提前设计好审计表 schema,至少包含操作类型、操作时间、被删除行的完整快照。我一般会把sno、sname、smajor、sclass、deleted_at都放进去,这样以后排查数据问题时不用翻binlog。
4.5 视图插入数据成功但不代表可以随意更新
view_sc视图建立在sc表之上,INSERT INTO view_sc VALUES('140210321','88','C01')能执行成功,是因为这个视图是基于单表的简单投影,MySQL允许对这类视图做DML操作。但如果你把视图改成多表JOIN,再执行INSERT,MySQL会直接抛出错误。新手容易产生“视图等于表”的误解,视图本质上只是一条保存的SELECT,底层还是操作物理表。
原因:可更新视图的条件比较苛刻,多表JOIN、分组聚合、DISTINCT都会让视图失去更新能力。解决:项目里视图一律只读,写入走存储过程或者直接操作基表。
5. 存储过程与触发器:把数据库从“存数据”变成“管数据”
5.1 存储过程:班级查询与成绩个数统计
原作业里第一个存储过程是从student表里检索班级为“4412”的学生信息。这里先看完整代码:
DELIMITER /// CREATE PROCEDURE proc_stud() READS SQL DATA BEGIN SELECT sno, sname, smajor FROM student WHERE sclass LIKE '%4412%' ORDER BY sno; END /// DELIMITER ;这段代码里有三个知识点要讲清楚。第一,DELIMITER ///的作用是把MySQL的命令结束符从分号暂时改成三个斜杠,因为存储过程内部有分号,如果不改结束符,MySQL会在第一个分号处截断,导致创建失败。执行完记得用DELIMITER ;恢复分号。第二,READS SQL DATA是声明这个存储过程只读数据,不修改数据,对权限校验有帮助,某些只读账号必须加这个声明才能执行,不是可有可无。第三,LIKE '%4412%'在班级号匹配上用到了前导通配符,会导致索引失效,这个写法更适合模糊匹配,精确匹配应该用sclass = '4412'。
第二个存储过程更接近真实业务,传入学号,统计该学生选了几门课:
DELIMITER /// CREATE PROCEDURE num_sc(IN tmp_sno char(9), OUT count_num INT) READS SQL DATA BEGIN SELECT COUNT(*) INTO count_num FROM sc WHERE sno = tmp_sno; END /// DELIMITER ;这个存储过程展示了IN和OUT参数的用法:IN参数是输入值,OUT参数是返回值。调用时先定义一个变量,用CALL传入,然后SELECT变量看结果。MySQL的存储过程不支持直接RETURN,所有返回值都通过OUT参数带出,这一点和编程语言里的函数习惯不太一样。
调用方式:
CALL num_sc('120210332', @cnt); SELECT @cnt;@cnt是MySQL的会话变量,存储过程执行完,变量值就被填充了。注意存储过程的参数类型char(9)和sc表的sno类型char(10)不一致,这部分原理我在避坑章节已经提到,这里不再展开,但参数类型最好和表字段完全一致。
5.2 触发器:删除学生的审计留痕
触发器是这份作业里设计得最贴近生产环境的部分。先在student表上建一个AFTER DELETE触发器:
CREATE TRIGGER trig_student AFTER DELETE ON student FOR EACH ROW INSERT INTO delstudent(sno, sname) VALUES(old.sno, old.sname);触发器的作用是:每当student表有记录被删除,MySQL自动把被删行的sno和sname插入到delstudent表。OLD关键字代表删除前的行,NEW代表新增或更新后的行。这是MySQL触发器里最容易混淆的点——DELETE操作只有OLD,INSERT操作只有NEW,UPDATE则两者都有。
验证方式在作业里也给了,执行一条DELETE语句删除某个学生,然后查询delstudent表看记录是否存在:
DELETE FROM student WHERE sname = '某学生'; SELECT * FROM delstudent;有一点要说明:原作业是在student表上定义触发器,但sc表里还存着这个学生的选课记录。如果student表被删除,sc表里的成绩记录就成了“孤儿数据”。在这个作业里没有级联删除或外键约束,所以实际场景中应该要么在触发器里同步删除sc表记录,要么在删除前先做数据核对。我建议把触发器改造成同时处理sc表,不然你删除一个学生后,统计平均成绩时会把已删除学生的成绩也算进去。
5.3 索引与性能考虑
原作业在6.1提到了建立索引,但没有给具体代码,这里补一个常见的索引建议。sc表上的查询主要围绕课程号cno和成绩degree,尤其是统计不及格名单时会出现WHERE cno = 'C01' AND degree < 60这样的过滤条件。此时联合索引比单独索引更有效:
CREATE INDEX idx_sc_cno_degree ON sc(cno, degree);这个联合索引能同时覆盖课程编号过滤和成绩范围过滤。查询时MySQL会先按cno定位到具体的课程,再在课程范围内扫degree。比全表扫描快得多。
另外,视图或者频繁执行的存储过程,建议用SQL语句手写一遍,再对比执行计划。Navicat里可以直接用EXPLAIN看索引是否命中:
EXPLAIN SELECT * FROM sc WHERE cno = 'C01' AND degree < 60;如果type列显示为ALL,说明全表扫描,检查索引是否建上,或者是否因为前导通配符导致索引失效。这一步是跑完作业后进阶优化的起点。
6. 二次开发思路:给成绩管理系统加一个Web入口
这份作业目前的形态是纯SQL脚本加Navicat图形界面,作为课程作业足够扎实,但作为系统还缺一层“给非技术人员用”的界面。如果让我继续往下做,我会用Python的Flask写一个最薄的Web层,把核心SQL操作包成HTTP接口。
先建一个简单的Flask应用:
from flask import Flask, request, jsonify import pymysql app = Flask(__name__) db = pymysql.connect( host="localhost", user="root", password="your_password", database="test", charset="utf8mb4" ) @app.route("/student/<sno>/grades") def get_grades(sno): sql = """ SELECT C.cname, S.degree FROM sc S JOIN course C ON S.cno = C.cno WHERE S.sno = %s """ with db.cursor() as cursor: cursor.execute(sql, (sno,)) result = cursor.fetchall() return jsonify(result) if __name__ == "__main__": app.run(debug=True)这段代码的核心思想是:SQL参数用%s占位符传参,而不是字符串拼接,避免了SQL注入。这是我在这个项目上最想强调的一点——原作业里的SQL全是用Navicat手动执行,拼接SQL的习惯一旦带到Web层,数据安全就完全失控。我一开始做这个小系统时就吃过亏,用了f"SELECT * FROM student WHERE sno = '{sno}'"这种写法,结果一个恶意输入直接能把整张表带出来。
你可以继续加三个接口:POST /grade录入成绩、GET /course/ /failed统计不及格名单、GET /teacher/free查无教学任务教师。对应的SQL都是从原作业里抄过来的,只是把常量换成参数。验证时可以先用cURL测接口,再用Postman做一轮完整的增删改查,确认视图和触发器在Web调用链路里行为一致。
如果愿意再进一步,可以把MySQL替换成SQLite,然后把这套学生成绩系统打包成单文件应用,适合做开源课设模板。转换时要注意decimal、char等类型在SQLite里没有对应语义,可以在Python层做类型转换。从后端开发的角度看,这份作业的底子打得不错,剩下的工作就是外层包装和边界校验。希望帮到你。
本文还有配套的精品资源,点击获取