2. Spark运行架构深潜:Driver与Executor的职责、RDD的血缘与依赖、DAG调度器的工作原理
很多同学学Spark,上来就写rdd.map(...).filter(...).reduceByKey(...),跑通了就觉得会了。但一旦遇到数据倾斜、任务卡死、OOM,就完全不知道从哪下手。
说白了,你不懂Spark底层是怎么调度、怎么容错的,调优就只能是瞎蒙。这一章,我带你把Spark的运行架构彻底吃透。
2.1 Driver与Executor:Spark的两大核心角色
一个Spark应用跑起来,其实就两个角色在干活:Driver 和 Executor。你可以把Driver想象成项目经理,Executor就是手底下的工人。
2.1.1 Driver——大脑与指挥官
Driver运行在main()方法里,负责整个应用的“大局”。它的核心职责有三块:
- 把用户代码转成任务:你写的
rdd.map(...),Driver会把它解析成DAG(有向无环图),再拆成一个个Task。 - 调度与分发:Task拆好后,Driver会跟集群管理器(YARN / Mesos / Standalone)要资源,然后把Task发到Executor上去跑。
- 监控与容错:哪个Task挂了,Driver会重新调度;哪个Stage慢了,Driver会感知到。
collect()这类操作时。
2.1.2 Executor——真正的苦力
Executor是跑在Worker节点上的JVM进程。它只做两件事:
- 执行Task:Driver发来的Task,Executor用多线程去跑。
- 存数据:如果RDD被
cache()或persist()了,数据就存在Executor的内存或磁盘里。
一个Executor可以同时跑多个Task(由spark.executor.cores控制)。但注意,一个Executor是一个JVM进程,里面的所有Task共享内存。这就是为什么你经常看到“一个Task把整个Executor撑爆”的情况。
| 角色 | 数量 | 生命周期 | 核心职责 |
|---|---|---|---|
| Driver | 1个 | 整个应用 | 解析代码、构建DAG、调度Task、监控容错 |
| Executor | 多个 | 随应用启动/销毁 | 执行Task、缓存数据 |
spark.executor.memory设得特别大,以为越大越好。结果因为JVM GC时间过长,任务反而更慢了。Executor内存不是越大越好,要结合spark.memory.fraction和spark.memory.storageFraction一起调。
2.2 RDD的血缘与依赖:Spark容错的基石
RDD(弹性分布式数据集)是Spark最核心的抽象。它为什么“弹性”?因为它记录了数据是怎么来的——这就是“血缘”(Lineage)。
2.2.1 血缘:数据的“族谱”
每个RDD都有一张“族谱”,记录了它是从哪个父RDD、经过什么操作得来的。比如:
val rdd1 = sc.textFile("hdfs://data.txt")
val rdd2 = rdd1.flatMap(_.split(" "))
val rdd3 = rdd2.map((_, 1))
val rdd4 = rdd3.reduceByKey(_ + _)
这里rdd4的血缘就是:rdd4 → rdd3 → rdd2 → rdd1 → HDFS文件。
为什么要记这个?为了容错。如果某个分区丢了,Spark不需要从头读整个HDFS文件,只需要顺着血缘找到对应的父RDD分区,重新计算那一小部分就行。这比Hadoop的“全量复制”高效得多。
2.2.2 依赖:窄依赖 vs 宽依赖
RDD之间的依赖关系分两种,这是理解DAG调度的关键:
- 窄依赖(Narrow Dependency):每个父RDD分区最多被子RDD的一个分区使用。比如
map、filter、union。 - 宽依赖(Wide Dependency):每个父RDD分区可能被子RDD的多个分区使用。比如
groupByKey、reduceByKey、join(非哈希分区时)。
窄依赖可以在同一个Stage内流水线执行,不需要Shuffle。宽依赖则意味着必须跨网络传输数据,也就是Shuffle。
| 特性 | 窄依赖 | 宽依赖 |
|---|---|---|
| 数据移动 | 无Shuffle | 需要Shuffle |
| 容错恢复 | 只重算丢失分区 | 可能重算所有父分区 |
| 典型算子 | map, filter, union | groupByKey, reduceByKey, join |
| Stage边界 | 不产生 | 产生 |
reduceByKey代替groupByKey。因为reduceByKey会在Map端先做一次预聚合,减少Shuffle数据量。说白了,就是“能窄就不宽,能预聚合就不全量传输”。
2.3 DAG调度器:从代码到任务的“翻译官”
你写的RDD操作链,最终是怎么变成一个个Task的?这就要靠DAG调度器(DAGScheduler)了。
2.3.1 DAG的构建与Stage划分
DAGScheduler会做三件事:
- 构建DAG:把RDD的血缘关系画成一张有向无环图。
- 划分Stage:遇到宽依赖(Shuffle)就切一刀,生成一个Stage。窄依赖的算子都放在同一个Stage里。
- 生成TaskSet:每个Stage包含多个Task(一个分区对应一个Task),DAGScheduler把这些Task打包成TaskSet,交给TaskScheduler去分发。
举个例子:
val rdd = sc.textFile("data.txt")
.flatMap(_.split(" "))
.map((_, 1))
.reduceByKey(_ + _)
.filter(_._2 > 10)
.collect()
这个DAG会被切成2个Stage:
- Stage 0:
textFile → flatMap → map(窄依赖,流水线执行) - Stage 1:
reduceByKey → filter → collect(宽依赖后开始新Stage)
你想想看,如果没有DAGScheduler的智能划分,每个算子都单独跑一个Stage,那Shuffle次数会多到爆炸。
2.3.2 调度流程全貌
下面这张图,是我自己总结的Spark调度全流程,你看完就全明白了:
2.3.3 为什么DAGScheduler这么重要?
我见过很多新手,以为Spark的“快”是魔法。其实,DAGScheduler的优化是核心原因之一:
- 流水线执行:窄依赖的算子在一个Stage里连续执行,数据在内存里流转,不需要落盘。
- 智能容错:某个Task失败,DAGScheduler只重算那个Task所在的Stage,而不是整个应用。
- 避免不必要的Shuffle:DAGScheduler会尽量把窄依赖算子合并,减少Stage数量。
rdd.groupByKey().mapValues(...),DAGScheduler硬生生切出了3个Stage。后来改成reduceByKey,Stage直接变成2个,性能提升了40%。所以,写代码时心里要时刻想着DAG,你写的每一行算子,都在影响Stage的划分。
2.4 小结:一张图看懂Spark运行架构
这一章的内容,说白了就是三句话:
- Driver 是大脑,负责拆任务、调度、容错。
- Executor 是苦力,负责执行任务、存数据。
- DAGScheduler 是翻译官,把RDD血缘变成Stage和Task。
你把这些搞懂了,再看Spark UI上的Stage和Task,就不会一脸懵了。下一层,我们就要深入到TaskScheduler和Executor内部,看看Task到底是怎么被执行的,以及怎么通过调整并行度来压榨集群性能。