MySQL学生选课系统数据库设计:从ER模型到存储过程并发控制
2026/9/12 17:49:05 网站建设 项目流程

简介:这是一份面向高校数据库课程设计的实践资源,主题为“某高校学生选课系统”,面向正在完成数据库原理及应用课程设计、需要掌握系统分析与数据库设计全流程的学生。压缩包共3个文件:1份课程设计报告(doc)、1个SQL脚本和1个数据库备份(bak),分别对应设计文档、数据定义与初始数据,整体仅802KB,轻量易用。资源已获得998人学习。内容围绕课程设计目的、需求分析、数据库设计及数据录入处理展开,报告涵盖系统分析、数据模型优化、数据库结构、功能结构以及安全性与完整性要求等关键环节;SQL脚本可直接导入数据库查看表结构、视图、存储过程等设计成果,bak备份文件则便于还原完整数据库进行对比验证。无论课程设计还是期末项目,都能帮助读者少走弯路,适合希望参考高分课设范例、快速理解从需求分析到数据库落地全过程的初学者。

1. 数据库课程设计遇到学生选课系统,先别急着建表

数据库课程设计里,学生选课系统是出现频率最高也最容易翻车的题目。表面看无非学生、课程、成绩三张表,真正按课程设计标准交上去的时候,却经常被一个问题问住:同一门课只剩最后一个名额,两个学生同时选课,拿什么保证不会超选。很多同学把 .rar 压缩包里的脚本重新执行一遍就发现,外键顺序错了、联合主键没表达出退课记录、成绩字段挂错了表。下面按课程设计通用的流程走,从业务规则、ER 模型、MySQL 建库脚本到存储过程选课事务,最后给出答辩前自检技巧,所有语句在 MySQL 8.0 下可以直接执行。

2. 先定业务规则再建模,学生选课系统的实体与关系模式

课程设计翻车的第一大原因,是拿到题干后直接打开 MySQL 建表,连业务规则都还没定。学生选课系统至少要考虑选课时间窗口、课程容量、退课截止时间、重修是否允许重复选这几条约束;这些规则决定了选课记录表的唯一约束写在哪一列、容量检查放在事务的哪一步、成绩更新会不会产生脏数据。我一直用的顺序是:先列用例和业务规则,画 ER 图,再补关系模式,最后才写 CREATE TABLE,哪怕时间再赶也不能跳。

2.1 用用例反推实体,选课记录是关系实体

常见做法是列出学生、教师、教学秘书三类角色,把每个角色能做的事翻译成用例。学生查课、选课、退课、查成绩,教师开课、录入成绩,教学秘书审核开课计划和调容量。去掉重复名词后得到的基本实体有学生、教师、课程、开课计划、选课记录;其中选课记录是学生和开课计划之间的关系实体,不能省略。开课计划必须在实体清单里单独出现,因为同一门课程在不同学期、不同班次会有不同容量与上课时间。把课程和开课计划混在一张表里,会让字段出现大量重复,后续为了调整上课时间还要拆表。

课程设计报告里可以直接用下面这样一张表格交代实体、关键属性、主键与关系,评阅老师扫一眼就知道数据模型有没有走样。

实体名关键属性主键候选与其它实体的关系
studentstudent_id, name, major, gradestudent_id与选课记录为 1:N
teacherteacher_id, name, titleteacher_id与开课计划为 1:N
coursecourse_id, course_name, creditcourse_id与开课计划为 1:N
course_scheduleschedule_id, semester, capacityschedule_id与选课记录为 1:N
enrollstudent_id, schedule_id, score, statusenroll_id 或联合主键关联学生和开课计划

2.2 选课记录用单列主键还是联合主键

关系模式转出来之后,第一个容易被追问的设计点是 enroll 表选哪种主键。联合主键 (student_id, schedule_id) 语义上直接对应“一个学生同一班次只能选一次”,适合需求简单的作业;但后续如果出现补考记录、重修分批、同一学期同一门课不同教学班,联合主键反而需要加入更多列。更稳妥的做法是给 enroll 表加一列自增 enroll_id,同时加一个唯一约束 uk_enroll(student_id, schedule_id)。这样做既保留了防重能力,又让业务表的主键保持稳定,写 JOIN 时也能少一层冗余。

课程设计报告里把这个决策写清楚,老师会认为你真正考虑过数据生命周期。联合主键在 InnoDB 里直接作为聚簇索引,长字符串组合会让次级索引变大;换用自增主键配合唯一约束,代码里也更容易判断某次操作影响的是选课记录本身,还是选课记录对应的学生。下面这条查询适合在答辩前做数据自检,用于确认有没有重复选课记录。

-- 计算出同一个学生重复选同一班次的次数,用于自检唯一性 SELECT student_id, schedule_id, COUNT(*) AS duplicate_count FROM enroll GROUP BY student_id, schedule_id HAVING COUNT(*) > 1;

这段 SQL 的核心是 GROUP BY 后接 HAVING COUNT(*) > 1,它筛出的每一行都代表一个潜在的重复选课。如果 enroll 表上有联合唯一约束,这个查询通常返回空集;如果业务层只写了一个 INSERT 而没有唯一约束,这条语句就是答辩时最直接的证据。GROUP BY 后面的两列顺序不影响结果集,但会影响 MySQL 是否能用上复合索引,实际数据量很小时可以不做额外调整。

2.3 用第二范式和第三范式检查属性归属

建表前把候选表逐张过一遍范式检查,能少填很多坑。第二范式处理联合主键下的部分依赖:如果 enroll 表使用 (student_id, course_id) 做联合主键,同时放了课程名,课程名只依赖 course_id,不依赖 student_id,这就是部分依赖,会导致课程改名时必须更新选课表多行。第三范式处理非主属性之间的传递依赖:把学院院长姓名放在 student 表里,属于传递依赖,学院改名或换院长时会产生不一致。

实际修改方向也不难判断:把只依赖主键一部分的字段拆出去,把依赖非主键的字段也拆出去。课程基本信息和教师基本信息各放各的表,选课记录里只保留 student_id 和 schedule_id 两个外键。表面上看查询多了一次 JOIN,但增删改查的数据一致性维护成本更低,课程设计评审时也更愿意给你过。

3. 用 MySQL 建库建表,跑通学生选课系统的增删改查

关系模式定完后进入实现。下面这套脚本以 MySQL 8.0 为准,存储引擎全部用 InnoDB,字符集用 utf8mb4,排序规则用 utf8mb4_unicode_ci。utf8mb4 不是可选项,课程名里出现数学符号、人名时,老字符集很容易报 Incorrect string value,答辩现场改表结构很浪费时间。InnoDB 提供事务和行级锁,后面的选课存储过程要靠它保证并发时不会超选,这一点在选题理由里也应该写一句。

3.1 建库建表脚本,外键先建父表再建子表

下面是课程设计里最常用的最小建表脚本,包含学生、教师、课程、开课计划、选课记录五张表。注意执行顺序:先建 student、teacher、course,再造 course_schedule,最后建 enroll,否则外键引用的表还不存在,MySQL 会直接报 errno 150。

CREATE DATABASE IF NOT EXISTS course_selection DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE course_selection; CREATE TABLE student ( student_id VARCHAR(20) PRIMARY KEY, name VARCHAR(50) NOT NULL, major VARCHAR(50), grade INT, enroll_year YEAR ) ENGINE=InnoDB; CREATE TABLE teacher ( teacher_id VARCHAR(20) PRIMARY KEY, name VARCHAR(50) NOT NULL, title VARCHAR(20) ) ENGINE=InnoDB; CREATE TABLE course ( course_id VARCHAR(20) PRIMARY KEY, course_name VARCHAR(100) NOT NULL, credit DECIMAL(3,1), course_type VARCHAR(20) ) ENGINE=InnoDB; CREATE TABLE course_schedule ( schedule_id INT AUTO_INCREMENT PRIMARY KEY, course_id VARCHAR(20) NOT NULL, teacher_id VARCHAR(20) NOT NULL, semester VARCHAR(20) NOT NULL, capacity INT DEFAULT 60, selected_count INT DEFAULT 0, CONSTRAINT fk_cs_course FOREIGN KEY (course_id) REFERENCES course(course_id) ON UPDATE CASCADE ON DELETE RESTRICT, CONSTRAINT fk_cs_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) ON UPDATE CASCADE ON DELETE RESTRICT ) ENGINE=InnoDB; CREATE TABLE enroll ( enroll_id INT AUTO_INCREMENT PRIMARY KEY, student_id VARCHAR(20) NOT NULL, schedule_id INT NOT NULL, score DECIMAL(5,2) NULL, status ENUM('selected','dropped','finished') DEFAULT 'selected', select_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_enroll (student_id, schedule_id), CONSTRAINT fk_enroll_student FOREIGN KEY (student_id) REFERENCES student(student_id) ON UPDATE CASCADE ON DELETE RESTRICT, CONSTRAINT fk_enroll_schedule FOREIGN KEY (schedule_id) REFERENCES course_schedule(schedule_id) ON UPDATE CASCADE ON DELETE RESTRICT ) ENGINE=InnoDB;

建表脚本要讲清楚三处参数。course_schedule 的 capacity 与 selected_count 都用 INT,课程设计阶段默认 60 即可,不要用 SMALLINT 加 CHECK 限制,因为旧版本 MySQL 对 CHECK 子句不一定执行。enroll 表的 status 用 ENUM 而不是 VARCHAR,能够把合法状态限定在 selected、dropped、finished 三种,省掉应用层一次数据校验。外键的 ON UPDATE CASCADE 保证学号、课程号变更时关联子表自动同步;ON DELETE RESTRICT 防止误删父表记录把成绩历史一起带走,这两个选项在答辩时是被问到最多的部分。

提示:如果脚本要反复执行,表已存在时先 DROP 再跑,或者在建表语句上补 IF NOT EXISTS;不要图方便开 SET FOREIGN_KEY_CHECKS=0,否则外键约束在导入数据时会失效,重复执行脚本很可能留下孤立数据。

3.2 插入测试数据,并用 JOIN 完成典型查询

脚本建好以后先插入几条带关联关系的测试数据,再跑查询,完整验证外键是否生效。课程设计报告中,这一步对应“系统测试”或“功能验证”章节。

INSERT INTO student (student_id, name, major, grade, enroll_year) VALUES ('2023001', '张三', '计算机科学与技术', 2023, 2023), ('2023002', '李四', '软件工程', 2023, 2023); INSERT INTO course (course_id, course_name, credit, course_type) VALUES ('CS101', '数据库原理', 3.5, '专业必修'), ('CS102', '操作系统', 3.0, '专业必修'); INSERT INTO teacher (teacher_id, name, title) VALUES ('T001', '王老师', '副教授'), ('T002', '陈老师', '讲师'); INSERT INTO course_schedule (course_id, teacher_id, semester, capacity) VALUES ('CS101', 'T001', '2024-2025-1', 60), ('CS102', 'T002', '2024-2025-1', 60); INSERT INTO enroll (student_id, schedule_id) VALUES ('2023001', 1), ('2023002', 1);

插入语句的要点是多值 INSERT,比逐条 VALUES 短,也便于恢复测试。注意 enroll 表没有显式插入 status 和 enroll_id,status 取默认值 selected,enroll_id 由自增列生成。真正要判断的外键是 student_id 和 schedule_id:如果插入一个不存在的学生,MySQL 会报 foreign key constraint fails;如果插入一个不存在的 schedule_id,也是外键约束报错,这个错误信息对课程设计排错很有用。

典型查询是“查看某学生已选课程列表”,它需要把 student、enroll、course_schedule、course 四张表串起来。

SELECT s.student_id, s.name, c.course_name, cs.semester, e.status, e.score FROM enroll e JOIN student s ON e.student_id = s.student_id JOIN course_schedule cs ON e.schedule_id = cs.schedule_id JOIN course c ON cs.course_id = c.course_id WHERE s.student_id = '2023001';

这个查询用三个 JOIN 完成多表关联,顺序上一般从事实表 enroll 出发,再逐级补充学生、开课计划、课程信息。WHERE 比 JOIN ON 晚一步过滤,所以当数据量变大时,尽量在 JOIN 之前用子查询先缩小 student 集合;课程设计阶段可以不做优化,但要能说清楚执行计划会怎么走。

3.3 用 ALTER TABLE 调整结构,UPDATE 和 DELETE 各补一例

课程设计报告里经常要体现“数据库维护功能”,至少要有一次 UPDATE 和一次 DELETE。修改课程名称对应 UPDATE,退课记录对应 DELETE。

UPDATE course SET course_name = '数据库系统概论' WHERE course_id = 'CS101'; DELETE FROM enroll WHERE student_id = '2023001' AND schedule_id = 1; SELECT ROW_COUNT() AS affected_rows;

UPDATE 的 WHERE 条件必须带主键或唯一索引,否则会整表更新。DELETE 同理,课程设计阶段没有开事务时,漏掉 WHERE 会把整张表清空。SELECT ROW_COUNT() 返回上一条语句影响的行数,可以用来确认到底删掉了哪一行。如果执行完发现数据乱了,最简单的方式是 DROP DATABASE course_selection,然后重新执行 3.1 的建表脚本和 3.2 的插入脚本,不要在半路手动改数据。

说到结构变更,常用的是 ALTER TABLE。给课程表增加校区字段、修改容量默认值,这两条在课程设计中足够覆盖大部分评分点。

ALTER TABLE course ADD COLUMN campus VARCHAR(50) DEFAULT '东校区'; ALTER TABLE course_schedule MODIFY COLUMN capacity INT NOT NULL DEFAULT 80;

ALTER TABLE 在执行时会与 DML 语句产生元数据锁竞争,但在课程设计的小数据量下基本无感。MODIFY COLUMN 会重建表,因此尽量把容量默认值和类型一次改到位,不要分两次操作。

4. 用存储过程实现选课事务,解决并发选课和容量检查

学生选课系统的评分拉开差距的地方通常在并发控制。普通做法是先 SELECT selected_count 判断是否满员,再 INSERT,但这个顺序在两个会话同时执行时会出现同一时间读到同一个人数,最后两个人都插入成功,课程超容。解决办法是把判断和插入放进同一个事务,并用 FOR UPDATE 锁住开课计划行,这个思路在数据库课程设计的答辩里要能完整复述。

4.1 为什么选课逻辑要收进存储过程,而不是写三条业务 SQL

如果应用层先执行 SELECT,再执行 INSERT,中间一旦有网络抖动或页面重复提交,两次请求根本不会感知对方。存储过程的优势是数据库端就能保证原子性,应用层只要调用一个 CALL 语句。课程设计中使用存储过程的另一个原因是报告上可以写“使用了数据库端业务逻辑封装”,这是一个明确的加分点;代价是调试比普通 SQL 麻烦,存储过程内部变量无法像应用日志那样直接打印。

4.2 选课存储过程的完整脚本与参数说明

下面是一个带事务和行锁的选课存储过程,用 InnoDB 的行级锁串行化同一个 schedule 的选课请求。

DELIMITER $$ CREATE PROCEDURE sp_select_course( IN p_student_id VARCHAR(20), IN p_schedule_id INT ) BEGIN DECLARE v_capacity INT DEFAULT 0; DECLARE v_selected INT DEFAULT 0; DECLARE v_exists INT DEFAULT 0; START TRANSACTION; SELECT capacity, selected_count INTO v_capacity, v_selected FROM course_schedule WHERE schedule_id = p_schedule_id FOR UPDATE; SELECT COUNT(*) INTO v_exists FROM enroll WHERE student_id = p_student_id AND schedule_id = p_schedule_id; IF v_exists > 0 OR v_selected >= v_capacity THEN ROLLBACK; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'repeat selection or course is full'; ELSE INSERT INTO enroll (student_id, schedule_id, status, select_time) VALUES (p_student_id, p_schedule_id, 'selected', NOW()); UPDATE course_schedule SET selected_count = selected_count + 1 WHERE schedule_id = p_schedule_id; COMMIT; END IF; END$$ DELIMITER ;

参数说明:p_student_id 和 p_schedule_id 是入参,分别代表学号和开课计划号;v_capacity、v_selected、v_exists 是局部变量,必须声明在 BEGIN 之后、任何可执行语句之前。SELECT ... FOR UPDATE 是这一脚本的关键,它会把 course_schedule 表对应 schedule_id 的行加上排他锁,其他事务更新同一行时会阻塞,直到 COMMIT 或 ROLLBACK。SELECT COUNT(*) INTO v_exists 用来检查重复选课,即使外层的 uk_enroll 唯一约束已经存在,事务里也要再查一次,这样做是为了给出可读的报错信息,而不是数据库抛一个含糊的 duplicate key。

IF 分支里只要满足重复选课或人数已满,就执行 ROLLBACK 并用 SIGNAL 抛错。SIGNAL SQLSTATE '45000' 是 MySQL 手动触发异常的标准写法,SET MESSAGE_TEXT 的内容会返回给客户端,应用层可以捕获后提示用户。正常情况下执行 INSERT 和 UPDATE,COMMIT 统一提交。需要注意 INSERT 里显式写了 select_time,调用时传入 NOW(),这样选课时间由数据库服务器统一生成,避免应用服务器时间不同步。

4.3 模拟两个客户端并发选课,观察锁等待行为

验证存储过程最直接的方法是开两个 mysql 客户端,同时向同一个 schedule_id 发起选课。会话 A 先执行 START TRANSACTION,然后调用过程,暂时不提交:

-- 会话 A START TRANSACTION; CALL sp_select_course('2023001', 1); -- 此时不 COMMIT,行锁被 A 持有 -- 会话 B CALL sp_select_course('2023002', 1); -- B 会阻塞在 SELECT ... FOR UPDATE,直到 A 提交或超时

如果 A 的选课结果是先进来占最后一个名额,B 会一直等待,等到 A COMMIT 后才会读到新的 selected_count,然后看到容量已满而回滚。这个现象解释了为什么 FOR UPDATE 比普通 SELECT 安全。生产环境中会在连接池层设置超时时间,MySQL 侧对应参数是 innodb_lock_wait_timeout,默认 50 秒,课程设计里可以调成 5 秒方便演示:

SET SESSION innodb_lock_wait_timeout = 5; CALL sp_select_course('2023002', 1);

设置超时后,如果 B 等待超过 5 秒,MySQL 会返回 Lock wait timeout exceeded,不会无限挂住。答辩演示时最好把两个终端窗口同时截进录屏,先开一个选课成功,再开另一个显示报错,比口头解释事务隔离级别更有说服力。还有一种情况是死锁,A 选了课程 1 又选课程 2,B 选了课程 2 又选课程 1,互相持有行锁;InnoDB 会自动检测并回滚其中一个事务。实际业务里避免死锁的常见做法是统一选课顺序,比如总是按 schedule_id 从小到大处理。

5. 交 .rar 之前用两条 SQL 自查,顺手把报告内容固化

课程设计最终提交的一般是一个压缩包,里面放着报告、SQL 脚本和截图。老师打开交付包后的第一步,通常是在干净数据库里执行一遍你的脚本,这一步很容易出问题。常见现象是脚本里的建表顺序和外键交叉混乱,或者某张表里的字符集不对导致中文变问号。我一般会固定一个最小自查顺序,跑完两条 SQL 再打包 .rar。

5.1 用 information_schema 确认表和行数

SELECT table_name, table_rows, auto_increment FROM information_schema.tables WHERE table_schema = 'course_selection';

这条语句把表名、估算行数、自增偏移量一起列出。要注意 information_schema.table_rows 是优化器估算值,不是精确行数,InnoDB 尤其只是近似值。把它拿来对比各表是否都有数据,适合作为脚本是否执行完整的快速判断。auto_increment 字段可以检查自增主键有没有因为多次删除被推得很高,如需复位,通常用 ALTER TABLE ... AUTO_INCREMENT = 1 完成。

5.2 用 LEFT JOIN 反查外键孤立行

拿到一个别人给的脚本时,如果对方在执行过程中临时关了外键检查,常见结果是 enroll 表里出现 student 表中不存在的学号,左边 JOIN 一下就能看出来。课程设计里自己的脚本最好也要跑一遍,保证交付的建表脚本不会产生游离数据。

SELECT e.enroll_id, e.student_id, e.schedule_id FROM enroll e LEFT JOIN student s ON e.student_id = s.student_id WHERE s.student_id IS NULL UNION ALL SELECT e.enroll_id, e.student_id, e.schedule_id FROM enroll e LEFT JOIN course_schedule cs ON e.schedule_id = cs.schedule_id WHERE cs.schedule_id IS NULL;

两条查询分别检测所选学生是否存在于 student 表,以及开课计划是否存在于 course_schedule 表;只要查询结果返回 0 行,说明外键关系完整。课程设计报告里放这个结果截图,比放一堆功能页面截图更能说明你理解了关系完整性。如果发现孤立行,优先检查外部导入脚本里的 SET FOREIGN_KEY_CHECKS=0 是否在结尾被恢复成 1。

再顺手处理交付压缩包的结构。我一般会把 SQL 文件拆成 01_schema.sql、02_data.sql、03_procedure.sql 三个编号文件,每个文件开头都写上 USE course_selection; 和 SET NAMES utf8mb4;,防止用 Navicat 导入时选错库或出现中文乱码。最后在 Navicat 里用“转储 SQL 文件”并勾选“包含建库语句”,把导出的脚本连同报告、ER 图截图一起压进 .rar,文件名带上课程号和学号,就能直接提交。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询