资讯动态

用Spark Scala分析TMDB电影数据集:Schema设计、嵌套JSON解析与性能调优

发布时间:2026/10/13 8:29:14 来源:尧图企业网站定制
最近我接到一个活儿用 Spark 在 Scala 环境下对 TMDB 电影数据集做一轮完整分析。说实话刚拿到手的时候我还有点懵——数据量不大几千万行以内的开放数据集平时用 Pandas 几十行代码就能跑出结果为什么要上 Spark但真做完之后我发现这个“小题大做”的选择其实非常值得。TMDBThe Movie Database的公开数据集包含电影基本信息、演职员表、预算票房、评分、类型、制作公司等一堆维度。单独看任何一个字段都不复杂难的是把“时间趋势”“类型表现”“阵容影响”“票房回报”这些因素串起来分析还要保证代码在数据量翻十倍后仍然能跑。这一轮我用 Spark SQL 和 DataFrame 操作完整走了一遍踩了不少坑也沉淀了一些可复用的写法。这篇文章就是一次没有过滤的实操记录从环境准备到性能调优从数据清洗到常见问题排查全部摊开写清楚。如果你正准备拿 TMDB 练手或者工作中要用 Spark Scala 处理类似的半结构化数据这篇内容可以直接当参考。不说教科书式的大道理只讲我试过之后觉得最稳的路径。1. 项目定位TMDB 电影数据到底能分析什么1.1 TMDB 数据源长什么样TMDB 的公开数据集通常由两个核心文件组成一个是电影基本信息表一个是演职员信息表。第一次打开这两个文件你可能会有点无所适从因为字段长得太“不规则”了。电影信息表里典型的字段包括id、title、budget、revenue、release_date、runtime、vote_average、vote_count、popularity、genres、production_companies、production_countries、spoken_languages等等。注意genres、production_companies这些字段不是普通的逗号分隔字符串而是 JSON 格式的数组。比如genres可能是[{id: 28, name: Action}, {id: 12, name: Adventure}]。这就需要我们在 Spark 里用from_json解析再用explode展开成多行。演职员信息表同样不简单每一行对应一部电影里面cast和crew都是很长的 JSON 数组。cast数组中的每个元素包含演员名字、角色名、演员 ID、性别等crew数组中每个元素包含职位job、人员名字、部门如 Directing、Writing 等。要把这些嵌套结构拆成可分析的表就必须设计好 Schema。我用一张表把最常用的字段整理出来方便你后面对照文件字段类型备注moviesidInteger电影唯一标识moviestitleString片名moviesrelease_dateString上映日期不规则moviesbudget / revenueLong预算 / 全球票房moviesruntimeInteger片长分钟moviesvote_average / vote_countDouble / Integer均分 / 投票数moviesgenresStringJSON 数组moviesproduction_companiesStringJSON 数组creditsmovie_idInteger对应 movies.idcreditscastStringJSON 数组演员卡司creditscrewStringJSON 数组含导演信息如果你的数据集还带了keywords、overview、tagline这些文本字段那还能做关键词分析和文本挖掘。但对大多数入门分析来说上面这些字段已经足够覆盖八成需求了。1.2 为什么是 Spark Scala 而不是其他方案有人会问就这几千上万条数据直接用 Pandas 不香吗香真的香。Pandas 做单机探索性分析非常高效交互式敲代码很顺手。但如果目标是“完整走一遍 Spark 大数据分析流程”那就必须用 Spark 自己的生态来跑。我用 Scala 而不是 Python有三个原因。第一Spark 的原生语言就是 ScalaDataFrame API 在 Scala 下的类型约束和能力边界更清晰很多 PySpark 里“看起来能跑、跑起来慢”的操作在 Scala 里因为编译期检查反而能提前暴露问题。第二生产环境里很多 Spark 任务就是用 Scala 写的尤其是和 Java 技术栈混合的团队直接维护 Scala 代码更顺。第三我想体验一下spark-shell里纯 Scala 的写法这对自己理解 Spark 的执行模型有帮助。真正跑起来之后我还发现 Spark 的一个隐性好處它对内存的管控比 Pandas 更“工业化”。Pandas 处理大数据时经常一次性把数据全部载入内存稍不小心就 OOMSpark 的 DataFrame 操作虽然有各种内存调优的坑但只要分区和缓存策略合理几千行和几千万行用的是同一套逻辑根本不需要重写。当然单纯从 TMDB 数据集本身出发你可能觉得 Spark 有点重。我建议你把这次练习当作“大数据管线的最小可用样例”因为以后换一个真正几十亿行的业务表操作模式和这个几乎一样。2. 环境准备、数据导入与 Schema 设计2.1 本地开发与集群部署怎么取舍这个项目我建议先在本地 Spark 环境跑通再考虑集群。原因很简单TMDB 数据量小本地模式足够发现绝大多数逻辑问题如果直接上集群调试成本会成倍增加。本地环境我用的是 Spark 3.x Scala 2.12IDE 里直接跑local[*]。这里有个小技巧local[*]默认使用机器所有 CPU 核心如果笔记本只有 4 核 8 线程并行度设成 4 就足够不要盲目跟风把spark.default.parallelism调很大。数据量小的时候并行度太高反而增加调度开销。如果你之后要提交到集群我的经验是先本地把代码写成完整的SparkSession应用再打包成 JAR 提交。不要用spark-shell一行行粘贴到生产环境那样既不好维护也很难做参数化。在代码里创建 SparkSession 时我习惯提前配置几个关键项val spark SparkSession.builder() .appName(TMDBMovieAnalysis) .master(local[4]) .config(spark.sql.shuffle.partitions, 8) .config(spark.sql.adaptive.enabled, true) .config(spark.driver.memory, 4g) .getOrCreate()shuffle.partitions这里设置成 8是因为本地开发数据量不大200 个默认分区纯属浪费。如果换到集群我会先保留默认 200再根据 shuffle 数据量动态观察。2.2 读取原始 CSV 的四个关键点TMDB 数据集动辄几百 MB直接spark.read.csv也能读但有四个细节一定要处理否则后面全是泪。第一必须指定headertrue否则第一行会被当成数据。第二最好用自定义 Schema 而不是inferSchema。虽然inferSchema很智能但在大文件上会额外扫描一遍数据而且对 JSON 字符串字段它只会推断成 StringType不会帮你做嵌套解析。第三注意文件编码和引号。CSV 里overview字段很长包含大量逗号、双引号甚至换行Spark 内置 CSV 解析器能处理但默认的escape和quote设置要对。我遇到过因为多语言字符产生编码问题指定charset是关键。第四路径上如果文件放在 HDFS 或 OSS要确保路径格式正确本地跑就老老实实写file://前缀。读取代码我这么写import org.apache.spark.sql.types._ val movieSchema StructType(Array( StructField(id, IntegerType, true), StructField(title, StringType, true), StructField(budget, LongType, true), StructField(revenue, LongType, true), StructField(release_date, StringType, true), StructField(runtime, IntegerType, true), StructField(vote_average, DoubleType, true), StructField(vote_count, IntegerType, true), StructField(popularity, DoubleType, true), StructField(genres, StringType, true), StructField(production_companies, StringType, true) )) val movies spark.read .option(header, true) .option(charset, UTF-8) .schema(movieSchema) .csv(data/tmdb_5000_movies.csv)这样读进来后genres仍然是原始字符串。我故意不在这个阶段解析因为后面的分析场景不同解析方式可能不同提前固化 Schema 反而不好扩展。2.3 Schema 设计宁可拆细不要事后返工做 Spark 项目最容易踩的坑就是拿到 CSV 直接inferSchema然后开跑等做到复杂分析时才发现字段类型不对、JSON 解析失败又回头改类型。所以我强烈建议先花 10 分钟把字段类型设计好再写任何聚合逻辑。设计 Schema 时有个原则原始文件里的字段先按最接近原生的类型读进来比如budget、revenue用 Longvote_average用 Double所有 ID 字段统一用 Integer 或 Long避免 join 时类型不一致。日期字段先用 String分析时再统一转成 Date 或提取年份。JSON 数组字段都先用 String 占位后续用from_json解析成结构体。另外可以在读取时设置mode和columnNameOfCorruptRecord.option(mode, PERMISSIVE) .option(columnNameOfCorruptRecord, _corrupt_record)这样遇到脏数据不会被直接打断而是把坏行标记出来方便后续检查。小数据集上这个功能不明显但到了生产环境你会感谢这个设置。3. 核心分析从评分、类型到票房的完整链路3.1 基础聚合评分分布与年份趋势拿到干净数据后我习惯先做一轮“体检式”聚合确认数据没有明显异常也给后续分析定个基调。先看评分和票房的整体分布。直接看统计值比肉眼扫描几万行靠谱得多movies.select(budget, revenue, vote_average, vote_count, runtime) .summary(count, min, max, mean, stddev) .show()这里有一个很容易忽略的点budget和revenue经常出现 0 值TMDB 里很多电影没有录入预算或票房。如果你直接用mean算平均会把一堆没有数据的电影当成“零票房”电影结果完全失真。所以我习惯在统计前加一个过滤条件只看budget 0和revenue 0的记录或者单独统计缺失比例movies.select( sum(when($budget 0, 1).otherwise(0)).alias(has_budget), sum(when($budget 0, 1).otherwise(0)).alias(no_budget), sum(when($revenue 0, 1).otherwise(0)).alias(has_revenue) ).show()然后再看上映年份趋势。release_date格式不统一需要先用to_date解析再取年份val moviesWithYear movies .withColumn(release_dt, to_date($release_date, yyyy-MM-dd)) .withColumn(year, year($release_dt)) moviesWithYear .groupBy(year) .agg( count(*).alias(movie_count), avg(vote_average).alias(avg_rating), avg(revenue).alias(avg_revenue) ) .orderBy(year) .show(30)这个输出能看出每年电影产量的变化、评分中位数趋势以及票房整体涨落。后面做窗口函数对比时也会基于这个“年份均值”来做。3.2 解析嵌套数组字段这是本次项目最关键的一步。genres字段长这样[{id: 28, name: Action}, {id: 12, name: Adventure}]在 Spark 中解析它要分两步。第一步定义这个数组结构体的 Schema第二步用from_json把字符串变成结构体数组再用explode炸开成多行。import org.apache.spark.sql.functions._ val genresSchema ArrayType(StructType(Array( StructField(id, IntegerType, true), StructField(name, StringType, true) ))) val moviesWithGenres movies .withColumn(genre_struct, from_json($genres, genresSchema)) .withColumn(genre, explode($genre_struct.name)) .drop(genre_struct, genres)很多新手会在这里翻车当genres为空数组时explode会直接把这行数据丢掉。如果你希望保留这些电影用explode_outer替代explode空数组会展开成 NULL 行。同样production_companies也可以用相同方式解析。解析完成之后就可以做“类型×平均票房”“类型×平均评分”这类分析了moviesWithGenres .groupBy(genre) .agg( count(*).alias(movie_count), avg(vote_average).alias(avg_rating), avg(revenue).alias(avg_revenue) ) .orderBy(desc(avg_revenue)) .show()这里有个细节一部电影可能同时属于多个类型explode之后同一部电影会出现在多行中。做“类型平均票房”时这其实是正确语义——因为票房在多个类型下都会参与计算。但如果你做“电影总数统计”就不能基于炸开后的数据否则会重复计数。要时刻清楚自己的分析粒度。3.3 关联分析导演、演员与票房关系TMDB 的credits文件天然适合和movies做 join。我们最想回答的问题是哪些演员/导演的平均票房更高他们真的能稳定带来口碑吗解析cast和crew的思路和genres一样。crew里面job为 Director 的成员就是导演。我先定义一个通用的解析函数val castSchema ArrayType(StructType(Array( StructField(id, IntegerType, true), StructField(name, StringType, true), StructField(gender, IntegerType, true), StructField(character, StringType, true) ))) val crewSchema ArrayType(StructType(Array( StructField(id, IntegerType, true), StructField(name, StringType, true), StructField(job, StringType, true), StructField(department, StringType, true) )))然后分别从 credits 中提取演员和导演val credits spark.read .option(header, true) .schema(creditSchema) .csv(data/tmdb_5000_credits.csv) val castDF credits .select( $movie_id, explode(from_json($cast, castSchema)).as(person) ) .select($movie_id, $person.name.as(actor)) val directorDF credits .select( $movie_id, explode(from_json($crew, crewSchema)).as(person) ) .filter($person.job Director) .select($movie_id, $person.name.as(director))有了这两个 DataFrame再做 join 聚合。这里我非常推荐给小表加一个broadcast提示因为credits炸开演员后也就几万行广播出去可以避免不必要的 shuffleimport org.apache.spark.sql.functions.broadcast val actorStats castDF .join(broadcast(movies), $movie_id $id) .groupBy(actor) .agg( count(*).alias(movie_count), avg(vote_average).alias(avg_rating), avg(revenue).alias(avg_revenue) ) .filter($movie_count 5) .orderBy(desc(avg_revenue)) .show(20)movie_count 5这个过滤条件很重要。如果不限制某些只拍了一部冷门电影的新人演员会因为单一样本冲到榜首结论没有参考价值。我通常会分别看“参演数量超过 3 部、5 部、10 部”三档再结合平均值下结论。导演的分析同理。如果想进一步分析“导演主演”的组合效应可以把导演表和演员表混在一起做一个组合 key但这会显著增加计算量。小数据集上可以跑生产环境就要谨慎了。3.4 窗口函数对比同期影片表现普通分组聚合只能给出大盘均值很多时候我们想知道的是某部电影在它所在的类型/年份里处于什么位置这种“组内排名、组内对比”的需求用窗口函数最合适。比如要求每个类型中评分最高的 10 部电影且只算投票数超过一定门槛的影片import org.apache.spark.sql.expressions.Window val genreRatingWindow Window .partitionBy(genre) .orderBy($vote_average.desc, $vote_count.desc) moviesWithGenres .filter($vote_count 500) .withColumn(rank_in_genre, row_number().over(genreRatingWindow)) .filter($rank_in_genre 10) .select(genre, title, vote_average, vote_count, rank_in_genre) .orderBy(genre, rank_in_genre) .show(50)另一种很实用的窗口用法是“当年均值对比”。把每部电影的评分和它所属年份的平均评分做差就能看出它是否跑赢了大盘val yearAvgWindow Window.partitionBy(year) moviesWithYear .withColumn(year_avg_rating, avg(vote_average).over(yearAvgWindow)) .withColumn(rating_diff, $vote_average - $year_avg_rating) .select(year, title, vote_average, year_avg_rating, rating_diff) .orderBy(desc(rating_diff)) .show(20)窗口函数在 Spark 里属于“重度操作”它会触发全量 shuffle数据量大时要注意分区和内存。不过在 TMDB 这个规模上几乎不会有性能问题。4. 性能优化与调优实录4.1 执行内存溢出排查我在第一次跑 join 的时候遇到过java.lang.OutOfMemoryError不是数据量大而是被自己的错误操作坑了。原因有两个一个是在本地模式下没有控制spark.sql.shuffle.partitions默认 200 个分区每个分区都往 driver 汇报造成内存飙升另一个是连续做了多个explode和from_json中间结果没有及时缓存或 checkpoint导致重复计算和对象膨胀。排查思路并不复杂。我先看 Spark UI 中的 Stages 页面确认哪个 Stage 消耗内存最大。通常你会发现瓶颈在 join 前的 Shuffle Write 阶段。解决办法有几个本地开发时把spark.sql.shuffle.partitions调成 8 或者更小。如果数据会被多个动作复用先cache()或者persist(StorageLevel.MEMORY_AND_DISK)。不要在一个 DataFrame 上反复调用show()每次都会重新触发整个 lineage尤其前面有from_json和explode时。一个典型的反模式是val parsed movies.withColumn(genre, explode(from_json(...))) parsed.groupBy(genre).count().show() parsed.groupBy(genre).mean(revenue).show()如果movies是从 CSV 读取的且没有 cache第二次show()会重新做一遍 JSON 解析和 explode。对大数据集这是灾难。正确做法是val parsed movies .withColumn(genre, explode(from_json(...))) .cache() parsed.count() // 触发计算并缓存 parsed.groupBy(genre).count().show() parsed.groupBy(genre).mean(revenue).show()注意cache()是懒执行的必须有一个 Action 比如count()才能真正把数据放进缓存。4.2 数据倾斜处理TMDB 数据量不大但也会出现“倾斜”的苗头。比如某些导演名下电影特别多某些类型如 Drama 占比极高。在 join 或 groupBy 时某个 key 的行数远远大于其他 key会导致单个 Task 处理时间特别长Spark UI 里表现为一个 Stage 大部分 Task 都完成了但有一两个 Task 还在跑。最直接的规避方式有两类。对于 groupBy 倾斜如果只是算比例最高的类型可以先观察倾斜 key 的占比再做“分桶或者过滤”处理。对于 join 倾斜小表就加broadcast这是最简单有效的办法。真正用 salting 方案也能处理// 针对导演名做拼接随机前缀的示例思路 val saltedDirector directorDF.withColumn(salt, (rand() * 10).cast(int)) val saltedMovies movies.crossJoin(spark.range(10)) .withColumn(salt, (rand() * 10).cast(int)) // 然后以 director 名称 salt 作为 join key但我要提醒一句TMDB 这个量级根本不需要用 salting加了反而增加复杂度。如果以后处理真正的大表遇到倾斜再考虑这个方案。4.3 合理设置分区与缓存策略Spark 性能调优很多时候不是“加内存”而是“减并行浪费”。本地跑 TMDB我最终确定的分区策略是初始读取用默认分区做explode之后按genre或year做repartition这能让后续 groupBy 的 shuffle 变小。val repartitioned moviesWithGenres.repartition(8, $genre)不过要注意repartition会引起一次全量 shuffle如果后面只做一次聚合可能得不偿失。我会先看explain()的执行计划再决定要不要重分区。缓存策略上可以缓存的核心 DataFrame 只有两个moviesWithYear和castDF。前者被多个分析复用后者要 join 好几次。缓存级别我一般用MEMORY_AND_DISK这样即使内存不够也不会丢数据。5. 常见问题排查与避坑指南5.1 日期和时间字段解析问题TMDB 的release_date并不都是干净的yyyy-MM-dd。有的没有日有的只有年份还有极少数是空字符串。直接用to_date(col, yyyy-MM-dd)会返回 NULL 并把整行过滤掉。我的处理办法是先探测日期格式分布movies.select(release_date) .where(release_date is not null) .groupBy(release_date).count() .orderBy(desc(count)) .show(100)这样能看到主要的日期格式。如果混杂多种格式可以用when多分支解析val releaseDateParsed when( $release_date.rlike(^\\d{4}-\\d{2}-\\d{2}$), to_date($release_date, yyyy-MM-dd) ).when( $release_date.rlike(^\\d{4}$), to_date(concat($release_date, lit(-01-01)), yyyy-MM-dd) ).otherwise(lit(null)) movies.withColumn(release_dt, releaseDateParsed)还有一种办法是直接提取年份不用完整日期import org.apache.spark.sql.functions.regexp_extract movies.withColumn(year, regexp_extract($release_date, ^(\\d{4}), 1))这样即使格式再乱只要能提取前四位就不会丢数据。5.2 字段嵌套结构解析失败from_json解析失败是高频问题。最常见的是 schema 定义不匹配比如genres实际是null但你的 schema 声明了ArrayType(StructType(...))或者 JSON 里字段类型和你定义的不一致。遇到这种问题我有一个快速诊断办法先解析一行看看结果。val schema schema_of_json(movies.select(genres).as[String].first()) println(schema)这会输出该样本推断出的 Spark 数据类型。你可以直接拿这个 schema 去定义from_json。不过注意schema_of_json只基于一条样本可能漏掉其他行里的额外字段。更稳妥的做法还是看 TMDB 官方文档然后手写 schema。另外explode空数组会丢行的坑前面提过了这里再强调一次如果需要保留电影本身使用explode_outer。而如果数组元素里某个字段为 NULL你可能需要用When或Coalesce补默认值避免后续 join 把大量 NULL 分到一个组里。5.3 数据质量与重复行问题TMDB 数据集中存在少量重复 id 或者同一电影多版本记录。join 之前我习惯先做去重val dedupMovies movies.dropDuplicates(id)然后检查主键是否唯一dedupMovies.groupBy(id).count().filter($count 1).show()如果结果为空说明 id 唯一后续 join 可以放心。另一个容易被忽视的问题movies的id是 Integercredits的movie_id可能也是 Integer但如果你之前的 schema 定义有误两者类型不一致join 会报错或者隐式转换。建议在所有 join 条件前打印两侧 schema并明确把键转成同一类型。我把我这次遇到的主要问题整理成一份速查表方便你排查现象可能原因建议处理读入后字段全是 nullheader 或 delimiter 配置不对指定 headertrue、逗号分隔from_json 解析出空结构JSON 字符串带转义符或 schema 不匹配用 schema_of_json 辅助确认explode 后电影行数变少空数组默认被丢弃改用 explode_outerjoin 后行数暴增join key 不唯一或数据重复预先 distinct 或 dropDuplicates本地跑任务极慢shuffle partitions 过大调小为 8 或按核数设置显示 OOM 但数据量很小重复计算 lineage 过长cache 中间结果、减少 show 次数6. 分析结果沉淀与后续扩展6.1 关键结论示例整个分析跑完后我得到了一些有意思的结论。虽然这些结论依赖具体数据版本但趋势上很值得参考剧情片、喜剧片、动作片是数量最多的三类但平均票房和平均评分并不占优。冒险、科幻、动画类型的平均票房相对更高说明这些类型更容易出“大片”。预算与票房存在正相关但“票房回报率”最高的电影并不是高预算大片。很多低成本恐怖片、喜剧片凭借极小的投入获得了超高的回报率高预算电影反而经常陷入“高投入、低回报”的困境。评分与票房之间不是强相关。有不少评分中上的电影票房平平也有评分很一般的电影因为 IP 或档期因素拿到高票房。从演员角度看参演 5 部以上电影的演员中“平均票房高”和“平均评分高”并不总是一拨人。流量型演员能带来票房但口碑波动极大。从年份趋势看近年来电影产量总体上升但评分均值没有明显上升说明内容供给增加并没有带来质量提升。这些结论当然不能代表所有电影市场但对理解 TMDB 数据集的字段关系、验证自己的 Spark 代码是否正确非常有帮助。6.2 可以继续做的深度方向这次项目做到后面我明显感觉到可扩展的空间很大。如果你想把 TMDB 分析做得更深这几个方向值得继续投入推荐系统基于用户对电影的评分用 Spark MLlib 里的 ALS 算法做协同过滤推荐。这需要你额外准备用户评分数据但工程链路很完整。演员合作网络用cast数据构建演员共现矩阵再用图计算库做社区发现能看出哪些演员经常搭戏。票房预测把budget、genre、runtime、release_date、vote_count、导演等特征组合起来用 Spark ML 做回归或分类预测。这个方向的变量工程比模型本身更重要。时间序列分析按周或按月统计电影发布数量、票房总量做趋势分解能看出档期效应。全链路部署把分析代码打包成 JAR用任务调度工具每天跑批结果写入 Hive 或数据库再对接可视化报表。这才能真正体现 Spark 的生产价值。每种扩展都会遇到新的坑但核心的 schema 设计、JSON 解析、join 优化思路是通用的。只要这一轮基础打牢了后面换数据源也不会慌。这次项目做完我最大的体会是Spark 解决的不只是“数据量大”的问题更是“分析逻辑复杂”的问题。TMDB 数据集里大量半结构化字段天然适合用from_json、explode、窗口函数这套组合拳来练手。还有一点让我印象深刻——写 Spark 代码一定要时刻盯着执行计划不要只看结果对不对。很多性能问题在数据量小时根本不会暴露但你把同一条逻辑搬到集群上可能就会因为一个多余的 shuffle 让任务跑几小时。所以我建议每个读者做完分析后都花一点时间看df.explain()弄明白哪些步骤触发了 reduce 阶段哪些可以避免。这比多写一百行分析逻辑更有价值。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑