ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Spring Boot的音乐推荐系统实战:ItemCF协同过滤与工程落地全解析

基于Spring Boot的音乐推荐系统实战:ItemCF协同过滤与工程落地全解析 做这个基于 Spring Boot 的音乐推荐系统起因其实很直接——我平时听歌就是那几首来回循环平台推的又不合胃口就想着不如自己动手做一个能按自己口味推荐的系统。从数据模型到协同过滤算法再到 Spring Boot 后端落地整体做完之后一个很深的感受是这类系统的难点根本不在“推荐算法有多高级”而在于怎么把算法、数据和工程细节捏到一块。这篇文章会把整个项目的设计思路、数据库建模、推荐算法实现、Spring Boot 集成过程以及上线后踩过的坑完整复盘一遍。适合两类人看一是准备做基于 Spring Boot 推荐类毕设的在校生二是在搞内容推荐小系统、想参考实现思路的后端开发者。看完你至少能知道这套系统从 0 到 1 应该先做什么、后做什么哪些地方容易翻车。1. 系统整体设计推荐不是算法堆砌而是工程取舍1.1 业务需求拆解与推荐场景定义开始写代码之前我先花了不少时间想清楚一件事这个音乐推荐系统到底要解决什么问题。从用户视角看最常见的诉求是三类打开首页能刷到“猜你喜欢”点开某首歌能看到“相似歌曲”新用户注册后不至于面对一片空白能快速建立初步偏好。这三类诉求对应到系统能力上分别是首页推荐流、相似歌曲推荐、热门榜与标签偏好召回。很多同学做类似项目时容易上来就找算法论文想直接上矩阵分解、深度学习模型。我的建议是不要贪大求全先把这三个场景跑通再考虑要不要引入更重的模型。还有一个前期需要敲定的事情是推荐实时性。音乐场景下用户行为是持续产生的但你不需要做到像短视频平台那样秒级反馈。用户今天听完几首歌明天打开软件能拿到更新过的推荐这个频率就完全够用了。所以我的设计定调是“离线预计算 在线拼接”既保证推荐质量又不给服务器增加太大压力。1.2 技术选型为什么是 Spring Boot MyBatis-Plus Redis后端框架选 Spring Boot理由是明摆着的自动装配省掉大量 XML 配置起步依赖按需引入内置 Tomcat 让我能直接把项目打成 jar 包扔服务器上跑。更重要的是社区生态足够庞大遇到问题几乎都能搜到解决方案对于做毕设或者中小型项目来说这是巨大的隐性优势。数据访问层我用的 MyBatis-Plus 而不是原生 MyBatis。这个系统里的单表操作非常多比如写入行为记录、分页查询歌曲列表、更新歌曲播放数MyBatis-Plus 的 BaseMapper 直接提供了现成 CRUDlambda 条件构造器也能覆盖大部分查询需求。至于 JPA写起来虽然更省事但复杂查询和 SQL 调优就不太好掌握MyBatis-Plus 算是折中方案。缓存中间件选的 Redis主要存推荐结果、热门榜、相似歌曲列表这类热点数据。这些数据有几个共同特点读取频率极高、实时性要求不高、计算结果比较重。如果不加缓存每次请求都去数据库查相似度表或者跑一遍推荐逻辑数据库很容易被打爆。另外我还考虑了消息队列想着用户行为上报后异步处理但项目早期流量不大用 Spring 的 Async 就能解决就先没引入 MQ避免架构过度设计。组件选型理由后端框架Spring Boot 2.7.x稳定、生态成熟、内置 Tomcat数据访问MyBatis-Plus单表 CRUD 效率高条件构造器好用数据库MySQL 8.0主流关系型数据库InnoDB 支持事务缓存Redis读取快适合存推荐结果和热点数据鉴权JWT无状态适合前后端分离架构前端Vue 3 Element Plus组件化开发快后台管理界面方便搭建1.3 推荐链路总览离线预计算加上在线实时拼接整个推荐流程我把它拆成离线和在线两条链路。离线链路每天凌晨通过定时任务跑相似度计算把结果写入 song_similarity 表同时把热门榜刷新到 Redis。在线链路则是用户请求推荐接口时先从 Redis 读缓存缓存没命中就去数据库拼装推荐列表再写回缓存并返回给前端。为什么采用离线加在线结合而不是纯实时原因很简单相似度计算需要遍历海量用户行为数据实时算一次代价太高。而且用户听歌习惯是慢变的一天更新一次相似关系已经很充足了。在线部分只需要做查表和排序耗时控制在几十毫秒级别用户体验也很好。离线链路行为数据 → 凌晨定时任务 → 相似度计算 → 写入 song_similarity 表 → 刷新 Redis 缓存 在线链路用户请求 → 查 Redis 缓存 → 未命中则查相似度表拼接 → 写入缓存 → 返回前端这个链路设计听起来不复杂但实际工程化时要考虑很多细节比如定时任务执行期间用户的新行为要不要纳入、缓存什么时候失效、相似度表数据量膨胀如何处理。这些坑我在后面章节详细展开。2. 数据模型设计音乐推荐的地基2.1 用户、歌曲、行为三大核心表数据库设计是整个推荐系统里最不能偷懒的部分。要是表结构一开始设计歪了后面写推荐算法的时候处处别扭。我把核心表分成三块用户表、歌曲信息表、用户行为表外加一张算法相关的关系表就是物品相似度表。用户表字段比较常规除了基础登录信息我特意加了一个 preferred_genres 字段存用户注册时选择的音乐风格用逗号分隔的字符串这个字段在冷启动阶段能派上大用场。歌曲表里除了歌名、歌手、专辑、时长之外还有 genre 类型、tags 标签、cover_url 封面地址和 audio_url 音频地址。如果自己做音频文件管理上传的文件可以放在本地磁盘或者对象存储里其实很多同学会把音频文件直接放 MinIO 之类的对象存储用外链访问Spring Boot 里集成也很方便。行为表是推荐算法的原材料来源也是整个系统里数据量最大的表。我用一个表统一记录播放、收藏、评分三类行为通过 behavior_type 字段区分而不是拆成三张表这样统计和后续计算都简单。CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL COMMENT BCrypt 加密存储, nickname varchar(50) DEFAULT NULL, preferred_genres varchar(200) DEFAULT NULL COMMENT 用户选择的曲风逗号分隔, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE song ( id bigint NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL, artist varchar(100) NOT NULL, album varchar(100) DEFAULT NULL, duration int NOT NULL COMMENT 时长秒, genre varchar(50) DEFAULT NULL COMMENT 曲风, tags varchar(200) DEFAULT NULL COMMENT 标签逗号分隔, cover_url varchar(255) DEFAULT NULL, audio_url varchar(255) DEFAULT NULL, play_count bigint DEFAULT 0 COMMENT 累计播放次数, create_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE user_behavior ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, song_id bigint NOT NULL, behavior_type tinyint NOT NULL COMMENT 1-播放 2-收藏 3-评分, rating decimal(2,1) DEFAULT NULL COMMENT 评分值播放和收藏行为为 NULL, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_user_song (user_id, song_id), KEY idx_song (song_id), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;行为表上的索引非常关键。idx_user_song 用于查询某个用户的历史行为idx_song 用于统计歌曲被哪些用户行为过idx_create_time 用于定时任务按时间窗口捞数据。字段设计上rating 字段只对评分行为有意义播放和收藏行为置空即可不额外拆表因为拆分会让聚合查询变得异常啰嗦。2.2 行为权重播放、收藏、评分怎么换算成偏好分数推荐算法输入的是“用户对物品的评分矩阵”但原始行为数据只有播放、收藏、评分这三类不能直接用。我需要先把行为换算成分数。这个换算在设计上有很多讲究权重定得合理不合理直接决定相似度计算的质量。我采用的换算规则是播放按次数累加每次有效播放记 1 分收藏记 3 分评分直接采用 1 到 5 分的原值。如果一个用户对同一首歌有多次行为最终得分取加权求和。注意“有效播放”这个概念很重要用户在歌曲页面停留两三秒就切走不算有效播放。所以我在上报播放行为时会带一个 play_duration 参数只有播放时长超过歌曲总时长 30% 才记为有效这个细节防止了恶意刷量行为污染特征数据。为什么要这样分配权重因为行为强度不同。播放可能只是随手一划收藏说明用户明确想留到以后听评分更直接地表达了喜欢程度。如果把播放和收藏等同看待推荐结果会偏向那些被顺手播放但用户并不喜欢的口水歌。实测下来这个权重比例效果还可以如果你有精力调参也可以把播放权重改成 2、收藏改成 5 之类的比例看看线上指标变化。// 行为分数换算逻辑示例 public double calculateUserItemScore(ListUserBehavior behaviors) { double score 0.0; for (UserBehavior behavior : behaviors) { if (behavior.getBehaviorType() 1) { score behavior.getPlayCount() * 1.0; } else if (behavior.getBehaviorType() 2) { score 3.0; } else if (behavior.getBehaviorType() 3) { score behavior.getRating() null ? 0 : behavior.getRating(); } } return score; }2.3 相似度结果表预计算才是性能的关键很多第一次接触推荐系统的同学会问一个问题相似度为什么不实时算而是要先算好存表里原因在上面已经说了一部分这里再补充一个关键点物品相似度表可以复用。同一首歌的相似歌曲在一天之内对每个用户来说都是一样的没必要为每个请求重复计算。把它预计算好写入数据库在线推荐时只需要按 song_id 查表取相似度最高的前 N 首再做一次排序合并即可。相似度表的字段比较简洁song_id 表示原歌曲similar_song_id 表示相似歌曲similarity_score 存相似度数值主键自增并在 song_id 上建索引用于快速查询。这里有个设计要点表里不要存全量相似关系。假设系统有一万首歌全量两两相似就会有接近五千万条记录表根本扛不住。我采用的策略是每首歌只保留相似度最高的前 20 条记录这样一张表的规模控制在“歌曲数乘以 20”对数据库非常友好查询速度也快。CREATE TABLE song_similarity ( id bigint NOT NULL AUTO_INCREMENT, song_id bigint NOT NULL, similar_song_id bigint NOT NULL, similarity_score double NOT NULL, create_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_song_id (song_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;至于相似度表什么时候更新我在设计里放在凌晨定时任务执行原因很简单这个时间段用户活跃度最低数据库负载也最小计算过程中产生的临时数据对线上影响降到最低。定时任务执行时我会按歌曲维度分批计算算完一批写入一批避免一次性构建全量数据导致内存和数据库压力过大。这个过程我用的是类似快照替换的思路——先写到临时表全部算完后再 rename 切换保证线上查询不会读到半新半旧的数据。3. 推荐算法实现从公式到可运行代码3.1 为什么选 ItemCF 而不是 UserCF协同过滤是推荐系统最经典的算法分两类基于用户的协同过滤 UserCF 和基于物品的协同过滤 ItemCF。我一开始两种方案都调研过也简单做了对比试验最后坚定选了 ItemCF 作为主力算法。UserCF 的核心思想是“人以群分”找到和你听歌偏好相似的一批用户把他们喜欢而你没听过的歌推荐给你。听起来很合理但在音乐场景下有个很致命的问题用户听歌行为高度稀疏。假设系统有一万首歌一个普通用户顶多听过其中几十首两个用户之间的共同听歌记录可能少得可怜算出来的相似度可信度很低。而且用户数量增长后实时计算用户相似度开销很大在线响应很难做快。ItemCF 的核心思想是“物以类聚”和你喜欢的歌相似的其他歌曲也可能符合你口味。为什么在音乐场景下更好用歌曲数量虽然多但歌曲的特征相对稳定被同一批用户行为过就能说明相似性。比如喜欢周杰伦《七里香》的人大概率也听过《夜曲》这两首歌的相似度就能通过大量用户行为算出来。ItemCF 的计算结果还能解释推荐理由——“因为你喜欢《七里香》推荐你听《夜曲》”这个文案对用户来说也非常友好。基于用户和基于物品的算法各有适用场景选型时要先看业务数据特征。像电商网站用户购买意愿强、行为相对集中UserCF 也有自己的优势但音乐场景下ItemCF 在准确率、稳定性和可解释性上都更合适。我的结论是中小型音乐推荐系统默认先从 ItemCF 做起不要一上来就挑战复杂模型。3.2 余弦相似度计算与 TopN 截断核心代码ItemCF 里最关键的一步就是计算歌曲之间的相似度。我用的度量方式是余弦相似度公式看起来吓人本质上是把两首歌看作两个向量向量里的每个维度是一个用户对它的偏好分数然后用两个向量之间夹角的余弦值来衡量它们方向的接近程度。夹角越小余弦值越接近 1说明两首歌被同一批用户喜欢的程度越接近。公式如下其中 A 和 B 是两首歌的用户评分向量分子的内积就是“同时喜欢这两首歌的用户们对它们的分数乘积再求和”分母是两个向量的模长相乘similarity(A, B) (A · B) / (|A| × |B|)放在工程里实现时首先要把 user_behavior 表里的行为记录汇总成“每首歌被哪些用户以多少分数消费”的倒排结构。我用 Map 嵌套 Map 表示外层 key 是歌曲 ID内层 key 是用户 IDvalue 是前面算好的行为分。然后遍历所有歌曲对找到共同消费过两首歌的用户集合套余弦公式计算相似度。public MapLong, MapLong, Double buildSongUserScoreMap() { ListUserBehavior behaviors behaviorMapper.selectList(null); MapLong, MapLong, Double songUserScoreMap new HashMap(); MapLong, MapLong, Integer playCountMap new HashMap(); for (UserBehavior behavior : behaviors) { Long songId behavior.getSongId(); Long userId behavior.getUserId(); songUserScoreMap.computeIfAbsent(songId, k - new HashMap()); playCountMap.computeIfAbsent(songId, k - new HashMap()); // 播放行为累加次数收藏计 3 分评分取原值 if (behavior.getBehaviorType() 1) { playCountMap.get(songId).merge(userId, 1, Integer::sum); } else if (behavior.getBehaviorType() 2) { songUserScoreMap.get(songId).merge(userId, 3.0, Double::sum); } else if (behavior.getBehaviorType() 3) { double rating behavior.getRating() null ? 0 : behavior.getRating(); songUserScoreMap.get(songId).merge(userId, rating, Double::sum); } } // 把播放次数的分加进去完成最终分数 playCountMap.forEach((songId, userPlayMap) - userPlayMap.forEach((userId, count) - songUserScoreMap.get(songId).merge(userId, count * 1.0, Double::sum) ) ); return songUserScoreMap; }拿到分数矩阵后相似度计算的核心逻辑如下。注意我加了一个重要的剪枝策略如果两个歌曲共同被用户行为过的数量少于 3直接跳过这对计算。这能把计算量降低一个数量级同时过滤掉低质量相似关系。public void calculateAndSaveSimilarity(MapLong, MapLong, Double songUserScoreMap) { ListLong songIds new ArrayList(songUserScoreMap.keySet()); double threshold 3; // 外层循环每首歌只取前 20 个相似结果 for (int i 0; i songIds.size(); i) { Long songA songIds.get(i); MapLong, Double scoreA songUserScoreMap.get(songA); MapLong, Double simMap new HashMap(); for (int j i 1; j songIds.size(); j) { Long songB songIds.get(j); MapLong, Double scoreB songUserScoreMap.get(songB); SetLong commonUsers new HashSet(scoreA.keySet()); commonUsers.retainAll(scoreB.keySet()); if (commonUsers.size() threshold) { continue; } double dotProduct 0.0; double normA 0.0; double normB 0.0; for (Long userId : commonUsers) { double scoreAU scoreA.getOrDefault(userId, 0.0); double scoreBU scoreB.getOrDefault(userId, 0.0); dotProduct scoreAU * scoreBU; normA scoreAU * scoreAU; normB scoreBU * scoreBU; } if (normA 0 || normB 0) { continue; } double similarity dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); if (similarity 0.05) { simMap.put(songB, similarity); } } // 按相似度排序保留 Top 20写入数据库 ListMap.EntryLong, Double sorted simMap.entrySet().stream() .sorted((e1, e2) - Double.compare(e2.getValue(), e1.getValue())) .limit(20) .collect(Collectors.toList()); saveBatchSimilarity(songA, sorted); } }这段代码看起来还算干净但实际跑起来会遇到一个问题外层套内层循环歌曲数量两三千的时候还好到上万之后计算量就是千万级甚至亿级内存和耗时都扛不住。解决办法我放在后面“上线踩坑”部分细聊这里提示一下思路分片计算或者先根据歌曲的热门程度缩小候选集合。3.3 冷启动兜底新用户也能有推荐内容新用户没有任何行为数据ItemCF 算不出来推荐接口如果直接返回空列表用户体验会非常差。这就是推荐系统里的冷启动问题。我的处理方式是做一套完整的兜底策略分三个层级递进。第一层是热门榜兜底。系统里每首歌都有累计播放次数取播放次数最高的 50 首作为全局热门榜新用户打开首页先看到这个列表。这是最简单也最稳妥的方案虽然不够个性化但至少用户不会面对空白页面。热门榜的读取已经做了缓存响应速度很快。第二层是注册时让用户选择偏好曲风。我在用户注册页面加了一个“选择你喜欢的音乐风格”步骤选项包括流行、摇滚、民谣、电子、说唱等常见类型。用户选择后服务端根据 preferred_genres 字段去歌曲表中做标签匹配按曲风召回一批歌曲再按播放次数排序返回。这一步让新用户第一次打开推荐页就能看到和自己兴趣方向相关的歌曲个性化体验瞬间提升一个档次。第三层是歌曲内容特征补充。如果用户连偏好都没选系统会获取用户的 IP 属地结合当前时间段做一个粗略的启发式推荐。比如晚上十点之后的用户多少会喜欢听一些舒缓的歌这个逻辑不需要太严谨只是保证推荐列表不是冷冰冰的热门歌单而已。3.4 结果融合与多样性控制把相似歌曲推荐和热门推荐简单合并时会出现一个现象推荐列表被热门歌霸占。因为相似度高的歌曲集中在少数头部热门歌上用户点开推荐页总是看到听过无数遍的老歌。所以我做推荐结果融合时特意加入了几道工序。第一步是候选池混合。用户请求推荐时我从三个渠道各取一批候选用户行为过的歌曲对应的相似歌曲、当前热门榜、用户偏好曲风下的新歌。相似歌曲占大部分权重热门榜和新歌作为补充。第二步是得分加权。最终排序得分不能只看相似度我把歌曲热度也纳入计算finalScore 0.7 * 相似度得分 0.3 * 热度归一化得分。这样即使某首歌相似度略低只要近期热度高也有机会浮上来推荐列表不至于全部是冷门歌曲。第三步是去重和干扰。用户已经听过的歌直接过滤掉防止推荐用户早就消费过的内容。同时我会对最终排序列表做一个小范围的随机扰动比如前 20 名里随机交换几个相邻元素的位置。这样做的好处很明显同一个用户连续两次刷新推荐页看到的不会是完全一样的顺序多了一些探索性。这样一个混排流程跑下来推荐结果既保持了 ItemCF 的准确性又兼顾了热门歌曲的流量和内容多样性。实测用户反馈里至少不会出现“怎么推荐全是周杰伦”的观感。4. Spring Boot 工程实现与关键代码4.1 项目搭建依赖、版本、Banner 小细节项目骨架我这里推荐用 Spring Initializr 生成IDEA 里操作很方便。版本选择上我建议用 Spring Boot 2.7.18很多同学一上来就追新用 3.x结果依赖兼容问题折腾一整天。如果用 3.xjavax.servlet 换成了 jakarta.servletMyBatis-Plus 得换成 mybatis-plus-spring-boot3-starterswagger 那一套注解兼容也有问题。2.7.18 是目前兼容性最稳定的版本做毕设和中小项目够用了。创建项目后引入核心依赖web、validation、mybatis-plus-boot-starter、redis、jjwt、mysql-connector-java、lombok。下面是我 pom.xml 里比较核心的部分。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies配置方面注意把连接池、Redis 连接参数、JWT 密钥这些都放到 application.yml 里统一管理。Spring Boot 启动时默认有一个横幅 banner很多人用它整活儿网上有 banner 生成器能在线生成字符画把生成的 banner.txt 丢到 resources 目录下启动时就能看到自己定制的 logo这个不算复杂但挺有意思团队做内部系统的时候也能增加点氛围。4.2 JWT 登录与用户行为上报接口用户鉴权我直接用的 JWT前后端分离项目这是最常规的做法。登录成功后服务端签发一个 token 返回给前端前端后续请求在 Header 里带上后端通过拦截器校验。实现方式就是在 Spring MVC 里注册一个 HandlerInterceptor把所有需要登录的接口路径保护起来。Component public class JwtInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); if (jwtUtil.validateToken(token)) { Long userId jwtUtil.getUserIdFromToken(token); request.setAttribute(currentUserId, userId); return true; } } response.setStatus(401); return false; } }行为上报接口是推荐系统的数据入口设计得很简单接收 userId、songId、behaviorType、rating、playDuration 这些参数校验通过后保存进 user_behavior 表。这里有个技巧保存行为时不要同步去更新歌曲的 play_count因为高频接口容易产生数据库锁竞争。我的做法是先写行为表返回成功至于播放数统计通过异步队列去累加或者干脆定时任务统一按行为表聚合。行为上报接口必须做成高吞吐、低延迟它直接影响前端用户体验。RestController RequestMapping(/api/behavior) public class BehaviorController { PostMapping(/report) public Result report(RequestBody BehaviorReportDTO dto, HttpServletRequest request) { Long currentUserId (Long) request.getAttribute(currentUserId); UserBehavior behavior new UserBehavior(); behavior.setUserId(currentUserId); behavior.setSongId(dto.getSongId()); behavior.setBehaviorType(dto.getBehaviorType()); behavior.setRating(dto.getRating()); behavior.setCreateTime(LocalDateTime.now()); behaviorMapper.insert(behavior); return Result.success(); } }很多项目会在这里用 Async 做异步插入我也尝试过但要注意线程池的配置不然高并发下反而丢数据。后来我干脆保持同步写入配合 MySQL 批量插入优化一样能满足中小系统的需求。4.3 Redis 缓存推荐结果把性能做稳推荐接口如果不加缓存每一次请求都会触发数据库查询、排序计算等等数据量上来之后响应会变得很慢。我的方案是给推荐结果、热门榜、相似歌曲列表都加上 Redis 缓存key 的设计如下缓存 keyValue过期时间user:recommend:{userId}List12 小时hot:songsList2 小时item:sim:{songId}List24 小时song:detail:{songId}SongVO1 小时推荐接口的逻辑是先去 Redis 取 user:recommend:{userId}取到直接返回取不到则进入回源逻辑查相似度表拼装候选列表排序后写进 Redis。这里有一个非常容易踩的坑如果一个用户没有行为数据回源查询结果为空如果不做处理每次请求都会打到数据库造成缓存穿透。我的解法是对空结果也做缓存存一个空列表过期时间设置短一些比如 5 分钟。这样既能保护数据库用户有行为后也能较快拿到新推荐。缓存内容格式我用 JSON 序列化Spring Boot 里集成 Redis 时默认用的序列化器是 JdkSerializationRedisSerializer这个序列化出来的内容可读性差且占用空间大。我改成了 GenericJackson2JsonRedisSerializer配合缓存 key 的字符串序列化调试时直接看 Redis 里的 JSON 内容排查问题省心很多。4.4 定时任务每天凌晨预计算相似度相似度预计算离不开定时任务。Spring Boot 里开启方式很简单主类加 EnableScheduling计算逻辑所在类加 Scheduled 注解即可。我的执行时间是每天凌晨 2 点用 cron 表达式表示就是 0 0 2 * * ?这个时候线上访问量最低适合跑计算型任务。Component public class RecommendCronTask { Scheduled(cron 0 0 2 * * ?) public void calculateSimilarityDaily() { // 1. 拉取 24 小时内新增的行为数据与历史行为合并 // 2. 构建歌曲-用户分数矩阵 // 3. 计算相似度并写入临时表 // 4. 切换临时表为目标表 // 5. 刷新热门榜缓存 } }如果系统部署了多个实例定时任务会在每台机器上都执行一遍导致重复计算甚至数据错乱。解决办法是加分布式锁我用 Redis 的 SETNX 命令实现一个简单的锁任务启动时尝试获取锁获取成功才执行逻辑执行完释放。当然也可以用 Redisson 分布式锁但就一个定时任务没必要引入新的重依赖。还有一个细节值得提计算过程中要防止用户正在听歌导致的行为数据快照不一致。我的做法是计算开始时记录当前时间点只读取到这个时间点为止的行为数据当天新增的行为留到明天再纳入相似度计算。这看起来不够“实时”但对推荐效果影响很小大的工程系统也是这种方案。5. 上线踩坑与排查经验5.1 全量相似度计算直接 OOM第一次跑相似度计算任务时我直接把全量行为数据加载到内存构建矩阵结果几万首歌曲、几十万条行为数据一进来JVM 内存直接爆掉任务报了 OutOfMemoryError。这个坑其实在算法原理里就能预料到两两计算本身是 O(n²) 的纯粹用一个 HashMap 把全量关系都装进内存不爆才怪。我采用的解决方案有三个方向。第一是数据层面把 u ser_behavior 里只有一两次播放记录的低质量用户行为过滤掉这些数据噪声大对相似度计算贡献小。第二是候选层面不是所有的歌曲对都值得算相似度我先按播放热度筛选出 Top 3000 首热门歌只有这些歌参与全量两两计算冷门歌的相似度单独按标签匹配处理。第三是执行层面任务改成按歌曲 ID 分片每片只处理一个批次算完写入临时表再释放内存整个过程永远不会把全部矩阵放在内存里。这三个优化做完相似度计算任务从跑不完变成十几分钟跑完内存占用也一直控制在安全范围内。我建议做推荐系统先估算清楚歌曲和用户的数量级再决定要不要用分布式计算框架单纯加内存不是解决问题的正确姿势。5.2 冷启动推荐被热门霸屏上线跑了一阵子之后我发现新注册用户的推荐页和前两天的热门榜几乎一样个性化程度很低。原因很简单冷启动用户没有行为数据兜底逻辑直接被热门榜接管。可视化查了一下数据推荐列表里排前面的都是周杰伦、林俊杰这些头部歌手的热门金曲虽然不会出错但用户的感受就是“这推荐没有灵魂”。我对冷启动策略做了两点改进。第一是在热门榜基础上引入随机抽样热门榜按播放量分为高中低三档每档随机取一部分让不同新用户看到的初始推荐有所差异。第二是强化了注册偏好选择的作用用户选完曲风之后系统根据曲风召回歌曲并和热门榜做交叉融合这样用户对推荐的初始满意度会高很多。改进后我让几个朋友从注册开始测试普遍反馈“推荐的歌有几分对味儿了”说明这个优化方向是有效的。冷启动优化的核心逻辑是个性化推荐不可能一步到位但至少要让用户第一眼看出推荐列表和他的选择有关系而不是简单重复大众热门。5.3 Spring Boot 版本兼容性坑搭建项目时有同学喜欢直接用最新版 Spring Boot这里面的兼容性问题我踩得够多拿出来分享。Spring Boot 3.x 最大的变化是把 javax.* 包换成了 jakarta.* 包如果你习惯了旧版的 HttpServlet、Validation 注解升级之后会当场编译报错。JDK 最低要求也变成了 17如果你的环境还在用 JDK 8也要一并升级。MyBatis-Plus 官方为适配 3.x 单独出了 mybatis-plus-spring-boot3-starter 包如果你忘了换依赖会报各种奇怪的 bean 找不到错误。还有接口文档框架原来是 springfox技术社区普遍反映 Spring Boot 3.x 和 springfox 兼容得不好换 springdoc 才是正路。springdoc 的配置也不完全一样例如 endpoints 路由、分组配置等。我的建议很明确不是非必要别升级到 3.xSpring Boot 2.7.18 足够稳。如果项目确实是新建且团队对 3.x 有了解那也要提前规划好这些坑不要等编译报错再临时查。5.4 Jar 部署后改配置的痛点Spring Boot 项目打包成 jar 之后如果只是改一个数据库密码或者 Redis 地址你不想重新打包重新发布。我也看到网上有人问“怎么把 Spring Boot jar 反编译成项目”说实话反编译再重新打包很容易出问题而且维护成本极高不建议这么搞。正确方式是让配置外置化。Spring Boot 启动时会自动读取 jar 同级目录下的 application.yml 或 config 子目录下的配置文件优先级高于 jar 内置配置。所以我部署时会把 application.yml 单独放在服务器的配置目录里用如下命令启动java -jar music-recommend-1.0.0.jar \ --spring.config.additional-locationfile:/opt/music/config/application.yml这样改配置就在服务器上编辑那个外部 yml 文件然后重启服务完全不用动 jar 包。这个方式也方便一套 jar 在多个环境开发、测试、生产之间切换配置。如果你连重启都不想还能用 Spring Boot Actuator 配合 RefreshScope但那要额外引入配置中心小项目暂时不值得。5.5 常见问题速查表最后把自己的排查经验整理成速查表遇到类似问题可以直接对应解决。现象可能原因解决方式推荐接口一直返回热门榜用户行为数据未入库检查行为上报接口及必要鉴权相似度表为空定时任务没执行或执行报错查看日志验证 cron 表达式和权限Redis 缓存和数据库不一致缓存未及时失效给 key 设置合理过期时间JWT 校验失败前端未带 token 或过期检查拦截器排除路径、前端封装逻辑定时任务重复执行多实例部署未加锁用 Redis setnx 实现分布式锁计算任务内存溢出数据矩阵过大分片计算 过滤低质量数据整个项目从设计到落地这个过程比我预期的要磨人但也正因为踩了上面这些坑才真正理解推荐系统在工程里到底是怎么运转的。这个项目后续要扩展的方向也不少比如接入 Elasticsearch 做歌曲和歌手的模糊搜索用 HanLP 对歌词做情感分析或者引入更复杂的深度学习召回模型。不过核心建议还是提前打好 Spring Boot 工程基础——数据模型设计合理、接口稳定、缓存到位任何算法优化才有发挥的土壤。
返回列表