简介面向计算机专业学生、毕业设计者及教务管理系统开发者这是一份基于Java的自助选课系统设计与实现的毕业设计论文含源码内容。针对高校传统选课方式效率低、信息不透明、过程易出错等问题系统采用MVC模式与B/S架构综合利用Java语言和MySQL数据库实现了用户管理、课程管理、课程推荐、选课管理、班级管理、教师信息管理及公告管理等功能。资源为单个docx文档大小7.03MB正文完整涵盖绪论、关键技术、需求分析、总体设计、数据库设计、系统实现与测试等章节并穿插核心代码和设计思路能够清晰还原从技术选型到功能落地的完整开发流程。目前已有35人学习适合作为毕业设计参考、课程设计模板或教务系统入门学习资料对需要快速理解选课系统架构与实现细节的读者很有帮助。1. 自助选课系统是什么先看懂这个毕设题在解决什么每年选课季校园里最热闹的不是食堂而是教务系统。几百个人同时点选课页面转圈转成菊花有人选上了有人没选上还有人选了同一时间的两门课最后课表自己打架。基于 Java 的自助选课系统做的就是把这套流程从「排队办理」变成「自助操作」学生自己登录、自己浏览课程、自己选课退课后台用事务和约束保证名额不超、时间不冲突。这套系统适合三类人正在做 Java 毕业设计、需要论文加可运行源码的学生想给学院搭一个内部教学管理小系统的老师以及想找一套「需求分析到系统测试」完整范式来参考的从业者。下面我会按一个完整工程的设计顺序把技术选型、库表设计、核心代码、论文写法到并发验证全部讲一遍。2. 技术选型与数据模型为什么建议 Spring Boot MyBatis表怎么建才经得起答辩2.1 技术栈取舍标题只写了 Java框架反而好选这几年基于 Spring Boot Vue 的各类管理系统设计与实现选题越来越多非遗展厅、商品管理、图书借阅、就业推荐原理都相通。自助选课系统也走这条路是最稳妥的Spring Boot 负责后端接口MyBatis 负责持久层前端可以用 Vue 做前后端分离也可以直接用 Thymeleaf 渲染页面。我的建议是如果你的重点在论文和业务逻辑不要在前端上花太多时间用服务端渲染把选课流程跑通比维护一堆跨域和鉴权代码省心得多。纯 Servlet/JSP 的老方案不是不行但现在再这么起项目答辩时容易被问「为什么不用 Spring Boot」。Spring Boot 把 Tomcat 内嵌进了启动器双击一个 jar 包就能跑演示环境不需要单独装服务器这也是课设演示最容易被忽略却最致命的需求。MyBatis 比 JPA 好在 SQL 自己掌控选课名额扣减这种对 SQL 有精确要求的场景写原生 SQL 心里最踏实。方案适合场景主要成本Servlet JSP课程要求指定传统 Java EE配置多、演示部署麻烦Spring Boot Thymeleaf MyBatis大多数毕设与课设快速跑通前后端不分离页面不够炫Spring Boot Vue MyBatis想演示前后端分离、加分项需要处理跨域、鉴权、打包一句话目标是毕业答辩就选第二行目标是简历加分才考虑第三行。2.2 核心表结构六张表撑起整个选课业务选课系统的数据库设计最忌讳一上来就堆十几张表。我一般从学生、教师、课程、选课记录这四张主表起步再加课程时间表和公告表一共六张。其中最重要的三张表是课程表、选课记录表和课程时间表大部分业务规则都落在它们上面。下面这段 SQL 是核心建表脚本可以直接跑在 MySQL 5.7 或 8.0 上。-- 学生表 CREATE TABLE t_student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, password VARCHAR(64) NOT NULL COMMENT MD5或BCrypt加密后的密码, real_name VARCHAR(30) NOT NULL, class_name VARCHAR(50) COMMENT 班级, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 课程表 CREATE TABLE t_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE COMMENT 课程编号, course_name VARCHAR(50) NOT NULL, teacher_id BIGINT NOT NULL COMMENT 授课教师ID, capacity INT NOT NULL DEFAULT 60 COMMENT 课程容量, selected_count INT NOT NULL DEFAULT 0 COMMENT 当前已选人数, credit DECIMAL(3,1) NOT NULL DEFAULT 2.0 COMMENT 学分, term VARCHAR(20) NOT NULL COMMENT 开课学期如2024-2025-1, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可选 0不可选 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 选课记录表 CREATE TABLE t_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, term VARCHAR(20) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1已选 2已退, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id, term) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 课程时间表 CREATE TABLE t_course_time ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, day_of_week TINYINT NOT NULL COMMENT 1-7 周一到周日, start_section TINYINT NOT NULL COMMENT 开始节次, end_section TINYINT NOT NULL COMMENT 结束节次, start_week INT NOT NULL COMMENT 起始周, end_week INT NOT NULL COMMENT 结束周, UNIQUE KEY uk_course_time (course_id, day_of_week, start_section) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段设计里有三个容易被问倒的点。第一selected_count是冗余字段它存的就是count(*) from t_selection where course_id?的缓存每次选课成功后在这个字段上加一这属于典型的用空间换查询性能。第二选课记录必须加联合唯一索引uk_student_course没有它重复点击选课就可能插进去两条记录这是防超卖的兜底防线。第三课程时间表单独拆出来是因为一门课可能每周有多条上课时间比如周一 3-4 节又周四 5-6 节拆成多行才能支持后面做时间冲突检测。2.3 功能模块划分直接从用例图拆出代码包论文里的用例图不是画出来好看的它应该直接对应代码包结构。自助选课系统的角色有三个学生、教师、管理员。学生端的用例是登录、浏览课程、选课、退课、查看已选课程教师端是查看选课名单管理员端是维护课程、开关选课窗口、查看选课统计。我一般会按这个包结构组织代码com.example.selection ├── controller # Web层接收请求 ├── service # 业务层选课核心逻辑 ├── mapper # MyBatis持久层接口 ├── entity # 数据库实体类 ├── dto # 接口出入参对象 └── config # 配置类如定时任务这样分论文里的「系统设计」章节每一小节都能对应到具体包和类答辩时老师问「你这个选课逻辑在哪一层写的」你直接打开 service 包给他看比在纸上比划半天有说服力。模块拆分到这里下一步就要落到最核心的选课方法上了。3. 选课核心链路事务、并发控制和冲突检测的 Java 实现3.1 从 Controller 到 Service请求入口的参数校验选课这个动作虽然业务简单但它是在高并发下执行的所以每一层都有该做的事。Controller 只做参数整理和粗校验比如 studentId 和 courseId 是否为空Service 做业务校验和名额扣减Mapper 做最终的数据库写入。这样分层的意义在于后续如果要把选课逻辑扩展成「预选抽签」或者「选课退课补选」改动面是可控的。RestController RequestMapping(/api/selection) public class SelectionController { PostMapping(/select) public ResultVoid select(RequestParam Long studentId, RequestParam Long courseId) { // 基础校验参数不能为空 if (studentId null || courseId null) { return Result.fail(学生ID和课程ID不能为空); } selectionService.selectCourse(studentId, courseId); return Result.ok(); } }控制层不写业务逻辑这是最基础的原则。选课失败时的异常类型、错误提示文案都应该由 Service 抛出来Controller 只负责捕获并转发。这样后面接前端时不管页面是 Vue 还是 JSP拿到的都是统一的 JSON 结构不会出现「服务端报 500前端一脸懵」的情况。3.2 核心选课方法为什么 synchronized 不能只锁方法选课方法最核心的矛盾是课程容量 60 人但可能有 200 个请求同时进来谁能选上、谁选不上必须有一个不会出错的判定顺序。我见过很多人一上来就写public synchronized Result selectCourse(...)然后在方法里查容量、判断、插入。单机单实例下这么写确实行只要线程都进了同一个 JVMsynchronized 就能挡住并发。但问题在于synchronized 锁的是当前进程如果系统部署了两个实例负载均衡把请求分发到两台机器上两个进程的锁互相不认识超卖立刻发生。更稳的写法是让数据库来仲裁。利用UPDATE语句的原子性一次性完成「判断容量 扣减名额」再把选课记录写进去。如下Override Transactional(rollbackFor Exception.class) public void selectCourse(Long studentId, Long courseId) { // 1. 课程是否存在且可选 Course course courseMapper.findById(courseId); if (course null || course.getStatus() ! 1) { throw new BizException(课程不存在或已关闭选课); } // 2. 原子扣减名额只有扣减成功影响行数为1才说明有名额 // 这条 UPDATE 在数据库层是串行化的天然避免超卖 int rows courseMapper.decreaseStock(courseId); if (rows 0) { throw new BizException(课程名额已满); } // 3. 防重复选课已有有效选课记录则抛异常 Selection exist selectionMapper.findActive(studentId, courseId, course.getTerm()); if (exist ! null) { throw new BizException(你已经选过这门课了); } // 4. 时间冲突检测 int conflictCount selectionMapper.countTimeConflict( studentId, course.getTerm(), courseId); if (conflictCount 0) { throw new BizException(选课失败与已选课程时间冲突); } // 5. 写入选课记录 Selection selection new Selection(); selection.setStudentId(studentId); selection.setCourseId(courseId); selection.setTerm(course.getTerm()); selection.setStatus(1); selectionMapper.insert(selection); }对应的 Mapper 里扣减名额的 SQL 是这一条UPDATE t_course SET selected_count selected_count 1 WHERE id #{courseId} AND selected_count capacity这里最关键的是selected_count capacity这个条件。两个并发事务同时执行这条 UPDATE数据库的行锁会让它们排队执行第一个执行成功影响一行第二个再执行时容量已经满了影响行数为 0于是抛异常。这个方法利用了数据库的原子性和行锁比 synchronized 可靠得多。但要注意Transactional的默认回滚条件只针对 RuntimeException所以我在注解里写了rollbackFor Exception.class保证检查型异常也能触发回滚。decreaseStock和insert必须落在同一个事务里否则会出现「名额扣了记录没插上」的中间状态。3.3 时间冲突检测用 SQL 判断课表重叠时间冲突是选课系统独有的规则普通商品秒杀系统不需要考虑这个。冲突的定义是同一学期的两门课上课时间在星期几、节次区间、起始周这三个维度上重叠。比如你已选了周一 3-4 节第 1-16 周的课现在要选的另一门课也是周一 3-4 节第 1-16 周那就冲突了。SELECT COUNT(*) FROM t_course_time ct WHERE ct.course_id IN ( SELECT s.course_id FROM t_selection s WHERE s.student_id #{studentId} AND s.status 1 AND s.term #{term} ) AND ct.day_of_week #{dayOfWeek} AND ct.start_section #{endSection} AND ct.end_section #{startSection} AND ct.start_week #{endWeek} AND ct.end_week #{startWeek}这个查询的时间复杂度不高因为一个学生一个学期的已选课程通常只有十几门课程时间记录也不多。判断条件里两段时间有交集等价于「开始小于对方结束 且 结束大于对方开始」这个逻辑写错了就会漏判值得单独写个测试用例覆盖。注意这个查询是放在事务里执行的但 MySQL 默认的 REPEATABLE READ 隔离级别下这个查询只加了普通快照读不会锁住别人的插入。真正可靠的防线是选课记录写入后的联合唯一索引和业务状态位冲突检测更多是提升用户体验、提前拦截而不是绝对防线。3.4 退课与容量释放注意事务一致性和状态标记退课对应的业务是选课的逆向操作有两个并行的动作把选课记录的状态从「已选」改成「已退」同时把课程表的selected_count减一。这里最容易出问题是退课不做幂等用户重复点击退课第一次成功第二次又把名额减了一次导致已选人数变成负数。Transactional(rollbackFor Exception.class) public void dropCourse(Long studentId, Long courseId) { // 只更新状态为1已选的记录避免重复退课 int rows selectionMapper.markDropped(studentId, courseId); if (rows 0) { throw new BizException(退课失败选课记录不存在或已退课); } // 释放名额 courseMapper.increaseStock(courseId); }关键在第一步UPDATE t_selection SET status 2 WHERE student_id ? AND course_id ? AND status 1rows为 0 说明这条记录要么不存在要么已经退过课了。这样重复点击最多只成功一次库存不会重复释放。提示markDropped和increaseStock必须在同一个事务里否则退课成功后名额没恢复系统里会出现「空名额不让选」的诡异问题几百个学生同时找管理员这个锅背不起。4. 论文与源码的对应关系把系统做成一篇能答辩的毕业设计4.1 论文标准章节与源码证据的映射表论文和源码不是两样东西而是一枚硬币的两面。评审老师拿到你的论文一定会打开源码工程对照着看论文里写了什么功能源码里就要能找到对应的类和代码。最怕的是论文写得天花乱坠代码工程里只有一个 hello world 级别的页面那基本一票否决。论文章节章节内容对应的源码证据摘要系统解决的问题、实现技术、测试结论工程 README 和启动类绪论选题背景、国内外现状、研究意义无代码但要引用真实课设/教务痛点的数据需求分析用例图、用例描述、非功能需求模块包结构、角色权限相关代码系统设计架构图、E-R 图、数据库表设计、接口设计建表 SQL、entity 包、mapper 接口系统实现核心功能页面截图 关键代码片段controller/service/mapper 三层实现系统测试功能测试用例表、性能测试结果、缺陷记录测试数据、JMeter 脚本截图这张表的妙处在于写论文之前先把「每一章要用源码里的什么来证明」列出来写的时候就不会开天窗。我见过不少同学论文写到最后发现「系统实现」这一章没内容可写就是因为前面边做边写关键功能没截图。正确顺序是先跑通系统再按论文的章节结构倒推需要什么证据最后补测试和截图。4.2 造种子数据与并发测试数据让论文里的数字站得住一篇合格的系统实现论文至少要有功能测试表和性能测试表。功能测试表要覆盖选课、退课、冲突检测、名额满等场景性能测试表通常要给出并发 50、100、200 用户下的响应时间和成功率。问题来了系统里只有 5 个学生、3 门课怎么压测 200 并发解法是造种子数据。不要用手点页面造数据效率太低直接用 SQL 批量插入。比如用下面的语句一次性生成 1000 个学生账号INSERT INTO t_student (student_no, password, real_name, class_name) SELECT CONCAT(2024, LPAD(n.n, 4, 0)) AS student_no, e10adc3949ba59abbe56e057f20f883e AS password, CONCAT(学生, n.n) AS real_name, CONCAT(班级, n.n % 20) AS class_name FROM ( SELECT a.N b.N * 10 c.N * 100 1 AS n FROM (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a CROSS JOIN (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b CROSS JOIN (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) c ) n LIMIT 1000;这段 SQL 用了三张数字表交叉连接生成 1 到 1000 的序列再拼成学号和姓名。密码字段我写的是e10adc...这是123456的 MD5 值方便测试时统一登录。造完数据后导入 JMeter配置一个 HTTP 请求指向选课接口线程组设为 100 并发、循环 10 次就能收集到性能数据。测试结果表不要只贴「全部成功」真实的响应时间曲线总是有波动的成功率 100% 但 p95 响应时间明显抬升这恰恰说明系统在压力下出现排队把这张表写进论文反而是加分项。4.3 论文配图的三个硬要求ER 图、用例图、时序图毕业设计的评审老师看论文先翻图再读字。图的数量和质量基本决定了第一印象。自助选课系统最少需要三张图数据库 ER 图、系统用例图、选课时序图。ER 图要把 t_student、t_course、t_selection、t_course_time 的实体、属性和关系画清楚用例图要体现三个角色与各自权限边界时序图画的是「学生点击选课 - 前端请求 Controller - Service 扣名额 - 数据库返回结果 - 页面提示成功」这条完整链路。画图工具有很多PowerDesigner、Visio、draw.io 都行但要注意两点。第一图和代码必须一致ER 图里画的字段和建表 SQL 对不上是硬伤第二图不要用截图代替用绘图工具重画一遍保持线条干净、字体统一。时序图不用画得过深画到 Service 层调用 Mapper 就够了再往下画到数据库内部执行计划反而暴露你不太理解底层。5. 自助选课系统的避坑清单五条血泪经验与排查记录5.1 选课人数爆了超卖问题的两种典型表现现象课程容量设置 60 人选课结束后查t_course发现这门课选了 63 个人。原因只可能是两类一是代码里先查selected_count capacity再插入两个线程都通过了查询然后先后写入二是只加了 synchronized 锁方法但系统实际部署了多个实例。解决方法是把判断和扣减合成一条原子 UPDATE也就是第 3 章里的UPDATE t_course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity。这条 SQL 在 InnoDB 里会锁住目标行并发请求排队执行超卖从根上被堵死了。补充一个容易混淆的点有些同学会想到用 Redis 做减库存这在大流量秒杀系统里是对的但课设级的选课系统引入 Redis 会增加部署成本和论文复杂度。数据库的 UPDATE 原子操作已经够用选课峰值一般是几百并发MySQL 处理几百并发毫无压力。不要为了炫技硬上缓存组件答辩时被追问缓存与数据库一致性反而下不来台。5.2 前端疯狂点点点后端线程排队卡死现象选课页面开启后大量学生同时点选课浏览器端的加载圈一直转后台日志出现连接池等待超时。定位后发现是数据库连接池默认连接数太小。Spring Boot 2.x 默认使用 HikariCPmaximum-pool-size 默认是 10。也就是说同一时刻最多只有 10 个事务能拿到数据库连接其他请求都在等待。10 个连接并发并不小但选课事务里如果还混入了耗时的统计查询、公告列表查询连接就会被长时间占用。解决思路分两步。第一步按实际并发调整连接池配置文件中将spring.datasource.hikari.maximum-pool-size调到 30 到 50minimum-idle调到 10。第二步把非核心的查询接口和核心选课接口分开页面加载时的公告列表、课程列表走只读数据源或者直接用缓存。最怕的是把各种无关查询写进选课事务里事务里任何一环慢整条链路的连接占用时间都会变长。5.3 时间冲突没拦住数据库里出现同学期同时间的课现象学生同时选了两门时间重叠的课程论文里的冲突检测形同虚设。原因有两个层次。业务层冲突检测 SQL 的区间重叠判断条件写错了start_section endSection AND end_section startSection这个条件漏写了等号边界比如一门课是 3-4 节另一门是 4-5 节4 节这个边界重叠被漏掉。数据库层t_course_time表里的课程时间是按课程维度拆开的无法用唯一索引约束「一个学生同一时刻只能上一门课」所以冲突检测只能依赖业务代码。解决方法是先补正业务判断逻辑把边界条件都覆盖到同时给「星期几 开始节次 结束节次」这个组合加上数据库约束只约束同一门课自己的时间不能自重叠。另一种更彻底的做法是引入「时间段位图」存储把每周课程节次映射成字符串判断冲突时做字符串位运算但课设论文里写清楚规则判断即可不建议引入复杂存储。5.4 退课后名额没回来事务回滚被吞掉了现象管理员查看课程列表发现某门课只有 20 人选了但学生反馈选课时提示名额已满。查t_selection发现退课记录存在但t_course.selected_count没减下去。最常见的原因是退课的两个数据库操作不在同一个事务里或者 Service 方法上的Transactional没有生效。Spring 的Transactional默认只对 public 方法生效而且只在通过代理调用时生效——同类内部调用this.dropCourse()会直接绕过代理事务注解形同虚设。排查方法很简单打开 MySQL 的通用日志或者看事务里第二步操作是否执行。解决方法是把markDropped和increaseStock放到同一个 Service 方法里且由 Controller 调用不要由同类的其他方法直接调用。如果用了 MyBatis-Plus还要确认 Mapper 类是否被 Spring 容器管理否则注入进来的就是空对象方法调用根本没走到数据库。5.5 论文查重与源码一致性系统是新的论文像抄的现象答辩时老师随手翻源码发现论文里的关键代码和工程里的代码不是同一份论文里贴的截图和实际操作流程对不上。说白了论文是拼出来的工程是后来补的。这类问题不是技术问题是工作顺序问题。正确做法是先按第 4 章的表把论文每章需要的证据列出来然后回归源码逐一对照。论文里的每个功能描述都要能落到一个具体的 Controller 方法和页面操作上。查重的另一个坑是核心代码被原样贴到论文里连续几十行代码和网上教程雷同。代码在论文里不宜整段粘贴截取核心方法的关键片段用注释说明思路即可完整代码交给源码工程去体现。论文的正文价值在需求分析和系统设计部分的逻辑不在代码复述上。6. 从能用到经得起答辩并发压测手法与占座优化进阶系统跑通只是第一步经得起并发测试才算真的做完。我给你推荐一个最常用的验证组合JMeter 做压测配合数据库查询核对结果。JMeter 里创建线程组时线程数设 100、Ramp-up period 设为 1 秒也就是 1 秒内启动 100 个线程同时打选课接口循环次数可以设 5 次加一个 JSON 断言检查返回结果。压测结束后执行一条 SQL 核对每门课的selected_count是否等于有效的选课记录数SELECT c.id, c.course_name, c.selected_count, (SELECT COUNT(*) FROM t_selection s WHERE s.course_id c.id AND s.status 1) AS actual_count FROM t_course c WHERE c.selected_count ( SELECT COUNT(*) FROM t_selection s WHERE s.course_id c.id AND s.status 1 );这条 SQL 会把所有「库存数字和实际选课记录数不一致」的课程列出来。没有结果返回说明事务一致性没问题。我一般会在压测前先跑一遍压测后再跑一遍把两次结果截图放进论文的测试章节说服力比任何文字描述都强。进阶技巧是给选课加一个「预占名额 定时释放」机制用来解决学生选了课但不确认的问题。思路是选课分为两步先预占名额把课程表的selected_count加一同时写一条状态为待确认的选课记录学生必须在 N 分钟内确认超时后由定时任务扫描过期记录自动调用退课逻辑释放名额。实现上用 Spring 的Scheduled注解写一个轮询任务每 30 秒扫一次待确认记录把超过 30 分钟未确认的清理掉。这个功能论文里非常好写能体现你对真实业务场景的思考。最后提醒一句调试习惯系统出了奇怪问题先看日志而不是先改代码。把日志级别调到 DEBUG看 SQL 执行顺序往往一眼就能看出是事务没生效还是 SQL 条件写错了。我当年调试选课超卖问题连续三晚没想明白最后发现是多个 Service 方法互相调用导致事务代理失效从此养成了先查日志再动手的习惯。这套系统的技术方案讲到这里希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?