Java学生选课管理系统:高并发名额扣减与超卖排查实战
2026/9/18 16:50:10 网站建设 项目流程

简介:本资源为基于Java的学生选课管理系统答辩PPT,面向计算机相关专业毕业生、课程设计或毕业设计答辩人以及需要梳理SSM+Vue技术方案的开发者。整套答辩材料围绕需求分析、可行性论证、功能模块与数据库设计展开,可直接用于答辩汇报,也能作为撰写论文与整理技术路线的参考。压缩包内仅含1个pptx文件,约20.09MB,内容涵盖研究背景与开发意义、经济与技术及操作三方面可行性分析、登录与课程信息、选课、成绩查询、作业等界面展示截图,以及项目总结与致谢页,篇幅完整、条理清晰。系统技术栈为SSM后端配合Vue.js、Vue-Router、Vuex与Element UI前端,数据库采用Mysql,通过Ajax完成前后端通信。目前已有129人学习,适合需要快速成型答辩演示、对照界面截图理解选课业务逻辑与前后端协作方式的读者参考。

1. 从一份答辩PPT倒推:Java学生选课管理系统真正难在哪

答辩现场最容易被追问的不是"你用了什么框架",而是"一门课 200 个名额,500 个人在同一秒点选课,你的系统会不会放 201 个人进来"。基于 Java 的学生选课管理系统,课程、学生、教师三张表的增删改查一个下午就能搭完,真正拉开水平的是三件事:名额扣减在并发下的一致性、上课时间区间冲突的判定、以及退课后名额怎么安全还回去。它适合正在做课程设计或毕业设计的在校生,也适合拿它当第一个 Java 后端作品、准备 Java 面试题里"并发扣库存"那类问题的求职者。下面按选型、建表、接口实现、并发排查、现场演示的顺序,把这个系统完整走一遍。

2. Java学生选课管理系统的技术选型与MySQL表设计

2.1 三层架构与 Spring Boot + MyBatis 的落地组合

老教材里的实现方式是 JSP + Servlet + JDBC,好处是依赖少,坏处是页面和逻辑糊在一起,答辩时想单独演示一个接口都做不到。现在的常见做法是 Spring Boot 提供 REST 接口,MyBatis 负责 SQL 映射,前端可以是 Vue 也可以是 Thymeleaf 服务端渲染,把 Controller、Service、Mapper 三层切开。这样做的直接收益是接口能被 Postman 和压测脚本直接调用,验证并发问题时不用绕开浏览器。

选型答辩时的说服点
控制层Spring MVC接口可被 curl/JMeter 直接压测
业务层手写 Service事务边界清晰,能讲清回滚点
持久层MyBatisSQL 可见,方便解释行锁与索引
数据库MySQL 8 + InnoDB支持行级锁、事务、唯一索引
前端Vue 3 / Thymeleaf演示选课结果刷新直观

pom.xml里最少要引的几项:

<!-- Web 层,提供内嵌 Tomcat 这个 Java 容器 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis 起步依赖,2.x 版本适配 Spring Boot 3 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

版本号不必照抄,对齐自己 JDK 的版本即可,Spring Boot 3 要求 JDK 17 以上。依赖装完之后如果启动报lombok will not work,那是编译器和 Lombok 版本没对齐,属于 Java 环境变量配置和 IDE 编译插件这类基础问题,答辩机上提前跑一次 clean install 就能暴露。

2.2 选课系统必须有的四张核心表

表设计决定后面所有逻辑好不好写。学生、课程、教学班、选课记录四张表就够支撑一个可演示的系统,教学班是课程在某学期的具体开设,容量和已选人数放在教学班而不是课程上,因为同一门课不同老师、不同学期的名额是独立的。

表名作用关键字段
student学生id、学号、姓名、专业
course课程id、课程编号、课程名、学分
teaching_class教学班容量、已选人数、周几、起止节次
course_selection选课记录学生、教学班、状态、选课时间
CREATE TABLE `teaching_class` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `course_id` BIGINT UNSIGNED NOT NULL COMMENT '关联的课程', `teacher_id` BIGINT UNSIGNED NOT NULL, `semester` VARCHAR(16) NOT NULL COMMENT '如 2025-2026-1', `capacity` INT NOT NULL DEFAULT 0 COMMENT '总名额', `selected` INT NOT NULL DEFAULT 0 COMMENT '已选人数', `week_day` TINYINT NOT NULL COMMENT '1=周一 ... 7=周日', `section_start` TINYINT NOT NULL COMMENT '开始节次', `section_end` TINYINT NOT NULL COMMENT '结束节次', `classroom` VARCHAR(32) NOT NULL, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=可选 0=停开', PRIMARY KEY (`id`), KEY `idx_course_semester` (`course_id`, `semester`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教学班(开课班)表'; CREATE TABLE `course_selection` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `student_id` BIGINT UNSIGNED NOT NULL, `teaching_class_id` BIGINT UNSIGNED NOT NULL, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1=已选 2=已退', `select_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_student_class` (`student_id`, `teaching_class_id`), KEY `idx_class_status` (`teaching_class_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选课记录表';

唯一键uk_student_class是防重复提交的最后一道闸,前端按钮连点两次也不会产生两条记录。注意退课如果只把 status 改成 2,学生就再也选不回这门课了,因为唯一键还在,所以退课记录要么物理删除、要么把唯一键加上 status 字段,两种做法都要在答辩时能说清取舍。

2.3 Mapper 接口没有实现类,为什么能注入

第一次接触 MyBatis 的人都会卡在这里:CourseSelectionMapper只是一个接口,没有实现类,Spring 却能把它注进 Service。原因是 MyBatis 在启动时用 JDK 动态代理给每个 Mapper 接口生成了代理对象,方法调用被代理拦截后,去找同名的 SQL 语句执行并把结果映射成对象。这也解释了为什么 Mapper 接口不能重载方法、方法名和 XML 里的 id 必须一一对应。

public interface TeachingClassMapper { // 条件扣减:名额没满才会更新,返回影响行数 int increaseSelected(@Param("classId") Long classId); // 退课时归还名额,selected 不允许减成负数 int decreaseSelected(@Param("classId") Long classId); TeachingClass selectById(@Param("classId") Long classId); }
<update id="increaseSelected"> UPDATE teaching_class SET selected = selected + 1 WHERE id = #{classId} AND status = 1 AND selected &lt; capacity </update> <update id="decreaseSelected"> UPDATE teaching_class SET selected = selected - 1 WHERE id = #{classId} AND selected &gt; 0 </update>

#{classId}是预编译占位符,不要写成${classId},后者是字符串拼接,会留下注入风险。selected &lt; capacity这个条件写在 UPDATE 的 WHERE 里,意味着"判断"和"扣减"在同一条语句里完成,靠的是 InnoDB 对该行的排他锁,这正是后面解决超卖的关键。

3. 选课接口实现:容量扣减、时间冲突与退课候补

3.1 选课主流程的 Controller 与 Service 分层

接口只做参数接收和结果包装,业务判断全部放 Service,这样压测脚本可以绕过页面直接打接口。Controller 里不要出现任何 SQL 或事务注解,答辩时被问到"事务从哪开始"要能一句话指出位置。

@RestController @RequestMapping("/api/selection") public class SelectionController { private final SelectionService selectionService; // 构造器注入,避免字段注入在单元测试里难以替换 public SelectionController(SelectionService selectionService) { this.selectionService = selectionService; } @PostMapping("/select") public Result<Void> select(@RequestParam Long studentId, @RequestParam Long classId) { selectionService.selectCourse(studentId, classId); return Result.ok(); } }
@Service public class SelectionService { private final TeachingClassMapper classMapper; private final CourseSelectionMapper selectionMapper; private final List<SelectStrategy> strategies; public SelectionService(TeachingClassMapper classMapper, CourseSelectionMapper selectionMapper, List<SelectStrategy> strategies) { this.classMapper = classMapper; this.selectionMapper = selectionMapper; this.strategies = strategies; } @Transactional(rollbackFor = Exception.class) public void selectCourse(Long studentId, Long classId) { // 1. 冲突校验放在扣减之前,避免无效请求长时间持有行锁 checkTimeConflict(studentId, classId); // 2. 原子扣减,影响行数为 0 说明名额已满或教学班已停开 if (classMapper.increaseSelected(classId) == 0) { throw new BizException("名额已满,请选择其他教学班"); } // 3. 写入选课记录,唯一索引兜底重复提交 selectionMapper.insert(studentId, classId); } }

@Transactional的边界是整个方法,任何一步抛异常都要整体回滚。rollbackFor = Exception.class不能省,默认只对运行时异常回滚,业务里如果抛的是受检异常,扣减就白做了。第 3 步插入如果撞上唯一键,会抛DuplicateKeyException,Spring 会把它归为运行时异常从而触发回滚,所以"扣了名额但记录写失败"的情况不会留下脏数据。

3.2 用条件 UPDATE 把名额卡在 capacity 之内

超卖的根源是"先 SELECT 查余量,再 UPDATE 扣减"这种两步写法,两个线程可能同时查到还有 1 个名额。改成单条条件更新后,数据库行锁保证了同一行的判断和修改是原子的。代价是拿不到精确的失败原因,只能知道"没扣成功",需要更细的提示时再补一次查询。

两种写法的差别可以直接列出来对比:

写法是否存在超卖说明
先查后改存在两个事务可能都读到 selected=199
条件 UPDATE不存在WHERE 在行锁内求值
SELECT ... FOR UPDATE不存在悲观锁,事务变长,容易堆积

SELECT ... FOR UPDATE也能防超卖,但它把锁的持有时间拉长到整个事务结束,冲突多时表现为请求排队、响应变慢。条件 UPDATE 只在语句执行期间持锁,更适合抢课这种瞬时高冲突场景。如果确实需要版本号控制,可以在教学班表加version字段,把AND version = #{version}拼进 WHERE,更新后版本自增,效果一致但要多一次读取。

3.3 上课时间冲突的 SQL 判定条件

时间冲突的本质是两个闭区间是否相交。设已选课程区间为 [a1, a2],目标课程区间为 [b1, b2],相交的条件是a1 <= b2 AND b1 <= a2。这个条件直接翻译成 SQL,交给数据库过滤,比把学生所有已选课程拉回 Java 里循环比较要省得多。

SELECT COUNT(1) FROM course_selection cs JOIN teaching_class tc ON tc.id = cs.teaching_class_id WHERE cs.student_id = #{studentId} AND cs.status = 1 AND tc.semester = #{semester} AND tc.week_day = #{weekDay} AND tc.section_start &lt;= #{sectionEnd} AND tc.section_end &gt;= #{sectionStart}

semester必须参与过滤,否则上学期选过的课会把这学期的选课全判成冲突。week_day不同直接跳过,剩下的才做节次区间比较。返回的 COUNT 大于 0 就抛业务异常。实际排课里还有单双周、前八周这种更细的维度,简单做法是在教学班表加week_pattern字段,先判断周次是否重叠,再判节次。

3.4 退课、候补与选课规则的策略组合

退课的实现顺序是:先把选课记录状态置为已退或直接删除,再调用decreaseSelected归还名额,两步在同一个事务里。顺序不能颠倒,否则中途失败会出现"名额还了但记录还在"的情况。归还语句里的selected > 0是防负数兜底,重复退课请求打进来也不会把计数减穿。

不同课程的选课规则差异很大:必修课直选直中、公共选修课超额抽签、专业限选课按绩点排队。把规则写成 if-else 会越来越长,常见做法是策略模式,每种规则一个实现类,由 Spring 注入成 List。

public interface SelectStrategy { // 该教学班是否适用本策略 boolean support(TeachingClass tc); // 执行选课,具体规则各自实现 void doSelect(Long studentId, Long classId); } @Component public class DirectSelectStrategy implements SelectStrategy { @Override public boolean support(TeachingClass tc) { return tc.getCourseType() == CourseType.MANDATORY; } @Override public void doSelect(Long studentId, Long classId) { // 必修课:名额够就直接占位 } }

注入List<SelectStrategy>时 Spring 会按@Order或类名顺序放入,业务代码遍历找到第一个support为 true 的策略执行。答辩被问到扩展性时,这套结构能直接回答"新增一种抽签规则只需要加一个类,不改主流程"。

4. 选课高并发下的压测、报错排查与参数调优

4.1 用脚本模拟 200 人抢同一门课

先造一门 200 名额的课,用 shell 并发打接口是最快的验证方式,&把请求丢到后台,wait等全部结束。这个脚本不统计成功数,适合先看服务会不会被打挂。

#!/bin/bash # 200 个请求同时抢 classId=1001 的教学班 CLASS_ID=1001 for i in $(seq 1 200); do curl -s -X POST "http://127.0.0.1:8080/api/selection/select" \ -d "studentId=$i&classId=$CLASS_ID" & done wait echo "全部请求已发出"

想拿到精确的成功计数,用 Java 写并发测试更可靠,CountDownLatch在这里有两个用途:一个当起跑线,让所有线程在同一时刻释放;一个当终点线,等所有线程都执行完再统计结果。

int threads = 300; ExecutorService pool = Executors.newFixedThreadPool(threads); CountDownLatch startGate = new CountDownLatch(1); // 起跑线 CountDownLatch endGate = new CountDownLatch(threads); // 终点线 AtomicInteger success = new AtomicInteger(); for (long i = 1; i <= threads; i++) { final long studentId = i; pool.execute(() -> { try { startGate.await(); // 全部线程在此阻塞,直到统一放行 if (select(studentId, 1001L)) { success.incrementAndGet(); } } catch (Exception e) { // 名额已满属于预期失败,不计入成功数 } finally { endGate.countDown(); // 每个线程结束都要报数 } }); } startGate.countDown(); // 放行 endGate.await(); // 等待线程全部完成 pool.shutdown(); System.out.println("成功选课人数 = " + success.get());

名额设为 200 时,success.get()必须正好等于 200,教学班表的selected也必须等于 200。如果跑出 201,说明扣减逻辑还是先查后改,或者事务隔离级别和预期不一致。多跑几轮取最差结果,比跑一次通过就收工更有说服力。

4.2 三类典型报错与对应根因

压测过程里最容易撞上的问题就那么几类,提前把日志特征和处置方式整理成表,排错时不用现查。

现象日志特征根因处置
名额超卖selected > capacity先查后改,判断与扣减分离改条件 UPDATE
事务死锁Deadlock found when trying to get lock多行加锁顺序不一致固定按 id 升序更新
连接拿不到HikariPool-1 - Connection is not available池太小或在事务里做远程调用调大池并缩短事务
重复选课Duplicate entry for key 'uk_student_class'前端连点或重试捕获后返回"已选过"

死锁那条值得多说一句:如果一个事务先更新教学班再写选课记录,另一个事务顺序相反,交叉持锁就会死锁。统一成"先更新教学班、后写记录"能消掉大部分这类问题。连接池告警则通常是事务里夹了 HTTP 调用或文件操作,把耗时动作挪到事务外即可。

4.3 索引与连接池参数调整清单

选课记录的查询有两个方向:按学生查已选课表、按教学班统计人数,索引要两边都覆盖。只建student_id一个索引,统计某班人数时就会全表扫。

-- 查课表、判冲突走这个索引 ALTER TABLE course_selection ADD INDEX idx_student_status (student_id, status); -- 统计教学班人数、批量退课走这个索引 ALTER TABLE course_selection ADD INDEX idx_class_status (teaching_class_id, status);
参数默认值建议值作用
server.tomcat.threads.max200400请求处理线程上限
hikari.maximum-pool-size1050连接池上限,别超数据库 max_connections
hikari.connection-timeout300003000拿不到连接快速失败,避免线程堆积
innodb_lock_wait_timeout5010行锁等待秒数,抢课场景缩短更快暴露问题

调整顺序是先看SHOW ENGINE INNODB STATUS里的锁等待,再看连接池活跃数,最后才动线程数。把参数一次全调大会掩盖问题,答辩时也说不清每个值的依据。

5. 答辩现场:把选课链路演示成一个能自证的系统

5.1 演示数据的准备脚本

演示最怕现场造数据来不及,提前把脚本写好,讲之前执行一次就有干净的初始状态。造一门 200 名额、周三 3 到 4 节的课,方便现场把名额抢空。

-- 回滚到初始状态,保证每轮演示结果一致 UPDATE teaching_class SET selected = 0 WHERE id = 1001; DELETE FROM course_selection WHERE teaching_class_id = 1001; -- 造一门容量 200 的教学班,用于现场压测 INSERT INTO teaching_class (course_id, teacher_id, semester, capacity, selected, week_day, section_start, section_end, classroom, status) VALUES (1, 1, '2025-2026-1', 200, 0, 3, 3, 4, '教三-201', 1);

selectedcourse_selection必须一起重置,只清计数不清记录,下一轮演示就会撞唯一键,看起来像系统有 bug。

5.2 答辩追问的应答要点

评审的问题通常围绕"为什么这么做"而不是"会不会写",提前准备几组对比式回答比背代码有效。

追问应答要点
为什么不用 synchronized单机锁在多实例部署下失效,还会把无关课程串行化,条件 UPDATE 每门课互不影响
时间冲突为什么交给 SQL课表数据在库里,一次区间比较比拉几十条记录回内存再循环更省 IO
退课归还名额会不会减成负数归还语句带 selected > 0 条件,且与记录删除在同一事务内
名额扣减失败怎么给用户提示影响行数为 0 时回查一次教学班,区分"已满"和"已停开"

5.3 把压测脚本放进演示目录

演示机上提前把服务起好,把 4.1 的并发测试类打成一个可执行 jar,评审问到并发时就地跑一遍,把成功选课人数 = 200和数据库里selected = 200两张截图并排放在同一页。截图的时间戳、班级 id、名额数三者能对上,比口头解释超卖是怎么避免的更有分量。演示目录里同时留一份git log,把条件 UPDATE 那次提交单独标出来,评审追问实现演进时可以直接翻到改动前后两版 SQL 的差异。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询