☰
SpringBoot集成Mahout实现基于用户协同过滤的电影推荐引擎
2026/10/13 13:15:49 网站建设 项目流程

简介:这是一份基于 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 部电影 List<RecommendedItem> items = recommender.recommend(1L, 10);

代码里的逻辑顺序是固定的:数据模型是一切的基础,相似度计算依赖数据模型,邻居选择依赖相似度,推荐器依赖全部三者。recommend(1L, 10)返回的RecommendedItem里,getItemID()是电影 ID,getValue()是系统预测的评分,这个值通常在 1 到 5 之间,但偶尔出现异常值也不用慌,后面避坑章节会讲。

有个很容易误用的点:UserNeighborhood不是必选的接口。Mahout 里也有基于物品的推荐器,它不圈用户邻居,而是找“和用户评分过的电影相似的电影”。但在这套电影推荐 Demo 里,基于用户的方式更贴合场景,因为电影评分数据天然存在“口味相近的人”,而且用户群体相对稳定。

2.3 SpringBoot 集成方式:启动时构建模型,请求时只算 topN

Mahout 只是个算法库,不是服务,它不处理 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.apache.mahout</groupId> <artifactId>mahout-mr</artifactId> <version>0.13.x</version> <exclusions> <exclusion> <groupId>org.slf4j</groupId> <artifactId>slf4j-log4j12</artifactId> </exclusion> </exclusions> </dependency> </dependencies>

mahout-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.csv

Java 版本用 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 List<RecommendedItem> 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 List<RecommendedItem> recommend(@PathVariable Long userId, @RequestParam(defaultValue = "10") int howMany) throws TasteException { return recommendService.recommend(userId, howMany); } }

启动之后,访问GET /api/recommend/1?howMany=5,返回的是 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 LogLikelihood

Mahout 提供了好几种UserSimilarity实现,选哪个直接决定推荐结果的质量。默认工程用的是PearsonCorrelationSimilarity,它适合评分尺度稳定的场景,但也有很多边界问题。我把常用的三个放到一起对比:

相似度实现计算依据适合场景典型问题
PearsonCorrelationSimilarity用户共同评分项的数值相关性评分数据较稠密、评分尺度一致共同评分项少时计算出 NaN
LogLikelihoodSimilarity用户是否评过分(布尔偏好)评分稀疏、只关心看没看过忽略评分高低,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 推荐结果全是 NaN:Pearson 相似度在稀疏数据上的经典翻车

现象:接口正常返回,但每条推荐的 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 的推荐工程就能稳定跑下去了。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询