“静思书屋”是我花了近两个月时间从零搭建并稳定运行的一个基于Java Web技术栈的高性能图书信息平台。这个项目最开始只是因为身边朋友开的小书屋被手工借阅登记搞得焦头烂额,图书找不到、库存对不上、逾期催还靠人肉打电话,后来我干脆把它当成一次完整的技术实践来做,不仅把借阅流程线上化,还把“高性能”作为硬指标去设计,前后经历过选型反复、慢SQL排查、缓存穿透和并发抢书等一系列问题。如果你正在做Java Web相关的项目,或者准备用JSP+Spring Boot这套组合交出实训/毕设作品,这篇对你会很有参考价值。
我会把整个项目从业务梳理、技术选型、数据库设计、性能优化到前端审批流落地都串起来讲,重点放在那些“上机课不会告诉你”的细节和取舍逻辑上。
1. 从柜台登记到信息平台:静思书屋到底需要什么
很多Java Web项目一上来就堆功能,结果做出来像大杂烩。静思书屋这个项目最大的不同在于,它的业务边界很清晰:一个线下的中小型书屋,想用一套信息平台替代手工登记,让读者能查书、预借、续借,也让管理员能处理入库、借还、催还和简单的审批。需求不复杂,但不代表实现简单,因为书屋的流量虽然没法跟电商比,但数据特点非常折磨人:图书总量几万册、热门书籍的借阅非常集中、查询条件杂乱,再加上借还需要非常严格的库存控制,一不留神就会做出一个“看起来能用、一上线就卡”的站点。
1.1 原有登记流程的四个真实痛点
我在做需求调研的时候,跟着门店伙计盯了一整天前台,总结下来的问题非常典型。
- 找书基本靠记忆。读者问“有没有讲Spring源码的书”,店员得跑到书架上一本本翻,翻不到就在本子上一页页查,体验极差。
- 库存数据等于摆设。系统里标着“可借”的书,可能在架位上早就不见了;被读者随手放错位置的书,两个月后才会有人发现。
- 借还登记靠手写。一本书被谁借走、什么时候该还,都记在纸质登记本上。书一多,经常漏记还书日期,逾期了也不知道该提醒谁。
- 热门书排队靠抢。一本新到的高并发/性能类图书,一天能有三四个人问,最后先到先得,其他人只能干等,没有任何预约机制。
这四个痛点决定了平台的核心定位:它不是一个纯做图书信息展示的网站,而是一个围绕“查询—预约—借还—逾期管理”的图书事务处理系统。
1.2 平台需要覆盖的核心业务链路
结合痛点,我把静思书屋的业务链路拆成了三段:
- 图书供给侧:管理员做新书入库、旧书下架、书目标签维护,每本书对应一个唯一的物理条码。
- 读者服务侧:读者检索图书、查看详情与库存、发起借阅申请、归还图书、续借、预约热门书。
- 后台管理侧:审批借阅/入库申请、处理逾期罚金、查看热门榜单、动态调整可借数量。
之前提的“审批流”,在静思书屋中主要体现在两块:普通读者借阅申请需要经过管理员审批(防止超借、超期未还的人继续借书),以及新书入库需要走过“录入—审核—上架”流程。
1.3 为什么“高性能”在这个项目里是硬指标
有人可能会说,一个小书屋能有多大的并发?还“高性能”?这是我一开始也有的想法,但实际做下来发现,中小型图书信息平台的瓶颈不在于总用户量,而在于三个人群集中的时段:每天午休、晚间和周末,读者会扎堆检索同一个关键词,比如“Java”这个词在考试季前后访问量会突然冲到平日的十几倍,首页轮播图和热门榜单接口极易被打爆。
再加上管理员会不停地刷新库存页面、读者端频繁提交预约请求,如果代码在SQL层面或缓存设计上偷懒,这台服务器会在最需要稳定的时候掉链子。因此,我在设计之初就定下了几个量化指标:首页响应时间不超过200ms,图书查询接口TP95响应时间不超过500ms,借阅申请提交高峰期的错误率不超过0.1%。后面所有技术选型、表设计和代码优化,都是围绕这几个数字展开的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型的弯路与定稿:Spring Boot+JSP这套组合为什么合适
技术选型是整个项目里最容易被忽视但实际上最值得复盘的部分。我在早期参考网上很多教程,差点把项目做成了Struts2+Spring+Hibernate的“古董组合”,后来冷静下来砍掉重来,才确定了一套兼顾教学、实现和运行效率的方案。
2.1 Struts2和Hibernate带来的教训
第一版我确实试过Struts2+Hibernate。Struts2的拦截器概念很经典,但放到今天,它的配置繁琐程度和社区活跃度已经不太适合新项目落地;Hibernate的ORM自动映射在表结构简单时很省事,但是静思书屋的图书表和借阅记录表之间存在大量动态统计查询,用Hibernate硬写HQL或Criteria反而别扭,遇到一次查询关联三张表、还要做动态条件拼接的情况,HQL的调试成本直线上升。
另一个坑是依赖冲突。Struts2和Spring整合时需要非常小心版本匹配,不然各种“ClassNotFound”会和你在生产环境里捉迷藏。折腾两周后,我做了一个很清醒的决定:既然项目不是老系统维护,没必要给未来的自己挖坑,干脆用目前Java Web领域更主流的Spring Boot + MyBatis组合。
2.2 定稿后的技术栈组成与理由阐述
最终技术栈如下:
| 层次 | 选型 | 选它的核心理由 |
|---|---|---|
| 开发框架 | Spring Boot 2.7 + Spring MVC | 自动配置减少大量XML,内置Tomcat,开发调试效率高 |
| ORM层 | MyBatis + 手写SQL | 复杂统计、动态SQL方便,SQL执行可控、可针对性优化 |
| 视图层 | JSP + JSTL + jQuery + Bootstrap | 服务端渲染首屏,前端只处理局部交互,开发门槛低 |
| 数据库 | MySQL 8.0 | 关系型数据模型清晰,全文索引和JSON类型都可用 |
| 缓存 | Redis | 处理热门榜单、图书详情热点数据,降低MySQL压力 |
| 前端资源 | jQuery + Ajax | 审批流、借阅状态等异步交互,代码简单粗暴但稳定 |
Spring Boot提供的能力恰好覆盖了我的所有需求:可以用一个@RestController快速暴露JSON接口,也可以用@Controller配合JSP做页面跳转。MyBatis则可以让我把精力放在SQL本身,而不是去猜框架到底生成了什么低效语句。
2.3 保留JSP渲染而不彻底前后端分离的原因
这个点我在知乎、掘金上都看到过很多争论。说实话,如果静思书屋面向公网海量用户,我会毫不犹豫地选择前后端分离,Vue或React + 后端纯API。但当我评估了项目的现实约束后,发现JSP服务端渲染反而更合适:
- 项目核心页面大多是图书列表、详情、用户借阅记录这类以内容展示为主的页面,首屏渲染用JSTL在服务端循环输出HTML,用户看到内容的耗时比先加载JS再发Ajax要短。
- 书屋管理员后台需要在普通PC甚至平板上操作,兼容性要求高,jQuery能很好满足。
- 开发团队和后期接手者如果是刚接触Java Web的学生,JSP这套模式比前端工程化那套心智负担小得多。
当然,这不意味着JSP页面里要写满Java代码。我的原则是JSP只做展示和简单判断,所有动态数据在Controller里组装好,复杂的交互逻辑统一通过jQuery发起Ajax请求,返回JSON格式数据由前端拼接渲染。这样既保留JSP快速开发的优势,又不至于让页面变得不可维护。
3. 数据库模型设计:借阅状态机先想清楚,后面才不会改到崩溃
数据库是Java Web项目的地基。静思书屋的数据库设计花了我大半个月,中间改了三版。最痛苦的一版是没考虑清楚“借阅申请”和“借阅记录”的关系,导致后面加审批流时不得不大面积返工。
3.1 六张核心表与关键字段说明
最终我会把表收敛到六张(用户表、图书表、图书副本表、借阅申请表、借阅记录表、逾期罚金表),外加两张字典表。这里必须强调一个容易犯的错误:不要直接用“图书表”去存库存数量。一本实体书在书屋里只有一个物理副本,假设同一本《Java编程思想》有5本,那么每本都应有一个独立的book_copy记录,有自己的条码、破损状态、所在架位。这样在做借阅登记时,才不会出现“书还在,库存却被借空”的幻觉。
以下是精简后的核心表结构设计要点:
user表:用户ID、昵称、读者证号、手机号、状态(正常/冻结)、累计借阅次数、信用分。book表:图书ID、书名、作者、出版社、ISBN、分类ID、封面URL、简介、当前可借副本数、总浏览量、加入时间。其中“当前可借副本数”是冗余字段,用于列表页快速展示。book_copy表:副本ID、book_id、条码号、所在书架位置、状态(在架/借出/损坏/下架)、入库时间。borrow_apply表:申请ID、读者ID、book_copy_id、申请类型(借阅/续借)、审批状态(PENDING/APPROVED/REJECTED/CANCELED)、申请时间、审批人ID、审批意见、审批时间。borrow_record表:记录ID、读者ID、book_copy_id、借出时间、应还时间、实际归还时间、状态(借出中/已归还/已逾期)、是否续借过。penalty表:罚金ID、record_id、金额、产生原因、是否缴纳、缴纳时间。
审批表与借阅记录表分离是为后续运营埋下的伏笔。审批流本身管理的是读者“有没有资格借这本书”;一旦审批通过,系统才生成真正意义上的借阅记录,这时才开始计算应还日期。很多类似项目把这两个动作放在一张表里,审批拒绝后还要去删除或伪造一次记录,逻辑路径会非常尴尬。
还需要特别关注的是,给book表加上total_views字段是为热榜准备的。页面每次点击图书详情时,把浏览量落在Redis里异步批量更新,而不是直接UPDATE数据库,否则一次热点请求就能把InnoDB的行锁打满。
3.2 借阅状态流转与并发扣减设计
状态机设计是一个信息管理系统最容易改崩的地方。静思书屋的借阅状态链有两种:
- 借阅申请单状态:PENDING(待审批)→APPROVED(通过)→生成借阅记录→读者取书;PENDING→REJECTED(拒绝);PENDING→CANCELED(读者取消)。
- 借阅记录状态:BORROWED(借出中)→RETURNED(已归还);BORROWED→OVERDUE(逾期)→RETURNED(归还并需缴纳罚金)。
这套状态链被写死在一个BorrowStatusFlow工具类里,任何状态变更都先做合法性校验,防止从“已归还”直接跳到“审核中”这类脏数据。
扣减可借副本数的过程则要处理并发。当读者和管理员同时操作同一本书时,纯粹用“先查再改”的方式会出现超借。最终我选择了数据库层面的乐观锁,在book表加一个version字段,或用UPDATE book SET available_copy = available_copy - 1 WHERE id = ? AND available_copy > 0这种方式直接执行原子更新。让数据库替我们完成判断,比在Java代码里加synchronized或Lock可靠得多,尤其是在将来部署多实例时,本地锁天然失效。
3.3 索引规划与慢SQL排查要点
表结构定了只是第一步,索引才是查询性能的分水岭。静思书屋上线初期遇到一个很典型的慢SQL,当时图书详情页需要关联book、book_copy和borrow_record去查询某本书是否可借,接口耗时有时候会跑到1.2秒以上。
通过EXPLAIN查看发现,borrow_record表正在做全表扫描,因为条件里查的是book_copy_id加status两个字段,只有一个普通索引。处理方案是先建立联合索引:
sql复制ALTER TABLE borrow_record ADD INDEX idx_copy_status (book_copy_id, status);
ALTER TABLE borrow_apply ADD INDEX idx_user_apply_status (user_id, status, create_time);
ALTER TABLE book ADD INDEX idx_book_category (category_id, create_time DESC);
建立联合索引时遵循最左前缀原则。以borrow_apply为例,如果查询条件是“按某个用户、按状态、按时间排序查申请列表”,那联合索引的顺序必须是user_id先行,接着是status,再往后是create_time,这样无论单独查用户、查用户+状态还是查用户+状态+排序,都能完整用上索引。
另外要提醒的是,不区分大小写的排序规则和中文排序在MySQL 8.0里默认一般没问题,但书名的utf8mb4_general_ci和utf8mb4_0900_ai_ci在不同版本可能有差别,老项目迁移时要特别小心,同一张表内排序规则不一致,关联查询会直接吃不到索引。
4. 图书检索与热门榜单背后的性能调优:从3秒到200ms的实操记录
这一部分是整个项目最硬核的地方,也是真正让静思书屋称得上“高性能”的核心。我不会只贴结论,会把当时的压测数据、踩坑和调整过程都摆出来。
4.1 首页热门图书接口的Redis缓存方案
静思书屋首页会展示“热门图书TOP10”和侧边栏“新书速递”。第一版代码是按浏览量从book表倒序查出来的,没有任何缓存。上线后发现,每次打开首页都要执行3条关联查询和1条子查询,哪怕只有50个人同时访问,MySQL的CPU也会飙升到70%,接口响应时间平均要2~3秒。
优化方案分成两层:
- 先用Redis存储热门榜单。每当用户访问图书详情页时,在Redis执行
ZINCRBY hot_books 1 {bookId},再用定时任务每5分钟把TOP20从Redis刷新到MySQL的book_rank_cache表。 - 首页接口读取时,直接查Redis里的有序集合,取出前10个bookId,再走一次批量主键查询补齐图书信息。
对应的核心代码如下:
java复制@Autowired
private StringRedisTemplate redisTemplate;
public List<BookVO> getHotBooks() {
// 1. 尝试从缓存中读取
String cacheKey = "portal:hotBooks";
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.hasText(cacheValue)) {
return JSON.parseArray(cacheValue, BookVO.class);
}
// 2. 缓存失效则从热点表读,并回填缓存,TTL设为10分钟
List<BookVO> hotBooks = bookRankMapper.selectHotBooks();
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(hotBooks), 10, TimeUnit.MINUTES);
return hotBooks;
}
就是这个缓存,直接让首页接口的TP99从2800ms降到40ms以下。不过缓存也不是加了就万事大吉,第一次上线我就遇到了缓存穿透问题——有人专门爬不存在的图书ID,每次请求都会穿透到数据库,还好我提前接入了布隆过滤器,把已经存在的bookId加载到内存位图里,过滤掉绝大多数的无效请求。
Redis里我还会维护一个“最近浏览记录”结构,供读者查询自己看过什么书。这个用LIST结构实现,每次往头部Push,只保留最近20条,可以避免频繁读写数据库。毕竟书目平台和电商不同,浏览记录本身没有长期交易价值,没有必要持久化到MySQL。
4.2 图书模糊搜索:别让“%关键词%”拖垮MySQL
静思书屋的检索条件是多元的:书名、作者、出版社、ISBN、分类。最开始我图省事,直接用MyBatis动态SQL拼一个LIKE '%${keyword}%',结果搜索“Java”的时候,MySQL会对整本图书表做一次全表扫描,索引完全失效,响应时间直接飙到6秒。这是因为%keyword%这种前后都带百分号的模糊查询,B+树索引根本无法命中。
我采取的改进策略是分三类处理:
- 精确匹配类(ISBN、条码号):直接等值查询,命中唯一索引,速度最快。
- 前缀匹配类(书名、作者):过滤掉“以%开头”的写法,程序把用户输入关键词去掉头尾空格后拼接成
keyword%,让索引尽可能参与查询。 - 全文检索模糊类(简介、多关键词组合):MySQL 8.0内置的ngram全文索引可以支持中文,我在
book表的书名和简介字段上创建了FULLTEXT索引,再使用MATCH ... AGAINST做全文检索。
后来发现“搜索词部分出现在中间”还是会有漏网之鱼,比如用户搜“程序设计”但书名是“Java程序设计实战”,用前缀匹配会漏掉中间匹配的书。最终我选择引入一个轻量级方案:为每本图书维护一个关键词表book_keyword,存入分词后的结果,查询时用关键词做等值匹配。这样的设计虽然要额外维护数据,但查询性能远远好于原生的MODEL LIKE。
如果你不想引入ES或者分词组件,这里的取舍思路是可以直接复用的:搜索引擎做的事本质上就是把文本拆分成多个可等值查询的词。即使将来图书量涨到几十万,这个表结构也能撑得住。
4.3 借阅并发扣库存:数据库原子更新解决“一册书同时借给两个人”
有一次线上联调时,两个读者同时请求借阅同一本剩余副本数为1的书,两个请求都通过了审批,最后借阅记录生成了两条。问题出在我的借阅申请通过逻辑是先SELECT available_copy,再判断是否大于0,然后再UPDATE,中间存在一个时间窗,两个线程同时读到旧值。
修复的SQL很直接:
xml复制<update id="decreaseAvailable">
UPDATE book
SET available_copy = available_copy - 1,
version = version + 1
WHERE id = #{bookId}
AND available_copy > 0
AND version = #{version}
</update>
如果这个UPDATE的影响行数为0,说明别人已经抢先扣减了,程序立刻返回“库存不足请预约”。这也解释了为什么说“Java Web高性能”不是单靠某个框架就能获得的,它需要从接口设计到SQL语句一层层去控制风险。以book表中的available_copy为例,这个字段永远是“可以被并发扣减”的资源,任何先读后写的代码都是不可靠的。
可借副本数扣减成功后,还需要保证借阅申请单和借阅记录在同一个事务里创建。如果只扣了库存而记录没生成,第二天开放运营时会发现库存神秘消失;只生成记录而没扣库存,则会出现超借。两个动作必须放到一个用@Transactional标注的方法里,而且事务要尽量短,不要把发短信、推送通知这些耗时操作放进去。
4.4 列表页深分页优化:放弃大偏移量LIMIT
图书列表页还有个典型问题:当用户翻到第500页时,SQL会执行LIMIT 10000, 20,MySQL仍需先扫描10020行再丢弃前10000行,越往后越慢。静思书屋的优化方法是把分页参数从pageNum/pageSize改成“上一页最后一条记录的游标值”,比如按时间排序时,客户端带上上一页最后一条的create_time和id,SQL就变成:
sql复制SELECT * FROM book
WHERE category_id = #{categoryId}
AND (create_time < #{lastCreateTime}
OR (create_time = #{lastCreateTime} AND id < #{lastId}))
ORDER BY create_time DESC, id DESC
LIMIT 20;
这种方式能稳定命中索引。游标分页初期会让人不习惯,但像图书书目这种数据变化不频繁、又存在大量顺序浏览的场景,它比传统分页可靠得多。
5. JSP+JS+jQuery实现审批流的完整落地细节
标题里提到的“Java Web + JSP项目中前端使用JS+jQuery如何实现设置审批流”,这是很多读者关注度很高的操作场景。静思书屋的借阅审批流是我踩了不少坑之后才跑通的一个完整闭环。它的要求是:读者在前端提交借阅申请,管理员在后台能看到待审批列表,点击同意或拒绝后,页面不刷新就能更新状态。
5.1 JSP服务端渲染与Ajax局部刷新怎么分工协作
我的核心设计原则是“首屏用JSP渲染,操作反馈用Ajax”。审批列表页的初始数据由后端Controller返回一个ModelAndView,使用JSTL遍历:
jsp复制<c:forEach items="${applyList}" var="app">
<tr id="apply_${app.applyId}">
<td>${app.bookName}</td>
<td>${app.readerName}</td>
<td>
<c:choose>
<c:when test="${app.status == 'PENDING'}">
<span class="badge badge-warning">待审批</span>
</c:when>
<c:when test="${app.status == 'APPROVED'}">
<span class="badge badge-success">已通过</span>
</c:when>
<c:otherwise>
<span class="badge badge-danger">已拒绝</span>
</c:otherwise>
</c:choose>
</td>
<td>
<button class="btn btn-sm btn-primary approve-btn" data-id="${app.applyId}">通过</button>
<button class="btn btn-sm btn-danger reject-btn" data-id="${app.applyId}">拒绝</button>
</td>
</tr>
</c:forEach>
“通过”和“拒绝”的事件处理全部委托给jQuery,点击按钮后,前端发起Ajax请求到后端,后端只返回JSON,前端根据返回值修改那一行的状态文字并禁用按钮,实现无刷新交互。
这里需要特别注意:不要把业务状态枚举的中文名直接输出到前端代码里,更不要用${app.status}去比对中文,状态值必须用统一英文枚举,展示名由模板或JS映射。
5.2 自定义审批意见弹窗与二次确认流程设计
在一开始的版本里,管理员点击“拒绝”会立即提交,没有任何填写意见的步骤,这在实际运营中非常不友好。后来我加了一个模态框,还没引入任何重型前端框架,只靠Bootstrap的Modal和jQuery就能实现。
javascript复制$(document).on('click', '.reject-btn', function () {
var applyId = $(this).data('id');
$('#rejectModal').modal('show');
// 将当前处理单号暂存到全局变量,供确认按钮使用
currentApplyId = applyId;
});
$('#confirmRejectBtn').on('click', function () {
var opinion = $('#rejectOpinion').val();
if (!opinion.trim()) {
alert('请填写驳回意见');
return;
}
$.ajax({
url: '/admin/borrowApply/reject',
type: 'POST',
contentType: 'application/json;charset=UTF-8',
data: JSON.stringify({ applyId: currentApplyId, opinion: opinion }),
dataType: 'json',
success: function (result) {
if (result.code === 0) {
$('#rejectModal').modal('hide');
// 更新列表行状态为已拒绝
$('#apply_' + currentApplyId + ' .status-cell')
.html('<span class="badge badge-danger">已拒绝</span>');
$('#apply_' + currentApplyId + ' .btn').prop('disabled', true);
$('#apply_' + currentApplyId + ' .opinion-cell').text(opinion);
} else {
alert(result.msg);
}
},
error: function () {
alert('网络异常,请稍后重试');
}
});
});
这样做最大的收益是整个页面不刷新,管理员连续审批几十条时,不会因每次点按钮都跳转而浪费几秒等待时间。表单提交后应该以JSON格式传输而不是普通表单序列化,这样后端在接收时可以用一个专门的ReviewRequest对象来接收消息,取值、校验会更干净。
5.3 后端接收与状态变更的拦截面
对应的Controller层代码示例如下:
java复制@PostMapping("/admin/borrowApply/reject")
@ResponseBody
public Result<Void> reject(@RequestBody ReviewRequest request) {
// 1. 校验权限:当前用户是否为管理员
// 2. 校验申请单存在且状态为PENDING
BorrowApplyDO apply = borrowApplyMapper.selectById(request.getApplyId());
if (apply == null || !"PENDING".equals(apply.getStatus())) {
return Result.fail("申请单不存在或已被处理");
}
// 3. 更新状态并记录审批意见
borrowApplyService.updateStatus(request.getApplyId(),
"REJECTED", request.getOpinion(), currentAdminId());
return Result.ok();
}
这里的拦截逻辑是安全底线。实际开发中,很多人会在前端隐藏了一个按钮就认为权限安全,但真正上线后,只要有接口地址,别人就可以直接模拟Post请求去操作数据。因此服务端必须每次都做“业务状态校验+权限校验”,而不是依赖前端界面去限制。
5.4 审批流实现的四个常见问题
- 重复提交:管理员双击“通过”按钮可能产生两条请求,前端要在第一次点击时把按钮设为
disabled,后端还需要在事务中通过状态条件更新来防止穿透。 - 状态乱序:申请单已经被拒绝后,管理员又通过一个缓存页面发来“通过”的请求。后端必须通过版本号或状态字段拒绝这种更新。
- Ajax请求超时:如果审批操作后面还涉及扣库存、发通知等耗时操作,Ajax很容易在10秒无响应后报错。解决办法是审批成功后先返回成功,然后把耗时操作丢到线程池异步执行。
- JSP页面变量冲突:JSP的EL表达式需要和后端模板变量保持一致,字段名最好使用驼峰规则并配
mapUnderscoreToCamelCase,减少手动映射的出错概率。
6. 稳定运行后的优化时刻:缓存双删、定时归还任务与其他细节
平台上线后的半个月并不是结束,恰恰是从“能跑”到“稳定跑”的开始。我在这个阶段处理了三个印象很深的问题,每个都值得提出来,因为它们很容易在你看似正确的代码里悄悄埋伏。
6.1 数据库更新后缓存里的旧数据无法自动失效
热门图书缓存设了10分钟有效期,但管理员有时会修改一本书的简介和封面图,修改之后,读者端的详情页还是旧信息,最长要等到缓存过期才会刷新。我最初用的是“先更新数据库,再删除Redis缓存”的策略,但在高并发下存在一个时间窗:请读者A读取时发现缓存为空,查询数据库得到旧值,在写入Redis之前,读者B完成了数据库更新并删除了缓存,然后A把旧值重新写入了缓存,导致旧数据残留。
这也是业内常说的缓存双删问题。实际处理中,我在数据库更新后先删一次Redis缓存,紧接着休眠500毫秒,然后再次删除。500毫秒是为了让可能存在的旧缓存写入动作先完成,确保第二次删除能把它清掉。虽然不能100%消灭竞态,但实际运行中几乎不会出错,是性价比极高的方案。
java复制// 1. 更新数据库
bookService.updateBookInfo(bookVO);
// 2. 删除缓存
redisTemplate.delete("book:detail:" + bookId);
// 3. 等待500ms,让并发读请求完成后再次删除
Thread.sleep(500);
redisTemplate.delete("book:detail:" + bookId);
如果你不想在业务代码里硬编码休眠,也可以采用消息队列的延迟删除方案,但对于这个小项目,双删已经够了。
6.2 还书逾期任务的执行窗口不能在整点
静思书屋需要每天检查借阅记录,把超过应还日期的记录状态置为“OVERDUE”,并计算罚金。一开始我用Spring自带的@Scheduled(cron = "0 0 1 * * ?")每天凌晨1点定时扫描,结果因为扫描范围是全部未归还记录,一个月后数据量涨到十几万条以后,任务开始变慢,有时持续到凌晨3点还没跑完,导致早上管理员看到的数据状态滞后。
优化方案是扫描增量数据:每次只查“应还日期小于当前时间且状态为BORROWED”的记录,同时把borrow_record表的due_time字段加上索引,让任务的扫描范围始终维持在很小的区间。另外,定时任务不可能比实时催还准确,我在还书接口里也做了一次防御性检查,如果读者归还时记录的状态仍为BORROWED,而当前时间已经超过应还时间,会先触发一次逾期计算再执行归还,这样就不会依赖定时任务调度了。
6.3 压测数据与整体运行表现对比
最后放一组当时的实测数据,环境是4核8G的云服务器,MySQL和Redis同机部署;压测工具用JMeter模拟了80个并发用户连续操作5分钟,主要场景包括首页访问、图书搜索、提交借阅申请。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首页接口平均响应时间 | 2100ms | 85ms |
| 图书搜索TP95响应时间 | 5900ms | 260ms |
| 借阅申请事务成功率 | 92% | 99.98% |
| MySQL CPU使用率(峰值) | 80%+ | 35% |
| 缓存命中率 | 0% | 91% |
从数据可以明显看出,性能优化不是某一条SQL能救回来的,它需要从索引、缓存、并发控制、定时任务调度各方面协同发力。静思书屋这套处理下来,单机扛住小几百人的同时在线访问绰绰有余,已经能很好覆盖中小型书屋的实际需求。
如果让我重新做一次,我会在项目一开始就引入完整的接口文档工具(例如SpringDoc或YApi),因为后期扩展审批流和借阅接口时,前端联调没有文档真的很痛苦。另外数据库连接池参数建议一开始就压测调好,而不是用HikariCP默认配置,默认的maximumPoolSize是10,在某些峰值场景确实会排队。Java Web这个技术栈也许在很多人眼里不够“新潮”,但对于一个图书信息平台来说,选型的意义不在于追逐热点,而在于用最合适的技术解决真实问题,这个道理放在任何时候都不过时。
