基于Spring Boot的在线图书借阅平台设计与实践

发布时间:2026/10/12 7:50:34
基于Spring Boot的在线图书借阅平台设计与实践 这两年陆陆续续帮人做过几个管理类系统如果要挑一个看起来人畜无害、做起来坑最多的类型在线图书借阅平台绝对排得上号。单看表面它就是一个图书列表加一个借还流程可真要面对多读者、多副本库存、逾期、预约、管理员后台这些场景复杂度会迅速涨上来。这篇文章写的是我最近完整跑通的一套基于 Spring Boot 的在线图书借阅平台从需求收敛、数据库建模到借还书事务、权限控制、性能调优和上线部署每一块都会给出能直接落地的方案也顺便把踩过的坑交代清楚。想用 Spring Boot 做图书管理、毕业设计或者公司内部借阅系统的人都可以拿这份实践经验当参考少走一些弯路。1. 需求收敛在线图书借阅平台先从哪几个角色入手1.1 角色的三个视角我在动手写第一行代码之前先逼自己把所有角色列清楚。图书借阅平台不是一个简单的 CRUD 演示参与方至少有三类普通读者、图书管理员、系统管理员。普通读者关心的是能不能快速搜到书、能不能借、什么时候还图书管理员关心的是借还操作是否准确、库存和实际在架数量是否对得上系统管理员关心的则是账号权限、图书上架下架、借阅规则配置这些全局事项。很多类似项目出问题往往不是因为代码写得不好而是需求压根没收敛。一开始我也想过把三个角色合并成两个页面后来发现完全行不通读者端和管理端对相同数据的操作范围、展示粒度、权限边界完全不同。比如同样是一本《Java 并发编程实战》读者端只需要看到“可借/不可借”和剩余数量管理员端却要看到总库存、借出数量、破损待修复数量、历史借阅记录。如果把两个入口硬塞在同一个界面里要么管理员操作起来局促要么读者会被一堆不该看到的按钮干扰。所以我的做法是拆成两个独立入口读者端面向借还和查询和后台管理端面向图书维护和记录管理。两个入口共用同一套后端服务通过不同请求路径和会话角色做隔离不搞两套系统那反而会带来维护噩梦。1.2 借阅流程闭环图书借阅的核心流程是一条完整的闭环注册登录 → 检索图书 → 提交借阅 → 库存扣减 → 生成借阅记录 → 按期归还 → 逾期处理。这里最容易被忽略的是“逾期处理”如果只是做一个“到期提醒”那还算是弱需求真要在系统里落地就得回答几个问题逾期按天罚金还是冻结借阅权限图书归还时是从实际还书日期还是应还日期开始算逾期逾期记录需要管理员人工豁免还是自动执行我在这个项目里选择了弹性规则默认借期 30 天支持管理员在后台调整到期前 3 天给读者发站内信提醒逾期后读者借阅权限自动冻结还书时才计算逾期天数并生成欠款记录欠款归还后由管理员手动解冻。为什么不做自动解冻因为现实中存在读者还了书但没付款的情况完全自动化容易让欠款变成烂账。这种现实问题不摸清楚代码写得再快也没有意义。另外还有两个经常被“未来再说”的流程续借和预约。续借本质上是把应还日期往后推预约则是在所有可借副本都被借走时让读者进入等待队列。这两个功能看起来是锦上添花实际上能极大提升平台的使用体验所以我决定在第一版就把它们加进需求范围后面单独讲实现细节。1.3 一开始没想明白而返工的需求点列的坑主要是“同一本书、多个副本”的建模方式。我第一次设计时偷懒把图书和库存当成一条记录有多少本就在表里存多少条相同书名的记录。结果借书时要去“挑一条未借出的记录”还书时又要逐条匹配哪怕是同一本书的不同副本记录之间完全没有区分度查询和统计都变得很别扭。后来才意识到这里必须拆成图书信息和库存记录两层。还有一个返工点是借阅历史查询。最初我只保存当前借阅中的记录图书归还后直接删除对应的借阅行。结果导致管理员查历史记录时数据永远只停留在“当前在借”的状态连谁以前借过哪本书都查不到。这个错误非常典型因为周期性的业务数据是天然需要留痕的删掉了就等于放弃了审计能力后来单独加了一张借阅流水表才把问题解决。2. 技术栈选型与工程结构搭建2.1 Java 后端为什么选了 Spring Boot 而不是更重的全家桶这套平台的后端我坚持用了Spring Boot 3.x MyBatis。有人可能会问现在微服务这么流行Spring Cloud 不是更“先进”对一个单体就能承载的业务来说微服务带来的分布式事务、服务注册、配置中心成本远大于收益。图书借阅平台的读多写少、角色固定单机 Spring Boot 完全够用后续真有必要再按模块拆出去也不迟。ORM 层面我选了 MyBatis 而不是 JPA/Hibernate。核心原因是借书、还书、库存扣减这一类写操作需要非常精确的 SQL 控制尤其是并发场景下的行锁和条件更新直接写在 SQL 里最容易掌控。MyBatis 虽然多写一点 XML但它不会帮你做“魔改”出了问题很快能定位到是哪一句 SQL 的事。前端我用了 Thymeleaf 服务端渲染加少量原生 JavaScript。选它的理由是后台管理页面并不追求炫酷交互表单提交、列表分页、状态切换这类操作服务端渲染反而更稳。如果硬上 Vue/React SPA还要额外处理跨域、鉴权 token 刷新、构建部署等一整套工程问题对中小型借阅平台属于过度设计。2.2 工程目录的分层打法包结构我会按业务方向分而不是按技术层分。常见做法是controller/service/mapper/entity四层但更推荐reader、admin、book、borrow这种按业务域先切开再在各域内分技术层的结构。这样做有一个直观好处改读者模块不会一不小心碰坏借阅模块新人看项目也能更快找到对应代码。因为按业务域之后controller 的数量可能变多我建议配合 Spring 的RequestMapping前缀做规范。例如读者端接口统一/api/reader/**后台管理端统一/api/admin/**这样后续做拦截器鉴权、接口权限控制路径规则非常清晰。下面是一个简化过的包结构可以直接参考com.example.library ├── common // 全局响应体、异常、工具类 ├── config // 拦截器、异步、WebMvc 配置 ├── module │ ├── reader // 读者管理注册、登录、个人信息 │ ├── admin // 管理员登录、统计数据 │ ├── book // 图书信息、库存、分类 │ └── borrow // 借阅、归还、续借、预约 └── infra // 数据访问层封装、分页、分布式ID等2.3 统一返回体和全局异常项目安全感的来源前后端交互如果没有统一的数据结构会出现一个接口返回对象、一个接口返回数组、另一个直接返回裸字符串的混乱情况。从第一个接口开始我就定了统一的接口返回结构public class ResultT { private int code; private String message; private T data; private long timestamp; }所有正常返回都包一层Result所有业务异常都抛出自定义的BizException再由RestControllerAdvice统一捕获。这样前端只用判断code是否为 200 即可不用关心具体接口是成功还是失败也方便后续在拦截器里做统一的响应日志。异常处理这块给了两个教训一是异常类型粒度要够但不要过度细分能用一个BizException带错误码解决的就不要去建十个异常类二是全局异常处理里千万不能把堆栈打到响应体里否则等于把代码结构暴露给调用方。堆栈只留在服务端日志响应只带错误码和可读消息。3. 数据库建模图书数量、可借状态与并发风险的原子化设计3.1 六张核心表的关系数据库设计是这套系统踩坑最多的地方也是最值得细讲的部分。最终我定下了六张表book图书基本信息、book_copy库存副本、reader读者、borrow_record借阅记录、reservation预约记录、user账号信息与读者表配合做登录。它们的关系并不复杂但表与表之间的约束必须非常严谨。book表只负责描述“这本书是什么”书名、作者、ISBN、出版社、分类、封面图路径、简介。book_copy表负责“图书馆到底有几本可借”每一本实体书对应一条记录有一个独立的copy_no并有状态字段表示在架、借出、损坏、下架。borrow_record记录谁在哪一天借走了哪一本reservation记录排队预约情况。这样设计后“这本书有多少本”通过book_copy按book_id聚合就能得到“目前可借数量”通过stateAVAILABLE计数得到。核心表结构可以简化为CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL, title VARCHAR(200) NOT NULL, author VARCHAR(100), category VARCHAR(50), cover_url VARCHAR(500), status TINYINT DEFAULT 1, created_time DATETIME ); CREATE TABLE book_copy ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_id BIGINT NOT NULL, copy_no VARCHAR(32) NOT NULL, state TINYINT NOT NULL COMMENT 0-下架,1-在架,2-借出,3-损坏, borrow_count INT DEFAULT 0, last_borrow_time DATETIME ); CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, reader_id BIGINT NOT NULL, copy_id BIGINT NOT NULL, book_id BIGINT NOT NULL, borrow_time DATETIME NOT NULL, due_time DATETIME NOT NULL, return_time DATETIME, overdue_days INT DEFAULT 0, fine_amount DECIMAL(10,2) DEFAULT 0, status TINYINT NOT NULL COMMENT 0-借出中,1-已归还,2-续借过 );3.2 字段和索引为什么要这样设计有些新手设计表时会喜欢把“图书名称”和“ISBN”都直接做成唯一索引。但现实中同一本《Spring Boot 实战》可能有两个版次、两种封面、甚至两个不同的 ISBN对应用户还都想要所以书名绝对不能唯一。ISBN 也不适合全局唯一因为同一本书的不同印刷批次 ISBN 可能不同。真正适合做唯一键的是book_copy.copy_no每一本实体副本编号是唯一的这才能保证借还时不会拿错书。在索引设计上我最重视三个组合索引borrow_record(reader_id, status)用于查某读者当前借了哪些书borrow_record(copy_id, status)用于查某个副本是否借出reservation(book_id, status, created_time)用于按图书查预约队列顺序。很多人会默认给外键字段单独建索引但如果查询条件里永远同时带上另一个字段单独索引反而白白增加写入开销组合索引更贴合实际 SQL。3.3 借阅记录唯一性约束借阅领域有个很现实的并发问题同一本书的同一本副本理论上不可能同时被两个人借走但如果不加约束高并发下就可能出现两条插入成功的借阅记录。解决方式有两个层级第一层靠代码里的事务和行锁第二层靠数据库唯一索引兜底。我在borrow_record上建了一个实际业务上的唯一索引CREATE UNIQUE INDEX uk_copy_borrowing ON borrow_record(copy_id, status) WHERE status 0;不过 MySQL 不支持WHERE条件索引所以我最终使用的是“冗余状态列 部分唯一逻辑”的方式通过应用层把状态判断做得足够收敛同时把copy_id与status组成普通唯一索引在插入借阅记录时把状态限定成“借出中”。如果并发插入导致事务冲突数据库会直接报唯一键异常此时把异常转换成“该书已被借走”的友好提示即可。这比纯靠代码判断更稳妥是很多人容易漏掉的那道保险。3.4 库存扣减与回补图书馆里的“库存扣减”和电商库存不同电商扣的是“SKU 数量”这里扣的是“具体某一本副本状态”。所以在借书事务里最核心的一句话是UPDATE book_copy SET state 2, last_borrow_time NOW() WHERE id #{copyId} AND state 1这句 SQL 自带原子性只有当副本状态还是“在架”时才能更新成“借出”update影响行数为 0 就说明状态不对事务直接回滚。还书时反过来UPDATE book_copy SET state 1 WHERE id #{copyId} AND state 2这样的方案比先去 SELECT 再 UPDATE 更安全只要事务里所有对副本状态的变更都走这种条件更新就不会出现“查的时候有、改的时候没了”的竞态问题。4. 借还书核心流程的实现细节4.1 借书从副本定位到记录生成一个最基础的借书接口背后至少要做六件事确认读者存在且未被冻结、确认该读者当前借阅数量未超限、找到可借的副本、更新副本状态、插入借阅记录、增加副本累计借阅次数。任何一步失败都要让前面所有步骤回滚所以整个方法必须加Transactional。实际代码如下Transactional(rollbackFor Exception.class) public BorrowResult borrow(Long readerId, Long bookId) { Reader reader readerMapper.selectById(readerId); if (reader null || reader.getFrozen() ! 0) { throw new BizException(ErrorCode.READER_NOT_AVAILABLE); } int activeCount borrowRecordMapper.countActiveByReader(readerId); if (activeCount borrowLimit) { throw new BizException(ErrorCode.BORROW_LIMIT_EXCEEDED); } BookCopy copy bookCopyMapper.selectAvailableByBookId(bookId); if (copy null) { throw new BizException(ErrorCode.BOOK_UNAVAILABLE); } int updated bookCopyMapper.lockAndBorrow(copy.getId()); if (updated 0) { throw new BizException(ErrorCode.BOOK_UNAVAILABLE); } BorrowRecord record new BorrowRecord(); record.setReaderId(readerId); record.setCopyId(copy.getId()); record.setBookId(bookId); record.setBorrowTime(new Date()); record.setDueTime(DateUtil.plusDays(new Date(), loanDays)); record.setStatus(0); borrowRecordMapper.insert(record); bookCopyMapper.increaseBorrowCount(copy.getId()); return BorrowResult.success(record); }这里lockAndBorrow对应的 SQL 就是前面那句条件更新UPDATE book_copy SET state2 ... WHERE id? AND state1。这里不必使用SELECT ... FOR UPDATE因为条件更新本身就锁住了一行既能拿到锁又能完成状态变更一步到位。4.2 还书逾期计算、图书状态与罚金还书比借书逻辑更碎因为还要处理逾期。实际场景里还书时副本可能是“正常可还”或“破损待修”。我在还书接口里额外接收一个参数bookCondition读者或管理员在还书时可以标一下“正常”或“破损”。如果标了破损副本状态不回到“在架”而是置成“损坏”需要管理员去维修或报废。逾期计算的规则很简单应还日期早于实际还书日期天数差就是逾期天数。罚金按每天 0.1 元计算但这个金额我做成系统参数管理员可以在后台改。还书事务里有一个看似不起眼但很重要的动作先把 overdue_days 和 fine_amount 算好写进 borrow_record再判断是否需要冻结读者权限。冻结动作不应该在还书事务里提交更好的办法是把“是否冻结”交给一个定时任务去扫描凡是存在借出中且 due_time 已过期的读者统一置为冻结状态。这样做的好处是即使还书事务因为某些原因失败了定时任务还是会兜底冻结逾期账号两条路径不会互相覆盖。4.3 续借与预约边界条件处理续借看似只是把 due_time 往后改一下但至少要考虑三个条件书尚未逾期、当前没有其他读者预约这本书、续借次数不能超过限制我设置为最多一次。只要这三个条件同时满足就把 due_time 加上原有借期并在borrow_record记录renewed1。预约功能稍微复杂。当所有副本都不可借时读者可以对book发起预约插入reservation表并记录预约时间。当管理员归还一本书且没有立即被另一个读者借走时系统自动把最早预约的读者状态置为“可借”并通知对方在 24 小时内来借阅。实现时可以简单一点还书事务里查一下reservation队列如果有排队读者就把该书对应的新可借副本“保留”给等待列表第一位其它人只能继续排队。这种预约逻辑里最容易出错的地方是并发。两个副本同时被归还而预约队列只有一个读者如果直接用“查到队列第一位就把副本状态置为待取”可能出现把两个副本都锁给同一个人。我的处理是用一个分布式锁或者数据库SELECT ... FOR UPDATE把预约队列和副本状态锁定后再判断避免一拥而上。5. 权限体系与管理员后台拦截器和数据范围如何做到安全5.1 为什么没直接上 Spring Security很多教程一上来就让你集成 Spring Security但实际做这个项目时我没有这么做。Spring Security 功能强大是事实但它的过滤器链、配置体系、用户认证流程对小型系统而言学习成本偏高而且默认行为很多出了问题需要排查的环节更多。我选择了自己实现一层HandlerInterceptor做会话校验只覆盖两个核心诉求登录状态判断、角色权限判断。如果你做的是一个更强调安全性、拥有大量第三方接入的系统那还是应该认真考虑 Spring Security 或 Sa-Token 这类成熟方案。但对图书借阅平台这种角色固定、接口有限的单体应用手动拦截器足够简洁高效。5.2 HandlerInterceptor 实现的会话校验用户登录成功后我会将用户 ID 和角色写入 Session并设置一个随机 token 放进 Cookie。在拦截器里我这样判断public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (CORS.isPreFlight(request)) { return true; } HttpSession session request.getSession(false); LoginUser loginUser session null ? null : (LoginUser) session.getAttribute(Constants.SESSION_LOGIN_USER); if (loginUser null) { throw new UnauthorizedException(请先登录); } if (adminPathPattern.match(request.getRequestURI()) !Role.ADMIN.equals(loginUser.getRole())) { throw new ForbiddenException(无权访问后台); } return true; }登录成功后 Session 里会放角色信息再配合路径匹配实现路由级权限控制。比如/api/admin/**只有管理员角色能进读者路径普通登录用户都能进。需要注意的一点是用户改密码、被冻结、被禁用后不能只改数据库字段还必须让旧的 Session 失效。我在拦截器里每次请求都会校验一次用户的实时状态比起单纯信任 Session这样能在用户被冻结后立刻生效。5.3 管理员操作的保护与前端按钮联动管理员操作通常有更高的数据影响范围比如删除一本书、调整借阅上限、强制标记某本书损坏。这类操作我都做了两层保护后端在方法上做操作日志和操作类型校验前端在弹窗处要求二次确认。很多人觉得前端弹窗只是一层皮其实它可以显著减少误操作真正安全的地方还是后端接口的幂等和状态校验。举一个典型例子管理员想“删除”一本书。业务上这本书可能已经有借阅历史、预约记录直接物理删除会破坏历史数据。我的做法是把book.status置成下架不会真正执行DELETE。这种软删除不仅可恢复还让借阅记录里的外键变得安全。类似地副本损坏状态也不是删除而是把state改成损坏。只要状态流转清晰后台管理就会走向稳定。6. 压测与性能细节索引优化、连接池、缓存6.1 表数据量上来后查询变慢的排查平台内部试运行一个月后借阅记录表大约到了十万条。此时读者端搜索图书、后台统计借阅次数开始出现明显延迟。用EXPLAIN查看后发现两个典型问题一是搜索语句里对title用了LIKE %关键词%完全没法走索引一次查询要全表扫描二是统计GROUP BY book_id时没有合适的索引内存临时表变得很大。对图书标题搜索最终用的是 MySQL 全文索引加ngram 分词解析器因为中文场景下LIKE %...%是无解的。如果数据量大、搜索要求更高我会考虑引入 Elasticsearch但对这个体量的系统没必要。把标题全文索引建好后秒出的结果直接让搜索体验上升了一个档次。6.2 读多写少场景下的连接池与缓存策略图书借阅平台典型特征是“查询量远大于借还量”首页排行榜、热门图书、图书详情都会被频繁点击。HikariCP 连接池默认配置一般是 10 个连接我通过压测发现把连接数调到 20 后的提升有限反而更优的思路是减少对数据库的重复查询。我引入了一层简单的本地缓存用Caffeine缓存热门图书列表和分类统计失效时间设置为 60 秒。排行榜一分钟刷一次是完全可以接受的但数据库压力能降一半。借阅记录和库存状态这类写入频繁或者实时性要求高的数据坚决不进缓存否则会出现库存显示错误这类严重事故。6.3 真实压测数据与参数取值用同一台 4 核 8G 的服务器做测试100 个并发用户循环访问图书列表接口优化前 QPS 大约在 720 左右加全文索引和本地缓存后到了 1500数据库 CPU 占用从 85% 降到 55%。借书接口的并发测试更能说明问题50 个并发同时抢同一本书的最后一本副本在条件更新和事务保护下只有 1 个请求成功其余 49 个全部返回“库存不足”没有出现一单超卖。如果你要复现这套压测我个人建议至少关注三个指标接口 P99 响应时间、数据库连接池等待时间、事务失败回滚数。只盯着 QPS 容易把性能问题掩盖掉真正决定用户体验的是高延迟分位数和事务成功率。7. 部署上线时最容易被环境细节卡住的经验7.1 云服务器上启动失败的排查本地跑得好好的一到云服务器就启动失败这种事十有八九和数据库地址、端口、时区、字符集有关。我最开始部署时application.yml里把 MySQL 地址写成了localhost但服务器上数据库是通过 Docker 跑的端口映射只映射到了127.0.0.1之外的不同网卡导致连接被拒。排查了快一个小时最后把连接地址改成容器各自的 IP 才解决。另外一定要检查时区。MySQL 连接串里如果没有serverTimezoneAsia/Shanghai借阅记录的borrow_time可能会出现 8 小时的偏差。图书这种对时间没有强实时性要求还好但逾期天数是按日期算的时区不对就会把大量正常的借阅记录误判成逾期。7.2 图片上传路径无法访问的坑图书封面是上传到服务器本地目录的结果线上环境里前端通过 URL 访问这些图片时总是 404。原因很简单Spring Boot 默认只处理静态资源目录下的文件上传目录是动态拼接的不在静态资源映射范围。解决方案是在WebMvcConfigurer里加一个虚拟路径映射Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath PathUtil.getUploadDir(); registry.addResourceHandler(/uploads/**) .addResourceLocations(file: uploadPath); }这里还有一个隐蔽坑Linux 环境下如果使用相对路径上传目录会落在当前工作目录下而服务重启后工作目录可能变化导致旧图片全部失效。我最终把所有外部文件都放在一个独立的/data/library-uploads目录并在启动参数里通过环境变量注入避免硬编码路径。7.3 按步复现的部署检查清单部署这件事全靠脑子记忆是不靠谱的我给自己整理了一份检查表数据库是否初始化了六张表并导入初始分类数据环境变量中的数据库密码、上传目录、日志级别是否全部配置前端静态资源是否构建到resources/static下启动脚本是否设置了-Xms512m -Xmx1024m的堆内存参数防火墙是否只放行需要对外暴露的端口定时任务是否开启了逾期扫描与到期提醒。每次重新部署时按顺序过一遍基本不会再出现之前的低级问题。项目发布后我最大的感受是在线图书借阅平台真正的难点从来不在“会不会写 CRUD”而在“数据状态流转是否严密、并发边界是否堵住、权限是否收得紧”。如果当初一上来就写 Controller后面八成会被库存超借、权限越权、统计跑偏这些问题折腾到崩溃。照着上面这套设计把这些基础打牢后面无论是加一个图书推荐模块、接一个消息通知服务还是把统计报表做进大屏都只是在稳定地基上添砖加瓦。