Spark处理数据倾斜

前言

继上一篇写了一篇Hadoop处理数据倾斜,本篇博客针对Spark应用开发 南国在这里参考网上学习的资料和一些日常开发经验,写一篇有关于Spark处理数据倾斜的文章。

1. 数据倾斜的基本概念

关于这点,其实上一篇博客里面 南国已经做了讲述。这里南国再做个简单的论述,数据倾斜主要就是大数据集群并行进行数据处理的时候,由于数据分布不均,导致大量的数据集中分不到一台或者某几台计算节点上,导致处理速度远低于平均计算速度,从而拖延导致整个计算过程过慢,影响整个计算性能,注意 这句话无论是放在哪个大数据框架爱里面都适用,因为他是一种很常见的现象,就好像是提到哈希函数 哈希算法 散列表,我们会自然而然的想到哈希冲突。

1.1 数据倾斜的危害:

  • 单个或者某几个task拖延整个任务运行时间,导致整体耗时过大
  • 单个task处理数据过多,很容易导致oom
  • Executor Kill lost,Shuffle error

1.2 数据倾斜的产生

数据倾斜容易产生在两个过程:

  • 1.本身数据源读的倾斜,这个主要由于本身文件的分布不均,主要是不能切分的文件isSplitable=false 例如gz
  • 2.在shuffle阶段,key的分布不均,导致大量的数据集中到单个或者某几个task上导致数据整个stage,执行慢,影响整个job作业。它包括,单个rdd中进行groupby 的时候key分布不均;多个rdd进行join过程中key的不均匀

一般情况下,我们主要描述是第二种情况(shuffle阶段 key值分布不均匀)。

但是Spark是基于内存计算的框架,所以我们在数据输入时 也需要第一种情况考虑数据源本身的倾斜。

关于第二种主要情形,在进行shuffle的时候,必须将各个节点上相同的key拉取到某个节点上的一个task来进行处理,比如按照key进行 聚合或join等操作。此时如果某个key对应的数据量特别大的话,就会发生数据倾斜。比如大部分key对应10条数据,但是个别key却对应了100万 条数据,那么大部分task可能就只会分配到10条数据,然后1秒钟就运行完了;但是个别task可能分配到了100万数据,要运行一两个小时。因此,整 个Spark作业的运行进度是由运行时间最长的那个task决定的。

因此出现数据倾斜的时候,Spark作业看起来会运行得非常缓慢,甚至可能因为某个task处理的数据量过大导致内存溢出。

下图就是一个很清晰的例子:hello这个key,在三个节点上对应了总共7条数据,这些数据都会被拉取到同一个task中进行处理;而world 和you这两个key分别才对应1条数据,所以另外两个task只要分别处理1条数据即可。此时第一个task的运行时间可能是另外两个task的7倍, 而整个stage的运行速度也由运行最慢的那个task所决定。
在这里插入图片描述

1.3 数据倾斜快速定位在这里插入图片描述

2. 常见的数据倾斜解决办法

2.1 数据源自身分布不均匀

2.1.1 原理
因为Spark本身是一个内存计算模型,它的数据输入是接触其他文件存储位置(本地磁盘或者HDFS)。

spark读取文件主要通过sparkContext.textFile调用hadoop的TextInputFormat读取文件,然后实现两个方法isSplitable和getSplits,isSplitable判断文件是否切分,getSplits是切分文件生成partition,每个partition对应一个rdd task,blocksize 的计算如下,切分的partition数量=goalSize/splitSize,运行任务的task的数量等于依赖的切分的partition数量

//默认blocksize为256M, minSize 默认1, Math.min(goalSize, blockSize) 计算文件的goalSize,
//如果文件goalSize小于blocksize则取goalSize,否则取blocksize
protected long computeSplitSize(long goalSize, long minSize, long blockSize) {
  return Math.max(minSize, Math.min(goalSize, blockSize));
}

//根据总的goalSize/splitSize 如果小于1.1倍,则停止split
while (((double) bytesRemaining)/splitSize > SPLIT_SLOP) {
  String[][] splitHosts = getSplitHostsAndCachedHosts(blkLocations, length-bytesRemaining, splitSize, clusterMap);
  splits.add(makeSplit(path, length-bytesRemaining, splitSize, splitHosts[0], splitHosts[1]));
  bytesRemaining -= splitSize;
}

2.1.2 案例
在这里插入图片描述
2.1.3 总结:

适用场景:对于数据源单个spark input read数据量过大,或者单个task 相对于其他task spark input read较大的情况,读取数据源明显不均匀
解决方式:尽量使用可切割的文本存储,生成尽量多的task进行并行计算
优点:从数据源避免倾斜,并且从源头增大并行度,避免倾斜
缺点:需要改造数据源,支持可切割

2.2 shuffle过程 数据分布不均匀(本文最重要的部分,核心中的核心)

2.2.1 原理

Shuffle阶段在分布式并行计算引擎中是常见一个过程,也是最重要的过程。在spark中当一个RDD的数据需要被多个子RDD所使用的时候,我们需要进行shuffle将数据打散,把数据均匀的分配给子RDD进行并行计算。这里给大家罗列一些常用的并且可能会触发shuffle操作的算子:distinct、 groupByKey、reduceByKey、aggregateByKey、join、cogroup、repartition等。其实细看一下,你会发现这些算子大多用于发生数据聚合的过程,也可以说是RDD血缘依赖关系发现变化的过程,关于这一点南国在之前的博客中有写到。

Shuffle过程中spark默认使用HashPartitioner对数据进行分区,在这个过程中可能由于我们的数据分布不均,我们在进行hash取模的时候,并行度设置不足,导致多数据分配到一个task上,导致倾斜,或者就是相同key的数据hash取摸之后就是比较大,分配同一个task导致数据倾斜等,对于这行情况我们分以下场景进行解决

2.2.2 案例1:shuffle中部分数据分布不均

spark shuffle默认使用HashPartitioner对数据进行分片,可能造成不同的key分配到一个task上,导致数据倾斜

1.spark 生成倾斜数据并提交任务,生成100w的数据,然后设置默认spark.default.parallelism并行的task为100,倾斜的分区为7,对大于100的数据,随按照new Random()).nextInt(defPar) * (skewPart)生成key,使key hash取摸的时候,都分配分区为7的task上,导致数据倾斜

val numbers = 1000000
val defPar = 100
val skewPart = 7
val spark = SparkSession.builder()
  .appName("spark_skew_test").master("local[2]")
  .config("spark.default.parallelism",defPar)
  .getOrCreate();
val data = for(num <- 1 to numbers) yield(if (num < defPar) num else numbers+ (new Random()).nextInt(defPar) * (skewPart),1)
spark.createDataFrame(data).rdd.map(row=>(row.get(0),row.get(1))).groupByKey(skewPart).count()
spark.close()

2.提交spark job 运行结果,我们进行groupByKey的时候,按照key分组,统计需要将key拉到一个reduce中进行计算,需要进行shuffle,stage 0 我们可以理解为map阶段,stage 1为reduce阶段,Stage 1从stage 0 把Shuffle Write的数据,拉到本地进行迭代汇总计算,图中我们看到Shuffle Write 和Shuffle Read的数据量一致
在这里插入图片描述
3. Stage 0 map阶段启动100个task并行将读入数据,然后按照reduce partition的数量(7),spark.shuffle.sort.bypassMergeThreshold默认为200,如果reduce数量<=spark.shuffle.sort.bypassMergeThreshold 并 且没有在mapSideCombine聚合,使用BypassMergeSortShuffleWriter生成shuffle 文件,map阶段默认使用HashPartitioner的生成reduce task 7个中间临时文件FileSegment,最后将7个临时文件通过NIO的transferTo合并,最后每个mapper task生成一个data文件和一个index索引文件,之后由Stage1 reduce task负责拉取
在这里插入图片描述
Stage 1 reduce阶段Shuffle Read到Stage 0通过fetchdata 拉取,由于Stage 0是通过HashPartitioner生成分区数据,就导致单个分区数据倾斜,图中红色框中,明显比其他task partition数据多7w倍,导致数据倾斜严重
在这里插入图片描述
4.解决方法

可以通过调整reduce task的并行度,将倾斜的数据分配的更均匀减少倾斜,我们在groupByKey的时候增大100个task

val numbers = 1000000
val defPar = 100
val skewPart = 7
val spark = SparkSession.builder()
  .appName("spark_skew_test").master("local[2]")
  .config("spark.default.parallelism",defPar)
  .getOrCreate();
val data = for(num <- 1 to numbers) yield(if (num < defPar) num else numbers+ (new Random()).nextInt(defPar) * (skewPart),1)
spark.createDataFrame(data).rdd.map(row=>(row.get(0),row.get(1))).groupByKey(skewPart+100).count()
spark.close()

增大reduce task的数量,数据通过hash取摸分配的更加均匀,可以有效减少数据倾斜,shuffle reader 的数据都比较均匀,无明显倾斜
在这里插入图片描述
自定义分区

val numbers = 1000000
val defPar = 100
val skewPart = 7
val spark = SparkSession.builder()
  .appName("spark_skew_test").master("local[2]")
  .config("spark.default.parallelism",defPar)
  .getOrCreate();

//自定义分区
val customPart = new Partitioner(){
  val partitions = 8
  override def numPartitions: Int  =  {
    return partitions
  }
  override def getPartition(key: Any): Int = {
    var partKey:Int = key.asInstanceOf[Int]
    partKey % partitions
  }
}

val data = for(num <- 1 to numbers) yield(if (num < defPar) num else numbers+ (new Random()).nextInt(defPar) * (skewPart),1)
spark.createDataFrame(data).rdd.map(row=>(row.get(0),row.get(1))).groupByKey(customPart).count()
spark.close()

在这里插入图片描述
通过repartition强制进行shuffle,增大并行度,将数据分布的更加均匀

val numbers = 1000000
val defPar = 100
val skewPart = 7
val spark = SparkSession.builder()
  .appName("spark_skew_test").master("local[2]")
  .config("spark.default.parallelism",defPar)
  .getOrCreate();
  
val data = for(num <- 1 to numbers) yield(if (num < defPar) num else numbers+ (new Random()).nextInt(defPar) * (skewPart),1)
spark.createDataFrame(data).rdd.map(row=>(row.get(0),row.get(1))).repartition(100).groupByKey().count()
spark.close()

我们强制进行shuffle,变成3个stage,repartition 默认按照hash增大分区
在这里插入图片描述
总结:
适用场景:大量的数据分配到相同的task中,导致倾斜
解决方案:通过repartition,spark.default.parallelism和自定义分区,如果是sql的话,调整spark.sql.shuffle.partitions增大并行数量,从而将倾斜数据分配到更多的task减少倾斜
优点:对于部分key倾斜,可以通过增大并行数,或者自定义分区,将数据分布的更加均匀,减少数据倾斜
缺点: 对于单个key倾斜,只能根据业务自定分区,减少数据倾斜

2.2.3 案例2:大小表join发生shuffle导致数据倾斜

大表跟小表进行join的时候,一般需要进行shuffle将所有key打散,发送到reduce进行计算,在这个过程中,非常有可能小表中的key在大表中占比较大,需要fetch read导致造成大量的网络和磁盘IO,导致效率底下,甚至OOM,导致任务失败,因此我们可以避免shuffle,在map端进行进行join,把小表的数据通过broadcast的方式发送到executor,之后直接在map 进行join计算,提高效率

spark.sql.autoBroadcastJoinThreshold是控制broadcast的阈值,默认10M,当小于10M自动broadcast join,可以根据实际join情况,调大这个值,测试我们的数据量不大,我们先调小这个,这个值使用shuffle exchange,merge join进行聚合

val numbers = 1000000
val defPar = 100
val skewPart = 7
val data1 = for(num <- 1 to numbers) yield(if (num < defPar) num else numbers+ (new Random()).nextInt(defPar) * (skewPart),1)
val data2 = for(num <- 1 to numbers) yield(if (num < defPar) num else numbers+ (new Random()).nextInt(defPar) * (skewPart),1)
val spark = SparkSession.builder()
  .appName("spark_skew_test").master("local[2]")
  .config("spark.default.parallelism",defPar)
  .config("spark.sql.autoBroadcastJoinThreshold","1")
  .config("spark.sql.shuffle.partitions",skewPart)
  .getOrCreate();
val dfSml = spark.createDataFrame(dataSml).toDF("id","value")
val dfBig = spark.createDataFrame(dataBig).toDF("id","value")
val df = data1.join(data2,data1.col("id")===data2.col("id"),"left")
df.count()
spark.stop()

物理执行计划

== Physical Plan ==
SortMergeJoin [id#5], [id#15], LeftOuter
:- *Sort [id#5 ASC NULLS FIRST], false, 0
:  +- Exchange hashpartitioning(id#5, 7)
:     +- LocalTableScan [id#5, value#6]
+- *Sort [id#15 ASC NULLS FIRST], false, 0
   +- Exchange hashpartitioning(id#15, 7)
      +- LocalTableScan [id#15, value#16]

在这里插入图片描述
任务使用SortMergeJoin,在reduce阶段每个reducer将两张表属于对应partition的数据拉取到同一个任务中做join,总运行时长53s
在这里插入图片描述
我们的数据task 2 的数据明显较其他数据大,因此task 2运行时间最大,整体影响任务执行时长,我们的测试数据量只有606w,如果数据放大,则倾斜更加明显
在这里插入图片描述
spark.sql.autoBroadcastJoinThreshold 我们调整这个阈值,在将数据使用broadcast的方式广播到executor中,不进行shuffle 就不会有数据倾斜

val numbers = 6000000
val sml = 60000
val defPar = 100
val skewPart = 7
val dataBig = for(num <- 1 to numbers) yield(if (num < defPar) num else numbers+ (new Random()).nextInt(defPar) * (skewPart),1)
val dataSml = for(num <- 1 to sml) yield(if (num < defPar) num else numbers+ (new Random()).nextInt(defPar) * (skewPart),1)

val spark = SparkSession.builder()
  .appName("spark_skew_test")
  .config("spark.default.parallelism",defPar)
  .config("spark.sql.autoBroadcastJoinThreshold",s"${100L * 1024 * 1024}")
  .config("spark.sql.shuffle.partitions",skewPart)
  .getOrCreate();
val dfSml = spark.createDataFrame(dataSml).toDF("id","value")
val dfBig = spark.createDataFrame(dataBig).toDF("id","value")
val df = dfSml.join(dfBig,dfSml.col("id")===dfBig.col("id"),"left")
spark.stop()

物理执行计划

== Physical Plan ==
*BroadcastHashJoin [id#5], [id#15], LeftOuter, BuildRight
:- LocalTableScan [id#5, value#6]
+- BroadcastExchange HashedRelationBroadcastMode(List(cast(input[0, int, false] as bigint)))
   +- LocalTableScan [id#15, value#16]

在这里插入图片描述
任务使用BroadcastHashJoin,不进行shuffle,以小表为buildsite 放到map中,大表为probe side 进行轮询getkey join,直接在map端进行,时间只需23s
在这里插入图片描述
spark.sql.autoBroadcastJoinThreshold 我们调整这个阈值,在将数据使用broadcast的方式广播到executor中,不进行shuffle 就不会有数据倾斜

总结:
适用场景:两个数据集差别较大,并且出现task数据倾斜,较小的数据集可以放到内存中map中进行join
解决方案:通过增大spark.sql.autoBroadcastJoinThreshold 阈值默认10M
优点:减少大的数据集shuffle,从而导致数据倾斜
缺点: join小表的数据需要足够小,能放到executor storage memory中

2.2.4 案例3:通过sample采样,对倾斜key单独进行处理

我们shuffle的过程中,由于单个或者某几个key倾斜,导致在shuffle的过程中,数据分布不均匀,这种情况增大并行对数据倾斜作用不太,即使我们的task数量1000个,仍然倾斜,这时候需要我们对倾斜的key进行单独处理
在这里插入图片描述
原理:
通过sample采样对key进行聚合groupby,然后算出key记录数多的key,将rdd数据按照倾斜的key进行filter过滤,分开计算
对于倾斜的数据我们通过添加随机前缀进行join得到dataset1
对于非倾斜的数据我们直接进行join得到dataset2
最后将两部分的数据使用union进行合并,得到最终结果

实现代码

val numbers = 10000
val sml = 100
val defPar = 100
val skewPart = 7
val dataBig = for (num <- 1 to numbers) yield (if (num < defPar) num else numbers + (new Random()).nextInt(skewPart) * (skewPart), num)
val dataSml = for (num <- 1 to sml) yield (if (num < defPar) num else numbers + (new Random()).nextInt(skewPart) * (skewPart), num)
val spark = SparkSession.builder()
  .appName("spark_skew_test")
  .master("local[2]")
  .getOrCreate(); 

val smlDf = spark.createDataFrame(dataSml).toDF("id", "value")
smlDf.createOrReplaceTempView("tbl_sml")
val dfBig = spark.createDataFrame(dataBig).toDF("id", "value")
dfBig.createOrReplaceTempView("tbl_big")

//get skew keys
import spark.sqlContext.implicits._
val skewKeys = dfBig.sample(false, 0.2).groupBy(dfBig.col("id")).count().orderBy($"count".desc).filter($"count" > 200).collect().map(_.get(0))

//split rdd
val noKewSmlDf = smlDf.filter(row => !skewKeys.contains(row.get(0)))
val skewSmlDf = smlDf.filter(row => skewKeys.contains(row.get(0)))
val randomSkewSmlDf = skewSmlDf.flatMap{ case Row(key: Int, value: Int) => {
  for(i<- 1 to 100)yield{
    val prefix = Random.nextInt(100)
    (prefix + "_" + key, value)
  }
}
}.toDF("id","value")

//split rdd
val noSkewBigDf = dfBig.filter(row=> !skewKeys.contains(row(0)))
val skewBigDf = dfBig.filter(row=>skewKeys.contains(row(0)))

val randomSkewBigDf = skewBigDf.map{case Row(key:Int,value:Int)=>
  val prefix = Random.nextInt(100)+1
  (s"${prefix}_${key}",value)
}.toDF("id","value")
val skewDf = randomSkewSmlDf.alias("a").join(randomSkewBigDf.alias("b"),"id").selectExpr("split(a.id,'_')[1] as id","b.value as val1","a.value val2").groupBy("id").agg(sum("val1").alias("total"))
val noSkewDf = noKewSmlDf.alias("a").join(noSkewBigDf.alias("b"),"id").groupBy("id").agg(sum("b.value").alias("total"))

//union
noSkewDf.union(skewDf).show(20)
spark.stop()

将两个rdd最后进行union,进行统计这样在数据倾斜特别严重的时候可以有效避shuffle倾斜

在这里插入图片描述
运行之后同样的1000个task我们每个task处理的数据更加均匀
在这里插入图片描述
总结:
适用场景:当极个别的task数据倾斜,并且量非常大,并且倾斜的数据无法在map端进行合并的时候,大量的数据需要shuffle,导致倾斜
解决方案:通过sample采样,得到倾斜的key,然后进行特殊处理,将倾斜的key通过加盐的方式,增大并行处理,之后将结果再合并,进而减少单个task的压力
优点:针对倾斜的key,我们可以我们可以控制Random大小,从而控制task并行度,充分发挥并行计算的优势,提高效率
缺点:需要sample采样,找出倾斜的key,然后通过代码分开处理,会造成一定的并且数据膨胀

总结

数据倾斜无法避免,也有没有一劳永逸的解决方式,处理数据倾斜是一个长期的过程需要我们慢慢积累经验,基本思想就是
1.首先从源头选择可以split的数据源,从源头避免倾斜
2.shufle过程中,增加并行度,减少shuffle 在map-side进行数据合并,避免reduce fetch数据倾斜
3.sample采样将倾斜的数据,特殊处理,这个方法可以适用于所有的数据倾斜问题, 另外,就是我们尽量使用spark-sql,spark-sql里面优化器提供很多基本CRO和CBO的优化策略,不仅帮我们从源头帮我们去除无关的数据减少计算数据量,其次在计算过程中会根据我们的table 的数据量,自动帮我们计算合适task partition数量,和选择合适join策略,从而提升计算性能,也避免shufle 数据倾斜

在实践中发现,如果要处理一个较为复杂的数据倾 斜场景,那么可能需要将多种方案组合起来使用。比如说,我们针对出现了多个数据倾斜环节的Spark作业,可以先过滤少数导致倾斜的key 预处理一部分数据;其次可以对某些shuffle操作提升并行度,优化其性能;最后还可以针对不同的聚合[两阶段聚合(局部聚合+全局聚合)]或join操作[将reduce join转为map join],选择一种方案来优化其性 能。大家需要对这些方案的思路和原理都透彻理解之后,在实践中根据各种不同的情况,灵活运用多种方案,来解决自己的数据倾斜问题。
参考资料:
Spark如何处理数据倾斜
Spark性能优化指南——高级篇

猜你喜欢

转载自blog.csdn.net/weixin_38073885/article/details/88602921
今日推荐