Spark 的shuffle分为老版本的HashShuffle(现在已经弃用)和新版本的SortShuffle。Shuffle过程发生在宽依赖切分Stage的过程中,前一个Stage称作ShuffleMap Stage,后一个Stage称作Result Stage。
1. Map Task将数据写入buffer缓冲区,待缓冲区达到阈值时开始溢写文件,文件数量取决于(等于)Reduce Task的数量。Reduce Task的数量取决于(等于)上一个Stage的最后一个RDD的分区数。
2. Reduce Task一边拉取对应分区的数据到buffer缓存中,一边进行处理,使用map方法对数据进行归并,直至所有的数据归并结束。
3. 未经优化的Hash Shuffle每个map task都会为下游的每个reduce task创建一个磁盘文件。如果有50个map task,100个renduce task,那么就会产生5000个小文件,产生过多的小文件,极大的影响了shuffle write的性能。
1. 设置spark.shuffle.consolidateFiles为true,开启优化机制(默认开启)
2. 开始优化机制后,就不再是每个map task为下游的每个reduce task生成一个磁盘文件,而是一个executor为一个reduce task生成一个磁盘文件。
3. consolidateFiles机制允许多个map task复用同一个磁盘文件,可以在一定程度上对map task的数据进行合并,从而大幅减小磁盘文件的数量,提升shuffle write性能。
在普通模式下,数据会先写入一个内存数据结构中,此时根据不同的shuffle算子,可以选用不同的数据结构。如果是由聚合操作的shuffle算子,就是用map的数据结构(边聚合边写入内存),如果是join的算子,就使用array的数据结构(直接写入内存)。接着,每写一条数据进入内存数据结构之后,就会判断是否达到了某个临界值,如果达到了临界值的话,就会尝试的将内存数据结构中的数据溢写到磁盘,然后清空内存数据结构。
在溢写到磁盘文件之前,会先根据key对内存数据结构中已有的数据进行排序,排序之后,会分批将数据写入磁盘文件。默认的batch数量是10000条,也就是说,排序好的数据,会以每批次1万条数据的形式分批写入磁盘文件,写入磁盘文件是通过Java的BufferedOutputStream实现的。BufferedOutputStream是Java的缓冲输出流,首先会将数据缓冲在内存中,当内存缓冲满溢之后再一次写入磁盘文件中,这样可以减少磁盘IO次数,提升性能。
此时task将所有数据写入内存数据结构的过程中,会发生多次磁盘溢写,会产生多个临时文件,最后会将之前所有的临时文件都进行合并,最后会合并成为一个大文件。最终只剩下两个文件,一个是合并之后的数据文件,一个是索引文件(标识了下游各个task的数据在文件中的start offset与end offset)。最终再由下游的task根据索引文件读取相应的数据文件。
1. 普通运行机制下,map task会先将数据写入到内存缓冲区中,当内存缓冲区中的数据达到一定阈值时,开始进行溢写。
2. 溢写到磁盘之前,先按照数据的key进行排序,排序后分批次写入磁盘, 默认每批次1w条数据。
3. 数据溢写到磁盘的过程中会产生多个临时磁盘文件,临时磁盘文件会进行merge归并,最终一个map task只会生成一个归并后的磁盘文件,同时还是生成一个对应的索引文件,记录数据的offset信息。
4.reduce task拉取对应分区的数据进行处理。
bypass 运行机制
1.bypass运行机制触发需要满足两个条件:①不是聚合类型的shuffle算子;②map task的数量小于bypassMergeThreshold的值,默认是200。
2.bypass运行机制,map task数据溢写到磁盘之前不会对数据进行排序。
3.bypass运行机制,数据的溢写规则和未经优化的hashshuffle一样,每个map task都会为下游的每个reduce task生成一个文件,但是最后会进行数据的归并,合并为一个磁盘文件。
4.bypass机制的好处就是不对数据进行排序,节省了这一部分的资源开销。
1.1. Map 端聚合
map-side 预聚合,就是在每个节点本地对相同的 key 进行一次聚合操作,类似于 MapReduce 中的本地 combiner。map-side 预聚合之后,每个节点本地就只会有一条相同的 key,因为多条相同的 key 都被聚合起来了。其他节点在拉取所有节点上的相同 key 时,就 会大大减少需要拉取的数据数量,从而也就减少了磁盘 IO 以及网络传输开销。
RDD 的话建议使用 reduceByKey 或者 aggregateByKey 算子来替代掉 groupByKey 算子。 因为 reduceByKey 和 aggregateByKey 算子都会使用用户自定义的函数对每个节点本地的相 同 key 进行预聚合。而 groupByKey 算子是不会进行预聚合的,全量的数据会在集群的各个 节点之间分发和传输,性能相对来说比较差。
SparkSQL 本身的 HashAggregte 就会实现本地预聚合+全局聚合。
1.2. 读取小文件优化
读取的数据源有很多小文件,会造成查询性能的损耗,大量的数据分片信息以及对应 产生的 Task 元信息也会给 Spark Driver 的内存造成压力,带来单点问题。设置参数:
spark.sql.files.maxPartitionBytes=128MB 默认 128m spark.files.openCostInBytes=4194304 默认 4m
maxPartitionBytes:一个分区最大字节数
openCostInBytes:打开一个文件的开销
1.3. 增大 map 溢写时输出流 buffer
a. map端Shuffle Write有一个缓冲区,初始阈值5m,超过会尝试增加到2*当前使用 内存。如果申请不到内存,则进行溢写。这个参数是 internal,指定无效(见下方源码)。 也就是说资源足够会自动扩容,所以不需要我们去设置。
b. 溢写时使用输出流缓冲区默认 32k,这些缓冲区减少了磁盘搜索和系统调用次数, 适当提高可以提升溢写效率。
c. Shuffle 文件涉及到序列化,是采取批的方式读写,默认按照每批次 1 万条去读写。 设置得太低会导致在序列化时过度复制,因为一些序列化器通过增长和复制的方式来翻倍 内部数据结构。这个参数是 internal,指定无效(见下方源码)。
综合以上分析,我们可以调整的就是输出缓冲区的大小。
2.1.增大 reduce 缓冲区,减少拉取次数
Spark Shuffle 过程中,reduce task 拉取属于自己的数据时,如果因为网络异常等原因导 致失败会自动进行重试。对于那些包含了特别耗时的 shuffle 操作的作业,建议增加重试最 大次数(比如60次),以避免由于JVM的full gc或者网络不稳定等因素导致的数据拉取失 败。在实践中发现,对于针对超大数据量(数十亿~上百亿)的 shuffle 过程,调节该参数可以大幅度提升稳定性。
reduce 端拉取数据重试次数可以通过 spark.shuffle.io.maxRetries 参数进行设置,该参数就代表了可以重试的最大次数。如果在指定次数之内拉取还是没有成功,就可能会导致 作业执行失败,默认为 3.
2.2. 调节 reduce 端拉取数据等待间隔
Spark Shuffle 过程中,reduce task 拉取属于自己的数据时,如果因为网络异常等原因导 致失败会自动进行重试,在一次失败后,会等待一定的时间间隔再进行重试,可以通过加 大间隔时长(比如 60s),以增加 shuffle 操作的稳定性。reduce 端拉取数据等待间隔可以通过 spark.shuffle.io.retryWait 参数进行设置,默认值 为 5s。
2.3. 输出产生小文件优化
2.4. 动态分区插入数据
有 Shuffle 的情况下,上面的 Task 数量 就变成了 spark.sql.shuffle.partitions(默认值 200)。那么最差情况就会有 spark.sql.shuffle.partitions * 表分区数。
当 spark.sql.shuffle.partitions 设置过大时,小文件问题就产生了;当设置过小时,任务的并行度就下降了,性能随之受到影响。
最理想的情况是根据分区字段进行shuffle,在上面的sql中加上distribute by aa。把同 一分区的记录都哈希到同一个分区中去,由一个 Spark 的 Task 进行写入,这样的话只会产 生 N 个文件, 但是这种情况下也容易出现数据倾斜的问题。
Spark 在 3.0 版本推出了 AQE(Adaptive Query Execution),即自适应查询执行。AQE 是Spark SQL 的一种动态优化机制,在运行时,每当 Shuffle Map 阶段执行完毕,AQE 都会结合这个阶段的统计信息,基于既定的规则动态地调整、修正尚未执行的逻辑计划和物理计 划,来完成对原始查询语句的运行时优化。
3.1. 动态合并分区
在 Spark 中运行查询处理非常大的数据时,shuffle 通常会对查询性能产生非常重要的 影响。shuffle 是非常昂贵的操作,因为它需要进行网络传输移动数据,以便下游进行计算。
最好的分区取决于数据,但是每个查询的阶段之间的数据大小可能相差很大,这使得 该数字难以调整:
a. 如果分区太少,则每个分区的数据量可能会很大,处理这些数据量非常大的分区, 可能需要将数据溢写到磁盘(例如,排序和聚合),降低了查询。
b. 如果分区太多,则每个分区的数据量大小可能很小,读取大量小的网络数据块, 这也会导致 I/O 效率低而降低了查询速度。拥有大量的 task(一个分区一个 task)也会给 Spark 任务计划程序带来更多负担。
为了解决这个问题,我们可以在任务开始时先设置较多的 shuffle 分区个数,然后在运 行时通过查看 shuffle 文件统计信息将相邻的小分区合并成更大的分区。
例如,假设正在运行select max(i) from tbl group by j。输入tbl很小,在分组前只有2 个分区。那么任务刚初始化时,我们将分区数设置为 5,如果没有 AQE,Spark 将启动五个 任务来进行最终聚合,但是其中会有三个非常小的分区,为每个分区启动单独的任务这样 就很浪费。
取而代之的是,AQE 将这三个小分区合并为一个,因此最终聚只需三个 task 而不是五 个
3.2. 动态切换 Join 策略
Spark 支持多种 join 策略,其中如果 join 的一张表可以很好的插入内存,那么 broadcast shah join 通常性能最高。因此,spark join 中,如果小表小于广播大小阀值(默认 10mb),Spark 将计划进行 broadcast hash join。但是,很多事情都会使这种大小估计出错 (例如,存在选择性很高的过滤器),或者 join 关系是一系列的运算符而不是简单的扫描表 操作。
为了解决此问题,AQE 现在根据最准确的 join 大小运行时重新计划 join 策略。从下图 实例中可以看出,发现连接的右侧表比左侧表小的多,并且足够小可以进行广播,那么 AQE 会重新优化,将 sort merge join 转换成为 broadcast hash join。
对于运行是的broadcast hash join,可以将shuffle优化成本地shuffle,优化掉stage减少 网络传输。Broadcast hash join 可以规避 shuffle 阶段,相当于本地 join。
3.3. 动态优化 Join 倾斜
当数据在群集中的分区之间分布不均匀时,就会发生数据倾斜。严重的倾斜会大大降 低查询性能,尤其对于join。AQE skew join优化会从随机shuffle文件统计信息自动检测到 这种倾斜。然后它将倾斜分区拆分成较小的子分区。
例如,下图 A join B,A 表中分区 A0 明细大于其他分区:
因此,skew join 会将 A0 分区拆分成两个子分区,并且对应连接 B0 分区
没有这种优化,会导致其中一个分区特别耗时拖慢整个 stage,有了这个优化之后每个 task 耗时都会大致相同,从而总体上获得更好的性能。
Spark3.0 增加了以下参数。
a. spark.sql.adaptive.skewJoin.enabled :是否开启倾斜 join 检测,如果开启了,那么会 将倾斜的分区数据拆成多个分区,默认是开启的,但是得打开 aqe。
b. spark.sql.adaptive.skewJoin.skewedPartitionFactor :默认值5,此参数用来判断分区数据量是否数据倾斜,当任务中最大数据量分区对应的数据量大于的分区中位数乘以此参数, 并且也大于 spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes 参数,那么此任务 是数据倾斜。
c. spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes :默认值256mb,用于判 断是否数据倾斜。
d. spark.sql.adaptive.advisoryPartitionSizeInBytes :此参数用来告诉 spark 进行拆分后推 荐分区大小是多少。
如果同时开启了 spark.sql.adaptive.coalescePartitions.enabled 动态合并分区功能,那么 会先合并分区,再去判断倾斜,将动态合并分区打开后,重新执行:
3.4. Spark3.0 DPP
Spark3.0 支持动态分区裁剪 Dynamic Partition Pruning,简称 DPP,核心思路就是先将 join 一侧作为子查询计算出来,再将其所有分区用到 join 另一侧作为表过滤条件,从而实 现对分区的动态修剪。如下图所示
触发条件:
a. 待裁剪的表 join 的时候,join 条件里必须有分区字段
b. 如果是需要修剪左表,那么join必须是inner join ,left semi join或right join,反之
亦然。但如果是 left out join,无论右边有没有这个分区,左边的值都存在,就不需要被裁剪.
c. 另一张表需要存在至少一个过滤条件,比如 a join b on a.key=b.key and a.id<2 参数 spark.sql.optimizer.dynamicPartitionPruning.enabled 默认开启。