[Spark 进阶]-- 优化Spark作业以获得最佳性能

感谢原文作者:https://michalsenkyr.github.io/2018/01/spark-performance

Spark作业的开发在表面上看起来很容易,而且大部分都是如此。提供的 API设计精良且功能丰富,如果您熟悉Scala集合或Java流,您将立即完成实施。实际上,当在集群上运行它们并且满负载时,硬件部分实际上是因为并非所有作业在性能方面都是相同的。不幸的是,要以最佳方式实现您的工作,您必须了解Spark及其内部结构。

在本文中,我将讨论在开发Spark应用程序时可能遇到的最常见的性能问题以及如何避免或减轻它们。

1.Transformations

使用RDD API时,最常见的性能问题是使用不适合特定用例的转换。这可能源于许多用户对SQL查询语言的熟悉以及他们对查询优化的依赖。重要的是要意识到RDD API不应用任何此类优化。

我们来看看同一计算的这两个定义:

val input = sc.parallelize(1 to 10000000, 42).map(x => (x % 42, x))
val definition1 = input.groupByKey().mapValues(_.sum)
val definition2 = input.reduceByKey(_ + _)
RDD 平均时间 闵。时间 最大。时间
定义1 2646.3ms 1570ms 8444ms
定义2 270.7ms 96ms 1569ms

Lineage(定义1):

(42) MapPartitionsRDD[3] at mapValues at :26 []
 |   ShuffledRDD[2] at groupByKey at :26 []
 +-(42) MapPartitionsRDD[1] at map at :24 []
    |   ParallelCollectionRDD[0] at parallelize at :24 []

Lineage(定义2):

(42) ShuffledRDD[4] at reduceByKey at :26 []
 +-(42) MapPartitionsRDD[1] at map at :24 []
    |   ParallelCollectionRDD[0] at parallelize at :24 []

第二个定义比第一个定义快得多,因为它在我们的用例上下文中更有效地处理数据,而不是不必要地收集所有元素。

在进行笛卡尔连接并稍后对结果数据进行过滤而不是转换为RDD并使用内部连接时,我们可以观察到类似的性能问题:

val input1 = sc.parallelize(1 to 10000, 42)
val input2 = sc.parallelize(1.to(100000, 17), 42)
val definition1 = input1.cartesian(input2).filter { case (x1, x2) => x1 % 42 == x2 % 42 }
val definition2 = input1.map(x => (x % 42, x)).join(input2.map(x => (x % 42, x))).map(_._2)
RDD 平均时间 闵。时间 最大。时间
定义1 9255.3ms 3750ms 12077ms
定义2 1525ms 623ms 2759ms

Lineage(定义1):

(1764) MapPartitionsRDD[34] at filter at :30 []
  |    CartesianRDD[33] at cartesian at :30 []
  |    ParallelCollectionRDD[0] at parallelize at :24 []
  |    ParallelCollectionRDD[1] at parallelize at :24 []

Lineage(定义2):

(42) MapPartitionsRDD[40] at map at :30 []
 |   MapPartitionsRDD[39] at join at :30 []
 |   MapPartitionsRDD[38] at join at :30 []
 |   CoGroupedRDD[37] at join at :30 []
 +-(42) MapPartitionsRDD[35] at map at :30 []
 |  |   ParallelCollectionRDD[0] at parallelize at :24 []
 +-(42) MapPartitionsRDD[36] at map at :30 []
    |   ParallelCollectionRDD[1] at parallelize at :24 []

这里的经验法则是始终使用转换边界处的最小数据量。RDD API尽最大努力优化任务调度,基于数据局部性的首选位置等背景内容。但它并不优化计算本身。事实上,它实际上是不可能的,因为每个转换都是由不透明的函数定义的,而Spark无法查看我们正在使用的数据以及如何处理。

还有另一条经验法则可以从中得出:使用丰富的变换,即在单个变换的上下文中尽可能多地进行变换。一个有用的工具是combineByKeyWithClassTag方法:

val input = sc.parallelize(1 to 1000000, 42).keyBy(_ % 1000)
val combined = input.combineByKeyWithClassTag((x: Int) => Set(x / 1000), (s: Set[Int], x: Int) => s + x / 1000, (s1: Set[Int], s2: Set[Int]) => s1 ++ s2)

Lineage:

(42) ShuffledRDD[61] at combineByKeyWithClassTag at :28 []
 +-(42) MapPartitionsRDD[57] at keyBy at :25 []
    |   ParallelCollectionRDD[56] at parallelize at :25 []

DataFrames and Datasets

Spark社区实际上认识到了这些问题,并开发了两套高级API来解决这个问题:DataFrame和Dataset。这些API带有关于数据的附加信息,并定义了整个框架中可识别的特定转换。在调用动作时,计算图被大量优化并转换为相应的RDD图,并执行该图。

为了演示,我们可以尝试两种等效的计算,以一种非常不同的方式定义,并比较它们的运行时间和作业图:

val input1 = sc.parallelize(1 to 10000, 42).toDF("value1")
val input2 = sc.parallelize(1.to(100000, 17), 42).toDF("value2")
val definition1 = input1.crossJoin(input2).where('value1 % 42 === 'value2 % 42)
val definition2 = input1.join(input2, 'value1 % 42 === 'value2 % 42)
数据帧 平均时间 闵。时间 最大。时间
定义1 1598.3ms 929ms 2765ms
定义2 1770.9ms 744ms 2954ms

解析逻辑计划(定义1):

'Filter (('value1 % 42) = ('value2 % 42))
+- Join Cross
   :- Project [value#2 AS value1#4]
   :  +- SerializeFromObject [input[0, int, false] AS value#2]
   :     +- ExternalRDD [obj#1]
   +- Project [value#9 AS value2#11]
      +- SerializeFromObject [input[0, int, false] AS value#9]
         +- ExternalRDD [obj#8]

解析逻辑计划(定义2):

Join Inner, ((value1#4 % 42) = (value2#11 % 42))
:- Project [value#2 AS value1#4]
:  +- SerializeFromObject [input[0, int, false] AS value#2]
:     +- ExternalRDD [obj#1]
+- Project [value#9 AS value2#11]
   +- SerializeFromObject [input[0, int, false] AS value#9]
      +- ExternalRDD [obj#8]

物理计划(定义1):

*SortMergeJoin [(value1#4 % 42)], [(value2#11 % 42)], Cross
:- *Sort [(value1#4 % 42) ASC NULLS FIRST], false, 0
:  +- Exchange hashpartitioning((value1#4 % 42), 200)
:     +- *Project [value#2 AS value1#4]
:        +- *SerializeFromObject [input[0, int, false] AS value#2]
:           +- Scan ExternalRDDScan[obj#1]
+- *Sort [(value2#11 % 42) ASC NULLS FIRST], false, 0
   +- Exchange hashpartitioning((value2#11 % 42), 200)
      +- *Project [value#9 AS value2#11]
         +- *SerializeFromObject [input[0, int, false] AS value#9]
            +- Scan ExternalRDDScan[obj#8]

物理计划(定义2):

*SortMergeJoin [(value1#4 % 42)], [(value2#11 % 42)], Inner
:- *Sort [(value1#4 % 42) ASC NULLS FIRST], false, 0
:  +- Exchange hashpartitioning((value1#4 % 42), 200)
:     +- *Project [value#2 AS value1#4]
:        +- *SerializeFromObject [input[0, int, false] AS value#2]
:           +- Scan ExternalRDDScan[obj#1]
+- *Sort [(value2#11 % 42) ASC NULLS FIRST], false, 0
   +- Exchange hashpartitioning((value2#11 % 42), 200)
      +- *Project [value#9 AS value2#11]
         +- *SerializeFromObject [input[0, int, false] AS value#9]
            +- Scan ExternalRDDScan[obj#8]

优化之后,原始类型和转换顺序无关紧要,这要归功于一种称为基于规则的查询优化的功能。由于基于成本的查询优化,数据大小也被考虑在内以正确的方式重新排序作业。最后,DataFrame API还将有关作业实际所需的列的信息推送到数据源读取器以限制输入读取(这称为谓词下推)。编写RDD作业实际上非常难以与DataFrame API提供的内容相提并论。

但是,有一个方面,DataFrames并不出色,并且促使创建另一种,第三种方式来表示Spark计算:类型安全性。由于数据列仅出于转换定义的目​​的而由名称表示,并且仅在运行时检查它们对实际数据类型的有效使用,这往往会导致繁琐的开发过程,我们需要跟踪所有正确的类型或我们最终在执行过程中出错。数据集API是作为此解决方案创建的。

Dataset API使用Scala的类型推断和基于implicits的技术来传递Encoders,这是描述Spark优化器数据类型的特殊类,就像DataFrames一样,同时保留编译时键入以进行类型检查和写入转换自然。如果这听起来很复杂,这是一个例子:

val input = sc.parallelize(1 to 10000000, 42)
val definition = input.toDS.groupByKey(_ % 42).reduceGroups(_ + _)
数据集 平均时间 闵。时间 最大。时间
定义 544.9ms 472ms 728ms

解析的逻辑计划:

'Aggregate [value#301], [value#301, unresolvedalias(reduceaggregator(org.apache.spark.sql.expressions.ReduceAggregator@1d490b2b, Some(unresolveddeserializer(upcast(getcolumnbyordinal(0, IntegerType), IntegerType, - root class: "scala.Int"), value#298)), Some(int), Some(StructType(StructField(value,IntegerType,false))), input[0, scala.Tuple2, true]._1 AS value#303, input[0, scala.Tuple2, true]._2 AS value#304, newInstance(class scala.Tuple2), input[0, int, false] AS value#296, IntegerType, false, 0, 0), Some())]
+- AppendColumns , int, [StructField(value,IntegerType,false)], cast(value#298 as int), [input[0, int, false] AS value#301]
   +- SerializeFromObject [input[0, int, false] AS value#298]
      +- ExternalRDD [obj#297]

实体计划:

ObjectHashAggregate(keys=[value#301], functions=[reduceaggregator(org.apache.spark.sql.expressions.ReduceAggregator@1d490b2b, Some(value#298), Some(int), Some(StructType(StructField(value,IntegerType,false))), input[0, scala.Tuple2, true]._1 AS value#303, input[0, scala.Tuple2, true]._2 AS value#304, newInstance(class scala.Tuple2), input[0, int, false] AS value#296, IntegerType, false, 0, 0)], output=[value#301, ReduceAggregator(int)#309])
+- Exchange hashpartitioning(value#301, 200)
   +- ObjectHashAggregate(keys=[value#301], functions=[partial_reduceaggregator(org.apache.spark.sql.expressions.ReduceAggregator@1d490b2b, Some(value#298), Some(int), Some(StructType(StructField(value,IntegerType,false))), input[0, scala.Tuple2, true]._1 AS value#303, input[0, scala.Tuple2, true]._2 AS value#304, newInstance(class scala.Tuple2), input[0, int, false] AS value#296, IntegerType, false, 0, 0)], output=[value#301, buf#383])
      +- AppendColumnsWithObject , [input[0, int, false] AS value#298], [input[0, int, false] AS value#301]
         +- Scan ExternalRDDScan[obj#297]

后来人们意识到DataFrames可以被认为只是这些数据集的一个特例,并且API是统一的(使用一个名为Row的特殊优化类作为DataFrame的数据类型)。

但是,在涉及数据集时,请记住一点需要注意。作为开发人员熟悉了采集样RDD API,数据集API提供了自己的变异是其最流行的方法- filtermapreduce。这些工作(如预期的那样)具有任意功能。因此,Spark无法理解这些函数的细节,并且其优化能力变得有些受损,因为它无法再正确传播某些信息(例如,用于谓词下推)。这将在序列化一节中进一步解释。

val input = spark.read.parquet("file:///tmp/test_data")
val dataframe = input.select('key).where('key === 1)
val dataset = input.as[(Int, Int)].map(_._1).filter(_ == 1)

解析的逻辑计划(数据帧):

'Filter ('key = 1)
+- Project [key#43]
   +- Relation[key#43,value#44] parquet

解析逻辑计划(数据集):

'TypedFilter , int, [StructField(value,IntegerType,false)], unresolveddeserializer(upcast(getcolumnbyordinal(0, IntegerType), IntegerType, - root class: "scala.Int"))
+- SerializeFromObject [input[0, int, false] AS value#57]
   +- MapElements , class scala.Tuple2, [StructField(_1,IntegerType,false), StructField(_2,IntegerType,false)], obj#56: int
      +- DeserializeToObject newInstance(class scala.Tuple2), obj#55: scala.Tuple2
         +- Relation[key#43,value#44] parquet

物理计划(数据框):

*Project [key#43]
+- *Filter (isnotnull(key#43) && (key#43 = 1))
   +- *FileScan parquet [key#43] Batched: true, Format: Parquet, Location: InMemoryFileIndex[file:/tmp/test_data], PartitionFilters: [], PushedFilters: [IsNotNull(key), EqualTo(key,1)], ReadSchema: struct

物理计划(数据集):

*SerializeFromObject [input[0, int, false] AS value#57]
+- *Filter .apply$mcZI$sp
   +- *MapElements , obj#56: int
      +- *DeserializeToObject newInstance(class scala.Tuple2), obj#55: scala.Tuple2
         +- *FileScan parquet [key#43,value#44] Batched: true, Format: Parquet, Location: InMemoryFileIndex[file:/tmp/test_data], PartitionFilters: [], PushedFilters: [], ReadSchema: struct

Parallel transformations

Spark可以并行运行多个计算。这可以通过在驱动程序上启动多个线程并在每个线程中发出一组转换来轻松实现。然后,生成的任务将同时运行并共享应用程序的资源。这确保了资源永远不会保持空闲(例如,在等待特定转换的最后任务完成时)。默认情况下,任务以FIFO方式处理(在作业级别),但可以通过使用备用应用程序内调度程序来确保公平性(通过设置spark.scheduler.modeFAIR)。然后,期望线程通过将spark.scheduler.pool本地属性(使用SparkContext.setLocalProperty)设置为适当的池名来设置其调度池。然后应在一个中提供每池资源分配配置spark.scheduler.allocation.file设置定义的XML文件(默认情况下,这是fairscheduler.xml在Spark的conf文件夹中)。

def input(i: Int) = sc.parallelize(1 to i*100000)
def serial = (1 to 10).map(i => input(i).reduce(_ + _)).reduce(_ + _)
def parallel = (1 to 10).map(i => Future(input(i).reduce(_ + _))).map(Await.result(_, 10.minutes)).reduce(_ + _)
计算 平均时间 闵。时间 最大。时间
串行 173.1ms 140ms的 336ms
平行 141ms 122ms 200毫秒

2.分区

大多数Spark作业遭遇的第二个问题是数据分区不足。为了使我们的计算有效,重要的是将我们的数据划分为足够大的分区,这些分区的大小尽可能接近(统一),以便Spark可以调度正在运行的各个任务。他们以不可知的方式仍然可以预测地执行。如果分区不统一,我们说分区是倾斜的。这可能由于多种原因以及我们计算的不同部分而发生。

分区偏斜的例子

从数据源读取时,我们的输入可能已经倾斜。在RDD API中,这通常使用textFilewholeTextFiles方法完成,这些方法具有令人惊讶的不同分区行为。该textFile方法旨在从(通常较大的)文件中读取单独的文本行,默认情况下将每个输入文件块作为单独的分区加载。它还提供了一个minPartitions参数,当大于块数时,它会尝试进一步拆分这些分区以满足指定的值。另一方面,wholeTextFiles方法,用于读取(通常较小的)文件的全部内容,将相关文件的块按其在集群内的实际位置组合到池中,默认情况下,为每个池创建一个分区(有关详细信息,请参阅Hadoop的CombineFileInputFormat,用于其实现)。minPartitions在这种情况下,参数控制这些池的最大大小(等于totalSize/minPartitions)。所有minPartitions参数的默认值为2.这意味着wholeTextFiles如果使用默认设置而不在集群上明确管理数据位置,则更容易获得非常少数量的分区。用于数据读入RDDS其它方法包括其它格式,例如sequenceFilebinaryFilesbinaryRecords,以及通用的方法hadoopRDDnewAPIHadoopRDD采用自定义格式实现(允许自定义分区)。

在随机边界上,分区特征经常发生变化。因此,暗示shuffle的操作提供了numPartitions指定新分区计数的参数(默认情况下,分区计数保持与原始RDD中的相同)。也可以通过shuffle引入Skew,尤其是在连接数据集时。

val input = sc.parallelize(1 to 1000, 42).keyBy(Math.min(_, 10))
val joined = input.cogroup(input)

[Spark 进阶]-- 优化Spark作业以获得最佳性能_第1张图片

由于这些情况下的分区完全取决于所选键(特别是其Murmur3哈希),因此必须注意避免为公共键创建异常大的分区(例如,空键是常见的特殊情况)。一种有效的解决方案是分离相关记录,将盐(随机值)引入其键并在多个阶段为它们执行后续操作(例如,减少)以获得正确的结果。

val input1 = sc.parallelize(1 to 1000, 42).keyBy(Math.min(_, 10) + Random.nextInt(100) * 100)
val input2 = sc.parallelize(1 to 1000, 42).keyBy(Math.min(_, 10) + Random.nextInt(100) * 100)
val joined = input1.cogroup(input2)

[Spark 进阶]-- 优化Spark作业以获得最佳性能_第2张图片

有时甚至有更好的解决方案,例如,如果其中一个数据集足够小,则使用地图侧连接。

val input = sc.parallelize(1 to 1000000, 42)
val lookup = Map(0 -> "a", 1 -> "b", 2 -> "c")
val joined = input.map(x => x -> lookup(x % 3))

DataFrames和Datasets

高级API共享一种分区数据的特殊方法。输入文件的所有数据块都被添加到公共池中,就像在wholeTextFiles,但是根据两个设置将池分成多个分区:spark.sql.files.maxPartitionBytes指定最大分区大小(默认为128MB),并spark.sql.files.openCostInBytes指定估计的成本以字节为单位打开一个可以读取的新文件(默认为4MB)。该框架将根据此信息自动确定输入数据的最佳分区。

在shuffle上进行分区时,遗憾的是,高级API非常缺乏(至少从Spark 2.2开始)。只能通过指定spark.sql.shuffle.partitions设置(默认为200)在作业级别上静态指定分区数。

高级API可以自动将连接操作转换为广播连接。这是由控制的spark.sql.autoBroadcastJoinThreshold,它指定考虑广播的表的最大大小(默认为10MB)spark.sql.broadcastTimeout,并控制执行者等待广播表的时间(默认为5分钟)。

重新分区

所有API还提供了两种方法来操作分区数。第一个是repartition强制shuffle以便在指定数量的分区之间重新分配数据(通过前面提到的Murmur散列)。由于洗牌数据是一项代价高昂的操作,因此应尽可能避免重新分区。此操作还有更具体的变体:可排序对RDD repartitionAndSortWithinPartitions可以与自定义分区程序一起使用,而DataFrames和Datasets具有repartition列参数来控制分区特征。

所有API提供的第二种方法coalescerepartition不刷新数据更有效,但只指示Spark将几个现有分区作为一个读取。但是,这只能用于减少分区数量,不能用于更改分区特征。通常没有理由使用它,因为Spark旨在利用大量的小分区,除了减少输出文件的数量或与一起使用时批量的数量foreachPartition(例如将结果发送到数据库) 。

3.序列化

正确处理的另一件事是序列化,它有两种类型:数据序列化和闭包序列化。数据序列化是指对存储在RDD中的实际数据进行编码的过程,而闭包序列化是指相同的过程,但是对于外部引入计算的数据(如共享字段或变量)。区分这两者很重要,因为它们在Spark中的工作方式非常不同。

数据序列化

Spark支持两种不同的序列化程序用于数据序列化。默认的是Java序列化,虽然它很容易使用(通过简单地实现Serializable接口),效率非常低。这就是为什么建议切换到第二个支持的序列化器Kryo,用于大多数生产用途。这是通过设置spark.serializerorg.apache.spark.serializer.KryoSerializer。Kryo效率更高,不需要实现类Serializable(因为它们是由Kryo的FieldSerializer序列化的)默认情况下)。但是,在非常罕见的情况下,Kryo可能无法序列化某些类,这是它仍然不是Spark的默认值的唯一原因。注册所有预期要序列化的类也是一个好主意(Kryo将能够使用索引而不是完整的类名来识别数据类型,减少序列化数据的大小,从而进一步提高性能)。

case class Test(a: Int = Random.nextInt(1000000),
                b: Double = Random.nextDouble,
                c: String = Random.nextString(1000),
                d: Seq[Int] = (1 to 100).map(_ => Random.nextInt(1000000))) extends Serializable

val input = sc.parallelize(1 to 1000000, 42).map(_ => Test()).persist(DISK_ONLY)
input.count() // Force initialization
val shuffled = input.repartition(43).count()
RDD 平均时间 闵。时间 最大。时间
java的 65990.9ms 64482ms 68148ms
KRYO 30196.5ms 28322ms 33012ms

Lineage(Java):

(42) MapPartitionsRDD[1] at map at :25 [Disk Serialized 1x Replicated]
 |        CachedPartitions: 42; MemorySize: 0.0 B; ExternalBlockStoreSize: 0.0 B; DiskSize: 3.8 GB
 |   ParallelCollectionRDD[0] at parallelize at :25 [Disk Serialized 1x Replicated]

Lineage(Kryo):

(42) MapPartitionsRDD[1] at map at :25 [Disk Serialized 1x Replicated]
 |        CachedPartitions: 42; MemorySize: 0.0 B; ExternalBlockStoreSize: 0.0 B; DiskSize: 3.1 GB
 |   ParallelCollectionRDD[0] at parallelize at :25 [Disk Serialized 1x Replicated]

DataFrames和Datasets

高级API在数据序列化方面效率更高,因为他们知道他们正在使用的实际数据类型。多亏了这一点,他们可以生成专门针对这些类型定制的优化序列化代码,以及Spark将在整个计算环境中使用它们的方式。对于某些转换,它也可能只生成部分序列化代码(例如计数或数组查找)。此代码生成步骤是Project Tungsten的一个组件,它是使高级API具有高性能的重要组成部分。

值得注意的是,Spark可以在此过程中了解应用转换的属性,因为它可以传播有关在整个作业图中使用哪些列的信息(谓词下推)。在转换中使用不透明函数(例如,数据集' mapfilter)时,此信息将丢失。

val input = sc.parallelize(1 to 1000000, 42).map(_ => Test()).toDS.persist(org.apache.spark.storage.StorageLevel.DISK_ONLY)
input.count() // Force initialization
val shuffled = input.repartition(43).count()
数据帧 平均时间 闵。时间 最大。时间
1102.9ms 912ms 1776ms

Lineage:

(42) MapPartitionsRDD[13] at rdd at :30 []
 |   MapPartitionsRDD[12] at rdd at :30 []
 |   MapPartitionsRDD[11] at rdd at :30 []
 |   *SerializeFromObject [assertnotnull(input[0, $line16.$read$$iw$$iw$Test, true]).a AS a#5, assertnotnull(input[0, $line16.$read$$iw$$iw$Test, true]).b AS b#6, staticinvoke(class org.apache.spark.unsafe.types.UTF8String, StringType, fromString, assertnotnull(input[0, $line16.$read$$iw$$iw$Test, true]).c, true) AS c#7, newInstance(class org.apache.spark.sql.catalyst.util.GenericArrayData) AS d#8]
+- Scan ExternalRDDScan[obj#4]
 MapPartitionsRDD[4] at persist at :27 []
 |       CachedPartitions: 42; MemorySize: 0.0 B; ExternalBlockStoreSize: 0.0 B; DiskSize: 3.2 GB
 |   MapPartitionsRDD[3] at persist at :27 []
 |   MapPartitionsRDD[2] at persist at :27 []
 |   MapPartitionsRDD[1] at map at :27 []
 |   ParallelCollectionRDD[0] at parallelize at :27 []

关闭序列化

在大多数Spark应用程序中,不仅需要序列化数据本身。还有在各个转换中使用的外部字段和变量。让我们考虑以下代码片段:

val factor = config.multiplicationFactor
rdd.map(_ * factor)

这里我们使用从应用程序配置加载的值作为计算本身的一部分。但是,由于转换函数外部发生的所有事情都发生在驱动程序上,因此Spark必须将值传输到相关的执行程序。因此Spark计算所谓的函数闭包map包含它使用的所有外部值,序列化这些值并通过网络发送它们。由于闭包可能非常复杂,因此决定仅在那里支持Java序列化。因此,闭包的序列化比数据本身的序列化效率低,但是由于闭包仅针对每个转换而不是每个转换的每个执行器进行序列化,因此这通常不会导致性能问题。(然而,需要这些值来实现令人不快的副作用Serializable。)

闭包中的变量很容易跟踪。使用字段可能会有很多混乱。我们来看下面的例子:

class SomeClass(d: Int) extends Serializable {
  val c = 1
  val e = new SomeComplexClass

  def closure(rdd: RDD[Int], b: Int): RDD[Int] = {
    val a = 0
    rdd.map(_ + a + b + c + d)
  }
}

在这里我们可以看到它a只是一个变量(就像factor之前一样),因此被序列化为Intb是一个方法参数(也表现为变量),因此也被序列化为Int。但是c是一个类字段,因此无法单独序列化。这意味着为了序列化它,Spark需要SomeClass用它来序列化整个实例(所以它必须扩展Serializable,否则我们会得到一个运行时异常)。d由于构造函数参数在内部转换为字段,因此也是如此。因此,在这两种情况下,星火也必须发送的值cde为遗嘱执行人。如e序列化的成本可能非常高,这绝对不是一个好的解决方案。我们可以通过避免闭包中的类字段来解决这个问题:

class SomeClass(d: Int) {
  val c = 1
  val e = new SomeComplexClass

  def closure(rdd: RDD[Int], b: Int): RDD[Int] = {
    val a = 0
    val sum = a + b + c + d
    rdd.map(_ + sum)
  }
}

这里我们通过将值存储在局部变量中来准备值sum。然后将其序列化为一个简单的Int并且不会拖动整个实例SomeClass(因此它不再需要扩展Serializable)。

Spark还定义了一个特殊的构造,以便在我们需要为多个转换序列化相同的值时提高性能。它被称为广播变量,并且在计算之前被序列化并仅发送给所有执行器一次。这对于查找表等大变量特别有用。

val broadcastMap = sc.broadcast(Map(0 -> "a", 1 -> "b", 2 -> "c"))
val input = sc.parallelize(1 to 1000000, 42)
val joined = input.map(x => x -> broadcastMap.value(x % 3))

Spark提供了一个有用的工具来确定名为SizeEstimator的内存中对象的实际大小,这可以帮助我们确定特定对象是否是广播变量的良好候选对象。

4.内存管理

应用程序以有效的方式使用其内存空间非常重要。由于每个应用程序的内存要求不同,Spark将应用程序驱动程序和执行程序的内存划分为多个部分,这些部分由适当的规则管理,并通过应用程序设置将其大小规范留给用户。

司机记忆

驱动程序的内存结构非常简单。它仅使用其配置的所有内存(由spark.driver.memory设置控制,默认为1GB)作为其共享堆空间。在群集部署设置中,还添加了一个开销,以防止YARN过早地使用过多资源来杀死驱动程序容器。

执行者记忆

执行者需要将他们的内存用于几个主要目的:当前转换的中间数据(执行内存),缓存的持久数据(存储内存)和转换中使用的自定义数据结构(用户内存)。由于Spark可以计算每个存储记录的实际大小,因此它能够监视执行和存储部分并做出相应的反应。执行内存的大小通常非常不稳定,需要立即执行,而存储内存使用寿命更长,更稳定,通常可以逐出磁盘,应用程序通常只需要整个计算的某些部分(有时根本不需要) )。因此,Spark为两者定义了共享空间,优先考虑执行内存。所有这些都由几个设置控制:spark.executor.memory(默认为1GB)定义可用堆空间的总大小,spark.memory.fraction设置(默认为0.6)定义执行和存储共享的内存的一小部分堆(减去300MB缓冲区)spark.memory.storageFraction(默认为0.5)定义了执行不可保存的存储内存部分。以最适合您的应用的方式定义它们很有用。例如,如果应用程序大量使用缓存数据并且不使用过多的聚合,则可以增加存储内存的比例以适应将所有缓存数据存储在RAM中,从而加快数据的读取速度。另一方面,如果应用程序使用昂贵的聚合并且不太依赖于缓存,则增加执行内存可以通过逐出不需要的缓存数据来改进计算本身。此外,请记住,您的自定义对象必须适合用户内存。

Spark还可以使用堆外内存进行存储和部分执行,这由设置spark.memory.offHeap.enabled(默认为false)和spark.memory.offHeap.size(默认为0)和OFF_HEAP持久性级别控制。这可以减轻垃圾收集暂停。

DataFrames和数据集

作为Project Tungsten的一部分,高级API使用自己的内存管理方式。由于数据类型是框架已知的,并且它们的生命周期定义得非常好,因此可以通过预先分配内存块并明确地对这些块进行微管理来完全避免垃圾收集。这样可以很好地重用已分配的内存,从而有效地消除了执行内存中垃圾收集的需要。这种优化实际上运行良好,使得堆外内存几乎没有额外的好处(尽管仍有一些)。

5.集群资源

通常导致性能降低的最后一个重点是群集资源分配不足。这需要多种形式,从低效使用数据局部性,处理分散执行程序到防止在不需要时占用集群资源。

Data locality

为了获得良好的性能,我们的应用程序的计算应尽可能接近实际数据,以避免不必要的传输。这意味着在同样存储数据本身的机器上运行执行程序是一个非常好的主意。使用HDFS时,Spark可以以最大化此概率的方式优化执行程序的分配。但是,我们可以通过良好的设计进一步提高这一点。

我们可以通过增加单个执行器的资源来减少所需的节点间通信量,同时减少执行器的总数,从而基本上强制任务由有限数量的节点处理。采用以下示例资源分配:

num_executors executor_cores executor_memory
15 1 1克
3 3克
3 5克

在所有情况下,我们将使用相同数量的资源(15核和15GB内存)。但是,随着我们减少执行程序的总数,我们也减少了在它们之间传输数据的需要。制定第三种选择通常是最快的。另一方面,节点级别的I / O吞吐量可能存在限制,具体取决于所请求的操作,因此我们无法无限期地增加它。例如,对于HDFS I / O,每个执行器的内核数量被认为在性能上达到峰值,大约为5。

我们还可以使用spark.locality.wait设置(默认为3秒)及其子部分(spark.locality.wait默认情况下相同)从群集中读取数据时调整Spark的局部性配置。这些定义了基于位置的调度的超时(在到达时降低了位置限制)。

Dynamic allocation

显式应用程序范围的执行程序分配可能有其缺点。在某些情况下,我们可能不希望在整个计算期间拥有统一数量的执行程序,而是希望进行一些扩展。在给定时间集群上可用的资源不足,但是我们想要运行我们的计算,我们可能正在处理需要更少资源并且不想比我们需要的更多的转换,等等。这是其中,动态分配的用武之地。

通过动态分配(通过设置spark.dynamicAllocation.enabled为true 启用)Spark通过尝试分配尽可能多的执行程序(最多为给定阶段的最大并行度或spark.dynamicAllocation.maxExecutors默认为无穷大)来开始每个阶段,其中第一阶段必须至少得到spark.dynamicAllocation.initialExecutors(相同于spark.dynamicAllocation.minExecutorsspark.executor.instances默认情况下)。

在计算过程中,如果执行程序空闲超过spark.dynamicAllocation.executorIdleTimeout(默认为60秒),它将被删除(除非它会使执行程序的数量低于spark.dynamicAllocation.minExecutors(默认为0)。这可确保我们的应用程序在执行时不会不必要地占用集群资源更便宜的转型。

为了能够启用动态分配,我们还必须启用Spark的外部shuffle服务。它充当在群集中的每台计算机上运行的单独服务器,当适当的执行程序不再存在(已被删除或丢失)时,该计算机能够管理随机文件。这在丢失执行者的情况下也是有益的(例如由于先发制人),因为不必重新计算所讨论的混洗数据。

Speculative execution

有时,即使我们正确地执行了所有操作,由于我们无法控制的情况(与Spark无关的重负载,硬件故障等),我们仍可能在特定计算机上的性能不佳。对于这些情况,我们可能会指示Spark在检测到此类落后者后自动重新执行任务。为此,请启用该spark.speculation设置。可以使用以下设置来配置检测例程:spark.speculation.interval定义检查落后者的频率(默认为100毫秒),spark.speculation.multiplier定义落后者必须慢多少倍(默认为1.5)并spark.speculation.quantile定义必须执行的任务的分数。完成,直到检测程序启动(默认为0.75)。

结论

正如您所看到的,为性能设计Spark应用程序可能非常具有挑战性,并且每一步都会增加复杂性,降低通用性或延长特定用例的分析。幸运的是,很少需要实现所有这些,因为无论如何典型的Spark应用程序都不是性能敏感的。此外,只需使用高级API(DataFrames或Datasets)即可实现很多功能。尽管在开发过程中必须尽早做出使用它们的决定,因为切换它们并非易事。

此外,还有许多其他技术可以帮助您进一步提高Spark作业的性能。即GC调整,适当的硬件配置和调整Spark的众多配置选项。

你可能感兴趣的:(Spark,Scala)