news 2026/10/30 2:49:47

SSM图书管理系统实战:三层架构、借还书事务与库存扣减排错

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SSM图书管理系统实战:三层架构、借还书事务与库存扣减排错

简介:面向Java Web初学者、课程设计与毕业设计人群的SSM图书管理系统项目包,可用于综合练习Spring、SpringMVC与MyBatis的分层开发,解决从框架整合到图书业务落地缺少完整案例的问题。项目围绕权限登录、图书信息维护、借阅续借归还、邮件通知和答题互动等模块展开,体现IoC依赖注入、AOP日志事务、MVC请求调度及MyBatis SQL映射等关键机制,模块划分清晰,便于理解控制层、业务层与持久层协作。压缩包共3个文件,包含1个rar主项目包、1个txt使用手册和1个sql数据库脚本,整体约91.12MB,可配合MySQL与Tomcat完成环境搭建、数据初始化与功能验证。已有3255人学习,适合据此梳理SSM项目结构、对照数据库设计理解业务表关系,并在现有代码上进行功能扩展与二次开发。

1. 先说清楚这套 SSM 图书管理系统能干什么

SSM 图书管理系统最典型的使用场景是课程设计和入门级完整项目练手:Spring 管 Bean 和事务、SpringMVC 管请求分发与视图跳转、MyBatis 管 SQL 映射,三件套拼起来正好覆盖一个中小型业务系统的全部骨架。它真正解决的问题不是“图书管理”这个业务本身,而是给你一条能跑通的完整闭环——登录鉴权、图书增删改查、分类维护、读者管理、借书还书、分页加模糊搜索,拿去做二次开发、换皮改字段、写设计文档都够用。

适合的人群也很明确:学完 JavaWeb、能在 IDEA 里部署一个 war 包、但还没独立写过一个完整三层架构项目的人。如果你已经能熟练用 Spring Boot 写 CRUD,这套东西对你来说偏“考古”,但用来理解 MyBatis 的 XML 映射和事务边界,反而比一堆注解更直观。

后面按“环境怎么对齐 → 表怎么设计 → 借还书链路怎么写 → 报错怎么查 → 还能怎么改”的顺序拆,每一步都是能直接抄的。

2. 工程结构与运行环境:三层在图书管理系统里各管什么

很多人拿到源码第一反应是双击打开、配个 Tomcat、点运行,然后卡在 404 或者 500 上耗掉一晚上。真正省时间的做法是先花十分钟看清目录,搞清楚哪个文件管哪一层,再动手配环境。这一章把这套 SSM 工程的骨架扒开,后面所有问题都能对上号。

2.1 目录结构:为什么 mapper 的 XML 要放在 resources 下

典型的单 war 工程长这样,注意resources和webapp的分工,这是后面一堆“找不到文件”报错的根源:

library/ ├── pom.xml ├── src/main/java/com/demo/library/ │ ├── controller/ # 只接请求、校验参数、返回结果 │ ├── service/ # 接口 │ │ └── impl/ # 实现类,事务注解加在这一层 │ ├── mapper/ # MyBatis 接口,方法名要和 XML 的 id 对上 │ ├── entity/ # 和数据库表一一对应的 POJO │ ├── interceptor/ # 登录拦截器 │ └── util/ # 统一返回体、日期工具、MD5 ├── src/main/resources/ │ ├── mapper/ # BookMapper.xml / BorrowMapper.xml 等 │ ├── spring-core.xml # 数据源、SqlSessionFactory、事务管理器 │ ├── spring-mvc.xml # 包扫描、视图解析器、拦截器注册 │ ├── jdbc.properties # 数据库四要素 │ └── log4j.properties # 控制台打 SQL 全靠它 ├── src/main/webapp/ │ ├── WEB-INF/ │ │ ├── web.xml # 编码过滤器、DispatcherServlet、ContextLoaderListener │ │ └── views/ # JSP 页面 │ └── static/ # js、css、图片

关键点在于:Maven 默认只把src/main/java下的.java编译进classes,同目录下的.xml会被忽略。所以 mapper 的 XML 必须放resources/mapper,再通过mapperLocations指向它。见过太多人把 XML 和接口放一起,本地 IDEA 跑得好好的,一打 war 包就Invalid bound statement,血泪经验。

注意:如果你确实想 XML 和接口同目录,就必须在pom.xml的<build><resources>里显式加上**/*.xml的 include,否则打包必丢。

2.2 三层的职责边界:位置写错,后面全是补丁

这套架构真正的价值不是“分了三层”,而是每一层知道自己在哪该停手。下面这张表是我改别人代码时最先看的东西:

层该做的事不该做的事常见误用
Controller接收参数、做非空与格式校验、决定返回 JSON 还是 JSP、组装统一返回体写 SQL、开事务、做库存判断在 Controller 里循环调 Mapper 拼业务
Service业务编排、跨表逻辑、事务边界、调用多个 Mapper出现HttpServletRequest、做页面跳转把事务注解加在 Controller 上
Mapper单表或多表 SQL、动态条件、结果映射写 if-else 业务分支、调用别的 Service一个方法里塞十张表的 join

选型上说一句为什么用 XML 而不是全注解:图书管理系统的查询页面基本都带“书名模糊 + 分类 + 是否可借”三条件组合,用<where>+<if>拼动态 SQL 比在注解里写<script>标签清爽太多,改起来不用重新编译整个类。这也是这套老技术栈到现在还有生命力的原因。

2.3 环境对齐:JDK 8 + Tomcat 9 + MySQL 8 这一组最省事

版本不对齐是新手最大的时间黑洞。最稳的组合是 JDK 8、Tomcat 8.5 或 9、MySQL 5.7 或 8.0、Maven 3.6+。重点提醒一句:不要用 Tomcat 10,它把javax.servlet换成了jakarta.servlet,这套基于javax写的工程部署上去直接 500。

步骤如下,先确认本地工具链:

# 三个版本必须能看到输出,版本号按上面那组对齐 java -version mvn -v mysql --version # 在工程根目录执行,先确认能编译过,排除依赖问题 mvn clean package -DskipTests

编译通过后,在 IDEA 里配 Tomcat:Run → Edit Configurations → + → Tomcat Server → Local,Deployment 页签添加library:war exploded,Application context 填/library。启动后访问http://localhost:8080/library/应该看到登录页。

如果登录页能出来但样式全丢,是 DispatcherServlet 把静态资源也拦了,在spring-mvc.xml里补一行:

<!-- 放行静态资源,否则 /static/** 会被当成一个 Controller 请求处理 --> <mvc:resources mapping="/static/**" location="/static/"/>

mapping是浏览器访问路径,location是 webapp 下的实际目录,两个斜杠都不能少。这一步配好,后面调页面才不会被 404 干扰。

3. 建库建表:三张主表怎么撑起借还业务

表设计决定了后面代码能写多干净。图书管理系统看着简单,真正容易翻车的地方只有两个:一是库存字段和借阅记录不同步,二是查询借阅列表时不知道该 join 几张表。把表设计对了,这两件事都能在 SQL 层面解决掉一半。

3.1 五张表的分工与关键字段

表名主键关键字段作用
book_infobook_idisbn、name、type_id、total、stock图书档案,total是馆藏总量,stock是可借数量
book_typetype_idtype_name分类字典,用于下拉框和分类筛选
reader_inforeader_idcard_no、name、max_borrow、status读者档案,max_borrow控制最多可借几本
borrow_infoborrow_idbook_id、reader_id、borrow_date、due_date、return_date借阅流水,return_date为空表示未还
adminadmin_idusername、password后台登录账号

这里有个设计取舍值得说:stock是冗余字段,理论上可以用total减去未还记录数算出来。但列表页要显示“可借 3 本”,每次去 count 一遍borrow_info会把查询拖慢,所以用冗余字段换读性能,代价是必须保证借还书时同步更新——这也是第四章事务要重点处理的地方。

borrow_info里我没有用status字段表示借还状态,而是直接用return_date IS NULL判断,好处是不会出现“状态是已还但归还时间还是空”这种脏数据,少一个字段就少一类 bug。

3.2 建库脚本:一次执行完,别再手工点

下面这段直接丢进 Navicat 或命令行执行,包含建库、建表、索引和几条初始数据:

-- 库和字符集一次定死,后面不用再改 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, isbn VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL, author VARCHAR(50) DEFAULT NULL, press VARCHAR(80) DEFAULT NULL, -- 出版社 type_id INT NOT NULL, price DECIMAL(8,2) DEFAULT 0.00, total INT NOT NULL DEFAULT 1, -- 馆藏总量 stock INT NOT NULL DEFAULT 1, -- 当前可借,借出减一,归还加一 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_name (name), -- 书名模糊查询走前缀索引 KEY idx_isbn (isbn) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE book_type ( type_id INT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(30) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE reader_info ( reader_id BIGINT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL, name VARCHAR(30) NOT NULL, phone VARCHAR(20) DEFAULT NULL, max_borrow INT NOT NULL DEFAULT 5, -- 最多同时借几本 status TINYINT NOT NULL DEFAULT 1, -- 1 正常 0 冻结 UNIQUE KEY uk_card_no (card_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE borrow_info ( borrow_id BIGINT PRIMARY KEY AUTO_INCREMENT, book_id BIGINT NOT NULL, reader_id BIGINT NOT NULL, borrow_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, due_date DATETIME NOT NULL, -- 应还日期,借出时算好 return_date DATETIME DEFAULT NULL, -- 为空表示未归还 KEY idx_reader (reader_id, return_date), -- 查某人的未还记录 KEY idx_book (book_id, return_date) -- 查某本书的借出情况 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE admin ( admin_id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL, password VARCHAR(64) NOT NULL, -- 存 MD5,不要存明文 UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 初始数据,够你跑通流程就行 INSERT INTO admin (username, password) VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e'); INSERT INTO book_type (type_name) VALUES ('计算机'), ('文学'), ('历史'); INSERT INTO book_info (isbn, name, author, press, type_id, price, total, stock) VALUES ('9787111128069', '算法导论', '某作者', '某出版社', 1, 89.00, 3, 3); INSERT INTO reader_info (card_no, name, phone) VALUES ('R2024001', 'A同学', '13800000000');

执行完SHOW TABLES;能看到五张表就算过了。这一段没什么玄学,但引擎必须是InnoDB——如果用 MyISAM,后面@Transactional加了也是白加,回滚根本不生效,这个坑第五章会细说。

3.3 索引与约束:三个必须加,一个千万别加

上面脚本里已经埋了索引,解释一下为什么是这几个:

borrow_info上的两个联合索引都带return_date,因为最常用的两条查询是“查这个读者还没还的书”和“查这本书当前被谁借着”,把过滤字段放进索引第二列,WHERE return_date IS NULL就能走索引。

book_info.name上只建了普通索引,没建唯一索引。有人觉得书名该唯一,实际业务里同名书太正常了(不同版次、不同出版社),建了唯一键后面插数据一直报Duplicate entry。

真正该唯一的是reader_info.card_no和admin.username,这两个是业务主键,重复了就是数据错误。

-- 上线前自查:看索引有没有生效 EXPLAIN SELECT * FROM borrow_info WHERE reader_id = 1 AND return_date IS NULL;

看type列是不是ref,key列是不是命中了idx_reader。如果显示ALL,说明索引白建了,多半是字段类型不匹配,比如reader_id传了字符串。

4. 借还书链路逐层落地:代码与事务边界怎么划

借书这个动作看着只有两步,实际牵扯四件事:校验读者状态和额度、扣库存、插借阅记录、算应还日期。四件事必须在同一个事务里,任何一步失败都要整体回退。这一章把这条链路从 Controller 一路写到 Mapper,给出能直接抄的代码,并解释每个参数为什么这么设。

4.1 Controller:只做三件事,多一件都是坑

@Controller @RequestMapping("/borrow") public class BorrowController { @Autowired private BorrowService borrowService; /** * 借书接口 * @param readerId 读者主键,前端从下拉框带过来 * @param bookId 图书主键 */ @PostMapping("/lend") @ResponseBody public Result lend(@RequestParam Long readerId, @RequestParam Long bookId) { // 1. 参数兜底校验,别指望前端一定传对 if (readerId == null || bookId == null) { return Result.fail("参数缺失"); } // 2. 业务全部交给 Service,Controller 不碰任何判断逻辑 try { borrowService.lend(readerId, bookId); return Result.ok("借阅成功"); } catch (BizException e) { // 3. 业务异常转成可读提示,堆栈打日志但不返回给前端 return Result.fail(e.getMessage()); } } }

@ResponseBody表示返回 JSON 而不是找 JSP,前端用 ajax 调就行。注意catch的是自定义的BizException,不是Exception——如果这里 catch 了Exception又没往外抛,Service 层的事务会因为异常被吞掉而不回滚,这是最隐蔽的一类 bug,第五章展开。

参数用@RequestParam而不是@RequestBody,是因为这套工程前端是 JSP 表单 + jQuery,走的是application/x-www-form-urlencoded。如果改成前后端分离传 JSON,换成@RequestBody加一个 DTO 即可,Service 层不用动。

4.2 Service:事务注解加在哪决定了回滚能不能生效

@Service public class BorrowServiceImpl implements BorrowService { @Autowired private BookMapper bookMapper; @Autowired private BorrowMapper borrowMapper; @Autowired private ReaderMapper readerMapper; /** * rollbackFor = Exception.class 必须写 * 默认只回滚 RuntimeException,业务里抛的受检异常不会触发回滚 */ @Override @Transactional(rollbackFor = Exception.class) public void lend(Long readerId, Long bookId) throws BizException { // 1. 读者是否存在且状态正常 Reader reader = readerMapper.selectById(readerId); if (reader == null || reader.getStatus() != 1) { throw new BizException("读者不存在或已被冻结"); } // 2. 是否超过可借上限 int current = borrowMapper.countUnreturned(readerId); if (current >= reader.getMaxBorrow()) { throw new BizException("已达可借上限 " + reader.getMaxBorrow() + " 本"); } // 3. 是否已经借了同一本书没还 if (borrowMapper.countSameBook(readerId, bookId) > 0) { throw new BizException("这本书还没还,不能重复借"); } // 4. 扣库存,返回受影响行数,0 表示库存已被抢空 int affected = bookMapper.reduceStock(bookId); if (affected == 0) { throw new BizException("库存不足"); } // 5. 插借阅记录,应还日期默认 30 天后 BorrowInfo record = new BorrowInfo(); record.setBookId(bookId); record.setReaderId(readerId); record.setBorrowDate(new Date()); record.setDueDate(DateUtil.plusDays(new Date(), 30)); borrowMapper.insert(record); // 任何一步抛异常,上面扣的库存会被一起回滚 } }

几个参数值得说明:

rollbackFor = Exception.class是硬性要求。Spring 默认只在遇到RuntimeException和Error时回滚,如果BizException继承的是Exception,抛出去事务照样提交,库存扣了记录没插,数据直接烂掉。

reduceStock的返回值一定要判断。很多人写成UPDATE book_info SET stock = stock - 1 WHERE book_id = ?,然后不管返回值直接插记录,库存为 0 的时候会扣成负数。加上AND stock > 0再判断受影响行数,天然就防住了超卖。

DateUtil.plusDays是工程里自己写的工具方法,用Calendar或LocalDateTime.plusDays实现都行,别用new Date(System.currentTimeMillis() + 30*24*3600*1000L)这种写法,跨时区和夏令时会偏。

提示:@Transactional只在被 Spring 代理的外部调用时生效。如果同一个类里 A 方法直接this.b(),事务注解在b上是不起作用的,这是自调用失效问题,排查时容易看漏。

4.3 Mapper:条件扣减与动态查询 SQL

接口定义保持极简,所有 SQL 都在 XML 里:

public interface BookMapper { // 带条件的原子扣减,返回受影响行数 int reduceStock(@Param("bookId") Long bookId); // 归还原位 int addStock(@Param("bookId") Long bookId); } public interface BorrowMapper { int countUnreturned(@Param("readerId") Long readerId); int countSameBook(@Param("readerId") Long readerId, @Param("bookId") Long bookId); int insert(BorrowInfo record); // 分页 + 多条件查询借阅列表 List<BorrowVO> selectPage(@Param("keyword") String keyword, @Param("readerId") Long readerId, @Param("onlyUnreturned") Boolean onlyUnreturned); }

对应的 XML:

<mapper namespace="com.demo.library.mapper.BookMapper"> <!-- stock > 0 是关键,写成 stock >= 0 就会扣成负数 --> <update id="reduceStock"> UPDATE book_info SET stock = stock - 1 WHERE book_id = #{bookId} AND stock &gt; 0 </update> <update id="addStock"> UPDATE book_info SET stock = stock + 1 WHERE book_id = #{bookId} AND stock &lt; total </update> </mapper> <mapper namespace="com.demo.library.mapper.BorrowMapper"> <!-- 查某读者当前未还数量,用于额度校验 --> <select id="countUnreturned" resultType="int"> SELECT COUNT(1) FROM borrow_info WHERE reader_id = #{readerId} AND return_date IS NULL </select> <select id="countSameBook" resultType="int"> SELECT COUNT(1) FROM borrow_info WHERE reader_id = #{readerId} AND book_id = #{bookId} AND return_date IS NULL </select> <!-- 借阅列表:三表 join,条件全部动态拼 --> <select id="selectPage" resultType="com.demo.library.entity.BorrowVO"> SELECT b.borrow_id, b.borrow_date, b.due_date, b.return_date, k.name AS bookName, k.isbn, r.name AS readerName, r.card_no FROM borrow_info b JOIN book_info k ON b.book_id = k.book_id JOIN reader_info r ON b.reader_id = r.reader_id <where> <if test="keyword != null and keyword != ''"> AND (k.name LIKE CONCAT('%', #{keyword}, '%') OR r.name LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="readerId != null"> AND b.reader_id = #{readerId} </if> <if test="onlyUnreturned != null and onlyUnreturned"> AND b.return_date IS NULL </if> </where> ORDER BY b.borrow_date DESC </select> </mapper>

&gt;和&lt;是 XML 里对><的转义,直接写会报解析错误,这个坑几乎人人踩一次。

selectPage里没有写LIMIT,因为分页交给 PageHelper 在 Service 里处理:

// startPage 必须紧挨着目标查询,中间不能插别的查询 PageHelper.startPage(pageNum, pageSize); List<BorrowVO> list = borrowMapper.selectPage(keyword, readerId, onlyUnreturned); PageInfo<BorrowVO> pageInfo = new PageInfo<>(list);

startPage只对紧随其后的第一条查询生效,这是 PageHelper 最容易被误用的地方,第五章会作为独立的坑展开。

还书链路就是反过来:先按borrow_id更新return_date(加WHERE return_date IS NULL防止重复归还),再调addStock加回库存,同样放在一个事务里,逻辑和借书对称,代码不再重复贴。

5. 常见问题排查:启动 500、乱码、事务不回滚的五条记录

这套东西跑不起来,九成问题集中在这五个地方。每条我都按“现象 → 原因 → 解决”写,方便你对着日志直接定位,不用从头读代码。

5.1 启动阶段的两个坑

坑一:部署到 Tomcat 10,访问任何页面都是 500,日志里ClassNotFoundException: javax.servlet.http.HttpServlet

现象是工程编译完全正常,一部署就崩,日志顶部通常是NoClassDefFoundError或者 Servlet 初始化失败。原因是 Tomcat 从 10.0 开始把 Servlet API 的包名从javax.servlet.*换成了jakarta.servlet.*,而这套 SSM 工程的web.xml还是 4.0 之前的javax版本,DispatcherServlet加载时找不到父类。解决方式有两个:把 Tomcat 换成 8.5 或 9(推荐,改动最小),或者把pom.xml里的javax.servlet-api和 Spring 版本一起升级到支持 Jakarta 的版本——后者牵一发动全身,不值得为一个课程设计折腾。

坑二:启动报Invalid bound statement (not found): com.demo.library.mapper.BookMapper.reduceStock

现象是 Tomcat 起来了,点借书按钮报 500,日志里提示找不到 Mapper 的语句。原因有三种:XML 没被打包进classes(最常见)、XML 的namespace和接口全限定名对不上、XML 里的id和方法名拼错。排查顺序建议这样:先看target/classes/mapper/目录下有没有 XML 文件,没有就是打包问题,在pom.xml加资源过滤:

<build> <resources> <resource> <directory>src/main/resources</directory> <includes> <include>**/*.xml</include> <include>**/*.properties</include> </includes> </resource> </resources> </build>

如果 XML 在,就核对namespace是否一字不差,再核对spring-core.xml里mapperLocations的路径是不是classpath*:mapper/*.xml——注意是classpath*带星号,写成classpath:只会加载第一个匹配的 jar。

5.2 运行阶段的三个坑

坑三:表单提交中文变问号,页面显示???

现象是新增图书时书名输入中文,入库后变成问号或者乱码。原因通常是三层里任意一层没对齐:web.xml没配编码过滤器、JDBC URL 没带字符集参数、数据库或表的字符集不是utf8mb4。解决要三处一起改,缺一处都还会乱:

<!-- web.xml 中,必须放在所有 filter 最前面 --> <filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

forceEncoding一定要设成true,它同时管请求和响应;只设encoding只管请求体。JDBC URL 里补上useUnicode=true&characterEncoding=utf8。数据库层面执行SHOW CREATE TABLE book_info;确认是utf8mb4,MySQL 8 默认已经是,5.7 需要手动指定。

坑四:借书时库存扣了,借阅记录没插进去,数据对不上

这是最要命的一类问题,往往还是偶发。原因有四个可能:@Transactional加在了 Controller 上、异常被catch后没重新抛出、同一个 Service 类内部方法自调用、表引擎是 MyISAM。逐条排查:事务注解必须在 Service 实现类的方法上;catch里如果要吞异常,必须手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();自调用改成注入自身代理或者把方法拆到另一个 Service;执行SHOW TABLE STATUS LIKE 'borrow_info';看Engine列是不是InnoDB。

另外别忘了rollbackFor = Exception.class,默认规则只回滚运行时异常,业务里抛的受检异常会让事务照常提交。我一般写完借还书逻辑会故意在插记录后手动throw new RuntimeException()测一次,看库存有没有回退——这是最省事的验证方式。

坑五:分页总数不对,或者每页条数乱跳

现象是列表页查出 10 条但总数显示 1,或者翻到第二页数据重复。原因基本是 PageHelper 的startPage位置放错了。它靠 ThreadLocal 把分页参数挂在当前线程,只对紧接着的第一条查询生效:

// 错误写法:中间插了别的查询,分页参数被"吃掉" PageHelper.startPage(pageNum, pageSize); int total = borrowMapper.countAll(); // 分页参数被这条消费了 List<BorrowVO> list = borrowMapper.selectPage(...); // 正确写法:startPage 和目标查询之间不能有任何 Mapper 调用 List<BorrowVO> list = borrowMapper.selectPage(...); // 先不写 PageHelper.startPage(pageNum, pageSize); // 紧贴 list = borrowMapper.selectPage(...);

还有一种更隐蔽的:查询结果里用了<collection>做一对多嵌套,PageHelper 会把子集合也一起截断,导致每页只剩一条主记录。这种情况要么改成两次查询在 Service 里组装,要么用PageHelper.startPage(pageNum, pageSize, false)关掉 count 查询自己算总数。

6. 进阶用法:库存扣减的原子写法和交付前的自检

把功能跑通只是及格线,这套东西真正能拿去交差,还得解决并发和验证两个问题。我一般会在原工程上做两处小改造,代码量不大,但对“能不能用”的判断影响很大。

6.1 库存扣减必须是带条件的 update

UPDATE ... SET stock = stock - 1 WHERE book_id = ? AND stock > 0这一句看似已经防住了超卖,实际上是靠数据库的行锁串行化的:两个请求同时到,第一个拿到行锁扣减成功,第二个等锁释放后再判断stock > 0,此时库存已经变了,条件不成立返回 0 行。这个机制成立的前提是WHERE条件里带了stock,如果只按主键更新再在 Java 里判断,就会出现经典的“先查后改”竞态。

-- 验证方式:开两个会话,手动模拟并发 START TRANSACTION; UPDATE book_info SET stock = stock - 1 WHERE book_id = 1 AND stock > 0; -- 先不 commit,另开一个会话执行同一条,会阻塞在这里 COMMIT;

第二个会话阻塞住,说明行锁生效了。如果它立刻返回 1 行,那多半是表引擎不对或者隔离级别被改过,需要回头看第三章的建表语句。

再补一层兜底:在 Service 里先查“该读者是否已借同一本书未还”,但这个查询在极端并发下也会漏。真正可靠的做法是给borrow_info加一个(reader_id, book_id, return_date)的联合唯一索引——未还时return_date为NULL,MySQL 的唯一索引允许多个NULL值,所以这个约束挡不住重复。更实用的方案是在应用层用 Redis 分布式锁或者数据库悲观锁SELECT ... FOR UPDATE,但对课程设计来说,前面那个带条件的 update 已经够用了,别过度设计。

6.2 交付前跑一遍的三条验证

功能写完不等于能交,至少要过这三关:

验证项操作通过标准
事务完整性临时在插借阅记录后抛RuntimeException库存回到借出前的值,borrow_info无新增
边界值把stock改成 0 再点借书提示“库存不足”,不产生任何数据变更
权限拦截清掉 Cookie 直接访问/book/list跳回登录页,不出现 500 或白屏

第三条需要拦器配好,spring-mvc.xml里注册时记得排除登录接口和静态资源,否则会出现“登录页自己被拦,跳转到登录页”的死循环:

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <!-- 这三个必须排除,否则永远登不进去 --> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/doLogin"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.demo.library.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>

从那以后,我每次改完借还书相关的代码,都会手动制造一次异常跑一遍事务验证,不管改动多小——因为库存和流水对不上这种问题,往往要等到答辩现场演示时才暴露,那时候连日志都来不及看。这套 SSM 图书管理系统的源码结构清晰、改动点集中,很适合拿来练这个习惯:先把环境按第二章对齐,把表按第三章建好,再照着第四章的链路走一遍,剩下的坑第五章基本都覆盖了。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/30 2:49:09

脉冲雷达测速测距MATLAB工程:从回波到距离-速度完整链路

简介&#xff1a;《脉冲雷达测速测距.rar》面向雷达技术、电子工程与信号处理方向的学习者&#xff0c;聚焦脉冲雷达如何通过回波时间差完成测距、借助多普勒频移估算目标速度&#xff0c;适合课程实验、原理验证和算法入门时对照理解。压缩包仅含1个.m源码文件&#xff0c;整体…

作者头像 李华
网站建设 2026/10/30 2:48:49

车牌识别数据集实战:从数据体检到YOLO与CRNN训练避坑指南

简介&#xff1a;这份训练数据集面向计算机视觉开发者与深度学习学习者&#xff0c;聚焦车牌检测与识别任务&#xff0c;可用于交通监控、智能停车、自动驾驶等场景的算法开发与模型优化。包内共1296个文件&#xff0c;以650张jpg车牌图片和646个xml标注文件为主&#xff0c;图…

作者头像 李华
网站建设 2026/10/30 2:48:36

C#自绘图表源码接入实践:GDI+坐标映射、交互与高DPI导出

简介&#xff1a;EeceChart.zip 是一份面向 C# 开发者与数据分析可视化学习者的实战项目包&#xff0c;围绕 Excel 数据读取、曲线绘制和交互式图形操作展开&#xff0c;可用于桌面端小型分析工具的原型搭建与技能练习。压缩包共 216 个文件&#xff0c;约 46.67MB&#xff0c;…

作者头像 李华
网站建设 2026/10/30 2:48:36

DRV8711步进电机驱动工程实战:从AD工程到寄存器配置与避坑指南

简介&#xff1a;这份资源面向从事步进电机驱动开发的嵌入式工程师与学习者&#xff0c;围绕TI的DRV8711驱动器&#xff0c;提供从硬件到软件的完整工程参考&#xff0c;帮助解决双极性步进电机的精准控制与驱动电路设计问题。压缩包共182个文件&#xff0c;约4.98MB&#xff0…

作者头像 李华
网站建设 2026/10/30 2:47:45

R语言期末设计报告通关指南:从数据清洗到建模的完整流程与评分要点

简介&#xff1a;这份R语言期末设计报告面向正在学习R语言、需要完成课程设计或想系统梳理数据分析流程的高校学生与自学者&#xff0c;围绕数据获取、清洗、统计建模到可视化呈现的完整链路展开&#xff0c;可作为课程作业参考与实战模板。压缩包共24个文件&#xff0c;约5.29…

作者头像 李华
网站建设 2026/10/30 2:46:14

好好学习C语言

学习之路还很长&#xff0c;一次失误并不能代表什么&#xff0c;我能成功&#xff0c;我会成功&#xff0c;我必成功&#xff01;

作者头像 李华