Spark内存管理机制
1 内存管理面临的挑战
- 内存消耗来源多种多样,难以统一管理。Spark运行时内存消耗主要包括3个方面:
- 框架本身在数据处理时所需要的内存(如Shuffle Read/Write阶段使用的类HashMap和Array数组)
- 数据缓存,对于需要重复使用的数据,我们一般缓存到内存中,避免重复计算
- 用户代码消耗的内存(User Code),如用户在reduceByKey(func)、mapPartitions(func)中自定踹出的数据结构,暂存处理结果
- 内存消耗动态变化、难以预估,为内存分配和回收带来困难。Spark运行时的内存消耗内存和多种因素相关,如Shuffle机制中的内存用量与Shuffle数据量、分区个数,用户自定的聚合函数相关,难以预估。用户代码的内存用量与func的计算逻辑、输入数据量有关,也难以估计。并且这些内存消耗来源产生的内存对象的生命周期不同。
- 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的内存使用限额。
Executor JVM的整个内存空间划分为以下3个部分:
- Reserved Memory(系统保留内存):Reserved Memory使用较小的空间存储Spark框架产生的内部对象(如Spark Executor对想,TaskMemoryManager对象等Spark内部对象),系统保留内存大小通过spark.testing.ReservedMemory设置(默认为300MB)。
- User Memory(用户代码空间):用户代码空间被用于存储用户代码生成的对象,如map()中用户自定义的数据结构。用户代码空间默认为40%的内存空间。
- 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大小)的空间。在框架执行时,借走的空间不会归还给数据缓存空间,原因是难以实现。
- 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实现类 | 内存消耗 |
No | No | <=200 | Serialized | BypassMergeSortShuffleWriter | Fixed buffers(on-heap) |
No | No | >200 & <16777216(2^24) | Serialized | SerializedShuffleWriter | PointerArray+Data pages(On-heap or off-heap) |
No | Yes | Unlimited | Unserialized | SortShuffleWriter(KeyOrdering=true) | Array(on-heap) |
Yes | Yes/No | Unlimited | Unserialized | SortShuffleWriter(mapSideCombine=true) | HashMap(on-heap) |
- BypassMergeSortShuffle-Writer:基于buffer,无map()端聚合,无排序且partition个数不超过200。
- SerializedShuffleWriter:无map()端聚合,无排序且partition个数大于200的情况。
- SortShuffleWriter(KeyOrder=true):基于数组,无map()端聚合但需要排序的情况。
- 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的优点
- 序列化后的record占用内存空间小。
- 不需要连续的内存空间。Serialized Shuffle将存储record的数组进行分页,分页可以利用内存碎片,不需要连续的内存空间,而普通数组需要连续的内存空间。
- 排序效率高。对序列化后的record按PID进行排序时,排序的不是record本身,而record序列化后字节数组的指针(元数据)。由于直接基于二进制数据进行操作,所以在这里面没有序列化和反序列化的过程,内存和GC开销降低。
- 可以使用cache-efficient sort等优化技术,提高排序性能。
- 可以使用堆外内存,分页可以方便统一管理堆内内存和堆外内存。
使用Serialized Shuffle需要满足4个条件:
- 不需要map()端聚合,也不需要按Key进行排序。
- 使用的序列化类(serializer)支持序列化Value的位置互换功能(relocation of serialized Value),目前KryoSerializer和Spark SQL的custom serializers都支持该功能。
- 分区个数小于16777216(2^24)。
- 单个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实现类 | 内存消耗 | 典型操作 |
No | No | BlockStoreShuffleReader (aggregate=false,Keysort=false) | Small buffer (on-heap) | partitionBy() |
No | Yes | BlockStoreShuffleReader (aggregate=false,Keysort=yes) | Array (on-heap) | sortByKey() |
No | No | BlockSortShuffleReader (aggregate=yes,Keysort=false) | HashMap (on-heap) | reduceByKey() |
- 无聚合且无排序的情况:采用基于buffer获取数据并直接处理的方式,这种方式最为简单,只需要一个大小为spark.reducer.maxSizeInFlight=48MB的缓冲区。适用于partitionBy()
- 无聚合但需要排序的情况:采用基于数组排序的方式,适用于sortByKey()
- 有聚合的情况:采用基于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的数据缓存空间不足。
内存不足:因为缓存方式是内存+磁盘,所以内存不足时放入磁盘。