在接手这个项目之前,我对"推荐系统"一直抱着敬而远之的态度,总觉得那是大厂才玩得转的算法工程,要上分布式、要有大数据平台。直到某公司内部搞了个电影点播平台,运营发现用户看完首页推荐列表的转化率低得可怜,首页堆满高分片却没人点——这才意识到,推荐这层逻辑再不上,整个平台的留存数据会越来越难看。本着"先跑通再优化"的思路,我把目标锁定在一个单机能扛住、代码量可控、又能真正体现推荐效果的技术栈上: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:业务逻辑,重点是推荐算法调度和评分业务
- mapper:MyBatis 数据访问接口,配合 XML 文件管理 SQL
- model:实体类、DTO、VO
前端用 Vite 初始化 Vue3 项目,按页面拆视图:首页推荐、电影列表、电影详情、个人评分、登录注册。状态管理一开始只用了简单的 Pinia store 存用户信息和推荐结果缓存,后来发现首页切换和详情页跳转频繁,store 里再追加一层推荐结果缓存,确实省了重复请求。
2.3 版本锁定与避坑提示
一个很容易被忽略的问题是版本搭配。我最初随手配了个新版本的 Node.js 跑前端,结果 Vite 初始化时提示引擎不兼容,后来锁在 18 LTS 版本才消停。SpringBoot 用的 2.7.x,搭配 MyBatis Spring Boot Starter 2.3.x,MySQL 驱动用 8.0.x 但连接串里指定了useSSL=false和serverTimezone=Asia/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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;评分表是整个系统的核心,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(Map<Long, Double> userRatings, Map<Long, Double> otherRatings) { List<Long> 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. 个人体会与后续扩展方向
整个项目从零到跑通再到上线试运行,前后花了一个多月,最大的体会是"推荐系统本质上是数据工程,而不是算法工程"。算法公式翻来覆去就那几个,真正花精力的是把数据表设计好、把查询优化好、把前后端的数据格式对齐。如果你也打算做一个电影推荐系统作为练手或内部工具,我的建议是先花一半时间在设计数据表和模拟评分数据上,再去写算法代码,顺序反了大概率会返工。
后续这个项目还可以继续往这几个方向扩展:加入基于内容的推荐作为冷启动补充、接入更复杂的隐语义模型、把相似度计算任务从定时任务改成实时增量更新。每一步都会引入新的复杂度,但也能让推荐质量再上一个台阶。到时候我会继续把这套系统的迭代过程分享出来。