我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

JavaWeb图书馆管理系统源码:MySQL部署、事务与二次开发避坑

JavaWeb图书馆管理系统源码:MySQL部署、事务与二次开发避坑 简介这份JavaWeb图书馆管理系统源码面向高校课程设计、毕业设计和JavaWeb入门练手者采用Servlet、JSP、JavaBean与MySQL实现图书信息、读者账户、借阅归还及管理员权限等业务可用于理解MVC分层和数据库表关联。压缩包共209个文件约1.91MB含51个JSP页面、32个Java源文件及编译类、9个JAR依赖、MySQL数据文件与SQL脚本并有properties配置、TLD标签库、JS/CSS和图片等资源其中JSP承担页面展示Java源文件对应业务与数据访问SQL脚本便于初始化数据库。已有359人学习适合需要可运行、可拆解、可二次开发案例的读者。源码中的DAO、实体类、表单对象与借阅流程相互配合便于梳理登录认证、权限控制、请求转发、异常处理及CRUD链路也能按模块替换界面或扩展功能对理解Servlet接收请求、调用业务层再转发JSP渲染的流程较有帮助。1. 拿到一个能跑的图书馆系统比从零写一百行 CRUD 更值很多人对「图书借还」这个业务的印象还停留在课程作业几张表、几个 JSP、一个 Servlet 转来转去。真上手维护一个学校或企业内部的小型图书室你会发现难点不在增删改查而在「借书那一刻库存和借阅记录要不要一起动」「读者超期了怎么算」「管理员和普通用户看到的菜单为什么不一样」。这份 javaWeb 图书馆管理系统源码MySQL 版本正好卡在这个位置上解压出来就是一套完整的 JavaWeb 工程图书、图书分类、读者、借阅、管理员几条主线都铺好了前端用 JSP 渲染后端走 Servlet JDBC 直连 MySQL没有 Spring 那层自动装配的封装读代码不用先猜框架在做什么。适合两类人一是实训阶段需要一个能演示完整业务闭环的工程作为底座二是工作里要快速搭个内部小系统的开发者。它能不能用得顺手取决于环境、库表、调用链这三件事有没有对齐。2. 环境对齐JDK、Tomcat、MySQL 的版本组合与工程导入的四步原生 JavaWeb 工程最容易让人在第一步就放弃不是代码写得差而是版本组合错一个整个工程满屏红色报错连启动都启动不了。这一章把版本选择、目录识别、建库导脚本、连接配置这四件事按顺序讲清楚照着做基本能跑起来。2.1 为什么纯 Servlet 工程对版本比对框架工程更敏感框架工程通常自带一套依赖管理版本冲突在编译期就被约束住了。而一份 JSP Servlet 的源码包编译期靠servlet-api.jar运行期靠容器提供实现两边的命名空间必须对上。JDK 跨过 8 之后javax.servlet整体迁移到jakarta.servlet包名一改工程里所有import javax.servlet.http.HttpServlet;全部失效这不是改一两行能救的。所以第一步不是急着往 IDE 里拖而是先定版本组合。我一般的固定搭配是 JDK 8 Tomcat 8.5 或 9 MySQL 5.7 或 8.0理由是这套组合的兼容窗口最宽javax命名空间在 Tomcat 9 及以前都还在jstl、mysql-connector-java这些老 jar 也都是按这个环境编译的。如果你本地已经装了 JDK 17不建议硬上装一个 8 的 JDK 并单独给 IDE 指定 SDK 更省事。组件建议版本判断依据越界的直接后果JDK8源码里出现javax.servlet.*编译报class file has wrong version或 import 全红Tomcat8.5 / 9只认javax命名空间Tomcat 10 及以上所有 Servlet import 失效MySQL5.7 / 8.0建表脚本里的存储引擎与字符集8.0 的库灌 5.7 脚本一般没事反向容易报语法错JDBC 驱动与 MySQL 大版本对齐WEB-INF/lib下的mysql-connector-java-*.jar驱动旧了连不上 8.0驱动新了报时区或公钥错误这张表不是让你死记而是让你在报错时有个对照方向。比如启动时抛NoClassDefFoundError: javax/servlet/http/HttpServlet八成是 Tomcat 版本和 JDK 版本对不上如果抛的是com.mysql.jdbc.Driver找不到那就是驱动 jar 没进WEB-INF/lib或者类名写的是老写法而驱动是 8.x 的新包。2.2 解压后先看三个文件判断工程类型压缩包解开别急着导进 IDE。先看目录结构和三个关键位置五分钟能判断出这个工程是什么技术形态后面所有操作都基于这个判断。# 1. 看整体结构确认是不是标准 Web 工程 ls -la library/ # 期望看到 src/、WebRoot/ 或 web/、数据库脚本 .sql 文件 # 2. 看 web.xml 是否存在Servlet 是不是在这里注册的 find . -name web.xml -path */WEB-INF/* # 3. 看 lib 目录里有哪些 jar直接决定技术栈 ls -la WebRoot/WEB-INF/lib/三个文件看下来的判断逻辑是这样如果WEB-INF/web.xml里塞满了servlet和servlet-mapping说明是原生 Servlet 分发代码里大概率有一个BaseServlet或者action参数分发器如果lib下出现spring-webmvc-*.jar、mybatis-*.jar那这套源码其实是 SSM 换了个名字流通配置文件要去找applicationContext.xml如果lib下只有mysql-connector-java、jstl、standard这几个那就是最纯粹的 JSP Servlet JDBC也是最容易读懂、最容易改的一种。第三种情况的源码包价值反而更高因为它没有隐藏的约定。schema或sql目录下的.sql文件是第二个必看的东西。打开扫一眼CREATE TABLE的数量和字段你能立刻知道这套系统的业务边界到底到哪有没有罚金计算、有没有预约、有没有管理员分级。心里有数之后再动手建库比先跑起来再回头补要省很多时间。2.3 建库与灌脚本字符集这一步别省数据库建错字符集后面中文乱码能折腾你一整天而且现象是间歇性的——有的页面正常有的页面出错排查起来完全没有方向。所以我一般把建库语句和灌脚本分开写建库时就把字符集钉死。# 建库时显式指定字符集别依赖 MySQL 默认值 mysql -u root -p -e CREATE DATABASE library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 灌入脚本注意指定 default-character-set否则脚本里的中文注释可能变问号 mysql -u root -p --default-character-setutf8mb4 library_db library_db.sql # 灌完立刻验证表数量和字符集 mysql -u root -p -e USE library_db; SHOW TABLES; SHOW CREATE TABLE book\G | head -40--default-character-setutf8mb4这个参数很多人会漏。如果脚本文件本身是 UTF-8 编码而客户端默认按 latin1 解析导入后的中文会变成一串问号而且这个错误在导入当时不报等页面查出来才发现回滚重导的成本很高。utf8mb4相比utf8多支持 emoji 和部分生僻字图书名里出现特殊符号时不会截断。灌完脚本后有两个动作值得马上做。一是SHOW CREATE TABLE看几张主表的字符集是不是都跟着库走了偶尔会有脚本里硬写了CHARSETlatin1的表那种表单独改掉。二是SELECT COUNT(*)看初始数据量如果图书表只有三五行示例数据说明这是一份纯演示工程交付前你得自己准备数据别指望它带真实书库。2.4 连接配置的四处必改以及 URL 里那几个参数源码包里的数据库连接信息通常散落在两三个地方只改一处是最常见的翻车点。老工程常见的分布是src/db.properties或jdbc.properties一份DBUtil.java里可能硬编码一份有的还会在web.xml里配一个context-param。最稳妥的做法是全项目搜关键词一次改干净。# db.properties 修改示例 drivercom.mysql.cj.jdbc.Driver urljdbc:mysql://127.0.0.1:3306/library_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue usernameroot password你的密码# 改之前先全项目扫一遍确认没有漏网之鱼 grep -rn jdbc:mysql\|com.mysql.jdbc.Driver\|3306 src/ WebRoot/WEB-INF/ 2/dev/nullURL 后面这几个参数不是可选项。useUnicodetruecharacterEncodingutf8决定驱动层怎么编码字符串缺了它在某些 MySQL 版本上会出现写入正常、读出乱码的诡异现象。serverTimezoneAsia/Shanghai是 MySQL 8.0 的硬要求缺了会抛The server time zone value is unrecognized因为 8.0 的驱动不再猜服务器时区。useSSLfalse是为了避免本地环境没有证书时的告警刷屏。allowPublicKeyRetrievaltrue对应 8.0 默认的caching_sha2_password认证插件缺了会报Public Key Retrieval is not allowed。驱动类名要跟 jar 版本对上mysql-connector-java5.x 用com.mysql.jdbc.Driver8.x 用com.mysql.cj.jdbc.Driver。8.x 的驱动其实也认老类名只是会打一行 deprecated 警告但如果 jar 是 5.x 而你写了新类名那就是直接ClassNotFoundException。另外驱动 jar 一定要放在WEB-INF/lib下而不是只加到 IDE 的构建路径里否则 IDE 里能跑、导成 war 部署就报找不到驱动这个坑我在第一次部署时踩得很结实。3. 库表拆解五张核心表的关系、索引与三个数据隐患图书馆系统的业务复杂度几乎全压在数据库上代码本身反而简单。这一章从表关系讲到字段设计再落到三个实际运行中一定会遇到的数据问题。3.1 五张核心表的关系以及外键该不该建一份典型的图书馆系统库表大概是这么几条线book存书目booktype存分类reader存读者borrow存借阅记录admin存后台账号。关系很清晰book.type_id指向booktype.idborrow.book_id指向bookborrow.reader_id指向reader。问题出在外键上。这类源码包十有八九是不建物理外键的理由也说得过去导入脚本时不用管表的创建顺序删数据时不用先删子表性能上少一层约束检查。代价是脏数据没人拦——删掉一本书borrow表里指向它的记录就变成孤儿查借阅历史时JOIN出来一堆NULL页面上显示空白书名。我的处理方式分场景如果是自己内部用、数据量不大补上外键并设置ON DELETE RESTRICT删书时直接报错逼着先处理借阅记录这是最省心的做法如果是要长期跑、数据量会上来就保持不建外键但在 Service 层把「删书前检查是否有未归还记录」写死同时给borrow.book_id建索引保证JOIN不慢。两种都能用怕的是既没外键也没业务校验那数据烂掉只是时间问题。3.2 建表脚本里我必改的几个字段原始脚本能用但有几个字段类型经不起实际使用。下面是我改过之后的简化版重点看类型和索引。-- 书目表库存字段和 ISBN 唯一索引是关键 CREATE TABLE book ( id INT NOT NULL AUTO_INCREMENT, book_name VARCHAR(120) NOT NULL COMMENT 书名, author VARCHAR(60) DEFAULT NULL, isbn VARCHAR(20) DEFAULT NULL, type_id INT NOT NULL COMMENT 分类ID, price DECIMAL(10,2) DEFAULT 0.00 COMMENT 定价别用float, stock INT NOT NULL DEFAULT 0 COMMENT 可借库存, total INT NOT NULL DEFAULT 0 COMMENT 馆藏总数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在架 0下架, PRIMARY KEY (id), UNIQUE KEY uk_isbn (isbn), KEY idx_type (type_id), KEY idx_name (book_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 借阅表状态位和联合索引决定查询性能 CREATE TABLE borrow ( id BIGINT NOT NULL AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL COMMENT 应还日期, return_date DATE DEFAULT NULL COMMENT 实际归还未还为NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0借出 1已还 2超期, PRIMARY KEY (id), KEY idx_reader_status (reader_id, status), KEY idx_book (book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;三处改动值得说明。第一price用DECIMAL(10,2)而不是FLOAT浮点数在金额上是老生常谈的坑累加之后会出现0.30000000000000004这种值罚款计算直接失真。第二borrow表加了(reader_id, status)联合索引因为「我的借阅」这个页面的查询条件是读者 ID 加状态过滤这是全系统调用频率最高的查询没索引时数据量一过万就明显卡。第三return_date允许为NULL表示未归还比用一个0000-00-00的假日期要干净得多后者在 JDBC 里读出来会直接抛异常。stock可借库存和total馆藏总数这两个字段分开存是我从实际使用里总结出来的。只存一个stock的话书全部借出后你就分不清这本书馆里到底有几本、是全借走了还是根本没采购。分开存之后total - stock就是当前在借数量对账非常方便。3.3 三个一定会遇到的数据隐患库存扣减的竞态问题。最常见的错误写法是先SELECT stock FROM book WHERE id ?在 Java 里判断stock 0然后UPDATE book SET stock stock - 1。单机单用户测试永远没问题两个人同时点借书就超借了。正确的写法是把判断压进 SQL让数据库的锁来保证原子性-- 一条语句完成有库存才扣减影响行数为 0 就是库存不足 UPDATE book SET stock stock - 1 WHERE id ? AND stock 0;执行后看executeUpdate()的返回值等于 1 表示扣减成功等于 0 表示库存已经没了直接给前端返回「该书已借完」。这个写法不需要显式加锁InnoDB 的行锁会处理好。日期类型用 VARCHAR 存。有些老脚本图省事把borrow_date写成VARCHAR(20)页面上看着没问题一旦做超期统计就崩了字符串比较2024-9-1 2024-10-1的结果是 true因为逐字符比到第二位9大于1。超期名单会算错而且是那种「看起来合理但就是不对」的错。统一改成DATE或DATETIME格式化交给 Java 的SimpleDateFormat或前端的toLocaleDateString。没有逻辑删除字段。直接DELETE的后果是统计报表永远对不上账去年的借阅总量今年再查因为书被删了JOIN不上数字少了一截。给book和reader加一个is_deleted TINYINT DEFAULT 0所有查询带上WHERE is_deleted 0删除只做UPDATE。代价是所有 SQL 都要多一个条件收益是历史数据永远可追溯。这个改动越早做越好等数据攒起来再补历史记录的is_deleted全是NULL判断逻辑又要多一层。4. 调用链还原从借书按钮到 SQL 执行中间发生了什么读懂调用链是改这套源码的前提。这一章用「借书」这一条完整链路串起 Servlet、Service、DAO 三层再把分页和事务这两个高频需求落到代码上。4.1 一条借书请求的完整路径JSP 页面上的「借阅」按钮提交到哪个 Servlet、带什么参数决定了你改功能时该动哪个文件。典型的分发方式是所有图书相关操作都打到同一个 Servlet用action参数区分具体方法。// BookServlet.java 片段按 action 分发这是老工程最常见的写法 protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 必须在取参数之前设置否则 POST 中文参数乱码 req.setCharacterEncoding(UTF-8); String action req.getParameter(action); if (borrow.equals(action)) { int bookId Integer.parseInt(req.getParameter(bookId)); int readerId (Integer) req.getSession().getAttribute(readerId); boolean ok borrowService.borrow(bookId, readerId); // 事务在这一层 // 借完重定向到列表页避免刷新重复提交 resp.sendRedirect(req.getContextPath() /book?actionlistmsg (ok ? ok : fail)); } }这段代码里有三个容易改错的地方。setCharacterEncoding必须放在第一行getParameter之前放后面等于没设。readerId从 session 取而不是从请求参数取否则用户改一下 URL 就能替别人借书。借书成功后用sendRedirect而不是getRequestDispatcher().forward()因为转发的话浏览器地址栏还停在提交页用户按 F5 会重复提交一次借书请求库存白扣。4.2 手写 JDBC 的三个固定套路没有 MyBatis 的工程DAO 层全是模板代码但有三个位置必须写对写错了就是连接泄漏或者注入漏洞。// DBUtil.java连接获取配置从 properties 读别硬编码 public class DBUtil { private static final Properties P new Properties(); static { try (InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(db.properties)) { P.load(in); // 配置和代码分离换环境只改 properties } catch (Exception e) { throw new ExceptionInInitializerError(数据库配置加载失败); } } public static Connection getConn() throws SQLException { return DriverManager.getConnection( P.getProperty(url), P.getProperty(username), P.getProperty(password)); } }// BookDao.java查询用 try-with-resources参数一律走占位符 public ListBook findByPage(int page, int size, String keyword) { String sql SELECT id, book_name, author, stock FROM book WHERE is_deleted 0 AND book_name LIKE ? ORDER BY id DESC LIMIT ?, ?; ListBook list new ArrayList(); try (Connection conn DBUtil.getConn(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, % keyword %); // 占位符防注入别拼字符串 ps.setInt(2, (page - 1) * size); // 起始偏移 ps.setInt(3, size); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Book b new Book(); b.setId(rs.getInt(id)); b.setBookName(rs.getString(book_name)); b.setStock(rs.getInt(stock)); list.add(b); } } } catch (SQLException e) { throw new RuntimeException(分页查询失败: e.getMessage(), e); } return list; }try-with-resources把Connection、PreparedStatement、ResultSet三个都包进去顺序是后开的先关这个语法从 JDK 7 就有没有理由不用。老工程里大量finally { rs.close(); ps.close(); conn.close(); }的写法只要中间任何一个close抛异常后面的就关不掉连接池很快就会耗尽表现出来就是跑一段时间后整个系统卡死、日志里全是Too many connections。这类问题重启能缓解但根因不修就会反复出现。LIKE查询用ps.setString(1, % keyword %)把通配符拼在参数值里而不是拼在 SQL 字符串里这样既保留了模糊匹配能力又不会因为用户输入 OR 11而把整张表拖出来。4.3 分页的两条 SQL 与页码越界分页必须有两条 SQL一条查总数算总页数一条查当前页数据。只写后者的后果是前端分页器渲染不出来因为你不知道一共多少页。// 总数查询和列表查询保持同样的 WHERE 条件否则页数对不上 public int countByKeyword(String keyword) { String sql SELECT COUNT(*) FROM book WHERE is_deleted 0 AND book_name LIKE ?; try (Connection conn DBUtil.getConn(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, % keyword %); try (ResultSet rs ps.executeQuery()) { return rs.next() ? rs.getInt(1) : 0; } } catch (SQLException e) { return 0; } }后端接收页码时要防两个边界page小于 1 时强制改成 1page超过总页数时返回空列表或者直接跳到最后一页而不是让LIMIT的偏移量算成一个巨大的数。(page - 1) * size在page很大时会整数溢出变成负数MySQL 直接报语法错误。另外size一定要设上限不设的话有人手动传size100000一次查询把整个表拖进内存小服务器当场就顶不住了。4.4 借书事务扣库存和插记录必须绑在一起借书这个动作在数据库上包含两条写操作book.stock减一borrow插一条记录。两条必须同生共死否则会出现「库存扣了但没借阅记录」或者反过来「有记录但库存没动」。手写 JDBC 时事务的关键是这两条 SQL 共用一个Connection。// BorrowService.java同一个连接内完成扣库存 插记录 public boolean borrow(int bookId, int readerId) { Connection conn null; try { conn DBUtil.getConn(); conn.setAutoCommit(false); // 关闭自动提交事务开始 // 1. 原子扣库存返回 0 说明没库存了 try (PreparedStatement ps conn.prepareStatement( UPDATE book SET stock stock - 1 WHERE id ? AND stock 0)) { ps.setInt(1, bookId); if (ps.executeUpdate() 0) { conn.rollback(); // 库存不足回滚并返回 return false; } } // 2. 插入借阅记录应还日期默认借出后 30 天 try (PreparedStatement ps conn.prepareStatement( INSERT INTO borrow(book_id, reader_id, borrow_date, due_date, status) VALUES (?, ?, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), 0))) { ps.setInt(1, bookId); ps.setInt(2, readerId); ps.executeUpdate(); } conn.commit(); // 两步都成功才提交 return true; } catch (SQLException e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ignore) {} throw new RuntimeException(借书失败, e); } finally { try { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } catch (SQLException ignore) {} } }setAutoCommit(false)之后如果忘记commit数据不会落库而且连接归还到池里时事务状态是脏的会影响下一次使用。finally里把autoCommit恢复成 true 再关闭是给连接池复用的一个保险。这套写法比框架里一个Transactional啰嗦得多但你能清楚看到事务边界在哪改逻辑时心里有底。5. 避坑排查五条踩过的坑按现象、原因、解决拆开下面这五条是我在这类工程上反复遇到的每一条都写清楚现象长什么样、根因在哪、怎么一次性解决。看的时候建议对照自己的报错信息找。5.1 启动阶段的两个致命报错坑一java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver现象是 Tomcat 启动日志里刷出这一行随后所有涉及数据库的页面全部 500。原因是驱动 jar 只加到了 IDE 的构建路径比如 Eclipse 的 Build Path 或者 IDEA 的 Libraries没有复制到WEB-INF/lib下。IDE 内嵌运行时能找到那个 jar一旦部署到独立的 Tomcat 就找不到了。解决方式很直接把mysql-connector-java-x.x.x.jar复制一份到WebRoot/WEB-INF/lib/然后在 IDE 的项目结构里刷新并重新构建。验证方法是解压打好的 war 包确认WEB-INF/lib/下确实有那个 jar。这个坑之所以高频是因为它符合「本地能跑、部署就崩」的典型特征很多人会误以为是服务器环境问题方向一错就是半天。坑二The server time zone value is unrecognized现象是应用能启动但第一次访问数据库就抛异常堆栈里能看到serverTimezone。原因是 MySQL 8.0 的驱动不再自动推断服务器时区URL 里必须显式指定。同时如果 MySQL 服务端本身没配时区表Asia/Shanghai也可能解析失败。解决分两层连接 URL 加上serverTimezoneAsia/Shanghai这是必须的如果加了还报错就在 MySQL 命令行执行SET GLOBAL time_zone 8:00;并写进my.ini的[mysqld]段用偏移量代替时区名绕开时区表缺失的问题。两个都做完这个报错基本不会再出现。5.2 页面与请求阶段的三个高频故障坑三CSS 和 JS 全部 404页面裸奔现象是 JSP 能打开但样式全丢浏览器控制台一片红请求的路径是/css/style.css而不是/library/css/style.css。原因是 JSP 里写的是绝对路径/css/style.css而应用部署在 Tomcat 的/library上下文下。项目从 IDE 内嵌运行切到独立 Tomcat上下文路径变了写死的绝对路径就全断了。正确的写法是加${pageContext.request.contextPath}link relstylesheet href${pageContext.request.contextPath}/css/style.css script src${pageContext.request.contextPath}/js/jquery.min.js/script同一份代码在 IDE 里上下文是/在 Tomcat 里是/war包名加了这个前缀两边都能跑。改的时候全项目搜href/和src/一次性替换干净别只改当前报错的那个页面。坑四中文乱码而且只在某些页面出现现象是新增图书时书名输入「数据结构」列表页显示成「????」或者「æ•°æ®ç»“æž„」。原因有三层任何一层没对上都会乱请求解码request.setCharacterEncoding(UTF-8)要在取参数前、响应编码response.setContentType(text/html;charsetUTF-8)、数据库连接URL 里的characterEncodingutf8以及表和字段本身的字符集。四层里数据库那层最容易漏因为连接串里写了characterEncoding不等于表就是 utf8mb4。排查顺序是从外到内先看浏览器返回的响应头Content-Type有没有charsetUTF-8再看SHOW CREATE TABLE book的字符集最后看连接串。三层都确认过还没解决就在 Servlet 里打印一次new String(req.getParameter(bookName).getBytes(ISO-8859-1), UTF-8)的中间结果能立刻定位是哪一层转错了。这个调试手法虽然土但比盲改配置快得多。坑五借书时提示成功但库存变成负数现象是并发测试时几个人同时借同一本书最后stock显示 -1 甚至 -2。原因是经典的先查后改Java 里SELECT出库存判断大于 0再单独发UPDATE两个请求都通过了判断然后各自减一。解决就是第 3 章里那条UPDATE book SET stock stock - 1 WHERE id ? AND stock 0把判断和修改压到一条语句里用数据库的行锁保证原子性。改完之后还要在 Service 层处理executeUpdate()返回 0 的情况给前端明确的「已借完」提示而不是静默失败。顺带一提如果业务上允许多人同时借同一本书馆藏有多本这个写法依然成立因为它只扣实际存在的库存。6. 二次开发的三个落点拦截、统计与上线自检能跑起来只是起点这套源码真正的价值在于它足够简单你想加什么都能在一个下午里加进去。下面三个改动是我每次拿到这类工程都会做的篇幅不长但都是实打实能用的。6.1 用 Filter 补一道登录拦截老工程常见的问题是每个 Servlet 里都写一遍if (session.getAttribute(admin) null) { 跳登录页 }漏写一个页面就裸奔。用一个 Filter 统一收口web.xml里配一次全站生效。// LoginFilter.java白名单放行其余一律校验 session public class LoginFilter implements Filter { private static final SetString WHITE Set.of(/login.jsp, /login, /css, /js); public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String path req.getRequestURI().substring(req.getContextPath().length()); // 白名单登录页、静态资源直接放行 for (String w : WHITE) { if (path.startsWith(w)) { chain.doFilter(request, response); return; } } if (req.getSession().getAttribute(admin) null) { resp.sendRedirect(req.getContextPath() /login.jsp); // 未登录踢回登录页 return; } chain.doFilter(request, response); } }!-- web.xml 中注册注意 url-pattern 覆盖全站 -- filter filter-nameLoginFilter/filter-name filter-classcom.example.filter.LoginFilter/filter-class /filter filter-mapping filter-nameLoginFilter/filter-name url-pattern/*/url-pattern /filter-mapping白名单里一定要带上静态资源目录否则登录页自己的 CSS 会被拦掉页面上出现「明明没登录却能看到一个没样式的登录框」的怪现象。另外这个 Filter 只做了「有没有登录」的判断如果要做管理员和普通读者的权限区分就在 session 里存一个role字段在 Filter 里按path前缀再判一次/admin/*只放行管理员逻辑加十行就够。6.2 一条聚合 SQL 顶掉一个报表模块「借阅排行」和「超期提醒」这两个需求很多人第一反应是写 Java 循环把借阅记录全查出来在内存里分组统计。数据量一大就是灾难。一条 SQL 就能解决-- 借阅排行 TOP10按书分组计数走 idx_book 索引 SELECT b.id, b.book_name, COUNT(*) AS times FROM borrow br JOIN book b ON br.book_id b.id WHERE br.borrow_date DATE_SUB(CURDATE(), INTERVAL 90 DAY) GROUP BY b.id, b.book_name ORDER BY times DESC LIMIT 10; -- 超期未还名单DATE 类型直接比较别用字符串 SELECT br.id, r.reader_name, b.book_name, br.due_date, DATEDIFF(CURDATE(), br.due_date) AS overdue_days FROM borrow br JOIN reader r ON br.reader_id r.id JOIN book b ON br.book_id b.id WHERE br.status 0 AND br.due_date CURDATE() ORDER BY overdue_days DESC;第一条里的时间窗口用DATE_SUB(CURDATE(), INTERVAL 90 DAY)而不是写死日期这样报表永远看最近三个月不用每月改代码。第二条依赖borrow.status和due_date两个条件如果你按第 3 章的建议建了(reader_id, status)联合索引这里的status 0能吃到索引的左前缀但due_date的过滤还是要回表数据量大的话可以再补一个(status, due_date)的联合索引。把这两条 SQL 的结果直接塞进request.setAttributeJSP 里用 JSTL 的c:forEach渲染成表格一个报表页面二十分钟能做完比在 Java 里折腾集合分组靠谱得多。6.3 上线前的一份自检表这套工程交付前我都会走一遍下面这张表五分鐘能过完但能挡掉八成的低级故障。检查项判定方法不合格的典型表现驱动 jar 在 war 内解压 war 看WEB-INF/lib独立 Tomcat 启动即 500连接串含时区参数grepserverTimezone首次访问数据库抛异常库表字符集为 utf8mb4SHOW CREATE TABLE中文变问号且只在部分页面静态资源用 contextPath 前缀grephref/部署后页面无样式库存扣减为单条 UPDATE查 Service 层代码并发下库存变负数事务方法共用 Connection查getConn()调用次数库存扣了但没借阅记录分页 size 有上限查参数校验一次请求拖垮内存这张表的价值不在于条目多全而在于每一条都对应一个具体的、我真实遇到过的故障。把它当成上线前的固定动作比出问题之后再回头翻日志要省太多时间。从那以后我每次拿到这类 JavaWeb 源码包都会先把版本组合钉死、把库表字符集和索引改掉、再顺着一条借书链路把 Servlet 到 SQL 走一遍最后过一遍自检表——这三步做完剩下的才是业务功能的增删。跳过前面直接改功能后面花在排查环境上的时间一定比省下来的多。希望帮到你。本文还有配套的精品资源点击获取
返回列表