Learning Spark中文版--第四章--使用键值对(1)
本章介绍了如何使用键值对RDD,Spark中很多操作都基于此数据类型。键值对RDD通常在聚合操作中使用,而且我们经常做一些初始的ETL(extract(提取),transform(转换)和load(加载))来把数据转化成键值对格式。键值对中有很多新操作(如,计算每个产品的评价,对相同键的数据进行分组,将两个不同的RDD组合在一起)。
我们还将讨论一种高级特性,可以让用户控制节点间的RDD的布局:partitioning(分区)。通过使用可控的分区,应用程序可以确保访问单个节点上的数据从而大大减少通信带来的成本。这可以带来显著的提速。我们使用PageRank算法作为例子来解读partitioning(分区)。为分布式数据集合选择正确的分区就如在单机上选择正确的数据结构一样,优秀的数据布局可以极大地影响性能。
Motivation
Spark为键值对RDD提供了特别的操作。这些RDD被称作键值对RDD。键值对RDD在很多程序中是个有用的组件,因为他们中的操作允许你并行地处理每个键或者在网络中对数据重新分组。举个例子,键值对RDD有个方法reduceBykey()
可以为每个键分别汇总数据,还有一个join()
方法可以根据相同的键合并两个RDD。从RDD中提取字段是很常见的(如事件时间,用户id,或者标识符),并且这些字段可以作为键值对RDD操作中的键。
Creating Pair RDDs
Spark中有很多获取键值对RDD的方式。我们会在第五章讨论大量的直接生成键值对RDD的键值数据的格式。在其他情况中我们有一个想要转换成键值对RDD的常规RDD。我们可以通过使用map()
函数来返回键值对。为了便于说明,我们展示的代码从一个文本RDD的每一行开始,生成文本行第一个单词作为键,行作为值的键值对RDD。
创建键值对RDD在不同语言中有所不同。在Python中,为了使输入的数据能用,我们需要返回由元组组成的RDD。
Example 4-1. Creating a pair RDD using the first word as the key in Python
pairs = lines.map(lambda x: (x.split(" ")[0], x))
在Scala中,也是使用的元组。在元组RDD中的隐式转换提供了额外的键值对函数。
Example 4-2. Creating a pair RDD using the first word as the key in Scala
val pairs = lines.map(x => (x.split(" ")(0), x))
Java自身没有元组这种数据类型,所以Spark的JavaApi让用户利用scala.Tuple2
类创建元组。这个类很简单:Java用户可以通过new Tuple2(elem1,elem2)
创建一个新的元组并且可以通过._1()
和._2()
方法来取元组中的元素。
Java用户当创建键值对RDD时需要调用Spark函数的特殊版本。举个例子,mapToPair()
函数应该在使用基础map()
函数的地方使用。43页会详细讨论,我们先来看看这个简单的例子:
Example 4-3. Creating a pair RDD using the first word as the key in Java
PairFunction<String, String, String> keyData =
new PairFunction<String, String, String>() {
public Tuple2<String, String> call(String x) {
return new Tuple2(x.split(" ")[0], x);
}
};
JavaPairRDD<String, String> pairs = lines.mapToPair(keyData);
当我们使用Scala和Python从内存中创建一个键值对RDD时,我们只需要在一个键值对集合上调用SarkContext.parallelize()
。如果使用Java,则调用SparkContext.parallelizePairs()
。
Transformations on Pair RDDs
标准RDD上的所有转换键值对RDD都可以使用。30页中介绍的传递函数到Spark的规则也适用于键值对RDD。因为键值对RDD包含元组,我们需要传递操作元组而不是单独元素的函数。表4-1和4-2总结了键值对RDD的转换,并且我们会在稍后会深入细节。
表4-1 键值对RDD转换(示例RDD:{(1,2),(3,4)(3,6)})
函数名 | 目的 | 例子 | 结果 |
---|---|---|---|
reduceByKey(func) | 把相同键的值结合 | rdd.reduceByKey((x,y)=>x+y) | |
groupByKey() | 根据相同的键分组值 | rdd.groupByKey() | |
combienByKey(createCombiner,mergeValue,mergeCombiner,partitioner) | 将相同键的值结合成不同的返回类型 | 例子4-12到4-14 | |
mapValues(func) | 在不改变key的情况下对每个RDD中的值应用func | rdd.mapValues(x =>x+1) | |
flatMapValues(func) | 函数作用于RDD每个值生成一个迭代器,最终产生一个旧键和迭代器中每个元素组成的键值对。通常用来标记 | rdd.flatMapValues(x =>(x to 5)) | |
keys() | 返回所有的键 | rdd.keys() | |
values | 返回所有的值 | rdd.values | |
sortByKey() | 返回根据键排序的RDD | rdd.sortByKey |
表4-1 两个键值对RDD转换(rdd:{(1,2),(3,4)(3,6)}; other = {(3,9)})
函数名 | 目的 | 例子 | 结果 |
---|---|---|---|
subtractByKey | 删除本RDD中与另一个RDD中键相同的元素 | rdd.subtractByKey(other) | |
join | 两个RDD内连接 | rdd.join(other) | |
rightOuterJoin | 右外连接,第一个RDD中的键必须存在 | rdd.rightOuterJoin(other) | |
leftOuterJoin | 左外链接,参数RDD中的键必须存在 | rdd.leftOuterJoin(other) | |
cogroup | 两个RDD根据相同的key分组数据 | rdd.cogroup(other) |
我们会在后面逐个讨论这些相似的键值对RDD函数。
键值对RDD仍然是元组RDD(Java/Scala中的Tuple2对象或Python中的元组),所以支持RDD那些函数。例如,我们可以像之前章节那样获取RDD并且过滤超过20字符的行,示例如下:
Example 4-4. Simple filter on second element in Python
result = pairs.filter(lambda keyValue: len(keyValue[1]) < 20)
Example 4-5. Simple filter on second element in Scala
pairs.filter{case (key, value) => value.length < 20}
Example 4-6. Simple filter on second element in Java
Function<Tuple2<String, String>, Boolean> longWordFilter =
new Function<Tuple2<String, String>, Boolean>() {
public Boolean call(Tuple2<String, String> keyValue) {
return (keyValue._2().length() < 20);
}
};
JavaPairRDD<String, String> result = pairs.filter(longWordFilter);
有时候你只需要键值对RDD中的值会有一些尴尬。好在SPark提供了mapValue(func),这个函数等同于map{case(x,y):(x,func(y))}。很多例子中我们都是用了这个函数。下面开始逐个讨论键值对函数家族成员,从聚合开始。
Aggregations
当数据是键值对格式的,通常会需要根据相同的键聚合所有元素的统计数据。之前我们已经看过了基础RDD上的fold()
,combine()
,reduce()
操作,简直对上的转换其实很类似。Spark有类似的操作把相同键的值组合在一起。这些操作返回RDD所以他们是transformation(转换)而不是action(动作)。
reduceByKey() 和reduce()
真的很相似,都是以一个函数作为参数并由该函数将值组合起来。reduceByKey()
运行多个并行地reduce操作,数据中的每个键都是一个reduce操作,每个reduce把键相同的值结合起来。因为数据可能有大量的键,所以reduceByKey()
没有作为给用户程序返回值的action(动作)实现。相反,返回了一个由每个键和键对应聚合后的值组成的新的RDD。
foldByKey() 和fold()
十分类似;都有一个和RDD数据类型相同的初始值和一个结合函数作为参数。和fold()
函数一样,结合函数作用于另一个元素时不会受到foldByValue()初始值的影响。
我们可以使用reduceByKey()
和mapValues()
来计算每个键的平均值,这很类似与使用fold()
和map()
计算整个RDD的平均值(如图 4-2)。与求平均一样,我们可以用更专业的函数来求平均值,后面会介绍。
Example 4-7. Per-key average with reduceByKey() and mapValues() in Python
rdd.mapValues(lambda x: (x, 1)).reduceByKey(lambda x, y: (x[0] + y[0], x[1] + y[1]))
Example 4-8. Per-key average with reduceByKey() and mapValues() in Scala
rdd.mapValues(x => (x, 1)).reduceByKey((x, y) => (x._1 + y._1, x._2 + y._2))
熟悉MapReduce的combiner概念的人应该注意,在计算每个键的全局总数之前,调用
reduceByKey()
和foldByKey()
将自动在每台机器上本地执行组合。用户不用再制定一个combiner。更常规的combineByKey()
接口允许你自定义组合行为。
我们可以使用相同的方法实现经典的分布式单词计数问题(Example4-9到4-11)。我们使用之前章节提到的flatMap()
构造一个单词和数字1组成的键值对RDD然后通过reduceByKey()
计算单词总数(Example4-7到4-8)。
Example 4-9. Word count in Python
rdd = sc.textFile("s3://...")
words = rdd.flatMap(lambda x: x.split(" "))
result = words.map(lambda x: (x, 1)).reduceByKey(lambda x, y: x + y)
Example 4-10. Word count in Scala
val input = sc.textFile("s3://...")
val words = input.flatMap(x => x.split(" "))
val result = words.map(x => (x, 1)).reduceByKey((x, y) => x + y)
Example 4-11. Word count in Java
JavaRDD<String> input = sc.textFile("s3://...")
JavaRDD<String> words = rdd.flatMap(new FlatMapFunction<String, String>() {
public Iterable<String> call(String x) { return Arrays.asList(x.split(" ")); }
});
JavaPairRDD<String, Integer> result = words.mapToPair(
new PairFunction<String, String, Integer>() {
public Tuple2<String, Integer> call(String x) { return new Tuple2(x, 1); }
}).reduceByKey(
new Function2<Integer, Integer, Integer>() {
public Integer call(Integer a, Integer b) { return a + b; }
});
其实我们可以更简洁地实现单词计数,在第一个RDD上使用
countByValue()
函数:
input.flatMap(x => x.split(" ")).countByValue()
。
combineByKey()
是最常用来根据键聚合的函数。大多数其他键组合函数都是在它基础上实现的。想aggregate()
,combineByKey()
允许使用者返回和输入类型数据不同的值。
思考如何处理每个元素的过程对于理解combineByKey()
函数很有帮助。由于combineByKey()
遍历分区中的元素,每个元素要么有一个它以前没有见过的键,要么与之前的元素有相同的键。
如果遇到一个新元素,combineByKey()
使用我们提供的一个叫做createCombiner()
的函数,来创建为这个键积累的初始值。需要注意的一点是创建发生在每个分区第一次发现一个新键的时候,而不是只有第一次在RDD上发现新键的时候。
如果处理分区时有些值我们之前已经见过了。那么它将把累加器提供的最近的值和新值应用在提供的mergeValue()
函数上。
因为每个分区的处理郭成都是独立的,对于相同的键我们可以有多个累加器。当我们将每个分区的结果合并的时候,如果两个或多个分区有有相同键的累加器,我们通过用户提供的mergeCombiners()
函数把累加器合并。
如果我们知道
combineByKey()
的map-side聚合(map-side:了解hadoop的mapreduce的应该知道hadoop中的数据处理有两个组件map和reduce,map用来把数据进行映射成不同类型,reduce用来对映射好的数据计算,map-side的聚合可以理解为映射时的聚合,有时候可以提高效率,有时候没什么用还浪费内存)没有什么好处的话我们可以禁用它。例如,groupByKey()
禁用map-side聚合函数,因为聚合函数(添加到一个列表上)不能节省任何空间。如果我们想要禁用map-side的组合,我们需要指定(partitioner)分区器,现在你可以通过传递rdd.partitioner
使用源RDD上的分区器。
因为combineByKey()
有很多不同的参数所以他是一个很好的例子。为了更好地理解combineByKey()
如何工作,我们看一个计算每个键平均值的例子(Example4-12到4-14,图4-3)。
Example 4-12. Per-key average using combineByKey() in Python
sumCount = nums.combineByKey((lambda x: (x,1)),
(lambda x, y: (x[0] + y, x[1] + 1)),
(lambda x, y: (x[0] + y[0], x[1] + y[1])))
sumCount.map(lambda key, xy: (key, xy[0]/xy[1])).collectAsMap()
Example 4-13. Per-key average using combineByKey() in Scala
val result = input.combineByKey(
(v) => (v, 1),
(acc: (Int, Int), v) => (acc._1 + v, acc._2 + 1),
(acc1: (Int, Int), acc2: (Int, Int)) => (acc1._1 + acc2._1, acc1._2 + acc2._2)
).map{ case (key, value) => (key, value._1 / value._2.toFloat) }
result.collectAsMap().map(println(_))
Example 4-14. Per-key average using combineByKey() in Java
public static class AvgCount implements Serializable {
public AvgCount(int total, int num) { total_ = total; num_ = num; }
public int total_;
public int num_;
public float avg() { return total_ / (float) num_; }
}
Function<Integer, AvgCount> createAcc = new Function<Integer, AvgCount>() {
public AvgCount call(Integer x) {
return new AvgCount(x, 1);
}
};
Function2<AvgCount, Integer, AvgCount> addAndCount =
new Function2<AvgCount, Integer, AvgCount>() {
public AvgCount call(AvgCount a, Integer x) {
a.total_ += x;
a.num_ += 1;
return a;
}
};
Function2<AvgCount, AvgCount, AvgCount> combine =
new Function2<AvgCount, AvgCount, AvgCount>() {
public AvgCount call(AvgCount a, AvgCount b) {
a.total_ += b.total_;
a.num_ += b.num_;
return a;
}
};
AvgCount initial = new AvgCount(0,0);
JavaPairRDD<String, AvgCount> avgCounts =
nums.combineByKey(createAcc, addAndCount, combine);
Map<String, AvgCount> countMap = avgCounts.collectAsMap();
for (Entry<String, AvgCount> entry : countMap.entrySet()) {
System.out.println(entry.getKey() + ":" + entry.getValue().avg());
}
有很多操作可以根据键聚合数据。大多数都是在combineByKey()
上实现的,但是提供了更简单的接口。在任何情况下,在Spark中使用专门的聚合函数比使用简单的分组数据再进行计算快得多。
Tuning the level of parallelism
目前为止,我们已经讨论了transformation(转换)是如何分配的,但是我们还没有了解Spark是如何决定工作是怎样分配的。每个RDD都有固定数量的分区,这个值决定了在RDD上执行操作的并行度。
当我们执行聚合或分组操作时,我们可以明确地设置一个分区的个数。Spark总实惠根据集群的大小推断一个合理的默认并行值,但是有些情况你想调整并行的级别来提升性能。
本章讨论的绝大多数操作接受第二个参数作为创建分组或聚合RDD时的分区数。示例如下:
Example 4-15. reduceByKey() with custom parallelism in Python
data = [("a", 3), ("b", 4), ("a", 1)]
sc.parallelize(data).reduceByKey(lambda x, y: x + y) # Default parallelism
sc.parallelize(data).reduceByKey(lambda x, y: x + y, 10) # Custom parallelism
Example 4-16. reduceByKey() with custom parallelism in Scala
val data = Seq(("a", 3), ("b", 4), ("a", 1))
sc.parallelize(data).reduceByKey((x, y) => x + y) // Default parallelism
sc.parallelize(data).reduceByKey((x, y) => x + y) // Custom parallelism
有事,我们想要在分组操作上下文之外改变RDD的分区。对于这种情况,Spark提供了repartition()
函数,这个函数会通过网络shuffle(混洗)数据来创建一个新的分区集合。记住重新分区数据是个代价高昂的操作。Spark还有个repartition()
函数的优化版本叫做coalesce()
,这个函数避免了数据移动,但是只有你在收缩RDD分区时可用。为了确保你是否可以安全的调用coalesce()
,你可以在Java/Scala中使用rdd.partitions.size()
,Python中使用rdd.getNumPartitions()
来检查RDD的大小,这样可以确保你把RDD合并到比当前更少的分区。
Grouping Data
对于键数据,一个常用的情况就是根据键进行分组。例如,查看所有用户的订单。
如果我们的键数据已经我们满足我们的想法,groupByKey()
会根据我们RDD上的键分组数据。如果RDD上的键是K类型,值是V类型,我们拿到的结果RDD是[K,Iterable[V]]。
groupBy()
函数用来处理非键值对数据或者我们想要使用键相等之外的不同条件来处理的数据。它使用一个函数应用到源RDD上的每一个元素并且返回的结果就是新RDD的键。
你是否发现使用
groupByKey()
函数,然后在值上使用reduce或fold函数,相比使用按键聚合函数可以更高效地得到相同的结果。(按键聚合函数)不是把整个RDD归约到内存中,而是根据每个键归约并且返回一个把值归约到相同键中的RDD。举个例子,rdd.reduceByKey(func)
最终产生了和rdd.groupByKey().mapValues(value => value.reduce(func))
相同的RDD,但是后者减少了为每个键创建一个值列表的步骤所以更高效。
除此之外,从一个RDD中分组数据,我们可以使用cogroup()
函数 在多个RDD中共享键然后进行数据分组。cogroup()
在两个RDD上共享键,如键的类型是K,值的类型是V和W,返回结果就是RDD[(K,(Iterable[v],Iterable[w]))]。如果其中一个RDD对于另一个RDD给定的键没有相应的元素,那对应的Iterable就是空的。cogroup()
的威力可以让我们在多个RDD上进行分组数据。后面章节我们将讨论cogroup()
用来作为join的组件的功能。
cogroup()
不止可以用来实现join,我们可以通过它来实现根据键的内联,另外,cogroup()
可以一次在三个或更多的RDD上使用。
Joins
键控数据(keyed data)中一些最有用的数据就是自身结合其他键控数据一起使用。把数据连接起来应该是键值对RDD最常用的操作。我们有很全面的选择,包括左右外连接,交叉连接和内连接。
内连接(数据库术语)是一个简单的连接操作。只有两个键值对RDD共有的键才会在输出中展示。当有一个输入的键有多个值,结果键值对RDD包含的键值对由双方共有的键和值的所有可能组成。示例如下:
Example 4-17. Scala shell inner join
storeAddress = {
(Store("Ritual"), "1026 Valencia St"), (Store("Philz"), "748 Van Ness Ave"),
(Store("Philz"), "3101 24th St"), (Store("Starbucks"), "Seattle")}
storeRating = {
(Store("Ritual"), 4.9), (Store("Philz"), 4.8))}
storeAddress.join(storeRating) == {
(Store("Ritual"), ("1026 Valencia St", 4.9)),
(Store("Philz"), ("748 Van Ness Ave", 4.8)),
(Store("Philz"), ("3101 24th St", 4.8))}
有时我们不需要双方的键都在结果中。举个例子,我们想连接用户信息和推荐信息但是即使没有推荐信息也不想删掉用户信息。leftOuterJoin(other) 和 rightOuterJoin(other)都是根据键连接键值对RDD的函数,即使一方RDD可能没有这个键。
leftOuterJoin()的结果RDD拥有源RDD中的每个键值对。在结果中每个键关联的值是来自源RDD值和另一个RDD值的一个Option(或Java中的Optional)对象组成的元组。在Python中,如果值不存在,就用None;如果值存在,并且不是被包装的常规值,就使用这个值。和join()
类似,我们可以为每个键建立多个entry,当发生这种情况的时候,我们会得到两个值列表之间的笛卡尔乘积。
Optional类是谷歌Guava库的一部分,用来表示一个可能丢失的值。我们可以通过
isPresent()
检查值是否存在,get()
会返回其中包含的数据。
rightOuterJoin()
和leftOuterJoin()
几乎相同,除了键必须是另一个RDD中的,并且元组中有源RDD而不是另外RDD的值的Option对象。
leftOuterJoin()
和rightOuterJoin()
示例如下:
Example 4-18. leftOuterJoin() and rightOuterJoin()
storeAddress.leftOuterJoin(storeRating) ==
{(Store("Ritual"),("1026 Valencia St",Some(4.9))),
(Store("Starbucks"),("Seattle",None)),
(Store("Philz"),("748 Van Ness Ave",Some(4.8))),
(Store("Philz"),("3101 24th St",Some(4.8)))}
storeAddress.rightOuterJoin(storeRating) ==
{(Store("Ritual"),(Some("1026 Valencia St"),4.9)),
(Store("Philz"),(Some("748 Van Ness Ave"),4.8)),
(Store("Philz"), (Some("3101 24th St"),4.8))}
Sorting Data
排了序的数据在很多情况下都很有用,特别是当你向下游输出的时候。假如键上定义了排序,我们可以根据键对RDD排序。一旦我们排序完数据,任何对该数据的collect()
或save()
的后续调用都是有序的。
我们经常会想把数据倒序处理,sortByKey()
函数接收一个叫参数ascending用来确定我们是否使用升序(默认是true)。有时我们想要一个完全不同的排序,为此我们可以提供一个自己的比较函数。示例4-19到4-21中,我们将通过将整数转换为字符串并使用字符串比较函数来对RDD进行排序。
Example 4-19. Custom sort order in Python, sorting integers as if strings
rdd.sortByKey(ascending=True, numPartitions=None, keyfunc = lambda x: str(x))
Example 4-20. Custom sort order in Scala, sorting integers as if strings
val input: RDD[(Int, Venue)] = ...
implicit val sortIntegersByString = new Ordering[Int] {
override def compare(a: Int, b: Int) = a.toString.compare(b.toString)
}
rdd.sortByKey()
Example 4-21. Custom sort order in Java, sorting integers as if strings
class IntegerComparator implements Comparator<Integer> {
public int compare(Integer a, Integer b) {
return String.valueOf(a).compareTo(String.valueOf(b))
}
}
rdd.sortByKey(comp)