Scala实践Spark(六)Spark调优与调试

SparkConf 配置Spark

创建一个应用

import org.apache.spark.SparkContext
import org.apache.spark.SparkConf
val conf = new SparkConf().setAppName("spark name").setMaster("local[4]")
val sc = new SparkContext(conf)

spark-submit工具为常用的Spark配置参数提供了专用标记,还有通用标记来接受任意Spark配置项的值。

$ bin/spark-submit \
	--class com.example.Myapp \
	--master local[4] \
	--name "My app" \
	--conf spark.ui.port=36000 \
	myApp.jar

当然也支持从文件中读取配置项的值。

$ bin.spark-submit \ 
	--class com.example.MyApp \ 
	--properties-file my-config.conf \
	myApp.jar

## 上述配置文件的内容  ##
spark.master   local[4]
spark.app.name   "My app"
spark.ui.port   36000	

一旦传给SparkContext的构造方法,应用所绑定的SparkConf就不可变了。
有时,同一个配置项可能在多个地方被设置,某用户可能在程序代码直接调用了setAppName(),同时也通过spark-submit的–name设置的这个值,针对这种情况,Spark有特定的优先级顺序选择实际配置,优先级最高是用户代码显式调用set()方法设置的选项,其次是spark-submit传递的参数,再次是写在配置文件的值,最后是系统的默认值。
常用的设置 Spark Configuration
几乎所有的Spark配置都发生在SparkConf的创建过程中,有一个重要选项是例外,你需要在conf/spark-env.sh将环境变量 SPARK_LOCAL_DIRS设置逗号隔开的存储位置列表,来指定Spark用来混洗数据的本地存储路径,这需要独立模式和Mesos模式设置,这个配置项之所以和其他的Spark配置项不一样,因为它的值在不同的物理主机上可能有所区别。

Spark执行:作业、任务、步骤

下面通过一个示例来理解一下Spark执行的各个阶段,以了解用户代码如何被编译为下层的执行计划。
以下是一个输入文本

## input.txt  ##
INFO This is a message with content
INFO This is some other content

INFO Here are songe messages
WARN This is a warning

ERROR Something bad happened
WARN More details on the bad thing
INFO back to normal messages

在Scala版本的Spark shell 中处理文本数据

scala> val input = sc.textFile("input.txt")
scala> val tokenized = input.map(line => line.split(" ")).filter(words => words.size>0)
scala> val counts = tokenized.map(words => (words(0),1)).reduceByKey{(a,b) => a+b}

这一系列命令生成了一个叫做counts的RDD,其中包含各级别对应的条目数。在shell执行这些命令后,程序并没有执行任何行动操作,相反,程序定义了一个RDD对象的有向无环图,我们可以在稍后行动操作被触发后用它来进行计算。每个RDD维护了其指向一个或多个父节点的引用,以及表示其余父节点之间关系的信息。比如,当你在RDD调用val b = a.map()时,b这个RDD就存下了对其父节点a的一个引用,这些引用可以使得RDD可以追踪到其所有的祖先节点。Spark提供了toDebugString()方法查看RDD的谱系。
收集RDD

scala> counts.collect()
res86: Array[(String,Int)] = Array((ERROT,1),(INFO,4),(WRAN,2))]

Spark调度器会创建出用于计算行动操作的RDD的物理执行计划。当我们调用collect()方法,RDD的每个分区都会被物化出来并发送到驱动器程序中。Spark从最终被调用行动操作的RDD出发,向上回溯所有必须计算的RDD。调度器会访问RDD的父节点、父节点的父节点,递归向上生成计算所有必要的祖先RDD的物理计划。
RDD图与执行计划的对应关系不一定是一一对应的,比如当调度器流水线执行,或把多个RDD合并到一个步骤中。当RDD不需要混洗数据就可以从父节点计算出来,调度器会自动进行流水线执行.我们通过上述toDebugString()输出的谱系图使用不同缩进来展示RDD是否会在物理步骤中进行流水线执行。
上述的collect()操作引发了两个步骤,ShuffleRDD前的HadoopRDD->MappedRDD->MappedRDD->FilterRDD为一条流水线执行。
除了流水线执行的优化,当一个RDD已经缓存在集群内存或磁盘上时,Spark内部调度器也会自动截短RDD谱系图。这种情况下,Spark会短路求值,直接基于缓存下来的RDD计算。还有一种截短RDD谱系图的情况发生在当RDD已经在之前的数据混洗中作为副产品物化出来时,哪怕该RDD并没有被显式的调用presist(),这种内部优化是基于Spark混洗操作的输出均被写入磁盘的特性,也利用了RDD某些部分会被多次计算的事实。
接下来我们将countsRDD缓存下来,查看之后的行动操作是怎样被截短的。

scala> counts.cache()
scala> counts.collect()
res87: Array[(String,Int)] = Array((ERROR,1),(INFO,4),(WARN,2),(##,1),(empty,2))
scala> counts.collect()
res88: Array[(String,Int)] = Array((ERROR,1),(INFO,4),(WARN,2),(##,1),(empty,2))

特定的行动操作所生成的步骤的集合被称为一个作业。我们通过类似count()的方法触发行动操作,创建出由一个或多个步骤组成的作业。步骤图确定下来,任务就会被创建出来发给内部的调度器。该调度器在不同的部署模式下会有所不同,物理计划的步骤会依赖其他步骤,如一个输出混洗后的数据的步骤会一定依赖于进行数据混洗的那个步骤。
一个物理步骤会启动很多任务,每个任务都是在不同的分区做同样的事情。任务内的流程是一样的如下:

  • 1.从数据存储或已有RDD或数据混洗的输出中获取输入数据。
  • 2.执行必要的操作计算这些操作所代表的RDD,对输入数据执行filter()和map()函数,或者进行分组或归约操作。
  • 3.把输出写到一个数据混洗文件中,写入外部存储或者发回驱动器程序。

Spark执行时有以下流程:

  • 用户代码定义RDD的有向无环图,RDD的操作会创建新的RDD,并引用他们的父节点,这样就创建了一个图
  • 行动操作把有向无环图强制转译为执行计划,当你调用一个RDD行动时,这个RDD必须被计算出来,也就要计算该RDD的父节点。Spark调度器提交一个作业来计算所有必要的RDD,这个作业会包含一个或多个步骤,每个步骤其实就是一波并行执行的计算任务,一个步骤对应有向无环图中的一个或多个RDD,因为一个步骤可能发生了流水线执行。
  • 任务于集群中调度并执行。步骤按顺序处理的,任务则独立地启动来计算RDD的一部分。一点作业的最后一个步骤结束,一个行动操作执行完毕。

查找信息

Spark在应用执行时记录详细的进度信息和性能指标。可以在网页界面(4040端口)和驱动器进程以及执行器进程生成的日志文件中。不过对于Yarn集群模式来说,应用的驱动器程序运行在集群内部,应该通过Yarn的资源管理器访问用户界面。

关键性能考量

Spark调度并运行任务时,Spark会为每个分区中的数据创建一个任务,该任务默认需要集群中的一个计算核心来执行。输入RDD会根据其底层的存储系统选择并行度,例如从HDFS上读数据的输入RDD会为数据在HDFS的每个文件区块创建一个分区。从数据混洗后的RDD派生下来的RDD则会采用与其父RDD相同的并行度。
并行度会从两方面影响程序的性能,当并行度过低,Spark集群会出现闲置资源;当并行度过高,每个分区产生的简介开销累计起来会更大。评判评定度是否过高的标准包括任务是否是几乎在瞬间完成,或者是否观察到任务没有任何读写任何数据。
有两种方法对操作的并行度调优,第一是在数据混洗时,使用参数的方式为混洗后的RDD指定并行度;第二种方法是对于任何已有的RDD,可以进行重新分区来获取更多或更少的分区数。重新分区通过repartition()实现,该操作随机打乱并分成设定的分区数目。如果确定要减少RDD分区,可以使用coalesce()更高效。
举个例子,假设我们从S3读取了大量数据,然后filter()筛选掉数据集的大部分数据,默认filter()筛选掉该数据集的大部分数据。默认情况下filter()返回的RDD分区数和其父节点一样,这样会产生很多空的分区或只有很少数据的分区,这种情况可以通过合并得到分区更少的RDD来提高应用性能。
以下是pyspark-shell合并分区过多的RDD

>>> input = sc.textFile("...")
>>> input.getNumPartitions()
>>> lines = input.filter(lambda line: line.startwith("2014-10-17"))
>>> lines.getNumPartitions()
>>> lines.coalesce(5).cache()
>>> lines.getNumPartitions()
>>> lines.count()

当Spark需要通过网络传输数据,或是将数据溢写到磁盘上,Spark需要把数据序列化为二进制格式。序列化会在数据进行混洗操作时发生,默认Spark使用Java内建的序列化库,也支持第三方库Kryo,可以提供比Java的序列化工具更短的序列化时间和更高压缩比的二进制,但不能序列化全部类型的对象。
要使用Kryo,设置spark.serializerorg.apache.spark.serializer为KryoSerializer。为了获得最佳性能,需要向Kryo注册你想要序列化的类。注册类可以让Kryo避免把每个对象的完整的类写下来。如果你想强制要求这种注册,可以把spark.kryo.registrationRequired设置为true,这样Kryo会在遇到未注册的类时抛出错误。

val conf = new Sparkconf()
conf.set("spark.serializer","org.apache.spark.serializer.KryoSerializer")
conf.set("spark.kryo.registrationRequired","true")
conf.registerKryoClassed(Array(classOf[MyClass],classOf[MyOtherClass]))

内存管理

内存有以下几种用户:
RDD存储,当调用RDD的persist()cache(),这个RDD的分区会被存储到缓存区中。Spark会根据spark.storage.memoryFraction限制用来缓存的内存占整个JVM堆空间的比例大小。如果超出限制,旧的分区数据会被移出内存。
数据混洗与聚合的缓存区,Spark会创建出一些中间缓存区来存储数据混洗的输出数据(前面提到数据混洗后的数据可以做短路连接,不需要重新计算或persist),这些缓存区用来存储聚合操作的中间结果,以及数据混洗操作中直接输出的部分缓存数据。Spark会尝试根据spark.shuffle.memoryFraction限制这种缓存区内存占总内存的比例。
用户代码,用户的函数可以自行申请大量内存,如果一个用户应用分配了巨大的数组或其他对象,这些都会占用总的内存。用户代码可以访问JVM堆空间中除分配给RDD存储和数据混洗存储以外的全部剩余空间。
默认情况下,Spark会使用60%的空间存储RDD,20%存储数据混洗操作产生的数据,20%留给用户程序。我们可以自定调节这些选项来追求更好的性能表现。
除了调整内存各区域比例,我们还可以改进缓存行为的某些要素。Spark默认的cache()操作会以MEMORY_ONLY的存储等级持久化数据。当RDD分区空间不够时,旧的分区就会直接被删除。当用到这些分区数据时重新测算。有时候以MEMORY_AND_DISK的存储等级调用persist()方法会获得更好的效果,这样内存放不下的旧分区会被写入磁盘,当再次需要用到则从磁盘上读取,这样的代价可能比重算各分区低很多,性能更稳定。
对于默认缓存策略的另一个改进是缓存序列化的对象而非直接缓存。可以通过MEMORY_ONLY_SERMEMORY_AND_DISK_SER的存储等级实现这一点。缓存序列化的对象使缓存过程变慢,因为序列化也会消耗一些代价,不过可以显著减少JVM的垃圾回收时间,因为很多独立的记录现在可以作为单个序列化的缓存而存储,垃圾回收的代价与堆里的对象数目相关,而不是和数据字节数相关。这种缓存方式会把大量对象序列化为一个巨大的缓存区对象。如果你需要以对象的形式缓存大量数据或是注意到了长时间的垃圾回收暂停,可以配置此选项。

硬件供给

影响集群的主要参数包括分配给每个执行器节点的内存大小、每个执行器节点占用的核心数、执行器节点总数,以及用来存储临时数据的本地磁盘数量。这些配置在之前都介绍过。
一般而言,更大的内存和更多的计算核心对Spark应用更有用处。Spark的架构允许线性伸缩;双倍的资源通常能使应用的运行时间减半,在调整集群规模时,需要额外考虑的方面还包括是否在计算中把中间结果数据集缓存起来。如果确实要使用缓存,那么内存缓存的数据越多,应用的表现越好。Spark用户界面中的存储页面会展示缓存的数据有哪些部分保留在内存中,我们可以通过在集群缓存一部分数据开始,推算缓存大量数据所需要的总内存量。
除了内存和CPU核心,Spark还要用到本地磁盘来存储数据混洗操作的中间数据,以及溢写到磁盘中的RDD分区数据。因此,使用大量本地磁盘可以帮助提升Spark应用的性能。在Yarn提供了自己指定的临时数据存储目录的机制,Spark本地磁盘配置项会直接从Yarn的配置中读取。独立模式下,我们可以在部署集群时,在spark-env.sh文件中设置环境变量SPARK_LOCAL_DIRS,这样Spark应用启动时就会自动读取这个配置项的值。如果运行的是Mesos模式,或者在别的模式下需要重载集群默认的存储位置,可以使用spark.local.dir选项来实现配置。在所有情况下,本地目录的设置都应当使用由单个逗号隔开的目录列表。一般的做法是在磁盘的每个分卷中都会Spark设置一个本地目录。写操作会被均衡地分配到所有提供的目录中,磁盘越多,可以提供的总吞吐量越高。
“越多越好”的原则在设置执行器节点内存时并不一定适用。适用巨大的堆空间可能会导致垃圾回收的长时间暂停,从而严重影响Spark作业的吞吐量。有时,适用较小内存的执行器实例可以缓解该问题。Mesos和Yarn本身就已经支持在一个物理主机运行多个较小的执行器实例,所以适用较小的执行器实例不代表应用的总资源一定减少。而在Spark的独立模式中,我们需要启动多个工作节点实例(SPARK_WORKER_INSTANCES)来让单个应用在一台主机上运行于多个执行器节点中。

下一篇是Spark处理结构化和半结构化数据的接口Spark SQL

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值