☰
Java毕设选课系统实战:SpringBoot并发控制与避坑指南
2026/10/7 10:23:14 网站建设 项目流程

简介:这是一套面向计算机专业学生与Java Web开发初学者的毕业设计完整源码,基于Java+SpringBoot后端与Vue.js前端构建学生网上选课系统,覆盖教室、教师、课程、教学计划、选课、成绩与学生管理及角色权限控制等核心模块,适合作为毕设选题参考或课程设计实践素材。压缩包共417个文件,约22.15MB,其中109个java文件承载后端业务逻辑,56个vue文件与17个js文件构成前端页面与交互,另有161个svg、19个png、14个jpg等静态资源,以及xml、yml、sql、bat等配置与数据库脚本,目录结构清晰,便于按模块检索学习。目前已有114人学习下载。读者可从中获得一套可直接运行的全栈项目方案,理解SpringBoot分层架构、Vue组件化开发与前后端数据交互流程,并借助sql脚本与bat启动脚本快速搭建环境,对照源码梳理选课冲突处理、成绩统计与权限设计等实现思路,为毕设答辩与项目实战积累经验。

1. 学生网上选课系统:为什么它是 Java 毕设里最容易翻车的一类项目

每年到了毕设季,选课系统几乎是出现频率最高的题目之一。原因很直接:业务场景人人熟悉,需求文档好写,答辩时老师也容易理解。但真正动手做过的人都知道,这类项目看着简单,实际落地时坑非常密集——选课并发、时间冲突校验、容量控制、退课重选,每一个点单独拎出来都能让系统在演示时当场翻车。

这个标题指向的是一个基于 Java + SpringBoot 的学生网上选课系统,交付物是源码工程。它要解决的核心问题是:让学生在线完成选课、退课、查课表,让管理员维护课程和名额,让教师查看选课名单。适合正在做毕设的本科生、需要快速搭出一个可演示可答辩系统的开发者,以及想拿一个完整 SpringBoot 项目练手的人。

我见过太多选课系统在本地跑得好好的,一到答辩现场多人同时点选课就出问题。所以这篇不打算只讲“怎么把功能堆出来”,而是按真实落地的顺序,把技术选型、数据库设计、核心业务实现、并发处理和排错一条条讲清楚。你照着做,至少能保证演示那天不丢人。

2. 技术选型与工程骨架:SpringBoot 版本、依赖和分层怎么定

2.1 为什么用 SpringBoot 而不是传统 SSM

毕设选课系统最常见的两套技术栈,一套是传统 SSM(Spring + SpringMVC + MyBatis),一套是 SpringBoot + MyBatis/MyBatis-Plus。我一般直接推荐后者,理由不是“新”,而是省事。

传统 SSM 要手写一堆 XML 配置:web.xml、spring-mvc.xml、applicationContext.xml、mybatis-config.xml,光是让项目跑起来就得折腾半天,而且版本冲突是玄学级别的。SpringBoot 把这些全部收敛到 application.yml 加几个 starter 依赖里,启动类一跑就起来,对毕设这种时间紧、要快速出成果的场景非常合适。

但这里有个热词里反复出现的问题:springboot版本太高。这是真实存在的坑。SpringBoot 3.x 要求 JDK 17 起步,而且把 javax.* 换成了 jakarta.*,很多老教程里的代码直接编译不过。如果你学校机房或者自己电脑装的是 JDK 8,那必须用 SpringBoot 2.x。我的建议是:

场景JDKSpringBoot说明
学校要求 JDK8 / 老教程82.7.x最稳,资料最多
自己电脑新,想用新特性173.2.x注意 jakarta 包名
不确定82.7.18保守选择不会错

选 2.7.x 的另一个好处是,网上绝大多数选课系统教程、MyBatis-Plus 文档都是基于这个版本的,你遇到问题能搜到答案。用 3.x 你会发现自己成了少数派,报错都没人踩过。

2.2 工程目录结构

一个能答辩、结构清晰的选课系统,包结构我通常这样分:

com.example.course ├── CourseApplication.java // 启动类 ├── config/ // 配置类:拦截器、跨域、MyBatis-Plus ├── controller/ // 接口层 ├── service/ // 业务层 │ └── impl/ ├── mapper/ // 数据访问层 ├── entity/ // 数据库实体 ├── dto/ // 请求参数对象 ├── vo/ // 返回给前端的对象 └── common/ // 统一返回、异常、工具类

分层不是形式主义。答辩老师很爱问“你的业务逻辑写在哪”,如果你把选课校验全塞在 Controller 里,这个问题就答不好。Service 层承载业务规则,Controller 只做参数接收和结果返回,这是基本要求。

2.3 核心依赖清单

pom.xml 里必须有的依赖,我列一下并说明每个的作用:

<!-- Web 层,提供 Controller、内置 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus,比原生 MyBatis 少写大量 XML --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok,省掉 getter/setter --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

参数说明:MyBatis-Plus 的版本要和 SpringBoot 版本匹配,2.7.x 配 3.5.x 没问题;MySQL 驱动 8.x 对应数据库 8.0,连接串要加serverTimezone=Asia/Shanghai,否则时间字段会差 8 小时,这是血泪经验。

提示:如果你用 SpringBoot 3.x,MySQL 驱动坐标和 MyBatis-Plus 版本都要换,MyBatis-Plus 需要 3.5.3.2 以上才支持 jakarta。

2.4 application.yml 关键配置

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/course_system?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8 username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true # 数据库下划线转Java驼峰 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印SQL,调试必备 global-config: db-config: logic-delete-field: deleted # 逻辑删除字段 logic-delete-value: 1 logic-not-delete-value: 0

map-underscore-to-camel-case打开后,数据库的student_id会自动映射到 Java 的studentId,不用手写 ResultMap。log-impl打开 SQL 日志,选课查不到数据时能直接看到执行的 SQL,排查效率翻倍。

3. 数据库设计:选课系统表结构和那几个不能省掉的字段

3.1 核心表清单

选课系统的表不多,但每张表的字段设计直接影响后面业务能不能写顺。核心五张表:

表名作用关键字段
student学生信息id, student_no, name, password, class_id
teacher教师信息id, teacher_no, name
course课程id, course_no, name, teacher_id, capacity, selected_count, credit
course_time上课时间id, course_id, week_day, start_section, end_section
selection选课记录id, student_id, course_id, select_time, status

这里重点说 course 表和 selection 表。course 里的capacity(容量)和selected_count(已选人数)是两个必须分开的字段。很多人图省事只存容量,选课时去 count 一下 selection 表,结果并发一来就超卖。把已选人数冗余到 course 表,配合数据库行锁,才能控制住。

selection 表的status字段也别省。退课不是物理删除记录,而是把 status 改成“已退选”。这样既保留了选课历史,又能防止同一学生反复选退同一门课刷数据。

3.2 建表 SQL

CREATE TABLE `course` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `course_no` VARCHAR(20) NOT NULL COMMENT '课程编号', `name` VARCHAR(100) NOT NULL COMMENT '课程名', `teacher_id` BIGINT NOT NULL COMMENT '授课教师', `capacity` INT NOT NULL DEFAULT 50 COMMENT '容量上限', `selected_count` INT NOT NULL DEFAULT 0 COMMENT '已选人数', `credit` DECIMAL(3,1) DEFAULT 2.0 COMMENT '学分', `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_course_no` (`course_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `selection` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `student_id` BIGINT NOT NULL, `course_id` BIGINT NOT NULL, `select_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `status` TINYINT DEFAULT 1 COMMENT '1已选 0已退', PRIMARY KEY (`id`), UNIQUE KEY `uk_stu_course` (`student_id`, `course_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

参数说明:uk_stu_course这个唯一索引是关键。它保证同一个学生对同一门课只能有一条记录,从数据库层面挡住重复选课。就算你代码里忘了判断,数据库也会拦下来。selected_count用 INT 而不是算出来的,是为了后面加锁更新。

3.3 时间冲突怎么存

上课时间单独一张表,因为一门课可能一周上两次。week_day存 1-7,start_section和end_section存第几节到第几节。判断两门课时间冲突,就是判断 week_day 相同且节次区间有重叠。这个逻辑放在 Service 层,后面第 4 章会写。

注意:不要图省事把上课时间存成字符串“周一3-4节”,那样冲突判断只能靠字符串解析,又慢又容易错。结构化存储是底线。

4. 选课核心业务:并发控制、时间冲突和容量校验怎么写

4.1 选课流程拆解

一次选课请求进来,Service 层要按顺序做这几件事:

  1. 校验学生是否已选过这门课(查 selection 表 status=1)
  2. 校验课程是否还有名额(selected_count < capacity)
  3. 校验时间是否与已选课程冲突
  4. 扣减名额(selected_count + 1)
  5. 插入选课记录

顺序不能乱。先做便宜的校验(查记录、查名额),再做时间冲突这种要联表计算的。任何一步失败就抛异常回滚。

4.2 并发超卖:为什么必须加锁

假设课程容量 50,已选 49,两个学生同时点选课。两个线程都读到 selected_count=49,都判断“还有名额”,然后都执行 +1,结果变成 51,超卖。这就是典型的并发问题,答辩时如果老师让你现场演示多人选课,不加锁必翻车。

解决办法有两种。一种是数据库悲观锁,在查询课程时加for update:

@Select("SELECT * FROM course WHERE id = #{id} FOR UPDATE") Course selectForUpdate(@Param("id") Long id);

FOR UPDATE会锁住这一行,第二个线程必须等第一个事务提交后才能读到最新值。缺点是并发高时排队严重,但毕设场景完全够用。

另一种是乐观锁,用版本号或直接条件更新:

@Update("UPDATE course SET selected_count = selected_count + 1 " + "WHERE id = #{id} AND selected_count < capacity") int increaseSelectedCount(@Param("id") Long id);

这条 SQL 把“判断名额”和“扣减”合并成一个原子操作,返回影响行数。如果返回 0,说明名额已满,直接抛异常。这种方式不用显式加锁,性能更好,我更推荐。

4.3 选课 Service 实现

@Service public class SelectionServiceImpl implements SelectionService { @Autowired private SelectionMapper selectionMapper; @Autowired private CourseMapper courseMapper; @Autowired private CourseTimeMapper courseTimeMapper; @Override @Transactional(rollbackFor = Exception.class) public void selectCourse(Long studentId, Long courseId) { // 1. 是否已选(status=1 表示有效选课) Long count = selectionMapper.countByStudentAndCourse(studentId, courseId); if (count > 0) { throw new BizException("你已经选过这门课了"); } // 2. 原子扣减名额,返回0说明满了 int rows = courseMapper.increaseSelectedCount(courseId); if (rows == 0) { throw new BizException("课程名额已满"); } // 3. 时间冲突校验 List<CourseTime> newTimes = courseTimeMapper.listByCourse(courseId); List<CourseTime> myTimes = courseTimeMapper.listByStudent(studentId); for (CourseTime nt : newTimes) { for (CourseTime mt : myTimes) { if (nt.getWeekDay().equals(mt.getWeekDay()) && nt.getStartSection() <= mt.getEndSection() && mt.getStartSection() <= nt.getEndSection()) { throw new BizException("与已选课程时间冲突"); } } } // 4. 插入选课记录 Selection sel = new Selection(); sel.setStudentId(studentId); sel.setCourseId(courseId); sel.setStatus(1); selectionMapper.insert(sel); } }

逻辑说明:@Transactional保证任何一步抛异常,前面的扣减都会回滚,不会出现“名额扣了但记录没插进去”的脏数据。时间冲突判断用的是区间重叠公式:A.start <= B.end && B.start <= A.end,这是判断两个区间是否相交的标准写法,记住它。

参数说明:rollbackFor = Exception.class必须写,因为 Spring 默认只对 RuntimeException 回滚,自定义的 BizException 如果继承 Exception 就不会回滚,这是高频翻车点。

4.4 退课实现

退课就是把 selection 的 status 改成 0,同时把 course 的 selected_count 减 1:

@Override @Transactional(rollbackFor = Exception.class) public void dropCourse(Long studentId, Long courseId) { int rows = selectionMapper.updateStatus(studentId, courseId, 0); if (rows == 0) { throw new BizException("你没有选这门课"); } courseMapper.decreaseSelectedCount(courseId); }

decreaseSelectedCount里要加selected_count > 0条件,防止减成负数。退课和选课一样,两步操作必须在同一个事务里。

5. 避坑与排查:选课系统演示前必须过的五道关

5.1 时间字段差 8 小时

现象:选课记录里的 select_time 比实际时间早 8 小时,或者查出来的时间对不上。

原因:MySQL 连接串没指定时区,驱动默认用 UTC。

解决:连接串加serverTimezone=Asia/Shanghai,同时确认数据库服务器时区。这个坑几乎每个新手都会踩一次。

5.2 中文乱码

现象:课程名、学生姓名存进数据库变成问号。

原因:数据库、表、连接串三处字符集不一致。

解决:建库建表统一用utf8mb4,连接串加useUnicode=true&characterEncoding=utf8。注意是 utf8mb4 不是 utf8,后者存不了 emoji,虽然选课系统用不上,但统一规范省事。

5.3 前端跨域 403

现象:前端页面调接口报 CORS 错误,Postman 里却正常。

原因:浏览器同源策略拦截,后端没配跨域。

解决:加一个全局跨域配置类,或者用@CrossOrigin注解。如果前端打包后放进 SpringBoot 的 static 目录,就不存在跨域问题,这也是热词里“vue打包放进springboot中”的常见做法。

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowCredentials(true); } }

5.4 逻辑删除后唯一索引冲突

现象:学生退课后重新选同一门课,插入报唯一键冲突。

原因:selection 表有uk_stu_course唯一索引,退课只是把 status 改成 0,记录还在,再插入就撞索引。

解决:重新选课时不要 insert,而是把已有记录的 status 改回 1。或者把唯一索引改成包含 status 的联合索引,但那样逻辑更绕。我一般用前者,退课记录复用。

5.5 事务失效

现象:选课抛异常了,但名额还是被扣了。

原因:方法不是 public、自调用、异常被 catch 没抛出,都会导致@Transactional失效。

解决:确保选课方法 public、由外部调用、异常往外抛。自调用场景(同类方法互相调)要用 AopContext 或者拆到另一个 Service。这个坑很隐蔽,建议在扣减名额后手动抛个异常测一下回滚。

6. 让选课系统更抗压:一个名额扣减的压测技巧和我的习惯

前面讲的原子扣减,到底有没有用,不能靠嘴说。答辩前我一般会做一次简单压测,用 JMeter 或者直接写个多线程测试,模拟 100 个学生抢 50 个名额,看最后 selected_count 是不是正好 50。

@Test public void testConcurrentSelect() throws InterruptedException { int threads = 100; ExecutorService pool = Executors.newFixedThreadPool(threads); CountDownLatch latch = new CountDownLatch(threads); AtomicInteger success = new AtomicInteger(); for (int i = 0; i < threads; i++) { final long studentId = i + 1; pool.submit(() -> { try { selectionService.selectCourse(studentId, 1L); // 抢课程1 success.incrementAndGet(); } catch (Exception e) { // 名额满或冲突,正常失败 } finally { latch.countDown(); } }); } latch.await(); Course course = courseMapper.selectById(1L); System.out.println("成功选课人数:" + success.get()); System.out.println("课程已选人数:" + course.getSelectedCount()); }

跑完看两个数字是否相等且不超过容量。如果 selected_count 大于 50,说明你的锁没生效,回去检查 SQL 条件。这个测试花不了十分钟,但能让你在答辩时底气十足。

再分享一个我的习惯:所有涉及“判断 + 更新”的业务,我都尽量合并成一条带条件的 UPDATE,靠影响行数判断成败,而不是先查再改。选课扣名额、退课减名额、库存扣减,都是这个套路。这样写出来的代码天然抗并发,也少了很多 if-else。

还有个细节,课程列表查询时,我会把selected_count和capacity一起返回给前端,显示成“已选 32/50”。学生看到快满了会紧张,这个体验比只显示课程名好得多,答辩演示时也是个加分项。

最后说一句,选课系统这类项目,功能谁都能堆出来,真正拉开差距的是边界处理——名额满了怎么办、时间冲突怎么提示、退课重选怎么兼容。把这些想清楚,你的系统就不只是“能跑”,而是“敢演示”。希望帮到你。

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

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

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

立即咨询