news 2026/10/13 4:06:34

Java Web选课系统实战:事务、并发控制与业务规则设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java Web选课系统实战:事务、并发控制与业务规则设计

1. 网站选课系统的需求边界:到底要做哪些功能

一提起"网上选课系统",很多同学的第一反应就是"这不就是做一个课程列表加一个选课按钮吗?"真动手做的时候才发现,事情远没有这么简单。选课系统是典型的业务驱动型项目,核心难点不在某个单独功能上,而在多个业务规则叠加时的互相制约。所以开工之前,先把需求边界划清楚,比急着写代码重要得多。

1.1 三类用户的权限模型与核心操作

一个完整的选课系统,至少要覆盖三类角色:学生、教师、管理员。虽然都是登录之后操作,但各自的业务范围完全不同,权限设计一旦模糊,后面越写越乱。

管理员负责全局配置和兜底处理,包括:

  • 基础数据维护:学生信息、教师信息、专业班级的导入(一般支持Excel批量导入,否则一个个录会累死);
  • 课程管理:开设新课、设定课程属性(学分、总容量、上课时间、上课地点)、调整开课状态;
  • 选课规则配置:选课时间窗口、选课门槛(比如部分课程限制专业方向)、退课截止时间;
  • 选课结果管理:手动补选、强制退课、导出选课名单。

教师的逻辑相对简单,主要就是:

  • 查看自己名下开设的课程;
  • 查看每门课的选课学生名单,一般附带学生的专业、班级基本信息;
  • 打印或导出名单,有的系统还允许教师录入成绩,不过如果只做选课环节,成绩功能可以砍掉,避免范围失控。

学生的操作逻辑是核心中的核心:

  • 在选课开放时间内浏览可选课程列表;
  • 选课时系统要完成一系列校验(后面详细讲);
  • 选课成功后进入"我的课表",可以看到已选课程的时间是否冲突;
  • 开放时间内允许退课,退课后容量恢复,其他同学可以继续选。

1.2 选课系统的业务规则:一句话拆出五个校验逻辑

很多毕业设计翻车,不是因为代码写不出来,而是因为业务规则没有想清楚。选课的最核心动作是"学生选择某一门课",而这一动作至少要过五道校验:

  1. 是否在选课时间窗口内——早一秒不行,晚一秒也不行,时间判断要用数据库服务器时间而不是客户端时间,否则改本机时间就能钻空子;
  2. 课程容量是否已满——已选人数不能超过总容量,这一步天然涉及并发问题,浏览器同时点选课按钮的场景必须考虑;
  3. 是否与已选课程时间冲突——一个学生同一时间段不能上两门课,这是选课系统最具业务特点的规则;
  4. 自己是否已选过这门课——重复提交要能拦截;
  5. 学分上限——很多学校会限定单学期选修学分上限,比如不超过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为例):

  1. 开启事务;
  2. 执行SELECT * FROM course WHERE id = #{courseId} FOR UPDATE,这一步会把当前课程记录行锁住,其他事务在更新这行数据时会阻塞等待;
  3. 在内存中判断selected_count是否小于total_capacity;
  4. 判断时间冲突、重复选课、学分上限(这些读操作在锁内执行,保证数据一致);
  5. 通过全部校验后,执行UPDATE course SET selected_count = selected_count + 1 WHERE id = #{courseId};
  6. 向course_selection表插入记录;
  7. 提交事务,释放锁。

注意这里的顺序不能乱。一定要先锁后查,也就是先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。原因是教务系统通常需要留存历史选课记录,方便追溯。退课的真实业务动作是两个:

  1. 把course_selection记录的status更新为1;
  2. 把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 答辩前的功能演示清单

最后分享一份我自己整理的演示路径,按这个顺序操作,答辩时逻辑会很顺畅:

  1. 管理员登录,导入学生数据、创建课程、设定选课时间窗口;
  2. 学生登录,浏览可选课程列表,演示筛选和排序功能;
  3. 选一门课,刷新页面看到已选课程出现、容量减一;
  4. 尝试重复选同一门课,演示拦截提示;
  5. 尝试选一门容量已满的课,演示"容量已满"提示;
  6. 选一个时间冲突的课,演示冲突提示;
  7. 退课,演示容量恢复;
  8. 教师登录,查看选课名单并导出Excel;
  9. 管理员登录,查看所有选课记录并按课程统计。

按这个顺序走下来,几乎覆盖了系统中所有核心业务逻辑,评委老师看到的也一定是一个完整、闭环、能解决实际问题的系统,而不是一个简单的CRUD拼凑品。

我做这类项目的一个体会是,完成度高的毕设作品并不在于功能堆得多,而在于每个功能背后都有清晰的业务逻辑支撑,尤其选课系统这种业务属性强的题目,把并发控制、事务边界、规则校验讲清楚,价值可能比多写两个页面还要高。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 4:06:23

RouterOS WebFig汉化实战:绕过限制的前端本地化方案

简介&#xff1a;本资源是专为中文网络管理员与RouterOS初学者设计的Web界面汉化补丁包&#xff0c;解决英文WebFig界面带来的理解与操作障碍&#xff0c;显著提升中文用户对Microtik路由器系统的配置效率与使用体验。压缩包共4个文件&#xff08;257KB&#xff09;&#xff0c…

作者头像 李华
网站建设 2026/10/13 4:06:20

工控协议pcap解析:从Wireshark解码到Scapy自定义Dissector

简介&#xff1a;本资源是一套面向工控安全研究者、网络协议分析人员及ICS领域学习者的工控协议流量数据集&#xff0c;聚焦S7、Modbus等主流协议的原始通信行为建模与异常检测基线构建。压缩包共255个文件&#xff0c;含251个pcap格式流量捕获文件&#xff08;覆盖S7、ICSHone…

作者头像 李华
网站建设 2026/10/13 4:06:13

定时播音系统音量放大实战:软件层面解决听不清问题

1. 定时播音的痛点&#xff1a;为什么我需要一个带音量放大的播音软件1.1 实际场景里的"听不清"先说个真实场景。朋友所在的某单位要做上下班打铃、午间休息提醒、安全巡查语音播报&#xff0c;原来是人工按喇叭&#xff0c;后面想换成全自动定时播音。设备倒是不缺&…

作者头像 李华
网站建设 2026/10/13 4:06:13

SpringBoot集成RabbitMQ实战:解耦、削峰与高可靠异步通信

做后端开发这几年&#xff0c;只要项目一上量&#xff0c;“调用越来越慢”“服务之间互相拖累”这类问题就躲不掉了。我之前维护过一个订单链路&#xff0c;下单接口后面要串行调用库存、积分、优惠券、通知好几个服务&#xff0c;高峰期接口耗时经常破2秒&#xff0c;下游任何…

作者头像 李华
网站建设 2026/10/13 4:06:04

PyTorch实现Skip-gram模型:从零训练词向量的完整指南

1. 理解这个项目到底在做什么如果你翻过任何一本自然语言处理相关的教材或课程大纲&#xff0c;看到类似"6.4 PyTorch实现Skip-gram模型"这种编号&#xff0c;大概率是某个章节的核心实验课。标题里的6.4通常是章节号&#xff0c;而真正的主角是"PyTorch实现&qu…

作者头像 李华
网站建设 2026/10/13 4:05:29

SpringBoot集成Hadoop生产实践:类加载隔离与多源WordCount实现

1. 为什么“复杂版WordCount”才是SpringBoot对接Hadoop的真正门槛很多人第一次在SpringBoot里跑通一个本地模式的WordCount&#xff0c;就以为自己已经掌握了Hadoop集成——结果一上YARN集群就卡在ClassNotFoundException、NoClassDefFoundError、Kerberos认证失败、序列化不兼…

作者头像 李华