每年选课季,高校教务系统崩溃几乎是保留节目。我读本科时亲身经历过一次:早上七点五十五,宿舍四个人守着三台电脑一台手机,八点整一刷新,页面直接502。折腾到九点半才挤进去,热门课早没了。后来我自己动手做高校学生选课系统,毫不犹豫选了 SpringBoot + Vue.js + MyBatis + MySQL 这套组合,把整套源码从零写到完整可部署。这篇文章不是课程作业式的功能介绍,而是把这套系统从需求梳理、表设计、后端选课逻辑、前端交互到部署上线的全过程拆给你看,重点讲清楚选课系统最容易翻车的并发控制,以及我实测踩过的那些坑。想拿这个题目做毕业设计、或者准备接手类似教务项目的同学,可以直接照着抄。
1. 选课场景的真实痛点:先想清楚系统要扛住什么
1.1 选课不是普通的CRUD
很多人一听"学生选课系统",第一反应就是"不就是课程表和选课记录两张表增删改查嘛"。如果你真这么想,写出来的东西大概率只能在演示PPT上跑通,一遇到真实选课场景就会被打回原形。
高校选课的业务特征非常特殊,我归纳成三点:
- 时间窗口极度集中。学校一般会规定一个时间段,比如中午12:00到13:00开放选课,全校几千甚至上万名学生同时涌入。平时在线几十个人的系统,瞬间要扛住几千并发。
- 数据一致性要求极高。一门课程容量30人,如果第29个和第30个学生同时提交选课请求,系统必须保证只有一个成功,不能出现两个人都选上、最后实际选了31人的事故。
- 业务流程分阶段。选课通常不只一步,有预选、正选、退选、补选等阶段。不同阶段学生的操作权限不一样,比如预选可以随便选,正选可能就要按优先级处理。这些状态切换如果不设计清楚,后面改起来非常痛苦。
所以我在动手写代码之前,先花了一整天把业务规则全部列出来,而不是急着建工程。这一步省了我后面至少一个星期的返工时间。
1.2 三种角色,三组截然不同的需求
这个系统里一共有三种角色,每种角色的痛点完全不同:
| 角色 | 核心诉求 | 典型操作 |
|---|---|---|
| 学生 | 快速选上想上的课、能看清时间冲突、随时退改 | 浏览课程、选课、退课、查看个人课表 |
| 教师 | 开课审核、查看选课名单、了解课程热度 | 开课申请、导出名单、查看选课统计 |
| 管理员 | 基础数据维护、选课规则配置、系统监控 | 管理课程/学生/教师、设置选课时间、容量调整 |
很多人做设计时容易把管理员功能做得很重,学生功能做得很浅。实际上这个系统里最复杂、最需要打磨的是学生端的选课流程,尤其是"选课冲突检测"和"容量控制"这两件事。管理员端反而相对标准化,无非就是增删改查加一点统计报表。
1.3 功能边界与状态控制
我把选课的业务规则理成了一张状态表,这是整个系统的地基:
- 课程有状态:可选的、已满的、选课中的、已关闭的
- 学生选课记录有状态:已选、已退、待确认
- 选课阶段有状态:未开始、选课中、已结束
后端每个接口在放行之前,都要先判断当前处于哪个阶段、课程处于什么状态、学生是否满足选课条件。我把这套判断封装在一个SelectionValidator组件里,凡是涉及选课状态变更的接口都要经过它,避免每个Service里各写一套判断逻辑,后面维护起来想死的心都有。
提示:状态判断一定要做在后端,不能只靠前端按钮隐藏。真实环境下总有学生绕过前端直接调接口,你在前端藏了选课按钮,不等于后端就安全了。
2. 技术选型:这套组合拳是怎么打出来的
2.1 后端为什么坚持SpringBoot
说实话,SpringBoot 现在已经不算什么新潮技术了,但在高校选课这种业务场景里,它依然是最稳妥的答案。选它有几个很实际的理由:
- 配置简化的收益非常直观。传统 SSM 框架要写一堆 XML 配置,SpringBoot 用自动配置帮你把大部分工作干完了。项目里我只需要一个
application.yml,数据源、MyBatis、事务管理器都能通过配置搞定。 - 内嵌Tomcat,部署方便。一台服务器装好JDK就能跑 jar 包,不用单独装Tomcat、配端口、配JNDI,这对没有专职运维的高校服务器来说太友好了。
- 生态成熟,踩坑资料多。说实话,做这个选题的同学百分之八九十都在用SpringBoot,意味着你遇到任何问题,搜索引擎能给你一堆解决方案。真到了赶工的时候,这一点能救命。
我用的版本是 SpringBoot 2.7.18。很多人问为什么不直接用 SpringBoot 3.x,我的看法是:如果你的项目不需要JDK17的新特性,2.7是更稳的选择,尤其是我本机环境还是JDK8,生态兼容性更好。2025年做新项目,我建议可以直接用3.x + JDK17,但如果你手上是教程资源比较老的,2.7反而少踩坑。
2.2 MyBatis:SQL控得住,才敢调优
选 MyBatis 而不是 MyBatis-Plus 或者 JPA,我是有明确理由的。
选课系统的查询场景非常吃SQL能力:课程要按学院、学分、上课时间、已选人数等多个条件动态过滤,排序规则还经常变。MyBatis 最大的优势就是SQL 写在自己手里,什么动态条件、连表查询、复杂聚合,我都能精确控制。MyBatis-Plus 虽然开发效率高,但遇到复杂查询时生成的SQL往往不是最优的。
另一个重要原因是MyBatis 的缓存机制。MyBatis 自带一级缓存和二级缓存,默认情况下同一个 SqlSession 内的查询会走缓存。但这里有个大坑:选课操作涉及实时数据变更,开二级缓存极有可能导致学生看到的选课人数不是最新的。我直接把二级缓存关了,保证每次查询都走数据库。这个决策在后面压测中也证明了是对的——任何时候选课人数都比缓存快一步。
如果你正在准备面试,MyBatis 的一二级缓存、SQL执行流程、动态SQL的工作原理都是高频考点,做这个项目顺手把这些搞懂了,等于一鱼两吃。
2.3 前端Vue.js:省心且够用
前端选 Vue.js 而不是 React,核心原因很简单:这个项目的交互复杂度还没到需要 React 那种灵活度的程度。
Vue 的响应式数据绑定和指令系统非常适合选课页面这种"列表 + 详情弹窗 + 已选列表联动的"场景。比如学生选了一门课,左侧课程列表的"已选人数"要实时 +1,右侧"我的课表"要立刻出现这节课,这种双向联动在 Vue 里就是改一下数据的事。如果换 jQuery 时代的写法,不知道要写多少 DOM 操作。
Vue Router 做前端路由很方便,配合导航守卫可以轻松实现"未登录不能访问""学生不能访问教师页面"这类权限控制。我用的版本是 Vue 2.6,配套 Element UI 组件库,页面做出来比较规范,适合课程设计和毕设展示。如果你是新项目,用 Vue 3 + Element Plus 也没问题,核心逻辑大同小异。
2.4 2025年的版本搭配参考
我把自己实测跑通的版本组合列在这里,照着装基本不会出兼容性问题:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 1.8 / 17 | 2.7配8,3.x配17 |
| SpringBoot | 2.7.18 | 稳定、教程多 |
| MyBatis | 3.5.x | 与SpringBoot starter整合 |
| MySQL | 5.7.44 | 兼容性好,安装包好找 |
| Vue | 2.6.14 | 配Element UI |
| Node.js | 16.x | 构建Vue前端用 |
| Maven | 3.8.x | 后端构建 |
注意:如果你用 MySQL 8.0,记得驱动类名是
com.mysql.cj.jdbc.Driver,URL 里一定要加serverTimezone=Asia/Shanghai,否则连接会报时区错误。这个坑几乎每个人都会踩一次。
3. 数据库设计:表结构定了,系统就成了一半
3.1 核心表设计
选课系统的表结构,我建议按这个思路拆:用户体系一张表,学生/教师扩展表各一张,课程两张表,选课记录一张表。
我最终落地的核心表如下:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| t_user | 登录账号 | id, username, password, role |
| t_student | 学生信息 | user_id, student_no, name, major_id |
| t_teacher | 教师信息 | user_id, teacher_no, name, dept_id |
| t_course | 课程基本信息 | id, course_name, credits, teacher_id, capacity, selected_count |
| t_course_time | 课程上课时间 | id, course_id, day_of_week, start_section, end_section |
| t_selection | 选课记录 | id, student_id, course_id, status, create_time |
这里有个细节你可能注意到了:课程容量 capacity 和已选人数 selected_count 我直接放到了课程表里。为什么不通过COUNT(*)去统计选课记录来判断课程是否已满?因为频繁的 COUNT 查询在大并发下会成为数据库的负担,而且这种统计逻辑放到业务代码里容易出现竞态条件。
在t_selection表上,我建了一个唯一约束:(student_id, course_id),这样从数据库层面就杜绝了同一个学生重复选同一门课。这种约束看起来简单,实际上能挡掉很多并发场景下的逻辑漏洞。
3.2 防超选的数据库层保险
选课系统最怕的事就是超选:课程容量50,结果选进去51个人。想从根上防住,数据库层面要做三层保险:
第一层,课程表里的 selected_count 字段不能超过 capacity,这个可以在应用层判断,但不是可靠保证。
第二层,选课记录表的唯一约束,保证一学生对一课程只有一条有效记录。
第三层,事务与锁,这个我在第六章详细讲。这里先给你一个结论:靠应用层的if (selectedCount < capacity)判断,在并发场景下是不够的,必须配合数据库锁才行。
我的实际做法是在t_course表里加了version字段(乐观锁),同时选课核心接口里用数据库悲观锁兜底。两种方案组合,把超选的几率压到零。
3.3 索引设计:选课季的查询性能靠它
选课季的查询流量非常大,几个核心查询必须走索引,否则数据库会直接被打满。
我给课程表建了这三个索引:
idx_course_teacher:按教师查课程,教师端常用idx_course_name:按课程名模糊搜索,学生端常用idx_course_credit:按学分筛选
选课记录表建了:
- 唯一索引
uk_student_course组合(student_id, course_id) idx_course_status组合(course_id, status) 用于快速查某门课的有效选课人数
索引不是越多越好。每多一个索引,写入的时候就要多维护一棵B+树。选课系统的写入集中在选课阶段,如果索引建得太多,高并发写入时数据库反而会变慢。我上线前用慢查询日志验证了一遍,把所有不常用索引都删掉了。
3.4 时间字段与逻辑删除的取舍
几个容易被忽略但很重要的字段设计:
create_time/update_time:所有核心表都要有,MyBatis 里通过插入时自动填充,排查问题的时候时间线能救命deleted逻辑删除:学生选课记录我用的是物理删除 + 状态字段。为什么不用逻辑删除?因为选课记录的查询走唯一索引,如果同一学生退课后再选课,逻辑删除会产生两条"未删除"冲突记录,处理起来很绕。直接把退课改成status = 'CANCELLED'并保留记录,还能保留学生历史选课轨迹。
提示:设计表的时候就想清楚"删除到底是真删还是标记删",比写代码时候临时决定靠谱得多。选课记录建议保留历史,但课程表里的垃圾数据定期清一清。
4. 后端核心逻辑:把选课事务写成铁桶
4.1 分层结构与请求流转
后端我用了经典的四层结构:Controller → Service → Mapper → MySQL。选课系统的业务不算复杂,这套结构完全够用,而且面试官看着也舒服。
请求流转的链路是:
- 前端发请求,带 JWT Token
- 拦截器校验Token,解析出用户ID和角色
- Controller 接收参数,调用 Service
- Service 先做业务校验(选课阶段、课程状态、时间冲突、容量判断)
- Mapper 操作数据库
- 统一返回
Result<T>结构
这里要强调一点:Controller 只做参数接收和返回,所有业务逻辑必须放 Service。很多人为了图省事把判断写在 Controller 里,一旦接口多了,代码烂得飞快。我就是坚持这个规矩,后面加接口的时候只需要复用Service,省了太多事。
4.2 JWT登录鉴权的实现细节
Token 方案我选的是 JWT,而不是传统的 Session。原因很简单:后端被部署成前后端分离架构,Session 跨域处理比较麻烦,JWT 天然无状态,后端只负责验签即可。
JWT 的结构是 Header.Payload.Signature 三段式,我用了一个简单封装:
// 生成Token String token = Jwts.builder() .setSubject(userId.toString()) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24)) .signWith(secretKey) .compact();然后在 SpringBoot 里写一个拦截器,继承HandlerInterceptorAdapter,对除了/api/login以外的所有接口做 Token 校验:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new AuthException("未登录"); } // 解析Token,把userId和role放进request attribute Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token.substring(7)).getBody(); request.setAttribute("userId", Integer.parseInt(claims.getSubject())); request.setAttribute("role", claims.get("role")); return true; }这里有个我踩过的坑:JWT 如果设置了过期时间,一定要在拦截器里捕获 ExpiredJwtException 并返回401,否则前端拿到的错误信息非常不友好,排查问题时一脸懵。
4.3 选课事务:@Transactional真的够用吗
选课这个动作,最少要操作两张表:插入选课记录 + 更新课程表已选人数。这两步必须在一个事务里,要么都成功,要么都回滚。
Spring 的@Transactional注解用起来很简单,但有几个坑必须注意:
- 事务方法不能被同类内部调用。比如
SelectionServiceImpl里的selectCourse()方法加了@Transactional,如果同类里的另一个方法直接this.selectCourse()调用,事务是不生效的,因为 Spring 的声明式事务基于动态代理,内部调用绕过了代理。我一开始就踩了这个坑,排查半天发现事务没起作用。 - try-catch 吞掉异常会让事务失效。事务回滚依赖 RuntimeException 往外抛,如果你在方法内部 catch 住了异常并吞掉,事务就认为执行成功了,不会回滚。正确做法是 catch 后要么往外抛,要么
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚。
选课的事务我简单描述一下:
@Transactional(rollbackFor = Exception.class) public void selectCourse(Integer studentId, Integer courseId) { // 1. 校验阶段、课程状态、时间冲突 selectionValidator.validate(studentId, courseId); // 2. 悲观锁锁定课程行 Course course = courseMapper.selectForUpdate(courseId); // 3. 容量判断 if (course.getSelectedCount() >= course.getCapacity()) { throw new SelectionException("课程已满"); } // 4. 插入选课记录 selectionMapper.insert(...); // 5. 更新已选人数 courseMapper.increaseSelectedCount(courseId); }4.4 防超选的并发方案对比
这是全系统最核心的技术点,我花了很大精力。
方案一:乐观锁
在课程表加version字段,更新时带上版本号:
UPDATE t_course SET selected_count = selected_count + 1, version = version + 1 WHERE id = #{courseId} AND version = #{version}如果返回受影响行数为0,说明 version 不匹配,也就是有其他人抢先更新了,这时候事务回滚,提示学生"课程已满或系统繁忙"。
方案二:悲观锁
SELECT ... FOR UPDATE锁住课程行,让其他事务等这个事务提交后再操作:
SELECT * FROM t_course WHERE id = #{courseId} FOR UPDATE我最终采用的是悲观锁为主、乐观锁兜底的组合方案。为什么?因为选课的核心冲突全都集中在同一行课程数据上,悲观锁能保证同一时刻只有一个请求能操作这一行的选课人数,逻辑最简单直接。乐观锁虽然并发性能更强,但会发生大量"白忙一场"的回滚和重试,在选课这种激烈竞争场景下,用户体验反而不如让请求排队等一下。
提示:悲观锁一定要配合事务使用,并且在事务内尽早释放锁。FOR UPDATE 执行后到事务提交前,其他线程会一直阻塞,如果事务里还做了其他慢操作,很容易造成数据库连接池耗尽。我实际压测时就把事务范围缩到最小,只保留"查 + 插 + 更新"三步。
4.5 MyBatis动态SQL:多条件筛选一个方法搞定
课程查询是学生使用频率最高的接口,条件有课程名、学院、学分、上课时间等等,而且可选条件不固定。这种场景用 MyBatis 动态SQL非常合适:
<select id="queryCourses" resultType="com.example.dto.CourseVO"> SELECT c.*, t.name AS teacher_name FROM t_course c LEFT JOIN t_teacher t ON c.teacher_id = t.id <where> <if test="courseName != null and courseName != ''"> AND c.course_name LIKE CONCAT('%', #{courseName}, '%') </if> <if test="teacherName != null and teacherName != ''"> AND t.name LIKE CONCAT('%', #{teacherName}, '%') </if> <if test="credits != null"> AND c.credits = #{credits} </if> <if test="availableOnly != null and availableOnly == true"> AND c.selected_count < c.capacity </if> </where> ORDER BY <choose> <when test="sortType == 'hot'"> c.selected_count DESC </when> <otherwise> c.id ASC </otherwise> </choose> </select>一个方法通吃列表页的全部筛选场景,不用为每个条件组合写一个SQL。动态SQL执行前最好在本地先打印出最终SQL,确认拼接逻辑正确,避免出现 where 后面啥条件都没有的裸奔查询。
5. Vue前端实现:选课页面背后的交互逻辑
5.1 路由与权限控制
前端路由我按角色拆成了三个模块:
/student/*:学生端,含课程列表、我的课表、选课页面/teacher/*:教师端,含开课管理、选课名单/admin/*:管理端,含学生管理、课程管理、选课阶段设置
每个模块的路由都挂在同一个Router实例上,通过导航守卫做访问控制。核心代码:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); return; } const role = localStorage.getItem('role'); if (token && !to.path.startsWith('/' + role)) { next('/' + role + '/dashboard'); return; } next(); });这段守卫的逻辑是:先判断有没有登录,再判断角色能不能访问目标路由。回到第五章开头说的,前端守卫只是提升用户体验的,真正的权限校验永远在后端。前端可以随便改代码绕过守卫,但改不了后端接口的权限判断。
5.2 状态管理:这个规模不用上Vuex
很多教程一上来就给你装 Vuex/Pinia,把用户信息、课程列表、已选列表全塞进去。但选课系统这种规模,状态管理用多了反而复杂。
我的做法是:
- 用户信息和 Token 放 localStorage,页面刷新后自动恢复
- 课程列表和已选列表放在各自的组件内,通过 props 和事件通信
- 只在"学生选课页"这种两个子组件(课程列表和课表)需要联动的地方,用到 Vue 的响应式变量
实测下来这套方案完全够用,代码还更好理解。如果你做毕业设计想展示技术含量,可以引入 Pinia 管理用户会话和选课列表状态,但要有清晰的模块划分,别把所有状态塞到一个 store 里。
5.3 选课页的核心交互细节
选课页是整个前端最复杂的部分,我的布局是:左侧课程列表(支持筛选和搜索),右侧我的课表(按周一到周日分列)。学生点课程卡片上的"选课"按钮,前端要做三件事:
- 立即禁用该按钮,防止重复点击造成重复请求
- 弹出确认框显示课程信息,包括时间、学分、余量
- 请求成功后更新左侧余量和右侧课表
这里最容易漏掉的是选课按钮的防抖。高并发场景下,学生可能会双击按钮,如果没有防抖,前端会发出两个一模一样的请求。我在按钮点击事件里加了disabled状态切换,同时在后端做了幂等校验——同一个学生在同一门课同时发起两次请求,后会到的那次会因为重复选课校验直接失败。
课程卡片的"余量"我用selected_count和capacity实时算出,但前端显示的数字永远可能滞后于真实值,所以我在页面顶部加了一条提示:"选课结果以下单结果为准"。这既是实话,也是在并发场景下降低学生困惑的常用策略。
5.4 Vue打包后如何放进SpringBoot
开发时前后端分离很舒服,但部署时把两个项目分开跑(前端Nginx + 后端Tomcat)在课程设计里显得不够"一体化"。其实完全可以把 Vue 打包后的静态文件直接放进 SpringBoot 里,一个 jar 包搞定。
步骤很简单:
# 1. 前端构建 npm run build # 2. 把dist目录下的所有文件复制到SpringBoot的static目录 cp -r dist/* src/main/resources/static/ # 3. 打包 mvn clean package -DskipTests需要注意一个关键配置:如果前端用了 Vue Router 的 history 模式,刷新页面会出现404,因为后端没有对应的路由处理。解决办法是加一个路由转发,把所有非API路径转发到 index.html:
@Controller public class PageForwardController { @RequestMapping(value = {"/", "/student/**", "/teacher/**", "/admin/**"}) public String forward() { return "forward:/index.html"; } }或者更简单,直接用 hash 模式路由,URL 里会带个#,刷新不会404。我为了界面好看用了 history 模式,然后配了这个转发。
6. 一次真实翻车:选课并发导致的超选事故复盘
6.1 现象:压测刚起,就发现超选
系统开发完第一次做模拟选课压测,我用JMeter模拟了500个并发请求同时选一门容量300的课。压测跑完,查数据库时头都大了:选课记录表里这门课的有效选课记录一共312条,超了12个人。
当时第一反应是事务没生效。检查日志,发现选课接口确实都走完了,没有报错。这说明不是简单的逻辑漏洞,而是并发条件下"检查-插入-更新"这三步之间的竞态问题。
6.2 排查链路:先查日志,再查SQL,最后定位到锁
我的排查顺序是:
- 看后端日志,确认300个成功之后还有请求正常返回,说明程序根本没意识到课程已满
- 看数据库日志,发现好几个事务同时执行了
SELECT selected_count FROM t_course WHERE id = ?,读到的都是299 - 看事务配置,发现
@Transactional生效,但两个事务可以同时读到同一行数据,在 MySQL 默认的 REPEATABLE READ 隔离级别下,事务A读到选课人数299,事务B也读到299,两个人都认为自己可以选 - 确认问题根因:应用层先查后改的判断,在并发下没有任何原子性保证
问题其实出在我最初的代码写法上——先用普通 SELECT 查容量,条件成立再插入。这种"检查再执行"模式,在单线程下没问题,多线程并发下就是个典型的竞态条件。
6.3 修复:一条FOR UPDATE锁解决
定位到问题后,修复方案很直接:把查询改成悲观锁,让"查容量"和"更新人数"之间不会插入第二个事务的操作。
修改后的核心逻辑:
@Transactional(rollbackFor = Exception.class) public void selectCourse(Integer studentId, Integer courseId) { selectionValidator.validateSelectionStage(); // 关键:使用悲观锁,锁住课程行 Course course = courseMapper.selectByIdForUpdate(courseId); if (course.getSelectedCount() >= course.getCapacity()) { throw new SelectionException("课程已满"); } // 检查时间冲突 List<CourseTime> studentCourseTimes = courseTimeMapper.selectByStudentId(studentId); List<CourseTime> thisCourseTimes = courseTimeMapper.selectByCourseId(courseId); if (hasTimeConflict(studentCourseTimes, thisCourseTimes)) { throw new SelectionException("上课时间冲突"); } selectionMapper.insert(Selection.builder() .studentId(studentId) .courseId(courseId) .status("SELECTED") .build()); courseMapper.increaseSelectedCount(courseId); }对应的 Mapper:
<select id="selectByIdForUpdate" resultType="Course"> SELECT * FROM t_course WHERE id = #{id} FOR UPDATE </select>FOR UPDATE会锁定这行数据,直到事务提交或回滚。第二个请求来了之后,会发现锁被占着,只能阻塞等待。等第一个事务提交后,它读到的selected_count已经是更新后的值,就能正确判断课程是否已满。
同时我还加了一行乐观锁兜底:
UPDATE t_course SET selected_count = selected_count + 1 WHERE id = #{id} AND selected_count < capacity这行的意思是,就算所有锁都没拦住,最后这行 UPDATE 也必须满足"已选人数小于容量"才执行。如果影响行数为0,说明超选了,事务整体回滚。
6.4 回归验证:压测数据说话
修复后重新跑同样的压测:500并发抢300容量的课,结果精确落在300条记录,一条不多一条不少。我又测试了1000并发抢500容量的课,最终也是500条整。
有一点要诚实告诉你:悲观锁方案在压测时接口的响应时间是有上升的,500并发的平均响应时间从修复前的约0.8秒涨到了1.5秒左右。但这也是符合预期的,因为并发请求本质上是串行排队处理了。对于选课这个场景,"排队但准确"远比"快速但出错"重要。
7. 部署上线与避坑清单
7.1 环境准备:一台普通服务器就够了
选课系统部署实际用到的环境很简单,我用一台4核8G的Linux服务器就能跑得很稳:
- JDK 1.8
- MySQL 5.7
- Nginx 1.20(如果前后端分离部署)
- Maven 3.8
如果你用的是 MySQL 8.0,连接配置里要注意:
spring: datasource: url: jdbc:mysql://localhost:3306/course_selection?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.DriveruseSSL=false一定要加,否则 MySQL 8.0 会尝试建立 SSL 连接,本地没有证书的话直接报错。我见过太多人卡在这一步。
7.2 打包与配置分离
后端打包我建议用 Maven 的 profile,把开发环境和生产环境的配置分开:
# 开发环境打包 mvn clean package -DskipTests -Pdev # 生产环境打包 mvn clean package -DskipTests -Pprod生产环境的application-prod.yml单独放数据库连接、Redis地址等敏感信息,这样换服务器部署时不用重新编译,直接改外部配置文件就行。我实际部署时就把配置文件放在 jar 包同级的 config 目录下,SpringBoot 启动时会优先读取外部配置,非常方便。
7.3 MySQL参数调优:连接数与连接池
选课系统的数据库压力集中在短时间内,两个参数很关键:
max_connections:MySQL默认最大连接数通常是151,这个值在选课期间可能不够用。我调整到了500。如果不改,连接数用完,后端会报Too many connections。
连接池大小:SpringBoot 默认的 HikariCP 连接池配置按需调整。根据我压测的经验,maximum-pool-size设置在30左右比较稳,太小请求会排队,太大反而增加数据库负担。还像这句话说的,"连接池不是一个越大越好的东西"。
7.4 我踩过的坑整理
最后随便挑几个实际项目中印象最深的坑,都是小事,但每个都能卡住你好几个小时:
| 问题 | 现象 | 解决办法 |
|---|---|---|
| MySQL 时区错误 | 连接报The server time zone value is unrecognized | URL加serverTimezone=Asia/Shanghai |
| 事务不生效 | 选课接口异常了数据还是写进去了 | 检查是不是同类内部调用,把方法拆到不同Bean或用AOP |
| MyBatis 驼峰映射失效 | 查出来的selected_count字段是null | 配置map-underscore-to-camel-case: true |
| Vue history 模式404 | 刷新页面报404 | 后端加页面转发,或改用hash路由 |
| JWT 过期没处理 | 前端一直收到500 | 拦截器捕获ExpiredJwtException返回401 |
这套系统从表设计到部署上线,前后花了大概三周。代码量不算大,最耗时间的反而是并发问题的排查和修复,但这部分恰恰是这个项目最有含金量的地方。如果你也准备做选课系统,建议把重心放在事务、锁、数据一致性这些"看不见"的地方,而不是页面做得有多花哨——毕竟系统能扛住选课那天的流量,才是真正检验实力的时刻。