简介:这份资源面向想入门推荐系统、又希望兼顾工程落地的 Java 开发者与在校学生,用 SpringBoot 搭建服务层、Apache Mahout 提供协同过滤算法,基于用户对电影的评分数据完成相似用户或相似物品的挖掘与推荐,属于可运行的中小型实战项目,适合作为课程设计、毕业设计或技术练手的参考。压缩包共 18 个文件,约 24.15MB,以 8 个 Java 源码文件为核心,配合 4 个 CSV 评分与书籍数据集、pom.xml 依赖配置、properties 配置文件及 Maven 包装脚本,覆盖从数据加载、算法调用到 REST 接口输出的完整链路。已有 617 人学习下载,说明其在同类入门项目中具备一定参考价值。读者可从中了解 SpringBoot 与 Mahout 的集成方式、基于用户与基于物品两类协同过滤的实现差异,以及项目分层与依赖组织思路,便于在此基础上替换数据集或扩展推荐接口。
1. 从一份评分表到一部想看的电影:这个推荐引擎到底在做什么
想象你手里有一份用户对电影的评分表,几百个用户、上千部电影、密密麻麻的星级打分。你想给某个用户推荐他没看过但大概率会喜欢的片子,靠人肉翻表显然不现实。这个基于 SpringBoot 与 Apache Mahout 的推荐引擎项目,干的就是这件事:把评分数据喂进去,用协同过滤算出相似用户或相似物品,再输出一份推荐列表。它适合两类人——想搞懂推荐系统底层链路的后端开发者,以及手里有一批行为数据、想快速搭个推荐 Demo 验证想法的人。技术栈是 Java 生态里最稳的组合之一,SpringBoot 负责服务与接口,Mahout 负责算法,跑起来不玄学,改起来也有清晰的切口。
2. 技术选型与工程骨架:为什么是 SpringBoot 加 Mahout
2.1 选型逻辑:一个管服务,一个管算法
推荐系统拆开看就两块:一块是对外的服务层,接收用户 ID、返回推荐结果;另一块是内部的算法层,处理评分矩阵、算相似度、生成候选集。SpringBoot 天然适合前者,自动装配、内嵌容器、REST 接口一套带走,省掉大量样板配置。Mahout 则是 Hadoop 时代沉淀下来的机器学习库,它的recommender子模块把协同过滤的常见实现都封装好了,包括基于用户的、基于物品的、Slope One 等,直接调 API 就能跑,不用自己从零推导相似度公式。
这个组合的好处是职责边界清楚:Controller 层只关心参数校验和结果组装,Recommender 层只关心算法配置和数据输入。坏处也要提前说清楚——Mahout 的很多实现偏老,依赖的 Guava、 commons 版本和现代 SpringBoot 工程容易打架,后面避坑章节会专门讲。我一般会在pom.xml里把 Mahout 相关依赖单独拎出来,锁定版本,避免传递依赖把整个工程拖下水。
2.2 目录结构:按职责切,不按技术切
一个能跑起来的工程骨架大致长这样,包名按职责划分而不是按 Controller/Service/Dao 机械三层:
recommender/ ├── pom.xml ├── src/main/java/com/example/recommender/ │ ├── RecommenderApplication.java # 启动类 │ ├── controller/ │ │ └── RecommendController.java # 推荐接口 │ ├── service/ │ │ ├── RecommenderService.java # 算法调用封装 │ │ └── DataLoadService.java # 评分数据加载 │ ├── model/ │ │ ├── Movie.java # 电影实体 │ │ └── Rating.java # 评分实体 │ └── config/ │ └── MahoutConfig.java # 推荐器 Bean 配置 └── src/main/resources/ ├── application.yml └── data/ ├── ratings.csv # 用户-电影-评分 └── movies.csv # 电影元数据这个结构的关键在于DataLoadService和RecommenderService分离。数据加载负责把 CSV 或数据库里的评分转成 Mahout 认识的DataModel,推荐服务负责基于这个模型构建UserBasedRecommender或ItemBasedRecommender。两者分开后,换数据源不影响算法,换算法不影响数据加载,二次开发时改动面小很多。
2.3 依赖配置:把版本钉死
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.apache.mahout</groupId> <artifactId>mahout-core</artifactId> <version>0.9</version> </dependency> <dependency> <groupId>org.apache.mahout</groupId> <artifactId>mahout-integration</artifactId> <version>0.9</version> </dependency>mahout-core提供推荐算法本体,mahout-integration提供文件数据源等集成能力。版本 0.9 是 Mahout 最后一个以单机推荐为主的稳定版本,再往后的版本重心转向了 Spark 后端,单机场景反而不好用。这里要注意 Mahout 0.9 依赖的 Guava 版本较老,如果工程里同时引入了高版本 Guava,启动时可能报NoSuchMethodError,解决办法是在pom.xml里显式排除或统一版本,具体在避坑章节展开。
3. 核心链路拆解:从评分文件到推荐列表
3.1 数据加载:把 CSV 变成 DataModel
Mahout 的推荐算法不直接吃 CSV,它要的是DataModel接口的实现。最常用的是FileDataModel,要求文件格式为用户ID,物品ID,评分,每行一条,逗号分隔。如果评分数据在数据库里,可以自己实现DataModel,但多数 Demo 场景用文件就够了。
@Service public class DataLoadService { @Value("${recommender.data.path}") private String dataPath; private volatile DataModel dataModel; // 懒加载 + 双检锁,避免每次请求都重新读文件 public DataModel getDataModel() throws IOException { if (dataModel == null) { synchronized (this) { if (dataModel == null) { // FileDataModel 会自动处理重复评分,取最后一次 dataModel = new FileDataModel(new File(dataPath)); } } } return dataModel; } }FileDataModel构造时会解析整个文件,把用户-物品-评分装进内存里的稀疏矩阵。dataPath从application.yml读取,方便不同环境切换。这里用双检锁做懒加载,是因为FileDataModel初始化有一定开销,但推荐接口是高频调用的,不能每次请求都重建。注意dataModel声明为volatile,保证多线程下的可见性。如果评分数据会动态更新,FileDataModel支持refresh()方法重新加载,但要注意并发读写问题,生产环境更稳妥的做法是定时任务重建模型后原子替换引用。
3.2 推荐器构建:UserBased 还是 ItemBased
Mahout 提供两种主流协同过滤:基于用户(UserBased)和基于物品(ItemBased)。前者找“和你口味相似的人喜欢什么”,后者找“和你喜欢的物品相似的物品”。用户数远小于物品数时用 UserBased 更省内存,物品数少或用户兴趣变化快时用 ItemBased 更稳。
@Configuration public class MahoutConfig { @Bean public UserSimilarity userSimilarity(DataModel dataModel) throws TasteException { // 皮尔逊相关系数,对评分尺度差异不敏感 return new PearsonCorrelationSimilarity(dataModel); } @Bean public UserNeighborhood userNeighborhood(UserSimilarity similarity, DataModel dataModel) throws TasteException { // 取最相似的 20 个用户作为邻域 return new NearestNUserNeighborhood(20, similarity, dataModel); } @Bean public Recommender recommender(DataModel dataModel, UserSimilarity similarity, UserNeighborhood neighborhood) throws TasteException { // 缓存装饰器,避免重复计算相似度 return new CachingRecommender( new GenericUserBasedRecommender(dataModel, neighborhood, similarity)); } }PearsonCorrelationSimilarity算的是两个用户评分向量的皮尔逊相关系数,取值 -1 到 1,越接近 1 越相似。选它而不是欧氏距离,是因为不同用户打分习惯不同,有人习惯打高分有人习惯打低分,皮尔逊能抵消这种尺度偏差。NearestNUserNeighborhood的 20 是邻域大小,调大推荐更发散但计算更慢,调小更精准但容易漏掉长尾兴趣,一般 10 到 50 之间试。CachingRecommender是装饰器模式,把相似度计算结果缓存起来,对高频请求场景很关键,不然每次推荐都重算一遍相似矩阵,响应时间会很难看。
3.3 推荐接口:参数校验与结果组装
@RestController @RequestMapping("/api/recommend") public class RecommendController { @Autowired private Recommender recommender; @GetMapping("/{userId}") public List<RecommendedItem> recommend(@PathVariable long userId, @RequestParam(defaultValue = "10") int howMany) { try { // howMany 控制返回条数,Mahout 内部会按预估评分降序排列 return recommender.recommend(userId, howMany); } catch (TasteException e) { // 用户不存在或评分数据不足时抛异常,返回空列表更友好 return Collections.emptyList(); } } }recommend(userId, howMany)返回的是List<RecommendedItem>,每个元素包含物品 ID 和预估评分。Mahout 内部会先找邻域用户,再聚合他们对候选物品的评分,加权算出预估分,最后降序取前howMany条。这里把TasteException捕获后返回空列表,是因为新用户没有评分记录时 Mahout 会直接抛异常,对前端来说空列表比 500 错误更好处理。实际项目里我会再加一层,把物品 ID 映射成电影名称和海报,这需要movies.csv做关联,属于展示层的活,不塞进推荐服务里。
3.4 冷启动处理:新用户怎么办
协同过滤的经典短板是冷启动——新用户没有评分,算不出相似用户。这个项目里我一般会加一个兜底策略:当recommend返回空时,退化为热门推荐,即统计所有物品的平均评分和评分人数,按加权分数排序返回。
public List<RecommendedItem> fallbackPopular(int howMany) throws TasteException { List<RecommendedItem> popular = new ArrayList<>(); // 遍历所有物品,按平均分 * log(评分人数) 排序,兼顾质量和热度 for (Long itemId : dataModel.getItemIDs()) { int count = dataModel.getNumUsersWhoRatedItem(itemId); double avg = dataModel.getPreferencesForItem(itemId).stream() .mapToDouble(p -> p.getValue()).average().orElse(0); double score = avg * Math.log1p(count); popular.add(new GenericRecommendedItem(itemId, (float) score)); } popular.sort((a, b) -> Float.compare(b.getValue(), a.getValue())); return popular.subList(0, Math.min(howMany, popular.size())); }Math.log1p(count)是对评分人数取对数,避免只有一两个人打满分的冷门物品霸榜,这是推荐系统里常见的平滑手段。这段兜底逻辑放在RecommenderService里,Controller 层根据返回是否为空决定是否调用,保持接口层干净。
4. 跑起来与调参:本地启动到接口验证
4.1 启动步骤:三步走
第一步,准备数据文件。ratings.csv每行格式为userId,movieId,rating,评分范围 1 到 5,至少准备几百条才有推荐效果。movies.csv格式为movieId,title,用于展示层关联。第二步,在application.yml里配好路径:
server: port: 8080 recommender: data: path: classpath:data/ratings.csv第三步,mvn spring-boot:run启动,看到 Tomcat 监听 8080 即可。验证接口用 curl:
curl "http://localhost:8080/api/recommend/1?howMany=5"返回 JSON 数组,每个元素含itemID和value。如果返回空数组,先确认用户 1 在ratings.csv里确实有评分记录,再确认评分条数是否足够支撑邻域计算——邻域大小设 20 但总用户才 10 个,自然算不出结果。
4.2 关键参数怎么调
| 参数 | 位置 | 作用 | 调整建议 |
|---|---|---|---|
| 邻域大小 | NearestNUserNeighborhood构造参数 | 参与推荐的相似用户数 | 数据量大调 30-50,数据少调 5-10 |
| 相似度算法 | UserSimilarity实现类 | 用户相似度计算方式 | 评分尺度差异大用皮尔逊,二元偏好用 Tanimoto |
| 返回条数 | 接口howMany | 推荐列表长度 | 展示位决定,一般 10-20 |
| 数据路径 | application.yml | 评分文件位置 | 生产环境指向外部挂载目录 |
相似度算法这块值得多说一句。PearsonCorrelationSimilarity适合评分粒度细的场景,TanimotoCoefficientSimilarity只看“评没评过”不看分值,适合行为数据只有点击没有评分的场景。换算法只需改MahoutConfig里那个 Bean 的实现类,其他代码不动,这就是前面按职责分层的好处。
4.3 日志与观测:出问题先看哪里
Mahout 计算相似度时如果数据稀疏,会大量输出No item preferences之类的警告,这些不是错误,是算法在告诉你某些用户对之间没有共同评分物品。真正要关注的是TasteException的堆栈,常见的有NoSuchUserException(用户 ID 不存在)和NoItemException(物品 ID 不存在)。我一般会在RecommenderService里对异常做分类日志,把用户 ID 和请求参数一起打出来,排查时不用猜。
5. 避坑与排查:那些让我返工的问题
5.1 启动报 NoSuchMethodError:Guava 版本冲突
现象:工程能编译,启动时抛java.lang.NoSuchMethodError,堆栈指向 Mahout 内部调用的 Guava 方法。原因:Mahout 0.9 依赖 Guava 14 左右,而 SpringBoot 新版本传递进来的 Guava 可能是 30 以上,方法签名变了。解决:在pom.xml里对 Mahout 依赖做排除,或者用dependencyManagement统一锁定 Guava 版本到 Mahout 兼容的范围。我一般选后者,锁一个两边都能用的中间版本,再跑一遍启动验证。
5.2 推荐结果为空:数据格式或邻域配置问题
现象:接口返回空数组,日志无异常。原因通常有两个——评分文件里用户 ID 是字符串而代码按 long 解析,导致用户查不到;或者邻域大小设得比实际用户数还大。解决:先用dataModel.getNumUsers()和getNumItems()打印数据规模,确认解析成功;再把邻域大小调到小于用户总数。血泪经验是评分文件第一行如果是表头,FileDataModel会把它当数据解析失败,要么去掉表头,要么用FileDataModel的带分隔符和跳过行数的构造方法。
5.3 接口响应慢:相似度重复计算
现象:单次推荐耗时几百毫秒甚至上秒,并发一上来直接拖垮。原因:每次请求都新建Recommender或没加缓存,相似矩阵反复计算。解决:把Recommender做成单例 Bean,外面套CachingRecommender,并且确认DataModel也是单例。注意CachingRecommender的缓存不会自动失效,数据更新后要重建实例,所以数据热更新场景下缓存策略要单独设计,不能无脑套。
5.4 内存溢出:评分矩阵太大
现象:数据量到几十万条评分后,启动或推荐时 OOM。原因:FileDataModel把整个矩阵装内存,Mahout 单机模式没有分片能力。解决:数据量超过单机内存承受范围时,要么换 ItemBased 降低内存占用(物品数通常远小于用户数),要么把 Mahout 换成 Spark 后端做分布式计算。这个项目定位是中小规模 Demo,真到大数据量就该考虑架构升级,硬扛没有意义。
5.5 推荐结果重复或离谱:评分数据脏
现象:同一个物品反复出现在推荐列表,或者推荐的全是冷门烂片。原因:评分文件里有重复记录、异常分值(比如 0 分或 10 分),或者大量用户只评了一两部电影导致相似度失真。解决:加载前做一次数据清洗,去重、过滤超出评分范围的记录、剔除评分物品数少于阈值的用户。这一步我一般写成独立的DataCleanService,在FileDataModel构造前处理原始文件,保持算法层拿到的是干净数据。
6. 二次开发:把 Demo 改成能用的推荐服务
6.1 换数据源:从文件到数据库
最直接的改造是把FileDataModel换成自定义DataModel,从 MySQL 读评分表。实现DataModel接口需要覆写getPreferencesFromUser、getItemIDs等方法,工作量不大但要注意每次查询都走数据库会很慢,常见做法是启动时全量加载到内存,定时任务增量刷新。我一般会保留FileDataModel作为兜底,数据库不可用时降级读本地快照,避免推荐接口整体挂掉。
6.2 混合推荐:协同过滤加内容特征
纯协同过滤在物品冷启动时无能为力——新电影没人评分,就永远进不了推荐池。改造思路是加一路基于内容的召回:用电影的类型、导演、标签算物品相似度,和协同过滤的结果做加权融合。Mahout 本身不直接支持内容特征,需要自己算相似度矩阵再合并,代码量不小,但效果提升明显。验证方法是做 A/B 对比,看点击率或转化率有没有变化,别凭感觉判断。
6.3 接口层改造:批量推荐与缓存
单用户推荐接口在高并发下压力大,可以加一个批量接口,一次传多个用户 ID 返回各自推荐列表,内部复用同一个DataModel和Recommender。再往上加一层 Redis 缓存,key 用userId + howMany,过期时间设短一些(比如 5 分钟),平衡实时性和性能。改造后记得压测,确认缓存命中率和响应时间符合预期。
6.4 验证方法:离线评估指标
改完算法怎么知道变好了?离线评估用留一法:从每个用户的评分里留一条出来,用剩下的数据训练,看推荐列表里有没有命中留出的那条。指标看命中率(Hit Rate)和平均倒数排名(MRR),Mahout 自带RecommenderEvaluator可以算 RMSE,但 RMSE 低不代表推荐列表好看,我一般两个指标一起看。线上则看点击率和人均消费时长,离线指标涨了线上没涨,说明评估集和真实场景分布不一致,得回头查数据。
从那以后我每次改推荐算法,都强制先跑一遍离线评估再上线,绝不凭直觉调参。希望帮到你。
本文还有配套的精品资源,点击获取