<Zhuuu_ZZ>Spark(四)分布式计算原理

本文深入探讨Spark的分布式计算原理,包括宽依赖与窄依赖的概念及优化,DAG的工作机制,如何划分Stage,以及Shuffle过程。此外,还介绍了RDD的持久化策略cache和persist,以及RDD共享变量广播变量和累加器的使用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一 宽依赖和窄依赖

1、宽窄依赖含义

  • Spark中RDD的高效与DAG(有向无环图)有着很大的关系,在DAG调度中需要对计算过程划分stage,而划分依据就是RDD之间的依赖关系。针对不同的转换函数,RDD之间的依赖关系分类窄依赖(narrow dependency)和宽依赖(wide dependency, 也称 shuffle dependency)
  • 窄依赖是指父RDD的每个分区只被子RDD的一个分区所使用,子RDD分区通常对应有限个父RDD分区(O(1),与数据规模无关)
  • 相应的,宽依赖是指父RDD的每个分区都可能被多个子RDD分区所使用,子RDD分区通常对应所有的父RDD分区(O(n),与数据规模有关)
  • 具体可以查看下图所示

在这里插入图片描述

2、窄依赖的优化有利性

  • 相比于宽依赖,窄依赖对优化很有利 ,主要基于以下两点:

    • 宽依赖往往对应着shuffle操作,需要在运行过程中将同一个父RDD的分区传入到不同的子RDD分区中,中间可能涉及多个节点之间的数据传输;而窄依赖的每个父RDD的分区只会传入到一个子RDD分区中,通常可以在一个节点内完成转换。

    • 当RDD分区丢失时(某个节点故障),spark会对数据进行重算。

      • 对于窄依赖,由于父RDD的一个分区只对应一个子RDD分区,这样只需要重算和子RDD分区对应的父RDD分区即可,所以这个重算对数据的利用率是100%的;
      • 对于宽依赖,重算的父RDD分区对应多个子RDD分区,这样实际上父RDD 中只有一部分的数据是被用于恢复这个丢失的子RDD分区的,另一部分对应子RDD的其它未丢失分区,这就造成了多余的计算;更一般的,宽依赖中子RDD分区通常来自多个父RDD分区,极端情况下,所有的父RDD分区都要进行重新计算。
      • 如下图所示,b1分区丢失,则需要重新计算a1,a2和a3,这就产生了冗余计算(a1,a2,a3中对应b2的数据)。
        在这里插入图片描述
  • 总结:首先,窄依赖允许在一个集群节点上以流水线的方式(pipeline)计算所有父分区。例如,逐个元素地执行map、然后filter操作;而宽依赖则需要首先计算好所有父分区数据,然后在节点之间进行Shuffle,这与MapReduce类似。第二,窄依赖能够更有效地进行失效节点的恢复,即只需重新计算丢失RDD分区的父分区,而且不同节点之间可以并行计算;而对于一个宽依赖关系的Lineage图,单个节点失效可能导致这个RDD的所有祖先丢失部分分区,因而需要整体重新计算。所以也就是说对于窄依赖,它的父RDD的重算都是必须的,不会产生冗余。

3、款窄依赖算子

  • 窄依赖算子:map, flatMap, filter, union, join(父RDD是hash-partitioned ), mapPartitions, mapValues
  • 宽依赖算子:distinct, …ByKey, join(父RDD不是hash-partitioned), partitionBy,groupBy

4、WordCount运行中的宽窄依赖

在这里插入图片描述

  • 宽依赖对应Shuffle过程,由此产生另一个Stage。

二 DAG(有向无环图)工作原理

1、有向无环图

  • 根据RDD之间的依赖关系,形成一个DAG(有向无环图)
  • DAGScheduler将DAG划分为多个Stage
    • 划分依据:是否发生宽依赖(Shuffle)
    • 划分规则:从后往前,遇到宽依赖切割为新的Stage
    • 每个Stage由一组并行的Task组成
      在这里插入图片描述

2、划分Stage

  • 划分Stage的必要性
    • 移动计算,而不是移动数据
    • 保证一个Stage内不会发生数据移动
      在这里插入图片描述
  • 分析上图
    • A—>B是宽依赖,对应Stage1—>Stage3
    • B—>G是窄依赖,对应Stage3内部过程
    • C—>D,D—>F,E—>F都是是窄依赖,对应Stage2内部过程
    • F—>G是宽依赖,对应Stage2—>Stage3

3、Shuffle过程

  • 在分区之间重新分配数据
    • 父RDD中同一分区中的数据按照算子要求重新进入子RDD的不同分区中
    • 中间结果写入磁盘
    • 由子RDD拉取数据,而不是由父RDD推送
    • 默认情况下,Shuffle不会改变分区数量
      在这里插入图片描述
      在这里插入图片描述

4、Shuffle实践

  • 比较下方两段代码
sc.textFile("hdfs:/data/test/input/names.txt")
.map(name=>(name.charAt(0),name))
.groupByKey()
.mapValues(names=>names.toSet.size)
.collect()
sc.textFile("hdfs:/data/test/input/names.txt")
.distinct(numPartitions=6)
.map(name=>(name.charAt(0),1))
.reduceByKey(_+_)
.collect()
  • 结论:
    • 第一段代码只有一个宽依赖算子groupByKey,所以只有一个Shuffle过程,那么也就只有两个Stage;
    • 第二段代码有两个宽依赖算子distinct和reduceByKey,所以产生两个Shuffle过程,会得到三个Stage;
    • Shuffle过程极其耗费资源,所以虽然两段代码的最后结果是一致的,但是第一段代码更节省资源,对优化更有利。

5、Spark的Job调度

  • 集群(Standalone|Yarn)
    • 一个Spark集群可以同时运行多个Spark应用
  • 应用
    • 我们所编写的完成某些功能的程序
    • 一个应用可以并发的运行多个Job
  • Job
    • Job对应着我们应用中的行动算子,每次执行一个行动算子,都会提交一个Job
    • 一个Job由多个Stage组成
  • Stage
    • 一个宽依赖做一次阶段的划分
    • 阶段的个数=宽依赖个数+1
    • 一个Stage由多个Task组成
  • Task
    • 每一个阶段的最后一个RDD的分区数,就是当前阶段的Task个数

三 RDD持久化之cache&persist&checkpoint

1、cache和persist

  • cache和persist都是用于将一个RDD进行缓存的,这样在之后使用的过程中就不需要重新计算了,可以大大节省程序运行时间。
  • 设置cache或者persist后,都是遇到第一个行动算子开始生效,第一个行动算子结束后完成生效,当遇到第二个行动算子才能看出效果。
  • 缓存本身耗费一定时间,所以完成第一个行动算子设置缓存要比不设置缓存耗费时间长。
  • cache源码
/**
   * Persist this RDD with the default storage level (`MEMORY_ONLY`).
   */
  def cache(): this.type = persist()
  def persist(): this.type = persist(StorageLevel.MEMORY_ONLY)
  • persist源码
 def persist(newLevel: StorageLevel): this.type = {
    if (isLocallyCheckpointed) {
      // This means the user previously called localCheckpoint(), which should have already
      // marked this RDD for persisting. Here we should override the old storage level with
      // one that is explicitly requested by the user (after adapting it to use disk).
      persist(LocalRDDCheckpointData.transformStorageLevel(newLevel), allowOverride = true)
    } else {
      persist(newLevel, allowOverride = false)
    }
  }
  • 从上面代码总结区别:cache()调用了persist(),但cache只有一个默认的缓存级别MEMORY_ONLY ,而persist可以根据情况设置其它的缓存级别。也就是说rdd.cache()等价于rdd.persist(StorageLevel.MEMORY_ONLY),而persist还有MEMORY_AND_DISK,DISK_ONLY等缓存级别。
  • 缓存应用场景
    • 从文件加载数据之后,因为重新获取文件成本较高
    • 经过较多的算子变换之后,重新计算成本较高
    • 单个非常消耗资源的算子之后
  • 使用注意事项
    • cache()或persist()后不要再有其他算子
    • cache()或persist()遇到Action算子完成后才生效,也就是说它遇到第二个Action算子才会看到效果,如运算速度变快等。
  • 缓存实践
package nj.zb.kb09.gaoji

import org.apache.spark.rdd.RDD
import org.apache.spark.storage.StorageLevel
import org.apache.spark.{SparkConf, SparkContext}


object CacheDemo {
  def main(args: Array[String]): Unit = {
    val conf: SparkConf = new SparkConf().setMaster("local[*]").setAppName("cache")
    val sc = new SparkContext(conf)
    val rdd1: RDD[String] = sc.textFile("in/users.csv")
    rdd1.cache() //放到内存中缓存,遇到第一个行动算子完成后生效,也就是遇到第二个行动算子才会更快
               //仅等价于rdd1.persist(StorageLevel.MEMORY_ONLY)
              //而persist还有其他缓存级别可使用。
    println("将rdd1缓存后,求读取count时间,即遇到第一个行动算子时缓存开始生效")
    var start: Long = System.currentTimeMillis()
    println(rdd1.count())
    var end: Long = System.currentTimeMillis()
 println(end-start)
    println("完成第二个行动算子时耗费的时间")
     start= System.currentTimeMillis()
    println(rdd1.count())
    end = System.currentTimeMillis()
    println(end-start)

    println("设置缓存失效,完成第三个行动算子耗费时间")
        rdd1.unpersist()
    start= System.currentTimeMillis()
    println(rdd1.count())
    end = System.currentTimeMillis()
    println(end-start)

  println("-------------persist(StorageLevel.MEMORY_ONLY)-------------")
    rdd1.persist(StorageLevel.MEMORY_ONLY)  //等价于rdd1.cache()
    rdd1.count()
    start=System.currentTimeMillis()
    println(rdd1.count())
    end=System.currentTimeMillis()
    println(end-start)
println("-----persist(StorageLevel.DISK_ONLY_2)-----")
    rdd1.unpersist()
    rdd1.persist(StorageLevel.DISK_ONLY_2) //缓存两份到硬盘
    rdd1.count()
    start=System.currentTimeMillis()
    println(rdd1.count())
    end=System.currentTimeMillis()
    println(end-start)
  }
}
/*
将rdd1缓存后,求读取count时间,即遇到第一个行动算子时缓存开始生效
152840
376
完成第二个行动算子时耗费的时间
152840
31
设置缓存失效,完成第三个行动算子耗费时间
152840
43
-------------persist(StorageLevel.MEMORY_ONLY)-------------
152840
9
-----persist(StorageLevel.DISK_ONLY_2)-----
152840
29

2、checkpoint

  • Cache缓存只是将数据保存起来,不切断血缘依赖。Checkpoint检查点切断血缘依赖。
  • Cache缓存的数据通常存储在磁盘、内存等地方,可靠性低。Checkpoint的数据通常存储在HDFS等容错、高可用的文件系统,可靠性高。
  • 建议对checkpoint()的RDD使用Cache缓存,这样checkpoint的job只需从Cache缓存中读取数据即可,否则需要再从头计算一次RDD。
  • 如果使用完了缓存,可以通过unpersist()方法释放缓存
  • SparkContext被销毁后,检查点数据不会被删除,因为其不在内存中,落地为文件了。
import org.apache.spark.rdd.RDD
import org.apache.spark.{SparkConf, SparkContext}

object CheckPointDemo {
  def main(args: Array[String]): Unit = {
    val conf: SparkConf = new SparkConf().setMaster("local[*]").setAppName("cache")
    val sc = new SparkContext(conf)
    sc.setCheckpointDir("file:///E:/KB09checkpoint") //设置检查路径
    val rdd1: RDD[(String, Int)] = sc.parallelize(List(("a",1),("a",2),("a",3),("b",4),("b",5)))
    rdd1.checkpoint()
    rdd1.collect()  //遇到动作算子,生成快照
    println(rdd1.isCheckpointed)
    println(rdd1.getCheckpointFile)
  }
}
/*
true
Some(file:/E:/KB09checkpoint/89288478-b267-4eb4-b36b-a1c8ea432389/rdd-0)

在这里插入图片描述

四 RDD共享变量

1、广播变量broadcast

  • 允许开发者将一个只读变量(Driver端)缓存到每个节点(Executor)上,而不是每个任务传递一个副本
  • Driver端变量在每个Executor上的每个Task保存一个变量副本
  • Driver端广播变量在每个Executor只保存一个变量副本
val broadcastVar=sc.broadcast(Array(1,2,3))  //定义广播变量
broadcastVar.value 		//访问方式
import org.apache.spark.broadcast.Broadcast
import org.apache.spark.rdd.RDD
import org.apache.spark.{SparkConf, SparkContext}

object BroadcastDemo {
  def main(args: Array[String]): Unit = {
    val conf: SparkConf = new SparkConf().setMaster("local[*]").setAppName("cache")
    val sc = new SparkContext(conf)
    //广播变量,在每个executor执行一次
    val arr=Array("hello","hi","good afternoon")
    val hei: Broadcast[Array[String]] = sc.broadcast(arr)

    val rdd1: RDD[(Int, String)] = sc.parallelize(List((1,"zhangsan"),(2,"lisi"),(3,"wangwu")))
    val rdd2: RDD[(Int, String)] = rdd1.mapValues(x => {
      println(x)

      println(arr.mkString(","))  //数组打印需要mkString
      arr(1)+" : "+x         //调用常规变量,事先不声明,每个Task得到一个变量副本

      println(hei.value.mkString(","))
      hei.value(0) + " : " + x   //调用广播变量,事先声明,每个Executor的得到一个变量副本,更节省资源。
    })
    rdd2.foreach(println)
  }
}

2、累加器accumulator

  • 只允许added操作,常用于实现计数
import org.apache.spark.{Accumulator, SparkConf, SparkContext}

object AccumulatorDemo {
  def main(args: Array[String]): Unit = {
    val conf: SparkConf = new SparkConf().setMaster("local[*]").setAppName("accumulator")
    val sc = new SparkContext(conf)
    val acumValue: Accumulator[Int] = sc.accumulator(0,"aaaaa")
    sc.makeRDD(List(1,2,3,4)).foreach(x=>{acumValue+=x;acumValue})
        println(acumValue.value)
  }
}
/*
10

五 RDD分区设计

1、分区设计原则

  • 分区大小限制为2GB
  • 分区太少
    • 不利于并发
    • 更容易受数据倾斜影响
    • groupBy, reduceByKey, sortByKey等内存压力增大
  • 分区过多
    • Shuffle开销越大
    • 创建任务开销越大
  • 经验
    • 每个分区大约128MB
    • 如果分区小于但接近2000,则设置为大于2000

2、分区中的数据倾斜

  • 指分区中的数据分配不均匀,数据集中在少数分区中
    • 严重影响性能
    • 通常发生在groupBy,join等之后
      在这里插入图片描述
  • 解决方案
    • 使用新的Hash值(如对key加盐)重新分区

六 加载常用外部数据源

1、装载CSV数据源

  • 文件预览
    在这里插入图片描述
  • 操作
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.{DataFrame, SparkSession}
import org.apache.spark.{SparkConf, SparkContext}

object CsvDemo {
 def main(args:Array[String]):Unit={
 //1.创建SparkConf并设置App名称
 val conf: SparkConf = new SparkConf().setAppName("SparkCoreTest").setMaster("local[*]")
 //2.创建SparkContext,该对象是提交Spark App的入口
 val sc: SparkContext = new SparkContext(conf)
 val lines: RDD[String] = sc.textFile("in/users.csv")

   println("总行数:",lines.count()) //逗号居然也可以
   println("一、通过分区索引来,认为第一行分到分区0,其实逻辑上是有问题的,凭什么第一行就到分区0,除非自定义分区规则")
   val fields: RDD[Array[String]] = lines.mapPartitionsWithIndex((index, value) => { //各分区并行操作
     if (index == 0)   //当为0分区时
       value.drop(1)   //删除该分区内第一行数据,即字段名
     else
       value           //否则返回原数据
   }).map(x => x.split(","))
   println("fields:"+fields.count())

   println("二、采用filter过滤器就更严谨,而且如果不合理的数据有多条,用上面分区索引很难做到")
val value: RDD[String] = lines.filter(v=>v.startsWith("user_id")==false)  //元素为每一行,该元素不以user_id开头就保留下来
   val field: RDD[Array[String]] = value.map(x=>x.split(","))
  println(field.count())

   println("三、采用SparkSession加载文件")
   val spark: SparkSession = SparkSession.builder().config(conf).getOrCreate()
   println("1.使header为true,表示第一行为表头")
   val df: DataFrame = spark.read.format("csv").option("header",true).load("in/users.csv")
   df.printSchema()
   df.select("user_id","locale","birthyear").show(3)

    println("2.使header为false,表示系统生成表头")
   val df1: DataFrame = spark.read.format("csv").option("header",false).load("in/users.csv")
   df1.printSchema()
   df1.show(3)

   println("3.不改变列数,只改变列名")
   val frame: DataFrame = df1.withColumnRenamed("_c0","id")
   println("4.列名相同用来修改数据类型,前后列名不相同则会数据拿过来再添加一列")
   val frame1: DataFrame = frame.withColumn("id",frame.col("id").cast("long"))
     frame1.printSchema()
   println("5.复制数据添加一列并删除原有的列")
   val frame3: DataFrame = df.withColumn("id",df.col("user_id").cast("long")).drop("user_id")
   frame3.printSchema()
 }
}
/*
(总行数:,152840)
一、通过分区索引来,认为第一行分到分区0,其实逻辑上是有问题的,凭什么第一行就到分区0,除非自定义分区规则
fields:152839
二、采用filter过滤器就更严谨,而且如果不合理的数据有多条,用上面分区索引很难做到
152836
三、采用SparkSession加载文件
1.使header为true,表示第一行为表头
root
 |-- user_id: string (nullable = true)
 |-- locale: string (nullable = true)
 |-- birthyear: string (nullable = true)
 |-- gender: string (nullable = true)
 |-- joinedAt: string (nullable = true)
 |-- location: string (nullable = true)
 |-- timezone: string (nullable = true)

+----------+------+---------+
|   user_id|locale|birthyear|
+----------+------+---------+
|3197468391| id_ID|     1993|
|3537982273| id_ID|     1992|
| 823183725| en_US|     1975|
+----------+------+---------+
only showing top 3 rows

2.使header为false,表示系统生成表头
root
 |-- _c0: string (nullable = true)
 |-- _c1: string (nullable = true)
 |-- _c2: string (nullable = true)
 |-- _c3: string (nullable = true)
 |-- _c4: string (nullable = true)
 |-- _c5: string (nullable = true)
 |-- _c6: string (nullable = true)

+----------+------+---------+------+--------------------+----------------+--------+
|       _c0|   _c1|      _c2|   _c3|                 _c4|             _c5|     _c6|
+----------+------+---------+------+--------------------+----------------+--------+
|   user_id|locale|birthyear|gender|            joinedAt|        location|timezone|
|3197468391| id_ID|     1993|  male|2012-10-02T06:40:...|Medan  Indonesia|     480|
|3537982273| id_ID|     1992|  male|2012-09-29T18:03:...|Medan  Indonesia|     420|
+----------+------+---------+------+--------------------+----------------+--------+
only showing top 3 rows

3.不改变列数,只改变列名
4.列名相同用来修改数据类型,前后列名不相同则会数据拿过来再添加一列
root
 |-- id: long (nullable = true)
 |-- _c1: string (nullable = true)
 |-- _c2: string (nullable = true)
 |-- _c3: string (nullable = true)
 |-- _c4: string (nullable = true)
 |-- _c5: string (nullable = true)
 |-- _c6: string (nullable = true)

5.复制数据添加一列并删除原有的列
root
 |-- locale: string (nullable = true)
 |-- birthyear: string (nullable = true)
 |-- gender: string (nullable = true)
 |-- joinedAt: string (nullable = true)
 |-- location: string (nullable = true)
 |-- timezone: string (nullable = true)
 |-- id: long (nullable = true)

2、装载json数据源

  • 数据准备
    在这里插入图片描述
  • 使用SparkContext
object JsonDemo {
  def main(args: Array[String]): Unit = {
    //1.创建SparkConf并设置App名称
    val conf: SparkConf = new SparkConf().setAppName("SparkCoreTest").setMaster("local[*]")
    //2.创建SparkContext,该对象是提交Spark App的入口
    val sc: SparkContext = new SparkContext(conf)
    val lines: RDD[String] = sc.textFile("in/users.json")
    println("--打印每行数据--")
     lines.collect().foreach(println)
    import  scala.util.parsing.json._  //导入scala内置JSON库
    val rdd: RDD[Option[Any]] = lines.map(x=>JSON.parseFull(x))
    println("--JSON解析打印成map键值对对象--")
    rdd.collect().foreach(println)
/*
--打印每行数据--
{"name":"Michael"}
{"name":"Andy","Age":30}
{"name":"Justin","Age":19}
--JSON解析打印成map键值对对象--
Some(Map(name -> Michael))
Some(Map(name -> Andy, Age -> 30.0))
Some(Map(name -> Justin, Age -> 19.0))
  • 使用SparkSession
 val spark: SparkSession = SparkSession.builder().config(conf).getOrCreate()
    val df: DataFrame = spark.read.format("json").option("header",true).load("in/users.json")
    df.printSchema()
    println("修改列名")
    val df2: DataFrame = df.withColumnRenamed("Age","age")
    df2.printSchema()
    df2.select("age","name").show()
    println("修改列名和类型")
    val df3: DataFrame = df.withColumn("age",df.col("Age").cast("int"))
    df3.printSchema()
    println("int转string可以正常打印")
    val df4: DataFrame = df.withColumn("age",df.col("Age").cast("string"))
     df4.printSchema()
    df4.show()
    println("string转int原先是非数字String,只能用null来代替")
    val df5: DataFrame = df.withColumn("Name",df.col("name").cast("int"))
  df5.printSchema()
    df5.show()
 println("withColumn修改,列名如果相同则列数不变,不同则添加一列新列")
    val df6: DataFrame = df.withColumn("age1",df.col("age")+5).withColumn("name1",df.col("name"))
    df6.printSchema()
    df6.show()
/*
root
 |-- Age: long (nullable = true)
 |-- name: string (nullable = true)

修改列名
root
 |-- age: long (nullable = true)
 |-- name: string (nullable = true)

+----+-------+
| age|   name|
+----+-------+
|null|Michael|
|  30|   Andy|
|  19| Justin|
+----+-------+

修改列名和类型
root
 |-- age: integer (nullable = true)
 |-- name: string (nullable = true)

int转string可以正常打印
root
 |-- age: string (nullable = true)
 |-- name: string (nullable = true)

+----+-------+
| age|   name|
+----+-------+
|null|Michael|
|  30|   Andy|
|  19| Justin|
+----+-------+

string转int原先是非数字String,只能用null来代替
root
 |-- Age: long (nullable = true)
 |-- Name: integer (nullable = true)

+----+----+
| Age|Name|
+----+----+
|null|null|
|  30|null|
|  19|null|
+----+----+

withColumn修改,列名如果相同则列数不变,不同则添加一列新列
root
 |-- Age: long (nullable = true)
 |-- name: string (nullable = true)
 |-- age1: long (nullable = true)
 |-- name1: string (nullable = true)

+----+-------+----+-------+
| Age|   name|age1|  name1|
+----+-------+----+-------+
|null|Michael|null|Michael|
|  30|   Andy|  35|   Andy|
|  19| Justin|  24| Justin|
+----+-------+----+-------+
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值