Spark總結整理(五):Spark Core 性能優化之數據傾斜調優

Spark性能優化主要分爲:

  1. 開發調優
  2. 資源調優
  3. 數據傾斜調優
  4. shuffle調優

數據傾斜調優,就是使用各種技術方案解決不同類型的數據傾斜問題,以保證Spark作業的性能

1. 數據傾斜發生時的現象

  1. 絕大多數task執行得都非常快,但個別task執行極慢

    比如,總共有1000個task,997個task都在1分鐘之內執行完了,但是剩餘兩三個task卻要一兩個小時,這種情況很常見

  2. 原本能夠正常執行的Spark作業,某天突然報出OOM(內存溢出)異常

    觀察異常棧,是我們寫的業務代碼造成的,這種情況比較少見

2. 數據傾斜發生的原理

數據傾斜的原理很簡單:在進行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所決定。

在這裏插入圖片描述
數據傾斜只會發生在shuffle過程中,如:distinct、groupByKey、reduceByKey、aggregateByKey、join、cogroup、repartition等

3. 定位數據傾斜的地方

如果是用yarn-client模式提交,那麼本地是直接可以看到log的,可以在log中找到當前運行到了第幾個stage

如果是用yarn-cluster模式提交,則可以通過Spark Web UI來查看當前運行到了第幾個stage

在Spark Web UI上深入看一下當前這個stage各個task分配的數據量,從而進一步確定是不是task分配的數據不均勻導致了數據傾斜

比如下圖中,倒數第三列顯示了每個task的運行時間。明顯可以看到,有的task運行特別快,只需要幾秒鐘就可以運行完;而有的task運行特別慢,需要幾分鐘才能運行完,此時單從運行時間上看就已經能夠確定發生數據傾斜了。此外,倒數第一列顯示了每個task處理的數據量,明顯可以看到,運行時間特別短的task只需要處理幾百KB的數據即可,而運行時間特別長的task需要處理幾千KB的數據,處理的數據量差了10倍。此時更加能夠確定是發生了數據傾斜。
在這裏插入圖片描述
找到數據傾斜的Task後,即可確定對應的RDD

4. 查看導致數據傾斜的key的數據分佈情況

查看key分佈的方式:

  1. 如果是Spark SQL中的group by、join語句導致的數據傾斜,那麼就查詢一下SQL中使用的表的key分佈情況
  2. 如果是對Spark RDD執行shuffle算子導致的數據傾斜,那麼可以在Spark作業中加入查看key分佈的代碼,比如RDD.countByKey(),然後對統計出來的各個key出現的次數,collect/take到客戶端打印一下,就可以看到key的分佈情況
val sampledPairs = pairs.sample(false, 0.1)
val sampledWordCounts = sampledPairs.countByKey()
sampledWordCounts.foreach(println(_))

我們可以先對pairs採樣10%的樣本數據,然後使用countByKey算子統計出每個key出現的次數,最後在客戶端遍歷和打印樣本數據中各個key的出現次數

5. 數據傾斜的解決方案

5.1 在上游數據源處預處理

使用場景:上游數據源(如Hive表)中的數據本身很不均勻(比如某個key對應了100萬數據,其他key纔對應了10條數據)

此時可以評估一下,是否可以通過Hive來進行數據預處理(即通過Hive ETL預先對數據按照key進行聚合,或者是預先和其他表進行join)

然後在Spark作業中針對的數據源就不是原來的Hive表了,而是預處理後的Hive表

此時由於數據已經預先進行過聚合或join操作了,那麼在Spark作業中也就不需要使用原先的shuffle類算子執行這類操作了

5.2 過濾少數導致傾斜的key

使用場景:導致傾斜的key就少數幾個,而且對計算本身的影響並不大

比如99%的key就對應10條數據,但是隻有一個key對應了100萬數據,從而導致了數據傾斜

5.3 提高shuffle操作的並行度

使用場景:處理數據傾斜最簡單的一種方案,建議優先使用這種方案(但效果有限)

增加shuffle read task的數量,可以讓原本分配給一個task的多個key分配給多個task,從而讓每個task處理比原來更少的數據

在這裏插入圖片描述

5.4 兩階段聚合(局部聚合+全局聚合)

使用場景:對RDD執行reduceByKey等聚合類shuffle算子或者在Spark SQL中使用group by語句進行分組聚合時,比較適用這種方案

方案的核心實現思路就是進行兩階段聚合

  1. 第一次是局部聚合,先給每個key都打上一個隨機數,比如10以內的隨機數,此時原先一樣的key就變成不一樣的了,比如(hello, 1) (hello, 1) (hello, 1) (hello, 1),就會變成(1_hello, 1) (1_hello, 1) (2_hello, 1) (2_hello, 1)

  2. 接着對打上隨機數後的數據,執行reduceByKey等聚合操作,進行局部聚合,那麼局部聚合結果,就會變成了(1_hello, 2) (2_hello, 2)

  3. 然後將各個key的前綴給去掉,就會變成(hello,2)(hello,2),再次進行全局聚合操作,就可以得到最終結果了,比如(hello, 4)

侷限:僅僅適用於聚合類的shuffle操作,適用範圍相對較窄,如果是join類的shuffle操作,還得用其他的解決方案

在這裏插入圖片描述

// 第一步,給RDD中的每個key都打上一個隨機前綴。
JavaPairRDD<String, Long> randomPrefixRdd = rdd.mapToPair(
        new PairFunction<Tuple2<Long,Long>, String, Long>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Tuple2<String, Long> call(Tuple2<Long, Long> tuple)
                    throws Exception {
                Random random = new Random();
                int prefix = random.nextInt(10);
                return new Tuple2<String, Long>(prefix + "_" + tuple._1, tuple._2);
            }
        });

// 第二步,對打上隨機前綴的key進行局部聚合。
JavaPairRDD<String, Long> localAggrRdd = randomPrefixRdd.reduceByKey(
        new Function2<Long, Long, Long>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Long call(Long v1, Long v2) throws Exception {
                return v1 + v2;
            }
        });

// 第三步,去除RDD中每個key的隨機前綴。
JavaPairRDD<Long, Long> removedRandomPrefixRdd = localAggrRdd.mapToPair(
        new PairFunction<Tuple2<String,Long>, Long, Long>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Tuple2<Long, Long> call(Tuple2<String, Long> tuple)
                    throws Exception {
                long originalKey = Long.valueOf(tuple._1.split("_")[1]);
                return new Tuple2<Long, Long>(originalKey, tuple._2);
            }
        });

// 第四步,對去除了隨機前綴的RDD進行全局聚合。
JavaPairRDD<Long, Long> globalAggrRdd = removedRandomPrefixRdd.reduceByKey(
        new Function2<Long, Long, Long>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Long call(Long v1, Long v2) throws Exception {
                return v1 + v2;
            }
        });

5.5 廣播小RDD全量數據 + map算子來實現 join 操作

使用場景:在對RDD使用join類操作,或者是在Spark SQL中使用join語句時,而且join操作中的一個RDD或表的數據量比較小(比如幾百M或者一兩G),比較適用此方案

實現步驟

  1. 將較小RDD中的數據直接通過collect算子拉取到Driver端的內存中來,然後對其創建一個Broadcast變量
  2. 接着對另外一個RDD執行map類算子,在算子函數內,從Broadcast變量中獲取較小RDD的全量數據,與當前RDD的每一條數據按照連接key進行比對
  3. 如果連接key相同的話,那麼就將兩個RDD的數據用需要的方式連接起來

完全規避掉shuffle類的操作,徹底避免數據傾斜的發生和出現

在這裏插入圖片描述

5.6 採樣傾斜key並分拆join操作

使用場景:兩個RDD進行join的時候,如果數據量都比較大,且數據傾斜是由其中一個RDD的少數key數據量過大,另一個RDD的key都分佈均勻,則此方案合適

實現步驟:

  1. 對導致數據傾斜的 RDD_W,通過 sample 算子採樣、統計每個key的數量,找出數據量最大的 Key_W
  2. 從 RDD_W 中 過濾出數據量大的Key_W,形成一個單獨的 RDD_W_1,並給每個key都打上n以內的隨機數作爲前綴,而不會導致傾斜的大部分key形成另外一個 RDD_W_2
  3. 將需要 join 的另一個 RDD_Y,也過濾出數據量大的Key_W, 形成一個單獨的 RDD_Y_1,將每條數據膨脹成n條數據,這n條數據都按順序附加一個0~n的前綴,不會導致傾斜的大部分key也形成另外一個 RDD_Y_2
  4. 將 RDD_W_1 與 RDD_Y_1 進行join,此時就可以將原先相同的key打散成n份,分散到多個task中去進行join了
  5. 而另外兩個普通的 RDD (RDD_W_2 和 RDD_Y_2) 就照常join即可
  6. 最後將兩次 join 的結果使用 union 算子合併起來即可,就是最終的 join 結果
    在這裏插入圖片描述
// 首先從包含了少數幾個導致數據傾斜key的rdd1中,採樣10%的樣本數據。
JavaPairRDD<Long, String> sampledRDD = rdd1.sample(false, 0.1);

// 對樣本數據RDD統計出每個key的出現次數,並按出現次數降序排序。
// 對降序排序後的數據,取出top 1或者top 100的數據,也就是key最多的前n個數據。
// 具體取出多少個數據量最多的key,由大家自己決定,我們這裏就取1個作爲示範。
JavaPairRDD<Long, Long> mappedSampledRDD = sampledRDD.mapToPair(
        new PairFunction<Tuple2<Long,String>, Long, Long>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Tuple2<Long, Long> call(Tuple2<Long, String> tuple)
                    throws Exception {
                return new Tuple2<Long, Long>(tuple._1, 1L);
            }     
        });
JavaPairRDD<Long, Long> countedSampledRDD = mappedSampledRDD.reduceByKey(
        new Function2<Long, Long, Long>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Long call(Long v1, Long v2) throws Exception {
                return v1 + v2;
            }
        });
JavaPairRDD<Long, Long> reversedSampledRDD = countedSampledRDD.mapToPair( 
        new PairFunction<Tuple2<Long,Long>, Long, Long>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Tuple2<Long, Long> call(Tuple2<Long, Long> tuple)
                    throws Exception {
                return new Tuple2<Long, Long>(tuple._2, tuple._1);
            }
        });
final Long skewedUserid = reversedSampledRDD.sortByKey(false).take(1).get(0)._2;

// 從rdd1中分拆出導致數據傾斜的key,形成獨立的RDD。
JavaPairRDD<Long, String> skewedRDD = rdd1.filter(
        new Function<Tuple2<Long,String>, Boolean>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Boolean call(Tuple2<Long, String> tuple) throws Exception {
                return tuple._1.equals(skewedUserid);
            }
        });
// 從rdd1中分拆出不導致數據傾斜的普通key,形成獨立的RDD。
JavaPairRDD<Long, String> commonRDD = rdd1.filter(
        new Function<Tuple2<Long,String>, Boolean>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Boolean call(Tuple2<Long, String> tuple) throws Exception {
                return !tuple._1.equals(skewedUserid);
            } 
        });

// rdd2,就是那個所有key的分佈相對較爲均勻的rdd。
// 這裏將rdd2中,前面獲取到的key對應的數據,過濾出來,分拆成單獨的rdd,並對rdd中的數據使用flatMap算子都擴容100倍。
// 對擴容的每條數據,都打上0~100的前綴。
JavaPairRDD<String, Row> skewedRdd2 = rdd2.filter(
         new Function<Tuple2<Long,Row>, Boolean>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Boolean call(Tuple2<Long, Row> tuple) throws Exception {
                return tuple._1.equals(skewedUserid);
            }
        }).flatMapToPair(new PairFlatMapFunction<Tuple2<Long,Row>, String, Row>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Iterable<Tuple2<String, Row>> call(
                    Tuple2<Long, Row> tuple) throws Exception {
                Random random = new Random();
                List<Tuple2<String, Row>> list = new ArrayList<Tuple2<String, Row>>();
                for(int i = 0; i < 100; i++) {
                    list.add(new Tuple2<String, Row>(i + "_" + tuple._1, tuple._2));
                }
                return list;
            }

        });

// 將rdd1中分拆出來的導致傾斜的key的獨立rdd,每條數據都打上100以內的隨機前綴。
// 然後將這個rdd1中分拆出來的獨立rdd,與上面rdd2中分拆出來的獨立rdd,進行join。
JavaPairRDD<Long, Tuple2<String, Row>> joinedRDD1 = skewedRDD.mapToPair(
        new PairFunction<Tuple2<Long,String>, String, String>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Tuple2<String, String> call(Tuple2<Long, String> tuple)
                    throws Exception {
                Random random = new Random();
                int prefix = random.nextInt(100);
                return new Tuple2<String, String>(prefix + "_" + tuple._1, tuple._2);
            }
        })
        .join(skewedRdd2)
        .mapToPair(new PairFunction<Tuple2<String,Tuple2<String,Row>>, Long, Tuple2<String, Row>>() {
                        private static final long serialVersionUID = 1L;
                        @Override
                        public Tuple2<Long, Tuple2<String, Row>> call(
                            Tuple2<String, Tuple2<String, Row>> tuple)
                            throws Exception {
                            long key = Long.valueOf(tuple._1.split("_")[1]);
                            return new Tuple2<Long, Tuple2<String, Row>>(key, tuple._2);
                        }
                    });

// 將rdd1中分拆出來的包含普通key的獨立rdd,直接與rdd2進行join。(或過濾掉 導致數據傾斜的Key)
JavaPairRDD<Long, Tuple2<String, Row>> joinedRDD2 = commonRDD.join(rdd2);

// 將傾斜key join後的結果與普通key join後的結果,uinon起來。
// 就是最終的join結果。
JavaPairRDD<Long, Tuple2<String, Row>> joinedRDD = joinedRDD1.union(joinedRDD2);

5.7 使用隨機前綴和擴容RDD進行join

使用場景:join操作時,一個RDD中key分佈相對較爲均勻,一個RDD中有大量的key傾斜,方案六無法解決時使用

流程如下:

  1. 首先查看RDD/Hive表中的數據分佈情況,找到那個造成數據傾斜的RDD/Hive表,比如有多個key都對應了超過1萬條數據
  2. 然後將該RDD的每條數據都打上一個n以內的隨機前綴
  3. 同時對另外一個正常的RDD進行擴容,將每條數據都擴容成n條數據,擴容出來的每條數據都依次打上一個0~n的前綴
  4. 將兩個處理後的RDD進行join即可
// 首先將其中一個key分佈相對較爲均勻的RDD膨脹100倍。
JavaPairRDD<String, Row> expandedRDD = rdd1.flatMapToPair(
        new PairFlatMapFunction<Tuple2<Long,Row>, String, Row>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Iterable<Tuple2<String, Row>> call(Tuple2<Long, Row> tuple)
                    throws Exception {
                List<Tuple2<String, Row>> list = new ArrayList<Tuple2<String, Row>>();
                for(int i = 0; i < 100; i++) {
                    list.add(new Tuple2<String, Row>(i + "_" + tuple._1, tuple._2));
                }
                return list;
            }
        });

// 其次,將另一個有數據傾斜key的RDD,每條數據都打上100以內的隨機前綴。
JavaPairRDD<String, String> mappedRDD = rdd2.mapToPair(
        new PairFunction<Tuple2<Long,String>, String, String>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Tuple2<String, String> call(Tuple2<Long, String> tuple)
                    throws Exception {
                Random random = new Random();
                int prefix = random.nextInt(100);
                return new Tuple2<String, String>(prefix + "_" + tuple._1, tuple._2);
            }
        });

// 將兩個處理後的RDD進行join即可。
JavaPairRDD<String, Tuple2<String, Row>> joinedRDD = mappedRDD.join(expandedRDD);

6. 總結

優先考慮:方案一和方案二

其次考慮:方案三:提高並行度

最後,根據是聚合還是join,根據實際選擇不同方案或方案的組合

參考:
批:https://blog.csdn.net/lw_ghy/article/details/51419877#commentBox
流:https://www.iteblog.com/archives/2061.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章