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会感知到。
我的经验: 我曾经遇到一个诡异的“任务卡死”问题,查了两天才发现是Driver所在节点内存不够,导致Driver频繁GC,没法及时给Executor发心跳。嗯,从那以后,我习惯给Driver单独配至少2-3倍于默认的内存,尤其是在用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的“全量复制”高效得多。

核心思想: 血缘让Spark实现了“部分失败、部分恢复”,而不是“全有或全无”。这是Spark比MapReduce快的一个重要原因。

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会做三件事:

  1. 构建DAG:把RDD的血缘关系画成一张有向无环图。
  2. 划分Stage:遇到宽依赖(Shuffle)就切一刀,生成一个Stage。窄依赖的算子都放在同一个Stage里。
  3. 生成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调度全流程,你看完就全明白了:

Spark DAG调度全流程 Spark DAG调度全流程 用户代码(RDD操作链) DAGScheduler Stage划分(宽依赖切分) Stage 0(窄依赖流水线) Stage 1(Shuffle后计算) TaskSet(分区数=Task数) TaskSet(分区数=Task数) TaskScheduler(分发到Executor) 关键点:窄依赖在同一个Stage内流水线执行,宽依赖产生Stage边界,触发Shuffle

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运行架构

这一章的内容,说白了就是三句话:

  1. Driver 是大脑,负责拆任务、调度、容错。
  2. Executor 是苦力,负责执行任务、存数据。
  3. DAGScheduler 是翻译官,把RDD血缘变成Stage和Task。

你把这些搞懂了,再看Spark UI上的Stage和Task,就不会一脸懵了。下一层,我们就要深入到TaskScheduler和Executor内部,看看Task到底是怎么被执行的,以及怎么通过调整并行度来压榨集群性能。