☰
Spring Boot+协同过滤:在线音乐个性化推荐APP实践
2026/10/21 3:21:52 网站建设 项目流程

1. 项目背景、定位与设计思路

1.1 做这个项目的起因与核心目标

我第一次有这个想法,是在某次跟朋友聊音乐App的时候。他发现自己的播放列表已经很久没有新增歌曲了,每天翻来覆去就是那几首老歌,算法推过来的歌要么不感兴趣,要么推送得太重复。这个场景很典型:曲库越做越大,但用户真正能找到新歌的路径却越来越窄。我当时就在想,如果让我来做一套带个性化推荐的音乐播放后端,技术难点其实不在“能放歌”,而在“怎么知道你想听什么”。

这个项目最初的定位并不复杂:做一个基于Spring Boot的在线音乐个性化推荐APP,覆盖用户注册登录、歌曲浏览搜索、歌单收藏、播放记录采集,以及最核心的“推荐个歌给你听”模块。整套系统要有移动端可交互的界面,后端能撑起基本并发,推荐模块不能是摆设,得能跑通“采集行为—计算偏好—产出推荐”的完整链路。

为什么不选其他框架而用Spring Boot?原因很现实:Spring Boot的自动配置极大减少了项目搭建时间,内嵌Tomcat让部署从“搞一个外部容器”变成“直接java -jar跑起来”;配合Spring生态里的校验、缓存、定时任务等组件,几乎每一种业务需求都有成熟方案,不需要自己造轮子。对个人开发者或者团队协作来说,这套技术栈的学习成本低、排错资料多,是最不折腾人的选择。

1.2 功能边界与整体思路拆解

项目立项时我就给自己划了一条功能红线:宁可少做几个功能,也要把主链路的体验做到位。最终确定的功能集包括:

  • 用户体系:注册、登录、个人信息维护,基于JWT做无状态鉴权。
  • 歌曲与歌单:歌曲信息管理、歌单创建与收藏、歌手/专辑基础资料。
  • 行为采集:播放、收藏、分享、听歌时长等核心行为记录,后台自动落库。
  • 推荐模块:个性化推荐流、相似歌曲推荐、热门榜单补位。
  • 管理后台:歌曲导入、用户数据查看、推荐策略参数调整。

推荐、播放、收藏这三条线是整个系统的骨架。播放记录是推荐模块的输入,收藏行为是用户偏好的强信号,歌单则是内容组织和推荐的中间载体,所有设计都围绕“行为—偏好—推荐”这条闭环展开。

我在技术方案选型时还特意做了一个取舍:不引入消息队列,不做复杂的分布式架构,就靠一台服务器、一个MySQL数据库、一个Redis缓存来支撑全部业务。原因很简单——这类项目最容易犯的错误是过度设计。Kafka、ES、微服务这些玩意儿一旦加进来,光是调试环境就够折腾一两个星期,而项目真正的价值在于推荐算法是否真的有效、接口是否稳定,不在于技术栈够不够花哨。先把单体架构做扎实,后期确有需要再进行拆分,这是我一直坚持的原则。

2. 技术选型、系统架构与数据模型设计

2.1 后端技术栈选择与理由

后端技术栈我最终敲定如下:Spring Boot 2.7、MyBatis-Plus、MySQL 8.0、Redis 6.x、Maven、JWT。前端部分采用了跨端方案,以Vue语法为基础打包成APP,同时保留H5入口便于演示和测试。

这里有一个细节值得说清楚——为什么用MyBatis-Plus而不是Spring Data JPA?因为音乐类业务的数据查询非常看重可控性,尤其是推荐模块要频繁执行带复杂条件的SQL,JPA的自动SQL生成在优化时让人抓狂。MyBatis-Plus兼顾了CRUD的自动化(BaseMapper直接提供单表操作)和SQL手写的灵活性,遇到多表联查、子查询、分页统计,写XML比折腾JPQL直观得多。更何况推荐计算中经常要批量取几千条用户行为数据,这种查询用MyBatis控制度更高,执行计划一眼就能看懂。

Redis在这里承担了三个任务:登录会话与Token缓存、热门榜单实时排序、推荐结果的短期缓存。前两个是常规用法,第三个是很多人容易忽略的:推荐模块如果每来一个请求都现算一遍相似度,数据库会被打爆,所以离线算好的推荐结果先写Redis,在线直接读,同时设置合理的过期时间保证数据新鲜度。

2.2 后端分层架构设计

整个后端项目按常见的四层结构组织:Controller层、Service层、Mapper层、Model层。除此之外,我单独拆了一个recommend包来放推荐相关的计算服务,没有把推荐逻辑混进普通的业务服务里。

com.example.music ├── controller # 接口层:用户、歌曲、歌单、推荐、统计 ├── service # 业务层:核心业务编排 │ ├── user │ ├── song │ ├── playlist │ └── recommend ├── mapper # 数据访问层:MyBatis-Plus Mapper ├── model # 实体、DTO、VO ├── config # 配置类:Redis、拦截器、跨域 ├── common # 通用工具、异常处理、常量 └── recommend # 推荐计算:数据预处理、相似度、策略

把推荐逻辑单独隔离出来是关键决策。推荐模块的代码最大特点是不稳定——今天换算法、明天调参数、后天引入新特征,如果跟业务代码搅在一起,改一次就要全量回归测试,得不偿失。isolated独立包的好处是:业务接口层永远只关心“给我用户ID,返回推荐列表”,至于底层是协同过滤还是热榜填充,随时可以替换。

Controller层的设计遵循一个原则——只做参数接收和结果封装,不做业务判断,所有异常交到全局异常处理器统一处理。Service层里我用了接口加实现的方式定义,倒不是为了强行套设计模式,而是为了后续测试时方便Mock,以及推荐算法有多个版本时可以按策略切换实现类。

2.3 数据库设计与核心表结构说明

数据库一共设计了九张核心表,其中跟推荐模块强相关的是行为记录表和用户偏好表。我把几个核心表的结构和大家过一下。

用户表(user)需要重点提的不是账号密码字段,而是两组辅助字段:age、gender、region组成的画像字段,以及user_tag这种逗号分隔的标签字段。画像字段用于冷启动阶段的“相似用户”计算,标签字段则服务于基于内容的推荐,这两类数据在用户注册时可能不全,但能收集多少算多少。

歌曲表(song)除了基础字段外,有一个song_tag字段特别重要。我给每首歌打了风格标签,比如“摇滚”“民谣”“电子”“华语流行”,推荐模块在做内容相似度匹配时主要靠这个字段。另一个是play_url和cover_url,存储文件访问路径而不是文件本身,文件统一放在服务器静态目录或OSS上,避免数据库读大字段拖慢查询。

播放记录表(play_record)是整张数据量膨胀最快的表。字段设计上我记了listen_duration_seconds(实际听歌时长)、finish_rate(完成率)、behavior_type(1播放,2收藏,3分享,4跳过),其中finish_rate是算用户真实兴趣的核心信号——只播三秒就切歌和完整听完,对偏好的贡献完全不同。

CREATE TABLE play_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, song_id BIGINT NOT NULL, behavior_type TINYINT NOT NULL, play_duration_seconds INT DEFAULT 0, song_duration_seconds INT DEFAULT 0, scene VARCHAR(32) DEFAULT '', create_time DATETIME NOT NULL, INDEX idx_user_time (user_id, create_time), INDEX idx_song_time (song_id, create_time) );

索引设计上我特意没做联合索引,而是拆成了两个单列索引。因为播放记录的查询模式有两个方向:一个是“某用户最近听了什么”用来算用户相似度,另一个是“某首歌最近被哪些用户听了”用来算物品相似度。两个方向都要高频执行,做成联合索引反而没法同时覆盖两种场景。

还要提醒一点:播放记录表没有任何更新操作,数据只增不改。按一个月一千万条的量级估算,半年下来就是六千万条,所以建表时我加好了按月份分表的预留逻辑,业务层对play_record的操作全部走分表路由工具类,这个坑后面会细说。

3. 个性化推荐模块:核心算法与工程落地

3.1 推荐算法选型:为什么不是“高大上”的深度学习

推荐模块的实现是整个项目的重头戏。很多人一提到个性化推荐,第一反应就是上深度学习模型,但在这个项目的实际约束下——数据量级有限、团队人力有限、算力有限——深度学习并不能发挥优势。数据量不够,再复杂的模型也训练不出来;就算能训练,部署维护的成本也扛不住。所以我把推荐模块定位为:以协同过滤为主体,以内容标签为辅,以热榜兜底的三层混合策略。

主体算法采用基于物品的协同过滤(Item-Based Collaborative Filtering),核心逻辑是:如果用户A喜欢歌曲X,而歌曲X和歌曲Y经常被同一批用户同时喜欢,那么歌曲Y大概率也会被用户A喜欢。以歌推歌的思路在音乐领域非常成熟,因为它不依赖用户的历史长序列,只需要用户有过少量行为记录就能产生推荐结果。

相似度计算采用余弦相似度。对歌曲i和歌曲j来说,每个用户对它们的打分向量在N维空间里的余弦值就是它们的相似度,公式为:

sim(i, j) = Σ(u_r_i * u_r_j) / (√Σ(u_r_i)^2 * √Σ(u_r_j)^2)

其中u_r_i表示用户u对歌曲i的评分。这个值越接近1,代表共同喜欢这两首歌的用户比例越高。实际代码里,我不会对全量曲库做两两相似度计算,那样复杂度是O(N²),曲库五万首歌就意味着十二亿次计算。折中方案是:先按歌曲风格标签粗筛候选集,把同风格或风格交叉的歌曲圈出来,再在候选集内做精细相似度计算,复杂度直接降一个量级。

3.2 用户行为权重与评分映射

协同过滤算法的输入是“用户对物品的评分矩阵”,但用户并没有真的在给歌打分,评分是从行为里映射出来的。每个行为在项目里的权重如下:

行为类型权重值说明
完整播放1.0正常听完整首歌
收藏2.5强兴趣信号,愿意收藏说明很满意
分享3.0最强的正反馈,主动传播
部分播放(完成率>60%)0.8听了一半以上
部分播放(完成率<30%)0.2弱信号,可能是随手点的
快进后切歌-0.5负反馈,用于偏好矫正

这个权重表并不是我凭空拍脑袋定的,而是参考了业内通用做法后结合实际情况微调出来的。收藏和分享的权重高于播放,是因为它们的操作成本更高——播放只需要点一下,收藏和分享需要用户主动表达态度,信息含量完全不一样。负反馈信号也很重要,如果用户听了不到十秒就切歌,还持续给他推这首歌,那就是灾难。

行为映射成评分后,还要加时间衰减因子。用户的兴趣是会发生漂移的——三个月前疯狂听的电音,现在未必还有兴趣。所以评分矩阵里的每个值,在进入计算前都会乘以衰减系数:

weight = base_weight * exp(-λ * age_days)

λ在这里取0.02,意思是距今30天的行为权重衰减到原来的55%,距今60天衰减到约30%。这个参数是可以通过后台配置动态调整的,不需要重新编译部署。

3.3 在线流程与离线计算的配合

推荐模块跑起来之后我遇到一个很现实的问题:用户的行为数据是实时产生的,但相似度计算却非常耗时,根本不可能在用户请求推荐的时候现算。解决思路是把计算拆成两块——离线计算和在线计算。

离线计算由定时任务触发,每天凌晨两点执行一次。任务流程如下:

  1. 从play_record全表拉取最近180天的有效行为数据;
  2. 按用户分组,生成用户—歌曲评分矩阵;
  3. 执行物品相似度计算,得到歌曲相似度表;
  4. 对每个活跃用户,根据其历史行为生成Top-N候选集(取相似度最高的50首歌);
  5. 将候选结果集写入Redis,推荐接口直接读取。

在线计算做的东西就轻量多了。用户登录后打开推荐页,后端先去Redis拿离线算好的候选推荐,如果有就直接返回;如果没有(说明用户是太新的用户,还没进入离线计算流程),就实时走一条“快速偏好识别”逻辑:取该用户最近最常听的三个风格标签,从标签对应的歌曲里按热度挑一批补上,同时混入一定的全局热门歌曲。

这套离线+在线的配合方案,本质上是用“算力换时间”——把耗时的相似度计算挪到低峰期完成,在线环节只做轻量查询和策略组合。Redis里存的key我是这么设计的:

rec:user:{userId}:daily # 离线算好的每日推荐列表 rec:user:{userId}:fresh # 在线实时生成的冷启动列表 rec:hot:{date} # 当天热门歌单

每个key都设了过期时间,daily类型24小时过期,fresh类型2小时过期。这样即使用户当天的推荐结果有问题,第二天也会自动刷新,不会永远困在一个错误的推荐列表里。

4. 核心业务功能实操要点

4.1 基于JWT的登录鉴权实现

用户登录模块看起来简单,但Spring Boot项目里能踩的坑其实不少。我采用JWT(JSON Web Token)做无状态认证:用户登录成功后,后端签发一个有效期为7天的Token返回给前端,前端每次请求在Header里带上Authorization字段。

// Token签发核心代码示意 String token = Jwts.builder() .setSubject(userId.toString()) .claim("nickname", user.getNickname()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();

这里要特别提醒两个点。第一,签名密钥必须放在后端配置文件中,绝对不能打进前端包,否则任何人都可以伪造Token;第二,JWT是无状态的,服务端没法主动让某个Token失效,如果用户想注销登录或者密码泄露,只能靠前端删除Token解决。如果项目对安全性要求更高,可以把Token的jti字段存到Redis里标记黑名单,每次请求时检查,这是标准做法,本项目中我采用了Redis黑名单方案。

拦截器的实现也很直观:写一个HandlerInterceptor,在preHandle方法里校验Header里的Token,校验失败直接返回401,校验通过就把解析出的userId放入ThreadLocal,后续Service层可以直接取当前登录用户的ID,不用在接口参数里到处传。

4.2 播放记录采集与场景区分

播放记录采集的埋点设计很考验细节。前端在不同场景播放歌曲时,传入的scene参数不同——首页推荐流播放、搜索页播放、歌单页播放、歌手页播放。这个参数看似不起眼,但对推荐算法有重大意义:用户从推荐流点进去听歌,和用户从搜索里主动找歌听,行为含义完全不同。前者是“被动接受推荐”,后者是“主动表达偏好”。

采集时机上我没有采用传统的“开始播放时记录”,而是伴奏播放过程中每15秒向后端上报一次心跳,由后端合并成一条完整的播放记录。这样做的好处是能准确知道用户听了多长时间、在哪一秒切歌,坏处是对接口压力大。我做了个折中:单次播放不足60秒的,前端合并成一条“短播放”行为上报;超过60秒的,每30秒上报一次心跳,后端只在最终结束时写库。

播放完成率是计算用户偏好的重要信号。如果用户开始播放一首5分钟的歌,完整听完了,记1.0权重;只听了一分钟就切歌,完成率只有20%,记0.2权重。这组数据在后端是可以拿到并计算的,因为play_record表里同时存了play_duration_seconds和song_duration_seconds两个字段。

4.3 热门榜单与Redis实时排序

热门榜单是推荐冷启动阶段的重要补充。实现方式并不复杂,但很有代表性:用Redis的ZSet(有序集合)来维护热门歌曲排行。

ZINCRBY hot:song:today 1 1001

每一首被播放的歌,ID作为member,播放次数作为score。播放一次就执行一次ZINCRBY,自动排序。前端要拿Top10时,直接执行:

ZREVRANGE hot:song:today 0 9 WITHSCORES

这个做法的妙处在于:完全不需要写任何SQL统计,也不需要在数据库里维护计数表,Redis天然支持高并发写入和按分数排序,一次操作O(log N)复杂度,扛住日常流量绰绰有余。

需要注意的一点是ZSet的key带日期后缀,每天一个key,然后设置30天过期时间。这样既不用手动清理历史数据,又能天然支持“昨日榜单”“一周热门”等多维度排行。一周热门的数据来源有两种做法:一种是每天定时执行ZUNIONSTORE把七天数据合并,另一种是每次播放同时写当日key和周key,我选了后者,逻辑简单且实时性更好。

4.4 歌曲搜索与模糊匹配优化

搜索模块最开始直接用了MySQL的LIKE '%关键字%',曲子少的时候勉强能用,数据量上万之后明显变慢。后期我优化成了前缀匹配+分词匹配结合的方式。用户输入关键字后,先做前后缀模糊匹配,匹配不到再走分词匹配。为了不影响主库性能,搜索查询全部走了从库,并把热门搜索词做了Redis缓存。

如果项目再往下走,我会把搜索模块平滑迁移到Elasticsearch或者是MySQL全文索引。但当前阶段没有引入额外中间件的必要,核心业务先跑通比啥都重要。

5. 实际开发中踩过的坑与解决方案

5.1 推荐冷启动问题:新用户没有行为数据

冷启动是推荐系统永恒的话题。项目上线初期,我一个新用户注册进来,后台其实没有任何他的行为数据,离线计算根本没法产出推荐结果。最开始我的做法是直接给他返回热门榜单,结果用户看到的东西跟自己毫无关系,App第二天就卸载了。

后来我做了两处改进。第一,注册阶段就开始采集画像信息,在注册表单里让用户至少选择三个喜欢的风格标签,这些标签直接进user偏好表。第二,冷启动推荐不再只推大而全的热门歌,而是把热门歌先按风格分类,用户选了“民谣”就把民谣类热门歌优先排,没有选标签的用户才推全局热门。这套组合拳下来,冷启动用户推荐流的点击率明显提升,最直接的数据就是:次日留存比纯热门推荐版本提升了十几个百分点,数据不一定精确,但趋势非常明显。

5.2 推荐结果“同质化”:越推越窄的问题

协同过滤有个通病——推荐结果会越来越集中,用户听过的每一首歌都能找到相似歌曲,导致推荐流里全是同曲风、同歌手的作品。我遇到过用户反馈:“我只不过多听了几首民谣,就开始给我推一整屏民谣,听腻了。”

解决这个问题的思路是引入多样性控制。在生成Top-N推荐列表时,我加了一个简单的MMR(最大边际相关性)算法,核心逻辑是:每轮选歌时不只参考相似度分数,还要看这首歌跟已经入选歌曲的差异程度,差异太小的歌即使分数高也要让位。

score = λ * similarity(user, song) - (1-λ) * max_similarity(selected, song)

这里λ控制推荐的相关性和多样性平衡,我初设了0.7,后来调到0.65才让推荐流既能匹配兴趣又不至于太重复。这个参数在后台配置里可以动态调整,运营人员可以实时干预推荐表现,不用每次改代码。

5.3 Redis缓存穿透与缓存雪崩

推荐接口的Redis缓存每天凌晨定时刷新,这引发了一个经典问题:所有key同时过期。早上八点用户集中打开App,缓存刚好全被清除,推荐接口瞬间回源数据库,数据库压力飙升。一次压力测试中,我甚至看到MySQL的CPU直接跑到90%。

解决办法有两个层面。第一是过期时间加随机扰动,避免集中失效;第二是对热点推荐key做了永久缓存+定时主动刷新机制。主动刷新比等待过期再查库更可控——定时任务在凌晨算完推荐结果后直接写入Redis,覆盖旧值,这样用户请求永远能拿到缓存数据,根本不会穿透到数据库。这个思路也建议大家在别的场景复用。

5.4 播放记录表膨胀后的慢查询

上线三个月后,play_record表的数据量突破千万级,相似度计算任务的执行时间从最初的二十分钟飙升到两小时,差点跑不完就被下一次任务覆盖。排查慢查询日志发现,问题集中在两个方向:全表扫描拉取行为数据,以及JOIN语句走了错误的索引。

我做了三个优化:

  • 按月份拆表,每个月的数据放在独立表里,查询加上时间条件自动路由到对应分表;
  • 相似度计算改成“增量+全量”结合:每天只对新增行为做增量更新,每周跑一次全量校准;
  • 数据压缩,超过180天的弱信号行为定期归档到冷表,主表只保留有效窗口内的数据。

这轮优化之后,计算任务的时间稳定在15分钟内,推荐结果还因为数据集中度提高而变得更精准了。数据量大并不是最可怕的,最可怕的是没有一套数据治理策略来应对持续膨胀。

5.5 接口层常见的异常处理问题

项目开发过程中,接口返回的异常信息一开始是全裸奔的——SQL异常直接堆栈抛出,前端拿到500报错完全看不懂。后来我加了全局异常处理器,统一返回结构为“状态码+提示信息+数据”,并细化了业务异常类型。比如用户不存在返回自定义的UserNotFoundException,歌曲已下架返回SongOfflineException,前端可以根据状态码精确提示用户,而不是简单弹个“系统错误”。

6. 项目复盘与演进方向

6.1 技术之外的收获与踩坑心得

这个项目做下来,我个人感触最深的一点是:推荐系统不是算法比赛,而是工程约束下的策略权衡。真正影响最终效果的不一定是模型多先进,更多是行为数据怎么采集、权重怎么设置、缓存怎么设计、冷启动怎么兜底这些工程细节。算法模型的差距可以通过调参弥补,但数据采集的缺失是补不回来的。

后端架构的能力也得到了一次完整检验。Spring Boot提供的生态组件覆盖了项目绝大多数需求,确实省去了大量重复工作。但如果再来一次,我会在项目初期就引入统一的时间戳字段规范、接口文档自动化,以及更严格的分层约束——这些基本功写代码时就要定好,后期改造成本极高。

6.2 推荐模块还能怎么扩展

如果这个项目继续往下做,我会优先考虑两个方向。第一是引入用户实时行为流,通过简单的规则引擎做实时推荐修正:比如用户刚搜索了“粤语老歌”,接下来几小时内推荐流里粤语歌曲就应该有加权。第二是尝试多路召回+排序的标准化框架,用规则召回、协同过滤召回、标签召回跑出多个候选集,再用一个简单的LR排序模型做打分融合。这个思路不是为了让模型变复杂,而是让推荐结果对用户的真实兴趣变化更敏感。

7. 最后想分享的一个小技巧

最后聊一个我在开发中反复验证有效的细节——推荐结果的“解释性”。我后来在推荐接口的返回VO里增加了reason字段,告诉用户“推荐这首歌是因为你常听某歌手的作品”或者“收藏这首歌的用户也收藏了你正在听的一首歌”。就这么一行字段,让推荐功能从“玄学”变成了用户可理解、可信任的功能,用户对推荐结果的反馈率也随之提升。

在线音乐个性化推荐APP的核心从来不只是能播歌,而是把合适的内容在合适的时机推给合适的人。代码可以优化,算法可以替换,但围绕这个核心持续做精细化迭代,才是这个项目真正值得深耕的地方。

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

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

立即咨询