☰
SpringBoot+Vue高校选课系统实战:并发控制与防超卖方案
2026/10/7 10:15:03 网站建设 项目流程

简介:本资源为基于SpringBoot与Vue的高校学生选课系统完整Java源码,面向计算机相关专业毕业生、课程设计学习者及需要前后端分离实战项目的开发者,可帮助解决选课业务中资源分配、时间冲突与容量控制等实际问题。压缩包共695个文件,约16.2MB,以131个Java后端源码、98个Vue前端组件、161个svg图标、38个js脚本及42张jpg图片为主,另含xml配置、css样式、sql相关文件与bat启动脚本,覆盖前后端完整工程结构。系统采用SpringBoot+MyBatisPlus+MySQL 5.7构建后端,Vue.js结合ElementUI实现前端界面,通过Maven管理依赖,涵盖账户维护、课程发布、选课执行与冲突检测、结果查询及多媒体教学资源关联等核心模块。已有74人学习下载,适合作为毕业设计参考或二次开发基础,便于快速理解B/S架构选课平台的实现思路与代码组织方式。

1. 选课系统为什么总在开学第一分钟崩:从标题拆出真实需求

每年开学季,教务系统被挤爆的新闻几乎成了固定节目。表面看是流量问题,根子上是选课业务本身的并发模型没设计对——几千人同时抢同一门课的名额,数据库行锁排队、超卖、重复提交,任何一个环节没处理好,系统就会在关键时刻掉链子。这个标题讲的就是用 SpringBoot 做后端、Vue 做前端,从零搭一套能扛住真实选课场景的高校学生选课系统。它解决的不是“能不能选课”,而是“在名额有限、时间集中、操作密集的条件下,怎么保证数据不错、响应不崩”。适合正在做课程设计的学生、需要交付教务类项目的开发者,以及想拿一个完整项目练手 SpringBoot 和 Vue 全栈配合的 Java 工程师。源码层面,核心难点集中在选课并发控制、课表冲突检测和前后端权限对齐这三块,后面会逐一拆开讲。

2. 技术选型与项目骨架:为什么是 SpringBoot 加 Vue

2.1 后端选 SpringBoot 的四个实际理由

选课系统的后端需求很明确:提供 REST 接口、管理数据库事务、做身份认证和权限控制。SpringBoot 在这几件事上都有成熟的默认方案,不需要从零配 Servlet 容器和 XML。具体来说,我选它的理由有四个。

第一,起步依赖把常用库打包好了。引入spring-boot-starter-web就带上了内嵌 Tomcat 和 SpringMVC,引入spring-boot-starter-data-jpa或mybatis-spring-boot-starter就能操作数据库,省掉大量版本对齐的麻烦。第二,自动配置让数据源、事务管理器、JSON 序列化这些基础设施开箱可用,application.yml里写几行就能跑。第三,SpringBoot 的拦截器和过滤器机制很适合做登录态校验和角色鉴权,选课系统里学生、教师、管理员三种角色走不同接口,用拦截器统一处理最省事。第四,社区资料足够多,遇到问题能搜到答案,这对课程设计类项目很重要——你不想在环境问题上耗掉一半时间。

注意:SpringBoot 版本不要盲目追最新。3.x 要求 Java 17 起步,如果你本地是 Java 8 或 11,直接用 2.7.x 更稳。很多教程里的配置在 3.x 上写法变了,比如spring.jpa.hibernate.ddl-auto的行为有调整,踩过这个坑的人不少。

2.2 前端选 Vue 的考虑与工程结构

前端选 Vue 的核心理由是上手快、组件化清晰、和 SpringBoot 的 REST 接口配合自然。选课系统前端主要有三个页面群:登录注册、学生选课与课表查看、教师和管理员的后台管理。用 Vue 的组件拆分,每个页面群对应一组组件,路由用vue-router管理,状态用pinia或简单的provide/inject就够,不需要上 Vuex 那么重。

工程结构上,我一般这样组织:

src/ api/ # 封装 axios 请求,按模块分文件 router/ # 路由配置,含路由守卫 store/ # 全局状态,存用户信息和 token views/ # 页面级组件 components/ # 可复用组件,如课表表格、选课卡片 utils/ # 请求拦截器、日期格式化等

后端项目结构按经典分层走:

src/main/java/com/example/course/ controller/ # 接口层 service/ # 业务逻辑 repository/ # 数据访问 entity/ # 数据库实体 config/ # 拦截器、跨域、安全配置 common/ # 统一返回体、异常处理

2.3 前后端联调的最小可运行配置

联调阶段最容易卡在跨域和端口上。开发时前端跑在 5173(Vite 默认)或 8080(Vue CLI 默认),后端跑在 8080 或 8081,端口冲突和跨域请求失败是高频问题。我的做法是后端统一配一个跨域配置类,前端 axios 的 baseURL 指向后端地址。

后端跨域配置:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") // 开发阶段放开,上线要收紧 .allowedMethods("GET", "POST", "PUT", "DELETE") .allowCredentials(true) .maxAge(3600); } }

这段配置的作用是允许前端跨域访问后端接口。allowedOriginPatterns在 SpringBoot 2.4 以后替代了allowedOrigins,用*加allowCredentials(true)时必须用 Pattern 版本,否则启动报错。上线时要把*换成具体的前端域名,否则等于没做安全限制。

前端 axios 封装:

import axios from 'axios' const request = axios.create({ baseURL: 'http://localhost:8080/api', // 后端接口前缀 timeout: 5000 }) // 请求拦截器:自动带上 token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = 'Bearer ' + token } return config }) // 响应拦截器:统一处理错误码 request.interceptors.response.use( res => res.data, err => { if (err.response && err.response.status === 401) { // token 过期,跳转登录 window.location.href = '/login' } return Promise.reject(err) } ) export default request

baseURL要和后端server.servlet.context-path或接口前缀对齐,否则 404。请求拦截器负责注入 token,响应拦截器负责处理 401 跳登录,这两个拦截器是前后端权限对齐的关键,少了任何一个都会出现“明明登录了却调不到接口”的情况。

3. 数据库设计与选课核心表结构

3.1 五张核心表的关系与字段设计

选课系统的数据模型不复杂,但字段设计直接影响后面并发控制能不能做。核心表有五张:学生表、教师表、课程表、选课记录表、开课表。这里重点说课程表和选课记录表,因为选课逻辑主要围绕它们转。

课程表存课程的基本信息(课程名、学分、教师),开课表存某个学期某门课的具体开设实例(上课时间、地点、容量、已选人数)。把课程和开课分开,是因为同一门课可能不同学期由不同老师开,容量也不同。选课记录表记录学生选了哪个开课实例,加一个唯一约束防止重复选课。

-- 开课表:每学期每门课的具体开设 CREATE TABLE course_offering ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL, semester VARCHAR(20) NOT NULL, capacity INT NOT NULL DEFAULT 50, -- 容量上限 selected_count INT NOT NULL DEFAULT 0, -- 已选人数,并发控制的关键字段 schedule VARCHAR(100), -- 如 "周一3-4节" location VARCHAR(50), UNIQUE KEY uk_course_teacher_sem (course_id, teacher_id, semester) ); -- 选课记录表 CREATE TABLE enrollment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, offering_id BIGINT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1, -- 1已选 0已退 UNIQUE KEY uk_student_offering (student_id, offering_id) );

selected_count这个字段是并发控制的核心。选课时先检查selected_count < capacity,然后加一。问题在于“检查”和“加一”之间如果有其他请求插进来,就会超卖。后面第 4 章会专门讲怎么用数据库行锁和乐观锁解决。

uk_student_offering唯一约束是防重复选课的最后一道防线。即使前端因为网络重试发了两次请求,数据库层面也会拒绝第二条。这个约束必须有,不能只靠前端按钮置灰。

3.2 课表冲突检测的字段依据

课表冲突检测依赖schedule字段的格式。如果存的是自由文本“周一3-4节”,程序解析起来很麻烦。我的做法是拆成三个字段:day_of_week(1-7)、start_section(第几节开始)、end_section(第几节结束)。这样冲突检测就是纯数值比较。

ALTER TABLE course_offering ADD COLUMN day_of_week TINYINT COMMENT '1-7 周一到周日', ADD COLUMN start_section TINYINT COMMENT '开始节次', ADD COLUMN end_section TINYINT COMMENT '结束节次';

冲突判断逻辑:两门课如果day_of_week相同,且节次区间有重叠,就算冲突。区间重叠的条件是start1 <= end2 AND start2 <= end1。这个判断放在 Service 层,选课前先查该学生已选课程,逐条比对。

3.3 用 MyBatis 还是 JPA:选课场景下的取舍

选课场景里,查询多、写入集中、需要精细控制 SQL。MyBatis 在这类场景下更顺手,因为你可以直接写带FOR UPDATE的查询来做行锁,也可以用UPDATE ... WHERE selected_count < capacity这种原子操作。JPA 的乐观锁@Version也能做,但配置和理解成本对课程设计来说偏高。

我一般用 MyBatis-Plus,基础 CRUD 不用写 SQL,复杂查询手写 XML 或注解。选课的核心 SQL 用注解写在 Mapper 里:

@Mapper public interface OfferingMapper extends BaseMapper<CourseOffering> { // 原子扣减:只有还有名额时才加一,返回影响行数 @Update("UPDATE course_offering SET selected_count = selected_count + 1 " + "WHERE id = #{offeringId} AND selected_count < capacity") int incrementSelectedCount(@Param("offeringId") Long offeringId); }

这条 SQL 是选课不超卖的关键。WHERE selected_count < capacity保证了只有名额未满时才执行加一,数据库的行锁保证了这个判断和更新是原子的。返回值为 0 说明名额已满,Service 层据此抛异常提示“课程已满”。这比先查再改的两步操作可靠得多,后者在并发下必然超卖。

4. 选课并发控制:从超卖到行锁的完整实现

4.1 超卖是怎么发生的:一个具体的时间线

假设课程容量 50,已选 49。两个学生同时点选课,后端两个线程同时执行“查询已选人数 → 判断小于容量 → 更新已选人数加一”。时间线可能是:线程 A 查到 49,判断通过;线程 B 也查到 49,判断也通过;A 更新为 50;B 更新为 51。结果容量 50 的课选了 51 人,超卖一个。这就是典型的检查后执行竞态。

解决思路有三种:数据库行锁、乐观锁版本号、Redis 预扣减。课程设计场景下,数据库行锁最简单可靠,不需要额外引入 Redis。乐观锁适合冲突不激烈的场景,选课这种集中抢课的场景冲突概率高,乐观锁重试次数会很多。Redis 预扣减性能最好,但引入了一个新组件,还要处理缓存和数据库一致性问题,对课程设计来说过重。

4.2 用数据库行锁实现不超卖选课

行锁方案的核心是让“检查容量”和“扣减容量”在同一个事务里原子完成。上面那条UPDATE ... WHERE selected_count < capacity就是干这个的。完整的 Service 方法:

@Service public class EnrollmentService { @Autowired private OfferingMapper offeringMapper; @Autowired private EnrollmentMapper enrollmentMapper; @Transactional(rollbackFor = Exception.class) public void selectCourse(Long studentId, Long offeringId) { // 1. 检查是否已选(唯一约束兜底,这里提前给出友好提示) Long count = enrollmentMapper.selectCount( new LambdaQueryWrapper<Enrollment>() .eq(Enrollment::getStudentId, studentId) .eq(Enrollment::getOfferingId, offeringId) .eq(Enrollment::getStatus, 1) ); if (count > 0) { throw new BizException("你已经选过这门课了"); } // 2. 检查课表冲突 checkScheduleConflict(studentId, offeringId); // 3. 原子扣减名额,返回 0 说明已满 int affected = offeringMapper.incrementSelectedCount(offeringId); if (affected == 0) { throw new BizException("课程名额已满"); } // 4. 写入选课记录 Enrollment enrollment = new Enrollment(); enrollment.setStudentId(studentId); enrollment.setOfferingId(offeringId); enrollment.setStatus(1); enrollmentMapper.insert(enrollment); } }

@Transactional保证整个方法在一个事务里,任何一步失败都回滚。第 3 步的原子扣减是防超卖的关键,第 4 步写入记录如果因为唯一约束失败(并发下同一学生重复提交),事务回滚,第 3 步的扣减也会撤销,不会出现“扣了名额但没选上课”的情况。

提示:@Transactional默认只对RuntimeException回滚。如果你的BizException继承的是Exception而不是RuntimeException,要加rollbackFor = Exception.class,否则异常抛出后事务不回滚,名额扣了但记录没写。这个坑我踩过,排查了半天。

4.3 退课逻辑与名额回收的注意事项

退课不是简单删记录,要把status置为 0 并回减selected_count。回减也要用原子操作,防止并发退课把计数减成负数。

@Update("UPDATE course_offering SET selected_count = selected_count - 1 " + "WHERE id = #{offeringId} AND selected_count > 0") int decrementSelectedCount(@Param("offeringId") Long offeringId);

退课 Service 里先更新选课记录状态,再调这个回减方法。WHERE selected_count > 0防止减到负数。退课和选课的并发也要考虑:一个学生退课的同时另一个学生选课,行锁会串行化这两个操作,不会出错。

4.4 用 JMeter 验证并发选课是否超卖

写完并发控制,必须验证。用 JMeter 开 100 个线程同时请求选课接口,课程容量设 50,跑完后查数据库selected_count是否等于 50,选课记录是否正好 50 条。如果大于 50,说明还有超卖漏洞。

验证 SQL:

-- 检查是否超卖 SELECT id, capacity, selected_count FROM course_offering WHERE id = 1; -- selected_count 应该等于 capacity,不能大于 -- 检查记录数是否和计数一致 SELECT COUNT(*) FROM enrollment WHERE offering_id = 1 AND status = 1; -- 这个数应该等于 selected_count

如果两个数不一致,说明扣减和写记录之间有事务问题,回头检查@Transactional是否生效、异常是否被吞掉。JMeter 的线程组配置:线程数 100,Ramp-Up 设 1 秒,循环 1 次,这样能制造出足够的并发压力。

5. 前后端权限对齐与接口联调避坑

5.1 三种角色的接口权限怎么划分

选课系统有三种角色:学生、教师、管理员。学生能选课、退课、查自己的课表;教师能查自己开的课的学生名单;管理员能管理课程、开课、用户。权限划分在接口层面做,用拦截器加注解的方式。

我一般定义一个@RequireRole注解,拦截器里读当前登录用户的角色,和注解声明的角色比对,不匹配就返回 403。

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); // 允许的角色 } // 拦截器里 public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (handler instanceof HandlerMethod) { HandlerMethod hm = (HandlerMethod) handler; RequireRole role = hm.getMethodAnnotation(RequireRole.class); if (role != null) { String userRole = getUserRoleFromToken(request); if (!Arrays.asList(role.value()).contains(userRole)) { response.setStatus(403); return false; } } } return true; }

Controller 方法上标@RequireRole({"STUDENT"})就限定了只有学生能调。这样权限规则跟着接口走,一目了然,比在配置文件里维护 URL 映射表清晰。

5.2 前端路由守卫与按钮级权限

前端权限分两层:路由级和按钮级。路由级用vue-router的beforeEach守卫,检查 token 和角色,没权限就跳 403 页面。按钮级用自定义指令或v-if判断角色,控制按钮显隐。

// 路由守卫 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.meta.requiresAuth && !token) { next('/login') } else if (to.meta.roles && !to.meta.roles.includes(role)) { next('/403') } else { next() } })

路由配置里给每个页面标meta: { requiresAuth: true, roles: ['STUDENT'] }。按钮级权限用v-if="role === 'ADMIN'"控制。注意前端权限只是体验优化,真正的安全边界在后端拦截器,前端隐藏了按钮不代表接口就安全,后端必须独立校验。

5.3 联调阶段最常见的四类报错

联调时高频报错有四类。第一类 404,通常是前端 baseURL 和后端接口路径对不上,或者后端context-path配了但前端没加。第二类 401,token 没带上或过期,检查请求拦截器是否生效、后端 token 解析逻辑是否正确。第三类 403,角色不匹配,检查 token 里的角色字段和接口要求的角色是否一致。第四类 500,后端异常,看后端日志的堆栈,通常是空指针或 SQL 错误。

排查顺序建议从后端日志入手,先确认请求有没有到后端。如果后端日志里没有对应请求,问题在前端或网络层;如果有请求但报错,看堆栈定位。这个顺序能省很多时间,不要一上来就怀疑前端代码。

6. 选课系统避坑与常见问题排查

6.1 坑一:事务不回滚导致名额扣了但没选上课

现象:并发测试时发现selected_count比实际选课记录多,学生反馈“显示选课成功但课表里没有”。

原因:@Transactional默认只对RuntimeException回滚,自定义的业务异常如果继承Exception,抛出后事务不回滚,扣减操作已提交。

解决:业务异常统一继承RuntimeException,或者在@Transactional上加rollbackFor = Exception.class。我现在的习惯是业务异常一律继承RuntimeException,省得每次都要记得加参数。

6.2 坑二:唯一约束冲突被全局异常处理器吞掉

现象:重复选课请求返回了 500 而不是友好提示,前端显示“系统错误”。

原因:数据库唯一约束冲突抛的是DuplicateKeyException,全局异常处理器没捕获这个类型,走了默认的 500 处理。

解决:在全局异常处理器里加@ExceptionHandler(DuplicateKeyException.class),返回“请勿重复选课”的提示。同时前端也要做防重复提交,按钮点击后置灰。

6.3 坑三:课表冲突检测漏掉了跨学期课程

现象:学生选了同一时间的两门课,系统没拦住。

原因:冲突检测只查了当前学期的已选课程,如果学生之前学期有未退的课,或者查询条件漏了学期过滤,就会漏判。

解决:冲突检测的查询条件要明确限定学期,并且只查status = 1的有效记录。检测逻辑要覆盖所有有效选课记录,不能只查当前操作涉及的学期。

6.4 坑四:前端 token 过期后接口全部 401 但不跳登录

现象:用户挂着页面一段时间后,所有操作都失败,但页面没跳登录页。

原因:响应拦截器里判断 401 的逻辑没写,或者写了但跳转被路由守卫拦截。

解决:响应拦截器里统一处理 401,清除本地 token 并跳登录页。跳转用window.location.href而不是router.push,避免路由守卫再次拦截造成死循环。

6.5 坑五:SpringBoot 版本升级后配置失效

现象:把 SpringBoot 从 2.7 升到 3.x 后,项目启动报错,数据源配置不生效。

原因:3.x 里一些配置项改名了,比如spring.datasource.url在某些情况下行为变化,MySQL 驱动类名从com.mysql.jdbc.Driver变成com.mysql.cj.jdbc.Driver,还有 Jakarta EE 包名从javax变成jakarta。

解决:升级前先看官方迁移指南,重点检查驱动类名、包名、配置项。课程设计项目不建议追新版本,用 2.7.x 稳定版就够了,把精力放在业务逻辑上。

7. 用压测数据反推容量参数:一个可复用的验证习惯

选课系统做完能跑,不等于能扛。我一般会做一轮压测,用数据反推几个关键参数:数据库连接池大小、Tomcat 线程数、课程容量设置是否合理。这一步很多人跳过,但它是区分“能演示”和“能上线”的分水岭。

压测工具用 JMeter,场景设计成:100 个学生并发抢 50 个名额的课,持续 30 秒。观察三个指标:接口平均响应时间、错误率、数据库selected_count是否准确。如果响应时间超过 2 秒或错误率超过 1%,就要调参数。

调优时重点看两个配置。数据库连接池用 HikariCP(SpringBoot 默认),maximum-pool-size默认 10,选课高峰期可能不够,可以调到 20 到 30,但要配合数据库的max_connections一起调,不能超过数据库上限。Tomcat 的server.tomcat.threads.max默认 200,一般够用,如果压测时请求排队严重可以适当调大。

spring: datasource: hikari: maximum-pool-size: 20 # 连接池上限,按数据库承载能力调 minimum-idle: 5 # 最小空闲连接 connection-timeout: 3000 # 获取连接超时,毫秒 server: tomcat: threads: max: 200 # 最大工作线程 min-spare: 20 # 最小空闲线程

这几个参数没有万能值,要根据压测结果调。我的习惯是先把连接池调到 20,跑一轮压测,看有没有获取连接超时的报错;如果有就继续加,直到错误消失或数据库连接数接近上限。Tomcat 线程数一般不用动,除非压测显示请求在排队。

还有一个容易被忽略的点:选课接口的响应体不要返回大对象。有的实现把课程详情、教师信息、已选学生列表全塞在一个响应里,单次响应几十 KB,并发一高网络就成了瓶颈。选课接口只返回成功或失败和必要提示,课表查询单独走一个接口,按需加载。

最后说一个验证习惯:每次改完并发相关的代码,都要重新跑一遍压测,确认selected_count和实际记录数一致。这个检查用一条 SQL 就能做,但能挡住绝大多数并发 bug。我现在的做法是把这条校验 SQL 写成一个定时任务,每隔一段时间跑一次,发现不一致就告警。课程设计项目不一定需要定时任务,但手动跑一遍是必须的。

这套方案我从课程设计做到实际交付用过几次,最深的教训是:并发问题不要靠猜,要靠压测数据说话。行锁方案在选课场景下足够用,不要为了“看起来高级”去上分布式锁,那会把简单问题复杂化。希望帮到你。

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

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

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

立即咨询