☰
Java+Vue高校选课系统实战:并发锁、数据库设计与前后端部署全解析
2026/10/20 7:23:06 网站建设 项目流程

写在前面的背景:临近选课季,团队接了一套高校学生选课系统的开发任务,技术栈就是标题里写的 Java + Vue 前后端分离方案。所谓选课系统,拆开来看其实是在解决一个非常现实的问题——同一时间大量学生同时操作,系统必须保证每个人选到的课程彼此不冲突、每门课不会超员,还要让教务员能灵活维护课程数据。整套系统从需求梳理、数据库建模,到前后端联调、部署上线,过程中踩了不少坑,尤其是并发选课那部分,深挖下去很有代表性。

这篇就围绕这套系统的完整落地过程展开,不堆理论,只讲真实建模和代码实现。适合正在做毕设、准备校招项目,或者想了解高校业务系统怎么从零搭建的开发者参考。

1. 从需求到模块:三种角色的权限边界与选课业务闭环

1.1 角色权限:谁在什么时间能做什么

选课系统不像普通的 CRUD 练习,它的核心难点之一在于权限不是简单的“管理员能进后台,学生能进前台”,而是和业务流程强耦合。

我最终把系统里的人员分成三类:管理员、教师、学生,各自有一套独立的操作边界。

角色核心操作权限边界
管理员维护学期、课程库、开课计划,设置选课轮次不参与具体选课,也不录入成绩
教师查看自己名下的开课记录、导出学生名单、录入成绩看不到其他教师的课程数据,不能修改选课规则
学生浏览课程、选课、退课、查看个人课表与成绩只能操作自己的数据,不能访问后台管理接口

这里有个容易被忽略的细节:教师端和管理员端在 UI 上可以做得完全不一样,但权限校验一定要在后端统一做。前端路由拦截只是用户体验层面的控制,真正保证安全的还是后端每个接口上的角色判断。我在项目里用的是基于 JWT 的 Token 认证,用户登录后生成 Token,前端每次请求都带上,后端从 Token 里解析出角色,再决定接口是否放行。

1.2 选课业务闭环的四个阶段

整套系统的业务流可以划分为四个阶段,理解了这四个阶段,后面的数据库设计和接口设计就都有据可依:

  • 前置阶段:管理员创建学期、录入课程库、安排开课计划,设置每门课的容量、上课时间、任课教师。
  • 选课阶段:学生在规定时间段内进入系统选课,系统实时校验课程容量和个人课表冲突。
  • 退课与调整阶段:学生在选课窗口期内可以退课,退课后容量立刻释放,供其他人选。
  • 成绩阶段:选课期结束后教师录入成绩,学生查看成绩。

模块划分上我是严格按照“角色 + 阶段”来组织的:管理员端存在学期管理、课程管理、开课管理、选课轮次管理,学生端存在选课大厅、我的课表、我的成绩,教师端存在我的课程、名单导出、成绩录入。

1.3 为什么选 Java + Vue,而不是单页 JSP 或纯前端

这个问题面试时常被问,我在这套系统里也有非常实际的体会。高校选课系统对稳定性的要求远高于对花哨交互的要求,后端选 Java 生态是因为 Spring Boot 在事务管理、权限框架、成熟度方面确实可靠,尤其是选课这种涉及强事务和并发控制的场景,Java 的 Spring 事务和锁机制用起来就是顺手。前端选 Vue 则看中它的组件化开发效率,选课大厅、课表视图这种复杂交互页面,用 Vue 的响应式状态管理比传统 JSP 拼接方便太多,而且前后端分离之后,接口写好了两边可以并行开发,整体节奏快很多。

2. 建表阶段的取舍:学生、课程、选课记录怎么建模才不乱

2.1 五张核心表的结构与关系

数据库设计是整个系统最关键也最容易返工的部分。我最后落地的核心表有五张:用户表、课程表、开课计划表、选课记录表、成绩表。

CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, account VARCHAR(50) NOT NULL UNIQUE COMMENT '学号/工号', password VARCHAR(100) NOT NULL COMMENT '加密后的密码', real_name VARCHAR(50) NOT NULL, role TINYINT NOT NULL COMMENT '1管理员 2教师 3学生', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE course ( id BIGINT AUTO_INCREMENT PRIMARY KEY, course_name VARCHAR(100) NOT NULL COMMENT '课程名称', course_code VARCHAR(30) NOT NULL UNIQUE COMMENT '课程编号', credit DECIMAL(3,1) NOT NULL COMMENT '学分', description TEXT ); CREATE TABLE course_plan ( id BIGINT AUTO_INCREMENT PRIMARY KEY, course_id BIGINT NOT NULL COMMENT '关联课程表', teacher_id BIGINT NOT NULL COMMENT '任课教师ID', semester VARCHAR(20) NOT NULL COMMENT '学期,如2025-2026-1', capacity INT NOT NULL COMMENT '课程容量', selected_count INT DEFAULT 0 COMMENT '已选人数', class_week VARCHAR(20) NOT NULL COMMENT '上课周次,如1-16周', class_day TINYINT NOT NULL COMMENT '星期几,1-7', class_begin INT NOT NULL COMMENT '开始节次', class_end INT NOT NULL COMMENT '结束节次', status TINYINT DEFAULT 1 COMMENT '1可选 0停开' ); CREATE TABLE course_selection ( id BIGINT AUTO_INCREMENT PRIMARY KEY, plan_id BIGINT NOT NULL COMMENT '开课计划ID', student_id BIGINT NOT NULL COMMENT '学生ID', select_time DATETIME NOT NULL, UNIQUE KEY uk_student_plan (student_id, plan_id) ); CREATE TABLE course_score ( id BIGINT AUTO_INCREMENT PRIMARY KEY, selection_id BIGINT NOT NULL, score DECIMAL(5,2), status TINYINT DEFAULT 0 COMMENT '0未录 1已录' );

这套设计看起来简单,实际上暗含了一个重要决策:把“课程库”和“开课计划”拆成两张表。课程库存课程本身的元数据,开课计划才对应“某学期某教师某时间段的实际教学班”。如果只做一张课程表,就会出现同一门课多个老师上课时不知道选课记录该关联到谁的尴尬情况。

2.2 选课记录的幂等设计:唯一索引是第一道保险

选课记录表上那个uk_student_plan (student_id, plan_id)唯一索引是我特意加的。它是防止重复选课的“物理级保险”,不管代码逻辑有没有漏洞,数据库层面都不允许同一个学生对同一门课产生两条选课记录。

这样做的好处在实际并发测试中体现得非常明显。后面我会讲到,在高并发下即使代码里做了校验,也仍然可能出现两个请求同时通过“你没选过这门课”的检查,然后一起插入数据的情况。唯一索引直接在数据库层拒绝第二个插入请求,抛出 DuplicateKeyException,让问题在源头被截住。

2.3 容量字段是冗余字段?这个得看场景

course_plan表里的selected_count字段在教科书上属于“冗余字段”,因为理论上可以通过SELECT COUNT(*) FROM course_selection WHERE plan_id = ?实时算出来。

但选课场景下我仍然保留了它。原因很实际:选课高峰期这个统计接口会被并发调用,每次实时 COUNT 整张表代价太高。维护一个冗余计数器,选课时UPDATE course_plan SET selected_count = selected_count + 1 WHERE id = ? AND selected_count < capacity,一条 SQL 就能同时完成“判断没满”和“人数加一”两个操作,性能和准确性都能兼顾。代价是必须在事务里保证计数器和选课记录的一致性,这一点到第三部分会详细展开。

2.4 时间冲突怎么在数据库层面表达

课程表里我用class_day、class_begin、class_end三个字段表达上课时间。为什么不直接用开始时间和结束时间?因为高校的上课时间是以“节次”为单位的,每节课 45 分钟,上午第 1-2 节、下午第 3-4 节这种说法是教务排课的“标准语言”。用节次存储,前端渲染课表时直接按格子填就行,时间格式统一,冲突检测的逻辑也简单。

冲突检测的 SQL 是这么写的:找出当前学生已经选的课里面,星期相同、并且节次区间有重叠的记录。

SELECT COUNT(*) FROM course_selection cs JOIN course_plan cp ON cs.plan_id = cp.id WHERE cs.student_id = ? AND cs.status = 1 AND cp.class_day = ? AND cp.class_begin <= ? AND cp.class_end >= ?;

这段 SQL 表达的逻辑是:上课星期相同,且新课的开始节次不大于旧课的结束节次,新课的结束节次不小于旧课的开始节次,那就是时间冲突了。

3. 后端选课接口的并发防线:锁、唯一索引与事务边界

3.1 选课接口一次完整的事务流程

选课接口是整个系统里最核心、也最容易出并发问题的部分。我把它的事务流程拆成了五个步骤:

  1. 从 Token 中解析学生信息,校验学生身份。
  2. 对开课计划记录加锁,读取当前容量与已选人数。
  3. 校验已选人数是否小于容量。
  4. 校验该学生的课表时间冲突、校验是否重复选课。
  5. 插入选课记录,更新已选人数,提交事务。

这里第一步、第二步看起来是“常规操作”,但第三步和第四步之间的竞态条件,是绝大多数选课系统出现超员和错选的根源。

3.2 第一步防线:事务 + 行锁

我选择在事务中对course_plan记录使用悲观锁,也就是SELECT ... FOR UPDATE。这条语句的作用是:在当前事务结束之前,其他事务想对该行做更新或再次加锁,都会被阻塞。这就能保证选课人数校验和更新操作是原子性的。

伪代码如下:

@Transactional(rollbackFor = Exception.class) public void selectCourse(Long planId, Long studentId) { CoursePlan plan = coursePlanMapper.selectByIdForUpdate(planId); if (plan.getSelectedCount() >= plan.getCapacity()) { throw new BusinessException("该课程已满"); } // 时间冲突校验、重复校验…… courseSelectionMapper.insert(selection); coursePlanMapper.increaseSelectedCount(planId); }

selectByIdForUpdate对应的 MyBatis-Plus 写法是:

@Select("SELECT * FROM course_plan WHERE id = #{id} FOR UPDATE") CoursePlan selectByIdForUpdate(@Param("id") Long id);

这里有一个很关键的细节:FOR UPDATE 必须加在事务里,并且锁的是开课计划这一行,而不是整张表。在 MySQL InnoDB 引擎下,如果id是主键,这是行锁,不同课程的选课请求互不阻塞,性能不会因为加了锁而急剧下降。

3.3 第二步防线:唯一索引兜底

悲观锁能解决大部分并发问题,但它有个前提——所有请求都走了同一套加锁逻辑。如果未来有人扩展了别的入口,比如管理员导入选课名单、补选接口等,漏掉了加锁步骤,并发问题就会回来。这时候唯一索引就派上用场了。

我在插入选课记录前做了重复性校验,但同时也在数据库层面建了uk_student_plan唯一索引。一旦有重复插入,MyBatis-Plus 会抛出 DuplicateKeyException,通常我在事务里会先根据这个异常判断是“重复选课”并给出友好提示。

try { courseSelectionMapper.insert(selection); } catch (DuplicateKeyException e) { throw new BusinessException("你已选择过该课程,请勿重复操作"); }

这样的兜底设计,让我对系统的并发正确性放心很多。

3.4 第三步防线:时间冲突检测

重复选课有唯一索引兜底了,时间冲突却没有物理约束能兜底,只能靠代码在事务里判断。因为冲突检测涉及“当前学生已选的多门课”和“新课”之间的比对,没有办法用唯一条目或数据库约束实现,必须实时算出。

我最终把时间冲突检测放在了 FOR UPDATE 加锁之后、插入记录之前。这样设计是为了保证:在同一时刻,针对同一门课的所有并发请求是串行处理的,拿到锁的事务会把容量和时间都校验完再释放。针对同一个学生的多个并发请求虽然加的锁是不同课程的锁,但同一个学生对同一门课只会有一个成功请求,其他的要么被唯一索引拦住,要么被时间冲突拦住。

有一个边界情况值得注意:如果学生同时提交了新课 A 和新课 B 两个请求,A 和 B 时间不冲突,但 A 和该学生已选的 C 课冲突,B 和已选的 D 课冲突,且四个请求并发到达。由于两个事务加的是不同课程的行锁,它们可能同时进入时间冲突校验,各自都读到“目前不冲突”,然后都插入成功。最终的课表里,A 和 C 冲突、B 和 D 冲突的问题就出现了。

这种情况在小规模系统里很少见,但我在做压测时确实碰到了。彻底解决需要引入表级别的学生选课锁,或者在事务里对student_id进行分布式锁。考虑到高校选课系统的真实并发量,我在单人单事务内对所有课程统一加一把学生级 Redis 锁或数据库锁是稳妥的做法。项目里为了降低复杂度,我最终选择了“学生维度数据库锁”,也就是在事务开始额外执行一次SELECT id FROM sys_user WHERE id = ? FOR UPDATE,让同一个学生的所有选课请求在同一把学生锁上排队,彻底避免交叉冲突。

3.5 退课为什么单独设计

退课接口同样用事务包裹,但与选课的方向相反:删除选课记录、已选人数减一。退课也要用 FOR UPDATE 锁住开课计划行,防止退课的同时有选课请求正在执行,导致已选人数短暂出现不一致。

@Transactional(rollbackFor = Exception.class) public void dropCourse(Long selectionId, Long studentId) { CourseSelection selection = courseSelectionMapper.selectById(selectionId); if (selection == null || !selection.getStudentId().equals(studentId)) { throw new BusinessException("选课记录不存在"); } CoursePlan plan = coursePlanMapper.selectByIdForUpdate(selection.getPlanId()); courseSelectionMapper.deleteById(selectionId); coursePlanMapper.decreaseSelectedCount(plan.getId()); }

退课接口还有一个人性化设计:选课轮次结束后自动关闭退课入口,如果是必修课还限制了退课期限。否则容易出现学生选完课又随意退课,导致最终开课时人数不足,教务只能临时调整教学班的情况。

4. 前端 Vue 的组织方式:登录态、选课交互与课表渲染

4.1 前端工程结构与路由划分

前端我选了 Vue 3 + Vite 的组合,UI 框架用的 Element Plus。工程结构上按“视图 + 通用组件 + API 层 + 状态管理”来组织,这样多人协作时每个人负责自己那块目录,不会互相打架。

src/ api/ # 把接口按模块拆文件 auth.js course.js selection.js views/ admin/ # 管理员端 teacher/ # 教师端 student/ # 学生端 components/ CourseTable.vue # 课表组件 CourseCard.vue # 课程卡片 store/ user.js # 登录态和用户信息 router/ index.js

路由划分上,我用的思路是“静态路由 + 动态权限”。基础路由只有登录页,登录成功后根据后端返回的角色字段,动态添加对应权限下的路由。

const roleRoutes = { admin: [ { path: '/admin', component: AdminLayout, children: [...] }, ], teacher: [...], student: [...], };

4.2 axios 拦截器与权限路由

axios 拦截器是前端和所有后端接口打交道的统一入口,我把两件事都放在这里:请求拦截器自动携带 JWT,响应拦截器统一处理错误码。

service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = `Bearer ${token}`; } return config; }); service.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { ElMessage.error(res.message); if (res.code === 401) { router.push('/login'); } return Promise.reject(new Error(res.message)); } return res.data; }, error => { ElMessage.error('网络异常,请稍后再试'); return Promise.reject(error); } );

这里有个容易被新手忽略的点:后端返回的 401 状态码不一定会在 axios 的 response 拦器里优雅处理。因为一些跨域配置错误导致拦截器收到的是服务端抛出的网络错误,而不是业务错误。我最后统一约定后端所有业务异常都返回 HTTP 200 加业务状态码,真正等到 HTTP 401 时直接清空本地登录态并跳转登录页。

4.3 选课页面的状态设计与交互

学生端最核心的页面是选课大厅。页面布局是左侧筛选条件,中间课程列表,右侧“已选课程”抽屉。

选课按钮的交互状态我用状态机管理:

  • 可选状态:课程未满、时间不冲突,按钮可点。
  • 已满状态:按钮置灰,显示“已满”。
  • 已选状态:按钮变成可点击的“退课”。
  • 冲突状态:按钮置灰,鼠标悬浮有 tooltip 提示与哪门课冲突。

点击选课按钮后,前端先做一次本地预校验(比如课程是否已满),然后再调后端接口。这里的预校验只是优化体验,真正的结果永远以后端返回为准。

<script setup> const selectedCourses = ref([]); async function handleSelect(plan) { const res = await selectCourseApi(plan.id); if (res.success) { ElMessage.success(`选课成功:${plan.courseName}`); await loadSelectedCourses(); } } </script>

选课成功后要重新加载“已选课程”列表,因为后端返回的数据里包含了最新的已选人数和课表信息,前端必须以这个响应为准刷新页面状态,否则会出现本地数据与后端不一致。

4.4 课表渲染:把二维数组做成组件

课表视图是选课系统里最有代表性的组件。我把它设计成一个CourseTable组件,接收课程列表作为 prop,内部生成一个 7 列 × 12 行的二维网格,按星期和节次定位单元格。

<template> <div class="course-table"> <div class="row" v-for="lesson in maxLessons" :key="lesson"> <div class="time-cell">{{ lesson }}节</div> <div class="day-cell" v-for="day in 7" :key="day"> {{ findCourse(lesson, day) }} </div> </div> </div> </template>

渲染逻辑不复杂,真正要注意的是课程在周次上的表达。如果一门课是 1-8 周,另一门课是 9-16 周,它们的星期和节次完全相同,也不应该算冲突。所以课表组件接收的课程对象里必须包含class_week字段,渲染时要把周次范围也展示出来,冲突检测的判断里也把它作为必要条件,否则系统会把不同周次的课程误判为冲突,导致学生无法选课。

5. 部署联调阶段的真实问题清单

5.1 跨域配置与前后端联调

前后端分离后的第一个坑大概率出现在跨域上。开发阶段我用的是 Vite 的代理解决,生产环境则通过 Nginx 把/api路径代理到后端服务。

server { listen 80; server_name your-domain.com; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

这里最容易忽略的是proxy_set_header,如果没有把真实 Host 转发过去,后端某些依赖请求来源的逻辑可能会拿到错误的域名信息,比如导出文件时生成下载地址就会出错。

5.2 并发超选的现场还原

上线前我用模拟脚本做了 200 个并发请求抢同一门容量为 5 的课程实验。第一次运行结束,选了 7 个人,超员了。

排查后发现是我在第一版实现里,把“容量判断”和“人数更新”放在了不同事务中,选课记录插入先提交,人数更新后才提交,中间产生了竞态窗口。把两个操作合并进同一个事务并加入FOR UPDATE锁后,再次压测,5 个请求成功,5 个请求收到“课程已满”的提示,问题解决。

这个经历说明了一个非常朴素的道理:选课这类的强一致场景,事务边界和锁必须画在同一个地方。校验和写入只要不在同一个事务里,任何中间状态都可能被并发请求利用。

5.3 服务器时区与日期存储的坑

系统部署到某台服务器后,学生选课记录里显示的选课时间比实际时间晚了 8 个小时。原因是数据库连接串里的serverTimezone没设置正确,MySQL JDBC 驱动默认用了服务器的 UTC 时区,和中国的东八区产生了时差。

修复方式是连接参数里显式指定时区:

spring: datasource: url: jdbc:mysql://localhost:3306/course_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

后面我索性统一约定:数据库一律存 UTC 时间,后端接口返回给前端时转换为本地时间字符串,前端展示不再做额外的时间运算。这样无论服务器部署在哪个区域,显示的时间都是用户本地时间。

5.4 构建部署与版本迭代习惯

前端 Vite 构建后产物在dist目录,直接交给 Nginx 托管即可。这一块我建议把dist目录的刷新规则配置清楚,否则会出现刷新页面 404 的问题。

location / { root /var/www/course-ui; try_files $uri $uri/ /index.html; }

后端则是标准的 Spring Boot 打 jar 包运行:

mvn clean package -DskipTests nohup java -jar course-server.jar --spring.profiles.active=prod > server.log 2>&1 &

这里我养成了一个习惯:每次发布前除了重新构建后端 jar 包,还会对比一遍数据库表结构变更,把增量 SQL 整理成独立文件,由运维在发布窗口执行,避免出现过线上库结构不一致导致接口报错的情况。选课系统最怕的不是功能少,而是发布之后才发现线上和测试环境行为不一致,那排查成本就很高了。

最后多说两句

从数据库设计到并发控制,再到前后端联调,这套选课系统麻雀虽小但五脏俱全。个人建议如果你也在做类似项目,可以先用单机版把完整链路跑通,再逐步加上并发保护、权限优化这些深度内容,每一层都能学到非常实际的东西。上面遇到的坑,尤其是并发超选和时区问题,都属于那种“文档里没写、一上线就出事”的典型问题,提前避开能省下不少周末时间。

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

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

立即咨询