简介:这是一份基于Spark的电影推荐系统设计与实现的完整课题资料,面向大数据、推荐系统方向的毕业设计与课程设计人员,也可供对协同过滤、内容推荐算法感兴趣的开发者参考。文档以论文形式系统梳理了课题背景、国内外研究现状、Spark与MongoDB等关键技术,并重点介绍了基于人口统计学、基于内容、基于协同过滤三类推荐算法的原理与设计思路。系统分析部分涵盖可行性与功能需求,设计部分细化各推荐算法流程,实现部分展示用户登录注册、个性化推荐、电影搜索、用户评分等核心功能,测试部分给出功能与性能验证结果,目录结构从绪论到致谢完整清晰,可直接作为相关课题的参考蓝本。资源包内仅含单个DOCX文档,大小约7.46MB,内容以论文正文为主,共一个文件,便于查阅和编辑。目前已有207人学习下载,适合需要快速掌握电影推荐系统整体架构与研究思路的学生或开发者。
1. 这个项目不只是论文:一套能从登录跑到推荐的完整系统
电影推荐系统这类课题,网上源码不少,但多数是单机跑个 Python 脚本调一下 surprise 库就完事,谈不上“系统”。这份资源不一样——它是一份带完整论文的工程实现,后端用 Spark 做分布式计算,MongoDB 存业务数据,Redis 做缓存,Elasticsearch 参与搜索,前端还有 AngularJS 页面。换句话说,它不是 toy demo,而是一条能沿着“用户登录 → 评分 → 离线统计 → 实时推荐 → 详情页相似推荐”跑通的完整链路。
我拆这份项目时最大的感受是:它的核心不在协同过滤那几行 ALS 代码,而在“三种推荐算法怎么分工”。基于人口统计学的离线推荐管冷启动和热门榜,基于内容的实时推荐管用户当下行为,协同过滤管个性化。单一算法都有各自的坑,混合起来才是个能用的推荐系统。这套设计思路,比代码本身更值得读。
适合读这份资源的人有三类:做大数据课程设计或毕业设计的学生,想把 Spark 推荐从“会调 API”推进到“能搭完整服务”的后端开发,以及想找一个可复现的推荐系统基线工程、准备加功能的算法工程师。下文我会把技术选型、模块结构、核心实现和运行步骤一层层拆开,最后给出我的改造建议。
2. 整体架构拆解:Spark 计算、MongoDB 存储、三套算法分工
2.1 技术栈全景与数据流向
这套系统的技术选型围绕一个核心约束:单机环境下模拟大数据处理流程,同时保证每一层都有真实组件在跑。先看全景:
- 计算引擎:Spark,负责离线批量统计和实时流处理
- 存储层:MongoDB 存用户、评分、电影和推荐结果;Redis 缓存热点数据;Elasticsearch 做电影搜索
- 后端:Spring + Maven,提供登录、评分、推荐结果的 REST 接口
- 前端:AngularJS 2,负责页面渲染和交互
- 集群形态:伪分布式,1 个 Master + 3 个 Worker 用虚拟机模拟
数据流向是这样的:用户在前端页面产生评分、点击、搜索行为,这些行为写入 MongoDB 的业务表;离线推荐任务用 Spark SQL 周期性从 MongoDB 读取评分表,跑统计和模型训练,把结果写回 MongoDB 的推荐结果表;实时推荐则通过 Spark Streaming 消费行为流,结合用户最近的评分记录做基于内容的相似推荐;前端通过 Spring 后端接口读取 MongoDB 里的推荐结果,展示给用户。
这里有一个值得注意的设计决定:MongoDB 同时承担“业务数据库”和“推荐结果数据库”两个角色。好的一面是技术栈干净,不用在 HBase 和 MySQL 之间做数据同步;坏的一面是推荐结果表要按用户维度设计好索引,否则前端高频读取时会有性能压力。实际工程里我会把推荐结果再拷一层到 Redis,这个后面改造章节细说。
2.2 离线推荐模块:基于人口统计学的四类统计任务
离线推荐是这套系统的“底座”,它解决的核心问题是冷启动:新用户没有任何行为记录时,推荐什么?答案是给所有人推一样的榜单——历史热门、近期热门、高分电影、各类型 Top10。
四个统计任务分别这样设计:
历史热门影片的统计:直接用 Spark SQL 从 MongoDB 的 ratings 表读取评分记录,按电影 ID 分组,统计评分人数,取数量最多的前 N 部。这个逻辑简单粗暴,但“评分人数多 = 热门”这个假设在大多数场景下成立。
近期热门影片的统计:在历史热门的基础上加了时间窗口。实现方式是定义一个 UDF,把评分时间戳转换成“是否在近一个月内”的布尔标记,然后只统计窗口内的评分。这个 UDF 是理解 Spark SQL 自定义函数的好例子。
电影平均评分的统计:按电影 ID 聚合所有评分,计算平均值,写入 AverageMovies 数据集。这个结果会用在详情页展示电影得分,也会作为离线推荐的一个排序因子。
各级别影片 Top10 的统计:这是最复杂的一个。电影的 genres 字段是“动作|冒险|科幻”这种按竖线分隔的字符串,需要先用 UDF 按|切分成数组,再用explode展开成多行,最后按每个类别分别计算评分 Top10。核心代码见下一章。
2.3 实时推荐与详情页推荐:基于内容的相似匹配
实时推荐模块用的是 Spark Streaming,它接收用户的行为流(比如刚给某部电影打了高分),然后做两件事:一是把行为写入 MongoDB 作为后续离线训练的输入,二是立刻基于这部电影的内容标签找相似电影,推给用户。这里的“相似”不是协同过滤算出来的用户相似,而是电影本身属性(类型、导演、主演)的相似度,用的是基于内容的推荐算法。
详情页推荐是实时推荐的“懒加载版本”:用户点进某部电影详情页时,后端才去计算这部电影的相似电影并返回。好处是不需要预先为每个用户算好结果,存储开销小;坏处是首次访问详情页会有计算延迟。在 Spark 环境下,这个计算可以做成一次性的批处理任务,把每部电影的相似 Top10 预先算好存起来,而不是实时算。
2.4 协同过滤模块:ALS 模型的定位
论文里提到协同过滤算法,也提到 Spark MLlib 里的 ALS。在这套系统里,协同过滤不是唯一的推荐来源,而是作为离线个性化推荐的补充。它的任务是从历史的“用户-电影-评分”矩阵中学习两个隐因子矩阵——用户特征矩阵和电影特征矩阵,然后用矩阵乘法预测用户对未看过电影的评分。
但 ALS 有两个天然痛点,论文里也明确说了:数据稀疏和冷启动。冷启动已经靠人口统计学推荐兜底了,数据稀疏则依赖我们输入 ALS 的数据质量。常见做法是把评分人数少于阈值的电影过滤掉,再把评分矩阵做归一化,让模型更容易收敛。这块我在改造章节会给出具体参数建议。
3. 核心实现逐段拆解:从 MongoDB 读取到推荐结果落地
3.1 Spark SQL 读取 MongoDB:连接器与 DataFrame
离线推荐的第一步是把 MongoDB 中的数据加载成 Spark DataFrame。项目里没有用自定义读取器,而是直接用了官方 MongoDB Spark Connector。加载评分表和电影表的写法如下:
import org.apache.spark.sql.SparkSession val spark = SparkSession.builder() .appName("OfflineRecommender") .master("local[*]") // 伪分布式环境下,这里也可以是 spark://hadoop102:7077 .config("spark.mongodb.input.uri", "mongodb://hadoop102:27017/movie.ratings") .config("spark.mongodb.output.uri", "mongodb://hadoop102:27017/movie.result") .getOrCreate() val ratingsDF = spark.read.format("mongo") .option("uri", "mongodb://hadoop102:27017/movie.ratings") .load() .select("uid", "mid", "score", "timestamp") ratingsDF.show(5)注意select里只取了四个字段:uid(用户 ID)、mid(电影 ID)、score(评分)、timestamp(评分时间戳)。之所以要显式限定字段,是因为 MongoDB 的文档结构往往比关系表松散,early 筛选能显著减少 Spark 在 shuffle 阶段的数据量。伪分布式环境下资源本来就紧张,这个习惯能帮你少踩不少 OOM 的坑。
3.2 UDF 实现:电影类别切分与近期热门统计
基于人口统计学的推荐里,两个核心统计逻辑都需要自定义 UDF。第一个是把电影的 genres 字符串切分成数组,第二个是判断评分时间是否落在近一个月窗口内。这段代码集中展示 UDF 的注册和用法:
import org.apache.spark.sql.functions._ import org.apache.spark.sql.expressions.UserDefinedFunction // 按 "|" 切分电影类别,如 "Action|Adventure|Sci-Fi" -> ["Action", "Adventure", "Sci-Fi"] val splitGenres: UserDefinedFunction = udf((genres: String) => { if (genres == null || genres.trim.isEmpty) Array.empty[String] else genres.split("\\|").map(_.trim) }) // 判断评分时间是否在近30天内 val isRecent: UserDefinedFunction = udf((ts: Long) => { val thirtyDaysAgo = System.currentTimeMillis() - 30L * 24 * 3600 * 1000 ts >= thirtyDaysAgo }) // 各类型电影 Top10 统计 val genreTopMovies = ratingsDF .join(moviesDF, ratingsDF("mid") === moviesDF("mid")) .withColumn("genre", explode(splitGenres(moviesDF("genres")))) .groupBy("genre", "mid") .agg(avg("score").as("avg_score"), count("mid").as("score_count")) .withColumn("rank", row_number().over(Window.partitionBy("genre").orderBy(desc("avg_score"), desc("score_count")))) .filter(col("rank") <= 10) .drop("rank")genreTopMovies这段逻辑的关键点在explode:它把“一部电影属于三个类别”这一行数据展开成三行,每个类别一行,这样后续 groupBy 才能按类别独立排序。窗口函数row_number() over partitionBy负责在每个类别内部取评分最高的 10 部。如果你的数据里有一些只有一两人评分的电影,建议在agg之后加一个过滤条件,只保留score_count >= 10的影片,否则“平均分第一名”容易被单条高分评价刷上去。
近期热门统计更简单:对 ratings 表用 UDF 过滤近一个月的评分,然后按 mid 聚合计数,取 Top N 写入 RateMoreRecentlyMovies 集合。
3.3 ALS 协同过滤训练与个性化推荐
ALS 是 Spark MLlib 里最成熟的协同过滤实现。代码不长,但调参直接影响推荐质量:
import org.apache.spark.ml.recommendation.ALS import org.apache.spark.ml.evaluation.RegressionEvaluator // 划分训练集和测试集,测试集用于评估模型误差 val Array(training, test) = ratingsDF.randomSplit(Array(0.8, 0.2)) val als = new ALS() .setRank(20) // 隐因子数量,越大表达能力越强,但过拟合风险也越高 .setMaxIter(10) // 迭代次数,日志里会显示每次迭代的 loss .setRegParam(0.1) // 正则化参数,控制过拟合 .setUserCol("uid") .setItemCol("mid") .setRatingCol("score") .setColdStartStrategy("drop") // 预测时遇到未见过的用户/电影,直接丢弃而非报错 val model = als.fit(training) // 为每个用户生成 Top20 推荐 val userRecs = model.recommendForAllUsers(20) userRecs.write.mode("overwrite").format("mongo") .option("uri", "mongodb://hadoop102:27017/movie.UserRecs") .save()setColdStartStrategy("drop")是必须强调的一个配置。ALS 模型在预测时如果遇到训练集里没出现过的用户或电影,默认会抛异常。线上系统里每天都有新用户新电影,所以必须设置成drop或nan,丢弃无法预测的条目,而不是让整个批次失败。rank 参数常见取值范围是 10 到 50,regParam 是 0.01 到 0.1,调参时优先观察测试集上的 RMSE(均方根误差)。
3.4 基于内容的相似度计算:标签向量与余弦相似度
基于内容的推荐核心是把电影转成向量,然后算向量相似度。这里用的是最简单的标签映射法:把电影的 genres、主演、导演拼接成文本,分词后映射成 TF-IDF 向量,再算两两之间的余弦相似度。
import org.apache.spark.ml.feature.{HashingTF, IDF, Tokenizer} import org.apache.spark.ml.linalg.SparseVector // 构造电影内容描述列 val movieContent = moviesDF .withColumn("content", concat_ws(" ", col("genres"), col("actors"), col("directors"))) // 分词 + TF-IDF,得到每部电影的特征向量 val tokenizer = new Tokenizer().setInputCol("content").setOutputCol("words") val hashingTF = new HashingTF().setInputCol("words").setOutputCol("rawFeatures").setNumFeatures(1000) val idf = new IDF().setInputCol("rawFeatures").setOutputCol("features") val pipeline = new Pipeline().setStages(Array(tokenizer, hashingTF, idf)) val featureModel = pipeline.fit(movieContent) val movieFeatures = featureModel.transform(movieContent) movieFeatures.select("mid", "features").show(5)这里使用了HashingTF而不是CountVectorizer,主要考虑是 HashingTF 不需要预先构建词典,适合跑批任务;缺点是哈希碰撞会引入噪声,setNumFeatures设得越大碰撞概率越低。算相似度时,对每个电影向量和其余所有向量做点积并归一化就能得到余弦相似度。如果电影数量很大,更聪明的做法是先用 ALS 电影因子向量代替 TF-IDF 向量,因为 ALS 训练出的隐因子已经隐含了“同类型电影向量接近”的特性。
3.5 后端接口与前端调用的衔接
推荐结果算完后,展示层还需要后端接口。Spring 后端提供了四类核心接口:登录注册、获取首页推荐、详情页相似推荐、提交评分。前端 AngularJS 通过 HTTP 调用这些接口,渲染推荐列表:
@RestController @RequestMapping("/api/recommend") public class RecommendController { @Autowired private MongoTemplate mongoTemplate; // 首页推荐:读取离线推荐表,按用户返回混合结果 @GetMapping("/home/{uid}") public List<Recommendation> home(@PathVariable String uid) { Query query = new Query(Criteria.where("uid").is(uid)); List<UserRecs> userRecs = mongoTemplate.find(query, UserRecs.class, "UserRecs"); return userRecs.stream() .flatMap(rec -> rec.getRecs().stream()) .sorted((a, b) -> b.getScore().compareTo(a.getScore())) .limit(20) .collect(Collectors.toList()); } // 提交评分:写入 MongoDB,同时作为实时推荐的触发信号 @PostMapping("/rate") public Result rate(@RequestBody RatingRequest request) { mongoTemplate.save(request, "ratings"); return Result.success(); } }前端接到recommendations数组后,按分数从高到低渲染成卡片列表。这个接口的写法比较直白,但它把分层关系讲清楚了:前端只认 userId,不关心推荐结果的来源是离线统计还是协同过滤,后端通过对UserRecs聚合实现多路推荐融合。如果你想加一路“热门兜底”,在接口里把GenresTopMovies也读出来混进去就行。
4. 环境搭建与程序运行:伪分布式 Spark 集群怎么跑起来
4.1 虚拟机模拟四节点 Spark 集群
论文的实际运行环境是单机伪分布式:一台物理机上用 VMware 开三台 CentOS 虚拟机,搭一个四节点 Spark 集群(1 Master + 3 Worker),三台机器的主机名分别设计为 hadoop102、hadoop103、hadoop104。之所以从 hadoop102 开始编号,是为了给 Namenode 留出 101 的位置,如果是生产环境你还会加一个 105 做备用 Master。
搭建步骤如下:
- 在三台虚拟机上分别安装 JDK 并配置
JAVA_HOME - 配置 SSH 免密登录,三台机器两两互信
- 修改
/etc/hosts,把三个主机名映射到固定 IP - 在 Spark 的
conf/slaves文件里填写三个 Worker 主机名 - 修改
spark-env.sh,指定SPARK_MASTER_HOST=hadoop102、SPARK_MASTER_PORT=7077
完成后在 Master 节点执行start-all.sh,浏览器访问hadoop102:8080,Worker 列表里能看到三个节点说明集群搭好了。
4.2 MongoDB 集合设计与启动
MongoDB 在 Windows 本机运行,负责存业务表和推荐结果表。数据库里设计了两类集合:一类是原始数据——User(用户)、Ratings(评分)、Movies(电影);另一类是推荐结果——AverageMovies、GenresTopMovies、RateMoreMovies、RateMoreRecentlyMovies、UserRecs。
启动方式最简单,命令行直接跑:
mongod --dbpath /data/db --port 27017 --bind_ip 0.0.0.0要注意的是--bind_ip 0.0.0.0,否则虚拟机里的 Spark 应用连不上 Windows 宿主机上的 MongoDB。这是伪分布式环境里的常见坑,后面避坑章节还会细说。业务表创建之后,用mongoimport把 ratings.dat 和 movies.dat 导入即可。
4.3 Redis 与 Elasticsearch 配置
Redis 在 Linux 虚拟机里运行,主要给实时推荐模块做缓存,缓存用户最近评分记录和热门推荐结果。配置项只需要改两处:bind 0.0.0.0允许远程访问,maxmemory设一个合理上限如 2GB,避免缓存无限增长把内存吃光。
Elasticsearch 的作用是搜索。电影搜索功能没有走 MongoDB 的模糊查询,而是把电影标题、类别、主演信息同步到 ES,用 ES 的倒排索引做全文检索。配置上需要注意 ES 的network.host要改成虚拟机能访问的 IP,discovery.type设为single-node,否则单机模式会因组播发现失败而启动异常。
4.4 系统启动顺序与验证
整个系统启动依赖顺序很关键,错了会出现“Spark 任务提交成功但读写数据失败”的怪问题:
# 1. 启动 MongoDB(Windows 宿主机) mongod --dbpath /data/db --port 27017 --bind_ip 0.0.0.0 # 2. 启动 Redis(hadoop103) redis-server /etc/redis/redis.conf # 3. 启动 Elasticsearch(hadoop104) elasticsearch -d # 4. 启动 Spark 集群(hadoop102 Master) start-all.sh # 5. 启动后端 Spring 服务(Windows,IDEA 里直接 Run) mvn spring-boot:run # 6. 前端 AngularJS 通过 Nginx 或开发服务器访问 localhost:8088启动完成后验证路径:浏览器访问localhost:8088能看到登录页;登录后首页能看到“热门推荐”“近期热门”“高分电影”三个榜单;点进任何一部电影详情页能看到相似电影;给任意电影打 5 分后,隔几秒刷新首页,实时推荐区应该出现与刚评分电影类型相近的影片。这四条验证过了,系统链路就算通了。
4.5 推荐任务的手动触发
离线推荐任务如果用spark-submit提交,可以做成 jar 单独跑:
spark-submit \ --class com.movie.recommender.OfflineRecommender \ --master spark://hadoop102:7077 \ --executor-memory 1G \ movie-recommender-1.0.jar--executor-memory在伪分布式环境下不要超过 2G。三台虚拟机共享一台物理机的内存,给多了反而会因为物理内存不足引起整机 swap,Spark 任务会越跑越慢,最后表现为 Web UI 上任务卡在 SCHEDULED 状态不动。
5. 常见问题避坑:伪分布式环境下的血泪经验
5.1 Spark 任务报错:找不到 MongoDB 驱动类
现象:提交离线推荐任务后,任务在读取 MongoDB 阶段抛出ClassNotFoundException: com.mongodb.MongoClient或类似驱动类缺失错误。
原因:Spark 集群的 Executor 节点没有加载 MongoDB Connector 的依赖 jar。本地 IDEA 运行时依赖在 classpath 里没问题,但spark-submit提交到集群后,Executor 的 classpath 是独立的。
解决:打包时把依赖打进 fat jar,或者提交任务时用--packages org.mongodb.spark:mongo-spark-connector_2.11指定依赖。从那以后我每次提交 Spark 任务都强制检查一遍--packages列表和代码里 import 的包一一对应。
5.2 Executor 频繁 OOM,任务卡死在 SCHEDULED 状态
现象:离线统计任务在跑大表聚合时报 Executor OOM,日志里有java.lang.OutOfMemoryError, Web UI 里看到多个 Executor 处于 FAILED 状态。
原因:伪分布式集群的内存总量有限,三台 Worker 共享物理内存。默认配置下 Spark 会为每个 Executor 分配物理内存的很大比例,多个 Executor 叠加直接吃满内存。另外,读取 MongoDB 时如果没有提前 select 字段,DataFrame 会带着大量冗余字段参加 shuffle,内存压力更大。
解决:第一,spark-env.sh里把SPARK_WORKER_MEMORY降到 2G,提交任务时--executor-memory 1G;第二,所有 Spark SQL 读取后的第一步就是select精简字段;第三,对特别大的 ratings 表,先按 uid 做一次repartition再聚合。这条优化在真实集群同样适用,只是真实集群阈值可以放宽。
5.3 UDF 在集群模式下抛序列化异常
现象:本地跑没问题,spark-submit提交到集群后报Task not serializable或org.apache.spark.SparkException: Task not serializable,错误栈指向代码里定义的 UDF。
原因:如果 UDF 内部引用了外部对象(比如配置类实例、SparkSession 以外的全局变量),这个外部对象必须可序列化,否则闭包无法在 Executor 间传输。本地模式下 Driver 和 Executor 在同一 JVM,问题不会暴露。
解决:UDF 内部不要引用外部非序列化对象,把所有依赖封装成Serializable案例类,或者在 UDF 内部直接创建所需对象。我的习惯是:任何 UDF 先问自己“如果它在另一台机器上执行,需要带什么东西?”,只带字符串和基本类型就是最安全的。
5.4 Elasticsearch 启动后外部无法访问
现象:ES 在本机启动正常,curl localhost:9200有响应,但在另一台机器上访问hadoop104:9200超时或拒绝连接。
原因:Elasticsearch 默认只绑定127.0.0.1,虚拟机上的 Spark 应用当然访问不到。
解决:修改elasticsearch.yml,设置network.host: 0.0.0.0,同时设置discovery.type: single-node,否则单节点会因为 bootstrap checks 拒绝启动。改完重启 ES 后用curl http://hadoop104:9200验证。
5.5 前端页面能打开但登录后一直跳回登录页
现象:localhost:8088 的页面加载正常,输入用户名密码后接口也能返回成功,但页面刷新后又回到登录页。
原因:AngularJS 前端把登录状态存到了浏览器 localStorage,但后端接口跨域或 Cookie 配置了Secure属性,导致 Cookie 没写进去。Spring 后端返回登录态时如果没有正确配置跨域头,浏览器会拦截写入。
解决:后端在 WebMvcConfigurer 里配置 CorsRegistry,允许前端域名跨域请求带上 Cookie;同时确认登录接口的响应头包含Access-Control-Allow-Credentials: true。如果用了 Nginx 代理前端,还要检查代理配置是否把 Cookie 头透传了。
6. 二次开发改造思路:把“能跑的推荐”变成“好用的推荐”
读完源码、跑通链路之后,真正的价值在于改造成符合自己场景的系统。我会从四个层面动刀,按投入产出比排序。
第一层:推荐结果的加权融合。当前首页接口直接合并 UserRecs 排序,本质上还是单一算法输出轮流展示。更稳的做法是给每路推荐打分:ALS 结果乘 0.5,基于内容的结果乘 0.3,热门榜乘 0.2,做加权取 TopN。这几个权重可以先用固定值,跑一周看各路的点击转化率再调。代码改动只在 RecommendController 里加一个融合函数,不动底层。
第二层:给 ALS 加一个数据预处理。当前 ratings 表是原始评分直接进模型,数据稀疏时会训练出大量零向量。建议训练前过滤掉评分人数少于 20 的电影,对 score 做标准化到 0-1 区间。能明显提升收敛速度和推荐精度。
第三层:实时推荐的触发源扩展。当前实时推荐只响应“评分”事件。改造方向是把“点击详情页”“搜索电影”也作为行为流输入,对点击行为降权即可。改动工作量集中在行为日志接入和权重映射。
第四层:把热点数据推进 Redis。详情页相似推荐和首页热门榜都可以做成定时任务预热进 Redis,前端接口只读缓存,降低 MongoDB 读压力。改造点是在离线任务结束后加一步写 Redis 的操作。
验证改造效果的方法不需要上线 A/B:准备两组用户,一组走老逻辑一组走新逻辑,各推 50 部电影,比较两组用户的点击率和评分数量。这个比较可以在 MongoDB 里直接用聚合查询完成,不用额外搭数据平台。
这套工程我前前后后拆过两次,每次都被同一个问题卡住:伪分布式环境下只要集群一重启,MongoDB 里的推荐结果表和 Redis 缓存就不同步。从那以后我每次改完离线任务都强制走一遍“清空结果表 → 重跑离线批 → 刷新 Redis 预热”的流程,确认三份数据一致才交给前端验收。做推荐系统,数据一致性和算法效果一样影响体验——希望这篇拆解能帮你少走我来回折腾的那几步路。
本文还有配套的精品资源,点击获取