ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java图书馆借阅管理系统实战:Spring Boot+MyBatis业务闭环

Java图书馆借阅管理系统实战:Spring Boot+MyBatis业务闭环 简介这是一份关于图书馆借阅管理系统设计与实现的完整毕业设计文档面向计算机相关专业学生及需要开发图书管理类项目的开发者可用于课程设计、毕业设计或项目参考。资源为单个docx文件压缩包约3.67MB内容完整目前已吸引54人学习。文档基于JSPMySQLB/S架构包含需求分析、系统设计目标、功能模块划分、技术选型、系统实现、系统测试等完整章节详细说明了管理员端与用户端的核心功能如用户管理、图书借阅/续借/归还、罚金缴纳、留言板管理、我的收藏等。同时阐述了用户体验优化、系统安全性与维护以及面向未来发展的可扩展性设计内容扎实结构清晰。通过阅读此文档可快速理解图书管理系统的开发流程与实现思路为同类项目开发提供可直接参考的完整方案。1. 基于 java 的图书馆借阅管理系统先把这个经典项目拆成业务闭环上周帮朋友验收一个基于 java 的图书馆借阅管理系统设计与实现的课程项目跑完借书、还书、逾期罚款三条链路后发现这类项目看着全是增删改查真正的难点是借书那一刻库存怎么扣、还书那一刻逾期怎么算、同一本书被两个人同时借时会不会“翻车”。它本质是把图书、读者、借阅记录、罚金四条数据线串起来的一套 Web 事务系统适合正在做毕业设计或实训项目的 java 学习者也适合想给小型图书室低成本落地一套管理后端的初级工程师。我会从业务建模、技术选型、核心代码、踩坑排查到交付验证一路讲清楚给你一条能照着复现的路径。2. 借阅业务建模与表结构设计借书还书背后的状态机与五张表2.1 从借书、还书、续借、挂失四条主流程反推系统边界做任何管理系统第一件事不是建表而是把业务主流程走一遍。图书馆借阅管理系统的核心是四条链路借书、还书、续借、挂失。借书要检查读者状态和可借额度还要确认这本书确有可借副本还书要判断是否逾期、是否产生罚金、是否要冻结读者借阅权续借本质是修改应还时间多数业务规则规定只能续借一次且不能有逾期记录挂失则是图书状态进入“丢失”读者赔偿后解冻。把这四条流程走完系统的功能边界就清楚了管理员维护图书和读者信息读者能够检索图书和查看自己的借阅历史管理员办理借还并处理罚金。这里不需要一开始就做预约排队、短信提醒这些花哨功能先把主链路的正确性保证好再扩展。图书的状态变化可以用一个简单的状态机描述在馆、已借出、逾期未还、挂失。读者状态则是正常、冻结两组。借阅记录状态是借出中、已还、丢失。很多新手把状态散落在各个字段里到后期统计时就对不上账。更好的习惯是把这些状态定义成常量或枚举让 service 层只能在这些状态间流转避免随便一个 update 把记录改成不存在的状态。业务规则也需要在建模前定清楚一个读者同时最多借几本、借期多少天、逾期每天罚多少、有未缴罚金时能不能继续借。这些在小型系统里往往被写成魔法数字散落在代码里我一般会把这几个参数放进配置表后面改规则不动代码这个技巧放到最后一章展开。2.2 MySQL 建表图书、读者、借阅记录、罚金一张 SQL 建清楚先给出一份可以直接执行的建表 SQL这是整个项目的地基。为了控制工程复杂度我采用“图书主表 可借副本数”的模型而不是给每一本实体书都建一行记录。这样借书时只需要扣减数字查询时也不需要关心“具体哪一本被借走”。CREATE DATABASE IF NOT EXISTS library DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE library; CREATE TABLE book_info ( book_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 图书ID, isbn VARCHAR(20) NOT NULL COMMENT ISBN号, title VARCHAR(100) NOT NULL COMMENT 书名, author VARCHAR(50) NOT NULL COMMENT 作者, category_id BIGINT COMMENT 分类ID, total_copies INT NOT NULL DEFAULT 1 COMMENT 馆藏总册数, available_copies INT NOT NULL DEFAULT 1 COMMENT 当前可借册数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_isbn (isbn) ) ENGINEInnoDB COMMENT 图书信息表; CREATE TABLE reader_info ( reader_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 读者ID, reader_no VARCHAR(20) NOT NULL UNIQUE COMMENT 借书证号, name VARCHAR(50) NOT NULL COMMENT 姓名, phone VARCHAR(20) COMMENT 联系电话, max_borrow INT NOT NULL DEFAULT 5 COMMENT 最大借阅数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0冻结, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT 读者信息表; CREATE TABLE borrow_record ( borrow_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 借阅记录ID, book_id BIGINT NOT NULL COMMENT 图书ID, reader_id BIGINT NOT NULL COMMENT 读者ID, borrow_time DATETIME NOT NULL COMMENT 借出时间, due_time DATETIME NOT NULL COMMENT 应还时间, return_time DATETIME DEFAULT NULL COMMENT 实际归还时间, penalty_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 罚金金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0借出中 1已还 2丢失, KEY idx_reader (reader_id), KEY idx_book (book_id), KEY idx_status (status) ) ENGINEInnoDB COMMENT 借阅记录表; CREATE TABLE penalty_record ( penalty_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 罚金ID, borrow_id BIGINT NOT NULL COMMENT 借阅记录ID, amount DECIMAL(10,2) NOT NULL COMMENT 罚金金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未缴 1已缴, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT 罚金记录表; CREATE TABLE sys_config ( config_key VARCHAR(50) PRIMARY KEY COMMENT 参数名, config_value VARCHAR(255) NOT NULL COMMENT 参数值 ) ENGINEInnoDB COMMENT 系统参数表;两个关键点要说明。第一金额字段必须用 DECIMAL(10,2)不要用 double 或 float否则 0.1 加 0.2 会算成 0.30000000000000004罚金对不上账。第二借阅记录表里的 book_id、reader_id 我故意没有建物理外键只加了普通索引。原因是历史借阅记录会长期保留物理外键会阻止你删除或下架图书开发期频繁造数据时也会被约束卡手。逻辑关联靠 JOIN 完成应用层保证数据一致性。available_copies是一个冗余字段冗余的目的是让借书操作变成一次原子 UPDATE而不需要每次统计借阅记录才能知道剩余数量。代价是每次借还都要同步维护它后面第 5 章会专门讲到它在并发下的坑。2.3 三个容易想漏的业务边界逾期粒度、罚款冻结、挂失处理第一个边界是逾期天数怎么算。如果把应还时间存成“借出日期加 30 天”的同一时刻比如上午 10 点借、下月同一天 10 点应还读者下午 4 点来还书按数据库里的 DATETIME 差值来算是提前了但按自然日来看已经是第 31 天容易引发争议。常见做法是计算借款期限时只关心日期不关心时分秒把应还时间归一化到应还日的 23:59:59还书时也只用日期部分来计算逾期天数。这个细节如果没定好后面罚金模块会出大问题。第二个边界是罚款和借阅权的关系。我一般设计为读者有未缴罚金时不能新借书但还书操作不受影响。还书产生罚金后把 reader_info.status 置为冻结读者缴清罚金后再恢复为正常。注意不要因为读者被冻结就连还书都不让操作那样会陷入死锁。罚金记录不要物理删除只用 status 表示已缴未缴方便对账和报表统计。第三个边界是挂失。图书挂失后不能再被借出馆藏库存减一同时生成一笔赔偿罚金。这个流程在借阅记录表里体现为 status2和正常归还区分开。如果一开始不做挂失功能也要在表设计里预留这个状态位否则后面加功能就要改表结构牵一发动全身。3. 技术栈与工程骨架用 Spring Boot MyBatis 把项目跑起来再谈设计3.1 为什么不是 Servlet/JSP 也不是 SSH三者选型的实际对比不少课程设计还停留在纯 Servlet JSP 或 SSHStruts Spring Hibernate的阶段。纯 Servlet 适合理解 HTTP 和请求转发但一个图书列表页面要写一堆 doGet、doPost 和手动参数封装页面里还要嵌入大量 Java 代码后期维护成本很高SSH 的配置文件和 Hibernate 映射学习曲线陡现在的新项目已经很少用了。我把三种方案对比一下技术栈学习成本编码效率事务控制当前应用场景Servlet JSP中低手动教学演示、理解底层SSH高低配置复杂遗留系统维护Spring Boot MyBatis中低高注解声明中小型管理系统主流Spring Boot 自动装配省掉大量 XML 配置MyBatis 把 SQL 控制权留在开发者手里对于借阅这种有复杂查询和事务的操作非常合适。环境准备方面JDK 8 或 11 都行配套 Maven 3.6 和 MySQL 8。如果你第一次配 java 环境变量注意 JAVA_HOME 要指向 JDK 安装目录而不是 bin 目录PATH 里加 %JAVA_HOME%\binMaven 的 MAVEN_HOME 同理。3.2 最小可运行骨架pom.xml、启动类、application.yml 一次配好先建一个空的 Maven 工程然后贴入 pom.xml。版本组合我建议使用 Spring Boot 2.7.18 配合 JDK 8 或 11这个搭配在大部分教学和实战环境里最稳。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdlibrary-system/artifactId version1.0.0/version properties java.version8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version scoperuntime/scope /dependency /dependenciesspring-boot-starter-web 提供内嵌 Tomcat 和 Spring MVCstarter-thymeleaf 用来渲染管理后台页面mybatis-spring-boot-starter 负责把 MyBatis 框架装配到 Spring Boot 中mysql-connector-java 是驱动scope 为 runtime 表示编译期不需要。版本号都可以根据自己的环境和仓库情况替换但建议保持 2.x 这条线迁移成本最低。然后是启动类和配置文件。package com.example.library; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication MapperScan(com.example.library.mapper) public class LibraryApplication { public static void main(String[] args) { SpringApplication.run(LibraryApplication.class, args); } }server: port: 8080 servlet: encoding: charset: UTF-8 force: true spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/library?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 你的数据库密码 thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.library.entityJDBC URL 里的参数每个都有用characterEncodingUTF-8 解决中文乱码serverTimezoneAsia/Shanghai 修正 MySQL 8 默认时区导致的日期偏差useSSLfalse 避免本地连接时的证书警告allowPublicKeyRetrievaltrue 解决 MySQL 8 客户端公钥检索问题。mybatis.mapper-locations 告诉框架去哪里加载 XML 映射文件。如果 java 启动失败优先检查两处项目端口是否被占用以及数据库连接串和账号密码是否匹配。3.3 包结构entity / mapper / service / controller 各放什么工程骨架搭好后包结构决定了代码能不能被别人快速看懂。我常用的分层是这样的com.example.library ├── LibraryApplication.java ├── controller # 接收页面请求返回视图或JSON ├── service # 业务逻辑和事务边界 ├── mapper # MyBatis接口定义方法签名 ├── entity # 与数据库表对应的JavaBean └── common # 统一异常、统一返回结果 resources ├── application.yml ├── mapper # MyBatis XML映射文件 └── templates # Thymeleaf页面模板controller 只做参数接收和结果转发不写任何 SQLservice 负责业务规则和事务mapper 接口只声明方法SQL 写在 XML 里。事务注解 Transactional 放在 service 方法上而不是 controller 上这个点经常出现在 java 面试题里controller 的方法可能被多次调用事务应该描述一个完整的业务操作借书、扣库存、生成记录是一个整体必须在一个事务方法里完成。entity 对应表的字段属性类型注意与数据库类型匹配BIGINT 对 LongVARCHAR 对 StringDECIMAL 对 BigDecimalDATETIME 对 LocalDateTime。不建议用 java.util.Date日期计算和时区处理会非常痛苦。4. 核心业务代码借书、还书、逾期罚金三条事务链的写法与参数说明4.1 借书先扣库存再生成记录把并发问题交给一条 UPDATE借书是整个系统最核心的写操作。新手最容易写成的错误版本是先查询图书可借数量在 Java 代码里判断大于 0然后执行 UPDATE 扣减。这个逻辑在单用户下没问题但两个管理员同时操作时两个线程可能都读到 available_copies1然后都走完判断把库存扣成 -1。正确做法是把“判断库存是否足够”和“扣减库存”合并成一条 UPDATE 语句让数据库在行锁层面保证原子性。下面是借书 Service 的核心代码Service public class BorrowService { private final BookInfoMapper bookInfoMapper; private final ReaderInfoMapper readerInfoMapper; private final BorrowRecordMapper borrowRecordMapper; private final SysConfigMapper sysConfigMapper; public BorrowService(BookInfoMapper bookInfoMapper, ReaderInfoMapper readerInfoMapper, BorrowRecordMapper borrowRecordMapper, SysConfigMapper sysConfigMapper) { this.bookInfoMapper bookInfoMapper; this.readerInfoMapper readerInfoMapper; this.borrowRecordMapper borrowRecordMapper; this.sysConfigMapper sysConfigMapper; } Transactional(rollbackFor Exception.class) public Long borrowBook(Long bookId, Long readerId) { // 1. 校验读者状态和借阅额度 ReaderInfo reader readerInfoMapper.findById(readerId); if (reader null || reader.getStatus() ! 1) { throw new BusinessException(读者不存在或当前处于冻结状态); } int notReturned borrowRecordMapper.countNotReturned(readerId); if (notReturned reader.getMaxBorrow()) { throw new BusinessException(借阅数量已达上限); } // 2. 原子扣减库存影响行数为0说明没有可借副本 int rows bookInfoMapper.decreaseAvailable(bookId); if (rows 0) { throw new BusinessException(这本书暂无可借副本); } // 3. 生成借阅记录 int borrowDays Integer.valueOf(sysConfigMapper.findByKey(borrow_days)); BorrowRecord record new BorrowRecord(); record.setBookId(bookId); record.setReaderId(readerId); record.setBorrowTime(LocalDateTime.now()); record.setDueTime(LocalDate.now().plusDays(borrowDays).atTime(LocalTime.MAX)); record.setStatus(0); borrowRecordMapper.insert(record); return record.getBorrowId(); } }对应的 BookInfoMapper 接口方法和 XMLMapper public interface BookInfoMapper { int decreaseAvailable(Param(bookId) Long bookId); }update iddecreaseAvailable update book_info set available_copies available_copies - 1 where book_id #{bookId} and available_copies 0 /update整个方法加了 TransactionalrollbackFor 设置为 Exception.class。这里有个新手常踩的知识点Spring 的 Transactional 默认只在 RuntimeException 时回滚如果你在方法里 catch 住 Exception 然后重新抛出、或者抛的是受检异常默认不会回滚。显式声明 rollbackFor Exception.class 是更稳妥的写法。decreaseAvailable的 where 条件中available_copies 0是关键它让数据库来做最后一道防线。影响行数为 0 时说明当前没有可借副本直接抛出业务异常事务回滚不会产生脏数据。库存的冗余字段由这条原子 UPDATE 保证一致性后续不需要额外的乐观锁也能防住普通并发场景。4.2 还书与罚金用 BigDecimal 算钱并用读者冻结保证规则闭环还书操作比借书多一个分支逾期要算罚金有未缴罚金要冻结读者。罚金计算只依赖 overdueDays 和 dailyPenalty 两个变量必须收敛在 service 方法内部不要散落到 controller 或页面脚本里。Transactional(rollbackFor Exception.class) public ReturnResult returnBook(Long borrowId) { // 1. 查询借阅记录并锁定防止同一笔记录被并发还两次 BorrowRecord record borrowRecordMapper.findByIdForUpdate(borrowId); if (record null || record.getStatus() ! 0) { throw new BusinessException(借阅记录不存在或已归还); } // 2. 计算逾期天数和罚金使用 BigDecimal 保证金额精确 long overdueDays calculateOverdueDays(record.getDueTime(), LocalDateTime.now()); BigDecimal penalty BigDecimal.ZERO; if (overdueDays 0) { BigDecimal dailyPenalty new BigDecimal(sysConfigMapper.findByKey(daily_penalty)); penalty dailyPenalty.multiply(BigDecimal.valueOf(overdueDays)); penaltyRecordMapper.insert(record.getBorrowId(), penalty); readerInfoMapper.updateStatus(record.getReaderId(), 0); } // 3. 更新借阅记录和图书库存 borrowRecordMapper.updateReturned(borrowId, LocalDateTime.now(), penalty); bookInfoMapper.increaseAvailable(record.getBookId()); ReturnResult result new ReturnResult(); result.setOverdueDays(overdueDays); result.setPenalty(penalty); return result; } private long calculateOverdueDays(LocalDateTime dueTime, LocalDateTime returnTime) { // 只比较日期部分业务上约定按自然日计逾期 return ChronoUnit.DAYS.between(dueTime.toLocalDate(), returnTime.toLocalDate()); }findByIdForUpdate 对应的 SQL 是select * from borrow_record where borrow_id #{borrowId} for update。FOR UPDATE 是悲观锁在事务内锁住这一行防止两个请求同时处理同一笔还书。对于小型图书馆系统这种锁的开销完全可以接受。如果不想用悲观锁也可以在 updateReturned 的 where 条件里加status 0影响行数为 0 就说明已经被其他请求处理过了。calculateOverdueDays 用ChronoUnit.DAYS.between并只取日期部分。这里要呼应第 2 章的设计借书时 setDueTime 用的是应还日的 23:59:59因此当天还书时两个 LocalDate 相同逾期天数是 0晚一天还则是 1不会因为时分秒的差异导致多算或少算。罚金金额的计算没有用 double 的乘法而是先new BigDecimal(3.00)再调用 multiply。如果写成dailyPenalty * overdueDays在 Java 里得到的是一个二进制浮点数表面上可能显示 9.0但在数据库里经过 DECIMAL 转换时可能产生精度问题。BigDecimal 的构造器也应该传字符串不要传 double。还书后如果产生了罚金读者状态更新为冻结。注意冻结只影响后续借书不影响还书和缴罚功能。这样业务闭环是自洽的逾期不缴就借不了书缴了罚金管理员在读者管理页面恢复状态即可。4.3 图书检索与借阅历史MyBatis 动态 SQL 和 LIMIT 分页的基本写法借阅管理系统的查询页面通常支持按书名、作者、ISBN 模糊搜索按分类筛选还要分页。MyBatis 动态 SQL 在这里最常用。select idsearchBooks resultTypecom.example.library.entity.BookInfo select * from book_info where if testkeyword ! null and keyword ! and (title like concat(%, #{keyword}, %) or author like concat(%, #{keyword}, %) or isbn like concat(%, #{keyword}, %)) /if if testcategoryId ! null and category_id #{categoryId} /if /where order by create_time desc limit #{offset}, #{pageSize} /selectwhere标签会自动处理“第一个条件前面多余 and”的问题比手动拼 SQL 更安全。like 拼接用concat(%, #{keyword}, %)不要写成%${keyword}%。${} 是字符串拼接会把参数直接嵌入 SQL存在注入风险#{} 是预编译占位符传给数据库的是参数值。图书量在几万册以内时limit 分页完全够用不需要引入 Elasticsearch 之类的重量级方案。分页的两个参数 offset 和 pageSize 由 service 层计算传入当前页从 1 开始offset (pageNum - 1) * pageSize。如果数据量将来增长到几十万再考虑换用 PageHelper 插件或改用基于 ID 的游标分页。对于毕业设计和中小型图书馆手写 LIMIT 反而更容易讲清楚原理。5. 避坑手册图书馆借阅管理系统最容易翻车的 5 个真实场景5.1 还书后库存没有加回去事务回滚和异常吞掉现象页面提示还书成功但 book_info 表里 available_copies 始终不变借阅记录的状态还是“借出中”。原因最常见的是 service 方法内部用了 try-catch 把异常吞掉了或者捕获后只打印日志没有重新抛出Spring 的事务代理完全感知不到异常自然不执行回滚。另一种情况是方法没有加 Transactionalinsert 罚金记录成功、update 库存失败时数据库留下半截数据。解决service 业务方法不要 catch 后吞异常要么不 catch要么 catch 后先记录日志再抛出业务异常事务注解统一写成Transactional(rollbackFor Exception.class)。养成的习惯是凡是涉及多表写操作的方法必须在一个事务里异常路径要能在日志里看到一个完整堆栈。5.2 逾期天数凭空多一天DATETIME 和 DATE 混用现象读者到期当天晚上 8 点来还书系统却显示逾期 1 天产生了罚金。读者在柜台当场懵掉管理员也解释不清楚。原因借书时把 due_time 存成了借出时刻的整点 30 天比如 10:00 借出应还时间就是下月同一天 10:00。读者晚上 8 点还书如果用returnTime - dueTime按小时算已经超过 24 小时自然被判定逾期 1 天。解决业务上约定按自然日计算逾期借书时把应还时间归一化到当天的 23:59:59计算逾期时只取toLocalDate()比较。遇到这类问题先看库里 due_time 和 return_time 具体值再判断是数据层面还是代码层面的偏差不要一上来就调罚金参数。5.3 外键约束把删除操作变成死胡同为什么我不建物理外键现象图书列表页面加了一个“删除”按钮点击后 MySQL 报错Cannot delete or update a parent row项目直接抛异常。原因borrow_record 表和 book_info 之间建了物理外键历史借阅记录里还引用着这条图书数据数据库约束不允许删除父表记录。课程设计里很多同学为了展示外键知识而建物理外键结果在后续操作中频繁被卡。这里要澄清外键约束能保证数据完整性但也限制了业务操作删除图书应该是一个逻辑操作而不是物理操作。解决不在表上建物理外键只保留普通索引用于 JOIN 关联。图书和读者的删除统一改为逻辑删除图书的 status 置为 0下架读者的 status 置为 0冻结所有查询默认过滤 status1。这样历史记录不会断链统计报表也能追溯。5.4 中文乱码从控制台一路黑到数据库现象页面输入“三体”存储到数据库变成“???”或者从数据库查询出来是一串乱码字符。原因三层编码不一致造成的。JDBC URL 没带 characterEncodingUTF-8数据库表字符集不是 utf8mb4页面响应也没指定 UTF-8其中任何一层不统一中文就会在这一层被错误解码。解决建库建表统一使用 utf8mb4JDBC URL 追加useUnicodetruecharacterEncodingUTF-8Spring Boot 配置server.servlet.encoding.forcetrue。排查时有一个顺序先用 SQL 客户端手动插入中文如果库里正常说明问题在应用层连接串或请求编码如果库里就是问号则要重建库表或修改表字符集。不要一上来就怀疑后端代码先把每一层用客户端验证一遍。5.5 并发压测把同一本书借给了两个人现象用 JMetert 或其他压测工具同时发起两个借书请求系统都提示成功最后 available_copies 变成了 -1。原因代码写成了“先查询可借数量再判断再 UPDATE”。两个线程同时读到 available_copies1都认为可借然后各自执行扣减。这个经典的“先查后改”不是原子操作在高并发下必然出现超额借出。解决把判断和扣减合并为一条 UPDATEupdate book_info set available_copies available_copies - 1 where book_id ? and available_copies 0影响行数为 0 就抛异常回滚。要更严格可以再加一个 version 字段做乐观锁。但在这个业务里单条原子 UPDATE 已经足够不要过度设计。验证方法也很简单把一本书的 available_copies 设为 1起两个线程同时调用借书接口断言只有一个成功。6. 从能跑到能答辩一份验收清单和把规则配置化的优化技巧6.1 十分钟跑完的功能验收清单项目交给老师或同事前我会按下面这张表完整跑一遍每条都要看到数据库层面的真实变化而不是只看页面提示场景操作预期结果借书成功管理员登录检索《三体》读者 A 办理借书可借数量减 1生成一条状态为 0 的借阅记录还书读者 A 归还《三体》可借数量加 1借阅记录状态变为 1无罚金逾期罚款手工把该记录 due_time 改为昨天再执行还书生成罚金记录读者状态变为冻结缴罚解冻管理员在罚金列表标记已缴恢复读者状态读者状态变为正常可再次借书并发借书将某书 available_copies 设为 1两个线程同时借只有一个成功库存不为负数数据保留删除一个读者借阅历史记录仍在读者列表不再展示该读者这张表既是功能测试用例也是设计文档的一部分能直接写进项目报告的功能测试章节。不用自动化框架手动操作加 SQL 查询就能完成。6.2 把借期、可借数、罚金抽成配置改规则不再碰 Java 代码最后说一个我强烈建议加进系统的优化把第 2 章建好的 sys_config 表用起来。借阅天数、每日罚金、读者最大借阅数都是业务规则规则一定会变不要写死在代码里。定义一个简单的 RuleService 统一读取Service public class RuleService { private final SysConfigMapper sysConfigMapper; public RuleService(SysConfigMapper sysConfigMapper) { this.sysConfigMapper sysConfigMapper; } public int borrowDays() { return Integer.parseInt(sysConfigMapper.findByKey(borrow_days)); } public BigDecimal dailyPenalty() { return new BigDecimal(sysConfigMapper.findByKey(daily_penalty)); } public int maxBorrow() { return Integer.parseInt(sysConfigMapper.findByKey(max_borrow)); } }把这个 Service 注入到 BorrowService 里替换常量整个系统的规则就与代码解耦了。答辩时老师如果问“借期从 30 天改成 60 天怎么做”你只要演示一个 INSERT 或 UPDATE sys_config 的语句把借阅天数从 30 改成 60然后重新借书即可。不要小看这个细节它比在代码里改常量再重新部署更能体现“设计与实现”的系统性也是很多开源项目的通用做法。我做这类系统有个习惯验收时不看页面有多少功能先盯着数据库把主链路跑三遍然后故意并发借同一本书再看库存字段是否保持非负。页面全绿但库里数据错乱是最难收拾的坑。把事务边界、库存扣减、日期算法这三件事守住这个项目就立住了。希望帮到你。本文还有配套的精品资源点击获取
返回列表