SpringBoot学生网上选课系统设计与实现:从数据库到并发控制的实战复盘
“springboot学生网上选课系统的设计与实现”这个题目在我做过的毕设指导里出现的频率大概能排进前三。它属于典型的“看着简单、做起来全是细节”的项目很多同学一口气写了三张表、五个接口觉得完工了结果一演示就翻车选课超员、时间冲突没校验、教师端删了课学生里的记录还在甚至SpringBoot版本选太高启动直接报错。这篇内容我打算把它当成一个完整的实战复盘来讲——从题目拆解开始到数据库建模、接口设计、并发选课的坑、版本兼容问题再到部署前要做的事一次性说清楚。适合正在拿这个题目做毕设的同学也适合刚入门SpringBoot、想练一个完整项目的人。先说结论这个系统的核心不在“会用它”而在“选课”这两个字背后的一系列业务约束。你把它做成了简单的CRUD答辩老师一眼就能看出来你把它当成一个真实教务场景里的高并发、强一致性问题来处理哪怕只用到单机方案论述深度也完全不同。下面我按自己做项目的顺序把思路和实操一条条写出来。1. 项目解读与需求拆解1.1 选课系统在解决什么真实问题不是所有毕设题目都有一个明确的现实痛点但“网上选课系统”有而且是所有高校都经历过的那种。以前选课靠线下填表、辅导员汇总、人工协调麻烦在于课程名额有限某门课爆满后需要逐个通知调整学生不知道哪些课还有位置教师拿不到一份实时名单。网上选课要解决的就是这三件事让名额变透明、让流程变快、让数据实时一致。把这个痛点写进论文的前言或研究意义比任何空话都管用。你这个系统的价值不光是“实现了增删改查”而是“在一个集中时段内让大量学生并发地选择有限课程名额并且要保证数据不冲突”。这句话本身就是业务核心也是后面设计表和接口时所有决策的依据。再说说标题里“11699”这个编号。有些高校的毕设题目库里每个题目都有编号11699就是题目编号本身不是特殊含义。写论文时不需要解释它但你可以跟着学校模板走封面、任务书、开题报告里的题目名称必须和系统里一致一个字符都别差。1.2 系统角色与核心业务流程这个系统一共三个角色很清晰学生、教师、管理员。别急着加第四个角色能把这三角色讲透就已经完整了。每个角色的核心用例必须画清楚这是答辩时最常被问的部分。学生端的核心操作是登录、浏览课程列表、按条件搜索课程、选课、退课、查看已选课程、查看成绩。这里有个容易被忽略的点学生查看课程列表时要能直观看到“还剩几个名额”而不是点进详情才发现选不了。教师端的核心操作是登录、维护自己开设的课程增加、修改、删除、查看选了自己课程的学生名单、录入成绩。需要注意教师只能操作自己的课程这条权限边界必须在后端校验不能只靠前端隐藏按钮。管理员端的核心操作是维护学生账号和教师账号、维护院系或班级信息、管理课程归属、设置选课时间窗口、查看统计信息。管理员的本质是做“数据治理”他不管具体选课这件事只管选课的环境准备。业务流程串起来是管理员在学期开始前录入课程、分配教师学生在规定时间窗口内登录选課选课时系统检查课程是否存在、是否在选课时间内、名额是否已满、是否与已有课程时间冲突通过后写入选课记录、扣减名额教师在选课结束后查看名单、录入成绩学生查看成绩。这个流程的每个环节都要对应到具体的表和接口一张表负责一块数据不要混在一起。1.3 技术栈选型SpringBoot为什么是对的选择这个课题限定了SpringBoot其实是个务实的选择。和传统SSM相比SpringBoot最大的优势是内置Tomcat、自动配置、起步依赖你写完一个接口不需要去配一堆XML就能跑起来。对于毕设来说减少环境层面的变量、把精力放在业务逻辑上比什么都重要。版本问题必须先说网络上能搜到的大量教程都是SpringBoot 2.7.x时代的代码用的是JDK8和javax命名空间而现在官方推荐SpringBoot 3.x强制JDK17包名从javax.servlet改成了jakarta.servlet你从老教程复制的代码会大面积报红。我的建议是选SpringBoot 2.7.182.x的最终版本配JDK8或JDK11教程兼容性最好答辩现场也不会出幺蛾子。如果老师指定必须用3.x那你就老老实实接受jakarta这个事实所有从网上复制的代码都要检查import。持久层框架二选一MyBatis-Plus或Spring Data JPA。我推荐MyBatis-Plus原因有三个——不用写繁琐的CRUD代码、有现成的分页插件、有TableField自动填充时间。JPA也不是不能用但懒加载和JSON序列化那堆坑对毕设来说纯属给自己增加工作量。前端如果只求稳定用服务端模板渲染比如Thymeleaf也未尝不可但如果答辩老师比较在意“系统设计”我建议用Vue3 Element Plus做一个简单的前后端分离页面这是现在的常规搭配。前后端分离意味着你要处理跨域和联调问题这在第5章里我会单独说。至于消息队列、微服务、分布式锁这个系统完全用不上。你可以在论文的理论基础部分提一句“本系统采用单体架构选课并发控制基于数据库行级锁实现在本科阶段的数据规模下已满足需求”反而显得你懂取舍。2. 数据库设计与核心表结构2.1 五张核心表的设计思路这个系统的表设计不复杂但每张表都有它的用途别指望用一张大表把所有东西装下。我最终用的结构是五张表加一张可选的学期表学生表studentid主键自增student_no学号唯一索引name姓名password密码存MD5或BCrypt加密后的值class_name班级major专业create_time创建时间教师表teacherid主键teacher_no工号唯一索引name姓名password密码title职称讲师/副教授/教授dept所属学院课程表courseid主键course_no课程编号唯一name课程名credit学分total_hours总课时teacher_id外键关联教师capacity容量最大可选人数selected_count已选人数class_week上课周次比如1-16周class_time上课时间周几第几节class_place上课地点semester开课学期status课程状态0未开放1开放中2已结束create_time、update_time选课表course_selectionid主键student_id外键关联学生course_id外键关联课程select_time选课时间status状态0正常1退课score成绩默认空教师录入后填充唯一约束(student_id, course_id)管理员表adminid主键username用户名唯一password密码课程表里的selected_count是我刻意做的冗余字段。你可能想直接SELECT COUNT(*) FROM course_selection WHERE course_id ?不就行了行但成本高。每次选课都要实时计算选课高峰期一次列表页就要对每门课跑一次COUNT性能不划算。而且后面做并发控制时selected_count可以用来做“原子扣减”比先查再更新靠谱得多。2.2 选课表的唯一约束与状态设计选课表是整个系统的核心它的设计直接决定业务逻辑怎么写。(student_id, course_id)上的唯一索引保证了一个学生同一门课只能产生一条选课记录。这个约束必须在数据库层面建不能只靠Java代码判断因为并发请求同时进来时两个线程可能同时读到“没有记录”然后都插入成功——数据库唯一索引兜底才能确保不会出现重复选课。status字段也很关键。有人问退课为什么不直接删除记录如果你删了那教师端“查看选课学生名单”就只能看到当前在选的学生看不到历史谁退过课无从追溯。而且成绩字段一旦录入过删了就啥都没了。所以我的做法是退课不删行只把status置为1学生在“已选课程”里看到的是status0的记录教师名单里也默认排除掉status1的记录。这样既保留了业务痕迹又方便回滚处理。2.3 时间冲突检测的数据支撑学生选课时必须判断“这门课我时间上能不能上”也就是不能选两门上课时间重叠的课。这个判断依赖课程表里的class_time字段。class_time具体怎么存我建议不要存一个字符串文本给程序解析而是拆成结构化字段class_day1-7代表周一到周日、start_slot开始节次比如第3节、end_slot结束节次比如第4节。判断冲突时先查这个学生所有status0的选课记录拿到对应课程的class_day, start_slot, end_slot再和待选课程比较如果星期相同且待选课程.start_slot 已选课程.end_slot AND 待选课程.end_slot 已选课程.start_slot就说明重叠了。这个判断逻辑用Java写起来很简单就是区间重叠判断。有个隐藏坑跨学期。课程表里有semester字段判断冲突时一定要先过滤同一个学期否则上学期和下学期的课时间重合会被误判成冲突。这个细节我在初版代码里漏了答辩前自测时才抓到很有代表性。3. SpringBoot项目搭建与关键配置3.1 项目骨架与目录结构推荐用Spring Initializr也就是start.spring.io生成基础项目然后按业务分包而不是把所有类堆在默认包下。我的目录结构大致是src/main/java/com/example/course/ ├── CourseApplication.java ├── controller/ // 接口层StudentController, TeacherController, AdminController, AuthController ├── service/ // 接口定义 ├── service/impl/ // 实现类 ├── mapper/ // MyBatis-Plus的Mapper接口 ├── entity/ // 实体类 ├── dto/ // 请求/响应对象比如选课请求DTO ├── config/ // 配置类CORS、拦截器、MyBatis-Plus分页 ├── common/ // 统一返回结果Result、状态码枚举、异常处理 └── utils/ // JWT工具类等common/ResultT这个统一返回类值得多写两句。它会让前端联调省心很多结构无非是code, message, data三个字段成功返回code200失败返回明确的业务错误码。比如选课失败、课容量满了、不在选课时间内这些情况不能抛一个500让前端收到个看不懂的报错要返回code400 message该课程已满员前端直接弹这个message就行。所有Controller都返回Result是前后端分离项目的基本素养。3.2 配置文件里的高频坑application.yml是SpringBoot里最容易踩坑的地方。我把自己常用的配置贴出来注释里写清楚为什么server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/course_selection?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 servlet: multipart: max-file-size: 10MB mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: your-secret-key expireHours: 12serverTimezoneAsia/Shanghai这个参数必须有否则MySQL连接时会因为时区差异报错或者时间字段莫名其妙多出8小时。map-underscore-to-camel-case开启后数据库的selected_count才能自动映射到Java的selectedCount少写一堆TableField。还有个容易踩的坑项目里讲义和博客里常见的spring.jpa.open-in-view警告。如果你用的是MyBatis-Plus没这回事用JPA的话启动日志里会冒出一句spring.jpa.open-in-view is enabled虽说只是警告不管也能跑但你有空顺手在配置里加一句spring.jpa.open-in-view: false日志干净也避免了懒加载异常。3.3 登录认证与跨域配置毕设级别的登录认证我推荐用JWT而不是Session。原因很简单前后端分离时Session天然有跨域和共享问题——后端一重启前端所有人的登录态全没了答辩现场巨尴尬。JWT的思路是用户登录成功后后端签发一个带过期时间的Token串返回给前端前端之后每次请求都带在请求头里后端用拦截器统一校验。加一个AuthInterceptor路径拦截规则放行/api/auth/login、/api/course/list和/api/course/detail其余需要登录。再按角色做权限判断比如/api/student/**只有学生角色能访问/api/teacher/**只有教师角色能访问。这里我踩过一个坑拦截器里解析完Token后要把当前用户ID放到ThreadLocal或请求域里方便后面的Service层拿。我一开始忘记传导致接口里到处重新解析一遍Token代码又丑又慢。跨域配置也别忘Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)和allowedOriginPatterns(*)要配套用用旧的allowedOrigins(*)会和allowCredentials(true)冲突浏览器直接拦截。这个坑也很有代表性前端页面死活收不到响应F12一看全是CORS错误。4. 核心业务逻辑实现4.1 选课操作的并发安全实现选课是这个系统里技术含量最高的一步。让我们先明确问题50个学生同时抢一门只剩10个名额的课如果代码写成“先查剩余名额足够就插入记录再把名额减一”在高并发下会发生什么两个请求同时读到selected_count40都判断“还够”都执行插入都更新selected_count41——最后结果是有两个学生选上了这仅有10个名额的课超卖。这不是理论问题演示时用JMeter发100个并发请求就能复现。解决方案是用一条原子SQL把“校验容量”和“扣减名额”合并Transactional(rollbackFor Exception.class) public ResultString selectCourse(Long studentId, Long courseId) { Course course courseMapper.selectById(courseId); if (course null) { return Result.error(课程不存在); } // 校验选课时间窗口 LocalDateTime now LocalDateTime.now(); if (now.isBefore(course.getSelectStartTime()) || now.isAfter(course.getSelectEndTime())) { return Result.error(不在选课时间内); } // 校验是否已选 Long count courseSelectionMapper.selectCount( new LambdaQueryWrapperCourseSelection() .eq(CourseSelection::getStudentId, studentId) .eq(CourseSelection::getCourseId, courseId) .eq(CourseSelection::getStatus, 0)); if (count 0) { return Result.error(不能重复选课); } // 时间冲突检测 ListCourseSelection selectedList courseSelectionMapper.selectList( new LambdaQueryWrapperCourseSelection() .eq(CourseSelection::getStudentId, studentId) .eq(CourseSelection::getStatus, 0)); ListCourse selectedCourses courseMapper.selectBatchIds( selectedList.stream().map(CourseSelection::getCourseId).collect(Collectors.toList())); boolean conflict hasConflict(course, selectedCourses); if (conflict) { return Result.error(与已选课程上课时间冲突); } // 原子扣减名额充足才更新成功 int updated courseMapper.reduceStock(courseId); if (updated 0) { return Result.error(课程已满员); } // 插入选课记录 CourseSelection selection new CourseSelection(); selection.setStudentId(studentId); selection.setCourseId(courseId); selection.setStatus(0); selection.setSelectTime(LocalDateTime.now()); courseSelectionMapper.insert(selection); return Result.success(选课成功); }其中reduceStock对应的SQL是UPDATE course SET selected_count selected_count 1 WHERE id #{courseId} AND selected_count capacity这一步是关键。UPDATE本身会锁住这行记录多个请求同时进来时它们会排队执行只有第一个请求能命中selected_count capacity条件后续请求的updated受影响行数都为0于是直接返回“课程已满员”。从根上解决了超卖问题不需要分布式锁不需要悲观锁整表锁死一个精准的UPDATE就够毕设用了。有个细节要抠先把reduceStock放在插入记录之前也就是“先占名额再写记录”。如果反过来先插入再扣减万一扣减失败选课记录已经写进去了还得想办法回滚或删除事务虽能回滚但逻辑绕了一圈。先扣后写更符合直觉名额占到了记录才成立。4.2 退课与名额释放的联动处理退课看起来是选课的逆操作但也有自己要注意的地方。我的做法是前端的“退课”按钮后端收到请求后在同一个事务里完成两件事——把选课记录的status改成1然后把course.selected_count减一。Transactional(rollbackFor Exception.class) public ResultString withdrawCourse(Long studentId, Long courseId) { // 查询有效的选课记录 CourseSelection selection courseSelectionMapper.selectOne( new LambdaQueryWrapperCourseSelection() .eq(CourseSelection::getStudentId, studentId) .eq(CourseSelection::getCourseId, courseId) .eq(CourseSelection::getStatus, 0)); if (selection null) { return Result.error(未找到有效选课记录); } // 如果已经录入成绩不允许退课 if (selection.getScore() ! null) { return Result.error(该课程已录入成绩无法退课); } selection.setStatus(1); courseSelectionMapper.updateById(selection); courseMapper.increaseStock(courseId); return Result.success(退课成功); }increaseStock对应的SQL是UPDATE course SET selected_count selected_count - 1 WHERE id #{courseId}直接把选课系统里最头疼的“名额释放”做干净了。退课之后释放的名额立刻能被其他学生看到并选上不用等管理员手动刷新。后面我还加了一层保护一旦教师录入成绩score字段非空退课接口就拒绝。这个业务规则的背后是现实逻辑——学期都结束了总不能让一个学生因退课而失去已获得的成绩记录。4.3 教师端课程管理与成绩录入教师端本质上是“管理自己的课程”。课程新增、修改、删除时一定要加上“课程归属校验”——教师只能操作teacher_id等于自己ID的课程。这个校验放到Service层不要只靠Controller里的一句话。教师端列表页有个接口很值得注意“查看选课学生名单”。这个接口要多表联查course_selection关联student表查学生姓名、学号、班级同时过滤掉status1的退课记录只返回当前还在选的学生。SQL大概是SELECT s.student_no, s.name, s.class_name, cs.select_time, cs.score FROM course_selection cs LEFT JOIN student s ON cs.student_id s.id WHERE cs.course_id #{courseId} AND cs.status 0 ORDER BY cs.select_time ASC成绩录入我建议用批量接口教师在前端录入整页成绩后一次性提交一个ListScoreDTO每个DTO里包含studentId和score后端循环更新同时校验分数范围0到100。成绩提交后课程状态可以设为已结束防止学生再退课——这段逻辑跟前面退课保护是呼应的。4.4 定时任务在选课系统里的正确用法选课系统天然需要“选课窗口”的概念管理员设置了select_start_time和select_end_time到点了系统自动切换状态。但注意选课接口里的时间判断不能依赖定时任务去控制定时任务只是锦上添花的状态维护。为什么定时任务有延迟万一服务重启了、任务还没跑窗口开了但学生选不了就尴尬了。所以正确姿势是选课接口内部每次请求都以course.select_start_time和select_end_time做实时判断这在前面的选课代码里已经体现。定时任务Scheduled能做的是每天凌晨跑一次把选课时间已截止的课程状态批量改成2已结束方便前端列表页做展示优化。一个很简单的cron表达式Scheduled(cron 0 0 1 * * ?) public void updateCourseStatus() { courseMapper.updateExpiredCourseStatus(); }这个任务不影响核心选课逻辑即使挂了也不影响当天选课因为最终判断都在接口里。把“实时校验”和“定时维护”分开是设计上更稳的思路。5. 常见问题与排查实录5.1 SpringBoot版本太高代码突然全红了这是今年做毕设的同学遇到最多的一个问题没有之一。老教程里import javax.servlet.*到了SpringBoot 3.x全变成了import jakarta.servlet.*你从网上复制的代码一粘贴满屏红X。再一个是JDK版本要求SpringBoot 3.x强制JDK17电脑上装的是JDK8启动直接报UnsupportedClassVersionError。排查思路很简单看一眼你的pom.xml里spring-boot-starter-parent的版本号如果是3.x开头那就要么主动降级到2.7.x要么把所有javax改成jakarta。降级方案我前面已经说了——2.7.18配JDK8最稳别犹豫。还有个冷门坑Spring Boot 3.x里spring.factories已经废弃很多自定义自动配置类会失效。热词里那条“springboot 自定义自动配置”如果你只是为了毕设完全用不上别去深挖除非你论文写到了“自动配置原理”要举例子那另说。5.2 并发选课超卖现象很直白课程容量30人选课结束后一看选上的人数变成35。上一条我已经给了原子更新的解法这里再补充两个排查细节。一是你写完reduceStock后要主动去验证。用JMeter压测或者自己写个多线程脚本模拟100个并发请求选同一门容量10的课跑完看数据库里选课记录是不是正好10条、selected_count是不是10。如果没压测过别觉得自己代码没问题并发问题肉眼是看不出来的。二是注意事务粒度。Transactional加在方法上只有方法内所有数据库操作都在同一个事务里时才安全。如果你把选课逻辑拆成好几个方法事务注解没加到入口方法上就会出现“插入成功了扣名额失败了”这种半成功状态。5.3 前后端跨域和联调问题前端用Vue3本地跑在localhost:5173后端跑在localhost:8080端口不一样就触发浏览器跨域。现象是前端发请求后控制台报CORS error或者has been blocked by CORS policy。解法上一条已经给了CorsConfig配置类注意allowedOriginPatterns的用法。还有个备选方案不用后端配跨域而是让前端在vite.config.js里配置代理让前端请求走/api开头由Vite代理转发到localhost:8080。两种方案都行但我更推荐后端统一配CORS因为答辩时很可能不用Vite启动前端直接build出来的静态文件部署在Nginx代理就不生效了。5.4 时间格式化与时区错乱典型症状前端看到的时间比数据库时间多8小时或少8小时。原因基本是jdbc连接串没配serverTimezone或者后端返回给前端的JSON时间没有统一格式。解决方案两件事第一jdbc:mysql://...serverTimezoneAsia/Shanghai第二实体类时间字段加注解JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone Asia/Shanghai) private LocalDateTime selectTime;LocalDateTime本身不带时区概念如果后端不指定格式Jackson默认序列化成数组格式前端拿到一堆数字完全没法展示。加上JsonFormat后在接口文档里也直观。5.5 高频小问题速查表我整理了毕设过程中最后阶段最常遇到的小问题直接列一个表方便你对着排查。症状原因解决办法端口被占用启动报Port 8080 was already in use上一个程序没停干净换端口或netstat -ano | findstr 8080杀掉对应进程SpringBoot启动成功但访问接口404请求路径没拼对Controller没加RestController核对RequestMapping路径确认类上有RestController数据库查询出现Table doesnt exist建表SQL没执行到目标库确认连接的是同一个库用SHOW TABLES检查Maven依赖下载慢或失败默认中央仓库网络不稳换阿里云镜像仓库重试一遍Lombok注解不生效getter/setter找不到IDE没装Lombok插件或没开注解处理装插件IDEA开启Enable annotation processing登录后访问其他接口提示未登录Token没传或解析失败检查前端请求拦截器有没有带Authorization头检查拦截器放行的路径是否配错MyBatis-Plus分页查询不生效分页插件没注册加MybatisPlusInterceptor并注册PaginationInnerInterceptor改了代码热部署不生效没引入devtools或没配自动重启引入spring-boot-devtools配合IDE自动构建这些坑如果说有什么共同经验那就是遇到问题第一步不是改代码而是看日志。SpringBoot启动失败时控制台最后几行报错里写的几乎都是真正的根因应用能启动但功能不对时application.yml里那句log-impl: StdOutImpl会把每条SQL打到控制台数据和逻辑一眼就能对比出来。写在最后的几句实话做完这个系统我自己最大的体会是毕设或练手项目最迷人的不是把技术名词堆得多高而是把每个细节做闭环。选课按钮点下去名额能减、记录能查、冲突能拦、重复能挡这一条链路通顺了比引入十个新技术更值钱。手里项目如果还想扩展我倒有几个顺手的方向前端用Vue3重构一下页面或者用Redis缓存课程列表来扛住选课高峰期再或者给教师端加一个导出Excel学生名单的功能都是半天一天的活但对答辩的完整度提升明显。最后啰嗦一句关键提示数据库的初始化SQL和演示前的数据重置脚本一定要留好答辩演示之前把所有数据清掉重新导入一遍你会上台讲得更从容。