如果绝大多数 task 执行都非常快,但是个别 task 执行极慢。比如:总共有 100 个 task,99 个task 都在 1分钟内执行完成,只剩下一个 task 却要更多的时间。这样就可以确认发生了数据倾斜,另外,数据倾斜严重的话,就会发生 OOM 错误,导致 Application 失败。
使用引起 shuffle 的算子,在进行 shuffle 时,必须将各个节点上相同的 key 拉取到某个节点上的一个 task 来进行处理,比如按照 key 进行聚合或 join 等操作。此时如果某个 key 对应的数据量特别大的话,就会发生数据倾斜。比如大部分 key 对应 10 条数据,但是个别 key 却对应了 100 万条数据,那么大部分 task 可能就只会分配到 10 条数据,然后在很短时间内就执行完成了,而个别的 task 可能分配到 100 万条数据,则可能需要运行很久。因此,整个 Spark 作业的运行进度是由运行时间最长的那个 task 决定的。
shuffle 导致了数据倾斜,常见导致 shuffle 的算子:distinct,groupByKey,reduceByKey,aggregateByKey,join,cogroup,reparation 等。因此可以在代码中直接找到相关的算子。
这些算子会产生 shuffle,shuffle 会划分 stage。所以,从 WebUI 中查看发生数据倾斜的 task 发生在哪个 stage中。无论是 spark standalone 模式还是 spark on yarn 模式的应用程序,都可以在 spark history server 中看到详细的执行信息。也可以通过 yarn logs 命令查看详细的日子信息。
定位了数据倾斜发生后,接着需要分析一个那个执行 shuffle 操作并且导致了数据倾斜的 RDD/Hive 表,查看一下其中 key 的分布情况。 这主要是为了之后选择哪种技术方案提供依据。查看 key 分布的方式:
场景:如果发现导致倾斜的 key 就少数几个,而且对计算本身没有太大的影响,那么就适合采用此方法来处理。就比如前文所举例:只有一个 key 对应有 100w 条数据,但是其他数据比之少之又少,从而因为该 key 而导致数据倾斜。
思路:countByKey 确定数据量超多的某个 Key,使用 filter 方法过滤。SparkSQL中使用 where 方法过滤。
此方法实现简单,而且效果也比较好,可以完全避免数据倾斜。但是适用场景不多。在大多数实际情况下,导致倾斜的 key 还是很多,并不是只有少数几个。
场景:无法使用过滤的方法来规避倾斜问题,只有面对数据倾斜的问题。
思路:执行 RDD shuffle 算子时,给 shuffle 算子传入一个参数,比如 reduceByKey(100),该参数设置了这个 shuffle 算子执行时 shuffle read task 的数量。对于 spark Sql 中的 shuffle 类语句,比如 group by,join等,需要设置一个参数,即 spark.sql.shuffle.partititon,该参数代表了 shuffle.read.task 的并行度,该值默认是 200。
此方法虽然实现简单,但是该方法治标不治本。例如某个 key 对应的数据量有 100w,那么无论 task 数量增加多少,这个对应着 100w 数据的 key 肯定还会被分配到一个 task 中来处理,因此还是会发生数据倾斜。
场景:对 RDD 执行 reduceByKey 等聚合类 shuffle 算子或者在 spark sql 中使用 group by 语句进行分配聚合时,比较使用这种方法。
思路:这个方案核心思路就是进行两段聚合,第一阶段是局部聚合,先给每个 key 都打上一个随机数,比如 10以内的随机数,此时原先一样的 key 就变成不一样了。比如 (hello, 1) (hello, 1) (hello, 1) (hello, 1),就会变成 (1_hello, 1) (1_hello, 1) (2_hello, 1) (2_hello, 1)。
第二阶段,接着对打上随机数后的数据,执行 reduceByKey 等聚合操作,进行局部聚合,那么局部聚合的结果,就变成了 (1_hello, 2) (2_hello, 2)。然后将各个 key 的随机数去掉,就会变成 (hello,2)(hello,2),再次进行全局聚合操作,就可以得到最终结果了。比如 (hello, 4)。
如果聚合类的 shuffle 算子导致的数据倾斜,能有效的处理倾斜,但是 join 类的 shuffle 算子就不适合了。
// 第1步,加随机前缀。
JavaPairRDD randomPrefixKeyRdd = pairRdd.mapToPair(
new PairFunction, String, Long>() {
private static final long serialVersionUID = 1L;
@Override
public Tuple2 call(Tuple2 tuple)
throws Exception {
Random random = new Random();
int prefix = random.nextInt(100);
return new Tuple2(prefix + "_" + tuple._1, tuple._2);
}
});
// 第2步,局部聚合。
JavaPairRDD firstAggRdd = randomPrefixKeyRdd.reduceByKey(
new Function2() {
private static final long serialVersionUID = 1L;
@Override
public Long call(Long v1, Long v2) throws Exception {
return v1 + v2;
}
});
// 第3步,去除key的随机前缀。
JavaPairRDD removedrandomPrefixKeyRdd = firstAggRdd.mapToPair(
new PairFunction, Long, Long>() {
private static final long serialVersionUID = 1L;
@Override
public Tuple2 call(Tuple2 tuple)
throws Exception {
long originalKey = Long.valueOf(tuple._1.split("_")[1]);
return new Tuple2(originalKey, tuple._2);
}
});
// 第4步全局聚合。
JavaPairRDD secondAggRdd = removedrandomPrefixKeyRdd.reduceByKey(
new Function2() {
private static final long serialVersionUID = 1L;
@Override
public Long call(Long v1, Long v2) throws Exception {
return v1 + v2;
}
});
场景:对 RDD 使用 join 类操作,或者是在 spark sql 中使用 join 语句时,而且 join 操作中的一个 RDD 或表的数据量比较小,比较适合此方案。
思路:不使用join算子进行连接操作,而使用 Broadcast 变量与 map 类算子实现 join 操作,进而完全规避 shuffle 类的操作,彻底避免数据倾斜的发生和出现。–其实这也是利用广播变量来处理。
var list1=List(("zhangsan",20),("lisi",22),("wangwu",26))
var list2=List(("zhangsan","spark"),("lisi","kafka"),("zhaoliu","hive"))
var rdd1=sc.parallelize(list1)
var rdd2=sc.parallelize(list2)
//这种方式存在性能问题,join引起shuffle。如何优化?
//rdd1.join(rdd2).collect().foreach( t =>println(t._1+" : "+t._2._1+","+t._2._2))
//使用广播变量对join进行调优 使用场景:join两边的RDD,有一个RDD的数据量比较小,此时可以使用广播变量,将这个小的RDD广播出去,从而将普通的join,装换为map-side join。
val rdd1Data=rdd1.collectAsMap()
val rdd1BC=sc.broadcast(rdd1Data)
val rdd3=rdd2.mapPartitions(partition => {
val bc=rdd1BC.value
for{
(key,value) <-partition
if(rdd1Data.contains(key))
}yield(key,(bc.get(key).getOrElse(""),value))
})
对 join 操作导致的数据倾斜,效果非常好。因为不会发生 shuffle,也就不会发生数据倾斜。但是这种场景一般适合一个大 RDD 和 一个小 RDD的情况。
为了对两个 RDD 中的数据进行 join,Spark需要将两个 RDD 上的数据拉取到同一个分区。Spark 中 join 的默认实现是 shuffle hash join: 通过使用与第一个数据集相同的默认分区器对第二个数据集进行分区,从而确保每个分区上的数据将包含相同的 key,从而使两个数据集具有相同 hash 的键值位于同一个分区。虽然这个方法总是可以运行,但是此种操作比较耗费资源,因为需要进行一次 shuffle。
如果两个 RDD 都有一个已知的分区,则可以避免 shuffle,如果它们有相同的分区器,则可以使数据在本地被合并,避免网络传输。因此,建议在 join 两个 RDD 之前,调用 Partitionby方法,并且使用相同的分区器。
val partitioner=new HashPartitioner(10)
agesRDD.partitionBy(partitioner)
addressRDD.partitionBy(partitioner)
如果只是处理较为简单的数据倾斜的场景,使用上述某一种方法即可以解决问题。但是如果要处理一个较为复杂的数据倾斜场景,那么需要将多种方法组合起来一起使用。