JavaWeb图书管理系统实战:从数据库设计到事务并发与答辩避坑

📅 发布时间:2026/10/13 13:04:52
JavaWeb图书管理系统实战:从数据库设计到事务并发与答辩避坑
简介这是一套面向JavaWeb初学者与课程设计学习者的图书管理系统完整项目包围绕HTML、CSS、JavaScript、JSP、Servlet、JavaBean、JDBC与DAO等技术栈构建了一个功能完备的在线图书管理应用可用于课程设计、毕业设计参考或JavaWeb入门实战练习。资源包共3个文件包含1个docx系统设计报告、1个zip项目源码包和1个sql数据库脚本整体约4.52MB分别对应设计文档说明、可运行工程代码与建表数据便于快速导入环境并对照学习。目前已有19488人学习下载热度较高。通过该系统读者可以掌握前后端交互流程、数据库增删查改操作以及MVC架构的实际应用理解Servlet控制器、JavaBean数据封装与DAO分层设计的协作方式并借助设计报告完成需求分析、数据库设计与功能模块实现的整体梳理适合作为提升JavaWeb开发能力的综合实践案例。1. JavaWeb图书管理系统从课程设计到能扛住答辩的落地路线很多同学做 JavaWeb图书管理系统第一反应是去搜一套现成源码改改页面就交差。结果答辩时被问一句“借阅上限怎么控制的”“并发借同一本书会怎样”当场卡壳。这个标题背后真正要解决的不是“写个增删改查”而是把图书、读者、借阅三条业务线用一套可解释的规则串起来并且让权限、事务、分页这些细节经得起追问。它适合正在做课程设计的学生也适合想用一个小系统把 Servlet、JSP、JDBC、过滤器这些知识点串成闭环的初学者。我见过太多人卡在环境配置和乱码上其实核心难点从来不是代码量而是业务规则和数据一致性的设计。2. 技术选型与数据库设计为什么这套组合最适合练手2.1 为什么选 Servlet JSP JDBC 而不是一上来就 Spring Boot常见做法是直接上 Spring Boot MyBatis代码少、启动快。但如果你对 HTTP 请求怎么变成 Java 对象、Session 怎么维持登录态还没有直觉框架会把这些全变成黑匣子。JavaWeb图书管理系统这个题目本身就是一个教学载体用原生 Servlet 能让你看清一次借书请求从浏览器到数据库再回来的完整链路。具体来说Servlet 负责接收请求和调度JSP 负责渲染JDBC 负责数据存取。三层各司其职没有额外抽象层。等你把这条链路跑通再去学框架会发现框架只是帮你把重复代码抽走了底层逻辑一模一样。选型上我一般会固定这几个版本组合JDK 8 或 11、Tomcat 8.5 或 9、MySQL 5.7 或 8.0、mysql-connector-java 8.x。不要用太新的 JDK 配太老的 Tomcat类加载会出玄学问题。IDE 用 IntelliJ IDEA 社区版就够注意新建项目时选 Java Enterprise 或手动配 Web 模块。2.2 图书管理系统的四张核心表怎么建数据库设计决定了后面代码好不好写。图书管理系统最少需要四张表用户表、图书表、借阅记录表、分类表。下面是我常用的建表语句字段命名和类型都经过实际项目验证。-- 用户表区分管理员和普通读者 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role TINYINT NOT NULL DEFAULT 0, -- 0读者 1管理员 max_borrow INT NOT NULL DEFAULT 5, -- 最大可借数量 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 图书表stock 表示可借库存total 表示总库存 CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, author VARCHAR(100), category_id INT, total INT NOT NULL DEFAULT 0, stock INT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 借阅记录表status 0借出 1已还 2逾期 CREATE TABLE borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, borrow_time DATETIME NOT NULL, due_time DATETIME NOT NULL, return_time DATETIME, status TINYINT NOT NULL DEFAULT 0, INDEX idx_user (user_id), INDEX idx_book (book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 分类表 CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个参数值得说明。stock和total分开存是因为借出后库存减少但总藏书量不变统计时两个都要用。max_borrow放在用户表而不是写死在代码里方便不同读者设置不同权限。borrow_record上的两个索引是为了按用户或按图书查借阅历史时不走全表扫描。字符集统一用utf8mb4避免书名里有生僻字或特殊符号时插入失败。2.3 项目目录结构与基础配置目录结构直接影响你找文件的速度。我一般按功能分包而不是按类型分包。src/ com.library.entity/ -- User, Book, BorrowRecord com.library.dao/ -- UserDao, BookDao, BorrowDao com.library.service/ -- BorrowService, BookService com.library.servlet/ -- LoginServlet, BorrowServlet com.library.filter/ -- EncodingFilter, AuthFilter com.library.util/ -- DBUtil, PageResult web/ WEB-INF/web.xml login.jsp book_list.jsp borrow_list.jsp数据库连接用 DBUtil 统一管理不要在每个 Servlet 里重复写 DriverManager。public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/library?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PWD your_password; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(驱动加载失败, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } }连接串里serverTimezone必须显式指定否则 MySQL 8 驱动会报时区错误。characterEncodingutf8配合数据库的utf8mb4解决中文乱码。生产环境应该换成连接池但练手阶段用 DriverManager 足够重点是理解连接的生命周期。3. 核心功能实现借书、还书与分页查询3.1 借书业务的事务控制与库存扣减借书是整个系统里唯一需要事务的地方。它要同时做三件事插入借阅记录、扣减图书库存、校验用户借阅上限。任何一步失败都必须回滚否则会出现“记录有了但库存没扣”或者“库存扣了但记录没写”的脏数据。public class BorrowService { public String borrow(int userId, int bookId) throws SQLException { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 校验用户当前借阅数量 int current BorrowDao.countActive(conn, userId); int max UserDao.getMaxBorrow(conn, userId); if (current max) { conn.rollback(); return 已达借阅上限; } // 2. 校验库存并扣减带条件更新防止超借 int affected BookDao.reduceStock(conn, bookId); if (affected 0) { conn.rollback(); return 库存不足; } // 3. 插入借阅记录 BorrowDao.insert(conn, userId, bookId); conn.commit(); return 借阅成功; } catch (Exception e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } } }关键在reduceStock的 SQL 写法必须带stock 0条件UPDATE book SET stock stock - 1 WHERE id ? AND stock 0这样即使两个请求同时进来数据库行锁会保证只有一个能更新成功另一个返回 0 行受影响直接走“库存不足”分支。这就是不用额外加锁就能防超借的做法。setAutoCommit(false)之后所有操作共用同一个 ConnectionDao 方法必须接收这个 Connection 参数不能各自去拿新连接否则事务不生效。3.2 还书与逾期判断的实现细节还书逻辑比借书简单但逾期判断容易写错。核心是比较due_time和当前时间。public String returnBook(int recordId) throws SQLException { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); BorrowRecord r BorrowDao.getById(conn, recordId); if (r null || r.getStatus() ! 0) { conn.rollback(); return 记录不存在或已归还; } // 判断是否逾期 int newStatus new Date().after(r.getDueTime()) ? 2 : 1; BorrowDao.markReturned(conn, recordId, newStatus); BookDao.increaseStock(conn, r.getBookId()); conn.commit(); return newStatus 2 ? 归还成功已逾期 : 归还成功; } catch (Exception e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } }借期我一般设 30 天due_time在插入借阅记录时用LocalDateTime.now().plusDays(30)算好存进去而不是每次查询时动态算。存下来查询快也方便做逾期统计。状态字段用 0/1/2 而不是布尔值是因为以后可能扩展“续借”“丢失”等状态。3.3 图书列表的分页查询与模糊搜索图书列表必须分页否则数据一多页面直接卡死。分页的核心是LIMIT offset, size配合一个 count 查询算总页数。public class BookDao { public PageResultBook search(String keyword, int page, int size) throws SQLException { String where ; ListObject params new ArrayList(); if (keyword ! null !keyword.trim().isEmpty()) { where WHERE title LIKE ? OR author LIKE ? ; params.add(% keyword %); params.add(% keyword %); } // 先查总数 String countSql SELECT COUNT(*) FROM book where; int total; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(countSql)) { for (int i 0; i params.size(); i) { ps.setObject(i 1, params.get(i)); } ResultSet rs ps.executeQuery(); rs.next(); total rs.getInt(1); } // 再查当前页数据 String dataSql SELECT * FROM book where ORDER BY id DESC LIMIT ?, ?; ListBook list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(dataSql)) { int idx 1; for (Object p : params) ps.setObject(idx, p); ps.setInt(idx, (page - 1) * size); ps.setInt(idx, size); ResultSet rs ps.executeQuery(); while (rs.next()) { list.add(mapRow(rs)); } } return new PageResult(list, total, page, size); } }page从 1 开始offset是(page-1)*size。size一般设 10 或 15太大页面加载慢太小翻页频繁。模糊搜索用LIKE %keyword%注意前后都有百分号时索引会失效数据量大时应该换成全文索引但课程设计阶段数据量小这样写没问题。ORDER BY id DESC保证新书排在前面也避免分页时数据顺序不稳定。4. 权限、编码与登录态三个最容易翻车的地方4.1 用 Filter 统一处理编码和登录校验编码问题如果每个 Servlet 都写一遍request.setCharacterEncoding(UTF-8)迟早会漏。用 Filter 统一处理最省心。WebFilter(/*) public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(req, resp); } }登录校验也放在 Filter 里但要注意放行登录页和静态资源否则会死循环。WebFilter(/admin/*) public class AuthFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; HttpSession session request.getSession(false); if (session null || session.getAttribute(user) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }getSession(false)表示没有会话时不新建避免给未登录用户创建无用 Session。拦截路径用/admin/*而不是/*把管理功能都放在 admin 目录下普通页面不拦截。4.2 登录态与密码存储的基本做法登录成功后把用户对象放进 Session后续页面从 Session 取。密码绝对不能明文存用 SHA-256 加盐。public static String hash(String password, String salt) { try { MessageDigest md MessageDigest.getInstance(SHA-256); md.update((salt password).getBytes(StandardCharsets.UTF_8)); byte[] digest md.digest(); StringBuilder sb new StringBuilder(); for (byte b : digest) { sb.append(String.format(%02x, b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } }盐值可以每个用户随机生成存到用户表也可以用一个固定的应用级盐。课程设计阶段用固定盐加 SHA-256 已经比明文强很多。登录时把用户输入的密码用同样方式哈希后跟数据库比对。4.3 会话超时与退出登录Session 默认 30 分钟超时可以在 web.xml 里调整。session-config session-timeout30/session-timeout /session-config退出登录就是session.invalidate()然后重定向到登录页。注意退出后浏览器后退仍可能看到缓存页面可以在 Filter 里加响应头禁止缓存response.setHeader(Cache-Control, no-cache, no-store, must-revalidate); response.setHeader(Pragma, no-cache); response.setDateHeader(Expires, 0);5. 避坑与排查那些让答辩翻车的细节5.1 中文乱码现象是页面显示问号原因是编码链路某一环没统一现象表单提交后数据库里存的是乱码或者页面显示???。原因通常有三处JSP 页面pageEncoding没设、请求体编码没设、数据库连接串没设字符集。解决JSP 顶部写% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 %Filter 里设request.setCharacterEncoding(UTF-8)连接串加characterEncodingutf8数据库和表都用utf8mb4。四处都对齐就不会乱。5.2 事务不生效现象是回滚没起作用原因是 Dao 各自拿了新连接现象借书时库存扣了但记录没插入回滚没生效。原因Service 里开了事务但 Dao 方法内部自己调DBUtil.getConnection()拿了新连接不在同一个事务里。解决所有参与事务的 Dao 方法必须接收外部传入的 ConnectionService 统一管理连接的获取、提交和回滚。这是最容易忽略的一点血泪经验。5.3 超借问题现象是库存变成负数原因是更新语句没加条件现象两个人同时借最后一本书库存变成 -1。原因先查库存再更新两步之间有时间窗口。解决把校验和扣减合并成一条带条件的 UPDATEWHERE id? AND stock0根据受影响行数判断是否成功。不要用“先 SELECT 再 UPDATE”的写法。5.4 分页页码越界现象是最后一页之后还能翻原因是没校验 page 范围现象URL 手动把 page 改成 999页面空白或报错。原因没有对 page 做上下界校验。解决Service 层拿到 page 后先判断小于 1 设为 1大于总页数设为总页数。总页数用(total size - 1) / size计算注意 total 为 0 时至少返回 1 页。5.5 过滤器拦截了静态资源现象是 CSS 和图片加载不出来原因是拦截路径太宽现象登录页样式全丢控制台一堆 404。原因AuthFilter 拦截了/*把 css、js、图片请求也重定向到登录页。解决拦截路径改成/admin/*或者在 Filter 里判断请求 URI 后缀放行.css、.js、.png等静态资源。我一般直接把管理页面放 admin 目录从路径上隔离。6. 进阶技巧把课程设计做成能讲清楚的作品6.1 用 PageResult 统一分页返回结构分页查询返回的不只是数据列表还有总数、当前页、总页数。封装成一个类JSP 里取值方便Servlet 之间传递也统一。public class PageResultT { private ListT list; private int total; private int page; private int size; public int getTotalPages() { return total 0 ? 1 : (total size - 1) / size; } // 省略 getter/setter }JSP 里渲染分页条时用getTotalPages()循环生成页码链接当前页高亮。这样换任何列表都能复用同一套分页逻辑。6.2 借阅上限和借期的可配置化不要把“最多借 5 本”“借期 30 天”写死在代码里。用户表的max_borrow字段已经支持按人配置借期可以放到配置文件或数据库的全局参数表。这样答辩时被问“如果我想改成 10 本怎么办”你可以说改数据库字段值就行不用重新编译。6.3 用日志代替 System.out.println 排查问题调试时到处System.out.println可以但提交前应该换成日志框架。最轻量的做法是用java.util.logging不用引额外依赖。private static final Logger logger Logger.getLogger(BorrowService.class.getName()); // 在关键分支打日志 logger.info(用户 userId 借书 bookId 结果 result);日志里记录用户 ID、操作类型、结果出问题时能快速定位是哪一步失败。比在控制台翻几百行输出高效得多。6.4 答辩前自测清单我一般会按这个顺序过一遍用两个浏览器分别登录管理员和读者验证权限隔离连续借同一本书直到库存为 0验证超借拦截手动改 URL 的 page 参数验证分页边界借一本书后把系统时间往后调验证逾期判断退出登录后直接访问 admin 路径验证拦截生效。这几条过了基本不会被问倒。做这个系统最大的教训是不要等到功能全写完才测试事务和并发那会儿改起来牵一发动全身。我习惯每写完一个涉及多表操作的 Service 方法立刻用两个线程模拟并发跑一遍确认数据一致再往下写。这个习惯帮我省掉了无数次返工。希望帮到你。本文还有配套的精品资源点击获取