简介:这是一份面向计算机专业毕业设计与大数据推荐系统学习者的电影推荐系统项目文档,围绕 Spark 平台下的个性化推荐展开,重点解决海量影片中快速筛选符合用户喜好内容的问题。文档按绪论、开发技术、系统分析设计、系统实现与测试等章节组织,涵盖 Spark、MongoDB 与 Web 技术,人口统计学、基于内容及协同过滤三类推荐算法,并涉及登录注册、个性化推荐、电影搜索、用户评分等功能模块,适合用于课程设计、毕业设计选题参考与推荐算法入门梳理。资源包共 1 个文件,为 docx 文档,大小约 7.46MB,便于直接查阅章节结构与论文写作框架。目前已有 207 人学习,可帮助读者理解推荐系统的需求分析、算法设计、功能实现与测试流程,并参考其目录组织和论文表述方式。
1. 推荐位永远是那几部片:这套源码解决的正是这个问题
之前接触过一个影音内容站点的改造需求,用户反馈最集中的一条是:首页推荐位翻来覆去就那几部片,新用户和老用户看到的一模一样。这套基于 Spark 的电影推荐系统源码,正好对上这个痛点——它把协同过滤、基于内容的相似推荐、人口统计学统计推荐拆成三条互不干扰的链路,用 Spark 做大数据的离线批算,Spark Streaming 接实时评分流,MongoDB 存推荐结果,Redis 挡热点,Elasticsearch 负责搜索。适合谁:要做课程设计或毕设的学生、想把推荐模块塞进自己后端项目的开发者、以及想搞清楚 ALS 到底该怎么调参的人。论文部分把算法推导和表结构讲清楚了,源码部分是 Scala 算法工程 + Spring 后端 + 前端页面三件套,能直接跑。
2. 技术栈与架构分层:一次推荐请求从登录到出结果的完整链路
先给结论:这套系统的技术选型不是堆名词,每一层都在解决一个具体问题。Spark 负责算,因为它要在评分矩阵上反复迭代;MongoDB 负责存,因为推荐结果是结构不固定的宽文档;Redis 负责挡,因为实时推荐要在毫秒级返回;Elasticsearch 负责找,因为电影名和主演的模糊匹配用数据库 LIKE 根本扛不住。Spring + Maven 把业务层包起来,前端用响应式框架对接接口。下面按组件职责、链路分层、数据模型三块拆开讲,读完你应该能画出自己的部署图。
2.1 Spark 只管算、MongoDB 只管存:职责边界划清楚
很多同学做推荐系统时会本能地选关系型数据库,我一开始也这么想,直到把 UserRecs 的表结构画出来才发现不对劲。一个用户的推荐结果是一串长度不固定的「电影ID + 预测分」对,今天推 20 条,明天模型换了推 50 条,字段随算法输出变化。用关系库就得拆成主表加明细表,前端拿一次推荐列表要 join 两三次,改一次算法改一次表结构,血泪经验。MongoDB 的文档模型直接把整个推荐列表塞进一个文档,一次查询全拿到,这是这个项目选它的核心理由,而不是「因为它是大数据所以要用 NoSQL」这种糊弄人的说法。
Spark 的选型逻辑更直接。协同过滤里的 ALS(交替最小二乘)是典型的迭代算法,每一轮都要重复读同一份评分矩阵。MapReduce 每轮把中间结果刷到磁盘,迭代十轮就是十次磁盘往返;Spark 把中间数据留在内存里,RDD 血统还能在分区丢失时重算,这才是它在大数据推荐场景里被普遍采用的原因。项目里 Scala 写算法、Java 写后端,Scala 的函数式写法让map / filter / reduceByKey这类算子链读起来接近伪代码,这也是常见做法。
Redis 和 Elasticsearch 是两个容易被忽略但很关键的补充。Redis 存的是「用户最近 K 次评分」和「实时推荐候选集」,用 Hash 结构按 uid 分 key,设置过期时间,避免冷用户长期占内存。Elasticsearch 承担的是电影搜索和详情页相似推荐,它的倒排索引对「主演」「类型」这种多值字段天然友好,比在 MongoDB 里写正则匹配靠谱得多。四个存储各司其职,别想着用一个库全包了。
2.2 离线、实时、详情页:三条链路各自的数据流
离线链路是这套系统的主力,跑批时间一般放在凌晨低峰期。流程是:Spark 任务从 MongoDB 的 Rating 集合读全量评分,跑完统计推荐(历史热门、近期热门、平均分、各类别 Top10)和 ALS 离线召回,把结果分别写回 AverageMovies、RateMoreMovies、RateMoreRecentlyMovies、GenresTopMovies、UserRecs 五个集合。这条链路的特点是数据量大、允许延迟、结果稳定,用户第二天登录看到的「猜你喜欢」就来自这里。
实时链路走的是另一条路。用户在页面上打了一个分,这个动作会写进消息队列,Spark Streaming 按批消费,拿到这个用户最近若干条评分,去查离线算好的电影相似度矩阵,做加权打分,取 Top N 写进 Redis。这条链路不追求全量准确,追求的是「刚打完分,刷新页面推荐就变了」这种即时反馈。要注意的是,实时链路依赖离线算出的相似度矩阵,所以离线任务挂了,实时推荐会退化成热门推荐,这个降级逻辑必须在代码里显式处理,不能靠运气。
详情页链路最简单也最实用。用户点进某部电影的详情页,系统拿这部电影的类型、导演、主演标签去 Elasticsearch 做相似检索,或者直接查离线算好的 Item-Item 相似度表。它不依赖用户历史,所以新用户也能用,这正好补上了协同过滤冷启动的短板。三条链路合起来,才是一个完整可用的推荐系统,只做其中一条都撑不起「个性化」这三个字。
2.3 集合设计:六个集合的字段与写入方
数据库这块建议先看表再读代码,不然后面算法章节容易迷路。项目里 MongoDB 的库名一般叫 movie,核心集合的职责划分如下:
| 集合名 | 关键字段 | 写入方 | 读取方 |
|---|---|---|---|
| User | uid、username、password、genres | 注册接口 | 登录、人口统计学推荐 |
| Movie | mid、name、genres、directors、actors | 初始化脚本 | 搜索、详情页、内容推荐 |
| Rating | uid、mid、score、timestamp | 评分接口 | 所有推荐任务 |
| AverageMovies | mid、avg | 离线统计任务 | 详情页、兜底推荐 |
| GenresTopMovies | genre、movies | 离线统计任务 | 分类页推荐 |
| RateMoreMovies / RateMoreRecentlyMovies | mid、count | 离线统计任务 | 首页热门位 |
| UserRecs | uid、recs(数组) | ALS 离线任务 | 个性化推荐位 |
注意 Rating 集合里 timestamp 的类型要统一成数值型时间戳,别一半存字符串一半存长整型,这个坑在第 5 章会展开。Movie 的 genres 字段用竖线拼接多值(比如「动作|悬疑」),是为了后面 Spark SQL 里 split 方便,代价是查询时要多做一次解析,属于典型的空间换时间取舍。UserRecs 的 recs 是一个内嵌数组,单文档有大小上限,推荐条数控制在几百以内比较稳妥,别把全量候选都塞进去。
3. 工程结构拆解:源码目录、模块依赖与启动顺序
拿到压缩包之后第一步不是急着跑,是先看目录。这套代码分三个工程:算法工程(Scala)、后端工程(Java + Spring)、前端工程(响应式框架),三者之间靠 HTTP 接口和 MongoDB 解耦,不是那种一个大单体全塞一起的结构。理清依赖方向很重要——算法工程只依赖 MongoDB 和 Spark,不依赖后端;后端依赖 MongoDB、Redis、Elasticsearch;前端只依赖后端接口。这个依赖方向决定了你的启动顺序,也决定了出问题时该去哪个工程看日志。
3.1 源码目录:算法、后端、前端三个工程怎么分
整理后的目录结构大致如下,不同版本的源码包可能在包名上有出入,但分层逻辑是一致的:
movie-recommend/ ├── recommender/ # Scala 算法工程(Maven 管理) │ ├── src/main/scala/com/rec/ │ │ ├── offline/ # 离线统计 + ALS 训练 │ │ │ ├── StatisticsRecommender.scala │ │ │ └── AlsRecommender.scala │ │ ├── streaming/ # 实时推荐 │ │ │ └── StreamingRecommender.scala │ │ ├── model/ # 样例类:Movie / Rating / UserRecs │ │ └── util/ # Mongo 连接、配置读取 │ └── src/main/resources/application.conf ├── web/ # Spring 后端 │ ├── src/main/java/com/web/ │ │ ├── controller/ # 登录、评分、推荐、搜索接口 │ │ ├── service/ # 业务编排 │ │ └── repository/ # Mongo / ES 数据访问 │ └── pom.xml └── front/ # 前端工程 ├── src/app/ # 页面组件、路由 └── proxy.conf.json # 开发环境接口代理offline包下每个 Scala 对象都是一个可独立提交的 Spark 应用,main方法里直接SparkSession.builder()起会话,用spark-submit单独跑,互不干扰。这种「一个任务一个 main」的组织方式比全都塞进一个 Application 里好维护,出了错能单独重跑,不用整条链路重来。model包里定义样例类,配合 MongoDB Connector 做自动映射,字段名对不上就会静默读出 null,这个后面也会提到。
后端controller层按功能切分,推荐接口和搜索接口分开写,避免一个类几百行。repository层建议把 Mongo 的MongoTemplate和 ES 的RestHighLevelClient分开封装,别在 service 里直接拼查询语句,否则换存储时要改一大片。前端工程的proxy.conf.json是开发环境用的,把/api前缀代理到后端端口,生产环境一般交给 Nginx,这个文件不改的话本地联调会一直 404。
3.2 配置改哪几处:集群地址、库名、端口一次说清
配置是这个项目最容易翻车的地方,因为地址散落在三四个文件里。算法工程的application.conf一般长这样,重点是三个 URI:
# recommender/src/main/resources/application.conf mongo { uri = "mongodb://127.0.0.1:27017" db = "movie" } redis { host = "127.0.0.1" port = 6379 } es { nodes = "127.0.0.1:9200" } spark { # 伪分布式环境下建议调小,否则单机会 OOM executorMemory = "1g" master = "local[2]" }mongo.uri和mongo.db分开配是有讲究的:Connector 的连接串里带库名,读写时再单独指定 collection,两者不一致就会出现「连上了但读不到数据」。做伪分布式实验时把spark.master写成local[2]比连真集群省事得多,两个线程够跑通全流程;真要连集群再改成spark://node01:7077,注意端口和 Master 节点主机名要跟你自己的环境对上,别照抄。
后端的配置集中在application.yml,除了三个中间件的地址,还要配spring.data.mongodb.auto-index-creation=true,让 Mongo 自动建索引,否则 UserRecs 按 uid 查会全表扫。前端只需要改proxy.conf.json里的 target,指到后端端口。三处配置里的库名、集合名必须完全一致,一个字母大小写不对,表现出来就是「接口返回空数组」,没有报错,非常难查,这是我最常提醒别人的一点。
3.3 启动顺序:从集群到 8088 端口的一条命令链
正确的启动顺序是:MongoDB → Redis → Elasticsearch → Spark → 算法任务 → 后端 → 前端。前四个是基础设施,算法任务依赖它们,后端又依赖算法写进去的结果。伪分布式环境下用一台机器模拟多节点,典型命令如下:
# 1. 启动基础服务(顺序别乱,ES 启动最慢,建议先起) mongod --dbpath /data/mongo --fork --logpath /data/mongo/mongo.log redis-server /etc/redis.conf --daemonize yes /opt/elasticsearch/bin/elasticsearch -d # 2. 启动 Spark 集群(伪分布式:一个 Master + 三个 Worker) $SPARK_HOME/sbin/start-all.sh # 打开 http://node01:8080 确认 Worker 列表里三个节点都是 ALIVE # 3. 提交离线任务,先跑统计再跑 ALS(ALS 依赖统计结果做兜底) $SPARK_HOME/bin/spark-submit \ --class com.rec.offline.StatisticsRecommender \ --master spark://node01:7077 \ --executor-memory 1g \ recommender-1.0.jar $SPARK_HOME/bin/spark-submit \ --class com.rec.offline.AlsRecommender \ --master spark://node01:7077 \ --executor-memory 2g \ recommender-1.0.jar # 4. 起后端和前端 java -jar web-1.0.jar --spring.config.location=./application.yml cd front && npm install && npm start--executor-memory是这里最需要盯的参数。单机伪分布式时,一个 Worker 往往要跑多个 Executor,内存给大了直接起不来,表现是任务卡在 ACCEPTED 状态不动的「玄学」现象,其实是资源不够排队。建议先给 1g 跑通,再按机器内存往上调。ALS 任务比统计任务吃内存,因为它要在内存里缓存评分矩阵,两者分开提交、分开调参,比合并成一个任务好排查。
访问localhost:8088能看到登录页,说明前后端链路通了。这时候别急着测推荐,先去 MongoDB 里db.UserRecs.count()看看有没有数据——没有数据就说明算法任务没写成功,页面上的推荐位会是空的,而这跟前端写得好不好一点关系都没有。先验数据、再验接口、最后验页面,这个顺序能省掉大量扯皮时间。
4. 核心算法落地:统计推荐、内容相似与 ALS 协同过滤的三段代码
算法工程是这个项目的重头戏,也是论文里篇幅最大的部分。它实际上做了四件事:用 Spark SQL 算统计类推荐、用向量化方法算内容相似度、用 ALS 算协同过滤、用 Streaming 做实时加权。四块代码风格不一样,统计类是 SQL 思维,内容相似是向量思维,ALS 是调参思维,实时那块是流处理思维。下面按这四块逐个拆,每块给出可抄的骨架代码和参数含义。
4.1 统计推荐:四个 Spark SQL 任务怎么跑
统计推荐属于非个性化推荐,不需要用户历史偏好,天然没有冷启动问题,所以它在这套系统里承担的是「兜底」角色。核心是四个任务:历史热门、近期热门、平均分、各类别 Top10。前三个都比较直白,先看代码:
// 从 MongoDB 读评分表,注意 collection 名要和后端写入的一致 val ratingDF = spark.read .option("uri", mongoUri + "/" + dbName) .option("collection", "Rating") .format("com.mongodb.spark.sql.DefaultSource") .load() // 任务一:历史热门,按被评分次数倒序取前 100 val rateMoreMovies = ratingDF .groupBy("mid") .agg(count("uid").as("count")) .orderBy(desc("count")) .limit(100) // 任务三:平均分,只统计有评分的电影 val averageMovies = ratingDF .groupBy("mid") .agg(avg("score").as("avg"))这里format的写法跟 Connector 版本强相关:老版本用com.mongodb.spark.sql.DefaultSource,新版本可以直接写mongodb并用spark.mongodb.read.connection.uri传连接串。抄代码前先确认依赖版本,否则会报找不到数据源,这个坑在第 5 章细说。groupBy("mid")之后一定要agg,直接count会把分组键丢掉,这是 Scala 里很常见的手误。
近期热门需要按时间过滤,项目里的做法是用 UDF 把时间戳格式化成月份字符串再筛:
// 任务二:近期热门,只统计最近一个月的评分记录 val getMonth = udf((ts: Long) => { new SimpleDateFormat("yyyyMM").format(new Date(ts)) }) val currentMonth = new SimpleDateFormat("yyyyMM").format(new Date()) val rateMoreRecentlyMovies = ratingDF .withColumn("month", getMonth(col("timestamp"))) .filter(col("month") === currentMonth) .groupBy("mid") .agg(count("uid").as("count")) .orderBy(desc("count")) .limit(100)UDF 的入参类型必须和集合里存的一致,存的是Long就写Long,写成String会直接抛类型转换异常。如果数据源的时间字段是字符串,先用to_timestamp转一遍再传,别在 UDF 里做兼容判断,那会让整个任务变慢。
类别 Top10 是四个任务里最绕的一个,因为电影的genres是竖线拼接的多值字段,得先炸开再分组:
// 任务四:各类别评分最高的 10 部电影 val movieDF = spark.read .option("uri", mongoUri + "/" + dbName) .option("collection", "Movie") .format("com.mongodb.spark.sql.DefaultSource") .load() // 用 explode 把 "动作|悬疑" 拆成两行,一行一个类别 val genreUdf = udf((genres: String) => genres.split("\\|").toSeq) val exploded = movieDF .withColumn("genre", explode(genreUdf(col("genres")))) .select("mid", "genre") // 关联平均分后按类别开窗,取每类前 10 val window = Window.partitionBy("genre").orderBy(desc("avg")) val genresTopMovies = exploded .join(averageMovies, Seq("mid")) .withColumn("rank", row_number().over(window)) .filter(col("rank") <= 10) .groupBy("genre") .agg(collect_list(struct("mid", "avg")).as("movies"))split("\\|")里的反斜杠是正则转义,少写一个就变成「或」的意思,切出来是空数组,表现是类别表全空,这个错误极其隐蔽。row_number()保证同一类别里没有并列排名导致的条数溢出,比rank()更稳。最后collect_list聚成数组写进 Mongo,正好对应GenresTopMovies的文档结构。
4.2 基于内容的相似度:标签向量与余弦距离
详情页推荐和实时推荐的底稿都来自这个词向量表。思路很朴素:把每部电影的类型、导演、主演拼成一段文本,做分词和哈希,算出 TF-IDF 向量,再两两算余弦相似度。Spark MLlib 里有现成的HashingTF和IDF,不用自己实现:
// 把多值字段拼成一段可分词文本,注意分隔符别用中文逗号 val movieFeatures = movieDF .withColumn("featureText", concat_ws(" ", col("genres"), col("directors"), col("actors"))) .select("mid", "featureText") // 分词 -> 哈希到固定维度 -> 过 IDF 降权高频词 val tokenizer = new Tokenizer().setInputCol("featureText").setOutputCol("words") val hashingTF = new HashingTF() .setInputCol("words") .setOutputCol("rawFeatures") .setNumFeatures(2000) // 维度别太大,两两算相似度是 O(n^2) val idf = new IDF().setInputCol("rawFeatures").setOutputCol("features") val pipeline = new Pipeline().setStages(Array(tokenizer, hashingTF, idf)) val vectorDF = pipeline.fit(movieFeatures).transform(movieFeatures) // 归一化后点积即余弦相似度,比手写循环快一个量级 val normalized = vectorDF .withColumn("norm", normalize(col("features"))) .select("mid", "norm")setNumFeatures(2000)是这里最需要权衡的参数。调大能保留更多特征,但相似度矩阵是 n×n 的,电影几万部时内存直接爆;调小会丢信息,类型相近的片子算出来区分度不够。我的经验是先按 2000 跑通,看推荐结果的相关性再往上调,别一上来就 10000。normalize把向量模长归一化之后,两个向量的点积就等于余弦相似度,这样可以用矩阵乘法批量算,不必写双层循环。
算出来的相似度结果按mid -> List[(相似mid, 分数)]的结构落库,实时链路每次要用的时候直接查,不用现算。这里建议只保留 Top 50 相似项,全量存会让文档过大,读写都慢。详情页展示时取前 10 条就够,反正用户也不会往下翻。
4.3 ALS 协同过滤:训练、预测、冷启动兜底
协同过滤是这套系统个性化能力的核心。Spark MLlib 的ALS把用户-物品评分矩阵分解成两个低秩矩阵,用交替最小二乘求解,代码不长但参数每一个都有讲究:
// 评分表转成 ALS 需要的 (user, item, rating) 三元组 val ratings = ratingDF .select(col("uid").cast("int"), col("mid").cast("int"), col("score").cast("float")) val Array(train, test) = ratings.randomSplit(Array(0.8, 0.2), seed = 42L) val als = new ALS() .setUserCol("uid") .setItemCol("mid") .setRatingCol("score") .setRank(50) // 隐因子维度 .setMaxIter(10) // 迭代轮数 .setRegParam(0.01) // 正则化,防过拟合 .setColdStartStrategy("drop") // 预测遇到未知用户/物品直接丢弃 .setImplicitPrefs(false) // 显式评分,不是隐式反馈 val model = als.fit(train) val predictions = model.transform(test)参数怎么定,给一张对照表更直观:
| 参数 | 常用取值 | 调大的后果 | 调小的后果 |
|---|---|---|---|
| rank | 10 ~ 200,默认 50 | 表达能力强但易过拟合、内存涨 | 欠拟合,推荐趋同 |
| maxIter | 10 ~ 20 | 训练时间线性增长 | 未收敛,误差大 |
| regParam | 0.01 ~ 0.1 | 泛化好但预测偏保守 | 过拟合,测试集 RMSE 反弹 |
| coldStartStrategy | 固定用 drop | — | 用默认值会产出 NaN |
coldStartStrategy是最容易漏的一个。不设置的话,预测阶段遇到训练集里没出现过的用户或电影会返回 NaN,这些 NaN 一旦写进 UserRecs,前端排序直接乱掉,而且日志里不一定报错,属于典型的黑匣子问题。设成drop之后,这些用户就由统计推荐兜底,逻辑上更干净。
训练完的模型要做两件事:一是给每个用户生成 Top N 推荐写进 UserRecs,二是把 Item 因子矩阵导出,供实时链路算相似度。给用户生成推荐时记得过滤掉他已经评过分的电影,否则推荐位会推「你已经看过的」,用户第一眼就失去信任。这个过滤在代码里就是一次 left_anti join,不难写但千万别忘。
4.4 实时链路:Streaming 里那 30 行加权打分
实时推荐的目标不是算得准,是算得快。用户刚打完分,刷新页面推荐就得变,所以这里不能用 ALS 全量重训,只能用离线算好的相似度做增量加权:
// 每批评分流:取出用户最近 K 次评分,找相似电影加权打分 stream.foreachRDD { rdd => rdd.foreachPartition { partition => val redis = JedisUtil.getConnection partition.foreach { rating => val uid = rating.uid // 该用户最近的评分记录,按时间倒序 val recent = redis.lrange(s"recent:$uid", 0, 9) val scores = mutable.Map[Int, Double]().withDefaultValue(0.0) recent.foreach { midStr => val mid = midStr.toInt // 查离线相似度表,取 Top 20 相似电影 val similar = SimilarityTable.get(mid).take(20) similar.foreach { case (simMid, simScore) => // 相似度 × 用户对该片评分的偏离度,偏离越大权重越高 val weight = simScore * (rating.score - 3.0) scores(simMid) += weight } } // 去掉已评分项,取 Top 10 写回 Redis val result = scores.toSeq .filterNot { case (mid, _) => redis.sismember(s"rated:$uid", mid.toString) } .sortBy(-_._2) .take(10) redis.set(s"rec:$uid", result.map(_._1).mkString(",")) } redis.close() } }(rating.score - 3.0)这个减 3 是「中心化」处理:评分 1~5,3 分是中位,高于 3 是喜欢,低于 3 是讨厌,减完就变成带符号的偏好强度,负分代表「别推类似的」。不减的话,一部被打了 1 分的片子也会正向贡献权重,推荐结果会朝着烂片一路狂奔,这是实时推荐最常见的效果翻车原因。
recent:$uid这个 List 要在评分接口里同步维护,lpush之后ltrim保留最近 10 条,不 trim 的话内存会无上限增长。rated:$uid是一个 Set,存用户评过分的电影,用来做去重过滤。两个 key 都要设过期时间,一般 7 天足够,太长了占内存,太短了实时推荐会退化成热门。
5. 避坑与排查:五条我踩过的坑,每条都带现象和药方
这套代码跑通不难,跑稳很难。下面五条是我在实际复现过程中真实遇到并记下来的,按「现象 → 原因 → 解决」写,遇到类似表现可以直接对号入座。
第一坑:Spark 任务一提交就报 NoSuchMethodError 或 ClassNotFoundException
现象是spark-submit后立刻退出,堆栈里出现com.mongodb.spark相关的类找不到,或者方法签名不匹配。原因是 MongoDB Spark Connector 的版本和集群上的 Spark 版本没有对齐,Connector 的次版本号对 Spark 版本比较敏感,跨一个大版本基本必炸。解决办法不是硬改代码,而是用--packages让 Spark 自己拉对应的 Connector,例如--packages org.mongodb.spark:mongo-spark-connector_2.11:2.x.x,后缀的_2.11是 Scala 版本,必须跟你编译时用的一致。拉下来之后如果还是不行,去$SPARK_HOME/jars看是不是残留了旧版 jar,有就清掉。
第二坑:推荐列表里出现 NaN,前端排序全乱
现象是 UserRecs 里的预测分有NaN,按分数倒序排出来是一片混乱。原因是 ALS 预测阶段遇到了训练集里没出现过的用户或电影,默认返回 NaN。解决是给 ALS 显式设置setColdStartStrategy("drop"),让这些预测被丢弃;同时在写库前加一道过滤filter(!isNaN(col("prediction")))做双保险。另一个容易忽略的点是 uid 或 mid 的类型,训练时是int,预测时传string,Spark 会当成全新的 id,同样产出 NaN。
第三坑:中文电影名搜不到,英文名却能搜到
现象是 Elasticsearch 里明明有数据,搜索「某部中文片名」返回空,搜英文或拼音却正常。原因是默认分词器对中文基本是按单字切,索引时和查询时的切分方式不一致,导致匹配不上。解决有两个方向:装中文分词插件后把索引的analyzer改成中文分词;或者不动分词器,给name字段加一个keyword子字段,查询时用match_phrase配合keyword做精确匹配。前者效果好但要改索引重建,后者改动小、适合快速验证,我一般先用后者把功能跑通。
第四坑:Spark 集群 Web UI 上能看到 Worker,但任务卡在 ACCEPTED 不动
现象是提交任务后一直排队,日志没有报错,Web UI 上 Executor 一个都不分配。原因基本都是资源不够:单机伪分布式下三个 Worker 共用一个物理机的内存,--executor-memory给到 4g、5g 时,Worker 报上去的可用内存不够分配,任务就一直等。解决是把--executor-memory降到 1g 先跑通,把spark.executor.cores调成 1,再逐步往上加。另一个隐蔽原因是 Worker 注册的地址是内网 IP,Driver 却用localhost连,网络不通也会卡在这一步,改spark.driver.host或统一用同一网段地址即可。
第五坑:实时推荐结果永远是空,离线结果却正常
现象是页面上的「为你推荐」有数据(走的是 UserRecs),但「实时推荐」区块一直空着。原因通常是 Redis 里的recent:$uid这个 key 没写进去,或者写了但过期时间设得太短,Streaming 消费时已经失效。排查顺序是:先redis-cli keys 'recent:*'看 key 是否存在,再看ttl是不是负数;存在的话检查评分接口是否真的调用了lpush,很多项目的评分接口只写了 Mongo 没写 Redis,这是个典型的遗漏。解决是把 Redis 写入和 Mongo 写入放在同一个事务性的代码块里,或者至少在评分成功后同步调用,别指望后续任务补。
6. 二次开发:把算法换掉、把指标量出来
这套代码最值得动手改的地方是 ALS 那一层,因为它是最容易做出差异化的模块。我的改造顺序一般是三步:先加一个离线评估脚本,再替换算法,最后接线上验证。第一步别跳过,没有指标你根本不知道换完是变好还是变坏。评估用 RMSE 加覆盖率两个指标就够,前者看准不准,后者看推荐是不是又收敛到那几部热门片:
// 离线评估:RMSE + 推荐覆盖率,跑完再决定要不要上线 val predictions = model.transform(test) .filter(!isNaN(col("prediction"))) val rmse = new RegressionEvaluator() .setMetricName("rmse") .setLabelCol("score") .setPredictionCol("prediction") .evaluate(predictions) // 覆盖率 = 被推荐过的不同电影数 / 电影总数,太低说明推荐趋同 val recDF = model.recommendForAllUsers(20) val covered = recDF .select(explode(col("recommendations")).as("r")) .select("r.mid") .distinct() .count() val total = movieDF.count() println(s"RMSE=$rmse coverage=${covered.toDouble / total}")recommendForAllUsers(20)是 MLlib 现成的批量推荐接口,比循环调predict快很多,离线跑全量用户推荐时一定要用它。RMSE 通常在 0.8 到 1.2 之间波动,具体看数据集,超过 1.5 基本说明参数没调好或者数据太稀疏。覆盖率低于 10% 就要警惕,那意味着首页推荐位又被少数电影垄断了,跟改造前的痛点一模一样。
替换算法时我建议保留原 ALS 做 A/B,别直接删。可以按 uid 哈希取模分流,一部分用户走 ALS,一部分走 ItemCF 或基于内容的召回,两边的点击和评分分别埋点,跑一周看差异。这套系统的表结构本来就支持——UserRecs 里加一个source字段标记来源就行,前端不用改。
还有一个具体技巧:把离线任务改成幂等。现在这些任务是先算再覆盖写,如果中途挂了会留下半份数据。做法是每次写库时带上batchId,写完后用一个标记文档记录「这一批已完整写入」,读取方只认最新且完整的批次。这么改完之后重跑任务不再需要手工清库,省掉很多半夜爬起来处理的事故。
从那以后我每次动推荐算法的参数,都强制自己先跑一遍离线指标、再拿小流量验证、最后才全量,一次都没再出现过「上线之后推荐全变成热门片」这种尴尬。希望帮到你。
本文还有配套的精品资源,点击获取