简介:这是一套面向高校计算机相关专业学生的数据库设计课程设计完整源码,采用jsp+servlet+javabean技术路线,基于Eclipse与Tomcat8.5开发,数据库选用SQL Server 2017,适合作为课程设计、毕业设计或Java Web入门练手项目。压缩包共237个文件,约5.95MB,涵盖java源码、jsp页面、css样式、js脚本及图片资源,并附有说明文档、E-R图与实体关系模型,便于理解整体架构。系统按管理员、教师、学生三种角色划分权限:管理员负责教师、学生、课程信息的增删改查及密码维护;教师可查看任课信息、开设或取消课程、录入成绩并自动更新学分,还能按班级、课程、学年统计成绩与排名;学生则可查看个人信息与课程、完成选退课及学年成绩查询。目前已有1052人学习下载,配套文档与关系模型能帮助读者快速理清表结构设计与业务逻辑,适合需要完整数据库课程设计参考方案的学习者。
1. 高校选课管理系统:为什么 JSP+Servlet+JavaBean 依然是数据库课设的硬骨头
每年一到数据库设计课程设计选题,高校选课管理系统几乎是出现频率最高的题目之一。很多同学第一反应是去网上找一套现成源码,改改界面就交差,结果答辩时被老师追问“你的表结构为什么这么设计”“并发选同一门课怎么处理”,当场卡壳。这套系统表面上看只是学生选课、教师录入成绩、管理员维护课程,但它背后牵扯到数据库设计里最核心的几件事:实体关系建模、事务控制、并发冲突处理、外键约束与索引优化。用 JSP+Servlet+JavaBean 这套经典 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 '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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_CHECKS=0,操作完再打开。
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?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; 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 里的serverTimezone=Asia/Shanghai必须加,否则 MySQL 8.0 驱动会报时区错误。useSSL=false在本地开发时加上,避免控制台一堆警告。characterEncoding=utf8保证中文不乱码,虽然数据库层面已经是 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传给 JSP,JSP 用 JSTL 或者 EL 表达式展示。比如课程列表页面:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head><title>课程列表</title></head> <body> <table border="1"> <tr><th>课程编号</th><th>课程名称</th><th>学分</th><th>教师</th><th>操作</th></tr> <c:forEach items="${courseList}" var="course"> <tr> <td>${course.courseId}</td> <td>${course.courseName}</td> <td>${course.credit}</td> <td>${course.teacherName}</td> <td> <form action="${pageContext.request.contextPath}/enroll" method="post"> <input type="hidden" name="courseId" value="${course.courseId}"> <input type="submit" value="选课"> </form> </td> </tr> </c:forEach> </table> </body> </html>EL 表达式${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 Set<String> 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 contentType="text/html;charset=UTF-8" %>;Servlet 里在读取参数前设置req.setCharacterEncoding("UTF-8");数据库连接 URL 加characterEncoding=utf8;建表时指定DEFAULT CHARSET=utf8mb4。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里写action="enroll",提交后可能变成/student/enroll而不是/enroll。
解决:所有路径都用${pageContext.request.contextPath}开头,这是最稳妥的做法。表单 action 写成${pageContext.request.contextPath}/enroll,CSS 引用写成${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在autocommit=1的情况下不会锁住行,因为事务立刻提交了。我当初调试了半天,以为锁没生效,后来才发现是 autocommit 的问题。如果你也遇到锁不住的情况,先检查conn.setAutoCommit(false)有没有写。这个细节很小,但翻车的时候真的很让人抓狂。希望帮到你。
本文还有配套的精品资源,点击获取