ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数据库设计实战:学生选课系统的ER模型、事务与并发控制

数据库设计实战:学生选课系统的ER模型、事务与并发控制

简介:这是一份针对数据库原理与应用课程的高分课程设计资源,围绕某高校学生选课系统展开,着重演示数据库系统分析与建模思路。资源重点放在数据库部分,不含完整应用程序,内含课程设计报告和数据库脚本,适合正在准备数据库课设或需要选课系统表结构参考的学生。压缩包内仅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_day(1-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学号,定长,兼容前导 0
studentnameVARCHAR(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.5
coursehoursINTNOT 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-1
course_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_id,course_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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也能显著缩短锁时间。

坑3:MySQL 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_mode=only_full_group_by。原因是默认sql_mode里开着ONLY_FULL_GROUP_BY,SELECT出来的非聚合列必须出现在GROUP BY里。解决办法是把student_id去掉,或者用ANY_VALUE(student_id)包住。不要去改全局sql_mode强行关掉这个检查,老师问起来解释不清楚。

坑4:JOIN 时报Illegal mix of collations。现象是两张表字段类型一样,JOIN时却报排序规则冲突。原因是一张表建库时用了utf8mb4_general_ci,另一张用了utf8mb4_unicode_ci,MySQL 不允许两种排序规则直接比较。解决方法是统一库表字符集和排序规则,执行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,再跑一遍压测,数字才对得上。这条路我替你踩过了,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进