一、概览
从集群上的Spark Streaming应用程序中获得最佳性能需要一些调整。一般会考虑2个因素:
- 通过高效利用集群资源,减少每批数据的流转时长
- 设置正确的批量大小,以便批量数据可以在接收到时尽快处理(即数据处理跟上数据摄取)
二、减少批处理时间
在Spark中可以进行许多优化,以最小化每个批次的流转时长,这里列举下重要的点:
1、数据接收中的并行级别
通过网络(如Kafka、socket等)接收数据需要将数据反序列化并存储在Spark中。如果数据接收成为系统中的瓶颈,则考虑并行化数据接收。请注意,每个输入DStream创建一个接收单个数据流的单个接收器(在工作机器上运行)。因此,可以通过创建多个输入DStreams并将其配置为从源接收数据流的不同分区来实现接收多个数据流。例如,接收两个Topic数据的单个Kafka输入DStream可以拆分为两个Kafka输入流,每个输入流只接收一个Topic。
这将运行两个接收器,允许并行接收数据,从而提高整体吞吐量。可以将这些多个DStream联合在一起以创建单个DStream。然后,应用于单个输入DStream的转换可以应用于统一流。这如下所示:
Scala
val numStreams = 5
val kafkaStreams = (1 to numStreams).map { i => KafkaUtils.createStream(...) }
val unifiedStream = streamingContext.union(kafkaStreams)
unifiedStream.print()
另一个应该考虑的参数是接收器的块间隔,它由配置参数spark.streaming.blockInterval决定。对于大多数接收器来说,接收到的数据存储在Spark的内存之前会合并在一起成为数据块。每个批次中的块数量决定了将用于处理接收到的数据的任务数量,以类似map的方式进行转换。每个接收器每个批次的任务数量将大致为(批次间隔/块间隔)。例如,200毫秒的块间隔将每2秒批次创建10个任务。
如果任务数量太少(即少于每台机器的核心数量),那么它将是低效的,因为所有可用的核心都不会用于处理数据。要增加给定批处理间隔的任务数量,请减少块间隔。但是,块间隔的推荐最小值约为50毫秒,低于该值的任务启动开销可能是一个问题。
使用多个输入流/接收器接收数据的替代方法是显式地重新划分输入数据流(使用inputStream.repartition(<number of partitions>)
)。这在进一步处理之前将接收到的数据批次分布在集群中指定数量的机器上。
2、数据处理中的并行级别
如果在计算的任何阶段中使用的并行任务的数量都不够高,集群资源可能会利用不足。例如,对于像reduceByKeyAndWindow和reduceByKey这样的分布式减少操作,并行任务的默认数量由spark.default.parallelism 配置属性控制。您可以将并行级别作为参数传递,或者设置spark.default.parallelism 配置属性来更改默认值。
3、数据序列化
通过调整序列化格式可以减少数据序列化的开销。在流式传输的情况下,有两种类型的数据需要序列化:
- 输入数据:默认情况下,通过Receivers接收的输入数据存储在执行器的内存中StorageLevel.MEMORY_AND_DISK_SER_2也就是说,数据被序列化为字节以减少GC开销,并被复制以容忍执行器故障。此外,数据首先保存在内存中,只有当内存不足以保存流计算所需的所有输入数据时,数据才会溢出到磁盘。这种序列化显然有开销------接收方必须反序列化接收到的数据,并使用Spark的序列化格式重新序列化它。
- 流式计算生成的持久RDD:流式计算生成的RDD可以持久化在内存中。例如,窗口操作会将数据持久化在内存中,因为它们会被多次处理。然而,与Spark Core默认值StorageLevel.MEMORY_ONLY不同,流式计算生成的持久RDD默认使用StorageLevel.MEMORY_ONLY_SER(即序列化)以最大限度地减少GC开销
在这两种情况下,使用Kryo序列化都可以减少CPU和内存开销。对于Kryo,请考虑注册自定义类并禁用对象引用跟踪。
在需要为流应用程序保留的数据量不大的特定情况下,将数据(两种类型)作为反序列化对象持久化而不会产生过多的GC开销可能是可行的。例如,如果您使用几秒钟的批处理间隔并且没有窗口操作,那么您可以尝试通过相应地显式设置存储级别来禁用持久化数据中的序列化。这将减少序列化导致的CPU开销,从而在不产生太多GC开销的情况下潜在地提高性能。
4、任务启动开销
如果每秒启动的任务数量很高(例如每秒50个或更多),那么向执行器发送任务的开销可能很大,并且很难实现亚秒级延迟。可以通过以下更改来减少开销:
执行模式:在Standalone模式或粗粒度Mesos模式下运行Spark可以比细粒度Mesos模式获得更好的任务启动时间
三、设置合适的批处理间隔
为了使在集群上运行的Spark Streaming应用程序稳定,系统应该能够以与接收数据一样快的速度处理数据。换句话说,批量数据的处理速度应该与它们生成的速度一样快。对于应用程序是否如此,可以通过监控流式Web UI中的处理时间来发现,其中批次处理作业时间应该小于批次间隔。
根据流计算的性质,所使用的批处理间隔可能会对应用程序在一组固定的集群资源上可以维持的数据速率产生重大影响。例如,让我们考虑较早的WordCountNetwork示例。对于特定的数据速率,系统可能能够每2秒(即2秒的批处理间隔)跟上报告字数,但不是每500毫秒。因此需要设置批处理间隔,以便生产中的预期数据速率能够持续。
为了给应用程序找出合适的批处理大小,一个好方法是使用保守的批处理间隔(例如,5-10秒)和低数据速率进行测试。为了验证系统是否能够跟上数据速率,您可以检查每个处理的批处理所经历的端到端延迟的值(要么在Spark驱动程序log4j日志中查找"总延迟",要么使用StreamingListener
四、内存调整
Spark任务的内存优化
调整内存使用有三个注意事项:对象使用的内存量、访问这些对象的成本以及垃圾回收机制的开销(如果对象周转率很高)。
默认情况下,Java对象访问速度很快,但很容易比字段中的"原始"数据多消耗2-5倍的空间。这是由于几个原因:
- 每个不同的Java对象都有一个"对象头",大约16个字节,包含指向其类的指针等信息。对于数据很少的对象(比如一个Int字段),这可能比数据大。
- Java字符串在原始字符串数据上有大约40个字节的开销(因为它们将其存储在Chars数组中并保留额外的数据,例如长度),并且由于String内部使用UTF-16编码,每个字符都存储为两个字节。因此,一个10个字符的字符串很容易消耗60个字节。
- 常见的集合类,如HashMap和LinkedList,使用链接数据结构,其中每个条目都有一个"包装器"对象(例如Map. Entry)。这个对象不仅有一个标题,还有指向列表中下一个对象的指针(通常每个8个字节)。
- 原始类型的集合通常将它们存储为"装箱"对象,例如java. lang.Integer。
1、内存管理概述
Spark中的内存使用分为两类:执行和存储。执行内存是指用于洗牌、连接、排序和聚合中的计算,而存储内存是指用于缓存和跨集群传播内部数据的内存。在Spark中,执行和存储共享一个统一的区域(M)。当不使用执行内存时,存储可以获取所有可用内存,反之亦然。如果需要,执行可能会驱逐存储,但只有在总存储内存使用低于某个阈值(R)之前。换句话说,R描述了M中的一个子区域,其中缓存块永远不会被驱逐。由于实现的复杂性,存储可能不会驱逐执行。
这种设计确保了几个理想的属性。首先,不使用缓存的应用程序可以使用整个空间来执行,避免不必要的磁盘溢出。其次,使用缓存的应用程序可以保留最小的存储空间(R),其中它们的数据块不会被驱逐。最后,这种方法为各种工作负载提供了合理的开箱即用性能,而不需要用户了解如何在内部划分内存。
一般情况下我们不需要调整这两类内存,因为默认值适用于大多数工作负载:
- spark.memory.fraction:将M的大小表示为(JVM堆空间-300MiB)(默认0.6)的一小部分。其余空间(40%)保留给用户数据结构、Spark中的内部元数据,并在记录稀疏和异常大的情况下防止OOM错误。
- spark.memory.storageFraction:将R的大小表示为M的一小部分(默认为0.5)。R是M中的存储空间,其中缓存块不受执行驱逐的影响
为了在JVM的旧代或"终身"代中更好地适应这一堆空间量,应设置spark.memory.fraction的值
2、确定内存消耗
调整数据集所需内存消耗量的最佳方法是创建一个RDD,将其放入缓存,然后查看Web UI中的"存储"页面。该页面将告诉您RDD占用了多少内存。
要估计特定对象的内存消耗,请使用SizeEstimator的``estimate()
。这对于尝试不同的数据布局来削减内存使用以及确定广播变量将在每个执行器堆上占用的空间量很有用。
3、调整数据结构
减少内存消耗的第一种方法是避免增加开销的Java特性,例如基于指针的数据结构和包装对象。有几种方法可以做到这一点:
- 将数据结构设计为数组和基本类型,而不是标准的Java或Scala集合类(例如HashMap)。
- 尽可能避免使用带有大量小对象和指针的嵌套结构
- 使用数字ID或枚举对象而不是键的字符串
- 如果RAM小于32 GiB,请设置JVM标志-XX:+UseCompressedOops以使指针为四个字节而不是八个字节。可以在spark-env.sh中添加这些选项。
4、序列化RDD存储
当对象仍然太大而无法有效存储时,尽管进行了这种调整,减少内存使用的一种更简单的方法是以序列化形式存储它们,使用RDD持久性API中的序列化StorageLevels,例如MEMORY_ONLY_SER。然后,Spark将把每个RDD分区存储为一个大字节数组。以序列化形式存储数据的唯一缺点是访问时间较慢,因为必须动态反序列化每个对象。如果您想以序列化形式缓存数据,强烈建议使用Kryo,因为它会导致比Java序列化小得多的大小(当然也比原始Java对象小得多)。
5、GC调优
就程序存储的RDD而言,当有大量"流失"时,JVM垃圾回收机制可能会成为问题。(在只读取一次RDD然后对其运行许多操作的程序中,这通常不是问题。)当Java需要驱逐旧对象以为新对象腾出空间时,它将需要跟踪所有Java对象并找到未使用的对象。这里要记住的要点是,GC机制的成本与Java对象的数量成正比,因此使用对象较少的数据结构(例如Ints数组而不是LinkedList)大大降低了这种成本。
一个更好的方法是以序列化的形式持久化对象,如上所述:现在每个RDD分区只有一个对象(一个字节数组)。在尝试其他技术之前,如果GC有问题,首先要尝试的是使用序列化缓存。
由于任务的工作内存(运行任务所需的空间量)和节点上缓存的RDD之间的干扰,GC也可能是一个问题。
测量GC的影响
GC调优的第一步是收集有关垃圾回收机制发生频率和GC花费的时间量的统计信息。这可以通过在Java选项中添加-详细:gc-XX:+PrintGC详细信息-XX:+PrintGCTimeStamps来完成。
bash
./bin/spark-submit \
--name "My app" \
--master local[4] \
--conf spark.eventLog.enabled=false \
--conf "spark.executor.extraJavaOptions=-XX:+PrintGCDetails -XX:+PrintGCTimeStamps" \
myApp.jar
下次运行Spark作业时,每次发生垃圾回收机制时,您都会在工作人员的日志中看到打印的消息。请注意,这些日志将在集群的工作节点上(在其工作目录的stdout文件中),而不是在driver 上
高级GC调优
为了进一步调整垃圾回收机制,我们首先需要了解一些关于JVM内存管理的基本信息:
- Java堆空间分为两个区域Young(新生代)和Old(老年代)。Young代用于保存生命周期较短的对象,而Old代用于保存生命周期较长的对象
- 年轻一代进一步分为三个区域[伊甸园,幸存者1,幸存者2 (Eden, Survivor1, Survivor2)]。
- 垃圾回收机制过程的简化描述:当Eden已满时,在Eden上运行一个小GC,并将Eden和Survivo1中的活动对象复制到Survivo2。交换Survivor区域。如果对象足够旧或Survivo2已满,则将其移动到Old。最后,当Old接近已满时,调用一个完整的GC。
在Spark中进行GC调优的目的是确保只有长寿命RDD存储在老一代中,而年轻一代的大小足以存储短寿命对象。这将有助于避免使用完整的GC来收集在任务执行期间创建的临时对象。一些可能有用的步骤是:
- 通过收集GC统计信息检查垃圾收集是否过多。如果在任务完成之前多次调用完整的GC,这意味着没有足够的可用存储器来执行任务。
- 如果有太多的小集合,但主要GC不多,为Eden分配更多的内存会有所帮助。您可以将Eden的大小设置为对每个任务需要多少内存的高估。如果Eden的大小确定为E,那么您可以使用选项-Xmn=4/3*E设置年轻一代的大小。(扩大4/3也是为了考虑幸存者区域使用的空间。)
- 在打印的GC统计信息中,如果OldGen接近满,则通过降低
spark.memory.fraction
来减少用于缓存的内存量;缓存更少的对象比减慢任务执行速度要好。或者,考虑减小年轻代的大小。如果您已按上述方式设置,这意味着降低-Xmn。如果没有,请尝试更改JVM的NewRatio参数的值。许多JVM将其默认为2,这意味着老年代占据了堆的2/3。它应该足够大,以便该分数超过spark.memory.fraction
。 - 尝试使用-XX:+UseG1GC的G1GC垃圾回收器。它可以在垃圾回收机制成为瓶颈的某些情况下提高性能。请注意,对于大型执行器堆大小,使用-XX:G1HeapRegionSize增加G1区域大小可能很重要。
- 例如,如果任务正在从HDFS读取数据,则可以使用从HDFS读取的数据块的大小来估计任务使用的内存量。请注意,解压缩块的大小通常是块大小的2或3倍。因此,如果我们希望有3或4个任务的工作空间,并且HDFS块大小为128 MiB,我们可以估计Eden的大小为4*3*128MiB。
- 监控垃圾回收机制的频率和时间如何随新设置而变化
经验表明,GC调优的效果取决于应用程序和可用存储器的数量。管理完整GC发生的频率有助于减少开销。
executors 的GC调优标志可以通过在作业配置中设置 spark.executor.defaultJavaOptions
或spark.executor.extraJavaOptions来指定。
Spark Streaming 内存优化
Spark Streaming应用程序所需的集群内存量在很大程度上取决于所使用的转换类型。例如,如果想对最后10分钟的数据使用窗口操作,那么集群应该有足够的内存来保存内存中价值10分钟的数据。或者如果想使用具有大量键的updateStateByKey,那么必要的内存将很高。相反,如果想进行简单的map-filter-store操作,那么必要的内存将很低。
一般来说,由于通过接收器接收的数据存储在StorageLevel中。MEMORY_AND_DISK_SER_2,不适合内存的数据会溢出到磁盘上。这可能会降低流应用程序的性能,因此建议根据流应用程序的要求提供足够的内存。最好尝试在小范围内查看内存使用情况并做出相应的估计。
内存调优的另一个方面是垃圾回收机制。对于需要低延迟的流应用程序,JVM垃圾回收引起的大暂停是不可取的。
以下是一些调整内存使用情况和GC开销的参数:
- DStreams的持久性级别:在前面的数据序列化部分中提到,输入数据和RDD默认保存为序列化字节。与反序列化持久性相比,这减少了内存使用和GC开销。启用Kryo序列化进一步减少了序列化大小和内存使用。可以通过压缩来进一步减少内存使用,但代价是CPU时间。
- 清除旧数据:默认情况下,DStream转换生成的所有输入数据和持久化RDD都会自动清除。Spark Streaming根据所使用的转换决定何时清除数据。例如,如果您使用的是10分钟的窗口操作,那么Spark Streaming将保留大约最后10分钟的数据,并主动丢弃旧数据。通过设置
streamingContext.remember
,数据可以保留更长的持续时间(例如交互式查询旧数据)。 - CMS垃圾收集器:强烈建议使用并发标记和扫描气相色谱来保持气相色谱相关的暂停持续较低。尽管并发气相色谱已知会降低系统的整体处理吞吐量,但仍建议使用它来实现更一致的批次处理作业时间。确保您在驱动程序(在spark-submit中使用
--driver-java-options
)和执行程序上设置了CMS气相色谱(使用Spark配置spark.executor.extraJavaOptions
) - 其他提示:为了进一步减少GC开销,这里有更多的提示可以尝试:使用OFF_HEAP存储级别保持RDD、使用更多堆大小较小的执行器。这将减少每个JVM堆中的GC压力
要点:
1、DStream与单个接收器相关联。为了获得读取并行性,需要创建多个接收器,即多个DStream。接收器在executor中运行。它占用一个内核。确保在预订接收器插槽后有足够的内核进行处理,即spark.cores.max应考虑接收器插槽。接收器以循环方式分配给executor。
2、当接收到来自流源的数据时,接收方创建数据块。每隔一个blockInterval毫秒就会生成一个新的数据块。在batchInterval 期间创建N个数据块,其中N=batchInterval/blockInterval。这些块由当前executor 的BlockManager分配给其他executor 的BlockManager 。之后,在driver 上运行的Network Input Tracker被告知块位置以供进一步处理。
3、在driver 上为batchInterval期间创建的块创建一个RDD。batchInterval 期间生成的块是RDD的分区。每个分区都是spark中的一个任务。blockInterval== batchinterval 意味着创建了一个分区,并且可能是在本地处理的。
4、块上的map任务在执行程序(一个接收块,另一个复制块)中处理,执行程序具有块,而与块间隔无关,除非非本地调度开始。拥有更大的块间隔意味着更大的块。高值的 spark.locality.wait
增加了在本地节点上处理块的机会。需要在这两个参数之间找到平衡,以确保更大的块在本地处理。
5、与batchInterval 和blockInterval不同的是,您可以通过调用inputDstream.repartition(n)
.来定义分区数量。这会随机重新洗牌RDD中的数据以创建n个分区。是的,为了更大的并行性。尽管是以洗牌为代价的。RDD的处理由driver'的作业调度程序作为作业进行调度。在给定的时间点,只有一个作业处于活动状态。因此,如果一个作业正在执行,其他作业就会排队。
6、如果您有两个dstream,将形成两个RDD,并且将创建两个作业,这些作业将一个接一个地调度。为了避免这种情况,您可以联合两个dstream。这将确保为dstream的两个RDD形成一个unionRDD。然后将此unionRDD视为单个作业。但是,RDD的分区不受影响。
7、如果批次处理作业的时间超过了批次间隔,那么显然接收器的内存将开始填满,并最终抛出异常(很可能是BlockNotFoundException)。目前,没有办法暂停接收器。使用SparkConf配置spark.streaming.receiver.maxRate
,,可以限制接收器的速率。