资讯动态

基于Spring Boot的协同过滤音乐推荐系统设计与实现

发布时间:2026/10/14 6:27:56 来源:尧图企业网站定制
如果你在网上搜音乐推荐系统会看到大量Demo把热歌榜点两下、按播放量排个序就叫推荐。说实话这类东西放到简历上第一轮问算法原理基本就露馅了。最近我给某同学梳理了一个基于Spring Boot的协同过滤音乐网站的设计与实现思路发现真正走通用户行为采集→离线协同过滤→在线接口返回这个闭环并不难难点在于很多人会跳过数据建模和冷启动直接去写Mapper查数据库。今天我把这个项目从零搭建的完整过程写出来方案为什么选物品协同过滤而不是用户协同过滤、行为日志怎么转成评分矩阵、相似度计算和TopN推荐怎么用Java实现、Spring Boot里怎么安排定时任务和缓存以及最后怎么评估推荐效果、怎么处理新用户和新歌曲。如果你想做的是毕业设计或者练手项目这篇应该能帮你少走不少弯路。1. 先别写代码方案选型才是这个项目的生死线1.1 为什么热门排行不是协同过滤我见过不少同学的推荐系统长这样写个接口查歌曲表按播放量倒序排翻页就完事了。热门播放榜本身是推荐系统里一个必要组件但它跟协同过滤是两码事。协同过滤的核心假设是和你相似的人喜欢的东西你也可能喜欢或者你喜欢的东西的相似物你也可能喜欢——注意它依赖的是用户之间的行为关联而不是单品本身的统计热度。这个区别直接决定系统有没有个性化。如果所有用户拿到同样30首歌那不管界面怎么包装它都不是推荐系统。我刚开始做这种项目时也犯过这个毛病后来被人一句话问住了你这个推荐结果把用户id换成随机数跟热歌榜有什么区别所以动手之前最好先想清楚到底要解决什么问题是让老用户发现新歌还是让新用户快速建立兴趣。这个问题想不清楚后面的算法选择全是碰运气。1.2 音乐场景下我为什么优先选ItemCF而不是UserCF基于用户的协同过滤UserCF找的是跟我口味像的人然后把这些人听过的歌推荐给我基于物品的协同过滤ItemCF找的是跟我听过的歌像的歌然后把相似的歌推荐给我。这两种思路在电商、视频、音乐里都有应用但音乐平台我更推荐ItemCF。原因有三点。第一用户量通常远大于歌曲量UserCF要在用户之间建相似矩阵用户数量到十万级别之后离线计算量会非常难看而歌曲库相对稳定ItemCF只需要维护歌曲之间的相似关系。第二音乐口味变化快用户这个月迷摇滚、下个月听民谣UserCF把历史口味相似的人当成一个稳定聚类很容易追不上这种口味漂移ItemCF基于最近听了什么歌做扩散能更快响应用户的短期兴趣。第三ItemCF推荐结果的解释性更好可以直接说因为你喜欢某首歌所以推荐了它的相似曲目这个理由在产品展示和论文答辩里都比因为你跟某用户相似好讲得多。当然这不是说UserCF完全不能用。如果系统用户规模很小、歌曲库特别大或者你想做好友都在听这种社交化推荐UserCF也有它的价值。选型没有绝对标准但得知道自己为了什么选它别拿个模板硬套。1.3 行为数据就是你的评分唯一靠谱的推荐信号协同过滤需要一个用户-物品评分矩阵但音乐产品通常没有显式的打分按钮所以常见的做法是把隐式行为转成分数。这是很多教程最糊弄的地方——直接初始化一个随机矩阵就当评分用了。如果用随机评分算法再花哨也是自嗨因为矩阵里根本没有真实规律可学。我在这个项目里的信号源就是一张行为日志表记录用户什么时候、对哪首歌、做了什么动作。动作至少包括播放、完整播放、收藏、加入歌单、跳过。别小看这几个动作后面评分规则定成什么样全靠它们撑起来。后面我会专门讲行为日志怎么落表和转分这里先记住一个原则真实行为日志是推荐系统的原料。没有原料后面全是空中楼阁。2. 搭建数据地基从行为日志到用户-歌曲评分矩阵2.1 四张核心表用户、歌曲、行为、推荐结果这个项目我用MySQL存业务数据Redis做缓存。数据库层面核心就四张表别贪多把字段设计好就够了。用户表很简单user_id、nickname、注册时间再加一个可选字段用来存新用户选择的音乐风格偏好这个字段在冷启动阶段会起到关键作用。歌曲表除了常规的歌曲名、歌手名、时长、封面图URL一定要留两个字段style风格标签比如摇滚、民谣、电子和状态位。风格标签是后面冷启动和相似歌扩散的关键很多Demo把歌曲表建得光秃秃的等要做新歌推荐时才发现没数据可用。行为日志表是重中之重我建议至少留这些字段字段说明user_id用户idsong_id歌曲idbehavior_type行为类型play/finish/collect/playlist/skiplisten_seconds本次听了多少秒song_duration歌曲总时长source来源标记比如搜索、歌单、推荐位create_time行为发生时间注意 listen_seconds 和 song_duration 必须同时存下来因为你判断完整播放不能靠用户有没有点暂停按钮要靠听歌时长和歌曲总时长的比例这个比例才是硬指标。推荐结果表存离线算好的结果user_id、song_id、score、batch_date。batch_date 用来区分是哪一批任务生成的。这张表要和行为日志表分开因为推荐结果是给在线接口快速读取的行为日志是给离线算法反复扫描的两个用途完全不同混在一张表里会互相拖累。2.2 行为转评分别把权重拍脑袋定死有了日志表第二步是把用户在某首歌上的所有行为聚合成一个分数。我用的是一套加权规则长这样行为类型触发条件得分完整播放listen_seconds / song_duration 0.91.0部分播放listen_seconds / song_duration 0.30.6收藏点击收藏按钮2.0加入歌单把歌加进自建歌单1.5搜索后播放source标记为search1.2跳过listen_seconds 10秒且非完整播放-0.5同一首歌的多次行为分数直接累加累加结果写进 user_song_score 表user_id、song_id、score。这一步本质上就是把一个用户对上百首歌的行为压成了一个稀疏向量。权重的具体数字不是关键关键在相对关系合理收藏的权重必须大于普通播放主动跳过要拉低分数搜索行为说明用户有明确意图应该比随机推荐点进来的播放更有分量。这些权重完全可以后续再调所以行为日志表字段一定要留够。我第一次做的时候只存了播放次数一个指标后来想加跳过惩罚发现根本查不到用户听了多少秒只能从零开始重新攒数据教训相当深刻。2.3 稀疏矩阵的存储方式别真去搞一个二维大数组很多教程画了一张很漂亮的用户×歌曲矩阵图但实际上你不会真的去存这个二维数组。用户数一万、歌曲数五千那就是五千万个格子存成普通表直接爆炸。正确做法是用稀疏结构只存用户对哪些歌有过行为行为之外的位置默认就是0。在Java内存里可以用 MapLong, MapLong, Double 表示外层key是userId内层key是songIdvalue是聚合分数。在MySQL里就是 user_song_score 表一行代表这个用户对这首歌有这么多分。这样就算十万用户、几十万行数据也完全扛得住。后面算法计算时也是在稀疏结构上遍历而不是去遍历那个巨大的全量矩阵这样性能才是可控的。3. 算法实现物品共现矩阵与TopN推荐生成3.1 从余弦相似度到物品共现矩阵物品协同过滤的相似度公式很经典歌曲i和歌曲j的相似度等于同时喜欢这两首歌的用户数 / 各自被喜欢次数的平方根数学上就是余弦相似度的一种形式公式写出来是sim(i, j) |N(i) ∩ N(j)| / sqrt(|N(i)| * |N(j)|)N(i) 是对歌曲i有正向行为的用户集合。分母的平方根是为了对热门歌曲做惩罚否则那些人人听过的热门歌会跟任何歌都相似推荐结果会越来越集中在头部长尾歌曲完全没有出头的机会。如果用户行为本身有强度差异也可以把分子换成两首歌上用户分数乘积之和这就是带权重的共现。我的建议是项目初期先用不带权的经典版本来理解跑通之后升级成带权版本效果会平滑一些也方便写对比实验。3.2 Java实现预计算歌曲相似表实现时没必要在每次请求里算相似度我选择在定时任务里预计算一张 song_similar 表字段是 song_id、similar_song_id、sim_score、update_time。核心代码如下public void buildSimilarTable(MapLong, MapLong, Double userSongs) { // 统计每首歌被多少用户产生过正向行为 MapLong, Double songCnt new HashMap(); // 共现矩阵key为歌曲ivalue为 歌曲j, 共现值 MapLong, MapLong, Double coMatrix new HashMap(); for (MapLong, Double songs : userSongs.values()) { ListLong ids new ArrayList(songs.keySet()); for (Long i : ids) { songCnt.merge(i, 1.0, Double::sum); for (Long j : ids) { if (Objects.equals(i, j)) continue; coMatrix.computeIfAbsent(i, k - new HashMap()) .merge(j, 1.0, Double::sum); } } } // 归一化除以 sqrt(歌曲i被喜欢次数 * 歌曲j被喜欢次数) ListSongSimilar similarList new ArrayList(); for (Map.EntryLong, MapLong, Double e : coMatrix.entrySet()) { Long i e.getKey(); for (Map.EntryLong, Double inner : e.getValue().entrySet()) { Long j inner.getKey(); double sim inner.getValue() / Math.sqrt(songCnt.get(i) * songCnt.get(j)); similarList.add(new SongSimilar(i, j, sim)); } } // 只保留每首歌TopK相似项排序后截断再批量落库 // 落库前清掉上批次数据避免脏数据累积 }这段代码看着简单但有三个地方要留意。第一遍历用户歌曲对时是 O(用户数 × 平均行为数²)如果平均行为数到几百这个循环会非常重所以后面必须做TopK截断、必须用定时任务而非在线计算。第二similarList 规模很大时不能逐条insert要用批处理或者先写临时表再整体替换。第三相似度截断不能只看绝对阈值建议先跑一次看看分数分布再决定保留前多少名我在后面调参部分还会详细说这个坑。3.3 推荐打分最近行为扩散加权有了相似歌曲表推荐生成就非常直接了。核心思路是拿用户最近一段时间有正向行为的歌曲去相似表里找它们的相似歌按相似度加权求和最后过滤掉用户已经听过的歌按分数排序取前N。用Java写出来大概是这样的public ListLong recommend(Long userId, int recentSize, int topN) { // 1. 取用户最近的 recentSize 首正向行为歌曲按时间倒序 ListLong recentSongs songScoreService.findRecentSongIds(userId, recentSize); if (recentSongs.isEmpty()) { return hotSongService.top(topN); // 冷启动兜底 } // 2. 收集候选歌曲并按加权分数累加 MapLong, Double scoreMap new HashMap(); SetLong history songScoreService.findHistorySongIds(userId); for (Long songId : recentSongs) { double userScore songScoreService.getUserSongScore(userId, songId); for (SongSimilar sim : songSimilarRepo.findTop30BySongId(songId)) { if (history.contains(sim.getSimilarSongId())) continue; scoreMap.merge(sim.getSimilarSongId(), userScore * sim.getSimScore(), Double::sum); } } // 3. 排序取前 topN return scoreMap.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); }这里几个参数值得解释一下。取最近有行为的歌曲时我只取最近20到30首而不是把用户几个月前的行为全部搬出来。推荐系统应该对齐用户当前口味而不是用户整个历史画像——就像你给朋友推荐歌不会把他高中时期听的所有歌全部算一遍。每条相似歌曲只取Top30是为了控制候选集规模和计算量。历史歌曲必须过滤不然用户已经听过的歌被反复推荐体验会非常差。3.4 为什么必须离线预计算而不是在线实时算这个点是我觉得工程化项目跟玩具Demo最大的分水岭。如果把相似度计算放在在线接口里每来一个请求就全量扫一遍共现矩阵数据库和CPU都会扛不住。用户量起来之后一个普通请求可能触发几十万次计算接口延迟直接飙到几秒然后引发连锁雪崩。正确做法是把计算拆成离线批处理和在线查询两步。离线批处理每天凌晨跑一次算好相似表和推荐结果写库在线接口只做三件事查缓存、查结果表、冷启动兜底。这样接口吞吐量能轻松上来算法逻辑也隔离在任务模块里下次想换算法比如换成基于标签的召回也不会牵连在线代码。4. Spring Boot工程落地的几个关键环节4.1 定时任务凌晨3点全量重算Spring Boot里做定时任务很简单一个 Scheduled 注解加一个配置项就行。我的任务配置是每天凌晨3点执行因为凌晨用户行为最少跑推荐计算不会跟在线请求争资源。Component Slf4j public class RecommendScheduler { Scheduled(cron 0 0 3 * * ?) public void rebuildAllRecommend() { long start System.currentTimeMillis(); // 1. 从行为日志聚合出用户-歌曲评分矩阵 MapLong, MapLong, Double userSongs behaviorService.buildUserSongMatrix(); // 2. 重建歌曲相似表 similarSongService.rebuild(userSongs); // 3. 对活跃用户生成推荐结果 recommendResultService.generateForActiveUsers(userSongs); // 4. 清理旧缓存 cacheService.clearRecommendCache(); log.info(recommend task finished, cost {} ms, System.currentTimeMillis() - start); } }这里重点想提醒的是幂等性。定时任务如果中途失败重跑或者上一批还没跑完下一批又开始会产生重复和脏数据。我的做法是给 recommend_result 表加唯一索引 (user_id, song_id, batch_date)每次任务用一个新的 batch_date跑完后再把旧批次清掉。另外日志里一定要记录耗时和处理量不然任务挂了你都不知道挂在哪一步。4.2 推荐接口设计与结果落库离线算好的推荐结果直接查表返回接口设计得很薄RestController RequestMapping(/api/recommend) public class RecommendController { GetMapping(/songs) public ResultListSongVO recommendSongs(RequestParam Long userId, RequestParam(defaultValue 30) int limit) { ListSongVO list recommendService.getTopN(userId, limit); return Result.ok(list); } }recommendService 内部的处理顺序是先查Redis缓存缓存没有就查 recommend_result 表表里也没有就进入冷启动分支。每一层都很快不会出现复杂计算。接口返回的歌曲对象里可以带 score 字段和理由字段前端拿到后可以展示因为你最近常听民谣为你推荐这张歌单交互感和答辩展示里的功能性亮点都有了。4.3 Redis缓存与热点回退推荐结果的缓存key我用 recommend:user:{userId}value是JSON数组过期时间设成22小时。这样设计有个讲究任务每天凌晨3点跑过期时间必须短于24小时否则任务更新完数据后旧缓存还得再存活一段时间用户看到的是新旧混在一起的结果。所以过期时间设成22小时是偏保守的保证凌晨任务跑完后缓存能被自然淘汰或者被主动清掉。另外不需要给所有用户都建缓存。活跃用户的推荐结果被频繁读取缓存价值最大长尾用户几个月登录一次缓存纯粹浪费内存直接查结果表就行。我通常只对近7天有过行为的用户生成推荐结果少于这个阈值的用户走冷启动或热门兜底省时省力指标也不会差。4.4 项目模块划分与整体数据流整个项目的模块划分我建议按职责拆别把什么都塞进一个Controller。大致是行为采集模块负责接收和落库用户行为推荐引擎模块负责定时任务和算法计算推荐服务模块负责在线接口和缓存歌曲管理模块负责后台歌曲录入和标签维护。数据流从头到尾是用户点歌产生行为日志 → 行为日志落库 → 定时任务聚合评分矩阵 → 重建相似表 → 生成推荐结果 → Redis或MySQL给在线接口读取。这条链路通了前端就是展示层的事后面换成别的算法也容易。5. 冷启动、离线评估与调参踩坑记录5.1 新用户和新歌曲的冷启动方案协同过滤对没有行为的用户无能为力这是算法本身的特性不是代码bug。新用户注册时我建议让他选几个喜欢的音乐风格存到用户表里。推荐服务检测到该用户没有足够行为时就先按风格偏好召回一批同风格歌曲再混入一定比例的热门歌。这样比单纯给热歌榜的体验好很多而且用户一旦开始产生行为系统就能平滑过渡到协同过滤。新歌曲的冷启动更容易被忽略。歌曲刚上传时没有任何用户行为永远不会进入相似表所谓推荐新歌就成了摆设。我的做法是歌曲上传后按风格标签找出同风格下的已有歌曲把新歌挂到这些歌曲的相似关系里同时在推荐结果里预留探索位比如Top10里放一两首新歌或低热度歌曲避免系统永远只推头部内容。探索位的比例不用太高10%到30%就够太高会影响整体准确率。5.2 离线评估留一法算准确率、召回率和覆盖率推荐效果不能靠肉眼感觉得有量化指标。我的离线评估方法是时间切分把每个用户的行为按时间排序取前80%作为训练集后20%作为测试集。用训练集跑推荐看推荐结果里有多少能命中测试集。核心指标有三个。准确率K等于推荐列表前K首里命中测试集的歌曲数除以K衡量推的到底是不是用户真的会听的召回率K等于命中数除以测试集歌曲总数衡量用户喜欢的歌有多少被找出来了覆盖率等于推荐结果里去重后的歌曲数除以歌曲总数衡量系统是不是只会推头部热门歌。这三个指标跑下来算法和参数的优劣就非常清楚了也方便论文里画对比图。5.3 我调参过程中踩过的三个坑第一个坑是相似度阈值拍脑袋定。我第一次把阈值设为0.1结果推荐结果发散得离谱因为数据量小的时候很多歌曲的共现次数会被偶然因素放大。后来我先把相似度分数分布统计出来发现大量相似度集中在0到0.05之间才把阈值定到0.15效果立刻稳了。记住阈值要根据自己数据的分布来定不是网上抄一个0.2就能直接用的。第二个坑是重复任务产生脏数据。第一次跑完定时任务后我手动又触发了一次结果推荐表里同一批数据出现两份前端展示时一首歌混在前后两个位置。加唯一索引之后解决。这种细节面试官很爱问因为能体现你是不是真的跑过完整流程而不只是写了个Demo。第三个坑是测试数据造得太干净。为了快速看到推荐效果我一开始模拟了100个用户每个人只听两个固定风格里的歌准确率高达80%以上看起来非常爽实际上完全失真。后来加了大约10%的随机行为模拟用户偶尔乱点的情况指标立刻回落到合理区间。造数据也要造得有噪声否则调参时会被虚假的漂亮指标带偏。最后给准备动手的人一个建议先不要急着做前端界面先把数据流跑通——用户点歌产生行为日志定时任务把行为变成相似表和推荐结果接口把结果吐出来。这一条链路通了后面换算法、加功能、写文档都有底气。真要说这套项目里最容易被低估的就是行为日志的设计多留几个字段后面调评分规则和做冷启动时能省一大堆事。我在实际项目里每改一次评分规则都会庆幸当初存了听歌时长和跳过动作而不是只存一个播放次数。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑