简介这是基于Java与MySQL实现的图书管理系统完整项目面向Java Web入门者与数据库初学者目标是理解前后端交互、业务分层与关系型数据库操作。压缩包共222个文件大小约8.58MB其中以160个Java源文件为主体另含12个jar依赖包、10个class编译文件以及properties配置文件、xml配置和sql脚本等覆盖从源码到运行环境的完整资源。当前已有1468人学习下载。项目采用ServletJSPMVC模式结合JDBC API实现图书添加、查询、借阅、归还、用户管理等核心功能内含数据库建表脚本和c3p0连接池配置便于直接导入部署。通过研读代码可掌握PreparedStatement防注入用法、数据库连接参数配置、MVC分层协作方式及依赖管理技巧适合作为课程设计、毕业设计或第一份完整Web项目的实战参考。1. 图书管理系统JavaMySQL为什么这个经典项目值得认真做一遍图书管理系统是 Java 后端开发里出现频率最高的练手项目没有之一。不管是在校课程设计还是转行简历上的第一个项目大概率都是它。很多人觉得它简单无非是对图书信息的增删改查外加一个借还书的按钮。但真正把 Java 和 MySQL 打通之后你会发现这个系统恰好把 JDBC 操作、事务边界、连接池配置、Servlet 请求映射和数据库表关系设计全都串起来了每一个环节都是实际工作中每天要面对的东西。我帮人排查过不少类似的作业项目功能页看着齐全代码量也有几千行但一上生产环境就暴露问题中文乱码、连接被断开、借书时并发超借、数据库表设计冗余到没法加新需求。这些问题不是某个功能写法不对而是整个项目在技术选型和数据模型上就没立住。本篇文章按我平时搭这类系统的顺序来写先定技术方案再设计数据库最后编码、避坑、上线验证。2. 技术选型ServletJSP 还是 Spring Boot先想清楚再动手2.1 两种主流方案的对比与选择标准做图书管理系统第一件事不是写代码而是决定用哪一套 Web 技术栈。最常见的两个选项传统 Servlet JSP以及 Spring Boot MyBatis/Spring Data JPA。我给某公司维护过一个内部图书借阅小系统用的就是 Servlet JSP 方案后来给另一个团队做课程设计辅导时对方坚持用 Spring Boot两个方案各有各的取舍。两者核心差别在于Servlet JSP 走的是 Java 原生 Web 规范请求由 Servlet 接收页面由 JSP 动态渲染Spring Boot 则内置了 Tomcat默认推荐 REST 接口 前端模板或前后端分离。对图书管理系统这个体量数据库三张表、页面十来个Servlet JSP 完全够用而且对底层的理解更透彻。Spring Boot 的优势是开发效率高、配置简洁但很多新手写 Boot 项目容易绕开 JDBC 细节出了问题反而看不懂日志。对比维度Servlet JSPSpring Boot MyBatis请求处理方式Servlet 类手动继承 HttpServletController 注解方法数据访问JDBC / 连接池MyBatis 或 Spring Data前端渲染JSP 标签库 ELThymeleaf 或前后端分离入门门槛低但要理解 Servlet 生命周期中需要理解自动配置学习价值看清 HTTP 请求全链路贴近主流企业开发适合场景课程设计、小型内网系统简历项目、后续扩展我的建议很直接如果是毕设或课程设计选 Servlet JSP理由是答辩时能讲清楚每一个请求怎么走进来、数据怎么查出来、页面怎么渲染如果是给简历增加项目经验且不急着深挖底层选 Spring Boot。决定之后不要中途换栈两个方案切换成本很高。2.2 JDBC 驱动与连接池配置数据访问层的底座不管上面选哪条路Java 连 MySQL 这一步绕不开。这里需要确定三个版本JDK 版本、MySQL 版本、JDBC 驱动版本。常见组合是 JDK 8 MySQL 5.7 或 8.0 mysql-connector-java 8.0.x。我一般会避开太老的驱动因为 MySQL 8.0 之后的认证插件改成 caching_sha2_password旧驱动连上去直接报认证失败。连接池我倾向 HikariCP它够轻、够快Spring Boot 2.x 之后默认也用它。Servlet 方案里手动引入 HikariCP 两个 jar 包即可。最容易被忽略的是连接池参数最大连接数设太小借还书高峰期会排队空闲超时设太短MySQL 的 wait_timeout 一回收连接池里全是失效连接。下面是一组我常用的 HikariCP 初始参数HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/library_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue); config.setUsername(root); config.setPassword(123456); config.setDriverClassName(com.mysql.cj.jdbc.Driver); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setIdleTimeout(600000); config.setMaxLifetime(1800000); config.setConnectionTimeout(30000); HikariDataSource dataSource new HikariDataSource(config);这段配置里最值得关注的是 JDBC URL 上的四个参数characterEncoding 必须和数据库字符集一致统一 utf8serverTimezone 不设置会直接报时区异常allowPublicKeyRetrieval 是 MySQL 8.0 下使用 caching_sha2_password 认证时需要的useSSL 在本地环境设 false 省去证书警告。连接池的 maxLifetime 建议比 MySQL wait_timeout 略小避免拿到被服务端断掉的连接。3. 数据库设计三张表撑起一个借阅系统但索引必须这样加3.1 表结构设计与关系分析图书管理系统的核心实体有三个图书、读者、借阅记录。很多新手会设计五张甚至八张表把图书分类单独建表、出版社单独建表结果多表联查把自己绕晕。图书管理系统不是电商系统分类用字段存字符串足够。借阅记录需要同时关联图书和读者是典型的多对多关系表主键用自增 id外键分别指到两张表。图书表里的核心字段包括书名、作者、ISBN、库存总量、当前可借数量。可借数量这个字段经常被新手忽略他们只存一个 ISBN 和册数借书时直接减总库存这会导致同一本书同名的多册混在一起无法区分。更合理的设计是维护一个总馆藏量和剩余可借量每次借书给剩余可借量减一还书加一查询时直接展示不需要实时 count。读者表字段相对简单姓名、学号或工号、联系方式、借阅状态。注意读者编号要加唯一索引因为登录和查借阅历史都靠它定位。借阅记录表要区分借出时间和归还时间还书时更新归还时间而不是删除记录这样历史借阅数据才能留存下来做统计。三张表的关系是借阅记录通过 book_id 关联图书表通过 reader_id 关联读者表。3.2 建库建表 SQL 与索引设计说明以下是我实际使用过的建表 SQL经过精简保留核心字段字符集统一 utf8mb4排序规则 utf8mb4_general_ci。utf8mb4 比 utf8 多支持 emoji 字符图书简介里偶尔会有特殊符号直接用 utf8mb4 一步到位。CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE library_db; CREATE TABLE book ( id INT NOT NULL AUTO_INCREMENT COMMENT 主键, book_name VARCHAR(128) NOT NULL COMMENT 书名, author VARCHAR(64) NOT NULL DEFAULT COMMENT 作者, isbn VARCHAR(32) NOT NULL DEFAULT COMMENT ISBN号, publisher VARCHAR(128) NOT NULL DEFAULT COMMENT 出版社, total_count INT NOT NULL DEFAULT 0 COMMENT 馆藏总量, available_count INT NOT NULL DEFAULT 0 COMMENT 当前可借数量, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 入库时间, PRIMARY KEY (id), UNIQUE KEY uk_isbn (isbn), KEY idx_book_name (book_name) ) ENGINEInnoDB COMMENT图书表; CREATE TABLE reader ( id INT NOT NULL AUTO_INCREMENT COMMENT 主键, reader_no VARCHAR(32) NOT NULL COMMENT 读者编号, reader_name VARCHAR(32) NOT NULL COMMENT 读者姓名, phone VARCHAR(20) NOT NULL DEFAULT COMMENT 联系方式, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1正常 0停用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_reader_no (reader_no) ) ENGINEInnoDB COMMENT读者表; CREATE TABLE borrow_record ( id INT NOT NULL AUTO_INCREMENT COMMENT 主键, book_id INT NOT NULL COMMENT 图书id, reader_id INT NOT NULL COMMENT 读者id, borrow_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 借出时间, return_time DATETIME DEFAULT NULL COMMENT 归还时间NULL表示未还, PRIMARY KEY (id), KEY idx_reader_id (reader_id), KEY idx_book_id (book_id), KEY idx_borrow_time (borrow_time), CONSTRAINT fk_record_book FOREIGN KEY (book_id) REFERENCES book(id), CONSTRAINT fk_record_reader FOREIGN KEY (reader_id) REFERENCES reader(id) ) ENGINEInnoDB COMMENT借阅记录表;这段设计里有一个血泪经验借阅记录表的外键一定要建。有些人为追求插入性能故意去掉外键约束但图书管理系统并发量很低外键带来的额外开销完全可以忽略。它换来的是数据完整性避免出现借阅记录指向一本不存在的书。索引方面borrow_record 上建了三个普通索引分别对应直接查一个人的借阅历史、查某本书被借过几次、按时间范围看流水这三个查询是后台最高频的操作。图书表的 uk_isbn 唯一索引也有讲究。同一本书的不同版本 ISBN 不同同 ISBN 的册数通过总量字段维护而不是插多行记录。这样借书时根据 book_id 就能精确锁定一本书不会出现三册同名书三行记录、不知道借的是哪一册的尴尬。4. 核心功能实现从工程骨架到借阅全流程的代码拆解4.1 工程结构与依赖先搭一个能跑起来的 Maven 项目如果你选择 Servlet JSP 路线工程结构建议用 Mavenwar 包形式这样一个干净的分层结构。代码分成两层Servlet 负责接收请求、调用 Service、转发页面DAO 负责 JDBC 数据访问。不要把所有逻辑堆在 Servlet 里后续加功能会越改越乱。library-management/ ├── pom.xml └── src/main/ ├── java/com/library/ │ ├── dao/BookDAO.java │ ├── dao/BorrowDAO.java │ ├── dao/ReaderDAO.java │ ├── entity/Book.java │ ├── entity/BorrowRecord.java │ ├── entity/Reader.java │ ├── servlet/BookListServlet.java │ ├── servlet/BorrowBookServlet.java │ └── util/DBUtil.java └── webapp/ ├── WEB-INF/web.xml ├── book_list.jsp ├── book_add.jsp └── reader_list.jsppom.xml 里只依赖三样东西javax.servlet-api、mysql-connector-java、HikariCP。JSP 不需要额外依赖Tomcat 自带运行环境。如果你用的是 Tomcat 9 继续用 javax.servlet 命名空间Tomcat 10 之后改成 jakarta.servlet这两者不能混用新手最容易在这里翻车。数据源初始化放在 DBUtil 静态代码块里保证整个应用只有一个连接池实例。注意 DBUtil 不要每次请求都 new HikariDataSource那是灾难性的会造成连接池不断创建、旧连接无法释放最终把 MySQL 连接数打满。4.2 数据访问层BookDAO 的查询与分页实现DAO 层的基本原则一个方法对应一条 SQL。不要写一个万能方法拼接 SQL 字符串后期维护是噩梦。以图书列表查询为例支持按书名模糊搜索和分页这是后台管理系统的标配功能。以下代码是 BookDAO 的核心方法使用 JDBC 预编译语句。public ListBook findBooks(String keyword, int pageNum, int pageSize) { String sql SELECT id, book_name, author, isbn, publisher, total_count, available_count FROM book WHERE book_name LIKE ? ORDER BY id DESC LIMIT ?, ?; ListBook list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, % keyword %); ps.setInt(2, (pageNum - 1) * pageSize); ps.setInt(3, pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Book book new Book(); book.setId(rs.getInt(id)); book.setBookName(rs.getString(book_name)); book.setAuthor(rs.getString(author)); book.setIsbn(rs.getString(isbn)); book.setPublisher(rs.getString(publisher)); book.setTotalCount(rs.getInt(total_count)); book.setAvailableCount(rs.getInt(available_count)); list.add(book); } } } catch (SQLException e) { throw new RuntimeException(查询图书列表失败, e); } return list; }这段代码有几个参数要点。LIMIT 的两个参数第一个是偏移量计算公式是 (页码 - 1) * 每页条数第二页就传 pageSize第二个是每页条数不要直接拼接数字进 SQL。LIKE 后面的关键字在 setString 时手动加上百分号而不是在 SQL 里写 CONCAT这样更直观。用 try-with-resources 能保证连接、语句、结果集都自动关闭不写 finally 块避免连接泄漏。特别注意一点返回的列表里查的是可借数量而不是库存总量加已借数量。按照第 3 章的字段设计available_count 在借书、还书事务里维护查询不需要实时计算这也是这个设计比实时 count 更省资源的地方。4.3 借书与还书事务边界必须包住两个写操作借书操作表面上是更新一条数据实际上是两个写操作往 borrow_record 插一条记录同时把 book 表的 available_count 减一。这两个操作必须在一个事务里否则会出现记录插入成功但库存没减或者库存减了但记录没插上。下面是借书 Servlet 里的事务控制代码。WebServlet(/borrowBook) public class BorrowBookServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int bookId Integer.parseInt(req.getParameter(bookId)); int readerId Integer.parseInt(req.getParameter(readerId)); String sqlBorrow INSERT INTO borrow_record (book_id, reader_id) VALUES (?, ?); String sqlUpdate UPDATE book SET available_count available_count - 1 WHERE id ? AND available_count 0; try (Connection conn DBUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps1 conn.prepareStatement(sqlBorrow)) { ps1.setInt(1, bookId); ps1.setInt(2, readerId); int inserted ps1.executeUpdate(); if (inserted ! 1) { conn.rollback(); resp.getWriter().write(借阅记录插入失败); return; } } try (PreparedStatement ps2 conn.prepareStatement(sqlUpdate)) { ps2.setInt(1, bookId); int updated ps2.executeUpdate(); if (updated ! 1) { conn.rollback(); resp.getWriter().write(库存扣减失败可能库存不足); return; } } conn.commit(); resp.sendRedirect(bookList); } catch (Exception e) { throw new ServletException(借书失败, e); } } }这段代码里最关键的是 UPDATE 语句尾部加了 available_count 0 条件这是防超借的关键。MySQL 执行更新时会对满足条件的行加锁如果库存已经是 0更新影响行数为 0借书失败。这个操作是原子性的比先 select 判断库存再 update 安全得多并发环境下后者必然出问题。另一个要点是事务必须从同一个 Connection 上获取 PreparedStatement如果从连接池里拿两次连接事务就失效了。还书逻辑与借书对称更新 borrow_record 的 return_time 为当前时间再给 book 表的 available_count 加一。还书不需要检查超期还书时把记录标记上即可。超期判断放到查询列表时做而不是还书时拦截因为还书动作本身是允许的只是需要承担逾期责任。5. 避坑与排查乱码、连接丢失和事务失效的现场记录5.1 中文乱码请求与响应两侧都要处理现象JSP 页面新增图书后数据库里存的名字变成问号或者页面上显示乱码。这个是最多新手踩的坑本质是字符编码在请求、服务端、数据库三个环节不一致。原因分析Tomcat 默认对 POST 请求按 ISO-8859-1 解码对 GET 请求按 URI 编码处理MySQL 连接串里如果没有 characterEncoding 参数JDBC 驱动按系统默认字符集转换页面本身没有声明 utf-8。这三个环节但凡有一个没对齐中文就保不住。解决统一三处编码。JSP 页面顶部加 pageEncodingServlet 里对 POST 请求在读取参数前调用 req.setCharacterEncoding(utf-8)JDBC URL 里带 characterEncodingutf8数据库表字符集用 utf8mb4。如果还乱码检查 Tomcat 的 server.xml 里 Connector 是否设置了 URIEncoding。我在代码里固定把编码设置放在 Servlet 的 doPost 第一行不要只依赖过滤器防止有人漏配。5.2 MySQL 8.0 连接失败驱动类与时区必须成对出现现象用 mysql-connector-java 5.x 版本连 MySQL 8.0 数据库启动时就报 ClassNotFoundException 或 CommunicationsException连接串明明没问题就是连不上。原因分析MySQL 8.0 默认认证插件是 caching_sha2_password老驱动不支持这个协议同时 8.0 的驱动类名从 com.mysql.jdbc.Driver 改成了 com.mysql.cj.jdbc.Driver很多人沿用旧驱动类名直接报错。serverTimezone 不设置会提示无法识别服务器时区也表现为连接失败。解决pom.xml 里统一用 8.0.x 驱动数据库连接串写成类似第 2 章给的那组完整参数。建议把下面三样看成固定搭配不要分别调整驱动类名用 com.mysql.cj.jdbc.DriverURL 里带 serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue。5.3 连接丢失等待时间超过 MySQL wait_timeout现象系统刚开始用正常隔一晚上第二天第一次点击查询就报连接超时或 Connection is not available多刷新几次又好了。原因分析MySQL 默认 wait_timeout 是 8 小时空闲连接超过这个时间被服务端断开连接池不知道连接已失效取出这个连接再去执行 SQL 自然失败。这是连接池使用中非常典型的玄学问题白天测试永远测不出来线上第二天必现。解决连接池配置里把 maxLifetime 设得比数据库 wait_timeout 短比如数据库设 8 小时连接池 maxLifetime 设 30 分钟同时设置 connection-test-query 为 SELECT 1。HikariCP 可以配置 connectionTestQuery每次获取连接时校验有效性成本可忽略。不要用 MySQL 端把 wait_timeout 调成很大来逃避问题连接失效是常态让连接池去适配才是正解。5.4 借书成功但库存没扣事务没生效现象接口正常返回成功日志也没报错但查询图书列表时可见数量没有减少甚至变成了负数。原因分析这种问题一般出在事务边界没包住两个 SQL。比如往 borrow_record 插入用的是 conn1更新 book 表又调用 DAO 里的方法重新从连接池拿了一个 conn2两个连接各自独立提交事务自然失效。还有一种情况是忘了 setAutoCommit(false)默认每个 SQL 独立提交第一条插入成功、第二条更新失败也不会回滚。解决写借书接口时整个业务逻辑只从一个 Connection 里拿状态所有 PreparedStatement 都从这个 Connection 创建。把事务开启、业务操作、提交或回滚都放在同一个方法里不要在 Servlet 开事务、DAO 里执行。一个好的检验方式是故意把第二条 SQL 的表名改成不存在的表跑一次借书看第一条插入是否被回滚如果记录还在事务边界一定有问题。6. 上线验证把工程打包部署到低配置服务器并验证完整请求链路6.1 构建 war 包与部署参数代码全部写完、本地测试通过后很多人直接把 IDEA 里的项目拷到服务器上运行这并不规范。正确做法是 Maven 打成 war 包扔到 Tomcat 的 webapps 目录下让 Tomcat 自动解压部署。打包命令如下在项目根目录终端里执行mvn clean package -DskipTests打出来的 war 包在 target 目录下文件名默认是 artifactId-version.war。如果嫌名字长可以在 pom.xml 里加 finalName 配置指定短名称。部署时把 war 包复制到 Tomcat 的 webapps 目录启动 Tomcat 后会自动解压成同名目录。低配置服务器部署有两个优化参数值得注意。第一Tomcat 启动参数里加 -Xms256m -Xmx512m限制堆内存避免小内存服务器被默认堆大小拖垮。第二数据库连接池最大连接数调到 5 就够图书管理系统并发量不会高连接数配多了反而占用 MySQL 资源。修改方式是在 catalina.sh 的 JAVA_OPTS 里加参数MySQL 这边无需额外操作。6.2 验证清单与第一人称经验部署完成后不要急着关终端按下面顺序验证一遍请求链路先在浏览器访问首页列表接口确认页面正常渲染然后执行一次借书操作立刻返回列表页看可借数量是否减一再到数据库里查 borrow_record 的插入时间最后执行还书确认数量恢复。我推荐用 curl 做一次接口冒烟测试绕过浏览器环境。图书列表接口如果是 GET可以这样验证curl -i http://服务器IP:8080/图书系统上下文/bookList?keywordJavapageNum1观察返回状态码和 Content-Type 里的字符集如果出现乱码就是 Tomcat 编码没配好。日志检查重点看 catalina.out 里有没有 SQLException 堆栈有的话按第 5 章的分类对号入座。这套系统正常运行后占用内存通常在 300M 以内MySQL 连接数峰值不超过 5。做这个项目的过程中我最大的教训是永远不要绕过数据库设计直接写页面。第一次做的时候我没有设计 available_count 字段每次查列表实时 count 已借记录数据量过一千条就慢得让人想砸电脑。后来按事务字段方案重写列表秒开借还书逻辑也简单了。如果你正在做类似的系统先从表结构入手再动代码。希望帮到你。本文还有配套的精品资源点击获取