简介:面向高校数据库课程设计与Java课程实践场景,这份学生选课信息管理系统提供可直接运行的CS架构示例,覆盖学生、教师、管理员三类角色:学生可维护个人信息、选课退课与成绩查询,教师可查看授课及选课情况并录入成绩,管理员负责学生、教师、课程、选课、成绩、院系与奖惩信息等管理,适合用来理解权限划分、业务表关系与完整功能闭环。压缩包共98个文件,以21个java源码、66个class编译文件及sql建表脚本、doc设计报告、jar驱动和项目配置文件为主,整体约1.98MB,源码、数据库脚本与报告相互配套,建表较规范。已有11833人学习下载,便于读者对照设计报告梳理E-R关系、理解各模块实现并完成课程设计或二次开发。
1. 从“能跑就行”到“能答辩”:一份 MySQL+Java 选课系统的真实定位
答辩前一晚,A 同学演示学生选课信息管理系统,两个人同时点“选课”,课程容量从 1 变成 -1,台下老师问了一句“你这个已选人数是怎么维护的”,场面就僵住了。这类数据库课程设计题目年年有人做,MySQL 建三张表、Java 写几个 CRUD 界面,看着谁都会,真正拉开差距的从来不是界面,而是约束有没有落到库里、选课扣减有没有包在事务里、并发下会不会超选。这份资源给的是 MySQL 8 加原生 Java(JDBC + 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,但在课程列表页会有 N+1 查询问题,课设规模下不明显,数据量一大就难看。
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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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_CHECKS=0再清表,导完记得改回 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、util,util 里放 DBUtil 和日期格式化。别把 SQL 字符串散落在 ui 里,答辩时老师随手让你改个查询条件,散落的 SQL 会让你当场找十分钟。
3.2 连接与 PreparedStatement:参数化是底线
连接工具类负责三件事:加载驱动、给连接串、统一关闭资源。连接串里几个参数必须显式写死,不然换台机器就乱码或者时区错乱。
public final class DBUtil { // useUnicode + characterEncoding 保证中文不乱码 // serverTimezone 用 GMT+8,避免插入时间差 8 小时 private static final String URL = "jdbc:mysql://127.0.0.1:3306/cs_db?useUnicode=true&characterEncoding=utf8" + "&serverTimezone=GMT%2B8&useSSL=false&allowPublicKeyRetrieval=true"; 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),顺序按“后开先关”传。
参数说明:characterEncoding=utf8配合库表的 utf8mb4,实际传输走的是 utf8mb4,不会出现四字节字符(比如某些生僻字、emoji)插入失败;allowPublicKeyRetrieval=true在 MySQL 8 默认的 caching_sha2_password 认证下不加会报公钥检索失败;useSSL=false仅限本机课设环境,生产环境要开。
所有带参数的 SQL 一律用PreparedStatement,包括看起来“不会有注入风险”的按学号查询。拼接字符串在课设里被老师扫一眼就是扣分项,而且学号里真出现单引号时程序会直接崩。
查询可选课程时,常见写法是WHERE cno NOT IN (SELECT cno FROM sc WHERE sno = ?)。这里有个坑:如果子查询结果里出现 NULL,NOT IN会整体返回空集。sc.cno 有 NOT NULL 约束所以本例安全,但换成“查没成绩的课”这类条件就容易中招。稳妥写法是NOT EXISTS:
SELECT 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. 计数 +1,WHERE 里再兜一次容量,双保险 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、连接串有没有useUnicode=true&characterEncoding=utf8、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一看是type=ALL全表扫。查“某学生没选的课”时,子查询里的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 个线程同时抢 cno=C001(容量 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 cno='C001'和SELECT selected FROM course WHERE cno='C001',两个结果都必须等于 5。如果成功人数大于 5,回去看第 3 章的事务是不是漏了FOR UPDATE;如果成功人数小于 5 但课程没满,大概率是唯一索引误伤或者连接串时区问题导致插入异常被吞掉了。
答辩现场被问得最多的其实是边界,我整理了一张自查表,演示前逐条过一遍:
| 检查项 | 期望结果 | 不通过时先看哪里 |
|---|---|---|
| 容量为 1 的课两人同抢 | 仅 1 人成功 | service 是否加行锁或 CAS |
| 同一学生连点两次选课 | 第二次提示已选 | sc 唯一索引是否建成 |
| 退课后重新选同一门课 | 能正常选上 | 退课是否真删了 sc 行 |
| 容量为 0 的课 | 任何人都选不上 | CHECK 约束与 WHERE 条件 |
| 学生姓名含生僻字 | 不乱码、不截断 | 三处字符集是否统一 |
| 连续操作 100 次 | 连接数不持续上涨 | finally 是否关闭资源 |
最后说个血泪经验:我最早做这类课设时,喜欢在 service 里用synchronized保证选课不超卖,本机演示确实没问题,老师一问“如果部署两台机器呢”就答不上来。后来改成数据库行锁加唯一索引,代码反而更短,也经得起追问。从那以后我每次交付这类系统,都会先跑一遍 20 线程压测再打开界面,压测不过就不演示。希望帮到你。
本文还有配套的精品资源,点击获取