简介:这份文档是北邮研一数据库课程大作业的完整详解,面向需要完成学生成绩管理系统课程设计的高校学生,尤其适合正在准备数据库大作业、课程设计或答辩复现的读者。内容围绕成绩信息维护、教师信息管理、不及格学生统计及无教学任务教师查询等功能展开,覆盖需求分析、Course、Student、Sc、Teacher四表设计、主外键与约束、局部及全局ER图、逻辑结构设计和建表SQL等关键环节。包内共1个docx文件,约503KB,便于直接查阅并整理成课程报告。已有607人学习,可作为从需求梳理到数据库落地的参考样例,帮助读者理解实体联系抽象、关系模式规范化及成绩管理系统实现思路,减少搭建表结构和撰写设计文档时的返工。
1. 从教务处的三句话倒推出来的设计
研一的数据库课大作业,最常见的题目就是学生成绩管理系统,四张表、七八条查询,看着两周就能交。真正卡人的往往不是 SQL 语法,而是前面那些没人催的活:教务处随口三句话怎么翻译成表结构,学号到底定char(9)还是char(10),成绩字段为什么能插进 200 分还一声不响。这份研一数据库大作业方案围绕某高校学生成绩管理系统展开,从需求分析、E-R 建模、建表约束,一路走到视图、存储过程、触发器、统计查询和最后的答辩验收。适合正在赶数据库课设、大作业的同学照着搭一遍,也适合想完整复习关系数据库设计流程的开发者,拿来对照检查自己方案里有没有漏掉的约束。
2. 需求分析与数据建模:从角色边界推到四张表
2.1 角色边界先划清,功能列表才不会写成一锅粥
很多人拿到题目第一反应是打开 Navicat 开始建表,建到一半发现「不及格名单」这个需求没地方放,于是回头加字段、改类型,最后一堆返工。我的习惯是先不碰数据库,把使用系统的人列出来,一人一行写清楚他能做什么、不能做什么。
题目里其实已经把角色给全了:授课教师负责成绩录入,学生只查自己的成绩,教务处做统计。这三类人对应三种完全不同的数据访问模式——教师是写(INSERT / UPDATE 到sc表),学生是读单条(按学号过滤),教务处是跨表聚合读(连接student、sc、course)。把这三条摆在一起,表结构该长什么样基本就定型了:
- 教师写成绩,说明成绩必须依附于「某个学生 + 某门课」这个组合,所以
sc表的主键只能是(sno, cno),不能有自增 id 当主键。 - 学生只读自己的,说明查询入口要按
sno走,student.sno必须是最左前缀能命中的主键或索引。 - 教务处要统计不及格名单,说明
degree字段要能参与比较运算,类型选DECIMAL而不是VARCHAR,否则degree < 60会退化成字符串比较。
除了题目明说的三条,还有两条隐含的维护需求:学生信息的增删改查、教师信息的增删改查。这两块通常是给管理员角色用的,和前面三类人不是同一套权限。到了实现阶段,这套角色划分最直接的落地方式就是用视图做权限边界——学生账号只授v_student的 SELECT 权限,连sc表长什么样都看不到:
-- 学生角色只读视图,不接触基表,天然限制了他只能看到成绩相关列 CREATE USER 'stu_reader'@'localhost' IDENTIFIED BY 'ChangeMe123'; GRANT SELECT ON test.v_student TO 'stu_reader'@'localhost'; -- 教师角色需要写 sc 表,权限范围要卡到列,避免误改学号课程号 GRANT SELECT, INSERT, UPDATE (degree) ON test.sc TO 'tea_writer'@'localhost'; FLUSH PRIVILEGES;这段的重点不在语法,而在UPDATE (degree)这个列级授权。数据库课设里很少有人写权限,但答辩时老师问一句「学生能不能改自己的成绩」,你要是答「前端限制了」,那基本就露怯了——数据库层面的约束才是最后一道闸。列级授权在 MySQL 里是支持的,写进方案里成本很低,加分却很明显。
提示:权限脚本建议单独存成一个
grant.sql,和建表脚本分开。答辩演示时先跑建表、再跑授权,顺序清晰,也方便回滚重来。
2.2 从局部 E-R 到全局 E-R:一对多和多对多到底怎么拆
角色划完,接着抽实体。题目里给了四组属性,可以直接对应四个实体雏形:学生(学号、姓名、性别、专业、班级)、课程(课程号、课程名、任课教师号)、教师(教师号、姓名、性别、系别)、成绩(学号、课程号、分数)。
这里有个容易绕进去的点:成绩到底算不算实体。严格按 E-R 模型的说法,成绩不是实体,是学生和课程之间「选修」联系上的属性。但落到关系模型里,多对多联系必须独立成表,于是它就变成了一张sc表。这个转换过程在报告里要写清楚,不能跳过——老师看 E-R 图,看的就是你会不会把 m:n 拆成联系表。
三组联系的基数要逐个说:
- 学生与课程:多对多。一个学生选多门课,一门课被多个学生选,拆出
sc(sno, cno, degree),(sno, cno)组合主键。 - 教师与课程:一对多。一门课由一位教师讲授,一位教师可以讲多门课。所以教师号作为外码放在
course表里,而不是反过来。 - 学生与班级、专业:这里不做拆分。班级和专业在课程设计的规模下当成学生的属性就够了,拆成独立表会多出两张表和两次连接,收益不大。
全局 E-R 图就是把上面三组联系拼起来:以学生和课程为中心,中间挂sc联系,课程一侧连到教师。画图的时候把每个实体的主键用下划线标出来,联系用菱形,基数标在连线上,这套符号规范在报告里比画得好看重要得多。
顺带说一个设计取舍:为什么不在course表里存「选课人数」。题目原文的字段表里出现过「选课人数(tno)」这种写法,字段名和注释明显对不上。这类冗余统计字段是典型的坑——选课人数是可以从sc表COUNT(*)算出来的派生数据,存进去就得靠触发器或者定时任务维护,一旦漏更新,数据就永久不一致。我的做法是坚决不存,统计时现算:
-- 选课人数现算,不落成字段,避免和 sc 表数据打架 SELECT c.cno, c.cname, COUNT(s.sno) AS stu_cnt FROM course c LEFT JOIN sc s ON c.cno = s.cno GROUP BY c.cno, c.cname;用LEFT JOIN而不是INNER JOIN,是为了让没人选的课也能出现在结果里,计数显示 0。这个小细节在答辩时被追问过一次,讲清楚就变成了加分项。
2.3 数据字典:字段类型和长度的取舍
数据字典这一节在报告里通常是一张表打天下,但表背后每个字段的选择都得有理由,否则老师随便挑一个问「为什么姓名用 VARCHAR(8)」,答不上来就尴尬。
| 表名 | 字段 | 类型 | 允许空 | 约束 | 选型理由 |
|---|---|---|---|---|---|
| student | sno | CHAR(10) | 否 | 主键 | 学号定长,CHAR 比 VARCHAR 省一个长度字节,且比较更快 |
| student | sname | VARCHAR(8) | 是 | — | 姓名长度不定,8 个字符足够覆盖常见中文名 |
| student | ssex | CHAR(2) | 是 | 取值男/女 | 定长枚举,用 CHAR 而不是 ENUM,方便跨库迁移 |
| student | smajor | VARCHAR(20) | 是 | — | 专业名称较长,留 20 个字符 |
| student | sclass | CHAR(10) | 是 | — | 班级编号格式固定,定长更省空间 |
| course | cno | CHAR(5) | 否 | 主键 | 课程号形如 C01,固定 5 位 |
| course | cname | VARCHAR(20) | 否 | — | 课程名不允许空,教务系统里无名课程没有意义 |
| course | tno | CHAR(3) | 是 | 外键→teacher | 允许空,用于表达「课程尚未排课」 |
| sc | sno | CHAR(10) | 否 | 组合主键、外键 | 必须与 student.sno 完全一致 |
| sc | cno | CHAR(5) | 否 | 组合主键、外键 | 必须与 course.cno 完全一致 |
| sc | degree | DECIMAL(4,1) | 是 | 0–100 | 一位小数,DECIMAL精确无误差,避免浮点比较失真 |
| teacher | tno | CHAR(3) | 否 | 主键 | 教师工号固定 3 位 |
| teacher | tname | VARCHAR(8) | 是 | — | 同姓名字段 |
| teacher | tsex | CHAR(2) | 是 | 取值男/女 | 同性别字段 |
| teacher | tdept | CHAR(16) | 是 | — | 系别名称,16 个字符够用 |
表里最值得单独拎出来说的是degree为什么选DECIMAL(4,1)而不是FLOAT。成绩是给人看的,61.5 就是 61.5,用浮点存进去可能变成 61.499999,degree >= 60这种判断就会莫名其妙地把及格判成不及格。数值精确性这件事在课设里体现不出来,但它是标准的工程习惯,写进报告里说明「避免二进制浮点表示误差」,比单纯罗列字段有说服力。
另一个细节是sno的长度。学生表定CHAR(9)、成绩表定CHAR(10),是很多往届方案里的原样照抄,两表长度不一致会直接导致外键建不上,或者连接时发生隐式截断。统一成CHAR(10)是最省事的做法,后面第 5 章还会专门讲这个坑。
3. 逻辑结构与建表落地:主键、外键和那把不生效的 CHECK
3.1 四张表的建表 SQL 与约束落地
设计讲完,开始落 SQL。建表脚本我一般按「无依赖的先建、有依赖的后建」顺序排:student、teacher、course三张独立表先来,sc表因为同时引用了student和course,必须放最后。顺序错了会直接报 1215 外键约束错误。
-- ========== 1. 学生表 ========== -- sno 统一为 CHAR(10),与 sc.sno 严格对齐,外键才能建成功 CREATE TABLE student ( sno CHAR(10) NOT NULL COMMENT '学号', sname VARCHAR(8) DEFAULT NULL COMMENT '姓名', ssex CHAR(2) DEFAULT NULL COMMENT '性别:男/女', smajor VARCHAR(20) DEFAULT NULL COMMENT '专业', sclass CHAR(10) DEFAULT NULL COMMENT '班级', PRIMARY KEY (sno) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- ========== 2. 教师表 ========== CREATE TABLE teacher ( tno CHAR(3) NOT NULL COMMENT '教师工号', tname VARCHAR(8) DEFAULT NULL COMMENT '姓名', tsex CHAR(2) DEFAULT NULL COMMENT '性别:男/女', tdept CHAR(16) DEFAULT NULL COMMENT '所属系别', PRIMARY KEY (tno) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- ========== 3. 课程表 ========== -- tno 是任课教师号,不是选课人数;一门课对应一位教师,故外键放这一侧 CREATE TABLE course ( cno CHAR(5) NOT NULL COMMENT '课程号', cname VARCHAR(20) NOT NULL COMMENT '课程名', tno CHAR(3) DEFAULT NULL COMMENT '任课教师号', PRIMARY KEY (cno), KEY idx_course_tno (tno), CONSTRAINT fk_course_teacher FOREIGN KEY (tno) REFERENCES teacher (tno) ON UPDATE CASCADE ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -- ========== 4. 成绩表 ========== CREATE TABLE sc ( sno CHAR(10) NOT NULL COMMENT '学号', cno CHAR(5) NOT NULL COMMENT '课程号', degree DECIMAL(4,1) DEFAULT NULL COMMENT '成绩,0-100', PRIMARY KEY (sno, cno), KEY idx_sc_cno (cno), KEY idx_sc_degree (degree), CONSTRAINT fk_sc_student FOREIGN KEY (sno) REFERENCES student (sno) ON DELETE CASCADE, CONSTRAINT fk_sc_course FOREIGN KEY (cno) REFERENCES course (cno) ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8;几个参数和写法的说明:
ENGINE=InnoDB必须显式写。MySQL 5.5 的默认引擎已经是 InnoDB,但显式声明能防止某些环境由于配置文件差异退化成 MyISAM——MyISAM 不支持外键,建表语句会静默忽略FOREIGN KEY子句,最后你的外键约束等于没写。
ON DELETE CASCADE放在sc对student的外键上,意思是学生被删除时,他的选课记录一起清掉,不留孤儿数据。而course对teacher用ON DELETE SET NULL,是因为教师离职不该牵连课程本身,把任课教师置空、课程保留,更符合教务的实际操作。这两种删除策略的区别,答辩时经常被问。
PRIMARY KEY (sno, cno)是组合主键,它同时保证了两件事:每个学生每门课只有一条成绩记录,以及(sno, cno)组合天然形成唯一索引,学生查自己成绩时走sno最左前缀,效率没问题。
注意:题目原文里写了
CONSTRAINT A2 CHECK(degree>=0 and degree<=100),这条在 MySQL 5.5 里会被解析但直接忽略,不报错也不生效。想真正限制成绩范围,得用触发器,见 3.3 和 5.1。
3.2 主键与组合主键的依赖顺序
主键的选择在四张表里其实只有一处需要动脑:sc表为什么不用自增 id。
假设给sc加一个id INT AUTO_INCREMENT PRIMARY KEY,然后把(sno, cno)降级成普通唯一索引,表面上没坏处,但会带来两个实际问题。第一,重复选课无法从数据库层面拦住——唯一索引也能拦,但主键的语义更直白;第二,所有按「学生+课程」定位记录的操作都要多绕一层,写UPDATE sc SET degree=? WHERE id=?的时候,你还得先查出 id,等于白搭。
组合主键还有个隐性好处:它本身就是覆盖索引。查「某学生某门课的成绩」,数据库只需要扫主键索引的叶子节点,不用回表。在数据量小的课设里体现不出来,但这是标准做法,报告里写一句「避免了回表查询」就够了。
主键定完,插入顺序也要跟着走。往sc里插一条成绩,前提是student里已有该学号、course里已有该课程号,否则外键直接拒绝。这个依赖链在造测试数据时最容易翻车——先插成绩再插学生,报 1452 错误,然后有人图省事把外键删掉,方案就废了一半。
-- 按依赖顺序造数据:先实体,后联系 INSERT INTO student (sno, sname, ssex, smajor, sclass) VALUES ('2021030101', '赵一', '男', '计算机科学与技术', '4412'); INSERT INTO teacher (tno, tname, tsex, tdept) VALUES ('101', '钱二', '女', '计算机系'); INSERT INTO course (cno, cname, tno) VALUES ('C01', '科学导论', '101'); INSERT INTO sc (sno, cno, degree) VALUES ('2021030101', 'C01', 86.5);另外,INSERT一定要显式写出列名。题目原文里有INSERT INTO teacher VALUES('140210316','叶何斌','男','计算机科学与技术','计算机1403')这种写法,五个值往四个列里塞,直接报 1136 列数不匹配。显式列名除了防这个,还能防止以后表加字段时旧脚本静默错位。
3.3 索引设计与查询代价
课设里索引往往被忽略,但它是最便宜的性能改进。四张表该建哪些索引,取决于三条查询路径:
第一条,学生查自己的成绩。走sc表主键的最左前缀sno,已经覆盖,不需要额外建。
第二条,教务处查某门课的不及格名单。条件同时包含cno和degree,我在sc表上单独建了idx_sc_cno和idx_sc_degree。严格说这里更优的做法是建联合索引(cno, degree),一个索引顶两个用,还能避免回表。课设数据量小,两棵单列索引也够跑,但报告里把「为什么可以合并成联合索引」写出来,层次立刻不一样。
第三条,查没有教学任务的老师。这条要拿teacher左连接course,course.tno上必须有索引,否则每扫一行教师都要全表扫一遍课程表。所以idx_course_tno不是可有可无的装饰。
-- 查看索引命中情况,答辩前跑一遍,确认没有全表扫 EXPLAIN SELECT s.sno, s.sname, s.degree FROM student s JOIN sc ON s.sno = sc.sno WHERE sc.cno = 'C03' AND sc.degree < 60; -- 如果 type 列出现 ALL,说明索引没生效,重点看 key 那一列是不是 NULLEXPLAIN这件事值得单独强调。答辩现场能随手敲出EXPLAIN并解释type、key、rows三个字段含义的同学,给老师留下的印象和只会说「我建了索引」的完全不在一个档位。type从好到差是const、eq_ref、ref、range、index、ALL,看到ALL就说明全表扫,rows估算值过大也要警惕。
索引不是越多越好。每建一个索引,插入和更新就多一份维护开销,sc表是写入最频繁的表,索引控制在三个以内是合理的。这一点写进报告,可以解释你为什么没有给sname、sclass这类字段建索引——课设规模下用不上,属于过度设计。
4. 数据库对象与统计查询:视图、存储过程、触发器的分工
4.1 视图:一个只读汇总,一个可写投影
题目里给了两个视图,恰好代表了两种完全不同的用途,这个对比是这章的核心。
v_student是三表连接的汇总视图,用来回答「选修科学导论的学生及其成绩」:
-- 三表连接视图,只读,不能直接 INSERT/UPDATE CREATE VIEW v_student AS SELECT a.sno, a.sname, c.degree FROM student a JOIN sc c ON a.sno = c.sno JOIN course b ON b.cno = c.cno WHERE b.cname = '科学导论';这个视图的价值不在查询本身,而在收敛复杂度。以后凡是查这门课的成绩,都写SELECT * FROM v_student,不用每次都把三表连接重敲一遍,也不用担心某次连接条件写漏导致笛卡尔积。
view_sc是单表投影视图,只包含sc的三列,属于可更新视图:
-- 单表投影,行列子集,属于可更新视图,可以透过它写基表 CREATE VIEW view_sc AS SELECT sno, degree, cno FROM sc; -- 通过视图插入,数据实际落进 sc 表 INSERT INTO view_sc (sno, cno, degree) VALUES ('2021030102', 'C01', 88.0); -- 修改视图定义和删除 ALTER VIEW view_sc AS SELECT sno, degree, cno FROM sc; DROP VIEW IF EXISTS view_sc;两个视图放在一起对比,能讲清楚一个关键知识点:视图可不可更新,取决于它的定义方式。单表、不含聚合、不含DISTINCT、不含GROUP BY的视图叫行列子集视图,可以更新;一旦涉及多表连接或者聚合函数,就只能读。这条规则在报告里写明白,比列一堆视图定义有价值得多。
如果需要限制通过视图插入的数据范围,可以加WITH CHECK OPTION,比如让学生只能看到自己班的记录,插入别班数据时直接拒绝。
4.2 存储过程:IN 参数、OUT 参数和那个必须改的结束符
存储过程在课设里的作用是「把一段常用逻辑封起来」,题目给了两个例子,正好覆盖了 IN 和 OUT 两种参数。
第一个是带IN隐含条件的查询过程,检索指定班级的学生:
DELIMITER // -- 把结束符临时改成 //,否则过程体里的分号会被客户端当成语句结尾提前提交 CREATE PROCEDURE proc_stud() READS SQL DATA BEGIN SELECT sno, sname, smajor FROM student WHERE sclass LIKE '%4412%' ORDER BY sno; END // DELIMITER ; -- 恢复成默认分号 CALL proc_stud();DELIMITER //这行是新手最容易漏的。客户端默认以分号作为语句结束标志,而存储过程体内本来就有一堆分号,不改结束符的话,执行到第一个分号就断了,报语法错误。READS SQL DATA是特征子句,声明这个过程只读不写,属于规范写法,答辩被问到可以解释「用于告知优化器过程的数据访问特征」。
第二个是带OUT参数的统计过程,返回某个学生修了几门课:
DELIMITER // CREATE PROCEDURE num_sc(IN tmp_sno CHAR(10), OUT count_num INT) READS SQL DATA BEGIN SELECT COUNT(*) INTO count_num FROM sc WHERE sno = tmp_sno; END // DELIMITER ; -- 调用时要传一个用户变量接返回值,再用 SELECT 打印 CALL num_sc('2021030101', @cnt); SELECT @cnt AS course_count;IN tmp_sno是传入参数,OUT count_num是传出参数。调用时用@cnt这个用户变量接住 OUT 值,过程跑完再单独 SELECT 出来。这里有个小细节:如果传入的学号在sc里一条记录都没有,COUNT(*)返回 0,@cnt就是 0 而不是 NULL,行为是可预期的。真正危险的是用SELECT ... INTO从空结果集取非聚合列,那会把变量置为 NULL 而不报错,属于典型的静默失败。
4.3 触发器:删除学生时把痕迹留下
触发器这块,题目要求很明确:学生被删除时,把学号姓名写进delstudent留档。这是个很实在的需求,教务系统里删学生是敏感操作,留痕是基本要求。
-- 先建一张结构相同的空表接数据,WHERE 1=0 保证只复制结构不带数据 CREATE TABLE delstudent ( sno CHAR(10), sname VARCHAR(8), del_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8; DELIMITER // CREATE TRIGGER trig_student AFTER DELETE ON student FOR EACH ROW BEGIN INSERT INTO delstudent (sno, sname) VALUES (OLD.sno, OLD.sname); END // DELIMITER ; -- 验证:删一个人,看留档表里有没有 DELETE FROM student WHERE sname = '孙三'; SELECT * FROM delstudent;几个关键点。AFTER DELETE说明在删除动作完成之后触发,此时OLD里还保留着被删那行的完整数据,用OLD.sno、OLD.sname就能取到。FOR EACH ROW是行级触发器的标准写法,注意是字母 O 不是数字 0,这个笔误在往届方案里出现过不止一次,报语法错误还找不到原因。
我在建delstudent时多加了一个del_at字段,默认值取当前时间。理由很简单:留档如果不带时间,事后根本没法追溯是哪天删的,留了等于没留。这个字段不在题目要求里,但属于「合格负责人会做的事」,写进报告里是加分项。
还有个更隐蔽的问题:sc表对student建了ON DELETE CASCADE外键,删除学生时选课记录会被数据库自动清掉。这意味着你无法在AFTER DELETE触发器里通过查OLD拿到被删学生的成绩明细——那时候已经没了。如果业务要求保留成绩历史,就得改在BEFORE DELETE里把成绩先备份走,或者干脆取消级联、改成软删除。这个顺序问题我在第一次做的时候完全没意识到,是跑完发现留档表只有姓名没有成绩才反应过来的。
4.4 多表连接:教务处最关心的几组统计
题目后面列了一串查询,看起来零散,其实可以归成三类:单表条件查、多表连接查、分组聚合查。每一类挑一两个讲透比全列一遍有用。
第一类,单表加简单聚合。算某门课的平均分:
-- AVG 会自动忽略 NULL,所以没录成绩的选课记录不会把平均分拉低 SELECT ROUND(AVG(degree), 2) AS avg_score FROM sc WHERE cno = 'C01';AVG忽略 NULL 这个特性要心里有数。如果某学生选了课但成绩还没录,这条记录不计入分母,算出来的平均分是「已录成绩学生的平均分」,不是「所有选课学生的平均分」。两种口径在教务上都有用,报告里说明清楚用哪种就行。
第二类,多表连接。查选修「高等数学」的学生学号姓名:
SELECT a.sno, a.sname FROM student a JOIN sc b ON a.sno = b.sno JOIN course c ON b.cno = c.cno WHERE c.cname = '高等数学';JOIN 的顺序不影响结果,但影响执行计划。驱动表选小表、被驱动表的连接列有索引,这两个原则在多表连接里一直适用。
第三类,是题目里两个真正有业务味道的统计需求。查 C03 课程不及格的学生:
SELECT a.sno, a.sname, a.ssex, a.smajor, a.sclass, b.degree FROM student a JOIN sc b ON a.sno = b.sno WHERE b.cno = 'C03' AND b.degree < 60 ORDER BY b.degree;再加上一条按课程维度的不及格人数汇总,教务处一次就能拿到全貌:
-- 每门课的不及格人数,课上没人挂科的显示 0 SELECT c.cno, c.cname, COUNT(CASE WHEN s.degree < 60 THEN 1 END) AS fail_cnt FROM course c LEFT JOIN sc s ON c.cno = s.cno GROUP BY c.cno, c.cname ORDER BY fail_cnt DESC;COUNT(CASE WHEN ... THEN 1 END)这种写法比SUM(IF(...))更标准,也更通用。COUNT只统计非 NULL 值,条件不成立时CASE返回 NULL,正好被跳过,一行代码同时解决了计数和过滤。
第二个特色需求是「查没有教学任务的老师」。这条至少有三种写法,效果一样但含义不同:
-- 写法一:左连接找空值,可读性最好,推荐 SELECT t.tno, t.tname, t.tdept FROM teacher t LEFT JOIN course c ON t.tno = c.tno WHERE c.cno IS NULL; -- 写法二:NOT EXISTS,外键列允许 NULL 时比 NOT IN 安全 SELECT t.tno, t.tname FROM teacher t WHERE NOT EXISTS (SELECT 1 FROM course c WHERE c.tno = t.tno); -- 写法三:NOT IN,注意 course.tno 为 NULL 时会返回空结果集 SELECT tno, tname FROM teacher WHERE tno NOT IN (SELECT tno FROM course);写法三是最容易翻车的一个。如果course.tno里存在 NULL(表示课程尚未排课),NOT IN的三值逻辑会让整个查询返回空集——明明有没排课的老师,查出来却是零条。这个坑极其隐蔽,我在调试时对着空结果集愣了半天,最后靠SELECT * FROM course WHERE tno IS NULL才定位到。所以方案里统一规定:外键列要么加NOT NULL,要么用NOT EXISTS或LEFT JOIN写法,别碰NOT IN。
5. 常见翻车点排查:五条踩过的坑和对应的解法
5.1 数据层面的三个坑
坑一:CHECK 约束写得很认真,但一条都没生效。
现象:sc表里插入了degree = 200的记录,数据库没有任何报错,查询时还能正常显示。原因是 MySQL 5.5 对CHECK子句只做语法解析,不做语义执行,建表语句不报错,运行时完全忽略。解决方式有两条路:一是把校验逻辑挪到触发器里用SIGNAL抛异常,二是改用支持 CHECK 的数据库版本。课设环境固定,选前者:
DELIMITER // CREATE TRIGGER trg_sc_degree_check BEFORE INSERT ON sc FOR EACH ROW BEGIN IF NEW.degree IS NOT NULL AND (NEW.degree < 0 OR NEW.degree > 100) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '成绩必须在 0 到 100 之间'; END IF; END // DELIMITER ;更新的时候也要拦,所以还得补一个BEFORE UPDATE的同名触发器。这类校验用SIGNAL SQLSTATE '45000'抛自定义错误,客户端能拿到MESSAGE_TEXT里那句话,比默认的约束错误信息友好得多。
坑二:两张学号表长度不一致,外键死活建不上。
现象:执行ALTER TABLE sc ADD FOREIGN KEY (sno) REFERENCES student(sno),报 1005 错误,SHOW ENGINE INNODB STATUS里能看到「Foreign key constraint is incorrectly formed」。原因是student.sno定的是CHAR(9),sc.sno定的是CHAR(10),类型长度必须完全一致才能建外键。更隐蔽的情况是两边长度一致但字符集不同,比如一张表utf8一张表latin1,同样建不上。解决就是建表前把所有关联列的类型、长度、字符集、排序规则统一,最稳的做法是先建student,然后直接复制它的列定义写sc,别手敲。
坑三:删除学生之后,成绩历史跟着没了。
现象:跑完删除学生的语句,delstudent留档表里只有学号和姓名,想追查这人以前考了多少分,发现sc里的记录已经没了。原因是sc表对student建了ON DELETE CASCADE,级联删除在触发器之前就执行完了。解决办法取决于业务要什么:如果只是要留个删除痕迹,现在的设计够了;如果要保留成绩历史,就别用在AFTER DELETE里查sc,改成在BEFORE DELETE触发器中先把sc的记录复制到一张del_sc表,再让级联删。或者干脆放弃物理删除,给student加一个is_deleted标志位做软删除,答辩时还能顺带聊两句为什么生产系统很少做物理删除。
5.2 对象与脚本层面的两个坑
坑四:拿三表连接的视图去 INSERT,报不可更新错误。
现象:照着v_student的定义插入一条数据,MySQL 报 1288「The target table of the UPDATE is not updatable」。原因就是 4.1 讲过的——含 JOIN 的视图是只读的。解决办法是区分场景:需要写入就直连基表sc,或者另建一个单表投影视图view_sc专门干写入的活。这个坑的价值在于它逼着学生去理解「视图到底是个什么」:视图不是表的副本,是保存下来的查询,能查不等于能写。
坑五:INSERT 列数和值个数对不上,报 1136。
现象:执行一条从往届方案抄来的INSERT INTO teacher VALUES('140210316','叶何斌','男','计算机科学与技术','计算机1403'),报 1136「Column count doesn't match value count at row 1」。数一下就知道,五个值往四列的teacher表里塞。原因是往届方案里teacher表有五个字段,改版后删了一个,插入语句没跟着改。解决办法是永远显式写出列名:INSERT INTO teacher (tno, tname, tsex, tdept) VALUES (...),这样表结构再变,对不上的时候报的是明确的列不存在,而不是含糊的个数不匹配。同理,造测试数据别用SELECT *顺序依赖的写法,字段顺序一调就全错。
6. 验收与答辩:一份能照着念的检查清单
方案写完不验收,等于没做完。答辩前我会把下面这张表跑一遍,每一项都有明确的执行语句和预期结果,跑完心里才踏实。
| 验收项 | 验证方式 | 及格线 |
|---|---|---|
| 四张表建成 | SHOW TABLES; | 返回 student、teacher、course、sc |
| 外键生效 | 插一条不存在的学号到 sc | 报 1452,插入失败 |
| 成绩范围受控 | 插degree = 120 | 报 45000,触发器拦住 |
| 组合主键去重 | 同学号同课程插两次 | 报 1062,主键冲突 |
| 视图可查 | SELECT * FROM v_student; | 只返回科学导论的记录 |
| 视图可写 | 向view_sc插一条 | 成功,基表sc能查到 |
| 存储过程可调 | CALL num_sc('xxx', @n); SELECT @n; | 返回该生修课门数 |
| 触发器留痕 | 删一个学生后查delstudent | 新增一条带时间的记录 |
| 索引命中 | EXPLAIN查不及格名单 | key列不为 NULL,type不是 ALL |
| 无教学任务教师 | 执行左连接查询 | 结果与手工核对一致 |
这张表的作用不只是自查,答辩演示时可以直接按顺序走一遍,节奏感会好很多。老师看到的是「这个人知道该验什么」,而不是「这个人背了几条 SQL」。
答辩现场被追问频率最高的三个问题,提前把答案想好。第一个是「为什么sc不用自增主键」,答案在 3.2,核心是组合主键天然唯一、避免回表、语义清晰。第二个是「一门课有多个老师怎么办」,这是对course.tno单外键设计的挑战,诚实点回答:当前设计基于「一门课一位主讲教师」的前提,如果要多教师授课,就得拆出一张teach(tno, cno)联系表,把一对多改成多对多,改动量不大但 E-R 图要重画。第三个是「成绩改了怎么追溯」,如果没有准备,这题很容易答成「改了就改了」,正确姿势是指向delstudent那套留痕思路,说清楚如果要完整审计还得加一张变更日志表,用触发器记录OLD.degree和NEW.degree。
最后说个我自己的习惯。第一次做这类课设时,我是先建表后想需求,结果建到一半发现「没排课的老师」这个查询需要course.tno允许为空,而我已经把它设成了NOT NULL,只能全表删了重来,两个小时的活白干。从那以后我每次动手建表前,都强制走一遍「三条核心查询能不能跑通」的推演——把教务处那几句原话翻译成 SQL 骨架,跑一遍心里过一遍,确认字段够用、类型对得上、空值允许,再打开 Navicat。这一步多花二十分钟,能省掉后面大半的返工。希望帮到你。
本文还有配套的精品资源,点击获取