【Spark】内存模型

一、简介

1、背景

Spark是基于内存的分布式计算引擎,内存模型与管理是核心知识点,理解它能更好地开发Spark应用和进行性能调优(解决作业GC耗时长问题—主要是Young GC)。

2、总体架构 & 运行流程

1
2

Spark整体运行流程:

  1. 构建运行环境。由Driver创建SparkContext,进行资源申请、任务分配与监控;
  2. 分配资源。SparkContext和Cluster Manager通信,为Executor申请资源,进行任务分配和监控,启动进程;
  3. 分解Stage,申请Task。 SparkContext构建DAG图,然后分解为Stage,每个Stage的TaskSet发给TaskScheduler。Executor向Driver申请Task.
  4. 运行 & 注销。Task在Executor上运行,执行完反馈给TaskScheduler与DAGScheduler,然后释放资源。

从整体流程看,Driver端创建SparkContext、提交作业、协调任务;Executor端执行Task。从内存使用角度看,Executor端内存设计比较复杂,下面予以概述。

二、Executor端内存设计

2.1 堆划分

3

Worker节点启动的Executor是一个JVM进程,因此Executor内存管理是建立在JVM内存管理上(On-Heap堆内内存),同时Spark也引入了Off-Heap(堆外内存),使之可以直接在系统内存中开辟空间,避免了在数据处理过程中不必要的序列化和反序列化开销,同时降低了GC开销。

2.1.1、On-Heap(堆内)

堆内内存大小由Spark应用程序启动时的-executor-memory或Spark.executor.memory参数配置。Executor内运行的并发任务共享JVM堆内内存,这些任务在缓存RDD或广播数据时被划为存储(Storage)内存,而这些任务在执行Shuffle时,占用的内存被划为执行(Execution)内存,剩余部分不做特殊规划,那些Spark内部对象实例或用户自定义Spark应用程序中的对象实例,均占用剩余空间。不同管理模式下,这三部分占用的空间大小各不相同。

Spark对堆内内存的管理只是逻辑上“规划式”的管理,对象实例占用内存的申请和释放都是由JVM完成,而Spark只是在申请后和释放前记录这些内存,下面看看具体流程:

【1】 申请内存
1、Spark在代码中new一个对象实例;
2、JVM从堆内内存中分配空间,创建对象并返回对象引用;
3、Spark会保存该对象引用,记录对象占用的内存。

【2】释放内存
1、Spark记录该对象释放的内存,删除该对象的引用
2、等待JVM垃圾回收机制来释放掉对象占用的堆内内存。

Spark序列化小知识:
JVM对象可以以序列化方式存储,序列化的过程是将对象转换为二进制字节流,本质上可以理解为将非连续空间的链式存储转化为连续空间或块存储,在访问时需要进行序列化的逆过程—反序列化,将字节流转化为对象,序列化的方式可以节省存储空间,但增加了存储和读取时的计算开销。
Spark中序列化的对象会以字节流形式存在,占用内存大小可以直接计算,而对于非序列化的对象,占用的内存只能通过周期性地采样近似估算得到,即并不是每次新增数据项都会计算一次占用内存大小,这种方法降低时间开销,但可能误差较大,导致某一时刻实际内存远超预期。此外,在被Spark标记为释放的对象实例时,很有可能在实际上没被JVM回收,导致实际可用内存小于Spark记录可用内存,所以Spark并不能准确记录实际可用堆内存,从而无法完全避免OOM。
虽然不能精准控制堆内内存申请和释放,但Spark通过对存储内存和执行内存各自独立的规划管理,可以决定是否在存储内存里缓存新的RDD,以及是否为新的任务分配执行内存,在一定程度上可以提升内存利用率,减少异常出现。

2.1.2、Off-Heap(堆外)

为了进一步优化内存使用和提供Shuffle时排序的效率,Spark引入堆外内存,使之可以直接在工作节点系统内存中开辟空间,存储经过序列化的二进制数据。利用JDK Unsafe API(从Spark 2.0开始,在管理堆外内存存储内存时不再基于Tachyon,而是与堆外执行内存一样,基于JDK Unsafe API实现),Spark可以直接操作系统堆外内存,减少不必要内存开销,以及频繁GC扫描和回收,提升了处理性能。堆外内存可以被精确申请和释放,而且序列化数据占用空间可以被精确计算,所以相比堆内内存,降低了管理难度和误差。
默认情况下堆外内存不启用,可通过Spark.memory.offHeap.enabled参数启用,并由Spark.memory.offHeap.size参数设定堆外空间大小。除了没有other空间,堆外内存和堆内内存的划分方式相同,所有运行中的并发任务共享存储内存和执行内存。

2.2 内存划分及对应功能

为减少OOM发生,Spark对堆内内存再划分,分为Storage、Executor、Other,这种内存划分方式各自管理内存利用率。

  • 可用堆内内存空间计算:
可用的存储内存 = systemMaxMemory * spark.storage.memoryFraction * spark.storage.safetyFraction
可用的执行内存 = systemMaxMemory * spark.shuffle.memoryFraction * spark.shuffle.safetyFraction
 
systemMaxMemory:取决于当前JVM堆内存大小
memoryFraction: ……
safetyFraction:降低实际内存超出预设范围而导致OOM风险(上文提到,对于非序列化对象的内存采用估算会产生误差),注意,这个预留保险区域只是逻辑规划,具体使用时Spark并没区别对待,和其他内存一样交给了JVM管理。

堆外空间分配较为简单,只有存储内存和执行内存,如图4所示,可用执行内存和存储内存占用空间大小直接由参数Spark.memory.storageFraction决定,由于堆外内存占用空间可被精确计算,所以无需再设保险区域。
静态内存管理机制实现简单,但如果用户不熟悉Spark存储机制,或没有根据具体数据规模和计算任务做相应配置,很容易造成“一般海水,一般火焰”的局面,即存储内存和执行内存一方剩余大量空间,而另一方却早早沾满,不得不淘汰或溢出旧内容以存储新的。由于新的内存管理机制的出现,这种方式目前已少有开发者使用,处于兼容考虑,Spark仍保留实现。
4

根据内存使用目的不同,堆内外内存划分如上:
针对堆内内存可划分为4块:
1、存储内存(Storage Memory):该部分内存主要用于缓存或内部数据传输过程中使用,如缓存RDD或广播数据;
2、执行内存(Execution Memory):该部分内存主要用于计算内存,包括shuffles.Join.sorts及aggregations;
3、其他内存(Other Memory):该部分内存主要给存储用户定义数据结构或Spark内部元数据;
4、预留内存:和other内存作用一样,但由于Spark对堆内内存采用估算的方式,所以提供了预留内存来保障足够空间。

堆外内存划分为2块(前面提到Spark对堆外内存使用可以精准计算):
1、存储内存(Storage Memory)
2、执行内存(Execution Memory)

2.3 统一内存管理机制

Spark 1.6后引入统一内存管理机制,与静态内存管理区别在于存储内存和执行内存共享同一块空间,可以动态占用对方空闲区域。

2.3.1 静态内存模型

静态内存模型最大特点是:堆内存每个区域大小在Spark应用程序运行期间是固定的,用户可以在启动前进行配置;这也需要用户对Spark内存模型非常熟悉,否则可能因为配置不当造成严重后果。
对于堆内存内存区域划分及比例如下图:
这里说下Unroll过程:RDD以Block形式被缓存到存储内存,Record在堆内或堆外存储内存中占用一块连续空间。把Partition由不连续的存储空间转换为连续空间的过程就是Unroll,也称为展开操作。
5
6

堆外内存划分比较简单,即只有存储内存和执行内存,因为Spark对堆外内存使用计算比较精确,所以不需要额外预留空间来避免OOM。

动态占用机制:
设定基本存储内存和执行内存区域(Spark.storage.storageFraction参数),该设定确定了双方各自拥有空间范围。
双方空间不足时,则存储到硬盘;若己方空间不足,而对方空余时,可借用对方空间。(存储空间不足是指不足以放下一个完整Block)
执行内存空间被对方占用后,可让对方占用部分转存到硬盘,然后“归还”借用空间。存储内存空间被对方占用后,无法让对方“归还”,因为需要考虑shuffle过程中很多因素,实现比较复杂。
7

凭借统一存储管理机制,Spark在一定程度上提高了堆内和堆外内存资源的利用率,降低了开发者维护Spark内存难度,但不意味着开发者可以高枕无忧。譬如,所以存储内存空间太大或缓存数据过多,反而导致频繁全量垃圾回收,降低任务执行时性能,因为缓存RDD数量通常是长期驻留内存。所以要想充分发挥Spark性能,需要开发者进一步了解存储内存和执行内存管理方式和实现原理。

三、存储内存管理

3.1 RDD持久化机制

RDD是数据抽象,基于稳定物理存储数据集创建,或在其他已有RDD上执行转换,产生的依赖关键构成血统(Lineage)。Spark凭借血统可以保证每个RDD被重新恢复,但RDD所有转换是lazy,知道遇到Action算子。
Task启动之初会读取分区并判断它是否被持久化,若没有则需要检查Checkpoint,按照血统重新计算。所以可在第一次使用persist或cache方法来持久化或缓存这个RDD,以避免重复计算,提升计算速度。cache使用默认MEMORY存储级别,将RDD持久化内存,故缓存是一种特殊持久化。堆内和堆外内存设计,可以对缓存RDD时使用内存做统一规划和管理。(缓存broadcast数据不在本文讨论范围内)。
RDD持久化由Spark的storage模块负责,实现RDD和物理存储解耦合。Storage模块负责管理Spark计算过程中产生数据,将那些内存或磁盘、在本地或远程存取数据功能封装起来。具体实现时Driver端和Executor端的BlockManager为slave。Storage模块在逻辑上以Block为基本存储单位,RDD每个Partition经过处理后唯一对应一个Block。Master负责整个Spark应用程序的Block元数据信息管理和维护,而slave需要将Block更新等状态上报到Master,同时接收Master命令,例如新增或删除一个RDD。
8

3.2 RDD缓存过程

RDD缓存存储内存前,Partition中数据一般以迭代器的数据结构来访问。通过Iterator可以获取分区中每一条序列化或非序列化的数据项,这些Record对象实例在逻辑上占用了JVM堆内内存other部分空间,同一Partition的不同Record空间并不连续。
RDD在缓存到存储内存后,Partition被转换为Block,Record在堆内或堆外存储内存中占用一块连续空间。将Partition由不连续存储空间转换为连续存储空间过程,Spark称为“展开(Unroll)”。Block由序列化和非序列化两种存储格式,具体方式取决于该RDD存储级别。非序列化Block以一种DeserializedMemoryEntry的数据结构定义,用一个数组存储所有对象实例。序列化Block则以SerialiedMemoryEntry数据结构定义,用字节缓冲区来存储二进制数据。
每个Executor的Storage模块用一个链式Map结构(LinkedHashMap)来管理堆内和堆外存储内存所有Block对象实例,对这个LinkedHashMap新增和删除间接记录了内存申请和释放。
因为不能保证存储空间可以一次容纳Iterator中所有数据,当前计算任务在Unroll时要向MemoryManager申请足够Unroll空间来临时占位,空间不足则Unroll失败,足够的话可以继续进行。
对于序列化的Partition,其所需要的Unroll空间可以直接累加计算,一次申请。而非序列化的Partition则要在遍历Record过程中依次申请,即没每读取一条Record,采样估算其所需Unroll空间并进行申请,空间不足可以中断,释放已占用Unroll空间。如果最终Unroll成功,当前Partition所占用的Unroll空间被转换为正常缓存RDD存储空间,如下图。
9

上面静态内存管理中,存储内存专门划分了一块Unroll空间,大小固定,统一内存管理则没对Unroll空间进行特别区分,当存储空间不足时会根据动态占用机制进行处理。

3.3 淘汰和落盘

同一个Executor所有计算任务共享有限存储内存空间,当由新的Block需要缓存但剩余空间不足且无法动态占用时,就要对LinkedHashMap中旧Block进行淘汰(Eviction),而被淘汰Block如果存储级别中同时包含存储磁盘的要求,则要对其进行罗盘,否则直接删除该Block。
存储内存淘汰规则为:
被淘汰的旧Block与新Block的MemoryMode相同,即同属于堆外/内内存。
新旧Block不能属于同一个RDD,避免循环淘汰。
旧Block所属RDD不能处于被读状态,避免引发一致性问题。
遍历LinkedHashMap中的Block,按照最少使用LRU顺序淘汰,知道满足Block所需空间,其中LRU时LinkedHashMap特性。
落盘流程简单,如果存储级别符合条件,再判断序列化形式,非则进行序列化,最后将数据存储到磁盘,在Storage模块中更新其信息。

四、执行内存管理

4.1 多任务间内存分配

Executor内运行任务同样共享执行内存,Spark用一个HashMap结构保存任务到内存耗费的映射。每个任务可占用执行内存大小范围为1/2N~1/N,N为Executor内运行任务数。每个任务启动时,要向MemoryManager请求申请最少为1/2N的执行内存,如果不能满足要求,则该任务被阻塞,直到由其他任务释放了足够执行内存,该任务才可以被唤醒。

4.2 Shuffle内存占用

执行内存主要是用来存储任务在执行shuffle时占用的内存,Shuffle是按照一定规则对RDD重新分区的过程。下面看看Writer和Read两阶段对执行内存的使用:
Shuffle Write:
若在map端采用普通排序方式,会采用ExternalSorter进行外排,在内存中主要使用堆内执行空间。
若在map端选择Tungsten排序,则采用ShuffleExternalSorter直接对序列化形式存储的数据排序,在内存中存储数据时可以占用堆外或堆内执行空间,取决于用户是否开启堆外内存及堆外执行内存是否足够。

Shuffle Read:
在对reduce端数据进行聚合时,要将数据交给Aggregator处理,在内存中存储数据时占用堆内执行空间。
如果需要进行最终结果排序,则要再次将数据交给ExternalSorter处理,占用堆内执行空间。

在ExternalSorter和Aggregator中,Spark会使用一种叫AppendOnlyMap的哈希表在堆内执行内存中存储数据,但在shuffle过程中所有数据并不能都保存到该哈希表中,当这个哈希表占用内存进行周期性地采用估算,当其达到一定程度,无法再从MemoryManager申请到新的执行内存时,Spark就会将全部内存存储到磁盘文件,这个过程叫溢存(Spill),溢存文件最后会被归并(Merge)。
Shuffle Write阶段用的Tungsten时Databricks公司提出的对Spark优化内存和CPU使用计划,解决了一些JVM 在性能上的限制和弊端。Spark会根据Shuffle情况自动选择是否采用Tungsten排序。
Tungsten采用的页式内存管理机制建立在MemeoryManager之上,即Tungsten对执行内存使用进行了一步抽象,这样shuffle过程中无需关心数据存储堆内还是堆外。每个内存页用一个MemoryBlock来定义,并用Object obj和long offset两个遍历统一标识一个内存页在系统内存中的地址。
堆内的MemoryBlock是以long型数组在JVM的初始偏移地址,两者配合使用可以定位这个数组在堆内的绝对地址,堆外MemoryBlock是直接申请到内存块,其obj是null,offset是这个内存块在系统内存中的64位绝对地址。Spark用MemoryBlock巧妙地将堆内和堆外内存页统一抽象封装,并用页表(pageTable)管理每个task申请到内存页。
Tungsten页式管理下所有内存用64位逻辑地址表示,由页号和页内偏移量组成:
页号:占13位,唯一表示一个内存页,Spark申请内存页前先申请空闲页号。
页内偏移量:占51位,是在使用内存页存储数据时,数据在页内地偏移地址。

有了统一寻址方式,Spark可用64位逻辑地址指针定位到堆内或堆外内存,整个Shuffle Write排序过程只需要对指针进行排序,并且无需反序列化,整个过程非常高效,对于内存访问和CPU使用效率带来了明显提升。

Spark存储内存和执行内存有截然不同地管理方式:对于存储内存来说,Spark用一个LinkedHashMap来集中管理所有Block,Block由需要缓存地RDD的Partition转化而成;而对于执行内存,Spark用AppendOnlyMap来存储Shuffle过程中数据,在Tungsten排序中抽象为页式内存管理,开辟全新JVM内存管理机制。

五、参考

1、Spark 内存模型
2、Tungsten On Spark-内存模型设计
3、Spark内存模型介绍及Spark应用内存优化踩坑记录
4、从一个sql任务理解spark内存模型
5、Spark 内存模型

猜你喜欢

转载自blog.csdn.net/HeavenDan/article/details/115250764