简介:这是一套面向JavaWeb初学者、课程设计者与毕业设计实践者的图书馆管理系统项目源码,围绕图书查询、借阅归还、读者管理、用户注册登录及管理员/普通用户权限区分等场景,呈现Servlet/JSP、JDBC与MVC分层开发的完整实现思路。压缩包共199个文件,以java源码、jsp页面和class文件为主,辅以配置、脚本、数据库文件及相关说明文档,整体约766KB,目录按功能模块组织,便于查看请求流转与二次调整。已有5170人学习,作为入门级Web项目具有较好的参考热度。读者可从中理解数据库表关系设计、图书与读者信息维护、借阅记录生成、超期处理、输入校验和Tomcat与MySQL部署要点,也能借助现有结构复盘CRUD操作、页面交互与分层职责划分,适合用于课程作业参考、项目练习和Web基础巩固。
1. 一次借还书,把 JavaWeb 的完整链路串起来了
有个做外包的朋友接了个私活,需求简单到一句话:能查书、能借书、能还书、能看谁超期。他第一反应是上 Spring Boot,结果客户机房只给了一台装着 Tomcat 8 和 MySQL 5.7 的老机器,连外网都没有。最后翻出来的就是这套基于 JavaWeb 的图书馆管理系统源码,Servlet + JSP + JDBC,没有脚手架,也没有注解魔法,但库表、借还事务、超期罚金、角色权限全都在。这类源码最适合两种人:刚学完 Servlet 想看看一个业务闭环怎么落地的人,以及要交课程设计、手上只有一台能跑 Tomcat 的机器的人。它不炫技,胜在整条路径是敞开的——请求从浏览器进来,经过 Filter、Servlet、Service、DAO,最后落到 SQL,每一步都能打断点。
2. 技术栈与目录结构:Servlet + JSP + JDBC 的三层骨架长什么样
2.1 没有 Spring 的 JavaWeb,为什么在这类项目里反而顺手
先把这套源码的技术底牌摊开。后端是原生 Servlet 3.1 加 JSP/JSTL,数据层走 JDBC 直连 MySQL,中间夹一层手写的BaseDao做增删改查的公共封装,连接池常见做法是挂 Druid,也有版本用 C3P0;前端是 Bootstrap 3 配 jQuery,弹层用 layer,表格和表单基本靠 JSP 循环渲染。整套东西打成 war 包,扔进 Tomcat 的webapps目录就能启动。
这种组合放到今天看有点老派,但它有个很实际的好处:依赖少。一个图书馆管理系统的核心业务无非是登录、图书增删改查、借书还书、超期查询、读者管理这五块,用不着 AOP 事务代理,也用不着分布式配置中心。Servlet 直接靠@WebServlet映射 URL,JSP 拿到request域里的 List 就循环输出,出问题时,从浏览器点击到 SQL 执行中间没有任何反射和代理,断点能一路跟到底。
代价也是明摆着的。所有事务要手写setAutoCommit(false)和commit(),参数校验得自己在 Servlet 里一条条判,页面复用靠<jsp:include>,稍微复杂一点的查询就会在 JSP 里堆出一坨<% %>脚本块。所以读这套源码的时候,心态要摆正:它不是让你学架构的,是让你看清一个业务系统最朴素的骨架,后面第 6 章我会讲怎么把这身骨架往可维护的方向掰一掰。
我一般会先看web.xml和 Servlet 的注解,把 URL 和类的对应关系列一遍,再看dao包里的 SQL。这两步做完,项目大概长什么样心里就有数了。
2.2 目录结构逐层拆:从 webapp 到 dao 包
这套源码的目录是标准的 Maven Web 工程布局,不是老式 Eclipse 的WebRoot结构,这一点对新手友好很多,IDEA 直接Open就能识别。
library-system/ ├── pom.xml # war 打包,依赖集中在 properties 里锁版本 ├── sql/ │ └── library.sql # 建库建表 + 初始管理员 + 测试图书数据 └── src/main/ ├── java/com/example/library/ │ ├── controller/ # Servlet 层,一个业务动作一个类 │ │ ├── LoginServlet.java │ │ ├── BookServlet.java │ │ └── BorrowServlet.java │ ├── service/ # 业务层,事务边界基本都在这层 │ │ ├── BookService.java │ │ └── BorrowService.java │ ├── dao/ # SQL 都在这,BaseDao 提供通用方法 │ │ ├── BaseDao.java │ │ ├── BookDao.java │ │ └── BorrowDao.java │ ├── entity/ # 与表一一对应的 POJO │ ├── filter/ # 编码过滤器 + 登录鉴权过滤器 │ └── util/ # DBUtil、MD5Util、PageResult ├── resources/ │ └── db.properties # 数据库连接参数,不写死在代码里 └── webapp/ ├── WEB-INF/web.xml # 只留欢迎页和错误页,映射基本走注解 ├── static/ # bootstrap、jquery、layer ├── admin/ # 管理员端 JSP:图书管理、借阅管理、读者管理 ├── reader/ # 读者端 JSP:查书、我的借阅 └── login.jsp看这个结构要知道三件事。第一,controller里的类名就是业务动词,BookServlet大概率用action参数分流(比如?action=list、?action=add),不是给每个操作单独建一个 Servlet,读的时候先找service方法里switch或if那段。第二,service是事务的家,凡是涉及两张表以上写入的操作,commit和rollback一定在这一层,不会散到 Servlet 里。第三,static和WEB-INF的区别要记牢,放在WEB-INF下的 JSP 只能通过forward访问,浏览器直接敲地址是 404,这是老项目做页面保护最省事的办法。
还有一个细节容易被忽略:entity包里的字段名和表字段名不一定完全一致,比如表里叫real_name,实体里可能是realName,赋值时靠ResultSet的getString("real_name")手工映射,而不是反射框架。所以改表结构的时候,dao里每一处rs.getString(...)都得同步看一遍,这是后面改需求最容易出错的地方。
2.3 依赖清单与环境版本对照
pom.xml里的依赖不多,但版本卡得很死,尤其是 MySQL 驱动和 Servlet API,版本对不上就是启动即报错。
<properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <!-- 只做编译期使用,运行时由 Tomcat 提供,scope 必须是 provided --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> <!-- JSTL 标签库,JSP 里 c:forEach 循环表格全靠它 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL 8 用 8.0.x 驱动,MySQL 5.7 用 5.1.x,驱动类名两者不同 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.9</version> </dependency> </dependencies>参数说明:scope=provided表示这个 jar 不打进 war 包,因为 Tomcat 的lib目录里已经有一份,打进去反而容易和容器自带的版本冲突,典型症状是启动时报NoSuchMethodError。mysql-connector-java的版本必须和数据库服务端对齐:连 MySQL 5.7 用 5.1.x,驱动类是com.mysql.jdbc.Driver;连 MySQL 8.0 用 8.0.x,驱动类是com.mysql.cj.jdbc.Driver,而且 JDBC URL 后面要带时区和 SSL 参数,否则控制台会刷一堆警告。
环境这一块我整理成一张表,照着对一遍能省掉大半启动问题:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 | 源码用了 lambda 和 try-with-resources,1.7 编译不过 |
| Tomcat | 8.5 / 9.0 | 9.0 起包名仍是 javax.servlet,10 之后变 jakarta,直接不兼容 |
| MySQL | 5.7 / 8.0 | 8.0 需改驱动类名和 JDBC URL |
| Maven | 3.6+ | 低版本解析不了部分中央仓库元数据 |
| 浏览器 | 任意现代浏览器 | 前端 Bootstrap 3 对 IE 兼容较好 |
这张表里最需要注意的是 Tomcat 版本。很多人本地装的是 Tomcat 10,把 war 包一扔,启动日志里全是ClassNotFoundException: javax.servlet.http.HttpServlet,然后开始怀疑源码有问题。实际情况是 Jakarta EE 9 之后包名从javax.*整体迁到了jakarta.*,而这套源码写的是javax,只能跑在 Tomcat 9 及以下。要么换容器,要么全局替换包名,没有第三条路。
3. 建库与表结构:借阅表才是这套系统的心脏
3.1 五张核心表与字段取舍
图书管理系统的表看着简单,真正决定系统好不好用的是那张借阅记录表。整套源码里有五张表:用户表、图书表、分类表、借阅表、公告表。前四张是业务刚需,公告表属于锦上添花,可以先不管。
用户表用role字段区分管理员和读者,而不是拆两张表。这个选择在老项目里很普遍,好处是登录逻辑只有一份,坏处是读者和管理员的字段需求不一样——管理员不需要借书额度,读者不需要管理权限,最后表里就会堆一批永远为空的列。读源码的时候看到t_user里有一堆DEFAULT NULL的字段,别觉得奇怪,这是历史妥协。
图书表的关键是total和stock两个字段。total是馆藏总量,stock是当前可借数量,借出一本stock减一,归还一本加一,total永远不动。这个冗余设计是整套系统的核心,第 3.2 节单独讲。
借阅表是业务闭环的载体,字段至少要有:谁借的(user_id)、借的哪本(book_id)、什么时候借的(borrow_time)、应该什么时候还(due_time)、实际什么时候还的(return_time)、当前状态(status)、产生的罚金(fine)。少了due_time就只能靠borrow_time现算,查询超期的时候每次都要写DATE_ADD,性能差还容易算错;少了fine就没法记录已经收过的钱,只能事后重算。
CREATE TABLE `t_borrow` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` INT UNSIGNED NOT NULL COMMENT '借书人', `book_id` INT UNSIGNED NOT NULL COMMENT '所借图书', `borrow_time` DATETIME NOT NULL COMMENT '借出时间', `due_time` DATETIME NOT NULL COMMENT '应还时间,借出时按 30 天算好落库', `return_time` DATETIME DEFAULT NULL COMMENT '实际归还时间,未还为空', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0 借出中 1 已归还 2 超期未还', `fine` DECIMAL(8,2) NOT NULL DEFAULT 0.00 COMMENT '超期罚金,归还时结算', PRIMARY KEY (`id`), KEY `idx_user` (`user_id`), KEY `idx_book` (`book_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅记录';字段类型上有两个点值得说。status用TINYINT而不是VARCHAR,因为状态判断在 SQL 里出现频率极高,整型比较比字符串快,而且不怕大小写和空格。fine用DECIMAL(8,2)而不是FLOAT,金额字段用浮点数迟早会遇到0.1 + 0.2 != 0.3的问题,罚金算出来 1.9999999 再四舍五入给用户看,解释起来很麻烦。
三个索引也有讲究。idx_user支撑「我的借阅」页面,idx_book支撑图书详情页查借阅历史,idx_status支撑管理员后台按状态筛选超期记录。少了idx_status,管理员点「只看超期」时就是全表扫描,数据量上万以后页面会明显卡顿。
3.2 库存字段为什么必须冗余一个 stock
这是整套系统里最值得琢磨的一个设计。理论上,可借数量完全可以通过total减去「未归还的借阅记录数」实时算出来,不需要单独存一个stock。为什么还要冗余?
因为并发。借书这个动作,如果按实时计算的方式做,流程是:先查这本书有多少条未归还记录,判断是否小于total,小于就插入一条借阅记录。两步之间没有任何锁保护,两个用户同时点借书,都查到「还剩 1 本」,都插入了记录,结果就是超借。要修就得在事务里锁住图书行或者加分布式锁,代价比冗余一个字段高得多。
冗余stock之后,扣减库存可以用一条带条件的 UPDATE 完成:
UPDATE t_book SET stock = stock - 1 WHERE id = ? AND stock > 0;这条语句的语义非常干净:只有库存大于 0 才会减,InnoDB会对这一行加排他锁,两个并发请求排队执行,第二个请求拿到的affectedRows就是 0,直接判定为借书失败。整个判断和扣减原子完成,不需要额外的查询。
代价是要维护一致性。归还的时候必须记得把stock加回去,管理员手工下架图书的时候要判断有没有在借记录,图书损坏报废的时候要同时改total和stock。这些地方漏一处,库存就会慢慢和实际对不上。后面第 5 章会讲一个专门排查库存不一致的 SQL,遇到「明明显示有货但借不了」的时候直接跑一遍就知道差在哪。
我一般的做法是在t_book上加一个约束CHECK (stock >= 0 AND stock <= total),MySQL 8.0.16 之后才真正生效,5.7 只能靠代码保证。如果数据库版本低,至少在BookService的所有写操作里统一走一个私有方法,别让改库存的 UPDATE 散落在各个 DAO 里。
3.3 从 SQL 脚本一键建库到初始化管理员
sql/library.sql这个脚本是整份源码能不能跑起来的第一道关。脚本一般包含三部分:建库、建表、插初始数据。执行方式有两种,命令行和客户端工具都行。
# 方式一:命令行直接导入,-u 用户名 -p 会提示输密码 mysql -uroot -p --default-character-set=utf8mb4 < sql/library.sql # 方式二:先登录客户端,再 source 绝对路径,路径不要带中文 mysql -uroot -p mysql> source /Users/dev/library-system/sql/library.sql;脚本开头的建库语句要注意字符集和排序规则:
DROP DATABASE IF EXISTS library_db; CREATE DATABASE library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE library_db;utf8mb4而不是utf8,因为 MySQL 的utf8实际上是三个字节的阉割版,书名里出现生僻字或者 emoji 会直接插入失败。utf8mb4_general_ci的ci表示大小写不敏感,登录时用户名搜Admin和admin能搜到同一条,符合用户直觉。
初始数据里最重要的是那条管理员账号。绝大多数版本插入的是明文 MD5,比如用户名admin,密码是123456对应的 32 位 MD5 值。这里有个坑:有些脚本插的 MD5 是带大写字母的,而MD5Util输出的是小写,登录时字符串比较不相等,密码明明对却登不进去。解决办法很简单,导入后先SELECT username, password FROM t_user;看一眼格式,如果长度不是 32,或者含非十六进制字符,那就是脚本里的值和代码算的对不上,手动 UPDATE 一条就行:
-- 登录密码统一按 123456 处理,MD5 小写 32 位 UPDATE t_user SET password = 'e10adc3949ba59abbe56e057f20f883e' WHERE username = 'admin';测试图书数据也别忽略。脚本如果只插了十本八本书,分页功能根本测不出来,pageSize设成 10 的话永远只有一页,翻页参数算错也看不出来。我一般会手动补一批数据,或者跑一段循环插入:
-- 造 60 条测试图书,方便验证分页和第二页的数据正确性 INSERT INTO t_book (isbn, title, author, category_id, total, stock, location) SELECT CONCAT('9780000000', LPAD(n, 2, '0')), CONCAT('测试图书', n), '某作者', 1, 3, 3, 'A-01' FROM ( SELECT a.N + b.N * 10 + 1 AS n FROM (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a, (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5) b ) t;这段 SQL 用了笛卡尔积生成 1 到 60 的序号,LPAD保证 ISBN 末尾补零到固定长度。跑完以后t_book里就有足够的数据,分页、搜索、排序这几个功能才测得出来。
4. 核心模块拆解:一次借书请求从 Servlet 走到事务提交
4.1 登录与权限过滤:Session 里存什么、Filter 拦什么
登录是整个系统的入口,也是安全性最薄弱的地方。这套源码的登录流程是:JSP 表单提交明文密码,LoginServlet收到后先做 MD5,再拿哈希去数据库比对,比对成功就把用户对象塞进 Session。
@WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // POST 中文参数必须先设编码,否则 username 里带中文会变乱码 req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); // 前端传明文,后端统一 MD5 后再比对,库里永远不存明文密码 User user = userService.login(username, MD5Util.encode(password)); if (user == null) { // 失败不区分「用户不存在」和「密码错误」,避免被撞库探测 req.setAttribute("msg", "用户名或密码不正确"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } // 只放必要字段,密码哈希绝对不进 Session user.setPassword(null); HttpSession session = req.getSession(true); session.setAttribute("loginUser", user); // 30 分钟无操作自动失效,别用默认的 30 秒 session.setMaxInactiveInterval(30 * 60); // 按角色分流到不同首页,读者进不了管理端 String target = user.getRole() == 1 ? "/admin/index.jsp" : "/reader/index.jsp"; resp.sendRedirect(req.getContextPath() + target); } }逻辑上有三个点值得展开。第一,req.setCharacterEncoding("UTF-8")必须写在getParameter之前,写在后面完全无效,这是 Servlet 的规范,不是执行顺序的巧合。第二,登录失败时统一提示语,不要写成「该用户不存在」,否则别人可以拿这个接口批量探测哪些用户名是有效的。第三,user.setPassword(null)这一行很容易被忽略,把整个实体塞进 Session 会导致密码哈希跟着序列化下去,一旦 Session 文件被读到就泄露了。
光有登录不够,还得有拦截。这套源码用了一个@WebFilter覆盖/admin/*和/reader/*:
@WebFilter(urlPatterns = {"/admin/*", "/reader/*"}) public class AuthFilter implements Filter { // 白名单:登录页、登录接口、静态资源必须放行,否则会死循环重定向 private static final List<String> WHITE_LIST = Arrays.asList("/login.jsp", "/login", "/static/"); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String uri = req.getRequestURI().substring(req.getContextPath().length()); if (WHITE_LIST.stream().anyMatch(uri::startsWith)) { chain.doFilter(request, response); return; } User user = (User) req.getSession().getAttribute("loginUser"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } // 越权校验放在服务端,页面上藏菜单只是体验优化,挡不住直接敲 URL if (uri.startsWith("/admin") && user.getRole() != 1) { resp.sendError(HttpServletResponse.SC_FORBIDDEN, "无权限访问"); return; } chain.doFilter(request, response); } }参数说明:urlPatterns用通配符匹配整个目录,比逐个列出 JSP 省事;WHITE_LIST里的/static/必须加,否则 CSS 和 JS 也会被重定向到登录页,页面加载出来是一堆没有样式的裸 HTML,容易被误判成前端崩了。substring(req.getContextPath().length())这步是去掉项目名,因为 war 包部署后路径会变成/library/admin/xxx,不去掉前缀匹配不上。
4.2 图书分页查询:LIMIT 参数怎么算
分页是查询类功能里最容易写错的地方,错的地方基本都在偏移量上。这套源码用的是最朴素的LIMIT offset, size,配合一次COUNT(*)拿总数。
public PageResult<Book> pageByKeyword(String keyword, int pageNum, int pageSize) { // 页码容错:小于 1 归 1,防止前端传 0 或负数导致偏移量为负 pageNum = Math.max(pageNum, 1); int offset = (pageNum - 1) * pageSize; // 关键字为空时走全量查询,用 1=1 拼条件比写两套 SQL 省事 String where = " FROM t_book WHERE 1=1 "; List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { where += " AND (title LIKE ? OR author LIKE ? OR isbn LIKE ?) "; String like = "%" + keyword.trim() + "%"; params.add(like); params.add(like); params.add(like); } // 先查总数,前端分页条要用它算总页数 long total = queryCount("SELECT COUNT(1) " + where, params.toArray()); // 再查当前页,ORDER BY id DESC 保证新书排在前面且分页结果稳定 String sql = "SELECT id,isbn,title,author,publisher,category_id,total,stock,location " + where + " ORDER BY id DESC LIMIT ?,?"; params.add(offset); params.add(pageSize); List<Book> rows = queryList(sql, params.toArray(), Book.class); return new PageResult<>(rows, total, pageNum, pageSize); }逻辑说明:offset的计算公式是(页码 - 1) * 每页条数,第一页偏移量是 0,第二页是pageSize。这个公式写反了(比如写成pageNum * pageSize)会导致第一页直接跳过前 10 条,用户看到的第一页其实是从第 11 本开始的,而且不报错,属于典型的静默 bug。
参数说明:LIMIT后面的两个占位符在PreparedStatement里只能传整型,传字符串会报语法错误;ORDER BY必须带一个唯一性足够的字段,如果按title排序而书名有重复,翻页时同一条记录可能出现在两页上,用主键id最稳。另外COUNT(1)和COUNT(*)在 InnoDB 下性能一致,选哪个都行,别用COUNT(字段名),那一列有 NULL 就会少算。
PageResult是个很简单的包装类,内部持有rows、total、pageNum、pageSize,另外提供getTotalPages()返回(total + pageSize - 1) / pageSize。JSP 里拿它循环渲染表格和底部分页条,c:forEach遍历rows,页码按钮按totalPages生成。
4.3 借书与还书:事务、行锁和超期罚金的算法
借书是整套系统唯一需要严格事务的地方,因为它要同时改两张表:t_book减库存,t_borrow插记录。中间任何一步失败都必须整体回滚,否则就会出现「库存扣了但没借出去」或者「借出去了但库存没扣」的脏数据。
public boolean borrow(int userId, int bookId) { Connection conn = null; try { conn = DBUtil.getConnection(); // 关掉自动提交,事务边界从这里开始 conn.setAutoCommit(false); // 第一步:扣库存。WHERE 带 stock > 0,靠 InnoDB 行锁挡住并发超借 String sqlStock = "UPDATE t_book SET stock = stock - 1 WHERE id = ? AND stock > 0"; try (PreparedStatement ps = conn.prepareStatement(sqlStock)) { ps.setInt(1, bookId); if (ps.executeUpdate() != 1) { conn.rollback(); return false; // 库存已空,直接失败,不抛异常 } } // 第二步:同一用户不能重复借同一本还没还的书 String sqlCheck = "SELECT COUNT(1) FROM t_borrow " + "WHERE user_id = ? AND book_id = ? AND status IN (0, 2)"; try (PreparedStatement ps = conn.prepareStatement(sqlCheck)) { ps.setInt(1, userId); ps.setInt(2, bookId); try (ResultSet rs = ps.executeQuery()) { if (rs.next() && rs.getInt(1) > 0) { conn.rollback(); return false; // 已借未还,回滚掉刚才扣的库存 } } } // 第三步:写借阅记录,应还时间在 SQL 里算好,避免应用和数据库时区不一致 String sqlInsert = "INSERT INTO t_borrow(user_id, book_id, borrow_time, due_time, status) " + "VALUES(?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), 0)"; try (PreparedStatement ps = conn.prepareStatement(sqlInsert)) { ps.setInt(1, userId); ps.setInt(2, bookId); ps.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { DBUtil.rollbackQuietly(conn); // 回滚本身也可能失败,静默处理并记录日志 throw new RuntimeException("借书失败,bookId=" + bookId, e); } finally { DBUtil.close(conn); // 恢复 autoCommit 并归还连接池 } }逻辑说明:三个步骤的顺序不能换。先扣库存再查重复借阅,是因为扣库存那条 UPDATE 会拿到行锁,相当于把并发请求串行化了;如果先查重复再扣库存,两个请求可能同时通过重复校验,然后一起去抢库存,虽然库存不会超,但会产生两条借阅记录。第三步的时间用NOW()和DATE_ADD由数据库计算,比在 Java 里new Date()再拼字符串可靠,应用服务器和数据库时区不一致时不会算错。
参数说明:INTERVAL 30 DAY是借阅期限,改成 60 天就动这一个数字;status IN (0, 2)里 0 是借出中、2 是超期未还,两种状态都算「没还」,都得拦住;DBUtil.rollbackQuietly是个包装方法,内部try-catch掉回滚异常,只打日志不抛出,避免回滚失败把原始异常吞掉。
还书流程反过来:更新t_borrow的return_time和status,同时把库存加回去,然后结算罚金。
// 罚金规则:超期天数 × 0.2 元,单本封顶 20 元 // 用 due_time 和 NOW() 比较,未超期返回 0 public double calcFine(Date dueTime) { long diffMs = System.currentTimeMillis() - dueTime.getTime(); if (diffMs <= 0) { return 0.0; } long days = diffMs / (1000L * 60 * 60 * 24); // 不足一天按一天算,向上取整更符合图书馆惯例 if (diffMs % (1000L * 60 * 60 * 24) > 0) { days++; } return Math.min(days * 0.2, 20.0); }这段算法的两个参数——0.2 的日罚金和 20 元的封顶——在实际项目里通常要做成可配置项,塞进t_config表或者配置文件,别硬编码在 Java 里。另外向上取整这个细节很关键,超期 1 小时按 1 天算,读者可能会觉得不近人情,但如果不取整,超期 23 小时罚金是 0,就会出现大批人卡着点还书的情况。
5. 本地跑通与避坑排查:五个让项目启动即翻车的点
5.1 从空机器到登录页的六步
拿到源码压缩包之后,别急着点运行,按顺序走这六步,成功率最高。
第一步,确认 JDK 是 1.8。命令行敲java -version,输出里带1.8.0_xxx才算对。装了 11 或 17 也能编译,但 Tomcat 8.5 跑起来会有模块访问警告,新手容易被吓到。
第二步,建库导数据。用 3.3 节的命令把library.sql导进去,然后USE library_db; SHOW TABLES;确认五张表都在。
第三步,改src/main/resources/db.properties:
# 驱动类名:MySQL 5.7 用 com.mysql.jdbc.Driver,8.0 用 com.mysql.cj.jdbc.Driver jdbc.driver=com.mysql.cj.jdbc.Driver # 8.0 必须带时区和 SSL 参数,否则启动刷警告,严重时连接直接失败 jdbc.url=jdbc:mysql://127.0.0.1:3306/library_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=你的密码 # 连接池参数,学习环境给 5 个够用,别开太大 jdbc.initialSize=2 jdbc.maxActive=10第四步,mvn clean package -DskipTests,产物在target/下,是个.war文件。如果报编译错误说找不到javax.servlet,检查pom.xml里 Servlet API 的scope是不是写成了compile,以及本地 Maven 仓库是不是下崩了。
第五步,把 war 包丢进 Tomcat 的webapps目录,启动bin/startup.sh(Windows 用startup.bat),看logs/catalina.out里有没有Server startup in xxx ms。有这行就说明容器起来了。
第六步,浏览器访问http://localhost:8080/library/,注意路径里的library是 war 包名,不是项目名,改了 war 包名路径就得跟着改。用初始管理员账号登录,能进后台就说明整条链路通了。
5.2 五条踩坑记录:现象、原因、解决
第一条,启动报NoClassDefFoundError: javax/servlet/http/HttpServlet。
现象是 Tomcat 日志里一堆类找不到,war 包根本没解压成功。原因是本地 Tomcat 是 10.x,包名已经迁到jakarta.servlet,和源码里的javax.servlet对不上。解决方式是换 Tomcat 9.0 或 8.5,不要试图改源码包名——那要改几百处 import 和 JSP 标签,得不偿失。
第二条,页面能打开但全是乱码,中文书名显示成问号。
现象是数据库里查出来正常,页面上是???。原因有两个层面:JDBC URL 里少了characterEncoding=utf8mb4,或者数据库和表的字符集建成了latin1。解决分两步,先在 URL 上补齐参数,再执行SHOW CREATE TABLE t_book;看表的CHARSET,不是utf8mb4就ALTER TABLE t_book CONVERT TO CHARACTER SET utf8mb4;。改了表还不行,那就检查 JSP 页面头部有没有<%@ page contentType="text/html;charset=UTF-8" %>。
第三条,登录一直提示密码错误,但数据库里明明有这条记录。
现象是账号存在,密码输对了也进不去。原因通常是脚本里插入的 MD5 值和MD5Util.encode算出来的不一致,比如大小写不同、或者插入的其实是明文。排查方法是在登录逻辑里临时打一行日志,输出算出来的哈希,和数据库里的值逐字符比对。解决就是按 3.3 节那条 UPDATE 语句把密码重置成标准的小写 32 位 MD5。
第四条,借书按钮点了没反应,控制台报 500 但日志里没有堆栈。
现象是接口返回 500,catalina.out里只有一行Servlet.service() threw exception。原因是BorrowServlet里 catch 了异常但只打了e.getMessage(),堆栈丢了。这种情况优先怀疑事务没提交或者连接没关,导致连接池耗尽。排查手段是查连接池状态,或者临时把 catch 块里的日志改成e.printStackTrace()。长期做法是统一用日志框架,别用System.out。
第五条,库存对不上,显示有货却借不了,或者显示无货却能借。
现象是t_book.stock和实际未归还记录数不符。原因多半是某次还书流程中途失败,库存没加回去,或者有人手工改过数据库。排查用这条 SQL 一次看清所有差异:
-- 理论可借数 = total - 未归还记录数,和 stock 比对,差值不为 0 的就是脏数据 SELECT b.id, b.title, b.total, b.stock, b.total - COUNT(br.id) AS should_stock, b.stock - (b.total - COUNT(br.id)) AS diff FROM t_book b LEFT JOIN t_borrow br ON br.book_id = b.id AND br.status IN (0, 2) GROUP BY b.id, b.title, b.total, b.stock HAVING diff <> 0;查出来之后别急着 UPDATE,先搞清楚是哪次操作漏了。如果只是测试环境,直接按should_stock刷一遍就行;生产环境要先补一条修正记录,再改库存,留个审计痕迹。
6. 二次开发:把 JSP 里散落的 SQL 收回到 DAO 层
这套源码拿来交作业没问题,但要真往上加功能,第一件该干的事不是加页面,是清理 JSP 里的脚本块。很多版本的图书列表页里有<%@ page import="java.sql.*" %>,直接在 JSP 里DriverManager.getConnection然后while(rs.next())拼表格。这种写法每加一个查询就复制一遍连接代码,连接泄漏几乎是必然的。
改造思路很朴素:JSP 只负责渲染,数据由 Servlet 通过 Service 调 DAO 拿到,放进request域再forward。下面是一个最小改动的对比,先把BookDao抽成接口,实现类里只留 SQL。
// 改造前:JSP 里直接查库,连接用完忘记关,池子几天就漏光 // 改造后:DAO 接口定义行为,实现类专注 SQL,Service 管事务 public interface BookDao { Book findById(int id); List<Book> pageByKeyword(String keyword, int offset, int size); long countByKeyword(String keyword); int decreaseStock(Connection conn, int bookId); // 传入连接,参与外层事务 int increaseStock(Connection conn, int bookId); }注意decreaseStock这个方法签名,它要求外部传入Connection,而不是自己从池里取。这是为了让借书事务能把扣库存和写借阅记录放在同一个连接里,如果 DAO 内部自己getConnection(),两条 SQL 就跑到不同的连接上,rollback只能回滚其中一条,事务形同虚设。这是我在改造时踩过的最大的一个坑,接口设计阶段没想清楚,后面所有涉及事务的方法都得重写。
验证改造有没有效果,我一般用两个指标:一是全局搜索DriverManager和getConnection,除了DBUtil之外不应该有第二处;二是压一下借书接口,用ab -n 200 -c 20打一轮,看连接池的活跃连接数会不会持续上涨不回落。如果涨上去下不来,就是有地方借了连接没还。
| 改造项 | 改前 | 改后 | 验证方式 |
|---|---|---|---|
| 数据访问 | JSP 内嵌 SQL | DAO 接口 + 实现类 | 全局搜DriverManager |
| 事务边界 | 无 | Service 层统一控制 | 借书失败后库存不变 |
| 分页 | 页面内LIMIT拼字符串 | PageResult统一封装 | 翻到最后一页数据不重复 |
| 权限 | 隐藏菜单 | Filter 服务端校验 | 直接敲/admin/返回 403 |
加新功能的时候也照这个套路来。比如要加「预约借书」,改动面其实很小:加一张t_reservation表存用户和图书的预约关系,ReservationService里写一个插入方法,图书归还时判断有没有预约记录并给用户发站内信,前端加一个按钮和一个列表页。整个过程不需要动借书还书的核心事务,因为库存和借阅记录的逻辑没变,预约只是挂在旁边的旁支。
有次我图省事,在还书方法里直接加了几行预约通知的代码,结果通知发送失败把整个还书事务带崩了,库存回滚了但书已经还到柜台,对账对了半天。从那以后我每次往已有事务里塞新逻辑,都强制问自己一遍:这段代码失败了,该不该让主流程跟着回滚?答案是不该的,就包一层 try-catch 单独处理,或者干脆挪到事务提交之后。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取