基于协同过滤的Java电影推荐系统源码实战 简介基于协同过滤算法的Java电影推荐系统源码面向Java开发者、电影类流媒体平台及相关专业学习者。系统通过分析用户历史行为数据提取偏好特征并智能推荐符合口味的电影适合课程设计、毕业设计参考或二次开发起点。压缩包共76个文件包括38个Java源文件、15个JSP页面、10个XML配置、2个SQL脚本及JS、CSS等业务逻辑、页面展示与数据库脚本分层存放整体结构清晰压缩包大小仅1.1MB下载部署轻量。目前已有823人学习/下载受到开发者关注。源码可直接导入常见IDE运行配合SQL脚本快速初始化数据库帮助理解协同过滤算法从评分矩阵构建、相似度计算到TopN推荐的完整落地过程同时可借鉴JSPServlet风格的Web分层设计、数据访问与接口组织方式是JavaWeb与推荐系统入门及项目实战的高性价比参考。1. 基于协同过滤算法的Java电影推荐系统源码它解决什么问题值不值得照着做你手头有一个电影站的后台每天进来几千用户留下几万条点击和打分但首页只能按时间或热度排运营问你“能不能给每个人推不一样的电影”。这个标题给的就是一条能直接落地的路线基于协同过滤算法的Java电影推荐系统源码从数据表设计、相似度计算到Top-N推荐接口全部用Java技术栈实现不依赖Python服务、不引入Spark一台普通服务器加MySQL就能跑起来。它适合三类人做Java课程设计和毕业设计的学生接私活需要快速交付推荐模块的工程师以及正在刷java基础面经、随时可能被问到“协同过滤怎么算、缺点是什么”的求职者。后面我会拆成算法选型、数据建模、核心代码、工程接口和踩坑记录五块每一步都能照着抄也能照着排错。2. 协同过滤算法选型UserCF与ItemCF的取舍以及Java实现前的数据准备协同过滤不是一种算法而是一族算法落地前如果选错方向后面写多少代码都救不回来。这一章先解决两个问题你的场景到底选UserCF还是ItemCF以及算法跑起来之前评分数据应该以什么形态装进Java内存。2.1 UserCF还是ItemCF先看你的用户量和电影量协同过滤分两派基于用户的UserCF和基于物品的ItemCF。UserCF先找出和你口味最接近的一批用户再把这批用户看过而你没看过的电影推荐给你ItemCF反过来先算出电影和电影的相似度然后从你自己打过高分的电影出发找没看过的相似片单。两者数学上都在算相似度矩阵但适用场景差的非常多。我的经验是用户量远大于物品量的场景UserCF会先崩。比如一个电影站有50万注册用户、1万部电影UserCF要算50万乘以50万的用户相似度矩阵那就不叫推荐系统叫内存杀手。反过来物品量远大于用户量的场景ItemCF的物品相似度表同样会爆炸。电影推荐的典型特征是物品稳定、用户量大业内普遍首选ItemCF做主力UserCF用来做“和你口味相似的人也在看”这种社交化榜单两个一起上也没问题。在这套源码里我会把两个都实现通过配置文件切换默认走ItemCF。这样既方便交作业时讲清选型理由也方便你拿同一份数据做对比实验。还有两个必调的参数相似度阈值和邻居数K。我一般把“相似度低于0.1的邻居直接扔掉”做成常量配置而不是散落在代码里后面调参时只需要改配置文件不用重编译。2.2 MovieLens数据集与Java实体建模从CSV到User-Rating矩阵推荐系统没有数据就是空中楼阁最常见的做法是拿MovieLens开源数据集里面包含users、movies、ratings三个文件。ratings文件每行是“userId::movieId::rating::timestamp”评分是1到5的整数这个字段格式直接决定了Java代码里怎么切分字符串。把这份数据映射成Java对象最少只需要两个东西一个Rating实体类和一个能装载整个评分矩阵的DataLoader。下面这段代码先定义了Rating实体再用一个方法把CSV解析成嵌套Map。为什么用Map而不是二维数组假设1万个用户、1万部电影稠密二维数组要开1亿个格子double[][]占用约800MB内存而真实评分记录可能只有10万条。稀疏Map只存放有的位置内存占用能降一个数量级。public class Rating { private int userId; private int movieId; private double score; private long timestamp; public Rating(int userId, int movieId, double score, long timestamp) { this.userId userId; this.movieId movieId; this.score score; this.timestamp timestamp; } public int getUserId() { return userId; } public int getMovieId() { return movieId; } public double getScore() { return score; } public long getTimestamp() { return timestamp; } }public class DataLoader { public static MapInteger, MapInteger, Double loadRatings(String path) throws IOException { MapInteger, MapInteger, Double userItemMap new HashMap(); try (BufferedReader br new BufferedReader(new FileReader(path))) { String line; while ((line br.readLine()) ! null) { String[] fields; if (line.contains(::)) { fields line.split(::); } else { fields line.split(,); } if (fields.length 3) { continue; } try { int userId Integer.parseInt(fields[0]); int movieId Integer.parseInt(fields[1]); double score Double.parseDouble(fields[2]); userItemMap.computeIfAbsent(userId, k - new HashMap()).put(movieId, score); } catch (NumberFormatException e) { // 脏数据直接跳过真实环境不能因为一行坏数据挂掉整个加载过程 } } } return userItemMap; } }逻辑说明userItemMap的结构是“用户ID - (电影ID - 评分)”这是UserCF的输入形态。关键方法是computeIfAbsent它是java基础里很常用的Map操作等价于“key不存在就new一个HashMap放进去存在就直接用”比先判断再put的三行样板代码干净得多。注意我在解析里同时兼容了双冒号和逗号两种分隔符因为MovieLens旧版用“::”新版改成了逗号写死任何一种都会在换数据集时翻车。NumberFormatException的catch也是必须的爬虫拉来的数据永远比你想象的脏。2.3 评分矩阵的存储与加载为什么要转置成ItemCF能用的结构实际写ItemCF时光有userItemMap还不够因为它要回答“某部电影被哪些人评过分”这是userItemMap的反向关系。常见做法是启动时做一次转置生成itemUserMap结构是“电影ID - (用户ID - 评分)”内存开销比再读一遍文件要小。public class DataLoader { public static MapInteger, MapInteger, Double transpose( MapInteger, MapInteger, Double userItemMap) { MapInteger, MapInteger, Double itemUserMap new HashMap(); for (Map.EntryInteger, MapInteger, Double userEntry : userItemMap.entrySet()) { int userId userEntry.getKey(); MapInteger, Double items userEntry.getValue(); for (Map.EntryInteger, Double itemEntry : items.entrySet()) { int movieId itemEntry.getKey(); double score itemEntry.getValue(); itemUserMap.computeIfAbsent(movieId, k - new HashMap()).put(userId, score); } } return itemUserMap; } }参数说明这段是纯循环遍历不依赖任何框架用Java 8的forEach也能写但传统的for在这种双重遍历下可读性更好。转置完以后ItemCF的相似度计算只需要把itemUserMap里的两个value取出来求余弦连Map结构都几乎不用改。还有一个值得做的预处理过滤掉评分记录少于5条的用户这些人对相似度矩阵贡献的是噪声删掉以后推荐质量会明显提升——这个阈值在真实项目里要按数据量调数据稀疏就降到2到3数据稠密就提到10。3. 用Java实现协同过滤核心相似度计算、UserCF与ItemCF的完整代码这一章是全篇的重头戏。我按“相似度函数 - UserCF流程 - ItemCF流程 - TopN排序”的顺序给代码每个函数都能直接复制进你的工程跑起来。拿到源码后先改数据路径再调阈值最后看推荐列表比对着概念空想快得多。3.1 余弦相似度与皮尔逊相关系数两段可直接抄的Java方法相似度是协同过滤的心脏。最常用的是余弦相似度和皮尔逊相关系数。余弦相似度把每个用户的评分看成一个向量计算两个向量夹角的余弦值完全同向为1垂直为0反向为-1。皮尔逊相关系数相当于对两个用户各自的评分做中心化也就是减掉各自均值之后再算余弦它能抵消“有人手松全打5分、有人手紧平均给3分”这种偏差。public class SimilarityUtil { public static double cosine(MapInteger, Double a, MapInteger, Double b) { SetInteger common new HashSet(a.keySet()); common.retainAll(b.keySet()); if (common.isEmpty()) { return 0.0; } double dot 0.0; for (int id : common) { dot a.get(id) * b.get(id); } double normA 0.0; for (double v : a.values()) { normA v * v; } double normB 0.0; for (double v : b.values()) { normB v * v; } if (normA 0.0 || normB 0.0) { return 0.0; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } public static double pearson(MapInteger, Double a, MapInteger, Double b) { SetInteger common new HashSet(a.keySet()); common.retainAll(b.keySet()); if (common.size() 2) { return 0.0; } double sumA 0.0; double sumB 0.0; for (int id : common) { sumA a.get(id); sumB b.get(id); } double meanA sumA / common.size(); double meanB sumB / common.size(); double numerator 0.0; double denomA 0.0; double denomB 0.0; for (int id : common) { double va a.get(id) - meanA; double vb b.get(id) - meanB; numerator va * vb; denomA va * va; denomB vb * vb; } if (denomA 0.0 || denomB 0.0) { return 0.0; } return numerator / (Math.sqrt(denomA) * Math.sqrt(denomB)); } }参数说明皮尔逊至少要求2个共同评分否则分子分母恒为0算出来是NaN在推荐链路里NaN会一路传染到排序结果。还要注意一个细节余弦相似度的normA、normB是对用户全部评分计算的而不是只对共同评分的电影计算。如果你只算共同交集里的模长结果会把“看过很多电影的用户”和“只看过几部电影的用户”拉到同一水平线上这是我踩过的坑忘一次错一次。3.2 用Java实现UserCF找TopK相似用户加权汇总候选电影UserCF的流程拆成四步取出目标用户的评分遍历全部用户算相似度按相似度排序取前K个邻居最后把邻居们评过分而目标用户没看过的电影按相似度加权汇总。预测分数用“加权平均”而不是简单求和这是为了避免邻居多的用户天然占优。public class UserCF { private final MapInteger, MapInteger, Double userItemMap; private final int topK; private final int topN; private final double minSimilarity; public UserCF(MapInteger, MapInteger, Double userItemMap, int topK, int topN, double minSimilarity) { this.userItemMap userItemMap; this.topK topK; this.topN topN; this.minSimilarity minSimilarity; } public ListMap.EntryInteger, Double recommend(int targetUserId) { MapInteger, Double targetRatings userItemMap.get(targetUserId); if (targetRatings null || targetRatings.isEmpty()) { return Collections.emptyList(); } MapInteger, Double simMap new HashMap(); for (Map.EntryInteger, MapInteger, Double entry : userItemMap.entrySet()) { int otherUserId entry.getKey(); if (otherUserId targetUserId) { continue; } double sim SimilarityUtil.pearson(targetRatings, entry.getValue()); if (sim minSimilarity) { simMap.put(otherUserId, sim); } } ListMap.EntryInteger, Double neighbors topN(simMap, topK); MapInteger, Double scoreMap new HashMap(); MapInteger, Double weightMap new HashMap(); for (Map.EntryInteger, Double neighbor : neighbors) { int neighborId neighbor.getKey(); double sim neighbor.getValue(); for (Map.EntryInteger, Double rating : userItemMap.get(neighborId).entrySet()) { int movieId rating.getKey(); if (targetRatings.containsKey(movieId)) { continue; } scoreMap.merge(movieId, sim * rating.getValue(), Double::sum); weightMap.merge(movieId, sim, Double::sum); } } MapInteger, Double predictMap new HashMap(); for (Integer movieId : scoreMap.keySet()) { predictMap.put(movieId, scoreMap.get(movieId) / weightMap.get(movieId)); } return topN(predictMap, topN); } private ListMap.EntryInteger, Double topN(MapInteger, Double map, int n) { return map.entrySet().stream() .sorted(Map.Entry.Integer, DoublecomparingByValue().reversed()) .limit(n) .collect(Collectors.toList()); } }逻辑说明minSimilarity默认给0.1低于这条线的用户不做邻居topK默认给20。在稀疏的数据集上把minSimilarity降到0.05能多召回不少候选但同时引入噪声。scoreMap里累加的是“相似度乘以邻居评分”weightMap里累加的是相似度本身两者相除得到加权平均分天然落在1到5区间不用再归一化。过滤“已看过的电影”必须做否则推荐列表里出现用户看过的片子产品验收这关就过不了。3.3 用Java实现ItemCF先离线建电影相似度表再在线召回ItemCF和UserCF最大的差别是把“用户与用户相似”换成“电影与电影相似”而电影相似度可以预先离线算好。第一步把userItemMap转置成itemUserMap第二步对两两电影算余弦相似度写入movieSimMap第三步在线推荐时从用户评分过的电影出发去相似表里捞候选。下面这段先给出建表和召回两个方法。public class ItemCF { private final MapInteger, MapInteger, Double itemUserMap; private final MapInteger, MapInteger, Double movieSimMap new HashMap(); private final int numSimilar; private final int topN; private final double minSimilarity; public ItemCF(MapInteger, MapInteger, Double userItemMap, int numSimilar, int topN, double minSimilarity) { this.itemUserMap DataLoader.transpose(userItemMap); this.numSimilar numSimilar; this.topN topN; this.minSimilarity minSimilarity; buildSimilarityTable(); } private void buildSimilarityTable() { ListInteger movieIds new ArrayList(itemUserMap.keySet()); for (int i 0; i movieIds.size(); i) { int movieA movieIds.get(i); MapInteger, Double usersOfA itemUserMap.get(movieA); for (int j i 1; j movieIds.size(); j) { int movieB movieIds.get(j); MapInteger, Double usersOfB itemUserMap.get(movieB); double sim SimilarityUtil.cosine(usersOfA, usersOfB); if (sim minSimilarity) { movieSimMap.computeIfAbsent(movieA, k - new HashMap()).put(movieB, sim); movieSimMap.computeIfAbsent(movieB, k - new HashMap()).put(movieA, sim); } } } } public ListMap.EntryInteger, Double recommend(MapInteger, Double userRatings) { MapInteger, Double scoreMap new HashMap(); MapInteger, Double weightMap new HashMap(); for (Map.EntryInteger, Double entry : userRatings.entrySet()) { int ratedMovie entry.getKey(); double rating entry.getValue(); MapInteger, Double simMovies movieSimMap.get(ratedMovie); if (simMovies null) { continue; } ListMap.EntryInteger, Double similarList topN(simMovies, numSimilar); for (Map.EntryInteger, Double similar : similarList) { int candMovie similar.getKey(); double sim similar.getValue(); if (userRatings.containsKey(candMovie)) { continue; } scoreMap.merge(candMovie, sim * rating, Double::sum); weightMap.merge(candMovie, sim, Double::sum); } } MapInteger, Double predictMap new HashMap(); for (Integer movieId : scoreMap.keySet()) { predictMap.put(movieId, scoreMap.get(movieId) / weightMap.get(movieId)); } return topN(predictMap, topN); } }逻辑说明buildSimilarityTable是双重循环只算上三角j从i1开始并在两边同时写入能把计算量减半。movieSimMap的规模是“存量电影数乘以平均邻居数”1万部电影最终可能存几十万个相似对完全在内存可接受的范围内。recommend方法里的numSimilar我通常取10意思是一部电影最多找10个相似电影参与累加避免相似度表过大时每个候选都要遍历全表。这段代码的潜在翻车点如果你在recommend里传入了空的userRatingsscoreMap和weightMap为空最后返回空列表——调用方要提前做冷启动判断不能救到这一步。3.4 TopN排序与推荐列表去重用一个工具方法收尾无论是UserCF还是ItemCF最后都要把候选按得分降序截断成指定长度。Java里最省事的写法是stream加sorted加limit但要注意一个细节当分数相同且条目数超过limit时stream limit的结果顺序不稳定。工程上更稳妥的做法是先按“分数降序、电影ID升序”做二次排序保证结果是确定性的。排序这块我不建议自己手写冒泡排序java教科书代码数据量上去之后性能不够看。用JDK自带的排序就行private ListMap.EntryInteger, Double topN(MapInteger, Double map, int n) { return map.entrySet().stream() .sorted(Map.Entry.Integer, DoublecomparingByValue().reversed() .thenComparing(Map.Entry.comparingByKey())) .limit(n) .collect(Collectors.toList()); }参数说明thenComparing(Map.Entry.comparingByKey())是加分项它解决了同分时排序结果不稳定导致的前端展示跳变问题。如果你还在JDK 8以下把stream换成Collections.sort然后手动截断效果一样。去重逻辑在核心流程里已经做了两次一是在累加候选时跳过用户已评分的电影二是在最终推荐表里不重复插入同一部电影复合主键(user_id, movie_id)兜底防止并发写重复。4. 把算法源码变成可交付的工程Spring Boot MyBatis的分层与接口算法能跑通只是第一步推荐系统源码真正值钱的部分在工程化目录分层是否清晰、数据从哪儿来、接口怎么暴露。这一章我按一个常见做法来拆Maven工程打底Spring Boot接HTTPMyBatis管SQL算法层保持纯净。4.1 推荐系统源码的目录结构算法包和业务包为什么必须分开我见过很多推荐系统源码最头疼的是把协同过滤算法写在Controller里一个类几百行既没法单测也没法复用。正确的做法是把算法层和Spring框架彻底隔离目录结构大致是这样movie-recommend/ ├── pom.xml └── src/main/java/com/example/recommend/ ├── algorithm/ │ ├── SimilarityUtil.java │ ├── UserCF.java │ ├── ItemCF.java │ └── DataLoader.java ├── entity/ │ ├── Movie.java │ └── UserRating.java ├── mapper/ │ ├── MovieMapper.java │ └── UserRatingMapper.java ├── service/ │ └── RecommendService.java ├── controller/ │ └── RecommendController.java └── RecommendApplication.javaalgorithm包里的类不依赖Spring的Component、Autowired这些注解放到任何Java 8环境都能直接运行。这样做的直接收益是你可以写一个main方法单独加载MovieLens数据调UserCF不用启动一个Web容器就能验证算法效果debug一个推荐逻辑从改代码到看到结果不超过10秒。这也是面向对象编程java里“职责单一”思想落地算法只负责算Service只负责编排Controller只负责传输。pom.xml里需要引入的东西很简单Spring Boot Web、MyBatis Starter和MySQL驱动。版本以你自己本地的Spring Boot为准我习惯用2.7.x搭配MyBatis 2.x如果是新学的同学直接用3.x也一样接口上没有大差别。4.2 MySQL表设计评分表、电影表与推荐结果表字段和索引这样定推荐系统要落库至少要有三张表电影表存元数据评分表存用户行为推荐结果表存离线算好的TopN。表结构可以直接照抄下面这个字段名我压到了最少CREATE TABLE movie ( id int NOT NULL, title varchar(255) NOT NULL, genres varchar(255) DEFAULT NULL, PRIMARY KEY (id) ); CREATE TABLE user_rating ( id bigint NOT NULL AUTO_INCREMENT, user_id int NOT NULL, movie_id int NOT NULL, score double NOT NULL, create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_movie_id (movie_id) ); CREATE TABLE recommend_result ( user_id int NOT NULL, movie_id int NOT NULL, score double NOT NULL, rank int NOT NULL, update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, rank) );参数说明user_rating表的核心是user_id和movie_id两个索引user_id索引服务于“给某用户取全部评分”movie_id索引服务于“给某电影取全部评分用户”第二个用途是ItemCF离线计算时的SQL支撑。recommend_result表用(user_id, rank)做复合主键rank直接存1到N意味着同一个用户新一轮结果只要按rank覆盖插入不用先删后插省一次事务。score字段存的是协同过滤算出来的预测分前端排序直接按它排就行。4.3 Spring Boot接口层Controller保持轻薄Service兜底冷启动接口层最常见的错误是把算法塞进Controller再包一层循环。下面这个例子是标准的三层写法先看ControllerRestController RequestMapping(/api/recommend) public class RecommendController { private final RecommendService recommendService; public RecommendController(RecommendService recommendService) { this.recommendService recommendService; } GetMapping(/{userId}) public ListMovieVO recommend(PathVariable int userId) { return recommendService.recommendForUser(userId); } }逻辑说明Controller只干一件事把userId参数取出来交给Service再把Service返回的结果以JSON形式吐给前端。构造函数注入比字段上加Autowired更利于测试这也是很多公司代码规范里的要求。MovieVO是一个轻量DTO只有movieId、title、score、rank四个字段不要直接把MyBatis的实体类返回给前端否则表结构一改接口字段就跟着变坑所有调用方。紧接着是Service层Service public class RecommendService { private final UserRatingMapper userRatingMapper; private final RecommendResultMapper recommendResultMapper; private final MovieMapper movieMapper; public RecommendService(UserRatingMapper userRatingMapper, RecommendResultMapper recommendResultMapper, MovieMapper movieMapper) { this.userRatingMapper userRatingMapper; this.recommendResultMapper recommendResultMapper; this.movieMapper movieMapper; } public ListMovieVO recommendForUser(int userId) { ListRecommendResult results recommendResultMapper.selectByUserId(userId); if (results null || results.isEmpty()) { ListMovie hotMovies movieMapper.selectHotMovies(20); return MovieVO.fromMovies(hotMovies); } return MovieVO.fromResults(results); } }参数说明Service层做了冷启动兜底——如果recommend_result表里查不到这个用户直接查movie表里的热门电影榜返回前端的推荐位永远不会空。这比在算法层强行判断更干净因为接口的语义变成“永远有推荐结果”。selectHotMovies的SQL用“评分人数和平均分”组合排序ORDER BY score_count DESC, avg_score DESC LIMIT 20不需要join性能就够。如果要把在线实时推荐也接进来做法是在Service里注入ItemCF的Bean把mapper查到的评分记录转成Map结构喂给算法但这要求评分数据量小到能在几十毫秒内完成遍历。数据量超过10万条以后我更建议离线计算落库再加每日定时任务别让算法接口扛实时压力这就是“离线为主、实时兜底”的常见架构。5. 协同过滤必踩的五个坑冷启动、稀疏矩阵、评分偏差与性能翻车排查笔记这一章的每条记录都是真实调参时会撞见的现象我按“现象、原因、解决”三段写你照着日志对比就知道问题在哪一环。5.1 冷启动新用户没有评分推荐结果永远是空的现象刚注册的用户调用推荐接口返回空列表前端页面在“猜你喜欢”区域一片空白转化率直接归零。原因UserCF和ItemCF都依赖历史评分。用户没有任何行为时相似度计算的分母是0任何公式都产出0算法层面无能为力。解决冷启动阶段不做协同过滤用热门榜兜底。我前面Service里的实现就是把selectHotMovies结果返回出去。热门榜不能简单用评分人数排序我一般用“评分人数×平均分”再乘时间衰减权重避免老片霸榜。等用户产生了五六条评分记录之后再切换回协同过滤逻辑这是最简单的渐进式冷启动。5.2 稀疏矩阵两个用户只共同看过一部电影相似度却高达0.9现象调日志时发现一对用户相似度0.87但他们共同评分只有1部电影。相似表里大量这样虚高的相似对推荐结果跑偏。原因余弦相似度在交集样本极少时统计上不显著一个共同评分就能把夹角带得极小相似度虚高。电影站动辄上万部电影用户平均评分又少交集为0是常态交集为1根本不稀奇。解决加置信度门槛当共同评分数小于阈值时直接返回0。我在SimilarityUtil.pearson里已经写了common.size() 2就返回0.0实际项目里建议把阈值提到5。同时优先使用皮尔逊相关系数替代原始余弦因为皮尔逊先做了均值中心化对“共同评分样本少但绝对分数高”的抗性更好。这条在数据稀疏的新站上尤其重要。5.3 评分偏差有人手松全打5分有人手紧只打3分余弦相似度直接失真现象用户A给所有电影打5分用户B同样几部电影打4分余弦相似度依然算得很高可两人的真实口味可能完全相反。原因余弦相似度把绝对分数当成向量方向来算没有减去每个用户的平均分。手松和手紧在绝对分数上差了整整一个常数偏移这个偏移在余弦公式里没有被消除。解决改用皮尔逊相关系数等价于把每个用户的评分先中心化原分数减均值再算余弦。我的SimilarityUtil里两个方法都写了工程里默认走pearson就对了。如果你坚持用余弦那就先做归一化我可以把“score - userAvgScore”存入Map后再交给余弦方法。5.4 热门电影屠榜推荐结果全是全局爆款多样性和个性化同时归零现象离线计算结果里Top10推荐总是那几部评分人数最多的电影每个用户的列表大同小异运营抱怨“推荐没有差异性”。原因热门电影被大量用户评分它们和所有电影都容易产生相似关系用户在评分过的电影里去捞相似候选热门电影出现的频率极高累加积分碾压小众片。解决累加权重时加入热门惩罚。常见做法是对候选电影的相似度乘上一个衰减系数sim / Math.pow(1 Math.log(1 itemUserMap.get(movieId).size()), 2)这个系数随评分人数上升而下降。还可以在后处理里做类型约束按genres分组每组最多选3部保证TopN里有喜剧也有悬疑而不是清一色的动作大片。这一步是推荐结果从“能用”到“像样”的分水岭。5.5 性能翻车循环里查库导致N1查询一次推荐接口把数据库打挂现象一次推荐请求发出后数据库慢查询日志里出现两三百条SQL接口耗时8秒QPS一高MySQL连接直接打满。原因典型的N1问题。常见写法是在算法里遍历用户列表每次循环调一次mapper查询评分比如for (userId : allUsers) { userItemMap userMapper.selectRatingsByUserId(userId); }。用户量一上来这个循环就是灾难。解决把行为数据一次性加载进内存。在Spring Boot启动时用PostConstruct或ApplicationRunner执行一次全量load把评分记录组装成userItemMap和itemUserMap保存在单例Bean里接口只从内存取数。数据量超过百万条时改为离线Spark或定时任务预计算后再落recommend_result表在线接口绝不碰评分明细表只查推荐结果表。这条不只是性能问题还是一个架构取舍推荐结果表里只有每个用户前20条结果查询走主键P95响应时间能压到10毫秒以内。6. 验证推荐结果是否靠谱离线切分、覆盖率与K值调参的进阶收尾推荐系统源码写完最容易被忽略的一步是验证。没有验证手段你就分不清“参数调好了”和“参数调顺手了”。我最后分享一个每次都会做的离线验证流程和两个指标。先把评分数据按时间切分按timestamp排序前80%作为训练集后20%作为测试集。协同过滤只用训练集计算相似度和推荐然后用测试集里用户真实看过的电影和推荐结果做比对。如果推荐结果里出现了用户真实看过的测试集电影说明这次推荐是命中的。命中数除以测试集电影数就是召回率再算一下推荐结果覆盖了电影全量的多少比例得到覆盖率。这两个数字一个衡量准确度一个衡量多样性。K值和相似度阈值的调参法固定minSimilarity为0.1把K从5、10、15、20依次跑一遍记录每组的召回率和覆盖率。你会发现K小的时候精度高但覆盖面窄K大了以后结果趋于热门化。我在做电影推荐这类的经验值是K20到30minSimilarity0.1到0.2如果你的数据更稀疏K往下调、minSimilarity往下调数据更稠密就反过来。调参顺序是先调K再调minSimilarity最后才调热门惩罚系数因为K对结果的影响最直接。最后一个进阶技巧是存一份movieSimMap的序列化缓存。ItemCF建电影相似度表在几千部电影时需要几十秒可能每次重启都要等。常见做法是用Java自带的ObjectOutputStream把movieSimMap写到本地临时文件启动时先检查文件存在就反序列化加载不存在才重建。我会在代码里注明缓存的失效条件只要评分数据更新了就要删掉缓存文件重新生成否则推荐永远不会反映新行为。这套源码我自己交过课设也改过生产环境的需求最大的教训是协同过滤不是装上去就完事的黑匣子它需要你把每一个阈值当成可配置的参数再把验证指标当成调参的准绳。推荐结果能打60分还是90分差别往往不在算法本身而在于你有没有把冷启动兜底和热门惩罚做到位。希望帮到你。本文还有配套的精品资源点击获取