简介这是一套面向高校计算机相关专业学生的数据库设计课程设计完整源码采用jspservletjavabean技术路线基于Eclipse与Tomcat8.5开发数据库选用SQL Server 2017适合作为课程设计、毕业设计或Java Web入门练手项目。压缩包共237个文件约5.95MB涵盖java源码、jsp页面、css样式、js脚本及图片资源并附有说明文档、E-R图与实体关系模型便于理解整体架构。系统按管理员、教师、学生三种角色划分权限管理员负责教师、学生、课程信息的增删改查及密码维护教师可查看任课信息、开设或取消课程、录入成绩并自动更新学分还能按班级、课程、学年统计成绩与排名学生则可查看个人信息与课程、完成选退课及学年成绩查询。目前已有1052人学习下载配套文档与关系模型能帮助读者快速理清表结构设计与业务逻辑适合需要完整数据库课程设计参考方案的学习者。1. 高校选课管理系统为什么 JSPServletJavaBean 依然是数据库课设的硬骨头每年一到数据库设计课程设计选题高校选课管理系统几乎是出现频率最高的题目之一。很多同学第一反应是去网上找一套现成源码改改界面就交差结果答辩时被老师追问“你的表结构为什么这么设计”“并发选同一门课怎么处理”当场卡壳。这套系统表面上看只是学生选课、教师录入成绩、管理员维护课程但它背后牵扯到数据库设计里最核心的几件事实体关系建模、事务控制、并发冲突处理、外键约束与索引优化。用 JSPServletJavaBean 这套经典 JavaWeb 组合来实现恰好能把数据库设计和后端逻辑串成一条线而不是把数据库当成一个只负责存数据的黑匣子。这篇文章面向的是正在做数据库课程设计、选了高校选课管理系统这个题目的同学也适合想用传统 JavaWeb 技术栈把数据库设计真正落地一遍的开发者。我会从表结构怎么设计开始一路讲到 Servlet 怎么处理选课请求、JavaBean 怎么封装数据、Tomcat 怎么部署中间穿插我踩过的坑和调参经验。你跟着走完至少能拿出一套自己能讲清楚、能经得起追问的方案而不是一份连自己都说不明白的源码。2. 数据库表设计从 ER 图到建表语句的完整落地2.1 先理清实体和关系别急着写 CREATE TABLE高校选课管理系统的核心实体其实不多学生、教师、课程、选课记录。但关系比想象中复杂。一个学生可以选多门课一门课可以被多个学生选这是典型的多对多关系必须用中间表来拆。教师和课程之间是一对多一个教师可以开多门课但一门课通常只由一个教师负责。管理员这个角色可以单独建表也可以直接在学生表或教师表里加一个角色字段我一般倾向于单独建一张用户表把登录认证统一收口。很多同学一上来就打开 Navicat 或者 DataGrip 开始建表字段名用拼音主键用 name外键忘了加最后查询的时候各种笛卡尔积。正确的顺序是先在纸上或者 draw.io 上画 ER 图把实体、属性、关系标清楚再转成关系模式最后才写建表语句。这一步花二十分钟后面能省你两天调试时间。选课记录表是整个系统的核心它不仅要记录谁选了哪门课还要记录选课时间、成绩、状态已选/已退/已修完。这张表的设计直接决定了后面选课逻辑能不能跑通。我见过有人把成绩直接放在课程表里结果一个课程只能有一个成绩完全没法用。2.2 建表语句与字段类型选择下面是我一般会用的建表语句基于 MySQL 8.0字符集用 utf8mb4排序规则用 utf8mb4_general_ci。字段类型的选择后面会逐条解释。-- 学生表 CREATE TABLE student ( student_id VARCHAR(20) PRIMARY KEY COMMENT 学号业务主键, name VARCHAR(50) NOT NULL COMMENT 姓名, gender ENUM(男,女) DEFAULT 男 COMMENT 性别, major VARCHAR(100) NOT NULL COMMENT 专业, grade YEAR NOT NULL COMMENT 入学年份, password VARCHAR(64) NOT NULL COMMENT 登录密码存哈希值, created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生信息表; -- 教师表 CREATE TABLE teacher ( teacher_id VARCHAR(20) PRIMARY KEY COMMENT 工号, name VARCHAR(50) NOT NULL COMMENT 姓名, title VARCHAR(30) DEFAULT NULL COMMENT 职称, password VARCHAR(64) NOT NULL COMMENT 登录密码, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT教师信息表; -- 课程表 CREATE TABLE course ( course_id VARCHAR(20) PRIMARY KEY COMMENT 课程编号, course_name VARCHAR(100) NOT NULL COMMENT 课程名称, credit DECIMAL(3,1) NOT NULL DEFAULT 2.0 COMMENT 学分, capacity INT NOT NULL DEFAULT 50 COMMENT 容量上限, teacher_id VARCHAR(20) NOT NULL COMMENT 授课教师, semester VARCHAR(20) NOT NULL COMMENT 开课学期, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(teacher_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程信息表; -- 选课记录表 CREATE TABLE enrollment ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 自增主键, student_id VARCHAR(20) NOT NULL COMMENT 学号, course_id VARCHAR(20) NOT NULL COMMENT 课程编号, enroll_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 选课时间, score DECIMAL(5,2) DEFAULT NULL COMMENT 成绩NULL 表示未出分, status TINYINT NOT NULL DEFAULT 1 COMMENT 1已选 2已退 3已修完, UNIQUE KEY uk_student_course (student_id, course_id), KEY idx_course (course_id), CONSTRAINT fk_enroll_student FOREIGN KEY (student_id) REFERENCES student(student_id), CONSTRAINT fk_enroll_course FOREIGN KEY (course_id) REFERENCES course(course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选课记录表;这里有几个关键决策需要说明。学号和工号用 VARCHAR 而不是 INT因为实际学号可能包含字母或者前导零用 INT 会丢前导零。密码字段长度给 64是因为我一般用 SHA-256 存哈希如果你用 BCrypt 需要给到 60 以上。选课记录表的唯一索引uk_student_course是防止同一个学生重复选同一门课的第一道防线但注意它不能防止并发插入后面讲事务的时候会细说。课程表的capacity字段是容量上限选课的时候需要检查已选人数是否超过这个值。选课记录表的status字段用 TINYINT 而不是 ENUM是因为 ENUM 在后续扩展状态时改表比较麻烦TINYINT 配合代码里的常量更灵活。score允许为 NULL表示还没出成绩这和 0 分是两回事查询的时候要特别注意。2.3 索引怎么加才不拖慢写入索引不是越多越好。选课记录表上uk_student_course这个唯一索引已经能覆盖“某学生选了哪些课”的查询因为最左前缀是 student_id。但“某门课有哪些学生选”这个查询走不到这个索引所以额外加了idx_course。如果你经常按学期查选课记录可以考虑在 enrollment 表里冗余一个 semester 字段再加索引但这是反范式设计课设阶段不建议做。学生表的 major 和 grade 字段如果经常用来筛选可以加联合索引idx_major_grade。但课设数据量通常不大几千条记录全表扫描也就几毫秒加不加索引体感差别不大。我的建议是先把唯一约束和外键加对索引等查询慢了再加不要一上来就堆一堆索引写入性能下降不说调试的时候还容易搞混。提示外键约束在课设阶段建议保留它能帮你发现数据不一致的问题。但如果你后面要做批量导入或者删除操作外键会带来额外的锁开销到时候可以临时禁用SET FOREIGN_KEY_CHECKS0操作完再打开。3. JavaBean 与 DAO 层把数据库操作从 JSP 里彻底剥离3.1 JavaBean 的写法与四个命名规范JavaBean 不是什么高深的东西就是一个普通的 Java 类满足四个条件有无参构造方法、属性私有、通过 getter/setter 访问、实现 Serializable 接口。在选课管理系统里每个数据库表对应一个 JavaBean比如 Student、Teacher、Course、Enrollment。这些类放在com.scms.entity包下。package com.scms.entity; import java.io.Serializable; import java.sql.Timestamp; public class Enrollment implements Serializable { private Long id; private String studentId; private String courseId; private Timestamp enrollTime; private Double score; private Integer status; public Enrollment() {} public Long getId() { return id; } public void setId(Long id) { this.id id; } public String getStudentId() { return studentId; } public void setStudentId(String studentId) { this.studentId studentId; } public String getCourseId() { return courseId; } public void setCourseId(String courseId) { this.courseId courseId; } public Timestamp getEnrollTime() { return enrollTime; } public void setEnrollTime(Timestamp enrollTime) { this.enrollTime enrollTime; } public Double getScore() { return score; } public void setScore(Double score) { this.score score; } public Integer getStatus() { return status; } public void setStatus(Integer status) { this.status status; } }注意 score 用 Double 而不是 double因为数据库里允许 NULL基本类型 double 没法表示 NULL取出来会变成 0.0这是个很隐蔽的坑。status 同理用 Integer。enrollTime 用java.sql.Timestamp而不是java.util.Date因为 JDBC 驱动对 Timestamp 的支持更直接省去转换的麻烦。3.2 DAO 层JDBC 工具类与选课事务DAO 层负责所有数据库操作Servlet 只调用 DAO不直接写 SQL。我一般会先写一个 DBUtil 工具类负责加载驱动和获取连接。课设阶段用 DriverManager 就够了不需要上连接池但如果你想让项目看起来更专业可以加一个简单的连接池比如 HikariCP不过那会增加配置复杂度量力而行。package com.scms.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/scms?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD your_password; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL 驱动加载失败, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r ! null) { try { r.close(); } catch (Exception ignored) {} } } } }URL 里的serverTimezoneAsia/Shanghai必须加否则 MySQL 8.0 驱动会报时区错误。useSSLfalse在本地开发时加上避免控制台一堆警告。characterEncodingutf8保证中文不乱码虽然数据库层面已经是 utf8mb4但 JDBC 连接层也要对齐。选课操作是整个系统里唯一需要事务的地方。因为选课要同时做两件事往 enrollment 表插一条记录同时检查课程容量。如果先查容量再插入中间有并发请求进来就可能超选。正确做法是在一个事务里用SELECT ... FOR UPDATE锁住课程行再检查容量再插入。public boolean enroll(String studentId, String courseId) { Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 锁定课程行防止并发超选 ps conn.prepareStatement(SELECT capacity FROM course WHERE course_id ? FOR UPDATE); ps.setString(1, courseId); rs ps.executeQuery(); if (!rs.next()) { conn.rollback(); return false; } int capacity rs.getInt(capacity); // 统计已选人数 ps conn.prepareStatement(SELECT COUNT(*) FROM enrollment WHERE course_id ? AND status 1); ps.setString(1, courseId); rs ps.executeQuery(); rs.next(); int enrolled rs.getInt(1); if (enrolled capacity) { conn.rollback(); return false; } // 插入选课记录 ps conn.prepareStatement(INSERT INTO enrollment (student_id, course_id, status) VALUES (?, ?, 1)); ps.setString(1, studentId); ps.setString(2, courseId); ps.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } e.printStackTrace(); return false; } finally { DBUtil.close(rs, ps, conn); } }这段代码里FOR UPDATE是关键它会在课程行上加排他锁其他事务想锁同一行就得等从而保证容量检查的准确性。但注意FOR UPDATE必须在事务里才有意义如果 autocommit 是 true锁会立刻释放。另外enrollment表的唯一索引会在插入重复记录时抛异常catch 块里回滚就行不用额外查重。注意SELECT ... FOR UPDATE在高并发下会带来锁等待课设演示时并发量不大问题不明显。但如果要模拟真实场景建议把事务隔离级别设为 READ COMMITTED减少间隙锁的影响。4. Servlet 与 JSP 分工Model2 模式下请求怎么流转4.1 Servlet 生命周期与线程安全问题Servlet 的生命周期分四个阶段加载和实例化、初始化init、请求处理service/doGet/doPost、销毁destroy。容器启动时或者第一次请求时创建 Servlet 实例之后所有请求复用同一个实例。这意味着 Servlet 是单实例多线程的成员变量会有线程安全问题。很多同学在 Servlet 里定义private Connection conn;然后每个请求都往里赋值结果并发时连接串了报各种莫名其妙的错。正确做法是所有变量都在方法内定义用局部变量或者用 ThreadLocal。我一般直接在 doPost 方法里获取连接方法结束就关闭不跨请求共享。WebServlet(/enroll) public class EnrollServlet extends HttpServlet { private EnrollmentDao enrollmentDao new EnrollmentDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session req.getSession(false); if (session null || session.getAttribute(studentId) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } String studentId (String) session.getAttribute(studentId); String courseId req.getParameter(courseId); boolean success enrollmentDao.enroll(studentId, courseId); req.setAttribute(message, success ? 选课成功 : 选课失败可能已选或容量已满); req.getRequestDispatcher(/result.jsp).forward(req, resp); } }enrollmentDao作为成员变量是安全的因为它没有可变状态所有方法都是无状态的。但如果你在 DAO 里存了 Connection 作为成员变量那就不安全了。判断标准很简单这个成员变量在多个请求之间会不会被修改会就不安全。4.2 JSP 只负责展示别写 Java 代码Model2 模式的核心就是 JSP 只做视图Servlet 做控制器JavaBean 做模型。但很多同学写着写着就把数据库查询写到 JSP 里了% Class.forName(...) %这种代码在课设里一抓一大把。这样写的问题不是不能跑而是没法维护而且老师一看就知道你是拼凑的。正确的做法是 Servlet 查完数据通过req.setAttribute传给 JSPJSP 用 JSTL 或者 EL 表达式展示。比如课程列表页面% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle课程列表/title/head body table border1 trth课程编号/thth课程名称/thth学分/thth教师/thth操作/th/tr c:forEach items${courseList} varcourse tr td${course.courseId}/td td${course.courseName}/td td${course.credit}/td td${course.teacherName}/td td form action${pageContext.request.contextPath}/enroll methodpost input typehidden namecourseId value${course.courseId} input typesubmit value选课 /form /td /tr /c:forEach /table /body /htmlEL 表达式${course.courseId}会自动调用 getter 方法不需要写% course.getCourseId() %。JSTL 的c:forEach比脚本片段里的 for 循环干净得多。注意teacherName这个属性在 Course 实体里可能不存在需要在 DAO 查询时用 JOIN 把教师姓名查出来或者单独查一次再拼装。我一般直接在 SQL 里 JOIN省事。4.3 登录与权限过滤器的实现选课系统至少有两种角色学生和教师。学生能选课、查成绩教师能录入成绩、查看选课名单。如果不做权限控制学生直接访问教师页面就能改成绩这是课设答辩时很容易被问到的问题。我一般用一个 Filter 来做统一的登录检查和角色校验。Filter 在 Servlet 之前执行可以拦截所有请求。WebFilter(/*) public class AuthFilter implements Filter { private static final SetString WHITE_LIST Set.of(/login.jsp, /login, /css/, /js/); Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String path request.getRequestURI().substring(request.getContextPath().length()); for (String white : WHITE_LIST) { if (path.startsWith(white)) { chain.doFilter(req, resp); return; } } HttpSession session request.getSession(false); if (session null || session.getAttribute(userId) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }白名单里的路径直接放行其他路径检查 session 里有没有 userId。这个 Filter 只做了登录检查角色检查可以在具体的 Servlet 里做比如 TeacherServlet 里判断session.getAttribute(role)是不是 teacher。更优雅的做法是用两个 Filter一个管登录一个管角色但课设阶段一个 Filter 加几个 if 就够了。提示Filter 的WebFilter(/*)会拦截所有请求包括静态资源白名单里记得把 css、js、images 目录加上否则页面样式会丢。5. 避坑与排查选课系统课设里最容易翻车的五个地方5.1 中文乱码从数据库到浏览器全链路排查现象选课记录里的课程名显示成问号或者表单提交后数据库里存的是乱码。原因乱码可能出现在三个环节——JSP 页面编码、Servlet 请求编码、数据库连接编码。任何一个环节没对齐 utf8中文就会出问题。解决JSP 页面头部加% page contentTypetext/html;charsetUTF-8 %Servlet 里在读取参数前设置req.setCharacterEncoding(UTF-8)数据库连接 URL 加characterEncodingutf8建表时指定DEFAULT CHARSETutf8mb4。Tomcat 8 以后 GET 请求默认用 UTF-8但 POST 请求如果不设setCharacterEncoding还是会用 ISO-8859-1。最稳妥的做法是在 Filter 里统一设置编码这样每个 Servlet 都不用重复写。5.2 选课超容量并发场景下的经典翻车现象课程容量设了 50但选课结束后发现选了 52 个人。原因先查人数再插入两个请求同时查到 49都判断没满然后都插入结果变成 51。这是典型的竞态条件。解决用事务加行锁就是第 3 章里SELECT ... FOR UPDATE的写法。另一种方案是在 course 表加一个enrolled_count字段每次选课用UPDATE course SET enrolled_count enrolled_count 1 WHERE course_id ? AND enrolled_count capacity根据 affected rows 判断是否成功。这种方案不用显式锁但需要维护计数一致性退课时要减回去。课设阶段推荐第一种逻辑清晰答辩时也好讲。5.3 外键约束导致删除失败现象想删除一个课程报Cannot delete or update a parent row: a foreign key constraint fails。原因enrollment 表里有该课程的选课记录外键挡住了删除。解决要么先删选课记录再删课程要么在删除课程时用ON DELETE CASCADE让选课记录跟着删。但级联删除有风险误删课程会把选课记录一起清掉。我一般建议在业务层做软删除给 course 表加一个is_deleted字段删除时只标记不真删查询时过滤掉。这样既保留了历史数据又避免了外键冲突。5.4 JSP 页面报 404路径问题排查现象表单提交后跳转 404或者 JSP 页面里的图片、CSS 加载不出来。原因路径写的是相对路径但请求转发后浏览器地址栏没变相对路径的基准就变了。比如/student/enroll.jsp里写actionenroll提交后可能变成/student/enroll而不是/enroll。解决所有路径都用${pageContext.request.contextPath}开头这是最稳妥的做法。表单 action 写成${pageContext.request.contextPath}/enrollCSS 引用写成${pageContext.request.contextPath}/css/style.css。这样不管页面在哪个目录路径都是对的。5.5 Tomcat 部署后 500驱动没放对位置现象本地 IDE 里跑得好好的打成 WAR 包部署到 Tomcat 就报ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因MySQL 驱动 jar 包没有放到WEB-INF/lib目录下。IDE 里通常是把驱动加到了项目的 classpath但打包时如果没勾选导出WAR 包里就没有这个 jar。解决在 IDEA 里打开 Project Structure找到 Artifacts确认mysql-connector-java在WEB-INF/lib下。或者直接用 Maven 管理依赖pom.xml里加 mysql-connector-java打包时自动带上。用 Maven 的话记得把 scope 设为compile而不是provided否则 Tomcat 里还是没有。6. 从课设到能讲清楚用 EXPLAIN 验证你的索引有没有生效课设答辩时老师经常会问“你这个查询有没有走索引”。很多同学答不上来因为从来没看过执行计划。MySQL 的EXPLAIN命令可以告诉你查询走了哪个索引、扫描了多少行、有没有用临时表。这个技巧不难但能让你在答辩时显得很专业。拿选课记录查询举例假设你要查某个学生选了哪些课EXPLAIN SELECT c.course_name, e.status, e.score FROM enrollment e JOIN course c ON e.course_id c.course_id WHERE e.student_id 2021001;执行结果里重点看三列type、key、rows。type如果是ref或eq_ref说明走了索引如果是ALL就是全表扫描。key显示实际使用的索引名应该是uk_student_course。rows是预估扫描行数越小越好。如果type是ALL且rows很大说明索引没生效需要检查查询条件是不是没用到索引的最左前缀。另一个常见问题是 JOIN 的顺序。MySQL 一般会自己选驱动表但你可以用STRAIGHT_JOIN强制顺序。课设数据量小优化效果不明显但知道这个概念能让你在答辩时多一个话题。还有一个实用技巧在enrollment表上查某个课程的选课人数时如果status字段区分了已选和已退记得在COUNT里加上status 1条件否则会把退课的人也统计进去。这个 bug 很隐蔽因为退课的人通常不多容量检查时不容易发现但一旦退课人数多了就会导致容量算错。-- 正确写法 SELECT COUNT(*) FROM enrollment WHERE course_id CS101 AND status 1; -- 错误写法会把已退课的也算进去 SELECT COUNT(*) FROM enrollment WHERE course_id CS101;我自己的习惯是每次写完一个查询先用EXPLAIN看一眼确认走了索引再放到 DAO 里。这个习惯花不了几秒钟但能避免很多性能问题。课设阶段数据量小不走索引也能跑但答辩时老师如果让你现场分析你能说出type和key的含义印象分直接拉满。最后说一个我踩过的坑SELECT ... FOR UPDATE在autocommit1的情况下不会锁住行因为事务立刻提交了。我当初调试了半天以为锁没生效后来才发现是 autocommit 的问题。如果你也遇到锁不住的情况先检查conn.setAutoCommit(false)有没有写。这个细节很小但翻车的时候真的很让人抓狂。希望帮到你。本文还有配套的精品资源点击获取
