学生选课系统数据库设计:从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_idCHAR(10)否学号定长CHAR 比 VARCHAR 省去长度字节比较也快creditDECIMAL(3,1)否学分有 0.5 的粒度浮点会有精度误差金额和学分一律用定点数capacitySMALLINT否教学班容量不会超过几百SMALLINT 足够enrolledSMALLINT否已选人数冗余计数默认 0与容量同类型便于比较termVARCHAR(11)否形如 2024-2025-1定长但用 VARCHAR 更灵活scoreDECIMAL(5,2)是可空表示尚未出分0 和 NULL 在业务上含义完全不同statusTINYINT否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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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)) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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结果T1SELECT 余量为 1A 读到 1T2SELECT 余量为 1B 也读到 1T3UPDATE 扣减未提交A 持锁T4UPDATE 阻塞B 等待T5COMMIT余量变 0T6UPDATE 执行余量变 -1超卖把会话 A 的写法换成带条件的单条UPDATET4 处 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其实是三字节存不下四字节字符插入表情符号直接报错。解决办法是在建库语句里把字符集和排序规则一次写死所有表继承连接串里显式指定charsetutf8mb4。排查时执行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(host127.0.0.1, port3306, usercs_app, passwordApp#2024, databasecourse_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 [(f2023010{i:03d}, 7) for i in range(1, 101)] with concurrent.futures.ThreadPoolExecutor(max_workers50) 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而是改到后面忘了前面为什么这么定。希望帮到你。本文还有配套的精品资源点击获取