多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SpringBoot+Vue高校选课系统实战:并发控制与防超卖方案

SpringBoot+Vue高校选课系统实战:并发控制与防超卖方案 简介本资源为基于SpringBoot与Vue的高校学生选课系统完整Java源码面向计算机相关专业毕业生、课程设计学习者及需要前后端分离实战项目的开发者可帮助解决选课业务中资源分配、时间冲突与容量控制等实际问题。压缩包共695个文件约16.2MB以131个Java后端源码、98个Vue前端组件、161个svg图标、38个js脚本及42张jpg图片为主另含xml配置、css样式、sql相关文件与bat启动脚本覆盖前后端完整工程结构。系统采用SpringBootMyBatisPlusMySQL 5.7构建后端Vue.js结合ElementUI实现前端界面通过Maven管理依赖涵盖账户维护、课程发布、选课执行与冲突检测、结果查询及多媒体教学资源关联等核心模块。已有74人学习下载适合作为毕业设计参考或二次开发基础便于快速理解B/S架构选课平台的实现思路与代码组织方式。1. 选课系统为什么总在开学第一分钟崩从标题拆出真实需求每年开学季教务系统被挤爆的新闻几乎成了固定节目。表面看是流量问题根子上是选课业务本身的并发模型没设计对——几千人同时抢同一门课的名额数据库行锁排队、超卖、重复提交任何一个环节没处理好系统就会在关键时刻掉链子。这个标题讲的就是用 SpringBoot 做后端、Vue 做前端从零搭一套能扛住真实选课场景的高校学生选课系统。它解决的不是“能不能选课”而是“在名额有限、时间集中、操作密集的条件下怎么保证数据不错、响应不崩”。适合正在做课程设计的学生、需要交付教务类项目的开发者以及想拿一个完整项目练手 SpringBoot 和 Vue 全栈配合的 Java 工程师。源码层面核心难点集中在选课并发控制、课表冲突检测和前后端权限对齐这三块后面会逐一拆开讲。2. 技术选型与项目骨架为什么是 SpringBoot 加 Vue2.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 前后端联调的最小可运行配置联调阶段最容易卡在跨域和端口上。开发时前端跑在 5173Vite 默认或 8080Vue 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 requestbaseURL要和后端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_week1-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 BaseMapperCourseOffering { // 原子扣减只有还有名额时才加一返回影响行数 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 更新为 50B 更新为 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 LambdaQueryWrapperEnrollment() .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 的线程组配置线程数 100Ramp-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-ifrole ADMIN控制。注意前端权限只是体验优化真正的安全边界在后端拦截器前端隐藏了按钮不代表接口就安全后端必须独立校验。5.3 联调阶段最常见的四类报错联调时高频报错有四类。第一类 404通常是前端 baseURL 和后端接口路径对不上或者后端context-path配了但前端没加。第二类 401token 没带上或过期检查请求拦截器是否生效、后端 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%就要调参数。调优时重点看两个配置。数据库连接池用 HikariCPSpringBoot 默认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 写成一个定时任务每隔一段时间跑一次发现不一致就告警。课程设计项目不一定需要定时任务但手动跑一遍是必须的。这套方案我从课程设计做到实际交付用过几次最深的教训是并发问题不要靠猜要靠压测数据说话。行锁方案在选课场景下足够用不要为了“看起来高级”去上分布式锁那会把简单问题复杂化。希望帮到你。本文还有配套的精品资源点击获取
返回列表