简介面向高校数据库课程设计与Java课程实践场景这份学生选课信息管理系统提供可直接运行的CS架构示例覆盖学生、教师、管理员三类角色学生可维护个人信息、选课退课与成绩查询教师可查看授课及选课情况并录入成绩管理员负责学生、教师、课程、选课、成绩、院系与奖惩信息等管理适合用来理解权限划分、业务表关系与完整功能闭环。压缩包共98个文件以21个java源码、66个class编译文件及sql建表脚本、doc设计报告、jar驱动和项目配置文件为主整体约1.98MB源码、数据库脚本与报告相互配套建表较规范。已有11833人学习下载便于读者对照设计报告梳理E-R关系、理解各模块实现并完成课程设计或二次开发。1. 从“能跑就行”到“能答辩”一份 MySQLJava 选课系统的真实定位答辩前一晚A 同学演示学生选课信息管理系统两个人同时点“选课”课程容量从 1 变成 -1台下老师问了一句“你这个已选人数是怎么维护的”场面就僵住了。这类数据库课程设计题目年年有人做MySQL 建三张表、Java 写几个 CRUD 界面看着谁都会真正拉开差距的从来不是界面而是约束有没有落到库里、选课扣减有没有包在事务里、并发下会不会超选。这份资源给的是 MySQL 8 加原生 JavaJDBC DAO 分层不套框架的完整实现思路核心就三件事表结构与约束、事务边界、并发下的正确性。适合正在做数据库课程设计、Java 课程设计或者需要交一份“能跑、能讲、被追问也答得上”的选课系统的同学也适合已经写完 CRUD、但说不清自己代码在并发下会发生什么的开发者。2. 库表设计先落地student/course/sc 三张表加 6 条约束2.1 先定实体和关系再谈建表选课系统的实体不多但关系容易画歪。学生student和课程course是多对多中间必须有选课关系表sc成绩、选课时间这些“属于某次选课行为”的字段只能挂在这张关系表上挂到学生表或课程表上都是建模错误。教师teacher与课程是一对多把教师号放在课程表里做外键即可不必单独再拉一张中间表。很多课设翻车不是因为 SQL 写错而是一开始把成绩放进了 student 表后面一个学生选两门课就没地方存分了。我一般会先把这三件事写死主键用什么、哪些列不允许为空、哪些组合必须唯一。学号用 CHAR(10) 而不是 INT因为学号可能带字母、可能有前导零用 INT 早晚要在某次导入时被截断课程号同理用 CHAR(8)。选课表额外加一个自增 BIGINT 做主键、把 (sno, cno) 做成唯一索引这样“同一学生重复选同一门课”在数据库层就被堵死应用层查重只是优化体验不是最后一道防线。容量控制这块course 表里同时放 capacity容量和 selected已选人数是常见做法好处是列表页一次查询就能显示“已选 32/50”不用每行去 count 一次 sc 表。代价是 selected 成了冗余字段必须靠事务保证它和 sc 表的真实行数一致这一点在第 3 章会展开。如果不想维护冗余也可以每次实时 count但在课程列表页会有 N1 查询问题课设规模下不明显数据量一大就难看。2.2 建表 SQL字符集、引擎、约束一次到位下面这三段是我建议直接抄的结构字符集统一 utf8mb4引擎统一 InnoDB事务和行锁都靠它外键和唯一索引全部显式命名方便后面报错时定位。-- 学生表学号做主键专业允许为空但给默认值 CREATE TABLE student ( sno CHAR(10) NOT NULL COMMENT 学号, sname VARCHAR(20) NOT NULL COMMENT 姓名, major VARCHAR(40) NOT NULL DEFAULT COMMENT 专业, grade SMALLINT NOT NULL COMMENT 入学年份如 2023, PRIMARY KEY (sno) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci; -- 课程表selected 是冗余计数靠 CHECK 兜住上下界 CREATE TABLE course ( cno CHAR(8) NOT NULL COMMENT 课程号, cname VARCHAR(60) NOT NULL COMMENT 课程名, credit DECIMAL(3,1) NOT NULL DEFAULT 2.0 COMMENT 学分, capacity SMALLINT NOT NULL DEFAULT 50 COMMENT 容量, selected SMALLINT NOT NULL DEFAULT 0 COMMENT 已选人数, tno CHAR(6) NULL COMMENT 任课教师号, PRIMARY KEY (cno), KEY idx_course_tno (tno), CONSTRAINT ck_course_selected CHECK (selected 0 AND selected capacity) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci; -- 选课关系表(sno, cno) 唯一成绩允许 NULL 表示未出分 CREATE TABLE sc ( id BIGINT NOT NULL AUTO_INCREMENT, sno CHAR(10) NOT NULL, cno CHAR(8) NOT NULL, score DECIMAL(4,1) NULL COMMENT 成绩NULL 表示未录入, select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_sc_sno_cno (sno, cno), KEY idx_sc_cno (cno), CONSTRAINT fk_sc_student FOREIGN KEY (sno) REFERENCES student(sno), CONSTRAINT fk_sc_course FOREIGN KEY (cno) REFERENCES course(cno) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;逻辑说明uk_sc_sno_cno是防重复选课的关键顺序不能反(sno, cno) 这个顺序同时能服务“查某学生选了哪些课”的查询idx_sc_cno单独建是为了“查某门课有哪些学生”和按课程统计时走索引否则只能靠唯一索引的最左前缀 sno查 cno 会全表扫。参数说明几个容易忽略的点CHECK约束在 MySQL 8.0.16 之前是被解析但忽略的如果你的环境是 8.0.16 以下这条约束等于没写得靠应用层补utf8mb4_0900_ai_ci是 8.0 默认排序规则如果学校机房装的是 5.7换成utf8mb4_general_ci否则建表直接报错DECIMAL(4,1)表示成绩最多 999.9 一位小数够用且不会出现浮点误差。提示外键会让批量导入学生数据时受顺序限制必须先导 student 再导 sc。如果答辩环境要求快速重置数据可以先SET FOREIGN_KEY_CHECKS0再清表导完记得改回 1。2.3 初始化数据与自增主键的取舍初始化数据别手写几十条 INSERT用存储过程或者一段带变量的 SQL 批量造学生 200 条、课程 30 条、选课记录随机 500 条基本能撑起演示和索引测试。课程数据里故意留两门容量很小的课比如容量 3、容量 1答辩时用来演示“选满”的拦截逻辑比嘴上说“我做了容量校验”有说服力得多。sc 表用自增 id 还是用 (sno, cno) 联合主键两边都有人用。联合主键省一个列、天然唯一但退课再选时主键值会重复出现某些 ORM 映射会不认自增 id 多一列换来的是关系表有了稳定标识后面做“选课记录导出”“成绩单打印”时按 id 排序很省事。课设场景我偏向自增 id代价只是多 8 字节。3. Java 端 DAO 与事务选课扣减、退课回滚的完整代码路径3.1 分层怎么切entity / dao / service / ui 各管什么不套框架的 Java 课设最容易被老师追问的是“你这个类为什么这么分”。一个能自圆其说的切法是四层entity 只放字段和 getter/setter不写业务dao 只做单表或简单联表的增删改查一个方法一个 SQL不掺杂判断service 承载业务规则和事务边界选课能不能成功、容量够不够、退课要不要回滚都在这一层ui 用 Swing 或控制台都行只负责收集参数和展示结果不允许直接调 dao。关键原则是事务只能开在 service 层。dao 里每个方法自己拿连接、自己提交那 service 就没法把“插选课记录”和“更新已选人数”绑成一个原子操作。我一般让 dao 的方法接收一个外部传入的Connection由 service 统一管理连接的获取、提交和回滚这样同一个事务里能串起多个 dao 调用也不用引入连接池之外的东西。包结构大致是entity、dao、dao.impl、service、service.impl、ui、utilutil 里放 DBUtil 和日期格式化。别把 SQL 字符串散落在 ui 里答辩时老师随手让你改个查询条件散落的 SQL 会让你当场找十分钟。3.2 连接与 PreparedStatement参数化是底线连接工具类负责三件事加载驱动、给连接串、统一关闭资源。连接串里几个参数必须显式写死不然换台机器就乱码或者时区错乱。public final class DBUtil { // useUnicode characterEncoding 保证中文不乱码 // serverTimezone 用 GMT8避免插入时间差 8 小时 private static final String URL jdbc:mysql://127.0.0.1:3306/cs_db?useUnicodetruecharacterEncodingutf8 serverTimezoneGMT%2B8useSSLfalseallowPublicKeyRetrievaltrue; private static final String USER cs_app; private static final String PWD change_me; static { try { // MySQL Connector/J 8.x 的驱动类名5.x 是 com.mysql.jdbc.Driver Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL 驱动未找到检查 jar 是否在 classpath); } } private DBUtil() {} public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } // 可变参数统一关闭避免 finally 里嵌套 try-catch public static void close(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r ! null) { try { r.close(); } catch (Exception ignore) { } } } } }逻辑说明Class.forName放在静态块里只执行一次驱动加载失败直接抛ExceptionInInitializerError比运行到一半报“No suitable driver”好排查。close用可变参数是为了让调用方一行写完DBUtil.close(rs, ps, conn)顺序按“后开先关”传。参数说明characterEncodingutf8配合库表的 utf8mb4实际传输走的是 utf8mb4不会出现四字节字符比如某些生僻字、emoji插入失败allowPublicKeyRetrievaltrue在 MySQL 8 默认的 caching_sha2_password 认证下不加会报公钥检索失败useSSLfalse仅限本机课设环境生产环境要开。所有带参数的 SQL 一律用PreparedStatement包括看起来“不会有注入风险”的按学号查询。拼接字符串在课设里被老师扫一眼就是扣分项而且学号里真出现单引号时程序会直接崩。查询可选课程时常见写法是WHERE cno NOT IN (SELECT cno FROM sc WHERE sno ?)。这里有个坑如果子查询结果里出现 NULLNOT IN会整体返回空集。sc.cno 有 NOT NULL 约束所以本例安全但换成“查没成绩的课”这类条件就容易中招。稳妥写法是NOT EXISTSSELECT c.cno, c.cname, c.credit, c.capacity, c.selected FROM course c WHERE NOT EXISTS ( SELECT 1 FROM sc WHERE sc.cno c.cno AND sc.sno ? ) ORDER BY c.cno;3.3 选课事务锁行、判容量、插记录、加计数选课逻辑的核心不是“能不能插进去”而是“两个人在同一毫秒选最后一门课容量为 1 的课时只能有一个人成功”。正确路径是开事务 → 用SELECT ... FOR UPDATE锁住该课程行 → 判断 selected 是否小于 capacity → 插入 sc 记录 →UPDATE course SET selected selected 1→ 提交。public boolean enroll(String sno, String cno) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 手动事务开始 // 1. 行锁锁住课程后面的并发请求会在这里排队 int capacity; int selected; try (PreparedStatement ps conn.prepareStatement( SELECT capacity, selected FROM course WHERE cno ? FOR UPDATE)) { ps.setString(1, cno); try (ResultSet rs ps.executeQuery()) { if (!rs.next()) { conn.rollback(); return false; } // 课程不存在 capacity rs.getInt(capacity); selected rs.getInt(selected); } } if (selected capacity) { conn.rollback(); return false; } // 已满 // 2. 插入选课记录唯一索引挡住重复选课 try (PreparedStatement ps conn.prepareStatement( INSERT INTO sc(sno, cno) VALUES(?, ?))) { ps.setString(1, sno); ps.setString(2, cno); ps.executeUpdate(); } // 3. 计数 1WHERE 里再兜一次容量双保险 try (PreparedStatement ps conn.prepareStatement( UPDATE course SET selected selected 1 WHERE cno ? AND selected capacity)) { ps.setString(1, cno); if (ps.executeUpdate() ! 1) { conn.rollback(); return false; } } conn.commit(); return true; } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignore) { } } // 1062 唯一键冲突说明重复选课属于业务失败不是系统异常 return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); } catch (SQLException ignore) { } DBUtil.close(conn); } } }逻辑说明FOR UPDATE加的是行锁作用域是当前事务第二个并发请求执行到同一行会被阻塞直到第一个事务提交或回滚从而把“判断 扣减”变成串行。步骤 3 的WHERE selected capacity是冗余校验防止有人绕过 service 直接改库导致计数越界。捕获 SQLException 时不区分具体错误码会掩盖真实问题实际代码里建议判断e.getErrorCode() 1062再返回“已选过”其他错误码往上抛或者记日志。参数说明cno用setString而不是拼进 SQL避免课程号里带特殊字符conn.setAutoCommit(false)必须在第一条 SQL 之前调用放在之后不生效finally 里恢复 autoCommit 是为了连接被复用时状态干净如果后面要接连接池这一步可以省但课设用 DriverManager 时建议保留。退课是反向操作顺序相反先删 sc 记录判断影响行数为 1 再减计数。如果先减计数再删记录删失败就会留下“人数少了但记录还在”的脏数据。public boolean drop(String sno, String cno) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); try (PreparedStatement ps conn.prepareStatement( DELETE FROM sc WHERE sno ? AND cno ?)) { ps.setString(1, sno); ps.setString(2, cno); if (ps.executeUpdate() ! 1) { conn.rollback(); return false; } // 没选过这门课 } try (PreparedStatement ps conn.prepareStatement( UPDATE course SET selected selected - 1 WHERE cno ? AND selected 0)) { ps.setString(1, cno); if (ps.executeUpdate() ! 1) { conn.rollback(); return false; } } conn.commit(); return true; } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignore) { } } return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); } catch (SQLException ignore) { } DBUtil.close(conn); } } }这里DELETE的影响行数判断是防“重复退课”UPDATE的selected 0是防计数减成负数。两个判断任何一个不满足都整体回滚保证 sc 行数和 course.selected 始终对得上。4. 踩坑排查5 个让演示当场翻车的现场与修法4.1 数据一致性超选、重复选、脏计数现象一并发点选课selected 超过 capacity甚至出现负数。原因很直接代码里先SELECT selected判断再UPDATE selected selected 1两条 SQL 之间没有锁两个线程都读到 4、都判断 4 5 通过然后各自加一变成 6。解决就是把判断和扣减放进同一个事务并且用SELECT ... FOR UPDATE锁行或者直接用UPDATE course SET selected selected 1 WHERE cno ? AND selected capacity这一条语句做 CAS靠影响行数判断成败。前者适合还要做其他校验的场景后者更轻。现象二同一学生连续点两次选课sc 表出现两条相同记录。原因是应用层用“先查有没有、没有再插”的逻辑两次点击都查到了“没有”。解决办法不是在 Java 里加 synchronized多实例部署就失效而是靠 2.2 里的uk_sc_sno_cno唯一索引插入时捕获错误码 1062转成“您已选过该课程”的提示。数据库约束是最后一道防线应用层判断只是提前拦截。现象三退课之后课程列表的已选人数对不上明明没人选却显示已选 3 人。通常是退课逻辑先更新计数、后删记录删记录时抛异常回滚了计数但记录没删或者反过来删了记录但计数更新失败却没回滚。统一做法是两件事必在同一事务里且用影响行数做前置判断任一环节返回 0 就整体 rollback。上线前跑一遍“选 10 次退 10 次看 selected 是否回到 0”这个自检比看代码管用。4.2 连接、字符集与查询性能现象四学生姓名显示成问号或者乱码导出的 CSV 打开也是乱码。乱码要分三处查库表字符集是不是 utf8mb4、连接串有没有useUnicodetruecharacterEncodingutf8、Java 源文件编译时编码是不是 UTF-8。三处任何一处不一致都会乱。最常见的是库表建成了 latin1或者连接串漏了参数。建库语句里直接写CREATE DATABASE cs_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci;从源头堵住。现象五程序跑十几分钟就卡死或者报“Too many connections”。原因是 Connection、PreparedStatement、ResultSet 没有在 finally 里关闭每操作一次泄漏一个连接MySQL 默认最大连接数 151点几十次就到顶。修法是用 try-with-resources 或者在 finally 里按“ResultSet → Statement → Connection”的顺序关闭且每个 close 单独 try-catch避免前一个抛异常导致后面关不掉。课设阶段用 DriverManager 每次新建连接泄漏问题比连接池更早暴露这其实是好事。另外补一个索引层面的课程列表页随着 sc 表变大越来越慢EXPLAIN一看是typeALL全表扫。查“某学生没选的课”时子查询里的sc.sno要有索引uk_sc_sno_cno的最左前缀已经覆盖查“某门课的学生名单”时sc.cno要有独立索引就是 2.2 里那条idx_sc_cno。索引不是越多越好sc 表三个索引已经够课设用再加就是给插入添负担。5. 并发压测与答辩自查用 20 线程把超选问题提前打出来选课系统的正确性不能靠手点两下验证。答辩前我一般会写一个 20 线程抢同一门容量 5 的课的压测跑完看三个数成功人数必须是 5、sc 表该课记录数必须是 5、course.selected 必须是 5三个数任何一个对不上就说明事务或约束有问题。// 20 个线程同时抢 cnoC001容量 5统计成功次数 int threads 20; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch start new CountDownLatch(1); // 让所有线程同时起跑 CountDownLatch done new CountDownLatch(threads); AtomicInteger success new AtomicInteger(); for (int i 0; i threads; i) { final String sno String.format(2023%06d, i); // 造 20 个不同学号 pool.submit(() - { try { start.await(); // 统一等待发令 if (service.enroll(sno, C001)) success.incrementAndGet(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { done.countDown(); } }); } start.countDown(); // 发令 done.await(); // 等全部结束 pool.shutdown(); System.out.println(成功人数 success.get()); // 期望正好 5CountDownLatch的作用是把 20 个线程压到同一时刻发起请求不用它的话线程是陆续启动的并发冲突根本压不出来。跑完再用两条 SQL 对账SELECT COUNT(*) FROM sc WHERE cnoC001和SELECT selected FROM course WHERE cnoC001两个结果都必须等于 5。如果成功人数大于 5回去看第 3 章的事务是不是漏了FOR UPDATE如果成功人数小于 5 但课程没满大概率是唯一索引误伤或者连接串时区问题导致插入异常被吞掉了。答辩现场被问得最多的其实是边界我整理了一张自查表演示前逐条过一遍检查项期望结果不通过时先看哪里容量为 1 的课两人同抢仅 1 人成功service 是否加行锁或 CAS同一学生连点两次选课第二次提示已选sc 唯一索引是否建成退课后重新选同一门课能正常选上退课是否真删了 sc 行容量为 0 的课任何人都选不上CHECK 约束与 WHERE 条件学生姓名含生僻字不乱码、不截断三处字符集是否统一连续操作 100 次连接数不持续上涨finally 是否关闭资源最后说个血泪经验我最早做这类课设时喜欢在 service 里用synchronized保证选课不超卖本机演示确实没问题老师一问“如果部署两台机器呢”就答不上来。后来改成数据库行锁加唯一索引代码反而更短也经得起追问。从那以后我每次交付这类系统都会先跑一遍 20 线程压测再打开界面压测不过就不演示。希望帮到你。本文还有配套的精品资源点击获取