Spark 性能优化之Map-Join

Spark 性能优化之Map-Join

1. Spark Stage的划分
1.1 RDD的依赖关系

RDD 和它依赖的 父RDD(s) 的关系有两种不同的类型,即:

  • 窄依赖(narrow dependency):每一个 父RDDPartition 最多被 子RDD 的一个 Partition 使用
  • 宽依赖(wide dependency):父RDDPartition 都可能被多个 子RDDPartition 使用
    在这里插入图片描述
    对于 mapfilter 形式的转换来说,它们只是将 Partition 的数据根据转换的规则进行转化,并不涉及其他的处理,可以简单地认为只是将数据从一个形式转换到另一个形式。对于 union ,只是将多个 RDD 合并成一个,父RDDPartition(s) 不会有任何的变化,可以认为只是把 父RDDPartition(s) 简单进行复制与合并。对于 join ,如果每个 Partition 仅仅和已知的、特定的 Partition 进行join,那么这个依赖关系也是窄依赖。对于这种有规则的数据的 join ,并不会引入昂贵的 Shuffle。对于窄依赖,由于 RDD 每个 Partition 依赖固定数量的 父RDD(s)Partition(s) ,因此可以通过一个计算任务来处理这些 Partition ,并且这些 Partition 相互独立,这些计算任务也就可以并行执行了。

对于 groupByKey子RDD 的所有 Partition(s) 会依赖于 父RDD 的所有 Partition(s)子RDDPartition父RDD 的所有 Partition Shuffle 的结果,因此这两个 RDD 是不能通过一个计算任务来完成的。同样,对于需要 父RDD 的所有 Partition 进行 join 的转换,也是需要 Shuffle

所有的依赖都要实现 trait Dependency[T]

abstract class Dependency[T] extends Serializable {
    def rdd: RDD[T]
}

其中 rdd 就是依赖的 父RDD

1.1.1 窄依赖的实现

对于窄依赖的实现是:

abstract class NarrowDependency[T](_rdd: RDD[T]) extends Dependency[T] {
    // 返回子RDD的partitionID依赖的所有的父RDD的Partition(s)
    def getParents(partitionId: Int): Seq[Int]
    override def rdd: RDD[T] = _rdd
}

现在有两种窄依赖的具体实现,一种是一对一的依赖,即 OneToOneDependency , 另一种是范围的依赖,即 RangeDependency

  • OneToOneDependency
class OneToOneDependency[T](rdd: RDD[T]) extends NarrowDependency[T](rdd) {
    // RDD仅仅依赖于parent RDD相同ID的Partition
    override def getParents(partitionId: Int): List(partitionId)
}
  • RangeDependency

RangeDependency 仅仅被 org.apache.spark.rdd.UnionRDD 使用。UnionRDD 是把多个 RDD 合成一个 RDD ,这些 RDD 是被拼接而成,即每个 父RDDPartition 的相对顺序不会变,只不过每个 父RDDUnionRDD 中的 Partition 的起始位置不同。

override def getParents(partitionId: Int) = {
    // inStart是父RDD中Partition的起始位置,
    // outStart是在UnionRDD中的起始位置,
    // length就是父RDD中Partition的数量
    if(partitionId >= outStart && partitionId < outStart + length) {
        List(partitionId - outStart + inStart)
    } else {
        Nil
    }
}
1.1.2 宽依赖的实现

宽依赖的实现只有一种: ShuffleDependency子RDD 依赖于 父RDD 的所有 Partition ,因此需要 Shuffle 过程:

class ShuffleDependency[K, V, C](
    @transient _rdd: RDD[_ <: Product2[K, V]],
    val partitioner: Partitioner,
    val serializer: Option[Serializer] = None,
    val keyOrdering: Option[Ordering[K]] = None,
    val aggregator: Option[Aggregator[K, V, C]] = None,
    val mapSideCombine: Boolean = false) extends Dependency[Product2[K, V]] {

	override def rdd = _rdd.asInstanceOf[RDD[Product2[K, V]]]
	//获取新的shuffleId
	val shuffleId: Int = _rdd.context.newShuffleId()
	//向ShuffleManager注册Shuffle的信息
	val shuffleHandle: ShuffleHandle =
	_rdd.context.env.shuffleManager.registerShuffle(shuffleId, _rdd.partitions.size, this)
    _rdd.sparkContext.cleaner.foreach(_.registerShuffleForCleanup(this))
}

宽依赖支持两种 Shuffle Manager ,即 org.apache.spark.shuffle.hash.HashShuffleManager (基于 HashShuffle 机制)和 org.apache.spark.shuffle.sort.SortShuffleManager (基于排序的 Shuffle 机制)。

上述内容引用自 张安站《Spark技术内幕:深入解析Spark内核架构设计与实现原理》

1.2 Lineage(血统)与DAG中Stage的划分
1.2.1 Lineage

原始的 RDD(s) 通过一系列转换就形成了 DAGRDD 之间的依赖关系,包含了 RDD 由哪些 父RDD(s) 转换而来和它依赖 父RDD(s) 的哪些 Partitions ,是 DAG 的重要属性。借助这些依赖关系,DAG 可以认为这些 RDD 之间形成了 Lineage(血统) 。在考虑容错时,与其考虑如何持久化每一份数据,不如保存血统依赖和上游数据集,从而在下游数据集出现可用性问题时,利用血统依赖和上游数据集重算进行恢复。这是利用了函数(血统依赖)在给定参数(上游数据集)情况下,一定能够得到既定输出(下游数据集)的特性。

上述内容引用自 范东来 《Spark海量数据处理:技术详解与平台实战》

对于 Spark 的容错机制,顺带提一下 TachyonTachyon 包含两个维度的容错,一个是 Tachyon 集群的元数据的容错,它采用了类似于 HDFSName Node 的元数据容错机制,即将元数据保存到一个 Image 文件,并且保存了元数据变化的编辑日志 (EditLog)。另外一个是 Tachyon 保存的数据的容错机制,这个机制类似于 RDD的LineageTachyon 会保留生成文件数据的 Lineage ,在数据丢失时会通过这个 Lineage 来恢复数据。如果是 Spark 的数据,那么在数据丢失时 Tachyon 会启动 SparkJob 来重算这部分内容。如果是 Hadoop 产生的数据,那么重新启动相应的 Map Reduce Job 就可以。现在 Tachyon 的容错机制的实现还处于开发阶段,并不推荐将这个机制应用于生产环境。不过,这并不影响 Spark 使用 Tachyon 。如果 Spark 保存到 Tachyon 的部分数据丢失,那么 Spark 会根据自有的容错机制来重算这部分数据。

在这里插入图片描述

上述内容引用自 张安站《Spark技术内幕:深入解析Spark内核架构设计与实现原理》

1.2.2 Stage的划分

用户提交的计算任务是一个由 RDD 构成的 DAG ,如果 RDD 在转换的时候需要做 Shuffle ,那么这个 Shuffle 的过程就将这个 DAG 分为了不同的阶段(即 Stage )。由于 Shuffle 的存在,不同的 Stage 是不能并行计算的,因为后面 Stage 的计算需要前面 StageShuffle 的结果。而一个 Stage 由一组完全独立的计算任务(即 Task )组成,每个Task的运算逻辑完全相同,只不过每个 Task 都会处理其所对应的 Partition。其中, Partition 的数量和 Task 的数量是一致的,即一个 Partition 会被该 Stage 的一个 Task 处理。

  • 划分依据:Shuffle

对于窄依赖,由于 RDD 每个 Partition 依赖固定数量的 父RDDPartition ,因此可以通过一个 Task 来处理这些 Partition ,而且这些 Partition 相互独立,所以这些 Task 可以并行执行。

对于宽依赖,由于需要 Shuffle ,因此只有所有的 父RDDPartition Shuffle 完成,新的 Partition 才会形成,这样接下来的 Task 才可以继续处理。

因此,宽依赖可以认为是 DAG 的分界线,或者说 Spark 根据宽依赖将 Job 划分为不同的阶段( Stage )。

在这里插入图片描述

Stage 的划分是从最后一个 RDD 开始的,也就是触发 Action 的那个 RDD 。上图就是 RDD G。首先,RDD G 会从 SparkContextrunJob 开始:

划分从 RDD G 的依赖开始, RDD G 依赖于两个 RDD ,一个是 RDD B ,一个是 RDD F 。其中先处理 RDD B 还是 RDD F 是随机的。这里首先处理 RDD B ,由于和 RDD B 的依赖是窄依赖,因此 RDD GRDD B 可以划分到 一个Stage(即Stage 3) 。再处理 RDD F ,由于这个依赖是宽依赖, RDD FRDD G 被划分到 不同的Stage(即Stage 2和Stage 3) ,其中 RDD F 所在的 Stage2RDD G 所在的 Stage3父Stage 。接下来处理 RDD B 的依赖,由于它依赖的 RDD A 是宽依赖,因此它们属于 不同的Stage(即Stage 1和Stage 3)。这样,RDD BRDD G 同属于 Stage 3 ,而这个 Stage 直接的 父Stage 有两个,就是 RDD ARDD F 分别属于的 两个Stage(即Stage1和Stage2) 。接下来的划分采用相同的逻辑,递归进行。最后,这个 DAG 被划分为三个 Stage ,即 RDD A 所在的Stage1RDD C、RDDD、RDD E、RDD F所在的Stage 2和RDD B及RDD G所在的Stage 3 。其中 Stage1Stage 2 是相互独立的,可以并发执行;Stage 3 依赖于 Stage 1Stage 2 ,只有 Stage 1Stage2 完成计算,它才可以开始计算。

上述内容引用自 张安站《Spark技术内幕:深入解析Spark内核架构设计与实现原理》

2. Reduce-Join和Map-Join
2.1 Reduce-Join 的原理

Reduce Join 包含Map、Shuffle、Reduce阶段,而 join 会在 reduce 阶段完成,故称为 Reduce Join

  • Map 阶段:读取源表的数据,Map 输出时候以 Join on 条件中的列为 key ,如果 Join 有多个关联键,则以这些关联键的组合作为 keyMap 输出的 valuejoin 之后所关心的列(即 select 或者 where 中需要用到的);同时在 value 中还会包含表的 Tag 信息,用于标明此 value 对应哪个表;

  • shuffle 阶段:根据 key 的值进行 hash ,并将 key/value 按照 hash 值推送至不同的 reduce 中,这样确保两个表中相同的 key 位于同一个 reduce 中。

  • reduce 阶段:通过 Tag 来判断每一个 value 是来自 table1 还是 table2 ,在内部分成2组,做集合笛卡尔积。

缺点:

  • shuffle 的网络传输和排序性能很低,reduce 端对2个集合做乘积计算,很耗内存,容易导致 OOM
  • 在进行 shuffle 的时候,必须将各个节点上相同的 key 拉取到某个节点上的一个 task 来进行处理,此时如果某个 key 对应的数据量特别大的话,就会发生数据倾斜。

原理图:

在这里插入图片描述

2.2 Map-Join 的原理

Map-Join 适用于有一份数据较小的连接情况。做法是把该小份数据直接全部加载到内存当中,按 Join关键字 建立索引。然后大份数据就作为 MapTask 的输入,对 map() 方法的每次输入都去内存当中直接进行匹配连接。然后把连接结果按 key 输出。这种方法要使用 Spark 中的 广播(Broadcast)功能 把小份数据分放到各个计算节点,每个 maptask 执行任务的节点都需要加载该数据到内存。由于 Join 是在 Map 阶段进行的,故称为 Map-Join

缺点:

  • 需要将小表建立索引,常用方式是建立Map表,在 Spark 中,可以通过 rdd.collectAsMap() 算子实现。但是 collectAsMap()key 重复时,后面的 value 会覆盖前面的,所以对于存在重复 key 的表,需做其他处理。另外,在 collectAsMap() 过程中,由于需要在 Driver 节点进行 collect ,所以需要保证 Driver 节点内存充足,可在 spark-commit 提交执行任务时,通过设置 driver-memory 调节 Driver 节点大小。
  • Map-Join 同时需要将经过 Map 广播到不同的 executor ,供 Task 获取数据进行连接并输出结果。所以 executor 也需要保证内存充足,可在 spark-commit 提交执行任务时,通过设置 --executor-cores 调节 Driver 节点大小。

在这里插入图片描述

3. Map-Join的手动实现

Sparkjoin 有三种实现,Shuffle Hash JoinBroadcast Hash Join 以及 Sort Merge Join 。其中 Shuffle Hash Join 属于 Reduce Join ,而 Broadcast Hash Join 属于 Map Join 。虽然Spark会根据数据的规模自动选择合适的算法,但有时候需要手动调整,以下为手动实现的 Map-Join

import session.implicits._
// table1: a列为key, b,c组合列为value, 此为小表, 需要调整为(key, value)形式
val table1 = sc.textFile(table1Path).map(
            line => {
                val lines = line.split("\t")
                val a = lines(0)
                val b = lines(1)
                val c = lines(2)
                val others = Array(b, c)
                (a, others.mkString(","))
            }
        ).cache()

// table2: a列为key, d列为value, 此为大表,
val table2 = sc.textFile(table2Path).map(
            line => {
                val lines = line.split("\t")
                val a = lines(0)
                val d = lines(1)
                (a, d)
            }
        ).cache()

val factor = (a: String) => { Array(a.split(",")(0), a.split(",")(1)) }
val StringToArrayUDF = udf(factor)

// 广播小表, sc为SparkContext实例, session.sparkContext
val table1Broad = sc.broadcast(table1.collectAsMap())

// toDF() 函数为隐式转换, 需要添加 import session.implicits._
val result = table2.mapPartitions(x => {
    // 获取广播变量
    val table1BroadValue: scala.collection.Map[String, String] = table1Broad.value
    for ((key, value) <- x if table1BroadValue.contains(key))
    	yield (key, table1BroadValue.get(key), value)
    // 列名为 (_1, _2, _3)
    // 列对应于 (a, (b, c), d)
    }).toDF().withColumn("cols", StringToArrayUDF($"_2"))
		.select(
            $"_1".as("a"),
            $"cols".getItem(0).as("b"),
            $"cols".getItem(1).as("c"),
            $"_3".as("d"),
        )
})
// 对result进行其他操作
result.show(20, truncate = false)
4. 性能优化
4.1 提高作业并行度

在作业并行程度不高的情况下,最有效的方式就是提高作业并行程度。在Spark作业划分中,一个Executor只能同时执行一个任务,一个计算任务的输入是一个分区(partition),因此改变并行程度只有一个办法就是提高同时运行Executor的个数。

但通常集群的资源总量是一定的,这样Executor数量增加,必然会导致单个Executor所分得的资源减少,这样的话,在每个分区不变的情况下,有可能会引起性能方面的问题,因此,我们可以增大分区数来降低每个分区的大小,从而避免这个问题。

RDD一开始的分区数与该份数据在HDFS上的数据块数量一致,后面可以通过coalesce与repartition算子进行重分区,这其实改变的是Map端的分区数,如果想改变Reduce端的分区数,有两个办法,一个是修改配置spark.default.parallelism,该配置设定所有Reduce端的分区数,会对所有Shuffle过程生效,另一个是直接在算子中将分区数作为参数传入,绝大多数算子都有分区数参数的重载版本,如groupByKey(600)等。在Shuffle过程中,Shuffle相关的算子会构建一个散列表,Reduce任务有时会因为这个表过大而造成内存溢出,这时就可以试着增大并行程度。

4.2 提高Shuffle性能
spark.shuffle.file.buffer
spark.reducer.maxSizeInFlight
spark.shuffle.compress
  • 第一个配置是Map端输出为中间结果的缓冲区大小,默认为32KB。

  • 第二个配置是Map端输出为中间结果的文件大小,默认为48 MB,该文件还会与其他文件进行合并。

  • 第三个配置是Map端输出是否开启压缩,默认开启。

缓冲区当然越大,写入性能越高,所以有条件可以增大缓冲区大小,提升Shuffle Write的性能。

Spark Shuffle会将中间结果写到spark.local.dir配置的目录下,可以将该目录配置多路磁盘目录,以提升写入性能。

另外一个办法是尽量减少shuffle或者降低shuffle数据量。

4.3 内存管理

Spark作业中内存主要有两个用途:计算和存储。计算是指在Shuffle、连接、排序和聚合等操作中用于执行计算任务的内存,而存储指的是用于跨集群缓存和传播数据的内存。

在Spark中,这两块共享一个统一的内存区域(M),如图所示,用计算内存时,存储部分可以获取所有可用内存,反之亦然,如有必要,计算内存也可以将数据从存储区移出,但会在总存储内存使用量下降到特定阈值(R)时才执行。换句话说,R决定了M内的一个分区,在这个分区中,数据不会被移出。由于实际情况的复杂性,存储区一般不会去占用计算区。

在这里插入图片描述

这样设计是为了使那些不使用缓存的作业可以尽可能地使用全部内存,而需要使用缓存的作业也会有一个区域始终用来缓存数据,这样用户就可以在不需要知道背后其复杂原理就自己根据实际内存需求来调节M与R的值以达到最好效果。下面是决定M与R的两个配置。

  • spark.memory.fraction:该配置表示M占JVM堆空间的比例,默认为0.6,剩下0.4用于存储用户数据结构、Spark中的内部元数据并防止在应对稀疏数据和异常大的数据时出现OutOfMemory的错误。
  • spark.memory.storageFraction:该配置表示R占M的比例,默认为0.5,这部分缓存的数据不会被移出。

Spark Executor除了堆内存以外,还有非堆内存空间,这个难度比较大,需要自己回收内存。

4.4 序列化

序列化是以时间换空间的一种内存取舍方式,其根本原因还是内存比较吃紧,我们可以优先选择对象数组或者基本类型而不是那些集合类型来实现自己的数据结构,fastutil包提供了与Java标准兼容的集合类型。除此之外,还应该避免使用大量小对象与指针嵌套的结构。我们可以考虑使用数据ID或者枚举对象来代替字符串键。

对于大对象来说,可以使用RDD的persist算子并选取MEMORY_ONLY_SER级别进行存储。最好选择序列化的方式进行存储。但反序列化会消耗大量时间。如果要用序列化,推荐使用Kyro格式。

4.5 JVM垃圾回收(GC)调优

通常来说,那种只读取RDD一次,然后对其进行各种操作的作业不太会引起垃圾回收(GC)问题。当Java需要将老对象释放而为新对象腾出空间时,需要追踪所有Java对象,然后在其中找出没有被使用的那些对象。GC的成本与Java对象数量成正比,因此使用较少对象的数据结构会大大减轻GC压力,如直接使用整型数组,而不选用链表。通常在出现GC问题的时候,序列化缓存是首先应该尝试的方法。

可以通过在作业中设置spark.executor.extraJavaOptions选项来指定执行程序的GC选项以及JVM内存各个区域的精确大小,但不能设置JVM堆大小,该项只能通过–executor-memory或者spark.executor.memory来进行设置。

4.6 数据本地性的取舍

对于分布式计算框架,通常都有数据本地性问题。如果数据所在的节点与计算任务(代码所在)节点相同,那么结果肯定会快,反之则需要将远端数据移动过来,这样就会慢。通常情况下,由于代码体积通常比数据小得多,因此一般Spark的调度准则会优先考虑分发代码。

  • PROCESS_LOCAL:该级别表示数据就在代码运行的JVM中,这无疑是最佳的级别。
  • NODE_LOCAL:该级别表示数据与代码在同节点,如数据所在的DataNode与代码运行的NodeManager是同一个节点,这种级别也不错。
  • NO_PREF:该级别表示数据所在之处对于集群所有节点来说都是无差异的,无论在何处访问速度都是一样的。
  • RACK_LOCAL:该级别表示数据与运行代码的节点同机架,因此需要交换机网络传输。
  • ANY:该级别表示数据在内网中的不同机架中。

Spark当然希望每个计算任务都具有最佳的数据本地性,但这不一定总是满足的。如果没有空闲的节点处理数据,这时就会有两种选择,一种情况是等待数据所在节点完成计算,另一种是切换到远端节点开始计算。

Spark默认会等待一小会儿(3 s),希望有节点完成计算,一旦超时,则只好退而求其次,切换到下一个本地性级别,每个级别的超时时间都可以配置,可以都配置到spark.locality.wait中,或者在以下配置中按级别分别进行配置:

spark.locality.wait.process
spark.locality.wait.node
spark.locality.wait.rack
4.7 将经常被使用的数据进行缓存

如果某份数据经常会被使用,可以尝试用cache算子将其缓存,有时效果极好。

4.8 使用广播变量避免Hash连接操作

在进行连接操作时,可以尝试将小表通过广播变量进行广播,从而避免Shuffle,这种方式也被称为Map端连接。

4.9 聚合filter算子产生的大量小分区数据

在使用filter算子后,通常数据会被打碎成很多个小分区,这会影响后面的执行操作,可以先对后面的数据用coalesce算子进行一次合并。

4.10 根据场景选用高性能算子

很多算子都能达到相同的效果,但是性能差异却比较大,例如在聚合操作时,选择reduceByKey无疑比groupByKey更好;在map函数初始化性能消耗太大或者单条记录很大时,mapPartition算子比map算子表现更好;在去重时,distinct算子比groupBy算子表现更好。

4.11 数据倾斜

数据倾斜是数据处理作业中的一个非常常见也是非常难以处理的问题。正常情况下,数据通常都会出现数据倾斜的问题,只是轻重不同而已。数据倾斜的症状是大量数据集中到一个或者几个任务里,导致这几个任务会拖慢整个作业的执行速度,严重的甚至会导致整个作业执行失败。通常某个任务处理了绝大多数数据,其他任务执行完成后需要等待此任务执行完成后,作业才算完成。对于这种情况,可以采取以下几种办法处理:

  • 过滤掉脏数据:很多情况下,数据倾斜通常是由脏数据引起的,这个时候需要将脏数据过滤。

  • 提高作业的并行度:这种方式仍然不能从根本上消除数据倾斜,只是尽可能地将数据分散到多个任务中去,这种方案只能提升作业的执行速度,但是不能解决数据倾斜的问题。

  • 广播变量:可以将小表进行广播,避免了Shuffle的过程,这样就使计算相对均匀地分布在每个Map任务中,但是对于数据倾斜严重的情况,还是会出现作业执行缓慢的问题。

  • 将不均匀的数据进行单独处理:在连接操作的时候,可以先从大表中将集中分布的连接键找出来,与小表单独处理,再与剩余数据连接的结果做合并。处理方法为如果大表的数据存在数据倾斜,而小表不存在这种情况,那么可以将大表中存在倾斜的数据提取出来,并将小表中对应的数据提取出来,这时可以将小表中的数据扩充n倍,而大表中的每条数据则打上一个n以内的随机数作为新键,小表中的数据则根据扩容批次作为新键。

在这里插入图片描述

这种方式可以将倾斜的数据打散,从而避免数据倾斜

  • 多段分组:对于那种分组统计的任务,可以通过两阶段聚合的方案来解决数据倾斜的问题,首先将数据打上一个随机的键值,并根据键的散列值进行分发,将数据均匀地分散到多个任务中去,然后在每个任务中按照真实的键值做局部聚合,最后再按照真实的键值分发一次,得到最后的结果。这样,最后一次分发的数据已经是聚合过后的数据,就不会出现数据倾斜的情况。这种方法虽然能够解决数据倾斜的问题但只适合聚合计算的场景。

在这里插入图片描述

  • 11
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值