资讯详情 Java图书馆管理系统实训:建表、分层、事务与并发借阅复习指南
📅 2026/10/23 16:19:51
简介面向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承载字段、做简单自校验访问数据库、写业务分支能脱离数据库单独 newdao单表增删改查、拼 SQL判断能不能借、算罚款方法名都是 find/save/updateservice业务规则、事务边界出现 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」这四步一次都没再在演示现场被问住过。希望帮到你。本文还有配套的精品资源点击获取