Shuffle是什么?(以下部分图片来自于网络,侵删)
Shuffle的本义是洗牌、混洗,把一组有一定规则的数据尽量转换成一组无规则的数据,越随机越好。MapReduce中的Shuffle更像是洗牌的逆过程,把一组无规则的数据尽量转换成一组具有一定规则的数据
为什么MapReduce计算模型需要Shuffle过程?
我们都知道MapReduce计算模型一般包括两个重要的阶段:
① Map是映射,负责数据的过滤分发
② Reduce是规约,负责数据的计算归并。Reduce的数据来源于Map,Map的输出即是Reduce的输入,Reduce需要通过Shuffle来获取数据
从Map输出到Reduce输入的整个过程可以广义地称为Shuffle,Shuffle横跨Map端和Reduce端,在Map端包括Spill过程,在Reduce端包括copy和sort过程,如图所示:
Spill过程
Spill过程包括输出、排序、溢写、合并等步骤,如图所示:
Collect
每个Map任务不断地以
对的形式把数据输出到在内存中构造的一个环形数据结构
中
使用环形数据结构是为了更有效地使用内存空间
,在内存中放置尽可能多的数据
这个数据结构其实就是个字节数组
,叫Kvbuffer
,名如其义,但是这里面不光放置了
数据,还放置了一些索引数据
,给放置索引数据的区域
起了一个Kvmeta
的别名,在Kvbuffer
的一块区域上穿了一个IntBuffer
(字节序采用的是平台自身的字节序)的马甲。
和索引数据区域
在Kvbuffer中是相邻不重叠的两个区域
,用一个分界点
来划分两者,分界点不是亘古不变的
,而是每次Spill之后都会更新一次
。初始的分界点是0
,
数据的存储方向是向上增长
,索引数据的存储方向是向下增长
,如图所示:
Kvbuffer
的存放指针bufindex
是一直闷着头地向上增长,比如bufindex
初始值为0,一个Int型的key写完之后,bufindex增长为4,一个Int型的value写完之后,bufindex增长为8
索引是对
在kvbuffer
中的索引,是个四元组
,包括:value的起始位置、key的起始位置、partition值、value的长度
,占用四个Int长度
,Kvmeta
的存放指针Kvindex
每次都是向下跳四个“格子”,然后再向上一个格子一个格子地填充四元组
的数据。比如Kvindex
初始位置是-4
,当第一个
Kvbuffer的大小虽然可以通过参数设置,但是总共就那么大,
关于Spill触发的条件,也就是Kvbuffer用到什么程度开始Spill,还是要讲究一下的。如果把Kvbuffer用得死死得,一点缝都不剩的时候再开始Spill,那Map任务就需要等Spill完成腾出空间之后才能继续写数据;如果Kvbuffer只是满到一定程度,比如80%的时候就开始Spill,那在Spill的同时,Map任务还能继续写数据,如果Spill够快,Map可能都不需要为空闲空间而发愁。两利相衡取其大,一般选择后者。
Spill这个重要的过程是由Spill线程承担,Spill线程从Map任务接到“命令”之后就开始正式干活,干的活叫SortAndSpill,原来不仅仅是Spill,在Spill之前还有个颇具争议性的Sort。
Sort
先把Kvbuffer中的数据按照partition值和key两个关键字升序排序,移动的只是索引数据,排序结果是Kvmeta中数据按照partition为单位聚集在一起,同一partition内的按照key有序
Spill
Spill线程为这次Spill过程创建一个磁盘文件:从所有的本地目录中轮训查找能存储这么大空间的目录,找到之后在其中创建一个类似于“spill12.out”的文件。Spill线程根据排过序的Kvmeta挨个partition的把
所有的partition对应的数据都放在这个文件里,虽然是顺序存放的,但是怎么直接知道某个partition在这个文件中存放的起始位置呢?强大的索引又出场了。有一个三元组记录某个partition对应的数据在这个文件中的索引:起始位置、原始数据长度、压缩之后的数据长度,一个partition对应一个三元组。然后把这些索引信息存放在内存中,如果内存中放不下了,后续的索引信息就需要写到磁盘文件中了:从所有的本地目录中轮训查找能存储这么大空间的目录,找到之后在其中创建一个类似于“spill12.out.index”的文件,文件中不光存储了索引数据,还存储了crc32的校验数据。(spill12.out.index不一定在磁盘上创建,如果内存(默认1M空间)中能放得下就放在内存中,即使在磁盘上创建了,和spill12.out文件也不一定在同一个目录下。)
每一次Spill过程就会最少生成一个out文件,有时还会生成index文件,Spill的次数也烙印在文件名中。索引文件和数据文件的对应关系如下图所示:
在Spill线程如火如荼的进行SortAndSpill工作的同时,Map任务不会因此而停歇,而是一无既往地进行着数据输出。Map还是把数据写到kvbuffer中,那问题就来了:
Map任务总要把输出的数据写到磁盘上,即使输出数据量很小在内存中全部能装得下,在最后也会把数据刷到磁盘上。
Merge
Map任务如果输出数据量很大,可能会进行好几次Spill,out文件和Index文件会产生很多,分布在不同的磁盘上。最后把这些文件进行合并的merge过程闪亮登场。
Merge过程怎么知道产生的Spill文件都在哪了呢?从所有的本地目录上扫描得到产生的Spill文件,然后把路径存储在一个数组里。Merge过程又怎么知道Spill的索引信息呢?没错,也是从所有的本地目录上扫描得到Index文件,然后把索引信息存储在一个列表里。到这里,又遇到了一个值得纳闷的地方。在之前Spill过程中的时候为什么不直接把这些信息存储在内存中呢,何必又多了这步扫描的操作?特别是Spill的索引数据,之前当内存超限之后就把数据写到磁盘,现在又要从磁盘把这些数据读出来,还是需要装到更多的内存中。之所以多此一举,是因为这时kvbuffer这个内存大户已经不再使用可以回收,有内存空间来装这些数据了。(对于内存空间较大的土豪来说,用内存来省却这两个io步骤还是值得考虑的。)
然后为merge过程创建一个叫file.out的文件和一个叫file.out.Index的文件用来存储最终的输出和索引。
一个partition一个partition的进行合并输出。对于某个partition来说,从索引列表中查询这个partition对应的所有索引信息,每个对应一个段插入到段列表中。也就是这个partition对应一个段列表,记录所有的Spill文件中对应的这个partition那段数据的文件名、起始位置、长度等等。
然后对这个partition对应的所有的segment进行合并,目标是合并成一个segment。当这个partition对应很多个segment时,会分批地进行合并:先从segment列表中把第一批取出来,以key为关键字放置成最小堆,然后从最小堆中每次取出最小的
最终的索引数据仍然输出到Index文件中。
Map端的Shuffle过程到此结束。
Copy
Reduce任务通过HTTP向各个Map任务拖取它所需要的数据。每个节点都会启动一个常驻的HTTP server,其中一项服务就是响应Reduce拖取Map数据。当有MapOutput的HTTP请求过来的时候,HTTP server就读取相应的Map输出文件中对应这个Reduce部分的数据通过网络流输出给Reduce。
Reduce任务拖取某个Map对应的数据,如果在内存中能放得下这次数据的话就直接把数据写到内存中。Reduce要向每个Map去拖取数据,在内存中每个Map对应一块数据,当内存中存储的Map数据占用空间达到一定程度的时候,开始启动内存中merge,把内存中的数据merge输出到磁盘上一个文件中。
如果在内存中不能放得下这个Map的数据的话,直接把Map数据写到磁盘上,在本地目录创建一个文件,从HTTP流中读取数据然后写到磁盘,使用的缓存区大小是64K。拖一个Map数据过来就会创建一个文件,当文件数量达到一定阈值时,开始启动磁盘文件merge,把这些文件合并输出到一个文件。
有些Map的数据较小是可以放在内存中的,有些Map的数据较大需要放在磁盘上,这样最后Reduce任务拖过来的数据有些放在内存中了有些放在磁盘上,最后会对这些来一个全局合并。
12.1.4Merge Sort
这里使用的Merge和Map端使用的Merge过程一样。Map的输出数据已经是有序的,Merge进行一次合并排序,所谓Reduce端的sort过程就是这个合并的过程。一般Reduce是一边copy一边sort,即copy和sort两个阶段是重叠而不是完全分开的。
Reduce端的Shuffle过程至此结束。
HashShuffle过程介绍
Spark丰富了任务类型,有些任务之间数据流转不需要通过Shuffle,但是有些任务之间还是需要通过Shuffle来传递数据,比如wide dependency的group by key
Spark中需要Shuffle输出的Map任务会为每个Reduce创建对应的bucket,Map产生的结果会根据设置的partitioner得到对应的bucketId,然后填充到相应的bucket中去。每个Map的输出结果可能包含所有的Reduce所需要的数据,所以每个Map会创建R个bucket(R是reduce的个数),M个Map总共会创建M*R
个bucket。
Map创建的bucket
其实对应磁盘上的一个文件
,Map的结果写到每个bucket中其实就是写到那个磁盘文件中,这个文件也被称为blockFile
,是Disk Block Manager
管理器通过文件名的Hash值
对应到本地目录的子目录中创建的。每个Map
要在节点上创建R
个磁盘文件用于结果输出
,Map的结果是直接输出到磁盘文件上的,100KB的内存缓冲
是用来创建Fast Buffered OutputStream
输出流。这种方式一个问题就是Shuffle文件过多
Mapper
创建出和Reducer
数目相同的bucket
,bucket
实际上是一个buffer
,其大小为spark.shuffle.file.buffer.kb
(默认32KB
)。partition算法
填充到每个bucket
中去,然后再写入到磁盘文件。block manager
中找到相应的文件读取数据。针对上述Shuffle过程产生的文件过多问题,Spark有另外一种改进的Shuffle过程:consolidation Shuffle
,以期显著减少Shuffle文件的数量。在consolidation Shuffle
中每个bucket
并非对应一个文件,而是对应文件中的一个segment
部分。Job的map在某个节点上第一次执行,为每个reduce创建bucket对应的输出文件,把这些文件组织成ShuffleFileGroup
,当这次map执行完之后,这个ShuffleFileGroup可以释放为下次循环利用;当又有map在这个节点上执行时,不需要创建新的bucket文件,而是在上次的ShuffleFileGroup中取得已经创建的文件继续追加写一个segment
;当前次map还没执行完,ShuffleFileGroup还没有释放,这时如果有新的map在这个节点上执行,无法循环利用这个ShuffleFileGroup,而是只能创建新的bucket文件组成新的ShuffleFileGroup来写输出。
比如一个Job有3个Map和2个reduce:
优点
缺点
通过 ① socket连接去取数据 ② 使用netty框架去取数据
① 每个节点的Executor
会创建一个BlockManager
,其中会创建一个BlockManagerWorker
用于响应请求
。当Reduce
的GET_BLOCK
的请求过来时,读取本地文件
将这个blockId
的数据
返回给Reduce
。
② 如果使用的是Netty
框架,BlockManager
会创建ShuffleSender
用于发送Shuffle数据
。
并不是所有的数据都是通过网络读取,对于在本节点
的Map数据
,Reduce直接去磁盘上读取而不再通过网络框架
Spark Map
输出的数据没有经过排序,Spark Shuffle
读取过来的数据也不会进行排序,Spark认为Shuffle过程中的排序不是必须的
,并不是所有类型的Spark Reduce
需要的数据都需要排序,强制地进行排序只会增加Shuffle的负担。Spark Reduce
拉取过来的数据会放在一个HashMap
中,HashMap
中存储的也是
对,key是Map输出的key,Map输出对应这个key的所有value组成HashMap的value
。Spark Reduce
将Shuffle拉取过来的每一个
对插入或者更新
到HashMap
中,来一个处理一个,HashMap全部放在内存中
Shuffle读取过来的数据全部存放在内存中,对于数据量比较小或者已经在Map端做过合并处理的Shuffle数据,占用内存空间不会太大,但是对于比如group by key
这样的操作,Spark Reduce
需要得到key
对应的所有value
,并将这些value组一个数组
放在内存
中,这样当数据量较大时,就需要较多内存
当内存不够
时,要不就失败
,要不就用老办法把内存中的数据溢写到磁盘里
,Spark意识到在处理数据规模远远大于内存空间时所带来的不足,引入了一个具有外部排序
的方案。Shuffle过来的数据先放在内存中,当内存中存储的
对超过1000
并且内存使用超过70%
时,判断节点上可用内存如果还足够
,则把内存缓冲区大小翻倍
,如果可用内存不再够
了,则把内存中的
对排序
然后写到磁盘文件
中。最后把内存缓冲区
中的数据排序
之后和那些磁盘文件组成一个最小堆
,每次从最小堆中读取最小的数据
,这个和MapReduce
中的merge
过程类似
SortShuffle
从1.2.0开始默认为sort shuffle(spark.shuffle.manager = sort)
,实现逻辑类似于Hadoop MapReduce
,Hash Shuffle每一个reducers产生一个文件
,但是Sort Shuffle
只是产生一个按照reducer id排序可索引的文件
,这样,只需获取有关文件中的相关数据块的位置信息
,并fseek
就可以读取指定Spark Reduce
的数据。但对于rueduce数
比较少的情况,Hash Shuffle
明显要比Sort Shuffle快
,因此Sort Shuffle
有个“fallback
”计划,对于reducers数少于 “spark.shuffle.sort.bypassMergeThreshold
” (200 by default
),我们使用fallback
计划,hashing
相关数据到分开的文件
,然后合并这些文件为一个
,具体实现为BypassMergeSortShuffleWriter
在Spark Map
进行排序,在Spark Reduce
端应用Timsort[1]
进行合并。Spark Map
端是否容许spill
,通过spark.shuffle.spill
来设置,默认是true
。设置为false
,如果没有足够的内存来存储map的输出,那么就会导致OOM错误,因此要慎用
用于存储map输出的内存
为:“JVM Heap Size” * spark.shuffle.memoryFraction * spark.shuffle.safetyFraction
,默认为“JVM Heap Size” * 0.2 * 0.8 = “JVM Heap Size” * 0.16
。如果你在同一个执行程序中运行多个线程(设定spark.executor.cores/ spark.task.cpus超过1),每个map任务存储的空间为“JVM Heap Size” * spark.shuffle.memoryFraction * spark.shuffle.safetyFraction / spark.executor.cores * spark.task.cpus
, 默认2个cores
,那么为0.08 * “JVM Heap Size”
。
spark使用AppendOnlyMap
存储map输出的数据
,利用开源hash函数MurmurHash3
和平方探测法
把key
和value
保存在相同的array
中。这种保存方法可以是spark进行combine。如果spill为true,会在spill前sort
与hash shuffle
相比,sort shuffle
中每个Mapper
只产生一个数据文件和一个索引文件
,数据文件中的数据按照Spark Reduce
排序,但属于同一个Reducer的数据不排序
。Mapper产生的数据先放到AppendOnlyMap
这个数据结构中,如果内存不够,数据则会spill到磁盘,最后合并成一个文件
。
与Hash shuffle相比,shuffle文件数量减少,内存使用更加可控。但排序会影响速度
优点
缺点
TungstenShuffle
Tungsten-sort
算不得一个全新的shuffle 方案,它在特定场景下基于类似现有的Sort Based Shuffle
处理流程,对内存/CPU/Cache
使用做了非常大的优化
。带来高效
的同时,也就限定了自己的使用场景。如果Tungsten-sort 发现自己无法处理
,则会自动使用 Sort Based Shuffle进行处理
。Tungsten 中文是钨丝
的意思。 Tungsten Project 是 Databricks 公司提出的对Spark优化内存和CPU使用的计划
,该计划初期似乎对Spark SQL优化的最多。不过部分RDD API 还有Shuffle也因此受益
Tungsten-sort优化点
主要在三个方面
:
直接
在serialized binary data
上sort
而不是java objects
,减少了memory
的开销和GC的overhead
。cache-efficient sorter
,使用一个8bytes
的指针
,把排序转化成了一个指针数组
的排序。spill
的merge
过程也无需反序列化
即可完成这些优化的实现导致引入了一个新的内存管理模型
,类似OS的Page,对应的实际数据结构
为MemoryBlock
,支持off-heap
以及 in-heap
两种模式
为了能够对Record
在这些MemoryBlock
进行定位,引入了Pointer
(指针)的概念
如果你还记得Sort Based Shuffle
里存储数据的对象PartitionedAppendOnlyMap
,这是一个放在JVM heap
里普通对象,在Tungsten-sort
中,他被替换
成了类似操作系统内存页的对象
。如果你无法申请到新的Page
,这个时候就要执行spill操作
,也就是写入到磁盘
的操作。具体触发条件,和Sort Based Shuffle
也是类似的。
Spark 默认开启
的是Sort Based Shuffle
,想要打开Tungsten-sort
,请设置spark.shuffle.manager=tungsten-sort
对应的实现类是:org.apache.spark.shuffle.unsafe.UnsafeShuffleManager
名字的来源是因为使用了大量JDK Sun Unsafe API
当且仅当下面条件都满足时,才会使用新的Shuffle方式:
可以看到,能使用的条件还是挺苛刻的
这些限制来源于哪里?
参看如下代码,page的大小:
this.pageSizeBytes = (int)Math.min(PackedRecordPointer.MAXIMUM_PAGE_SIZE_BYTES, shuffleMemoryManager.pageSizeBytes());
这就保证了页大小不超过PackedRecordPointer.MAXIMUM_PAGE_SIZE_BYTES
的值,该值就被定义成了128M。