☰
Spring Boot+ItemCF智能阅读推荐系统:从数据建模到冷启动实战
2026/10/14 21:55:19 网站建设 项目流程

书荒困境与 Spring Boot 智能阅读推荐系统的完整落地

我一直觉得阅读类产品最难的不是内容供给,而是把对的书在对的时候推到对的人面前。前阵子帮一个做阅读产品的小团队整理后台数据,发现书库里明明有上千本质量不错的书,但用户来回看的还是畅销榜上那几十本,很多人打开 App 不到两分钟就退出。问题不是书不够多,而是用户根本不知道自己下一条该读什么。这种场景下,靠人工运营去排书单根本忙不过来,必须要有一套能理解用户口味的推荐机制。

我当时的方案是直接用 Spring Boot 从零搭一个智能阅读推荐系统。这个项目麻雀虽小但五脏俱全,覆盖了用户注册、图书管理、行为采集、个性化推荐、冷启动兜底、离线相似度计算、缓存加速和效果评估这些完整链路。核心推荐算法用的是基于物品的协同过滤(ItemCF),不需要训练深度学习模型,一个小团队完全养得起。前后花了大概两周业余时间,从数据库设计到接口联调全部跑通。这篇文章我会把整个项目拆开讲,包括技术选型背后的逻辑、数据库怎么建模、算法代码怎么写、冷启动怎么做、性能怎么优化、以及实测中踩过的坑。

如果你正准备做阅读类产品、想提升用户阅读时长和留存,又或者刚学完 Spring Boot 想找一个真正有深度的练手项目,这篇笔记应该能给你一套可以直接落地的参考思路。

1. 书荒背后的真实需求:个性化推荐到底要解决什么

1.1 三类典型用户场景拆解

我开始动手前先没有急着写代码,而是把用户场景梳理了一遍。这个系统不是给编辑部的运营人员用的,是给真正在读书的用户用的。所以我列了三类典型用户:

第一类是刚注册的新用户,打开 App 面对几千本书完全无从下手。他要的不是什么深度个性化,而是"我一进来就看到一本我愿意点开的书"。这个需求听起来基础,但很多产品都没做好——把畅销榜往首页一摆,跟书店门口摆摊没区别。

第二类是已经读过十几本书的老用户,口味开始收敛。比如连续读了好几本悬疑推理之后,再推同类容易腻,推太跳的又接不住。他需要的是"顺着已知口味慢慢拓宽边界"的推荐方式。

第三类是轻度用户,一周上一次,行为数据不多但也不是完全空白。推荐系统要能从很稀疏的行为里找出他的偏好信号,同时兼顾新鲜感,不能因为一次点击就把他钉死在某类书里。

这三类场景落到技术上其实是同一个问题:怎么用有限的行为数据预测用户下一本愿意打开的书。图书推荐的难点跟电商还不一样,电商复购周期短,用户行为频繁;图书阅读周期动辄几周,行为稀疏得多,推荐策略不能生搬硬套电商那套热门逻辑。

1.2 功能边界划定:先保证核心链路跑通

需求拆完我给自己划了一个很明确的边界:第一版只做五个核心接口——用户注册登录、图书分类浏览、行为上报、热门榜单、个性化推荐。书单分享、社交评论、签到积分这些先统统不做。原因很朴素,推荐系统项目最容易死在无限膨胀的业务需求里,先把推荐这条主链路跑通,才能真正验证算法有没有价值。

系统模块划分则分成了四层:数据层用 MySQL 存用户、图书、行为三类核心数据,Redis 存相似度矩阵和热门榜单;算法层负责离线计算相似度矩阵、在线生成推荐候选和排序;业务层用 Spring Boot 的 Service 和 Controller 处理登录、图书查询、行为上报和推荐请求;接入层统一走 RESTful API 给前端或客户端调用。

这个分层看起来稀松平常,但有一个关键点我想强调:算法层和业务层一定要解耦。千万不要把相似度计算逻辑直接写在推荐 Service 里,否则后面想换算法会非常痛苦。我的做法是把相似度计算和在线推荐拆成两个物理上独立的模块,前者只负责定时把计算结果写进 Redis,后者只负责从 Redis 读数据算排序,两者之间没有任何直接的方法调用。

2. 技术选型复盘:Spring Boot 3 + ItemCF 的组合逻辑

2.1 为什么整个项目选了 Spring Boot 而不是 Python 或 Node

很多人一听说做推荐系统就默认应该用 Python,毕竟市面上的算法教程全是 Python 写的。但我的项目形态是一个完整的 Web 工程,它不只有算法,还要管用户会话、图书管理、权限控制、接口限流这些活。这些事恰恰是 Spring Boot 的强项。算法部分用 Java 实现协同过滤也不复杂,几百行就能搞定,加上 Spring Boot 自带的定时任务、事务管理、连接池和监控体系,比用 Node 或 Python 从头组装一套 Web 基建要省事太多。

我最终采用的组合是 Spring Boot 3.2.5 + JDK 17 + Maven。Spring Boot 3 基于 Spring Framework 6,默认支持 JDK 17,可以用 record 定义 DTO、用 var 简化局部变量,写起来没有旧版本那种啰嗦感。如果还有人纠结要不要从 Spring Boot 2.x 迁到 3.x,我的建议是新项目直接上 3.x 加 JDK 17,旧版本虽然稳但新特性全都没法用,踩坑还得自己补。

持久层我选了 MyBatis-Plus,没走 Spring Data JPA。原因是行为数据的查询条件非常灵活,经常要按时间范围聚合、按行为类型过滤、按用户去重,写 SQL 比靠 JPA 的方法名派生查询更可控。MyBatis-Plus 的分页插件也很成熟,热门榜单这种分页查询直接就能接上。

2.2 推荐算法选型:ItemCF 在阅读场景的天然优势

阅读场景是一个典型的长尾稀疏场景:一个用户一年高质量阅读可能就几十本,但书库里有几千本。如果选 UserCF(基于用户的协同过滤),每次推荐都要实时计算用户和用户之间的相似度,用户规模一上来性能就很难看。更麻烦的是用户兴趣漂移快——上个月爱看悬疑推理,这个月追言情,UserCF 的相似用户群根本反应不过来。

ItemCF(基于物品的协同过滤)在这两点上都更稳。书和书之间的相似关系变化很慢,《三体》和《球状闪电》的相似度不会因为某个用户的短期口味变化而剧烈波动,所以计算结果可以离线算好存起来,在线推荐只做查询聚合,性能压力小得多。ItemCF 还有个额外好处是解释性强,推荐结果可以顺口说一句"因为你读过《XX》,所以推荐《XX》",这个推荐理由在产品界面上非常有用,用户信任度高。

深度学习模型比如 DeepFM、双塔召回结构我也认真考虑过,但最后还是否决了。原因很现实:这类模型需要大量训练资源、特征工程和样本标注,小体量产品根本喂不饱模型,训练出来的东西还不如 ItemCF 稳定。我的判断标准很简单,先低成本跑通闭环,如果以后用户量真的大到一定程度,再在 ItemCF 之上加一层深度学习排序器做精排,两者并不冲突。

2.3 MySQL 和 Redis 在推荐链路里各管什么

整个系统的存储方案被我把职责切得很清晰。MySQL 只管三张核心表:用户表、图书表、行为表。行为表是推荐系统的原材料,也是数据量增长最快的表,所以索引一定要设计好,否则刷几百万条记录后查询就开始卡。

Redis 干两件事:第一,存图书相似度矩阵,用 Hash 结构,主 key 是book:similarity:图书ID,field 是相似图书的 ID,value 是相似度分数;第二,存热门榜、新品榜这些轻量榜单,用 ZSet 结构,按热度评分排序,每小时刷新一次。

为什么相似度矩阵一定要放 Redis 而不是 MySQL?因为在线推荐时,系统要高频随机读取"某本书的相似邻居列表",Redis Hash 一次 HGETALL 就能把整本书的相似列表全部拉回来,耗时在毫秒级。如果放 MySQL,每次都要走二级索引查几千行再组装,延迟直接高一个数量级。缓存这东西用对场景就是降本增效,用错场景就是给自己多造一个故障点。我还顺手给 Redis 开了一个内存估算:假设有效书池 5000 本,每本书保留 Top 50 个相似邻居,每个 field 大约 16 字节,最终相似矩阵占用的内存大概 25MB 左右,完全在可控范围内。

3. 数据库建模与埋点设计:把推荐系统这块地基打牢

3.1 用户、图书、行为三张核心表的字段设计

推荐系统绕不开的就是那张行为表。我在设计user_behavior表时没有偷懒,字段直接对标埋点需求设计。主键用自增id,业务字段包括user_id、book_id、behavior_type、read_progress、read_duration、source和created_at。其中behavior_type存的是枚举值,包含 VIEW、SEARCH、COLLECT、READ 四种;read_progress存阅读进度百分比,从 0 到 100;source记的是本次行为来源,比如首页推荐位、搜索页、分类页,这个字段平时看着没用,但分析推荐位点击率的时候全靠它。

索引设计是这张表的重中之重。我建了三个索引:联合索引(user_id, created_at)支撑按用户拉取行为时间线,联合索引(book_id, created_at)支撑按图书统计谁读过它,单独给behavior_type建普通索引支撑按行为类型过滤。在百万级行为数据量下,这套索引方案实测查询都能控制在几十毫秒以内,足够支撑第一版业务。

图书表除了基本的书名、作者、分类、封面、简介、字数、出版时间之外,我额外加了两个专为推荐服务的字段:hot_score和avg_rating。hot_score不依赖用户手动打分,而是通过最近时间段的点击、收藏、完整阅读等行为加权计算出的热度值,冷启动阶段直接按这个字段取热门榜。avg_rating是评分均值,作为推荐结果里的辅助信号,让用户对推荐书目的质量有个预判。

3.2 行为数据的采集、去重与权重规则

行为采集的前端链路很简单,客户端在用户完成指定动作时调一个统一上报接口POST /api/behavior/report,上报内容是行为表对应的 JSON。但这里藏着一个非常容易被忽视的坑:重复上报。客户端网络抖动、用户快速连点、前端事件重复绑定,这些情况都会导致同一条行为在几秒内被上报多次。如果不做去重,后面算相似度矩阵的时候,虚假的重复行为会把两本书的相似度拉得虚高。

我做了两层去重防线。第一层在接口层,用 Redis 布隆过滤器记录"用户ID+图书ID+行为类型+时间窗口"是否已经处理过,处理过的直接丢弃。第二层放在离线计算任务里,基于相同user_id、book_id、behavior_type、日期四个维度做聚合去重,只保留一条有效记录。两层方向不同,接口层拦截突发重复上报,离线层清理历史脏数据,配合使用效果最好。

行为权重方面,我按产品直觉定了一套初始规则,以后完全可以基于 AB 实验再来调整。我的设定是:VIEW 点击权重 0.5,SEARCH 搜索权重 2.0,COLLECT 收藏权重 4.0,READ 完整阅读(进度大于等于 95%)权重 6.0,未完结的阅读按进度线性衰减。这套权重决定了推荐模型如何理解"什么行为代表真正的兴趣"——一个反复点击又不读的人,跟一个整本读完的人,肯定不应该享受同样的推荐待遇。

4. 推荐引擎核心实现:从相似度矩阵到在线推荐

4.1 离线相似度计算的完整实现

算法层最核心的离线任务是每天算一次图书相似度矩阵。我直接用 Spring 自带的@Scheduled注解,每天凌晨 2 点触发。这个时间点业务流量最低,不会跟在线接口抢资源。计算流程拆成四步,每一步都对应代码里的一个逻辑块:

第一步,从行为表拉取最近 30 天的有效正反馈行为。正反馈定义为 SEARCH、COLLECT 和完整阅读,单纯的 VIEW 不纳入相似度计算,因为点击太容易造假,噪音太大。

第二步,构建用户到图书的倒排表,也就是一个Map<Long, Set<Long>>,表示每个用户正反馈过哪些书。

第三步,统计两本书之间的共现次数。所谓共现,就是指有多少个用户同时跟这两本书发生过正反馈行为。

第四步,用余弦相似度公式计算最终分数:sim(A,B) = 共现次数(A,B) / sqrt(包含A的用户数 * 包含B的用户数)。这个公式是 ItemCF 的标准做法,分子表示两本书被同一批人看过的次数,分母做归一化,避免热门书天然就跟所有书都相似。

代码我贴了一段核心逻辑,去掉了一部分工程包装,你可以直接照着实现:

@Component public class ItemSimilarityCalculator { @Scheduled(cron = "0 0 2 * * ?") public void runDailyCalculate() { // 1. 拉取最近30天的正反馈行为 List<UserBehavior> behaviors = behaviorRepository.findRecentPositiveBehaviors(30); if (behaviors.isEmpty()) { log.info("无有效行为数据,跳过本次相似度计算"); return; } // 2. 构建 用户 -> 图书集合 的倒排表 Map<Long, Set<Long>> userBooks = behaviors.stream() .collect(Collectors.groupingBy( UserBehavior::getUserId, Collectors.mapping(UserBehavior::getBookId, Collectors.toSet()) )); // 3. 统计每本书被多少个用户正反馈过 Map<Long, Integer> itemCounts = new HashMap<>(); userBooks.values().forEach(bookIds -> bookIds.forEach(bookId -> itemCounts.merge(bookId, 1, Integer::sum))); // 4. 统计两两共现次数 Map<Long, Map<Long, Integer>> cooccurrence = new HashMap<>(); userBooks.values().forEach(bookIds -> { List<Long> ids = new ArrayList<>(bookIds); for (int i = 0; i < ids.size(); i++) { for (int j = i + 1; j < ids.size(); j++) { cooccurrence.computeIfAbsent(ids.get(i), k -> new HashMap<>()) .merge(ids.get(j), 1, Integer::sum); cooccurrence.computeIfAbsent(ids.get(j), k -> new HashMap<>()) .merge(ids.get(i), 1, Integer::sum); } } }); // 5. 计算余弦相似度,每本书只保留 Top 50 个相似邻居 cooccurrence.forEach((bookId, related) -> { Map<Long, Double> similarity = new HashMap<>(); related.forEach((otherId, count) -> { double sim = count / Math.sqrt( itemCounts.get(bookId) * itemCounts.get(otherId)); similarity.put(otherId, sim); }); List<Map.Entry<Long, Double>> topEntries = similarity.entrySet().stream() .sorted(Map.Entry.<Long, Double>comparingByValue().reversed()) .limit(50) .collect(Collectors.toList()); redisTemplate.opsForHash().putAll( "book:similarity:" + bookId, topEntries.stream().collect(Collectors.toMap( e -> String.valueOf(e.getKey()), e -> String.valueOf(e.getValue())) )); }); log.info("相似度矩阵计算完成,共 {} 本图书参与计算", cooccurrence.size()); } }

这里有一个很少有人强调的工程细节:必须做 Top K 截断,只保留每本书相似度最高的 50 个邻居。如果不截断,一本热门书可能跟几千本书都有关联,相似度矩阵会膨胀到无法控制,在线推荐时也会把大量低质量候选拉进排序列表,既浪费计算又稀释推荐质量。加上截断之后,Redis 内存可控,在线查询压力也小得多。

4.2 在线推荐:候选生成与加权排序

离线算好相似度矩阵,在线推荐的处理就变得很清爽。整个流程从接口收到userId开始,分四步走:

第一步,从行为表里查这个用户最近一段时间有正反馈的书,最多取 20 本。为什么要限 20 本?因为用户最近读的 20 本书已经能充分代表近期兴趣,再拉更多,数据量大了计算耗时也会上来,收益却很小。

第二步,遍历这 20 本书,从 Redis 里分别取出各自的相似书列表。

第三步,对候选书打分。候选书 j 的得分等于所有用户读过的书 i 的行为权重与相似度sim(i,j)的乘积之和。这个公式的含义是:用户越喜欢一本书,且这本书跟候选书越像,候选书得分就越高。

第四步,过滤掉用户已经读过的书,按得分降序取前 topN 返回。

代码核心部分如下:

@Service public class RecommendService { public List<BookVO> recommendForUser(Long userId, int topN) { // 1. 获取用户最近的正反馈行为 List<UserBehavior> positiveBehaviors = behaviorRepository .findRecentPositiveByUserId(userId, 20); if (positiveBehaviors.isEmpty()) { return getColdStartBooks(userId, topN); } // 2. 聚合候选得分 Map<Long, Double> candidateScores = new HashMap<>(); for (UserBehavior behavior : positiveBehaviors) { Map<Long, Double> similarBooks = getSimilarityMap(behavior.getBookId()); double weight = behaviorWeight(behavior); similarBooks.forEach((bookId, similarity) -> { if (!hasPositiveFeedback(userId, bookId)) { candidateScores.merge(bookId, weight * similarity, Double::sum); } }); } // 3. 排序取 topN List<Long> recommendIds = candidateScores.entrySet().stream() .sorted(Map.Entry.<Long, Double>comparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); return bookRepository.findByBatchId(recommendIds); } private Map<Long, Double> getSimilarityMap(Long bookId) { Map<Object, Object> entries = redisTemplate.opsForHash() .entries("book:similarity:" + bookId); Map<Long, Double> result = new HashMap<>(); entries.forEach((key, value) -> result.put(Long.valueOf(key.toString()), Double.parseDouble(value.toString()))); return result; } private double behaviorWeight(UserBehavior behavior) { switch (behavior.getBehaviorType()) { case COLLECT: return 4.0; case READ: return behavior.getReadProgress() >= 95 ? 6.0 : 1.0 + behavior.getReadProgress() / 25.0; case SEARCH: return 2.0; default: return 0.5; } } }

在线推荐这段逻辑,看上去简单,但它是整个系统吞吐量的核心。因为所有数据都从 Redis 读,计算都在内存里跑,单次推荐请求的耗时能稳稳压在 30 毫秒内。这也再次证明了一开始选型时的判断:ItemCF 的结构决定了它天然适合做"离线计算 + 在线查询"这种高性能架构。

4.3 定时任务的线程隔离与缓存原子切换

定时任务这块有个坑必须单独拎出来说。@Scheduled默认的调度线程池只有 1 个线程,如果任务在计算过程中阻塞了,其他所有定时任务都会排队等待。我第一次上线时,下午高峰时段用户侧接口出现了明显卡顿,排查到最后发现就是定时任务占着唯一一个调度线程做全量计算。后来我把计算逻辑抽出来用独立线程池去跑,调度方法只负责提交任务,彻底解决了线程抢占问题。

另一个容易被忽略的细节是 Redis 写入时的原子性。相似度矩阵是整个批量覆盖的,如果计算过程中把中间状态直接写进线上 key,在线请求就可能读到半截数据,导致某本书的相似邻居列表不完整。我的处理方式是先写入临时 key,等整份矩阵全部算完,再执行rename做原子切换;或者在 key 里带上版本号,线上读取时固定读最新版本。这种方式虽然多一步,但能保证数据一致性,值得养成习惯。

5. 接口层设计与冷启动实战策略

5.1 三个核心接口的定义与实现

接口我刻意控制在三个,避免第一版就接口满天飞。第一个是行为上报接口,第二个是个性化推荐接口,第三个是热门榜接口。所有接口统一返回一个Result包装类,包含code、msg、data三个字段,错误码收敛到一个枚举里,前端联调时只要对一次文档就能记住。

@RestController @RequestMapping("/api") public class RecommendController { @PostMapping("/behavior/report") public Result<Void> report(@RequestBody BehaviorReportRequest request) { behaviorService.report(request); return Result.ok(); } @GetMapping("/recommend/{userId}") public Result<List<BookVO>> recommend(@PathVariable Long userId, @RequestParam(defaultValue = "10") int topN) { return Result.ok(recommendService.recommendForUser(userId, topN)); } @GetMapping("/books/hot") public Result<PageResult<BookVO>> hotBooks( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return Result.ok(bookService.hotBooks(page, size)); } }

行为上报接口要做两件事:一是校验参数合法性,比如用户 ID、图书 ID、行为类型必须存在,阅读进度不能超过 100;二是做前面提到的布隆过滤器去重,这是防止脏数据进入推荐链路的关键关卡。推荐接口的topN参数默认给 10,这个值可以根据产品需求调,但最好不要超过 30,否则推荐列表太长,用户根本看不完,转化率反而下降。

5.2 新用户与新上架图书:两条不同的冷启动路径

冷启动是推荐系统绕不开的话题。我把冷启动分成两个方向分别处理。

新用户的冷启动,最直接有效的办法就是在注册流程里让用户勾选两三个感兴趣的图书分类。有了分类标签之后,第一轮推荐就可以按这些分类做混排,完全不需要等用户产生行为。同时我会在推荐结果里混入一定比例的热门榜和新书榜内容,保持新鲜感,让用户感觉系统推荐的不是清一色同类书。

新上架图书的冷启动麻烦一点,因为它没有行为数据,ItemCF 算不出相似度。我做了一个内容层面的规则近似:同作者、同分类、同标签的新书,默认给它一个基础相似度分数 0.1。这个分数不高,不会冲击正常推荐结果,但能让新书有机会出现在关联推荐位。等这本书积累够行为数据之后,离线任务会自动把它纳入真实的相似度矩阵,规则近似会被覆盖掉。

除此之外我还做了多样性限制:最终 topN 推荐结果里,同一分类的书最多占 50%。不做这个限制,就会出现用户爱看悬疑,推荐列表前八本全是悬疑的情况,虽然精准但容易让用户产生阅读疲劳。推荐系统不能永远追着用户已知的兴趣跑,适度探索是拉住用户的重要手段。

6. 推荐效果验证与性能调优实测

6.1 离线数据上的推荐质量评估

推荐做得好不好,不能光靠感觉。我采用了一套最朴素的离线评估方案:把每个用户的行为序列按时间切成 80% 和 20% 两段,前 80% 作为已知历史输入推荐系统,后 20% 作为待预测的"标准答案",然后看推荐结果中有多少本书出现在后 20% 里。指标用三个:精确率@10、召回率@10、覆盖率。

我构造了一批模拟行为数据来做测试,规模是 300 个用户、3 万条行为记录、覆盖 800 本书。测试结果如下表所示:

指标数值说明
Precision@100.16推荐 10 本里平均有 1.6 本是用户真实行为的书
Recall@100.24用户后 20% 真实行为中有 24% 被推荐系统命中
Coverage37%推荐池覆盖了全部图书的 37%,多样性尚可

这个数据谈不上惊艳,但要知道它是在没有深度学习模型、没有精心调参的情况下跑出来的。业务侧的体感是,用户收到的推荐比单纯畅销榜更贴合个人口味。我特别提醒一句:推荐系统的评估不能只看精确率和召回率,覆盖率低说明系统永远在推同一批热门书,长尾图书会彻底失去曝光,这对内容型产品是慢性死亡。

6.2 三个性能瓶颈与对应优化

第一个瓶颈是离线计算量的爆炸。如果不做任何限制,让任意两本书都算一遍相似度,10 万本书就是 10 的 10 次方量级的组合,单机根本跑不完。我的优化策略是只对"有效书池"做计算,也就是近 30 天有正反馈行为的书,这一步通常能砍掉 70% 以上的无效计算。再加上每本书只保留 Top 50 个相似邻居,计算量被压到了一个单机完全能承受的范围。

第二个瓶颈是 Redis 内存增长。相似度矩阵如果不做邻居截断,热门书关联的 key 数量会到百万级,内存很快爆掉。限制 Top 50 邻居后,内存占用大约是完整矩阵的十分之一,这个取舍非常划算。

第三个瓶颈是在线查询延迟。第一版直接串行遍历用户读过的 20 本书,逐个从 Redis 取相似列表,并发一上来延迟就开始飘。我的优化方案是在本地加了一层 Caffeine 缓存,热门书的相似列表直接命中本地,Redis 查询压力大幅下降。实测优化前后推荐接口的 P99 延迟从 210 毫秒降到 30 毫秒左右。推荐系统不能只追求算法准确率,性能决定了它能不能在大流量下活下去,这两个指标都要抓。

7. 踩坑记录与后续扩展方向

7.1 三个值得记录的实战坑

这个项目做下来,最深刻的三个坑值得分享。

第一个坑是行为数据重复导致相似度虚高。前面虽然设计了布隆过滤和离线去重,但最初版本没有严格执行,结果某本热门书几乎跟所有书都算出了高相似度,推荐列表被这本书的关联书霸榜。真正排查下来才发现是刷点击的行为没有被过滤掉。后来接口层布隆过滤和离线聚合去重双管齐下才彻底解决。这类问题最隐蔽,因为它不会让程序报错,只会悄悄污染算法质量。

第二个坑是定时任务阻塞核心线程。前面提到过@Scheduled默认单线程的问题,当时的表现是下午高峰用户侧接口卡顿,日志里发现很多请求排队等待调度线程。排查过程花了大半天,最终解决方案是独立线程池执行计算任务,调度方法只负责提交。这个坑在小项目里非常典型,因为本地开发时很难压出并发问题,一上线就暴露。

第三个坑是 Redis 序列化方式不统一。Hash 的 field 和 value 在写入时用了一套序列化器,读取时又换了另一套,导致部分 key 解析失败。后来全链路统一使用 String 序列化,杜绝了JdkSerializationRedisSerializer和StringRedisSerializer混用的问题。这种问题在本地测试时很难触发,因为同一个 JVM 环境序列化一致,跨环境部署就现形了。

7.2 推荐系统后续可以考虑的扩展方向

整个项目跑通之后,我觉得后续有四个方向值得按优先级推进。

第一是引入图数据库。图书、作者、读者、标签之间存在非常丰富的网状关系,用关系型数据库表达很别扭,换成图数据库之后可以做多跳关联推荐,比如"读 A 的人也读 B,读 B 的人还喜欢作者 C 的书"这种深度链路。

第二是接入大模型做内容标签提取。现在新书冷启动全靠人工规则给一个基础相似度,如果引入大模型自动提取书籍摘要、主题、情感倾向,新书在上架当天就能获得高质量内容画像,冷启动时间会大幅缩短。

第三是在 ItemCF 之上叠加排序模型。ItemCF 负责从几万本书里快速筛出几百个候选,再用简单的逻辑回归或者 XGBoost 对候选做精排,融合更多特征比如图书评分、出版时间、用户阅读时长,推荐精度还能上一个台阶。

第四是搭一个简易的 AB 实验平台。推荐策略改动不能拍脑袋上线,先小流量验证指标,再逐步放量,这是推荐系统进入生产环境后必须补上的一环。

这个项目从需求梳理到完全跑通,前后大概花了两周,代码我整理得比较干净,依赖和部署说明都写在 README 里了。如果你在准备做阅读类产品,或者想拿 Spring Boot 练手推荐系统,欢迎私信找我拿完整源码参考。最后再分享一条我最大的心得体会:推荐算法的核心不在于模型多么炫酷,而在于你对用户行为数据的理解有多深。数据干净、权重合理、链路顺畅,哪怕只是最朴素的协同过滤,也能让用户明显感觉到"这个 App 懂我"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询