Spark 性能优化之Map-Join
1. Spark Stage的划分
1.1 RDD的依赖关系
RDD
和它依赖的 父RDD(s)
的关系有两种不同的类型,即:
- 窄依赖(narrow dependency):每一个
父RDD
的Partition
最多被子RDD
的一个Partition
使用 - 宽依赖(wide dependency):
父RDD
的Partition
都可能被多个子RDD
的Partition
使用
对于map
和filter
形式的转换来说,它们只是将Partition
的数据根据转换的规则进行转化,并不涉及其他的处理,可以简单地认为只是将数据从一个形式转换到另一个形式。对于union
,只是将多个RDD
合并成一个,父RDD
的Partition(s)
不会有任何的变化,可以认为只是把父RDD
的Partition(s)
简单进行复制与合并。对于join
,如果每个Partition
仅仅和已知的、特定的Partition
进行join,那么这个依赖关系也是窄依赖。对于这种有规则的数据的join
,并不会引入昂贵的Shuffle
。对于窄依赖,由于RDD
每个Partition
依赖固定数量的父RDD(s)
的Partition(s)
,因此可以通过一个计算任务来处理这些Partition
,并且这些Partition
相互独立,这些计算任务也就可以并行执行了。
对于 groupByKey
,子RDD
的所有 Partition(s)
会依赖于 父RDD
的所有 Partition(s)
,子RDD
的 Partition
是 父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
是被拼接而成,即每个 父RDD
的 Partition
的相对顺序不会变,只不过每个 父RDD
在 UnionRDD
中的 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
(基于 Hash
的 Shuffle
机制)和 org.apache.spark.shuffle.sort.SortShuffleManager
(基于排序的 Shuffle
机制)。
上述内容引用自 张安站《Spark技术内幕:深入解析Spark内核架构设计与实现原理》
1.2 Lineage(血统)与DAG中Stage的划分
1.2.1 Lineage
原始的 RDD(s)
通过一系列转换就形成了 DAG
。RDD
之间的依赖关系,包含了 RDD
由哪些 父RDD(s)
转换而来和它依赖 父RDD(s)
的哪些 Partitions
,是 DAG
的重要属性。借助这些依赖关系,DAG
可以认为这些 RDD
之间形成了 Lineage(血统)
。在考虑容错时,与其考虑如何持久化每一份数据,不如保存血统依赖和上游数据集,从而在下游数据集出现可用性问题时,利用血统依赖和上游数据集重算进行恢复。这是利用了函数(血统依赖)在给定参数(上游数据集)情况下,一定能够得到既定输出(下游数据集)的特性。
上述内容引用自 范东来 《Spark海量数据处理:技术详解与平台实战》
对于 Spark
的容错机制,顺带提一下 Tachyon
。 Tachyon
包含两个维度的容错,一个是 Tachyon
集群的元数据的容错,它采用了类似于 HDFS
的 Name Node
的元数据容错机制,即将元数据保存到一个 Image
文件,并且保存了元数据变化的编辑日志 (EditLog)
。另外一个是 Tachyon
保存的数据的容错机制,这个机制类似于 RDD的Lineage
,Tachyon
会保留生成文件数据的 Lineage
,在数据丢失时会通过这个 Lineage
来恢复数据。如果是 Spark
的数据,那么在数据丢失时 Tachyon
会启动 Spark
的 Job
来重算这部分内容。如果是 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
的计算需要前面 Stage
的 Shuffle
的结果。而一个 Stage
由一组完全独立的计算任务(即 Task
)组成,每个Task的运算逻辑完全相同,只不过每个 Task
都会处理其所对应的 Partition
。其中, Partition
的数量和 Task
的数量是一致的,即一个 Partition
会被该 Stage
的一个 Task
处理。
- 划分依据:Shuffle
对于窄依赖,由于 RDD
每个 Partition
依赖固定数量的 父RDD
的 Partition
,因此可以通过一个 Task
来处理这些 Partition
,而且这些 Partition
相互独立,所以这些 Task
可以并行执行。
对于宽依赖,由于需要 Shuffle
,因此只有所有的 父RDD
的 Partition Shuffle
完成,新的 Partition
才会形成,这样接下来的 Task
才可以继续处理。
因此,宽依赖可以认为是 DAG
的分界线,或者说 Spark
根据宽依赖将 Job
划分为不同的阶段( Stage
)。
Stage
的划分是从最后一个 RDD
开始的,也就是触发 Action
的那个 RDD
。上图就是 RDD G。首先,RDD G 会从 SparkContext
的 runJob
开始:
划分从 RDD G 的依赖开始, RDD G 依赖于两个 RDD ,一个是 RDD B ,一个是 RDD F 。其中先处理 RDD B 还是 RDD F 是随机的。这里首先处理 RDD B ,由于和 RDD B 的依赖是窄依赖,因此 RDD G 和 RDD B 可以划分到 一个Stage(即Stage 3) 。再处理 RDD F ,由于这个依赖是宽依赖, RDD F 和 RDD G 被划分到 不同的Stage(即Stage 2和Stage 3) ,其中 RDD F 所在的 Stage2 是 RDD G 所在的 Stage3 的 父Stage 。接下来处理 RDD B 的依赖,由于它依赖的 RDD A 是宽依赖,因此它们属于 不同的Stage(即Stage 1和Stage 3)。这样,RDD B 和 RDD G 同属于 Stage 3 ,而这个 Stage 直接的 父Stage 有两个,就是 RDD A 和 RDD F 分别属于的 两个Stage(即Stage1和Stage2) 。接下来的划分采用相同的逻辑,递归进行。最后,这个 DAG 被划分为三个 Stage ,即 RDD A 所在的Stage1 ,RDD C、RDDD、RDD E、RDD F所在的Stage 2和RDD B及RDD G所在的Stage 3 。其中 Stage1 和 Stage 2 是相互独立的,可以并发执行;Stage 3 依赖于 Stage 1 和 Stage 2 ,只有 Stage 1 和 Stage2 完成计算,它才可以开始计算。
上述内容引用自 张安站《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
有多个关联键,则以这些关联键的组合作为key
;Map
输出的value
为join
之后所关心的列(即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的手动实现
Spark 的 join 有三种实现,Shuffle Hash Join 、Broadcast 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以内的随机数作为新键,小表中的数据则根据扩容批次作为新键。
这种方式可以将倾斜的数据打散,从而避免数据倾斜
- 多段分组:对于那种分组统计的任务,可以通过两阶段聚合的方案来解决数据倾斜的问题,首先将数据打上一个随机的键值,并根据键的散列值进行分发,将数据均匀地分散到多个任务中去,然后在每个任务中按照真实的键值做局部聚合,最后再按照真实的键值分发一次,得到最后的结果。这样,最后一次分发的数据已经是聚合过后的数据,就不会出现数据倾斜的情况。这种方法虽然能够解决数据倾斜的问题但只适合聚合计算的场景。