
简介这份JavaWeb实训资源面向计算机相关专业学生与Java Web初学者围绕学生选课系统这一经典课程设计场景提供从需求分析到代码落地的完整参考。系统按角色划分功能学生可注册登录、浏览课程、选课退课并查询已选结果教师可发布与删除课程、查看选课情况涵盖注册登录、学生选课、选课结果浏览、教师浏览结果、教师添加课程五大模块。资源包共111个文件约20.84MB包含java源码、jsp页面、class编译文件、css与scss样式、sql建库脚本以及实训报告docx和答辩pptx另有jar依赖与图片等素材结构完整、便于直接运行与二次修改。目前已有4575人学习下载适合用作课程设计、期末大作业或答辩准备的参考方案帮助读者快速理解Servlet、DAO分层与数据库交互的实现思路。1. 从一份“交作业”的选课系统说起为什么它值得你认真做一遍每年学期末总有一批 JavaWeb 实训卡在同一个地方功能能跑但代码一团乱答辩能讲但一问 SQL 就露馅。学生选课系统几乎是所有高校 JavaWeb 实训的默认题目原因很现实——它同时踩中了三件必须练的事多表关联的业务逻辑、会话状态的管理、以及并发场景下的数据一致性。你如果只是把它当成一次“交作业”那它确实没什么价值但如果你把它当成一次完整的工程演练从建库、写 DAO、配 Tomcat 到答辩时能讲清楚“为什么选课要加唯一索引”那这套东西的含金量会完全不同。这篇笔记面向三类人正在做 JavaWeb 实训、需要一份能跑通且能讲明白的选课系统的同学想拿一个完整案例练手 MySQL 多表操作和 Servlet 流程的初学者以及需要准备答辩、但不知道怎么把技术点讲出深度的开发者。我会按“建库建表 → 后端分层 → 前端交互 → 并发与事务 → 答辩与调试”的顺序把一套可复现的方案拆开讲。代码和 SQL 都会给到关键片段参数怎么设、坑在哪、为什么这么选都会说清楚。你照着做至少能拿到一个结构清晰、能经得起追问的实训成果。2. 建库建表选课系统的 SQL 文件到底该怎么写2.1 四张核心表的关系与字段设计选课系统的数据模型不复杂但很多同学一上来就建一张大表把学生、课程、选课记录全塞在一起后面查询和更新都会出问题。常见做法是拆成四张表学生表、课程表、教师表、选课记录表。选课记录表是典型的中间表用来表达学生和课程之间的多对多关系。先看建表语句的关键部分。下面这段 SQL 可以直接放进你的schema.sql文件里在 MySQL 中执行-- 学生表学号作为业务主键id 作为物理主键 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, sname VARCHAR(50) NOT NULL COMMENT 姓名, password VARCHAR(64) NOT NULL COMMENT 密码存哈希值, major VARCHAR(50) DEFAULT NULL COMMENT 专业, grade INT DEFAULT NULL COMMENT 年级 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 课程表课程容量和已选人数分开存方便做并发控制 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, cno VARCHAR(20) NOT NULL UNIQUE COMMENT 课程号, cname VARCHAR(100) NOT NULL COMMENT 课程名, credit DECIMAL(3,1) NOT NULL DEFAULT 0 COMMENT 学分, capacity INT NOT NULL DEFAULT 50 COMMENT 容量上限, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, teacher_id INT DEFAULT NULL COMMENT 授课教师 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 选课记录表学生和课程的多对多中间表 CREATE TABLE sc ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 选课时间, 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;这段 SQL 里有几个设计点值得展开。第一student表的sno加了UNIQUE因为学号是业务上的唯一标识登录和查询都靠它不能重复。第二course表把capacity和selected_count分开存而不是每次去sc表里COUNT(*)这是为了在选课高峰期减少聚合查询的压力。第三sc表上的UNIQUE KEY uk_student_course是整套系统里最关键的一行——它从数据库层面保证同一个学生不能重复选同一门课后面讲并发的时候会反复提到它。提示如果你的 MySQL 版本是 5.7 以下utf8mb4的默认排序规则可能不一样建表时最好显式写上COLLATEutf8mb4_general_ci避免中文乱码。2.2 初始化数据与自增主键的坑建完表之后你需要插入一些测试数据。很多同学在这里会犯一个错误手动指定id值比如INSERT INTO student (id, sno, sname) VALUES (1, 2021001, 张三)。这样做在单次测试时没问题但如果你后面用AUTO_INCREMENT继续插入或者做数据迁移主键冲突就会找上门。我一般会让学生直接省略id让数据库自己分配INSERT INTO student (sno, sname, password, major, grade) VALUES (2021001, 张三, e10adc3949ba59abbe56e057f20f883e, 计算机科学与技术, 2021), (2021002, 李四, e10adc3949ba59abbe56e057f20f883e, 软件工程, 2021), (2021003, 王五, e10adc3949ba59abbe56e057f20f883e, 网络工程, 2022); INSERT INTO course (cno, cname, credit, capacity, selected_count, teacher_id) VALUES (C001, JavaWeb 开发, 3.0, 60, 0, 1), (C002, 数据库原理, 2.5, 50, 0, 2), (C003, 数据结构, 3.0, 55, 0, 1);密码字段存的是 MD5 值e10adc3949ba59abbe56e057f20f883e对应的是123456。这里不要存明文答辩时老师如果问“为什么密码不存明文”你要能答出“防止数据库泄露后直接暴露用户凭证实际项目中还会加盐”。虽然 MD5 现在也不够安全但作为实训用它来演示“不存明文”这个意识是够的。还有一个容易翻车的点外键约束的顺序。如果你先插sc表数据再插student或course会因为外键找不到引用而报错。正确的顺序永远是先插主表再插关联表。退课的时候如果直接DELETE掉sc记录selected_count不会自动减一这个逻辑要在 Java 代码里手动处理后面会讲。3. 后端分层Servlet DAO Service 的最小可运行结构3.1 项目目录与依赖配置一个能跑起来的 JavaWeb 项目目录结构比代码本身更重要。很多同学把所有类都扔在src根目录下编译能过但答辩时一看结构就扣分。我一般会按下面这样组织src/ main/ java/ com/example/selection/ dao/ -- 数据访问层 service/ -- 业务逻辑层 servlet/ -- 控制层 entity/ -- 实体类 util/ -- 工具类数据库连接等 resources/ db.properties -- 数据库配置 webapp/ WEB-INF/ web.xml -- 部署描述符 login.jsp course_list.jsp依赖方面如果你用 Mavenpom.xml里至少需要javax.servlet-api和mysql-connector-java。注意 Servlet 的版本要和 Tomcat 匹配Tomcat 9 用javax.servletTomcat 10 用jakarta.servlet这个不匹配会导致ClassNotFoundException是新手最常见的翻车点之一。dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependencyscope设为provided是因为 Tomcat 自带 Servlet API打包时不需要重复包含。MySQL 驱动版本要和你的数据库版本对应8.0 以上的驱动类名是com.mysql.cj.jdbc.Driver连接 URL 里要加serverTimezoneAsia/Shanghai否则会报时区错误。3.2 数据库连接工具类与 DAO 的写法数据库连接不要在每个 Servlet 里都写一遍DriverManager.getConnection那样代码重复且难以维护。常见做法是写一个DBUtil类从db.properties读取配置public class DBUtil { private static String url; private static String username; private static String password; static { try (InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(db.properties)) { Properties props new Properties(); props.load(in); url props.getProperty(jdbc.url); username props.getProperty(jdbc.username); password props.getProperty(jdbc.password); Class.forName(props.getProperty(jdbc.driver)); } catch (Exception e) { throw new RuntimeException(数据库配置加载失败, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } }db.properties的内容大致如下注意 URL 里的参数jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/selection_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 jdbc.usernameroot jdbc.passwordyour_passwordDAO 层负责所有 SQL 操作。以选课为例核心方法有两个查询可选课程列表、插入选课记录。插入的时候要同时更新course表的selected_count并且这两步必须在同一个事务里public boolean selectCourse(int studentId, int courseId) { String checkSql SELECT capacity, selected_count FROM course WHERE id ? FOR UPDATE; 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 DBUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement checkStmt conn.prepareStatement(checkSql)) { checkStmt.setInt(1, courseId); ResultSet rs checkStmt.executeQuery(); if (!rs.next()) { conn.rollback(); return false; } int capacity rs.getInt(capacity); int selected rs.getInt(selected_count); if (selected capacity) { conn.rollback(); return false; } } try (PreparedStatement insertStmt conn.prepareStatement(insertSql)) { insertStmt.setInt(1, studentId); insertStmt.setInt(2, courseId); insertStmt.executeUpdate(); } try (PreparedStatement updateStmt conn.prepareStatement(updateSql)) { updateStmt.setInt(1, courseId); updateStmt.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { // 唯一索引冲突会走到这里说明重复选课 return false; } }这段代码里有三个关键点。第一SELECT ... FOR UPDATE在事务中锁住了课程行防止两个学生同时选最后一个名额时超卖。第二conn.setAutoCommit(false)开启了手动事务插入和更新要么都成功要么都回滚。第三捕获SQLException时没有打印堆栈而是直接返回false因为重复选课会触发唯一索引冲突这是预期内的业务异常不需要当成系统错误。参数方面studentId和courseId都从 Session 中获取不要从前端表单传否则学生可以伪造 ID 选别人的课。4. 前端交互与 Session 管理登录、选课、退课的完整链路4.1 登录验证与 Session 存什么登录是选课系统的入口。前端login.jsp提交学号和密码到LoginServlet后端查库比对密码哈希成功后把学生信息写入HttpSessionWebServlet(/login) public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String sno req.getParameter(sno); String password req.getParameter(password); String md5 DigestUtils.md5Hex(password); // 假设用了 commons-codec StudentDao dao new StudentDao(); Student student dao.findBySnoAndPassword(sno, md5); if (student ! null) { req.getSession().setAttribute(student, student); resp.sendRedirect(course_list); } else { req.setAttribute(msg, 学号或密码错误); req.getRequestDispatcher(login.jsp).forward(req, resp); } } }Session 里存的是完整的Student对象而不是只存一个studentId。这样做的好处是后续页面可以直接从 Session 取姓名、专业等信息展示不用每次查库。但要注意Session 默认 30 分钟过期如果学生选课中途去吃了顿饭回来Session 失效点击选课会跳回登录页。这个体验问题可以在前端加一个定时心跳或者把 Session 超时时间调长在web.xml里配置session-config session-timeout60/session-timeout /session-config单位是分钟。实训环境里调到 60 分钟足够生产环境要根据安全要求权衡。4.2 选课列表的渲染与退课逻辑课程列表页面用 JSTL 遍历后端传来的ListCourse每一行显示课程名、学分、已选人数和操作按钮。这里有一个细节如果当前学生已经选了某门课按钮应该显示“退课”而不是“选课”。实现方式是在后端查询时用LEFT JOIN sc判断当前学生是否已选SELECT c.id, c.cno, c.cname, c.credit, c.capacity, c.selected_count, CASE WHEN sc.id IS NULL THEN 0 ELSE 1 END AS selected FROM course c LEFT JOIN sc sc ON c.id sc.course_id AND sc.student_id ? AND sc.status 1 ORDER BY c.cno;这个查询把“是否已选”作为一个字段返回前端根据selected的值决定按钮文案和链接。退课操作不是物理删除sc记录而是把status更新为 0同时course.selected_count减一。这样做的好处是保留选课历史答辩时如果老师问“学生退课后记录还在吗”你可以答“逻辑删除方便统计和审计”。退课的 SQL 同样需要事务public boolean dropCourse(int studentId, int courseId) { String updateSc UPDATE sc SET status 0 WHERE student_id ? AND course_id ? AND status 1; String updateCourse UPDATE course SET selected_count selected_count - 1 WHERE id ? AND selected_count 0; try (Connection conn DBUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps1 conn.prepareStatement(updateSc); PreparedStatement ps2 conn.prepareStatement(updateCourse)) { ps1.setInt(1, studentId); ps1.setInt(2, courseId); int rows ps1.executeUpdate(); if (rows 0) { conn.rollback(); return false; // 没有有效的选课记录 } ps2.setInt(1, courseId); ps2.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { conn.rollback(); return false; } } catch (SQLException e) { return false; } }注意updateCourse里加了AND selected_count 0这是防止并发退课时把人数减成负数。虽然概率低但一旦出现负数答辩演示时被看到就很尴尬。5. 避坑与排查选课系统实训里最容易翻车的 5 个地方5.1 中文乱码从 JSP 到数据库的全链路排查现象登录后页面显示“å¼ ä¸‰”这样的乱码或者选课成功后数据库里的课程名变成问号。原因乱码可能出现在三个环节——JSP 页面编码、Servlet 请求编码、数据库连接编码。任何一个环节没统一成 UTF-8中文就会出问题。解决JSP 文件头部加% page contentTypetext/html;charsetUTF-8 languagejava %Servlet 里在doPost第一行加req.setCharacterEncoding(UTF-8)数据库连接 URL 里加characterEncodingutf8建表时指定CHARSETutf8mb4。四个地方都检查一遍基本能解决 95% 的乱码问题。5.2 选课超卖为什么最后一个名额被两个人同时选到现象课程容量 50已选 49两个学生同时点击选课结果两个人都选上了selected_count变成 51。原因没有加锁或者事务隔离级别不够。两个线程同时执行SELECT查到selected_count49都认为还有名额然后各自执行UPDATE。解决在事务中使用SELECT ... FOR UPDATE锁住课程行或者直接用UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count capacity根据executeUpdate的返回值判断是否选课成功。后者更轻量不需要显式锁。5.3 重复选课唯一索引报错被当成系统异常现象学生快速双击选课按钮第二次请求抛出SQLIntegrityConstraintViolationException页面显示 500 错误。原因sc表上的唯一索引阻止了重复插入但代码没有捕获这个异常直接抛到了容器。解决在 DAO 的selectCourse方法里捕获SQLException判断错误码是否为 1062MySQL 唯一键冲突如果是就返回false前端提示“您已选过这门课”。同时前端按钮加disabled防止双击。5.4 Tomcat 启动报 ClassNotFoundException: com.mysql.cj.jdbc.Driver现象项目在 IDE 里跑得好好的部署到 Tomcat 后启动就报驱动类找不到。原因MySQL 驱动的 jar 包没有放到WEB-INF/lib目录下。IDE 的编译 classpath 和 Tomcat 运行时的 classpath 是两回事。解决确认pom.xml里 MySQL 依赖的scope不是provided然后执行mvn package把生成的 war 包部署到 Tomcat。或者直接在项目结构里把驱动 jar 复制到webapp/WEB-INF/lib下。5.5 Session 失效后选课跳转登录页数据丢失现象学生填了一半的选课信息Session 过期点击提交后跳回登录页之前选的课全没了。原因Session 超时后getAttribute(student)返回nullServlet 没有做空判断直接抛出NullPointerException或者重定向到登录页。解决写一个LoginFilter拦截所有需要登录的请求检查 Session 中是否有student对象。如果没有保存当前请求的 URL 到 Session登录成功后跳回原页面。这样至少不会丢失操作意图。6. 答辩前怎么把代码讲出深度三个能加分的验证技巧答辩的时候老师不会只看你功能跑没跑通更想听你讲清楚“为什么这么做”和“如果出问题你怎么查”。下面三个技巧是我带学生时反复验证过的能让你在答辩现场从“背代码”变成“讲设计”。第一个技巧用EXPLAIN验证你的 SQL 有没有走索引。选课列表的查询涉及course和sc两张表的连接如果sc表的student_id没有索引数据量一大就会全表扫描。你可以在 MySQL 客户端里执行EXPLAIN SELECT c.id, c.cname, c.selected_count FROM course c LEFT JOIN sc ON c.id sc.course_id AND sc.student_id 1001 WHERE c.capacity c.selected_count;看type列是不是ref或eq_ref看key列有没有用到索引。如果type是ALL说明走了全表扫描需要在sc表的student_id上建索引。答辩时把这个执行计划截图放出来比你说十句“我做了优化”都有用。第二个技巧用SHOW ENGINE INNODB STATUS看最近一次死锁信息。如果你在事务里用了FOR UPDATE并发测试时可能会遇到死锁。MySQL 会把最近一次死锁的详细信息记录在 InnoDB 状态里包括两个事务分别持有什么锁、等待什么锁。你不需要完全看懂但能指出“这里显示事务 A 在等课程行的锁事务 B 持有该锁并在等选课记录的锁”就足以证明你理解并发控制。第三个技巧把selected_count和sc表的实际记录数做一次对账查询。系统跑了一段时间后可能因为异常退课或手动改库导致selected_count和实际选课人数不一致。写一个对账 SQLSELECT c.id, c.cname, c.selected_count, (SELECT COUNT(*) FROM sc WHERE sc.course_id c.id AND sc.status 1) AS actual_count FROM course c HAVING c.selected_count actual_count;如果查出来有差异说明你的退课或选课逻辑有漏洞。答辩时主动展示这个对账过程老师会觉得你不仅写了功能还考虑了数据一致性。我自己的习惯是每次改完选课相关的代码都跑一遍这个对账查询确认没有偏差再提交。这个习惯帮我省了很多次“演示时数据对不上”的后悔药。希望帮到你。本文还有配套的精品资源点击获取