基于Java的学生选课系统:高并发选课与事务一致性实战
发布时间:2026/9/28 12:58:14来源:尧图网络
简介这份资源是基于Java的学生选课系统完整源码包面向计算机相关专业学生、Java后端初学者及需要课程设计或毕业设计参考的开发者帮助解决选课管理、权限控制与数据可视化等实际开发问题。系统采用前后端分离架构前端基于Vue.js后端使用Spring Boot数据库为MySQL并实现了数据加密与防SQL注入等安全措施支持二次开发与定制。压缩包为zip格式大小约21.84MB文件总数暂未提供主要包含Java后端源码、Vue前端页面及数据库脚本等类型文件分别对应业务逻辑、界面交互与数据存储。目前已有164人学习下载适合作为实战练手或项目搭建的起点。读者可获得一套可运行的选课系统源码涵盖用户管理、权限角色分配、图表报表可视化及高级查询等模块便于快速理解前后端分离项目的目录结构与开发流程同时可参考其安全设计与文档支持进行排错与功能扩展。1. 基于 Java 的学生选课系统从课程设计到能跑通的最小闭环每年学期末总有一批同学被“学生选课系统”这个课程设计卡住。表面看它就是个增删改查真动手才发现坑全在看不见的地方同一门课 30 个名额50 个人同时点“选课”最后数据库里塞进去 50 条记录退课时名额没还回去下一轮选课直接报满老师想导出选课名单结果中文全是乱码。基于 Java 的学生选课系统本质是用 Java 把“课程容量、选课时间窗、学生身份、并发扣减”这四件事串成一个能自洽的业务闭环。它适合正在做课程设计的学生、需要快速搭教学管理原型的开发者也适合拿它当 Java 基础、JDBC、Servlet 综合练习的练手项目。下面我按自己搭过几套的经验把选型、建表、并发控制和排错一条条讲清楚。2. 技术选型与数据库设计为什么这套组合最稳2.1 课程设计场景下的技术栈取舍学生选课系统这类项目最常见的做法是 Servlet JSP JDBC MySQL或者 Spring Boot MyBatis MySQL。两者没有绝对优劣关键看你的约束条件。如果课程要求“体现 Java Web 基础”Servlet/JSP 反而更合适因为过滤器、会话、请求转发这些概念必须自己写一遍才记得住。如果时间紧、想快速出效果Spring Boot 起步更快但容易变成“只会注解不会原理”。我一般会这样分核心业务逻辑选课、退课、容量判断单独抽成 Service 层不跟 Servlet 或 Controller 混在一起。这样无论前端用 JSP 还是 JSON 接口业务代码都能复用。数据库统一用 MySQL 8字符集选 utf8mb4排序规则用 utf8mb4_general_ci避免中文和特殊符号出问题。提示不要用 MyISAM 引擎。选课涉及事务和行锁必须用 InnoDB。2.2 四张核心表与字段约束选课系统的表不用多四张就够学生表、课程表、选课记录表、教师表可选。关键是字段类型和约束要一次定对后面改表成本很高。CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL COMMENT 存哈希不存明文, major VARCHAR(50), grade INT ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(20) NOT NULL UNIQUE, course_name VARCHAR(100) NOT NULL, teacher_name VARCHAR(50), capacity INT NOT NULL DEFAULT 0 COMMENT 容量上限, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, credit DECIMAL(3,1), semester VARCHAR(20) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1 COMMENT 1已选 0已退, UNIQUE KEY uk_student_course (student_id, course_id), KEY idx_course (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;course表里的selected_count是冗余字段目的是避免每次选课都去course_selection里 count 一遍。冗余的代价是必须保证它和真实记录数一致后面并发控制会专门处理。course_selection上的唯一索引uk_student_course是防重复选课的最后一道防线即使代码判断漏了数据库也会拦住。2.3 容量扣减的两种思路对比容量扣减是选课系统最容易翻车的地方。常见做法有两种一是先查selected_count capacity再更新二是直接用一条带条件的 UPDATE 语句。第一种在并发下必然超卖因为查和更新之间有间隙。第二种把判断和更新合成原子操作是更可靠的做法。UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity;执行后检查受影响行数如果是 0说明要么课程不存在要么已经满了。这个判断必须在同一个事务里完成配合course_selection的插入任何一步失败都回滚。这样即使 100 个人同时点数据库行锁也会把请求排队不会出现超卖。3. 选课与退课的核心实现事务、锁与幂等3.1 选课接口的完整事务流程选课不是一条 SQL 能搞定的它至少包含三步检查是否已选、扣减容量、插入选课记录。这三步必须在一个事务里否则会出现“容量扣了但记录没插进去”或者“记录插了但容量没扣”的脏数据。public boolean selectCourse(Long studentId, Long courseId) { Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 检查是否已经选过唯一索引兜底这里提前判断给友好提示 String checkSql SELECT COUNT(*) FROM course_selection WHERE student_id? AND course_id? AND status1; try (PreparedStatement ps conn.prepareStatement(checkSql)) { ps.setLong(1, studentId); ps.setLong(2, courseId); ResultSet rs ps.executeQuery(); if (rs.next() rs.getInt(1) 0) { conn.rollback(); return false; // 已选过 } } // 2. 原子扣减容量 String updateSql UPDATE course SET selected_count selected_count 1 WHERE id? AND selected_count capacity; try (PreparedStatement ps conn.prepareStatement(updateSql)) { ps.setLong(1, courseId); int affected ps.executeUpdate(); if (affected 0) { conn.rollback(); return false; // 容量已满 } } // 3. 插入选课记录 String insertSql INSERT INTO course_selection(student_id, course_id, status) VALUES(?, ?, 1); try (PreparedStatement ps conn.prepareStatement(insertSql)) { ps.setLong(1, studentId); ps.setLong(2, courseId); ps.executeUpdate(); } conn.commit(); return true; } catch (SQLIntegrityConstraintViolationException e) { // 唯一索引冲突说明并发下重复插入 rollbackQuietly(conn); return false; } catch (SQLException e) { rollbackQuietly(conn); throw new RuntimeException(选课失败, e); } finally { closeQuietly(conn); } }这段代码的关键点有三个。第一setAutoCommit(false)开启事务所有操作要么全成功要么全回滚。第二扣减容量用带条件的 UPDATE把“判断是否满”和“扣减”合成原子操作。第三捕获SQLIntegrityConstraintViolationException这是唯一索引冲突的异常说明并发下有两个请求同时通过了前面的检查但数据库只允许一条插入。参数说明studentId和courseId都从会话或请求参数获取不要信任前端传来的值必须在服务端校验学生身份。capacity和selected_count都是 INT容量一般不超过 500不会溢出。3.2 退课与容量归还的幂等处理退课比选课更容易出问题因为用户可能重复点退课或者退一门根本没选的课。幂等的意思是同一个退课请求执行一次和执行多次结果一样。public boolean dropCourse(Long studentId, Long courseId) { Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); // 1. 把选课记录状态改为已退只有 status1 的才更新 String updateSel UPDATE course_selection SET status0 WHERE student_id? AND course_id? AND status1; int affected; try (PreparedStatement ps conn.prepareStatement(updateSel)) { ps.setLong(1, studentId); ps.setLong(2, courseId); affected ps.executeUpdate(); } if (affected 0) { conn.rollback(); return false; // 没选过或已退直接返回 } // 2. 归还容量同样用条件更新防止减到负数 String updateCourse UPDATE course SET selected_count selected_count - 1 WHERE id? AND selected_count 0; try (PreparedStatement ps conn.prepareStatement(updateCourse)) { ps.setLong(1, courseId); ps.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { rollbackQuietly(conn); throw new RuntimeException(退课失败, e); } finally { closeQuietly(conn); } }退课的核心是status1这个条件。只有当前状态是“已选”的记录才能被改成“已退”重复请求第二次执行时affected为 0直接返回 false不会重复扣减容量。归还容量时加selected_count 0也是防御性写法避免因为历史脏数据把计数减成负数。3.3 选课时间窗与身份校验选课系统通常有开放时间比如每学期第 1 周开放第 2 周关闭。这个判断不要写死在代码里建议在course表加select_start和select_end两个 DATETIME 字段或者单独建一张system_config表。public boolean isSelectionOpen(Long courseId) { String sql SELECT select_start, select_end FROM course WHERE id?; // 查询后比较当前时间 // 如果当前时间在 start 和 end 之间返回 true }身份校验方面学生只能选自己的课退自己的课。服务端的每个接口都要从会话里取studentId而不是从请求参数里取。请求参数里的studentId只能用来做管理员代选普通学生接口必须忽略它。注意JSP 页面里不要用隐藏域传studentId很容易被篡改。用session.getAttribute(studentId)。4. 并发选课翻车现场超卖、死锁与重复插入的排查4.1 超卖50 人抢 30 个名额数据库里 50 条记录现象压测时用 50 个线程同时选同一门容量 30 的课最后course_selection表里有 50 条status1的记录selected_count也是 50。原因代码里先SELECT判断容量再UPDATE扣减两个操作不在一个原子步骤里。并发下多个线程都查到selected_count29 30然后都执行了更新。解决把判断和扣减合并成一条带条件的 UPDATE如 3.1 节所示。执行后检查affected rows为 0 就回滚。这个方案在 MySQL InnoDB 下行锁会保证同一行的更新串行化不会超卖。4.2 死锁两个事务互相等对方的锁现象日志里出现Deadlock found when trying to get lock选课接口偶尔报 500。原因如果选课逻辑里先更新course再插入course_selection而退课逻辑里先更新course_selection再更新course两个事务的加锁顺序相反并发下就可能死锁。解决统一加锁顺序。所有涉及选课和退课的事务都按“先 course 后 course_selection”的顺序操作。或者更简单把隔离级别降到 READ COMMITTED减少间隙锁。但根本办法还是统一顺序。4.3 重复插入唯一索引报错但没捕获现象并发选课时后台日志刷Duplicate entry 1001-2001 for key uk_student_course前端显示“系统错误”。原因代码里检查了是否已选但两个线程同时通过了检查然后都执行 INSERT第二个被唯一索引拦住抛出的异常没有单独处理。解决捕获SQLIntegrityConstraintViolationException返回“请勿重复选课”的友好提示而不是 500。同时保留唯一索引它是最后一道防线不能删。4.4 退课容量没归还selected_count 只增不减现象学生退了课course_selection里status变成 0但course.selected_count没变导致课程显示已满但实际有空位。原因退课代码只更新了选课记录忘了更新course表。或者更新了但事务回滚了。解决退课和选课一样必须在同一个事务里完成状态变更和容量归还。写完后用一条 SQL 对账SELECT course_id, COUNT(*) FROM course_selection WHERE status1 GROUP BY course_id和course.selected_count比对不一致就说明有 bug。4.5 中文乱码课程名和教师名变成问号现象JSP 页面提交的课程名存到数据库变成???或者查询出来显示乱码。原因数据库字符集不是 utf8mb4或者 JDBC 连接串没指定characterEncodingutf8或者 Tomcat 的URIEncoding没配。解决三处都要改。数据库和表用DEFAULT CHARSETutf8mb4JDBC URL 加?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiTomcat 的server.xml里 Connector 加URIEncodingUTF-8。JSP 页面顶部加% page contentTypetext/html;charsetUTF-8 %。5. 从能跑到好用选课系统的验证清单与压测技巧5.1 用一条 SQL 做数据一致性对账系统跑起来之后最怕的是数据对不上。我习惯在每次选课高峰期后跑一遍对账 SQL确认course.selected_count和真实选课记录数一致。SELECT c.id, c.course_name, c.capacity, c.selected_count, COUNT(cs.id) AS real_count FROM course c LEFT JOIN course_selection cs ON c.id cs.course_id AND cs.status 1 GROUP BY c.id HAVING c.selected_count COUNT(cs.id);这条 SQL 会列出所有计数不一致的课程。如果结果为空说明容量字段维护正确。如果有记录优先检查退课逻辑和并发选课的事务边界。这个对账脚本可以做成定时任务每天凌晨跑一次有问题发告警。5.2 用 JMeter 或 ab 做并发验证不压测的选课系统等于没测。用 JMeter 建一个线程组50 个线程循环 1 次同时请求选课接口。或者用 ab 命令ab -n 100 -c 50 -p select.json -T application/json http://localhost:8080/course/selectselect.json里放{courseId: 1}注意studentId要从会话取所以压测时需要先登录拿 Cookie。压测后检查course_selection里status1的记录数是否等于capacityselected_count是否等于capacity。如果记录数大于容量说明并发控制有漏洞。5.3 选课系统的边界参数怎么定容量、时间窗、并发数这三个参数直接决定系统行为。容量按教室座位数设一般不超过 200。时间窗建议精确到分钟开始和结束时间都存 DATETIME。并发数取决于数据库连接池大小HikariCP 默认 10选课高峰期可以调到 20但不要超过数据库max_connections的 80%。还有一个容易被忽略的参数事务超时时间。选课事务如果卡住超过 5 秒应该自动回滚避免长时间占锁。在 Spring 里可以用Transactional(timeout 5)原生 JDBC 可以用conn.setNetworkTimeout或数据库的innodb_lock_wait_timeout。5.4 我踩过的一个坑连接池耗尽导致选课全部超时有一次压测50 个并发上来选课接口全部超时。查日志发现连接池只有 10 个连接每个选课事务持有连接的时间比较长后面的请求全在等连接。后来把连接池调到 30同时把选课事务里不必要的查询去掉问题解决。教训是连接池大小要结合事务持有时长来算不是越大越好但太小一定出事。提示选课接口里不要做发邮件、写日志到文件这类慢操作全部异步化。5.5 验证清单上线前至少跑一遍上线或提交课程设计前按这个清单过一遍选课成功后selected_count加 1退课后减 1重复选课返回友好提示容量满时选课失败并发 50 线程不超卖中文课程名不乱码选课时间窗外无法选课学生只能操作自己的记录。每一条都对应一个具体的 SQL 或接口测试跑完心里才有底。这套东西我前后搭过三遍每次都会在并发和事务上翻车一次后来把对账 SQL 和压测脚本做成固定流程才算是有了后悔药。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网