1.序列化调优
序列化在任何分布式应用程序的性能中起着重要的作用。 缓慢的对象序列化过程或消费大量字节的格式都将会大大减慢计算速度
Spark 宗旨在于方便(允许您使用操作中的任何 Java 类型)和性能(速度与消耗更少的资源)之间.Spark 提供了 Java , Kryo 两种序列化库
1.1 Java serialization
Spark默认使用Java内置的序列化器.它非常方便灵活,支持几乎所有的类型.但速度相对缓慢,且对某些对象会产生大型序列化格式
1.2 Kryo serialization
Spark 也内置了 Kryo 库(Kryo2)来更快地对对象进行序列化.Kryo 比 Java 序列化(通常10x)要快得多,而且更紧凑,只是需要先注册将在程序中使用的类.
1.2.1 Kryo简介
Kryo 是一个快速序列化/反序列化工具,其使用了字节码生成机制(底层依赖了 ASM 库),因此具有比较好的运行速度
Kryo 序列化出来的结果,是其自定义的、独有的一种二进制的格式,不是 JSON 或者其他现有的通用格式.所以其体积更小
Kryo 一般只用来进行序列化(然后作为缓存,或者落地到存储设备之中)、反序列化,而不用于在分布式系统或者多种语言间进行数据交换
Kryo 支持常见的 JDK 类型,这些类型组合而来的普通 POJO,枚举,任意 Collention,子类/多态,内部类,泛型等
Kryo 不支持 增加或删除对象中的字段(比如某个 Bean 使用 Kryo 序列化后缓存,之后新增或删除某个字段再反序列化缓存,此时Kryo会报错)
1.2.2 Spark 使用 Kryo
conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))
注: 如果没有在Kryo对类进行注册,它依然可以使用,但它必须存储每个对象的完整类名称,这会造成存储空间的浪费
2.内存调优
默认情况下,一个Java对象所消耗的存储空间总会比它的纯粹数据多二到五倍.这些额外的存储消耗体现在:
i).对象头(object header).每个对象都会有一个对象头,它包含一个指向它的类的指针.对象头大约占用16字节
ii).一个String对象会比原始字串多耗用40字节(因为它实际存储一个char数组中,它会保存例如数组长度等的额外信息).并且内部使用UTF-16的话,每个字符还会存储为两个字节
iii).公共集合类使用中的为了保持数据结构,也会额外存储一些关于数据结构的信息.
这里从Spark 的内存管理概述开始,然后讨论用户可以采取的具体策略,以便在其应用程序中更有效地使用内存
2.2 Spark的内存管理
在Spark中的内存使用主要分为两类: 执行(execution)内存 和 存储(storage)内存
执行内存是指用于运行混洗,连接,排序和聚合计算的存储器的内存
存储内存是指用于在集群中缓存和传播内部数据的内存
在Spark中,执行和存储共享一个统一的区域.Spark将在内部对这两者进行调配,策略如下
i).当没有使用执行内存时,存储内存可以用满所有可用的内存.反之亦然.
ii).如果执行内存需要,可以驱逐存储内存(但存储内存自身可以设置最小区域,即这种驱逐会将存储内存压到设定的最小值为止,而之后的部分,执行内存无法驱逐).
这种设计确保了几个理想的性能
首先,不使用缓存的应用程序可以将整个空间用于执行,从而避免不必要的磁盘泄漏。
第二,使用缓存的应用程序可以保留最小的存储空间(R),其中数据块不受驱逐。
最后,这种方法为各种工作负载提供了合理的开箱即用性能,而不需要用户内部如何分配内存的专业知识
一些用于控制Spark内存使用的配置项
spark.memory.fraction 用于执行和存储的(堆空间 - 300MB)的分数。这个值越低,溢出和缓存数据逐出越频繁
此配置的目的是在稀疏、异常大的记录的情况下为内部元数据,用户数据结构和不精确的大小估计预留内存。推荐使用默认值
spark.memory.storageFraction 不会被逐出内存的总量,表示为 spark.memory.fraction
留出的区域大小的一小部分。
这个越高,工作内存可能越少,执行和任务可能更频繁地溢出到磁盘。 推荐使用默认值
虽然有这些配置,但典型用户不需要调整它们,因为默认值适用于大多数工作负载
2.3 使用优化
要确定一个数据集的内存消耗,最佳方式是将这个数据集创建为RDD再将其放入缓存,这些从Spark UI中,可以直接看到这个数据的实际内存消耗.
而减少内存消耗的第一种方法是避免添加开销的 Java 功能,例如基于指针的数据结构和包装对象
i) 调整数据结构
使用fastutil , 将数据结构设计为偏好对象数组和原始类型,而不是标准的 Java 或 Scala 集合类(例如: HashMap
)
ii).尽可能避免使用很多小对象和指针的嵌套结构
iii).考虑使用数字 ID 或枚举对象而不是键的字符串
iv).如果您的 RAM 小于32 GB,请设置 JVM 标志 -XX:+UseCompressedOops
,使指针为4个字节而不是8个字节 (spark-env.sh设置)
v).序列化 RDD 存储
当您的对象仍然太大而无法有效存储,减少内存使用的一个更简单的方法是以序列化形式存储它们.=>调整存储级别为: MEMORY_ONLY_SER
序列化存储时,Spark 将会将每个 RDD 分区存储为一个大字节数组。
以序列化形式存储数据的唯一缺点是访问时间较短,因为必须对每个对象进行反序列化.(强烈建议使用 Kryo ,因为它导致比 Java 序列化更小的尺寸更小)
2.4 GC
当您的程序存储的 RDD 有很大的”流失”时,JVM 垃圾收集可能是一个问题.(程序中通常没有问题,只读一次 RDD ,然后在其上运行许多操作)
垃圾收集的成本与 Java 对象的数量成正比,因此使用较少对象的数据结构(例如: Ints
数组,而不是 LinkedList)
会大大降低了GC成本
一个更好的方法是如上所述以序列化形式持久化对象:现在每个 RDD 分区只有一个对象(一个字节数组)
2.4.1 测量GC的影响
GC 调整的第一步是收集关于垃圾收集发生频率和GC花费的时间的统计信息
做法: 添加 -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
下次运行 Spark 作业时,每当发生垃圾回收时,都会看到在工作日志中打印的消息(消息将在executor输出而不是driver端)
2.4.2 高级GC优化
首先应对JVM的GC机制有所了解. Young三代(Eden(0代), Survivor1(1代), Survivor2(2代))和Old代(这个自行了解,在此略过)
Spark 中 GC 调优的目的是确保只有长寿命的 RDD 存储在 Old 代中,并且 Young代的大小足够存储短命期的对象.一些有用的步骤如下:
通过收集 GC 统计信息来检查垃圾收集是否太多
i).如果在任务完成之前多次调用完整的 GC ,这意味着没有足够的可用于执行任务的内存
ii).如果小对象非常多产生大量的0代GC,可以考虑增大GC的0代内存
做法: 设置年轻一代的大小 -Xmn=4/3*E (E=调整之前的0代大小,按比例增加4/3是考虑幸存者使用的空间)
iii).GC 统计信息中,如果Old代接近于满
方案一:可以通过降低减少用于缓存的内存量 spark.memory.fraction
(缓存较少的对象比减慢任务执行更好)
方案二:可以调低Young代内存.这意味着 -Xmn
如果您将其设置为如上所述降低。
如果没有,请尝试更改 JVM NewRatio
参数的值.许多 JVM 默认为2,这意味着 Old 版本占据堆栈的2/3.它应该足够大,使得该分数超过 spark.memory.fraction
iv).尝试使用 G1GC 垃圾回收器
在垃圾收集是瓶颈的一些情况下,使用 G1GC可以提高性能
做法: -XX:+UseG1GC (对于大型 excutor 的堆大小,通过设置 -XX:G1HeapRegionSize 参数来增加 G1 区域的大小 是非常重要的)
例如: 如果您的任务是从 HDFS 读取数据,则可以使用从 HDFS 读取的数据块的大小来估计任务使用的内存量。
请注意,解压缩块的大小通常是块大小的2或3倍.所以如果我们希望有3或4个任务的工作空间,而 HDFS 块的大小是128MB,我们可以估计0代的大小4*3*128MB
关于JVM调整,可以通过spark.executor.extraJavaOptions
在作业的配置中设置来指定执行器的 GC 调整标志
3.其它
3.1 并行度
Spark作为一个分布式的计算引擎,一个比较重要的概念就是利用集群资源并行计算.
所以Spark的一个优化方法就是: 合理情况下,尽可能的调高并行度以重分的利用集群资源
比如: spark.default.parallelism 或者在 Spark.Core 中 map 系的第二参数,groupByKey
和 reduceByKey
的第二参数等等
一般来说,Spark官方建议是群集中每个 CPU 内核有2-3个任务
3.2 减少任务内存的使用
有时,会得到一个 OutOfMemoryError,这是因为您的其中一个任务的工作集(如其中一个 reduce 任务groupByKey
)太大.
Spark的 shuffle(sortBykey groupByKey reduceByKey join) 操作,会建立每个集合的Hash集来进行分组,这往往会使用到比较大的内存.
一个比较简单的做法是提高并行级别,将其分散多个任务中以让单个任务的工作集变小
Spark可以有效的支持短达200毫秒的任务,因为它将多个任务执行载体的一个JVM重用,并且任务启动成本相对较低,
因此,可以将并行级别调整到等于集群的核数,也是比较安全的
3.3 合理使用广播
使用 可用的广播功能 SparkContext 可以大大减少每个序列化任务的大小,以及在群集上启动作业的成本。
如果您的任务使用其中的驱动程序中的任何大对象(例如:静态查找表),请考虑将其变为广播变量。
Spark 打印主机上每个任务的序列化大小,因此您可以查看该任务以决定您的任务是否过大; 一般任务大于20 KB大概值得优化
3.4 数据本地化
数据本地化可能会对 Spark job 的性能产生重大影响。
如果数据和在其上操作的代码在一起,则计算往往是快速的.但如果代码和数据分开,则必须移动到另一个(通常移动代码,因为一般代码大小远小于数据).
Spark 围绕数据局部性的一般原则构建其调度,数据本地化是指数据和代码处理有多近.根据数据的当前位置有几个地方级别.
从最近到最远的顺序如下:
PROCESS_LOCAL
数据与运行代码在同一个 JVM 中.这是可能的最好的地方
NODE_LOCAL
数据在同一个节点上(示例可能在同一节点上的 HDFS 或同一节点上的另一个执行程序中).这比 PROCESS_LOCAL略差
因为数据必须在进程之间移动
NO_PREF
数据从任何地方同样快速访问,并且没有本地偏好
RACK_LOCAL
数据位于同一机架上的服务器上。数据位于同一机架上的不同服务器上,因此需要通过网络发送,通常通过单个交换机发送
ANY
数据在网络上的其他地方,而不在同一个机架中
Spark 喜欢将所有 task 安排在最佳的本地级别,但这并不总是可能的。在任何空闲 executor 中没有未处理数据的情况下, Spark 将切换到较低的本地级别。
有两个选项:
i).等待一个繁忙的 CPU 释放在相同服务器上的数据上启动任务
ii).立即在更远的地方启动一个新的任务,需要在那里移动数据
默认做法,Spark会等待繁忙的CPU空闲下来,这种等待会持续一段时间.一旦超时就会选择策略2在远处启动新任务并移动数据过去.
注意:每个级别的回退与等待超时都会使用段时间. 这个可以使用 spark.locality 参数进行干预
如果您的 task 很长,并且本地化很差,可以考虑增加这些设置,但默认值通常会很好