DataSkew —— 数据倾斜问题解析及解决方案实践总结小记

10 篇文章 0 订阅

文章目录

注意,要区分开数据倾斜与数据量过量这两种情况,数据倾斜是指少数task被分配了绝大多数的数据,因此少数task运行缓慢;数据过量是指所有task被分配的数据量都很大,相差不多,所有task都运行缓慢。

什么是数据倾斜

简单的讲,数据倾斜就是我们在计算数据的时候,数据的分散度不够,导致大量的数据集中到了一台或者几台机器上计算,这些数据的计算速度远远低于平均计算速度,导致整个计算过程过慢。
hash的时候,如果有一个值特别多,那么hash的操作是均分不掉数据的,只能放到一个机器上去处理,这就是数据倾斜。
相信大部分做过数据的朋友都会遇到数据倾斜,数据倾斜会发生在数据开发的各个环节中,比如:

  • Hive算数据的时候reduce阶段卡在99.99%
  • 用SparkStreaming做实时算法时候,一直会有executor出现OOM的错误,但是其余的executor内存使用率却很低

数据倾斜有一个关键因素是数据量大,可以达到千亿级。其实所谓的数据倾斜就是数据分布不均匀。

数据倾斜的现象

Hadoop中的数据倾斜

Hadoop中直接贴近用户使用使用的时Mapreduce程序和Hive程序,虽说Hive最后也是用MR来执行(至少目前Hive内存计算并不普及),但是毕竟写的内容逻辑区别很大,一个是程序,一个是Sql,因此这里稍作区分。Hadoop中的数据倾斜主要表现在ruduce阶段卡在99.99%,一直99.99%不能结束。

这里如果详细的看日志或者和监控界面的话会发现:

  • 有一个多几个reduce卡住
  • 各种container报错OOM
  • 读写的数据量极大,至少远远超过其它正常的reduce

伴随着数据倾斜,会出现任务被kill等各种诡异的表现。

Spark中的数据倾斜

Spark中的数据倾斜也很常见,这里包括Spark StreamingSpark Sql,表现主要有下面几种:

  • Executor lostOOMShuffle过程出错
  • Driver OOM
  • 单个Executor执行时间特别久,整体任务卡在某个阶段不能结束
  • 正常运行的任务突然失败

补充一下,在Spark streaming程序中,数据倾斜更容易出现特别是在程序中包含一些类似sql的join、group这种操作的时候因为Spark Streaming程序在运行的时候,我们一般不会分配特别多的内存,因此一旦在这个过程中出现一些数据倾斜,就十分容易造成OOM。

Hive中的数据倾斜

任务进度长时间维持在99%(或100%),查看任务监控页面,发现只有少量(1个或几个)reduce子任务未完成。因为其处理的数据量和其他reduce差异过大。单一reduce的记录数与平均记录数差异过大,通常可能达到3倍甚至更多。 最长时长远大于平均时长。
经验: Hive的数据倾斜,一般都发生在SqlGroup ByJoin On上,而且和数据逻辑绑定比较深。

数据倾斜产生的原因

Hive数仓为何会出现DateSkew

数仓中发生数据倾斜的根本原因就是:某值的的数量过多。导致谋职的数量过多的原因有三个。
产生原因:

  • key 分布不均匀
  • 业务数据本身的特性
  • 空值,大量的游客用户登录访问。
  • 数据类型不匹配,使用从mysql数据导过来的数据和hive数仓里的数据做联合查询的时候,可能某个字段mysql里的是int类型,hive里是string类型,这些string类型的数据就会积压在一起。
  • 表结构本身有问题,比如,这个地区字段,都是市,北京市是一个值,太原市是一个值,到时候肯定会北京市的数据量特别大导致数据倾斜。这个例子比较极端,只是想说地区人口差异很大,数据量也会有很大差距。

Spark为何会出现DateSkew

数据倾斜的原因:

出现数据倾斜的原因,基本只可能是因为发生了shuffle操作,在shuffle的过程中,出现了数据倾斜的问题。因为某个或者某些key对应的数据,远远的高于其他的keyShuffle数据之后导致数据分布不均匀,但是所有节点的机器的性能都是一样的,程序也是一样的,就是数据量不一致,所以决定了task的执行时长就被数据量决定了。

数据分区的策略:
  • 随机分区: 每一个数据分配的任意一个分区的概率是均等的
  • Hash分区: 使用数据的Hash分区值,%分区数。(导致数据倾斜的原因)
  • 范围分区: 将数据范围划分,数据分配到不同的范围中(分布式的全局排序)
定位数据倾斜问题
  1. 查阅代码中会产生shuffle的算子,例如distinct、groupByKey、reduceByKey、aggregateByKey、join、cogroup、repartition等算子,根据代码逻辑判断此处是否会出现数据倾斜。
  2. 查看Spark作业的log文件,log文件对于错误的记录会精确到代码的某一行,可以根据异常定位到的代码位置来明确错误发生在第几个stage(哪一个stage生成的task特别慢),通过stage定位到对应的shuffle算子是哪一个,从而确定是什么地方发生数据倾斜。
查看数据倾斜的key的分布情况:
//使用spark中的抽样算子sample,查看相应的key的分布
val sampledPairs = pairs.sample(false, 0.1)  //抽样
val sampledWordCounts = sampledPairs.countByKey()
sampledWordCounts.foreach(println(_))

数据倾斜产生的原理

如果大家对MapReduceshuffle过程或者Sparkshuffle很熟悉的话完全不用看这个图的,因为图片也是笔者随便找的本不想放在这里。多说一句以MR为例就是由于shuffle后数据经过HashPartitioner进行分组后形成数据集的数量均衡造成的。数据分组效果大致如下图:
在这里插入图片描述

产生数据倾斜的操作

关键操作情形结果
Join其中一个表较小,但是key集中分发到某一个或几个Reduce上的数据远高于平均值
大表与大表,但是分桶的判断字段0值或者空值过多这些空值都由一个redice处理,很慢
group bygroup by维度过小,某值得数量过多处理某值的reduce十分耗时
Count(DISTINCT XXX)某特殊值过多处理此特殊值的reduce十分耗时
reduceByKey某值得数量过多处理某值的reduce十分耗时
countByKey某值得数量过多处理某值的reduce十分耗时
groupByKey某值得数量过多处理某值的reduce十分耗时

不同情形倾斜数据处理方案

Hql和SparkSql中处理

Join
小表join大表

主要考虑使用Map Join代替Reduce Join
使用map join让小的维度表(1000条以下的记录条数) 先进内存。在map端完成reduce
由于笔者之前总结过map join如何使用,这里就不过多陈述。可以参照hive sql数据倾斜——大表join小表如何使用map join

大表join大表skewjoin
  • key存在无效值时:
    空key变成一个字符串加上随机数,把倾斜的数据分到不同的reduce上,由于null值关联不上,处理后并不影响最终结果。处理方式如下:
SELECT *
FROM log a
LEFT JOIN bmw_users b 
ON CASE WHEN a.user_id IS NULL THEN concat(‘dp_hive’,rand()) >ELSE a.user_id END = b.user_id;
  • key值都是有效值时
    可使用hive配置:
-- 指定是否开启数据倾斜的join运行时优化,默认不开启即false。
set hive.optimize.skewjoin=true;
-- 判断数据倾斜的阈值,如果在join中发现同样的key超过该值,则认为是该key是倾斜key。默认100000。
-- 一般可以设置成处理的总记录数/reduce个数的2-4倍。
set hive.skewjoin.key=100000;
-- 指定是否开启数据倾斜的join编译时优化,默认不开启即false。
set hive.optimize.skewjoin.compiletime=true;

具体来说,会基于存储在原数据中的倾斜key,在编译时为导致倾斜的key单独创建执行计划,而其他key也有一个执行计划用来join。然后,对上面生成的两个join执行后求并集。因此,除非相同的倾斜key同时存在于这两个join表中,否则对于引起倾斜的keyjoin就会优化为map-side join。此外,该参数与hive.optimize.skewjoin之间的主要区别在于,此参数使用存储在metastore中的倾斜信息在编译时来优化执行计划。如果元数据中没有倾斜信息,则此参数无效。一般可将这两个参数都设为true。如果元数据中有倾斜信息,则hive.optimize.skewjoin不做任何操作。

数据类型不匹配

数据类型不匹配,可以直接把两个表的主键直接统一。

select * 
from users a
left outer join logs b
on a.usr_id = cast(b.user_id as string)
关联主键含有大量空key

这种情况需要按照场景进行处理,分为单条件联合多条件联合查询。如果是多条件联合查询可以将多个主键进行拼接产生新的主键来尽可能避免主键空key的情况,当然这种情况需要与业务数据紧密结合,尽可能多的确保数据中多个关连键有一个不为空,是的空key数量保持在正常分组水平,也可以极大提高查询性能。如果实在不能确保那么我们应该对每一个关联主键按照单条件关联的方式进行处理。实例如下:

--发生数据倾斜的SQL
select *
from advertisement_log log
left join user_center user
on log.open_id = user.open_id
and log.union_id = user.union_id

--调整后的sql
select *
from advertisement_log log
left join user_center user
on concat_ws('_',log.open_id,log.union_id) = concat_ws('_',user.open_id,user.union_id)

如果是单条件联合查询建议使用如下实例两个方式:

解决方式1:user_id为空的不参与关联

SELECT *
FROM log a
JOIN bmw_users b
  ON a.user_id IS NOT NULL
 AND a.user_id = b.user_id
UNION ALL
SELECT *
FROM log a
WHERE a.user_id IS NULL;

解决方式2:赋与空值分新的key值

SELECT *
FROM log a
LEFT JOIN bmw_users b 
ON CASE WHEN a.user_id IS NULL THEN concat(‘dp_hive’,rand()) ELSE a.user_id END = b.user_id;

结论:

方法2比方法1效率更好,不但io少了,而且作业数也少了。解决方法1 log表被读取了两次,jobs2。这个适合优化无效 id(比如-99 , ’’, null等) 产生的倾斜问题。把空key 变成一个字符串加上随机数,就能把倾斜的数据分到不同的reduce上 ,解决数据倾斜问题。

关联主键中少部分key的数据量巨大

在业务逻辑优化效果的不大情况下,有些时候是可以将倾斜的数据单独拿出来处理。最后union回去。

这种情况笔者是在对日志表表数据做处理时遇到的,主要是不同埋点的日志数据,热埋点和冷埋点被点击的数据量偏差非常巨大,相差四五个数据级。笔者是按照需求设计埋点表字典表,按照数据量的级别对埋点进行划分,分别进行join,最后使用union all将数据进行合并。这种处理方式需要对数据进行深入的分析,但是处理比较简单有效,原理和空key的解决方式1类似。实例如下:

字典表:

CREATE EXTERNAL TABLE dim.event_v1`
(
   `event_id` STRING COMMENT '事件ID', 
   `describe` STRING COMMENT '事件(埋点)描述', 
   `district` STRING COMMENT '事件区域', 
   `part` STRING COMMENT '事件所属部分', 
   `action` STRING COMMENT '行为[点击,展示]', 
   `work_table` STRING COMMENT '使用表'
)
STORED AS textfile
LOCATION '/big-data/dim/event_v1';

解决方式:

SELECT /*+MAPJOIN(event)*/
   user_id,
   app_id,
   provice,
   city,
   district
FROM fct.log log
JOIN (
     SELECT 
     FROM dim.event_1
     WHERE work_table = 'big_event_log'
)event
ON log.event_id = event.event_id
UNION ALL
SELECT /*+MAPJOIN(event)*/
   user_id,
   app_id,
   provice,
   city,
   district
FROM fct.log log
JOIN (
     SELECT 
     FROM dim.event_1
     WHERE work_table = 'small_event_log'
)event
ON log.event_id = event.event_id
Join驱动表选取和数据量优化:
  1. 关于驱动表的选取,选用join key分布最均匀的表作为驱动表
  2. 做好列裁剪和filter操作,以达到两表做join的时候,数据量相对变小的效果。
left semi join

这种join的应用场景主要是用来代替sql中的in,来提升性能,使用于大表join小表的一些场景中,如果关联的结果只需要保留左表中的字段数据且重复数据在结果中只需要出现一次,那么这个时候就可以使用left semi join来避免因为关联主键某些值大量出现引起的倾斜。在后续的文章中笔者会对left semi join用法进行总结。

Join 倾斜总结

关于join的倾斜总结目前笔者遇到基本就是这些,如果后续遇到其他情况笔者在更。

Count(DISTINCT XXX)

count distinct时,会将值为空的情况单独处理,比如可以直接过滤空值的行,在最后结果中加1。如果还有其他计算,需要进行group by,可以先将值为空的记录单独处理,再和其他计算结果进行union
能先进行 group操作的时候先进行group操作,把key先进行一次 reduce,之后再进行 count或者distinct count 操作。

group by

注:group by优于distinct group
采用sum() + group by的方式来替换count(distinct)完成计算。

增加Reuducer个数

默认是由参数hive.exec.reducers.bytes.per.reducer来推断需要的Reducer个数。可通过mapred.reduce.tasks控制。

调优
hive.map.aggr=true
--开启map端combiner
set hive.map.aggr=true

开启map combiner。在map中会做部分聚集操作,效率更高但需要更多的内存。
假如map各条数据基本上不一样, 聚合没什么意义,做combiner反而画蛇添足,hive里也考虑的比较周到通过参数可以进行相关的设置:

hive.groupby.mapaggr.checkinterval = 100000 (默认)
hive.map.aggr.hash.min.reduction=0.5(默认)
hive.groupby.skewindata=true
--开启数据倾斜时负载均衡
set hive.groupby.skewindata=true;

就是先随机分发并处理,再按照key group by来分发处理。
当选项设定为true,生成的查询计划会有两个MRJob

  • 第一个MRJob中,Map的输出结果集合会随机分布到Reduce中,每个Reduce做部分聚合操作,并输出结果,这样处理的结果是相同的GroupBy Key有可能被分发到不同的Reduce中,从而达到负载均衡的目的;
  • 第二个MRJob再根据预处理的数据结果按照GroupBy Key分布到Reduce中(这个过程可以保证相同的原始GroupBy Key被分布到同一个Reduce中),最后完成最终的聚合操作。
    它使计算变成了两个mapreduce,先在第一个中在shuffle过程 partition 时随机给 key 打标记,使每个key随机均匀分布到各个 reduce 上计算,但是这样只能完成部分计算,因为相同key没有分配到相同reduce上。所以需要第二次的mapreduce,这次就回归正常shuffle,但是数据分布不均匀的问题在第一次mapreduce已经有了很大的改善,因此基本解决数据倾斜。因为大量计算已经在第一次mr中随机分布到各个节点完成。
增加并行度
  • 场景: 两个大表,数据分布均匀,为了提高效率,使用mapjoin,采用切分大表的方法。
  • 方法: 采用将大表切分为小表,然后进行连接。

原始测试表

+----------+------------+
| test.id  | test.name  |
+----------+------------+
| 1        | aa         |
| 2        | bb         |
| 3        | cc         |
| 4        | dd         |
+----------+------------+

将其切分为两个:

 select * from test tablesample(bucket 1 out of 2 on id);

+----------+------------+
| test.id  | test.name  |
+----------+------------+
| 2        | bb         |
| 4        | dd         |
+----------+------------+
 select * from test tablesample(bucket 2 out of 2 on id);

+----------+------------+
| test.id  | test.name  |
+----------+------------+
| 1        | aa         |
| 3        | cc         |
+----------+------------+

切分为四个:

select * from test tablesample(bucket 1 out of 4 on id);

+----------+------------+
| test.id  | test.name  |
+----------+------------+
| 4        | dd         |
+----------+------------+
select * from test tablesample(bucket 2 out of 4 on id);
+----------+------------+
| test.id  | test.name  |
+----------+------------+
| 1        | aa         |
+----------+------------+
select * from test tablesample(bucket 3 out of 4 on id);

+----------+------------+
| test.id  | test.name  |
+----------+------------+
| 2        | bb         |
+----------+------------+
select * from test tablesample(bucket 4 out of 4 on id);
+----------+------------+
| test.id  | test.name  |
+----------+------------+
| 3        | cc         |
+----------+------------+

tablesample(bucket 3 out of 4 on id),其中tablesample为关键字,bucket关键字,3为要去的分表,4为拆分表的数目,id拆分依据。

*多表 union all 会优化成一个 job

推广效果表要和商品表关联,效果表中的 auction id 列既有商品 id,也有数字 id,和商品表关联得到商品的信息。

SELECT *
FROM effect a
JOIN (
      SELECT auction_id AS auction_id
      FROM auctions
      UNION ALL
      SELECT auction_string_id AS auction_id
      FROM auctions
     ) b
ON a.auction_id = b.auction_id;

这样子比分别过滤数字 id,字符串 id,然后分别和商品表关联性能要好。这样写的好处:1个 MR 作业,商品表只读取一次,推广效果表只读取一次。把这个 sql 换成 MR 代码的话,map 的时候,把 a 表的记录打上标签 a ,商品表记录每读取一条,打上标签 t,变成两个<key,value> 对,<t,数字id,value>,<t,字符串id,value>。所以商品表的 HDFS(Hadoop Distributed File System) 读只会是一次。

消灭子查询内的 group by

原写法:

SELECT *
FROM (
     SELECT *
     FROM t1
     GROUP BY c1, c2, c3
     UNION ALL                                                                                                             
     SELECT *
     FROM t2
     GROUP BY c1, c2, c3
     ) t3
GROUP BY c1, c2, c3;

优化写法:

SELECT *
FROM (
      SELECT *
      FROM t1
      UNION ALL
      SELECT *
      FROM t2
     ) t3
GROUP BY c1, c2, c3;

从业务逻辑上说,子查询内的 group by 功能与外层的 group by 重复,除非子查询内有 count(distinct)。经过测试,并未出现 union allhive bug,数据是一致的。MR 的作业数由3减少到1t1相当于一个目录,t2相当于一个目录,对map reduce程序来说,t1,t2可以做为 map reduce 作业的 mutli inputs。这可以通过一个 map reduce 来解决这个问题。Hadoop的计算框架,不怕数据多,怕作业数多。

消灭子查询内的count(distinct),max,min

原写法:

SELECT c1, c2, c3, sum(pv)
FROM (
    SELECT c1, c2, c3, COUNT(c4)
    FROM t1
    GROUP BY c1, c2, c3
    UNION ALL
    SELECT c1, c2, c3, COUNT(DISTINCT c4)
    FROM t2
    GROUP BY c1, c2, c3
) t3
GROUP BY c1, c2, c3;

这种我们不能直接uniongroup by,因为其中有一个表的操作用到了去重,这种情况,我们可以通过建立临时表来消灭这种数据倾斜问题。

优化写法:

INSERT INTO t4
SELECT c1, c2, c3, COUNT(DISTINCT c4)
FROM t2
GROUP BY c1, c2, c3;

SELECT c1, c2, c3, SUM(pv)
FROM (
    SELECT c1, c2, c3, COUNT(c4)
    FROM t1
    UNION ALL
    SELECT *
    FROM t4
) t3
GROUP BY c1, c2, c3;
reduce的时间过长

假设一个memberid对应的log里有很多数据,那么最后合并的时候,也是十分耗时的,所以,这里需要找到一个方法来解决这种reduce分配不均的问题。

解决方法:

SELECT *
FROM log a
LEFT JOIN (
           SELECT memberid, number
           FROM users d
           JOIN num e
        ) b
ON a.memberid = b.memberid
AND mod(a.pvtime, 30) + 1 = b.number;

解释一下,上面的num是一张1列30行的表,对应1-30的正整数,把users表膨胀成N份(基于倾斜程度做一个合适的选择),然后把log数据根据memberid和pvtime分到不同的reduce里去,这样可以保证每个reduce分配到的数据可以相对均匀

过多的where条件

有的时候,我们会写超级多的where条件来限制查询,其实这样子是非常低效的,主要原因是因为这个and条件hive在生成执行计划时产生了一个嵌套层次很多的算子。

  • 解决方案:
    1)把筛选条件对应的值写入一张小表,再一次性join到主表;
    2)或者写个udfuser-defined function,用户定义函数),把这些预设值读取进去,udf来完成这个and数据过滤操作。
分组结果很多,但是你只需要topK

原写法:

SELECT mid, url, COUNT(1) AS cnt
FROM (
      SELECT *
      FROM r_atpanel_log
      WHERE pt = '20190610'
      AND pagetype = 'normal'
    ) subq
GROUP BY mid, url
ORDER BY cnt DESC
LIMIT 15;

优化写法:

SELECT *
FROM (
      SELECT mid, url, COUNT(1) AS cnt
      FROM (
            SELECT *
            FROM r_atpanel_log
            WHERE pt = '20190610'
            AND pagetype = 'normal'
          ) subq
      GROUP BY mid, url
) subq2
WHERE cnt > 100
ORDER BY cnt DESC
LIMIT 15;

可以看出,我们先过滤掉无关的内容,再进行排序,这样子快很多。

Spark解决数据倾斜具体方法

解决方案一:使用Hive ETL预处理数据
方案适用场景:

导致数据倾斜的是Hive表。如果该Hive表中的数据本身很不均匀(比如某个key对应了100万数据,其他key才对应了10条数据),而且业务场景需要频繁使用SparkHive表执行某个分析操作,那么比较适合使用这种技术方案。

方案实现思路:

此时可以评估一下,是否可以通过Hive来进行数据预处理(即通过Hive ETL预先对数据按照key进行聚合,或者是预先和其他表进行join),然后在Spark作业中针对的数据源就不是原来的Hive表了,而是预处理后的Hive表。此时由于数据已经预先进行过聚合或join操作了,那么在Spark作业中也就不需要使用原先的shuffle类算子执行这类操作了。

方案实现原理:

这种方案从根源上解决了数据倾斜,因为彻底避免了在Spark中执行shuffle类算子,那么肯定就不会有数据倾斜的问题了。但是这里也要提醒一下大家,这种方式属于治标不治本。因为毕竟数据本身就存在分布不均匀的问题,所以Hive ETL中进行group by或者joinshuffle操作时,还是会出现数据倾斜,导致Hive ETL的速度很慢。我们只是把数据倾斜的发生提前到了Hive ETL中,避免Spark程序发生数据倾斜而已。

方案优点:

实现起来简单便捷,效果还非常好,完全规避掉了数据倾斜,Spark作业的性能会大幅度提升。

方案缺点:

治标不治本,Hive ETL中还是会发生数据倾斜

方案实践经验:

在一些Java系统与Spark结合使用的项目中,会出现Java代码频繁调用Spark作业的场景,而且对Spark作业的执行性能要求很高,就比较适合使用这种方案。将数据倾斜提前到上游的Hive ETL,每天仅执行一次,只有那一次是比较慢的,而之后每次Java调用Spark作业时,执行速度都会很快,能够提供更好的用户体验。

项目实践经验:

在某互联网的交互式用户行为分析系统中使用了这种方案,该系统主要是允许用户通过Java Web系统提交数据分析统计任务,后端通过Java提交Spark作业进行数据分析统计。要求Spark作业速度必须要快,尽量在10分钟以内,否则速度太慢,用户体验会很差。所以我们将有些Spark作业的shuffle操作提前到了Hive ETL中,从而让Spark直接使用预处理的Hive中间表,尽可能地减少Sparkshuffle操作,大幅度提升了性能,将部分作业的性能提升了6倍以上。

解决方案二:过滤少数导致倾斜的key
方案适用场景:

如果发现导致倾斜的key就少数几个,而且对计算本身的影响并不大的话,那么很适合使用这种方案。比如99%的key就对应10条数据,但是只有一个key对应了100万数据,从而导致了数据倾斜。

方案实现思路:

如果我们判断那少数几个数据量特别多的key,对作业的执行和计算结果不是特别重要的话,那么干脆就直接过滤掉那少数几个key。比如,在Spark SQL中可以使用where子句过滤掉这些key或者在Spark Core中对RDD执行filter算子过滤掉这些key。如果需要每次作业执行时,动态判定哪些key的数据量最多然后再进行过滤,那么可以使用sample算子对RDD进行采样,然后计算出每个key的数量,取数据量最多的key过滤掉即可。

方案实现原理:

将导致数据倾斜的key给过滤掉之后,这些key就不会参与计算了,自然不可能产生数据倾斜。

方案优点:

实现简单,而且效果也很好,可以完全规避掉数据倾斜。

方案缺点:

适用场景不多,大多数情况下,导致倾斜的key还是很多的,并不是只有少数几个。

方案实践经验:

在项目中我们也采用过这种方案解决数据倾斜。有一次发现某一天Spark作业在运行的时候突然OOM了,追查之后发现,是Hive表中的某一个key在那天数据异常,导致数据量暴增。因此就采取每次执行前先进行采样,计算出样本中数据量最大的几个key之后,直接在程序中将那些key给过滤掉。

解决方案三:提高shuffle操作的并行度
方案适用场景:

如果我们必须要对数据倾斜迎难而上,那么建议优先使用这种方案,因为这是处理数据倾斜最简单的一种方案。

方案实现思路:

在很多 shuffle算子中可直接指定并行度,在对RDD执行shuffle算子时,给shuffle算子传入一个参数,比如reduceByKey(1000)(注意如果并行度大于 executor 数 * executor core 数,以小为准),该参数就设置了这个shuffle算子执行时shuffle read task的数量。对于Spark SQL中的shuffle类语句,比如group by、join等,需要设置一个参数,即spark.sql.shuffle.partitions,该参数代表了shuffle read task的并行度,该值默认是200,对于很多场景来说都有点过小。

方案实现原理:

增加 reduce 并行度其实就是增加reducetask 的数量, 这样每个 task处理的数据量减少,避免oom。也就是增加shuffle read task的数量,可以让原本分配给一个task的多个key分配给多个task,从而让每个task处理比原来更少的数据。举例来说,如果原本有5key,每个key对应10条数据,这5key都是分配给一个task的,那么这个task就要处理50条数据。而增加了shuffle read task以后,每个task就分配到一个key,即每个task就处理10条数据,那么自然每个task的执行时间都会变短了。具体原理如下图所示。

在这里插入图片描述

方案优点:

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

方案缺点:

只是缓解了数据倾斜而已,没有彻底根除问题,根据实践经验来看,其效果有限。map端不断地写入数据,reduce task不断地从指定位置读取数据,如果 task 很多,读取的速度增加,但是每个key对应的reduce处理的总量没变,所以它并没有从根本上解决数据倾斜的问题,只是尽量去减少reduce task的数据量,适用于较多key对应的数据量都很大的问题;
试想,如果只有1key数据量较大,那么其他key高并行就是资源的浪费;

方案实践经验:

该方案通常无法彻底解决数据倾斜,因为如果出现一些极端情况,比如某个key对应的数据量有100万,那么无论你的task数量增加到多少,这个对应着100万数据的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)

方案实现原理:

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

方案优点:

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

方案缺点:

仅仅适用于聚合类的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;
            }
        });
解决方案五:将reduce join转为map join
方案适用场景:

在对RDD使用join类操作,或者是在Spark SQL中使用join语句时,而且join操作中的一个RDD或表的数据量比较小(比如几百M或者一两G),比较适用此方案。

方案实现思路:

不使用join算子进行连接操作,而使用Broadcast变量与map类算子实现join操作,进而完全规避掉shuffle类的操作,彻底避免数据倾斜的发生和出现。将较小RDD中的数据直接通过collect算子拉取到Driver端的内存中来,然后对其创建一个Broadcast变量;接着对另外一个RDD执行map类算子,在算子函数内,从Broadcast变量中获取较小RDD的全量数据,与当前RDD的每一条数据按照连接key进行比对,如果连接key相同的话,那么就将两个RDD的数据用你需要的方式连接起来。

方案实现原理:

普通的join是会走shuffle过程的,而一旦shuffle,就相当于会将相同key的数据拉取到一个shuffle read task中再进行join,此时就是reduce join。但是如果一个RDD是比较小的,则可以采用广播小RDD全量数据+map算子来实现与join同样的效果,也就是map join,此时就不会发生shuffle操作,也就不会发生数据倾斜。具体原理如下图所示。

方案优点:

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

方案缺点:

适用场景较少,因为这个方案只适用于一个大表和一个小表的情况。毕竟我们需要将小表进行广播,此时会比较消耗内存资源driver和每个Executor内存中都会驻留一份小RDD的全量数据。如果我们广播出去的RDD数据比较大,比如10G以上,那么就可能发生内存溢出了。因此并不适合两个都是大表的情况。

在这里插入图片描述

// 首先将数据量比较小的RDD的数据,collect到Driver中来。
List<Tuple2<Long, Row>> rdd1Data = rdd1.collect()
// 然后使用Spark的广播功能,将小RDD的数据转换成广播变量,这样每个Executor就只有一份RDD的数据。
// 可以尽可能节省内存空间,并且减少网络传输性能开销。
final Broadcast<List<Tuple2<Long, Row>>> rdd1DataBroadcast = sc.broadcast(rdd1Data);

// 对另外一个RDD执行map类操作,而不再是join类操作。
JavaPairRDD<String, Tuple2<String, Row>> joinedRdd = rdd2.mapToPair(
        new PairFunction<Tuple2<Long,String>, String, Tuple2<String, Row>>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Tuple2<String, Tuple2<String, Row>> call(Tuple2<Long, String> tuple)
                    throws Exception {
                // 在算子函数中,通过广播变量,获取到本地Executor中的rdd1数据。
                List<Tuple2<Long, Row>> rdd1Data = rdd1DataBroadcast.value();
                // 可以将rdd1的数据转换为一个Map,便于后面进行join操作。
                Map<Long, Row> rdd1DataMap = new HashMap<Long, Row>();
                for(Tuple2<Long, Row> data : rdd1Data) {
                    rdd1DataMap.put(data._1, data._2);
                }
                // 获取当前RDD数据的key以及value。
                String key = tuple._1;
                String value = tuple._2;
                // 从rdd1数据Map中,根据key获取到可以join到的数据。
                Row rdd1Value = rdd1DataMap.get(key);
                return new Tuple2<String, String>(key, new Tuple2<String, Row>(value, rdd1Value));
            }
        });

// 这里得提示一下。
// 上面的做法,仅仅适用于rdd1中的key没有重复,全部是唯一的场景。
// 如果rdd1中有多个相同的key,那么就得用flatMap类的操作,在进行join的时候不能用map,而是得遍历rdd1所有数据进行join。
// rdd2中每条数据都可能会返回多条join后的数据。
解决方案六:sample采样倾斜key并分拆join操作

这种方法和sql中的同key某个值贼多的处理方式异曲同工共。

方案适用场景:

两个RDD/Hive表进行join的时候,如果数据量都比较大,无法采用“解决方案五”,那么此时可以看一下两个RDD/Hive表中的key分布情况。如果出现数据倾斜,是因为其中某一个RDD/Hive表中的少数几个key的数据量过大,而另一个RDD/Hive表中的所有key都分布比较均匀,那么采用这个解决方案是比较合适的。

方案实现思路:

对包含少数几个数据量过大的key的那个RDD

  1. 通过sample算子采样出一份样本来,
  2. 然后统计一下每个key的数量,计算出来数据量最大的是哪几个key
  3. 然后将这几个key对应的数据从原来的RDD中拆分出来,形成一个单独的RDD,并给每个key都打上n以内的随机数作为前缀,而不会导致倾斜的大部分key形成另外一个RDD
  4. 接着将需要join的另一个RDD,也过滤出来那几个倾斜key对应的数据并形成一个单独的RDD,将每条数据膨胀成n条数据,这n条数据都按顺序附加一个0~n的前缀,不会导致倾斜的大部分key也形成另外一个RDD
  5. 再将附加了随机前缀的独立RDD与另一个膨胀n倍的独立RDD进行join,此时就可以将原先相同的key打散成n份,分散到多个task中去进行join了。而另外两个普通的RDD就照常join即可。
  6. 最后将两次join的结果使用union算子合并起来即可,就是最终的join结果。
方案实现原理:

对于join导致的数据倾斜,如果只是某几个key导致了倾斜,可以将少数几个key分拆成独立RDD,并附加随机前缀打散成n份去进行join,此时这几个key对应的数据就不会集中在少数几个task上,而是分散到多个task进行join了。具体原理见下图。

方案优点:

对于join导致的数据倾斜,如果只是某几个key导致了倾斜,采用该方式可以用最有效的方式打散key进行join。而且只需要针对少数倾斜key对应的数据进行扩容n倍,不需要对全量数据进行扩容。避免了占用过多内存。

方案缺点:

如果导致倾斜的key特别多的话,比如成千上万个key都导致数据倾斜,那么这种方式也不适合。

在这里插入图片描述

// 首先从包含了少数几个导致数据倾斜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(skewedUserid2infoRDD)
        .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。
JavaPairRDD<Long, Tuple2<String, Row>> joinedRDD2 = commonRDD.join(rdd2);

// 将倾斜key join后的结果与普通key join后的结果,uinon起来。
// 就是最终的join结果。
JavaPairRDD<Long, Tuple2<String, Row>> joinedRDD = joinedRDD1.union(joinedRDD2);
解决方案七:使用随机前缀和扩容RDD进行join
方案适用场景:

如果在进行join操作时,RDD中有大量的key导致数据倾斜,那么进行分拆key也没什么意义,此时就只能使用最后一种方案来解决问题了。

方案实现思路:

该方案的实现思路基本和“解决方案六”类似,

  1. 首先查看RDD/Hive表中的数据分布情况,找到那个造成数据倾斜的RDD/Hive表,比如有多个key都对应了超过1万条数据。
  2. 然后将该RDD的每条数据都打上一个n以内的随机前缀。
  3. 同时对另外一个正常的RDD进行扩容,将每条数据都扩容成n条数据,扩容出来的每条数据都依次打上一个0~n的前缀。
  4. 最后将两个处理后的RDD进行join即可。
方案实现原理:

将原先一样的key通过附加随机前缀变成不一样的key,然后就可以将这些处理后的“不同key”分散到多个task中去处理,而不是让一个task处理大量的相同key。该方案与“解决方案六”的不同之处就在于,上一种方案是尽量只对少数倾斜key对应的数据进行特殊处理,由于处理过程需要扩容RDD,因此上一种方案扩容RDD后对内存的占用并不大;而这一种方案是针对有大量倾斜key的情况,没法将部分key拆分出来进行单独处理,因此只能对整个RDD进行数据扩容,对内存资源要求很高

方案优点:

join类型的数据倾斜基本都可以处理,而且效果也相对比较显著,性能提升效果非常不错。

方案缺点:

该方案更多的是缓解数据倾斜,而不是彻底避免数据倾斜。而且需要对整个RDD进行扩容,对内存资源要求很高。

方案实践经验:

曾经开发一个数据需求的时候,发现一个join导致了数据倾斜。优化之前,作业的执行时间大约是60分钟左右;使用该方案优化之后,执行时间缩短到10分钟左右,性能提升了6倍。

// 首先将其中一个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>(0 + "_" + 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);
解决方案八:多种方案组合使用

在实践中发现,很多情况下,如果只是处理较为简单的数据倾斜场景,那么使用上述方案中的某一种基本就可以解决。但是如果要处理一个较为复杂的数据倾斜场景,那么可能需要将多种方案组合起来使用。比如说,我们针对出现了多个数据倾斜环节的Spark作业,可以先运用解决方案一和二,预处理一部分数据,并过滤一部分数据来缓解;其次可以对某些shuffle操作提升并行度,优化其性能;最后还可以针对不同的聚合或join操作,选择一种方案来优化其性能。大家需要对这些方案的思路和原理都透彻理解之后,在实践中根据各种不同的情况,灵活运用多种方案,来解决自己的数据倾斜问题。

解决方案九:repartition

这个也是较常用的方法,它的本质就是减少 task处理的数据量,一般发生在shuffle之前,当然它本身也是个shuffle操作。

结语

上述为笔者目前知道的数据倾斜处理的方案和遇到的部分情形,以后若在遇到在来更新。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

扫地增

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值