简介这是一份针对数据库原理与应用课程的高分课程设计资源围绕某高校学生选课系统展开着重演示数据库系统分析与建模思路。资源重点放在数据库部分不含完整应用程序内含课程设计报告和数据库脚本适合正在准备数据库课设或需要选课系统表结构参考的学生。压缩包内仅3个文件doc格式的课程设计报告阐述设计目的、需求分析、数据模型优化及安全完整性要求sql脚本用于建库和数据处理bak备份文件可还原示例数据库方便对比学习与二次修改。报告不仅给出完整设计流程还覆盖数据库定义、数据录入和处理等环节能帮助理解选课系统的表结构、约束关系及常见数据处理方法。整体大小仅802KB内容精炼已有998人浏览学习参考后可将设计思路迁移到自己的课程设计项目中。1. 学生选课系统为什么是数据库课程设计的“常青树”学生选课系统大概是数据库课程设计里出现频率最高的题目没有之一。原因很朴素实体不多但学生、课程、选课之间恰好把多对多、外键、约束、事务这些教学内容全串了起来业务上还有容量限制、时间冲突、退课释放名额这些能写出花来的规则。很多人拿到像“某高校学生选课系统的设计.rar”这样的资源第一反应是解压、导入、截图、写报告。但反直觉的事实是一个只在单用户、小数据量下能跑通的选课系统离合格还差得远——老师最爱问“30人的课为什么选出了32人”。下文从表结构讲到事务与并发控制最后给一套能自己复现的验证脚本适合正在做课设、想弄懂关系型数据库建模的读者照着重做一遍。2. 从需求到表结构选课系统的ER模型、范式与字段设计2.1 先把业务规则盘清楚一门课能选、能退要过哪几道门槛数据库设计的第一步不是画ER图而是把业务规则一条条列出来。选课系统的规则比一般人想的多至少要覆盖下面这些编号业务规则数据库层面怎么落实R1同一学生同一开课只能选一次enrollment表建(student_id, offering_id)唯一键R2课程容量有限比如 60 人满了不能再选存储过程里检查容量或用一条原子UPDATE扣名额R3同一学生同一时间段不能选两门课开课表拆出星期与节次字段选课时做区间重叠检查R4退课后名额释放删除选课记录后同步减selected_count触发器或事务内两步完成R5成绩由教师录入学生只能查enrollment.grade可空应用层控制写权限这些规则直接决定表结构长什么样。比如 R3如果把上课时间存成字符串“周三5-6节”展示很方便但冲突判断要解析字符串SQL 写起来非常别扭。常见的做法是拆成week_day1-7 表示周一到周日、start_section、end_section三个数值段再额外留一个time_slot_text字段给人看。这样时间冲突就能写成区间重叠判断而不是正则匹配。另外要注意数据库的唯一约束只能防“精确重复”防不住“部分重叠”。比如一门课占了 5-6 节另一门占 6-7 节两门课在 6 节上是重叠的(semester, week_day, start_section, end_section)唯一键抓不住这种情况。所以 R3 必须在存储过程里做区间检查不能指望约束兜底。2.2 从ER图到三张核心表学生、课程、选课关系的建模取舍选课系统最核心的ER关系是三对学生和课程之间是典型的多对多一个学生可以选多门课一门课可以被多个学生选。多对多不能直接落表必须引入“选课记录”这个联系实体由它把学生和课程之间的每次选课行为变成一行数据教师和课程之间则通过“开课”关联——同 一门课在不同学期可能由不同教师上所以“开课”也是一个独立实体不是课程表里的一个字段。很多课设新手会把“课程表”直接挂上教师字段一学期只开一次课这样也可以但到了第二个学期、同一门课两个平行班的时候就彻底乱了。我一般建议把course课程基本信息和course_offering某个学期的某次开课分开哪怕目前只有一条开课记录。分开之后enrollment表指向的不是course_id而是offering_id这样“选课”和“选哪次课”就分清楚了。范式上有个常被问到的点enrollment表要不要用(student_id, offering_id)做复合主键从数据库理论角度看这完全符合 2NF成绩完全依赖于这个复合键但从工程习惯看我见过的大多数课设都用自增id做主键再用唯一约束去保证业务唯一性。这样有一个实际好处后续如果要做退课日志、选课流水其他表引用enrollment_id比引用一长串复合主键方便得多。两种做法都能过答辩关键是能说清楚自己为什么这么选。2.3 六张表的完整字段字典类型、长度、约束一次说清下面这套表结构是按“一个学期多次开课、支持退课、成绩后录”的常见需求设计的可以直接抄进课设报告的数据字典章节。基础表学生、教师、课程表名字段类型约束/默认说明studentstudent_idCHAR(10)PK学号定长兼容前导 0studentnameVARCHAR(20)NOT NULL姓名studentgenderCHAR(1)NULL性别studentclass_idVARCHAR(10)NOT NULL行政班号studentmajorVARCHAR(50)NULL专业studentenroll_yearYEARNULL入学年份teacherteacher_idCHAR(8)PK工号teachernameVARCHAR(20)NOT NULL姓名teacherdeptVARCHAR(30)NULL院系teachertitleVARCHAR(20)NULL职称coursecourse_idCHAR(8)PK课程号coursecourse_nameVARCHAR(50)NOT NULL课程名coursecreditDECIMAL(3,1)NOT NULL学分如 2.5coursehoursINTNOT NULL学时coursecourse_typeVARCHAR(10)NULL必修/选修业务表开课表、选课表。这两张是设计的重心字段要仔细看。表名字段类型约束/默认说明course_offeringoffering_idINTPK AUTO_INCREMENT开课编号course_offeringcourse_idCHAR(8)FK → course课程号course_offeringteacher_idCHAR(8)FK → teacher教师工号course_offeringsemesterVARCHAR(20)NOT NULL如 2024-2025-1course_offeringweek_dayTINYINTNOT NULL1周一7周日course_offeringstart_sectionTINYINTNOT NULL开始节次course_offeringend_sectionTINYINTNOT NULL结束节次course_offeringtime_slot_textVARCHAR(30)NULL展示用如“周三5-6节”course_offeringroomVARCHAR(30)NULL教室course_offeringcapacityINTDEFAULT 60容量course_offeringselected_countINTDEFAULT 0已选人数反范式冗余enrollmentenroll_idINTPK AUTO_INCREMENT选课记录号enrollmentstudent_idCHAR(10)FK → student学号enrollmentoffering_idINTFK → course_offering开课编号enrollmentselect_timeDATETIMEDEFAULT CURRENT_TIMESTAMP选课时间enrollmentgradeDECIMAL(4,1)NULL成绩教师录入selected_count是一个典型的受控冗余字段它本可以通过COUNT(*)从enrollment查出来但选课列表页每次都要算人数很费。把它冗余在开课表里由触发器或存储过程维护查询直接读一个整数性能好得多。代价是数据一致性要靠程序保证后面第 4 章会专门讲这个坑。2.4 范式与反范式的边界这个设计里哪里违反3NF为什么保留如果老师追问范式这套表结构里有三个点值得提前准备好答案。第一处course_offering里不能放学分。学分是course表的属性offering_id决定course_idcourse_id又决定credit如果我把学分也塞进course_offering就有了传递依赖offering_id → course_id → credit违反 3NF。正确做法是查询时 JOINcourse表取学分。第二处前面提到的selected_count冗余不违反 3NF因为它只依赖于offering_id这个主键。但它是“可以由其他数据推导出来”的冗余属于反范式设计。课设里要强调“受控冗余”这四个字业务上必须保证它和被推导数据的一致性所以要用存储过程或触发器统一维护不能让学生从多个入口随意改。第三处BCNF 的边界题。假设学校规定“一个教师在一个学期只能开一门课”那(teacher_id, semester) → course_id就是一个决定关系但(teacher_id, semester)不是候选键候选键是offering_id所以这个表违反 BCNF。修复办法很简单在course_offering上加一个UNIQUE KEY (teacher_id, semester)约束就行不需要真正拆表。这个例子在答辩时很加分因为它说明你不只会画 ER 图还能解释约束背后的理论依据。3. 把设计落成SQL脚本建库、建表、索引与测试数据3.1 建库建表最小脚本字符集、引擎与存储参数的选择拿到一张空数据库第一步是把库建起来并定好字符集。选课系统里有中文姓名、中文课程名移动端还可能写入 emoji所以字符集直接用utf8mb4不要用utf8——utf8在 MySQL 里只有 3 字节存不了 emoji生僻字也可能出问题。CREATE DATABASE IF NOT EXISTS course_selection DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE course_selection; CREATE TABLE student ( student_id CHAR(10) NOT NULL COMMENT 学号, name VARCHAR(20) NOT NULL COMMENT 姓名, gender CHAR(1) DEFAULT NULL COMMENT 性别, class_id VARCHAR(10) NOT NULL COMMENT 行政班号, major VARCHAR(50) DEFAULT NULL COMMENT 专业, enroll_year YEAR DEFAULT NULL COMMENT 入学年份, PRIMARY KEY (student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT学生表; CREATE TABLE teacher ( teacher_id CHAR(8) NOT NULL COMMENT 工号, name VARCHAR(20) NOT NULL COMMENT 姓名, dept VARCHAR(30) DEFAULT NULL COMMENT 院系, title VARCHAR(20) DEFAULT NULL COMMENT 职称, PRIMARY KEY (teacher_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT教师表; CREATE TABLE course ( course_id CHAR(8) NOT NULL COMMENT 课程号, course_name VARCHAR(50) NOT NULL COMMENT 课程名, credit DECIMAL(3,1) NOT NULL COMMENT 学分, hours INT NOT NULL COMMENT 学时, course_type VARCHAR(10) DEFAULT NULL COMMENT 必修/选修, PRIMARY KEY (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT课程表;引擎选 InnoDB 不需要犹豫它支持事务、行级锁、外键选课系统的核心并发场景全依赖这三个特性。MyISAM 虽然查询快但没有事务并发选课时连个靠谱的回滚手段都没有课设里直接排除。CHAR和VARCHAR的选择上学号、工号这类固定长度的标识用CHAR课程名这类长度浮动大的用VARCHAR。字段上的COMMENT一定要写第 4 章会用它直接生成数据字典。3.2 外键与索引的落地联合索引建在哪、最左前缀怎么用继续建开课表和选课表。这两张表的约束和索引是整个设计的核心CREATE TABLE course_offering ( offering_id INT NOT NULL AUTO_INCREMENT COMMENT 开课编号, course_id CHAR(8) NOT NULL COMMENT 课程号, teacher_id CHAR(8) NOT NULL COMMENT 教师工号, semester VARCHAR(20) NOT NULL COMMENT 如2024-2025-1, week_day TINYINT NOT NULL COMMENT 1周一...7周日, start_section TINYINT NOT NULL COMMENT 开始节次, end_section TINYINT NOT NULL COMMENT 结束节次, time_slot_text VARCHAR(30) DEFAULT NULL COMMENT 展示用时间段, room VARCHAR(30) DEFAULT NULL COMMENT 教室, capacity INT NOT NULL DEFAULT 60 COMMENT 容量, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, PRIMARY KEY (offering_id), UNIQUE KEY uk_room_time (semester, week_day, start_section, end_section, room), KEY idx_course (course_id), KEY idx_teacher (teacher_id), CONSTRAINT fk_offering_course FOREIGN KEY (course_id) REFERENCES course (course_id), CONSTRAINT fk_offering_teacher FOREIGN KEY (teacher_id) REFERENCES teacher (teacher_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT开课表; CREATE TABLE enrollment ( enroll_id INT NOT NULL AUTO_INCREMENT COMMENT 选课记录号, student_id CHAR(10) NOT NULL COMMENT 学号, offering_id INT NOT NULL COMMENT 开课编号, select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 选课时间, grade DECIMAL(4,1) DEFAULT NULL COMMENT 成绩, PRIMARY KEY (enroll_id), UNIQUE KEY uk_student_offering (student_id, offering_id), KEY idx_offering (offering_id), CONSTRAINT fk_enroll_student FOREIGN KEY (student_id) REFERENCES student (student_id), CONSTRAINT fk_enroll_offering FOREIGN KEY (offering_id) REFERENCES course_offering (offering_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT选课记录表;uk_student_offering这个唯一键承担两件事一是约束层面禁止同一学生重复选同一开课二是给(student_id, offering_id)这组高频查询提供索引。idx_offering单独挂在offering_id上是为了支持“查某门课选了哪些人”和后面按开课统计人数的COUNT(*)。uk_room_time限制一个教室在同一时间段只能安排一门课这是 R3 在约束层的最后一道保险。这里要理解 MySQL 索引的最左前缀原则联合索引(student_id, offering_id)能高效支持按student_id查但如果一个查询只有offering_id条件这个索引就废了所以offering_id要单独建普通索引。很多课设翻车就翻在这里——索引建了一堆EXPLAIN一看全是ALL全表扫描增删改查CRUD功能都对但“性能分析”那一章一个字都写不出来。3.3 造测试数据手工插入与存储过程批量填充的配合表建好之后要往里塞数据。手工 INSERT 几条只能应付功能演示要做并发压测、写自测脚本至少需要几百个学生、几十门课、几千条选课记录。MySQL 8.0 可以用递归 CTE 批量生成INSERT INTO student (student_id, name, gender, class_id, major, enroll_year) WITH RECURSIVE seq AS ( SELECT 1 AS n UNION ALL SELECT n 1 FROM seq WHERE n 500 ) SELECT CONCAT(2021, LPAD(n, 6, 0)), CONCAT(学生, n), IF(n % 2 0, 女, 男), CONCAT(CS, 2000 n % 5), IF(n % 5 0, 软件工程, 计算机科学与技术), 2021 FROM seq;逻辑说明递归 CTE 生成 1 到 500 的序列LPAD(n, 6, 0)把数字补成 6 位确保学号定长IF(n % 2 0, 女, 男)按奇偶生成性别CONCAT(CS, 2000 n % 5)把 500 个学生均匀分到 5 个班。如果用的是 MySQL 5.7递归 CTE 不可用常见做法是建一张数字辅助表或者写一个简单的存储过程循环插入效果一样就是代码长一点。开课数据和选课数据同理量不用太大每学期开 20 门课每门课容量随机设成 30 到 60然后让每个学生选 4 到 6 门注意在插入时手动错开同一学生的时间段冲突。这些数据是后面所有验证的基础不要嫌麻烦造得越接近真实踩坑越早暴露。4. 把业务规则变成数据库机制视图、存储过程与触发器4.1 选课主流程的存储过程事务、容量检查与并发窗口选课是整个系统的核心动作我一般把它封装成一个存储过程而不是让应用层拼几条 SQL。这样做的好处有两个事务边界收在数据库内部应用层只需要调用一个接口容量检查、重复检查、时间冲突检查都在一个事务里完成逻辑内聚。DELIMITER $$ CREATE PROCEDURE sp_enroll ( IN p_student_id CHAR(10), IN p_offering_id INT ) BEGIN DECLARE v_exists INT DEFAULT 0; DECLARE v_selected INT DEFAULT 0; DECLARE v_capacity INT DEFAULT 0; DECLARE v_week_day TINYINT; DECLARE v_start_section TINYINT; DECLARE v_end_section TINYINT; START TRANSACTION; SELECT COUNT(*) INTO v_exists FROM course_offering WHERE offering_id p_offering_id; IF v_exists 0 THEN ROLLBACK; SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 开课不存在; END IF; SELECT capacity, selected_count, week_day, start_section, end_section INTO v_capacity, v_selected, v_week_day, v_start_section, v_end_section FROM course_offering WHERE offering_id p_offering_id FOR UPDATE; IF v_selected v_capacity THEN ROLLBACK; SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 课程容量已满; END IF; IF EXISTS (SELECT 1 FROM enrollment WHERE student_id p_student_id AND offering_id p_offering_id) THEN ROLLBACK; SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 请勿重复选课; END IF; IF EXISTS (SELECT 1 FROM enrollment e JOIN course_offering o ON e.offering_id o.offering_id WHERE e.student_id p_student_id AND o.semester (SELECT semester FROM course_offering WHERE offering_id p_offering_id) AND o.week_day v_week_day AND v_start_section o.end_section AND v_end_section o.start_section) THEN ROLLBACK; SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 与已有课程时间冲突; END IF; INSERT INTO enrollment (student_id, offering_id) VALUES (p_student_id, p_offering_id); UPDATE course_offering SET selected_count selected_count 1 WHERE offering_id p_offering_id; COMMIT; END$$ DELIMITER ;逻辑说明先查开课是否存在再SELECT ... FOR UPDATE锁住这一行接着依次做容量检查、重复检查、时间冲突检查最后插入选课记录并给selected_count加一。FOR UPDATE是当前读会把course_offering里对应的行锁住直到事务提交。第二个并发事务走到同一行时只能等待这样就避免两个会话同时读到selected_count 59然后都插入造成超卖。参数说明里值得注意的有两点。第一SIGNAL SQLSTATE 45000是 MySQL 里主动抛业务异常的标准写法应用层捕获后可以把MESSAGE_TEXT直接展示给学生。第二FOR UPDATE必须在START TRANSACTION之后才有意义如果单独执行一条锁会立即释放。第三这个版本把“扣名额”放在事务最后持锁时间相对长压测时可能遇到锁等待后面 4.2 会讲一个更短的替代写法。4.2 退课与补选的触发器级联人数维护的两种写法选课人数selected_count的维护常见做法是存储过程和触发器二选一。刚才sp_enroll里在插入后手动UPDATE了一次这就是存储过程维护。但如果学生退课删enrollment时没人减数量数据就对不上了。用触发器可以把这个逻辑统一收口DELIMITER $$ CREATE TRIGGER trg_enroll_count AFTER INSERT ON enrollment FOR EACH ROW BEGIN UPDATE course_offering SET selected_count selected_count 1 WHERE offering_id NEW.offering_id; END$$ CREATE TRIGGER trg_unenroll_count AFTER DELETE ON enrollment FOR EACH ROW BEGIN UPDATE course_offering SET selected_count GREATEST(selected_count - 1, 0) WHERE offering_id OLD.offering_id; END$$ DELIMITER ;NEW和OLD分别代表插入后的新行和删除前的旧行触发器内部可以通过它们拿到offering_id。GREATEST(selected_count - 1, 0)是为了防止人数被扣成负数——如果因为历史数据没初始化导致selected_count已经是错的删除时会把这个负数问题暴露出来而不是继续恶化。注意如果决定用触发器维护人数就要把存储过程里的那句UPDATE course_offering SET selected_count selected_count 1删掉改成只做校验和INSERT。否则插一条选课记录存储过程加一次、触发器又加一次人数变成两倍。这种“双写”是课设里最常见的翻车点报告里写清楚“人数维护只归触发器管”反而是一个亮点。触发器维护完可以用一条对账 SQL 检查一致性SELECT o.offering_id, o.capacity, o.selected_count, (SELECT COUNT(*) FROM enrollment e WHERE e.offering_id o.offering_id) AS actual_count FROM course_offering o HAVING selected_count actual_count;没有结果说明两边一致。这条 SQL 我建议放进课设报告的“系统测试”部分作为数据一致性验证的凭证。4.3 视图与数据字典给老师查课表、给系统做接口视图的价值是把复杂的 JOIN 封装成一张“虚拟表”应用层只查视图不关心底层关联。选课系统里至少应该建两个视图学生课表视图和课程余量视图。CREATE VIEW v_student_schedule AS SELECT s.student_id, s.name AS student_name, c.course_name, o.semester, o.time_slot_text, o.room, t.name AS teacher_name FROM enrollment e JOIN course_offering o ON e.offering_id o.offering_id JOIN student s ON e.student_id s.student_id JOIN course c ON o.course_id c.course_id JOIN teacher t ON o.teacher_id t.teacher_id; CREATE VIEW v_course_remain AS SELECT o.offering_id, c.course_name, t.name AS teacher_name, o.semester, o.time_slot_text, o.room, o.capacity, o.selected_count, o.capacity - o.selected_count AS remain_count FROM course_offering o JOIN course c ON o.course_id c.course_id JOIN teacher t ON o.teacher_id t.teacher_id;视图在 MySQL 里不是物化表查视图本质还是查底层原表不能给视图建索引。课设阶段数据量小无所谓如果老师问性能就说视图用于封装复杂查询真正的高频查询还是要落到底层索引上。最后是每个课设都逃不掉的数据字典。与其手工抄几十个字段不如直接从information_schema生成SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_KEY, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA course_selection ORDER BY TABLE_NAME, ORDINAL_POSITION;把结果导出成 CSV 或 Excel贴进报告就是完整的数据字典。前提是建表时把COMMENT都写清楚了这就是前面强调注释的意义——表结构在数据字典就在。5. 并发才是选课系统的重头戏事务隔离、锁与5个踩坑记录5.1 为什么你的选课在并发下会超选行锁、间隙锁与隔离级别很多课设想不明白一个问题容量明明 60选课选到 62 人才发现超了。这不一定是你代码写错而是并发场景下“检查”和“写入”之间有一条缝。MySQL 默认隔离级别是REPEATABLE READ普通SELECT是快照读读的是事务开始时的快照不是当前最新值。两个事务同时读到selected_count 59都认为还有名额各自插入提交后人数变成 61。-- 会话A -- 会话B BEGIN; BEGIN; SELECT selected_count SELECT selected_count FROM course_offering FROM course_offering WHERE offering_id 1; WHERE offering_id 1; -- 读到59 -- 读到59快照读互相不可见 INSERT INTO enrollment INSERT INTO enrollment VALUES (s1, 1); VALUES (s2, 1); UPDATE course_offering UPDATE course_offering SET selected_count 60 SET selected_count 61 WHERE offering_id 1; WHERE offering_id 1; COMMIT; COMMIT;问题出在“判断selected_count capacity”这个动作没有和后续的INSERT放在同一个原子操作里。解决办法有两种。第一种是悲观锁也就是第 4 章存储过程里用的SELECT ... FOR UPDATE它把行锁住再做判断其他人只能等。第二种是乐观锁把判断条件直接写进UPDATE的WHERE子句UPDATE course_offering SET selected_count selected_count 1 WHERE offering_id 1 AND selected_count 60;如果影响行数为 0说明名额已经没了。这种方式锁持有时间更短在高并发下吞吐量更好但应用层要处理“更新失败”的分支不能直接抛“成功”。5.2 死锁与锁超时事务写错顺序的典型现场死锁在选课系统里最常见的触发场景是“反向更新”。比如批量选课的逻辑里事务 A 先更新开课 1 再更新开课 2事务 B 先更新开课 2 再更新开课 1。两个事务互相持有对方等待的行锁InnoDB 检测到死锁后会回滚其中一个事务代价小的那个牺牲。这时系统会出现偶发的Deadlock found when trying to get lock; try restarting transaction。解决死锁的标准做法是让所有事务以同样的顺序访问资源。比如都按offering_id升序更新那么事务 A 和 B 都会先锁开课 1 再锁开课 2永远不会互相等待。另外事务越短越好锁持有时间越短死锁窗口越小。排查死锁现场用这条命令SHOW ENGINE INNODB STATUS\G输出里的LATEST DETECTED DEADLOCK段会列出两个事务分别持有哪把锁、等待哪把锁照着日志改代码就行。这套分析思路在数据库面试题里也反复出现能讲清楚一次就已经超过大多数课设答辩者。5.3 现象 → 原因 → 解决5个常见的课设翻车点坑1删课程时报外键失败。现象是执行DELETE FROM course WHERE course_id CS101报错Cannot delete or update a parent row: a foreign key constraint fails。原因是course_offering和enrollment都通过外键引用了course只要有选课记录物理删除就被拒。解决办法有三种先删子表再删父表、外键改成ON DELETE SET NULL、或者干脆不物理删除给course加一个is_active字段做逻辑停用。课设里推荐逻辑停用因为历史选课数据不能丢。答辩时主动说“我没有物理删除而是逻辑停用”比被老师问住再解释体面得多。坑2压测时大量报Lock wait timeout exceeded。现象是手点功能一切正常脚本并发 20 个选课请求几十个报超时。原因是事务里持锁时间太长或者多个事务在互相等锁超过了innodb_lock_wait_timeout的 50 秒默认值。解决思路是先把事务做小别在事务里做查询闭包、远程调用这类耗时操作再检查UPDATE语句的WHERE条件有没有走索引如果扫了全表行锁就升级成表锁等待面更大。把selected_count的维护改成原子UPDATE也能显著缩短锁时间。坑3MySQL 5.7 及以上分组查询报错 1055。现象是统计每个学期选课人数时SELECT semester, student_id, COUNT(*) FROM enrollment GROUP BY semester直接报Expression #2 of SELECT list is not in GROUP BY clause ... incompatible with sql_modeonly_full_group_by。原因是默认sql_mode里开着ONLY_FULL_GROUP_BYSELECT出来的非聚合列必须出现在GROUP BY里。解决办法是把student_id去掉或者用ANY_VALUE(student_id)包住。不要去改全局sql_mode强行关掉这个检查老师问起来解释不清楚。坑4JOIN 时报Illegal mix of collations。现象是两张表字段类型一样JOIN时却报排序规则冲突。原因是一张表建库时用了utf8mb4_general_ci另一张用了utf8mb4_unicode_ciMySQL 不允许两种排序规则直接比较。解决方法是统一库表字符集和排序规则执行ALTER TABLE xxx CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci并把这个参数固定写进建库脚本。坑5人数对不上账。现象是selected_count和实际enrollment行数不一致有时还出现负数。原因通常有两个一是存储过程和触发器两边都在更新人数加了两次二是先导入了历史数据再创建触发器触发器只对创建之后的变更生效历史数据没被初始化。解决方法是先跑第 4 章的对账 SQL 找到不一致的offering_id把selected_count修正为实际COUNT(*)然后停掉存储过程里的那行UPDATE只保留触发器维护。修完再跑一遍对账直到没有结果返回。6. 验证技巧与一个让课设更值的进阶设计从自测脚本到面审兜底拿到一套课设验证方式不能只是“页面能打开、数据能插入”。最有效的验证是把并发场景拉起来跑一遍。这里给一个简单的压测脚本用进程模拟 20 个学生同时抢同一门课seq 1 20 | xargs -P 10 -I {} \ mysql -uroot -p123456 course_selection -e \ CALL sp_enroll(2021000001, 1);xargs -P 10表示最多并行 10 个进程20 个选课请求基本能模拟出竞争效果。跑完查两个数SELECT COUNT(*) FROM enrollment WHERE offering_id 1和SELECT selected_count FROM course_offering WHERE offering_id 1。如果两个数一致且不超过容量说明并发控制是有效的如果超出回到第 5.1 节检查锁和隔离级别。真实系统里连接池会复用连接而不是每请求建连但课设阶段用进程模拟已经足够暴露问题。如果想让方案再往上走一步可以在course_offering上加一个version字段做乐观锁ALTER TABLE course_offering ADD COLUMN version INT NOT NULL DEFAULT 0; UPDATE course_offering SET selected_count selected_count 1, version version 1 WHERE offering_id 1 AND selected_count 60;ROW_COUNT()返回 0 就回滚报“名额不足”。这个设计在面审时特别能打——你能讲清楚悲观锁靠“锁行”乐观锁靠“条件更新”以及什么时候用哪个。答辩现场把这套自测脚本的运行结果截图附在报告里再印上一张information_schema生成的数据字典基本就齐了。我以前交课设时也以为能把数据插进去就算完成直到被问“30人的课为什么选出了32人”才发现并发窗口这个黑匣子必须打开看。后来在开课行加FOR UPDATE、把人数扣减改成原子UPDATE再跑一遍压测数字才对得上。这条路我替你踩过了希望帮到你。本文还有配套的精品资源点击获取