Spark运行原理与架构:作业提交流程、Driver与Executor角色、RDD依赖与DAG调度

说实话,很多Spark初学者上来就写代码,跑通了就觉得自己会了。但一旦遇到性能问题,或者作业莫名其妙挂掉,就完全摸不着头脑。我见过太多人把Spark当成一个黑盒在用——这其实挺危险的。

这一章,咱们就把这个黑盒拆开看看。你搞懂了Spark的运行原理,调优才有方向。不然你连瓶颈在哪都不知道,调什么呢?

1. Spark作业提交流程:从代码到集群

你写了一个WordCount程序,然后敲下spark-submit。这背后发生了什么?

我习惯把这个过程分成四个阶段:

  1. 客户端提交:spark-submit脚本启动,加载你的配置和代码。
  2. 申请资源:向集群管理器(YARN、Mesos或Kubernetes)申请资源。
  3. 启动Driver:集群管理器在某个节点上启动Driver进程。
  4. 启动Executor:Driver反向向集群管理器申请Executor资源,然后启动它们。

嗯,这里要注意一个细节。很多人以为spark-submit提交完就完事了。其实不是。提交模式不同,Driver跑的位置也不同。

部署模式 Driver位置 适用场景
client模式 提交作业的客户端机器 调试、交互式开发
cluster模式 集群内部某个节点 生产环境

我在项目中遇到过一个问题:用client模式跑生产作业,结果网络波动导致客户端和集群断开,整个作业就挂了。后来全部改成cluster模式,Driver跑在集群里,稳多了。

避坑指南:生产环境务必使用cluster模式。client模式只适合本地调试。我曾经见过有人把client模式部署到线上,结果客户端一关机,整个Spark作业就没了。

2. Driver与Executor:Spark的两大核心角色

Spark集群里,有两个最重要的角色:Driver和Executor。说白了,Driver是大脑,Executor是手脚。

2.1 Driver:作业的总指挥

Driver负责三件事:

  • 解析代码:把你的RDD操作转换成DAG(有向无环图)。
  • 调度任务:把DAG切分成Stage,再把Stage拆成Task,分发给Executor。
  • 管理状态:记录每个Task的执行结果,处理失败重试。

你想想看,如果Driver挂了,整个作业就完了。所以Driver的内存配置很关键。我一般建议给Driver分配至少1-2GB内存,如果用了collect()这类操作,还要更大。

2.2 Executor:真正的执行者

Executor是跑在Worker节点上的进程。它负责:

  • 执行Task:运行Driver分配过来的任务。
  • 存储数据:缓存RDD、Shuffle数据等。
  • 汇报状态:定期向Driver报告心跳和任务进度。

Executor的数量和大小,直接决定了你的作业能跑多快。我见过一个调优案例:有人把Executor内存设得特别大,但只开了2个。结果GC频繁,性能反而很差。后来改成8个Executor,每个4GB,性能提升了3倍。

个人经验:Executor的内存不是越大越好。超过32GB后,GC开销会急剧上升。我一般控制在8-16GB之间,配合合适的并行度,效果最好。

3. RDD依赖与DAG调度:Spark的精髓

这部分是Spark最核心的东西。你搞懂了RDD依赖和DAG调度,就掌握了Spark的命脉。

3.1 RDD依赖:宽依赖与窄依赖

RDD之间有依赖关系。我把它分成两种:

  • 窄依赖(Narrow Dependency):每个父RDD的分区最多被子RDD的一个分区使用。比如map()、filter()。
  • 宽依赖(Wide Dependency):每个父RDD的分区可能被子RDD的多个分区使用。比如groupByKey()、reduceByKey()。

为什么要区分这个?因为宽依赖会触发Shuffle。Shuffle是Spark性能的杀手。你想想看,数据要在网络间重新分区,磁盘IO、网络传输、序列化,每一步都可能成为瓶颈。

核心原则:尽量减少宽依赖,或者用窄依赖替代宽依赖。比如用reduceByKey()代替groupByKey(),前者会在map端做预聚合,减少Shuffle数据量。

3.2 DAG调度:从代码到物理执行

Driver拿到你的代码后,会做三件事:

  1. 构建DAG:把RDD的操作链画成一个有向无环图。
  2. 划分Stage:遇到宽依赖就切一刀,分成不同的Stage。
  3. 生成Task:每个Stage内部,根据分区数生成对应的Task。

我画了一张图,帮你理解这个过程:

Spark DAG调度流程 HDFS文件 map() filter() groupByKey() mapValues() saveAsTextFile() 说明: 1. Stage1:包含map()和filter(),都是窄依赖,可以流水线执行。 2. 宽依赖(groupByKey())触发Shuffle,划分Stage边界。 3. Stage2:包含mapValues()和saveAsTextFile(),等待Stage1完成后执行。 4. 每个Stage内部,根据分区数生成多个Task,并行执行。

3.3 任务调度:Stage内部怎么跑?

每个Stage被切分成多个Task。Task的数量等于该Stage最后一个RDD的分区数。Driver会把Task分发到各个Executor上。

这里有个关键点:数据本地性。Driver会尽量把Task调度到数据所在的节点上,避免网络传输。优先级是这样的:

  • PROCESS_LOCAL:数据和Task在同一进程(最佳)。
  • NODE_LOCAL:数据和Task在同一节点。
  • RACK_LOCAL:数据和Task在同一机架。
  • ANY:任意节点(最差)。

我遇到过一个问题:某个作业一直卡在NODE_LOCAL级别,数据本地性上不去。后来发现是Executor数量太少,数据分布不均匀。加了几个Executor后,大部分Task都变成了PROCESS_LOCAL,性能提升明显。

调优技巧:在Spark UI的Stages页面,可以查看每个Stage的“Data Locality Level”。如果大部分是ANY或RACK_LOCAL,说明数据本地性不好。可以尝试增加Executor数量,或者调整spark.locality.wait参数(默认3秒)。

4. 总结:一张图看懂Spark运行原理

好了,咱们把这一章的核心内容串起来:

  • 提交作业:spark-submit → 集群管理器 → 启动Driver和Executor。
  • Driver:构建DAG → 划分Stage → 生成Task → 调度到Executor。
  • Executor:执行Task → 缓存数据 → 汇报状态。
  • RDD依赖:窄依赖(无Shuffle) vs 宽依赖(触发Shuffle)。
  • DAG调度:宽依赖是Stage的分界线,Stage内部Task并行执行。

你把这些搞清楚了,后面讲调优的时候,我提到“减少Shuffle”、“优化数据本地性”、“调整并行度”,你就能立刻明白背后的原理。而不是盲目地改参数。