JSP+Servlet学生信息管理系统:MVC请求链路与避坑
简介这是一套面向高校课程设计与Java Web入门者的学生信息管理系统项目资料采用JSPServlet为核心结合Layui、jQuery完成页面交互后端连接MySQL保存数据支持学生自主注册登记、教师集中管理学生信息适合用来练习分层开发、表单提交、会话控制与数据库增删改查也便于理解传统Java Web项目的请求响应流程。资源包共2个文件以1个zip源码压缩包和1个htm说明页为主整体约2.13MB源码包内可查看JSP页面、Servlet处理逻辑、实体与数据库操作代码说明页便于快速了解目录和运行方式。项目基于JDK7、Tomcat8、MySQL与IntelliJ IDEA环境依赖jar已随项目lib目录提供可直接导入调试或在此基础上扩展班级、成绩等模块。目前已有6437人学习适合需要完成课程设计、进行Java Web入门实战或参考Servlet项目结构的学生与开发者对课程设计答辩、项目复现和二次开发均有参考价值。1. 一套能跑通的 JSPServlet 学生信息管理系统适合谁拿来练手有位同学拿着一份课程设计来找我页面能打开、数据也能查出来但答辩老师问了一句“你的请求是从哪进到哪出的”他半天没答上来。问题不在功能而在他全程把 Java 代码和 SQL 写在 JSP 里Servlet 只当了个摆设。这套基于 JSPServlet 的简单学生信息管理系统正好是反面教材的解药它把控制器交给 Servlet把视图留在 JSP把 SQL 统一放在 DAO 层学生信息的新增、删除、修改、查询、模糊搜索和分页都跑在一条清晰的 MVC 链路上数据库用 MySQL容器用 Tomcat没有 Spring 那一堆注解和依赖要啃。适合两类人一是要交课程设计、需要一个完整可演示后台的学生二是写久了框架、想回头把 Java Web 请求链路重新摸一遍的开发者。它的体量刚好一晚上能读完一周能改出自己的东西。2. 工程骨架与技术选型Tomcat 里到底放了哪些东西拿到一份 Java Web 项目先别急着点运行先看目录。Eclipse 时代的动态 Web 工程和 IDEA 的 Maven 工程长得不一样这套源码属于前者也就是srcWebContent那种经典结构。目录看懂了后面 404、ClassNotFound 这类问题基本能自己定位不用靠猜。2.1 目录结构src 放 JavaWebContent 放 JSP标准结构大概是这样我用tree的形式列出来StudentManager/ ├── src/ # Java 源码编译后进 WEB-INF/classes │ └── com/example/sms/ │ ├── bean/ # 实体类和表一一对应 │ │ ├── Student.java │ │ └── Admin.java │ ├── dao/ # 数据访问层SQL 只在这层出现 │ │ ├── StudentDao.java │ │ └── AdminDao.java │ ├── servlet/ # 控制器接收请求、调 DAO、转发 │ │ ├── StudentListServlet.java │ │ ├── StudentAddServlet.java │ │ ├── StudentUpdateServlet.java │ │ ├── StudentDeleteServlet.java │ │ └── LoginServlet.java │ ├── filter/ # 编码过滤器、登录拦截器 │ │ ├── EncodingFilter.java │ │ └── LoginFilter.java │ └── util/ # JDBC 工具类 │ └── DBUtil.java ├── WebContent/ # Web 资源根目录 │ ├── WEB-INF/ │ │ ├── web.xml # Servlet/Filter 映射或改用注解 │ │ └── lib/ # 第三方 jar必须放这里 │ │ ├── mysql-connector-java-5.1.47.jar │ │ ├── jstl.jar │ │ └── standard.jar │ ├── jsp/ # 视图页面 │ │ ├── login.jsp │ │ ├── student_list.jsp │ │ ├── student_add.jsp │ │ └── student_edit.jsp │ ├── css/ │ └── index.jsp # 入口一般重定向到列表或登录 └── .classpath / .project # Eclipse 工程描述文件src下的包按 bean、dao、servlet、filter、util 分这是 Java Web 最朴素的分层没有任何框架魔法。WebContent是浏览器能直接访问的根目录但它下面的WEB-INF是个禁区浏览器访问不到所以web.xml和lib放在这里是安全的。很多人把mysql-connector的 jar 拖到项目根目录或者WebContent外面编译期一切正常一启动就ClassNotFoundException血泪经验就来自这里——jar 必须落在WEB-INF/lib下Tomcat 才会把它加进 Web 应用的类加载器。提示如果用 IDEA目录名可能是web而不是WebContent含义一样都是 Web 资源根。配置 Artifact 时记得把WEB-INF/lib一起打进 war。2.2 技术选型为什么是 JSPServlet 而不是框架这套系统里Servlet 承担的是 DispatcherServlet 的角色web.xml或WebServlet承担的是 HandlerMapping 的角色DAO 承担的是 Mapper 的角色。你现在用 Spring MVC 写的东西它一样不少只是全部手写。选它的理由很实际第一依赖极少。一个 MySQL 驱动、两个 JSTL 的 jar加上 Servlet APITomcat 自带就能跑。没有 Maven 仓库连不上、依赖冲突、版本不兼容的折磨对于要在机房电脑或者别人的笔记本上现场演示的课程设计来说这一点比什么都重要。第二请求链路是可见的。请求从http://localhost:8080/StudentManager/student/list进来容器根据web.xml里的url-pattern找到对应的 Servlet 类调service()再分发到doGet()你在 IDE 里打断点能一路跟到底。框架帮你藏起来的东西这里全是明牌适合把 Java Web 的基础打牢。第三改起来没有心理负担。加一个字段改实体类、改建表语句、改 JSP、改 DAO 里两条 SQL四处就完事不用去翻注解、配置类和自动装配。当然代价也明显业务逻辑会和 Servlet 混在一起事务要靠手动提交代码量随功能线性膨胀。所以我在第 6 章会讲怎么往 Service 层和连接池上改那是这套代码真正能“长大”的地方。数据库这边是 MySQL字符集建议直接utf8mb4连接串里显式带上characterEncodingutf8否则中文姓名、家庭住址这类字段很可能在插入时变成问号。JDK 用 8 就够Tomcat 8.5 或 9 都行但要注意 Tomcat 10 之后包名从javax.servlet变成了jakarta.servlet源码里所有import javax.servlet.*都会编译不过这是一个很常见的翻车点。2.3 环境准备与第一次跑起来按顺序做不要跳步装 JDK 8配置JAVA_HOME命令行敲java -version确认。装 MySQL 5.7 或 8.0建库student_manager字符集选utf8mb4。把项目里的建表 SQL 导进去下一章会给完整语句。打开DBUtil.java改数据库地址、用户名、密码这是唯一需要改配置的地方。Eclipse 导入 Existing Projects into Workspace或者 IDEA 用File → Open打开配置好 Tomcat 运行时。确认WEB-INF/lib下的三个 jar 都在右键项目Build Path里没有红色报错。部署到 Tomcat启动浏览器访问http://localhost:8080/StudentManager/。启动之后第一件事不是点功能是看控制台。Tomcat 的日志里如果出现ClassNotFoundException、No suitable driver、Unknown database基本就是驱动、连接串、库名三类问题第 5 章会逐条拆。日志干净了页面能出来这套系统最难的坎就过去了。3. 数据库与 DAO 层五张表撑起的增删改查一个学生信息管理系统听起来简单但真要做完整至少得有三类数据登录账号、学生档案、以及可选的班级和成绩。这套源码常见做法是四到五张表admin存管理员账号student存学生档案class_info存班级score选修课成绩再加一张major或者把专业直接冗余在student表里。表不多但每一张都对应一个明确的 DAO 方法这就是它能被当作教学样本的原因。3.1 表结构设计与建表 SQL先把库和表建好。下面这份 SQL 是我按这套系统的字段习惯补出来的字段名和后续 Java 代码一一对应-- 创建数据库字符集用 utf8mb4避免生僻字和 emoji 存不进去 CREATE DATABASE IF NOT EXISTS student_manager DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE student_manager; -- 管理员表登录用密码字段在原始版本里多为明文 CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(32) ); -- 学生表核心表学号唯一班级用外键或冗余字段 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(32) NOT NULL COMMENT 姓名, gender CHAR(2) DEFAULT 男 COMMENT 性别, age INT DEFAULT 18, birthday DATE, phone VARCHAR(20), address VARCHAR(200), class_id INT COMMENT 所属班级, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 班级表下拉框数据来源 CREATE TABLE class_info ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 班级名称, major VARCHAR(50) COMMENT 专业 ); -- 初始化一条管理员和一个班方便第一次登录 INSERT INTO admin(username, password, real_name) VALUES (admin, 123456, 管理员); INSERT INTO class_info(name, major) VALUES (软件一班, 软件工程);这段 SQL 有三个地方值得说。sno加了UNIQUE因为学号重复是学生管理里最典型的脏数据来源靠程序判重不如让数据库兜底。class_id用的是INT而不是班级名字符串后续做“按班级筛选”时只需要传一个 id查询效率和不一致风险都好控制。create_time给了DEFAULT CURRENT_TIMESTAMP插入时不用手动 set少一个字段就少一个 NPE 的机会。注意sno和name这种字段如果用了utf8mb4在低版本 MySQL 上联合索引长度可能超限课程设计规模没问题真要上量就把学号独立索引。3.2 JDBC 工具类连接、关闭、异常所有 SQL 最终都从DBUtil拿连接这个类写得好不好直接决定后面会不会出现“跑两天连接耗尽”。原始版本常见写法是每次DriverManager.getConnection()新建连接用完close()小并发下没毛病但要知道它的边界在哪里。package com.example.sms.util; import java.sql.*; public class DBUtil { // 8.0 驱动类名是 com.mysql.cj.jdbc.Driver5.x 才是 com.mysql.jdbc.Driver private static final String DRIVER com.mysql.cj.jdbc.Driver; private static final String URL jdbc:mysql://localhost:3306/student_manager ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD root; static { try { // 静态块只加载一次驱动避免每条请求都反射 Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL 驱动未找到检查 WEB-INF/lib: e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } // 统一关闭顺序必须是 ResultSet - Statement - Connection public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt ! null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } } }URL里那串参数每一个都有原因useUnicodecharacterEncoding决定中文能不能正确传输useSSLfalse是为了避免 8.0 驱动默认要求 SSL 时控制台刷警告serverTimezoneAsia/Shanghai则是 8.0 驱动不认系统时区、插入时间差 8 小时的标准解法。close方法把三个资源包在一起调用方只要把引用传进来哪怕某一步为 null 也不会炸这是比每处try-finally更省心的写法。3.3 StudentDao把 SQL 收口到一层DAO 层的价值在于全项目只有这里出现SELECT、INSERT。列表查询要分页我把findByPage和count拆成两个方法因为分页组件需要先知道总数才能算页数package com.example.sms.dao; import com.example.sms.bean.Student; import com.example.sms.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class StudentDao { /** 按关键字模糊查询分页返回。keyword 为空时查全部 */ public ListStudent findByPage(String keyword, int page, int pageSize) { ListStudent list new ArrayList(); // LIMIT 的两个占位符分别是 offset 和 sizeMath.max 防止负数页码 String sql SELECT s.*, c.name AS class_name FROM student s LEFT JOIN class_info c ON s.class_id c.id WHERE s.name LIKE ? OR s.sno LIKE ? ORDER BY s.id DESC LIMIT ?, ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { String like % (keyword null ? : keyword.trim()) %; ps.setString(1, like); ps.setString(2, like); ps.setInt(3, Math.max(0, (page - 1)) * pageSize); ps.setInt(4, pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); } } } catch (SQLException e) { throw new RuntimeException(查询学生列表失败, e); } return list; } /** 与 findByPage 使用同一套 WHERE 条件保证总数和列表对得上 */ public int count(String keyword) { String sql SELECT COUNT(*) FROM student WHERE name LIKE ? OR sno LIKE ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { String like % (keyword null ? : keyword.trim()) %; ps.setString(1, like); ps.setString(2, like); try (ResultSet rs ps.executeQuery()) { return rs.next() ? rs.getInt(1) : 0; } } catch (SQLException e) { throw new RuntimeException(统计学生数量失败, e); } } public int insert(Student s) { String sql INSERT INTO student(sno,name,gender,age,birthday,phone,address,class_id) VALUES(?,?,?,?,?,?,?,?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, s.getSno()); ps.setString(2, s.getName()); ps.setString(3, s.getGender()); ps.setInt(4, s.getAge() null ? 18 : s.getAge()); // java.util.Date 转 java.sql.Date这是初学者最容易漏的一步 ps.setDate(5, s.getBirthday() null ? null : new java.sql.Date(s.getBirthday().getTime())); ps.setString(6, s.getPhone()); ps.setString(7, s.getAddress()); ps.setInt(8, s.getClassId() null ? 0 : s.getClassId()); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(新增学生失败, e); } } public int deleteById(int id) { String sql DELETE FROM student WHERE id ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(删除学生失败, e); } } /** 把结果集一行映射成实体新增字段只改这一处 */ private Student mapRow(ResultSet rs) throws SQLException { Student s new Student(); s.setId(rs.getInt(id)); s.setSno(rs.getString(sno)); s.setName(rs.getString(name)); s.setGender(rs.getString(gender)); s.setAge(rs.getInt(age)); s.setBirthday(rs.getDate(birthday)); s.setPhone(rs.getString(phone)); s.setAddress(rs.getString(address)); s.setClassId(rs.getInt(class_id)); s.setClassName(rs.getString(class_name)); return s; } }findByPage里用了LEFT JOIN是为了列表页能直接显示班级名而不是一个数字 id避免 JSP 里再发一次查询。PreparedStatement的?占位符不只是拼串方便更重要的是防 SQL 注入——搜索框里输入 OR 11这类内容用字符串拼接的版本会把整张表捞出来用占位符则只会当成普通文本匹配。mapRow单独抽出来是因为增删改查之外后面加“按 id 查询”时还能复用。insert里那个java.util.Date到java.sql.Date的转换是birthday字段为DATE类型时必须做的适配不做的话编译期就过不去。提示DAO 里抛RuntimeException是为了让 Servlet 层能统一接住并跳错误页真实项目里更推荐自定义DaoException这里保持简单。4. Servlet 与 JSP 的配合一次「查询学生列表」请求的完整链路功能里最能体现 MVC 的就是列表页地址栏带page和keyword两个参数Servlet 接住、调 DAO、把结果塞进 request 域再 forward 给 JSP 渲染。这条链路走通了增删改只是换个方法名的事。下面按请求进入的顺序拆。4.1 请求进来Servlet 的 doGet 与参数接收列表请求是 GET参数从request.getParameter()拿。有两个坑要提前处理页码可能是空串关键字可能带空格任何一个没判空都会在 DAO 里变成NumberFormatException或者查出奇怪的结果。package com.example.sms.servlet; import com.example.sms.bean.Student; import com.example.sms.dao.StudentDao; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; import java.util.List; // 注解方式映射等价于 web.xml 里的 servlet servlet-mapping WebServlet(/student/list) public class StudentListServlet extends HttpServlet { private final StudentDao studentDao new StudentDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 取参数给默认值杜绝 null 往下传 String keyword req.getParameter(keyword); String pageStr req.getParameter(page); int page 1; try { page Integer.parseInt(pageStr); } catch (NumberFormatException ignored) { // 参数缺失或非法时回落到第一页不抛异常给用户看 } int pageSize 8; // 每页条数可按需调整 // 2. 调 DAO先总数后列表 int total studentDao.count(keyword); int totalPages (total pageSize - 1) / pageSize; // 向上取整 if (page totalPages totalPages 0) page totalPages; ListStudent students studentDao.findByPage(keyword, page, pageSize); // 3. 放进 request 域供 JSP 用 EL 取 req.setAttribute(students, students); req.setAttribute(page, page); req.setAttribute(totalPages, totalPages); req.setAttribute(total, total); req.setAttribute(keyword, keyword null ? : keyword); // 4. 转发不是重定向地址栏不变request 域还在 req.getRequestDispatcher(/jsp/student_list.jsp).forward(req, resp); } }totalPages那个(total pageSize - 1) / pageSize是整数向上取整的老写法比Math.ceil少一次浮点转换。page totalPages的判断是为了处理“删完最后一页数据后页码越界”的情况不加的话列表空白但分页条还显示在第 5 页用户会以为数据丢了。forward和redirect的区别在这里很关键转发是服务器内部跳浏览器地址栏还是/student/list?page2刷新不会重复提交如果用sendRedirectrequest 域里的students会全部丢失页面直接空掉。这个点我在代码评审里见过太多人写反。4.2 分页与模糊搜索的参数怎么联动分页条上的每个链接都得把keyword带上否则点第二页搜索条件就丢了。JSP 里拼 URL 时容易漏转义keyword里有中文会被浏览器编码成%E5%BC%A0这没问题但如果含或#就会截断。稳妥做法是先URLEncoder.encode一次再拼或者在 Servlet 里统一处理。分页本身有两个参数值得调pageSize写死 8 是课程设计的习惯实际项目一般 10 或 20并且做成配置项page的边界处理除了上面说的上限还要防负数DAO 里Math.max(0, page-1) * pageSize兜的就是这个。搜索这里用的是LIKE %关键字%前置通配符会让索引失效数据量上万以后会明显变慢改造时换成name LIKE 关键字%或者上全文索引第 6 章会提。4.3 转发到 JSPrequest 域与 JSTL 渲染JSP 只负责展示一行 Java 脚本都不该有。用 JSTL 的c:forEach遍历c:if控制空数据提示fmt:formatDate格式化生日% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % html headtitle学生列表/title/head body form action${pageContext.request.contextPath}/student/list methodget 姓名/学号input typetext namekeyword value${keyword} button typesubmit搜索/button /form table border1 cellspacing0 trth学号/thth姓名/thth性别/thth年龄/th th生日/thth班级/thth操作/th/tr c:forEach items${students} vars tr td${s.sno}/td td${s.name}/td td${s.gender}/td td${s.age}/td %-- birthday 是 java.util.Date用 fmt 标签格式化避免打印出 GMT 字符串 --% tdfmt:formatDate value${s.birthday} patternyyyy-MM-dd//td td${s.className}/td td a href${pageContext.request.contextPath}/student/edit?id${s.id}编辑/a a href${pageContext.request.contextPath}/student/delete?id${s.id} onclickreturn confirm(确认删除)删除/a /td /tr /c:forEach /table %-- 空结果提示比一张空表友好 --% c:if test${empty students} p没有匹配的学生记录/p /c:if p共 ${total} 条第 ${page} / ${totalPages} 页/p /body /html${pageContext.request.contextPath}拿的是项目上下文路径/StudentManager所有链接都拼上它项目改名或者部署到根路径都不会 404。fmt:formatDate必须用直接${s.birthday}会输出Mon Jan 01 00:00:00 CST 2001这种字符串用户看不懂。删除链接上的confirm是一层最廉价的防误触真项目里还得配权限校验防止有人直接构造 URL 删数据。注意如果页面出现${s.name}原样输出八成是 JSTL 的 jar 没放全。jstl.jar和standard.jar两个都要缺一个 EL 就不解析。4.4 web.xml 映射与注解两种写法这套源码大概率两套都用得上Servlet 用WebServletFilter 因为执行顺序需要常写在web.xml里。两种方式并存时注意别重复注册同一个类既加注解又在web.xml里配一遍启动会报IllegalArgumentException。?xml version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee version3.1 !-- 编码过滤器放最前面保证后续所有 request 都是 UTF-8 -- filter filter-nameencodingFilter/filter-name filter-classcom.example.sms.filter.EncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping !-- 登录拦截放行登录页和登录请求 -- filter filter-nameloginFilter/filter-name filter-classcom.example.sms.filter.LoginFilter/filter-class /filter filter-mapping filter-nameloginFilter/filter-name url-pattern/student/*/url-pattern /filter-mapping welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list /web-app过滤器顺序按filter-mapping在web.xml里出现的先后执行所以编码过滤器必须排在登录拦截前面否则拦截器里读中文参数会先乱码。LoginFilter里判断session.getAttribute(admin)是否为空为空就sendRedirect到登录页这是这套系统唯一的权限控制。它很薄但足够挡住“不登录直接敲 URL 进列表页”的情况。5. 避坑与排查部署、乱码、连不上库的高频翻车现场这套系统的功能不复杂真正花时间的往往是环境和配置。下面五条是我在帮人看这类项目时出现频率最高的按“现象 → 原因 → 解决”记下来下次遇到直接对号入座。5.1 启动就 404路径、映射、上下文三选一现象Tomcat 起来了访问http://localhost:8080/StudentManager/student/list返回 404控制台没有任何异常。原因三种可能。一是WebServlet(/student/list)写成了/studentList注解值和访问路径不一致二是web.xml里配了url-pattern/student/list/url-pattern但类上也有注解容器只认其中一份三是访问时漏了项目名直接敲localhost:8080/student/list。解决先在 Tomcat 日志里搜Mapping关键字看容器实际注册了哪些路径再把项目Deployment Assembly或 IDEA 的 Artifact 里上下文路径确认成/StudentManager最后统一映射方式要么全注解要么全 XML别混着来。5.2 中文变问号四个地方要一起改现象新增学生时姓名输入“张三”列表页显示???或者搜索中文查不出结果。原因编码链路有四处断点——JSP 页面pageEncoding、request 的setCharacterEncoding、数据库连接串的characterEncoding、以及数据库和表的字符集。任何一处是 ISO-8859-1整条链就废了。解决JSP 顶部写% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 %在EncodingFilter里统一request.setCharacterEncoding(UTF-8)和response.setContentType(text/html;charsetUTF-8)连接串加useUnicodetruecharacterEncodingutf8建库建表用utf8mb4。如果是 GET 请求中文乱码还得在 Tomcat 的server.xml里给 Connector 加URIEncodingUTF-8Tomcat 8 以后默认就是 UTF-8老版本才需要。5.3 ClassNotFoundExceptionjar 放错地方了现象启动或第一次查询时报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因驱动 jar 只加进了 IDE 的编译路径Build Path没放进WEB-INF/lib。编译期能过运行时 Tomcat 的 Web 类加载器找不到它。解决把mysql-connector-java-*.jar复制一份到WebContent/WEB-INF/lib下重新部署。顺便检查驱动类名MySQL 5.x 是com.mysql.jdbc.Driver8.x 是com.mysql.cj.jdbc.Driver版本和类名对不上一样报这个错。5.4 连接失败时区、SSL、库名三连现象Communications link failure或者The server time zone value ... is unrecognized。原因MySQL 8.0 驱动默认要求服务器时区可识别系统时区是CST时会解析失败另外 8.0 默认尝试 SSL本地没配证书就会警告甚至拒绝连接。解决连接串补上serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue。如果报Unknown database student_manager那就是库没建或者名字拼错回去执行第 3 章的建库语句。5.5 连接不关跑一会儿就卡死现象功能刚启动正常点几十次之后页面一直转圈最后超时重启 Tomcat 又好了。原因某个 DAO 方法里Connection、PreparedStatement、ResultSet有分支没走到close连接池或者 MySQL 的max_connections被慢慢占满。异常分支里最容易漏。解决统一用 try-with-resources像第 3 章StudentDao那样把资源声明在try (...)里异常时也会自动关闭。临时排查可以在 MySQL 里执行SHOW PROCESSLIST看到大量Sleep状态的连接就是泄漏的铁证。6. 二次开发把这套课程设计改造成能交付的后台这套代码能跑但直接拿去接活会很难受。我最先动的是分层把 Servlet 里的参数校验、业务判断挪进StudentServiceServlet 只留“收参数、调 Service、转发”三件事。这一步做完同一个业务逻辑在列表、新增、修改三个入口就不用抄三遍改规则只改一处。第二步换连接池。DriverManager每次新建 TCP 连接在几十并发下延迟很明显。加一个 HikariCP 或者 Druid 的 jar把DBUtil.getConnection()改成从DataSource取代码里其他一行不用动// HikariCP 初始化放在静态块里全应用共用一个池 private static final HikariDataSource DS; static { HikariConfig cfg new HikariConfig(); cfg.setJdbcUrl(URL); cfg.setUsername(USER); cfg.setPassword(PASSWORD); cfg.setMaximumPoolSize(10); // 按 Tomcat 线程数和 DB 上限调 cfg.setConnectionTimeout(3000); // 拿不到连接 3 秒就失败别让用户干等 DS new HikariDataSource(cfg); } public static Connection getConnection() throws SQLException { return DS.getConnection(); // 调用方 close() 时归还到池不是真关 }第三步是安全。admin表里明文存123456这在任何交付场景都说不过去用 BCrypt 在注册/改密时哈希登录时BCrypt.checkpw()比对改动量不到二十行。同时把LoginFilter的放行名单显式列出来别用“只要不是登录页就拦”这种反向逻辑后期加静态资源会误伤。第四步才是界面。JSP 里那套border1的表格换成 Bootstrap 的table table-hover分页条换成组件前端观感立刻不一样但底层请求链路没变风险可控。改完按这个顺序验证先跑一遍增删改查确认数据对再用两个浏览器同时登录确认 session 互不干扰最后用ab或 JMeter 压五十个并发看连接池会不会被打满。改造项改动位置风险验证方式抽 Service 层servlet 包 → 新增 service 包低三个入口功能回归引入连接池DBUtil中并发 50 次查询无超时密码哈希AdminDao、LoginServlet中老账号需重置密码分页组件化student_list.jsp低首页/末页/越界页有次我图省事改完连接池没压测就交了结果对方导入了两万条学生数据列表页一开就Connection is not available查了半天才发现池大小设成了 2。从那以后我每次动数据访问层都强制先跑一遍“翻到最后一页 连续搜索十个关键词”的流程再提交。这套 JSPServlet 的底子不新但把这几处磨平它完全能撑起一个内部使用的学生档案后台希望帮到你。本文还有配套的精品资源点击获取