基于JavaWeb实现学生选课系统:Servlet+MySQL事务与并发实战
发布时间:2026/9/28 2:02:11来源:尧图网络
简介本资源是一套基于ServletJSP实现的学生选课系统面向计算机相关专业正在做毕业设计的学生以及需要JavaWeb项目实战练习的学习者可直接作为毕设选题使用。项目采用JavaWeb、Servlet与MySQL构建后端前端使用JSP、Bootstrap、jQuery与CSS开发环境为IDEA或Eclipse搭配Navicat与JDK1.8。系统划分管理员、教师、学生三种权限管理员负责学生、教师与课程信息管理教师可查看课程与学生、录入成绩并查看个人信息学生可浏览课程、选课、查询成绩及查看个人信息。压缩包共112个文件约2.39MB包含22个java源文件、22个class、21个jsp页面、10个jar依赖、5个css与5个js等并附数据库脚本源码与脚本齐全项目经过严格调试可正常运行。目前已有4273人学习下载适合作为课程设计或毕业设计的完整参考方案。1. 基于JavaWeb实现学生选课系统从Servlet到数据库的完整落地路径很多同学做毕业设计或课程实训时一看到“学生选课系统”就觉得是个烂大街的题目随便找个模板改改就交了。但真正动手写的时候才发现选课系统里藏着不少硬骨头并发抢课怎么保证不超选、课程容量怎么实时扣减、学生已选课程怎么去重、退课后名额怎么回滚。这些不是靠堆页面能解决的必须把JavaWeb那套请求流转和数据库事务吃透。这篇笔记就围绕“基于JavaWeb实现学生选课系统”这个方向把技术选型、环境搭建、核心业务实现、以及我踩过的坑一条条讲清楚。适合正在做JavaWeb项目完整案例、需要一套能跑通的MySQLServlet方案的同学也适合想从JSP头歌实训答案里跳出来、真正理解一个Web项目怎么落地的人。2. 技术选型与环境搭建为什么不用Spring Boot也要能跑起来2.1 选型逻辑ServletJSPMySQL的经典组合还值不值得用现在网上搜“javaweb项目完整案例”一大半都是Spring Boot MyBatis Vue。但如果你学校课程明确要求用JavaWeb原生技术栈或者你想真正搞懂一个HTTP请求从浏览器到数据库再返回页面到底经过了什么那ServletJSPJDBC这套组合反而是最好的学习路径。它没有魔法没有自动配置每一层都得自己写写完之后你对MVC的理解会比抄一个Spring Boot项目深得多。具体选型上我一般会这样定层次技术选择理由前端展示JSP JSTL 原生CSS不引入前端框架降低环境复杂度控制层Servlet 3.1直接处理请求映射和参数解析业务层普通Java类Service手写事务控制理解Connection传递数据层JDBC PreparedStatement不依赖ORMSQL完全可控数据库MySQL 5.7/8.0选课系统需要事务支持InnoDB引擎连接池Druid或HikariCP避免每次请求都新建连接服务器Tomcat 8.5/9.0与Servlet版本匹配这个组合的好处是你在IDEA里新建一个JavaWeb项目配好Tomcat直接就能跑不需要Maven拉一堆依赖当然用Maven管理jar包也可以。对于“idea运行javaweb项目配置”这个热搜词后面我会专门讲IDEA里的关键配置项。2.2 数据库表结构设计选课系统的四张核心表选课系统的数据模型不复杂但字段设计直接影响后面业务能不能跑通。我一般会建四张表学生表、课程表、选课记录表、教师表可选。核心是选课记录表它承载了“谁选了什么课”这个关系。-- 学生表 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL COMMENT 存SHA-256哈希, major VARCHAR(50), grade INT ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 课程表 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(20) NOT NULL UNIQUE, course_name VARCHAR(100) NOT NULL, teacher_name VARCHAR(50), credit DECIMAL(3,1), capacity INT NOT NULL DEFAULT 50 COMMENT 课程容量, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, semester VARCHAR(20) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 选课记录表 CREATE TABLE course_selection ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1 COMMENT 1-已选 0-已退, UNIQUE KEY uk_student_course (student_id, course_id), FOREIGN KEY (student_id) REFERENCES student(id), FOREIGN KEY (course_id) REFERENCES course(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个设计点值得展开说。第一course表里冗余了selected_count字段而不是每次去course_selection表里count。这是典型的读多写少场景下的反范式设计——选课页面要频繁展示每门课的已选人数如果每次都count课程列表一多就会拖慢查询。第二course_selection表上建了(student_id, course_id)的唯一索引这是防止重复选课的第一道防线后面讲并发的时候会重点说。第三status字段用逻辑删除而不是物理删除退课只是把status改成0这样选课历史可追溯也避免了外键约束下删除记录带来的麻烦。2.3 IDEA里跑通第一个Servlet配置项和常见报错在IDEA里新建JavaWeb项目最容易翻车的地方不是代码而是项目结构配置。我见过太多人卡在“404”或者“ClassNotFoundException”上其实都是配置问题。步骤上先确认Project Structure里Modules的Web配置Web Resource Directory指向src/main/webappDeployment Descriptor指向web.xml如果用注解可以不要。然后Artifacts里要有一个war exploded类型的输出Output Layout里把WEB-INF/lib下的依赖都加进去。最后Tomcat配置里Deployment选这个artifactApplication context设成/或者/course。// 一个最小的课程列表Servlet验证环境是否跑通 WebServlet(/course/list) public class CourseListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 设置编码防止中文乱码 req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); ListCourse courses new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement( SELECT id, course_code, course_name, teacher_name, credit, capacity, selected_count FROM course WHERE semester ?)) { ps.setString(1, 2024-2025-1); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Course c new Course(); c.setId(rs.getInt(id)); c.setCourseCode(rs.getString(course_code)); c.setCourseName(rs.getString(course_name)); c.setTeacherName(rs.getString(teacher_name)); c.setCredit(rs.getBigDecimal(credit)); c.setCapacity(rs.getInt(capacity)); c.setSelectedCount(rs.getInt(selected_count)); courses.add(c); } } } catch (SQLException e) { throw new ServletException(查询课程列表失败, e); } req.setAttribute(courseList, courses); req.getRequestDispatcher(/WEB-INF/views/course-list.jsp).forward(req, resp); } }这段代码的逻辑很直白建立数据库连接执行查询把结果集映射成Course对象列表存到request作用域转发给JSP渲染。参数说明上req.setCharacterEncoding(UTF-8)必须在获取任何参数之前调用否则POST请求里的中文会乱码。resp.setContentType则保证浏览器用UTF-8解析HTML。DBUtil是一个简单的连接池封装用Druid的话就是DruidDataSource的getConnection()。如果这一步跑起来报404先检查WebServlet的路径和浏览器访问路径是否一致如果报500且堆栈里有ClassNotFoundException: com.mysql.cj.jdbc.Driver说明MySQL驱动jar没放到WEB-INF/lib下如果报Access denied for user检查DBUtil里的用户名密码和MySQL实际配置是否匹配。这些坑看着简单但新手至少有一半时间耗在这上面。3. 选课核心业务实现事务、并发与容量扣减3.1 选课接口的完整实现从参数校验到事务提交选课是整个系统里最核心也最容易出问题的操作。它不是一个简单的INSERT而是包含了一系列判断学生是否存在、课程是否存在、是否已选过、课程是否已满、当前是否在选课时间窗口内。这些判断必须在同一个事务里完成否则并发场景下必然超选。public class SelectionService { public SelectionResult selectCourse(int studentId, int courseId) { Connection conn null; try { conn DBUtil.getConnection(); // 关闭自动提交开启事务 conn.setAutoCommit(false); // 设置事务隔离级别为可重复读MySQL默认级别 conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ); // 1. 检查是否已选利用唯一索引兜底这里先查一次给友好提示 if (hasSelected(conn, studentId, courseId)) { conn.rollback(); return SelectionResult.fail(您已选过该课程); } // 2. 锁定课程行防止并发超选 Course course lockCourse(conn, courseId); if (course null) { conn.rollback(); return SelectionResult.fail(课程不存在); } if (course.getSelectedCount() course.getCapacity()) { conn.rollback(); return SelectionResult.fail(课程已满); } // 3. 插入选课记录 insertSelection(conn, studentId, courseId); // 4. 更新课程已选人数 updateSelectedCount(conn, courseId); conn.commit(); return SelectionResult.success(选课成功); } catch (SQLIntegrityConstraintViolationException e) { // 唯一索引冲突说明并发下重复插入 rollbackQuietly(conn); return SelectionResult.fail(请勿重复选课); } catch (SQLException e) { rollbackQuietly(conn); throw new RuntimeException(选课失败, e); } finally { closeQuietly(conn); } } private Course lockCourse(Connection conn, int courseId) throws SQLException { // SELECT ... FOR UPDATE 锁定该行其他事务必须等待 String sql SELECT id, capacity, selected_count FROM course WHERE id ? FOR UPDATE; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, courseId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { Course c new Course(); c.setId(rs.getInt(id)); c.setCapacity(rs.getInt(capacity)); c.setSelectedCount(rs.getInt(selected_count)); return c; } } } return null; } private void insertSelection(Connection conn, int studentId, int courseId) throws SQLException { String sql INSERT INTO course_selection (student_id, course_id, status) VALUES (?, ?, 1); try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, studentId); ps.setInt(2, courseId); ps.executeUpdate(); } } private void updateSelectedCount(Connection conn, int courseId) throws SQLException { String sql UPDATE course SET selected_count selected_count 1 WHERE id ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, courseId); ps.executeUpdate(); } } }这段代码的关键在于SELECT ... FOR UPDATE。它会在事务执行期间锁住课程行其他并发事务想选同一门课就必须排队等待。等第一个事务提交后selected_count已经加1第二个事务再读到的就是更新后的值从而避免超选。参数上事务隔离级别用REPEATABLE_READ是MySQL默认值配合FOR UPDATE的悲观锁已经足够。如果换成READ_COMMITTEDFOR UPDATE依然能锁行但不可重复读可能导致同一事务内两次读取结果不一致所以不建议改。另外SQLIntegrityConstraintViolationException的捕获是最后一道防线。即使前面的检查因为某种原因漏掉了唯一索引也会阻止重复插入保证数据一致性。3.2 退课与名额回滚别让容量字段变成负数退课的逻辑比选课简单但同样要放在事务里。核心操作是把选课记录的status改成0同时把课程的selected_count减1。这里有个容易忽略的点减1的时候要加条件selected_count 0否则一旦出现异常数据容量会变成负数。public boolean dropCourse(int studentId, int courseId) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 检查选课记录是否存在且状态为已选 String checkSql SELECT id FROM course_selection WHERE student_id ? AND course_id ? AND status 1 FOR UPDATE; int selectionId -1; try (PreparedStatement ps conn.prepareStatement(checkSql)) { ps.setInt(1, studentId); ps.setInt(2, courseId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { selectionId rs.getInt(id); } } } if (selectionId -1) { conn.rollback(); return false; } // 2. 逻辑删除选课记录 String updateSel UPDATE course_selection SET status 0 WHERE id ?; try (PreparedStatement ps conn.prepareStatement(updateSel)) { ps.setInt(1, selectionId); ps.executeUpdate(); } // 3. 回滚课程名额加selected_count 0条件防止负数 String updateCourse UPDATE course SET selected_count selected_count - 1 WHERE id ? AND selected_count 0; try (PreparedStatement ps conn.prepareStatement(updateCourse)) { ps.setInt(1, courseId); int affected ps.executeUpdate(); if (affected 0) { // 说明selected_count已经是0数据异常回滚 conn.rollback(); return false; } } conn.commit(); return true; } catch (SQLException e) { rollbackQuietly(conn); throw new RuntimeException(退课失败, e); } finally { closeQuietly(conn); } }退课接口在页面上通常是一个“退选”按钮提交后回到我的课程列表。这里要注意的是退课之后该学生可以重新选同一门课但唯一索引(student_id, course_id)会阻止插入新记录。所以重新选课时应该先查一下是否有status0的记录如果有就UPDATE回status1而不是INSERT。这个逻辑我在第一次做的时候漏了导致学生退课后无法再选同一门课排查了半天才发现是唯一索引在作怪。3.3 我的课程列表与已选学分统计学生登录后需要看到自己已选的课程和总学分。这个查询需要关联三张表course_selection、course、student。SQL写起来不复杂但要注意只查status1的记录。SELECT c.course_code, c.course_name, c.teacher_name, c.credit, cs.select_time FROM course_selection cs JOIN course c ON cs.course_id c.id WHERE cs.student_id ? AND cs.status 1 ORDER BY cs.select_time DESC;学分统计可以直接在Java里累加也可以再用一条SQLSELECT COALESCE(SUM(c.credit), 0) AS total_credit FROM course_selection cs JOIN course c ON cs.course_id c.id WHERE cs.student_id ? AND cs.status 1;COALESCE的作用是当没有选课记录时返回0而不是NULL避免前端显示空白。这个细节虽小但用户体验上差别很大。在JSP页面渲染时用JSTL的c:forEach遍历课程列表用fmt:formatNumber格式化学分。如果页面出现中文乱码检查JSP顶部的% page contentTypetext/html;charsetUTF-8 languagejava %和% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %是否都写对了。4. 避坑与排查选课系统里那些让人半夜爬起来改代码的问题4.1 并发选课导致超选现象、原因与解决现象课程容量设为50选课结束后发现选了52个人。压测时更容易复现两个线程同时读到selected_count49都判断未满然后都执行了加1结果变成51。原因检查容量和更新容量之间存在时间窗口没有锁的保护。即使加了事务如果隔离级别不够或者没有FOR UPDATE两个事务可能同时读到旧值。解决在查询课程时使用SELECT ... FOR UPDATE锁定行让并发事务串行化。同时把唯一索引作为兜底防止重复选课。如果不想用悲观锁也可以用UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity通过affected rows判断是否成功这是一种乐观锁的思路性能更好但需要处理失败重试。4.2 中文乱码从请求到响应到数据库的三层排查现象选课页面提交后课程名显示成“???”或者“æ•°æ®åº“。原因乱码可能出现在三个环节——浏览器提交编码、服务器解析编码、数据库存储编码。任何一层不一致都会出问题。解决第一JSP页面和HTML的meta都声明UTF-8第二Servlet里req.setCharacterEncoding(UTF-8)必须在getParameter之前第三数据库连接URL加上useUnicodetruecharacterEncodingutf8第四MySQL的库和表都用utf8mb4字符集。这四步做完基本不会再乱码。如果还有问题检查Tomcat的server.xml里Connector有没有URIEncodingUTF-8。4.3 数据库连接泄漏连接池耗尽后的连锁反应现象系统运行一段时间后所有请求都卡住日志里出现“wait timeout”或者“too many connections”。原因Connection没有在finally里关闭或者ResultSet和PreparedStatement没关。虽然连接池有回收机制但泄漏速度超过回收速度时池子很快就被占满。解决所有数据库操作都用try-with-resources确保Connection、PreparedStatement、ResultSet按顺序关闭。如果业务层需要传递Connection来保证事务那就在Service层统一关闭DAO层只负责使用不负责关闭。我一般会在DBUtil里加一个closeQuietly方法专门处理关闭时的异常吞掉避免关闭失败影响主流程。4.4 选课时间窗口校验被绕过前端校验不可信现象选课时间已经结束但通过Postman直接调接口还能选课。原因只在JSP页面用JavaScript做了时间判断后端Servlet没有校验。前端校验永远只是用户体验优化不能作为安全边界。解决在SelectionService的selectCourse方法开头查询当前学期配置的选课开始时间和结束时间如果当前时间不在窗口内直接返回失败。这个校验和事务无关可以放在事务开启之前减少不必要的数据库锁竞争。4.5 JSP页面直接访问绕过登录过滤器配置遗漏现象不登录直接在浏览器输入/course/list也能看到课程列表。原因没有配置登录过滤器或者过滤器的url-pattern写错了。解决写一个LoginFilter实现Filter接口在doFilter里检查session中是否有当前用户没有就重定向到登录页。web.xml里配置filter filter-nameLoginFilter/filter-name filter-classcom.example.filter.LoginFilter/filter-class /filter filter-mapping filter-nameLoginFilter/filter-name url-pattern/*/url-pattern /filter-mapping注意登录页和登录接口要排除在过滤之外否则会死循环。可以在Filter里判断请求路径如果是/login或/login.jsp就直接放行。5. 进阶技巧用连接池监控和SQL日志把问题扼杀在摇篮里选课系统做完能跑只是第一步真正上线或者答辩演示的时候最怕的是突然卡顿或者数据对不上。我后来养成了一个习惯在项目里集成Druid连接池开启它的监控和SQL日志功能。Druid自带一个Web监控页面能看到当前活跃连接数、慢SQL、执行次数最多的SQL。配置很简单在druid.properties里加上druid.stat.sql.MaxSize1000 druid.filter.stat.logSlowSqltrue druid.filter.stat.slowSqlMillis500然后在web.xml里注册Druid的StatViewServlet访问/druid/index.html就能看到监控面板。这个面板在排查“为什么选课接口越来越慢”时特别有用——有一次我发现选课接口平均耗时从20ms涨到了800ms打开监控一看是course_selection表的全表扫描因为唯一索引在某个版本里被误删了。加上索引后立刻恢复。另一个技巧是在Service层的关键操作前后打日志记录学生ID、课程ID、耗时、结果。不用引入Log4j那么重用java.util.logging或者简单的System.out加时间戳也行。但要注意日志里不要打印密码等敏感信息。我一般会在选课成功和失败的分支各打一条格式是[选课] studentId123 courseId456 resultsuccess cost15ms。这样当用户反馈“我明明选了课但列表里没有”时直接grep日志就能定位是选课没成功还是查询有问题。还有一个容易被忽略的点是数据库连接的有效性检测。MySQL默认8小时空闲连接会断开如果连接池里的连接被断开了但池子不知道下次借出去就会报“Communications link failure”。解决办法是在连接池配置里加上validationQuerySELECT 1和testWhileIdletrue让池子定期检测空闲连接。这个配置加上之后我再也没遇到过隔夜后第一个请求必报错的问题。最后说一个关于选课系统扩展性的小技巧。如果以后要加“候补选课”功能也就是课程满了之后学生可以排队有人退课就自动补位那可以在course_selection表里加一个waitlist_order字段status用2表示候补。退课时查询候补队列里order最小的记录把它改成status1并更新selected_count。这个逻辑不复杂但要注意候补转正和正常选课一样需要事务和锁。我一般会把这个功能放在选课系统稳定运行之后再考虑先把核心的选课退课做扎实。做这个项目最大的教训就是不要相信任何没有锁保护的数量判断不要相信前端传来的任何数据不要等到出问题了才去看日志。把事务边界画清楚把唯一索引建好把连接池配好选课系统其实没那么玄学。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网