
简介这是一份基于JavaServletJDBCJSPMySQL实现的Web学生信息管理系统源码面向正在学习Java Web开发的学生也适合用于课程设计或毕业设计场景。项目完整覆盖从前端JSP页面到后端Servlet控制层、JDBC数据访问层及MySQL数据库存储的整套流程并采用MVC分层思想组织代码结构清晰便于理解请求处理、数据库增删改查与动态页面渲染的核心机制为零基础入门者降低了学习门槛。压缩包共78个文件包含12个Java源码、20个JSP页面、24个class编译文件及10个XML配置等另附SQL建表脚本和图片资源整体体积仅4.48MB轻量易用。资源已有6944人学习下载。通过该资源可快速搭建学生信息管理应用掌握ServletJDBCJSP协同开发方法适合参考实现登录、查询、添加、删除、修改等常见功能并可直接用于课程报告或项目答辩的场景。1. 凭什么2025年还要写ServletJdbc的Web学生信息管理系统很多人觉得ServletJsp这套组合已经是上个时代的课程设计但我一直认为它是把Web开发底裤翻出来给人看的最佳样本。Spring Boot把Tomcat内嵌了、连接池自动配置了、ORM帮你绑定好了结果很多人连一次HTTP请求从浏览器到数据库再回到页面的完整路径都讲不清楚。这个学生信息管理系统强制你手动处理Servlet映射、Jdbc连接、Jsp渲染做完之后你再看Spring Boot那些注解背后的黑匣子会突然变得透明。这个项目解决的是两件事一是帮你建立Web应用的最小认知闭环——请求怎么进来、数据怎么存取、页面怎么出去二是给你一份能跑、能改、能拿去答辩的完整代码骨架。适合刚学完Java基础想找第一个Web项目练手的人也适合被Spring Boot搞晕、想回头补底层的人。我下面写的内容就是按我当年做这个系统时踩过的坑整理出来的落地路径照着做基本能通。2. 把环境配到能跑通第一个ServletJDK、MySQL、Tomcat、IDEA的配置路径2.1 没写版本就装环境是第一个深坑JDK/Tomcat/MySQL怎么搭配ServletJdbc这套技术栈对环境版本非常敏感尤其是Tomcat。我见过太多人装了Tomcat 10然后发现代码里import javax.servlet报红因为Tomcat 10把所有包名从javax改成了jakarta。所以第一步先把版本锚定后面所有代码才能对上号。我一般推荐下面这套组合组件建议版本说明JDK8 或 11覆盖面最广Tomcat 9完全兼容Tomcat9.x使用javax.servlet包名网上绝大多数教程都基于它MySQL5.7 或 8.0下文代码按8.0写5.7只需改驱动和URLIDEIDEA 2020以上Ultimate版自带Tomcat集成Community版需要手动配置Maven3.6用来管理依赖避免手动拷jar包注意Tomcat 9对应Servlet 4.0规范所以pom里servlet-api要用4.0.1版本别随手写个最新的5.x那个是给Tomcat 10用的。MySQL方面8.0和5.7最大的差别在驱动类名和URL参数这个我在避坑章节专门展开。安装顺序也有讲究先JDK配置JAVA_HOME和PATH命令行执行java -version确认再装MySQL安装时记住root密码最后解压Tomcat。IDEA建议用Ultimate版因为社区版没有内置Tomcat运行配置新手手动配置很容易卡在deployment环节。2.2 Maven建工程与依赖导入servlet-api为什么scope是provided我习惯用Maven的webapp骨架创建项目这样目录结构、依赖管理和打包都省心。IDEA里新建项目时选择Maven Archetype然后填上maven-archetype-webapp作为骨架。创建完你看到的目录是这样的student-web/ ├── pom.xml └── src/ └── main/ ├── java/ # 源码目录存放Servlet、DAO、实体类 ├── resources/ # 配置文件目录放db.properties └── webapp/ # Web根目录 ├── WEB-INF/ │ └── web.xml └── index.jsp注意WEB-INF目录很关键放在里面的JSP页面外部不能直接访问必须通过Servlet转发这正好符合我们这个系统的安全需要。pom.xml里的依赖要精确控制版本我直接把可用的版本列出来dependencies !-- Servlet APITomcat自带所以scope用provided -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- JSP API同样由Tomcat提供 -- dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency !-- JSTL标签库用来在JSP里做循环和判断 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency !-- MySQL驱动8.0对应这个坐标 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependencies这里有三个容易理解错的地方。第一个servlet-api的scope必须是provided因为Tomcat里已经带了这套jar如果你打包进war反而会和Tomcat自带的冲突启动时各种NoSuchMethodError。第二个mysql驱动的坐标在8.0以后改过老教程里那种com.mysql:mysql-connector-j的坐标也能用但8.0.33这个坐标最稳。第三个JSTL依赖不加的话JSP里写c:forEach会报错说找不到TLD描述符。依赖确认没问题后先跑一次mvn package看看能不能编译通过这一步能提前暴露JDK版本和Maven配置的问题不用等部署到Tomcat再排查。2.3 第一个Servlet跑通链路Tomcat配置、部署和验证环境搭配好了先写一个最简单的Servlet把整条链路跑通再开始写业务代码。这一步的目标是让浏览器发请求、Servlet收到并返回响应、Tomcat日志无异常。先创建一个类package com.student.servlet; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; WebServlet(/hello) public class HelloServlet extends HttpServlet { Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.setContentType(text/html;charsetUTF-8); response.getWriter().write(h2Servlet链路已打通/h2); } }代码里的WebServlet是Servlet 3.0的注解映射方式用这个就不用在web.xml里写servlet配置了。URL是/hello方法里固定输出的是一段HTML。注意一定要显式设置ContentType并带上charsetUTF-8否则浏览器遇到中文会乱码。然后配置Tomcat运行环境IDEA右上角打开Run/Debug Configurations新建一个Tomcat Server LocalServer标签页里选本地的Tomcat目录Deployment标签页点加号选择Artifact选带war exploded的那个Application context填/student。这里我强烈建议用war exploded而不是war包因为前者支持JSP修改后热更新不用每次改页面都重启Tomcat。启动成功后浏览器访问http://localhost:8080/student/hello看到“Servlet链路已打通”就说明JDK、Tomcat、IDEA、Maven这条链完全通了。如果报404先看Tomcat启动日志里有没有报错再看IDEA控制台有没有Artifact部署的日志。接下来做数据库部分把数据层接进来。3. 数据库设计与Jdbc访问层学生表、连接管理、DAO实现3.1 t_student表设计字段命名、类型选择和两条约束学生信息管理系统的核心数据就是学生记录字段不需要多但设计时要把类型卡准。学号和电话这类字段看起来很短的字符串你能直接想到用int存或varchar(11)存但实际上学号可能带字母前缀电话也可能有座机号所以统一用varchar最稳。建表SQL如下CREATE DATABASE IF NOT EXISTS student_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE student_db; CREATE TABLE t_student ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 主键自增, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(50) NOT NULL COMMENT 姓名, gender CHAR(2) DEFAULT 男 COMMENT 性别, age INT DEFAULT 0 COMMENT 年龄, class_name VARCHAR(50) DEFAULT COMMENT 班级, phone VARCHAR(20) DEFAULT COMMENT 联系电话, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生信息表;建表有两点值得单独说明。第一数据库和表都指定utf8mb4而不是utf8因为utf8mb4才是真正的四字节UTF-8能存emoji和生僻字Jdbc连接串里也要对应。第二student_no加了UNIQUE唯一约束目的是防止重复学号这个约束在DAO层写代码时可以作为DuplicateKeyException捕获给前端返回友好提示。gender用CHAR(2)而不是TINYINT是因为直接存“男/女”对JSP渲染最方便不用再写一层字典转换。age用INT就够不用TINYINT去省那一字节。create_time给个默认值CURRENT_TIMESTAMP这样插入时不用手动set当前时间。3.2 JdbcUtil连接工具类配置分离和资源关闭的固定写法每做一次数据库操作都要获取Connection、释放资源这块代码必须抽出来统一写。我见过有人每个DAO方法里都调Class.forName注册驱动重复注册其实没什么问题但显得乱。正规做法是用一个静态工具类类加载时读配置、注册驱动后续所有DAO共用一个入口。先写配置文件jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/student_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrue jdbc.usernameroot jdbc.password你的密码这个properties文件放在src/main/resources下Maven构建时会自动复制到classpath根目录。URL里的useSSLfalse是关闭SSL握手本地开发不关会报警告serverTimezoneAsia/Shanghai是告诉驱动用中国时区不设的话MySQL 8.0会直接报CST时区错误characterEncodingutf8让中文在传输过程中不走样。然后写JdbcUtil工具类package com.student.util; import java.io.InputStream; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.Properties; public class JdbcUtil { private static String url; private static String username; private static String password; static { try (InputStream in JdbcUtil.class.getClassLoader().getResourceAsStream(db.properties)) { Properties props new Properties(); props.load(in); Class.forName(props.getProperty(jdbc.driver)); url props.getProperty(jdbc.url); username props.getProperty(jdbc.username); password props.getProperty(jdbc.password); } catch (Exception e) { throw new ExceptionInInitializerError(db.properties读取或驱动加载失败: e.getMessage()); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } public static void close(ResultSet rs, Statement stmt, Connection conn) { 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) {} } } }这段代码的关键在于静态代码块只执行一次避免每次连接都重新读取配置。close方法的参数顺序固定为ResultSet、Statement、Connection释放顺序务必和资源创建顺序相反否则MySQL连接可能被提前归还导致后续查询报错。JDK 8以后Connection是AutoCloseable但它在DAO里和PreparedStatement共用try-with-resources更安全我习惯在DAO里用try-with-resourcesJdbcUtil里的close方法留给一些特殊场景的兜底。3.3 StudentDao增删改查PreparedStatement和ResultSet映射DAO层是Jdbc访问的核心我对每个方法的要求就两条SQL用PreparedStatement避免拼接注入ResultSet映射成Java对象。先把实体类写了字段和表列名一一对应package com.student.entity; public class Student { private Integer id; private String studentNo; private String name; private String gender; private Integer age; private String className; private String phone; public Student() {} // getter/setter 省略IDEA快捷键 AltInsert 一键生成 }实体类字段用包装类型Integer而不是int这样查询结果里如果某个字段为NULL也不会触发拆箱空指针。下面写DAO包含列表查询、按ID查询、新增、更新、删除五个方法package com.student.dao; import com.student.entity.Student; import com.student.util.JdbcUtil; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; public class StudentDao { public ListStudent findAll() { ListStudent list new ArrayList(); String sql SELECT id, student_no, name, gender, age, class_name, phone FROM t_student ORDER BY id; try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { list.add(mapStudent(rs)); } } catch (SQLException e) { throw new RuntimeException(查询学生列表失败, e); } return list; } public Student findById(Integer id) { String sql SELECT id, student_no, name, gender, age, class_name, phone FROM t_student WHERE id ?; try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { return mapStudent(rs); } } } catch (SQLException e) { throw new RuntimeException(查询学生详情失败, e); } return null; } public int insert(Student s) { String sql INSERT INTO t_student(student_no, name, gender, age, class_name, phone) VALUES(?,?,?,?,?,?); try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, s.getStudentNo()); ps.setString(2, s.getName()); ps.setString(3, s.getGender()); ps.setInt(4, s.getAge()); ps.setString(5, s.getClassName()); ps.setString(6, s.getPhone()); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(新增学生失败, e); } } public int update(Student s) { String sql UPDATE t_student SET student_no?, name?, gender?, age?, class_name?, phone? WHERE id?; try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, s.getStudentNo()); ps.setString(2, s.getName()); ps.setString(3, s.getGender()); ps.setInt(4, s.getAge()); ps.setString(5, s.getClassName()); ps.setString(6, s.getPhone()); ps.setInt(7, s.getId()); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(更新学生失败, e); } } public int delete(Integer id) { String sql DELETE FROM t_student WHERE id ?; try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(删除学生失败, e); } } private Student mapStudent(ResultSet rs) throws SQLException { Student s new Student(); s.setId(rs.getInt(id)); s.setStudentNo(rs.getString(student_no)); s.setName(rs.getString(name)); s.setGender(rs.getString(gender)); s.setAge(rs.getInt(age)); s.setClassName(rs.getString(class_name)); s.setPhone(rs.getString(phone)); return s; } }所有参数都用setXxx(下标, 值)的形式填进去下标从1开始。executeUpdate的返回值是受影响行数新增或更新成功一定是1如果返回0说明WHERE条件没匹配到记录可以用来判断更新是否命中。mapStudent是私有方法把ResultSet当前行的列值映射到Student实体列名要和SQL里select出来的别名一致千万别写错成Java字段名。这里有个容易踩的坑try-with-resources里声明多个资源时关闭顺序是反过来的先关ResultSet再关Statement最后关Connection代码里写在一起就行不用手动管。如果你是在循环里调用findById那每次都新建连接性能肯定不好但现阶段先保证功能正确连接池我放在最后一章说怎么改。4. 用ServletJsp把增删改查做成Web页面请求链路与实现4.1 列表页的请求链Servlet查数据JSP用JSTL渲染DAO层写完后需要一个Servlet接收浏览器请求、调用DAO、把数据放到request作用域再转发给JSP。以列表页为例浏览器请求/student/listStudentListServlet处理逻辑如下package com.student.servlet; import com.student.dao.StudentDao; import com.student.entity.Student; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; WebServlet(/student/list) public class StudentListServlet extends HttpServlet { private final StudentDao studentDao new StudentDao(); Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); ListStudent list studentDao.findAll(); request.setAttribute(studentList, list); request.getRequestDispatcher(/WEB-INF/jsp/studentList.jsp).forward(request, response); } }Servlet的作用是把业务逻辑和页面解耦这里只做三件事查数据、存数据、跳页面。request.setAttribute存入的对象默认以Object存在JSP里用EL表达式${studentList}能直接拿到。注意路径是/WEB-INF/jsp/studentList.jsp放在WEB-INF下面浏览器不能直接访问到JSP源文件必须走Servlet转发代码才安全。对应的JSP页面通过JSTL遍历集合渲染表格% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % !DOCTYPE html html head title学生信息列表/title style table { border-collapse: collapse; width: 85%; margin: 20px auto; } th, td { border: 1px solid #ccc; padding: 8px; text-align: center; } /style /head body h2 styletext-align:center学生信息列表/h2 div styletext-align:center; margin-bottom:10px; a href${pageContext.request.contextPath}/student/toAdd新增学生/a /div table tr thID/th th学号/th th姓名/th th性别/th th年龄/th th班级/th th联系电话/th th操作/th /tr c:forEach items${studentList} vars tr td${s.id}/td td${s.studentNo}/td td${s.name}/td td${s.gender}/td td${s.age}/td td${s.className}/td td${s.phone}/td td a href${pageContext.request.contextPath}/student/toEdit?id${s.id}编辑/a a href${pageContext.request.contextPath}/student/delete?id${s.id} onclickreturn confirm(确定删除该学生吗)删除/a /td /tr /c:forEach /table /body /htmlc:forEach是JSTL的核心标签items指定集合var指定循环变量名作用相当于Java里的for(Student s : list)。EL表达式${s.name}会自动调用getName()方法所以实体类必须有对应getter否则页面直接报错。页面里所有链接都用了${pageContext.request.contextPath}拼前缀这是获取项目上下文路径的标准写法防止部署后路径不对。编辑和删除链接上都挂了?id${s.id}这是GET方式传参后面Servlet通过request.getParameter(id)接收。确认删除用了onclick里的confirm弹窗这算是最基础的前端防护防误点但不防伪造请求。4.2 新增和编辑共用表单id为空是新增有值是更新新增页和编辑页高度相似我习惯共用一个studentForm.jsp区别只在于回显数据。Servlet先区分请求来源/student/toAdd是新增/student/toEdit是编辑后者查询出已有数据塞进request前者什么都不塞。package com.student.servlet; import com.student.dao.StudentDao; import com.student.entity.Student; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; WebServlet(/student/toEdit) public class StudentToEditServlet extends HttpServlet { private final StudentDao studentDao new StudentDao(); Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); Integer id Integer.valueOf(request.getParameter(id)); Student student studentDao.findById(id); request.setAttribute(student, student); request.getRequestDispatcher(/WEB-INF/jsp/studentForm.jsp).forward(request, response); } }toAdd的Servlet几乎一样只是不查数据直接转发。表单页利用EL的empty判断来控制是新增模式还是编辑模式% page contentTypetext/html;charsetUTF-8 languagejava % !DOCTYPE html html head title学生表单/title /head body h2 styletext-align:center${empty student ? 新增学生 : 编辑学生}/h2 form action${pageContext.request.contextPath}/student/save methodpost input typehidden nameid value${student.id}/ table stylemargin:0 auto; tr td学号/td tdinput typetext namestudentNo value${student.studentNo}//td /tr tr td姓名/td tdinput typetext namename value${student.name}//td /tr tr td性别/td td input typeradio namegender value男 ${student.gender 男 ? checked : }/男 input typeradio namegender value女 ${student.gender 女 ? checked : }/女 /td /tr tr td年龄/td tdinput typetext nameage value${student.age}//td /tr tr td班级/td tdinput typetext nameclassName value${student.className}//td /tr tr td联系电话/td tdinput typetext namephone value${student.phone}//td /tr tr td colspan2 styletext-align:center; button typesubmit保存/button /td /tr /table /form /body /html这里的核心技巧是隐藏域id。新增时student为空${student.id}输出空字符串表单提交后id参数为空编辑时隐藏域带着当前记录的id提交后Servlet根据id是否有值决定调insert还是update。这个写法避免了给新增和编辑各写一套Servlet逻辑很清爽。性别radio的回显用了三元表达式判定checked属性EL里比较字符串用单引号包裹否则JSP会把男当成一个EL表达式去解析。表单提交方式是post能避免中文参数出现在URL里但post提交一样要设置编码。4.3 保存与删除的跳转逻辑重定向刷新防重复提交表单提交到/student/save这个Servlet同时负责新增和更新。写完数据库操作后不能直接forward回列表页因为forward不会让浏览器地址栏变化用户刷新一次就重复提交一次表单。正确做法是用sendRedirect重定向。package com.student.servlet; import com.student.dao.StudentDao; import com.student.entity.Student; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; WebServlet(/student/save) public class StudentSaveServlet extends HttpServlet { private final StudentDao studentDao new StudentDao(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String idParam request.getParameter(id); Student student new Student(); if (idParam ! null !idParam.isEmpty()) { student.setId(Integer.valueOf(idParam)); } student.setStudentNo(request.getParameter(studentNo)); student.setName(request.getParameter(name)); student.setGender(request.getParameter(gender)); student.setAge(Integer.valueOf(request.getParameter(age))); student.setClassName(request.getParameter(className)); student.setPhone(request.getParameter(phone)); if (student.getId() null) { studentDao.insert(student); } else { studentDao.update(student); } response.sendRedirect(request.getContextPath() /student/list); } }注意doPost方法第一行必须设置request.setCharacterEncoding(UTF-8)而且要在读取任何参数之前调用设置晚了就白费了。如果你想偷懒只在doGet里设置那POST请求中文必乱码。另外Integer.valueOf(request.getParameter(age))这种写法如果前端传了空串会抛NumberFormatException我一般在真实项目里会单独抽一个parseInt工具方法教学场景先保证表单必填。删除Servlet更简单拿到id调delete然后重定向WebServlet(/student/delete) public class StudentDeleteServlet extends HttpServlet { private final StudentDao studentDao new StudentDao(); Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); Integer id Integer.valueOf(request.getParameter(id)); studentDao.delete(id); response.sendRedirect(request.getContextPath() /student/list); } }删除用GET方式在真实项目里是有争议的因为爬虫或者预加载机制可能误触发DELETE操作。演示系统无所谓但你要知道这个隐患进阶做法是把删除改成POST表单提交或者用button调Ajax。重定向的路径必须是带contextPath的完整路径否则会跳到Tomcat根目录出现404。到这一步整个学生信息管理系统已经能完成增删改查的闭环了列表查数据、新增填表单、编辑回显、删除确认。接下来我要写的是这个系统跑起来以后你大概率会遇到的几个让人抓狂的问题。5. 学生信息管理系统避坑手册5个常见问题和排查思路5.1 中文乱码POST和GET乱码的根源是两回事现象从表单新增一个学生叫张三保存后列表页显示一堆问号或者乱码直接在地址栏里带中文参数查询也是乱码。原因POST乱码是因为请求体默认按ISO-8859-1解码必须显式设置UTF-8GET乱码是因为Tomcat 8以后虽然URI默认是UTF-8但如果你代码里对GET乱码问题去手动处理反而做了多余操作导致乱码。解决POST请求在Servlet里读取参数前加request.setCharacterEncoding(UTF-8)GET请求确认Tomcat的server.xml里Connector没有加URIEncodingISO-8859-1保持默认UTF-8就行。另外MySQL连接串里一定要带characterEncodingutf8三处编码统一了中文基本就不会出问题。排查顺序建议是先看数据库表和连接串是不是utf8mb4再看Servlet编码设置最后看页面文件本身的编码是不是UTF-8IDEA右下角有文件编码指示。我自己遇到乱码时习惯先做一个不加数据库的test.jsp直接输出一段固定中文如果这个都乱说明是Tomcat或浏览器编码问题跟项目代码无关。5.2 启动报ClassNotFoundException驱动jar包可能根本没进war包现象Tomcat启动正常但一访问列表页就抛ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因pom依赖加了mysql-connector-java但依赖scope是provided或者IDEA里Artifact的WEB-INF/lib下压根没有这个jar。Maven依赖只是编译期能用最终部署时要打进war包lib目录才行。解决检查pom里mysql驱动的scope不要用provided默认compile即可。然后打开IDEA的Project Structure里Artifacts面板看Output Layout里WEB-INF/lib下有没有Available Elements里的mysql jar没有就右键添加。如果是手动部署直接去本地Maven仓库找到mysql-connector-java的jar复制到Tomcat的webapps/你的应用/WEB-INF/lib目录下。5.3 MySQL 8.0和5.7驱动差异类名与URL参数对不上现象同一个项目在别人电脑上跑得好好的你换了一台MySQL 5.7的机器启动报错或者连接超时。原因MySQL 8.0的驱动类名是com.mysql.cj.jdbc.DriverURL必须带serverTimezone参数MySQL 5.7及以前的驱动类是com.mysql.jdbc.Driver不需要serverTimezone。用旧教程里的配置连MySQL 8.0会直接报The server time zone value乱码的错。解决看自己MySQL版本决定配置。8.0用我这篇文章里的写法5.7改成jdbc.drivercom.mysql.jdbc.DriverURL里的serverTimezone去掉。另外8.0的URL通常要加allowPublicKeyRetrievaltrue否则连一些授权方式的MySQL会报Public Key Retrieval is not allowed。5.4 Tomcat端口被占用和Artifact部署404的排查顺序现象启动Tomcat时控制台报Port 8080 was already in use或者启动成功但访问/student/hello显示404。原因端口占用大概率是之前那个Tomcat进程没被杀干净或者装了多个Java应用。404则常见于Artifact没部署全、Application context配错、或者JSP放在不该放的位置。解决端口占用时先lsof -i:8080找到进程IDkill掉Windows下用netstat -ano | findstr 8080然后taskkill /PID 对应进程 /F。404排查按这个顺序看Tomcat启动日志里有没有InstantiateListener或Deploy的过程确认IDEA控制台里Artifact状态是exploded且路径正确确认Application context填的是/student不是别的最后确认Servlet类有没有被编译进classes目录。90%的404是部署路径问题不是代码问题。5.5 JSP改了不生效、EL表达式原样输出现象修改了JSP页面样式或者文字刷新浏览器还是旧样式或者${student.name}直接以字符串显示在页面上。原因JSP改完不生效是因为IDEA对JSP的热部署有时候不及时需要Rebuild一下或者重启Tomcat尤其是新增的文件。EL表达式原样输出绝大多数是因为page指令里漏了isELIgnoredfalse或者web.xml配置了较老的Servlet版本默认忽略EL。解决JSP不更新就先Rerun一次应用还不行就Build - Rebuild Project。EL问题检查页面顶部% page isELIgnoredfalse %同时确认web.xml的web-app版本声明是3.0或更高老版本schema确实会忽略EL。还有一个隐藏点pom里如果没引入JSTLc:forEach这种标签会变成一段普通的白色文字所以jstl依赖别省略。6. 收尾但没完分页、模糊搜索、连接池与验收清单增删改查跑通之后这个系统已经可以作为课程设计交差了但我建议把三个增强功能加上它们直接决定项目在同组同学里是及格还是亮眼而且改动量都很小。第一个是分页。列表页数据超过几十条后页面加载就会明显变慢分页的核心是SQL的LIMIT子句配合两个参数page表示当前页size表示每页条数。查询时ps.setInt(1, (page-1) * size)算出偏移量ps.setInt(2, size)限制返回行数再单独执行一次SELECT COUNT(*) FROM t_student拿到总记录数就能算出总页数并渲染页码链接。注意页码点击后是通过GET参数传给Servlet的所以列表Servlet要从request里读page参数默认值为1。第二个是按姓名模糊搜索。给学生列表加一个搜索框提交keyword参数DAO里把SQL改成WHERE name LIKE ?参数设为%keyword%。这里必须坚持用PreparedStatement参数占位绝对不能直接在SQL里拼字符串否则你做的学生管理系统就是个带SQL注入漏洞的反面教材。搜索和分页同时存在时页码链接里要把keyword也带过去否则翻页以后搜索条件就丢了。第三个是把JdbcUtil里的DriverManager换成连接池。学完这套手写Jdbc你才会真正理解Druid或HikariCP解决了什么问题每次getConnection都新建物理连接高并发下性能很差连接池做的就是连接复用。改动只需要把getConnection方法换成DruidDataSource的getConnection业务代码一行不用动。最后给你一个验收清单做完照着测一遍测试项操作路径预期结果列表展示访问/student/list表格展示已有学生数据新增学生点新增→填表→保存跳回列表且出现新记录编辑学生点编辑→改姓名→保存原来那条记录被更新删除学生点删除→确认记录消失中文正常新增一条姓名为中文的记录页面和数据库都无乱码刷新不重复提交表单保存后按F5列表刷新不产生重复数据非法学号重复插入两个相同学号抛出异常并有日志可查我当年做这个系统时因为漏掉重定向在保存成功后直接forward回列表演示答辩时手滑按了两次保存结果数据库里插进了两条一模一样的记录。那个场景我现在都记得。所以最后送你一句话Server端的每一次跳转选择都是在为用户操作兜底转发和重定向的区别就是你线上事故和课堂演示之间隔着的那道墙。希望帮到你。本文还有配套的精品资源点击获取