
1. 为什么电影场景天然适合协同过滤需求拆解与算法选型最近一段时间我陆续收到好几个朋友的消息问题都差不多平时用 SpringBoot、Vue3 做过各种管理后台现在想做一个有意思一点的项目技术栈定了 Java SpringBoot Vue3 MyBatis MySQL但业务题材拿不准。我建议他们做电影推荐系统原因很简单电影推荐是推荐算法里最容易讲清楚、也最容易验证效果的应用场景用户对推荐的感受非常直接一眼就能看出结果有没有道理。而且这个领域有大量公开的评分数据可以拿来做测试不需要依赖特定平台的接口完全可以自己造数据集。这里先讲清楚协同过滤解决的是什么事。推荐系统的目标是帮用户在我们有什么和用户喜欢什么之间搭一座桥。协同过滤走的路子是不分析电影内容只分析用户和物品之间的交互关系。所有信息被整理成一张矩阵行代表用户列代表电影格子代表用户给电影的评分。有了这张矩阵推荐问题就变成了一个纯数学问题谁和谁口味接近哪部片子和哪部片子容易被同一拨人喜欢。电影场景天然适合这套逻辑因为电影是标准化程度很高的内容产品看完之后打分是件非常自然的事情。换到资讯、短视频那些场景用户行为是滑走没滑走、停留几秒标签体系复杂得多。所以做一个电影推荐系统的练手项目能让你把算法、数据库、接口、页面四个环节完整打通却又不会被业务复杂度淹没。这也是我建议朋友选这个题材最核心的理由。1.1 两种流派UserCF 与 ItemCF 的分工逻辑协同过滤分两派。基于用户的协同过滤UserCF思路是先找到与当前用户口味最相似的 K 个用户把这 K 个人看过且评分高的电影汇总排序推荐给当前用户核心是计算用户之间的相似度。基于物品的协同过滤ItemCF反过来先离线算好电影和电影之间的相似度矩阵线上运行时拿用户评过分的电影去匹配相似电影加权汇总得到候选集。这里有一个很值得展开的经验点在电影场景里ItemCF 通常比 UserCF 更实用。原因有两个方面。第一用户的规模通常远大于电影规模做一个小型系统用户可能几千上万而电影库一般几百到几千部。计算所有用户两两之间的相似度复杂度是 O(U²×M)U 是用户数M 是电影数用户一多就崩了而物品相似度是 O(M²×U)M 小得多离线可算。第二UserCF 找出来的相似用户之间很可能只共同看过一两部片子交集太小相似度这个数字参考价值不高推荐结果容易跑偏ItemCF 的推荐理由是因为你喜欢 A而 B 和 A 相似这个解释用户一听就懂。所以我的方案定为离线计算物品相似度矩阵在线根据用户自己的评分历史加权生成推荐。这样既保证推荐质量稳定又把线上接口的响应时间控制住了。后面有一章我会专门写这套算法在 Java 里怎么落地。1.2 选型之前先把数据流画出来在动手建工程之前我先梳理了一遍整个系统的数据流。用户在前端页面浏览电影、给电影打分评分写入 MySQL 的评分表。后端有一个定时任务每天凌晨读取这段时间的新评分重新计算电影相似度矩阵把结果保存在内存缓存里。当用户打开推荐页面时前端调用后端接口后端根据当前用户 ID 查出他的评分记录结合离线算好的相似度矩阵算出候选电影的预测分排序后取前 N 部返回给前端展示。这个过程想清楚之后技术栈的分工也变得很清晰SpringBoot 负责提供 RESTful 接口和执行算法逻辑MyBatis 负责跟 MySQL 打交道完成数据的读写Vue3 负责把电影列表、评分按钮、推荐结果渲染出来。整条链路没有多余的环节每个组件都有明确的位置。这也是我在做任何项目之前都会先做的事——不急着建工程先画数据流把模块边界定下来后面编码效率会高很多。2. SpringBootVue3MyBatis 的分工这套技术栈到底怎么配合2.1 前后端分离的模块边界前后端分离是这个项目的基本形态。我把它分成两个独立的目录recsys-backend 和 recsys-frontend。后端只暴露 JSON 接口不做页面渲染前端只负责展示和交互通过 HTTP 请求拿数据。分开部署的好处是开发时可以并行推进也可以分别做扩展——将来想在接口层加缓存、做限流或者给前端换一套主题互不影响。后端的内部又按常见的分层结构拆成了 controller、service、mapper、model 四层。controller 负责接收请求、参数校验、返回统一结果service 负责业务逻辑和算法计算mapper 是 MyBatis 的接口层定义数据库操作方法model 存放实体类。这个分层不一定是最新潮的架构但非常稳对中小型项目来说足够清晰团队协作时也能很快定位问题。前端我用 Vue3 Vite 搭起来组件用 Vue 的单文件组件方式组织页面路由用 vue-router状态管理暂时不需要引入重量级方案推荐页面的数据直接从接口拿就好。页面主要三个电影列表页、电影详情页、我的推荐页。用户在列表页和详情页可以评分评分数据进入推荐链路之后推荐页才有东西可算。2.2 为什么持久层选了 MyBatis这个项目里数据库操作不算复杂无非是用户查询、电影分页、评分写入和统计但是有两个点让我倾向于 MyBatis 而不是 JPA。第一是评分统计类的 SQL 经常要写 GROUP BY、子查询和联表MyBatis 里 SQL 完全由自己掌控写出来什么样执行就是什么样定位性能问题时可以直接把 SQL 拿出来跑一遍。JPA 那种根据方法名自动生成 SQL 的方式在简单 CRUD 上确实很爽一旦要调优就有点隔靴搔痒。第二是 MyBatis 的 Mapper XML 和实体映射足够直白适合作为学习项目让初学者看清数据库表长什么样、SQL 写在哪、结果怎么映射成对象这三者的关系。我在写评分统计接口的时候一个联表查询直接在 XML 里写完后端 service 拿到的是现成的结果集非常省心。当然用 MyBatis 也有代价比如每个表的增删改查要写不少样板代码。我的选择是保留手写 SQL 的方式但把分页查询这类通用能力封装成一个基础方法避免每个 Mapper 都复制一份。后来实测下来这套代码读起来比满屏注解的 JPA 更符合国内开发者的习惯也更容易讲给正在入门的朋友听。2.3 版本选择与项目骨架版本上我按当前主流组合来选JDK 17SpringBoot 3.2 左右MyBatis 用和 SpringBoot 3 适配的版本前端 Vue 3.4 Vite 5。数据库用 MySQL 8.0因为 8.0 的窗口函数和默认字符集 utf8mb4 都更省心。这里要提醒一句SpringBoot 3 的起点比 2 高很多必须配上相应版本的 MyBatis Starter直接沿用网上老教程的 2.7 配置可能会遇到类找不到的问题。项目目录结构我按下面这样组织读者如果拿这套源码去复现可以先照这个骨架把目录建出来再逐个文件去比对会轻松很多。recsys-backend/ pom.xml src/main/java/com/example/recsys/ RecsysApplication.java controller/ MovieController.java RatingController.java RecommendController.java service/ RecommendService.java MovieService.java SimilarityService.java mapper/ UserMapper.java MovieMapper.java RatingMapper.java model/ User.java Movie.java Rating.java src/main/resources/ application.yml mapper/ UserMapper.xml MovieMapper.xml RatingMapper.xml recsys-frontend/ package.json vite.config.js src/ main.js api/ request.js movie.js views/ MovieList.vue MovieDetail.vue Recommend.vue router/ index.js后端启动后默认跑在 8080 端口前端 Vite 跑在 5173 端口。本地开发时我把前端 API 请求前缀统一配成 /api然后在 Vite 的 proxy 配置里把 /api 转发到 localhost:8080这样页面里不需要写完整的后端地址联调阶段比较卫生。这套骨架我前前后后用了很多次省下了一堆联调时的重复配置。3. 推荐系统的地基MySQL 三张核心表与索引设计3.1 user、movie、rating 三张表的结构协同过滤算法的原材料是用户、评分、电影这三样数据表结构也从这里出发。用户表很简单主键 ID、用户名、密码、创建时间。密码字段我建议直接存 BCrypt 加密后的串虽然自己练手项目也可以明文但养成好习惯总是没坏处。电影表包含主键 ID、片名、类型、导演、简介、上映年份、海报 URL字段不求全够推荐页面展示就行。评分表是核心它记录的是用户 ID、电影 ID、评分值、评分时间。下面是我实际用的建表语句读者可以直接照着改成自己的风格CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE movie ( id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR(100) NOT NULL, genre VARCHAR(50) DEFAULT NULL, director VARCHAR(50) DEFAULT NULL, description TEXT, release_year INT DEFAULT NULL, poster_url VARCHAR(200) DEFAULT NULL, 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, rated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_movie_id (movie_id), UNIQUE KEY uk_user_movie (user_id, movie_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;评分表的联合唯一约束 uk_user_movie 是必须加的它能挡住同一个人给同一部电影打分两条记录的情况。实际项目里用户可能手滑点了好几次业务层要先判断数据库约束是最后一道兜底。少了这个约束后面算相似度时同一个用户对同一部电影的评分会被重复统计推荐结果直接漂移。3.2 索引设计别让推荐接口被慢查询拖死索引设计上我的习惯是先把查询语句列出来再倒推索引。这个项目里最常见的查询有三个查某用户所有评分WHERE user_id ?、查某部电影被哪些用户评过分WHERE movie_id ?、分页查电影列表。前两个查询分别命中 idx_user_id 和 idx_movie_id第三个命中主键索引没问题。还有一个统计场景计算同时给两部电影打分的人数这种查询会涉及两个子查询的连接数据量小的时候走索引就够了数据量大再考虑中间表预聚合。我记得第一次没有认真做索引电影到了 5000 部、评分数据到了 20 万条时推荐接口里那个带子查询的统计 SQL 跑到了 3 秒多后知后觉补上索引才降到几十毫秒。所以表结构建好之后第一时间把涉及计算的 SQL 链路过一遍把需要的索引补上很有必要。索引也不是越多越好评分表已经有两个单列索引加一个联合唯一索引写操作仍然很快但再多加索引就要考虑写入成本了。3.3 评分稀疏性对表设计的影响评分数据有两个特点一是稀疏绝大多数用户只给几十部电影打分而电影库动辄几千部矩阵中 99% 以上的格子是空的二是 1 到 5 分档位少区分度不是特别高。这两个特点决定了算法在实现层面的一些策略比如相似度计算时我只考虑两个用户共同评过分的电影没有共同评分的用户对相似度直接按 0 处理又比如相似度算完后先归一化避免个别打分特别多的人权重过高。稀疏性也提醒我一个事测试数据不能造得太完美。我最初为了验证功能往库里灌了很多整齐的评分每部电影都被 100 个人评过导致算法怎么跑都好看。后来换成更接近真实的稀疏数据之后冷启动和推荐质量的问题才暴露出来。所以如果读者是自己造数据建议模拟真实分布——大部分用户只评少量电影少部分用户是重度评分者这样算法优化才有意义。4. 协同过滤从公式到代码相似度计算与 Top-N 推荐实现4.1 评分数据加载与内存矩阵构建算法落地的第一步是把 MySQL 里的评分数据加载进内存。我的做法比较直接在 SimilarityService 里写一个方法用 RatingMapper 查出所有评分记录然后按用户分组构建成一个 MapInteger, MapInteger, Integer外层 key 是用户 ID内层 key 是电影 IDvalue 是评分。电影数量不大时这个 Map 完全够用没必要引入专门的矩阵库。示例代码public MapInteger, MapInteger, Double loadUserRatings() { ListRating ratings ratingMapper.selectAll(); MapInteger, MapInteger, Double matrix new HashMap(); for (Rating r : ratings) { matrix .computeIfAbsent(r.getUserId(), k - new HashMap()) .put(r.getMovieId(), r.getScore().doubleValue()); } return matrix; }加载完之后我会再做一个倒排处理构建电影 - 评过这部电影的用户列表的映射。倒排表在后面算物品相似度时非常关键它可以把计算复杂度从所有电影两两比较降为至少被同一个用户评过的电影对之间比较。这个优化属于典型的时间换空间效果立竿见影尤其是数据量上来之后能不能撑住全靠这一步。4.2 余弦相似度的实现与边界判断物品之间的相似度我用的余弦相似度公式并不复杂两个物品被共同用户评分的点积除以两个物品评分向量的模长乘积。之所以选余弦而不是皮尔逊是因为实现简单、效果稳健而且我们这里评分是绝对值意义明确的 1 到 5 分不需要再做减去均值的中心化。如果将来要更精细可以再对比皮尔逊的效果但在中小型项目里余弦已经够用。代码这样写public double cosineSimilarity(MapInteger, Double movieA, MapInteger, Double movieB) { double dot 0.0; double normA 0.0; double normB 0.0; for (Map.EntryInteger, Double entry : movieA.entrySet()) { Double ratingB movieB.get(entry.getKey()); if (ratingB ! null) { dot entry.getValue() * ratingB; } normA entry.getValue() * entry.getValue(); } for (Double value : movieB.values()) { normB value * value; } if (normA 0.0 || normB 0.0) { return 0.0; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); }这里有两个容易被忽略的细节。第一点积只需要遍历两部电影中评过分的那部分交集不要在两层循环里把全部电影都跑一遍否则性能会非常差。第二如果 normA 或 normB 是 0说明这部片子没有任何评分记录直接返回 0否则会出现除零错误。我一开始没写这个判断数据里有新导入的冷门电影算法直接抛了 ArithmeticException接口 500 了半夜。算完所有物品对之后我会把相似度结果保存成一个二维稀疏结构用 MapLong, MapLong, Double 表示外层 key 是当前电影 ID内层是相似电影 ID 和相似度。保存前还要做一次过滤只保留相似度大于 0.05 的那些并且把每部电影的相似列表按相似度降序排序留前 50 个这样内存占用和后续计算量都可控。4.3 预测评分与 Top-N 推荐流程物品相似度矩阵算好之后在线推荐的流程就非常简单了。拿到当前用户的评分历史遍历他评过分的每一部电影找到和它相似的电影按相似度加权累加得分。得分的定义我用了加权物品平均的做法候选电影的得分等于所有当前用户评过分的、与该候选相似的电影评分 × 相似度的和。这一步在 RecommendService 里实现public ListRecommendItem recommend(Long userId, int topN, int maxSimilar) { MapInteger, Integer userRatings loadByUser(userId); MapInteger, Double scores new HashMap(); for (Map.EntryInteger, Integer rated : userRatings.entrySet()) { MovieId movieId rated.getKey(); double userScore rated.getValue(); ListSimilarEntry similarList similarMatrix.get(movieId); if (similarList null) continue; for (SimilarEntry sim : similarList) { if (userRatings.containsKey(sim.movieId)) continue; scores.merge( sim.movieId, userScore * sim.similarity, Double::sum ); } } return scores.entrySet().stream() .sorted(Map.Entry.Integer, DoublecomparingByValue().reversed()) .limit(topN) .map(e - new RecommendItem(e.getKey(), e.getValue())) .toList(); }这段逻辑里有一个关键动作已经评过分的电影要从候选集里剔除否则用户会看到自己已经点评过的电影再被推荐一遍体验非常奇怪。另外相似度矩阵里每部电影只保留相似度最高的前 maxSimilar 个邻居所以在线计算是 O(用户已评数量 × maxSimilar)这个量级对于小型系统来说轻松扛得住。预测分数的归一化我放在最后一步做把得分映射到 0 到 100 区间方便前端用进度条展示推荐度。公式是score / maxScore * 100maxScore 取本批候选的最大值。这个不是必须的但在前端展示时确实比裸的浮点数好看太多用户也能直观感受到推荐强度的差异。5. 前后端分离联调接口设计、跨域处理与 Vue3 页面5.1 RESTful 接口规划接口设计上我尽量遵循 RESTful 风格让 URL 一眼能看出来在操作什么资源。下面是整个项目用到的主要接口方法路径说明GET/api/movie/list?page1size10分页获取电影列表GET/api/movie/{id}获取电影详情POST/api/rating提交一条评分GET/api/rating/{userId}?page1size10查询用户已评分电影GET/api/recommend/{userId}?topN10获取推荐电影列表统一返回值结构我用 Result 包装code、message、data 三个字段code 为 0 表示成功。这样做的好处是前端请求封装层可以统一判断状态码不需要每个接口单独处理异常。分页参数这块直接在 Controller 里接收 page 和 size然后传给 MyBatis 的查询方法前端拿到 PageResult 结构list、total、page、size。5.2 SpringBoot Controller 与 MyBatis MapperController 层的代码不复杂以推荐接口为例RestController RequestMapping(/api/recommend) public class RecommendController { Autowired private RecommendService recommendService; GetMapping(/{userId}) public ResultListRecommendItem recommend( PathVariable Long userId, RequestParam(defaultValue 10) int topN) { ListRecommendItem items recommendService.recommend(userId, topN, 50); return Result.ok(items); } }MyBatis Mapper 的写法也比较典型。评分表查询某个用户的电影 ID 和评分XML 里就是一条简单 SQLselect idselectByUser resultTypeRating SELECT movie_id, score FROM rating WHERE user_id #{userId} /select这里有一个容易踩的坑是实体类字段和数据库列名的映射。MyBatis 默认开启驼峰转下划线后movieId 能映射到 movie_id所以我在 application.yml 里配置了 map-underscore-to-camel-case: true。如果没配这个开关查询结果里 movieId 会是 null那你的评分查询拿回来全是空电影 ID算法跑出来的推荐自然全是空排查这种问题很容易怀疑人生。跨域问题单独说一下。前后端分离时前端从 5173 端口访问后端 8080 端口浏览器会拦截跨域请求。我在后端做了一个全局 CORS 配置允许本地开发地址和所有常用请求头同时又在 Vite 里配了代理这两层配置各管各的生产环境只需要保留代理层跨域配置可以在部署时收紧。别两个都不配也别只配一个就指望两个环境都好使这是我联调阶段最真实的体会。5.3 Vue3 页面与请求封装前端我把请求封装统一放在 src/api/request.js 里用 axios 创建实例设置 baseURL 为 /api然后在响应拦截器里统一取 Result.data。页面组件只需要关心业务数据不用每次写状态码判断。推荐页面的核心逻辑其实就是一段组合式 API 的代码script setup import { ref } from vue import { fetchRecommend } from ../api/movie const movies ref([]) const loading ref(false) async function loadRecommend() { loading.value true try { const res await fetchRecommend(userId, 10) movies.value res.data } finally { loading.value false } } /script页面结构上我分了三个视图电影列表页展示海报卡片点击进入详情页详情页里放一个评分组件用户选完分数点提交POST 到 /api/rating 存储推荐页展示 /api/recommend 返回的结果每张卡片带一个推荐度进度条。整个联调过程中最耗时间的不是页面绘制而是处理接口返回的数据结构和后端实体类不完全一致的情况。所以我索性让后端直接返回前端需要的结构比如推荐页需要电影的标题、海报 URL、推荐度我就在 RecommendItem 里直接把这些字段组装好前端不做二次加工。这个取舍在中小项目里是划算的前端代码更简洁后端也不复杂。6. 线上化真实考验冷启动、稀疏矩阵与性能优化6.1 冷启动问题的兜底策略冷启动是协同过滤绕不开的坎。新用户没有任何评分算法拿不到他的偏好推荐接口算出来的候选集是空的。我的处理策略有三层第一层后端在推荐接口里判断用户评分记录数如果少于 5 条直接返回一个电影热门榜热门榜按平均评分和评分人数综合排序第二层注册时可以选几个喜欢的电影类型记到用户表里推荐时在热门榜基础上按类型过滤第三层随着用户评分增多逐步切换回协同过滤结果。这个兜底逻辑放在 service 层做最合适。Controller 不用关心推荐结果是算法算的还是热门榜凑的只要返回推荐列表就行。我实现的时候给 RecommendService 加了一个简单的分支判断评分数够就走协同过滤不够走热门榜这样用户在冷启动阶段也能看到一个说得过去的推荐页不会觉得系统是坏的。6.2 稀疏评分下的相似度失真与处理评分稀疏导致的另一个问题是相似度失真。两个人只共同看过一部电影并且都给了高分余弦相似度会直接变成 1.0这看起来完美相似但实际上是过拟合不能反映真实的品味接近程度。我的处理办法是对相似度做降权修正在相似度基础上乘以一个共同评分数相关的权重比如weight sim * min(common, 10) / 10共同评分数越多权重越大共同评分数只有 1 的时候完整相似度被压到原来的 0.1。这个修正量级不算大但效果很明显。我第一次不做修正时推荐结果里混进来一堆只有一两个冷门共同评分的伪相似电影用户反馈说推荐结果莫名其妙。加了权重修正之后推荐列表的稳定度高了很多。这类修正属于工程经验范畴不是算法书上会教的东西但对实际体验影响很大。6.3 性能优化预计算、缓存与定时任务最后说性能。这个项目的计算热点在物品相似度矩阵每次算一遍大约需要几分钟到十几分钟取决于数据集大小。如果每次用户请求都现场算接口必然卡死。所以我把相似度矩阵做成预计算每天凌晨 2 点通过 SpringBoot 的 Scheduled 定时任务重新加载评分数据、重算相似度、更新内存中的矩阵。白天所有推荐请求都只读内存里的矩阵不再触碰 MySQL 的原始评分大表。Scheduled(cron 0 0 2 * * *) public void rebuildSimilarityMatrix() { MapInteger, MapInteger, Double userRatings loadUserRatings(); MapInteger, MapInteger, Double simMatrix buildItemSimilarity(userRatings); similarMatrixHolder.update(simMatrix); log.info(相似度矩阵重建完成共 {} 部电影, simMatrix.size()); }这是工程上一个很重要的取舍推荐系统往往不追求绝对实时而是追求新鲜度够用。评分是稀疏的长尾行为用户评分后推荐列表里能立刻看到他刚打的分价值并不高反倒是每天一次的频率既能保证推荐结果跟随口味变化又能让系统复杂度维持在很低的水平。如果将来数据量大了这个定时任务可以换成消息队列触发或者引入 Redis 缓存相似度矩阵但基本思路不会变。数据库这块我顺便把连接池参数调了一下初始化连接数设为 10、最大连接数设为 20推荐接口的数据库查询集中在查用户评分和查相似度矩阵上前者走索引毫秒级返回后者根本不走数据库所以整个接口的磁盘 IO 压力很小。实测下来一万个用户、三千部电影、五十万条评分的数据集上推荐接口单次响应基本保持在 50 毫秒以内。最后再分享一个我实际编码中的小习惯我会在本地写一个模拟评分工具类专门生成带随机热度的测试数据这样调优时可以快速制造各种稀疏程度的数据集。当初就是靠它发现相似度降权修正和冷启动策略的问题的。这个项目做完之后我对推荐系统的理解比以前只看书要深入得多因为每一个看起来简单的公式落到工程里都藏着无数的细节。