PySpark 大数据分析实用指南(一)
原文:
zh.annas-archive.org/md5/62C4D847CB664AD1379DE037B94D0AE5译者:飞龙
前言
Apache Spark 是一个开源的并行处理框架,已经存在了相当长的时间。Apache Spark 的许多用途之一是在集群计算机上进行数据分析应用程序。
本书将帮助您实施一些实用和经过验证的技术,以改进 Apache Spark 中的编程和管理方面。您不仅将学习如何使用 Spark 和 Python API 来创建高性能的大数据分析,还将发现测试、保护和并行化 Spark 作业的技术。
本书涵盖了 PySpark 的安装和设置、RDD 操作、大数据清理和整理,以及将数据聚合和总结为有用报告。您将学习如何从所有流行的数据托管平台(包括 HDFS、Hive、JSON 和 S3)获取数据,并使用 PySpark 处理大型数据集,获得实际的大数据经验。本书还将帮助您在本地机器上开发原型,然后逐步处理生产环境和大规模的混乱数据。
本书的受众
本书适用于开发人员、数据科学家、业务分析师或任何需要可靠地分析大量大规模真实世界数据的人。无论您是负责创建公司的商业智能功能,还是为机器学习模型创建出色的数据平台,或者希望使用代码放大业务影响,本书都适合您。
本书涵盖的内容
第一章《安装 Pyspark 并设置开发环境》涵盖了 PySpark 的安装,以及学习 Spark 的核心概念,包括弹性分布式数据集(RDDs)、SparkContext 和 Spark 工具,如 SparkConf 和 SparkShell。
第二章《使用 RDD 将大数据导入 Spark 环境》解释了如何使用 RDD 将大数据导入 Spark 环境,使用各种工具与修改数据进行交互,以便提取有用的见解。
第三章《使用 Spark 笔记本进行大数据清理和整理》介绍了如何在笔记本应用程序中使用 Spark,从而促进 RDD 的有效使用。
第四章《将数据聚合和总结为有用报告》描述了如何使用 map 和 reduce 函数计算平均值,执行更快的平均值计算,并使用键/值对数据点的数据透视表。
第五章《使用 MLlib 进行强大的探索性数据分析》探讨了 Spark 执行回归任务的能力,包括线性回归和 SVM 等模型。
第六章《使用 SparkSQL 为大数据添加结构》解释了如何使用 Spark SQL 模式操作数据框,并使用 Spark DSL 构建结构化数据操作的查询。
第七章《转换和操作》介绍了 Spark 转换以推迟计算,然后考虑应避免的转换。我们还将使用reduce和reduceByKey方法对数据集进行计算。
第八章《不可变设计》解释了如何使用 DataFrame 操作进行转换,以讨论高度并发环境中的不可变性。
第九章《避免洗牌和减少运营成本》涵盖了洗牌和应该使用的 Spark API 操作。然后我们将测试在 Apache Spark 中引起洗牌的操作,以了解应避免哪些操作。
第十章《以正确格式保存数据》解释了如何以正确格式保存数据,以及如何使用 Spark 的标准 API 将数据保存为纯文本。
第十一章《使用 Spark 键/值 API》,讨论了可用于键/值对的转换。我们将研究键/值对的操作,并查看键/值数据上可用的分区器。
第十二章《测试 Apache Spark 作业》更详细地讨论了在不同版本的 Spark 中测试 Apache Spark 作业。
第十三章,利用 Spark GraphX API,介绍了如何利用 Spark GraphX API。我们将对 Edge API 和 Vertex API 进行实验。
充分利用本书
本书需要一些 PySpark、Python、Java 和 Scala 的基本编程经验。
下载示例代码文件
您可以从您在www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packt.com/support并注册,以便文件直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择“支持”选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名并按照屏幕上的说明操作。
下载文件后,请确保使用最新版本的解压缩或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Big-Data-Analytics-with-PySpark。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还有其他代码包,来自我们丰富的书籍和视频目录,可在**github.com/PacktPublishing/**上找到。请查看!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781838644130_ColorImages.pdf。
使用的约定
本书中使用了许多文本约定。
CodeInText:指示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。以下是一个例子:“将下载的WebStorm-10*.dmg磁盘映像文件挂载为系统中的另一个磁盘。”
代码块设置如下:
test("Should use immutable DF API") {
import spark.sqlContext.implicits._
//given
val userData =
spark.sparkContext.makeRDD(List(
UserData("a", "1"),
UserData("b", "2"),
UserData("d", "200")
)).toDF()
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:
class ImmutableRDD extends FunSuite {
val spark: SparkContext = SparkSession
.builder().master("local[2]").getOrCreate().sparkContext
test("RDD should be immutable") {
//given
val data = spark.makeRDD(0 to 5)
任何命令行输入或输出都以以下方式编写:
total_duration/(normal_data.count())
粗体:表示一个新术语、一个重要词或屏幕上看到的词。例如,菜单或对话框中的词会以这种方式出现在文本中。以下是一个例子:“从管理面板中选择系统信息。”
警告或重要说明会出现在这样的地方。
提示和技巧会出现在这样的地方。
第一章:安装 Pyspark 并设置开发环境
在本章中,我们将介绍 Spark 并学习核心概念,如 SparkContext,以及 Spark 工具,如 SparkConf 和 Spark shell。唯一的先决条件是对基本 Python 概念的了解,并且希望从大数据中寻求洞察力。我们将学习如何使用 Spark SQL 分析和发现模式,以改进我们的业务智能。此外,您将能够通过设置 PySpark 来快速迭代解决方案。在本书结束时,您将能够使用 PySpark 处理真实的混乱数据集,从而获得实际的大数据经验。
在本章中,我们将涵盖以下主题:
-
PySpark 概述
-
在 Windows 上设置 Spark 和 PySpark
-
Spark 和 PySpark 中的核心概念
PySpark 概述
在开始安装 PySpark 之前,PySpark 是 Spark 的 Python 接口,让我们先了解一些 Spark 和 PySpark 的核心概念。Spark 是 Apache 的最新大数据工具,可以通过简单地转到spark.apache.org/找到。它是用于大规模数据处理的统一分析引擎。这意味着,如果您有大量数据,您可以将这些数据输入 Spark 以快速创建一些分析。如果我们比较 Hadoop 和 Spark 的运行时间,Spark 比 Hadoop 快一百倍以上。它非常易于使用,因为有非常好的 API 可用于与 Spark 一起使用。
Spark 平台的四个主要组件如下:
-
Spark SQL:Spark 的清理语言
-
Spark Streaming:允许您提供实时流数据
-
MLlib(机器学习):Spark 的机器学习库
-
GraphX(图形):Spark 的图形库
Spark 中的核心概念是 RDD,它类似于 pandas DataFrame,或 Python 字典或列表。这是 Spark 用来在基础设施上存储大量数据的一种方式。RDD 与存储在本地内存中的内容(如 pandas DataFrame)的关键区别在于,RDD 分布在许多机器上,但看起来像一个统一的数据集。这意味着,如果您有大量数据要并行操作,您可以将其放入 RDD 中,Spark 将为您处理并行化和数据的集群。
Spark 有三种不同的接口,如下所示:
-
Scala
-
Java
-
Python
Python 类似于 PySpark 集成,我们将很快介绍。现在,我们将从 PySpark 包中导入一些库,以帮助我们使用 Spark。我们理解 Spark 的最佳方式是查看示例,如下面的屏幕截图所示:
lines = sc.textFile("data.txt")
lineLengths = lines.map(lambda s: len(s))
totalLength = lineLengths.reduce(lambda a, b: a + b)
在上面的代码中,我们通过调用SC.textFile("data.txt")创建了一个名为lines的新变量。sc是代表我们的 Spark 集群的 Python 对象。Spark 集群是一系列存储我们的 Spark 进程的实例或云计算机。通过调用textFile构造函数并输入data.text,我们可能已经输入了一个大型文本文件,并仅使用这一行创建了一个 RDD。换句话说,我们在这里要做的是将一个大型文本文件输入到分布式集群和 Spark 中,而 Spark 会为我们处理这个集群。
在第二行和第三行,我们有一个 MapReduce 函数。在第二行,我们使用lambda函数将长度函数映射到data.text的每一行。在第三行,我们调用了一个减少函数,将所有lineLengths相加,以产生文档的总长度。虽然 Python 的lines是一个包含data.text中所有行的变量,但在幕后,Spark 实际上正在处理data.text的片段在 Spark 集群上的两个不同实例上的分布,并处理所有这些实例上的 MapReduce 计算。
Spark SQL
Spark SQL 是 Spark 平台上的四个组件之一,正如我们在本章中之前看到的。它可以用于执行 SQL 查询或从任何现有的 Hive 绝缘中读取数据,其中 Hive 也是来自 Apache 的数据库实现。Spark SQL 看起来非常类似于 MySQL 或 Postgres。以下代码片段是一个很好的例子:
#Register the DataFrame as a SQL temporary view
df.CreateOrReplaceTempView("people")
sqlDF = spark.sql("SELECT * FROM people")
sqlDF.show()
#+----+-------+
#| age| name|
#+----+-------+
#+null|Jackson|
#| 30| Martin|
#| 19| Melvin|
#+----|-------|
您需要从某个表中选择所有列,例如people,并使用 Spark 对象,您将输入一个非常标准的 SQL 语句,这将显示一个 SQL 结果,就像您从正常的 SQL 实现中所期望的那样。
现在让我们看看数据集和数据框。数据集是分布式数据集合。它是在 Spark 1.6 中添加的一个接口,提供了 RDD 的优势。另一方面,数据框对于那些使用过 pandas 或 R 的人来说非常熟悉。数据框只是一个组织成命名列的数据集,类似于关系数据库或 Python 中的数据框。数据集和数据框之间的主要区别在于数据框有列名。可以想象,这对于机器学习工作和输入到诸如 scikit-learn 之类的东西非常方便。
让我们看看如何使用数据框。以下代码片段是数据框的一个快速示例:
# spark is an existing SparkSession
df = spark.read.json("examples/src/main/resources/people.json")
# Displays the content of the DataFrame to stdout
df.show()
#+----+-------+
#| age| name|
#+----+-------+
#+null|Jackson|
#| 30| Martin|
#| 19| Melvin|
#+----|-------|
与 pandas 或 R 一样,read.json允许我们从 JSON 文件中输入一些数据,而df.show以类似于 pandas 的方式显示数据框的内容。
正如我们所知,MLlib 用于使机器学习变得可扩展和简单。MLlib 允许您执行常见的机器学习任务,例如特征化;创建管道;保存和加载算法、模型和管道;以及一些实用程序,例如线性代数、统计和数据处理。另一件事需要注意的是,Spark 和 RDD 几乎是不可分割的概念。如果您对 Spark 的主要用例是机器学习,Spark 现在实际上鼓励您使用基于数据框的 MLlib API,这对我们来说非常有益,因为我们已经熟悉 pandas,这意味着平稳过渡到 Spark。
在下一节中,我们将看到如何在 Windows 上设置 Spark,并设置 PySpark 作为接口。
在 Windows 上设置 Spark 和 PySpark
完成以下步骤,在 Windows 计算机上安装 PySpark:
-
从
github.com/bmatzelle/gow/releases/download/v0.8.0/Gow-0.8.0.exe下载Gnu on Windows(GOW)。 -
GOW 允许在 Windows 上使用 Linux 命令。我们可以使用以下命令来查看通过安装 GOW 允许的基本 Linux 命令:
gow --list
这会产生以下输出:
-
下载并安装 Anaconda。如果需要帮助,可以参考以下教程:
medium.com/@GalarnykMichael/install-python-on-windows-anaconda-c63c7c3d1444。 -
关闭先前的命令行,打开一个新的命令行。
-
转到 Apache Spark 网站(
spark.apache.org/)。 -
要下载 Spark,请从下拉菜单中选择以下内容:
-
最近的 Spark 版本
-
适当的软件包类型
以下屏幕截图显示了 Apache Spark 的下载页面:
-
然后,下载 Spark。下载完成后,将文件移动到您想要解压缩的文件夹中。
-
您可以手动解压缩,也可以使用以下命令:
gzip -d spark-2.1.0-bin-hadoop2.7.tgz tar xvf spark-2.1.0-bin-hadoop2.7.tar
- 现在,使用以下命令将
winutils.exe下载到您的spark-2.1.0-bin-hadoop2.7\bin文件夹中:
curl -k -L -o winutils.exe https://github.com/steveloughran/winutils/blob/master/hadoop-2.6.0/bin/winutils.exe?raw=true
- 确保您的计算机上已安装 Java。您可以使用以下命令查看 Java 版本:
java --version
这会产生以下输出:
- 使用以下命令检查 Python 版本:
python --version
这会产生以下输出:
- 让我们编辑我们的环境变量,这样我们可以在任何目录中打开 Spark,如下所示:
setx SPARK_HOME C:\opt\spark\spark-2.1.0-bin-hadoop2.7
setx HADOOP_HOME C:\opt\spark\spark-2.1.0-bin-hadoop2.7
setx PYSPARK_DRIVER_PYTHON ipython
setx PYSPARK_DRIVER_PYTHON_OPTS notebook
将C:\opt\spark\spark-2.1.0-bin-hadoop2.7\bin添加到你的路径中。
- 关闭终端,打开一个新的终端,并输入以下命令:
--master local[2]
PYSPARK_DRIVER_PYTHON和PYSPARK_DRIVER_PYTHON_OPTS参数用于在 Jupyter Notebook 中启动 PySpark shell。--master参数用于设置主节点地址。
- 接下来要做的是在
bin文件夹中运行 PySpark 命令:
.\bin\pyspark
这将产生以下输出:
Spark 和 PySpark 中的核心概念
现在让我们来看看 Spark 和 PySpark 中的以下核心概念:
-
SparkContext
-
SparkConf
-
Spark shell
SparkContext
SparkContext 是 Spark 中的一个对象或概念。它是一个大数据分析引擎,允许你以编程方式利用 Spark 的强大功能。
当你有大量数据无法放入本地机器或笔记本电脑时,Spark 的强大之处就显现出来了,因此你需要两台或更多计算机来处理它。在处理数据的同时,你还需要保持处理速度。我们不仅希望数据在几台计算机上进行计算,还希望计算是并行的。最后,你希望这个计算看起来像是一个单一的计算。
让我们考虑一个例子,我们有一个包含 5000 万个名字的大型联系人数据库,我们可能想从每个联系人中提取第一个名字。显然,如果每个名字都嵌入在一个更大的联系人对象中,将 5000 万个名字放入本地内存中是困难的。这就是 Spark 发挥作用的地方。Spark 允许你给它一个大数据文件,并将帮助处理和上传这个数据文件,同时为你处理在这个数据上进行的所有操作。这种能力由 Spark 的集群管理器管理,如下图所示:
集群管理器管理多个工作节点;可能有 2 个、3 个,甚至 100 个。关键是 Spark 的技术有助于管理这个工作节点集群,你需要一种方法来控制集群的行为,并在工作节点之间传递数据。
SparkContext 让你可以像使用 Python 对象一样使用 Spark 集群管理器的功能。因此,有了SparkContext,你可以传递作业和资源,安排任务,并完成从SparkContext到Spark 集群管理器的下游任务,然后Spark 集群管理器完成计算后将结果带回来。
让我们看看这在实践中是什么样子,以及如何设置 SparkContext:
-
首先,我们需要导入
SparkContext。 -
创建一个新对象,将其赋给变量
sc,代表使用SparkContext构造函数的 SparkContext。 -
在
SparkContext构造函数中,传递一个local上下文。在这种情况下,我们正在研究PySpark的实际操作,如下所示:
from pyspark import SparkContext
sc = SparkContext('local', 'hands on PySpark')
- 一旦我们建立了这一点,我们只需要使用
sc作为我们 Spark 操作的入口点,就像下面的代码片段中所演示的那样:
visitors = [10, 3, 35, 25, 41, 9, 29] df_visitors = sc.parallelize(visitors) df_visitors_yearly = df_visitors.map(lambda x: x*365).collect() print(df_visitors_yearly)
让我们举个例子;如果我们要分析我们服装店的虚拟数据集的访客数量,我们可能有一个表示每天访客数量的visitors列表。然后,我们可以创建一个 DataFrame 的并行版本,调用sc.parallelize(visitors),并输入visitors数据集。df_visitors然后为我们创建了一个访客的 DataFrame。然后,我们可以映射一个函数;例如,通过映射一个lambda函数,将每日数字(x)乘以365,即一年中的天数,将其推断为一年的数字。然后,我们调用collect()函数,以确保 Spark 执行这个lambda调用。最后,我们打印出df_visitors_yearly。现在,我们让 Spark 在幕后处理我们的虚拟数据的计算,而这只是一个 Python 操作。
Spark shell
我们将返回到我们的 Spark 文件夹,即spark-2.3.2-bin-hadoop2.7,然后通过输入.\bin\pyspark来启动我们的 PySpark 二进制文件。
我们可以看到我们已经在以下截图中启动了一个带有 Spark 的 shell 会话:
现在,Spark 对我们来说是一个spark变量。让我们在 Spark 中尝试一件简单的事情。首先要做的是加载一个随机文件。在每个 Spark 安装中,都有一个README.md的 markdown 文件,所以让我们将其加载到内存中,如下所示:
text_file = spark.read.text("README.md")
如果我们使用spark.read.text然后输入README.md,我们会得到一些警告,但目前我们不必太担心这些,因为我们将在稍后看到如何解决这些问题。这里的主要问题是我们可以使用 Python 语法来访问 Spark。
我们在这里所做的是将README.md作为spark读取的文本数据放入 Spark 中,然后我们可以使用text_file.count()来让 Spark 计算我们的文本文件中有多少个字符,如下所示:
text_file.count()
从中,我们得到以下输出:
103
我们还可以通过以下方式查看第一行是什么:
text_file.first()
我们将得到以下输出:
Row(value='# Apache Spark')
现在,我们可以通过以下方式计算包含单词Spark的行数:
lines_with_spark = text_file.filter(text_file.value.contains("Spark"))
在这里,我们使用filter()函数过滤了行,并在filter()函数内部指定了text_file_value.contains包含单词"Spark",然后将这些结果放入了lines_with_spark变量中。
我们可以修改上述命令,简单地添加.count(),如下所示:
text_file.filter(text_file.value.contains("Spark")).count()
现在我们将得到以下输出:
20
我们可以看到文本文件中有20行包含单词Spark。这只是一个简单的例子,展示了我们如何使用 Spark shell。
SparkConf
SparkConf 允许我们配置 Spark 应用程序。它将各种 Spark 参数设置为键值对,通常会使用SparkConf()构造函数创建一个SparkConf对象,然后从spark.*底层 Java 系统中加载值。
有一些有用的函数;例如,我们可以使用sets()函数来设置配置属性。我们可以使用setMaster()函数来设置要连接的主 URL。我们可以使用setAppName()函数来设置应用程序名称,并使用setSparkHome()来设置 Spark 将安装在工作节点上的路径。
您可以在spark.apache.org/docs/0.9.0/api/pyspark/pysaprk.conf.SparkConf-class.html了解更多关于 SparkConf 的信息。
摘要
在本章中,我们学习了 Spark 和 PySpark 中的核心概念。我们学习了在 Windows 上设置 Spark 和使用 PySpark。我们还介绍了 Spark 的三大支柱,即 SparkContext、Spark shell 和 SparkConf。
在下一章中,我们将学习如何使用 RDD 将大数据导入 Spark 环境。
第二章:使用 RDD 将大数据导入 Spark 环境
主要是,本章将简要介绍如何使用弹性分布式数据集(RDDs)将大数据导入 Spark 环境。我们将使用各种工具来与和修改这些数据,以便提取有用的见解。我们将首先将数据加载到 Spark RDD 中,然后使用 Spark RDD 进行并行化。
在本章中,我们将涵盖以下主题:
-
将数据加载到 Spark RDD 中
-
使用 Spark RDD 进行并行化
-
RDD 操作的基础知识
将数据加载到 Spark RDD 中
在本节中,我们将看看如何将数据加载到 Spark RDD 中,并将涵盖以下主题:
-
UCI 机器学习数据库
-
从存储库将数据导入 Python
-
将数据导入 Spark
让我们首先概述一下 UCI 机器学习数据库。
UCI 机器学习库
我们可以通过导航到archive.ics.uci.edu/ml/来访问 UCI 机器学习库。那么,UCI 机器学习库是什么?UCI 代表加州大学尔湾分校机器学习库,它是一个非常有用的资源,可以获取用于机器学习的开源和免费数据集。尽管 PySpark 的主要问题或解决方案与机器学习无关,但我们可以利用这个机会获取帮助我们测试 PySpark 功能的大型数据集。
让我们来看一下 KDD Cup 1999 数据集,我们将下载,然后将整个数据集加载到 PySpark 中。
将数据从存储库加载到 Spark
我们可以按照以下步骤下载数据集并将其加载到 PySpark 中:
-
点击数据文件夹。
-
您将被重定向到一个包含各种文件的文件夹,如下所示:
您可以看到有 kddcup.data.gz,还有 kddcup.data_10_percent.gz 中的 10%数据。我们将使用食品数据集。要使用食品数据集,右键单击 kddcup.data.gz,选择复制链接地址,然后返回到 PySpark 控制台并导入数据。
让我们看看如何使用以下步骤:
- 启动 PySpark 后,我们需要做的第一件事是导入
urllib,这是一个允许我们与互联网上的资源进行交互的库,如下所示:
import urllib.request
- 接下来要做的是使用这个
request库从互联网上拉取一些资源,如下面的代码所示:
f = urllib.request.urlretrieve("https://archive.ics.uci.edu/ml/machine-learning-databases/kddcup99-mld/kddcup.data.gz"),"kddcup.data.gz"
这个命令将需要一些时间来处理。一旦文件被下载,我们可以看到 Python 已经返回,控制台是活动的。
- 接下来,使用
SparkContext加载这个。所以,在 Python 中,SparkContext被实例化或对象化为sc变量,如下所示:
sc
此输出如下面的代码片段所示:
SparkContext
Spark UI
Version
v2.3.3
Master
local[*]
AppName
PySparkShell
将数据导入 Spark
- 接下来,使用
sc将 KDD cup 数据加载到 PySpark 中,如下面的命令所示:
raw_data = sc.textFile("./kddcup.data.gz")
- 在下面的命令中,我们可以看到原始数据现在在
raw_data变量中:
raw_data
此输出如下面的代码片段所示:
./kddcup.data,gz MapPartitionsRDD[3] at textFile at NativeMethodAccessorImpl.java:0
如果我们输入raw_data变量,它会给我们关于kddcup.data.gz的详细信息,其中包含数据文件的原始数据,并告诉我们关于MapPartitionsRDD。
现在我们知道如何将数据加载到 Spark 中,让我们学习一下如何使用 Spark RDD 进行并行化。
使用 Spark RDD 进行并行化
现在我们知道如何在从互联网接收的文本文件中创建 RDD,我们可以看一种不同的创建这个 RDD 的方法。让我们讨论一下如何使用我们的 Spark RDD 进行并行化。
在这一部分,我们将涵盖以下主题:
-
什么是并行化?
-
我们如何将 Spark RDD 并行化?
让我们从并行化开始。
什么是并行化?
了解 Spark 或任何语言的最佳方法是查看文档。如果我们查看 Spark 的文档,它清楚地说明,对于我们上次使用的textFile函数,它从 HDFS 读取文本文件。
另一方面,如果我们看一下parallelize的定义,我们可以看到这是通过分发本地 Scala 集合来创建 RDD。
使用parallelize创建 RDD 和使用textFile创建 RDD 之间的主要区别在于数据的来源。
让我们看看这是如何实际工作的。让我们回到之前离开的 PySpark 安装屏幕。因此,我们导入了urllib,我们使用urllib.request从互联网检索一些数据,然后我们使用SparkContext和textFile将这些数据加载到 Spark 中。另一种方法是使用parallelize。
让我们看看我们可以如何做到这一点。让我们首先假设我们的数据已经在 Python 中,因此,为了演示目的,我们将创建一个包含一百个数字的 Python 列表如下:
a = range(100)
a
这给我们以下输出:
range(0, 100)
例如,如果我们看一下a,它只是一个包含 100 个数字的列表。如果我们将其转换为list,它将显示我们的 100 个数字的列表:
list (a)
这给我们以下输出:
[0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
...
以下命令向我们展示了如何将其转换为 RDD:
list_rdd = sc.parallelize(a)
如果我们看一下list_rdd包含什么,我们可以看到它是PythonRDD.scala:52,因此,这告诉我们 Scala 支持的 PySpark 实例已经识别出这是一个由 Python 创建的 RDD,如下所示:
list_rdd
这给我们以下输出:
PythonRDD[3] at RDD at PythonRDD.scala:52
现在,让我们看看我们可以用这个列表做什么。我们可以做的第一件事是通过以下命令计算list_rdd中有多少元素:
list_rdd.count()
这给我们以下输出:
100
我们可以看到list_rdd计数为 100。如果我们再次运行它而不切入结果,我们实际上可以看到,由于 Scala 在遍历 RDD 时是实时运行的,它比只运行a的长度要慢,后者是瞬时的。
然而,RDD 需要一些时间,因为它需要时间来遍历列表的并行化版本。因此,在小规模的情况下,只有一百个数字,可能没有这种权衡非常有帮助,但是对于更大量的数据和数据元素的更大个体大小,这将更有意义。
我们还可以从列表中取任意数量的元素,如下所示:
list_rdd.take(10)
这给我们以下输出:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
当我们运行上述命令时,我们可以看到 PySpark 在返回列表的前十个元素之前进行了一些计算。请注意,所有这些现在都由 PySpark 支持,并且我们正在使用 Spark 的功能来操作这个包含 100 个项目的列表。
现在让我们在list_rdd中使用reduce函数,或者在 RDDs 中一般使用,来演示我们可以用 PySpark 的 RDDs 做什么。我们将两个参数函数应用为匿名的lambda函数到reduce调用如下:
list_rdd.reduce(lambda a, b: a+b)
在这里,lambda接受两个参数a和b。它简单地将这两个数字相加,因此a+b,并返回输出。通过RDD的reduce调用,我们可以依次将 RDD 列表的前两个数字相加,返回结果,然后将第三个数字添加到结果中,依此类推。因此,最终,通过使用reduce,您可以将所有 100 个数字添加到相同的结果中。
现在,在通过分布式数据库进行一些工作之后,我们现在可以看到,从0到99的数字相加得到4950,并且所有这些都是使用 PySpark 的 RDD 方法完成的。您可能会从 MapReduce 这个术语中认出这个函数,确实,它就是这样。
我们刚刚学习了在 PySpark 中并行化是什么,以及我们如何可以并行化 Spark RDDs。这实际上相当于我们创建 RDDs 的另一种方式,对我们非常有用。现在,让我们来看一些 RDD 操作的基础知识。
RDD 操作的基础知识
现在让我们来看一些 RDD 操作的基础知识。了解某个功能的最佳方法是查看文档,以便我们可以严格理解函数的执行方式。
这是非常重要的原因是文档是函数定义和设计用途的黄金来源。通过阅读文档,我们确保我们在理解上尽可能接近源头。相关文档的链接是spark.apache.org/docs/latest/rdd-programming-guide.html。
让我们从map函数开始。map函数通过将f函数应用于此 RDD 的每个元素来返回一个 RDD。换句话说,它的工作方式与我们在 Python 中看到的map函数相同。另一方面,filter函数返回一个仅包含满足谓词的元素的新 RDD,该谓词是一个布尔值,通常由输入filter函数的f函数返回。同样,这与 Python 中的filter函数非常相似。最后,collect()函数返回一个包含此 RDD 中所有元素的列表。这就是我认为阅读文档真正发光的地方,当我们看到这样的说明时。如果你只是在谷歌搜索这个,这种情况永远不会出现在 Stack Overflow 或博客文章中。
因此,我们说collect()只有在预期结果数组很小的情况下才应该使用,因为所有数据都加载在驱动程序的内存中。这意味着,如果我们回想一下第一章,安装 PySpark 并设置开发环境,Spark 非常出色,因为它可以在许多不同的独立机器上收集和并行化数据,并且可以从一个终端透明地操作。collect()的说明是,如果我们调用collect(),则生成的 RDD 将完全加载到驱动程序的内存中,在这种情况下,我们将失去在 Spark 实例集群中分发数据的好处。
现在我们知道了所有这些,让我们看看如何实际将这三个函数应用于我们的数据。因此,返回到 PySpark 终端;我们已经将原始数据作为文本文件加载,就像我们在之前的章节中看到的那样。
我们将编写一个filter函数来查找所有包含单词normal的行,指示 RDD 数据,如下面的屏幕截图所示:
contains_normal = raw_data.filter(lambda line: "normal." in line)
让我们分析一下这意味着什么。首先,我们正在为 RDD 原始数据调用filter函数,并且我们正在向其提供一个匿名的lambda函数,该函数接受一个line参数并返回谓词,正如我们在文档中所读到的,关于单词normal是否存在于该行中。此刻,正如我们在之前的章节中讨论的那样,我们实际上还没有计算这个filter操作。我们需要做的是调用一个实际整合数据并迫使 Spark 计算某些内容的函数。在这种情况下,我们可以依赖contains_normal,就像下面的屏幕截图中所示的那样:
您可以看到,在原始数据中,包含单词normal的行数超过了 970,000 行。要使用filter函数,我们提供了一个lambda函数,并使用一个整合函数,比如counts,来强制 Spark 计算和计算底层 DataFrame 中的数据。
对于第二个例子,我们将使用 map。由于我们下载了 KDD 杯数据,我们知道它是一个逗号分隔的值文件,因此,我们很容易做的一件事是通过两个逗号拆分每一行,如下所示:
split_file = raw_data.map(lambda line: line.split(","))
让我们分析一下发生了什么。我们在raw_data上调用map函数。我们向它提供了一个名为line的匿名lambda函数,在这个函数中,我们使用,来分割line函数。结果是一个分割文件。现在,这里真正发挥了 Spark 的力量。回想一下,在contains_normal.过滤器中,当我们调用一个强制 Spark 计算count的函数时,需要几分钟才能得出正确的结果。如果我们执行map函数,它会产生相同的效果,因为我们需要对数百万行数据进行映射。因此,快速预览我们的映射函数是否正确运行的一种方法是,我们可以将几行材料化而不是整个文件。
为了做到这一点,我们可以使用之前使用过的take函数,如下面的截图所示:
这可能需要几秒钟,因为我们只取了五行,这是我们的分割,实际上相当容易管理。如果我们查看这个样本输出,我们可以理解我们的map函数已经成功创建。我们可以做的最后一件事是在原始数据上调用collect(),如下所示:
raw_data.collect()
这旨在将 Spark 的 RDD 数据结构中的所有原始数据移动到内存中。
总结
在本章中,我们学习了如何在 Spark RDD 上加载数据,还介绍了 Spark RDD 的并行化。在加载数据之前,我们简要概述了 UCI 机器学习存储库。我们概述了基本的 RDD 操作,并检查了官方文档中的函数。
在下一章中,我们将介绍大数据清洗和数据整理。
第三章:使用 Spark 笔记本进行大数据清洗和整理
在本章中,我们将学习使用 Spark 笔记本进行大数据清洗和整理。我们还将看看在笔记本应用程序上使用 Spark 如何有效地使用 RDD。我们将使用 Spark 笔记本快速迭代想法,并进行抽样/过滤 RDD 以挑选出相关数据点。我们还将学习如何拆分数据集并使用集合操作创建新的组合。
在本章中,我们将讨论以下主题:
-
使用 Spark 笔记本快速迭代想法
-
对 RDD 进行抽样/过滤以挑选出相关数据点
-
拆分数据集并创建一些新的组合
使用 Spark 笔记本快速迭代想法
在这一部分,我们将回答以下问题:
-
什么是 Spark 笔记本?
-
如何启动 Spark 笔记本?
-
如何使用 Spark 笔记本?
让我们从为 Spark 设置类似 Jupyter Notebook 的环境开始。Spark 笔记本只是一个使用 Scala 和 Spark 的交互式和反应式数据科学环境。
如果我们查看 GitHub 页面(github.com/spark-notebook/spark-notebook),我们会发现笔记本的功能实际上非常简单,如下截图所示:
如果我们看一下 Spark 笔记本,我们会发现它们看起来非常像 Python 开发人员使用的 Jupyter 笔记本。您可以在文本框中输入一些代码,然后在文本框下方执行代码,这与笔记本格式类似。这使我们能够使用 Apache Spark 和大数据生态系统执行可重现的分析。
因此,我们可以直接使用 Spark 笔记本,我们只需要转到 Spark 笔记本网站,然后点击“快速启动”即可启动笔记本,如下截图所示:
我们需要确保我们正在运行 Java 7。我们可以看到设置步骤也在文档中提到,如下截图所示:
Spark 笔记本的主要网站是spark-notebook.io,在那里我们可以看到许多选项。以下截图显示了其中一些选项:
我们可以下载 TAR 文件并解压缩。您可以使用 Spark 笔记本,但是在本书中我们将使用 Jupyter Notebook。因此,回到 Jupyter 环境,我们可以查看 PySpark 附带的代码文件。在第三章笔记本中,我们已经包含了一个方便的方法来设置环境变量,以使 PySpark 与 Jupyter 一起工作,如下截图所示:
首先,我们需要在我们的环境中创建两个新的环境变量。如果您使用 Linux,可以使用 Bash RC。如果您使用 Windows,您只需要更改和编辑系统环境变量。有多个在线教程可以帮助您完成此操作。我们要做的是编辑或包含PYSPARK_DRIVER_PYTHON变量,并将其指向您的 Jupyter Notebook 安装位置。如果您使用 Anaconda,可能会指向 Anaconda Jupyter Bash 文件。由于我们使用的是 WinPython,我已将其指向了我的 WinPython Jupyter Notebook Bash 文件。我们要导出的第二个环境变量只是PYSPARK_DRIVER_PYTHON_OPTS。
其中一个建议是,我们在选项中包括笔记本文件夹和笔记本应用程序,要求它不要在浏览器中打开,并告诉它要绑定到哪个端口。 在实践中,如果您使用的是 Windows 和 WinPython 环境,那么您实际上不需要在这里使用这行代码,您可以直接跳过它。 完成后,只需从命令行重新启动 PySpark。 发生的情况是,与我们以前看到的控制台不同,它直接启动到 Jupyter Notebook 实例,并且我们可以像在 Jupyter Notebook 中一样使用 Spark 和 SparkContext 变量。 因此,让我们测试一下,如下所示:
sc
我们立即获得了我们的SparkContext,告诉我们 Spark 的版本是2.3.3,我们的Master是local,AppName是 Python SparkShell(PySparkShell),如下面的代码片段所示:
SparkContext
Spark UI
Version
v2.3.3
Master
local[*]
AppName
PySparkShell
因此,现在我们知道了如何在 Jupyter 中创建类似笔记本的环境。 在下一节中,我们将看一下对 RDD 进行抽样和过滤以挑选出相关数据点。
抽样/过滤 RDD 以挑选出相关数据点
在本节中,我们将查看对 RDD 进行抽样和过滤以挑选出相关数据点。 这是一个非常强大的概念,它使我们能够规避大数据的限制,并在特定样本上执行我们的计算。
现在让我们检查抽样不仅加速了我们的计算,而且还给了我们对我们试图计算的统计量的良好近似。 为此,我们首先导入time库,如下所示:
from time import time
我们接下来要做的是查看 KDD 数据库中包含单词normal的行或数据点:
raw_data = sc.textFile("./kdd.data.gz")
我们需要创建raw_data的样本。 我们将样本存储到sample变量中,我们正在从raw_data中进行无替换的抽样。 我们正在抽样数据的 10%,并且我们提供42作为我们的随机种子:
sampled = raw_data.sample(False, 0.1, 42)
接下来要做的是链接一些map和filter函数,就像我们通常处理未抽样数据集一样:
contains_normal_sample = sampled.map(lambda x: x.split(",")).filter(lambda x: "normal" in x)
接下来,我们需要计算在样本中计算行数需要多长时间:
t0 = time()
num_sampled = contains_normal_sample.count()
duration = time() - t0
我们在这里发布计数声明。 正如您从上一节中所知,这将触发 PySpark 中contains_normal_sample中定义的所有计算,并且我们记录了样本计数发生之前的时间。 我们还记录了样本计数发生后的时间,这样我们就可以看到在查看样本时需要多长时间。 一旦完成了这一点,让我们来看看以下代码片段中duration持续了多长时间:
duration
输出将如下所示:
23.724565505981445
我们花了 23 秒来运行这个操作,占数据的 10%。 现在,让我们看看如果我们在所有数据上运行相同的转换会发生什么:
contains_normal = raw_data.map(lambda x: x.split(",")).filter(lambda x: "normal" in x)
t0 = time()
num_sampled = contains_normal.count()
duration = time() - t0
让我们再次看一下duration:
duration
这将提供以下输出:
36.51565098762512
有一个小差异,因为我们正在比较36.5秒和23.7秒。 但是,随着数据集变得更加多样化,以及您处理的数据量变得更加复杂,这种差异会变得更大。 这其中的好处是,如果您通常处理大数据,使用数据的小样本验证您的答案是否合理可以帮助您更早地捕捉错误。
最后要看的是我们如何使用takeSample。 我们只需要使用以下代码:
data_in_memory = raw_data.takeSample(False, 10, 42)
正如我们之前学到的,当我们呈现新函数时,我们调用takeSample,它将给我们10个具有随机种子42的项目,现在我们将其放入内存。 现在这些数据在内存中,我们可以使用本机 Python 方法调用相同的map和filter函数,如下所示:
contains_normal_py = [line.split(",") for line in data_in_memory if "normal" in line]
len(contains_normal_py)
输出将如下所示:
1
我们现在通过将data_in_memory带入来计算我们的contains_normal函数。 这很好地说明了 PySpark 的强大之处。
我们最初抽取了 10,000 个数据点的样本,这导致了机器崩溃。 因此,在这里,我们将取这十个数据点,看看它是否包含单词normal。
我们可以看到在前一个代码块中计算已经完成,它比在 PySpark 中进行计算花费了更长的时间并且使用了更多的内存。这就是为什么我们使用 Spark,因为 Spark 允许我们并行处理任何大型数据集,并且以并行方式操作它,这意味着我们可以用更少的内存和更少的时间做更多的事情。在下一节中,我们将讨论拆分数据集并使用集合操作创建新的组合。
拆分数据集并创建一些新的组合
在本节中,我们将看看如何拆分数据集并使用集合操作创建新的组合。我们将学习特别是减法和笛卡尔积。
让我们回到我们一直在查看包含单词normal的数据集中的行的 Jupyter 笔记本的第三章。让我们尝试获取不包含单词normal的所有行。一种方法是使用filter函数查看不包含normal的行。但是,在 PySpark 中我们可以使用一些不同的东西:一个名为subtract的函数来取整个数据集并减去包含单词normal的数据。让我们看看以下片段:
normal_sample = sampled.filter(lambda line: "normal." in line)
然后我们可以通过从整个样本中减去normal样本来获得不包含单词normal的交互或数据点如下:
non_normal_sample = sampled.subtract(normal_sample)
我们取normal样本,然后从整个样本中减去它,这是整个数据集的 10%。让我们按如下方式发出一些计数:
sampled.count()
这将为我们提供以下输出:
490705
正如你所看到的,数据集的 10%给我们490705个数据点,其中有一些包含单词normal的数据点。要找出它的计数,写下以下代码:
normal_sample.count()
这将为我们提供以下输出:
97404
所以,这里有97404个数据点。如果我们计算正常样本,因为我们只是从另一个样本中减去一个样本,计数应该大约略低于 400,000 个数据点,因为我们有 490,000 个数据点减去 97,000 个数据点,这应该导致大约 390,000。让我们看看使用以下代码片段会发生什么:
non_normal_sample.count()
这将为我们提供以下输出:
393301
正如预期的那样,它返回了393301的值,这验证了我们的假设,即减去包含normal的数据点会给我们所有非正常的数据点。
现在让我们讨论另一个名为cartesian的函数。这允许我们给出两个不同特征的不同值之间的所有组合。让我们看看以下代码片段中这是如何工作的:
feature_1 = sampled.map(lambda line: line.split(",")).map(lambda features: features[1]).distinct()
在这里,我们使用,来拆分line函数。因此,我们将拆分逗号分隔的值 - 对于拆分后得到的所有特征,我们取第一个特征,并找到该列的所有不同值。我们可以重复这个过程来获取第二个特征,如下所示:
feature_2 = sampled.map(lambda line: line.split(",")).map(lambda features: features[2]).distinct()
因此,我们现在有两个特征。我们可以查看feature_1和feature_2中的实际项目,如下所示,通过发出我们之前看到的collect()调用:
f1 = feature_1.collect()
f2 = feature_2.collect()
让我们分别看一下如下:
f1
这将提供以下结果:
['tcp', 'udp', 'icmp']
所以,f1有三个值;让我们检查f2如下:
f2
这将为我们提供以下输出:
f2有更多的值,我们可以使用cartesian函数收集f1和f2之间的所有组合如下:
len(feature_1.cartesian(feature_2).collect())
这将为我们提供以下输出:
198
这是我们如何使用cartesian函数找到两个特征之间的笛卡尔积。在本章中,我们看了 Spark 笔记本;抽样、过滤和拆分数据集;以及使用集合操作创建新的组合。
摘要
在本章中,我们看了 Spark 笔记本进行快速迭代。然后我们使用抽样或过滤来挑选出相关的数据点。我们还学会了如何拆分数据集并使用集合操作创建新的组合。
在下一章中,我们将介绍将数据聚合和汇总为有用的报告。
第四章:将数据聚合和汇总为有用的报告
在本章中,我们将学习如何将数据聚合和汇总为有用的报告。我们将学习如何使用 map 和 reduce 函数计算平均值,执行更快的平均计算,并使用键值对数据点的数据透视表。
本章中,我们将涵盖以下主题:
-
使用
map和reduce计算平均值 -
使用聚合进行更快的平均计算
-
使用键值对数据点进行数据透视表
使用 map 和 reduce 计算平均值
在本节中,我们将回答以下三个主要问题:
-
我们如何计算平均值?
-
什么是 map?
-
什么是 reduce?
您可以在spark.apache.org/docs/latest/api/python/pyspark.html?highlight=map#pyspark.RDD.map上查看文档。
map 函数接受两个参数,其中一个是可选的。map 的第一个参数是 f,它是一个应用于整个 RDD 的函数。第二个参数或参数是 preservesPartitioning 参数,默认值为 False。
如果我们查看文档,它说 map 通过将函数应用于此 RDD 的每个元素来简单地返回一个新的 RDD,显然,此函数指的是我们输入到 map 函数本身的 f。文档中有一个非常简单的例子,如果我们并行化一个包含三个字符 b、a 和 c 的 rdd 方法,并且我们映射一个创建每个元素的元组的函数,那么我们将创建一个包含三个元组的列表,其中原始字符放在元组的第一个元素中,整数 1 放在第二个元素中,如下所示:
rdd = sc.paralleize(["b", "a", "c"])
sorted(rdd.map(lambda x: (x, 1)).collect())
这将给我们以下输出:
[('a', 1), ('b', 1), ('c', 1)]
reduce 函数只接受一个参数,即 f。f 是一个将列表减少为一个数字的函数。从技术角度来看,指定的可交换和可结合的二进制运算符减少了此 RDD 的元素。
让我们使用我们一直在使用的 KDD 数据来举个例子。我们启动我们的 Jupyter Notebook 实例,它链接到一个 Spark 实例,就像我们以前做过的那样。然后我们通过从本地磁盘加载 kddcup.data.gz 文本文件来创建一个 raw_data 变量,如下所示:
raw_data = sc.textFile("./kddcup.data.gz")
接下来要做的是将此文件拆分为 csv,然后我们将过滤包含单词 normal 的特征 41 的行:
csv = raw_data.map(lambda x: x.split(","))
normal_data = csv.filter(lambda x: x[41]=="normal.")
然后我们使用 map 函数将这些数据转换为整数,最后,我们可以使用 reduce 函数来计算 total_duration,然后我们可以打印 total_duration 如下:
duration = normal_data.map(lambda x: int(x[0]))
total_duration = duration.reduce(lambda x, y: x+y)
total_duration
然后我们将得到以下输出:
211895753
接下来要做的是将 total_duration 除以数据的计数,如下所示:
total_duration/(normal_data.count())
这将给我们以下输出:
217.82472416710442
稍微计算后,我们将使用 map 和 reduce 创建两个计数。我们刚刚学会了如何使用 PySpark 计算平均值,以及 PySpark 中的 map 和 reduce 函数是什么。
使用聚合进行更快的平均计算
在上一节中,我们看到了如何使用 map 和 reduce 计算平均值。现在让我们看看如何使用 aggregate 函数进行更快的平均计算。您可以参考前一节中提到的文档。
aggregate 是一个带有三个参数的函数,其中没有一个是可选的。
第一个是 zeroValue 参数,我们在其中放入聚合结果的基本情况。
第二个参数是顺序运算符 (seqOp),它允许您在 zeroValue 之上堆叠和聚合值。您可以从 zeroValue 开始,将您的 RDD 中的值传递到 seqOp 函数中,并将其堆叠或聚合到 zeroValue 之上。
最后一个参数是combOp,表示组合操作,我们只需将通过seqOp参数聚合的zeroValue参数组合成一个值,以便我们可以使用它来完成聚合。
因此,我们正在聚合每个分区的元素,然后使用组合函数和中性零值对所有分区的结果进行聚合。在这里,我们有两件事需要注意:
-
op函数允许修改t1,但不应修改t2 -
第一个函数
seqOp可以返回不同的结果类型U
在这种情况下,我们都需要一个操作来将T合并到U,以及一个操作来合并这两个U。
让我们去我们的 Jupyter Notebook 检查这是如何完成的。aggregate允许我们同时计算总持续时间和计数。我们调用duration_count函数。然后我们取normal_data并对其进行聚合。请记住,聚合有三个参数。第一个是初始值;也就是零值,(0,0)。第二个是一个顺序操作,如下所示:
duration_count = duration.aggregate(
(0,0),
(lambda db, new_value: (db[0] + new_value, db[1] + 1))
)
我们需要指定一个具有两个参数的lambda函数。第一个参数是当前的累加器,或者聚合器,或者也可以称为数据库(db)。然后,在我们的lambda函数中,我们有第二个参数new_value,或者我们在 RDD 中处理的当前值。我们只是想对数据库做正确的事情,也就是说,我们知道我们的数据库看起来像一个元组,第一个元素是持续时间的总和,第二个元素是计数。在这里,我们知道我们的数据库看起来像一个元组,持续时间的总和是第一个元素,计数是第二个元素。每当我们查看一个新值时,我们需要将新值添加到当前的运行总数中,并将1添加到当前的运行计数中。
运行总数是第一个元素,db[0]。然后我们只需要将1添加到第二个元素db[1],即计数。这是顺序操作。
每当我们得到一个new_value,如前面的代码块所示,我们只需将其添加到运行总数中。而且,因为我们已经将new_value添加到运行总数中,我们需要将计数增加1。其次,我们需要放入组合器操作。现在,我们只需要将两个单独的数据库db1和db2的相应元素组合如下:
duration_count = duration.aggregate(
(0,0),
(lambda db, new_value: (db[0] + new_value, db[1] + 1)),
(lambda db1, db2: (db1[0] + db2[0], db1[1] + db2[1]))
)
由于持续时间计数是一个元组,它在第一个元素上收集了我们的总持续时间,在第二个元素上记录了我们查看的持续时间数量,计算平均值非常简单。我们需要将第一个元素除以第二个元素,如下所示:
duration_count[0]/duration_count[1]
这将给我们以下输出:
217.82472416710442
您可以看到它返回了与我们在上一节中看到的相同的结果,这很棒。在下一节中,我们将看一下带有键值对数据点的数据透视表。
带有键值对数据点的数据透视表
数据透视表非常简单且易于使用。我们将使用大型数据集,例如 KDD 杯数据集,并根据某些键对某些值进行分组。
例如,我们有一个包含人和他们最喜欢的水果的数据集。我们想知道有多少人把苹果作为他们最喜欢的水果,因此我们将根据水果将人数进行分组,这是值,而不是键。这就是数据透视表的简单概念。
我们可以使用map函数将 KDD 数据集移动到键值对范例中。我们使用lambda函数将数据集的特征41映射到kv键值,并将值附加如下:
kv = csv.map(lambda x: (x[41], x))
kv.take(1)
我们使用特征41作为键,值是数据点,即x。我们可以使用take函数来获取这些转换行中的一个,以查看其外观。
现在让我们尝试类似于前面的例子。为了找出特征41中每种数值的总持续时间,我们可以再次使用map函数,简单地将41特征作为我们的键。我们可以将数据点中第一个数字的浮点数作为我们的值。我们将使用reduceByKey函数来减少每个键的持续时间。
因此,reduceByKey不仅仅是减少所有数据点,而是根据它们所属的键来减少持续时间数字。您可以在spark.apache.org/docs/latest/api/python/pyspark.html?highlight=map#pyspark.RDD.reduceByKey上查看文档。reduceByKey使用关联和交换的reduce函数合并每个键的值。它在将结果发送到减速器之前在每个映射器上执行本地合并,这类似于 MapReduce 中的组合器。
reduceByKey函数只需一个参数。我们将使用lambda函数。我们取两个不同的持续时间并将它们相加,PySpark 足够聪明,可以根据键应用这个减少函数,如下所示:
kv_duration = csv.map(lambda x: (x[41], float(x[0]))).reduceByKey(lambda x, y: x+y)
kv_duration.collect()
结果输出如下截图所示:
如果我们收集键值持续时间数据,我们可以看到持续时间是由出现在特征41中的值收集的。如果我们在 Excel 中使用数据透视表,有一个方便的函数是countByKey函数,它执行的是完全相同的操作,如下所示:
kv.countByKey()
这将给我们以下输出:
您可以看到调用kv.countByKey()函数与调用reduceByKey函数相同,先前是从键到持续时间的映射。
摘要
在本章中,我们学习了如何使用map和reduce计算平均值。我们还学习了使用aggregate进行更快的平均计算。最后,我们了解到数据透视表允许我们根据特征的不同值对数据进行聚合,并且在 PySpark 中,我们可以利用reducedByKey或countByKey等方便的函数。
在下一章中,我们将学习关于 MLlib 的内容,其中涉及机器学习,这是一个非常热门的话题。
第五章:使用 MLlib 进行强大的探索性数据分析
在本章中,我们将探索 Spark 执行回归任务的能力,使用线性回归和支持向量机等模型。我们将学习如何使用 MLlib 计算汇总统计,并使用 Pearson 和 Spearman 相关性发现数据集中的相关性。我们还将在大型数据集上测试我们的假设。
我们将涵盖以下主题:
-
使用 MLlib 计算汇总统计
-
使用 Pearson 和 Spearman 方法发现相关性
-
在大型数据集上测试我们的假设
使用 MLlib 计算汇总统计
在本节中,我们将回答以下问题:
-
什么是汇总统计?
-
我们如何使用 MLlib 创建汇总统计?
MLlib 是随 Spark 一起提供的机器学习库。最近有一个新的发展,允许我们使用 Spark 的数据处理能力传输到 Spark 本身的机器学习能力。这意味着我们不仅可以使用 Spark 来摄取、收集和转换数据,还可以分析和使用它来构建 PySpark 平台上的机器学习模型,这使我们能够拥有更无缝的可部署解决方案。
汇总统计是一个非常简单的概念。我们熟悉某个变量的平均值、标准差或方差。这些是数据集的汇总统计。之所以称其为汇总统计,是因为它通过某个统计量给出了某个东西的摘要。例如,当我们谈论数据集的平均值时,我们正在总结数据集的一个特征,而这个特征就是平均值。
让我们看看如何在 Spark 中计算汇总统计。关键因素在于colStats函数。colStats函数计算rdd输入的逐列汇总统计。colStats函数接受一个参数,即rdd,并允许我们使用 Spark 计算不同的汇总统计。
让我们看一下 Jupyter Notebook 中的代码(可在github.com/PacktPublishing/Hands-On-Big-Data-Analytics-with-PySpark/tree/master/Chapter05找到),在Chapter5.ipynb中的本章。我们将首先从kddcup.data.gz文本文件中收集数据,并将其传输到raw_data变量中:
raw_data = sc.textFile("./kddcup.data.gz")
kddcup.data文件是一个逗号分隔值(CSV)文件。我们必须通过,字符拆分这些数据,并将其放入csv变量中,如下所示:
csv = raw_data.map(lambda x: x.split(","))
让我们取数据文件的第一个特征x[0];这个特征代表持续时间,也就是数据的方面。我们将把它转换为整数,并将其包装成列表,如下所示:
duration = csv.map(lambda x: [int(x[0])])
这有助于我们对多个变量进行汇总统计,而不仅仅是其中一个。要激活colStats函数,我们需要导入Statistics包,如下面的代码片段所示:
from pyspark.mllib.stat import Statistics
这个Statistics包是pyspark.mllib.stat的一个子包。现在,我们需要在Statistics包中调用colStats函数,并向其提供一些数据。这里,我们谈论的是数据集中的持续时间数据,并将汇总统计信息输入到summary变量中:
summary = Statistics.colStats(duration)
要访问不同的汇总统计,如平均值、标准差等,我们可以调用summary对象的函数,并访问不同的汇总统计。例如,我们可以访问mean,由于我们的持续时间数据集中只有一个特征,我们可以通过00索引对其进行索引,然后得到数据集的平均值,如下所示:
summary.mean()[0]
这将给我们以下输出:
47.97930249928637
同样,如果我们从 Python 标准库中导入sqrt函数,我们可以创建数据集中持续时间的标准差,如下面的代码片段所示:
from math import sqrt
sqrt(summary.variance()[0])
这将给我们以下输出:
707.746472305374
如果我们不使用[0]对摘要统计信息进行索引,我们可以看到summary.max()和summary.min()会返回一个数组,其中第一个元素是我们所需的摘要统计信息,如下面的代码片段所示:
summary.max()
array ([58329.]) #output
summary.min()
array([0.]) #output
使用 Pearson 和 Spearman 相关性来发现相关性
在这一部分,我们将看到在数据集中计算相关性的两种不同方法,这两种方法分别称为 Pearson 和 Spearman 相关性。
Pearson 相关性
Pearson 相关系数向我们展示了两个不同变量同时变化的程度,然后根据它们的变化程度进行调整。如果你有一个数据集,这可能是计算相关性最流行的方法之一。
Spearman 相关性
Spearman 秩相关不是内置在 PySpark 中的默认相关计算,但它非常有用。Spearman 相关系数是排名变量之间的 Pearson 相关系数。使用不同的相关性观察方法可以让我们更全面地理解相关性的工作原理。让我们看看在 PySpark 中如何计算这个。
计算 Pearson 和 Spearman 相关性
为了理解这一点,让我们假设我们正在从数据集中取出前三个数值变量。为此,我们要访问之前定义的csv变量,我们只需使用逗号(,)分割raw_data。我们只考虑前三列是数值的特征。我们不会取包含文字的任何内容;我们只对纯粹基于数字的特征感兴趣。在我们的例子中,在kddcup.data中,第一个特征的索引是0;特征 5 和特征 6 的索引分别是4和5,这些是我们拥有的数值变量。我们使用lambda函数将这三个变量放入一个列表中,并将其放入metrics变量中:
metrics = csv.map(lambda x: [x[0], x[4], x[5]])
Statistics.corr(metrics, method="spearman")
这将给我们以下输出:
array([[1\. , 0.01419628, 0.29918926],
[0.01419628, 1\. , -0.16793059],
[0.29918926, -0.16793059, 1\. ]])
在使用 MLlib 计算摘要统计信息部分,我们只是将第一个特征放入一个列表中,并创建了一个长度为 1 的列表。在这里,我们将三个变量的三个量放入同一个列表中。现在,每个列表的长度都是三。
为了计算相关性,我们在metrics变量上调用corr方法,并指定method为"spearman"。PySpark 会给我们一个非常简单的矩阵,告诉我们变量之间的相关性。在我们的例子中,metrics变量中的第三个变量比第二个变量更相关。
如果我们再次在metrics上运行corr,但指定方法为pearson,那么它会给我们 Pearson 相关性。因此,让我们看看为什么我们需要有资格称为数据科学家或机器学习研究人员来调用这两个简单的函数,并简单地改变第二个参数的值。许多机器学习和数据科学都围绕着我们对统计学的理解,对数据行为的理解,对机器学习模型基础的理解以及它们的预测能力是如何产生的。
因此,作为一个机器学习从业者或数据科学家,我们只是把 PySpark 当作一个大型计算器来使用。当我们使用计算器时,我们从不抱怨计算器使用简单——事实上,它帮助我们以更直接的方式完成目标。PySpark 也是一样的情况;一旦我们从数据工程转向 MLlib,我们会注意到代码变得逐渐更容易。它试图隐藏数学的复杂性,但我们需要理解不同相关性之间的差异,也需要知道如何以及何时使用它们。
在大型数据集上测试我们的假设
在本节中,我们将研究假设检验,并学习如何使用 PySpark 测试假设。让我们看看 PySpark 中实现的一种特定类型的假设检验。这种假设检验称为 Pearson 卡方检验。卡方检验评估了两个数据集之间的差异是由偶然因素引起的可能性有多大。
例如,如果我们有一个没有任何人流量的零售店,突然之间有了人流量,那么这是随机发生的可能性有多大,或者现在我们得到的访客水平与以前相比是否有任何统计学上显著的差异?之所以称之为卡方检验,是因为测试本身参考了卡方分布。您可以参考在线文档了解更多关于卡方分布的信息。
Pearson 的卡方检验有三种变体。我们将检查观察到的数据集是否与理论数据集分布不同。
让我们看看如何实现这一点。让我们从pyspark.mllib.linalg中导入Vectors包开始。使用这个向量,我们将创建一个存储中每天访客频率的密集向量。
假设访问频率从每小时的 0.13 到 0.61,0.8,0.5,最后在星期五结束时为 0.3。因此,我们将这些访客频率放入visitors_freq变量中。由于我们使用 PySpark,我们可以很容易地从Statistics包中运行卡方检验,我们已经导入如下:
from pyspark.mllib.linalg import Vectors
visitors_freq = Vectors.dense(0.13, 0.61, 0.8, 0.5, 0.3)
print(Statistics.chiSqTest(visitors_freq))
通过运行卡方检验,visitors_freq变量为我们提供了大量有用的信息,如下截图所示:
前面的输出显示了卡方检验的摘要。我们使用了pearson方法,在我们的 Pearson 卡方检验中有 4 个自由度,统计数据为 0.585,这意味着pValue为 0.964。这导致没有反对零假设的推定。这样,观察到的数据遵循与预期相同的分布,这意味着我们的访客实际上并没有不同。这使我们对假设检验有了很好的理解。
摘要
在本章中,我们学习了摘要统计信息并使用 MLlib 计算摘要统计信息。我们还了解了 Pearson 和 Spearman 相关性,以及如何使用 PySpark 在数据集中发现这些相关性。最后,我们学习了一种特定的假设检验方法,称为 Pearson 卡方检验。然后,我们使用 PySpark 的假设检验函数在大型数据集上测试了我们的假设。
在下一章中,我们将学习如何在 Spark SQL 中处理大数据的结构。
第六章:使用 SparkSQL 为您的大数据添加结构
在本章中,我们将学习如何使用 Spark SQL 模式操作数据框,并使用 Spark DSL 构建结构化数据操作的查询。到目前为止,我们已经学会了将大数据导入 Spark 环境使用 RDD,并对这些大数据进行多个操作。现在让我们看看如何操作我们的数据框并构建结构化数据操作的查询。
具体来说,我们将涵盖以下主题:
-
使用 Spark SQL 模式操作数据框
-
使用 Spark DSL 构建查询
使用 Spark SQL 模式操作数据框
在本节中,我们将学习更多关于数据框,并学习如何使用 Spark SQL。
Spark SQL 接口非常简单。因此,去除标签意味着我们处于无监督学习领域。此外,Spark 对聚类和降维算法有很好的支持。通过使用 Spark SQL 为大数据赋予结构,我们可以有效地解决学习问题。
让我们看一下我们将在 Jupyter Notebook 中使用的代码。为了保持一致,我们将使用相同的 KDD 杯数据:
- 我们首先将
textFile输入到raw_data变量中,如下所示:
raw_data = sc.textFile("./kddcup.data.gz")
- 新的是我们从
pyspark.sql中导入了两个新包:
-
Row -
SQLContext
- 以下代码向我们展示了如何导入这些包:
from pyspark.sql import Row, SQLContext
sql_context = SQLContext(sc)
csv = raw_data.map(lambda l: l.split(","))
使用SQLContext,我们创建一个新的sql_context变量,其中包含由 PySpark 创建的SQLContext变量的对象。由于我们使用SparkContext来启动这个SQLContext变量,我们需要将sc作为SQLContext创建者的第一个参数。之后,我们需要取出我们的raw_data变量,并使用l.splitlambda 函数将其映射为一个包含我们的逗号分隔值(CSV)的对象。
- 我们将利用我们的新重要
Row对象来创建一个新对象,其中定义了标签。这是为了通过我们正在查看的特征对我们的数据集进行标记,如下所示:
rows = csv.map(lambda p: Row(duration=int(p[0]), protocol=p[1], service=p[2]))
在上面的代码中,我们取出了我们的逗号分隔值(csv),并创建了一个Row对象,其中包含第一个特征称为duration,第二个特征称为protocol,第三个特征称为service。这直接对应于实际数据集中的标签。
- 现在,我们可以通过在
sql_context变量中调用createDataFrame函数来创建一个新的数据框。要创建这个数据框,我们需要提供我们的行数据对象,结果对象将是df中的数据框。之后,我们需要注册一个临时表。在这里,我们只是称之为rdd。通过这样做,我们现在可以使用普通的 SQL 语法来查询由我们的行构造的临时表中的内容:
df = sql_context.createDataFrame(rows)
df.registerTempTable("rdd")
- 在我们的示例中,我们需要从
rdd中选择duration,这是一个临时表。我们在这里选择的协议等于'tcp',而我们在一行中的第一个特征是大于2000的duration,如下面的代码片段所示:
sql_context.sql("""SELECT duration FROM rdd WHERE protocol = 'tcp' AND duration > 2000""")
- 现在,当我们调用
show函数时,它会给我们每个符合这些条件的数据点:
sql_context.sql("""SELECT duration FROM rdd WHERE protocol = 'tcp' AND duration > 2000""").show()
- 然后我们将得到以下输出:
+--------+
|duration|
+--------+
| 12454|
| 10774|
| 13368|
| 10350|
| 10409|
| 14918|
| 10039|
| 15127|
| 25602|
| 13120|
| 2399|
| 6155|
| 11155|
| 12169|
| 15239|
| 10901|
| 15182|
| 9494|
| 7895|
| 11084|
+--------+
only showing top 20 rows
使用前面的示例,我们可以推断出我们可以使用 PySpark 包中的SQLContext变量将数据打包成 SQL 友好格式。
因此,PySpark 不仅支持使用 SQL 语法查询数据,还可以使用 Spark 领域特定语言(DSL)构建结构化数据操作的查询。
使用 Spark DSL 构建查询
在本节中,我们将使用 Spark DSL 构建结构化数据操作的查询:
- 在以下命令中,我们使用了与之前相同的查询;这次使用了 Spark DSL 来说明和比较使用 Spark DSL 与 SQL 的不同之处,但实现了与我们在前一节中展示的 SQL 相同的目标:
df.select("duration").filter(df.duration>2000).filter(df.protocol=="tcp").show()
在这个命令中,我们首先取出了在上一节中创建的df对象。然后我们通过调用select函数并传入duration参数来选择持续时间。
- 接下来,在前面的代码片段中,我们两次调用了
filter函数,首先使用df.duration,第二次使用df.protocol。在第一种情况下,我们试图查看持续时间是否大于2000,在第二种情况下,我们试图查看协议是否等于"tcp"。我们还需要在命令的最后附加show函数,以获得与以下代码块中显示的相同结果。
+--------+
|duration|
+--------+
| 12454|
| 10774|
| 13368|
| 10350|
| 10409|
| 14918|
| 10039|
| 15127|
| 25602|
| 13120|
| 2399|
| 6155|
| 11155|
| 12169|
| 15239|
| 10901|
| 15182|
| 9494|
| 7895|
| 11084|
+--------+
only showing top 20 rows
在这里,我们再次有了符合代码描述的前 20 行数据点的结果。
总结
在本章中,我们涵盖了 Spark DSL,并学习了如何构建查询。我们还学习了如何使用 Spark SQL 模式操纵 DataFrames,然后我们使用 Spark DSL 构建了结构化数据操作的查询。现在我们对 Spark 有了很好的了解,让我们在接下来的章节中看一些 Apache Spark 中的技巧和技术。
在下一章中,我们将看一下 Apache Spark 程序中的转换和操作。
第七章:转换和操作
转换和操作是 Apache Spark 程序的主要构建模块。在本章中,我们将看一下 Spark 转换来推迟计算,然后看一下应该避免哪些转换。然后,我们将使用reduce和reduceByKey方法对数据集进行计算。然后,我们将执行触发实际计算的操作。在本章结束时,我们还将学习如何重用相同的rdd进行不同的操作。
在本章中,我们将涵盖以下主题:
-
使用 Spark 转换来推迟计算到以后的时间
-
避免转换
-
使用
reduce和reduceByKey方法来计算结果 -
执行触发实际计算我们的有向无环图(DAG)的操作
-
重用相同的
rdd进行不同的操作
使用 Spark 转换来推迟计算到以后的时间
让我们首先了解 Spark DAG 的创建。我们将通过发出操作来执行 DAG,并推迟关于启动作业的决定,直到最后一刻来检查这种可能性给我们带来了什么。
让我们看一下我们将在本节中使用的代码。
首先,我们需要初始化 Spark。我们进行的每个测试都是相同的。在开始使用之前,我们需要初始化它,如下例所示:
class DeferComputations extends FunSuite {
val spark: SparkContext = SparkSession.builder().master("local[2]").getOrCreate().sparkContext
然后,我们将进行实际测试。在这里,test被称为should defer computation。它很简单,但展示了 Spark 的一个非常强大的抽象。我们首先创建一个InputRecord的rdd,如下例所示:
test("should defer computations") {
//given
val input = spark.makeRDD(
List(InputRecord(userId = "A"),
InputRecord(userId = "B")))
InputRecord是一个具有可选参数的唯一标识符的案例类。
如果我们没有提供它和必需的参数userId,它可以是一个随机的uuid。InputRecord将在本书中用于测试目的。我们已经创建了两条InputRecord的记录,我们将对其应用转换,如下例所示:
//when apply transformation
val rdd = input
.filter(_.userId.contains("A"))
.keyBy(_.userId)
.map(_._2.userId.toLowerCase)
//.... built processing graph lazy
我们只会过滤userId字段中包含A的记录。然后我们将其转换为keyBy(_.userId),然后从值中提取userId并将其映射为小写。这就是我们的rdd。所以,在这里,我们只创建了 DAG,但还没有执行。假设我们有一个复杂的程序,在实际逻辑之前创建了许多这样的无环图。
Spark 的优点是直到发出操作之前不会执行,但我们可以有一些条件逻辑。例如,我们可以得到一个快速路径的执行。假设我们有shouldExecutePartOfCode(),它可以检查配置开关,或者去 REST 服务计算rdd计算是否仍然相关,如下例所示:
if (shouldExecutePartOfCode()) {
//rdd.saveAsTextFile("") ||
rdd.collect().toList
} else {
//condition changed - don't need to evaluate DAG
}
}
我们已经使用了简单的方法进行测试,我们只是返回true,但在现实生活中,这可能是复杂的逻辑:
private def shouldExecutePartOfCode(): Boolean = {
//domain logic that decide if we still need to calculate
true
}
}
在它返回true之后,我们可以决定是否要执行 DAG。如果要执行,我们可以调用rdd.collect().toList或saveAsTextFile来执行rdd。否则,我们可以有一个快速路径,并决定我们不再对输入的rdd感兴趣。通过这样做,只会创建图。
当我们开始测试时,它将花费一些时间来完成,并返回以下输出:
"C:\Program Files\Java\jdk-12\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\lib\idea_rt.jar=50627:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\bin" -Dfile.encoding=UTF-8 -classpath C:\Users\Sneha\IdeaProjects\Chapter07\out\production\Chapter07 com.company.Main
Process finished with exit code 0
我们可以看到我们的测试通过了,我们可以得出它按预期工作的结论。现在,让我们看一些应该避免的转换。
避免转换
在本节中,我们将看一下应该避免的转换。在这里,我们将专注于一个特定的转换。
我们将从理解groupByAPI 开始。然后,我们将研究在使用groupBy时的数据分区,然后我们将看一下什么是 skew 分区以及为什么应该避免 skew 分区。
在这里,我们正在创建一个交易列表。UserTransaction是另一个模型类,包括userId和amount。以下代码块显示了一个典型的交易,我们正在创建一个包含五个交易的列表:
test("should trigger computations using actions") {
//given
val input = spark.makeRDD(
List(
UserTransaction(userId = "A", amount = 1001),
UserTransaction(userId = "A", amount = 100),
UserTransaction(userId = "A", amount = 102),
UserTransaction(userId = "A", amount = 1),
UserTransaction(userId = "B", amount = 13)))
我们已经为userId = "A"创建了四笔交易,为userId = "B"创建了一笔交易。
现在,让我们考虑我们想要合并特定userId的交易以获得交易列表。我们有一个input,我们正在按userId分组,如下例所示:
//when apply transformation
val rdd = input
.groupBy(_.userId)
.map(x => (x._1,x._2.toList))
.collect()
.toList
对于每个x元素,我们将创建一个元组。元组的第一个元素是一个 ID,而第二个元素是该特定 ID 的每个交易的迭代器。我们将使用toList将其转换为列表。然后,我们将收集所有内容并将其分配给toList以获得我们的结果。让我们断言结果。rdd应该包含与B相同的元素,即键和一个交易,以及A,其中有四个交易,如下面的代码所示:
//then
rdd should contain theSameElementsAs List(
("B", List(UserTransaction("B", 13))),
("A", List(
UserTransaction("A", 1001),
UserTransaction("A", 100),
UserTransaction("A", 102),
UserTransaction("A", 1))
)
)
}
}
让我们开始这个测试,并检查它是否按预期行为。我们得到以下输出:
"C:\Program Files\Java\jdk-12\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\lib\idea_rt.jar=50822:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\bin" -Dfile.encoding=UTF-8 -classpath C:\Users\Sneha\IdeaProjects\Chapter07\out\production\Chapter07 com.company.Main
Process finished with exit code 0
乍一看,它已经通过了,并且按预期工作。但是,为什么我们要对它进行分组的问题就出现了。我们想要对它进行分组以将其保存到文件系统或进行一些进一步的操作,例如连接所有金额。
我们可以看到我们的输入不是正常分布的,因为几乎所有的交易都是针对userId = "A"。因此,我们有一个偏斜的键。这意味着一个键包含大部分数据,而其他键包含较少的数据。当我们在 Spark 中使用groupBy时,它会获取所有具有相同分组的元素,例如在这个例子中是userId,并将这些值发送到完全相同的执行者。
例如,如果我们的执行者有 5GB 的内存,我们有一个非常大的数据集,有数百 GB,其中一个键有 90%的数据,这意味着所有数据都将传输到一个执行者,其余的执行者将获取少数数据。因此,数据将不会正常分布,并且由于非均匀分布,处理效率将不会尽可能高。
因此,当我们使用groupBy键时,我们必须首先回答为什么要对其进行分组的问题。也许我们可以在groupBy之前对其进行过滤或聚合,然后我们只会对结果进行分组,或者根本不进行分组。我们将在以下部分中研究如何使用 Spark API 解决这个问题。
使用 reduce 和 reduceByKey 方法来计算结果
在本节中,我们将使用reduce和reduceBykey函数来计算我们的结果,并了解reduce的行为。然后,我们将比较reduce和reduceBykey函数,以确定在特定用例中应该使用哪个函数。
我们将首先关注reduceAPI。首先,我们需要创建一个UserTransaction的输入。我们有用户交易A,金额为10,B的金额为1,A的金额为101。假设我们想找出全局最大值。我们对特定键的数据不感兴趣,而是对全局数据感兴趣。我们想要扫描它,取最大值,并返回它,如下例所示:
test("should use reduce API") {
//given
val input = spark.makeRDD(List(
UserTransaction("A", 10),
UserTransaction("B", 1),
UserTransaction("A", 101)
))
因此,这是减少使用情况。现在,让我们看看如何实现它,如下例所示:
//when
val result = input
.map(_.amount)
.reduce((a, b) => if (a > b) a else b)
//then
assert(result == 101)
}
对于input,我们需要首先映射我们感兴趣的字段。在这种情况下,我们对amount感兴趣。我们将取amount,然后取最大值。
在前面的代码示例中,reduce有两个参数,a和b。一个参数将是我们正在传递的特定 Lambda 中的当前最大值,而第二个参数将是我们现在正在调查的实际值。如果该值高于到目前为止的最大状态,我们将返回a;如果不是,它将返回b。我们将遍历所有元素,最终结果将只是一个长数字。
因此,让我们测试一下,检查结果是否确实是101,如以下代码输出所示。这意味着我们的测试通过了。
"C:\Program Files\Java\jdk-12\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\lib\idea_rt.jar=50894:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\bin" -Dfile.encoding=UTF-8 -classpath C:\Users\Sneha\IdeaProjects\Chapter07\out\production\Chapter07 com.company.Main
Process finished with exit code 0
现在,让我们考虑一个不同的情况。我们想找到最大的交易金额,但这次我们想根据用户来做。我们不仅想找出用户A的最大交易,还想找出用户B的最大交易,但我们希望这些事情是独立的。因此,对于相同的每个键,我们只想从我们的数据中取出最大值,如以下示例所示:
test("should use reduceByKey API") {
//given
val input = spark.makeRDD(
List(
UserTransaction("A", 10),
UserTransaction("B", 1),
UserTransaction("A", 101)
)
)
要实现这一点,reduce不是一个好选择,因为它将遍历所有的值并给出全局最大值。我们在 Spark 中有关键操作,但首先,我们要为特定的元素组做这件事。我们需要使用keyBy告诉 Spark 应该将哪个 ID 作为唯一的,并且它将仅在特定的键内执行reduce函数。因此,我们使用keyBy(_.userId),然后得到reducedByKey函数。reduceByKey函数类似于reduce,但它按键工作,因此在 Lambda 内,我们只会得到特定键的值,如以下示例所示:
//when
val result = input
.keyBy(_.userId)
.reduceByKey((firstTransaction, secondTransaction) =>
TransactionChecker.higherTransactionAmount(firstTransaction, secondTransaction))
.collect()
.toList
通过这样做,我们得到第一笔交易,然后是第二笔。第一笔将是当前的最大值,第二笔将是我们正在调查的交易。我们将创建一个辅助函数,它接受这些交易并称之为higherTransactionAmount。
higherTransactionAmount函数用于获取firstTransaction和secondTransaction。请注意,对于UserTransaction类型,我们需要传递该类型。它还需要返回UserTransaction,我们不能返回不同的类型。
如果您正在使用 Spark 的reduceByKey方法,我们需要返回与input参数相同的类型。如果firstTransaction.amount高于secondTransaction.amount,我们将返回firstTransaction,因为我们返回的是secondTransaction,所以是交易对象而不是总金额。这在以下示例中显示:
object TransactionChecker {
def higherTransactionAmount(firstTransaction: UserTransaction, secondTransaction: UserTransaction): UserTransaction = {
if (firstTransaction.amount > secondTransaction.amount) firstTransaction else secondTransaction
}
}
现在,我们将收集、添加和测试交易。在我们的测试之后,我们得到了输出,对于键B,我们应该得到交易("B", 1),对于键A,交易("A", 101)。没有交易("A", 10),因为我们已经过滤掉了它,但我们可以看到对于每个键,我们都能找到最大值。这在以下示例中显示:
//then
result should contain theSameElementsAs
List(("B", UserTransaction("B", 1)), ("A", UserTransaction("A", 101)))
}
}
我们可以看到测试通过了,一切都如预期的那样,如以下输出所示:
"C:\Program Files\Java\jdk-12\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\lib\idea_rt.jar=50909:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\bin" -Dfile.encoding=UTF-8 -classpath C:\Users\Sneha\IdeaProjects\Chapter07\out\production\Chapter07 com.company.Main
Process finished with exit code 0
在下一节中,我们将执行触发数据计算的操作。
执行触发计算的操作
Spark 有更多触发 DAG 的操作,我们应该了解所有这些,因为它们非常重要。在本节中,我们将了解 Spark 中可以成为操作的内容,对操作进行一次遍历,并测试这些操作是否符合预期。
我们已经涵盖的第一个操作是collect。除此之外,我们还涵盖了两个操作——在上一节中我们都涵盖了reduce和reduceByKey。这两种方法都是操作,因为它们返回单个结果。
首先,我们将创建我们的交易的input,然后应用一些转换,仅用于测试目的。我们将只取包含A的用户,使用keyBy_.userId,然后只取所需交易的金额,如以下示例所示:
test("should trigger computations using actions") {
//given
val input = spark.makeRDD(
List(
UserTransaction(userId = "A", amount = 1001),
UserTransaction(userId = "A", amount = 100),
UserTransaction(userId = "A", amount = 102),
UserTransaction(userId = "A", amount = 1),
UserTransaction(userId = "B", amount = 13)))
//when apply transformation
val rdd = input
.filter(_.userId.contains("A"))
.keyBy(_.userId)
.map(_._2.amount)
我们已经知道的第一个操作是rdd.collect().toList。接下来是count(),它需要获取所有的值并计算rdd中有多少值。没有办法在不触发转换的情况下执行count()。此外,Spark 中还有不同的方法,如countApprox、countApproxDistinct、countByValue和countByValueApprox。以下示例显示了rdd.collect().toList的代码:
//then
println(rdd.collect().toList)
println(rdd.count()) //and all count*
如果我们有一个庞大的数据集,并且近似计数就足够了,你可以使用countApprox,因为它会快得多。然后我们使用rdd.first(),但这个选项有点不同,因为它只需要取第一个元素。有时,如果你想取第一个元素并执行我们 DAG 中的所有操作,我们需要专注于这一点,并以以下方式检查它:
println(rdd.first())
此外,在rdd上,我们有foreach(),这是一个循环,我们可以传递任何函数。假定 Scala 函数或 Java 函数是 Lambda,但要执行我们结果rdd的元素,需要计算 DAG,因为从这里开始,它就是一个操作。foreach()方法的另一个变体是foreachPartition(),它获取每个分区并返回分区的迭代器。在其中,我们有一个迭代器再次进行迭代并打印我们的元素。我们还有我们的max()和min()方法,预期的是,max()取最大值,min()取最小值。但这些方法都需要隐式排序。
如果我们有一个简单的原始类型的rdd,比如Long,我们不需要在这里传递它。但如果我们不使用map(),我们需要为 Spark 定义UserTransaction的排序,以便找出哪个元素是max,哪个元素是min。这两件事需要执行 DAG,因此它们被视为操作,如下面的例子所示:
rdd.foreach(println(_))
rdd.foreachPartition(t => t.foreach(println(_)))
println(rdd.max())
println(rdd.min())
然后我们有takeOrdered(),这是一个比first()更耗时的操作,因为first()取一个随机元素。takeOrdered()需要执行 DAG 并对所有内容进行排序。当一切都排序好后,它才取出顶部的元素。
在我们的例子中,我们取num = 1。但有时,出于测试或监控的目的,我们需要只取数据的样本。为了取样,我们使用takeSample()方法并传递一个元素数量,如下面的代码所示:
println(rdd.takeOrdered(1).toList)
println(rdd.takeSample(false, 2).toList)
}
}
现在,让我们开始测试并查看实现前面操作的输出,如下面的屏幕截图所示:
List(1001, 100, 102 ,1)
4
1001
1001
100
102
1
第一个操作返回所有值。第二个操作返回4作为计数。我们将考虑第一个元素1001,但这是一个随机值,它是无序的。然后我们在循环中打印所有的元素,如下面的输出所示:
102
1
1001
1
List(1)
List(100, 1)
然后我们得到max和min值,如1001和1,这与first()类似。之后,我们得到一个有序列表List(1),和一个样本List(100, 1),这是随机的。因此,在样本中,我们从输入数据和应用的转换中得到随机值。
在下一节中,我们将学习如何重用rdd进行不同的操作。
重用相同的 rdd 进行不同的操作
在这一部分,我们将重用相同的rdd进行不同的操作。首先,我们将通过重用rdd来最小化执行时间。然后,我们将查看缓存和我们代码的性能测试。
下面的例子是前面部分的测试,但稍作修改,这里我们通过currentTimeMillis()取start和result。因此,我们只是测量执行的所有操作的result:
//then every call to action means that we are going up to the RDD chain
//if we are loading data from external file-system (I.E.: HDFS), every action means
//that we need to load it from FS.
val start = System.currentTimeMillis()
println(rdd.collect().toList)
println(rdd.count())
println(rdd.first())
rdd.foreach(println(_))
rdd.foreachPartition(t => t.foreach(println(_)))
println(rdd.max())
println(rdd.min())
println(rdd.takeOrdered(1).toList)
println(rdd.takeSample(false, 2).toList)
val result = System.currentTimeMillis() - start
println(s"time taken (no-cache): $result")
}
如果有人对 Spark 不太了解,他们会认为所有操作都被巧妙地执行了。我们知道每个操作都意味着我们要上升到链中的rdd,这意味着我们要对所有的转换进行加载数据。在生产系统中,加载数据将来自外部的 PI 系统,比如 HDFS。这意味着每个操作都会导致对文件系统的调用,这将检索所有数据,然后应用转换,如下例所示:
//when apply transformation
val rdd = input
.filter(_.userId.contains("A"))
.keyBy(_.userId)
.map(_._2.amount)
这是一个非常昂贵的操作,因为每个操作都非常昂贵。当我们开始这个测试时,我们可以看到没有缓存的时间为 632 毫秒,如下面的输出所示:
List(1)
List(100, 1)
time taken (no-cache): 632
Process finished with exit code 0
让我们将这与缓存使用进行比较。乍一看,我们的测试看起来非常相似,但这并不相同,因为您正在使用cache(),而我们正在返回rdd。因此,rdd将已经被缓存,对rdd的每个后续调用都将经过cache,如下例所示:
//when apply transformation
val rdd = input
.filter(_.userId.contains("A"))
.keyBy(_.userId)
.map(_._2.amount)
.cache()
第一个操作将执行 DAG,将数据保存到我们的缓存中,然后后续的操作将根据从内存中调用的方法来检索特定的内容。不会有 HDFS 查找,所以让我们按照以下示例开始这个测试,看看需要多长时间:
//then every call to action means that we are going up to the RDD chain
//if we are loading data from external file-system (I.E.: HDFS), every action means
//that we need to load it from FS.
val start = System.currentTimeMillis()
println(rdd.collect().toList)
println(rdd.count())
println(rdd.first())
rdd.foreach(println(_))
rdd.foreachPartition(t => t.foreach(println(_)))
println(rdd.max())
println(rdd.min())
println(rdd.takeOrdered(1).toList)
println(rdd.takeSample(false, 2).toList)
val result = System.currentTimeMillis() - start
println(s"time taken(cache): $result")
}
}
第一个输出将如下所示:
List(1)
List(100, 102)
time taken (no-cache): 585
List(1001, 100, 102, 1)
4
第二个输出将如下所示:
1
List(1)
List(102, 1)
time taken(cache): 336
Process finished with exit code 0
没有缓存,值为585毫秒,有缓存时,值为336。这个差异并不大,因为我们只是在测试中创建数据。然而,在真实的生产系统中,这将是一个很大的差异,因为我们需要从外部文件系统中查找数据。
总结
因此,让我们总结一下这一章节。首先,我们使用 Spark 转换来推迟计算到以后的时间,然后我们学习了哪些转换应该避免。接下来,我们看了如何使用reduceByKey和reduce来计算我们的全局结果和特定键的结果。之后,我们执行了触发计算的操作,然后了解到每个操作都意味着加载数据的调用。为了缓解这个问题,我们学习了如何为不同的操作减少相同的rdd。
在下一章中,我们将看一下 Spark 引擎的不可变设计。
第八章:不可变设计
在本章中,我们将看看 Apache Spark 的不可变设计。我们将深入研究 Spark RDD 的父/子链,并以不可变的方式使用 RDD。然后,我们将使用 DataFrame 操作进行转换,以讨论在高度并发的环境中的不可变性。在本章结束时,我们将以不可变的方式使用数据集 API。
在这一章中,我们将涵盖以下主题:
-
深入研究 Spark RDD 的父/子链
-
以不可变的方式使用 RDD
-
使用 DataFrame 操作进行转换
-
在高度并发的环境中的不可变性
-
以不可变的方式使用数据集 API
深入研究 Spark RDD 的父/子链
在本节中,我们将尝试实现我们自己的 RDD,继承 RDD 的父属性。
我们将讨论以下主题:
-
扩展 RDD
-
与父 RDD 链接新的 RDD
-
测试我们的自定义 RDD
扩展 RDD
这是一个有很多隐藏复杂性的简单测试。让我们从创建记录的列表开始,如下面的代码块所示:
class InheritanceRdd extends FunSuite {
val spark: SparkContext = SparkSession
.builder().master("local[2]").getOrCreate().sparkContext
test("use extended RDD") {
//given
val rdd = spark.makeRDD(List(Record(1, "d1")))
Record只是一个具有amount和description的案例类,所以amount是 1,d1是描述。
然后我们创建了MultipledRDD并将rdd传递给它,然后将乘数设置为10,如下面的代码所示:
val extendedRdd = new MultipliedRDD(rdd, 10)
我们传递父 RDD,因为它包含在另一个 RDD 中加载的数据。通过这种方式,我们构建了两个 RDD 的继承链。
与父 RDD 链接新的 RDD
我们首先创建了一个多重 RDD 类。在MultipliedRDD类中,我们有两个传递参数的东西:
-
记录的简要 RDD,即
RDD[Record] -
乘数,即
Double
在我们的情况下,可能会有多个 RDD 的链,这意味着我们的 RDD 中可能会有多个 RDD。因此,这并不总是所有有向无环图的父级。我们只是扩展了类型为记录的 RDD,因此我们需要传递扩展的 RDD。
RDD 有很多方法,我们可以覆盖任何我们想要的方法。但是,这一次,我们选择了compute方法,我们将覆盖计算乘数的方法。在这里,我们获取Partition分区和TaskContext。这些是执行引擎传递给我们方法的,因此我们不需要担心这一点。但是,我们需要返回与我们通过继承链中的 RDD 类传递的类型完全相同的迭代器。这将是记录的迭代器。
然后我们执行第一个父逻辑,第一个父只是获取我们链中的第一个 RDD。这里的类型是Record,我们获取split和context的iterator,其中split只是将要执行的分区。我们知道 Spark RDD 是由分区器分区的,但是在这里,我们只是获取我们需要拆分的特定分区。因此,迭代器获取分区和任务上下文,因此它知道应该从该迭代方法返回哪些值。对于迭代器中的每条记录,即salesRecord,如amount和description,我们将amount乘以传递给构造函数的multiplier来获得我们的Double。
通过这样做,我们已经将我们的金额乘以了乘数,然后我们可以返回具有新金额的新记录。因此,我们现在有了旧记录乘以我们的“乘数”的金额和salesRecord的描述。对于第二个过滤器,我们需要“覆盖”的是getPartitions,因为我们希望保留父 RDD 的分区。例如,如果之前的 RDD 有 100 个分区,我们也希望我们的MultipledRDD有 100 个分区。因此,我们希望保留关于分区的信息,而不是丢失它。出于同样的原因,我们只是将其代理给firstParent。RDD 的firstParent然后只会从特定 RDD 中获取先前的分区。
通过这种方式,我们创建了一个新的multipliedRDD,它传递了父级和乘数。对于我们的extendedRDD,我们需要collect它并调用toList,我们的列表应该包含10和d1,如下例所示:
extendedRdd.collect().toList should contain theSameElementsAs List(
Record(10, "d1")
)
}
}
当我们创建新的 RDD 时,计算会自动执行,因此它总是在没有显式方法调用的情况下执行。
测试我们的自定义 RDD
让我们开始这个测试,以检查这是否已经创建了我们的 RDD。通过这样做,我们可以扩展我们的父 RDD 并向我们的 RDD 添加行为。这在下面的截图中显示:
"C:\Program Files\Java\jdk-12\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\lib\idea_rt.jar=51687:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\bin" -Dfile.encoding=UTF-8 -classpath C:\Users\Sneha\IdeaProjects\Chapter07\out\production\Chapter07 com.company.Main
Process finished with exit code 0
在下一节中,我们将以不可变的方式使用 RDD。
以不可变的方式使用 RDD
现在我们知道如何使用 RDD 继承创建执行链,让我们学习如何以不可变的方式使用 RDD。
在这一部分,我们将讨论以下主题:
-
理解 DAG 的不可变性
-
从一个根 RDD 创建两个叶子
-
检查两个叶子的结果
让我们首先了解有向无环图的不可变性以及它给我们带来了什么。然后,我们将从一个节点 RDD 创建两个叶子,并检查如果我们在一个叶子 RDD 上创建一个转换,那么两个叶子是否完全独立地行为。然后,我们将检查当前 RDD 的两个叶子的结果,并检查对任何叶子的任何转换是否不会改变或影响根 RDD。以这种方式工作是至关重要的,因为我们发现我们将无法从根 RDD 创建另一个叶子,因为根 RDD 将被更改,这意味着它将是可变的。为了克服这一点,Spark 设计师为我们创建了一个不可变的 RDD。
有一个简单的测试来显示 RDD 应该是不可变的。首先,我们将从0 到 5创建一个 RDD,它被添加到来自 Scala 分支的序列中。to获取Int,第一个参数是一个隐式参数,来自 Scala 包,如下例所示:
class ImmutableRDD extends FunSuite {
val spark: SparkContext = SparkSession
.builder().master("local[2]").getOrCreate().sparkContext
test("RDD should be immutable") {
//given
val data = spark.makeRDD(0 to 5)
一旦我们有了 RDD 数据,我们可以创建第一个叶子。第一个叶子是一个结果(res),我们只是将每个元素乘以2。让我们创建第二个叶子,但这次它将被标记为4,如下例所示:
//when
val res = data.map(_ * 2)
val leaf2 = data.map(_ * 4)
所以,我们有我们的根 RDD 和两个叶子。首先,我们将收集第一个叶子,并看到其中的元素为0, 2, 4, 6, 8, 10,所以这里的一切都乘以2,如下例所示:
//then
res.collect().toList should contain theSameElementsAs List(
0, 2, 4, 6, 8, 10
)
然而,即使我们在res上有了通知,数据仍然与一开始的完全相同,即0, 1, 2, 3, 4, 5,如下例所示:
data.collect().toList should contain theSameElementsAs List(
0, 1, 2, 3, 4, 5
)
}
}
所以,一切都是不可变的,执行* 2的转换并没有改变我们的数据。如果我们为leaf2创建一个测试,我们将collect它并调用toList。我们会看到它应该包含像0, 4, 8, 12, 16, 20这样的元素,如下例所示:
leaf2.collect().toList should contain theSameElementsAs List(
0, 4, 8, 12, 16, 20
)
当我们运行测试时,我们会看到我们执行中的每条路径,即数据或第一个叶子和第二个叶子,彼此独立地行为,如下面的代码输出所示:
"C:\Program Files\Java\jdk-12\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\lib\idea_rt.jar=51704:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\bin" -Dfile.encoding=UTF-8 -classpath C:\Users\Sneha\IdeaProjects\Chapter07\out\production\Chapter07 com.company.Main
Process finished with exit code 0
每次变异都是不同的;我们可以看到测试通过了,这表明我们的 RDD 是不可变的。
使用 DataFrame 操作进行转换
API 的数据下面有一个 RDD,因此 DataFrame 是不可变的。在 DataFrame 中,不可变性甚至更好,因为我们可以动态地添加和减去列,而不改变源数据集。
在这一部分,我们将涵盖以下主题:
-
理解 DataFrame 的不可变性
-
从一个根 DataFrame 创建两个叶子
-
通过发出转换来添加新列
我们将首先使用操作的数据来转换我们的 DataFrame。首先,我们需要了解 DataFrame 的不可变性,然后我们将从一个根 DataFrame 创建两个叶子,但这次是。然后,我们将发出一个略有不同于 RDD 的转换。这将向我们的结果 DataFrame 添加一个新列,因为我们在 DataFrame 中是这样操作的。如果我们想要映射数据,那么我们需要从第一列中获取数据,进行转换,并保存到另一列,然后我们将有两列。如果我们不再感兴趣,我们可以删除第一列,但结果将是另一个 DataFrame。
因此,我们将有第一个 DataFrame 有一列,第二个有结果和源,第三个只有一个结果。让我们看看这一部分的代码。
我们将创建一个 DataFrame,所以我们需要调用toDF()方法。我们将使用"a"作为"1","b"作为"2","d"作为"200"来创建UserData。UserData有userID和data两个字段,都是String类型,如下例所示:
test("Should use immutable DF API") {
import spark.sqlContext.implicits._
//given
val userData =
spark.sparkContext.makeRDD(List(
UserData("a", "1"),
UserData("b", "2"),
UserData("d", "200")
)).toDF()
在测试中使用案例类创建 RDD 是很重要的,因为当我们调用 DataFrame 时,这部分将推断模式并相应地命名列。以下代码是这方面的一个例子,我们只从userData中的userID列中进行过滤:
//when
val res = userData.filter(userData("userId").isin("a"))
我们的结果应该只有一条记录,所以我们要删除两列,但是我们创建的userData源将有 3 行。因此,通过过滤对其进行修改,创建了另一个名为res的 DataFrame,而不修改输入的userData,如下例所示:
assert(res.count() == 1)
assert(userData.count() == 3)
}
}
让我们开始这个测试,看看来自 API 的不可变数据的行为,如下屏幕截图所示:
"C:\Program Files\Java\jdk-12\bin\java.exe" "-javaagent:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\lib\idea_rt.jar=51713:C:\Program Files\JetBrains\IntelliJ IDEA 2018.3.5\bin" -Dfile.encoding=UTF-8 -classpath C:\Users\Sneha\IdeaProjects\Chapter07\out\production\Chapter07 com.company.Main
Process finished with exit code 0
正如我们所看到的,我们的测试通过了,并且从结果(res)中,我们知道我们的父级没有被修改。因此,例如,如果我们想在res.map()上映射一些东西,我们可以映射userData列,如下例所示:
res.map(a => a.getString("userId") + "can")
另一个叶子将具有一个额外的列,而不更改userId源代码,因此这就是 DataFrame 的不可变性。
高并发环境中的不可变性
我们看到了不可变性如何影响程序的创建和设计,现在我们将了解它的用途。
在本节中,我们将涵盖以下主题:
-
可变集合的缺点
-
创建两个同时修改可变集合的线程
-
推理并发程序
让我们首先了解可变集合的原因。为此,我们将创建两个同时修改可变集合的线程。我们将使用此代码进行测试。首先,我们将创建一个ListBuffer,它是一个可变列表。然后,我们可以添加和删除链接,而无需为任何修改创建另一个列表。然后,我们可以创建一个具有两个线程的Executors服务。我们需要两个线程同时开始修改状态。稍后,我们将使用Java.util.concurrent中的CountDownLatch构造。这在下面的例子中显示:
import java.util.concurrent.{CountDownLatch, Executors}
import org.scalatest.FunSuite
import scala.collection.mutable.ListBuffer
class MultithreadedImmutabilityTest extends FunSuite {
test("warning: race condition with mutability") {
//given
var listMutable = new ListBuffer[String]()
val executors = Executors.newFixedThreadPool(2)
val latch = new CountDownLatch(2)
CountDownLatch是一种构造,它帮助我们阻止线程处理,直到我们要求它们开始。我们需要等待逻辑,直到两个线程开始执行。然后,我们向executors提交一个Runnable,我们的run()方法通过发出countDown()来表示准备好进行操作,并将"A"添加到listMutable,如下例所示:
//when
executors.submit(new Runnable {
override def run(): Unit = {
latch.countDown()
listMutable += "A"
}
})
然后,另一个线程启动,并且也使用countDown来表示它已准备好开始。但首先,它会检查列表是否包含"A",如果没有,就会添加"A",如下例所示:
executors.submit(new Runnable {
override def run(): Unit = {
latch.countDown()
if(!listMutable.contains("A")) {
listMutable += "A"
}
}
})
然后,我们使用await()等待countDown发出,当它发出时,我们可以继续验证我们的程序,如下例所示:
latch.await()
listMutable包含"A"或可能包含"A","A"。listMutable检查列表是否包含"A",如果没有,它将不会添加该元素,如下例所示:
//then
//listMutable can have ("A") or ("A","A")
}
}
但这里存在竞争条件。在检查if(!listMutable.contains("A"))之后,run()线程可能会将"A"元素添加到列表中。但我们在if中,所以我们将通过listMutable += "A"添加另一个"A"。由于状态的可变性以及它通过另一个线程进行了修改,我们可能会有"A"或"A","A"。
在使用可变状态时需要小心,因为我们不能有这样一个损坏的状态。为了缓解这个问题,我们可以在java.util集合上使用同步列表。
但如果我们有同步块,那么我们的程序将非常慢,因为我们需要独占地访问它。我们还可以使用java.util.concurrent.locks包中的lock。我们可以使用ReadLock或WriteLock等实现。在下面的例子中,我们将使用WriteLock:
val lock = new WriteLock()
我们还需要对我们的lock()进行lock,然后再进行下一步,如下例所示:
lock.lock()
之后,我们可以使用unlock()。然而,我们也应该在第二个线程中这样做,这样我们的列表只有一个元素,如下例所示:
lock.unlock()
输出如下:
锁定是一个非常艰难和昂贵的操作,因此不可变性是性能程序的关键。
以不可变的方式使用数据集 API
在本节中,我们将以不可变的方式使用数据集 API。我们将涵盖以下主题:
-
数据集的不可变性
-
从一个根数据集创建两个叶子
-
通过发出转换添加新列
数据集的测试用例非常相似,但我们需要对我们的数据进行toDS()以确保类型安全。数据集的类型是userData,如下例所示:
import com.tomekl007.UserData
import org.apache.spark.sql.SparkSession
import org.scalatest.FunSuite
class ImmutableDataSet extends FunSuite {
val spark: SparkSession = SparkSession
.builder().master("local[2]").getOrCreate()
test("Should use immutable DF API") {
import spark.sqlContext.implicits._
//given
val userData =
spark.sparkContext.makeRDD(List(
UserData("a", "1"),
UserData("b", "2"),
UserData("d", "200")
)).toDF()
现在,我们将发出对userData的过滤,并指定isin,如下例所示:
//when
val res = userData.filter(userData("userId").isin("a"))
它将返回结果(res),这是一个带有我们的1元素的叶子。由于这个明显的根,userData仍然有3个元素。让我们执行这个程序,如下例所示:
assert(res.count() == 1)
assert(userData.count() == 3)
}
}
我们可以看到我们的测试通过了,这意味着数据集也是 DataFrame 之上的不可变抽象,并且具有相同的特性。userData有一个非常有用的类型集,如果使用show()方法,它将推断模式并知道"a"字段是字符串或其他类型,如下例所示:
userData.show()
输出将如下所示:
+------+----+
|userId|data|
|----- |----|
| a| 1|
| b| 2|
| d| 200|
+------|----+
在前面的输出中,我们有userID和data字段。
总结
在本章中,我们深入研究了 Spark RDD 的父子链,并创建了一个能够根据父 RDD 计算一切的乘数 RDD,还基于父 RDD 的分区方案。我们以不可变的方式使用了 RDD。我们看到,从父级创建的叶子的修改并没有修改部分。我们还学习了一个更好的抽象,即 DataFrame,因此我们学会了可以在那里使用转换。然而,每个转换只是添加到另一列,而不是直接修改任何内容。接下来,我们只需在高度并发的环境中设置不可变性。我们看到了当访问多个线程时,可变状态是不好的。最后,我们看到数据集 API 也是以不可变的方式创建的,我们可以在这里利用这些特性。
在下一章中,我们将看看如何避免洗牌和减少个人开支。
第九章:避免洗牌和减少操作费用
在本章中,我们将学习如何避免洗牌并减少我们作业的操作费用,以及检测过程中的洗牌。然后,我们将测试在 Apache Spark 中导致洗牌的操作,以找出我们何时应该非常小心以及我们应该避免哪些操作。接下来,我们将学习如何改变具有广泛依赖关系的作业设计。之后,我们将使用keyBy()操作来减少洗牌,在本章的最后一节中,我们将看到如何使用自定义分区来减少数据的洗牌。
在本章中,我们将涵盖以下主题:
-
检测过程中的洗牌
-
在 Apache Spark 中进行导致洗牌的测试操作
-
改变具有广泛依赖关系的作业设计
-
使用
keyBy()操作来减少洗牌 -
使用自定义分区器来减少洗牌
检测过程中的洗牌
在本节中,我们将学习如何检测过程中的洗牌。
在本节中,我们将涵盖以下主题:
-
加载随机分区的数据
-
使用有意义的分区键发出重新分区
-
通过解释查询来理解洗牌是如何发生的
我们将加载随机分区的数据,以查看数据是如何加载的以及数据加载到了哪里。接下来,我们将使用有意义的分区键发出一个分区。然后,我们将使用确定性和有意义的键将数据重新分区到适当的执行程序。最后,我们将使用explain()方法解释我们的查询并理解洗牌。在这里,我们有一个非常简单的测试。
我们将创建一个带有一些数据的 DataFrame。例如,我们创建了一个带有一些随机 UID 和user_1的InputRecord,以及另一个带有user_1中随机 ID 的输入,以及user_2的最后一条记录。假设这些数据是通过外部数据系统加载的。数据可以从 HDFS 加载,也可以从数据库加载,例如 Cassandra 或 NoSQL:
class DetectingShuffle extends FunSuite {
val spark: SparkSession = SparkSession.builder().master("local[2]").getOrCreate()
test("should explain plan showing logical and physical with UDF and DF") {
//given
import spark.sqlContext.implicits._
val df = spark.sparkContext.makeRDD(List(
InputRecord("1234-3456-1235-1234", "user_1"),
InputRecord("1123-3456-1235-1234", "user_1"),
InputRecord("1123-3456-1235-9999", "user_2")
)).toDF()
在加载的数据中,我们的数据没有预定义或有意义的分区,这意味着输入记录编号 1 可能会最先出现在执行程序中,而记录编号 2 可能会最先出现在执行程序中。因此,即使数据来自同一用户,我们也很可能会为特定用户执行操作。
如前一章第八章中所讨论的,不可变设计,我们使用了reducebyKey()方法,该方法获取用户 ID 或特定 ID 以减少特定键的所有值。这是一个非常常见的操作,但具有一些随机分区。最好使用有意义的键repartition数据。
在使用userID时,我们将使用repartition的方式,使结果记录具有相同用户 ID 的数据。因此,例如user_1最终将出现在第一个执行程序上:
//when
val q = df.repartition(df("userId"))
第一个执行程序将拥有所有userID的数据。如果InputRecord("1234-3456-1235-1234", "user_1")在执行程序 1 上,而InputRecord("1123-3456-1235-1234", "user_1")在执行程序 2 上,在对来自执行程序 2 的数据进行分区后,我们需要将其发送到执行程序 1,因为它是此分区键的父级。这会导致洗牌。洗牌是由于加载数据而导致的,这些数据没有有意义地分区,或者根本没有分区。我们需要处理我们的数据,以便我们可以对特定键执行操作。
我们可以进一步repartition数据,但应该在链的开头进行。让我们开始测试来解释我们的查询:
q.explain(true)
我们在逻辑计划中对userID表达式进行了重新分区,但当我们检查物理计划时,显示使用了哈希分区,并且我们将对userID值进行哈希处理。因此,我们扫描所有 RDD 和所有具有相同哈希的键,并将其发送到相同的执行程序以实现我们的目标:
在下一节中,我们将测试在 Apache Spark 中导致洗牌的操作。
在 Apache Spark 中进行导致洗牌的测试操作
在本节中,我们将测试在 Apache Spark 中导致洗牌的操作。我们将涵盖以下主题:
-
使用 join 连接两个 DataFrame
-
使用分区不同的两个 DataFrame
-
测试导致洗牌的连接
连接是一种特定的操作,会导致洗牌,我们将使用它来连接我们的两个 DataFrame。我们将首先检查它是否会导致洗牌,然后我们将检查如何避免它。为了理解这一点,我们将使用两个分区不同的 DataFrame,并检查连接两个未分区或随机分区的数据集或 DataFrame 的操作。如果它们位于不同的物理机器上,将会导致洗牌,因为没有办法连接具有相同分区键的两个数据集。
在我们连接数据集之前,我们需要将它们发送到同一台物理机器上。我们将使用以下测试。
我们需要创建UserData,这是一个我们已经见过的案例类。它有用户 ID 和数据。我们有用户 ID,即user_1,user_2和user_4:
test("example of operation that is causing shuffle") {
import spark.sqlContext.implicits._
val userData =
spark.sparkContext.makeRDD(List(
UserData("user_1", "1"),
UserData("user_2", "2"),
UserData("user_4", "200")
)).toDS()
然后我们创建一些类似于用户 ID(user_1,user_2和user_3)的交易数据:
val transactionData =
spark.sparkContext.makeRDD(List(
UserTransaction("user_1", 100),
UserTransaction("user_2", 300),
UserTransaction("user_3", 1300)
)).toDS()
我们使用joinWith在UserData上的交易,使用UserData和transactionData的userID列。由于我们发出了inner连接,结果有两个元素,因为记录和交易之间有连接,即UserData和UserTransaction。但是,UserData没有交易,Usertransaction没有用户数据:
//shuffle: userData can stay on the current executors, but data from
//transactionData needs to be send to those executors according to joinColumn
//causing shuffle
//when
val res: Dataset[(UserData, UserTransaction)]
= userData.joinWith(transactionData, userData("userId") === transactionData("userId"), "inner")
当我们连接数据时,数据没有分区,因为这是 Spark 的一些随机数据。它无法知道用户 ID 列是分区键,因为它无法猜测。由于它没有预分区,要连接来自两个数据集的数据,需要将数据从用户 ID 发送到执行器。因此,由于数据没有分区,将会有大量数据从执行器洗牌。
让我们解释查询,执行断言,并通过启动测试显示结果:
//then
res.show()
assert(res.count() == 2)
}
}
我们可以看到我们的结果如下:
+------------+-------------+
| _1 | _2|
+----------- +-------------+
+ [user_1,1] | [user_1,100]|
| [user_2,2] | [user_2,300]|
+------------+-------------+
我们有[user_1,1]和[user_1,100],即userID和userTransaction。看起来连接工作正常,但让我们看看物理参数。我们使用SortMergeJoin对第一个数据集和第二个数据集使用userID,然后我们使用Sort和hashPartitioning。
在前一节中,检测过程中的洗牌,我们使用了partition方法,该方法在底层使用了hashPartitioning。虽然我们使用了join,但我们仍然需要使用哈希分区,因为我们的数据没有正确分区。因此,我们需要对第一个数据集进行分区,因为会有大量的洗牌,然后我们需要对第二个 DataFrame 做完全相同的事情。再次,洗牌将会进行两次,一旦数据根据连接字段进行分区,连接就可以在执行器本地进行。
在执行物理计划后,将对记录进行断言,指出userID用户数据一与用户交易userID一位于同一执行器上。没有hashPartitioning,就没有保证,因此我们需要进行分区。
在下一节中,我们将学习如何更改具有广泛依赖的作业的设计,因此我们将看到如何在连接两个数据集时避免不必要的洗牌。
更改具有广泛依赖的作业的设计
在本节中,我们将更改在未分区数据上执行join的作业。我们将更改具有广泛依赖的作业的设计。
在本节中,我们将涵盖以下主题:
-
使用公共分区键对 DataFrame 进行重新分区
-
理解使用预分区数据进行连接
-
理解我们如何避免洗牌
我们将在 DataFrame 上使用repartition方法,使用一个公共分区键。我们发现,当进行连接时,重新分区会在底层发生。但通常,在使用 Spark 时,我们希望在 DataFrame 上执行多个操作。因此,当我们与其他数据集执行连接时,hashPartitioning将需要再次执行。如果我们在加载数据时进行分区,我们将避免再次分区。
在这里,我们有我们的示例测试用例,其中包含我们之前在 Apache Spark 的“导致洗牌的测试操作”部分中使用的数据。我们有UserData,其中包含三条用户 ID 的记录 - user_1,user_2和user_4 - 以及UserTransaction数据,其中包含用户 ID - 即user_1,user_2,user_3:
test("example of operation that is causing shuffle") {
import spark.sqlContext.implicits._
val userData =
spark.sparkContext.makeRDD(List(
UserData("user_1", "1"),
UserData("user_2", "2"),
UserData("user_4", "200")
)).toDS()
然后,我们需要对数据进行repartition,这是要做的第一件非常重要的事情。我们使用userId列来重新分区我们的userData:
val repartitionedUserData = userData.repartition(userData("userId"))
然后,我们将使用userId列重新分区我们的数据,这次是针对transactionData:
val repartitionedTransactionData = transactionData.repartition(transactionData("userId"))
一旦我们重新分区了我们的数据,我们就可以确保具有相同分区键的任何数据 - 在本例中是userId - 将落在同一个执行器上。因此,我们的重新分区数据将不会有洗牌,连接将更快。最终,我们能够进行连接,但这次我们连接的是预分区的数据:
//when
//data is already partitioned using join-column. Don't need to shuffle
val res: Dataset[(UserData, UserTransaction)]
= repartitionedUserData.joinWith(repartitionedTransactionData, userData("userId") === transactionData("userId"), "inner")
我们可以使用以下代码显示我们的结果:
//then
res.show()
assert(res.count() == 2)
}
}
输出显示在以下截图中:
在上述截图中,我们有用户 ID 和交易的物理计划。我们对用户 ID 数据和交易数据的用户 ID 列执行了哈希分区。在连接数据之后,我们可以看到数据是正确的,并且连接有一个物理计划。
这次,物理计划有点不同。
我们有一个SortMergeJoin操作,并且我们正在对我们的数据进行排序,这些数据在我们执行引擎的上一步已经预分区。这样,我们的 Spark 引擎将执行排序合并连接,无需进行哈希连接。它将正确排序数据,连接将更快。
在下一节中,我们将使用keyBy()操作来进一步减少洗牌。
使用 keyBy()操作来减少洗牌
在本节中,我们将使用keyBy()操作来减少洗牌。我们将涵盖以下主题:
-
加载随机分区的数据
-
尝试以有意义的方式预分区数据
-
利用
keyBy()函数
我们将加载随机分区的数据,但这次使用 RDD API。我们将以有意义的方式重新分区数据,并提取底层正在进行的信息,类似于 DataFrame 和 Dataset API。我们将学习如何利用keyBy()函数为我们的数据提供一些结构,并在 RDD API 中引起预分区。
本节中我们将使用以下测试。我们创建两个随机输入记录。第一条记录有一个随机用户 ID,user_1,第二条记录有一个随机用户 ID,user_1,第三条记录有一个随机用户 ID,user_2:
test("Should use keyBy to distribute traffic properly"){
//given
val rdd = spark.sparkContext.makeRDD(List(
InputRecord("1234-3456-1235-1234", "user_1"),
InputRecord("1123-3456-1235-1234", "user_1"),
InputRecord("1123-3456-1235-9999", "user_2")
))
我们将使用rdd.toDebugString提取 Spark 底层发生的情况:
println(rdd.toDebugString)
此时,我们的数据是随机分布的,用户 ID 字段的记录可能在不同的执行器上,因为 Spark 执行引擎无法猜测user_1是否对我们有意义,或者1234-3456-1235-1234是否有意义。我们知道1234-3456-1235-1234不是一个有意义的键,而是一个唯一标识符。将该字段用作分区键将给我们一个随机分布和大量的洗牌,因为在使用唯一字段作为分区键时没有数据局部性。
Spark 无法知道相同用户 ID 的数据将落在同一个执行器上,这就是为什么在分区数据时我们需要使用用户 ID 字段,即user_1、user_1或user_2。为了在 RDD API 中实现这一点,我们可以在我们的数据中使用keyBy(_.userId),但这次它将改变 RDD 类型:
val res = rdd.keyBy(_.userId)
如果我们检查 RDD 类型,我们会发现这次,RDD 不是输入记录,而是字符串和输入记录的 RDD。字符串是我们在这里期望的字段类型,即userId。我们还将通过在结果上使用toDebugString来提取有关keyBy()函数的信息:
println(res.toDebugString)
一旦我们使用keyBy(),相同用户 ID 的所有记录都将落在同一个执行器上。正如我们所讨论的,这可能是危险的,因为如果我们有一个倾斜的键,这意味着我们有一个具有非常高基数的键,我们可能会耗尽内存。此外,结果上的所有操作都将按键进行,因此我们将在预分区数据上进行操作:
res.collect()
让我们开始这个测试。输出将如下所示:
我们可以看到我们的第一个调试字符串非常简单,我们只有 RDD 上的集合,但第二个有点不同。我们有一个keyBy()方法,并在其下面创建了一个 RDD。我们有来自第一部分的子 RDD 和父 RDD,即测试在 Apache Spark 中引起洗牌的操作,当我们扩展了 RDD 时。这是由keyBy()方法发出的父子链。
在下一节中,我们将使用自定义分区器进一步减少洗牌。
使用自定义分区器来减少洗牌
在本节中,我们将使用自定义分区器来减少洗牌。我们将涵盖以下主题:
-
实现自定义分区器
-
使用
partitionBy方法在 Spark 上使用分区器 -
验证我们的数据是否被正确分区
我们将使用自定义逻辑实现自定义分区器,该分区器将对数据进行分区。它将告诉 Spark 每条记录应该落在哪个执行器上。我们将使用 Spark 上的partitionBy方法。最后,我们将验证我们的数据是否被正确分区。为了测试的目的,我们假设有两个执行器:
import com.tomekl007.UserTransaction
import org.apache.spark.sql.SparkSession
import org.apache.spark.{Partitioner, SparkContext}
import org.scalatest.FunSuite
import org.scalatest.Matchers._
class CustomPartitioner extends FunSuite {
val spark: SparkContext = SparkSession.builder().master("local[2]").getOrCreate().sparkContext
test("should use custom partitioner") {
//given
val numberOfExecutors = 2
假设我们想将我们的数据均匀地分成2个执行器,并且具有相同键的数据实例将落在同一个执行器上。因此,我们的输入数据是一个UserTransactions列表:"a","b","a","b"和"c"。值并不那么重要,但我们需要记住它们以便稍后测试行为。给定UserTransactions的amount分别为100,101,202,1和55:
val data = spark
.parallelize(List(
UserTransaction("a", 100),
UserTransaction("b", 101),
UserTransaction("a", 202),
UserTransaction("b", 1),
UserTransaction("c", 55)
当我们使用keyBy时,(_.userId)被传递给我们的分区器,因此当我们发出partitionBy时,我们需要扩展override方法:
).keyBy(_.userId)
.partitionBy(new Partitioner {
override def numPartitions: Int = numberOfExecutors
getPartition方法接受一个key,它将是userId。键将在这里传递,类型将是字符串:
override def getPartition(key: Any): Int = {
key.hashCode % numberOfExecutors
}
})
这些方法的签名是Any,所以我们需要override它,并且还需要覆盖分区的数量。
然后我们打印我们的两个分区,numPartitions返回值为2:
println(data.partitions.length)
getPartition非常简单,因为它获取hashCode和numberOfExecutors的模块。它确保相同的键将落在同一个执行器上。
然后,我们将为各自的分区映射每个分区,因为我们得到一个迭代器。在这里,我们正在为测试目的获取amount:
//when
val res = data.mapPartitionsLong.map(_.amount)
).collect().toList
最后,我们断言55,100,202,101和1;顺序是随机的,所以不需要关心顺序:
//then
res should contain theSameElementsAs List(55, 100, 202, 101, 1)
}
}
如果我们仍然希望,我们应该使用sortBy方法。让我们开始这个测试,看看我们的自定义分区器是否按预期工作。现在,我们可以开始了。我们有2个分区,所以它按预期工作,如下面的截图所示:
总结
在本章中,我们学习了如何检测过程中的洗牌。我们涵盖了在 Apache Spark 中导致洗牌的测试操作。我们还学习了如何在 RDD 中使用分区。如果需要分区数据,了解如何使用 API 是很重要的,因为 RDD 仍然被广泛使用,所以我们使用keyBy操作来减少洗牌。我们还学习了如何使用自定义分区器来减少洗牌。
在下一章中,我们将学习如何使用 Spark API 以正确的格式保存数据。