简介:基于Java+JSP+Servlet+MySQL的Web学生选课管理系统,是一个适合高校学生及Java Web初学者的完整项目实例。系统覆盖登录认证与权限控制、课程信息维护、学生选课与冲突检测、选课记录查询、成绩录入及数据备份恢复等功能模块,整体采用MVC设计模式,清晰展现了Servlet控制层、JSP视图层与JDBC数据层的协作方式。压缩包内共408个文件,包含43个Java源码与43个Class类文件、25个JSP页面、50个JavaScript脚本、15个CSS样式表、162张GIF操作演示图,涵盖登录、选课、成绩管理等关键流程,另附SQL数据库脚本、Eclipse工程配置和数据库连接池配置,压缩后约6.59MB,资源组织清晰便于按模块查阅运行。目前已有6906人学习下载该项目,借助完整源码与演示素材可快速搭建运行环境,深入理解从数据库设计到前后端交互的完整开发链路,也适合作为毕业设计或课程设计的参考模板。
1. Java+JSP+Servlet+MySQL:这套选课系统到底值不值得花时间复现
选课管理系统几乎是每个Java Web初学者绕不过去的课程设计题,网上资源多到泛滥,但真正能跑通、表结构合理、代码能看懂并改造成自己项目的版本并不多。这套基于JSP+Servlet+MySQL的Web学生选课管理系统,属于典型的Java Web三层架构落地项目——没有Spring全家桶的复杂封装,全靠Servlet控制请求流转、JSP渲染页面、原生JDBC操作MySQL,反而能让你把Servlet生命周期、请求转发与重定向、数据库连接管理这些最底层的东西一次性看清。适合正在做课程设计、准备Java面试前想补Web基础、或者打算接手这类老项目的人使用。我拆完这套代码的感受是:它不是一个惊艳的项目,但作为一份能跑通、能改、能讲清楚原理的源码包,比那些套了Spring Boot壳子却说不清底层的东西要有价值得多。
2. 从MySQL表结构说起:三张核心表和一条选课主线
2.1 用户、课程、选课记录:没有冗余的设计才能扛住并发校验
这套系统的数据库设计遵循了经典的第三范式,核心是三张表:学生信息表(student)、课程表(course)、选课记录表(sc)。表结构不复杂,但设计上有一个容易被新手忽略的关键点:选课记录表通过联合主键确保同一学生不能重复选同一门课。
CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '学号', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录用户名', password VARCHAR(50) NOT NULL COMMENT '登录密码', real_name VARCHAR(50) NOT NULL COMMENT '真实姓名', major VARCHAR(100) COMMENT '专业', grade INT COMMENT '年级' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '课程编号', course_name VARCHAR(100) NOT NULL COMMENT '课程名称', teacher VARCHAR(50) NOT NULL COMMENT '授课教师', credit DECIMAL(3,1) NOT NULL COMMENT '学分', max_students INT NOT NULL COMMENT '选课容量', selected_count INT DEFAULT 0 COMMENT '已选人数' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sc ( student_id INT NOT NULL, course_id INT NOT NULL, select_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES student(id), FOREIGN KEY (course_id) REFERENCES course(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这一段SQL里有两个地方值得停下来说。第一,sc表用PRIMARY KEY (student_id, course_id)联合主键,这是防止重复选课的第一道防线——就算代码里忘了判断,数据库层面也会直接报错。第二,course表里维护了selected_count字段,这个冗余字段在选课高峰期会带来并发问题,但在这个项目规模下,配合UPDATE course SET selected_count = selected_count + 1 WHERE id = ? AND selected_count < max_students这种条件更新,反而比每次都COUNT(*)再判断要高效得多。
2.2 为什么不用MyBatis而用原生JDBC:课程设计场景下的务实选择
现在很多学生一上来就用Spring Boot + MyBatis Plus,但如果你选课系统这类经典题目,我更建议先吃透原生JDBC。这套资源用的是DAO + JDBC模式,BaseDAO类统一管理Connection、PreparedStatement、ResultSet的创建和释放,业务DAO继承它再写具体SQL。
public class BaseDAO { private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/course_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { e.printStackTrace(); } } protected Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }这里有一个非常具体的版本坑:MySQL 8.x 的驱动类名必须是com.mysql.cj.jdbc.Driver,MySQL 5.x 是com.mysql.jdbc.Driver。如果你本机装的是MySQL 8.0,用旧驱动类名会直接ClassNotFoundException。另外serverTimezone=Asia/Shanghai这个参数在MySQL 8.x下必须显式声明,否则会报Server returns invalid timezone。这套代码里还暴露了密码硬编码的问题,实际项目应该放配置文件,但课程设计场景能跑通优先。
2.3 初始化数据脚本:你需要的不是造数据,而是能反复重置的测试环境
项目自带一个init.sql,包含建库、建表、插入管理员和学生账号。我建议你在这个脚本基础上加两样东西:一是给course表插入至少十几门课程,覆盖容量已满、待选、学分不同这三种状态;二是故意创建一个test账号,专门用来测试选课失败场景。
INSERT INTO student (username, password, real_name, major, grade) VALUES ('admin', 'admin123', '系统管理员', '教务处', 0), ('stu001', '123456', '张三', '计算机科学与技术', 2022), ('stu002', '123456', '李四', '软件工程', 2022); INSERT INTO course (course_name, teacher, credit, max_students, selected_count) VALUES ('Java程序设计', '王老师', 3.5, 50, 0), ('操作系统', '刘老师', 3.0, 40, 40), ('数据库原理', '陈老师', 3.0, 60, 0);插入时注意selected_count = 40这种写法,是故意模拟一门已经选满的课程。新手在测试时最容易翻车的点是:自己造的数据里没有满员课程,导致选课冲突检测分支一直走不到,报错时都不知道是自己代码错了还是数据没造对。
3. 三层架构落地:从实体类到Servlet再到JSP页面的完整链路
3.1 实体类和DAO:字段映射是第一步,别在类型上栽跟头
这套项目的实体类定义比较规矩:Student、Course、SC三个POJO与数据库表字段一一对应。Course实体里特别注意credit用的是BigDecimal而不是Double,max_students和selected_count用Integer——这种细节在银行和教务系统里很关键,因为浮点数在比较时会有精度问题,而选课人数本身就是整数,没必要用Long。
public class Course { private Integer id; private String courseName; private String teacher; private BigDecimal credit; private Integer maxStudents; private Integer selectedCount; // 无参构造器、有参构造器、getter/setter 省略 }DAO层接口和实现分开写,接口定义方法签名,实现类写SQL和执行逻辑。比如CourseDAOImpl里的findAvailableCourses方法,核心SQL是查询selected_count < max_students的课程:
public List<Course> findAvailableCourses() { String sql = "SELECT * FROM course WHERE selected_count < max_students"; List<Course> list = new ArrayList<>(); try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Course c = new Course(); c.setId(rs.getInt("id")); c.setCourseName(rs.getString("course_name")); c.setTeacher(rs.getString("teacher")); c.setCredit(rs.getBigDecimal("credit")); c.setMaxStudents(rs.getInt("max_students")); c.setSelectedCount(rs.getInt("selected_count")); list.add(c); } } catch (SQLException e) { e.printStackTrace(); } return list; }代码里用了try-with-resources,这是JDK 7+的语法,Connection、PreparedStatement、ResultSet会自动关闭,不需要在finally里手动释放。很多人从旧教程里抄来的代码还在用finally块做关闭,这一点新版代码更干净,也避免了连接泄漏。
3.2 Servlet跳转方式:forward和sendRedirect用错会出大问题
Servlet在这一套架构里是纯控制器,负责接收请求、调用Service或DAO、决定跳转方向。常见分层是JSP -> Servlet -> Service -> DAO,但多数课程设计直接是JSP -> Servlet -> DAO,少一层Service。这套项目属于后者,对于选课这种简单业务逻辑,少一层反而容易看懂。
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); if ("list".equals(action)) { List<Course> courseList = courseDAO.findAvailableCourses(); request.setAttribute("courseList", courseList); request.getRequestDispatcher("/course_list.jsp").forward(request, response); } else if ("myCourses".equals(action)) { Integer studentId = (Integer) request.getSession().getAttribute("studentId"); List<Course> myCourses = courseDAO.findCoursesByStudentId(studentId); request.setAttribute("myCourses", myCourses); request.getRequestDispatcher("/my_courses.jsp").forward(request, response); } }这里forward和sendRedirect的区别值得说透。forward是服务器内部转发,地址栏不变,可以用来带request属性到JSP页面;sendRedirect是客户端重定向,地址栏会变,适合选课成功之后跳转,防止刷新页面重复提交。一个典型错误是选课成功后用forward跳转回列表页,用户一刷新就重复选课——sendRedirect才是正解,因为刷新时请求的是新的URL,不会再执行选课逻辑。
3.3 JSP页面职责:JSTL + EL能让你少写一百行Java代码
页面层这套项目用的是JSP + JSTL + EL,没有在页面里写乱七八糟的<% ... %>脚本片段。课程列表页的核心逻辑是循环输出课程信息,并判断是否还能选:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table border="1"> <tr> <th>课程编号</th> <th>课程名称</th> <th>教师</th> <th>学分</th> <th>已选/容量</th> <th>操作</th> </tr> <c:forEach items="${courseList}" var="course"> <tr> <td>${course.id}</td> <td>${course.courseName}</td> <td>${course.teacher}</td> <td>${course.credit}</td> <td>${course.selectedCount}/${course.maxStudents}</td> <td> <c:choose> <c:when test="${course.selectedCount < course.maxStudents}"> <a href="selectCourse?action=select&courseId=${course.id}">选课</a> </c:when> <c:otherwise> <span style="color:gray;">已满</span> </c:otherwise> </c:choose> </td> </tr> </c:forEach> </table>EL表达式里的${course.courseName}会自动调用POJO的getCourseName()方法,这是EL的规范,不用在页面里手动调getter。c:choose和c:when组合实现if-else,比在页面里写Java脚本片段要干净得多。这里需要注意JSTL 1.2的uri是http://java.sun.com/jsp/jstl/core,到了JSTL 2.0改成了jakarta.tags.core——如果你用的Tomcat版本和JSTL版本不匹配,页面会报Unable to find taglib的错误。
4. 核心功能拆解:登录校验、选课事务和权限控制
4.1 登录逻辑:MySQL查询结果和Session状态必须同步处理
登录功能是每个Web项目的入口,这套系统的做法很常规:表单提交username和password到LoginServlet,Servlet调用StudentDAO.findByUsernameAndPassword查库,查到了就放进Session,查不到就回登录页带错误提示。
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = request.getParameter("username"); String password = request.getParameter("password"); Student student = studentDAO.findByUsernameAndPassword(username, password); if (student != null) { HttpSession session = request.getSession(); session.setAttribute("student", student); session.setAttribute("studentId", student.getId()); response.sendRedirect("course?action=list"); } else { request.setAttribute("errorMsg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } }登录成功后这里用的是sendRedirect而不是forward,这个细节有必要强调。登录请求本身就是POST提交,如果forward到列表页,用户刷新时浏览器会重复提交表单,可能产生重复登录的副作用(比如Session被反复刷新)。sendRedirect让浏览器发起全新的GET请求,符合Post/Redirect/Get(PRG)模式。Session里同时存了student对象和studentId,后面选课操作只需要studentId,不需要把整个对象传来传去。
4.2 选课事务:三步操作要放在同一个Connection里才安全
选课是这个项目最核心的功能,涉及三个步骤:检查课程是否还有余量、插入选课记录、更新课程已选人数。这三步必须在一个数据库事务里完成,否则会出现超卖——两个人同时选最后一门课,两个人都看到余量是1,都插入成功,但实际只有一条记录能选上。
public boolean selectCourse(int studentId, int courseId) { String checkSql = "SELECT selected_count, max_students FROM course WHERE id = ?"; String insertSql = "INSERT INTO sc (student_id, course_id) VALUES (?, ?)"; String updateSql = "UPDATE course SET selected_count = selected_count + 1 WHERE id = ?"; try (Connection conn = getConnection()) { conn.setAutoCommit(false); // 第一步:查询当前余量 PreparedStatement ps1 = conn.prepareStatement(checkSql); ps1.setInt(1, courseId); ResultSet rs = ps1.executeQuery(); if (!rs.next()) { return false; } int selected = rs.getInt("selected_count"); int max = rs.getInt("max_students"); if (selected >= max) { return false; // 已满 } // 第二步:插入选课记录 PreparedStatement ps2 = conn.prepareStatement(insertSql); ps2.setInt(1, studentId); ps2.setInt(2, courseId); int insert = ps2.executeUpdate(); if (insert != 1) { conn.rollback(); return false; } // 第三步:更新课程人数 PreparedStatement ps3 = conn.prepareStatement(updateSql); ps3.setInt(1, courseId); ps3.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { e.printStackTrace(); try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } return false; } }事务的坑在于setAutoCommit(false)之后,每一步都要留意失败回滚。上面这段代码里有一个经典陷阱:UPDATE语句本身不会因为selected_count超过max_students而失败,需要在第一步先读出来比较。但这里还有个并发窗口——两个请求同时读到selected_count = max_students - 1,都通过了判断,都执行了插入和更新,最终selected_count变成了max_students + 1。本项目规模下用SELECT ... FOR UPDATE加锁是最简单的解法:
SELECT selected_count, max_students FROM course WHERE id = ? FOR UPDATE加了FOR UPDATE之后,两个并发事务会串行执行,第二个事务等第一个提交后才能读到最新数据,自然就走到了selected >= max分支,选课失败。这是InnoDB行锁的基本使用方式,面试时能把这一点讲清楚,比背十道八股文都有用。
4.3 权限控制:页面级拦截靠Filter,别指望每个Servlet里重复判断
这套系统里有三种角色:管理员、学生、未登录游客。防越权的标准姿势是写一个LoginFilter,在web.xml里配置好URL映射,拦截所有/pages/*和需要登录才能访问的Servlet。
<filter> <filter-name>LoginFilter</filter-name> <filter-class>com.course.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>LoginFilter</filter-name> <url-pattern>/selectCourse</url-pattern> <url-pattern>/course</url-pattern> </filter-mapping>Filter里检查Session是否有student对象,没有就重定向到登录页:
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); if (session != null && session.getAttribute("student") != null) { chain.doFilter(request, response); } else { resp.sendRedirect(req.getContextPath() + "/login.jsp"); } }注意req.getSession(false)这个写法——传false表示如果当前没有Session就返回null而不是创建一个新Session。很多人写成getSession(),会导致未登录用户也被强制创建一个Session,多少有点浪费。管理员功能的控制同样靠Filter或Servlet里的角色判断,这个项目里把管理员和学生的页面做了物理隔离,管理端JSP放到/admin/目录下,Filter只对这个目录做额外的角色校验。
5. 避坑指南:从配置到部署的五个高危雷区
5.1 Tomcat版本和Java版本不匹配导致项目起不来
现象:Tomcat启动正常,访问JSP页面报java.lang.NoClassDefFoundError: javax/servlet/ServletException或直接404。
原因:Tomcat 10把包名从javax.servlet改成了jakarta.servlet,老项目里所有import javax.servlet.*全部失效。如果你下载的代码是2020年之前写的,那时候的规范还是javax命名空间。
解决:要么换Tomcat 9(兼容javax),要么把代码里所有javax.servlet改成jakarta.servlet并引入对应的JSTL版本。我建议课程设计直接用Tomcat 9.0.x,省事,JDK 8也能跑。
5.2 MySQL驱动JAR包版本对不上
现象:启动Tomcat后第一次操作数据库报ClassNotFoundException: com.mysql.cj.jdbc.Driver,或者报Could not create connection to database server。
原因:你拷贝的驱动JAR包是5.1.x的旧版本,里面只有com.mysql.jdbc.Driver类;而代码里写的是8.x驱动类名。
解决:确认WEB-INF/lib下的驱动JAR版本。MySQL 5.7用mysql-connector-java-5.1.49.jar,MySQL 8.0用mysql-connector-java-8.0.30.jar。文件名直接看版本,别靠猜。同时pom.xml里如果引了Maven依赖,检查<artifactId>是mysql-connector-java还是mysql-connector-j,后者也要匹配版本。
5.3 中文乱码:URL参数和JSP页面编码不一致
现象:列表页中文正常,但查询条件传中文时Servlet收到的是一串问号。
原因:JSP页面是UTF-8,但Tomcat默认的POST表单解码是ISO-8859-1,GET请求的URI解码默认也是ISO-8859-1。只设置了pageEncoding没设置请求解码。
解决:Servlet里统一加编码处理——POST方式在读取参数前设置请求编码:
request.setCharacterEncoding("UTF-8");GET方式修改Tomcat的server.xml连接器配置:
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" />注意GET手动编解码也可以,但改server.xml更一劳永逸。另外数据库连接串里已经有characterEncoding=utf8,这个别删,否则写进库里的中文直接变乱码。
5.4 数据库连接没有关闭导致连接数耗尽
现象:跑半小时后系统变慢,MySQL报Too many connections,Tomcat日志出现Connection refused或Connection is not available。
原因:DAO代码里只关了ResultSet和PreparedStatement,忘了关Connection,或者关闭顺序出错。MySQL默认最大连接数是151,连接泄漏时会快速打满。
解决:养成try-with-resources习惯,因为Connection、Statement、ResultSet都实现了AutoCloseable。代码里如果还在用finally { conn.close(); },检查是否在null判断后才关闭。另外一个排查技巧:MySQL执行SHOW PROCESSLIST;,如果大量Sleep状态的连接堆积,就是连接没有释放。
5.5 刷新页面重复提交选课
现象:选课成功后刷新页面,提示重复选课或出现同一条选课记录。
原因:选课的POST请求forward到列表页后,浏览器地址栏还是那个POST提交的URL,刷新时浏览器重发POST请求。
解决:选课成功后使用sendRedirect跳转到course?action=list,避免重复提交。同时sc表的联合主键已经做了兜底,二次插入会违反主键约束抛SQLIntegrityConstraintViolationException,代码里需要捕获这个异常并提示用户已选过这门课。
6. 部署验证与调试习惯:从IDE到Tomcat的最后一公里
拆完这套源码包,最后一步是把它跑起来。我建议你把部署过程当作一次完整的验收测试,严格走下面这三条路径,而不是启动成功就交差。
第一条路径是验证管理员功能:用管理员账号登录,进入管理端,尝试新增课程、修改课程容量、查看所有选课记录。退出时注意看Session是否被正常清除。第二条路径是验证学生选课全流程:学生登录后看到课程列表,选一门有余量的课,然后在“我的课程”里确认存在;再选一门已经满员的课,确认系统给出提示而不是报500错误。第三条路径是验证异常场景:不登录直接访问course?action=list,看Filter是否把你拦截回登录页;用一个不存在的学号登录,确认报“用户名或密码错误”。
设备验证完之后,可以用一个自动化脚本辅助检查数据库状态,确保选课事务的原子性:
mysql -uroot -p123456 course_db -e " SELECT c.course_name, c.selected_count, c.max_students, (SELECT COUNT(*) FROM sc WHERE sc.course_id = c.id) AS actual_count FROM course c WHERE c.selected_count != (SELECT COUNT(*) FROM sc WHERE sc.course_id = c.id); "这条SQL用于检查selected_count字段和sc表实际记录数是否一致。如果查询有返回,说明事务回滚逻辑有Bug,selected_count被更新了但sc记录没插入。我第一次做课程设计时就没发现这个问题,直到查这个对比才发现事务边界没拿捏好,从那以后我每次做带事务的项目,强制自己先写一遍这个一致性检查SQL,再开始写业务代码,这个习惯帮我少踩了无数个隐性问题,希望帮到你。
本文还有配套的精品资源,点击获取