简介:这是一份基于SpringBoot框架的学生网上选课系统毕业设计论文,面向高校计算机相关专业学生及需要完成同类课题的开发者。文档从传统人工选课管理效率低、易出错等痛点切入,系统论述了需求分析、可行性研究、总体设计,以及基于Eclipse、MySQL、Tomcat的详细实现过程。功能模块覆盖用户管理、新闻公告、课程管理、选课操作与数据统计,数据库设计包含用户表、课程表、选课表等核心表结构,并给出了系统测试与结论展望。资源包内含1个DOC格式文档,大小1.29MB,已有92人浏览学习。对于正在筹备毕业设计或学习SpringBoot实战的读者,这套资料不仅能作为论文结构与写作的范本,也能帮助理解选课业务逻辑、数据建模以及SpringBoot项目开发思路,具备较强的参考与复用价值。
1. 基于SpringBoot的学生网上选课系统:这个毕业设计题目到底在考你什么
每年毕业季都能看到大量“基于SpringBoot的学生网上选课系统”这类题目,乍一看是一个CRUD管理系统,实际做下来你会发现它比想象中多一层东西。这个标题背后隐藏着完整的业务闭环:学生登录、浏览课程、选课、退课,教师维护课程信息、录入成绩,管理员管理学生和教师账号、审核课程,还要处理选课冲突、容量限制、时间冲突这些真实业务规则。也就是说,它不只是“增删改查”,而是带状态流转和业务约束的小型管理系统。
从技术选型来看,SpringBoot是这个题目的绝对核心,围绕它的还有MyBatis-Plus或JPA做持久层、MySQL存数据、Thymeleaf或Vue做前端页面,如果做得更完整一些会加Spring Security或JWT做登录认证。整套东西做下来,你会发现它实际上是一个标准的企业级单体应用原型,具备权限控制、事务处理、数据校验、异常处理这些真实开发中天天要面对的问题。
这篇笔记就按我做这类项目的实际路径来讲:先拆表结构设计,再讲接口和权限怎么定,然后重点讲选课这个核心业务怎么写代码,最后把最容易踩的坑和答辩时的高频追问整理出来。你要是正在做这个题目,或者想接这类单子,照着这条路径走能把翻车概率压到最低。
2. 选课系统的表结构设计:五张核心表与三个容易埋雷的字段
2.1 从业务反推数据模型:为什么至少要五张表
学生网上选课系统的业务角色很清晰:学生、教师、管理员,核心业务是选课和退课。按这个反推,最基础的表至少要有这几张:
- student(学生表):学号、姓名、专业、年级、密码、已选学分
- teacher(教师表):工号、姓名、学院、职称、密码
- course(课程表):课程编号、课程名、学分、授课教师、上课时间、上课地点、容量、已选人数
- student_course(选课记录表):选课ID、学号、课程号、选课时间、成绩
- admin(管理员表):账号、密码
如果想把系统做得更像回事,还要加一张 semester(学期表)用来区分不同学期的开课计划,但很多毕设做单学期的简化版本也够用。核心在于 student_course 这张关联表,它是学生和课程的多对多关系的载体,所有核心业务——选课、退课、查成绩——都围绕它展开。
这里我一般建议用逻辑外键代替物理外键,就是只存关联ID,不建 FOREIGN KEY 约束。原因很简单:毕设阶段经常要批量导入测试数据,物理外键在删除和导入时会带来一堆顺序上的麻烦,逻辑外键在代码层控制引用完整性就足够了。这一点后面在避坑章节会详细说。
CREATE TABLE student_course ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id BIGINT NOT NULL COMMENT '学生学号', course_id BIGINT NOT NULL COMMENT '课程ID', choose_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间', score DECIMAL(5,2) DEFAULT NULL COMMENT '成绩,未录入时为NULL', status TINYINT DEFAULT 0 COMMENT '0-正常 1-退课 2-结课', UNIQUE KEY uk_student_course (student_id, course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选课记录表';这张表里有个容易被忽略的点:选课记录用 status 字段做软删除/状态标记,而不是直接 DELETE 删除记录。退课这个行为在业务上需要留痕,比如管理员要能查“谁在什么时候退过课”,而且统计维度上退课后该学生选课名额要释放。用状态字段替代物理删除,是最常见的做法。
2.2 容量字段与已选人数:一个字段的两种设计思路
课程表里“容量”和“已选人数”这两个字段看起来简单,实际上有两种设计路径。第一种是只存容量,已选人数通过 COUNT(*) 实时算出来;第二种是把已选人数也存成一个字段,选课时用 UPDATE ... SET selected_count = selected_count + 1 WHERE selected_count < capacity 这样的原子操作来更新。
实时 COUNT 的做法写起来简单,但每次选课都要对 student_course 按 course_id 做聚合,数据量上来后性能会明显变差,而且并发选课的时候会产生超选。维护一个冗余字段的做法常见于真实生产系统,配合数据库的行锁或乐观锁可以很好解决并发问题,代价是每次选课/退课都要同时更新这个字段,多一层一致性维护。
ALTER TABLE course ADD COLUMN selected_count INT DEFAULT 0 COMMENT '已选人数';我个人推荐把 selected_count 字段加上。理由是这种带容量限制的抢课场景,用整型字段做条件更新是最高效的并发控制手段。完整实现下一章会展开讲,这里先把表结构定好。
2.3 时间字段的三个细节:为什么要把时间字段单独拎出来讲
课表里“上课时间”这个字段是最容易做错的。第一种做法是存字符串,比如“周一 3-4节”,第二种是存成星期几加节次编号两个数字字段。我做的话会选择后者:course_week TINYINT 存1到7代表周一到周日,course_start TINYINT 和 course_end TINYINT 存起始节次和结束节次。这样做的直接好处是排课冲突检测可以用数字比较来完成,比如判断两门课是否时间重叠时,不用去解析中文字符串。
另一个细节是 student 和 teacher 的密码字段,很多初稿会把密码明文存进去。这个题目的要求虽然不会卡得特别严,但答辩时如果你主动说出“密码用 BCrypt 加密存储”,印象分会明显不一样。Spring Security 自带的 BCryptPasswordEncoder 一行代码就能完成加密和校验,投入产出比很高。
第三个细节是 create_time 和 update_time 这类审计字段,用 MyBatis-Plus 的自动填充注解或者数据库的 DEFAULT CURRENT_TIMESTAMP 都能解决,不要每个插入语句手动传时间,既丑又容易漏。
2.4 字符集与存储引擎:两个看起来没技术含量但直接影响体验的配置
建表时 DEFAULT CHARSET 一定要用 utf8mb4 而不是 utf8。utf8 在 MySQL 里是 utf8mb3 的别名,存不了 emoji 和部分生僻字,学生姓名里万一有生僻字就直接插入报错。另外建议统一用 InnoDB 引擎——如果你在毕设里用了 MyISAM,一旦答辩老师问“为什么不支持事务”就是给自己挖坑。InnoDB 支持事务和外键,行锁在并发选课时是必要的。
还有一点值得多说:每张表都建议加一个自增主键 id,而不是直接用 student_id / course_id 当主键。逻辑业务主键用 UNIQUE KEY 约束保证唯一性,自增主键只做物理定位用。这种“代理主键+业务唯一键”的方式对后续分页、关联查询、批量删除都更友好,也是生产环境的主流实践。
3. 用户认证与权限设计:三个角色如何共用一套登录入口
3.1 登录认证选型:JWT还是Session,毕设场景哪个更合适
登录认证这块,一开始我建议不要引入太重的方案。最常见的做法是 SpringBoot + Interceptor 做登录拦截,用 Session 保存登录状态,简单直观,答辩也好讲。但如果你想要更好的前后端分离体验,或者想在简历里多提一个亮点,JWT 方案是更合适的选择。
JWT 的本质是把用户信息加密后发给前端,之后每次请求由后端解密验证。它的好处是服务端无状态,缺点是 token 过期和注销处理要自己写。Spring Security + JWT 的完整配置涉及过滤器链、SecurityContextHolder、自定义认证入口,这部分代码量不小。在做这个选课系统时,我建议走一条折中路:用 Spring MVC 的拦截器实现角色鉴权,用 JWT 或 Session 做登录态管理,两种方案都不需要引入 Spring Security 全家桶,但能讲出清晰的认证流程。
@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 从请求头获取token,也可以从session获取,这里演示JWT方式 String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } // 解析token,得到用户角色 try { Long userId = JwtUtil.parseToken(token.replace("Bearer ", "")); request.setAttribute("userId", userId); return true; } catch (Exception e) { response.setStatus(401); return false; } } }拦截器的逻辑不复杂但位置关键:preHandle 里做身份验证和角色判断,放行后 Controller 才能拿到当前用户信息。这里要注意拦截器只做“是否登录”的检查,角色判断建议在拦截器里根据请求路径前缀做区分,比如 /student/** 和 /teacher/** 分别限制对应角色访问。
3.2 注册登录接口的常见翻车点:密码加密与登录态续期
密码加密是登录接口最常翻车的地方。用 MD5 加密在今天的视角看来基本等于没过脑——MD5 撞库成本极低,随便一个彩虹表都能把简单密码还原出来。这个环节我用 BCrypt 来做,它是 Spring Security 自带的密码哈希算法,自带盐值,同密码每次哈希结果不同,暴力破解成本比 MD5 高很多。
@PostMapping("/login") public Result<?> login(@RequestBody LoginRequest request) { // 先查数据库找到用户,再比对密码 Student student = studentMapper.selectByStudentNo(request.getAccount()); if (student == null || !new BCryptPasswordEncoder().matches(request.getPassword(), student.getPassword())) { return Result.error("账号或密码错误"); } // 登录成功后生成token Map<String, Object> claims = new HashMap<>(); claims.put("userId", student.getId()); claims.put("role", "student"); String token = JwtUtil.createToken(claims, 24 * 3600 * 1000L); return Result.success(token); }这段代码里最容易忽略的是登录接口的“用户类型判断”,很多系统把学生、教师、管理员登录拆成三个接口,前端维护三套入口。做成交互简单一点的常见做法是让用户选择身份,或者自动识别账号前缀——这些设计要提前想好,否则后面联调时前端会找你改接口。
登录态过期问题也值得注意。JWT 设置的过期时间是 24 小时,学生选课把页面挂了一晚上,第二天提交选课直接 401,体验很差。常见的处理办法是前端在收到 401 时自动跳转登录页,或者后端提供 refresh token 接口做续期。毕设做简单版的话,设 12 到 24 小时的 token 有效期并让前端在过期前静默续签,就够了。
3.3 三个角色的权限矩阵该怎么落地
权限控制最容易做成一团浆糊的地方是在 Controller 层堆 if-else。标准做法是用拦截器按路径前缀做粗粒度控制,再在需要细粒度控制的接口里取当前登录用户的ID进行数据属主校验。比如学生只能看自己的选课记录,教师只能改自己的课程信息,这些校验一旦漏掉,就会出现“学生调用接口修改了别人的选课记录”这种低级问题。
具体落地路径:定义一个 WebConfig 注册拦截器,设置 addPathPatterns 和 excludePathPatterns。登录接口、注册接口、静态资源放行,其余全部进拦截器。然后定义三个子路径前缀:/api/student/、/api/teacher/、/api/admin/,分别在拦截器里校验 JWT 中的角色字段是否匹配前缀。这样比在 Controller 层加注解更直观,答辩时也容易讲清楚。
数据属主校验是权限设计的第二层。教师修改课程时,要校验 course 表中的 teacher_id 是否等于当前登录用户的ID,否则一个教师能改所有人的课程。很多初稿在这一层漏掉,系统看起来能跑,但接口是被“裸奔”的。
4. 选课核心业务:从事务到并发控制,这块代码决定你的项目档次
4.1 选课接口的需求拆解:容量检查、时间冲突、重复选课
选课是整套系统的核心业务,也是最容易写出“看起来能用、一并发就挂”的代码的地方。选课接口至少要完成四件事:校验课程是否存在、校验是否已选过、校验课程容量是否已满、校验上课时间是否和其他已选课程冲突。
@Transactional public Result<?> chooseCourse(Long studentId, Long courseId) { // 1. 查课程信息,加行锁防止并发超选 Course course = courseMapper.selectByIdForUpdate(courseId); if (course == null) { return Result.error("课程不存在"); } // 2. 检查是否已经选过这门课 int count = studentCourseMapper.countByStudentIdAndCourseId(studentId, courseId); if (count > 0) { return Result.error("不能重复选课"); } // 3. 时间冲突检测:查出该学生所有已选课程的时间段 List<Course> selectedCourses = courseMapper.selectByStudentId(studentId); for (Course selected : selectedCourses) { if (isTimeConflict(course, selected)) { return Result.error("上课时间冲突:" + selected.getCourseName()); } } // 4. 容量检查,用条件更新原子扣减 int updated = courseMapper.reduceCapacity(courseId); if (updated == 0) { return Result.error("课程已满"); } // 5. 插入选课记录 StudentCourse record = new StudentCourse(); record.setStudentId(studentId); record.setCourseId(courseId); record.setStatus(0); studentCourseMapper.insert(record); return Result.success("选课成功"); }这段代码是选课接口的标准实现,关键有两点:整个方法加 @Transactional 保证原子性,任何一步失败都整体回滚;容量检查用 UPDATE 的条件更新而不是先查再判断,避免两个并发请求同时读到容量有余量。
4.2 乐观锁与悲观锁的取舍:超选问题的两种解法
超选问题是选课系统的经典技术点。所谓超选,指的是并发场景下最后一个人选的课程超过容量。解决办法有两种路线:
悲观锁方案:查询课程时用 SELECT ... FOR UPDATE 把这一行锁住,其他事务必须等当前事务提交后才能继续操作。好处是逻辑简单,缺点是并发性能差,而且锁粒度大,同一门课的选课请求会排队。 乐观锁方案:在 course 表加一个 version 字段,每次更新时带上版本号条件,更新成功说明没人改过,更新失败说明数据已经变了需要重新处理。
UPDATE course SET selected_count = selected_count + 1, version = version + 1 WHERE id = #{courseId} AND selected_count < capacity;这条 SQL 是乐观锁的常见变体——不引入 version 字段,直接用“当前已选人数小于容量”作为更新条件。affected rows 为 0 说明没抢到名额,为 1 说明更新成功。它比纯 version 方案少一次查询,在容量扣减这个场景里是更优雅的解决方式。
我自己的实践是两种都写一下:选课接口用 UPDATE 条件更新的乐观锁方案做容量扣减,同时在查询课程时配合 FOR UPDATE 做余额读取的一致性保证。答辩时主动把这个设计讲出来,然后解释为什么弃用纯悲观锁,能明显体现出对并发问题的理解深度。
4.3 排课冲突检测:用数字比较替代字符串解析
时间冲突检测的核心是把“上课时间”从文本形式结构化。前面建表时设计了 course_week、course_start、course_end 三个数字字段,冲突检测就变成一个简单的区间相交判断:
public boolean isTimeConflict(Course newCourse, Course existedCourse) { // 不在同一天上课,肯定不冲突 if (!newCourse.getCourseWeek().equals(existedCourse.getCourseWeek())) { return false; } // 判断区间是否重叠:新课程开始节次小于已有课程结束节次 // 且新课程结束节次大于已有课程开始节次 return newCourse.getCourseStart() < existedCourse.getCourseEnd() && newCourse.getCourseEnd() > existedCourse.getCourseStart(); }这个判断等价于两个闭区间 [start, end] 是否有交集。需要注意边界条件:第3节到第4节和第4节到第5节是否算冲突?按照实际排课逻辑,相邻节次之间如果课间不休息算连上,算冲突;如果课间休息则不算。在数据录入时统一规则即可。大部分毕设的实现里,第4节到第5节之间有课间休息,可以不冲突。
4.4 退课接口:释放名额与保留记录
退课接口是选课的反向操作,但要注意的细节更多。退课时要更新选课记录的状态为“已退课”,释放 course 表的已选人数,同时要判断课程是否已经结束或者老师是否已录入成绩。
@Transactional public Result<?> dropCourse(Long studentId, Long courseId) { // 查选课记录,确认这条记录存在且状态正常 StudentCourse record = studentCourseMapper.selectByStudentIdAndCourseId(studentId, courseId); if (record == null || record.getStatus() != 0) { return Result.error("选课记录不存在或已退课"); } // 防止退已录入成绩的课程 if (record.getScore() != null) { return Result.error("该课程已录入成绩,无法退课"); } // 状态改为退课 record.setStatus(1); studentCourseMapper.updateById(record); // 释放容量 courseMapper.increaseCapacity(record.getCourseId()); return Result.success("退课成功"); }这里的业务规则是“教师录入成绩后不能退课”,这个约束在需求文档里经常不写,但答辩时如果被问到你会怎么处理,能答出来是加分项。另外退课释放容量时要确认释放的确实是之前选课占用的名额,这一点因为退课只改变状态不删除记录,所以不会发生“释放了别人的名额”的问题。
5. 从零跑通SpringBoot项目:手把手搭建到生成器写码
5.1 环境准备与项目初始化
开始动手前先确认环境:JDK 8 或 JDK 11、Maven 3.6+、MySQL 5.7+。IDE 用 IntelliJ IDEA 或 Eclipse 都行,IDEA 的 Spring Initializr 可以直接从菜单新建 SpringBoot 项目,选择 Web、MyBatis、MySQL Driver 这几个依赖即可。
这里有一个常见分歧:用 Spring Boot 2.x 还是 3.x。2.x 基于 javax 命名空间,网上资料最多,兼容性最好,直到今天仍是国内教学的主力版本。3.x 基于 jakarta 命名空间,要求 JDK17,部分老教程的代码直接粘过去会报错。做毕设缺乏试错时间的话,选 2.7.x 是最稳的。
pom.xml 里除了 Web 和 MySQL 驱动,还要加 MyBatis-Plus 和 Lombok,前者把单表 CRUD 的样板代码省掉,后者解决实体类 getter/setter 的冗长问题。这两个依赖加入之后,你不用写一行 SQL 就能完成对五张表的增删改查,选课、退课、登录这些核心接口的 SQL 再手写。
5.2 三种代码生成路径:手写、逆向工程与代码生成器
实体类和 Mapper 的代码生成有两种主流方案。第一种是手写——先从数据库表字段推导 Java 属性,再写 Mapper 接口,再写 XML 的 SQL。五张表大约 50 个属性,手写大概一小时能完成,但容易在字段名映射上出低级错误。
第二种方案是使用 MyBatis-Plus 的代码生成器。它根据数据库表结构自动生成实体类、Mapper 接口、Service、Controller,SpringBoot 项目里配置一个生成器主函数,运行一次就能把基础代码全部产出。生成出来的代码可以直接跑,也可以在此基础上改业务逻辑。
public class CodeGenerator { public static void main(String[] args) { FastAutoGenerator.create("jdbc:mysql://localhost:3306/course_system", "root", "password") .globalConfig(builder -> builder.author("developer").outputDir("src/main/java")) .packageConfig(builder -> builder.parent("com.example.course")) .strategyConfig(builder -> builder.addInclude("student", "teacher", "course", "student_course", "admin")) .execute(); } }代码生成器能省时间,但生成的代码是“能用”而不是“好看”。比如分页查询的默认实现是简单的 Page 对象,多表关联查询需要手写 Join SQL。你要在这里投入修改时间:给生成的 Service 补业务校验,给 Controller 补参数校验,在生成代码的基础上加业务逻辑,而不是把生成结果直接当作成品。
就我经验来看,项目里真正值得手写的是三块:选课相关的 Mapper 方法(需要用 FOR UPDATE、条件 UPDATE),统计类的聚合 SQL(已选学分、选课人数),以及所有带状态流转的更新语句。其他纯单表查询交给 MyBatis-Plus 的 QueryWrapper 足够。
5.3 配置文件的边界:哪些参数必须调、哪些不用动
application.yml 的配置看似琐碎,有几个参数不调会直接影响开发和联调体验:
spring: datasource: url: jdbc:mysql://localhost:3306/course_system?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: status logic-delete-value: 1 logic-not-delete-value: 0datasource 的 URL 里 serverTimezone=Asia/Shanghai 是必须的,否则 MySQL 8.x 连接时区报错;characterEncoding=utf8 保证中文写入不乱码。mybatis-plus 的 log-impl 配成 StdOutImpl,控制台会打印每条 SQL,联调阶段排错效率翻倍,上线前再关掉。
逻辑删除的配置可以省——上一章我们用 status 字段做手动状态管理,如果配置了全局逻辑删除,MyBatis-Plus 的所有查询会自动带上 status = 0 条件,反而会影响正常查询。这里我给的建议是别配全局逻辑删除,在需要状态过滤的查询里手动加条件,看得更清楚。
5.4 运行与验证:启动后第一件事是测登录还是测选课
项目启动后不要急着点前端页面,先按顺序验证核心链路。第一步用 curl 或 Postman 调登录接口拿 token,第二步拿 token 调学生列表接口,确认认证拦截生效,第三步选一门容量充足的课程,第四步选同一门课确认重复选课拦截生效,第五步用一个学生账号同时选两门时间冲突的课,确认冲突检测生效。这五步走完,系统的核心骨架就是健康的。
用 curl 验证是最快的路径:
curl -X POST http://localhost:8080/api/login \ -H "Content-Type: application/json" \ -d '{"account":"2021001","password":"123456","role":"student"}' curl -X POST http://localhost:8080/api/student/choose \ -H "Content-Type: application/json" \ -H "Authorization: Bearer eyJhbGciOi..." \ -d '{"courseId":1}'这里要提一个很多新人会卡住的点:前端如果直接用浏览器地址栏访问选课接口,因为没带 Authorization 头,会被拦截器 401 挡回来。这不代表系统有问题,而是说明认证生效了。你还需要做的是测试 token 过期、错误 token、无 token 三种异常路径,确保拦截器对非法请求的响应是一致的。
6. SpringBoot选课系统最常见的五个坑:现象、原因与解决方案
6.1 中文乱码出现在 JSON 响应或写入数据库
现象:前端页面显示的课程名称是“???”,或者登录接口返回的提示信息变成乱码。原因一般是两个:数据库连接串没有 characterEncoding=utf8,或者数据库表本身是 latin1 字符集。解决路径是先看数据库表和字段的字符集,确保是 utf8mb4,再改连接串加 characterEncoding=utf8 参数。如果都没问题,看一下 SpringBoot 的 server.servlet.encoding 配置是否强制 UTF-8。排查顺序从底层往上查,先数据库再连接串再应用层。
6.2 选课人数超出课程容量导致 0 人名单
现象:课程容量 30 人,但 student_course 表里对应课程有 32 条记录。原因几乎是必然的:选课接口没有用事务包裹容量检查和插入,或者没有用条件 UPDATE 扣减容量。两个并发请求同时读到容量还有 1 个名额,都通过检查,都执行插入。解决方法是回到第四章的选课代码,把容量扣减改成 UPDATE ... WHERE selected_count < capacity 的原子操作,并且方法上必须有 @Transactional。
6.3 拦截器放行了登录接口却拦截了静态资源
现象:页面加载时 CSS、JS 全部 404,或验证码图片加载不出来。原因:WebConfig 里只配置了登录接口放行,没有放行 /static/、/css/、/js/** 这些路径,拦截器把所有请求拦下来后静态资源直接响应 401。解决方法是把静态资源路径加入 excludePathPatterns,另外如果前后端分离,Swagger 或 Knife4j 的接口文档路径也要在开发环境放行,否则没法在浏览器里调试接口。
6.4 前端分页显示正常但搜索功能查不到数据
现象:按课程名称模糊搜索时,关键字带中文能查到,带英文查不到,或者百分比符号 % 被当成通配符导致查询结果异常。原因:MyBatis 的 XML 里拼 SQL 时用了 ${keyword} 而不是 #{keyword},或者模糊查询时没有对 % 和 _ 做转义。解决方法是模糊查询统一用 CONCAT('%', #{keyword}, '%') 方式拼接,不要用字符串直接拼到 SQL 里——既能防止 SQL 注入,也能避免通配符污染查询条件。
6.5 事务没生效:明明加了 @Transactional 但选课记录还是插入了
现象:选课逻辑里故意抛异常测试回滚,发现选课记录还是写进去了。原因:最典型的两种情况,一是 SpringBoot 启动类或配置类没有加 @EnableTransactionManagement 注解(Spring Boot 2.x 其实默认开启了,但如果你自定义了 DataSource 就需要显式声明),二是 @Transactional 加在了同一个类内部调用的私有方法上——Spring AOP 基于代理,内部调用不走代理,事务自然失效。解决方法是把事务方法放到独立的 Service 类里,并且确保方法被外部调用,不要同类调用同类。
排查事务是否生效有个简单办法:在选课方法里故意写一行 throw new RuntimeException(),如果选课记录没有插入,说明事务生效;如果插入成功了,说明代理没起作用。
7. 答辩问不倒的进阶优化:缓存、压测与经典追问准备
做到这一步,系统该有的功能都已经能跑了,但答辩时老师很少会满足于“能跑”。准备几个进阶话题,能明显拉开和其他同学的差距。
第一个话题是缓存优化。选课高峰期首页会展示所有课程列表,每次请求都查数据库压力不小。用 Spring Cache 或 Caffeine 给课程列表加个本地缓存,设置 30 秒过期,可以显著降低数据库压力。要注意的是缓存只用于查询接口,选课和退课这类写操作必须走数据库。
@Cacheable(value = "courseList", key = "'all'") public List<Course> getAllCourses() { return courseMapper.selectList(new QueryWrapper<Course>().orderByAsc("course_id")); }@CacheEvict(value = "courseList", allEntries = true) public Result<?> chooseCourse(Long studentId, Long courseId) { // 选课逻辑... }缓存的粒度要控制好:课程列表可以缓存,但“已选人数”这种高频变化数据不适合放缓存,否则学生看到的名额和实际能选的会不一致,产生超卖假象。这里只缓存课程基本信息列表,已选人数保持实时读取,是最稳妥的折中方案。
第二个话题是并发压测。你可以在答辩时主动说“我用 JMeter 对选课接口做了并发测试,50 个线程同时选同一门容量 30 的课程,最终选课记录只有 30 条,没有超选”。这个验证结果比任何口头解释都有说服力。做这个测试只需要 JMeter 创建一个线程组,配置 HTTP 请求,添加聚合报告查看响应数据,不需要写任何代码。
jmeter -n -t choose_course_test.jmx -l result.jtl -e -o report/第三条要准备的是经典追问的答法。老师常问的问题包括:“你的登录状态是怎么保持的?”“并发情况下怎么防止超选?”“学生选了课但没退,老师能不能改成绩?”“数据库表是怎么设计的,为什么这么拆?”这些问题的答案在这篇笔记里都覆盖到了,关键是自己能顺着代码讲出来,而不是背概念。
最后我自己的习惯是:做完系统后,抽一天时间从头删掉数据库重建,严格按照 README 的步骤走一遍部署流程。这一步能发现所有“我自己电脑上明明能跑”的问题——比如忘记初始化 SQL 脚本、密码写死在配置里、第三方依赖没打进去。一个能照着文档复制粘贴跑通的系统,和一个只能在自己电脑上跑的系统,答辩观感是完全不同的。
希望这篇笔记能帮你在做这个题目的过程中少踩几个坑。
本文还有配套的精品资源,点击获取