Spark2-初学者指南-全-
Spark2 初学者指南(全)
原文:
zh.annas-archive.org/md5/06608df603d1ca0304c6b60fd0c9ab2d译者:飞龙
前言
命名为 Spark 的数据处理框架最初是为了证明,通过在多次迭代中重复使用数据集,它可以在 Hadoop MapReduce 作业表现不佳的地方提供价值。研究论文《Mesos:数据中心细粒度资源共享平台》讨论了 Spark 设计背后的哲学。加州大学伯克利分校的研究人员为了测试 Mesos 而构建的一个非常简单的参考实现,后来发展成为一个完整的数据处理框架,后来成为 Apache 项目中最活跃的项目之一。它从一开始就被设计用来在 Hadoop、Mesos 等集群上以及独立模式下进行分布式数据处理。Spark 是一个基于 JVM 的数据处理框架,因此它可以在支持 JVM 应用程序的大多数操作系统上运行。Spark 在 UNIX 和 Mac OS X 平台上广泛安装,Windows 的采用率正在增加。
Spark 提供了使用 Scala、Java、Python 和 R 编程语言统一的编程模型。换句话说,无论使用哪种语言来编写 Spark 应用程序,API 在所有语言中几乎都是相同的。这样,组织可以采用 Spark 并在他们选择的编程语言中开发应用程序。这也使得如果需要的话,Spark 应用程序可以从一种语言快速迁移到另一种语言而无需太多努力。Spark 的大部分开发都是使用 Scala 进行的,因此 Spark 编程模型本质上支持函数式编程原则。最基础的 Spark 数据抽象是弹性分布式数据集(RDD),所有其他库都是基于它构建的。基于 RDD 的 Spark 编程模型是开发者可以构建数据处理应用程序的最低级别。
Spark 发展迅速,以满足更多数据处理用例的需求。当在产品路线图上采取这样的前瞻性步骤时,出现了使编程对商业用户更高级别的需求。建立在 Spark Core 之上的 Spark SQL 库,通过其 DataFrame 抽象,是为了满足大量非常熟悉无处不在的 SQL 的开发者的需求。
数据科学家使用 R 来满足他们的计算需求。R 的最大局限性是所有需要处理的数据都应该适合在运行 R 程序的计算机的主内存中。Spark 的 R API 将数据科学家引入了他们熟悉的数据框抽象的分布式数据处理世界。换句话说,使用 Spark 的 R API,数据处理可以在 Hadoop 或 Mesos 上并行进行,远远超出宿主计算机的本地内存限制。
在当前大规模应用程序收集数据的时代,摄入数据的速度非常高。许多应用用例要求对流数据进行实时处理。建立在 Spark Core 之上的 Spark Streaming 库正是如此。
静态数据或流数据被输入到机器学习算法中,以训练数据模型并使用它们来回答业务问题。在 Spark 之前创建的所有机器学习框架在处理计算机的内存、无法进行并行处理、重复读写周期等方面都有许多限制。Spark 没有这些限制,因此建立在 Spark Core 和 Spark DataFrames 之上的 Spark MLlib 机器学习库最终成为了一个最佳的机器学习库,它将数据处理管道和机器学习活动粘合在一起。
图是一种非常有用的数据结构,在许多特殊用例中被广泛使用。在图数据结构中处理数据的算法计算量很大。在 Spark 之前,出现了许多图处理框架,其中一些在处理方面非常快,但预处理数据以生成图数据结构在大多数这些图处理应用中变成了一个很大的瓶颈。建立在 Spark 之上的 Spark GraphX 库填补了这一空白,使得数据处理和图处理成为连锁活动。
在过去,存在许多数据处理框架,其中许多是专有的,迫使组织陷入供应商锁定陷阱。Spark 为各种数据处理需求提供了一个非常有用的替代方案,无需任何许可费用;同时,它得到了许多领先公司的支持,提供了专业的生产支持。
本书涵盖内容
第一章, Spark 基础 讨论了 Spark 作为框架的基本原理,包括其 API 和附带库,以及 Spark 交互的整个数据处理生态系统。
第二章, Spark 编程模型 讨论了 Spark 中使用的基于函数式编程方法论原则的统一编程模型,并涵盖了弹性分布式数据集(RDD)、Spark 转换和 Spark 动作的基本原理。
第三章, Spark SQL 讨论了 Spark SQL,这是 Spark 中最强大的库之一,用于使用无处不在的 SQL 构造与 Spark DataFrame API 结合来操作数据,以及它是如何与 Spark 程序一起工作的。本章还讨论了 Spark SQL 如何用于从各种数据源访问数据,从而实现数据源的数据处理统一。
第四章, 《Spark Programming with R》讨论了 SparkR 或 R on Spark,这是 Spark 的 R API;这使用户能够利用他们熟悉的数据框抽象来使用 Spark 的数据处理能力。它为 R 用户熟悉 Spark 数据处理生态系统提供了一个非常好的基础。
第五章,《Spark Data Analysis with Python》讨论了使用 Spark 进行数据处理和使用 Python 进行数据分析,利用 Python 可用的各种图表和绘图库。本章讨论了将这两个相关活动结合在一起作为一个 Spark 应用程序,其中 Python 是首选的编程语言。
第六章,《Spark Stream Processing》讨论了 Spark Streaming,这是捕获和处理作为流摄取的数据的最强大的 Spark 库之一。还讨论了作为分布式消息代理的 Kafka 和一个作为消息消费者的 Spark Streaming 应用程序。
第七章,《Spark Machine Learning》讨论了 Spark MLlib,这是用于开发入门级机器学习应用程序的最强大的 Spark 库之一。
第八章,《Spark Graph Processing》讨论了 Spark GraphX,这是处理图数据结构最强大的 Spark 库之一,并附带大量用于在图中处理数据的算法。本章涵盖了 GraphX 的基础和一些使用 GraphX 提供的算法实现的用例。
第九章,《Designing Spark Applications》讨论了 Spark 数据处理应用程序的设计和开发,涵盖了本书前几章中提到的 Spark 的各种功能。
你需要这本书什么
要运行代码示例并进行进一步的活动以了解更多关于该主题的信息,至少需要在独立机器上安装 Spark 2.0.0 或更高版本。对于第六章,即 Spark Stream Processing,需要安装和配置 Kafka 作为消息代理,其命令行生产者产生消息,而使用 Spark 开发的应用程序作为这些消息的消费者。
这本书面向谁
如果你是一名应用程序开发人员、数据科学家或对将 Spark 的数据处理能力与 R 相结合、将数据处理、流处理、机器学习和图处理整合到一个统一且高度互操作的框架中感兴趣,并使用 Scala 或 Python 通过统一的 API 进行数据处理的解决方案架构师,这本书适合你。
习惯用法
在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“将此属性spark.driver.memory自定义为一个更高的值是一个好主意。”
代码块设置如下:
Python 3.5.0 (v3.5.0:374f501f4567, Sep 12 2015, 11:00:19)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
任何命令行输入或输出都如下所示:
$ python
Python 3.5.0 (v3.5.0:374f501f4567, Sep 12 2015, 11:00:19)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“本书中的快捷键基于Mac OS X 10.5+方案。”
注意
警告或重要注意事项如下所示。
小贴士
技巧和窍门如下所示。
读者反馈
我们的读者反馈总是受欢迎的。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。要发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书的标题。如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已成为 Packt 图书的骄傲拥有者,我们有一些东西可以帮助您充分利用您的购买。
下载示例代码
您可以从您的账户下载本书的示例代码文件,账户地址为www.packtpub.com。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持选项卡上。
-
点击代码下载与勘误。
-
在搜索框中输入书的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击代码下载。
文件下载完成后,请确保您使用最新版本的以下软件解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Apache-Spark-2-for-Beginners。我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们!
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出中的变化。您可以从 www.packtpub.com/sites/default/files/downloads/ApacheSpark2forBeginners_ColorImages.pdf 下载此文件。
错误清单
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站或添加到该标题的错误清单部分。
查看之前提交的错误清单,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误清单部分。
盗版
在互联网上盗版受版权保护的材料是所有媒体中持续存在的问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 copyright@packtpub.com 联系我们,并提供指向疑似盗版材料的链接。
我们感谢您的帮助,以保护我们的作者和我们为您提供有价值内容的能力。
咨询
如果您对本书的任何方面有问题,您可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。
第一章:Spark 基础
数据是任何组织最重要的资产之一。在组织中收集和使用数据的规模正在超越想象。数据被摄入的速度、正在使用的不同数据类型的多样性,以及正在处理和存储的数据量都在每一刻打破历史记录。如今,即使在小型组织中,数据量也从千兆字节增长到太字节再到拍字节,这种情况非常普遍。同样,处理需求也在增长,需要具备处理静态数据和移动数据的能力。
任何组织的成功都取决于其领导者的决策,而为了做出明智的决策,你需要有良好的数据和数据处理所产生信息的支持。这给如何在及时且成本效益的方式下处理数据带来了巨大挑战,以便能够做出正确的决策。自从计算机的早期阶段以来,数据处理技术已经发展。无数的数据处理产品和框架进入市场,并在这些年中消失。大多数这些数据处理产品和框架在本质上不是通用的。大多数组织依赖他们自己的定制应用程序来满足他们的数据处理需求,以隔离的方式,或者与特定产品结合使用。
大规模互联网应用,通常称为物联网(IoT)应用,预示了处理大量以极快速度摄入的各种类型数据的开放框架的普遍需求。大型网站、媒体流应用以及组织的大量批量处理需求使这一需求更加相关。随着互联网的增长,开源社区也在显著增长,提供由知名软件公司支持的量产级软件。大量公司开始使用开源软件,并将它们部署到他们的生产环境中。
从技术角度来看,数据处理需求面临着巨大的挑战。数据量开始从单个机器溢出到由大量机器组成的集群。单个 CPU 的处理能力达到了顶峰,现代计算机开始将它们组合在一起以获得更多的处理能力,这被称为多核计算机。应用程序没有设计和开发来利用多核计算机中的所有处理器,浪费了典型现代计算机中可用的大量处理能力。
注意
在本书的整个过程中,术语节点、主机和机器指的是以独立模式或集群模式运行的计算机。
在这种背景下,一个理想的数据处理框架应该具备哪些品质?
-
它应该能够处理分布在计算机集群中的数据块
-
它应该能够以并行方式处理数据,以便将巨大的数据处理作业分成多个并行处理的任务,从而大大减少处理时间
-
它应该能够使用计算机中所有核心或处理器的处理能力
-
它应该能够使用集群中所有可用的计算机
-
它应该能够在通用硬件上运行
有两个开源数据处理框架值得提及,它们满足所有这些要求。第一个是 Apache Hadoop,第二个是 Apache Spark。
我们将在本章中介绍以下主题:
-
Apache Hadoop
-
Apache Spark
-
Spark 2.0 安装
Apache Hadoop 概述
Apache Hadoop 是一个从头开始设计的开源软件框架,旨在在计算机集群上执行分布式数据存储,并对分布在计算机集群中的数据进行分布式数据处理。该框架包含一个用于数据存储的分布式文件系统,即Hadoop 分布式文件系统(HDFS),以及一个数据处理框架,即 MapReduce。HDFS 的创建灵感来源于谷歌的研究论文《The Google File System》,而 MapReduce 基于谷歌的研究论文《MapReduce: Simplified Data Processing on Large Clusters》。
通过实施大型 Hadoop 集群进行数据处理,Hadoop 被组织大规模采用。从 Hadoop MapReduce 版本 1(MRv1)到 Hadoop MapReduce 版本 2(MRv2),它经历了巨大的增长。从纯粹的数据处理角度来看,MRv1 由 HDFS 和 MapReduce 作为核心组件组成。许多应用程序,通常称为 Hadoop 上的 SQL 应用程序,如 Hive 和 Pig,堆叠在 MapReduce 框架之上。非常常见的是,尽管这些类型的应用程序是独立的 Apache 项目,但作为一个套件,许多这样的项目提供了巨大的价值。
另一个资源协调器(YARN)项目随着除 MapReduce 类型之外的计算框架在 Hadoop 生态系统中的应用而变得突出。在 YARN 引入到 HDFS 之上,并在组件架构分层视角中位于 MapReduce 之下,用户可以编写自己的应用程序,这些应用程序可以在 YARN 和 HDFS 上运行,以利用 Hadoop 生态系统的分布式数据存储和数据处理能力。换句话说,全新改版的 MapReduce 版本 2(MRv2)成为了一个位于 HDFS 和 YARN 之上的应用框架之一。
图 1 简要介绍了这些组件以及它们是如何堆叠在一起的:

图 1
MapReduce 是一个通用的数据处理模型。数据处理分为两个步骤,即map步骤和reduce步骤。在第一步中,输入数据被分成多个较小的部分,以便每个部分都可以独立处理。一旦map步骤完成,其输出将被整合,最终结果在reduce步骤中生成。在一个典型的词频统计示例中,将每个单词作为键,值为 1 的键值对创建是map步骤。根据键对这些对进行排序,对具有相同键的值的求和属于一个中间的combine步骤。生成包含唯一单词及其出现次数的对是reduce步骤。
从应用编程的角度来看,一个过度简化的 MapReduce 应用程序的基本要素如下:
-
输入位置
-
输出位置
-
实现数据处理所需的 Map 函数需要从
MapReduce库的适当接口和类中 -
实现数据处理所需的 Reduce 函数需要从
MapReduce库的适当接口和类中
将 MapReduce 作业提交到 Hadoop 中运行,一旦作业完成,可以从指定的输出位置获取输出。
将MapReduce数据处理作业分为map和reduce任务的这个两步过程非常有效,并且最终证明非常适合许多批处理数据处理用例。在整个过程中,在幕后发生了大量的输入/输出(I/O)操作。即使在 MapReduce 作业的中间步骤中,如果内部数据结构填充了数据或当任务完成超过一定百分比时,也会发生写入操作。正因为如此,MapReduce 作业的后续步骤必须从磁盘读取。
然后另一个最大的挑战出现在有多个 MapReduce 作业需要按顺序完成时。换句话说,如果一个大型的数据处理工作需要通过两个 MapReduce 作业来完成,使得第一个 MapReduce 作业的输出是第二个 MapReduce 作业的输入。在这种情况下,无论第一个 MapReduce 作业的输出大小如何,都必须将其写入磁盘,第二个 MapReduce 才能将其用作其输入。因此,在这种情况下,存在一个明确且不必要的写入操作。
在许多批处理数据处理的用例中,这些 I/O 操作并不是一个大问题。如果结果高度可靠,对于许多批处理数据处理的用例,延迟是可以容忍的。但最大的挑战出现在进行实时数据处理时。MapReduce 作业中涉及的巨大 I/O 操作使得它不适合具有最低延迟的实时数据处理。
理解 Apache Spark
Spark 是一个基于Java 虚拟机(JVM)的分布式数据处理引擎,它可以扩展,并且与其他许多数据处理框架相比,速度很快。Spark 起源于加州大学伯克利分校,后来成为 Apache 项目中的顶级项目之一。研究论文《Mesos:数据中心细粒度资源共享平台》讨论了 Spark 设计背后的哲学。该研究论文指出:
"为了测试简单专用框架提供价值的假设,我们确定了一类在 Hadoop 上被机器学习研究人员发现表现不佳的工作:迭代型工作,其中数据集在多次迭代中被重复使用。我们构建了一个针对这些工作负载优化的专用框架,称为 Spark。"
Spark 关于速度的最大声明是它能够"在内存中运行程序比 Hadoop MapReduce 快 100 倍,或在磁盘上快 10 倍". Spark 能够提出这个声明,因为它在工作节点的内存中进行处理,并防止与磁盘进行不必要的I/O 操作。Spark 提供的另一个优势是能够在应用程序编程级别链式连接任务,而无需写入磁盘或最小化写入磁盘的次数。
Spark 与 MapReduce 相比,在数据处理方面是如何变得如此高效的?它附带一个非常先进的有向无环图(DAG)数据处理引擎。这意味着对于每个 Spark 作业,都会创建一个 DAG 任务由引擎执行。在数学术语中,DAG 由一组顶点和连接它们的定向边组成。任务将按照 DAG 布局执行。在 MapReduce 的情况下,DAG 只包含两个顶点,一个顶点用于map任务,另一个顶点用于reduce任务。边从map顶点指向reduce顶点。内存中的数据处理加上其基于 DAG 的数据处理引擎使得 Spark 非常高效。在 Spark 的情况下,任务的 DAG 可以非常复杂。幸运的是,Spark 附带了一些工具,可以提供任何正在运行的 Spark 作业的 DAG 的优秀可视化。在一个词频计数示例中,Spark 的 Scala 代码将类似于以下代码片段。这个编程方面的细节将在接下来的章节中介绍:
val textFile = sc.textFile("README.md")
val wordCounts = textFile.flatMap(line => line.split(" ")).map(word =>
(word, 1)).reduceByKey((a, b) => a + b)
wordCounts.collect()
Spark 附带的一个 Web 应用程序能够监控工作节点和应用程序。前面 Spark 作业动态生成的 DAG(有向无环图)将看起来像图 2,如下所示:

图 2
Spark 编程范式非常强大,并公开了一个统一的编程模型,支持在多种编程语言中的应用程序开发。尽管不是所有支持的编程语言都具有功能等价性,Spark 支持使用 Scala、Java、Python 和 R 进行编程。除了用这些编程语言编写 Spark 应用程序之外,Spark 还有一个具有 读取、评估、打印和循环(REPL)功能的交互式 shell,适用于 Scala、Python 和 R 编程语言。目前,Spark 中没有对 Java 的 REPL 支持。Spark REPL 是一个非常通用的工具,可以用来以交互式方式尝试和测试 Spark 应用程序代码。Spark REPL 使原型设计、调试以及更多操作变得简单。
除了核心数据处理引擎之外,Spark 还附带了一组强大的特定领域库,这些库使用核心 Spark 库并提供各种功能,这些功能对各种大数据处理需求非常有用。以下表格列出了支持的库:
| 库 | 用途 | 支持的语言 |
|---|---|---|
| Spark SQL | 允许在 Spark 应用程序中使用 SQL 语句或 DataFrame API | Scala, Java, Python 和 R |
| Spark Streaming | 允许处理实时数据流 | Scala, Java 和 Python |
| Spark MLlib | 允许开发机器学习应用程序 | Scala, Java, Python 和 R |
| Spark GraphX | 允许图处理并支持不断增长的图算法库 | Scala |
Spark 可以部署在各种平台上。Spark 在操作系统 操作系统(OS) Windows 和 UNIX(例如 Linux 和 Mac OS)上运行。Spark 可以在具有支持操作系统的单个节点上以独立模式部署。Spark 还可以在 Hadoop YARN 以及 Apache Mesos 的集群节点上部署。Spark 还可以部署在 Amazon EC2 云上。Spark 可以从各种数据存储中访问数据,其中一些最受欢迎的包括 HDFS、Apache Cassandra、Hbase、Hive 等。除了之前列出的数据存储之外,如果有一个驱动程序或连接器程序可用,Spark 可以从几乎任何数据源访问数据。
提示
本书中所使用的所有示例都是在 Mac OS X 版本 10.9.5 的计算机上开发、测试和运行的。对于所有其他平台(除了 Windows)都适用相同的说明。在 Windows 上,对应于所有 UNIX 命令,有一个具有 .cmd 扩展名的文件,并且必须使用它。例如,对于 UNIX 中的 spark-shell,Windows 中有一个 spark-shell.cmd。程序行为和结果应跨所有支持的操作系统保持一致。
在任何分布式应用程序中,通常会有一个控制执行的驱动程序程序,并且将有一个或多个工作节点。驱动程序程序将任务分配给适当的工作节点。即使 Spark 以独立模式运行,也是如此。在 Spark 应用程序的情况下,其SparkContext对象是驱动程序程序,它与适当的集群管理器通信以运行任务。Spark 核心库的一部分 Spark master、Mesos master 和 Hadoop YARN 资源管理器是 Spark 支持的集群管理器之一。在 Hadoop YARN 部署 Spark 的情况下,Spark 驱动程序程序在 Hadoop YARN 应用程序主进程中运行,或者 Spark 驱动程序程序作为 Hadoop YARN 的客户端运行。图 3描述了 Spark 的独立部署:

图 3
在 Spark 的 Mesos 部署模式下,集群管理器将是Mesos Master。图 4描述了 Spark 的 Mesos 部署:

图 4
在 Spark 的 Hadoop YARN 部署模式下,集群管理器将是 Hadoop 资源管理器,其地址将从 Hadoop 配置中获取。换句话说,在提交 Spark 作业时,不需要提供显式的 master URL,它将从 Hadoop 配置中获取集群管理器的详细信息。图 5描述了 Spark 的 Hadoop YARN 部署:

图 5
Spark 也可以在云中运行。在 Spark 部署到 Amazon EC2 的情况下,除了从常规支持的数据源访问数据外,Spark 还可以访问来自 Amazon 的在线数据存储服务 Amazon S3 的数据。
在您的机器上安装 Spark
Spark 支持使用 Scala、Java、Python 和 R 进行应用程序开发。在这本书中,使用了 Scala、Python 和 R。以下是选择这些语言作为本书示例原因的说明。Spark 交互式 shell 或 REPL 允许用户即时执行程序,就像在终端提示符下输入操作系统命令一样,并且它仅适用于 Scala、Python 和 R 语言。REPL 是在将代码组合到文件中并作为应用程序运行之前尝试和测试 Spark 代码的最佳方式。REPL 甚至可以帮助经验丰富的程序员尝试和测试代码,从而促进快速原型设计。因此,特别是对于初学者来说,使用 REPL 是开始使用 Spark 的最佳方式。
在安装 Spark 和用 Python 和 R 进行 Spark 编程之前,需要先安装 Python 和 R。
Python 安装
访问 www.python.org 下载并安装适用于您的计算机的 Python。安装完成后,请确保所需的二进制文件在操作系统搜索路径中,并且 Python 交互式 shell 能够正常启动。shell 应显示类似以下内容:
$ python
Python 3.5.0 (v3.5.0:374f501f4567, Sep 12 2015, 11:00:19)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
对于图表和绘图,正在使用matplotlib库。
注意
使用 Python 版本 3.5.0 作为首选的 Python 版本。尽管 Spark 支持使用 Python 2.7 进行编程,但作为前瞻性的实践,使用的是最新且最稳定的 Python 版本。此外,大多数重要的库也正在移植到 Python 3.x 版本。
访问 matplotlib.org 下载并安装库。为确保库已正确安装并且图表和绘图能够正确显示,请访问 matplotlib.org/examples/index.html 页面,获取一些示例代码,并检查您的计算机是否具备图表和绘图所需的全部资源和组件。在尝试运行这些图表和绘图示例时,如果在 Python 代码中导入库的上下文中出现缺少区域设置的错误,请在该用户的适当配置文件中设置以下环境变量以消除错误信息:
export LC_ALL=en_US.UTF-8
export LANG=en_US.UTF-8
R 安装
访问 www.r-project.org 下载并安装适用于您的计算机的 R。安装完成后,请确保所需的二进制文件在操作系统搜索路径中,并且 R 交互式 shell 能够正常启动。shell 应显示类似以下内容:
$ r
R version 3.2.2 (2015-08-14) -- "Fire Safety"
Copyright (C) 2015 The R Foundation for Statistical Computing
Platform: x86_64-apple-darwin13.4.0 (64-bit)
R is free software and comes with ABSOLUTELY NO WARRANTY.
You are welcome to redistribute it under certain conditions.
Type 'license()' or 'licence()' for distribution details.
Natural language support but running in an English locale
R is a collaborative project with many contributors.
Type 'contributors()' for more information and
'citation()' on how to cite R or R packages in publications.
Type 'demo()' for some demos, 'help()' for on-line help, or
'help.start()' for an HTML browser interface to help.
Type 'q()' to quit R.
[Previously saved workspace restored]
>
注意
R 版本 3.2.2 是选择使用的 R 版本。
Spark 安装
Spark 的安装可以通过多种不同的方式进行。Spark 安装最重要的先决条件是系统已安装 Java 1.8 JDK,并且JAVA_HOME环境变量设置为指向 Java 1.8 JDK 的安装目录。访问 spark.apache.org/downloads.html 了解、选择和下载适合您计算机的正确类型的安装。本书中给出的示例选择 Spark 版本 2.0.0。任何对从源代码构建和使用 Spark 感兴趣的人应访问:spark.apache.org/docs/latest/building-spark.html 获取说明。默认情况下,从源代码构建 Spark 时,它不会构建 Spark 的 R 库。为此,必须构建 SparkR 库,并在从源代码构建 Spark 时包含适当的配置文件。以下命令显示了如何包含构建 SparkR 库所需的配置文件:
$ mvn -DskipTests -Psparkr clean package
一旦 Spark 安装完成,请在适当的用户配置文件中定义以下环境变量:
export SPARK_HOME=<the Spark installation directory>
export PATH=$SPARK_HOME/bin:$PATH
如果系统中存在多个 Python 可执行版本,那么在以下环境变量设置中明确指定 Spark 使用的 Python 可执行版本会更好:
export PYSPARK_PYTHON=/usr/bin/python
在$SPARK_HOME/bin/pyspark脚本中,有一段代码用于确定 Spark 使用的 Python 可执行版本:
# Determine the Python executable to use if PYSPARK_PYTHON or PYSPARK_DRIVER_PYTHON isn't set:
if hash python2.7 2>/dev/null; then
# Attempt to use Python 2.7, if installed:
DEFAULT_PYTHON="python2.7"
else
DEFAULT_PYTHON="python"
fi
因此,始终明确设置 Spark 的 Python 可执行版本会更好,即使系统中只有一种 Python 版本。这是一种预防措施,以防将来安装了额外的 Python 版本时出现意外行为。
一旦完成所有前面的步骤,请确保 Scala、Python 和 R 的所有 Spark 外壳都能正常工作。在操作系统终端提示符下运行以下命令,并确保没有错误,并且显示的内容类似于以下内容。以下命令集用于启动 Spark 的 Scala REPL:
$ cd $SPARK_HOME
$ ./bin/spark-shellUsing Spark's default log4j profile: org/apache/spark/log4j-defaults.properties
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel).
16/06/28 20:53:48 WARN NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
16/06/28 20:53:49 WARN SparkContext: Use an existing SparkContext, some configuration may not take effect.
Spark context Web UI available at http://192.168.1.6:4040
Spark context available as 'sc' (master = local[*], app id = local-1467143629623).
Spark session available as 'spark'.
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/___/ .__/\_,_/_/ /_/\_\ version 2.0.1
/_/
Using Scala version 2.11.8 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_66)
Type in expressions to have them evaluated.
Type :help for more information.
scala>
scala>exit
在前面的显示中,请核实 JDK 版本、Scala 版本和 Spark 版本是否与 Spark 安装的计算机中的设置一致。需要验证的最重要的一点是不要显示任何错误信息。
以下命令集用于启动 Spark 的 Python REPL:
$ cd $SPARK_HOME
$ ./bin/pyspark
Python 3.5.0 (v3.5.0:374f501f4567, Sep 12 2015, 11:00:19)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
Using Spark's default log4j profile: org/apache/spark/log4j-defaults.properties
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel).
16/06/28 20:58:04 WARN NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/__ / .__/\_,_/_/ /_/\_\ version 2.0.1
/_/
Using Python version 3.5.0 (v3.5.0:374f501f4567, Sep 12 2015 11:00:19)
SparkSession available as 'spark'.
>>>exit()
在前面的显示中,请核实 Python 版本和 Spark 版本是否与 Spark 安装的计算机中的设置一致。需要验证的最重要的一点是不要显示任何错误信息。
以下命令集用于启动 Spark 的 R REPL:
$ cd $SPARK_HOME
$ ./bin/sparkR
R version 3.2.2 (2015-08-14) -- "Fire Safety"
Copyright (C) 2015 The R Foundation for Statistical Computing
Platform: x86_64-apple-darwin13.4.0 (64-bit)
R is free software and comes with ABSOLUTELY NO WARRANTY.
You are welcome to redistribute it under certain conditions.
Type 'license()' or 'licence()' for distribution details.
Natural language support but running in an English locale
R is a collaborative project with many contributors.
Type 'contributors()' for more information and
'citation()' on how to cite R or R packages in publications.
Type 'demo()' for some demos, 'help()' for on-line help, or
'help.start()' for an HTML browser interface to help.
Type 'q()' to quit R.
[Previously saved workspace restored]
Launching java with spark-submit command /Users/RajT/source-code/spark-source/spark-2.0/bin/spark-submit "sparkr-shell" /var/folders/nf/trtmyt9534z03kq8p8zgbnxh0000gn/T//RtmphPJkkF/backend_port59418b49bb6
Using Spark's default log4j profile: org/apache/spark/log4j-defaults.properties
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel).
16/06/28 21:00:35 WARN NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/___/ .__/\_,_/_/ /_/\_\ version 2.0.1
/_/
Spark context is available as sc, SQL context is available as sqlContext
During startup - Warning messages:
1: 'SparkR::sparkR.init' is deprecated.
Use 'sparkR.session' instead.
See help("Deprecated")
2: 'SparkR::sparkRSQL.init' is deprecated.
Use 'sparkR.session' instead.
See help("Deprecated")
>q()
在前面的显示中,请核实 R 版本和 Spark 版本是否与 Spark 安装的计算机中的设置一致。需要验证的最重要的一点是不要显示任何错误信息。
如果 Scala、Python 和 R 的所有 REPL 都工作正常,那么几乎可以肯定 Spark 安装是好的。作为最后的测试,运行一些 Spark 附带的一些示例程序,并确保它们给出的结果接近以下命令显示的结果,并且在控制台没有抛出任何错误信息。当运行这些示例程序时,除了命令下面的输出外,控制台还会显示很多其他信息。为了专注于结果,这些信息被省略了:
$ cd $SPARK_HOME
$ ./bin/run-example SparkPi
Pi is roughly 3.1484
$ ./bin/spark-submit examples/src/main/python/pi.py
Pi is roughly 3.138680
$ ./bin/spark-submit examples/src/main/r/dataframe.R
root
|-- name: string (nullable = true)
|-- age: double (nullable = true)
root
|-- age: long (nullable = true)
|-- name: string (nullable = true)
name
1 Justin
开发工具安装
本书将要讨论的大多数代码都可以在适当的 REPL 中进行尝试和测试。但是,没有一些基本的构建工具,就无法进行适当的 Spark 应用程序开发。作为最低限度的要求,在 Scala 中开发和构建 Spark 应用程序时,Scala 构建工具(sbt)是必需的。请访问www.scala-sbt.org下载和安装 sbt。
Maven 是构建 Java 应用程序的首选构建工具。本书不讨论 Java 中的 Spark 应用程序开发,但最好也在系统中安装 Maven。如果要从源代码构建 Spark,Maven 将非常有用。请访问maven.apache.org下载和安装 Maven。
对于 Scala 和 Java,都有许多可用的集成开发环境(IDEs)。这是一个个人选择的问题,开发者可以根据他/她正在开发的 Spark 应用程序的语言选择他/她喜欢的工具。
可选软件安装
Scala 的 Spark REPL 是一个很好的起点,用于原型设计和测试一些小的代码片段。但是,当需要用 Scala 开发、构建和打包 Spark 应用程序时,拥有基于 sbt 的 Scala 项目和使用支持的 IDE(包括但不限于 Eclipse 或 IntelliJ IDEA)进行开发是很好的。请访问相应的网站下载和安装 Scala 首选的 IDE。
笔记本风格的应用程序开发工具在数据分析师和研究人员中非常普遍。这类似于实验室笔记本。在一个典型的实验室笔记本中,会有实验的说明、详细描述和遵循的步骤。然后进行实验。一旦实验完成,笔记本中就会记录结果。如果将这些结构组合在一起,并适应软件程序的环境,并以实验室笔记本的格式进行建模,那么将会有文档、代码、输入以及运行代码生成的输出。这将产生非常好的效果,尤其是如果程序生成大量的图表和绘图。
小贴士
对于那些不熟悉笔记本风格的应用程序开发 IDE 的用户来说,有一篇非常棒的名为《交互式笔记本:共享代码》的文章,可以在www.nature.com/news/interactive-notebooks-sharing-the-code-1.16261上阅读。在以下部分中,我们将描述作为 Python 可选软件开发 IDE 的 IPython 笔记本。安装后,在开始严肃的开发之前,先熟悉一下这个工具。
IPython
在 Python 进行 Spark 应用程序开发的情况下,IPython 提供了一个优秀的笔记本式开发工具,这是一个用于 Jupyter 的 Python 语言内核。Spark 可以与 IPython 集成,因此当调用 Python 的 Spark REPL 时,它将启动 IPython 笔记本。然后,创建一个笔记本并开始像在 Spark REPL 中给出命令一样在笔记本中编写代码。访问 ipython.org 下载并安装 IPython 笔记本。安装完成后,调用 IPython 笔记本界面并确保一些示例 Python 代码运行良好。从存储笔记本的目录或笔记本将要存储的目录调用命令。在这里,IPython 笔记本是从临时目录启动的。当调用以下命令时,它将打开网络界面,并从那里通过点击下拉菜单中的“新建”并选择适当的 Python 版本来创建一个新的笔记本。
以下截图显示了如何在 IPython 笔记本中将 Markdown 风格的文档、Python 程序和生成的输出组合在一起:
$ cd /Users/RajT/temp
$ ipython notebook

图 6
图 6 展示了如何使用 IPython 笔记本来编写简单的 Python 程序。IPython 笔记本可以被配置为 Spark 的首选外壳,当调用 Python 的 Spark REPL 时,它将启动 IPython 笔记本,并可以使用 IPython 笔记本来进行 Spark 应用程序的开发。为了实现这一点,需要在适当的用户配置文件中定义以下环境变量:
export PYSPARK_DRIVER_PYTHON=ipython
export PYSPARK_DRIVER_PYTHON_OPTS='notebook'
现在,不再从命令提示符调用 IPython 笔记本,而是调用 Python 的 Spark REPL。就像之前所做的那样,创建一个新的 IPython 笔记本并开始用 Python 编写 Spark 代码:
$ cd /Users/RajT/temp
$ pyspark
查看以下截图:

图 7
小贴士
在任何语言的标准 Spark REPL 中,可以使用相对路径引用位于本地文件系统中的文件。当使用 IPython 笔记本时,本地文件需要使用它们的完整路径来引用。
RStudio
在 R 用户社区中,首选的 R IDE 是 RStudio。RStudio 也可以用来开发 R 语言的 Spark 应用程序。访问 www.rstudio.com 下载并安装 RStudio。安装完成后,在运行任何 Spark R 代码之前,必须包含 SparkR 库并设置一些变量以确保 Spark R 程序在 RStudio 中顺利运行。以下代码片段实现了这一点:
SPARK_HOME_DIR <- "/Users/RajT/source-code/spark-source/spark-2.0"
Sys.setenv(SPARK_HOME=SPARK_HOME_DIR)
.libPaths(c(file.path(Sys.getenv("SPARK_HOME"), "R", "lib"), .libPaths()))
library(SparkR)
spark <- sparkR.session(master="local[*]")
在前面的 R 代码中,将 SPARK_HOME_DIR 变量定义更改为指向 Spark 安装的目录。图 8 展示了从 RStudio 运行 Spark R 代码的示例运行情况:

图 8
一旦安装、配置了所有必要的软件,并且它们按照之前给出的详细信息正常工作,就可以为使用 Scala、Python 和 R 进行 Spark 应用程序开发做好准备。
小贴士
Jupyter 笔记本通过为各种语言定制内核实现策略来支持多种语言。对于 Jupyter 有一个本地的 R 内核,即 IRkernel,它可以作为一个 R 包安装。
Apache Zeppelin
Apache Zeppelin 是另一个目前正处于孵化阶段的具有潜力的项目。它是一个基于 Web 的笔记本,类似于 Jupyter,但通过其解释器策略支持多种语言、shell 和技术,从而实现 Spark 应用程序的本能开发。目前它还处于起步阶段,但它有很大的潜力成为最好的基于笔记本的应用程序开发平台之一。Zeppelin 使用笔记本中编写的程序生成数据,具有非常强大的内置图表和绘图功能。
气球(Zeppelin)是通过其解释器框架构建的,具有高度的可扩展性,能够插入多种类型的解释器。最终用户,就像任何其他基于笔记本的系统一样,在笔记本界面中输入各种命令。这些命令将由某些解释器处理以生成输出。与许多其他笔记本式系统不同,Zeppelin 自带支持大量解释器或后端,例如 Spark、Spark SQL、Shell、Markdown 等。在前端方面,它同样采用可插拔架构,即氦框架。后端生成数据由前端组件,如 Angular JS,进行展示。有多种选项可以以表格格式、由解释器生成的原始格式、图表和图形等形式展示数据。由于后端、前端和可插拔各种组件等关注点的架构分离,选择适合特定任务的异构组件是一种很好的方法。同时,它很好地集成了各种组件,提供了一个和谐且用户友好的数据处理生态系统。尽管 Zeppelin 具有各种组件的可插拔架构能力,但其可视化功能有限。换句话说,在 Zeppelin 中,开箱即用的图表和绘图选项只有少数。一旦笔记本运行良好并产生预期的结果,通常情况下,笔记本会被与他人共享,为此,笔记本需要被持久化。在这方面,Zeppelin 再次表现出其高度灵活的笔记本存储系统。笔记本可以被持久化到文件系统、Amazon S3 或 Git,如果需要,还可以添加其他存储目标。
平台即服务(PaaS)在过去几年中随着云作为应用开发和部署平台的大量创新而不断发展。对于软件开发者来说,通过云提供了许多 PaaS 平台,这消除了他们需要拥有自己的应用开发栈的需求。Databricks 引入了一个基于云的大数据平台,用户可以通过基于笔记本的 Spark 应用开发界面访问该平台,同时结合微集群基础设施,Spark 应用可以提交到该基础设施。还有一个社区版,满足更广泛的开发社区的需求。这个 PaaS 平台最大的优势是它是一个基于浏览器的界面,用户可以在多个版本的 Spark 和不同类型的集群上运行他们的代码。
参考文献
如需更多信息,请参阅以下链接:
-
static.googleusercontent.com/media/research.google.com/en//archive/gfs-sosp2003.pdf -
static.googleusercontent.com/media/research.google.com/en//archive/mapreduce-osdi04.pdf
摘要
Spark 是一个非常强大的数据处理平台,支持统一的编程模型。它支持使用 Scala、Java、Python 和 R 进行应用程序开发,提供一系列高度互操作的库,用于满足各种类型的数据处理需求,以及大量利用 Spark 生态系统并覆盖各种其他数据处理用例的第三方库。本章简要介绍了 Spark 以及为 Spark 应用开发设置的开发环境,这些内容将在本书后续章节中介绍。
下一章将讨论 Spark 编程模型、基本抽象和术语、Spark 转换和 Spark 动作,并结合实际应用案例。
第二章:Spark 编程模型
提取(Extract)、转换(Transform)和加载(Load)(ETL)工具随着组织数据量的增长而迅速发展。在数据到达目的地之前,需要将其从一种源移动到一种或多种目的地,并在途中进行处理,这些都是当时的需求。大多数情况下,这些 ETL 工具只支持少数类型的数据,只支持少数类型的数据源和目的地,并且难以扩展以支持新的数据类型、新的源和目的地。由于这些工具的严格限制,有时甚至一个步骤的转换过程也需要分多个步骤完成。这些复杂的方法要求在人力和其他计算资源方面产生不必要的浪费。商业 ETL 供应商的主要论点始终相同,即一种解决方案不能适用于所有情况。因此,使用我们的工具套件而不是市场上可用的点产品。由于对数据处理的大量需求,许多组织陷入了供应商锁定。几乎在 2005 年之前推出的所有工具都没有利用计算机多核架构的真正力量,如果它们支持在通用硬件上运行其工具。因此,使用这些工具进行简单但数据量大的数据处理作业需要数小时,有时甚至需要数天才能完成。
由于 Spark 能够处理大量数据类型以及不断增长的数据源和数据目的地,它在市场上迅速走红。Spark 提供的重要且基本的数据抽象是弹性分布式数据集(RDD)。正如前一章所讨论的,Spark 支持在节点集群上的分布式处理。一旦有节点集群,在数据处理过程中,某些节点可能会死亡。当发生此类故障时,框架应该能够从这些故障中恢复。Spark 正是为此而设计的,这就是 RDD 中的弹性部分所表示的含义。如果需要处理大量数据,并且集群中有可用节点,框架应该具备将大数据集拆分成更小的数据块并将它们并行分配到集群中多个节点进行处理的 capability。Spark 能够做到这一点,这就是 RDD 中的分布式部分所表示的含义。换句话说,Spark 从一开始就被设计成其基本数据抽象能够确定性地拆分成更小的部分,并分布到集群中的多个节点进行并行处理,同时优雅地处理节点故障。
在本章中,我们将涵盖以下主题:
-
使用 Spark 进行函数式编程
-
Spark RDD
-
数据转换和操作
-
Spark 监控
-
Spark 编程基础
-
从文件创建 RDD
-
Spark 库
使用 Spark 进行函数式编程
运行时对象的突变,以及由于程序逻辑产生的副作用而无法从程序或函数中获得一致结果,使得许多应用程序非常复杂。如果编程语言中的函数开始像数学函数那样行为,即函数的输出仅取决于输入,这将为应用程序提供很多可预测性。计算机编程范式非常重视基于此构建函数和其他元素的过程,并且像使用其他数据类型一样使用这些函数,这种范式被称为函数式编程范式。在基于 JVM 的编程语言中,Scala 是其中最重要的之一,它具有非常强大的函数式编程能力,同时不失对象导向性。Spark 主要用 Scala 编写。正因为如此,Spark 从 Scala 中吸取了许多非常好的概念。
理解 Spark RDD
Spark 从 Scala 继承的最重要特性是能够将函数用作 Spark 转换和 Spark 操作的参数。在 Spark 中,RDD 的行为常常就像 Scala 中的集合对象。正因为如此,Scala 集合的一些数据转换方法名称在 Spark RDD 中被用来执行相同的功能。这是一个非常整洁的方法,那些精通 Scala 的人会发现使用 RDD 编程非常容易。在接下来的几节中,我们将看到一些重要特性。
Spark RDD 是不可变的
RDD 的创建基于一些强规则。一旦 RDD 被创建,无论是故意还是无意,都不能对其进行更改。这为我们提供了关于 RDD 构建的另一个见解。正因为如此,当处理 RDD 某一部分的节点死亡时,驱动程序可以重新创建这些部分,并将处理这些部分的任务分配给另一个节点,最终成功完成数据处理工作。
由于 RDD 是不可变的,因此可以将一个大 RDD 拆分成更小的 RDD,将它们分发到各个工作节点进行处理,最后编译结果以生成最终结果,可以安全地进行,无需担心底层数据发生变化。
Spark RDD 是可分发的
如果 Spark 以集群模式运行,其中存在多个可供执行任务的 worker 节点,那么所有这些节点将具有不同的执行上下文。单个任务被分发并在不同的 JVM 上运行。一个大 RDD 的所有活动,包括将其分成更小的块、分发到工作节点进行处理,以及最终组装结果,对用户来说都是完全隐藏的。
Spark 具有从系统故障和其他数据处理过程中发生的错误中恢复的机制,因此这种数据抽象具有高度的鲁棒性。
Spark RDD 存在于内存中
Spark 尽可能地将所有 RDD 保留在内存中。只有在 Spark 内存不足或数据大小超出容量时,才会将其写入磁盘。RDD 的大部分处理都在内存中进行,这也是 Spark 能够以闪电般的速度处理数据的原因。
Spark RDD 是强类型的
Spark RDD 可以使用任何支持的数据类型创建。这些数据类型可以是 Scala/Java 支持的内建数据类型,也可以是自定义创建的数据类型,例如您自己的类。从这个设计决策中产生的最大优势是避免了运行时错误。如果因为数据类型问题而要崩溃,它将在编译时崩溃。
以下表格捕捉了一个包含零售银行账户数据的元组的 RDD 的结构。它属于类型 RDD[(string, string, string, double)]:
| AccountNo | FirstName | LastName | AccountBalance |
|---|---|---|---|
| SB001 | John | Mathew | 250.00 |
| SB002 | Tracy | Mason | 450.00 |
| SB003 | Paul | Thomson | 560.00 |
| SB004 | Samantha | Grisham | 650.00 |
| SB005 | John | Grove | 1000.00 |
假设这个 RDD 正在通过一个过程来计算三个节点 N1、N2 和 N3 组成的集群中所有这些账户的总金额,它可以被分割并分配用于数据处理的并行化。以下表格包含了分配给节点 N1 进行处理的 RDD[(string, string, string, double)]的元素:
| AccountNo | FirstName | LastName | AccountBalance |
|---|---|---|---|
| SB001 | John | Mathew | 250.00 |
| SB002 | Tracy | Mason | 450.00 |
以下表格包含了分配给节点 N2 进行处理的 RDD[(string, string, string, double)]的元素:
| AccountNo | FirstName | LastName | AccountBalance |
|---|---|---|---|
| SB003 | Paul | Thomson | 560.00 |
| SB004 | Samantha | Grisham | 650.00 |
| SB005 | John | Grove | 1000.00 |
在节点 N1 上,进行求和过程,并将结果返回给 Spark 驱动程序。同样,在节点 N2 上,进行求和过程,将结果返回给 Spark 驱动程序,并计算最终结果。
Spark 在将大 RDD 分割成更小的块以分配到各个节点方面有非常确定的规则,因此,即使节点 N1 发生故障,Spark 也知道如何重新创建节点 N1 中丢失的块,并通过向节点 N3 发送相同的有效载荷来继续数据处理操作。
图 1 捕捉了该过程的精髓:

图 1
提示
Spark 在其驱动器内存和集群节点上的执行器内存中进行大量处理。Spark 有许多可配置和微调的参数,以确保在处理开始之前提供所需的资源。
数据转换和 RDD 操作
Spark 使用 RDD 进行数据处理。从相关数据源,如文本文件和 NoSQL 数据存储中读取数据以形成 RDD。在这样一个 RDD 上执行各种数据转换,最后收集结果。更准确地说,Spark 自带 Spark 转换和作用于 RDD 的 Spark 操作。让我们看一下以下 RDD,它捕获了零售银行交易列表,其类型为 RDD[(string, string, double)]:
| AccountNo | TranNo | TranAmount |
|---|---|---|
| SB001 | TR001 | 250.00 |
| SB002 | TR004 | 450.00 |
| SB003 | TR010 | 120.00 |
| SB001 | TR012 | -120.00 |
| SB001 | TR015 | -10.00 |
| SB003 | TR020 | 100.00 |
要计算从形式为(AccountNo,TranNo,TranAmount)的 RDD 中交易的账户级别摘要:
-
首先,它必须转换为键值对的形式
(AccountNo,TranAmount),其中AccountNo是键,但将会有多个具有相同键的元素。 -
在这个键上对
TranAmount进行求和操作,结果是一个形式为(AccountNo,TotalAmount)的 RDD,其中每个 AccountNo 将只有一个元素,TotalAmount 是给定 AccountNo 的所有 TranAmount 的总和。 -
现在按
AccountNo对键值对进行排序并存储输出。
在整个过程中描述的,所有都是 Spark 转换,除了存储输出。存储输出是一个Spark 操作。Spark 根据需要执行所有这些操作。Spark 在应用 Spark 转换时不采取行动。真正的行动发生在链中的第一个 Spark 操作被调用时。然后它勤奋地按顺序应用所有先前的 Spark 转换,然后执行第一个遇到的 Spark 操作。这是基于称为延迟评估的概念。
注意
在编程语言中声明和使用变量的上下文中,延迟评估意味着变量仅在程序中首次使用时进行评估。
除了将输出存储到磁盘上的操作之外,还有许多其他可能的 Spark 操作,包括但不限于以下列表中给出的一些:
-
将结果 RDD 中的所有内容收集到驱动程序中的数组
-
计算 RDD 中的元素数量
-
在 RDD 元素中计算每个键的元素数量
-
取 RDD 中的第一个元素
-
从 RDD 中取出常用作 Top N 报告的给定数量的元素
-
从 RDD 中取样本元素
-
遍历 RDD 中的所有元素
在这个例子中,在完成过程之前,对各种动态创建的 RDD 进行了许多转换。换句话说,每当对 RDD 进行转换时,就会创建一个新的 RDD。这是因为 RDD 本质上是不可变的。在每个转换结束时创建的这些 RDD 可以保存以供将来参考,或者最终将超出作用域。
总结来说,创建一个或多个 RDD 并在它们上应用转换和动作的过程是 Spark 应用程序中普遍存在的非常常见的使用模式。
注意
在前面提到的数据转换示例中,所引用的表格包含了一个类型为 RDD[(string, string, double)] 的 RDD 中的值。在这个 RDD 中,有多个元素,每个元素都是一个类型为 (string, string, double) 的元组。在程序员和用户社区中,为了便于引用和传达思想,经常使用术语 record 来指代 RDD 中的一个元素。在 Spark RDD 中,没有记录、行和列的概念。换句话说,术语 record 错误地被用来同义于 RDD 中的一个元素,该元素可能是一个复杂的数据类型,如元组或非标量数据类型。在这本书中,这种做法被严格避免,以使用正确的术语。
在 Spark 中,有大量的 Spark 转换可用。这些转换非常强大,因为其中大多数将函数作为输入参数来进行转换。换句话说,这些转换根据用户定义和提供的函数在 RDD 上进行操作。结合 Spark 的一致编程模型,这种能力变得更加强大。无论选择的编程语言是 Scala、Java、Python 还是 R,Spark 转换和 Spark 动作的使用方式都是相似的。这使得组织可以选择他们偏好的编程语言。
在 Spark 中,尽管 Spark 动作的数量有限,但它们非常强大,如果需要,用户可以编写自己的 Spark 动作。市场上有很多 Spark 连接器程序,主要用于从各种数据存储中读取和写入数据。这些连接器程序是由用户社区或数据存储供应商设计和开发的,以便与 Spark 连接。除了可用的 Spark 动作外,它们还可以定义自己的动作来补充现有的 Spark 动作集。例如,Spark Cassandra 连接器用于从 Spark 连接到 Cassandra。它有一个动作 saveToCassandra。
使用 Spark 进行监控
前一章介绍了使用 Spark 开发和运行数据处理应用程序所需的安装和开发工具设置细节。在大多数实际应用中,Spark 应用程序可能会变得非常复杂,拥有一个由 Spark 转换和 Spark 动作构成的真正巨大的有向无环图(DAG)。Spark 提供了强大的监控工具来监控给定 Spark 生态系统中的作业。监控不会自动启动。
小贴士
注意,这是运行 Spark 应用程序的完全可选步骤。如果启用,它将非常深入地了解 Spark 应用程序的运行方式。在生产环境中启用此功能时需要谨慎,因为它可能会影响应用程序的响应时间。
首先,需要进行一些配置更改。事件日志机制应该开启。为此,请执行以下步骤:
$ cd $SPARK_HOME
$ cd conf
$ cp spark-defaults.conf.template spark-defaults.conf
完成前面的步骤后,编辑新创建的 spark-defaults.conf 文件,使其具有以下属性:
spark.eventLog.enabled true
spark.eventLog.dir <give a log directory location>
提示
完成前面的步骤后,请确保之前使用的日志目录在文件系统中存在。
除了前面的配置文件更改之外,该配置文件中还有许多属性可以更改以微调 Spark 运行时。其中最重要的是经常使用的 Spark 驱动内存。如果应用程序处理大量数据,将此属性 spark.driver.memory 定制为更高的值是一个好主意。然后运行以下命令以启动 Spark 主机:
$ cd $SPARK_HOME
$ ./sbin/start-master.sh
完成前面的步骤后,请确保 Spark 网页 用户界面(UI)正在启动,方法是通过访问 http://localhost:8080/。这里的假设是 8080 端口上没有运行其他应用程序。如果出于某种原因需要在不同的端口上运行此应用程序,可以在启动网页用户界面时在脚本中使用命令行选项 --webui-port <PORT>。
网页 UI 应该看起来与图 2 中显示的类似:

图 2
在前面的图中需要注意的最重要信息是完全限定的 Spark 主机 URL(而不是 REST URL)。它将在本书中讨论的许多动手练习中反复使用。URL 可能会因系统而异,以及 DNS 设置。此外,请注意,在本书中,对于所有动手练习,都使用 Spark 独立部署,这是在单台计算机上开始部署中最容易的。
提示
现在提供这些 Spark 应用程序监控步骤,以便让读者熟悉 Spark 提供的工具集。那些熟悉这些工具或对应用程序行为非常有信心的人不需要这些工具的帮助。但为了理解概念、调试以及一些过程的可视化,这些工具确实提供了巨大的帮助。
从图 2 中给出的 Spark 网页 UI 可以看出,没有可用的工作节点来执行任何任务,也没有正在运行的应用程序。以下步骤记录了启动工作节点的指令。注意在启动工作节点时如何使用 Spark 主机 URL:
$ cd $SPARK_HOME
$ ./sbin/start-slave.sh spark://Rajanarayanans-MacBook-Pro.local:7077
一旦启动工作节点,在 Spark 网页界面中,将显示新启动的工作节点。$SPARK_HOME/conf/slaves.template 模板捕获了将使用前面命令启动的默认工作节点。
注意
如果需要额外的工作节点,将 slaves.template 文件复制并命名为 slaves,并将条目捕获在其中。当启动 spark-shell、pyspark 或 sparkR 时,可以给出指令让它使用指定的 Spark 主节点。这在需要运行 Spark 应用程序或语句在远程 Spark 集群或针对给定的 Spark 主节点时非常有用。如果没有给出任何内容,Spark 应用程序将在本地模式下运行。
$ cd $SPARK_HOME
$ ./bin/spark-shell --master spark://Rajanarayanans-MacBook-Pro.local:7077
一旦成功启动工作节点,Spark 网页界面将类似于图 3 所示。在此之后,如果使用前面的 Spark 主节点 URL 运行应用程序,该应用程序的详细信息也将显示在 Spark 网页界面中。本章将详细说明应用程序。使用以下脚本停止工作节点和主进程:
$ cd $SPARK_HOME
$ ./sbin/stop-all.sh

图 3
Spark 编程的基础知识
Spark 编程围绕 RDD 进行。在任何 Spark 应用程序中,要处理的数据的输入被用来创建一个适当的 RDD。首先,从最基本创建 RDD 的方式开始,即从一个列表开始。用于此类 hello world 类型的应用程序的输入数据是一小批零售银行业务交易。为了解释核心概念,只选取了一些非常基础的数据项。交易记录包含账户号码和交易金额。
提示
在这些用例以及书中所有即将到来的用例中,如果使用了术语“记录”,那么它将处于业务或用例的上下文中。
在这里用于阐明 Spark 转换和 Spark 动作的用例如下所示:
-
交易记录以逗号分隔值的形式出现。
-
从列表中过滤出只有好的交易记录。账户号码应以
SB开头,交易金额应大于零。 -
找出所有交易金额大于 1000 的高价值交易记录。
-
找出所有账户号码不良的交易记录。
-
找出所有交易金额小于或等于零的交易记录。
-
找出所有不良交易记录的合并列表。
-
找出所有交易金额的总和。
-
找出所有交易金额的最大值。
-
找出所有交易金额的最小值。
-
找出所有良好的账户号码。
在本书中,对于将要开发的任何应用程序,所遵循的方法是从相应语言的 Spark REPL 开始。启动 Spark 的 Scala REPL 并确保它没有错误启动,并且可以看到提示符。对于这个应用程序,我们将启用监控以学习如何进行监控并在开发过程中使用它。除了明确启动 Spark 主节点和从节点外,Spark 还附带一个脚本,可以使用单个脚本同时启动这两个节点。然后,使用 Spark 主节点 URL 启动 Scala REPL:
$ cd $SPARK_HOME
$ ./sbin/start-all.sh
$ ./bin/spark-shell --master spark://Rajanarayanans-MacBook-Pro.local:7077
在 Scala REPL 提示符下,尝试以下语句。语句的输出以粗体显示。注意 scala> 是 Scala REPL 的提示符:
scala> val acTransList = Array("SB10001,1000", "SB10002,1200", "SB10003,8000", "SB10004,400", "SB10005,300", "SB10006,10000", "SB10007,500", "SB10008,56", "SB10009,30","SB10010,7000", "CR10001,7000", "SB10002,-10")
acTransList: Array[String] = Array(SB10001,1000, SB10002,1200, SB10003,8000, SB10004,400, SB10005,300, SB10006,10000, SB10007,500, SB10008,56, SB10009,30, SB10010,7000, CR10001,7000, SB10002,-10)
scala> val acTransRDD = sc.parallelize(acTransList)
acTransRDD: org.apache.spark.rdd.RDD[String] = ParallelCollectionRDD[0] at parallelize at <console>:23
scala> val goodTransRecords = acTransRDD.filter(_.split(",")(1).toDouble > 0).filter(_.split(",")(0).startsWith("SB"))
goodTransRecords: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[2] at filter at <console>:25
scala> val highValueTransRecords = goodTransRecords.filter(_.split(",")(1).toDouble > 1000)
highValueTransRecords: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[3] at filter at <console>:27
scala> val badAmountLambda = (trans: String) => trans.split(",")(1).toDouble <= 0
badAmountLambda: String => Boolean = <function1>
scala> val badAcNoLambda = (trans: String) => trans.split(",")(0).startsWith("SB") == false
badAcNoLambda: String => Boolean = <function1>
scala> val badAmountRecords = acTransRDD.filter(badAmountLambda)
badAmountRecords: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[4] at filter at <console>:27
scala> val badAccountRecords = acTransRDD.filter(badAcNoLambda)
badAccountRecords: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[5] at filter at <console>:27
scala> val badTransRecords = badAmountRecords.union(badAccountRecords)
badTransRecords: org.apache.spark.rdd.RDD[String] = UnionRDD[6] at union at <console>:33
所有的前述语句都属于一个类别,除了第一个 RDD 创建和两个函数值定义之外。它们都是 Spark 转换。以下是逐步详细说明到目前为止所做的工作:
-
acTransList的值是包含逗号分隔的交易记录的数组。 -
acTransRDD的值是从数组中创建的 RDD,其中sc是 Spark 上下文或 Spark 驱动程序,RDD 以并行化方式创建,以便 RDD 元素可以形成一个分布式数据集。换句话说,向 Spark 驱动程序下达了一条指令,从给定的值集合中形成一个并行集合或 RDD。 -
goodTransRecords的值是从acTransRDD中创建的 RDD,在过滤条件交易金额大于 0 且账户号码以SB开头后得到。 -
highValueTransRecords的值是从goodTransRecords中创建的 RDD,在过滤条件交易金额大于 1000 后得到。 -
接下来的两个语句是将函数定义存储在 Scala 值中,以便以后方便引用。
-
badAmountRecords和badAccountRecords的值是从acTransRDD中创建的 RDD,分别用于过滤包含错误交易金额和无效账户号码的坏记录。 -
badTransRecords包含了badAmountRecords和badAccountRecords两个 RDD 元素的并集。
到目前为止,这个应用的 Spark Web UI 不会显示任何内容,因为到目前为止只执行了 Spark 转换。真正的活动只有在执行第一个 Spark 动作后才会开始。
以下语句是已执行语句的延续:
scala> acTransRDD.collect()
res0: Array[String] = Array(SB10001,1000, SB10002,1200, SB10003,8000, SB10004,400, SB10005,300, SB10006,10000, SB10007,500, SB10008,56, SB10009,30, SB10010,7000, CR10001,7000, SB10002,-10)
scala> goodTransRecords.collect()
res1: Array[String] = Array(SB10001,1000, SB10002,1200, SB10003,8000, SB10004,400, SB10005,300, SB10006,10000, SB10007,500, SB10008,56, SB10009,30, SB10010,7000)
scala> highValueTransRecords.collect()
res2: Array[String] = Array(SB10002,1200, SB10003,8000, SB10006,10000, SB10010,7000)
scala> badAccountRecords.collect()
res3: Array[String] = Array(CR10001,7000)
scala> badAmountRecords.collect()
res4: Array[String] = Array(SB10002,-10)
scala> badTransRecords.collect()
res5: Array[String] = Array(SB10002,-10, CR10001,7000)
所有的前述语句都做了一件事情,那就是在之前定义的 RDD 上执行 Spark 动作。所有 RDD 的评估只在调用这些 RDD 上的 Spark 动作时发生。以下语句正在对 RDD 进行一些计算:
scala> val sumAmount = goodTransRecords.map(trans => trans.split(",")(1).toDouble).reduce(_ + _)
sumAmount: Double = 28486.0
scala> val maxAmount = goodTransRecords.map(trans => trans.split(",")(1).toDouble).reduce((a, b) => if (a > b) a else b)
maxAmount: Double = 10000.0
scala> val minAmount = goodTransRecords.map(trans => trans.split(",")(1).toDouble).reduce((a, b) => if (a < b) a else b)
minAmount: Double = 30.0
前面的数字计算了所有良好记录中交易金额的总和、最大值和最小值。在所有前面的转换中,交易记录是逐个处理的。从这些记录中,提取并处理账户号码和交易金额。之所以这样做,是因为用例要求是这样的。现在,在交易记录中的逗号分隔值被拆分,而不考虑它是账户号码还是交易金额。结果 RDD 将包含一个包含所有这些混合元素的集合。从这些元素中,如果选择以SB开头的元素,将得到良好的账户号码。以下语句将执行此操作:
scala> val combineAllElements = acTransRDD.flatMap(trans => trans.split(","))
combineAllElements: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[10] at flatMap at <console>:25
scala> val allGoodAccountNos = combineAllElements.filter(_.startsWith("SB"))
allGoodAccountNos: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[11] at filter at <console>:27
scala> combineAllElements.collect()
res10: Array[String] = Array(SB10001, 1000, SB10002, 1200, SB10003, 8000, SB10004, 400, SB10005, 300, SB10006, 10000, SB10007, 500, SB10008, 56, SB10009, 30, SB10010, 7000, CR10001, 7000, SB10002, -10)
scala> allGoodAccountNos.distinct().collect()
res14: Array[String] = Array(SB10006, SB10010, SB10007, SB10008, SB10009, SB10001, SB10002, SB10003, SB10004, SB10005)
现在,在这个时候,如果打开 Spark Web UI,与图 3 中看到的不同,可以注意到一个差异。由于已经执行了一些 Spark 操作,将显示一个应用程序条目。由于 Spark 的 Scala REPL 仍在运行,它显示在仍在运行的应用程序列表中。以下图 4 捕捉了这一点:

图 4
通过单击应用程序 ID 进行导航,以查看与运行中的应用程序相关的所有指标,包括 DAG 可视化等。
这些语句涵盖了所有讨论过的用例,回顾到目前为止涵盖的 Spark 转换是值得的。这些是一些基本但非常重要的转换,将在大多数应用程序中反复使用:
| Spark 转换 | 它执行的操作 |
|---|---|
filter(fn) |
遍历 RDD 中的所有元素,应用传入的函数,并获取函数对元素评估为 true 的元素。 |
map(fn) |
遍历 RDD 中的所有元素,应用传入的函数,并获取函数返回的输出。 |
flatMap(fn) |
遍历 RDD 中的所有元素,应用传入的函数,并获取函数返回的输出。与 Spark 转换map(fn)相比,这里的大不同在于函数作用于单个元素,并返回一个扁平化的元素集合。例如,它将一条银行交易记录拆分为多个字段,从而从一个元素生成一个集合。 |
union(other) |
将此 RDD 和其他 RDD 的所有元素合并。 |
值得注意的是,回顾到目前为止涵盖的 Spark 动作。这些是一些基本的动作,但将会在适当的时候涵盖更多动作。
| Spark 动作 | 它执行的操作 |
|---|---|
collect() |
将 RDD 中的所有元素收集到 Spark 驱动程序中的数组中。 |
reduce(fn) |
对 RDD 的所有元素应用函数 fn,最终结果按照函数定义计算。它应该是一个接受两个参数并返回一个结果的函数,同时具有交换性和结合性。 |
foreach(fn) |
对 RDD 的所有元素应用函数 fn。这主要用于副作用。Spark 转换map(fn)将函数应用于 RDD 的所有元素并返回另一个 RDD。但foreach(fn) Spark 转换不返回 RDD。例如,foreach(println)将取 RDD 中的每个元素并将其打印到控制台。尽管在这里的用例中没有使用,但仍有必要提及。 |
Spark 学习过程的下一步是尝试 Python REPL 中的语句,覆盖完全相同的用例。为了便于思想吸收,变量定义在两种语言中尽可能保持相似。与 Scala 的方式相比,它们的使用方式可能存在一些细微的差异;从概念上讲,它与选择的语言无关。
启动 Spark 的 Python REPL,并确保它没有错误地启动,并且可以看到提示符。在玩 Scala 代码时,监控已经启用。现在使用 Spark 主 URL 启动 Python REPL:
$ cd $SPARK_HOME
$ ./bin/pyspark --master spark://Rajanarayanans-MacBook-Pro.local:7077
在 Python REPL 提示符下,尝试以下语句。语句的输出以粗体显示。请注意>>>是 Python REPL 提示符:
>>> from decimal import Decimal
>>> acTransList = ["SB10001,1000", "SB10002,1200", "SB10003,8000", "SB10004,400", "SB10005,300", "SB10006,10000", "SB10007,500", "SB10008,56", "SB10009,30","SB10010,7000", "CR10001,7000", "SB10002,-10"]
>>> acTransRDD = sc.parallelize(acTransList)
>>> goodTransRecords = acTransRDD.filter(lambda trans: Decimal(trans.split(",")[1]) > 0).filter(lambda trans: (trans.split(",")[0]).startswith('SB') == True)
>>> highValueTransRecords = goodTransRecords.filter(lambda trans: Decimal(trans.split(",")[1]) > 1000)
>>> badAmountLambda = lambda trans: Decimal(trans.split(",")[1]) <= 0
>>> badAcNoLambda = lambda trans: (trans.split(",")[0]).startswith('SB') == False
>>> badAmountRecords = acTransRDD.filter(badAmountLambda)
>>> badAccountRecords = acTransRDD.filter(badAcNoLambda)
>>> badTransRecords = badAmountRecords.union(badAccountRecords)
>>> acTransRDD.collect()
['SB10001,1000', 'SB10002,1200', 'SB10003,8000', 'SB10004,400', 'SB10005,300', 'SB10006,10000', 'SB10007,500', 'SB10008,56', 'SB10009,30', 'SB10010,7000', 'CR10001,7000', 'SB10002,-10']
>>> goodTransRecords.collect()
['SB10001,1000', 'SB10002,1200', 'SB10003,8000', 'SB10004,400', 'SB10005,300', 'SB10006,10000', 'SB10007,500', 'SB10008,56', 'SB10009,30', 'SB10010,7000']
>>> highValueTransRecords.collect()
['SB10002,1200', 'SB10003,8000', 'SB10006,10000', 'SB10010,7000']
>>> badAccountRecords.collect()
['CR10001,7000']
>>> badAmountRecords.collect()
['SB10002,-10']
>>> badTransRecords.collect()
['SB10002,-10', 'CR10001,7000']
>>> sumAmounts = goodTransRecords.map(lambda trans: Decimal(trans.split(",")[1])).reduce(lambda a,b : a+b)
>>> sumAmounts
Decimal('28486')
>>> maxAmount = goodTransRecords.map(lambda trans: Decimal(trans.split(",")[1])).reduce(lambda a,b : a if a > b else b)
>>> maxAmount
Decimal('10000')
>>> minAmount = goodTransRecords.map(lambda trans: Decimal(trans.split(",")[1])).reduce(lambda a,b : a if a < b else b)
>>> minAmount
Decimal('30')
>>> combineAllElements = acTransRDD.flatMap(lambda trans: trans.split(","))
>>> combineAllElements.collect()
['SB10001', '1000', 'SB10002', '1200', 'SB10003', '8000', 'SB10004', '400', 'SB10005', '300', 'SB10006', '10000', 'SB10007', '500', 'SB10008', '56', 'SB10009', '30', 'SB10010', '7000', 'CR10001', '7000', 'SB10002', '-10']
>>> allGoodAccountNos = combineAllElements.filter(lambda trans: trans.startswith('SB') == True)
>>> allGoodAccountNos.distinct().collect()
['SB10005', 'SB10006', 'SB10008', 'SB10002', 'SB10003', 'SB10009', 'SB10010', 'SB10004', 'SB10001', 'SB10007']
Spark 的统一编程模型的真实力量,在将 Scala 和 Python 代码集进行比较时表现得非常明显。Spark 的转换和操作在两种语言实现中都是相同的。由于编程语言语法差异,将这些函数传递给这些操作的方式不同。
在运行 Spark 的 Python REPL 之前,已经关闭了 Scala REPL,这是故意为之。然后,Spark Web UI 应该看起来与图 5 中所示类似。由于 Scala REPL 已关闭,因此它被列在已完成的作业列表中。由于 Python REPL 仍然打开,因此它被列在正在运行的作业列表中。注意 Spark Web UI 中 Scala REPL 和 Python REPL 的应用程序名称。这些是标准名称。当从文件运行自定义应用程序时,有方法可以在定义 Spark 上下文对象时为应用程序分配自定义名称,以方便应用程序的监控和日志记录。这些细节将在本章后面进行介绍。
花时间熟悉 Spark Web UI 中捕获的所有指标以及 UI 中给出的 DAG 可视化是一个好主意。这将在调试复杂的 Spark 应用程序时非常有帮助。

图 5
MapReduce
从第一天起,Spark 就被定位为 Hadoop MapReduce 程序的替代品。一般来说,如果数据处理作业可以分解成多个任务并且可以并行执行,并且最终结果可以在收集所有这些分布式部分的结果后计算,那么这些作业将以 MapReduce 风格完成。与 Hadoop MapReduce 不同,Spark 即使在活动的 DAG 超过两个阶段(如 Map 和 Reduce)的情况下也能做到这一点。Spark 就是为了这个目的而设计的,这也是 Spark 强调的最大价值主张之一。
本节将继续使用相同的零售银行业务应用,并选取一些适合 MapReduce 类型数据处理的使用案例。
在这里阐述 MapReduce 类型数据处理所选择的使用案例如下:
-
零售银行业务交易记录附带账户号码和以逗号分隔的交易金额。
-
将交易配对,形成如(
AccNo,TranAmount)这样的键/值对。 -
找到所有交易的账户级别摘要以获取账户余额。
在 Scala REPL 提示符下,尝试以下语句:
scala> val acTransList = Array("SB10001,1000", "SB10002,1200", "SB10001,8000", "SB10002,400", "SB10003,300", "SB10001,10000", "SB10004,500", "SB10005,56", "SB10003,30","SB10002,7000", "SB10001,-100", "SB10002,-10")
acTransList: Array[String] = Array(SB10001,1000, SB10002,1200, SB10001,8000, SB10002,400, SB10003,300, SB10001,10000, SB10004,500, SB10005,56, SB10003,30, SB10002,7000, SB10001,-100, SB10002,-10)
scala> val acTransRDD = sc.parallelize(acTransList)
acTransRDD: org.apache.spark.rdd.RDD[String] = ParallelCollectionRDD[0] at parallelize at <console>:23
scala> val acKeyVal = acTransRDD.map(trans => (trans.split(",")(0), trans.split(",")(1).toDouble))
acKeyVal: org.apache.spark.rdd.RDD[(String, Double)] = MapPartitionsRDD[1] at map at <console>:25
scala> val accSummary = acKeyVal.reduceByKey(_ + _).sortByKey()
accSummary: org.apache.spark.rdd.RDD[(String, Double)] = ShuffledRDD[5] at sortByKey at <console>:27
scala> accSummary.collect()
res0: Array[(String, Double)] = Array((SB10001,18900.0), (SB10002,8590.0), (SB10003,330.0), (SB10004,500.0), (SB10005,56.0))
这里是逐步详细捕捉到目前为止所做的工作:
-
值
acTransList是包含以逗号分隔的交易记录的数组。 -
值
acTransRDD是由数组创建的 RDD,其中 sc 是 Spark 上下文或 Spark 驱动程序,RDD 以并行方式创建,以便 RDD 元素可以形成一个分布式数据集。 -
将
acTransRDD转换为acKeyVal以形成形式为(K,V)的键值对,其中账户号码被选为键。在这个 RDD 的元素集中,将会有多个具有相同键的元素。 -
在下一步中,根据键对键值对进行分组,并传递了一个减少函数,该函数将交易金额相加,形成包含 RDD 中特定键的一个元素和相同键的所有金额总和的键值对。然后在生成最终结果之前按键对元素进行排序。
-
在驱动程序级别将元素收集到数组中。
假设 RDD acKeyVal被分成两部分并分布到集群中进行处理,图 6 捕捉了处理的核心:

图 6
以下表格捕捉了在本用例中引入的 Spark 动作:
| Spark 动作 | 它做什么? |
|---|---|
reduceByKey(fn,[noOfTasks]) |
在形式为(K,V)的 RDD 上应用函数 fn,并减少重复键,将作为参数传递的函数应用于键级别的值。 |
sortByKey([ascending], [numTasks]) |
如果 RDD 的形式为(K,V),则按其键 K 对 RDD 元素进行排序 |
reduceByKey操作值得特别提及。在图 6 中,按键对元素进行分组是一个已知的操作。但在下一步中,对于相同的键,传递给参数的函数接受两个参数并返回一个。正确获取这个结果并不直观,你可能想知道在迭代每个键的(K,V)对的值时,两个输入从何而来。这种行为是从 Scala 集合方法reduceLeft的概念中来的。以下图 7,使用键SB10001的值执行reduceByKey(_ + _)操作,是为了解释这个概念。这只是为了说明这个示例,而实际的 Spark 实现可能不同:

图 7
在图 7 的右侧,展示了 Scala 集合方法的reduceLeft操作。这是尝试提供一些关于reduceLeft函数的两个参数来源的见解。实际上,Spark RDD 上使用的许多转换都是从 Scala 集合方法中改编而来的。
在 Python REPL 提示符下,尝试以下语句:
>>> from decimal import Decimal
>>> acTransList = ["SB10001,1000", "SB10002,1200", "SB10001,8000", "SB10002,400", "SB10003,300", "SB10001,10000", "SB10004,500", "SB10005,56", "SB10003,30","SB10002,7000", "SB10001,-100", "SB10002,-10"]
>>> acTransRDD = sc.parallelize(acTransList)
>>> acKeyVal = acTransRDD.map(lambda trans: (trans.split(",")[0],Decimal(trans.split(",")[1])))
>>> accSummary = acKeyVal.reduceByKey(lambda a,b : a+b).sortByKey()
>>> accSummary.collect()
[('SB10001', Decimal('18900')), ('SB10002', Decimal('8590')), ('SB10003', Decimal('330')), ('SB10004', Decimal('500')), ('SB10005', Decimal('56'))]
reduceByKey接受一个输入参数,它是一个函数。与此类似,还有一个不同的转换,它以稍微不同的方式执行基于键的操作。它是groupByKey()。它收集给定键的所有值,并从所有单个元素形成值的列表。
如果需要对每个键的相同值元素集合进行多级处理,这种转换是合适的。换句话说,如果有许多(K,V)对,这种转换将为每个键返回(K, Iterable
小贴士
开发者唯一需要关注的是确保这样的(K,V)对的数量不是真的很大,以免操作造成性能问题。没有硬性规则来确定这一点,这更多取决于用例。
在所有前面的代码片段中,为了从逗号分隔的交易记录中提取账户号码或其他字段,在map()转换中多次使用了 split(,)。这是为了展示在map()或任何其他转换或方法中使用数组元素。提取交易记录字段的一个更好的方法是将其转换为包含所需字段的元组,然后使用元组中的字段在以下代码片段中应用它们。这样,就不需要为每个字段提取重复调用 split (,).
连接
在关系数据库管理系统(RDBMS)的世界中,基于键连接多个表行是一个非常常见的做法。当涉及到 NoSQL 数据存储时,连接多个表成为一个真正的问题,因为许多 NoSQL 数据存储不支持表连接。在 NoSQL 世界中,冗余是被允许的。无论技术是否支持表连接,业务用例总是要求基于键连接数据集。因此,在许多用例中,以批量模式执行连接是必不可少的。
Spark 提供了基于键连接多个 RDD 的转换。这支持许多用例。如今,许多 NoSQL 数据存储都有与 Spark 通信的连接器。当与这样的数据存储一起工作时,构建来自多个表的数据 RDD 非常简单,可以在 Spark 中进行连接,并将结果以批量模式或近乎实时模式存储回数据存储中。Spark 转换支持左外连接、右外连接以及全外连接。
用于阐明使用键连接多个数据集的用例如下。
第一个数据集包含一个零售银行主记录摘要,包括账户号码、名和姓。第二个数据集包含零售银行账户余额,包括账户号码和余额金额。这两个数据集的关键是账户号码。将这两个数据集连接起来,创建一个包含账户号码、全名和余额金额的数据集。
在 Scala REPL 提示符下,尝试以下语句:
scala> val acMasterList = Array("SB10001,Roger,Federer", "SB10002,Pete,Sampras", "SB10003,Rafael,Nadal", "SB10004,Boris,Becker", "SB10005,Ivan,Lendl")
acMasterList: Array[String] = Array(SB10001,Roger,Federer, SB10002,Pete,Sampras, SB10003,Rafel,Nadal, SB10004,Boris,Becker, SB10005,Ivan,Lendl)
scala> val acBalList = Array("SB10001,50000", "SB10002,12000", "SB10003,3000", "SB10004,8500", "SB10005,5000")
acBalList: Array[String] = Array(SB10001,50000, SB10002,12000, SB10003,3000, SB10004,8500, SB10005,5000)
scala> val acMasterRDD = sc.parallelize(acMasterList)
acMasterRDD: org.apache.spark.rdd.RDD[String] = ParallelCollectionRDD[0] at parallelize at <console>:23
scala> val acBalRDD = sc.parallelize(acBalList)
acBalRDD: org.apache.spark.rdd.RDD[String] = ParallelCollectionRDD[1] at parallelize at <console>:23
scala> val acMasterTuples = acMasterRDD.map(master => master.split(",")).map(masterList => (masterList(0), masterList(1) + " " + masterList(2)))
acMasterTuples: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[3] at map at <console>:25
scala> val acBalTuples = acBalRDD.map(trans => trans.split(",")).map(transList => (transList(0), transList(1)))
acBalTuples: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[5] at map at <console>:25
scala> val acJoinTuples = acMasterTuples.join(acBalTuples).sortByKey().map{case (accno, (name, amount)) => (accno, name,amount)}
acJoinTuples: org.apache.spark.rdd.RDD[(String, String, String)] = MapPartitionsRDD[12] at map at <console>:33
scala> acJoinTuples.collect()
res0: Array[(String, String, String)] = Array((SB10001,Roger Federer,50000), (SB10002,Pete Sampras,12000), (SB10003,Rafael Nadal,3000), (SB10004,Boris Becker,8500), (SB10005,Ivan Lendl,5000))
到现在为止,所有给出的语句都应该已经熟悉,除了 Spark 转换连接。与这个转换类似,leftOuterJoin、rightOuterJoin和fullOuterJoin也以相同的用法模式提供:
| Spark 转换 | 它做什么 |
|---|---|
join(other, [numTasks]) |
将此 RDD 与另一个 RDD 连接,元素根据键连接在一起。假设原始 RDD 的形式为(K,V1),第二个 RDD 的形式为(K,V2),那么连接操作将产生形式为(K, (V1,V2))的元组,包含每个键的所有配对。 |
在 Python REPL 提示符下,尝试以下语句:
>>> acMasterList = ["SB10001,Roger,Federer", "SB10002,Pete,Sampras", "SB10003,Rafael,Nadal", "SB10004,Boris,Becker", "SB10005,Ivan,Lendl"]
>>> acBalList = ["SB10001,50000", "SB10002,12000", "SB10003,3000", "SB10004,8500", "SB10005,5000"]
>>> acMasterRDD = sc.parallelize(acMasterList)
>>> acBalRDD = sc.parallelize(acBalList)
>>> acMasterTuples = acMasterRDD.map(lambda master: master.split(",")).map(lambda masterList: (masterList[0], masterList[1] + " " + masterList[2]))
>>> acBalTuples = acBalRDD.map(lambda trans: trans.split(",")).map(lambda transList: (transList[0], transList[1]))
>>> acJoinTuples = acMasterTuples.join(acBalTuples).sortByKey().map(lambda tran: (tran[0], tran[1][0],tran[1][1]))
>>> acJoinTuples.collect()
[('SB10001', 'Roger Federer', '50000'), ('SB10002', 'Pete Sampras', '12000'), ('SB10003', 'Rafael Nadal', '3000'), ('SB10004', 'Boris Becker', '8500'), ('SB10005', 'Ivan Lendl', '5000')]
更多动作
到目前为止,重点主要在 Spark 转换上。Spark 动作也同样重要。为了深入了解一些更重要的 Spark 动作,请看以下用例,继续从上一节用例中停止的地方:
-
从包含账户号码、姓名和账户余额的列表中,获取账户余额最高的一个
-
从包含账户号码、姓名和账户余额的列表中,获取账户余额最高的前三个
-
在账户级别计算余额交易记录数
-
计算总余额交易记录数
-
打印所有账户的名称和账户余额
-
计算账户余额总和
提示
非常常见的需求是遍历集合中的元素,对每个元素进行一些数学计算,并在最后使用结果。RDD 被分区并分布到工作节点上。如果在遍历 RDD 元素时使用任何普通变量来存储累积结果,则可能不会得到正确的结果。在这种情况下,而不是使用常规变量,应使用 Spark 提供的累加器。
在 Scala REPL 提示符下,尝试以下语句:
scala> val acNameAndBalance = acJoinTuples.map{case (accno, name,amount) => (name,amount)}
acNameAndBalance: org.apache.spark.rdd.RDD[(String, String)] = MapPartitionsRDD[46] at map at <console>:35
scala> val acTuplesByAmount = acBalTuples.map{case (accno, amount) => (amount.toDouble, accno)}.sortByKey(false)
acTuplesByAmount: org.apache.spark.rdd.RDD[(Double, String)] = ShuffledRDD[50] at sortByKey at <console>:27
scala> acTuplesByAmount.first()
res19: (Double, String) = (50000.0,SB10001)
scala> acTuplesByAmount.take(3)
res20: Array[(Double, String)] = Array((50000.0,SB10001), (12000.0,SB10002), (8500.0,SB10004))
scala> acBalTuples.countByKey()
res21: scala.collection.Map[String,Long] = Map(SB10001 -> 1, SB10005 -> 1, SB10004 -> 1, SB10002 -> 1, SB10003 -> 1)
scala> acBalTuples.count()
res22: Long = 5
scala> acNameAndBalance.foreach(println)
(Boris Becker,8500)
(Rafel Nadal,3000)
(Roger Federer,50000)
(Pete Sampras,12000)
(Ivan Lendl,5000)
scala> val balanceTotal = sc.accumulator(0.0, "Account Balance Total")
balanceTotal: org.apache.spark.Accumulator[Double] = 0.0
scala> acBalTuples.map{case (accno, amount) => amount.toDouble}.foreach(bal => balanceTotal += bal)
scala> balanceTotal.value
res8: Double = 78500.0)
下表总结了在本用例中引入的 Spark 动作:
| Spark 动作 | 执行的操作 |
|---|---|
first() |
返回 RDD 中的第一个元素。 |
take(n) |
返回 RDD 中前 n 个元素的数组。 |
countByKey() |
返回按键的元素计数。如果 RDD 包含 (K,V) 对,则返回一个 (K, numOfValues) 的字典。 |
count() |
返回 RDD 中的元素数量。 |
foreach(fn) |
将函数 fn 应用到 RDD 中的每个元素。在上一个用例中,Spark Accumulator 与 foreach(fn) 一起使用。 |
在 Python REPL 提示符下,尝试以下语句:
>>> acNameAndBalance = acJoinTuples.map(lambda tran: (tran[1],tran[2]))
>>> acTuplesByAmount = acBalTuples.map(lambda tran: (Decimal(tran[1]), tran[0])).sortByKey(False)
>>> acTuplesByAmount.first()
(Decimal('50000'), 'SB10001')
>>> acTuplesByAmount.take(3)
[(Decimal('50000'), 'SB10001'), (Decimal('12000'), 'SB10002'), (Decimal('8500'), 'SB10004')]
>>> acBalTuples.countByKey()
defaultdict(<class 'int'>, {'SB10005': 1, 'SB10002': 1, 'SB10003': 1, 'SB10004': 1, 'SB10001': 1})
>>> acBalTuples.count()
5
>>> acNameAndBalance.foreach(print)
('Pete Sampras', '12000')
('Roger Federer', '50000')
('Rafael Nadal', '3000')
('Boris Becker', '8500')
('Ivan Lendl', '5000')
>>> balanceTotal = sc.accumulator(0.0)
>>> balanceTotal.value0.0>>> acBalTuples.foreach(lambda bals: balanceTotal.add(float(bals[1])))
>>> balanceTotal.value
78500.0
从文件创建 RDD
到目前为止,讨论的重点是 RDD 功能和 RDD 编程。在所有前面的用例中,RDD 的创建都是从集合对象中完成的。但在现实世界的用例中,数据将来自存储在本地文件系统中的文件和 HDFS。通常,数据将来自如 Cassandra 这样的 NoSQL 数据存储。可以通过从这些数据源读取内容来创建 RDD。一旦创建了 RDD,那么所有操作都是统一的,如前所述的用例所示。从文件系统中出来的数据文件可能是固定宽度、逗号分隔的或任何其他格式。但读取此类数据文件的常见模式是逐行读取数据并将行分割成必要的数据项分隔。在来自其他来源的数据的情况下,应使用适当的 Spark 连接器程序和适当的读取数据 API。
许多第三方库可用于从各种类型的文本文件中读取内容。例如,GitHub 上可用的 Spark CSV 库是一个非常有用的库,可以从 CSV 文件创建 RDD。
下表总结了从各种来源(如本地文件系统、HDFS 等)读取文本文件的方式。如前所述,文本文件的处理取决于用例要求:
| 文件位置 | RDD 创建 | 执行的操作 |
|---|---|---|
| 本地文件系统 | val textFile = sc.textFile("README.md") |
通过从 Spark shell 调用的目录中读取名为 README.md 的文件内容,创建一个 RDD。在这里,RDD 的类型是 RDD[string],元素将是文件中的行。** |
| HDFS | val textFile = sc.textFile ("hdfs://<location in HDFS>") |
通过读取 HDFS URL 中指定的文件内容创建一个 RDD |
从本地文件系统读取文件时最重要的方面是文件应该可在所有 Spark 工作节点上可用。除了前面表格中给出的这两个文件位置之外,还可以使用任何支持的文件系统 URI。
就像从各种文件系统中读取文件内容一样,也可以使用saveAsTextFile(path) Spark 操作将 RDD 写入文件。
小贴士
这里讨论的所有 Spark 应用程序用例都是在 Spark 相应语言的 REPL 上运行的。当编写应用程序时,它们将写入适当的源代码文件。在 Scala 和 Java 的情况下,应用程序代码文件必须编译、打包,并带有适当的库依赖项运行,通常使用 maven 或 sbt 构建。这将在本书的最后一章详细介绍设计使用 Spark 的数据处理应用程序时进行。
理解 Spark 库栈
Spark 附带一个核心数据处理引擎和一系列在核心引擎之上工作的库。理解在核心框架之上堆叠库的概念非常重要。
所有这些利用核心框架提供服务的库都支持核心框架提供的数据抽象,以及更多功能。在 Spark 进入市场之前,有许多独立的开源产品正在做现在讨论的库栈正在做的事情。这些点产品的最大缺点是它们的互操作性。它们不能很好地堆叠在一起。它们是用不同的编程语言实现的。这些产品支持的选择的编程语言以及这些产品暴露的 API 的不一致性,使得使用两个或更多这样的产品完成一个应用程序变得非常具有挑战性。这就是在 Spark 之上工作的库栈的相关性。它们都使用相同的编程模型一起工作。这有助于组织标准化数据处理工具集,避免供应商锁定。
Spark 附带以下特定领域的库栈,图 8 展示了开发者眼中整个生态系统的全面视图:
-
Spark SQL
-
Spark Streaming
-
Spark MLlib
-
Spark GraphX

图 8
在任何组织中,结构化数据仍然非常广泛地使用。与结构化数据最普遍的数据访问机制是 SQL。Spark SQL 提供了在称为 DataFrame API 的结构化数据抽象之上编写类似 SQL 查询的能力。DataFrame 和 SQL 配合得很好,支持来自各种来源的数据,例如 Hive、Avro、Parquet、JSON 等。一旦数据被加载到 Spark 上下文中,它们就可以像它们都来自同一来源一样进行操作。换句话说,如果需要,可以使用类似 SQL 的查询来连接来自不同来源的数据,例如 Hive 和 JSON。Spark SQL 和 DataFrame API 带给开发者的另一个重大优势是易用性以及不需要了解函数式编程方法,这是使用 RDD 进行编程的要求。
小贴士
使用 Spark SQL 和 DataFrame API,可以从各种数据源读取数据,并像它们都来自统一源一样进行处理。Spark 转换和 Spark 操作支持统一的编程接口。因此,数据源统一、API 统一以及使用多种编程语言编写数据处理应用程序的能力,有助于组织标准化一个数据处理框架。
组织数据接收器的数据接收量每天都在增加。同时,数据接收的速度也在增加。Spark Streaming 提供了库来处理从各种来源以极高速度接收的数据。
在过去,数据科学家面临在所选编程语言中构建自己的机器学习算法和工具的实现挑战。通常,这样的编程语言与组织的数据处理工具集不兼容。Spark MLlib 提供了统一过程,其中它自带了许多机器学习算法和工具,这些算法和工具在 Spark 数据处理引擎之上运行。
物联网应用,尤其是社交媒体应用,要求具备数据处理能力,其中数据适合于图状结构。例如,LinkedIn 中的连接、Facebook 中朋友之间的关系、工作流应用以及许多此类用例,广泛使用了图抽象。使用图进行各种计算需要非常高的数据处理能力和复杂的算法。Spark GraphX 库提供了图的 API,并利用了 Spark 的并行计算范式。
小贴士
社区开发了众多 Spark 库,用于各种目的。许多这样的第三方库包在网站spark-packages.org/上有特色。随着 Spark 用户社区的日益增长,包的数量每天都在增加。当在 Spark 中开发数据处理应用程序时,如果需要特定的领域库,首先查看这个网站是个好主意,看看是否有人已经开发了它。
参考文献
如需更多信息,请访问:github.com/databricks/spark-csv
摘要
本章讨论了 Spark 的基本编程模型及其主要数据抽象 RDDs。本章使用 Scala 和 Python API 介绍了从各种数据源创建 RDDs,以及使用 Spark 转换和 Spark 操作在 RDDs 中处理数据。本章通过实际案例研究涵盖了 Spark 编程模型的所有重要功能。本章还讨论了 Spark 附带库栈及其各自的功能。总之,Spark 提供了一个非常用户友好的编程模型,并提供了非常强大的数据处理工具集。
下一章将讨论数据集 API 和 DataFrame API。数据集 API 将成为使用 Spark 编程的新方式,而 DataFrame API 则用于处理更结构化的数据。Spark SQL 也被引入以操作结构化数据,并展示如何将其与任何 Spark 数据处理应用程序混合使用。
第三章. Spark SQL
大多数企业始终在处理大量的结构化数据。即使处理非结构化数据的方法有很多,许多应用场景仍然需要结构化数据。处理结构化数据和非结构化数据之间主要的区别是什么?如果数据源是结构化的,并且数据处理引擎事先知道数据结构,数据处理引擎在处理数据时可以进行很多优化,甚至在处理之前。这在数据处理量巨大且周转时间非常关键时非常关键。
企业数据的激增要求赋予最终用户在简单且易于使用的应用程序用户界面中查询和处理数据的能力。关系数据库管理系统(RDBMS)供应商联合起来,结构化查询语言(SQL)应运而生,作为解决这一问题的方案。在过去的几十年里,所有处理数据的人如果还不是高级用户,也熟悉了 SQL。
社交网络和微博等大型互联网应用产生了超出许多传统数据处理工具消费能力的数据。在处理如此庞大的数据海洋时,从其中挑选和选择正确的数据变得更加重要。Spark 是一个高度流行的数据处理平台,其基于 RDD 的编程模型与 Hadoop MapReduce 数据处理框架相比,降低了数据处理工作量。但是,Spark 基于 RDD 的编程模型的初始版本在让最终用户,如数据科学家、数据分析师和业务分析师使用 Spark 方面仍然难以捉摸。他们无法利用基于 RDD 的 Spark 编程模型的主要原因是因为它需要一定程度的函数式编程。解决这个问题的方法是 Spark SQL。Spark SQL 是建立在 Spark 之上的库。它公开了 SQL 接口和 DataFrame API。DataFrame API 支持编程语言 Scala、Java、Python 和 R。
如果提前知道数据的结构,如果数据符合行和列的模型,那么数据的来源并不重要,Spark SQL 可以将其全部一起使用,并像所有数据都来自单一来源一样进行处理。此外,查询方言是通用的 SQL。
在本章中,我们将涵盖以下主题:
-
数据结构
-
Spark SQL
-
聚合
-
多数据源连接
-
数据集
-
数据目录
理解数据结构
这里所讨论的数据结构需要进一步阐明。我们所说的数据结构是什么意思?存储在关系型数据库管理系统(RDBMS)中的数据以行/列或记录/字段的方式存储。每个字段都有一个数据类型,每个记录都是相同或不同数据类型的字段集合。在 RDBMS 的早期阶段,字段的数据类型是标量型的,而在最近版本中,它扩展到包括集合数据类型或复合数据类型。因此,无论记录包含标量数据类型还是复合数据类型,这里要强调的重要一点是,底层数据是有结构的。许多数据处理范式都采用了在内存中镜像 RDBMS 或其他存储中持久化的底层数据结构的概念,以简化数据处理。
换句话说,如果一个关系型数据库表中的数据正在被数据处理应用程序处理,如果相同的表样数据结构在内存中对程序、最终用户和程序员可用,那么建模应用程序和查询数据对它们来说就很容易了。例如,假设有一组以逗号分隔的数据项,每行有固定数量的值,这些值在所有行中的特定位置具有特定的数据类型。这是一个结构化数据文件。它是一个数据表,非常类似于 RDBMS 表。
在 R 等编程语言中,有一个用于在内存中存储数据表的 DataFrame 抽象。Python 数据分析库 Pandas 也有类似的数据框概念。一旦这种数据结构在内存中可用,程序就可以提取数据,并根据需要对其进行切片和切块。相同的数据表概念在 Spark 中得到了扩展,称为 DataFrame,它建立在 RDD 之上,Spark SQL 中有一个非常全面的 API 称为 DataFrame API,用于处理 DataFrame 中的数据。在 DataFrame 抽象之上还开发了一种类似 SQL 的查询语言,以满足最终用户查询和处理底层结构化数据的需求。总之,DataFrame 是一个按行和列组织的数据表,并为每个列命名。
基于 Spark 构建的 Spark SQL 库是基于名为 "Spark SQL: Relational Data Processing in Spark" 的研究论文开发的。它讨论了 Spark SQL 的四个目标,以下为原文照搬:
-
支持在 Spark 程序内部(在本地 RDD 上)以及使用程序员友好的 API 在外部数据源上进行关系处理
-
使用成熟的数据库管理系统(DBMS)技术提供高性能
-
便于支持新的数据源,包括半结构化数据和适用于查询联合的外部数据库
-
允许使用高级分析算法进行扩展,例如图处理和机器学习
DataFrame 存储结构化数据,并且是分布式的。它允许选择、过滤和聚合数据。听起来很像是 RDD 吗?RDD 和 DataFrame 之间的关键区别在于,DataFrame 存储了比 RDD 更多的关于数据结构的信息,例如列的数据类型和名称。这使得 DataFrame 能够比 Spark 对 RDD 进行处理的转换和操作更有效地优化处理。在这里需要提到的另一个最重要的方面是,所有 Spark 支持的编程语言都可以用来开发使用 Spark SQL DataFrame API 的应用程序。从所有实际应用的角度来看,Spark SQL 是一个分布式 SQL 引擎。
小贴士
之前在 Spark 1.3 版本中工作过的人一定熟悉 SchemaRDD,DataFrame 的概念正是建立在 SchemaRDD 之上,并且保持了 API 级别的兼容性。
为什么选择 Spark SQL?
毫无疑问,SQL 是进行数据分析的通用语言,而 Spark SQL 是 Spark 工具集家族中用于数据分析的解决方案。那么,它提供了什么?它提供了在 Spark 上运行 SQL 的能力。无论数据来自 CSV、Avro、Parquet、Hive,还是来自 Cassandra 这样的 NoSQL 数据存储,甚至是 RDBMS,Spark SQL 都可以用来分析数据,并与 Spark 程序混合使用。这里提到的许多数据源都由 Spark SQL 内置支持,而许多其他数据源则由外部包支持。在这里需要强调的最重要的一点是 Spark SQL 处理来自非常广泛的数据源的能力。一旦数据作为 Spark 中的 DataFrame 可用,Spark SQL 就可以以完全分布式的方式处理数据,将来自各种数据源的数据帧组合起来进行处理和查询,就像整个数据集都来自单一来源一样。
在上一章中,我们已经详细讨论了 RDD,并将其介绍为 Spark 编程模型。Spark SQL 的 DataFrame API 和 SQL 方言的使用是否正在取代基于 RDD 的编程模型?当然不是!基于 RDD 的编程模型是 Spark 中通用的和基本的数据处理模型。基于 RDD 的编程需要使用真正的编程技术。Spark 的转换和操作使用了大量的函数式编程结构。尽管与 Hadoop MapReduce 或其他任何范式相比,基于 RDD 的编程模型所需的代码量较少,但仍然需要编写一些函数式代码。这对许多数据科学家、数据分析师和业务分析师来说是一个障碍,他们可能需要进行大量的探索性数据分析或使用数据进行原型设计。Spark SQL 完全消除了这些限制。基于简单易用的领域特定语言(DSL)的方法来从数据源读取和写入数据,类似于 SQL 的语言来选择、过滤和聚合,以及从广泛的数据源读取数据的能力,使得任何了解数据结构的人都能轻松使用它。
注意
使用 RDD 的最佳用例是什么,使用 Spark SQL 的最佳用例又是什么?答案非常简单。如果数据是有结构的,如果它可以被安排在表格中,并且如果每一列都可以被赋予一个名称,那么就使用 Spark SQL。这并不意味着 RDD 和 DataFrame 是两个截然不同的实体。它们可以很好地交互。从 RDD 到 DataFrame 以及相反的转换都是可能的。许多通常应用于 RDD 的 Spark 转换和操作也可以应用于 DataFrame。
通常,在设计应用阶段,业务分析师通常使用 SQL 对应用数据进行大量分析,并将其用于应用需求和测试工件。在设计大数据应用时,也需要同样的东西,在这种情况下,除了业务分析师外,数据科学家也会在团队中。在基于 Hadoop 的生态系统中,Hive 被广泛用于大数据的数据分析。现在 Spark SQL 将这种能力带到了任何支持大量数据源的平台。如果有一个在通用硬件上的独立 Spark 安装,就可以进行大量此类活动来分析数据。在通用硬件上以独立模式部署的基本 Spark 安装就足以处理大量数据。
SQL-on-Hadoop 策略引入了许多应用程序,例如 Hive 和 Impala 等,为存储在 Hadoop 分布式文件系统(HDFS)中的底层大数据提供了类似 SQL 的接口。Spark SQL 在这个空间中处于什么位置?在深入探讨这个问题之前,先简要提及 Hive 和 Impala。Hive 是一种基于 MapReduce 的数据仓库技术,由于查询处理使用了 MapReduce,因此 Hive 查询在完成查询之前需要进行大量的 I/O 操作。Impala 通过在内存中处理数据并利用描述数据的 Hive 元存储提出了一个绝妙的解决方案。Spark SQL 使用 SQLContext 来执行所有数据操作。但它也可以使用 HiveContext,HiveContext 比 SQLContext 功能更丰富、更先进。HiveContext 可以执行 SQLContext 可以执行的所有操作,并且在此基础上,它还可以从 Hive 元存储和表中读取数据,还可以访问 Hive 用户定义的函数。显然,使用 HiveContext 的唯一要求是应该有一个已经存在的 Hive 设置 readily available。这样,Spark SQL 可以轻松与 Hive 共存。
注意
从 Spark 2.0 开始,SparkSession 成为基于 Spark SQL 的应用程序的新起点,它是 SQLContext 和 HiveContext 的组合,同时支持与 SQLContext 和 HiveContext 的向后兼容。
Spark SQL 可以使用其 Hive 查询语言比 Hive 更快地处理 Hive 表中的数据。Spark SQL 的另一个非常有趣的功能是它可以读取不同版本的 Hive 数据,这是一个非常棒的功能,使得数据源整合在数据处理中成为可能。
注意
提供了 Spark SQL 和 DataFrame API 的库提供了可以通过 JDBC/ODBC 访问的接口。这开启了一个全新的数据分析世界。例如,一个通过 JDBC/ODBC 连接到数据源的 商业智能(BI)工具可以使用 Spark SQL 支持的许多数据源。此外,BI 工具可以将计算密集型的连接聚合操作推送到 Spark 基础设施中巨大的工作节点集群。
Spark SQL 的结构
与 Spark SQL 库的交互主要通过两种方法进行。一种是通过类似 SQL 的查询,另一种是通过 DataFrame API。在深入了解基于 DataFrame 的程序的工作原理之前,先看看基于 RDD 的程序是如何工作的是个好主意。
Spark 转换和 Spark 操作被转换为 Java 函数,并在 RDD 上执行,RDD 实际上就是 Java 对象对数据进行操作。由于 RDD 是一个纯 Java 对象,在编译时或运行时都无法知道将要处理什么数据。在执行引擎之前没有可用的元数据来优化 Spark 转换或 Spark 操作。没有预先可用的多个执行路径或查询计划来处理这些数据,因此无法评估各种执行路径的有效性。
在这里,因为没有与数据关联的模式,所以没有执行优化的查询计划。在 DataFrame 的情况下,结构是预先知道的。正因为如此,查询可以提前优化,数据缓存也可以提前建立。
下面的图 1给出了关于同一内容的想法:

图 1
对 DataFrame 进行的类似 SQL 的查询和 DataFrame API 调用被转换为语言无关的表达式。对应于 SQL 查询或 DataFrame API 的语言无关表达式称为未解析的逻辑计划。
通过对 DataFrame 元数据中的列名进行验证,将未解析的逻辑计划转换为逻辑计划。通过应用标准规则,如表达式简化、表达式评估和其他优化规则,进一步优化逻辑计划,形成优化的逻辑计划。优化的逻辑计划被转换为多个物理计划。这些物理计划是通过在逻辑计划中使用 Spark 特定的操作来创建的。选择最佳的物理计划,并将结果查询推送到 RDD 以对数据进行操作。由于 SQL 查询和 DataFrame API 调用被转换为语言无关的查询表达式,因此这些查询的性能在所有支持的语言中都是一致的。这也是为什么 DataFrame API 被所有 Spark 支持的语言(如 Scala、Java、Python 和 R)支持的原因。在未来,由于这个原因,许多更多的语言可能会支持 DataFrame API 和 Spark SQL。
在这里需要提及的是 Spark SQL 的查询计划和优化。在 DataFrame 上通过 SQL 查询或通过 DataFrame API 进行的任何查询操作,在物理上对底层的 base RDD 应用相应的操作之前,都经过了高度优化。在真正的 RDD 操作发生之前,有许多过程。
图 2给出了整个查询优化过程的一些想法:

图 2
可以对 DataFrame 调用两种类型的查询。它们是 SQL 查询或 DataFrame API 调用。它们经过适当的分析,以得出逻辑查询执行计划。然后,在逻辑查询计划上应用优化,以得出优化的逻辑查询计划。从最终的优化逻辑查询计划中,生成一个或多个物理查询计划。对于每个物理查询计划,都会计算出成本模型,并根据最优成本选择合适的物理查询计划,并生成高度优化的代码,针对 RDD 运行。这就是 DataFrame 上任何类型查询性能一致的原因。这也是为什么来自所有这些不同语言的 DataFrame API 调用(Scala、Java、Python 和 R)都能提供一致性能的原因。
让我们再次回顾一下更大的图景,如图 3所示,以设定上下文并在我们进入和讨论用例之前了解这里正在讨论的内容:

图 3
这里将要讨论的用例将展示混合 SQL 查询与 Spark 程序的能力。将选择多个数据源,使用 DataFrame 从这些源读取数据,并展示统一的数据访问。演示中使用的编程语言仍然是 Scala 和 Python。使用 R 操作 DataFrame 的用法将在本书的议程上,并有一个专门的章节介绍。
DataFrame 编程
用于阐明使用 DataFrame 进行 Spark SQL 编程的用例如下:
-
交易记录以逗号分隔值的形式出现。
-
从列表中过滤出只有有效的交易记录。账户号码应以
SB开头,交易金额应大于零。 -
找到所有交易金额大于 1000 的高价值交易记录。
-
找到所有账户号码无效的交易记录。
-
找到所有交易金额小于或等于零的交易记录。
-
找到所有无效交易记录的合并列表。
-
找到所有交易金额的总和。
-
找到所有交易金额的最大值。
-
找到所有交易金额的最小值。
-
找到所有有效的账户号码。
这正是前一章中使用的相同的一组用例,但在这里编程模型完全不同。使用这组用例,这里展示了两种编程模型。一种是使用 SQL 查询,另一种是使用 DataFrame API。
使用 SQL 进行编程
在 Scala REPL 提示符下,尝试以下语句:
scala> // Define the case classes for using in conjunction with DataFrames
scala> case class Trans(accNo: String, tranAmount: Double)
defined class Trans
scala> // Functions to convert the sequence of strings to objects defined by the case classes
scala> def toTrans = (trans: Seq[String]) => Trans(trans(0), trans(1).trim.toDouble)
toTrans: Seq[String] => Trans
scala> // Creation of the list from where the RDD is going to be created
scala> val acTransList = Array("SB10001,1000", "SB10002,1200", "SB10003,8000", "SB10004,400", "SB10005,300", "SB10006,10000", "SB10007,500", "SB10008,56", "SB10009,30","SB10010,7000", "CR10001,7000", "SB10002,-10")
acTransList: Array[String] = Array(SB10001,1000, SB10002,1200, SB10003,8000, SB10004,400, SB10005,300, SB10006,10000, SB10007,500, SB10008,56, SB10009,30, SB10010,7000, CR10001,7000, SB10002,-10)
scala> // Create the RDD
scala> val acTransRDD = sc.parallelize(acTransList).map(_.split(",")).map(toTrans(_))
acTransRDD: org.apache.spark.rdd.RDD[Trans] = MapPartitionsRDD[2] at map at <console>:30
scala> // Convert RDD to DataFrame
scala> val acTransDF = spark.createDataFrame(acTransRDD)
acTransDF: org.apache.spark.sql.DataFrame = [accNo: string, tranAmount: double]
scala> // Register temporary view in the DataFrame for using it in SQL
scala> acTransDF.createOrReplaceTempView("trans")
scala> // Print the structure of the DataFrame
scala> acTransDF.printSchema
root
|-- accNo: string (nullable = true)
|-- tranAmount: double (nullable = false)
scala> // Show the first few records of the DataFrame
scala> acTransDF.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10004| 400.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10007| 500.0|
|SB10008| 56.0|
|SB10009| 30.0|
|SB10010| 7000.0|
|CR10001| 7000.0|
|SB10002| -10.0|
+-------+----------+
scala> // Use SQL to create another DataFrame containing the good transaction records
scala> val goodTransRecords = spark.sql("SELECT accNo, tranAmount FROM trans WHERE accNo like 'SB%' AND tranAmount > 0")
goodTransRecords: org.apache.spark.sql.DataFrame = [accNo: string, tranAmount: double]
scala> // Register temporary view in the DataFrame for using it in SQL
scala> goodTransRecords.createOrReplaceTempView("goodtrans")
scala> // Show the first few records of the DataFrame
scala> goodTransRecords.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10004| 400.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10007| 500.0|
|SB10008| 56.0|
|SB10009| 30.0|
|SB10010| 7000.0|
+-------+----------+
scala> // Use SQL to create another DataFrame containing the high value transaction records
scala> val highValueTransRecords = spark.sql("SELECT accNo, tranAmount FROM goodtrans WHERE tranAmount > 1000")
highValueTransRecords: org.apache.spark.sql.DataFrame = [accNo: string, tranAmount: double]
scala> // Show the first few records of the DataFrame
scala> highValueTransRecords.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10006| 10000.0|
|SB10010| 7000.0|
+-------+----------+
scala> // Use SQL to create another DataFrame containing the bad account records
scala> val badAccountRecords = spark.sql("SELECT accNo, tranAmount FROM trans WHERE accNo NOT like 'SB%'")
badAccountRecords: org.apache.spark.sql.DataFrame = [accNo: string, tranAmount: double]
scala> // Show the first few records of the DataFrame
scala> badAccountRecords.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|CR10001| 7000.0|
+-------+----------+
scala> // Use SQL to create another DataFrame containing the bad amount records
scala> val badAmountRecords = spark.sql("SELECT accNo, tranAmount FROM trans WHERE tranAmount < 0")
badAmountRecords: org.apache.spark.sql.DataFrame = [accNo: string, tranAmount: double]
scala> // Show the first few records of the DataFrame
scala> badAmountRecords.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| -10.0|
+-------+----------+
scala> // Do the union of two DataFrames and create another DataFrame
scala> val badTransRecords = badAccountRecords.union(badAmountRecords)
badTransRecords: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [accNo: string, tranAmount: double]
scala> // Show the first few records of the DataFrame
scala> badTransRecords.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|CR10001| 7000.0|
|SB10002| -10.0|
+-------+----------+
scala> // Calculate the sum
scala> val sumAmount = spark.sql("SELECT sum(tranAmount) as sum FROM goodtrans")
sumAmount: org.apache.spark.sql.DataFrame = [sum: double]
scala> // Show the first few records of the DataFrame
scala> sumAmount.show
+-------+
| sum|
+-------+
|28486.0|
+-------+
scala> // Calculate the maximum
scala> val maxAmount = spark.sql("SELECT max(tranAmount) as max FROM goodtrans")
maxAmount: org.apache.spark.sql.DataFrame = [max: double]
scala> // Show the first few records of the DataFrame
scala> maxAmount.show
+-------+
| max|
+-------+
|10000.0|
+-------+
scala> // Calculate the minimum
scala> val minAmount = spark.sql("SELECT min(tranAmount) as min FROM goodtrans")
minAmount: org.apache.spark.sql.DataFrame = [min: double]
scala> // Show the first few records of the DataFrame
scala> minAmount.show
+----+
| min|
+----+
|30.0|
+----+
scala> // Use SQL to create another DataFrame containing the good account numbers
scala> val goodAccNos = spark.sql("SELECT DISTINCT accNo FROM trans WHERE accNo like 'SB%' ORDER BY accNo")
goodAccNos: org.apache.spark.sql.DataFrame = [accNo: string]
scala> // Show the first few records of the DataFrame
scala> goodAccNos.show
+-------+
| accNo|
+-------+
|SB10001|
|SB10002|
|SB10003|
|SB10004|
|SB10005|
|SB10006|
|SB10007|
|SB10008|
|SB10009|
|SB10010|
+-------+
scala> // Calculate the aggregates using mixing of DataFrame and RDD like operations
scala> val sumAmountByMixing = goodTransRecords.map(trans => trans.getAsDouble).reduce(_ + _)
sumAmountByMixing: Double = 28486.0
scala> val maxAmountByMixing = goodTransRecords.map(trans => trans.getAsDouble).reduce((a, b) => if (a > b) a else b)
maxAmountByMixing: Double = 10000.0
scala> val minAmountByMixing = goodTransRecords.map(trans => trans.getAsDouble).reduce((a, b) => if (a < b) a else b)
minAmountByMixing: Double = 30.0
零售银行交易记录包含账户号码、交易金额,并使用 SparkSQL 处理以获得用例所需的预期结果。以下是前面脚本所做操作的摘要:
-
定义了一个 Scala 案例类来描述要输入到 DataFrame 中的交易记录的结构。
-
使用必要的交易记录定义了一个数组。
-
RDD 是从数组中生成的,将逗号分隔的值拆分,使用在脚本的第一步中定义的 Scala 案例类映射创建对象,并将 RDD 转换为 DataFrame。这是 RDD 和 DataFrame 之间互操作性的一个用例。
-
使用名称注册了一个表与 DataFrame。这个注册的表名可以在 SQL 语句中使用。
-
然后,所有其他活动只是使用
spark.sql方法发出 SQL 语句。在这里,spark 对象是 SparkSession 类型。 -
所有这些 SQL 语句的结果存储为 DataFrame,就像 RDD 的
collect操作一样,DataFrame 的show方法用于将值提取到 Spark 驱动程序中。 -
聚合值计算以两种不同的方式进行。一种是在 SQL 语句方式中,这是最简单的方式。另一种是使用常规的 RDD 风格的 Spark 转换和 Spark 操作。这表明 DataFrame 也可以像 RDD 一样操作,Spark 转换和 Spark 操作可以应用于 DataFrame 之上。
-
有时,通过函数式风格的运算使用函数进行一些数据处理活动是很简单的。因此,这里有一个灵活性,可以混合 SQL、RDD 和 DataFrame,以获得一个非常方便的编程模型来处理数据。
-
使用 DataFrame 的
show方法以表格格式显示 DataFrame 的内容。 -
使用
printSchema方法显示了 DataFrame 的结构视图。这类似于数据库表的describe命令。
在 Python 交互式解释器提示符下,尝试以下语句:
>>> from pyspark.sql import Row
>>> # Creation of the list from where the RDD is going to be created
>>> acTransList = ["SB10001,1000", "SB10002,1200", "SB10003,8000", "SB10004,400", "SB10005,300", "SB10006,10000", "SB10007,500", "SB10008,56", "SB10009,30","SB10010,7000", "CR10001,7000", "SB10002,-10"]
>>> # Create the DataFrame
>>> acTransDF = sc.parallelize(acTransList).map(lambda trans: trans.split(",")).map(lambda p: Row(accNo=p[0], tranAmount=float(p[1]))).toDF()
>>> # Register temporary view in the DataFrame for using it in SQL
>>> acTransDF.createOrReplaceTempView("trans")
>>> # Print the structure of the DataFrame
>>> acTransDF.printSchema()
root
|-- accNo: string (nullable = true)
|-- tranAmount: double (nullable = true)
>>> # Show the first few records of the DataFrame
>>> acTransDF.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10004| 400.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10007| 500.0|
|SB10008| 56.0|
|SB10009| 30.0|
|SB10010| 7000.0|
|CR10001| 7000.0|
|SB10002| -10.0|
+-------+----------+
>>> # Use SQL to create another DataFrame containing the good transaction records
>>> goodTransRecords = spark.sql("SELECT accNo, tranAmount FROM trans WHERE accNo like 'SB%' AND tranAmount > 0")
>>> # Register temporary table in the DataFrame for using it in SQL
>>> goodTransRecords.createOrReplaceTempView("goodtrans")
>>> # Show the first few records of the DataFrame
>>> goodTransRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10004| 400.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10007| 500.0|
|SB10008| 56.0|
|SB10009| 30.0|
|SB10010| 7000.0|
+-------+----------+
>>> # Use SQL to create another DataFrame containing the high value transaction records
>>> highValueTransRecords = spark.sql("SELECT accNo, tranAmount FROM goodtrans WHERE tranAmount > 1000")
>>> # Show the first few records of the DataFrame
>>> highValueTransRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10006| 10000.0|
|SB10010| 7000.0|
+-------+----------+
>>> # Use SQL to create another DataFrame containing the bad account records
>>> badAccountRecords = spark.sql("SELECT accNo, tranAmount FROM trans WHERE accNo NOT like 'SB%'")
>>> # Show the first few records of the DataFrame
>>> badAccountRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|CR10001| 7000.0|
+-------+----------+
>>> # Use SQL to create another DataFrame containing the bad amount records
>>> badAmountRecords = spark.sql("SELECT accNo, tranAmount FROM trans WHERE tranAmount < 0")
>>> # Show the first few records of the DataFrame
>>> badAmountRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| -10.0|
+-------+----------+
>>> # Do the union of two DataFrames and create another DataFrame
>>> badTransRecords = badAccountRecords.union(badAmountRecords)
>>> # Show the first few records of the DataFrame
>>> badTransRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|CR10001| 7000.0|
|SB10002| -10.0|
+-------+----------+
>>> # Calculate the sum
>>> sumAmount = spark.sql("SELECT sum(tranAmount)as sum FROM goodtrans")
>>> # Show the first few records of the DataFrame
>>> sumAmount.show()
+-------+
| sum|
+-------+
|28486.0|
+-------+
>>> # Calculate the maximum
>>> maxAmount = spark.sql("SELECT max(tranAmount) as max FROM goodtrans")
>>> # Show the first few records of the DataFrame
>>> maxAmount.show()
+-------+
| max|
+-------+
|10000.0|
+-------+
>>> # Calculate the minimum
>>> minAmount = spark.sql("SELECT min(tranAmount)as min FROM goodtrans")
>>> # Show the first few records of the DataFrame
>>> minAmount.show()
+----+
| min|
+----+
|30.0|
+----+
>>> # Use SQL to create another DataFrame containing the good account numbers
>>> goodAccNos = spark.sql("SELECT DISTINCT accNo FROM trans WHERE accNo like 'SB%' ORDER BY accNo")
>>> # Show the first few records of the DataFrame
>>> goodAccNos.show()
+-------+
| accNo|
+-------+
|SB10001|
|SB10002|
|SB10003|
|SB10004|
|SB10005|
|SB10006|
|SB10007|
|SB10008|
|SB10009|
|SB10010|
+-------+
>>> # Calculate the sum using mixing of DataFrame and RDD like operations
>>> sumAmountByMixing = goodTransRecords.rdd.map(lambda trans: trans.tranAmount).reduce(lambda a,b : a+b)
>>> sumAmountByMixing
28486.0
>>> # Calculate the maximum using mixing of DataFrame and RDD like operations
>>> maxAmountByMixing = goodTransRecords.rdd.map(lambda trans: trans.tranAmount).reduce(lambda a,b : a if a > b else b)
>>> maxAmountByMixing
10000.0
>>> # Calculate the minimum using mixing of DataFrame and RDD like operations
>>> minAmountByMixing = goodTransRecords.rdd.map(lambda trans: trans.tranAmount).reduce(lambda a,b : a if a < b else b)
>>> minAmountByMixing
30.0
在前面的 Python 代码片段中,除了导入库和 lambda 函数定义等一些语言特定的结构之外,编程风格几乎与 Scala 代码相同,大多数时候都是如此。这是 Spark 统一编程模型的优势。如前所述,当业务分析师或数据分析师提供数据访问的 SQL 时,很容易将其与 Spark 中的数据处理代码集成。这种统一的编程风格对组织使用所选语言在 Spark 中开发数据处理应用程序非常有用。
小贴士
在 DataFrame 上,如果适用 Spark 转换,则返回 Dataset 而不是 DataFrame。Dataset 的概念在本章末尾介绍。DataFrame 和 Dataset 之间有非常紧密的联系,这一点在介绍 Dataset 的章节中解释。在开发应用程序时,必须小心处理这种情况。例如,在 Scala REPL 中尝试前面的代码片段中的以下转换时,它将返回一个数据集:val amount = goodTransRecords.map(trans => trans.getAsDouble)amount: org.apache.spark.sql.Dataset[Double] = [value: double]
使用 DataFrame API 编程
在本节中,代码片段将在适当的语言 REPL 中运行,作为上一节的延续,这样就不需要重复设置数据和其它初始化。与前面的代码片段类似,最初给出一些 DataFrame 特定的基本命令。这些命令被经常使用,用于查看内容并对 DataFrame 及其内容进行一些基本测试。这些是在数据分析的探索阶段通常使用的命令,常常用于深入了解底层数据的结构和内容。
在 Scala REPL 提示符下,尝试以下语句:
scala> acTransDF.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10004| 400.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10007| 500.0|
|SB10008| 56.0|
|SB10009| 30.0|
|SB10010| 7000.0|
|CR10001| 7000.0|
|SB10002| -10.0|
+-------+----------+
scala> // Create the DataFrame using API for the good transaction records
scala> val goodTransRecords = acTransDF.filter("accNo like 'SB%'").filter("tranAmount > 0")
goodTransRecords: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [accNo: string, tranAmount: double]
scala> // Show the first few records of the DataFrame
scala> goodTransRecords.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10004| 400.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10007| 500.0|
|SB10008| 56.0|
|SB10009| 30.0|
|SB10010| 7000.0|
+-------+----------+
scala> // Create the DataFrame using API for the high value transaction records
scala> val highValueTransRecords = goodTransRecords.filter("tranAmount > 1000")
highValueTransRecords: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [accNo: string, tranAmount: double]
scala> // Show the first few records of the DataFrame
scala> highValueTransRecords.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10006| 10000.0|
|SB10010| 7000.0|
+-------+----------+
scala> // Create the DataFrame using API for the bad account records
scala> val badAccountRecords = acTransDF.filter("accNo NOT like 'SB%'")
badAccountRecords: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [accNo: string, tranAmount: double]
scala> // Show the first few records of the DataFrame
scala> badAccountRecords.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|CR10001| 7000.0|
+-------+----------+
scala> // Create the DataFrame using API for the bad amount records
scala> val badAmountRecords = acTransDF.filter("tranAmount < 0")
badAmountRecords: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [accNo: string, tranAmount: double]
scala> // Show the first few records of the DataFrame
scala> badAmountRecords.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| -10.0|
+-------+----------+
scala> // Do the union of two DataFrames
scala> val badTransRecords = badAccountRecords.union(badAmountRecords)
badTransRecords: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [accNo: string, tranAmount: double]
scala> // Show the first few records of the DataFrame
scala> badTransRecords.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|CR10001| 7000.0|
|SB10002| -10.0|
+-------+----------+
scala> // Calculate the aggregates in one shot
scala> val aggregates = goodTransRecords.agg(sum("tranAmount"), max("tranAmount"), min("tranAmount"))
aggregates: org.apache.spark.sql.DataFrame = [sum(tranAmount): double, max(tranAmount): double ... 1 more field]
scala> // Show the first few records of the DataFrame
scala> aggregates.show
+---------------+---------------+---------------+
|sum(tranAmount)|max(tranAmount)|min(tranAmount)|
+---------------+---------------+---------------+
| 28486.0| 10000.0| 30.0|
+---------------+---------------+---------------+
scala> // Use DataFrame using API for creating the good account numbers
scala> val goodAccNos = acTransDF.filter("accNo like 'SB%'").select("accNo").distinct().orderBy("accNo")
goodAccNos: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [accNo: string]
scala> // Show the first few records of the DataFrame
scala> goodAccNos.show
+-------+
| accNo|
+-------+
|SB10001|
|SB10002|
|SB10003|
|SB10004|
|SB10005|
|SB10006|
|SB10007|
|SB10008|
|SB10009|
|SB10010|
+-------+
scala> // Persist the data of the DataFrame into a Parquet file
scala> acTransDF.write.parquet("scala.trans.parquet")
scala> // Read the data into a DataFrame from the Parquet file
scala> val acTransDFfromParquet = spark.read.parquet("scala.trans.parquet")
acTransDFfromParquet: org.apache.spark.sql.DataFrame = [accNo: string, tranAmount: double]
scala> // Show the first few records of the DataFrame
scala> acTransDFfromParquet.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10008| 56.0|
|SB10009| 30.0|
|CR10001| 7000.0|
|SB10002| -10.0|
|SB10001| 1000.0|
|SB10004| 400.0|
|SB10007| 500.0|
|SB10010| 7000.0|
+-------+----------+
下面是从 DataFrame API 视角对前面脚本所做操作的总结:
-
本处使用的是包含前面章节中使用的数据超集的 DataFrame。
-
接下来演示了记录的过滤。这里,需要注意的最重要的一点是,过滤谓词必须与 SQL 语句中的谓词完全相同。过滤器可以串联使用。
-
聚合方法一次性计算为结果 DataFrame 中的三个列。
-
本组中的最后几个语句在一个单链语句中执行选择、过滤、选择不同记录和排序操作。
-
最后,事务记录以 Parquet 格式持久化,从 Parquet 存储中读取并创建一个 DataFrame。关于持久化格式的更多细节将在下一节中介绍。
-
在此代码片段中,Parquet 格式的数据存储在从相应 REPL 调用的当前目录中。当它作为一个 Spark 程序运行时,目录再次将是从该目录调用 Spark submit 的当前目录。
在 Python REPL 提示符下,尝试以下语句:
>>> acTransDF.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10004| 400.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10007| 500.0|
|SB10008| 56.0|
|SB10009| 30.0|
|SB10010| 7000.0|
|CR10001| 7000.0|
|SB10002| -10.0|
+-------+----------+
>>> # Print the structure of the DataFrame
>>> acTransDF.printSchema()
root
|-- accNo: string (nullable = true)
|-- tranAmount: double (nullable = true)
>>> # Create the DataFrame using API for the good transaction records
>>> goodTransRecords = acTransDF.filter("accNo like 'SB%'").filter("tranAmount > 0")
>>> # Show the first few records of the DataFrame
>>> goodTransRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10004| 400.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10007| 500.0|
|SB10008| 56.0|
|SB10009| 30.0|
|SB10010| 7000.0|
+-------+----------+
>>> # Create the DataFrame using API for the high value transaction records
>>> highValueTransRecords = goodTransRecords.filter("tranAmount > 1000")
>>> # Show the first few records of the DataFrame
>>> highValueTransRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10006| 10000.0|
|SB10010| 7000.0|
+-------+----------+
>>> # Create the DataFrame using API for the bad account records
>>> badAccountRecords = acTransDF.filter("accNo NOT like 'SB%'")
>>> # Show the first few records of the DataFrame
>>> badAccountRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|CR10001| 7000.0|
+-------+----------+
>>> # Create the DataFrame using API for the bad amount records
>>> badAmountRecords = acTransDF.filter("tranAmount < 0")
>>> # Show the first few records of the DataFrame
>>> badAmountRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| -10.0|
+-------+----------+
>>> # Do the union of two DataFrames and create another DataFrame
>>> badTransRecords = badAccountRecords.union(badAmountRecords)
>>> # Show the first few records of the DataFrame
>>> badTransRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|CR10001| 7000.0|
|SB10002| -10.0|
+-------+----------+
>>> # Calculate the sum
>>> sumAmount = goodTransRecords.agg({"tranAmount": "sum"})
>>> # Show the first few records of the DataFrame
>>> sumAmount.show()
+---------------+
|sum(tranAmount)|
+---------------+
| 28486.0|
+---------------+
>>> # Calculate the maximum
>>> maxAmount = goodTransRecords.agg({"tranAmount": "max"})
>>> # Show the first few records of the DataFrame
>>> maxAmount.show()
+---------------+
|max(tranAmount)|
+---------------+
| 10000.0|
+---------------+
>>> # Calculate the minimum
>>> minAmount = goodTransRecords.agg({"tranAmount": "min"})
>>> # Show the first few records of the DataFrame
>>> minAmount.show()
+---------------+
|min(tranAmount)|
+---------------+
| 30.0|
+---------------+
>>> # Create the DataFrame using API for the good account numbers
>>> goodAccNos = acTransDF.filter("accNo like 'SB%'").select("accNo").distinct().orderBy("accNo")
>>> # Show the first few records of the DataFrame
>>> goodAccNos.show()
+-------+
| accNo|
+-------+
|SB10001|
|SB10002|
|SB10003|
|SB10004|
|SB10005|
|SB10006|
|SB10007|
|SB10008|
|SB10009|
|SB10010|
+-------+
>>> # Persist the data of the DataFrame into a Parquet file
>>> acTransDF.write.parquet("python.trans.parquet")
>>> # Read the data into a DataFrame from the Parquet file
>>> acTransDFfromParquet = spark.read.parquet("python.trans.parquet")
>>> # Show the first few records of the DataFrame
>>> acTransDFfromParquet.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10008| 56.0|
|SB10009| 30.0|
|CR10001| 7000.0|
|SB10002| -10.0|
|SB10001| 1000.0|
|SB10004| 400.0|
|SB10007| 500.0|
|SB10010| 7000.0|
+-------+----------+
在前面的 Python 代码片段中,除了聚合计算中的一些细微差异外,编程结构几乎与 Scala 对应版本相似。
前面的 Scala 和 Python 部分的最后几行是关于将 DataFrame 内容持久化到媒体中的。在任何类型的数据处理操作中,写入和读取操作都是非常必要的,但大多数工具都没有统一的写入和读取方式。Spark SQL 是不同的。DataFrame API 提供了一套丰富的持久化机制。将 DataFrame 的内容写入许多支持的持久化存储非常简单。所有这些写入和读取操作都有非常简单的 DSL 风格接口。以下是一些 DataFrame 可以写入和读取的内置格式。
除了这些,还有许多其他通过第三方包支持的外部数据源:
-
JSON
-
Parquet
-
Hive
-
MySQL
-
PostgreSQL
-
HDFS
-
纯文本
-
Amazon S3
-
ORC
-
JDBC
在前面的代码片段中已经演示了 DataFrame 到 Parquet 以及从 Parquet 读写的过程。所有之前内在支持的数据存储都有非常简单的 DSL 风格语法用于持久化和读取,这使得编程风格再次统一。DataFrame API 参考是了解如何处理每个数据存储细节的绝佳来源。
本章中的示例代码以 Parquet 和 JSON 格式持久化数据。所选的数据存储位置名称为 python.trans.parquet、scala.trans.parquet 等。这只是为了表明使用了哪种编程语言以及数据的格式。这并不是一个正确的约定,而是一种便利。当程序的一次运行完成后,这些目录就会被创建。下次运行相同的程序时,它将尝试创建相同的目录,并导致错误。解决方案是在后续运行之前手动删除这些目录,然后继续。适当的错误处理机制和精细编程的其他细微之处可能会分散注意力,因此故意从本书中省略。
理解 Spark SQL 中的聚合
在 SQL 中,数据的聚合非常灵活。在 Spark SQL 中也是如此。在这里,Spark SQL 可以在分布式数据源上执行与在单个机器上的单个数据源上运行 SQL 语句相同的事情。在前一章中,讨论了一个 MapReduce 用例来进行数据聚合,这里同样使用它来展示 Spark SQL 的聚合能力。在本节中,用例也是以 SQL 查询方式和 DataFrame API 方式来处理的。
在此处阐述 MapReduce 类型的数据处理所选择的用例如下:
-
零售银行交易记录包含账户号码和以逗号分隔的交易金额字符串
-
找到所有交易的账户级别摘要以获取账户余额
在 Scala REPL 提示符下,尝试以下语句:
scala> // Define the case classes for using in conjunction with DataFrames
scala> case class Trans(accNo: String, tranAmount: Double)
defined class Trans
scala> // Functions to convert the sequence of strings to objects defined by the case classes
scala> def toTrans = (trans: Seq[String]) => Trans(trans(0), trans(1).trim.toDouble)
toTrans: Seq[String] => Trans
scala> // Creation of the list from where the RDD is going to be created
scala> val acTransList = Array("SB10001,1000", "SB10002,1200","SB10001,8000", "SB10002,400", "SB10003,300", "SB10001,10000","SB10004,500","SB10005,56", "SB10003,30","SB10002,7000","SB10001,-100", "SB10002,-10")
acTransList: Array[String] = Array(SB10001,1000, SB10002,1200, SB10001,8000, SB10002,400, SB10003,300, SB10001,10000, SB10004,500, SB10005,56, SB10003,30, SB10002,7000, SB10001,-100, SB10002,-10)
scala> // Create the DataFrame
scala> val acTransDF = sc.parallelize(acTransList).map(_.split(",")).map(toTrans(_)).toDF()
acTransDF: org.apache.spark.sql.DataFrame = [accNo: string, tranAmount: double]
scala> // Show the first few records of the DataFrame
scala> acTransDF.show
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10001| 8000.0|
|SB10002| 400.0|
|SB10003| 300.0|
|SB10001| 10000.0|
|SB10004| 500.0|
|SB10005| 56.0|
|SB10003| 30.0|
|SB10002| 7000.0|
|SB10001| -100.0|
|SB10002| -10.0|
+-------+----------+
scala> // Register temporary view in the DataFrame for using it in SQL
scala> acTransDF.createOrReplaceTempView("trans")
scala> // Use SQL to create another DataFrame containing the account summary records
scala> val acSummary = spark.sql("SELECT accNo, sum(tranAmount) as TransTotal FROM trans GROUP BY accNo")
acSummary: org.apache.spark.sql.DataFrame = [accNo: string, TransTotal: double]
scala> // Show the first few records of the DataFrame
scala> acSummary.show
+-------+----------+
| accNo|TransTotal|
+-------+----------+
|SB10005| 56.0|
|SB10004| 500.0|
|SB10003| 330.0|
|SB10002| 8590.0|
|SB10001| 18900.0|
+-------+----------+
scala> // Create the DataFrame using API for the account summary records
scala> val acSummaryViaDFAPI = acTransDF.groupBy("accNo").agg(sum("tranAmount") as "TransTotal")
acSummaryViaDFAPI: org.apache.spark.sql.DataFrame = [accNo: string, TransTotal: double]
scala> // Show the first few records of the DataFrame
scala> acSummaryViaDFAPI.show
+-------+----------+
| accNo|TransTotal|
+-------+----------+
|SB10005| 56.0|
|SB10004| 500.0|
|SB10003| 330.0|
|SB10002| 8590.0|
|SB10001| 18900.0|
+-------+----------+
在此代码片段中,一切与前面章节的代码非常相似。唯一的区别是,在这里,SQL 查询以及 DataFrame API 中都使用了聚合操作。
在 Python REPL 提示符下,尝试以下语句:
>>> from pyspark.sql import Row
>>> # Creation of the list from where the RDD is going to be created
>>> acTransList = ["SB10001,1000", "SB10002,1200", "SB10001,8000","SB10002,400", "SB10003,300", "SB10001,10000","SB10004,500","SB10005,56","SB10003,30","SB10002,7000", "SB10001,-100","SB10002,-10"]
>>> # Create the DataFrame
>>> acTransDF = sc.parallelize(acTransList).map(lambda trans: trans.split(",")).map(lambda p: Row(accNo=p[0], tranAmount=float(p[1]))).toDF()
>>> # Register temporary view in the DataFrame for using it in SQL
>>> acTransDF.createOrReplaceTempView("trans")
>>> # Use SQL to create another DataFrame containing the account summary records
>>> acSummary = spark.sql("SELECT accNo, sum(tranAmount) as transTotal FROM trans GROUP BY accNo")
>>> # Show the first few records of the DataFrame
>>> acSummary.show()
+-------+----------+
| accNo|transTotal|
+-------+----------+
|SB10005| 56.0|
|SB10004| 500.0|
|SB10003| 330.0|
|SB10002| 8590.0|
|SB10001| 18900.0|
+-------+----------+
>>> # Create the DataFrame using API for the account summary records
>>> acSummaryViaDFAPI = acTransDF.groupBy("accNo").agg({"tranAmount": "sum"}).selectExpr("accNo", "`sum(tranAmount)` as transTotal")
>>> # Show the first few records of the DataFrame
>>> acSummaryViaDFAPI.show()
+-------+----------+
| accNo|transTotal|
+-------+----------+
|SB10005| 56.0|
|SB10004| 500.0|
|SB10003| 330.0|
|SB10002| 8590.0|
|SB10001| 18900.0|
+-------+----------+
在 Python 的 DataFrame API 中,与 Scala 的对应版本相比,有一些微小的语法差异。
理解 SparkSQL 中的多数据源连接
在前一章中,已经讨论了基于键的多个 RDD 的连接。在本节中,使用 Spark SQL 实现了相同的用例。这里给出的用于阐明使用键连接多个数据集的用例已选定。
第一个数据集包含一个零售银行主记录摘要,包括账户号码、名和姓。第二个数据集包含零售银行账户余额,包括账户号码和余额金额。这两个数据集的关键是账户号码。将这两个数据集连接起来,创建一个包含账户号码、名、姓和余额金额的数据集。从这个报告中,挑选出余额金额最高的前三个账户。
在本节中,还演示了从多个数据源连接数据的概念。首先,从两个数组创建 DataFrame。它们以 Parquet 和 JSON 格式持久化。然后,从磁盘读取它们以形成 DataFrame,并将它们连接起来。
在 Scala REPL 提示符下,尝试以下语句:
scala> // Define the case classes for using in conjunction with DataFrames
scala> case class AcMaster(accNo: String, firstName: String, lastName: String)
defined class AcMaster
scala> case class AcBal(accNo: String, balanceAmount: Double)
defined class AcBal
scala> // Functions to convert the sequence of strings to objects defined by the case classes
scala> def toAcMaster = (master: Seq[String]) => AcMaster(master(0), master(1), master(2))
toAcMaster: Seq[String] => AcMaster
scala> def toAcBal = (bal: Seq[String]) => AcBal(bal(0), bal(1).trim.toDouble)
toAcBal: Seq[String] => AcBal
scala> // Creation of the list from where the RDD is going to be created
scala> val acMasterList = Array("SB10001,Roger,Federer","SB10002,Pete,Sampras", "SB10003,Rafael,Nadal","SB10004,Boris,Becker", "SB10005,Ivan,Lendl")
acMasterList: Array[String] = Array(SB10001,Roger,Federer, SB10002,Pete,Sampras, SB10003,Rafael,Nadal, SB10004,Boris,Becker, SB10005,Ivan,Lendl)
scala> // Creation of the list from where the RDD is going to be created
scala> val acBalList = Array("SB10001,50000", "SB10002,12000","SB10003,3000", "SB10004,8500", "SB10005,5000")
acBalList: Array[String] = Array(SB10001,50000, SB10002,12000, SB10003,3000, SB10004,8500, SB10005,5000)
scala> // Create the DataFrame
scala> val acMasterDF = sc.parallelize(acMasterList).map(_.split(",")).map(toAcMaster(_)).toDF()
acMasterDF: org.apache.spark.sql.DataFrame = [accNo: string, firstName: string ... 1 more field]
scala> // Create the DataFrame
scala> val acBalDF = sc.parallelize(acBalList).map(_.split(",")).map(toAcBal(_)).toDF()
acBalDF: org.apache.spark.sql.DataFrame = [accNo: string, balanceAmount: double]
scala> // Persist the data of the DataFrame into a Parquet file
scala> acMasterDF.write.parquet("scala.master.parquet")
scala> // Persist the data of the DataFrame into a JSON file
scala> acBalDF.write.json("scalaMaster.json")
scala> // Read the data into a DataFrame from the Parquet file
scala> val acMasterDFFromFile = spark.read.parquet("scala.master.parquet")
acMasterDFFromFile: org.apache.spark.sql.DataFrame = [accNo: string, firstName: string ... 1 more field]
scala> // Register temporary view in the DataFrame for using it in SQL
scala> acMasterDFFromFile.createOrReplaceTempView("master")
scala> // Read the data into a DataFrame from the JSON file
scala> val acBalDFFromFile = spark.read.json("scalaMaster.json")
acBalDFFromFile: org.apache.spark.sql.DataFrame = [accNo: string, balanceAmount: double]
scala> // Register temporary view in the DataFrame for using it in SQL
scala> acBalDFFromFile.createOrReplaceTempView("balance")
scala> // Show the first few records of the DataFrame
scala> acMasterDFFromFile.show
+-------+---------+--------+
| accNo|firstName|lastName|
+-------+---------+--------+
|SB10001| Roger| Federer|
|SB10002| Pete| Sampras|
|SB10003| Rafael| Nadal|
|SB10004| Boris| Becker|
|SB10005| Ivan| Lendl|
+-------+---------+--------+
scala> acBalDFFromFile.show
+-------+-------------+
| accNo|balanceAmount|
+-------+-------------+
|SB10001| 50000.0|
|SB10002| 12000.0|
|SB10003| 3000.0|
|SB10004| 8500.0|
|SB10005| 5000.0|
+-------+-------------+
scala> // Use SQL to create another DataFrame containing the account detail records
scala> val acDetail = spark.sql("SELECT master.accNo, firstName, lastName, balanceAmount FROM master, balance WHERE master.accNo = balance.accNo ORDER BY balanceAmount DESC")
acDetail: org.apache.spark.sql.DataFrame = [accNo: string, firstName: string ... 2 more fields]
scala> // Show the first few records of the DataFrame
scala> acDetail.show
+-------+---------+--------+-------------+
| accNo|firstName|lastName|balanceAmount|
+-------+---------+--------+-------------+
|SB10001| Roger| Federer| 50000.0|
|SB10002| Pete| Sampras| 12000.0|
|SB10004| Boris| Becker| 8500.0|
|SB10005| Ivan| Lendl| 5000.0|
|SB10003| Rafael| Nadal| 3000.0|
+-------+---------+--------+-------------+
继续使用相同的 Scala REPL 会话,以下代码行通过 DataFrame API 获取相同的结果:
scala> // Create the DataFrame using API for the account detail records
scala> val acDetailFromAPI = acMasterDFFromFile.join(acBalDFFromFile, acMasterDFFromFile("accNo") === acBalDFFromFile("accNo"), "inner").sort($"balanceAmount".desc).select(acMasterDFFromFile("accNo"), acMasterDFFromFile("firstName"), acMasterDFFromFile("lastName"), acBalDFFromFile("balanceAmount"))
acDetailFromAPI: org.apache.spark.sql.DataFrame = [accNo: string, firstName: string ... 2 more fields]
scala> // Show the first few records of the DataFrame
scala> acDetailFromAPI.show
+-------+---------+--------+-------------+
| accNo|firstName|lastName|balanceAmount|
+-------+---------+--------+-------------+
|SB10001| Roger| Federer| 50000.0|
|SB10002| Pete| Sampras| 12000.0|
|SB10004| Boris| Becker| 8500.0|
|SB10005| Ivan| Lendl| 5000.0|
|SB10003| Rafael| Nadal| 3000.0|
+-------+---------+--------+-------------+
scala> // Use SQL to create another DataFrame containing the top 3 account detail records
scala> val acDetailTop3 = spark.sql("SELECT master.accNo, firstName, lastName, balanceAmount FROM master, balance WHERE master.accNo = balance.accNo ORDER BY balanceAmount DESC").limit(3)
acDetailTop3: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [accNo: string, firstName: string ... 2 more fields]
scala> // Show the first few records of the DataFrame
scala> acDetailTop3.show
+-------+---------+--------+-------------+
| accNo|firstName|lastName|balanceAmount|
+-------+---------+--------+-------------+
|SB10001| Roger| Federer| 50000.0|
|SB10002| Pete| Sampras| 12000.0|
|SB10004| Boris| Becker| 8500.0|
+-------+---------+--------+-------------+
在代码的前一部分中选定的连接类型是内连接。而不是这样,可以通过 SQL 查询方式或 DataFrame API 方式使用任何其他类型的连接。在这个特定用例中,可以看到 DataFrame API 变得有点笨拙,而 SQL 查询看起来非常直接。这里的要点是,根据具体情况,在应用程序代码中,可以混合使用 SQL 查询方式和 DataFrame API 方式来产生所需的结果。以下脚本中给出的 DataFrame acDetailTop3 是一个例子。
在 Python REPL 提示符下,尝试以下语句:
>>> from pyspark.sql import Row
>>> # Creation of the list from where the RDD is going to be created
>>> AcMaster = Row('accNo', 'firstName', 'lastName')
>>> AcBal = Row('accNo', 'balanceAmount')
>>> acMasterList = ["SB10001,Roger,Federer","SB10002,Pete,Sampras", "SB10003,Rafael,Nadal","SB10004,Boris,Becker", "SB10005,Ivan,Lendl"]
>>> acBalList = ["SB10001,50000", "SB10002,12000","SB10003,3000", "SB10004,8500", "SB10005,5000"]
>>> # Create the DataFrame
>>> acMasterDF = sc.parallelize(acMasterList).map(lambda trans: trans.split(",")).map(lambda r: AcMaster(*r)).toDF()
>>> acBalDF = sc.parallelize(acBalList).map(lambda trans: trans.split(",")).map(lambda r: AcBal(r[0], float(r[1]))).toDF()
>>> # Persist the data of the DataFrame into a Parquet file
>>> acMasterDF.write.parquet("python.master.parquet")
>>> # Persist the data of the DataFrame into a JSON file
>>> acBalDF.write.json("pythonMaster.json")
>>> # Read the data into a DataFrame from the Parquet file
>>> acMasterDFFromFile = spark.read.parquet("python.master.parquet")
>>> # Register temporary table in the DataFrame for using it in SQL
>>> acMasterDFFromFile.createOrReplaceTempView("master")
>>> # Register temporary table in the DataFrame for using it in SQL
>>> acBalDFFromFile = spark.read.json("pythonMaster.json")
>>> # Register temporary table in the DataFrame for using it in SQL
>>> acBalDFFromFile.createOrReplaceTempView("balance")
>>> # Show the first few records of the DataFrame
>>> acMasterDFFromFile.show()
+-------+---------+--------+
| accNo|firstName|lastName|
+-------+---------+--------+
|SB10001| Roger| Federer|
|SB10002| Pete| Sampras|
|SB10003| Rafael| Nadal|
|SB10004| Boris| Becker|
|SB10005| Ivan| Lendl|
+-------+---------+--------+
>>> # Show the first few records of the DataFrame
>>> acBalDFFromFile.show()
+-------+-------------+
| accNo|balanceAmount|
+-------+-------------+
|SB10001| 50000.0|
|SB10002| 12000.0|
|SB10003| 3000.0|
|SB10004| 8500.0|
|SB10005| 5000.0|
+-------+-------------+
>>> # Use SQL to create another DataFrame containing the account detail records
>>> acDetail = spark.sql("SELECT master.accNo, firstName, lastName, balanceAmount FROM master, balance WHERE master.accNo = balance.accNo ORDER BY balanceAmount DESC")
>>> # Show the first few records of the DataFrame
>>> acDetail.show()
+-------+---------+--------+-------------+
| accNo|firstName|lastName|balanceAmount|
+-------+---------+--------+-------------+
|SB10001| Roger| Federer| 50000.0|
|SB10002| Pete| Sampras| 12000.0|
|SB10004| Boris| Becker| 8500.0|
|SB10005| Ivan| Lendl| 5000.0|
|SB10003| Rafael| Nadal| 3000.0|
+-------+---------+--------+-------------+
>>> # Create the DataFrame using API for the account detail records
>>> acDetailFromAPI = acMasterDFFromFile.join(acBalDFFromFile, acMasterDFFromFile.accNo == acBalDFFromFile.accNo).sort(acBalDFFromFile.balanceAmount, ascending=False).select(acMasterDFFromFile.accNo, acMasterDFFromFile.firstName, acMasterDFFromFile.lastName, acBalDFFromFile.balanceAmount)
>>> # Show the first few records of the DataFrame
>>> acDetailFromAPI.show()
+-------+---------+--------+-------------+
| accNo|firstName|lastName|balanceAmount|
+-------+---------+--------+-------------+
|SB10001| Roger| Federer| 50000.0|
|SB10002| Pete| Sampras| 12000.0|
|SB10004| Boris| Becker| 8500.0|
|SB10005| Ivan| Lendl| 5000.0|
|SB10003| Rafael| Nadal| 3000.0|
+-------+---------+--------+-------------+
>>> # Use SQL to create another DataFrame containing the top 3 account detail records
>>> acDetailTop3 = spark.sql("SELECT master.accNo, firstName, lastName, balanceAmount FROM master, balance WHERE master.accNo = balance.accNo ORDER BY balanceAmount DESC").limit(3)
>>> # Show the first few records of the DataFrame
>>> acDetailTop3.show()
+-------+---------+--------+-------------+
| accNo|firstName|lastName|balanceAmount|
+-------+---------+--------+-------------+
|SB10001| Roger| Federer| 50000.0|
|SB10002| Pete| Sampras| 12000.0|
|SB10004| Boris| Becker| 8500.0|
+-------+---------+--------+-------------+
在前面的章节中,已经展示了在 DataFrame 上应用 RDD 操作。这显示了 Spark SQL 与 RDDs 交互以及反之亦然的能力。同样,SQL 查询和 DataFrame API 可以混合使用,以便在解决应用程序中的实际用例时,能够灵活地使用计算的最简单方法。
介绍数据集
当涉及到开发数据处理应用程序时,Spark 编程范式提供了许多抽象供选择。Spark 编程的基础始于可以轻松处理非结构化、半结构化和结构化数据的 RDDs。Spark SQL 库在处理结构化数据时提供了高度优化的性能。这使得基本的 RDDs 在性能方面看起来有些不足。为了填补这一差距,从 Spark 1.6 版本开始,引入了一种新的抽象,名为 Dataset,它补充了基于 RDD 的 Spark 编程模型。它在 Spark 转换和 Spark 操作方面几乎与 RDD 相同,同时,它像 Spark SQL 一样高度优化。Dataset API 在编写程序时提供了强大的编译时类型安全性,因此,Dataset API 仅在 Scala 和 Java 中可用。
在涵盖 Spark 编程模型的章节中讨论的交易银行业务案例在此再次提出,以阐明基于 dataset 的编程模型,因为这种编程模型与基于 RDD 的编程非常相似。该案例主要涉及一组银行交易记录以及在这些记录上执行的各种处理,以从中提取各种信息。案例描述在此不再重复,通过查看注释和代码不难理解。
以下代码片段演示了创建 Dataset 所使用的方法,以及它的使用、将 RDD 转换为 DataFrame 以及将 DataFrame 转换为 dataset 的过程。RDD 到 DataFrame 的转换已经讨论过,但在此再次捕获以保持概念的一致性。这主要是为了证明 Spark 中的各种编程模型和数据抽象具有高度的互操作性。
在 Scala REPL 提示符下,尝试以下语句:
scala> // Define the case classes for using in conjunction with DataFrames and Dataset
scala> case class Trans(accNo: String, tranAmount: Double)
defined class Trans
scala> // Creation of the list from where the Dataset is going to be created using a case class.
scala> val acTransList = Seq(Trans("SB10001", 1000), Trans("SB10002",1200), Trans("SB10003", 8000), Trans("SB10004",400), Trans("SB10005",300), Trans("SB10006",10000), Trans("SB10007",500), Trans("SB10008",56), Trans("SB10009",30),Trans("SB10010",7000), Trans("CR10001",7000), Trans("SB10002",-10))
acTransList: Seq[Trans] = List(Trans(SB10001,1000.0), Trans(SB10002,1200.0), Trans(SB10003,8000.0), Trans(SB10004,400.0), Trans(SB10005,300.0), Trans(SB10006,10000.0), Trans(SB10007,500.0), Trans(SB10008,56.0), Trans(SB10009,30.0), Trans(SB10010,7000.0), Trans(CR10001,7000.0), Trans(SB10002,-10.0))
scala> // Create the Dataset
scala> val acTransDS = acTransList.toDS()
acTransDS: org.apache.spark.sql.Dataset[Trans] = [accNo: string, tranAmount: double]
scala> acTransDS.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10004| 400.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10007| 500.0|
|SB10008| 56.0|
|SB10009| 30.0|
|SB10010| 7000.0|
|CR10001| 7000.0|
|SB10002| -10.0|
+-------+----------+
scala> // Apply filter and create another Dataset of good transaction records
scala> val goodTransRecords = acTransDS.filter(_.tranAmount > 0).filter(_.accNo.startsWith("SB"))
goodTransRecords: org.apache.spark.sql.Dataset[Trans] = [accNo: string, tranAmount: double]
scala> goodTransRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10004| 400.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10007| 500.0|
|SB10008| 56.0|
|SB10009| 30.0|
|SB10010| 7000.0|
+-------+----------+
scala> // Apply filter and create another Dataset of high value transaction records
scala> val highValueTransRecords = goodTransRecords.filter(_.tranAmount > 1000)
highValueTransRecords: org.apache.spark.sql.Dataset[Trans] = [accNo: string, tranAmount: double]
scala> highValueTransRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10006| 10000.0|
|SB10010| 7000.0|
+-------+----------+
scala> // The function that identifies the bad amounts
scala> val badAmountLambda = (trans: Trans) => trans.tranAmount <= 0
badAmountLambda: Trans => Boolean = <function1>
scala> // The function that identifies bad accounts
scala> val badAcNoLambda = (trans: Trans) => trans.accNo.startsWith("SB") == false
badAcNoLambda: Trans => Boolean = <function1>
scala> // Apply filter and create another Dataset of bad amount records
scala> val badAmountRecords = acTransDS.filter(badAmountLambda)
badAmountRecords: org.apache.spark.sql.Dataset[Trans] = [accNo: string, tranAmount: double]
scala> badAmountRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| -10.0|
+-------+----------+
scala> // Apply filter and create another Dataset of bad account records
scala> val badAccountRecords = acTransDS.filter(badAcNoLambda)
badAccountRecords: org.apache.spark.sql.Dataset[Trans] = [accNo: string, tranAmount: double]
scala> badAccountRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|CR10001| 7000.0|
+-------+----------+
scala> // Do the union of two Dataset and create another Dataset
scala> val badTransRecords = badAmountRecords.union(badAccountRecords)
badTransRecords: org.apache.spark.sql.Dataset[Trans] = [accNo: string, tranAmount: double]
scala> badTransRecords.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10002| -10.0|
|CR10001| 7000.0|
+-------+----------+
scala> // Calculate the sum
scala> val sumAmount = goodTransRecords.map(trans => trans.tranAmount).reduce(_ + _)
sumAmount: Double = 28486.0
scala> // Calculate the maximum
scala> val maxAmount = goodTransRecords.map(trans => trans.tranAmount).reduce((a, b) => if (a > b) a else b)
maxAmount: Double = 10000.0
scala> // Calculate the minimum
scala> val minAmount = goodTransRecords.map(trans => trans.tranAmount).reduce((a, b) => if (a < b) a else b)
minAmount: Double = 30.0
scala> // Convert the Dataset to DataFrame
scala> val acTransDF = acTransDS.toDF()
acTransDF: org.apache.spark.sql.DataFrame = [accNo: string, tranAmount: double]
scala> acTransDF.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10004| 400.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10007| 500.0|
|SB10008| 56.0|
|SB10009| 30.0|
|SB10010| 7000.0|
|CR10001| 7000.0|
|SB10002| -10.0|
+-------+----------+
scala> // Use Spark SQL to find out invalid transaction records
scala> acTransDF.createOrReplaceTempView("trans")
scala> val invalidTransactions = spark.sql("SELECT accNo, tranAmount FROM trans WHERE (accNo NOT LIKE 'SB%') OR tranAmount <= 0")
invalidTransactions: org.apache.spark.sql.DataFrame = [accNo: string, tranAmount: double]
scala> invalidTransactions.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|CR10001| 7000.0|
|SB10002| -10.0|
+-------+----------+
scala> // Interoperability of RDD, DataFrame and Dataset
scala> // Create RDD
scala> val acTransRDD = sc.parallelize(acTransList)
acTransRDD: org.apache.spark.rdd.RDD[Trans] = ParallelCollectionRDD[206] at parallelize at <console>:28
scala> // Convert RDD to DataFrame
scala> val acTransRDDtoDF = acTransRDD.toDF()
acTransRDDtoDF: org.apache.spark.sql.DataFrame = [accNo: string, tranAmount: double]
scala> // Convert the DataFrame to Dataset with the type checking
scala> val acTransDFtoDS = acTransRDDtoDF.as[Trans]
acTransDFtoDS: org.apache.spark.sql.Dataset[Trans] = [accNo: string, tranAmount: double]
scala> acTransDFtoDS.show()
+-------+----------+
| accNo|tranAmount|
+-------+----------+
|SB10001| 1000.0|
|SB10002| 1200.0|
|SB10003| 8000.0|
|SB10004| 400.0|
|SB10005| 300.0|
|SB10006| 10000.0|
|SB10007| 500.0|
|SB10008| 56.0|
|SB10009| 30.0|
|SB10010| 7000.0|
|CR10001| 7000.0|
|SB10002| -10.0|
+-------+----------+
很明显,基于 dataset 的编程在许多数据处理用例中具有良好的适用性;同时,它与其他 Spark 内部的数据处理抽象具有高度的互操作性。
小贴士
在前面的代码片段中,DataFrame 通过类型指定acTransRDDToDF.as[Trans]转换为 Dataset。这种类型的转换在从外部数据源(如 JSON、Avro 或 Parquet 文件)读取数据时确实是必需的。那时就需要强类型检查。通常,结构化数据被读取到 DataFrame 中,然后可以通过以下方式一次性转换为具有强类型安全检查的 DataSet:spark.read.json("/transaction.json").as[Trans]
如果检查本章中的 Scala 代码片段,当在 DataFrame 上调用某些方法时,返回的不是 DataFrame 对象,而是一个 org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] 类型的对象。这是 DataFrame 和 dataset 之间的重要关系。换句话说,DataFrame 是一个 org.apache.spark.sql.Row 类型的 dataset。如果需要,可以使用 toDF() 方法显式地将此 org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] 类型的对象转换为 DataFrame。
选项过多会让人感到困惑。在这里的 Spark 编程模型中,也存在同样的问题。但相较于许多其他编程范式,它并不那么令人困惑。每当需要以非常高的灵活性处理各种数据,并且需要最低级别的 API 控制如库开发时,基于 RDD 的编程模型是理想的。每当需要以灵活的方式处理结构化数据,并且在整个支持的编程语言中具有优化性能时,基于 DataFrame 的 Spark SQL 编程模型是理想的。
当需要以优化性能要求以及编译时类型安全,但不是非常复杂的 Spark 转换和 Spark 动作使用要求来处理非结构化数据时,基于 dataset 的编程模型是理想的。在数据处理应用程序开发层面,如果选择的编程语言允许,最好使用 dataset 和 DataFrame 以获得更好的性能。
理解数据目录
本章的前几节介绍了 DataFrame 和 dataset 的编程模型。这两个编程模型都可以处理结构化数据。结构化数据包含元数据或描述数据结构的描述性数据。Spark SQL 为数据处理应用程序提供了一个名为 Catalog API 的最小化 API,用于查询和应用程序中的元数据。Catalog API 提供了一个包含许多数据库的目录抽象。对于常规的 SparkSession,它将只有一个数据库,即默认数据库。但如果 Spark 与 Hive 一起使用,则整个 Hive 元存储将通过 Catalog API 可用。以下代码片段展示了 Scala 和 Python 中 Catalog API 的使用示例。
从相同的 Scala REPL 提示符继续,尝试以下语句:
scala> // Get the catalog object from the SparkSession object
scala> val catalog = spark.catalog
catalog: org.apache.spark.sql.catalog.Catalog = org.apache.spark.sql.internal.CatalogImpl@14b8a751
scala> // Get the list of databases
scala> val dbList = catalog.listDatabases()
dbList: org.apache.spark.sql.Dataset[org.apache.spark.sql.catalog.Database] = [name: string, description: string ... 1 more field]
scala> // Display the details of the databases
scala> dbList.select("name", "description", "locationUri").show()**+-------+----------------+--------------------+**
**| name| description| locationUri|**
**+-------+----------------+--------------------+**
**|default|default database|file:/Users/RajT/...|**
**+-------+----------------+--------------------+**
scala> // Display the details of the tables in the database
scala> val tableList = catalog.listTables()
tableList: org.apache.spark.sql.Dataset[org.apache.spark.sql.catalog.Table] = [name: string, database: string ... 3 more fields]
scala> tableList.show()**+-----+--------+-----------+---------+-----------+**
**| name|database|description|tableType|isTemporary|**
**+-----+--------+-----------+---------+-----------+**
**|trans| null| null|TEMPORARY| true|**
**+-----+--------+-----------+---------+-----------+**
scala> // The above list contains the temporary view that was created in the Dataset use case discussed in the previous section
// The views created in the applications can be removed from the database using the Catalog APIscala> catalog.dropTempView("trans")
// List the available tables after dropping the temporary viewscala> val latestTableList = catalog.listTables()
latestTableList: org.apache.spark.sql.Dataset[org.apache.spark.sql.catalog.Table] = [name: string, database: string ... 3 more fields]
scala> latestTableList.show()**+----+--------+-----------+---------+-----------+**
**|name|database|description|tableType|isTemporary|**
**+----+--------+-----------+---------+-----------+**
**+----+--------+-----------+---------+-----------+**
同样,Catalog API 也可以从 Python 代码中使用。由于在 Python 中 dataset 示例不适用,因此表列表将为空。在 Python REPL 提示符下,尝试以下语句:
>>> #Get the catalog object from the SparkSession object
>>> catalog = spark.catalog
>>> #Get the list of databases and their details.
>>> catalog.listDatabases() [Database(name='default', description='default database', locationUri='file:/Users/RajT/source-code/spark-source/spark-2.0/spark-warehouse')]
// Display the details of the tables in the database
>>> catalog.listTables()
>>> []
当编写数据处理应用程序时,Catalog API 非常方便,它可以根据元存储中的内容动态处理数据,尤其是在与 Hive 结合使用时。
参考文献
如需更多信息,您可以参考:
摘要
Spark SQL 是 Spark 核心基础设施之上的一个非常有用的库。这个库使得 Spark 编程对那些熟悉命令式编程风格但不太擅长函数式编程的程序员更加包容。除此之外,Spark SQL 是 Spark 数据处理库家族中处理结构化数据的最佳库。基于 Spark SQL 的数据处理应用程序可以使用类似 SQL 的查询或 DataFrame API 的命令式程序风格进行编写。本章还演示了混合 RDD 和 DataFrame、混合类似 SQL 的查询和 DataFrame API 的各种策略。这为应用程序开发者提供了极大的灵活性,让他们可以以最舒适的方式或更符合用例的方式编写数据处理程序,同时不牺牲性能。
数据集 API 是 Spark 中基于数据集的下一代编程模型,提供优化的性能和编译时类型安全。
目录 API 是一个非常实用的工具,可以根据元存储的内容动态处理数据。
R 是数据科学家的语言。在 Spark SQL 支持 R 作为编程语言之前,对于他们来说,主要的分布式数据处理并不容易。现在,使用 R 作为首选语言,他们可以无缝地编写分布式数据处理应用程序,就像他们使用个人机器上的 R 数据框一样。下一章将讨论在 Spark SQL 中使用 R 进行数据处理。
第四章. 使用 R 语言进行 Spark 编程
R 是一种流行的统计计算编程语言,被许多人使用,并且可以在 通用公共许可证(GNU)下免费获得。R 语言起源于由 John Chambers 创建的编程语言 S,由 Ross Ihaka 和 Robert Gentleman 开发。许多数据科学家使用 R 来满足他们的计算需求。R 语言具有许多内置的统计函数和许多标量数据类型,并为向量、矩阵、数据框等提供了复合数据结构,用于统计计算。R 语言高度可扩展,因此可以创建外部包。一旦创建了外部包,就必须安装和加载它,以便任何程序可以使用它。这些包的集合在目录下形成了一个 R 库。换句话说,R 语言自带了一套基础包,以及可以安装在其上的附加包,以形成满足所需计算需求所需的库。除了函数外,数据集也可以打包在 R 包中。
本章将涵盖以下主题:
-
SparkR 的需求
-
R 语言基础
-
数据框
-
聚合
-
使用 SparkR 的多数据源连接
SparkR 的需求
纯 R 语言基础安装无法与 Spark 交互。SparkR 包暴露了 R 与 Spark 生态系统通信所需的所有对象和函数。与 Scala、Java 和 Python 相比,R 语言的 Spark 编程有所不同,SparkR 包主要暴露了基于 DataFrame 的 Spark SQL 编程的 R API。目前,R 无法直接操作 Spark 的 RDD。因此,从实际应用的角度来看,R API 对 Spark 的访问仅限于 Spark SQL 抽象。Spark MLlib 也可以使用 R 进行编程,因为 Spark MLlib 使用 DataFrame。
SparkR 如何帮助数据科学家更好地进行数据处理?基础 R 安装要求所有要存储(或可访问)的数据都在安装 R 的计算机上。数据处理发生在可访问 R 安装的单一计算机上。此外,如果数据量超过计算机上的主内存,R 将无法进行所需的处理。使用 SparkR 包,可以访问一个全新的节点集群,用于数据存储和数据处理。借助 SparkR 包,可以使用 R 访问 Spark DataFrame 以及 R DataFrame。
了解两种数据框类型的区别非常重要,即 R 数据框和 Spark 数据框。R 数据框是完全局部的,是 R 语言的数结构。Spark 数据框是由 Spark 基础设施管理的结构化数据的并行集合。
R 数据框可以转换为 Spark 数据框,Spark 数据框也可以转换为 R 数据框。
当 Spark DataFrame 转换为 R DataFrame 时,它应该适合计算机可用的内存。这种转换是一个很好的特性,并且有必要这样做。通过将 R DataFrame 转换为 Spark DataFrame,数据可以分布式并行处理。通过将 Spark DataFrame 转换为 R DataFrame,可以使用其他 R 函数执行的大量计算、图表和绘图操作。简而言之,SparkR 包将分布式和并行计算能力引入 R。
在使用 R 进行数据处理时,由于数据量巨大以及需要将其放入计算机的主内存中,数据处理通常在多个批次中完成,并将结果合并以计算最终结果。如果使用 Spark 与 R 处理数据,可以完全避免这种多批次处理。
通常,报告、图表和绘图是在汇总和总结的原始数据上完成的。原始数据的大小可能很大,不一定适合在一个计算机中。在这种情况下,可以使用 Spark 与 R 处理整个原始数据,最后,使用汇总和总结的数据来生成报告、图表或绘图。
由于无法处理大量数据以及使用 R 进行数据分析,很多时候,ETL 工具被用来在原始数据上执行预处理或转换,并且仅在最终阶段使用 R 进行数据分析。由于 Spark 能够大规模处理数据,Spark 与 R 可以替代整个 ETL 管道,并使用 R 执行所需的数据分析。
许多 R 用户使用 dplyr R 包来操作 R 中的数据集。这个包提供了与 R DataFrames 一起快速的数据操作能力。就像操作本地 R DataFrame 一样,它还可以访问一些 RDBMS 表中的数据。除了这些原始的数据操作能力之外,它还缺少 Spark 中可用的许多数据处理功能。因此,Spark 与 R 是 dplyr 等包的良好替代品。
SparkR 包是另一个 R 包,但这并没有阻止任何人使用已经使用的任何 R 包。同时,它通过利用 Spark 的巨大数据处理能力,补充了 R 的数据处理能力。
R 语言基础
这不是任何形式的 R 编程指南。但是,为了使不熟悉 R 的人能够欣赏本章所涵盖的内容,简要地介绍 R 语言的基本知识是很重要的。这里涵盖了语言特性的非常基本的介绍。
R 随带一些内置数据类型来存储数值、字符和布尔值。有复合数据结构可用,其中最重要的有,即向量、列表、矩阵和数据框。向量是由给定类型的值按顺序排列的集合。列表是元素按顺序排列的集合,这些元素可以是不同类型的。例如,列表可以包含两个向量,其中一个向量包含数值,另一个向量包含布尔值。矩阵是一个二维数据结构,在行和列中存储数值。数据框是一个二维数据结构,包含行和列,其中列可以有不同的数据类型,但单个列不能包含不同的数据类型。
以下是一些使用变量(向量的特例)、数值向量、字符向量、列表、矩阵、数据框以及为数据框分配列名的代码示例。变量名尽可能具有自描述性,以便读者在没有额外解释的情况下理解。以下在常规 R REPL 上运行的代码片段给出了 R 的数据结构概念:
$ r
R version 3.2.2 (2015-08-14) -- "Fire Safety"
Copyright (C) 2015 The R Foundation for Statistical Computing
Platform: x86_64-apple-darwin13.4.0 (64-bit)
R is free software and comes with ABSOLUTELY NO WARRANTY.
You are welcome to redistribute it under certain conditions.
Type 'license()' or 'licence()' for distribution details.
Natural language support but running in an English locale
R is a collaborative project with many contributors.
Type 'contributors()' for more information and
'citation()' on how to cite R or R packages in publications.
Type 'demo()' for some demos, 'help()' for on-line help, or
'help.start()' for an HTML browser interface to help.
Type 'q()' to quit R.
Warning: namespace 'SparkR' is not available and has been replaced
by .GlobalEnv when processing object 'goodTransRecords'
[Previously saved workspace restored]
>
> x <- 5
> x
[1] 5
> aNumericVector <- c(10,10.5,31.2,100)
> aNumericVector
[1] 10.0 10.5 31.2 100.0
> aCharVector <- c("apple", "orange", "mango")
> aCharVector
[1] "apple" "orange" "mango"
> aBooleanVector <- c(TRUE, FALSE, TRUE, FALSE, FALSE)
> aBooleanVector
[1] TRUE FALSE TRUE FALSE FALSE
> aList <- list(aNumericVector, aCharVector)
> aList
[[1]]
[1] 10.0 10.5 31.2 100.0
[[2]]
[1] "apple" "orange" "mango"
> aMatrix <- matrix(c(100, 210, 76, 65, 34, 45),nrow=3,ncol=2,byrow = TRUE)
> aMatrix
[,1] [,2]
[1,] 100 210
[2,] 76 65
[3,] 34 45
> bMatrix <- matrix(c(100, 210, 76, 65, 34, 45),nrow=3,ncol=2,byrow = FALSE)
> bMatrix
[,1] [,2]
[1,] 100 65
[2,] 210 34
[3,] 76 45
> ageVector <- c(21, 35, 52)
> nameVector <- c("Thomas", "Mathew", "John")
> marriedVector <- c(FALSE, TRUE, TRUE)
> aDataFrame <- data.frame(ageVector, nameVector, marriedVector)
> aDataFrame
ageVector nameVector marriedVector
1 21 Thomas FALSE
2 35 Mathew TRUE
3 52 John TRUE
> colnames(aDataFrame) <- c("Age","Name", "Married")
> aDataFrame
Age Name Married
1 21 Thomas FALSE
2 35 Mathew TRUE
3 52 John TRUE
这里讨论的主要话题将围绕数据框展开。这里展示了与数据框常用的一些函数。所有这些命令都应在常规 R REPL 中执行,作为执行前一个代码片段的会话的延续:
> # Returns the first part of the data frame and return two rows
> head(aDataFrame,2)
Age Name Married
1 21 Thomas FALSE
2 35 Mathew TRUE
> # Returns the last part of the data frame and return two rows
> tail(aDataFrame,2)
Age Name Married
2 35 Mathew TRUE
3 52 John TRUE
> # Number of rows in a data frame
> nrow(aDataFrame)
[1] 3
> # Number of columns in a data frame
> ncol(aDataFrame)
[1] 3
> # Returns the first column of the data frame. The return value is a data frame
> aDataFrame[1]
Age
1 21
2 35
3 52
> # Returns the second column of the data frame. The return value is a data frame
> aDataFrame[2]
Name
1 Thomas
2 Mathew
3 John
> # Returns the named columns of the data frame. The return value is a data frame
> aDataFrame[c("Age", "Name")]
Age Name
1 21 Thomas
2 35 Mathew
3 52 John
> # Returns the contents of the second column of the data frame as a vector.
> aDataFrame[[2]]
[1] Thomas Mathew John
Levels: John Mathew Thomas
> # Returns the slice of the data frame by a row
> aDataFrame[2,]
Age Name Married
2 35 Mathew TRUE
> # Returns the slice of the data frame by multiple rows
> aDataFrame[c(1,2),]
Age Name Married
1 21 Thomas FALSE
2 35 Mathew TRUE
R 和 Spark 中的 DataFrames
当使用 R 与 Spark 一起工作时,很容易对 DataFrame 数据结构感到困惑。如前所述,它在 R 和 Spark SQL 中都存在。以下代码片段处理将 R DataFrame 转换为 Spark DataFrame 以及相反操作。当使用 R 编程 Spark 时,这将是一个非常常见的操作。以下代码片段应在 Spark 的 R REPL 中执行。从现在开始,所有关于 R REPL 的引用都是指 Spark 的 R REPL:
$ cd $SPARK_HOME
$ ./bin/sparkR
R version 3.2.2 (2015-08-14) -- "Fire Safety"
Copyright (C) 2015 The R Foundation for Statistical Computing
Platform: x86_64-apple-darwin13.4.0 (64-bit)
R is free software and comes with ABSOLUTELY NO WARRANTY.
You are welcome to redistribute it under certain conditions.
Type 'license()' or 'licence()' for distribution details.
Natural language support but running in an English locale
R is a collaborative project with many contributors.
Type 'contributors()' for more information and
'citation()' on how to cite R or R packages in publications.
Type 'demo()' for some demos, 'help()' for on-line help, or
'help.start()' for an HTML browser interface to help.
Type 'q()' to quit R.
[Previously saved workspace restored]
Launching java with spark-submit command /Users/RajT/source-code/spark-source/spark-2.0/bin/spark-submit "sparkr-shell" /var/folders/nf/trtmyt9534z03kq8p8zgbnxh0000gn/T//RtmpmuRsTC/backend_port2d121acef4
Using Spark's default log4j profile: org/apache/spark/log4j-defaults.properties
Setting default log level to "WARN".
To adjust logging level use sc.setLogLevel(newLevel).
16/07/16 21:08:50 WARN NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/___/ .__/\_,_/_/ /_/\_\ version 2.0.1-SNAPSHOT
/_/
Spark context is available as sc, SQL context is available as sqlContext
During startup - Warning messages:
1: 'SparkR::sparkR.init' is deprecated.
Use 'sparkR.session' instead.
See help("Deprecated")
2: 'SparkR::sparkRSQL.init' is deprecated.
Use 'sparkR.session' instead.
See help("Deprecated")
>
> # faithful is a data set and the data frame that comes with base R
> # Obviously it is an R DataFrame
> head(faithful)
eruptions waiting
1 3.600 79
2 1.800 54
3 3.333 74
4 2.283 62
5 4.533 85
6 2.883 55
> tail(faithful)
eruptions waiting
267 4.750 75
268 4.117 81
269 2.150 46
270 4.417 90
271 1.817 46
272 4.467 74
> # Convert R DataFrame to Spark DataFrame
> sparkFaithful <- createDataFrame(faithful)
> head(sparkFaithful)
eruptions waiting
1 3.600 79
2 1.800 54
3 3.333 74
4 2.283 62
5 4.533 85
6 2.883 55
> showDF(sparkFaithful)
+---------+-------+
|eruptions|waiting|
+---------+-------+
| 3.6| 79.0|
| 1.8| 54.0|
| 3.333| 74.0|
| 2.283| 62.0|
| 4.533| 85.0|
| 2.883| 55.0|
| 4.7| 88.0|
| 3.6| 85.0|
| 1.95| 51.0|
| 4.35| 85.0|
| 1.833| 54.0|
| 3.917| 84.0|
| 4.2| 78.0|
| 1.75| 47.0|
| 4.7| 83.0|
| 2.167| 52.0|
| 1.75| 62.0|
| 4.8| 84.0|
| 1.6| 52.0|
| 4.25| 79.0|
+---------+-------+
only showing top 20 rows
> # Try calling a SparkR function showDF() on an R DataFrame. The following error message will be shown
> showDF(faithful)
Error in (function (classes, fdef, mtable) :
unable to find an inherited method for function 'showDF' for signature '"data.frame"'
> # Convert the Spark DataFrame to an R DataFrame
> rFaithful <- collect(sparkFaithful)
> head(rFaithful)
eruptions waiting
1 3.600 79
2 1.800 54
3 3.333 74
4 2.283 62
5 4.533 85
6 2.883 55
在支持的功能方面,R DataFrame 和 Spark DataFrame 之间没有完全的兼容性和互操作性。
小贴士
作为一种良好的实践,在 R 程序中最好使用约定的命名约定来命名 R DataFrame 和 Spark DataFrame,以便在两种不同类型之间有所区分。并非所有在 R DataFrame 上支持的功能都在 Spark DataFrame 上支持,反之亦然。始终参考 Spark 的正确版本 R API。
那些大量使用图表和绘图的人在使用 R DataFrame 与 Spark DataFrame 结合时必须格外小心。R 的图表和绘图仅与 R DataFrame 一起工作。如果需要使用 Spark DataFrame 中处理的数据生成图表或绘图,则必须将其转换为 R DataFrame 才能进行图表和绘图。以下代码片段将给出一个想法。我们将再次使用 faithful 数据集,在 Spark 的 R REPL 中进行阐明:
head(faithful)
eruptions waiting
1 3.600 79
2 1.800 54
3 3.333 74
4 2.283 62
5 4.533 85
6 2.883 55
> # Convert the faithful R DataFrame to Spark DataFrame
> sparkFaithful <- createDataFrame(faithful)
> # The Spark DataFrame sparkFaithful NOT producing a histogram
> hist(sparkFaithful$eruptions,main="Distribution of Eruptions",xlab="Eruptions")
Error in hist.default(sparkFaithful$eruptions, main = "Distribution of Eruptions", :
'x' must be numeric
> # The R DataFrame faithful producing a histogram
> hist(faithful$eruptions,main="Distribution of Eruptions",xlab="Eruptions")
此图仅用于演示 Spark DataFrame 不能用于图表和绘图,而必须使用 R DataFrame 进行相同的操作:

图 1
当与 Spark DataFrame 一起使用时,由于数据类型的不兼容,图表和绘图库出现了错误。
小贴士
需要牢记的最重要的一点是,R DataFrame 是一个内存驻留的数据结构,而 Spark DataFrame 是跨节点集群分布的数据集的并行集合。因此,所有使用 R DataFrame 的函数不必与 Spark DataFrame 一起工作,反之亦然。
让我们再次回顾一下更大的图景,如图 2 所示,以设置上下文并了解在这里讨论的内容,然后再进入并处理使用案例。在前一章中,使用 Scala 和 Python 编程语言介绍了相同主题。在这一章中,将使用与 Spark SQL 编程中使用的相同的一组使用案例,但使用 R 来实现:

图 2
这里将要讨论的使用案例将展示在 R 中混合 SQL 查询与 Spark 程序的能力。将选择多个数据源,使用 DataFrame 从这些源读取数据,并演示统一的数据访问。
使用 R 进行 Spark DataFrame 编程
用于阐明使用 DataFrame 进行 Spark SQL 编程的使用案例如下:
-
交易记录是逗号分隔值。
-
从列表中过滤出仅包含良好交易记录。账户号码应以
SB开头,交易金额应大于零。 -
查找所有交易金额大于 1000 的高价值交易记录。
-
查找所有账户号码错误的交易记录。
-
查找所有交易金额小于或等于零的交易记录。
-
查找所有不良交易记录的合并列表。
-
查找所有交易金额的总和。
-
查找所有交易金额的最大值。
-
查找所有交易金额的最小值。
-
查找所有账户号码良好的记录。
这正是上一章中使用的一组用例,但在这里,编程模型完全不同。在这里,编程是在 R 中完成的。使用这组用例,展示了两种类型的编程模型。一种是使用 SQL 查询,另一种是使用 DataFrame API。
小贴士
运行以下代码片段所需的数据文件与 R 代码所在的同一目录中可用。
在以下代码片段中,数据是从文件系统中读取的。由于所有这些代码片段都是在 Spark 的 R REPL 中执行的,因此所有数据文件都必须保存在$SPARK_HOME目录中。
使用 SQL 进行编程
在 R REPL 提示符下,尝试以下语句:
> # TODO - Change the data directory location to the right location in the system in which this program is being run
> DATA_DIR <- "/Users/RajT/Documents/CodeAndData/R/"
> # Read data from a JSON file to create DataFrame
>
> acTransDF <- read.json(paste(DATA_DIR, "TransList1.json", sep = ""))
> # Print the structure of the DataFrame
> print(acTransDF)
SparkDataFrame[AccNo:string, TranAmount:bigint]
> # Show sample records from the DataFrame
> showDF(acTransDF)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|SB10001| 1000|
|SB10002| 1200|
|SB10003| 8000|
|SB10004| 400|
|SB10005| 300|
|SB10006| 10000|
|SB10007| 500|
|SB10008| 56|
|SB10009| 30|
|SB10010| 7000|
|CR10001| 7000|
|SB10002| -10|
+-------+----------+
> # Register temporary view definition in the DataFrame for SQL queries
> createOrReplaceTempView(acTransDF, "trans")
> # DataFrame containing good transaction records using SQL
> goodTransRecords <- sql("SELECT AccNo, TranAmount FROM trans WHERE AccNo like 'SB%' AND TranAmount > 0")
> # Register temporary table definition in the DataFrame for SQL queries
> createOrReplaceTempView(goodTransRecords, "goodtrans")
> # Show sample records from the DataFrame
> showDF(goodTransRecords)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|SB10001| 1000|
|SB10002| 1200|
|SB10003| 8000|
|SB10004| 400|
|SB10005| 300|
|SB10006| 10000|
|SB10007| 500|
|SB10008| 56|
|SB10009| 30|
|SB10010| 7000|
+-------+----------+
> # DataFrame containing high value transaction records using SQL
> highValueTransRecords <- sql("SELECT AccNo, TranAmount FROM goodtrans WHERE TranAmount > 1000")
> # Show sample records from the DataFrame
> showDF(highValueTransRecords)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|SB10002| 1200|
|SB10003| 8000|
|SB10006| 10000|
|SB10010| 7000|
+-------+----------+
> # DataFrame containing bad account records using SQL
> badAccountRecords <- sql("SELECT AccNo, TranAmount FROM trans WHERE AccNo NOT like 'SB%'")
> # Show sample records from the DataFrame
> showDF(badAccountRecords)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|CR10001| 7000|
+-------+----------+
> # DataFrame containing bad amount records using SQL
> badAmountRecords <- sql("SELECT AccNo, TranAmount FROM trans WHERE TranAmount < 0")
> # Show sample records from the DataFrame
> showDF(badAmountRecords)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|SB10002| -10|
+-------+----------+
> # Create a DataFrame by taking the union of two DataFrames
> badTransRecords <- union(badAccountRecords, badAmountRecords)
> # Show sample records from the DataFrame
> showDF(badTransRecords)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|CR10001| 7000|
|SB10002| -10|
+-------+----------+
> # DataFrame containing sum amount using SQL
> sumAmount <- sql("SELECT sum(TranAmount) as sum FROM goodtrans")
> # Show sample records from the DataFrame
> showDF(sumAmount)
+-----+
| sum|
+-----+
|28486|
+-----+
> # DataFrame containing maximum amount using SQL
> maxAmount <- sql("SELECT max(TranAmount) as max FROM goodtrans")
> # Show sample records from the DataFrame
> showDF(maxAmount)
+-----+
| max|
+-----+
|10000|
+-----+
> # DataFrame containing minimum amount using SQL
> minAmount <- sql("SELECT min(TranAmount)as min FROM goodtrans")
> # Show sample records from the DataFrame
> showDF(minAmount)
+---+
|min|
+---+
| 30|
+---+
> # DataFrame containing good account number records using SQL
> goodAccNos <- sql("SELECT DISTINCT AccNo FROM trans WHERE AccNo like 'SB%' ORDER BY AccNo")
> # Show sample records from the DataFrame
> showDF(goodAccNos)
+-------+
| AccNo|
+-------+
|SB10001|
|SB10002|
|SB10003|
|SB10004|
|SB10005|
|SB10006|
|SB10007|
|SB10008|
|SB10009|
|SB10010|
+-------+
零售银行交易记录包含账户号码和交易金额,使用 SparkSQL 进行处理以获得用例的预期结果。以下是前面脚本所做操作的摘要:
-
与 Spark 支持的其他编程语言不同,R 没有 RDD 编程能力。因此,不是从集合中构建 RDD,而是从包含交易记录的 JSON 文件中读取数据。
-
从 JSON 文件创建 Spark DataFrame。
-
使用 DataFrame 注册一个带有名称的表。这个注册的表名可以在 SQL 语句中使用。
-
然后,所有其他活动都是通过 SparkR 包中的 SQL 函数发出 SQL 语句。
-
所有这些 SQL 语句的结果都存储为 Spark DataFrame,并使用 showDF 函数将值提取到调用 R 程序中。
-
聚合值计算也是通过 SQL 语句完成的。
-
DataFrame 的内容使用 SparkR 的
showDF函数以表格格式显示。 -
使用打印函数可以显示 DataFrame 的结构视图。这类似于数据库表的 describe 命令。
在前面的 R 代码中,编程风格与 Scala 代码不同,因为它是一个 R 程序。使用 SparkR 库,正在使用 Spark 功能。但函数和其他抽象并没有真正不同的风格。
注意
在本章中,将会有使用 DataFrame 的实例。很容易混淆哪个是 R DataFrame,哪个是 Spark DataFrame。因此,特别注意通过限定 DataFrame 来具体说明,例如 R DataFrame 和 Spark DataFrame。
使用 R DataFrame API 进行编程
在本节中,代码片段将在相同的 R REPL 中运行。与前面的代码片段一样,最初给出了一些 DataFrame 特定的基本命令。这些命令被经常使用,用于查看内容并对 DataFrame 及其内容进行一些基本测试。这些是在数据分析的探索阶段经常使用的命令,以获得更多对底层数据结构和内容的洞察。
在 R 的 REPL 提示符下,尝试以下语句:
> # Read data from a JSON file to create DataFrame
> acTransDF <- read.json(paste(DATA_DIR, "TransList1.json", sep = ""))
> print(acTransDF)
SparkDataFrame[AccNo:string, TranAmount:bigint]
> # Show sample records from the DataFrame
> showDF(acTransDF)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|SB10001| 1000|
|SB10002| 1200|
|SB10003| 8000|
|SB10004| 400|
|SB10005| 300|
|SB10006| 10000|
|SB10007| 500|
|SB10008| 56|
|SB10009| 30|
|SB10010| 7000|
|CR10001| 7000|
|SB10002| -10|
+-------+----------+
> # DataFrame containing good transaction records using API
> goodTransRecordsFromAPI <- filter(acTransDF, "AccNo like 'SB%' AND TranAmount > 0")
> # Show sample records from the DataFrame
> showDF(goodTransRecordsFromAPI)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|SB10001| 1000|
|SB10002| 1200|
|SB10003| 8000|
|SB10004| 400|
|SB10005| 300|
|SB10006| 10000|
|SB10007| 500|
|SB10008| 56|
|SB10009| 30|
|SB10010| 7000|
+-------+----------+
> # DataFrame containing high value transaction records using API
> highValueTransRecordsFromAPI = filter(goodTransRecordsFromAPI, "TranAmount > 1000")
> # Show sample records from the DataFrame
> showDF(highValueTransRecordsFromAPI)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|SB10002| 1200|
|SB10003| 8000|
|SB10006| 10000|
|SB10010| 7000|
+-------+----------+
> # DataFrame containing bad account records using API
> badAccountRecordsFromAPI <- filter(acTransDF, "AccNo NOT like 'SB%'")
> # Show sample records from the DataFrame
> showDF(badAccountRecordsFromAPI)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|CR10001| 7000|
+-------+----------+
> # DataFrame containing bad amount records using API
> badAmountRecordsFromAPI <- filter(acTransDF, "TranAmount < 0")
> # Show sample records from the DataFrame
> showDF(badAmountRecordsFromAPI)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|SB10002| -10|
+-------+----------+
> # Create a DataFrame by taking the union of two DataFrames
> badTransRecordsFromAPI <- union(badAccountRecordsFromAPI, badAmountRecordsFromAPI)
> # Show sample records from the DataFrame
> showDF(badTransRecordsFromAPI)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|CR10001| 7000|
|SB10002| -10|
+-------+----------+
> # DataFrame containing sum amount using API
> sumAmountFromAPI <- agg(goodTransRecordsFromAPI, sumAmount = sum(goodTransRecordsFromAPI$TranAmount))
> # Show sample records from the DataFrame
> showDF(sumAmountFromAPI)
+---------+
|sumAmount|
+---------+
| 28486|
+---------+
> # DataFrame containing maximum amount using API
> maxAmountFromAPI <- agg(goodTransRecordsFromAPI, maxAmount = max(goodTransRecordsFromAPI$TranAmount))
> # Show sample records from the DataFrame
> showDF(maxAmountFromAPI)
+---------+
|maxAmount|
+---------+
| 10000|
+---------+
> # DataFrame containing minimum amount using API
> minAmountFromAPI <- agg(goodTransRecordsFromAPI, minAmount = min(goodTransRecordsFromAPI$TranAmount))
> # Show sample records from the DataFrame
> showDF(minAmountFromAPI)
+---------+
|minAmount|
+---------+
| 30|
+---------+
> # DataFrame containing good account number records using API
> filteredTransRecordsFromAPI <- filter(goodTransRecordsFromAPI, "AccNo like 'SB%'")
> accNosFromAPI <- select(filteredTransRecordsFromAPI, "AccNo")
> distinctAccNoFromAPI <- distinct(accNosFromAPI)
> sortedAccNoFromAPI <- arrange(distinctAccNoFromAPI, "AccNo")
> # Show sample records from the DataFrame
> showDF(sortedAccNoFromAPI)
+-------+
| AccNo|
+-------+
|SB10001|
|SB10002|
|SB10003|
|SB10004|
|SB10005|
|SB10006|
|SB10007|
|SB10008|
|SB10009|
|SB10010|
+-------+
> # Persist the DataFrame into a Parquet file
> write.parquet(acTransDF, "r.trans.parquet")
> # Read the data from the Parquet file
> acTransDFFromFile <- read.parquet("r.trans.parquet")
> # Show sample records from the DataFrame
> showDF(acTransDFFromFile)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|SB10007| 500|
|SB10008| 56|
|SB10009| 30|
|SB10010| 7000|
|CR10001| 7000|
|SB10002| -10|
|SB10001| 1000|
|SB10002| 1200|
|SB10003| 8000|
|SB10004| 400|
|SB10005| 300|
|SB10006| 10000|
+-------+----------+
下面是从 DataFrame API 角度对前面脚本所做操作的总结:
-
本节使用的是包含上一节中使用的数据集的超集的 DataFrame。
-
接下来演示记录的过滤。这里,需要注意的最重要方面是过滤谓词必须与 SQL 语句中的谓词完全相同。过滤器不能链式使用。
-
接下来计算聚合方法。
-
本集合中的最后几个语句正在进行选择、过滤、选择不同的记录以及排序操作。
-
最后,事务记录以 Parquet 格式持久化,从 Parquet 存储中读取,并创建了一个 Spark DataFrame。关于持久化格式的更多细节已在上一章中介绍,概念保持不变。只是 DataFrame API 的语法有所不同。
-
在这个代码片段中,Parquet 格式数据存储在当前目录中,从该目录调用相应的 REPL。当它作为一个 Spark 程序运行时,目录再次将是 Spark 提交调用的当前目录。
最后几个语句是关于将 DataFrame 内容持久化到媒体中的。如果与上一章中基于 Scala 和 Python 的持久化机制进行比较,这里也是以类似的方式进行。
理解 Spark R 中的聚合
在 SQL 中,数据的聚合非常灵活。在 Spark SQL 中也是如此。在这里,Spark SQL 可以在分布式数据源上执行与在单台机器上的单个数据源上运行 SQL 语句相同的事情。在介绍基于 RDD 的编程的章节中,讨论了一个 MapReduce 用例来进行数据聚合,这里同样使用它来展示 Spark SQL 的聚合能力。在本节中,使用 SQL 查询方式以及 DataFrame API 方式来处理用例。
下面给出用于阐明 MapReduce 类型数据处理的使用案例:
-
零售银行交易记录包含以逗号分隔的账户号码和交易金额字符串
-
找到所有交易的账户级别摘要以获取账户余额
在 R 的 REPL 提示符下,尝试以下语句:
> # Read data from a JSON file to create DataFrame
> acTransDFForAgg <- read.json(paste(DATA_DIR, "TransList2.json", sep = ""))
> # Register temporary view definition in the DataFrame for SQL queries
> createOrReplaceTempView(acTransDFForAgg, "transnew")
> # Show sample records from the DataFrame
> showDF(acTransDFForAgg)
+-------+----------+
| AccNo|TranAmount|
+-------+----------+
|SB10001| 1000|
|SB10002| 1200|
|SB10001| 8000|
|SB10002| 400|
|SB10003| 300|
|SB10001| 10000|
|SB10004| 500|
|SB10005| 56|
|SB10003| 30|
|SB10002| 7000|
|SB10001| -100|
|SB10002| -10|
+-------+----------+
> # DataFrame containing account summary records using SQL
> acSummary <- sql("SELECT AccNo, sum(TranAmount) as TransTotal FROM transnew GROUP BY AccNo")
> # Show sample records from the DataFrame
> showDF(acSummary)
+-------+----------+
| AccNo|TransTotal|
+-------+----------+
|SB10001| 18900|
|SB10002| 8590|
|SB10003| 330|
|SB10004| 500|
|SB10005| 56|
+-------+----------+
> # DataFrame containing account summary records using API
> acSummaryFromAPI <- agg(groupBy(acTransDFForAgg, "AccNo"), TranAmount="sum")
> # Show sample records from the DataFrame
> showDF(acSummaryFromAPI)
+-------+---------------+
| AccNo|sum(TranAmount)|
+-------+---------------+
|SB10001| 18900|
|SB10002| 8590|
|SB10003| 330|
|SB10004| 500|
|SB10005| 56|
+-------+---------------+
在 R DataFrame API 中,与 Scala 或 Python 的对应版本相比,存在一些语法差异,主要是因为这是一个纯 API 编程模型。
理解 SparkR 中的多数据源连接
在上一章中,已经讨论了基于键的多个 DataFrame 的连接。在本节中,使用 Spark SQL 的 R API 实现了相同的用例。用于阐明使用键连接多个数据集的用例将在以下章节中给出。
第一个数据集包含一个零售银行主记录摘要,包括账户号码、名和姓。第二个数据集包含零售银行账户余额,包括账户号码和余额金额。这两个数据集的关键是账户号码。将这两个数据集连接起来,创建一个包含账户号码、名、姓和余额金额的单一数据集。从这份报告中,挑选出余额金额最高的前三个账户。
Spark DataFrame 是从持久化的 JSON 文件创建的。除了 JSON 文件,还可以是任何支持的数据文件。然后它们从磁盘读取以形成 DataFrame,并将它们连接在一起。
在 R 交互式命令行提示符下,尝试以下语句:
> # Read data from JSON file
> acMasterDF <- read.json(paste(DATA_DIR, "MasterList.json", sep = ""))
> # Show sample records from the DataFrame
> showDF(acMasterDF)
+-------+---------+--------+
| AccNo|FirstName|LastName|
+-------+---------+--------+
|SB10001| Roger| Federer|
|SB10002| Pete| Sampras|
|SB10003| Rafael| Nadal|
|SB10004| Boris| Becker|
|SB10005| Ivan| Lendl|
+-------+---------+--------+
> # Register temporary view definition in the DataFrame for SQL queries
> createOrReplaceTempView(acMasterDF, "master")
> acBalDF <- read.json(paste(DATA_DIR, "BalList.json", sep = ""))
> # Show sample records from the DataFrame
> showDF(acBalDF)
+-------+---------+
| AccNo|BalAmount|
+-------+---------+
|SB10001| 50000|
|SB10002| 12000|
|SB10003| 3000|
|SB10004| 8500|
|SB10005| 5000|
+-------+---------+
> # Register temporary view definition in the DataFrame for SQL queries
> createOrReplaceTempView(acBalDF, "balance")
> # DataFrame containing account detail records using SQL by joining multiple DataFrame contents
> acDetail <- sql("SELECT master.AccNo, FirstName, LastName, BalAmount FROM master, balance WHERE master.AccNo = balance.AccNo ORDER BY BalAmount DESC")
> # Show sample records from the DataFrame
> showDF(acDetail)
+-------+---------+--------+---------+
| AccNo|FirstName|LastName|BalAmount|
+-------+---------+--------+---------+
|SB10001| Roger| Federer| 50000|
|SB10002| Pete| Sampras| 12000|
|SB10004| Boris| Becker| 8500|
|SB10005| Ivan| Lendl| 5000|
|SB10003| Rafael| Nadal| 3000|
+-------+---------+--------+---------+
> # Persist data in the DataFrame into Parquet file
> write.parquet(acDetail, "r.acdetails.parquet")
> # Read data into a DataFrame by reading the contents from a Parquet file
> acDetailFromFile <- read.parquet("r.acdetails.parquet")
> # Show sample records from the DataFrame
> showDF(acDetailFromFile)
+-------+---------+--------+---------+
| AccNo|FirstName|LastName|BalAmount|
+-------+---------+--------+---------+
|SB10002| Pete| Sampras| 12000|
|SB10003| Rafael| Nadal| 3000|
|SB10005| Ivan| Lendl| 5000|
|SB10001| Roger| Federer| 50000|
|SB10004| Boris| Becker| 8500|
+-------+---------+--------+---------+
从相同的 R 交互式命令行会话继续,以下代码行通过 DataFrame API 得到相同的结果:
> # Change the column names
> acBalDFWithDiffColName <- selectExpr(acBalDF, "AccNo as AccNoBal", "BalAmount")
> # Show sample records from the DataFrame
> showDF(acBalDFWithDiffColName)
+--------+---------+
|AccNoBal|BalAmount|
+--------+---------+
| SB10001| 50000|
| SB10002| 12000|
| SB10003| 3000|
| SB10004| 8500|
| SB10005| 5000|
+--------+---------+
> # DataFrame containing account detail records using API by joining multiple DataFrame contents
> acDetailFromAPI <- join(acMasterDF, acBalDFWithDiffColName, acMasterDF$AccNo == acBalDFWithDiffColName$AccNoBal)
> # Show sample records from the DataFrame
> showDF(acDetailFromAPI)
+-------+---------+--------+--------+---------+
| AccNo|FirstName|LastName|AccNoBal|BalAmount|
+-------+---------+--------+--------+---------+
|SB10001| Roger| Federer| SB10001| 50000|
|SB10002| Pete| Sampras| SB10002| 12000|
|SB10003| Rafael| Nadal| SB10003| 3000|
|SB10004| Boris| Becker| SB10004| 8500|
|SB10005| Ivan| Lendl| SB10005| 5000|
+-------+---------+--------+--------+---------+
> # DataFrame containing account detail records using SQL by selecting specific fields
> acDetailFromAPIRequiredFields <- select(acDetailFromAPI, "AccNo", "FirstName", "LastName", "BalAmount")
> # Show sample records from the DataFrame
> showDF(acDetailFromAPIRequiredFields)
+-------+---------+--------+---------+
| AccNo|FirstName|LastName|BalAmount|
+-------+---------+--------+---------+
|SB10001| Roger| Federer| 50000|
|SB10002| Pete| Sampras| 12000|
|SB10003| Rafael| Nadal| 3000|
|SB10004| Boris| Becker| 8500|
|SB10005| Ivan| Lendl| 5000|
+-------+---------+--------+---------+
在代码的前一部分中选择的连接类型是内连接。而不是这样,可以使用任何其他类型的连接,无论是通过 SQL 查询方式还是通过 DataFrame API 方式。在使用 DataFrame API 进行连接之前有一个注意事项是,两个 Spark DataFrame 的列名必须不同,以避免在结果 Spark DataFrame 中产生歧义。在这个特定的用例中,可以看到 DataFrame API 处理起来有点困难,而 SQL 查询方式看起来非常直接。
在前面的章节中,已经介绍了 Spark SQL 的 R API。一般来说,如果可能的话,最好尽可能多地使用 SQL 查询方式编写代码。DataFrame API 正在变得更好,但它不如 Scala 或 Python 等其他语言灵活。
与本书中的其他章节不同,这是一个独立的章节,旨在向 R 程序员介绍 Spark。本章中讨论的所有用例都是在 Spark 的 R 交互式命令行中运行的。但在现实世界的应用中,这种方法并不理想。R 命令必须组织在脚本文件中,并提交到 Spark 集群以运行。最简单的方法是使用已经存在的$SPARK_HOME/bin/spark-submit <R 脚本文件路径>脚本,其中完全限定的 R 文件名是根据命令执行的当前目录给出的。
参考文献
更多信息请参阅:spark.apache.org/docs/latest/api/R/index.html
摘要
本章介绍了 R 语言的快速浏览,随后特别提到了需要区分理解 R DataFrame 与 Spark DataFrame 之间的差异。接着,使用与之前章节相同的用例,介绍了基于 R 的基本 Spark 编程。本章涵盖了 Spark 的 R API,并使用 SQL 查询方式和 DataFrame API 方式实现了用例。这有助于数据科学家理解 Spark 的强大功能,并使用 SparkR 包(Spark 附带)将其应用于 R 应用程序中。这开启了使用 Spark 与 R 处理结构化数据的大数据处理之门。
在各种语言中基于 Spark 的数据处理主题已经被讨论,现在是时候专注于一些带有图表和绘图的数据分析了。Python 附带了许多图表和绘图库,可以生成高质量的图片。下一章将讨论使用 Spark 处理的数据进行图表和绘图。
第五章:使用 Python 进行 Spark 数据分析
数据处理的最终目的是使用结果来回答业务问题。理解用于回答业务问题的数据非常重要。为了更好地理解数据,使用了各种表格方法、图表和图形技术。数据的可视化表示加强了底层数据的理解。正因为如此,数据可视化在数据分析中被广泛使用。
在各种出版物中,使用不同的术语来表示为回答业务问题而进行的数据分析。数据分析、数据分析和企业智能是一些普遍使用的术语。本章不会深入讨论这些术语的含义、相似性或差异。另一方面,重点是解决数据科学家或数据分析师通常进行的两个主要活动之间的差距。第一个是数据处理。第二个是使用处理后的数据,在图表和图形的帮助下进行分析。数据分析是数据分析师和数据科学家的强项。本章将专注于使用 Spark 和 Python 处理数据,并生成图表和图形。
在许多数据分析用例中,处理数据集的超集,并使用减少后的结果数据集进行数据分析。这在大数据分析的情况下特别适用,其中使用一小部分处理后的数据进行分析。根据用例,为了满足各种数据分析需求,作为先决条件进行适当的数据处理。本章将要涵盖的大多数用例都符合这种模式,其中第一步涉及必要的数据处理,第二步涉及数据分析所需的图表和图形绘制。
在典型的数据分析用例中,活动链涉及一个广泛的多阶段提取、转换和加载(ETL)管道,以数据分析和平台或应用程序结束。这一活动链的最终结果包括但不限于汇总数据的表格和各种以图表和图形形式表示的数据可视化。由于 Spark 可以非常有效地处理来自异构分布式数据源的数据,因此传统数据分析应用程序中存在的巨大 ETL 管道可以整合成自包含的应用程序,这些应用程序执行数据处理和数据分析。
本章将涵盖以下主题:
-
图表和图形库
-
设置数据集
-
捕获数据分析用例的高级细节
-
各种图表和图形
图表和图形库
Python 是一种目前被数据分析师和数据科学家广泛使用的编程语言。Python 中有许多科学和统计数据处理库,以及图表和绘图库,可以在 Python 程序中使用。Python 也被广泛用作在 Spark 中开发数据处理应用程序的编程语言。这为 Spark、Python 和 Python 库提供了一个统一的数据处理和分析框架,使我们能够进行科学和统计处理、图表和绘图。有大量的此类库与 Python 一起工作。在所有这些库中,这里使用NumPy和SciPy库来进行数值、统计和科学数据处理。这里使用matplotlib库来进行生成 2D 图像的图表和绘图。
小贴士
在尝试本章给出的代码示例之前,确保NumPy、SciPy和matplotlibPython 库与 Python 安装正常工作非常重要。这必须在将其用于 Spark 应用程序之前单独测试和验证。
如图 1所示的整体应用堆栈结构图:

图 1
设置数据集
有许多公共数据集可供公众消费,可用于教育、研究和开发目的。MovieLens 网站允许用户对电影进行评分并个性化电影推荐。GroupLens Research 发布了来自 MovieLens 的评分数据集。这些数据集可以从他们的网站grouplens.org/datasets/movielens/下载。在本章中,使用 MovieLens 100K 数据集来演示使用 Spark 结合 Python、NumPy、SciPy 和 matplotlib 进行分布式数据处理的使用方法。
小贴士
在数据集下载的 GroupLens Research 网站上,除了前面提到的数据集外,还有更多大量数据集可供下载,如 MovieLens 1M 数据集、MovieLens 10M 数据集、MovieLens 20M 数据集以及最新的 MovieLens 数据集。一旦读者对程序非常熟悉,并且达到了足够的舒适度来处理数据,这些额外的数据集就可以被读者用来进行自己的分析工作,以加强从本章获得的知识。
MovieLens 100K 数据集包含多个文件中的数据。以下是在本章数据分析用例中将要使用的一些文件:
-
u.user: 关于已评分电影的用户的统计数据信息。数据集的结构如下,直接从数据集附带的 README 文件中复制而来:-
用户 ID
-
年龄
-
性别
-
职业
-
邮编
-
-
u.item:关于用户评分的电影信息。数据集的结构如下,直接从数据集附带的 README 文件中复制,保持原样:-
电影 ID
-
电影标题
-
发布日期
-
视频发布日期
-
IMDb URL
-
未知
-
动作
-
冒险片
-
动画片
-
儿童片
-
喜剧片
-
犯罪片
-
纪录片
-
剧情
-
奇幻片
-
黑色电影
-
恐怖片
-
音乐片
-
悬疑片
-
爱情
-
科幻片
-
惊悚片
-
战争片
-
西部片
-
数据分析用例
以下列表捕获了数据分析用例的高级细节。大多数用例都是围绕创建各种图表和图形进行的:
-
使用直方图绘制评分用户的年龄分布。
-
使用与绘制直方图相同的数据,绘制用户的年龄概率密度图。
-
绘制年龄分布数据的摘要,以找到用户的年龄最小值、25%分位数、中位数、75%分位数和最大值。
-
在同一图上绘制多个图表或图形,以便进行数据并排比较。
-
创建一个柱状图,展示按电影评分人数排名前 10 的职业。
-
创建一个堆积柱状图,展示按职业划分的男性和女性用户对电影的评分数量。
-
创建一个饼图,展示按电影评分人数排名后 10 的职业。
-
创建一个饼图,展示按电影评分人数排名前 10 的邮编。
-
使用三个职业类别,创建箱线图,展示评分用户的汇总统计信息。所有三个箱线图必须绘制在单个图上,以便进行比较。
-
创建一个柱状图,展示按电影类型划分的电影数量。
-
创建一个散点图,展示按每年上映电影数量排名前 10 的年份。
-
创建一个散点图,展示按每年上映电影数量排名前 10 的年份。在这个图中,用与该年上映电影数量成比例的圆形代替图中的点。
-
创建一个折线图,包含两个数据集,其中一个数据集是过去 10 年上映的动作电影数量,另一个数据集是过去 10 年上映的剧情电影数量,以便进行比较。
小贴士
在所有前面的用例中,当涉及到实现时,使用 Spark 处理数据并准备所需的数据集。一旦所需的处理数据在 Spark DataFrame 中可用,就收集到驱动程序中。换句话说,数据从 Spark 的分布式集合转移到 Python 程序中的本地集合,作为元组,用于图表和绘图。对于图表和绘图,Python 需要本地数据。它不能直接使用 Spark DataFrame 进行图表和绘图。
图表和图形
本节将专注于创建各种图表和绘图,以直观地表示与上一节中描述的用例相关的 MovieLens 100K 数据集的各个方面。本章中描述的图表和绘图绘制过程遵循一个模式。以下是该活动模式中的重要步骤:
-
使用 Spark 从数据文件中读取数据。
-
将数据在 Spark DataFrame 中可用。
-
使用 DataFrame API 应用必要的数据处理。
-
处理主要是为了仅提供图表和绘图所需的最低限度和必要的数据。
-
将处理后的数据从 Spark DataFrame 传输到 Spark Driver 程序中的本地 Python 集合对象。
-
使用图表和绘图库,通过 Python 集合对象中的数据生成图形。
直方图
直方图通常用于显示给定数值数据集在连续的非重叠等大小区间上的分布情况。区间或区间大小基于数据集选择。区间或区间代表数据的范围。在本用例中,数据集由用户的年龄组成。在这种情况下,区间大小为 100 没有意义,因为只有一个区间,整个数据集都将落入其中。表示区间的柱状图的高度表示该区间或区间中数据项的频率。
以下命令集用于启动 Spark 的 Python REPL,随后是进行数据处理、图表和绘图的程序:
$ cd $SPARK_HOME
$ ./bin/pyspark
>>> # Import all the required libraries
>>> from pyspark.sql import Row
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> import pylab as P
>>> plt.rcdefaults()
>>> # TODO - The following location has to be changed to the appropriate data file location
>>> dataDir = "/Users/RajT/Documents/Writing/SparkForBeginners/SparkDataAnalysisWithPython/Data/ml-100k/">>> # Create the DataFrame of the user dataset
>>> lines = sc.textFile(dataDir + "u.user")
>>> splitLines = lines.map(lambda l: l.split("|"))
>>> usersRDD = splitLines.map(lambda p: Row(id=p[0], age=int(p[1]), gender=p[2], occupation=p[3], zipcode=p[4]))
>>> usersDF = spark.createDataFrame(usersRDD)
>>> usersDF.createOrReplaceTempView("users")
>>> usersDF.show()
+---+------+---+-------------+-------+
|age|gender| id| occupation|zipcode|
+---+------+---+-------------+-------+
| 24| M| 1| technician| 85711|
| 53| F| 2| other| 94043|
| 23| M| 3| writer| 32067|
| 24| M| 4| technician| 43537|
| 33| F| 5| other| 15213|
| 42| M| 6| executive| 98101|
| 57| M| 7|administrator| 91344|
| 36| M| 8|administrator| 05201|
| 29| M| 9| student| 01002|
| 53| M| 10| lawyer| 90703|
| 39| F| 11| other| 30329|
| 28| F| 12| other| 06405|
| 47| M| 13| educator| 29206|
| 45| M| 14| scientist| 55106|
| 49| F| 15| educator| 97301|
| 21| M| 16|entertainment| 10309|
| 30| M| 17| programmer| 06355|
| 35| F| 18| other| 37212|
| 40| M| 19| librarian| 02138|
| 42| F| 20| homemaker| 95660|
+---+------+---+-------------+-------+
only showing top 20 rows
>>> # Create the DataFrame of the user dataset with only one column age
>>> ageDF = spark.sql("SELECT age FROM users")
>>> ageList = ageDF.rdd.map(lambda p: p.age).collect()
>>> ageDF.describe().show()
+-------+------------------+
|summary| age|
+-------+------------------+
| count| 943|
| mean| 34.05196182396607|
| stddev|12.186273150937206|
| min| 7|
| max| 73|
+-------+------------------+
>>> # Age distribution of the users
>>> plt.hist(ageList)
>>> plt.title("Age distribution of the users\n")
>>> plt.xlabel("Age")
>>> plt.ylabel("Number of users")
>>> plt.show(block=False)
在上一节中,用户数据集被逐行读取以形成 RDD。从 RDD 中创建了一个 Spark DataFrame。使用 Spark SQL,创建了一个只包含年龄列的另一个 Spark DataFrame。该 Spark DataFrame 的摘要被显示出来,以展示内容的摘要统计;内容被收集到一个本地的 Python 集合对象中。使用收集到的数据,绘制了年龄列的直方图,如图图 2所示:

图 2
密度图
另有一个与直方图非常相似的图表。它是密度图。当存在有限的数据样本且需要估计随机变量的概率密度函数时,密度图被大量使用。直方图无法显示数据平滑或数据点连续的情况。为此目的,使用密度图。
注意
由于直方图和密度图用于类似的目的,但对于相同的数据显示不同的行为,通常在许多应用中直方图和密度图是并排使用的。
图 3是为与绘制直方图相同的同一数据集绘制的密度图。
作为同一 Python REPL 的 Spark 的延续,运行以下命令:
>>> # Draw a density plot
>>> from scipy.stats import gaussian_kde
>>> density = gaussian_kde(ageList)
>>> xAxisValues = np.linspace(0,100,1000)
>>> density.covariance_factor = lambda : .5
>>> density._compute_covariance()
>>> plt.title("Age density plot of the users\n")
>>> plt.xlabel("Age")
>>> plt.ylabel("Density")
>>> plt.plot(xAxisValues, density(xAxisValues))
>>> plt.show(block=False)

图 3
在前面的章节中,使用了只包含年龄列的相同 Spark DataFrame,并将内容收集到本地的 Python 集合对象中。使用收集到的数据,绘制了年龄列的密度图,如图 3 所示,线空间从 0 到 100 代表年龄。
如果需要并排查看多个图表或图形,matplotlib 库提供了实现这一功能的方法。图 4 展示了并排的直方图和箱线图。
作为与 Spark 相同的 Python REPL 的延续,运行以下命令:
>>> # The following example demonstrates the creation of multiple diagrams
in one figure
>>> # There are two plots on one row
>>> # The first one is the histogram of the distribution
>>> # The second one is the boxplot containing the summary of the
distribution
>>> plt.subplot(121)
>>> plt.hist(ageList)
>>> plt.title("Age distribution of the users\n")
>>> plt.xlabel("Age")
>>> plt.ylabel("Number of users")
>>> plt.subplot(122)
>>> plt.title("Summary of distribution\n")
>>> plt.xlabel("Age")
>>> plt.boxplot(ageList, vert=False)
>>> plt.show(block=False)

图 4
在前面的章节中,使用了只包含年龄列的相同 Spark DataFrame,并将内容收集到本地的 Python 集合对象中。使用收集到的数据,绘制了年龄列的直方图,以及包含最小值、25^(th) 分位数、中位数、75^(th) 分位数和最大值的箱线图,如图 4 所示。当在一个图中绘制多个图表或图形时,为了控制布局,可以查看方法调用 plt.subplot(121)。这指的是在一行两列布局中选择的图形,并选择了第一个。同样,plt.subplot(122) 指的是在一行两列布局中选择的图形,并选择了第二个。
条形图
条形图可以以不同的方式绘制。最常见的一种是条形垂直于 X 轴站立。另一种变化是条形绘制在 Y 轴上,条形水平排列。图 5 展示了一个水平条形图。
注意
很容易将直方图和条形图混淆。重要的区别在于,直方图用于绘制连续但有限的数值,而条形图用于表示分类数据。
作为与 Spark 相同的 Python REPL 的延续,运行以下命令:
>>> occupationsTop10 = spark.sql("SELECT occupation, count(occupation) as usercount FROM users GROUP BY occupation ORDER BY usercount DESC LIMIT 10")
>>> occupationsTop10.show()
+-------------+---------+
| occupation|usercount|
+-------------+---------+
| student| 196|
| other| 105|
| educator| 95|
|administrator| 79|
| engineer| 67|
| programmer| 66|
| librarian| 51|
| writer| 45|
| executive| 32|
| scientist| 31|
+-------------+---------+
>>> occupationsTop10Tuple = occupationsTop10.rdd.map(lambda p:
(p.occupation,p.usercount)).collect()
>>> occupationsTop10List, countTop10List = zip(*occupationsTop10Tuple)
>>> occupationsTop10Tuple
>>> # Top 10 occupations in terms of the number of users having that
occupation who have rated movies
>>> y_pos = np.arange(len(occupationsTop10List))
>>> plt.barh(y_pos, countTop10List, align='center', alpha=0.4)
>>> plt.yticks(y_pos, occupationsTop10List)
>>> plt.xlabel('Number of users')
>>> plt.title('Top 10 user types\n')
>>> plt.gcf().subplots_adjust(left=0.15)
>>> plt.show(block=False)

图 5
在前面的章节中,创建了一个包含用户按评价电影数量排名前 10 的职业的 Spark DataFrame。数据被收集到 Python 集合对象中,用于绘制条形图。
堆叠条形图
前面章节中绘制的条形图显示了按用户数量排名的前 10 位用户职业。但这并没有给出关于用户性别构成的具体细节。在这种情况下,使用堆叠条形图是一个好主意,其中每个条形都显示了按性别划分的计数。图 6 展示了一个堆叠条形图。
作为与 Spark 相同的 Python REPL 的延续,运行以下命令:
>>> occupationsGender = spark.sql("SELECT occupation, gender FROM users")>>> occupationsGender.show()
+-------------+------+
| occupation|gender|
+-------------+------+
| technician| M|
| other| F|
| writer| M|
| technician| M|
| other| F|
| executive| M|
|administrator| M|
|administrator| M|
| student| M|
| lawyer| M|
| other| F|
| other| F|
| educator| M|
| scientist| M|
| educator| F|
|entertainment| M|
| programmer| M|
| other| F|
| librarian| M|
| homemaker| F|
+-------------+------+
only showing top 20 rows
>>> occCrossTab = occupationsGender.stat.crosstab("occupation", "gender")>>> occCrossTab.show()
+-----------------+---+---+
|occupation_gender| M| F|
+-----------------+---+---+
| scientist| 28| 3|
| student|136| 60|
| writer| 26| 19|
| salesman| 9| 3|
| retired| 13| 1|
| administrator| 43| 36|
| programmer| 60| 6|
| doctor| 7| 0|
| homemaker| 1| 6|
| executive| 29| 3|
| engineer| 65| 2|
| entertainment| 16| 2|
| marketing| 16| 10|
| technician| 26| 1|
| artist| 15| 13|
| librarian| 22| 29|
| lawyer| 10| 2|
| educator| 69| 26|
| healthcare| 5| 11|
| none| 5| 4|
+-----------------+---+---+
only showing top 20 rows
>>> occupationsCrossTuple = occCrossTab.rdd.map(lambda p:
(p.occupation_gender,p.M, p.F)).collect()
>>> occList, mList, fList = zip(*occupationsCrossTuple)
>>> N = len(occList)
>>> ind = np.arange(N) # the x locations for the groups
>>> width = 0.75 # the width of the bars
>>> p1 = plt.bar(ind, mList, width, color='r')
>>> p2 = plt.bar(ind, fList, width, color='y', bottom=mList)
>>> plt.ylabel('Count')
>>> plt.title('Gender distribution by occupation\n')
>>> plt.xticks(ind + width/2., occList, rotation=90)
>>> plt.legend((p1[0], p2[0]), ('Male', 'Female'))
>>> plt.gcf().subplots_adjust(bottom=0.25)
>>> plt.show(block=False)

图 6
在上一节中,创建了一个只包含职业和性别列的 Spark DataFrame。对这个 DataFrame 进行了交叉表操作,生成了另一个 Spark DataFrame,其中包含了职业、男性用户数量和女性用户数量列。在第一个包含职业和性别列的 Spark DataFrame 中,这两个都是非数值列,因此基于这些数据绘制图表或图形没有意义。但是,如果对这两个列值进行交叉表操作,对于每个不同的职业字段,性别列的值计数将可用。这样,职业字段就变成了一个分类变量,使用数据绘制条形图是有意义的。由于数据中只有两个性别值,因此使用堆叠条形图来查看每个职业类别中男性和女性用户数量的总数和比例是有意义的。
在 Spark 的 DataFrame 中有很多统计和数学函数可用。在这种情况下,对 Spark DataFrame 进行交叉表操作非常有用。对于大型数据集,交叉表操作可能会非常消耗处理器资源且耗时,但 Spark 的分布式处理能力在这种情况下非常有帮助。
Spark SQL 自带了许多数学和统计数据处理能力。在上一节中,使用了describe().show()方法对SparkDataFrame对象进行了操作。在这些 Spark DataFrame 中,上述方法作用于可用的数值列。在存在多个数值列的情况下,上述方法具有选择所需列以获取汇总统计信息的能力。同样,还有方法可以在 Spark DataFrame 的数据上找到协方差、相关性等。以下代码片段演示了这些方法:
>>> occCrossTab.describe('M', 'F').show()
+-------+------------------+------------------+
|summary| M| F|
+-------+------------------+------------------+
| count| 21| 21|
| mean|31.904761904761905| 13.0|
| stddev|31.595516200735347|15.491933384829668|
| min| 1| 0|
| max| 136| 60|
+-------+------------------+------------------+
>>> occCrossTab.stat.cov('M', 'F')
381.15
>>> occCrossTab.stat.corr('M', 'F')
0.7416099517313641
饼图
如果需要以视觉方式表示数据集来解释整体与部分的关系,饼图是非常常用的。图 7展示了饼图。
作为与 Spark 相同的 Python REPL 的延续,运行以下命令:
>>> occupationsBottom10 = spark.sql("SELECT occupation, count(occupation) as usercount FROM users GROUP BY occupation ORDER BY usercount LIMIT 10")
>>> occupationsBottom10.show()
+-------------+---------+
| occupation|usercount|
+-------------+---------+
| homemaker| 7|
| doctor| 7|
| none| 9|
| salesman| 12|
| lawyer| 12|
| retired| 14|
| healthcare| 16|
|entertainment| 18|
| marketing| 26|
| technician| 27|
+-------------+---------+
>>> occupationsBottom10Tuple = occupationsBottom10.rdd.map(lambda p: (p.occupation,p.usercount)).collect()
>>> occupationsBottom10List, countBottom10List = zip(*occupationsBottom10Tuple)
>>> # Bottom 10 occupations in terms of the number of users having that occupation who have rated movies
>>> explode = (0, 0, 0, 0,0.1,0,0,0,0,0.1)
>>> plt.pie(countBottom10List, explode=explode, labels=occupationsBottom10List, autopct='%1.1f%%', shadow=True, startangle=90)
>>> plt.title('Bottom 10 user types\n')
>>> plt.show(block=False)

图 7
在上一节中,创建了一个包含用户按评价电影数量排名前 10 的职业的 Spark DataFrame。数据被收集到一个 Python 集合对象中,以绘制饼图。
环形图
饼图可以以不同的形式绘制。其中一种形式,即环形图,现在经常被使用。图 8 展示了这种饼图的环形图变体。
作为与 Spark 相同的 Python REPL 的延续,运行以下命令:
>>> zipTop10 = spark.sql("SELECT zipcode, count(zipcode) as usercount FROM users GROUP BY zipcode ORDER BY usercount DESC LIMIT 10")
>>> zipTop10.show()
+-------+---------+
|zipcode|usercount|
+-------+---------+
| 55414| 9|
| 55105| 6|
| 20009| 5|
| 55337| 5|
| 10003| 5|
| 55454| 4|
| 55408| 4|
| 27514| 4|
| 11217| 3|
| 14216| 3|
+-------+---------+
>>> zipTop10Tuple = zipTop10.rdd.map(lambda p: (p.zipcode,p.usercount)).collect()
>>> zipTop10List, countTop10List = zip(*zipTop10Tuple)
>>> # Top 10 zipcodes in terms of the number of users living in that zipcode who have rated movies>>> explode = (0.1, 0, 0, 0,0,0,0,0,0,0) # explode a slice if required
>>> plt.pie(countTop10List, explode=explode, labels=zipTop10List, autopct='%1.1f%%', shadow=True)
>>> #Draw a circle at the center of pie to make it look like a donut
>>> centre_circle = plt.Circle((0,0),0.75,color='black', fc='white',linewidth=1.25)
>>> fig = plt.gcf()
>>> fig.gca().add_artist(centre_circle)
>>> # The aspect ratio is to be made equal. This is to make sure that pie chart is coming perfectly as a circle.
>>> plt.axis('equal')
>>> plt.text(- 0.25,0,'Top 10 zip codes')
>>> plt.show(block=False)

图 8
在前面的章节中,创建了一个包含用户按居住在该地区并评分的电影数量排名前 10 的邮政编码的 Spark DataFrame。数据被收集到一个 Python 集合对象中,以绘制饼图。
小贴士
与本书中的其他图表相比,图 8的标题位于中间。这是使用text()方法而不是使用title()方法完成的。此方法可用于在图表和绘图上打印水印文本。
箱线图
经常需要在一个图表中比较不同数据集的摘要统计信息。箱线图是一种非常常见的图表,用于以直观的方式捕捉数据集的摘要统计信息。接下来的部分正是如此,为了做到这一点,图 9在一个图表上显示了多个箱线图。
作为与 Spark 相同 Python REPL 的延续,运行以下命令:
>>> ages = spark.sql("SELECT occupation, age FROM users WHERE occupation ='administrator' ORDER BY age")
>>> adminAges = ages.rdd.map(lambda p: p.age).collect()
>>> ages.describe().show()
+-------+------------------+
|summary| age|
+-------+------------------+
| count| 79|
| mean| 38.74683544303797|
| stddev|11.052771408491363|
| min| 21|
| max| 70|
+-------+------------------+
>>> ages = spark.sql("SELECT occupation, age FROM users WHERE occupation ='engineer' ORDER BY age")>>> engAges = ages.rdd.map(lambda p: p.age).collect()
>>> ages.describe().show()
+-------+------------------+
|summary| age|
+-------+------------------+
| count| 67|
| mean| 36.38805970149254|
| stddev|11.115345348003853|
| min| 22|
| max| 70|
+-------+------------------+
>>> ages = spark.sql("SELECT occupation, age FROM users WHERE occupation ='programmer' ORDER BY age")>>> progAges = ages.rdd.map(lambda p: p.age).collect()
>>> ages.describe().show()
+-------+------------------+
|summary| age|
+-------+------------------+
| count| 66|
| mean|33.121212121212125|
| stddev| 9.551320948648684|
| min| 20|
| max| 63|
+-------+------------------+
>>> # Box plots of the ages by profession
>>> boxPlotAges = [adminAges, engAges, progAges]
>>> boxPlotLabels = ['administrator','engineer', 'programmer' ]
>>> x = np.arange(len(boxPlotLabels))
>>> plt.figure()
>>> plt.boxplot(boxPlotAges)
>>> plt.title('Age summary statistics\n')
>>> plt.ylabel("Age")
>>> plt.xticks(x + 1, boxPlotLabels, rotation=0)
>>> plt.show(block=False)

图 9
在前面的章节中,创建了一个包含三个职业(管理员、工程师和程序员)的职业和年龄列的 Spark DataFrame。在一个图表上为这些数据集创建了箱线图,其中包含每个数据集的最小值、25%分位数、中位数、75%分位数、最大值和异常值指标,以方便比较。程序员职业的箱线图显示了两个由+符号表示的值点。它们是异常值。
竖直条形图
在前面的章节中,用于引发各种图表和绘图用例的主要数据集是用户数据。接下来要使用的数据集是电影数据集。在许多数据集中,为了生成各种图表和绘图,需要使数据适合适当的图形。Spark 拥有丰富的数据处理功能。
以下用例演示了通过应用一些聚合和使用 Spark SQL 来准备数据;为包含按类型计数的电影数量的经典条形图准备所需的数据集。图 10显示了在电影数据上应用聚合操作后的条形图。
作为与 Spark 相同 Python REPL 的延续,运行以下命令:
>>> movieLines = sc.textFile(dataDir + "u.item")
>>> splitMovieLines = movieLines.map(lambda l: l.split("|"))
>>> moviesRDD = splitMovieLines.map(lambda p: Row(id=p[0], title=p[1], releaseDate=p[2], videoReleaseDate=p[3], url=p[4], unknown=int(p[5]),action=int(p[6]),adventure=int(p[7]),animation=int(p[8]),childrens=int(p[9]),comedy=int(p[10]),crime=int(p[11]),documentary=int(p[12]),drama=int(p[13]),fantasy=int(p[14]),filmNoir=int(p[15]),horror=int(p[16]),musical=int(p[17]),mystery=int(p[18]),romance=int(p[19]),sciFi=int(p[20]),thriller=int(p[21]),war=int(p[22]),western=int(p[23])))
>>> moviesDF = spark.createDataFrame(moviesRDD)
>>> moviesDF.createOrReplaceTempView("movies")
>>> genreDF = spark.sql("SELECT sum(unknown) as unknown, sum(action) as action,sum(adventure) as adventure,sum(animation) as animation, sum(childrens) as childrens,sum(comedy) as comedy,sum(crime) as crime,sum(documentary) as documentary,sum(drama) as drama,sum(fantasy) as fantasy,sum(filmNoir) as filmNoir,sum(horror) as horror,sum(musical) as musical,sum(mystery) as mystery,sum(romance) as romance,sum(sciFi) as sciFi,sum(thriller) as thriller,sum(war) as war,sum(western) as western FROM movies")
>>> genreList = genreDF.collect()
>>> genreDict = genreList[0].asDict()
>>> labelValues = list(genreDict.keys())
>>> countList = list(genreDict.values())
>>> genreDict
{'animation': 42, 'adventure': 135, 'romance': 247, 'unknown': 2, 'musical': 56, 'western': 27, 'comedy': 505, 'drama': 725, 'war': 71, 'horror': 92, 'mystery': 61, 'fantasy': 22, 'childrens': 122, 'sciFi': 101, 'filmNoir': 24, 'action': 251, 'documentary': 50, 'crime': 109, 'thriller': 251}
>>> # Movie types and the counts
>>> x = np.arange(len(labelValues))
>>> plt.title('Movie types\n')
>>> plt.ylabel("Count")
>>> plt.bar(x, countList)
>>> plt.xticks(x + 0.5, labelValues, rotation=90)
>>> plt.gcf().subplots_adjust(bottom=0.20)
>>> plt.show(block=False)

图 10
在前面的章节中,使用电影数据集创建了一个SparkDataFrame。电影的类型被捕获在单独的列中。在整个数据集上使用 Spark SQL 进行了聚合,创建了一个新的SparkDataFrame摘要,并将数据值收集到一个 Python 集合对象中。由于数据集中列太多,使用 Python 函数将这种数据结构转换为包含列名作为键,所选单行值作为键的值的字典对象。从这个字典中创建了两个数据集,并绘制了一个条形图。
小贴士
当使用 Spark 时,Python 用于开发数据分析应用程序,几乎可以肯定会有很多图表和图形。与其在本章中尝试所有给出的代码示例在 Spark 的 Python REPL 上,不如使用 IPython 笔记本作为 IDE,这样代码和结果就可以一起查看。本书的下载部分包含了包含所有这些代码和结果的 IPython 笔记本。读者可以直接开始使用。
散点图
散点图非常常用,用于绘制具有两个变量的值,例如在笛卡尔空间中具有X值和Y值的点。在这个电影数据集中,给定年份上映的电影数量显示了这种行为。在散点图中,通常,在X坐标和Y坐标的交点处表示的值是点。由于最近的技术发展和复杂图形包的可用性,许多人使用不同的形状和颜色来表示点。在下面的散点图中,如图图 11所示,使用了具有均匀面积和随机颜色的细小圆圈来表示值。当在散点图中使用这种直观且巧妙的技术来表示点时,必须注意确保它不会破坏目的,并失去散点图提供的简单性,以传达数据的这种行为。简单且优雅的形状,不会使笛卡尔空间杂乱无章,是这种非点值表示的理想选择。
作为 Spark 相同 Python REPL 的延续,运行以下命令:
>>> yearDF = spark.sql("SELECT substring(releaseDate,8,4) as releaseYear, count(*) as movieCount FROM movies GROUP BY substring(releaseDate,8,4) ORDER BY movieCount DESC LIMIT 10")
>>> yearDF.show()
+-----------+----------+
|releaseYear|movieCount|
+-----------+----------+
| 1996| 355|
| 1997| 286|
| 1995| 219|
| 1994| 214|
| 1993| 126|
| 1998| 65|
| 1992| 37|
| 1990| 24|
| 1991| 22|
| 1986| 15|
+-----------+----------+
>>> yearMovieCountTuple = yearDF.rdd.map(lambda p: (int(p.releaseYear),p.movieCount)).collect()
>>> yearList,movieCountList = zip(*yearMovieCountTuple)
>>> countArea = yearDF.rdd.map(lambda p: np.pi * (p.movieCount/15)**2).collect()
>>> plt.title('Top 10 movie release by year\n')
>>> plt.xlabel("Year")
>>> plt.ylabel("Number of movies released")
>>> plt.ylim([0,max(movieCountList) + 20])
>>> colors = np.random.rand(10)
>>> plt.scatter(yearList, movieCountList,c=colors)
>>> plt.show(block=False)

图 11
在前面的章节中,使用SparkDataFrame收集了按当年上映电影数量排名前十的年份,并将值收集到 Python 集合对象中,并绘制了散点图。
增强散点图
图 11是一个非常简单且优雅的散点图,但它并没有真正传达给定绘图值与其他相同空间内值的比较行为。为了做到这一点,如果将点绘制为面积与值成比例的圆圈,那么这将提供不同的视角。图 12 将展示具有相同数据的散点图,但圆圈具有成比例的面积来表示点。
作为 Spark 相同 Python REPL 的延续,运行以下命令:
>>> # Top 10 years where the most number of movies have been released
>>> plt.title('Top 10 movie release by year\n')
>>> plt.xlabel("Year")
>>> plt.ylabel("Number of movies released")
>>> plt.ylim([0,max(movieCountList) + 100])
>>> colors = np.random.rand(10)
>>> plt.scatter(yearList, movieCountList,c=colors, s=countArea)
>>> plt.show(block=False)

图 12
在前面的章节中,使用相同的数据集为图 11绘制了相同的散点图。而不是用均匀面积的圆圈绘制点,而是用成比例面积的圆圈绘制点。
小贴士
在所有这些代码示例中,图表和图形都是通过 show 方法显示的。matplotlib 中有方法可以将生成的图表和图形保存到磁盘上,可用于电子邮件、发布到仪表板等。
折线图
散点图和折线图之间存在相似之处。散点图非常适合表示单个数据点,但将所有点综合起来则可以显示出趋势。折线图也代表单个数据点,但点之间是相连的。这对于观察从一个点到另一个点的过渡非常理想。在一个图中可以绘制多个折线图,从而实现两个数据集的比较。前面的用例使用散点图来表示过去几年内上映的电影数量。这些数字只是在一个图中绘制的离散数据点。如果需要看到电影发行随年份变化的趋势,折线图是理想的。同样,如果需要比较不同类型电影随年份的发行情况,则可以使用一条线表示每个类型,并将它们绘制在同一个折线图上。图 13 是一个包含多个数据集的折线图。
作为与 Spark 相同的 Python REPL 的延续,运行以下命令:
>>> yearActionDF = spark.sql("SELECT substring(releaseDate,8,4) as actionReleaseYear, count(*) as actionMovieCount FROM movies WHERE action = 1 GROUP BY substring(releaseDate,8,4) ORDER BY actionReleaseYear DESC LIMIT 10")
>>> yearActionDF.show()
+-----------------+----------------+
|actionReleaseYear|actionMovieCount|
+-----------------+----------------+
| 1998| 12|
| 1997| 46|
| 1996| 44|
| 1995| 40|
| 1994| 30|
| 1993| 20|
| 1992| 8|
| 1991| 2|
| 1990| 7|
| 1989| 6|
+-----------------+----------------+
>>> yearActionDF.createOrReplaceTempView("action")
>>> yearDramaDF = spark.sql("SELECT substring(releaseDate,8,4) as dramaReleaseYear, count(*) as dramaMovieCount FROM movies WHERE drama = 1 GROUP BY substring(releaseDate,8,4) ORDER BY dramaReleaseYear DESC LIMIT 10")
>>> yearDramaDF.show()
+----------------+---------------+
|dramaReleaseYear|dramaMovieCount|
+----------------+---------------+
| 1998| 33|
| 1997| 113|
| 1996| 170|
| 1995| 89|
| 1994| 97|
| 1993| 64|
| 1992| 14|
| 1991| 11|
| 1990| 12|
| 1989| 8|
+----------------+---------------+
>>> yearDramaDF.createOrReplaceTempView("drama")
>>> yearCombinedDF = spark.sql("SELECT a.actionReleaseYear as releaseYear, a.actionMovieCount, d.dramaMovieCount FROM action a, drama d WHERE a.actionReleaseYear = d.dramaReleaseYear ORDER BY a.actionReleaseYear DESC LIMIT 10")
>>> yearCombinedDF.show()
+-----------+----------------+---------------+
|releaseYear|actionMovieCount|dramaMovieCount|
+-----------+----------------+---------------+
| 1998| 12| 33|
| 1997| 46| 113|
| 1996| 44| 170|
| 1995| 40| 89|
| 1994| 30| 97|
| 1993| 20| 64|
| 1992| 8| 14|
| 1991| 2| 11|
| 1990| 7| 12|
| 1989| 6| 8|
+-----------+----------------+---------------+
>>> yearMovieCountTuple = yearCombinedDF.rdd.map(lambda p: (p.releaseYear,p.actionMovieCount, p.dramaMovieCount)).collect()
>>> yearList,actionMovieCountList,dramaMovieCountList = zip(*yearMovieCountTuple)
>>> plt.title("Movie release by year\n")
>>> plt.xlabel("Year")
>>> plt.ylabel("Movie count")
>>> line_action, = plt.plot(yearList, actionMovieCountList)
>>> line_drama, = plt.plot(yearList, dramaMovieCountList)
>>> plt.legend([line_action, line_drama], ['Action Movies', 'Drama Movies'],loc='upper left')
>>> plt.gca().get_xaxis().get_major_formatter().set_useOffset(False)
>>> plt.show(block=False)

图 13
在前面的章节中,创建了 Spark DataFrames 来获取过去 10 年内动作电影和剧情电影发行的数据集。数据被收集到 Python 集合对象中,并在同一图中绘制了折线图。
Python 与 matplotlib 库结合使用,在生成出版物质量图表和图形的方法上非常丰富。Spark 可以用作处理来自异构数据源数据的动力源,并且结果也可以保存到多种数据格式中。
对于接触过 Python 数据分析库 pandas 的人来说,会发现理解本章涵盖的材料很容易,因为 Spark DataFrames 是从底层设计的,灵感来源于 R DataFrame 以及 pandas。
本章仅介绍了使用 matplotlib 库可以创建的一些示例图表和图形。本章的主要思想是帮助读者理解结合 Spark 使用此库的能力,其中 Spark 负责数据处理,而 matplotlib 负责图表和图形的绘制。
本章使用的数据文件是从本地文件系统读取的。相反,它也可以从 HDFS 或任何其他 Spark 支持的数据源读取。
当使用 Spark 作为数据处理的主体框架时,需要记住的最重要的一点是,任何可能的数据处理都应该由 Spark 完成,主要是因为 Spark 可以以最佳方式处理数据。只有处理过的数据需要返回给 Spark 驱动程序进行图表和图形的绘制。
参考文献
如需更多信息,请参阅以下链接:
摘要
处理后的数据用于数据分析。数据分析需要深入理解处理后的数据。图表和图形增强了理解底层数据特性的能力。本质上,对于数据分析应用来说,数据处理、图表制作和图形绘制是必不可少的。本章已涵盖使用 Python 与 Spark 结合,以及与 Python 图表和图形库结合,开发数据分析应用的使用方法。
在大多数组织中,业务需求推动着构建涉及实时数据摄入的数据处理应用,这些数据以各种形状和形式出现,速度极快。这要求处理流向组织数据汇聚点的数据流。下一章将讨论 Spark Streaming,这是一个在 Spark 之上工作的库,它能够处理各种类型的数据流。
第六章 Spark 流处理
数据处理用例可以主要分为两种类型。第一种类型是数据静态,处理作为一个工作单元整体完成,或者将其分成更小的批次。在数据处理过程中,基础数据集不会改变,也不会有新的数据集添加到处理单元中。这是批处理。
第二种类型是数据像流一样生成,并且数据处理是在数据生成时进行的。这是流处理。在这本书的前几章中,所有的数据处理用例都属于前一种类型。本章将重点关注后一种。
本章将涵盖以下主题:
-
数据流处理
-
微批数据处理
-
日志事件处理器
-
窗口数据处理和其他选项
-
Kafka 流处理
-
使用 Spark 的流式作业
数据流处理
数据源生成数据就像流一样,许多现实世界的用例需要它们实时处理。实时的含义可能因用例而异。定义特定用例中实时含义的主要参数是,摄入的数据或自上次间隔以来所有摄入数据的频繁间隔需要多快被处理。例如,当重大体育赛事正在进行时,消耗比分事件并将它们发送给订阅用户的程序应该尽可能快地处理数据。发送得越快,越好。
但这里的快是什么意思?在比分事件发生后,比如说一个小时之内处理比分数据可以吗?可能不行。在比分事件发生后一分钟内处理数据可以吗?这肯定比一个小时后处理要好。在比分事件发生后一秒内处理数据可以吗?可能可以,并且比之前的数据处理时间间隔要好得多。
在任何数据流处理用例中,这个时间间隔非常重要。数据处理框架应该具备在选择的适当时间间隔内处理数据流的能力,以提供良好的商业价值。
当在选择的常规时间间隔内处理流数据时,数据是从时间间隔的开始收集到结束,分组在一个微批中,然后对这个数据批次进行处理。在较长一段时间内,数据处理应用程序会处理许多这样的微批数据。在这种处理类型中,数据处理应用程序只能看到在特定时间点正在处理的具体微批数据。换句话说,应用程序将没有任何可见性或访问权来查看已经处理过的微批数据。
现在,这种处理类型又增加了一个维度。假设一个特定的用例要求每分钟处理数据,但同时,在处理给定微批量的数据时,还需要查看在最后 15 分钟内已经处理过的数据。零售银行交易处理应用程序的欺诈检测模块是满足这种特定业务需求的良好例子。毫无疑问,零售银行交易应在发生后的毫秒内进行处理。在处理 ATM 现金取款交易时,查看是否有人试图连续取款是一个好主意,如果发现这种情况,应发送适当的警报。为此,在处理特定的现金取款交易时,应用程序会检查在最后 15 分钟内是否还有使用相同卡片从同一 ATM 取款的现金取款。业务规则是在最后 15 分钟内有超过两次此类交易时发送警报。在这种情况下,欺诈检测应用程序应该能够看到在 15 分钟窗口内发生的所有交易。
一个好的流数据处理框架应该能够处理任何给定时间间隔内的数据,以及能够查看在滑动时间窗口内摄取的数据。在 Spark 上工作的 Spark Streaming 库是具有这两种能力的最佳数据流处理框架之一。
再次查看 图 1 中给出的 Spark 库堆栈的更大图景,以设置上下文并了解在这里讨论的内容,然后再进入并处理用例。

图 1
微批数据处理
每个 Spark Streaming 数据处理应用程序将持续运行,直到被终止。此应用程序将不断 监听 数据源以接收传入的数据流。Spark Streaming 数据处理应用程序将有一个配置的批处理间隔。在每一个批处理间隔结束时,它将产生一个名为 离散流(DStream)的数据抽象,其工作方式与 Spark 的 RDD 非常相似。就像 RDD 一样,DStream 支持用于常用 Spark 转换和 Spark 操作的等效方法。
小贴士
就像 RDD 一样,DStream 也是不可变和分布式的。
图 2 展示了在 Spark Streaming 数据处理应用程序中 DStream 的生成方式。

图 2
图 2 展示了 Spark Streaming 应用程序最重要的元素。对于配置的批处理间隔,应用程序会产生一个 DStream。每个 DStream 是由在该批处理间隔内收集的数据组成的 RDD 集合。对于给定的批处理间隔,DStream 中 RDD 的数量可能会有所不同。
小贴士
由于 Spark Streaming 应用程序是持续运行并收集数据的应用程序,在本章中,我们讨论了完整的应用程序,包括编译、打包和运行的指令,而不是在 REPL 中运行代码。
Spark 编程模型在第二章中进行了讨论,Spark 编程模型。
使用 DStreams 进行编程
在 Spark Streaming 数据流处理应用程序中使用 DStreams 进行编程也遵循一个非常类似的模型,因为 DStreams 由一个或多个 RDD 组成。当在 DStream 上调用 Spark 转换或 Spark 操作时,等效操作会被应用到构成 DStream 的所有 RDD 上。
注意
这里需要注意的一个重要点是,并非所有在 RDD 上工作的 Spark 转换和 Spark 操作都不支持在 DStreams 上使用。另一个值得注意的变化是不同编程语言之间能力的差异。
Spark Streaming 的 Scala 和 Java API 在支持 Spark Streaming 数据流处理应用程序开发的功能数量上优于 Python API。
图 3描述了应用于 DStream 的方法是如何应用于底层的 RDDs 的。在使用 DStream 上的任何方法之前,应查阅 Spark Streaming 编程指南。当 Python API 与其 Scala 或 Java 对应版本不同时,Spark Streaming 编程指南会带有包含文本Python API的特殊提示。
假设在一个 Spark Streaming 数据流处理应用程序中,给定一个批次间隔,会生成一个包含多个 RDD 的 DStream。当对这个 DStream 应用过滤方法时,它会被转换成底层的 RDDs。图 3展示了在一个包含两个 RDD 的 DStream 上应用过滤转换,由于过滤条件,结果生成另一个只包含一个 RDD 的 DStream:

图 3
日志事件处理器
这些天,在许多企业中,拥有一个中央应用程序日志事件存储库是非常常见的。此外,日志事件会实时流式传输到数据处理应用程序,以便实时监控运行应用程序的性能,以便及时采取补救措施。这里讨论了这样一个用例,以展示使用 Spark Streaming 数据流处理应用程序实时处理日志事件。在这个用例中,实时应用程序日志事件被写入 TCP 套接字。Spark Streaming 数据流处理应用程序持续监听给定主机上的指定端口,以收集日志事件流。
准备 Netcat 服务器
在大多数 UNIX 安装中附带使用的 Netcat 实用程序在此用作数据服务器。为了确保 Netcat 已安装在系统中,请输入以下脚本中给出的手册命令,并在退出后运行它,确保没有错误信息。一旦服务器启动并运行,标准输入的 Netcat 服务器控制台中输入的内容将被视为应用程序日志事件,以简化演示。以下从终端提示符运行的命令将在本地主机端口9999上启动 Netcat 数据服务器:
$ man nc
NC(1) BSD General Commands Manual
NC(1)
NAME
nc -- arbitrary TCP and UDP connections and listens
SYNOPSIS
nc [-46AcDCdhklnrtUuvz] [-b boundif] [-i interval] [-p source_port] [-s source_ip_address] [-w timeout] [-X proxy_protocol] [-x proxy_address[:port]]
[hostname] [port[s]]
DESCRIPTION
The nc (or netcat) utility is used for just about anything under the sun involving TCP or UDP. It can open TCP connections, send UDP packets, listen on
arbitrary TCP and UDP ports, do port scanning, and deal with both IPv4 and IPv6. Unlike telnet(1), nc scripts nicely, and separates error messages onto
standard error instead of sending them to standard output, as telnet(1) does with some.
Common uses include:
o simple TCP proxies
o shell-script based HTTP clients and servers
o network daemon testing
o a SOCKS or HTTP ProxyCommand for ssh(1)
o and much, much more
$ nc -lk 9999
完成前面的步骤后,Netcat 服务器就绪,Spark Streaming 数据处理应用程序将处理之前控制台窗口中输入的所有行。不要操作这个控制台窗口;所有后续的 shell 命令将在不同的终端窗口中运行。
由于不同编程语言之间 Spark Streaming 功能的对等性不足,因此使用 Scala 代码来解释所有 Spark Streaming 概念和用例。之后,将给出 Python 代码,如果 Python 中不支持正在讨论的任何功能,也会捕获。
Scala 和 Python 代码的组织方式如图 4所示。为了编译、打包和运行代码,使用了 bash 脚本,以便读者可以轻松运行它们以产生一致的结果。这里讨论了每个脚本文件的内容。
文件组织
在以下文件夹结构中,project和target文件夹是在运行时创建的。本书附带源代码可以直接复制到系统中的方便文件夹中:

图 4
为了编译和打包,使用了Scala 构建工具(sbt)。为了确保 sbt 正常工作,请在终端窗口中从图 4中的树形结构的Scala文件夹运行以下命令。这是为了确保 sbt 运行良好且代码正在编译:
$ cd Scala
$ sbt
> compile
[success] Total time: 1 s, completed 24 Jul, 2016 8:39:04 AM
> exit
$
以下表格捕捉了正在讨论的 Spark Streaming 数据处理应用程序中代表性文件样本列表以及每个文件的目的。
| 文件名 | 用途 |
|---|---|
README.txt |
运行应用程序的说明。一个用于 Scala 应用程序,另一个用于 Python 应用程序。 |
submitPy.sh |
用于将 Python 作业提交到 Spark 集群的 Bash 脚本。 |
compile.sh |
用于编译 Scala 代码的 Bash 脚本。 |
submit.sh |
用于将 Scala 作业提交到 Spark 集群的 Bash 脚本。 |
config.sbt |
sbt 配置文件。 |
*.scala |
使用 Scala 编写的 Spark Streaming 数据处理应用程序代码。 |
*.py |
使用 Python 编写的 Spark Streaming 数据处理应用程序代码。 |
*.jar |
需要下载并放置在lib文件夹下以使应用程序正常运行的 Spark Streaming 和 Kafka 集成 JAR 文件。这个文件在submit.sh以及submitPy.sh中也被使用,用于将作业提交到集群。 |
将作业提交到 Spark 集群
为了正确运行应用程序,一些配置取决于运行它的系统。它们需要在submit.sh文件和submitPy.sh文件中进行编辑。无论何时需要此类编辑,都会使用[FILLUP]标签给出注释。其中最重要的是 Spark 安装目录和 Spark 主配置的设置,这些可能因系统而异。前面submit.sh文件的源代码如下:
#!/bin/bash
#-----------
# submit.sh
#-----------
# IMPORTANT - Assumption is that the $SPARK_HOME and $KAFKA_HOME environment variables are already set in the system that is running the application
# [FILLUP] Which is your Spark master. If monitoring is needed, use the desired Spark master or use local
# When using the local mode. It is important to give more than one cores in square brackets
#SPARK_MASTER=spark://Rajanarayanans-MacBook-Pro.local:7077
SPARK_MASTER=local[4]
# [OPTIONAL] Your Scala version
SCALA_VERSION="2.11"
# [OPTIONAL] Name of the application jar file. You should be OK to leave it like that
APP_JAR="spark-for-beginners_$SCALA_VERSION-1.0.jar"
# [OPTIONAL] Absolute path to the application jar file
PATH_TO_APP_JAR="target/scala-$SCALA_VERSION/$APP_JAR"
# [OPTIONAL] Spark submit commandSPARK_SUBMIT="$SPARK_HOME/bin/spark-submit"
# [OPTIONAL] Pass the application name to run as the parameter to this script
APP_TO_RUN=$1
sbt package
if [ $2 -eq 1 ]
then
$SPARK_SUBMIT --class $APP_TO_RUN --master $SPARK_MASTER --jars $KAFKA_HOME/libs/kafka-clients-0.8.2.2.jar,$KAFKA_HOME/libs/kafka_2.11-0.8.2.2.jar,$KAFKA_HOME/libs/metrics-core-2.2.0.jar,$KAFKA_HOME/libs/zkclient-0.3.jar,./lib/spark-streaming-kafka-0-8_2.11-2.0.0-preview.jar $PATH_TO_APP_JAR
else
$SPARK_SUBMIT --class $APP_TO_RUN --master $SPARK_MASTER --jars $PATH_TO_APP_JAR $PATH_TO_APP_JAR
fi
前面脚本文件submitPy.sh的源代码如下:
#!/usr/bin/env bash
#------------
# submitPy.sh
#------------
# IMPORTANT - Assumption is that the $SPARK_HOME and $KAFKA_HOME environment variables are already set in the system that is running the application
# Disable randomized hash in Python 3.3+ (for string) Otherwise the following exception will occur
# raise Exception("Randomness of hash of string should be disabled via PYTHONHASHSEED")
# Exception: Randomness of hash of string should be disabled via PYTHONHASHSEED
export PYTHONHASHSEED=0
# [FILLUP] Which is your Spark master. If monitoring is needed, use the desired Spark master or use local
# When using the local mode. It is important to give more than one cores in square brackets
#SPARK_MASTER=spark://Rajanarayanans-MacBook-Pro.local:7077
SPARK_MASTER=local[4]
# [OPTIONAL] Pass the application name to run as the parameter to this script
APP_TO_RUN=$1
# [OPTIONAL] Spark submit command
SPARK_SUBMIT="$SPARK_HOME/bin/spark-submit"
if [ $2 -eq 1 ]
then
$SPARK_SUBMIT --master $SPARK_MASTER --jars $KAFKA_HOME/libs/kafka-clients-0.8.2.2.jar,$KAFKA_HOME/libs/kafka_2.11-0.8.2.2.jar,$KAFKA_HOME/libs/metrics-core-2.2.0.jar,$KAFKA_HOME/libs/zkclient-0.3.jar,./lib/spark-streaming-kafka-0-8_2.11-2.0.0-preview.jar $APP_TO_RUN
else
$SPARK_SUBMIT --master $SPARK_MASTER $APP_TO_RUN
fi
监控运行中的应用程序
如第二章中所述,Spark 编程模型,Spark 安装附带了一个强大的 Spark Web UI,用于监控正在运行的 Spark 应用程序。
对于正在运行的 Spark Streaming 作业,还有额外的可视化可用。
以下脚本启动 Spark 主节点和工作者节点,并启用监控。这里的假设是读者已经按照第二章中建议的配置更改进行了所有配置,以启用 Spark 应用程序监控。如果没有这样做,应用程序仍然可以运行。唯一需要更改的是,在submit.sh文件和submitPy.sh文件中将情况放入,以确保使用local[4]之类的而不是 Spark 主节点 URL。在终端窗口中运行以下命令:
$ cd $SPARK_HOME
$ ./sbin/start-all.sh
starting org.apache.spark.deploy.master.Master, logging to /Users/RajT/source-code/spark-source/spark-2.0/logs/spark-RajT-org.apache.spark.deploy.master.Master-1-Rajanarayanans-MacBook-Pro.local.out
localhost: starting org.apache.spark.deploy.worker.Worker, logging to /Users/RajT/source-code/spark-source/spark-2.0/logs/spark-RajT-org.apache.spark.deploy.worker.Worker-1-Rajanarayanans-MacBook-Pro.local.out
确保 Spark Web UI 正在运行,可以通过访问http://localhost:8080/来检查。
使用 Scala 实现应用程序
以下代码片段是日志事件处理应用程序的 Scala 代码:
/**
The following program can be compiled and run using SBT
Wrapper scripts have been provided with this
The following script can be run to compile the code
./compile.sh
The following script can be used to run this application in Spark
./submit.sh com.packtpub.sfb.StreamingApps
**/
package com.packtpub.sfb
import org.apache.spark.sql.{Row, SparkSession}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import org.apache.spark.storage.StorageLevel
import org.apache.log4j.{Level, Logger}
object StreamingApps{
def main(args: Array[String])
{
// Log level settings
LogSettings.setLogLevels()
// Create the Spark Session and the spark context
val spark = SparkSession
.builder
.appName(getClass.getSimpleName)
.getOrCreate()
// Get the Spark context from the Spark session for creating the streaming context
val sc = spark.sparkContext
// Create the streaming context
val ssc = new StreamingContext(sc, Seconds(10))
// Set the check point directory for saving the data to recover when
there is a crash ssc.checkpoint("/tmp")
println("Stream processing logic start")
// Create a DStream that connects to localhost on port 9999
// The StorageLevel.MEMORY_AND_DISK_SER indicates that the data will be
stored in memory and if it overflows, in disk as well
val appLogLines = ssc.socketTextStream("localhost", 9999,
StorageLevel.MEMORY_AND_DISK_SER)
// Count each log message line containing the word ERROR
val errorLines = appLogLines.filter(line => line.contains("ERROR"))
// Print the elements of each RDD generated in this DStream to the
console errorLines.print()
// Count the number of messages by the windows and print them
errorLines.countByWindow(Seconds(30), Seconds(10)).print()
println("Stream processing logic end")
// Start the streaming ssc.start()
// Wait till the application is terminated
ssc.awaitTermination() }
}object LogSettings{
/**
Necessary log4j logging level settings are done
*/ def setLogLevels() {
val log4jInitialized =
Logger.getRootLogger.getAllAppenders.hasMoreElements
if (!log4jInitialized) {
// This is to make sure that the console is clean from other INFO
messages printed by Spark
Logger.getRootLogger.setLevel(Level.WARN)
}
}
}
在前面的代码片段中,有两个 Scala 对象。一个是用于设置适当的日志级别,以确保不显示不想要的消息。StreamingApps Scala 对象包含流处理的逻辑。以下列表捕捉了功能的核心:
-
使用应用程序名称创建一个 Spark 配置。
-
创建了一个 Spark
StreamingContext对象,这是流处理的核心。StreamingContext构造函数的第二个参数是批处理间隔,这里是 10 秒。包含ssc.socketTextStream的行在每一个批处理间隔(这里为 10 秒)创建 DStream,包含在 Netcat 控制台中输入的行。 -
在 DStream 上应用了一个过滤器转换,以只包含包含单词
ERROR的行。过滤器转换创建新的 DStream,其中只包含过滤后的行。 -
下一行将 DStream 内容打印到控制台。换句话说,对于每个批次间隔,如果有包含单词
ERROR的行,这些行将在控制台中显示。 -
在数据处理逻辑的末尾,启动了给定的
StreamingContext并将一直运行,直到被终止。
在之前的代码片段中,没有循环结构告诉应用程序重复执行直到运行中的应用程序终止。这是由 Spark Streaming 库本身实现的。从开始到数据处理应用程序的终止,所有语句都只运行一次。DStreams 上的所有操作(内部)都会为每个批次重复执行。如果仔细检查上一个应用程序的输出,println() 语句的输出在控制台中只出现一次,尽管这些语句位于 StreamingContext 的初始化和终止之间。这是因为 魔法循环 只会重复包含原始和派生 DStreams 的语句。
由于 Spark Streaming 应用程序中实现的循环的特殊性质,在应用程序代码中的流逻辑内给出打印语句和日志语句是徒劳的,就像代码片段中给出的那样。如果这是必须的,那么这些日志语句必须在传递给 DStreams 的转换和操作的功能中进行配置。
小贴士
如果需要处理数据的持久性,DStreams 提供了许多输出操作,就像 RDDs 一样。
编译和运行应用程序
以下命令在终端窗口中运行以编译和运行应用程序。除了使用 ./compile.sh 之外,还可以使用简单的 sbt compile 命令。
注意
注意,正如之前讨论的,在执行这些命令之前,Netcat 服务器必须正在运行。
$ cd Scala
$ ./compile.sh
[success] Total time: 1 s, completed 24 Jan, 2016 2:34:48 PM
$ ./submit.sh com.packtpub.sfb.StreamingApps
Stream processing logic start
Stream processing logic end
-------------------------------------------
Time: 1469282910000 ms
-------------------------------------------
-------------------------------------------
Time: 1469282920000 ms
-------------------------------------------
如果没有显示错误消息,并且结果显示与之前的输出一致,则 Spark Streaming 数据处理应用程序已正确启动。
处理输出
注意,打印语句的输出在 DStream 输出打印之前。到目前为止,Netcat 控制台中还没有输入任何内容,因此没有内容可以处理。
现在转到之前启动的 Netcat 控制台,并输入以下行日志事件消息,在每行之间留出几秒钟的间隔以确保输出超过一个批次,其中批次大小为 10 秒:
[Fri Dec 20 01:46:23 2015] [ERROR] [client 1.2.3.4.5.6] Directory index forbidden by rule: /home/raj/
[Fri Dec 20 01:46:23 2015] [WARN] [client 1.2.3.4.5.6] Directory index forbidden by rule: /home/raj/
[Fri Dec 20 01:54:34 2015] [ERROR] [client 1.2.3.4.5.6] Directory index forbidden by rule: /apache/web/test
[Fri Dec 20 01:54:34 2015] [WARN] [client 1.2.3.4.5.6] Directory index forbidden by rule: /apache/web/test
[Fri Dec 20 02:25:55 2015] [ERROR] [client 1.2.3.4.5.6] Client sent malformed Host header
[Fri Dec 20 02:25:55 2015] [WARN] [client 1.2.3.4.5.6] Client sent malformed Host header
[Mon Dec 20 23:02:01 2015] [ERROR] [client 1.2.3.4.5.6] user test: authentication failure for "/~raj/test": Password Mismatch
[Mon Dec 20 23:02:01 2015] [WARN] [client 1.2.3.4.5.6] user test: authentication failure for "/~raj/test": Password Mismatch
一旦将日志事件消息输入到 Netcat 控制台窗口中,以下结果将开始在 Spark Streaming 数据处理应用程序中显示,仅过滤包含关键字 ERROR 的日志事件消息。
-------------------------------------------
Time: 1469283110000 ms
-------------------------------------------
[Fri Dec 20 01:46:23 2015] [ERROR] [client 1.2.3.4.5.6] Directory index
forbidden by rule: /home/raj/
-------------------------------------------
Time: 1469283190000 ms
-------------------------------------------
-------------------------------------------
Time: 1469283200000 ms
-------------------------------------------
[Fri Dec 20 01:54:34 2015] [ERROR] [client 1.2.3.4.5.6] Directory index
forbidden by rule: /apache/web/test
-------------------------------------------
Time: 1469283250000 ms
-------------------------------------------
-------------------------------------------
Time: 1469283260000 ms
-------------------------------------------
[Fri Dec 20 02:25:55 2015] [ERROR] [client 1.2.3.4.5.6] Client sent
malformed Host header
-------------------------------------------
Time: 1469283310000 ms
-------------------------------------------
[Mon Dec 20 23:02:01 2015] [ERROR] [client 1.2.3.4.5.6] user test:
authentication failure for "/~raj/test": Password Mismatch
-------------------------------------------
Time: 1453646710000 ms
-------------------------------------------
Spark 网页 UI (http://localhost:8080/) 已经启用,图 5 和 6 显示了 Spark 应用程序和统计信息。
从主页(访问 URL http://localhost:8080/)开始,点击运行中的 Spark Streaming 数据处理应用程序的名称链接,以打开常规监控页面。从该页面,点击Streaming标签,以显示包含流统计信息的页面。
需要点击的链接和标签页用红色圆圈标注:

图 5
从图 5所示的页面,点击圆圈中的应用程序链接;它将带您到相关页面。从该页面,一旦点击Streaming标签,包含流统计信息的页面将显示,如图 6所示:

图 6
从这些 Spark web UI 页面中可以获取大量应用程序统计信息,广泛探索它们是一个好主意,以更深入地了解提交的 Spark Streaming 数据处理应用程序的行为。
小贴士
在启用流应用程序监控时必须小心,因为它不应影响应用程序本身的性能。
在 Python 中实现应用程序
同样的用例在 Python 中实现,并在StreamingApps.py中保存以下代码片段来完成此操作:
# The following script can be used to run this application in Spark
# ./submitPy.sh StreamingApps.py
from __future__ import print_function
import sys
from pyspark import SparkContext
from pyspark.streaming import StreamingContext
if __name__ == "__main__":
# Create the Spark context
sc = SparkContext(appName="PythonStreamingApp")
# Necessary log4j logging level settings are done
log4j = sc._jvm.org.apache.log4j
log4j.LogManager.getRootLogger().setLevel(log4j.Level.WARN)
# Create the Spark Streaming Context with 10 seconds batch interval
ssc = StreamingContext(sc, 10)
# Set the check point directory for saving the data to recover when
there is a crash
ssc.checkpoint("\tmp")
# Create a DStream that connects to localhost on port 9999
appLogLines = ssc.socketTextStream("localhost", 9999)
# Count each log messge line containing the word ERROR
errorLines = appLogLines.filter(lambda appLogLine: "ERROR" in appLogLine)
# // Print the elements of each RDD generated in this DStream to the console
errorLines.pprint()
# Count the number of messages by the windows and print them
errorLines.countByWindow(30,10).pprint()
# Start the streaming
ssc.start()
# Wait till the application is terminated
ssc.awaitTermination()
以下命令在终端窗口中运行以从代码下载的目录中运行 Python Spark Streaming 数据处理应用程序。在运行应用程序之前,与用于运行 Scala 应用程序的脚本所做的修改相同,submitPy.sh文件也必须更改以指向正确的 Spark 安装目录并配置 Spark master。如果启用了监控,并且提交指向正确的 Spark master,则相同的 Spark web UI 将捕获 Python Spark Streaming 数据处理应用程序的统计信息。
以下命令在终端窗口中运行以运行 Python 应用程序:
$ cd Python
$ ./submitPy.sh StreamingApps.py
一旦将用于 Scala 实现的相同日志事件消息输入到 Netcat 控制台窗口中,以下结果将开始在流应用程序中显示,仅过滤包含关键字ERROR的日志事件消息:
-------------------------------------------
Time: 2016-07-23 15:21:50
-------------------------------------------
-------------------------------------------
Time: 2016-07-23 15:22:00
-------------------------------------------
[Fri Dec 20 01:46:23 2015] [ERROR] [client 1.2.3.4.5.6]
Directory index forbidden by rule: /home/raj/
-------------------------------------------
Time: 2016-07-23 15:23:50
-------------------------------------------
[Fri Dec 20 01:54:34 2015] [ERROR] [client 1.2.3.4.5.6]
Directory index forbidden by rule: /apache/web/test
-------------------------------------------
Time: 2016-07-23 15:25:10
-------------------------------------------
-------------------------------------------
Time: 2016-07-23 15:25:20
-------------------------------------------
[Fri Dec 20 02:25:55 2015] [ERROR] [client 1.2.3.4.5.6]
Client sent malformed Host header
-------------------------------------------
Time: 2016-07-23 15:26:50
-------------------------------------------
[Mon Dec 20 23:02:01 2015] [ERROR] [client 1.2.3.4.5.6]
user test: authentication failure for "/~raj/test": Password Mismatch
-------------------------------------------
Time: 2016-07-23 15:26:50
-------------------------------------------
如果您查看 Scala 和 Python 程序输出的结果,您可以清楚地看到在给定的批次间隔中是否有包含单词ERROR的任何日志事件消息。一旦数据被处理,应用程序会丢弃处理过的数据,而不会保留它们供将来使用。
换句话说,应用程序永远不会保留或记住之前批次间隔中的任何日志事件消息。如果需要捕获错误消息的数量,例如在最后 5 分钟或更长时间内,则之前的方法将不起作用。我们将在下一节中讨论这个问题。
窗口数据处理
在上一节讨论的 Spark Streaming 数据处理应用程序中,假设需要计数前三个批次中包含关键字 ERROR 的日志事件消息的数量。换句话说,应该有在三个批次窗口中计数此类事件消息的能力。在任何给定的时间点,窗口应随着新数据批次的出现而滑动。这里讨论了三个重要术语,图 7解释了它们。它们是:
-
批量间隔:生成 DStream 的时间间隔
-
窗口长度:需要查看那些批量间隔中产生的所有 DStreams 的批次数量。
-
滑动间隔:执行窗口操作(如计数事件消息)的间隔

图 7
在图 7中,在特定的时间点,用于执行操作的 DStreams 被包含在一个矩形内。
在每个批量间隔中,都会生成一个新的 DStream。在这里,窗口长度为三,窗口中要执行的操作是计数该窗口中的事件消息数量。滑动间隔保持与批量间隔相同,以便在生成新的 DStream 时执行计数操作,确保计数始终正确。
在时间t2,对在时间t0、t1和t2生成的 DStreams 执行计数操作。在时间t3,由于滑动窗口保持与批量间隔相同,因此再次执行计数操作,这次是在时间t1、t2和t3生成的 DStreams 上计数事件。在时间t4,再次执行计数操作,这次是在时间t2、t3和t4生成的 DStreams 上计数事件。操作以这种方式继续,直到应用程序终止。
在 Scala 中计数处理日志事件消息的数量
在前一节中,讨论了日志事件消息的处理。在相同的 Scala 应用程序代码中,在打印包含单词ERROR的日志事件消息之后,包括以下代码行:
errorLines.print()errorLines.countByWindow(Seconds(30), Seconds(10)).print()
第一个参数是窗口长度,第二个参数是滑动窗口间隔。在 Netcat 控制台中输入以下行后,这一行魔法代码将打印出处理过的日志事件消息的计数:
[Fri Dec 20 01:46:23 2015] [ERROR] [client 1.2.3.4.5.6] Directory index forbidden by rule: /home/raj/[Fri Dec 20 01:46:23 2015] [WARN] [client 1.2.3.4.5.6] Directory index forbidden by rule: /home/raj/[Fri Dec 20 01:54:34 2015] [ERROR] [client 1.2.3.4.5.6] Directory index forbidden by rule: /apache/web/test
添加了额外代码的相同 Scala Spark Streaming 数据处理应用程序产生了以下输出:
-------------------------------------------
Time: 1469284630000 ms
-------------------------------------------
[Fri Dec 20 01:46:23 2015] [ERROR] [client 1.2.3.4.5.6] Directory index
forbidden by rule: /home/raj/
-------------------------------------------
Time: 1469284630000 ms
-------------------------------------------
1
-------------------------------------------
Time: 1469284640000 ms
-------------------------------------------
[Fri Dec 20 01:54:34 2015] [ERROR] [client 1.2.3.4.5.6] Directory index
forbidden by rule: /apache/web/test
-------------------------------------------
Time: 1469284640000 ms
-------------------------------------------
2
-------------------------------------------
Time: 1469284650000 ms
-------------------------------------------
2
-------------------------------------------
Time: 1469284660000 ms
-------------------------------------------
1
-------------------------------------------
Time: 1469284670000 ms
-------------------------------------------
0
如果仔细研究输出,可以注意到,在第一个批次间隔中,处理了一个日志事件消息。显然,显示的计数为1。在下一个批次间隔中,处理了一个额外的日志事件消息。该批次间隔显示的计数为2。在下一个批次间隔中,没有处理日志事件消息。但是该窗口的计数仍然是2。对于另一个窗口,计数显示为2。然后减少到1,然后是0。
需要注意的最重要的一点是,在 Scala 和 Python 的应用代码中,在创建 StreamingContext 之后,需要立即插入以下代码行以指定检查点目录:
ssc.checkpoint("/tmp")
在 Python 中计算处理过的日志事件消息的数量
在 Python 应用程序代码中,在打印包含单词 ERROR 的日志事件消息之后,在 Scala 应用程序中包含以下代码行:
errorLines.pprint()
errorLines.countByWindow(30,10).pprint()
第一个参数是窗口长度,第二个参数是滑动窗口间隔。在 Netcat 控制台中输入以下行后,这一行神奇的代码将打印处理过的日志事件消息的数量:
[Fri Dec 20 01:46:23 2015] [ERROR] [client 1.2.3.4.5.6]
Directory index forbidden by rule: /home/raj/
[Fri Dec 20 01:46:23 2015] [WARN] [client 1.2.3.4.5.6]
Directory index forbidden by rule: /home/raj/
[Fri Dec 20 01:54:34 2015] [ERROR] [client 1.2.3.4.5.6]
Directory index forbidden by rule: /apache/web/test
在 Python 中,相同的 Spark Streaming 数据处理应用程序,通过添加额外的代码行,产生以下输出:
-------------------------------------------
Time: 2016-07-23 15:29:40
-------------------------------------------
[Fri Dec 20 01:46:23 2015] [ERROR] [client 1.2.3.4.5.6] Directory index forbidden by rule: /home/raj/
-------------------------------------------
Time: 2016-07-23 15:29:40
-------------------------------------------
1
-------------------------------------------
Time: 2016-07-23 15:29:50
-------------------------------------------
[Fri Dec 20 01:54:34 2015] [ERROR] [client 1.2.3.4.5.6] Directory index forbidden by rule: /apache/web/test
-------------------------------------------
Time: 2016-07-23 15:29:50
-------------------------------------------
2
-------------------------------------------
Time: 2016-07-23 15:30:00
-------------------------------------------
-------------------------------------------
Time: 2016-07-23 15:30:00
-------------------------------------------
2
-------------------------------------------
Time: 2016-07-23 15:30:10
-------------------------------------------
-------------------------------------------
Time: 2016-07-23 15:30:10
-------------------------------------------
1
-------------------------------------------
Time: 2016-07-23 15:30:20
-------------------------------------------
-------------------------------------------
Time: 2016-07-23 15:30:20
-------------------------------------------
Python 应用程序的输出模式也与 Scala 应用程序非常相似。
更多处理选项
除了窗口中的计数操作外,还可以在 DStreams 上执行更多与窗口结合的操作。以下表格总结了重要的转换。所有这些转换都在选定的窗口上操作,并返回一个 DStream。
| 转换 | 描述 |
|---|---|
window(windowLength, slideInterval) |
返回窗口中的 DStreams 计算结果 |
countByWindow(windowLength, slideInterval) |
返回元素的数量 |
reduceByWindow(func, windowLength, slideInterval) |
通过应用聚合函数返回一个元素 |
reduceByKeyAndWindow(func, windowLength, slideInterval, [numTasks]) |
在每个键上应用聚合函数后,返回每个键的一个键/值对 |
countByValueAndWindow(windowLength, slideInterval, [numTasks]) |
在每个键上应用每个键的多个值的计数后,返回每个键的一个键/计数对 |
流处理最重要的步骤之一是将流数据持久化到二级存储。由于 Spark Streaming 数据处理应用程序中的数据速度将非常快,任何引入额外延迟的持久化机制都不是一个可取的解决方案。
在批处理场景中,写入 HDFS 和其他基于文件系统的存储是可行的。但是,当涉及到流输出存储时,根据用例,应选择理想的流数据存储机制。
如 Cassandra 这样的 NoSQL 数据存储支持快速写入时间序列数据。它也适合读取存储的数据以进行进一步分析。Spark Streaming 库支持 DStreams 的许多输出方法。它们包括将流数据保存为文本文件、对象文件、Hadoop 文件等选项。此外,还有许多第三方驱动程序可用于将数据保存到各种数据存储中。
Kafka 流处理
本章中涵盖的日志事件处理器示例正在监听 Spark Streaming 数据处理应用要处理的流消息的 TCP 套接字。但在现实世界的用例中,情况并非如此。
具有发布-订阅能力的消息队列系统通常用于处理消息。传统的消息队列系统由于需要处理每秒大量消息而无法胜任,这对于大规模数据处理应用的需求来说。
Kafka 是许多物联网应用使用的发布-订阅消息系统,用于处理大量消息。以下 Kafka 的功能使其成为最广泛使用的消息系统之一:
-
极其快速:Kafka 通过处理来自许多应用客户端的短时间间隔内的读写操作,可以处理大量数据
-
高度可扩展:Kafka 设计用于向上和向外扩展,使用通用硬件形成一个集群
-
持存大量消息:达到 Kafka 主题的消息被持久化到二级存储中,同时它还在处理通过的大量消息
注意
Kafka 的详细讨论超出了本书的范围。假设读者熟悉 Kafka 并具有实际操作知识。从 Spark Streaming 数据处理应用的角度来看,使用 TCP 套接字或 Kafka 作为消息源实际上并没有太大区别。但是,使用 Kafka 作为消息生产者的示例用例将有助于更好地理解企业大量使用的工具集。"学习 Apache Kafka" 第二版 由 Nishant Garg 编著 (www.packtpub.com/big-data-and-business-intelligence/learning-apache-kafka-second-edition) 是一本很好的参考书,可以了解更多关于 Kafka 的信息。
以下是一些 Kafka 的重要元素,是在进一步了解之前需要理解的概念:
-
生产者:消息的真实来源,例如气象传感器或移动电话网络
-
代理:接收并持久化各种生产者发布到其主题的消息的 Kafka 集群
-
消费者:订阅 Kafka 主题并消费发布到主题的消息的数据处理应用
在上一节中讨论的相同日志事件处理用例在此处再次使用,以阐明 Kafka 与 Spark Streaming 的使用方法。与从 TCP 套接字收集日志事件消息不同,这里 Spark Streaming 数据处理应用将充当 Kafka 主题的消费者,并将发布到主题的消息进行消费。
Spark Streaming 数据处理应用使用 Kafka 的 0.8.2.2 版本作为消息代理,假设读者已经安装了 Kafka,至少是以独立模式安装。以下活动是为了确保 Kafka 准备好处理生产者产生的消息,并且 Spark Streaming 数据处理应用可以消费这些消息:
-
启动 Kafka 安装包中包含的 Zookeeper。
-
启动 Kafka 服务器。
-
为生产者创建一个发送消息的主题。
-
选择一个 Kafka 生产者并开始向新创建的主题发布日志事件消息。
-
使用 Spark Streaming 数据处理应用处理发布到新创建主题的日志事件。
启动 Zookeeper 和 Kafka
以下脚本是在单独的终端窗口中运行的,以启动 Zookeeper 和 Kafka 代理,并创建所需的 Kafka 主题:
$ cd $KAFKA_HOME
$ $KAFKA_HOME/bin/zookeeper-server-start.sh
$KAFKA_HOME/config/zookeeper.properties
[2016-07-24 09:01:30,196] INFO binding to port 0.0.0.0/0.0.0.0:2181 (org.apache.zookeeper.server.NIOServerCnxnFactory)
$ $KAFKA_HOME/bin/kafka-server-start.sh $KAFKA_HOME/config/server.properties
[2016-07-24 09:05:06,381] INFO 0 successfully elected as leader
(kafka.server.ZookeeperLeaderElector)
[2016-07-24 09:05:06,455] INFO [Kafka Server 0], started
(kafka.server.KafkaServer)
$ $KAFKA_HOME/bin/kafka-topics.sh --create --zookeeper localhost:2181
--replication-factor 1 --partitions 1 --topic sfb
Created topic "sfb".
$ $KAFKA_HOME/bin/kafka-console-producer.sh --broker-list
localhost:9092 --topic sfb
提示
确保环境变量 $KAFKA_HOME 指向 Kafka 安装的目录。此外,在单独的终端窗口中启动 Zookeeper、Kafka 服务器、Kafka 生产者和 Spark Streaming 日志事件数据处理应用非常重要。
Kafka 消息生产者可以是任何能够向 Kafka 主题发布消息的应用程序。在此,选择 Kafka 中的 kafka-console-producer 作为首选的生产者。一旦生产者开始运行,在其控制台窗口中输入的内容将被视为发布到所选 Kafka 主题的消息。Kafka 主题在启动 kafka-console-producer 时作为命令行参数给出。
消费由 Kafka 生产者产生的日志事件消息的 Spark Streaming 数据处理应用的提交方式与上一节中介绍的应用略有不同。在此,需要许多 Kafka jar 文件进行数据处理。由于它们不是 Spark 基础设施的一部分,因此必须将它们提交到 Spark 集群。以下 jar 文件对于此应用的正常运行是必需的:
-
$KAFKA_HOME/libs/kafka-clients-0.8.2.2.jar -
$KAFKA_HOME/libs/kafka_2.11-0.8.2.2.jar -
$KAFKA_HOME/libs/metrics-core-2.2.0.jar -
$KAFKA_HOME/libs/zkclient-0.3.jar -
Code/Scala/lib/spark-streaming-kafka-0-8_2.11-2.0.0-preview.jar -
Code/Python/lib/spark-streaming-kafka-0-8_2.11-2.0.0-preview.jar
在先前的 jar 文件列表中,spark-streaming-kafka-0-8_2.11-2.0.0-preview.jar 的 Maven 仓库坐标为 "org.apache.spark" %% "spark-streaming-kafka-0-8" % "2.0.0-preview"。这个特定的 jar 文件必须下载并放置在图 4 所示的目录结构中的 lib 文件夹中。它被用于 submit.sh 和 submitPy.sh 脚本中,这些脚本将应用程序提交到 Spark 集群。该 jar 文件的下载 URL 在本章的参考部分给出。
在 submit.sh 和 submitPy.sh 文件中,最后几行包含一个条件语句,寻找第二个参数值为 1 以识别此应用程序并将所需的 jar 文件发送到 Spark 集群。
提示
在提交作业时,不需要分别将这些单独的 jar 文件发送到 Spark 集群,可以使用 sbt 创建的 assembly jar。
在 Scala 中实现应用程序
以下代码片段是处理 Kafka 生产者产生的消息的日志事件处理应用程序的 Scala 代码。该应用程序的使用案例与前述部分关于窗口操作讨论的使用案例相同:
/**
The following program can be compiled and run using SBT
Wrapper scripts have been provided with this
The following script can be run to compile the code
./compile.sh
The following script can be used to run this application in Spark. The second command line argument of value 1 is very important. This is to flag the shipping of the kafka jar files to the Spark cluster
./submit.sh com.packtpub.sfb.KafkaStreamingApps 1
**/
package com.packtpub.sfb
import java.util.HashMap
import org.apache.spark.streaming._
import org.apache.spark.sql.{Row, SparkSession}
import org.apache.spark.streaming.kafka._
import org.apache.kafka.clients.producer.{ProducerConfig, KafkaProducer, ProducerRecord}
object KafkaStreamingApps {
def main(args: Array[String]) {
// Log level settings
LogSettings.setLogLevels()
// Variables used for creating the Kafka stream
//The quorum of Zookeeper hosts
val zooKeeperQuorum = "localhost"
// Message group name
val messageGroup = "sfb-consumer-group"
//Kafka topics list separated by coma if there are multiple topics to be listened on
val topics = "sfb"
//Number of threads per topic
val numThreads = 1
// Create the Spark Session and the spark context
val spark = SparkSession
.builder
.appName(getClass.getSimpleName)
.getOrCreate()
// Get the Spark context from the Spark session for creating the streaming context
val sc = spark.sparkContext
// Create the streaming context
val ssc = new StreamingContext(sc, Seconds(10))
// Set the check point directory for saving the data to recover when there is a crash
ssc.checkpoint("/tmp")
// Create the map of topic names
val topicMap = topics.split(",").map((_, numThreads.toInt)).toMap
// Create the Kafka stream
val appLogLines = KafkaUtils.createStream(ssc, zooKeeperQuorum, messageGroup, topicMap).map(_._2)
// Count each log messge line containing the word ERROR
val errorLines = appLogLines.filter(line => line.contains("ERROR"))
// Print the line containing the error
errorLines.print()
// Count the number of messages by the windows and print them
errorLines.countByWindow(Seconds(30), Seconds(10)).print()
// Start the streaming
ssc.start()
// Wait till the application is terminated
ssc.awaitTermination()
}
}
与前述部分的 Scala 代码相比,主要区别在于创建流的方式。
在 Python 中实现应用程序
以下代码片段是处理 Kafka 生产者产生的消息的日志事件处理应用程序的 Python 代码。该应用程序的使用案例也与前述部分关于窗口操作讨论的使用案例相同:
# The following script can be used to run this application in Spark
# ./submitPy.sh KafkaStreamingApps.py 1
from __future__ import print_function
import sys
from pyspark import SparkContext
from pyspark.streaming import StreamingContext
from pyspark.streaming.kafka import KafkaUtils
if __name__ == "__main__":
# Create the Spark context
sc = SparkContext(appName="PythonStreamingApp")
# Necessary log4j logging level settings are done
log4j = sc._jvm.org.apache.log4j
log4j.LogManager.getRootLogger().setLevel(log4j.Level.WARN)
# Create the Spark Streaming Context with 10 seconds batch interval
ssc = StreamingContext(sc, 10)
# Set the check point directory for saving the data to recover when there is a crash
ssc.checkpoint("\tmp")
# The quorum of Zookeeper hosts
zooKeeperQuorum="localhost"
# Message group name
messageGroup="sfb-consumer-group"
# Kafka topics list separated by coma if there are multiple topics to be listened on
topics = "sfb"
# Number of threads per topic
numThreads = 1
# Create a Kafka DStream
kafkaStream = KafkaUtils.createStream(ssc, zooKeeperQuorum, messageGroup, {topics: numThreads})
# Create the Kafka stream
appLogLines = kafkaStream.map(lambda x: x[1])
# Count each log messge line containing the word ERROR
errorLines = appLogLines.filter(lambda appLogLine: "ERROR" in appLogLine)
# Print the first ten elements of each RDD generated in this DStream to the console
errorLines.pprint()
errorLines.countByWindow(30,10).pprint()
# Start the streaming
ssc.start()
# Wait till the application is terminated
ssc.awaitTermination()
在终端窗口中运行以下命令以运行 Scala 应用程序:
$ cd Scala
$ ./submit.sh com.packtpub.sfb.KafkaStreamingApps 1
在终端窗口中运行以下命令以运行 Python 应用程序:
$ cd Python
$
./submitPy.sh KafkaStreamingApps.py 1
当先前的两个程序都在运行时,无论在 Kafka 控制台生产者的控制台窗口中键入什么日志事件消息,并使用以下命令和输入调用,都将由应用程序处理。该程序的输出将与前述部分给出的输出非常相似:
$ $KAFKA_HOME/bin/kafka-console-producer.sh --broker-list localhost:9092
--topic sfb
[Fri Dec 20 01:46:23 2015] [ERROR] [client 1.2.3.4.5.6] Directory index forbidden by rule: /home/raj/
[Fri Dec 20 01:46:23 2015] [WARN] [client 1.2.3.4.5.6] Directory index forbidden by rule: /home/raj/
[Fri Dec 20 01:54:34 2015] [ERROR] [client 1.2.3.4.5.6] Directory index forbidden by rule:
/apache/web/test
Spark 提供了两种处理 Kafka 流的方法。第一种是之前讨论过的基于接收器的方案,第二种是直接方法。
这种直接处理 Kafka 消息的方法是一种简化方法,其中 Spark Streaming 像任何 Kafka 主题消费者一样,使用 Kafka 的所有可能功能,并针对特定主题轮询消息,以及通过消息的偏移量来分区。根据 Spark Streaming 数据处理应用程序的批处理间隔,它从 Kafka 集群中选取一定数量的偏移量,并将这个偏移量范围作为一批处理。这种方法非常高效,非常适合需要精确一次处理的消息。此方法还减少了 Spark Streaming 库执行消息处理精确一次语义所需进行额外工作的需求,并将该责任委托给 Kafka。此方法的编程结构在用于数据处理的应用程序接口中略有不同。有关详细信息,请参阅适当的参考材料。
前面的章节介绍了 Spark Streaming 库的概念,并讨论了一些实际应用案例。从部署的角度来看,用于处理静态批量数据的 Spark 数据处理应用程序与用于处理动态流数据的 Spark 数据处理应用程序之间存在很大差异。数据处理应用程序处理数据流的能力必须是持续的。换句话说,此类应用程序不应具有单点故障的组件。下一节将讨论这个话题。
生产中的 Spark Streaming 作业
当 Spark Streaming 应用程序正在处理传入的数据时,拥有不间断的数据处理能力非常重要,以确保所有被摄取的数据都得到处理。在业务关键型流式应用程序中,大多数情况下,丢失哪怕一条数据都可能对业务产生巨大影响。为了处理这种情况,避免应用程序基础设施中的单点故障非常重要。
从 Spark Streaming 应用程序的角度来看,了解生态系统中底层组件的布局是很有好处的,这样就可以采取适当的措施来避免单点故障。
部署在 Hadoop YARN、Mesos 或 Spark Standalone 模式等集群中的 Spark Streaming 应用程序有两个主要组件,与任何其他类型的 Spark 应用程序非常相似:
-
Spark 驱动程序:这包含用户编写的应用程序代码
-
执行器:执行 Spark 驱动程序提交的作业的执行器
但是,执行器有一个额外的组件,称为接收器,它接收作为流输入的数据,并将其保存为内存中的数据块。当一个接收器正在接收数据并形成数据块时,它们会被复制到另一个执行器以实现容错。换句话说,数据块的内存复制是在不同的执行器上完成的。在每个批处理间隔结束时,这些数据块会被组合成一个 DStream,并输出以进行进一步的处理。
图 8展示了在集群中部署的 Spark Streaming 应用程序基础设施中协同工作的组件:

图 8
在图 8中,有两个执行器。接收组件在第二个执行器中故意没有显示,以表明它没有使用接收器,而是仅仅从另一个执行器收集复制的数据块。但是,当需要时,例如在第一个执行器失败的情况下,第二个执行器中的接收器可以开始工作。
在 Spark Streaming 数据处理应用程序中实现容错性
Spark Streaming 数据处理应用程序的基础设施有许多动态部分。任何一部分都可能发生故障,从而导致数据处理的中断。通常,故障可能发生在 Spark 驱动程序或执行器上。
注意
本节的目的不是详细说明在生产环境中运行具有容错能力的 Spark Streaming 应用程序,而是让读者了解在生产环境中部署 Spark Streaming 数据处理应用程序时应采取的预防措施。
当一个执行器失败时,由于数据复制是定期发生的,接收数据流的任务将由数据正在复制的执行器接管。有一种情况是,当一个执行器失败时,所有未处理的数据都将丢失。为了避免这个问题,有一种方法可以将数据块以预写日志的形式持久化到 HDFS 或 Amazon S3。
小贴士
在一个基础设施中不需要同时拥有数据块的内存复制和预写日志。根据需要,只保留其中之一。
当 Spark 驱动程序失败时,被驱动的程序会停止,所有执行器都会失去连接,并停止工作。这是最危险的情况。为了处理这种情况,需要进行一些配置和代码更改。
Spark 驱动程序必须配置为具有自动驱动程序重启功能,这由集群管理器支持。这包括更改 Spark 作业提交方法,以便在任何集群管理器中都具有集群模式。当驱动程序重启时,为了从崩溃的地方重新开始,驱动程序程序中必须实现一个检查点机制。这已经在使用的代码示例中完成。以下代码行执行这项任务:
ssc = StreamingContext(sc, 10)
ssc.checkpoint("\tmp")
小贴士
在一个示例应用中,使用本地系统目录作为检查点目录是可以的。但在生产环境中,如果使用 Hadoop,最好将此检查点目录保持在 HDFS 位置;如果使用亚马逊云,则保持在 S3 位置。
从应用程序编码的角度来看,创建StreamingContext的方式略有不同。不是每次都创建一个新的StreamingContext,而是应该使用一个函数与StreamingContext的工厂方法getOrCreate一起使用,如以下代码段所示。如果这样做,当驱动程序重启时,工厂方法将检查检查点目录以查看是否正在使用早期的StreamingContext,如果找到检查点数据,则创建它。否则,将创建一个新的StreamingContext。
以下代码片段给出了一个函数的定义,该函数可以与StreamingContext的getOrCreate工厂方法一起使用。如前所述,这些方面的详细处理超出了本书的范围:
/**
* The following function has to be used when the code is being restructured to have checkpointing and driver recovery
* The way it should be used is to use the StreamingContext.getOrCreate with this function and do a start of that
*/
def sscCreateFn(): StreamingContext = {
// Variables used for creating the Kafka stream
// The quorum of Zookeeper hosts
val zooKeeperQuorum = "localhost"
// Message group name
val messageGroup = "sfb-consumer-group"
//Kafka topics list separated by coma if there are multiple topics to be listened on
val topics = "sfb"
//Number of threads per topic
val numThreads = 1
// Create the Spark Session and the spark context
val spark = SparkSession
.builder
.appName(getClass.getSimpleName)
.getOrCreate()
// Get the Spark context from the Spark session for creating the streaming context
val sc = spark.sparkContext
// Create the streaming context
val ssc = new StreamingContext(sc, Seconds(10))
// Create the map of topic names
val topicMap = topics.split(",").map((_, numThreads.toInt)).toMap
// Create the Kafka stream
val appLogLines = KafkaUtils.createStream(ssc, zooKeeperQuorum, messageGroup, topicMap).map(_._2)
// Count each log messge line containing the word ERROR
val errorLines = appLogLines.filter(line => line.contains("ERROR"))
// Print the line containing the error
errorLines.print()
// Count the number of messages by the windows and print them
errorLines.countByWindow(Seconds(30), Seconds(10)).print()
// Set the check point directory for saving the data to recover when there is a crash
ssc.checkpoint("/tmp")
// Return the streaming context
ssc
}
在数据源级别,为了加快数据处理速度,构建并行性是一个好主意,并且根据数据源的不同,可以通过不同的方式实现。Kafka 在主题级别内建支持分区,这种扩展机制支持大量的并行性。作为 Kafka 主题的消费者,Spark Streaming 数据处理应用程序可以通过创建多个流来拥有多个接收器,并且这些流生成的数据可以通过在 Kafka 流上执行联合操作来合并。
Spark Streaming 数据处理应用程序的生产部署应完全基于所使用的应用程序类型。之前给出的某些指南只是介绍性和概念性的。没有一劳永逸的解决生产部署问题的方法,它们必须随着应用程序开发而发展。
结构化流
在迄今为止涵盖的数据流用例中,有许多关于构建结构化数据和实现应用程序容错性的开发任务。迄今为止在数据流应用程序中处理的数据是无结构化数据。就像批处理数据处理的用例一样,即使在流用例中,如果能够处理结构化数据,那将是一个巨大的优势,可以避免大量的预处理。数据流处理应用程序是持续运行的应用程序,它们注定会发展出故障或中断。在这种情况下,在数据流应用程序中构建容错性是至关重要的。
在任何数据流应用程序中,数据都在持续摄入,如果需要在任何给定时间点查询接收到的数据,应用开发者必须将处理过的数据持久化到支持查询的数据存储中。在 Spark 2.0 中,结构化流的概念围绕这些方面构建,而构建这个全新功能的整个理念是从根本上减轻应用开发者的这些痛点。在撰写本章时,正在构建一个具有参考编号 SPARK-8360 的功能,其进度可以通过访问相应的页面进行监控。
结构化流的概念可以通过一个现实世界的用例来解释,例如我们之前看过的银行交易用例。假设包含账户号码和交易金额的逗号分隔的交易记录正在以流的形式传入。在结构化流处理方法中,所有这些数据项都会被摄入到一个支持使用 Spark SQL 查询的无界表或 DataFrame 中。换句话说,由于数据累积在 DataFrame 中,使用 DataFrame 可以进行的数据处理也可以在流数据上进行。这减轻了应用开发者的负担,他们可以专注于应用程序的业务逻辑,而不是与基础设施相关的问题。
参考文献
更多信息,请访问以下链接:
摘要
Spark 在 Spark 核心之上提供了一个非常强大的库来处理以高速摄入的数据流。本章介绍了 Spark Streaming 库的基本知识,并开发了一个简单的日志事件消息处理系统,该系统使用了两种类型的数据源:一种使用 TCP 数据服务器,另一种使用 Kafka。本章末尾简要介绍了 Spark Streaming 数据处理应用程序的生产部署,并讨论了在 Spark Streaming 数据处理应用程序中实现容错性的可能方法。
Spark 2.0 引入了在流式应用程序中处理和查询结构化数据的能力,并引入了这一概念,从而减轻了应用开发者对非结构化数据进行预处理、构建容错性和查询近实时摄入数据的负担。
应用数学家和统计学家已经找到了基于对现有数据集已完成的学习来回答与新数据相关问题的方法和手段。通常这些问题包括但不限于:这块数据是否符合给定的模型,这块数据能否以某种方式分类,以及这块数据是否属于任何组或聚类?
可用于训练数据模型并向此模型询问关于新数据的各种算法很多。这一快速发展的数据科学分支在数据处理中具有巨大的应用性,通常被称为机器学习。下一章将讨论 Spark 的机器学习库。
第七章. Spark 机器学习
基于公式或算法的计算自古以来就被广泛用于找到给定输入的输出。但不知道公式或算法,计算机科学家和数学家设计了基于现有输入/输出数据集生成公式或算法的方法,并基于生成的公式或算法预测新输入数据的输出。通常,从数据集中学习并基于学习进行预测的过程被称为机器学习。机器学习起源于计算机科学中人工智能的研究。
实际的机器学习有众多应用,这些应用正被普通人在日常生活中所消费。YouTube 用户现在根据他们正在观看的视频获得播放列表中下一项内容的建议。流行的电影评分网站根据用户对电影类型的偏好给出评分和建议。社交媒体网站如 Facebook 建议用户朋友的名单,以便于图片的标记。Facebook 在这里所做的是根据现有相册中已有的名字对图片进行分类,并检查新添加的图片是否与现有的图片有任何相似之处。如果发现相似之处,它就会建议名字。这类图片识别的应用是多方面的。所有这些应用的工作方式都是基于已经收集的大量输入/输出数据集以及基于这些数据集所进行的学习。当新的输入数据集到达时,通过利用计算机或机器已经完成的学习来进行预测。
在本章中,我们将涵盖以下主题:
-
使用 Spark 进行机器学习
-
模型持久化
-
垃圾邮件过滤
-
特征算法
-
寻找同义词
理解机器学习
在传统计算中,输入数据被输入到程序中以生成输出。但在机器学习中,输入数据和输出数据被输入到机器学习算法中,以生成一个函数或程序,该函数或程序可以根据输入/输出数据集在机器学习算法中的学习来预测输入的输出。
在野外可用的数据可能被分为不同的组,可能形成簇,或者可能适合于某些关系。这些都是不同类型的机器学习问题。例如,如果有一个包含二手车销售价格及其相关属性或特征的数据库,那么仅通过了解相关的属性或特征,就有可能预测汽车的价格。回归算法用于解决这类问题。如果有一个包含垃圾邮件和非垃圾邮件的数据库,那么当一封新邮件到来时,就有可能预测这封新邮件是垃圾邮件还是非垃圾邮件。分类算法用于解决这类问题。
这些只是几种机器学习算法类型。但一般来说,当使用数据集时,如果需要应用机器学习算法并使用该模型进行预测,那么数据应该被划分为特征和输出。例如,在汽车价格预测问题中,价格是输出,以下是一些可能的数据特征:
-
汽车制造商
-
汽车型号
-
制造年份
-
油耗
-
燃料类型
-
变速箱类型
因此,无论使用哪种机器学习算法,都会有一组特征和一个或多个输出。
注意
许多书籍和出版物使用“标签”一词来指代输出。换句话说,“特征”是输入,“标签”是输出。
图 1 描述了机器学习算法如何对底层数据进行处理以实现预测。

图 1
数据以各种形状和形式存在。根据所使用的机器学习算法,训练数据必须进行预处理,以便将特征和标签以正确的格式提供给机器学习算法。这反过来又生成适当的理论函数,该函数以特征作为输入并产生预测标签。
小贴士
“假设”这个词的字典定义是基于有限证据提出的假设或解释,作为进一步调查的起点。在这里,由机器学习算法生成的函数或程序是基于提供给机器学习算法的有限证据,即训练数据,因此它通常被称为假设函数。
换句话说,这个假设函数不是一个始终如一地产生一致结果的确定函数,它是一个基于训练数据的函数。当将新的数据添加到训练数据集时,需要重新学习,那时甚至生成的假设函数也会相应地改变。
实际上,图 1 中给出的流程并不像看起来那么简单。一旦模型被训练,就必须对模型进行大量的测试,以测试已知标签的预测。训练和测试过程的链是一个迭代过程,在每次迭代中,算法的参数都会进行调整,以使预测质量更好。一旦模型产生了可接受的测试结果,该模型就可以转移到生产环境中进行实时预测需求。Spark 附带了一个功能丰富的机器学习库,使得实用的机器学习成为可能。
为什么选择 Spark 进行机器学习?
前几章详细介绍了 Spark 的各种数据处理功能。Spark 机器学习库使用了 Spark 核心功能以及 Spark 库,如 Spark SQL。Spark 机器学习库通过在一个节点集群上结合数据处理和机器学习算法实现,以及能够以统一框架读取和写入各种数据格式的能力,使得机器学习应用开发变得简单。
Spark 提供了机器学习库的两个版本。它们是spark.mllib和spark.ml。第一个是在 Spark 的 RDD 抽象之上开发的,第二个是在 Spark 的 DataFrame 抽象之上开发的。建议对于任何未来的机器学习应用开发,都使用 spark.ml 库。
本章将仅关注 spark.ml 机器学习库。以下列表解释了在本章中反复使用的术语和概念:
-
估计器:这是一个在包含特征和标签的 Spark DataFrame 上工作的算法。它使用 Spark DataFrame 中提供的数据进行训练,并创建一个模型。这个模型用于进行未来的预测。
-
转换器:这会将包含特征的 Spark DataFrame 转换成另一个包含预测的 Spark DataFrame。估计器创建的模型是一个转换器。
-
参数:这是由估计器和转换器使用的。通常,这取决于机器学习算法。Spark 机器学习库为算法指定正确的参数提供了一个统一的 API。
-
管道:这是一系列协同工作的估计器和转换器,形成一个机器学习工作流程。
所有这些新术语在理论层面上稍微有些难以理解,但如果给出一个例子,概念就会变得清晰得多。
葡萄酒质量预测
加州大学欧文分校机器学习库(archive.ics.uci.edu/ml/index.html)为对机器学习感兴趣的人提供大量数据集作为服务。这里使用的葡萄酒质量数据集(archive.ics.uci.edu/ml/datasets/Wine+Quality)用于展示一些机器学习应用。它包含来自葡萄牙的两种不同特征的白葡萄酒和红葡萄酒数据集。
注意
葡萄酒质量数据集的下载链接允许您下载红葡萄酒和白葡萄酒的数据集,作为两个单独的 CSV 文件。一旦下载了这些文件,编辑这两个数据集以删除包含列名的第一行标题。这是为了让程序能够无错误地解析数值数据。故意避免详细的错误处理和排除标题记录,以便专注于机器学习功能。
在这个红酒质量预测用例中,使用了包含红酒各种特征的 dataset。以下是该 dataset 的特征:
-
固定酸度
-
挥发性酸度
-
柠檬酸
-
残糖
-
氯化物
-
自由二氧化硫
-
总二氧化硫
-
密度
-
pH
-
硫酸盐
-
酒精
根据这些特征,确定质量(介于 0 到 10 之间的分数)。在这里,质量是此 dataset 的标签。使用此 dataset,将训练一个模型,然后使用训练好的模型进行测试和预测。这是一个回归问题。线性回归算法用于训练模型。线性回归算法生成一个线性假设函数。从数学的角度来看,线性函数是一阶或一阶以下的多项式。在这个机器学习应用用例中,它处理建模因变量(酒的质量)和一组自变量(酒的特征)之间的关系。
在 Scala REPL 提示符下,尝试以下语句:
scala> import org.apache.spark.ml.regression.LinearRegression
import org.apache.spark.ml.regression.LinearRegression
scala> import org.apache.spark.ml.param.ParamMap
import org.apache.spark.ml.param.ParamMap
scala> import org.apache.spark.ml.linalg.{Vector, Vectors}
import org.apache.spark.ml.linalg.{Vector, Vectors}
scala> import org.apache.spark.sql.Row
import org.apache.spark.sql.Row
scala> // TODO - Change this directory to the right location where the data
is stored
scala> val dataDir = "/Users/RajT/Downloads/wine-quality/"
dataDir: String = /Users/RajT/Downloads/wine-quality/
scala> // Define the case class that holds the wine data
scala> case class Wine(FixedAcidity: Double, VolatileAcidity: Double, CitricAcid: Double, ResidualSugar: Double, Chlorides: Double, FreeSulfurDioxide: Double, TotalSulfurDioxide: Double, Density: Double, PH: Double, Sulphates: Double, Alcohol: Double, Quality: Double)
defined class Wine
scala> // Create the the RDD by reading the wine data from the disk
scala> //TODO - The wine data has to be downloaded to the appropriate working directory in the system where this is being run and the following line of code should use that path
scala> val wineDataRDD = sc.textFile(dataDir + "winequality-red.csv").map(_.split(";")).map(w => Wine(w(0).toDouble, w(1).toDouble, w(2).toDouble, w(3).toDouble, w(4).toDouble, w(5).toDouble, w(6).toDouble, w(7).toDouble, w(8).toDouble, w(9).toDouble, w(10).toDouble, w(11).toDouble))
wineDataRDD: org.apache.spark.rdd.RDD[Wine] = MapPartitionsRDD[3] at map at <console>:32
scala> // Create the data frame containing the training data having two columns. 1) The actual output or label of the data 2) The vector containing the features
scala> //Vector is a data type with 0 based indices and double-typed values. In that there are two types namely dense and sparse.
scala> //A dense vector is backed by a double array representing its entry values
scala> //A sparse vector is backed by two parallel arrays: indices and values
scala> val trainingDF = wineDataRDD.map(w => (w.Quality, Vectors.dense(w.FixedAcidity, w.VolatileAcidity, w.CitricAcid, w.ResidualSugar, w.Chlorides, w.FreeSulfurDioxide, w.TotalSulfurDioxide, w.Density, w.PH, w.Sulphates, w.Alcohol))).toDF("label", "features")
trainingDF: org.apache.spark.sql.DataFrame = [label: double, features: vector]
scala> trainingDF.show()
+-----+--------------------+
|label| features|
+-----+--------------------+
| 5.0|[7.4,0.7,0.0,1.9,...|
| 5.0|[7.8,0.88,0.0,2.6...|
| 5.0|[7.8,0.76,0.04,2....|
| 6.0|[11.2,0.28,0.56,1...|
| 5.0|[7.4,0.7,0.0,1.9,...|
| 5.0|[7.4,0.66,0.0,1.8...|
| 5.0|[7.9,0.6,0.06,1.6...|
| 7.0|[7.3,0.65,0.0,1.2...|
| 7.0|[7.8,0.58,0.02,2....|
| 5.0|[7.5,0.5,0.36,6.1...|
| 5.0|[6.7,0.58,0.08,1....|
| 5.0|[7.5,0.5,0.36,6.1...|
| 5.0|[5.6,0.615,0.0,1....|
| 5.0|[7.8,0.61,0.29,1....|
| 5.0|[8.9,0.62,0.18,3....|
| 5.0|[8.9,0.62,0.19,3....|
| 7.0|[8.5,0.28,0.56,1....|
| 5.0|[8.1,0.56,0.28,1....|
| 4.0|[7.4,0.59,0.08,4....|
| 6.0|[7.9,0.32,0.51,1....|
+-----+--------------------+
only showing top 20 rows
scala> // Create the object of the algorithm which is the Linear Regression
scala> val lr = new LinearRegression()
lr: org.apache.spark.ml.regression.LinearRegression = linReg_f810f0c1617b
scala> // Linear regression parameter to make lr.fit() use at most 10 iterations
scala> lr.setMaxIter(10)
res1: lr.type = linReg_f810f0c1617b
scala> // Create a trained model by fitting the parameters using the training data
scala> val model = lr.fit(trainingDF)
model: org.apache.spark.ml.regression.LinearRegressionModel = linReg_f810f0c1617b
scala> // Once the model is prepared, to test the model, prepare the test data containing the labels and feature vectors
scala> val testDF = spark.createDataFrame(Seq((5.0, Vectors.dense(7.4, 0.7, 0.0, 1.9, 0.076, 25.0, 67.0, 0.9968, 3.2, 0.68,9.8)),(5.0, Vectors.dense(7.8, 0.88, 0.0, 2.6, 0.098, 11.0, 34.0, 0.9978, 3.51, 0.56, 9.4)),(7.0, Vectors.dense(7.3, 0.65, 0.0, 1.2, 0.065, 15.0, 18.0, 0.9968, 3.36, 0.57, 9.5)))).toDF("label", "features")
testDF: org.apache.spark.sql.DataFrame = [label: double, features: vector]
scala> testDF.show()
+-----+--------------------+
|label| features|
+-----+--------------------+
| 5.0|[7.4,0.7,0.0,1.9,...|
| 5.0|[7.8,0.88,0.0,2.6...|
| 7.0|[7.3,0.65,0.0,1.2...|
+-----+--------------------+
scala> testDF.createOrReplaceTempView("test")scala> // Do the transformation of the test data using the model and predict the output values or lables. This is to compare the predicted value and the actual label value
scala> val tested = model.transform(testDF).select("features", "label", "prediction")
tested: org.apache.spark.sql.DataFrame = [features: vector, label: double ... 1 more field]
scala> tested.show()
+--------------------+-----+-----------------+
| features|label| prediction|
+--------------------+-----+-----------------+
|[7.4,0.7,0.0,1.9,...| 5.0|5.352730835898477|
|[7.8,0.88,0.0,2.6...| 5.0|4.817999362011964|
|[7.3,0.65,0.0,1.2...| 7.0|5.280106355653388|
+--------------------+-----+-----------------+
scala> // Prepare a dataset without the output/lables to predict the output using the trained model
scala> val predictDF = spark.sql("SELECT features FROM test")predictDF: org.apache.spark.sql.DataFrame = [features: vector]
scala> predictDF.show()
+--------------------+
| features|
+--------------------+
|[7.4,0.7,0.0,1.9,...|
|[7.8,0.88,0.0,2.6...|
|[7.3,0.65,0.0,1.2...|
+--------------------+
scala> // Do the transformation with the predict dataset and display the predictions
scala> val predicted = model.transform(predictDF).select("features", "prediction")
predicted: org.apache.spark.sql.DataFrame = [features: vector, prediction: double]
scala> predicted.show()
+--------------------+-----------------+
| features| prediction|
+--------------------+-----------------+
|7.4,0.7,0.0,1.9,...|5.352730835898477|
|[7.8,0.88,0.0,2.6...|4.817999362011964|
|[7.3,0.65,0.0,1.2...|5.280106355653388|
+--------------------+-----------------+
scala> //IMPORTANT - To continue with the model persistence coming in the next section, keep this session on.
前面的代码做了很多事情。它在 pipeline 中执行以下一系列活动:
-
它从数据文件中读取红酒数据,形成一个训练 DataFrame。
-
然后它创建了一个
LinearRegression对象并设置了参数。 -
它使用训练数据拟合模型,这完成了估计器 pipeline。
-
它创建了一个包含测试数据的 DataFrame。通常,测试数据将包含特征和标签。这是为了确保模型是正确的,并且用于比较预测标签和实际标签。
-
使用创建的模型,它对测试数据进行转换,并从生成的 DataFrame 中提取特征、输入标签和预测。请注意,在模型进行转换时,标签不是必需的。换句话说,标签根本不会被使用。
-
使用创建的模型,它对预测数据进行转换,并从生成的 DataFrame 中提取特征和预测。请注意,在模型进行转换时,标签没有被使用。换句话说,在预测时没有使用标签。这完成了一个转换器 pipeline。
小贴士
前面的代码片段中的 pipelines 是单阶段 pipelines,因此不需要使用 Pipeline 对象。多阶段 pipelines 将在接下来的章节中讨论。
在实际应用中,拟合/测试阶段会迭代重复,直到模型在预测时给出期望的结果。图 2 阐述了通过代码演示的 pipeline 概念:
![红酒质量预测
图 2
以下代码使用 Python 演示了相同的用例。在 Python REPL 提示符下,尝试以下语句:
>>> from pyspark.ml.linalg import Vectors
>>> from pyspark.ml.regression import LinearRegression
>>> from pyspark.ml.param import Param, Params
>>> from pyspark.sql import Row
>>> # TODO - Change this directory to the right location where the data is stored
>>> dataDir = "/Users/RajT/Downloads/wine-quality/"
>>> # Create the the RDD by reading the wine data from the disk
>>> lines = sc.textFile(dataDir + "winequality-red.csv")
>>> splitLines = lines.map(lambda l: l.split(";"))
>>> # Vector is a data type with 0 based indices and double-typed values. In that there are two types namely dense and sparse.
>>> # A dense vector is backed by a double array representing its entry values
>>> # A sparse vector is backed by two parallel arrays: indices and values
>>> wineDataRDD = splitLines.map(lambda p: (float(p[11]), Vectors.dense([float(p[0]), float(p[1]), float(p[2]), float(p[3]), float(p[4]), float(p[5]), float(p[6]), float(p[7]), float(p[8]), float(p[9]), float(p[10])])))
>>> # Create the data frame containing the training data having two columns. 1) The actula output or label of the data 2) The vector containing the features
>>> trainingDF = spark.createDataFrame(wineDataRDD, ['label', 'features'])
>>> trainingDF.show()
+-----+--------------------+
|label| features|
+-----+--------------------+
| 5.0|[7.4,0.7,0.0,1.9,...|
| 5.0|[7.8,0.88,0.0,2.6...|
| 5.0|[7.8,0.76,0.04,2....|
| 6.0|[11.2,0.28,0.56,1...|
| 5.0|[7.4,0.7,0.0,1.9,...|
| 5.0|[7.4,0.66,0.0,1.8...|
| 5.0|[7.9,0.6,0.06,1.6...|
| 7.0|[7.3,0.65,0.0,1.2...|
| 7.0|[7.8,0.58,0.02,2....|
| 5.0|[7.5,0.5,0.36,6.1...|
| 5.0|[6.7,0.58,0.08,1....|
| 5.0|[7.5,0.5,0.36,6.1...|
| 5.0|[5.6,0.615,0.0,1....|
| 5.0|[7.8,0.61,0.29,1....|
| 5.0|[8.9,0.62,0.18,3....|
| 5.0|[8.9,0.62,0.19,3....|
| 7.0|[8.5,0.28,0.56,1....|
| 5.0|[8.1,0.56,0.28,1....|
| 4.0|[7.4,0.59,0.08,4....|
| 6.0|[7.9,0.32,0.51,1....|
+-----+--------------------+
only showing top 20 rows
>>> # Create the object of the algorithm which is the Linear Regression with the parameters
>>> # Linear regression parameter to make lr.fit() use at most 10 iterations
>>> lr = LinearRegression(maxIter=10)
>>> # Create a trained model by fitting the parameters using the training data
>>> model = lr.fit(trainingDF)
>>> # Once the model is prepared, to test the model, prepare the test data containing the labels and feature vectors
>>> testDF = spark.createDataFrame([(5.0, Vectors.dense([7.4, 0.7, 0.0, 1.9, 0.076, 25.0, 67.0, 0.9968, 3.2, 0.68,9.8])),(5.0,Vectors.dense([7.8, 0.88, 0.0, 2.6, 0.098, 11.0, 34.0, 0.9978, 3.51, 0.56, 9.4])),(7.0, Vectors.dense([7.3, 0.65, 0.0, 1.2, 0.065, 15.0, 18.0, 0.9968, 3.36, 0.57, 9.5]))], ["label", "features"])
>>> testDF.createOrReplaceTempView("test")
>>> testDF.show()
+-----+--------------------+
|label| features|
+-----+--------------------+
| 5.0|[7.4,0.7,0.0,1.9,...|
| 5.0|[7.8,0.88,0.0,2.6...|
| 7.0|[7.3,0.65,0.0,1.2...|
+-----+--------------------+
>>> # Do the transformation of the test data using the model and predict the output values or lables. This is to compare the predicted value and the actual label value
>>> testTransform = model.transform(testDF)
>>> tested = testTransform.select("features", "label", "prediction")
>>> tested.show()
+--------------------+-----+-----------------+
| features|label| prediction|
+--------------------+-----+-----------------+
|[7.4,0.7,0.0,1.9,...| 5.0|5.352730835898477|
|[7.8,0.88,0.0,2.6...| 5.0|4.817999362011964|
|[7.3,0.65,0.0,1.2...| 7.0|5.280106355653388|
+--------------------+-----+-----------------+
>>> # Prepare a dataset without the output/lables to predict the output using the trained model
>>> predictDF = spark.sql("SELECT features FROM test")
>>> predictDF.show()
+--------------------+
| features|
+--------------------+
|[7.4,0.7,0.0,1.9,...|
|[7.8,0.88,0.0,2.6...|
|[7.3,0.65,0.0,1.2...|
+--------------------+
>>> # Do the transformation with the predict dataset and display the predictions
>>> predictTransform = model.transform(predictDF)
>>> predicted = predictTransform.select("features", "prediction")
>>> predicted.show()
+--------------------+-----------------+
| features| prediction|
+--------------------+-----------------+
|[7.4,0.7,0.0,1.9,...|5.352730835898477|
|[7.8,0.88,0.0,2.6...|4.817999362011964|
|[7.3,0.65,0.0,1.2...|5.280106355653388|
+--------------------+-----------------+
>>> #IMPORTANT - To continue with the model persistence coming in the next section, keep this session on.
如前所述,线性回归是统计模型,是建模两种类型变量之间关系的方法。一种是自变量,另一种是因变量。因变量由自变量计算得出。在许多情况下,如果只有一个自变量,则回归将是简单线性回归。但在现实中,在实际的实际应用中,将存在大量的自变量,就像在葡萄酒数据集中一样。这属于多元线性回归的情况。这不应与多元线性回归混淆。在多元回归中,预测多个相关因变量。
在这里讨论的用例中,预测仅针对一个变量,即葡萄酒的质量,因此它是一个多元线性回归问题,而不是多元线性回归问题。一些学校甚至将多元线性回归视为一元线性回归。换句话说,无论独立变量的数量如何,如果只有一个因变量,则称为一元线性回归。
模型持久性
Spark 2.0 具有轻松跨编程语言保存和加载机器学习模型的能力。换句话说,你可以在 Scala 中创建机器学习模型,并在 Python 中加载它。这允许我们在一个系统中创建模型,保存它,复制它,并在其他系统中使用它。继续使用相同的 Scala REPL 提示符,尝试以下语句:
scala> // Assuming that the model definition line "val model =
lr.fit(trainingDF)" is still in context
scala> import org.apache.spark.ml.regression.LinearRegressionModel
import org.apache.spark.ml.regression.LinearRegressionModel
scala> model.save("wineLRModelPath")
scala> val newModel = LinearRegressionModel.load("wineLRModelPath")
newModel: org.apache.spark.ml.regression.LinearRegressionModel =
linReg_6a880215ab96
现在可以像原始模型一样使用加载的模型进行测试或预测。继续使用相同的 Python REPL 提示符,尝试以下语句来加载使用 Scala 程序保存的模型:
>>> from pyspark.ml.regression import LinearRegressionModel
>>> newModel = LinearRegressionModel.load("wineLRModelPath")
>>> newPredictTransform = newModel.transform(predictDF)
>>> newPredicted = newPredictTransform.select("features", "prediction")
>>> newPredicted.show()
+--------------------+-----------------+
| features| prediction|
+--------------------+-----------------+
|[7.4,0.7,0.0,1.9,...|5.352730835898477|
|[7.8,0.88,0.0,2.6...|4.817999362011964|
|[7.3,0.65,0.0,1.2...|5.280106355653388|
+--------------------+-----------------+
葡萄酒分类
在这个葡萄酒质量分类用例中,使用了包含白葡萄酒各种特征的数据库。以下是数据库的特征:
-
固定酸度
-
挥发性酸度
-
柠檬酸
-
残糖
-
氯化物
-
自由二氧化硫
-
总二氧化硫
-
密度
-
pH 值
-
硫酸盐
-
酒精
根据这些特征,质量(介于 0 到 10 之间的分数)被确定。如果质量小于 7,则将其分类为差,标签赋值为 0。如果质量为 7 或以上,则将其分类为好,标签赋值为 1。换句话说,分类值是此数据集的标签。使用此数据集,将训练一个模型,然后使用训练好的模型进行测试和预测。这是一个分类问题。使用逻辑回归算法来训练模型。在这个机器学习应用用例中,它处理建模因变量(葡萄酒质量)和一组自变量(葡萄酒的特征)之间的关系。在 Scala REPL 提示符下,尝试以下语句:
scala> import org.apache.spark.ml.classification.LogisticRegression
import org.apache.spark.ml.classification.LogisticRegression
scala> import org.apache.spark.ml.param.ParamMap
import org.apache.spark.ml.param.ParamMap
scala> import org.apache.spark.ml.linalg.{Vector, Vectors}
import org.apache.spark.ml.linalg.{Vector, Vectors}
scala> import org.apache.spark.sql.Row
import org.apache.spark.sql.Row
scala> // TODO - Change this directory to the right location where the data is stored
scala> val dataDir = "/Users/RajT/Downloads/wine-quality/"
dataDir: String = /Users/RajT/Downloads/wine-quality/
scala> // Define the case class that holds the wine data
scala> case class Wine(FixedAcidity: Double, VolatileAcidity: Double, CitricAcid: Double, ResidualSugar: Double, Chlorides: Double, FreeSulfurDioxide: Double, TotalSulfurDioxide: Double, Density: Double, PH: Double, Sulphates: Double, Alcohol: Double, Quality: Double)
defined class Wine
scala> // Create the the RDD by reading the wine data from the disk
scala> val wineDataRDD = sc.textFile(dataDir + "winequality-white.csv").map(_.split(";")).map(w => Wine(w(0).toDouble, w(1).toDouble, w(2).toDouble, w(3).toDouble, w(4).toDouble, w(5).toDouble, w(6).toDouble, w(7).toDouble, w(8).toDouble, w(9).toDouble, w(10).toDouble, w(11).toDouble))
wineDataRDD: org.apache.spark.rdd.RDD[Wine] = MapPartitionsRDD[35] at map at <console>:36
scala> // Create the data frame containing the training data having two columns. 1) The actula output or label of the data 2) The vector containing the features
scala> val trainingDF = wineDataRDD.map(w => (if(w.Quality < 7) 0D else 1D, Vectors.dense(w.FixedAcidity, w.VolatileAcidity, w.CitricAcid, w.ResidualSugar, w.Chlorides, w.FreeSulfurDioxide, w.TotalSulfurDioxide, w.Density, w.PH, w.Sulphates, w.Alcohol))).toDF("label", "features")
trainingDF: org.apache.spark.sql.DataFrame = [label: double, features: vector]
scala> trainingDF.show()
+-----+--------------------+
|label| features|
+-----+--------------------+
| 0.0|[7.0,0.27,0.36,20...|
| 0.0|[6.3,0.3,0.34,1.6...|
| 0.0|[8.1,0.28,0.4,6.9...|
| 0.0|[7.2,0.23,0.32,8....|
| 0.0|[7.2,0.23,0.32,8....|
| 0.0|[8.1,0.28,0.4,6.9...|
| 0.0|[6.2,0.32,0.16,7....|
| 0.0|[7.0,0.27,0.36,20...|
| 0.0|[6.3,0.3,0.34,1.6...|
| 0.0|[8.1,0.22,0.43,1....|
| 0.0|[8.1,0.27,0.41,1....|
| 0.0|[8.6,0.23,0.4,4.2...|
| 0.0|[7.9,0.18,0.37,1....|
| 1.0|[6.6,0.16,0.4,1.5...|
| 0.0|[8.3,0.42,0.62,19...|
| 1.0|[6.6,0.17,0.38,1....|
| 0.0|[6.3,0.48,0.04,1....|
| 1.0|[6.2,0.66,0.48,1....|
| 0.0|[7.4,0.34,0.42,1....|
| 0.0|[6.5,0.31,0.14,7....|
+-----+--------------------+
only showing top 20 rows
scala> // Create the object of the algorithm which is the Logistic Regression
scala> val lr = new LogisticRegression()
lr: org.apache.spark.ml.classification.LogisticRegression = logreg_a7e219daf3e1
scala> // LogisticRegression parameter to make lr.fit() use at most 10 iterations and the regularization parameter.
scala> // When a higher degree polynomial used by the algorithm to fit a set of points in a linear regression model, to prevent overfitting, regularization is used and this parameter is just for that
scala> lr.setMaxIter(10).setRegParam(0.01)
res8: lr.type = logreg_a7e219daf3e1
scala> // Create a trained model by fitting the parameters using the training data
scala> val model = lr.fit(trainingDF)
model: org.apache.spark.ml.classification.LogisticRegressionModel = logreg_a7e219daf3e1
scala> // Once the model is prepared, to test the model, prepare the test data containing the labels and feature vectors
scala> val testDF = spark.createDataFrame(Seq((1.0, Vectors.dense(6.1,0.32,0.24,1.5,0.036,43,140,0.9894,3.36,0.64,10.7)),(0.0, Vectors.dense(5.2,0.44,0.04,1.4,0.036,38,124,0.9898,3.29,0.42,12.4)),(0.0, Vectors.dense(7.2,0.32,0.47,5.1,0.044,19,65,0.9951,3.38,0.36,9)),(0.0,Vectors.dense(6.4,0.595,0.14,5.2,0.058,15,97,0.991,3.03,0.41,12.6)))).toDF("label", "features")
testDF: org.apache.spark.sql.DataFrame = [label: double, features: vector]
scala> testDF.show()
+-----+--------------------+
|label| features|
+-----+--------------------+
| 1.0|[6.1,0.32,0.24,1....|
| 0.0|[5.2,0.44,0.04,1....|
| 0.0|[7.2,0.32,0.47,5....|
| 0.0|[6.4,0.595,0.14,5...|
+-----+--------------------+
scala> testDF.createOrReplaceTempView("test")
scala> // Do the transformation of the test data using the model and predict the output values or labels. This is to compare the predicted value and the actual label value
scala> val tested = model.transform(testDF).select("features", "label", "prediction")
tested: org.apache.spark.sql.DataFrame = [features: vector, label: double ... 1 more field]
scala> tested.show()
+--------------------+-----+----------+
| features|label|prediction|
+--------------------+-----+----------+
|[6.1,0.32,0.24,1....| 1.0| 0.0|
|[5.2,0.44,0.04,1....| 0.0| 0.0|
|[7.2,0.32,0.47,5....| 0.0| 0.0|
|[6.4,0.595,0.14,5...| 0.0| 0.0|
+--------------------+-----+----------+
scala> // Prepare a dataset without the output/lables to predict the output using the trained model
scala> val predictDF = spark.sql("SELECT features FROM test")
predictDF: org.apache.spark.sql.DataFrame = [features: vector]
scala> predictDF.show()
+--------------------+
| features|
+--------------------+
|[6.1,0.32,0.24,1....|
|[5.2,0.44,0.04,1....|
|[7.2,0.32,0.47,5....|
|[6.4,0.595,0.14,5...|
+--------------------+
scala> // Do the transformation with the predict dataset and display the predictions
scala> val predicted = model.transform(predictDF).select("features", "prediction")
predicted: org.apache.spark.sql.DataFrame = [features: vector, prediction: double]
scala> predicted.show()
+--------------------+----------+
| features|prediction|
+--------------------+----------+
|[6.1,0.32,0.24,1....| 0.0|
|[5.2,0.44,0.04,1....| 0.0|
|[7.2,0.32,0.47,5....| 0.0|
|[6.4,0.595,0.14,5...| 0.0|
+--------------------+----------+
上述代码片段与线性回归用例的工作方式完全相同,只是这里使用的模型不同。这里使用的模型是逻辑回归,其标签只取两个值,0 和 1。创建模型、测试模型以及预测在这里都是相似的。换句话说,管道看起来非常相似。
下面的代码演示了使用 Python 实现相同用例。在 Python 交互式命令行提示符下,尝试以下语句:
>>> from pyspark.ml.linalg import Vectors
>>> from pyspark.ml.classification import LogisticRegression
>>> from pyspark.ml.param import Param, Params
>>> from pyspark.sql import Row
>>> # TODO - Change this directory to the right location where the data is stored
>>> dataDir = "/Users/RajT/Downloads/wine-quality/"
>>> # Create the the RDD by reading the wine data from the disk
>>> lines = sc.textFile(dataDir + "winequality-white.csv")
>>> splitLines = lines.map(lambda l: l.split(";"))
>>> wineDataRDD = splitLines.map(lambda p: (float(0) if (float(p[11]) < 7) else float(1), Vectors.dense([float(p[0]), float(p[1]), float(p[2]), float(p[3]), float(p[4]), float(p[5]), float(p[6]), float(p[7]), float(p[8]), float(p[9]), float(p[10])])))
>>> # Create the data frame containing the training data having two columns. 1) The actula output or label of the data 2) The vector containing the features
>>> trainingDF = spark.createDataFrame(wineDataRDD, ['label', 'features'])
>>> trainingDF.show()
+-----+--------------------+
|label| features|
+-----+--------------------+
| 0.0|[7.0,0.27,0.36,20...|
| 0.0|[6.3,0.3,0.34,1.6...|
| 0.0|[8.1,0.28,0.4,6.9...|
| 0.0|[7.2,0.23,0.32,8....|
| 0.0|[7.2,0.23,0.32,8....|
| 0.0|[8.1,0.28,0.4,6.9...|
| 0.0|[6.2,0.32,0.16,7....|
| 0.0|[7.0,0.27,0.36,20...|
| 0.0|[6.3,0.3,0.34,1.6...|
| 0.0|[8.1,0.22,0.43,1....|
| 0.0|[8.1,0.27,0.41,1....|
| 0.0|[8.6,0.23,0.4,4.2...|
| 0.0|[7.9,0.18,0.37,1....|
| 1.0|[6.6,0.16,0.4,1.5...|
| 0.0|[8.3,0.42,0.62,19...|
| 1.0|[6.6,0.17,0.38,1....|
| 0.0|[6.3,0.48,0.04,1....|
| 1.0|[6.2,0.66,0.48,1....|
| 0.0|[7.4,0.34,0.42,1....|
| 0.0|[6.5,0.31,0.14,7....|
+-----+--------------------+
only showing top 20 rows
>>> # Create the object of the algorithm which is the Logistic Regression with the parameters
>>> # LogisticRegression parameter to make lr.fit() use at most 10 iterations and the regularization parameter.
>>> # When a higher degree polynomial used by the algorithm to fit a set of points in a linear regression model, to prevent overfitting, regularization is used and this parameter is just for that
>>> lr = LogisticRegression(maxIter=10, regParam=0.01)
>>> # Create a trained model by fitting the parameters using the training data>>> model = lr.fit(trainingDF)
>>> # Once the model is prepared, to test the model, prepare the test data containing the labels and feature vectors
>>> testDF = spark.createDataFrame([(1.0, Vectors.dense([6.1,0.32,0.24,1.5,0.036,43,140,0.9894,3.36,0.64,10.7])),(0.0, Vectors.dense([5.2,0.44,0.04,1.4,0.036,38,124,0.9898,3.29,0.42,12.4])),(0.0, Vectors.dense([7.2,0.32,0.47,5.1,0.044,19,65,0.9951,3.38,0.36,9])),(0.0, Vectors.dense([6.4,0.595,0.14,5.2,0.058,15,97,0.991,3.03,0.41,12.6]))], ["label", "features"])
>>> testDF.createOrReplaceTempView("test")
>>> testDF.show()
+-----+--------------------+
|label| features|
+-----+--------------------+
| 1.0|[6.1,0.32,0.24,1....|
| 0.0|[5.2,0.44,0.04,1....|
| 0.0|[7.2,0.32,0.47,5....|
| 0.0|[6.4,0.595,0.14,5...|
+-----+--------------------+
>>> # Do the transformation of the test data using the model and predict the output values or lables. This is to compare the predicted value and the actual label value
>>> testTransform = model.transform(testDF)
>>> tested = testTransform.select("features", "label", "prediction")
>>> tested.show()
+--------------------+-----+----------+
| features|label|prediction|
+--------------------+-----+----------+
|[6.1,0.32,0.24,1....| 1.0| 0.0|
|[5.2,0.44,0.04,1....| 0.0| 0.0|
|[7.2,0.32,0.47,5....| 0.0| 0.0|
|[6.4,0.595,0.14,5...| 0.0| 0.0|
+--------------------+-----+----------+
>>> # Prepare a dataset without the output/lables to predict the output using the trained model
>>> predictDF = spark.sql("SELECT features FROM test")
>>> predictDF.show()
+--------------------+
| features|
+--------------------+
|[6.1,0.32,0.24,1....|
|[5.2,0.44,0.04,1....|
|[7.2,0.32,0.47,5....|
|[6.4,0.595,0.14,5...|
+--------------------+
>>> # Do the transformation with the predict dataset and display the predictions
>>> predictTransform = model.transform(predictDF)
>>> predicted = testTransform.select("features", "prediction")
>>> predicted.show()
+--------------------+----------+
| features|prediction|
+--------------------+----------+
|[6.1,0.32,0.24,1....| 0.0|
|[5.2,0.44,0.04,1....| 0.0|
|[7.2,0.32,0.47,5....| 0.0|
|[6.4,0.595,0.14,5...| 0.0|
+--------------------+----------+
逻辑回归与线性回归非常相似。逻辑回归的主要区别在于其因变量是一个分类变量。换句话说,因变量只取一组选定的值。在这个用例中,值是 0 或 1。值 0 表示酒的质量差,值 1 表示酒的质量好。更精确地说,在这里使用的因变量是一个二元因变量。
到目前为止,所涵盖的用例只有少数几个特征。但在现实世界的用例中,特征的数目将会非常大,尤其是在机器学习用例中,那里进行了大量的文本处理。下一节将讨论这样一个用例。
垃圾邮件过滤
垃圾邮件过滤是一个非常常见的用例,在许多应用程序中使用。它在电子邮件应用程序中无处不在。它是应用最广泛的分类问题之一。在一个典型的邮件服务器中,处理了大量的电子邮件。垃圾邮件过滤是在电子邮件发送到收件人的邮箱之前进行的。对于任何机器学习算法,在做出预测之前必须先训练模型。要训练模型,需要训练数据。如何收集训练数据?一种简单的方法是用户自己将收到的某些电子邮件标记为垃圾邮件。使用邮件服务器中的所有电子邮件作为训练数据,并定期刷新模型。这包括垃圾邮件和非垃圾邮件。当模型有这两种类型电子邮件的良好样本时,预测将会很准确。
这里介绍的垃圾邮件过滤用例不是一个完整的、可用于生产的应用程序,但它很好地说明了如何构建一个。在这里,为了简化,而不是使用整个电子邮件的文本,只使用一行。如果要将此扩展到处理真实电子邮件,而不是单个字符串,则读取整个电子邮件的内容到一个字符串中,并按照此应用程序中给出的逻辑进行操作。
与本章前面用例中涵盖的数值特征不同,这里的输入是纯文本,选择特征不像那些用例那样容易。行被分割成单词以形成词袋,单词被选为特征。由于处理数值特征很容易,这些单词被转换成哈希词频向量。换句话说,行中的单词或术语序列被转换为它们的词频,使用哈希方法。因此,即使在小型文本处理用例中,也会有数千个特征。这就是为什么它们需要被哈希以便于比较。
如前所述,在典型的机器学习应用中,输入数据需要经过大量的预处理,以便将其转换为模型所需的特征和标签的正确形式。这通常形成了一个转换和估计的管道。在这个用例中,输入行被分割成单词,然后使用 HashingTF 算法对这些单词进行转换,并在预测之前训练一个 LogisticRegression 模型。这是通过 Spark 机器学习库中的 Pipeline 抽象来完成的。在 Scala REPL 提示符下,尝试以下语句:
scala> import org.apache.spark.ml.classification.LogisticRegression
import org.apache.spark.ml.classification.LogisticRegression
scala> import org.apache.spark.ml.param.ParamMap
import org.apache.spark.ml.param.ParamMap
scala> import org.apache.spark.ml.linalg.{Vector, Vectors}
import org.apache.spark.ml.linalg.{Vector, Vectors}
scala> import org.apache.spark.sql.Row
import org.apache.spark.sql.Row
scala> import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.Pipeline
scala> import org.apache.spark.ml.feature.{HashingTF, Tokenizer, RegexTokenizer, Word2Vec, StopWordsRemover}
import org.apache.spark.ml.feature.{HashingTF, Tokenizer, RegexTokenizer, Word2Vec, StopWordsRemover}
scala> // Prepare training documents from a list of messages from emails used to filter them as spam or not spam
scala> // If the original message is a spam then the label is 1 and if the message is genuine then the label is 0
scala> val training = spark.createDataFrame(Seq(("you@example.com", "hope you are well", 0.0),("raj@example.com", "nice to hear from you", 0.0),("thomas@example.com", "happy holidays", 0.0),("mark@example.com", "see you tomorrow", 0.0),("xyz@example.com", "save money", 1.0),("top10@example.com", "low interest rate", 1.0),("marketing@example.com", "cheap loan", 1.0))).toDF("email", "message", "label")
training: org.apache.spark.sql.DataFrame = [email: string, message: string ... 1 more field]
scala> training.show()
+--------------------+--------------------+-----+
| email| message|label|
+--------------------+--------------------+-----+
| you@example.com| hope you are well| 0.0|
| raj@example.com|nice to hear from...| 0.0|
| thomas@example.com| happy holidays| 0.0|
| mark@example.com| see you tomorrow| 0.0|
| xyz@example.com| save money| 1.0|
| top10@example.com| low interest rate| 1.0|
|marketing@example...| cheap loan| 1.0|
+--------------------+--------------------+-----+
scala> // Configure an Spark machine learning pipeline, consisting of three stages: tokenizer, hashingTF, and lr.
scala> val tokenizer = new Tokenizer().setInputCol("message").setOutputCol("words")
tokenizer: org.apache.spark.ml.feature.Tokenizer = tok_166809bf629c
scala> val hashingTF = new HashingTF().setNumFeatures(1000).setInputCol("words").setOutputCol("features")
hashingTF: org.apache.spark.ml.feature.HashingTF = hashingTF_e43616e13d19
scala> // LogisticRegression parameter to make lr.fit() use at most 10 iterations and the regularization parameter.
scala> // When a higher degree polynomial used by the algorithm to fit a set of points in a linear regression model, to prevent overfitting, regularization is used and this parameter is just for that
scala> val lr = new LogisticRegression().setMaxIter(10).setRegParam(0.01)
lr: org.apache.spark.ml.classification.LogisticRegression = logreg_ef3042fc75a3
scala> val pipeline = new Pipeline().setStages(Array(tokenizer, hashingTF, lr))
pipeline: org.apache.spark.ml.Pipeline = pipeline_658b5edef0f2
scala> // Fit the pipeline to train the model to study the messages
scala> val model = pipeline.fit(training)
model: org.apache.spark.ml.PipelineModel = pipeline_658b5edef0f2
scala> // Prepare messages for prediction, which are not categorized and leaving upto the algorithm to predict
scala> val test = spark.createDataFrame(Seq(("you@example.com", "how are you"),("jain@example.com", "hope doing well"),("caren@example.com", "want some money"),("zhou@example.com", "secure loan"),("ted@example.com","need loan"))).toDF("email", "message")
test: org.apache.spark.sql.DataFrame = [email: string, message: string]
scala> test.show()
+-----------------+---------------+
| email| message|
+-----------------+---------------+
| you@example.com| how are you|
| jain@example.com|hope doing well|
|caren@example.com|want some money|
| zhou@example.com| secure loan|
| ted@example.com| need loan|
+-----------------+---------------+
scala> // Make predictions on the new messages
scala> val prediction = model.transform(test).select("email", "message", "prediction")
prediction: org.apache.spark.sql.DataFrame = [email: string, message: string ... 1 more field]
scala> prediction.show()
+-----------------+---------------+----------+
| email| message|prediction|
+-----------------+---------------+----------+
| you@example.com| how are you| 0.0|
| jain@example.com|hope doing well| 0.0|
|caren@example.com|want some money| 1.0|
| zhou@example.com| secure loan| 1.0|
| ted@example.com| need loan| 1.0|
+-----------------+---------------+----------+
以下代码片段执行了典型的活动链:准备训练数据,使用 Pipeline 抽象创建模型,然后使用测试数据进行预测。它没有揭示特征是如何创建和处理的。从应用开发的角度来看,Spark 机器学习库承担了繁重的工作,并使用 Pipeline 抽象在幕后完成所有工作。如果不使用 Pipeline 方法,那么标记化和哈希化将作为单独的 DataFrame 转换来完成。以下代码片段作为前面命令的延续执行,将揭示如何将其作为简单的转换来完成,以便用肉眼查看特征:
scala> val wordsDF = tokenizer.transform(training)
wordsDF: org.apache.spark.sql.DataFrame = [email: string, message: string ... 2 more fields]
scala> wordsDF.createOrReplaceTempView("word")
scala> val selectedFieldstDF = spark.sql("SELECT message, words FROM word")
selectedFieldstDF: org.apache.spark.sql.DataFrame = [message: string, words: array<string>]
scala> selectedFieldstDF.show()
+--------------------+--------------------+
| message| words|
+--------------------+--------------------+
| hope you are well|[hope, you, are, ...|
|nice to hear from...|[nice, to, hear, ...|
| happy holidays| [happy, holidays]|
| see you tomorrow|[see, you, tomorrow]|
| save money| [save, money]|
| low interest rate|[low, interest, r...|
| cheap loan| [cheap, loan]|
+--------------------+--------------------+
scala> val featurizedDF = hashingTF.transform(wordsDF)
featurizedDF: org.apache.spark.sql.DataFrame = [email: string, message: string ... 3 more fields]
scala> featurizedDF.createOrReplaceTempView("featurized")
scala> val selectedFeaturizedFieldstDF = spark.sql("SELECT words, features FROM featurized")
selectedFeaturizedFieldstDF: org.apache.spark.sql.DataFrame = [words: array<string>, features: vector]
scala> selectedFeaturizedFieldstDF.show()
+--------------------+--------------------+
| words| features|
+--------------------+--------------------+
|[hope, you, are, ...|(1000,[0,138,157,...|
|[nice, to, hear, ...|(1000,[370,388,42...|
| [happy, holidays]|(1000,[141,457],[...|
|[see, you, tomorrow]|(1000,[25,425,515...|
| [save, money]|(1000,[242,520],[...|
|[low, interest, r...|(1000,[70,253,618...|
| [cheap, loan]|(1000,[410,666],[...|
+--------------------+--------------------+
同样的用例在 Python 中的实现如下。在 Python REPL 提示符下,尝试以下语句:
>>> from pyspark.ml import Pipeline
>>> from pyspark.ml.classification import LogisticRegression
>>> from pyspark.ml.feature import HashingTF, Tokenizer
>>> from pyspark.sql import Row
>>> # Prepare training documents from a list of messages from emails used to filter them as spam or not spam
>>> # If the original message is a spam then the label is 1 and if the message is genuine then the label is 0
>>> LabeledDocument = Row("email", "message", "label")
>>> training = spark.createDataFrame([("you@example.com", "hope you are well", 0.0),("raj@example.com", "nice to hear from you", 0.0),("thomas@example.com", "happy holidays", 0.0),("mark@example.com", "see you tomorrow", 0.0),("xyz@example.com", "save money", 1.0),("top10@example.com", "low interest rate", 1.0),("marketing@example.com", "cheap loan", 1.0)], ["email", "message", "label"])
>>> training.show()
+--------------------+--------------------+-----+
| email| message|label|
+--------------------+--------------------+-----+
| you@example.com| hope you are well| 0.0|
| raj@example.com|nice to hear from...| 0.0|
| thomas@example.com| happy holidays| 0.0|
| mark@example.com| see you tomorrow| 0.0|
| xyz@example.com| save money| 1.0|
| top10@example.com| low interest rate| 1.0|
|marketing@example...| cheap loan| 1.0|
+--------------------+--------------------+-----+
>>> # Configure an Spark machin learning pipeline, consisting of three stages: tokenizer, hashingTF, and lr.
>>> tokenizer = Tokenizer(inputCol="message", outputCol="words")
>>> hashingTF = HashingTF(inputCol="words", outputCol="features")
>>> # LogisticRegression parameter to make lr.fit() use at most 10 iterations and the regularization parameter.
>>> # When a higher degree polynomial used by the algorithm to fit a set of points in a linear regression model, to prevent overfitting, regularization is used and this parameter is just for that
>>> lr = LogisticRegression(maxIter=10, regParam=0.01)
>>> pipeline = Pipeline(stages=[tokenizer, hashingTF, lr])
>>> # Fit the pipeline to train the model to study the messages
>>> model = pipeline.fit(training)
>>> # Prepare messages for prediction, which are not categorized and leaving upto the algorithm to predict
>>> test = spark.createDataFrame([("you@example.com", "how are you"),("jain@example.com", "hope doing well"),("caren@example.com", "want some money"),("zhou@example.com", "secure loan"),("ted@example.com","need loan")], ["email", "message"])
>>> test.show()
+-----------------+---------------+
| email| message|
+-----------------+---------------+
| you@example.com| how are you|
| jain@example.com|hope doing well|
|caren@example.com|want some money|
| zhou@example.com| secure loan|
| ted@example.com| need loan|
+-----------------+---------------+
>>> # Make predictions on the new messages
>>> prediction = model.transform(test).select("email", "message", "prediction")
>>> prediction.show()
+-----------------+---------------+----------+
| email| message|prediction|
+-----------------+---------------+----------+
| you@example.com| how are you| 0.0|
| jain@example.com|hope doing well| 0.0|
|caren@example.com|want some money| 1.0|
| zhou@example.com| secure loan| 1.0|
| ted@example.com| need loan| 1.0|
+-----------------+---------------+----------+
如前所述,Pipeline 抽象的转换使用 Python 明确阐述如下。以下代码片段作为前面命令的延续执行,将揭示如何将其作为简单的转换来完成,以便用肉眼查看特征:
>>> wordsDF = tokenizer.transform(training)
>>> wordsDF.createOrReplaceTempView("word")
>>> selectedFieldstDF = spark.sql("SELECT message, words FROM word")
>>> selectedFieldstDF.show()
+--------------------+--------------------+
| message| words|
+--------------------+--------------------+
| hope you are well|[hope, you, are, ...|
|nice to hear from...|[nice, to, hear, ...|
| happy holidays| [happy, holidays]|
| see you tomorrow|[see, you, tomorrow]|
| save money| [save, money]|
| low interest rate|[low, interest, r...|
| cheap loan| [cheap, loan]|
+--------------------+--------------------+
>>> featurizedDF = hashingTF.transform(wordsDF)
>>> featurizedDF.createOrReplaceTempView("featurized")
>>> selectedFeaturizedFieldstDF = spark.sql("SELECT words, features FROM featurized")
>>> selectedFeaturizedFieldstDF.show()
+--------------------+--------------------+
| words| features|
+--------------------+--------------------+
|[hope, you, are, ...|(262144,[128160,1...|
|[nice, to, hear, ...|(262144,[22346,10...|
| [happy, holidays]|(262144,[86293,23...|
|[see, you, tomorrow]|(262144,[29129,21...|
| [save, money]|(262144,[199496,2...|
|[low, interest, r...|(262144,[68685,13...|
| [cheap, loan]|(262144,[12946,16...|
+--------------------+--------------------+
基于前一个用例提供的见解,可以通过使用 Spark 机器学习库的 Pipelines 抽象出大量的转换来开发许多文本处理机器学习应用。
小贴士
就像机器学习模型被持久化到媒体上一样,Spark 机器学习库的所有 Pipelines 也可以被持久化到媒体上,并由其他程序重新加载。
特征算法
在现实世界的应用案例中,很难以适当的形式获取原始数据中的特征和标签以训练模型。进行大量的预处理是非常常见的。与其他数据处理范式不同,Spark 与 Spark 机器学习库结合提供了一套全面的工具和算法来完成这项任务。这些预处理算法可以分为三类:
-
特征提取
-
特征转换
-
特征选择
从原始数据中提取特征的过程称为特征提取。在前一个案例中使用的 HashingTF 算法是一个将文本数据的术语转换为特征向量的算法的好例子。将特征转换为不同格式的过程称为特征转换。从超集中选择特征子集的过程称为特征选择。涵盖所有这些内容超出了本章的范围,但下一节将讨论一个 Estimator,它是一种用于提取特征的算法,用于在文档中寻找单词的同义词。这些不是单词的实际同义词,而是在上下文中与给定单词相关联的单词。
寻找同义词
同义词是指与另一个词具有完全相同意义或非常接近意义的词或短语。从纯文学的角度来看,这种解释是正确的,但在更广泛的角度来看,在特定的上下文中,一些词将具有非常紧密的关系,这种关系也被称为同义。例如,罗杰·费德勒与网球是同义的。在上下文中找到这种同义词是实体识别、机器翻译等领域的非常常见需求。Word2Vec算法计算给定文档或词集合中单词的分布式向量表示。如果取这个向量空间,具有相似性或同义性的词将彼此靠近。
加州大学欧文分校机器学习库(archive.ics.uci.edu/ml/index.html)为对机器学习感兴趣的人提供大量数据集作为服务。在这里使用的二十个新闻组数据集(archive.ics.uci.edu/ml/datasets/Twenty+Newsgroups)用于寻找上下文中的单词同义词。它包含从 20 个新闻组中选取的 20,000 条消息组成的数据集。
备注
Twenty Newsgroups 数据集下载链接允许您下载此处讨论的数据集。需要下载并解压缩文件 20_newsgroups.tar.gz。以下代码片段中使用的数据目录应指向数据以未压缩形式可用的目录。如果 Spark Driver 由于数据量巨大而出现内存错误,请移除一些不感兴趣的新闻组数据,并使用数据子集进行实验。在此,为了训练模型,只使用了以下新闻组数据:talk.politics.guns、talk.politics.mideast、talk.politics.misc 和 talk.religion.misc。
在 Scala REPL 提示符下,尝试以下语句:
scala> import org.apache.spark.ml.feature.{HashingTF, Tokenizer, RegexTokenizer, Word2Vec, StopWordsRemover}
import org.apache.spark.ml.feature.{HashingTF, Tokenizer, RegexTokenizer, Word2Vec, StopWordsRemover}
scala> // TODO - Change this directory to the right location where the data is stored
scala> val dataDir = "/Users/RajT/Downloads/20_newsgroups/*"
dataDir: String = /Users/RajT/Downloads/20_newsgroups/*
scala> //Read the entire text into a DataFrame
scala> // Only the following directories under the data directory has benn considered for running this program talk.politics.guns, talk.politics.mideast, talk.politics.misc, talk.religion.misc. All other directories have been removed before running this program. There is no harm in retaining all the data. The only difference will be in the output.
scala> val textDF = sc.wholeTextFiles(dataDir).map{case(file, text) => text}.map(Tuple1.apply).toDF("sentence")
textDF: org.apache.spark.sql.DataFrame = [sentence: string]
scala> // Tokenize the sentences to words
scala> val regexTokenizer = new RegexTokenizer().setInputCol("sentence").setOutputCol("words").setPattern("\\w+").setGaps(false)
regexTokenizer: org.apache.spark.ml.feature.RegexTokenizer = regexTok_ba7ce8ec2333
scala> val tokenizedDF = regexTokenizer.transform(textDF)
tokenizedDF: org.apache.spark.sql.DataFrame = [sentence: string, words: array<string>]
scala> // Remove the stop words such as a, an the, I etc which doesn't have any specific relevance to the synonyms
scala> val remover = new StopWordsRemover().setInputCol("words").setOutputCol("filtered")
remover: org.apache.spark.ml.feature.StopWordsRemover = stopWords_775db995b8e8
scala> //Remove the stop words from the text
scala> val filteredDF = remover.transform(tokenizedDF)
filteredDF: org.apache.spark.sql.DataFrame = [sentence: string, words: array<string> ... 1 more field]
scala> //Prepare the Estimator
scala> //It sets the vector size, and the method setMinCount sets the minimum number of times a token must appear to be included in the word2vec model's vocabulary.
scala> val word2Vec = new Word2Vec().setInputCol("filtered").setOutputCol("result").setVectorSize(3).setMinCount(0)
word2Vec: org.apache.spark.ml.feature.Word2Vec = w2v_bb03091c4439
scala> //Train the model
scala> val model = word2Vec.fit(filteredDF)
model: org.apache.spark.ml.feature.Word2VecModel = w2v_bb03091c4439
scala> //Find 10 synonyms of a given word
scala> val synonyms1 = model.findSynonyms("gun", 10)
synonyms1: org.apache.spark.sql.DataFrame = [word: string, similarity: double]
scala> synonyms1.show()
+---------+------------------+
| word| similarity|
+---------+------------------+
| twa|0.9999976163843671|
|cigarette|0.9999943935045497|
| sorts|0.9999885527530025|
| jj|0.9999827967650881|
|presently|0.9999792188771406|
| laden|0.9999775888361028|
| notion|0.9999775296680583|
| settlers|0.9999746245431419|
|motivated|0.9999694932468436|
|qualified|0.9999678135106314|
+---------+------------------+
scala> //Find 10 synonyms of a different word
scala> val synonyms2 = model.findSynonyms("crime", 10)
synonyms2: org.apache.spark.sql.DataFrame = [word: string, similarity: double]
scala> synonyms2.show()
+-----------+------------------+
| word| similarity|
+-----------+------------------+
| abominable|0.9999997331058447|
|authorities|0.9999946968941679|
|cooperation|0.9999892536435327|
| mortazavi| 0.999986396931714|
|herzegovina|0.9999861828226779|
| important|0.9999853354260315|
| 1950s|0.9999832312575262|
| analogy|0.9999828272311249|
| bits|0.9999820987679822|
|technically|0.9999808208936487|
+-----------+------------------+
上述代码片段包含了很多功能。数据集是从文件系统中读取到 DataFrame 中,作为一个给定文件的文本句子。然后使用正则表达式进行分词,将句子转换为单词,并去除空格。接着,从这些单词中移除停用词,以便我们只保留相关单词。最后,使用 Word2Vec 估计器,用准备好的数据训练模型。从训练好的模型中,确定同义词。
以下代码演示了使用 Python 实现的相同用例。在 Python REPL 提示符下,尝试以下语句:
>>> from pyspark.ml.feature import Word2Vec
>>> from pyspark.ml.feature import RegexTokenizer
>>> from pyspark.sql import Row
>>> # TODO - Change this directory to the right location where the data is stored
>>> dataDir = "/Users/RajT/Downloads/20_newsgroups/*"
>>> # Read the entire text into a DataFrame. Only the following directories under the data directory has benn considered for running this program talk.politics.guns, talk.politics.mideast, talk.politics.misc, talk.religion.misc. All other directories have been removed before running this program. There is no harm in retaining all the data. The only difference will be in the output.
>>> textRDD = sc.wholeTextFiles(dataDir).map(lambda recs: Row(sentence=recs[1]))
>>> textDF = spark.createDataFrame(textRDD)
>>> # Tokenize the sentences to words
>>> regexTokenizer = RegexTokenizer(inputCol="sentence", outputCol="words", gaps=False, pattern="\\w+")
>>> tokenizedDF = regexTokenizer.transform(textDF)
>>> # Prepare the Estimator
>>> # It sets the vector size, and the parameter minCount sets the minimum number of times a token must appear to be included in the word2vec model's vocabulary.
>>> word2Vec = Word2Vec(vectorSize=3, minCount=0, inputCol="words", outputCol="result")
>>> # Train the model
>>> model = word2Vec.fit(tokenizedDF)
>>> # Find 10 synonyms of a given word
>>> synonyms1 = model.findSynonyms("gun", 10)
>>> synonyms1.show()
+---------+------------------+
| word| similarity|
+---------+------------------+
| strapped|0.9999918504219028|
| bingo|0.9999909957939888|
|collected|0.9999907658056393|
| kingdom|0.9999896797527402|
| presumed|0.9999806586578037|
| patients|0.9999778970248504|
| azats|0.9999718388241235|
| opening| 0.999969723774294|
| holdout|0.9999685636131942|
| contrast|0.9999677676714386|
+---------+------------------+
>>> # Find 10 synonyms of a different word
>>> synonyms2 = model.findSynonyms("crime", 10)
>>> synonyms2.show()
+-----------+------------------+
| word| similarity|
+-----------+------------------+
| peaceful|0.9999983523475047|
| democracy|0.9999964568156694|
| areas| 0.999994036518118|
| miniscule|0.9999920828755365|
| lame|0.9999877327660102|
| strikes|0.9999877253180771|
|terminology|0.9999839393584438|
| wrath|0.9999829348358952|
| divided| 0.999982619125983|
| hillary|0.9999795817857984|
+-----------+------------------+
Scala 实现与 Python 实现的主要区别在于,在 Python 实现中,没有移除停用词。这是因为 Spark 机器学习库的 Python API 中没有这个功能。由于这个差异,Scala 程序和 Python 程序生成的同义词列表是不同的。
参考文献
更多信息请参考以下链接:
摘要
Spark 提供了一个非常强大的核心数据处理框架,并且 Spark 机器学习库利用了 Spark 和 Spark 库(如 Spark SQL)的所有核心功能,以及其丰富的机器学习算法集。本章介绍了一些非常常见的预测用例和分类用例,使用 Scala 和 Python 实现了 Spark 机器学习库的少量代码。这些葡萄酒质量预测、葡萄酒分类、垃圾邮件过滤器以及同义词查找的机器学习用例有很大的潜力发展成为完整的现实世界用例。Spark 2.0 通过启用模型和管道的持久化,为模型创建、管道创建以及在不同语言编写的不同程序中的使用提供了灵活性。
在现实世界的应用案例中,成对关系非常普遍。在强大的数学理论基础上,计算机科学家们已经开发了许多数据结构和相应的算法,这些算法属于图论领域。这些数据结构和算法在社交网络网站、调度问题以及其他许多应用中具有巨大的适用性。图处理计算量非常大,而像 Spark 这样的分布式数据处理范式非常适合进行此类计算。建立在 Spark 之上的 Spark GraphX 库是一系列图处理 API。下一章将探讨 Spark GraphX。
第八章:Spark 图处理
图是计算机科学中的一个数学概念和数据结构。它在许多现实世界的用例中有着巨大的应用。它用于模拟实体之间的成对关系。在这里,实体被称为顶点,两个顶点通过边连接。一个图由顶点的集合和连接它们的边组成。
从概念上讲,这是一个欺骗性的简单抽象,但当涉及到处理大量顶点和边时,它计算密集,消耗大量的处理时间和计算资源。以下是一个具有四个顶点和三条边的图的表示:

图 1
本章我们将涵盖以下主题:
-
图及其用途
-
GraphX 库
-
PageRank 算法
-
连通分量算法
-
GraphFrames
-
图查询
理解图及其用法
有许多可以建模为图的用例结构。在社交网络应用中,用户之间的关系可以建模为图,其中用户形成图的顶点,用户之间的关系形成图的边。在多阶段作业调度应用中,单个任务形成图的顶点,任务的顺序形成图的边。在道路交通建模系统中,城镇形成图的顶点,连接城镇的道路形成图的边。
给定图的边有一个非常重要的属性,即连接的方向。在许多用例中,连接的方向并不重要。例如,通过道路连接城市的情况。但如果用例是在城市内生成驾驶方向,则交通枢纽之间的连通性是有方向的。取任何两个交通枢纽,都存在道路连通性,但也可能是一条单行道。所以这完全取决于交通的流向。如果道路从交通枢纽 J1 到 J2 开放,但从 J2 到 J1 关闭,那么驾驶方向的图将从 J1 到 J2 有连通性,而不是从 J2 到 J1。在这种情况下,连接 J1 和 J2 的边有方向。如果 J2 和 J3 之间的道路在两个方向上都开放,那么连接 J2 和 J3 的边没有方向。所有边都有方向的图称为有向图。
小贴士
当以图形方式表示图时,必须给出有向图边的方向。如果不是有向图,边可以没有任何方向地表示,或者可以表示为双向的。这取决于个人的选择。图 1 不是一个有向图,但它表示了连接边的两个顶点的方向。
在图 2中,一个社交网络应用用例中两个用户之间的关系被表示为图。用户形成顶点,用户之间的关系形成边。用户 A 关注用户 B。同时,用户 A 是用户 B 的儿子。在这个图中,存在两条具有相同源和目标顶点的并行边。包含并行边的图称为多重图。图 2中显示的图也是一个有向图。这是一个有向多重图的好例子。

图 2
在现实世界的用例中,图的顶点和边代表现实世界的实体。这些实体具有属性。例如,在社交网络应用的用户社交连接图中,用户形成顶点,用户具有许多属性,如姓名、电子邮件、电话号码等。同样,用户之间的关系形成图的边,连接用户顶点的边可以具有如关系等属性。任何图处理应用程序库都应该足够灵活,能够将任何类型的属性附加到图的顶点和边上。
Spark GraphX 库
对于图处理,开源世界中有很多库可用。Giraph、Pregel、GraphLab 和 Spark GraphX 都是其中的一些。Spark GraphX 是最近进入这个领域的新成员。
Spark GraphX 有什么特别之处?Spark GraphX 是一个建立在 Spark 数据处理框架之上的图处理库。与其他图处理库相比,Spark GraphX 具有真正的优势。它可以利用 Spark 的所有数据处理能力。然而,在现实中,图处理算法的性能并不是唯一需要考虑的方面。
在许多应用中,需要将数据建模为图,但这些数据并不以这种形式自然存在。在许多用例中,除了图处理之外,还需要大量的处理器时间和其他计算资源来获取数据,以便应用图处理算法。这正是 Spark 数据处理框架和 Spark GraphX 库发挥其价值的地方。使用 Spark 工具包中可用的众多工具,可以轻松完成使数据准备好供 Spark GraphX 消费的数据处理作业。总之,作为 Spark 家族的一部分,Spark GraphX 库结合了 Spark 的核心数据处理能力以及一个非常易于使用的图处理库。
再次回顾一下图 3所展示的更大图景,以设定上下文并了解在这里将要讨论的内容,然后再进入用例的使用。与其它章节不同,在本章中,代码示例将只使用 Scala 编写,因为 Spark GraphX 库目前只提供了 Scala API。

图 3
GraphX 概述
在任何实际应用场景中,理解由顶点和边组成的图的概念是很容易的。但是,当涉及到实现时,即使对于优秀的设计师和程序员来说,这也不是一个非常理解的数据结构。原因是简单的:与其他普遍存在的数据结构,如列表、集合、映射、队列等不同,图在大多数应用中并不常用。考虑到这一点,概念是逐步、稳步地引入的,一次一步,通过简单且平凡的例子,然后再考虑一些实际应用场景。
Spark GraphX 库最重要的方面是一个数据类型,Graph,它扩展了 Spark 弹性分布式数据集(RDD)并引入了一种新的图抽象。Spark GraphX 中的图抽象是一个带有所有顶点和边属性的定向多重图。这些顶点和边的属性可以是 Scala 类型系统支持的由用户定义的类型。这些类型在 Graph 类型中是参数化的。给定的图可能需要顶点或边有不同的数据类型。这可以通过使用一个与继承层次结构相关的类型系统来实现。除了所有这些基本规则之外,该库还包括一系列图构建器和算法。
图中的一个顶点由一个唯一的 64 位长标识符识别,org.apache.spark.graphx.VertexId。除了 VertexId 类型,一个简单的 Scala 类型,Long,也可以使用。除此之外,顶点可以接受任何类型的属性。图中的一个边应该有一个源顶点标识符、一个目标顶点标识符以及任何类型的属性。
图 4 展示了一个顶点属性为 String 类型,边属性也为 String 类型的图。除了属性之外,每个顶点都有一个唯一的标识符,每个边都有一个源顶点编号和目标顶点编号。

图 4
在处理图时,有方法可以获取顶点和边。但是,在独立处理时,这些图中的独立对象可能不足以进行处理。
如前所述,一个顶点有一个唯一的标识符和属性。边通过其源顶点和目标顶点唯一标识。为了在图处理应用中轻松处理每个边,Spark GraphX 库的三元组抽象提供了一种从单个对象访问源顶点、目标顶点和边属性的简单方法。
下面的 Scala 代码片段用于使用 Spark GraphX 库创建图 4所示的图。在创建图之后,可以在图上调用许多方法来暴露图的各个属性。在 Scala REPL 提示符下,尝试以下语句:
scala> import org.apache.spark._
import org.apache.spark._
scala> import org.apache.spark.graphx._
import org.apache.spark.graphx._
scala> import org.apache.spark.rdd.RDD
import org.apache.spark.rdd.RDD
scala> //Create an RDD of users containing tuple values with a mandatory
Long and another String type as the property of the vertex
scala> val users: RDD[(Long, String)] = sc.parallelize(Array((1L,
"Thomas"), (2L, "Krish"),(3L, "Mathew")))
users: org.apache.spark.rdd.RDD[(Long, String)] = ParallelCollectionRDD[0]
at parallelize at <console>:31
scala> //Created an RDD of Edge type with String type as the property of the edge
scala> val userRelationships: RDD[Edge[String]] = sc.parallelize(Array(Edge(1L, 2L, "Follows"), Edge(1L, 2L, "Son"),Edge(2L, 3L, "Follows")))
userRelationships: org.apache.spark.rdd.RDD[org.apache.spark.graphx.Edge[String]] = ParallelCollectionRDD[1] at parallelize at <console>:31
scala> //Create a graph containing the vertex and edge RDDs as created beforescala> val userGraph = Graph(users, userRelationships)
userGraph: org.apache.spark.graphx.Graph[String,String] = org.apache.spark.graphx.impl.GraphImpl@ed5cf29
scala> //Number of edges in the graph
scala> userGraph.numEdges
res3: Long = 3
scala> //Number of vertices in the graph
scala> userGraph.numVertices
res4: Long = 3
scala> //Number of edges coming to each of the vertex.
scala> userGraph.inDegrees
res7: org.apache.spark.graphx.VertexRDD[Int] = VertexRDDImpl[19] at RDD at
VertexRDD.scala:57
scala> //The first element in the tuple is the vertex id and the second
element in the tuple is the number of edges coming to that vertex
scala> userGraph.inDegrees.foreach(println)
(3,1)
(2,2)
scala> //Number of edges going out of each of the vertex. scala> userGraph.outDegrees
res9: org.apache.spark.graphx.VertexRDD[Int] = VertexRDDImpl[23] at RDD at VertexRDD.scala:57
scala> //The first element in the tuple is the vertex id and the second
element in the tuple is the number of edges going out of that vertex
scala> userGraph.outDegrees.foreach(println)
(1,2)
(2,1)
scala> //Total number of edges coming in and going out of each vertex.
scala> userGraph.degrees
res12: org.apache.spark.graphx.VertexRDD[Int] = VertexRDDImpl[27] at RDD at
VertexRDD.scala:57
scala> //The first element in the tuple is the vertex id and the second
element in the tuple is the total number of edges coming in and going out of that vertex.
scala> userGraph.degrees.foreach(println)
(1,2)
(2,3)
(3,1)
scala> //Get the vertices of the graph
scala> userGraph.vertices
res11: org.apache.spark.graphx.VertexRDD[String] = VertexRDDImpl[11] at RDD at VertexRDD.scala:57
scala> //Get all the vertices with the vertex number and the property as a tuplescala> userGraph.vertices.foreach(println)
(1,Thomas)
(3,Mathew)
(2,Krish)
scala> //Get the edges of the graph
scala> userGraph.edges
res15: org.apache.spark.graphx.EdgeRDD[String] = EdgeRDDImpl[13] at RDD at
EdgeRDD.scala:41
scala> //Get all the edges properties with source and destination vertex numbers
scala> userGraph.edges.foreach(println)
Edge(1,2,Follows)
Edge(1,2,Son)
Edge(2,3,Follows)
scala> //Get the triplets of the graph
scala> userGraph.triplets
res18: org.apache.spark.rdd.RDD[org.apache.spark.graphx.EdgeTriplet[String,String]]
= MapPartitionsRDD[32] at mapPartitions at GraphImpl.scala:48
scala> userGraph.triplets.foreach(println)
((1,Thomas),(2,Krish),Follows)
((1,Thomas),(2,Krish),Son)
((2,Krish),(3,Mathew),Follows)
读者将熟悉使用 RDD 进行 Spark 编程。前面的代码片段解释了使用 RDD 构建图顶点和边的过程。可以使用存储在各种数据存储中的数据进行 RDD 的构建。在实际应用场景中,大多数情况下数据将来自外部来源,例如 NoSQL 数据存储,并且有方法可以使用此类数据构建 RDD。一旦构建了 RDD,就可以使用这些 RDD 构建图。
前面的代码片段还解释了图提供的各种方法来获取给定图的所需详细信息。这里涵盖的示例用例在规模上非常小。在实际应用场景中,图的顶点和边的数量可以达到数百万。由于所有这些抽象都作为 RDD 实现,因此所有固有的不可变性、分区、分布和并行处理的优势都自动获得,这使得图处理高度可扩展。最后,以下表格显示了顶点和边的表示方式:
顶点表:
| 顶点 ID | 顶点属性 |
|---|---|
| 1 | Thomas |
| 2 | Krish |
| 3 | Mathew |
边表:
| 源顶点 ID | 目标顶点 ID | 边属性 |
|---|---|---|
| 1 | 2 | Follows |
| 1 | 2 | Son |
| 2 | 3 | Follows |
三元组表:
| 源顶点 ID | 目标顶点 ID | 源顶点属性 | 边属性 | 目标顶点属性 |
|---|---|---|---|---|
| 1 | 2 | Thomas | Follows | Krish |
| 1 | 2 | Thomas | Son | Krish |
| 2 | 3 | Krish | Follows | Mathew |
注意
需要注意的是,这些表仅用于说明目的。实际的内部表示遵循 RDD 表示的规则和规定。
如果任何内容表示为 RDD,它必然会进行分区和分布。但是,如果分区和分布是自由进行的,没有任何对图的管控,那么在图处理性能方面将是不优的。正因为如此,Spark GraphX 库的创建者提前深思熟虑了这个问题,并实现了一种图分区策略,以便以优化的形式将图表示为 RDD。
图分区
理解图 RDD 如何在各个分区中进行分区和分布是很重要的。这将对确定构成图的各个 RDD 的分区和分布的高级优化很有用。
通常,对于给定的图有三个 RDD。除了顶点 RDD 和边 RDD 之外,还有一个内部使用的 RDD,即路由 RDD。为了获得最佳性能,所有构成给定边的顶点都保留在存储边的同一个分区中。如果给定顶点参与多个边,而这些边位于不同的分区中,那么这个特定的顶点可以存储在多个分区中。
为了跟踪给定顶点冗余存储的分区,还维护了一个路由 RDD,其中包含顶点详情以及每个顶点可用的分区。
图 5 解释了这一点:

图 5
在 图 5 中,假设边被分区到分区 1 和 2。同样假设顶点被分区到分区 1 和 2。
在分区 1 中,所有所需的顶点都可用于边的本地。但在分区 2 中,只有边的一个顶点可用于本地。因此,缺失的顶点也存储在分区 2 中,以便所有所需的顶点都可在本地获得。
为了跟踪复制,顶点路由 RDD 维护了给定顶点可用的分区号。在 图 5 中,在顶点路由 RDD 中,使用标注符号来显示这些顶点复制的分区。这样,在处理边或三元组时,所有与构成顶点相关的信息都可在本地获得,性能将非常优化。由于 RDD 是不可变的,因此即使它们存储在多个分区中,与信息更改相关的问题也被消除了。
图处理
用户暴露的图的构成元素是顶点 RDD 和边 RDD。就像任何其他数据结构一样,由于底层数据的改变,图也会经历许多变化。为了支持各种用例所需的图操作,有许多算法可用,使用这些算法可以处理图数据结构中的数据,以产生预期的业务结果。在深入了解处理图的算法之前,了解一些使用航空旅行用例的图处理基础知识是很好的。
假设一个人正在尝试从曼彻斯特到班加罗尔的便宜往返机票。在旅行偏好中,这个人提到他/她不关心停靠次数,但价格应该是最低的。假设机票预订系统为单程和返程旅程选择了相同的停靠点,并产生了以下路线或旅程段,以最低的价格:
Manchester → London → Colombo → Bangalore
Bangalore → Colombo → London → Manchester
这条路线计划是图的完美例子。如果将单程旅程视为一个图,而返程旅程视为另一个图,则可以通过反转单程旅程图来生成返程旅程图。在 Scala REPL 提示符下,尝试以下语句:
scala> import org.apache.spark._
import org.apache.spark._
scala> import org.apache.spark.graphx._
import org.apache.spark.graphx._
scala> import org.apache.spark.rdd.RDD
import org.apache.spark.rdd.RDD
scala> //Create the vertices with the stops
scala> val stops: RDD[(Long, String)] = sc.parallelize(Array((1L, "Manchester"), (2L, "London"),(3L, "Colombo"), (4L, "Bangalore")))
stops: org.apache.spark.rdd.RDD[(Long, String)] = ParallelCollectionRDD[33] at parallelize at <console>:38
scala> //Create the edges with travel legs
scala> val legs: RDD[Edge[String]] = sc.parallelize(Array(Edge(1L, 2L, "air"), Edge(2L, 3L, "air"),Edge(3L, 4L, "air")))
legs: org.apache.spark.rdd.RDD[org.apache.spark.graphx.Edge[String]] = ParallelCollectionRDD[34] at parallelize at <console>:38
scala> //Create the onward journey graph
scala> val onwardJourney = Graph(stops, legs)onwardJourney: org.apache.spark.graphx.Graph[String,String] = org.apache.spark.graphx.impl.GraphImpl@190ec769scala> onwardJourney.triplets.map(triplet => (triplet.srcId, (triplet.srcAttr, triplet.dstAttr))).sortByKey().collect().foreach(println)
(1,(Manchester,London))
(2,(London,Colombo))
(3,(Colombo,Bangalore))
scala> val returnJourney = onwardJourney.reversereturnJourney: org.apache.spark.graphx.Graph[String,String] = org.apache.spark.graphx.impl.GraphImpl@60035f1e
scala> returnJourney.triplets.map(triplet => (triplet.srcId, (triplet.srcAttr,triplet.dstAttr))).sortByKey(ascending=false).collect().foreach(println)
(4,(Bangalore,Colombo))
(3,(Colombo,London))
(2,(London,Manchester))
在返程旅程段中,单程旅程段的目的地和起点被反转。当一个图被反转时,只有边的源点和目标点被反转,顶点的身份保持不变。
换句话说,每个顶点的顶点标识符保持不变。在处理图时,了解三元属性的名字很重要。它们对于编写程序和处理图非常有用。作为同一 Scala REPL 会话的延续,尝试以下语句:
scala> returnJourney.triplets.map(triplet => (triplet.srcId,triplet.dstId,triplet.attr,triplet.srcAttr,triplet.dstAttr)).foreach(println)
(2,1,air,London,Manchester)
(3,2,air,Colombo,London)
(4,3,air,Bangalore,Colombo)
下表列出了可以用来处理图并从图中提取所需数据的属性列表。前面的代码片段和下面的表格可以相互验证,以全面理解:
| 三元属性 | 描述 |
|---|---|
srcId |
源顶点标识符 |
dstId |
目标顶点标识符 |
attr |
边属性 |
srcAttr |
源顶点属性 |
dstAttr |
目标顶点属性 |
在一个图中,顶点是 RDD,边也是 RDD,正因为如此,才可能进行转换。
现在,为了演示图转换,使用相同的使用案例,但略有变化。假设一个旅行社从航空公司为选择的航线获得特殊的折扣价格。旅行社决定保留折扣并向客户提供市场价格,为此,他在航空公司给出的价格上增加了 10%。这位旅行社注意到机场名称显示不一致,并确保在整个网站上显示时保持一致的表现,因此决定将所有停靠站名称更改为大写。作为同一 Scala REPL 会话的延续,尝试以下语句:
scala> // Create the vertices
scala> val stops: RDD[(Long, String)] = sc.parallelize(Array((1L,
"Manchester"), (2L, "London"),(3L, "Colombo"), (4L, "Bangalore")))
stops: org.apache.spark.rdd.RDD[(Long, String)] = ParallelCollectionRDD[66] at parallelize at <console>:38
scala> //Create the edges
scala> val legs: RDD[Edge[Long]] = sc.parallelize(Array(Edge(1L, 2L, 50L), Edge(2L, 3L, 100L),Edge(3L, 4L, 80L)))
legs: org.apache.spark.rdd.RDD[org.apache.spark.graphx.Edge[Long]] = ParallelCollectionRDD[67] at parallelize at <console>:38
scala> //Create the graph using the vertices and edges
scala> val journey = Graph(stops, legs)
journey: org.apache.spark.graphx.Graph[String,Long] = org.apache.spark.graphx.impl.GraphImpl@8746ad5
scala> //Convert the stop names to upper case
scala> val newStops = journey.vertices.map {case (id, name) => (id, name.toUpperCase)}
newStops: org.apache.spark.rdd.RDD[(org.apache.spark.graphx.VertexId, String)] = MapPartitionsRDD[80] at map at <console>:44
scala> //Get the edges from the selected journey and add 10% price to the original price
scala> val newLegs = journey.edges.map { case Edge(src, dst, prop) => Edge(src, dst, (prop + (0.1*prop))) }
newLegs: org.apache.spark.rdd.RDD[org.apache.spark.graphx.Edge[Double]] = MapPartitionsRDD[81] at map at <console>:44
scala> //Create a new graph with the original vertices and the new edges
scala> val newJourney = Graph(newStops, newLegs)
newJourney: org.apache.spark.graphx.Graph[String,Double]
= org.apache.spark.graphx.impl.GraphImpl@3c929623
scala> //Print the contents of the original graph
scala> journey.triplets.foreach(println)
((1,Manchester),(2,London),50)
((3,Colombo),(4,Bangalore),80)
((2,London),(3,Colombo),100)
scala> //Print the contents of the transformed graph
scala> newJourney.triplets.foreach(println)
((2,LONDON),(3,COLOMBO),110.0)
((3,COLOMBO),(4,BANGALORE),88.0)
((1,MANCHESTER),(2,LONDON),55.0)
实质上,这些转换确实是 RDD 转换。如果对如何将这些不同的 RDD 组合在一起形成图有概念性的理解,那么任何具有 RDD 编程能力的程序员都能够很好地进行图处理。这是 Spark 统一编程模型强大功能的又一例证。
之前的使用案例对顶点和边 RDD 进行了 map 转换。同样,filter 转换也是另一种常用的有用类型。除了这些,所有转换和操作都可以用来处理顶点和边 RDD。
图结构处理
在上一节中,一种图处理方式是通过单独处理所需的顶点或边来完成的。这种方法的缺点是处理过程需要经过三个不同的阶段,如下所示:
-
从图中提取顶点或边
-
处理顶点或边
-
重新创建一个包含处理过的顶点和边的新图
这很繁琐且容易导致用户编程错误。为了解决这个问题,Spark GraphX 库中提供了一些结构化操作符,允许用户将图作为一个独立的单元进行处理,从而生成一个新的图。
在前一个部分中已经讨论了一个重要的结构运算,即图的反转,它产生一个所有边方向都反转的新图。另一个常用的结构运算是从给定图中提取子图。结果子图可以是整个父图本身,也可以是父图的一个子集,具体取决于对父图进行的操作。
当从外部数据源创建图表时,可能会出现边具有无效顶点的情况。如果顶点和边是由来自两个不同来源或不同应用程序的数据创建的,这种情况尤为可能。使用这些顶点和边创建图表时,一些边将具有无效顶点,处理结果可能会出现意外。以下是一个用例,其中对包含无效顶点的边进行了剪枝操作,以使用结构运算符消除这些问题。在 Scala REPL 提示符下,尝试以下语句:
scala> import org.apache.spark._
import org.apache.spark._ scala> import org.apache.spark.graphx._
import org.apache.spark.graphx._ scala> import org.apache.spark.rdd.RDD
import org.apache.spark.rdd.RDD scala> //Create an RDD of users containing tuple values with a mandatory
Long and another String type as the property of the vertex
scala> val users: RDD[(Long, String)] = sc.parallelize(Array((1L,
"Thomas"), (2L, "Krish"),(3L, "Mathew")))
users: org.apache.spark.rdd.RDD[(Long, String)] = ParallelCollectionRDD[104]
at parallelize at <console>:45
scala> //Created an RDD of Edge type with String type as the property of
the edge
scala> val userRelationships: RDD[Edge[String]] =
sc.parallelize(Array(Edge(1L, 2L, "Follows"), Edge(1L, 2L,
"Son"),Edge(2L, 3L, "Follows"), Edge(1L, 4L, "Follows"), Edge(3L, 4L, "Follows")))
userRelationships:
org.apache.spark.rdd.RDD[org.apache.spark.graphx.Edge[String]] =
ParallelCollectionRDD[105] at parallelize at <console>:45
scala> //Create a vertex property object to fill in if an invalid vertex id is given in the edge
scala> val missingUser = "Missing"
missingUser: String = Missing
scala> //Create a graph containing the vertex and edge RDDs as created
before
scala> val userGraph = Graph(users, userRelationships, missingUser)
userGraph: org.apache.spark.graphx.Graph[String,String] = org.apache.spark.graphx.impl.GraphImpl@43baf0b9
scala> //List the graph triplets and find some of the invalid vertex ids given and for them the missing vertex property is assigned with the value "Missing"scala> userGraph.triplets.foreach(println)
((3,Mathew),(4,Missing),Follows)
((1,Thomas),(2,Krish),Son)
((2,Krish),(3,Mathew),Follows)
((1,Thomas),(2,Krish),Follows)
((1,Thomas),(4,Missing),Follows)
scala> //Since the edges with the invalid vertices are invalid too, filter out
those vertices and create a valid graph. The vertex predicate here can be any valid filter condition of a vertex. Similar to vertex predicate, if the filtering is to be done on the edges, instead of the vpred, use epred as the edge predicate.
scala> val fixedUserGraph = userGraph.subgraph(vpred = (vertexId, attribute) => attribute != "Missing")
fixedUserGraph: org.apache.spark.graphx.Graph[String,String] = org.apache.spark.graphx.impl.GraphImpl@233b5c71
scala> fixedUserGraph.triplets.foreach(println)
((2,Krish),(3,Mathew),Follows)
((1,Thomas),(2,Krish),Follows)
((1,Thomas),(2,Krish),Son)
在大型图中,有时根据用例,可能会有大量的并行边。在某些用例中,可以将并行边的数据进行合并,只维护一条边,而不是维护大量并行边。在前面的用例中,最终没有无效边的图,存在并行边,一个具有Follows属性,另一个具有Son属性,它们具有相同的源和目标顶点。
将这些并行边合并成一条具有从并行边拼接的属性的单一边是可以的,这将减少边的数量而不会丢失信息。这是通过图的 groupEdges 结构运算完成的。作为同一 Scala REPL 会话的延续,尝试以下语句:
scala> // Import the partition strategy classes
scala> import org.apache.spark.graphx.PartitionStrategy._
import org.apache.spark.graphx.PartitionStrategy._
scala> // Partition the user graph. This is required to group the edges
scala> val partitionedUserGraph = fixedUserGraph.partitionBy(CanonicalRandomVertexCut)
partitionedUserGraph: org.apache.spark.graphx.Graph[String,String] = org.apache.spark.graphx.impl.GraphImpl@5749147e
scala> // Generate the graph without parallel edges and combine the properties of duplicate edges
scala> val graphWithoutParallelEdges = partitionedUserGraph.groupEdges((e1, e2) => e1 + " and " + e2)
graphWithoutParallelEdges: org.apache.spark.graphx.Graph[String,String] = org.apache.spark.graphx.impl.GraphImpl@16a4961f
scala> // Print the details
scala> graphWithoutParallelEdges.triplets.foreach(println)
((1,Thomas),(2,Krish),Follows and Son)
((2,Krish),(3,Mathew),Follows)
图中的前述结构变化通过分组边减少了边的数量。当边属性是数值型,并且通过聚合它们进行合并是有意义的,那么也可以通过移除并行边来减少边的数量,这可以显著减少图处理时间。
注意
在此代码片段中需要注意的一个重要点是,在边上的 group-by 操作之前,图已经被分区。
默认情况下,给定图的边和构成顶点不需要位于同一分区。为了使 group-by 操作生效,所有并行边都必须位于同一分区。CanonicalRandomVertexCut 分区策略确保两个顶点之间的所有边都发生本地化,无论方向如何。
Spark GraphX 库中还有一些其他结构运算符可用,查阅 Spark 文档将提供对这些运算符的深入了解。它们可以根据用例使用。
网球锦标赛分析
由于基本的图处理基础已经建立,现在是时候处理一个使用图的现实世界用例了。在这里,网球锦标赛的结果是通过图来建模的。2015 年巴克莱斯 ATP 世界巡回赛单打比赛的结果是通过图来建模的。顶点包含球员详情,边包含个人比赛。边是以这样的方式形成的,即源顶点是赢得比赛的球员,目标顶点是输掉比赛的球员。边属性包含比赛类型、获胜者在比赛中获得的分数以及比赛中球员的头对头计数。这里使用的积分系统是虚构的,它只是获胜者在特定比赛中获得的一种权重。初始小组赛携带的权重最小,半决赛携带的权重更大,决赛携带的权重最大。通过这种方式建模结果,通过处理图来找出以下详细信息:
-
列出所有比赛详情。
-
列出所有比赛,包括球员姓名、比赛类型和结果。
-
列出所有第一组获胜者及其比赛中的得分。
-
列出所有第二组获胜者及其比赛中的得分。
-
列出所有半决赛获胜者及其比赛中的得分。
-
列出比赛中的得分详情的最终获胜者。
-
列出在整个锦标赛中赢得的总分的球员名单。
-
通过找出球员在比赛中获得的最高得分来列出比赛的获胜者。
-
在基于小组的比赛,由于循环赛制的平局,相同的球员可能会相遇多次。找出在这个锦标赛中相互比赛超过一次的球员。
-
列出至少赢得一场比赛的球员名单。
-
列出至少输掉一场比赛的球员名单。
-
列出至少赢得一场比赛和至少输掉一场比赛的球员名单。
-
列出一场比赛都没有赢的球员名单。
-
列出一场比赛都没有输的球员名单。
对于不熟悉网球游戏的人来说,没有必要担心,因为这里没有讨论游戏规则,也不需要理解这个用例。从所有实际目的来看,它只被视为两个人之间进行的游戏,其中一人获胜,另一人失败。在 Scala REPL 提示符下,尝试以下语句:
scala> import org.apache.spark._
import org.apache.spark._
scala> import org.apache.spark.graphx._
import org.apache.spark.graphx._
scala> import org.apache.spark.rdd.RDD
import org.apache.spark.rdd.RDD
scala> //Define a property class that is going to hold all the properties of the vertex which is nothing but player information
scala> case class Player(name: String, country: String)
defined class Player
scala> // Create the player vertices
scala> val players: RDD[(Long, Player)] = sc.parallelize(Array((1L, Player("Novak Djokovic", "SRB")), (3L, Player("Roger Federer", "SUI")),(5L, Player("Tomas Berdych", "CZE")), (7L, Player("Kei Nishikori", "JPN")), (11L, Player("Andy Murray", "GBR")),(15L, Player("Stan Wawrinka", "SUI")),(17L, Player("Rafael Nadal", "ESP")),(19L, Player("David Ferrer", "ESP"))))
players: org.apache.spark.rdd.RDD[(Long, Player)] = ParallelCollectionRDD[145] at parallelize at <console>:57
scala> //Define a property class that is going to hold all the properties of the edge which is nothing but match informationscala> case class Match(matchType: String, points: Int, head2HeadCount: Int)
defined class Match
scala> // Create the match edgesscala> val matches: RDD[Edge[Match]] = sc.parallelize(Array(Edge(1L, 5L, Match("G1", 1,1)), Edge(1L, 7L, Match("G1", 1,1)), Edge(3L, 1L, Match("G1", 1,1)), Edge(3L, 5L, Match("G1", 1,1)), Edge(3L, 7L, Match("G1", 1,1)), Edge(7L, 5L, Match("G1", 1,1)), Edge(11L, 19L, Match("G2", 1,1)), Edge(15L, 11L, Match("G2", 1, 1)), Edge(15L, 19L, Match("G2", 1, 1)), Edge(17L, 11L, Match("G2", 1, 1)), Edge(17L, 15L, Match("G2", 1, 1)), Edge(17L, 19L, Match("G2", 1, 1)), Edge(3L, 15L, Match("S", 5, 1)), Edge(1L, 17L, Match("S", 5, 1)), Edge(1L, 3L, Match("F", 11, 1))))
matches: org.apache.spark.rdd.RDD[org.apache.spark.graphx.Edge[Match]] = ParallelCollectionRDD[146] at parallelize at <console>:57
scala> //Create a graph with the vertices and edges
scala> val playGraph = Graph(players, matches)
playGraph: org.apache.spark.graphx.Graph[Player,Match] = org.apache.spark.graphx.impl.GraphImpl@30d4d6fb
网球锦标赛的图已经创建,从现在开始,将要进行的所有操作都是对这个基本图的加工以及从中提取信息以满足用例的要求:
scala> //Print the match details
scala> playGraph.triplets.foreach(println)
((15,Player(Stan Wawrinka,SUI)),(11,Player(Andy Murray,GBR)),Match(G2,1,1))
((15,Player(Stan Wawrinka,SUI)),(19,Player(David Ferrer,ESP)),Match(G2,1,1))
((7,Player(Kei Nishikori,JPN)),(5,Player(Tomas Berdych,CZE)),Match(G1,1,1))
((1,Player(Novak Djokovic,SRB)),(7,Player(Kei Nishikori,JPN)),Match(G1,1,1))
((3,Player(Roger Federer,SUI)),(1,Player(Novak Djokovic,SRB)),Match(G1,1,1))
((1,Player(Novak Djokovic,SRB)),(3,Player(Roger Federer,SUI)),Match(F,11,1))
((1,Player(Novak Djokovic,SRB)),(17,Player(Rafael Nadal,ESP)),Match(S,5,1))
((3,Player(Roger Federer,SUI)),(5,Player(Tomas Berdych,CZE)),Match(G1,1,1))
((17,Player(Rafael Nadal,ESP)),(11,Player(Andy Murray,GBR)),Match(G2,1,1))
((3,Player(Roger Federer,SUI)),(7,Player(Kei Nishikori,JPN)),Match(G1,1,1))
((1,Player(Novak Djokovic,SRB)),(5,Player(Tomas Berdych,CZE)),Match(G1,1,1))
((17,Player(Rafael Nadal,ESP)),(15,Player(Stan Wawrinka,SUI)),Match(G2,1,1))
((11,Player(Andy Murray,GBR)),(19,Player(David Ferrer,ESP)),Match(G2,1,1))
((3,Player(Roger Federer,SUI)),(15,Player(Stan Wawrinka,SUI)),Match(S,5,1))
((17,Player(Rafael Nadal,ESP)),(19,Player(David Ferrer,ESP)),Match(G2,1,1))
scala> //Print matches with player names and the match type and the resultscala> playGraph.triplets.map(triplet => triplet.srcAttr.name + " won over " + triplet.dstAttr.name + " in " + triplet.attr.matchType + " match").foreach(println)
Roger Federer won over Tomas Berdych in G1 match
Roger Federer won over Kei Nishikori in G1 match
Novak Djokovic won over Roger Federer in F match
Novak Djokovic won over Rafael Nadal in S match
Roger Federer won over Stan Wawrinka in S match
Rafael Nadal won over David Ferrer in G2 match
Kei Nishikori won over Tomas Berdych in G1 match
Andy Murray won over David Ferrer in G2 match
Stan Wawrinka won over Andy Murray in G2 match
Stan Wawrinka won over David Ferrer in G2 match
Novak Djokovic won over Kei Nishikori in G1 match
Roger Federer won over Novak Djokovic in G1 match
Rafael Nadal won over Andy Murray in G2 match
Rafael Nadal won over Stan Wawrinka in G2 match
Novak Djokovic won over Tomas Berdych in G1 match
值得注意的是,在图中使用三元组对于从单个对象中提取给定网球比赛的所需所有数据元素非常有用,包括谁在比赛、谁获胜以及比赛类型。以下分析用例的实现涉及筛选锦标赛的网球比赛记录。在这里,只使用了简单的过滤逻辑,但在现实世界的用例中,任何复杂的逻辑都可以在函数中实现,并且可以作为参数传递给过滤转换:
scala> //Group 1 winners with their group total points
scala> playGraph.triplets.filter(triplet => triplet.attr.matchType == "G1").map(triplet => (triplet.srcAttr.name, triplet.attr.points)).foreach(println)
(Kei Nishikori,1)
(Roger Federer,1)
(Roger Federer,1)
(Novak Djokovic,1)
(Novak Djokovic,1)
(Roger Federer,1)
scala> //Find the group total of the players
scala> playGraph.triplets.filter(triplet => triplet.attr.matchType == "G1").map(triplet => (triplet.srcAttr.name, triplet.attr.points)).reduceByKey(_+_).foreach(println)
(Roger Federer,3)
(Novak Djokovic,2)
(Kei Nishikori,1)
scala> //Group 2 winners with their group total points
scala> playGraph.triplets.filter(triplet => triplet.attr.matchType == "G2").map(triplet => (triplet.srcAttr.name, triplet.attr.points)).foreach(println)
(Rafael Nadal,1)
(Rafael Nadal,1)
(Andy Murray,1)
(Stan Wawrinka,1)
(Stan Wawrinka,1)
(Rafael Nadal,1)
以下分析用例的实现涉及按键分组并进行汇总计算。它不仅限于找到网球比赛记录点的总和,如以下用例实现所示;相反,可以使用用户定义的函数来进行计算:
scala> //Find the group total of the players
scala> playGraph.triplets.filter(triplet => triplet.attr.matchType == "G2").map(triplet => (triplet.srcAttr.name, triplet.attr.points)).reduceByKey(_+_).foreach(println)
(Stan Wawrinka,2)
(Andy Murray,1)
(Rafael Nadal,3)
scala> //Semi final winners with their group total points
scala> playGraph.triplets.filter(triplet => triplet.attr.matchType == "S").map(triplet => (triplet.srcAttr.name, triplet.attr.points)).foreach(println)
(Novak Djokovic,5)
(Roger Federer,5)
scala> //Find the group total of the players
scala> playGraph.triplets.filter(triplet => triplet.attr.matchType == "S").map(triplet => (triplet.srcAttr.name, triplet.attr.points)).reduceByKey(_+_).foreach(println)
(Novak Djokovic,5)
(Roger Federer,5)
scala> //Final winner with the group total points
scala> playGraph.triplets.filter(triplet => triplet.attr.matchType == "F").map(triplet => (triplet.srcAttr.name, triplet.attr.points)).foreach(println)
(Novak Djokovic,11)
scala> //Tournament total point standing
scala> playGraph.triplets.map(triplet => (triplet.srcAttr.name, triplet.attr.points)).reduceByKey(_+_).foreach(println)
(Stan Wawrinka,2)
(Rafael Nadal,3)
(Kei Nishikori,1)
(Andy Murray,1)
(Roger Federer,8)
(Novak Djokovic,18)
scala> //Find the winner of the tournament by finding the top scorer of the tournament
scala> playGraph.triplets.map(triplet => (triplet.srcAttr.name, triplet.attr.points)).reduceByKey(_+_).map{ case (k,v) => (v,k)}.sortByKey(ascending=false).take(1).map{ case (k,v) => (v,k)}.foreach(println)
(Novak Djokovic,18)
scala> //Find how many head to head matches held for a given set of players in the descending order of head2head count
scala> playGraph.triplets.map(triplet => (Set(triplet.srcAttr.name , triplet.dstAttr.name) , triplet.attr.head2HeadCount)).reduceByKey(_+_).map{case (k,v) => (k.mkString(" and "), v)}.map{ case (k,v) => (v,k)}.sortByKey().map{ case (k,v) => v + " played " + k + " time(s)"}.foreach(println)
Roger Federer and Novak Djokovic played 2 time(s)
Roger Federer and Tomas Berdych played 1 time(s)
Kei Nishikori and Tomas Berdych played 1 time(s)
Novak Djokovic and Tomas Berdych played 1 time(s)
Rafael Nadal and Andy Murray played 1 time(s)
Rafael Nadal and Stan Wawrinka played 1 time(s)
Andy Murray and David Ferrer played 1 time(s)
Rafael Nadal and David Ferrer played 1 time(s)
Stan Wawrinka and David Ferrer played 1 time(s)
Stan Wawrinka and Andy Murray played 1 time(s)
Roger Federer and Stan Wawrinka played 1 time(s)
Roger Federer and Kei Nishikori played 1 time(s)
Novak Djokovic and Kei Nishikori played 1 time(s)
Novak Djokovic and Rafael Nadal played 1 time(s)
以下分析用例的实现涉及从查询中查找唯一记录。Spark 的 distinct 转换可以做到这一点:
scala> //List of players who have won at least one match
scala> val winners = playGraph.triplets.map(triplet => triplet.srcAttr.name).distinct
winners: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[201] at distinct at <console>:65
scala> winners.foreach(println)
Kei Nishikori
Stan Wawrinka
Andy Murray
Roger Federer
Rafael Nadal
Novak Djokovic
scala> //List of players who have lost at least one match
scala> val loosers = playGraph.triplets.map(triplet => triplet.dstAttr.name).distinct
loosers: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[205] at distinct at <console>:65
scala> loosers.foreach(println)
Novak Djokovic
Kei Nishikori
David Ferrer
Stan Wawrinka
Andy Murray
Roger Federer
Rafael Nadal
Tomas Berdych
scala> //List of players who have won at least one match and lost at least one match
scala> val wonAndLost = winners.intersection(loosers)
wonAndLost: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[211] at intersection at <console>:69
scala> wonAndLost.foreach(println)
Novak Djokovic
Rafael Nadal
Andy Murray
Roger Federer
Kei Nishikori
Stan Wawrinka
scala> //List of players who have no wins at all
scala> val lostAndNoWins = loosers.collect().toSet -- wonAndLost.collect().toSet
lostAndNoWins:
scala.collection.immutable.Set[String] = Set(David Ferrer, Tomas Berdych)
scala> lostAndNoWins.foreach(println)
David Ferrer
Tomas Berdych
scala> //List of players who have no loss at all
scala> val wonAndNoLosses = winners.collect().toSet -- loosers.collect().toSet
wonAndNoLosses:
scala.collection.immutable.Set[String] = Set()
scala> //The val wonAndNoLosses returned an empty set which means that there is no single player in this tournament who have only wins
scala> wonAndNoLosses.foreach(println)
在这个用例中,并没有花费太多精力使结果看起来更美观,因为它们被简化为基于 RDD 的结构,可以使用书中最初章节中已经介绍过的 RDD 编程技术进行所需的操作。
Spark 的高效和统一的编程模型,结合 Spark GraphX 库,帮助开发者用很少的代码构建现实世界的用例。这也证明了,一旦用相关数据构建了正确的图结构,并支持相应的图操作,就可以揭示隐藏在底层数据中的许多真相。
应用 PageRank 算法
一篇名为《大规模超文本搜索引擎的解剖学》的研究论文,由谢尔盖·布林和拉里·佩奇撰写,彻底改变了网络搜索,谷歌的搜索引擎就是基于这种 PageRank 概念而建立的,并最终主导了其他网络搜索引擎。
当使用谷歌搜索网络时,其算法排名靠前的页面会被显示出来。在图论中,如果顶点根据相同的算法进行排名,而不是网页,就可以得出许多新的推论。从外部来看,这个 PageRank 算法可能听起来只对网络搜索有用。但它具有在许多其他领域应用的巨大潜力。
在图论中,如果存在一个边 E 连接两个顶点,从 V1 到 V2,根据 PageRank 算法,V2 比 V1 更重要。在一个由顶点和边组成的大型图中,可以计算每个顶点的 PageRank。
PageRank 算法可以很好地应用于前面章节中涵盖的网球锦标赛分析用例。在本节采用的图表示中,每场比赛由一条边表示。源顶点包含胜者的详细信息,目标顶点包含败者的详细信息。在网球比赛中,如果这可以称为某种虚构的重要性排名,那么在给定比赛中,胜者的重要性排名高于败者。
如果将前一个用例中的图用来演示 PageRank 算法,那么该图必须反转,使得每场比赛的胜者成为每条边的目标顶点。在 Scala REPL 提示符下,尝试以下语句:
scala> import org.apache.spark._
import org.apache.spark._
scala> import org.apache.spark.graphx._
import org.apache.spark.graphx._
scala> import org.apache.spark.rdd.RDD
import org.apache.spark.rdd.RDD
scala> //Define a property class that is going to hold all the properties of the vertex which is nothing but player informationscala> case class Player(name: String, country: String)
defined class Player
scala> // Create the player verticesscala> val players: RDD[(Long, Player)] = sc.parallelize(Array((1L, Player("Novak Djokovic", "SRB")), (3L, Player("Roger Federer", "SUI")),(5L, Player("Tomas Berdych", "CZE")), (7L, Player("Kei Nishikori", "JPN")), (11L, Player("Andy Murray", "GBR")),(15L, Player("Stan Wawrinka", "SUI")),(17L, Player("Rafael Nadal", "ESP")),(19L, Player("David Ferrer", "ESP"))))
players: org.apache.spark.rdd.RDD[(Long, Player)] = ParallelCollectionRDD[212] at parallelize at <console>:64
scala> //Define a property class that is going to hold all the properties of the edge which is nothing but match informationscala> case class Match(matchType: String, points: Int, head2HeadCount: Int)
defined class Match
scala> // Create the match edgesscala> val matches: RDD[Edge[Match]] = sc.parallelize(Array(Edge(1L, 5L, Match("G1", 1,1)), Edge(1L, 7L, Match("G1", 1,1)), Edge(3L, 1L, Match("G1", 1,1)), Edge(3L, 5L, Match("G1", 1,1)), Edge(3L, 7L, Match("G1", 1,1)), Edge(7L, 5L, Match("G1", 1,1)), Edge(11L, 19L, Match("G2", 1,1)), Edge(15L, 11L, Match("G2", 1, 1)), Edge(15L, 19L, Match("G2", 1, 1)), Edge(17L, 11L, Match("G2", 1, 1)), Edge(17L, 15L, Match("G2", 1, 1)), Edge(17L, 19L, Match("G2", 1, 1)), Edge(3L, 15L, Match("S", 5, 1)), Edge(1L, 17L, Match("S", 5, 1)), Edge(1L, 3L, Match("F", 11, 1))))
matches: org.apache.spark.rdd.RDD[org.apache.spark.graphx.Edge[Match]] = ParallelCollectionRDD[213] at parallelize at <console>:64
scala> //Create a graph with the vertices and edgesscala> val playGraph = Graph(players, matches)
playGraph: org.apache.spark.graphx.Graph[Player,Match] = org.apache.spark.graphx.impl.GraphImpl@263cd0e2
scala> //Reverse this graph to have the winning player coming in the destination vertex
scala> val rankGraph = playGraph.reverse
rankGraph: org.apache.spark.graphx.Graph[Player,Match] = org.apache.spark.graphx.impl.GraphImpl@7bb131fb
scala> //Run the PageRank algorithm to calculate the rank of each vertex
scala> val rankedVertices = rankGraph.pageRank(0.0001).vertices
rankedVertices: org.apache.spark.graphx.VertexRDD[Double] = VertexRDDImpl[1184] at RDD at VertexRDD.scala:57
scala> //Extract the vertices sorted by the rank
scala> val rankedPlayers = rankedVertices.join(players).map{case
(id,(importanceRank,Player(name,country))) => (importanceRank,
name)}.sortByKey(ascending=false)
rankedPlayers: org.apache.spark.rdd.RDD[(Double, String)] = ShuffledRDD[1193] at sortByKey at <console>:76
scala> rankedPlayers.collect().foreach(println)
(3.382662570589846,Novak Djokovic)
(3.266079758089846,Roger Federer)
(0.3908953124999999,Rafael Nadal)
(0.27431249999999996,Stan Wawrinka)
(0.1925,Andy Murray)
(0.1925,Kei Nishikori)
(0.15,David Ferrer)
(0.15,Tomas Berdych)
如果仔细审查前面的代码,可以看出排名最高的玩家赢得了最多的比赛。
连通分量算法
在一个图中,找到由连通顶点组成的子图是一个非常常见的需求,具有巨大的应用。在任何图中,两个顶点通过由一个或多个边组成的路径相互连接,并且不与图中任何其他顶点连接,这些顶点被称为连通分量。例如,在一个图 G 中,顶点 V1 通过一条边与 V2 连接,V2 通过另一条边与 V3 连接。在同一个图 G 中,顶点 V4 通过另一条边与 V5 连接。在这种情况下,V1 和 V3 是连通的,V4 和 V5 是连通的,而 V1 和 V5 是不连通的。在图 G 中,有两个连通分量。Spark GraphX 库实现了连通分量算法。
在社交网络应用程序中,如果将用户之间的连接建模为图,检查给定用户是否与另一个用户连接,可以通过检查是否存在包含这两个顶点的连通分量来实现。在计算机游戏中,从点 A 到点 B 的迷宫穿越可以通过将迷宫的交汇点建模为顶点,将连接交汇点的路径建模为图中的边来使用连通分量算法完成。
在计算机网络中,通过使用连通分量算法来检查是否可以从一个 IP 地址向另一个 IP 地址发送数据包。在物流应用中,例如快递服务,通过使用连通分量算法来检查是否可以从点 A 向点 B 发送数据包。图 6显示了具有三个连通分量的图:

图 6
图 6是图的图形表示。在其中,有三个通过边连接的顶点簇。换句话说,在这个图中有三个连通分量。
这里再次以社交网络应用中用户相互关注的使用场景为例进行说明。通过提取图的连通分量,可以查看任何两个用户是否相连。图 7 展示了用户图:

图 7
在 图 7 所示的图中,很明显存在两个连通分量。可以说托马斯和马修是相连的,同时托马斯和马丁是不相连的。如果提取连通分量图,可以看到托马斯和马丁将具有相同的连通分量标识符,同时,托马斯和马丁将具有不同的连通分量标识符。在 Scala REPL 提示符下,尝试以下语句:
scala> import org.apache.spark._
import org.apache.spark._
scala> import org.apache.spark.graphx._
import org.apache.spark.graphx._
scala> import org.apache.spark.rdd.RDD
import org.apache.spark.rdd.RDD
scala> // Create the RDD with users as the vertices
scala> val users: RDD[(Long, String)] = sc.parallelize(Array((1L, "Thomas"), (2L, "Krish"),(3L, "Mathew"), (4L, "Martin"), (5L, "George"), (6L, "James")))
users: org.apache.spark.rdd.RDD[(Long, String)] = ParallelCollectionRDD[1194] at parallelize at <console>:69
scala> // Create the edges connecting the users
scala> val userRelationships: RDD[Edge[String]] = sc.parallelize(Array(Edge(1L, 2L, "Follows"),Edge(2L, 3L, "Follows"), Edge(4L, 5L, "Follows"), Edge(5L, 6L, "Follows")))
userRelationships: org.apache.spark.rdd.RDD[org.apache.spark.graphx.Edge[String]] = ParallelCollectionRDD[1195] at parallelize at <console>:69
scala> // Create a graph
scala> val userGraph = Graph(users, userRelationships)
userGraph: org.apache.spark.graphx.Graph[String,String] = org.apache.spark.graphx.impl.GraphImpl@805e363
scala> // Find the connected components of the graph
scala> val cc = userGraph.connectedComponents()
cc: org.apache.spark.graphx.Graph[org.apache.spark.graphx.VertexId,String] = org.apache.spark.graphx.impl.GraphImpl@13f4a9a9
scala> // Extract the triplets of the connected components
scala> val ccTriplets = cc.triplets
ccTriplets: org.apache.spark.rdd.RDD[org.apache.spark.graphx.EdgeTriplet[org.apache.spark.graphx.VertexId,String]] = MapPartitionsRDD[1263] at mapPartitions at GraphImpl.scala:48
scala> // Print the structure of the tripletsscala> ccTriplets.foreach(println)
((1,1),(2,1),Follows)
((4,4),(5,4),Follows)
((5,4),(6,4),Follows)
((2,1),(3,1),Follows)
scala> //Print the vertex numbers and the corresponding connected component id. The connected component id is generated by the system and it is to be taken only as a unique identifier for the connected component
scala> val ccProperties = ccTriplets.map(triplet => "Vertex " + triplet.srcId + " and " + triplet.dstId + " are part of the CC with id " + triplet.srcAttr)
ccProperties: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[1264] at map at <console>:79
scala> ccProperties.foreach(println)
Vertex 1 and 2 are part of the CC with id 1
Vertex 5 and 6 are part of the CC with id 4
Vertex 2 and 3 are part of the CC with id 1
Vertex 4 and 5 are part of the CC with id 4
scala> //Find the users in the source vertex with their CC id
scala> val srcUsersAndTheirCC = ccTriplets.map(triplet => (triplet.srcId, triplet.srcAttr))
srcUsersAndTheirCC: org.apache.spark.rdd.RDD[(org.apache.spark.graphx.VertexId, org.apache.spark.graphx.VertexId)] = MapPartitionsRDD[1265] at map at <console>:79
scala> //Find the users in the destination vertex with their CC id
scala> val dstUsersAndTheirCC = ccTriplets.map(triplet => (triplet.dstId, triplet.dstAttr))
dstUsersAndTheirCC: org.apache.spark.rdd.RDD[(org.apache.spark.graphx.VertexId, org.apache.spark.graphx.VertexId)] = MapPartitionsRDD[1266] at map at <console>:79
scala> //Find the union
scala> val usersAndTheirCC = srcUsersAndTheirCC.union(dstUsersAndTheirCC)
usersAndTheirCC: org.apache.spark.rdd.RDD[(org.apache.spark.graphx.VertexId, org.apache.spark.graphx.VertexId)] = UnionRDD[1267] at union at <console>:83
scala> //Join with the name of the users
scala> val usersAndTheirCCWithName = usersAndTheirCC.join(users).map{case (userId,(ccId,userName)) => (ccId, userName)}.distinct.sortByKey()
usersAndTheirCCWithName: org.apache.spark.rdd.RDD[(org.apache.spark.graphx.VertexId, String)] = ShuffledRDD[1277] at sortByKey at <console>:85
scala> //Print the user names with their CC component id. If two users share the same CC id, then they are connected
scala> usersAndTheirCCWithName.collect().foreach(println)
(1,Thomas)
(1,Mathew)
(1,Krish)
(4,Martin)
(4,James)
(4,George)
Spark GraphX 库中还有一些其他的图处理算法,对完整算法集的详细处理足以写成一本书。这里要说明的是,Spark GraphX 库提供了非常易于使用的图算法,这些算法非常适合 Spark 的统一编程模型。
理解 GraphFrames
Spark GraphX 库是支持编程语言最少的图处理库。Scala 是 Spark GraphX 库唯一支持的编程语言。GraphFrames 是一个由 Databricks、加州大学伯克利分校和麻省理工学院开发的新的图处理库,作为一个外部 Spark 包提供,它建立在 Spark DataFrames 之上。由于它是建立在 DataFrames 之上的,因此 DataFrame 上可以进行的所有操作在 GraphFrames 上都有可能实现,并支持 Scala、Java、Python 和 R 等编程语言,具有统一的 API。由于 GraphFrames 是建立在 DataFrames 之上的,因此数据的持久性、对众多数据源的支持以及 Spark SQL 中的强大图查询是用户免费获得的额外好处。
就像 Spark GraphX 库一样,在 GraphFrames 中,数据存储在顶点和边中。顶点和边使用 DataFrame 作为数据结构。本章开头讨论的第一个用例再次用于说明基于 GraphFrames 的图处理。
注意
警告:GraphFrames 是一个外部 Spark 包。它与 Spark 2.0 存在一些不兼容性。因此,以下代码片段在 Spark 2.0 中将无法工作。它们在 Spark 1.6 中可以工作。请参考他们的网站以检查 Spark 2.0 的支持情况。
在 Spark 1.6 的 Scala REPL 提示符下,尝试以下语句。由于 GraphFrames 是一个外部 Spark 包,在启动适当的 REPL 时,必须导入库,并在终端提示符中使用以下命令来启动 REPL 并确保库加载时没有错误信息:
$ cd $SPARK_1.6__HOME
$ ./bin/spark-shell --packages graphframes:graphframes:0.1.0-spark1.6
Ivy Default Cache set to: /Users/RajT/.ivy2/cache
The jars for the packages stored in: /Users/RajT/.ivy2/jars
:: loading settings :: url = jar:file:/Users/RajT/source-code/spark-source/spark-1.6.1
/assembly/target/scala-2.10/spark-assembly-1.6.2-SNAPSHOT-hadoop2.2.0.jar!
/org/apache/ivy/core/settings/ivysettings.xml
graphframes#graphframes added as a dependency
:: resolving dependencies :: org.apache.spark#spark-submit-parent;1.0
confs: [default]
found graphframes#graphframes;0.1.0-spark1.6 in list
:: resolution report :: resolve 153ms :: artifacts dl 2ms
:: modules in use:
graphframes#graphframes;0.1.0-spark1.6 from list in [default]
---------------------------------------------------------------------
| | modules || artifacts |
| conf | number| search|dwnlded|evicted|| number|dwnlded|
---------------------------------------------------------------------
| default | 1 | 0 | 0 | 0 || 1 | 0 |
---------------------------------------------------------------------
:: retrieving :: org.apache.spark#spark-submit-parent
confs: [default]
0 artifacts copied, 1 already retrieved (0kB/5ms)
16/07/31 09:22:11 WARN NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/___/ .__/\_,_/_/ /_/\_\ version 1.6.1
/_/
Using Scala version 2.10.5 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_66)
Type in expressions to have them evaluated.
Type :help for more information.
Spark context available as sc.
SQL context available as sqlContext.
scala> import org.graphframes._
import org.graphframes._
scala> import org.apache.spark.rdd.RDD
import org.apache.spark.rdd.RDD
scala> import org.apache.spark.sql.Row
import org.apache.spark.sql.Row
scala> import org.apache.spark.graphx._
import org.apache.spark.graphx._
scala> //Create a DataFrame of users containing tuple values with a mandatory Long and another String type as the property of the vertex
scala> val users = sqlContext.createDataFrame(List((1L, "Thomas"),(2L, "Krish"),(3L, "Mathew"))).toDF("id", "name")
users: org.apache.spark.sql.DataFrame = [id: bigint, name: string]
scala> //Created a DataFrame for Edge with String type as the property of the edge
scala> val userRelationships = sqlContext.createDataFrame(List((1L, 2L, "Follows"),(1L, 2L, "Son"),(2L, 3L, "Follows"))).toDF("src", "dst", "relationship")
userRelationships: org.apache.spark.sql.DataFrame = [src: bigint, dst: bigint, relationship: string]
scala> val userGraph = GraphFrame(users, userRelationships)
userGraph: org.graphframes.GraphFrame = GraphFrame(v:[id: bigint, name: string], e:[src: bigint, dst: bigint, relationship: string])
scala> // Vertices in the graph
scala> userGraph.vertices.show()
+---+------+
| id| name|
+---+------+
| 1|Thomas|
| 2| Krish|
| 3|Mathew|
+---+------+
scala> // Edges in the graph
scala> userGraph.edges.show()
+---+---+------------+
|src|dst|relationship|
+---+---+------------+
| 1| 2| Follows|
| 1| 2| Son|
| 2| 3| Follows|
+---+---+------------+
scala> //Number of edges in the graph
scala> val edgeCount = userGraph.edges.count()
edgeCount: Long = 3
scala> //Number of vertices in the graph
scala> val vertexCount = userGraph.vertices.count()
vertexCount: Long = 3
scala> //Number of edges coming to each of the vertex.
scala> userGraph.inDegrees.show()
+---+--------+
| id|inDegree|
+---+--------+
| 2| 2|
| 3| 1|
+---+--------+
scala> //Number of edges going out of each of the vertex.
scala> userGraph.outDegrees.show()
+---+---------+
| id|outDegree|
+---+---------+
| 1| 2|
| 2| 1|
+---+---------+
scala> //Total number of edges coming in and going out of each vertex.
scala> userGraph.degrees.show()
+---+------+
| id|degree|
+---+------+
| 1| 2|
| 2| 3|
| 3| 1|
+---+------+
scala> //Get the triplets of the graph
scala> userGraph.triplets.show()
+-------------+----------+----------+
| edge| src| dst|
+-------------+----------+----------+
|[1,2,Follows]|[1,Thomas]| [2,Krish]|
| [1,2,Son]|[1,Thomas]| [2,Krish]|
|[2,3,Follows]| [2,Krish]|[3,Mathew]|
+-------------+----------+----------+
scala> //Using the DataFrame API, apply filter and select only the needed edges
scala> val numFollows = userGraph.edges.filter("relationship = 'Follows'").count()
numFollows: Long = 2
scala> //Create an RDD of users containing tuple values with a mandatory Long and another String type as the property of the vertex
scala> val usersRDD: RDD[(Long, String)] = sc.parallelize(Array((1L, "Thomas"), (2L, "Krish"),(3L, "Mathew")))
usersRDD: org.apache.spark.rdd.RDD[(Long, String)] = ParallelCollectionRDD[54] at parallelize at <console>:35
scala> //Created an RDD of Edge type with String type as the property of the edge
scala> val userRelationshipsRDD: RDD[Edge[String]] = sc.parallelize(Array(Edge(1L, 2L, "Follows"), Edge(1L, 2L, "Son"),Edge(2L, 3L, "Follows")))
userRelationshipsRDD: org.apache.spark.rdd.RDD[org.apache.spark.graphx.Edge[String]] = ParallelCollectionRDD[55] at parallelize at <console>:35
scala> //Create a graph containing the vertex and edge RDDs as created before
scala> val userGraphXFromRDD = Graph(usersRDD, userRelationshipsRDD)
userGraphXFromRDD: org.apache.spark.graphx.Graph[String,String] =
org.apache.spark.graphx.impl.GraphImpl@77a3c614
scala> //Create the GraphFrame based graph from Spark GraphX based graph
scala> val userGraphFrameFromGraphX: GraphFrame = GraphFrame.fromGraphX(userGraphXFromRDD)
userGraphFrameFromGraphX: org.graphframes.GraphFrame = GraphFrame(v:[id: bigint, attr: string], e:[src: bigint, dst: bigint, attr: string])
scala> userGraphFrameFromGraphX.triplets.show()
+-------------+----------+----------+
| edge| src| dst|
+-------------+----------+----------+
|[1,2,Follows]|[1,Thomas]| [2,Krish]|
| [1,2,Son]|[1,Thomas]| [2,Krish]|
|[2,3,Follows]| [2,Krish]|[3,Mathew]|
+-------------+----------+----------+
scala> // Convert the GraphFrame based graph to a Spark GraphX based graph
scala> val userGraphXFromGraphFrame: Graph[Row, Row] = userGraphFrameFromGraphX.toGraphX
userGraphXFromGraphFrame: org.apache.spark.graphx.Graph[org.apache.spark.sql.Row,org.apache.spark.sql.Row] = org.apache.spark.graphx.impl.GraphImpl@238d6aa2
在创建 GraphFrame 的 DataFrame 时,需要注意的唯一事项是,对于顶点和边有一些强制性的列。在顶点的 DataFrame 中,id 列是强制性的。在边的 DataFrame 中,src 和 dst 列是强制性的。除此之外,可以存储任意数量的任意列,与 GraphFrame 的顶点和边一起。在 Spark GraphX 库中,顶点标识符必须是长整数,但 GraphFrame 没有这样的限制,支持任何类型的顶点标识符。读者应该已经熟悉 DataFrame;可以在 GraphFrame 的顶点和边上执行任何可以在 DataFrame 上执行的操作。
小贴士
Spark GraphX 支持的 所有图处理算法,GraphFrames 也支持。
GraphFrames 的 Python 版本功能较少。由于 Python 不是 Spark GraphX 库的支持编程语言,因此 Python 中不支持 GraphFrame 到 GraphX 和 GraphX 到 GraphFrame 的转换。由于读者熟悉使用 Python 在 Spark 中创建 DataFrame,因此这里省略了 Python 示例。此外,GraphFrames API 的 Python 版本中还有一些悬而未决的缺陷,并且截至写作时,之前使用 Scala 函数演示的所有功能在 Python 中可能无法正常工作。
理解 GraphFrames 查询
Spark GraphX 库是基于 RDD 的图处理库,但 GraphFrames 是一个基于 Spark DataFrame 的图处理库,它作为一个外部包提供。Spark GraphX 支持许多图处理算法,但 GraphFrames 不仅支持图处理算法,还支持图查询。图处理算法和图查询之间的主要区别在于,图处理算法用于处理图数据结构中隐藏的数据,而图查询用于在图数据结构中隐藏的数据中搜索模式。在 GraphFrame 的术语中,图查询也被称为模式发现。这在处理序列模式的遗传学和其他生物科学领域有巨大的应用。
从用例的角度来看,以社交媒体应用中用户相互关注为例。用户之间存在关系。在前面的章节中,这些关系被建模为图。在现实世界的用例中,这样的图可以变得非常大,如果需要找到在两个方向上都有关系的用户,这可以表示为图查询中的模式,并且可以使用简单的程序结构找到这样的关系。以下演示在 GraphFrame 中建模用户之间的关系,并使用该模式进行搜索。
在 Spark 1.6 的 Scala REPL 提示符下,尝试以下语句:
$ cd $SPARK_1.6_HOME
$ ./bin/spark-shell --packages graphframes:graphframes:0.1.0-spark1.6
Ivy Default Cache set to: /Users/RajT/.ivy2/cache
The jars for the packages stored in: /Users/RajT/.ivy2/jars
:: loading settings :: url = jar:file:/Users/RajT/source-code/spark-source/spark-1.6.1/assembly/target/scala-2.10/spark-assembly-1.6.2-SNAPSHOT-hadoop2.2.0.jar!/org/apache/ivy/core/settings/ivysettings.xml
graphframes#graphframes added as a dependency
:: resolving dependencies :: org.apache.spark#spark-submit-parent;1.0
confs: [default]
found graphframes#graphframes;0.1.0-spark1.6 in list
:: resolution report :: resolve 145ms :: artifacts dl 2ms
:: modules in use:
graphframes#graphframes;0.1.0-spark1.6 from list in [default]
---------------------------------------------------------------------
| | modules || artifacts |
| conf | number| search|dwnlded|evicted|| number|dwnlded|
---------------------------------------------------------------------
| default | 1 | 0 | 0 | 0 || 1 | 0 |
---------------------------------------------------------------------
:: retrieving :: org.apache.spark#spark-submit-parent
confs: [default]
0 artifacts copied, 1 already retrieved (0kB/5ms)
16/07/29 07:09:08 WARN NativeCodeLoader:
Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/___/ .__/\_,_/_/ /_/\_\ version 1.6.1
/_/
Using Scala version 2.10.5 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_66)
Type in expressions to have them evaluated.
Type :help for more information.
Spark context available as sc.
SQL context available as sqlContext.
scala> import org.graphframes._
import org.graphframes._
scala> import org.apache.spark.rdd.RDD
import org.apache.spark.rdd.RDD
scala> import org.apache.spark.sql.Row
import org.apache.spark.sql.Row
scala> import org.apache.spark.graphx._
import org.apache.spark.graphx._
scala> //Create a DataFrame of users containing tuple values with a mandatory String field as id and another String type as the property of the vertex. Here it can be seen that the vertex identifier is no longer a long integer.
scala> val users = sqlContext.createDataFrame(List(("1", "Thomas"),("2", "Krish"),("3", "Mathew"))).toDF("id", "name")
users: org.apache.spark.sql.DataFrame = [id: string, name: string]
scala> //Create a DataFrame for Edge with String type as the property of the edge
scala> val userRelationships = sqlContext.createDataFrame(List(("1", "2", "Follows"),("2", "1", "Follows"),("2", "3", "Follows"))).toDF("src", "dst", "relationship")
userRelationships: org.apache.spark.sql.DataFrame = [src: string, dst: string, relationship: string]
scala> //Create the GraphFrame
scala> val userGraph = GraphFrame(users, userRelationships)
userGraph: org.graphframes.GraphFrame = GraphFrame(v:[id: string, name: string], e:[src: string, dst: string, relationship: string])
scala> // Search for pairs of users who are following each other
scala> // In other words the query can be read like this. Find the list of users having a pattern such that user u1 is related to user u2 using the edge e1 and user u2 is related to the user u1 using the edge e2\. When a query is formed like this, the result will list with columns u1, u2, e1 and e2\. When modelling real-world use cases, more meaningful variables can be used suitable for the use case.
scala> val graphQuery = userGraph.find("(u1)-[e1]->(u2); (u2)-[e2]->(u1)")
graphQuery: org.apache.spark.sql.DataFrame = [e1: struct<src:string,dst:string,relationship:string>, u1: struct<
d:string,name:string>, u2: struct<id:string,name:string>, e2: struct<src:string,dst:string,relationship:string>]
scala> graphQuery.show()
+-------------+----------+----------+-------------+
| e1| u1| u2| e2|
+-------------+----------+----------+-------------+
|[1,2,Follows]|[1,Thomas]| [2,Krish]|[2,1,Follows]|
|[2,1,Follows]| [2,Krish]|[1,Thomas]|[1,2,Follows]|
+-------------+----------+----------+-------------+
注意,图查询结果中的列是由搜索模式中给出的元素组成的。模式的形成方式没有限制。
注意
注意图查询结果的数据类型。它是一个 DataFrame 对象。这为使用熟悉的 Spark SQL 库处理查询结果带来了极大的灵活性。
Spark GraphX 库的最大局限性是其 API 目前不支持 Python 和 R 等编程语言。由于 GraphFrames 是一个基于 DataFrame 的库,一旦它成熟,它将使所有支持 DataFrame 的编程语言都能进行图处理。这个 Spark 外部包无疑是作为 Spark 一部分的潜在候选者。
参考文献
如需更多信息,请访问以下链接:
摘要
图是一种非常有用的数据结构,具有巨大的应用潜力。尽管在大多数应用中并不常用,但有一些独特的应用场景,在这些场景中使用图作为数据结构是必不可少的。只有当数据结构与经过良好测试和高度优化的算法结合使用时,数据结构才能有效利用。数学家和计算机科学家已经提出了许多算法来处理图数据结构中的数据。Spark GraphX 库在 Spark 核心之上实现了大量此类算法。本章简要介绍了 Spark GraphX 库,并通过入门级别的用例覆盖了一些基础知识。
名为 GraphFrames 的基于 DataFrame 的图抽象,它是一个外部 Spark 包,可以从 Spark 独立获取,在图处理和图查询方面具有巨大的潜力。为了进行图查询以找到图中的模式,已提供了对该外部 Spark 包的简要介绍。
任何一本教授新技术书籍都必须以一个涵盖其显著特性的应用来结尾。Spark 也不例外。到目前为止,本书已经涵盖了 Spark 作为一个下一代数据处理平台的内容。现在,是时候将所有松散的环节串联起来,构建一个端到端的应用程序了。下一章将介绍使用 Spark 及其构建在其之上的库族来设计和开发数据处理应用程序。
第九章:设计 Spark 应用程序
以函数式思维。以像管道一样设计的应用程序功能,每个部分都连接在一起,完成整个工作的某个部分。这全部关于数据处理,这正是 Spark 以高度灵活的方式所做的事情。数据处理从进入处理管道的种子数据开始。种子数据可以是系统摄取的新数据,也可以是某种存储在企业数据存储中的主数据集,需要被切割和重组以产生不同的视图,服务于各种目的和业务需求。在设计和发展数据处理应用程序时,这种切割和重组将成为常态。
任何应用程序开发练习都是从研究领域、业务需求s和技术工具选择开始的。这里也不例外。尽管本章将探讨 Spark 应用的设计和开发,但最初的焦点将放在数据处理应用程序的整体架构、用例、数据以及将数据从一种状态转换到另一种状态的应用程序上。Spark 只是一个将数据处理逻辑和数据组合在一起的驱动程序,利用其高度强大的基础设施产生所需的结果。
本章我们将涵盖以下主题:
-
Lambda 架构
-
使用 Spark 进行微博
-
数据字典
-
编码风格
-
数据摄取
Lambda 架构
应用程序架构对于任何类型的软件开发都非常重要。它是决定软件如何构建的蓝图,具有相当程度的通用性和在需要时定制的功能。对于常见的应用程序需求,有一些流行的架构可供选择,使用它们不需要任何从头开始的架构努力。这些公共架构框架是由一些最优秀的大脑为公众的利益而设计的。这些流行的架构非常有用,因为它们没有进入障碍,并且被许多人使用。有流行的架构可供网络应用程序开发、数据处理等使用。
Lambda 架构是一种近期流行且理想的架构,适用于开发数据处理应用程序。市场上有很多工具和技术可用于开发数据处理应用程序。但独立于技术,数据处理应用程序组件的分层和组合是由架构框架驱动的。这就是为什么 Lambda 架构是一个技术无关的架构框架,根据需要,可以选择适当的技术来开发各个组件。图 1 捕捉了 Lambda 架构的精髓:

图 1
Lambda 架构由三层组成:
-
批处理层是主要的数据存储。任何类型的处理都发生在这个数据集上。这是金数据集。
-
服务层处理主数据集并为特定目的准备视图,在这里它们被称为预定视图。这个处理的中介步骤是必要的,用于服务查询或为特定需求生成输出。查询和特定数据集准备不直接访问主数据集。
-
速度层完全是关于数据流处理。数据流以实时方式处理,如果这是业务需求,则会准备易变的实时视图。查询或特定生成输出的过程可能从预定的数据视图和实时视图中消耗数据。
使用 Lambda 架构的原则来构建大数据处理系统,这里将使用 Spark 作为数据处理工具。Spark 非常适合所有三个不同层次的数据处理需求。
本章将讨论一些选定的微博应用数据处理用例。应用程序功能、其部署基础设施和可扩展性因素超出了本工作的范围。在典型的批处理层中,主数据集可以是普通的可分割序列化格式或 NoSQL 数据存储,具体取决于数据访问方法。如果应用程序用例都是批量操作,则标准序列化格式就足够了。但如果用例需要随机访问,NoSQL 数据存储将是理想的。在这里,为了简化,所有数据文件都存储在本地纯文本文件中。
典型的应用程序开发最终会形成一个完全功能的应用程序。但在这里,用例是在 Spark 数据处理应用程序中实现的。数据处理始终作为主应用程序功能的一部分,并计划以批量模式运行或作为监听器等待数据并处理它。因此,针对每个用例,都会开发单独的 Spark 应用程序,并且可以根据情况安排或使其以监听器模式运行。
使用 Lambda 架构进行微博
博客作为一种发布媒介已经存在了几十年,形式各异。在博客初期作为发布媒介的日子里,只有专业或渴望成为作家的作者通过博客发布文章。这导致了一个错误的观念,即只有严肃的内容才会通过博客发布。近年来,微博的概念让公众也加入了博客文化。微博是以几句话、照片、视频或链接的形式突然爆发出的个人思维过程。像 Twitter 和 Tumblr 这样的网站通过数亿活跃用户使用网站的方式,在最大规模上普及了这种文化。
SfbMicroBlog 概述
SfbMicroBlog是一个拥有数百万用户发布短消息的微博应用。即将使用此应用的新用户需要使用用户名和密码进行注册。要发布消息,用户必须先登录。用户在不登录的情况下唯一能做的就是阅读用户发布的公开消息。用户可以关注其他用户。关注是一种单向关系。如果用户 A 关注用户 B,用户 A 可以看到用户 B 发布的所有消息;同时,用户 B 不能看到用户 A 发布的消息,因为用户 B 没有关注用户 A。默认情况下,所有用户发布的消息都是公开消息,可以被所有人看到。但用户有设置,可以使消息只对关注消息所有者的用户可见。成为关注者后,取消关注也是允许的。
用户名必须在所有用户中是唯一的。登录需要用户名和密码。每个用户都必须有一个主要电子邮件地址,没有这个地址,注册过程将不会完成。为了额外的安全性和密码恢复,可以在个人资料中保存备用电子邮件地址或手机号码。
消息长度不得超过 140 个字符。消息可以包含以#符号开头的前缀词,以将它们分组到不同的主题下。消息可以包含以@符号开头的前缀用户名,以通过发布的消息直接向用户发送。换句话说,用户可以在他们的消息中提及任何其他用户,而无需成为其关注者。
一旦发布,消息就不能更改。一旦发布,消息就不能删除。
熟悉数据
所有进入主数据集的数据都通过一个流进入。数据流被处理,检查每条消息的适当标题,并执行将其存储在数据存储中的正确操作。以下列表包含通过同一流进入存储的重要数据项:
-
用户: 此数据集包含用户在登录或用户数据变更时的详细信息
-
关注者: 此数据集包含当用户选择关注另一个用户时捕获的关系数据
-
消息: 此数据集包含注册用户发布的消息
此数据集列表构成了黄金数据集。基于此主数据集,创建了各种视图,以满足应用中关键业务功能的需求。以下列表包含主数据集的重要视图:
-
用户消息: 此视图包含系统中每个用户发布的消息。当特定用户想要查看他们自己发布的消息时,使用此视图生成的数据。这也被给定用户的关注者使用。这是一个主要数据集用于特定目的的情况。消息数据集为该视图提供了所有所需的数据。
-
用户消息:在消息中,可以通过在收件人用户名前加上@符号来指定特定用户。此数据视图包含使用@符号指定的用户和相应的消息。在实现中有一个限制:一条消息中只能有一个收件人。
-
标记消息:在消息中,以#符号开头的前缀单词成为可搜索的消息。例如,消息中的#spark 单词表示该消息可以通过#spark 进行搜索。对于给定的标签,用户可以看到所有公开消息以及他/她关注的用户的消息,都在一个列表中。此视图包含标签及其对应消息的配对。在实现中有一个限制:一条消息中只能有一个标签。
-
关注用户:此视图包含关注给定用户的用户列表。在图 2中,用户U1和U3在关注U4的用户列表中。
-
关注用户:此视图包含由给定用户关注的用户列表。在图 2中,用户U2和U4在用户U1关注的用户列表中:

图 2
简而言之,图 3给出了 Lambda 架构视图的解决方案,并提供了数据集和相应视图的详细信息:

图 3
设置数据字典
数据字典描述了数据、其含义以及与其他数据项的关系。对于 SfbMicroBlog 应用,数据字典将是一个非常简约的,以实现选定的用例。以此为基础,读者可以扩展并实现他们自己的数据项,并包括数据处理用例。数据字典提供了所有主数据集以及数据视图。
以下表格显示了用户数据集的数据项:
| 用户数据 | 类型 | 用途 |
|---|---|---|
| Id | 长整型 | 用于唯一标识用户,同时也是用户关系图中的顶点标识符 |
| Username | 字符串 | 用于唯一标识系统用户 |
| 名字 | 字符串 | 用于捕获用户的第一个名字 |
| 姓氏 | 字符串 | 用于捕获用户的姓氏 |
| 电子邮件 | 字符串 | 用于与用户通信 |
| 备用电子邮件 | 字符串 | 用于密码恢复 |
| 主要电话 | 字符串 | 用于密码恢复 |
以下表格捕获了关注者数据集的数据项:
| 关注者数据 | 类型 | 用途 |
|---|---|---|
| 关注者用户名 | 字符串 | 用于识别关注者 |
| 被关注用户名 | 字符串 | 用于识别被关注的人 |
以下表格捕获了消息数据集的数据项:
| 消息数据 | 类型 | 用途 |
|---|---|---|
| Username | 字符串 | 用于捕获发布消息的用户 |
| 消息 ID | 长整型 | 用于唯一标识一条消息 |
| 消息 | 字符串 | 用于捕获正在发布的消息 |
| 时间戳 | 长整型 | 用于捕获消息发布的时间 |
以下表格捕获了消息到用户视图的数据项:
| 消息到用户数据 | 类型 | 用途 |
|---|---|---|
| 发送者用户名 | 字符串 | 用于捕获发布消息的用户 |
| 目标用户名 | 字符串 | 用于捕获消息的目标用户;它是前缀为@符号的用户名 |
| 消息 ID | 长整型 | 用于唯一标识一条消息 |
| 消息 | 字符串 | 用于捕获正在发布的消息 |
| 时间戳 | 长整型 | 用于捕获消息发布的时间 |
以下表格捕获了标记消息视图的数据项:
| 标记消息数据 | 类型 | 用途 |
|---|---|---|
| 标签 | 字符串 | 前缀为#符号的单词 |
| 用户名 | 字符串 | 用于捕获发布消息的用户 |
| 消息 ID | 长整型 | 用于唯一标识一条消息 |
| 消息 | 字符串 | 用于捕获正在发布的消息 |
| 时间戳 | 长整型 | 用于捕获消息发布的时间 |
用户的关注关系非常直接,由存储在数据存储中的用户标识号对组成。
实现 Lambda 架构
Lambda 架构的概念在本章的开头被引入。由于它是一个与技术无关的架构框架,当使用它设计应用程序时,捕捉特定实现中使用的具体技术选择是至关重要的。以下各节正是这样做的。
批处理层
批处理层的核心是一个数据存储。对于大数据应用,数据存储的选择有很多。通常,Hadoop 分布式文件系统(HDFS)与 Hadoop YARN 结合是当前和被接受的平台,数据存储在其中,主要是因为它能够在 Hadoop 集群中分区和分布数据。
任何持久存储支持两种类型的数据访问:
-
批处理读写
-
随机读写
这两者都需要单独的数据存储解决方案。对于批数据处理操作,通常使用可分割的序列化格式,如 Avro 和 Parquet。对于随机数据操作,通常使用 NoSQL 数据存储。其中一些 NoSQL 解决方案建立在 HDFS 之上,而另一些则不是。它们是否建立在 HDFS 之上并不重要,它们都提供了数据的分区和分布。因此,根据用例和正在使用的分布式平台,可以使用适当的解决方案。
当谈到在 HDFS 中存储数据时,常用的格式如 XML 和 JSON 失败,因为 HDFS 会分区和分发文件。当这种情况发生时,这些格式有开标签和结束标签,并且文件中的随机位置分割会使数据变得混乱。因此,可分割的文件格式如 Avro 或 Parquet 在 HDFS 中存储时效率更高。
当谈到 NoSQL 数据存储解决方案时,市场上有很多选择,尤其是来自开源世界的。其中一些 NoSQL 数据存储,如 Hbase,位于 HDFS 之上。还有一些 NoSQL 数据存储,如 Cassandra 和 Riak,不需要 HDFS,可以在常规操作系统上部署,并且可以以无主节点的方式部署,这样在集群中就没有单点故障。NoSQL 存储的选择再次取决于组织内部特定技术的使用、现有的生产支持合同以及许多其他参数。
小贴士
本书不推荐使用一组特定的数据存储技术与 Spark 一起使用,因为 Spark 驱动程序对于大多数流行的序列化格式和 NoSQL 数据存储都是丰富的。换句话说,大多数数据存储供应商已经开始大规模支持 Spark。另一个有趣的趋势是,许多突出的 ETL 工具开始支持 Spark,因此使用此类 ETL 工具的人可以在他们的 ETL 处理管道中使用 Spark 应用程序。
在这个应用中,既没有使用基于 HDFS 的数据存储,也没有使用任何基于 NoSQL 的数据存储,目的是为了保持简单,并避免为读者运行应用所需的复杂基础设施设置。在整个过程中,数据都存储在本地系统上的文本文件格式中。对尝试在 HDFS 或其他 NoSQL 数据存储上运行示例感兴趣的读者可以尝试,只需对应用的数据读写部分进行一些修改。
服务层
服务层可以使用 Spark 通过各种方法实现。如果数据是非结构化的且完全是基于对象的,那么基于低级 RDD 的方法是合适的。如果数据是结构化的,DataFrame 是理想的。这里讨论的使用案例是处理结构化数据,因此尽可能使用 Spark SQL 库。从数据存储中读取数据并创建 RDD。将 RDD 转换为 DataFrame,并使用 Spark SQL 完成所有服务需求。这样,代码将简洁易懂。
速度层
速度层将作为一个使用 Kafka 作为代理的 Spark Streaming 应用程序来实现,该代理有自己的生产者来产生消息。Spark Streaming 应用程序将作为 Kafka 主题的消费者,接收正在产生的数据。如 Spark Streaming 章节所述,生产者可以是 Kafka 控制台生产者或 Kafka 支持的任何其他生产者。但在此作为消费者的 Spark Streaming 应用程序不会实现将处理后的消息持久化到文本文件的逻辑,因为这些在现实世界的用例中通常不使用。以这个应用程序为基础,读者可以实施他们自己的持久化机制。
查询
所有查询都来自速度层和服务层。由于数据以 DataFrame 的形式提供,如前所述,所有针对用例的查询都是使用 Spark SQL 实现的。显而易见的原因是 Spark SQL 作为一种统一数据源和目的地的技术。当读者使用本书中的示例,并且准备在现实世界的用例中实施时,整体方法可以保持不变,但数据源和目的地可能不同。以下是一些可以从服务层生成的查询。读者可以根据自己的想象对数据字典进行必要的更改,以便能够编写这些视图或查询:
-
找到按给定标签分组的消息
-
找到发送给指定用户的消息
-
找到指定用户的粉丝
-
找到指定用户的关注者
与 Spark 应用程序一起工作
此应用程序的工作核心是数据处理引擎,由许多 Spark 应用程序组成。通常,它们可以分为以下类型:
-
一个用于摄取数据的 Spark Streaming 应用程序:这是主要监听应用程序,它接收作为流传入的数据并将其存储在适当的总数据集中。
-
一个用于创建目的视图和查询的 Spark 应用程序:这是用于从主数据集中创建各种目的视图的应用程序。除此之外,查询也包括在这个应用程序中。
-
一个用于执行自定义数据处理的 Spark GraphX 应用程序:这是用于处理用户关注关系的应用程序。
所有这些应用都是独立开发的,并且独立提交,但流处理应用将始终作为一个监听应用运行,以处理传入的消息。除了主要的数据流应用外,所有其他应用都像常规作业一样进行调度,例如 UNIX 系统中的 cron 作业。在这个应用中,所有这些应用都在生成各种目的的视图。调度取决于应用类型以及主数据集和视图之间可以接受的延迟量。这完全取决于业务功能。因此,本章将专注于 Spark 应用开发,而不是调度,以保持对早期章节中学到的教训的关注。
小贴士
在实现实际应用场景时,将速度层的数据持续存储到文本文件中并不是最佳选择。为了简化,所有数据都存储在文本文件中,以便让所有层次的读者都能以最简单的设置使用。使用 Spark Streaming 实现的速度层是一个没有持久化逻辑的骨架实现。读者可以增强这一点,将持久化引入他们希望的数据存储中。
代码风格
代码风格已在早期章节中讨论,并且已经进行了大量的 Spark 应用程序编程。到目前为止,本书已经证明 Spark 应用开发可以使用 Scala、Python 和 R 语言进行。在大多数早期章节中,选择的语言是 Scala 和 Python。在本章中,这一趋势将继续。只有对于 Spark GraphX 应用,由于没有 Python 支持,应用将仅使用 Scala 开发。
代码风格将简单直接。为了专注于 Spark 功能,故意避免了错误处理和其他最佳实践的应用开发。在本章中,尽可能从适当语言的 Spark REPL 运行代码。由于完整的应用程序结构和构建、编译和作为应用程序运行它们的脚本已在讨论 Spark Streaming 的章节中介绍,源代码下载将提供完整、可运行的应用程序。此外,讨论 Spark Streaming 的章节还介绍了完整 Spark 应用程序的结构,包括构建和运行 Spark 应用程序的脚本。同样的方法也将用于本章将要开发的应用程序。当运行此类独立 Spark 应用程序时,如本书最初章节所述,读者可以启用 Spark 监控并查看应用程序的行为。为了简洁起见,这些讨论将不再在此处进行。
设置源代码
图 4展示了本章中使用的源代码结构和数据目录的结构。这里没有提供每个部分的描述,因为读者应该熟悉它们,并且它们已在第六章中介绍,Spark 流处理。运行使用 Kafka 的程序需要外部库文件依赖项。为此,在 lib 文件夹中的 TODO.txt 文件中有下载 JAR 文件的说明。submitPy.sh 和 submit.sh 文件还使用了 Kafka 安装中的某些 Kafka 库。所有这些外部 JAR 文件依赖项已在第六章中介绍,Spark 流处理。

图 4
理解数据摄取
Spark Streaming 应用程序作为监听应用程序,接收来自其生产者的数据。由于 Kafka 将用作消息代理,Spark Streaming 应用程序将成为其消费者应用程序,监听由其生产者发送的主题。由于批处理层中的主数据集包含以下数据集,因此为每个主题以及数据集创建单独的 Kafka 主题是理想的。
-
用户数据集:User
-
关注者数据集:Follower
-
消息数据集:Message
图 5展示了基于 Kafka 的 Spark Streaming 应用程序结构的整体视图:

图 5
由于 Kafka 设置已在第六章中介绍,Spark 流处理,因此这里只介绍应用程序代码。
以下脚本需要在终端窗口中运行。请确保环境变量 $KAFKA_HOME 指向 Kafka 安装的目录。此外,非常重要的一点是,需要在单独的终端窗口中启动 Zookeeper、Kafka 服务器、Kafka 生产者和 Spark Streaming 日志事件数据处理应用程序。一旦脚本中显示的必要 Kafka 主题创建完成,相应的生产者必须开始产生消息。在继续之前,请参考已在第六章中介绍的 Kafka 设置细节,Spark 流处理。
在终端窗口提示符中尝试以下命令:
$ # Start the Zookeeper
$ cd $KAFKA_HOME
$ $KAFKA_HOME/bin/zookeeper-server-start.sh
$KAFKA_HOME/config/zookeeper.properties
[2016-07-30 12:50:15,896] INFO binding to port 0.0.0.0/0.0.0.0:2181
(org.apache.zookeeper.server.NIOServerCnxnFactory)
$ # Start the Kafka broker in a separate terminal window
$ $KAFKA_HOME/bin/kafka-server-start.sh $KAFKA_HOME/config/server.properties
[2016-07-30 12:51:39,206] INFO [Kafka Server 0], started
(kafka.server.KafkaServer)
$ # Create the necessary Kafka topics. This is to be done in a separate terminal window
$ $KAFKA_HOME/bin/kafka-topics.sh --create --zookeeper localhost:2181
--replication-factor 1 --partitions 1 --topic user
Created topic "user".
$ $KAFKA_HOME/bin/kafka-topics.sh --create --zookeeper localhost:2181
--replication-factor 1 --partitions 1 --topic follower
Created topic "follower".
$ $KAFKA_HOME/bin/kafka-topics.sh --create --zookeeper localhost:2181
--replication-factor 1 --partitions 1 --topic message
Created topic "message".
$ # Start producing messages and publish to the topic "message"
$ $KAFKA_HOME/bin/kafka-console-producer.sh --broker-list localhost:9092
--topic message
本节提供了处理 Kafka 生成者产生的消息的 Kafka 主题消费者应用程序的 Scala 代码的详细信息。在运行以下代码片段之前,假设 Kafka 正在运行,所需的生成者正在产生消息,然后,如果运行应用程序,它将开始消费消息。运行数据摄取的 Scala 程序是通过将其提交到 Spark 集群来完成的。从 图 4 所示的 Scala 目录开始,首先编译程序然后运行它。需要查阅 README.txt 文件以获取额外说明。需要执行以下两个命令来编译和运行程序:
$ ./compile.sh
$ ./submit.sh com.packtpub.sfb.DataIngestionApp 1
下面的代码是要使用前面命令编译和运行的程序列表:
/**
The following program can be compiled and run using SBT
Wrapper scripts have been provided with thisThe following script can be run to compile the code
./compile.sh
The following script can be used to run this application in Spark.
The second command line argument of value 1 is very important.
This is to flag the shipping of the kafka jar files to the Spark cluster
./submit.sh com.packtpub.sfb.DataIngestionApp 1
**/
package com.packtpub.sfb
import java.util.HashMap
import org.apache.spark.streaming._
import org.apache.spark.sql.{Row, SparkSession}
import org.apache.spark.streaming.kafka._
import org.apache.kafka.clients.producer.{ProducerConfig, KafkaProducer, ProducerRecord}
import org.apache.spark.storage.StorageLevel
import org.apache.log4j.{Level, Logger}
object DataIngestionApp {
def main(args: Array[String]) {
// Log level settings
LogSettings.setLogLevels()
//Check point directory for the recovery
val checkPointDir = "/tmp"
/**
* The following function has to be used to have checkpointing and driver recovery
* The way it should be used is to use the StreamingContext.getOrCreate with this function and do a start of that
* This function example has been discussed but not used in the chapter covering Spark Streaming. But here it is being used */
def sscCreateFn(): StreamingContext = {
// Variables used for creating the Kafka stream
// Zookeeper host
val zooKeeperQuorum = "localhost"
// Kaka message group
val messageGroup = "sfb-consumer-group"
// Kafka topic where the programming is listening for the data
// Reader TODO: Here only one topic is included, it can take a comma separated string containing the list of topics.
// Reader TODO: When using multiple topics, use your own logic to extract the right message and persist to its data store
val topics = "message"
val numThreads = 1
// Create the Spark Session, the spark context and the streaming context
val spark = SparkSession
.builder
.appName(getClass.getSimpleName)
.getOrCreate()
val sc = spark.sparkContext
val ssc = new StreamingContext(sc, Seconds(10))
val topicMap = topics.split(",").map((_, numThreads.toInt)).toMap
val messageLines = KafkaUtils.createStream(ssc, zooKeeperQuorum, messageGroup, topicMap).map(_._2)
// This is where the messages are printed to the console.
// TODO - As an exercise to the reader, instead of printing messages to the console, implement your own persistence logic
messageLines.print()
//Do checkpointing for the recovery
ssc.checkpoint(checkPointDir)
// return the Spark Streaming Context
ssc
}
// Note the function that is defined above for creating the Spark streaming context is being used here to create the Spark streaming context.
val ssc = StreamingContext.getOrCreate(checkPointDir, sscCreateFn)
// Start the streaming
ssc.start()
// Wait till the application is terminated
ssc.awaitTermination()
}
}
object LogSettings {
/**
Necessary log4j logging level settings are done
*/
def setLogLevels() {
val log4jInitialized = Logger.getRootLogger.getAllAppenders.hasMoreElements
if (!log4jInitialized) {
// This is to make sure that the console is clean from other INFO messages printed by Spark
Logger.getRootLogger.setLevel(Level.INFO)
}
}
}
运行数据摄取的 Python 程序是通过将其提交到 Spark 集群来完成的。从 图 4 所示的 Python 目录开始,运行程序。需要查阅 README.txt 文件以获取额外说明。即使运行此 Python 程序,所有 Kafka 安装要求仍然有效。运行程序的命令如下。由于 Python 是一种解释型语言,这里不需要编译:
$ ./submitPy.sh DataIngestionApp.py 1
下面的代码片段是相同应用的 Python 实现:
# The following script can be used to run this application in Spark
# ./submitPy.sh DataIngestionApp.py 1
from __future__ import print_function
import sys
from pyspark import SparkContext
from pyspark.streaming import StreamingContext
from pyspark.streaming.kafka import KafkaUtils
if __name__ == "__main__":
# Create the Spark context
sc = SparkContext(appName="DataIngestionApp")
log4j = sc._jvm.org.apache.log4j
log4j.LogManager.getRootLogger().setLevel(log4j.Level.WARN)
# Create the Spark Streaming Context with 10 seconds batch interval
ssc = StreamingContext(sc, 10)
# Check point directory setting
ssc.checkpoint("\tmp")
# Zookeeper host
zooKeeperQuorum="localhost"
# Kaka message group
messageGroup="sfb-consumer-group"
# Kafka topic where the programming is listening for the data
# Reader TODO: Here only one topic is included, it can take a comma separated string containing the list of topics.
# Reader TODO: When using multiple topics, use your own logic to extract the right message and persist to its data store
topics = "message"
numThreads = 1
# Create a Kafka DStream
kafkaStream = KafkaUtils.createStream(ssc, zooKeeperQuorum, messageGroup, {topics: numThreads})
messageLines = kafkaStream.map(lambda x: x[1])
# This is where the messages are printed to the console. Instead of this, implement your own persistence logic
messageLines.pprint()
# Start the streaming
ssc.start()
# Wait till the application is terminated
ssc.awaitTermination()
生成目的视图和查询
下面的 Scala 和 Python 实现是创建本章前面部分讨论的目的视图和查询的应用程序。在 Scala REPL 提示符下,尝试以下语句:
//TODO: Change the following directory to point to your data directory
scala> val dataDir = "/Users/RajT/Documents/Writing/SparkForBeginners/To-PACKTPUB/Contents/B05289-09-DesigningSparkApplications/Code/Data/"
dataDir: String = /Users/RajT/Documents/Writing/SparkForBeginners/To-PACKTPUB/Contents/B05289-09-DesigningSparkApplications/Code/Data/
scala> //Define the case classes in Scala for the entities
scala> case class User(Id: Long, UserName: String, FirstName: String, LastName: String, EMail: String, AlternateEmail: String, Phone: String)
defined class User
scala> case class Follow(Follower: String, Followed: String)
defined class Follow
scala> case class Message(UserName: String, MessageId: Long, ShortMessage: String, Timestamp: Long)
defined class Message
scala> case class MessageToUsers(FromUserName: String, ToUserName: String, MessageId: Long, ShortMessage: String, Timestamp: Long)
defined class MessageToUsers
scala> case class TaggedMessage(HashTag: String, UserName: String, MessageId: Long, ShortMessage: String, Timestamp: Long)
defined class TaggedMessage
scala> //Define the utility functions that are to be passed in the applications
scala> def toUser = (line: Seq[String]) => User(line(0).toLong, line(1), line(2),line(3), line(4), line(5), line(6))
toUser: Seq[String] => User
scala> def toFollow = (line: Seq[String]) => Follow(line(0), line(1))
toFollow: Seq[String] => Follow
scala> def toMessage = (line: Seq[String]) => Message(line(0), line(1).toLong, line(2), line(3).toLong)
toMessage: Seq[String] => Message
scala> //Load the user data into a Dataset
scala> val userDataDS = sc.textFile(dataDir + "user.txt").map(_.split("\\|")).map(toUser(_)).toDS()
userDataDS: org.apache.spark.sql.Dataset[User] = [Id: bigint, UserName: string ... 5 more fields]
scala> //Convert the Dataset into data frame
scala> val userDataDF = userDataDS.toDF()
userDataDF: org.apache.spark.sql.DataFrame = [Id: bigint, UserName: string ... 5 more fields]
scala> userDataDF.createOrReplaceTempView("user")
scala> userDataDF.show()
+---+--------+---------+--------+--------------------+----------------+--------------+
| Id|UserName|FirstName|LastName| EMail| AlternateEmail| Phone|
+---+--------+---------+--------+--------------------+----------------+--------------+
| 1| mthomas| Mark| Thomas| mthomas@example.com|mt12@example.com|+4411860297701|
| 2|mithomas| Michael| Thomas|mithomas@example.com| mit@example.com|+4411860297702|
| 3| mtwain| Mark| Twain| mtwain@example.com| mtw@example.com|+4411860297703|
| 4| thardy| Thomas| Hardy| thardy@example.com| th@example.com|+4411860297704|
| 5| wbryson| William| Bryson| wbryson@example.com| bb@example.com|+4411860297705|
| 6| wbrad| William|Bradford| wbrad@example.com| wb@example.com|+4411860297706|
| 7| eharris| Ed| Harris| eharris@example.com| eh@example.com|+4411860297707|
| 8| tcook| Thomas| Cook| tcook@example.com| tk@example.com|+4411860297708|
| 9| arobert| Adam| Robert| arobert@example.com| ar@example.com|+4411860297709|
| 10| jjames| Jacob| James| jjames@example.com| jj@example.com|+4411860297710|
+---+--------+---------+--------+--------------------+----------------+--------------+
scala> //Load the follower data into an Dataset
scala> val followerDataDS = sc.textFile(dataDir + "follower.txt").map(_.split("\\|")).map(toFollow(_)).toDS()
followerDataDS: org.apache.spark.sql.Dataset[Follow] = [Follower: string, Followed: string]
scala> //Convert the Dataset into data frame
scala> val followerDataDF = followerDataDS.toDF()
followerDataDF: org.apache.spark.sql.DataFrame = [Follower: string, Followed: string]
scala> followerDataDF.createOrReplaceTempView("follow")
scala> followerDataDF.show()
+--------+--------+
|Follower|Followed|
+--------+--------+
| mthomas|mithomas|
| mthomas| mtwain|
| thardy| wbryson|
| wbrad| wbryson|
| eharris| mthomas|
| eharris| tcook|
| arobert| jjames|
+--------+--------+
scala> //Load the message data into an Dataset
scala> val messageDataDS = sc.textFile(dataDir + "message.txt").map(_.split("\\|")).map(toMessage(_)).toDS()
messageDataDS: org.apache.spark.sql.Dataset[Message] = [UserName: string, MessageId: bigint ... 2 more fields]
scala> //Convert the Dataset into data frame
scala> val messageDataDF = messageDataDS.toDF()
messageDataDF: org.apache.spark.sql.DataFrame = [UserName: string, MessageId: bigint ... 2 more fields]
scala> messageDataDF.createOrReplaceTempView("message")
scala> messageDataDF.show()
+--------+---------+--------------------+----------+
|UserName|MessageId| ShortMessage| Timestamp|
+--------+---------+--------------------+----------+
| mthomas| 1|@mithomas Your po...|1459009608|
| mthomas| 2|Feeling awesome t...|1459010608|
| mtwain| 3|My namesake in th...|1459010776|
| mtwain| 4|Started the day w...|1459011016|
| thardy| 5|It is just spring...|1459011199|
| wbryson| 6|Some days are rea...|1459011256|
| wbrad| 7|@wbryson Stuff ha...|1459011333|
| eharris| 8|Anybody knows goo...|1459011426|
| tcook| 9|Stock market is p...|1459011483|
| tcook| 10|Dont do day tradi...|1459011539|
| tcook| 11|I have never hear...|1459011622|
| wbrad| 12|#Barcelona has pl...|1459157132|
| mtwain| 13|@wbryson It is go...|1459164906|
+--------+---------+--------------------+----------+
这些步骤完成了将所有必需数据从持久存储加载到 DataFrame 的过程。在这里,数据来自文本文件。在实际应用场景中,数据可能来自流行的 NoSQL 数据存储、传统的 RDBMS 表,或从 HDFS 加载的 Avro 或 Parquet 序列化数据存储。
下文使用这些 DataFrame 创建了各种目的的视图和查询:
scala> //Create the purposed view of the message to users
scala> val messagetoUsersDS = messageDataDS.filter(_.ShortMessage.contains("@")).map(message => (message.ShortMessage.split(" ").filter(_.contains("@")).mkString(" ").substring(1), message)).map(msgTuple => MessageToUsers(msgTuple._2.UserName, msgTuple._1, msgTuple._2.MessageId, msgTuple._2.ShortMessage, msgTuple._2.Timestamp))
messagetoUsersDS: org.apache.spark.sql.Dataset[MessageToUsers] = [FromUserName: string, ToUserName: string ... 3 more fields]
scala> //Convert the Dataset into data frame
scala> val messagetoUsersDF = messagetoUsersDS.toDF()
messagetoUsersDF: org.apache.spark.sql.DataFrame = [FromUserName: string, ToUserName: string ... 3 more fields]
scala> messagetoUsersDF.createOrReplaceTempView("messageToUsers")
scala> messagetoUsersDF.show()
+------------+----------+---------+--------------------+----------+
|FromUserName|ToUserName|MessageId| ShortMessage| Timestamp|
+------------+----------+---------+--------------------+----------+
| mthomas| mithomas| 1|@mithomas Your po...|1459009608|
| wbrad| wbryson| 7|@wbryson Stuff ha...|1459011333|
| mtwain| wbryson| 13|@wbryson It is go...|1459164906|
+------------+----------+---------+--------------------+----------+
scala> //Create the purposed view of tagged messages
scala> val taggedMessageDS = messageDataDS.filter(_.ShortMessage.contains("#")).map(message => (message.ShortMessage.split(" ").filter(_.contains("#")).mkString(" "), message)).map(msgTuple => TaggedMessage(msgTuple._1, msgTuple._2.UserName, msgTuple._2.MessageId, msgTuple._2.ShortMessage, msgTuple._2.Timestamp))
taggedMessageDS: org.apache.spark.sql.Dataset[TaggedMessage] = [HashTag: string, UserName: string ... 3 more fields]
scala> //Convert the Dataset into data frame
scala> val taggedMessageDF = taggedMessageDS.toDF()
taggedMessageDF: org.apache.spark.sql.DataFrame = [HashTag: string, UserName: string ... 3 more fields]
scala> taggedMessageDF.createOrReplaceTempView("taggedMessages")
scala> taggedMessageDF.show()
+----------+--------+---------+--------------------+----------+
| HashTag|UserName|MessageId| ShortMessage| Timestamp|
+----------+--------+---------+--------------------+----------+
|#Barcelona| eharris| 8|Anybody knows goo...|1459011426|
|#Barcelona| wbrad| 12|#Barcelona has pl...|1459157132|
+----------+--------+---------+--------------------+----------+
scala> //The following are the queries given in the use cases
scala> //Find the messages that are grouped by a given hash tag
scala> val byHashTag = spark.sql("SELECT a.UserName, b.FirstName, b.LastName, a.MessageId, a.ShortMessage, a.Timestamp FROM taggedMessages a, user b WHERE a.UserName = b.UserName AND HashTag = '#Barcelona' ORDER BY a.Timestamp")
byHashTag: org.apache.spark.sql.DataFrame = [UserName: string, FirstName: string ... 4 more fields]
scala> byHashTag.show()
+--------+---------+--------+---------+--------------------+----------+
|UserName|FirstName|LastName|MessageId| ShortMessage| Timestamp|
+--------+---------+--------+---------+--------------------+----------+
| eharris| Ed| Harris| 8|Anybody knows goo...|1459011426|
| wbrad| William|Bradford| 12|#Barcelona has pl...|1459157132|
+--------+---------+--------+---------+--------------------+----------+
scala> //Find the messages that are addressed to a given user
scala> val byToUser = spark.sql("SELECT FromUserName, ToUserName, MessageId, ShortMessage, Timestamp FROM messageToUsers WHERE ToUserName = 'wbryson' ORDER BY Timestamp")
byToUser: org.apache.spark.sql.DataFrame = [FromUserName: string, ToUserName: string ... 3 more fields]
scala> byToUser.show()
+------------+----------+---------+--------------------+----------+
|FromUserName|ToUserName|MessageId| ShortMessage| Timestamp|
+------------+----------+---------+--------------------+----------+
| wbrad| wbryson| 7|@wbryson Stuff ha...|1459011333|
| mtwain| wbryson| 13|@wbryson It is go...|1459164906|
+------------+----------+---------+--------------------+----------+
scala> //Find the followers of a given user
scala> val followers = spark.sql("SELECT b.FirstName as FollowerFirstName, b.LastName as FollowerLastName, a.Followed FROM follow a, user b WHERE a.Follower = b.UserName AND a.Followed = 'wbryson'")
followers: org.apache.spark.sql.DataFrame = [FollowerFirstName: string, FollowerLastName: string ... 1 more field]
scala> followers.show()
+-----------------+----------------+--------+
|FollowerFirstName|FollowerLastName|Followed|
+-----------------+----------------+--------+
| William| Bradford| wbryson|
| Thomas| Hardy| wbryson|
+-----------------+----------------+--------+
scala> //Find the followedUsers of a given user
scala> val followedUsers = spark.sql("SELECT b.FirstName as FollowedFirstName, b.LastName as FollowedLastName, a.Follower FROM follow a, user b WHERE a.Followed = b.UserName AND a.Follower = 'eharris'")
followedUsers: org.apache.spark.sql.DataFrame = [FollowedFirstName: string, FollowedLastName: string ... 1 more field]
scala> followedUsers.show()
+-----------------+----------------+--------+
|FollowedFirstName|FollowedLastName|Follower|
+-----------------+----------------+--------+
| Thomas| Cook| eharris|
| Mark| Thomas| eharris|
+-----------------+----------------+--------+
在前面的 Scala 代码片段中,由于选择的语言是 Scala,因此使用了数据集和 DataFrame 基于的编程模型。现在,由于 Python 不是一个强类型语言,Python 中不支持 Dataset API,因此下面的 Python 代码使用了 Spark 的传统 RDD 基于的编程模型与 DataFrame 基于的编程模型结合。在 Python REPL 提示符下,尝试以下语句:
>>> from pyspark.sql import Row
>>> #TODO: Change the following directory to point to your data directory
>>> dataDir = "/Users/RajT/Documents/Writing/SparkForBeginners/To-PACKTPUB/Contents/B05289-09-DesigningSparkApplications/Code/Data/"
>>> #Load the user data into an RDD
>>> userDataRDD = sc.textFile(dataDir + "user.txt").map(lambda line: line.split("|")).map(lambda p: Row(Id=int(p[0]), UserName=p[1], FirstName=p[2], LastName=p[3], EMail=p[4], AlternateEmail=p[5], Phone=p[6]))
>>> #Convert the RDD into data frame
>>> userDataDF = userDataRDD.toDF()
>>> userDataDF.createOrReplaceTempView("user")
>>> userDataDF.show()
+----------------+--------------------+---------+---+--------+--------------+--------+
| AlternateEmail| EMail|FirstName| Id|LastName| Phone|UserName|
+----------------+--------------------+---------+---+--------+--------------+--------+
|mt12@example.com| mthomas@example.com| Mark| 1| Thomas|+4411860297701| mthomas|
| mit@example.com|mithomas@example.com| Michael| 2| Thomas|+4411860297702|mithomas|
| mtw@example.com| mtwain@example.com| Mark| 3| Twain|+4411860297703| mtwain|
| th@example.com| thardy@example.com| Thomas| 4| Hardy|+4411860297704| thardy|
| bb@example.com| wbryson@example.com| William| 5| Bryson|+4411860297705| wbryson|
| wb@example.com| wbrad@example.com| William| 6|Bradford|+4411860297706| wbrad|
| eh@example.com| eharris@example.com| Ed| 7| Harris|+4411860297707| eharris|
| tk@example.com| tcook@example.com| Thomas| 8| Cook|+4411860297708| tcook|
| ar@example.com| arobert@example.com| Adam| 9| Robert|+4411860297709| arobert|
| jj@example.com| jjames@example.com| Jacob| 10| James|+4411860297710| jjames|
+----------------+--------------------+---------+---+--------+--------------+--------+
>>> #Load the follower data into an RDD
>>> followerDataRDD = sc.textFile(dataDir + "follower.txt").map(lambda line: line.split("|")).map(lambda p: Row(Follower=p[0], Followed=p[1]))
>>> #Convert the RDD into data frame
>>> followerDataDF = followerDataRDD.toDF()
>>> followerDataDF.createOrReplaceTempView("follow")
>>> followerDataDF.show()
+--------+--------+
|Followed|Follower|
+--------+--------+
|mithomas| mthomas|
| mtwain| mthomas|
| wbryson| thardy|
| wbryson| wbrad|
| mthomas| eharris|
| tcook| eharris|
| jjames| arobert|
+--------+--------+
>>> #Load the message data into an RDD
>>> messageDataRDD = sc.textFile(dataDir + "message.txt").map(lambda line: line.split("|")).map(lambda p: Row(UserName=p[0], MessageId=int(p[1]), ShortMessage=p[2], Timestamp=int(p[3])))
>>> #Convert the RDD into data frame
>>> messageDataDF = messageDataRDD.toDF()
>>> messageDataDF.createOrReplaceTempView("message")
>>> messageDataDF.show()
+---------+--------------------+----------+--------+
|MessageId| ShortMessage| Timestamp|UserName|
+---------+--------------------+----------+--------+
| 1|@mithomas Your po...|1459009608| mthomas|
| 2|Feeling awesome t...|1459010608| mthomas|
| 3|My namesake in th...|1459010776| mtwain|
| 4|Started the day w...|1459011016| mtwain|
| 5|It is just spring...|1459011199| thardy|
| 6|Some days are rea...|1459011256| wbryson|
| 7|@wbryson Stuff ha...|1459011333| wbrad|
| 8|Anybody knows goo...|1459011426| eharris|
| 9|Stock market is p...|1459011483| tcook|
| 10|Dont do day tradi...|1459011539| tcook|
| 11|I have never hear...|1459011622| tcook|
| 12|#Barcelona has pl...|1459157132| wbrad|
| 13|@wbryson It is go...|1459164906| mtwain|
+---------+--------------------+----------+--------+
这些步骤完成了将所有必需数据从持久存储加载到 DataFrame 的过程。在这里,数据来自文本文件。在实际应用场景中,数据可能来自流行的 NoSQL 数据存储、传统的 RDBMS 表,或从 HDFS 加载的 Avro 或 Parquet 序列化数据存储。下文使用这些 DataFrame 创建了各种目的的视图和查询:
>>> #Create the purposed view of the message to users
>>> messagetoUsersRDD = messageDataRDD.filter(lambda message: "@" in message.ShortMessage).map(lambda message : (message, " ".join(filter(lambda s: s[0] == '@', message.ShortMessage.split(" "))))).map(lambda msgTuple: Row(FromUserName=msgTuple[0].UserName, ToUserName=msgTuple[1][1:], MessageId=msgTuple[0].MessageId, ShortMessage=msgTuple[0].ShortMessage, Timestamp=msgTuple[0].Timestamp))
>>> #Convert the RDD into data frame
>>> messagetoUsersDF = messagetoUsersRDD.toDF()
>>> messagetoUsersDF.createOrReplaceTempView("messageToUsers")
>>> messagetoUsersDF.show()
+------------+---------+--------------------+----------+----------+
|FromUserName|MessageId| ShortMessage| Timestamp|ToUserName|
+------------+---------+--------------------+----------+----------+
| mthomas| 1|@mithomas Your po...|1459009608| mithomas|
| wbrad| 7|@wbryson Stuff ha...|1459011333| wbryson|
| mtwain| 13|@wbryson It is go...|1459164906| wbryson|
+------------+---------+--------------------+----------+----------+
>>> #Create the purposed view of tagged messages
>>> taggedMessageRDD = messageDataRDD.filter(lambda message: "#" in message.ShortMessage).map(lambda message : (message, " ".join(filter(lambda s: s[0] == '#', message.ShortMessage.split(" "))))).map(lambda msgTuple: Row(HashTag=msgTuple[1], UserName=msgTuple[0].UserName, MessageId=msgTuple[0].MessageId, ShortMessage=msgTuple[0].ShortMessage, Timestamp=msgTuple[0].Timestamp))
>>> #Convert the RDD into data frame
>>> taggedMessageDF = taggedMessageRDD.toDF()
>>> taggedMessageDF.createOrReplaceTempView("taggedMessages")
>>> taggedMessageDF.show()
+----------+---------+--------------------+----------+--------+
| HashTag|MessageId| ShortMessage| Timestamp|UserName|
+----------+---------+--------------------+----------+--------+
|#Barcelona| 8|Anybody knows goo...|1459011426| eharris|
|#Barcelona| 12|#Barcelona has pl...|1459157132| wbrad|
+----------+---------+--------------------+----------+--------+
>>> #The following are the queries given in the use cases
>>> #Find the messages that are grouped by a given hash tag
>>> byHashTag = spark.sql("SELECT a.UserName, b.FirstName, b.LastName, a.MessageId, a.ShortMessage, a.Timestamp FROM taggedMessages a, user b WHERE a.UserName = b.UserName AND HashTag = '#Barcelona' ORDER BY a.Timestamp")
>>> byHashTag.show()
+--------+---------+--------+---------+--------------------+----------+
|UserName|FirstName|LastName|MessageId| ShortMessage| Timestamp|
+--------+---------+--------+---------+--------------------+----------+
| eharris| Ed| Harris| 8|Anybody knows goo...|1459011426|
| wbrad| William|Bradford| 12|#Barcelona has pl...|1459157132|
+--------+---------+--------+---------+--------------------+----------+
>>> #Find the messages that are addressed to a given user
>>> byToUser = spark.sql("SELECT FromUserName, ToUserName, MessageId, ShortMessage, Timestamp FROM messageToUsers WHERE ToUserName = 'wbryson' ORDER BY Timestamp")
>>> byToUser.show()
+------------+----------+---------+--------------------+----------+
|FromUserName|ToUserName|MessageId| ShortMessage| Timestamp|
+------------+----------+---------+--------------------+----------+
| wbrad| wbryson| 7|@wbryson Stuff ha...|1459011333|
| mtwain| wbryson| 13|@wbryson It is go...|1459164906|
+------------+----------+---------+--------------------+----------+
>>> #Find the followers of a given user
>>> followers = spark.sql("SELECT b.FirstName as FollowerFirstName, b.LastName as FollowerLastName, a.Followed FROM follow a, user b WHERE a.Follower = b.UserName AND a.Followed = 'wbryson'")>>> followers.show()
+-----------------+----------------+--------+
|FollowerFirstName|FollowerLastName|Followed|
+-----------------+----------------+--------+
| William| Bradford| wbryson|
| Thomas| Hardy| wbryson|
+-----------------+----------------+--------+
>>> #Find the followed users of a given user
>>> followedUsers = spark.sql("SELECT b.FirstName as FollowedFirstName, b.LastName as FollowedLastName, a.Follower FROM follow a, user b WHERE a.Followed = b.UserName AND a.Follower = 'eharris'")
>>> followedUsers.show()
+-----------------+----------------+--------+
|FollowedFirstName|FollowedLastName|Follower|
+-----------------+----------------+--------+
| Thomas| Cook| eharris|
| Mark| Thomas| eharris|
+-----------------+----------------+--------+
实现用例所需的目的视图和查询被开发为一个单一的应用程序。但在现实中,将所有视图和查询放在一个应用程序中并不是一个好的设计实践。通过持久化视图并在定期间隔刷新它们来分离它们是更好的做法。如果只使用一个应用程序,可以采用缓存和使用广播到 Spark 集群的自定义上下文对象来访问视图。
理解自定义数据处理
这里创建的视图是为了服务于各种查询并生成所需的输出。还有一些其他类别的数据处理应用程序通常被开发来实施现实世界的用例。从 Lambda 架构的角度来看,这也属于服务层。这些自定义数据处理之所以归入服务层,主要是因为它们大多数都使用或处理来自主数据集的数据,并创建视图或输出。自定义处理的数据保持为视图的可能性也非常大,以下用例就是其中之一。
在 SfbMicroBlog 微博应用程序中,查看给定用户 A 是否以某种方式直接或间接地与用户 B 相关联是一个非常常见的需求。这个用例可以通过使用图数据结构来实现,以查看两个相关用户是否属于同一个连通分量,是否以间接方式连接,或者根本不连接。为此,使用基于 Spark GraphX 库的 Spark 应用程序构建了一个包含所有用户作为顶点和关注关系作为边的图。在 Scala REPL 提示符下,尝试以下语句:
scala> import org.apache.spark.rdd.RDD
import org.apache.spark.rdd.RDD
scala> import org.apache.spark.graphx._
import org.apache.spark.graphx._
scala> //TODO: Change the following directory to point to your data directory
scala> val dataDir = "/Users/RajT/Documents/Writing/SparkForBeginners/To-PACKTPUB/Contents/B05289-09-DesigningSparkApplications/Code/Data/"
dataDir: String = /Users/RajT/Documents/Writing/SparkForBeginners/To-PACKTPUB/Contents/B05289-09-DesigningSparkApplications/Code/Data/
scala> //Define the case classes in Scala for the entities
scala> case class User(Id: Long, UserName: String, FirstName: String, LastName: String, EMail: String, AlternateEmail: String, Phone: String)
defined class User
scala> case class Follow(Follower: String, Followed: String)
defined class Follow
scala> case class ConnectedUser(CCId: Long, UserName: String)
defined class ConnectedUser
scala> //Define the utility functions that are to be passed in the applications
scala> def toUser = (line: Seq[String]) => User(line(0).toLong, line(1), line(2),line(3), line(4), line(5), line(6))
toUser: Seq[String] => User
scala> def toFollow = (line: Seq[String]) => Follow(line(0), line(1))
toFollow: Seq[String] => Follow
scala> //Load the user data into an RDD
scala> val userDataRDD = sc.textFile(dataDir + "user.txt").map(_.split("\\|")).map(toUser(_))
userDataRDD: org.apache.spark.rdd.RDD[User] = MapPartitionsRDD[160] at map at <console>:34
scala> //Convert the RDD into data frame
scala> val userDataDF = userDataRDD.toDF()
userDataDF: org.apache.spark.sql.DataFrame = [Id: bigint, UserName: string ... 5 more fields]
scala> userDataDF.createOrReplaceTempView("user")
scala> userDataDF.show()
+---+--------+---------+--------+-----------+----------------+--------------+
Id|UserName|FirstName|LastName| EMail| AlternateEmail| Phone|
+---+--------+---------+--------+----------+-------------+--------------+
| 1| mthomas| Mark| Thomas| mthomas@example.com|mt12@example.com|
+4411860297701|
| 2|mithomas| Michael| Thomas|mithomas@example.com| mit@example.com|
+4411860297702|
| 3| mtwain| Mark| Twain| mtwain@example.com| mtw@example.com|
+4411860297703|
| 4| thardy| Thomas| Hardy| thardy@example.com| th@example.com|
+4411860297704|
| 5| wbryson| William| Bryson| wbryson@example.com| bb@example.com|
+4411860297705|
| 6| wbrad| William|Bradford| wbrad@example.com| wb@example.com|
+4411860297706|
| 7| eharris| Ed| Harris| eharris@example.com| eh@example.com|
+4411860297707|
| 8| tcook| Thomas| Cook| tcook@example.com| tk@example.com|
+4411860297708|
| 9| arobert| Adam| Robert| arobert@example.com| ar@example.com|
+4411860297709|
| 10| jjames| Jacob| James| jjames@example.com| jj@example.com|
+4411860297710|
+---+--------+---------+--------+-------------+--------------+--------------+
scala> //Load the follower data into an RDD
scala> val followerDataRDD = sc.textFile(dataDir + "follower.txt").map(_.split("\\|")).map(toFollow(_))
followerDataRDD: org.apache.spark.rdd.RDD[Follow] = MapPartitionsRDD[168] at map at <console>:34
scala> //Convert the RDD into data frame
scala> val followerDataDF = followerDataRDD.toDF()
followerDataDF: org.apache.spark.sql.DataFrame = [Follower: string, Followed: string]
scala> followerDataDF.createOrReplaceTempView("follow")
scala> followerDataDF.show()
+--------+--------+
|Follower|Followed|
+--------+--------+
| mthomas|mithomas|
| mthomas| mtwain|
| thardy| wbryson|
| wbrad| wbryson|
| eharris| mthomas|
| eharris| tcook|
| arobert| jjames|
+--------+--------+
scala> //By joining with the follower and followee users with the master user data frame for extracting the unique ids
scala> val fullFollowerDetails = spark.sql("SELECT b.Id as FollowerId, c.Id as FollowedId, a.Follower, a.Followed FROM follow a, user b, user c WHERE a.Follower = b.UserName AND a.Followed = c.UserName")
fullFollowerDetails: org.apache.spark.sql.DataFrame = [FollowerId: bigint, FollowedId: bigint ... 2 more fields]
scala> fullFollowerDetails.show()
+----------+----------+--------+--------+
|FollowerId|FollowedId|Follower|Followed|
+----------+----------+--------+--------+
| 9| 10| arobert| jjames|
| 1| 2| mthomas|mithomas|
| 7| 8| eharris| tcook|
| 7| 1| eharris| mthomas|
| 1| 3| mthomas| mtwain|
| 6| 5| wbrad| wbryson|
| 4| 5| thardy| wbryson|
+----------+----------+--------+--------+
scala> //Create the vertices of the connections graph
scala> val userVertices: RDD[(Long, String)] = userDataRDD.map(user => (user.Id, user.UserName))
userVertices: org.apache.spark.rdd.RDD[(Long, String)] = MapPartitionsRDD[194] at map at <console>:36
scala> userVertices.foreach(println)
(6,wbrad)
(7,eharris)
(8,tcook)
(9,arobert)
(10,jjames)
(1,mthomas)
(2,mithomas)
(3,mtwain)
(4,thardy)
(5,wbryson)
scala> //Create the edges of the connections graph
scala> val connections: RDD[Edge[String]] = fullFollowerDetails.rdd.map(conn => Edge(conn.getAsLong, conn.getAsLong, "Follows"))
connections: org.apache.spark.rdd.RDD[org.apache.spark.graphx.Edge[String]] = MapPartitionsRDD[217] at map at <console>:29
scala> connections.foreach(println)
Edge(9,10,Follows)
Edge(7,8,Follows)
Edge(1,2,Follows)
Edge(7,1,Follows)
Edge(1,3,Follows)
Edge(6,5,Follows)
Edge(4,5,Follows)
scala> //Create the graph using the vertices and the edges
scala> val connectionGraph = Graph(userVertices, connections)
connectionGraph: org.apache.spark.graphx.Graph[String,String] = org.apache.spark.graphx.impl.GraphImpl@3c207acd
完成了包含用户作为顶点和连接关系形成边的用户图的构建。在这个图数据结构上运行图处理算法,即连通分量算法。以下代码片段实现了这一点:
scala> //Calculate the connected users
scala> val cc = connectionGraph.connectedComponents()
cc: org.apache.spark.graphx.Graph[org.apache.spark.graphx.VertexId,String] = org.apache.spark.graphx.impl.GraphImpl@73f0bd11
scala> // Extract the triplets of the connected users
scala> val ccTriplets = cc.triplets
ccTriplets: org.apache.spark.rdd.RDD[org.apache.spark.graphx.EdgeTriplet[org.apache.spark.graphx.VertexId,String]] = MapPartitionsRDD[285] at mapPartitions at GraphImpl.scala:48
scala> // Print the structure of the triplets
scala> ccTriplets.foreach(println)
((9,9),(10,9),Follows)
((1,1),(2,1),Follows)
((7,1),(8,1),Follows)
((7,1),(1,1),Follows)
((1,1),(3,1),Follows)
((4,4),(5,4),Follows)
((6,4),(5,4),Follows)
创建了连通分量图cc及其三元组ccTriplets,现在可以使用它来运行各种查询。由于图是一个基于 RDD 的数据结构,如果需要进行查询,将图 RDD 转换为 DataFrames 是一种常见的做法。以下代码演示了这一点:
scala> //Print the vertex numbers and the corresponding connected component id. The connected component id is generated by the system and it is to be taken only as a unique identifier for the connected component
scala> val ccProperties = ccTriplets.map(triplet => "Vertex " + triplet.srcId + " and " + triplet.dstId + " are part of the CC with id " + triplet.srcAttr)
ccProperties: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[288] at map at <console>:48
scala> ccProperties.foreach(println)
Vertex 9 and 10 are part of the CC with id 9
Vertex 1 and 2 are part of the CC with id 1
Vertex 7 and 8 are part of the CC with id 1
Vertex 7 and 1 are part of the CC with id 1
Vertex 1 and 3 are part of the CC with id 1
Vertex 4 and 5 are part of the CC with id 4
Vertex 6 and 5 are part of the CC with id 4
scala> //Find the users in the source vertex with their CC id
scala> val srcUsersAndTheirCC = ccTriplets.map(triplet => (triplet.srcId, triplet.srcAttr))
srcUsersAndTheirCC: org.apache.spark.rdd.RDD[(org.apache.spark.graphx.VertexId, org.apache.spark.graphx.VertexId)] = MapPartitionsRDD[289] at map at <console>:48
scala> //Find the users in the destination vertex with their CC id
scala> val dstUsersAndTheirCC = ccTriplets.map(triplet => (triplet.dstId, triplet.dstAttr))
dstUsersAndTheirCC: org.apache.spark.rdd.RDD[(org.apache.spark.graphx.VertexId, org.apache.spark.graphx.VertexId)] = MapPartitionsRDD[290] at map at <console>:48
scala> //Find the union
scala> val usersAndTheirCC = srcUsersAndTheirCC.union(dstUsersAndTheirCC)
usersAndTheirCC: org.apache.spark.rdd.RDD[(org.apache.spark.graphx.VertexId, org.apache.spark.graphx.VertexId)] = UnionRDD[291] at union at <console>:52
scala> //Join with the name of the users
scala> //Convert the RDD to DataFrame
scala> val usersAndTheirCCWithName = usersAndTheirCC.join(userVertices).map{case (userId,(ccId,userName)) => (ccId, userName)}.distinct.sortByKey().map{case (ccId,userName) => ConnectedUser(ccId, userName)}.toDF()
usersAndTheirCCWithName: org.apache.spark.sql.DataFrame = [CCId: bigint, UserName: string]
scala> usersAndTheirCCWithName.createOrReplaceTempView("connecteduser")
scala> val usersAndTheirCCWithDetails = spark.sql("SELECT a.CCId, a.UserName, b.FirstName, b.LastName FROM connecteduser a, user b WHERE a.UserName = b.UserName ORDER BY CCId")
usersAndTheirCCWithDetails: org.apache.spark.sql.DataFrame = [CCId: bigint, UserName: string ... 2 more fields]
scala> //Print the usernames with their CC component id. If two users share the same CC id, then they are connected
scala> usersAndTheirCCWithDetails.show()
+----+--------+---------+--------+
|CCId|UserName|FirstName|LastName|
+----+--------+---------+--------+
| 1|mithomas| Michael| Thomas|
| 1| mtwain| Mark| Twain|
| 1| tcook| Thomas| Cook|
| 1| eharris| Ed| Harris|
| 1| mthomas| Mark| Thomas|
| 4| wbrad| William|Bradford|
| 4| wbryson| William| Bryson|
| 4| thardy| Thomas| Hardy|
| 9| jjames| Jacob| James|
| 9| arobert| Adam| Robert|
+----+--------+---------+--------+
使用前面实现的目的视图来获取用户列表及其连通分量识别号,如果需要找出两个用户是否连接,只需读取这两个用户的记录并查看它们是否具有相同的连通分量识别号。
参考文献
更多信息,请访问以下链接:
摘要
本章以一个单一应用的使用案例结束本书,该案例使用了本书前几章学到的 Spark 概念来实现。从数据处理应用架构的角度来看,本章介绍了 Lambda 架构作为数据处理应用的技术无关性架构框架,在大数据应用开发领域具有巨大的适用性。
从数据处理应用开发的角度来看,已经涵盖了基于 RDD 的 Spark 编程、基于 Dataset 的 Spark 编程、基于 Spark SQL 的 DataFrames 来处理结构化数据、基于 Spark Streaming 的监听程序,该程序持续监听传入的消息并处理它们,以及基于 Spark GraphX 的应用来处理关注者关系。到目前为止所涵盖的使用案例为读者提供了巨大的空间来添加他们自己的功能并增强本章讨论的应用用例。


浙公网安备 33010602011771号