学生选课系统数据库设计:从ER图到并发防超卖实战
简介:面向高校《数据库系统概论》课程设计与期末大作业场景的完整范文模板,适合计算机相关专业学生参考撰写与答辩自查。文档围绕学生选课管理信息系统展开,从需求分析、组织结构与业务流、数据流分析、数据字典,到概念结构设计中的实体属性与联系分析、.cdm 概念模型图,再到逻辑结构转换与 .pdm 图,并落地到 SQL Server 环境下各表设计、完整性约束建表代码,以及视图、索引、存储过程和触发器的创建与功能调试,最后给出登录与成绩管理等应用程序设计章节;章节层次清晰,图表和代码也都齐全,可直接对照修改并套用到自己的选题。压缩包共 1 个 docx 文件,约 3.96MB,目录完整、编号清晰,便于按模块摘取参考。目前已有 852 人学习。
1. 学生选课管理信息系统不是三张表的堆叠
课程设计答辩现场,最容易被问住的一句话是:两个学生同时点下「选课」按钮,最后一个名额给了谁?表建得再好看,答不上来就说明这套学生选课管理信息系统只是纸面作业。它要解决的问题不复杂:学生、教师、课程、教学班、选课记录、成绩六类数据之间要能对得上账;真正难的是选课高峰时名额不能超卖、重复选课不能进库、退课之后余量要能立刻回补。适合正在做数据库系统课程设计、准备把 ER 图落成可运行库表、又担心演示时被追问一致性的读者。下面从表结构定稿讲到并发控制、压测验证和报告里的图该怎么画,每一步都能在本机复现。
2. 从需求到 ER 图再到关系模式:表结构怎么定才不返工
选课系统的返工,九成发生在建表之前:需求没拆干净,ER 图里把「课程」和「教学班」混成一个实体,等写到成绩录入才发现同一门课一学期开了三个班,成绩不知道挂在哪。这一章把需求、ER 和关系模式三段连起来讲,目的只有一个,让后面的 DDL 一次写对。
2.1 需求梳理:把「学生选课」拆成五类实体动作
先别急着画图,把角色和动作列成一张功能清单。角色的划分决定了权限和视图设计,动作的划分决定了实体和联系。课程设计常见做法是三类角色:学生、教师、教务管理员。学生侧的动作是查询可选教学班、选课、退课、查课表、查成绩;教师侧的动作是查看自己教学班的名单、录入与修改成绩、导出成绩单;教务侧的动作是维护学生与教师档案、维护课程库、开设教学班并设定容量和上课时段、开关选课窗口、统计选课人数。
把动作翻译成实体,会得到六张核心表:学生、教师、课程、教学班、选课记录,成绩则挂在选课记录上而不是单独一张表。这里有个关键判断,「课程」和「教学班」必须分开。课程是课程库里稳定的那条记录,比如课程号、课程名、学分、学时、课程性质;教学班是某一学期由某位教师开设的一次授课,带学期、上课时间、容量。一个学生选的是教学班,不是课程本身。如果不拆,同一门课第二个学期再开就无处安放,重修也没法记录。
另一个需要提前想清楚的是「重修」和「退课」的语义。退课是删除还是打标记?课程设计的体面做法是保留记录、改状态位,因为教师可能已经录了平时分,物理删除会把成绩一起带走。重修则是同一学生在不同学期产生多条选课记录,这直接决定了后面唯一约束的粒度是「学生 + 教学班」而不是「学生 + 课程」。把这些写在需求分析章节里,报告的可信度立刻上一个层次,因为评审看的就是你有没有想过正常路径之外的场景。
2.2 ER 图转关系模式:外键、复合主键与选课表的取舍
ER 图到关系模式的转换规则本身不复杂,难的是取舍。一对多联系靠外键实现:教学班引用课程号,教学班引用教师号,选课记录引用学号和教学班号。多对多联系必须拆成独立的关系,学生与教学班的「选课」就是典型的多对多,拆出来就是选课记录表。
主键的选择是这一节的重点。选课记录表有两种常见写法:一是复合主键(student_id, offering_id);二是自增代理键enrollment_id,再加一个唯一约束UNIQUE(student_id, offering_id)。课程设计里我一般选后者,理由有三条。第一,成绩补录、退课重选这类操作需要精确定位到某一条记录,单个整型主键比两个字段拼起来好写。第二,后续如果要加「选课流水」或「调课记录」这类子表,外键指向单列比指向复合列省事。第三,唯一约束已经能表达业务规则,代理键不会削弱约束强度。代价是多一个字段和一处索引,对这个规模的库完全可以接受。
外键要不要建,是另一个常见分歧。有人担心外键拖慢插入、带来锁竞争,就在应用层做校验。课程设计场景数据量在万级以内,外键带来的开销可以忽略,而它能挡住「选课记录指向一个不存在的教学班」这类脏数据,演示时也方便讲清楚参照完整性。我的建议是全部建上,并且统一命名规则,比如fk_选课记录_教学班,答辩时一眼能看出引用关系。真正要注意的是删除行为:教学班被删时选课记录怎么办?用ON DELETE RESTRICT挡住,教务要删班必须先确认名单为空,比级联删除安全得多。
还有一处容易被忽略:教师的上课时间冲突。同一个教师在同一学期、同一星期几、同一节次不能开两个班,这是一条业务约束,用UNIQUE(term, teacher_id, weekday, start_slot)就能在库层面挡住。同理,学生的时间冲突没法用唯一约束表达,因为它跨行比较,只能在存储过程里查一次。把哪些约束交给数据库、哪些交给程序,在报告里写清楚,比笼统写「保证数据一致性」有说服力得多。
2.3 字段与约束逐条定:类型、可空性和检查约束
字段定稿最考验基本功,也是答辩时最爱抠的地方。下面这张表是核心字段的选型依据,写报告时可以直接扩展成字段说明表。
| 字段 | 类型 | 可空 | 选型理由 |
|---|---|---|---|
| student_id | CHAR(10) | 否 | 学号定长,CHAR 比 VARCHAR 省去长度字节,比较也快 |
| credit | DECIMAL(3,1) | 否 | 学分有 0.5 的粒度,浮点会有精度误差,金额和学分一律用定点数 |
| capacity | SMALLINT | 否 | 教学班容量不会超过几百,SMALLINT 足够 |
| enrolled | SMALLINT | 否 | 已选人数冗余计数,默认 0,与容量同类型便于比较 |
| term | VARCHAR(11) | 否 | 形如 2024-2025-1,定长但用 VARCHAR 更灵活 |
| score | DECIMAL(5,2) | 是 | 可空表示尚未出分,0 和 NULL 在业务上含义完全不同 |
| status | TINYINT | 否 | 1 有效、0 已退,用于软删除 |
可空性是重灾区。成绩字段如果设成NOT NULL DEFAULT 0,统计平均分时就会把没出分的课算成 0 分,GPA 直接被拉低,这是血泪经验。同理,手机号这类非核心字段允许为空,但姓名、学号、课程名一律NOT NULL。默认值也要谨慎,除了enrolled DEFAULT 0、created_at DEFAULT CURRENT_TIMESTAMP这种有明确语义的,其余不要随便给默认值,默认值会掩盖程序漏传参数的问题。
检查约束在 MySQL 8.0.16 之后才真正生效,写之前确认版本。值得加的几条是:容量必须为正、已选人数必须落在 0 到容量之间、成绩必须在 0 到 100 之间或者为空。注意「已选人数不超过容量」这条约束在并发场景下会和锁配合,它属于最后一道防线,不是主要手段,主要手段在第 4 章。枚举类型用ENUM还是TINYINT加字典表,课程设计里用ENUM更直观,报告里画得出取值域,代价是后续加枚举值要改表结构,这一点在报告里说明取舍即可。
3. 建库建表与约束落地:一份能直接跑的脚本
表结构定稿之后,剩下的工作是把它翻译成可执行的 SQL,并且让索引、视图、常用查询配套跟上。这一章给出一份完整脚本,顺序、字符集、引擎、索引都按实际踩过的坑排好,复制到本地 MySQL 就能跑通。
3.1 建库与建表顺序:先被引用的表,后引用它的表
建表顺序必须遵守外键依赖:学生、教师、课程三张主表先建,教学班引用课程和教师,选课记录最后建。字符集统一用utf8mb4,排序规则用 8.0 的默认值,全库统一,避免后面 join 时因为字符集不同导致索引失效。存储引擎必须是 InnoDB,事务和行锁是第 4 章的前提,MyISAM 不支持事务,选课一旦中途失败就会留下半条数据。
-- 建库:字符集与排序规则一次定死,后续所有表继承 DROP DATABASE IF EXISTS course_selection; CREATE DATABASE course_selection DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_0900_ai_ci; USE course_selection; -- 学生表:学号做主键,班级与年级建联合索引供名单查询 CREATE TABLE student ( student_id CHAR(10) NOT NULL COMMENT '学号', name VARCHAR(30) NOT NULL COMMENT '姓名', gender ENUM('男','女') NOT NULL DEFAULT '男', grade_year SMALLINT NOT NULL COMMENT '入学年份', major VARCHAR(40) NOT NULL COMMENT '专业', class_name VARCHAR(30) NOT NULL COMMENT '班级', phone VARCHAR(20) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (student_id), KEY idx_student_class (class_name, grade_year) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生档案'; -- 教师表 CREATE TABLE teacher ( teacher_id CHAR(6) NOT NULL COMMENT '工号', name VARCHAR(30) NOT NULL, title VARCHAR(20) NOT NULL DEFAULT '讲师', dept VARCHAR(40) NOT NULL, PRIMARY KEY (teacher_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教师档案'; -- 课程库:课程是稳定的,不带学期和教师 CREATE TABLE course ( course_id CHAR(8) NOT NULL COMMENT '课程号', course_name VARCHAR(60) NOT NULL, credit DECIMAL(3,1) NOT NULL COMMENT '学分', hours SMALLINT NOT NULL COMMENT '学时', course_type ENUM('必修','选修','通识') NOT NULL DEFAULT '选修', PRIMARY KEY (course_id), CONSTRAINT chk_course_credit CHECK (credit > 0 AND credit <= 20) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程库'; -- 教学班:某学期某教师开的一次课,容量与已选人数在这里 CREATE TABLE offering ( offering_id BIGINT NOT NULL AUTO_INCREMENT, course_id CHAR(8) NOT NULL, teacher_id CHAR(6) NOT NULL, term VARCHAR(11) NOT NULL COMMENT '如 2024-2025-1', weekday TINYINT NOT NULL COMMENT '1-7', start_slot TINYINT NOT NULL COMMENT '第几节开始', weeks VARCHAR(20) NOT NULL DEFAULT '1-16', capacity SMALLINT NOT NULL COMMENT '容量', enrolled SMALLINT NOT NULL DEFAULT 0 COMMENT '已选人数', PRIMARY KEY (offering_id), UNIQUE KEY uk_offering_teacher_slot (term, teacher_id, weekday, start_slot), KEY idx_offering_term_course (term, course_id), CONSTRAINT fk_offering_course FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE RESTRICT, CONSTRAINT fk_offering_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) ON DELETE RESTRICT, CONSTRAINT chk_offering_capacity CHECK (capacity BETWEEN 1 AND 500), CONSTRAINT chk_offering_enrolled CHECK (enrolled >= 0 AND enrolled <= capacity) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教学班'; -- 选课记录:唯一约束挡住重复选课,成绩挂在记录上 CREATE TABLE enrollment ( enrollment_id BIGINT NOT NULL AUTO_INCREMENT, student_id CHAR(10) NOT NULL, offering_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1有效 0已退', score DECIMAL(5,2) NULL COMMENT 'NULL 表示未出分', enroll_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (enrollment_id), UNIQUE KEY uk_enroll_student_offering (student_id, offering_id), KEY idx_enroll_offering (offering_id), CONSTRAINT fk_enroll_student FOREIGN KEY (student_id) REFERENCES student(student_id) ON DELETE RESTRICT, CONSTRAINT fk_enroll_offering FOREIGN KEY (offering_id) REFERENCES offering(offering_id) ON DELETE RESTRICT, CONSTRAINT chk_enroll_score CHECK (score IS NULL OR (score >= 0 AND score <= 100)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选课记录';逐条说逻辑。DROP DATABASE放在最前是为了反复演练时可重来,正式环境不要带这一行。学生表用CHAR(10)存学号,定长字段在 InnoDB 里比较和索引都更紧凑;class_name与grade_year建联合索引,是因为教务按班级拉名单是最常见的操作,联合索引的最左前缀正好是这个组合。教学班表的唯一键uk_offering_teacher_slot是业务约束的落地点,它挡住教师时段冲突,顺带在插入时自动生成索引。weeks用字符串存「1-16」这类区间,课程设计够用,如果要精确到单周,需要拆成周次位图或者子表,这一点写在报告的局限性里反而加分。
选课记录表的uk_enroll_student_offering是整套设计的地基。即使应用层判断漏了,数据库也会用 1062 错误把重复选课顶回去。idx_enroll_offering则是给「按教学班查名单」和「统计已选人数」准备的,唯一键的最左列是学号,查教学班用不上它,所以必须单独建。
提示:
CHECK约束在 MySQL 8.0.16 之前会被解析但静默忽略,建表后执行SHOW CREATE TABLE确认约束真的写进去了,再往下做。
3.2 索引从查询倒推:三个高频查询与它们的覆盖方式
索引不是越多越好,每多一个索引,插入和更新就多一次写放大。正确的做法是先列出高频查询,再倒推需要哪些索引,最后和已有的唯一索引去重。选课系统里跑得最多的三类查询是:学生查本学期课表、教师查教学班名单、教务查某教学班的余量。
学生查课表按学号过滤,走的是uk_enroll_student_offering的最左列,再回表拿教学班信息,这一步没问题。教师查名单按offering_id过滤,走idx_enroll_offering。教务查余量按term加course_id过滤,走idx_offering_term_course。三条查询各自有索引兜底,剩下的工作是确认执行计划真的用上了。
-- 看执行计划:重点看 type、key、rows、Extra 四列 EXPLAIN SELECT o.offering_id, c.course_name, o.capacity - o.enrolled AS remain FROM offering o JOIN course c ON c.course_id = o.course_id WHERE o.term = '2024-2025-1' AND c.course_id = 'CS1001'; -- 查某学生的课表:按学号走唯一索引最左列 EXPLAIN SELECT o.term, c.course_name, o.weekday, o.start_slot FROM enrollment e JOIN offering o ON o.offering_id = e.offering_id JOIN course c ON c.course_id = o.course_id WHERE e.student_id = '2023010101' AND e.status = 1;判断标准很直接:type出现ALL说明全表扫描,必须处理;key为空说明索引没用上;rows远大于实际返回行数说明选择度差。这里有个易错点,第一条查询如果写成WHERE c.course_name = '数据库原理',优化器可能先扫课程表再回教学班表,因为课程名没有索引。要么给课程名建索引,要么强制按课程号查,课程设计里推荐后者,课程号本来就是业务主键。另一个易错点是隐式类型转换:学号是CHAR,如果传入的参数是整数,MySQL 会把列转成数字再比较,索引直接失效,这条在演示时特别容易被踩。
Extra列里出现Using index表示覆盖索引,出现Using filesort表示额外排序,后者在小数据量下无所谓,但报告里可以写一句「余量统计查询通过联合索引避免了回表」,显得你知道自己在干什么。
3.3 视图与常用查询:课表、成绩单和余量统计
视图在这个系统里有三个实打实的用途:把多表 join 封装成一张「看得懂的表」、控制权限只暴露部分列、给报表查询提供稳定接口。下面三个视图覆盖了演示时最常被要求现场查的数据。
-- 余量视图:把容量、已选、剩余算好,前端直接读 CREATE OR REPLACE VIEW v_offering_remain AS SELECT o.offering_id, c.course_id, c.course_name, c.credit, t.name AS teacher_name, o.term, o.weekday, o.start_slot, o.capacity, o.enrolled, o.capacity - o.enrolled AS remain FROM offering o JOIN course c ON c.course_id = o.course_id JOIN teacher t ON t.teacher_id = o.teacher_id; -- 学生课表视图:只暴露有效选课 CREATE OR REPLACE VIEW v_student_timetable AS SELECT e.student_id, s.name AS student_name, o.term, c.course_name, t.name AS teacher_name, o.weekday, o.start_slot, o.weeks FROM enrollment e JOIN student s ON s.student_id = e.student_id JOIN offering o ON o.offering_id = e.offering_id JOIN course c ON c.course_id = o.course_id JOIN teacher t ON t.teacher_id = o.teacher_id WHERE e.status = 1; -- 成绩单视图:未出分的记录保留,score 为 NULL CREATE OR REPLACE VIEW v_score_report AS SELECT e.student_id, s.name AS student_name, o.term, c.course_id, c.course_name, c.credit, e.score FROM enrollment e JOIN student s ON s.student_id = e.student_id JOIN offering o ON o.offering_id = e.offering_id JOIN course c ON c.course_id = o.course_id;视图建好之后,几个高频统计查询就能写得非常短。查某学期余量大于零的教学班,直接SELECT * FROM v_offering_remain WHERE term = '2024-2025-1' AND remain > 0 ORDER BY course_id。查某学生加权平均分,注意必须排除score IS NULL的记录,否则未出分的课会被当成 0 分参与计算,这是前面反复强调可空性的原因。
-- 加权平均分:只统计已出分且有效的记录 SELECT s.student_id, s.name, ROUND(SUM(c.credit * e.score) / SUM(c.credit), 2) AS weighted_avg, SUM(c.credit) AS earned_credit FROM student s JOIN enrollment e ON e.student_id = s.student_id JOIN offering o ON o.offering_id = e.offering_id JOIN course c ON c.course_id = o.course_id WHERE e.status = 1 AND e.score IS NOT NULL GROUP BY s.student_id, s.name;还有一个演示效果很好的查询:找出同一学期时间冲突的选课。虽然存储过程会挡,但用 SQL 反查一遍能证明约束确实生效,写法是对同一学生的选课记录自连接,比较学期、星期和节次是否相同。这类查询在报告里放一条,比写十行「系统保证了数据一致性」有说服力。
4. 选课并发与事务:用行锁和唯一约束挡住超卖和重复
并发选课是这套系统唯一真正棘手的地方,也是课程设计从「能跑」到「能讲」的分水岭。这一章把一次选课请求拆开,讲清楚改哪几张表、事务边界画在哪、锁按什么顺序拿,最后落成一个带异常码的存储过程。
4.1 一次选课要动几张表:余量扣减和记录插入的顺序
一次成功的选课,至少动两张表:教学班表的enrolled加一,选课记录表插入一行。如果还要记流水日志,就是第三张。顺序不能反,先扣余量再插记录,还是先插记录再扣余量,决定了失败回滚时的中间状态。
先说余量为什么不用实时COUNT(*)算。实时统计的好处是不会有冗余数据不一致,坏处是每次查列表都要对选课记录做聚合,列表页动辄几十个教学班,每个都 count 一遍代价不小,而且并发下COUNT读到的是快照,扣减时还是得加锁。课程设计规模小,两种做法都能接受,但用冗余计数字段 + 事务保证一致,更能体现你对并发和性能的理解,报告里也好展开。代价是必须在退课、教务强制退课、教学班删除等所有路径上都维护这个字段,任何一个入口漏掉,余量就和实际记录对不上。
顺序上的选择是:先UPDATE offering SET enrolled = enrolled + 1 WHERE offering_id = ? AND enrolled < capacity,检查影响行数是否为一,再INSERT INTO enrollment。这个顺序的好处是把容量判断和扣减合成一条原子语句,数据库在同一行上加排他锁,两个并发请求会串行执行,第二个请求看到的是已经加过的值,enrolled < capacity不成立,影响行数为零,直接判满。如果把判断写成先SELECT查余量、程序里比较、再UPDATE,两个请求可能同时读到剩余 1,然后都去扣,这就是经典超卖,也是答辩最爱问的那句「同时点按钮怎么办」的标准答案。
插入选课记录时可能撞上唯一键。撞了说明这个学生已经选过这个教学班,无论是重复点击还是页面重放,都应该返回「已选过」而不是当成系统错误。捕获 1062 错误码之后回滚整个事务,余量自然回退,不需要手工补偿。这一点很重要:凡是靠事务回滚能解决的问题,不要写补偿逻辑,补偿逻辑是 bug 的温床。
4.2 事务边界与锁:为什么必须先锁教学班再插记录
事务边界画在哪,直接决定锁持有多久。边界应该是「进入业务逻辑前START TRANSACTION,所有写操作完成后COMMIT」,中间不要夹任何网络调用、文件读写或者等待用户输入。常见错误是在事务里调一次外部接口校验学籍,接口慢两秒,行锁就多持两秒,选课高峰直接把教学班那一行锁成瓶颈。
隔离级别用默认的REPEATABLE READ就够。这个级别下 InnoDB 对UPDATE的WHERE条件走索引时加的是行锁,不是表锁,前提是offering_id上有主键或索引,这里正好是主键。如果WHERE条件没走索引,行锁会退化成锁全表,整个选课入口全部排队,这种翻车现场在压测时一眼能看出来:并发数一上去,响应时间从毫秒级跳到秒级。
锁的顺序要固定,所有涉及多行锁的事务都按同一顺序拿锁,这里就是先锁教学班行、再动选课记录表。原因是死锁检测虽然会自动回滚其中一个事务,但回滚哪个不确定,用户看到的是随机失败。固定顺序能把死锁概率压到极低。如果存储过程里还要查时间冲突,那条查询是普通读,不加锁,读到的是事务开始时的快照,够用;要更严格可以加FOR SHARE,但会增加锁竞争,课程设计里不必。
下面这张表能直观说明超卖的成因,演示时用两个会话按顺序执行就能复现。
| 时刻 | 会话 A | 会话 B | 结果 |
|---|---|---|---|
| T1 | SELECT 余量为 1 | A 读到 1 | |
| T2 | SELECT 余量为 1 | B 也读到 1 | |
| T3 | UPDATE 扣减,未提交 | A 持锁 | |
| T4 | UPDATE 阻塞 | B 等待 | |
| T5 | COMMIT | 余量变 0 | |
| T6 | UPDATE 执行,余量变 -1 | 超卖 |
把会话 A 的写法换成带条件的单条UPDATE,T4 处 B 会在 A 提交后重新判断条件,影响行数为零,超卖就不会发生。
4.3 存储过程实现选课与退课:参数、异常码和返回约定
把并发逻辑放进存储过程,好处是事务边界和锁的顺序集中在数据库侧,应用层只负责传参和读返回码,换语言重写也不用重写这段逻辑。返回约定用两个出参:p_code表示结果码,0 成功,负数表示各类失败;p_msg给人看。这种约定比抛异常友好,前端可以直接展示。
DELIMITER $$ CREATE PROCEDURE sp_enroll( IN p_student CHAR(10), IN p_offering BIGINT, OUT p_code INT, OUT p_msg VARCHAR(64) ) BEGIN DECLARE v_term VARCHAR(11); DECLARE v_weekday TINYINT; DECLARE v_slot TINYINT; DECLARE v_conflict INT DEFAULT 0; -- 任何未预期异常都回滚,并给统一错误码 DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; SET p_code = -99; SET p_msg = '数据库异常,已回滚'; END; START TRANSACTION; -- 第一步:锁住教学班这一行,后续读到的容量是稳定值 SELECT term, weekday, start_slot INTO v_term, v_weekday, v_slot FROM offering WHERE offering_id = p_offering FOR UPDATE; IF v_term IS NULL THEN ROLLBACK; SET p_code = -1; SET p_msg = '教学班不存在'; ELSE -- 第二步:检查时间冲突,同一学期同一天同一节次 SELECT COUNT(*) INTO v_conflict FROM enrollment e JOIN offering o ON o.offering_id = e.offering_id WHERE e.student_id = p_student AND e.status = 1 AND o.term = v_term AND o.weekday = v_weekday AND o.start_slot = v_slot; IF v_conflict > 0 THEN ROLLBACK; SET p_code = -2; SET p_msg = '与已选课程时间冲突'; ELSE -- 第三步:带条件的原子扣减,影响行数为 0 说明满员 UPDATE offering SET enrolled = enrolled + 1 WHERE offering_id = p_offering AND enrolled < capacity; IF ROW_COUNT() = 0 THEN ROLLBACK; SET p_code = -3; SET p_msg = '名额已满'; ELSE -- 第四步:插入记录,撞唯一键由异常处理器回滚 INSERT INTO enrollment (student_id, offering_id) VALUES (p_student, p_offering); COMMIT; SET p_code = 0; SET p_msg = '选课成功'; END IF; END IF; END IF; END$$ DELIMITER ;逐段说逻辑。DECLARE EXIT HANDLER放在所有语句之前,捕获任何 SQL 异常后回滚并统一返回 -99,这样应用层不用区分 1062、1213 还是别的错误码。第一步的SELECT ... FOR UPDATE拿到的是排他行锁,作用是把教学班这一行锁住,让后面查时间冲突时看到的容量和时段是稳定的;这一步用的是主键,锁的是一行,不会锁表。第二步的时间冲突检查是普通读,走uk_enroll_student_offering的最左列,数据量小的时候很快。第三步是整个过程的重点,容量判断和扣减写在同一条UPDATE里,ROW_COUNT()为 0 就说明条件不成立,要么名额满了,要么行被别人改过,两种情况都该拒绝。
参数上有几处约定值得写进报告。p_student用CHAR(10)而不是VARCHAR,和应用层传入的类型保持一致,避免隐式转换让索引失效。p_offering用BIGINT,和主键类型严格对齐。出参p_code的取值表要单独列出来:0 成功、-1 教学班不存在、-2 时间冲突、-3 名额已满、-99 系统异常。这张表在报告里占不了几行,但评审一看就知道你设计过错误路径。
退课过程相对简单,但同样要注意顺序:先把status改成 0,再扣减enrolled,还是反过来?推荐先扣减再改状态,且扣减语句带上enrolled > 0的条件,防止余量扣成负数。删除记录还是软删除,前面已经定过,用软删除,因为教师可能已经录了分。
调用和验证这样写:
-- 正常选课 CALL sp_enroll('2023010101', 1, @code, @msg); SELECT @code AS code, @msg AS msg; -- 重复选课:第二次调用应返回 -99(撞唯一键回滚) CALL sp_enroll('2023010101', 1, @code, @msg); SELECT @code AS code, @msg AS msg; -- 把容量改成 1 之后用两个会话同时调用,验证只会成功一次 UPDATE offering SET capacity = 1, enrolled = 0 WHERE offering_id = 1;第二条预期返回 -99 而不是 0,因为唯一键冲突被顶层异常处理器接住了。想给用户更友好的「已选过」提示,可以在插入前先查一次enrollment,或者在异常处理器里判断SQLSTATE = '23000'再细分返回码。两种写法各有取舍,前者多一次查询,后者依赖错误码,报告里说明即可。
5. 避坑与排查:课程设计里最容易翻车的五个点
这一章是踩坑记录合集,每条按现象、原因、解决三段写。这些问题在本地单机演示时未必暴露,一旦并发或者换台机器就全冒出来,提前处理掉能省掉答辩当场的手忙脚乱。
5.1 现象:同一学生出现两条选课记录,余量也被扣了两次
原因是唯一约束建错了粒度。有人把唯一键建成UNIQUE(student_id, course_id),看似合理,实际选课记录表里根本没有course_id这一列,只好先删约束再补,补的时候漏掉了或者建成了普通索引。另一种情况是唯一键建在(student_id, offering_id, status)上,加了status之后,退课再选会插入第二条status = 1的记录,约束挡不住。
解决办法是回到业务语义:一个学生对一个教学班,有效记录只能有一条。约束就建成UNIQUE(student_id, offering_id),退课用更新status,不要用删除再插入。如果业务上允许退课后重新选并保留历史,就把历史记录挪到单独的流水表,主表仍然保持唯一。建完执行SHOW INDEX FROM enrollment确认唯一索引存在且列顺序正确。
5.2 现象:压测时响应时间突然从几十毫秒涨到好几秒,CPU 却不高
原因是锁等待,不是计算慢。最常见的触发点是UPDATE offering SET enrolled = enrolled + 1 WHERE offering_id = ?里的offering_id没走索引,或者存储过程里查时间冲突的那条 join 走了全表扫描,导致每次调用都持有大量行锁甚至锁住整张表。另一个隐性原因是事务里夹了慢操作,比如在存储过程里调用了带SLEEP的测试语句忘了删。
排查方法是开两个会话,一个执行选课不提交,另一个查performance_schema.data_locks和data_lock_waits,看看到底锁了哪些行、谁在等谁。解决思路是保证所有WHERE条件都走索引,把事务里的非数据库操作全部挪出去,并且用SHOW PROCESSLIST确认没有长事务挂着。压测前后各跑一次EXPLAIN,看type有没有退化成ALL。
5.3 现象:本地跑得好好的,换台机器中文全变问号,或者排序结果不一致
原因是字符集不统一。建库时用了默认的latin1,建表时又指定了utf8mb4,连接层再是另一套,三处不一致就会出现乱码;更隐蔽的是排序规则不同导致JOIN时索引失效,查询计划突然变差。还有一种情况是旧版本 MySQL 的utf8其实是三字节,存不下四字节字符,插入表情符号直接报错。
解决办法是在建库语句里把字符集和排序规则一次写死,所有表继承,连接串里显式指定charset=utf8mb4。排查时执行SHOW VARIABLES LIKE 'character_set%'和SHOW TABLE STATUS,逐层对齐。排序规则优先用数据库版本的默认值,不要一半表用general_ci一半用0900_ai_ci,混用会在 join 时报「Illegal mix of collations」。
5.4 现象:教务在页面上删了一个教学班,选课记录还在,查课表时报外键错误
原因是外键的删除行为设成了RESTRICT,删除被挡住了,但应用层把数据库报错吞掉了,界面上显示删除成功,实际没删,数据处于半吊子状态。另一种相反的情况是设了ON DELETE CASCADE,删教学班把学生成绩一起删了,这是不可接受的数据丢失。
处理原则是教学班不允许物理删除,改为设置is_open = 0关闭选课,或者把status置为归档。确实要删,必须先检查该教学班下有没有有效选课记录,有就拒绝并提示。应用层不能吞异常,捕获外键错误后要把明确提示返回给界面。报告里可以把这条写成「参照完整性与业务规则的冲突处理」,是个加分点。
5.5 现象:报告里的 ER 图和建表脚本对不上,答辩被追问哪张是准的
这是文档问题,但杀伤力很大。常见情形是 ER 图画了「成绩」实体,脚本里成绩是选课记录的字段;或者 ER 图里课程和教学班是一个实体,脚本里拆成了两张表。评审一眼就能看出图和代码不是同一次思考的产物。
解决办法是先定脚本,再从脚本反推 ER 图。用SHOW CREATE TABLE导出全部表结构,逐表核对实体、属性、联系,图上的每个联系都要能在脚本里找到对应的外键或者中间表。字段说明表也要和脚本的注释保持一致,改了一处就同步另一处。答辩前留半小时专门做这项对齐,比多写两页功能描述有用。
6. 从能跑到能交:压测验证、执行计划检查和报告里的关键图
写完存储过程,很多人就停在「手动点两次能选上」这一步。真正能让方案站住的是压测:用脚本模拟一百个人抢同一个教学班,看成功数是不是正好等于容量。下面这段脚本用线程池并发调用存储过程,跑完统计结果码分布,成功数大于容量就说明有超卖,必须回去查锁和条件更新。
import concurrent.futures import mysql.connector DSN = dict(host="127.0.0.1", port=3306, user="cs_app", password="App#2024", database="course_selection") def enroll(student_id, offering_id): conn = mysql.connector.connect(**DSN) # 每个线程独立连接,避免共享游标 try: cur = conn.cursor() cur.execute("CALL sp_enroll(%s, %s, @code, @msg)", (student_id, offering_id)) cur.execute("SELECT @code, @msg") return cur.fetchone() # 返回 (结果码, 提示) finally: conn.close() # 无论成败都释放连接 if __name__ == "__main__": # 100 个不同学号抢同一个教学班,容量先设成 30 tasks = [(f"2023010{i:03d}", 7) for i in range(1, 101)] with concurrent.futures.ThreadPoolExecutor(max_workers=50) as pool: results = list(pool.map(lambda t: enroll(*t), tasks)) ok = sum(1 for code, _ in results if code == 0) print("成功:", ok, "满员拒绝:", sum(1 for c, _ in results if c == -3))参数上有三个地方要按本机改:连接串里的账号密码、offering_id换成你自己库里真实的班号、学号列表要保证在学生表里真实存在,否则会返回 -99 而不是 -3。跑之前把该教学班的capacity设成 30、enrolled清零,跑完对一下SELECT enrolled FROM offering WHERE offering_id = 7和SELECT COUNT(*) FROM enrollment WHERE offering_id = 7 AND status = 1,两个数必须相等,且都等于 30。
报文里最值钱的三张图,我现在都会提前准备好:一张 ER 图,严格从建表脚本反推,每个外键都在图上;一张并发时序图,就是 4.2 节那张两会话对照表,画成图更能说明超卖怎么发生、条件更新怎么挡住;一张执行计划对比截图,优化前后的EXPLAIN并排放,标出type从ALL变成ref的那一行。这三张图比任何文字描述都能证明这套学生选课管理信息系统是真跑过的。
我自己的习惯是,每改一次表结构就重跑一遍压测脚本,把成功数、耗时、执行计划截图存进一个固定目录,答辩前只翻这个目录,不去回忆当时改了什么。课程设计最容易翻车的不是不会写 SQL,而是改到后面忘了前面为什么这么定。希望帮到你。
本文还有配套的精品资源,点击获取