☰
Spark电影推荐系统全链路:ALS协同过滤、伪分布式搭建与避坑
2026/11/4 12:47:12 网站建设 项目流程

简介:围绕基于Spark的电影推荐系统设计与实现,这份文档面向大数据、推荐系统方向的课程设计、毕业设计及项目入门者,帮助梳理从需求分析、算法选型到系统实现与测试的完整思路。包内共1个docx文件,约7.46MB,标题标注“论文+源码”,正文以论文章节组织,涵盖Spark、MongoDB与Web开发技术,人口统计学、基于内容及协同过滤三类推荐算法,以及离线、实时和详情页推荐、登录注册、个性化推荐、电影搜索、用户评分等功能模块和功能、性能测试。目录还包含可行性分析、系统设计、结论与参考文献,内容预览可见摘要、关键词及完整章节安排,便于读者快速定位技术路线与写作框架。已有207人学习,适合需要参考Spark电影推荐系统方案、理解协同过滤落地流程或搭建论文结构的人群。

1. 一份能跑通的 Spark 电影推荐系统毕设:从算法到页面的完整链路

很多人搜「Spark 电影推荐系统」时,真正想要的是一个能编译、能出推荐结果、论文里算法讲得清的完整工程,而不只是一段协同过滤的公式推导。这份资源是一套论文加源码的组合:后端用 Scala 和 Java 写推荐模块,Spring 和 Maven 管业务与依赖,MongoDB 存业务表和推荐结果表,Redis 做缓存,Elasticsearch 承担搜索,前端用 AngularJS;算法层把基于人口统计学、基于内容、协同过滤三类算法分别放在离线推荐、实时推荐、详情页推荐三个入口上。它适合正在做大数据方向课程设计的学生,也适合想找一个「推荐系统全链路长什么样」参照物的后端开发者。

2. 技术选型拆解:为什么是 Spark + MongoDB 这套组合

2.1 Spark 在这套系统里到底承担什么活

把推荐系统拆开看,其实只有三类计算:全量统计(哪部电影评分最高、哪个类别最热门)、相似度计算(这部电影和哪些电影像、这个用户和哪些用户像)、增量更新(用户刚打了一个分,马上要影响推荐)。这三类活如果放在单机 Python 脚本里,几万条评分数据也能算,但一旦数据量上到百万级评分、几十万用户,单机就要开始等。

Spark 的价值在于它把中间结果放在内存里。协同过滤里的矩阵分解、ALS 迭代是个典型的反复读写同一批数据的场景,MapReduce 每轮迭代都落盘,Spark 靠 RDD 缓存把这个开销省掉。这套项目里 Spark 主要干四件事:用 Spark SQL 读 MongoDB 里的评分表做统计聚合、用 ALS 训练用户-物品隐向量、用 SSM(Spark Streaming)消费实时评分流做增量推荐、把算出来的推荐结果批量写回 MongoDB 的推荐结果表。

选型上有一个点值得说清楚:项目里 Spark 跑的是伪分布式,一台物理机上用虚拟化切出 Master 加三个 Worker 的节点结构。这在毕设场景里是合理妥协,因为真实三台机器的成本和调试成本都不低,而伪分布式的代码和真集群代码完全一致,只是spark.master配置不同。理解这一点,后面迁移到真集群时才不会翻车。

MongoDB 的位置也很关键。推荐结果是一批形态不固定的文档(用户 ID、推荐电影列表、每部电影的预测分、推荐理由标签),用关系型数据库要拆好几张表还要频繁 join,MongoDB 直接一个文档塞进去,前端一次查询就能拿到整页数据。这是典型的「读多写少、结构灵活」场景下的合理选择,不是跟风用 NoSQL。

2.2 三类推荐算法的分工与边界

这套系统没有把所有推荐都压给协同过滤,而是按场景分工,这个设计思路比算法本身更值得抄:

推荐入口使用的算法触发时机主要解决的问题
离线推荐基于人口统计学 + 统计热门定时批处理新用户冷启动、全局兜底
实时推荐基于内容 + 协同过滤用户产生新评分兴趣漂移、即时反馈
详情页推荐基于内容用户点击某部电影相似影片发现
热门榜单Spark SQL 统计定时批处理高分片、近期热门、分类 Top10

基于人口统计学的算法不看用户历史行为,只看用户注册时填的年龄、性别等基本信息,把相似人群喜欢的电影推给新用户。它的准确率确实一般,但它是唯一能在「这个用户一条评分都没有」时还能出结果的方案,所以被放在离线兜底的位置。

基于内容的算法靠电影自身的标签(类型、主演、导演)算相似度。它的优势是不依赖其他用户的数据,一部冷门新片只要标签齐全就能被推出去;劣势是永远在用户已知的兴趣圈里打转,推不出意外惊喜。所以它被放在详情页——用户已经点开某部电影了,这时候推相似的同类片命中率最高。

协同过滤靠用户行为矩阵找相似,是三者里个性化程度最高的,但它吃数据量,矩阵稀疏时效果会崩。项目把它放在实时推荐链路里,是因为用户持续打分后矩阵会逐渐稠密,这时候 CF 才有发挥空间。

提示:三类算法不是互相替代的关系,是互补。毕设答辩时如果被问「为什么不用深度学习」,从冷启动、数据量、可解释性三个角度答,比硬吹模型先进要稳。

3. 环境搭建:从零把四节点伪分布式跑起来

3.1 节点规划与基础环境

项目里是三个节点的规划(一个 Master、两个 Worker 的命名习惯),主机名常见做法是 hadoop102、hadoop103、hadoop104 这种序列化命名。这里给出可复现的配置步骤,实际节点数按自己机器内存调整,每个 Worker 至少给 2GB。

# 三台机器统一执行:改主机名(以第一台为例) hostnamectl set-hostname hadoop102 # 三台机器的 /etc/hosts 都要写全,否则后面 Spark 找不到节点 cat >> /etc/hosts <<'EOF' 192.168.56.102 hadoop102 192.168.56.103 hadoop103 192.168.56.104 hadoop104 EOF # 安装 JDK 并配置环境变量 tar -zxvf jdk-8u*-linux-x64.tar.gz -C /opt/module/ cat >> /etc/profile <<'EOF' export JAVA_HOME=/opt/module/jdk1.8.0 export PATH=$JAVA_HOME/bin:$PATH EOF source /etc/profile java -version

主机名和 hosts 这一步不能省。Spark 的 Worker 注册、Web UI 里的节点列表都靠主机名解析,如果只在单机改了 hosts,另外两台解析不到,表现就是 Master 页面上 Worker 一直是 0,或者节点状态反复在 ALIVE 和 DEAD 之间跳。这是新手最容易卡住的地方,排查时先ping hadoop103看通不通。

免密登录也要做,因为启动脚本start-all.sh要从 Master 分发到各 Worker:

# 在 hadoop102 上生成密钥并分发(三次回车,不设密码) ssh-keygen -t rsa ssh-copy-id hadoop102 ssh-copy-id hadoop103 ssh-copy-id hadoop104 # 验证:能不进密码直接登录才算成功 ssh hadoop103 "hostname"

3.2 Spark 配置与启动验证

Spark 的配置文件主要在conf目录下,需要从模板复制出spark-env.sh和workers:

cd /opt/module/spark/conf cp spark-env.sh.template spark-env.sh cp workers.template workers # spark-env.sh 里写这几行 cat >> spark-env.sh <<'EOF' export JAVA_HOME=/opt/module/jdk1.8.0 export SPARK_MASTER_HOST=hadoop102 export SPARK_MASTER_PORT=7077 export SPARK_WORKER_CORES=2 export SPARK_WORKER_MEMORY=2g EOF # workers 文件列出所有 Worker 节点(不要写 Master) cat > workers <<'EOF' hadoop103 hadoop104 EOF # 启动集群 /opt/module/spark/sbin/start-all.sh jps # Master 节点应看到 Master 进程,Worker 节点应看到 Worker 进程

这里SPARK_WORKER_MEMORY给 2g 是下限,如果机器只有 8G 内存还要同时跑 MongoDB 和 Elasticsearch,可以把 Worker 数减到两个、每个给 1.5g,但别低于 1g,否则 ALS 训练会直接 OOM。

启动后在浏览器访问http://hadoop102:8080,能看到 Worker 列表里两个节点的 IP、状态和核数,就说明集群成了。注意端口是 8080,项目文档里出现的 8989 之类端口是特定配置下的结果,以自己spark-env.sh里写的为准,别照着抄死。

3.3 MongoDB、Redis、Elasticsearch 的落位

这三个组件各自功能不同,别混着装:

# MongoDB:Windows 或 Linux 均可,这里给 Linux 启服务的方式 mongod --dbpath /data/db --port 27017 --bind_ip 0.0.0.0 --fork --logpath /var/log/mongod.log # 建库和建集合(推荐结果表必须提前建好,Spark 写入时不会自动建索引) mongosh --eval ' use recommend; db.createCollection("UserRecs"); db.createCollection("AverageMovies"); db.createCollection("GenresTopMovies"); db.createCollection("RateMoreMovies"); db.createCollection("RateMoreRecentlyMovies"); db.createCollection("Movie"); db.createCollection("Rating"); db.UserRecs.createIndex({userId: 1}); ' # Redis:做缓存,配置基本默认即可,改个密码保护 redis-server /etc/redis.conf --daemonize yes redis-cli ping # 返回 PONG # Elasticsearch:承担搜索,注意单节点要改两个配置否则起不来 echo "discovery.type: single-node" >> /etc/elasticsearch/elasticsearch.yml echo "network.host: 0.0.0.0" >> /etc/elasticsearch/elasticsearch.yml systemctl start elasticsearch curl http://localhost:9200 # 返回 JSON 带 cluster_name 即成功

UserRecs上的userId索引建议一开始就建。推荐结果表随着定时任务反复写入会越来越大,前端按用户查推荐时如果没有索引,查询会随数据量线性变慢,这在演示阶段就能感觉出来。Elasticsearch 的discovery.type不设为 single-node 时,单机启动会一直卡在 elected master 阶段,日志里反复报节点发现失败,这是最容易踩的一个坑。

4. 推荐模块落地:四段可抄的核心代码

4.1 用 Spark SQL 做统计推荐

统计推荐是整套系统里最先该跑通的部分,因为它不依赖模型训练,出结果快,能用来验证数据链路是否通。

import org.apache.spark.sql.SparkSession import org.apache.spark.sql.functions._ import com.mongodb.spark.config._ val spark = SparkSession.builder() .appName("StatisticsRecommender") .master("local[*]") // 集群模式改成 spark://hadoop102:7077 .config("spark.mongodb.input.uri", "mongodb://hadoop102:27017/recommend.Rating") .config("spark.mongodb.output.uri", "mongodb://hadoop102:27017/recommend") .getOrCreate() val ratingDF = spark.read.format("com.mongodb.spark.sql.DefaultSource").load() // 1) 历史热门:按电影被评分次数排名 val rateMoreMovies = ratingDF.groupBy("mid") .agg(count("uid").as("count")) .orderBy(desc("count")) // 2) 平均评分:只统计有评分的电影,避免 0 分拉低均值 val averageMovies = ratingDF.groupBy("mid") .agg(avg("score").as("avg")) .filter(col("avg") > 0) // 3) 近期热门:先加时间戳字段,再按电影聚合 val recentMovies = ratingDF .withColumn("yearMonth", date_format(col("timestamp"), "yyyyMM")) .groupBy("mid", "yearMonth") .agg(count("uid").as("count")) .orderBy(desc("count")) rateMoreMovies.write.option("collection", "RateMoreMovies").mode("overwrite").save() averageMovies.write.option("collection", "AverageMovies").mode("overwrite").save() recentMovies.write.option("collection", "RateMoreRecentlyMovies").mode("overwrite").save()

这段的关键在写入模式。mode("overwrite")意味着每次定时任务跑完会整表覆盖,这对统计类结果是对的,因为统计必须基于全量数据重算;但如果你把它用在UserRecs上就会出事——用户推荐结果是分用户增量更新的,整表覆盖会把其他用户的推荐清空。常见做法是统计表用 overwrite,用户级推荐表用 append 或者按 userId 过滤后局部覆盖。

另一个细节是filter(col("avg") > 0)。很多电影只有一两个评分,平均值波动大,有些数据源里还混着 0 分记录(表示未评分),不过滤的话平均分榜单会被这些噪声刷屏。

4.2 ALS 协同过滤训练与参数调优

协同过滤是这套系统的算法核心,Spark MLlib 的 ALS 是标准实现。它的原理是把用户-电影评分矩阵分解成两个低维矩阵的乘积,用隐向量表达用户口味和电影特征。

import org.apache.spark.ml.recommendation.ALS import org.apache.spark.sql.functions._ // 输入:uid(int), mid(int), score(double),注意类型必须转成数值 val ratings = ratingDF .withColumn("uid", col("uid").cast("int")) .withColumn("mid", col("mid").cast("int")) .withColumn("score", col("score").cast("double")) // 划分训练/测试集,seed 固定保证可复现 val Array(training, test) = ratings.randomSplit(Array(0.8, 0.2), seed = 42) val als = new ALS() .setMaxIter(10) // 迭代轮数,10 轮在中小数据集上基本收敛 .setRegParam(0.1) // 正则项,防过拟合,数据稀疏时适当调大 .setRank(10) // 隐向量维度,10~50 之间试 .setUserCol("uid") .setItemCol("mid") .setRatingCol("score") .setColdStartStrategy("drop") // 必须加,否则预测集有未知用户会出 NaN val model = als.fit(training) // 评估:RMSE 越低越好,超过 1.0 说明模型基本没学到东西 val predictions = model.transform(test) val rmse = new RegressionEvaluator() .setMetricName("rmse") .setLabelCol("score") .setPredictionCol("prediction") .evaluate(predictions) // 给每个用户生成 20 条推荐 val userRecs = model.recommendForAllUsers(20) userRecs.write.option("collection", "UserRecs").mode("overwrite").save() println(s"ALS RMSE = $rmse")

四个参数的作用要拎清:rank是隐向量维度,太小表达不了复杂口味,太大会过拟合且在数据稀疏时方差爆炸;regParam是正则强度,评分数据稀疏时调大到 0.1~0.5 能明显稳定结果;maxIter到 10 轮后 RMSE 下降已经很平缓,加轮数收益不大;setColdStartStrategy("drop")是必须项,训练集里没出现过的用户在预测时会产出 NaN,不 drop 的话整个评估直接 NaN,很多人卡在这一步还以为模型训练失败了。

还有一个隐性的坑:ALS 会为每个用户推荐「预测分最高」的物品,但它不会自动剔除用户已经评分过的电影。如果前端直接把结果展示出来,用户会看到自己刚打过 5 分的电影又被推了一次。常见做法是在生成推荐后 join 原始评分表做一次 anti-join 过滤:

// 过滤掉用户已经看过(评过分)的电影 val seen = ratings.select("uid", "mid") val recs = model.recommendForAllUsers(50) .select(col("uid"), explode(col("recommendations")).as("rec")) .select(col("uid"), col("rec.mid").as("mid")) .join(seen, Seq("uid", "mid"), "left_anti")

先取 50 条再过滤,过滤后保留前 20 条,这样不会因为过滤导致某些用户推荐不足。这个细节在论文里通常一字不提,但在实际演示时一眼就能被看出来。

4.3 基于内容的详情页相似推荐

详情页推荐靠标签匹配。电影的类型字段通常是动作|科幻|冒险这种竖线分隔的字符串,需要先切分成集合,再算杰卡德相似度或 TF-IDF 余弦相似度。

import org.apache.spark.sql.functions._ // movies 表:mid, name, genres(如 "Action|Sci-Fi|Adventure") val movies = spark.read.format("com.mongodb.spark.sql.DefaultSource") .option("uri", "mongodb://hadoop102:27017/recommend.Movie") .load() // 把类型字符串切成数组,并统计每部电影的类型集合大小 val movieGenres = movies .withColumn("genreArr", split(col("genres"), "\\|")) .select(col("mid"), col("name"), col("genreArr")) // 自身笛卡尔积,算杰卡德相似度:交集大小 / 并集大小 val similar = movieGenres.as("a").crossJoin(movieGenres.as("b")) .filter(col("a.mid") =!= col("b.mid")) .withColumn("interSize", size(array_intersect(col("a.genreArr"), col("b.genreArr")))) .withColumn("unionSize", size(array_union(col("a.genreArr"), col("b.genreArr")))) .withColumn("similarity", col("interSize") / col("unionSize")) .filter(col("similarity") > 0.3) .select(col("a.mid").as("mid"), col("b.mid").as("simMid"), col("similarity")) similar.write.option("collection", "MovieSimilar").mode("overwrite").save()

笛卡尔积是这段代码的性能敏感点。电影表如果是几万条,自身积就是几亿对,虽然 Spark 扛得住,但耗时明显。可行的优化做法是先按首个类型分组,组内做积,把规模降下来;或者干脆用 TF-IDF 把标签向量化后用RowMatrix的columnSimilarities算,复杂度更低。毕设数据量下直接 crossJoin 没问题,但答辩被问性能时,你要能说出这个瓶颈在哪。

similarity > 0.3这个阈值需要凭经验调。设太低,推荐出来一堆只共享一个泛类型(比如都带「剧情」)的片子,观感很差;设太高,冷门类型电影可能一条相似结果都没有,详情页空着。0.3 在常见数据集上是个折中值,实际调的时候建议抽样看二三十部电影的推荐列表再定。

4.4 实时推荐链路与前端落地

实时推荐的流程是:用户打分 → 消息进入流处理 → 触发该用户推荐重算 → 结果写 Redis → 前端读 Redis 展示。整条链路里最容易出问题的是流处理的重启状态恢复,比赛演示时如果中途崩溃,没有 checkpoint 就会从零开始。

import org.apache.spark.streaming._ import org.apache.spark.streaming.kafka010._ val ssc = new StreamingContext(spark.sparkContext, Seconds(5)) ssc.checkpoint("hdfs://hadoop102:9000/stream-checkpoint") // 断点恢复的关键 val stream = KafkaUtils.createDirectStream[String, String]( ssc, LocationStrategies.PreferConsistent, ConsumerStrategies.Subscribe[String, String](Array("rating-topic"), kafkaParams) ) // 每批取出新的评分,按 uid 分组后触发该用户的推荐更新 stream.map(_.value()).foreachRDD { rdd => val newRatings = spark.read.json(rdd) if (!newRatings.isEmpty) { val userIds = newRatings.select("uid").distinct().collect() userIds.foreach { row => val uid = row.getInt(0) // 用该用户近期评分 + 内容相似度重算 TopN,写入 Redis val recs = computeUserRecs(uid) redisClient.set(s"rec:$uid", recs.mkString(",")) } } } ssc.start() ssc.awaitTermination()

每批 5 秒的批间隔是个折中:太短会导致大量空批次和频繁的连接建立开销,太长则实时感全无。生产环境里更合理的做法不是每个用户变更就全量重算,而是把增量评分先累积,按分钟级窗口批量更新,这一点在毕设规模下可以简化,但要知道简化在哪。

前端这块是 AngularJS 拉数据渲染,登录注册、评分、搜索各对应一个接口。搜索走 Elasticsearch 的match查询,比 MongoDB 的正则模糊匹配快一个量级,尤其是中文片名分词后的召回效果差异明显。前端访问端口按项目配置来,页面能正常跳转、推荐列表能刷出数据,就说明前后端链路打通了。

5. 避坑排查:五个真实会卡住你的问题

5.1 Worker 显示 0 或反复掉线

现象:start-all.sh执行完,Master 的 Web UI 上 Worker 数量为 0,或者一开始有两个过一会变成 DEAD。

原因:九成是主机名解析问题。Worker 启动时向 Master 注册用的是自己的主机名,如果 Master 的 hosts 里没配这个主机名,或者防火墙拦了通信端口,注册就会失败。

解决:三台机器互相ping主机名确认解析,然后telnet hadoop102 7077和telnet hadoop103 8081确认端口通。如果只是 Web UI 上地址显示成了内网随机 IP,在spark-env.sh里加SPARK_LOCAL_IP显式指定。

5.2 ALS 训练报 NaN 或评估结果为空

现象:模型 transform 之后 prediction 列全是 NaN,RMSE 算出来也是 NaN。

原因:测试集里存在训练集没出现过的用户或电影,ALS 对未知 ID 无法给出预测。

解决:必须设置setColdStartStrategy("drop")。另外检查评分字段类型,如果 score 混进字符串或者空值,训练阶段就会污染,读数据后先filter(col("score").isNotNull)再 cast。

5.3 Spark 写 MongoDB 报连接超时

现象:本地mongosh能连上,Spark 任务写入时报Timeout waiting for a server。

原因:MongoDB 默认只监听 127.0.0.1,Spark 从集群节点连过去连不上;或者连接串里的数据库和集合名写错。

解决:启动 MongoDB 时加--bind_ip 0.0.0.0,连接串格式是mongodb://主机:27017/数据库名.集合名,注意.分隔的写法,写成/是另一个含义。写入前先用 shell 手动插一条验证。

5.4 Elasticsearch 单机启动卡在 master 选举

现象:systemctl start elasticsearch之后服务卡住,日志反复输出节点发现相关报错。

原因:默认配置按生产集群设计,单节点找不到可参与选举的候选节点。

解决:elasticsearch.yml里设discovery.type: single-node,同时把堆内存调小到 1g,否则在 8G 内存的机器上和 Spark 抢资源会互相拖死。

5.5 推荐结果里出现用户已看过的电影

现象:刚给某部电影打了分,刷新页面推荐列表里还有它。

原因:ALS 只按预测分排序,不知道哪些物品已被消费,没有做已读过滤。

解决:按 4.2 节最后那段left_antijoin 做过滤,并且要在过滤前多取一些候选,避免过滤后数量不足。同时给前端加去重逻辑作为双保险。

6. 效果验证与一个我常用的抽样验收法

模型跑完不等于系统可用,验收要看具体推荐内容。我一般不看 RMSE 一个数就下结论,而是从推荐结果表里随机抽 20 个用户,人工看每个用户的 Top10 推荐,从三个维度打分:类型贴合度(推的片子类型和该用户历史评分类型重合多少)、新颖度(是否有用户没接触过的类型)、明显错配(是否推出了完全无关的片子)。这三个维度里,类型贴合度低于六成说明 ALS 参数或数据有问题,明显错配超过两三条基本可以判定是相似度阈值太松或者过滤没做好。

冷启动用户要单独验。新建一个账号,只填基本信息不打分,看离线推荐能不能出结果。如果返回空,检查人口统计学分组逻辑是否覆盖了这个年龄段和性别;如果返回的全是同一批热门片,那是正常的,但要确保前端有「热门推荐」这个明确入口,而不是把它混在「为你推荐」里,否则用户会觉得推荐系统很敷衍。

时序上的验证容易被忽略:给某用户连续打三个同一类型的高分,十分钟后重新拉取推荐,看推荐结果是否发生了偏移。如果没有变化,说明实时链路根本没通——常见原因是流处理任务启动了但没消费到消息,或者结果写进了 Redis 但前端读的还是旧缓存。这条验证比看日志有效得多。

最后提供一个排查数据分布的小脚本,用来在训练前判断协同过滤到底有没有戏:

# 用 Python 快速摸一下数据稀疏度,避免盲目训练 import pandas as pd ratings = pd.read_csv("ratings.csv") # 导出为 CSV 后使用 n_users = ratings["uid"].nunique() n_items = ratings["mid"].nunique() density = len(ratings) / (n_users * n_items) print(f"用户数 {n_users},电影数 {n_items}") print(f"矩阵稀疏度 {density:.5f}") print(ratings.groupby("uid").size().describe()) # 看人均评分条数 # 经验判断:密度低于 0.001 且人均评分少于 10 条时,ALS 效果会很差

这段代码的意义在于提前止损。如果人均评分条数只有个位数,矩阵极端稀疏,ALS 出来的结果基本等同于随机,这时候应该把权重往基于内容和统计热门上倾斜,而不是继续调regParam硬救。我在第一次做这类系统时就吃了没先看分布直接开训的亏,跑了一晚上 RMSE 还是 1.2,后来一查发现大部分用户只有三四个评分,换再多参数也没意义。从那以后每次拿到评分数据,我先跑一遍稀疏度统计再决定用哪个算法打主力,这个习惯帮我省掉了很多无效调参时间。希望帮到你。

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

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

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

立即咨询