简介:这份《数据库系统课程设计报告——学生选课管理信息系统》面向高校数据库课程设计的学生与指导教师,提供可直接参考的完整范文与模板。报告围绕选课系统展开,依次覆盖需求分析、组织结构与业务流/数据流分析、数据字典、概念模型(CDM)、逻辑模型(PDM)、SQL Server 表设计与完整性约束,以及视图、触发器、存储过程、索引的创建和功能调试,并延伸到登录与成绩管理模块的应用程序设计,便于读者理解从建模到实现的完整链路。压缩包内共 1 个 docx 文件,约 3.96MB,结构完整、目录清晰,适合按章节检索和二次修改。目前已有 852 人学习,可作为课程设计选题、报告撰写和数据库实验排版的实用素材。
1. 学生选课管理信息系统的课程设计,真正拉开差距的是数据一致性
答辩现场最容易被追问的一句话,往往不是“你画了几张 ER 图”,而是“两个学生同一秒抢最后一个名额,你的系统会不会超卖”。学生选课管理信息系统这个题目看起来温和——无非学生、课程、教师、选课几张表——但课程设计报告的分数常常就压在这种边界上。能建表、能增删改查只是及格线;约束有没有写进 DDL、选课事务有没有锁住课容量、高频查询有没有走索引,才是报告里真正能拿出来讲的东西。这篇内容面向正在做数据库系统课程设计的同学,也面向想补一遍数据库工程落地经验的开发者:从需求拆实体、写 DDL、做事务与并发,一路做到用执行计划证明优化有效,每一步都给能直接抄的结构和语句,也会把踩过的坑摊开讲清楚。
2. 从需求到 ER 图:选课系统最容易画错的三处关系
2.1 用业务动作反推实体清单,而不是先画方框
很多人一上来就在纸上画“学生”“课程”两个方框,然后中间连一条线,写个“选”。这样画出来的图能交,但一落到表结构就会出问题:成绩往哪放?同一门课这学期开、下学期还开,课程名要重复录几遍?教师和课程是什么关系?这些都得回头改图。
我一般的做法是先把业务动作列全,再从动作里抠名词。选课系统的动作其实是固定的几条:学生查可选课程、学生选课、学生退课、教师查看选课名单、教师录入成绩、教务管理员排课(开教学班)、教务管理员统计选课人数。把每条动作涉及的对象写下来,实体清单基本就出来了。
| 业务动作 | 涉及实体 | 关键属性(先粗后细) |
|---|---|---|
| 学生查可选课程 | 学生、教学班、课程 | 学号、班级号、课程名、学分、余量 |
| 学生选课 / 退课 | 学生、教学班、选课记录 | 学号、班级号、选课时间、状态 |
| 教师查名单 | 教师、教学班、选课记录 | 工号、班级号、学生名单 |
| 教师录成绩 | 选课记录 | 平时分、期末分、总评 |
| 管理员排课 | 课程、教师、教学班、学期 | 课程号、工号、容量、上课时间 |
| 管理员统计 | 教学班、选课记录 | 选课人数、余量、课程分布 |
这张表的用处不只是列实体,它同时告诉你哪些字段会被频繁查询。比如“查可选课程”这条动作,前端列表要显示课程名、学分、余量、教师名,这意味着如果严格按范式拆表,这个查询至少要 join 四张表——那就得考虑视图或者适度冗余,这个判断在后面的索引一节还会用到。
主键的选择在这里就该定下来。学号用业务主键(定长字符串)比自增整数好,因为它本身有含义、跨系统对得上;教学班号用自增整数没有语义,可以放心用代理主键。这一点看着是小事,但报告里写清楚“为什么这张表用业务主键、那张表用代理主键”,就是一个能体现设计思考的点。
2.2 选课记录为什么必须独立成表
“学生”和“教学班”是多对多关系,多对多必须落成中间表,这个大家都知道。但真正容易翻车的是这张中间表的粒度。
第一个问题是联系上的属性。选课这个动作带了一堆属性:选课时间、选课状态(已选、已退、已锁定)、成绩。这些属性既不属于学生,也不属于教学班,只能挂在联系上。如果图省事把成绩塞进学生表(比如加一列“已修课程成绩”),那一个学生修十门课就没地方存了。所以选课记录必须是一张独立的表,一行代表“某个学生在某个教学班的一次选课”。
第二个问题是重修。同一个学生、同一门课程,可能在不同学期各选一次,甚至同一学期因为挂科补考产生多条记录。这时候如果把唯一约束设成(学号, 课程号),重修直接选不上——这是课程设计里出现频率极高的一个坑,我会在第 5 章展开讲。正确的唯一键应该是(学号, 教学班号),因为教学班本身已经绑定了学期。
第三个问题是状态。选课不是一个瞬时动作,它有生命周期:选了可能退,退了可能再选,教师录完成绩之后这条记录就冻结。把状态做成一个字段而不是靠“删行”来表达退课,好处是能留痕、能统计退课率,坏处是唯一索引会跟你较劲。这个取舍在报告里值得单独写一段,它体现的是你对“删除 vs 状态位”的理解。
2.3 ER 图转关系模式:教学班到底该不该从课程里拆出来
这是整个设计里最能体现水平的一个决策,也是我见过最多人做错的地方。
新手常见写法是只建一张“课程表”,字段是课程号、课程名、学分、教师、上课时间、容量。问题是:同一门“数据结构”,这学期张老师教、下学期李老师教,你要么插两行课程(课程号重复,主键冲突),要么改原来那行(历史数据没了)。这两种都不行。
正确做法是拆成两层:课程(课程号、课程名、学分、学时、开课院系)和教学班(班级号、课程号、教师工号、学期、容量、已选人数、上课时间、教室)。课程是“这门课是什么”,教学班是“这门课这学期由谁在哪上”。一门课程可以对应多个教学班,一个教师可以带多个教学班,学生选的是教学班而不是课程。
转换规则其实就三条,写报告时可以直接对应:
- 1:N 联系(课程—教学班、教师—教学班),把“1”端的主键放到“N”端做外键,不要为它单独建表。
- M:N 联系(学生—教学班),必须独立建表,并把两端主键组合成联合唯一键。
- 带属性的联系(选课记录上的成绩、时间、状态),属性跟着中间表走,不要上提到实体表。
再往上一层还有院系。学生表里如果直接存“院系名称”,改一次学院名就要更新几百行,还会出现同一学院两种写法。按第三范式把院系拆出去,学生表只留院系编号,这个动作在报告里一句话就能说清,但答辩时被问到“你的表满足第几范式”,能指着这里答,比背定义强得多。
最后提醒一点:ER 图上的菱形(联系)和矩形(实体)数量不必追求多。我见过画了十几个实体、结果一半没有对应查询的表,这种图好看但没用。判断标准很简单——每个实体都要能回答“谁会查它、查它干什么”,答不上来的就删掉。
3. 建表落地:把选课规则写进 DDL 约束
3.1 字段类型与编码:课程号为什么不建议用自增整数
课程号、学号这类字段有一个共同点:它们有语义、跨系统要对得上。学号里通常编码了入学年份和院系,课程号里通常编码了开课单位。如果图省事全部用自增整数,导数据的时候对不上,答辩演示时也没法口头解释“这个 10023 是哪门课”。所以我的习惯是:业务主键用定长字符(学号CHAR(12)、课程号CHAR(8)),代理主键只在没有自然键的表上用(比如教学班号、选课流水号)。
几个容易选错的类型,列一下:
| 字段 | 推荐类型 | 不推荐 | 原因 |
|---|---|---|---|
| 学号 | CHAR(12) | VARCHAR、INT | 定长、有前导零、有语义 |
| 成绩 | DECIMAL(5,2) | FLOAT、DOUBLE | 浮点会出现 89.99999 这种值 |
| 学分 | DECIMAL(3,1) | INT | 存在 0.5、1.5 学分 |
| 选课状态 | TINYINT+ 注释 | VARCHAR | 省空间、比较快、便于加索引 |
| 选课时间 | DATETIME | VARCHAR | 要按时间排序和范围查询 |
| 学期 | CHAR(11)(如 2024-2025-1) | 两个INT | 单字段比较,排序天然正确 |
字符集这一项单独说。中文课程名、教师名必须用utf8mb4,老版本里的utf8实际是三个字节,遇到生僻字或者某些符号会直接截断报错。更麻烦的是,如果建库时用了一个字符集、建表时用了另一个,join 的时候会走隐式转换,索引直接失效——这个坑在第 5 章会详细说。
注意:成绩字段允许为
NULL,表示“还没录入”,不要用 0 代替。0 分和未录入在统计及格率时是完全不同的两件事。
3.2 主外键、唯一约束与 CHECK:把业务规则从代码搬到数据库
约束写在数据库里,比写在应用层代码里可靠得多。应用层可能有好几个入口(网页、脚本、手工 SQL),只要有一个漏了校验,脏数据就进去了。下面是我常用的建表顺序,注意外键依赖,被引用的表必须先建。
-- 1. 院系:被学生、教师、课程引用,最先建 CREATE TABLE department ( dept_id INT PRIMARY KEY AUTO_INCREMENT, dept_name VARCHAR(50) NOT NULL, UNIQUE KEY uk_dept_name (dept_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 2. 学生:学号用业务主键,状态 1 在读 / 0 离校 CREATE TABLE student ( sno CHAR(12) PRIMARY KEY, sname VARCHAR(30) NOT NULL, gender CHAR(1) NOT NULL DEFAULT 'U', dept_id INT, enroll_year SMALLINT NOT NULL, status TINYINT NOT NULL DEFAULT 1, CONSTRAINT ck_student_gender CHECK (gender IN ('M','F','U')), CONSTRAINT fk_student_dept FOREIGN KEY (dept_id) REFERENCES department(dept_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 3. 课程:课程本身的属性,不含教师和学期 CREATE TABLE course ( cno CHAR(8) PRIMARY KEY, cname VARCHAR(60) NOT NULL, credit DECIMAL(3,1) NOT NULL, hours SMALLINT NOT NULL DEFAULT 0, dept_id INT, CONSTRAINT ck_course_credit CHECK (credit > 0 AND credit <= 10), CONSTRAINT fk_course_dept FOREIGN KEY (dept_id) REFERENCES department(dept_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 4. 教学班:一门课在某学期由某教师开的一个班 CREATE TABLE teaching_class ( class_id INT PRIMARY KEY AUTO_INCREMENT, cno CHAR(8) NOT NULL, teacher_id INT NOT NULL, semester CHAR(11) NOT NULL, capacity SMALLINT NOT NULL, selected SMALLINT NOT NULL DEFAULT 0, classroom VARCHAR(30), schedule VARCHAR(60), CONSTRAINT ck_tc_capacity CHECK (capacity > 0 AND capacity <= 500), CONSTRAINT ck_tc_selected CHECK (selected >= 0 AND selected <= capacity), UNIQUE KEY uk_tc (cno, teacher_id, semester, schedule), KEY idx_tc_semester (semester, cno), CONSTRAINT fk_tc_course FOREIGN KEY (cno) REFERENCES course(cno), CONSTRAINT fk_tc_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有几个细节值得展开。uk_tc这个联合唯一键防的是“同一位老师在同一学期、同一时间段开了两门同样的课”,属于排课阶段的重复录入,不是选课阶段的重复选课,两者别混。ck_tc_selected这个 CHECK 是最后一道防线——它保证已选人数永远不会超过容量,就算应用层逻辑写错了,数据库也会把这条 UPDATE 挡回去。
选课表单独拿出来,因为它的约束最多:
CREATE TABLE enrollment ( enroll_id BIGINT PRIMARY KEY AUTO_INCREMENT, sno CHAR(12) NOT NULL, class_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 1, -- 1 已选 2 已退 3 已锁 select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, score DECIMAL(5,2) DEFAULT NULL, CONSTRAINT ck_enroll_score CHECK (score IS NULL OR (score >= 0 AND score <= 100)), UNIQUE KEY uk_enroll (sno, class_id, status), KEY idx_enroll_class (class_id, status), KEY idx_enroll_sno (sno), CONSTRAINT fk_enroll_student FOREIGN KEY (sno) REFERENCES student(sno), CONSTRAINT fk_enroll_class FOREIGN KEY (class_id) REFERENCES teaching_class(class_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_enroll里带上status是为了解决退课重选:退课不删行,把status改成 2,学生再选时插入一条status=1的新行不会撞唯一键。这个设计有代价——同一个学生同一门课会积累多条历史行,查询当前有效选课必须带status = 1条件,漏了就会重复统计。另一种更干脆的做法是退课直接DELETE,唯一键只留(sno, class_id),简单但丢历史。两种都能用,报告里选一种并说明理由即可。
提示:MySQL 在 8.0.16 之前的版本会解析但不执行CHECK 约束。如果你的环境版本偏低,上面那些 CHECK 等于没写,必须改用触发器或者应用层校验,别以为写上去就生效了。
3.3 索引规划:选课系统的四类高频查询
索引不是越多越好,每加一个索引,插入和更新就多一份维护成本。选课系统的查询其实很集中,按使用频率排下来大概是这四类:
第一类是“查某学期可选课程列表”,条件通常是semester = ?,可能再按课程号过滤,走idx_tc_semester。这个索引是(semester, cno)的联合索引,注意最左前缀——如果查询只带cno不带semester,用不上它。
第二类是“查某个教学班已选人数”或者“查某班名单”,条件是class_id = ?,走idx_enroll_class。之所以把status也放进联合索引,是因为几乎所有这类查询都要加status = 1,两列一起走能少回表。
第三类是“查某个学生的课表”,条件是sno = ?,走idx_enroll_sno。
第四类是成绩统计,比如“某课程平均分、及格率”,这类查询往往要扫全表或大范围数据,索引帮不上太多忙。我的处理方式是接受它慢,把统计结果物化成一张汇总表,录完成绩后批量刷新,而不是让报表页面每次实时算。
反过来,哪些列不该建索引?性别、状态这种只有两三个取值的列,选择性太低,走索引还不如全表扫;频繁更新的selected列也不要单独建索引,因为每次选课都要更新它,索引维护成本会拖慢整个事务。这一条写进报告,比列一堆索引更有说服力。
4. 选课事务与并发:最后一张余票为什么会被超卖
4.1 事务边界:一次选课到底包含哪几步
先把“选一门课”这个动作拆成数据库层面看,它至少包含:检查教学班是否还有余量、写入一条选课记录、把教学班的已选人数加一。这三步必须在同一个事务里,缺一不可。
如果分开做,就会出现这样的时序:甲查到余量是 1,乙也查到余量是 1,甲插入记录并把人数字段加到 1,乙插入记录也把人数字段加到 1——最后容量 30 的班收了 31 个人,这就是超卖。注意这里有两个根源:一是“先查后改”的读和写之间有窗口期,二是冗余的selected字段本身就是一个需要维护的派生数据。
所以事务边界的划法有两种思路,对应下面两种写法。
4.2 条件更新与行锁:两种防超卖写法怎么选
第一种是悲观锁,先锁行再判断。关键是FOR UPDATE必须放在事务里,且查询条件要能命中索引(走主键或唯一键),否则 InnoDB 会退化成锁全表,并发一上来就集体卡住。
START TRANSACTION; -- 锁住这个教学班这一行,其他人此时只能等 SELECT capacity, selected INTO @cap, @sel FROM teaching_class WHERE class_id = 1001 FOR UPDATE; -- 应用层判断 @sel < @cap,不满足就 ROLLBACK UPDATE teaching_class SET selected = selected + 1 WHERE class_id = 1001; INSERT INTO enrollment (sno, class_id, status) VALUES ('202301010001', 1001, 1); COMMIT;第二种是条件更新,把判断塞进 WHERE 里,靠影响行数来判断成败。这种写法不需要显式加锁,InnoDB 在执行 UPDATE 时会自动给命中的行加排他锁,语义更紧凑:
-- 只有还有余量时才会更新成功,rowcount 返回 0 就代表满了 UPDATE teaching_class SET selected = selected + 1 WHERE class_id = 1001 AND selected < capacity; -- 上一步影响行数为 1 才继续,否则直接回滚返回“已满” INSERT INTO enrollment (sno, class_id, status) VALUES ('202301010001', 1001, 1);放到应用层,用 Python 写出来大概是这样,重点看异常分支的顺序:
import mysql.connector def enroll(conn, sno, class_id): cur = conn.cursor() try: conn.start_transaction() # 显式开事务,别依赖 autocommit cur.execute( "UPDATE teaching_class SET selected = selected + 1 " "WHERE class_id = %s AND selected < capacity", (class_id,) ) if cur.rowcount == 0: # 余量为 0,抢失败了 conn.rollback() return "CAPACITY_FULL" cur.execute( "INSERT INTO enrollment (sno, class_id, status) VALUES (%s, %s, 1)", (sno, class_id) ) conn.commit() return "OK" except mysql.connector.IntegrityError: conn.rollback() # 撞唯一键:重复选课 return "DUPLICATE" except Exception: conn.rollback() # 任何异常都要回滚,否则计数会虚高 raise这段代码有两个顺序上的讲究。第一,必须先更新计数再插选课记录。如果反过来,先插记录再更新计数,一旦计数更新失败(比如容量满),你得手动删掉刚插的那行,而回滚整件事更干净。第二,插入时撞唯一键抛异常,回滚会把刚才那次selected + 1一起撤销,计数不会多出来——这正是把两步放进同一个事务的价值所在。
两种写法的对比:
| 维度 | SELECT ... FOR UPDATE | 条件 UPDATE |
|---|---|---|
| 锁的范围 | 显式锁住命中的行 | 隐式锁住更新的行 |
| 代码复杂度 | 需要读出来再判断 | 判断写在 WHERE 里 |
| 失败语义 | 判断后主动回滚 | 看 rowcount |
| 适用场景 | 选课前还要做复杂校验 | 单纯抢名额,逻辑简单 |
| 风险点 | 事务里夹杂慢查询会长时间持锁 | WHERE 条件写错会导致全表更新 |
注意:条件 UPDATE 里的
WHERE class_id = ?必须命中主键或索引。少了它,一条 UPDATE 会锁住整张教学班表,别人的选课全部排队,看起来就像“系统卡死”。
4.3 隔离级别怎么选,死锁出现了怎么看
选课场景我一般用READ COMMITTED。原因是这个业务对“不可重复读”不敏感——学生刷新一下看到余量变了是正常的、也是应该的;但REPEATABLE READ下快照读可能让他一直看到旧余量,反而容易误判。四个级别的取舍列个表,报告里可以直接用:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 选课场景适用性 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 不推荐,会读到别人未提交的计数 |
| READ COMMITTED | 不会 | 可能 | 可能 | 推荐,余量变化实时可见 |
| REPEATABLE READ | 不会 | 不会 | 快照读下基本不会 | 可用,但要注意快照读不是最新值 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 并发下大量锁等待,不适合选课高峰 |
死锁在选课系统里是真实会发生的:甲同学先选 A 课再选 B 课,乙同学先选 B 课再选 A 课,两个事务各持一把锁等对方释放,数据库检测到环路后干掉其中一个,报错码一般是 1213。解决办法不是加重试就完事,而是统一加锁顺序——比如批量选课时按class_id升序逐个加锁,让所有人的加锁路径一致,环路就形不成。
排查的时候,先看数据库的死锁日志,能看到具体是哪两条 SQL 在互相等待。这一步别猜,日志里写得比你回忆的准。
4.4 唯一索引、触发器、存储过程,各管哪一段
这三个东西经常被混用,其实职责不同。
唯一索引管“不许重复”。uk_enroll是防重复选课的最后一道闸,应用层判重可能因为并发漏掉,唯一索引不会。它的代价是插入失败会抛异常,代码里必须捕获并转成友好提示,否则用户看到的是 500。
触发器管“自动联动”。比如选课记录插入后自动把selected加一,听起来很优雅,但我不推荐在选课主链路上用它。原因是触发器里的逻辑对应用层是黑匣子,出问题时你只看到“插了一条记录,计数莫名其妙多了 2”,排查成本极高;而且触发器执行失败会连带整个事务回滚,错误信息往往被吞掉。触发器更适合做审计日志、状态变更留痕这种“失败了也不影响主流程”的事。
存储过程管“封装多步”。把选课逻辑写成存储过程,好处是网络交互少、逻辑集中;坏处是可移植性差、调试困难、版本管理麻烦。课程设计的场景下,我更倾向于把逻辑放在应用层,用事务包住,数据库只负责约束和存储——这样报告里讲起来清楚,代码也方便贴进附录。
5. 排查实录:五个把我卡到后半夜的坑
5.1 建表与数据导入阶段的三个坑
坑一:外键依赖顺序错了,建表直接报 1215 / 1452。
现象是执行建表脚本时,前面几张表成功,到中间某张表报“Cannot add foreign key constraint”,或者导入测试数据时报 1452 外键约束失败。原因很简单:被引用的表还没建,或者被引用的列上没有索引、类型和引用列不一致(比如一边INT一边BIGINT,一边utf8mb4一边utf8)。解决方式是按依赖顺序建表:院系 → 学生/教师 → 课程 → 教学班 → 选课记录;导入数据同理。另外,脚本开头先执行关闭外键检查、结尾再打开,可以规避历史数据顺序问题,但生产环境慎用,它只是让错误延后暴露。
坑二:唯一键设成(sno, cno),重修的学生选不上课。
现象很直观:某个学生上学期这门课挂了,这学期重新选,界面提示“已选过该课程”,但查选课记录明明是上学期那条。原因是唯一索引建在了学号和课程号上,而课程号在不同学期是同一个值。解决方式是把唯一键改成(sno, class_id),因为教学班号已经隐含了学期信息;如果确实要限制“同一学期不能重复选同一门课”,那也应该是(sno, cno, semester)这种带学期的组合,而不是裸的(sno, cno)。改完记得清理历史脏数据,否则加索引那一步会失败。
坑三:字符集不一致,中文课程名变问号,join 还特别慢。
现象分两种:轻的是查出来一堆???,重的是本来该走索引的关联查询突然变成全表扫。原因通常是建库用了utf8mb4、某张历史表却是latin1或三字节的utf8,两表 join 时数据库要做隐式字符集转换,索引直接失效。解决办法是统一在库级别指定DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci,逐表检查SHOW CREATE TABLE的输出,把不一致的用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4改过来。这一步在导入外部提供的测试数据时尤其要做。
5.2 事务与并发阶段的两个坑
坑四:事务里夹了一个慢查询,锁等待超时 1205 满天飞。
现象是压测时零星报“Lock wait timeout exceeded”,但代码逻辑看着没问题。抓日志才发现,选课事务里除了更新计数,还顺手查了一次“该生已修学分总计”,而这个查询没走索引、扫了几万行,把行锁持有了好几秒。原因是事务的边界画大了——所有查询都被包在一个事务里,锁的持有时间等于最慢那条 SQL 的耗时。解决办法是把只读查询挪到事务外面先做,事务里只保留“更新计数 + 插入记录”这两步,锁持有时间从秒级降到毫秒级。
坑五:写了FOR UPDATE还是超卖,最后发现是 autocommit 惹的祸。
现象很邪门:代码里明明有SELECT ... FOR UPDATE,压测还是超卖。原因有两个可能,一是连接开了 autocommit,每条 SQL 自成一个事务,FOR UPDATE加的锁在语句结束就释放了,等于没锁;二是 ORM 或者连接池把事务悄悄提交了,你以为在同一个事务里,其实不是。排查方式是打开数据库的通用日志,看实际发出去的语句序列里有没有BEGIN/START TRANSACTION,以及COMMIT出现的时机。解决方式是显式start_transaction(),并在连接池配置里关掉自动提交,压测时用两个并发各跑一百次验证计数是否精确等于成功次数。
6. 让报告站得住:执行计划、造数脚本和答辩前的自检
索引建了不等于生效,这句话在报告里最好用数据证明。最直接的办法是拿EXPLAIN对比加索引前后的执行计划:
EXPLAIN SELECT c.cname, t.selected, t.capacity FROM teaching_class t JOIN course c ON c.cno = t.cno WHERE t.semester = '2024-2025-1' AND t.selected < t.capacity;重点看四列:type是不是ref或range(出现ALL就是全表扫)、key是不是你建的那个索引、rows的估算量级、Extra里有没有Using filesort或Using temporary。把加索引前后的两份结果并排贴进报告,比写十句“性能得到优化”都有说服力。
造测试数据也别手工插。写个脚本批量生成,注意两点:选课记录要随机分布到各个教学班,不然所有数据挤在一个班里,索引效果根本测不出来;批量插入要分批提交,一次插十万行容易把日志撑爆。
import random import mysql.connector conn = mysql.connector.connect(host="localhost", database="course_selection", user="app", password="***", autocommit=False) cur = conn.cursor() classes = list(range(1, 201)) # 假设有 200 个教学班 students = [f"2023{i:08d}" for i in range(1, 2001)] batch = [] for sno in students: for class_id in random.sample(classes, 8): # 每人随机选 8 门 batch.append((sno, class_id, 1)) if len(batch) >= 1000: # 每 1000 条提交一次 cur.executemany( "INSERT IGNORE INTO enrollment (sno, class_id, status) VALUES (%s, %s, %s)", batch) conn.commit() batch.clear() conn.commit() cur.close() conn.close()INSERT IGNORE是为了跳过撞唯一键的重复数据,避免脚本跑到一半中断;autocommit=False配合分批提交,是为了让一万六千条记录在可接受的时间内写完,同时不给回滚日志太大压力。
答辩前我会自己跑三项检查:一是拿两个并发线程各选同一个只剩一个名额的班,看成功数是不是严格等于 1;二是把所有外键约束逐个试一遍,确认插非法数据会被挡回来;三是把报告里出现的每条 SQL 都在干净库上重跑一次,确保没有依赖本地残留数据。这三项都过,基本不会被问倒。
我自己的习惯是:写完 DDL 先别急着写应用代码,拿几十条手工构造的脏数据往库里灌一遍,看约束能不能挡住。挡不住的地方,就是我们血泪经验里最值钱的部分——因为答辩老师问的,往往就是这些挡不住的地方。希望帮到你。
本文还有配套的精品资源,点击获取