简介:这是一份基于Java+Jsp+Mysql实现的高校毕业设计选题系统完整项目,适合计算机专业毕业设计参考、Java Web课程实训及自学练手。系统采用经典MVC分层结构,实现管理员、教师、学生三种角色闭环管理:管理员统一维护学生、教师与课题数据,并进行系统维护;教师可维护个人信息、添加并查看自己的课题;学生可查看全部选题并在线选择,流程覆盖管理员—教师—学生的完整业务链。资源共221个文件,主要包含98个JSP页面、12个Java源码、24个class文件,以及MySQL数据库脚本、XML配置、JAR依赖库、CSS样式、GIF配图和Excel说明文档等,压缩包仅5.57MB,轻量易部署。项目SQL脚本位于sql文件夹,可直接导入数据库,部署门槛低;代码结构清晰,便于按角色模块进行二次开发或用于毕业设计答辩演示。目前已有2416人学习下载,经多轮测试运行无误,可放心参考使用。
1. 用Java+Jsp+Mysql做毕设选题系统:一套能答辩也能上线的技术选型
每年毕业季,网上都会冒出大量“Java+Jsp+Mysql实现Web毕业设计选题系统”的需求:学生要交毕设,老师要管理选题,而传统JSP项目恰好是教学方案里成本最低、最容易把原理讲清的一套组合。它解决的典型问题很具体——学生登录浏览课题、提交选题申请、教师审核、管理员重置流程,业务闭环不复杂,却覆盖了Servlet、Session、JDBC、SQL事务这些Java Web核心考点。适合两类人:一是第一次完整做Web项目的应届生,需要一份可运行、可解释、能接住追问的代码;二是要把老系统快速迁移或重构的工程师。我下面按实际做过的方案展开,从建库、搭框架到打包部署的坑位一次交代清楚。
2. 先建数据库:选题系统的三张表与角色权限模型
2.1 角色模型:学生、教师、管理员在JSP页面里怎么区分
毕业设计选题系统的业务闭环并不复杂。学生登录后浏览课题库,每个课题有容量上限,比如某位教师最多带6个人;学生选定一个课题后进入待审核状态;教师看到自己名下课题的申请记录,选择通过或退回;管理员负责兜底,比如学生双选冲突、误操作选错题时,由管理员手动重置。
这里首先要做一个角色建模决策:是把三类角色拆成三张表,还是放进同一张用户表用role字段区分。拆三张表的好处是字段各自专有——学生有学号,教师有职称、研究方向——但选题系统的业务里,这些扩展字段使用频率极低,拆表反而让登录校验的逻辑变复杂。我一般建议用一个sys_user表加role字段,1代表学生,2代表教师,3代表管理员,通过外键关联课题表和选题记录表。这样设计还有个答辩时的实用好处:画ER图时三张表之间的关系干净,外键、唯一约束都能讲出真实业务依据,不是为建表而建表。
在传统的Java+Jsp+Mysql项目里,Session中存一个User对象,JSP页面通过${sessionScope.loginUser.role}判断当前是什么角色,再决定页面顶部显示哪个导航菜单。不少新手会把角色判断写成字符串“student”“teacher”,这样也能跑,但数据库里存TINYINT更省空间,也更容易在Servlet里做整数分支。接口传参时记得做白名单校验,不能直接把前端传来的role值写进数据库,否则就有了越权创建管理员的洞。
2.2 MySQL表结构设计:从字段到索引一次定义到位
下面这份DDL是整套系统的地基。我把它按“用户、课题、选题记录”三张表拆开,每一处约束都在对应真实业务规则:
CREATE DATABASE IF NOT EXISTS graduation_selection DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE graduation_selection; CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, real_name VARCHAR(50) NOT NULL, student_no VARCHAR(20) NULL, role TINYINT NOT NULL DEFAULT 1 COMMENT '1学生 2教师 3管理员', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINE=InnoDB COMMENT='系统用户表'; CREATE TABLE topic ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, description TEXT, teacher_id INT NOT NULL, max_students INT NOT NULL DEFAULT 1, selected_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT '1开放 0关闭', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_teacher_id (teacher_id) ) ENGINE=InnoDB COMMENT='课题表'; CREATE TABLE selection_record ( id INT PRIMARY KEY AUTO_INCREMENT, topic_id INT NOT NULL, student_id INT NOT NULL, select_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1通过 2退回', teacher_comment VARCHAR(500), UNIQUE KEY uk_topic_student (topic_id, student_id), KEY idx_student_status (student_id, status) ) ENGINE=InnoDB COMMENT='选题记录表';表结构里最容易讲不清的是为什么要在topic表里冗余一个selected_count字段。如果是严格关系型设计,已选人数随时可以用COUNT(*)统计,不用冗余。但毕设系统的课题列表页需要在一次循环里同时展示“剩余名额”和“容量”,如果每次都去count,列表页就要为每个课题发一条子查询,10个课题就是20条SQL,页面延迟立刻上去。用selected_count冗余后,列表页一次查完即可,代价是必须在选题、退选、重置时用事务同步维护这个字段,否则数字会花。
索引方面,除了主键,我还会在selection_record上建(student_id, status)联合索引,因为最频繁的查询是“某学生选过的课题按状态过滤”。topic表的teacher_id也必须有索引,教师登录后第一件事就是查自己发布的课题。这几条索引在几千条数据时看不出差距,但答辩时能展开讲索引选择逻辑,面试官会认同你不是背题的。
提示:建表统一用utf8mb4,不要用utf8。如今JSP页面里的默认表情、部分生僻字都会让utf8插入失败,utf8mb4是MySQL 8的默认方向,早改早省事。
2.3 连接层选型:JDBC裸写还是用连接池
如果只做毕业设计,很多人会在每个Servlet里写Class.forName("com.mysql.cj.jdbc.Driver"),然后每次请求都新建连接。这样不是不能跑,但并发一高就卡,一旦连接没有finally关闭,MySQL的max_connections会被耗尽,整个Web项目彻底假死。责任在代码里,表面症状却是“数据库连不上”。
我一般用Apache DBCP2连接池,理由很朴素:传统JSP项目丢两个jar进WEB-INF/lib就能用,不引入Spring,配置集中在properties文件里,答辩时能把连接池原理讲完整。相比C3P0,DBCP2的配置项更少;相比Druid,没有那种重型监控面板,但一个毕设系统根本用不到慢SQL分析。DataSourceUtil是整套代码里被复用次数最多的类,写成这样:
package com.example.util; import org.apache.commons.dbcp2.BasicDataSource; import java.sql.Connection; import java.sql.SQLException; import java.util.Properties; public class DataSourceUtil { private static final BasicDataSource DATA_SOURCE = new BasicDataSource(); static { try { Properties props = new Properties(); props.load(DataSourceUtil.class.getClassLoader() .getResourceAsStream("db.properties")); DATA_SOURCE.setDriverClassName(props.getProperty("jdbc.driver")); DATA_SOURCE.setUrl(props.getProperty("jdbc.url")); DATA_SOURCE.setUsername(props.getProperty("jdbc.username")); DATA_SOURCE.setPassword(props.getProperty("jdbc.password")); DATA_SOURCE.setInitialSize(5); DATA_SOURCE.setMaxTotal(20); DATA_SOURCE.setMaxIdle(10); DATA_SOURCE.setMinIdle(5); DATA_SOURCE.setMaxWaitMillis(3000); // 每次取连接前先验证,避免MySQL 8小时闲置后连接失效 DATA_SOURCE.setTestOnBorrow(true); DATA_SOURCE.setValidationQuery("SELECT 1"); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DATA_SOURCE.getConnection(); } }db.properties里最关键的是连接串参数:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/graduation_selection?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=你的密码连接串里四个参数缺一不可:characterEncoding=utf8解决中文写入乱码;serverTimezone=Asia/Shanghai解决MySQL 8的时区警告;useSSL=false避免本地开发时SSL握手开销;allowPublicKeyRetrieval=true解决MySQL 8 caching_sha2_password认证下的公钥获取报错。如果用MySQL 8.0以上版本,驱动类名必须写成com.mysql.cj.jdbc.Driver,旧版com.mysql.jdbc.Driver在新驱动里已经不存在了。如果你刚装完MySQL还不会配环境变量,记得先把bin目录加到Path里,否则后续命令行操作全部卡住。
3. 搭工程:idea新建jsp项目到第一行可运行代码的完整链路
3.1 idea新建jsp项目:配置Tomcat与项目目录结构
IntelliJ IDEA里新建JSP项目有两条路线。一条是直接建Java Enterprise项目,勾选Web Application,再配置本机Tomcat;另一条是先建普通Java项目,手动添加Web Facet。新手建议走第一条,IDEA会自动生成webapp/WEB-INF/web.xml,Artifacts默认打出war exploded包,点运行就能启动Tomcat,省去手工配置的挫败感。
如果你习惯用Maven管理依赖,也可以建Maven WebApp骨架,把javax.servlet-api、jstl、mysql-connector-java等依赖写进pom.xml。但纯JSP项目的jar数量很少,不用Maven直接往WEB-INF/lib里丢完全可行,很多教学项目就是这么带课的。用Maven时要注意servlet-api的scope必须是provided,这个问题会在第5章单独展开。
项目结构我习惯按功能分包,Controller、DAO、Entity清清楚楚:
src/main/java/com/example ├── entity/ User, Topic, SelectionRecord ├── dao/ 只写数据库操作 ├── servlet/ 接收HTTP请求,调度业务 ├── filter/ 编码、登录校验 └── util/ DataSourceUtil src/main/webapp ├── login.jsp ├── index.jsp ├── student/ topic_list.jsp, my_selection.jsp ├── teacher/ publish_topic.jsp, audit_list.jsp ├── admin/ reset.jsp ├── css/js/ ├── WEB-INF │ ├── web.xml │ └── lib/ 所有jar这里给新手一个提醒:JSP页面不要全部塞进WEB-INF下。虽然放WEB-INF里更安全、不能直接URL访问,但对毕设项目来说页面跳转链路会变得很绕,学生做到一半容易把自己绕晕。折中做法是敏感页面加登录过滤器保护,不要指望物理路径隐藏来保证安全。JSP本质是服务端渲染模板,只要过滤器和角色校验到位,放哪都不是安全漏洞。
3.2 登录校验过滤器:比在每个Servlet里写Session判断干净得多
传统JSP项目里最容易被忽略但又最影响体验的,是登录态控制。不少同学在每个Servlet里重复写“session.getAttribute为空就重定向”,代码冗余且容易漏。用Filter统一拦截是Servlet规范里最合适的做法,改一个类就能覆盖所有页面。
package com.example.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.*; import java.io.IOException; @WebFilter("/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String uri = req.getRequestURI(); // 登录页、登录接口、静态资源直接放行 if (uri.endsWith("/login.jsp") || uri.endsWith("/LoginServlet") || uri.contains("/css/") || uri.contains("/js/")) { chain.doFilter(req, resp); return; } HttpSession session = req.getSession(false); Object loginUser = session == null ? null : session.getAttribute("loginUser"); if (loginUser == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }逻辑说明:这里没有用@WebFilter(urlPatterns = "/student/*")做细粒度匹配,而是用/*全量拦截后在代码里放行。好处是后续要加“学生角色才能访问/student/、教师角色才能访问/teacher/”的限制,只需在同一过滤器里加分支判断。很多教材喜欢把登录态判断写在Servlet基类里,但对没有继承体系的传统JSP项目,过滤器侵入性更小,加的代码也最少。
参数说明:req.getSession(false)是关键。传false时,如果当前没有Session就直接返回null,不会为未登录用户创建多余对象。传true的话,未登录用户先被建一个空Session再进入判断,虽然不影响安全校验结果,但会无谓增加服务器内存占用。JSP入门阶段的同学看到false往往不理解,这行字可以当面试考点记下来。
3.3 统一编码过滤器:为什么必须在过滤器而不是JSP里写
JSP页面里有人用pageEncoding和contentType同时设置UTF-8,结果POST请求还是乱码。原因是pageEncoding只控制JSP页面输出本身,不控制请求体的解码。浏览器表单提交时按UTF-8编码,容器默认按ISO-8859-1解码,数据一进Servlet就变成乱码,后面存数据库当然也是错的。
我用一个编码过滤器在请求生命周期最早层统一设置:
package com.example.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; @WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); chain.doFilter(request, response); } }这段代码看着简单,但它必须在LoginFilter之前执行,否则先取参数后设置编码就晚了。注解方式下,过滤器的执行顺序按类名字典序,EncodingFilter排在LoginFilter前面,正好符合需求。如果你喜欢保险,直接在web.xml里用filter-mapping显式声明顺序,那是最高优先级,IDE里调整也直观。
提示:IDEA的File Encodings面板里,Global Encoding、Project Encoding、Default encoding for properties files三处都改成UTF-8。这个设置在新建jsp项目时最容易漏,漏了之后乱码问题会反复出现。
4. 实现选题闭环:学生选、教师审、管理员兜底的完整事务
4.1 学生端选题:用FOR UPDATE加事务解决并发超选
学生端核心页面是课题列表,每个课题显示标题、指导教师、剩余名额,点击“选题”后进入选题Servlet。这里最容易被单测蒙蔽的是并发场景:两个学生同时点了最后一个名额,朴素写法是先查询“是否已选”,再插入记录,这个“查-插”的时间差会让两个人同时成功。等老师审核时才发现课题爆了,管理员就得手动删记录,这就是典型的黑匣子事故。
解决方式不需要分布式锁,在单库单表架构下,对课题行加锁就够了。先SELECT ... FOR UPDATE锁定目标行,再判断容量、插入记录、更新已选数。下面代码给出完整流程:
package com.example.servlet; import com.example.util.DataSourceUtil; import com.example.entity.User; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; @WebServlet("/student/ChooseTopicServlet") public class ChooseTopicServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); HttpSession session = request.getSession(false); User loginUser = (User) (session == null ? null : session.getAttribute("loginUser")); if (loginUser == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } int topicId = Integer.parseInt(request.getParameter("topicId")); int studentId = loginUser.getId(); Connection conn = null; try { conn = DataSourceUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 锁定课题行,防止并发超选 String lockSql = "SELECT max_students, selected_count, status FROM topic WHERE id=? FOR UPDATE"; try (PreparedStatement ps = conn.prepareStatement(lockSql)) { ps.setInt(1, topicId); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { conn.rollback(); request.setAttribute("msg", "课题不存在"); request.getRequestDispatcher("/student/topic_list.jsp").forward(request, response); return; } if (rs.getInt("status") != 1) { conn.rollback(); request.setAttribute("msg", "课题未开放选课"); request.getRequestDispatcher("/student/topic_list.jsp").forward(request, response); return; } if (rs.getInt("selected_count") >= rs.getInt("max_students")) { conn.rollback(); request.setAttribute("msg", "名额已满,请选其他题目"); request.getRequestDispatcher("/student/topic_list.jsp").forward(request, response); return; } } } // 2. 插入选题记录,状态默认0(待审核) String insertSql = "INSERT INTO selection_record(topic_id, student_id, select_time, status) " + "VALUES (?, ?, NOW(), 0)"; try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setInt(1, topicId); ps.setInt(2, studentId); ps.executeUpdate(); } // 3. 冗余字段同步+1 String updateSql = "UPDATE topic SET selected_count = selected_count + 1 WHERE id=?"; try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, topicId); ps.executeUpdate(); } conn.commit(); response.sendRedirect(request.getContextPath() + "/student/topic_list.jsp?success=1"); } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (Exception ignore) { } } throw new ServletException("选题失败", e); } finally { if (conn != null) { try { conn.close(); } catch (Exception ignore) { } } } } }逻辑说明:FOR UPDATE是InnoDB的行级悲观锁,锁住的这一行从“查询容量”到“更新已选数”之间,其他事务的FOR UPDATE会等待,从根源上掐掉并发超选。使用这套方案前要确认topic表是InnoDB且id是主键。如果表是MyISAM,行锁会退化成表锁,并发能力大幅下降,整个列表页操作都会被拖慢。
参数说明:setAutoCommit(false)之后每一步都必须严谨。finally里的conn.close()把连接还给连接池,不主动提交的话,事务在归还前就已经通过commit或rollback结束。业务分支里的rollback点在容量已满、课题关闭等场景直接返回页面提示;catch里的rollback则是兜住SQL异常,两条路径分开处理,不会出现“异常后还继续提交”的尴尬。
事务提交后要对密码字段做掩码处理吗?不需要,密码在登录时用MD5加盐存储即可。实体类里的password字段加@JsonIgnore或者置空再放Session,避免JSP页面误展示。
4.2 教师端审核:JSP列表页与审核Servlet
教师登录后默认看到自己发布的课题,每个课题下关联的待审核申请需要一屏展示。这里不需要复杂前端表格组件,一个JSP加JSTL循环就能搞定。JSP里强烈建议用JSTL而不是scriptlet写if/for,页面短得多,也不会满屏<% %>。
教师审核列表片段:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table> <tr> <th>学号</th> <th>姓名</th> <th>课题</th> <th>申请时间</th> <th>操作</th> </tr> <c:forEach var="apply" items="${applyList}"> <tr> <td>${apply.studentNo}</td> <td>${apply.studentName}</td> <td>${apply.topicTitle}</td> <td>${apply.selectTime}</td> <td> <form action="${pageContext.request.contextPath}/teacher/AuditServlet" method="post"> <input type="hidden" name="recordId" value="${apply.id}"/> <input type="hidden" name="action" value="approve"/> <button type="submit">通过</button> </form> <form action="${pageContext.request.contextPath}/teacher/AuditServlet" method="post"> <input type="hidden" name="recordId" value="${apply.id}"/> <input type="hidden" name="action" value="reject"/> <input type="text" name="teacherComment" placeholder="退回原因必填"/> <button type="submit">退回</button> </form> </td> </tr> </c:forEach> </table>教师在看到applyList之前,AuditListServlet要先从selection_record关联topic和sys_user查出数据,封装成一个ApplyView对象,字段包括recordId、studentNo、studentName、topicTitle、selectTime。查询时用PreparedStatement或者MyBatis,不要用字符串拼接SQL,否则一个单引号就能让整个选题表被拖库。
审核Servlet的业务本质是一次状态更新。设计细节在于:通过时selected_count不动,退回时是否把名额减回去?答案是要减,否则学生退回后重新选题,容量计数越走越高。更稳的做法是课题列表页直接用SQL实时统计已通过数量,而不是信任冗余字段。如果坚持用冗余字段,审核Servlet里退回分支必须同步执行“更新记录状态+selected_count减1”两件事,并且放在同一个事务里。管理员的统计报表才能对得上。
4.3 管理员重置与统计报表
管理员模块的核心不是增删改查,而是两件事:重置学生选课状态、查看选题统计。重置的典型场景是学生误选,教师还没审核,管理员直接删除selection_record记录并把topic.selected_count减1。这个操作必须放在事务里,不然删了记录名额没减,或者名额减了记录还在,数据就对不上了。
统计页最常用的一条SQL是:
SELECT t.id, t.title, t.max_students, t.selected_count, COUNT(sr.id) AS approved_count FROM topic t LEFT JOIN selection_record sr ON sr.topic_id = t.id AND sr.status = 1 GROUP BY t.id;这条SQL用LEFT JOIN而不是INNER JOIN,是因为有的课题还没有任何人申请,但仍然要显示在页面上。GROUP BY t.id之后,approved_count统计的是已通过的学生数,selected_count是冗余的申请数,两者一起看就知道哪些课题还剩真实名额。如果两个数字对不上,优先查审核退回操作是否同步更新了计数器。
管理员页面本身不需要太多魔法,一个search.jsp拉出全部用户和课题状态,配合reset按钮即可。这里要注意的是管理员操作必须记录日志。最简单的方式就是在selection_record表加一个operation_log字段,或者在工程里加一张audit_log表。答辩时被问到“误操作怎么恢复”,有日志可查和没有日志是完全不同的回答深度。
5. 避坑清单:传统jsp项目打包war的5个高频翻车点
5.1 Public Key Retrieval is not allowed:MySQL 8驱动联调第一道坎
现象:项目启动后第一次执行SQL,控制台直接抛出java.sql.SQLNonTransientConnectionException: Public Key Retrieval is not allowed,整个选题列表加载不出来。
原因:MySQL 8默认认证插件是caching_sha2_password,在非SSL连接下,客户端需要向服务器请求公钥来完成加密口令交换。驱动为了安全默认禁止自动获取公钥,于是连接建立阶段就失败。
解决:jdbc.url里追加两个参数,组合出现:useSSL=false&allowPublicKeyRetrieval=true。如果你的网络环境要求必须开SSL,那就用useSSL=true并导入服务器证书,但对毕业设计项目来说,关SSL简化开发更务实,生产环境再考虑证书体系。这个参数在db.properties里改了以后,需要重启Tomcat才生效,光点刷新页面没用。
5.2 JSP页面中文全乱码:四个地方必须对齐
现象:浏览器打开页面,中文字符变成问号或乱码,刷新多次时好时坏。
原因:JSP页面本身编码、服务器响应输出编码、请求参数解码编码、数据库连接编码,四个环节只要有一层配置不一致,中文就会在某一环变形。常见情况是JSP文件用UTF-8保存,但连接串里没加characterEncoding,数据库存进去的就是乱码。
解决:第一,JSP头写成<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;第二,IDEA的File Encodings面板三处全部设为UTF-8;第三,用第3.3节的编码过滤器统一设置请求和响应;第四,db.properties连接串加上characterEncoding=utf8。四条同时满足,中文问题基本绝迹。如果还乱,检查MySQL表结构默认字符集,是不是建表时被建成了latin1。
5.3 Tomcat部署后404:欢迎页、上下文路径和war命名
现象:war包放进Tomcat的webapps目录后启动,访问http://localhost:8080/直接404,访问http://localhost:8080/项目名/也404。
原因:一半是web.xml里没配欢迎页,一半是Artifacts输出名导致context path不对。Tomcat启动日志里其实已经打印了部署路径,但很多人不看日志直接猜。
解决:web.xml加上<welcome-file>login.jsp</welcome-file>,确保login.jsp在webapp根目录。部署路径对应的是war包文件名,访问时要带一层目录。为了评委演示方便,我一般把构建产物改名叫ROOT.war,或配置Tomcat的context映射到根路径,这样输入http://localhost:8080/就直接进登录页,少一层目录,演示时出错概率低。
5.4 javax.servlet-api与Tomcat原生类冲突
现象:IDEA里编译正常,部署到Tomcat后启动报NoClassDefFoundError,或提示某个Servlet类被重复加载。
原因:项目把servlet-api.jar打包进了WEB-INF/lib,而Tomcat的lib目录里自带一份同款类。类加载器里出现两份javax.servlet的接口类,加载顺序不同导致版本冲突。Maven项目则是因为scope没有设成provided。
解决:WEB-INF/lib里不要放servlet-api、jsp-api这类容器提供的jar。用Maven时依赖声明写成:
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>provided表示编译期需要、运行期由Tomcat提供,打war时不会打进去。这是“传统jsp项目打包war”最容易踩的依赖坑。
5.5 Tomcat无法Undeploy:连接池线程泄漏导致的假死
现象:在IDEA里点热部署或Tomcat Manager里点Undeploy,应用卡在“正在停止”,日志停在线程等待,最后要杀进程。
原因:DBCP连接池创建的线程是non-daemon,如果应用销毁时没有显式关闭数据源,Tomcat会等这些线程退出,等不到就假死。很多情况下背后是代码里连接没close,连接池的维护线程一直无法回收。
解决:写一个ServletContextListener,在contextDestroyed里显式关闭数据源:
package com.example.listener; import com.example.util.DataSourceUtil; import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener; import javax.servlet.annotation.WebListener; @WebListener public class ContextListener implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { // 可在这里加载全局配置 } @Override public void contextDestroyed(ServletContextEvent sce) { DataSourceUtil.closeDataSource(); } }DataSourceUtil类里加一个closeDataSource方法,调用BasicDataSource的close()即可。这个坑平时不出现,只在反复热部署时给你来一下,解决一次就养成固定习惯:所有Servlet的finally块里必须归还连接,Listener里必须关池子。
提示:出现假死时最快的自救是直接重启Tomcat,然后看catalina.out里最后几十行日志,凡是在“Waiting for n non-daemon threads”附近停了很久的,基本都是连接池泄漏。
6. 进阶:把JSP项目往Spring Boot迁移的三步走
做完这套系统如果准备找工作,那句“Java+Jsp+Mysql”只是第一层脚手架。真正被面试官追问的多半不是JSP标签,而是“怎么防止超选”“事务边界在哪”“SQL怎么优化”。所以我建议的进阶路径是:把连接层换掉、把控制器换掉、把登录校验换掉,但业务逻辑不动,表结构和事务边界全部保留,这样你就有一份能讲出渊源的“改造经历”。
迁移时先做三件事。第一,把topic和selection_record的DDL原封不动搬过去,表名、索引、约束都不用改。第二,把ChooseTopicServlet里的事务逻辑抽到一个@Transactional的Service方法中。这里有个血泪经验:事务注解默认只在方法入口生效,如果类内部用this调用另一个方法,会绕过Spring代理,事务失效,超选问题会像绿皮火车一样直着开回来。所以要单独注入一个Service,让Spring的代理对象去执行方法。第三,页面层可以保留JSP,加上spring-boot-starter-tomcat和jasper依赖就能继续用,也可以改造成Thymeleaf,二者选一个,别同时上。
做完迁移,验证工作我习惯用三张测试单。第一张是功能验证表,覆盖“学生选课题、教师通过、学生重新选、管理员重置”四个主流程,每个流程记录期望结果和实际结果。第二张是并发验证表,用两个浏览器窗口同时登录不同账号,同时点同一个课题的最后一个名额,看是否只有一个成功。第三张是边界验证表,比如教师退回时不填原因是否被拦截、退选后再选统计数是否对得上。这三张表整理一下就能变成答辩时的项目材料,比截图更硬核。
我踩过最不值当的坑,是以为“加了事务就一定安全”。其实如果Service方法里catch了异常没有继续向上抛,@Transactional感知不到失败,照样提交。后来我给自己定了个规矩:手写事务代码时,catch块第一行永远是rollback,先回滚再考虑要不要做补偿。这套Java+Jsp+Mysql的毕设选题系统做完,你对数据一致性的理解会比背八股文扎实得多,希望帮到你。
本文还有配套的精品资源,点击获取