Java+JSP+MySQL图书管理系统:从JDBC到Tomcat部署全解析

发布时间:2026/9/11 23:18:28
Java+JSP+MySQL图书管理系统:从JDBC到Tomcat部署全解析
简介面向Java Web初学者的完整图书管理系统项目以后台Java业务逻辑、JSP动态页面和MySQL数据存储为主线涵盖用户登录、图书检索、借阅归还等典型功能流程适合课程设计、毕业设计或自学练手。压缩包大小约4.04MB目前已有7143人学习下载。项目中可看到基于MVC的分层设计Servlet与JavaBean负责业务处理JSP配合JSTL简化页面渲染并涉及Tomcat容器部署、MySQL表结构设计、SQL增删改查与事务处理。在数据库层面通过合理的表结构设计存储图书、用户和借阅记录并通过事务保证数据一致性在安全方面使用加密算法保存密码、注意SQL注入防护同时配合Bootstrap前端框架提升界面质量此外还包含错误处理与日志记录、测试与部署思路方便排查与维护。这些内容覆盖Web开发从设计、编码到部署测试的完整链路能让读者在实操中理解请求处理和工作原理也便于直接应用到课程设计或毕业设计中。1. JavaJSPMySQL 搭建 Web 图书管理系统老技术为什么还值得写一遍每年毕业设计和 Java 学习路线里JavaJSPMySQL 图书管理系统都排在检索量前列。没有 Spring Boot 的统一约定没有 MyBatis 帮你生成 SQLJSP 负责页面渲染、Servlet 接收请求、Java 类操作数据库这三层全靠手工拼起来反而能把一个 Web 请求从浏览器到数据库再返回的完整路径看得一清二楚。这套组合特别适合两类人一类是准备基于 Java 的图书管理系统毕业设计的学生需要能答辩、能演示、能讲清楚原理的项目另一类是熟悉框架但没摸过底层细节的开发者想借一个最小 Web 工程补上 HTTP、Session、JDBC 这些基本功。下面按表结构、代码闭环、部署排错、面试加分的顺序展开照着搭完就是一个能跑的完整系统。2. MySQL 表结构设计决定 JSP 页面好不好写2.1 图书、用户、借阅三张表的建表 SQL动手写第一行 JSP 之前先把 MySQL 里的表结构定下来。图书管理系统的业务边界不复杂管理员维护图书用户登录后查书、借书、还书。至少需要三张表来支撑book 存图书信息users 存账号borrow 存借还记录。用户和图书是多对多关系借阅记录独立成表避免在用户表里塞冗余字段。实体关系对应着 ER 图里的三个实体、两条连线答辩时画图正好用得上。CREATE DATABASE libsys DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE libsys; CREATE TABLE book ( book_id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(100) NOT NULL, author VARCHAR(50), category VARCHAR(30), publisher VARCHAR(50), stock INT NOT NULL DEFAULT 0, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT图书表; CREATE TABLE users ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, user_role TINYINT NOT NULL DEFAULT 1 COMMENT 1读者 2管理员, real_name VARCHAR(30) ) ENGINEInnoDB COMMENT用户表; CREATE TABLE borrow ( borrow_id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, borrow_time DATETIME NOT NULL, return_time DATETIME DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0借出 1已还, CONSTRAINT fk_borrow_user FOREIGN KEY (user_id) REFERENCES users(user_id), CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book(book_id) ) ENGINEInnoDB COMMENT借阅表;字符集全部使用 utf8mb4 而不是老项目里常见的 utf8是因为 MySQL 8 的默认字符集就是 utf8mb4中文、英文、emoji 都能存排序规则 utf8mb4_unicode_ci 处理中英文混合数据时表现更稳定。图书的 isbn 字段加上 UNIQUE 约束防止同一本书被重复录入。stock 库存字段设计成整型而不是每次去 count 借阅记录借书时直接对 stock 做原子扣减后面代码里会看到这个字段怎么写才能避免超借。password 字段长度设为 64对应 SHA-256 十六进制摘要的长度为第 5 章要做的密码加密预留空间。外键约束在 InnoDB 下生效批量导数据时外键会影响导入性能但毕业设计这个量级完全不需要担心。生产环境很多团队刻意不建外键靠应用层保证一致性面试被问到外键利弊时要能说清这层权衡。建完表后用 MySQL Workbench 执行一次脚本再插入几本测试图书后面调试页面就有数据可看。2.2 JDBC 连接 MySQL 8 的驱动参数与连接池选择表结构定好接下来是 Java 侧怎么连上 MySQL。MySQL 8 的驱动类名和 URL 参数跟 5.x 版本有明显区别最常见的报错就是驱动类找不到或者时区错误。下面这组配置是验证过的写法jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/libsys?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue jdbc.usernameroot jdbc.password你的密码 jdbc.pool.maximumPoolSize10 jdbc.pool.minimumIdle2参数的含义直接决定连接是否成功逐项说明参数作用漏掉的后果useUnicodetruecharacterEncodingutf8让 JDBC 按 UTF-8 编码传输字符串页面中文全部乱码serverTimezoneAsia/Shanghai指定数据库服务器时区MySQL 8 报 CST 时区无法识别useSSLfalse本地开发关闭 SSL 加密连接变慢且控制台刷 SSL 警告allowPublicKeyRetrievaltrue允许客户端获取 RSA 公钥报 Public Key Retrieval is not allowed驱动 jar 使用 mysql-connector-java 8.0.x放到项目的 WEB-INF/lib 目录下。如果项目用 Maven 管理在 pom.xml 里添加对应依赖即可。注意 5.1.x 老驱动连 MySQL 8 会报认证插件错误所以版本要配套。Java 代码里最简单的方式是每次用 DriverManager.getConnection() 拿连接教学项目常见但每次连接都要走 TCP 握手和 MySQL 认证页面上连续操作时延迟明显。更稳妥的做法是引入连接池HikariCP 是轻量又常用的选择HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/libsys); config.setUsername(root); config.setPassword(你的密码); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(30000); HikariDataSource dataSource new HikariDataSource(config);maximumPoolSize 是池中最大连接数Tomcat 单机部署、并发不高时设为 10 足够minimumIdle 是常驻空闲连接设为 2 避免频繁创建销毁connectionTimeout 是获取连接的等待上限超过 30 秒抛异常防止极端情况下线程全部卡死在等连接上。图书管理系统这个量级不接连接池也能跑但这段代码在答辩时聊性能优化比单纯说用了我封装好的工具类要扎实得多。2.3 JSP 页面的路由约定与公用头页面结构上JavaJSP 项目的请求分两种浏览器直接访问 .jsp 文件或者访问 Servlet 地址再由 Servlet 转发到 JSP。第二种更符合 MVC 习惯。URL 规划建议如下/login渲染登录页和接收登录表单提交POST 方法处理校验/book/list查询图书列表转发到 book_list.jsp/book/addPOST 接收新增图书的表单数据/book/delete?id3按主键删除适合用 GET 携带参数/borrow/add借书操作必须用 POST避免浏览器预加载或后退重复提交每张 JSP 页面的开头都应该有统一的指令和权限判断公用代码可以抽成一个 head.jsp 再 include 进来。典型开头如下% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % c:if test${empty sessionScope.user} c:redirect urllogin.jsp/ /c:ifpage 指令中的 contentType 决定响应头的字符集pageEncoding 决定 JSP 文件本身的读取编码两者都设成 UTF-8 才能避免中文乱码。taglib 声明 JSTL 核心标签库注意 Tomcat 8 之后 JSTL 不会自动引入需要把 jstl 1.2 的 jar 放进 WEB-INF/lib否则页面上 c: 开头的标签直接报错。权限判断用 EL 表达式读取 session 里的 user 对象为空就重定向到登录页。这里用 EL 表达式判断 Session。需要注意JSTL 的 c:redirect 会返回 302 响应所以页面地址栏会变成 login.jsp符合预期。这一章把数据库和页面的契约定清楚了。下一章进入 Java 代码把点开图书列表、数据库查出数据、显示在页面上这条链路完整跑通。3. Java Servlet 与 JSP 的数据交互从列表查询到借书操作3.1 DAO 层用 JDBC PreparedStatement 查出图书列表数据库操作集中在 DAO 类里而不是散落在 Servlet 或 JSP 的 Java 小脚本中。BookDao 里查询图书列表的方法是最核心的一段以 findAll 为例public ListBook findAll() { ListBook books new ArrayList(); String sql SELECT book_id, isbn, title, author, category, publisher, stock FROM book ORDER BY book_id DESC; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { Book b new Book(); b.setBookId(rs.getInt(book_id)); b.setIsbn(rs.getString(isbn)); b.setTitle(rs.getString(title)); b.setAuthor(rs.getString(author)); b.setCategory(rs.getString(category)); b.setPublisher(rs.getString(publisher)); b.setStock(rs.getInt(stock)); books.add(b); } } catch (SQLException e) { e.printStackTrace(); } return books; }try-with-resources 语法保证了 Connection、PreparedStatement、ResultSet 三个资源在方法结束后自动关闭省去手写 finally 的样板代码。这个细节很重要用了连接池后连接不归还会让池子里的连接慢慢耗尽最终整个系统卡死。使用 PreparedStatement 而不是字符串拼接 SQL是为了让驱动对参数做预编译和转义比如书名包含单引号时不会破坏 SQL 结构这也是最基础的 SQL 注入防线。带关键字查询时占位符的写法有讲究。正确的写法是String sql SELECT book_id, isbn, title, author, stock FROM book WHERE title LIKE ?; ps.setString(1, % keyword %);百分号放在 Java 代码里拼接而不是拼进 SQL 语句这样 PreparedStatement 仍然能对 keyword 做安全处理。如果写成LIKE %keyword%就算用了 PreparedStatement 也等于没防。3.2 Servlet 把数据放进 requestJSP 用 JSTL 渲染表格浏览器请求先进 ServletServlet 调用 DAO 拿到数据再通过 forward 转发给 JSP 渲染。这是标准的 Model 2 写法。BookListServlet 的 doGet 方法如下WebServlet(/book/list) public class BookListServlet extends HttpServlet { private BookDao bookDao new BookDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String keyword req.getParameter(keyword); ListBook books; if (keyword ! null !keyword.trim().isEmpty()) { books bookDao.findByTitle(keyword.trim()); } else { books bookDao.findAll(); } req.setAttribute(bookList, books); req.getRequestDispatcher(/WEB-INF/jsp/book_list.jsp).forward(req, resp); } }这里的方法规定doGet 只负责查询和页面跳转新增、删除、借书这类写操作放到 doPost 里。forward 是服务器内部转发浏览器地址栏 URL 不会变化request 里 setAttribute 的属性在转发后的 JSP 里能直接读取。与之对应的是 sendRedirect那是 302 跳转浏览器会重新发起一次请求request 里的属性全部丢失所以携带数据必须用 forward或者把数据放进 session。JSP 拿到数据后渲染表格的核心代码table border1 trthISBN/thth书名/thth作者/thth分类/thth库存/thth操作/th/tr c:forEach items${bookList} varbook tr td${book.isbn}/td td${book.title}/td td${book.author}/td td${book.category}/td td${book.stock}/td tda hrefbook/delete?id${book.bookId} onclickreturn confirm(确定删除吗?)删除/a/td /tr /c:forEach /tablec:forEach 的 items 绑定的是 request 作用域里的 bookListvarbook 定义循环变量EL 表达式 ${book.title} 实际调用的是 Book 类的 getTitle() 方法。页面放在 WEB-INF/jsp 目录下浏览器无法直接访问必须通过 Servlet 转发才能渲染这个约束天然地保证了权限控制的兜底。删除操作放在 GET 请求上是为了演示方便实际项目中删除是修改数据的操作应该改用 POST。3.3 借书流程里的参数校验与库存原子扣减借书操作涉及两张表的变更先减图书库存再插一条借阅记录两个动作必须作为一个整体考虑。BorrowServlet 的 doPost 方法给出了不做事务时的写法WebServlet(/borrow/add) public class BorrowServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int userId Integer.parseInt(req.getParameter(userId)); int bookId Integer.parseInt(req.getParameter(bookId)); BookDao bookDao new BookDao(); BorrowDao borrowDao new BorrowDao(); if (!bookDao.decreaseStock(bookId)) { req.setAttribute(msg, 库存不足); req.getRequestDispatcher(/WEB-INF/jsp/error.jsp).forward(req, resp); return; } if (!borrowDao.insertBorrow(userId, bookId)) { bookDao.increaseStock(bookId); req.setAttribute(msg, 该用户已借阅此书); req.getRequestDispatcher(/WEB-INF/jsp/error.jsp).forward(req, resp); return; } resp.sendRedirect(book/list); } }BookDao 里扣减库存的方法值得单独写一下它是防超借的关键public boolean decreaseStock(int bookId) { String sql UPDATE book SET stock stock - 1 WHERE book_id ? AND stock 0; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, bookId); return ps.executeUpdate() 1; } catch (SQLException e) { e.printStackTrace(); return false; } }SQL 里的AND stock 0把库存判断和扣减合并成一条原子语句。UPDATE 执行时会对命中行加行级锁两个请求同时进来也只能有一个把库存从 1 扣到 0另一个因为条件不满足影响行数为 0返回 false。如果用先 SELECT stock 再决定是否 UPDATE的写法并发下两个请求可能同时读到库存为 1造成超借。插入借阅失败后调用 increaseStock 回补是补偿式的写法第 5 章会用数据库事务给出更完整的方案。Integer.parseInt 在参数不是数字时会抛 NumberFormatException所以 Servlet 入口最好做一层预处理正则\\d先过滤或者用 try-catch 包住后跳转到错误页。表单里还可以加一个 hidden 字段 carry token防止双击提交或刷新页面重复借书这也是成熟系统防重复提交的常用手段。4. 部署到 Tomcat 后JSPMySQL 最常见的 6 个报错4.1 从 ClassNotFoundException 到 JSP 修改不生效本地 IDEA 里能跑通不代表把 war 包丢到 Tomcat 上一定能跑。开发环境和服务器环境的差异会引出一批经典报错先给对照表报错信息或现象根本原因解决方式ClassNotFoundException: com.mysql.cj.jdbc.Driver驱动 jar 没有打进 WEB-INF/lib检查 war 包里 lib 目录是否有 jarAccess denied for user rootlocalhost数据库账号或密码不对用 MySQL 客户端确认凭据Public Key Retrieval is not allowedJDBC URL 缺少 allowPublicKeyRetrieval连接串加上该参数The server time zone value CST is unrecognized缺少 serverTimezone 参数URL 加 serverTimezoneAsia/Shanghai页面中文变成问号响应、请求、数据库字符集不一致按第 2 章统一成 UTF-8修改的 JSP 刷新后不生效Tomcat 没有重新编译 JSP删除 Tomcat 的 work 目录后重启最后一条JSP 改了不生效很多人遇到过。Tomcat 会把 JSP 编译成 Java 再编译成 class缓存在 work/Catalina/localhost 目录下。有些情况下 JSP 文件时间戳没变化或缓存策略生效服务器还在用旧的 class。删除 work 目录再重启是干净的强制重新编译方式生产环境不要频繁这么干但开发阶段算是最快的一招。4.2 404 和 500 的排查顺序部署完成后打开系统最常见的两个状态码是 404 和 500。看到 404 先分清 URL 访问的是 JSP 文件还是 Servlet 映射排查路径完全不同404 且 URL 以 .jsp 结尾页面文件不在 webapps 对应目录下直接检查部署目录结构war 包解压后是否包含该页面。404 且 URL 是 /book/list 这种路径Servlet 的 WebServlet 注解没有生效或者类没有被编译进 classes 目录。IDEA 打 war 包时要确认 Artifacts 里已经把编译后的 class 和 lib 打进去。500 且堆栈第一行是 ClassNotFoundException缺依赖 jar优先检查驱动和 JSTL。500 且出现 SQLSyntaxErrorExceptionSQL 与表结构不匹配最常见的起因是把 5.x 时代的建表脚本导入 MySQL 8比如 typeInnoDB 这种写法已经不再支持必须改成 ENGINEInnoDB。除了看 Tomcat 的 logs/localhost 日志还有一个快速定位手段在 web.xml 中配置全局错误页让异常信息直接显示在浏览器上。开发阶段这样能少翻很多日志注意上线前要删掉error-page exception-typejava.lang.Throwable/exception-type location/error.jsp/location /error-pageerror.jsp 里用${pageContext.exception.message}输出异常消息。javax.servlet 的 exception 隐式对象能拿到异常链信息包括具体是哪个 Servlet、哪一行 SQL 出的问题。这个配置只放在开发环境上线前移除否则 SQL 语句和表结构细节会暴露给访问者安全上不合规。4.3 部署目录、环境变量和 MySQL 版本核对把项目打成 war 包丢进 Tomcat 的 webapps 目录Tomcat 启动时自动解压。如果之前部署过同名应用先停 Tomcat、删除旧 war 和解压目录再放新的否则可能加载到旧 class 或出现文件占用冲突。服务器上若没有配置 JAVA_HOME 环境变量Tomcat 启动脚本会直接报找不到 java 命令这是部署到新机器时的常见翻车点先执行java -version确认环境。MySQL 版本差异也需要核对。本机 MySQL 8、服务器 MySQL 5.7 这样混搭很常见。驱动用 8.0.x 去连接 5.7 是向下兼容的反过来用 5.1.x 驱动连 MySQL 8会报 Unable to load authentication plugin caching_sha2_password因为 MySQL 8 默认的认证插件是 caching_sha2_password旧驱动不认识。总的原则是驱动版本不低于 MySQL 版本直接用与 MySQL 8 配套的驱动最省事。5. JavaJSP 图书管理系统的 3 个面试加分点事务、加密、分页5.1 借书操作用数据库事务包起来第 3 章里先减库存失败再回补的代码在单用户场景没问题但并发借书时存在竞态窗口。数据库事务能把两个写操作串成原子动作Connection conn DBUtil.getConnection(); conn.setAutoCommit(false); try { String sql1 UPDATE book SET stock stock - 1 WHERE book_id ? AND stock 0; try (PreparedStatement ps conn.prepareStatement(sql1)) { ps.setInt(1, bookId); if (ps.executeUpdate() ! 1) { throw new SQLException(库存不足); } } String sql2 INSERT INTO borrow(user_id, book_id, borrow_time, status) VALUES(?, ?, NOW(), 0); try (PreparedStatement ps conn.prepareStatement(sql2)) { ps.setInt(1, userId); ps.setInt(2, bookId); ps.executeUpdate(); } conn.commit(); } catch (SQLException e) { conn.rollback(); e.printStackTrace(); } finally { conn.setAutoCommit(true); conn.close(); }setAutoCommit(false) 之后两条 SQL 要么一起提交要么一起回滚。这里 finally 里要把连接状态复位再归还否则连接池中的连接会带着事务状态影响下一个使用者。答辩时把这个和 3.3 的补偿写法对比着讲就是很完整的发现问题到优化的经历。5.2 密码不能明文落库users 表的 password 字段如果直接存明文数据库泄露等于账号全灭。JDK 自带 MessageDigest 加盐哈希是零依赖的做法盐值随机生成后拼在密码里算出 SHA-256 摘要数据库里存盐 摘要。校验时取出盐重新计算比较。做到这一步安全设计已经不输大多数课程设计面试聊起来也有内容。5.3 列表查询加分页图书数据过百条后一次渲染全部数据体验就开始变差。分页查询用 SQL 的 LIMIT 加偏移量SELECT book_id, isbn, title, author, stock FROM book ORDER BY book_id LIMIT ?, ?;第一个问号是偏移量 offset第二个问号是每页条数 pageSize。Servlet 接收 currentPage 和 pageSize 参数后计算 offset(currentPage-1)*pageSize再把总记录数和总页数通过 request 传给 JSP。页码导航用 c:forEach 循环输出加上 c:if 判断上一页下一页是否可点。分页做出来之后顺手把排序字段也提出来作为参数比如 ORDER BY create_time DESC查询接口的通用性会好很多。这三个点落实后系统的完成度就从能交作业提升到了能写进简历。本文还有配套的精品资源点击获取