☰
Java图书馆管理系统实训:建表、分层、事务与并发借阅复习指南
2026/10/23 16:19:46 网站建设 项目流程

简介:面向Java初学者与高校实训课程学习者的图书馆管理系统实训项目源码包,围绕图书信息管理、读者信息管理、借阅与归还、图书查询及统计分析等核心模块展开,可作为课程设计、期末大作业或Java面向对象与数据库编程练习的参考实现。压缩包共1988个文件、约6MB,以676个xmi建模文件、128个class字节码、30个java源码为主,另含xml配置、launch运行配置、prefs与log等工程元数据,较完整地保留了原始工程结构与建模痕迹。已有230人学习下载。读者可借此了解MVC分层组织方式、JDBC数据访问写法与Swing界面布局思路,并对照UML模型与编译产物梳理从设计到实现的完整链路;遇到数据库连接、界面布局或业务规则判断等问题时,也能找到可参考的排查线索。

1. 知识地图:图书馆管理系统这门实训到底在考什么

图书馆管理系统几乎是每个 Java 实训任务里被选得最多的一道题,原因不是它简单,而是它把所有基础知识点串成了一条线:建表、外键、JDBC、分层、事务、并发、界面,一个都跑不掉。整体长这样——一张 ER 图(图书、读者、借阅记录、管理员),四层代码(实体、DAO、Service、界面),三条业务主线(借书、还书、查询统计)。适合刚学完语法和 JDBC、要交实训项目的人,也适合拿它复习数据库约束和事务边界的人。反直觉的地方在于:多数人把八成时间花在界面上,可演示时被问住的永远是那句「两个人同时借最后一本会怎样」。这份笔记就按「数据模型 → 分层 → 核心业务 → 易错点 → 验收清单」重排一遍,拿来就能照着复习。

2. 数据模型先行:图书、读者、借阅三张表的边界

2.1 实体与关系怎么落成表

做这个系统最容易犯的顺序错误,是先写界面再想表。真实的推进顺序应该反过来:先把名词挑出来,再判断哪些名词配得上单独一张表。图书、读者、借阅记录、分类、管理员,这五个名词里,前三个是核心,分类是字典表,管理员是权限表。判断标准很朴素:如果一个对象需要独立维护生命周期(上架、下架、注销、挂失),它就该有主键。

关系上有三组一对多必须理清:分类对图书是一对多,读者对借阅记录是一对多,图书对借阅记录也是一对多。注意第三组——很多人下意识把「图书」和「借阅记录」写成一对多之后,又在 book 表里塞一个current_reader_id,这就把历史记录压扁成了当前状态,一旦要查「某读者三个月前借过什么」就无解了。借阅记录必须独立成表,一次借阅一行,这条是底线。

表名主键外键作用常见误设
bookidcategory_id图书档案与库存把借阅人写进本表
readerid无读者档案与状态缺少「可借上限」字段
borrow_recordidbook_id、reader_id每次借还的流水归还后删除该行
categoryid无分类字典直接用字符串存分类名

这张表建议直接抄进需求文档里,因为实训答辩时老师翻的就是这块。分类如果用字符串硬存,后期改名会出现「文学」和「文学类」并存,统计口径立刻乱掉。

2.2 字段与约束:把规则写进 DDL

建表时能写进数据库的规则,就别留给 Java 代码去兜。这不是洁癖,而是因为 Service 层可以绕过去(比如你后来加了个批量导入工具),数据库约束绕不过去。下面这段 DDL 是模拟项目里最终采用的版本,几个关键点:isbn加唯一索引防重复录入,库存用CHECK卡住区间,借阅记录用DATE而不是DATETIME存日期,罚款用DECIMAL而不是DOUBLE。

-- 图书表:库存拆成总量与可借两个字段,CHECK 卡住非法区间 CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL, title VARCHAR(120) NOT NULL, author VARCHAR(60) NOT NULL, category_id INT NOT NULL, total_stock INT NOT NULL DEFAULT 0, available INT NOT NULL DEFAULT 0, price DECIMAL(8,2) DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1, -- 1 上架 0 下架 create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uk_book_isbn UNIQUE (isbn), CONSTRAINT ck_book_stock CHECK (available >= 0 AND available <= total_stock) ); -- 借阅流水表:一次借阅一行,归还只改状态不删行 CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_id BIGINT NOT NULL, reader_id BIGINT NOT NULL, borrow_date DATE NOT NULL, due_date DATE NOT NULL, return_date DATE NULL, status TINYINT NOT NULL DEFAULT 0, -- 0 借出 1 已还 2 逾期未还 renew_count TINYINT NOT NULL DEFAULT 0, fine DECIMAL(8,2) NOT NULL DEFAULT 0.00, CONSTRAINT fk_br_book FOREIGN KEY (book_id) REFERENCES book(id), CONSTRAINT fk_br_reader FOREIGN KEY (reader_id) REFERENCES reader(id), INDEX idx_br_reader_status (reader_id, status) );

available >= 0这条约束是整套系统的最后一道防线,2.3 节和 4.3 节都会回到它。idx_br_reader_status这个联合索引不是凑数的:「查某读者当前未还的书」是最高频的查询,没有它就是全表扫。due_date单独存而不是每次用borrow_date + 30算,是因为续借会改期限,一旦允许续借,可推导字段就必须落成实字段。

注意:MySQL 8.0 之前的版本会解析CHECK但不生效,如果实训环境是 5.7,这条约束等于没写,Service 层必须自己判一次。

2.3 库存字段:冗余还是实时计算

这是设计阶段最值得吵一架的点。方案 A 只在 borrow_record 里记流水,可借数量用「总量减去未归还条数」实时算;方案 B 在 book 表里冗余一个available字段,借书减一、还书加一。两种都有人用,差别在取舍。

对比项实时计算(方案 A)冗余字段(方案 B)
数据一致性天然一致需要事务保证
查询性能每次都要 JOIN 聚合单表读取
并发扣减难以原子化可条件更新
出错表现慢,但不会错快,但可能对不上

实训项目里推荐方案 B,理由不是性能,而是并发。方案 A 要保证「两个人同时借最后一本只有一个人成功」,得靠SELECT ... FOR UPDATE锁住聚合结果,写法绕;方案 B 一句UPDATE book SET available = available - 1 WHERE id = ? AND available > 0就解决了,看受影响行数是不是 1 即可。代价是必须付出对账成本——第六章给了一条对账 SQL,建议在提交前跑一遍。

3. 分层架构:实体、DAO、Service、界面各管什么

3.1 四层职责与调用方向

分层最容易写成「为了分而分」:DAO 里写业务判断,Service 里拼 SQL,界面层直接 new 一个 Connection。判断分层有没有做到位,看一条规则就够——依赖只能从上往下,不能反向,也不能跨层。

层该做的事不该做的事判定信号
entity承载字段、做简单自校验访问数据库、写业务分支能脱离数据库单独 new
dao单表增删改查、拼 SQL判断能不能借、算罚款方法名都是 find/save/update
service业务规则、事务边界出现 ResultSet、出现 SQL 字符串一个方法对应一个用例
ui收集输入、渲染结果校验业务规则、开事务换一套界面不用改下层

一个很实用的自检:把 ui 层整个删掉,Service 的单元测试还能不能跑通?能跑通,说明分层是真的;跑不通,说明业务逻辑漏到界面里了。A 同学当年就是把「读者有逾期未还的书就不许再借」写在了按钮的点击事件里,后来加了个批量导入借阅数据的功能,规则直接失效。

3.2 DAO 层:模板化 JDBC 与占位符

DAO 层的手写 JDBC 风格很固定:拿连接、建 PreparedStatement、绑参数、遍历结果集、关资源。写多了会烦,但实训阶段不建议直接上框架,因为答辩问的恰恰是底层。下面这段是「按 ID 查图书」的标准写法,重点看两处:try-with-resources保证连接一定关,以及所有变量都用?占位。

public Book findById(long id) { String sql = "SELECT id, isbn, title, author, total_stock, available, status " + "FROM book WHERE id = ?"; // try-with-resources:异常时也会按相反顺序关闭 rs / ps / conn try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, id); // 参数下标从 1 开始,不是 0 try (ResultSet rs = ps.executeQuery()) { return rs.next() ? mapRow(rs) : null; // 查不到返回 null,交给上层判空 } } catch (SQLException e) { throw new DaoException("查询图书失败, id=" + id, e); // 包装后往上抛,不吞异常 } }

mapRow抽成私有方法,是为了让所有查询共用一套字段映射,将来加了字段只改一处。异常包装这步很多人省掉,直接把SQLException往上抛,结果 Service 层到处catch (SQLException),业务代码被异常处理淹掉。统一包成运行时异常,Service 只关心业务失败。

3.3 Service 层:业务规则住在这里

把「能不能借」拆开,其实有四条独立规则:图书存在且上架、可借数量大于零、读者状态正常且未达借阅上限、读者没有逾期未还的书。这四条放一起判,任何一条不满足就抛业务异常,UI 只负责把异常信息弹出来。这种写法让规则可测、可改、可答辩。

public BorrowResult borrow(long bookId, long readerId) { Book book = bookDao.findById(bookId); if (book == null || book.getStatus() != 1) { return BorrowResult.fail("图书不存在或已下架"); } Reader reader = readerDao.findById(readerId); if (reader == null || reader.getStatus() != 1) { return BorrowResult.fail("读者状态异常,无法借阅"); } if (recordDao.countUnreturned(readerId) >= reader.getMaxBorrow()) { return BorrowResult.fail("已达借阅上限"); } if (recordDao.countOverdue(readerId) > 0) { return BorrowResult.fail("存在逾期未还图书"); } // 真正的扣减放到事务里,见 4.2 return doBorrow(book, reader); }

返回值用BorrowResult而不是布尔值,是因为失败原因要展示给用户,「不能借」和「为什么不能借」是两件事。这条设计在答辩时是个加分项,因为它体现了「界面不做判断」这个原则。

3.4 界面层只做两件事

不管用 Swing、JavaFX 还是 Servlet/JSP,界面层的职责边界是一样的:把控件里的值取出来,交给 Service;把 Service 的结果渲染回去。它不该知道什么叫逾期,也不该知道罚款怎么算。判断标准前面提过——换一套界面不用动下层。

界面层唯一的额外责任是格式化和输入初筛:日期字符串转LocalDate、金额保留两位、必填项非空。这些属于表达层的事,不算业务规则。把「日期格式解析失败」和「该读者有逾期」混在一起抛,用户看到的就是一句看不懂的报错,这是实训演示时最常见的尴尬场面。

4. 借阅与归还的核心逻辑:状态机与事务

4.1 一条借阅记录的状态机

整个系统里唯一有状态流转的实体是借阅记录,把它画清楚,业务逻辑就清楚了一半。状态只有四个:借出、已还、逾期、丢失。允许的迁移也很少:借出 → 已还(正常归还)、借出 → 逾期(到期未还,可由定时任务或查询时判定)、借出 → 丢失(人工登记)、逾期 → 已还。禁止的迁移更多:已还不能回到借出,丢失不能直接变成已还,已还的记录不能再算罚款。

当前状态允许动作目标状态附带副作用
借出归还已还available + 1,写 return_date
借出到期未还逾期无,仅状态变化
借出续借借出due_date 后延,renew_count + 1
逾期归还已还available + 1,计算罚款
丢失登记丢失扣减 total_stock,不还库存

续借要卡renew_count < 1,这条规则最好也写进 Service 而不是界面按钮的可用状态里——按钮可以被绕过,Service 不能。至于逾期状态,实训项目里最省事的做法是不设定时任务,在查询时用due_date < CURDATE()动态判定,代价是状态字段和实际语义会短暂不一致,这一点在答辩时要能说清楚。

4.2 事务边界:一次借书写了哪几张表

一次借阅要落两处数据:borrow_record 插一行,book.available 减一。这两步必须同生共死,否则会出现「流水有记录、库存没扣」这种对不上账的情况,而对账一旦出错,排查成本极高。事务边界的划分原则是:把「必须一起成功」的操作圈在一起,圈得越小越好。

private BorrowResult doBorrow(Book book, Reader reader) { Connection conn = null; try { conn = DbUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,事务开始 // 第一步:条件扣减库存,返回 0 说明被别人抢走了 int affected = bookDao.decreaseAvailable(conn, book.getId()); if (affected == 0) { conn.rollback(); return BorrowResult.fail("该书已被借完"); } // 第二步:写借阅流水,到期日 = 借出日 + 30 天 LocalDate today = LocalDate.now(); recordDao.insert(conn, book.getId(), reader.getId(), today, today.plusDays(30)); conn.commit(); // 两步都成功才提交 return BorrowResult.ok(); } catch (SQLException e) { rollbackQuietly(conn); // 回滚本身也可能抛,必须吞掉 throw new ServiceException("借阅失败", e); } finally { closeQuietly(conn); // 归还连接,别在这里 commit } }

两个细节值得单独记:setAutoCommit(false)之后,DAO 方法必须接收外部传入的conn,不能自己DbUtil.getConnection(),否则就是两个连接、两个事务,白写;finally里只关连接不回滚,因为回滚已经在 catch 里做过了,重复回滚在某些驱动上会抛异常,把真正的错误盖掉。

4.3 并发:条件更新压住最后一次借阅

两个人同时点「借阅」抢最后一本,是这类系统最经典的翻车点。错误的写法是先SELECT available拿到 1,判断大于零,再UPDATE available = 1 - 1——两个线程都读到 1,都判断通过,最后库存变成 -1。正确写法是把判断塞进 UPDATE 的 WHERE 里,让数据库的原子性替你做判断。

// 条件更新:只有 available > 0 才会命中,受影响行数就是判定结果 String sql = "UPDATE book SET available = available - 1 " + "WHERE id = ? AND available > 0";

这个方法返回受影响行数,1 代表抢到,0 代表没抢到,不需要额外查询。这种做法本质上就是乐观锁,成本极低,在库存扣减、余额扣减这类场景通用。如果还想更严格,可以再加一个version字段做版本号,但实训项目通常用不上。

提示:别用SELECT ... FOR UPDATE去锁 book 行然后再 UPDATE,虽然也能对,但它会持有行锁到事务结束,一旦中间夹了慢操作(比如生成流水号),并发性能立刻掉下来,还容易出现死锁。

4.4 归还与逾期罚款

归还要做三件事:定位记录、算罚款、回补库存,三件事在同一个事务里。罚款按「逾期天数 × 日费率」,日费率在模拟项目里取 0.1 元,天数用ChronoUnit.DAYS.between(dueDate, returnDate)算,负数取 0。这里最容易被忽略的是时间基准:LocalDate和LocalDateTime千万别混用,一个借阅记录用DATE存,代码里却拿LocalDateTime.now()去减,跨零点就会出现一天的误差。

long days = ChronoUnit.DAYS.between(record.getDueDate(), returnDate); BigDecimal fine = days <= 0 ? BigDecimal.ZERO : BigDecimal.valueOf(days).multiply(DAILY_RATE); // 金额用 BigDecimal,别用 double

金额用double会在累加时出现0.30000000000000004这种结果,展示层再一格式化,用户看到的就是莫名奇妙的数字。这条不是理论洁癖,是血泪经验。

5. 易错点:那些跑得通但会翻车的地方

5.1 连接泄漏:最安静的一种错误

易漏点 → 忘了在异常分支关连接,或把Connection存成成员变量复用。为什么漏 → 单机测十条数据完全正常,连接池上限没到之前毫无征兆。怎么补 → 全部改成try-with-resources,并在验收前把池子最大连接数调成 2,跑一轮借还操作,撑不住就是漏了。

这个手法很好用:不用看代码,改个配置就能把泄漏暴露出来。连接池满之后的表现是请求卡住不返回,而不是报错,所以很多人以为是界面卡死,去查错方向。

5.2 库存对不上:状态改了,冗余字段没跟上

易漏点 → 归还时只更新了borrow_record.status,忘了available + 1;或者续借改了due_date,没更新记录状态。为什么漏 → 两个操作写在两个 DAO 方法里,中间没事务,第一个成功第二个抛异常就断在那了。怎么补 → 所有「改状态 + 改库存」的动作统一走一个 Service 方法、一个事务,并且定期跑对账 SQL(见 6 章)。

比对账更省事的办法是:在 Service 里把「回补库存」封成唯一入口,任何路径都必须调它。只要入口唯一,漏掉的可能性就降到零。

5.3 日期与金额:跨月、闰年、double

易漏点 → 用Date.getMonth()(已废弃且从 0 开始)算月份;用毫秒差除以 86400000 算天数;用double存罚款。为什么漏 → 测试数据都在同一个月内,看不出偏差。怎么补 → 统一用java.time包,日期差用ChronoUnit.DAYS.between,金额用BigDecimal且指定RoundingMode.HALF_UP。

还有一个隐蔽的:LocalDate.now()依赖服务器时区,如果部署环境和开发环境不一致,逾期天数会整体偏一天。实训演示一般不会踩到,但写进笔记里,将来上生产能省一次通宵。

5.4 SQL 拼接与逻辑删除

易漏点 → 图书检索用字符串拼LIKE '%" + keyword + "%';删除图书直接DELETE FROM book。为什么漏 → 前者在功能上完全正常,后者在单表测试时也不报错。怎么补 → 检索一律PreparedStatement加LIKE CONCAT('%', ?, '%');有借阅历史的图书只做逻辑删除(status = 0),物理删除会被外键约束挡住,或者更糟——级联删掉了历史流水。

外键报错其实是好事,它替你拦住了错误操作。真正危险的是建表时图省事没加外键,删书删得干干净净,等要统计「这本书历史上被借过多少次」时,数据已经没了,后悔药没处买。

6. 复习清单:从建表到演示的验收路径

最后一章给一份能照着走的验收路径,按顺序做完,基本不会在演示现场翻车。第一步,清库重跑建表脚本,确认CHECK和外键都生效(5.7 环境要单独验证);第二步,灌入三条测试数据:一本库存为 1 的书、一个已达借阅上限的读者、一条已逾期的记录;第三步,按「借书 → 续借 → 归还 → 逾期罚款」跑一遍主流程;第四步,开两个窗口同时借最后一本,看是否只有一个成功。

验收动作期望结果出错先看哪
重复录入同一 ISBN唯一索引报错uk_book_isbn 是否存在
库存 1 的书被两人同借一人成功一人提示借完UPDATE 是否带 available > 0
归还后查库存available 回补 1事务是否包含两步
逾期 10 天归还罚款 1.00 元日期类型是否混用
检索关键字含单引号正常返回空结果而非报错是否用了字符串拼接

跑完流程后,用一条对账 SQL 收尾,把「库存字段」和「借阅流水」两套数据对一遍,能查出所有中途漏更新的记录:

-- 对账:可借数量应等于总量减去未归还条数,不一致的就是漏更新 SELECT b.id, b.title, b.total_stock, b.available, b.total_stock - COUNT(r.id) AS expect_available FROM book b LEFT JOIN borrow_record r ON r.book_id = b.id AND r.return_date IS NULL GROUP BY b.id, b.title, b.total_stock, b.available HAVING b.available <> expect_available;

结果是空集才算过关,非空就顺着 id 去查那张表的操作日志。这条 SQL 建议直接存进项目的sql/目录,答辩时被问「你怎么保证数据一致」,把它跑一遍比讲十分钟设计都管用。

复习顺序上,我的建议是先啃第 2 章的建表和约束,再回头看第 3、4 章的分层和事务——因为大部分「事务写了却没生效」的根因,其实是表设计阶段就埋下的(比如冗余字段没加约束、外键缺失导致删改不受控)。第 5 章的四条易错点适合放在演示前一天过一遍,当成 checklist 用,每条对应一个可以当场验证的动作。

这份实训任务的建表脚本、示例数据和分层代码按模块整理在一起,需要的话可以按目录取用,建议先自己按上面的路径跑一遍,再对照着看差异,收获比直接抄大一截。

从那以后我每次交这类系统之前,都会强制走一遍「清库重建 → 灌边界数据 → 双窗口并发 → 跑对账 SQL」这四步,一次都没再在演示现场被问住过。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询