Skip to the content.

Abstract

我们提出了一种分布式内存抽象——弹性分布式数据集(RDDs),可以让程序员在大型集群上以容错的方式执行内存计算。RDDs的动机来源于当前计算框架处理不太高效的两类应用:迭代算法和交互式数据挖掘工具。在这两种情况下,将数据保存在内存中可以将性能提高一个数量级。为了有效实现容错,RDDs提供了一种受限形式的共享内存,这种共享内存是基于对共享状态的粗粒度转换而实现的,而非基于对共享状态的细粒度更新来实现的。然而,我们表明RDDs具有足够的表达能力来捕获广泛的计算类别,包括最近针对迭代作业的专门编程模型,如Pregel,以及这些模型目前无法捕获的新应用程序。我们在一个名为Spark的系统中实现了RDDs,我们通过各种用户应用程序和基准测试来评估该系统。

1. Introduction

集群计算框架,如MapReduce [10] 和 Dryad [19],已广泛应用于大规模数据分析。这些系统允许用户使用一组高级操作符编写并行计算,而无需担心工作分布和容错。这两种框架简化了在大型集群上进行数据处理和分析的过程。

尽管当前的框架为访问集群计算资源提供了众多抽象,但却缺乏利用分布式内存的抽象。这使得它们在处理一类重要的新兴应用方面效率较低:那些在多个计算中重用中间结果的应用。数据重用(Data reuse)在许多迭代机器学习和图算法中很常见,例如PageRank、K-均值聚类和逻辑回归。交互式数据挖掘也是一个具有说服力的应用场景,用户在同一数据子集上运行多个临时查询。不幸的是,在大多数当前框架中,计算之间重用数据的唯一方法(例如,在两个MapReduce作业之间)是将其写入外部稳定存储系统,例如分布式文件系统。这会由于数据复制、磁盘I/O和序列化而产生大量开销,从而在很大程度上影响应用程序的执行时间。

认识到这个问题,研究人员为需要数据重用的某些应用开发了专门的框架。例如,Pregel [22] 是一个将中间数据保存在内存中的迭代图计算系统,而HaLoop [7] 提供了一个迭代MapReduce接口。然而,这些框架仅支持特定的计算模式(例如,循环一系列MapReduce步骤),并隐式地为这些模式执行数据共享。它们没有为更通用的重用提供抽象,例如允许用户将多个数据集加载到内存中并在它们之间运行临时查询。

在本文中,我们提出了一种名为弹性分布式数据集(RDDs)的新抽象,它能够在广泛应用中实现有效的数据重用。RDDs是可容错的可并行化数据结构,允许用户显式地将中间结果持久化到内存中,控制其分区以优化数据存放,并使用丰富的操作符集合对其进行操作

设计RDDs的主要挑战在于定义一个能有效提供容错能力的编程接口。现有的集群内存存储抽象,如分布式共享内存[24]、键值存储[25]、数据库和Piccolo[27],都是基于对可变状态(例如,表中的单元格)的细粒度更新提供接口。采用这个接口,提供容错的唯一方法是将数据复制到不同的机器上或在不同的机器上记录更新。然而,对于数据密集型工作负载,这两种方法都非常昂贵,因为它们需要在集群网络中复制大量数据,而集群网络的带宽远低于RAM,同时还带来了巨大的存储开销。

与这些系统相比,RDDs提供了一种基于粗粒度转换(例如,map、filter和join)的接口,该接口可对许多数据项执行相同的操作。这使得它们可以通过记录用于构建数据集(其lineage-血统)的转换而非实际数据来有效提供容错能力。

在数据集的存储中,“lineage”是指产生该数据集的一系列转换和依赖关系。通过跟踪lineage,系统可以通过重新应用输入数据或中间数据上的转换来重建丢失或损坏的数据。这种信息用于容错和恢复,因为它允许系统在不需要昂贵的数据复制的情况下恢复数据。

在某些RDD中进行数据检查点可能是有用的,特别是当lineage变得很长时,我们在第5.4节中讨论了如何实现它。

如果RDD的一个分区丢失了,RDD有足够的信息来了解它是如何从其他RDDs派生出来的,从而仅重新计算丢失的分区。因此,可以恢复丢失的数据,而且通常速度非常快,无需昂贵的复制操作。

虽然基于粗粒度转换的接口一开始可能看起来有限,但RDDs非常适合许多并行应用,因为这些应用会自然地将相同的操作应用于多个数据项。实际上,我们证明RDDs可以有效地表达迄今为止已经作为单独系统提出的许多集群编程模型,包括MapReduce、DryadLINQ、SQL、Pregel和HaLoop,以及这些系统无法捕获的新应用,例如交互式数据挖掘。我们相信,RDDs能够适应以前只能通过引入新框架才能满足的计算需求,这是RDD抽象能力的最可靠证据。

我们已经在一个名为Spark的系统中实现了RDDs,它正在加州大学伯克利分校以及几家公司的研究和生产应用中使用。Spark提供了一种类似于DryadLINQ[31]的便捷的语言集成编程接口,使用Scala编程语言[2]。此外,Spark还可以交互式地用于从Scala解释器查询大数据集。我们相信,Spark是第一个允许通用编程语言以交互式的速度用于集群内存数据挖掘的系统。

我们通过微基准测试和用户应用程序的测量来评估RDDs和Spark。实验表明,对于迭代应用,Spark的速度比Hadoop快20倍;在实际的数据分析报告中,Spark能提速40倍;同时,Spark可以交互式地在5-7秒的延迟内扫描1TB的数据集。更重要的是,为了展示RDDs的通用性,我们已经在Spark之上实现了Pregel和HaLoop编程模型,包括它们所采用的布局优化,作为相对较小的库(每个库200行代码)。

本文首先概述RDDs(第2节)和Spark(第3节),然后讨论RDDs的内部表示(第4节)、实现(第5节)以及实验结果(第6节)。最后,我们讨论了RDDs如何捕获现有的集群编程模型(第7节),概述相关工作(第8节)并做出总结。

1.1 fine-grained or coarse-grained interface

在这个背景下,粗粒度和细粒度接口是指操作在数据上应用的粒度层次。 粗粒度接口是一种将操作一次性应用于大量数据元素的接口。这意味着操作通常在单个步骤中转换或处理多个数据项。RDDs使用诸如map、filter和join这样的粗粒度转换,这些转换在多个数据项上执行相同的操作^[2]^。

尽管单个RDD是不可变的,但通过使用多个RDD表示数据集的多个版本,仍然可以实现可变状态。我们将RDD设为不可变,是为了更容易描述lineage图(转换过程与依赖关系图),但我们的抽象可以是版本化数据集,并在lineage图中跟踪版本,这样做是等效的。

相比之下,细粒度接口允许对单个数据元素或小组数据元素进行操作。这为指定哪些数据元素应受到特定操作影响提供了更多灵活性,但可能需要更多的开销来管理数据上的操作。

在RDDs和分布式数据处理的上下文中,像RDDs这样的粗粒度接口更适合并行应用,因为它们可以有效地将相同的转换应用于众多数据项,从而提高容错能力并简化恢复过程。

2. Resilient Distributed Datasets (RDDs)

本节概述了RDDs。我们首先定义RDDs(第2.1节),然后介绍它们在Spark中的编程接口(第2.2节)。接下来,我们将RDDs与更细粒度的共享内存抽象进行比较(第2.3节)。最后,我们讨论RDD模型的局限性(第2.4节)。

2.1 RDD Abstraction

正式地说,一个RDD是一个**只读的**、**分区的**记录集合。RDDs只能通过对(1)稳定存储中的数据或(2)其他RDDs的确定性操作来创建。我们将这些操作称为转换,以便将它们与RDDs上的其他操作区分开。转换的例子包括map、filter和join。

一旦RDD(弹性分布式数据集)的分区生成后,它是只读的。RDD是不可变的,这意味着在创建后无法更改RDD的内容。但是,您可以通过对现有RDD执行转换操作来生成新的RDD。这种不可变性特性有助于确保容错性和更容易实现分布式计算。

RDD并不需要始终实现物化(materialized),即将RDD的实际数据表现出来(在内存中或稳定存储中)。相反,RDD有足够的信息来了解它是如何从其他数据集(它的血统)中派生出来的,以便从稳定存储中的数据计算它的分区。这是一个强大的特性:本质上,程序不能引用在失败后无法重建的RDD。

在这里,“物化”的意思是把数据在内存或硬盘上实际地表示出来。但其实RDDs并不是时刻都需要物化的。因为它们存好了关于它们的血统(如何从其他数据集中派生而来)的足够信息,所以要是需要,它们可以通过稳定存储里的源数据重新计算。 当一个RDD还没完全物化时,它的分区只有在操作或动作需要时才会被计算。这种方法叫做延迟求值,有助于节省内存和计算资源。一旦RDD被物化了(也就是说它的数据已经在内存或硬盘上了),就可以立马为其他操作或动作所用,不用再重新计算分区。

最后,用户可以控制RDD的其他两个方面:持久性和分区。用户可以指示他们将重用哪些RDD,并为它们选择一个存储策略(如内存存储)。另外,用户还可以要求基于每个记录中的一个键将RDD的元素分区到不同的机器上。这对于布局优化非常有用,例如确保将要连接在一起的两个数据集以相同的方式进行哈希分区。

2.2 Spark Programming Interface

Spark通过类似于DryadLINQ [31]和FlumeJava [8]的语言集成API公开RDDs,其中每个数据集表示为一个对象,使用这些对象上的方法调用转换。

程序员首先通过对稳定存储中的数据进行转换(例如,map和filter)来定义一个或多个RDD。然后,他们可以在操作中使用这些RDD,这些操作将向应用程序返回一个值或将数据导出到存储系统。操作的例子包括count(返回数据集中的元素数量)、collect(返回元素本身)和save(将数据集输出到存储系统)。像DryadLINQ一样,Spark在第一次在操作中使用RDD时会惰性计算RDD,以便可以将转换管道化。

此外,程序员可以调用persist方法来指示他们在未来操作中想要重用哪些RDD。Spark默认将持久性RDD(未来操作中要重用的RDDs)保存在内存中,但如果内存不足,它可以将它们溢出到磁盘上。用户还可以通过persist标志请求其他持久化策略,如将RDD仅存储在磁盘上或在机器之间复制。最后,用户可以为每个RDD设置持久性优先级,以指定哪个内存数据应首先溢出到磁盘上。

2.2.1 Example: Console Log Mining

假设一个网络服务遇到了错误,操作员希望搜索Hadoop文件系统(HDFS)中的数以TB的日志以找到原因。使用Spark,操作员可以将日志中的错误消息加载到一组节点的RAM中,并进行交互式查询。首先,她会输入以下Scala代码:

lines = spark.textFile("hdfs://...")
errors = lines.filter(_.startswith("ERROR"))
errors.persist()

第一行定义了一个由 HDFS 文件支持的 RDD(作为文本行的集合),而第二行从中派生出一个过滤后的 RDD。第三行要求errors持久化在内存中,以便在各个查询之间共享。请注意,filter的参数是表示闭包的 Scala 语法。

在这个阶段,集群上还没有执行任何工作。然而,用户现在可以在操作中使用RDD,例如,计算消息的数量。errors.count()

用户还可以在RDDs上执行进一步的转换,并使用它们的结果,如下所示:

// Count errors mentioning MySQL:
errors.filter(_.contains("MySQL")).count()
// Return the time fields of errors mentioning
// HDFS as an array (assuming time is field
// number 3 in a tab-separated format):
errors.filter(_.contains("HDFS"))
	  .map(_.split('\t')(3))
	  .collect(

在涉及errors的第一个操作运行之后,Spark 将把errors分区存储在内存中,大大加快了后续对其的计算速度。请注意,基本的 RDD,lines,并未加载到 RAM 中。这是可取的,因为错误消息可能只占数据的一小部分(足够小以适应内存)。

一个RDD可能会包含多个分区,这里只是加载了这个RDD中的errors分区。

最后,为了说明我们的模型如何实现容错能力,我们展示了图1(如下图)中第三个查询的RDD的lineage(转换和依赖关系)图。

image-20230718103122061

如上图所示,在这个查询中,我们从errors 开始,errors 是 lines 运行filter得到的结果,并在进一步进行filter和map之前运行了一个collect操作(返回元素本身),此时errors就作为一个单独的内容返回给我们了,这时正常情况下不必再依赖lines。Spark调度程序将对后两个转换进行流水线处理,并将一组任务发送给计算这些任务的节点,这些节点持有errors的缓存分区。此外,如果丢失了errors的一个分区,Spark会通过仅对lines对应分区应用filter来重建它。

2.3 Advantages of the RDD Model

为了理解RDD作为分布式内存抽象的优势,我们在表1中将它们与分布式共享内存(DSM,即列存)进行比较。

image-20230718104942991

在DSM系统中,应用程序读取和写入全局地址空间中的任意位置。请注意,在此定义下,我们不仅包括传统的共享内存系统[24],还包括其他系统,其中应用程序对共享状态进行细粒度写操作,包括提供共享DHT的Piccolo[27]和分布式数据库。 DSM是一个非常通用的抽象,但这种通用性使它在商用集群上实现高效和容错性更加困难。

在分布式系统中,”straggler mitigation”(中文翻译为“滞后者缓解”)指的是一种技术,旨在解决在分布式计算中遇到的一种常见问题:某些计算节点比其他节点运行得更慢,从而导致整个计算任务的速度变慢。

通常,分布式计算中的任务会被分成多个子任务,并且每个子任务会分配给不同的计算节点来处理。但是,当某些计算节点的运行速度比其他节点慢时,这些节点处理的子任务可能需要更长的时间来完成。结果,这些节点会成为整个计算任务的瓶颈,从而导致整个任务速度变慢。

为了解决这个问题,”straggler mitigation”技术通常会使用一些方法,例如数据复制、任务重启、任务分割、资源调度等来缓解滞后者的影响。其中,数据复制是最常用的方法,它可以将数据复制到多个计算节点上,从而避免某个节点成为瓶颈。任务重启是另一种方法,它可以在某些节点处理任务失败时重新启动任务,从而避免任务失败对整个任务的影响。

RDDs和DSM的主要区别在于,RDDs只能通过粗粒度转换来创建(written),而分布式共享内存(DSM)允许对每个内存位置进行读写操作^[3]^。 这限制了RDD只能用于执行批量写入的应用程序,但允许更高效的容错。特别是,RDD不需要承担检查点的开销,因为它们可以通过血缘关系进行恢复^[4]^。此外,只有在失败时才需要重新计算丢失的RDD分区,而且它们可以在不同的节点上并行重新计算,无需回滚整个程序。

^[3]^请注意,对RDD的读取仍然可以是细粒度的。例如,应用程序可以将RDD视为大型只读查找表。

^[4]^在某些应用程序中,在长转换链中检查RDD仍然有帮助,我们将在第5.4节中讨论。然而,由于RDD是不可变的,因此可以在后台完成此操作,而无需像在DSM中那样获取整个应用程序的快照。

RDD的第二个优点是其不可变性让系统通过运行缓慢任务的备份副本(如MapReduce [10]中的备份任务)来减轻慢速节点(拖后腿者)的影响。使用DSM实现备份任务会很困难,因为两个任务副本将访问相同的内存位置并干扰彼此的更新。

在RDDs中,由于RDDs分区的生成是通过将lineage中的信息应用于初始数据,而不是直接复制其他副本上的信息,因此这不会与其他副本上的操作产生冲突。所以,RDDs的备份操作可以在后台进行。

最后,相较于DSM(分布式共享内存),RDDs(弹性分布式数据集)在两个方面具有更多优势。首先,在对RDDs进行批量操作时,运行时可以根据数据局部性来调度任务,以提高性能。其次,只要在扫描为基础的操作中使用RDDs,它们在内存不足时也能优雅降级。无法放入RAM的分区可以存储在硬盘上,性能将与当前的数据并行系统相当。

2.4 Applications Not Suitable for RDDs(应用场景)

正如引言中所讨论的,RDD最适合于批处理应用,这些应用对数据集的所有元素应用相同的操作。在这些情况下,RDD可以有效地将每个转换记住为一个lineage图中的一个步骤,并且可以在不需要记录大量数据的情况下恢复丢失的分区。然而,对于异步细粒度更新共享状态的应用,如web应用的存储系统或增量式web爬虫,RDD就不太适合了。对于这些应用,使用执行传统更新日志记录和数据检查点的系统会更高效,例如数据库、RAMCloud [25]、Percolator [26] 和Piccolo [27]。我们的目标是为批量分析提供高效的编程模型,并将这些异步应用留给专门的系统。

3. Spark Programming Interface

Spark 通过类似于 DryadLINQ [31] 的 Scala [2](一种针对 Java 虚拟机的静态类型函数式编程语言)中的语言集成 API 提供 RDD 抽象。我们选择了 Scala,因为它既简洁(便于交互式使用),又高效(由于静态类型)。然而,RDD 抽象并不需要函数式语言

image-20230719091111407

图2:Spark 运行时。用户的驱动程序启动多个工作节点,这些工作节点从分布式文件系统中读取数据块,并可以将计算后的 RDD 分区保留在内存中。

在 Spark 系统中,驱动程序负责协调分布式计算任务。它将需要处理的大型数据集分成多个较小的数据块,然后将这些数据块分配给各个工作节点。这些工作节点在分布式文件系统中读取和处理数据块,同时还可以将计算后的 RDD 分区存储在内存中以便后续操作更快访问。这种设计极大地提高了数据处理速度和性能。

要使用Spark,开发人员需要编写一个驱动程序(driver program),该程序连接到集群中的若干个工作节点,如上图所示。驱动程序定义一个或多个RDD,并对它们执行操作。驱动程序上的Spark代码跟踪RDD的lineage图。工作节点是长期运行的进程,可以在操作间将RDD分区存储在RAM中。

在2.2.1节的日志挖掘示例中,用户通过传递闭包(函数字面量)为RDD操作(如map)提供参数。Scala将每个闭包表示为一个Java对象,这些对象可以被序列化并在另一个节点上加载以便在网络上传递闭包。Scala还将在闭包中绑定的任何变量保存为Java对象中的字段。

例如,可以编写如下代码: var x = 5; rdd.map(_ + x) 这样就可以将5添加到RDD的每个元素。在这里,rdd是一个弹性分布式数据集(RDD)对象,map是其中的一个转换操作,用于对RDD中的每个元素进行处理。闭包(_ + x)用于定义对每个元素所执行的操作,也就是将每个元素与x(在这里,x的值为5)相加。这样,RDD中的每个元素都会与x相加,从而实现一个加法操作^[5]^ 。

我们在创建每个闭包时保存它,这样即使x发生变化,本例中的map也将始终添加5。

RDD本身是由元素类型参数化的静态类型对象。例如,RDD[Int]是一个整数类型集合的RDD。然而,由于Scala支持类型推断,我们的大多数示例都省略了类型。

虽然我们在Scala中暴露RDD的方法在概念上很简单,但我们必须使用`reflection`[33]解决Scala闭包对象的问题。此外,为了让Spark能够从Scala解释器使用,我们还需要更多工作,这将在第5.2节中讨论。尽管如此,我们不需要修改Scala编译器。

3.1 RDD Operations in Spark

表2(如下表所示)列出了Spark中可用的主要RDD变换和操作。我们给出了每个操作的签名,用方括号显示类型参数。请记住,transformations是定义新RDD的惰性操作(即生成对应的lineage图,而并不会真的去执行计算),而actions会启动计算,将值返回给程序或将数据写入外部存储。

image-20230719093226535

某些操作,如join,仅对键值对形式的RDD可用。此外,我们为了匹配Scala和其他函数式编程语言中的其他API,我们选择了类似的函数名称。例如,map操作是一对一映射,而flatMap操作将每个输入值映射到一个或多个输出值(类似于MapReduce中的map操作)。 这使得RDD的操作更加直观和一致,从而降低了学习曲线。通过这种方式,开发人员可以更容易地将他们现有的编程知识应用于Spark中的RDD操作,从而更高效地处理大规模数据。

除了这些操作符,用户还可以要求对 RDD 进行persist(让其停留在内存中)。此外,用户可以获取 RDD 的分区顺序,该顺序由 Partitioner 类表示,并根据它对另一个数据集进行分区。像 groupByKeyreduceByKeysort 这样的操作会自动生成具有哈希或范围分区的 RDD。

3.2 Example Applications

在2.2.1章节的数据挖掘示例中,我们补充了两个迭代应用:逻辑回归和PageRank。后者还展示了如何通过控制RDD的分区来提高性能。

3.2.1 Logistic Regression

许多机器学习算法的本质上是迭代的,因为它们运行迭代优化过程,例如梯度下降,以最大化函数。因此,通过将数据保留在内存中,它们可以运行得更快。

作为一个例子,以下程序实现了逻辑回归[14],这是一种常见的分类算法,用于寻找最佳分隔两组点(例如,垃圾邮件和非垃圾邮件)的超平面w。该算法使用梯度下降:它从一个随机值开始计算w,在每次迭代中,通过对数据进行w函数之和,以改进w的方向移动w。

val points = spark.textFile(...)
				  .map(parsePoint).persist()
var w = // random initial vector
for (i <- 1 to ITERATIONS) {
	val gradient = points.map{ p =>
		p.x *(1/(1+exp(-p.y*(w dot p.x)))-1)*p.y
    }.reduce((a,b) =>a + b)
	w-= gradient
}

首先,我们通过在文本文件上执行 map 转换来定义一个名为 points 的持久化RDD,该转换将每行文本解析为一个 Point 对象。然后,我们通过对 points 反复运行 map 和 reduce 来计算每一步的梯度,梯度计算这一步骤是通过对当前 w 使用一个函数来求和实现的。在迭代过程中将 points 保留在内存中可以获得 20 倍的速度提升,这在第 6.1 节中有所展示。

3.2.2 PageRank

在PageRank[6]算法中,数据共享的模式更加复杂。该算法通过累加指向某个文档(这里的某个指的就是下面说到的”每个文档”)的其他文档的贡献来迭代更新每个文档的排名。在每次迭代中,每个文档都将向其相邻文档发送贡献度 r/n,其中r表示其排名,n表示其相邻文档的数量。然后,它将自己的排名更新为α/N + (1 - α) ∑ ci,其中,求和是针对收到的所有贡献进行的,N表示文档的总数。在 Spark 中,我们可以按照如下方式编写 PageRank 算法:

image-20230724093712970

// Load graph as an RDD of (URL,outlinks) pairs
val links = spark.textFile(. . . ).map(...).persist()
var ranks = // RDD of (URL,rank) pairs
for (i <- 1 to ITERATIONS) {
    // Build an RDD of (targetURL,float) pairs
    // with the contributions sent by each page
    val contribs = links.join(ranks). flatMap {
        (url(linksrank)) =>
    	links.map(dest =>(destrank/links.size))
	}
     // Sum contributions by URL and get new ranks
    ranks = contribs.reduceByKey((x,y) =>x+y)
    .mapvalues(sum => a/N +(1-a)*sum)
}

这个程序导致了图 3 中的 RDD lineage图。在每次迭代中,我们都会根据之前迭代的 contribsranks 以及静态的 links 数据集创建一个新的 ranks 数据集。

请注意,尽管RDD是不可变的,但程序中的变量rankscontribs在每次迭代中指向不同的RDD。

该图的一个有趣特点是它随着迭代次数的增加而变长。因此,在具有多次迭代的任务中,可能需要可靠地复制 ranks 的某些版本以减少故障恢复时间[20]。用户可以通过调用带有 RELIABLE 标志的 persist 来执行此操作。然而,需要注意的是,links 数据集不需要被复制,因为可以通过在输入文件的块上重新运行 map 来有效地重建它的分区。这个数据集通常比 ranks 要大得多,因为每个文档有很多链接,但只有一个数字作为它的排名,所以使用lineage图恢复它的时间比检查程序的整个内存状态的系统要快。

最后,我们可以通过控制 RDDs 的分区来优化 PageRank 中的通信。如果为 links 指定一个分区(例如,通过 URL 将链接列表跨节点进行哈希分区),我们可以以相同的方式对 ranks 进行分区,并确保 links 和 ranks 之间的 join 操作不需要通信(因为每个 URL 的rank都将与其link list位于同一台机器上)。我们还可以编写一个自定义的 Partitioner 类来将link在一起的页面分组(例如,按域名分区 URLs)。这两种优化都可以通过在定义 links 时调用 partitionBy 来实现:

links = (
    spark.textFile(...)
    .map(...)
    .partitionBy(myPartFunc)
    .persist()
)

通过这种方式,我们可以利用 RDD 的分区特性减少迭代过程中的数据交换和通信开销,从而提高计算效率和性能。

在进行这个初始调用之后,links 和 ranks 之间的 join 操作将自动将每个 URL 的贡献聚合到其links列表所在的机器上,在那里计算其新rank,并将其与其link lists进行join操作。这种跨迭代的一致分区方式是像 Pregel 这样的专用框架的主要优化之一。RDDs 让用户直接表达这个目标。

通过在迭代过程中保持稳定的分区策略,我们可以避免在每次迭代中进行不必要的数据交换和数据移动。这有助于提高分布式计算任务的效率,尤其是在涉及到大量数据迭代的算法中。保持这种一致性的分区策略是专用大数据处理框架中的关键优化,RDD 提供了一种简洁的方法让用户直接实现这一优化。

4. Representing RDDs

为 RDD 提供抽象的挑战之一是选择一种能跟踪各种transformation lineage的表示法。理想情况下,实现 RDD 的系统应该提供尽可能丰富的transformation操作符(例如,表 2 中的操作符),并让用户以任意方式组合它们。我们提出了一种基于图形的简单 RDD 表示方法,以便实现这些目标。我们在 Spark 中使用这种表示方法支持了很多类型的转换,而不需要为每个转换添加特殊逻辑到调度器中,这极大地简化了系统设计。

image-20230724100243043

简而言之,我们提出了通过一个通用接口来表示每个 RDD,该接口暴露五个信息:

例如,表示 HDFS 文件的 RDD 对于文件的每个块都有一个分区,并知道每个块在哪些机器上。与此同时,对此 RDD 进行 map 操作的结果具有相同的分区,但在计算其元素时会将 map 函数应用于父数据。我们在表 3 中总结了这个接口。

在设计这个接口时,最有趣的问题是如何表示 RDD 之间的依赖关系。我们发现将依赖关系分为两类是既充分又有用的:窄依赖,其中父 RDD 的每个分区最多只被一个子 RDD 的分区使用;宽依赖,其中多个子分区可能依赖于它。例如,map 操作导致窄依赖,而 join 操作导致宽依赖(除非父 RDD 是 hash 分区的)。图 4 显示了其他示例(下面会对图4进行单独的说明)。

这种区分有两个方面的用途。首先,窄依赖允许在一个集群节点上进行流水线式的执行,可以计算所有父分区。例如,可以逐个元素地应用 map 操作后接 filter 操作。相比之下,宽依赖需要所有父分区的数据都可用,并在节点之间使用类似于 MapReduce 的操作进行洗牌。其次,在节点故障后的恢复过程中,窄依赖更高效,因为只需要重新计算丢失的父分区,并且可以在不同的节点上并行地重新计算。相比之下,在具有宽依赖关系的lineage图中,单个故障节点可能导致某个 RDD 的所有祖先节点的某个分区丢失,从而需要完全重新执行。

这种通用的 RDD 接口使得在 Spark 中实现大多数transformations只需要不到 20 行代码。实际上,即使是新的 Spark 用户也可以在不了解调度器细节的情况下实现新的转换(例如,采样和各种类型的joins操作)。以下是一些 RDD 实现的示例。

image-20230724105636011

HDFS files:在我们的示例中,输入的 RDDs 是 HDFS 上的文件。对于这些 RDDs,partitions 方法会为文件的每个块返回一个partition(每个 Partition 对象中都存储有该块的偏移量),preferredLocations 方法会给出存储该块的节点,iterator 方法会读取该块。

map:在任何 RDD 上调用 map 将返回一个 MappedRDD 对象。这个对象具有与其父 RDD 相同的分区和首选位置,但在其 iterator 方法中将传递给 map 的函数应用于父记录。

union: 在两个 RDD 上调用 union 将返回一个其分区是父分区并集的 RDD。通过对应父分区的窄依赖关系,每个子分区都可以计算出来。

注意,论文中的union操作不会丢弃重复的值。

sample: 采样(Sampling)操作类似于映射,只是 RDD 为每个分区存储一个随机数生成器种子,以确定性地对父记录进行采样。

join: 连接两个 RDD 可能导致两个窄依赖(如果它们都使用相同的分区器进行哈希/范围分区),两个宽依赖,或者一个混合依赖(如果一个父 RDD 有分区器,另一个没有)。无论采用哪种方式,输出 RDD 都具有一个分区器(可以是从父 RDD 继承的分区器,也可以是默认哈希分区器)。

5. Implementation

我们已经用大约 14,000 行的 Scala 代码实现了 Spark。该系统在 Mesos 集群管理器[17]上运行,允许与 Hadoop、MPI 和其他应用程序共享资源。每个 Spark 程序为一个独立的 Mesos 应用程序运行,包括它自己的驱动程序(主节点)和工作节点,这些应用程序之间的资源共享由 Mesos 处理。

Spark 可以使用 Hadoop 的现有输入插件 API 从任何 Hadoop 输入源(例如,HDFS 或 HBase)读取数据,并在未经修改的 Scala 版本上运行。

现在我们概述系统中的一些技术上的有趣部分:我们的作业调度器(§5.1)、允许交互式使用的 Spark 解释器(§5.2)、内存管理(§5.3)和支持检查点(§5.4)。

5.1 Job Scheduling

Spark 的调度器使用了我们在第 4 节中描述的 RDD 表示。

image-20230724111806036

图 5:Spark 计算作业阶段的示例。实线框表示的是 RDD。分区表示为阴影矩形,如果已经在内存中,则为黑色。要在 RDD G 上运行操作,我们在宽依赖处进行build阶段,并在每个阶段内对窄转换进行流水线处理。在这种情况下,第 1 阶段的输出 RDD 已经在内存中,因此我们运行第 2 阶段,然后是第 3 阶段。

总体而言,我们的调度器与 Dryad[19] 类似,但它还需要考虑哪些持久化 RDD 分区在内存中可用。每当用户对 RDD 执行一个操作(如 count 或 save)时,调度器会检查该 RDD 的lineage图,构建一个执行阶段的 DAG(有向无环图),如图 5 所示。每个阶段包含尽可能多的具有窄依赖关系的流水线转换。每个阶段的边界是执行宽依赖所需的洗牌操作,或者任何已经计算的可以减少父 RDD 计算的分区。然后,调度器启动任务来计算每个阶段中缺失的分区,直到计算目标 RDD。

我们的调度器使用延迟调度[32]根据数据局部性为机器分配任务。如果一个任务需要处理在某个节点内存中可用的分区,我们会将该任务发送到那个节点。否则,如果一个任务处理的分区包含了 RDD 提供的首选位置(例如,HDFS 文件),我们会将任务发送到这些位置。

首选位置(preferred locations)表示 Spark 在分配任务时会考虑优先选择的节点,以最小化数据传输成本,充分利用数据局部性,提高计算效率。

例如,当 RDD 从 HDFS 文件系统中读取数据时,首选位置通常是存储数据块所在的 HDFS 数据节点。

对于宽依赖关系(即,洗牌依赖关系),我们目前在保存父分区的节点上物化中间记录,以简化故障恢复,这与 MapReduce 对 map 输出的物化方式非常类似。

如果一个任务失败了,我们会在另一个节点上重新运行它,只要该阶段的父阶段仍然可用。如果某些阶段变得不再可用(例如,因为洗牌过程中的“map端”输出丢失了),我们将重新提交任务以并行计算缺失的分区。我们尚未支持调度程序故障容错,尽管复制 RDD 的lineage图相对简单。

最后,尽管 Spark 中的所有计算目前都是响应驱动程序中调用的操作来运行的,我们还在尝试让集群上的任务(例如 map)调用lookup操作,它能通过键为哈希分区的 RDD 提供随机访问功能。在这种情况下,如果缺少所需的分区,任务需要告诉调度器计算所需的分区。

5.2 Interpreter integration

Scala包括一个类似于Ruby和Python的交互式shell。考虑到内存数据的低延迟,我们希望让用户可以从解释器中交互式地运行Spark来查询大型数据集。

Scala解释器通常通过为用户键入的每一行代码编译一个类、将其加载到JVM并调用其上的函数来操作。这个类包括一个单例对象,该对象包含该行上的变量或函数,并在初始化方法中运行该行的代码。例如,如果用户输入了var x = 5,然后输入println(x),解释器会定义一个名为Line1的类,其中包含x,并使第二行代码编译为println(Line1.getInstance().x)

在Spark中,我们对解释器进行了两处修改:

  1. 类分发(class shipping):为了让工作节点获取每行代码创建的类的字节码,我们修改了解释器,使其通过HTTP提供这些类。
  2. 修改代码生成(Modified code generation):通常情况下,每行代码创建的单例对象通过对应类的静态方法进行访问。这意味着当我们序列化引用在先前的line中定义的变量(如上例中的Line1.x)的闭包时,Java无法通过对象图追踪以发送包含x的Line1实例。因此,工作节点将无法接收到x。我们修改了代码生成逻辑,直接引用每个行对象的实例。

这两个修改使得Spark能够将用户在解释器中定义的变量和闭包有效地传递给集群中的工作节点,从而实现在集群环境中的分布式计算。这为用户提供了一个强大的、交互式的数据处理环境,使用户能够直接运行Spark操作以轻松查询大型数据集。

image-20230725103738356

图 6(上图) 展示了在我们所做修改之后,解释器如何将用户键入的一组行转换为 Java 对象。

我们发现经过改进的 Spark 解释器在处理我们研究过程中获取的大型跟踪数据以及探索存储在 HDFS 中的数据集时非常有用。此外,我们还计划使用它来交互式运行更高级别的查询语言,例如 SQL。

5.3 Memory Management

Spark为持久化RDD提供了三种存储选项:1. 作为反序列化的Java对象的内存存储;2. 作为序列化数据的内存存储;3. 磁盘存储。第一个选项性能最快,因为 Java 虚拟机可以直接访问每个RDD元素。第二个选项在空间有限时可以让用户选择比Java对象图更节省内存的表示形式,但性能较低。第三个选项适用于那些太大以至于无法保留在RAM中,但在每次使用时重新计算都很昂贵的RDD。

对于第二种方式(将RDD作为序列化数据的内存存储的方式),成本取决于应用程序每字节数据的计算量,但对于轻量级处理而言,成本最高可达2倍。

为了管理有限的可用内存,我们在RDD级别使用了LRU(最近最少使用)替换策略。当计算出新的RDD分区,但没有足够的空间存储时,我们会替换掉最近最少访问过的RDD中的一个分区,除非这个RDD就是新分区所属的那个RDD。在这种情况下,我们将旧分区保留在内存中,以防止同一个RDD中的分区反复进出。这很重要,因为大多数操作都会在整个RDD上运行任务,因此很可能将来需要已经在内存中的那个分区。我们发现迄今为止,在所有应用中,这个默认策略都表现得很好,但是我们还通过为每个RDD设置“持久化优先级”来进一步给用户提供控制权。

最后,集群中的每个Spark实例都拥有各自独立的内存空间。在未来的工作中,我们计划通过统一的内存管理器,在Spark实例之间共享RDD。这将有助于在集群中更高效地处理和共享数据。

5.4 Support for checkpointing

尽管在故障后总是可以利用lineage重新计算RDD,但对于具有长lineagea链的RDD,这样的恢复可能耗时较长。因此,将某些RDD检查点保存到稳定存储中可能会有所帮助。

一般来说,检查点对于具有长lineage图且包含宽依赖关系的RDD十分有用,例如我们PageRank示例(§3.2.2)中的rank数据集。在这些情况下,集群中的节点故障可能导致每个父RDD中的部分数据片丢失,需要完全重新计算[20]。相反,对于依赖于稳定存储中数据的窄依赖关系的RDD,例如我们逻辑回归示例(§3.2.1)中的点和PageRank中的链接列表,检查点可能永远不值得。如果节点发生故障,这些RDD中丢失的分区可以在其他节点上并行重新计算,其成本只是复制整个RDD的一部分。

Spark 目前提供了一个用于检查点的API(persist操作中的REPLICATE标志),但将对哪些数据进行检查点的选择留给用户。然而,我们还在研究如何进行自动检查点。因为我们的调度器了解每个数据集的大小以及首次计算所需的时间,所以它应该能够选择一个最佳的RDD检查点集合来最小化系统恢复时间[30]。

最后,请注意,RDD的只读性质使它们比一般的共享内存更容易进行检查点。由于一致性不成问题,可以在后台写出RDD,而无需暂停程序或使用分布式快照方案。这使得RDD在分布式环境中具有更好的鲁棒性和数据容错能力。

6. Evaluation

实验部分不看,后续有需要可以看。

7. Discussion

尽管由于它们的不可变性质和粗粒度转换,RDDs似乎提供了有限的编程接口,但我们发现它们适用于广泛的应用类别。特别是,RDDs可以表示到目前为止已经被提出的作为独立框架的许多集群编程模型,使用户能够在一个程序中组合这些模型(例如,运行MapReduce操作构建一个图,然后在其上运行Pregel)并在它们之间共享数据。在本节中,我们讨论RDDs可以表示哪些编程模型,以及为什么它们如此广泛适用(§7.1)。此外,我们还讨论了RDDs中lineage信息的另一个好处,即我们正在研究的跨这些模型的调试功能(§7.2)。

7.1 Expressing Existing Programming Models

RDDs能够高效地表示迄今为止已经独立提出的许多集群编程模型。所谓“高效”,不仅指RDDs能够产生与这些模型编写的程序相同的输出,还指RDDs能够捕捉这些框架执行的优化,例如保持数据在内存中、划分数据以最小化通信和有效地从故障中恢复。使用RDDs可表达的模型包括:

MapReduce:该模型可以用flatMap和groupByKey操作在Spark中表示,或者在有组合器的情况下用reduceByKey表示。

DryadLINQ:DryadLINQ系统在更通用的Dryad运行时中提供了比MapReduce更广泛的操作符,但这些都是对应于Spark中可用的RDD转换的批量操作符(map、groupByKey、join等)。

SQL:与DryadLINQ表达式一样,SQL查询会对记录集执行数据并行操作。

Pregel:谷歌的Pregel[22]是一种专门针对迭代图应用的模型,与其他系统中的面向集合的编程模型看起来完全不同。在Pregel中,程序作为一系列协调的superstep的形式运行。在每个superstep中,图中的每个顶点会运行一个用户函数,该函数可以更新顶点关联的状态、改变图拓扑并向其他顶点发送消息以供下一个超步使用。这种模型可以表示许多图算法,包括最短路径、二分匹配和PageRank等。

关键观察使我们能够用RDDs实现这个模型,这是因为在每次迭代时,Pregel都会将相同的用户函数应用于所有顶点。因此,我们可以将每次迭代的顶点状态存储在RDD中,并执行批量转换(flatMap)以应用此函数并生成消息的RDD。然后,我们可以将这个RDD与顶点状态进行连接以执行消息交换。同样重要的是,RDDs允许我们像Pregel一样将顶点状态保存在内存中,通过控制分区以最小化通信,并在故障时支持部分恢复。我们已经在Spark之上实现了Pregel,这是一个200行的库,详见[33]。

Iterative MapReduce:最近提出的一些系统(包括HaLoop [7]和Twister [11])提供了一个迭代的MapReduce模型,用户可以为系统提供一系列要循环的MapReduce作业。这些系统在迭代过程中保持数据一致的分区,Twister还可以将其保存在内存中。使用RDDs可以简单地表示这两种优化,我们能够使用Spark实现一个200行的HaLoop库。

Batched Stream Processing:最近提出的一些增量处理系统,如[21, 15, 4],适用于周期性更新具有新数据的结果的应用。例如,一个应用程序每15分钟更新一次关于广告点击的统计数据,它应该能够将上一个15分钟窗口的中间状态与新日志中的数据进行组合。这些系统执行类似于Dryad的批量操作,但将应用程序状态存储在分布式文件系统中。将中间状态放入RDDs将加速它们的处理速度。

Explaining the Expressivity of RDDs:为什么RDDs能够表示这些不同的编程模型?原因是在许多并行应用程序中,RDD的限制影响很小。特别是,尽管RDD只能通过批量转换进行创建,但许多并行程序可以自然地将相同的操作应用于许多记录,使得它们容易表达。同样,RDD的不可变性并不是一个障碍,因为可以创建多个RDD来表示相同数据集的版本。实际上,现今许多MapReduce应用程序都是在不允许更新文件的文件系统上运行,例如HDFS。

最后一个问题是为什么以前的框架没有提供相同程度的通用性。我们认为这是因为这些系统探讨了MapReduce和Dryad不擅长处理的特定问题,例如迭代,但没有发现这些问题的共同原因是缺乏数据共享抽象。

7.2 Leveraging RDDs for Debugging

尽管我们最初设计RDDs为了具有确定性可重新计算的容错能力,但这一特性也有助于调试。特别是,通过记录在作业过程中创建的RDD的lineage,我们可以:(1)稍后重构这些RDD,让用户进行交互式查询;(2)通过重新计算任务所依赖的RDD分区,可以在单个进程调试器中重新运行作业的任何任务。

与通用分布式系统(distributed systems)的传统重放调试器(replay debuggers)(例如[13])不同,它需要捕获或推断多个节点之间事件的顺序,这种方法几乎没有记录开销,因为只需记录RDD lineage图。

与这些系统不同,基于RDD的调试器将不会重放用户函数中的非确定性行为(例如,非确定性map),但至少可以通过对数据进行校验和来报告它。

“非确定性行为”(nondeterministic behavior)是指在特定条件下,程序或函数的输出不是固定的,而是在一定范围内有多种可能的结果。换句话说,当程序具有非确定性行为时,相同的输入可能导致不同的输出。这可能是由随机算法、多线程竞态条件或依赖于外部环境状态的操作所引起的。在分布式系统中,这种非确定性行为可能导致难以预测和重现的问题,从而增加了调试的复杂性。

我们目前正在基于这些想法开发一个基于Spark的调试器[33]。这种调试器将有助于找出程序中的问题并优化性能,大大提高Spark数据处理和分析任务的可靠性和效率。

8. Related Work

Cluster Programming Models:集群编程模型的相关研究可以分为几类:

  1. 数据流模型,如MapReduce [10]、Dryad [19] 和Ciel [23],支持用于处理数据的丰富操作符集,但通过稳定的存储系统共享数据。相较于稳定存储,RDD作为数据共享抽象更加高效,因为它避免了数据复制、I/O和序列化的成本。

    实际上,通过基于RDD的调试器,我们可以在一定程度上加速调试过程并提高可靠性。同时,即使在诸如RAMCloud这样的高性能内存数据存储系统上运行数据流操作,也需要考虑特定应用场景和其计算要求,如数据复制和序列化的开销。这些挑战需要在数据处理和分析系统的设计和实施过程中仔细权衡。

  2. 数个高级编程接口(如DryadLINQ [31] 和FlumeJava [8])为数据流系统提供了与语言集成的API,用户可以通过map和join等操作符来操作“并行集合”。然而,在这些系统中,这些并行集合代表的要么是磁盘上的文件,要么是用于表达查询计划的短暂数据集。尽管这些系统会在同一查询中的不同操作符之间进行数据流水线(例如,一个map后跟另一个map),但它们无法在不同查询之间高效地共享数据。我们选择并行集合模型作为Spark API的基础,原因在于它的便利性。我们不要求集成式语言接口具有创新性,但通过提供RDD作为该接口背后的存储抽象,我们使其能够支持更广泛的应用类别。

  3. 第三类系统为特定类别的应用提供高级接口,这些应用需要共享数据。例如,Pregel[22]支持迭代图应用,而Twister[11]和HaLoop[7]是迭代的MapReduce运行时。然而,这些框架为它们支持的计算模式隐式地进行数据共享,并且没有提供通用的抽象,所谓通用的抽象,就是用户可以使用该抽象在她选择的操作之间共享自己选择的数据。例如,用户无法使用Pregel或Twister将数据集加载到内存中,然后决定在其上运行哪个查询。RDDs明确提供了分布式存储抽象,因此可以支持这些专用系统无法捕捉到的应用,例如交互式数据挖掘。

最后,一些系统公开共享的可变状态,以允许用户执行内存计算。例如,Piccolo [27]允许用户运行并行函数,读取和更新分布式哈希表中的单元格。分布式共享内存(DSM)系统[24]和类似RAMCloud [25]的键值存储提供了类似的模型。RDDs与这些系统在两个方面有所不同。 首先,RDDs基于诸如map、sort和join等操作符提供更高级别的编程接口,而Piccolo和DSM的接口仅针对表单元格的读取和更新。其次,Piccolo和DSM系统通过检查点和回滚来实现恢复,与许多应用程序中RDDs基于血统的策略相比,这种方法的成本更高。最后,如2.3节中所讨论的,RDDs还提供了一些其他优点,如减轻数据处理中的拖延问题。

Caching Systems:Nectar [12] 可以通过使用程序分析[16]来识别公共子表达式,从而在DryadLINQ作业之间重用中间结果。将这种功能添加到基于RDD的系统中将非常具有吸引力。然而,Nectar不提供内存缓存(它将数据放在分布式文件系统中),也不允许用户显式地控制要持久化哪些数据集以及如何分区它们。Ciel [23] 和FlumeJava [8] 同样可以缓存任务结果,但不提供内存缓存或对缓存数据的显式控制。

Ananthanarayanan等人提议在分布式文件系统中添加一个内存缓存,以利用数据访问的时间和空间局部性[3]。虽然此解决方案提供了对文件系统中已有数据的更快访问,但它在应用程序内共享中间结果的效率不如rdd,因为它仍然需要应用程序在各个阶段之间将这些结果写入文件系统。

总之,尽管现有的缓存系统在一定程度上提高了数据处理和分析的性能,但它们在内存缓存、数据持久化和分区控制等方面存在局限性。而基于RDD的系统允许用户更有效地共享和管理中间结果,从而在大数据处理任务中具有更高的性能和可扩展性。

Lineage长期以来,捕获数据的lineage或来源信息一直是科学计算和数据库领域的研究课题,其应用包括解释结果、让其他人复现结果、如果工作流程中发现了一个错误或者数据集丢失,可以重新计算(生成)数据。我们推荐读者参考文献[5]和[9]了解该领域的综述。RDDs提供了一种并行编程模型,其中细粒度血统信息便宜易得,利于故障恢复。

我们基于血统的恢复机制与MapReduce和Dryad等框架内部的计算(作业)恢复机制也类似,这些框架跟踪任务有向无环图(DAG)之间的依赖关系。然而,在这些系统中,一旦作业结束,lineage信息就会丢失,需要依靠复制存储系统来在不同计算任务之间共享数据。 相比之下,RDD通过应用lineage信息,就可以将数据高效地跨计算任务在内存中持久化,而不需要复制和磁盘I/O成本。。

通过血统,RDDs可以在内存中持久化数据,同时跨计算执行时利用血统信息进行故障恢复,提高了大数据处理中的容错能力和性能。这使得基于RDD的系统在许多场景下比传统的数据共享和处理框架更具优势。

Relational Databases从概念上讲,RDDs类似于数据库中的视图(views),而持久化RDDs类似于物化视图(`materialized views`)[28]。然而,与分布式共享内存(DSM)系统一样,数据库通常允许对所有记录进行细粒度的读写访问,这需要记录操作和数据用于容错,并产生额外开销以维护一致性。而对于RDD的粗粒度转换模型来说,这些开销是不必要的。

9. Conclusion

我们已经介绍了弹性分布式数据集(RDDs),这是一种用于集群应用程序中数据共享的高效、通用和容错抽象。RDDs可以表示各种并行应用程序,包括许多针对迭代计算的专用编程模型,以及这些模型无法捕获的新应用程序。与现有的用于集群的存储抽象不同,后者需要数据复制来实现容错,而RDDs提供了基于粗粒度转换的API,允许使用lineage信息有效地恢复数据。我们在一个叫做Spark的系统中实现了RDDs,它在迭代应用程序中的性能比Hadoop高出20倍,还可以用于交互式地查询数百GB的数据。

我们已经将Spark开源在spark-project.org,作为一个可扩展数据分析和系统研究的工具。 back.