文章目录
- 什么是数据倾斜
- 数据倾斜的现象
- 数据倾斜产生的原因
- 数据倾斜产生的原理
- 产生数据倾斜的操作
- 不同情形倾斜数据处理方案
- Hql和SparkSql中处理
- Spark解决数据倾斜具体方法
- 结语
注意,要区分开数据倾斜与数据量过量这两种情况,
数据倾斜
是指少数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 Streaming
和Spark Sql
,表现主要有下面几种:
Executor lost
,OOM
,Shuffle过程出错
Driver OOM
单个Executor执行时间特别久,整体任务卡在某个阶段不能结束
正常运行的任务突然失败
补充一下,在
Spark streaming程序中,数据倾斜更容易出现
,特别是
在程序中包含一些类似sql的join、group这种操作的时候
。 因为Spark Streaming程序在运行的时候,我们一般不会分配特别多的内存,因此一旦在这个过程中出现一些数据倾斜,就十分容易造成OOM。
Hive中的数据倾斜
任务进度长时间维持在
99%(或100%)
,查看任务监控页面,发现只有少量(1个或几个)reduce
子任务未完成。因为其处理的数据量和其他reduce
差异过大。单一reduce
的记录数与平均记录数差异过大,通常可能达到3
倍甚至更多。 最长时长远大于平均时长。
经验:
Hive
的数据倾斜,一般都发生在Sql
中Group By
和Join On
上,而且和数据逻辑绑定比较深。
数据倾斜产生的原因
Hive数仓为何会出现DateSkew
数仓中发生数据倾斜的根本原因就是:某值的的数量过多。导致谋职的数量过多的原因有三个。
产生原因:
- key 分布不均匀
- 业务数据本身的特性
- 空值,大量的游客用户登录访问。
- 数据类型不匹配,使用从
mysql
数据导过来的数据和hive
数仓里的数据做联合查询的时候,可能某个字段mysql
里的是int
类型,hive
里是string
类型,这些string
类型的数据就会积压在一起。- 表结构本身有问题,比如,这个地区字段,都是市,北京市是一个值,太原市是一个值,到时候肯定会北京市的数据量特别大导致数据倾斜。这个例子比较极端,只是想说地区人口差异很大,数据量也会有很大差距。
Spark为何会出现DateSkew
数据倾斜的原因:
出现数据倾斜的原因,基本只可能是因为发生了
shuffle
操作,在shuffle
的过程中,出现了数据倾斜的问题。因为某个或者某些key
对应的数据,远远的高于其他的key
。Shuffle
数据之后导致数据分布不均匀,但是所有节点的机器的性能都是一样的,程序也是一样的,就是数据量不一致,所以决定了task
的执行时长就被数据量决定了。
数据分区的策略:
随机分区:
每一个数据分配的任意一个分区的概率是均等的Hash分区:
使用数据的Hash分区值,%分区数。(导致数据倾斜的原因)范围分区:
将数据范围划分,数据分配到不同的范围中(分布式的全局排序)
定位数据倾斜问题
- 查阅代码中会产生
shuffle
的算子,例如distinct、groupByKey、reduceByKey、aggregateByKey、join、cogroup、repartition
等算子,根据代码逻辑判断此处是否会出现数据倾斜。- 查看
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(_))
数据倾斜产生的原理
如果大家对
MapReduce
的shuffle
过程或者Spark
的shuffle
很熟悉的话完全不用看这个图的,因为图片也是笔者随便找的本不想放在这里。多说一句以MR
为例就是由于shuffle
后数据经过HashPartitioner
进行分组后形成数据集的数量均衡造成的。数据分组效果大致如下图:
产生数据倾斜的操作
关键操作 | 情形 | 结果 |
---|---|---|
Join | 其中一个表较小,但是key集中 | 分发到某一个或几个Reduce上的数据远高于平均值 |
大表与大表,但是分桶的判断字段0值或者空值过多 | 这些空值都由一个redice处理,很慢 | |
group by | group 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
表中,否则对于引起倾斜的key
的join
就会优化为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
少了,而且作业数也少了。解决方法1log
表被读取了两次,jobs
是2
。这个适合优化无效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驱动表选取和数据量优化:
- 关于驱动表的选取,
选用join key分布最均匀的表作为驱动表
;- 做好
列裁剪和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 all
的hive bug
,数据是一致的。MR
的作业数由3
减少到1
。t1
相当于一个目录,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;
这种我们不能直接
union
再group 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)或者写个udf
(user-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条数据),而且业务场景需要频繁使用Spark
对Hive
表执行某个分析操作,那么比较适合使用这种技术方案。
方案实现思路:
此时可以评估一下,是否可以通过
Hive
来进行数据预处理(即通过Hive ETL
预先对数据按照key
进行聚合,或者是预先和其他表进行join
),然后在Spark
作业中针对的数据源就不是原来的Hive
表了,而是预处理后的Hive
表。此时由于数据已经预先进行过聚合或join
操作了,那么在Spark
作业中也就不需要使用原先的shuffle
类算子执行这类操作了。
方案实现原理:
这种方案从根源上解决了数据倾斜,因为彻底避免了在
Spark
中执行shuffle
类算子,那么肯定就不会有数据倾斜的问题了。但是这里也要提醒一下大家,这种方式属于治标不治本
。因为毕竟数据本身就存在分布不均匀的问题,所以Hive ETL
中进行group by
或者join
等shuffle
操作时,还是会出现数据倾斜,导致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
中间表,尽可能地减少Spark
的shuffle
操作,大幅度提升了性能,将部分作业的性能提升了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
并行度其实就是增加reduce
端task
的数量, 这样每个task
处理的数据量减少,避免oom
。也就是增加shuffle read task
的数量,可以让原本分配给一个task
的多个key
分配给多个task
,从而让每个task
处理比原来更少的数据。举例来说,如果原本有5
个key
,每个key
对应10
条数据,这5
个key
都是分配给一个task
的,那么这个task
就要处理50
条数据。而增加了shuffle read task
以后,每个task
就分配到一个key
,即每个task
就处理10
条数据,那么自然每个task
的执行时间都会变短了。具体原理如下图所示。
方案优点:
实现起来比较简单,可以有效缓解和减轻数据倾斜的影响。
方案缺点:
只是缓解了数据倾斜而已,没有彻底根除问题,根据实践经验来看,其效果有限。
map
端不断地写入数据,reduce task
不断地从指定位置读取数据,如果task
很多,读取的速度增加,但是每个key
对应的reduce
处理的总量没变,所以它并没有从根本上解决数据倾斜的问题,只是尽量去减少reduce task
的数据量,适用于较多key
对应的数据量都很大的问题;
试想,如果只有1
个key
数据量较大,那么其他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
,
- 通过
sample
算子采样出一份样本来,- 然后统计一下每个
key
的数量,计算出来数据量最大的是哪几个key
。- 然后将这几个
key
对应的数据从原来的RDD
中拆分出来,形成一个单独的RDD
,并给每个key
都打上n
以内的随机数作为前缀,而不会导致倾斜的大部分key
形成另外一个RDD
。- 接着将需要
join
的另一个RDD
,也过滤出来那几个倾斜key
对应的数据并形成一个单独的RDD
,将每条数据膨胀成n
条数据,这n
条数据都按顺序附加一个0~n
的前缀,不会导致倾斜的大部分key
也形成另外一个RDD
。- 再将附加了随机前缀的独立
RDD
与另一个膨胀n
倍的独立RDD
进行join
,此时就可以将原先相同的key
打散成n
份,分散到多个task
中去进行join
了。而另外两个普通的RDD
就照常join
即可。- 最后将两次
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
也没什么意义,此时就只能使用最后一种方案来解决问题了。
方案实现思路:
该方案的实现思路基本和“解决方案六”类似,
- 首先查看
RDD/Hive
表中的数据分布情况,找到那个造成数据倾斜的RDD/Hive
表,比如有多个key
都对应了超过1
万条数据。- 然后将该
RDD
的每条数据都打上一个n
以内的随机前缀。- 同时对另外一个正常的
RDD
进行扩容,将每条数据都扩容成n
条数据,扩容出来的每条数据都依次打上一个0~n
的前缀。- 最后将两个处理后的
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
操作。
结语
上述为笔者目前知道的数据倾斜处理的方案和遇到的部分情形,以后若在遇到在来更新。