Spark storage 模块

目录

 

 

问题探讨:Stage3 包含哪些 rdd?

问题探讨:小文件参数

知识点复习串联

RDD

Spark 调度流程

spark shuffle 过程

存储模块

整体架构

存储的基本单位Block

块的唯一标识:BlockID

块数据:BlockData

块元信息:BlockInfo

存储系统BlockManager

存储级别StorageLevel

 

存储实现BlockStore

DiskStrore实现详解

MemoryStore实现详解

TachyonStore实现讲解

spark OOM

Shuffle 的内存占用

Shuffle Write

Shuffle Read

Executor heap:

表现:

原因分析:

解决方法:

Driver heap:


 

问题探讨:Stage3 包含哪些 rdd?

Stage3 含有 RDD_B 和 RDD_G。

RDD_A 和 RDD_B 、RDD_F 和 RDD_G 之间是宽依赖(分区是一对多),其他都是窄依赖。

 

下图截自 《Spark大数据商业实战三部曲_内核解密_商业案例_性能调优》的349/1147。

 

数据运行:上游 stage 和下游 stage 间串行,stage 内数据 pipeline,不需要父RDD把Partition中所有的Records计算完毕才整体往后流动数据进行计算。

 

问题探讨:小文件参数

1.什么是小文件?

存储于HDFS中,文件的大小远小于HDFS上块(dfs.block.size)大小的文件,目前集群默认的dfs.blck.size=64MB。

Spark SQL写入HDFS的文件,也就是结果数据写入 HDFS 写,不是 shuffle 过程的文件。

shuffle 过程数据存在单个节点的磁盘和内存中

Persist 和 Cache 只能保存在本地的磁盘和内存中

Checkpoint 可以保存数据到 HDFS 这类可靠的存储上

 

2.小文件影响

  • 大量的小文件会给Hadoop集群的扩展性和性能带来严重的影响。NameNode在内存中维护整个文件系统的元数据镜像,用户HDFS的管理;如果小文件过多,会占用大量内存,直接影响NameNode的性能。

  • HDFS读写小文件也会更加耗时,因为每次都需要从NameNode获取元信息,并与对应的DataNode建立连接

  • 小文件造成查询性能的损耗,大量的数据分片信息以及对应产生的Task元信息会造成内存压力

 

3.参数略看

阶段

原因解释

spark参数设置

参数解释

map合并

一个task对于一个分区(一个目录)只写出一个文件。

控制最终stage的task个数,也就是控制整个作业的并行度,具体来讲,可以从最开始单个map输入size,shuffle之后单个reduce的size两方面来控制。

set spark.hadoop.hive.exec.orc.split.strategy=ETL;

spark.hadoopRDD.targetBytesInPartition

spark.hadoop.mapreduce.input.fileinputformat.split.maxsize

spark.hadoop.mapreduce.input.fileinputformat.split.minsize

首先需要把切分策略设置成ETL模式,其余三个参数都可以控制合并后的文件大小。

reduce合并

控制最终stage的task个数,也就是控制整个作业的并行度,具体来讲,可以从最开始单个map输入size,shuffle之后单个reduce的size两方面来控制。

set spark.sql.adaptive.enabled=true;

spark.sql.adaptive.shuffle.targetPostShuffleInputSize

打开自适应开关,在最后一个stage中增加每个task的处理量,进而减少task数量,最终减少小文件数量。

task_num=shuffle_read_size/spark.sql.adaptive.shuffle.targetPostShuffleInputSize

写入后合并

在写入HDFS之后,计算平均文件大小,merge小文件(但是这种做法只能缓解NN元数据的压力,由于存在写小文件,统计平均文件大小,读小文件、写出大文件这一连串操作,会增加NN RPC的压力,在NN负载高的时候,还会增加作业本身的执行时间)。

spark.sql.mergeSmallFileSize

spark.sql.targetBytesInPartitionWhenMerge

spark.sql.mergeSmallFileSize是小文件合并的阈值,最终的文件大小由max(spark.sql.mergeSmallFileSize, spark.sql.targetBytesInPartitionWhenMerge , spark.hadoopRDD.targetBytesInPartition )来决定

写多个hdfs分区

一个 task 写多个分区会有多个文件

distribute by partition_date

可以保证按照分区重新shuffle,一个分区内的数据在一个task内处理

 

 

知识点复习串联

RDD

RDD—全称是 Resilient Distributed Datasets 弹性分布式数据集,spark中的数据抽象,编程抽象。

  • 数据集的基本组成单位分片(Partition):对于RDD来说,每个分片都会被一个计算任务处理,并决定并行计算的粒度。

  • RDD 的计算(compute)函数:compute函数负责的是父RDD分区数据到子RDD分区数据的变换逻辑。。

  • RDD的依赖:包括宽窄依赖,窄依赖RDD 之间的分区是一对一的关系,宽依赖 RDD 之间分区是一对多。

  • RDD的分片函数Partitioner:包括RangePartitioner(范围分区)和HashPartitioner,如果数据计算涉及到shuffle,则会由Partitioner来对数据进行重新分区。

  • RDD的每个Partition的优先位置(preferred location):对于一个HDFS文件来说,这个列表保存的就是每个Partition所在的块的位置。

 

RDD缓存(cache/persist)

cache和persist其实是RDD的两个API,并且cache底层调用的就是persist,区别之一就在于cache不能显示指定缓存方式,只能缓存在内存中,但是persist可以通过指定缓存方式,比如显示指定缓存在内存中和磁盘上并且序列化等。通过RDD的缓存,后续可以对此RDD或者是基于此RDD衍生出的其他的RDD处理中重用这些缓存的数据集

 

 

 

Spark 调度流程

 

 

Spark集群启动时,需要从主节点和从节点分别启动Master进程和Worker进程,对整个集群进行控制。在一个Spark应用的执行过程中

  • Driver是应用的逻辑执行起点,运行Application的main函数并创建SparkContext。

  • DAGScheduler把Job中的RDD有向无环图根据依赖关系划分为多个Stage,每一个Stage是一个TaskSet,把TaskSet交给TaskScheduler调度。

  • TaskScheduler把Task分发给Worker中的Executor。

  • Worker启动Executor,Executor启动线程池用于执行Task。完成之后将结果返回给 driver。

     

一个Spark应用程序包括Job、Stage以及Task三个概念:

  • Job是以Action方法为界,遇到一个Action方法则触发一个Job

  • Stage是Job的子集,以RDD宽依赖(即Shuffle)为界,遇到Shuffle做一次划分

  • Task是Stage的子集,以并行度(分区数)来衡量,分区数是多少,则有多少个task

 

Task分为两类:

  • ResultTask:最后一个 stage,会根据生成结果的partition数据量来生成与partition数量相同的的ResultTask,把分区数据作为参数输入到action函数中,最终计算出特定的结果返回给driver。

  • ShuffleMapTask:非最后的stage,会根据每个stage的partition数量来生成ShuffleMapTask,把分区数据作为参数传入分区函数,最终形成新的RDD中的分区数据,保存在各个Executor节点中,并将分区数据信息MapStatus(ShuffleMapTask返回调度器scheduler的对象,包括任务运行结果的BlockID和对应每个reducer的输出大小)返回给driver。

 

各个概念间的数量对应关系:

Application : Job = 1 :n

Job : Stage = 1 :n

Stage : Task = 1 : n

Task : Rdd Partition = 1 : 1

Worker : Executor = 1 : n

Executor : 虚拟的 core = 1 : n

虚拟的 core : Task = 1 : 1 (Executor中的内存是有限的,task以线程的方式执行,各线程共享Execution 内存)

 

spark shuffle 过程

Shuffle的整个过程一般包含两个阶段任务,如下图所示

  1. 产生Shuffle数据的阶段(Shuffle Write阶段,涉及数据持久化)

  2. 使用Shuffle数据的阶段(Shuffle Read阶段)

 

reduceByKey:

 

关注问题

1.map端record是否有序?

• 仅按partitionId排序: partitionBy, groupByKey

• 同时按partitionId + key排序: reduceByKey, sortByKey

2.为什么Key=1和3被分到reduce T1?

根据Partitioner而定

3.为什么mapP3没有到reduce T2的连线?

没reduce T2要的数据

shuffle write 分为:

  • Hash Based Shuffle Write:

1).普通机制:M(map task的个数)*R(reduce task的个数),每个 map 的 task 为每个 reduce 的 task 生成一个文件。

2).优化机制(Consolidate机制):C(core的个数)*R(Reduce的个数),每个 core 里面的 task 为每个 reduce 的 task 生成一个文件。同一个 core 后面的 task 就追加写第一个 task 写的文件。

  • Sort Based Shuffle Write:

 

 

1).普通机制: 2*M(其中M代表Mapper阶段中并行Partition的总数量),一个 map 的 task 写一个数据文件 和一个索引文件(下游各个task的数据在文件中的start offset与end offset)。

详细过程

在该模式下,数据会先写入一个内存数据结构中,此时根据不同的shuffle算子,可能选用不同的数据结构。如果是reduceByKey这种聚合类的shuffle算子,那么会选用Map数据结构,一边通过Map进行聚合,一边写入内存;如果是join这种普通的shuffle算子,那么会选用Array数据结构,直接写入内存。接着,每写一条数据进入内存数据结构之后,就会判断一下,是否达到了某个临界阈值。如果达到临界阈值的话,那么就会尝试将内存数据结构中的数据溢写到磁盘,然后清空内存数据结构。

  在溢写到磁盘文件之前,会先根据key对内存数据结构中已有的数据进行排序。排序过后,会分批将数据写入磁盘文件。默认的batch数量是10000条,也就是说,排序好的数据,会以每批1万条数据的形式分批写入磁盘文件。写入磁盘文件是通过Java的BufferedOutputStream实现的。BufferedOutputStream是Java的缓冲输出流,首先会将数据缓冲在内存中,当内存缓冲满溢之后再一次写入磁盘文件中,这样可以减少磁盘IO次数,提升性能。

  一个task将所有数据写入内存数据结构的过程中,会发生多次磁盘溢写操作,也就会产生多个临时文件。最后会将之前所有的临时磁盘文件都进行合并,这就是merge过程,此时会将之前所有临时磁盘文件中的数据读取出来,然后依次写入最终的磁盘文件之中。此外,由于一个task就只对应一个磁盘文件,也就意味着该task为下游stage的task准备的数据都在这一个文件中,因此还会单独写一份索引文件,其中标识了下游各个task的数据在文件中的start offset与end offset。

2).bypass机制,没有排序:2*M。shuffle write过程中不会进行排序操作,而是直接按照未经优化的HashShuffleManager的方式去写数据,但是最后会将每个task产生的所有临时磁盘文件都合并成一个文件,并会创建单独的索引文件。启用该机制的最大好处在于,shuffle write过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销。

bypass运行机制的触发条件如下:

  1、shuffle map task数量小于spark.shuffle.sort.bypassMergeThreshold参数的值。
  2、不是 聚合类的shuffle算子(比如聚合类算子reduceByKey)。

 

shuffle read:会通过数据所在的位置划分数据的读取策略:

  • 如果数据在本地,那么可以直接从BlockManager中获取。

  • 如果数据在其他节点上,需要通过网络获取。但由于Shuffle的数据量可能会很大,又有以下2种策略:

    • 每次最多启动5个线程到最多5个节点上读取数据。

    • 每次请求的数据大小不会超过spark.reducer.maxMbInFlight(默认为48MB)的五分之一。

 

 

存储模块

Storage模块负责管理Spark计算过程中产生的数据,包括基于Disk的和基于Memory的。

用户在实际编程中,面对的是RDD,可以将RDD的数据通过cache持久化,持久化的动作都是由Storage模块完成的,包括Shuffle过程中的数据,也都是由Storage模块管理的。
可以说RDD实现用户的逻辑,而Storage管理用户的数据。在Driver端和Executor端,都会有Storage模块。

 

整体架构

RDD 的持久化由 Spark 的 Storage 模块负责,实现了 RDD 与物理存储的解耦合。Storage 模块负责管理 Spark 在计算过程中产生的数据,将那些在内存或磁盘、在本地或远程存取数据的功能封装了起来。在具体实现时 Driver 端和 Executor 端的 Storage 模块构成了主从式的架构,即 Driver 端的 BlockManager 为 Master,Executor 端的 BlockManager 为 Slave。Storage 模块在逻辑上以 Block 为基本存储单位,RDD 的每个 Partition 经过处理后唯一对应一个 Block(BlockId 的格式为 rdd_RDD-ID_PARTITION-ID )。Master 负责整个 Spark 应用程序的 Block 的元数据信息的管理和维护,而 Slave 需要将 Block 的更新等状态上报到 Master,同时接收 Master 的命令,例如新增或删除一个 RDD。

 

存储的基本单位Block

在Spark的存储体系中,数据的读写是以块为单位,也就是说Block是Spark存储的基本单位。

这里的Block和Hdfs的Block是不一样的:

HDFS中是对大文件进行分Block进行存储,Block大小是由dfs.blocksize决定的;而Spark中的Block是用户的操作单位,一个Block对应一块有组织的内存,一个完整的文件或文件的区间端,并没有固定每个Block大小的做法。saprk 的 RDD partition 跟 spark 的 RDD block 是一一对应的。而 spark 的 partition 跟 hdfs 的关系是,从hdfs分布式文件系统hdfs://生成的rdd,操作时如果没有指定分区数,则默认分区数规则为:rdd的分区数 = max(hdfs文件的block数目, sc.defaultMinPartitions)。

Block是用户的操作单位,而这个操作对应的key就是这里BlockID,该Key所对应的真实数据内容为ManagerBuffer;

块的唯一标识:BlockID

每个块都有唯一的标识,Spark把这个标识抽象为BlockId。BlockId本质上是一个字符串,但是在Spark中将它保证为"一组"case类,这些类的不同本质是BlockID这个命名字符串的不同,从而可以通过BlockID这个字符串来区别BlockId,继承体系如下:

可以看出,块主要是用来服务Shuffle,RDD,TaskResult,Broadcast以及Temp文件使用,命名规则如下:

  1. RDDBlockId是从数据源读取到的初始RDD或者transform得到的RDD: "rdd_" + rddId + "_" + splitIndex

  2. Shuffle过程中会产生shuffleBlock,以及数据Block和索引Block文件,分别命名如下:

    1. ShuffleBlockId: "shuffle_" + shuffleId + "_" + mapId + "_" + reduceId

    2. ShuffleDataBlockId: "shuffle_" + shuffleId + "_" + mapId + "_" + reduceId + ".data"

    3. ShuffleIndexBlockId:"shuffle_" + shuffleId + "_" + mapId + "_" + reduceId + ".index"

  3. BroadcastBlockId中使用的Block命名:"broadcast_" + broadcastId + (if (field == "") "" else "_" + field)

  4. TaskResultBlockId是ResultTask中返回结果使用的Block命名: "taskresult_" + taskId

  5. Tmp相关的Block是中间文件:

    1. TempShuffleBlockId是shuffle中的中间文件: "temp_shuffle_" + id

    2. TempLocalBlockId是本地文件,DiskBlockManager中会使用到,命名:"temp_local_" + id

  6. TestBlockId供测试使用,命名"test_" + id

 

块数据:BlockData

scala trait(相当于 java 的 interface 接口) 定义:

  • toInputStream():将块数据转化为java.io.InputStream。

  • toNetty():将块数据转化为适合Netty上传输的对象格式。

  • toChunkedByteBuffer():将块数据转化为o.a.s.util.io.ChunkedByteBuffer。ChunkedByteBuffer是对多个java.nio.ByteBuffer的封装,表示多个不连续的内存缓冲区中的数据。虽然Chunk这个词在中文中一般也翻译作“块”,但它与上面的Block相比,更是一个逻辑概念而非物理概念。

  • toByteBuffer():将块数据转化为单个java.nio.ByteBuffer。

  • size():返回这个BlockData的长度。

  • dispose():销毁BlockData。

BlockData只是定义了数据转化的规范,并没有涉及具体的存储格式和读写流程,实现起来比较自由,所以前面说它是个松散的特征。BlockData目前有3个实现类:基于内存和ChunkedByteBuffer的ByteBufferBlockData、基于磁盘和File的DiskBlockData,以及加密的EncryptedBlockData。ChunkedByteBuffer实际上就是定义了对Array[ByteBuffer]类型的各种操作。

 

块元信息:BlockInfo

BlockInfo是对一个Block元信息的记录,可以方便跟踪块的一些基本数据。该类主要由三个变量:

  1. level:块的期望存储等级,不代表实际的存储情况

  2. classTag:块的类标签

  3. tellMaster:是否要将该块的元信息告知Master

另外提供了size, readerCount, writerTask三个方法。
size:块的大小,以字节为单位。

  1. readerCount:该块被读取的次数。因为读取块时需要上锁,因此也就相当于加读锁的次数。

  2. writerTask:当前持有该块写锁的Task ID。

BlockInfo记录了块存储级别,块大小,以及当前被读取的次数,以及占有写锁的任务ID,但是BlockInfo并不进行实际的lock/unlock操作,这部分是由BlockInfoManager进行控制。

 

存储系统BlockManager

BlockManager是spark自己的存储系统,RDD-Cache、 Shuffle-output、broadcast等的实现都是基于BlockManager来实现的,BlockManager也是分布式结构,在Driver和所有Executor上都会有blockmanager节点,每个节点上存储的block信息都会汇报给driver端的BlockManagerMaster作统一管理,BlockManager主要是通过内部的两个组件MemoryStore和DiskStore来管理数据向内存或磁盘写入的。

 

一个作业的运行过程中都有哪些地方使用到了BlockManager:

  • DAGScheduler.getCacheLocs。这个方法的调用是在提交一个stage时,需要获取分区的偏向位置时会调用该方法。我们知道rdd是可以缓存的,而rdd的缓存就是通过blockManager来管理的,有一个专门的RDDBlockId用来表示一个RDD缓存块的唯一标识。

最终调用的方法是:blockManagerMaster.getLocations(blockIds)

  • 广播变量。在DAGscheduler中提交stage时需要把rdd和ShuffleDependency(对于ResultStage则是一个函数)对象序列化用于网络传输,实际上序列化后的字节数组是通过broadcastManager组件进行网络传输的,而broadcastManager实际又是通过BlockMananger来将要广播的数据存储成block,并在executor端发送rpc请求向BlockManangerMaster请求数据。每个广播变量会对应一个TorrentBroadcast对象,TorrentBroadcast对象内的writeBlocks和readBlocks是读写广播变量的方法,

最终调用的方法是:blockManager.putSingle和blockManager.putBytes

  • Shuffle的map阶段输出。如果我们没有启动外部shuffle服务及ExternalShuffle,那么就会用spark自己的shuffle机制,在map阶段输出时通过blockManager对输出的文件进行管理。shuffle这部分主要使用的是DiskBlockManager组件。

最终调用的是:DiskBlockManager相关方法包括createTempShuffleBlock,getDiskWriter, DiskBlockObjectWriter相关方法,包括write方法和commitAndGet方法

  • 任务运行结果序列化后传回driver。这里分为两种情况,如果结果序列化后体积较小,小于maxDirectResultSize,则直接通过rpc接口传回,如果体积较大,就需要先通过blockManager写入executor的内存和磁盘中,然后在driver端进行拉取。

最终调用的是:blockManager.putBytes

 

存储block的流程: doPut()方法

 

1)为block创建BlockInfo并加锁使其不能被其他线程访问;

2)按照block的存储级别:useMemory, useOffHeap, useDisk进行存储,并标识该block可以被其他线程访问;

3)tellMaster=true(默认就时true): reportBlockStatus(blockId, putBlockInfo, putBlockStatus)

  通知BlockManagerMaster有新的数据写入,在BlockManagerMaster中更新Block信息

4)根据block的replication数决定是否将该block备份到其他节点(异步)

 

获取block的流程:get()方法

 

1)先从本地的BlockManager查找:依次从useMemory, useOffHeap, useDisk去查找;

根据blockid获得到对应的blockinfo(该blockinfo被加锁了),获取到该blockinfo的storagelevel,进入如下分支进行查找:

  level.useMemory    从Memory中取出block并返回,如果没有就进入下一个分支;

  level.useOffHeap   从Tachyon中取出block并返回,如果没有就进入下一个分支;

  level.useDisk

    level.useMemory==true 将block从disk中读出并写入内存以便下次使用时从内存中获取,同时返回该block;

    level.useMemory==false 将block从disk中读出并返回;

2)本地获取不到再从远端(executor)的BlockManager去查找(BlockManagerWorker.syncGetBlock)

  获得该block的location信息;

  根据location向远端发送请求获取block,只要有一个远端返回block该函数就返回而不继续发送请求;

 

 

存储级别StorageLevel

存储级别,对于用户来说是RDD相关的持久化和缓存。这实际上也是Spark最终要的特征之一。每个节点都将RDD的Partition的数据保存在内存中,后续的计算将会变得非常快。可以说,缓存是Spark构建迭式算法和快速交互式查询的关键。

只有触发了一个Action后,计算才会提交到集群中开始真正的运算。因此,RDD只有经过一次Acttion之后,才能将RDD缓存到内存中以供以后的计算使用。这个缓存也有容错机制,如果某个缓存丢失了,那么会通过原来的计算过程进行重算。

1.存储级别的定义

  • NONE:
    不会保存任何数据

  • DISK_ONLY:
    直接将RDD的Partition保存在该节点的Disk上。

  • MEMORY_ONLY:
    将RDD的Partition对应的原生Java Object保存在JVM中。如果RDD太大导致它的部分Partition不能存储在内存中,那么这些Partition将不会被缓存,并且在需要的时候被重新计算。这还是默认的级别。

  • MEMORY_ONlY_SER:
    将RDD的Partition序列化后的对象(每一个Partition占用一个字节数组)存储在JVM中。通常来说,这将比直接保存院士对象的空间利用率更高,尤其当使用fast seralizer(快速序列化)时。但在读取时由于需要反序列化会比较占用CPU。

  • DISK_ONLY_2
    MEMORY_ONLY_2
    MEMORY_ONLY_SER_2
    MEMORY_AND_DISK_2
    MEMORY_AND_DISK_SER_2:
    同上书的存储级别。不同就是在其他节点上会保存一个相同的备份。从集群的角度看,一共有两个备份。

  • OFF_HEAP:
    将RDD的Partition序列化后存储到Tachyon中。相比MEMORY_ONLY_SER,OFF_HEAP有几个优势:
    1)减少了GC带来的性能损耗
    2)使得Executor内存使用更加轻量级
    3)在集群的角度共享一个内存池,非常有利于对于内存有超大需求的Application;而且使得在每个Executor中间共享内存数据成为可能

对于DISK和Memory两种级别是可以同时出现的,而OffHeap与其他两个是互斥的.

OffHeap 优点

优点是减少GC的代销,Spark中实现的OffHeap是基于Tachyon:分布式内存文件系统来实现的。

2.选择合适的存储级别

Spark不同的存储级别是内存使用和CPU效率的折中。Spark官网建议按照以下步骤来选择合适的存储级别:

  • 1)如果你的RDD可以和默认的存储级别有很好的的契合,那么就无需任何特殊的设定了。默认的存储级别是CPU最高效的选项,也是运算能够最快完成的选项。

  • 2)如果不行,那么需要减少内存的使用,可以使用MEMORY_ONLY_SER。这个时候需要选择一个合适的序列化方案。需要在空间效率和反序列化是所需要的CPU中做一个合适的选择。

  • 3)尽量不要落在硬盘上,除非是计算逻辑非常复杂,或者是需要从一个超大规模的数据集过滤出一小部分数据。否则重新计算一个Partition的速度可能和从硬盘读差不多(考虑到出错的概率和写硬盘的开销,因此采用失败重算要比读硬盘持久化的数据要好)。

  • 4)如果你需要故障的快速恢复能力(比如使用Spark来处理Web的请求),那么可以考虑使用存储级别的多副本机制。实际上所有的存储级别都提供了Partition数据丢失时的重算机制,只不过有备份的话可以让Application直接使用副本而无需等待重新计算丢失的Partition数据。

  • 5)如果集群有大量的内存或者有很多的运行任务,则选择OFF_HEAP。现在处于试验阶段的OFF_HEAP有以下的优势:
    a) 它使得多个Executor可以共享一个内存池。
    b) 它显著地减少了GC的开销。
    c) 缓存在内存中的数据即使是产生它的Executor异常退出了也不会丢失。

 

存储实现BlockStore

org.apache.spark.storage.BlockStore:存储Block的抽象类,实现包括三类:
1)org.apache.spark.storage.DiskStore
2)org.apache.spark.storage.MemoryStore
3)org.apache.spark.storage.Tachyon

DiskStrore实现详解

DiskStore即基于文件来存储Block. 基于Disk来存储,首先必须要解决一个问题就是磁盘文件的管理:磁盘目录结构的组成,目录的清理等,在Spark对磁盘文件的管理是通过 DiskBlockManager来进行管理的。

  • DiskStore通过org.apache.spark.DiskBlockManager来管理文件。前面介绍过,DiskBlockManager管理和维护了逻辑上的Block存储和存储在Disk上物理的Block的映射。一般来说,一个逻辑的Block会根据他的blockId生成的名字映射到一个物理的文件。

  • 一般来说,DiskStore 会通过 blockId 从 DiskBlockManager 获取一个文件句柄,然后通过这个文件句柄来读写文件。

那么如何确定某个 Block 需要保存在哪个子目录呢?DiskBlockManager根据文件名的 hash 值取得其应该存放的以及目录(即下面图上的"spark-local-yyyyMMddHHmmss-xxxx"),然后根据该 hash 值取得其应该存放的二级目录(即一级目录的子目录)。主要的实现如下:

确定文件路径代码

val hash = Utils.nonNegativeHash(filename)
val dirId = hash % localDirs.length
val subDIrId = (hash / localDirs.length) % subDirsPerLocalDir

DiskBlockManager 会为 Executor 在每个目录下创建一个子目录,子目录的命名方式是 "spark-local-yyyyMMddHHmmss-xxxx",其中,最后的 xxxx 是一个随机数。而每个 "spark-local-yyyyMMddHHmmss-xxxx" 目录下,会根据需要生成个数至多为 spark.diskStore.subDirectories(默认值为64)的子目录,子目录以数字命名(从00到文件个数)。图8-6表示了用户在 spark.local.dir 中设置了3个目录,每个目录下有64个子目录的目录布局。

 

DiskBlockManager的核心工作就是这个,即提供 def getFile(filename: String): File 接口,根据filename确定一个文件的路径; 剩下来的就是目录清理等工作;

DiskStore的实现,对于最为简单的PutBytes接口,DiskStore通过BlockID为文件名称,通过diskManager来获取Block对应的文件,进而完成Block的写。

putBytes 代码

override def putBytes(blockId: BlockId, _bytes: ByteBuffer, level: StorageLevel): PutResult = {
    val bytes = _bytes.duplicate()
    val startTime = System.currentTimeMillis
    val file = diskManager.getFile(blockId)
    val channel = new FileOutputStream(file).getChannel
    while (bytes.remaining > 0) {
      channel.write(bytes)
    }
    channel.close()
    val finishTime = System.currentTimeMillis
    PutResult(bytes.limit(), Right(bytes.duplicate()))
  }

对于putIterator接口,DiskStore是通过BlockManager的dataSerializeStream接口,将Iterator序列化为Stream流并写到blockID对应的文件中.

putIterator 代码

override def putIterator(blockId: BlockId,values: Iterator[Any],level: StorageLevel,
      returnValues: Boolean): PutResult = {
    val file = diskManager.getFile(blockId)
    val outputStream = new FileOutputStream(file)
    blockManager.dataSerializeStream(blockId, outputStream, values)
    PutResult(length, null)
  }

对于get类的接口这里要说一下文件segment的概念,在Shuffle中要用到,一个BlockID对应一个文件是有些浪费,会造成很多小文件,影响读写性能;因此Spark提供了对文件segment 的支持,文件的segment即为文件一个区段,由offset和length组成,默认offset=0,length=filesize,即读取整个文件。

getBytes 代码

def getBytes(segment: FileSegment): Option[ByteBuffer] = {
    getBytes(segment.file, segment.offset, segment.length)
 }

MemoryStore实现详解

MemoryStore采用的JVM的heap内存进行Block存储。采用LinkedHashMap进行存储每个Block,Block的内容为MemoryEntry,它是一个Value的封装;

代码块

private case class MemoryEntry(value: Any, size: Long, deserialized: Boolean) 
private val entries = new LinkedHashMap[BlockId, MemoryEntry](32, 0.75f, true)

Executor内存总体布局:

  • JVM堆外内存:大小由 spark.yarn.executor.memoryOverhead 参数指定。默认大小为 max(executorMemory * 0.10, 384m) (ETL中默认配置1G)。主要用于JVM自身,Native方法调用,线程栈,字符串, NIO Buffer等开销。

  • 堆内内存:大小由 Spark 应用程序启动时的 –executor-memory 或 spark.executor.memory 参数配置 (ETL中默认配置2G)

    大小限制:spark.executor.memory + spark.yarn.executor.memoryOverhead <= 16G (YARN container最大内存限制)

    单个Executor上Task内存共享

    Spark的一个Executor的多个task共享Executor的内存,默认情况下,一个Executor中同时可以执行的task数目与executor-cores数目相等。

    所以在Executor内存不变的情况下,executor-cores数越大,平均下来一个task可以使用的内存就越少。

堆内内存:

  • 执行内存 (Execution Memory) : 主要用于存放 Shuffle、Join、Sort、Aggregation 等计算过程中的临时数据;

  • 存储内存 (Storage Memory) : 主要用于存储 spark 的 cache 数据,例如RDD的缓存、广播变量;

  • 用户内存(User Memory): 主要用于存储 RDD 转换操作所需要的数据,例如 RDD 依赖等信息;

  • 预留内存(Reserved Memory): 系统预留内存,会用来存储Spark内部对象。

 

Execution 内存和 Storage 内存动态占用机制(统一内存管理机制):

在 Spark 1.5 之前,Execution 内存和 Storage 内存分配是静态的,动态与静态内存管理最大的区别在于存储内存和执行内存共享同一块空间,可以动态占用对方的空闲区域。

 

对于存储内存中,首先第一个需要解决的问题就是使用多少的内存用于Store存储?首先看几个配置:

  • spark.storage.memoryFraction:多少比例的JVM最大内存用于store存储,默认是0.6

  • spark.storage.safetyFraction:一个安全比例,默认是0.9,即memoryFraction基础上做了一个缩小操作。因为cache block都是估算的,所以需要一个安全系数来保证安全。

那么最大可以用于Store的内存大小就为: (Runtime.getRuntime.maxMemory * memoryFraction * safetyFraction).toLong

 

RDD 缓存过程

RDD 在缓存到存储内存之前,Partition 中的数据一般以迭代器(Iterator)的数据结构来访问,这是 Scala 语言中一种遍历数据集合的方法。通过 Iterator 可以获取分区中每一条序列化或者非序列化的数据项(Record),这些 Record 的对象实例在逻辑上占用了 JVM 堆内内存的 other 部分的空间,同一 Partition 的不同 Record 的空间并不连续

RDD 在缓存到存储内存之后,Partition 被转换成 Block,Record 在堆内或堆外存储内存中占用一块连续的空间。 将 Partition 由不连续的存储空间转换为连续存储空间的过程,Spark 称之为”展开”(Unroll) 。Block 有序列化和非序列化两种存储格式,具体以哪种方式取决于该 RDD 的存储级别。非序列化的 Block 以一种 DeserializedMemoryEntry 的数据结构定义,用一个数组存储所有的对象实例,序列化的 Block 则以 SerializedMemoryEntry的数据结构定义,用字节缓冲区(ByteBuffer)来存储二进制数据。每个 Executor 的 Storage 模块用一个链式 Map 结构(LinkedHashMap)来管理堆内和堆外存储内存中所有的 Block 对象的实例,对这个 LinkedHashMap 新增和删除间接记录了内存的申请和释放。

因为不能保证存储空间可以一次容纳 Iterator 中的所有数据,当前的计算任务在 Unroll 时要向 MemoryManager 申请足够的 Unroll 空间来临时占位,空间不足则 Unroll 失败,空间足够时可以继续进行。对于序列化的 Partition,其所需的 Unroll 空间可以直接累加计算,一次申请。非序列化的 Partition 则要在遍历 Record 的过程中依次申请,即每读取一条 Record,采样估算其所需的 Unroll 空间并进行申请,空间不足时可以中断,释放已占用的 Unroll 空间。如果最终 Unroll 成功,当前 Partition 所占用的 Unroll 空间被转换为正常的缓存 RDD 的存储空间,如下图所示:

 

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

存储内存和执行内存有着截然不同的管理方式:

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

 

淘汰和落盘

由于同一个 Executor 的所有的计算任务共享有限的存储内存空间,当有新的 Block 需要缓存但是剩余空间不足且无法动态占用时,就要对 LinkedHashMap 中的旧 Block 进行淘汰(Eviction),而被淘汰的 Block 如果其存储级别中同时包含存储到磁盘的要求,则要对其进行落盘(Drop),否则直接删除该 Block。

存储内存的淘汰规则为:

  • 被淘汰的旧 Block 要与新 Block 的 MemoryMode 相同,即同属于堆外或堆内内存

  • 新旧 Block 不能属于同一个 RDD,避免循环淘汰

  • 旧 Block 所属 RDD 不能处于被读状态,避免引发一致性问题

  • 遍历 LinkedHashMap 中 Block,按照最近最少使用(LRU)的顺序淘汰,直到满足新 Block 所需的空间。其中 LRU 是 LinkedHashMap 的特性。

落盘的流程则比较简单,如果其存储级别符合_useDisk 为 true 的条件,再根据其_deserialized 判断是否是非序列化的形式,若是则对其进行序列化,最后将数据存储到磁盘,在 Storage 模块中更新其信息。

 

剩下了的PUT/GET/REMOVE接口都比较简单,序列化的知识和上面谈到DiskStore基本一致,仅仅多了一个内存大小的限制和Unroll的过程。

 

TachyonStore实现讲解

Tachyon实现了Spark将缓存的数据放到Tachyon中。这个实现可以看作是实现了一个Tachyon的客户端,通过这个客户端Spark可以读写Tachyon的数据。

Tachyon 是什么

Tachyon基于内存的分布式存储系统。Tachyon相当于是在存储层和计算层之间的cache层,Tachyon并不是要替代任何的存储系统,它的作用是加快计算层对存储层的访问速度。

 

 


 

spark OOM

 

有淘汰和落盘为什么还有 oom 呢?

oom通常出现在execution内存中,因为storage这块内存在放满之后,会直接丢弃内存中旧的数据,对性能有点影响但不会导致oom。

Shuffle 的内存占用

执行内存主要用来存储任务在执行 Shuffle 时占用的内存,Shuffle 是按照一定规则对 RDD 数据重新分区的过程:

Shuffle Write

若在 map 端选择普通的排序方式,会采用 ExternalSorter 进行外排,在内存中存储数据时主要占用堆内执行空间。

若在 map 端选择 Tungsten 的排序方式,则采用 ShuffleExternalSorter 直接对以序列化形式存储的数据排序,在内存中存储数据时可以占用堆外或堆内执行空间,取决于用户是否开启了堆外内存以及堆外执行内存是否足够。

Shuffle Read

在对 reduce 端的数据进行聚合时,要将数据交给 Aggregator 处理,在内存中存储数据时占用堆内执行空间。

刚获取来的 FileSegment 存放在 softBuffer 缓冲区(缓冲区大小由spark.reducer.maxMbInFlight决定),经过处理后的数据放在内存 + 磁盘上。
内存使用的是AppendOnlyMap ,类似 Java 的HashMap,内存+磁盘使用的是ExternalAppendOnlyMap,如果内存空间不足时,ExternalAppendOnlyMap可以将 records 进行 sort 后 spill(溢出)到磁盘上,等到需要它们的时候再进行归并

拉取处理数据:

MapOutputTracker

MapOutputTracker 用于跟踪 stage 的 map 阶段的任务输出的位置,这个位置便于 reduce 阶段任务获取 中址以及中间输出结果。由于每个 reduce 任务的输入可能是多个 map 任务的输出,reduce 会到各个 map 任务所在节点去拉 Block,即 shuffle.

获取本地数据:

 

Executor heap:

表现:

1.UI Task的失败原因显示: java.lang.OutOfMemoryError:

 

2.UI Task的失败原因显示: ExecutorLostFailure 和Executor exit code 为143:

Diagnostics: Container killed on request. Exit code is 143

Container exited with a non-zero exit code 143

Killed by external signal

 

3.UI Task失败的原因显示: ExecutorLostFailure 和Executor Lost的原因是Executor heartbeat timed out Spark heap heart beat:

ExecutorLostFailure(executor 923 exited caused by one of the running tasks) Reason: Executor heartbeat timed out after 167123 ms

 

4.堆外内存:Spark UI中出现“Consider boosting spark.yarn.executor.memoryOverhead”,例如

ExecutorLostFailure (executor 29 exited caused by one of the running tasks)

Reason: Container killed by YARN for exceeding memory limits. 3.1 GB of 3 GB physical memory used.

Consider boosting spark.yarn.executor.memoryOverhead.

 

 

原因分析:

1.使用cache方式缓存RDD,由于Storage分配内存不足造成OOM:

疑问:为什么某些case下,使用cache会导致作业失败并报错OOM,而取消cache就可以正常运行呢?

Spark Executor内存管理并非按照storage.memoryFraction,shuffle.memoryFraction隔离分块(如果没有cache数据,storage部分内存将闲置),而是使用JVM整体内存。如果没有进行cache操作,那么其他包括对象创建,collection等可以使用除shuffle已缓存数据使用内存之外的全部内存。

当有cache操作,这部分内存将缩小,导致某项操作依赖的数据可能不能全部加载到内存,引发频繁GC,最终出现OutOfMemroy。

 

2.Shuffle Write/Read 造成OOM:

无论是在 Map 还是在 Reduce 端,插入数据到内存,排序,归并都是比较都是比较占用内存的。因为有 Spill,理论上不会因为数据倾斜造成 OOM。 但是,由于对堆内对象的分配和释放是由 JVM 管理的,而 Spark 是通过采样获取已经使用的内存情况,有可能因为采样不准确而不能及时 Spill,导致OOM。

 

Shuffle Write:

1)在归并排序阶段,会将同key的数据全部加载到内存进行处理。当算子为sortBykey或者groupByKey(shuffle write 阶段不聚合数据)等无法进行聚合的情况下,数据量过大,造成OOM。

2)归并排序后,将单partition的数据写入Segment时,受限于参数“spark.serializer.objectStreamReset”默认每一百条才会flush buffer,当单条记录过大时,造成OOM。

 

Shuffle Read:

1)在 Reduce 获取数据时,由于数据倾斜,有可能造成单个 Block 的数据非常的大,默认情况下是需要有足够的内存来保存单个 Block 的数据。 当某时刻fetch请求的block大小超过该数据暂存的空间,默认使用堆外内存(executor.memoryOverhead),此时Fetch过程会造成Executor端的OOM。可以设置 spark.maxRemoteBlockSizeFetchToMem 参数,设置这个参数以后,超过一定的阈值,会自动将数据 Spill 到磁盘,此时便可以避免因为数据倾斜造成 OOM 的情况。

2)在 Reduce 获取数据后,默认情况会对数据流进行解压校验(参数 spark.shuffle.detectCorrupt)。由于这部分没有 Spill 到磁盘操作,也有很大的可性能会导致 OOM。

 

解决方法:

如果executor-cores 比较大(>2), 首先减小 executor-cores。如果executor-cores 比较小,增大spark.executor.memory

1.增加reduce 聚合操作的内存(Execution Memory)的比例

2.增加Executor memory的大小 --executor-memory 5G

3.增大堆外内存 --conf spark.yarn.executor.memoryoverhead 2048M 。spark.executor.memory + spark.yarn.executor.memoryOverhead <= 16G (YARN container最大内存限制)

4.数据倾斜导致的oom就解决数据倾斜问题。

 

 

 

Driver heap:

1.用户在Driver端口生成大对象, 比如创建了一个大的集合数据结构

解决思路:

  • 考虑将该大对象转化成Executor端加载. 例如调用sc.textFile/sc.hadoopFile等

  • 增加driver-memory的值

2.从Executor端收集数据回Driver端 ,比如Collect。

某个Stage中Executor端发回的所有数据量不能超过spark.driver.maxResultSize,默认1g. 如果用户增加该值, 请对应增加2倍增量到Driver Memory, resultSize该值只是数据序列化之后的Size, 如果是Collect的操作会将这些数据反序列化收集, 此时真正所需内存需要膨胀2-5倍, 甚至10倍.

解决思路:

  • 本身不建议将大的数据从Executor端, collect回来. 建议将Driver端对collect回来的数据所做的操作, 转化成Executor端RDD操作.

  • 增加driver-memory的值

3.大量shuffle中间结果返回Driver导致OOM:

Shuffle Read阶段要从Driver拉取某ShuffleId对应的MapStatus数组,每个Array[MapStatus]经过压缩后的大小近似等于reducePartitionNum * 8 具体是 mapPartitions *( reducePartitions * 1B + RefBytes ) 。reducePartitionNum为shuffle阶段reduce的个数,即shuffleDependency.partitioner.numPartitions。当driver内存分配较小,而分区数过多,在对driver端MapOutputTrackerMaster中的mapStatuses解压缩后获取特定shuffleId,reduceId的shuffle信息,容易造成内存溢出。多个Executor同时请求,
Driver会建立多个管道,将数据进行拷贝后发送,发送完成管道释放。此时,如果Executor并发过高,大量的数据拷贝,将造成OOM。

解决思路:

  • 减少分区数。使用repartition或者coalesce减少分区数(代码)。spark.sql.shuffle.partitions (sql)。

  • 增加driver-memory的值

4.大表被广播。

被广播的表首先被collect到driver端,然后被冗余分发到每个executor上,所以当表比较大时,采用broadcast join会对driver端和executor端造成较大的压力。

解决思路:

  • 检查一下是否有将spark.sql.autoBroadcastJoinThreshold设置比较大,或者对大表加了广播的hint(mapjoin、broadcast)。

  • 增加driver-memory的值

5.Spark本身框架的数据消耗.

现在在Spark1.6版本之后主要由Spark UI数据消耗, 取决于作业的累计Task个数.

解决思路:

  • 减少分区数。

  • 减少保存在Master内存中的的stage和job信息。参数spark.ui.retainedStages(默认1000)、spark.ui.retainedJobs(默认1000)控制。

  • 增加driver-memory的值。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值