拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java学生选课系统实战:JDBC+MySQL三层架构与并发事务设计

简介学生选课系统源码包面向高校学生选课场景完整覆盖学生选课、教师打印成绩、管理员维护成员信息三大核心业务流程适用于Java Web课程设计、毕业设计或初学系统分析与设计的开发者。压缩包共61个文件包含27个Java源文件、27个编译后的class文件、4个XML配置、项目结构文件及MySQL 8.0连接驱动jar包整体大小2.16MB源码与编译产物并存便于直接运行与对照学习。已有79人学习下载。项目按dao、service、util、model等分层组织体现了信息管理系统的模块化设计思路同时融入人工智能数据分析与个性化推荐等扩展设想可为读者提供从业务需求梳理到编码实现的完整参考具备较高的实践借鉴价值。1. Java 版学生选课系统从仓库结构看三大核心功能怎么落地一份能直接跑起来的学生选课系统源码最怕的不是功能少而是结构乱。这份压缩包里的student-course-main工程对应的是经典 Java 分层写法src下拆出dao、service、model、util根目录还有Test.java、test2.javalibs里只放了一个mysql-connector-java-8.0.18.jar。换句话说这是一套不依赖 Spring 全家桶、用 JDBC 直连 MySQL 的轻量信息管理系统。对学生选课、教师打印成绩、管理员管理成员信息这三个场景代码路径非常直观model 封装实体DAO 做查询Service 管业务规则最后通过控制台或 Swing 界面触发调用。它最适合两类人一是想用 Java 把系统分析与设计完整走一遍的开发入门者二是需要在课设或内网小工具上快速改出一套选课后台的从业者。2. MySQL 8 数据建模系统分析与设计阶段的角色、ER 与建表 SQL2.1 先把三类用户的业务边界定下来系统分析与设计的第一步不是急着写代码而是把使用者的诉求拆成功能点。这个系统里存在三种角色它们对数据的操作权限完全不同。学生关心的是“还能选什么课”和“我选了哪些课”核心动作是浏览课程、提交选课、查看选课结果。教师关心的是“我这门课有哪些学生”以及“成绩怎么录入和打印”核心动作是查看选课花名册、录入/修改分数、生成成绩单。管理员则拥有系统级权限要能维护用户账号、重置密码、变更角色、停用异常账号。这三个需求最终会映射成两类模块常规的 CRUD 功能以及需要事务保护的选课和成绩提交功能。如果一开始就把这些边界画清楚后面做数据库设计和代码分层时会少很多返工。2.2 实体关系与核心表设计从功能反推数据模型最少需要三张基础表和一张关联表。学生、教师、管理员虽然角色不同但都拥有账号密码等公共属性可以把他们合并到一张sys_user表中用role字段区分。课程信息单独放course表包含课程编号、课程名、教师、上课时间、容量、已选人数等。学生和课程的关系放到student_course关联表记录选课时间和成绩。这种统一用户表的设计带来的直接好处是登录逻辑只需要写一套管理员在维护成员信息时也不需要分别操作学生表和教师表。代价是用户表里会混入不同角色的额外属性例如教师职称、学生班级这类字段实践中可以把公共字段保留在sys_user个性化字段再拆扩展表。考虑到这个项目的定位单表加角色字段已经够用。2.3 建表 SQL 与初始化数据在 MySQL 8.0 环境里新建数据库后可以按下面的脚本初始化。连接器版本是 8.0.18所以理论上 MySQL 5.7 和 8.0 都能兼容但推荐直接使用 MySQL 8.0 以匹配驱动版本。CREATE DATABASE IF NOT EXISTS course_system DEFAULT CHARSET utf8mb4; USE course_system; CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(50) NOT NULL, role TINYINT NOT NULL DEFAULT 3 COMMENT 1管理员 2教师 3学生, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE, course_name VARCHAR(100) NOT NULL, teacher_id INT NOT NULL, credit DECIMAL(3,1) DEFAULT 2.0, max_students INT NOT NULL DEFAULT 50, selected_count INT NOT NULL DEFAULT 0, schedule_time VARCHAR(100) NOT NULL COMMENT 上课时间如 周一第3-4节, location VARCHAR(100), FOREIGN KEY (teacher_id) REFERENCES sys_user(id) ); CREATE TABLE student_course ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,1) DEFAULT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id), FOREIGN KEY (student_id) REFERENCES sys_user(id), FOREIGN KEY (course_id) REFERENCES course(id) );这里最值得注意的设计有两个。第一sys_user通过role区分三种身份初始化时插入管理员和教师测试账号非常方便管理员管理成员信息时只需要 UPDATE 这一张表。第二student_course表上加了联合唯一索引uk_student_course这能从数据库层面挡住同一个学生重复选同一门课的情况。即使业务层没有做前置判断重复插入也会直接抛DuplicateKeyException相当于给选课功能加了一道保险。2.4 初始化数据时的姿势建完表后至少要预置一组能跑通全流程的数据一名管理员、一名教师、两名学生、两三门课程再往student_course里放入一条选课记录。常见做法是直接写一个data.sql复制到 Navicat 或命令行执行测试时再用Test.java里的 main 方法验证。在真实项目里我一般会再加一张selection_log表记录选课日志用来做后续的学生选课行为分析也就是摘要里提到的智能推荐和异常检测。当前这套表结构已经具备产生分析数据的能力只要student_course表里积累足够多的选课记录统计每门课的选课人数、时间分布、同一学生选课的时间冲突都只需要一条 SQL 就能完成。3. Model/DAO/Service 三层代码JDBC 连接管理、资源释放与事务边界3.1 为什么源码要把 model、dao、service 拆成三个包打开压缩包里的src目录可以看到dao service model util几个包这是 Java Web 出现之前就非常成熟的普通 Java 分层模式。model里的类对应数据库表结构例如SysUser、Course、StudentCourse属性名和表字段基本一一对应。dao层只负责拼 SQL、执行 SQL、把ResultSet转换成 model 对象不处理业务规则。service层是业务逻辑的集中地比如选课前检查容量、判断上课时间是否冲突、管理员修改成员信息前校验权限。util包则托管数据库连接工具和字符串处理等公共方法。各层职责可以简单归纳为下面这张表层级主要职责依赖典型操作model数据载体字段与表对应无属性、getter/setterdao数据库读写SQL 封装model、util增删改查、ResultSet 映射service业务规则与事务边界dao、model校验、组合多个 DAO 调用controller/main接收用户输入展示结果service控制台菜单、按钮事件如果所有 JDBC 代码都堆在无限制的main或test2.java中虽然小规模演示能跑但一旦需要扩展功能查改逻辑会互相干扰。尤其是教师打印成绩和管理员管理成员信息这两个功能都要操作多张表没有 Service 层做事务包裹数据很容易出现半成功状态。3.2 DbUtil连接保存在哪里为什么 8.0 驱动类名必须带 cjutil包里最常见的是一个DbUtil类。它负责加载 MySQL 驱动、创建 Connection、关闭资源。由于libs下依赖的是mysql-connector-java-8.0.18.jar驱动类名必须使用com.mysql.cj.jdbc.Driver旧版的com.mysql.jdbc.Driver在新版本中会给出过时警告甚至在去掉兼容层时直接抛异常。package util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DbUtil { private static final String URL jdbc:mysql://localhost:3306/course_system ?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(AutoCloseable... resources) { for (AutoCloseable res : resources) { if (res ! null) { try { res.close(); } catch (Exception ignored) { } } } } }代码里serverTimezoneAsia/Shanghai是 MySQL 8.0 连接时非常关键的参数。如果缺失驱动会把服务器时区记为 UTC导致CURRENT_TIMESTAMP存的选课时间比本地时间慢八小时。characterEncodingutf8保证中文校名和课程名称不会乱码useSSLfalse则用于跳过本地开发时的 SSL 握手。这套参数是 MySQL Connector/J 8.x 连接的标准配置如果换成 MySQL 5.7 且驱动版本较低时区参数可以不写但 8.0 下建议保留。3.3 UserDAO 与 CourseDAOSQL 语句放在 DAO业务判断放 ServiceDAO 类的典型写法是每个表对应一个 DAO内部方法尽量做到“一个方法只做一件事”。例如UserDAO负责findByUsername、insertUser、updateRole等操作CourseDAO负责findAvailableCourses、increaseSelectedCount等。下面这段代码是UserDAO.findByUsername的一种实现方式使用PreparedStatement防止 SQL 注入。package dao; import model.SysUser; import util.DbUtil; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public class UserDAO { public SysUser findByUsername(String username) { String sql SELECT id, username, password, real_name, role, status FROM sys_user WHERE username ?; try (Connection conn DbUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { SysUser user new SysUser(); user.setId(rs.getInt(id)); user.setUsername(rs.getString(username)); user.setPassword(rs.getString(password)); user.setRealName(rs.getString(real_name)); user.setRole(rs.getInt(role)); user.setStatus(rs.getInt(status)); return user; } } } catch (SQLException e) { e.printStackTrace(); } return null; } }注意这段代码在try-with-resources中同时管理 Connection 和 PreparedStatement方法结束自动关闭不需要手动调用DbUtil.close()。之前讲过DbUtil.close是为了兼容旧代码新代码建议直接使用 try-with-resources。DAO 层不应该抛业务异常而是把 SQL 异常转成运行时异常或向上层返回 null由 Service 层决定是提示用户还是记录日志。3.4 事务边界一次选课里插入选课记录和扣减课程名额必须同时成功选课是这个系统里最容易出数据问题的操作。一个完整的选课动作涉及两次写操作向student_course插入记录、把course.selected_count加一。如果没有事务包裹插入成功但更新名额失败就会出现“实际上没选上但界面显示选上了”的状态。更严重的是并发场景下两个学生同时抢同一门课各自读到剩余名额为 1同时执行插入最终超过max_students。Service 层正确做法是在一个事务里完成所有写操作并在课程行上加锁。以 MySQL InnoDB 为例可以使用SELECT ... FOR UPDATE锁定课程记录直到事务提交或回滚。下面给你一个推荐写法public boolean selectCourse(int studentId, int courseId) { String lockSql SELECT max_students, selected_count FROM course WHERE id ? FOR UPDATE; String updateSql UPDATE course SET selected_count selected_count 1 WHERE id ?; String insertSql INSERT INTO student_course(student_id, course_id) VALUES(?, ?); try (Connection conn DbUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement lockPs conn.prepareStatement(lockSql)) { lockPs.setInt(1, courseId); try (ResultSet rs lockPs.executeQuery()) { if (!rs.next()) { conn.rollback(); return false; } int max rs.getInt(max_students); int selected rs.getInt(selected_count); if (selected max) { conn.rollback(); return false; } try (PreparedStatement updatePs conn.prepareStatement(updateSql)) { updatePs.setInt(1, courseId); updatePs.executeUpdate(); } try (PreparedStatement insertPs conn.prepareStatement(insertSql)) { insertPs.setInt(1, studentId); insertPs.setInt(2, courseId); insertPs.executeUpdate(); } conn.commit(); return true; } } catch (SQLException e) { conn.rollback(); throw new RuntimeException(选课失败, e); } } catch (SQLException e) { throw new RuntimeException(数据库连接失败, e); } }这里把setAutoCommit(false)放在 DAO 外层实际上破坏了 DAO 的单层职责所以更专业的做法是将这段逻辑放入SelectionService然后调用CourseDAO和StudentCourseDAO的原子方法。不过当前资源没有使用 Spring没有现成的Transactional注解只能自己控制事务理解上面这段代码对你改造原生 JDBC 项目会有帮助。锁的粒度是单个课程行并发选不同课程互不阻塞并发选同一课程时第二个事务会等第一个提交或回滚后才继续判断剩余名额。这种机制能很好防止超选前提是course.id是主键索引否则FOR UPDATE会退化成表锁。4. 三大核心功能实战学生选课、教师打印成绩、管理员管理成员信息4.1 学生选课先查可报课程再提交选课学生端流程分成两步查询可选课程和提交选课。可选课程需要同时满足两个条件sys_user里对应学生未被禁用、course.selected_count小于max_students。查询语句可以直接写成下面这样在 Service 层再排除掉已经选过的课程更好因为 SQL 里的NOT EXISTS和业务判断混在一起会增加维护成本。SELECT c.id, c.course_no, c.course_name, c.credit, c.schedule_time, c.location, c.teacher_id FROM course c WHERE c.selected_count c.max_students AND c.status 1 ORDER BY c.id;拿到课程列表后学生选择一门课程调用我们刚写过的selectCourse(studentId, courseId)即可。这里我建议在前端或控制台界面做一个“确认再次输入课程号”的交互避免误触。提交后根据返回值提示“选课成功”或“课程已满”。4.2 教师打印成绩分数录入、统计与打印格式教师端打印成绩的核心是“查出某课程的所有选课学生并组装成绩单”。如果没有单独的成绩录入页面可以先按课程维度列出学生再逐个更新student_course.score。查询成绩单的 SQL 如下SELECT u.username, u.real_name, c.course_name, sc.score, sc.select_time FROM student_course sc JOIN sys_user u ON sc.student_id u.id JOIN course c ON sc.course_id c.id WHERE sc.course_id ? ORDER BY u.real_name;拿到数据后教师端可以把结果输出成带分栏的文本文件或直接打印。常见做法是循环遍历ResultSet每打印一行记录一条学号 姓名 分数最后追加一行总分和平均分。如果要把数据输出到 Excel可以参考 POI 库的SXSSFWorkbook写法但这会引入额外依赖当前资源里的 libs 并没有这些 jar。想做成网页打印则可以把数据输出为 HTML 表格再用浏览器打印这种方式不依赖额外库还支持样式控制。4.3 管理员管理成员信息CRUD 与角色变更管理员的核心操作是维护sys_user表但并非所有操作都可以直接复用UserDAO。修改角色、禁用账号这类操作就要增加权限校验。在 Java 控制台版本中最常见的做法是先用当前登录用户的role字段做判断若为 1 才继续执行。下面这段代码展示了UserAdminService.changeRole的基本逻辑public boolean changeRole(int adminId, int targetUserId, int newRole) { SysUser admin userDAO.findById(adminId); if (admin null || admin.getRole() ! 1) { return false; } String sql UPDATE sys_user SET role ? WHERE id ?; try (Connection conn DbUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, newRole); ps.setInt(2, targetUserId); return ps.executeUpdate() 1; } catch (SQLException e) { e.printStackTrace(); return false; } }这里有一个容易被忽略的点管理员也不应该直接修改密码字段为明文。即使课程设计阶段不要求加密也建议至少用SHA-256做一次散列。mysql-connector-java-8.0.18.jar本身不提供加密工具但 JDK 自带的MessageDigest就能实现不用额外引包。4.4 测试入口与冒烟路径资源里的Test.java和test2.java应该是两个能直接运行的测试入口。我一般会把 Test 类设计成最小可运行程序登录验证 → 输出菜单 → 根据输入调用 Service。例如管理员登录后菜单分别包括查看用户列表、新增用户、修改角色、删除用户。每个功能调完都打印日志。如果你希望快速验证某个 Service 方法可以在 main 里直接写一行调用并打印返回结果。public class Test { public static void main(String[] args) { SelectionService service new SelectionService(); boolean ok service.selectCourse(2, 1); System.out.println(选课结果 ok); } }类似这种写法可以作为代码的“冒烟测试”只要这条链路能通说明连接池配置、DAO 映射、事务提交基本没有大问题。后面扩展 AI 推荐或异常检测时也只把新的 Service 类接入同一个 main 方法验证即可。5. MySQL 8 Connector/J 8.0.18 环境下的并发控制与异常排查5.1 连接参数怎么配才不容易踩坑libs下只有mysql-connector-java-8.0.18.jar说明项目默认走传统 JDBC 直连。很多人卡在“驱动类找不到”其实应该检查两件事。第一jar 是否真的被加入 IDEA 的 Project Structure 或 classpath。第二Class.forName 中类名是否正确。8.0 系列的包名是com.mysql.cj.jdbc.Driver不是com.mysql.jdbc.Driver。URL 写法上推荐在数据库名后面补全以下参数参数示例值作用useSSLfalse本地开发跳过 SSL 握手serverTimezoneAsia/Shanghai修正日期时间偏移characterEncodingutf8保证中文写入不乱码allowPublicKeyRetrievaltrue解决 caching_sha2_password 认证报错MySQL 8.0 默认认证插件是caching_sha2_password部分旧版连接器无法使用。如果你用的就是自带的 8.0.18 驱动绝大多数情况下是没问题的但如果你把 jar 替换成了 5.x 版本就必须在 URL 里增加allowPublicKeyRetrievaltrue或者把 MySQL 用户改为mysql_native_password。5.2 用唯一索引挡住“重复选课”用行锁挡住“超卖”在之前建表 SQL 里student_course表加了联合唯一索引这能直接挡住同一学生对同一课程的重复插入。由于 Service 层有SELECT ... FOR UPDATE锁课程行两个并发用户同一时刻选同一门课时只有第一个事务能拿到锁并更新selected_count第二个事务拿到锁后重新读取的就是最新值。这个组合能同时避免重复选课和超选是原生 JDBC 下最可靠的方案。如果你使用的是 Spring Boot可以补一个Transactional注解来管理事务但这里讨论的是无框架环境所以务必手动在selectCourse方法里把事务控制放对位置。注意FOR UPDATE必须在事务内使用事务提交或回滚后锁会自动释放。如果代码里竟然先执行了commit再执行后续插入锁会提前释放超卖问题又会回来。5.3 高频异常与处理手段异常现象可能原因处理手段ClassNotFoundException: com.mysql.cj.jdbc.Driverjar 未引入 classpath检查 IDEA Project Structure确认 libs 目录已加入依赖Access denied for user rootlocalhost密码错误或账号无访问权限检查 DbUtil 中用户名密码授权内网网段访问Unknown database course_system数据库不存在或名字不一致执行建表脚本前先 CREATE DATABASEConnection reset / Communications link failure服务端主动断开或防火墙拦截缩短空闲时间使用连接池保持连接Deadlock found when trying to get lock; try restarting transaction多事务持锁顺序不一致统一 lock 课程行的执行顺序尽可能减少锁范围在原生 DriverManager 环境下连接并不会自动回收每次用完不关闭最终会触发Too many connections。代码里所有Connection/Statement/ResultSet都必须放进 try-with-resources或者使用DbUtil.close统一释放。5.4 要不要引入连接池当系统流量增大后DriverManager 每次创建连接的开销会变得不可忽略。常见做法是替换成 HikariCP 或 Tomcat JDBC。资源包里没有这些依赖但你可以把 libs 下新增的 jar 放进项目并把 DbUtil 改成从HikariDataSource获取连接。连接池的核心参数通常包括maximumPoolSize、minimumIdle、connectionTimeout。选课系统这类信息管理系统并发量一般几十到几百maximumPoolSize设置到 10-20 已经足够不需要盲目调大连接池过大会反而增加数据库压力。6. 把选课数据接入推荐与预警一个可加进去的轻量规则模块要让这套系统与“人工智能”产生实际联系最简单可行的不是强行训练神经网络而是基于选课历史做规则统计和异动识别。student_course表里已经存了学生、课程、选课时间这就是高质量的行为数据。在原有service包下新增一个RecommendService用 SQL 聚合就能产生初版推荐结果。推荐逻辑可以从三个维度切入热门课程、冲突时间、同专业偏好。热门课程用一条统计 SQL 就能算出按选课次数降序取前五名。冲突时间需要比较course.schedule_time提前判断两门课是否在同一时段。同专业偏好则要建立临时表记录学生专业按相同专业学生的选课行为做协同过滤。下面是热门课程统计的示例SELECT c.id, c.course_name, COUNT(sc.course_id) AS select_cnt FROM student_course sc JOIN course c ON sc.course_id c.id GROUP BY c.id, c.course_name ORDER BY select_cnt DESC LIMIT 5;在这条 SQL 的基础上RecommendService可以取出结果 List在选课成功页展示出来。不需要额外引入大数据框架只需要在原有查询上做一次聚合性能损耗对课设级系统完全可接受。更复杂的预测模型可以用 Java 的weka库也可以把选课记录导出成 CSV 后用 Python 离线训练再用 HTTP 接口为 Java 项目提供预测结果。异常选课检测也能用相似思路实现。假设某门课在一分钟内被同一 IP 报选了 100 次明显不是正常学生的操作习惯。在 Service 层加一个计数器每执行一次选课就把该课程的 IP 计数加一超过阈值时抛出告警并暂时锁定该课程。这种做法不依赖复杂 AI 框架但很有效同时也不会要求学生输入验证码学习成本低。接入后的验证方法我建议这样先跑一遍Test.java的原有流程确认管理员创建新用户、教师录入成绩、学生选课这三条主链路没被改坏。再单独执行推荐模块的查询观察选课次数排序是否和真实选课行为一致。这里需要留意的是推荐结果只能作为辅助信息展示不能在界面上暗示学生“这门课必定热门”否则会带偏选课决策。把推荐计算放到选课成功后的异步任务里不污染主流程新增的数据表继续保持与sys_user、course的主外键关联系统整体便不会因为这个扩展模块失去稳定性。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门