SpringBoot+Vue3电影推荐系统:协同过滤算法实战落地

📅 发布时间:2026/10/17 2:36:59
SpringBoot+Vue3电影推荐系统:协同过滤算法实战落地
在接手这个项目之前我对推荐系统一直抱着敬而远之的态度总觉得那是大厂才玩得转的算法工程要上分布式、要有大数据平台。直到某公司内部搞了个电影点播平台运营发现用户看完首页推荐列表的转化率低得可怜首页堆满高分片却没人点——这才意识到推荐这层逻辑再不上整个平台的留存数据会越来越难看。本着先跑通再优化的思路我把目标锁定在一个单机能扛住、代码量可控、又能真正体现推荐效果的技术栈上SpringBoot Vue3 MyBatis MySQL配合协同过滤算法做一套前后端分离的电影推荐系统。这篇文章就围绕这套系统的完整落地过程展开从算法选型、数据库设计、核心代码实现、前后端联调一直到实际踩坑记录尽量把关键细节都交代清楚。1. 为什么偏偏是协同过滤三种推荐方案的取舍1.1 内容推荐的局限标签无法描述感觉接手这个项目后团队里最初讨论过要不要走内容推荐路线也就是让运营手工给每部电影打标签类型、导演、主演、年代、主题关键词然后根据用户看过的电影匹配同标签内容。听起来很合理但真正落地时问题很大——标签体系怎么定《教父》和《愤怒的公牛》都算犯罪和传记可喜欢《教父》的人未必吃《愤怒的公牛》这种强压抑感的镜头语言。换句话讲内容推荐期望用显性的标注去表达隐性的观影偏好这个过程损耗极高而且运营逐部维护标签的工作量根本不现实几百部片子上限也许勉强能撑几千部就是一整支团队的长期负担。1.2 协同过滤的核心优势让用户行为自己说话协同过滤的思路恰好绕开了理解电影内容这一步。它不关心电影讲的是什么只看用户行为矩阵A和B的评分行为高度相似那B喜欢而A没看过的电影就有很大概率被A接受。这种做法本质上是把人工特征工程替成了人群行为归纳不需要运营参与系统自己从评分数据里学规律。电影推荐系统选协同过滤还有个天然好处电影评分是一个相对稳定的行为不像资讯流那样以秒级变化一天之内的评分数据足够支撑相似度计算却又不至于频繁到把矩阵打散。1.3 第三种方案混合推荐的现实价值当然真正上线时我做了个折中——纯协同过滤在冷启动阶段会比较难看所以又在首页推荐接口里加入了兜底逻辑当某个用户评分少于某一阈值时暂时以整体热度榜代替个性化结果等行为数据积累到一定量级再切换为协同过滤输出。这个设计本意是规避算法空窗期后面实测下来也确实挽救了首版演示的体验。选型结论本身不复杂小规模、单机部署、数据量在十万级以内的场景基于用户与基于物品的协同过滤完全够用上来就堆深度模型反而是给自己找麻烦。2. 前后端分离的骨架搭建与技术选型2.1 技术组合的实际收益分层技术选型不是看谁热闹而是要看组合能否覆盖一条完整的数据流。项目选了 SpringBoot 做后端看中的是无缝集成和社区成熟度MyBatis 作为持久层则是因为推荐系统里很多查询是带复杂条件的分页和聚合手写 SQL 比 ORM 自动生成更可控。前端 Vue3 配 Element Plus 组件库负责把推荐结果、评分交互、用户登录注册这些界面快速搭出来。MySQL 5.7 做数据存储同时承担相似度计算所需的临时表操作。整套组合没有一样是新奇玩意但组合起来后开发节奏非常顺从空仓库到能点通的Demo我只用了不到两周。2.2 项目结构前后端分离的具体落点后端我按常见的多模块单体方式来组织没有一上来拆微服务推荐系统这类业务根本没有服务拆分的必要。核心代码分四层controller暴露 REST 接口负责参数校验和简单组装service业务逻辑重点是推荐算法调度和评分业务mapperMyBatis 数据访问接口配合 XML 文件管理 SQLmodel实体类、DTO、VO前端用 Vite 初始化 Vue3 项目按页面拆视图首页推荐、电影列表、电影详情、个人评分、登录注册。状态管理一开始只用了简单的 Pinia store 存用户信息和推荐结果缓存后来发现首页切换和详情页跳转频繁store 里再追加一层推荐结果缓存确实省了重复请求。2.3 版本锁定与避坑提示一个很容易被忽略的问题是版本搭配。我最初随手配了个新版本的 Node.js 跑前端结果 Vite 初始化时提示引擎不兼容后来锁在 18 LTS 版本才消停。SpringBoot 用的 2.7.x搭配 MyBatis Spring Boot Starter 2.3.xMySQL 驱动用 8.0.x 但连接串里指定了useSSLfalse和serverTimezoneAsia/Shanghai。这些细节看着不起眼等到联调阶段一个个冒出来时才后悔没早锁定。3. 数据库表设计评分数据是推荐系统的燃料3.1 五张核心表的字段规划推荐系统的起点不是算法是数据表设计。这版系统一共建了五张核心表用户表、电影表、评分表、类型表、电影类型关联表。这里直接给出我实际使用的建表语句关键部分。CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(64) NOT NULL, password VARCHAR(128) NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE movie ( id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR(128) NOT NULL, poster VARCHAR(256), description TEXT, release_year INT, rating DECIMAL(3,1) DEFAULT 0.0, rating_count INT DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE rating ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, movie_id BIGINT NOT NULL, score TINYINT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user_movie (user_id, movie_id), KEY idx_movie_id (movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;评分表是整个系统的核心uk_user_movie唯一键保证了同一用户对同一部电影只会有一条评分记录后期做数据更新时可直接走INSERT ... ON DUPLICATE KEY UPDATE。3.2 索引设计相似度计算快不快就看这里协同过滤算法最耗时的操作是取某个用户的全部评分记录和取某部电影的全部评分记录。如果没有合适的索引每次触发都是在做全表扫描数据量稍微上来一点接口就卡死。所以在评分表上除了用户和电影的联合唯一键之外还单独为movie_id建了普通索引。MySQL 里 InnoDB 的 B 树索引天然适合这种等值查询两个查询路径都能命中索引。电影表上我用release_year做了普通索引前端电影列表页支持按年份筛选这个字段查询频率高值得加索引。至于description这种长文本字段没有任何查询条件会直接命中所以完全不用索引。3.3 模拟评分数据的生成策略系统刚开发时没有真实用户测试协同过滤算法必须要有像样的评分数据。我不推荐手工一条条插入纯属浪费时间。写个一次性脚本按照评分符合正态分布的思路随机生成先产生若干模拟用户比如某平台内部测试号再让每个用户对随机数量的电影打分。这样生成的矩阵能接近真实行为保证算法调试验证有意义而不是对着全零矩阵自嗨。具体生成逻辑很简单预设用户数量 100、电影数量 300每个用户随机打 20 到 80 部电影分数集中在 3 到 5 分之间少量 1 到 2 分。测试数据的稀疏度和分布能直接影响算法效果判断太密集的模拟数据会给算法开挂上线后反而露馅。4. 协同过滤算法落地从相似度公式到 SQL 查询4.1 基于用户的协同过滤完整计算链路这套系统的推荐流程可以拆成四步找到与当前用户评分行为最相似的 K 个用户收集这 K 个用户评分过的电影去掉当前用户已经看过/评过的按相似度加权预测当前用户对候选电影的评分按预测分降序取 TopN 输出前两步是找相似用户后两步是生成推荐列表并排序。每步都能用 SQL 和 Java 配合完成。4.2 核心代码皮尔逊相似度实现相似度算法我采用的是皮尔逊相关系数它比余弦相似度多了去均值中心化这一步。两人评分习惯不一样有人喜欢全打 4 分有人偏好 1 到 5 分极端分布皮尔逊能消除这种个体偏差。核心代码如下public double pearsonSimilarity(MapLong, Double userRatings, MapLong, Double otherRatings) { ListLong commonMovies userRatings.keySet().stream() .filter(otherRatings::containsKey) .collect(Collectors.toList()); if (commonMovies.size() 2) { return 0.0; } double sum1 0, sum2 0, sum1Sq 0, sum2Sq 0, pSum 0; int n commonMovies.size(); for (Long movieId : commonMovies) { double r1 userRatings.get(movieId); double r2 otherRatings.get(movieId); sum1 r1; sum2 r2; sum1Sq r1 * r1; sum2Sq r2 * r2; pSum r1 * r2; } double num pSum - (sum1 * sum2 / n); double den Math.sqrt((sum1Sq - sum1 * sum1 / n) * (sum2Sq - sum2 * sum2 / n)); if (den 0) { return 0.0; } return num / den; }这里有个关键细节commonMovies.size() 2时直接返回 0。因为只有一条共同评分时皮尔逊公式的分母必定为零强行计算会出现除零异常而且在统计意义上单条共同评分也说明不了偏好相似。4.3 从相似度矩阵到推荐列表生成计算出当前用户和其他所有用户的相似度之后我按相似度从高到低排序取前 K 个相似用户K 这个参数通过配置项控制我系统里默认取 10。接着用一个 SQL 把这 K 个用户评分过、但当前用户没有评分的电影捞出来SELECT r.movie_id, SUM((r.score - u.avg_score) * sim.similarity) AS weighted_score FROM rating r JOIN ( SELECT id, similarity FROM temp_user_similarity WHERE target_user_id #{userId} ) sim ON r.user_id sim.id LEFT JOIN rating cur ON cur.user_id #{userId} AND cur.movie_id r.movie_id WHERE cur.user_id IS NULL GROUP BY r.movie_id ORDER BY weighted_score DESC LIMIT #{limit}临时表temp_user_similarity是算法运行时把当前用户与其他用户的相似度先算好放进去的。之所以这么做是为了把 Java 里算出的浮点结果给 SQL 复用避免 SQL 里重复算一遍相似度。SUM 里的(r.score - u.avg_score)是评分去均值后的偏差加权——这个做法比直接加权原始评分更稳用户偏好差异和评分习惯都做了归一化。4.4 基于物品的协同过滤换一个维度思考基于用户的协同过滤在用户数少的时候效果不稳定——用户量 100共同评分电影想达到 2 部以上都难。因此我在系统中还实现了基于物品的协同过滤逻辑正好反过来先根据所有用户的评分记录算出电影之间的相似度矩阵然后给用户推荐和他看过的高分电影相似的电影。电影相似度计算放在后台任务里做用movie_rating_similarity表存结果而不是实时算。表结构很简单movie_id_a、movie_id_b、similarity、updated_at。查询时拿用户评分过的电影去 JOIN 这张表去掉已看过的再按相似度加权聚合排序。这个方案的实时计算压力要小很多效果也对冷启动更友好——只要有少数几部电影评分就能扩展出一批候选。4.5 为什么选择离线计算加缓存无论是基于用户还是基于物品的协同过滤相似度计算的时间复杂度都在 O(m*n) 量级。如果我每次用户刷新推荐页都在请求里现算数据库和 CPU 肯定吃不消。实际的架构是用 Spring 定时任务每隔 6 小时全量计算一次电影相似度矩阵结果写入缓存表基于用户的相似度则只在用户登录后首次请求推荐时计算结果放进 Redis 缓存缓存过期时间设为 30 分钟。这套设计牺牲了一点实时性但换来了接口响应时间稳定在 200ms 以内。5. SpringBoot 后端接口与 Vue3 前端联调5.1 后端接口设计遵循动作清晰原则接口我严格按照 RESTful 风格来设计主要接口如下方法路径说明POST/api/user/register用户注册POST/api/user/login用户登录GET/api/movie/list分页获取电影列表GET/api/movie/{id}获取电影详情POST/api/rating提交评分GET/api/recommend/{userId}获取推荐列表GET/api/user/ratings获取用户评分记录接口路径不要写成动词/api/recommend这种直接以资源为主体的风格更清晰。前端请求时统一走 Axios 实例带上 token 请求头后端在 Spring 拦截器里校验登录状态未登录的请求返回 401。5.2 Vue3 前端页面的组织逻辑前端我分了五个页面视图。首页推荐页最复杂进入后先请求/api/recommend/{userId}拿推荐列表如果没有推荐内容就显示热度榜兜底用户对推荐卡片点想看或直接评分后当前卡片立即移除并请求下一条。评分页用一个统一的评分对话框组件用户点星星打分之后调用 POST/api/rating提交。电影详情页展示了电影信息和用户评分状态。如果用户已经评过分后端返回的userScore字段就有值没有就显示未评分状态。这个交互看起来简单但要注意评分数据提交后必须刷新推荐列表否则用户会觉得我打完分推荐没变。5.3 联调中的两个关键体验问题前后端联调时最容易忽略的是接口返回结构的一致性。我这里全部接口统一走 Result 包装code、message、data。前端 Axios 拦截器里统一判断code 200后才放行否则弹错误提示。这样后端异常时前端能统一处理而不是每个组件各自 try-catch。另一个坑是前端请求推荐接口时没有做 loading 状态。协同过滤首次计算可能要等一两秒页面如果毫无反馈用户会以为卡死了。后来我在 Axios 拦截器里加了一个全局 loading 计数器发出请求时递增响应回来后递减计数大于 0 就显示全屏 loading。这个改动虽小但对体验提升非常明显。6. 实测效果与典型踩坑记录6.1 冷启动新用户没有评分推荐结果一片空白系统上线试运行时我拿一批测试账号去登录发现新注册用户点击推荐页面后直接空白。排查发现推荐算法里对无评分用户求相似度时找不到任何相似用户候选电影集合为空。这也是协同过滤算法的经典冷启动问题。最终方案是加了一个推荐兜底策略当用户评分数量不足 5 条时接口返回全站热度榜按评分人数和平均分加权排序用户评分数达到 5 条后才切换到协同过滤结果。6.2 稀疏矩阵用户数少导致共同评分电影过少测试阶段总用户量不到 80每个用户平均只评了 30 部电影理论上共同评分的电影数量非常少。基于用户的协同过滤效果很差几乎推不出和当前用户偏好相关的电影。另一个策略是改用基于物品的协同过滤作为主要推荐引擎因为电影相似度计算结果受稀疏矩阵影响相对较小。这个调整上线后推荐结果的相关性明显提升。6.3 Long 类型主键的前端精度丢失有用户反馈点击某部电影详情页时偶尔会跳错排查发现是后端返回的 movieId 是 Long 类型而前端 JavaScript 处理超出 2^53 的数字时精度丢失最后几位变成 0。这个问题在自增主键不超长的测试阶段根本不会暴露等到数据量增大后就出现了。解决方法是统一在后端把 Long 类型的 ID 转成字符串输出到前端数据库操作时再用Long.parseLong转换回来。6.4 全量计算的性能瓶颈本地开发时数据量小定时任务跑一遍电影相似度矩阵只要几十秒。但把数据量提升到一万部电影时全量计算耗时直接飙到十几分钟。后来优化方式是只对最近三个月有过评分的活跃电影计算相似度冷门老片直接跳过。同时把计算任务拆成小批次每次处理 500 部电影写到临时表最后统一合并。这个调整之后定时任务耗时降到了三分钟以内。7. 个人体会与后续扩展方向整个项目从零到跑通再到上线试运行前后花了一个多月最大的体会是推荐系统本质上是数据工程而不是算法工程。算法公式翻来覆去就那几个真正花精力的是把数据表设计好、把查询优化好、把前后端的数据格式对齐。如果你也打算做一个电影推荐系统作为练手或内部工具我的建议是先花一半时间在设计数据表和模拟评分数据上再去写算法代码顺序反了大概率会返工。后续这个项目还可以继续往这几个方向扩展加入基于内容的推荐作为冷启动补充、接入更复杂的隐语义模型、把相似度计算任务从定时任务改成实时增量更新。每一步都会引入新的复杂度但也能让推荐质量再上一个台阶。到时候我会继续把这套系统的迭代过程分享出来。