简介:这份资源面向希望入门推荐系统的 Java 开发者与学习者,以 SpringBoot 集成 Apache Mahout 为主线,演示如何基于用户对电影的评分数据实现协同过滤推荐,涵盖基于用户与基于物品两种思路,适合具备一定 Java 与 Spring 基础、想动手实践推荐算法的中级读者。压缩包共 18 个文件,约 24.15MB,以 8 个 java 源码文件为核心,配合 4 个 csv 评分与图书数据集、pom.xml 依赖配置、properties 配置文件及 mvnw 构建脚本,构成可直接导入运行的 Maven 工程。已有 617 人学习下载。项目按标准分层组织,启动类、配置类与 Mahout 推荐逻辑分离,数据加载、相似度计算与推荐结果输出链路完整,读者可据此理解协同过滤的落地方式,并在此基础上替换数据集或扩展 REST 接口,作为推荐系统入门练手与二次开发的起点。
1. 从一份评分表到一部想看的电影:这个推荐引擎到底在做什么
前阵子帮一个做影视内容站的朋友看后台,他手里攒了几万条用户打分记录,却只会按时间倒序把最新电影怼到首页,用户点开就走。问题不在数据量,而在于没人把「谁给什么打了多少分」这层关系用起来。这个基于 SpringBoot + Apache Mahout 的推荐引擎项目,干的就是这件事:拿用户对电影的评分矩阵,算出相似用户或相似物品,再给目标用户吐出他没看过、但大概率会喜欢的片单。它适合两类人——想给自家内容站补一个「猜你喜欢」模块的后端开发,以及想搞懂协同过滤到底怎么落到 Java 工程里的学习者。技术栈是 SpringBoot 做 Web 层和依赖装配,Mahout 提供推荐算法内核,数据侧常见做法是 MySQL 存评分明细、启动时灌进内存模型。整套东西不复杂,但把「数据怎么进、模型怎么建、接口怎么出」这条链路走通了,二次开发的空间就打开了。
2. 工程骨架与依赖装配:一个推荐服务是怎么被拼起来的
2.1 目录结构与模块职责
拿到源码先别急着跑,先看目录。这类项目通常是一个标准 Maven 单模块工程,包结构大致如下:
recommender/ ├── pom.xml └── src/main/ ├── java/com/example/recommender/ │ ├── RecommenderApplication.java # 启动类 │ ├── config/ # Mahout 数据源与模型配置 │ ├── controller/ # REST 接口层 │ ├── service/ # 推荐业务逻辑 │ ├── model/ # 评分、电影、推荐结果实体 │ └── repository/ # 数据访问 └── resources/ ├── application.yml # 数据源、端口配置 └── data/ratings.csv # 冷启动用的评分样本config包是整个项目的枢纽,Mahout 的DataModel在这里被构建成单例 Bean,供 service 层注入。controller只做参数校验和结果包装,真正的算法调用压在service里。这种分层的好处是,你换算法(比如从 UserCF 换成 ItemCF)只动 service 和 config,接口层不用碰。我一般会先读RecommenderApplication和config两个文件,五分钟就能判断这个项目的模型是启动时一次性加载还是每次请求重建——这直接决定了它的性能天花板。
2.2 pom.xml 里的关键依赖
Mahout 的依赖版本要和 Hadoop 生态对齐,这是最容易翻车的地方。核心依赖通常长这样:
<dependencies> <!-- Web 层 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Mahout 推荐内核,注意排除自带的 slf4j 绑定避免冲突 --> <dependency> <groupId>org.apache.mahout</groupId> <artifactId>mahout-core</artifactId> <version>0.9</version> <exclusions> <exclusion> <groupId>org.slf4j</groupId> <artifactId>slf4j-log4j12</artifactId> </exclusion> </exclusions> </dependency> <!-- 数据访问 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> </dependencies>mahout-core是算法本体,mahout-integration才管文件系统和 JDBC 数据源,如果项目要从数据库直接读评分,还得补上后者。排除slf4j-log4j12是血泪经验:SpringBoot 默认用 Logback,Mahout 老版本会拖进 Log4j 绑定,两个日志门面打架,启动时刷一屏Class path contains multiple SLF4J bindings,虽然不致命但很干扰排查。版本号 0.9 是 Mahout 最后一个包含完整内存推荐器的稳定版,再往上就偏分布式了,单机场景没必要。
2.3 启动类与配置加载顺序
@SpringBootApplication public class RecommenderApplication { public static void main(String[] args) { SpringApplication.run(RecommenderApplication.class, args); } }启动类本身没什么可讲的,关键在于config里的 Bean 初始化顺序。DataModel依赖数据源就绪,Recommender又依赖DataModel,所以配置类里要用@DependsOn或者干脆把构建逻辑塞进一个@PostConstruct方法,保证模型在第一个请求进来之前就建好。如果偷懒写成懒加载,第一个用户请求会卡好几秒去读全量评分,体验很差。常见做法是在application.yml里配一个recommender.reload-on-startup: true开关,本地调试时关掉加快启动,上线前打开。
3. 核心推荐链路拆解:从评分矩阵到 TopN 片单
3.1 构建 DataModel:评分数据怎么变成算法能吃的格式
Mahout 的推荐器不认数据库表,它只认DataModel。最直接的方式是从 CSV 文件构建:
@Configuration public class RecommenderConfig { @Value("${recommender.data.path}") private String dataPath; @Bean public DataModel dataModel() throws IOException { // FileDataModel 要求每行格式:userId,movieId,rating[,timestamp] FileDataModel model = new FileDataModel(new File(dataPath)); // 可选:加载后打印统计,确认数据没读歪 System.out.println("用户数: " + model.getNumUsers() + ", 物品数: " + model.getNumItems()); return model; } }FileDataModel的构造参数是一个File,内部会按逗号切分并做去重合并——同一个用户对同一部电影多次评分,它默认取最后一次。这里有个参数细节:如果评分数据带时间戳,FileDataModel会用它做增量更新判断,文件修改时间变了才重读。数据量上万条时构建耗时在百毫秒级,可以接受;上百万条就得考虑换MySQLJDBCDataModel走数据库分页加载,否则启动内存吃不消。我一般会在 Bean 里加一行统计打印,用户数和物品数对不上预期,八成是 CSV 里有空行或者分隔符写成了分号。
3.2 UserBasedRecommender:相似用户怎么找、推荐怎么算
模型有了,接下来是推荐器。基于用户的协同过滤核心就两步:找和你口味相近的人,把他们喜欢而你没看过的片子推给你。
@Service public class UserCfRecommendService { private final UserBasedRecommender recommender; public UserCfRecommendService(DataModel model) { // 相似度用皮尔逊相关系数,邻居数取 20 UserSimilarity similarity = new PearsonCorrelationSimilarity(model); // 只保留相似度大于 0.1 的邻居,过滤掉弱关系 UserNeighborhood neighborhood = new ThresholdUserNeighborhood(0.1, similarity, model); this.recommender = new GenericUserBasedRecommender( model, neighborhood, similarity); } public List<RecommendedItem> recommend(long userId, int howMany) throws TasteException { // howMany 控制返回条数,内部会按预估评分降序 return recommender.recommend(userId, howMany); } }PearsonCorrelationSimilarity衡量两个用户评分向量的线性相关度,取值 -1 到 1,比欧氏距离更抗评分尺度差异——有人习惯打 5 分,有人最高只给 3 分,皮尔逊能把这层偏移消掉。ThresholdUserNeighborhood的 0.1 是相似度门槛,调高邻居更精但可能不够用,调低覆盖面广但噪声大,这个值我一般从 0.1 起步,看推荐结果的多样性再微调。GenericUserBasedRecommender是通用实现,recommend返回的RecommendedItem里带getItemID()和getValue(),后者是预估评分,前端排序直接用它。
3.3 ItemBasedRecommender:物品相似度的另一种解法
用户量远大于物品量时,基于物品的协同过滤更划算,因为物品相似度矩阵可以离线算好复用:
@Service public class ItemCfRecommendService { private final ItemBasedRecommender recommender; public ItemCfRecommendService(DataModel model) { ItemSimilarity similarity = new LogLikelihoodSimilarity(model); this.recommender = new GenericItemBasedRecommender(model, similarity); } public List<RecommendedItem> recommend(long userId, int howMany) throws TasteException { return recommender.recommend(userId, howMany); } }LogLikelihoodSimilarity适合隐式反馈或评分稀疏的场景,它不依赖具体分值,只看「共同评分过」这件事发生的概率是否显著高于随机,对冷门物品更友好。和 UserCF 相比,ItemCF 的推荐理由更好解释——「因为你看过 A,而 B 和 A 很像」,产品侧做文案时这点很值钱。两个 service 可以并存,controller 里按参数type=user|item路由,方便做 A/B 对比。
3.4 Controller 层:把推荐结果包装成 REST 接口
@RestController @RequestMapping("/api/recommend") public class RecommendController { private final UserCfRecommendService userCfService; public RecommendController(UserCfRecommendService userCfService) { this.userCfService = userCfService; } @GetMapping("/{userId}") public ResponseEntity<?> recommend(@PathVariable long userId, @RequestParam(defaultValue = "10") int size) { try { List<RecommendedItem> items = userCfService.recommend(userId, size); // 转成 DTO,避免把 Mahout 内部对象直接暴露给前端 List<Map<String, Object>> result = items.stream() .map(i -> Map.of("movieId", i.getItemID(), "score", i.getValue())) .collect(Collectors.toList()); return ResponseEntity.ok(result); } catch (TasteException e) { // 用户不存在或评分太少时 Mahout 会抛异常,降级返回空列表 return ResponseEntity.ok(Collections.emptyList()); } } }size参数默认 10,上限建议在 service 里卡死(比如最大 50),否则有人传 10000 会把内存里的候选集全捞出来。TasteException必须捕获,新用户没有任何评分时 Mahout 直接抛NoSuchUserException,不处理就是 500。降级返回空列表,前端展示「暂无推荐」比报错体面得多。DTO 转换这步别省,RecommendedItem是接口,序列化行为依赖具体实现类,直接返回容易踩坑。
4. 本地跑通与数据准备:三步把服务拉起来
4.1 建库建表与评分数据导入
先准备 MySQL,建一张评分表:
CREATE TABLE movie_rating ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, movie_id BIGINT NOT NULL, rating DOUBLE NOT NULL, rated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_movie (user_id, movie_id) );uk_user_movie唯一索引很关键,防止同一用户对同一电影重复插入导致模型里出现脏数据。导入样本数据时,rating用 1 到 5 的浮点,别用整数类型,Mahout 内部按 double 算,类型不一致会在构建模型时抛转换异常。如果项目走文件模式,把数据导出成userId,movieId,rating三列的 CSV 放到resources/data/下即可。
4.2 application.yml 关键配置
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/movie_db?useSSL=false&serverTimezone=UTC username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver recommender: data: path: ./data/ratings.csv # 文件模式下的评分路径 reload-on-startup: trueserverTimezone=UTC不加的话,MySQL 8 驱动启动就报时区错误,这是新手最常见的翻车点。recommender.data.path用相对路径时,注意工作目录是项目根还是target,跑java -jar和 IDE 里启动的基准目录不一样,稳妥做法是配成绝对路径或者用classpath:前缀。
4.3 启动与接口验证
# 打包 mvn clean package -DskipTests # 启动 java -jar target/recommender-0.0.1-SNAPSHOT.jar # 验证:给用户 1 推荐 5 部电影 curl "http://localhost:8080/api/recommend/1?size=5"返回的 JSON 里每个元素带movieId和score,score是预估评分,按它降序就是推荐优先级。如果返回空数组,先确认用户 1 在评分表里有没有记录,再看日志里用户数/物品数的统计是否正常。启动日志里出现DataModel构建耗时,超过 3 秒说明数据量偏大或者磁盘 IO 慢,可以考虑加缓存。
5. 避坑与排查:那些跑起来才会暴露的问题
5.1 启动报 SLF4J 多绑定
现象是控制台刷Class path contains multiple SLF4J bindings,日志输出重复。原因是 Mahout 传递依赖带进了 Log4j 绑定,和 SpringBoot 的 Logback 冲突。解决方式是在mahout-core依赖里排除slf4j-log4j12,或者用mvn dependency:tree定位后统一排除。这个警告不影响功能,但日志重复会掩盖真正的错误信息。
5.2 推荐结果为空或全是冷门片
现象是接口返回空列表,或者推的全是没人评分的边缘电影。原因通常是评分数据太稀疏,用户之间没有共同评分项,皮尔逊相似度算不出来。解决办法是降低ThresholdUserNeighborhood的门槛到 0.05,或者换LogLikelihoodSimilarity走 ItemCF;数据侧则要保证每个用户至少有 3 到 5 条评分,否则协同过滤无从下手。
5.3 新用户请求直接 500
现象是刚注册、没有任何评分的用户调接口报错。原因是 Mahout 对未知用户抛NoSuchUserException,而 controller 没捕获。解决方式是在 service 层先判断model.getUserIDs()里是否包含该用户,不存在就走热门推荐兜底,而不是硬算协同过滤。
5.4 评分文件改了但推荐没变
现象是往 CSV 里追加了数据,重启后推荐结果还是旧的。原因是FileDataModel按文件修改时间判断是否重载,某些部署方式(比如容器挂载)时间戳不更新。解决方式是启动时强制new FileDataModel(file, true)传forceReload参数,或者在配置里加版本号,版本变了就重建模型。
6. 二次开发:把单机推荐器改成能持续进化的服务
真正上线后你会发现,启动时一次性加载模型这套玩法撑不了多久——评分数据每天都在涨,总不能天天重启。我一般会做三件事。第一,把DataModel的构建从@PostConstruct挪到一个定时任务里,用@Scheduled(cron = "0 0 3 * * ?")每天凌晨重建,重建期间用AtomicReference持有旧模型,新模型建好再原子替换,请求不中断:
@Component public class ModelRefresher { private final AtomicReference<DataModel> holder = new AtomicReference<>(); @Scheduled(cron = "0 0 3 * * ?") public void refresh() throws IOException { FileDataModel fresh = new FileDataModel(new File(dataPath), true); holder.set(fresh); // 原子替换,读请求无锁 } public DataModel current() { return holder.get(); } }第二,给推荐结果加一层业务过滤。Mahout 只懂评分,不懂「用户已经看过的不能推」「下架电影不能推」,这些规则得在 service 里用IDRescorer实现:
IDRescorer filter = new IDRescorer() { @Override public double rescore(long id, double originalScore) { return originalScore; // 不改分,只做过滤 } @Override public boolean isFiltered(long id) { return watchedMovieIds.contains(id); // 看过的直接剔除 } }; recommender.recommend(userId, howMany, filter);isFiltered返回 true 的物品会被跳过,rescore可以用来做加权,比如给新片乘个 1.2 的系数。第三,把 UserCF 和 ItemCF 的结果做加权融合,各取 TopN 再按分数归一化合并,比单算法稳。验证改造是否有效,别只看接口通不通,要留一批历史评分做离线评估,算准确率和召回率,指标掉了就回滚参数。
说个教训:我早期图省事,把模型重建放在请求线程里做懒加载,结果高峰期第一个请求触发重建,直接把线程池打满,监控上一片红。从那以后我每次加缓存或者模型刷新,都强制走一遍「并发压测 + 降级开关」的流程,宁可多写二十行代码,也不让重建逻辑跑在用户请求路径上。希望这个项目的拆解能帮到你,少走我踩过的那些弯路。
本文还有配套的精品资源,点击获取