分区的意义
在Spark这类分布式程序中,通信的开销非常大。控制数据分区的意义就在于,通过合理的数据分布减少网络传输从而提升性能。对数据进行分区主要用于优化基于键的操作。比如我们整理出要给用户推荐的召回结果,在推荐之前先用其最近浏览结果进行一次过滤:
from pyspark import SparkConf, SparkContext
conf = SparkConf().setMaster('local').setAppName('MyApp')
sc = SparkContext(conf=conf)
user_recall = sc.parallelize([
('uid001', ('item001', 'item003', 'item006', 'item009', 'item012')),
('uid002', ('item003', 'item004', 'item007', 'item011', 'item015')),
('uid003', ('item002', 'item005', 'item008', 'item010', 'item011')),
('uid004', ('item001', 'item004', 'item007', 'item012', 'item014')),
('uid005', ('item005', 'item008', 'item009', 'item014', 'item015'))
])
latest_impressions = sc.parallelize([
('uid001', ('item006', 'item003')),
('uid004', ('item001',)),
('uid002', ('item009', 'item003'))
])
def remove_impre(rdd):
uid, rec_impre = rdd
rec_list, impre_list = rec_impre
if impre_list is None:
return uid, rec_list
else:
filtered = []
for item in rec_list:
if item not in impre_list:
filtered.append(item)
return uid, tuple(filtered)
def filter_impressions():
joined = user_recall.leftOuterJoin(latest_impressions)
return joined.map(remove_impre)
print(filter_impressions().collectAsMap())
'''
{'uid001': ('item001', 'item009', 'item012'),
'uid002': ('item004', 'item007', 'item011', 'item015'),
'uid003': ('item002', 'item005', 'item008', 'item010', 'item011'),
'uid004': ('item004', 'item007', 'item012', 'item014'),
'uid005': ('item005', 'item008', 'item009', 'item014', 'item015')}
'''
上面的代码可以进行曝光过滤,但是考虑到latest_impressions需要实时更新,这个过滤操作可能会被经常调用,而上面的代码每次都会执行join()操作,导致代码效率很低。
实际工作中,存储全量用户的user_recall表要比一直更新的latest_impressions表大很多,并且没有那么频繁的更新。一种比较常见的解决方式是,调用partitionBy()函数,先将user_recall表进行分区,再进行持久化。这样这张user_recall表就不需要在每次调用join()的时候进行数据混洗了。
user_recall = sc.parallelize([
('uid001', ('item001', 'item003', 'item006', 'item009', 'item012')),
('uid002', ('item003', 'item004', 'item007', 'item011', 'item015')),
('uid003', ('item002', 'item005', 'item008', 'item010', 'item011')),
('uid004', ('item001', 'item004', 'item007', 'item012', 'item014')),
('uid005', ('item005', 'item008', 'item009', 'item014', 'item015'))
]).partitionBy(numPartitions=5).persist()
为user_recall的键指定分区后,每次使用user_recall的键时,Spark都能知道它的键是根据键的哈希值进行过分区的。这样,当user_recall调用join()以连接latest_impressions时,Spark仅会latest_impressions进行数据混洗,然后将特定的键发送到对应的user_recall所在的机器上,从而降低网络传输数据的开销。
这里需要注意两点,第一是partitionBy()是一个转化操作,它返回的是新的RDD,而不是去修改原先的RDD。因此务必将partitionBy()的结果持久化,并保存为一个新的RDD。第二是参数numPartitions表示分区数,同时也会控制这个RDD后面操作的并行任务数,因此这个值一般和集群中的总核心数一致。
PySpark也支持自定义的分区方式,只要将自定义的哈希函数作为参数传递给partitionBy()就可以了。比如仅根据uid的最后两位进行哈希:
def uid_hash(uid):
return hash(uid[-2:])
user_recall = user_recall.partitionBy(numPartitions=2, partitionFunc=uid_hash).persist()
保留分区信息
在Spark的一些转化操作中,如果该操作可以获取到父RDD的分区信息,那这些操作会将这些信息设定在返回的RDD结果中。同时,很多操作也可以利用已知的RDD分区信息,比如sortByKey()和groupByKey(),他们分别生成范围分区的RDD和哈希分区的RDD。但是类似map()这样可以修改键的操作,它返回的RDD并不会记录父RDD的分区信息。Spark提供了替代方案mapValues(),可以保证仅操作值而不更改键,因此结果可以保留父RDD的分区信息。同样的还有flatMapValues()。
x = sc.parallelize([("a", ["apple", "banana", "lemon"]), ("b", ["grapes"])])
result = x.mapValues(lambda x: len(x)).collect()
print(result)
# [('a', 3), ('b', 1)]
一般情况下,一个pair RDD的操作结果的分区,取决于父RDD的分区方式,默认为哈希分区,且分区数也一致。如果两个父RDD都设置了自己的分区方式,那结果会按照第一个父RDD的方式进行分区。