☰
SpringBoot+Vue3电影推荐系统实战:协同过滤算法与前后端分离实现
2026/10/21 4:41:57 网站建设 项目流程

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. SpringBoot+Vue3+MyBatis 的分工:这套技术栈到底怎么配合

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 17,SpringBoot 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) 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, `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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

评分表的联合唯一约束 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 查出所有评分记录,然后按用户分组,构建成一个 Map<Integer, Map<Integer, Integer>>,外层 key 是用户 ID,内层 key 是电影 ID,value 是评分。电影数量不大时这个 Map 完全够用,没必要引入专门的矩阵库。

示例代码:

public Map<Integer, Map<Integer, Double>> loadUserRatings() { List<Rating> ratings = ratingMapper.selectAll(); Map<Integer, Map<Integer, 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(Map<Integer, Double> movieA, Map<Integer, Double> movieB) { double dot = 0.0; double normA = 0.0; double normB = 0.0; for (Map.Entry<Integer, 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 了半夜。

算完所有物品对之后,我会把相似度结果保存成一个二维稀疏结构,用 Map<Long, Map<Long, Double>> 表示:外层 key 是当前电影 ID,内层是相似电影 ID 和相似度。保存前还要做一次过滤,只保留相似度大于 0.05 的那些,并且把每部电影的相似列表按相似度降序排序,留前 50 个,这样内存占用和后续计算量都可控。

4.3 预测评分与 Top-N 推荐流程

物品相似度矩阵算好之后,在线推荐的流程就非常简单了。拿到当前用户的评分历史,遍历他评过分的每一部电影,找到和它相似的电影,按相似度加权累加得分。得分的定义我用了加权物品平均的做法:候选电影的得分等于"所有当前用户评过分的、与该候选相似的电影,评分 × 相似度"的和。这一步在 RecommendService 里实现:

public List<RecommendItem> recommend(Long userId, int topN, int maxSimilar) { Map<Integer, Integer> userRatings = loadByUser(userId); Map<Integer, Double> scores = new HashMap<>(); for (Map.Entry<Integer, Integer> rated : userRatings.entrySet()) { MovieId movieId = rated.getKey(); double userScore = rated.getValue(); List<SimilarEntry> 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, Double>comparingByValue().reversed()) .limit(topN) .map(e -> new RecommendItem(e.getKey(), e.getValue())) .toList(); }

这段逻辑里有一个关键动作:已经评过分的电影要从候选集里剔除,否则用户会看到自己已经点评过的电影再被推荐一遍,体验非常奇怪。另外,相似度矩阵里每部电影只保留相似度最高的前 maxSimilar 个邻居,所以在线计算是 O(用户已评数量 × maxSimilar),这个量级对于小型系统来说轻松扛得住。

预测分数的归一化我放在最后一步做:把得分映射到 0 到 100 区间,方便前端用进度条展示"推荐度"。公式是score / maxScore * 100,maxScore 取本批候选的最大值。这个不是必须的,但在前端展示时确实比裸的浮点数好看太多,用户也能直观感受到推荐强度的差异。

5. 前后端分离联调:接口设计、跨域处理与 Vue3 页面

5.1 RESTful 接口规划

接口设计上,我尽量遵循 RESTful 风格,让 URL 一眼能看出来在操作什么资源。下面是整个项目用到的主要接口:

方法路径说明
GET/api/movie/list?page=1&size=10分页获取电影列表
GET/api/movie/{id}获取电影详情
POST/api/rating提交一条评分
GET/api/rating/{userId}?page=1&size=10查询用户已评分电影
GET/api/recommend/{userId}?topN=10获取推荐电影列表

统一返回值结构我用 Result 包装:code、message、data 三个字段,code 为 0 表示成功。这样做的好处是前端请求封装层可以统一判断状态码,不需要每个接口单独处理异常。分页参数这块直接在 Controller 里接收 page 和 size,然后传给 MyBatis 的查询方法,前端拿到 PageResult 结构:list、total、page、size。

5.2 SpringBoot Controller 与 MyBatis Mapper

Controller 层的代码不复杂,以推荐接口为例:

@RestController @RequestMapping("/api/recommend") public class RecommendController { @Autowired private RecommendService recommendService; @GetMapping("/{userId}") public Result<List<RecommendItem>> recommend( @PathVariable Long userId, @RequestParam(defaultValue = "10") int topN) { List<RecommendItem> items = recommendService.recommend(userId, topN, 50); return Result.ok(items); } }

MyBatis Mapper 的写法也比较典型。评分表查询某个用户的电影 ID 和评分,XML 里就是一条简单 SQL:

<select id="selectByUser" resultType="Rating"> 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() { Map<Integer, Map<Integer, Double>> userRatings = loadUserRatings(); Map<Integer, Map<Integer, Double>> simMatrix = buildItemSimilarity(userRatings); similarMatrixHolder.update(simMatrix); log.info("相似度矩阵重建完成,共 {} 部电影", simMatrix.size()); }

这是工程上一个很重要的取舍:推荐系统往往不追求"绝对实时",而是追求"新鲜度够用"。评分是稀疏的长尾行为,用户评分后推荐列表里能立刻看到他刚打的分,价值并不高;反倒是每天一次的频率,既能保证推荐结果跟随口味变化,又能让系统复杂度维持在很低的水平。如果将来数据量大了,这个定时任务可以换成消息队列触发,或者引入 Redis 缓存相似度矩阵,但基本思路不会变。

数据库这块,我顺便把连接池参数调了一下,初始化连接数设为 10、最大连接数设为 20,推荐接口的数据库查询集中在"查用户评分"和"查相似度矩阵"上,前者走索引毫秒级返回,后者根本不走数据库,所以整个接口的磁盘 IO 压力很小。实测下来,一万个用户、三千部电影、五十万条评分的数据集上,推荐接口单次响应基本保持在 50 毫秒以内。

最后再分享一个我实际编码中的小习惯:我会在本地写一个模拟评分工具类,专门生成带随机热度的测试数据,这样调优时可以快速制造各种稀疏程度的数据集。当初就是靠它发现相似度降权修正和冷启动策略的问题的。这个项目做完之后,我对推荐系统的理解比以前只看书要深入得多,因为每一个"看起来简单的公式"落到工程里都藏着无数的细节。

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

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

立即咨询