☰
SpringBoot+Vue.js高校学生选课系统完整设计与实现源码解析
2026/10/10 9:08:51 网站建设 项目流程

【2025最新】源码级拆解:SpringBoot+Vue.js高校学生选课系统的完整设计与实现

每年开学季选课系统崩溃的新闻几乎成了固定节目。做过高校信息系统的人心里都清楚,选课系统表面看就是"学生选课、教师开课、管理员排课"的增删改查,可真正动手做的时候,容量控制、时间冲突检测、先修课程校验、高峰期并发,每一个环节都是坑。今年我基于SpringBoot+Vue.js+MyBatis+MySQL重新整理了一套完整的高校学生选课系统源码,把之前踩过的坑逐一填平,借这篇文章把整个设计思路、核心代码逻辑、数据库设计、并发控制方案以及部署排查经验完整分享出来。

这套系统适合几类人:正在做毕业设计、需要完整可运行参考项目的同学;公司或学校内部需要快速搭一套选课/预约类业务系统的开发者;以及想弄明白"选课高峰期的并发控制到底怎么落地"的后端工程师。全文按"技术选型 → 数据库设计 → 后端实现 → 前端交互 → 并发优化 → 部署排错"的顺序展开,每一节都会讲清楚"为什么这么做",而不只是丢一堆代码出来。

1. 为什么选课系统是SpringBoot+Vue这个组合:技术选型的真实考量

1.1 从业务需求反推技术栈

很多人一上来就纠结"用SpringBoot还是SSM""用Vue还是React",其实应该先想清楚选课系统到底要解决什么问题。高校选课系统的核心业务是四件事:学生选课、学生退课、教师开课、管理员排课。看起来简单,但这类系统有两个非常典型的特点,直接决定了技术选型的方向。

第一个特点是访问量极度集中。全校几千甚至上万名学生会在同一个时间段涌进来抢课,平时可能只有个位数并发,选课窗口开放那一刻能冲到每秒几百上千次请求。这意味着架构不能只考虑"功能能不能跑通",还要考虑高峰期的吞吐能力、数据库连接池的承受能力,以及失败请求的降级策略。

第二个特点是业务规则强约束。选课不是简单的insert一条记录,它牵扯到课程容量限制(每门课有人数上限)、时间冲突检测(同一学生同一时间不能上两门课)、先修课程校验(有些课必须通过前置课程才能选)、学分上限约束、重复选课校验。这些规则如果零散地写在业务代码里,后期维护会非常痛苦,必须在设计阶段就确定好校验的层级和顺序。

从这两个特点出发,SpringBoot+Vue+MyBatis+MySQL这个组合的优势就很明显了。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的组合式API(Composition 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(List<String> selectedTimeSlots, List<String> newTimeSlots) { Set<String> 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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里的serverTimezone=Asia/Shanghai不能省。MySQL 8对时区敏感,不指定时区直接报The server time zone value is unrecognized,这是刚接触MySQL 8的同学最容易碰到的报错之一。

3.2 选课核心接口的实现逻辑:从六步校验到原子占位

选课接口是整个系统的心脏。我先说完整流程,再贴关键代码。

学生点击"选课"按钮后,后端要按顺序执行这些步骤:

  1. 校验学生存在性、是否在选课时间段内
  2. 校验课程存在性、是否在当前学期开放选课
  3. 校验是否已选过该课程(防止重复选)
  4. 校验先修课程是否满足
  5. 校验时间是否冲突
  6. 校验学分是否超限
  7. 原子化占用课程容量(UPDATE selected_count)
  8. 写入选课记录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. 先修课程校验 List<Long> prereqIds = coursePrerequisiteMapper.findPrereqIds(courseId); for (Long prereqId : prereqIds) { if (!studentCourseMapper.hasPassed(studentId, prereqId)) { return Result.error("未满足先修课程要求"); } } // 5. 时间冲突检测 List<String> selectedTimeSlots = studentCourseMapper.findTimeSlotsByStudentId(studentId); List<String> 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 id="selectCourseListByCondition" resultType="com.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 test="courseName != null and courseName != ''"> AND c.course_name LIKE CONCAT('%', #{courseName}, '%') </if> <if test="courseCode != null and courseCode != ''"> AND c.course_code = #{courseCode} </if> <if test="credits != null"> AND c.credits = #{credits} </if> <if test="courseType != null and courseType != ''"> AND c.course_type = #{courseType} </if> <if test="semester != 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预占 + 数据库方案
平均响应时间850ms320ms
95%分位响应时间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] Description=Course Select System After=network.target [Service] User=root WorkingDirectory=/opt/course-select ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar course-select.jar Restart=always RestartSec=10 [Install] WantedBy=multi-user.target

JVM内存参数按服务器资源调整。我见过有人在小内存服务器上配了-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加serverTimezone=Asia/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加useSSL=false直接关闭SSL,还能省一点握手开销。如果必须用SSL,要确认MySQL服务器的SSL证书配置正确,并用verifyServerCertificate=false配合useSSL=true。

数据库连接池配置不当。Druid的initial-size、min-idle、max-active这几个参数要根据实际并发量调整。我看到过不少项目把max-active设成10,结果选课高峰100个并发请求排队等连接,接口超时一片。建议起步配置max-active=100,压测后再根据数据库负载调整。

Nginx上传超时。如果系统里教师需要上传课件或头像,Nginx默认的proxy_read_timeout是60秒,大文件上传会超时。需要在Nginx配置中单独给上传接口配置更长的超时时间,同时调大client_max_body_size:

location /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二级缓存等完整功能,前后端都可以直接运行。如果你正在做类似的系统,或者需要一套参考实现,完全可以基于这份源码改自己的业务。选课系统这个领域做深了你会发现,它几乎涵盖了所有业务系统都会遇到的共性问题——权限控制、并发安全、数据一致性、定时任务、报表统计。认真做好一个选课系统,等于把后端开发的基本功完整练了一遍。

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

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

立即咨询