Tuning of data skew Spark

What is data skew?

Spark abstract calculated as follows

Spark computing abstract .png

Refers to a data skew: centralized parallel processing, a subset of data (e.g., a Spark Kafka or the Partition) is significantly higher than the other portions, so that the portion of the processing speed of the entire data set of the bottleneck process.

If the data can not be solved tilt, other optimization methods Guards are no good again, as short-board effect, not to see the efficiency of the task is completed the fastest task, but the slowest one.

Data dumping Consequences:
  1. Direct data skew may lead to a situation: Out Of Memory or GC timeout.
  2. The task is not necessarily fail, but extremely slow. (But now I encounter data skew almost failed)

Data schematic oblique

image.png.png

As shown in FIG
individual ShuffleMapTask2 (98 billion pieces of data) over a large amount of data processing. Lead to slow down the execution time of the whole Job.
This may result in the Task OOM machine is located, or run very slowly.

Tilt principle:

When performing the shuffle, the same key must be pulled on the respective nodes to a node on a task to be processed, such as polymerization or the like according to the key for join operations. At this time, if the data corresponding to a key, then particularly, data skew occurs.

For example, most of the key pieces of data corresponding to several hundreds, but thousands of individual key has a corresponding pieces of data, then most likely it will only task assigned to the small amount of data, then 1 seconds to run over;
but the individual task may assigned to the massive amounts of data, to run twelve hours.
Therefore, the progress of the Spark job run is the longest running time that task by the decision.
NOTE: Since all the same within a Stage Task performs the same calculation, the exclusion of different computing nodes premise computing the difference, the difference between the different time-consuming Task Task major amount of data processed by this decision.

Positioning inclined position

The operator may trigger

Operators may trigger the (incomplete)

task memory overflow

这种情况下去定位出问题的代码(触发JOb的Action位)就比较容易了。

可以直接看 yarn-client 模式下本地 log 的异常栈,或者是通过 YARN 查看 yarn-cluster 模式下的 log 中的异常栈。

一般来说,通过异常栈信息就可以定位到你的代码中哪一行(触发JOb的Action位置)发生了内存溢出和溢出的Stage是哪一个。然后在那行代码附近找找,一般也会有 shuffle类算子,此时很可能就是这个算子导致了数据倾斜,但是是经工作中发现,这个定位具体行数还是比较困难,因为日志只会出现触发JOb的Action算子的代码行数,而一个Job可能有多可shuffle阶段,你要很了解任务的划分才有可能找对位置。

要注意的是,出现内存溢出不一定就是倾斜。这只是一种可能而已。

task 执行特别慢的情况

与上面类似,虽然不报错,但是程序就在这里停住了,某部分task一直没有完成。

为了进一步确定是否倾斜,最好的办法是去看web ui,查看当前task 所在Stage的所有task,看看执行特别慢的task 运行时间、所处理的数据量、GC等信息。

如果与其他task差异较大,说明出现了倾斜问题,那我们接下来就该去解决问题了。

key 的数据分布情况

我工作中因为权限、环境等各种问题,无法查看Web UI 所以对于定位GC、OOM的问题特别难受~~~。

所有有时候采用很笨的方法来确定一下是否数据倾斜

上述表格中列举了可能出现倾斜的算子,那么这些我们可以抽样统计一下该算子操作的key对应的数据量。如果key 的分布及不均匀,某种程度上也可以判定是出现了倾斜

df(dataFrame) 部分数据如下
+--------+-----------+------------+------+--------+----+
|  userid|  zubo_nums|  total_nums|  nums|     day|hour|
+--------+-----------+------------+------+--------+----+
| userid1| zubo_nums1| total_nums1| nums1|20190101|  00|
| userid2| zubo_nums2| total_nums2| nums2|20190101|  00|
| userid3| zubo_nums3| total_nums3| nums3|20190101|  00|
| userid4| zubo_nums4| total_nums4| nums4|20190101|  00|
| userid5| zubo_nums5| total_nums5| nums5|20190101|  00|
| userid6| zubo_nums6| total_nums6| nums6|20190101|  00|
| userid7| zubo_nums7| total_nums7| nums7|20190101|  00|
| userid8| zubo_nums8| total_nums8| nums8|20190101|  00|
| userid9| zubo_nums9| total_nums9| nums9|20190101|  00|
|userid10|zubo_nums10|total_nums10|nums10|20190101|  00|
+--------+-----------+------------+------+--------+----+
logger.info("\n df count=" +df.count())
df.sample(false,0.1).rdd.keyBy(row=>row.getAs("userid").toString).countByKey().foreach(println _)
df count=2058

多次抽样对比
(userid88,3)
(userid99,1)
(userid61,2)
(userid50,2)
(userid34,2)
(userid1,33)
(userid39,4)
(userid83,3)
--------------------
(userid61,1)
(userid50,1)
(userid34,1)
(userid1,35)
(userid83,2)
(userid17,1)
(userid69,2)
---------
(userid99,2)
(userid61,1)
(userid50,2)
(userid34,2)
(userid1,25)
(userid39,1)
(userid83,1)
(userid94,2)
(userid17,1)

从上述抽样结果接可以看出,userid1这个key数量明显多余其他key。
多次抽样也可以看出,这样统计一定程度上可以反应倾斜的问题并且可以确定倾斜的key,这样对于我们后续解决倾斜问题有一定的帮助。


解决数据倾斜

从源端数据解决

下面距两个例子说明:

kafka数据源
我们一般通过 DirectStream 方式读取 Kafka数据。

由于 Kafka 的每一个 Partition 对应 Spark 的一个Task(Partition),所以 Kafka 内相关 Topic 的各Partition 之间数据是否平衡,直接决定 Spark处理该数据时是否会产生数据倾斜。

Kafka 某一 Topic 内消息在不同 Partition之间的分布,主要由 Producer 端所使用的Partition 实现类决定。

如果使用随机 Partitioner,则每条消息会随机发送到一个 Partition 中,从而从概率上来讲,各Partition间的数据会达到平衡。此时源 Stage(直接读取 Kafka 数据的 Stage)不会产生数据倾斜。

所以如果业务没有特别需求,我们可以在Producer端的 Partitioner 采用随机的方式,并且可以每个批次数据量适当增加 Partition 的数量,达到增加task目的。

但是很多业务要求将具备同一特征的数据顺序消费,此时就需要将具有相同特征的数据放于同一个 Partition 中。比如某个地市、区域的数据需要放在一个Partition 中,如果此时出现了数据倾斜,就只能采用其他的办法解决了。

hive数据源
如果数据源是来自hive,那么我们可以考虑在hive端就针对该key一次etl处理。

如果评估可行,那我们在Spark就可以在Spark端使用etl后的数据了,也就不用Spark中执行之前倾斜的部分的逻辑了。

优点:实现起来简单便捷,效果不错,完全规避掉了数据倾斜,Spark 作业的性能会大幅度提升。

缺点:治标不治本,我们只是把数据倾斜提前到了hive端,Hive ETL 中还是会发生数据倾斜,所以我们还是避免不了要在hive端处理倾斜问题。

适用情况:
因为本质上没有解决数据倾斜的问题,我们只有解决了Hive端数据倾斜的问题才算真正的解决这个问题。
所以当hive端的数据仅仅被调用一两次的时候,这样做性能提升不大;
但是当频繁的调用相关数据的时候,如果在Spark调用Hive etl后的数据是就不会出现数据倾斜的问题,这样性能的提升就非常可观了


调整并行度

原理:调整并行度,分散同一个 Task 的不同 Key 到更多的Task

注意:调整并行度不一定是增加,也可能是减少,目的是为了,分散同一个 Task 中倾斜 Key 到更多的Task,所以如果减少并行度可以实现,也是可以的

对于Spark Sql配置下列参数spark.sql.shuffle.partitions

对于RDD,可以对shuflle算子设置并行度,如

 rdd.map(p=>(p._1,1)).reduceByKey( (c1, c2)=>(c1+c2),1000)
 
 默认使用HashPartitioner,并行度默认为 spark.default.parallelism
 def reduceByKey(func: (V, V) => V, numPartitions: Int): RDD[(K, V)] = self.withScope {
    reduceByKey(new HashPartitioner(numPartitions), func)
  }

优点:实现起来比较简单,理论上可以有效缓解和减轻数据倾斜的影响。

方案缺点:只是缓解了数据倾斜而已,没有彻底根除问题,对于某个key倾斜的情况毫无办法,因为无论你设置并行度为多少,相同的key总会在同一个partition中

一般如果出现数据倾斜,都可以通过这种方法先试验几次,如果问题未解决,再尝试其它方法。

适用场景少,只能将分配到同一 Task 的不同 Key 分散开,但对于同一 Key 倾斜严重的情况该方法并不适用。
并且该方法一般只能缓解数据倾斜,没有彻底消除问题。

根据我工作遇到倾斜问题的来看,这方法有一定效果但是作用不大,还没试过只调整并行度就直接解决的案例。


自定义分区函数

原理:使用自定义的 Partitioner(默认为 HashPartitioner),将原本被分配到同一个 Task 的不同 Key 分配到不同 Task。

class CustomerPartitioner(numParts: Int) extends Partitioner{
  override def numPartitions: Int = numParts

  override def getPartition(key: Any): Int = {
    //自定义分区
    val id: Int = key.toString.toInt
    //这里自定义分区的方式比较灵活,可以根据key的分布设计不同的计算方式
    if (id <= 10000) //10000 以内的id容易出现倾斜
      return new java.util.Random().nextInt(10000) % numPartitions
    else
      return id % numPartitions
  }
}
rdd.map(p=>(p._1,1)).groupByKey(new CustomerPartitioner(10))

适用场景:大量不同的 Key 被分配到了相同的 Task 造成该 Task 数据量过大。

优点:不影响原有的并行度设计。如果改变并行度,后续 Stage 的并行度也会默认改变,可能会影响后续 Stage。

缺点:适用场景有限,只能将不同 Key 分散开,对于同一 Key 对应数据集非常大的场景不适用。
效果与调整并行度类似,只能缓解数据倾斜而不能完全消除数据倾斜。
而且不够灵活,需要根据数据特点自定义专用的 Partitioner(即需要非常了解key的分分布)。


ReduceJoin转MapJoin(Broadcast )

原理:如果一个 RDD 是比较小的,则可以采用广播小 RDD 全量数据 +map 算子来实现与 join 同样的效果,也就是 map join,此时就不会发生 shuffle 操作,也就不会发生数据倾斜。

示意图

ReduceJoin转MapJoin.png

优点:对 join 操作导致的数据倾斜,效果非常好,因为根本就不会发生 shuffle,也就根本不会发生数据倾斜。

缺点:要求参与 Join的一侧数据集足够小,并且主要适用于 Join 的场景,不适合聚合的场景,适用条件有限。

通过 Spark 的 Broadcast 机制,将 Reduce 侧 Join 转化为 Map 侧 Join,避免 Shuffle 从而完全消除 Shuffle 带来的数据倾斜。

Web UI的DAG图如下

ReduceJoin.png

MapJoin
MapJoin.png

相关参数:

将 Broadcast 的阈值设置得足够大
SET spark.sql.autoBroadcastJoinThreshold=10485760

局部聚合+全局聚合

原理:将原本相同的 key 通过附加随机前缀的方式,变成多个不同的 key,就可以让原本被一个 task 处理的数据分散到多个 task 上去做局部聚合,进而解决单个 task 处理数据量过多的问题。接着去除掉随机前缀,再次进行全局聚合,就可以得到最终的结果。

 rdd1
      .map(s=>(new Random().nextInt(100)+"_"+s._1,s._2))//添加前缀
      .reduceByKey(_+_)//局部聚合
      .map(s=>(s._1.split("_")(1),s._2))//去除前缀
      .reduceByKey(_+_)//全局聚合

+ Partial polymerization Global polymerization .png

适用场景:对 RDD 执行 reduceByKey 等聚合类 shuffle 算子或者在 Spark SQL 中使用 group by 语句进行分组聚合时,比较适用这种方案。

优点:对于聚合类的 shuffle 操作导致的数据倾斜,效果是非常不错的。通常都可以解决掉数据倾斜,或者至少是大幅度缓解数据倾斜,将 Spark 作业的性能提升数倍以上。

缺点:仅仅适用于聚合类的 shuffle 操作,适用范围相对较窄。如果是 join 类的 shuffle 操作,还得用其他的解决方案。


倾斜 key 增加随机前/后缀

实现原理:将倾斜的key 与非倾斜的key 分别与右表join,得到skewedJoinRDD和unskewedJoinRDD最后unoin得到最终结果

skewedJoinRDD部分实现步骤:

  1. 将 rddLeft 中倾斜的 key(即 userid1 与 userid2)对应的数据单独过滤出来,且加上 1 到 n 的随机前缀)形成单独的 left: RDD[(String, Int)]。
  2. 将 rddRight 中倾斜 key 对应的数据抽取出来,并通过 flatMap 操作将该数据集中每条数据均转换为 n 条数据(每条分别加上 1 到 n 的随机前缀),形成单独的 right: RDD[(String, String)]。
  3. 将 left 与 right 进行 Join,并将并行度设置为 n,且在 Join 过程中将随机前缀去掉,得到倾斜数据集的 Join 结果 skewedJoinRDD。

unskewedJoinRDD部分实现步骤:

  1. 将 rddLeft: RDD[(String, Int)] 中不包含倾斜 Key 的数据抽取出来作为单独的 leftUnSkewRDD。
  2. 对 leftUnSkewRDD 与原始的 rddRight: RDD[(String, String)] 进行Join,并行度也设置为 n,得到 Join 结果 unskewedJoinRDD。
  3. 通过 union 算子将 skewedJoinRDD 与 unskewedJoinRDD 进行合并,从而得到完整的 Join 结果集。

实现代码

  def prix(): Unit = {
    val sc = spark.sparkContext
    val rddLeft: RDD[(String, Int)] = srdd.rdd.keyBy(row => row.getAs("userid").toString).map(p => (p._1, 1))
    val rddRight: RDD[(String, String)] = srdd.rdd.keyBy(row => row.getAs("userid").toString).map(p => (p._1, p._2.getAs("nums").toString))
    val skewedKeySet = Set("userid1", "userid2") //倾斜的key

    val addList: Seq[Int] = 1 to 24 //右表前缀

    val skewedKey: Broadcast[Set[String]] = sc.broadcast(skewedKeySet) //广播倾斜key

    val addLisPrix: Broadcast[Seq[Int]] = sc.broadcast(addList) //广播右表前缀

    val left: RDD[(String, Int)] = rddLeft
      .filter(kv => skewedKey.value.contains(kv._1)) //左表筛选倾斜key
      .map(kv => (new Random().nextInt(24) + "," + kv._1, kv._2)) //倾斜key增加前缀

    val leftUnSkewRDD: RDD[(String, Int)] = rddLeft
      .filter(kv => !skewedKey.value.contains(kv._1)) //左表筛选非倾斜key
    val right: RDD[(String, String)] = rddRight
      .filter(kv => skewedKey.value.contains(kv._1)) //右表筛选倾斜key
      .map(kv => (addLisPrix.value.map(str => (str + "," + kv._1, kv._2)))) //右表倾斜key每个增加1 to 24 的前缀
      .flatMap(kv => kv.iterator)


    val skewedJoinRDD: RDD[(String, String)] = left
      .join(right, 100) //关联操作
      .mapPartitions(kv => kv.map(str => (str._1.split(",")(1), str._2._2))) //去除前缀

    val unskewedJoinRDD: RDD[(String, String)] = leftUnSkewRDD
      .join(rddRight, 100) //非倾斜关联操作
      .mapPartitions(kv => kv.map(str => (str._1, str._2._2)))
    
    //合并倾斜与非倾斜key
    skewedJoinRDD.union(unskewedJoinRDD).collect().foreach(println _)
  }

用场景:两张表都比较大,无法使用 Map 侧 Join。其中一个 RDD 有少数几个 Key 的数据量过大,另外一个 RDD 的 Key 分布较为均匀。

Advantages: Map side with respect to the Join, Join can adapt to large data sets.
If resources are sufficient, the non-inclined portion inclined portion data set data set may be performed in parallel, significantly enhance efficiency.
And only make data extensions for data skew part of the limited increase of resources consumption.

Disadvantage: If the inclination Key is very large, the other side of the expansion data is very large, this scheme is not applicable.
Also at this time and the non-inclined oblique Key Key separately, you need to scan data set twice, increasing overhead.


Table with random inclination of n random prefix, small table expanded n times

Principle: The key is inclined comprising rdd into different additional random key by the prefix 1 to n, can then be "different key" after these processes to the task distributed to the plurality of process. Each record prefixed by 1 to n expansion non-inclined rdd, then join

(There is also a variant of this method, the inclination of the key is pulled out of n random prefix added, expanding small table n times, and the non-inclined inclined separately, on a similar example)

The principle

  def prixAndMul(): Unit = {
    val sc = spark.sparkContext
    val rddLeft: RDD[(String, Int)] = srdd.rdd.keyBy(row => row.getAs("userid").toString).map(p => (p._1, 1))
    val rddRight: RDD[(String, String)] = srdd.rdd.keyBy(row => row.getAs("userid").toString).map(p => (p._1, p._2.getAs("nums").toString))
    val skewedKeySet = Set("userid1", "userid2") //倾斜的key

    val addList: Seq[Int] = 1 to 24 //右表前缀

    val addLisPrix: Broadcast[Seq[Int]] = sc.broadcast(addList) //广播右表前缀

    val left: RDD[(String, Int)] = rddLeft
      .map(kv => (new Random().nextInt(24) + "," + kv._1, kv._2)) //倾斜key增加前缀
    
    val right: RDD[(String, String)] = rddRight
      .map(kv => (addLisPrix.value.map(str => (str + "," + kv._1, kv._2)))) //右表倾斜key每个增加1 to 24 的前缀
      .flatMap(kv => kv.iterator)
    
    val resultRDD: RDD[(String, String)] = left
      .join(right, 100) //关联操作
      .mapPartitions(kv => kv.map(str => (str._1.split(",")(1), str._2._2))) //去除前缀
    
    resultRDD.collect().foreach(println _)
  }
  
  

Advantages: The basic data can process the type of join is inclined, and the effect is relatively significant, very good performance.

Cons: The program is more inclined to ease data, rather than completely avoid data skew. And the need for expansion of the entire RDD, memory resource demanding.

The program can at least ensure that the program can run to completion, speed, then look at the actual situation, after all, start and through re-optimization.


Filter a few key inclined lead

For data requirements are not very stringent, the key may be obtained by sampling the inclination, then filtered off directly


About data skew, not a fixed solution, according to the actual situation of the data, using a variety of flexible solutions

Guess you like

Origin www.cnblogs.com/lillcol/p/11246231.html