☰
从零啃Spark架构与RDD:核心机制、依赖关系与实操避坑指南
2026/10/17 11:44:33 网站建设 项目流程

1. 为什么我要从零啃Spark架构和RDD

刚接触分布式计算那会儿,我对Spark的认知就停留在“比Hadoop快”这个层面。直到有一次处理一批日志数据,单机跑Python脚本跑了四十多分钟还没出结果,我才真正下决心把这套东西搞明白。这篇文章就是我当时学习路径的完整复盘,从架构设计到RDD的核心机制,再到实际写代码时踩过的坑,全部摊开来讲。

如果你正在做数据清洗、日志分析、特征工程这类活儿,或者你所在的项目数据量已经大到单机内存扛不住了,那Spark大概率是你绕不开的一站。这篇文章面向的是有一定编程基础(会Python或Scala都行)、但对分布式计算还没有系统认知的读者。我会尽量用生活化的类比把架构讲清楚,同时给出可以直接跑的代码示例和参数配置,让你看完就能上手试。

核心关键词我先摆出来:Spark架构、RDD、Driver、Executor、DAG、惰性求值、宽依赖与窄依赖。这些概念是Spark整个体系的骨架,理解了它们,后面学Spark SQL、Structured Streaming都会顺很多。

2. Spark架构到底是怎么设计的

2.1 从“一个任务怎么被拆散又拼起来”说起

很多人第一次看Spark架构图会觉得组件太多、箭头太乱。我当时的做法是先不看图,而是想一个问题:如果让我设计一个分布式计算框架,我会怎么做?

最朴素的想法是:有一台机器负责指挥,把任务拆成小块,分给其他机器去算,算完了把结果收回来。Spark基本上就是这个思路,只不过它把每个角色都做了更精细的抽象。

Spark的架构核心是主从模式。集群里有一个Driver(可以理解成“总指挥”),还有若干个Executor(可以理解成“干活的工人”)。Driver负责解析你的代码、生成执行计划、调度任务;Executor负责真正执行计算任务、缓存数据、把结果汇报回去。

但这里有个容易混淆的点:Driver和Executor都是进程,它们可以跑在同一台机器上,也可以分布在不同机器上。你本地写代码测试的时候,Driver和Executor其实都在你的笔记本上,只是用了多线程模拟分布式。这就是为什么本地模式能跑通但性能不代表集群表现——资源隔离和网络通信的开销完全不一样。

2.2 Driver、Executor、Cluster Manager三者的分工

我把这三个角色的职责列成表格,方便对照记忆:

组件职责类比
Driver解析代码、构建DAG、划分Stage、调度Task、收集结果项目经理
Executor执行Task、缓存RDD数据、汇报心跳和结果施工队
Cluster Manager分配资源、启动Executor、监控存活人力资源部

Driver里有两个关键对象:DAGScheduler和TaskScheduler。DAGScheduler负责把RDD的依赖关系图(DAG)切成不同的Stage,TaskScheduler负责把Stage里的Task分发给Executor。这两个东西是Spark调度的核心,后面讲宽窄依赖的时候还会提到它们。

Cluster Manager在生产环境里常见的有YARN、Kubernetes和Standalone模式。本地测试用local[*]就行,*表示用所有可用的CPU核心。这里有个实操心得:本地模式调试的时候,把local[*]改成local[2]反而更容易复现并发问题,因为核心数少了,Task之间的竞争关系更明显。

2.3 一个Job的完整生命周期

我拿一个最简单的word count来串一遍整个流程。假设你写了这样一段代码:

from pyspark import SparkContext sc = SparkContext("local[2]", "WordCountDemo") text = sc.textFile("data.txt") words = text.flatMap(lambda line: line.split(" ")) pairs = words.map(lambda word: (word, 1)) counts = pairs.reduceByKey(lambda a, b: a + b) result = counts.collect()

这段代码提交后,Driver会做以下几件事:

  1. 构建RDD血缘关系:textFile生成HadoopRDD,flatMap生成MapPartitionsRDD,map再生成一个MapPartitionsRDD,reduceByKey生成ShuffledRDD。每个RDD都记录了自己的父RDD和转换函数。

  2. 遇到collect触发执行:collect是一个Action操作,它会触发Job的提交。DAGScheduler从最终的ShuffledRDD开始反向遍历依赖关系。

  3. 划分Stage:reduceByKey产生了Shuffle依赖,所以DAGScheduler会在Shuffle处切一刀,把Job分成两个Stage。第一个Stage负责读取文件和flatMap/map,第二个Stage负责reduceByKey和collect。

  4. 提交Task:每个Stage里的Task数量由分区数决定。假设textFile读进来是2个分区,那第一个Stage就有2个Task,分别处理两个分区的数据。

  5. Executor执行并返回结果:Task在Executor上跑完后,Shuffle数据写到磁盘,第二个Stage的Task去拉取对应的Shuffle数据做聚合,最后collect把结果拉回Driver。

注意:collect会把所有结果拉到Driver内存里,数据量大的时候直接OOM。生产环境千万别随便collect,用take或者write代替。

3. RDD:Spark最核心的数据抽象

3.1 RDD到底是什么,为什么需要它

RDD全称是Resilient Distributed Dataset,翻译过来是“弹性分布式数据集”。这三个词每个都有含义:

  • Resilient(弹性):数据丢了能重新算出来,靠的是血缘关系(Lineage)。
  • Distributed(分布式):数据分散在多个节点上,分区存储。
  • Dataset(数据集):就是一个数据集合,可以类比成一个大数组或者大列表。

我刚开始不理解为什么要搞这么个抽象。后来想明白了:分布式计算最大的难题是容错。如果一台机器挂了,它上面算到一半的数据怎么办?传统做法是做检查点(Checkpoint),把中间状态存到磁盘。但Spark选择了另一条路——记录计算过程而不是计算结果。

举个例子:你有一个RDD,它经过map、filter、flatMap三次转换得到。如果某个分区的数据丢了,Spark不需要从磁盘恢复,只需要把那个分区对应的原始数据重新走一遍map、filter、flatMap就行了。这就是血缘关系的价值。

RDD有五个核心特性,我逐个解释:

  1. 分区列表(Partitions):每个RDD都有若干个分区,分区是并行的基本单位。
  2. 计算函数(Compute):每个分区怎么算,由这个函数定义。
  3. 依赖关系(Dependencies):记录父RDD是谁,怎么从父RDD算出来。
  4. 分区器(Partitioner):对于key-value类型的RDD,决定数据怎么分区(HashPartitioner或RangePartitioner)。
  5. 首选位置(Preferred Locations):数据在哪台机器上,尽量把计算调度到那台机器,减少网络传输。

3.2 创建RDD的两种方式与选择依据

创建RDD主要有两种方式:

第一种:从集合创建

data = [1, 2, 3, 4, 5] rdd = sc.parallelize(data, numSlices=3)

numSlices指定分区数。如果不指定,默认是CPU核心数。这种方式主要用于测试和小规模数据。

第二种:从外部存储创建

rdd = sc.textFile("hdfs://path/to/file.txt", minPartitions=4)

minPartitions是最小分区数,实际分区数可能更多,取决于文件大小和HDFS块大小。比如一个1GB的文件,HDFS块大小是128MB,那至少会有8个分区。

选择依据很简单:生产环境永远用第二种,测试用第一种。但这里有个细节:textFile返回的是每行文本的RDD,如果你要处理CSV或JSON,需要自己解析。我见过有人直接用textFile读CSV然后split,结果遇到字段里有逗号的情况就崩了。正确做法是用Spark SQL的DataFrameReader,或者用专门的CSV解析库。

3.3 转换算子与行动算子的本质区别

Spark的算子分两类:Transformation(转换)和Action(行动)。

转换算子是惰性的,调用它不会立即执行,只是记录“我要做这个操作”。行动算子才会触发真正的计算。这个设计的好处是:Spark可以在执行前做全局优化,比如合并多个map、下推filter。

常见的转换算子:

  • map:一对一转换
  • flatMap:一对多转换
  • filter:过滤
  • reduceByKey:按key聚合
  • groupByKey:按key分组(慎用,后面会讲)
  • join:连接两个RDD

常见的行动算子:

  • collect:拉回所有数据
  • count:计数
  • take(n):取前n条
  • saveAsTextFile:写文件
  • foreach:遍历

实操心得:调试的时候,如果发现代码没报错但也没输出,大概率是你只写了转换算子没写行动算子。Spark不会自动触发计算。

4. 依赖关系与DAG:Spark怎么决定怎么算

4.1 窄依赖和宽依赖的判定方法

依赖关系是Spark调度的基础。窄依赖是指父RDD的每个分区最多被一个子RDD分区使用;宽依赖是指父RDD的每个分区可能被多个子RDD分区使用。

判断方法很简单:看子RDD的每个分区是否只依赖父RDD的一个分区。如果是,就是窄依赖;如果依赖多个,就是宽依赖。

窄依赖的例子:map、filter、flatMap、union。 宽依赖的例子:groupByKey、reduceByKey、join(未co-partitioned时)。

宽依赖意味着Shuffle。Shuffle是Spark里最昂贵的操作,因为它涉及磁盘I/O、网络传输和数据序列化。我做过一个测试:同样数据量下,一个包含reduceByKey的Job比纯map/filter的Job慢5到8倍,差距主要就在Shuffle上。

4.2 DAG划分Stage的规则与实操验证

DAGScheduler划分Stage的规则是:从后往前遍历,遇到宽依赖就切分。每个Stage内部都是窄依赖,可以流水线执行。

我写一段代码来验证:

rdd = sc.parallelize(range(100), 4) mapped = rdd.map(lambda x: (x % 10, x)) filtered = mapped.filter(lambda x: x[1] > 50) reduced = filtered.reduceByKey(lambda a, b: a + b) result = reduced.collect()

这段代码会分成两个Stage:

  • Stage 0:parallelize -> map -> filter
  • Stage 1:reduceByKey -> collect

你可以在Spark UI的Stage页面看到这个划分。本地模式下访问http://localhost:4040就能看到。

注意:Stage的数量不等于Task的数量。每个Stage里的Task数量等于该Stage最后一个RDD的分区数。

4.3 Shuffle为什么昂贵,怎么减少Shuffle

Shuffle昂贵的原因有三个:

  1. 磁盘I/O:Shuffle数据要写到磁盘,因为内存放不下。
  2. 网络传输:不同节点的Executor要互相拉取数据。
  3. 序列化开销:数据要序列化成字节流才能传输。

减少Shuffle的手段:

  • 用reduceByKey代替groupByKey:reduceByKey会在map端先做本地聚合(combiner),大幅减少传输量。groupByKey会把所有数据原样传过去。
  • 用broadcast join代替普通join:如果一张表很小,可以广播到每个Executor,避免Shuffle。
  • 合理设置分区数:分区太少会导致单个Task处理数据量过大,分区太多会导致Task调度开销增加。一般建议每个分区处理128MB左右的数据。

我踩过的一个坑:有一次用groupByKey处理用户行为日志,每个用户的行为记录有几千条,结果Shuffle数据量爆炸,Job跑了两个小时。后来改成reduceByKey先做预聚合,时间降到十五分钟。

5. 实操环境搭建与第一个Spark程序

5.1 本地环境配置的详细步骤

我推荐用Python写Spark,因为上手快、生态好。以下是完整步骤:

第一步:安装Java

Spark依赖JVM,所以Java是必须的。推荐Java 8或Java 11。

# Ubuntu/Debian sudo apt-get install openjdk-11-jdk # 验证 java -version

第二步:安装Spark

去Apache官网下载预编译版本,注意选对Hadoop版本。如果只是本地测试,选“Pre-built for Apache Hadoop 3.3 and later”就行。

tar -xzf spark-3.5.0-bin-hadoop3.tgz export SPARK_HOME=/path/to/spark-3.5.0-bin-hadoop3 export PATH=$SPARK_HOME/bin:$PATH

第三步:安装PySpark

pip install pyspark==3.5.0

版本要和Spark版本一致,否则可能报错。

第四步:验证安装

from pyspark.sql import SparkSession spark = SparkSession.builder \ .appName("TestApp") \ .master("local[2]") \ .getOrCreate() rdd = spark.sparkContext.parallelize([1, 2, 3, 4, 5]) print(rdd.map(lambda x: x * 2).collect()) # 输出:[2, 4, 6, 8, 10] spark.stop()

5.2 一个完整的日志分析案例

我拿一个模拟的日志数据来演示。假设日志格式是:

2024-01-01 10:00:00 ERROR UserService Login failed for user 1001 2024-01-01 10:00:01 INFO OrderService Order 2001 created 2024-01-01 10:00:02 ERROR PaymentService Payment timeout for order 2001

目标:统计每种日志级别出现的次数,并找出ERROR日志里涉及的服务名。

from pyspark import SparkContext sc = SparkContext("local[2]", "LogAnalysis") # 读取日志 logs = sc.textFile("app.log") # 提取日志级别 levels = logs.map(lambda line: line.split(" ")[2]) level_counts = levels.map(lambda level: (level, 1)).reduceByKey(lambda a, b: a + b) print("日志级别统计:", level_counts.collect()) # 提取ERROR日志的服务名 error_services = logs \ .filter(lambda line: "ERROR" in line) \ .map(lambda line: line.split(" ")[3]) \ .distinct() print("出错的服务器:", error_services.collect()) sc.stop()

这段代码里,distinct()是一个转换算子,它会触发Shuffle,因为要去重就必须把相同的数据放到同一个分区。

5.3 分区数设置的经验法则

分区数设置是个经验活。我的建议是:

  • 本地模式:分区数设为CPU核心数的2到4倍。比如4核机器设8到16个分区。
  • 集群模式:每个分区处理128MB到256MB数据。总分区数 = 总数据量 / 128MB。
  • Shuffle后:默认分区数等于父RDD的分区数,但可以通过repartition或coalesce调整。

repartition会触发全量Shuffle,coalesce可以避免Shuffle但只能减少分区数。如果你要把分区从1000减到100,用coalesce(100);如果要增加到2000,只能用repartition(2000)。

实操心得:分区数不是越多越好。我试过把分区数设成10000,结果Task调度开销比计算时间还长。一般单个Stage的Task数控制在几百到几千比较合理。

6. 常见问题与排查技巧实录

6.1 内存溢出(OOM)的几种典型场景

OOM是Spark最常见的错误。我遇到过的几种情况:

场景一:collect数据太大

# 错误示范 all_data = rdd.collect() # 数据量超过Driver内存直接OOM

解决方法:用take(n)取样,或者用saveAsTextFile写到磁盘。

场景二:Executor内存不足

报错信息通常是Container killed by YARN for exceeding memory limits。原因是单个Task处理的数据量太大,或者缓存了太多RDD。

解决方法:

  • 增加Executor内存:--executor-memory 8g
  • 增加分区数,让每个Task处理更少的数据
  • 用MEMORY_AND_DISK存储级别代替MEMORY_ONLY

场景三:数据倾斜

某个key的数据量特别大,导致一个Task处理了80%的数据。表现是大部分Task很快完成,少数Task跑很久。

解决方法:

  • 给热点key加随机前缀,打散到多个分区
  • 用reduceByKey代替groupByKey
  • 开启自适应执行:spark.sql.adaptive.enabled=true

6.2 序列化错误的排查思路

序列化错误通常长这样:Task not serializable。原因是你在闭包里引用了不可序列化的对象。

# 错误示范 class MyService: def __init__(self): self.connection = create_db_connection() # 数据库连接不可序列化 service = MyService() rdd.map(lambda x: service.process(x)) # 报错

解决方法:把不可序列化的对象在Executor端创建,而不是在Driver端创建后传过去。

# 正确做法 def process(x): service = MyService() # 在每个Executor上创建 return service.process(x) rdd.map(process)

6.3 常见问题速查表

问题现象可能原因排查方向解决方案
Job卡在某个Stage数据倾斜看Spark UI的Task时间分布加盐打散、调整分区
OOM内存不足看Executor日志增加内存、减少缓存
序列化错误闭包引用不可序列化对象看报错堆栈在Executor端创建对象
Shuffle失败磁盘空间不足看磁盘使用率清理临时目录、增加磁盘
Task重试多次网络抖动或GC频繁看Executor GC日志调整GC参数、增加内存

6.4 我踩过的三个坑

第一个坑:以为转换算子会立即执行。刚开始写代码的时候,我写了一个map然后print,发现什么都没输出。后来才知道必须调用action算子。这个坑新手几乎都会踩。

第二个坑:用groupByKey处理大数据。前面提过,groupByKey不会做map端聚合,数据量大的时候Shuffle数据爆炸。后来我养成了一个习惯:能用reduceByKey就不用groupByKey。

第三个坑:本地模式测试通过就以为集群没问题。本地模式没有网络传输、没有资源竞争、没有数据倾斜。我试过本地跑10秒的Job,上集群跑了20分钟。后来学乖了,上集群前先用小规模数据在集群上验证。

7. 从RDD到Spark SQL的过渡思路

RDD是Spark的底层抽象,灵活但写起来繁琐。Spark SQL的DataFrame API在RDD之上做了优化,有Catalyst优化器和Tungsten执行引擎,性能通常更好。

我现在的做法是:能用DataFrame就用DataFrame,只有在需要精细控制分区或做复杂自定义计算时才用RDD。两者可以互相转换:

# RDD转DataFrame from pyspark.sql import Row rdd = sc.parallelize([(1, "a"), (2, "b")]) df = spark.createDataFrame(rdd, ["id", "name"]) # DataFrame转RDD rdd2 = df.rdd

但要注意:DataFrame转RDD会丢失schema信息,而且性能会下降。所以转换要谨慎。

如果你已经理解了RDD的分区、依赖、惰性求值这些概念,学DataFrame会很快,因为底层原理是一样的,只是API更高级。

最后分享一个我个人的学习路径:先跑通word count,然后理解DAG和Stage划分,接着搞明白宽窄依赖和Shuffle,最后用Spark UI去验证你的理解。每一步都动手跑一遍,比看十篇文章都管用。

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

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

立即咨询