SparkCore之-调优
建议:大家在读文章的时候尽量先看看文字描述,这样的话可能对大佬们来说更容易理解一些,么么么哒么么么么么大!~
由于大多数Spark计算都是基于内存的,Spark程序可能会受到集群中任何资源(Cpu、网络带宽、内存)的瓶颈。通常,如果内存足够那么瓶颈有可能是网络带宽,有时,我们可以通过一些调整:例如序列化存储RDD=>减少内存使用量
,以下通过3个方面来介绍Spark调优:
数据序列化
减少内存消耗及IO消耗内存调优
防止OOM等,避免频繁GC其他调优选项
1、数据序列化
NOTE:序列化在任何分布式应用程序中都起着直观重要的作用,将对象序列化为慢速格式或者占用大量字节的格式将很大程度的减慢计算速度,通常,这是我们对于优化应该考虑的第一件事,Spark旨在便利性与性能之间取得平衡,因此Spark提供了2个序列化库:
Java序列化:
默认情况下,spark使用Java的ObjectOutputStream框架对对象进行序列化,并且可以对你所创建的任何类使用[
java.io.Serializable
],我们还可以通过[java.io.Externalizable
]扩展来更紧密的控制序列化的性能,但是Java序列化很灵活并且适用性很强,但是因为序列化多多处来很多额外的数据,特别是对于包装类和集合,Java序列化会多处来16个字节的描述信息,导致序列化之后的数据很大,所以性能上就很慢。
Kryo序列化:
Spark还可以使用Kryo库更快的序列化对象。性能通常是Java序列化的10倍(吹牛逼),因为Kryo更紧凑、更快,但是Kryo不支持所有的Serializable类型,所以要使用Kryo那么我们需要在程序中注册需要使用的类,以实现最佳性能。
如果需要使用Kryo注册我们自定义的类我们需要在代码中作以下操作:
package com.shufang.tuning
import com.shufang.beans.{KryoEntity1, KryoEntity2}
import com.shufang.utils.SparkUtil
import org.apache.spark.SparkContext
/**
* 官方建议使用Kryo进行序列化,在Spark2.0.0之后,
* 默认在shuffle阶段针对某些类型使用Kryo序列化方式:
* |-- 简单类型例如: Int、Long...
* | -- 简单的数组类型如: Array
* |-- String类型
*/
object KryoSerializerDemo {
def main(args: Array[String]): Unit = {
val sc: SparkContext = SparkUtil.getLocalSC()
sc.getConf
//1、指定默认的序列化方式为KryoSerializer,这样不仅仅指定了Woker之间进行shuffle的序列化方式,
// 同时也指定了RDD序列化存储到disk磁盘上的序列化方式,提高磁盘IO序列化效率
.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
//2、注册想要以Kryo序列化的自定义类,用逗号分隔,也可以通过:spark.kryo.classesToRegister参数指定
.registerKryoClasses(Array(classOf[KryoEntity1], classOf[KryoEntity2]))
//3、如果需要序列化的对象很大,我们可以通过增加以下参数来进行优化,这个缓存需要足够大去hold住你需要被序列化的最大的对象
.set("spark.kryoserializer.buffer","128k") //default 64k,不够的话会扩容
.set("spark.kryoserializer.buffer.max","128M") //default 64M
sc.stop()
}
}
同时除了默认的2个序列化库,我们还可以引入第三方的序列化库,摘自官网:
Spark automatically includes Kryo serializers for the many commonly-used core Scala classes covered in the AllScalaRegistrar from the Twitter chill library.
2、内存调优
在内存的调整我们可以从3个方面去考虑
1、存储对象内存
2、访问对象内存成本
3、GC内存消耗成本
默认情况下Java对象的访问速度很快,到那时与其字段中的‘原始’数据相比,很容易消耗2-5倍的存储空间,这是由于以下几个原因:
# 1、 每个不同的Java对象都有一个‘object header’大约16个bytes,这个对象头包含诸如指向该类的指针的信息等元数据信息,对于其中数据很少的Int类型对象,该存储内存消耗可能大于实际数据大小
- 2、 Java中的String类型在原始字符串数据上超出大概40bytes开销,(因为String类型底层以Array[Char]进行存储,所以多出许多类似于长度、索引之类的额外数据),并且由于UTF-16的内部用法,因此每个字符都存储为2个bytes的String编码,因此一个10bytes大小的字符串存储时可以轻松消耗60个字节
# 3、常见的集合类(HashMap\LinkedList)使用链接的数据机构,其中每个节点Map.Entry都有一个包装(Wrapper)对象,该对象不仅具备标题,而且还具有指向下一个对象的指针(通常是8个bytes)
- 4、基本数据类型的集合通常是将它们存储成‘封装对象’,例如java.lang.Integer
这里首先概述Spark中的内存管理,然后会针对用户需求讨论采取的特定的strategy策略,以更有效地使用其应用程序中的内存。特别是对象的内存使用情况以及如何通过更改数据结构以串行化的格式来存储数据来改善对象的使用情况
然后介绍调整Spark的缓存大小和JavaGC-collector
2.1 内存管理概述
Spark的内存管理是规划式的,也就是只要启动一个客户端进程,那么所配置的内存资源就会被占用,其中还包括预留的内存,防止OOM。
在Spark中的内存使用情况大致分为3类
- Storage Memory
- Execution Memory
- Other memory
2.1.1 Storage Memory
存储内存是指用于在集群中缓存和传播内部数据的内存:如: Cache、Broadcast、Accumulator等
2.1.2 Execution Memory
执行内存主要用于shuffle、join、sort、aagregate
的计算内存,主要存储中间的状态数据
2.1.3 Storage Memory与Execution Memory的关系
在Spark中,Storage Memory
与Execution Memory
共享一个统一的内存区域(M),当不使用Storage Memory时,Execution Memory可以使用所有的内存,反之亦然,这个就是Spark1.6之后引进的动态内存管理,但是如果共享的内存的可使用量达到一个阈值,那么执行内存会驱逐存储内存,使其将数据刷写到指定的磁盘。
这种动态内存占用确保了几种理想的性能:
1、首先,不使用缓存的应用程序可以将整个内存使用于执行,从而避免不必要的磁盘溢出;
2、其次,使用了缓存或者共享变量的应用程序可以预留一个最小的存储空间,以免其数据块被驱逐;
3、最后,这个方法可以为各种工作负载提供合理的即用性能,而无需我们使用者了解其内部的内存划分。
2.2 确定内存消耗
1、如何确定一个RDD数据集的内存消耗,最好的方法就是创建一个RDD,将其放入缓存中,然后通过Spark的WebUI中的Storage页面
,这个页面将会告诉你该RDD占用了多少内存;
2、要估算特性对象的内存消耗,请使用SizeEstimator
的estimate()
方法,这对于尝试使用不同的数据布局以减少内存使用&&确定广播变量将在每个Executor-JVM上占用的内存空间很有用
package com.shufang.beans
import org.apache.spark.util.SizeEstimator
/**
* 本代码用于估算特定对象的内存占用大小
*/
object BeanObjectSizeEvaluateDemo {
def main(args: Array[String]): Unit = {
val bean = new KryoEntity2()
val size: Long = SizeEstimator.estimate(bean)
//该对象占用24个bytes
println(size)
//估算一个String类型的对象的占用内存,应该比原始内存要大,
//目测是10个Char,应该是20个Byte,可是实际内存肯定不止,最后查看是64Bytes
//WOW~~~~!!!为什么呢?因为String类型底层是以Array[Char]进行存储的,除了初始内存,还包括对象头、索引、数组长度等描述
val stringVar = "helloworld"
println(SizeEstimator.estimate(stringVar))
}
}
GOOD=>看到这里我们就开始真正的内存优化吧!
2.3 调整数据结构优化
减少内存消耗的第一种方法就是避免使用Java相关的功能,例如基于指针的数据结构如java.util.List&Integer等包装对象,我们可以从以下方面去实现:
1、将数据结构设计为对象数组或者原生的类型,而不是标准的Java或者Scala的集合类(HashMap),fastutil
库提供了很多便利的集合类和原生类与Java标准库进行完美匹配
2、避免使用带有许多小对象和指针的嵌套结构(LinkedList)
3、使用数字或者枚举对象作为key代替strings的key,String太耗内存了
4、如果内存少于32GB,考虑设置JVM flag -XX:+UseCompressedOops
去把指针存储由8个bytes改成4个bytes,我们可以在spark-env.sh中设置好,或者在提交job的时候通过bin/spark-commit …–conf -XX:+UseCompressedOops去设置
2.4 序列化RDD存储优化
如果一个对象特别大但是我们需要高效的存储它,简单的方式就是以序列化的方式进行存储,可以在persist(,StorageLevel.MEMORY_ONLY_SER,)
中指定存级别,并且需要以序列化方式存储数据,那么强烈建议:kryo\kryo\kryo
,重要的事情说三遍,更紧凑、更快、更顺滑~~
但是!please note that !这样虽然减少了存储内存的消耗,但是每次去访问的时候都必须动态的反序列化该对象,这样的话就导致访问时间较慢。
2.5、Garbage Collection「GC」优化
如果你的程序中RDD的方面有很大的搅动
,那么JVM的垃圾回收
会成为瓶颈,下面统称GC,GC是不可避免的,如何减少不必要的GC也是一种优化手段。
(搅动:好比你一次读取一个RDD,然后程序操作一次之后再也没有引用了,如果你的程序频繁的出现类似的情况,那么你们程序RDD的搅动就真的很大!,记住,一个RDD被读取一次,但是读取之后需要针对该RDD作许多操作,并且形成了比较长的DAG,linear血缘关系,那么这不叫'搅动!'
)
当Java需要驱逐旧对象为新对象腾出空间时,它需要遍历所有的Java对象,并且标记出不被引用的对象,而且至少遍历2遍(比如:标记清除法、标记压缩法),这个与Java对象的数量成正比,一、因此不使用嵌套数据结构可大大降低成本Ints而不是LinkedList
除此之外,一个更好的方法就是二、将对象以序列化方式进行存储,这种情况下RDD的每个分区都只会作为单独的一个对象(一个字节数组,Array[Byte])
,所以在尝试其他技术之前,首先尝试使用GC解决问题的方法是使用序列化缓存
优化方式如下2.5.x所示
2.5.1 衡量GC的影响
GC调整的第一步就是收集有关GC的发生频率
和GC所花费的时间
,这些可以在spark-env.sh来配置:-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
等Java Options来进行配置,或者通过
--conf "spark.executor.extraJavaOptions=-XX:+PrintGCDetails -XX:+PrintGCTimeStamps"
或者通过
conf.set("spark.executor.extraJavaOptions","-XX:+PrintGCDetails -XX:+PrintGCTimeStamps")
来指定运行时配置,配置完之后,启动Spark-jobs,我们就能看到日志分别打印在worker的
2.5.2 有效的GC调优
为了更好的做好GC调优,我们首先需要明白一些关于JVM内存管理的常识:
- Java堆被分成2个区域【young(新生代)、old(老年区)】,新生代是用来存储生命周期比较短的对象,而老年区是为生命周期比较长的对象准备的
- 新生代又分为[Eden,Survivor1,Survivor2]3个区域
- 一个简单的GC简单描述:当Eden区满了,一个minorGC开始被运行,之后Survivor1和Eden区的存活的对象被拷贝到Survivor2区域,然后Survivor1和Suvivor2区域的对象交换位置,保证Survivor1的内存是空的,当一个对象足够老(经过16次minorGC依然存活)或者Survivor2满了之后,那么就将这些对象移到Old区,Finally,当Old区的对象接近了一个阈值快要满的时候,full GC就被调用了(Stop the world!)
Spark-GC调优的目的是确保只有「长生命周期」的RDD被存储在Old区,而且Young区也有充足的空间去存储「短生命周期」的对象,这将会帮助我们避免频繁的收集在执行过程中创建的临时对象引起的full GC,下面的步骤可能会十分有用。
# 1、通过worker日志统计这里是否有太多的GC,如果full GC在一个task完成之前被调用多次,就意味着给tasks执行的内存空间已经不够用了
# 2、如果这里minor GC比较多但是full GC比较少,那么给Eden分配更多空间会很有用,你可以给Eden区分配内存超过每个Task执行需要的内存的评估结果,如果Eden区的内存大小用E表示,那么我们可以设置Young区域的大小通过:-Xmn=4/3*E,按4/3比例放大也是为了考虑幸存者区域使用的空间
# 3、在打印GC统计时,如果OldGen即将满了,我们可以降低:spark.memory.fraction;
与减慢任务执行速度相比,缓存较少的对象更好,或者考虑减少Young区的内存大小,我们可以通过更改JVM的‘NewRatio’参数的值,如果JVM的NewRatio=2,那么意味着Old区占了堆的2/3,它应该足够大,大到超过spark.memory.fraction。
# 4、尝试使用G1 GC垃圾收集器 -XX:+UseG1GC ,在垃圾收集成瓶颈的某些情况下,它能提高性能;如果在executor堆大小足够大的时候,增加G1 region size:
-XX:G1HeapRegionSize会很有用
# 5、举个栗子,如果从HDFS读取数据,那么评估之后的内存使用量应该是HDFS上文件的块大小,但是注意⚠️解压缩块的大小通常是块大小的2-3倍,因此如果我们希望拥有3-4个任务的工作空间,并且HDFS的块大小为128MB,那么我们可以将Eden的大小估计为4*3*128M.
所有的GC的相关配置都可以通过conf.set("spark.executor.extraJavaOptions"," XXXXX")
的形式进行配置。
3、其他优化想法
3.1 并行度
除非你为每个Operation设置足够高的并行度,不然的话集群CPU资源都不会被充分利用,Spark会自动为不同的数据源自动根据数据量的大小设置map tasks的数量
,当然在程序中我们可以通过类似于sc.textFile("path",parallelisms)
来设置并行度,对于reduceByKey等聚合操作,将会默认延用该父RDD中最大的并行度
,我们可以通过:spark.default.parallelism
来修改默认的并行度,官方建议每个CPU分配2-3个Task
,官网鲁迅先生的原话为:we recommend 2-3 tasks per CPU core in your cluster.
so!~
假如你的集群总共有128个core ,那么就分配 32*4*[2~3] = 256~384个Task
3.2 ReduceTasks的内存使用
跑Spark程序的时候是否经常遇见OOM OutOfMemoryError
,哈哈蛤不用急,那并不是是因为你的RDDs你的内存已经装不下了,'而是因为你任务中有些Tasks线程的内存已经装不下groupByKey[reduceByKey\join\sortbyKey等]的所有key的数据了
,那么怎么解决呢?
此处最简单的方法就是在增加并行度,让key根据hash分布得更均匀一点
Spark可以很高效的支持短至200ms的任务,因为它可以在多个任务中冲用一个执器JVM,并且任务启动成本低,因此我们可以很安全地将并行级别提高到集群中核心的数量之上,这里又类似于Flink中的基于Slots的并行度分配了,是不是很奇妙,大佬们
3.3 广播大变量
使用广播变量可以极大的加少每个序列化任务的大小,以及在集群中启动作业的成本
,如果您的任务使用驱动程序中的任何大对象(e.g静态表),共享变量详情可以供大家参考,
3.3.1 那么为什么可以减少序列化任务的大小
假如说,不使用广播变量,当Executor中执行的Tasks需要用到一个RDD外部变量时,首先得保证这个变量对象支持序列化,同时当使用该变量的闭包都传递到对应的Task去执行,其中当然包括该变量,这个从Driver到Executor的过程所有的闭包都得序列化,那么这个过程将消耗大量的序列化及网络IO,每个Task都相当于在内存中存储了一份该变量的副本,从Driver到Executor的过程实际上经历了序列化=>反序列化=>使用=>GC的过程
假如该变量很大占用m,同时并行度为n,那么m大小的变量得至少被序列化n次,那么最终占用的集群内存为m*n,这还是不考虑序列化的情况下,假如以序列化存储,那么会占用更多的存储空间,如果一直被引用,内存的不到释放,那么会引起频繁的GC甚至fullGC,oh my god!~好阔怕
那么使用广播之后呢,相当于对变量在Driver端进行了封装,然后一次性序列化传输到所有的Worker节点,节点数肯定比Tasks数目要少的多,而且广播变量是对于Executor可见的只读变量,序列化之后 object header少了写相关的描述,而且最重要的是广播变量从Driver到Executor只会被传递一次,如果Task需要用到该变量,只需要在相应的Executor内存中进行本地调用就行了,简直o**k!完美,所以20K以上的共享数据可以考虑使用广播变量进行优化。
3.3.2 为什么可以减少作业启动成本
一个job的launch启动,需要经历找到配置、jar包等等等~,首先Driver会根据代码触发Job => 划分stage =>
封装taskmanager => 放入调度队列 => 分发任务 => 这个分发任务就相当于把需要执行的代码和数据序列化并且传递到executor端进行执行,那么这个过程如果需要频繁的序列化或者序列化时间较长,那么肯定会拖延job的启动时长,使用广播变量提高了数据的序列化效率、反序列化效率&内存占用缩减&传递效率增加
,那么就相当减少了Job的启动成本。
3.4 数据本地化执行
数据本地化对Spark job的性能有很大的影响,如果数据与代码在一起(一个JVM或者一个节点)被操作,那么会非常快,如果不在一起那么就需要将其中的一方移到另一方去执行,通常,移动代码比移动数据要快得多,因为代码比数据要小很多,所以Spark提供了5种本地化级别 [data local level]
PROCESS_LOCAL 数据与代码运行在同一JVM中,这是最好的位置
NODE_LOCAL 数据与代码运行在同一个集群节点,由于数据需要在不同进程中传递,那么就会相对较慢
NO_PREF 可以从任何位置访问数据吗,并且不受位置限制
RACK_LOCAL 数据与代码运行在同一机架上,因此通常需要经过交换机通过网络发送
ANY 数据在网络上的其他位置,而不是在同一机架
Spark倾向于最佳位置即PROCESS_LOCAL
调度所有任务,但这并不总是可能的,在任何空闲的Executor上没有未处理数据的情况下,Spark会切换到到更低层次的本地化级别,这里有2种选择:
- 1、等待知道一个忙碌的CPU资源被释放之后在data所在的server上启动一个task
- 2、立刻在更远的地方启动一个task,并且要求将data移动过去
通常Spark会默认等待本地busy的CPU空闲,但是超时之后,就会将数据移动到其他的空闲CPU所在的位置,不同的超时时间可以通过 spark.locality 参数进行配置,具体的可以参考官网配置页面
Spark官网配置页面
spark.locality.wait | 3s | How long to wait to launch a data-local task before giving up and launching it on a less-local node. The same wait will be used to step through multiple locality levels (process-local, node-local, rack-local and then any). It is also possible to customize the waiting time for each level by setting spark.locality.wait.node , etc. You should increase this setting if your tasks are long and see poor locality, but the default usually works well. |
---|---|---|
spark.locality.wait.node | spark.locality.wait | Customize the locality wait for node locality. For example, you can set this to 0 to skip node locality and search immediately for rack locality (if your cluster has rack information). |
spark.locality.wait.process | spark.locality.wait | Customize the locality wait for process locality. This affects tasks that attempt to access cached data in a particular executor process. |
spark.locality.wait.rack | spark.locality.wait |
如果你的任务时间很长,并且位置不佳,则应该增加这些设置,但是官方的说明是:默认配置3s通常是效果比较好的,所以我觉得基本可以按默认来搞的
优化最后总结:
# TODO
//TODO,Spark默认的是FIFO调度,但是如果是多个用户使用的服务,那么可以设置为FAIR公平调度。
除了代码封装优化意外,今后如果还有总结的地方我会持续添加到博客里