
简介这份资源是面向高校计算机相关专业学生的数据库课程设计完整交付物以学生选课信息管理系统为题目采用Java语言与MySQL数据库、C/S架构实现适合正在准备课程设计或需要参考数据库建模与Java桌面端开发的学习者。系统按学生、教师、管理员三类角色划分功能学生可修改个人信息、查询课程、选课退课、查询成绩并打印成绩单、查看奖惩信息教师可查看所授课程与学生选课情况并录入成绩管理员则负责学生、教师、课程、选课、成绩、院系及奖惩信息等综合管理。压缩包共112个文件约1.99MB包含21个java源文件、66个class编译文件、1个sql建表脚本、1个jar依赖包以及2份doc设计报告另附项目配置与说明文件建表规范合理。目前已有16471人学习下载可帮助读者快速理解选课系统的业务逻辑、数据库表结构设计与C/S分层实现思路并对照设计报告完成自己的课程设计任务。1. 学生选课系统为什么总在退课环节翻车从标题拆出四个必须落地的技术点每年一到选课季教务群里最热闹的不是抢课而是退课。学生点了退课按钮页面转了三秒弹出一句“系统繁忙”再刷新课还在但名额已经被别人抢走了。这个场景几乎成了数据库课程设计的经典翻车现场。标题里的“学生选课信息管理系统”配上“MySQLJava”看起来是一个标准的课程设计题目但真正动手写的时候你会发现它同时踩中了数据库并发控制、事务隔离级别、Java 后端连接池配置、以及前端重复提交四个坑。这篇文章不打算给你一份“能跑就行”的作业模板而是把我在带课程设计和帮学生排查问题时反复遇到的真实问题拆开告诉你一个选课系统从建表到退课到底要过哪几关。适合正在做数据库课程设计、想拿 MySQL 和 Java 写一个能讲清楚原理而不是只交差的学生也适合刚入行、想拿这个项目练手事务和锁的初级开发。读完你至少能判断你的选课系统是真能扛住并发还是只是“看起来能跑”。2. 选课系统的表结构怎么定从 ER 图到 MySQL 建表语句的四个决策点2.1 先想清楚“选课”到底是一张表还是两张表很多课程设计的 ER 图里学生和课程之间画一条“选课”关系然后直接建一张student_course表就完事。这样做不是不行但你要先回答一个问题选课记录需不需要保留“选课时间”和“状态”如果只是学生和课程的多对多映射那student_id course_id联合主键就够了。但真实场景里退课不是物理删除而是把状态改成“已退选”否则你没法做选课历史查询也没法处理“退课后名额释放”的逻辑。我一般会建两张表一张course存课程基本信息和容量一张course_selection存选课记录字段包括id、student_id、course_id、select_time、status。status用TINYINT0 表示已退选1 表示已选上2 表示候补。这样退课就是UPDATE course_selection SET status 0 WHERE ...而不是DELETE。代价是查询时要多带一个status 1条件但换来的是可追溯和可恢复。2.2 课程容量字段到底放哪冗余还是实时算课程表里要不要放一个selected_count字段如果放每次选课成功就UPDATE course SET selected_count selected_count 1退课就减一。好处是查剩余名额快坏处是并发下容易和course_selection表的数据对不上。我见过一个课程设计学生退课了但selected_count没减结果课程显示满员但实际有空位。我的做法是course表只存capacity剩余名额通过SELECT capacity - (SELECT COUNT(*) FROM course_selection WHERE course_id ? AND status 1)实时算。数据量小的时候完全没问题数据量大了再加缓存或定时任务。课程设计阶段实时算更安全也更容易讲清楚一致性。2.3 建表语句里必须写上的约束和索引下面是我在 MySQL 5.7 和 8.0 上都验证过的一套建表语句你可以直接抄但要注意字符集和引擎。-- 学生表 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 课程表 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(20) NOT NULL UNIQUE, course_name VARCHAR(100) NOT NULL, teacher VARCHAR(50), capacity INT NOT NULL DEFAULT 0, credit DECIMAL(3,1) DEFAULT 0.0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 选课记录表 CREATE TABLE course_selection ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 0-退选 1-已选 2-候补, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id), KEY idx_course_status (course_id, status), CONSTRAINT fk_selection_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_selection_course FOREIGN KEY (course_id) REFERENCES course(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明uk_student_course唯一索引保证一个学生不能对同一门课产生两条选课记录这是防止重复选课的第一道防线。idx_course_status是为了加速“查某门课已选人数”的查询。外键约束在课程设计里建议加上虽然生产环境有时会去掉但作业里加上能体现你对参照完整性的理解。参数上status用TINYINT而不是ENUM因为ENUM改起来麻烦TINYINT在 Java 里映射成Integer更顺手。字符集统一utf8mb4避免中文乱码。2.4 选课时间字段的默认值陷阱select_time用了DEFAULT CURRENT_TIMESTAMP这在 MySQL 5.7 和 8.0 里都支持。但要注意如果你在 Java 里用INSERT时不传这个字段数据库会自动填当前时间。如果你传了null那就会报错。我见过学生用 MyBatis 插入时把select_time写成#{selectTime}而 Java 对象里这个字段是null结果直接插入失败。解决办法要么在 Java 里new Date()要么在 SQL 里不写这个字段。课程设计报告里可以把这个点写进去叫“默认值陷阱”答辩时老师会觉得你确实动手跑过。3. Java 后端怎么连 MySQLJDBC 连接池配置与 DAO 层写法3.1 为什么不要用 DriverManager 直接连课程设计里最常见的写法是DriverManager.getConnection(url, user, password)每次操作都新建连接。这在单机测试时没问题但选课系统一旦有多个学生同时点连接数会瞬间涨上去MySQL 默认max_connections是 151超过就报Too many connections。我一般会让学生用 HikariCP 或者 Druid但考虑到课程设计环境可能不让加太多依赖用DriverManager加一个简单的连接池封装也能讲清楚原理。下面是一个最小可用的连接池示例用BlockingQueue存连接适合课程设计演示。import java.sql.*; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; public class SimplePool { private static final String URL jdbc:mysql://localhost:3306/course_system?useSSLfalseserverTimezoneUTCcharacterEncodingutf8; private static final String USER root; private static final String PASSWORD your_password; private static final int POOL_SIZE 10; private static final BlockingQueueConnection pool new LinkedBlockingQueue(POOL_SIZE); static { try { Class.forName(com.mysql.cj.jdbc.Driver); for (int i 0; i POOL_SIZE; i) { pool.offer(DriverManager.getConnection(URL, USER, PASSWORD)); } } catch (Exception e) { throw new RuntimeException(连接池初始化失败, e); } } public static Connection getConnection() throws InterruptedException { return pool.take(); } public static void release(Connection conn) { if (conn ! null) { pool.offer(conn); } } }逻辑说明静态块里预创建 10 个连接放进BlockingQueue。getConnection用take()队列空时阻塞等待避免无限创建连接。release把连接放回队列。参数上useSSLfalse在本地开发时避免 SSL 警告serverTimezoneUTC防止时区报错characterEncodingutf8保证中文正常。注意这个简易池没有做连接有效性检测如果 MySQL 重启池里的连接会失效。课程设计里可以接受但你要知道生产环境要用 HikariCP 的connectionTestQuery。3.2 选课操作的 DAO 写法与事务边界选课的核心逻辑是检查课程是否已满、检查学生是否已选、插入选课记录、更新课程已选人数如果用了冗余字段。这四步必须在一个事务里。下面是一个用 JDBC 手写事务的示例。public boolean selectCourse(int studentId, int courseId) { Connection conn null; try { conn SimplePool.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 查课程容量和已选人数加行锁 String checkSql SELECT capacity, (SELECT COUNT(*) FROM course_selection WHERE course_id ? AND status 1) AS selected FROM course WHERE id ? FOR UPDATE; PreparedStatement ps conn.prepareStatement(checkSql); ps.setInt(1, courseId); ps.setInt(2, courseId); ResultSet rs ps.executeQuery(); if (!rs.next()) { conn.rollback(); return false; } int capacity rs.getInt(capacity); int selected rs.getInt(selected); if (selected capacity) { conn.rollback(); return false; // 课程已满 } // 2. 检查是否已经选过 String dupSql SELECT id FROM course_selection WHERE student_id ? AND course_id ? AND status 1; ps conn.prepareStatement(dupSql); ps.setInt(1, studentId); ps.setInt(2, courseId); rs ps.executeQuery(); if (rs.next()) { conn.rollback(); return false; // 已选过 } // 3. 插入选课记录 String insertSql INSERT INTO course_selection (student_id, course_id, status) VALUES (?, ?, 1); ps conn.prepareStatement(insertSql); ps.setInt(1, studentId); ps.setInt(2, courseId); ps.executeUpdate(); conn.commit(); return true; } catch (Exception e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); } catch (SQLException e) { e.printStackTrace(); } SimplePool.release(conn); } } }逻辑说明FOR UPDATE对课程行加排他锁防止两个学生同时读到相同的已选人数。setAutoCommit(false)开启事务任何一步失败都rollback。参数上status 1表示有效选课。注意FOR UPDATE在 MySQL 里必须用在事务中否则锁会立即释放。这个写法在并发不高时没问题但如果选课人数多行锁会变成热点后面会讲怎么优化。3.3 退课操作为什么不能直接 DELETE退课如果写成DELETE FROM course_selection WHERE student_id ? AND course_id ?那选课历史就没了而且如果学生误操作没法恢复。正确做法是UPDATE course_selection SET status 0 WHERE student_id ? AND course_id ? AND status 1。这样名额释放的逻辑就变成查已选人数时只统计status 1的记录。退课本身也要放在事务里并且要检查是否真的有一条status 1的记录被更新否则可能是重复退课。public boolean dropCourse(int studentId, int courseId) { Connection conn null; try { conn SimplePool.getConnection(); conn.setAutoCommit(false); String sql UPDATE course_selection SET status 0 WHERE student_id ? AND course_id ? AND status 1; PreparedStatement ps conn.prepareStatement(sql); ps.setInt(1, studentId); ps.setInt(2, courseId); int rows ps.executeUpdate(); if (rows 0) { conn.rollback(); return false; // 没有可退的选课记录 } conn.commit(); return true; } catch (Exception e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); } catch (SQLException e) { e.printStackTrace(); } SimplePool.release(conn); } } }逻辑说明rows 0说明没有找到status 1的记录可能是已经退过了或者根本没选过。返回false让前端提示“退课失败”。参数上status 0是退选状态。这个写法比DELETE多了一次更新但保留了历史也避免了唯一索引冲突因为uk_student_course还在只是状态变了。4. 并发选课怎么不超卖MySQL 锁机制与隔离级别的实战选择4.1 超卖是怎么发生的两个学生同时读到 29/30假设课程容量 30已选 29。学生 A 和学生 B 同时点选课。A 的事务读到已选 29判断 29 30准备插入。B 的事务也读到已选 29也判断 29 30。然后 A 插入成功B 也插入成功。结果已选变成 31超卖。这就是典型的“读-判断-写”竞态。解决办法有三种一是用FOR UPDATE对课程行加锁让 B 等 A 提交后再读二是用UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity利用 MySQL 的行锁和WHERE条件原子性三是用应用层锁比如synchronized但只适合单机。课程设计里我推荐第一种或第二种因为能讲清楚数据库锁的原理。4.2 隔离级别选 READ COMMITTED 还是 REPEATABLE READMySQL 默认隔离级别是REPEATABLE READ。在这个级别下SELECT ... FOR UPDATE会加行锁但普通SELECT是一致性读读的是快照。如果你在事务里先普通SELECT查已选人数再INSERT那查到的可能是旧数据。所以选课逻辑里要么用FOR UPDATE要么把隔离级别降到READ COMMITTED。我一般建议课程设计用默认的REPEATABLE READ但所有涉及判断的查询都加FOR UPDATE。这样虽然锁范围大一点但逻辑清晰不容易出错。你可以用SELECT transaction_isolation;查看当前隔离级别用SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;临时改。4.3 用 UPDATE 条件更新代替 SELECT FOR UPDATE第二种方案更轻量不在事务里先查再插而是直接尝试更新课程已选人数用WHERE selected_count capacity保证不超卖。但前提是course表里要有selected_count字段。更新影响行数为 1 才继续插入选课记录否则回滚。UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity;如果返回 1说明抢到名额再INSERT INTO course_selection。如果返回 0说明满了。这个方案的好处是不需要FOR UPDATE锁粒度更小。坏处是selected_count可能和实际记录数不一致需要定期校准。课程设计里可以两种都写对比优缺点答辩时是加分项。4.4 死锁是怎么来的两个事务互相等锁如果学生 A 选了课程 1 和课程 2学生 B 选了课程 2 和课程 1两个事务都先锁课程 1 再锁课程 2就会死锁。MySQL 会自动检测死锁并回滚其中一个事务报Deadlock found when trying to get lock。避免办法是按固定顺序加锁比如按course_id升序。在 Java 里如果一次选多门课先把course_id排序再依次加锁。课程设计里一般一次只选一门课所以死锁不常见但你要知道这个坑。5. 选课系统避坑清单从环境配置到 SQL 写法的五个血泪教训5.1 现象MySQL 8.0 连接报Public Key Retrieval is not allowed原因MySQL 8.0 默认用caching_sha2_password认证插件JDBC 连接时如果不允许公钥检索就会报这个错。解决在 JDBC URL 里加allowPublicKeyRetrievaltrue或者把用户认证插件改成mysql_native_password。我一般直接在 URL 里加参数改起来快。jdbc:mysql://localhost:3306/course_system?useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneUTC5.2 现象插入中文变成问号原因数据库、表、连接三处的字符集不一致。解决建库时用CREATE DATABASE course_system DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;建表时指定DEFAULT CHARSETutf8mb4JDBC URL 加characterEncodingutf8。三处都对了中文就不会乱。5.3 现象选课成功但页面显示“已满”原因前端查剩余名额时用了缓存或者selected_count没更新。解决每次选课/退课后前端重新查一次课程列表或者后端返回最新的剩余名额。课程设计里不要在前端算剩余名额一律从后端拿。5.4 现象退课按钮点两次第二次报错原因第一次退课已经把status改成 0第二次UPDATE ... WHERE status 1影响行数为 0后端返回失败。解决前端点一次后禁用按钮或者后端把“已退选”也当成成功处理。我一般让后端返回true如果记录已经是退选状态避免学生困惑。5.5 现象FOR UPDATE没生效还是超卖原因FOR UPDATE必须在事务里如果autocommit是true锁会立即释放。解决确保conn.setAutoCommit(false)在FOR UPDATE之前执行并且事务提交或回滚后再恢复autocommit。这个坑我见过至少五次每次都是学生忘了关自动提交。6. 用存储过程把选课逻辑收进数据库一个可验证的进阶技巧课程设计写到后面老师可能会问“能不能把选课逻辑放到数据库层”这时候你可以用一个存储过程来展示你对 MySQL 编程的理解。下面这个存储过程封装了选课的核心判断Java 只需要调用CALL select_course(?, ?, result)然后取result。DELIMITER // CREATE PROCEDURE select_course( IN p_student_id INT, IN p_course_id INT, OUT p_result INT ) BEGIN DECLARE v_capacity INT; DECLARE v_selected INT; DECLARE v_exists INT; -- 开启事务 START TRANSACTION; -- 检查课程是否存在并加锁 SELECT capacity INTO v_capacity FROM course WHERE id p_course_id FOR UPDATE; IF v_capacity IS NULL THEN SET p_result -1; -- 课程不存在 ROLLBACK; ELSE -- 查已选人数 SELECT COUNT(*) INTO v_selected FROM course_selection WHERE course_id p_course_id AND status 1; -- 查是否已选 SELECT COUNT(*) INTO v_exists FROM course_selection WHERE student_id p_student_id AND course_id p_course_id AND status 1; IF v_exists 0 THEN SET p_result -2; -- 已选过 ROLLBACK; ELSEIF v_selected v_capacity THEN SET p_result -3; -- 已满 ROLLBACK; ELSE INSERT INTO course_selection (student_id, course_id, status) VALUES (p_student_id, p_course_id, 1); SET p_result 1; -- 成功 COMMIT; END IF; END IF; END // DELIMITER ;逻辑说明FOR UPDATE锁住课程行START TRANSACTION和COMMIT/ROLLBACK控制事务。p_result返回 1 表示成功负数表示不同失败原因。Java 调用时用CallableStatementCallableStatement cs conn.prepareCall({CALL select_course(?, ?, ?)}); cs.setInt(1, studentId); cs.setInt(2, courseId); cs.registerOutParameter(3, Types.INTEGER); cs.execute(); int result cs.getInt(3);参数上p_result是输出参数Java 里用registerOutParameter注册。这个存储过程的优点是逻辑集中在数据库层Java 代码更薄。缺点是调试麻烦而且不同数据库语法不通用。课程设计里可以作为一个亮点但不要把所有逻辑都塞进去否则答辩时老师问“为什么不用 Java 写”你不好回答。验证方法开两个 MySQL 客户端同时调用这个存储过程选同一门容量只剩 1 的课看是否只有一个返回 1另一个返回 -3。如果两个都返回 1说明锁没生效检查FOR UPDATE是否在事务里。我一般会让学生自己跑这个测试跑通了再写进报告。最后说一个我自己的习惯每次写完选课逻辑我都会用SHOW ENGINE INNODB STATUS看一眼有没有死锁再用EXPLAIN看course_selection的查询有没有走索引。课程设计不是交完就完而是你以后面试时能讲清楚“我处理过并发选课”的底气。希望帮到你。本文还有配套的精品资源点击获取