☰
Java图书管理系统实战:Servlet+JSP+MySQL从架构到部署避坑全解析
2026/10/9 2:42:04 网站建设 项目流程

简介:面向高校JavaWeb课程实训、期末项目与毕业设计场景的图书管理系统项目,采用JSP+Servlet+JavaBean的MVC分层模式,搭配MySQL5.6/5.7数据库,实现图书信息管理、学生借阅、归还等核心业务,覆盖从页面交互到数据库操作的完整链路,适合需要可运行案例的初中级学习者。压缩包约8.74MB,打包了项目源码、运行文档、配套论文与答辩PPT;源码可导入MyEclipse或Eclipse,配合JDK1.7和Tomcat8即可部署,运行文档中的部署说明与数据库配置要点能有效降低环境搭建门槛。目前已有1321人浏览学习,作者说明代码均亲自测试运行,项目按MVC结构划分JSP视图、Servlet控制器、JavaBean业务及数据访问层,各模块职责清晰,便于定位和二次修改。对照文档阅读源码,既能掌握传统JavaWeb项目的分层开发、数据库交互和借阅归还业务设计,也能直接复用论文与答辩PPT框架,为课程报告、毕业设计与答辩展示提供现成素材。

1. 从zip到可答辩项目:Java图书管理系统servlet+jsp+mysql的核心价值

一个 java图书管理系统,用 servlet+jsp+mysql 把学生借阅归还跑通,源码里还带文档报告——这套组合几乎是大学课设和毕业设计里出现频率最高的中介方案。它不像 Spring Boot 全家桶那样要处理一堆自动配置,也不像纯 PHP 那样没门槛;它刚好处在“能讲清楚原理”和“能落地运行”的交叉点。如果你手里正好有这样一份 zip 源码,想把它当作进入 Java Web 的练习,或者拿去应对结课验收,这篇笔记就是按一线开发的顺序,从表结构到部署把每一块掰开讲。我不打算复述某个具体开源包的文件清单,而是把这类项目最常见的工程结构、运行步骤和隐患逐条摊开,让你拿到手就能判断能不能用、怎么改、哪里会翻车。

2. 先把架构立住:Servlet/JSP/MySQL 三层划分与数据表设计

2.1 Servlet 与 JSP 的职责边界:为什么这套技术栈还没过时

很多同学一开始会困惑:Servlet 和 JSP 都能输出 HTML,为什么还要分成两个东西?我在带人做课设时经常打一个比方:Servlet 是后台处理业务的“服务员”,JSP 是摆给用户看的“菜单”。服务员点单、传菜、处理特殊要求,菜单只负责展示菜品和价格。你要是让菜单自己去后厨炒菜,那就是把 JDBC 代码写进了 JSP,早期很多“图书管理系统”就是这么烂掉的。

Servlet 是 Java EE 规范里处理 HTTP 请求的组件,继承 HttpServlet、重写 doGet/doPost,通过 request 拿参数、用 response 写回内容。它天然适合做控制层:接收前端请求、调用 DAO 访问数据库、把结果放进 request 或 session、再转发或重定向到 JSP。JSP 的本质是“在 HTML 里嵌入 Java 代码和 JSTL 标签”,它最终也会被 Tomcat 编译成 Servlet,只是因为职责定位不同,JSP 更适合做视图渲染。

把项目按“页面 -> Servlet -> DAO -> MySQL”拆成几层以后,每个文件都能单独测试。借阅流程可以写成清晰的调用链:JSP 表单提交给 BorrowServlet,BorrowServlet 拿到 studentId 和 bookId,调用 BookDao 查库存和插入借阅记录,最后跳回列表页。这套结构即使换成 Spring MVC,思想也是一样的:DispatcherServlet 替代了你的 Servlet,但 Controller、Service、Mapper 的边界和这里几乎一一对应。

当年这套技术栈被大量用在课设和外包项目里,还有一个现实原因:它不需要引入一堆依赖。一个能跑的 java 图书管理系统,核心依赖就是 mysql-connector-java 的 jar 包和整套 Tomcat 自带的东西。没有 Maven 要去中央仓库拉十几个包的问题,拷贝到另一台电脑也能跑。作为学习项目,你能看清楚一次 HTTP 请求从浏览器到数据库再返回的完整路径,这个价值比“会配 Spring Boot 自动配置”更实在。

2.2 数据表设计:学生、图书、借阅记录三张核心表

图书管理系统的数据模型非常标准,我一般会按三张表起步:学生表、图书表、借阅记录表。如果你想加管理员,再加一张 user 表,但课设常把学生和管理员合并处理,或者单独放一个 role 字段。下面这套 SQL 是按最常见的方案写的,字段名和类型对新手比较友好:

CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', name VARCHAR(50) NOT NULL COMMENT '姓名', password VARCHAR(64) NOT NULL DEFAULT '123456' COMMENT '密码', major VARCHAR(50) COMMENT '专业', phone VARCHAR(20), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_student_no (student_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表'; CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, book_no VARCHAR(20) NOT NULL UNIQUE COMMENT '图书编号', title VARCHAR(100) NOT NULL COMMENT '书名', author VARCHAR(50) COMMENT '作者', publisher VARCHAR(50) COMMENT '出版社', total_count INT NOT NULL DEFAULT 1 COMMENT '总库存', remain_count INT NOT NULL DEFAULT 1 COMMENT '剩余可借数量', status TINYINT NOT NULL DEFAULT 1 COMMENT '1可借 0下架', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_book_no (book_no), KEY idx_title (title) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表'; CREATE TABLE borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL COMMENT '学生ID', book_id INT NOT NULL COMMENT '图书ID', borrow_time DATETIME NOT NULL COMMENT '借出时间', due_time DATETIME NOT NULL COMMENT '应还时间', return_time DATETIME DEFAULT NULL COMMENT '实际归还时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0借出 1已还 2逾期未还', KEY idx_student_id (student_id), KEY idx_book_id (book_id), KEY idx_status (status), CONSTRAINT fk_borrow_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅记录表';

几个设计上的细节值得琢磨。book 表里我刻意放了 remain_count,而不是每次借书都去 borrow_record 里 count。借阅表会越来越长,每次都实时统计,到了一定数据量页面就会明显变慢;在课设阶段用冗余字段换查询速度,是可接受的。status 字段用数字而不用字符串,是为了后续写条件时简洁:status=0 表示“借出中”,status=1 表示“已归还”。逾期状态不要一开始就写死,因为还书动作发生时才知道是否逾期,后面可以用定时任务或查询时计算去更新。

借阅记录表的 status 是这套系统的核心状态机。借书时插入一条 status=0 的记录,还书时把它改成 1,并写入 return_time。逾期不还的书,在用户查询列表时用 due_time 和当前时间比较得出“已过期”,这比单独维护一张“罚款表”简单得多。外键我建议保留,虽然有些开发者为避免删除麻烦故意不用外键,但课设答辩时外键和表关系是加分项。

2.3 环境选型:JDK 8 + Tomcat 8/9 + MySQL 5.7/8.0 的兼容组合

拿到一个 servlet+jsp+mysql 项目,先别急着双击运行,第一步是确认环境组合能对上。我见过太多因为版本错位导致启动失败的案例:JDK 9 以上移除了部分 JAXB 相关支持,某些老项目会编不过;Tomcat 10 将 javax.servlet 换成了 jakarta.servlet,老项目的 import 全部失效;MySQL 8 默认认证插件是 caching_sha2_password,老版本的驱动根本无法连接。

最稳的组合是 JDK 8 + Tomcat 8.5 或 9 + MySQL 5.7。这套组合对 servlet+jsp 项目非常友好,你不需要在环境上折腾太久。如果要用 MySQL 8,也没问题,但驱动必须换 8.x 版本,JDBC 连接串要追加 serverTimezone 参数。具体对应关系我习惯这样记:

环境项经典组合说明
JDK8编译级别设为 1.8
Tomcat8.5 / 9不要用 Tomcat 10
MySQL5.7 或 8.05.7 配 5.1.x 驱动,8.0 配 8.0.x 驱动
IDEEclipse / IDEA按压缩包类型选择导入方式
数据库工具Navicat / DataGrip初始化和查看数据用

还要注意一个细节:很多 zip 源码里写的是 com.mysql.jdbc.Driver,那是 MySQL 5.x 时代的驱动类名。MySQL 8 驱动类名变成了 com.mysql.cj.jdbc.Driver。如果你只把驱动 jar 换新而不改类名,启动时一定会报 ClassNotFoundException。这类问题玄学得很,但本质就一句话:驱动版本、驱动类名、JDBC 连接参数三者必须匹配。后面的避坑章节我会再展开。

3. 从 DAO 到 Servlet 再到 JSP:借阅归还功能的最小可运行实现

3.1 数据库连接工具类:Class.forName 与 url 参数背后的坑

不管源码里采用 DAO 还是直接在 Servlet 里写 JDBC,第一步永远是数据库连接。常见做法是写一个 DBUtil 类,静态块注册驱动,暴露一个 getConnection 方法。下面这段是我习惯的最小实现:

package util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { // MySQL 8.x 用 com.mysql.cj.jdbc.Driver;5.x 用 com.mysql.jdbc.Driver private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/library_db" + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }

逻辑上,Class.forName 的作用是让驱动类被加载并完成注册。JDBC 4 以后其实可以省略,驱动 jar 里 META-INF/services 会自动注册,但保留这行可以避免老驱动不识别的问题,也方便排查“驱动不存在”的报错。URL 里的参数基本是必需品:useSSL=false 是为了避免 MySQL 8 报 SSL 连接告警;serverTimezone=Asia/Shanghai 是为了解决驱动要求指定时区的问题;characterEncoding=utf8 是为了让中文从 Java 到 MySQL 不乱码。

这段代码放在 Servlet 工程里能跑通,但它有三个明显短板:连接串写死在代码里、每次请求都新建 Connection、没有连接池。课设验收不会苛求连接池,不过你可以把用户名密码抽到 src 下的 db.properties 文件里,避免答辩时被问“密码都写到代码里怎么办”。至于连接池 C3P0 或 Druid,属于加分项,放到最后一章讲。

3.2 借书逻辑:事务边界、库存扣减与重复借阅判断

借书是整个系统里最容易写错的地方。纯新手版代码往往是这样:先查余量,再减库存,最后插记录。但如果不加事务控制,第二步和第三步中间一旦抛异常,库存减了记录没插进去,数据就对不上了。我一般会在 Servlet 里演示完整的手动事务写法,代码结构如下:

@WebServlet("/borrow") public class BorrowServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); int studentId = Integer.parseInt(req.getParameter("studentId")); int bookId = Integer.parseInt(req.getParameter("bookId")); String dueDate = req.getParameter("dueDate"); // 形如 2025-06-30 Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 查询并锁定该书库存行 PreparedStatement ps1 = conn.prepareStatement( "SELECT remain_count FROM book WHERE id=? AND status=1 FOR UPDATE"); ps1.setInt(1, bookId); ResultSet rs = ps1.executeQuery(); if (!rs.next() || rs.getInt("remain_count") <= 0) { conn.rollback(); resp.sendRedirect("bookList.jsp?msg=no_stock"); return; } // 2. 检查该学生是否已借此书且未还 PreparedStatement ps2 = conn.prepareStatement( "SELECT id FROM borrow_record WHERE student_id=? AND book_id=? AND status=0"); ps2.setInt(1, studentId); ps2.setInt(2, bookId); if (ps2.executeQuery().next()) { conn.rollback(); resp.sendRedirect("bookList.jsp?msg=already_borrowed"); return; } // 3. 条件更新库存,防止并发超借 PreparedStatement ps3 = conn.prepareStatement( "UPDATE book SET remain_count = remain_count - 1 WHERE id=? AND remain_count > 0"); ps3.setInt(1, bookId); if (ps3.executeUpdate() != 1) { conn.rollback(); resp.sendRedirect("bookList.jsp?msg=no_stock"); return; } // 4. 插入借阅记录 PreparedStatement ps4 = conn.prepareStatement( "INSERT INTO borrow_record(student_id, book_id, borrow_time, due_time, status) " + "VALUES(?, ?, NOW(), ?, 0)"); ps4.setInt(1, studentId); ps4.setInt(2, bookId); ps4.setString(3, dueDate + " 23:59:59"); ps4.executeUpdate(); conn.commit(); resp.sendRedirect("borrowList.jsp?msg=success"); } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } resp.sendRedirect("error.jsp?msg=" + URLEncoder.encode(e.getMessage(), "UTF-8")); } finally { // 依次关闭 PreparedStatement 和 Connection } } }

这里有两个关键参数容易忽略。第一个是 FOR UPDATE,它给 book 表对应行加了行级锁,两个学生同时借同一本书时,后一个请求会等前一个事务结束,避免“查询时显示还有一本,插入时却发现没有了”。第二个是第 3 步的 UPDATE 条件,剩余库存大于 0 才会真的执行扣减,用 executeUpdate 返回的行数判断是否成功,这比再一次 SELECT 更可靠。dueDate 从页面传来,拼成“当天 23:59:59”可以避免逾期计算差一天的问题。

finally 块里关资源的顺序也有讲究:先关 ResultSet,再关 PreparedStatement,最后关 Connection。很多老代码只关连接不关语句,连接池场景下会耗尽连接。如果你的源码里没有这段事务控制,建议补上,这是答辩时“你如何避免超借”的标准答案。

3.3 还书逻辑:状态翻转与归还时间

还书相对简单,但同样要放在事务里。核心动作是把 borrow_record 的 status 从 0 改成 1,同时把 book 表的 remain_count 加回去。顺序不能反,否则归还动作完成一半时,库存多了一本但记录还处于借出状态。参考实现:

@WebServlet("/returnBook") public class ReturnServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); int recordId = Integer.parseInt(req.getParameter("recordId")); Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 查询借阅记录,锁定记录行 PreparedStatement ps1 = conn.prepareStatement( "SELECT book_id FROM borrow_record WHERE id=? AND status=0 FOR UPDATE"); ps1.setInt(1, recordId); ResultSet rs = ps1.executeQuery(); if (!rs.next()) { conn.rollback(); resp.sendRedirect("borrowList.jsp?msg=record_not_found"); return; } int bookId = rs.getInt("book_id"); // 更新借阅记录状态 PreparedStatement ps2 = conn.prepareStatement( "UPDATE borrow_record SET status=1, return_time=NOW() WHERE id=? AND status=0"); ps2.setInt(1, recordId); ps2.executeUpdate(); // 归还库存 PreparedStatement ps3 = conn.prepareStatement( "UPDATE book SET remain_count = remain_count + 1 WHERE id=?"); ps3.setInt(1, bookId); ps3.executeUpdate(); conn.commit(); resp.sendRedirect("borrowList.jsp?msg=return_ok"); } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } resp.sendRedirect("error.jsp?msg=" + URLEncoder.encode(e.getMessage(), "UTF-8")); } finally { // 关闭资源 } } }

还书接口有一个容易被忽视的边界:如果同一本借阅记录被两个浏览器标签页同时提交还书,PS2 的 UPDATE 条件 status=0 能保证只有一条记录真正被更新,第二个请求更新的行数为 0,事务回滚,不会出现库存被加两次的情况。这才是真正能扛住误操作的写法。虽然课设验收通常不会真的并发测试,但你在文档里写清楚这层保护,和只写“点击还书按钮”是完全不同的完成度。

3.4 JSP 列表页:列表循环与状态回显

页面端不需要太多炫技,但“借书表单怎样带参数、状态怎样回显”值得统一设计。下面是一个图书列表页的核心片段,用 JSTL 循环输出,并内嵌借书表单:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <body> <table border="1"> <tr><th>书名</th><th>作者</th><th>剩余</th><th>状态</th><th>操作</th></tr> <c:forEach items="${bookList}" var="book"> <tr> <td>${book.title}</td> <td>${book.author}</td> <td>${book.remainCount}</td> <td> <c:choose> <c:when test="${book.status == 1 && book.remainCount > 0}">可借</c:when> <c:otherwise>不可借</c:otherwise> </c:choose> </td> <td> <form action="borrow" method="post" style="margin:0"> <input type="hidden" name="studentId" value="${sessionScope.student.id}" /> <input type="hidden" name="bookId" value="${book.id}" /> <input type="hidden" name="dueDate" value="<%= new java.text.SimpleDateFormat("yyyy-MM-dd").format( new java.util.Date(System.currentTimeMillis() + 30L*24*60*60*1000)) %>" /> <button type="submit">借阅</button> </form> </td> </tr> </c:forEach> </table> </body> </html>

这段 JSP 的要点在于表单传参。借阅动作只需要三个参数:学生 ID、图书 ID、应还日期。学生 ID 从 session 里取,避免每个页面都隐藏一个可篡改的用户 ID;duedate 用 JSP 表达式算了 30 天后的日期,这在课设阶段是常见做法,但更好的方式应该是在 Servlet 里提前算好再放进 request scope,JSP 只负责展示。JSTL 的 c:forEach 比手写 for 循环加 out.println 清晰得多,前提是 WEB-INF/lib 下要有 jstl 和 standard 两个 jar 包,否则页面会报错。

4. 让下载到的项目跑起来:导入、编译、部署与 MySQL 初始化

4.1 先用压缩包内结构判断是不是 Maven 工程

拿到 zip 源码后,我建议你先看一眼根目录,再决定导入方式。判断逻辑很简单:有 pom.xml 是 Maven 工程,整个过程围绕 Maven 依赖下载和打包展开;没有 pom.xml 而是 .classpath/.project 或 WebContent 目录,就是传统的 Eclipse 动态 Web 工程或 IDEA Web 工程。两种结构导入方式完全不同,硬套会白白浪费时间。

传统非 Maven 项目在 IDEA 里的导入方式是:File -> Open 选择解压后的目录,确认它是 Web 项目后,在 Project Structure 里把 src 标记成 Sources,把 WebContent 或者 src/main/webapp 标记成 Web Resource Directory,再给 Artifacts 配置一个 exploded war。Eclipse 更简单,直接 Import -> Existing Projects into Workspace,但要注意服务器要选 Tomcat 8/9,不要选 Tomcat 10。

Maven 项目就省事很多,但你要先确认依赖能不能拉到。常见问题是中央仓库访问慢,或者镜像设置不正确。我一般会在 pom.xml 里加上阿里云镜像仓库配置,然后执行 mvn clean install。如果编译报 package javax.servlet 不存在,说明 pom.xml 里缺少 servlet-api 的 provided 依赖,补一段依赖声明即可。新手经常犯的一个错误是双击 index.jsp 直接在浏览器里打开,那不是运行项目,那只是看静态页面。必须把 war 包部署进 Tomcat,通过 http://localhost:8080/项目名 访问。

4.2 Tomcat 部署路径与 lib 目录里的 mysql-connector 版本

Servlet 项目运行机制是“编译 -> 打成 war/exploded war -> 部署到 Tomcat”。IDEA 配置 Tomcat 时,Deployment 里加 Artifact,Application context 建议改成 /,这样访问时不带项目名;如果项目里大量硬编码了 /library 或 /books 这样的绝对路径,那 Application context 必须和硬编码保持一致,否则页面跳转全部 404。这也是一个经常查半天才发现的问题。

lib 目录是另一个重灾区。非 Maven 项目把第三方 jar 放在 WEB-INF/lib 下,很多下载下来的源码已经替你放好了,但版本却是老的。要检查 mysql-connector-java 的版本和 MySQL 实际版本是否匹配,最简单的命令是查看 jar 包里的驱动类名:

jar tf mysql-connector-java-8.0.33.jar | grep Driver.class

这个命令会列出 jar 包中所有 Driver 相关类。能看到 com/mysql/cj/jdbc/Driver.class,说明是 MySQL 8 驱动;如果只有 com/mysql/jdbc/Driver.class,说明是 MySQL 5 时代的驱动。驱动类名必须和代码里的 Class.forName 完全一致。此外,如果 WEB-INF/lib 下同时存在 servlet-api.jar,且 Tomcat 的 lib 里也有一份,可能会抛出奇怪的安全异常,解决办法是把项目里的 servlet-api.jar 删除,因为 Servlet 容器会提供这个 API。

4.3 初始化 MySQL:字符集、时区与连接配置核对

数据库初始化顺序错了,后面全是白搭。先从 SQL 脚本建库开始,再执行建表语句。很多项目 zip 里带了一个 library_db.sql,里面是建库和插入测试数据的完整脚本,用命令行执行即可:

mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p library_db < library_db.sql

创建数据库时指定 utf8mb4 是一件值得养成习惯的事情。utf8mb4 是 UTF-8 的完整实现,能存生僻字和特殊字符,而 MySQL 的 utf8 只是 utf8mb3,遇到罕见字会变成乱码。默认字符集错了,后面加再多的 characterEncoding=utf8 都没用。

连接配置在非 Maven 项目里通常写在一个 .properties 文件里,或者直接写在 DBUtil 里头。如果把配置抽出来,常见的文件内容如下:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/library_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=123456

allowPublicKeyRetrieval=true 是 MySQL 8 经常需要的参数,它配合 caching_sha2_password 认证插件使用,否则某些版本驱动会报 Public Key Retrieval is not allowed。如果不确定,把参数加上通常不会影响 MySQL 5.7。注意,properties 文件的等号两边不要加空格,value 里不要加引号,很多解析工具不会自动去掉引号。

4.4 日志是把项目跑起来的唯一证据

环境配完,点下 Tomcat 启动按钮,控制台突然刷出一大片红字,这种场景我经历过太多。别慌,先看日志再动手改。Tomcat 的日志在 logs 目录下,Linux 上主要是 catalina.out,Windows 上分布在 logs 文件夹里。启动阶段的问题看 catalina.out,访问阶段的 500 错误看 localhost.yyyy-MM-dd.log。常用排查命令:

tail -n 100 /path/to/tomcat/logs/catalina.out | grep -i "exception"

grep 出来的第一行异常往往才是真正的根因,后面的 Caused by 才是精确位置。比如看到 java.sql.SQLException: Access denied for user,第一反应不是改代码,而是核对用户名密码;看到 Connection refused,检查 MySQL 服务有没有启动,端口是不是默认的 3306。很多“翻车”现场都不是代码逻辑问题,而是服务没起、端口被占、驱动没放对位置。养成先看日志再动手的习惯,能省掉一半调试时间。

5. 避坑手册:运行这套系统最常见的五类翻车现场

这一节写的都是我没少帮人擦过的真实问题,每条按“现象 -> 原因 -> 解决”的顺序说清楚。如果你的项目也卡在这些地方,照着处理基本都能过。

5.1 现象:访问页面报 500,日志显示 ClassNotFoundException: com.mysql.jdbc.Driver

原因基本是三种:WEB-INF/lib 下没放 MySQL 驱动 jar;代码里写的是 MySQL 5 的驱动类名 com.mysql.jdbc.Driver,但实际依赖是 MySQL 8 的驱动;或者 IDE 没有把 lib 下的 jar 打包进 Artifact。前两个好理解,第三个最具迷惑性,代码和 jar 都在,但运行时 Tomcat 没加载到。

解决步骤是:先确认 lib 里有 jar 包,再确认驱动类名和 jar 版本匹配,最后检查 IDEA/Eclipse 部署时 Artifact 里是否包含 lib。IDEA 中可以在 Artifacts 页面看 Available Elements,确认 WEB-INF/lib 被带进去。做了这三步,此类问题基本就能解决。顺便说一句,不要同时放两个版本的 mysql-connector jar,驱动类冲突会报其他更诡异的错误。

5.2 现象:插入数据库的中文变成问号,控制台打印却能正常显示

这是 servlet+jsp+mysql 项目最容易踩的坑之一。控制台正常说明 Java 内存里字符串没问题,数据库里变问号说明问题出在传输层或存储层。常见原因有三个层次:数据库或表本身的 charset 不是 utf8mb4;JDBC 连接串缺少 characterEncoding;请求参数没有被正确解码。

解决要分三层同时做。第一层,确认库表字符集,执行 SHOW CREATE TABLE book,看到 CHARSET=utf8mb4 才行。第二层,连接串里加上 characterEncoding=UTF-8。第三层,在每个 doPost 开头调用 req.setCharacterEncoding("UTF-8"),并且在过滤器中对所有请求统一处理。一个更省事的方案是直接加 Filter,代码如下:

@WebFilter("/*") public class EncodingFilter implements Filter { 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); } }

过滤器的关键作用是在请求进入 Servlet 之前完成编码设置。如果 Filter 已经配置了编码,Servlet 内部可以不再写 setCharacterEncoding,但重复写也不会有副作用。这套组合拳打下来,中文乱码基本能绝迹。

5.3 现象:登录成功跳到主页,一刷新又弹回登录页

这个问题的根源几乎都在 session。登录成功了但没有把用户对象放进 session,或者 session 里存的东西在下次请求时取不到;另一种可能是过滤器拦截了所有路径,每次都会检查一个能拿到的“登录标记”,但因为跳转后请求路径变了,导致标记没有被带上。比如登录后 sendRedirect 到 /bookList.jsp,过滤器只放行了 /login,那必然被拦回去。

解决思路是把用户信息统一放在 session 里,过滤器判断 session 中是否有指定属性。登录成功的代码写成:

HttpSession session = request.getSession(); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60);

过滤器里不要用 request.getSession(),因为这会创建一个新的空 session,要写成 request.getSession(false),取不到就说明没登录。同时检查过滤器映射路径,如果是 /*,必须放行登录路径和静态资源,否则登录页面都打不开。还有一个细节,有些项目配置了多个 Context 或加了项目前缀,跳转时绝对路径写错也会出现“看似没登录”的现象,这种情况按 404 和 302 一起抓包看。

5.4 现象:借书成功后库存没变,或者库存变成负数

原因分两类。一类是借书时根本没有扣减库存,只是插了一条 borrow_record;另一类是代码里先 SELECT remain_count 判断大于 0,再 UPDATE remain_count = remain_count - 1,两个操作之间没有锁,并发时两个人同时读到余量 1,于是都通过检查,最后库存变成 -1。后者在课设演示时不容易出现,但答辩追问“多个人同时借最后一本书怎么办”时,答不上来就很扣分。

解决方法是改成条件更新:UPDATE book SET remain_count = remain_count - 1 WHERE id = ? AND remain_count > 0,然后判断返回值是否等于 1。等于 1 说明扣减成功,等于 0 说明没有可借库存。再配合前面讲的事务和 FOR UPDATE,就能把超借问题从根上堵住。如果发现库存负数的问题已经存在,先手动 UPDATE 修数据,再补业务代码,不要直接在页面里改显示逻辑。

5.5 现象:源码里带的文档报告和数据表对不上,答辩被追问后冷场

很多 zip 里带的文档报告是通用模板,表名、字段名、功能模块图和实际代码不是一个版本。答辩老师最喜欢做的事就是随手翻一页文档,问你“这个表在哪”,一旦对不上,整个项目的可信度会打折扣。解决方法是拿到源码后主动做一次“文档逆向对齐”。把真实数据库脚本里的每张表的字段列表整理成数据字典,再和文档里的 SQL 对照,不一致的地方以源码为准。不要嫌麻烦,这一步等于给自己准备了一面镜子,面试官问什么你都能指到具体代码位置。

6. 从 60 分到 80 分:四个不改架构的增强点与验收清单

一个能跑但没什么亮点的图书管理系统,想从课设堆里脱颖而出,不需要引入 Spring Boot,也不必重写前端,只需在现有 servlet+jsp+mysql 骨架上加四个实用功能。

第一个增强是超期计算与状态自动更新。还书时比较 return_time 和 due_time 已经能判断是否超期,但“未还但已超期”的记录怎么显示?可以写一个定时任务,每天扫描一次 borrow_record,把 due_time 小于当前时间且 status=0 的记录更新为 status=2。注意,这一步不要在 JSP 查询时现算后 UPDATE,那会让每个用户访问页面时都触发一次写操作。改成 Tomcat 的 ServletContextListener,在应用启动时启动一个守护线程,每天凌晨执行一次,既体现你对服务器资源的理解,又不会影响正常请求。

第二个增强是列表分页。图书量大了以后,一次渲染上千行会让页面卡顿,答辩演示时也显得不专业。分页最常见的写法是 LIMIT 加偏移量:

SELECT COUNT(*) FROM book WHERE status = 1; SELECT * FROM book WHERE status = 1 ORDER BY id LIMIT 10 OFFSET 0;

第一个 SQL 拿到总记录数,算出一共几页;第二个 SQL 取当前页的数据。注意 LIMIT 和 OFFSET 的参数不要直接拼字符串,一定要用 PreparedStatement 的 setInt。JSP 里渲染页码列表时,当前页高亮,上一页下一页按钮各自带上 page 参数。这个功能代码量不大,但能让你的项目在“可用性”上明显拉开差距。

第三个增强是热门图书统计。借阅排行是最容易出彩的报表需求,一条 SQL 就能搞定:

SELECT book_id, COUNT(*) AS borrow_count FROM borrow_record GROUP BY book_id ORDER BY borrow_count DESC LIMIT 10;

把结果在 Servlet 里封装成 List 放进 request,JSP 用一个表格展示排名和借阅次数。表面上看是个查询功能,但你实际上用到了 GROUP BY 和聚合函数,文档和答辩都有的讲。第四个增强其实不是功能,而是验收习惯:每次改动后,按“登录 -> 借书 -> 还书 -> 再借同一本 -> 查看记录”的顺序完整走一遍,每个步骤截一张图,这是你文档报告里最有说服力的证据。

我带过的学生里,不少人都有一个共同问题:项目是自己写出来的,但被问“你怎么验证它是对的”时就卡住了。后来我养成一个习惯,所有功能改完,先不着急写文档,拿边界数据去测:借同一本书两次、还一本已经还过的书、借余量为 0 的书,把这些“翻车”操作的结果全部记录下来。这才是这套系统最真实的质量证明。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询