对Spark性能的调优离不开对任务的监控,只有在运行过程中,通过监控手段发现问题,才能迅速定位问题所在。
SparkUI使用
在运行Spark应用程序时,默认会在Driver节点的4040端口启动WebUI服务,通过此WebUI可对Spark的应用程序的Job划分、Stage划分、Task执行缓存的使用等各个方面进行了监控。
在执行Shuffle操作时,Map端使用ExternalSorter对数据进行分组,按分区排序,如果内存不足,则会将内存中的数据写入磁盘,为后续数据迭代留出内存空间。在Reduce端使用ExternalSorter对key进行排序时,如果内存不足则同样会溢写到磁盘中。在Reduce端对所有Map端的Task中的数据进行聚合时,会使用ExternalAppendOnlyMap组件,如果内存不足,则该组件会将数据溢写到磁盘中。
在Reduce端对所有Map端的Task中的数据进行聚合时,会使用ExternalAppendOnlyMap组件,如果内存不足,则该组件会将数据溢写到磁盘中。
a)在Spark中,任务运行的并行度是对性能影响的最大因素,任务的并行度决定了能够同时运行多少个任务来处理RDD的数据。在Spark中一个Job会被划分为多个Stage,每个Stage会生成多个Task,每个Task计算RDD的一个分区的数据。所以并行度是针对某个Stage而言的,不同的Stage其并行度可能不同。如果经过Shuffle以后,Map端的Task数量和Reduce端的任务数量一般是不同的。
如果并行度太低则无法充分利用集群的计算资源,如在某个Stage中,计算的RDD只有一个分区,只能生成一个Task,即使集群中配置了再多的Executor,每个Executor中有很多CPU,在这个Task执行时也只能使用其中一个Executor的一个CPU,其他分配的集群资源处于空闲状态。而且由于RDD的所有数据都通过一个分区计算,因此造成一个Task计算的数据过大,运行缓慢。
分区器决定了Map端的每个Task将数据分组的数量,也决定了Reduce端的分区的数量,默认此数量为200,通过
spark.default.parallelism
配置,如果在计算的任务中有大量的数据,并且有足够的CPU资源计算,则可以考虑适当提高此参数,提高Reduce端的并行度,在一些产生Shuffle的算子中,也可以手动指定分区的数量,如groupByKey(1000);
在一个Stage中,各个RDD的转换,都是通过流水线的形式进行转换的,在Stage的第一个RDD负责该Stage中的数据的初始化加载,初始化RDD加载数据的来源只有两种:一种是Stage为Shuffle的Reduce端,通过拉取上游的数据完成数据加载。二是从不同的数据源中加载如文件系统,集合等。第一种中并行度的调节已介绍,在第二种中,如从文件系统加载数据时,Hadoop会为HDFS每个Block块创建一个分区,此时RDD的分区数据量取决于文件Block块的个数。
在某些极端的情况下,用户不能控制RDD分区的数量,得到的RDD的分区量可能特比小,造成并行程度低,可用过repartition()函数手动增加RDD的分区,来生成新的ShuffleRDD,在这个过程中会发生shuffle操作,所以此函数应慎重使用。
在数据计算过程中,如果某个RDD使用了多次,应避免创建多个该RDD对象,如某RDD为从文件中加载数据,后续又使用到了该文件,这时应该直接使用之前创建的RDD对象。
在执行Spark应用程序的过程中,每个action操作都会产生1个Spark job,多个Spark job可能会使用到同一个RDD,默认情况下,RDD会通过其计算函数获取RDD某分区的数据,计算函数在获取数据时首先根据父RDD的BlockId到BlockManager中查找该RDD的分区是否被缓存,如果缓存了则不再通过父RDD进行计算,直接从BlockManager中获取。
当一个RDD被不同Job多次使用时,可以将该RDD进行缓存,在第一次使用时会从父RDD进行计算,计算完成后会将RDD每个分区的数据缓存到对应Executor的BlockManager中,后续使用的过程中,会到BlockManager查找缓存,从而避免了RDD的重复计算。
但并不是重复使用的RDD就需要进行缓存,如果一个RDD从HDFS中读取的数据被使用了多次,此时对这个RDD进行缓存可能得不偿失,因为HDFS的数据会比集群中的内存大的多,内存中无法容纳该RDD的数据,根据指定的缓存策略可能会将数据保存到节点的磁盘中,再次使用的时候还是会从磁盘中进行读取,这时和从HDFS中直接读取没什么区别,甚至可能还没有从HDFS中读取磁盘的吞吐量高。
真正被缓存的RDD应该是经过了复杂计算的,经过多次shuffle、过滤后重复使用的RDD.如果某个RDD从初始的10TB数据经历了各种shuffle,过滤等计算最终得到了10GB的数据,而这个10GB的数据重复地被利用,此时缓存才能发挥其加速的作用。
在执行的算子中,如果使用了外部的变量,Spark会通过序列化将该变量放到每个Task中,如果使用到的外部变量较大,此时会产生大量的网络I/O,而且每个Task中保存重复的大量变量造成内存的极大浪费。
如果外部的变量过大,应使用广播变量(Broadcast),使用广播变量后,多个Task在一个Executor中运行时, 共享一个变量即可,大大减少了内存的使用。而且Executor中的广播变量是懒加载的,只有Task使用到该变量时,对应节点的BlockManager才会从其他节点中拉取数据。如果一个变量为100MB,执行的Task有1000个,可使用的Executor有100个,此时如果不进行变量的广播,则有Task使用的内存大小为100MBx1000=100G。如果使用广播变量,则所有Task使用的内存大小为100Mx100=10G,相差10倍??的数据量。这10倍的重复数据存到内存中造成了严重的浪费,100GB的数据在网络中传输也是很大的开销。
此外如果两个RDD进行join时,在一定条件下也可以通过广播变量实现。两个RDD在进行join时,如果他们的partitioner不同,会产生shuffle操作(相同则不走shuffle)。如果两个join的RDD有一个数据量较少,也可以将该RDD的数据进行广播,使用广播变量手动进行join,此时可以避免shuffle的过程,从而加快计算速度。如果两个RDD的数据量都比较大,则该方法将不使用,因为获取RDD的所有数据会将所有分区的数据传入到Driver端,如果RDD的数据较大,很可能会造成Driver端内存的溢出。???做法
在Spark任务执行的过程中,很多地方用到了数据的序列化,对数据序列化也影响到了Spark任务的执行速度,尤其是对RDD数据的序列化,大量数据的序列化会消耗一部分时间,Spark中主要有以下几方面使用到了序列化功能:
a)Task会序列化,传输到Executor节点。
b)广播变量的数据会被序列化,在BlockManager中存储并传输到Executor节点。
c)对RDD进行缓存时,根据指定的缓存策略,有的会将数据序列化存储,在读取时需要将数据反序列化。
d)在Shuffle的Map端使用SortShuffleWriter将数据按照key聚合,按分区排序的过程需要两次将数据序列化。
e)在Reduce端对所有Map端的Task中的数据进行聚合时,会使用ExternalAppendOnlyMap组件,如果内存不足则该组件会将数据溢写到磁盘中,需要进行序列化。在磁盘数据合并时,需要反序列化。
f)在Shuffle的Reduce端使用ExternalSorter对key进行排序时,如果内存不足需要将数据溢写到磁盘中,此过程需要对数据序列化,在读取时需要反序列化。
在如此多的序列化和反序列化中,使用一个高性能的序列化类库非常重要,如常见的Kyro序列化类库,它的性能会比Java自带的序列化类库高很多,官方介绍其性能要比Java高出10倍。无论是在速度还是在序列化数据的大小方面都有很大的优势,但是Spark默认并没有使用此类库,因为Kyro要求用户自定义的类型进行序列化时需要进行注册,使用不够方便。但Spark内部使用的某些数据进行序列化时,使用的都是Kyro类库。在使用Kyro类库时,首先需要设置Spark的序列化类库,并对需要序列化的自定义类进行注册。
val conf=new SparkConf()
//设置序列化器为KyroSerializer
conf.set("spark.serializer",""org.apache.spark.serializer,KyroSerializer)
//注册要序列化自定义类型
conf.registerKyroClasses(Array(ClassOf[类名1],ClassOf[类名2])..)
在Spark将数据处理完成后,往往需要将处理的结果写入到持久化组件中,如写入数据库、写入文件等。在抵用这些连接时,如果调用RDD的foreach()函数,在函数内部创建连接会为每条RDD的记录都创建一个连接。造成极大的性能损失。
rdd.foreach{rocord=>
val connection=createNewConnection()
connection.send(record)
connection.close()
}
//在Spark中可以使用RDD的foreachPartition()函数,为每个分区数据执行相应的操作.
rdd.foreachPartition{partitionOfRecords=>
val connection=createNewConnection()
partitionOfRecords.foreach(record=>connection.send(record))
connection.close()
}
Spark Job的执行是通过Job划分为多个有依赖关系的Stage,每个Stage划分为多个Task。Task并行执行,从而形成了Spark的并行计算。在SparkContext初始化的时候,会向对应的集群管理器中申请Executor,每个Executor中包含了可用的CPU和内存。Executor启动成功后会向Driver端进行注册,从而Driver端的集群管理器知道了有多少Executor可用,每个Executor有多少CPU可用。当Executor的CPU有空闲时,Driver端会将当前执行的Stage中等待运行的Task提交到Executor中执行。直到Executor中的CPU都占用完毕或者该Stage中没有等待运行的Task为止。当Executor中某个Task执行完毕时,会将运行结果发送至Driver端,Driver端将对应Executor增加可用的CPU。再次判断是否有等待的Task需要执行,如果有,则将Task提交到刚刚空闲出的CPU的Executor中。
因此,Spark应用程序分配的CPU个数决定了集群中能够同时并行运行的Task个数。如果应用程序只分配了一个Executor,Executor中只有一个CPU。在Stage中的多个Task执行时,则会一个一个排队执行,一个Task运行完毕后,其他的Task才能够进行执行。
在计算过程中,如果想提高程序运行的并行度,应提高CPU的分配数量,但是并行度也可能受到RDD分区的限制。如果RDD的分区太小,则会造成Task数量过少,此时分配再多的CPU也不能提高执行的并行度。
spark-submit \
--master yarn \
--deploy-mode cluster \
--queue xxx
--num-executors 8 \
--executor-memory 11G \
--driver-memory 2G \
--executor-cores 3 \
--conf spark.network.timeout=10000000 \
--class com.cnki.changeCode.transferValueCode_to_Name_IBRD
Spark应用程序在运行时,所有节点可分为两种角色:Driver和Executor。Driver负责将Job划分为有多个依赖关系的Stage。每个Stage划分为多个Task,将多个Task提交到Executor中执行。并接收Executor返回的Task的计算结果,对结果进行汇总。所以在内存的分配中,需要为Driver和Executor分别分配内存。
Driver节点的内存,仅仅需要将任务结果进行汇总,如果没有使用collect算子将大量的数据拉取到Driver中,一般Driver不会使用特别多的内存,因为Driver本身并不负责RDD的计算。
在Executor节点的内存中,除了一部分用于正常任务执行使用的内存外,还有一部分内存被该节点的Executor管理,Executo将其管理的内存分为存储内存和执行内存,在该Executor中运行的所有Task都共享该节点的存储内存和执行内存。对RDD进行缓存时,会占用存储内存,在Shuffle时,Map端和Reduce端会占用执行内存。所以在对Executor内存进行分配时应充分考虑缓存部分和执行部分的大小。当一个Executor中分配的CPU较多时,应适当分配内存用于每个Task申请执行内存,否则执行内存过小,可能会造成shuffle过程中,数据频繁溢写到磁盘,从而使任务的时间变长。
此外在进行内存分配时,内存的大小应避免在32~40G,因为在这个范围内由于JVM关闭了指针压缩功能,被分配的内存一部分会被指针占用,使得用户并没有从分配的更多内存中收益。
在可用资源一定的情况下,会涉及如何确定Executor数量的问题,如在yarn中,用户可指定需要启动的Executor和每个Executor分配的内存与CPU,此时同样的CPU资源是多分配Executor,将每个Executor中CPU数量减少,还是减少Executor数量为每个Executor分配更多的CPU呢。
如果仅仅对CPU而言,其实是相同的,因为每个Task的执行会占用一个CPU,Task不会关心是哪个Executor的CPU运行的。
对于内存使用则不同,因为如果在一个Executor中CPU的数量过多,在该Executor中执行的Task数量就会变多,如果Task需要进行Shuffle操作,则所有Task会共享同一个Executor中的执行内存。假如此时为Executor分配的内存过少,则会造成每个Task分配的执行内存过少。同样,如果对RDD进行缓存,在一个Executor中CPU过多,会将同一个RDD的多个分区都缓存到同一个Executor中,此时也需要增加内存,满足多个分区的数据缓存使用。
如果在任务中使用到了大量广播变量,此时分配的Executor越多,那么共享变量的副本数就越多。在同一个Executor中运行的Task是可以使用同一份共享内存的,因此在进行Executor数量的划分中,也应考虑共享内存的使用。
此外Executor进程本身运行也需要消耗内存。在Spark中,统一内存管理器为Executor的运行保留了300MB的内存,其中的60%作为存储内存,40%为执行内存和其他内存(用于存储对象的引用),如果Executor运行过多,在相同的内存条件下,系统预留使用的内存会增多。
在进行Shuffle的过程中,Reduce端需要对Map端相同的key进行聚合,此过程中会加载大量数据。在Spark中已经定义了一些能够在Map端预聚合的算子,如reduceByKey,会在Map端预聚合再写入文件中,在shuffle阶段只需要拉取聚合后的数据即可。
使用groupByKey聚合的过程如下图:
使用reduceByKey聚合的过程如下图:
Spark的BlockManager在对磁盘写入数据时,首先会把数据写入缓冲区,再将缓冲区的数据一次性写入磁盘。默认缓冲区的大小为32KB。如果适当提高该缓冲区的大小,可以减少I/O次数,该参数通过下面参数配置,如果用户内存资源充足,则可提高至64KB~1024KB。
spark.shuffle.file.buffer
Reduce端的一个Task拉取上游Map端的多个Task的总内存是可以控制的,通过
spark.reducer.maxSizeInFlight
来控制,默认为64KB.当拉取的task超过64kb的时候会等待它之前聚合的task内存被释放,如果内存充足可以增大该值。
在shuffle的过程中,ExternalSorter和ExternalAppendOnlyMap都会通过
spark.shuffle.spill.batchSize
参数控制内存中记录的数量,默认为10000。当内存中的记录达到这个数的时候,无论执行内存是否充足都会将内存中的数据强制写到磁盘中。这对大内存而言并不太合适,因为内存还有足够的空间,还能将数据存储在内存中,对于大内存的Executor而言可以提高这个参数,保证内存被充分利用。
此参数不能调节过大,因为这两个组件的底层用的都是AppendOnlyMap存储内存的数据,当该值过大时,可能造成Map中的key的哈希值严重冲突,从而造成Map性能下降。
注:
ExternalSorter用于shuffle中map端对数据进行分组,按分组排序。在reduce端中用于对key排序。
ExternalAppendOnlyMap在reduce端对所有map端的数据进行聚合。
在发生数据倾斜时,通常一个Stage的99%的Task很快都计算完成,剩余1个或几个Task会消耗很长的时间才能计算完成。
产生这种情况的是因为在发生Shuffle的过程中,某个key的数据量特别大,而其他的key的数据量相对而言比较小,在Reduce端同一个key会被一个Task进行处理,这是这个Task处理的数据量特别大,而其他Task处理的数量却很少,如下图:
在这种情况下可以将所有key加上一个随机数前缀,如1~1000的随机前缀,原有的1个发生倾斜的key加上随机前缀后将会变成4个不同的key,在进行shuffle的过程中,首先对着4个key进行聚合,聚合完成后这个倾斜的key将会得到4组聚合后的数据。再将4组数据去掉前缀再聚合得到最终的结果。在这个过程中发生倾斜的key由于加上了随机前缀,在shuffle的时候,会被分到不同的分区中,避免了之前所有的key在同一个分区中处理的情况。随机前缀聚合原理如下图:
此外,还可以适当提高Reduce端的并行度,使每个分区处理的key 的数量减少,这在一定程度上解决了数据倾斜的问题,但是如果只有一个key发生了严重的数据清下,这种方式解决不了问题。增加reduce并行度如下图:
优化后