ARTICLE DETAIL

资讯详情

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

从零手搓学生选课系统:JavaWeb全链路实战与并发避坑指南

从零手搓学生选课系统:JavaWeb全链路实战与并发避坑指南 简介这是一套基于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依赖、5个CSS与5个JS脚本以及SQL数据库脚本和项目配置文件源码与数据库脚本齐全项目经过严格调试可正常运行。目前已有4273人学习下载适合需要完整选课系统方案、权限设计与数据库脚本参考的读者。1. 从零手搓学生选课系统为什么我劝你先别急着写 Controller每年毕业季总有人问我同一个问题想做一个能写进简历、答辩时不被老师问倒的 JavaWeb 项目选什么好我的答案几乎没变过——学生选课系统。它看起来平平无奇但真正动手做一遍你就会发现这个题目把 JavaWeb 的核心链路全串起来了登录鉴权、角色区分、多表关联、事务控制、并发抢课一个都跑不掉。很多人一上来就打开 IDEA 新建 Controller结果写到一半发现数据库表设计有漏洞选课逻辑根本兜不住只能推倒重来。这篇笔记就按我实际做项目的顺序从环境搭建、数据库设计、后端接口到并发处理把学生选课系统这条线完整走一遍。适合正在找练手项目的在校生也适合想复习 JavaWeb 全链路但不知道从哪下手的初级开发。读完你至少能拿到一个结构清晰、能跑通、能讲明白的完整案例。2. 环境与数据库把地基打牢再动砖2.1 IDEA 运行 JavaWeb 项目的正确姿势很多人卡在第一步IDEA 里新建了项目Tomcat 也配了一启动就报 404 或者 ClassNotFound。问题通常不在代码而在项目结构。JavaWeb 项目在 IDEA 里有两个容易混淆的概念——Artifact 和 Module。Module 是你的源码和依赖Artifact 是最终打包部署到 Tomcat 的产物。如果 Artifact 里没有把WEB-INF/classes和WEB-INF/lib正确导出Tomcat 启动时找不到类自然就 404。我一般会按这个顺序操作。先确认 JDK 版本学生选课系统用 JDK 8 或 11 都行但不要用 JDK 17 以上因为很多老教程用的 Servlet 版本和 Tomcat 版本对不上。Tomcat 选 8.5 或 9.0这两个版本对 Servlet 3.1/4.0 支持最稳。新建项目时选 Java Enterprise勾选 Web ApplicationIDEA 会自动生成web目录和WEB-INF/web.xml。# 检查 JDK 版本确保是 8 或 11 java -version # 检查 Tomcat 版本建议 8.5.x 或 9.0.x # 在 Tomcat 安装目录的 bin 下执行 ./version.sh # Linux/Mac version.bat # Windows逻辑说明JDK 版本决定了你能用的语言特性也影响 Servlet 容器的兼容性。Tomcat 8.5 对应 Servlet 3.1Tomcat 9.0 对应 Servlet 4.0两者都支持注解式 Servlet写起来比 web.xml 配置舒服得多。参数上如果你用 Maven 管理依赖pom.xml里 Servlet API 的 scope 要设成provided因为 Tomcat 自带 Servlet 实现打包时不需要重复引入。!-- pom.xml 中 Servlet 依赖的正确写法 -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope !-- 关键Tomcat 已提供打包时不带入 -- /dependency接下来是 Artifact 配置。打开 Project Structure找到 Artifacts确认 Output Layout 里WEB-INF/classes指向你的编译输出目录WEB-INF/lib包含所有第三方 jar。如果用的是 MavenIDEA 通常会自动处理但偶尔会抽风手动检查一遍能省掉很多玄学问题。2.2 选课系统的数据库表设计与 MySQL 连接表设计是学生选课系统的灵魂。我见过太多人把学生、课程、选课记录塞在一张表里结果查询时各种冗余和异常。正确的做法是至少三张表学生表、课程表、选课关系表。选课关系表用联合主键或者独立主键加唯一索引防止同一个学生重复选同一门课。-- 学生表 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL COMMENT 建议存 BCrypt 哈希, major VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 课程表 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE COMMENT 课程编号, name VARCHAR(100) NOT NULL, teacher VARCHAR(50), credit DECIMAL(3,1) NOT NULL DEFAULT 2.0, capacity INT NOT NULL DEFAULT 50 COMMENT 容量上限, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, semester VARCHAR(20) NOT NULL COMMENT 学期如 2024-2025-1 ) 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 一遍。但冗余字段必须和关系表在同一个事务里更新否则会出现数据不一致。course_selection表的唯一索引uk_student_course是防止重复选课的最后一道防线即使应用层判断漏了数据库也会拦住。MySQL 连接用 JDBC 或者连接池。学生项目里我推荐用 Druid 或者 HikariCP别直接用 DriverManager 每次新建连接性能差而且容易泄漏。配置文件放在src/main/resources/druid.properties。# druid.properties driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/course_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse usernameroot passwordyour_password initialSize5 maxActive20 maxWait3000参数说明serverTimezone必须设否则 MySQL 8 会报时区错误。useSSLfalse在本地开发时关掉省得配证书。maxActive20对课程设计够用了生产环境要根据实际并发调。连接池初始化时建议加一个测试查询确认数据库连通再启动应用。3. 后端接口从登录鉴权到选课事务3.1 登录与角色鉴权Filter 还是 Interceptor学生选课系统有两类角色学生和管理员。学生能选课、退课、查成绩管理员能增删课程、查看选课名单。鉴权方案我一般用 Filter 做粗粒度拦截用 Session 存用户信息。别一上来就上 Spring Security课程设计项目里那套配置能把人绕晕而且答辩时老师问起来你未必讲得清。// LoginFilter.java WebFilter(urlPatterns {/student/*, /admin/*}) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; HttpSession session request.getSession(false); // 放行登录页和静态资源 String uri request.getRequestURI(); if (uri.contains(/login) || uri.contains(/css/) || uri.contains(/js/)) { chain.doFilter(req, resp); return; } if (session null || session.getAttribute(user) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } // 角色校验/admin/* 只允许管理员访问 if (uri.contains(/admin/) !admin.equals(session.getAttribute(role))) { response.sendError(403, 无权访问); return; } chain.doFilter(req, resp); } }逻辑说明getSession(false)表示不自动创建新 Session避免未登录用户也生成 Session 浪费内存。放行规则里把登录页和静态资源排除否则登录页自己都被拦截了。角色校验放在 Filter 里做一层粗筛细粒度的权限比如某个学生只能退自己的课还是在 Service 层用业务逻辑判断。参数上Session 超时时间在web.xml里配默认 30 分钟课程设计可以设成 60 分钟方便调试。如果前后端分离Session 方案就不合适了得换 Token但学生选课系统用 JSP 或 Thymeleaf 服务端渲染更省事Session 完全够用。3.2 选课核心逻辑事务、行锁与容量控制选课是整个系统里唯一需要认真对待并发的地方。假设一门课容量 50 人当前已选 49 人两个学生同时点选课如果不加控制两人都会读到 49然后都加 1最后变成 51超卖了。这就是典型的并发问题也是答辩时老师最爱问的点。解决方案分两层。第一层在应用层用synchronized或者ReentrantLock按课程 ID 加锁但单机锁在集群下失效而且粒度太粗影响性能。第二层在数据库层用行锁在事务里SELECT ... FOR UPDATE锁住课程记录再判断容量。// CourseService.java 选课方法 public boolean selectCourse(int studentId, int courseId) { Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 锁定课程行防止并发超卖 String lockSql SELECT capacity, selected_count FROM course WHERE id ? FOR UPDATE; PreparedStatement lockStmt conn.prepareStatement(lockSql); lockStmt.setInt(1, courseId); ResultSet rs lockStmt.executeQuery(); if (!rs.next()) { conn.rollback(); return false; // 课程不存在 } int capacity rs.getInt(capacity); int selectedCount rs.getInt(selected_count); if (selectedCount capacity) { conn.rollback(); return false; // 已满 } // 2. 检查是否已选过唯一索引兜底这里先查一次给友好提示 String checkSql SELECT id FROM course_selection WHERE student_id ? AND course_id ? AND status 1; PreparedStatement checkStmt conn.prepareStatement(checkSql); checkStmt.setInt(1, studentId); checkStmt.setInt(2, courseId); if (checkStmt.executeQuery().next()) { conn.rollback(); return false; // 重复选课 } // 3. 插入选课记录 String insertSql INSERT INTO course_selection (student_id, course_id) VALUES (?, ?); PreparedStatement insertStmt conn.prepareStatement(insertSql); insertStmt.setInt(1, studentId); insertStmt.setInt(2, courseId); insertStmt.executeUpdate(); // 4. 更新已选人数 String updateSql UPDATE course SET selected_count selected_count 1 WHERE id ?; PreparedStatement updateStmt conn.prepareStatement(updateSql); updateStmt.setInt(1, courseId); updateStmt.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }逻辑说明FOR UPDATE是 InnoDB 的行级锁锁住课程记录后其他事务再想锁同一行就会阻塞直到当前事务提交。这样容量判断和更新就在同一个临界区里不会超卖。注意FOR UPDATE必须在事务里才有意义autocommittrue时锁会立即释放。参数上事务隔离级别用默认的REPEATABLE READ就行MySQL 的 RR 级别配合FOR UPDATE能保证可重复读且不会幻读。退课逻辑类似先锁课程行再更新选课记录状态为 0同时selected_count - 1。注意退课也要检查是否真的选过别把没选的课退了。3.3 课程列表查询与分页学生登录后第一眼看到的就是可选课程列表。这个查询要关联课程表和选课表显示每门课的已选人数、剩余容量以及当前学生是否已选。分页用LIMIT offset, size别一次性查全表。-- 查询课程列表带当前学生选课状态 SELECT c.id, c.course_no, c.name, c.teacher, c.credit, c.capacity, c.selected_count, (c.capacity - c.selected_count) AS remaining, CASE WHEN cs.id IS NOT NULL THEN 1 ELSE 0 END AS has_selected FROM course c LEFT JOIN course_selection cs ON c.id cs.course_id AND cs.student_id ? AND cs.status 1 WHERE c.semester ? ORDER BY c.course_no LIMIT ?, ?;参数说明LEFT JOIN保证即使学生没选任何课课程列表也能完整显示。CASE WHEN把选课状态转成 0/1前端好处理。LIMIT的两个参数分别是偏移量和每页条数偏移量 (页码 - 1) * 每页条数。课程量大的时候深分页LIMIT 10000, 20会变慢可以用游标或者记住上次最大 ID但课程设计项目里几百条数据无所谓。4. 避坑与排查那些让我熬夜的瞬间4.1 中文乱码从请求到响应全链路排查现象表单提交的学生姓名在数据库里变成问号或者页面显示乱码。原因通常有三处JSP 页面编码、请求体编码、数据库连接编码。解决顺序是从后往前查。先确认 MySQL 库和表的字符集是utf8mb4再看 JDBC URL 有没有characterEncodingutf8然后检查web.xml有没有配CharacterEncodingFilter最后看 JSP 页面的pageEncoding和contentType。!-- web.xml 中配置编码过滤器放在所有 Filter 最前面 -- filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping注意forceEncodingtrue会同时设置请求和响应编码省得两边分别配。如果用了 Tomcat 8 以上GET 请求的 URI 编码由server.xml的URIEncoding决定默认已经是 UTF-8不用改。4.2 连接池耗尽Connection leak 的典型症状现象系统跑一段时间后所有数据库操作都卡住日志里出现wait millis 3000, active 20, maxActive 20。原因很简单——有地方拿了连接没关。最常见的是在 DAO 里手动getConnection()后异常分支直接 return 了没走finally关闭。解决方法是统一用 try-with-resources或者用 Spring 的JdbcTemplate帮你管。// 错误写法异常时连接泄漏 Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery(); // 如果这里抛异常conn 永远不会关闭 // 正确写法try-with-resources 自动关闭 try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, studentId); try (ResultSet rs ps.executeQuery()) { // 处理结果 } } catch (SQLException e) { e.printStackTrace(); }排查技巧Druid 连接池自带监控页面配好stat-view-servlet后访问/druid就能看到活跃连接数和泄漏检测。如果不想配监控在获取连接的地方打印堆栈跑一遍就能定位到哪一行没关。4.3 选课超卖为什么加了 synchronized 还是出问题现象明明在 Service 方法上加了synchronized压测时还是超卖。原因有两个可能一是锁的对象不对比如锁了this但 Service 是多例的二是事务提交在锁释放之后锁释放了但事务还没提交其他线程读到旧数据。解决方法是把锁加在事务外层或者直接用数据库行锁别依赖 JVM 锁。// 错误锁在事务方法内部事务提交前锁已释放 public synchronized boolean selectCourse(int studentId, int courseId) { // 事务开始 // ... 业务逻辑 // 事务提交 // 方法返回锁释放 —— 但事务提交可能还没完成 } // 正确用数据库行锁不依赖 JVM 锁 // 见 3.2 节的 FOR UPDATE 方案血泪经验JVM 锁只在单机有效而且和事务边界很难对齐。学生选课系统里直接用SELECT ... FOR UPDATE是最稳的代码也不复杂。如果非要用 JVM 锁确保锁的范围覆盖整个事务并且 Service 是单例的。4.4 外键约束导致删课失败现象管理员想删除一门已经有人选的课程数据库报Cannot delete or update a parent row: a foreign key constraint fails。原因是course_selection表有外键指向course。解决方法是先删选课记录再删课程或者把外键改成ON DELETE CASCADE但级联删除有风险我一般不建议。-- 方案一先删选课记录再删课程推荐逻辑清晰 DELETE FROM course_selection WHERE course_id ?; DELETE FROM course WHERE id ?; -- 方案二软删除课程表加 is_deleted 字段不物理删除 ALTER TABLE course ADD COLUMN is_deleted TINYINT DEFAULT 0; UPDATE course SET is_deleted 1 WHERE id ?; -- 查询时加 WHERE is_deleted 0注意软删除更适合选课系统因为历史选课记录需要保留。物理删除会导致数据丢失答辩时老师问「学生以前选过这门课怎么查」就尴尬了。4.5 Tomcat 启动报错 Port already in use现象IDEA 启动 Tomcat 时提示Address already in use: JVM_Bind。原因是 8080 端口被占用了可能是上次 Tomcat 没关干净也可能是其他程序比如另一个项目在用。解决方法是找到占用端口的进程并杀掉或者改 Tomcat 端口。# Windows 查端口占用 netstat -ano | findstr :8080 taskkill /PID 进程ID /F # Linux/Mac 查端口占用 lsof -i :8080 kill -9 进程ID改端口的话在 Tomcat 的conf/server.xml里找到Connector port8080改成 8081 或别的。IDEA 里如果配了多个 Tomcat 实例每个实例的端口都要错开包括 HTTP 端口、AJP 端口和 shutdown 端口。5. 进阶技巧把选课系统做得更像真实项目5.1 用存储过程或触发器兜底容量控制应用层的行锁方案已经能解决超卖但如果你想让数据库自己保证一致性可以加一个触发器。当course_selection插入记录时触发器检查课程容量满了就抛异常回滚。这样即使应用层有 bug数据库也不会被写坏。DELIMITER // CREATE TRIGGER trg_check_capacity BEFORE INSERT ON course_selection FOR EACH ROW BEGIN DECLARE current_count INT; DECLARE max_capacity INT; SELECT selected_count, capacity INTO current_count, max_capacity FROM course WHERE id NEW.course_id FOR UPDATE; IF current_count max_capacity THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 课程已满选课失败; END IF; END // DELIMITER ;逻辑说明触发器在BEFORE INSERT时执行FOR UPDATE锁住课程行检查容量。如果满了SIGNAL抛出自定义错误事务自动回滚。参数上SQLSTATE 45000是用户自定义异常的通用状态码应用层捕获SQLException后根据错误信息提示用户。注意触发器会增加数据库负担高并发场景下不如应用层锁灵活但作为兜底手段很可靠。5.2 选课结果验证用 SQL 自查数据一致性项目做完后怎么确认没有超卖、没有重复选课、没有人数对不上我一般写几条自查 SQL跑一遍心里就有底了。-- 1. 检查是否有课程超卖 SELECT c.id, c.name, c.capacity, c.selected_count FROM course c WHERE c.selected_count c.capacity; -- 2. 检查 selected_count 和实际选课记录数是否一致 SELECT c.id, c.name, c.selected_count, COUNT(cs.id) AS actual_count FROM course c LEFT JOIN course_selection cs ON c.id cs.course_id AND cs.status 1 GROUP BY c.id, c.name, c.selected_count HAVING c.selected_count ! COUNT(cs.id); -- 3. 检查是否有学生重复选同一门课唯一索引应该拦住但查一下放心 SELECT student_id, course_id, COUNT(*) AS cnt FROM course_selection WHERE status 1 GROUP BY student_id, course_id HAVING cnt 1;这三条 SQL 分别对应超卖、计数不一致、重复选课。第一条和第二条如果有结果说明事务逻辑有漏洞第三条如果有结果说明唯一索引没生效或者被绕过了。每次改完选课逻辑跑一遍这三条比写单元测试还快。5.3 一个我常用的调试习惯做学生选课系统那会儿我养成了一个习惯每次改完选课相关的代码先不急着点页面直接在数据库里手动模拟并发。开两个 MySQL 客户端都执行BEGIN; SELECT ... FOR UPDATE;然后一个提交一个回滚看另一个会不会阻塞。这个土办法帮我提前发现了三次锁范围不对的问题。后来我把它写成了一个简单的测试脚本用 Java 的CountDownLatch模拟 100 个线程同时选同一门课跑完检查selected_count是否等于capacity。这个脚本我到现在还留着换任何并发场景都能改改用。希望帮到你。本文还有配套的精品资源点击获取
返回列表