Spark快速大数据分析(1)

推荐序
译者序

前言
第1章 Spark数据分析导论
第2章 Spark下载与入门
第3章 RDD基础
第4章 键值对操作
第5章 数据读取与存储
第6章 Spark编程进阶
第7章 在集群上运行Spark
第8章 Spark调优与调试
第9章 Spark SQL
第10章 Spark Streaming
第11章 基于MLib的机器学习

只整理最核心的部分和python代码,力求在一个小时之内能看完

推荐序

近年来大数据逐渐升温,经常有人问起大数据为何重要。我们处在一个数据爆炸的时代,大量涌现的智能手机、平板、可穿戴设备及物联网设备每时每刻都在产生新的数据。当今世界,有 90% 的数据是在过去短短两年内产生的。到2020 年,将有500 多亿台的互联设备产生Zeta 字节级的数据。带来革命性改变的并非海量数据本身,而是我们如何利用这些数据。大数据解决方案的强大在于它们可以快速处理大规模、复杂的数据集,可以比传统方法更快、更好地生成洞见。

一套大数据解决方案通常包含多个重要组件,从存储、计算和网络等硬件层,到数据处理引擎,再到利用改良的统计和计算算法、数据可视化来获得商业洞见的分析层。这中间,数据处理引擎起到了十分重要的作用。毫不夸张地说,数据处理引擎之于大数据就像CPU之于计算机,或大脑之于人类。

不同于传统的数据处理框架,Spark 基于内存的基本类型(primitive)为一些应用程序带来了100倍的性能提升。Spark 允许用户程序将数据加载到集群内存中用于反复查询,非常适用于大数据和机器学习,日益成为最广泛采用的大数据模块之一。包括 Cloudera 和 MapR 在内的大数据发行版也在发布时添加了Spark。

目前,Spark 正在促使 Hadoop 和大数据生态系统发生演变,以更好地支持端到端的大数据分析需求,例如:Spark 已经超越 Spark 核心,发展到了Spark streaming、SQL、MLlib、GraphX、SparkR 等模块。学习 Spark 和它的各个内部构件不仅有助于改善大数据处理速度,还能帮助开发者和数据科学家更轻松地创建分析应用。从企业、医疗、交通到零售业,Spark 这样的大数据解决方案正以前所未见的力量推进着商业洞见的形成,带来更多更好的洞见以加速决策制定。

这本书不是简单地教开发者如何使用 Spark,而是更深入介绍了 Spark 的内部构成,并通过各种实例展示了如何优化大数据应用。我向大家推荐这本书,或更具体点,推荐这本书里提倡的优化方法和思路,相信它们能帮助你创建出更好的大数据应用。

Spark 作为下一代大数据处理引擎,在非常短的时间里崭露头角,并且以燎原之势席卷业界。Spark 对曾经引爆大数据产业革命的 Hadoop MapReduce 的改进主要体现在这几个方面:首先,Spark 速度更快;其次,Spark 丰富的 API 带来了更强大的易用性;最后,Spark 不单单支持传统批处理应用,更支持交互式查询、流式计算、机器学习、图计算等各种应用,满足各种不同应用场景下的需求。

前言

随着并行数据分析变得越来越流行,各行各业的工作者都迫切需要更好的数据分析工具。Spark 应运而生,并且迅速火了起来。作为 MapReduce 的继承者,Spark 主要有三个优点。首先,Spark 非常好用。由于高级 API 剥离了对集群本身的关注,你可以专注于你所要做的计算本身,只需在自己的笔记本电脑上就可以开发 Spark 应用。其次,Spark 很快,支持交互式使用和复杂算法。最后,Spark 是一个通用引擎,可用它来完成各种各样的运算,包括 SQL 查询、文本处理、机器学习等,而在 Spark 出现之前,我们一般需要学习各种各样的引擎来分别处理这些需求。这三大优点也使得 Spark 可以作为学习大数据的一个很好的起点。

本书的目标读者是数据科学家和工程师。

数据科学家关注如何从数据中发现关联以及建立模型。数据科学家通常有着统计学或者数学背景,他们中的大多数也熟悉 Python 语言、R 语言、SQL 等传统数据分析工具。在本书中,我们不仅会讲到 Spark 中一些机器学习和高级数据分析的程序库,也会把一些 Python 或者 SQL 的应用作为 Spark 使用示例进行展示。如果你是一位数据科学家,我们希望你读完本书之后,能够在获得更快速度和更大数据规模支持的同时,使用早已熟悉的方式来解决问题。

对于工程师,不管你擅长的是 Java 还是 Python,抑或是别的编程语言,我们希望这本书能够教会你如何搭建一个 Spark 集群,如何使用 Spark shell,以及如何编写 Spark 应用程序来解决需要并行处理的问题。如果你熟悉 Hadoop,你就已经在如何与 HDFS 进行交互以及如何管理集群的领域中领先了一小步。即使你没有 Hadoop 经验也不用担心,我们会在本书中讲解一些基本的分布式执行的概念。

第1章 Spark数据分析导论

1.1 Spark是什么

Spark 是一个用来实现快速而通用的集群计算的平台。

在速度方面,Spark 扩展了广泛使用的 MapReduce 计算模型,而且高效地支持更多计算模式,包括交互式查询和流处理。在处理大规模数据集时,速度是非常重要的。速度快就意味着我们可以进行交互式的数据操作,否则我们每次操作就需要等待数分钟甚至数小时。

Spark 的一个主要特点就是能够在内存中进行计算,因而更快。不过即使是必须在磁盘上进行的复杂计算,Spark 依然比 MapReduce 更加高效。

总的来说,Spark 适用于各种各样原先需要多种不同的分布式平台的场景,包括批处理、迭代算法、交互式查询、流处理。通过在一个统一的框架下支持这些不同的计算,Spark 使我们可以简单而低耗地把各种处理流程整合在一起。而这样的组合,在实际的数据分析过程中是很有意义的。不仅如此,Spark 的这种特性还大大减轻了原先需要对各种平台分别管理的负担。

Spark 所提供的接口非常丰富。除了提供基于Python、Java、Scala 和SQL 的简单易用的 API 以及内建的丰富的程序库以外,Spark 还能和其他大数据工具密切配合使用。例如,Spark 可以运行在 Hadoop 集群上,访问包括 Cassandra 在内的任意 Hadoop 数据源。

1.2 一个大一统的软件栈

Spark 项目包含多个紧密集成的组件。Spark 的核心是一个对由很多计算任务组成的、运行在多个工作机器或者是一个计算集群上的应用进行调度、分发以及监控的计算引擎。由于 Spark 的核心引擎有着速度快和通用的特点,因此 Spark 还支持为各种不同应用场景专门设计的高级组件,比如 SQL 和机器学习等。这些组件关系密切并且可以相互调用,这样你就可以像在平常软件项目中使用程序库那样,组合使用这些的组件。

各组件间密切结合的设计原理有这样几个优点。首先,软件栈中所有的程序库和高级组件都可以从下层的改进中获益。比如,当 Spark 的核心引擎新引入了一个优化时,SQL 和机器学习程序库也都能自动获得性能提升。其次,运行整个软件栈的代价变小了。不需要运行 5 到 10 套独立的软件系统了,一个机构只需要运行一套软件系统即可。这些代价包括系统的部署、维护、测试、支持等。这也意味着 Spark 软件栈中每增加一个新的组件,使用Spark 的机构都能马上试用新加入的组件。这就把原先尝试一种新的数据分析系统所需要的下载、部署并学习一个新的软件项目的代价简化成了只需要升级 Spark。

最后,密切结合的原理的一大优点就是,我们能够构建出无缝整合不同处理模型的应用。例如,利用Spark,你可以在一个应用中实现将数据流中的数据使用机器学习算法进行实时分类。与此同时,数据分析师也可以通过SQL 实时查询结果数据,比如将数据与非结构化的日志文件进行连接操作。不仅如此,有经验的数据工程师和数据科学家还可以通过 Python shell 来访问这些数据,进行即时分析。其他人也可以通过独立的批处理应用访问这些数据。IT 团队始终只需要维护一套系统即可。

Spark 的各个组件如图 1-1 所示,下面来依次简要介绍它们。

1.2.1 Spark Core

Spark Core 实现了 Spark 的基本功能,包含任务调度、内存管理、错误恢复、与存储系统交互等模块。Spark Core 中还包含了对弹性分布式数据集(resilient distributed dataset,简称RDD)的API 定义。RDD 表示分布在多个计算节点上可以并行操作的元素集合,是 Spark 主要的编程抽象。Spark Core 提供了创建和操作这些集合的多个API。

1.2.2 Spark SQL

Spark SQL 是 Spark 用来操作结构化数据的程序包。通过Spark SQL,我们可以使用 SQL 或者 Apache Hive 版本的 SQL 方言(HQL)来查询数据。Spark SQL 支持多种数据源,比如 Hive 表、Parquet 以及 JSON 等。除了为 Spark 提供了一个SQL 接口,Spark SQL 还支持开发者将 SQL 和传统的 RDD 编程的数据操作方式相结合,不论是使用 Python、Java 还是 Scala,开发者都可以在单个的应用中同时使用 SQL 和复杂的数据分析。通过与 Spark 所提供的丰富的计算环境进行如此紧密的结合,Spark SQL 得以从其他开源数据仓库工具中脱颖而出。Spark SQL 是在Spark 1.0 中被引入的。

在 Spark SQL 之前,加州大学伯克利分校曾经尝试修改 Apache Hive 以使其运行在 Spark 上,当时的项目叫作 Shark。现在,由于 Spark SQL 与 Spark 引擎和 API 的结合更紧密,Shark 已经被 Spark SQL 所取代。

1.2.3 Spark Streaming

Spark Streaming 是 Spark 提供的对实时数据进行流式计算的组件。比如生产环境中的网页服务器日志,或是网络服务中用户提交的状态更新组成的消息队列,都是数据流。Spark Streaming 提供了用来操作数据流的 API,并且与 Spark Core 中的 RDD API 高度对应。这样一来,程序员编写应用时的学习门槛就得以降低,不论是操作内存或硬盘中的数据,还是操作实时数据流,程序员都更能应对自如。从底层设计来看,Spark Streaming 支持与 Spark Core 同级别的容错性、吞吐量以及可伸缩性。

1.2.4 MLlib

Spark 中还包含一个提供常见的机器学习(ML)功能的程序库,叫作 MLlib。MLlib 提供了很多种机器学习算法,包括分类、回归、聚类、协同过滤等,还提供了模型评估、数据导入等额外的支持功能。MLlib 还提供了一些更底层的机器学习原语,包括一个通用的梯度下降优化算法。所有这些方法都被设计为可以在集群上轻松伸缩的架构。

1.2.5 GraphX

GraphX 是用来操作图(比如社交网络的朋友关系图)的程序库,可以进行并行的图计算。与 Spark Streaming 和 Spark SQL 类似,GraphX 也扩展了 Spark 的 RDD API,能用来创建一个顶点和边都包含任意属性的有向图。GraphX 还支持针对图的各种操作(比如进行图分割的 subgraph 和操作所有顶点的 mapVertices),以及一些常用图算法(比如 PageRank 和三角计数)。

1.2.6 集群管理器

就底层而言,Spark 设计为可以高效地在一个计算节点到数千个计算节点之间伸缩计算。为了实现这样的要求,同时获得最大灵活性,Spark 支持在各种集群管理器(cluster manager)上运行,包括 Hadoop YARN、Apache Mesos,以及 Spark 自带的一个简易调度器,叫作独立调度器。如果要在没有预装任何集群管理器的机器上安装Spark,那么 Spark 自带的独立调度器可以让你轻松入门;而如果已经有了一个装有 Hadoop YARN 或 Mesos 的集群,通过Spark 对这些集群管理器的支持,你的应用也同样能运行在这些集群上。第 7 章会详细探讨这些不同的选项以及如何选择合适的集群管理器。

1.3 Spark的用户和用途

Spark 是一个用于集群计算的通用计算框架,因此被用于各种各样的应用程序。在前言中我们提到了本书的两大目标读者人群:数据科学家和工程师。仔细分析这两个群体以及他们使用 Spark 的方式,我们不难发现这两个群体使用 Spark 的典型用例并不一致,不过我们可以把这些用例大致分为两类——数据科学应用和数据处理应用。
当然,这种领域和使用模式的划分是比较模糊的。很多人也兼有数据科学家和工程师的能力,有的时候扮演数据科学家的角色进行研究,然后摇身一变成为工程师,熟练地编写复杂的数据处理程序。不管怎样,分开看这两大群体和相应的用例是很有意义的。

1.3.1 数据科学任务

数据科学是过去几年里出现的新学科,关注的是数据分析领域。尽管没有标准的定义,但我们认为数据科学家(data scientist)就是主要负责分析数据并建模的人。数据科学家有可能具备 SQL、统计、预测建模(机器学习)等方面的经验,以及一定的使用 Python、Matlab 或 R 语言进行编程的能力。将数据转换为更方便分析和观察的格式,通常被称为数据转换(data wrangling),数据科学家也对这一过程中的必要技术有所了解。

数据科学家使用他们的技能来分析数据,以回答问题或发现一些潜在规律。他们的工作流经常会用到即时分析,所以他们可以使用交互式 shell 替代复杂应用的构建,这样可以在最短时间内得到查询语句和一些简单代码的运行结果。Spark 的速度以及简单的 API 都能在这种场景里大放光彩,而 Spark 内建的程序库的支持也使得很多算法能够即刻使用。

Spark 通过一系列组件支持各种数据科学任务。Spark shell 通过提供 Python 和 Scala 的接口,使我们方便地进行交互式数据分析。Spark SQL 也提供一个独立的 SQL shell,我们可以在这个 shell 中使用 SQL 探索数据,也可以通过标准的 Spark 程序或者 Spark shell 来进行 SQL 查询。机器学习和数据分析则通过 MLlib 程序库提供支持。另外,Spark 还能支持调用 R 或者 Matlab 写成的外部程序。数据科学家在使用 R 或 Pandas 等传统数据分析工具时所能处理的数据集受限于单机,而有了Spark,就能处理更大数据规模的问题。

在初始的探索阶段之后,数据科学家的工作需要被应用到实际中。具体问题包括扩展应用的功能、提高应用的稳定性,并针对生产环境进行配置,使之成为业务应用的一部分。例如,在数据科学家完成初始的调研之后,我们可能最终会得到一个生产环境中的推荐系统,可以整合在网页应用中,为用户提供产品推荐。一般来说,将数据科学家的工作转化为实际生产中的应用的工作是由另外的工程师或者工程师团队完成的,而不是那些数据科学家。

1.3.2 数据处理应用

Spark 的另一个主要用例是针对工程师的。在这里,我们把工程师定义为使用Spark 开发生产环境中的数据处理应用的软件开发者。这些开发者一般有基本的软件工程概念,比如
封装、接口设计以及面向对象的编程思想,他们通常有计算机专业的背景,并且能使用工
程技术来设计和搭建软件系统,以实现业务用例。
对工程师来说,Spark 为开发用于集群并行执行的程序提供了一条捷径。通过封装,Spark 不需要开发者关注如何在分布式系统上编程这样的复杂问题,也无需过多关注网络通信和程序容错性。Spark 已经为工程师提供了足够的接口来快速实现常见的任务,以及对应用进行监视、审查和性能调优。其 API 模块化的特性(基于传递分布式的对象集)使得利用程序库进行开发以及本地测试大大简化。

Spark 用户之所以选择Spark 来开发他们的数据处理应用,正是因为Spark 提供了丰富的功能,容易学习和使用,并且成熟稳定。

1.4 Spark简史

Spark 是由一个强大而活跃的开源社区开发和维护的,社区中的开发者们来自许许多多不
同的机构。如果你或者你所在的机构是第一次尝试使用 Spark,也许你会对 Spark 这个项目的历史感兴趣。Spark 是于 2009 年作为一个研究项目在加州大学伯克利分校 RAD 实验室(AMPLab 的前身)诞生。实验室中的一些研究人员曾经用过 Hadoop MapReduce。他们发现MapReduce 在迭代计算和交互计算的任务上表现得效率低下。因此,Spark 从一开始就是为交互式查询和迭代算法设计的,同时还支持内存式存储和高效的容错机制

第3章 RDD编程

本章介绍Spark 对数据的核心抽象——弹性分布式数据集(Resilient Distributed Dataset,简称RDD)。RDD 其实就是分布式的元素集合。在 Spark 中,对数据的所有操作不外乎创建 RDD、转化已有 RDD 以及调用 RDD 操作进行求值。而在这一切背后,Spark 会自动将 RDD 中的数据分发到集群上,并将操作并行化执行。

3.1 RDD基础

Spark 中的 RDD 就是一个不可变的分布式对象集合。每个 RDD 都被分为多个分区,这些分区运行在集群中的不同节点上。RDD 可以包含 Python、Java、Scala 中任意类型的对象,甚至可以包含用户自定义的对象。

用户可以使用两种方法创建 RDD:读取一个外部数据集,或在驱动器程序里分发驱动器程
序中的对象集合(比如 list 和 set)。

# 例 3-1:在 Python 中过使用 SparkContext.textFile() 来读取文本文件作为一个字符串 RDD
>>> lines = sc.textFile("README.md")

创建出来后,RDD 支持两种类型的操作:转化操作(transformation)和行动操作(action)。转化操作会由一个 RDD 生成一个新的 RDD。例如,根据谓词匹配情况筛选数据就是一个常见的转化操作。

# 例3-2:调用转化操作 filter()
# 用筛选来生成一个只存储包含单词 Python 的字符串的新的RDD
>>> pythonLines = lines.filter(lambda line: "Python" in line)

另一方面,行动操作会对 RDD 计算出一个结果,并把结果返回到驱动器程序中,或把结果存储到外部存储系统(如HDFS)中。

例3-3:调用 first() 行动操作返回 RDD 的第一个元素
>>> pythonLines.first()
u'## Interactive Python Shell'

转化操作和行动操作的区别在于 Spark 计算 RDD 的方式不同。虽然你可以在任何时候定
义新的 RDD,但 Spark 只会惰性计算这些 RDD。它们只有第一次在一个行动操作中用到
时,才会真正计算。这种策略刚开始看起来可能会显得有些奇怪,不过在大数据领域是很
有道理的。比如,看看例 3-2 和例 3-3,我们以一个文本文件定义了数据,然后把其中包含 Python 的行筛选出来。如果 Spark 在我们运行 lines = sc.textFile(...) 时就把文件中所有的行都读取并存储起来,就会消耗很多存储空间,而我们马上就要筛选掉其中的很多数据。相反, 一旦 Spark 了解了完整的转化操作链之后,它就可以只计算求结果时真正需要的数据。事实上,在行动操作 first() 中,Spark 只需要扫描文件直到找到第一个匹配的行为止,而不需要读取整个文件。

最后,默认情况下,Spark 的RDD 会在你每次对它们进行行动操作时重新计算。如果想在多个行动操作中重用同一个 RDD,可以使用 RDD.persist() 让 Spark 把这个 RDD 缓存下来。我们可以让Spark 把数据持久化到许多不同的地方,可用的选项会在表3-6 中列出。

在第一次对持久化的 RDD 计算之后,Spark 会把 RDD 的内容保存到内存中(以分区方式存储到集群中的各机器上),这样在之后的行动操作中,就可以重用这些数据了。我们也可以把 RDD 缓存到磁盘上而不是内存中。默认不进行持久化可能也显得有些奇怪,不过这对于大规模数据集是很有意义的:如果不会重用该 RDD,我们就没有必要浪费存储空间,Spark 可以直接遍历一遍数据然后计算出结果。

在实际操作中,你会经常用 persist() 来把数据的一部分读取到内存中,并反复查询这部分数据。

例 3-4:如果想多次对 README 文件中包含 Python 的行进行计算,可以把 RDD 持久化到内存中
>>> pythonLines.persist
>>> pythonLines.count()
2
>>> pythonLines.first()
u'## Interactive Python Shell'

总的来说,每个 Spark 程序或 shell 会话都按如下方式工作。
(1) 从外部数据创建出输入 RDD。
(2) 使用诸如 filter() 这样的转化操作对 RDD 进行转化,以定义新的 RDD。
(3) 告诉 Spark 对需要被重用的中间结果 RDD 执行 persist() 操作。
(4) 使用行动操作(例如 count() 和 first() 等)来触发一次并行计算,Spark 会对计算进行优化后再执行。

3.2 创建RDD

Spark 提供了两种创建 RDD 的方式:

  1. 读取外部数据集,
  2. 在驱动器程序中对一个集合进行并行化。

创建 RDD 最简单的方式就是把程序中一个已有的集合传给 SparkContext 的 parallelize() 方法。这种方式在学习 Spark 时非常有用,它让你可以在 shell 中快速创建出自己的 RDD,然后对这些 RDD 进行操作。不过,需要注意的是,除了开发原型和测试时,这种方式用得并不多,毕竟这种方式需要把你的整个数据集先放在一台机器的内存中。

# 例3-5:Python 中的 parallelize() 方法
lines = sc.parallelize(["pandas", "i like pandas"])

# 例3-6:Scala 中的 parallelize() 方法
val lines = sc.parallelize(List("pandas", "i like pandas"))

# 例3-7:Java 中的 parallelize() 方法
JavaRDD<String> lines = sc.parallelize(Arrays.asList("pandas", "i like pandas"));

更常用的方式是从外部存储中读取数据来创建 RDD。外部数据集的读取会在第 5 章详细介绍。不过,我们已经接触了用来将文本文件读入为一个存储字符串的 RDD 的方法 SparkContext.textFile(),用法如例 3-8 至例 3-10 所示。

# 例3-8:Python 中的 textFile() 方法
lines = sc.textFile("/path/to/README.md")

# 例3-9:Scala 中的 textFile() 方法
val lines = sc.textFile("/path/to/README.md")

# 例3-10:Java 中的 textFile() 方法
JavaRDD<String> lines = sc.textFile("/path/to/README.md");
3.3 RDD操作

我们已经讨论过,RDD 支持两种操作:转化操作和行动操作。RDD 的转化操作是返回一个新的 RDD 的操作,比如 map() 和 filter(),而行动操作则是向驱动器程序返回结果或把结果写入外部系统的操作,会触发实际的计算,比如 count() 和 first()。

Spark 对待转化操作和行动操作的方式很不一样,因此理解你正在进行的操作的类型是很重要的。如果对于一个特定的函数是属于转化操作还是行动操作感到困惑,你可以看看它的返回值类型:转化操作返回的是 RDD,而行动操作返回的是其他的数据类型。

3.3.1 转化操作

RDD 的转化操作是返回新 RDD 的操作。我们会在 3.3.3 节讲到,转化出来的 RDD 是惰性求值的,只有在行动操作中用到这些 RDD 时才会被计算。许多转化操作都是针对各个元素的,也就是说,这些转化操作每次只会操作 RDD 中的一个元素。不过并不是所有的转
化操作都是这样的。
举个例子,假定我们有一个日志文件log.txt,内含有若干消息,希望选出其中的错误消息。我们可以使用前面说过的转化操作 filter()。

# 例3-11:用 Python 实现 filter() 转化操作
inputRDD = sc.textFile("log.txt")
errorsRDD = inputRDD.filter(lambda x: "error" in x)

# 例3-12:用 Scala 实现 filter() 转化操作
val inputRDD = sc.textFile("log.txt")
val errorsRDD = inputRDD.filter(line => line.contains("error"))

# 例3-13:用 Java 实现 filter() 转化操作
JavaRDD<String> inputRDD = sc.textFile("log.txt");
JavaRDD<String> errorsRDD = inputRDD.filter(
  new Function<String, Boolean>() {
    public Boolean call(String x) { return x.contains("error"); }
  }
});

注意,filter() 操作不会改变已有的inputRDD 中的数据。实际上,该操作会返回一个全新的 RDD。inputRDD 在后面的程序中还可以继续使用,比如我们还可以从中搜索别的单词。

事实上,要再从 inputRDD 中找出所有包含单词 warning 的行。接下来,我们使用另一个转化操作 union() 来打印出包含 error 或 warning 的行数。union() 与 filter() 的不同点在于它操作两个 RDD 而不是一个。转化操作可以操作任意数量的输入 RDD。

# 例3-14:用 Python / Scala / Java 进行 union() 转化操作
# 更好的方法是直接筛选出要么包含 error 要么包含 warning 的行,这样只对 inputRDD 进行一次筛选即可
errorsRDD = inputRDD.filter(lambda x: "error" in x)
warningsRDD = inputRDD.filter(lambda x: "warning" in x)
badLinesRDD = errorsRDD.union(warningsRDD)

通过转化操作,你从已有的 RDD 中派生出新的 RDD,Spark 会使用谱系图(lineage graph)来记录这些不同 RDD 之间的依赖关系。Spark 需要用这些信息来按需计算每个 RDD,也可以依靠谱系图在持久化的 RDD 丢失部分数据时恢复所丢失的数据。

3.3.2 行动操作

我们已经看到了如何通过转化操作从已有的 RDD 创建出新的 RDD,不过有时,我们希望
对数据集进行实际的计算。行动操作是第二种类型的 RDD 操作,它们会把最终求得的结
果返回到驱动器程序,或者写入外部存储系统中。由于行动操作需要生成实际的输出,它
们会强制执行那些求值必须用到的 RDD 的转化操作。

继续我们在前几章中用到的日志的例子,我们可能想输出关于 badLinesRDD 的一些信息。为此,需要使用两个行动操作来实现:用 count() 来返回计数结果,用 take() 来收集 RDD 中的一些元素。

# 例3-15:在 Python 中使用行动操作对错误进行计数
print "Input had " + badLinesRDD.count() + " concerning lines"
print "Here are 10 examples:"
for line in badLinesRDD.take(10):
  print line

# 例3-16:在 Scala 中使用行动操作对错误进行计数
println("Input had " + badLinesRDD.count() + " concerning lines")
println("Here are 10 examples:")
badLinesRDD.take(10).foreach(println)

# 例3-17:在 Java 中使用行动操作对错误进行计数
System.out.println("Input had " + badLinesRDD.count() + " concerning lines")
System.out.println("Here are 10 examples:")
for (String line: badLinesRDD.take(10)) {
  System.out.println(line);
}

在这个例子中,我们在驱动器程序中使用 take() 获取了 RDD 中的少量元素。然后在本地遍历这些元素,并在驱动器端打印出来。RDD 还有一个 collect() 函数,可以用来获取整个 RDD 中的数据。如果你的程序把 RDD 筛选到一个很小的规模,并且你想在本地处理这些数据时,就可以使用它。记住,只有当你的整个数据集能在单台机器的内存中放得下
时,才能使用 collect(),因此,collect() 不能用在大规模数据集上。

在大多数情况下,RDD 不能通过 collect() 收集到驱动器进程中,因为它们一般都很大。此时,我们通常要把数据写到诸如 HDFS 或 Amazon S3 这样的分布式的存储系统中。你可以使用 saveAsTextFile()、saveAsSequenceFile(),或者任意的其他行动操作来把 RDD 的数据内容以各种自带的格式保存起来。我们会在第 5 章讲解导出数据的各种选项。

需要注意的是,每当我们调用一个新的行动操作时,整个 RDD 都会从头开始计算。要避
免这种低效的行为,用户可以将中间结果持久化,这会在 3.6 节中介绍。

3.3.3 惰性求值

前面提过,RDD 的转化操作都是惰性求值的。这意味着在被调用行动操作之前 Spark 不会开始计算。这对新用户来说可能与直觉有些相违背之处,但是对于那些使用过诸如 Haskell 等函数式语言或者类似 LINQ 这样的数据处理框架的人来说,会有些似曾相识。

惰性求值意味着当我们对RDD 调用转化操作(例如调用map())时,操作不会立即执行。相反,Spark 会在内部记录下所要求执行的操作的相关信息。我们不应该把 RDD 看作存
放着特定数据的数据集,而最好把每个 RDD 当作我们通过转化操作构建出来的、记录如何计算数据的指令列表。把数据读取到 RDD 的操作也同样是惰性的。因此,当我们调用 sc.textFile() 时,数据并没有读取进来,而是在必要时才会读取。和转化操作一样的是,读取数据的操作也有可能会多次执行。

Spark 使用惰性求值,这样就可以把一些操作合并到一起来减少计算数据的步骤。在类似 Hadoop MapReduce 的系统中,开发者常常花费大量时间考虑如何把操作组合到一起,以
减少MapReduce 的周期数。而在 Spark 中,写出一个非常复杂的映射并不见得能比使用很多简单的连续操作获得好很多的性能。因此,用户可以用更小的操作来组织他们的程序,
这样也使这些操作更容易管理。

虽然转化操作是惰性求值的,但还是可以随时通过运行一个行动操作来强制 Spark 执行RDD 的转化操作,比如使用 count()。这是一种对你所写的程序进行部分测试的简单方法。

3.4 向Spark传递函数

Spark 的大部分转化操作和一部分行动操作,都需要依赖用户传递的函数来计算。在我们
支持的三种主要语言中,向 Spark 传递函数的方式略有区别。

3.4.1 Python

在 Python 中,我们有三种方式来把函数传递给 Spark。

  1. 使用lambda 表达式来传递:传递比较短的函数时
  2. 传递顶层函数
  3. 传递定义的局部函数
例3-18:在 Python 中使用lambda 表达式来传递函数
word = rdd.filter(lambda s: "error" in s)
def containsError(s):
  return "error" in s
word = rdd.filter(containsError)

传递函数时需要小心的一点是,Python 会在你不经意间把函数所在的对象也序列化传出
去。当你传递的对象是某个对象的成员,或者包含了对某个对象中一个字段的引用时(例如 self.field),Spark 就会把整个对象发到工作节点上,这可能比你想传递的东西大得多(见例3-19)。有时,如果传递的类里面包含 Python 不知道如何序列化传输的对象,也会导致你的程序失败。

# 例3-19:传递一个带字段引用的函数(别这么做!)
class SearchFunctions(object):
  def __init__(self, query):
    self.query = query
  def isMatch(self, s):
    return self.query in s
  def getMatchesFunctionReference(self, rdd):
    # 问题:在"self.isMatch"中引用了整个 self
    return rdd.filter(self.isMatch)
  def getMatchesMemberReference(self, rdd):
    # 问题:在"self.query"中引用了整个 self
    return rdd.filter(lambda x: self.query in x)

替代的方案是,只把你所需要的字段从对象中拿出来放到一个局部变量中,然后传递这个
局部变量,如例3-20 所示。

# 例3-20:传递不带字段引用的Python 函数
class WordFunctions(object):
  ...
  def getMatchesNoReference(self, rdd):
    # 安全:只把需要的字段提取到局部变量中
    query = self.query
    return rdd.filter(lambda x: query in x)
3.4.2 Scala

在 Scala 中,我们可以把定义的内联函数、方法的引用或静态方法传递给 Spark,就像 Scala 的其他函数式 API 一样。我们还要考虑其他一些细节,比如所传递的函数及其引用的数据需要是可序列化的(实现了 Java 的 Serializable 接口)。除此以外,与Python 类似,传递一个对象的方法或者字段时,会包含对整个对象的引用。这在 Scala 中不是那么明显,毕竟我们不会像 Python 那样必须用 self 写出那些引用。

类似在例 3-20 中对 Python 执行的操作,我们可以把需要的字段放到一个局部变量中,来避免传递包含该字段的整个对象,如例3-21 所示。

# 例3-21:Scala 中的函数传递
# 如果在 Scala 中出现了 NotSerializableException,通常问题就在于我们传递了一个不可序列化的类中的函数或字段
# 传递局部可序列化变量或顶级对象中的函数始终是安全的
class SearchFunctions(val query: String) {
  def isMatch(s: String): Boolean = {
  s.contains(query)
  }
  def getMatchesFunctionReference(rdd: RDD[String]): RDD[String] = {
    // 问题:"isMatch"表示"this.isMatch",因此我们要传递整个"this"
    rdd.map(isMatch)
  }
  def getMatchesFieldReference(rdd: RDD[String]): RDD[String] = {
    // 问题:"query"表示"this.query",因此我们要传递整个"this"
    rdd.map(x => x.split(query))
  }
  def getMatchesNoReference(rdd: RDD[String]): RDD[String] = {
    // 安全:只把我们需要的字段拿出来放入局部变量中
    val query_ = this.query
    rdd.map(x => x.split(query_))
  }
}
3.4.3 Java

在 Java 中,函数需要作为实现了 Spark 的 org.apache.spark.api.java.function 包中的任一函数接口的对象来传递。根据不同的返回类型,我们定义了一些不同的接口。

# 例3-22:在 Java 中使用匿名内部类进行函数传递
RDD<String> errors = lines.filter(new Function<String, Boolean>() {
  public Boolean call(String x) { return x.contains("error"); }
});

# 例3-23:在 Java 中使用具名类进行函数传递
class ContainsError implements Function<String, Boolean>() {
  public Boolean call(String x) { return x.contains("error"); }
}
RDD<String> errors = lines.filter(new ContainsError());

# 例3-24:带参数的 Java 函数类
# 顶级具名类通常在组织大型程序时显得比较清晰,并且可以给它们的构造函数添加参数
class Contains implements Function<String, Boolean>() {
  private String query;
  public Contains(String query) { this.query = query; }
  public Boolean call(String x) { return x.contains(query); }
}
RDD<String> errors = lines.filter(new Contains("error"));

# 例3-25:在 Java 中使用 Java 8 的 lambda 表达式进行函数传递
RDD<String> errors = lines.filter(s -> s.contains("error"));
3.5 常见的转化操作和行动操作
3.5.1 基本 RDD

以下转化操作和行动操作受任意数据类型的 RDD 支持。

1.针对各个元素的转化操作

两个最常用的转化操作是 map() 和 filter()。转化操作 map() 接收一个函数,把这个函数用于 RDD 中的每个元素,将函数的返回结果作为结果。我们可以使用 map() 来做各种各样的事情:可以把我们的 URL 集合中的每个 URL 对应的主机名提取出来,也可以简单到只对各个数字求平方值。map() 的返回值类型不需要和输入类型一样。这样如果有一个字符串 RDD,并且我们的 map() 函数是用来把字符串解析并返回一个 Double 值的,那么此时我们的输入 RDD 类型就是 RDD[String],而输出类型是 RDD[Double]。

# 例3-26:Python 版计算 RDD 中各值的平方
nums = sc.parallelize([1, 2, 3, 4])
squared = nums.map(lambda x: x * x).collect()
for num in squared:
  print "%i " % (num)

# 例3-27:Scala 版计算 RDD 中各值的平方
val input = sc.parallelize(List(1, 2, 3, 4))
val result = input.map(x => x * x)
println(result.collect().mkString(","))

# 例3-28:Java 版计算 RDD 中各值的平方
JavaRDD<Integer> rdd = sc.parallelize(Arrays.asList(1, 2, 3, 4));
JavaRDD<Integer> result = rdd.map(new Function<Integer, Integer>() {
  public Integer call(Integer x) { return x*x; }
});
System.out.println(StringUtils.join(result.collect(), ","));

有时候,我们希望对每个输入元素生成多个输出元素。实现该功能的操作叫作 flatMap()。和 map() 类似,我们提供给 flatMap() 的函数被分别应用到了输入 RDD 的每个元素上。不过返回的不是一个元素,而是一个返回值序列的迭代器。输出的 RDD 倒不是由迭代器组成的。我们得到的是一个包含各个迭代器可访问的所有元素的 RDD。flatMap() 的一个简单用途是把输入的字符串切分为单词。

# 例3-29:Python 中的 flatMap() 将行数据切分为单词
lines = sc.parallelize(["hello world", "hi"])
words = lines.flatMap(lambda line: line.split(" "))
words.first() # 返回"hello"

# 例3-30:Scala 中的 flatMap() 将行数据切分为单词
val lines = sc.parallelize(List("hello world", "hi"))
val words = lines.flatMap(line => line.split(" "))
words.first() // 返回"hello"

# 例3-31:Java 中的 flatMap() 将行数据切分为单词
JavaRDD<String> lines = sc.parallelize(Arrays.asList("hello world", "hi"));
JavaRDD<String> words = lines.flatMap(new FlatMapFunction<String, String>() {
  public Iterable<String> call(String line) {
    return Arrays.asList(line.split(" "));
  }
});
words.first(); // 返回"hello"

2.伪集合操作

尽管 RDD 本身不是严格意义上的集合,但它也支持许多数学上的集合操作,比如合并和相交操作。图3-4 展示了四种操作。注意,这些操作都要求操作的 RDD 是相同数据类型的。

我们的 RDD 中最常缺失的集合属性是元素的唯一性,因为常常有重复的元素。如果只要唯一的元素,我们可以使用 RDD.distinct() 转化操作来生成一个只包含不同元素的新 RDD。不过需要注意,distinct() 操作的开销很大,因为它需要将所有数据通过网络进行混洗(shuffle),以确保每个元素都只有一份。第 4 章会详细介绍数据混洗,以及如何避免数据混洗。

并:最简单的集合操作是 union(other),它会返回一个包含两个 RDD 中所有元素的 RDD。这在很多用例下都很有用,比如处理来自多个数据源的日志文件。与数学中的 union() 操作不同的是,如果输入的 RDD 中有重复数据,Spark 的 union() 操作也会包含这些重复数据(如有必要,我们可以通过 distinct() 实现相同的效果)。

交:Spark 还提供了 intersection(other) 方法,只返回两个 RDD 中都有的元素。intersection() 在运行时也会去掉所有重复的元素(单个 RDD 内的重复元素也会一起移除)。尽管 intersection() 与 union() 的概念相似,intersection() 的性能却要差很多,因为它需要通过网络混洗数据来发现共有的元素。

差:有时我们需要移除一些数据。subtract(other) 函数接收另一个 RDD 作为参数,返回一个由只存在于第一个RDD 中而不存在于第二个RDD 中的所有元素组成的RDD。和 intersection() 一样,它也需要数据混洗。

笛卡儿积:cartesian(other) 转化操作会返回所有可能的 (a, b) 对,其中 a 是源 RDD 中的元素,而 b 则来自另一个 RDD。笛卡儿积在我们希望考虑所有可能的组合的相似度时比较有用,比如计算各用户对各种产品的预期兴趣程度。我们也可以求一个 RDD 与其自身的笛卡儿积,这可以用于求用户相似度的应用中。不过要特别注意的是,求大规模 RDD 的笛卡儿积开销巨大。

表 3-2 和表 3-3 总结了这些常见的 RDD 转化操作。

3.行动操作

你很有可能会用到基本 RDD 上最常见的行动操作 reduce()。它接收一个函数作为参数,这个函数要操作两个 RDD 的元素类型的数据并返回一个同样类型的新元素。一个简单的例子就是函数+,可以用它来对我们的 RDD 进行累加。使用 reduce(),可以很方便地计算出 RDD 中所有元素的总和、元素的个数,以及其他类型的聚合操作。

fold() 和 reduce() 类似,接收一个与 reduce() 接收的函数签名相同的函数,再加上一个“初始值”来作为每个分区第一次调用时的结果。你所提供的初始值应当是你提供的操作的单位元素;也就是说,使用你的函数对这个初始值进行多次计算不会改变结果(例如 + 对应的 0,* 对应的 1,或拼接操作对应的空列表)。可以通过原地修改并返回两个参数中的前一个的值来节约在fold() 中创建对象的开销。但是没有办法修改第二个参数。

fold() 和 reduce() 都要求函数的返回值类型需要和我们所操作的 RDD 中的元素类型相同。这很符合像 sum 这种操作的情况。但有时我们确实需要返回一个不同类型的值。例如,在计算平均值时,需要记录遍历过程中的计数以及元素的数量,这就需要我们返回一个二元组。可以先对数据使用 map() 操作,来把元素转为该元素和 1 的二元组,也就是我们所希望的返回类型。这样reduce() 就可以以二元组的形式进行归约了。

# 例3-32:Python 中的 reduce()
sum = rdd.reduce(lambda x, y: x + y)

# 例3-33:Scala 中的 reduce()
val sum = rdd.reduce((x, y) => x + y)

# 例3-34:Java 中的 reduce()
Integer sum = rdd.reduce(new Function2<Integer, Integer, Integer>() {
  public Integer call(Integer x, Integer y) { return x + y; }
});

aggregate() 函数则把我们从返回值类型必须与所操作的 RDD 类型相同的限制中解放出来。与 fold() 类似,使用 aggregate() 时,需要提供我们期待返回的类型的初始值。然后通过一个函数把 RDD 中的元素合并起来放入累加器。考虑到每个节点是在本地进行累加的,最终,还需要提供第二个函数来将累加器两两合并。

可以用 aggregate() 来计算 RDD 的平均值,来代替 map() 后面接fold() 的方式。

# 例3-35:Python 中的 aggregate()
sumCount = nums.aggregate((0, 0),
               (lambda acc, value: (acc[0] + value, acc[1] + 1),
               (lambda acc1, acc2: (acc1[0] + acc2[0], acc1[1] + acc2[1]))))
return sumCount[0] / float(sumCount[1])

# 例3-36:Scala 中的 aggregate()
val result = input.aggregate((0, 0))(
               (acc, value) => (acc._1 + value, acc._2 + 1),
               (acc1, acc2) => (acc1._1 + acc2._1, acc1._2 + acc2._2))
val avg = result._1 / result._2.toDouble

# 例3-37:Java 中的 aggregate()
class AvgCount implements Serializable {
  public AvgCount(int total, int num) {
    this.total = total;
    this.num = num;
  }
  public int total;
  public int num;
  public double avg() {
    return total / (double) num;
  }
}
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);
AvgCount result = rdd.aggregate(initial, addAndCount, combine);
System.out.println(result.avg());

RDD 的一些行动操作会以普通集合或者值的形式将 RDD 的部分或全部数据返回驱动器程序中。

collect():将整个 RDD 的内容返回。通常在单元测试中使用,因为此时 RDD 的整个内容不会很大,可以放在内存中,使得 RDD 的值与预期结果之间的对比变得很容易。由于需要将数据复制到驱动器进程中,该操作要求所有数据都必须能一同放入单台机器的内存中。

take(n):返回 RDD 中的 n 个元素,并且尝试只访问尽量少的分区,因此该操作会得到一个不均衡的集合。需要注意的是,这些操作返回元素的顺序与你预期的可能不一样。

这些操作对于单元测试和快速调试都很有用,但是在处理大规模数据时会遇到瓶颈。

top():如果为数据定义了顺序,就可以使用该操作从 RDD 中获取前几个元素。此时会使用数据的默认顺序,但我们也可以提供自己的比较函数,来提取前几个元素。

takeSample(withReplacement, num, seed):可以从数据中获取一个采样,并指定是否替换。

foreach():有时我们会对 RDD 中的所有元素应用一个行动操作,但是不把任何结果返回到驱动器程序中,这也是有用的。比如可以用 JSON 格式把数据发送到一个网络服务器上,或者把数据存到数据库中。都可以使用该操作来对 RDD 中的每个元素进行操作,而不需要把 RDD 发回本地。

3.5.2 在不同RDD类型间转换

有些函数只能用于特定类型的 RDD,比如 mean() 和 variance() 只能用在数值RDD 上,而 join() 只能用在键值对 RDD 上。我们会在第 6 章讨论数值 RDD 的专门函数,在第 4 章讨论键值对 RDD 的专有操作。在 Scala 和 Java 中,这些函数都没有定义在标准的 RDD 类中,所以要访问这些附加功能,必须要确保获得了正确的专用 RDD 类。

1.Scala
在 Scala 中,将 RDD 转为有特定函数的 RDD(比如在 RDD[Double] 上进行数值操作)是由隐式转换来自动处理的。这些隐式转换可以隐式地将一个 RDD 转为各种封装类,比如 DoubleRDDFunctions
(数值数据的 RDD)和 PairRDDFunctions(键值对 RDD),这样我们就有了诸如 mean() 和 variance() 之类的额外的函数。

2.Java
在 Java 中,各种 RDD 的特殊类型间的转换更为明确。Java 中有两个专门的类 JavaDoubleRDD 和 JavaPairRDD,来处理特殊类型的 RDD,这两个类还针对这些类型提供了额外的函数。这让你可以更加了解所发生的一切,但是也显得有些累赘。

要构建出这些特殊类型的 RDD,需要使用特殊版本的类来替代一般使用的 Function 类。如果
要从 T 类型的 RDD 创建出一个 DoubleRDD,我们就应当在映射操作中使用 DoubleFunction<T> 来替代 Function<T, Double>。表 3-5 展示了一些特殊版本的函数类及其用法。

此外,我们也需要调用 RDD 上的一些别的函数(因此不能只是创建出一个 DoubleFunction
然后把它传给map())。当需要一个 DoubleRDD 时,我们应当调用 mapToDouble() 来替代 map(),跟其他所有函数所遵循的模式一样。

# 例3-38:用 Java 创建 DoubleRDD
# 生成一个 JavaDoubleRDD、计算 RDD 中每个元素的平方值的示例,这样就可以调用 DoubleRDD 独有的函数了,比如 mean() 和 variance()
JavaDoubleRDD result = rdd.mapToDouble(
  new DoubleFunction<Integer>() {
    public double call(Integer x) {
    return (double) x * x;
  }
});
System.out.println(result.mean());

3.Python
Python 的 API 结构与 Java 和 Scala 有所不同。在 Python 中,所有的函数都实现在基本的 RDD 类中,但如果操作对应的 RDD 数据类型不正确,就会导致运行时错误。

3.6 持久化(缓存)

如前所述,Spark RDD 是惰性求值的,而有时我们希望能多次使用同一个 RDD。如果简单地对 RDD 调用行动操作,Spark 每次都会重算 RDD 以及它的所有依赖。这在迭代算法中消耗格外大,因为迭代算法常常会多次使用同一组数据。

# 例3-39:Scala 中的两次执行
# 先对RDD 作一次计数,再把该 RDD 输出
val result = input.map(x => x*x)
println(result.count())
println(result.collect().mkString(","))

为了避免多次计算同一个 RDD,可以让 Spark 对数据进行持久化。当我们让 Spark 持久化存储一个 RDD 时,计算出 RDD 的节点会分别保存它们所求出的分区数据。如果一个有持久化数据的节点发生故障,Spark 会在需要用到缓存的数据时重算丢失的数据分区。如果希望节点故障的情况不会拖累我们的执行速度,也可以把数据备份到多个节点上。

出于不同的目的,我们可以为 RDD 选择不同的持久化级别。

# 例3-40:在 Scala 中使用 persist() 对数据进行缓存
# 在 Scala 和 Java 中,默认情况下 persist() 会把数据以序列化的形式缓存在 JVM 的堆空间中。
# 在 Python 中,会始终序列化要持久化存储的数据,所以持久化级别默认值就是以序列化后的对象存储在 JVM 堆空间中。
# 当我们把数据写到磁盘或者堆外存储上时,也总是使用序列化后的数据。
# 在第一次对这个 RDD 调用行动操作前就调用了 persist() 方法。persist() 调用本身不会触发强制求值。
# RDD 还有一个方法叫作 unpersist(),调用该方法可以手动把持久化的 RDD 从缓存中移除。
val result = input.map(x => x * x)
result.persist(StorageLevel.DISK_ONLY)
println(result.count())
println(result.collect().mkString(","))

如果要缓存的数据太多,内存中放不下,Spark 会自动利用最近最少使用(LRU)的缓存策略把最老的分区从内存中移除。对于仅把数据存放在内存中的缓存级别,下一次要用到已经被移除的分区时,这些分区就需要重新计算。但是对于使用内存与磁盘的缓存级别的分区来说,被移除的分区都会写入磁盘。不论哪一种情况,都不必担心你的作业因为缓存了太多数据而被打断。不过,缓存不必要的数据会导致有用的数据被移出内存,带来更多重算的时间开销。

3.7 总结

在本章中,我们介绍了 RDD 运行模型以及 RDD 的许多常见操作。如果你读到了这里,恭喜——你已经学完了 Spark 的所有核心概念。我们在进行并行聚合、分组等操作时,常常需要利用键值对形式的 RDD。下一章会讲解键值对形式的 RDD 上一些相关的特殊操作。然后,我们会讨论各种数据源的输入输出,以及一些关于使用 SparkContext 的进阶话题。

第4章 键值对操作

键值对 RDD 是 Spark 中许多操作所需要的常见数据类型。本章就来介绍如何操作键值对 RDD。键值对 RDD 通常用来进行聚合计算。我们一般要先通过一些初始ETL(抽取、转化、装载)操作来将数据转化为键值对形式。键值对 RDD 提供了一些新的操作接口(比如统计每个产品的评论,将数据中键相同的分为一组,将两个不同的 RDD 进行分组合并等)。

本章也会讨论用来让用户控制键值对 RDD 在各节点上分布情况的高级特性:分区。有时,使用可控的分区方式把常被一起访问的数据放到同一个节点上,可以大大减少应用的通信开销。这会带来明显的性能提升。我们会使用 PageRank 算法来演示分区的作用。为分布式数据集选择正确的分区方式和为本地数据集选择合适的数据结构很相似——在这两种情况下,数据的分布都会极其明显地影响程序的性能表现。

4.1 动机

Spark 为包含键值对类型的 RDD 提供了一些专有的操作。这些 RDD 被称为 pair RDD。Pair RDD 是很多程序的构成要素,因为它们提供了并行操作各个键或跨节点重新进行数据分组的操作接口。例如,pair RDD 提供 reduceByKey() 方法,可以分别归约每个键对应的数据,还有 join() 方法,可以把两个 RDD 中键相同的元素组合到一起,合并为一个 RDD。我们通常从一个 RDD 中提取某些字段(例如代表事件时间、用户ID 或者其他标识符的字段),并使用这些字段作为 pair RDD 操作中的键。、

4.2 创建 Pair RDD

在 Spark 中有很多种创建 pair RDD 的方式。第 5 章会讲到,很多存储键值对的数据格式会在读取时直接返回由其键值对数据组成的 pair RDD。此外,当需要把一个普通的 RDD 转为 pair RDD 时,可以调用 map() 函数来实现,传递的函数需要返回键值对。

构建键值对 RDD 的方法在不同的语言中会有所不同。

在 Python 中,为了让提取键之后的数据能够在函数中使用,需要返回一个由二元组组成的RDD(见例4-1)。

# 例4-1:在Python 中使用第一个单词作为键创建出一个 pair RDD
# 在Python 中,为了让提取键之后的数据能够在函数中使用,需要返回一个由二元组组成的 RDD
# 要从一个内存中的数据集创建 pair RDD 时,只需要对这个由二元组组成的集合调用 SparkContext.parallelize() 方法。
pairs = lines.map(lambda x: (x.split(" ")[0], x))

# 例4-2:在 Scala 中使用第一个单词作为键创建出一个 pair RDD
# 在 Scala 中,为了让提取键之后的数据能够在函数中使用,同样需要返回二元组,隐式转换可以让二元组 RDD 支持附加的键值对函数
# 要从一个内存中的数据集创建 pair RDD 时,只需要对这个由二元组组成的集合调用 SparkContext.parallelize() 方法。
val pairs = lines.map(x => (x.split(" ")(0), x))

# 例4-3:在 Java 中使用第一个单词作为键创建出一个 pair RDD
# Java 没有自带的二元组类型,因此 Spark 的 Java API 让用户使用 scala.Tuple2 类来创建二
元组。
# 这个类很简单:Java 用户可以通过new Tuple2(elem1, elem2) 来创建一个新的二元组,并且可以通过._1() 和._2() 方法访问其中的元素。
# Java 用户还需要调用专门的 Spark 函数来创建 pair RDD。例如,要使用 mapToPair() 函数
来代替基础版的 map() 函数。
# 而要使用 Java 从内存数据集创建 pair RDD 的话,则需要使用 SparkContext.parallelizePairs()。
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);
4.3 Pair RDD 的转化操作

Pair RDD 可以使用所有标准 RDD 上的可用的转化操作。

由于pair RDD 中包含二元组,所以需要传递的函数应当操作二元组而不是独立的元素。

Pair RDD 也还是 RDD(元素为 Java 或 Scala 中的 Tuple2 对象或 Python 中的元组),因此同样支持 RDD 所支持的函数。

# 拿前一节中的 pair RDD,筛选掉长度超过 20 个字符的行
# 例4-4:用 Python 对第二个元素进行筛选
result = pairs.filter(lambda keyValue: len(keyValue[1]) < 20)

# 例4-5:用 Scala 对第二个元素进行筛选
pairs.filter{case (key, value) => value.length < 20}

# 例4-6:用 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);

有时,我们只想访问 pair RDD 的值部分,这时操作二元组很麻烦。由于这是一种常见的使用模式,因此 Spark 提供了 mapValues(func) 函数,功能类似于 map{case (x, y): (x, func(y))}。可以在很多例子中使用这个函数。

接下来就依次讨论pair RDD 的各种操作。

4.3.1 聚合操作

当数据集以键值对形式组织的时候,聚合具有相同键的元素进行一些统计是很常见的操作。之前讲解过基础 RDD 上的 fold()、combine()、reduce() 等行动操作,pair RDD 上则有相应的针对键的转化操作。Spark 有一组类似的操作,可以组合具有相同键的值。这些操作返回 RDD,因此它们是转化操作而不是行动操作。

reduceByKey()reduce() 相当类似:它们都接收一个函数,并使用该函数对值进行合并。该操作为数据集中的每个键进行并行的归约操作,每个归约操作会将键相同的值合并起来。因为数据集中可能有大量的键,所以该操作没有被实现为向用户程序返回一个值的行动操作。实际上,它会返回一个由各键和对应键归约出来的结果值组成的新的 RDD。

foldByKey() 则与fold() 相当类似:它们都使用一个与 RDD 和合并函数中的数据类型相同的零值作为初始值。这两个操作所使用的合并函数对零值与另一个元素进行合并,结果仍为该元素。

reduceByKey() 和 mapValues() 类似:用来计算每个键的对应值的均值。这和使用 fold() 和 map() 计算整个 RDD 平均值的过程很相似。

对于求平均,可以使用更加专用的函数来获取同样的结果,后面就会讲到。

熟悉 MapReduce 中的合并器(combiner)概念的读者可能已经注意到,调用 reduceByKey() 和 foldByKey() 会在为每个键计算全局的总结果之前先自动在每台机器上进行本地合并。用户不需要指定合并器。更泛化的 combineByKey() 接口可以让你自定义合并的行为。

# 例4-7:在 Python 中使用 reduceByKey() 和 mapValues() 计算每个键对应的平均值
rdd.mapValues(lambda x: (x, 1)).reduceByKey(lambda x, y: (x[0] + y[0], x[1] + y[1]))

# 例4-8:在 Scala 中使用 reduceByKey() 和 mapValues() 计算每个键对应的平均值
rdd.mapValues(x => (x, 1)).reduceByKey((x, y) => (x._1 + y._1, x._2 + y._2))
# 可以使用 flatMap() 来生成以单词为键、以数字 1 为值的 pair RDD,然后使用 reduceByKey() 对所有的单词进行计数
# 事实上,我们可以对第一个 RDD 使用 countByValue() 函数,以更快地实现单词计数:input.flatMap(x => x.split(" ")).countByValue()
# 例4-9:用 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)

# 例4-10:用 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)

# 例4-11:用 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; }
});

combineByKey() 是最为常用的基于键进行聚合的函数。大多数基于键聚合的函数都是用它实现的。和 aggregate() 一样,combineByKey() 可以让用户返回与输入数据的类型不同的返回值。

要理解 combineByKey(), 要先理解它在处理数据时是如何处理每个元素的。由于 combineByKey() 会遍历分区中的所有元素,因此每个元素的键要么还没有遇到过,要么就和之前的某个元素的键相同。

  1. 如果这是一个新的元素,combineByKey() 会使用一个叫作 createCombiner() 的函数来创建
    那个键对应的累加器的初始值。需要注意的是,这一过程会在每个分区中第一次出现各个键时发生,而不是在整个RDD 中第一次出现一个键时发生。
  2. 如果这是一个在处理当前分区之前已经遇到的键,它会使用 mergeValue() 方法将该键的累加器对应的当前值与这个新的值进行合并。

由于每个分区都是独立处理的,因此对于同一个键可以有多个累加器。如果有两个或者更多的分区都有对应同一个键的累加器,就需要使用用户提供的 mergeCombiners() 方法将各个分区的结果进行合并。

如果已知数据在进行 combineByKey() 时无法从 map 端聚合中获益的话,可以禁用它。例如,由于聚合函数(追加到一个队列)无法在 map 端聚合时节约任何空间,groupByKey() 就把它禁用了。如果希望禁用 map 端组合,就需要指定分区方式。就目前而言,你可以通过传递 rdd.partitioner 来直接使用源 RDD 的分区方式。

combineByKey() 有多个参数分别对应聚合操作的各个阶段,因而非常适合用来解释聚合操作各个阶段的功能划分。

# 例4-12:在 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()

# 例4-13:在 Scala 中使用 combineByKey() 求每个键对应的平均值
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(_))

# 例4-14:在 Java 中使用 combineByKey() 求每个键对应的平均值
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() { returntotal_/(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 中使用这些专用的聚合函数,始终要比手动将数据分组再归约快很多。

并行度调优

到目前为止,我们已经讨论了所有的转化操作的分发方式,但是还没有探讨Spark 是怎样确定如何分割工作的。每个 RDD 都有固定数目的分区,分区数决定了在 RDD 上执行操作时的并行度

在执行聚合或分组操作时,可以要求 Spark 使用给定的分区数。Spark 始终尝试根据集群的大小推断出一个有意义的默认值,但是有时候你可能要对并行度进行调优来获取更好的性能表现。

本章讨论的大多数操作符都能接收第二个参数,这个参数用来指定分组结果或聚合结果的
RDD 的分区数,如例4-15 和例4-16 所示。

# 例4-15:在 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) # 自定义并行度

# 例4-16:在 Scala 中自定义 reduceByKey() 的并行度
val data = Seq(("a", 3), ("b", 4), ("a", 1))
sc.parallelize(data).reduceByKey((x, y) => x + y) // 默认并行度
sc.parallelize(data).reduceByKey((x, y) => x + y) // 自定义并行度

有时,我们希望在除分组操作和聚合操作之外的操作中也能改变 RDD 的分区。对于这样的情况,Spark 提供了 repartition() 函数。它会把数据通过网络进行混洗,并创建出新的分区集合。切记,对数据进行重新分区是代价相对比较大的操作。Spark 中也有一个优化版的 repartition(),叫作 coalesce()。你可以使用 Java 或 Scala 中的 rdd.partitions.size() 以及 Python 中的 rdd.getNumPartitions 查看 RDD 的分区数,并确保调用 coalesce() 时将 RDD 合并到比现在的分区数更少的分区中。

.3.2 数据分组

对于有键的数据,一个常见的用例是将数据根据键进行分组——比如查看一个顾客的所有订单。

如果数据已经以预期的方式提取了键,groupByKey() 就会使用RDD 中的键来对数据进行分组。对于一个由类型 K 的键和类型 V 的值组成的 RDD,所得到的结果 RDD 类型会是[K, Iterable[V]]。

groupBy() 可以用于未成对的数据上,也可以根据除键相同以外的条件进行分组。它可以接收一个函数,对源 RDD 中的每个元素使用该函数,将返回结果作为键再进行分组。

如果你发现自己写出了先使用 groupByKey() 然后再对值使用 reduce() 或者 fold() 的代码,你很有可能可以通过使用一种根据键进行聚合的函数来更高效地实现同样的效果。对每个键归约数据,返回对应每个键的归约值的 RDD,而不是把 RDD 归约为一个内存中的值。例如,rdd.reduceByKey(func) 与 rdd.groupByKey().mapValues(value => value.reduce(func)) 等价,但是前者更为高效,因为它避免了为每个键创建存放值的列表的步骤。

除了对单个 RDD 的数据进行分组,还可以使用一个叫作 cogroup() 的函数对多个共享同一个键的 RDD 进行分组。对两个键的类型均为 K 而值的类型分别为 V 和 W 的 RDD 进行 cogroup() 时,得到的结果 RDD 类型为 [(K, (Iterable[V], Iterable[W]))]。如果其中的一个 RDD 对于另一个 RDD 中存在的某个键没有对应的记录,那么对应的迭代器则为空。

下一节中要讲的连接操作的构成要素 cogroup() 提供了为多个 RDD 进行数据分组的方法。不仅可以用于实现连接操作,还可以用来求键的交集。除此之外,还能同时应用于三个及以上的 RDD。

4.3.3 连接

将有键的数据与另一组有键的数据一起使用是对键值对数据执行的最有用的操作之一。连接数据可能是 pair RDD 最常用的操作之一。连接方式多种多样:右外连接、左外连接、交叉连接以及内连接。

内连接:普通的 join 操作符表示内连接。只有在两个 pair RDD 中都存在的键才会输出。当一个输入对应的某个键有多个值时,生成的 pair RDD 会包括来自两个输入 RDD 的每一组相对应的记录。

例4-17:在Scala shell 中进行内连接
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,但是允许结果中存在其中的一个 pair RDD 所缺失的键。

在使用 leftOuterJoin() 产生的 pair RDD 中,源 RDD 的每一个键都有对应的记录。每个键相应的值是由一个源 RDD 中的值与一个包含第二个 RDD 的值的 Option(在 Java 中为 Optional)对象组成的二元组。在 Python 中,如果一个值不存在,则使用 None 来表示;而数据存在时就用常规的值来表示,不使用任何封装。和 join() 一样,每个键可以得到多条记录;当这种情况发生时,我们会得到两个 RDD 中对应同一个键的两组值的笛卡尔积。

例4-18:leftOuterJoin() 与 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))
}
4.3.4 数据排序

很多时候,让数据排好序是很有用的,尤其是在生成下游输出时。如果键有已定义的顺序,就可以对这种键值对 RDD 进行排序。当把数据排好序后,后续对数据进行 collect() 或 save() 等操作都会得到有序的数据。

我们经常要将 RDD 倒序排列,因此 sortByKey() 函数接收一个叫作 ascending 的参数,表示我们是否想要让结果按升序排序(默认值为 true)。有时我们也可能想按完全不同的排序依据进行排序。要支持这种情况,我们可以提供自定义的比较函数。

# 将整数转为字符串,然后使用字符串比较函数来对 RDD 进行排序
# 例4-19:在 Python 中以字符串顺序对整数进行自定义排序
rdd.sortByKey(ascending=True, numPartitions=None, keyfunc = lambda x: str(x))

# 例4-20:在 Scala 中以字符串顺序对整数进行自定义排序
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()

# 例4-21:在 Java 中以字符串顺序对整数进行自定义排序
class IntegerComparator implements Comparator<Integer> {
  public int compare(Integer a, Integer b) {
    return String.valueOf(a).compareTo(String.valueOf(b))
  }
}
rdd.sortByKey(comp)
4.4 Pair RDD 的行动操作

和转化操作一样,所有基础 RDD 支持的传统行动操作也都在 pair RDD 上可用。Pair RDD 提供了一些额外的行动操作,可以让我们充分利用数据的键值对特性。

就 pair RDD 而言,还有别的一些行动操作可以保存 RDD,会在第 5 章介绍。

4.5 数据分区(进阶)

本章要讨论的最后一个 Spark 特性是对数据集在节点间的分区进行控制。在分布式程序中,通信的代价是很大的,因此控制数据分布以获得最少的网络传输可以极大地提升整体性能。和单节点的程序需要为记录集合选择合适的数据结构一样,Spark 程序可以通过控制 RDD 分区方式来减少通信开销。分区并不是对所有应用都有好处的——比如,如果给定 RDD 只需要被扫描一次,我们完全没有必要对其预先进行分区处理。只有当数据集多次在诸如连接这种基于键的操作中使用时,分区才会有帮助。

Spark 中所有的键值对 RDD 都可以进行分区。系统会根据一个针对键的函数对元素进行分组。尽管 Spark 没有给出显示控制每个键具体落在哪一个工作节点上的方法(部分原因是 Spark 即使在某些节点失败时依然可以工作),但 Spark 可以确保同一组的键出现在同一个节点上。比如,你可能使用哈希分区将一个 RDD 分成了 100 个分区,此时键的哈希值对 100 取模的结果相同的记录会被放在一个节点上。你也可以使用范围分区法,将键在同一个范围区间内的记录都放在同一个节点上。

举个简单的例子,我们分析这样一个应用,它在内存中保存着一张很大的用户信息表——也就是一个由 (UserID, UserInfo) 对组成的 RDD,其中 UserInfo 包含一个该用户所订阅的主题的列表。该应用会周期性地将这张表与一个小文件进行组合,这个小文件中存着过去五分钟内发生的事件——其实就是一个由(UserID, LinkInfo) 对组成的表,存放着过去五分钟内某网站各用户的访问情况。例如,我们可能需要对用户访问其未订阅主题的页面的情况进行统计。我们可以使用 Spark 的 join() 操作来实现这个组合操作,其中需要把 UserInfo 和 LinkInfo 的有序对根据 UserID 进行分组。

例4-22:简单的Scala 应用
// 初始化代码:从 HDFS 上的一个 Hadoop SequenceFile 中读取用户信息
// userData 中的元素会根据它们被读取时的来源,即 HDFS 块所在的节点来分布
// Spark 此时无法获知某个特定的 UserID 对应的记录位于哪个节点上
val sc = new SparkContext(...)
val userData = sc.sequenceFile[UserID, UserInfo]("hdfs://...").persist()
// 周期性调用函数来处理过去五分钟产生的事件日志
// 假设这是一个包含 (UserID, LinkInfo) 对的 SequenceFile
def processNewLogs(logFileName: String) {
  val events = sc.sequenceFile[UserID, LinkInfo](logFileName)
  val joined = userData.join(events)// RDD of (UserID, (UserInfo, LinkInfo)) pairs
  val offTopicVisits = joined.filter {
  case (userId, (userInfo, linkInfo)) => // Expand the tuple into its components
    !userInfo.topics.contains(linkInfo.topic)
  }.count()
  println("Number of visits to non-subscribed topics: " + offTopicVisits)
}

这段代码可以正确运行,但是不够高效。这是因为在每次调用 processNewLogs() 时都会用到 join() 操作,而我们对数据集是如何分区的却一无所知。默认情况下,连接操作会将两个数据集中的所有键的哈希值都求出来,将该哈希值相同的记录通过网络传到同一台机器上,然后在那台机器上对所有键相同的记录进行连接操作。因为 userData 表比每五分钟出现的访问日志表 events 要大得多,所以要浪费时间做很多额外工作:在每次调用时都对 userData 表进行哈希值计算和跨节点数据混洗,虽然这些数据从来都不会变化。

要解决这一问题也很简单:在程序开始时,对 userData 表使用 partitionBy() 转化操作,将这张表转为哈希分区。可以通过向partitionBy 传递一个spark.HashPartitioner 对象来实现该操作。

例4-23: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 进行进一步操作(比如连接操作)时有多少任务会并行执行。总的来说,分区数目至少应该和集群中的总核心数一样

如果没有将 partitionBy() 转化操作的结果持久化,那么后面每次用到这个 RDD 时都会重复地对数据进行分区操作。不进行持久化会导致整个 RDD 谱系图重新求值。那样的话,partitionBy() 带来的好处就会被抵消,导致重复对数据进行分区以及跨节点的混洗,和没有指定分区方式时发生的情况十分相似。

事实上,许多其他 Spark 操作会自动为结果 RDD 设定已知的分区方式信息,而且除 join() 外还有很多操作也会利用到已有的分区信息。比如,sortByKey() 和 groupByKey() 会分别生成范围分区的 RDD 和哈希分区的RDD。而另一方面,诸如 map() 这样的操作会导致新的 RDD 失去父 RDD 的分区信息,因为这样的操作理论上可能会修改每条记录的键。

接下来的几节中,我们会讨论如何获取RDD 的分区信息,以及数据分区是如何影响各种Spark 操作的。

Java 和 Python 中的数据分区

Spark 的 Java 和 Python 的 API 都和 Scala 的一样,可以从数据分区中获益。不过,在Python 中,你不能将 HashPartitioner 对象传给 partitionBy,而只需要把需要的分区数传递过去,例如rdd.partitionBy(100)。

4.5.1 获取RDD的分区方式

在 Scala 和 Java 中,你可以使用 RDD 的 partitioner 属性(Java 中使用 partitioner() 方法)来获取 RDD 的分区方式。它会返回一个 scala.Option 对象,这是 Scala 中用来存放可能存在的对象的容器类。你可以对这个 Option 对象调用 isDefined() 来检查其中是否有值,调用 get() 来获取其中的值。如果存在值的话,这个值会是一个 spark.Partitioner 对象。这本质上是一个告诉我们 RDD 中各个键分别属于哪个分区的函数。

在 Spark shell 中使用 partitioner 属性不仅是检验各种 Spark 操作如何影响分区方式的一种好办法,还可以用来在你的程序中检查想要使用的操作是否会生成正确的结果。

# 例4-24:获取 RDD 的分区方式
scala> val pairs = sc.parallelize(List((1, 1), (2, 2), (3, 3)))
pairs: spark.RDD[(Int, Int)] = ParallelCollectionRDD[0] at parallelize at
<console>:12
//创建一个由 (Int, Int) 对组成的 RDD,初始时没有分区方式信息(一个值为 None 的 Option 对象)
scala> pairs.partitioner
res0: Option[spark.Partitioner] = None
//通过对第一个 RDD 进行哈希分区,创建出第二个 RDD
scala> val partitioned = pairs.partitionBy(new spark.HashPartitioner(2))
partitioned: spark.RDD[(Int, Int)] = ShuffledRDD[1] at partitionBy at <console>:14
scala> partitioned.partitioner
res1: Option[spark.Partitioner] = Some(spark.HashPartitioner@5147788d)
//如果确实要在后续操作中使用 partitioned,那就应当在定义 partitioned 时,在第三行输入的最后加上 persist()
//如果不调用persist() 的话,后续的 RDD 操作会对 partitioned 的整个谱系重新求值,这会导致对 pairs 一遍又一遍地进行哈希分区操作

Python API 没有提供查询分区方式的方法,但是 Spark 内部仍然会利用已有的分区信息。

4.5.2 从分区中获益的操作

Spark 的许多操作都引入了将数据根据键跨节点进行混洗的过程。所有这些操作都会从数据分区中获益。就 Spark 1.0 而言,能够从数据分区中获益的操作有:
cogroup()
groupWith()
join()
leftOuterJoin()
rightOuterJoin()
groupByKey()
reduceByKey()
combineByKey()
lookup()

对于像 reduceByKey() 这样只作用于单个 RDD 的操作,运行在未分区的 RDD 上的时候会导致每个键的所有对应值都在每台机器上进行本地计算,只需要把本地最终归约出的结果值从各工作节点传回主节点,所以原本的网络开销就不算大。而对于诸如 cogroup() 和 join() 这样的二元操作,预先进行数据分区会导致其中至少一个 RDD(使用已知分区器的那个 RDD)不发生数据混洗。如果两个 RDD 使用同样的分区方式,并且它们还缓存在同样的机器上(比如一个 RDD 是通过 mapValues() 从另一个 RDD 中创建出来的,这两个 RDD 就会拥有相同的键和分区方式),或者其中一个 RDD 还没有被计算出来,那么跨节点的数据混洗就不会发生了。

4.5.3 影响分区方式的操作

Spark 内部知道各操作会如何影响分区方式,并将会对数据进行分区的操作的结果 RDD 自动设置为对应的分区器。例如,如果你调用 join() 来连接两个 RDD:由于键相同的元素会被哈希到同一台机器上,Spark 知道输出结果也是哈希分区的,这样对连接的结果进行诸如 reduceByKey() 这样的操作时就会明显变快。

不过,转化操作的结果并不一定会按已知的分区方式分区,这时输出的RDD 可能就会没有设置分区器。例如,当你对一个哈希分区的键值对 RDD 调用 map() 时,由于传给 map() 的函数理论上可以改变元素的键,因此结果就不会有固定的分区方式。Spark 不会分析你的函数来判断键是否会被保留下来。不过,Spark 提供了另外两个操作 mapValues() 和 flatMapValues() 作为替代方法,它们可以保证每个二元组的键保持不变。

这里列出了所有会为生成的结果 RDD 设好分区方式的操作:
cogroup()
groupWith()
join()
leftOuterJoin()
rightOuterJoin()
groupByKey()
reduceByKey()
combineByKey()
partitionBy()
sort()
mapValues()
flatMapValues():如果父 RDD 有分区方式的话
filter():如果父 RDD 有分区方式的话

其他所有的操作生成的结果都不会存在特定的分区方式。

最后,对于二元操作,输出数据的分区方式取决于父 RDD 的分区方式。默认情况下,结果会采用哈希分区,分区的数量和操作的并行度一样。不过,如果其中的一个父 RDD 已经设置过分区方式,那么结果就会采用那种分区方式;如果两个父 RDD 都设置过分区方式,结果 RDD 会采用第一个父 RDD 的分区方式。

4.5.4 示例:PageRank

PageRank 是一种从 RDD 分区中获益的更复杂的算法,我们以它为例进行分析。PageRank 算法是以 Google 的拉里· 佩吉(Larry Page)的名字命名的,用来根据外部文档指向一个文档的链接,对集合中每个文档的重要程度赋一个度量值。该算法可以用于对网页进行排序,当然,也可以用于排序科技文章或社交网络中有影响的用户。

PageRank 是执行多次连接的一个迭代算法,因此它是 RDD 分区操作的一个很好的用例。算法会维护两个数据集:一个由 (pageID, linkList) 的元素组成,包含每个页面的相邻页面的列表;另一个由 (pageID, rank) 元素组成,包含每个页面的当前排序值。

// 例4-25:Scala 版 PageRank
// 假设相邻页面列表以 Spark objectFile 的形式存储
val links = sc.objectFile[(String, Seq[String])]("links")
              .partitionBy(new HashPartitioner(100))
              .persist()
// 将每个页面的排序值初始化为 1.0;由于使用 mapValues,生成的 RDD 的分区方式会和"links"的一样
var ranks = links.mapValues(v => 1.0)

// 在每次迭代中,对页面 p,向其每个相邻页面(有直接链接的页面)发送一个值为 rank(p)/numNeighbors(p) 的贡献值,不断更新 ranks 变量
// 在此过程中,算法会逐渐收敛于每个页面的实际 PageRank 值
// 在实际操作中,收敛通常需要大约 10 轮迭代
for(i <- 0 until 10) {
  // 对当前的 ranksRDD 和静态的 linksRDD 进行一次 join() 操作,来获取每个页面 ID 对应的相邻页面列表和当前的排序值
  val contributions = links.join(ranks).flatMap {
    case (pageId, (links, rank)) =>
      links.map(dest => (dest, rank / links.size))
  }
  // 再把这些贡献值按照页面ID(根据获得共享的页面)分别累加起来,把该页面的排序值设为 0.15 + 0.85 * contributionsReceived
  // 使用 flatMap 创建出“contributions”来记录每个页面对各相邻页面的贡献
  ranks = contributions.reduceByKey((x, y) => x + y).mapValues(v => 0.15 + 0.85 * v)
}

// 写出最终排名
ranks.saveAsTextFile("ranks")

确保 RDD 以比较高效的方式进行分区,以最小化通信开销:

(1)请注意,linksRDD 在每次迭代中都会和 ranks 发生连接操作。由于 links 是一个静态数据集,所以我们在程序一开始的时候就对它进行了分区操作,这样就不需要把它通过网络进行数据混洗了。实际上,linksRDD 的字节数一般来说也会比ranks 大很多,毕竟它包含每个页面的相邻页面列表(由页面 ID 组成),而不仅仅是一个 Double 值,因此这一优化相比 PageRank 的原始实现(例如普通的 MapReduce)节约了相当可观的网络通信开销。
(2)出于同样的原因,我们调用 links 的 persist() 方法,将它保留在内存中以供每次迭代使用。
(3)当我们第一次创建 ranks 时,我们使用 mapValues() 而不是 map() 来保留父 RDD(links)的分区方式,这样对它进行的第一次连接操作就会开销很小。
(4)在循环体中,我们在 reduceByKey() 后使用 mapValues(),因为 reduceByKey() 的结果已经是哈希分区的了,这样一来,下一次循环中将映射操作的结果再次与links 进行连接操作时就会更加高效。

为了最大化分区相关优化的潜在作用,你应该在无需改变元素的键时尽量使用 mapValues() 或 flatMapValues()。

4.5.5 自定义分区方式

虽然 Spark 提供的 HashPartitioner 与 RangePartitioner 已经能够满足大多数用例,但 Spark 还是允许你通过提供一个自定义的 Partitioner 对象来控制 RDD 的分区方式。这可以让你利用领域知识进一步减少通信开销。

举个例子,假设我们要在一个网页的集合上运行前一节中的 PageRank 算法。在这里,每个页面的 ID(RDD 中的键)是页面的 URL。当我们使用简单的哈希函数进行分区时,拥有相似的URL 的页面(比如 http://www.cnn.com/WORLDhttp://www.cnn.com/US )可能会被分到完全不同的节点上。然而,我们知道在同一个域名下的网页更有可能相互链接。

由于 PageRank 需要在每次迭代中从每个页面向它所有相邻的页面发送一条消息,因此把这些页面分组到同一个分区中会更好。可以使用自定义的分区器来实现仅根据域名而不是整个 URL 来分区。

要实现自定义的分区器,你需要继承 org.apache.spark.Partitioner 类并实现下面三个方法。
• numPartitions: Int:返回创建出来的分区数。
• getPartition(key: Any): Int:返回给定键的分区编号(0 到 numPartitions-1)。
• equals():Java 判断相等性的标准方法。这个方法的实现非常重要,Spark 需要用这个方法来检查你的分区器对象是否和其他分区器实例相同,这样 Spark 才可以判断两个 RDD 的分区方式是否相同。

有一个问题需要注意,当你的算法依赖于 Java 的 hashCode() 方法时,这个方法有可能会返回负数。你需要十分谨慎,确保 getPartition() 永远返回一个非负数。

// 编写一个前面构思的基于域名的分区器,这个分区器只对 URL 中的域名部分求哈希
// 例4-26:Scala 自定义分区方式
class DomainNamePartitioner(numParts: Int) extends Partitioner {
  override def numPartitions: Int = numParts
  override def getPartition(key: Any): Int = {
    val domain = new Java.net.URL(key.toString).getHost()
    val code = (domain.hashCode % numPartitions)
    if(code < 0) {
      code + numPartitions // 使其非负
    }else{
      code
  }
}
  // 用来让 Spark 区分分区函数对象的 Java equals 方法
  // 在 equals() 方法中,使用 Scala 的模式匹配操作符(match)来检查 other 是否是 DomainNamePartitioner,并在成立时自动进行类型转换;这和 Java 中的 instanceof() 是一样的
  override def equals(other: Any): Boolean = other match {
    case dnp: DomainNamePartitioner =>
      dnp.numPartitions == numPartitions
    case _ =>
      false
  }
}

// 在 Java 中创建一个自定义 Partitioner 的方法与 Scala 中的做法非常相似:只需要扩展 spark.Partitioner 类并且实现必要的方法即可。

# 在 Python 中,不需要扩展 Partitioner 类,而是把一个特定的哈希函数作为一个额外的参数传给 RDD.partitionBy() 函数
# 注意,这里你所传过去的哈希函数会被与其他 RDD 的分区函数区分开来
# 如果你想要对多个 RDD 使用相同的分区方式,就应该使用同一个函数对象,比如一个全局函数,而不是为每个 RDD 创建一个新的函数对象
# 例4-27:Python 自定义分区方式
import urlparse
def hash_domain(url):
  return hash(urlparse.urlparse(url).netloc)
rdd.partitionBy(20, hash_domain) # 创建20个分区

使用自定义的 Partitioner 是很容易的:只要把它传给 partitionBy() 方法即可。Spark 中有许多依赖于数据混洗的方法,比如 join() 和 groupByKey(),它们也可以接收一个可选的 Partitioner 对象来控制输出数据的分区方式。

4.6 总结

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350