
简介这是一份基于 Java Web 技术栈的学生信息管理系统完整项目包面向学习 Servlet、JSP、JDBC 与 MySQL 整合开发的初学者也适合作为课程设计或实训参考。项目采用 MVC 分层思路Servlet 承担控制器职责JSP 负责页面展示与用户交互JDBC 完成对 MySQL 的增删改查覆盖学生信息的查询、添加、修改、删除等典型功能。压缩包共 78 个文件大小约 4.48MB包含 20 个 JSP 页面、12 个 Java 源文件、24 个 class 文件、1 个 SQL 建表脚本以及 XML 配置、图片和依赖 JAR目录结构清晰可直接导入 IDE 运行调试。已有 6944 人学习下载文件分层便于对照源码理解请求从 JSP 到 Servlet、再到数据库的完整链路。对于希望掌握 Java Web 基础开发流程或快速搭建同类管理系统的读者这份资源提供了可复用的代码骨架、完整的 SQL 初始化脚本和较全面的项目示范可帮助梳理前端交互、后端逻辑与数据持久化之间的协作关系。1. 学生信息管理系统看起来简单却是唯一能把Servlet、JDBC、JSP、MySQL串成闭环的练手项目一个Java初学者最尴尬的时刻是学完基础语法后对着空荡荡的IDE不知道写什么。做学生信息管理系统是最不容易翻车的选择——需求足够明确就是学生的增删改查加一个登录页技术栈足够经典ServletJDBCJSPMySQL这套组合没有框架包装每一步都落在明面上做完还能直接当课程设计或面试谈资。笔者带过的实习生里凡是能把这套系统独立写完的java面试八股里的servlet生命周期、jdbc增删改查、mysql建表规范基本都能答得上来。这套系统适合刚学完Java基础的在校生、准备java面试的求职者以及需要接手维护传统jsp项目的新人。项目不大但由此延伸出的编码问题、连接池问题、部署问题每一项都是真实工作里躲不开的。2. 先把表和项目结构定下来从需求到MySQL建库建表一个系统最怕的不是代码难写而是表结构没想清楚就动手。学生信息管理系统的表设计并不复杂但表之间的关系、字段类型、编码方式直接影响后面所有DAO和JSP页面的写法。这一章先把数据库和目录结构定死后面写代码才不会反复推翻重来。2.1 需求拆解一个学生信息管理系统最少要几张表学生信息管理核心是学生资料的增删改查。但仔细拆一下会发现最少需要三张表学生表存学生属性班级表存班级信息避免学生表里反复写计算机2301班这种冗余文本用户表管登录课程设计通常要交一个能登录的版本。课程、成绩这些业务表第一版不用急着加功能越少联调时排查问题的范围越小先把主链路跑通再说。业务规则也要在动手前定清楚。学号必须唯一不能两个学生共用删除班级时要么禁止删除有学生引用要么级联删除二选一不能什么都不做——否则会出现学生指向一个根本不存在的班级性别用char(1)存男/女还是用int存0/1我一般用char(1)直接可读JSP页面不用再做字典映射。年龄我倾向于用INT方便排序和统计数据库层面没必要做成VARCHAR给自己添麻烦。另一个容易被忽视的规则是时间字段。每个表加一个create_time默认值设为当前时间插入时不用额外赋值后面排查数据问题时这列能帮你判断记录到底是什么时候进去的。规则定完后把表设计文档画出来再进下一步不然以后每次改表结构DAO和Servlet都得跟着改一遍前后端一起返工。2.2 MySQL建库建表把这几条SQL存成init.sql数据库字符集统一用utf8mb4。很多mysql安装教程里默认的utf8在实际存emoji或特殊符号时会报错utf8mb4是utf8的超集兼容性最好MySQL 8.x里也推荐直接用utf8mb4。建库和建表语句全部写进一个init.sql重装环境时一条命令就能恢复整个库的结构。-- 学生信息管理系统数据库初始化脚本 CREATE DATABASE IF NOT EXISTS student_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE student_db; -- 班级表 CREATE TABLE t_class ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 班级ID, class_name VARCHAR(50) NOT NULL COMMENT 班级名称如计算机2301, grade VARCHAR(20) NOT NULL COMMENT 年级如2023级, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 学生表 CREATE TABLE t_student ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 自增主键, stu_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号唯一约束, name VARCHAR(30) NOT NULL COMMENT 姓名, gender CHAR(1) NOT NULL DEFAULT 男 COMMENT 性别男/女, age INT DEFAULT 18 COMMENT 年龄, class_id INT NOT NULL COMMENT 所属班级ID外键指向t_class.id, phone VARCHAR(11) DEFAULT NULL COMMENT 手机号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_student_class FOREIGN KEY (class_id) REFERENCES t_class(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生信息表;ENGINEInnoDB一定不要省——InnoDB支持外键和事务MyISAM在并发写入和崩溃恢复上都不如它。stu_no加了UNIQUE从约束层面堵死重复学号。class_id的外键约束用来保证数据一致但这只在数据库层面生效后面写DELETE FROM t_class时也要在代码里先检查有没有学生引用这个班级双重保险。create_time字段利用DEFAULT CURRENT_TIMESTAMP自动填充插入语句里不用写这一列省一行代码。这里补一张用户表登录功能最常被忽视却又必考。密码不能存明文MySQL的SHA2()函数可以直接在插入时算好摘要-- 用户表用于系统登录 CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 密码存SHA-256摘要, role VARCHAR(10) DEFAULT admin COMMENT 角色 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 初始化一个账号admin / 123456 INSERT INTO t_user (username, password) VALUES (admin, SHA2(123456, 256));密码存摘要而不是明文这是java面试里数据一致性话题的常客。登录校验时把用户输入的密码同样做SHA2再比对就算数据库泄漏密码也不会以明文暴露。执行这几条SQL的方式很灵活MySQL命令行里source /path/init.sql或者用Navicat直接打开文件运行两种都行。执行完用SHOW TABLES;确认三张表都在再进下一步。2.3 项目目录结构MVC分层在哪里落地表结构定完接下来把MVC三层落到目录里。常见做法是分四个包entity装JavaBeandao装数据访问servlet装控制层util装工具类JSP页面放在web根目录。下面这个层级是传统jsp项目最常见的形态照着这个结构建包就能避免后期越写越乱student-manager/ ├── src/ │ └── com/student/ │ ├── entity/Student.java │ ├── dao/StudentDao.java │ ├── servlet/ │ │ ├── StudentListServlet.java │ │ ├── StudentAddServlet.java │ │ ├── StudentUpdateServlet.java │ │ └── StudentDeleteServlet.java │ └── util/DBUtil.java ├── web/ │ ├── WEB-INF/web.xml │ ├── list.jsp │ ├── add.jsp │ └── update.jsp └── lib/ └── mysql-connector-java-8.0.33.jarlib目录放MySQL驱动包。在IntelliJ IDEA里可以通过Project Structure - Libraries引入驱动但最后打war包部署到Tomcat时驱动jar必须收进WEB-INF/lib否则Tomcat一启动就报ClassNotFoundException。这个坑在第五章会展开讲。实体类Student.java要严格遵守JavaBean规范——无参构造必须有getter/setter命名必须跟属性名对应package com.student.entity; import java.time.LocalDateTime; public class Student { private Integer id; private String stuNo; private String name; private String gender; private Integer age; private Integer classId; // 这个字段不属于t_student表是多表联查时补充进来的 private String className; private String phone; private LocalDateTime createTime; public Student() {} public Integer getId() { return id; } public void setId(Integer id) { this.id id; } // 其余getter/setter由IDE自动生成不再逐行粘贴 }className字段值得注意它来自班级表而并非学生表字段却在列表页面里被频繁使用。把它直接放进Student实体里DAO联查后填值页面端就不需要维护两个对象。id用包装类型Integer而不是基本类型int原因是结果集里rs.getInt(id)如果数据库值为NULL会返回0而不是null用包装类型可以区分没查到和查到0两种状态这在按主键查找时很有意义。目录结构和实体类定完后就可以开始写数据访问层。下一章从JDBC最基础的连接讲起。3. 用JDBC把数据访问层写稳连接、增删改查与连接池数据访问层是整套系统的地基。地基不稳Servlet写得再花哨页面上也只会是一片空白或一堆异常。JDBC的套路固定且重复难点在于细节——URL参数写错一个连接就建立不起来资源不关闭数据库连接数很快被耗尽。这一章把DBUtil和StudentDao完整写一遍。3.1 JDBC六步走从Class.forName到释放资源JDBC的操作固定六步加载驱动、建立连接、创建Statement、执行SQL、处理结果集、释放资源。MySQL 8.x的驱动类是com.mysql.cj.jdbc.Driver老版本用的是com.mysql.jdbc.Driver这两个类名在面试里经常被挖坑——用8.x驱动写老类名直接报ClassNotFoundException。URL里的serverTimezone参数必须显式指定否则驱动会用本地时区与MySQL服务端比对报时区错误。package com.student.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/student_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4; private static final String USER root; private static final String PASSWORD 123456; static { try { // 1. 加载驱动MySQL 8.x必须用cj包路径 Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL驱动未找到请检查lib目录, e); } } public static Connection getConnection() throws SQLException { // 2. 建立连接 return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(AutoCloseable... resources) { // 6. 统一释放资源逆序关闭 for (AutoCloseable r : resources) { if (r ! null) { try { r.close(); } catch (Exception ignored) {} } } } }URL三个参数逐个说useSSLfalse关掉SSL握手本地开发能省掉那页报错serverTimezoneAsia/Shanghai避免时区偏差导致的时间错乱characterEncodingutf8mb4保证中文按UTF-8进库。close方法用可变参数接Connection、Statement、ResultSet调用方一行DBUtil.close(conn, ps, rs)全部关完。连接关闭后用它创建的Statement和ResultSet理论上也会被回收但显式关闭是必须养成的习惯——后面换成连接池时不关的话资源泄漏会直接拖垮整个系统。3.2 DAO层实现StudentDao的增删改查怎么写DAO层的责任是把SQL彻底封装起来Servlet里不应该出现任何SQL字符串SQL只允许出现在DAO里。以新增学生为例完整写一遍package com.student.dao; import com.student.entity.Student; import com.student.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class StudentDao { // 新增学生返回受影响行数1表示成功 public int add(Student s) { String sql INSERT INTO t_student (stu_no, name, gender, age, class_id, phone) VALUES (?, ?, ?, ?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, s.getStuNo()); ps.setString(2, s.getName()); ps.setString(3, s.getGender()); ps.setInt(4, s.getAge()); ps.setInt(5, s.getClassId()); ps.setString(6, s.getPhone()); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } // 查询全部学生联查班级名按学号排序 public ListStudent findAll() { String sql SELECT s.*, c.class_name FROM t_student s LEFT JOIN t_class c ON s.class_id c.id ORDER BY s.stu_no; ListStudent list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { Student s new Student(); s.setId(rs.getInt(id)); s.setStuNo(rs.getString(stu_no)); s.setName(rs.getString(name)); s.setGender(rs.getString(gender)); s.setAge(rs.getInt(age)); s.setClassId(rs.getInt(class_id)); s.setClassName(rs.getString(class_name)); s.setPhone(rs.getString(phone)); list.add(s); } } catch (SQLException e) { e.printStackTrace(); } return list; } // 按主键删除学生 public int deleteById(Integer id) { String sql DELETE FROM t_student WHERE id ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } // 按主键更新学生信息 public int update(Student s) { String sql UPDATE t_student SET name?, gender?, age?, class_id?, phone? WHERE id?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, s.getName()); ps.setString(2, s.getGender()); ps.setInt(3, s.getAge()); ps.setInt(4, s.getClassId()); ps.setString(5, s.getPhone()); ps.setInt(6, s.getId()); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } }这里用了try-with-resources语法Java 7之后的推荐写法连接和Statement自动关闭省掉finally里那一大坨判空逻辑。PreparedStatement的占位符从1开始计数setString和setInt的类型必须与表字段匹配。实际项目里我更推荐setObject由驱动自动做类型转换能把一半的类型异常挡在门外。查询用LEFT JOIN而不是先查学生再查班级这样客户端拿到的就是一个完整的List直接在JSP里渲染不用为班级名做二次查询。注意findAll里rs.getInt(id)拿的是t_student表的主键SELECT的字段列表里同时有id和class_id列名冲突时一定要用别名区分这是联查最容易翻车的地方。3.3 参数与边界PreparedStatement为什么必须用DAO里从头到尾没有出现过Statement只出现PreparedStatement这是刻意为之。用字符串拼接SQL虽然代码更短但有两个致命问题第一用户输入的数据一旦包含 OR 11这类片段拼接出来的SQL会改变语义这就是经典的SQL注入第二每次拼接生成的SQL文本都不同数据库无法缓存执行计划性能也会打折扣。PreparedStatement把SQL文本和参数分离占位符里的内容永远被当作数据而不是SQL指令解析注入在根本上被堵死。参数设置的颜色线是占位符的编号从1开始一个参数都不能漏漏一个编译期不会报错运行期抛Parameter index out of range。如果哪个DAO方法参数数量老是对不上写完之后数一遍?的个数比反复调试更快。另一个边界是连接资源的生命周期。每一轮的getConnection都走TCP握手和MySQL认证本地开发还能忍受部署到服务器上并发一高连接建立的开销会直接反映在响应时间上。这一版的DBUtil没有连接池第六章会把Druid连接池替换进来。先跑通功能再优化性能这个顺序不要颠倒。4. Servlet控制层与JSP视图层请求怎么进、页面怎么出数据库和DAO层就绪后开始写控制层和视图层。Servlet负责接收浏览器请求、调用DAO、把数据交给JSPJSP只负责渲染。这一章用列表页做主线把Servlet生命周期、请求转发、EL表达式、编码过滤器一次讲透。4.1 Servlet生命周期与请求流转doGet和doPost怎么分工Servlet生命周期是java面试高频考点也是理解请求流转的钥匙容器加载Servlet类实例化后调用init()一次每次请求到达时调用service()service()根据HTTP方法分发给doGet或doPost容器关闭时调用destroy()。这个生命周期里最容易踩的坑是重写了service()却忘了调用super.service()导致doGet/doPost全部失效。doGet和doPost的分工在实际项目里有个约定俗成的标准查询、跳转页面用doGet表单提交、写入数据用doPost。列表页需要在地址栏能直接访问所以走doGet新增和修改表单用methodpost提交Servlet里重写doPost。删除操作如果用a标签触发默认是GET请求URL里会带着?id3地址栏里能看见语义上不太严谨但课程设计阶段可以接受。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 StudentDao studentDao new StudentDao(); Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 调用DAO查询数据 ListStudent students studentDao.findAll(); // 2. 把数据放进request作用域 request.setAttribute(students, students); // 3. 转发到JSP渲染 request.getRequestDispatcher(/list.jsp).forward(request, response); } }WebServlet(/student/list)是Servlet 3.0之后推荐的映射方式省去web.xml里的servlet配置。doGet里只做三件事查数据、存数据、转发。转发路径以web根目录为准/list.jsp对应web目录下的list.jsp。为什么用forward而不用sendRedirect因为forward是容器内部转发请求只有一次request里setAttribute的数据在JSP里能拿到sendRedirect是浏览器重新发起第二次请求request作用域直接丢失页面就会看到空列表。需要重定向的场景是表单提交成功之后——那个场景反而必须用sendRedirect第五章会专门讲。4.2 写一个列表页从Servlet到list.jspJSP页面里最忌讳的是写大段Java代码。JSP的本职是模板渲染Java逻辑应该放到Servlet或标签库里去。列表页用EL表达式和JSTL的c:forEach来遍历数据代码会非常干净% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html head title学生列表/title /head body h2学生信息列表/h2 table border1 tr th学号/thth姓名/thth性别/thth年龄/thth班级/thth操作/th /tr c:forEach items${students} vars tr td${s.stuNo}/td td${s.name}/td td${s.gender}/td td${s.age}/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 a href${pageContext.request.contextPath}/add.jsp新增学生/a /body /html${s.stuNo}对应Student.getStuNo()EL表达式调用的是getter方法而不是直接访问属性所以实体类里的getter一旦命名不规范页面上取到的就是空值。${pageContext.request.contextPath}会渲染成部署后的上下文路径比如/student-manager——如果不加这一段所有相对路径都会相对于当前URL目录解析部署后几乎必出404。使用JSTL需要jstl-1.2.jar放进WEB-INF/lib。Tomcat 10及以上版本要特别注意JSTL的标准实现已经从javax.servlet迁移到jakarta.servlet传统jsp项目打包war部署到Tomcat 9能跑换到Tomcat 10直接抛NoClassDefFoundError。我一般建议这种技术栈的项目固定用Tomcat 9省掉这批兼容性问题。4.3 中文乱码与路径问题两个绕不开的配置学生信息几乎全是中文乱码问题几乎每台机器都会遇到一次。乱码分两个层面请求层和响应层。POST请求提交的表单数据如果Servlet不处理Tomcat默认按ISO-8859-1解码中文变成问号响应层如果JSP声明的contentType没有指定charset浏览器按默认编码渲染也会乱。最省心的方案是写一个编码过滤器拦截所有请求统一设置package com.student.util; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; WebFilter(/*) public class CharacterEncodingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(request, response); } }WebFilter(/*)拦截所有请求包括静态资源。过滤器只设置编码然后放行真正的业务逻辑由后面的Servlet处理。这一版过滤器同时设置了请求和响应编码POST表单和JSP输出都不会再乱。注意GET请求的中文参数不在过滤器管辖范围内——GET传参的中文乱码是Tomcat的URI编码问题需要修改server.xml里的Connector加URIEncodingUTF-8但浏览器地址栏里中文参数本身就不规范建议不在GET URL里带中文。如果你更习惯web.xml的方式在web.xml里配置filter和filter-mapping效果一样二选一即可。两者的区别只是配置位置过滤器代码可以复用。写完过滤器列表页在浏览器里应该能看到数据库里的学生数据了下一步就是处理新增和编辑表单的提交链路。5. 常见问题排查从404到中文乱码的五个高频坑学生信息管理系统跑不出来的原因高度集中前后带过几十个新人遇到的翻车现场基本都在这五个里面。每一条都按现象、原因、解决三步写清楚遇到问题直接对照着查。5.1 页面能打开但列表是空的控制台也没有报错现象浏览器能正常打开list.jsp表格边框都在但一行数据都没有Tomcat控制台也不打印任何异常。原因DAO里把所有SQLException都catch住然后e.printStackTrace()异常打印在Tomcat的catalina.out日志里浏览器端看不出任何痕迹。最常见的情况是SQL写错——表名少了一个字母或者字段名和数据库对不上。还有一种隐蔽情况驱动没加载Class.forName抛了异常被catch后继续执行getConnection也抛异常被catch最后findAll返回一个空List。解决先打开Tomcat控制台看有没有红色堆栈信息。本地开发时临时把e.printStackTrace()改成throw new RuntimeException(e)让异常直接抛出页面会显示完整错误信息定位快得多。定位到具体SQL后把SQL复制到Navicat或命令行执行数据库端报的错比Tomcat端更直接。5.2 表单提交后数据库里全是问号中文全部丢失现象新增学生表单填了张三刷新数据库一看name字段存的是???。原因POST请求的编码没设置。表单数据在HTTP Body里按页面声明的charset编码但如果Servlet读请求时字符集还是Tomcat默认的ISO-8859-1两次编码不一致中文必然变问号。这一般是编码过滤器没生效或者过滤器配置的URL拦截范围不对。解决确认WebFilter(/*)的注解存在且被容器扫描到。如果用web.xml配置检查url-pattern是否写了/*而不是/。还有一点容易被忽略如果同时存在注解过滤器和web.xml过滤器两者都会执行别重复设置编码但不会造成乱码。5.3 Tomcat启动后访问页面报ClassNotFoundException: com.mysql.cj.jdbc.Driver现象IDE里运行一切正常把项目打成war包部署到Tomcat第一次访问报ClassNotFoundException。原因MySQL驱动jar只在IDE的Libraries里添加了打war包时没有进WEB-INF/lib。IDEA里依赖是编译期的Tomcat运行期找不到这个类。这是传统jsp项目最经典的部署坑。解决把mysql-connector-java-8.0.33.jar复制到项目的web/WEB-INF/lib目录重新构建。如果手头没有现成jar用Maven的话在pom.xml里声明依赖并执行mvn package也会自动打包进去。注意要在war包解压后检查WEB-INF/lib里是否真的有这个jar别只看IDEA的Gradle/Maven窗口里有没有。5.4 刷新浏览器页面学生记录重复插入现象新增一条学生后点击浏览器刷新数据库里立刻出现两条相同的数据再刷新又多一条。原因表单提交走的是POST请求浏览器刷新时会重新提交上一次的POST请求体Servlet的doPost被再次执行导致重复插入。这是PRGPost/Redirect/Get模式缺失造成的。解决Servlet在add成功之后不要forward到JSP而是重定向到列表页// 新增成功重定向到列表页 response.sendRedirect(request.getContextPath() /student/list);sendRedirect返回302让浏览器发起新的GET请求地址栏变成/student/list刷新时只会重新查列表不会重新提交表单。这个前后端联动的经典问题面试里也经常借题发挥问的就是这个模式。5.5 MySQL连接报SSL错误或时区错误现象第一次启动项目控制台报SSL connection error或The server time zone value Öйú±ê׼ʱ¼ä is unrecognized。原因MySQL 8.x默认启用SSL而JDBC URL里没有显式声明同时MySQL 8.x的时区信息与驱动默认值不一致需要客户端显式指定。解决把DBUtil里的URL修改为完整参数jdbc:mysql://localhost:3306/student_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4本地开发加useSSLfalse就够了。生产环境如果要求加密传输把useSSL改成true并配置证书但这些对课程设计和内部系统没有意义。这条坑在mysql连接池配置里同样适用换Druid或HikariCP时URL参数原样带过去就行。6. 再往前推一步分页查询、事务回滚与Druid连接池列表页一次查出所有学生数据量大时页面会卡这是第一个要解决的真实问题。分页的做法是SQL层加LIMITDAO方法增加两个参数public ListStudent findByPage(int page, int size) { String sql SELECT s.*, c.class_name FROM t_student s LEFT JOIN t_class c ON s.class_id c.id ORDER BY s.stu_no LIMIT ?, ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, (page - 1) * size); // offset从0开始 ps.setInt(2, size); // 结果集处理逻辑与findAll相同 } catch (SQLException e) { e.printStackTrace(); } return list; }LIMIT两个参数的含义是偏移量和条数第1页20条对应LIMIT 0, 20第2页对应LIMIT 20, 20所以offset的计算公式是(page - 1) * size。同时还需要一个count()方法查总条数算出一共多少页。翻页时在JSP里渲染上一页、下一页链接参数带page就行。事务是第二个必须补的能力。比如批量导入学生前9条插入成功第10条失败没有事务时前9条已落库数据状态不一致。写法是拿到Connection后关闭自动提交Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); // 循环执行多条INSERT conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.close(); }setAutoCommit(false)之后的SQL都包装在一个数据库事务里要么全部提交要么全部回滚。注意异常时要rollback()这个动作才是后悔药忘了写的话前9条照样进库。第三个优化是连接池。DBUtil每次getConnection都新建物理连接换成Druid后连接被复用响应时间能降一个量级DruidDataSource dataSource new DruidDataSource(); dataSource.setUrl(URL); dataSource.setUsername(USER); dataSource.setPassword(PASSWORD); dataSource.setInitialSize(5); // 初始连接数 dataSource.setMaxActive(20); // 最大活动连接数 dataSource.setMaxWait(60000); // 获取连接超时毫秒数Druid的initialSize和maxActive是重点参数maxActive太小高并发时会排队太大MySQL连接数被占满反而拖垮数据库。本机学习环境5/20足够。DBUtil里的getConnection改成从dataSource.getConnection()获取其余代码一行不用动。验证这套改造是否有效的习惯我一直保留先看列表页响应时间是否明显变快再用SHOW PROCESSLIST观察连接是否被复用而不是反复创建。这套系统做完后再去接触Spring、MyBatis底层逻辑你会发现框架做的无非就是把这套手工流程自动化。验证通过后把这个项目里踩过的坑记下来再动手写下一个系统速度会快得多。希望帮到你。本文还有配套的精品资源点击获取