Spark-3.5.7文档2 - RDD 编程指南

概述

从高层次来看,每个 Spark 应用程序都包含一个驱动程序,该程序运行用户的主函数并在集群上执行各种并行操作。Spark 提供的核心抽象是弹性分布式数据集(RDD),即一种分布在集群节点间的元素集合,可被并行操作。RDD 可通过以下方式创建:从 Hadoop 文件系统(或任何其他 Hadoop 支持的文件系统)中的文件起步,或基于驱动程序中的现有 Scala 集合进行转换生成。用户也可要求 Spark 将 RDD 持久化到内存中,使其能在并行操作间高效复用。最后,RDD 能自动从节点故障中恢复。

Spark 的第二个抽象是可在并行操作中使用的共享变量。默认情况下,当 Spark 在不同节点上以任务集形式并行运行函数时,它会将函数中使用的每个变量副本分别发送给每个任务。有时需要在任务之间或任务与驱动程序之间共享变量。Spark 支持两种类型的共享变量:广播变量(可用于在所有节点的内存中缓存值)和累加器(仅支持"累加"操作的变量,例如计数器和求和器)。

本指南将通过 Spark 支持的每种语言展示这些特性。若您启动 Spark 的交互式 shell(Scala shell 使用 bin/spark-shell,Python 使用 bin/pyspark)进行跟随操作,将更易于理解。

与 Spark 链接

Python方式

Spark 3.5.7 支持 Python 3.8 及以上版本。它可以使用标准的 CPython 解释器,因此像 NumPy 这样的 C 库也能正常使用。同时,它也兼容 PyPy 7.3.6 及以上版本。

在 Python 中运行 Spark 应用程序有两种方式:

使用 bin/spark-submit 脚本(该脚本在运行时已包含 Spark 环境);

通过将其添加到你的 setup.py 配置中,例如:

python 复制代码
    install_requires=[
        'pyspark==3.5.7'
    ]

若要在不通过 pip 安装 PySpark 的情况下运行 Python 版 Spark 应用程序,请使用 Spark 目录下的 bin/spark-submit 脚本。该脚本将加载 Spark 的 Java/Scala 库,并允许您向集群提交应用程序。您也可以使用 bin/pyspark 启动交互式 Python shell。

如需访问 HDFS 数据,您需要使用与您 HDFS 版本关联的 PySpark 构建版本。Spark 官网也为常见 HDFS 版本提供了预编译包。

最后,您需要在程序中导入部分 Spark 类。添加以下代码行:

python 复制代码
from pyspark import SparkContext, SparkConf

PySpark 要求驱动节点和工作节点使用相同次要版本的 Python。默认情况下会使用 PATH 中的 Python 版本,您也可以通过 PYSPARK_PYTHON 环境变量指定所需版本,例如:

shell 复制代码
$ PYSPARK_PYTHON=python3.8 bin/pyspark
$ PYSPARK_PYTHON=/path-to-your-pypy/pypy bin/spark-submit examples/src/main/python/pi.py

Scala 方式

Spark 3.5.7 默认基于 Scala 2.12 构建和分发(Spark 也可编译为支持其他 Scala 版本)。使用 Scala 编写应用程序时,需确保 Scala 版本兼容(例如 2.12.X)。

开发 Spark 应用程序需通过 Maven 添加 Spark 依赖。Spark 可通过 Maven Central 仓库获取,坐标如下:

scala 复制代码
groupId = org.apache.spark
artifactId = spark-core_2.12
version = 3.5.7

此外,如需访问 HDFS 集群,需添加与您 HDFS 版本对应的 hadoop-client 依赖:

scala 复制代码
groupId = org.apache.hadoop
artifactId = hadoop-client
version = <您的 HDFS 版本>

最后,在程序中导入必要的 Spark 类。添加以下代码行:

scala 复制代码
import org.apache.spark.SparkContext
import org.apache.spark.SparkConf

(在 Spark 1.3.0 之前,需显式导入 org.apache.spark.SparkContext._ 以启用必要的隐式转换。)

Java 方式

Spark 3.5.7 支持使用 lambda 表达式简洁地编写函数,您也可以使用 org.apache.spark.api.java.function 包中的类。

请注意,Spark 2.2.0 已移除对 Java 7 的支持。

使用 Java 编写 Spark 应用程序时,需添加 Spark 依赖。Spark 可通过 Maven Central 仓库获取,坐标如下:

java 复制代码
groupId = org.apache.spark
artifactId = spark-core_2.12
version = 3.5.7

此外,如需访问 HDFS 集群,需添加与您 HDFS 版本对应的 hadoop-client 依赖:

java 复制代码
groupId = org.apache.hadoop
artifactId = hadoop-client
version = <您的 HDFS 版本>

最后,在程序中导入必要的 Spark 类。添加以下代码行:

java 复制代码
import org.apache.spark.api.java.JavaSparkContext;
import org.apache.spark.api.java.JavaRDD;
import org.apache.spark.SparkConf;

初始化 Spark

Python方式

Spark 程序的首要步骤是创建一个 SparkContext 对象,该对象用于告知 Spark 如何访问集群。创建 SparkContext 前,需先构建一个包含应用程序配置信息的 SparkConf 对象。

python 复制代码
conf = SparkConf().setAppName(appName).setMaster(master)
sc = SparkContext(conf=conf)

appName 参数是应用程序在集群 UI 上显示的名称。master 参数可以是 Spark、Mesos 或 YARN 集群的 URL,或特殊的 "local" 字符串(表示以本地模式运行)。

实际部署到集群时,通常不应在代码中硬编码 master 值,而是通过 spark-submit 启动应用并动态获取该参数。但对于本地测试和单元测试,可传递 "local" 来在进程内运行 Spark。

Scala 方式

Spark 程序的首要步骤是创建一个 SparkContext 对象,该对象用于告知 Spark 如何访问集群。创建 SparkContext 前,需先构建一个包含应用程序配置信息的 SparkConf 对象。

每个 JVM 中只能有一个活跃的 SparkContext。在创建新的 SparkContext 前,必须通过 stop() 方法关闭当前活跃的实例。

scala 复制代码
val conf = new SparkConf().setAppName(appName).setMaster(master)
new SparkContext(conf)

appName 参数是应用程序在集群 UI 上显示的名称。master 参数可以是 Spark、Mesos 或 YARN 集群的 URL,或特殊的 "local" 字符串(表示以本地模式运行)。

实际部署到集群时,通常不应在代码中硬编码 master 值,而是通过 spark-submit 启动应用并动态获取该参数。但对于本地测试和单元测试,可传递 "local" 来在进程内运行 Spark。

Java 方式

Spark 程序的首要步骤是创建一个 JavaSparkContext 对象,该对象用于告知 Spark 如何访问集群。创建 SparkContext 前,需先构建一个包含应用程序配置信息的 SparkConf 对象。

java 复制代码
SparkConf conf = new SparkConf().setAppName(appName).setMaster(master);
JavaSparkContext sc = new JavaSparkContext(conf);

appName 参数是应用程序在集群 UI 上显示的名称。master 参数可以是 Spark、Mesos 或 YARN 集群的 URL,或特殊的 "local" 字符串(表示以本地模式运行)。

实际部署到集群时,通常不应在代码中硬编码 master 值,而是通过 spark-submit 启动应用并动态获取该参数。但对于本地测试和单元测试,可传递 "local" 来在进程内运行 Spark。

使用 Shell

Python 方式

在 PySpark shell 中,系统已预先为您创建了一个特殊的解释器感知型 SparkContext,该对象存储在名为 sc 的变量中。此时自行创建 SparkContext 将无法正常工作。

您可通过 --master 参数指定上下文连接的集群主节点,并通过 --py-files 参数传递逗号分隔的列表,将 Python 的 .zip、.egg 或 .py 文件添加到运行时路径。关于第三方 Python 依赖管理,请参阅 Python 包管理文档。

若需在 shell 会话中添加依赖(如 Spark 包),可通过 --packages 参数提供以逗号分隔的 Maven 坐标。依赖可能存在的其他仓库(如 Sonatype)可通过 --repositories 参数传递。例如,若要在恰好四个核心上运行 bin/pyspark,可使用以下命令:

shell 复制代码
$ ./bin/pyspark --master local[4]

或者,若要将 code.py 添加到搜索路径(以便后续能够导入 code 模块),可使用以下命令:

shell 复制代码
$ ./bin/pyspark --master local[4] --py-files code.py

若要查看完整的选项列表,可运行 pyspark --help。实际上,pyspark 在底层调用了更通用的 spark-submit 脚本。

此外,还可以在增强型 Python 解释器 IPython 中启动 PySpark shell。PySpark 兼容 IPython 1.0.0 及更高版本。如需使用 IPython,请在运行 bin/pyspark 时将 PYSPARK_DRIVER_PYTHON 变量设置为 ipython:

shell 复制代码
$ PYSPARK_DRIVER_PYTHON=ipython ./bin/pyspark

要使用 Jupyter notebook(此前称为 IPython notebook),

shell 复制代码
$ PYSPARK_DRIVER_PYTHON=jupyter PYSPARK_DRIVER_PYTHON_OPTS=notebook ./bin/pyspark

您可以通过设置 PYSPARK_DRIVER_PYTHON_OPTS 来自定义 ipython 或 jupyter 命令。

启动 Jupyter Notebook 服务器后,您可以从 "Files" 标签页创建新笔记本。在笔记本中开始使用 Jupyter notebook 尝试 Spark 之前,可以输入命令 %pylab inline 作为笔记本的一部分。

Scala 方式

在 Spark shell 中,系统已预先为您创建了一个特殊的解释器感知型 SparkContext,该对象存储在名为 sc 的变量中。此时自行创建 SparkContext 将无法正常工作。

您可通过 --master 参数指定上下文连接的集群主节点,并通过 --jars 参数传递逗号分隔的列表,将 JAR 文件添加到类路径中。

若需在 shell 会话中添加依赖(如 Spark 包),可通过 --packages 参数提供以逗号分隔的 Maven 坐标。依赖可能存在的其他仓库(如 Sonatype)可通过 --repositories 参数传递。例如,若要在恰好四个核心上运行 bin/spark-shell,可使用以下命令:

shell 复制代码
$ ./bin/spark-shell --master local[4]

或者,若要将 code.jar 添加到其类路径中,可使用以下命令:

shell 复制代码
$ ./bin/spark-shell --master local[4] --jars code.jar

若要通过 Maven 坐标添加依赖项:

shell 复制代码
$ ./bin/spark-shell --master local[4] --packages "org.example:example:0.1"

若要查看完整的选项列表,可运行 spark-shell --help。实际上,spark-shell 在底层调用了更通用的 spark-submit 脚本。

弹性分布式数据集(RDDs)

Spark 的核心概念是弹性分布式数据集(RDD),它是一个可并行操作的容错元素集合。创建 RDD 有两种方式:

  • 在驱动程序中并行化现有集合;

  • 引用外部存储系统(如共享文件系统、HDFS、HBase 或任何提供 Hadoop InputFormat 的数据源)中的数据集。

并行化集合

Python 方式

并行化集合是通过在驱动程序中的现有可迭代对象或集合上调用 SparkContext 的 parallelize 方法创建的。集合的元素会被复制以形成一个可并行操作的分布式数据集。例如,以下代码展示了如何创建一个包含数字 1 到 5 的并行化集合:

python 复制代码
data = [1, 2, 3, 4, 5]
distData = sc.parallelize(data)

创建后,分布式数据集(distData)即可进行并行操作。例如,我们可以调用 distData.reduce(lambda a, b: a + b) 来对列表中的元素求和。我们将在后续内容中介绍分布式数据集的操作。

并行集合的一个重要参数是数据集的分区数量,即将数据集切分成多少个部分。Spark 会为集群中的每个分区运行一个任务。通常,您希望为集群中的每个 CPU 设置 2 到 4 个分区。默认情况下,Spark 会尝试根据您的集群自动设置分区数。但您也可以通过将分区数作为第二个参数传递给 parallelize 来手动设置(例如 sc.parallelize(data, 10))。

注意:代码中的某些地方使用术语 slices(分区的同义词)以保持向后兼容性。

Scala 方式

并行化集合是通过在驱动程序中的现有集合(Scala Seq)上调用 SparkContext 的 parallelize 方法创建的。集合的元素会被复制以形成一个可并行操作的分布式数据集。例如,以下代码展示了如何创建一个包含数字 1 到 5 的并行化集合:

scala 复制代码
val data = Array(1, 2, 3, 4, 5)
val distData = sc.parallelize(data)

创建后,分布式数据集(distData)即可进行并行操作。例如,我们可以调用 distData.reduce((a, b) => a + b) 来对数组中的元素求和。我们将在后续内容中介绍分布式数据集的操作。

并行集合的一个重要参数是数据集的分区数量,即将数据集切分成多少个部分。Spark 会为集群中的每个分区运行一个任务。通常,您希望为集群中的每个 CPU 设置 2 到 4 个分区。默认情况下,Spark 会尝试根据您的集群自动设置分区数。但您也可以通过将分区数作为第二个参数传递给 parallelize 来手动设置(例如 sc.parallelize(data, 10))。

注意:代码中的某些地方使用术语 slices(分区的同义词)以保持向后兼容性。

Java 方式

并行化集合是通过在驱动程序中的现有集合上调用 JavaSparkContext 的 parallelize 方法创建的。集合的元素会被复制以形成一个可并行操作的分布式数据集。例如,以下代码展示了如何创建一个包含数字 1 到 5 的并行化集合:

java 复制代码
List<Integer> data = Arrays.asList(1, 2, 3, 4, 5);
JavaRDD<Integer> distData = sc.parallelize(data);

创建后,分布式数据集(distData)即可进行并行操作。例如,我们可以调用 distData.reduce((a, b) -> a + b) 来对列表中的元素求和。我们将在后续内容中介绍分布式数据集的操作。

并行集合的一个重要参数是数据集的分区数量,即将数据集切分成多少个部分。Spark 会为集群中的每个分区运行一个任务。通常,您希望为集群中的每个 CPU 设置 2 到 4 个分区。默认情况下,Spark 会尝试根据您的集群自动设置分区数。但您也可以通过将分区数作为第二个参数传递给 parallelize 来手动设置(例如 sc.parallelize(data, 10))。

注意:代码中的某些地方使用术语 slices(分区的同义词)以保持向后兼容性。

外部数据集

Python 方式

PySpark可以从Hadoop支持的任何存储源创建分布式数据集,包括本地文件系统、HDFS、Cassandra、HBase、Amazon S3等。Spark支持文本文件、SequenceFiles以及任何其他Hadoop输入格式。

文本文件RDD可以通过SparkContext的textFile方法创建。该方法接收文件URI(可以是本地路径,或hdfs://、s3a://等URI)并将其按行读取为集合。以下是调用示例:

python 复制代码
>>> distFile = sc.textFile("data.txt")

创建后,distFile可被数据集操作处理。例如,我们可以通过map和reduce操作累加所有行的长度:distFile.map(lambda s: len(s)).reduce(lambda a, b: a + b)。

关于Spark读取文件的注意事项:

若使用本地文件系统路径,该文件必须在所有工作节点的相同路径下可访问。需将文件复制到所有工作节点,或使用网络挂载的共享文件系统。

Spark所有基于文件的输入方法(包括textFile)支持对目录、压缩文件和通配符进行操作。例如:textFile("/my/directory")、textFile("/my/directory/.txt") 和 textFile("/my/directory/ .gz")。

textFile方法支持可选的第二参数用于控制文件分区数。默认情况下,Spark为文件的每个块创建一个分区(HDFS中默认为128MB),但可通过传递更大值要求更多分区。注意分区数不能少于块数。

除文本文件外,Spark的Python API还支持其他数据格式:

SparkContext.wholeTextFiles 可读取包含多个小文本文件的目录,并以(文件名,内容)键值对形式返回每个文件。这与textFile(按行返回记录)形成对比。

RDD.saveAsPickleFile 和 SparkContext.pickleFile 支持将RDD以Python对象序列化的简易格式保存。序列化时默认采用批量处理,批次大小为10。

SequenceFile和Hadoop输入/输出格式

(注意:此功能目前标记为实验性,面向高级用户。未来可能被基于Spark SQL的读写支持取代,届时建议优先使用Spark SQL)

Writable支持

PySpark的SequenceFile支持在Java中加载键值对RDD,将Writable转换为基本Java类型,并通过pickle序列化Java对象。当将键值对RDD保存为SequenceFile时,PySpark执行反向操作:将Python对象反序列化为Java对象,再转换为Writable。以下Writable类型会自动转换:

Writable Type Python Type
Text str
IntWritable int
FloatWritable float
DoubleWritable float
BooleanWritable bool
BytesWritable bytearray
NullWritable None
MapWritable dict

数组无法直接处理。用户需要在读写时指定自定义的ArrayWritable子类型。写入时,用户还需指定将数组转换为自定义ArrayWritable子类型的转换器。读取时,默认转换器会将自定义ArrayWritable子类型转换为Java Object[],随后序列化为Python元组。若要获取基本类型数组对应的Python array.array,用户需指定自定义转换器。

保存与读取SequenceFiles

与文本文件类似,可通过指定路径保存和读取SequenceFiles。键值类型可以指定,但对于标准Writable类型则无需显式声明。

python 复制代码
>>> rdd = sc.parallelize(range(1, 4)).map(lambda x: (x, "a" * x))
>>> rdd.saveAsSequenceFile("path/to/file")
>>> sorted(sc.sequenceFile("path/to/file").collect())
[(1, u'a'), (2, u'aa'), (3, u'aaa')]

保存与读取其他Hadoop输入/输出格式

PySpark同样支持读写任何Hadoop InputFormat或OutputFormat(包括新旧版Hadoop MapReduce API)。必要时,可将Hadoop配置以Python字典形式传入。以下是通过Elasticsearch ESInputFormat的调用示例:

python 复制代码
$ ./bin/pyspark --jars /path/to/elasticsearch-hadoop.jar
>>> conf = {"es.resource" : "index/type"}  # assume Elasticsearch is running on localhost defaults
>>> rdd = sc.newAPIHadoopRDD("org.elasticsearch.hadoop.mr.EsInputFormat",
                             "org.apache.hadoop.io.NullWritable",
                             "org.elasticsearch.hadoop.mr.LinkedMapWritable",
                             conf=conf)
>>> rdd.first()  # the result is a MapWritable that is converted to a Python dict
(u'Elasticsearch ID',
 {u'field1': True,
  u'field2': u'Some Text',
  u'field3': 12345})

需要注意的是,若InputFormat仅依赖于Hadoop配置和/或输入路径,且键值类型可根据上表轻松转换,则此方法能良好适用于此类场景。

如果涉及自定义序列化的二进制数据(例如从Cassandra/HBase加载数据),则需要先在Scala/Java端将数据转换为可由pickle序列化器处理的格式。为此提供了Converter特质(trait),只需扩展该特质并在convert方法中实现转换逻辑。请确保此类以及访问InputFormat所需的依赖项均打包至Spark作业jar包,并包含在PySpark类路径中。

关于使用自定义转换器操作Cassandra/HBase InputFormat和OutputFormat的示例,可参考Python示例及Converter示例。

Scala 方式

Spark可以从Hadoop支持的任何存储源创建分布式数据集,包括本地文件系统、HDFS、Cassandra、HBase、Amazon S3等。Spark支持文本文件、SequenceFiles以及任何其他Hadoop输入格式。

文本文件RDD可以通过SparkContext的textFile方法创建。该方法接收文件URI(可以是本地路径,或hdfs://、s3a://等URI)并将其按行读取为集合。以下是调用示例:

scala 复制代码
scala> val distFile = sc.textFile("data.txt")
distFile: org.apache.spark.rdd.RDD[String] = data.txt MapPartitionsRDD[10] at textFile at <console>:26

创建后,distFile可被数据集操作处理。例如,我们可以通过map和reduce操作累加所有行的长度:distFile.map(s => s.length).reduce((a, b) => a + b)

关于Spark读取文件的注意事项:

  • 若使用本地文件系统路径,该文件必须在所有工作节点的相同路径下可访问。需将文件复制到所有工作节点,或使用网络挂载的共享文件系统。
  • Spark所有基于文件的输入方法(包括textFile)支持对目录、压缩文件和通配符进行操作。例如:textFile("/my/directory")textFile("/my/directory/*.txt")textFile("/my/directory/*.gz")。读取多个文件时,分区的顺序取决于文件系统返回文件的顺序(例如不一定按路径字典序排列),分区内元素保持其在原文件中的顺序。
  • textFile方法支持可选的第二参数用于控制文件分区数。默认情况下,Spark为文件的每个块创建一个分区(HDFS中默认为128MB),但可通过传递更大值要求更多分区。注意分区数不能少于块数。

除文本文件外,Spark的Scala API还支持其他数据格式:

  • SparkContext.wholeTextFiles 可读取包含多个小文本文件的目录,并以(文件名,内容)键值对形式返回每个文件。这与textFile(按行返回记录)形成对比。分区由数据本地性决定,有时可能导致分区过少,此时可通过可选的第二参数控制最小分区数。
  • 对于SequenceFiles,可使用SparkContext.sequenceFile[K, V]方法,其中K和V需为Hadoop Writable接口的子类(如IntWritable和Text)。Spark还支持为常见Writable类型指定原生类型,例如sequenceFile[Int, String]会自动读取IntWritables和Texts。
  • 对于其他Hadoop InputFormats,可使用SparkContext.hadoopRDD方法(需指定JobConf、输入格式类及键值类型),或使用SparkContext.newAPIHadoopRDD(基于新版MapReduce API)。
  • RDD.saveAsObjectFileSparkContext.objectFile 支持以序列化Java对象的简易格式保存RDD。虽然效率不如Avro等专业格式,但提供了保存任意RDD的便捷方式。
Java 方式

Spark可以从Hadoop支持的任何存储源创建分布式数据集,包括本地文件系统、HDFS、Cassandra、HBase、Amazon S3等。Spark支持文本文件、SequenceFiles以及任何其他Hadoop输入格式。

文本文件RDD可以通过SparkContext的textFile方法创建。该方法接收文件URI(可以是本地路径,或hdfs://、s3a://等URI)并将其按行读取为集合。以下是调用示例:

java 复制代码
JavaRDD<String> distFile = sc.textFile("data.txt");

创建后,distFile可被数据集操作处理。例如,我们可以通过map和reduce操作累加所有行的长度:distFile.map(s -> s.length()).reduce((a, b) -> a + b)

关于Spark读取文件的注意事项:

  • 若使用本地文件系统路径,该文件必须在所有工作节点的相同路径下可访问。需将文件复制到所有工作节点,或使用网络挂载的共享文件系统。
  • Spark所有基于文件的输入方法(包括textFile)支持对目录、压缩文件和通配符进行操作。例如:textFile("/my/directory")textFile("/my/directory/*.txt")textFile("/my/directory/*.gz")
  • textFile方法支持可选的第二参数用于控制文件分区数。默认情况下,Spark为文件的每个块创建一个分区(HDFS中默认为128MB),但可通过传递更大值要求更多分区。注意分区数不能少于块数。

除文本文件外,Spark的Java API还支持其他数据格式:

  • JavaSparkContext.wholeTextFiles 可读取包含多个小文本文件的目录,并以(文件名,内容)键值对形式返回每个文件。这与textFile(按行返回记录)形成对比。
  • 对于SequenceFiles,可使用SparkContext.sequenceFile[K, V]方法,其中K和V需为Hadoop Writable接口的子类(如IntWritable和Text)。
  • 对于其他Hadoop InputFormats,可使用JavaSparkContext.hadoopRDD方法(需指定JobConf、输入格式类及键值类型),或使用JavaSparkContext.newAPIHadoopRDD(基于新版MapReduce API)。
  • JavaRDD.saveAsObjectFileJavaSparkContext.objectFile 支持以序列化Java对象的简易格式保存RDD。虽然效率不如Avro等专业格式,但提供了保存任意RDD的便捷方式。

RDD 操作

RDD支持两种类型的操作:转换(transformations)和行动(actions)。转换操作会从现有数据集创建新数据集,而行动操作在对数据集进行计算后向驱动程序返回结果值。例如,map是一种转换操作,它通过函数处理每个数据集元素,并返回代表结果的新RDD;reduce则是一种行动操作,它使用特定函数聚合RDD的所有元素,并将最终结果返回给驱动程序(虽然也存在返回分布式数据集的并行操作reduceByKey)。

Spark中的所有转换操作都是惰性的,它们不会立即计算结果,而是仅记录对基础数据集(如文件)应用的转换操作。只有当行动操作需要将结果返回给驱动程序时,转换操作才会真正执行计算。这种设计让Spark能够更高效地运行。例如,系统可以识别出通过map创建的数据集将用于reduce操作,此时只需向驱动程序返回reduce的结果,而无需传递庞大的map处理后的数据集。

默认情况下,每次对转换后的RDD执行行动操作时都可能重新计算该RDD。但您可以使用persist(或cache)方法将RDD持久化到内存中,这样Spark会在集群中保留元素内容,显著提升后续查询速度。Spark还支持将RDD持久化到磁盘,或在多个节点间进行备份存储。

基础操作
Python 方式

RDD基础示例

以下简单程序演示RDD基本操作:

python 复制代码
lines = sc.textFile("data.txt")
lineLengths = lines.map(lambda s: len(s))
totalLength = lineLengths.reduce(lambda a, b: a + b)
  • 第一行从外部文件定义基础RDD。该数据集并未立即加载到内存或执行操作:lines仅为指向文件的引用。
  • 第二行通过map转换定义lineLengths。由于惰性计算机制,lineLengths不会立即被计算。
  • 最后执行行动操作reduce。此时Spark将计算拆分为任务在多个机器上运行,每台机器既执行本地map操作也进行局部聚合,最终仅将结果返回驱动程序。

若后续需要重复使用lineLengths,可在reduce前添加:

python 复制代码
lineLengths.persist()

这将在首次计算后将lineLengths持久化到内存中。

Scala 方式

RDD基础示例

以下简单程序演示RDD基本操作:

scala 复制代码
val lines = sc.textFile("data.txt")
val lineLengths = lines.map(s => s.length)
val totalLength = lineLengths.reduce((a, b) => a + b)
  • 第一行从外部文件定义基础RDD。该数据集并未立即加载到内存或执行操作:lines仅为指向文件的引用。
  • 第二行通过map转换定义lineLengths。由于惰性计算机制,lineLengths不会立即被计算。
  • 最后执行行动操作reduce。此时Spark将计算拆分为任务在多个机器上运行,每台机器既执行本地map操作也进行局部聚合,最终仅将结果返回驱动程序。

若后续需要重复使用lineLengths,可在reduce前添加:

scala 复制代码
lineLengths.persist()

这将在首次计算后将lineLengths持久化到内存中。

Java 方式

RDD基础示例

以下简单程序演示RDD基本操作:

java 复制代码
JavaRDD<String> lines = sc.textFile("data.txt");
JavaRDD<Integer> lineLengths = lines.map(s -> s.length());
int totalLength = lineLengths.reduce((a, b) -> a + b);
  • 第一行从外部文件定义基础RDD。该数据集并未立即加载到内存或执行操作:lines仅为指向文件的引用。
  • 第二行通过map转换定义lineLengths。由于惰性计算机制,lineLengths不会立即被计算。
  • 最后执行行动操作reduce。此时Spark将计算拆分为任务在多个机器上运行,每台机器既执行本地map操作也进行局部聚合,最终仅将结果返回驱动程序。

若后续需要重复使用lineLengths,可在reduce前添加:

java 复制代码
lineLengths.persist(StorageLevel.MEMORY_ONLY());

这将在首次计算后将lineLengths持久化到内存中。

向 Spark 传递函数
Python 方式

Spark的API高度依赖将驱动程序中的函数传递到集群上运行。推荐以下三种实现方式:

  1. Lambda表达式:适用于可简写为单行表达式的简单函数(Lambda不支持多语句函数或无返回值的语句)
  2. 在调用Spark的函数内部定义局部函数:适用于较长代码段
  3. 使用模块中的顶级函数

例如,当需要传递比lambda表达式更复杂的函数时,可参考以下代码:

python 复制代码
"""MyScript.py"""
if __name__ == "__main__":
    def myFunc(s):
        words = s.split(" ")
        return len(words)
    
    sc = SparkContext(...)
    sc.textFile("file.txt").map(myFunc)

注意:虽然也可以传递类实例中的方法引用(与单例对象相对),但这需要将包含该方法的整个对象一起发送到集群。例如:

python 复制代码
class MyClass(object):
    def func(self, s):
        return s
    def doStuff(self, rdd):
        return rdd.map(self.func)

此时若创建新的MyClass实例并调用doStuff方法,map操作会引用该实例的func方法,导致整个对象被发送到集群。

类似地,访问外部对象的字段也会引用整个对象:

python 复制代码
class MyClass(object):
    def __init__(self):
        self.field = "Hello"
    def doStuff(self, rdd):
        return rdd.map(lambda s: self.field + s)

为避免此问题,最简单的方法是将字段复制到局部变量而非直接访问外部字段:

python 复制代码
def doStuff(self, rdd):
    field = self.field
    return rdd.map(lambda s: field + s)
Scala 方式

Spark API 在很大程度上依赖于将驱动程序中的函数传递到集群上执行。推荐以下两种实现方式:

  • 匿名函数语法,适用于简短代码

  • 全局单例对象中的静态方法。例如定义对象 MyFunctions 后传递 MyFunctions.func1:

scala 复制代码
object MyFunctions {
  def func1(s: String): String = { ... }
}

myRdd.map(MyFunctions.func1)

注意:虽然也可以传递类实例中的方法引用(与单例对象相对),但这种方式需要将包含该方法的整个对象一同发送到集群。例如:

scala 复制代码
class MyClass {
  def func1(s: String): String = { ... }
  def doStuff(rdd: RDD[String]): RDD[String] = { rdd.map(func1) }
}

此时若创建新的 MyClass 实例并调用其 doStuff 方法,内部的 map 操作会引用该实例的 func1 方法,因此需要将整个对象发送至集群。这相当于编写 rdd.map(x => this.func1(x))。

类似地,访问外部对象的字段也会引用整个对象:

scala 复制代码
class MyClass {
  val field = "Hello"
  def doStuff(rdd: RDD[String]): RDD[String] = { rdd.map(x => field + x) }
}

上述代码等价于 rdd.map(x => this.field + x),其中引用了完整的 this 对象。为避免该问题,最简单的方法是将字段复制到局部变量中,而非从外部访问:

scala 复制代码
def doStuff(rdd: RDD[String]): RDD[String] = {
  val field_ = this.field
  rdd.map(x => field_ + x)
}
Java 方式

Spark API 在很大程度上依赖于将驱动程序中的函数传递到集群上执行。在 Java 中,函数通过实现 org.apache.spark.api.java.function 包中接口的类来表示。创建此类函数有两种方式:

在自己的类中实现 Function 接口(可作为匿名内部类或命名类),并将其实例传递给 Spark

使用 lambda 表达式来简洁地定义实现

虽然本指南为求简洁大量使用 lambda 语法,但同样可以轻松使用完整形式的 API。例如,上述代码可以改写为:

java 复制代码
JavaRDD<String> lines = sc.textFile("data.txt");
JavaRDD<Integer> lineLengths = lines.map(new Function<String, Integer>() {
  public Integer call(String s) { return s.length(); }
});
int totalLength = lineLengths.reduce(new Function2<Integer, Integer, Integer>() {
  public Integer call(Integer a, Integer b) { return a + b; }
});

如果内联编写函数显得冗长,也可以采用:

java 复制代码
class GetLength implements Function<String, Integer> {
  public Integer call(String s) { return s.length(); }
}
class Sum implements Function2<Integer, Integer, Integer> {
  public Integer call(Integer a, Integer b) { return a + b; }
}

JavaRDD<String> lines = sc.textFile("data.txt");
JavaRDD<Integer> lineLengths = lines.map(new GetLength());
int totalLength = lineLengths.reduce(new Sum());

需要注意的是,Java 中的匿名内部类只要声明为 final,也可以访问封闭作用域中的变量。Spark 会将这些变量的副本发送到各个工作节点,其处理方式与其他语言一致。

理解闭包

Spark 中一个较难理解的方面是在跨集群执行代码时变量和方法的范围与生命周期。修改其范围外部变量的 RDD 操作常常会引发困惑。在下面的示例中,我们将查看使用 foreach() 递增计数器的代码,但类似问题也可能出现在其他操作中。

示例

考虑下面这种简单的 RDD 元素求和方法,其行为可能因执行是否在同一个 JVM 内而发生差异。一个常见的例子是:在本地模式(--master = local[n])下运行 Spark 与将 Spark 应用程序部署到集群(例如通过 spark-submit 提交到 YARN)时的对比:

python 复制代码
counter = 0
rdd = sc.parallelize(data)

# Wrong: Don't do this!!
def increment_counter(x):
    global counter
    counter += x
rdd.foreach(increment_counter)

print("Counter value: ", counter)
scala 复制代码
var counter = 0
var rdd = sc.parallelize(data)

// Wrong: Don't do this!!
rdd.foreach(x => counter += x)

println("Counter value: " + counter)
java 复制代码
int counter = 0;
JavaRDD<Integer> rdd = sc.parallelize(data);

// Wrong: Don't do this!!
rdd.foreach(x -> counter += x);

println("Counter value: " + counter);
本地模式与集群模式

上述代码的行为是不确定的,可能无法按预期工作。为了执行作业,Spark 将 RDD 操作的处理过程分解为多个任务,每个任务由一个执行器(executor)执行。在执行之前,Spark 会计算任务的闭包(closure)。闭包是指那些执行器为了对 RDD(在本例中是 foreach())执行计算而必须可见的变量和方法。这个闭包会被序列化并发送给每个执行器。

发送给每个执行器的闭包内的变量现在是副本,因此,当在 foreach 函数内部引用 counter 时,它不再是驱动节点(driver node)上的那个 counter。驱动节点的内存中仍然存在一个 counter,但执行器无法再看到它!执行器只能看到序列化闭包中的副本。因此,counter 的最终值将仍然是零,因为所有对 counter 的操作都是在引用序列化闭包内的值。

在本地模式(local mode)下,在某些情况中,foreach 函数实际上会在与驱动程序相同的 JVM 中执行,并引用同一个原始的 counter,从而可能实际更新它。

为了确保在这类场景中有明确的行为定义,应该使用累加器(Accumulator)。Spark 中的累加器专门用于提供一种机制,当执行过程分布在集群的工作节点(worker nodes)上时,可以安全地更新变量。本指南的累加器部分会更详细地讨论这些内容。

总的来说,不应使用闭包------例如循环或局部定义的方法这类结构------来改变某些全局状态。Spark 不定义也不保证对从闭包外部引用的对象进行修改的行为。某些这样做的代码在本地模式下可能有效,但这只是偶然情况,此类代码在分布式模式下不会按预期运行。如果需要某种全局聚合,请改用累加器。

打印 RDD 中的元素

另一种常见的做法是尝试使用 rdd.foreach(println) 或 rdd.map(println) 来打印 RDD 的元素。在单机环境下,这会生成预期的输出并打印所有 RDD 元素。然而,在集群模式下,执行器调用 stdout 的输出会写入到执行器的标准输出中,而不是驱动节点的标准输出,因此驱动节点上的 stdout 不会显示这些内容!

如果要在驱动节点上打印所有元素,可以使用 collect() 方法先将 RDD 数据收集到驱动节点,例如:rdd.collect().foreach(println)。但这种方式可能导致驱动节点内存不足,因为 collect() 会将整个 RDD 获取到单个机器上。如果只需要打印 RDD 的少量元素,更安全的方法是使用 take():rdd.take(100).foreach(println)。

键值对操作
Python 方式

虽然大多数 Spark 操作适用于包含任意类型对象的 RDD,但一些特殊操作仅适用于键值对形式的 RDD。其中最常见的是分布式的"洗牌"操作,例如按键对元素进行分组或聚合。

在 Python 中,这些操作适用于包含 Python 内置元组(如 (1, 2))的 RDD。只需创建此类元组,然后调用所需操作即可。

例如,以下代码对键值对使用 reduceByKey 操作,统计文件中每行文本出现的次数:

python 复制代码
lines = sc.textFile("data.txt")
pairs = lines.map(lambda s: (s, 1))
counts = pairs.reduceByKey(lambda a, b: a + b)

我们还可以使用 counts.sortByKey() 按字母顺序对键值对进行排序,最后通过 counts.collect() 将它们作为对象列表返回驱动程序。

Scala 方式

虽然大多数 Spark 操作可作用于包含任意类型对象的 RDD,但某些特殊操作仅适用于键值对形式的 RDD。其中最常见的当属分布式的"洗牌(shuffle)"操作,例如根据键(key)对元素进行分组或聚合。

在 Scala 中,这些操作会自动作用于包含 Tuple2 对象(即通过 (a, b) 这种简单写法创建的语言内置元组)的 RDD。键值对操作由 PairRDDFunctions 类提供,该类会自动封装包含元组的 RDD。

例如,以下代码对键值对使用 reduceByKey 操作,统计文件中每行文本出现的次数:

scala 复制代码
val lines = sc.textFile("data.txt")
val pairs = lines.map(s => (s, 1))
val counts = pairs.reduceByKey((a, b) => a + b)

我们还可以使用 counts.sortByKey() 按字母顺序对键值对排序,最后通过 counts.collect() 将它们作为对象数组传回驱动程序。

注意: 当使用自定义对象作为键值对操作中的键时,必须确保自定义的 equals() 方法配套实现了相应的 hashCode() 方法。完整要求请参阅 Object.hashCode() 文档中概述的约定。

Java 方式

虽然大多数 Spark 操作适用于包含任意类型对象的 RDD,但某些特殊操作仅适用于键值对形式的 RDD。其中最常见的是分布式的"洗牌"操作,例如根据键对元素进行分组或聚合。

在 Java 中,键值对使用 Scala 标准库中的 scala.Tuple2 类表示。只需调用 new Tuple2(a, b) 即可创建元组,后续可通过 tuple._1() 和 tuple._2() 访问其字段。

键值对 RDD 由 JavaPairRDD 类表示。可以通过特殊版本的映射操作(如 mapToPair 和 flatMapToPair)从 JavaRDD 构建 JavaPairRDD。JavaPairRDD 既包含标准 RDD 函数,也包含专门的键值对函数。

例如,以下代码对键值对使用 reduceByKey 操作来统计文件中每行文本出现的次数:

java 复制代码
JavaRDD<String> lines = sc.textFile("data.txt");
JavaPairRDD<String, Integer> pairs = lines.mapToPair(s -> new Tuple2(s, 1));
JavaPairRDD<String, Integer> counts = pairs.reduceByKey((a, b) -> a + b);

我们还可以使用 counts.sortByKey() 按字母顺序对键值对排序,最后通过 counts.collect() 将它们作为对象数组传回驱动程序。

注意: 当使用自定义对象作为键值对操作中的键时,必须确保自定义的 equals() 方法配套实现了相应的 hashCode() 方法。完整要求请参阅 Object.hashCode() 文档中概述的约定。

转换操作

下表列出了 Spark 支持的一些常用转换操作。详细内容请参阅 RDD API 文档(Scala、Java、Python、R 版本)以及键值对 RDD 函数文档(Scala、Java 版本)。

转换操作 含义
map(func) 将源数据集的每个元素通过函数 func 传递,返回一个新的分布式数据集
filter(func) 返回一个新数据集,包含源数据集中使 func 返回 true 的元素
flatMap(func) 与 map 类似,但每个输入项可映射为 0 个或多个输出项(因此 func 应返回一个 Seq 而不是单个项)
mapPartitions(func) 与 map 类似,但在 RDD 的每个分区上单独运行,因此在类型为 T 的 RDD 上运行时,func 必须为 Iterator => Iterator 类型
mapPartitionsWithIndex(func) 与 mapPartitions 类似,但会向 func 提供表示分区索引的整数值,因此在类型为 T 的 RDD 上运行时,func 必须为 (Int, Iterator) => Iterator 类型
sample(withReplacement, fraction, seed) 使用给定的随机数生成器种子,对数据进行抽样,抽样比例为 fraction,可设置是否有放回
union(otherDataset) 返回一个新数据集,包含源数据集和参数数据集中元素的并集
intersection(otherDataset) 返回一个新 RDD,包含源数据集和参数数据集中元素的交集
distinct([numPartitions]) 返回一个新数据集,包含源数据集中的不重复元素
groupByKey([numPartitions]) 当在 (K, V) 对的数据集上调用时,返回一个 (K, Iterable) 对的数据集 注意:如果分组是为了对每个键执行聚合操作(如求和或平均值),使用 reduceByKey 或 aggregateByKey 将获得更好的性能 注意:默认情况下,输出的并行度取决于父 RDD 的分区数。可以传递可选的 numPartitions 参数来设置不同的任务数
reduceByKey(func, [numPartitions]) 当在 (K, V) 对的数据集上调用时,返回一个 (K, V) 对的数据集,其中每个键的值使用给定的 reduce 函数 func 进行聚合,func 必须为 (V,V) => V 类型。与 groupByKey 类似,reduce 任务的数量可通过可选的第二个参数配置
aggregateByKey(zeroValue)(seqOp, combOp, [numPartitions]) 当在 (K, V) 对的数据集上调用时,返回一个 (K, U) 对的数据集,其中每个键的值使用给定的组合函数和一个中性的"零"值进行聚合。允许聚合值类型与输入值类型不同,同时避免不必要的内存分配。与 groupByKey 类似,reduce 任务的数量可通过可选的第二个参数配置
sortByKey([ascending], [numPartitions]) 当在 (K, V) 对的数据集上调用时(其中 K 实现了 Ordered),返回一个按键升序或降序排序的 (K, V) 对数据集,排序顺序由布尔参数 ascending 指定
join(otherDataset, [numPartitions]) 当在类型为 (K, V) 和 (K, W) 的数据集上调用时,返回一个 (K, (V, W)) 对的数据集,包含每个键的所有元素对。通过 leftOuterJoin、rightOuterJoin 和 fullOuterJoin 支持外连接
cogroup(otherDataset, [numPartitions]) 当在类型为 (K, V) 和 (K, W) 的数据集上调用时,返回一个 (K, (Iterable, Iterable)) 元组的数据集。此操作也称为 groupWith
cartesian(otherDataset) 当在类型为 T 和 U 的数据集上调用时,返回一个 (T, U) 对的数据集(所有元素的组合对)
pipe(command, [envVars]) 通过 shell 命令(如 Perl 或 bash 脚本)处理 RDD 的每个分区。RDD 元素写入进程的 stdin,输出到 stdout 的行作为字符串 RDD 返回
coalesce(numPartitions) 将 RDD 中的分区数减少到 numPartitions。在过滤大型数据集后用于更高效地运行操作
repartition(numPartitions) 随机重新洗牌 RDD 中的数据,以创建更多或更少的分区并在其间平衡数据。这总是通过网络洗牌所有数据
repartitionAndSortWithinPartitions(partitioner) 根据给定的分区器对 RDD 重新分区,并在每个结果分区内按键对记录排序。这比先调用 repartition 然后在每个分区内排序更高效,因为它可以将排序下推到洗牌机制中
Actions

下表列出了 Spark 支持的一些常用行动操作。详细内容请参阅 RDD API 文档(Scala、Java、Python、R 版本)以及键值对 RDD 函数文档(Scala、Java 版本)。

行动操作 含义
reduce(func) 使用函数 func(接收两个参数并返回一个值)聚合数据集的元素。该函数应满足交换律和结合律,以便能够正确并行计算。
collect() 将数据集的所有元素以数组形式返回到驱动程序。这通常在过滤或其他返回数据子集且结果数据量较小的操作之后使用。
count() 返回数据集中的元素个数。
first() 返回数据集中的第一个元素(类似于 take(1))。
take(n) 返回由数据集前 n 个元素组成的数组。
takeSample(withReplacement, num, [seed]) 返回数据集中 num 个元素的随机抽样数组,可设置是否有放回,并可选择预设随机数生成器种子。
takeOrdered(n, [ordering]) 使用元素的自然顺序或自定义比较器,返回 RDD 的前 n 个元素。
saveAsTextFile(path) 将数据集的元素写入本地文件系统、HDFS 或其他 Hadoop 支持的文件系统中指定目录下的文本文件(或一组文本文件)。Spark 会对每个元素调用 toString 方法,将其转换为文件中的一行文本。
saveAsSequenceFile(path)
(Java 和 Scala) 将数据集的元素以 Hadoop SequenceFile 格式写入本地文件系统、HDFS 或其他 Hadoop 支持的文件系统中的指定路径。此操作适用于实现了 Hadoop Writable 接口的键值对 RDD。在 Scala 中,也适用于可隐式转换为 Writable 的类型(Spark 包含了基本类型如 Int、Double、String 等的转换)。
saveAsObjectFile(path)
(Java 和 Scala) 使用 Java 序列化以简单格式写入数据集的元素,之后可通过 SparkContext.objectFile() 加载。
countByKey() 仅适用于 (K, V) 类型的 RDD。返回一个 (K, Int) 对的哈希映射,包含每个键的计数。
foreach(func) 对数据集的每个元素运行函数 func。这通常用于产生副作用,例如更新累加器(Accumulator)或与外部存储系统交互。 注意: 在 foreach() 外部修改除累加器之外的变量可能导致未定义行为。更多细节请参阅《理解闭包》章节。

异步行动操作 Spark RDD API 还暴露了一些行动的异步版本,例如 foreachAsync 对应 foreach,这些操作会立即向调用者返回一个 FutureAction,而不是阻塞等待行动完成。这可用于管理或等待行动的异步执行。

Shuffle操作

Spark中的某些操作会触发一个称为**洗牌(Shuffle)**的事件。洗牌是Spark重新分发数据的机制,使得数据能够以不同的方式在各个分区中进行分组。这一过程通常涉及在执行器(Executor)和机器之间复制数据,因此洗牌是一项复杂且代价高昂的操作。

背景原理

要理解洗牌过程中发生了什么,我们可以以 reduceByKey 操作为例。reduceByKey 操作会生成一个新的RDD,其中同一个键的所有值会被组合成一个元组------包含该键以及对与该键关联的所有值执行归约函数后的结果。这里面临的挑战是,同一个键的所有值不一定存储在同一个分区,甚至不一定在同一台机器上,但为了计算结果,它们必须被放在一起。

在Spark中,数据通常不会为了某个特定操作的需要而预先分布在合适的分区上。在计算过程中,单个任务仅操作单个分区。因此,为了组织好所有数据,以便执行单个 reduceByKey 的归约任务,Spark需要执行一个全对全(all-to-all)操作。它必须从所有分区读取数据,找到所有键的所有值,然后将跨分区的值汇聚起来计算每个键的最终结果------这个过程就是洗牌。

尽管新洗牌数据的每个分区中的元素集合是确定性的,分区本身的顺序也是确定性的,但分区内这些元素的顺序是不确定的。如果需要在洗牌后获得有确定性顺序的数据,可以使用以下方法:

  • 使用 mapPartitions 对每个分区进行排序,例如使用 .sorted。
  • 使用 repartitionAndSortWithinPartitions 在重新分区的同时高效地对分区进行排序。
  • 使用 sortBy 来生成一个全局有序的RDD。

可能引起洗牌的操作包括:重新分区操作(如 repartition 和 coalesce)、'ByKey'操作(计数操作除外,例如 groupByKey 和 reduceByKey),以及连接操作(如 cogroup 和 join)。

性能影响

洗牌是一个代价高昂的操作,因为它涉及磁盘I/O、数据序列化和网络I/O。为了组织数据以进行洗牌,Spark会生成两组任务:映射任务(Map tasks) 用于组织数据,以及归约任务(Reduce tasks) 用于聚合数据。这个命名源于MapReduce,与Spark的 map 和 reduce 操作没有直接关系。

在内部,单个映射任务的结果会保存在内存中,直到内存无法容纳为止。然后,这些结果会根据目标分区进行排序,并写入单个文件。在归约端,任务会读取相关的已排序数据块。

某些洗牌操作会消耗大量的堆内存,因为它们使用内存中的数据结构来在传输记录之前或之后组织记录。具体来说,reduceByKey 和 aggregateByKey 会在映射端创建这些结构,而'ByKey'操作会在归约端生成这些结构。当数据无法完全装入内存时,Spark会将这些表溢出(spill)到磁盘,从而导致额外的磁盘I/O开销和垃圾回收(GC)压力。

洗牌还会在磁盘上生成大量的中间文件。从Spark 1.3开始,这些文件会被保留,直到相应的RDD不再被使用并被垃圾回收为止。这样做是为了在需要重新计算血缘关系(lineage)时,无需重新创建洗牌文件。如果应用程序长时间保留对这些RDD的引用,或者垃圾回收不频繁发生,那么垃圾回收可能要在很长时间后才会进行。这意味着长时间运行的Spark作业可能会消耗大量的磁盘空间。临时存储目录由配置Spark上下文时的 spark.local.dir 配置参数指定。

可以通过调整各种配置参数来调优洗牌行为。请参阅《Spark配置指南》中的"Shuffle Behavior"部分。

RDD 持久化

Spark最重要的功能之一是在内存中跨操作**持久化(或缓存)**数据集。当你持久化一个RDD时,每个节点会将其计算的分区存储在内存中,并在该数据集(或衍生自它的数据集)的其他操作中重用它们。这使得后续操作能够快得多(通常能快10倍以上)。缓存是迭代算法和快速交互式使用的关键工具。

你可以使用RDD的 persist() 或 cache() 方法来标记其需要被持久化。当它第一次在某个行动操作中被计算时,它会被保存在各个节点的内存中。Spark的缓存是容错的------如果RDD的任何一个分区丢失,它会自动使用最初创建它的那些转换操作重新计算。

此外,每个被持久化的RDD可以使用不同的存储级别进行存储,例如,允许你将数据集持久化到磁盘、以序列化Java对象的形式持久化在内存中(为了节省空间)、在节点间进行复制等。这些级别通过向 persist() 传递一个 StorageLevel 对象(Scala, Java, Python)来设置。cache() 方法是使用默认存储级别(即 StorageLevel.MEMORY_ONLY,将反序列化对象存储在内存中)的简写形式。完整的存储级别如下:

存储级别 含义
MEMORY_ONLY 将RDD以反序列化的Java对象形式存储在JVM内存中。如果RDD无法完全装入内存,部分分区将不会被缓存,并在每次需要时动态重新计算。这是默认级别。
MEMORY_AND_DISK 将RDD以反序列化的Java对象形式存储在JVM内存中。如果RDD无法完全装入内存,则将不适合的分区存储在磁盘上,并在需要时从磁盘读取。
MEMORY_ONLY_SER (Java和Scala) 将RDD以序列化的Java对象形式存储(每个分区一个字节数组)。这通常比反序列化对象更节省空间,尤其是在使用快速序列化库时,但读取时CPU开销更大。
MEMORY_AND_DISK_SER (Java和Scala) 与 MEMORY_ONLY_SER 类似,但会将不适合内存的分区**溢出(spill)**到磁盘,而不是在每次需要时动态重新计算。
DISK_ONLY 仅将RDD分区存储在磁盘上。
MEMORY_ONLY_2, MEMORY_AND_DISK_2, 等 与上述级别相同,但会在两个集群节点上复制每个分区。
OFF_HEAP (实验性) 与 MEMORY_ONLY_SER 类似,但将数据存储在堆外内存中。这需要启用堆外内存。

注意:在Python中,存储的对象总是使用Pickle库进行序列化,因此选择序列化级别无关紧要。Python中可用的存储级别包括 MEMORY_ONLY, MEMORY_ONLY_2, MEMORY_AND_DISK, MEMORY_AND_DISK_2, DISK_ONLY, DISK_ONLY_2, 和 DISK_ONLY_3。

即使在用户没有调用 persist 的情况下,Spark也会在洗牌操作(例如 reduceByKey)中自动持久化一些中间数据。这是为了避免在洗牌过程中若有节点失败,需要重新计算整个输入。如果用户计划重用洗牌结果产生的RDD,我们仍然建议用户对其调用 persist。

如何选择存储级别?

Spark的存储级别旨在在内存使用率和CPU效率之间提供不同的权衡。我们建议通过以下流程进行选择:

如果您的RDD能很好地适应默认存储级别(MEMORY_ONLY),则保持原样。这是CPU效率最高的选项,允许RDD上的操作尽可能快地运行。

如果不能,请尝试使用MEMORY_ONLY_SER并选择一个快速的序列化库,使对象的空间效率更高,同时仍能保持合理的访问速度。(适用于Java和Scala)

除非计算数据集的函数开销很大,或者它们过滤了大量数据,否则不要将数据溢出到磁盘。因为在这种情况下,重新计算一个分区的速度可能与从磁盘读取它的速度一样快。

如果需要快速故障恢复(例如,使用Spark为Web应用程序提供请求服务),请使用带复制的存储级别。所有存储级别都通过重新计算丢失的数据来提供完整的容错能力,但带复制的级别允许您继续在RDD上运行任务,而无需等待重新计算丢失的分区。

数据移除

Spark会自动监控每个节点上的缓存使用情况,并以最近最少使用(LRU)的方式逐出旧的数据分区。如果您希望手动移除RDD而不是等待它被缓存自动逐出,请使用RDD.unpersist()方法。请注意,此方法默认不阻塞。若需要阻塞直到资源被释放,请在调用此方法时指定blocking=true。

共享变量

通常,当传递给 Spark 操作(如 map 或 reduce)的函数在远程集群节点上执行时,它操作的是函数中使用的所有变量的独立副本。这些变量会被复制到每台机器,且远程机器上对变量的更新不会传回驱动程序。支持跨任务的通用读写共享变量效率会很低。然而,Spark 确实为两种常见的使用模式提供了两种有限类型的共享变量:广播变量和累加器。

广播变量

广播变量允许程序员将一个只读变量缓存在每台机器上,而不是随着任务一起发送副本。例如,它们可以用于高效地向每个节点分发一个大型输入数据集的副本。Spark 还会尝试使用高效的广播算法来分发广播变量,以降低通信成本。

Spark 动作(actions)通过一系列阶段(stages)执行,这些阶段由分布式的"洗牌"(shuffle)操作分隔。Spark 会自动广播每个阶段内任务所需的公共数据。以这种方式广播的数据以序列化形式缓存,并在运行每个任务前反序列化。这意味着,只有当跨多个阶段的任务需要相同数据,或者以反序列化形式缓存数据非常重要时,显式创建广播变量才有用。

广播变量通过对变量 v 调用 SparkContext.broadcast(v) 来创建。广播变量是 v 的一个包装器,可以通过调用 value 方法来访问其值。以下代码展示了这一点:

python 复制代码
val broadcastVar = sc.broadcast(Array(1, 2, 3))
broadcastVar.value
// 返回: Array(1, 2, 3)
scala 复制代码
scala> val broadcastVar = sc.broadcast(Array(1, 2, 3))
broadcastVar: org.apache.spark.broadcast.Broadcast[Array[Int]] = Broadcast(0)

scala> broadcastVar.value
res0: Array[Int] = Array(1, 2, 3)
java 复制代码
Broadcast<int[]> broadcastVar = sc.broadcast(new int[] {1, 2, 3});

broadcastVar.value();
// returns [1, 2, 3]

广播变量创建后,在集群上运行的任何函数中都应使用该广播变量,而不是原始值 v,以避免将 v 多次发送到节点。此外,对象 v 在广播后不应被修改,以确保所有节点获取的广播变量值一致(例如,如果该变量后续被发送到一个新节点)。

在 UI 中跟踪累加器对于理解运行中阶段的进度很有用(注意:Python 中尚不支持此功能)。

要释放广播变量复制到执行器(executors)上的资源,请调用 .unpersist()。如果之后再次使用该广播变量,它会被重新广播。要永久释放广播变量使用的所有资源,请调用 .destroy()。此后该广播变量将无法再使用。请注意,这些方法默认不会阻塞。若要阻塞直到资源释放,请在调用时指定 blocking=true。

累加器

累加器是一种只能通过关联性和交换性操作进行"累加"的变量,因此可以高效地并行支持。它们可用于实现计数器(如 MapReduce 中)或求和。Spark 原生支持数值类型的累加器,程序员可以添加对新类型的支持。

作为用户,您可以创建命名或未命名的累加器。如下图所示,命名累加器(在此例中名为 counter)会在 Web UI 中显示于修改该累加器的阶段下。Spark 会在"Tasks"表中显示由任务修改的每个累加器的值。

在用户界面(UI)中跟踪累加器有助于了解运行中各阶段的进度(注意:此功能目前在 Python 中尚未支持)。

Python 方式

累加器可以通过调用 SparkContext.accumulator(v) 从初始值 v 创建。在集群上运行的任务随后可以使用 add 方法或 += 运算符来对其进行累加。但是,任务无法读取累加器的值。只有驱动程序可以使用累加器的 value 方法来读取其值。

以下代码展示了如何使用累加器对数组元素进行求和:

python 复制代码
>>> accum = sc.accumulator(0)
>>> accum
Accumulator<id=0, value=0>

>>> sc.parallelize([1, 2, 3, 4]).foreach(lambda x: accum.add(x))
...
10/09/29 18:41:08 INFO SparkContext: Tasks finished in 0.317106 s

>>> accum.value
10

虽然这段代码使用了 Spark 对 Int 类型累加器的内置支持,但程序员也可以通过继承 AccumulatorParam 类来创建自定义类型的累加器。AccumulatorParam 接口有两个方法:zero 方法为您的数据类型提供一个"零值",addInPlace 方法将两个值相加。例如,假设我们有一个表示数学向量的 Vector 类,我们可以这样写:

python 复制代码
class VectorAccumulatorParam(AccumulatorParam):
    def zero(self, initialValue):
        return Vector.zeros(initialValue.size)

    def addInPlace(self, v1, v2):
        v1 += v2
        return v1

# 然后,创建这种类型的累加器:
vecAccum = sc.accumulator(Vector(...), VectorAccumulatorParam())

对于仅在动作(action)内部执行的累加器更新,Spark 保证每个任务对累加器的更新只会被应用一次,即重新启动的任务不会再次更新该值。在转换(transformation)操作中,用户应注意,如果任务或作业阶段被重新执行,每个任务的更新可能会被应用多次。

累加器不会改变 Spark 的惰性求值模型。如果累加器是在对 RDD 的某个操作中进行更新的,那么它们的值只有在该 RDD 作为某个动作的一部分被计算时才会更新。因此,在像 map() 这样的惰性转换操作中进行累加器更新,并不能保证会被执行。下面的代码片段展示了这个特性:

python 复制代码
accum = sc.accumulator(0)
def g(x):
    accum.add(x)
    return f(x)
data.map(g)
# Here, accum is still 0 because no actions have caused the `map` to be computed.
Scala 方式

可以通过调用 SparkContext.longAccumulator() 或 SparkContext.doubleAccumulator() 来分别创建累加 Long 或 Double 类型值的数值累加器。在集群上运行的任务可以使用 add 方法对其进行累加。但是,任务无法读取其值。只有驱动程序可以使用累加器的 value 方法读取其值。

以下代码展示了如何使用累加器对数组元素进行求和:

scala 复制代码
scala> val accum = sc.longAccumulator("My Accumulator")
accum: org.apache.spark.util.LongAccumulator = LongAccumulator(id: 0, name: Some(My Accumulator), value: 0)

scala> sc.parallelize(Array(1, 2, 3, 4)).foreach(x => accum.add(x))
...
10/09/29 18:41:08 INFO SparkContext: Tasks finished in 0.317106 s

scala> accum.value
res2: Long = 10

虽然这段代码使用了 Spark 对 Long 类型累加器的内置支持,但程序员也可以通过继承 AccumulatorV2 类来创建自定义类型的累加器。AccumulatorV2 抽象类有几个必须重写的方法:reset 用于将累加器重置为零,add 用于将另一个值加到累加器中,merge 用于将另一个相同类型的累加器合并到当前累加器。必须重写的其他方法包含在 API 文档中。例如,假设我们有一个表示数学向量的 MyVector 类,我们可以这样写:

scala 复制代码
class VectorAccumulatorV2 extends AccumulatorV2[MyVector, MyVector] {

  private val myVector: MyVector = MyVector.createZeroVector

  def reset(): Unit = {
    myVector.reset()
  }

  def add(v: MyVector): Unit = {
    myVector.add(v)
  }
  ...
}

// 然后,创建这种类型的累加器:
val myVectorAcc = new VectorAccumulatorV2
// 然后,将其注册到 spark 上下文中:
sc.register(myVectorAcc, "MyVectorAcc1")

请注意,当程序员定义自己的 AccumulatorV2 类型时,最终的类型可以与所添加元素的类型不同。

对于仅在动作(action)内部执行的累加器更新,Spark 保证每个任务对累加器的更新只会被应用一次,即重新启动的任务不会再次更新该值。在转换(transformation)操作中,用户应注意,如果任务或作业阶段被重新执行,每个任务的更新可能会被应用多次。

累加器不会改变 Spark 的惰性求值模型。如果累加器是在对 RDD 的某个操作中进行更新的,那么它们的值只有在该 RDD 作为某个动作的一部分被计算时才会更新。因此,在像 map() 这样的惰性转换操作中进行累加器更新,并不能保证会被执行。下面的代码片段展示了这个特性:

scala 复制代码
val accum = sc.longAccumulator
data.map { x => accum.add(x); x }
// 在这里,accum 的值仍然是 0,因为没有触发 map 操作计算的行动(action)。
Java 方式

可以通过调用 SparkContext.longAccumulator() 或 SparkContext.doubleAccumulator() 来分别创建用于累加 Long 或 Double 类型值的数值累加器。在集群上运行的任务可以使用 add 方法对其进行累加。但是,任务无法读取其值。只有驱动程序可以使用累加器的 value 方法读取其值。

以下代码展示了如何使用累加器对数组元素进行求和:

java 复制代码
LongAccumulator accum = jsc.sc().longAccumulator();

sc.parallelize(Arrays.asList(1, 2, 3, 4)).foreach(x -> accum.add(x));
// ...
// 10/09/29 18:41:08 INFO SparkContext: Tasks finished in 0.317106 s

accum.value();
// 返回 10

虽然这段代码使用了 Spark 对 Long 类型累加器的内置支持,但程序员也可以通过继承 AccumulatorV2 类来创建自定义类型的累加器。AccumulatorV2 抽象类有几个必须重写的方法:reset 用于将累加器重置为零,add 用于将另一个值加到累加器中,merge 用于将另一个相同类型的累加器合并到当前累加器。必须重写的其他方法包含在 API 文档中。例如,假设我们有一个表示数学向量的 MyVector 类,我们可以这样写:

java 复制代码
class VectorAccumulatorV2 implements AccumulatorV2<MyVector, MyVector> {

  private MyVector myVector = MyVector.createZeroVector();

  public void reset() {
    myVector.reset();
  }

  public void add(MyVector v) {
    myVector.add(v);
  }
  ...
}

// 然后,创建这种类型的累加器:
VectorAccumulatorV2 myVectorAcc = new VectorAccumulatorV2();
// 然后,将其注册到 spark 上下文中:
jsc.sc().register(myVectorAcc, "MyVectorAcc1");

请注意,当程序员定义自己的 AccumulatorV2 类型时,最终的类型可以与所添加元素的类型不同。

警告:当 Spark 任务完成时,Spark 会尝试将此任务中的累积更新合并到累加器中。如果合并失败,Spark 将忽略该失败,仍将任务标记为成功并继续运行其他任务。因此,一个有问题的累加器不会影响 Spark 作业,但即使 Spark 作业成功,该累加器也可能无法正确更新。

对于仅在动作(action)内部执行的累加器更新,Spark 保证每个任务对累加器的更新只会被应用一次,即重新启动的任务不会再次更新该值。在转换(transformation)操作中,用户应注意,如果任务或作业阶段被重新执行,每个任务的更新可能会被应用多次。

累加器不会改变 Spark 的惰性求值模型。如果累加器是在对 RDD 的某个操作中进行更新的,那么它们的值只有在该 RDD 作为某个动作的一部分被计算时才会更新。因此,在像 map() 这样的惰性转换操作中进行累加器更新,并不能保证会被执行。下面的代码片段展示了这个特性:

java 复制代码
LongAccumulator accum = jsc.sc().longAccumulator();
data.map(x -> { accum.add(x); return f(x); });
// Here, accum is still 0 because no actions have caused the `map` to be computed.

部署到集群

应用程序提交指南介绍了如何将应用程序提交到集群。简而言之,一旦您将应用程序打包成 JAR 文件(针对 Java/Scala)或一组 .py 或 .zip 文件(针对 Python),就可以使用 bin/spark-submit 脚本将其提交到任何受支持的集群管理器。

从 Java/Scala 启动 Spark 任务

org.apache.spark.launcher 包提供了使用简单 Java API 将 Spark 作业作为子进程启动的类。

单元测试

Spark 能够很好地与任何流行的单元测试框架配合进行单元测试。只需在测试中创建一个 SparkContext,将其 master URL 设置为 local,运行您的操作,然后调用 SparkContext.stop() 来终止它。请确保在 finally 块或测试框架的 tearDown 方法中停止上下文,因为 Spark 不支持在同一程序中同时运行两个上下文。

进一步学习方向

您可以在 Spark 网站上查看一些 Spark 程序示例。此外,Spark 在 examples 目录中包含了多个示例(Scala、Java、Python、R)。您可以通过将类名传递给 Spark 的 bin/run-example 脚本来运行 Java 和 Scala 示例;例如:

./bin/run-example SparkPi

对于 Python 示例,请改用 spark-submit:

./bin/spark-submit examples/src/main/python/pi.py

对于 R 示例,请改用 spark-submit:

./bin/spark-submit examples/src/main/r/dataframe.R

有关优化程序的帮助,配置和调优指南提供了最佳实践信息。这些对于确保数据以高效格式存储在内存中尤为重要。有关部署的帮助,集群模式概述描述了分布式操作中涉及的组件和受支持的集群管理器。

最后,完整的 API 文档可用于 Scala、Java、Python 和 R。

相关推荐
happy_king_zi3 小时前
RabbitMQ 是否也支持消费组
分布式·rabbitmq
艾莉丝努力练剑3 小时前
【C++:红黑树】深入理解红黑树的平衡之道:从原理、变色、旋转到完整实现代码
大数据·开发语言·c++·人工智能·红黑树
ImproveJin3 小时前
Flink Source源码解析
大数据·flink
PONY LEE3 小时前
Flink Rebalance触发乱序的问题
大数据·flink
snowful world3 小时前
实验四 综合数据流处理-Storm案例实现
大数据·storm
金融Tech趋势派4 小时前
金融机构如何用企业微信实现客户服务优化?
大数据·人工智能·金融·企业微信·企业微信scrm
Acrelhuang4 小时前
筑牢用电防线:Acrel-1000 自动化系统赋能 35kV 园区高效供电-安科瑞黄安南
java·大数据·开发语言·人工智能·物联网
Elastic 中国社区官方博客4 小时前
使用 Mastra 和 Elasticsearch 构建具有语义回忆功能的知识 agent
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索