Spark-Core之调优

可参考上一篇对官网的翻译:https://blog.csdn.net/liweihope/article/details/93386494

有哪些地方可以优化呢?
集群的任何资源都有可能成为Spark程序的瓶颈:CPU,网络带宽,或者内存。
本篇主要是数据序列化和内存调优。

数据序列化(重要)

优化Spark应用程序的第一件事情就是考虑一下数据序列化。

有两种序列化,Java serialization和Kryo serialization。

默认是Java序列化,Java序列化灵活但是通常很,而且一般序列化结果比较
比起Java的序列化来说,Kryo不但速度更,而且产生的结果更为,但是前提是需要在程序中提前注册。不注册也能使用,但是会很慢,结果会更大。

如何注册?
第一步先配置一下:
通过用SparkConf初始化任务并调用conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer"),你可以切换到Kryo序列化。
可以这样使用:spark-submit --conf spark.serializer=org.apache.spark.serializer.KryoSerializer

建议直接配置到spark-defaults.conf文件里:

spark.serializer      org.apache.spark.serializer.KryoSerializer

第二部去注册:
下面MyClass1、MyClass2这两个类是你要要去注册的类,

val conf = new SparkConf().setMaster(...).setAppName(...)
conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))
val sc = new SparkContext(conf)

下面举例说明一下MEMORY_ONLY、MEMORY_ONLY_SER(默认Java序列化)、用MEMORY_ONLY_SER且Kryo序列化不注册、用MEMORY_ONLY_SER且Kryo序列化且注册,对比一下使用效果。
代码如下:

package com.ruozedata.spark.com.ruozedata.spark.core

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

//定义一个case class类,方便后面用它来创建一个新的RDD
case class InfoLog(cdn:String,region:String,level:String,date:String,ip:String, domain:String, url:String, traffic:String)

object SerializerApp {
  def main(args: Array[String]): Unit = {
    val sparkConf = new SparkConf().setAppName("SerializerApp").setMaster("local[2]")
    //sparkConf.set("spark.serializer","org.apache.spark.serializer.KryoSerializer")
    //sparkConf.registerKryoClasses(Array(classOf[InfoLog]))
    //sparkConf.set("spark.rdd.compress","true")
    val sc = new SparkContext(sparkConf)

    //test.log日志文件含有8个字段,每一行以\t键分割字段
    val file =sc.textFile("E://test.log")
    
    //调用函数,并用count触发job运行
    fileCache(file).count()
    Thread.sleep(20000)
    sc.stop()
  }

    //定义一个函数,传进去一个RDD,RDD元素类型为String,进行一波操作,缓存到内存中,最后返回一个RDD,RDD类型为InfoLog类
   def fileCache(file:RDD[String]): RDD[InfoLog] ={
     file.map(x => {
       val fields = x.split("\t")
       InfoLog(fields(0),fields(1),fields(2),fields(3),fields(4),fields(5),fields(6),fields(7))
       }).persist(StorageLevel.MEMORY_ONLY)
  }
}

源文件大小为:221.5 MB
①用persist中,StorageLevel用MEMORY_ONLY,不进行序列化,仅仅缓存到内存中,结果大小为:511.1 MB
②用persist中,StorageLevel用MEMORY_ONLY_SER,序列化并缓存到内存中,进行序列化,(默认序列化为Java序列化),结果大小为:265.9 MB
③用persist中,StorageLevel用MEMORY_ONLY_SER,而且把sparkConf.set("spark.serializer","org.apache.spark.serializer.KryoSerializer")开启,就是说用Kryo序列化,但是不进行注册,结果大小为:327.4 MB
④用persist中,StorageLevel用MEMORY_ONLY_SER,而且把sparkConf.set("spark.serializer","org.apache.spark.serializer.KryoSerializer")开启,而且把sparkConf.registerKryoClasses(Array(classOf[InfoLog]))开启,就是说用Kryo序列化,而且把用到的自定义的类进行注册,结果大小为:227.7 MB
⑤在④基础上,再把压缩开启的话,结果大小为:59.0 MB。这个貌似用的不多,会消耗CPU。
总结为:

存储方式 大小
原始大小 221.5 MB
MEMORY_ONLY 511.1 MB
MEMORY_ONLY_SER(Java序列化) 265.9 MB
MEMORY_ONLY_SER kyro序列化 未注册 327.4 MB
MEMORY_ONLY_SER kyro序列化 且注册 227.7 MB
MEMORY_ONLY_SER 注册kryo序列化并开启RDD压缩 59.0 MB

可以看到,除去压缩之外,用MEMORY_ONLY_SER 而且用kyro序列化 并且注册,这个效果最好,结果是最小的,比较接近文件的原始大小,如果不注册结果会更大。
如果CPU不够的话,还是老老实实不要用序列化,更不要用压缩,如果CPU够用的话就取用序列化,这个要具体情况具体分析。

内存调优(重要)

需要知道Sark1.x和2.x两种不同的内存管理方式,两个管理方式的区别,从源码来分析,如何分析,面试如何去应答。

内存调优主要有三个方面的考虑,比如你现在想把一个数据放到内存中,你要考虑:对象使用的内存大小(你可能想要整个数据集都加载到内存),访问这些对象的成本(涉及到JVM),还有垃圾回收的消耗(如果你需要大批量地创建和销毁对象)。

默认情况下,Java对象可以很快的被访问,但同时Java对象会比原始数据占用的空间多2~5倍(从上面的例子就可以看到当你cache数据,把数据丟到内存里的时候,结果比原始数据要大好几倍的)。
大的原因不再述说。

Spark中的内存使用主要分为两类:执行内存和存储内存

  • 执行内存用于洗牌(shuffle),连接(join),排序(sort)和聚合(aggregation)
  • 存储内存指用于 缓存和传输 集群内部数据的内存(比如cache操作)
统一内存管理

spark的内存管理有两种,分别对应的类是UnifiedMemoryManager和StaticMemoryManager。spark1.5之后默认使用的是UnifiedMemoryManager统一内存管理,1.6之前用的是StaticMemoryManager。

在Spark中,执行和存储共享统一的内存区域(M区)。当不需要使用执行内存时,存储可以占据整个区域的内存,反之亦然。需要的时候执行内存可能会驱逐存储内存,直到所有的存储内存使用降到某个阈值以下(R区)。(执行计算可能会抢占数据存储使用的内存,如果必要的话会将存储于内存的数据逐出内存,直到数据存储占用的内存比例降低到一个指定的比例(R))。换句话说,R是M基础上的一个子区域,这个子区域的内存数据永远不会被逐出内存。而存储内存不会驱逐执行内存,因为实现起来太复杂了(比如执行shuffle有中间数据,把中间数据驱除,那么作业就走不下去了)。

源码分析(这个面试可以看你是否看过源码):
在SparkEnv.scala的源代码中,可以找到

    val useLegacyMemoryManager = conf.getBoolean("spark.memory.useLegacyMode", false)
    val memoryManager: MemoryManager =
      if (useLegacyMemoryManager) {
      //老的内存管理方式,Spark1.6版本之前
        new StaticMemoryManager(conf, numUsableCores)
      } else {
      //新的内存管理方式,Spark1.6版本才开始有
        UnifiedMemoryManager(conf, numUsableCores)
      }

再通过MemoryManager 这个找到它的源码:

//An abstract memory manager that enforces how memory is shared between execution and storage.
//There exists one MemoryManager per JVM.
//它是一个抽象类,要被子类继承后去实现,它决定执行和存储端需要分配的内存分别是多少
//下面的UnifiedMemoryManager和StaticMemoryManager都要继承它
private[spark] abstract class MemoryManager(
    conf: SparkConf,
    numCores: Int,
    onHeapStorageMemory: Long,
    onHeapExecutionMemory: Long) extends Logging {
    ......省略

看一下老的内存管理方式(这个要知道怎么回事):

      if (useLegacyMemoryManager) {
        new StaticMemoryManager(conf, numUsableCores)
      }

通过StaticMemoryManager找到下面:

  def this(conf: SparkConf, numCores: Int) {//附属构造器,传进来两个参数
    this(//里面又调用这个附属构造器,拿到最大的执行和存储内存
      conf,
      StaticMemoryManager.getMaxExecutionMemory(conf),
      StaticMemoryManager.getMaxStorageMemory(conf),
      numCores)
  }
  private def getMaxExecutionMemory(conf: SparkConf): Long = {
    val systemMaxMemory = conf.getLong("spark.testing.memory", Runtime.getRuntime.maxMemory)
  ........省略
    val memoryFraction = conf.getDouble("spark.shuffle.memoryFraction", 0.2)
    val safetyFraction = conf.getDouble("spark.shuffle.safetyFraction", 0.8)
    (systemMaxMemory * memoryFraction * safetyFraction).toLong
    //比如:10G*0.2*0.8 = 1.6G
    //就是说虽然系统最大内存有10个G,但是你只能使用1.6个G
  }

  private def getMaxStorageMemory(conf: SparkConf): Long = {
    val systemMaxMemory = conf.getLong("spark.testing.memory", Runtime.getRuntime.maxMemory)
    val memoryFraction = conf.getDouble("spark.storage.memoryFraction", 0.6)
    val safetyFraction = conf.getDouble("spark.storage.safetyFraction", 0.9)
    (systemMaxMemory * memoryFraction * safetyFraction).toLong
    //比如:10G*0.6*0.9 = 5.4G
    //就是说虽然系统最大内存有10个G,但是你只能使用5.4个G
  }

下面来分析一下新的内存管理方式,就是统一内存管理:

//并没有new,底层调用的是apply方法
 UnifiedMemoryManager(conf, numUsableCores)
  private val RESERVED_SYSTEM_MEMORY_BYTES = 300 * 1024 * 1024
  
  def apply(conf: SparkConf, numCores: Int): UnifiedMemoryManager = {
    val maxMemory = getMaxMemory(conf)
    new UnifiedMemoryManager(
      conf,
      maxHeapMemory = maxMemory,
      onHeapStorageRegionSize =
        (maxMemory * conf.getDouble("spark.memory.storageFraction", 0.5)).toLong,
      numCores = numCores)
  }

通过下面的getMaxMemory这个拿到最大内存:

//Return the total amount of memory shared between execution and storage, in bytes.
  private def getMaxMemory(conf: SparkConf): Long = {
    val systemMemory = conf.getLong("spark.testing.memory", Runtime.getRuntime.maxMemory)
    val reservedMemory = conf.getLong("spark.testing.reservedMemory",
      if (conf.contains("spark.testing")) 0 else RESERVED_SYSTEM_MEMORY_BYTES)
    val minSystemMemory = (reservedMemory * 1.5).ceil.toLong
..............省略

	//比如:10G-300M
    val usableMemory = systemMemory - reservedMemory
    val memoryFraction = conf.getDouble("spark.memory.fraction", 0.6)
    //比如(10G-300M)*0.6   这是可以使用的最大内存
    (usableMemory * memoryFraction).toLong
  }

拿到最大内存,再来看一下:

  def apply(conf: SparkConf, numCores: Int): UnifiedMemoryManager = {
    //和上面一致比如(10G-300M)*0.6
    val maxMemory = getMaxMemory(conf)
    new UnifiedMemoryManager(
      conf,
      maxHeapMemory = maxMemory,
      
      //最终给存储端用的内存为:(10G-300M)*0.6 *0.5
      //那么剩下的内存就是给executor执行端使用的
      onHeapStorageRegionSize =
        (maxMemory * conf.getDouble("spark.memory.storageFraction", 0.5)).toLong,
      numCores = numCores)
  }

由以上可以看出:
假如现在系统给分配的JVM堆内存空间大小为10G,那么:
老的内存管理方式:执行端分配内存为:1.6G,存储端分配内存为5.4G,而且两个是独立的,不够的时候不能相互借用。静态管理。
新的内存管理方式:执行端和存储端共用内存(10G-300M)*0.6 G,一开始各分配50%,但是如果不够,还可以相互借用。动态管理。

下面是常用的属性值,详情看官网,比如spark.memory.fraction值配置的越低,那么数据spill到磁盘的可能性越高,缓存的数据被驱逐的可能性也越高:

属性 意义
spark.memory.fraction 0.6 (heap space - 300MB) *0.6 就是存储和执行共用的内存,heap space是堆空间,300M是系统预留
spark.memory.storageFraction 0.5 分配给存储端用的空间,剩下的给执行端,就是各占一半,spark.memory.fraction*0.5
spark.memory.offHeap.enabled false 默认不开启对 堆外内存的使用
spark.memory.useLegacyMode false 用哪种内存管理模式,默认不使用老的内存管理方式,就是说是用新的方式(1.6版本开始)
spark.shuffle.memoryFraction 0.2 (deprecated) 已弃用,已过时,因为这是老的管理方式中的,其它属性类似

spark.memory.fraction表示M区占据整个JVM堆内存(JVM堆空间—300MB)的比例,默认为0.6。留下40%的空间给用户数据结构、Spark内部元数据、以及避免OOM错误的安全预留空间(稀疏数据和异常大的数据记录)。

spark.memory.storageFraction表示R区占据M区空间的比例,默认为0.5。R区是M区中的存储区域,该区域中的缓存的数据块永远不会因执行计算任务而被逐出内存。

虽然Spark提供了两个相关配置,但一般用户不应该需要调整它们,因为默认值在大多数情况都可以满足工作负载。

垃圾回收(GC)调优(重要)

如果你在程序需要大量新建和销毁RDD操作的时候,JVM垃圾回收可能会成为一个问题。(只是读取一个RDD然后操作多次不会产生这个问题)。Java需要将旧对象驱逐出内存来容纳新的对象,这时它会追踪所有的Java对象,找出其中不再使用的部分。这里的关键是垃圾回收的成本是和Java对象的数量成正比的,所以使用包含少量对象的数据结构(比如整形数组而不是链表结构 LinkedList)会显著减少这项成本。一个更好的办法是以序列化形式存储对象,就像上面描述的一样:这样每个RDD分区只有一个对象(一个字节数组)。如果GC存在问题,在尝试其他方法之前,首先要尝试的是去使用 序列化缓存(serialized caching)。

GC出现问题的另外一种原因是作业中的各个任务的工作内存(执行任务需要的内存大小)和节点上存储的RDD缓存占用的内存 产生冲突。下面我们将讨论一下如何控制好RDD缓存使用的内存空间,以减少这种冲突。

估算GC的影响

GC调优的第一步是统计一下,GC多久发生一次以及花在GC上的时间。具体可以通过增加Java参数 -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps 。下次你的Spark任务执行的时候,你会在Spark作业的worker日志中看到每次GC发生的时间。注意这些日志会在集群的worker节点(在各节点的工作目录的stdout文件里),而不是你的 driver program。

高级GC调优

为了进一步调优GC,我们首先需要理解一些关于JVM内存管理的基本信息:

ava堆内存空间分为两个区域:新生代(Young generation)和老生代(Old generation)。新生代用以保存生存周期短的对象,而老生代则是保存生存周期长的对象。

新生代区域被进一步划分为三个子区域:Eden,Survivor1,Survivor2。

简要描述一下垃圾回收的过程:如果Eden区满了,则会在Eden区启动一个 minor GC,生存下来(没有被回收掉)的Eden中的对象和Survivor1区中的对象一并复制到Survivor2中,这个时候Eden区和Survivor1区就会空下来。两个Survivor区域是互相切换使用的(就是说,下次从Eden和Survivor2中复制到Survivor1中,,这个时候Eden区和Survivor2区就会空下来)。如果某个对象的年龄(每次GC所有生存下来的对象长一岁)超过某个阈值,或者Survivor2(下次是Survivor1)区域满了,则将对象移到老生代(Old区)。最终如果老生代也快满了,full GC(全局GC)就会启动。

Spark GC调优的目标就是确保老生代(Old generation )只保存生命周期长的RDD,而同时新生代(Young generation )的空间又能足够保存生命周期短的对象。这样就能在任务执行期间,避免启动full GC来收集任务执行期间创建的临时对象。
一些可能有帮助的步骤如下:

  • 收集GC统计信息,检查是否有过多的GC。如果在任务完成前full GC发生了多次,这意味着没有足够多的可用内存提供给该正在执行的任务。

  • 如果有很多minor GC却没有很多major GC,分配更多内存给Eden区可以改善这个问题。你可以将Eden区的大小调为高于每个任务所需内存。如果Eden区的小大为E,你可以通过参数 -Xmn=4/3*E 设置新生代的大小。(增大为4/3倍是因为Survivor分区也需要空间。)

  • 在打印出来的GC统计信息中,如果老生代接近用满,降低 spark.memory.fraction 来减少RDD缓存占用的内存。缓存少一点对象总比拖慢任务执行要好。或者考虑减小新生代分区的大小也是可以的。这意味着将 -Xmn 调低,如果你已经按照上文做了的话。如果没有的话,尝试改变JVM的NewRatio参数。许多JVM默认将此参数设为2,意味这老生代占据堆大小的2/3。这个值应该足够大应该要超过 spark.memory.fraction。

  • 尝试设置参数 -XX:+UseG1GC 应用G1GC垃圾收集器。在某些情况下,当垃圾回收成为瓶颈时它可以提高性能。注意在executor的堆空间比较大的情况下,使用 –XX:G1HeapRegionSize 参数提高G1分区大小是很重要的。

  • 举例,如果你的任务从HDFS读取数据,可以通过读取的数据块大小估算任务使用的内存大小。注意解压后的数据块大小通常是原来大小的2至3倍。所以如果希望给3或4个任务分配工作空间,而且HDFS块大小为128M,我们可以估计Eden区大概需要 43128MB 的空间。

  • 更新设置后,监控GC发生的频率以及消耗时间的变化。

根据经验我们认为GC调优的效果取决于具体应用程序(比如说代码)和可提供内存的大小。不过总体来说,控制full GC发生的频率能有效减少垃GC成本。

Executor的GC调优可以通过设置任务配置中的 spark.executor.extraJavaOptions 来指定。

这里面几篇文章很好的讲述了GC调优:
https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/index.html

估算内存消耗

方式①:
计算一个数据集需要的内存大小的最好的方式是,创建一个RDD并把它放进缓存,查看web UI界面的”Storage”页面。这个页面会告诉你这个RDD占用了多少内存。

方式②:
要估算某个特定对象的内存消耗,可以使用 SizeEstimator 的 estimate 方法。这个方法对试验哪种数据结构能够裁剪内存占用量比较有用,同时,也可以计算确定 广播变量在每个执行器堆上占用的内存空间的大小。
先把import org.apache.spark.util.SizeEstimator包导进来,然后创建一个RDD,然后调用SizeEstimator.estimate(RDD)来估算这个RDD占用的大小大概是多少。

数据结构调优

(稍微了解,这个对性能提升帮助不大)
如果内存不足32GB,设置JVM选项 -XX:+UseCompressedOops 将指针由默认8字节改为4字节。你可以将这些选项加到 spark-en.sh 中。

RDD序列化存储

使用 RDD persistence API 的序列化存储级别(StorageLevels),比如 MEMORY_ONLY_SER。之后Spark会将RDD的每个分区存为一个大字节数组。以序列化格式存储的唯一缺点是访问数据会变慢,因为需要在访问时进行反序列化。如果你打算以序列化方式缓存数据,我们强烈推荐使用Kryo,因为它序列化的结果比Java序列化要小很多(当然也比原始Java对象小很多)。

并行度

sc.textFile(路径,并行度),这个并行度可以不写,就默认,也可以按照需要自己写。
reduceByKey(函数,并行度),这个并行度可以不写,就默认,也可以按照需要自己写。
groupByKey(并行度),这个并行度可以不写,就默认,也可以按照需要自己写。

当然也可以这样:设置配置属性 spark.default.parallelism 来改变默认值。一般来说,我们推荐集群内每个CPU(每个core)执行2至3个任务。这样的话可以充分利用CPU。

Reduce任务的内存使用

(了解)
有时发生内存溢出错误并不是因为内存放不下RDD,而是其中一个task处理的数据集太大了,比如在 groupByKey 的reduce任务中就可能出现这种情况。Spark的shuffle操作(sortByKey, groupByKey, reduceByKey, join等等)为了进行分组操作,在每个task中都构建一个哈希表,哈希表可能会非常大。最简单的修复办法是提高并行度,这样每个task的输入都会变小。 Spark能够非常有效的支持短时间任务(例如200ms),因为它可以跨许多task复用一个executor JVM,并且task的启动成本都较低,所以你可以安全地将并行度提高到集群cpu核数以上。
但是这种增加并行度并不能解决数据倾斜。

广播大变量

(了解)
使用SparkContext中的广播函数可以显著减小每个序列化task的大小,还有在集群上启动作业的成本。如果任务需要从Driver程序获取大对象(比如静态的扫表),你可以考虑将这个对象转变为广播变量。Spark将每个task序列化后的大小打印在master上,你可以根据这个来判断task是不是太大。通常来说task大于20KB就可能需要优化。

数据本地化

(了解即可,实际生产环境中很难保证的)
如果数据和处理它的代码在一起,在同一节点,计算会快一些。不过如果数据和代码是分开的,那么其中一个必须移动到另外一个那里。一般来说,移动序列化后的代码比移动一大堆数据要快,因为代码远比数据小。但这都是理想情况,代码是driver端发过去的,发给各个executor,实际情况,作业启动后,作业代码已经确定好了,在哪些executor上了,所以正常情况下是移动数据的。
driver端发送各个任务到各个executor上面去。executor所在的机器会有相应的DataNode和NameNode。

  • PROCESS_LOCAL(进程本地):executor运行任务和cache数据,现在这个executor把数据cache住了,计算时可以直接拿来用,都在同一个JVM里面,这是最好的本地性。
  • NODE_LOCAL(节点本地):数据在同一个机器上。例如在同一个机器的HDFS上,或者在同一机器的另外一个executor上。
  • RACK_LOCAL(机架本地): 数据在同一个机架的节点上。
  • ANY(任何) 数据在网络上其他地方,但数据和代码不在同一机架上。

生产中,能达到RACK_LOCAL 这个情况已经很好了。

总结

最重要的是,数据序列化和内存调优。对于大多数程序来说,切换到Kryo序列化和将数据序列化存储会解决大部分常见的性能问题。

猜你喜欢

转载自blog.csdn.net/liweihope/article/details/93533267
今日推荐