简介一份面向Java毕设的个性化音乐推荐系统完整源码包基于SpringBootVue开发JDK1.8MySQL5.7环境涵盖用户行为跟踪、偏好学习、相似性计算、推荐列表生成、用户反馈、音乐库管理、用户管理、评论管理及数据可视化等核心模块。系统采用协同过滤算法通过用户历史行为构建画像找出相似用户或相似歌曲并生成推荐管理员可维护音乐与用户信息整体功能完善适合计算机专业毕业生作为毕设参考或二次开发。共366个文件含101个Java源文件、79个Vue前端文件、41个JS脚本、46个PNG图片及CSS、XML、SQL、配置文件等压缩包约10.61MB源码、数据库脚本与说明文档齐全目录结构清晰便于快速部署与理解。目前已有70人学习下载对于需要落地推荐系统或完成毕设代码复现的同学来说是一份可直接运行、可拓展的高性价比资源。1. 基于协同过滤算法的音乐推荐系统这个 Java 毕设选题怎么做才不翻车“基于协同过滤算法的个性化音乐推荐系统”这个选题几乎是 Java 毕设题库里最常被点名的几个之一——听起来不复杂但真要跑通一版“能讲清楚”的推荐中间藏着一堆细节用户行为矩阵怎么建、相似度用余弦还是皮尔逊、怎么排除热门歌曲的干扰、离线算完怎么缓存给在线接口用。这篇我按自己做过的方案把从相似度计算到 TopN 推荐再到 Spring Boot 接口的完整链路写下来适合正在准备 Java 相关毕设、或者想补一道推荐系统项目的初级 Java 开发。不整花架子照这个顺序做一周能跑通答辩也有得讲。2. 协同过滤算法选型为什么音乐推荐该用 ItemCF 而不是 UserCF2.1 先搞清楚协同过滤在算什么协同过滤的核心从来不是“分析歌曲特征”而是“看人和人的行为交集或者物和物的被选择交集”。这句话想明白后面代码才好写。两个用户都收藏了同一批老歌那他们口味大概率接近两首歌被同一批人反复播放那它们多半也沾亲带故。要做这件事第一步是拿到“用户对歌曲的行为”。行为不能只用播放次数还要分权重。我一般这么定仅试听一次1 分完整播放2 分收藏3 分加入歌单4 分下载5 分权重不是越复杂越好关键是拉开“低意愿行为”和“高意愿行为”的差距。试听可能只是手滑收藏和下载则代表明确偏好。这个表在后续相似度计算里直接影响结果值得先定好这比后面调参重要得多。2.2 为什么音乐场景更该用 ItemCF 而不是 UserCF如果只看标题很多人上来就写 UserCF——先找“跟我像的人”再推荐他们听的歌。这在新闻资讯场景确实成立因为热点变化快人群的即时兴趣很重要。但音乐不一样听歌口味是慢变量十年前喜欢民谣的人今天大概率还是民谣。靠“找相似用户”来做每个用户要在线找邻居用户量一大计算量直线上升而且推荐结果会因为某个活跃用户的突发行为剧烈抖动。ItemCF基于物品的协同过滤是反过来的离线把所有“歌与歌之间的相似度”算好线上只需要拿用户历史行为去匹配相似歌曲实时性高结果稳定还天然带解释性——因为可以告诉用户“因为你收藏了 A 和 B所以推荐 C”。这个解释性在毕业设计答辩时极有用能让评委一眼看懂你的推荐不是黑匣子。我把两者差异整理成一张表维度UserCFItemCF计算时机用户相似度需在线实时算或离线维护物品相似度离线算好线上查表适用场景新闻、短视频、社交动态音乐、电商、视频点播推荐理由难解释只能泛泛说“相似用户喜欢”可直接指向具体历史行为稳定程度活跃用户行为会导致推荐突变歌与歌的关系稳定结果平稳冷启动压力新用户找不到邻居新用户只要有几次行为即可推荐所以说音乐推荐系统选 ItemCF 不是拍脑袋是场景决定的。工程上也好落地离线任务每天晚上算一次歌曲相似度白天接口只做查表和排序。2.3 用户-歌曲矩阵的构建与行为权重设置不管算法选哪个数据准备都是第一步。假设 MySQL 里有三张表user、song、play_log。play_log 每行记录一条用户行为字段包括 user_id、song_id、action_type、create_time。构建矩阵的代码我一般放在 service 层用 HashMap 组装成“userId - songId - 行为总分”的结构。public MapLong, MapLong, Double buildUserSongMatrix() { ListPlayLog logs playLogMapper.selectAll(); MapLong, MapLong, Double userSongMatrix new HashMap(); MapString, Double weightMap new HashMap(); weightMap.put(listen, 1.0); weightMap.put(play, 2.0); weightMap.put(collect, 3.0); weightMap.put(playlist, 4.0); weightMap.put(download, 5.0); for (PlayLog log : logs) { Long userId log.getUserId(); Long songId log.getSongId(); Double weight weightMap.get(log.getActionType()); if (weight null) continue; userSongMatrix .computeIfAbsent(userId, k - new HashMap()) .merge(songId, weight, Double::sum); } return userSongMatrix; }这段代码的核心是computeIfAbsent和merge前者负责在用户第一次出现时建立内层 Map后者把同一用户对同一首歌的多次行为累加成分数。注意我没有把“收听时长”直接乘进去因为时长数据容易被长时间挂机污染。如果要加可以做区间映射比如 30 秒以下不计分、30 秒到 3 分钟算 1 分、完整播放算 2 分这样比直接乘秒数干净得多。另一个值得处理的是时间衰减。三个月前收藏的歌和三天前收藏的歌对当下口味的代表程度完全不同。常见做法是给行为分数乘一个衰减系数比如weight * Math.exp(-days / 30)30 天半衰期。这个参数我调过几次30 天在音乐场景比较合适太短会让长尾推荐消失太长又会让相似度矩阵僵化。3. 相似度计算与推荐生成跑通 ItemCF 的最小实现3.1 余弦相似度和皮尔逊相关系数怎么选有了用户-歌曲矩阵下一步是算歌与歌之间的相似度。最常用的两个指标是余弦相似度和皮尔逊相关系数。余弦相似度看的是两个向量的方向是否一致公式用代码表达就是把同时给两首歌打过分的用户找出来算这两首歌在这些用户下的分数向量的夹角余弦。它的特点是只关心相对打分趋势不受用户打分习惯影响——有人全部打 1 分有人全部打 5 分余弦相似度依然能算出“这两首歌是不是被同一批人喜欢”。皮尔逊相关系数更进一步会先减去每个用户自己的平均打分再算相关性。好处是能修正“有人打分偏高、有人打分偏低”的系统偏差坏处是计算量更大而且很多用户行为数据稀疏减均值后反而放大噪声。在音乐推荐这种以隐式反馈为主没有显式评分的场景我一般直接用余弦相似度简单、稳定、好解释。你在论文里也能讲清楚为什么不做皮尔逊行为数据不是评分数据均值本身没有意义。3.2 ItemCF 相似度计算最小算子就是这几行下面的代码用 Java 实现“歌曲两两相似度”的计算。输入是第 2 节构建的 userSongMatrix输出是songId1 - songId2 - 相似度的嵌套 Map。public MapLong, MapLong, Double calcItemSimilarity( MapLong, MapLong, Double userSongMatrix) { MapLong, MapLong, Double simMatrix new HashMap(); // 第一步收集每首歌被哪些用户打过多少分 MapLong, MapLong, Double songUserMatrix new HashMap(); for (Map.EntryLong, MapLong, Double userEntry : userSongMatrix.entrySet()) { Long userId userEntry.getKey(); for (Map.EntryLong, Double songEntry : userEntry.getValue().entrySet()) { Long songId songEntry.getKey(); Double score songEntry.getValue(); songUserMatrix .computeIfAbsent(songId, k - new HashMap()) .put(userId, score); } } // 第二步对每对歌曲计算余弦相似度 ListLong songIds new ArrayList(songUserMatrix.keySet()); for (int i 0; i songIds.size(); i) { for (int j i 1; j songIds.size(); j) { Long s1 songIds.get(i); Long s2 songIds.get(j); double dot 0.0, norm1 0.0, norm2 0.0; MapLong, Double users1 songUserMatrix.get(s1); MapLong, Double users2 songUserMatrix.get(s2); for (Map.EntryLong, Double e : users1.entrySet()) { Long uid e.getKey(); Double v1 e.getValue(); Double v2 users2.get(uid); norm1 v1 * v1; if (v2 ! null) { dot v1 * v2; } } for (Double v2 : users2.values()) { norm2 v2 * v2; } double sim dot / (Math.sqrt(norm1) * Math.sqrt(norm2) 1e-9); if (sim 0.01) continue; // 过滤无意义的弱关联 simMatrix.computeIfAbsent(s1, k - new HashMap()).put(s2, sim); simMatrix.computeIfAbsent(s2, k - new HashMap()).put(s1, sim); } } return simMatrix; }逻辑分成两步第一步把用户-歌曲矩阵转置成歌曲-用户矩阵第二步遍历歌曲对计算共同用户的分数点积。1e-9是防除零用的这个细节虽然小但在真实数据集里经常遇到。过滤阈值sim 0.01是为了减少稀疏数据带来的噪声关联也顺带压缩了内存。simMatrix中每首歌只保留相似度大于阈值的好邻居否则数据量一大内存先崩。这段代码还有两个值得注意的点。一是没有做“热门歌曲惩罚”因为 ItemCF 天然会给热门歌带来更高的相似度——这个问题我放在第 5 章细说。二是时间复杂度是 O(n²)歌曲量超过一两万以后会很吃力常见的做法是把songIds按热度截断只保留出现次数排名前 5000 或 8000 的歌曲参与两两计算长尾歌曲用标签或歌手补足。3.3 生成 TopN 推荐把历史行为映射到相似歌曲上相似度矩阵只是中间产物用户真正看到的是“候选歌曲加权求和后排出来的 TopN”。规则是用户听过/收藏过一首歌 A那么和 A 相似的歌 B 就会获得一个候选分B候选分 A的行为分 * sim(A, B)。把所有候选歌的分加起来去掉用户已经听过的取前 N 个。下面这段代码输入是相似度矩阵和单个用户的历史行为输出是该用户的推荐列表。public ListLong recommendForUser(Long userId, int topN, MapLong, MapLong, Double simMatrix, MapLong, MapLong, Double userSongMatrix) { MapLong, Double userScores userSongMatrix.getOrDefault(userId, Collections.emptyMap()); MapLong, Double candScores new HashMap(); for (Map.EntryLong, Double history : userScores.entrySet()) { Long likedSongId history.getKey(); Double likeScore history.getValue(); MapLong, Double neighbors simMatrix.getOrDefault(likedSongId, Collections.emptyMap()); for (Map.EntryLong, Double neighbor : neighbors.entrySet()) { Long candSong neighbor.getKey(); if (userScores.containsKey(candSong)) continue; // 排除已听过的 double score likeScore * neighbor.getValue(); candScores.merge(candSong, score, Double::sum); } } return candScores.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); }参数说明topN作为接口入参一般推荐页取 10 或 20candScores用merge累加避免同一个候选歌被多个历史歌曲命中时被覆盖。过滤逻辑我放在累加过程中看到userScores.containsKey(candSong)直接跳过这样不会因为“听了 A 又听了 A 的相似歌 B”导致结果重复推荐。这里容易踩一个思维误区以为候选歌一定要和历史歌不同。其实如果用户只听过 3 首歌相似歌可能有几十首候选池足够不必额外膨胀。真正的瓶颈在于用户历史行为太少时候选池本身就很浅这个问题的解法在冷启动章节讲。4. Java 工程化落地离线计算 Redis 缓存 在线推荐接口4.1 数据准备从 MySQL 行为日志到入模矩阵数据准备的核心是“别在应用层做太重的 SQL 聚合”。如果你的表设计合理一条 SQL 就能拿到原始行为剩下的矩阵构建交给 Java 内存。一个容易翻车的点是时间范围不设限把所有历史数据拉出来样本太大直接 OOM。我一般限定最近 90 天足够覆盖口味变化又不会拖垮内存。SELECT user_id, song_id, action_type, create_time FROM play_log WHERE create_time DATE_SUB(NOW(), INTERVAL 90 DAY) ORDER BY user_id, create_time;参数说明90 天这个窗口不是一个拍脑袋的数字。我在本地试过 30、60、90、180 天四档30 天数据量太少冷门歌的相似度矩阵稀疏得没法看180 天则会把用户早已失效的口味带进来。90 天在和业务方核对历史行为分布时是个折中点。实际做毕设时可以按你本地的日志量先跑一版再观察 TopN 推荐里是否出现明显过时歌曲如果有就缩短窗口。拿到原始日志后用第 2 节的buildUserSongMatrix组装矩阵。注意action_type的取值在代码里是写死的如果表里存的是中文或者数字编码记得在 mapper 层或 service 入口做一次统一映射。我见过同学在这里直接拿字符串比较DB 里存的是1,2,3代码里判断的是collect结果所有行为权重全部落到默认值推荐结果一团糟。4.2 离线计算任务定时跑相似度结果写进 Redis在线接口不可能现场算全量相似度否则用户请求一来就卡死。常规做法是 Spring 的Scheduled定时触发离线任务每天凌晨算一次相似度矩阵和 TopN 推荐结果直接写入 Redis。写入 Key 的设计很重要我常用的两个 Key 分别是rec:song:sim:{songId}存歌曲相似 TopK 列表value 用 JSON 序列化rec:user:topn:{userId}存用户推荐结果value 也存 JSONKey 加前缀的好处是后期清缓存方便redis-cli --scan --pattern rec:user:topn:*一把就能全部扫出来。过期时间建议设成 24 小时加随机抖动比如24 * 3600 Random.nextInt(3600)防止缓存同时失效导致 Redis 雪崩——这个小技巧在笔试和面试里也常被问到。Component public class RecommendJob { Resource private RedisTemplateString, String redisTemplate; Scheduled(cron 0 30 2 * * ?) public void runOfflineRecommend() { MapLong, MapLong, Double matrix buildUserSongMatrix(); MapLong, MapLong, Double simMatrix calcItemSimilarity(matrix); for (Map.EntryLong, MapLong, Double entry : simMatrix.entrySet()) { Long songId entry.getKey(); ListMap.EntryLong, Double topSims entry.getValue().entrySet().stream() .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) .limit(30) .collect(Collectors.toList()); redisTemplate.opsForValue().set( rec:song:sim: songId, JsonUtil.toJson(topSims), 30, TimeUnit.DAYS); } // 用户 TopN 推荐结果也在这里批量算好写缓存 for (Long userId : matrix.keySet()) { ListLong recs recommendForUser(userId, 20, simMatrix, matrix); redisTemplate.opsForValue().set( rec:user:topn: userId, JsonUtil.toJson(recs), 1, TimeUnit.DAYS); } } }这段代码里buildUserSongMatrix和recommendForUser是前面写好的方法整个 offline 任务就是“建矩阵 → 算相似度 → 写推荐结果”三步。topSims限制 30 个邻居是为了防止相似度 Map 过大。线上推荐时只取前 20 或 30 个邻居做加权再多对精度提升的贡献很小反而是纯计算开销。cron 0 30 2 * * ?表示每天凌晨 2 点 30 分执行。选凌晨是因为深夜用户行为最少数据稳定算出来的结果不会因为某个用户凌晨疯狂刷歌而抖动。这里有一个笔记我一直留着首次上线时每天手动触发一次任务观察 Redis 里的 Key 是否齐全、推荐结果是否为空确认没问题再交给定时器。4.3 在线推荐接口从 Redis 取缓存返回给前端在线接口要做到“只读缓存不做算法计算”。接收用户 ID 后先查缓存如果命中直接返回如果缓存没命中或者已过期就做一次降级按热度返回默认歌曲列表同时打一条日志供排查。RestController RequestMapping(/api/recommend) public class RecommendController { Resource private RedisTemplateString, String redisTemplate; GetMapping(/{userId}) public ResultListSongVO recommend(PathVariable Long userId) { String cacheKey rec:user:topn: userId; String cached redisTemplate.opsForValue().get(cacheKey); if (cached null) { // 降级返回热门歌曲兜底同时记录日志便于排查 log.warn(recommend cache miss, userId{}, userId); return Result.ok(getHotSongs()); } ListLong songIds JsonUtil.fromJson(cached, List.class); ListSongVO songs songService.listByIds(songIds); return Result.ok(songs); } }这段代码最值得说的不是返回逻辑而是缓存穿透的处理。如果用户量大了N 多新用户同时来请求Redis 里不可能有他们的rec:user:topn:{userId}就会全部打到热门兜底上。这里我没做布隆过滤器因为毕设数据量远没到那个量级但我在代码注释里标了“此处可扩展布隆过滤器”。答辩时老师如果追问你能接住这句话比愣住强得多。另一个细节是songService.listByIds查数据库时songIds 的顺序可能会丢。MySQL 的IN查询默认不保证顺序Redis 里存的顺序是推荐算法排好的查完数据库最好用 Map 重新排一次序。很多人在这里翻车接口返回 20 首歌顺序全乱了推荐效果就算算法没问题看着也像瞎排的。5. 避坑排查音乐推荐系统最常见的 5 个翻车现场5.1 场景一相似度矩阵全是 0推荐结果为空现象跑完离线任务日志显示相似度矩阵条目数为 0接口返回热门兜底列表。原因数据太稀疏。用户行为表只有几百条记录且集中在几十首歌上两首歌之间几乎没有共同用户余弦相似度自然算不出有效值。另一个常见原因是行为权重表设了collect之类英文键但代码里拿到的action_type是数字编码导致所有行为的权重都是空值。解决第一步去查play_log表里action_type的实际取值把权重映射改成一致的枚举。第二步是扩大行为窗口从 90 天放宽到 180 天或把“仅试听”和“完整播放”合并成同一种正反馈增加共同用户的计算基数。如果数据量实在太小可以考虑把歌曲按“歌手 专辑”做粗粒度合并先推荐到歌手的层级再展开成歌曲。这算是在数据受限时的常规替代方案。5.2 场景二热门歌曲霸榜推荐结果千篇一律现象不同用户的推荐列表里反复出现同一批爆款歌曲比如排行榜前 20 的歌几乎人人可见。原因热门歌被大量用户行为命中相似度矩阵里它们的邻居数量远超冷门歌加权求和后热门歌的分数天然碾压。这不是 ItemCF 的 bug而是它的偏见——缺乏对热度的惩罚。解决在相似度计算时对热门物品做惩罚常见做法是引入一个与歌曲热度成反比的权重因子。我常用的公式是double sim dot / (Math.sqrt(norm1) * Math.sqrt(norm2) 1e-9); double hotPenalty 1.0 / Math.pow(1 Math.log(1 hotCount), 0.5); double finalSim sim * hotPenalty;hotCount是这首歌被多少用户行为命中过取对数后再开方是为了让惩罚力度平滑不至于把所有热门歌一刀切掉。这个参数我调过几次指数取 0.5 比较温和取 1 会明显偏向冷门歌推荐结果里会出现一些质量不可控的歌。5.3 场景三新用户和新歌冷启动推荐列表是空的现象新注册用户没有任何行为数据recommendForUser返回空列表新上架歌曲没被任何人听过相似度矩阵里根本找不到它。原因ItemCF 依赖历史行为新用户没有历史行为就没有推荐依据新歌没有行为交集就无法进入相似度矩阵。这是协同过滤的天然短板不是代码写错。解决新用户走“热门兜底 冷门探索”两路。热门兜底在前 10 个推荐位放热度榜歌曲后面几个位置随机塞几首冷门歌既保证有内容可看又给新歌曝光机会。新歌则做“基于内容的标签匹配”给每首歌打风格标签比如“民谣”“摇滚”“说唱”新歌只要标签和用户历史行为中的歌曲标签重合度够高可以直接进入候选池。标签匹配代码很轻量一张song_tag_map表就够不必上复杂的内容模型。5.4 场景四Redis 缓存过期后接口雪崩现象凌晨 2 点半定时任务把 1 万个用户的 Recommend 结果全部写入 Redis过期时间全是 24 小时。第二天凌晨 2 点半上万条 Key 同时过期所有请求同时压到数据库接口平均耗时从 50ms 跳到 5s。原因所有 Key 的过期时间相同产生缓存雪崩。这个坑在单机小程序上不明显但只要是真实部署就一定会遇到。解决过期时间加随机抖动让 Key 的失效时间分散在一个时间段内。另一个方案是不给用户 TopN 结果设置过期时间而是让离线任务每天覆盖写一次写完以后删除旧的 Key。两种方案我都用过第一种实现简单第二种在数据量不大时更稳定。毕设里用第一种就够了代码就一行setExpire(key, 24 * 3600 Random.nextInt(3600))。5.5 场景五答辩时被问“你怎么证明推荐效果好”现象演示完了评委问推荐准确率高不高你答不上来。原因只写了推荐功能没写评估模块。这在毕设里非常常见——推荐系统是一个没有“标准答案”的模块不量化就没法证明算法有效。解决在项目里加一个简单的离线评估任务把用户历史行为按时间截断前 70% 做训练、后 30% 做验证。评估指标用 Precision10 和召回率就够了。计算方式不复杂把测试集里用户真实听过的歌曲记为正样本把推荐 Top10 中命中的数量除以 10就是 Precision10。代码里维护一个evaluationResult对象把命中数、总推荐数、总真实行为数统计出来写成日志。这个评估模块总共不过 50 行但它在答辩时的说服力远超“我觉得推荐挺准的”。6. 让推荐结果更像个产品时间衰减、推荐理由与离线验证6.1 给相似度加上时间衰减别让三个月前的收藏决定今天的歌单前面第 2 节提过行为分数最好乘一个时间衰减系数。这个不该只作用在构建矩阵时更合理的做法是在累积候选分数时再衰减一次因为用户最近的播放行为在生成推荐时应该权重更高。double days ChronoUnit.DAYS.between(behaviorTime, LocalDate.now()); double timeDecay Math.exp(-days / 30.0); double score likeScore * neighbor.getValue() * timeDecay; candScores.merge(candSong, score, Double::sum);这段代码放在recommendForUser的循环体里把“行为发生时间”纳入评分公式。days / 30意味着 30 天前的行为权重降到 37% 左右90 天前基本可以忽略。这个参数我建议你拿到自己的数据上先画一张衰减曲线再决定半衰期是 30 还是 45 天——没有统一最优值只有“符合你的数据分布”的值。6.2 用一段 50 行的离线评估脚本说服答辩老师评估模块我会写成一个独立的EvaluationJob跑完输出四个数字推荐总次数、命中次数、Precision10、召回率。这四个数字就是整个项目的“成绩单”比你贴十张界面截图都有说服力。要注意的是评估切分规则按用户行为时间排序后取前 70% 做训练而不是随机切分——随机切分会把未来发生的事情混进训练集评估结果虚高答辩时被问到反而露怯。我自己的习惯是把所有调参记录写进一个tuning.md每改一个参数就留一行结果。比如“cosine 时间衰减30天 热度惩罚0.5 → Precision100.214”。虽然麻烦但答辩前翻一遍这些记录你能立刻讲清楚每个参数改动的动机评委也会觉得你在这个项目上真的下了功夫。这几年帮同学看过不少推荐系统毕设翻车最多的往往不是算法本身而是“矩阵没转置”“缓存没有过期时间”“热门歌没惩罚”这些细节。算法选型、数据建模、工程链路、评估验证四个环节都踩过坑之后这个题目才算真正跑通。希望这篇笔记里的经验和参数能让你少走几段弯路也希望你在调通第一次推荐结果时把那些参数变化记录在案——那是这个项目里最宝贵的部分。本文还有配套的精品资源点击获取