引入
-
既然有溢写机制了,为什么还会出现OOM呢?
-
这两个内存是干什么用的呢?
set spark.executor.memory = 4g;
set spark.executor.memoryOverhead = 3g;
- 直接用JVM的垃圾回收做内存管理可以吗
一、内存管理机制要解决的问题
大数据处理框架如MapReduce、Spark等, 需要在内存中处理大量的数据,内存消耗要比一般的软件高。但是因为内存空间有限,所以如何搞笑管理和使用内存成为重要问题。Spark的内存管理机制需要解决如下问题。
1.1 内存消耗来源多种多样,难以管理
我们首先定义内存消耗是什么,MapReduce中的map/reduce是以JVM进程的方式运行,因此MR应用内存消耗是指的是进程消耗的内存。而对于Spark来说,其task以线程的方式运行,因此我们主要关注单个线程的内存消耗。
Spark运行时内存消耗主要包括三个方面:
- 框架本身在处理数据时需要消耗内存,如Spark在Shuffle Write/Read过程中使用类似于HashMap和Array的数据结构进行聚合和排序
- 数据缓存,Spark需要将重复使用的数据缓存到内存中避免重复计算
- 用户代码消耗的内存,如用户可以在reduceByKey(func)、mapPartitions(func)的func中自己定义数据结构,暂存中间处理结果。
由于内存空间有限,如何对这些缓存数据和计算过程中的数据进行统一管理,以及如何平衡数据计算与缓存的内存消耗等这些解决内存空间不足的问题都具有挑战性。
举例
def main(args: Array[String]): Unit = {
val conf = new SparkConf().setAppName("wordCount").setMaster("local[*]")
val sc = new SparkContext(conf)
//获取文件
val textFile: RDD[String] = sc.textFile("/Users/zhaolei/IdeaProjects/spark-demo/src/resource/wordcount.txt")
//切割
val word: RDD[String] = textFile.flatMap(
line => {
val list = line.split(" ")
list
}
)
//映射成 (word, one)
val word_one: RDD[(String, Int)] = word.map(word => (word, 1))
//统计每个 word 的个数
val word_count: RDD[(String, Int)] = word_one.reduceByKey(_ + _)
word_count.foreach(x => println(x._1, x._2))
word_count.cache()
}
1.2 内存消耗动态变化、难以预估
Spark运行时的内存消耗与多种因素有关
- Shuffle机制中的内存用量与Shuffle数据量、分区个数(我理解是hashshuffle才会相关)、用户自定义的聚合函数等相关,并且难以预估
- 用户代码的内存用量与func的计算逻辑、输入数据量有关,也难以预估
这些内存消耗来源产生的内存对象生命周期不同,如何分配大小合适的内存空间,何时对这些对象进行回收,也具有挑战性
1.3 task之间共享内存,导致内存竞争
task之间共享内存,导致内存竞争。在MapReduce中,框架为每个task启动一个单独的JVM运行,task之间没有内存竞争。在Spark中,多个task以线程的方式运行在同一个Executor JVM中,task之间还存在内存共享和竞争,如何平衡内存共享和内存竞争的问题也具有挑战性。
三、内存管理模型
分析完内存管理需要解决的问题后,我们来看下Spark解决这些问题的方法及其内存管理模型。
3.1 静态内存管理模型
由于内存消耗包含三种来源且内存空间是连续的,所以,一个简单的解决办法是将内存划分为三个分区,每个分区负责存储一种消耗来源,并根据经验确定三者的比例。
Spark1.6以前采用了这个方法,用静态内存管理模型将内存空间划分为如下三个分区:
-
数据缓存空间。约占60%的内存空间,用于存储RDD缓存数据、广播数据、task的一些计算结果。
-
框架执行空间。约占20%的内存空间,用于存储Shuffle机制中的中间数据。
-
用户代码空间。约占20%的内存空间,用于存储用户代码的中间结果、Spark框架本身产生的内部对象,以及Executor JVM自身的一些内存对象等。
这种静态划分方式的优点是各个分区的角色分明、实现简单,缺点是分区之间存在“硬”界限,难以平衡三者的内存消耗。例如GroupBy()、join()等应用需要较大的框架执行空间,用于存放Shuffle机制中的中间数据,并不需要太多的数据缓存空间。再例如,某个应用不需要缓存数据,但是用户代码空间复杂度很高,因此需要较大的用户代码空间,容易出现诸如内存不足、内存溢出之类的错误。为了缓解这个问题,Spark允许用户自己设定三者的空间比例,但对于普通用户来说很难确定一个合适的比例,而且内存用量在运行过程中不断变化,并不存在一个最优的静态比例,也就容易造成内存资源浪费、内存溢出等问题。
3.2 统一内存管理模型
为了能够平衡用户代码、Shuffle机制中的中间数据,以及数据缓存的内存空间需求,最理想的办法是为三者分配一定的内存配额,并且在运行时根据三者的实际内存用量,动态调整配额比例。
缓存数据和Shuffle机制中的中间数据可以被监控,但用户代码的内存消耗很难被监控和估计。所以,优化后的内存管理主要是根据监控得到的内存用量信息,来动态调节用于Shuffle机制和用于缓存内存空间的。
根据以上思想,Spark从1.6版本开始,设计实现了更高效的统一内存管理模型,仍然将内存划分为3个分区:系统保留空间、用户代码空间和框架内存空间,这三个空间的大小都是固定的。具体如下
3.2.1 系统保留内存
系统保留内存使用较小的空间存储Spark框架产生的内部对象(如Spark Executor对象,TaskMemoryManager对象等内部对象),系统保留内存大小默认时300MB。
3.2.2 用户代码空间
用户代码空间被用于存储用户代码生成的对象,如map()、mapPartition()等函数中中用户自定义的数据结构。用户代码空间默认约为40%的内存空间。
3.2.3 框架内存空间
框架内存空间分为框架执行空间和数据缓存空间。总大小为spark.memory.fraction(default * 0.6) * (heap - Reserved memory)。约为60%的内存空间。两者共享这个内存空间,其中一方空间不足时可以动态向另一方借用。具体地,当数据缓存空间不足时,可以向框架执行空间借用其空闲空间,后续当框架执行需要更多空间时,数据缓存空间需要“归还”借用的空间,这时候Spark可能将部分缓存数据移除来归还空间。同样,当框架执行空间不足时,可以向数据缓存空间借用空间,如果借用以后依然不够用, 会触发Shuffle溢写。注意,框架执行空间借用缓存空间后,并不会被动归还,所以如果缓存空间被占满后,新来的缓存数据只能放到磁盘上。
思考:为什么框架执行空间不会被动归还内存?
因为溢写的数据一定会被重新从磁盘读入内存,而缓存数据在后续可能并不需要,二者带来的性能开销不一样。
因为监控不到用户内存使用了多少,所以用户内存可能会超出自己的使用范围,如果用户内存侵犯了框架内存,而框架内存此时利用率又比较高,就会触发内存溢出。
到这里,我们就了解了分区模型,接下来我们进一步研究每个分区空间内的具体使用和回收机制,以及task之间的竞争情况。
四、Spark框架内存消耗与管理
4.1 内存共享与竞争
因为Executor中存在多个task,因此框架内存空间实际上是由多个task(Spark中的task名为ShuffleMap Task或ResultTask)共享的。在运行过程中,Executor中活跃的task数目在[0, ExecutorCores]内变化。Spark会尝试确保每个任务获得合理的内存份额,而不是某些任务首先增加到大量内存,然后导致其他任务重复溢出到磁盘。
如果有N个任务,它确保每个任务在溢出之前可以获取至少 1 / 2N的内存,最多 1/N。因为N是动态变化的,所以task可以分配到的内存也在变化。如最初一个Executor中有4个活跃的task,那么每个task可以使用的最小内存是 1/8,最大内存为 1 /4 。当完成2个task后,剩余的两个task最少使用的内存是 1 / 4,最大使用内存为 1/ 2。
这个与Flink里slot配置死的内存有区别,我理解原因是因为Flink是流程序,不存在“完成”这个概念。
4.2 框架执行空间内存使用
框架执行空间主要用于Shuffle阶段,在这个过程中需要对数据进行partition、sort、merge、fetch、aggregate等操作,执行这些操作需要类似于数组、HashMap之类的结构。而且也可能把数据序列化后存到堆外,具体怎么使用内存,依据于采取的Shuffle方式。
之前我们分析过Shuffle的实现方式,这里再重新回顾,并且分析其使用的内存。
4.2.1 Shuffle Write阶段内存消耗与管理
Shuffle Write的实现具体参照:https://blog.csdn.net/stable_zl/article/details/128678127
Shuffle Wirte可根据是否需要map端聚合,是否需要按照Key进行排序,分为4种方式
-
无map()端聚合、无排序且partition个数不超过200,采用的BypassMergeSortShuffle-Writer方式,Spark根据partitionId,将record依次输出到不同的buffer中,每当buffer被填满,就将record溢写到磁盘上的分区中。
消耗内存就是32KB的buffer
-
无map()端聚合、无排序且partition个数大于200的情况,采用Serialized ShuffleWriter。(输出文件会按照partitoinId排序)
可以在堆内,也可以在堆外,默认在堆外,通过指定spark.executor.memoryOverhead来设置其大小。
join都是采用的这种操作,常见问题:
- 我们在数据量大的时候经常会发现报错说程序在创建一个超大的数组,这个数组就是利用堆外内存机制的时候在堆内创建的指针数组,这个数组没有溢写操作,所以很容易报溢出,如下图(这是我的一个group by + cube的操作,然后再做join,cube出来的数据量太大引起的):
- 堆外内存不够,爆Overhead内存溢出。
-
无map()端聚合但需要排序的情况,采用基于数组的SortShuffleWriter(输出文件会按照partitionId+key排序)
内存消耗是堆内的数组
-
有map端聚合的情况,采用AppendOnlyMap的SortShuffleWriter(会按照partitionId排序)
内存消耗是堆内的AppendOnlyMap,groupByKey采用的是这种方式
4.2.2 Shuffle Read阶段内存消耗与管理
注:ShuffleRead里用的都是堆内内存,没有堆外
-
无聚合且无排序的情况,采用基于buffer获取数据并直接处理的方式,典型的操作是partitionBy
内存消耗是一个小的buffer
-
无聚合但是需要排序的情况,采用基于数组的排序方式,典型的操作是sortByKey。
内存消耗是堆内的数组
-
有聚合的情况,采用基于AppendOnlyMap的聚合方式,典型的操作是reduceByKey。
内存消耗是堆内的AppendOnlyMap
4.3 数据缓存空间内存使用
缓存空间主要存放3种数据:RDD缓存数据、广播数据、以及task的计算结果。如下图所示
和框架执行空间相同的是,缓存空间也可以同时使用堆内和堆外,而且有task共享。不同的是,每个task的存储空间并没有被限制1/N。在缓存时如果发现数据缓存空间不够,且不能从框架执行空间借用空间时,就只能采用缓存替换或者直接丢掉数据的方式。