Spark2-初学者手册-全-

Spark2 初学者手册(全)

原文:zh.annas-archive.org/md5/4803F9F0B1A27EADC7FE0DFBB64A3594

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

数据处理框架 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 核心的 Spark SQL 库,以其 DataFrame 抽象,被构建来满足大量非常熟悉无处不在的 SQL 的开发者的需求。

数据科学家使用 R 语言进行计算需求。R 语言最大的限制是所有需要处理的数据都应该适合运行 R 程序的计算机的主内存。Spark 为 R 语言引入的 API 让数据科学家们熟悉了在熟悉的数据帧抽象中的分布式数据处理世界。换句话说,使用 R 语言的 Spark 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 从各种数据源访问数据,实现对多样数据源的数据处理统一。

第四章,使用 R 进行 Spark 编程,讨论了 SparkR 或 R on Spark,这是 Spark 的 R API;这使得 R 用户能够利用 Spark 的数据处理能力,使用他们熟悉的数据帧抽象。它为 R 用户提供了一个很好的基础,以便熟悉 Spark 数据处理生态系统。

第五章,使用 Python 进行 Spark 数据分析,讨论了使用 Spark 进行数据处理和使用 Python 进行数据分析,利用了 Python 提供的各种图表和绘图库。本章讨论了将这两项相关活动结合在一起,作为使用 Python 作为首选编程语言的 Spark 应用程序。

第六章,Spark 流处理,讨论了 Spark Streaming,这是用于捕获和处理以流形式输入的数据的最强大的 Spark 库之一。还讨论了作为分布式消息代理的 Kafka 和作为消费者的 Spark Streaming 应用程序。

第七章,Spark 机器学习,探讨了 Spark MLlib,这是用于开发入门级机器学习应用程序的最强大的 Spark 库之一。

第八章,Spark 图处理,讨论了 Spark GraphX,这是处理图数据结构的最强大的 Spark 库之一,并附带了许多用于图数据处理的算法。本章涵盖了 GraphX 的基础知识以及使用 GraphX 提供的算法实现的一些用例。

第九章,设计 Spark 应用程序,讨论了 Spark 数据处理应用程序的设计和开发,涵盖了本书前几章中介绍的 Spark 的各种特性。

本书所需条件

Spark 2.0.0 或更高版本需要安装在至少一台独立机器上,以运行代码示例并进行进一步的活动,以更深入地了解该主题。对于第六章,Spark 流处理,需要安装并配置 Kafka 作为消息代理,其命令行生产者产生消息,而使用 Spark 开发的应用程序作为这些消息的消费者。

本书面向的读者

如果你是应用程序开发者、数据科学家或大数据解决方案架构师,并对将 Spark 的数据处理能力与 R 结合,以及将数据处理、流处理、机器学习、图处理整合到一个统一且高度互操作的框架中,使用统一的 API(Scala 或 Python)感兴趣,那么这本书适合你。

约定

在本书中,您会发现多种文本样式用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 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+方案。"

注意

警告或重要提示以这样的方框形式出现。

提示

提示和技巧这样呈现。

第一章:Spark 基础

数据是任何组织最重要的资产之一。组织中收集和使用的数据规模正以超出想象的速度增长。数据摄取的速度、使用的数据类型多样性以及处理和存储的数据量每时每刻都在打破历史记录。如今,即使在小型组织中,数据从千兆字节增长到太字节再到拍字节也变得非常普遍。因此,处理需求也在增长,要求能够处理静态数据以及移动中的数据。

以任何组织为例,其成功取决于领导者所做的决策,而为了做出明智的决策,你需要依赖于处理数据产生的良好数据和信息。这给如何及时且成本效益高地处理数据提出了巨大挑战,以便做出正确决策。自计算机早期以来,数据处理技术已经发展。无数的数据处理产品和框架进入市场,又随着时间的推移而消失。这些数据处理产品和框架大多数并非通用性质。大多数组织依赖于定制的应用程序来满足其数据处理需求,以孤岛方式或与特定产品结合使用。

大规模互联网应用,俗称物联网IoT)应用,预示着对开放框架的共同需求,以高速处理各种类型的大量数据。大型网站、媒体流应用以及组织的大规模批处理需求使得这一需求更加迫切。开源社区也随着互联网的发展而显著壮大,提供由知名软件公司支持的生产级软件。众多公司开始采用开源软件,并将其部署到生产环境中。

从技术角度看,数据处理需求正面临巨大挑战。数据量从单机溢出到大量机器集群。单个 CPU 的处理能力达到瓶颈,现代计算机开始将它们组合起来以获取更多处理能力,即所谓的多核计算机。应用程序并未设计成充分利用多核计算机中的所有处理器,导致现代计算机中大量处理能力被浪费。

注意

本书中,节点主机机器这些术语指的是在独立模式或集群中运行的计算机。

在此背景下,理想的数据处理框架应具备哪些特质?

  • 它应能处理分布在计算机集群中的数据块

  • 它应该能够以并行方式处理数据,以便将大型数据处理任务分解为多个并行处理的子任务,从而显著减少处理时间

  • 它应该能够利用计算机中所有核心或处理器的处理能力

  • 它应该能够利用集群中所有可用的计算机

  • 它应该能够在商品硬件上运行

有两个开源数据处理框架值得提及,它们满足所有这些要求。第一个是 Apache Hadoop,第二个是 Apache Spark。

本章我们将涵盖以下主题:

  • Apache Hadoop

  • Apache Spark

  • 安装 Spark 2.0

Apache Hadoop 概览

Apache Hadoop 是一个开源软件框架,从零开始设计用于在计算机集群上进行分布式数据存储,并对分布在集群计算机上的数据进行分布式数据处理。该框架配备了一个分布式文件系统用于数据存储,即Hadoop 分布式文件系统HDFS),以及一个数据处理框架,即 MapReduce。HDFS 的创建灵感来自 Google 的研究论文《The Google File System》,而 MapReduce 则基于 Google 的研究论文《MapReduce: Simplified Data Processing on Large Clusters》。

Hadoop 被组织大规模采用,通过实施庞大的 Hadoop 集群进行数据处理。从 Hadoop MapReduce 版本 1(MRv1)到 Hadoop MapReduce 版本 2(MRv2),它经历了巨大的增长。从纯粹的数据处理角度来看,MRv1 由 HDFS 和 MapReduce 作为核心组件组成。许多应用程序,通常称为 SQL-on-Hadoop 应用程序,如 Hive 和 Pig,都建立在 MapReduce 框架之上。尽管这些类型的应用程序是独立的 Apache 项目,但作为一套,许多此类项目提供了巨大的价值,这种情况非常常见。

Yet Another Resource NegotiatorYARN)项目随着非 MapReduce 类型的计算框架在 Hadoop 生态系统中运行而崭露头角。随着 YARN 的引入,位于 HDFS 之上,从组件架构分层的角度看,位于 MapReduce 之下,用户可以编写自己的应用程序,这些应用程序可以在 YARN 和 HDFS 上运行,以利用 Hadoop 生态系统的分布式数据存储和数据处理能力。换句话说,经过全面改造的 MapReduce 版本 2(MRv2)成为了位于 HDFS 和 YARN 之上的应用程序框架之一。

图 1简要介绍了这些组件以及它们如何堆叠在一起:

Apache Hadoop 概览

图 1

MapReduce 是一种通用数据处理模型。数据处理经过两个步骤,即映射步骤和归约步骤。在第一步中,输入数据被分割成许多较小的部分,以便每个部分可以独立处理。一旦映射步骤完成,其输出被整合,最终结果在归约步骤中生成。在典型的词频统计示例中,以每个单词为键,值为 1 创建键值对是映射步骤。基于键对这些对进行排序,对具有相同键的对的值进行求和属于中间合并步骤。生成包含唯一单词及其出现次数的对是归约步骤。

从应用程序编程的角度来看,一个过度简化的 MapReduce 应用程序的基本要素如下:

  • 输入位置

  • 输出位置

  • MapReduce 库中适当的接口和类实现了数据处理所需的Map函数。

  • MapReduce 库中适当的接口和类实现了数据处理所需的Reduce函数。

将 MapReduce 作业提交给 Hadoop 运行,一旦作业完成,可以从指定的输出位置获取输出。

MapReduce数据处理作业分为映射归约任务的两个步骤过程非常有效,并且证明是许多批量数据处理用例的完美匹配。在整个过程中,有许多与磁盘的输入/输出(I/O)操作在幕后发生。即使在 MapReduce 作业的中间步骤中,如果内部数据结构充满数据或当任务完成超过一定百分比时,写入磁盘也会发生。因此,MapReduce 作业的后续步骤必须从磁盘读取。

然后,当有多个 MapReduce 作业需要以链式方式完成时,另一个最大的挑战出现了。换句话说,如果一项大数据处理工作是通过两个 MapReduce 作业完成的,使得第一个 MapReduce 作业的输出成为第二个 MapReduce 作业的输入。在这种情况下,无论第一个 MapReduce 作业的输出大小如何,它都必须写入磁盘,然后第二个 MapReduce 作业才能将其用作输入。因此,在这种情况下,存在一个明确且不必要的写操作。

在许多批量数据处理的用例中,这些 I/O 操作并不是大问题。如果结果高度可靠,对于许多批量数据处理用例来说,延迟是可以容忍的。但最大的挑战出现在进行实时数据处理时。MapReduce 作业中涉及的大量 I/O 操作使其不适合以最低可能延迟进行实时数据处理。

理解 Apache Spark

Spark 是一个基于Java 虚拟机JVM)的分布式数据处理引擎,具有可扩展性,且速度远超许多其他数据处理框架。Spark 起源于加州大学伯克利分校,后来成为 Apache 的顶级项目之一。研究论文《Mesos:数据中心细粒度资源共享平台》阐述了 Spark 设计背后的理念。论文指出:

"为了验证简单专用框架的价值,我们识别出实验室机器学习研究人员发现运行不佳的一类作业:迭代作业,其中数据集在多次迭代中被重复使用。我们构建了一个专为这些工作负载优化的框架,名为 Spark。"

Spark 关于速度的最大宣称是,它能在内存中“运行程序比 Hadoop MapReduce 快 100 倍,或在磁盘上快 10 倍”。Spark 之所以能做出这一宣称,是因为它在工作者节点的主内存中进行处理,避免了不必要的磁盘 I/O 操作。Spark 的另一优势是,即使在应用程序编程级别,也能链式执行任务,完全不写入磁盘或最小化磁盘写入次数。

Spark 相较于 MapReduce,为何在数据处理上如此高效?这得益于其先进的有向无环图DAG)数据处理引擎。这意味着每个 Spark 作业都会创建一个任务 DAG 供引擎执行。在数学术语中,DAG 由一组顶点和连接它们的定向边组成。任务按照 DAG 布局执行。而在 MapReduce 中,DAG 仅包含两个顶点,一个用于映射任务,另一个用于归约任务,边从映射顶点指向归约顶点。内存数据处理与基于 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,如图所示:

理解 Apache Spark

图 2

Spark 编程范式非常强大,提供了一个统一的编程模型,支持使用多种编程语言进行应用程序开发。尽管在所有支持的编程语言之间没有功能对等性,但 Spark 支持 Scala、Java、Python 和 R 的编程。除了使用这些编程语言编写 Spark 应用程序外,Spark 还为 Scala、Python 和 R 提供了具有读取、评估、打印和循环REPL)功能的交互式 Shell。目前,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 可以在具有支持 OS 的单个节点上以独立模式部署。Spark 也可以在 Hadoop YARN 和 Apache Mesos 的集群节点上部署。Spark 还可以在 Amazon EC2 云上部署。Spark 可以从各种数据存储中访问数据,其中一些最受欢迎的包括 HDFS、Apache Cassandra、Hbase、Hive 等。除了前面列出的数据存储外,如果有驱动程序或连接器程序可用,Spark 几乎可以从任何数据源访问数据。

Tip

  • 本书中使用的所有示例均在 Mac OS X Version 10.9.5 计算机上开发、测试和运行。除 Windows 外,相同的指令适用于所有其他平台。在 Windows 上,对应于所有 UNIX 命令,都有一个带有.cmd扩展名的文件,必须使用该文件。例如,对于 UNIX 中的spark-shell,Windows 中有spark-shell.cmd。程序行为和结果应在所有支持的操作系统上保持一致。

在任何分布式应用中,通常都有一个控制执行的主程序和多个工作节点。主程序将任务分配给相应的工作节点。即使在 Spark 独立模式下也是如此。对于 Spark 应用,其SparkContext对象即为主程序,它与相应的集群管理器通信以运行任务。Spark 核心库中的 Spark 主节点、Mesos 主节点和 Hadoop YARN 资源管理器都是 Spark 支持的一些集群管理器。在 Hadoop YARN 部署的 Spark 情况下,Spark 驱动程序在 Hadoop YARN 应用主进程内运行,或者作为 Hadoop YARN 的客户端运行。图 3描述了 Spark 的独立部署:

理解 Apache Spark

图 3

在 Spark 的 Mesos 部署模式下,集群管理器将是Mesos 主节点图 4描述了 Spark 的 Mesos 部署:

理解 Apache Spark

图 4

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

理解 Apache Spark

图 5

Spark 也运行在云端。在 Spark 部署在 Amazon EC2 的情况下,除了从常规支持的数据源访问数据外,Spark 还可以从 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 编程的前提条件,必须在安装 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 代码中引入了库,可能会出现缺少 locale 的错误。此时,请在相应的用户配置文件中设置以下环境变量以消除错误信息:

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

因此,即使系统中只有一个版本的 Python,也最好明确设置 Spark 的 Python 可执行文件。这是为了防止将来安装其他版本的 Python 时出现意外行为的安全措施。

一旦完成所有前面的步骤并成功,确保所有语言(Scala、Python 和 R)的 Spark shell 都能正常工作。在操作系统终端提示符下运行以下命令,并确保没有错误,且显示内容与以下类似。以下命令集用于启动 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。

有许多集成开发环境IDEs)适用于 Scala 和 Java。这是个人选择,开发者可以根据自己开发 Spark 应用程序所用的语言选择工具。

可选软件安装

Spark REPL for Scala 是开始进行代码片段原型设计和测试的好方法。但当需要开发、构建和打包 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 笔记本。然后,创建一个笔记本并在其中编写代码,就像在 Python 的 Spark REPL 中给出命令一样。访问ipython.org下载并安装 IPython 笔记本。安装完成后,调用 IPython 笔记本界面,并确保一些示例 Python 代码运行正常。从存储笔记本的目录或将要存储笔记本的目录调用命令。这里,IPython 笔记本是从临时目录启动的。当调用以下命令时,它将打开 Web 界面,从中通过点击“新建”下拉框并选择适当的 Python 版本来创建新笔记本。

下图展示了如何在 IPython 笔记本中将 Markdown 风格的文档、Python 程序以及生成的输出结合起来:

$ cd /Users/RajT/temp 
$ ipython notebook 

IPython

图 6

图 6展示了如何使用 IPython 笔记本编写简单的 Python 程序。IPython 笔记本可以配置为 Spark 的首选 Shell,当调用 Python 的 Spark REPL 时,它将启动 IPython 笔记本,从而可以使用 IPython 笔记本进行 Spark 应用程序开发。为此,需要在适当的用户配置文件中定义以下环境变量:

export PYSPARK_DRIVER_PYTHON=ipython 
export PYSPARK_DRIVER_PYTHON_OPTS='notebook' 

现在,不是从命令提示符调用 IPython 笔记本,而是调用 Python 的 Spark REPL。就像之前所做的那样,创建一个新的 IPython 笔记本并在其中编写 Spark 代码:

$ cd /Users/RajT/temp 
$ pyspark 

请看下面的截图:

IPython

图 7

提示

在任何语言的标准 Spark REPL 中,都可以通过相对路径引用本地文件系统中的文件。当使用 IPython 笔记本时,需要通过完整路径引用本地文件。

RStudio

在 R 用户社区中,首选的 IDE 是 RStudio。RStudio 也可用于开发 R 中的 Spark 应用程序。访问www.rstudio.com下载并安装 RStudio。安装完成后,在运行任何 Spark R 代码之前,必须包含SparkR库并设置一些变量,以确保从 RStudio 顺利运行 Spark R 程序。以下代码片段实现了这一点:

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 代码的示例:

RStudio

图 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 等。在前端方面,它同样采用可插拔架构,即Helium 框架。后端生成的数据由前端组件(如 Angular JS)显示。有多种选项可以显示数据,包括表格格式、解释器生成的原始格式、图表和绘图。由于后端、前端以及能够插入各种组件的架构分离关注点,它是一种选择异构组件以适应不同任务的绝佳方式。同时,它能够很好地集成,提供一个和谐的用户友好型数据处理生态系统。尽管 Zeppelin 为各种组件提供了可插拔架构能力,但其可视化选项有限。换句话说,Zeppelin 开箱即用提供的图表和绘图选项并不多。一旦笔记本正常工作并产生预期结果,通常会将笔记本共享给其他人,为此,笔记本需要被持久化。Zeppelin 在这方面再次与众不同,它拥有一个高度灵活的笔记本存储系统。笔记本可以持久化到文件系统、Amazon S3 或 Git,并且如有需要,还可以添加其他存储目标。

平台即服务PaaS)自云计算作为应用开发和部署平台以来,在过去几年中经历了巨大的创新和发展。对于软件开发者而言,通过云提供的众多 PaaS 平台消除了他们拥有自己的应用开发栈的需求。Databricks 推出了一款基于云的大数据平台,用户可以访问基于笔记本的 Spark 应用开发界面,并与微集群基础设施相结合,以便提交 Spark 应用。此外,还有一个社区版,服务于更广泛的开发社区。该 PaaS 平台最大的优势在于它是一个基于浏览器的界面,用户可以在多个版本的 Spark 和不同类型的集群上运行代码。

参考文献

更多信息请参考以下链接:

摘要

Spark 是一个功能强大的数据处理平台,支持统一的编程模型。它支持 Scala、Java、Python 和 R 中的应用程序开发,提供了一系列高度互操作的库,用于满足各种数据处理需求,以及大量利用 Spark 生态系统的第三方库,涵盖了其他各种数据处理用例。本章简要介绍了 Spark,并为本书后续章节将要介绍的 Spark 应用程序开发设置了开发环境。

下一章将讨论 Spark 编程模型、基本抽象和术语、Spark 转换和 Spark 操作,结合实际用例进行阐述。

第二章:Spark 编程模型

提取转换加载ETL)工具随着组织中数据的增长,大量涌现。将数据从一个源移动到一个或多个目的地,并在到达目的地之前对其进行实时处理,这些都是当时的需求。大多数情况下,这些 ETL 工具仅支持少数类型的数据,少数类型的数据源和目的地,并且对扩展以支持新数据类型和新源和目的地持封闭态度。由于这些工具的严格限制,有时甚至一个步骤的转换过程也必须分多个步骤完成。这些复杂的方法要求在人力以及其他计算资源方面产生不必要的浪费。商业 ETL 供应商的主要论点始终如一,那就是“一刀切”并不适用。因此,请使用我们的工具套件,而不是市场上可用的单一产品。许多组织因处理数据的迫切需求而陷入供应商锁定。几乎所有在 2005 年之前推出的工具,如果它们支持在商品硬件上运行,都没有充分利用计算机多核架构的真正力量。因此,简单但大量的数据处理任务使用这些工具需要数小时甚至数天才能完成。

Spark 因其处理大量数据类型以及不断增长的数据源和数据目的地的能力而在市场上迅速走红。Spark 提供的最重要的基本数据抽象是弹性分布式数据集RDD)。如前一章所述,Spark 支持在节点集群上进行分布式处理。一旦有了节点集群,在数据处理过程中,某些节点可能会死亡。当此类故障发生时,框架应能够从中恢复。Spark 的设计就是为了做到这一点,这就是 RDD 中弹性部分的含义。如果有大量数据要处理,并且集群中有可用节点,框架应具备将大数据集分割成小块并在集群中多个节点上并行处理的能力。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 分割成小块,分发到各个工作节点进行处理,并最终编译结果以生成最终结果,而无需担心底层数据被更改。

Spark RDD 是可分布的

如果 Spark 在集群模式下运行,其中有多台工作节点可以接收任务,所有这些节点将具有不同的执行上下文。各个任务被分发并在不同的 JVM 上运行。所有这些活动,如大型 RDD 被分割成小块,被分发到工作节点进行处理,最后将结果重新组装,对用户是完全隐藏的。

Spark 拥有自己的机制来从系统故障和其他数据处理过程中发生的错误中恢复,因此这种数据抽象具有极高的弹性。

Spark RDD 存储在内存中

Spark 尽可能将所有 RDD 保留在内存中。仅在极少数情况下,如 Spark 内存不足或数据量增长超出容量时,数据才会被写入磁盘。RDD 的大部分处理都在内存中进行,这也是 Spark 能够以极快速度处理数据的原因。

Spark RDD 是强类型的

Spark RDD 可以使用任何受支持的数据类型创建。这些数据类型可以是 Scala/Java 支持的固有数据类型,也可以是自定义创建的数据类型,如您自己的类。这一设计决策带来的最大优势是避免了运行时错误。如果因数据类型问题导致程序崩溃,它将在编译时崩溃。

下表描述了包含零售银行账户数据元组的 RDD 结构。其类型为 RDD[(string, string, string, double)]:

账户号 名字 姓氏 账户余额
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)]的元素:

账户号 名字 姓氏 账户余额
SB001 John Mathew 250.00
SB002 Tracy Mason 450.00

下表包含了分配给节点 N2 进行处理的 RDD[(string, string, string, double)]的元素:

账户号 名字 姓氏 账户余额
SB003 Paul Thomson 560.00
SB004 Samantha Grisham 650.00
SB005 John Grove 1000.00

在节点 N1 上,求和过程发生并将结果返回给 Spark 驱动程序。同样,在节点 N2 上,求和过程发生,结果返回给 Spark 驱动程序,并计算最终结果。

Spark 在将大型 RDD 分割成小块并分配给各个节点方面有非常确定的规则,因此,即使某个节点如 N1 出现问题,Spark 也知道如何精确地重新创建丢失的块,并通过将相同的负载发送到节点 N3 来继续数据处理操作。

图 1 捕捉了该过程的本质:

Spark RDD 是强类型的

图 1

提示

Spark 在其驱动内存和集群节点的执行器内存中进行大量处理。Spark 有多种可配置和微调的参数,以确保在处理开始前所需的资源已就绪。

使用 RDD 进行数据转换和操作

Spark 使用 RDD 进行数据处理。从相关的数据源(如文本文件和 NoSQL 数据存储)读取数据以形成 RDD。对这样的 RDD 执行各种数据转换,并最终收集结果。确切地说,Spark 提供了作用于 RDD 的 Spark 转换和 Spark 动作。让我们以捕获零售银行业务交易列表的以下 RDD 为例,其类型为 RDD[(string, string, double)]:

账户号 交易号 交易金额
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 计算账户级交易摘要:

  1. 首先需要将其转换为键值对形式(AccountNo,TranAmount),其中AccountNo是键,但会有多个具有相同键的元素。

  2. 在此键上对TranAmount执行求和操作,生成另一个 RDD,形式为(AccountNo,TotalAmount),其中每个 AccountNo 只有一个元素,TotalAmount 是给定 AccountNo 的所有 TranAmount 的总和。

  3. 现在按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 可以保存以供将来参考,或者它们最终会超出作用域。

总结来说,创建一个或多个 RDD 并对它们应用转换和操作的过程是 Spark 应用程序中非常普遍的使用模式。

注意

前面数据转换示例中提到的表包含一个类型为 RDD[(string, string, double)]的 RDD 中的值。在这个 RDD 中,有多个元素,每个元素都是一个类型为(string, string, double)的元组。为了便于参考和传达思想,程序员和用户社区通常使用术语记录来指代 RDD 中的一个元素。在 Spark RDD 中,没有记录、行和列的概念。换句话说,术语记录被错误地用作 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 应用程序可能会变得非常复杂,涉及一个庞大的有向无环图(DAG),其中包含 Spark 转换和 Spark 操作。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

完成前述步骤后,确保通过访问http://localhost:8080/启动 Spark Web 用户界面 (UI)。这里假设8080端口上没有其他应用程序运行。如果出于某种原因,需要在不同的端口上运行此应用程序,可以在启动 Web 用户界面的脚本中使用命令行选项--webui-port <PORT>

Web UI 应该类似于图 2 所示:

使用 Spark 进行监控

图 2

前述图中最重要的信息是完整的 Spark 主 URL(不是 REST URL)。它将在本书中讨论的许多实践练习中反复使用。该 URL 可能因系统而异,并受 DNS 设置影响。还要注意,本书中所有实践练习均使用 Spark 独立部署,这是在单台计算机上开始部署最简单的方式。

提示

现在给出这些 Spark 应用程序监控步骤,是为了让读者熟悉 Spark 提供的工具集。熟悉这些工具或对应用程序行为非常自信的人可能不需要这些工具的帮助。但为了理解概念、调试以及一些过程的可视化,这些工具无疑提供了巨大的帮助。

从图 2 所示的 Spark Web UI 中可以看出,没有可用于执行任何任务的工作节点,也没有正在运行的应用程序。以下步骤记录了启动工作节点的指令。注意在启动工作节点时如何使用 Spark 主 URL:

$ cd $SPARK_HOME 
$ ./sbin/start-slave.sh spark://Rajanarayanans-MacBook-Pro.local:7077

一旦 worker 节点启动,在 Spark Web UI 中,新启动的 worker 节点将被显示。$SPARK_HOME/conf/slaves.template模板捕获了默认的 worker 节点,这些节点将在执行上述命令时启动。

注意

如果需要额外的 worker 节点,将slaves.template文件复制并重命名为 slaves,并在其中捕获条目。当启动 spark-shell、pyspark 或 sparkR 时,可以给出指令让其使用特定的 Spark master。这在需要远程 Spark 集群或针对特定 Spark master 运行 Spark 应用程序或语句时非常有用。如果没有给出任何内容,Spark 应用程序将在本地模式下运行。

$ cd $SPARK_HOME 
$ ./bin/spark-shell --master spark://Rajanarayanans-MacBook-Pro.local:7077 

Spark Web UI 在成功启动 worker 节点后将类似于图 3 所示。之后,如果使用上述 Spark master URL 运行应用程序,该应用程序的详细信息也会显示在 Spark Web UI 中。本章后续将详细介绍应用程序。使用以下脚本停止 worker 和 master 进程:

$ cd $SPARK_HOME 
$ ./sbin/stop-all.sh

使用 Spark 进行监控

图 3

Spark 编程基础

Spark 编程围绕 RDD 展开。在任何 Spark 应用程序中,待处理的数据被用来创建适当的 RDD。首先,从创建 RDD 的最基本方式开始,即从一个列表开始。用于这种hello world类型应用程序的输入数据是一小部分零售银行交易。为了解释核心概念,只选取了一些非常基础的数据项。交易记录包含账户号和交易金额。

提示

在本书的所有用例中,如果使用术语“记录”,那将是在业务或用例的上下文中。

以下是用于阐释 Spark 转换和 Spark 动作的用例:

  1. 交易记录以逗号分隔的值形式传入。

  2. 从列表中筛选出仅包含良好交易记录的部分。账户号应以SB开头,且交易金额应大于零。

  3. 查找所有交易金额大于 1000 的高价值交易记录。

  4. 查找所有账户号有问题的交易记录。

  5. 查找所有交易金额小于或等于零的交易记录。

  6. 查找所有不良交易记录的合并列表。

  7. 计算所有交易金额的总和。

  8. 找出所有交易金额的最大值。

  9. 找出所有交易金额的最小值。

  10. 查找所有良好账户号。

本书中将遵循的方法是,对于任何将要开发的应用程序,都从适用于相应语言的 Spark REPL 开始。启动 Scala REPL 以使用 Spark,并确保它无错误启动且可以看到提示符。对于此应用程序,我们将启用监控以学习如何操作,并在开发过程中使用它。除了显式启动 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 值中,以便稍后轻松引用。

  • badAmountRecordsbadAccountRecords是从acTransRDD创建的 RDD,分别用于过滤包含错误交易金额和无效账户号码的不良记录。

  • badTransRecords包含badAmountRecordsbadAccountRecords两个 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 上触发 Spark 动作时,才会对 RDD 进行评估。以下语句正在对 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 捕捉了这一点:

Spark 编程基础

图 4

点击应用程序 ID 进行导航,以查看与运行中的应用程序相关的所有指标,包括 DAG 可视化图表等更多内容。

这些语句涵盖了讨论过的所有用例,值得回顾迄今为止介绍的 Spark 转换。以下是一些基本但非常重要的转换,它们将在大多数应用程序中反复使用:

Spark 转换 功能描述
filter(fn) 遍历 RDD 中的所有元素,应用传入的函数,并选取函数评估为真的元素。
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']

如果比较 Scala 和 Python 代码集,Spark 统一编程模型的真正力量就非常明显。Spark 转换和 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 应用程序。

Spark 编程基础

图 5

MapReduce

自 Spark 诞生之日起,它就被定位为 Hadoop MapReduce 程序的替代品。通常,如果一个数据处理任务可以分解为多个子任务,并且这些子任务能够并行执行,且最终结果可以在收集所有这些分布式片段的结果后计算得出,那么该任务就会采用 MapReduce 风格。与 Hadoop MapReduce 不同,即使活动的有向无环图(DAG)超过两个阶段(如 Map 和 Reduce),Spark 也能完成这一过程。Spark 正是为此而设计,这也是 Spark 强调的最大价值主张之一。

本节将继续探讨同一零售银行应用程序,并选取一些适合 MapReduce 类型数据处理的理想用例。

此处为阐明 MapReduce 类型数据处理所选用的用例如下:

  1. 零售银行交易记录带有以逗号分隔的账户号码和交易金额字符串。

  2. 将交易配对成键/值对,例如(AccNo, TranAmount)。

  3. 查找所有交易的账户级别汇总,以获取账户余额。

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

以下是迄今为止所做工作的详细步骤记录:

  1. acTransList是包含逗号分隔交易记录的数组。

  2. acTransRDD是由数组创建的 RDD,其中 sc 是 Spark 上下文或 Spark 驱动程序,RDD 以并行化方式创建,以便 RDD 元素可以形成分布式数据集。

  3. acTransRDD转换为acKeyVal,以拥有形式为(K,V)的键值对,其中选择账户号码作为键。在此 RDD 的元素集合中,将存在多个具有相同键的元素。

  4. 下一步,将键值对按键分组,并传递一个缩减函数,该函数会将交易金额累加,形成包含特定键的一个元素以及同一键下所有金额总和的键值对。然后在生成最终结果前,根据键对元素进行排序。

  5. 在驱动程序级别收集元素到数组中。

假设 RDD acKeyVal被分为两部分并分布到集群进行处理,图 6 捕捉了处理的核心:

MapReduce

图 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 实现可能有所不同:

MapReduce

图 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 转换连接之外,之前给出的所有语句现在应该都很熟悉了。类似地,leftOuterJoinrightOuterJoinfullOuterJoin也以相同的用法模式提供:

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 被分区并分布在 worker 节点上。如果在遍历 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 行动:

触发行动 其作用
first() 返回 RDD 的第一个元素。
take(n) 返回 RDD 的前n个元素的数组。
countByKey() 按键返回元素计数。如果 RDD 包含(K,V)对,这将返回一个字典(K, numOfValues)
count() 返回 RDD 中的元素数量。
foreach(fn) 将函数 fn 应用于 RDD 中的每个元素。在前述用例中,使用foreach(fn)与 Spark Accumulator。

在 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") 通过读取目录中名为README.md的文件内容创建 RDD,该目录是 Spark shell 被调用的位置。这里,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 进入市场之前,有很多独立的开放源代码产品在做这里讨论的库栈现在所做的事情。这些点产品最大的缺点是它们的互操作性。它们不能很好地堆叠在一起。它们是用不同的编程语言实现的。这些产品支持的编程语言选择,以及这些产品暴露的 API 缺乏统一性,对于使用两个或更多此类产品完成一个应用程序来说确实具有挑战性。这就是在 Spark 之上工作的库栈的相关性。它们都使用相同的编程模型协同工作。这有助于组织在没有供应商锁定的情况下标准化数据处理工具集。

Spark 附带了以下一系列特定领域的库,图 8 为开发者提供了一个全面的生态系统概览:

  • Spark SQL

  • Spark Streaming

  • Spark MLlib

  • Spark GraphX

理解 Spark 库栈

图 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。从各种数据源创建 RDDs,以及使用 Spark 转换和 Spark 操作处理 RDDs 中的数据,这些内容都通过 Scala 和 Python API 进行了介绍。所有 Spark 编程模型的重要特性都通过真实世界的用例进行了讲解。本章还讨论了随 Spark 一起提供的库栈以及每个库的功能。总之,Spark 提供了一个非常用户友好的编程模型,并因此提供了一个非常强大的数据处理工具集。

下一章将讨论数据集 API 和数据帧 API。数据集 API 将成为使用 Spark 编程的新方式,而数据帧 API 则处理更结构化的数据。Spark SQL 也被引入,用于操作结构化数据,并展示如何将其与任何 Spark 数据处理应用程序混合使用。

第三章:Spark SQL

大多数企业始终处理着大量的结构化数据。尽管处理非结构化数据的方法众多,但许多应用场景仍需依赖结构化数据。处理结构化数据与非结构化数据的主要区别是什么?如果数据源是结构化的,且数据处理引擎事先知晓数据结构,那么该引擎在处理数据时可以进行大量优化,甚至提前进行。当数据处理量巨大且周转时间极为关键时,这一点尤为重要。

企业数据的激增要求赋予终端用户通过简单易用的应用程序用户界面查询和处理数据的能力。关系型数据库管理系统供应商联合起来,结构化查询语言SQL)应运而生,成为解决这一问题的方案。在过去几十年里,所有与数据打交道的人,即使不是高级用户,也熟悉了 SQL。

社交网络和微博等大规模互联网应用产生的数据超出了许多传统数据处理工具的消耗能力。面对如此海量的数据,从中挑选并选择正确的数据变得更为重要。Spark 是一个广泛使用的数据处理平台,其基于 RDD 的编程模型相比 Hadoop MapReduce 数据处理框架减少了数据处理的工作量。然而,Spark 早期基于 RDD 的编程模型对于终端用户(如数据科学家、数据分析师和业务分析师)来说使用起来并不直观。主要原因是它需要一定程度的功能编程知识。解决这一问题的方案是 Spark SQL。Spark SQL 是建立在 Spark 之上的一个库,它提供了 SQL 接口和 DataFrame API。DataFrame API 支持 Scala、Java、Python 和 R 等编程语言。

如果事先知道数据的结构,如果数据符合行和列的模型,那么数据来自哪里并不重要,Spark SQL 可以将所有数据整合在一起处理,仿佛所有数据都来自单一来源。此外,查询语言是普遍使用的 SQL。

本章我们将涵盖以下主题:

  • 数据结构

  • Spark SQL

  • 聚合

  • 多数据源连接

  • 数据集

  • 数据目录

理解数据结构

此处讨论的数据结构需要进一步阐明。我们所说的数据结构是什么意思?存储在 RDBMS 中的数据以行/列或记录/字段的方式存储。每个字段都有数据类型,每个记录是相同或不同数据类型的字段集合。在 RDBMS 早期,字段的数据类型是标量的,而在近期版本中,它扩展到包括集合数据类型或复合数据类型。因此,无论记录包含标量数据类型还是复合数据类型,重要的是要注意底层数据具有结构。许多数据处理范式已采用在内存中镜像 RDBMS 或其他存储中持久化的底层数据结构的概念,以简化数据处理。

换言之,如果 RDBMS 表中的数据正被数据处理应用程序处理,且内存中存在与该表类似的数据结构供程序、最终用户和程序员使用,那么建模应用程序和查询数据就变得容易了。例如,假设有一组逗号分隔的数据项,每行具有固定数量的值,且每个特定位置的值都有特定的数据类型。这是一个结构化数据文件,类似于 RDBMS 表。

在 R 等编程语言中,使用数据框抽象在内存中存储数据表。Python 数据分析库 Pandas 也有类似的数据框概念。一旦该数据结构在内存中可用,程序就可以根据需要提取数据并进行切片和切块。同样的数据表概念被扩展到 Spark,称为 DataFrame,建立在 RDD 之上,并且有一个非常全面的 API,即 Spark SQL 中的 DataFrame API,用于处理 DataFrame 中的数据。还开发了一种类似 SQL 的查询语言,以满足最终用户查询和处理底层结构化数据的需求。总之,DataFrame 是一个分布式数据表,按行和列组织,并为每个列命名。

Spark SQL 库建立在 Spark 之上,是基于题为“Spark SQL:Spark 中的关系数据处理”的研究论文开发的。它提出了 Spark SQL 的四个目标,并如下所述:

  • 支持在 Spark 程序内部(基于原生 RDD)以及使用程序员友好 API 的外部数据源上进行关系处理

  • 利用成熟的 DBMS 技术提供高性能

  • 轻松支持新的数据源,包括半结构化数据和易于查询联合的外部数据库

  • 支持扩展高级分析算法,如图形处理和机器学习

DataFrame 存储结构化数据,并且是分布式的。它允许进行数据的选择、过滤和聚合。听起来与 RDD 非常相似吗?RDD 和 DataFrame 的关键区别在于,DataFrame 存储了关于数据结构的更多信息,如数据类型和列名,这使得 DataFrame 在处理优化上比基于 RDD 的 Spark 转换和操作更为有效。另一个需要提及的重要方面是,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、NoSQL 数据存储如 Cassandra,甚至是 RDBMS,Spark SQL 都能用于分析数据,并与 Spark 程序混合使用。这里提到的许多数据源都由 Spark SQL 内在支持,而其他许多则由外部包支持。这里最值得强调的是 Spark SQL 处理来自极其多样数据源的数据的能力。一旦数据作为 DataFrame 在 Spark 中可用,Spark SQL 就能以完全分布式的方式处理数据,将来自不同数据源的 DataFrames 组合起来进行处理和查询,仿佛整个数据集都来自单一源。

在前一章中,详细讨论了 RDD 并将其引入为 Spark 编程模型。Spark SQL 中的 DataFrames API 和 SQL 方言的使用是否正在取代基于 RDD 的编程模型?绝对不是!基于 RDD 的编程模型是 Spark 中通用且基本的数据处理模型。基于 RDD 的编程需要使用实际的编程技术。Spark 转换和 Spark 操作使用了许多函数式编程结构。尽管与 Hadoop MapReduce 或其他范式相比,基于 RDD 的编程模型所需的代码量较少,但仍需要编写一定量的函数式代码。这对于许多数据科学家、数据分析师和业务分析师来说是一个障碍,他们可能主要进行探索性的数据分析或对数据进行一些原型设计。Spark SQL 完全消除了这些限制。简单易用的领域特定语言DSL)方法,用于从数据源读取和写入数据,类似 SQL 的语言用于选择、过滤和聚合,以及从各种数据源读取数据的能力,使得任何了解数据结构的人都可以轻松使用它。

注意

何时使用 RDD,何时使用 Spark SQL 的最佳用例是什么?答案很简单。如果数据是结构化的,可以排列在表格中,并且可以为每一列命名,那么使用 Spark SQL。这并不意味着 RDD 和 DataFrame 是两个截然不同的实体。它们互操作得非常好。从 RDD 到 DataFrame 以及反之的转换都是完全可能的。许多通常应用于 RDD 的 Spark 转换和 Spark 操作也可以应用于 DataFrames。

通常,在应用程序设计阶段,业务分析师通常使用 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,后者比 SQLContext 功能更丰富、更高级。HiveContext 可以执行 SQLContext 所能做的一切,并且在此基础上,它可以读取 Hive 元存储和表,还可以访问 Hive 用户定义的函数。使用 HiveContext 的唯一要求显然是应该有一个现成的 Hive 设置。这样,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展示了相关内容:

Spark SQL 结构解析

图 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 的查询规划和优化也值得一提。通过 SQL 查询或 DataFrame API 对 DataFrame 执行的任何查询操作在物理应用于底层基本 RDD 之前都经过了高度优化。在 RDD 上实际操作发生之前,存在许多中间过程。

图 2 提供了关于整个查询优化过程的一些见解:

Spark SQL 结构解析

图 2

针对 DataFrame,可以调用两种类型的查询:SQL 查询或 DataFrame API 调用。它们经过适当的分析,生成逻辑查询执行计划。随后,对逻辑查询计划进行优化,以得到优化的逻辑查询计划。从最终的优化逻辑查询计划出发,制定一个或多个物理查询计划。对于每个物理查询计划,都会计算成本模型,并根据最优成本选择合适的物理查询计划,生成高度优化的代码,并针对 RDDs 运行。这就是 DataFrame 上任何类型查询性能一致的原因。这也是为什么从 Scala、Java、Python 和 R 等不同语言调用 DataFrame API 都能获得一致性能的原因。

让我们再次回顾图 3所示的大局,以设定背景,了解当前讨论的内容,然后再深入探讨并处理这些用例:

Spark SQL 结构

图 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 case 类被定义以描述将要输入 DataFrame 的交易记录的结构。

  • 定义了一个包含必要交易记录的数组。

  • RDD 由数组生成,分割逗号分隔的值,映射以使用 Scala 脚本中定义的第一个步骤中的 case 类创建对象,并将 RDD 转换为 DataFrame。这是 RDD 与 DataFrame 之间互操作性的一个用例。

  • 使用一个名称将表注册到 DataFrame。该注册表的名称可以在 SQL 语句中使用。

  • 然后,所有其他活动只是使用 spark.sql 方法发出 SQL 语句。这里的 spark 对象是 SparkSession 类型。

  • 所有这些 SQL 语句的结果存储为 DataFrames,并且就像 RDD 的 collect 操作一样,使用 DataFrame 的 show 方法将值提取到 Spark 驱动程序中。

  • 聚合值的计算以两种不同的方式进行。一种是使用 SQL 语句的方式,这是最简单的方式。另一种是使用常规的 RDD 风格的 Spark 转换和 Spark 操作。这是为了展示即使 DataFrame 也可以像 RDD 一样操作,并且可以在 DataFrame 上应用 Spark 转换和 Spark 操作。

  • 有时,通过使用函数的功能样式操作进行一些数据操作活动很容易。因此,这里有一个灵活性,可以混合使用 SQL、RDD 和 DataFrame,以拥有一个非常方便的数据处理编程模型。

  • DataFrame 内容以表格格式使用 DataFrame 的 show 方法显示。

  • 使用 printSchema 方法展示 DataFrame 结构的详细视图。这类似于数据库表的 describe 命令。

在 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", "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 之间有着非常紧密的联系,这在涵盖 Datasets 的部分中有所解释。在开发应用程序时,必须在这种情况下谨慎行事。例如,在前面的代码片段中,如果在 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 提交的当前目录。

在 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

  • 纯文本

  • 亚马逊 S3

  • ORC

  • JDBC

在前述代码片段中已演示了将 DataFrame 写入和读取自 Parquet。所有这些内置支持的数据存储都具有非常简单的 DSL 风格语法用于持久化和读取,这使得编程风格再次统一。DataFrame API 参考资料是了解处理这些数据存储细节的绝佳资源。

本章中的示例代码将数据持久化在 Parquet 和 JSON 格式中。所选数据存储位置名称如python.trans.parquetscala.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 与 RDD 之间互操作的能力。同样地,SQL 查询和 DataFrame API 可以混合使用,以便在解决应用程序中的实际用例时,能够灵活地采用最简便的计算方法。

引入数据集

Spark 编程范式在开发数据处理应用时提供了多种抽象选择。Spark 编程的基础始于 RDD,它能轻松处理非结构化、半结构化和结构化数据。Spark SQL 库在处理结构化数据时展现出高度优化的性能,使得基本 RDD 在性能上显得逊色。为了弥补这一差距,自 Spark 1.6 起,引入了一种名为 Dataset 的新抽象,它补充了基于 RDD 的 Spark 编程模型。在 Spark 转换和 Spark 操作方面,Dataset 的工作方式与 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。如果需要,这个类型为org.apache.spark.sql.Dataset[org.apache.spark.sql.Row]的对象可以显式地使用toDF()方法转换为 DataFrame。

过多的选择让所有人困惑。在 Spark 编程模型中,同样的问题也存在。但这并不像许多其他编程范式那样令人困惑。每当需要处理任何类型的数据,并且对数据处理要求具有极高的灵活性,以及拥有最低级别的 API 控制,如库开发时,基于 RDD 的编程模型是理想选择。每当需要处理结构化数据,并且需要跨所有支持的编程语言灵活访问和处理数据,同时优化性能时,基于 DataFrame 的 Spark SQL 编程模型是理想选择。

每当需要处理非结构化数据,同时要求优化性能和编译时类型安全,但不需要非常复杂的 Spark 转换和 Spark 操作使用要求时,基于 dataset 的编程模型是理想选择。在数据处理应用开发层面,如果所选编程语言允许,使用 dataset 和 DataFrame 会获得更好的性能。

理解数据目录

本章前几节介绍了使用 DataFrames 和 datasets 的编程模型。这两种编程模型都能处理结构化数据。结构化数据自带元数据,即描述数据结构的数据。Spark SQL 提供了一个极简的 API,称为 Catalog 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 代码中使用。由于 dataset 示例在 Python 中不适用,表列表将为空。在 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 的 DSL 风格命令式程序编写。本章还展示了混合 RDD 和 DataFrames、混合类似 SQL 的查询和 DataFrame API 的各种策略。这为应用程序开发人员提供了极大的灵活性,使他们能够以最舒适的方式或更适合用例的方式编写数据处理程序,同时不牺牲性能。

Dataset API 作为基于 Spark 中数据集的下一代编程模型,提供了优化的性能和编译时的类型安全。

目录 API 作为一个非常便捷的工具,可根据元数据存储的内容动态处理数据。

R 是数据科学家的语言。在 Spark SQL 支持 R 作为编程语言之前,对他们来说,进行大规模分布式数据处理并不容易。现在,使用 R 作为首选语言,他们可以无缝地编写分布式数据处理应用程序,就像使用个人机器上的 R 数据框一样。下一章将讨论在 Spark SQL 中使用 R 进行数据处理。

第四章:Spark 编程与 R

R 是一种流行的统计计算编程语言,被许多人使用,并根据通用公共许可证GNU)免费提供。R 源自 John Chambers 创建的编程语言 S。R 由 Ross Ihaka 和 Robert Gentleman 开发。许多数据科学家使用 R 来满足他们的计算需求。R 内置支持许多统计函数和许多标量数据类型,并具有向量、矩阵、数据框等复合数据结构,用于统计计算。R 高度可扩展,因此可以创建外部包。一旦创建了外部包,就必须安装并加载它,以便任何程序都可以使用它。目录下的一组此类包构成一个 R 库。换句话说,R 附带了一组基本包和附加包,可以安装在它上面,以形成满足所需计算需求的库。除了函数之外,数据集也可以打包在 R 包中。

本章我们将涵盖以下主题:

  • 对 SparkR 的需求

  • R 基础

  • 数据框

  • 聚合

  • 使用 SparkR 进行多数据源连接

对 SparkR 的需求

SparkR 包使得基础 R 安装能够与 Spark 交互,它提供了 R 与 Spark 生态系统对话所需的所有对象和函数。与 Scala、Java 和 Python 相比,R 中的 Spark 编程有所不同,SparkR 包主要提供基于 DataFrame 的 Spark SQL 编程的 R API。目前,R 无法直接操作 Spark 的 RDD。因此,实际上,R 的 Spark API 只能访问 Spark SQL 抽象。由于 Spark MLlib 使用 DataFrames,因此也可以使用 R 进行编程。

SparkR 如何帮助数据科学家进行更好的数据处理?基础 R 安装要求所有数据都必须存储(或可访问)在安装了 R 的计算机上。数据处理发生在安装了 R 的单台计算机上。此外,如果数据大小超过了计算机上的主内存,R 将无法执行所需的加工。通过 SparkR 包,可以访问一个全新的集群节点世界,用于数据存储和数据处理。借助 SparkR 包,R 可以访问 Spark DataFrames 和 R DataFrames。

了解 R Dataframes 和 Spark Dataframes 这两种数据框之间的区别非常重要。R DataFrame 是完全本地的,是 R 语言的数据结构。Spark DataFrame 是由 Spark 基础设施管理的结构化数据的并行集合。

R DataFrame 可以转换为 Spark DataFrame,Spark DataFrame 也可以转换为 R DataFrame。

当 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 DataFrames 一样,它也可以访问某些 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 中的数据框

在使用 R 操作 Spark 时,很容易对 DataFrame 数据结构感到困惑。如前所述,R 和 Spark SQL 中都存在 DataFrame。下面的代码片段涉及将 R 数据框转换为 Spark 数据框以及反向转换。当使用 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 数据框与 Spark 数据框之间没有完全的兼容性和互操作性。

提示

为了区分两种不同类型的数据框,在 R 程序中最好按照约定俗成的规则为 R 数据框和 Spark 数据框命名。并非所有 R 数据框支持的函数都适用于 Spark 数据框,反之亦然。务必参考正确的 R API 版本以使用 Spark。

对于经常使用图表和绘图的人来说,在处理 R DataFrames 与 Spark DataFrames 结合时必须格外小心。R 的图表和绘图功能仅适用于 R DataFrames。如果需要使用 Spark 处理的数据并将其呈现在 Spark DataFrame 中的图表或绘图中,则必须将其转换为 R DataFrame 才能继续进行图表和绘图。以下代码片段将对此有所启示。我们将在 Spark 的 R REPL 中再次使用 faithful 数据集进行说明:

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 进行相同的操作:

R 与 Spark 中的 DataFrames

图 1

当图表和绘图库与 Spark DataFrame 一起使用时,由于数据类型不兼容,出现了错误。

提示

需要记住的最重要的一点是,R DataFrame 是一种内存驻留数据结构,而 Spark DataFrame 是一种跨集群节点分布的并行数据集集合。因此,所有使用 R DataFrames 的功能不一定适用于 Spark DataFrames,反之亦然。

让我们再次回顾一下大局,如图 2所示,以便设定背景并了解正在讨论的内容,然后再深入探讨并处理这些用例。在前一章中,同一主题是通过使用 Scala 和 Python 编程语言引入的。在本章中,将使用 R 实现 Spark SQL 编程中使用的同一组用例:

R 与 Spark 中的 DataFrames

图 2

此处将要讨论的用例将展示在 R 中混合使用 SQL 查询和 Spark 程序的能力。将选择多个数据源,使用 DataFrame 从这些源读取数据,并演示统一的数据访问。

Spark DataFrame 编程与 R

以下是用于阐明使用 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 DataFrames,并使用showDF函数将值提取到调用的 R 程序中。

  • 通过 SQL 语句也进行了聚合值的计算。

  • 使用 SparkR 的showDF函数,DataFrame 内容以表格形式显示。

  • 使用 print 函数显示 DataFrame 结构的详细视图。这类似于数据库表的 describe 命令。

在前述的 R 代码中,编程风格与 Scala 代码相比有所不同,这是因为它是 R 程序。通过使用 SparkR 库,正在使用 Spark 特性。但函数和其他抽象并没有采用截然不同的风格。

注意

本章中,将多次涉及 DataFrames 的使用。很容易混淆哪个是 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 中亦是如此。与在单机上的单一数据源运行 SQL 语句不同,Spark 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 DataFrames 由持久化的 JSON 文件创建。除 JSON 文件外,还可使用任何支持的数据文件。随后,这些文件从磁盘读取以形成 DataFrames,并进行连接。

在 R REPL 提示符下,尝试以下语句:

> # 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 REPL 会话中,以下代码行通过 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 的列名必须不同,以避免结果 DataFrame 中的歧义。在此特定用例中,可以看出 DataFrame API 处理起来略显复杂,而 SQL 查询方式则显得非常直接。

在前述章节中,已涵盖 Spark SQL 的 R API。通常,若可能,应尽可能使用 SQL 查询方式编写代码。DataFrame API 正在改进,但与其他语言(如 Scala 或 Python)相比,其灵活性仍显不足。

与本书其他章节不同,本章是专为 R 程序员介绍 Spark 的独立章节。本章讨论的所有用例均在 Spark 的 R REPL 中运行。但在实际应用中,这种方法并不理想。R 命令需组织在脚本文件中,并提交至 Spark 集群运行。最简便的方法是使用现有的$SPARK_HOME/bin/spark-submit <path to the R script file>脚本,其中 R 文件名需相对于命令调用时的当前目录给出完整路径。

参考资料

更多信息,请参考:spark.apache.org/docs/latest/api/R/index.html

总结

本章涵盖了对 R 语言的快速概览,随后特别提到了需要明确理解 R DataFrame 与 Spark DataFrame 之间的区别。接着,使用与前几章相同的用例介绍了基本的 Spark 编程与 R。涵盖了 Spark 的 R API,并通过 SQL 查询方式和 DataFrame API 方式实现了用例。本章帮助数据科学家理解 Spark 的强大功能,并将其应用于他们的 R 应用程序中,使用随 Spark 附带的 SparkR 包。这为使用 Spark 与 R 处理结构化数据的大数据处理打开了大门。

关于基于 Spark 的数据处理在多种语言中的主题已经讨论过,现在是时候专注于一些数据分析以及图表和绘图了。Python 自带了许多能够生成出版质量图片的图表和绘图库。下一章将讨论使用 Spark 处理的数据进行图表和绘图。

第五章:Spark 与 Python 的数据分析

处理数据的最终目标是利用结果回答业务问题。理解用于回答业务问题的数据至关重要。为了更好地理解数据,采用了各种制表方法、图表和绘图技术。数据的可视化表示强化了对底层数据的理解。因此,数据可视化在数据分析中得到了广泛应用。

在各种出版物中,用于表示分析数据以回答业务问题的术语各不相同。数据分析、数据分析和商业智能是一些普遍存在的术语。本章不会深入讨论这些术语的含义、相似之处或差异。相反,重点将放在如何弥合数据科学家或数据分析师通常执行的两个主要活动之间的差距。第一个是数据处理。第二个是利用处理过的数据借助图表和绘图进行分析。数据分析是数据分析师和数据科学家的专长。本章将重点介绍使用 Spark 和 Python 处理数据,并生成图表和图形。

在许多数据分析用例中,处理一个超集数据,并将缩减后的结果数据集用于数据分析。在大数据分析中,这一点尤为正确,其中一小部分处理过的数据用于分析。根据用例,针对各种数据分析需求,作为前提条件进行适当的数据处理。本章将要涵盖的大多数用例都属于这种模式,其中第一步涉及必要的数据处理,第二步涉及数据分析所需的图表和绘图。

在典型的数据分析用例中,活动链涉及一个广泛且多阶段的提取转换加载ETL)管道,最终形成一个数据分析平台或应用程序。这一系列活动链的最终结果包括但不限于汇总数据表以及以图表和图形形式呈现的各种数据可视化。由于 Spark 能非常有效地处理来自异构分布式数据源的数据,因此在传统数据分析应用中存在的庞大 ETL 管道可以整合为自包含的应用程序,进行数据处理和数据分析。

本章我们将探讨以下主题:

  • 图表和绘图库

  • 设置数据集

  • 捕捉数据分析用例的高层次细节

  • 各种图表和图形

图表和绘图库

Python 是当今数据分析师和数据科学家广泛使用的编程语言。有众多科学和统计数据处理库,以及图表和绘图库,可在 Python 程序中使用。Python 也广泛用于开发 Spark 中的数据处理应用程序。这为使用 Spark、Python 及其库实现统一的数据处理和分析框架提供了极大的灵活性,使我们能够进行科学和统计处理,以及图表和绘图。有许多与 Python 兼容的此类库。其中,NumPySciPy库在此用于数值、统计和科学数据处理。matplotlib库在此用于生成 2D 图像的图表和绘图。

提示

确保NumPySciPymatplotlib Python 库与 Python 安装正常工作非常重要,然后再尝试本章给出的代码示例。这需要在将其用于 Spark 应用程序之前进行测试和验证。

如图图 1所示的框图给出了应用程序堆栈的整体结构:

图表和绘图库

图 1

设置数据集

有许多公共数据集可供公众用于教育、研究和开发目的。MovieLens 网站允许用户对电影进行评分并个性化推荐。GroupLens Research 发布了来自 MovieLens 的评分数据集。这些数据集可从其网站grouplens.org/datasets/movielens/下载。本章使用 MovieLens 100K 数据集来演示如何结合 Python、NumPy、SciPy 和 matplotlib 使用 Spark 进行分布式数据处理。

提示

在 GroupLens Research 网站上,除了上述数据集外,还有更多庞大的数据集可供下载,如 MovieLens 1M 数据集、MovieLens 10M 数据集、MovieLens 20M 数据集以及 MovieLens 最新数据集。一旦读者对程序相当熟悉,并在处理数据时达到足够的舒适度,就可以利用这些额外数据集进行自己的分析工作,以巩固本章所获得的知识。

MovieLens 100K 数据集包含多个文件中的数据。以下是本章数据分析用例中将使用的文件:

  • u.user:关于对电影进行评分的用户的用户人口统计信息。数据集的结构如下所示,与数据集附带的 README 文件中复制的相同:

    • 用户 ID

    • 年龄

    • 性别

    • 职业

    • 邮政编码

  • u.item:关于用户评分的电影信息。数据集的结构如下所示,从随数据集提供的 README 文件中复制而来:

    • 电影 ID

    • 电影标题

    • 发行日期

    • 视频发行日期

    • IMDb 链接

    • 未知类型

    • 动作片

    • 冒险片

    • 动画片

    • 儿童片

    • 喜剧片

    • 犯罪片

    • 纪录片

    • 剧情片

    • 奇幻片

    • 黑色电影

    • 恐怖片

    • 音乐片

    • 悬疑片

    • 爱情片

    • 科幻片

    • 惊悚片

    • 战争片

    • 西部片

数据分析用例

以下列表捕捉了数据分析用例的高级细节。大多数用例都围绕创建各种图表和图形展开:

  • 使用直方图绘制对电影进行评分的用户的年龄分布。

  • 使用与直方图相同的数据,绘制用户的年龄概率密度图。

  • 绘制年龄分布数据的摘要,以找到用户的最低年龄、第 25 百分位数、中位数、第 75 百分位数和最高年龄。

  • 在同一图上绘制多个图表或图形,以便对数据进行并排比较。

  • 创建一个条形图,捕捉对电影进行评分的用户数量最多的前 10 个职业。

  • 创建一个堆叠条形图,按职业显示对电影进行评分的男性和女性用户数量。

  • 创建一个饼图,捕捉对电影进行评分的用户数量最少的 10 个职业。

  • 创建一个圆环图,捕捉对电影进行评分的用户数量最多的前 10 个邮政编码。

  • 使用三个职业类别,创建箱线图,捕捉对电影进行评分的用户的汇总统计信息。所有三个箱线图都必须在单个图上绘制,以便进行比较。

  • 创建一个条形图,按电影类型捕捉电影数量。

  • 创建一个散点图,捕捉每年发行电影数量最多的前 10 年。

  • 创建一个散点图,捕捉每年发行电影数量最多的前 10 年。在这个图中,不是用点来表示,而是创建与该年发行电影数量成比例的圆形区域。

  • 创建一条折线图,包含两个数据集,一个数据集是过去 10 年发行的动作片数量,另一个数据集是过去 10 年发行的剧情片数量,以便进行比较。

提示

在前述所有用例中,当涉及实施时,Spark 用于处理数据并准备所需的数据集。一旦所需的已处理数据在 Spark DataFrame 中可用,它就会被收集到驱动程序中。换句话说,数据从 Spark 的分布式集合转移到本地集合,在 Python 程序中作为元组,用于制图和绘图。对于制图和绘图,Python 需要本地数据。它不能直接使用 Spark DataFrames 进行制图和绘图。

图表和图形

本节将重点介绍创建各种图表和图形,以直观地表示与前述部分描述的用例相关的 MovieLens 100K 数据集的各个方面。本章描述的图表和图形绘制过程遵循一种模式。以下是该活动模式中的重要步骤:

  1. 使用 Spark 从数据文件读取数据。

  2. 使数据在 Spark DataFrame 中可用。

  3. 使用 DataFrame API 应用必要的数据处理。

  4. 处理主要是为了仅提供制图和绘图所需的最小和必要数据。

  5. 将处理后的数据从 Spark DataFrame 传输到 Spark 驱动程序中的本地 Python 集合对象。

  6. 使用图表和绘图库,利用 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是为绘制直方图的同一数据集绘制的密度图。

在同一 Spark 的 Python REPL 中继续运行以下命令:

>>> # 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 展示了并排的直方图和箱线图。

作为同一 Python REPL 的 Spark 的延续,运行以下命令:

>>> # 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 百分位数、中位数、第 75 百分位数和最大值指示器的箱线图,如图 4所示。当在同一图形中绘制多个图表或图时,为了控制布局,请查看方法调用plt.subplot(121)。这是关于一行两列布局中图表选择的讨论,并选择了第一个。同样,plt.subplot(122)讨论了一行两列布局中的图表选择,并选择了第二个。

条形图

条形图可以以不同方式绘制。最常见的是条形垂直于X轴站立。另一种变体是条形绘制在Y轴上,条形水平排列。图 5展示了一个水平条形图。

注意

人们常常混淆直方图和条形图。重要的区别在于,直方图用于绘制连续但有限的数值,而条形图用于表示分类数据。

作为同一 Python REPL 的 Spark 的延续,运行以下命令:

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

在前一节中,创建了一个 Spark DataFrame,其中包含按用户评分电影数量排名的前 10 种职业。数据被收集到一个 Python 集合对象中,用于绘制条形图。

堆叠条形图

在前一节中绘制的条形图展示了按用户数量排名的前 10 种用户职业。但这并未提供关于该数字如何按用户性别构成的详细信息。在这种情况下,使用堆叠条形图是很好的选择,每个条形图显示按性别统计的数量。图 6展示了一个堆叠条形图。

作为同一 Python REPL 的 Spark 的延续,运行以下命令:

>>> 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。对其实施了交叉表操作,生成了另一个 Spark DataFrame,该 DataFrame 包含了职业、男性用户数和女性用户数列。在最初的 Spark DataFrame 中,职业和性别列均为非数值列,因此基于这些数据绘制图表或图形并无意义。但若对这两列的值进行交叉表操作,针对每个不同的职业字段,性别列的值计数将得以呈现。如此一来,职业字段便成为了一个分类变量,此时绘制条形图便合乎逻辑。鉴于数据中仅有两种性别值,采用堆叠条形图既能显示总数,又能展示各职业类别中男女用户数的比例,显得合情合理。

在 Spark DataFrame 中,有许多统计和数学函数可供使用。在这种情境下,交叉表操作显得尤为便捷。对于庞大的数据集,交叉表操作可能会非常耗费处理器资源和时间,但 Spark 的分布式处理能力在此类情况下提供了极大的帮助。

Spark SQL 具备丰富的数学和统计数据处理功能。前述部分使用了SparkDataFrame对象上的describe().show()方法。在这些 Spark DataFrames 中,该方法作用于现有的数值列。在存在多个数值列的情况下,该方法能够选择所需的列以获取汇总统计信息。同样,也有方法可以计算来自 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展示了一个饼图。

在同一 Python REPL 的 Spark 会话中,执行以下命令:

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

在前述部分中,创建了一个 Spark DataFrame,其中包含了用户按评分电影数量排名的底部 10 种职业。数据被收集到一个 Python 集合对象中,以便绘制饼图。

环形图

饼图可以有多种绘制形式。其中一种形式,即环形图,近年来颇受欢迎。图 8展示了这种饼图的环形图变体。

在同一 Python REPL 的 Spark 会话中,执行以下命令:

>>> 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展示了单个图表上的多个箱形图。

在同一 Python REPL 的 Spark 中继续,运行以下命令:

>>> 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展示了在电影数据上应用聚合操作后的条形图。

在同一 Python REPL 的 Spark 中继续,运行以下命令:

>>> 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所示,使用了具有统一面积和随机颜色的小圆圈来表示这些值。当采用这些直观且巧妙的技术在散点图中表示点时,必须确保它不会违背散点图的初衷,也不会失去散点图传达数据行为所提供的简洁性。简单而优雅的形状,不会使笛卡尔空间显得杂乱,是这种非点表示值的理想选择。

在同一 Python REPL 中继续使用 Spark,运行以下命令:

>>> 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来收集按电影发布数量排名的前 10 年,并将这些值收集到一个 Python 集合对象中,并绘制了一个散点图。

增强型散点图

图 11是一个非常简单而优雅的散点图,但它并没有真正传达出与同一空间中其他值相比,给定绘制值的比较行为。为此,与其将点表示为固定半径的圆,不如将点绘制为面积与值成比例的圆,这将提供一个不同的视角。图 12 将展示具有相同数据但用面积与值成比例的圆来表示点的散点图。

在同一 Python REPL 中继续使用 Spark,运行以下命令:

>>> # 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是一个包含多个数据集的折线图。

作为同一 Python REPL 会话中 Spark 的延续,运行以下命令:

>>> 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 数据处理应用程序中 DStreams 是如何产生的。

微批处理数据处理

图 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。在使用 DStreams 上的任何方法之前,应查阅 Spark Streaming 编程指南。Spark Streaming 编程指南在 Python API 与其 Scala 或 Java 对应部分存在差异的地方,用特殊标注包含文本Python API

假设在 Spark Streaming 数据处理应用程序的给定批次间隔内,生成一个包含多个 RDD 的 DStream。当对该 DStream 应用过滤方法时,以下是其如何转换为底层 RDDs 的过程。图 3显示了对包含两个 RDD 的 DStream 应用过滤转换,由于过滤条件,结果生成仅包含一个 RDD 的另一个 DStream。

使用 DStreams 进行编程

图 3

日志事件处理器

如今,许多企业普遍拥有一个中央应用程序日志事件存储库。此外,这些日志事件被实时流式传输到数据处理应用程序,以便实时监控运行应用程序的性能,从而及时采取补救措施。本节将讨论这样一个用例,以展示使用 Spark Streaming 数据处理应用程序对日志事件进行实时处理。在此用例中,实时应用程序日志事件被写入 TCP 套接字。Spark Streaming 数据处理应用程序持续监听给定主机上的特定端口,以收集日志事件流。

准备 Netcat 服务器

这里使用大多数 UNIX 安装附带的 Netcat 实用程序作为数据服务器。为了确保系统中安装了 Netcat,请按照以下脚本中的手动命令操作,退出后运行它,并确保没有错误消息。一旦服务器启动并运行,在 Netcat 服务器控制台的标准输入中输入的内容将被视为应用程序日志事件,以简化演示目的。从终端提示符运行的以下命令将在 localhost 端口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 中讨论的任何特性缺乏支持,也会记录下来。

图 4所示,Scala 和 Python 代码的组织方式。为了编译、打包和运行代码,使用了 Bash 脚本,以便读者可以轻松运行它们以产生一致的结果。这些脚本文件的内容在此讨论。

文件组织

在下面的文件夹树中,projecttarget文件夹在运行时创建。本书附带的源代码可以直接复制到系统中方便的文件夹中:

文件组织

图 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 向 Spark 集群提交 Python 作业的 Bash 脚本。
compile.sh 编译 Scala 代码的 Bash 脚本。
submit.sh 向 Spark 集群提交 Scala 作业的 Bash 脚本。
config.sbt sbt 配置文件。
*.scala Scala 中的 Spark Streaming 数据处理应用程序代码。
*.py Python 中的 Spark Streaming 数据处理应用程序代码。
*.jar 需要下载并放置在lib目录下的 Spark Streaming 和 Kafka 集成 JAR 文件,以确保应用程序正常运行。这在submit.shsubmitPy.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 编程模型中的建议进行了所有配置更改,以启用 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

通过访问http://localhost:8080/确保 Spark Web UI 已启动并运行。

在 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 秒)创建 DStreams,其中包含在 Netcat 控制台中输入的行。

  • 接下来对 DStream 应用过滤转换,只包含包含单词ERROR的行。过滤转换创建仅包含过滤行的新 DStreams。

  • 下一行将 DStream 内容打印到控制台。换句话说,对于每个批处理间隔,如果存在包含单词ERROR的行,则会在控制台上显示。

  • 在此数据处理逻辑结束时,给定的StreamingContext启动并运行,直到被终止。

在前面的代码片段中,没有循环结构告诉应用程序重复直到运行应用程序被终止。这是由 Spark Streaming 库本身实现的。从数据处理应用程序开始到终止,所有语句都运行一次。对 DStreams 的所有操作都会重复(内部)每个批次。如果仔细检查前一个应用程序的输出,尽管这些语句位于StreamingContext的初始化和终止之间,但只能在控制台上看到 println()语句的输出一次。这是因为魔法循环仅对包含原始和派生 DStreams 的语句重复。

由于 Spark Streaming 应用程序中实现的循环的特殊性,在应用程序代码的流逻辑中给出打印语句和日志语句是徒劳的,就像代码片段中给出的那样。如果必须这样做,那么这些日志语句应该在传递给 DStreams 进行转换和操作的函数中进行设置。

提示

如果需要对处理后的数据进行持久化,DStreams 提供了多种输出操作,就像 RDDs 一样。

编译和运行应用程序

以下命令在终端窗口中运行以编译和运行应用程序。可以使用简单的 sbt 编译命令,而不是使用./compile.sh

注意

请注意,如前所述,在执行这些命令之前,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 Web UI(http://localhost:8080/)已启用,图 5 和图 6 显示了 Spark 应用程序和统计信息。

从主页(访问 URL http://localhost:8080/后),点击正在运行的 Spark Streaming 数据处理应用程序的名称链接,以调出常规监控页面。从该页面,点击Streaming标签,以显示包含流统计信息的页面。

需要点击的链接和标签以红色圆圈标出:

处理输出

图 5

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

处理输出

图 6

这些 Spark 网页界面提供了大量的应用程序统计信息,深入探索它们有助于更深入地理解提交的 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 主节点。如果启用了监控,并且提交指向了正确的 Spark 主节点,则相同的 Spark 网页界面也将捕获 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,计数操作针对在时间t0t1t2生成的 DStreams 执行。在时间t3,由于滑动窗口保持与批处理间隔相同,计数操作再次执行,这次针对在时间t1t2t3生成的 DStreams 进行事件计数。在时间t4,计数操作再次执行,针对在时间t2t3t4生成的 DStreams 进行事件计数。操作以此类推,直到应用程序终止。

在 Scala 中统计已处理的日志事件消息数量

在前述部分,讨论了日志事件消息的处理。在同一应用程序代码中,在打印包含单词ERROR的日志事件消息之后,在 Scala 应用程序中包含以下代码行:

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 和其他基于文件系统的存储写入数据是可行的。但涉及到流输出存储时,应根据用例选择理想的流数据存储机制。

NoSQL 数据存储如 Cassandra 支持快速写入时间序列数据。它也非常适合读取存储的数据以供进一步分析。Spark Streaming 库支持 DStreams 上的多种输出方法。它们包括将流数据保存为文本文件、对象文件、Hadoop 文件等的选项。此外,还有许多第三方驱动程序可用于将数据保存到各种数据存储中。

Kafka 流处理

本章介绍的日志事件处理器示例正在监听 TCP 套接字,以接收 Spark Streaming 数据处理应用程序将要处理的消息流。但在现实世界的用例中,情况并非如此。

具有发布-订阅功能的消息队列系统通常用于处理消息。传统的消息队列系统因每秒需要处理大量消息以满足大规模数据处理应用的需求而表现不佳。

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 的使用。这里,Spark Streaming 数据处理应用程序将作为 Kafka 主题的消费者,而发布到该主题的消息将被消费。

Spark Streaming 数据处理应用程序使用 Kafka 作为消息代理的 0.8.2.2 版本,假设读者已经至少在独立模式下安装了 Kafka。以下活动是为了确保 Kafka 准备好处理生产者产生的消息,并且 Spark Streaming 数据处理应用程序可以消费这些消息:

  1. 启动随 Kafka 安装一起提供的 Zookeeper。

  2. 启动 Kafka 服务器。

  3. 为生产者创建一个主题以发送消息。

  4. 选择一个 Kafka 生产者,开始向新创建的主题发布日志事件消息。

  5. 使用 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-console-producer时,Kafka 主题作为命令行参数给出。

提交消费由 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.shsubmitPy.sh脚本中,这些脚本将应用程序提交给 Spark 集群。该 jar 文件的下载 URL 在本章的参考部分给出。

submit.shsubmitPy.sh文件中,最后几行包含一个条件语句,查找第二个参数值为 1 以识别此应用程序,并将所需的 jar 文件发送到 Spark 集群。

提示

与其在提交作业时单独将这些 jar 文件发送到 Spark 集群,不如使用 sbt 创建的程序集 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。此方法的编程构造在用于数据处理的 API 中略有不同。请查阅相关参考资料以获取详细信息。

前述章节介绍了 Spark Streaming 库的概念,并讨论了一些实际应用案例。从部署角度来看,开发用于处理静态批处理数据的 Spark 数据处理应用程序与开发用于处理动态流数据的应用程序之间存在很大差异。处理数据流的数据处理应用程序的可用性必须持续不断。换句话说,此类应用程序不应具有单点故障组件。下一节将讨论此主题。

Spark Streaming 作业在生产环境中

当 Spark Streaming 应用程序处理传入数据时,确保数据处理不间断至关重要,以便所有正在摄取的数据都能得到处理。在关键业务流应用程序中,大多数情况下,即使遗漏一条数据也可能产生巨大的业务影响。为应对这种情况,避免应用程序基础设施中的单点故障至关重要。

从 Spark Streaming 应用程序的角度来看,了解生态系统中底层组件的布局是有益的,以便采取适当措施避免单点故障。

部署在 Hadoop YARN、Mesos 或 Spark 独立模式等集群中的 Spark Streaming 应用程序,与其他类型的 Spark 应用程序一样,主要包含两个相似的组件:

  • Spark 驱动程序:包含用户编写的应用程序代码

  • 执行器:执行由 Spark 驱动程序提交的作业的执行器

但执行器有一个额外的组件,称为接收器,它接收作为流输入的数据并将其保存为内存中的数据块。当一个接收器正在接收数据并形成数据块时,它们会被复制到另一个执行器以实现容错。换句话说,数据块的内存复制是在不同的执行器上完成的。在每个批处理间隔结束时,这些数据块被合并以形成 DStream,并发送出去进行下游进一步处理。

图 8描绘了在集群中部署的 Spark Streaming 应用基础设施中协同工作的组件:

生产环境中的 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

以下代码片段给出了一个函数的定义,该函数可与StreamingContextgetOrCreate工厂方法一起使用。如前所述,这些方面的详细讨论超出了本书的范围:

	 /** 
  * 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 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 SQL 等 Spark 库。Spark 机器学习库通过在统一的框架中结合数据处理和机器学习算法实现,使得机器学习应用开发变得简单,该框架能够在集群节点上进行数据处理,并能够读写各种数据格式。

Spark 提供了两种机器学习库:spark.mllibspark.ml。前者基于 Spark 的 RDD 抽象开发,后者基于 Spark 的 DataFrame 抽象开发。建议在未来的机器学习应用开发中使用 spark.ml 库。

本章将专注于 spark.ml 机器学习库。以下列表解释了本章中反复使用的术语和概念:

  • 估计器:这是一种算法,它作用于包含特征和标签的 Spark DataFrame 之上。它对 Spark DataFrame 中提供的数据进行训练,并创建一个模型。该模型用于未来的预测。

  • 转换器:它将包含特征的 Spark DataFrame 转换为包含预测的另一个 Spark DataFrame。由 Estimator 创建的模型就是一个 Transformer。

  • 参数:这是供 Estimators 和 Transformers 使用的。通常,它特定于机器学习算法。Spark 机器学习库提供了一个统一的 API,用于为算法指定正确的参数。

  • 流水线:这是一系列 Estimators 和 Transformers 协同工作,形成机器学习工作流程。

从理论角度看,这些新术语略显晦涩,但若辅以实例,概念便会清晰许多。

葡萄酒质量预测

加州大学欧文分校机器学习资料库(archive.ics.uci.edu/ml/index.html)为对机器学习感兴趣的人提供了大量数据集。葡萄酒质量数据集(archive.ics.uci.edu/ml/datasets/Wine+Quality)在此用于展示一些机器学习应用。它包含两个数据集,分别描述了葡萄牙白葡萄酒和红葡萄酒的各种特征。

注意

葡萄酒质量数据集下载链接允许您下载红葡萄酒和白葡萄酒的两个单独 CSV 文件。下载这些文件后,编辑两个数据集以删除包含列名的第一行标题。这是为了让程序无误地解析数值数据。为了专注于机器学习功能,故意避免了详细的错误处理和排除标题记录。

本案例中用于葡萄酒质量预测的数据集包含了红葡萄酒的各种特征。以下是数据集的特征:

  • 固定酸度

  • 挥发性酸度

  • 柠檬酸

  • 残余糖分

  • 氯化物

  • 游离二氧化硫

  • 总二氧化硫

  • 密度

  • pH

  • 硫酸盐

  • 酒精

基于这些特征,确定质量(分数介于 0 和 10 之间)。在这里,质量是此数据集的标签。使用此数据集,将训练一个模型,然后使用训练好的模型进行测试并做出预测。这是一个回归问题。使用线性回归算法来训练模型。线性回归算法生成一个线性假设函数。在数学术语中,线性函数是一次或更低次的多项式。在这个机器学习应用案例中,它涉及建模因变量(葡萄酒质量)和一组自变量(葡萄酒的特征)之间的关系。

在 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.

上述代码做了很多事情。它在管道中执行以下一系列活动:

  1. 它从数据文件读取葡萄酒数据以形成训练 DataFrame。

  2. 然后创建一个LinearRegression对象并设置参数。

  3. 它使用训练数据拟合模型,从而完成了估计器管道。

  4. 它创建了一个包含测试数据的 DataFrame。通常,测试数据将同时包含特征和标签。这是为了确保模型的正确性,并用于比较预测标签和实际标签。

  5. 使用创建的模型,它对测试数据进行转换,并从生成的 DataFrame 中提取特征、输入标签和预测结果。注意,在使用模型进行转换时,不需要标签。换句话说,标签将完全不被使用。

  6. 使用创建的模型,它对预测数据进行转换,并从生成的 DataFrame 中提取特征和预测结果。注意,在使用模型进行转换时,不使用标签。换句话说,在进行预测时,不使用标签。这完成了转换器管道。

提示

上述代码片段中的管道是单阶段管道,因此无需使用 Pipeline 对象。多阶段管道将在后续部分讨论。

在实际应用中,拟合/测试阶段会迭代重复,直到模型在进行预测时给出期望的结果。图 2 通过代码阐明了演示的管道概念:

![葡萄酒质量预测

图 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 REPL 提示符下,尝试以下语句:

 >>> 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)为那些对机器学习感兴趣的人提供了大量数据集。Twenty Newsgroups 数据集(archive.ics.uci.edu/ml/datasets/Twenty+Newsgroups)被用于在上下文中查找单词的同义词。它包含一个由 20 个新闻组中的 20,000 条消息组成的数据集。

注意

二十个新闻组数据集下载链接允许您下载此处讨论的数据集。文件 20_newsgroups.tar.gz 需要下载并解压缩。以下代码片段中使用的数据目录应指向数据以解压缩形式可用的目录。如果 Spark 驱动程序因数据量巨大而出现内存不足错误,请删除一些不感兴趣的新闻组数据,并对数据子集进行实验。在这里,为了训练模型,仅使用了以下新闻组数据: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 SQL)的所有核心特性,并拥有丰富的机器学习算法集合。本章涵盖了一些常见的预测和分类用例,使用 Scala 和 Python 通过 Spark 机器学习库实现,仅用几行代码。这些葡萄酒质量预测、葡萄酒分类、垃圾邮件过滤器和同义词查找器等机器学习用例具有巨大的潜力,可以发展成为完整的现实世界应用。Spark 2.0 通过启用模型和管道持久化,为模型创建、管道创建及其在不同语言编写的不同程序中的使用带来了灵活性。

成对关系在现实世界的用例中非常普遍。基于强大的数学理论基础,计算机科学家们开发了多种数据结构及其配套算法,这些都属于图论的研究范畴。这些数据结构和算法在社交网络网站、调度问题以及许多其他应用中具有广泛的应用价值。图处理计算量巨大,而分布式数据处理范式如 Spark 非常适合进行此类计算。建立在 Spark 之上的 Spark GraphX 库是一套图处理 API 集合。下一章将探讨 Spark GraphX。

第八章:Spark 图处理

图是数学概念,也是计算机科学中的数据结构。它在许多现实世界的应用场景中有着广泛的应用。图用于建模实体之间的成对关系。这里的实体称为顶点,两个顶点通过一条边相连。图由一组顶点和连接它们的边组成。

从概念上讲,这是一种看似简单的抽象,但当涉及到处理大量顶点和边时,它计算密集,消耗大量处理时间和计算资源。以下是一个具有四个顶点和三条边的图的表示:

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

在现实世界的用例中,图的顶点和边代表了现实世界的实体。这些实体具有属性。例如,在社交网络应用的用户社交连接图中,用户构成顶点,并拥有诸如姓名、电子邮件、电话号码等属性。同样,用户之间的关系构成图的边,连接用户顶点的边可以具有如关系等属性。任何图处理应用库都应足够灵活,以便为图的顶点和边附加任何类型的属性。

火花图 X 库

在开源世界中,有许多用于图处理的库,如 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。

Spark GraphX 库

图 3

GraphX 概览

在任何现实世界的用例中,理解由顶点和边组成的图的概念很容易。但当涉及到实现时,即使是优秀的设计师和程序员也不太了解这种数据结构。原因很简单:与其他无处不在的数据结构(如列表、集合、映射、队列等)不同,图在大多数应用程序中并不常用。考虑到这一点,概念被逐步引入,一步一个脚印,通过简单和微不足道的例子,然后才涉及一些现实世界的用例。

Spark GraphX 库最重要的方面是一种数据类型,Graph,它扩展了 Spark 弹性分布式数据集RDD)并引入了一种新的图抽象。Spark GraphX 中的图抽象是有向多图,其所有顶点和边都附有属性。这些顶点和边的每个属性可以是 Scala 类型系统支持的用户定义类型。这些类型在 Graph 类型中参数化。给定的图可能需要为顶点或边使用不同的数据类型。这是通过使用继承层次结构相关的类型系统实现的。除了所有这些基本规则外,该库还包括一组图构建器和算法。

图中的一个顶点由一个唯一的 64 位长标识符 org.apache.spark.graphx.VertexId 标识。除了 VertexId 类型,简单的 Scala 类型 Long 也可以使用。此外,顶点可以采用任何类型作为属性。图中的边应具有源顶点标识符、目标顶点标识符和任何类型的属性。

图 4 展示了一个图,其顶点属性为字符串类型,边属性也为字符串类型。除了属性外,每个顶点都有一个唯一标识符,每条边都有源顶点编号和目标顶点编号。

GraphX 概览

图 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 实现,因此固有的不可变性、分区、分布和并行处理的开箱即用特性使得图处理高度可扩展。最后,以下表格展示了顶点和边的表示方式:

顶点表

顶点 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,其中包含顶点详细信息以及每个顶点可用的分区。

图 5对此进行了解释:

图分区

图 5

图 5中,假设边被划分为分区 1 和 2。同样假设顶点被划分为分区 1 和 2。

在分区 1 中,所有边所需的顶点都可在本地获取。但在分区 2 中,只有一个边的顶点可在本地获取。因此,缺失的顶点也存储在分区 2 中,以便所有所需的顶点都可在本地获取。

为了跟踪复制情况,顶点路由 RDD 维护了给定顶点可用的分区编号。在图 5中,在顶点路由 RDD 中,使用标注符号来显示这些顶点被复制的分区。这样,在处理边或三元组时,所有与组成顶点相关的信息都可在本地获取,性能将高度优化。由于 RDD 是不可变的,即使它们存储在多个分区中,与信息更改相关的问题也被消除。

图处理

向用户展示的图的组成元素是顶点 RDD 和边 RDD。就像任何其他数据结构一样,由于底层数据的变化,图也会经历许多变化。为了使所需的图操作支持各种用例,有许多算法可用,使用这些算法可以处理图数据结构中隐藏的数据,以产生所需的业务成果。在深入了解处理图的算法之前,了解一些使用航空旅行用例的图处理基础知识是很有帮助的。

假设有人试图寻找从曼彻斯特到班加罗尔的廉价返程机票。在旅行偏好中,此人提到他/她不在乎中转次数,但价格应为最低。假设机票预订系统为往返旅程选择了相同的中转站,并生成了以下具有最低价格的路线或航段:

曼彻斯特 → 伦敦 → 科伦坡 → 班加罗尔

班加罗尔 → 科伦坡 → 伦敦 → 曼彻斯特

该路线规划是一个图的完美示例。如果将前行旅程视为一个图,将返程视为另一个图,那么返程图可以通过反转前行旅程图来生成。在 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 进行了映射转换。类似地,过滤转换是另一种常用的有用类型。除了这些,所有转换和操作都可以用于处理顶点和边 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 世界巡回赛单打比赛的结果。顶点包含球员详情,边包含个人比赛记录。边的形成方式是,源顶点是赢得比赛的球员,目标顶点是输掉比赛的球员。边属性包含比赛类型、赢家在比赛中获得的分数以及球员之间的交锋次数。这里使用的积分系统是虚构的,仅仅是赢家在那场比赛中获得的权重。小组赛初赛权重最低,半决赛权重更高,决赛权重最高。通过这种方式模拟结果,处理图表以找出以下详细信息:

  • 列出所有比赛详情。

  • 列出所有比赛,包括球员姓名、比赛类型和结果。

  • 列出所有小组 1 的获胜者及其比赛中的积分。

  • 列出所有小组 2 的获胜者及其比赛中的积分。

  • 列出所有半决赛获胜者及其比赛中的积分。

  • 列出决赛获胜者及其比赛中的积分。

  • 列出球员在整个锦标赛中获得的总积分。

  • 通过找出得分最高的球员来列出比赛获胜者。

  • 在小组赛阶段,由于循环赛制,同一组球员可能会多次相遇。查找是否有任何球员在这场锦标赛中相互比赛超过一次。

  • 列出至少赢得一场比赛的球员。

  • 列出至少输掉一场比赛的球员。

  • 列出至少赢得一场比赛且至少输掉一场比赛的球员。

  • 列出完全没有获胜的球员。

  • 列出完全没有输掉比赛的球员。

对于不熟悉网球比赛的人来说,无需担心,因为这里不讨论比赛规则,也不需要理解这个用例。实际上,我们只将其视为两人之间的比赛,其中一人获胜,另一人输掉。在 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 算法

由 Sergey Brin 和 Lawrence Page 撰写的研究论文,题为The Anatomy of a Large-Scale Hypertextual Web Search Engine,彻底改变了网络搜索,Google 基于这一 PageRank 概念构建了其搜索引擎,并主导了其他网络搜索引擎。

使用 Google 搜索网页时,其算法排名高的页面会被显示。在图形的上下文中,如果基于相同的算法对顶点进行排名,可以得出许多新的推断。从表面上看,这个 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 所示的图中,很明显可以看出存在两个连通分量。可以轻松判断 Thomas 和 Mathew 相连,而 Thomas 和 Martin 不相连。如果提取连通分量图,可以看到 Thomas 和 Martin 将具有相同的连通分量标识符,同时 Thomas 和 Martin 将具有不同的连通分量标识符。在 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 构建的,因此所有可以在 DataFrames 上执行的操作都可能适用于 GraphFrames,支持 Scala、Java、Python 和 R 等编程语言,并具有统一的 API。由于 GraphFrames 基于 DataFrames,因此数据的持久性、对多种数据源的支持以及在 Spark SQL 中强大的图查询功能是用户免费获得的额外好处。

与 Spark GraphX 库类似,在 GraphFrames 中,数据存储在顶点和边中。顶点和边使用 DataFrames 作为数据结构。本章开头介绍的第一个用例再次用于阐明基于 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 创建 DataFrames 时,唯一需要注意的是,对于顶点和边有一些强制性列。在顶点的 DataFrame 中,id 列是强制性的。在边的 DataFrame 中,src 和 dst 列是强制性的。除此之外,可以在 GraphFrame 的顶点和边上存储任意数量的任意列。在 Spark GraphX 库中,顶点标识符必须是长整型,但 GraphFrame 没有这样的限制,任何类型都可以作为顶点标识符。读者应该已经熟悉 DataFrames;任何可以在 DataFrame 上执行的操作都可以在 GraphFrame 的顶点和边上执行。

提示

所有 Spark GraphX 支持的图处理算法,GraphFrames 也同样支持。

GraphFrames 的 Python 版本功能较少。由于 Python 不是 Spark GraphX 库支持的编程语言,因此在 Python 中不支持 GraphFrame 与 GraphX 之间的转换。鉴于读者熟悉使用 Python 在 Spark 中创建 DataFrames,此处省略了 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 库进行了快速概览,并介绍了一些基础知识。

基于 DataFrame 的图抽象名为 GraphFrames,它是 Spark 的一个外部包,可单独获取,在图处理和图查询方面具有巨大潜力。为了进行图查询以发现图中的模式,已提供了对该外部 Spark 包的简要介绍。

任何教授新技术的书籍都应以一个涵盖其显著特点的应用案例作为结尾。Spark 也不例外。到目前为止,本书已经介绍了 Spark 作为下一代数据处理平台的特性。现在是时候收尾并构建一个端到端应用了。下一章将涵盖使用 Spark 及其上层构建的库家族设计和开发数据处理应用的内容。

第九章:设计 Spark 应用程序

从功能性角度思考。设想一种应用功能,它被设计成一个管道,每个部分通过管道连接,共同完成手头工作的某一部分。这一切都关乎数据处理,而 Spark 正是以高度灵活的方式进行数据处理的。数据处理始于进入处理管道的种子数据。种子数据可以是系统摄取的新数据片段,也可以是企业数据存储中的某种主数据集,需要对其进行切片和切块以生成不同的视图,以满足各种目的和业务需求。在设计和开发数据处理应用程序时,这种切片和切块将成为常态。

任何应用程序开发实践都始于对领域的研究、业务需求的分析以及技术工具的选择。在这里也不例外。尽管本章将探讨 Spark 应用程序的设计和开发,但最初的焦点将放在数据处理应用程序的整体架构、用例、数据以及将数据从一种状态转换为另一种状态的应用程序上。Spark 只是一个驱动程序,它利用其强大的基础设施将数据处理逻辑和数据组合在一起,以产生期望的结果。

本章我们将涵盖以下主题:

  • Lambda 架构

  • 使用 Spark 进行微博

  • 数据字典

  • 编码风格

  • 数据摄取

Lambda 架构

应用程序架构对于任何类型的软件开发都至关重要。它是决定软件如何构建的蓝图,具有一定程度的通用性,并在需要时具备定制能力。对于常见的应用需求,已有一些流行的架构可供选择,无需从头开始构建架构。这些公共架构框架由一些顶尖人才设计,旨在惠及大众。这些流行的架构非常有用,因为它们没有准入门槛,并被许多人使用。对于 Web 应用程序开发、数据处理等,都有流行的架构可供选择。

Lambda 架构是一种新兴且流行的架构,非常适合开发数据处理应用程序。市场上有许多工具和技术可用于开发数据处理应用程序。但无论采用何种技术,数据处理应用程序组件的分层和组合方式都由架构框架驱动。这就是为什么 Lambda 架构是一种与技术无关的架构框架,根据需要,可以选择适当的技术来开发各个组件。图 1捕捉了 Lambda 架构的精髓:

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中,用户U1U3位于关注U4的用户列表中。

  • 被关注用户:此视图包含被特定用户关注的用户列表。在图 2中,用户U2U4位于被用户U1关注的用户列表中:

熟悉数据

图 2

简而言之,图 3给出了解决方案的 Lambda 架构视图,并详细说明了数据集及其对应的视图:

熟悉数据

图 3

设置数据字典

数据字典描述了数据、其含义以及与其他数据项的关系。对于 SfbMicroBlog 应用程序,数据字典将是一个非常简约的实现所选用例的工具。以此为基础,读者可以扩展并实现自己的数据项,并包含数据处理用例。数据字典为所有主数据集以及数据视图提供。

下表展示了用户数据集的数据项:

用户数据 类型 用途
用户 ID 长整型 用于唯一标识用户,同时也是用户关系图中的顶点标识
用户名 字符串 用于系统中用户的唯一标识
名字 字符串 用于记录用户的名
姓氏 字符串 用于记录用户的姓
邮箱 字符串 用于与用户沟通
备用邮箱 字符串 用于密码找回
主电话 字符串 用于密码找回

下表捕捉了关注者数据集的数据项:

关注者数据 类型 用途
关注者用户名 字符串 用于识别关注者身份
被关注用户名 字符串 用于识别被关注者

下表捕捉了消息数据集的数据项:

消息数据 类型 用途
用户名 字符串 用于记录发布消息的用户
消息 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 转换为 DataFrames,并使用 Spark SQL 完成所有服务需求。这样,代码将简洁且易于理解。

速度层

速度层将作为 Spark Streaming 应用程序实现,使用 Kafka 作为代理,其自己的生产者生成消息。Spark Streaming 应用程序将作为 Kafka 主题的消费者,接收正在生产的数据。正如在涵盖 Spark Streaming 的章节中所讨论的,生产者可以是 Kafka 控制台生产者或 Kafka 支持的任何其他生产者。但这里的 Spark Streaming 应用程序作为消费者,不会实现将处理过的消息持久化到文本文件的逻辑,因为这在现实世界的用例中并不常见。以这个应用程序为基础,读者可以实现自己的持久化机制。

查询

所有查询都来自速度层和服务层。由于数据以 DataFrames 的形式提供,如前所述,该用例的所有查询都是使用 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 的程序需要外部库文件依赖。为此,下载 JAR 文件的说明位于lib文件夹中的TODO.txt文件。submitPy.shsubmit.sh文件也使用了 Kafka 安装中的一些Kafka库。所有这些外部 JAR 文件依赖已在第六章,Spark 流处理中介绍。

设置源代码

图 4

理解数据摄取

Spark Streaming 应用作为监听应用,接收来自其生产者的数据。由于 Kafka 将用作消息代理,Spark Streaming 应用将成为其消费者应用,监听主题以接收生产者发送的消息。由于批处理层的母数据集包含以下数据集,因此为每个主题及其数据集分别设置 Kafka 主题是理想的。

  • 用户数据集:用户

  • 关注者数据集:关注者

  • 消息数据集:消息

图 5展示了基于 Kafka 的 Spark Streaming 应用的整体结构:

理解数据摄取

图 5

由于已在第六章,Spark 流处理中介绍了 Kafka 设置,此处仅涵盖应用代码。

以下脚本从终端窗口运行。确保$KAFKA_HOME环境变量指向 Kafka 安装目录。同时,在单独的终端窗口中启动 Zookeeper、Kafka 服务器、Kafka 生产者以及 Spark Streaming 日志事件数据处理应用非常重要。一旦按照脚本创建了必要的 Kafka 主题,相应的生产者就必须开始发送消息。在进一步操作之前,请参考已在第六章,Spark 流处理中介绍的 Kafka 设置细节。

尝试在终端窗口提示符下执行以下命令:

 $ # 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

本节提供了 Scala 代码的详细信息,该代码用于 Kafka 主题消费者应用程序,该应用程序处理 Kafka 生产者产生的消息。在运行以下代码片段之前,假设 Kafka 已启动并运行,所需的生产者正在产生消息,然后,如果运行该应用程序,它将开始消费这些消息。Scala 数据摄取程序通过将其提交到 Spark 集群来运行。从 Scala 目录开始,如图 4所示,首先编译程序,然后运行它。应查阅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 集群来运行。从 Python 目录开始,如图 4所示,运行程序。应查阅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|

      +--------+---------+--------------------+----------+ 

这些步骤完成了将所有必需数据从持久存储加载到 DataFrames 的过程。这里,数据来自文本文件。在实际应用中,数据可能来自流行的 NoSQL 数据存储、传统 RDBMS 表,或是从 HDFS 加载的 Avro 或 Parquet 序列化数据存储。

以下部分使用这些 DataFrames 创建了各种目标视图和查询:

 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|

      +---------+--------------------+----------+--------+ 

这些步骤完成了将所有必需数据从持久存储加载到 DataFrames 的过程。这里,数据来自文本文件。在实际应用中,数据可能来自流行的 NoSQL 数据存储、传统 RDBMS 表,或是从 HDFS 加载的 Avro 或 Parquet 序列化数据存储。以下部分使用这些 DataFrames 创建了各种目标视图和查询:

 >>> #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 库构建了一个图,其中所有用户作为顶点,关注关系作为边。在 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 的应用程序处理关注者关系。到目前为止所涵盖的使用案例为读者提供了广阔的空间,以添加自己的功能并增强本章讨论的应用程序用例。

posted @ 2024-05-21 12:53  绝不原创的飞龙  阅读(14)  评论(0编辑  收藏  举报