协同过滤推荐系统实战:Spring Boot+Vue+MySQL从算法到落地
简介基于协同过滤算法的音乐推荐系统完整源码包采用Spring Boot、MyBatis、MySQL搭建后端服务使用Vue.js构建前台用户端与后台管理端实现前后端分离开发适合具备一定Java基础的开发者、计算机专业学生用于课程设计或毕业设计。压缩包共包含九百零一个文件体积约三十四点零一兆源码层面有一百零四个Java服务端类、八十四个Vue组件、一百三十二个JavaScript脚本素材层面含一百一十五首MP3样例、二百四十张JPG页面配图另有SQL数据库脚本、XML配置、SCSS样式、Maven构建及gitignore、eslintrc等工程规范文件目录按用户端与管理端分离模块归属清楚。目前已有8688人学习下载资源在推荐系统教学与音乐网站起步开发场景中具有较高参考价值。项目以协同过滤推荐为核心涉及用户行为记录、歌曲信息维护、相似度计算、个性化列表生成等完整环节建议结合SQL初始化脚本和推荐Service实现来阅读这样既能快速跑通前后端联调也能深入理解推荐算法在真实Web项目中的落地过程。1. 推荐列表一年没换过歌协同过滤系统怎么把新歌推到首页你打开音乐 App首页推荐的十首歌里八首是听了半年的老面孔自己最近反复循环的新单曲一首都没出现。这种推荐翻车不是算法玄学而是工程链路里某个环节没闭环——要么行为数据没采全要么算法算完结果没落库要么冷启动阶段直接给了空列表。这次拆的这份源码是一个 Spring Boot Vue 前后端分离的音乐推荐系统核心协同过滤算法MySQL 存用户、歌曲与行为数据离线计算脚本单独跑相似度和 TopN 推荐算完写回数据库在线接口只做查询。它适合正在做毕业设计、或者想搞懂推荐算法怎么真正跑进 Web 系统的后端开发者你能看到从行为采集到推荐上屏的完整链路。2. 系统拆解Spring Boot Vue MySQL 的职责划分与数据库四张表2.1 技术选型为什么这套组合能把推荐链路跑通而不是跑死如果只是想验证协同过滤算法用 Python 跑 Jupyter 就能出结果。但这份源码把范围拉到了「Web 系统真实可用」——你要有账号体系、歌曲管理、行为采集、推荐展示这些全是工程问题不是算法问题。Spring Boot 在这条链路里承担接口与业务编排内嵌 Tomcat打包成 jar 就能跑部署成本低Vue 负责前端交互组件化开发非常适合推荐列表这种重复渲染的场景MySQL 管数据落盘四张核心表关系清晰。关键决策是把协同过滤计算放在 Python 离线脚本里而不是 Java 代码里。原因很直接pandas 和 NumPy 处理矩阵比 Java 顺手太多余弦相似度计算用向量化运算几行就写完而 Spring Boot 侧只查推荐结果表完全不碰矩阵运算接口响应时间就能压得很低。这种「Python 离线算 Java 在线读」的混合架构在中小型项目里是常见做法规避了引入重型 Java 机器学习依赖的麻烦。为什么不干脆全用 Python 后端因为业务接口涉及用户权限、歌曲分类、后台管理Spring Boot 做这些成熟顺手而且不少毕设题目本身就点名要求用 Spring Boot。前端选 Vue 也因为生态成熟Element UI 做管理界面、轮播图组件都有现成的不用自己从零写。2.2 数据库表设计user、song、behavior、recommend 四张表怎么建推荐系统的数据模型核心是「用户-物品-行为」三元组。用户表存账号信息歌曲表存元数据行为表记录用户对歌曲的每一次操作推荐结果表存算好的内容。这四张表是整条链路的骨架建表时几个坑要先避掉。CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE song ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, singer VARCHAR(50) NOT NULL, category VARCHAR(30) DEFAULT 流行, duration INT DEFAULT 0 COMMENT 时长单位秒, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE behavior ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, song_id BIGINT NOT NULL, behavior_type TINYINT DEFAULT 1 COMMENT 1-播放 2-收藏 3-下载, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_song (user_id, song_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE recommend ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, song_id BIGINT NOT NULL, score DECIMAL(10, 6) NOT NULL, rank INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个设计决策值得展开。ID 字段全部用 BIGINT不用 INT。MySQL 的 INT 上限是 21 亿左右用户量和歌曲量一旦涨上去就头疼更重要的是 Python 的 pandas 在读回 DataFrame 时INT 列经常被转成 float导致 ID 变成3001.0写回数据库时类型对不上——这个坑后面章节专门讲所以建表时直接用 BIGINT 能减少一层风险。behavior_type 用 TINYINT 存枚举值不直接存字符串。播放、收藏、下载三种行为在 SQL 里聚合统计时数字比字符串高效得多占用空间也小。前端展示文案在 Java 侧映射就行。behavior 表建立idx_user_song联合索引因为两种主流协同过滤算法——UserCF 算用户相似度、ItemCF 算物品相似度——第一步都是按用户和歌曲分组聚合行为数据。没有这个索引几万行记录时查询就会全表扫描。recommend 表的 score 字段用 DECIMAL(10, 6)最大支持 9999.999999协同过滤算出的相似度得分通常在 0 到 10 之间这个精度足够。rank 字段存推荐位次前端排序直接用避免接口层再排序一次。一个容易忽略的点离线脚本每次重算推荐结果是「先清空整表再插入」数据量小的时候没问题。如果用户量上到十万级每次 DELETE 全表再 INSERT 会产生大量 binlog 和锁竞争。我一般建议改成按 day 分区或者先写入一张临时表再通过 RENAME TABLE 切换这样接口查询完全不受影响。这份源码数据量几千用户时直接清空重建是可以接受的。3. 协同过滤算法落地UserCF 与 ItemCF 的选择逻辑和离线计算脚本3.1 相似度算法怎么选一张对比表看清两种协同过滤的边界协同过滤分两大类基于用户的 UserCF 和基于物品的 ItemCF。两者都有成熟的理论基础但落到实际项目里选择不是看哪个更「高级」而是看数据规模和业务场景。先看一张对比表。维度UserCFItemCF计算对象用户与用户之间的相似度物品与物品之间的相似度适用场景用户量小、兴趣标签垂直物品量大、用户行为稀疏冷启动表现新用户无行为完全无法计算新用户只要有 1-2 次播放就能推相似歌曲可解释性难解释总不能说「和你相似的人也听了这首」容易解释「因为你收藏了《晴天》推荐周杰伦同类曲目」线上代价用户相似矩阵随用户增长快速膨胀物品相似矩阵相对稳定音乐推荐场景我基本倾向于 ItemCF。原因很务实一个音乐系统里用户量通常远大于歌曲量几万用户对应几千首歌用户相似矩阵是几万乘几万的稠密矩阵内存和计算量都吃不消而歌曲相似矩阵只有几千乘几千算完一次可以稳定用很久。行为稀疏问题上 ItemCF 也占优——新用户只要点过一两首歌就能基于歌曲相似度给出推荐UserCF 则需要找到一批相似用户才能工作。但这不代表 UserCF 没用。如果一个音乐社区主打相同口味用户聚集比如「听 Post-Rock 的人都在关注这个歌单」UserCF 的社交属性更强。所以这份源码把两种算法都实现了通过配置文件切换第 6 章会讲具体怎么配。你现在只要理解默认跑 ItemCF用户行为量积累到一定程度再对比两种算法的线上效果。3.2 Python 离线脚本读行为数据、算相似度、把 TopN 写回 recommend 表协同过滤离线计算的核心流程分五步连接数据库读取行为数据、按行为类型赋予权重、构建用户-物品矩阵、计算物品间余弦相似度、为每个用户聚合出 TopN 推荐。下面这份脚本是完整可跑的也是整个推荐系统的核心。import pymysql import pandas as pd import numpy as np from collections import defaultdict # 连接 MySQL读取全部行为数据 conn pymysql.connect(hostlocalhost, userroot, password123456, databasemusic_rec, charsetutf8mb4) df pd.read_sql(SELECT user_id, song_id, behavior_type FROM behavior, conn) # 行为权重映射播放 1 分收藏 2 分下载 3 分 weight_map {1: 1.0, 2: 2.0, 3: 3.0} df[weight] df[behavior_type].map(weight_map) df df.groupby([user_id, song_id], as_indexFalse)[weight].sum() # 构建用户-物品矩阵行是用户列是歌曲值是该用户对歌曲的累计行为权重 matrix df.pivot_table(indexuser_id, columnssong_id, valuesweight).fillna(0) # 计算物品之间余弦相似度 item_vectors matrix.T.values n_items item_vectors.shape[0] similarity np.zeros((n_items, n_items)) for i in range(n_items): norm_i np.linalg.norm(item_vectors[i]) if norm_i 0: continue for j in range(i 1, n_items): norm_j np.linalg.norm(item_vectors[j]) if norm_j 0: continue dot np.dot(item_vectors[i], item_vectors[j]) sim dot / (norm_i * norm_j) similarity[i][j] similarity[j][i] sim # 为每个用户生成 TopN 推荐 top_n 10 result {} for user_id in matrix.index: user_row matrix.loc[user_id] # 已经播放/收藏/下载过的歌曲不再推荐 watched set(user_row[user_row 0].index) scores defaultdict(float) for song_id in watched: song_idx list(matrix.columns).index(song_id) # 取与当前歌曲相似度最高的前 20 首作为候选池 candidates np.argsort(similarity[song_idx])[::-1][:20] for idx in candidates: similar_song matrix.columns[idx] if similar_song in watched: continue # 累加得分用户对原歌曲的偏好 × 两首歌的相似度 scores[similar_song] user_row[song_id] * similarity[song_idx][idx] recommended sorted(scores.items(), keylambda x: x[1], reverseTrue)[:top_n] result[user_id] recommended # 清空旧推荐结果写入新计算的 TopN cursor conn.cursor() cursor.execute(DELETE FROM recommend) for user_id, items in result.items(): for rank, (song_id, score) in enumerate(items): cursor.execute( INSERT INTO recommend (user_id, song_id, score, rank) VALUES (%s, %s, %s, %s), (int(user_id), int(song_id), round(float(score), 6), rank) ) conn.commit() conn.close()这段脚本里有几个参数直接影响推荐效果逐个说清楚。weight_map 的行为权重是我调过很多次的。播放是最低成本的消费行为用户可能只是随机切歌权重给 1收藏代表明确偏好给 2下载是强意图行为通常用户要离线听给 3。你可以根据产品需求调整——如果更看重完播率就加一个「超过 30 秒算完整播放」的中间值如果收藏行为很少把收藏权重提高到 4 也行。fillna(0)这一步是稀疏矩阵的常规处理。真实场景里大多数用户只听几十首歌矩阵里 95% 以上的位置是 0。余弦相似度公式里模长为 0 的向量要跳过脚本里用if norm_i 0做了保护否则除零错误会直接中断任务。选余弦相似度而不是皮尔逊相关系数是我在稀疏数据上调出来的经验。皮尔逊需要每个向量减均值在只有一两个非零项的向量上均值本身就不稳定算出来的相关系数会震荡得很厉害。余弦相似度只依赖向量夹角对数值尺度不敏感在稀疏音乐场景下表现更稳。候选池大小[:20]和top_n 10是控制计算量和推荐效果的平衡点。候选池过小推荐结果会局限在几首热门歌里绕圈候选池过大长尾歌曲的噪声会被放大。我一般先用 top 20 候选跑一周看推荐结果的点击率再决定要不要放大到 30。最后写入部分有个细节int(user_id)强转因为 pivot_table 之后索引类型可能已经变成 float不转的话 SQL 参数绑定会报错或者查不到数据。这个坑踩过的人不在少数。提示删全表重建在数据量超过十万行时不要这么干。按 user_id 范围分批删除或者用临时表替换正式表能避免锁表导致接口查询阻塞。4. 前后端打通与冷启动Spring Boot 接口、Vue 展示、热门兜底三层链路4.1 推荐结果落库后Spring Boot 接口怎么把歌曲返回给前端离线脚本算完的推荐数据躺在 recommend 表里Spring Boot 的职责就是把它查出来、和歌曲表关联、包装成前端友好的结构返回。接口设计上要注意一个约束推荐表里只有歌曲 ID 和得分前端展示要的是歌曲名、歌手、封面这些信息所以查完推荐结果还要回表查询歌曲详情。RestController RequestMapping(/api/recommend) public class RecommendController { Autowired private RecommendService recommendService; Autowired private SongService songService; GetMapping(/{userId}) public ResultListSongVO getRecommend(PathVariable Long userId) { // 1. 先查推荐结果表拿到排序后的歌曲 ID 集合 ListLong songIds recommendService.getTopSongIds(userId); if (songIds.isEmpty()) { // 2. 冷启动兜底登录用户没有任何推荐记录时给热门榜 Top20 songIds songService.getHotSongIds(20); } // 3. 批量查出歌曲详情保持推荐顺序 ListSongVO songs songService.listByIdsOrdered(songIds); return Result.ok(songs); } }这个接口的核心思想是「推荐结果已经算好接口不做任何计算」。查询 recommend 表走idx_user索引只返回该用户的最高 10 条记录这个操作在几十万行数据下也能做到毫秒级。你可能注意到接口里用了两个 Service——RecommendService 查推荐结果SongService 兜底热门榜和歌曲详情。职责分清楚后后续要加「推荐理由」功能就只在 RecommendService 里改不会污染歌曲模块。SongVO 是独立的视图对象不直接暴露数据库实体避免把 create_at 这类字段无谓地传给前端。开发环境跨域问题也别忽略。Vue 开发服务器通常在 5173 端口Spring Boot 在 8080两者端口不同会产生跨域请求。常见做法是加一个 CORS 配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*); } }生产环境走 Nginx 反向代理后前端请求/api前缀被转发到 8080跨域问题自然消失这个配置只在开发期生效。4.2 Vue 页面怎么把推荐列表展示出来前端不关心推荐算法怎么实现的它只需要在页面加载时请求接口、拿到歌曲数组、渲染成卡片。Vue 3 组合式 API 的写法里逻辑集中在 setup 函数中。script setup import { ref, onMounted } from vue import request from /utils/request const songList ref([]) const loading ref(false) const loadRecommend async (userId) { loading.value true try { const res await request.get(/api/recommend/${userId}) songList.value res.data.data } catch (e) { console.error(加载推荐列表失败, e) } finally { loading.value false } } onMounted(() { // 实际项目中 userId 从登录态获取这里演示用固定值 loadRecommend(1) }) /script template div classrecommend-grid div v-forsong in songList :keysong.id classsong-card img :srcsong.cover :altsong.title / p classsong-title{{ song.title }}/p span classsong-singer{{ song.singer }}/span /div /div /template这里的request是封装好的 axios 实例baseURL 统一从环境变量读取方便后续切换开发、测试、生产环境。前端拿到songList后通过v-for生成卡片样式上推荐页就是横向轮播或者网格列表不需要复杂交互。一个容易被新手忽略的点接口返回的歌曲顺序就是推荐位次前端渲染时不要自己再排序。rank字段虽然存在 recommend 表里但接口返回 List 时已经按 rank 排好序前端保持数组原始顺序即可。如果前端重新洗牌推荐权重就乱了。4.3 冷启动兜底新用户没有行为数据时给什么协同过滤的死穴是冷启动——新用户一条行为都没有根本没法算相似度。成熟产品会用热门榜、编辑精选、新歌尝鲜这些策略兜底。这套源码的兜底逻辑是查行为表里播放量最高的 20 首歌SELECT s.id, s.title, s.singer, COUNT(b.id) AS play_count FROM song s LEFT JOIN behavior b ON s.id b.song_id GROUP BY s.id ORDER BY play_count DESC LIMIT 20;为什么选播放量而不是收藏数做热门排序因为播放行为基数最大一首歌第一天上线就可能有几百次播放而收藏行为要积累几天才有量级。用播放量能在数据稀疏时更快地体现歌曲热度。这条 SQL 在行为表几万行时性能还行量再大了就对每天的行为做增量统计把结果缓存到一张单独的 hot_song 表里。冷启动兜底不只是给新人用。老用户如果长期没回来行为数据可能被定期清理或者算法版本升级导致部分用户推荐为空兜底接口这时候派上用场。接口里songIds.isEmpty()的判断保证用户在异常情况下也有内容可看这是推荐系统可用性的底线。5. 避坑记录从行为稀疏到 ID 错位四个生产环境踩过的坑5.1 行为数据太少协同过滤算出来等于随机榜现象导入几百条测试行为数据后跑推荐发现推荐结果跟用户听歌历史毫无关联甚至把用户根本没听过的冷门歌曲排在最前面。原因单个用户行为记录只有 1-2 条用户-物品矩阵的稀疏度超过 99%。余弦相似度在如此稀疏的向量上算出来的数值全都集中在 0.001 量级排序时微小的数值误差就会导致顺序随机化推荐质量自然不可控。解决离线脚本里加一个行为量阈值判断——用户行为少于 10 条时不参与协同过滤计算直接走热门榜兜底。阈值参数写进配置文件上线初期调低一些让更多用户能收到算法推荐跑一段时间后逐步调高。从那以后我每次试新数据源都先看一眼「用户平均行为数」这个统计量低于阈值就不急着看推荐效果先攒数据。5.2 Python 写回 MySQL 时歌曲 ID 变成浮点数现象推荐接口返回的歌曲 ID 在歌曲表里查不到日志里频繁出现0 rows retrieved但通过 Navicat 手动查 recommend 表发现推荐记录是存在的。原因pandas 的 pivot_table 在处理稀疏索引时会把整型索引自动升为 float64于是歌曲 ID 从3001变成3001.0。用 Python 的 MySQL 驱动写回时这个浮点值直接写入 BIGINT 列会被四舍五入或截断而 Java 侧查询时用的是 Long 类型的3001两边对不上。解决写回数据库前对索引列强制转换类型脚本里int(user_id)和int(song_id)不能省。同时把 MySQL 表结构里的 ID 字段显式定义为 BIGINT不留隐式转换空间。从那以后我凡是 Python 与 MySQL 之间传输 ID 字段一律先astype(int)再入库这个习惯已经成了条件反射。5.3 推荐逻辑放在接口里实时算数据库被打崩现象系统上线当天推荐接口平均响应时间从 50 毫秒涨到 2 秒数据库 CPU 占用接近 100%前端页面直接白屏。原因初版把协同过滤算法写在 Java Service 里每次请求都现场扫描 behavior 表聚合成矩阵、计算相似度、生成推荐。行为表数据涨到几万行后全表扫描加矩阵运算的耗时完全不可控相当于把离线任务硬塞给在线接口。解决彻底改成「离线计算落库 在线只查结果」的架构。Python 脚本定时在凌晨跑完推荐计算结果写回 recommend 表Java 接口只做按用户 ID 查询必要时再加一层 Redis 缓存缓存没有就查 recommend 表回填。改造之后接口响应稳定在 50 毫秒以内数据库压力也降下来了。血泪教训推荐系统在线侧永远不做算法计算。5.4 新上架歌曲永远进不了推荐候选集现象运营连续一周添加新歌但所有用户的推荐列表里从没见过新歌的影子新歌的播放量全靠搜索。原因ItemCF 的候选集来自已有行为数据。新歌没有播放、收藏、下载记录它在相似度矩阵里对应的向量全为 0永远不会被推荐出来。这是基于反馈的推荐系统的通病——没有曝光就没有行为没有行为就没有曝光恶性循环。解决离线脚本生成推荐结果后额外取最近 7 天入库的新歌按 10% 的比例随机混入每个用户的推荐位。这个比例不能太高否则推荐质量会明显下降也不能太低否则曝光效果不显著。经验值 10% 左右既能保证新歌有露出又不至于让老用户觉得推荐列表乱了。运营侧再配合「新歌首发」板块做人工曝光双管齐下效果好很多。6. 进阶算法开关配置化以及增量推荐怎么省算力系统跑稳定之后下一步要解决的就不是「能不能推荐」而是「怎么调得更好」和「怎么算得更快」。两个实用技巧分享给你。第一个技巧是把算法选择做成配置开关。源码里同时实现了 UserCF 和 ItemCF日常跑 ItemCF但想对比效果时需要能一键切换。用 Spring Boot 的配置文件最省事recommend: algorithm: itemcf # 可选 usercf / itemcf top-n: 10 candidate-size: 20 weight: play: 1.0 favorite: 2.0 download: 3.0 cold-start-user-threshold: 10Python 脚本启动时读取这份 YAML算法名决定走哪套计算逻辑权重和阈值都不用改代码。切换算法后可以跑 A/B 测试对比两组用户的点击率、播放完成率、收藏转化率。我自己的经验是用户量低于 1 万时用 ItemCF 就够了UserCF 的优势要等用户画像非常丰富、用户间行为差异明显时才体现得出来。第二个技巧是增量推荐。全量重算的问题是凌晨任务跑的时间越来越长——歌曲从一千首涨到一万首物品相似度矩阵的计算量涨了 100 倍。不需要每天全量重算可以保留上一次的相似度矩阵只把当天新增的行为增量合并进去# 读取最近一天的新行为记录 new_df pd.read_sql( SELECT user_id, song_id, behavior_type FROM behavior \ WHERE created_at DATE_SUB(NOW(), INTERVAL 1 DAY), conn) new_df[weight] new_df[behavior_type].map(weight_map) # 加载上次保存的相似度矩阵更新受影响的行 history_sim np.load(item_sim.npy) # 只用新增行为涉及的歌曲去更新相似度不重新计算全量矩阵全量重算每周做一次放在周末凌晨平时每天只增量更新当天新进入系统的歌曲。这套方案把跑批时间从几小时压缩到十几分钟。歌曲维度有个利好物品相似度矩阵的重算频率本来就可以比用户行为更新频率低很多因为歌曲特征是稳定的。如果连增量计算都觉得重最后的兜底方案是把推荐结果按「用户活跃度」分层——活跃用户每天刷新推荐沉默用户每周刷新一次。沉默用户本来就不怎么打开 App每周更新一次推荐结果既省算力又不会因为长期不更新错过跨周的新热门。从那以后我接手任何推荐项目第一件事永远是问数据团队这张 behavior 表每天涨多少行、峰值在几点。数据规模和数据节奏决定架构选型算法只看排名替代不了这个基本面。希望这套思路能帮到你省去你在数据对齐和任务调度上再翻一次车。本文还有配套的精品资源点击获取