尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot学生选课系统实战:数据建模、事务并发与分页优化

Spring Boot学生选课系统实战:数据建模、事务并发与分页优化 简介基于Spring Boot的学生选课系统项目包面向计算机相关专业正在完成期末大作业或课程设计的学生也适合需要项目实战练习的初级学习者。系统作为成熟的大作业方案曾获得98分涵盖选课管理、课程信息维护、学生信息管理等典型业务模块可帮助读者快速理解Spring BootMyBatis等主流技术栈的实际项目结构。资源压缩包共34个文件主体为23个Java源文件包含controller、service、mapper等分层代码还提供2个XML配置、1个YAML配置及2个properties文件用于项目环境配置1个SQL数据库脚本可直接导入建表另附README开发文档和Maven相关文件说明清晰。整个包仅85KB为纯代码与脚本资源方便自行部署调试。目前已有187人学习使用。通过该项目可掌握学生选课系统的完整流程、数据库设计思路以及Spring Boot项目的打包运行方法对完成类似课设作业和提升Java开发能力均有直接帮助。1. 用 Spring Boot 做学生选课系统为什么它能成为期末大作业的经典题学生选课系统大概是高校里出现频率最高的 Spring Boot 练手项目没有之一。它之所以被反复布置原因不在功能多而在边界清晰学生、教师、课程、选课记录四个实体就能把 CRUD、关联查询、分页、权限、事务、约束冲突这些后端面试八股里反复考察的点全部覆盖掉。一个选课系统写透了Spring Boot 的项目结构和数据访问套路基本就通了。如果你打算直接拿现成的源代码改改去交那这篇文对你反而不太适用。我做这个题目时的判断是系统的运行效果大同小异但设计取舍决定了它是能答辩的完整项目还是套壳 Demo。下面都是基于 Spring Boot MyBatis / Spring Data JPA MySQL 这套经典组合的落地方案每一节都按「先讲清楚为什么这么设计再给出可直接复制的实现」来推演数据库建表脚本和关键模块代码一并给出。你自己搭骨架或者拿参考工程改造都能用得上中间这些思路。2. Spring Boot 学生选课系统的数据建模与表结构设计2.1 实体关系拆解从需求文档到 ER 图一个典型的学生选课系统需求方也就是教务老师会提这么几个诉求学生能浏览课程、选课、退课教师能发布课程、查看选了自己课的名单管理员能管理学生和教师账号。这些诉求落到数据模型上就是五张表student学生表学号、姓名、密码、专业、班级teacher教师表工号、姓名、密码、职称、院系course课程表课程号、课程名、教师工号、学分、容量、已选人数、上课时间地点student_course选课关系表选课 ID、学号、课程号、选课时间、成绩、状态admin管理员表管理员账号通常是默认内置一个选课关系表是这里的核心。它不叫 course_student 而是叫 student_course是因为一张表要同时表达「学生选了哪些课」和「某门课有哪些学生」两个查询方向。外键上只建逻辑关联不要在 MySQL 里强加物理外键约束——这是分库分表和批量导入时代的主流做法也给你的期末答辩留一个「我考虑了扩展性」的加分点。2.2 建表 SQL容量、已选人数和唯一约束的取舍CREATE DATABASE IF NOT EXISTS course_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE course_system; CREATE TABLE student ( student_id VARCHAR(20) PRIMARY KEY COMMENT 学号, password VARCHAR(64) NOT NULL COMMENT 登录密码, name VARCHAR(50) NOT NULL COMMENT 姓名, major VARCHAR(100) COMMENT 专业, class_name VARCHAR(50) COMMENT 班级, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT学生表; CREATE TABLE teacher ( teacher_id VARCHAR(20) PRIMARY KEY COMMENT 工号, password VARCHAR(64) NOT NULL, name VARCHAR(50) NOT NULL, title VARCHAR(50) COMMENT 职称, department VARCHAR(100) COMMENT 院系 ) ENGINEInnoDB COMMENT教师表; CREATE TABLE course ( course_id INT AUTO_INCREMENT PRIMARY KEY COMMENT 课程号, course_name VARCHAR(100) NOT NULL COMMENT 课程名, teacher_id VARCHAR(20) NOT NULL COMMENT 授课教师工号, credit DECIMAL(3,1) DEFAULT 2.0 COMMENT 学分, capacity INT NOT NULL DEFAULT 50 COMMENT 容量上限, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, schedule VARCHAR(100) COMMENT 上课时间地点如 周一3-4节教301, status TINYINT DEFAULT 1 COMMENT 1发布 0下架, KEY idx_teacher (teacher_id) ) ENGINEInnoDB COMMENT课程表; CREATE TABLE student_course ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id VARCHAR(20) NOT NULL, course_id INT NOT NULL, selected_time DATETIME DEFAULT CURRENT_TIMESTAMP, score DECIMAL(5,1) DEFAULT NULL COMMENT 成绩教师录入, status TINYINT DEFAULT 1 COMMENT 1选课中 0已退课, UNIQUE KEY uk_student_course (student_id, course_id) ) ENGINEInnoDB COMMENT选课关系表;几个值得在文档里写清楚的细节student_course 上的UNIQUE KEY uk_student_course是选课不重复的兜底防线。就算你的 Service 层忘了判断数据库也会抛出重复键异常不至于出现同一学生同课两条记录。course 表里维护selected_count是典型的反规范化设计。正常范式下这个值应该用COUNT(*)查出来但选课列表页每页 20 条课程都要统计选课人数子查询会拖慢列表接口。牺牲一个冗余字段换来列表页一次简单查询。status TINYINT DEFAULT 1用来做逻辑删除不要用 DELETE 物理删除课程。实际教务场景中一门课下架后学生成绩单里还要能溯源。选课关系的 status 字段支持退课后保留记录方便统计历史选课行为和恢复误删操作。2.3 为什么用 utf8mb4 而不是 utf8一个必答面试点utf8mb4 是 UTF-8 的完整实现支持四字节字符也就是 emoji 和生僻字。MySQL 的 utf8 字符集实际只有三字节学生名字里出现生僻字、教师备注里贴个表情符号写入直接报错「Incorrect string value」。阿里开发规范里明确要求数据库字符集统一用 utf8mb4原因就是这个。你的开发文档里如果能写上「考虑到姓名生僻字和学生备注内容多样性字符集选用 utf8mb4」班子答辩时老师会高看一眼。排序规则选utf8mb4_general_ci就够了它不区分大小写性能也好于utf8mb4_unicode_ci。如果你用 Spring Data JPA配合ddl-auto: update可以让实体类自动同步建表上面的 SQL 脚本则作为初始化数据用。3. 核心模块实现登录鉴权和选课业务写清楚这个项目就立住了3.1 登录模块Session 还是 JWT学生选课系统的访问端只有浏览器页面不涉及 App 和跨域用 Session 是最稳的方案。具体做法是用户登录成功后把 studentId / teacherId / role 放进 HttpSession后续请求通过 Spring MVC 的拦截器判断 session 是否存在。JWT 在这个场景里属于过度设计——你把 token 存到 localStorage还要额外处理 XSS 和过期刷新为了一个「用了 JWT」的简历词条增加若干风险点不划算。真想让项目看起来有水平可以做角色分离的登录拦截器。学生接口前缀/api/student/**教师/api/teacher/**各配各的拦截路径规则比一刀切全拦截要清晰。Component public class RoleInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(false); if (session null || session.getAttribute(loginUser) null) { response.sendRedirect(/login); return false; } LoginUser user (LoginUser) session.getAttribute(loginUser); String uri request.getRequestURI(); if (uri.startsWith(/api/student) !STUDENT.equals(user.getRole())) { response.setStatus(HttpStatus.FORBIDDEN.value()); return false; } if (uri.startsWith(/api/teacher) !TEACHER.equals(user.getRole())) { response.setStatus(HttpStatus.FORBIDDEN.value()); return false; } return true; } }配置类里注册拦截器把/api/student/**、/api/teacher/**加进来放行/login、/api/auth/**和静态资源/css/**、/js/**。密码存储不要用明文加盐 MD5 都不够看用 Spring Security Crypto 里的 BCryptPasswordEncoder一次加密一次匹配代码量没多多少但写在文档里会显得你关注了安全问题。3.2 选课事务容量检查、唯一约束和乐观锁的场景化理解选课接口是系统里并发压力最大的入口。同一个热门课程两百个学生同时点选课如果没有并发控制selected_count 会被后写入的请求直接覆盖出现超选。解决思路有两层第一层是数据库行的原子更新。不要先 SELECT 再 UPDATE而是直接用一条 UPDATE 语句让数据库帮你判断Repository public interface CourseMapper extends BaseMapperCourse { // 乐观锁更新capacity 大于当前已选人数才会更新成功 Update(UPDATE course SET selected_count selected_count 1 WHERE course_id #{courseId} AND selected_count capacity) int increaseSelectedCount(Param(courseId) Integer courseId); }这条 SQL 的返回值为 0 时说明课程已经满员写入失败。它把「判断是否满员」和「已选人数 1」合并成一个原子操作天然防超卖——MyBatis-Plus 在 3.4 以上版本里用Version注解做乐观锁但手写 SQL 的意图更直白也方便排错。第二层是选课业务的事务边界。先用上面的 SQL 做更新拿到影响的记录数再往 student_course 表里插入选课关系。这两步要放在同一个事务里否则会出现「人数加了但选课记录没插上」的脏数据。Service public class CourseServiceImpl implements CourseService { Override Transactional(rollbackFor Exception.class) public void selectCourse(String studentId, Integer courseId) { // 1. 原子更新课程已选人数返回 0 表示课程已满 int affected courseMapper.increaseSelectedCount(courseId); if (affected 0) { throw new BizException(课程已满员); } // 2. 插入选课关系uk_student_course 唯一约束兜底 try { StudentCourse sc new StudentCourse(); sc.setStudentId(studentId); sc.setCourseId(courseId); studentCourseMapper.insert(sc); } catch (DuplicateKeyException e) { // 发生重复选课时回滚第 1 步的人数 1 throw new BizException(您已选过该课程); } } }抛出的 BizException 是自定义运行时异常配合Transactional(rollbackFor Exception.class)保证异常时整段回滚。这里的 catch 里不能吞异常必须往外抛触发回滚。重复选课自己新写一个表 unique key 不算本事这个 catch 里的回滚逻辑才是加分点。3.3 退课业务容量回补与关联清理退课相对简单把 student_course 表的记录状态置为 0同时把 course 表的 selected_count 减回去。两行代码的事但要注意别物理删除选课记录——有的学生退课后又要选回来历史记录没了很麻烦。Transactional(rollbackFor Exception.class) public void dropCourse(String studentId, Integer courseId) { studentCourseMapper.cancelByStudentIdAndCourseId(studentId, courseId); courseMapper.decreaseSelectedCount(courseId); }3.4 教师端看名单和录成绩MyBatis-Plus 条件构造器能少写一半代码教师端最常用的两个功能是查看选了自己课的名单和录入成绩。名单查询是典型的三表关联student_course 连 student 拿姓名、连 course 拿课程名过滤条件用 teacher_id。public ListStudentCourseVO listByTeacherId(String teacherId) { return studentCourseMapper.selectVOList( new LambdaQueryWrapperStudentCourse() .eq(StudentCourse::getTeacherId, teacherId) .orderByDesc(StudentCourse::getSelectedTime) ); }这里的关键是 StudentCourseVO一个专门给前端展示用的视图对象字段包括学号、姓名、课程名、上课时间、成绩。写 VO 而不是直接返回实体类能避免把密码之类不该暴露的字段序列化出去。VO 和实体分离这件事写在技术文档里也比「用了 Spring Boot」更有说服力。4. 分页查询与列表接口学生端和教师端的查询性能优化4.1 课程列表分页MyBatis-Plus 分页插件与 Page 对象学生首页的课程列表不能一次性全查出来几十门课无所谓一旦课程上到几百门页面会卡。分页插件是 MyBatis-Plus 的标准配置核心代码是配置一个拦截器Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页查询的 Service 层代码里连表查询会带上 course 和 teacher 两张表的信息。传给 Page 对象时页码和每页条数从 Controller 的 RequestParam 接收前端表格的排序参数也能直接映射到 ORDER BY 上public PageCourseVO pageCourses(int pageNum, int pageSize, String keyword) { PageCourseVO page new Page(pageNum, pageSize); LambdaQueryWrapperCourse wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(keyword)) { wrapper.like(Course::getCourseName, keyword); } wrapper.orderByDesc(Course::getCourseId); return courseMapper.selectPageVO(page, wrapper); }注意 keyword 的模糊查询用 like前端传 JAVA 时会把 「Java 程序设计」「JAVA Web 开发」都匹配出来。这个语义符合直觉不用额外处理大小写。4.2 课程列表的已选人数显示冗余字段和实时性的平衡课程卡片上「已选人数 23/40」这个数字是列表接口里最容易被面试官追问的点。如果每次都去 student_course 表做 GROUP BY 统计课程多了以后慢查询套餐跑不掉。所以我设计时让 course 表自带 selected_count每次选课、退课事务里同步增减List 接口直接取值即可。这个方案的代价是极端情况下冗余值可能和真实关联记录数不一致比如手动改了数据库、事务部分回滚失败。补偿措施是加一个定时任务每天凌晨用一条 SQL 校准一次UPDATE course c LEFT JOIN ( SELECT course_id, COUNT(*) AS cnt FROM student_course WHERE status 1 GROUP BY course_id ) sc ON c.course_id sc.course_id SET c.selected_count IFNULL(sc.cnt, 0);写文档时把这个对账 SQL 贴出来会让你的系统「真实感」强很多——很多商业系统都未必做了这一步。4.3 接口返回体统一封装Result 类和全局异常处理前后端分离的项目接口返回结构必须统一。{ code: 200, message: ok, data: {...} }是约定俗成的结构用泛型类定义一次所有 Controller 都返回它Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT error(String message) { ResultT r new Result(); r.code 500; r.message message; return r; } }配合全局异常处理器RestControllerAdvice把 BizException、参数校验异常、数据库异常分别映射到不同的 code。这样做的好处是前端的 axios 拦截器只需要判断 code 统一弹 tip不用每个接口单独写错误处理。5. 前后端联调与部署检查把这些跑通了再提交5.1 阿里巴巴 Java 开发规范的几个硬指标期末大作业的评分标准里通常有「代码规范」这一项。与其赌老师看不看注释不如把规范体现在硬指标上类名大驼峰、方法名小驼峰、常量全大写下划线、不可变字段用 final、日志输出用 slf4j 的{}占位符而不是字符串拼接。实体类里日期字段用 java.time.LocalDateTime 而不是 java.util.Date前者能直接映射 MySQL 的 datetime 类型还附带了时区处理的改进。Controller 层只做参数接收和结果返回业务逻辑全部下沉到 Service。Controller 里出现超过三行的 if 就属于越界Service 里出现 SQL 字符串就属于越界——严格遵守这两条代码自然分层。5.2 数据库连接池和字符集编码application.yml 里有一组配置是每个 Spring Boot 项目都绕不开的这里给一套可直接用的基线配置每项都做注释解释为什么是这个值spring: datasource: url: jdbc:mysql://localhost:3306/course_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8characterEncodingutf8配合建库时的 utf8mb4两层都保证中文不乱码。serverTimezoneAsia/Shanghai是必须的否则新版 MySQL 驱动会因默认时区和本机不一致报错。HikariCP 是 Spring Boot 默认的连接池不用额外引依赖maximum-pool-size从默认的 10 提到 20给并发选课留余量。5.3 后端压测与慢查询定位从 Postman 跑到 JMeter提交前至少做一次并发冒烟测试用 JMeter 模拟 30 个线程同时选同一门课观察接口的失败率和数据库是否报死锁。做法是新建线程组循环 100 次请求选课接口断言返回结果里的 code 字段。如果出现 DuplicateKeyException 或死锁日志优先排查事务里两个资源获取顺序是否一致——最常见的死锁场景是选课事务里先更新 course 再插 student_course而退课事务里先更新 student_course 再减 course 人数两个互相等。排查慢查询的手段一句话说清打开 MySQL 的慢查询日志超过一秒的 SQL 自动记下来再用 EXPLAIN 看是否走了索引。student_course 表主查询条件是 student_id联合唯一索引 uk_student_course 恰好能命中course 表的 teacher_id 建了普通索引教师端按工号查课程也能命中索引。6. 让开发文档成为答辩提分项ER 图、接口清单和部署说明这样写开发文档在这个项目里的重要性可以和代码本身五五开。评分老师看不到你调试到凌晨的过程只能通过文档判断「这个人是否真的理解了系统」。我的经验是按四个部分写技术栈选型说明、ER 图和核心表字段解释、接口 API 表含参数和响应示例、部署运行步骤。接口清单用 Markdown 表格截几个核心字段如下路径方法角色参数返回/api/auth/loginPOST学生/教师username, passwordResult/api/student/course/selectPOST学生courseIdResult/api/student/course/dropPOST学生courseIdResult/api/student/course/listGET学生pageNum, pageSizeResultPage /api/teacher/course/studentsGET教师courseIdResultList /api/teacher/score/updatePOST教师studentId, courseId, scoreResult部署说明里不要只写「启动 Application.main」把环境要求写清楚JDK 1.8 还是 JDK 17、MySQL 5.7 还是 8.0、如何导入初始 SQL、后端默认端口 8080 怎么改。如果老师要求在本地跑通再演示这些就是他的操作手册——他省事你分高。最后一章收在一个小技巧上项目里加一个数据初始化 Runner用 CommandLineRunner 接口在启动时自动创建一个管理员账号省去手动往 admin 表里插数据的步骤。这个动作虽小但直接体现了「考虑到了部署体验」这一层放到最后一个自然段说算是一个务实的能力佐证。本文还有配套的精品资源点击获取
返回列表