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会做以下几件事:
构建RDD血缘关系:textFile生成HadoopRDD,flatMap生成MapPartitionsRDD,map再生成一个MapPartitionsRDD,reduceByKey生成ShuffledRDD。每个RDD都记录了自己的父RDD和转换函数。
遇到collect触发执行:collect是一个Action操作,它会触发Job的提交。DAGScheduler从最终的ShuffledRDD开始反向遍历依赖关系。
划分Stage:reduceByKey产生了Shuffle依赖,所以DAGScheduler会在Shuffle处切一刀,把Job分成两个Stage。第一个Stage负责读取文件和flatMap/map,第二个Stage负责reduceByKey和collect。
提交Task:每个Stage里的Task数量由分区数决定。假设textFile读进来是2个分区,那第一个Stage就有2个Task,分别处理两个分区的数据。
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有五个核心特性,我逐个解释:
- 分区列表(Partitions):每个RDD都有若干个分区,分区是并行的基本单位。
- 计算函数(Compute):每个分区怎么算,由这个函数定义。
- 依赖关系(Dependencies):记录父RDD是谁,怎么从父RDD算出来。
- 分区器(Partitioner):对于key-value类型的RDD,决定数据怎么分区(HashPartitioner或RangePartitioner)。
- 首选位置(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昂贵的原因有三个:
- 磁盘I/O:Shuffle数据要写到磁盘,因为内存放不下。
- 网络传输:不同节点的Executor要互相拉取数据。
- 序列化开销:数据要序列化成字节流才能传输。
减少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去验证你的理解。每一步都动手跑一遍,比看十篇文章都管用。