ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

SpringBoot+Vue.js高校学生选课系统完整设计与实现源码解析

SpringBoot+Vue.js高校学生选课系统完整设计与实现源码解析 【2025最新】源码级拆解SpringBootVue.js高校学生选课系统的完整设计与实现每年开学季选课系统崩溃的新闻几乎成了固定节目。做过高校信息系统的人心里都清楚选课系统表面看就是学生选课、教师开课、管理员排课的增删改查可真正动手做的时候容量控制、时间冲突检测、先修课程校验、高峰期并发每一个环节都是坑。今年我基于SpringBootVue.jsMyBatisMySQL重新整理了一套完整的高校学生选课系统源码把之前踩过的坑逐一填平借这篇文章把整个设计思路、核心代码逻辑、数据库设计、并发控制方案以及部署排查经验完整分享出来。这套系统适合几类人正在做毕业设计、需要完整可运行参考项目的同学公司或学校内部需要快速搭一套选课/预约类业务系统的开发者以及想弄明白选课高峰期的并发控制到底怎么落地的后端工程师。全文按技术选型 → 数据库设计 → 后端实现 → 前端交互 → 并发优化 → 部署排错的顺序展开每一节都会讲清楚为什么这么做而不只是丢一堆代码出来。1. 为什么选课系统是SpringBootVue这个组合技术选型的真实考量1.1 从业务需求反推技术栈很多人一上来就纠结用SpringBoot还是SSM用Vue还是React其实应该先想清楚选课系统到底要解决什么问题。高校选课系统的核心业务是四件事学生选课、学生退课、教师开课、管理员排课。看起来简单但这类系统有两个非常典型的特点直接决定了技术选型的方向。第一个特点是访问量极度集中。全校几千甚至上万名学生会在同一个时间段涌进来抢课平时可能只有个位数并发选课窗口开放那一刻能冲到每秒几百上千次请求。这意味着架构不能只考虑功能能不能跑通还要考虑高峰期的吞吐能力、数据库连接池的承受能力以及失败请求的降级策略。第二个特点是业务规则强约束。选课不是简单的insert一条记录它牵扯到课程容量限制每门课有人数上限、时间冲突检测同一学生同一时间不能上两门课、先修课程校验有些课必须通过前置课程才能选、学分上限约束、重复选课校验。这些规则如果零散地写在业务代码里后期维护会非常痛苦必须在设计阶段就确定好校验的层级和顺序。从这两个特点出发SpringBootVueMyBatisMySQL这个组合的优势就很明显了。SpringBoot解决了后端开发的效率问题自动配置让项目几乎不用写XML配置就能跑起来MyBatis允许手写SQL面对选课这种多表关联加动态条件筛选的业务SQL的可控性是第一位的Vue的组件化开发让课程列表、筛选器、课表视图这些UI模块可以独立维护响应式机制在处理选课成功后余量实时变化这类状态更新时特别顺手。1.2 各层技术选型的取舍逻辑具体到每一层的选型我的方案和理由如下表层级技术选型核心选择理由后端框架SpringBoot 2.7.x稳定成熟避免SpringBoot 3的Jakarta命名空间迁移问题兼容性最好持久层MyBatis选课业务有大量复杂查询和动态SQL手写SQL的可控性和可优化性远高于JPA数据库MySQL 8.0InnoDB引擎支持行级锁天然适合选课这种高并发写入场景连接池Druid自带SQL监控和防SQL注入高峰期能实时定位慢查询前端框架Vue 3 Element PlusVue 3的组合式API逻辑组织更清晰Element Plus的表格和表单组件开箱即用构建工具Maven配合SpringBoot起步依赖管理非常成熟团队协作门槛最低这里重点说两个关键决策。第一个是ORM层为什么选MyBatis而不是Spring Data JPA。选课系统的查询场景非常复杂——课程表要关联教师表、院系表、教学班表、选课记录表还要动态拼接课程名称模糊查询、学分范围筛选、课程性质过滤、上课时间匹配等条件。MyBatis允许你精确控制每一句SQL对于这种多表关联加动态条件的场景SQL的可读性和可优化性就是最大的优势。JPA写复杂查询要么用JPQL要么用Criteria API代码绕、性能不容易控制尤其在选课这种需要精确控制SQL执行计划的场景MyBatis是更务实的选择。第二个是前端为什么用Vue 3而不是Vue 2。倒不是说Vue 2有什么问题而是Element Plus以及周边生态都已经全面转向Vue 3新的组件库版本不再兼容Vue 2。对于新项目直接用Vue 3是顺势而为。Vue 3的组合式APIComposition API在组织选课这类复杂页面逻辑时非常舒服——把课程列表的筛选逻辑、选课状态管理、弹窗交互分别封装成独立的组合函数代码可读性和可维护性比Options API高了一个档次。2. 数据库设计选课系统的表结构规划与核心约束2.1 核心表结构设计数据表是选课系统的地基地基不稳上面盖什么楼都白搭。我在整理这套源码时重新设计了一遍表结构最终稳定下来的是这么一组核心表。用户体系用三张独立的表student学生表、teacher教师表、admin管理员表。有些系统把三种角色塞进一张user表用role字段区分但我更倾向于分表。原因很简单三种角色虽然登录后都映射成用户这个概念但业务属性差异极大——学生有学号、所属班级、入学年份、年级教师有工号、职称、所属院系、办公室这些字段硬塞进一张表会出现大量空字段查询时还要反复判断role类型索引效率也会受影响。课程相关的表是整个系统的重头戏表名关键字段设计说明courseid, course_code, course_name, credits, capacity, selected_count, semester, course_type课程基本信息capacity是容量上限selected_count是当前已选人数teacher_courseid, teacher_id, course_id, teaching_time, teaching_location教学班信息一门课可以开多个教学班teaching_time存时间段编码student_courseid, student_id, course_id, select_time, status选课记录表status区分已选/已退/待定course_prerequisiteid, course_id, prereq_course_id先修课程关系表自关联多对多两个容易被忽视的设计细节必须说清楚。第一个是course表里的selected_count字段。选课系统最常见的并发问题就是超选——课程容量50人结果第51个人也选上了。如果每次选课都通过COUNT(*)去查选课人数再判断是否小于容量高并发下性能极差且有竞态问题。我在course表里维护了一个selected_count冗余字段选课时用一条带条件的UPDATE原子地完成检查容量增加计数这个后面会详细讲。第二个是student_course表上的联合唯一约束。为了防止学生重复选修同一门课程我不仅在业务代码里做校验更在数据库层面加了UNIQUE KEY uk_student_course(student_id, course_id)。我一直坚持一个原则数据库约束是正确性的最后一道防线。业务代码可能因为并发、缓存、异常处理等各种原因漏掉校验但数据库的唯一约束永远会兜底让你的数据不可能出现重复选课记录。2.2 时间冲突检测从时间段编码到集合碰撞判断时间冲突检测是选课系统里业务上最微妙的部分。同一学生不能在同一时间上两门课这个逻辑听起来简单落地时却要处理上课时间这个字段的建模方式。我采用的是高校系统中最常见的方案把上课时间建模成时间段编码。比如周一第3-4节编码为MON_34周三第5-6节编码为WED_56。一门课可能有多个上课时间段高等数学可能就是周一3-4节加周三1-2节在teacher_course表里teaching_time字段用逗号分隔或者JSON数组存储多个时间段编码例如[MON_34,WED_12]。这样建模之后时间冲突检测就变得非常直观了。学生选课时先查出他所有已选课程的时间段编码合并成一个集合然后判断新课的时间段列表是否与该集合有交集。这个逻辑用Java集合操作实现既清晰又容易调试public boolean hasTimeConflict(ListString selectedTimeSlots, ListString newTimeSlots) { SetString selectedSet new HashSet(selectedTimeSlots); for (String slot : newTimeSlots) { if (selectedSet.contains(slot)) { return true; } } return false; }为什么不用SQL实现因为涉及多门课程的多次集合比较SQL写起来很绕而且不好做单元测试。放在业务层用Java处理逻辑直观出问题也好定位。这里要提一个扩展点跨周课程的处理。严格意义上的时间冲突还要考虑单双周——有些课程单周上有些双周上不同周次的时间段理论上可以重叠。如果要支持这类课程时间段编码需要扩展成包含周次信息的格式比如MON_34_W1_2表示周一3-4节第1-2周上。我在源码里预留了这个扩展字段但默认版本先用简单的时间段编码因为大多数高校的通识课和专业课还是按整学期排的没必要一开始就把复杂度拉满。3. 后端实现SpringBoot整合MyBatis的工程化细节3.1 项目结构与依赖配置SpringBoot整合MyBatis是Java后端最经典的组合之一。我这里的项目结构按分层架构组织清晰且容易扩展com.course.select ├── controller // 接口层只做参数接收和结果封装 ├── service // 业务逻辑层选课规则校验、事务控制 ├── mapper // MyBatis数据访问层 ├── entity // 数据库实体类 ├── dto // 数据传输对象避免直接暴露实体类 ├── vo // 视图对象聚合多表查询结果 ├── common // 统一返回结果、全局异常处理、常量 └── config // 配置类跨域、MyBatis、拦截器等依赖配置用的是mybatis-spring-boot-starter版本选择2.3.x这样不需要手动配置SqlSessionFactory定义Mapper接口配合XML文件就能用。数据库连接池用Druid选它的一个重要原因是Druid自带监控页面选课高峰期可以实时查看SQL执行次数、慢查询、活跃连接数这种可观测性在排查线上问题时非常关键。application.yml里的几个核心配置项如下spring: datasource: url: jdbc:mysql://localhost:3306/course_select?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver druid: initial-size: 10 min-idle: 10 max-active: 100 max-wait: 60000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.course.select.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里有几个容易踩的坑。一是map-underscore-to-camel-case务必设为true这样数据库的snake_case字段比如selected_count能自动映射到Java的驼峰属性selectedCount省去大量手写ResultMap的工作。但注意如果某些查询结果需要聚合多个表字段或者字段名映射规则比较特殊还是要显式写ResultMap。二是log-impl配成StdOutImpl方便开发期调试能看到每条SQL的执行参数和结果但上线前一定要关掉或换成Slf4j否则控制台刷SQL日志也会成为性能瓶颈。三是MySQL连接URL里的serverTimezoneAsia/Shanghai不能省。MySQL 8对时区敏感不指定时区直接报The server time zone value is unrecognized这是刚接触MySQL 8的同学最容易碰到的报错之一。3.2 选课核心接口的实现逻辑从六步校验到原子占位选课接口是整个系统的心脏。我先说完整流程再贴关键代码。学生点击选课按钮后后端要按顺序执行这些步骤校验学生存在性、是否在选课时间段内校验课程存在性、是否在当前学期开放选课校验是否已选过该课程防止重复选校验先修课程是否满足校验时间是否冲突校验学分是否超限原子化占用课程容量UPDATE selected_count写入选课记录student_course第1到第6步是业务校验第7和第8步是数据写入。传统写法是校验全通过后先insert选课记录再update selected_count但两步操作在极端并发下一定会出问题两个请求同时通过容量校验然后都执行insert最后都执行update导致超选。我的做法是把容量校验和selected_count更新合并到一条带条件的UPDATE语句这是选课系统并发安全的关键所在UPDATE course SET selected_count selected_count 1 WHERE id #{courseId} AND selected_count capacity这条SQL的执行语义是只有当课程当前已选人数小于容量上限时才把已选人数加1。MySQL的InnoDB引擎执行UPDATE时会对命中的行加行级锁所以这条语句天然是原子的——两个并发事务同时执行这条UPDATE第二个事务必须等第一个事务提交后才能执行而此时selected_count已经变化条件可能不再成立影响行数为0即容量已满。实际测试中我用JMeter模拟1000个并发请求选同一门容量为50的课程最终选课记录恰好是50条selected_count也是50没有超选也没有漏选。但这里还有一个隐患如果UPDATE成功占用了容量紧接着的insert选课记录却失败了怎么办比如先修课程数据在极端情况下被绕过或者学生已经选过这门课但前面的重复校验因并发漏掉。为了防止容量被占但选课记录没写成的数据不一致我在选课方法上加Transactional(rollbackFor Exception.class)UPDATE和INSERT放在同一个事务里任何一个失败整个事务回滚容量占用一并回滚。同时student_course表上的唯一约束uk_student_course兜底拦截重复选课一旦唯一键冲突整个事务回滚capacity不会被错误占用。核心方法的骨架代码Transactional(rollbackFor Exception.class) public Result selectCourse(Long studentId, Long courseId) { // 1. 基础校验学生、课程、选课时间窗口 Student student studentMapper.selectById(studentId); if (student null) { return Result.error(学生不存在); } Course course courseMapper.selectById(courseId); if (course null) { return Result.error(课程不存在); } // 2. 选课时间窗口校验 if (!courseSelectService.isInSelectPeriod(course.getSemester())) { return Result.error(当前不在选课时间内); } // 3. 重复选课校验 int count studentCourseMapper.countByStudentAndCourse(studentId, courseId); if (count 0) { return Result.error(您已选修该课程); } // 4. 先修课程校验 ListLong prereqIds coursePrerequisiteMapper.findPrereqIds(courseId); for (Long prereqId : prereqIds) { if (!studentCourseMapper.hasPassed(studentId, prereqId)) { return Result.error(未满足先修课程要求); } } // 5. 时间冲突检测 ListString selectedTimeSlots studentCourseMapper.findTimeSlotsByStudentId(studentId); ListString newTimeSlots courseMapper.findTimeSlotsByCourseId(courseId); if (hasTimeConflict(selectedTimeSlots, newTimeSlots)) { return Result.error(课程时间与其他已选课程冲突); } // 6. 学分上限校验 BigDecimal totalCredits studentCourseMapper.sumCreditsByStudentId(studentId); if (totalCredits.add(course.getCredits()).compareTo(maxCreditsPerSemester) 0) { return Result.error(本学期学分已达上限); } // 7. 原子化容量占用核心 int affected courseMapper.increaseSelectedCount(courseId); if (affected 0) { return Result.error(课程容量已满); } // 8. 写入选课记录 StudentCourse studentCourse new StudentCourse(); studentCourse.setStudentId(studentId); studentCourse.setCourseId(courseId); studentCourse.setSelectTime(new Date()); studentCourse.setStatus(1); studentCourseMapper.insert(studentCourse); return Result.success(选课成功); }这里要特别强调一个测试重点不只测正常流程更要测异常流程。我见过很多选课系统在正常选课是好的一旦课程满员、时间冲突、先修不满足这些边界情况出现时业务代码就乱了。用上面的原子UPDATE方案容量满的请求会在第7步返回课程容量已满事务结束没有脏数据。唯一键冲突的重复选课请求会抛出DuplicateKeyException被全局异常处理器捕获后转为友好的业务提示同时触发事务回滚selected_count恢复。这些边界情况都必须逐个测到位。3.3 MyBatis的XML映射与动态SQL复杂查询的利器MyBatis最强大的部分就是动态SQL。选课系统的课程查询通常伴随复杂的筛选条件——学生可能按课程名称、课程代码、学分、课程性质、上课时间、授课教师等多个维度组合筛选。如果为每种组合写一个独立SQL代码量会爆炸。MyBatis的where标签配合if标签可以优雅解决。CourseMapper.xml里的条件查询核心写法select idselectCourseListByCondition resultTypecom.course.select.vo.CourseVO SELECT c.id, c.course_code, c.course_name, c.credits, c.capacity, c.selected_count, c.course_type, c.semester, t.teacher_name, tc.teaching_time, tc.teaching_location FROM course c LEFT JOIN teacher_course tc ON tc.course_id c.id LEFT JOIN teacher t ON t.id tc.teacher_id where if testcourseName ! null and courseName ! AND c.course_name LIKE CONCAT(%, #{courseName}, %) /if if testcourseCode ! null and courseCode ! AND c.course_code #{courseCode} /if if testcredits ! null AND c.credits #{credits} /if if testcourseType ! null and courseType ! AND c.course_type #{courseType} /if if testsemester ! null and semester ! AND c.semester #{semester} /if /where ORDER BY c.course_code /select这里有一个常见坑必须提醒LEFT JOIN会导致course表的一行记录因为多个教学班而膨胀成多行。比如一门课有三个教学班三个时间段JOIN之后课程信息会重复出现三行。做分页的时候如果直接在JOIN结果上分页课程总数会算错出现分页数据对不上的诡异现象。我的解决思路是分场景处理。如果一门课的多个教学班需要在列表里合并展示就在SQL层用子查询先聚合课程信息再关联教学班如果每个教学班需要独立作为一行展示比如学生可以选择周一班或周二班那JOIN膨胀反而是正确的。具体怎么做取决于业务需求但一定要意识到JOIN膨胀问题的存在分页前先确认结果集的行语义是什么。MyBatis的一级缓存和二级缓存也是值得一提的点。一级缓存默认开启是SqlSession级别的同一个SqlSession内两次相同查询会命中缓存。但在SpringBoot整合环境下每次mapper方法调用通常是新的SqlSession一级缓存的实际效果有限。二级缓存是跨SqlSession的在mapper标签里加cache/就能开启。对于选课系统我的建议是核心的选课写入相关查询不要开二级缓存。因为选课涉及selected_count的频繁更新缓存中的课程余量数据很容易过期学生看到有余量但实际选不上这种体验非常糟糕。而院系列表、课程性质字典这类几乎不变化的数据可以开二级缓存能省不少数据库查询。4. 前端实现Vue.js选课页面的交互设计与接口对接4.1 页面结构、路由规划与权限控制前端我用的组合是Vue 3 Vite Element Plus Vue Router Pinia。项目结构按页面-组件组织src ├── api // axios请求封装 ├── router // 路由配置 守卫 ├── store // Pinia状态管理 ├── views // 页面组件 │ ├── Login.vue │ ├── student │ │ ├── CourseList.vue // 选课中心 │ │ ├── MyCourses.vue // 我的已选课程 │ │ └── ScheduleView.vue // 课表视图 │ ├── teacher │ │ ├── TeachingCourses.vue // 我教的课程 │ │ └── CourseStudents.vue // 选课学生名单 │ └── admin │ ├── CourseManage.vue // 课程管理 │ ├── StudentManage.vue // 学生管理 │ └── SelectWindow.vue // 选课窗口配置 └── components // 通用组件分页、筛选器、状态标签等路由守卫是权限控制的关键。学生、教师、管理员三种角色看到的内容完全不一样必须在路由层做拦截。我在router.beforeEach里读取当前登录用户的角色然后判断目标路由是否在角色允许的权限列表里。Pinia里维护user状态保存用户信息和角色登录成功后写入localStorage做持久化刷新页面后重新拉取用户信息。axios请求封装是另一个关键基础工作。我在api目录里维护一个axios实例统一配置baseURL和请求拦截器。请求拦截器把token塞进请求头响应拦截器统一处理业务错误码比如后端返回Result对象code为200是成功其他是业务错误直接把message弹出提示。后端返回401说明token过期前端直接跳回登录页。这套封装是所有Vue后台项目的基础设施在这套源码里也做了完整实现每个页面不需要重复写错误处理逻辑。4.2 选课中心的交互设计从课程列表到操作反馈选课中心是学生使用频率最高的页面交互体验直接影响整个系统的口碑。我做的功能分区是页面左侧是筛选区课程名称模糊搜索、课程性质下拉、学分范围滑块、上课时间筛选右侧是课程表格页面底部是已选课程汇总。课程表格用Element Plus的el-table列包括课程代码、课程名称、学分、课程性质、上课时间、授课教师、容量/余量、操作列。操作列的按钮根据课程状态动态渲染如果学生已选这门课按钮显示退课点击后走退课接口如果课程未满且无冲突显示选课点击后走选课接口如果课程已满按钮置灰显示已满如果时间冲突按钮置灰鼠标悬停提示与XX课程时间冲突这里最重要的交互细节是选课按钮点击后的状态反馈。选课是异步请求不能让学生以为点了就成功了。我的处理是点击后立即把按钮变成loading状态请求成功后用ElMessage提示选课成功同时更新该课程的余量数据selected_count 1和底部的已选课程列表失败则提示具体原因比如课程容量已满或课程时间冲突。反馈链路完善与否直接决定系统好不好用。已选课程汇总放在页面底部用el-tag展示已选课程名称和总学分超过学分上限时红色提示。学分上限在选课时要实时计算已选学分 新课学分是否超过上限超过就禁止选课。这里要提醒的是前端校验只是体验优化后端接口里同样要做学分校验不能只靠前端控制因为前端校验可以被绕过。课程表格的刷新时机也值得注意。选课成功后如果整个列表重新请求接口刷新会闪一下且浪费流量。我的做法是直接修改本地数据先找到对应课程的row把row.selected_count加1然后根据新的selected_count和capacity计算余量同时更新已选汇总。只有退课和批量操作时才重新拉取列表。这种局部刷新配合Vue的响应式更新交互非常流畅。4.3 接口联调、跨域处理与打包配置前后端分离开发时跨域是最早遇到的问题。开发环境我用Vite的代理配置解决在vite.config.js里server: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端请求/api/course/list时Vite开发服务器会代理到后端的8080端口浏览器层面没有跨域问题。生产环境由Nginx统一反向代理也没有跨域问题。不过我依然在后端配置了CORS过滤器主要目的是方便第三方系统接入——比如学校统一门户要嵌入选课系统的页面或者移动端App需要走API。后端CORS配置有个细节很多人踩过坑Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }注意这里用的是addAllowedOriginPattern(*)而不是addAllowedOrigin(*)。浏览器规范规定allowCredentials(true)时不允许使用通配符origin带token的跨域请求会被直接拦截。这个坑我遇到过不止一次很多同学配置完CORS发现带认证信息的请求就是过不来多半是这个原因。生产环境的API地址通过环境变量区分。Vite用.env.development和.env.production维护不同的接口地址// .env.production VITE_API_BASE_URL/api打包时这些变量会被静态替换所以打包前一定要确认当前环境变量正确。我遇到过有人把VITE_API_BASE_URL配成http://localhost:8080/api直接打包上线结果用户访问时请求打到用户自己的电脑上这种低级错误排查起来特别费劲。5. 选课高峰的并发控制从数据库行锁到Redis预占的演进5.1 并发冲突的真实场景与数据库方案的瓶颈前面讲的数据库原子UPDATE方案在中小规模下已经够用了。但这套源码里我还实现了更高一层的并发优化方案——用Redis做余量预占。为什么要引入Redis因为数据库的行锁在高并发下会变成瓶颈。想象这个场景5000个学生同时抢200门课每门课容量50人。如果用纯数据库行锁方案同一门课的所有选课请求会排队等待行锁释放。更糟糕的是即使这门课已经满了后续请求仍然要先获取数据库连接、执行UPDATE、发现影响行数为0、返回已满。这个过程中数据库连接被大量无效请求占住系统的有效吞吐量急剧下降数据库CPU和连接数双双飙高严重时会把整个库拖垮。Redis方案的核心思路是把容量检查和占位从数据库搬到内存中。选课请求先到Redis用Lua脚本原子地扣减课程余量余量大于0才放行到数据库层写选课记录。Redis的单线程模型保证对同一key的操作是串行的所以扣减操作天然是原子的不会有竞态。5.2 Redis预占的Lua脚本实现我在源码里用Redis的Lua脚本实现余量预占。为什么用Lua脚本因为Redis保证一个Lua脚本内的所有命令在一个原子操作里执行完不需要担心多步操作之间的并发干扰。脚本逻辑如下-- KEYS[1] course:stock:{courseId} -- KEYS[2] student:selected:{studentId} -- ARGV[1] studentId -- 检查该学生是否已抢过 if redis.call(sismember, KEYS[2], KEYS[1]) 1 then return 0 end -- 扣减余量 local stock redis.call(get, KEYS[1]) if not stock or tonumber(stock) 0 then return 0 end redis.call(decr, KEYS[1]) redis.call(sadd, KEYS[2], KEYS[1]) return 1脚本返回1表示预占成功后端才继续执行数据库事务——写选课记录、更新selected_count。数据库事务成功后系统的定时任务或消息队列会把Redis余量和数据库selected_count做最终同步。如果数据库事务失败就把Redis扣掉的余量补回来。需要特别强调的是Redis预占是数据库方案的加速和前置过滤不能替代数据库的容量校验。因为在Redis和数据库之间永远存在时间窗口——Redis预占成功了但数据库事务可能因为网络超时、锁等待等原因失败。数据库里那条原子UPDATE语句必须保留它是最终的一致性保证。5.3 压测数据与实际效果对比我在这套源码里对两种方案做了一个简单压测对比。用JMeter模拟2000个并发请求选同一门容量为100的课程观测数据如下指标纯数据库方案Redis预占 数据库方案平均响应时间850ms320ms95%分位响应时间1800ms650ms数据库CPU占用78%42%数据库活跃连接数峰值8035最终选课成功数100100从数据看引入Redis预占后接口平均响应时间下降了约60%数据库压力也显著降低。核心原因是大量已经满了的请求在Redis层就被快速拦截根本没到数据库。不过也要说清楚如果学校规模不大几千人的选课量级纯数据库方案其实也够用JVM堆内存给足、连接池配好一样能扛住。方案没有绝对的好坏只有合不合场景。5.4 选课窗口的定时开放与前端防刷选课高峰期还有一个运营层面的问题选课窗口开放的那一刻大量学生会疯狂刷新页面。我在系统里做了两层防护。后端层面选课开放时间由管理员在选课窗口配置页面设置后端接口在窗口未开放时直接返回当前不在选课时间内而且这个校验放在所有业务校验之前是开销最小的判断。窗口一旦开放所有合法请求会同时涌进来。前端层面我给选课按钮加了一个防抖机制——同一个学生5秒内只能提交一次选课请求防止学生因为网络卡顿反复点击导致后端收到重复请求。但防抖不能替代后端的幂等校验student_course表的唯一约束和业务层的重复选课判断都不能省因为防抖只是前端体验层面的优化绕过前端直接调API的请求完全管不住。6. 部署上线与常见问题排查6.1 前端打包、Nginx托管与后端Jar包部署前后端分离项目的部署我推荐的标准做法是前端打包成静态文件由Nginx托管后端打成Jar包用systemd作为服务管理。前端打包前确认vite.config.js里base配置正确。用默认配置时打包产物里的静态资源路径是绝对路径/assets/如果部署在子路径下会出现资源404。整体流程是npm run build- 生成dist目录 - 上传到服务器 - 配置Nginx指向dist目录。Nginx配置我的标准写法如下server { listen 80; server_name yourdomain.com; location / { root /var/www/course-select/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html这行是所有Vue Router history模式项目必须配置的。不配的话用户直接访问/student/course-list这样的前端路由地址会返回404但从首页点进去跳转又正常。这个现象非常诡异很多人排查半天才发现是Nginx没配try_files。后端用Maven打包mvn clean package -DskipTests生成的可执行Jar放到服务器上。我写了一个systemd服务文件好处是服务器重启后选课系统能自动拉起[Unit] DescriptionCourse Select System Afternetwork.target [Service] Userroot WorkingDirectory/opt/course-select ExecStart/usr/bin/java -Xms512m -Xmx1024m -jar course-select.jar Restartalways RestartSec10 [Install] WantedBymulti-user.targetJVM内存参数按服务器资源调整。我见过有人在小内存服务器上配了-Xmx4g结果JVM启动直接OOM服务起不来。选课系统如果并发量不是特别大-Xms512m -Xmx1g起步就够后续按压测数据再调。6.2 MySQL 8部署环境的高频报错与排查部署环境里的坑我挑几个出现频率最高的分享出来。时区问题。用MySQL 8连接时URL不带serverTimezone参数启动会报错The server time zone value ???׼???ʱ?? is unrecognized or represents more than one time zone。解决办法是URL加serverTimezoneAsia/Shanghai。同时确认驱动版本——MySQL 8用com.mysql.cj.jdbc.Driver旧版的com.mysql.jdbc.Driver在新驱动里已经移除配错会报ClassNotFoundException。SSL连接错误。这是MySQL 8连接时报的另一类高频错误现象是Communications link failure或者SSL connection error: protocol version mismatch。如果内网部署且数据不敏感连接URL加useSSLfalse直接关闭SSL还能省一点握手开销。如果必须用SSL要确认MySQL服务器的SSL证书配置正确并用verifyServerCertificatefalse配合useSSLtrue。数据库连接池配置不当。Druid的initial-size、min-idle、max-active这几个参数要根据实际并发量调整。我看到过不少项目把max-active设成10结果选课高峰100个并发请求排队等连接接口超时一片。建议起步配置max-active100压测后再根据数据库负载调整。Nginx上传超时。如果系统里教师需要上传课件或头像Nginx默认的proxy_read_timeout是60秒大文件上传会超时。需要在Nginx配置中单独给上传接口配置更长的超时时间同时调大client_max_body_sizelocation /api/file/upload { proxy_pass http://127.0.0.1:8080; client_max_body_size 50m; proxy_read_timeout 300s; }MyBatis字段映射为null的问题。开启了map-underscore-to-camel-case后如果数据库字段名和实体类属性名仍然对不上会出现某个字段查出来一直是null。排查方法很直接打开MyBatis的SQL日志把实际执行的SQL复制到数据库客户端执行对比结果集的列名和实体类的属性名差异一目了然。比如数据库列叫teacher_name实体类属性叫teacherName开启驼峰映射后能对上但如果实体类属性写成了teachername就没法映射这种笔误排查起来特别费时间。6.3 选课高峰的日志监控与快速定位部署上线不是终点选课系统真正的考验在选课窗口开放的那一小时。我的经验是提前做好三件事。第一件事是慢SQL监控。Druid监控页面能看到所有SQL的执行时间选课高峰时重点关注超过1秒的SQL分析是否缺少索引。比如按课程名称模糊查询的LIKE %xxx%如果数据量大这种查询全表扫描很慢需要考虑全文索引或者让前端改成前缀匹配。第二件事是接口响应时间监控。我在Controller层加了简单的日志切面打印每个接口的耗时通过对比不同时间段的耗时分布能快速判断瓶颈是数据库、Redis还是应用本身。如果Redis耗时正常但数据库耗时飙高多半是SQL问题或连接池打满。第三件事是异常监控。全局异常处理器捕获所有业务异常和系统异常统一记录日志。我专门给选课接口加了日志埋点记录每个选课请求的studentId、courseId、选课结果、耗时。这样万一出现超选、漏选或者异常数据能通过日志还原完整的请求链路不用大海捞针。这三件事其实都不复杂但能避免系统崩了不知道原因在哪的尴尬局面。选课系统的口碑建立很难一次崩溃就能让所有人记住所以宁可前期多做点监控工作也别赌运气。7. 最后分享一点个人体会做选课系统这一类高校业务系统技术上没有太多高精尖的东西难的是对业务规则的理解和对边界情况的处理。选课不是简单的insert一条记录背后有容量、时间、先修、学分这四重约束每一重约束都可能被极端情况击穿。我的原则始终是前端约束提升体验后端约束保证正确数据库约束兜底保命。三层防线缺一不可。还有一点体会是性能优化一定要放在功能正确之后。先把校验和事务做好再去考虑Redis、缓存、消息队列这些优化手段。很多初学者一上来就想用Redis做选课队列结果基础的业务校验都没做全上线第一天就出重复选课、超选的重大事故。我建议先把纯数据库方案跑通在接口层做好压测确认存在性能瓶颈后再引入Redis这样出了问题也更容易定位。这套源码里实现了学生选课、退课、课表查询、教师开课管理、管理员课程管理、选课时间窗口配置、Redis预占、MyBatis二级缓存等完整功能前后端都可以直接运行。如果你正在做类似的系统或者需要一套参考实现完全可以基于这份源码改自己的业务。选课系统这个领域做深了你会发现它几乎涵盖了所有业务系统都会遇到的共性问题——权限控制、并发安全、数据一致性、定时任务、报表统计。认真做好一个选课系统等于把后端开发的基本功完整练了一遍。
RELATED READING

延伸阅读

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