☰
SpringBoot+Vue3协同过滤电影推荐系统实战详解
2026/10/17 18:19:33 网站建设 项目流程

做电影推荐系统这个选题,很多初学或者做毕设的同学都会下意识选SpringBoot全家桶,但真正能把协同过滤算法跑通、把前后端分离架构理清楚、把推荐逻辑和业务代码揉在一起的完整案例其实并不多。这个项目的标题虽然看起来像一个普通的“源码仓库”,但拆开看,它涵盖了三条经典主线:Java后端工程化、Vue3前端交互、协同过滤推荐算法落地。下面我就以这个项目为主线,把设计思路、核心实现、踩坑记录和可复现的代码细节一次性讲透,尽量做到你拿过去能直接照着写,而不是只看看项目结构图。

1. 项目整体设计与架构拆解

1.1 为什么选SpringBoot + Vue3 + MyBatis这套组合

先说选型。SpringBoot在国内Java技术栈里的地位不用多赘述,它解决的是“配置地狱”问题,内嵌容器、自动装配、起步依赖这些特性让后端服务能快速跑起来。MyBatis作为ORM层,相比JPA/Hibernate更贴近SQL本身,对于推荐引擎里那些需要手写复杂关联查询、分批统计的场景,控制力明显更强。

Vue3这边,Composition API带来的代码组织方式非常适合推荐系统这种“多模块、多状态”的前端——电影列表、评分弹窗、推荐结果、用户中心,每个模块的逻辑可以独立封装成hook或store,互不干扰。再加上前后端分离架构,前端只负责渲染和交互,后端只暴露RESTful API,开发和联调都能并行推进,后期扩展成小程序端或者移动端也只需要复用API层。

1.2 系统功能边界与模块划分

从标题里能确定的核心功能点是“协同过滤推荐”,那么整个系统的业务闭环应该是这样:用户注册登录 → 浏览电影列表 → 对电影评分 → 系统基于评分数据计算相似用户或相似物品 → 生成推荐列表 → 前端展示推荐结果。

围绕这条主线,我把系统拆成五个子模块:

  • 用户模块:注册、登录、个人信息维护,登录态用Token管理。
  • 电影模块:电影信息的增删改查、分类浏览、关键词搜索、电影详情页。
  • 评分模块:用户对电影打分,评分数据是协同过滤算法的唯一输入来源。
  • 推荐模块:离线计算用户相似度/电影相似度,生成推荐结果并落库。
  • 前端展示模块:Vue3页面、路由守卫、Axios请求封装、推荐结果轮播展示。

模块划分的意义在于:推荐算法不是孤立存在的,它必须依赖评分数据和用户行为数据。如果没有评分模块,协同过滤就是空中楼阁。所以即便你只是想要一个“推荐系统源码”,也一定要把评分链路做完整。

1.3 为什么推荐结果要落库而不是实时计算

这一点值得展开说。很多初学者第一次写推荐系统,会直接在Controller里写一个方法,每次请求都现算相似度,这在数据量小的时候没问题,但一旦用户量上百、电影量上千,实时计算用户两两相似度的复杂度会暴涨,接口响应时间会从毫秒级直接飙到秒级甚至超时。

我在这里做了个折中方案:推荐结果采用离线计算 + 定时任务刷新,启动时计算一次,然后每隔6小时或每天凌晨重新计算一次,把结果写入推荐表。前端接口只查询推荐表,响应时间稳定在几十毫秒内。这套模式本质上是“空间换时间”,在真实推荐系统里也是主流做法,比如短视频平台的推荐列表也不是实时从零点开始重算的。

2. 数据库设计与核心表结构

2.1 五张核心业务表的设计逻辑

先看DDL结构,对照着说明每一张表的用途和设计考量。

-- 用户表 CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(255) NOT NULL COMMENT '密码(加盐哈希)', `nickname` varchar(50) DEFAULT NULL, `avatar` varchar(255) DEFAULT NULL, `create_time` datetime 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(200) NOT NULL COMMENT '电影名称', `poster` varchar(500) DEFAULT NULL COMMENT '海报URL', `director` varchar(100) DEFAULT NULL, `actors` varchar(500) DEFAULT NULL COMMENT '主演', `genre` varchar(100) DEFAULT NULL COMMENT '类型:动作/爱情/科幻', `release_year` int DEFAULT NULL, `rating_avg` decimal(3,1) DEFAULT '0.0' COMMENT '平均分', `description` text, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影信息表';
-- 评分表 CREATE TABLE `rating` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `movie_id` bigint NOT NULL, `score` tinyint NOT NULL COMMENT '1-5分', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_movie` (`user_id`,`movie_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户评分表';
-- 相似度表 CREATE TABLE `similarity` ( `id` bigint NOT NULL AUTO_INCREMENT, `item_id_a` bigint NOT NULL, `item_id_b` bigint NOT NULL, `score` decimal(10,8) NOT NULL COMMENT '相似度分值', PRIMARY KEY (`id`), KEY `idx_item_a` (`item_id_a`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影相似度表';
-- 推荐结果表 CREATE TABLE `recommendation` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `movie_id` bigint NOT NULL, `score` decimal(10,8) NOT NULL COMMENT '预测评分', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

设计时需要注意几个关键点:

评分表一定要建联合唯一索引uk_user_movie,这样同一个用户对同一部电影只能有一条评分记录,用户重复评分时走的是更新逻辑而不是插入新记录。这个约束在业务层也要双保险,否则并发请求下数据库会直接抛唯一约束异常。

相似度表我选择了item_id_a和item_id_b两个字段存储电影对,并约定a < b,避免出现(A,B)和(B,A)两条重复数据。这个约定在计算时能省掉一半的写库量,查询时也能减少去重逻辑。

2.2 用户-电影评分矩阵怎么映射到表结构

协同过滤算法在理论讲解中通常用“用户-物品评分矩阵”表示,比如U1对M1评4分,U2对M3评5分。但在关系型数据库里不存在“矩阵”这种结构,只能存储稀疏的评分记录。这也是评分表设计成一行一条评分记录的原因。

实际做算法计算时,我并不是直接去数据库里查矩阵,而是在Java内存里构建映射结构:Map<Long, Map<Long, Double>>表示用户对电影的评分,外层key是用户ID,内层key是电影ID,value是评分。这样每次相似度计算只需要一次全量查询就能把所有用户评分数据加载到内存,计算效率比逐条查库高几个数量级。

如果你是更追求性能的场景,还可以用Redis的Hash结构缓存评分矩阵,但考虑到这个项目的定位是学习和毕设用途,数据量级不会太大,全量内存加载已经完全够用。

3. 协同过滤推荐算法的工程化实现

3.1 基于用户的协同过滤还是基于物品的协同过滤

这个项目我采用了基于物品的协同过滤(Item-based Collaborative Filtering,简称ItemCF)。原因很实际:

用户量通常远大于电影量,用户两两计算相似度的开销远高于物品两两计算。一个学校级别的系统,用户可能有几千个,但电影只有几百部,物品相似度矩阵的计算规模小得多。ItemCF生成的相似度表可以独立于用户,所有用户共享同一份物品相似度矩阵,推荐时只需要查当前用户的历史评分,再结合矩阵生成推荐列表,内存和时间成本都很可控。

ItemCF的核心思想通俗讲就是:喜欢电影A的用户往往也喜欢电影B,因为A和B相似。比如你给《星际穿越》打了5分,系统发现《盗梦空间》与《星际穿越》相似度最高,那就把《盗梦空间》推荐给你。这套逻辑非常容易在项目展示时讲清楚,也方便在毕设答辩时和老师解释。

3.2 余弦相似度计算的原理解读与代码实现

物品相似度最常用的是余弦相似度,计算两个电影被同一批用户评分的向量夹角。公式表达为:

similarity(A, B) = sum(ra * rb) / (sqrt(sum(ra^2)) * sqrt(sum(rb^2)))

其中ra是用户对A电影的评分,rb是同一用户对B电影的评分。

在实际编码中,为了避免重复扫描用户列表,我先把评分矩阵按电影维度重组:Map<Long, Map<Long, Double>>,外层key变成电影ID,内层是用户ID到评分的映射。然后对每对电影求共同评分用户的集合,再套公式计算。

下面这段代码是核心实现,我尽量写清楚每一步的意图:

@Service public class ItemCfService { private final RatingMapper ratingMapper; private final SimilarityMapper similarityMapper; public ItemCfService(RatingMapper ratingMapper, SimilarityMapper similarityMapper) { this.ratingMapper = ratingMapper; this.similarityMapper = similarityMapper; } public void computeItemSimilarity() { // 1. 全量读取评分记录 List<Rating> ratingList = ratingMapper.selectAll(); if (ratingList.isEmpty()) { return; } // 2. 构建电影 -> (用户 -> 评分) 的映射 Map<Long, Map<Long, Double>> movieToUserScores = new HashMap<>(); for (Rating r : ratingList) { movieToUserScores.computeIfAbsent(r.getMovieId(), k -> new HashMap<>()) .put(r.getUserId(), r.getScore().doubleValue()); } List<Long> movieIds = new ArrayList<>(movieToUserScores.keySet()); List<Similarity> similarities = new ArrayList<>(); // 3. 两两计算电影相似度 for (int i = 0; i < movieIds.size(); i++) { for (int j = i + 1; j < movieIds.size(); j++) { Long movieA = movieIds.get(i); Long movieB = movieIds.get(j); Map<Long, Double> scoresA = movieToUserScores.get(movieA); Map<Long, Double> scoresB = movieToUserScores.get(movieB); // 找到共同评分的用户 double dotProduct = 0.0; double normA = 0.0; double normB = 0.0; int commonCount = 0; for (Map.Entry<Long, Double> entry : scoresA.entrySet()) { Double scoreB = scoresB.get(entry.getKey()); if (scoreB != null) { double ra = entry.getValue(); double rb = scoreB; dotProduct += ra * rb; normA += ra * ra; normB += rb * rb; commonCount++; } } if (commonCount == 0) { continue; } normA = Math.sqrt(normA); normB = Math.sqrt(normB); if (normA == 0 || normB == 0) { continue; } double similarity = dotProduct / (normA * normB); Similarity sim = new Similarity(); sim.setItemIdA(movieA); sim.setItemIdB(movieB); // 保留8位小数,避免浮点数精度问题 sim.setScore(BigDecimal.valueOf(similarity).setScale(8, RoundingMode.HALF_UP)); similarities.add(sim); } } // 4. 先清空旧数据再批量插入 similarityMapper.deleteAll(); if (!similarities.isEmpty()) { similarityMapper.batchInsert(similarities); } } }

这段代码里有个容易忽略的优化点:内层循环时只遍历movieA的用户评分,然后去movieB的Map里get,这样天然对称地覆盖了所有共同评分的用户,不需要额外set去重。同时,commonCount的作用不只是计数,还可以作为相似度可信度的加权依据——如果两个电影只有一个共同用户,即使相似度算出来很高也不可靠,可以在后续推荐时设置最小共同评分用户数阈值进行过滤。

3.3 基于相似度的推荐列表生成

物品相似度算完之后,推荐逻辑就简单了。对于目标用户,取出他所有评分过的电影,对每个已评分电影A,去相似度表里查与A相似的其他电影B,然后加权累加得到一个预测分:

public List<Recommendation> recommendForUser(Long userId, int topN) { // 1. 取用户评分记录 List<Rating> userRatings = ratingMapper.selectByUserId(userId); if (userRatings.isEmpty()) { return Collections.emptyList(); } Map<Long, Double> userScoreMap = new HashMap<>(); for (Rating r : userRatings) { userScoreMap.put(r.getMovieId(), r.getScore().doubleValue()); } // 2. 候选电影加权累加 Map<Long, Double> candidateScores = new HashMap<>(); Map<Long, Integer> candidateCount = new HashMap<>(); for (Rating r : userRatings) { List<Similarity> simList = similarityMapper.selectByItemId(r.getMovieId()); for (Similarity sim : simList) { Long otherMovieId = sim.getItemIdA().equals(r.getMovieId()) ? sim.getItemIdB() : sim.getItemIdA(); // 排除用户已经看过的电影 if (userScoreMap.containsKey(otherMovieId)) { continue; } double weighted = sim.getScore().doubleValue() * r.getScore(); candidateScores.merge(otherMovieId, weighted, Double::sum); candidateCount.merge(otherMovieId, 1, Integer::sum); } } // 3. 按加权分排序,同时过滤掉共同评分人数过少的 return candidateScores.entrySet().stream() .filter(e -> candidateCount.get(e.getKey()) >= 2) .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) .limit(topN) .map(e -> { Recommendation rec = new Recommendation(); rec.setUserId(userId); rec.setMovieId(e.getKey()); rec.setScore(BigDecimal.valueOf(e.getValue()).setScale(6, RoundingMode.HALF_UP)); return rec; }) .collect(Collectors.toList()); }

这段代码里有两个细节值得单独说:

过滤掉“已评分电影”这一步非常关键。如果不做过滤,推荐列表里就会混入用户已经看过的电影,这在产品逻辑上属于硬伤。用户在推荐页看到自己打过分的老电影,第一反应是“这个推荐系统是坏的”,直接影响对系统的信任度。

第二个细节是我的一个经验补强:只保留至少被2个用户共同评分过的候选电影。单独一个共同用户产生的相似度噪声太大,比如某部冷门电影只有两个人评分,碰巧分数一样,相似度算出来就是1.0,但这种“巧合”不具备统计显著性,过滤后推荐结果质量会明显提升。

3.4 冷启动问题的两种工程解法

协同过滤有一个天生缺陷叫“冷启动”:新用户没有任何评分记录,无法计算他的喜好;新电影没有任何评分,无法参与相似度计算。这个项目里我用了两种务实解法:

对于新用户,推荐模块降级为“热门电影推荐”,直接按照电影表中rating_avg字段降序排列,取前N条。这样新用户进入系统不会面对空荡荡的推荐页,也能顺势引导用户去评分。

对于新电影,做法是给它的rating_avg设置一个初始默认分,或者直接把新电影编入“最近上映”列表,这个功能本质上已经超出协同过滤的范畴,变成内容运营策略。在课程设计或毕设里,能把这层逻辑讲清楚,本身就是加分项,说明你不只是会调库,还理解了推荐系统的业务难点。

4. SpringBoot后端核心实现细节

4.1 项目目录结构与职责边界

后端工程我按照经典的分层结构组织,每一层只做自己该做的事:

com.example.movierec ├── controller // 接收HTTP请求,参数校验,返回统一结果 ├── service // 业务逻辑,算法计算,事务控制 ├── mapper // MyBatis数据访问接口 ├── entity // 数据库实体类 ├── dto // 前端交互传输对象,避免直接暴露实体 ├── config // 配置类:拦截器、跨域、Swagger等 ├── common // 统一返回封装、自定义异常、工具类 └── task // 定时任务,离线计算推荐结果

Controller层非常薄,只做参数接收和结果返回;业务逻辑集中在Service层;复杂的SQL语句写在XML Mapper里。这样做的直接好处是:当推荐算法需要调整时,只需要改Service层,Controller和数据库层完全不用动。

另外我强烈建议引入DTO层。很多初学者图省事,直接把Entity返回给前端,这样会导致数据库表结构泄露到前端接口里,比如用户表的密码字段如果不加处理就会被序列化出去。用DTO之后,即使你往实体里加了password字段,只要DTO里没有,就永远不可能泄露。

4.2 登录鉴权与Token方案

登录模块我选择使用JWT实现无状态鉴权,流程是:

用户输入用户名密码 → 后端校验 → 生成JWT令牌 → 返回给前端 → 前端后续请求在Header中携带Authorization: Bearer <token>→ 拦截器解析Token并放行。

@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException(401, "未登录或登录已过期"); } String jwt = token.substring(7); Claims claims = Jwts.parser() .setSigningKey("your-secret-key") .parseClaimsJws(jwt) .getBody(); request.setAttribute("userId", claims.get("userId")); return true; } }

密码存储方面,我使用的是加盐哈希,具体用BCryptPasswordEncoder,每次生成密码时自动带随机盐,存储的哈希值即使泄露也无法反推出明文密码。实际项目中密码一定不能明文存储,这是底线,就算只是一个课程设计也不能偷懒。

4.3 MyBatis XML下复杂查询的写法要点

这个项目里的MyBatis配置有两个高频使用的模式值得记录:批量插入和动态条件查询。

批量插入相似度记录时,数据量可能几千条,一条条insert会有严重的性能问题,我选择在XML里用foreach拼接批量插入:

<insert id="batchInsert" parameterType="List"> INSERT INTO similarity (item_id_a, item_id_b, score) VALUES <foreach collection="list" item="item" separator=","> (#{item.itemIdA}, #{item.itemIdB}, #{item.score}) </foreach> </insert>

批量插入需要特别注意MySQL的max_allowed_packet限制,默认一般是64M,几千条数据完全可以承载,但如果你把整个相似度矩阵一次性插入,数据量涨到十万条级别时就要分批插入,每次500条比较稳妥。

电影查询接口需要支持关键词搜索、类型筛选、年份区间等多个条件的组合,我用<where>标签动态拼接SQL,避免为每种组合单独写一条SQL:

<select id="searchMovies" resultType="Movie"> SELECT * FROM movie <where> <if test="keyword != null and keyword != ''"> AND title LIKE CONCAT('%', #{keyword}, '%') </if> <if test="genre != null and genre != ''"> AND genre = #{genre} </if> <if test="startYear != null"> AND release_year >= #{startYear} </if> </where> ORDER BY rating_avg DESC </select>

这个写法还有一个隐性的好处:如果所有查询条件都为空,<where>会自动去掉多余的WHERE关键字,SQL不会报错,返回的就是全表数据,作为一个兜底行为非常安全。

4.4 定时任务刷新推荐结果

离线计算的触发我用了Spring自带的@Scheduled注解,比引入Quartz轻量得多。

@Component public class RecommendTask { private final ItemCfService itemCfService; private final RecommendService recommendService; @Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点执行 public void refreshAll() { // 1. 先计算电影相似度 itemCfService.computeItemSimilarity(); // 2. 再为每个用户生成推荐列表 recommendService.generateForAllUsers(20); } }

定时任务配置要注意一个细节:单机环境下Spring的@Scheduled默认单线程串行执行,多个任务要错开时间。如果项目部署在多台服务器上,同一时刻多台机器都会执行任务造成重复计算,需要引入分布式锁。这个项目定位单机部署,所以直接用@Scheduled即可,但你在答辩时可以提一句分布式扩展的思路。

5. Vue3前端界面与API对接实战

5.1 前端工程结构和页面路由设计

前端使用Vue3 + Vite + Vue Router + Pinia + Axios这套主流组合。Vite的启动速度和热更新体验比Webpack好太多,用起来非常顺手。页面结构我设计了六条路由:

  • /login登录页
  • /register注册页
  • /首页:轮播图 + 推荐列表
  • /movies电影列表:支持分类筛选和搜索
  • /movie/:id电影详情:展示信息、用户评分入口
  • /profile个人中心:我的评分历史

路由守卫控制访问权限:未登录访问除登录注册外的页面,统一重定向到/login。判断依据是Pinia仓库里的Token字段,页面加载时再从localStorage同步一次,刷新后状态不丢失。

5.2 Axios封装与拦截器设计

Axios如果每次请求都手写一遍配置,代码会很快失控。我用一个统一封装来处理Header注入、错误提示、超时设置:

// request.js import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '../store/user' import router from '../router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { ElMessage.error('登录已过期,请重新登录') const userStore = useUserStore() userStore.clearToken() router.push('/login') } else { ElMessage.error(error.message || '网络异常') } return Promise.reject(error) } )

统一返回结构设计成{ code, message, data },code为200表示成功。拦截器统一剥离data层,这样业务代码里拿到的直接是真实数据,不用每个页面都写res.data.data这种嵌套地狱。401处理放在拦截器里也是为了避免每个页面重复写“跳转登录”的逻辑。

5.3 电影推荐页与评分交互的实现

推荐页是前端最核心的展示界面。我用el-carousel做推荐电影轮播,从后端接口拿推荐列表数据,每部电影展示海报和预测分,点击后跳转详情页。

评分交互是引导用户贡献数据的关键,也是协同过滤闭环的重要一环。电影详情页里我放了一个五角星评分组件,用户选择星级后调用评分接口:

<template> <div class="rating-panel"> <h3>我的评分</h3> <el-rate v-model="score" :max="5" @change="submitRating" /> </div> </template> <script setup> import { ref } from 'vue' import request from '../utils/request' const props = defineProps({ movieId: Number }) const score = ref(0) async function submitRating(val) { try { await request.post('/rating/submit', { movieId: props.movieId, score: val }) ElMessage.success('评分成功') } catch (e) { score.value = 0 } } </script>

这里有一个值得注意的交互细节:提交评分后,需要在前端本地更新电影的平均分展示,而不是重新拉取整个详情接口。这样用户能立刻看到自己评分对平均分的影响,感知到系统的“反馈闭环”。在相亲场景里这叫“即时反馈”,在推荐系统里这就是用户参与度提升的触发点。

5.4 Vite开发代理解决跨域问题

前后端分离必然遇到跨域问题。开发模式下的解法很简单:在vite.config.js里配置代理,让前端发起请求时把/api开头的地址转发到后端端口:

export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

生产部署时,用Nginx做反向代理统一处理前后端路由。前端构建产物放静态目录,/api转发到Java服务。这种部署模式下,浏览器里只有一个域名,不存在跨域问题,也不用手动配置CORS,比在后端代码里写@CrossOrigin到处放行更安全。

6. 常见问题排查与性能优化实录

6.1 相似度全为0或推荐列表为空

这是最典型的翻车现场。排查思路先看评分表里有没有数据。如果用户都还没有评分,相似度表肯定是空的,推荐页自然什么都出不来。

另一个常见原因是浮点数精度问题。用BigDecimal计算时,如果比例尺设置不当,比如相似度值全部被改成0.00000000,导致取相似电影时查不到任何记录。解决办法是把相似度保留位数设定为8位,并且插入前检查score是否大于一个极小阈值如0.0001,小于则跳过,避免噪声数据污染。

还有一种隐蔽情况:user_score字段是TINYINT类型,Java实体类里如果设置成Integer,计算时要手动转成double,否则表达式sim.getScore().doubleValue() * r.getScore()会因为是Integer计算自动降级成int,结果直接变成0。这类坑非常隐秘,我排查了很久。

6.2 SQL性能瓶颈与索引优化

评分表在数据量上来之后,SELECT * FROM rating这种全表查询就会成为瓶颈。我加了两个关键索引:

  • idx_user_id:按用户查评分记录时使用
  • 联合唯一索引uk_user_movie:同时兼顾了按用户/电影查询的性能和唯一性约束

相似度表查询selectByItemId依赖idx_item_a索引,查询时按item_id_a过滤,效率非常高。

实际测试里,100个用户、500部电影、8000条评分记录,ItemCF全量计算在普通笔记本上耗时在1-2秒内,可接受。但当电影数增长到5000部以上时,两两计算的时间会指数增长,后续可以考虑只对“活跃电影”计算相似度,或者引入Spark分布式计算,这也是系统演进的方向。

6.3 热门电影比推荐电影更受欢迎的现象

第一次运行系统时我发现一个现象:推荐列表里总会出现几部高评分热门电影,淹没了那些更应该推荐的“个性化”电影。原因是热门电影被很多人评分,它们的相似度连接更广,在加权累加时会累积出更高的候选分。

解法是在推荐排序时加入一个惩罚因子:候选电影的评分人数越多,降权越多。具体做法是把候选电影对应的相似度表中共同用户数作为惩罚项参与排序。这样能把推荐结果从“大家都在看的热门片”拉向“你可能会喜欢的冷门佳作”,推荐系统的个性化和惊喜度提升非常明显。

6.4 定时任务跑完但没有新推荐结果

这个问题的根因通常不是任务没执行,而是推荐结果表没清理或者用户评分数据没变化。我在generateForAllUsers方法里先deleteAll再重新插入,如果计算过程中出现异常,新数据没写入,旧数据又被删了,推荐页就空了。

解决策略:把“删除”和“生成”拆成两个阶段,先生成新结果,全部成功后再做替换。简单做法是推荐表增加一个batch_id字段,每次计算生成新的batch编号,查询时只取最新batch的数据,旧数据自动失效。这种“双写切换”模式在电商推荐场景也是标配。

7. 项目迭代方向与个人实操心得

做到这里,这个系统的功能已经完整可跑了。但如果你想让项目更出彩,有三条明确的迭代方向可以选:

第一条是引入混合推荐。协同过滤+基于内容的推荐,内容推荐可以依据电影类型、导演、演员做规则匹配,两路结果按权重融合,能同时缓解冷启动问题。

第二条是增加实时行为反馈。当前系统只有评分一种反馈,可以增加“收藏”“点击详情”“浏览时长”这些隐式反馈,用这些信号动态调整推荐排序。隐式反馈不需要用户主动打星,数据量更大,推荐效果会更细腻。

第三条是可视化分析模块。在后台管理页面展示评分数据分布、用户活跃度、推荐命中率等统计图表——技术上用ECharts就能做,但能让整个系统从“能运行”变成“可汇报”。答辩时PPT里放几张可视化分析图表,效果远好于截图代码。

我在实际做这个东西的过程中体会最深的一点是:协同过滤本身的数学公式并不难,真正耗时的是把算法和工程架构融合起来。数据库表之间怎么设计才能让算法取数方便,前端交互怎么引导用户产生有效评分,定时任务怎么保证可靠不重不漏——这些工程细节才是区分一个“能用”的系统和“好用”的系统之间的分水岭。

如果你决定上手这个项目,我个人的建议是不要直接去跑现成源码,先把评分表建起来,手动插入20条评分数据,再调算法接口看推荐结果,逐步理解它的工作逻辑。当你看到推荐的电影恰好是自己喜欢的方向时,那种“算法技术终于落地”的正反馈会给你带来非常大的成就感。

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

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

立即咨询