SparkRdd官档翻译(Python)

RDD程序开发指南

概述

从较高的层面来看,每一个Spark应用都包含运行用户main方法和执行各种并行操作的Driver程序。

Spark最主要的抽象概念是分布式数据集,它是可以执行并行操作且跨集群节点的分区的元素集合。RDD可以从Hadoop文件系统中创建(其他任何Hadoop支持的文件系统),或者一个当前存在于Driver程序中的Scala集合,及其通过转换来创建一个RDD。我们也可以请求Spark把RDD驻留(持久化)在内存中,并允许用户在跨分区操作时高效地复用。最终,RDD会自动地从节点恢复失败的任务。

Spark第二个主要的抽象概念是并行操作时的变量共享。当Spark在多个节点上并行执行一个函数的一组任务时,它将函数中使用的每个变量的副本发送给每个任务。有时候,一个变量需要在多个任务间共享,或者在计算任务和Driver程序间共享。Spark支持两种共享变量的类型:广播(broadcast)变量,通常用于在所有节点的内存中缓存一个值;累积变量(accumulators),只是用于做累加的变量,例如计数或求和。

Spark依赖

  • Spark 2.3.1及以上的版本,支持python2和python3(2.7+或3.4+),以及PyPy2.3+。也支持CPyhon标准的编译器,所以像NumPy之类的C类库也可以使用。
  • 对python2.6的支持,在Spark2.2.0时已经移除*
  • Spark Python应用可以运行在以bin/spark-submit脚本直接启动的Spark运行时,或者由你自定义的setup.py脚本所加载的配置,例如:
    install_requires=[
        pyspark=={site.SPARK_VERSION}
    ]

运行Spark的Python应用是不需要pip安装PySpark的,可以使用Spark目录中的bin/spark-submit脚本。这个脚本会加载Spark的Java/Scala类库并允许你在集群中提交应用。你还可以使用bin/pyspark启动一个交互式的python shell

如果你想访问HDFS的数据,需要编译对应的PySpark的版本。Spark主页上还提供了预编译的软件包,用于常见的HDFS版本。

最后,你需要导入一些Spark的类到你的程序中,添加如下的一行代码:

from pyspark import SparkContext, SparkConf

PySpark要求Driver和Worker必须有相同的Python次要版本(X.X)(且最小版本有效)。PySpark使用默认的PATH路径的Python版本,当然,你可以自己指定要使用的Python版本,变量名是PYSPARK_PYTHON

$ PYSPARK_PYTHON=python3.4 bin/pyspark
$ PYSPARK_PYTHON=/opt/pypy-2.5/bin/pypy bin/spark-submit examples/src/main/python/pi.py

初始化Spark

Spark程序的第一步是创建一个SparkContext对象,创建SparkContext对象,你首先需要创建一个包含你应用信息的SparkConf对象。JVM中只能有一个SparkContext对象处于活跃状态,如果你想新创建一个SparkContext对象必须先关闭上一个SparkContxt

conf = SparkConf().setAppName(appName).setMaster(master)
sc = SparkContext(conf=conf)

appName:展示在集群UI界面上的任务名称。

master:Spark,Mesos,YARN cluster URL,local(运行模式)

实际上,当在集群上运行时,你并不希望硬编码程序,而是使用spark-submit启动应用程序并接收返回,但是,对于本地测试和单元测试,你可以通过“local”模式来运行Spark进程

使用shell

在Spark shell中,已经为创建好了一个变量名为sc的SparkContext对象。你不能再自己创建一个SparkContext对象,你可以使用–-master 来指定运行模式,还可以使用–-jars 来指定添加需要的jar包,如果有多个可以使用“,”号分割,可以用过–-packages 来指定你需要的Maven依赖,多个依赖同样使用“,”号分割。另外,任何可能存在依赖关系的附加库(例如Sonatype)都可以传递给–repositories参数。使用4个cores来启动spark-shell的示例代码如下:

$ ./bin/spark-shell --master local[4]

如果需要引入jar包:

$ ./bin/spark-shell --master local[4] --jars code.jar

添加一个maven依赖:

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

通过spark-shell –help命令可以获得所有选项配置

RDD

RDD是Spark的核心概念,RDD是一个容错的集合并且可以执行并行计算。我们可以使用两种方式创建RDD:在Driver程序中调用parallelize方法作用于一个已经存在的数据集,或者读取一个外部数据集,比如共享文件系统,HDFS,HBase或者任何Hadoop支持的数据集。

并行容器

在Driver程序中通过调用SparkContext的parallelize方法传入一个已经存在的数据集可以创建一个并行化的集合。集合的元素被复制成一个可以并行操作的分布式数据集。比如,这里使用1到5的数字创建了一个并行化的数据集:

val data = Array(1, 2, 3, 4, 5)
val distData = sc.parallelize(data)

一旦创建,分布式数据集(distData)就可以进行并行计算,我们可以调用distData.reduce(lambda a, b: a + b)方法将数组中的元素进行累加,我们稍后将介绍分布式数据集上的更多操作。

并行集合的一个重要参数是设置数据集的分区数。Spark将在集群的每个分区运行一个任务。通常你想为集群中的每个CPU分配2-4个分区。正常情况下,Spark将根据您的集群自动设置分区的数量。然而,你也可以通过设置parallelize的第二个参数来设置分区的个数,例:sc.parallelize(data, 10)

扩展数据集

Spark可以使用hadoop支持任何数据源创建分布式数据集,包括你本地的文件系统,HDFS, Cassandra, HBase, Amazon S3。Spark支持序列化的文本文件,和任何Hadoop支持的输入格式。

使用SparkContext的textFile方法读入一个text文件创建RDD,这个方法需要传入一个文件的URI(本地机器路径,hdfs://, s3n://, etc URI)并且按行读取。这里有一个调用的例子:

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”)带通配符的方式加载该目录下的所有txt文件,或者像这样
    textFile(“/my/directory/*.gz”)加载目录下所有的gz压缩文件。
  • textFile方法同样可以设置第二个参数来控制输入文件的分区个数。默认情况下,Spark将对每一个数据块文件(HDFS上默认每个数据块是128M)创建一个分区,但是你也可以为每个数据块设置多个分区,注意你不能设置比数据块更小的分区数。

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

  • SparkContext.wholeTextFiles方法可以读取包含多个小文本文件的目录,并且返回(文件名,内容)键值对数据。这和textFile方法有点区别,textFile将在每个文件中返回一条记录。分区是由数据位置决定的,在某些情况下,可能导致分区太少。对于这些情况,wholeTextFiles 提供了一个可选的第二个参数来控制最小的分区数。
  • RDD.saveAsPickleFile和SparkContext.pickleFile支持保存一个RDD到由python pickled对象组成的简单格式中。该操作支持批量操作,默认大小是10个。
  • 序列化文件和其他hadoop支持的输入输出格式。

    注意:目前这些特性处于试验阶段,并只支持高级用户。很可能被Spark SQL所支持的基本的读写文件的类型所替换,在此类场景中,Spark SQL是更好的选择

保存和加载序列化文件

类似于文本文件,序列化文件也可以保存到或由指定路径加载。键值对类型也可以被指定,但是对于标准的输出流,不是必须指定

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')]

支持的可写类型

PySpark序列化文件支持被java的键值对创建的RDD所加载,将可写文件转换成基本java类型,并通过Pyrolite将生成的java对象存储在pickles中。

当将储存键值对的RDD保存到序列化文件中时,PySpark会做一次反转。它将Python对象解压为Java对象,然后将它们转换成可写的对象。

保存和加载其它类型文件

PySpark通过新旧两套Hadoop MapReduce APIs,也可以读取任何Hadoop支持的输入格式和写出任何Hadoop支持的输出格式。如果有格式要求,Hadoop的配置可以作为python的字典传递,这里是一个ES的例子:

$ ./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配置和/或输入路径,并且键和值类可以根据上面表中例子轻松地转换,那么这种方法对于这种情况应该工作得很好。如果你习惯序列化二进制数据,那么你首先要把scala或java方面的数据转换成pickler可以支持的。Scala的一个转化器支持这项操作。简单的做一些扩展,就可以在转化器的函数中运行你的转换代码。记得确保你的类存在,以及访问您的inputFormat所需的任何依赖项,封装到你的Spark任务的jar中,并包含在PySpark的classpath上。

RDD操作

RDD支持两种类型的操作:转换(transformations),从已有的数据集中通过转换操作创建一个新的RDD。行为(actions),在数据集上执行计算并返回结果到驱动程序。比如,map是一个转换操作将对数据集中的每个元素执行某个函数里面的逻辑并返回一个新带的数据集。另一方面,reduce是一种行为,它使用某个函数聚合所有RDD元素,并将最终结果返回给驱动程序

所有的转换操作都是惰性加载的,它们并不会立即进行计算操作。事实上,转换操作仅仅记录下应用操作了一些基础数据集信息。当action操作执行时transformations才会进行真正的计算。这种设计,是运行更高效。例如,我们可以实现,通过map创建的数据集将在reduce中使用,并且只将reduce的结果返回给driver程序,而不是更大的map数据集。

默认情况下,每个“转换“的RDD,在每次有运行其之上的“行为”时,都有可能进行再计算。不过,你也可以使用persist(或者cache)函数将RDD驻留在内存中,这时,Spark将保留元素在整个集群中,用于提高下一次的查询速度。同时,也支持将RDD保留在硬盘或者在多个节点上复制。

基本操作

为了说明RDD基础,请思考下面的简单程序:

lines = sc.textFile("data.txt")
lineLengths = lines.map(lambda s: len(s))
totalLength = lineLengths.reduce(lambda a, b: a + b)

第一行通过外部文件定义了一个基本的RDD。这个数据集并没有把数据加载到内存或者以其他方式进行操作:行仅仅是指向文件的指针。

第二行定义了lineLengths,作为map的转换结果。由于惰性计算,lineLengths并不会被马上计算出来。

最终,我们执行reduce,这是一个“行为”。此时,Spark将计算分解为各个机器上分开运行的任务,同时每台机器运行部分map和局部的reduce,并且只将结果返回给Driver程序。

可能我们晚些时候会再用到lineLengths,我们可以添加如下代码:

lineLengths.persist()

在执行reduce之前,执行第一次计算之后,lineLengths被保存在内存中。

提交函数到Spark

Spark的API很大程度上依赖于将函数提交到运行在集群上的Driver程序。有以下三种实现方式:

  • Lambda表达式 对于简单函数可以写成一个表达式。(Lambdas不支持多语句生命和没有返回值的声明)。
  • 在本地定义一个函数,被Spark调用,通常用于较长的代码。
  • 对象中的高级函数。

例如,提交一个比lambda支持的更长的函数,思考如下的代码:

    """MyScript.py"""
    if __name__ == "__main__":
        def myFunc(s):
            words = s.split(" ")
            return len(words)

        sc = SparkContext(...)
        sc.textFile("file.txt").map(myFunc)

注意,也可以在类实例中传递对方法的引用(与单例对象相反),这需要发送包含该类的对象以及方法,例如:

class MyClass(object):
    def func(self, s):
        return s
    def doStuff(self, rdd):
        return rdd.map(self.func)

这里,如果我们创建了一个新的MyClass对象,并且调用doStuff,其内部的map引用MyClass的func函数,所以,要将整个对象发送到集群。

以类似的方式,访问外部对象的字段将引用整个对象:

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

为了避免这个问题,最简单的方法是将字段复制到局部变量中,而不是从外部访问它:

def doStuff(self, rdd):
    field = self.field
    return rdd.map(lambda s: field + s)

理解闭包

在集群中执行代码时,一个关于 Spark 更难的事情是理解变量和方法的范围和生命周期. 修改其范围之外的变量 ,是RDD 操作混淆的常见原因。在下面的例子中,我们将看一下使用的 foreach() 代码递增累加计数器,但类似的问题,也可能会出现其他操作上。

考虑一个简单的 RDD 元素求和,以下行为可能不同,具体取决于是否在同一个 JVM 中执行. 一个常见的例子是当 Spark 运行在 local 本地模式(–master = local[n])时,与部署 Spark 应用到群集(例如,通过 spark-submit 到 YARN):

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)

Local(本地)vs. cluster(集群)模式

上面的代码行为是不确定的,并且可能无法按预期正常工作。执行作业时,Spark 会分解 RDD 操作到每个 executor 中的 task 里。在执行之前,Spark 计算任务的 closure(闭包)。闭包是指 executor 要在RDD上进行计算时必须对执行节点可见的那些变量和方法(在这里是foreach())。闭包被序列化并被发送到每个 executor。

闭包的变量副本发给每个 executor ,当 counter 被 foreach 函数引用的时候,它已经不再是 Driver node 的 counter 了。虽然在 driver node 仍然有一个 counter 在内存中,但是对 executors 已经不可见。executor 看到的只是序列化的闭包一个副本。所以 counter 最终的值还是 0,因为对 counter 所有的操作均引用序列化的 closure 内的值。

在 local 本地模式,在某些情况下的 foreach 功能实际上是同一 JVM 上的驱动程序中执行,并会引用同一个原始的 counter 计数器,实际上可能更新.

为了确保这些类型的场景明确的行为应该使用的 Accumulator 累加器。当一个执行的任务分配到集群中的各个 worker 结点时,Spark 的累加器是专门提供安全更新变量的机制。本指南的累加器的部分会更详细地讨论这些。

在一般情况下,closures - constructs 像循环或本地定义的方法,不应该被用于改动一些全局状态。Spark中没有定义或保证引用集群外部对象的行为。有些代码可能以本地模式的方式运行,但是这只是偶然的,这样的代码在分布式模式下不会像预期的那样运行。有时需要用Accumulator(累加器)来替代全局的聚合计算。

打印 RDD 的 elements

另一种常见的语法用于打印 RDD 的所有元素使用 rdd.foreach(println) 或 rdd.map(println)。在一台机器上,这将产生预期的输出和打印 RDD 的所有元素。然而,在集群 cluster 模式下,stdout 输出被正在执行写操作的 executors 的 stdout 代替,而不是在一个Driver程序上,因此 stdout 的 Driver 程序不会显示这些!要打印 Driver 程序的所有元素,可以使用的 collect() 方法首先把 RDD 放到 Driver 程序节点上: rdd.collect().foreach(println)。这可能会导致 Driver 程序耗尽内存,虽说,因为 collect() 获取整个 RDD 到一台机器; 如果你只需要打印 RDD 的几个元素,一个更安全的方法是使用 take(): rdd.take(100).foreach(println)。

与 Key-Value Pairs 一起使用

虽然Spark的大部分操作都工作在包含任意类型的RDD上,有少量特殊操作只有键值对类型的RDD可以支持。最常见的分布式操作是"shuffle",例如按照key进行的分组和聚合操作。

在Python中,这些操作对包含内置Python元组(如1, 2)的RDDS进行工作。只需创建这些元组,然后调用所需的操作。

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

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

我们也可以使用counts.sortByKey(),例如,按照字母顺序对键值对进行排序,最终counts.collect()以对象集合的形式返回给driver程序。

Transformations(转换)

Transformation(转换) Meaning(含义)
map(func) 返回一个新的 distributed dataset(分布式数据集),它由每个 source(数据源)中的元素应用一个函数 func 来生成.
filter(func) 返回一个新的 distributed dataset(分布式数据集),它由每个 source(数据源)中应用一个函数 func 且返回值为 true 的元素来生成.
flatMap(func) 与 map 类似,但是每一个输入的 item 可以被映射成 0 个或多个输出的 items(所以 func 应该返回一个 Seq 而不是一个单独的 item).
mapPartitions(func) 与 map 类似,但是单独的运行在在每个 RDD 的 partition(分区,block)上,所以在一个类型为 T 的 RDD 上运行时 func 必须是 Iterator => Iterator 类型.
mapPartitionsWithIndex(func) 与 mapPartitions 类似,但是也需要提供一个代表 partition 的 index(索引)的 interger value(整型值)作为参数的 func,所以在一个类型为 T 的 RDD 上运行时 func 必须是 (Int, Iterator) => Iterator 类型.
sample(withReplacement, fraction, seed) 样本数据,设置是否放回(withReplacement), 采样的百分比(fraction)、使用指定的随机数生成器的种子(seed).
union(otherDataset) 反回一个新的 dataset,它包含了 source dataset(源数据集)和 otherDataset(其它数据集)的并集.
intersection(otherDataset) 返回一个新的 RDD,它包含了 source dataset(源数据集)和 otherDataset(其它数据集)的交集.
distinct([numTasks])) 返回一个新的 dataset,它包含了 source dataset(源数据集)中去重的元素.
groupByKey([numTasks]) 在一个 (K, V) pair 的 dataset 上调用时,返回一个 (K, Iterable) .
 注意: 如果分组是为了在每一个 key 上执行聚合操作(例如,sum 或 average),此时使用 reduceByKey 或 aggregateByKey 来计算性能会更好.
注意: 默认情况下,并行度取决于父 RDD 的分区数。可以传递一个可选的 numTasks 参数来设置不同的任务数.
reduceByKey(func, [numTasks]) 在 (K, V) pairs 的 dataset 上调用时, 返回 dataset of (K, V) pairs 的 dataset, 其中的 values 是针对每个 key 使用给定的函数 func 来进行聚合的, 它必须是 type (V,V) => V 的类型. 像 groupByKey 一样, reduce tasks 的数量是可以通过第二个可选的参数来配置的.
aggregateByKey(zeroValue)(seqOp, combOp, [numTasks]) 在 (K, V) pairs 的 dataset 上调用时, 返回 (K, U) pairs 的 dataset,其中的 values 是针对每个 key 使用给定的 combine 函数以及一个 neutral “0” 值来进行聚合的. 允许聚合值的类型与输入值的类型不一样, 同时避免不必要的配置. 像 groupByKey 一样, reduce tasks 的数量是可以通过第二个可选的参数来配置的.
sortByKey([ascending], [numTasks]) 在一个 (K, V) pair 的 dataset 上调用时,其中的 K 实现了 Ordered,返回一个按 keys 升序或降序的 (K, V) pairs 的 dataset, 由 boolean 类型的 ascending 参数来指定.
join(otherDataset, [numTasks]) 在一个 (K, V) 和 (K, W) 类型的 dataset 上调用时,返回一个 (K, (V, W)) pairs 的 dataset,它拥有每个 key 中所有的元素对。Outer joins 可以通过 leftOuterJoin, rightOuterJoin 和 fullOuterJoin 来实现.
cogroup(otherDataset, [numTasks]) 在一个 (K, V) 和的 dataset 上调用时,返回一个 (K, (Iterable, Iterable)) tuples 的 dataset. 这个操作也调用了 groupWith.
cartesian(otherDataset) 在一个 T 和 U 类型的 dataset 上调用时,返回一个 (T, U) pairs 类型的 dataset(所有元素的 pairs,即笛卡尔积).
pipe(command, [envVars]) 通过使用 shell 命令来将每个 RDD 的分区给 Pipe。例如,一个 Perl 或 bash 脚本。RDD 的元素会被写入进程的标准输入(stdin),并且 lines(行)输出到它的标准输出(stdout)被作为一个字符串型 RDD 的 string 返回.
coalesce(numPartitions) Decrease(降低)RDD 中 partitions(分区)的数量为 numPartitions。对于执行过滤后一个大的 dataset 操作是更有效的.
repartition(numPartitions) Reshuffle(重新洗牌)RDD 中的数据以创建或者更多的 partitions(分区)并将每个分区中的数据尽量保持均匀. 该操作总是通过网络来 shuffles 所有的数据.
repartitionAndSortWithinPartitions(partitioner) 根据给定的 partitioner(分区器)对 RDD 进行重新分区,并在每个结果分区中,按照 key 值对记录排序。这比每一个分区中先调用 repartition 然后再 sorting(排序)效率更高,因为它可以将排序过程推送到 shuffle 操作的机器上进行.

###Actions(动作)

Action(动作) Meaning(含义)
reduce(func) 使用函数 func 聚合 dataset 中的元素,这个函数 func 输入为两个元素,返回为一个元素。这个函数应该是可交换(commutative )和关联(associative)的,这样才能保证它可以被并行地正确计算.
collect() 在 driver 程序中,以数组的形式返回dataset中的所有元素。这在过滤器(filter)或其他操作(other operation)之后返回较小(sufficiently small)的数据子集通常比较有用.
count() 返回 dataset 中元素的个数.
first() 返回 dataset 中的第一个元素(类似于 take(1).
take(n) 将数据集中的前 n 个元素作为一个 array 数组返回.
takeSample(withReplacement, num, [seed]) 对一个 dataset 进行随机抽样,返回一个包含 num 个随机抽样(random sample)元素的数组,参数 withReplacement 指定是否有放回抽样,参数 seed 指定生成随机数的种子.
takeOrdered(n, [ordering]) 返回 RDD 按自然顺序(natural order)或自定义比较器(custom comparator)排序后的前 n 个元素.
saveAsTextFile(path) 将 dataset 中的元素以文本文件(或文本文件集合)的形式写入本地文件系统、HDFS 或其它 Hadoop 支持的文件系统中的给定目录中。Spark 将对每个元素调用 toString 方法,将数据元素转换为文本文件中的一行记录.
saveAsSequenceFile(path) (Java and Scala) 将 dataset 中的元素以 Hadoop SequenceFile 的形式写入到本地文件系统、HDFS 或其它 Hadoop 支持的文件系统指定的路径中。该操作可以在实现了 Hadoop 的 Writable 接口的键值对(key-value pairs)的 RDD 上使用。在 Scala 中,它还可以隐式转换为 Writable 的类型(Spark 包括了基本类型的转换,例如 Int, Double, String 等等).
saveAsObjectFile(path) (Java and Scala) 使用 Java 序列化(serialization)以简单的格式(simple format)编写数据集的元素,然后使用 SparkContext.objectFile() 进行加载.
countByKey() 仅适用于(K,V)类型的 RDD 。返回具有每个 key 的计数的 (K , Int)pairs 的 hashmap.
foreach(func) 对 dataset 中每个元素运行函数 func 。这通常用于副作用(side effects),例如更新一个 Accumulator(累加器)或与外部存储系统(external storage systems)进行交互。
注意:修改除 foreach()之外的累加器以外的变量(variables)可能会导致未定义的行为(undefined behavior

Spark RDD的API还暴露了一些 actions(操作)的异步版本,例如针对 foreach的foreachAsync,它们会立即返回一个FutureAction到调用者,而不是在完成action时阻塞。这可以用于管理或等待 action 的异步执行。

Shuffle 操作

Spark 里的某些操作会触发 shuffle。shuffle 是spark 重新分配数据的一种机制,使得这些数据可以跨不同的区域进行分组。这通常涉及在 executors 和 机器之间拷贝数据,这使得 shuffle 成为一个复杂的、代价高的操作。

后台操作

为了明白 reduceByKey 操作的过程,我们以 reduceByKey 为例。reduceBykey 操作产生一个新的 RDD,其中 key 所有相同的的值组合成为一个 tuple - key 以及与 key 相关联的所有值在 reduce 函数上的执行结果。面临的挑战是,一个 key 的所有值不一定都在一个同一个 partition 分区里,甚至是不一定在同一台机器里,但是它们必须共同被计算。

在 Spark 里,特定的操作需要数据不跨分区分布。在计算期间,一个任务在一个分区上执行,为了所有数据都在单个reduceByKey的reduce 任务上运行,我们需要执行一个 all-to-all 操作。它必须从所有分区读取所有的key和key对应的所有的值,并且跨分区聚集去计算每个 key 的结果——这个过程就叫做shuffle。

尽管每个分区新 shuffle 的数据集将是确定的,分区本身的顺序也是这样,但是这些数据的顺序是不确定的。如果希望 shuffle 后的数据是有序的,可以使用:

mapPartitions 对每个 partition 分区进行排序,例如,sorted。
repartitionAndSortWithinPartitions 在分区的同时对分区进行高效的排序。
sortBy对RDD进行全局的排序。
触发的shuffle操作包括repartition操作,如repartition和coalesce,ByKey操作(除了 counting外)。

性能影响

该 Shuffle 是一个代价比较高的操作,它涉及磁盘 I/O、数据序列化、网络I/O。为了准备shuffle 操作的数据,Spark启动了一系列的任务,map任务组织数据,reduce完成数据的聚合。这些术语来自 MapReduce,跟Spark的map操作和reduce操作没有关系。

在内部,一个map任务的所有结果数据会保存在内存,直到内存不能全部存储为止。然后,这些数据将基于目标分区进行排序并写入一个单独的文件中。在reduce 时,任务将读取相关的已排序的数据块。

某些shuffle操作会大量消耗堆内存空间,因为shuffle操作在数据转换前后,需要在使用内存中的数据结构对数据进行组织。需要特别说明的是,reduceByKey和aggregateByKey在map时会创建这些数据结构,ByKey操作在reduce时创建这些数据结构。当内存满的时候,Spark会把溢出的数据存到磁盘上,这将导致额外的磁盘I/O开销和垃圾回收开销的增加。

shuffle操作还会在磁盘上生成大量的中间文件。在Spark 1.3中,这些文件将会保留至对应的RDD不在使用并被垃圾回收为止。这么做的好处是,如果在Spark重新计算RDD的血统关系(lineage)时,shuffle 操作产生的这些中间文件不需要重新创建。如果Spark应用长期保持对RDD的引用,或者垃圾回收不频繁,这将导致垃圾回收的周期比较长。这意味着,长期运行Spark任务可能会消耗大量的磁盘空间。临时数据存储路径可以通过SparkContext中设置参数spark.local.dir进行配置。

shuffle操作的行为可以通过调节多个参数进行设置。详细的说明请看Spark配置指南中的“Shuffle 行为” 部分。

RDD Persistence(持久化)

Spark中一个很重要的能力是将数据persisting持久化(或称为 caching 缓存),在多个操作间都可以访问这些持久化的数据。当持久化一个RDD时,每个节点的其它分区都可以使用RDD在内存中进行计算,在该数据上的其他action操作将直接使用内存中的数据。这样会让以后的action操作计算速度加快(通常运行速度会加速 10 倍)。缓存是迭代算法和快速的交互式使用的重要工具。

RDD可以使用persist()方法或cache()方法进行持久化。数据将会在第一次action操作时进行计算,并缓存在节点的内存中。Spark的缓存具有容错机制如果一个缓存的RDD的某个分区丢失了,Spark将按照原来的计算过程,自动重新计算并进行缓存。

另外,每个持久化的RDD可以使用不同的storage level存储级别进行缓存,例如,持久化到磁盘、已序列化的Java 对象形式持久化到内存(可以节省空间)、跨节点间复制、以off-heap的方式存储在 Tachyon。这些存储级别通过传递一个StorageLevel对象(Scala, Java, Python)给persist()方法进行设置。cache()方法是使用默认存储级别的快捷设置方法,默认的存储级别是 StorageLevel.MEMORY_ONLY(将反序列化的对象存储到内存中)。详细的存储级别介绍如下:

Storage Level(存储级别) Meaning(含义)
MEMORY_ONLY 将 RDD 以反序列化的 Java 对象的形式存储在 JVM 中. 如果内存空间不够,部分数据分区将不再缓存,在每次需要用到这些数据时重新进行计算. 这是默认的级别.
MEMORY_AND_DISK 将 RDD 以反序列化的 Java 对象的形式存储在 JVM 中。如果内存空间不够,将未缓存的数据分区存储到磁盘,在需要使用这些分区时从磁盘读取.
MEMORY_ONLY_SER (Java and Scala) 将 RDD 以序列化的 Java 对象的形式进行存储(每个分区为一个 byte 数组)。这种方式会比反序列化对象的方式节省很多空间,尤其是在使用 fast serializer 时会节省更多的空间,但是在读取时会增加 CPU 的计算负担.
MEMORY_AND_DISK_SER (Java and Scala) 类似于 MEMORY_ONLY_SER ,但是溢出的分区会存储到磁盘,而不是在用到它们时重新计算.
DISK_ONLY 只在磁盘上缓存 RDD.
MEMORY_ONLY_2, MEMORY_AND_DISK_2, etc. 与上面的级别功能相同,只不过每个分区在集群中两个节点上建立副本.
OFF_HEAP (experimental 实验性) 类似于 MEMORY_ONLY_SER, 但是将数据存储在 off-heap memory 中. 这需要启用 off-heap 内存

注意: 在Python中,stored objects总是使用 Pickle library 来序列化对象, 所以无论你选择序列化级别都没关系。在Python中可用的存储级别有MEMORY_ONLY,MEMORY_ONLY_2,MEMORY_AND_DISK, MEMORY_AND_DISK_2,DISK_ONLY,和DISK_ONLY_2。

在shuffle操作中(例如 reduceByKey),即便是用户没有调用persist方法,Spark 也会自动缓存部分中间数据.这么做的目的是,在 shuffle 的过程中某个节点运行失败时,不需要重新计算所有的输入数据。如果用户想多次使用某个RDD,强烈推荐在该RDD上调用persist方法。

如何选择存储级别 ?

Spark 的存储级别的选择,核心问题是在memory内存使用率和CPU效率之间进行权衡。建议按下面的过程进行存储级别的选择:

如果您的RDD适合于默认存储级别 (MEMORY_ONLY)。这是CPU效率最高的选项,允许RDD上的操作尽可能快地运行.

如果不是, 试着使用MEMORY_ONLY_SER和选择一个能快速序列化的类库以使对象更加节省空间,但仍然能够快速访问。(Java和Scala)

不要溢出到磁盘,除非计算您的数据集的函数是昂贵的,,或者它们过滤大量的数据,否则,重新计算分区可能与从磁盘读取分区一样快。

如果需要快速故障恢复,请使用复制的存储级别 (例如,如果使用Spark服务响应来自网络应用程序的请求)。All存储级别通过重新计算丢失的数据来提供完整的容错能力,但复制的数据可让您继续在 RDD 上运行任务,而无需等待重新计算一个丢失的分区。

删除数据

Spark会自动监视每个节点上的缓存使用情况,并使用least-recently-used(LRU)的方式来丢弃旧数据分区。如果您想手动删除RDD而不是等待它掉出缓存,使用RDD.unpersist()方法。

共享变量

通常情况下,一个传递给 Spark 操作(例如 map 或 reduce)的函数 func 是在远程的集群节点上执行的。该函数 func 在多个节点执行过程中使用的变量,是同一个变量的多个副本。这些变量的以副本的方式拷贝到每个机器上,并且各个远程机器上变量的更新并不会传播回 driver program(驱动程序)。通用且支持 read-write(读-写) 的共享变量在任务间是不能胜任的。所以,Spark 提供了两种特定类型的共享变量 : broadcast variables(广播变量)和 accumulators(累加器)。

广播变量

Broadcast variables(广播变量)允许程序员将一个 read-only(只读的)变量缓存到每台机器上,而不是给任务传递一个副本。它们是如何来使用呢,例如,广播变量可以用一种高效的方式给每个节点传递一份比较大的 input dataset(输入数据集)副本。在使用广播变量时,Spark 也尝试使用高效广播算法分发 broadcast variables(广播变量)以降低通信成本。

Spark 的 action(动作)操作是通过一系列的 stage(阶段)进行执行的,这些 stage(阶段)是通过分布式的 “shuffle” 操作进行拆分的。Spark 会自动广播出每个 stage(阶段)内任务所需要的公共数据。这种情况下广播的数据使用序列化的形式进行缓存,并在每个任务运行前进行反序列化。这也就意味着,只有在跨越多个 stage(阶段)的多个任务会使用相同的数据,或者在使用反序列化形式的数据特别重要的情况下,使用广播变量会有比较好的效果。

广播变量通过在一个变量 v 上调用 SparkContext.broadcast(v) 方法来进行创建。广播变量是 v 的一个 wrapper(包装器),可以通过调用 value 方法来访问它的值。代码示例如下:

broadcastVar = sc.broadcast([1, 2, 3])

broadcastVar.value
输出
[1, 2, 3]

在创建广播变量之后,在集群上执行的所有的函数中,应该使用该广播变量代替原来的v值,所以节点上的v 最多分发一次。另外,对象v在广播后不应该再被修改,以保证分发到所有的节点上的广播变量具有同样的值(例如,如果以后该变量会被运到一个新的节点)。

Accumulators(累加器)

Accumulators(累加器)是一个仅可以执行 “added”(添加)的变量来通过一个关联和交换操作,因此可以高效地执行支持并行。累加器可以用于实现counter(计数,类似在MapReduce中那样)或者 sums(求和)。原生Spark支持数值型的累加器,并且程序员可以添加新的支持类型。
一个累加器通常通过调用SparkContext.accumulator(v)方法,从初始化值v来创建。累加器在集群中运行时,可以由函数或+=操作来进行累加。但是,集群中并不能读取累积器的值,只用通过Driver程序才能读取累积器的值。
下面的代码显示了一个累加器,用来累加数组的元素:

accum = sc.accumulator(0)
accum
Accumulator
sc.parallelize([1, 2, 3, 4]).foreach(lambda x: accum.add(x))
accum.value

你可能感兴趣的:(大数据生态,spark,spark入门,spark,rdd,spark官档)