《Spark快速大数据分析》笔记Ch4 键值对操作

来源:互联网 发布:nginx 跨域访问配置 编辑:程序博客网 时间:2024/05/29 03:20

  键值对 RDD 是 Spark 中许多操作所需要的常见数据类型。本章就来介绍如何操作键值对RDD。键值对 RDD 通常用来进行聚合计算。我们一般要先通过一些初始 ETL(抽取、转化、装载)操作来将数据转化为键值对形式。
  本章也会讨论用来让用户控制键值对 RDD 在各节点上分布情况的高级特性: 分区。有时,使用可控的分区方式把常被一起访问的数据放到同一个节点上, 可以大大减少应用的通信开销。这会带来明显的性能提升。为分布式数据集选择正确的分区方式和为本地数据集选择合适的数据结构很相似——在这两种情况下,数据的分布都会极其明显地影响程序的性能表现。
1、动机
  Spark 为包含键值对类型的 RDD 提供了一些专有的操作。这些 RDD 被称为 pair RDD。 Pair RDD 是很多程序的构成要素, 因为它们提供了并行操作各个键或跨节点重新进行数据分组的操作接口。我们通常从一个 RDD 中提取某些字段(例如代表事件时间、用户 ID 或者其他标识符的字段),并使用这些字段作为 pair RDD 操作中的键。
2、创建Pair RDD
  在 Spark 中有很多种创建 pair RDD 的方式。很多存储键值对的数据格式会在读取时直接返回由其键值对数据组成的 pair RDD。此外,当需要把一个普通的 RDD 转为 pair RDD 时,可以调用 map() 函数来实现,传递的函数需要返回键值对。
  构建键值对 RDD 的方法在不同的语言中会有所不同。在 Python 中,为了让提取键之后的数据能够在函数中使用,需要返回一个由二元组组成的 RDD。

pairs = lines.map(lambda x: (x.split(" ")[0], x))

  当用 Python 从一个内存中的数据集创建 pair RDD 时,只需要对这个由二元组组成的集合调用 SparkContext.parallelize() 方法。
3、Pair RDD的转化操作
  Pair RDD 可以使用所有标准 RDD 上的可用的转化操作。 由于 pair RDD 中包含二元组,所以需要传递的函数应当操作二元组而不是独立的元素。
这里写图片描述
这里写图片描述
这里写图片描述
  Pair RDD 也还是 RDD,因此同样支持 RDD 所支持的函数。例如,我们可以拿前一节中的 pair RDD,筛选掉长度超过20个字符的行。
  用 Python 对第二个元素进行筛选

result = pairs.filter(lambda keyValue: len(keyValue[1]) < 20)

  有时,我们只想访问 pair RDD 的值部分,这时操作二元组很麻烦。由于这是一种常见的使用模式,因此 Spark 提供了 mapValues(func) 函数,功能类似于 map{case (x, y): (x,func(y))}。
3.1 聚合操作
  当数据集以键值对形式组织的时候,聚合具有相同键的元素进行一些统计是很常见的操作。之前讲解过基础 RDD 上的 fold()、 combine()、 reduce() 等行动操作, pair RDD 上则有相应的针对键的转化操作。 Spark 有一组类似的操作,可以组合具有相同键的值。这些操作返回 RDD,因此它们是转化操作而不是行动操作。
reduceByKey() 与 reduce() 相当类似;它们都接收一个函数,并使用该函数对值进行合并。reduceByKey() 会为数据集中的每个键进行并行的归约操作,每个归约操作会将键相同的值合并起来。因为数据集中可能有大量的键,所以 reduceByKey() 没有被实现为向用户程序返回一个值的行动操作。实际上,它会返回一个由各键和对应键归约出来的结果值组成的新的 RDD。
  foldByKey() 则与 fold() 相当类似;它们都使用一个与 RDD 和合并函数中的数据类型相同的零值作为初始值。 与 fold() 一样, foldByKey() 操作所使用的合并函数对零值与另一个元素进行合并,结果仍为该元素。
  可以使用 reduceByKey() 和 mapValues() 来计算每个键的对应值的均值。这和使用 fold() 和 map() 计算整个 RDD 平均值的过程很相似。
  在 Python 中使用 reduceByKey() 和 mapValues() 计算每个键对应的平均值:

rdd.mapValues(lambda x: (x, 1)).reduceByKey(lambda x, y: (x[0] + y[0], x[1] + y[1]))

这里写图片描述
  经典的分布式单词计数问题。可以使用前一章中讲过的 flatMap() 来生成以单词为键、以数字 1 为值的 pair RDD,然后使用 reduceByKey() 对所有的单词进行计数。
  用 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)

  combineByKey() 是最为常用的基于键进行聚合的函数。大多数基于键聚合的函数都是用它实现的。和 aggregate() 一样, combineByKey() 可以让用户返回与输入数据的类型不同的返回值。
  要理解 combineByKey(),要先理解它在处理数据时是如何处理每个元素的。 由于combineByKey() 会遍历分区中的所有元素,因此每个元素的键要么还没有遇到过,要么就和之前的某个元素的键相同。
  如果这是一个新的元素, combineByKey() 会使用一个叫作 createCombiner() 的函数来创建那个键对应的累加器的初始值。 需要注意的是,这一过程会在每个分区中第一次出现各个键时发生,而不是在整个 RDD 中第一次出现一个键时发生。
  如果这是一个在处理当前分区之前已经遇到的键, 它会使用 mergeValue() 方法将该键的累加器对应的当前值与这个新的值进行合并。由于每个分区都是独立处理的, 因此对于同一个键可以有多个累加器。如果有两个或者更多的分区都有对应同一个键的累加器, 就需要使用用户提供的 mergeCombiners() 方法将各个分区的结果进行合并。
  combineByKey() 有多个参数分别对应聚合操作的各个阶段,因而非常适合用来解释聚合操作各个阶段的功能划分。 为了更好地演示 combineByKey() 是如何工作的,下面来看看如何计算各键对应的平均值。
  在 Python 中使用 combineByKey() 求每个键对应的平均值:

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()

这里写图片描述
  有很多函数可以进行基于键的数据合并。它们中的大多数都是在 combineByKey() 的基础上实现的,为用户提供了更简单的接口。不管怎样,在 Spark 中使用这些专用的聚合函数,始终要比手动将数据分组再归约快很多。
并行度调优
  到目前为止,已经讨论了所有的转化操作的分发方式,但是还没有探讨 Spark 是怎样确定如何分割工作的。 每个 RDD 都有固定数目的分区,分区数决定了在 RDD 上执行操作时的并行度。
  在执行聚合或分组操作时, 可以要求 Spark 使用给定的分区数。 Spark 始终尝试根据集群的大小推断出一个有意义的默认值, 但是有时候你可能要对并行度进行调优来获取更好的性能表现。
  本章讨论的大多数操作符都能接收第二个参数, 这个参数用来指定分组结果或聚合结果的RDD 的分区数。
  在 Python 中自定义 reduceByKey() 的并行度:

data = [("a", 3), ("b", 4), ("a", 1)]sc.parallelize(data).reduceByKey(lambda x, y: x + y) # 默认并行度sc.parallelize(data).reduceByKey(lambda x, y: x + y, 10) # 自定义并行度

  有时,希望在除分组操作和聚合操作之外的操作中也能改变 RDD 的分区。对于这样的情况, Spark 提供了 repartition() 函数。它会把数据通过网络进行混洗,并创建出新的分区集合。 切记,对数据进行重新分区是代价相对比较大的操作。 Spark 中也有一个优化版的 repartition(),叫作 coalesce()。
3.2 数据分组
  对于有键的数据,一个常见的用例是将数据根据键进行分组——比如查看一个顾客的所有订单。
  如果数据已经以预期的方式提取了键, groupByKey() 就会使用 RDD 中的键来对数据进行分组。对于一个由类型 K 的键和类型 V 的值组成的 RDD,所得到的结果 RDD 类型会是[K, Iterable[V]]。
  groupBy() 可以用于未成对的数据上,也可以根据除键相同以外的条件进行分组。它可以接收一个函数,对源 RDD 中的每个元素使用该函数,将返回结果作为键再进行分组。
  除了对单个 RDD 的数据进行分组,还可以使用一个叫作 cogroup() 的函数对多个共享同一个键的 RDD 进行分组。对两个键的类型均为 K 而值的类型分别为 V 和 W 的 RDD 进行cogroup() 时,得到的结果 RDD 类型为 [(K, (Iterable[V], Iterable[W]))]。如果其中的一个 RDD 对于另一个 RDD 中存在的某个键没有对应的记录,那么对应的迭代器则为空。
  cogroup() 提供了为多个 RDD 进行数据分组的方法。
3.3 连接
  将有键的数据与另一组有键的数据一起使用是对键值对数据执行的最有用的操作之一。连接数据可能是 pair RDD 最常用的操作之一。连接方式多种多样: 右外连接、左外连接、交叉连接以及内连接。
  普通的 join 操作符表示内连接。只有在两个 pair RDD 中都存在的键才叫输出。当一个输入对应的某个键有多个值时, 生成的 pair RDD 会包括来自两个输入 RDD 的每一组相对应的记录。
  有时,我们不希望结果中的键必须在两个 RDD 中都存在。例如,在连接客户信息与推荐时,如果一些客户还没有收到推荐,我们仍然不希望丢掉这些顾客。 leftOuterJoin(other)和 rightOuterJoin(other) 都会根据键连接两个 RDD,但是允许结果中存在其中的一个pair RDD 所缺失的键。
  在使用 leftOuterJoin() 产生的 pair RDD 中,源 RDD 的每一个键都有对应的记录。每个键相应的值是由一个源 RDD 中的值与一个包含第二个 RDD 的值的 Option(在 Java 中为Optional)对象组成的二元组。在 Python 中,如果一个值不存在,则使用 None 来表示;而数据存在时就用常规的值来表示, 不使用任何封装。和 join() 一样,每个键可以得到多条记录;当这种情况发生时,我们会得到两个 RDD 中对应同一个键的两组值的笛卡尔积。
3.4 数据排序
  很多时候,让数据排好序是很有用的,尤其是在生成下游输出时。如果键有已定义的顺序,就可以对这种键值对 RDD 进行排序。当把数据排好序后,后续对数据进行 collect()或 save() 等操作都会得到有序的数据。
  我们经常要将 RDD 倒序排列,因此 sortByKey() 函数接收一个叫作 ascending 的参数,表示我们是否想要让结果按升序排序(默认值为 true)。有时我们也可能想按完全不同的排序依据进行排序。 要支持这种情况,我们可以提供自定义的比较函数。将整数转为字符串,然后使用字符串比较函数来对 RDD 进行排序,在 Python 中以字符串顺序对整数进行自定义排序:

rdd.sortByKey(ascending=True, numPartitions=None, keyfunc = lambda x: str(x))

4、Pair RDD的行动操作
这里写图片描述
5、数据分区(进阶)
  本章要讨论的最后一个 Spark 特性是对数据集在节点间的分区进行控制。在分布式程序中,通信的代价是很大的, 因此控制数据分布以获得最少的网络传输可以极大地提升整体性能。和单节点的程序需要为记录集合选择合适的数据结构一样, Spark 程序可以通过控制RDD 分区方式来减少通信开销。 分区并不是对所有应用都有好处的——比如,如果给定RDD 只需要被扫描一次, 我们完全没有必要对其预先进行分区处理。只有当数据集多次在诸如连接这种基于键的操作中使用时, 分区才会有帮助。
  Spark 中所有的键值对 RDD 都可以进行分区。系统会根据一个针对键的函数对元素进行分组。尽管 Spark 没有给出显示控制每个键具体落在哪一个工作节点上的方法(部分原因是Spark 即使在某些节点失败时依然可以工作),但 Spark 可以确保同一组的键出现在同一个节点上。比如,你可能使用哈希分区将一个 RDD 分成了 100 个分区,此时键的哈希值对100 取模的结果相同的记录会被放在一个节点上。 你也可以使用范围分区法,将键在同一个范围区间内的记录都放在同一个节点上。
  这是因为在每次调用 processNewLogs() 时都会用到 join() 操作,而我们对数据集是如何分区的却一无所知。默认情况下,连接操作会将两个数据集中的所有键的哈希值都求出来, 将该哈希值相同的记录通过网络传到同一台机器上,然后在那台机器上对所有键相同的记录进行连接操作(见图 4-4)。因为 userData 表比每五分钟出现的访问日志表 events 要大得多,所以要浪费时间做很多额外工作:在每次调用时都对 userData 表进行哈希值计算和跨节点数据混洗,虽然这些数据从来都不会变化。
这里写图片描述
要解决这一问题也很简单:在程序开始时,对 userData 表使用 partitionBy() 转化操作,将这张表转为哈希分区。 可以通过向 partitionBy 传递一个spark.HashPartitioner 对象来实现该操作。
Scala自定义分区方式

val sc = new SparkContext(...)val userData = sc.sequenceFile[UserID, UserInfo]("hdfs://...").partitionBy(new HashPartitioner(100)) // 构造100个分区.persist()

processNewLogs() 方法可以保持不变:在processNewLogs() 中,eventsRDD是本地变量,只在该方法中使用了一次, 所以为 events 指定分区方式没有什么用处。由于在构建 userData 时调用了 partitionBy(), Spark 就知道了该 RDD 是根据键的哈希值来分区的,这样在调用 join() 时, Spark 就会利用到这一点。具体来说,当调用 userData.
join(events) 时, Spark 只会对 events 进行数据混洗操作,将 events 中特定 UserID 的记录发送到 userData 的对应分区所在的那台机器上。这样,需要通过网络传输的数据就大大减少了,程序运行速度也可以显著提升了。
注意, partitionBy() 是一个转化操作,因此它的返回值总是一个新的 RDD,但它不会改变原来的 RDD。 RDD 一旦创建就无法修改。因此应该对partitionBy() 的结果进行持久化,并保存为 userData,而不是原来的 sequenceFile() 的输出。此外,传给 partitionBy() 的100 表示分区数目,它会控制之后对这个 RDD 进行进一步操作(比如连接操作)时有多少任务会并行执行。总的来说,这个值至少应该和集群中的总核心数一样。
这里写图片描述
5.1 获取RDD的分区方式
5.2 从分区中获益的操作
5.3 影响分区方式的操作
5.4 示例:PageRank
5.5 自定义分区

6、总结
  本章我们学习了如何使用 Spark 提供的专门的函数来操作键值对数据。第 3 章中讲到的技巧也同样适用于 pair RDD。在下一章中我们会介绍如何读取和保存数据。

阅读全文
0 0
原创粉丝点击