简介:基于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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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无响应。serverTimezone=Asia/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"; Map<String, 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 + JSP(JavaWeb) | JDK 8或11 | Tomcat 8.5或9 | 兼容性最好 |
| Spring Boot 2.x | JDK 8或11 | 内嵌Tomcat | 直接java -jar跑 |
| Spring Boot 3.x | JDK 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里瞎调。
配置文件里最容易忽视的坑是useSSL=false参数。MySQL 8.0以上版本默认开启SSL,而你本地往往没有配置证书。连接池初始化时可能不报错,但第一次执行SQL时有时会抛SSL相关的异常,干脆在URL末尾加上useSSL=false&allowPublicKeyRetrieval=true,本地开发环境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没加characterEncoding=utf8,Tomcat的请求解码字符集不对,页面编码不是UTF-8。
解决:依次排查。数据库URL加参数:
jdbc:mysql://localhost:3306/school?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/ShanghaiTomcat 8以上版本在conf/server.xml的Connector里加上URIEncoding="UTF-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 port="8080"为其他端口,比如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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';加这张表最有价值的一点,是它能在答辩前帮你把“为什么这个学生选课失败”变成一条条日志,而不是一句“我猜是……”的模糊解释。排课调整时也建议保留旧课程记录的is_deleted标记,不要物理删除——学生选课记录外键还引用着旧课程呢,物理删除会把历史选课记录一起毁掉。我自己在做这类系统时吃过一次这个亏,后来凡是带选课状态的表,一律软删除。把日志表加进事务里一起提交,成本不高,但后面排查问题、写设计文档时省下的时间远远超过写这张表本身的时间。希望帮到你。
本文还有配套的精品资源,点击获取