1. 为什么RDD依赖关系是Spark性能的分水岭
很多人学Spark的时候,前面几个章节都挺顺——RDD是什么、怎么创建、Transformation和Action怎么区分,这些概念看一遍就懂了。但一到依赖关系这块,尤其是宽依赖和窄依赖,就开始犯迷糊。我当初也是这么过来的,第一次看到“宽依赖”和“窄依赖”这两个词,心里想的是:不就是数据从上游流到下游吗,分这么细干嘛?
后来踩了坑才明白,依赖关系是Spark整个执行模型的地基。你不理解依赖关系,就永远搞不懂Stage是怎么划分的、Task是怎么调度的、Shuffle为什么那么慢、数据倾斜该怎么调。换句话说,前面几篇你学的是“怎么用”,这一篇开始你要理解的是“为什么快”和“为什么慢”。
这篇内容我会把RDD依赖关系从头到尾拆一遍。不管你是刚接触Spark的新手,还是已经写过一些作业但总觉得性能上不去的老手,应该都能从里面找到对自己有用的东西。我会先讲清楚依赖关系的本质是什么,然后重点分析宽依赖和窄依赖的区别,接着讲DAG和Stage是怎么根据依赖关系切出来的,最后给几个实际调优中跟依赖关系相关的经验。代码示例用Scala和Python各给一些,方便不同技术栈的读者参考。
2. RDD依赖关系到底是什么
2.1 从“血统”说起:RDD是怎么记住自己从哪来的
Spark里的RDD有一个非常核心的特性叫血统(Lineage),说白了就是每个RDD都记着自己是怎么从父RDD变过来的。你调用一个map,新的RDD就记住了“我是从父RDD做了一次map操作得到的”;你再调用一个filter,新的RDD又记住了“我是从上一个RDD做了一次filter得到的”。
这个血统链就是依赖关系。它有两个非常重要的作用:
第一,容错。某个分区的数据丢了,Spark不需要从头重新跑整个作业,只需要根据血统找到丢失分区的父RDD,重新计算那一个分区的数据就行了。这比做数据副本(比如HDFS的三副本)要轻量得多。
第二,调度优化。Spark根据依赖关系的类型来决定哪些计算可以串在一起在同一个节点上完成,哪些必须等上游全部完成才能开始。这就是Stage划分的依据。
我经常用一个类比来解释:把RDD想象成一摞盘子,每个Transformation就是往盘子上加一层。血统就是记录每一层盘子是怎么加上去的。如果某一层碎了,你只需要重新做那一层,不需要把整摞盘子全扔了重做。
2.2 依赖关系的代码体现
在Spark源码里,依赖关系用Dependency这个抽象类表示,它有两个直接子类:
// 窄依赖 abstract class NarrowDependency[T](rdd: RDD[T]) extends Dependency[T] { def getParents(partitionId: Int): Seq[Int] } // 宽依赖 class ShuffleDependency[K, V, C]( rdd: RDD[_ <: Product2[K, V]], partitioner: Partitioner, ...) extends Dependency[Product2[K, V]]你不需要记住这些源码细节,但理解这个结构有助于你明白:窄依赖的每个子分区只依赖固定的父分区,而宽依赖的子分区依赖所有父分区。这个区别看起来很小,但它带来的性能差异是数量级的。
2.3 一个容易混淆的点:依赖关系不是RDD之间的“引用”
有些人第一次学的时候会以为依赖关系是RDD对象之间的引用关系。不完全是。依赖关系描述的是分区级别的对应关系,不是RDD对象级别的。两个RDD之间有依赖,具体到每个分区,依赖的方式可能不同。比如union操作,子RDD的每个分区只依赖一个父RDD的一个分区,虽然它有两个父RDD,但每个分区只跟其中一个父RDD的对应分区有关系。这就是为什么union是窄依赖而不是宽依赖。
3. 窄依赖与宽依赖:一字之差,天壤之别
3.1 窄依赖:一对一,流水线式执行
窄依赖的定义很直白:父RDD的每个分区最多被一个子RDD的分区使用。注意“最多”这个词,意思是有些父分区可能不被任何子分区使用(比如filter之后数据变少了),但绝不会出现一个父分区被多个子分区同时依赖的情况。
常见的窄依赖操作:
| 操作 | 依赖类型 | 说明 |
|---|---|---|
| map | 窄依赖 | 一对一 |
| filter | 窄依赖 | 一对一,但可能过滤掉部分数据 |
| flatMap | 窄依赖 | 一对多,但每个输出分区只依赖一个输入分区 |
| union | 窄依赖 | 多对一,但每个输出分区只依赖一个输入分区 |
| mapPartitions | 窄依赖 | 分区级别的一对一 |
| coalesce(不触发shuffle时) | 窄依赖 | 减少分区但不跨节点 |
窄依赖最大的好处是可以在同一个Stage里流水线执行。什么意思呢?假设你有一个操作链:rdd.map(f).filter(g),因为都是窄依赖,Spark可以把这两个操作合并成一个Task,对每条记录先执行f再执行g,不需要等map全部完成再开始filter。这就像工厂流水线,每个工位处理完直接传给下一个工位,不需要把半成品先堆到仓库里。
实操心得:窄依赖的流水线执行是Spark比MapReduce快的重要原因之一。MapReduce每个Map和Reduce之间必须落盘,而Spark的窄依赖链可以在内存里一路算到底。
3.2 宽依赖:多对多,必须等上游全部完成
宽依赖的定义正好相反:父RDD的每个分区都可能被多个子RDD的分区使用。最典型的就是groupByKey、reduceByKey、join(非co-partitioned的情况)这些操作。
为什么宽依赖这么特殊?因为子RDD的每个分区需要拿到父RDD所有分区中符合特定key的数据。比如你按key做groupByKey,key为“hello”的数据可能散落在父RDD的100个分区里,子RDD的某个分区要处理key为“hello”的数据,就必须等这100个分区全部把“hello”的数据发过来。
这个过程就是Shuffle。Shuffle是Spark里最昂贵的操作,没有之一。它涉及磁盘I/O、网络传输、数据序列化和反序列化,任何一个环节都可能成为瓶颈。
3.3 一张表看清宽窄依赖的本质区别
| 对比维度 | 窄依赖 | 宽依赖 |
|---|---|---|
| 分区对应关系 | 一对一或多对一 | 多对多 |
| 是否触发Shuffle | 否 | 是 |
| 能否流水线执行 | 能 | 不能 |
| Stage划分 | 同一个Stage内 | Stage边界 |
| 容错代价 | 只重算丢失的分区 | 需要重算上游所有分区 |
| 典型操作 | map, filter, union | groupByKey, reduceByKey, join |
| 网络传输 | 无 | 有,数据量可能很大 |
| 磁盘I/O | 通常无 | 有,Shuffle write和read |
这张表建议你记在心里。每次写Spark作业的时候,看到这些操作就应该条件反射地想到:这个操作会不会触发Shuffle?如果会,能不能避免或者优化?
3.4 为什么宽依赖的容错代价更高
窄依赖的容错很轻量:某个子分区丢了,只需要找到它对应的那一个父分区,重新计算就行了。但宽依赖不一样:某个子分区丢了,你需要找到所有父分区中跟这个子分区相关的数据,全部重新计算一遍。如果父分区本身也是宽依赖的结果,那就要继续往上追溯,代价会指数级放大。
这也是为什么Spark在宽依赖的Shuffle过程中会把中间数据写到磁盘。虽然写磁盘慢,但至少丢了之后不用从头算。这是一种用空间换时间的策略。
4. DAG与Stage:依赖关系如何决定执行计划
4.1 DAG是什么,为什么需要它
DAG(有向无环图)是Spark对作业执行流程的抽象表示。你写的每一行Transformation代码,在Spark眼里都是在往DAG上加一条边。DAG的“有向”意味着数据从上游流向下游,“无环”意味着不会出现循环依赖——这保证了计算一定能结束。
为什么要用DAG而不是像MapReduce那样固定的Map-Shuffle-Reduce三段式?因为DAG更灵活。你可以写任意复杂的操作链,Spark会根据依赖关系自动优化执行计划。比如连续多个窄依赖操作会被合并到一个Stage里,避免不必要的中间结果落盘。
4.2 Stage划分的核心逻辑:遇到宽依赖就切一刀
Stage划分的规则非常简单粗暴:从后往前推,遇到窄依赖就继续往前,遇到宽依赖就切一刀,形成一个Stage。
举个例子:
val rdd1 = sc.textFile("data.txt") // Stage 0开始 val rdd2 = rdd1.filter(_.nonEmpty) // 窄依赖 val rdd3 = rdd2.map(_.split(",")) // 窄依赖 val rdd4 = rdd3.map(x => (x(0), x(1))) // 窄依赖 val rdd5 = rdd4.reduceByKey(_ + _) // 宽依赖!切一刀 val rdd6 = rdd5.mapValues(_ * 2) // 窄依赖 val rdd7 = rdd6.filter(_._2 > 100) // 窄依赖这个DAG会被切成两个Stage:
- Stage 0:rdd1 → rdd2 → rdd3 → rdd4(全是窄依赖,可以流水线执行)
- Stage 1:rdd5 → rdd6 → rdd7(reduceByKey之后的操作)
Stage 0的每个Task处理一个分区,一路map/filter算到底,然后通过Shuffle把数据写到磁盘。Stage 1的Task从磁盘读取属于自己的那部分Shuffle数据,继续处理。
4.3 Stage的类型:ShuffleMapStage和ResultStage
Spark里有两种Stage:
ShuffleMapStage:不是最后一个Stage,它的输出会作为下一个Stage的输入。它需要把数据按分区写到磁盘(Shuffle write)。
ResultStage:最后一个Stage,直接产出结果或者触发Action。它要么把结果返回给Driver,要么写到外部存储。
一个作业里可以有多个ShuffleMapStage,但只有一个ResultStage。每遇到一个宽依赖就产生一个新的ShuffleMapStage。
4.4 Task与Stage的关系
Stage是逻辑概念,Task是物理执行单元。一个Stage里有多少个分区,就会启动多少个Task。这些Task可以并行运行在不同的Executor上。
这里有一个常见的面试题:一个Stage的Task数量等于什么?答案是这个Stage最后一个RDD的分区数。比如reduceByKey默认会产生200个分区(由spark.default.parallelism或spark.sql.shuffle.partitions控制),那Stage 1就会有200个Task。
注意事项:Stage 0的Task数量取决于数据源的分区数。如果你读的是一个HDFS文件,分区数等于文件的分块数。如果分区数太少,Stage 0的并行度就不够,整个作业会被拖慢。这时候可以用
repartition或coalesce调整,但要注意repartition会触发Shuffle。
4.5 一个完整的Stage划分实例
假设我们有一个稍微复杂的作业:
rdd1 = sc.parallelize(range(1000), 10) rdd2 = rdd1.map(lambda x: (x % 5, x)) rdd3 = rdd2.groupByKey() # 宽依赖,Shuffle rdd4 = rdd3.mapValues(sum) rdd5 = rdd4.filter(lambda x: x[1] > 100) rdd6 = rdd5.join(rdd4) # 宽依赖,Shuffle rdd7 = rdd6.mapValues(lambda x: x[0] + x[1]) result = rdd7.collect()Stage划分过程:
- 从rdd7往前推,rdd7依赖rdd6(窄依赖),rdd6依赖rdd5和rdd4(宽依赖,join),切第一刀。
- rdd5依赖rdd4(窄依赖),rdd4依赖rdd3(窄依赖),rdd3依赖rdd2(宽依赖,groupByKey),切第二刀。
- rdd2依赖rdd1(窄依赖),rdd1是数据源,到头了。
最终得到三个Stage:
- Stage 0:rdd1 → rdd2
- Stage 1:rdd3 → rdd4 → rdd5
- Stage 2:rdd6 → rdd7
注意rdd4被两个Stage用到(Stage 1和Stage 2),Spark会通过缓存或重新计算来处理这种情况。如果你在rdd4上调用了cache(),它就会被缓存在内存里,Stage 2直接读缓存;如果没有缓存,Stage 2需要重新计算rdd4,这意味着rdd3的Shuffle可能要重做。
5. 依赖关系在实际调优中的应用
5.1 尽量减少宽依赖:能map-side聚合就别reduce-side
这是最经典的优化原则。reduceByKey和groupByKey都能实现按key分组,但reduceByKey会在map端先做一次本地聚合(combiner),再发送到reduce端。这意味着网络传输的数据量可能减少几十倍甚至上百倍。
// 不推荐:groupByKey会把所有数据都Shuffle出去 rdd.groupByKey().mapValues(_.sum) // 推荐:reduceByKey先在map端聚合 rdd.reduceByKey(_ + _)为什么groupByKey不推荐?因为它不关心value是什么,把所有(key, value)对都发到reduce端。如果某个key有100万条记录,这100万条都要走网络。而reduceByKey在map端就把这100万条聚合成一条了,网络传输量直接降到1。
同样的道理,aggregateByKey、combineByKey、foldByKey都是map-side聚合的操作,优先用它们而不是groupByKey。
5.2 宽依赖的容错代价:什么时候该cache
前面说过,宽依赖的容错代价很高。如果一个RDD被多个下游Stage使用,而且它的计算代价很大(比如经过了复杂的Shuffle),那就应该考虑cache。
val baseRDD = rdd1.map(...).filter(...).reduceByKey(...) baseRDD.cache() // 缓存,避免重复计算 val result1 = baseRDD.map(...).collect() val result2 = baseRDD.filter(...).count()但cache不是万能的。如果内存不够,Spark会把部分数据溢写到磁盘,反而可能比不缓存更慢。我的经验是:只有当RDD被使用两次以上,且计算代价明显大于读取代价时,才值得cache。你可以通过Spark UI的Storage页面查看缓存命中率和内存使用情况。
5.3 分区数对宽依赖性能的影响
宽依赖的Shuffle过程涉及大量的磁盘I/O和网络传输,分区数直接决定了并行度和每个分区的数据量。
分区数太少:每个Task处理的数据量太大,容易OOM,而且并行度不够,集群资源利用率低。
分区数太多:每个Task处理的数据量太小,Task启动和调度的开销占比过高,而且会产生大量小文件,增加NameNode压力。
一般来说,每个分区的数据量控制在100MB到200MB之间比较合适。你可以根据数据总量除以目标分区大小来估算分区数。比如10GB的数据,可以设置50到100个分区。
# 调整Shuffle分区数 spark.conf.set("spark.sql.shuffle.partitions", "100") # 或者在RDD API里手动指定 rdd.reduceByKey(_ + _, numPartitions=100)实操心得:
spark.sql.shuffle.partitions的默认值是200,这个值对于小数据量来说太大了,会产生大量空Task。对于数据量不大的作业,可以调到跟CPU核数相当的水平。对于大数据量作业,200可能又太小了,需要根据实际情况调大。
5.4 数据倾斜:宽依赖最头疼的问题
数据倾斜是宽依赖最典型的性能杀手。假设你按key做groupByKey,100个key里有1个key的数据量是其他key的1000倍,那处理这个key的Task就会成为瓶颈,其他99个Task早就跑完了,它还在慢慢磨。
解决数据倾斜的常见思路:
方案一:加盐打散。给倾斜的key加上随机前缀,让它分散到多个分区,聚合完再去掉前缀做二次聚合。
import random def add_salt(k): return (k, random.randint(0, 9)) rdd_salted = rdd.map(lambda kv: (add_salt(kv[0]), kv[1])) rdd_agg = rdd_salted.reduceByKey(lambda a, b: a + b) rdd_result = rdd_agg.map(lambda kv: (kv[0][0], kv[1])).reduceByKey(lambda a, b: a + b)方案二:广播小表。如果是join导致的数据倾斜,且其中一张表比较小,可以用broadcast join把小的那张表广播到每个Executor,避免Shuffle。
from pyspark.sql.functions import broadcast result = large_df.join(broadcast(small_df), "key")方案三:拆分倾斜key单独处理。把倾斜的key单独拿出来处理,剩下的key正常处理,最后union结果。
这几种方案各有适用场景,没有银弹。关键是要先通过Spark UI定位到是哪个Stage、哪个Task出现了倾斜,再对症下药。
6. 常见问题与排查技巧实录
6.1 怎么判断一个操作是宽依赖还是窄依赖
这个问题我被问过很多次。最直接的方法是查官方文档,但更实用的判断标准是:这个操作需不需要从多个父分区收集数据到同一个子分区?
如果答案是“需要”,那就是宽依赖。比如groupByKey需要把所有分区中相同key的数据收集到一起,所以是宽依赖。如果答案是“不需要”,每个子分区只跟一个父分区打交道,那就是窄依赖。
另一个经验法则是:看这个操作会不会改变分区方式。如果操作前后分区方式不变(比如map、filter),就是窄依赖;如果操作会导致数据重新分布(比如reduceByKey、join),就是宽依赖。
6.2 为什么我的作业Stage特别多
Stage多通常意味着宽依赖多。每遇到一个宽依赖就切一个Stage,如果你写了五六个reduceByKey、join,那Stage数量自然就上去了。
Stage多本身不是问题,问题是每个Stage之间的Shuffle都会产生磁盘I/O和网络传输。如果你发现Stage数量异常多,可以检查一下:
- 是不是用了不必要的groupByKey?能不能换成reduceByKey?
- 是不是多个join可以合并?比如连续join同一张表,能不能先合并再join?
- 是不是可以调整分区方式让join变成窄依赖?如果两个RDD用相同的partitioner,join就不会触发Shuffle。
6.3 Shuffle数据量太大怎么办
Shuffle数据量太大通常有两个原因:一是map端没有做聚合,二是分区数不合理。
先检查是不是用了groupByKey而不是reduceByKey。如果是,换掉。然后检查分区数,如果每个分区的数据量超过200MB,考虑增加分区数。如果分区数已经很多但数据量还是大,那可能是数据本身的问题,需要考虑在业务层面做过滤或预聚合。
还有一个容易被忽略的点:序列化方式。Spark默认用Java序列化,效率比较低。换成Kryo序列化可以显著减少Shuffle数据量。
val conf = new SparkConf() .set("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 某个Task特别慢 | 数据倾斜 | 看Spark UI的Task时间分布 | 加盐、广播小表、拆分倾斜key |
| Shuffle数据量异常大 | map端未聚合 | 检查是否用了groupByKey | 换成reduceByKey/aggregateByKey |
| Stage数量过多 | 宽依赖过多 | 看DAG图 | 合并操作、调整partitioner |
| OOM | 分区数据量太大 | 看每个Task处理的数据量 | 增加分区数、减少cache |
| 作业跑得比预期慢 | 并行度不够 | 看Executor利用率和Task数量 | 调整分区数、增加Executor |
| 缓存命中率低 | 内存不足 | 看Storage页面 | 减少缓存数据量、调整存储级别 |
6.5 一个真实的排查案例
之前有个作业,处理一天的日志数据,大概500GB。跑一次要两个多小时,远超预期。我打开Spark UI一看,发现Stage 3的某个Task处理了80GB的数据,其他Task只处理了几百MB。典型的数据倾斜。
进一步排查发现,日志里有一个字段是设备ID,大部分设备只产生几条日志,但有一台设备产生了上亿条日志——明显是异常设备。业务上确认这台设备的数据可以单独处理,于是我们在代码里把这个设备ID的数据过滤出来单独跑,剩下的数据正常处理,最后合并结果。优化后作业时间从两个多小时降到了20分钟。
这个案例说明:数据倾斜的根因往往在业务层面,纯技术手段只能缓解,不能根治。遇到倾斜先别急着写加盐代码,先看看数据本身有没有问题。
7. 依赖关系与Spark执行模型的关系
7.1 从依赖关系到Task执行的全链路
把依赖关系、Stage、Task串起来看,整个执行流程是这样的:
- 你写的Transformation代码构建出一个RDD DAG。
- Spark根据宽依赖把DAG切分成多个Stage。
- 每个Stage根据最后一个RDD的分区数生成对应数量的Task。
- Task被调度到Executor上执行,窄依赖的操作在同一个Task内流水线完成。
- 宽依赖处,上游Stage的Task把数据写到磁盘(Shuffle write),下游Stage的Task从磁盘读取属于自己的数据(Shuffle read)。
- 最后一个Stage的Task把结果返回给Driver或写到外部存储。
理解这个全链路之后,你就能明白为什么宽依赖是性能瓶颈:它打断了流水线,强制数据落盘和网络传输。每一次宽依赖都是一次“断点”,Spark必须等所有上游Task完成才能开始下游Task。
7.2 依赖关系对容错的影响
前面提过窄依赖容错代价低、宽依赖容错代价高。这里再展开说一下Spark的具体做法。
窄依赖的容错:如果某个分区丢了,Spark找到它的父分区,重新执行那个分区的计算逻辑。因为是一对一的,只需要重算一个分区。
宽依赖的容错:如果某个分区丢了,Spark需要找到所有父分区中跟这个分区相关的数据。如果父分区还在(比如在磁盘上),直接读取;如果父分区也丢了,就需要继续往上追溯,可能触发更大范围的重算。
这就是为什么Spark在Shuffle时会写磁盘。虽然写磁盘慢,但它提供了一个“恢复点”。如果下游计算失败,不需要从最开始的RDD重新算,只需要从最近的Shuffle输出重新读。
7.3 依赖关系与Checkpoint
说到容错就不得不提Checkpoint。Checkpoint是把RDD的数据持久化到可靠存储(通常是HDFS),同时切断血统链。为什么要切断血统?因为血统链太长的话,容错重算的代价会非常大。
sc.setCheckpointDir("hdfs://path/to/checkpoint") val rdd = sc.textFile("data.txt").map(...).reduceByKey(...) rdd.checkpoint() rdd.count() // 触发checkpointCheckpoint和cache的区别:cache把数据放内存(或磁盘),但不切断血统;checkpoint把数据放可靠存储,并切断血统。如果你的RDD血统链很长,而且被多个下游使用,checkpoint是比cache更安全的选择。
注意事项:checkpoint会触发一次额外的作业执行(因为要先把数据算出来写到HDFS)。所以checkpoint通常配合cache使用,先cache再checkpoint,避免重复计算。
8. 一些容易踩的坑和实操建议
8.1 不要盲目追求“零Shuffle”
有些教程会说“好的Spark作业应该没有Shuffle”,这是误导。很多业务逻辑天然就需要Shuffle,比如全局排序、按key聚合、join。你能做的是减少不必要的Shuffle,而不是消灭所有Shuffle。
比如两个RDD做join,如果它们已经用相同的partitioner分区了,join就不会触发Shuffle。但如果你为了“避免Shuffle”强行把数据都collect到Driver再处理,那反而更慢,还可能OOM。
8.2 repartition和coalesce的选择
repartition会触发Shuffle,可以增加或减少分区数,数据分布均匀。coalesce默认不触发Shuffle,只能减少分区数,可能导致数据分布不均。
什么时候用哪个?如果你需要增加分区数,只能用repartition。如果你需要减少分区数且当前数据分布比较均匀,用coalesce更高效。如果你需要减少分区数但数据分布不均,用repartition保证均匀。
// 减少分区,不Shuffle,但可能不均 rdd.coalesce(10) // 减少分区,触发Shuffle,保证均匀 rdd.repartition(10) // 增加分区 rdd.repartition(100)8.3 用Spark UI验证你的优化
所有的优化都要用数据说话。Spark UI是你最好的朋友。重点看几个地方:
- Jobs页面:看每个Job的Stage数量和耗时。
- Stages页面:看每个Stage的Shuffle read/write大小、Task数量、耗时分布。
- Storage页面:看缓存使用了多少内存、命中率如何。
- SQL页面(如果用DataFrame):看物理执行计划,确认有没有发生不必要的Shuffle。
我见过太多人凭感觉调优,改了一堆参数结果更慢了。正确的做法是:先看UI定位瓶颈,再针对性优化,优化后再看UI验证效果。
8.4 关于依赖关系的一个常见误解
有人以为“窄依赖一定比宽依赖快”,这也不完全对。窄依赖确实避免了Shuffle,但如果窄依赖链太长,每个Task要处理的数据量可能很大,反而容易OOM。而且窄依赖链太长会导致单个Task的执行时间过长,影响整个Stage的完成时间。
所以合理的做法是:在窄依赖链中间适当“断一下”,比如加一个repartition或者cache,把长链拆成短链,提高并行度和容错性。当然,这会引入Shuffle或内存开销,需要权衡。
8.5 最后分享一个实用技巧
如果你不确定某个操作是宽依赖还是窄依赖,可以在Spark Shell里做个小实验:
val rdd = sc.parallelize(1 to 100, 4) val rdd2 = rdd.map(_ * 2) println(rdd2.dependencies) // 打印依赖类型 val rdd3 = rdd2.groupBy(_ % 3) println(rdd3.dependencies) // 打印依赖类型rdd2.dependencies会显示org.apache.spark.OneToOneDependency(窄依赖),rdd3.dependencies会显示org.apache.spark.ShuffleDependency(宽依赖)。这个方法比查文档快多了,而且能加深你对依赖关系的直观理解。
我在实际使用中发现,真正把依赖关系吃透之后,看Spark UI的DAG图会变得非常清晰。你能一眼看出哪些Stage之间有Shuffle、哪些操作被合并到了同一个Stage、瓶颈在哪个环节。这种“看得懂执行计划”的能力,是从会写Spark到会调Spark的关键一步。