Spark的宽依赖与窄依赖 - 实践

Spark的宽依赖与窄依赖 - 实践

RDD的计算图-血统图(Lineage Graph)RDD上的计算表示成血统图(Lineage Graph):即表示RDD之上所做操作的有向无环图。Spark通过这些血统图/有向无环图来表示RDD,并据此进行优化。

RDD的具体表示(四部分)组成部分说明分区数据集的原子划分,每个计算节点有一或多个依赖用来对RDD和其上的分区与派生它的RDD(Parent RDD)之间的关系进行建模函数基于父RDD对数据集进行计算的操作函数元数据关于RDD的分区策略及数据存放位置的元数据RDD依赖与ShuffleRDD依赖关系表明了数据必须经由网络移动(即shuffle)的时机。Transformations操作导致shuffle,且Transformations产生依赖。

两种依赖关系的定义窄依赖:父RDD的每个分区只被子RDD的一个分区所使用。

1个子RDD的分区对应于1个父RDD的分区(如map、filter、union等算子);1个子RDD的分区对应于N个父RDD的分区(如co-partioned join,协同划分的join)。宽依赖:父RDD的每个分区都可能被多个子RDD分区所使用。

1个父RDD分区对应非全部多个子RDD分区(如groupByKey、reduceByKey、sortByKey);1个父RDD分区对应所有子RDD分区(如未经协同划分的join)。为什么要有宽窄依赖?窄依赖的价值:支持在同一个集群Executor上以pipeline(管道)形式顺序执行多条命令,例如执行map后紧接着执行filter。分区内计算收敛,无需依赖所有分区数据,可在不同节点并行计算;失败恢复更高效,只需重新计算丢失的父分区(Parent Partition)。宽依赖的必要性:虽需等待父RDD全部分区数据就绪才能计算,且可能涉及跨节点数据传输(类似MapReduce操作),但能支持分组、排序、非协同划分join等复杂场景——这些场景必须聚合多分区数据才能完成计算,是宽依赖存在的核心原因。依赖关系的特性对比特性窄依赖宽依赖数据流向父分区→单个子分区(一对一或多对一)父分区→多个子分区(一对多)网络传输无需shuffle,可在单个节点内完成需要shuffle,依赖跨节点数据传输计算效率支持pipeline执行,效率高需等待父RDD全部分区就绪,效率较低容错恢复仅需重算对应父分区,恢复快,数据利用率100%需重算多个父分区,恢复慢,存在冗余计算可视化依赖关系示例假设存在以下RDD转换流程:

原始RDD(2个分区) → filter(窄依赖) → map(窄依赖) → groupByKey(宽依赖) → 结果RDD(2个分区)

对应的依赖关系示意图:

父RDD分区0 → 子RDD分区0(filter) → 子RDD分区0(map) → 子RDD分区0(groupByKey)

父RDD分区1 → 子RDD分区1(filter) → 子RDD分区1(map) → 子RDD分区1(groupByKey)

其中filter和map属于窄依赖(每个父分区仅对应一个子分区);groupByKey属于宽依赖(父分区数据会分发到多个子分区)。Transformations操作与依赖类型对应表依赖类型对应操作窄依赖map、mapValues、flatMap、filter、mapPartitions、mapPartitionsWithIndex、union、join(父RDD是hash-partitioned)宽依赖groupByKey、join(父RDD不是hash-partitioned)、partitionBy、cogroup、groupWith、leftOuterJoin、rightOuterJoin、reduceByKey、combineByKey、distinct、intersection、repartition、coalesce注意:join既有可能是宽依赖,也有可能是窄依赖(窄依赖的join可能因groupBy之后做了cache操作)。

确定依赖类型的方法通过dependencies方法:

返回Dependency对象序列,供Spark调度器内部使用:

// Scala示例:查看RDD依赖类型

val rdd = sc.parallelize(1 to 10).map(_ * 2).groupBy(_ % 3)

rdd.dependencies.foreach { dep =>

println(dep.getClass.getSimpleName) // 输出:ShuffleDependency(宽依赖)

}

通过toDebugString方法:

可视化打印RDD的血统图及调度信息(缩进分组表示stage):

// Scala示例:打印血统图

val rdd = sc.parallelize(1 to 10).filter(_ > 5).map(_ * 2)

println(rdd.toDebugString)

// 输出(窄依赖操作在同一stage):

// (8) MapPartitionsRDD[2] at map at :25 []

// | FilteredRDD[1] at filter at :25 []

// | ParallelCollectionRDD[0] at parallelize at :25 []

DAG与Stage划分DAG的形成:

RDD之间的依赖关系形成DAG(有向无环图)。在Spark作业调度系统中,调度需先判断任务间的因果依赖关系(部分任务需先执行,后续依赖任务才能执行),且任务无循环依赖,因此适合用DAG表示。

Stage划分依据:

以宽依赖(shuffle依赖)为边界,每个宽依赖会将DAG拆分为新的Stage。因shuffle依赖需等父RDD全部分区数据可读才能计算,Spark设计为让父RDD将结果写在本地,写完后通知子RDD,这种特性决定了宽依赖必须作为Stage划分的边界。

Stage执行逻辑:

第一个Stage:需将结果shuffle到本地(如groupByKey先聚合某个key的所有记录,此汇聚过程即shuffle);第二个Stage:读入shuffle后的本地数据,执行后续计算。Stage 划分与 Shuffle 的对应关系:

Shuffle 过程存在“先写后读”的强依赖——必须等前一个 Stage 所有任务的 Shuffle Write 全部完成(所有临时文件就绪),后一个 Stage 的 Shuffle Read 才能开始拉取数据,两者无法并行执行,因此 Spark 必须以 Shuffle 为界,将前后操作拆分为两个独立的 Stage,按“先上游 Stage 再下游 Stage”的顺序调度执行。

而窄依赖操作(如 map → filter)因无 Shuffle,可在同一个 Stage 内以“流水线(Pipeline)”方式执行(一个数据元素从 map 到 filter 无需落地磁盘,直接在内存中连续处理),无需拆分 Stage。

阶段(Stage)核心操作类比 MapReduce作用前一个 Stage(Shuffle 上游)Shuffle Write(写 shuffle 数据)Map 端执行到 Shuffle 依赖前的所有窄依赖操作(如 map、filter),最后将计算结果按 Key 分区,写入本地磁盘的 shuffle 临时文件(供下游读取)。例:groupByKey 中“聚合本地 Key 记录”的操作,就属于这个阶段的 Shuffle Write。后一个 Stage(Shuffle 下游)Shuffle Read(读 shuffle 数据)Reduce 端先从上游所有节点的 Shuffle Write 临时文件中,拉取属于当前分区的 Key 数据(跨节点网络传输),再执行后续窄依赖操作(如 reduce 聚合、filter)。例:groupByKey 中“汇总所有节点同一 Key 数据并最终聚合”的操作,就属于这个阶段的 Shuffle Read 之后的计算。血统图与容错性(对优化的帮助)血统图是Spark容错性的关键,其借助函数式编程思想实现容错:

RDD是不可变的;使用map、flatMap、filter等高阶函数对不可变数据进行函数式变换;基于父RDD对数据集计算所做的变换函数,也是血统图中RDD构成的一部分。当RDD分区丢失(如节点故障)时,Spark通过血统图重新计算恢复,两种依赖的恢复差异直接影响效率:

窄依赖:因父RDD一个分区只对应一个子RDD分区,只需重算对应父分区,数据利用率100%,且不同节点可并行恢复;宽依赖:父RDD分区对应多个子RDD分区,重算时仅部分数据用于恢复丢失子分区,存在冗余计算;极端情况下,子RDD分区来自所有父分区,需重算全部父分区,恢复成本极高。区分宽窄依赖的核心优化价值:

窄依赖支持节点内pipeline计算,减少数据传输;宽依赖需针对性优化shuffle(如调整分区数),降低跨节点开销;指导Stage划分与资源分配,让窄依赖操作聚合在同一Stage,提升并行效率;容错时差异化处理:窄依赖优先并行重算,宽依赖需尽量减少冗余计算(如优先复用缓存数据)。

相关推荐