Spark内存管理机制

Spark内存管理机制

1 内存管理面临的挑战

  1. 内存消耗来源多种多样,难以统一管理。Spark运行时内存消耗主要包括3个方面:
    1. 框架本身在数据处理时所需要的内存(如Shuffle Read/Write阶段使用的类HashMap和Array数组)
    2. 数据缓存,对于需要重复使用的数据,我们一般缓存到内存中,避免重复计算
    3. 用户代码消耗的内存(User Code),如用户在reduceByKey(func)、mapPartitions(func)中自定踹出的数据结构,暂存处理结果
  2. 内存消耗动态变化、难以预估,为内存分配和回收带来困难。Spark运行时的内存消耗内存和多种因素相关,如Shuffle机制中的内存用量与Shuffle数据量、分区个数,用户自定的聚合函数相关,难以预估。用户代码的内存用量与func的计算逻辑、输入数据量有关,也难以估计。并且这些内存消耗来源产生的内存对象的生命周期不同。
  3. task之间共享内存,导致内存竞争。task一线程方式运行在同一个Executor JVM中,task之间还存在内存共享和内存竞争。

2 应用内存消耗来源和影响因素

Hadoop MapReduce中,task是以JVm进程方式运行,因此内存消耗指的是进程的内存消耗。而Spark的task是以线程方式运行在Executor JVM中,因此,Spark内存消耗在微观上指的是task线程的内存消耗,在宏观上指的是Executor JVM的内存消耗。由于Executor JVM中可以同时运行多个task,存在内存竞争,为了简化分析,我们主要关注单个task的内存消耗。

2.1 用户代码

用户代码指的是用户采用数据操作和其中死func,如map(func),reduceByKey(func)等。func有两种类型:一种是一进一出的,对产生的结果不进行存储,这类内存消耗可以忽略不计。另一种是对中间计算结果进行一定程度的存储,如groupByKey(),需要利用数组对数据进行存储,并存储计算结果,这些中间结果会造成内存消耗。

影响因素:包括数据操作的输入数据大小,以及func的空间复杂度。输入数据大小决定用户代码会产生多少个中间计算结果,空间复杂度决定有多少中间计算结果保存在内存之中。

2.2 Shuffle机制中产生的中间数据

Shuffle Write:该阶段首先对map task输出的Output-records进行分区,以便后续分配不同的reduce task,在这个过程中只需要计算每个record的partitionId,因此内存消耗可以忽略不计。然后,如果需要进行combine()聚合,没么Spark会将record内存存放到AppendOnlyMap或ExternalAppendOnlyMap类HashMap的数据结构中进行聚合,这个过程中HashMap会占用大量内存。最后Spark会按照partitionId或Key对record进行排序,这个过程中可能会使用数组保存record,也会消耗一定的内存空间。

Shuffle Read:该阶段将来自不同map task的分区数据进行聚合、排序,得到结果后进行下一步计算。首先分配一个buffer暂存从不同map task获取的record,这个buffer需要消耗一些内存。然后,如果需要对数据进行聚合,那么Spark将采用类似HashMap的数据结构对这些record进行聚合,会占用大量内存空间。最后,如果需要对Key进行排序,那么会建立数组来进行排序,需要消耗一定内存空间。

影响因素:首先,Shuffle方式影响内存消耗,对分区、排序、聚合这些需求不同导致需要的内存不同。其次Shuffle Write/Read的数据量影响中间数据大小,进而影响内存消耗。最后,用户定义的聚合函数的空间复杂度影响中间计算结果大小,进而影响内存消耗。

2.3 缓存数据

RDD会被缓存到内存中以减少下一个job的计算开销,尤其是迭代型应用对这方面的需求更大。

影响因素:需要缓存的RDD大小、缓存级别、是否序列化等。

3 Spark框架统一内存管理模型(UnifiedMemoryManager)

将内存划分为3个分区:数据缓存空间、框架执行空间和用户代码空间。在这3个分区中,数据缓存空间和框架执行空间组成(共享)了一个大的空间,称为Framework memory。Framework memory大小固定,且为数据缓存空间和框架执行空间设置了初始比例,但这个比例可以在应用执行过程中动态调整。同时,两者之间比例也有上下界,使得一方不能完全侵占另一方的空间,从而为了避免某一方空间占满导致后续的数据缓存操作或Shuffle操作无法执行。对于用户代码空间,Spark将其设定为固定大小,原因是难以在运行时获取用户代码的真实内存消耗,也就难以动态设定用户代码空间的比例。

另外,当框架内存执行空间不足时,会将Shuffle数据spill到磁盘上;当数据缓存空间不足时,Spark会进行缓存替换,移除缓存数据等操作。最后,为了限制每个task的内存使用,也会对每个task的内存使用限额。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wQz2RklL-1675353299035)(Spark内存模型.jpg)]

Executor JVM的整个内存空间划分为以下3个部分:

  1. Reserved Memory(系统保留内存):Reserved Memory使用较小的空间存储Spark框架产生的内部对象(如Spark Executor对想,TaskMemoryManager对象等Spark内部对象),系统保留内存大小通过spark.testing.ReservedMemory设置(默认为300MB)。
  2. User Memory(用户代码空间):用户代码空间被用于存储用户代码生成的对象,如map()中用户自定义的数据结构。用户代码空间默认为40%的内存空间。
  3. Framework Memory(框架内存):Framework Memory包括Executor Memory和Storage Memory。总大小为spark.memory.fraction(default 0.6)*(heap-Reserved memory),约等于60%的内存空间,两者共享这个空间,其中一方空间不足时可以动态向另一方借用。但是至少要保证数据缓存空间具有50%左右(spark.memory.storageFraction(default 0.5)*FrameWork memory大小)的空间。在框架执行时,借走的空间不会归还给数据缓存空间,原因是难以实现。
  4. Framwork Memory的堆外内存:为了减少GC开销,Spark也允许使用堆外内存,这个空间不受JVM GC管理,在结束时需要手动释放空间。因为堆外内存主要存储序列化对象数据,而用户代码处理的是普通Java对象,因此堆外内存只用于框架执行空间和数据缓存空间,而不用于用户代码空间。如果用户定义了堆外内存,其大小通过spark.memory.offHeap.size设置,那么Spark仍然会按照堆外内存使用的spark.memory.storageFraction比例将堆外内存分为框架执行空间和数据缓存空间,而且堆外内存的管理方式和功能与堆内内存的Framework Memory一样。在运行应用是,Spark会根据应用Shuffle方式及用户设置的数据缓存级别来使用堆内内存还是堆外内存。SerializedShuffle方式可以利用堆外内存来进行Shuffle Write,用户可以使用rdd.persist(OFF_HEAP)后可以将rdd存储到堆外内存。

注意:虽然Spark内存模型可以限制框架使用的空间大小,但无法控制用户代码的内存消耗量,用户代码运行时的实际内存消耗量可能超过用户代码空间的界限,侵占框架使用的空间,此时如果框架也使用了大量内存空间,则可能会OOM。

4 Spark框架执行内存消耗与管理

内存共享与竞争

由于Executor中存在多个task,因此框架执行空间实际上是由多个task(ShuffleMapTask或ResultTask)共享。在运行过程中,Executor中活跃的task数目在[0,#ExecutorCores]内变化,#ExecutorCores是每个Executor分配的CPU个数。为了公平性,每个task可使用的内存空间被均分,也就是空间大小被控制在[1/2N,1/N]*ExecutorMemory内,N是当前活跃的task数目。这个策略也适用于堆外内存中的Executition Memory。

内存使用

框架执行空间主要用于Shuffle阶段,Shuffle阶段中需要对数据进行partition、sort、merge、fetch、aggregate等操作,Spark的数据结构会优先将这个过程中产生的中间数据保存在内存中,当内存不足时,spill到磁盘上。

4.1 Shuffle Write阶段内存消耗和管理

Map()端聚合按Key排序Partition类别Shuffle Write实现类内存消耗
NoNo<=200SerializedBypassMergeSortShuffleWriterFixed buffers(on-heap)
NoNo>200 & <16777216(2^24)SerializedSerializedShuffleWriterPointerArray+Data pages(On-heap or off-heap)
NoYesUnlimitedUnserializedSortShuffleWriter(KeyOrdering=true)Array(on-heap)
YesYes/NoUnlimitedUnserializedSortShuffleWriter(mapSideCombine=true)HashMap(on-heap)
  1. BypassMergeSortShuffle-Writer:基于buffer,无map()端聚合,无排序且partition个数不超过200。
  2. SerializedShuffleWriter:无map()端聚合,无排序且partition个数大于200的情况。
  3. SortShuffleWriter(KeyOrder=true):基于数组,无map()端聚合但需要排序的情况。
  4. SortShuffleWriter(mapSideCombine=true):基于HashMap,有map()端聚合的情况。

1、2、4这3种方式都是利用堆内存来聚合、排序record对象的,属于Unserialized Shuffle方式。这种方式处理的record对象是普通的Java对象,有较大的内存消耗,也会造成较大的JVM立即回收开销。Spark为了提高Shuffle效率,在2.0版本中引入了Serialized Shuffle方式,核心思想是直接在内存中操作序列化后的record对象(二进制数据),降低内存消耗和GC开销,同时也可以利用堆外内存。然后,由于Serialized Shuffle方式处理的是序列化后的数据,也有一些适用性上的不足,如在Shuffle Write中,只用于无map()端聚合且无排序的情况。

BypassmergeSortShuffle-Writer

不需要map()端聚合,不需要按Key进行排序,且分区个数较小,只需要实现数据分区功能即可。buffer-based Shuffle。map()依次输出<K,V>record,并根据record的PID,将其输出到不同的buffer中,每当buffer填满record溢写到磁盘上的分区文件中。分配buffer的原因是map()输出record的速度很快,需要进行缓冲来减少磁盘I/O。

内存消耗:整个Shuffle Writer过程中只有buffer消耗内存,buffer被分配在堆内内存(On-heap)中,buffer的个数与分区个数相等,并且生命周期直至Shuffle Write结束。因此,每个task的内存消耗为BufferSize(default 32KB)*partition number。如果partition个数较多,task数目也较多,那么总的内存消耗会很大。所以,该Shuffle方式只适用于分区个数较小的情况。

SerializedShuffleWriter

不需要map()端聚合,不需要按Key进行排序,但分区个数较大。当分区个数较多时,BypassmergeSortShuffle-Writer这种方式buffer内存消耗过大,此时可以采用基于数组排序的方法,将map()输出的<K,V>record不断放进数组,然后将数组里的record按照PID进行排序,最后输出即可。这样是可行的,但是普通的record对象是Java对象,占用空间较大,需要大的数组,而太大的数组容易造成OMM。另外,大量record对象存放到内存中也会造成频繁GC。前面提到过,为了提升内存利用率,Spark设计了SerializedShuffle方式(SerializedShuffleWriter),将record对象序列化再存放到可分页存储的数组中,序列化可以减少存储开销,分页可以利用不连续的空间。

内存消耗:PointerArray,存储record的Page,sort算法所需的额外空间,总大小不超过task的内存限制。需要注意的是,单个数据结构(如PointerArray、serialized record)不能同时使用堆内内存和堆外内存,因此Serialized Shuf使用堆外内存最大的问题是,在Shuffle Write时不能同时利用堆内内存和堆外内存,可能会造成更多的spill次数。

Serialized Shuffle的优点

  1. 序列化后的record占用内存空间小。
  2. 不需要连续的内存空间。Serialized Shuffle将存储record的数组进行分页,分页可以利用内存碎片,不需要连续的内存空间,而普通数组需要连续的内存空间。
  3. 排序效率高。对序列化后的record按PID进行排序时,排序的不是record本身,而record序列化后字节数组的指针(元数据)。由于直接基于二进制数据进行操作,所以在这里面没有序列化和反序列化的过程,内存和GC开销降低。
  4. 可以使用cache-efficient sort等优化技术,提高排序性能。
  5. 可以使用堆外内存,分页可以方便统一管理堆内内存和堆外内存。

使用Serialized Shuffle需要满足4个条件:

  1. 不需要map()端聚合,也不需要按Key进行排序。
  2. 使用的序列化类(serializer)支持序列化Value的位置互换功能(relocation of serialized Value),目前KryoSerializer和Spark SQL的custom serializers都支持该功能。
  3. 分区个数小于16777216(2^24)。
  4. 单个Serialized record小于128MB。

实现方式:Serialized Shufffle采用了分页技术,想操作系统一样将内存空间划分为Page,每个Page大小在1MB~64MB,既可以在堆内内存上分配,也可以在堆外内存上分配。Page由Executor中的TaskMemoryManager对象来管理,TaskMemoryManager包含一个PageTable,可以最多寻址8192个Page。

对于map()输出的每个<K,V>record,Spark将其序列化后写入某个Page中,再将该record的索引,包括PID,所在的PageNum,以及在该Page中的Offse放到PointerArray中,然后通过排序PID来对record进行排序。

当Page总大小达到了task的内存限制时,如Task1种的Page0+Page1+Page2大小超过Task的内存界限,将这些Page中的record按照PID进行排序,并spill到磁盘上。这样,在Shuffle Write过程中可能会形成多个spill文件。最后,task将这些spill文件归并即可。

具体过程:首先将新来的<K,V>record,序列化写入一个1MB的缓冲区(serBuffer),然后将serBuffer中序列化的record放到ShuffleExternalSorter的Page中进行排序。插入和排序方法是,首先分配一个LongArray来保存record的指针,指针为64位,前24位存储record的PID,中间13位存储record所在的Page Num,后27位存储record在该Page中的偏移量。也就是说LongArray最多可以管理2^(13+27)=8192*128MB=1TB的内存。随着record不断地插入Page中,如果LongArray不够用或Page不够用,则会通过allocatePage()向TaskMemoryManager申请,如果申请不到,就启动spill()程序,将中间结果spill到磁盘上,最后再由UnsafeShuffleWriter进行统一的merger。Page有TaskMemoryManager管理和分配,可以存放在堆内内存或堆外内存。

SortShuffleWriter(KeyOrder=true)

不需要map()端combine,但是需要排序,这种情况下需要按照PID+Key进行排序。Spark采用了基于数组排序的方法。具体方法是建立一个Array(PartitionedPairBuffer)来存放map()输出的record,并对Array中元素的Key进行精心设计,将每个<K,V>record转化为<(PID,K),V>record存储,然后按照PID+Key对record进行排序,最后将record写入一个文件中,通过建立索引来标识每个分区。

如果Array存放不下,就会先扩容,如果还存放不下,就将Array中的元素排序后spill到磁盘上,等待map()输出玩以后,再将Array中的元素与磁盘上已排序的record进行全局排序,得到最终有序的record,并写入文件中。

内存消耗:最大的内存消耗是存储record的数组PartitionedPairBuffer,占用堆内内存,具有扩容能力,但大小不超过task的内存限制。

SortShuffleWriter(mapSideCombine=true)

需要map()端聚合,需要或不需要按Key进行排序,此时Spark采用基于HashMap的聚合方法。具体实现方法是建立一个类HashMap的数据结构PartitionedAppendOnlyMap对map()端输出的record进行聚合,HashMap中的Key是PID+Key,HashMap中的Value是经过相同的combine()的聚合结果。如果不需要按Key进行排序,则指根据PID进行排序,如果需要按Key排序,则根据PID+Key进行排序。最后,将排序后的record写入一个分区文件中。

内存消耗:HashMap在堆内分配,需要消耗大量内存。如果HashMap存放部下,则会先扩容为两倍大小,如果还存放部下,九江HashMap中的record排序后spill到磁盘上,放入堆内HashMap或buffer中的record大小,如果超过task的内存限制,那么会spill到磁盘上,该Shuffle方式的有点是通用性强、对分区个数也无限制,缺点是内存消耗高(record是普通Java对象)、不能使用堆外内存。

4.2 Shuffle Read阶段内存消耗及管理

fetch -> aggregate -> sort

reduce端聚合按Key排序Shuffle Read实现类内存消耗典型操作
NoNoBlockStoreShuffleReader
(aggregate=false,Keysort=false)
Small buffer
(on-heap)
partitionBy()
NoYesBlockStoreShuffleReader
(aggregate=false,Keysort=yes)
Array
(on-heap)
sortByKey()
NoNoBlockSortShuffleReader
(aggregate=yes,Keysort=false)
HashMap
(on-heap)
reduceByKey()
  1. 无聚合且无排序的情况:采用基于buffer获取数据并直接处理的方式,这种方式最为简单,只需要一个大小为spark.reducer.maxSizeInFlight=48MB的缓冲区。适用于partitionBy()
  2. 无聚合但需要排序的情况:采用基于数组排序的方式,适用于sortByKey()
  3. 有聚合的情况:采用基于HashMap聚合的方式,适用于reduceByKey()

这3种方式都是利用内存来完成数据处理的,属于UnSerialized Shuffle方式。

BlockStoreShuffleReader(aggregate=false,Keysort=yes)

不需要聚合但需要排序

只需要实现数据获取和按Kye进行排序的功能。Spark采用了基于数据的排序方式,下游的task不断获取上游task输出的record,经过缓冲后,将record一次输出到一个Array结构(PartitionedPairBuffer)中,然后,对Array中的record按照Key进行排序,并将排序结果输出或者传递给下一步操作。

当内存中无法存下所有record时,PartitionedPairBuffer会将record排序后spill到磁盘上,最后将内存中和磁盘上的record进行归并排序,得到最终结果。

内存消耗:由于Shuffle Read端获取的是各个上游task的输出数据,因此需要较大的Array(PartitionPairBuffer)来存储和排序这些数据。Array大小可控,具有扩容和spill到磁盘上的功能,并在堆内分配。

BlockSortShuffleReader(aggregate=yes,Keysort=false)

需要聚合且需要排序

Spark采用基于HashMap的聚合方法和基于数组的排序方法。获取record后,Spark建立一个类似HashMap的数据结构(ExternalAppendOnlyMap)对buffer中的record进行聚合,HashMap中的Key是record中的Key,HashMap中的Value是具有相同Key的record经过聚合函数(func)计算后的结果。由于ExternalAppendOnlyMap底层实现是基于数据来存放<K,V>record,因此,如果需要排序,则可以直接对数组中的record按Key进行排序,排序后结果输出或传递给下一步操作。

如果HashMap存放不下,则会先扩容为两倍大小,如果还存放部下,就将HashMap中的record排序后spill到磁盘上,最后将磁盘文件和内存中record进行全局merge。

内存消耗:由于Shuffle Read端获取的是各个上游task的输出数据,用于数据聚合的HashMap结构后消耗大量内存,而且只能使用堆内内存。当然HashMap的内存消耗量也与record中不同Key的个数及聚合函数的复杂度相关。HashMap具有扩容和spill到磁盘上的功能,支持任意规模数据的聚合。

5 数据缓存空间管理

数据缓存空间主要存放3种数据:RDD缓存数据(RDD partition)、广播数据(Broadcast data)和task的计算结果(TaskResult)。另外,还有集中临时空间,如用于反序列化(展开iterator为Array[])的临时空间、用于存放Netty网络数据传输的临时空间。

与框架执行内存空间一样,数据缓存空间也同时存放在堆内和堆外,而且由task共享。不同的是,每个task的存储空间并没有被限制为1/N。在缓存时,如果发现数据缓存空间不够,且不能从框架执行内存空间借用空间时,就只能采取缓存替换或者直接丢掉数据的方式。

5.1 RDD缓存数据

数据缓存空间最主要存储的是RDD缓存数据。

缓存级别存储位置序列化存储内存不足放磁盘
MEMORY_ONLY内存××重新计算
MEMORY_AND_DISK内存+磁盘×
MEMORY_ONLY_SER内存×
MEMORY_AND_DISK_SER内存+磁盘
OFF_HEAP堆外内存×

另外,还有MEMORY_ONLY_2、MEMORY_AND_DISK_2等模式,可以将缓存数据赋值到多台机器上。

5.1.1 MEMORY_ONLY/MEMORY_AND_DISK

实现方式:对于需要缓存的RDD,task在计算该RDD partition的过程中会将该partition缓存到Executor的memoryStore中,而快车认为memoryStore代表了堆内的数据缓存空间。memoryStore持有一个链表(LinkedHashMap)来存储和管理缓存的RDD partition。在链表中,Key的形式是(rddId=m,partitionId=n),表示其Value存储的数据来自RDD m的第n个分区,Value是该partition的引用,引用指向一个名为DeserializedMemoryEntry的对象。该对象包含一个Vector,里面存放了partition中的record。由于缓存级别没有设置为序列化存储,这些record以普通Java对象的方式存放在Vector中。需要注意的是,一个Executor中可能同时运行多个task,因此,链表被多个task共用,即数据缓存空间由多个task共享。

内存消耗:数据缓存空间的内存消耗由存放到其中的RDD record大小决定,即等于所有task缓存的RDD partition的record总大小。

5.1.2 MEMORY_ONLY_SER/MEMORY_AND_DISK_SER

实现方式:与MEMORY_ONLY的实现方式方式基本相同,唯一不同的是,这里的partition中的record以序列化方式存储在一个ChunkedByteBuffer(不连续的ByteBuffer数据)中。使用不连续的ByteBuffer数组的目的是方便分配和回收,因为如果record非常多,序列化后就需要一个非常大的数据来存储,而此时的内存空间如果没有连续的一大块空间,就无法存储。在之前的MEMORY_ONLY模式中不存在这个问题,因为单个普通Java对象可以存放在内存中的任何位置。

内存消耗:由存储的record总大小决定,即等于所有task缓存的RDD partition的record序列化后的总大小。

5.1.3 OFF_HEAP

实现方式:该缓存模式的存储方式与MEMORY_ONLY_SER/MEMORY_AND_DISK_SER模式基本相同,需要缓存的partition中的record也是以序列化的方式存储在一个ChunkedByteBuffer(不连续的ByteBuffer数组)中,只是存放的是堆外内存。

内存消耗:存放到OFF_HEAP中的partition的原始大小。

在Spark中,目前还没有实现同时使用堆内和外内存的缓存级别,堆外和堆内内存并没有协作。

5.2 广播数据

实现方式:Broadcast默认使用类似BT下载的TorrentBroadcast方式。需要广播的数据一般预先存储在Driver端,Spark在Driver端将要广播的数据划分大小为spark.Broadcast.blockSize=4MB的数据块(block),然后赋予每个数据快一个blockId为BroadcastblockId(id,“piece”+i),id是block编号,piece表示被划分后的第几个block。之后,使用类似BT的方式将每个block广播到每个Executor中,Executo接收到每个block数据块后,将其放到堆内的数据缓存空间的ChunkedbyteBuffer里面,缓存模式为MEMORY_AND_DISK_SER,因此,这里的ChuckedByteBuffer构造与MEMORY_ONLY_SER模式中的一样,都是用不连续的空间来存储序列化数据。

内存消耗:序列化后的Broadcast block总大小。

内存不足:Broadcast data的存放方式是内存+磁盘,内存不足时放入磁盘。

5.3 task计算结果

当task的输出结果大小超过spark.task.maxDirectResultSize=1MB且小于1GB时,需要先将每个task的输出结果缓存到执行该task的Executor中,存放模式是MEMORY_AND_DISK_SER,然后Executor将task的输出结果发送到Driver端进一步处理。

Driver端需要收集task1和task2的计算结果,那么task1和task2计算得到结果Result1和Result2后,先将其缓存到Executor的数据缓存空间中,缓存级别为MEMORY_AND_DISK_SER,缓存结构仍然采用ChunkedByteBuffer。

内存消耗:序列化后的task输出结果大小,不超过1GB。在Executor中一般运行多个task,如果每个task都占用了1GB以上的话,则会引起Executor的数据缓存空间不足。

内存不足:因为缓存方式是内存+磁盘,所以内存不足时放入磁盘。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值