简介:这是一套基于Java技术栈的学生选课系统完整源码,面向计算机相关专业学生、Java后端初学者及需要课程设计或毕业设计参考的开发者,帮助解决选课流程管理、数据统计与权限控制等实际问题。资源包为zip格式,压缩后约21.84MB,文件总数与类型明细上游暂未提供,从标签与描述可知内容涵盖Java后端、Vue前端及数据库脚本等核心模块,可支撑前后端分离项目的完整搭建与二次开发。目前已有164人学习下载,具备一定参考热度。系统采用Spring Boot与Vue.js前后端分离架构,数据库选用MySQL,实现了用户管理、数据可视化、角色权限控制等功能,并加入数据加密与防SQL注入等安全措施。读者可据此掌握选课系统的业务建模、接口设计、图表展示与权限校验思路,也可在现有代码基础上按需定制功能,适合作为课程实践与项目练手的参考方案。
1. 从一份「基于 Java 的学生选课系统.zip」说起:它到底能解决什么
很多计算机专业的学生和刚转 Java 的开发者,手里都躺着一个叫「基于 Java 的学生选课系统.zip」的压缩包。它通常出现在课程设计、毕业设计或者 Java 学习路线的实操环节里,解压之后是一堆.java文件、几个 JSP 页面、一份 SQL 脚本,外加一个 README。问题在于,大部分人拿到它之后只会做两件事:改改包名交作业,或者直接扔进收藏夹吃灰。真正把它跑起来、把选课并发、数据一致性这些坑踩一遍的人少之又少。
这个标题背后其实是一套完整的 Java Web 落地链路:面向对象建模、JDBC 或 ORM 持久化、事务控制、并发抢课、权限分级。它适合三类人——正在做课程设计需要一份能跑通的参考实现的学生、想用一个小型项目把 Java 基础和数据库操作串起来的自学者、以及准备 Java 面试时想拿一个真实场景讲清楚「怎么保证数据一致性」的求职者。选课系统看着简单,但「同一门课被 200 个人同时点选,容量只剩 5 个」这个场景,足够把乐观锁、悲观锁、事务隔离级别全部拉出来遛一遍。下面我按自己实际复现和改造过的路径,把这份压缩包从解压到能扛并发讲清楚。
2. 解压之后先别急着改代码:环境、依赖与数据库三件事
拿到压缩包,第一反应不该是打开 IDE 改包名,而是先把运行环境对齐。Java Web 项目翻车,十次里有六次是环境不对:JDK 版本和编译目标对不上、Tomcat 版本和 Servlet 规范对不上、MySQL 驱动和数据库版本对不上。这一章先把这三件事定死,再谈代码。
2.1 JDK 与编译目标版本的对齐
压缩包里的项目大概率是几年前写的,pom.xml或 IDE 配置里写的可能是 Java 8。如果你本地装的是 JDK 17,直接编译会看到那句经典的报错:警告: 源发行版 17 需要目标发行版 17,或者反过来提示源版本过低。这不是玄学,是编译器的source和target没对齐。
先确认本地版本:
java -version javac -version如果输出是 17 或更高,而项目是 Java 8 写的,最稳的做法是装一个 JDK 8 专门跑这个项目,而不是硬升。硬升会碰到javax包被移除、反射模块化限制等一堆问题。用 Maven 的话,在pom.xml里显式锁死:
<properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties>source和target必须一致,encoding必须是 UTF-8,否则中文课程名和教师名会变成乱码。参数说明:source决定用哪个版本的语法检查,target决定生成哪个版本的字节码,两者不一致时编译器会用较低的那个并给警告,但某些语法糖会直接编译失败。
2.2 依赖管理与数据库驱动的选择
项目如果带pom.xml,先执行一次依赖解析,看有没有下载失败的:
mvn clean dependency:resolve常见问题是 MySQL 驱动版本。老项目写的是mysql-connector-java5.x,而你的 MySQL 是 8.x,连接时会报Unknown system variable 'query_cache_size'或者时区错误。两个办法:要么把驱动升到 8.x,要么在 JDBC URL 里补参数。我一般直接升驱动,改pom.xml:
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>注意 groupId 从mysql变成了com.mysql,artifactId 也变了,这是 8.x 之后的命名调整。如果项目用的是老坐标,改完记得同步更新代码里的驱动类名,从com.mysql.jdbc.Driver换成com.mysql.cj.jdbc.Driver。JDBC URL 建议写成:
jdbc:mysql://localhost:3306/course_selection?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=trueserverTimezone不写会报时区异常,allowPublicKeyRetrieval在 MySQL 8 默认加密方式下不写会连不上。这两个参数是血泪经验,别省。
2.3 建库建表与初始数据导入
压缩包里一般有一份.sql文件,先看它有没有CREATE DATABASE语句。没有的话手动建:
CREATE DATABASE course_selection DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE course_selection; SOURCE /path/to/init.sql;utf8mb4而不是utf8,因为 MySQL 的utf8是三字节的,存不了 emoji 和部分生僻字,课程名里如果有特殊符号会插入失败。导入之后检查三张核心表:学生表、课程表、选课记录表。选课记录表上应该有(student_id, course_id)的唯一索引,这是防止重复选课的第一道防线,没有的话自己补:
ALTER TABLE course_selection ADD UNIQUE KEY uk_student_course (student_id, course_id);这个唯一索引很关键,后面讲并发的时候会反复用到。建完索引,用SHOW INDEX FROM course_selection;确认一下。
3. 把项目跑起来:从 Tomcat 部署到第一个选课请求
环境对齐之后,下一步是让项目真正启动并响应请求。这一步的目标不是「能打开首页」,而是「能完整走通一次选课流程」,因为只有走通了,你才知道哪些环节是脆弱的。
3.1 部署方式的选择与启动排错
项目如果是传统 Servlet + JSP 结构,用 Tomcat 部署最直接。把编译产物打成 war 包扔进webapps,或者直接在 IDE 里配 Tomcat。启动时重点看日志里有没有这几类错误:
第一类是ClassNotFoundException,通常是依赖没打进WEB-INF/lib,Maven 项目执行mvn package时会自动处理,手动构建的话要自己拷。第二类是NoClassDefFoundError,多半是依赖冲突,同一个类有两个版本,用mvn dependency:tree找出来排除掉。第三类是数据库连接池初始化失败,看context.xml或db.properties里的连接串对不对。
启动成功后访问首页,如果 404,检查web.xml里的url-pattern和实际访问路径是否一致;如果 500,看堆栈第一行,通常是空指针或者 SQL 语法错误。
3.2 选课核心逻辑的代码走读
找到选课的那个 Servlet 或 Controller,核心逻辑一般长这样:
public void selectCourse(int studentId, int courseId) { // 1. 查询课程剩余容量 Course course = courseDao.findById(courseId); if (course.getRemaining() <= 0) { throw new RuntimeException("课程已满"); } // 2. 插入选课记录 selectionDao.insert(studentId, courseId); // 3. 扣减剩余容量 courseDao.decreaseRemaining(courseId); }这段代码在单人操作时没问题,但它是典型的「检查后执行」竞态。两个线程同时查到remaining = 1,都通过检查,都插入记录,都扣减,最后容量变成 -1,还多了一条重复选课记录。这就是为什么前面要加唯一索引——它至少能挡住重复选课,但挡不住超卖。
参数说明:studentId和courseId是业务主键,remaining是课程表的冗余字段,用来快速判断容量。冗余字段的好处是查询快,坏处是必须和选课记录表保持一致,一旦不一致就是数据一致性问题。
3.3 用事务把三步操作包起来
上面三步必须在一个事务里,否则插入成功但扣减失败,数据就脏了。改成:
@Transactional public void selectCourse(int studentId, int courseId) { Course course = courseDao.findByIdForUpdate(courseId); // 加行锁 if (course.getRemaining() <= 0) { throw new RuntimeException("课程已满"); } selectionDao.insert(studentId, courseId); courseDao.decreaseRemaining(courseId); }findByIdForUpdate对应 SQL 里的SELECT ... FOR UPDATE,它会在这一行上加排他锁,其他事务想读这一行必须等。这样就把并发串行化了,超卖问题解决。代价是吞吐量下降,同一门课的选课请求会排队。参数上要注意:FOR UPDATE必须在事务里才生效,自动提交模式下加了等于没加;另外锁的是索引行,如果WHERE条件没走索引,会升级成表锁,整个课程表都被锁住,性能直接崩。
4. 并发选课的三种方案对比:悲观锁、乐观锁与 Redis 预扣
把项目跑通只是起点,真正体现水平的是怎么处理高并发选课。这一章把三种主流方案摆出来,说清楚各自适用场景和参数怎么调,这也是 Java 面试里「怎么保证数据一致性」的高频考点。
4.1 悲观锁方案与它的吞吐量边界
悲观锁就是上面用的SELECT ... FOR UPDATE,思路是「先锁住再操作」。优点是实现简单、绝对不超卖;缺点是并发能力差。实测在单机 MySQL 上,同一门课的选课 TPS 大概在几百到一千之间,取决于事务持有时长。优化方向有两个:一是缩小锁范围,只锁课程行不锁其他;二是缩短事务,把非数据库操作(比如发通知)挪到事务外。
配置上要确认 InnoDB 引擎和事务隔离级别。SELECT ... FOR UPDATE在REPEATABLE READ下锁的是索引记录,在READ COMMITTED下行为略有不同。查看当前隔离级别:
SELECT @@transaction_isolation;如果是REPEATABLE-READ,配合唯一索引,基本够用。但要注意死锁:如果两个事务分别锁了不同课程再互相请求对方的锁,就会死锁。MySQL 会自动检测并回滚其中一个,应用层要捕获DeadlockLoserDataAccessException并重试。
4.2 乐观锁方案:版本号与重试机制
乐观锁的思路是「先操作,提交时检查有没有被别人改过」。在课程表加一个version字段:
ALTER TABLE course ADD COLUMN version INT DEFAULT 0;扣减容量的 SQL 改成:
UPDATE course SET remaining = remaining - 1, version = version + 1 WHERE id = #{courseId} AND remaining > 0 AND version = #{version};Java 层判断受影响行数,如果是 0 说明被别人抢先改了,重试或者返回失败:
int affected = courseDao.decreaseWithVersion(courseId, version); if (affected == 0) { // 重试或提示用户 throw new RetryableException("选课冲突,请重试"); }乐观锁的优点是读不加锁、吞吐量高,适合冲突不激烈的场景。缺点是冲突多的时候重试次数暴涨,反而更慢。参数上要设重试上限,一般 3 次,超过就返回失败,避免请求堆积。remaining > 0这个条件不能省,它是最后一道防线,保证不会扣成负数。
4.3 Redis 预扣减与数据库最终一致
如果选课量真的很大,比如开学第一秒几万人同时抢,数据库扛不住,常见做法是把库存预扣放到 Redis。流程是:课程容量初始化时写入 Redis,选课请求先在 Redis 里DECR,扣到负数就拒绝,扣成功再异步落库。
Long remaining = redisTemplate.opsForValue().decrement("course:stock:" + courseId); if (remaining < 0) { redisTemplate.opsForValue().increment("course:stock:" + courseId); // 回补 throw new RuntimeException("课程已满"); } // 异步写数据库 mqProducer.send(new SelectionMessage(studentId, courseId));这里的关键是 Redis 的DECR是原子操作,天然防超卖。但引入了新问题:Redis 扣成功、数据库写失败怎么办?所以要有补偿机制,比如消息队列重试加对账。参数上要注意 Redis 的持久化配置,如果没开 AOF,宕机会丢库存数据。这套方案复杂度最高,适合真正的高并发场景,课程设计级别用悲观锁就够了,别过度设计。
5. 踩坑记录:选课系统从能跑到能用的五个坎
这一章是我自己复现和帮别人调这个项目时踩过的坑,每条按现象、原因、解决写,都是能直接对号入座的。
5.1 中文课程名变问号
现象:数据库里课程名显示正常,但页面上全是???。原因:数据库连接串没指定字符编码,或者表字符集是latin1。解决:JDBC URL 加characterEncoding=utf8,表改成utf8mb4,Tomcat 的server.xml里Connector加URIEncoding="UTF-8"。三处都改,缺一处都可能乱码。
5.2 选课成功但容量没减
现象:选课记录插入了,但课程剩余容量还是原值。原因:插入和扣减不在同一个事务里,或者扣减的 SQL 条件写错。解决:确认方法上有@Transactional,确认扣减 SQL 的WHERE条件能匹配到行,用SELECT ROW_COUNT()看受影响行数。如果是 0,说明条件没命中。
5.3 并发下出现重复选课记录
现象:同一个学生同一门课出现两条记录。原因:没有唯一索引,或者唯一索引建在了错误的列上。解决:加(student_id, course_id)唯一索引,应用层捕获DuplicateKeyException并提示「已选过该课程」。注意唯一索引和逻辑删除会冲突,如果用了is_deleted标记,唯一索引要带上这一列。
5.4 事务不生效
现象:加了@Transactional但回滚没起作用。原因:常见有三种——方法不是 public、同类内部调用、异常被 catch 没抛出。解决:确认方法是 public,把事务方法抽到单独的 Service 里,catch 之后要么重新抛出RuntimeException要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
5.5 连接池耗尽
现象:压测一会儿就报Could not get JDBC Connection。原因:连接泄漏,某个分支没关闭连接,或者连接池最大连接数设太小。解决:用 try-with-resources 管理连接,检查连接池配置,maxActive一般设 20 到 50,maxWait设 3000 毫秒,超时快速失败而不是无限等。
6. 让选课系统经得起追问:压测验证与一个可复用的检查习惯
把功能跑通、把并发方案选好之后,最后一步是验证它到底扛不扛得住。我一般用 JMeter 或者简单的多线程脚本压测选课接口,重点看三个指标:有没有超卖、有没有重复记录、响应时间随并发怎么变。
写一个最小压测脚本,用 Java 的CountDownLatch模拟同时开抢:
int threads = 200; CountDownLatch latch = new CountDownLatch(threads); ExecutorService pool = Executors.newFixedThreadPool(threads); for (int i = 0; i < threads; i++) { final int studentId = i; pool.submit(() -> { try { latch.await(); // 等所有线程就绪 selectionService.selectCourse(studentId, 1); // 抢同一门课 } catch (Exception e) { // 记录失败原因 } finally { latch.countDown(); } }); }跑完之后查数据库:SELECT COUNT(*) FROM course_selection WHERE course_id = 1;应该等于课程容量,SELECT remaining FROM course WHERE id = 1;应该是 0。如果记录数大于容量,说明防超卖失效;如果小于容量,说明有请求被误杀。两个方向都要查。
验证方法上,我习惯做一个「对账 SQL」,每次压测后跑一遍:
SELECT c.id, c.capacity, c.remaining, COUNT(s.id) AS actual FROM course c LEFT JOIN course_selection s ON c.id = s.course_id GROUP BY c.id HAVING c.remaining != c.capacity - actual;这条 SQL 能一次性找出所有容量和实际选课数对不上的课程,比人工核对快得多。参数说明:capacity是总容量,remaining是剩余,actual是实际选课数,三者必须满足remaining = capacity - actual,不满足就是数据不一致。
进阶一点,可以把这条对账逻辑做成定时任务,每十分钟跑一次,发现不一致就告警。这样即使线上出了并发问题,也能第一时间发现而不是等用户投诉。我自己的习惯是:任何涉及库存、余额、名额的系统,上线前必须有一条对账 SQL,这是后悔药,平时用不上,出事时能救命。
这套从解压到压测的路径走下来,你会发现「基于 Java 的学生选课系统」远不止一份课程设计作业,它是一块很好的试金石,能把 Java 基础、数据库事务、并发控制、压测验证串成一条线。面试时被问到「怎么保证数据一致性」,你可以直接拿这个场景讲,比背八股文有说服力得多。希望帮到你。
本文还有配套的精品资源,点击获取