2026/10/13 13:15:51

SpringBoot集成Mahout实现基于用户协同过滤的电影推荐引擎

SpringBoot集成Mahout实现基于用户协同过滤的电影推荐引擎 简介这是一份基于 SpringBoot 与 Apache Mahout 构建电影推荐系统的完整工程源码面向入门至中级 Java 开发者适合希望理解协同过滤算法落地流程、或需要快速搭建推荐服务原型的学习者。项目围绕用户评分数据展开分别实现基于用户与基于物品两种协同过滤思路并借助 SpringBoot 提供 RESTful 接口输出推荐结果。资源共 18 个文件包含 8 个 Java 源文件启动类、配置与推荐逻辑、4 个 CSV 评分与图书数据集、Maven 构建文件pom.xml、mvnw 等及说明文档整体压缩包 24.15MB。文件结构清晰可直接导入 IDE 运行调试便于观察从数据加载到算法执行、结果返回的完整链路。目前已有 613 人学习下载。对想同时掌握 SpringBoot 工程化开发与 Mahout 推荐算法实践的读者来说这是一份能直接上手、边看边改的参考资料。1. 这套 SpringBoot Apache Mahout 推荐引擎工程值得动手跑一遍拿到这套 SpringBoot Apache Mahout 推荐引擎工程时第一印象是它把推荐系统的核心闭环给全了用户对电影的评分数据进来启动后就能按用户输出 topN 推荐结果。它不是画架构图的玩具而是能直接改数据、加用户、切数据源的落地骨架。Mahout 负责相似度计算、邻居选择、推荐生成这些手写容易翻车的地方SpringBoot 负责把推荐能力封装成接口Java 生态里两者组合很顺。它解决的问题很具体你有一批用户对电影的评分系统对每个用户算出接下来他可能想看哪几部。适合正打算给项目塞推荐功能、又不想从零写协同过滤的开发者。真正影响推荐效果的往往不是算法而是评分数据的样子多少人评过分、用户之间共同评分有多少。这些判断下面从原理到踩坑都会讲到。2. 为什么选 Mahout 而不是自己写相似度协同过滤的三个关键选择2.1 基于用户的协同过滤先找相似的人再找他们喜欢的推荐系统里最常见的套路是协同过滤其中基于用户的协同过滤User-Based CF思路最直观把每个用户看过、评过分的电影当成他的“口味画像”想给用户 A 推荐时先找出和 A 口味最像的一批用户 B、C再把 B、C 高分而 A 没看过的电影推给 A。听上去简单写成代码却要处理三个问题。第一个是“口味相似”怎么量化两个用户都看过三部电影评分分别是 5/4/3 和 4/4/2他们到底有多像。第二个是“最像的一批用户”怎么圈出来是取相似度最高的前 50 个还是取相似度超过 0.7 的所有人这两种策略在新用户和老用户身上效果差别很大。第三个是推荐分数怎么算邻居用户对某部电影的评分加权汇总后还要处理“没人看过这部电影”的情况。自己写这三块不是不行但边界情况极多。比如两个用户只共同看过一部电影算出来的相似度能直接冲到满分实际上参考意义很小再比如评分数据稀疏到大部分用户之间没有交集整个推荐流程直接断掉。Mahout 把这些算法和边界处理封装好了而且数据源可以换算法可以换评估器也是现成的这也是我选它而不是自己写相似度的原因。2.2 Mahout 的四个核心接口DataModel 到 Recommender 的组装顺序Mahout 的推荐引擎由四个核心组件拼起来理解它们的组装顺序比背 API 重要DataModel负责提供评分数据可以读文件、读数据库也可以直接放内存。UserSimilarity负责计算两个用户之间的相似度输出一个 0 到 1 之间的值。UserNeighborhood负责从所有用户里圈出目标用户的邻居集合。Recommender把前三者组合起来对外输出推荐结果。看一下最基础的组装代码// 第一步把评分数据装进模型文件里是 userID,itemID,preference DataModel model new FileDataModel(new File(data/ratings.csv)); // 第二步定义“口味相似”怎么算Pearson 相关系数是经典选择 UserSimilarity similarity new PearsonCorrelationSimilarity(model); // 第三步圈邻居这里取相似度最高的前 50 个用户 UserNeighborhood neighborhood new NearestNUserNeighborhood(50, similarity, model); // 第四步用前面三个组件组装推荐器 Recommender recommender new GenericUserBasedRecommender(model, neighborhood, similarity); // 第五步给用户 1 推荐 10 部电影 ListRecommendedItem items recommender.recommend(1L, 10);代码里的逻辑顺序是固定的数据模型是一切的基础相似度计算依赖数据模型邻居选择依赖相似度推荐器依赖全部三者。recommend(1L, 10)返回的RecommendedItem里getItemID()是电影 IDgetValue()是系统预测的评分这个值通常在 1 到 5 之间但偶尔出现异常值也不用慌后面避坑章节会讲。有个很容易误用的点UserNeighborhood不是必选的接口。Mahout 里也有基于物品的推荐器它不圈用户邻居而是找“和用户评分过的电影相似的电影”。但在这套电影推荐 Demo 里基于用户的方式更贴合场景因为电影评分数据天然存在“口味相近的人”而且用户群体相对稳定。2.3 SpringBoot 集成方式启动时构建模型请求时只算 topNMahout 只是个算法库不是服务它不处理 HTTP 请求不管理接口生命周期。这套工程里SpringBoot 的角色是把推荐器的构建收敛到启动阶段把推荐计算暴露成 REST 接口。我把推荐器声明成一个 Spring Bean应用启动时只构建一次后续请求直接复用Configuration public class RecommenderConfig { Bean public Recommender recommender() throws IOException { DataModel model new FileDataModel(new File(data/ratings.csv)); UserSimilarity similarity new PearsonCorrelationSimilarity(model); UserNeighborhood neighborhood new NearestNUserNeighborhood(50, similarity, model); return new GenericUserBasedRecommender(model, neighborhood, similarity); } }这里的关键点是单例复用。FileDataModel在构造时会扫描整个评分文件把全量数据加载进内存PearsonCorrelationSimilarity在构建时会预先计算部分用户相似度矩阵。如果放到请求里执行每来一个用户都要全量载入一次数据接口性能会直接崩掉。用 Spring 的Bean解决这个问题最省事容器启动时初始化一次之后所有请求共享同一个推荐器实例。等到后面数据量变大需要定期刷新模型时再把Bean改成带缓存失效策略的实例这是从 Demo 走向生产的第一步。3. 把推荐跑起来一个基于 CSV 评分数据的最小实现3.1 项目结构与依赖一份能直接启动的 pom我拆这套工程时先看的是依赖和目录结构。工程是个标准 SpringBoot 项目入口类、配置类、Service、Controller 分层清楚。核心依赖只有两个Web 和 Mahout。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.apache.mahout/groupId artifactIdmahout-mr/artifactId version0.13.x/version exclusions exclusion groupIdorg.slf4j/groupId artifactIdslf4j-log4j12/artifactId /exclusion /exclusions /dependency /dependenciesmahout-mr是 Mahout 的核心模块推荐引擎相关的类都在里面。版本选 Maven 中央仓库能拉到的稳定版即可我本地用的是 0.13.x 这一代。排除slf4j-log4j12是必须的否则 Mahout 自带的日志绑定会和 SpringBoot 的日志实现打架启动时大概率出现SLF4J: Found multiple bindings警告严重时日志直接不输出。目录结构是这样的recommender ├── pom.xml └── src/main/ ├── java/com/example/recommender/ │ ├── RecommenderApplication.java │ ├── RecommenderConfig.java │ ├── RecommendService.java │ └── RecommendController.java └── resources/ ├── application.yml └── data/ratings.csvJava 版本用 8 以上就行Mahout 对更高版本 Java 的支持稍有滞后如果用的是 Java 17注意观察启动时有没有反射相关的警告。3.2 评分数据长什么样CSV 的列顺序和数据类型Mahout 的FileDataModel读取的 CSV 格式非常死板每行三列依次是用户 ID、电影 ID、评分分隔符默认是英文逗号没有表头不能有空行。例如1,101,5.0 1,102,3.0 2,101,4.0 2,103,2.0 3,101,2.0 3,102,5.0这里每一列的类型都是固定的用户 ID 和电影 ID 会被解析成 long 类型评分必须是浮点数。注意评分列写成5虽然也能读但 Mahout 内部是按 float 处理的建议统一写成5.0这种形式避免类型转换时出现意外。文件里以#开头的行会被自动忽略这个特性可以当注释用。但千万不能把类似userId,itemId,rating的表头放进去Mahout 不会像 pandas 那样帮你跳过表头它会把表头当成一条真实评分记录而且评分解析成 NaN后面的推荐结果会直接乱掉。这个坑在后面避坑章节会详细说。3.3 核心 Service从数据模型到推荐结果只要五步RecommendService是整个工程的业务核心它把推荐器的构建和使用拆开Controller 只依赖这个 Service不直接碰 Mahout 的 API。Service public class RecommendService { private final Recommender recommender; public RecommendService(Recommender recommender) { this.recommender recommender; } public ListRecommendedItem recommend(Long userId, int howMany) throws TasteException { return recommender.recommend(userId, howMany); } }构造器注入的方式让Recommender在 Spring 容器里只初始化一次Service 的方法调用是线程安全的因为GenericUserBasedRecommender内部不持有可变状态。howMany参数控制返回条数比如传 5 就返回 5 部电影。TasteException是 Mahout 的顶层异常recommend方法在用户不在数据模型里、或者邻居数量不足时会抛出它。工程里直接网上抛接口层会返回 500实践中通常应该捕获后返回空列表或兜底推荐。3.4 对外暴露 REST 接口给指定用户返回 n 部电影RestController RequestMapping(/api/recommend) public class RecommendController { private final RecommendService recommendService; public RecommendController(RecommendService recommendService) { this.recommendService recommendService; } GetMapping(/{userId}) public ListRecommendedItem recommend(PathVariable Long userId, RequestParam(defaultValue 10) int howMany) throws TasteException { return recommendService.recommend(userId, howMany); } }启动之后访问GET /api/recommend/1?howMany5返回的是 5 部电影的推荐结果。RecommendedItem直接序列化成 JSON 时字段名是itemID和value分别对应电影 ID 和预测评分。一个细节是RecommendedItem直接暴露给前端不太干净它会把内部类名、资源 ID 这些字段一并带出去。常见的做法是在 Service 层转成 DTO只保留itemId和score两个字段。Demo 阶段可以直接用对应到生产时再加一层映射就够。4. 从 Demo 走向可用数据库数据源与推荐参数调优4.1 用 JDBCDataModel 替换文件模型让评分数据实时参与计算FileDataModel的缺点是启动时全量载入评分数据更新后必须重启应用才能生效。在真实项目里用户评分是不断写入的重启才能刷新模型的方案没法用。常见的做法是换成MySQLJDBCDataModel让 Mahout 直接查数据库表。先准备一张评分表CREATE TABLE user_preferences ( user_id BIGINT NOT NULL, item_id BIGINT NOT NULL, preference FLOAT NOT NULL, PRIMARY KEY (user_id, item_id) );然后在配置类里换成数据库数据源MysqlDataSource dataSource new MysqlDataSource(); dataSource.setServerName(localhost); dataSource.setUser(root); dataSource.setPassword(password); dataSource.setDatabaseName(recommender_db); DataModel model new MySQLJDBCDataModel( dataSource, user_preferences, user_id, item_id, preference, null);MySQLJDBCDataModel构造函数的参数依次是数据源、表名、用户 ID 列名、电影 ID 列名、评分列名、时间戳列名。时间戳列传 null 表示不用如果表里有created_at这类字段传进去可以让模型感知用户偏好的时间衰减但 Demo 阶段没必要。换数据库源后相似度计算和邻居圈选的代码不用动这正是 Mahout 拆分层的好处。要注意的是JDBCDataModel每次取用户数据都会发 SQL 查询接口响应时间会比内存模型慢不少。如果数据量到了几十万条评分更稳的做法是定期把数据库数据快照成内存模型比如每分钟重建一次GenericDataModel这样既保留实时性又不至于每次都查库。4.2 相似度算法怎么选Pearson 相关系数 vs LogLikelihoodMahout 提供了好几种UserSimilarity实现选哪个直接决定推荐结果的质量。默认工程用的是PearsonCorrelationSimilarity它适合评分尺度稳定的场景但也有很多边界问题。我把常用的三个放到一起对比相似度实现计算依据适合场景典型问题PearsonCorrelationSimilarity用户共同评分项的数值相关性评分数据较稠密、评分尺度一致共同评分项少时计算出 NaNLogLikelihoodSimilarity用户是否评过分布尔偏好评分稀疏、只关心看没看过忽略评分高低5 分和 1 分视为相同EuclideanDistanceSimilarity评分差值的距离希望评分差异敏感对评分尺度很敏感需要归一化实际调参时我的经验是如果评分数据是从业务系统里收集的真实评分而且大部分用户都评过几十部电影优先用 Pearson如果数据特别稀疏比如很多用户只评过两三部Pearson 算出来的相似度非常失真这时候换成LogLikelihoodSimilarity反而稳。它只看两个用户有没有共同评过的电影不关心评分绝对值在稀疏数据上能圈出更多有效邻居。UserSimilarity similarity new LogLikelihoodSimilarity(model);一行代码就能切换算法这也是 Mahout 的工程价值。换完之后推荐结果的排序会有明显变化用户评分行为偏激进全是高分或全是低分时LogLikelihood 反而比 Pearson 更抗噪。4.3 邻居数量与推荐数量参数之间的连锁反应先区分两个参数NearestNUserNeighborhood(50, ...)里的 50 是邻居数量recommend(userId, 10)里的 10 是返回的推荐条数。前者决定“参考哪些人的口味”后者决定“最终推几部电影”。邻居数量太小比如取 5推荐结果会高度集中在少数几个邻居的偏好里偶然性很大邻居数量太大比如取 200参考的人过多口味被平均掉推荐结果趋于大众热门个性化反而变弱。我一般从 30 到 80 之间起步观察推荐列表里是否出现“用户没看过但确实符合口味”的电影。threshold方式的邻居选择更容易理解只圈相似度超过某个阈值的用户。比如ThresholdUserNeighborhood(0.6, similarity, model)只保留相似度大于 0.6 的邻居。这种方式适合对相似度值分布有把握的场景但阈值设太高容易导致邻居为空返回空推荐列表。// 基于相似度阈值的邻居选择0.6 是经验值 UserNeighborhood neighborhood new ThresholdUserNeighborhood(0.6, similarity, model);冷启动阶段的用户评分很少无论哪种邻居选择方式都很难圈到人。常见做法是评分数据少于 20 条时直接用热门电影兜底评分积累够了再走推荐引擎。5. 避坑与排查我在这套代码上翻过车的五个地方5.1 推荐结果全是 NaNPearson 相似度在稀疏数据上的经典翻车现象接口正常返回但每条推荐的 value 都是NaN排序也是乱的前端拿到数据直接懵了。原因PearsonCorrelationSimilarity计算两个用户的相关系数时如果分母为 0比如两个用户只共同看了一部电影或者其中一个用户对所有电影评分完全一样结果就是 NaN。多个 NaN 参与加权汇总后推荐分数也变成 NaN。解决先检查用户之间的共同评分数量。给UserSimilarity换成LogLikelihoodSimilarity是最快的验证手段它能避开 NaN 问题。如果必须用 Pearson需要自己预处理评分数据把共同评分少于 5 部的用户对过滤掉。5.2 CSV 多了一个表头行所有推荐全部失效现象启动时日志正常但调用接口时要么抛NoSuchItemException要么推荐列表一直是空的看了半天代码找不到问题。原因FileDataModel不会识别表头userId,itemId,rating这行会被当成一条普通评分其中rating列是个字符串解析成 float 时变成了 NaN这行数据又会被算进用户偏好里。解决去掉 CSV 的表头。如果数据是程序生成的生成时不要输出表头如果文件已经存在直接删掉第一行sed -i 1d ratings.csv同时检查文件末尾有没有空行或逗号结尾的行这些都可能被 Mahout 当成一条无效评分。从那以后我每次换数据集都先跑一个wc -l和head -3的快速检查。5.3 新用户加入评分表却推荐不出内容现象新用户插入 20 条评分后调用推荐接口返回空列表但老用户一切正常。原因基于用户的协同过滤完全依赖邻居新用户和现有用户的共同评分太少NearestNUserNeighborhood凑不够邻居或者算出来的相似度低于任何有效阈值Mahout 直接返回空结果。解决冷启动阶段先走兜底策略比如按全局热门评分返回前端逻辑也要容错推荐列表为空时展示一个“大家都在看”的区块。等用户评分超过 20 条再切换回推荐引擎实测这个阈值对多数电影评分数据都够用。5.4 每次请求都重新构建 Recommender接口越来越慢现象上线后刚开始一切正常请求量一上去接口从几十毫秒涨到几秒数据库连接数也被打满。原因有人在 Controller 方法里直接new FileDataModel加上全套推荐器组装每个请求都全量扫描评分文件并重建相似度矩阵复杂度直接翻倍还叠加了 GC 压力。解决把推荐器构建收拢到RecommenderConfig里用 Spring 的单例 Bean 管理。如果你需要定期刷新模型可以加一个定时任务每 5 分钟重建一次推荐器并用volatile引用替换旧实例而不是每次请求都重建。5.5 JDBCDataModel 偶发数据库连接失效现象数据库重启或网络闪断后推荐接口开始报连接相关的 SQL 异常重启应用才恢复。原因MySQLJDBCDataModel和FileDataModel不一样它持有数据库连接连接被数据库回收后Mahout 内部不会自动重连旧数据源里的连接全部变成死连接。解决给数据源配连接池SpringBoot 默认的 HikariCP 就能用把空闲连接超时调大一点spring: datasource: url: jdbc:mysql://localhost:3306/recommender_db username: root password: password hikari: maximum-pool-size: 10 idle-timeout: 60000同时做好降级捕获TasteException和 SQL 异常时接口返回缓存的上一次推荐结果而不是直接 500。6. 进阶验证用 Mahout 自带的评估器判断推荐是否靠谱6.1 用平均绝对误差量化推荐质量推荐系统改完参数不能只看“推荐出几部电影”就收工。Mahout 提供了离线评估器把评分数据分成训练集和测试集用训练集建模用测试集验证预测误差。我每次调参都跑一遍这个流程// 训练集的数据模型构建器 DataModelBuilder modelBuilder trainingData - new GenericDataModel(trainingData); // 用和线上一致的参数构建推荐器 RecommenderBuilder recommenderBuilder model - { UserSimilarity similarity new PearsonCorrelationSimilarity(model); UserNeighborhood neighborhood new NearestNUserNeighborhood(50, similarity, model); return new GenericUserBasedRecommender(model, neighborhood, similarity); }; // 90% 数据训练10% 数据验证 AverageAbsoluteDifferenceRecommenderEvaluator evaluator new AverageAbsoluteDifferenceRecommenderEvaluator(); double score evaluator.evaluate(recommenderBuilder, modelBuilder, dataModel, 0.9, 1.0); System.out.println(MAE score);evaluate方法的参数依次是推荐器构建器、数据模型构建器、原始数据模型、训练数据占比、评估数据占比。返回的 score 是平均绝对误差意思是预测评分和真实评分平均差多少分。比如 Score 是 0.8说明系统预测的评分和用户真实评分平均差 0.8 分在 1 到 5 分的评分体系里小于 1 已经是不错的结果超过 1.5 就说明参数或数据源有问题。6.2 一个容易被忽略的评估细节评估结果分高不代表线上体验一定好。因为测试集里都是用户真实评过分的电影而推荐引擎更擅长预测用户“会喜欢的电影”而不是用户“已经看过的电影”。所以我会同时看推荐列表里有没有出现用户没评过分的冷门电影如果推荐结果全是热门片说明邻居数量太大个性化被平均掉了。从那以后我每次改相似度算法、邻居数量、数据源都强制先跑一遍评估器误差没有恶化再去碰接口。这套流程不复杂但对“改完参数感觉变好了”这种主观判断是个很好的刹车评估器加上冷启动兜底、数据源切换配合前面踩过的坑这套 SpringBoot 加 Mahout 的推荐工程就能稳定跑下去了。希望帮到你。本文还有配套的精品资源点击获取