Apache Spark 是专为大规模数据处理而设计的快速通用的计算引擎同,它最初是UC Berkeley AMP lab (加州大学伯克利分校的AMP实验室)所开源的类Hadoop MapReduce的通用并行框架。
Spark拥有Hadoop MapReduce所具有的优点,但不同于MapReduce的是其中间输出结果更倾向于保存在内存中,从而不再需要读写HDFS,因此Spark的计算效率得到了极大的提升,与此同时,Spark内置了丰富的函数、算法,能更好地适用于数据挖掘与机器学习等应用,这使其在大数据领域占据了更加重要的地位。
在实践过程中只有了解Spark对存储特别是内存的管理机制,才正使其发挥更佳的性能。
在Spark中专门有一整套成熟的内存管理模块,且扮演着非常重要的角色,本文将从堆内外内存、统一内存管理机制(Unified Memory Manager)及BlockManager等方面,尝试阐述Spark分布式内存及存储管理机制。
- 堆内外内存
Spark是由Scala编写,而Scala是执行于JVM上的类JAVA语言。Spark在执行的过程中,同样脱离不了JVM的支撑,Spark Executor本身的内存管理就是基于JVM传统内存管理的,但是Spark可以在Executor所属节点的系统内存之中另行开辟空间作为Off-heap使用,这极大的方便了Spark的数据操作,但这并不意味着Spark放弃了对On-heap内存的优化,相反的是。随着Spark不断升级,其对On-heap内存的管理愈发详尽,细化到Task层级,在Executor中的每一个Task都可以完成对On-heap以及Off-heap内存堆的使用。
- 堆内内存(On-heap)
Executor中的众多Task共享使用同一块On-heap内存,而这一块On-heap内存被分为三部分:
- Execution内存:此内存主要用于任务shuffer、join、sort等。
- Storage内存:此内存主要用于缓存本节点或者其它节点间的数据传输,具体包括RDD的缓存数据、广播变量、自定义累加器等
- 其它内存。此内存用于存储除了上述两种用途之外的其它内存(对象),比如内部实例等
受限于JVM本身,Spark对堆内内存的管理只是一种记录式管理,每当有内存(实例)在申请后或释放前,Spark会对此进行记录。
在申请内存时,首先由Spark在Task中新建一个实例,然后JVM收到请求后在on-heap中为此实例分配空间,并且为Spark创建该实例的引用,Spark拿到该引用信息之后,将引用及其占用的内存信息进行保存。释放内存则先行记录释放实例,删除上述拿到的引用,最后交由JVM本身机制释放回收on-heap内存。
可以发现,在on-heap内存申请与释放过程中存在着误差。
Spark中的对象大都为字节流形式的可序列化对象(serialization),这些对象占用内存的情况与JVM相似,可以直接通过计算获得,但是非序列化对象只能估算内存占用值,这就导致Spark并不能完全掌握on-heap的内存占用情况。
而在内存释放的过程中,内存误差的风险更大,因为虽然有些对象在Spark内被标记删除,如果JVM尚未真正回收,这些对象占用的空间并没有被释放,这就导致Spark内存记录失真。
Spark对on-heap内存管理的不准确性,也就使其从根本上是无法避免OOM的。
虽然无法避免,但是Spark从多处下手,尽量优化了Executor运行中的内存使用情况。
Spark程序运行时,spark.executor.memory参数决定了Spark JVM heap的大小,但在实际中,Spark并不会将此数值完全用尽,spark.storage.safetyFraction将安全空间阈值设置为了0.9,这就使Spark保留了部分空间作为预留内存使用,以期减少OOM出现。
- 堆外内存:
为突破JVM的内存枷锁,Spark另行开辟了堆外内存使用,堆外内存可由Executor单独创建,用于存储已被序列化的二进制,堆外内存相比堆内内存有诸多好处:
- 在大内存情境下表现更佳
- 有利于改善垃圾回收造成的性能干扰
- JVM进程数据共享
不过相对堆内内存,Off-heap只能用于存储序列化文件,这也造成了反序列化过程中的性能损耗,但这种损耗与Off-heap带来的Shuffle效率提升相比是完全值得的。
Spark Shuffle的底层传输实现是基于Netty,Netty的在进行网络传输的过程中同样使用off-heap,off-heap不足会导致任务缓慢或者失败,当Spark出现executor loast/ shuffle file cannot find/task lost等情况时,就需要调大Executor的堆外内存了。
堆外内存被Spark分为了两部分:Storage与Execution,其区别已在上文描述,此处不再赘述。
- 统一内存管理机制(Unified Memory Manager)
在Spark中内存管理的工作主要由MemoryManager组件来完成,而MemoryManager又有两种不同的实现,一种为StaticMemoryManager,另一种为UnifiedMemoryManager,在新版中后者(UnifiedMemoryManager)替代前者成为默认机制,本文也主要关注UnifiedMemoryManager。
UnifiedMemoryManager将Spark堆内内存分为三部分:预留内存区(Reserved Memory)、自由内存区(Usable Memory)、统一管理内存区(Unified Memory)。
此处所述部分,与上述Spark堆内内存的划分似乎有冲突,但实际上并非如此,详情见如下内容。
预留内存区(Reserved Memory)默认为300M,Spark据此值的1.5倍计算出最小所需内存值(MinSystemMemory),如果系统可用内存小于此值的话,Spark会抛出OOM异常。
自由内存区(Usable Memory)为系统最大内存减去预留内存区的值
统一管理内存区(Unified Memory)为自由内存区的60%,这些内存将由Storage与Execution共享,两者各占用一半,但当已方内存不足时,可占用对方空间。
内存划分见下图:
需要注意的是Reserved Memory只是逻辑上的概念,其与Other一起存储其它类型的数据。
可以发现,UnifiedMemoryManager的三部分内存区域划分,在实际上依旧是Storage、Execution、Other。这也就与上述堆内内存的介绍内容相统一了。
下面,将就Storage与Execution的内存写入机制进行简要的分析。
首先是Storage,在Storage中一般存储的是Task运行完成之后的输出结果(中间结果),在Task运行的生命周期中,如果Task运行结果需要持久化(或需要缓存),则调用Spark Block Manager模块,由此模块调用putBytes()方法完成实际写入动作,但是在写入之前,Block Manager会调用acquireStorageMemory()判断内存时否足够,判断逻辑如下:
- 如果acquireStorageMemory()发现申请内存大于Unified Memory内可用内存总量,则报出内存不足
- 如果申请内存小于Unified Memory内可用内存总量,但大于Storage预分配可用量,则从Execution预分配内存区借用内存,相应的Execution可用内存减去被借走的内存量,从而完成整个动态内存调整
在申请到内存空间之后,putBytes()将以字符流的形式将数据写入。
其次是Execution,前文提到Execution主要是存储Shuffle的数据,在Shuffle的过程中会调用 ShuffleExternalSorter组件,它负责将Record数据(Shuffle数据的逻辑存储概念)写入数据页中,对数据页将在BlockManager中进行简要说明。
对Execution数据,分别区分On-heap与Off-heap两种分配方式,Off-heap相对简单,主要是通过每个Task任务管理相应的内存空间,在On-heap内存申请过程中,当Execution内存不够使用时,Spark一定会把StorageMemoryPool的可用内存全部借给Execution使用,如果当前StorageMemoryPool比初始时要大,说明Storage借用了Execution的内存空间,StorageMemoryPool会调用shrinkPoolToFreeSpace回收内存并减持,这部分释放出的内存空间便再次给予了Execution。
可以看出,在统一内存管理机制中,Execution的优先级比Storage更高,动态调整过程中,如果Execution内存空间被对方占用时,Execution可以将Storage占用的部分转存到硬盘,而后将Storage占用的内存收回,但反之却不行。产生这种情况的主要原因,是Execution存储是的Shuffle过程的数据,这部分数据涉及较为复杂,不能进行简单的“回收”工作。
- BlockManager存储模块
BlockManager是内嵌于Spark的一套分布式存储管理系统,它管理着Spark内所有数据操作,提供了数据的本地远程Writer/Read封装,而Unified Memory Manager组件的运行更离不开BlockManager的支持,因为Spark的分布式特性,BlockManager也是分布式的,它随Spark一起运行在集群所有的节点上,包括Driver以及Executor,相似的地,BlockManager同样是Master-Slaves结构,在SparkEnv启动的时候,会根据启动在Driver还是Executor分别启动Master与Slave,在Driver上运行的是BlockManagerMaster,而Executor上则为BlockManagerSlave。
详细来讲,在Driver启动时,会构造BlockManagerMasterEndpoint,并注册自己,而Executor则通过initialize实例化本机上的BlockManager并创建BlockManagerSlaveEndpoint轮训接收BlockManagerMasterEndpoint的消息命令,最后Executor上的BlockManager会向BlockManagerMasterEndpoint注册自己信息。
在整个流程中,Executor上的BlockManager向Driver上的Master注册自己的信息,其它BlockManager则按需从Master内拉取数据进行cache 、shuffle等动作。
在BlockManger内部,有多种方式实现对数据的存储(持久),主要有On-heap,Off-heap,Disk等,本文主要分析内存中的Storage与Execution管理。
- BlockManager中的Storage管理
在Spark中,Transformation与Action是两种基本数据操作,这两种数据处理方式的作用对象则叫RDD(弹性分布式数据集),在多次Transformation之后,各RDD之间也就形成了基本的血缘信息,但此时多种RDD的转换在任务引擎中心中并不会真正引起数据变动(也可以称之为数据运算),除非当应用执行到了Action操作。
Action操作是触发数据的运算的入口,Action操作初始化时,Task会从持久层校验RDD是否已被缓存,其中RDD的缓存主要便是内存中的Storage类型,这些数据可以存储在On-heap与Off-heap内。
Task是如何判断RDD是否已被缓存呢?
在存储层,BlockManager的存储单位是Block,而RDD则由逻辑概念上的Partition组成,每一个Partition在处理完成后都对应一个Block,这种关系是稳定维一的,这种关系的存在使Task与BlockManager可以完成数据逻辑层上的通信。
严格来讲在RDD计算时,首先会根据RDD ID和Partition Index构造出Block ID,Task(Exectutor)即可以从BlockManager中按此ID查询对应的Block,如果此Block已被缓存入Storage内,Task会使用已缓存数据进行下一组的Transformation(Action),如果没有,Task会按照血缘信息(LIneage)重新计算所需RDD,而生成的RDD便根据它所有Partition的Block ID进行Storage内部存储。
基于此,BlockManger就完成了对RDD与Storage存储空间的统一管理,逻辑上的Partition数据也就转换为了的Block Memory空间。
- BlockManager中的Execution管理
Execution主要是Spark Shuffle过程中的内存管理。
Shuffle过程可以说是分布式运算的灵魂所在,而Shuffle的不确定性也是影响整个应用的关键之处。
毫无疑问,Spark作为一个分布式系统,它将整个集群的存储进行了相对统一的管理,在Shuffle的过程中,RDD被打散重装,BlockManger则对数据进行了重新分区,这就必然造成了伴随Shuffle阶段的数据读与写——这种读/写的效率很大程序上取决于数据所处节点的相对空间位置,在Shuffle中,Spark倾向于使用AppendOnlyMap来存储中间数据,并将堆内堆外进行了抽象,也就是上文提到的数据页,数据页使Spark上层逻辑无需关心使用的是On-heap内的Execution还是Off-heap的Execution,相当于对不同位置的Execution进行了统一封装。
因为RDD与Block的统一性,逻辑上的RDD传输最终会相应的转换成Block操作,Exectutor上的各个Task会通过使用BlockManager组件接口,由BlockManagerMaster向BlockManagerMasterEndpoint查询其本身运算所需的BlockID信息以及集群中有哪些Executor已经保存了该Block数据。
也许读者此时比较疑惑,既然读取到了BlockID,那么根据ID读写数据即可,何必要了解集群中有哪些Executor已经保存了该Block数据。
其实,这主要体现了大数据领域核心思想之一的“移动计算,而不是移动数据”,当Spark发现已保存该Block数据的Executor上的计算逻辑与当前Executor(Task)一致时,会优先直接使用远程端Executor来完成相关运算,这比将数据传输至本地要高效的多。
- 结语
了解Spark的JVM内存管理机制以及BlockManager组件对提升应用程序的稳定与效率而言至关重要,特别是Shuffle过程中数据运算、传输的情况往往决定了应用程序的健壮性,而这一部分更需要对Stage\Cache\Partitio\GC的持久优化,本文并未对此深入展开,至此Spark的内存管理及BlockManager已简单分析完毕。