ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JavaWeb学生选课系统实战:从Servlet到Spring Boot的完整实现与避坑指南

JavaWeb学生选课系统实战:从Servlet到Spring Boot的完整实现与避坑指南 简介这是一套基于Servlet与JSP实现的学生选课系统面向计算机相关专业正在做毕业设计的学生以及需要JavaWeb项目实战练习的学习者可直接作为毕设选题使用。项目采用JavaWeb、Servlet与MySQL构建后端前端使用JSP、Bootstrap、jQuery与CSS开发环境为IDEA或Eclipse搭配Navicat与JDK1.8并经过严格调试确保可运行。压缩包共112个文件约2.39MB包含22个Java源文件与22个class编译文件、21个JSP页面、10个依赖jar包以及css、js、图片、xml配置和一份sql数据库脚本覆盖源码与建库脚本结构完整。系统划分管理员、教师、学生三种权限管理员负责学生、教师与课程信息管理教师可查看课程与学生、录入成绩、查看个人信息学生可浏览课程、选课、查询成绩与查看个人信息。目前已有4273人学习适合作为课程设计或毕设参考帮助读者快速理解分层开发与权限控制的实现思路。1. 从零手搓学生选课系统JavaWeb 项目到底该从哪一层开始写很多人第一次接触 JavaWeb都是从「学生选课系统」这个题目开始的。它看起来简单——学生、课程、选课记录三张表增删改查而已。但真正动手时问题就来了JSP 里该不该写业务逻辑Servlet 和 Spring Boot 到底选哪个数据库连接池要不要配我见过太多人打开 IDEA 新建项目对着空白的 webapp 目录发呆半小时最后从网上抄一份跑不起来的代码交差。这篇笔记就是来解决这个问题的。我会按一个真实可落地的路径把基于 JavaWeb 实现学生选课系统的完整链路拆开从技术选型、数据库设计到 Servlet JSP 的经典实现再到用 Spring Boot 重构的进阶方案最后给出参数配置和排错清单。适合正在做课设、实训项目或者想从零理解 JavaWeb 请求流转全过程的开发者。读完你至少能拿到一套能跑通的代码骨架以及知道每一步为什么这么写。2. 选课系统的技术选型Servlet JSP 还是 Spring Boot2.1 两种路线的真实差异在动手之前必须先定技术栈。学生选课系统这个题目市面上主要有两种实现路线一种是经典 JavaWeb 路线用 Servlet JSP JDBC 手写另一种是 Spring Boot MyBatis Thymeleaf 的现代路线。两者没有绝对优劣但适用场景完全不同。经典路线的核心是「显式」——每一个请求都对应一个 Servlet 类每一次数据库操作都手写 JDBC 代码页面跳转靠request.getRequestDispatcher()。这种写法很啰嗦但好处是你能清楚看到 HTTP 请求从浏览器到数据库再返回的完整链路。对于要理解 JavaWeb 底层机制的人来说这是绕不开的一步。而且很多学校的实训环境就是 Tomcat JSP用 Spring Boot 反而可能因为版本问题跑不起来。Spring Boot 路线的核心是「约定优于配置」——内嵌 Tomcat自动装配一个RestController就能暴露接口。开发效率高但隐藏了大量细节。如果你已经理解了 Servlet 的生命周期和请求转发机制用 Spring Boot 重构是顺理成章的进阶如果一上来就用 Spring Boot遇到问题时很难定位是框架的锅还是自己的锅。我的建议是先用手写 Servlet JSP 跑通核心选课逻辑再用 Spring Boot 重构一遍。这样你既懂了原理又掌握了企业里真正在用的技术栈。2.2 数据库设计三张核心表与选课状态机不管用什么框架数据库设计是绕不开的。学生选课系统的核心表就三张学生表、课程表、选课记录表。但选课记录表的设计有个关键点——选课状态。-- 学生表 CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, major VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 课程表 CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(20) NOT NULL UNIQUE COMMENT 课程编号, course_name VARCHAR(100) NOT NULL, teacher VARCHAR(50), credit DECIMAL(3,1) NOT NULL DEFAULT 0, capacity INT NOT NULL DEFAULT 50 COMMENT 容量上限, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, semester VARCHAR(20) COMMENT 开课学期, schedule VARCHAR(100) COMMENT 上课时间地点 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 选课记录表 CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-已选 2-已退 3-已修完, selected_at DATETIME DEFAULT CURRENT_TIMESTAMP, 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字段用整数而不是布尔值因为选课状态不止「选/没选」两种还有退课、修完等状态用枚举值扩展性更好。容量控制是选课系统的核心逻辑。常见做法是在选课时用UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity通过affected rows判断是否选课成功。这条 SQL 在 InnoDB 行锁下是原子的不需要额外加锁。3. 用 Servlet JSP 跑通选课核心链路3.1 项目结构与基础配置先看经典 JavaWeb 项目的目录结构。用 Maven 管理依赖pom.xml里核心就三个依赖Servlet API、JSP API、MySQL 驱动。dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency /dependenciesscope设为provided是因为 Tomcat 容器本身已经带了 Servlet 和 JSP 的 jar 包打包时不需要重复打入。MySQL 驱动版本要和你的数据库版本匹配8.0 以上的驱动类名是com.mysql.cj.jdbc.Driver连接 URL 需要加时区参数。数据库连接池用 Druid配置文件放在src/main/resources/druid.propertiesdriverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/course_selection?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse usernameroot passwordyour_password initialSize5 maxActive20 maxWait3000maxActive20是最大连接数课设环境 20 足够。maxWait3000表示获取连接的超时时间 3 秒超过就抛异常避免请求无限等待。serverTimezone必须配否则 MySQL 8 会报时区错误。3.2 选课 Servlet 的完整实现选课的核心逻辑在一个 Servlet 里完成校验登录状态、检查课程容量、插入选课记录、更新已选人数。这四步必须在一个数据库事务里完成。WebServlet(/course/select) public class SelectCourseServlet extends HttpServlet { private CourseDao courseDao new CourseDao(); private SelectionDao selectionDao new SelectionDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 校验登录 HttpSession session req.getSession(false); if (session null || session.getAttribute(student) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } Student student (Student) session.getAttribute(student); Long courseId Long.parseLong(req.getParameter(courseId)); Connection conn null; try { conn DruidUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 2. 检查是否已选唯一索引兜底这里做友好提示 if (selectionDao.existsActive(conn, student.getId(), courseId)) { conn.rollback(); req.setAttribute(msg, 你已经选过这门课了); req.getRequestDispatcher(/course/list).forward(req, resp); return; } // 3. 原子更新容量affected rows 0 说明已满 int updated courseDao.increaseSelectedCount(conn, courseId); if (updated 0) { conn.rollback(); req.setAttribute(msg, 课程已满选课失败); req.getRequestDispatcher(/course/list).forward(req, resp); return; } // 4. 插入选课记录 selectionDao.insert(conn, student.getId(), courseId); conn.commit(); resp.sendRedirect(req.getContextPath() /course/my); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } throw new ServletException(选课失败, e); } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } } }这段代码的关键在increaseSelectedCount的 SQLpublic int increaseSelectedCount(Connection conn, Long courseId) throws SQLException { String sql UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, courseId); return ps.executeUpdate(); // 返回受影响行数 } }WHERE selected_count capacity这个条件保证了不会超卖。在 InnoDB 默认的 REPEATABLE READ 隔离级别下这条 UPDATE 会对匹配的行加排他锁并发请求会串行执行所以不需要额外加分布式锁。executeUpdate()返回 0 就说明课程已满直接回滚。注意conn.setAutoCommit(false)之后所有操作必须在同一个 Connection 上执行。如果你在 DAO 里各自从连接池取连接事务就失效了。这是新手最容易翻车的地方——代码看起来没问题但事务根本没生效。3.3 JSP 页面与请求转发JSP 负责展示不写业务逻辑。课程列表页用 JSTL 遍历% page contentTypetext/html;charsetUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle课程列表/title/head body h2可选课程/h2 c:if test${not empty msg} p stylecolor:red${msg}/p /c:if table border1 trth课程编号/thth课程名称/thth教师/thth学分/thth已选/容量/thth操作/th/tr c:forEach items${courseList} varc tr td${c.courseCode}/td td${c.courseName}/td td${c.teacher}/td td${c.credit}/td td${c.selectedCount}/${c.capacity}/td td c:choose c:when test${c.selectedCount c.capacity}已满/c:when c:otherwise form action${pageContext.request.contextPath}/course/select methodpost input typehidden namecourseId value${c.id} button typesubmit选课/button /form /c:otherwise /c:choose /td /tr /c:forEach /table /body /html${pageContext.request.contextPath}用来拼接项目上下文路径避免部署路径变化导致 404。选课按钮用表单 POST 提交不要用 GET——GET 请求会被浏览器缓存而且参数暴露在 URL 里刷新页面会重复提交。4. 用 Spring Boot 重构从手写 Servlet 到自动装配4.1 重构后的项目结构与依赖当你理解了 Servlet 的请求流转之后用 Spring Boot 重构会非常顺畅。核心变化是不再手写 Servlet用RestController或Controller替代不再手动管理数据库连接用spring-boot-starter-jdbc或 MyBatis 自动配置。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency /dependencies选 Spring Boot 2.7.x 而不是 3.x是因为 3.x 要求 JDK 17 且把javax.servlet换成了jakarta.servlet很多课设环境还是 JDK 8 或 11。2.7 是最后一个兼容javax的版本踩坑最少。application.yml配置spring: datasource: url: jdbc:mysql://localhost:3306/course_selection?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 thymeleaf: cache: false prefix: classpath:/templates/ suffix: .htmlconnection-timeout: 3000对应 Druid 的maxWait单位毫秒。thymeleaf.cache: false在开发阶段关闭模板缓存改完 HTML 刷新就能看到效果不用重启。4.2 选课接口的事务控制Spring Boot 里用Transactional声明事务比手写setAutoCommit简洁得多但有几个坑必须注意。Service public class SelectionService { Autowired private CourseMapper courseMapper; Autowired private SelectionMapper selectionMapper; Transactional(rollbackFor Exception.class) public void selectCourse(Long studentId, Long courseId) { // 1. 检查是否已选 int exists selectionMapper.countActive(studentId, courseId); if (exists 0) { throw new BizException(你已经选过这门课了); } // 2. 原子扣减容量 int updated courseMapper.increaseSelectedCount(courseId); if (updated 0) { throw new BizException(课程已满选课失败); } // 3. 插入选课记录 selectionMapper.insert(studentId, courseId); } }Transactional(rollbackFor Exception.class)必须加rollbackFor因为 Spring 默认只对RuntimeException回滚如果你的业务异常继承自Exception不加这个参数事务不会回滚。这是血泪教训——测试时发现选课失败了但容量还是被扣了查了半天才发现是异常类型的问题。另一个坑是Transactional的自调用失效。如果你在同一个类里方法 A 调用方法 B而 B 上有Transactional事务不会生效因为 Spring 的 AOP 代理是基于外部调用的。解决办法是把 B 方法抽到另一个 Service 里或者用AopContext.currentProxy()获取代理对象。4.3 用 Thymeleaf 替代 JSPThymeleaf 的语法和 JSP 差别不大但它是纯 HTML可以直接在浏览器里预览。!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org headmeta charsetUTF-8title课程列表/title/head body h2可选课程/h2 p th:if${msg} th:text${msg} stylecolor:red/p table border1 trth课程编号/thth课程名称/thth教师/thth学分/thth已选/容量/thth操作/th/tr tr th:eachc : ${courseList} td th:text${c.courseCode}/td td th:text${c.courseName}/td td th:text${c.teacher}/td td th:text${c.credit}/td td th:text${c.selectedCount / c.capacity}/td td span th:if${c.selectedCount c.capacity}已满/span form th:unless${c.selectedCount c.capacity} th:action{/course/select} methodpost input typehidden namecourseId th:value${c.id} button typesubmit选课/button /form /td /tr /table /body /htmlth:action{/course/select}会自动拼接上下文路径比 JSP 的${pageContext.request.contextPath}更简洁。th:each替代c:forEachth:if替代c:if语法更接近原生 HTML。5. 选课系统避坑与排查那些让你加班到凌晨的坑5.1 中文乱码从请求到响应全链路排查现象表单提交的课程名称在数据库里显示为???或者页面返回的中文变成乱码。原因乱码可能出现在三个环节——请求编码、数据库连接编码、响应编码。Servlet 3.0 之前需要手动request.setCharacterEncoding(UTF-8)Tomcat 8 之后默认就是 UTF-8但 GET 请求的参数编码取决于server.xml里的URIEncoding。数据库连接 URL 没加characterEncodingutf8也会导致乱码。解决三步走。第一检查server.xml的 Connector 是否配了URIEncodingUTF-8第二JDBC URL 必须带useUnicodetruecharacterEncodingutf8第三响应头设置response.setContentType(text/html;charsetUTF-8)。Spring Boot 项目在application.yml里配server.servlet.encoding.charsetUTF-8和forcetrue即可。5.2 事务不生效选课失败但容量被扣现象课程已满时选课失败但刷新页面发现selected_count还是增加了。原因事务没有回滚。常见原因有三个——异常被 catch 后没有重新抛出、Transactional自调用失效、多数据源下事务管理器不匹配。解决先确认异常有没有被吞掉。如果用了 try-catch必须在 catch 里throw new RuntimeException(e)或者手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。如果是自调用问题把事务方法抽到独立的 Service 类里。多数据源场景需要显式指定Transactional(transactionManager xxxTransactionManager)。5.3 连接池耗尽并发选课时的 timeout 异常现象压测时大量请求报Could not get JDBC Connection或wait millis 3000。原因连接池最大连接数太小或者有连接泄漏——某个 DAO 方法拿了连接没关闭。解决先查泄漏。用 Druid 的监控页面看活跃连接数如果持续增长不下降基本就是泄漏。检查所有getConnection()的地方确保在 finally 里close()。如果用 Spring 的JdbcTemplate它会自动管理连接不会泄漏。连接数调大只能缓解不能根治泄漏问题。5.4 重复选课唯一索引冲突的异常处理现象用户快速双击选课按钮第二次请求报Duplicate entry异常页面显示 500 错误。原因应用层判断「是否已选」和「插入记录」之间有并发窗口两个请求同时通过了判断但只有一个能插入成功。解决唯一索引是最后防线但异常要友好处理。在插入时 catchDuplicateKeyException转换成「你已经选过这门课了」的提示。更优雅的做法是用INSERT IGNORE或ON DUPLICATE KEY UPDATE但要注意这两种写法在选课场景下语义不同——INSERT IGNORE会静默忽略用户不知道发生了什么。5.5 IDEA 运行 JavaWeb 项目的配置陷阱现象项目在 Eclipse 里能跑换到 IDEA 就 404 或者 ClassNotFound。原因IDEA 的 Artifact 配置和 Tomcat 部署路径与 Eclipse 不同。常见问题是WEB-INF/lib下没有打入依赖 jar或者web.xml的url-pattern配错。解决在 IDEA 的 Project Structure 里检查 Artifact确保Available Elements里的 Maven 依赖都放到了WEB-INF/lib下。Tomcat 配置里Application context设为/或你的项目名Deployment里选artifact:war exploded。如果用的是 Spring Boot直接跑main方法就行不需要配 Tomcat。6. 选课系统的进阶技巧容量控制与并发安全容量控制是选课系统最核心的技术点也是面试和答辩时最容易被追问的地方。前面用的UPDATE ... WHERE selected_count capacity在单机 MySQL 下是可靠的但有几个边界情况需要处理。第一个边界是退课后的容量释放。退课逻辑不能直接DELETE选课记录而是把status改为 2已退同时UPDATE course SET selected_count selected_count - 1 WHERE id ? AND selected_count 0。selected_count 0是防御性条件防止并发退课导致计数变成负数。第二个边界是超卖检测。即使有WHERE selected_count capacity在高并发下仍然可能出现「实际选课人数 容量」的情况——如果有人在数据库里手动改了selected_count或者程序有 bug 导致计数不准。建议加一个定时任务或管理端接口定期用SELECT COUNT(*) FROM course_selection WHERE course_id ? AND status 1校准selected_count。第三个边界是分布式场景。如果选课系统部署了多个实例单机 MySQL 的行锁仍然有效因为锁是在数据库层面的。但如果用了 Redis 缓存课程容量就需要考虑缓存和数据库的一致性。常见做法是「先更新数据库再删除缓存」而不是「先删缓存再更新数据库」——后者在并发下容易读到旧数据。// 退课逻辑状态改为已退容量减一 Transactional(rollbackFor Exception.class) public void dropCourse(Long studentId, Long courseId) { int updated selectionMapper.updateStatus(studentId, courseId, 2); if (updated 0) { throw new BizException(你没有选这门课无法退课); } courseMapper.decreaseSelectedCount(courseId); }// 容量校准管理端手动触发或定时任务 public void calibrateCapacity(Long courseId) { int actualCount selectionMapper.countActiveByCourse(courseId); courseMapper.updateSelectedCount(courseId, actualCount); }这两个方法看起来简单但退课的updateStatus必须带status 1的条件否则重复退课会把已经退过的记录再退一次容量被多减。校准方法则是在系统低峰期跑避免和正常选课请求冲突。我自己的习惯是每次写完选课逻辑都会用 JMeter 或ab压测一遍模拟 100 个并发请求抢 10 个名额然后查数据库确认selected_count和实际记录数一致。这个习惯帮我抓过好几次事务配置错误。选课系统不难难的是把并发边界想清楚。希望帮到你。本文还有配套的精品资源点击获取
返回列表