1. 项目概述:为什么要用Spark+Hadoop+Hive做影视推荐
做影视推荐这个方向,很多人第一反应是搞个协同过滤算法,拿Python写个SVD或者ItemCF调通就完事了。但一旦把数据规模拉到真实生产环境,比如几百万用户、几十万部影视作品、上亿条打分记录,单机内存根本扛不住,训练一次模型跑几个小时甚至直接OOM,这时候就必须把目光投向大数据计算平台。
这个项目选择Spark+Hadoop+Hive这套组合,核心思路是:HDFS负责分布式存储原始数据,Hive把结构化数据管理起来提供SQL分析能力,Spark负责跑分布式推荐算法。整套系统跑在YARN资源调度之上,数据从埋点日志到最终推荐结果全程走离线链路。我见过不少同行直接拿Pandas处理千万级评分数据,卡到怀疑人生,而用Spark写同样的逻辑,节点横向扩展一下,处理速度能翻几十倍,这就是选它的理由。
整套系统适合谁参考?一种是正在做课程设计或毕业设计的学生,这套架构完整、技术栈有分量;另一种是中小型团队想搭建基础版推荐系统、又不想一上来就上Flink实时那套复杂体系的工程师。你不需要提前掌握分布式原理,按下面的步骤走一遍,就能理解数据怎么流动、算法怎么并行跑起来。
先说清楚项目的边界:这不是一个实时推荐系统,而是按批处理方式运行的离线推荐引擎。每天凌晨定时调度,计算前一天的用户行为数据,更新模型和推荐结果。对于影视推荐这个场景,用户的观影兴趣在几天甚至几周内变化不大,离线更新完全够用,这也是业界主流做法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构设计与技术选型思考
2.1 影视推荐系统通常长什么样
一个完整的离线推荐系统,从数据进来到结果输出,需要经历四个核心环节:数据采集与存储、数据清洗与特征加工、推荐算法计算、结果入库与对外服务。
数据采集层面,前端埋点会记录用户的浏览、点击、收藏、评分、播放时长等行为日志,这些日志一般以JSON格式落地到HDFS的原始目录。元数据层面,影视资源信息(导演、演员、类型、上映年份、评分等)通常存在业务数据库里,需要定期同步到Hive表。
数据清洗与特征加工层面,原始日志往往有脏数据,比如空字段、异常值、机器人刷量等,需要去重、过滤、归一化。清洗后的数据会生成两张核心表:用户行为事实表和影视维度表,这两张表就是推荐算法的直接输入。
算法计算层面,整个Spark作业读Hive表数据,跑分布式协同过滤或矩阵分解算法,产出每个用户的Top-N推荐列表。这里要注意,算法跑出来的原始结果还需要做后处理,比如过滤掉用户已经看过的片子、过滤掉最近30天内的冷门影片、按业务规则做多样性打散。
结果入库与对外服务层面,推荐结果会写回Hive表供离线分析,同时以Parquet格式同步到Redis或MySQL,业务后端直接读取Redis返回给前端展示。这样设计的好处是查询性能极好,推荐结果秒出。
2.2 选Spark而不是MapReduce和Flink
可能有人会问,Hadoop自带的MapReduce也能做分布式计算,为什么非要用Spark?答案很简单:数学计算场景下,MapReduce每跑一个Job都要落盘,中间结果反复读写HDFS,做迭代式算法(比如协同过滤要算多轮矩阵操作)性能损耗太大。Spark基于内存计算,同一个Stage内的算子链可以流水线处理,迭代计算性能比MapReduce高出十倍甚至百倍。
那为什么不直接上Flink做实时推荐?影视推荐不同于电商秒杀场景,对实时性要求没那么极端。用户今天看完一部电影,明天收到同类型推荐,完全可以接受。Flink的实时流处理意味着更复杂的状态管理、精确一次语义、消息队列接入,开发和运维成本比离线批处理高一个量级。我们拿Spark批处理+按天调度,用最小的成本拿到80%的效果,性价比最高。
2.3 数据仓库分层:Hive在这里扮演什么角色
很多初学者不理解,Spark本身就能读文件做计算,为什么中间还要塞一个Hive?因为Hive提供了表结构定义和SQL查询能力,把散落的日志文件组织成有业务含义的数据表,同时通过分区、分桶、ORC列式存储等手段优化查询性能。
这个项目的Hive数仓分三层搭建。第一层ODS原始数据层,表结构和日志文件字段一一对应,不做任何加工,保留最原始的数据,方便排查问题。第二层DWD明细层做清洗和标准化,解析JSON字段、转换时间格式、过滤无效数据。第三层DWS服务层生成算法直接使用的宽表,比如用户-物品评分矩阵表、物品特征表、用户近期行为汇总表。分层的好处是每层职责单一,哪一层出问题就在哪一层修复,不会像摊大饼一样改一处崩全局。
项目里HDFS目录规划为:/data/warehouse/ods/user_behavior_log、/data/warehouse/dwd/user_behavior_clean、/data/warehouse/dws/user_item_score,层层递进,谁在哪个目录一目了然。
3. 核心算法选型:ItemCF还是ALS
3.1 两种算法的对比分析
影视推荐领域最常见的两种算法是基于物品的协同过滤(ItemCF)和基于模型的矩阵分解(ALS)。ItemCF的核心逻辑是:如果用户A喜欢电影X,而电影X和电影Y经常被同一批用户喜欢,那么就把电影Y推荐给用户A。它的优点是可解释性强,能明确告诉用户“因为你看过《流浪地球》,所以推荐《星际穿越》”,这对影视产品提升信任感非常重要,业界有个通俗叫法是“看了又看”。
ALS(交替最小二乘法)则是把用户-物品评分矩阵分解成两个低维矩阵,用户矩阵和物品矩阵各带K个隐因子,通过迭代优化逼近原始评分。它处理稀疏矩阵的能力更强,能发现用户自己都没意识到的潜在兴趣,适合做“你可能也喜欢”这类探索性推荐。
实操中的选择依据主要有三点:
| 对比维度 | ItemCF | ALS |
|---|---|---|
| 可解释性 | 强,可展示关联影片 | 弱,无法解释隐因子含义 |
| 训练耗时 | 快,只需算相似度矩阵 | 较慢,需要多轮迭代 |
| 数据稀疏度 | 对稀疏数据敏感 | 对稀疏数据更鲁棒 |
| 冷启动 | 新影片和用户都难处理 | 同样难处理 |
| 实现成本 | 自研逻辑编程简单 | MLlib内置,调包即可 |
实际项目里我的建议是两条腿走路:主推结果用ItemCF,负责稳定输出用户熟悉口味的推荐;探索性推荐用ALS的topN结果,两个结果集按7:3的比例混合后做去重重排。如果项目时间紧、只做一套,那就选ALS,因为Spark MLlib直接提供了现成的ALS算子,代码量少、稳定性高。
3.2 ItemCF的数学原理与Spark实现思路
既然这个项目从教学角度更有料,我把ItemCF手动实现一遍,让你彻底搞懂分布式环境下相似度计算怎么做。
第一步,把用户行为数据转换成评分矩阵的坐标形式。假设我们有三条数据:
code复制用户A 看过《肖申克的救赎》 评分5
用户A 看过《教父》 评分4
用户B 看过《肖申克的救赎》 评分5
用Spark的RDD表示就是(userId, movieId, rating)元组集合,不需要展开成稠密矩阵,分布式存储稀疏坐标更省空间。
第二步,计算物品共现矩阵。对每个用户,取出他评分过的所有电影,两两组合生成(movieId_i, movieId_j)对,计数加1。在Spark里可以用flatMap加reduceByKey实现:先按用户分组,组内做笛卡尔积,映射出一个共现key,最后累加。注意这里有个经验:用户看过的电影数量如果太多(比如超过200部),全量笛卡尔积会产生大量中间数据,通常只取该用户评分最高的前50部参与共现计算,既能保持准确度又能大幅削减计算量。
第三步,计算物品相似度矩阵。常见公式是余弦相似度:similarity(i,j) = cooccur(i,j) / sqrt(popularity(i) * popularity(j)),其中cooccur(i,j)是共现次数,popularity是物品被评分的总次数。这个公式能压制热门影片的相似度虚高问题,一个冷门片和一个全民片被一起观看的次数必然多,但分母会把这种虚假关联压下去。
第四步,生成推荐列表。对用户已看过的每一部电影i,遍历它的TopK相似电影j,累加评分(i) * similarity(i,j),按累加值降序排列,过滤掉已看过的和冷门影片,取前N个输出。
整个流程在Spark里的实现关键点在于:一定要把相似度矩阵广播到每个Executor(用broadcast),避免每算一个用户都去Shuffle拉全量数据,这是性能优化的关键命门。
4. 实操过程:从环境搭建到推荐结果落库
4.1 集群环境规划与安装配置
先给出一套亲测稳定的环境组合:CentOS 7.9系统,JDK 1.8,Hadoop 3.3.4,Hive 3.1.3,Spark 3.3.2(内置Scala 2.12),Python 3.8,PySpark直接随Spark发行版自带。
装这套环境有几个大坑必须提醒。第一,Hadoop和Spark的版本要兼容。网上很多报错都是因为Spark 2.x配Hadoop 3.x或者反过来,我建议参考Hadoop本身的版本说明,简单安全的选择是Hadoop 3.3.x配Spark 3.3.x,这个组合在社区里踩坑最少。第二,Hive和Spark的集成需要额外的livy或HiveServer2,但如果是Spark作业直接读Hive表,只需要把hive-site.xml和mysql-connector-java.jar拷贝到Spark的conf和jars目录,不需要额外装引擎。第三,所有节点要统一系统时间和hostname映射,时钟漂移会导致HDFS通信异常。
环境验证时,用start-dfs.sh启动HDFS,start-yarn.sh启动YARN,然后跑一条Hive建表语句,再跑一条Spark SQL查询,能通就说明基础链路没问题。这里分享一个经验:建议提前把SPARK_HOME和PYTHON_HOME写进/etc/profile,后面写PySpark作业会省去大量环境变量吐槽。
4.2 数据准备:构造用户行为表和影片信息表
没有真实业务数据的情况下,用公开数据集MovieLens的ml-latest-small或ml-25m是最省事的选择。ml-latest-small包含600多用户、9000多部电影、10万条评分,适合本地开发调试;ml-25m有2500万评分,适合压测集群性能。
先把ratings.csv和movies.csv上传到HDFS的/data/raw目录,然后用Hive建表并导入数据:
sql复制CREATE DATABASE IF NOT EXISTS movie_rec;
USE movie_rec;
CREATE EXTERNAL TABLE IF NOT EXISTS ods_ratings(
user_id INT,
movie_id INT,
rating DOUBLE,
timestamp BIGINT
)
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
STORED AS TEXTFILE
LOCATION '/data/raw/ratings';
CREATE EXTERNAL TABLE IF NOT EXISTS ods_movies(
movie_id INT,
title STRING,
genres STRING
)
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
STORED AS TEXTFILE
LOCATION '/data/raw/movies';
这里特意用了外部表(EXTERNAL TABLE),数据文件放在HDFS而表结构挂在Hive上,两边解耦,删表不影响原始文件。建完表后,执行SELECT COUNT(*) FROM ods_ratings验证数据能查到,正常应该返回10万行。
还要做一步很关键的清洗,转成DWD层明细表。清洗规则包括:去掉评分为0的记录、去掉timestamp字段为NULL的记录、去掉重复的(user_id, movie_id)组合只保留最新一条、把时间戳转成日期字符串方便后续按天分区。在Hive里执行SQL做清洗,生成的表按天分区:
sql复制CREATE TABLE IF NOT EXISTS dwd_user_behavior(
user_id INT,
movie_id INT,
rating DOUBLE,
rating_date STRING
)
PARTITIONED BY (dt STRING)
STORED AS ORC;
INSERT OVERWRITE TABLE dwd_user_behavior PARTITION(dt='2024-01-15')
SELECT user_id, movie_id, rating, FROM_UNIXTIME(timestamp, 'yyyy-MM-dd') AS rating_date
FROM ods_ratings
WHERE rating IS NOT NULL AND timestamp IS NOT NULL
GROUP BY user_id, movie_id, rating, FROM_UNIXTIME(timestamp, 'yyyy-MM-dd');
4.3 PySpark实现ItemCF全网最细拆解
数据准备好了,现在进入核心部分。用PySpark写ItemCF,代码结构可以拆成四段:初始化SparkSession、读取Hive表转换为RDD、计算物品相似度、生成用户推荐列表。
第一段代码,初始化时要开启Hive支持:
python复制from pyspark.sql import SparkSession
spark = SparkSession.builder \
.appName("MovieItemCF") \
.master("yarn") \
.config("spark.sql.shuffle.partitions", 200) \
.config("spark.executor.memory", "4g") \
.config("spark.executor.cores", 2) \
.config("spark.sql.warehouse.dir", "hdfs:///user/hive/warehouse") \
.enableHiveSupport() \
.getOrCreate()
这里重点说下spark.sql.shuffle.partitions这个参数。默认值是200,很多初学者不管数据量多大都不改。如果处理的数据量很小(几万条),200个分区会产生大量空任务,浪费调度开销;如果数据量很大(几亿条),200个分区又会导致单个分区数据过重,OOM风险上升。经验值是让每个分区数据量在100MB到200MB之间,如果总共1TB数据,设1000个分区左右比较合适。
第二段代码,读取评分数据并转换为(user_id, (movie_id, rating))结构:
python复制ratings_df = spark.sql("SELECT user_id, movie_id, rating FROM dwd_user_behavior")
ratings_rdd = ratings_df.rdd.map(lambda row: (row.user_id, (row.movie_id, row.rating)))
第三段代码是核心的相似度计算。先按用户分组,每个用户只取评分最高的前50部电影,然后做两两组合:
python复制# 每个用户对电影的评分列表
user_movies_rdd = ratings_rdd.groupByKey().mapValues(list)
def build_pairs(items):
# 按评分降序排序,取前50部
items_sorted = sorted(items, key=lambda x: x[1], reverse=True)[:50]
pairs = []
for i in range(len(items_sorted)):
for j in range(i + 1, len(items_sorted)):
movie_i, rating_i = items_sorted[i]
movie_j, rating_j = items_sorted[j]
# 两个方向的贡献都记录
pairs.append(((movie_i, movie_j), 1))
pairs.append(((movie_j, movie_i), 1))
return pairs
cooccur_rdd = user_movies_rdd.flatMapValues(build_pairs).map(
lambda x: (x[1][0], x[1][1])
).reduceByKey(lambda a, b: a + b)
这里有个容易错的地方:我在一个用户的pair生成里同时写了(item_i, item_j)和(item_j, item_i)两个方向,目的是后面计算某物品的相似物品时,不需要再对键做翻转,直接查表就行。这属于牺牲一倍共现矩阵存储空间换取查询便利,在数据量可控时非常划算。
第四步,计算每个物品的流行度并归一化相似度:
python复制# 统计每个电影的评分人数
item_popularity = ratings_rdd.map(lambda x: (x[1][0], 1)).reduceByKey(lambda a, b: a + b)
# 构建相似度矩阵
def compute_similarity(cooccur_entry, pop_map):
(item_i, item_j), count = cooccur_entry
pop_i = pop_map.value.get(item_i, 1)
pop_j = pop_map.value.get(item_j, 1)
similarity = count / (pop_i ** 0.5 * pop_j ** 0.5)
return (item_i, (item_j, similarity))
pop_bc = spark.sparkContext.broadcast(item_popularity.collectAsMap())
similarity_rdd = cooccur_rdd.map(lambda x: compute_similarity(x, pop_bc))
第五步,计算每个用户的Top-N推荐列表。为了效率,先把相似度矩阵按物品维度聚合成字典并广播出去:
python复制# 每个item的相似物品列表
item_sim_dict = similarity_rdd.groupByKey().mapValues(list).collectAsMap()
sim_bc = spark.sparkContext.broadcast(item_sim_dict)
def recommend(user_movies):
user_id, items = user_movies
# 用户看过的电影集合
watched_set = set([movie_id for movie_id, _ in items])
scores = {}
for movie_id, rating in items:
similar_movies = sim_bc.value.get(movie_id, [])
for sim_movie, sim_score in similar_movies:
if sim_movie in watched_set:
continue
# 累计加权评分
scores[sim_movie] = scores.get(sim_movie, 0) + rating * sim_score
top_n = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:20]
return (user_id, top_n)
recommend_rdd = user_movies_rdd.map(recommend)
最后把推荐结果保存到Hive:
python复制result_df = recommend_rdd.flatMap(
lambda x: [(x[0], movie_id, score) for movie_id, score in x[1]]
).toDF(["user_id", "movie_id", "score"])
result_df.write.mode("overwrite").saveAsTable("dws_user_recommend")
跑完这个作业后在Spark Web UI上能看到Job的DAG图,如果发现某个Stage的Shuffle数据量特别大,重点看是不是groupByKey或reduceByKey的key分布不均匀,这就是数据倾斜的隐患。应对方案后面专门讲。
4.4 推荐结果的性能优化与落地存储
算法跑通只是第一步,真正上线还要考虑两个问题:Executor端的参数调优和结果存储的读写性能。
Spark参数调优方面,经验值如下:Executor内存建议每个4GB到8GB,executor-cores设2个,避免一个Executor分配过多核心导致GC频繁。如果使用YARN部署,还要关注executor-memoryOverhead,默认是executor内存的10%,对于Python进程额外内存占用建议调大到15%。另外开启spark.serializer=org.apache.spark.serializer.KryoSerializer并注册需要序列化的类,能减少序列化开销,这个优化在数据量大时效果十分明显。
推荐结果表在Hive里存储时不要用TEXTFILE,用ORC列式存储加SNAPPY压缩,查询性能能提升3到5倍,存储空间减少一半以上:
sql复制CREATE TABLE dws_user_recommend_parquet(
user_id INT,
movie_id INT,
score DOUBLE
)
STORED AS ORC
TBLPROPERTIES ("orc.compress"="SNAPPY");
对外服务方面,把推荐结果同步到Redis是一个很常规的操作。每天凌晨算法跑完后,写一个小的同步脚本,逐用户把Top20电影列表写成Redis的List结构,key为rec:user:{userId},过期时间48小时。前端查询时直接LRANGE整个列表,毫秒级响应。同步脚本可以用Spark输出到临时目录,再走Python的redis-py批量写入,也可以直接用Spark的foreachPartition,在Executor内创建Redis连接批量提交,后者性能更好,但要注意Redis连接数不能超过服务端maxclients限制。
5. 常见问题与排查技巧实录
5.1 跑PySpark作业时的OOM与数据倾斜
我在项目调试阶段踩过不少坑,挑最典型的三个说。
第一个是Executor内存溢出。症状是日志里出现OutOfMemoryError或Container killed by YARN for exceeding memory limits。排查步骤:先看Spark UI里各个Stage的Shuffle Read和Shuffle Spill情况,如果Spill体积巨大说明分区内数据量超过了Executor可用内存;再做两件事,一是调大spark.executor.memory,二是增加分区数(调大spark.sql.shuffle.partitions或用repartition手动重分区)。大多数OOM都和数据倾斜相关,单个key的数据量远超平均水平,解法是用salting做key加盐打散。
第二个是任务一直卡在某个Stage不执行。概率最高的原因是groupByKey用在了值集合很大的场景,某个用户的行为数据就要几百MB,单任务卡死。解决办法是能不用groupByKey坚决不用,改用reduceByKey或aggregateByKey,它们能在Map端做合并,大幅减少Shuffle数据量。
第三个是最终推荐结果里热门影片霸屏。看日志发现相似度矩阵里《肖申克的救赎》这种超高人气电影和所有电影都有偏高的相似度。解决方法是加一个抑制因子,把公式里的popularity从count改成count * log(count),这样热门影片的相似度会被压得更狠,冷门优质影片才有出头机会。
5.2 环境搭建的经典报错与修复方案
java.io.IOException: Cannot create directory ... NameNode is in safe mode。这个大概率是HDFS刚启动还在安全模式,等30秒到1分钟自动退出,也可以执行hdfs dfsadmin -safemode leave强制退出。此外检查磁盘空间,NameNode节点的dfs.namenode.name.dir所在磁盘满了也会一直安全模式。
java.sql.SQLException: Access denied for user 'root'@'localhost',这个出现在Hive初始化MySQL元数据库时。Hive的元数据库默认要求MySQL建一个独立账号,不要偷懒用root,在MySQL里执行CREATE USER 'hive'@'%' IDENTIFIED BY 'hive123'; GRANT ALL PRIVILEGES ON *.* TO 'hive'@'%';,改完重启Hive即可。
Python in worker has different version 3.6 than that in driver 3.8,这个问题很隐蔽。Spark的Python worker会默认找到节点上另一个版本的Python。解决办法是在spark-submit时显式指定:
bash复制spark-submit \
--master yarn \
--deploy-mode cluster \
--conf spark.pyspark.python=/usr/bin/python3.8 \
--conf spark.pyspark.driver.python=/usr/bin/python3.8 \
itemcf_recommend.py
5.3 推荐结果质量评估的两个硬指标
推荐跑完了不能只看结果热闹,要有定量指标验证。离线阶段常用的两个指标是准确率和召回率。把用户行为数据按时间切分,前80%做训练集,后20%做测试集。对每个用户,用训练集计算推荐列表,看测试集里用户实际看过的电影有多少出现在推荐列表中:
- 准确率 = 推荐列表中用户真正看过的部数 / 推荐列表总部数
- 召回率 = 推荐列表中用户真正看过的部数 / 测试集用户看过的总部数
影视推荐项目的经验参考值是Top20推荐的准确率在10%-20%之间就算不错,因为用户看过的电影占总电影库比例极低,推荐命中本身是个高难度任务。如果准确率低于5%,优先检查特征是否太少、评分数据是否稀疏、相似度公式是否被热门物品带偏。
6. 项目扩展:从离线批处理到准实时推荐
如果项目做完了想继续深入,可以考虑把离线链路升级成准实时。其实不需要推到Flink全套,而是在现有架构上加一个轻量增量任务:每15分钟用Spark Streaming或Structured Streaming消费Kafka里的用户新行为数据,增量计算热门物品和用户近期偏好,只更新受影响的用户推荐结果。其他用户继续使用每天的全量结果,这样既控制了成本,又能让活跃用户的推荐结果在半小时内得到更新。
另一个扩展方向是引入多路召回和重排。现在只用了协同过滤一路召回,还可以加入基于内容的召回(根据影片类型、导演、演员做相似度匹配)、热门召回(全网近期最热的影片兜底)。各路召回结果合并后,用LR或简单规则做重排。多路召回的好处是保证推荐结果的多样性,避免用户困在信息茧房里。
最后我想认真说一点:这套项目做下来,最大的收获不是代码本身,而是理解了大数据系统的容错思想。你在本地调通的逻辑,搬到分布式的Spark上可能因为数据倾斜、版本兼容、资源不足出现各种奇怪问题。学会看Spark Web UI的Stage耗时图、学会在日志里定位Executor报错、学会用合理的参数调优,是比跑通一个算法更值钱的本事。如果你也正在做类似项目,可以先拿小数据量在本地模式调试算法逻辑,再切到YARN集群跑全量数据,这能节省大量调试时间。
