《高性能spark》中的金发女孩问题

《高性能spark》这本书用了一整章,超过50页的篇幅介绍了一个复杂的数据处理问题。书中提供了4种解决方案和数据倾斜下的解决方案。

金发女孩问题可以这样描述:假设有一亿行,一百列的数据。具体一点,比方说,一块电池有100个电芯,那么这一行数据就是100个电芯的电压,这一亿行数据是这块电池采集到的不同时间的电压数据。然后金发女孩想知道每一个电芯第3,第30,000,第3,000,000,第30,000,000高的电压值。

第一种方案,循环100次,每次对一个列排序。这个方法比较直接。但缺点是如果有8000个电芯,那就要循环8000次。

后面的方案,都要先把这张宽表转换成长表,这个长表有两个字段,一个是电芯编号,一个是电压值。这可以用flatMap来实现。

第二种方案,是备受争议的groupByKey。这种方法也很直接,并且相比方案一,并行度大大提升了。这里面用到的key就是电芯编号。groupByKey会把key对应的值全部载入内存,通常把这些值转成数组,然后迭代处理。groupByKey一度非常流行,现在仍有一些人坚持使用,他们声称这种方法更接近底层,所以更快。但是一次载入内存已经被公认内存消耗太大,在一个有很多用户使用的集群环境中,这种方法要么经常挂掉,要么严重影响他人的任务。对于大多数的业务数据处理,groupByKey都可以被map端合并的方法(例如reduceByKey)或者窗口函数替代。

第三者方案,作者引入了一个更底层的函数:repartitionAndSortWithinPartitions。通过自定义一个分区器,可以用这个函数实现“按键分组并按值排序”的功能。事实上,作者实现的是row_number()这个窗口函数。如果用窗口函数,这个问题的sql不到10行。我认为,这也是最主流、使用最广泛的方法。

但作者并没有就此停下。使用窗口函数的方案意味着按键分区。由于我们一个电芯有一亿条数据,所以一个分区也至少一亿条数据。如果单个分区太大,仍有可能失败。spark sql对数据压缩做了精心优化,我的经验是这种情况可能性不大,但是如果资源条件非常苛刻,有没有其他办法呢?

第四种方案,作者对分区内的数据做了更精细的处理。这次不再按电芯编号分区,而是直接对所有电压值全排序。我们知道spark中sortByKey使用的分区器是RangePartitioner。注意,第三种方案每个分区一亿条数据,所以不会数据倾斜。但如果是全排序,还是有可能倾斜的。这里先假设数据比较均匀。

排序之后我们可以得到分区编号。然后我们统计一下每个分区,每个电芯有几条数据。假设对于1号电芯,前30个分区有29000条数据,前31个分区有31000条数据,那么1号电芯第30000高的电压就在第31个分区的第1000条数据中。

作者是用rdd的sortByKey和mapPartitions实现的。每个分区每个电芯有几条数据,这个存到一个数组收集到driver端。mapPartitions的使用要非常小心,因为它传入的参数是一个Iterator。《高性能spark》5.4节做出了解释。如果你的代码是迭代器到迭代器的转换,那么spark可以一条一条处理,而不是把所有数据一次载入内存,所以迭代器到迭代器的转换节省资源、效率高。什么是迭代器到迭代器的转换,理解起来比较绕,这里先略过。

第四种方案能不能用sql实现呢?我认为可以。spark sql有一个函数spark_partition_id。在方案三中,我们按电芯编号分区。而现在,当我们统计每个电芯在每个分区的条数时,我们是按电芯编号+分区编号再分区。这样的分区,会比方案三的分区小很多。这让我们想到当发生数据倾斜时,给键加盐的那个方法。给键加盐,就是加一个随机数把分区打散。

最后再补充一下数据倾斜的处理。作者提到,假设有25%的电压值是0的情况。作者提供的方案是全排序之前按值聚合,也就是count group by 电压值。然后统计每个分区每个电芯数据量时,只要修改成count值加和即可。当然还有其它方法。比如我们可以先用抽样的方法找到哪些值数量特别大,这里假设0特别多。在这个特殊问题中我们可以把0直接过滤掉然后再用方案四,对吧,哈哈。

-- 常规方案。当group_id包含的数据量非常大时,可能会失败
-- 但是sql的压缩率很高,我认为group_id包含上亿条数据也不容易失败
select group_id, val
from
(select group_id, val,
  row_number() over(partition by group_id order by val) rn
from table
)
where rn in (3,94)


-- 缓存这张表 table1
select group_id, val, spark_partition_id() pid
from
(select group_id, val
from table
order by val
)

-- 用上面缓存的表table1,对每个分区的group_id计数,记作table2
select group_id, pid,
  count(1) cnt
from table1
group by 1,2

-- 对table2,确定每个group_id排名第3和第94的元素所在的pid和位置
-- 记作table3
select group_id, pid, cnt, 
  3 - last_sum_cnt shift3,
  94 - last_sum_cnt shift94
from
(select group_id, pid, cnt, sum_cnt,
  case when lag(sum_cnt) over(partition by group_id, pid order by pid, group_id) is null then 0 
       else lag(sum_cnt) over(partition by group_id, pid order by pid, group_id) end
           last_sum_cnt
from
(select group_id, pid, cnt,
  sum(cnt) over(partition by group_id, pid order by pid, group_id) sum_cnt
from table2 
)
)
where 3 - last_sum_cnt between 1 and cnt
  or 94 - last_sum_cnt between 1 and cnt


-- 最后,把table3广播join到table1上,关联的键是group_id和pid,得到每个group_id排名第3和94的元素对应的值

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值