YAOTU INSIGHTS

Java学生选课系统如何保证并发数据一致性?从表结构到事务实战

Java学生选课系统如何保证并发数据一致性?从表结构到事务实战
简介基于Java的学生选课系统压缩包是一套前后端分离的应用源码面向需要处理课程数据管理、选课排课与权限分配的高校实训、课程设计或小型教务场景适合具备一定Java与Vue基础的开发者参考。系统后端采用Spring Boot前端基于Vue.js数据库使用MySQL覆盖用户管理、数据可视化、权限控制等功能模块并支持自定义查询与图表展示资源大小约21.84MB压缩包内以源码及相关项目文件为主具体文件清单暂未在详情中列出整体结构便于按模块查阅。已有164人学习该资源可作为课程设计与毕业设计的起步项目帮助理解前后端交互、数据库设计与权限控制的完整流程。系统还包含数据加密与防SQL注入等安全考虑同时说明支持按需二次开发和提供使用文档适合在此基础上快速扩展与维护。1. 基于Java的学生选课系统这份zip要解决的不是选课而是并发下的数据一致性期末课程设计季一到“基于Java的学生选课系统.zip”这类源码包就成了最抢手的资源。打开之后里面通常是项目源码、数据库SQL脚本和一份使用说明但真正动手跑起来才发现选课系统最难的从来不是把页面调通而是两个学生同时点选同一门课时数据库里那一行余量字段怎么保证不超卖。这篇文章我按自己带课程设计的习惯把这份系统拆成数据模型、核心事务、环境启动、踩坑记录和进阶优化五层来讲。做课程设计、毕业设计或者想拿这个练手写后端的人都能照着跑通也能在答辩时把“为什么这样设计”讲明白。2. 数据模型选课系统的表结构和字段先于代码决定成败2.1 三张核心表的关系学生、课程、选课记录打开任何一个学生选课系统的SQL脚本核心都逃不开三张表学生表、课程表、选课记录表。如果你拿到手的项目里没有第三张表而是把课程ID直接塞进学生表的一个字段里那这个项目基本不用看代码了——它在数据模型上就已经废了一半。第三张选课记录表存在的意义是让“学生”和“课程”之间形成多对多关系一个学生可以选多门课一门课可以被多个学生选。这个关系必须独立成表才能支持退课、改选、按学期查询这些后续操作。下面是这套系统里最基础的一张选课记录表我按课程设计常见的MySQL写法给出CREATE TABLE course_selection ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, student_id varchar(20) NOT NULL COMMENT 学号, course_id bigint NOT NULL COMMENT 课程编号, select_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 选课时间, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1-有效0-已退课, semester varchar(20) NOT NULL COMMENT 学期如2025-2026-1, PRIMARY KEY (id), UNIQUE KEY uk_student_course (student_id, course_id, semester), KEY idx_course_semester (course_id, semester) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选课记录表;这张表里有几个字段值得说明。status字段用来区分当前这条记录是有效选课还是已退课不是把退掉的记录物理删除。因为课程设计阶段的系统没有审计需求但退课记录留着后续查“谁选过又退了”会非常方便而且能避免大量DELETE操作在InnoDB里产生碎片。联合唯一键uk_student_course是防重复选课的最后一道关卡哪怕代码里忘了判断数据库层面也会拒绝同一个学生在同一学期重复选同一门课。semester字段看起来简单却是实际使用里最容易漏掉的。没有它到了下学期重新排课同一个学生选同一门课就会因为唯一键冲突直接报错。所以三张表合在一起的关系是学生表只存学生基本属性课程表只存课程属性选课这张关联表承担所有“人和课之间的行为状态”。2.2 余量字段为什么课程表里必须冗余一个已选人数课程表里除了课程名称、学分、教师、上课时间地点这些基本字段一定会有两个字段capacity容量和selected_count已选人数。很多新手会问已选人数不是能用SELECT COUNT(*) FROM course_selection WHERE course_id ?算出来吗为什么非要在课程表里单独存一个字段答案是并发。选课系统的高峰期集中在开放选课的前几分钟几千个请求同时打过来。每次选课都对选课记录表做一次全表COUNT再判断是否小于容量在数据量大和连接池有限的场景下这个查询很快会变成瓶颈。更重要的是COUNT结果和后续INSERT之间有间隔两个并发请求可能同时读到同一个已选人数各自判断“还有余量”然后双双插入成功——数据就错了。所以一般做法是选课成功的那个事务里对课程表的余量做原子更新UPDATE course SET selected_count selected_count 1 WHERE course_id ? AND selected_count capacity;这条SQL用了selected_count capacity作为条件如果受影响行数为0说明已经满了事务直接回滚。这种写法把“检查余量”和“扣减余量”合并成一条原子语句是课程设计里最容易拿分的点也是答辩时老师最爱问的地方。2.3 初始化脚本建议直接在SQL里把数据造全拿到.zip解压后数据库初始化脚本一般是school.sql或db.sql。导入时我建议你先打开看一眼表结构再执行导入。常见做法是mysql -u root -p school.sql导入成功后检查一下USE school; SHOW TABLES; SELECT COUNT(*) FROM student; SELECT COUNT(*) FROM course;有些给了脚本的学生选课系统里学生表密码直接是MD5加密后的字符串比如e10adc3949ba59abbe56e057f20f883e就是123456的MD5值。初始化时最好把这些都确认一遍别等登录页面弹“用户名或密码错误”再回头翻脚本。另外MySQL 8.0以上版本默认字符集是utf8mb4建表语句里也写上了尽量不要改成utf8否则插入中文课程名和教师名时可能会遇到字符集相关的报错。3. 核心业务实现登录、选课、退课、冲突检测3.1 连接池配置别再用DriverManager.getConnection课程设计源码里最常见的低级写法是每次请求都调DriverManager.getConnection()拿连接用完再关。这在并发量低的时候没问题但选课高峰一来每次新建连接都要经历TCP握手和MySQL鉴权连接池很快就会被打满。我一般会把数据源换成连接池哪怕是最简单的DBCP或C3P0都行。下面是一个极简的DBCP配置写法import org.apache.commons.dbcp2.BasicDataSource; public class DataSourceUtil { private static final BasicDataSource dataSource new BasicDataSource(); static { dataSource.setDriverClassName(com.mysql.cj.jdbc.Driver); dataSource.setUrl(jdbc:mysql://localhost:3306/school?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai); dataSource.setUsername(root); dataSource.setPassword(123456); dataSource.setInitialSize(5); // 初始连接数 dataSource.setMaxTotal(20); // 最大连接数 dataSource.setMaxIdle(10); // 最大空闲连接 dataSource.setMinIdle(5); // 最小空闲连接 dataSource.setMaxWaitMillis(3000); // 取连接超时3秒直接报错 } public static BasicDataSource getDataSource() { return dataSource; } }两个参数特别说一下。setMaxWaitMillis(3000)是防止高峰期线程全部阻塞在“等待连接”上超过3秒直接抛异常而不是无限等下去否则用户看到的不是选课慢而是整个Tomcat无响应。serverTimezoneAsia/Shanghai是MySQL 8.0的硬性要求不写这个参数连接时会报时区错误这属于最常见的启动翻车点之一。3.2 登录模块角色区分和会话校验学生选课系统一般有两类角色学生和管理员。管理员负责排课、调课学生负责选课和退课。登录接口在架构上没什么悬念但有一个细节容易被忽略登录成功后用户信息要放进Session而不是每次都查数据库。用一个简单的LoginServlet来写WebServlet(/login) public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); try (Connection conn DataSourceUtil.getDataSource().getConnection()) { String sql SELECT id, username, role, real_name FROM sys_user WHERE username ? AND password ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, MD5Util.md5(password)); // 注意密码加密存储 try (ResultSet rs ps.executeQuery()) { if (rs.next()) { // 登录成功把用户信息放进Session User user new User(); user.setId(rs.getLong(id)); user.setUsername(rs.getString(username)); user.setRole(rs.getString(role)); user.setRealName(rs.getString(real_name)); req.getSession().setAttribute(loginUser, user); if (admin.equals(user.getRole())) { resp.sendRedirect(admin/courseList.jsp); } else { resp.sendRedirect(student/courseSelect.jsp); } } else { req.setAttribute(errorMsg, 用户名或密码错误); req.getRequestDispatcher(login.jsp).forward(req, resp); } } } } catch (Exception e) { throw new ServletException(登录失败, e); } } }这套登录逻辑里密码加盐的问题值得展开。MD5本身已经不够安全但在课程设计这种时间有限的场景里仍然大量使用。下面是MD5加盐的写法比直接MD5强一点public class MD5Util { public static String md5(String input) { try { MessageDigest md MessageDigest.getInstance(MD5); byte[] bytes md.digest((input course_salt_2025).getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } }这里加了一个固定的盐值course_salt_2025。真实的项目里盐应该每个用户单独随机生成存到用户表里独立字段。但课程设计阶段固定盐至少能让两个相同密码的用户得到不同结果这件事不做也能讲清楚。如果追求完整可以在用户表加salt字段登录时先查出盐再算哈希逻辑上多一次查询但安全性上一个档次。3.3 选课事务把“查余量”和“扣余量”放进同一个事务选课是整个系统的核心操作也是并发风险最集中的地方。选课成功日志里多一条选课记录课程表余量减一这两个操作必须同时成功或同时失败。用Transactional声明事务只是第一步真正关键的是事务内部的SQL顺序。下面这段是一个选课操作的标准写法用Spring的JdbcTemplate来写比裸JDBC省去大量样板代码Service public class CourseServiceImpl implements CourseService { Autowired private JdbcTemplate jdbcTemplate; Transactional(rollbackFor Exception.class) Override public void selectCourse(String studentId, Long courseId, String semester) { // 1. 锁行把课程表的这一行锁住防止其他事务同时修改余量 String lockSql SELECT course_id, capacity, selected_count FROM course WHERE course_id ? FOR UPDATE; MapString, Object course jdbcTemplate.queryForMap(lockSql, courseId); int capacity ((Number) course.get(capacity)).intValue(); int selectedCount ((Number) course.get(selected_count)).intValue(); if (selectedCount capacity) { throw new BizException(该课程已选满); } // 2. 插入选课记录 String insertSql INSERT INTO course_selection (student_id, course_id, select_time, status, semester) VALUES (?, ?, NOW(), 1, ?); jdbcTemplate.update(insertSql, studentId, courseId, semester); // 3. 更新余量 String updateSql UPDATE course SET selected_count selected_count 1 WHERE course_id ?; jdbcTemplate.update(updateSql, courseId); } }这里的核心是第1步的SELECT ... FOR UPDATE。它把课程表对应行加了排他锁事务提交前其他事务想对这一行做SELECT FOR UPDATE会一直阻塞等待。也就是说同一门课同时有10个人选最终只会有一个事务真正执行完整个选课流程其他人要么等锁释放后拿到最新余量发现满了要么超时抛异常。rollbackFor Exception.class这个属性必须写。Spring默认只在遇到运行时异常时回滚遇到受检异常不会回滚。课程设计里如果选了Exception做异常基类而不声明rollbackFor会出现“选课记录插进去了余量更新却失败了事务还提交了”这种数据不一致的问题。这个坑几乎每年都能在学生的答辩代码里看到。3.4 时间冲突检测SQL写法比Java循环判断更干净容量冲突只是选课系统的第一道坎时间冲突才是真正考验设计的点。很多学生用Java代码把所有已选课程查出来再逐条比较星期和节次这个思路没错但代码写起来又臭又长。我用一条SQL把时间冲突判断加进业务逻辑里SELECT COUNT(*) AS conflict_count FROM course_selection cs JOIN course c ON cs.course_id c.course_id WHERE cs.student_id ? AND cs.status 1 AND cs.semester ? AND c.week_day ? AND c.start_time ? AND c.end_time ?;这条SQL的语义是找出这个学生在这个学期里所有在上课日week_day相同、且在时间区间上与新课程重叠的课程。判断重叠的核心条件是c.start_time 新课程结束时间 AND c.end_time 新课程开始时间这是区间重叠判断的标准写法。这里有个边界问题假设一节课8:00到9:40另一节课9:40到11:20它们算冲突吗按大多数学校的规则不算——前一节课下课和下一节课上课是无缝衔接的。所以重叠条件里应该用开区间即start_time 新课程end_time AND end_time 新课程start_time这样连堂的课只会在等值边界上相遇不会被误判为冲突。这是课程设计里最常见的边界坑之一。4. 本地把工程跑起来从解压到浏览器能打开全流程4.1 第一步确认JDK和Tomcat版本匹配拿到zip后第一步不是急着打开IDE而是先看项目里有没有.idea、.classpath、pom.xml或web.xml判断这到底是Maven项目还是普通JavaWeb项目。老式课程设计源码多半是Eclipse导出的JavaWeb项目新一些的可能是Spring Boot Maven项目。这两种的启动方式完全不同提前确认能省下不少时间。JDK版本问题是最常见的启动拦路虎。如果你用JDK 17去跑一个用JDK 8写的项目编译报错几乎是必然的。版本对应关系大致如下项目类型推荐JDK推荐Tomcat说明Servlet JSPJavaWebJDK 8或11Tomcat 8.5或9兼容性最好Spring Boot 2.xJDK 8或11内嵌Tomcat直接java -jar跑Spring Boot 3.xJDK 17内嵌Tomcat依赖Jakarta EE老代码升级成本高检查JDK版本用命令java -version javac -version报错“错误: 不支持发行版本”通常就是JDK版本和项目编译级别不匹配要么把IDE里的Project Structure改成当前JDK要么给项目降级到匹配的JDK。4.2 修改数据库连接配置四个位置必须对齐把SQL导入MySQL之后接下来就是修改项目的数据库连接配置。老式JavaWeb项目里这个配置通常在src/db.properties、WEB-INF/classes/db.properties或Spring的applicationContext.xml里Spring Boot项目则在application.yml里。需要改的无非是四行URL、用户名、密码、驱动类。改完后先别急着启动直接在命令行验证一下配置能不能连上mysql -h 127.0.0.1 -P 3306 -u root -p school这一条命令能通说明MySQL端口、账号、库名都没问题。如果这一步就报错多半是密码错误或MySQL服务没启动与项目代码无关别在IDE里瞎调。配置文件里最容易忽视的坑是useSSLfalse参数。MySQL 8.0以上版本默认开启SSL而你本地往往没有配置证书。连接池初始化时可能不报错但第一次执行SQL时有时会抛SSL相关的异常干脆在URL末尾加上useSSLfalseallowPublicKeyRetrievaltrue本地开发环境Visual大过安全。直接说本地开发环境可以关闭SSL这样更稳妥。4.3 启动与验证看日志的几个关键行如果是老式JavaWeb项目用Tomcat启动后确认成功的标志不是IDEA控制台不报错而是能看到下面这一行信息 [main] org.apache.catalina.startup.Catalina.start Server startup in [1234] milliseconds看到“Server startup in”就说明Tomcat起来了。之后在浏览器访问登录页http://localhost:8080/student_system/login.jsp管理员后台登录后用管理员账号一般是从loginSuccess页面重定向过去学生选课页登录后能看到课程列表和已选课程如果打开页面发现CSS样式全丢了或者图片裂了先按F12看Network面板里静态资源的请求路径。JavaWeb项目的资源路径通常要带项目名比如/student_system/css/style.css如果代码里写的是/css/style.css就会404。这是课程设计源码里出现频率极高的路径问题改起来很简单在JSP页面里用${pageContext.request.contextPath}拼接前缀。5. 选课系统避坑五个血泪排查记录5.1 余量明明满了为什么还是被选了十几个人现象选课开放五分钟后管理员发现好几门容量60的课程选课记录到了73条课程表里的selected_count却是60。数据完全对不上。原因候选代码里用的是“先查余量、再插入、再更新”三步走但查余量时没有加FOR UPDATE锁。两个请求同时SELECT读到的selected_count都是59判断“未满”后同时进入插入逻辑结果两个都成功。这里的关键是默认的REPEATABLE READ隔离级别下普通SELECT不会锁行多个事务可以同时读到相同的历史余量。解决把余量检查改成原子更新条件或者用SELECT ... FOR UPDATE先锁行再判断。我当时处理这个问题的SQL是UPDATE course SET selected_count selected_count 1 WHERE course_id ? AND selected_count capacity;如果update返回0说明已经满员直接抛异常回滚。这条SQL的好处是检查、更新一步到位不存在并发窗口。5.2 事务方法自己调用自己回滚成了摆设现象选课方法里调用了this.checkAndSelect()方法内部抛了异常被catch住了但数据库里选课记录还是插进去了。事务没有回滚。原因这是一个经典的Spring事务失效场景。事务是加在Spring AOP生成的对象上的而this指向的是原始对象不是Spring包装后的对象。this.xxx()直接调用原始对象的方法事务切面根本没有机会介入Transactional注解形同虚设。解决把需要事务的方法拆到另一个Service类里通过注入的方式调用或者自己注入自己Autowired private CourseService self;再self.selectCourse()。这个问题在答辩时被老师问到的概率极高因为几乎每个学生都踩过。5.3 中文乱码登录后用户名显示成问号现象登录页输入中文用户名登录成功后页面显示用户名全是“???”数据库里存进去的也是问号。原因链路有多个环节任何一个环节字符集不对都会出问题。常见的有三处数据库连接的URL没加characterEncodingutf8Tomcat的请求解码字符集不对页面编码不是UTF-8。解决依次排查。数据库URL加参数jdbc:mysql://localhost:3306/school?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiTomcat 8以上版本在conf/server.xml的Connector里加上URIEncodingUTF-8。另外把JSP页面顶部的pageEncoding和contentType里的charset统一改为UTF-8。这三处只要有一处遗漏乱码就还在。排查顺序按“页面→Tomcat→数据库”来。5.4 时间冲突判断把连堂课误判成冲突现象学生选课时选了周一1-2节再去选周一3-4节系统报“时间冲突”但这两节课明明是连着的并不冲突。原因时间重叠判断用了闭区间即newEndTime oldStartTime AND newStartTime oldEndTime这种带等号的比较。当新旧课程的边界时间点完全重合时比如一门9:40结束、另一门9:40开始等号把边界情形算成了重叠。解决把判断条件改为严格不等号新课程的结束时间要大于旧课程的开始时间且新课程的开始时间要小于旧课程的结束时间两边都去掉等号。如果用的是start_time ? AND end_time ?这种参数化写法确保传进去的参数是新课程的结束时间和开始时间配合前闭后开的区间定义即可。5.5 Tomcat启动报Address already in use: JVM_Bind现象Tomcat启动到一半就挂掉控制台报端口占用。原因前一个Tomcat实例没完全关闭或者8080端口被其他程序占了。Windows下经常是上一个IDEA里的Tomcat被强制结束进程还残留在后台。解决先查是谁占了端口netstat -ano | findstr 8080然后根据最后一列的PID结束进程taskkill /PID 12345 /F或者直接改Tomcat的端口号在conf/server.xml里改Connector port8080为其他端口比如8081。这个坑不复杂但每次都能浪费新手不少时间。6. 从能跑到好用批量排课、幂等防重与日志追溯6.1 批量排课一条INSERT插入几百条别再用循环传统做法是一条一条INSERT课程数据数据量大时既慢又容易写错。更关键的是如果排课过程中间出错了要么全部回滚重来要么留下一半数据。用MySQL的多值INSERT配合事务可以一次性把整学期的课程写进去INSERT INTO course (course_name, teacher, credit, capacity, selected_count, week_day, start_time, end_time, semester) VALUES (Java程序设计, 张老师, 3, 60, 0, 1, 08:00, 09:40, 2025-2026-1), (数据结构, 李老师, 4, 50, 0, 1, 10:00, 11:40, 2025-2026-1), (数据库原理, 王老师, 3, 55, 0, 2, 14:00, 15:40, 2025-2026-1);在代码里执行单条INSERT无法直接回滚到“没插入任何数据”的状态但把多条INSERT放进同一个事务里配合Transactional任何一条失败都会整体回滚。批量导入排课时我会顺手把selected_count初始化为0防止后面选课时余量判断出错。6.2 选课接口的幂等设计防住用户的重复点击选课高峰期用户等不及页面响应连点三下“选课”按钮前端可能发来三个一模一样的请求。如果后端不做幂等处理同一门课会被选三次而且每次都能成功——因为每个请求都带着同一个学生ID和课程ID但三次请求之间的事务是隔离的彼此感知不到对方已插入。最简单可靠的防重方法还是数据库层的唯一索引。前面建表时加了uk_student_course(student_id, course_id, semester)三个重复请求同时到达时第一个事务INSERT成功另外两个会收到DuplicateKeyException。捕获这个异常向外提示“您已选过这门课”即可。如果想做得更细可以在选课表上维护一个version字段每次请求带版本号做乐观锁UPDATE course_selection SET status 1, version version 1 WHERE id ? AND version ? AND status 0;影响行数为0说明版本号对不上说明这一条选课记录已经被别的请求处理过了当前请求直接返回。这种方式能应对“用户先退课、再次选课”这种更复杂的操作序列擦除脏请求的效果比单纯依赖唯一索引更彻底。6.3 加一张操作日志表最后的后悔药学生选课系统的数据变更集中在选课、退课、管理员调课三类操作。我用一张统一的系统操作日志表把整个选课过程变成一张可追溯的时间线CREATE TABLE oper_log ( id bigint NOT NULL AUTO_INCREMENT, student_id varchar(20) DEFAULT NULL, course_id bigint DEFAULT NULL, action varchar(20) NOT NULL COMMENT select/drop/admin_update, detail varchar(500) DEFAULT NULL COMMENT 操作详情如退课原因, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_student_time (student_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT操作日志表;加这张表最有价值的一点是它能在答辩前帮你把“为什么这个学生选课失败”变成一条条日志而不是一句“我猜是……”的模糊解释。排课调整时也建议保留旧课程记录的is_deleted标记不要物理删除——学生选课记录外键还引用着旧课程呢物理删除会把历史选课记录一起毁掉。我自己在做这类系统时吃过一次这个亏后来凡是带选课状态的表一律软删除。把日志表加进事务里一起提交成本不高但后面排查问题、写设计文档时省下的时间远远超过写这张表本身的时间。希望帮到你。本文还有配套的精品资源点击获取