1. 网站选课系统的需求边界:到底要做哪些功能
一提起"网上选课系统",很多同学的第一反应就是"这不就是做一个课程列表加一个选课按钮吗?"真动手做的时候才发现,事情远没有这么简单。选课系统是典型的业务驱动型项目,核心难点不在某个单独功能上,而在多个业务规则叠加时的互相制约。所以开工之前,先把需求边界划清楚,比急着写代码重要得多。
1.1 三类用户的权限模型与核心操作
一个完整的选课系统,至少要覆盖三类角色:学生、教师、管理员。虽然都是登录之后操作,但各自的业务范围完全不同,权限设计一旦模糊,后面越写越乱。
管理员负责全局配置和兜底处理,包括:
- 基础数据维护:学生信息、教师信息、专业班级的导入(一般支持Excel批量导入,否则一个个录会累死);
- 课程管理:开设新课、设定课程属性(学分、总容量、上课时间、上课地点)、调整开课状态;
- 选课规则配置:选课时间窗口、选课门槛(比如部分课程限制专业方向)、退课截止时间;
- 选课结果管理:手动补选、强制退课、导出选课名单。
教师的逻辑相对简单,主要就是:
- 查看自己名下开设的课程;
- 查看每门课的选课学生名单,一般附带学生的专业、班级基本信息;
- 打印或导出名单,有的系统还允许教师录入成绩,不过如果只做选课环节,成绩功能可以砍掉,避免范围失控。
学生的操作逻辑是核心中的核心:
- 在选课开放时间内浏览可选课程列表;
- 选课时系统要完成一系列校验(后面详细讲);
- 选课成功后进入"我的课表",可以看到已选课程的时间是否冲突;
- 开放时间内允许退课,退课后容量恢复,其他同学可以继续选。
1.2 选课系统的业务规则:一句话拆出五个校验逻辑
很多毕业设计翻车,不是因为代码写不出来,而是因为业务规则没有想清楚。选课的最核心动作是"学生选择某一门课",而这一动作至少要过五道校验:
- 是否在选课时间窗口内——早一秒不行,晚一秒也不行,时间判断要用数据库服务器时间而不是客户端时间,否则改本机时间就能钻空子;
- 课程容量是否已满——已选人数不能超过总容量,这一步天然涉及并发问题,浏览器同时点选课按钮的场景必须考虑;
- 是否与已选课程时间冲突——一个学生同一时间段不能上两门课,这是选课系统最具业务特点的规则;
- 自己是否已选过这门课——重复提交要能拦截;
- 学分上限——很多学校会限定单学期选修学分上限,比如不超过30学分。
不用把这些校验全部堆在Service层写一遍,而是拆成独立的校验方法,每个方法只负责一项,顺序执行。这样做的好处是:任何一个规则变化(比如这学期突然规定某一门课只能本专业选),改动只涉及一个方法,不会影响其他逻辑。
之前我带过一个同学,他一开始把所有校验写在一个if嵌套里,后面加规则改逻辑时看得头皮发麻。后来按我建议拆分后,每个校验10行以内搞定,思路立刻清晰了。
2. 技术选型与数据库设计:选课系统的地基怎么打
2.1 技术栈怎么定:主流方案和轻量级选择
题目写的是"Java web",这给了一个比较宽泛的发挥空间。目前比较主流的是Spring Boot + MyBatis/MyBatis-Plus + MySQL这套组合。Spring Boot简化配置和部署,MyBatis-Plus让单表CRUD基本不用写SQL,可以把精力聚焦在业务逻辑(也就是上面那五道校验和事务处理)上,这部分才是毕业设计答辩时真正出彩的地方。
如果是学校课程设计要求用纯JSP + Servlet + JDBC,也不难,核心逻辑代码完全一样,只是把框架换成了更原始的写法,数据访问部分从Mapper接口换成JDBC工具类。所以这篇文章后面讲的核心逻辑代码,两种技术栈都能直接套用。
前端建议用JSP + Bootstrap或者Thymeleaf这种服务端渲染方案。选课系统本质上是一个管理系统,对交互要求不高,不一定要上Vue。如果你有精力,用Vue + Element UI做前后端分离也没什么问题,但会增加不少前期搭建成本,毕业设计时间紧张时并不划算。
2.2 核心表结构设计:五张表解决所有问题
数据库设计遵循"一个核心表 + 两个从属表 + 两个配置表"的思路,不需要刻意做复杂设计。
用户表(user):保存学生和教师的登录认证信息,字段包括主键id、用户名、密码(存MD5或BCrypt加密后的值)、角色(student/teacher/admin)、关联的角色ID。为什么不把学生和教师分开建表?因为登录认证逻辑是一样的,合并成一张表在登录时只查一次,后面通过角色字段分流到不同的业务表。这样设计虽然有一点点不符合纯范式,但实际开发中反而更顺手。
学生表(student)/ 教师表(teacher):存业务信息。学生表存学号、姓名、专业、班级;教师表存工号、姓名、职称。两张表通过user_id和user表关联。
课程表(course):这是信息量最大的一张表。核心字段有:
- course_code(课程编号)、course_name(课程名称);
- credit(学分)、total_capacity(总容量)、selected_count(已选人数);
- teacher_id(授课教师);
- semester(开课学期,比如"2024-2025-2");
- status(课程状态:0未开始选课,1选课中,2选课结束);
- course_time(上课时间,存储格式为"周几+第几节+单双周",如"周一第3-4节");
- location(上课地点)。
选课记录表(course_selection):记录学生选课事实,字段包括:主键id、student_id、course_id、selection_time(选课时间)、status(0正常,1已退课)。这张表的核心设计点在student_id和course_id上建立联合唯一索引,从数据库层面保证同一个学生只能选同一门课一次。
2.3 为什么课程表里要放selected_count字段
这个设计很多人会忽略或纠结:已选人数为什么存在课程表里,而不是每次选课记录count一下?原因是查询效率。选课列表页学生一进来就要看到每门课的剩余名额,如果每次都由count聚合算出来,数据库压力全在无谓的计算上。在course表里直接维护selected_count,查询时直接读字段,增加一个字段的代价换来的是列表页查询性能的成倍提升。
那退课时怎么办?事务里把selected_count减1就行。这个字段的变化必须和选课/退课操作处于同一个事务中,否则会出现人数错乱,这就是后面要讲的并发控制问题。很多做简单增删改查的同学意识不到这一点,结果一并发选课就翻车。
此外,还可以给course表加一个version字段(整数类型),这是乐观锁实现的关键,后面会在并发控制部分具体说明。
3. 选课核心流程的实现细节:事务、校验与并发控制
3.1 选课操作的执行顺序:先锁数据,再执行校验
选课不是简单地执行一条INSERT。在并发场景下,两个学生同时选同一门只剩一个名额的课,如果代码里不控制,结果就是两个人都选上了,超选了。解决这个问题最实用的办法是MySQL的悲观锁:在事务内先SELECT ... FOR UPDATE把这条课程记录锁住,再执行校验和更新。
具体流程是这样的(以Spring Boot + MyBatis为例):
- 开启事务;
- 执行
SELECT * FROM course WHERE id = #{courseId} FOR UPDATE,这一步会把当前课程记录行锁住,其他事务在更新这行数据时会阻塞等待; - 在内存中判断selected_count是否小于total_capacity;
- 判断时间冲突、重复选课、学分上限(这些读操作在锁内执行,保证数据一致);
- 通过全部校验后,执行
UPDATE course SET selected_count = selected_count + 1 WHERE id = #{courseId}; - 向course_selection表插入记录;
- 提交事务,释放锁。
注意这里的顺序不能乱。一定要先锁后查,也就是先FOR UPDATE,再判断容量。如果先查再锁,那就是经典的"检查后再更新"竞态条件,两个事务可能同时看到容量还有1,然后同时通过校验,最后双双插入成功。
3.2 Service层选课事务代码逐行拆解
选课逻辑放在Service层,事务注解直接打在公开方法上,下面是核心代码的关键部分,可以直接套用:
@Override @Transactional(rollbackFor = Exception.class) public boolean selectCourse(Integer studentId, Integer courseId) { // 1. 对课程记录加锁,阻塞其他选课事务对本行的操作 Course course = courseMapper.selectCourseForUpdate(courseId); if (course == null) { throw new BizException("课程不存在"); } // 2. 时间窗口校验 if (!isInSelectionPeriod(course)) { throw new BizException("当前不在选课时间范围内"); } // 3. 容量校验 if (course.getSelectedCount() >= course.getTotalCapacity()) { throw new BizException("课程容量已满"); } // 4. 重复选课校验:利用联合唯一索引兜底 CourseSelection existing = selectionMapper .selectByStudentAndCourse(studentId, courseId); if (existing != null && "0".equals(existing.getStatus())) { throw new BizException("您已选择过该课程"); } // 5. 时间冲突校验:查出学生所有有效选课记录,循环比对时间 List<CourseSelection> selections = selectionMapper .selectValidByStudent(studentId); for (CourseSelection selection : selections) { if (isTimeConflict(course.getCourseTime(), selection.getCourseTime())) { throw new BizException("与已选课程时间冲突"); } } // 6. 学分上限校验(如果有这个规则) int totalCredit = selections.size() == 0 ? 0 : selections.stream() .mapToInt(s -> courseMapper.selectById(s.getCourseId()).getCredit()) .sum(); if (totalCredit + course.getCredit() > MAX_CREDIT) { throw new BizException("当前学期学分已达上限"); } // 7. 更新课程已选人数 courseMapper.increaseSelectedCount(courseId); // 8. 插入选课记录 CourseSelection newSelection = new CourseSelection(); newSelection.setStudentId(studentId); newSelection.setCourseId(courseId); newSelection.setStatus("0"); newSelection.setSelectionTime(new Date()); selectionMapper.insert(newSelection); return true; }@Transactional(rollbackFor = Exception.class)这行是关键,它确保步骤7和8要么全部成功,要么全部回滚。如果不加rollbackFor,Spring默认只在RuntimeException时才回滚,而自定义的BizException如果继承的是Exception,就不会触发回滚,结果就是人数加了但选课记录没插进去,数据就脏了。这个坑我见过不少。
3.3 时间冲突检测怎么做
时间冲突是整个系统里最体现业务深度的地方。课程时间的存储建议用"星期几 + 起始节次 + 结束节次"的方式,比如周一3-4节存成:week=1, periodStart=3, periodEnd=4。然后用简单的区间重叠判断:
public boolean isTimeConflict(String existingTime, String newTime) { // 简化版:时间字符串格式约定为 "周几,开始节次,结束节次" String[] ex = existingTime.split(","); String[] ne = newTime.split(","); if (!ex[0].equals(ne[0])) { return false; } int exStart = Integer.parseInt(ex[1]); int exEnd = Integer.parseInt(ex[2]); int neStart = Integer.parseInt(ne[1]); int neEnd = Integer.parseInt(ne[2]); return !(neEnd < exStart || neStart > exEnd); }如果课程还涉及单双周(第1-16周单周上课),冲突判断就要再加一个weekType字段,判断条件变成"同一周次类型 + 同一天 + 区间重叠"三个条件同时满足才算冲突。这些规则不需要一次做完,先做基础版,再考虑扩展。
4. 前端页面组织与交互:从登录到课表的完整链路
4.1 页面清单和导航结构
页面不用多,但每一页都要对应一个明确的使用场景。建议按角色划分页面:
公共页面:
- login.jsp:登录页,用户名+密码,登录后按角色跳转到不同的首页;
- 公共的400/500错误页。
管理员页面:
- admin_course_list.jsp:课程管理列表,支持新增、编辑、上下架;
- admin_user_list.jsp:学生/教师信息管理,支持单个录入和批量导入;
- admin_selection_view.jsp:选课记录查询,可按课程或学生维度查看。
教师页面:
- teacher_course_list.jsp:我的课程列表;
- teacher_course_detail.jsp:课程详情,可查看选课学生名单,支持导出。
学生页面:
- student_course_list.jsp:可选课程列表,带筛选功能、剩余容量展示;
- student_selected_courses.jsp:已选课程列表,支持退课;
- student_timetable.jsp:课表视图,按周一到周日展示已选课程,这个页面建议直接用Java后端处理好数据,拼成二维表格输出,不用前端写复杂循环。
4.2 选课按钮的交互设计:避免重复提交
学生页面的"选课"按钮有一个很多毕设会忽略的细节:点击后要立刻禁用,等后端返回结果后再恢复。否则网络慢的时候,学生手一抖点了两下,虽然后端有联合唯一索引兜底,但第二次请求抛出的异常会显示一个红色错误提示,体验很糟糕。
前端做法是给按钮绑定一个标志位:
$('#selectBtn').click(function() { if ($(this).hasClass('disabled')) { return; } $(this).addClass('disabled').text('提交中...'); // 发起异步请求 });后端仍然保留联合唯一索引做最终防线,但前端减少无效请求更符合实际使用的体验预期。两边都做,才能既稳又顺。
4.3 退课操作的业务约束
退课不等于把记录delete掉,更推荐逻辑删除:把course_selection表里的记录status从0更新为1。原因是教务系统通常需要留存历史选课记录,方便追溯。退课的真实业务动作是两个:
- 把course_selection记录的status更新为1;
- 把course表的selected_count减1。
同样要在事务里完成,并且退课之前要判断当前是否还在退课开放时间内。有些系统允许选课期间退课,选课结束后禁止退课,需要管理员单独开接口处理。如果不做时间限制,就会出现"选课已截止,但学生退课导致名额被释放"这种规则漏洞。
我见过有人把退课做成DELETE,然后管理员想查历史退课记录时抓瞎了,只能去翻数据库备份。逻辑删除和物理删除的取舍,是一个值得在答辩时主动讲出来的设计思考。
5. 并发选课压测过程中的意外收获:从服务崩溃到承载数百并发
5.1 最初的代码问题:为什么一压测就会数据错乱
系统主体功能开发完成后,我用JMeter模拟了200个并发用户同时选同一门课。课程总容量是100,预期是100个成功,100个失败,但测试结果让我怀疑人生——成功数量超过了100,甚至出现了选课记录比容量还多的情况。
问题的根源就是前面说的竞态条件。一开始的代码逻辑是先查selected_count,再判断是否小于容量,然后才执行UPDATE。两个线程同时读到selected_count=99,都认为还剩1个名额,一个线程更新为100,另一个线程也读到99,再更新为100,最后两个选课记录都插进去了,总数变成101,但课程表的total_capacity只有100。
在压测环境中复现问题后,我把代码改成FOR UPDATE先用行锁锁住课程记录。再次压测,成功数量稳定在100,失败提示全部是"课程容量已满",数据完全一致了。
5.2 行锁还是乐观锁:两种方案的取舍
直接使用SELECT ... FOR UPDATE属于悲观锁方案,实现简单、理解直观、效果可靠,但缺点是每次选课都会对course表对应的行加锁,选课高峰期数据库的锁等待时间可能变长。
更优雅的方案是乐观锁,也就是在course表上加一个version版本号字段,更新语句加上版本判断:
UPDATE course SET selected_count = selected_count + 1, version = version + 1 WHERE id = #{courseId} AND version = #{oldVersion}如果执行后影响行数为0,说明这条记录已经被其他事务抢先修改,当前事务需要重新读取课程信息,再重试整个选课流程。这种方案在读多写少的场景下性能更好,但实现复杂度更高,需要考虑重试策略。
对毕业设计来说,我建议直接用悲观锁。理由很简单:选课系统的写入并发规模不大,即使几百人同时抢课,数据库行锁完全扛得住,而不需要引入乐观锁的重试机制和版本管理逻辑。在答辩时主动说明"为什么选悲观锁而不是乐观锁",反而是一个加分项,因为说明你真正理解了两种方案的适用场景。
5.3 数据库连接池参数优化
压测过程中还有一个容易被忽略的问题:Spring Boot默认的HikariCP连接池最大连接数是10,如果并发超过10,多余的请求会排队等待数据库连接。对小型选课系统来说,10个连接确实够用,但压测时如果吞吐量上不去,可以先看一眼连接池配置。
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000把maximum-pool-size调到20,压测结果立刻提升了大约30%的吞吐。这里要注意的是,连接池参数并不是越大越好,太大反而会增加数据库的线程调度开销。20个连接对一个毕设级别的MySQL实例来说是比较稳妥的中间值。
6. 提升完成度的几个关键功能:完善系统到答辩可用的水平
6.1 批量导入与数据初始化
管理端录入学生和教师信息是个体力活,手工一条条录不仅慢还容易出错。建议做一个Excel批量导入功能,用POI或EasyExcel解析上传的xlsx文件,按固定模板批量插入用户表和学生表。这里有一个实用的设计点:导入时用一条SQL语句批量插入,而不是循环单条插入。数据量几百条时循环插入可能感觉不到差别,但如果你的导入模板包含几千条记录,逐条插入的耗时差异会很明显。
6.2 消息提示与操作反馈机制
任何用户操作都要能给到明确反馈。选课成功、退课成功、容量已满、时间冲突,这些提示不能是同一个通用错误页,而是要在当前页面以提示框的形式展示。至少要实现:操作成功时页面顶部出现绿色提示条,操作失败时出现红色提示条,同时保持用户之前浏览的位置不变。
技术方案上用服务器端重定向并携带参数不太优雅,建议用AJAX异步提交,后端返回JSON格式的操作结果,前端根据结果动态修改DOM。这样做的好处是用户选课时不会感觉到页面刷新,体验更像真正的教务系统。
6.3 答辩前的功能演示清单
最后分享一份我自己整理的演示路径,按这个顺序操作,答辩时逻辑会很顺畅:
- 管理员登录,导入学生数据、创建课程、设定选课时间窗口;
- 学生登录,浏览可选课程列表,演示筛选和排序功能;
- 选一门课,刷新页面看到已选课程出现、容量减一;
- 尝试重复选同一门课,演示拦截提示;
- 尝试选一门容量已满的课,演示"容量已满"提示;
- 选一个时间冲突的课,演示冲突提示;
- 退课,演示容量恢复;
- 教师登录,查看选课名单并导出Excel;
- 管理员登录,查看所有选课记录并按课程统计。
按这个顺序走下来,几乎覆盖了系统中所有核心业务逻辑,评委老师看到的也一定是一个完整、闭环、能解决实际问题的系统,而不是一个简单的CRUD拼凑品。
我做这类项目的一个体会是,完成度高的毕设作品并不在于功能堆得多,而在于每个功能背后都有清晰的业务逻辑支撑,尤其选课系统这种业务属性强的题目,把并发控制、事务边界、规则校验讲清楚,价值可能比多写两个页面还要高。