首页 / 资讯中心 / 文章详情

基于SpringBoot与Vue的高校选课系统:高并发抢课与数据一致性实现

基于SpringBoot与Vue的高校选课系统:高并发抢课与数据一致性实现 ★ FEATURED ARTICLE
简介这份资源是面向高校计算机相关专业学生与Java开发学习者的毕业设计参考项目聚焦高校学生选课系统的完整实现方案可帮助读者理解前后端分离架构下的选课业务落地思路。项目后端以Java语言结合SpringBoot框架搭建数据库采用MySQL 5.7持久层使用MyBatisPlus前端基于Vue.js与ElementUI构建交互界面并通过Maven统一管理依赖整体遵循B/S模式浏览器即可访问。压缩包共695个文件约16.2MB其中131个java源文件承载核心业务逻辑98个vue文件对应前端页面组件另有xml配置、js脚本、css样式、jpg与png图片资源及sql相关文件等覆盖从后端接口到前端展示的完整代码结构。系统涵盖学生与教师账户维护、课程发布编排、选课执行与冲突检测、选课结果确认查询等模块并支持教师上传图片、视频等多媒体教学资源。目前已有74人学习适合需要选课系统赛题方案、代码参考与架构学习的技术人员。1. 选课系统为什么总在开学第一分钟崩从标题拆出真实战场每年开学季教务系统崩上热搜几乎成了固定节目。学生掐着点刷新页面转圈选课按钮点下去没反应等终于进去了想选的课已经满了。这不是段子是很多高校教务处的真实压力测试。基于 SpringBoot 与 Vue 的高校学生选课系统设计与实现本质上要解决的就是这个问题在高并发抢课场景下保证课程容量不被超卖、每个学生的选课结果准确、系统在压力下不雪崩。这个标题适合两类人一是正在做课程设计或毕业设计的计算机专业学生需要一套能跑通、能讲清楚技术选型理由的完整方案二是刚入行的 Java 工程师想通过一个真实业务场景理解 SpringBoot 后端与 Vue 前端的协作方式。选课系统看起来简单但它是典型的“读多写少、瞬时并发高、数据一致性要求严”的场景把这个问题吃透比做十个增删改查的练手项目都有价值。我见过太多选课系统的实现停留在“能选就行”的层面结果一压测就原形毕露。下面从架构选型开始一步步拆到能复现的程度。2. 技术选型与项目骨架为什么是 SpringBoot Vue 而不是别的2.1 后端选 SpringBoot 的三个实际理由选型不是追新是算账。高校选课系统的后端需求很明确快速开发、内嵌容器部署、生态成熟。SpringBoot 在这三点上都是当前 Java 生态里最稳的选择。第一自动配置省掉了大量 XML。选课系统涉及数据库、缓存、消息队列、定时任务如果用传统 Spring光配置文件就能写几百行。SpringBoot 的 starter 机制把这些都封装好了引入spring-boot-starter-data-redis就自动配好连接工厂引入spring-boot-starter-web就自动配好 DispatcherServlet。对于课程设计这种周期紧、人手少的场景省下来的时间可以花在业务逻辑上。第二内嵌 Tomcat 让部署变简单。传统 WAR 包要装 Tomcat、配 server.xml、调 JVM 参数SpringBoot 打成可执行 JAR 后java -jar就能跑。配合 Maven 或 Gradle 插件构建和启动一条命令搞定。我一般用 Maven因为高校环境里 Maven 的教程和镜像配置更普及。第三Spring 生态的中间件整合成本低。选课系统需要 Redis 做缓存和分布式锁需要 RabbitMQ 或 RocketMQ 做异步削峰需要 MyBatis 或 JPA 做持久化。这些在 SpringBoot 下都有官方或社区维护的 starter版本兼容性由 Spring 官方 BOM 统一管理不会出现“这个库依赖那个版本、那个库又冲突”的玄学问题。注意SpringBoot 版本不要盲目追最新。3.x 要求 Java 17 起步很多学校机房还停留在 Java 8 或 11。如果课程设计环境不可控选 SpringBoot 2.7.x 更稳妥它支持 Java 8 到 Java 17兼容性最好。2.2 前端选 Vue 的考量与工程结构Vue 的核心优势是上手快、组件化清晰、与后端分离彻底。选课系统的前端页面不算复杂登录页、课程列表页、选课结果页、管理员排课页。用 Vue 3 的 Composition API 可以把每个页面的逻辑收拢在setup里配合 Vue Router 做路由守卫控制权限配合 Pinia 做全局状态管理比如存储当前登录用户信息和已选课程 ID 集合。项目骨架我一般这样组织course-selection/ ├── backend/ # SpringBoot 后端 │ ├── src/main/java/com/example/selection/ │ │ ├── controller/ # 接口层 │ │ ├── service/ # 业务逻辑层 │ │ ├── mapper/ # MyBatis 数据访问层 │ │ ├── entity/ # 数据库实体 │ │ ├── dto/ # 数据传输对象 │ │ ├── config/ # 配置类Redis、RabbitMQ、CORS │ │ └── SelectionApplication.java │ ├── src/main/resources/ │ │ ├── application.yml # 主配置 │ │ └── mapper/ # MyBatis XML │ └── pom.xml ├── frontend/ # Vue 前端 │ ├── src/ │ │ ├── views/ # 页面组件 │ │ ├── components/ # 通用组件 │ │ ├── router/ # 路由配置 │ │ ├── store/ # Pinia 状态 │ │ ├── api/ # Axios 封装 │ │ └── main.js │ ├── package.json │ └── vite.config.js └── README.md前后端分离的关键是接口约定。我习惯用 RESTful 风格选课相关接口大致如下接口方法路径说明获取课程列表GET/api/courses?page1size20分页查询支持按名称模糊搜索选课POST/api/selections请求体含 courseId从 Token 解析 studentId退课DELETE/api/selections/{courseId}逻辑删除释放容量我的选课GET/api/selections/my返回当前学生已选课程管理员排课POST/api/admin/courses需要 ADMIN 角色2.3 数据库表设计的最小可用集合表设计直接决定后面能不能扛住并发。核心就三张表学生表、课程表、选课记录表。课程表里必须有一个capacity字段表示容量一个selected_count字段表示已选人数。选课记录表用(student_id, course_id)做联合唯一索引防止重复选课。CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL, teacher VARCHAR(64) NOT NULL, capacity INT NOT NULL DEFAULT 0, selected_count INT NOT NULL DEFAULT 0, start_time DATETIME, end_time DATETIME, version INT NOT NULL DEFAULT 0, INDEX idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id), INDEX idx_course (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;version字段是乐观锁用的后面讲并发控制时会展开。selected_count和capacity放在同一张表是为了在一条 UPDATE 语句里完成“判断容量 扣减”的原子操作。3. 选课核心链路从点击按钮到数据落库的完整实现3.1 选课接口的并发控制乐观锁 Redis 预扣选课最怕超卖。假设课程容量 50当前已选 49两个请求同时进来都读到 49都判断小于 50都执行加一结果变成 51。这就是典型的并发问题。解决方案分两层。第一层用 Redis 做预扣把课程容量缓存到 Redis用 Lua 脚本保证判断和扣减的原子性。第二层用数据库乐观锁兜底防止 Redis 数据丢失或不一致。先看 Redis 预扣的 Lua 脚本-- keystore: course:stock:{courseId} -- argv[1]: 本次选课数量通常为 1 local stock redis.call(GET, KEYS[1]) if not stock then return -1 -- 缓存不存在需要回源 end if tonumber(stock) tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 -- 预扣成功这段脚本在 Redis 单线程模型下执行天然原子。返回 -1 表示缓存没命中需要从数据库加载后重试返回 0 表示没抢到返回 1 表示预扣成功可以继续走数据库写入。Java 侧调用public SelectionResult selectCourse(Long studentId, Long courseId) { String stockKey course:stock: courseId; // 先尝试 Redis 预扣 Long result redisTemplate.execute( new DefaultRedisScript(LUA_DECR_STOCK, Long.class), Collections.singletonList(stockKey), 1 ); if (result null || result -1) { // 缓存未命中从数据库加载容量后重试 loadStockToRedis(courseId); result redisTemplate.execute( new DefaultRedisScript(LUA_DECR_STOCK, Long.class), Collections.singletonList(stockKey), 1 ); } if (result null || result 0) { return SelectionResult.fail(课程已满); } // Redis 预扣成功走数据库乐观锁写入 try { int updated courseMapper.decreaseStock(courseId); if (updated 0) { // 数据库扣减失败回滚 Redis redisTemplate.opsForValue().increment(stockKey); return SelectionResult.fail(选课失败请重试); } selectionMapper.insert(studentId, courseId); return SelectionResult.success(); } catch (DuplicateKeyException e) { // 重复选课回滚 Redis redisTemplate.opsForValue().increment(stockKey); return SelectionResult.fail(请勿重复选课); } }对应的 MyBatis 更新语句用乐观锁update iddecreaseStock UPDATE course SET selected_count selected_count 1, version version 1 WHERE id #{courseId} AND selected_count lt; capacity AND version #{version} /update这里有个细节version需要从查询结果传入。实际写法是先SELECT拿到当前 version再带着 version 去UPDATE。如果更新影响行数为 0说明在查询和更新之间有人改了数据需要重试或直接返回失败。提示Redis 预扣和数据库扣减之间不是强一致。如果数据库扣减成功但 Redis 回滚失败会导致 Redis 库存比实际少。生产环境需要定时任务对账把 Redis 库存重置为capacity - selected_count。课程设计场景下加一个每 5 分钟执行的对账任务就够。3.2 前端选课交互防重复提交与结果反馈前端不是只画页面。选课按钮点下去之后要立刻禁用按钮、显示 loading防止学生狂点。用 Vue 3 的ref控制按钮状态const submitting ref(false) async function handleSelect(courseId) { if (submitting.value) return submitting.value true try { const res await selectCourseApi(courseId) if (res.code 200) { ElMessage.success(选课成功) await refreshCourseList() } else { ElMessage.warning(res.message || 选课失败) } } catch (e) { ElMessage.error(网络异常请稍后重试) } finally { submitting.value false } }submitting是一个响应式布尔值为 true 时按钮的:disabled绑定生效同时可以显示“提交中…”文案。finally里恢复状态保证无论成功失败按钮都能再次点击。Axios 封装里要加 Token 拦截和统一错误处理import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } ) export default request拦截器做了两件事请求发出前自动带上 Token响应返回后如果 401 就跳登录页。这样每个页面组件不用重复写鉴权逻辑。3.3 课程列表的分页与缓存策略课程列表是读多写少的典型。每次学生打开选课页都要查一遍如果每次都走数据库高峰期数据库压力很大。我一般用 Redis 缓存课程列表的第一页因为大部分学生只看第一页。public PageResultCourseVO listCourses(int page, int size, String keyword) { String cacheKey course:list: page : size : (keyword null ? : keyword); String cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return JSON.parseObject(cached, new TypeReferencePageResultCourseVO() {}); } PageHelper.startPage(page, size); ListCourseVO list courseMapper.selectByPage(keyword); PageInfoCourseVO pageInfo new PageInfo(list); PageResultCourseVO result new PageResult(pageInfo.getTotal(), pageInfo.getList()); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result), 30, TimeUnit.SECONDS); return result; }缓存时间设 30 秒是因为选课过程中课程余量变化快缓存太久会导致学生看到过期的余量。30 秒是一个折中既能挡住大部分重复查询又不会让数据太旧。管理员排课或修改课程后主动删除对应的缓存 key。分页用 PageHelper 插件PageHelper.startPage之后紧跟的查询会自动加 LIMIT。注意startPage只对下一次查询生效不要把它写在无关查询前面。4. 避坑与排查选课系统最容易翻车的五个地方4.1 坑一Redis 预扣成功但数据库写入失败库存对不上现象压测时发现 Redis 里课程余量显示还有 10 个但数据库里selected_count已经等于capacity学生点选课提示“课程已满”但 Redis 预扣却通过了。原因Redis 预扣和数据库扣减是两步操作中间可能因为数据库连接超时、乐观锁冲突、唯一索引冲突等原因失败。失败后如果忘记回滚 Redis就会导致 Redis 库存虚高。解决在数据库操作失败的catch块里必须回滚 Redis。更稳妥的做法是用 Redis 的INCR把预扣的数量加回去。同时加一个定时对账任务每 5 分钟扫描所有课程把 Redis 库存重置为capacity - selected_count。对账任务的 SQL 很简单SELECT id, capacity, selected_count FROM course遍历结果对每个课程执行redisTemplate.opsForValue().set(course:stock: id, capacity - selected_count)。4.2 坑二Vue 打包后放进 SpringBoot 静态目录刷新 404现象开发时前端跑在 5173 端口后端跑在 8080一切正常。把 Vue 打包后的dist目录拷到 SpringBoot 的src/main/resources/static下访问首页正常但刷新/courses页面就 404。原因Vue Router 默认用 history 模式刷新时浏览器会向服务器请求/courses这个路径SpringBoot 找不到对应的 Controller返回 404。解决在 SpringBoot 里加一个配置类把所有非 API 路径转发到index.htmlConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/**) .addResourceLocations(classpath:/static/); } Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{path:[^\\.]*}) .setViewName(forward:/index.html); } }addViewControllers里的正则[^\\.]*表示匹配不含点的路径这样/courses会转发到index.html而/js/app.js这种带点的静态资源请求不会被拦截。4.3 坑三SpringBoot 版本太高导致 Java 8 环境跑不起来现象用 Spring Initializr 生成了 SpringBoot 3.2 的项目本地 Java 17 跑得好好的拿到学校机房 Java 8 环境里java -jar直接报UnsupportedClassVersionError。原因SpringBoot 3.x 要求 Java 17 起步编译后的 class 文件版本是 61Java 8 的 JVM 只认到 52。解决如果部署环境不可控直接降级到 SpringBoot 2.7.x。在pom.xml里把 parent 版本改成2.7.18同时把java.version设为1.8。注意 SpringBoot 2.7 的依赖坐标和 3.x 有差异比如javax.servlet和jakarta.servlet的包名不同降级后需要把 import 改回javax。4.4 坑四选课记录表没有联合唯一索引学生重复选课现象学生快速点击两次选课按钮或者网络重试导致请求重复发送数据库里出现两条相同的(student_id, course_id)记录。原因表设计时只加了自增主键没有对(student_id, course_id)做唯一约束。业务代码里的“先查再插”在并发下不可靠。解决加联合唯一索引UNIQUE KEY uk_student_course (student_id, course_id)。这样即使两个请求同时插入数据库也会保证只有一条成功另一条抛DuplicateKeyException。Java 侧捕获这个异常返回“请勿重复选课”即可。4.5 坑五前端 Token 过期后接口全部 401但页面不跳转现象学生登录后放置半小时再点选课所有接口返回 401但页面没有任何反应学生以为系统卡死。原因Axios 响应拦截器里只处理了业务错误没有统一处理 401 状态码。或者处理了但跳转逻辑写在组件里每个页面都要重复写。解决在 Axios 响应拦截器里统一处理 401清除本地 Token 并跳转登录页。注意跳转要用window.location.href而不是 Vue Router 的push因为拦截器里拿不到路由实例。跳转前可以加一个判断避免在登录页反复跳转if (error.response?.status 401) { localStorage.removeItem(token) if (window.location.pathname ! /login) { window.location.href /login } }5. 压测验证与一个容易被忽略的细节5.1 用 JMeter 模拟 500 人同时抢课写完代码不算完得验证。我用 JMeter 做过一轮压测线程数设 500Ramp-up 设 1 秒循环 1 次模拟 500 个学生在 1 秒内同时点选课。目标课程容量设 50。测试计划配置组件配置Thread Group500 线程Ramp-up 1s循环 1 次HTTP RequestPOST /api/selectionsBody 含 courseIdHTTP Header ManagerContent-Type: application/jsonAuthorization: Bearer ${token}Summary Report查看吞吐量、平均响应时间、错误率第一次跑错误率 12%主要是“课程已满”和“请重试”。查看数据库selected_count正好 50没有超卖。Redis 库存也归零。说明并发控制生效了。但平均响应时间 800ms偏高。排查发现是每次选课都查了一次数据库拿 version这个查询在 500 并发下成了瓶颈。优化方案把 version 也缓存到 Redis选课时直接从 Redis 读 version数据库更新时带上这个 version。如果更新失败再从数据库重新加载 version 重试一次。优化后平均响应时间降到 320ms错误率降到 3% 以下。剩下的错误主要是乐观锁冲突后的重试失败属于可接受范围。5.2 一个容易被忽略的细节选课时间窗口校验很多实现只校验容量忘了校验选课时间窗口。学生可能在选课结束后通过直接调接口选课或者管理员还没开放选课就有人抢跑。正确做法是在选课接口入口加时间校验public SelectionResult selectCourse(Long studentId, Long courseId) { Course course courseMapper.selectById(courseId); LocalDateTime now LocalDateTime.now(); if (now.isBefore(course.getStartTime())) { return SelectionResult.fail(选课尚未开始); } if (now.isAfter(course.getEndTime())) { return SelectionResult.fail(选课已结束); } // ... 后续并发控制逻辑 }这个校验要放在 Redis 预扣之前避免无效请求打到 Redis。时间窗口可以从数据库读也可以缓存到 Redis 并设置过期时间等于endTime过期后自动失效。另外退课接口也要加时间窗口校验通常退课截止时间比选课截止时间早一两天方便教务处统计最终名单。这些时间参数建议做成配置项放在application.yml里不同学期改配置就行不用改代码。selection: window: select-start: 2025-02-20T08:00:00 select-end: 2025-02-25T22:00:00 drop-end: 2025-02-23T22:00:00用ConfigurationProperties绑定到一个配置类注入到 Service 里使用。这样比硬编码在代码里灵活得多。5.3 我踩过的一个血泪教训早期做选课系统时我把 Redis 预扣和数据库扣减放在同一个事务里想着“要么都成功要么都失败”。结果发现 Redis 操作不参与 Spring 的Transactional数据库回滚了 Redis 不会回滚反而更容易出问题。后来改成“Redis 预扣 数据库扣减 失败手动回滚 Redis 定时对账”这套组合拳才稳定下来。分布式场景下不要试图用本地事务去包住两个不同的存储系统。接受最终一致用对账兜底比追求强一致更实际。这个习惯我保持到现在任何跨存储的操作先问自己“失败了怎么补偿”而不是“怎么让它不失败”。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站