1. 项目概述
做图书管理系统的人不少,但真正把“个性化推荐”做进去的,其实不多。这个项目正好踩在我觉得最有价值的一条线上:用 SpringBoot + Vue 搭一套完整的图书管理系统,再通过 MyBatis 操作 MySQL 里的用户行为数据,跑一个基于协同过滤的推荐逻辑,让每个用户登录后看到的书单都不一样。
这套系统能干什么?简单说三层:第一层是基础图书管理,图书的增删改查、分类管理、借阅记录,这些都是常规操作;第二层是用户体系,包括注册登录、个人信息维护、借阅历史;第三层才是这个项目的灵魂,把用户的历史借阅数据、评分数据收集起来,用算法算出一个“猜你喜欢”的推荐列表,首页推荐位、详情页相关推荐都从这里来。
适合谁来参考?一是正在做毕业设计或者课程设计的同学,这个项目麻雀虽小五脏俱全,前端后端数据库算法全沾;二是刚入行的 Java 开发,想看看 SpringBoot + Vue 前后端分离项目怎么落地,推荐算法在业务里怎么跟 CRUD 结合;三是想在图书管理系统基础上做二次开发的朋友,推荐模块是独立封装的,拆出来换数据集也方便。
整个项目用 Maven 做依赖管理,前端用 Vue CLI 构建,后端是标准的 SpringBoot 分层架构,数据库脚本直接提供建库建表语句,属于拿到就能跑的那种。我下面会把项目从结构到实现一层层拆开讲,卡点和我踩过的坑也会一并交代清楚。
2. 整体设计与技术选型思路
2.1 为什么是 SpringBoot + Vue 这个组合
先说后端。图书管理系统本质上是典型的业务系统,核心就是围绕图书和用户这两张主表做 CRUD,再加上一层推荐计算的逻辑。SpringBoot 在这个场景里几乎是“标准答案”:内嵌 Tomcat,不用额外部署容器;自动配置机制省掉了一大堆 XML 配置;配合 MyBatis 做数据访问,SQL 自己控制,比 JPA 那种全自动的 ORM 更直观,尤其适合有复杂查询需求的推荐场景。
Vue 这边,前后端分离已经是当下开发的主流形态。Vue 的组件化开发让页面拆成独立的模块,图书列表、推荐位、评分组件、用户中心互不干扰,维护起来省心。配合 Axios 调后端接口,数据驱动视图更新,用户体验比传统的 JSP 模板渲染顺滑得多。
这个组合还有一个实际的好处:就业市场认可度高。SpringBoot 是现在 Java 后端的事实标准,Vue 是前端框架里占有率最高的之一,做这个项目练手,简历上的技术栈写出来是有说服力的。
2.2 推荐算法的选型考量
推荐算法这个事儿,很多图书管理系统压根不做,做了的也多半是“按分类推荐同类型”这种伪推荐。我在这套系统里用的是基于用户的协同过滤(User-Based Collaborative Filtering),说人话就是:找到跟你口味相似的一群人,看他们读什么书,把那些你还没读过的书推荐给你。
为什么选协同过滤而不是其他算法?三个原因:
第一,数据模型匹配。图书管理系统天然有用户、图书、评分(或者借阅行为)这三样东西,恰好构成了协同过滤要求的用户-物品评分矩阵。不需要额外采集数据,现有业务数据就能喂饱算法。
第二,实现成本可控。在项目里用 Java 代码手写一个协同过滤核心逻辑,大概两百行左右就够,不需要引入 Spark、Mahout 这类重型计算框架。对单机部署的管理系统来说,计算量完全在可接受范围内。
第三,效果直观可解释。推荐结果能追溯到“哪些相似用户影响了你”,这在做系统演示和答辩的时候特别好讲,一个链路讲下来,业务价值和技术深度都体现出来了。
当然也有妥协的地方。基于用户的协同过滤在用户量小的时候会面临冷启动问题——新用户没有行为数据,算不出他的相似邻居。这个我在系统里做了兜底策略:新用户或者行为数据不足的用户,默认走基于图书分类的热门推荐,等积累了一定行为数据后再切到个性化推荐。
2.3 数据库设计的隐藏逻辑
MySQL 这边,核心表设计走的是典型的三表加两辅表结构。三表是用户表、图书表、借阅记录表,两辅表是评分表和分类表。这里有一个细节很多人容易忽略:评分表和借阅记录表其实是分开的。
为什么分开?因为借阅是行为,评分是态度,两者产生的时机和数据形态都不一样。借阅记录只要有借书还书动作就实时写入,评分则是在用户归还图书后或者在使用过程中主动提交的。分开存,统计借阅热度和计算用户偏好时各自取数,逻辑清晰,也方便后续做数据清洗。
字段设计上,图书表除了基本的信息字段,我专门加了一个cover_url存封面图地址,一个stock存库存,还有borrow_count做借阅次数的冗余统计。这个borrow_count是典型的空间换时间做法,热门排序直接ORDER BY borrow_count DESC,不用每次实时去 count 借阅表,查询效率高很多。
3. 核心功能模块与实现要点
3.1 用户模块:从注册到偏好画像
用户模块看着简单,其实有细节。注册做的最基本校验是用户名唯一性和密码长度,但密码不能明文入库,这里用 MD5 加盐的方式处理。加盐是啥意思?就是用户在注册时生成一个随机字符串,跟密码拼在一起再做 MD5,这样即使用户密码相同,库里存的密文也不一样,能有效对抗彩虹表攻击。
用户登录成功之后,系统要做两件事:一是发 Token,这里用 JWT(JSON Web Token),无状态认证,后端不用存 session,前端拿着 Token 放在请求头里访问受保护接口;二是初始化用户画像,把该用户的历史借阅数据、评分数据从库里捞出来,准备算推荐。
用户画像说白了就是一个临时对象,里面存着用户对不同分类的偏好权重。比如张三借了 5 本计算机的书,给其中 3 本打了高分,那他的画像里“计算机”这个分类的权重就高。这个画像不是静态存的,是每次登录时基于最新数据实时算的,为了效率,用了一个定时任务每天凌晨把全量用户的画像批量算一遍存入缓存,白天登录就直接读缓存,速度飞快。
3.2 图书管理模块:常规背后的特殊设计
图书的增删改查没什么好说的,标准 MyBatis 操作。但要提两个设计上的点。
第一个是分页查询。图书数量一上来,全量查询就是灾难。这里用 MyBatis 的 PageHelper 插件,一行代码搞定物理分页,前端传 pageNum 和 pageSize,后端返回总条数和当前页数据。PageHelper 的原理是基于拦截器,在 MyBatis 执行 SQL 前自动拼接 LIMIT 语句,用起来省心,但有一个坑:使用时必须紧跟第一条查询语句,中间不能夹带其他查询,否则分页参数会串,这个我在后面问题排查里细说。
第二个是图书封面的存储方案。项目里封面图没有走本地上传,而是直接填图片 URL。这样做的好处是避免了后端处理文件上传、存储路径映射、静态资源拦截这一大堆事,开发效率高。本地开发可以填本地图片,上线可以换 OSS 地址,灵活性很好。当然如果要做完整的图书管理功能,本地上传也值得加,我建议用 Vue 的 Element UI 上传组件 + SpringBoot 的 MultipartFile 接收,存入服务器指定目录,再把访问路径写入数据库,这块后面有需要可以扩展。
3.3 借阅与评分:推荐算法的数据源泉
借阅流程是:用户在前端选书,点击借阅,后端生成一条借阅记录(图书 ID、用户 ID、借阅时间),同时把图书表的库存减一;还书时更新记录,库存加一。
这里有一个业务规则上的细节:一个用户同一本书只能借一本未归还的。这个约束不能只靠前端判断,后端在生成借阅记录前必须查库确认,否则并发下会出现同一用户重复借同一本书的情况。实现上就是在借阅表建立一个联合唯一索引(user_id, book_id, status),其中 status 为 1 表示借阅中,从数据库层面兜底。
评分模块被很多人忽略,但它恰恰是推荐准确度的关键。协同过滤的输入有两种:显式反馈和隐式反馈。显式反馈就是用户主动打的分(1-5 星),隐式反馈就是借阅行为本身(借了就是喜欢,至少是感兴趣)。我在这套系统里把两个信号都用了:有评分用评分,没评分用借阅次数折算成默认分值(借一次算 3.5 分),这样评分矩阵的稀疏度会大幅降低,推荐的覆盖面更广。
4. 推荐算法落地与实时推荐链路
4.1 用户相似度计算的核心逻辑
协同过滤的核心第一步,是把用户的行为数据转成评分矩阵。我用一个 Map 来组织这个矩阵:key 是用户 ID,value 是另一个 Map,记录该用户对所有图书的评分。
矩阵长这样:
用户A -> {图书1: 5.0, 图书2: 3.0, 图书3: 4.0} 用户B -> {图书1: 4.0, 图书2: 2.0, 图书4: 5.0} 用户C -> {图书2: 4.0, 图书3: 5.0, 图书5: 3.0}用户相似度的计算,我用的是皮尔逊相关系数。公式我就不贴了,说人话就是:看两个用户各自对共同评过分的书,打分趋势是否一致。A 觉得好的书 B 也觉得好,A 觉得烂的书 B 也觉得烂,那这两个人的口味就高度一致,系数接近 1。
但这里有个现实问题:两个用户共同评过分的书往往很少,矩阵很稀疏,皮尔逊相关系数在样本量少的时候不稳定。所以我在代码里做了一个修正:共同评分数少于 3 本的用户,直接认为相似度为 0,不参与后续计算,避免噪声干扰推荐结果。
整个计算的复杂度是 O(N²·M),N 是用户数,M 是图书数。对图书管理系统这个规模来说完全能扛住,但如果用户量上万,建议把计算逻辑改成离线批量任务,配合定时更新相似度矩阵,实时接口只做查询。
4.2 推荐列表生成与 TopN 截取
算出目标用户的 K 个最近邻后,推荐列表的生成逻辑就是加权聚合:
对每个邻居用户,遍历他评分过的图书,如果目标用户没借过也没评过这本书,就把这本书加入候选集。候选图书的预测评分是邻居相似度与邻居对该书评分的加权和,相似度越高的用户,对预测分的影响越大。
最后按预测评分从高到低排序,取前 N 本书作为推荐列表返回。N 我通常设成 10,前端首页推荐位正好够展示。如果候选集不足 10 本,就用分类热门补位,保证推荐位永远不空。
这里补充一个工程细节:计算的时候注意做归一化。不同用户打分习惯不一样,有人给分普遍偏高,有人打分保守,直接从原始分加权会有偏差。归一化方式我用的均值中心化,每个用户的评分先减去他自己的平均分再参与计算,这样比较的是“相对偏好”,推荐结果会更稳。
4.3 实时推荐链路的接口与缓存策略
推荐的接口设计是/api/recommend/{userId},前端进入首页时调用,返回推荐图书列表。这个接口内部做了三层数据获取:
第一层查 Redis 缓存,key 设计成recommend:{userId},如果命中就直接返回,不查库。第二层查数据库的推荐结果表,这个表存储上一次离线任务算好的推荐结果。第三层才是实时计算,触发条件包括用户刚完成一次借阅或评分,系统会重新计算该用户的个性化推荐并写回缓存。
说实话,实时计算每一次请求都跑协同过滤是不现实的,所以这套链路的核心思想是:离线批量计算 + 实时增量更新。定时任务每天凌晨跑全量用户推荐;用户行为触发时只重算单个用户。实际测下来,接口响应大部分时间在 50ms 以内,用户体验是感受不到计算过程的。
5. 项目工程结构与关键代码解析
5.1 后端分层架构
后端包的划分是按标准的三层架构走的,controller 层只做参数接收和结果封装,service 层写业务逻辑,mapper 层跟数据库打交道。
com.library ├── controller # 前端接口入口,@RestController ├── service # 业务逻辑层,接口+实现类 ├── mapper # MyBatis 数据访问层 ├── entity # 数据库实体类 ├── dto # 传输对象,避免直接暴露实体 ├── common # 通用返回类、异常处理 ├── config # 配置类,如 JWT 拦截器、跨域配置 └── recommend # 推荐算法核心代码,独立分包推荐算法单独放在 recommend 包下,跟业务代码解耦。内部包含三个核心类:RatingMatrixBuilder负责从数据库加载行为数据构建评分矩阵,UserSimilarityCalculator负责计算相似度,RecommendationEngine负责生成推荐列表。这样做的好处是可以单独对推荐模块做单元测试,不依赖 Spring 容器也能跑。
5.2 前端核心页面与组件拆解
Vue 这边用 Vue CLI 创建的项目,用了 Vue Router 管理页面路由,Element UI 作为组件库。主要页面有首页、图书列表页、图书详情页、个人中心、管理后台等。
首页是用户进来看到的第一个页面,组件结构是这样的:
Home.vue ├── SearchBar # 搜索栏,支持按书名/作者/分类搜索 ├── RecommendSection # 个性化推荐位,核心组件 ├── HotSection # 热门图书榜 └── NewArrivalSection # 新书速递RecommendSection加载数据的时机在 mounted 钩子里,调用推荐接口。这里前端做了一个交互优化:用户登录后马上显示推荐位,未登录则显示登录提示和默认热门推荐列表。推荐位还做了加载占位效果,用 Element UI 的骨架屏组件 Skeleton,避免接口返回前页面看起来空荡荡的。
Vue Router 里有一个细节值得说一下:路由守卫。未登录用户可以访问首页和图书列表,但点击借阅、查看个人推荐时,需要在路由的 meta 字段里标记 requiresAuth,然后在全局前置守卫里检查 localStorage 有没有 token,没有就跳转登录页并带上 redirect 参数,登录成功后回跳原页面,这个交互逻辑虽然简单,但很实用。
5.3 推荐核心代码实现过程
这段代码是项目里最有含金量的部分,我挑核心逻辑拆开讲。
第一步,构建用户评分矩阵:
public Map<Integer, Map<Integer, Double>> buildRatingMatrix() { // 查询所有评分记录 List<Rating> ratings = ratingMapper.selectAll(); // 查询借阅记录,转成隐式评分 List<BorrowRecord> borrowRecords = borrowRecordMapper.selectAll(); Map<Integer, Map<Integer, Double>> matrix = new HashMap<>(); for (Rating rating : ratings) { matrix.computeIfAbsent(rating.getUserId(), k -> new HashMap<>()) .put(rating.getBookId(), rating.getScore()); } for (BorrowRecord record : borrowRecords) { Map<Integer, Double> userRatings = matrix.computeIfAbsent(record.getUserId(), k -> new HashMap<>()); // 隐式反馈,借阅过但没评分的书,给默认分 3.5 userRatings.putIfAbsent(record.getBookId(), 3.5); } return matrix; }第二步,计算用户间相似度,用皮尔逊相关系数:
public double pearsonSimilarity(Map<Integer, Double> user1, Map<Integer, Double> user2) { Set<Integer> commonBooks = new HashSet<>(user1.keySet()); commonBooks.retainAll(user2.keySet()); // 共同评分少于 3 本,相似度直接视为 0 if (commonBooks.size() < 3) { return 0.0; } double sum1 = 0, sum2 = 0, sum1Sq = 0, sum2Sq = 0, pSum = 0; for (int bookId : commonBooks) { double score1 = user1.get(bookId); double score2 = user2.get(bookId); sum1 += score1; sum2 += score2; sum1Sq += score1 * score1; sum2Sq += score2 * score2; pSum += score1 * score2; } int n = commonBooks.size(); double numerator = pSum - (sum1 * sum2 / n); double denominator = Math.sqrt((sum1Sq - sum1 * sum1 / n) * (sum2Sq - sum2 * sum2 / n)); if (denominator == 0) { return 0.0; } return numerator / denominator; }第三步,生成 TopN 推荐列表。先找到目标用户最相似的 K 个邻居,然后聚合候选图书评分,最后按预测分排序。
public List<Integer> recommend(int userId, int topN) { Map<Integer, Map<Integer, Double>> matrix = buildRatingMatrix(); Map<Integer, Double> targetUser = matrix.get(userId); if (targetUser == null || targetUser.isEmpty()) { // 冷启动,返回热门图书 return hotBookMapper.selectTop(topN); } // 1. 计算目标用户与其他所有用户的相似度 Map<Integer, Double> similarities = new HashMap<>(); for (Map.Entry<Integer, Map<Integer, Double>> entry : matrix.entrySet()) { if (entry.getKey() == userId) { continue; } double sim = pearsonSimilarity(targetUser, entry.getValue()); if (sim > 0) { similarities.put(entry.getKey(), sim); } } // 2. 取前 K 个相似用户,K 设 10 List<Map.Entry<Integer, Double>> sorted = similarities.entrySet().stream() .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) .limit(10) .collect(Collectors.toList()); // 3. 加权计算候选图书预测分 Map<Integer, Double> scores = new HashMap<>(); Map<Integer, Double> weights = new HashMap<>(); for (Map.Entry<Integer, Double> entry : sorted) { int neighborId = entry.getKey(); double sim = entry.getValue(); Map<Integer, Double> neighborRatings = matrix.get(neighborId); for (Map.Entry<Integer, Double> ratingEntry : neighborRatings.entrySet()) { int bookId = ratingEntry.getKey(); if (targetUser.containsKey(bookId)) { continue; // 目标用户已读过的跳过 } scores.put(bookId, scores.getOrDefault(bookId, 0.0) + sim * ratingEntry.getValue()); weights.put(bookId, weights.getOrDefault(bookId, 0.0) + sim); } } // 4. 按加权平均分排序,取前 topN List<Integer> result = new ArrayList<>(scores.keySet()); result.sort((b1, b2) -> Double.compare( scores.get(b2) / weights.get(b2), scores.get(b1) / weights.get(b1))); return result.subList(0, Math.min(topN, result.size())); }这段代码设计上有个细节:预测分计算用的是加权算术平均而不是简单累加。累加的话,评分记录多的用户天然占便宜,加权平均能消除这种偏差。代码整体不算复杂,但工程上的坑都在细节里。
5.4 数据持久层:MyBatis 与 MySQL 的协作
MyBatis 在这个项目里的角色就是帮 Java 代码跟 MySQL 打交道。实体类对应表结构,Mapper 接口定义方法,XML 文件写 SQL。
举一个查询的例子,图书分页条件查询:
<select id="selectByCondition" resultType="com.library.entity.Book"> SELECT * FROM book <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> </where> ORDER BY id DESC </select>用<where>标签加<if>动态拼接条件,是 MyBatis 最常用也最实用的能力。比在 Java 代码里手动拼 SQL 字符串安全得多,能有效避免 SQL 注入和拼错引号的低级错误。
索引设计上,我给借阅记录表的(user_id, status)建了联合索引,因为推荐算法的隐式反馈数据要从这个表全量查;图书表的category_id建了普通索引,因为分类筛选是个高频接口。建索引这事看着简单,实际执行计划验证一下很有必要,用EXPLAIN SELECT ...看一下有没有走索引就能确认。
6. 部署流程与快速启动指南
6.1 环境准备与初始化
环境版本我直接给出来:JDK 1.8、Maven 3.6+、Node.js 14+、MySQL 5.7+。SpringBoot 建议用 2.7.x 版本,JDK 8 兼容性好,各种 starter 的坑也少。
数据库初始化:项目提供 book_recommend.sql 脚本,用 Navicat 或者命令行导入即可。脚本里包含了建库、建表、基础数据(分类、示例图书、测试用户),最贴心的是带了一些模拟评分数据,不然推荐算法跑出来是空的,没法演示效果。
后端启动常规三步:修改 application.yml 里的数据库账号密码,执行mvn spring-boot:run或者用 IDEA 直接跑主类。前端启动是npm install装依赖,再npm run serve起开发服务器,Vite 和 Vue CLI 都行,默认端口 8080,后端是 8081,跨域配置已经处理好了。
6.2 部署过程中的坑
Vue 项目打包有个高频经典问题:npm run build之后打开页面一片空白,控制台报资源路径错误。原因是从绝对路径引资源,打包部署到服务器子目录时找不到。解决办法是在 vue.config.js 里把publicPath设为相对路径:
module.exports = { publicPath: './' }这样打包后引用的 CSS、JS 路径就变成了相对路径,部署到任意子目录都能跑。
后端的坑集中在端口和跨域。前端 8080 请求后端 8081,跨域是必然的。我在后端做了一个全局 CORS 配置,允许指定前端地址跨域,这里要注意不要图省事配置成allowedOrigins("*"),因为基础版不支持携带 Cookie,以后要加登录态保持功能的时候会踩坑。
7. 常见问题与排查技巧实录
7.1 MySQL 连接与驱动问题
驱动连接失败,提示Public Key Retrieval is not allowed。这个报错在 MySQL 8.0 连接时会碰到,原因是允许公钥检索的开关默认关闭。解决办法是在 JDBC URL 后面加上参数allowPublicKeyRetrieval=true&useSSL=false。
时区报错,The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。因为 MySQL 8.0 对时区更敏感,URL 里加serverTimezone=Asia/Shanghai就能解决。
7.2 MyBatis 应用中的典型翻车现场
动态 SQL 的 where 条件不生效。常见原因是参数名没对上,用了@Param注解但 XML 里的名字不一致。排查方法是打开 MyBatis 的 SQL 日志,看打印出来的完整 SQL 到底拼了什么条件。日志配置我建议开发阶段必须开:
mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl分页参数混乱。PageHelper 的坑前面提过,必须在查询前立即调用PageHelper.startPage(pageNum, pageSize),如果中间有别的查询语句,分页会被那个语句截胡。一个经验之谈:分页逻辑和服务层业务逻辑分开写,服务里先做查询前的准备,最后一步再查列表。
7.3 推荐结果不准确的调试思路
如果你跑完发现推荐列表乱七八糟,先别怀疑算法错了,按顺序排查三步:
第一步,看评分矩阵对不对。在 RecommendationEngine 的 buildRatingMatrix 方法里打印矩阵,确认用户行为数据有没有正确加载。常见问题是有借阅记录但没评分的用户没有被隐藏到矩阵里,导致新用户被当成冷启动。
第二步,看相似度分布。打印目标用户与其他所有用户的相似度列表,如果全部为 0 或者最高只有 0.1,说明用户行为数据太稀疏。这时候调低“共同评分数量小于 3 视为相似度 0”的阈值,改成 2 试试。
第三步,看候选集覆盖度。如果最终推荐列表长度不够,说明邻居用户的评分记录太窄,可以扩大 K 值,或者把相似度大于 0 的用户全部纳入邻居,不要限制在 10 个。
7.4 Vue 端的布局与依赖问题
打包后布局错乱基本就是两种原因。一是破坏了组件结构,检查是否有标签没有闭合;二是样式污染,组件没有做 scoped 样式隔离,或者全局样式覆盖了组件样式。排查办法是用浏览器开发者工具看元素计算出来的样式,找到是哪条规则在覆盖。
npm install 卡死或者下载缓慢,大概率是默认源的问题,换成国内镜像能缓解:
npm config set registry https://registry.npmmirror.comvue-router 刷新 404,这个在 nginx 部署时很经典。因为 Vue Router 用的 history 模式,刷新某个前端路由时后端没有匹配到对应路径。解决办法是 nginx 配置 try_files 指令:
location / { try_files $uri $uri/ /index.html; }依赖版本冲突,我的建议是锁版本,package.json 里不要用^这种模糊版本号,直接锁定精确版本,避免队友或重新 install 时升级了依赖导致行为异常。
8. 测试方法与数据准备
8.1 接口测试要点
接口测试建议用 Postman,我把核心接口整理成一份测试清单:
| 接口 | 方法 | 测试点 |
|---|---|---|
| /api/user/register | POST | 用户名重复校验、密码加密入库 |
| /api/user/login | POST | 正确密码登录、错误密码提示 |
| /api/book/list | GET | 分页参数、关键词搜索、分类筛选 |
| /api/book/{id} | GET | 详情返回完整性 |
| /api/borrow/add | POST | 库存扣减、重复借阅拦截 |
| /api/borrow/return | POST | 库存回补、记录状态更新 |
| /api/rating/submit | POST | 分数边界值校验 |
| /api/recommend/{userId} | GET | 冷启动兜底、正常推荐 |
每个接口的鉴权逻辑也要测到:带 Token 的请求能过,不带 Token 的请求被拦截返回 401。我专门写了一个拦截器测试,覆盖放行路径(登录注册接口)和鉴权路径(借阅评分推荐接口)。
8.2 推荐效果数据准备
测试推荐效果需要模拟出一个“社区”的感觉。我自己造数据的策略是:准备 10 个测试用户、30 本图书,让用户-图书的评分矩阵覆盖率达到 30% 以上。
具体做法:给用户 A 和用户 B 设置几乎一致的偏好,都喜欢计算机类图书且打分接近,这样 A 的推荐列表里应该出现 B 高分而 A 没读过的计算机书。如果算法正常,B 读过的书排在 A 推荐列表的前列,说明协同过滤链路是通的;如果推荐出来的都是无关分类,就要回头查相似度计算逻辑。
8.3 性能压力测试心得
图书管理系统的 TPS 要求不高,但接口速度直接影响体验。我用 JMeter 做了简单的压测:100 个并发用户持续调用图书列表接口,观察响应时间。
实测结果:不开缓存时列表接口平均响应 300ms 左右,加上推荐位数据后会到 500ms 以上,对管理系统来说勉强能接受。加了首页热点数据的缓存后,响应降到 50ms 以内。这个环节验证了一个重要的经验:推荐接口一定要有缓存层,哪怕是最简单的本地缓存也比每次实时算强。
9. 项目扩展思路
项目做完之后,我一直在想还能怎么往上走。有几个方向我认为是值得做的:
第一个是让推荐算法再“聪明”一步。目前的协同过滤只用到了用户行为数据,下一步可以引入基于物品的协同过滤,用户点了某本书的详情页,立刻给他推荐类似的书,这在“详情页相关推荐”场景里效果更好。更进阶的可以做混合推荐,把协同过滤的结果和基于内容的推荐(图书标题、简介的关键词匹配)按权重融合,冷启动问题也能得到缓解。
第二个是管理后台的可视化。现在后台只是简单的表格展示,可以引入 ECharts 做一个数据看板,把图书借阅热度的趋势、分类偏好分布、推荐点击率可视化出来。推荐系统的运营人员需要看到的不只是“推荐了什么”,还有“用户到底买不买账”,点击率统计和效果追踪是推荐系统完整闭环里不能缺失的一块。
第三个是引入 Redis 做缓存层。前面提到了缓存策略,如果系统用户量上规模,Redis 的分布式缓存是必然选项。JWT Token 无效化、推荐结果的缓存失效策略、热门排行榜的实时更新,这些都是可以落到代码里的优化点。
第四个是借阅流程的精细化。现在的借阅是用户在系统里手动操作,实际图书馆场景里还可能有预约、续借、逾期费用计算等逻辑,这些业务规则的加入会让系统更贴近真实场景。
回到推荐本身。我个人的体会是:推荐算法最难的不是算法实现,而是数据。你用什么数据喂它,它就回报你什么质量的结果。所以做这个项目时我花了不少精力在行为数据的收集和清洗上,评分、借阅、分类权重,每个细节都直接影响最终推荐效果的观感。这大概是这个项目让我受益最多的地方——技术栈是骨架,数据才是血液,理解了这一点,后续做任何数据相关的项目都会更有底。