ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue选课系统高并发防超卖实战

SpringBoot+Vue选课系统高并发防超卖实战 简介这份资源是面向计算机与软件工程专业学生、Java Web 初学者及毕设开发者的学生网上选课系统完整项目包采用 Spring Boot Vue 前后端分离架构后端基于 Java 与 JDK1.8前端使用 Vue.js 构建交互界面数据层搭配 MySQL 5.7 存储课程与选课信息可帮助读者理解用户管理、课程管理、选课逻辑与数据持久化等核心模块的实现思路。压缩包共 417 个文件约 21.82MB以 109 个 Java 后端源码、56 个 Vue 组件、17 个 JavaScript 脚本及 13 个 XML 配置为主另含 161 个 SVG 图标、19 张 PNG 与 14 张 JPG 界面素材以及 SQL 脚本、批处理启动文件和万字论文文档目录结构清晰便于按模块检索学习。目前已有 33 人学习下载。资源提供前后台完整源码、数据库脚本与详细文档源码经调试可运行适合作为课程实践与毕业设计的参考案例也可用于二次开发与远程调试学习。1. 选课系统为什么总在开放选课那五分钟崩掉每年选课季教务群里最热闹的不是“选上了吗”而是“又卡死了”。一个看起来只是“学生点一下按钮”的网上选课系统真正的技术难点从来不在页面好不好看而在开放选课那一瞬间几千人同时刷新课程列表、抢同一个教学班名额、提交选课请求数据库行锁、超卖、重复提交全挤在一起。基于 SpringBoot Vue 的学生网上选课系统本质就是拿一套前后端分离的架构去扛这种典型的“读多写少、瞬时高并发、强一致”场景。这套方案适合谁适合正在做课程设计、毕业设计或者要给学院做一套内部选课工具的开发者。它不需要多么高深的分布式中间件SpringBoot 负责后端接口、事务和权限Vue 负责把课程表、选课结果、退课操作做得顺手MySQL 兜住数据一致性。把选课这个业务吃透你顺手就掌握了 SpringBoot 项目结构、Vue 路由、前后端联调、并发控制这几块最常被问到的硬骨头。下面我按“先想清楚再动手”的顺序把整套系统从建表到防超卖讲一遍。2. 先把业务模型和数据库定死别急着写 Controller选课系统的坑八成埋在数据库设计阶段。很多同学上来就student、course、sc三张表结果做到一半发现要处理“同一个课程多个教学班”“先修课限制”“学分上限”只能回头改表前端全跟着返工。所以第一步不是搭 SpringBoot 项目而是把实体和约束想清楚。2.1 选课系统的核心实体与关系一个能落地的选课系统至少要有这几张核心表学生表student、教师表teacher、课程表course、教学班表teaching_class、选课记录表course_selection。注意course和teaching_class必须拆开课程是“数据结构”这门课教学班是“2024 秋 数据结构 周一 3-4 节 张老师”这个具体开班。学生选的是教学班不是课程。这个区分不做后面排课、容量、教师工作量全乱。选课记录表是整套系统的核心它同时承担“谁选了什么”和“防重复”两个职责。常见做法是在(student_id, teaching_class_id)上建唯一索引让数据库帮你挡住重复提交而不是只靠后端 if 判断——并发下 if 判断根本不可靠。-- 教学班表容量、已选人数、时间地点 CREATE TABLE teaching_class ( 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 60, -- 容量上限 selected_count INT NOT NULL DEFAULT 0, -- 已选人数冗余字段 class_time VARCHAR(50), UNIQUE KEY uk_course_teacher_sem (course_id, teacher_id, semester) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 选课记录表唯一索引防重复选课 CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, teaching_class_id BIGINT NOT NULL, select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 1, -- 1已选 0已退 UNIQUE KEY uk_student_class (student_id, teaching_class_id), KEY idx_class (teaching_class_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;selected_count是冗余字段用空间换查询性能课程列表要显示“已选 45/60”如果每次都COUNT(*)扫选课表几千人刷新时数据库直接跪。冗余字段的代价是必须和真实记录保持一致后面讲并发时会用事务保证。status字段而不是物理删除是为了保留退课历史方便审计和“退课后能否再选”的判断。2.2 用 SpringBoot 搭出分层结构数据库定完再搭后端。SpringBoot 项目结构别乱放按 controller / service / mapper / entity / dto 分层这是最经得起 review 的写法。用 IDEA 的 Spring Initializr 建项目依赖勾选 Spring Web、MyBatis-Plus或 MyBatis、MySQL Driver、Lombok。!-- pom.xml 关键依赖版本按你本地能拉到的稳定版填 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependencyapplication.yml里把数据源和 MyBatis-Plus 配好注意serverTimezone和useSSL这两个参数时区不对会导致选课时间记录错乱这是新手最常翻的车之一。spring: datasource: url: jdbc:mysql://localhost:3306/course_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: automap-underscore-to-camel-case打开后数据库的teaching_class_id会自动映射到 Java 的teachingClassId省掉一堆Results注解。id-type: auto对应建表时的AUTO_INCREMENT。这两项配错最常见的现象是查询返回字段全为 null别急着怀疑 SQL先看这里。3. 选课接口怎么写才不会被并发打穿后端最核心的就是选课和退课两个接口。选课接口要同时做四件事校验学生身份、校验是否重复选、校验容量、扣减名额并写记录。这四步必须在一个事务里而且扣名额那一步要用能扛住并发的写法。3.1 选课接口的事务与防超卖先看一个能用的实现。核心思路是用带条件的 UPDATE 语句扣名额靠数据库的行锁和selected_count capacity条件保证不超卖而不是先查再判断。Service public class SelectionService { Autowired private TeachingClassMapper classMapper; Autowired private CourseSelectionMapper selectionMapper; Transactional(rollbackFor Exception.class) public void selectCourse(Long studentId, Long classId) { // 1. 条件更新只有还有名额时才 1返回影响行数 int updated classMapper.increaseSelectedCount(classId); if (updated 0) { throw new BizException(该教学班名额已满); } // 2. 插入选课记录唯一索引挡住重复选课 try { CourseSelection cs new CourseSelection(); cs.setStudentId(studentId); cs.setTeachingClassId(classId); cs.setStatus(1); selectionMapper.insert(cs); } catch (DuplicateKeyException e) { // 重复选课事务回滚名额自动还回去 throw new BizException(你已经选过这门课了); } } }对应的 Mapper SQL 是关键selected_count capacity这个条件写在 UPDATE 的 WHERE 里是防超卖的灵魂update idincreaseSelectedCount UPDATE teaching_class SET selected_count selected_count 1 WHERE id #{classId} AND selected_count lt; capacity /update逻辑说明并发下多个线程同时执行这条 UPDATEInnoDB 会对这一行加排他锁串行执行。第一个线程把selected_count从 59 改成 60第二个线程再执行时selected_count capacity不成立影响行数为 0直接抛“名额已满”。这样不需要在 Java 里加 synchronized也不依赖先查后改天然避免超卖。参数说明capacity是教学班容量selected_count是已选人数两者都在teaching_class表里。Transactional的rollbackFor Exception.class必须写否则遇到受检异常事务不回滚名额扣了但记录没写数据就脏了。DuplicateKeyException是 Spring 对唯一索引冲突的封装捕获它比先SELECT COUNT再插入可靠得多。3.2 退课接口与名额归还退课不是简单删记录要把status置 0 并把名额减回去同样要在一个事务里且减名额要防止减成负数。Transactional(rollbackFor Exception.class) public void dropCourse(Long studentId, Long classId) { // 1. 把有效选课记录置为已退影响行数为 0 说明本来就没选 int updated selectionMapper.updateStatus(studentId, classId, 0); if (updated 0) { throw new BizException(你没有选这门课无法退课); } // 2. 名额 -1条件 selected_count 0 防止减成负数 classMapper.decreaseSelectedCount(classId); }update iddecreaseSelectedCount UPDATE teaching_class SET selected_count selected_count - 1 WHERE id #{classId} AND selected_count gt; 0 /update这里有个容易忽略的点退课接口的幂等性。学生手抖点两次退课第一次把 status 改成 0第二次updateStatus因为带了status 1条件影响行数为 0直接抛异常不会重复减名额。所以updateStatus的 SQL 一定要带AND status 1这是很多人写漏的地方。3.3 课程列表查询的缓存与分页选课高峰期课程列表的查询量远大于选课请求。如果每次刷新都全表查数据库压力很大。常见做法是用 MyBatis-Plus 的分页插件配合 Redis 缓存课程列表缓存时间设短一点比如 10 秒既能挡住大部分重复查询又不会让“已选人数”显示得太旧。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页插件配好后PageTeachingClassVO page new Page(pageNo, pageSize)就能直接用。注意pageSize要限制上限比如最大 50否则有人传pageSize100000直接把内存打爆。这个校验放在 Controller 层做别信前端传的值。4. Vue 前端怎么把选课体验做顺后端接口稳了前端要解决的是“学生看得清、点得准、不重复提交”。Vue 这边核心是三块路由组织、课程列表渲染、选课按钮的状态管理。4.1 Vue 路由与页面结构用 Vue CLI 或 Vite 建项目路由按角色分学生端、教师端、管理员端。学生端至少要有课程列表页、我的课表页、选课结果页。路由用vue-router的嵌套路由把公共布局抽出来。// router/index.js const routes [ { path: /student, component: () import(/layout/StudentLayout.vue), children: [ { path: course-list, component: () import(/views/CourseList.vue) }, { path: my-schedule, component: () import(/views/MySchedule.vue) }, { path: selection-result, component: () import(/views/SelectionResult.vue) } ] } ]路由懒加载用() import(...)别一次性全打包进主 chunk否则首屏加载慢选课开放时学生等页面出来就错过了。StudentLayout.vue里放侧边栏和顶部导航子路由通过router-view渲染。路由守卫里做登录校验没登录直接跳登录页别让未登录用户能调选课接口。4.2 课程列表渲染与选课按钮防抖课程列表用v-for渲染每行显示课程名、教师、时间、已选/容量、选课按钮。按钮要根据“是否已选”“是否满员”显示不同状态避免学生点了才发现选不了。template div v-forcls in classList :keycls.id classcourse-row span{{ cls.courseName }}/span span{{ cls.teacherName }}/span span{{ cls.selectedCount }}/{{ cls.capacity }}/span button :disabledcls.selected || cls.selectedCount cls.capacity || submitting clickhandleSelect(cls.id) {{ cls.selected ? 已选 : (cls.selectedCount cls.capacity ? 已满 : 选课) }} /button /div /template script export default { data() { return { classList: [], submitting: false } }, methods: { async handleSelect(classId) { if (this.submitting) return // 防重复点击 this.submitting true try { await this.$http.post(/api/selection/select, { classId }) this.$message.success(选课成功) this.loadClassList() // 重新拉列表刷新人数 } catch (e) { this.$message.error(e.response?.data?.msg || 选课失败) } finally { this.submitting false } } } } /scriptsubmitting这个标志位是防抖的关键学生网络慢时容易连点没有它就会发多个请求虽然后端唯一索引能挡住但前端体验很差还会白白增加后端压力。disabled条件里同时判断selected和容量让按钮状态和后端数据一致。选课成功后重新拉列表而不是本地改数字避免显示和后端不一致。4.3 前后端联调与跨域处理开发阶段前端跑在 5173 或 8080后端跑在 8080 或 9090必然跨域。两种做法后端加 CORS 配置或者前端配代理。推荐前端配代理生产环境用 Nginx 转发代码里不写死后端地址。// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这样前端请求/api/selection/select会被代理到后端浏览器不认为跨域。生产环境把 Vue 打包后的dist目录交给 Nginx/api反向代理到 SpringBoot 服务。注意打包时base路径和 Nginx 的root要对应否则会出现白屏或静态资源 404这是部署阶段最常见的坑。5. 上线前必须排查的五个坑系统能跑起来不代表能上线。下面这几条是我踩过或者见别人踩过的按“现象 → 原因 → 解决”列出来上线前逐条对一遍。坑一选课人数超过容量出现负数名额。现象是课程列表显示“已选 61/60”。原因是扣名额的 UPDATE 没带selected_count capacity条件或者先查后改。解决是把条件写进 UPDATE 的 WHERE靠数据库行锁保证别在 Java 里判断。坑二同一学生选同一门课出现两条记录。现象是“我的课表”里同一门课显示两次。原因是选课记录表没建唯一索引或者建了但字段顺序不对。解决是在(student_id, teaching_class_id)上建唯一索引并在代码里捕获DuplicateKeyException转成友好提示。坑三退课后名额没还回去或者还多了。现象是退课成功但课程列表人数不变或者退一次减了两次。原因是退课事务里更新记录和减名额没放在一起或者updateStatus没带status 1条件导致重复执行。解决是把两步放进同一个Transactional并给状态更新加条件。坑四选课时间记录成 UTC和实际差 8 小时。现象是数据库里select_time比北京时间早 8 小时。原因是 JDBC URL 没配serverTimezoneAsia/Shanghai或者 MySQL 服务端时区是 UTC。解决是 URL 里显式指定时区并确认 MySQL 的time_zone参数。坑五Vue 打包后刷新页面 404。现象是首页能打开点进子路由再刷新就 404。原因是 Vue 是单页应用路由由前端控制Nginx 找不到对应的物理文件。解决是在 Nginx 配置里加try_files $uri $uri/ /index.html;把所有找不到的路径回退到index.html。提示这五条里前三条是数据一致性问题必须在上线前用并发测试验证后两条是配置问题部署时对着检查一遍就能避免。6. 用压测验证防超卖别靠感觉系统写完怎么证明它真的扛得住别靠“我觉得没问题”用工具压一把。最简单的是用 JMeter 或 ApacheBench模拟 200 个并发同时选同一个教学班看最终selected_count是否等于capacity以及选课记录数是否和它一致。# 用 ab 压测选课接口200 并发共 500 次请求 ab -n 500 -c 200 -p select.json -T application/json \ http://localhost:8080/api/selection/selectselect.json里放请求体比如{classId: 1}。压完之后查数据库SELECT capacity, selected_count FROM teaching_class WHERE id 1; SELECT COUNT(*) FROM course_selection WHERE teaching_class_id 1 AND status 1;如果selected_count等于capacity且选课记录数也等于capacity说明防超卖生效。如果selected_count大于capacity回去检查 UPDATE 条件如果记录数大于selected_count检查唯一索引。这个验证做完你对自己写的并发控制才有底。再进一步可以测“同一学生并发选同一门课”看唯一索引是否挡住了重复。压测时把studentId固定classId固定发 50 个并发最终记录数应该是 1。这一步能验证DuplicateKeyException的处理是否正确事务有没有把多余的名额还回去。我自己的习惯是任何涉及“扣减”的逻辑上线前必须压一遍而且要看数据库最终状态不看接口返回的成功率。接口返回成功不代表数据对这是血泪经验。选课系统这种场景数据错了比系统崩了更麻烦崩了学生知道重试数据错了要人工核对半天。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进