弹性分布式数据集:一种对内存集群计算的容错抽象(一)

来源:互联网 发布:知乎 邮箱注册 编辑:程序博客网 时间:2024/05/29 01:54

说明

本文是翻译自讲述Spark核心设计思想的经典论文“Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing”。在翻译的过程中,更系统的理解了Spark中的RDD设计起源、优点与实际应用场景。阅读这样的经典文章,读者也能了解到一个大型的数据处理系统,是怎样被设计出来的。我在博客中计划用3篇博文来完成这个翻译,本篇为第1/3篇,如下既是正文。

摘要

本文展示了RDDs(弹性分布式数据集),一种让程序员在大规模集群中,以一种容错的方式,进行内存计算的分布式内存抽象。

RDDs的发现,是由当前计算框架无效处理的两种类型的应用程序所驱动的。哪两种呢:迭代算法和交互式数据挖掘工具。在这两种情况下,将数据保持在内存中,可以将性能提高一个数量级。

为了有效地实现容错,RDDs基于粗粒度变换提供了一种受限形式的共享存储器,而不是对共享状态的细粒度更新。

然而,我们的结果显示,RDDs具有足够的表达能力,能够捕获广泛的计算类别。包括用于迭代工作的最新专业编程模型,如Pregel,以及这些模型未捕获的新应用程序。

我们已经实现RDDs,系统叫做Spark。我们也通过大量的用户应用程序和基准测试对其进行了评估。

1. 介绍

集群计算框架,如MapReduce和Dryad,已被广泛应用于大型数据分析。这些系统允许用户使用一组高级操作符来编写并行计算程序,而不必担心分布式工作分配和容错。

虽然目前的框架,已经为访问群集的计算资源提供了许多抽象,但他们都缺乏利用分布式内存的抽象。这使得它们对于一类重要的新兴应用程序是无效的:那些在多个计算中重用中间结果的应用程序。数据重用,在很多迭代机器学习和图算法中,是很常用的,比如:PageRank,K-means聚类,和Logistic回归。

另一个引人注目的例子是交互式数据挖掘,即用户在同一个数据子集上运行多个即席查询。不幸的是,在大多数现有的框架中,在计算之间(例如,两个MapReduce作业之间)重用数据的唯一方法是将其写入外部稳定存储系统(例如分布式文件系统)。这导致了由于数据复制,磁盘I / O和序列化而导致的巨大开销,这消耗了大量的应用程序执行时间。

意识到这个问题后,研究者们已经针对需要数据重用的应用,开发了特殊的计算框架。例如,Pregel是用于将中间数据保留在内存中的迭代图计算系统,而HaLoop提供了一个迭代的MapReduce接口。然而,这些框架仅支持特定的计算模式(例如,循环一系列MapReduce步骤),并且为这些模式隐含地执行数据共享。但它们不提供更一般的重用的抽象,例如:让用户将多个数据集加载到内存中,并在其间运行临时查询。

本文提出了一种称为弹性分布式数据集(RDDs)的新抽象,可以在广泛的应用中实现高效的数据重用。RDD是容错的并行数据结构,可以让用户明确地将中间结果保留在内存中,控制其分区以优化数据放置,并使用丰富的运算符来操纵它们。

设计RDDs的主要挑战,在于定义了可以有效提供容错能力的编程接口。在集群上的内存存储的现有抽象,例如分布式共享存储器,键值存储,数据库和Piccolo,提供了基于可变状态的细粒度更新的接口(例如, 表格中的单元)。有了这个接口,提供容错的唯一方法是跨机器复制数据或跨机器记录更新。这两种方法对于数据密集型工作负载来说都是昂贵的,因为它们需要通过集群网络复制大量数据,这些数据的带宽远低于RAM的带宽,并导致大量的存储开销。

与现有的系统对比,RDD提供了一种基于粗粒度变换的接口(例如map, filter与join),它能对很多数据项进行相同的操作。这允许他们通过记录用于构建数据集的变换,而不是对实际数据做变换,来有效提供容错。如果RDDs的分区丢失,则RDDs具有足够的信息,说明如何从其他RDDs导出重新计算该分区的信息。因此,丢失的数据可以很快恢复,而不需要昂贵的复制。

虽然,基于粗粒度变换的接口,看起来功能有限。RDD非常适合用于并行应用程序,因为这些应用程序自然地将相同的操作应用于多种数据项。实际上,我们发现,RDDs可以有效地用于表示迄今为止被提出作为独立系统的很多集群编程模型,包括MapReduce,DryadLINQ,SQL,Pregel和HaLoop,以及这些系统未捕获的新应用程序,如交互式数据挖掘 。我们认为,RDDs能够适应先前仅通过引入新框架才能满足的计算需求的能力,这是RDDs抽象的最有力证据。

我们已经实现了RDDs系统,叫做Spark,目前它已经被用于研究和产品中,再UC Berkeley和其他的公司。Spark提供了一种方便的语言集成编程接口,类似于Scala编程语言中的DryadLINQ。此外,Spark可以交互使用,从Scala解释器查询大数据集。我们相信,Spark是第一个允许以交互式,使用通用编程语言,进行集群内存数据挖掘的系统。

我们通过微基准和用户应用程序的测量来评估RDDs和Spark。我们发现,Spark的迭代应用比Hadoop快20倍,加速了40倍的现实世界数据分析报告,可以交互方式,用5-7秒的延迟扫描1 TB数据集。更基本的是,为了说明RDDs的一般性,我们在Spark之上实现了Pregel和HaLoop编程模型,包括它们所采用的布局优化,以及相对较小的库(每行200行)。

本文首先介绍了RDDs(第2节)和Spark(第3节)。如何讨论了RDDs的内部表示(第4节),具体实现(第5节),实验和结果(第6节)。最后,我们讨论RDDs如何捕获几个现有的集群编程模型(第7节),相关工作的调用(第8节),以及结论。

2. 弹性分布式数据集(RDDs)

本节对RDDs进行了概述。我们首先定义RDDs(2.1),并介绍了它的编程接口(2.2)。然后,我们将RDD与更细化的共享内存抽象(§2.3)进行比较。 最后,我们讨论RDDs模型的限制(§2.4)。

2.1 RDDs抽象

正式的说,RDDs是一个只读分区的记录集合。RDDs只能通过对稳定存储中的(1)数据或(2)其他RDDs的确定性操作来创建。我们将这样的(创建RDDs)的操作,称为转化操作。常见的转化操作是map,filter和join。

对RDDs调用转化操作时,操作不会立即执行。相反,RDDs有足够的信息,说明如何从其他数据集(其谱系)派生出来,以便从稳定存储中的数据计算其分区。这是一个强大的属性:实质上,其它程序无法引用在出错后无法重建的RDDs。

最后,RDDs有两个地方是可以被用户控制的:持久化,分区。用户可以定义哪个RDDs会被重用,以及定义RDDs的存储策略(比如内存、磁盘)。他们还可以要求RDDs的元素根据每个记录中的键在机器之间进行分区。这对于展示位置优化,是非常有用的,例如确保将以相同的方式将两个连接在一起的数据集进行哈希分区。

2.2 Spark编程接口

Spark通过API来暴露RDDs,这类似于DryadLINQ和FlumeJava。其中每个数据集都被表示为一个对象,通过调用对象的方法来实现转化操作。

程序员通过对数据执行转化操作(map,filter),定义一个或多个RDDs。然后,对RDDs执行行动操作,这些操作是向应用程序返回值,或将数据导出到存储系统的操作。行动操作的例子:count(它返回数据集中的元素数量),collect(返回元素本身)和save(将数据集输出到存储系统)。与DryadLINQ一样,Spark首次执行行动操作,是惰性求值,所以它可以进行管道转化。

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

2.2.1 例子:控制台Log数据挖掘

假设有一个web服务,遇到了问题。操作员想从它的LOG中(存储在Hadoop的HDFS),查找错误原因。使用Spark,操作员只需将错误消息从日志中加载到一组节点中,并以交互方式进行查询。 她将首先输入以下Scala代码:

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

第1行定义了将HDFS文件(文本行的集合)创建为RDD,而第2行继承了一个过滤后的RDD。第3行错误RDD持久化到内存,以便可以在不同的查询中共享RDD。请注意,过滤器的参数是Scala闭包语法。

程序执行到这里,集群上并没有执行任何操作。但是呢,用户后面可以执行RDD的行动操作。例如统计错误信息的数量:

errors.count()

用户也可以对RDD执行转化操作,以使用其结果,如下面几行代码:

// 统计含有关键字“MySQL”的错误信息数量errors.filter(_.contains("MySQL")).count()// 返回错误信息的时间信息// 时间信息是HDFS错误信息第3位errors.filter(_.contains("HDFS")).map(_.split(’\t’)(3)).collect()

在第一个errors.persist()之后,Spark会将错误信息存储到内存中,大大加快了后续的计算。请注意,基本的RDD(图1中的lines)不会加载到RAM中。 这是可取的,因为错误消息可能只是数据的一小部分(足够小以适合内存)。

最后,为了说明我们的模型如何实现容错,我们在图1展示了第三个查询的RDD谱系图。在这次查询请求中,图中的errors是lines上执行转化操作filter的结果。并在执行collect操作前,对RDD进行了另一个filter和map操作。Spark调度器会将后两个转化操作(filter和map)并行化,并发送一组任务,以将其计算到保存缓存的errors分区的节点。另外,如果errors分区丢失,则通过仅在对应的lines分区上,执行转化操作filter重新构建RDD。

这里写图片描述

2.3 RDD模型的优势

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

表1:RDDs与分布式共享内存(DSM)的比较

特性 RDDs DSM 读 粗粒度或细粒度 细粒度 写 粗粒度 细粒度 一致性 琐碎(不变) 与app/runtime有关 故障恢复 细粒度和使用血统的lowoverhead 需要检查点和程序回滚 结点落后问题的缓和 可能使用备份任务 很难解决 工作模式 自动 与app有关 内存不足时的行为 与现有的数据流系统类似 性能很差

在DSM系统中,应用程序可以读取和写入全局地址空间中的任意位置。请注意,这个定义,不仅包括传统的共享内存系统,还包括应用程序对共享状态进行细粒度写入的其他系统,包括提供共享DHT的Piccolo和分布式数据库。DSM是一种非常通用的抽象,这使得它难以在集群上,以更有效率和容错的方式被实现。

RDDs与DSM的主要区别在于:RDDs只能通过粗粒度的转化操作来创建(写),而DSM可以读写任何位置的内存。这限制了RDDs在批量写入场景下的应用,但能做到更高效的容错。特别地,RDD不会引起检查点的开销,因为它们可以使用谱系恢复。此外,只有RDD丢失分区的部分,在故障时,才需要重新计算,它还可以在不同节点上并行重新计算,无需回滚整个程序。

RDD的第二个优点是,它的不变性质,使系统可以通过运行缓慢任务的备份副本(如MapReduce)来缓解节点(stragglers)。由于任务的两个副本将访问相同的内存位置并干扰彼此的更新,因此使用DSM难以实现任务备份。

最后,RDD与DSM相比,有另外两个优势。首先,在RDD的批量操作中,runtime可以基于数据位置来调度任务,以提高性能。第二,当没有足够的存储器存储它们时,只要它们仅用于基于扫描的操作,则RDD会正常地降级。不适合放到RAM的分区,可以存储在磁盘上,并提供与当前数据并行系统类似的性能。

2.4 不适合用RDDs的应用

正如第1节所述,RDDs适合用在对数据集中所有元素进行相同操作的场合(批处理)。在这样的情况下,RDDs可以有效的将每个转化操作记录为谱系图中的一个步骤,并且可以恢复丢失的分区,而无需记录大量数据。RDDs不太适合需要异步细粒度更新的应用程序,例如Web应用程序的存储系统或Web爬虫。对于这样的应用,使用传统的日志记录与数据检查系统(如数据库,RAMCloud,Percolator和Piccolo)更有效。我们的目标是为批量分析提供有效的编程模型,而将这些异步应用程序留给专门的系统。

3. Spark编程接口

Spark通过类似于Scala中的DryadLINQ的语言集成API,来构建RDD抽象。Scala是一种静态类型的函数式编程语言,通过Java VM执行。我们之所以选择Scala来构建Spark,是因为它的简洁(便于交互)和效率(静态类型)。 然而,关于RDD抽象的一切都不需要函数式编程语言。

要使用Spark,开发人员编写一个driver程序,连接到workers集群,如图2所示。driver定义了一个或多个RDD,并对这些RDD进行操作。dirver程序也跟踪RDDs的谱系。worker是长期存活的进程,它能将操作中的RDD分区存储在RAM中。

这里写图片描述

正如我们在2.2.1节的Log数据挖掘系统中所述,用户通过传递闭包(函数名)到RDD,从而对RDD进行操作,如map。Scala将每个闭包表示为Java对象,通过网络传递闭包,这些对象就可以被序列化并加载到另一个节点上。Scala还将闭包中绑定的任何变量作为字段保存在Java对象中。例如,可以编写像这样的代码var x = 5; rdd.map(_ + x)将5添加到RDD的每个元素中。

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

虽然我们在Scala中暴露RDD的方法,在概念上很简单,但我们不得不使用反射来解决Scala封闭对象的问题。我们还需要做更多的工作,才能使Spark可以从Scala解释器使用,我们将在5.2节讨论这些。尽管如此,我们也不必修改Scala编译器。

3.1 Spark中的RDD操作

表2中列出了RDD的主要转化操作与行动操作。我们给出每个操作的函数,显示方括号中的类型参数。回想一下,转化操作是用于定义新RDD的惰性操作,而行动操作会启动计算以向程序返回值,或将数据写入外部存储。

这里写图片描述

请注意,某些操作(如join)仅适用于键值对的RDD。此外,我们在选择接口名时,也考虑将其尽量与Scala或函数式编程语言的API相同。例如,map是一对一映射,而flatMap将每个输入值映射到一个或多个输出(类似于MapReduce中的映射)。

除了这些接口,用户还能将RDD持久化。此外,用户可以得到一个RDD的分区顺序,它由一个分区器类表示,并根据它,来分区其他数据集。诸如groupByKey,reduceByKey和sort的操作,会自动生成散列或RDD。

3.2 应用示例

在2.2.1节中,我们用两个迭代示例,来对LOG数据挖掘进行补充:Logistic回归和PageRank。后者还展示了如何控制RDD的分区可以提高性能。

3.2.1 Logistic回归

有很多机器学习算法,在本质上是迭代的,因为它们运行迭代优化程序, 如梯度下降, 来求取函数最大值。因此,将他们的数据放到内存中,能运行的更快。

举个例子,下面的程序实现了逻辑斯蒂回归,这是一种常用的分类算法,它能找到一个超平面w,将两个点集合的数据(比如垃圾邮件&非垃圾邮件)分离开。该算法使用了梯度下降法:将w随机赋值,在每一次迭代时,再将w叠加一个梯度值。

val points = spark.textFile(...).map(parsePoint).persist()var w = // random initial vectorfor (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}

程序通过读入外部文本文件中的数据,从而定义了一个RDD。第一行程序的map函数,解析文本文件的每一行,并将其转化为点对象(points),并由persist()将RDD持久化。然后,在每次迭代中,我们不断的运行map和reduce操作,从而求得梯度值,并将其叠加到w。在迭代计算时,将点数据保存到内存中,可以产生20倍加速,正如我们在第6.1节中所讲的一样。

3.2.2 PageRank

PageRank中有更复杂的数据共享模式。算法对每个文档,都迭代更新rank,通过累加连接到文档的贡献值来累加。在每一次迭代中,每个文档都发送一个贡献值(r/n)到它的连域,其中r是它的排名,n是它的连域个数。接下来,将rank更新为α/N+(1α)Σci,这里的总和,是指其收到所有贡献,N是文档总数。可以用下面的程序来实现。

// Load graph as an RDD of (URL, outlinks) pairsval links = spark.textFile(...).map(...).persist()var ranks = // RDD of (URL, rank) pairsfor (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, (links, rank)) =>        links.map(dest => (dest, rank/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中的谱系图来表示。每次迭代中,我们基于上一次迭代和静态链接数据集的贡献创建了一个新的排名数据集。该图的一个有趣的特征,是它随着迭代次数的增长而增长。因此,在有很多迭代的作业中,可能需要可靠地复制一些版本的rank,以减少故障恢复时间。用户可以使用RELIABLE标志,调用持久化操作。 但是,请注意,links数据集不需要被重新复制,因为可以通过在输入文件的块上重新运行映射,来高效地重建其分区。这个数据集通常会比ranks大得多,因为每个文档都有许多链接,但只有一个能被用于其排名,所以使用谱系恢复它,可以节省检查程序整个内存状态的时间。

这里写图片描述

最后,我们可以通过控制RDD的分区来优化PageRank中的通信。如果我们为链接指定了一个分区(例如,通过节点对URL进行哈希分区),我们可以以相同的方式分区排名,并确保链接和排名之间的连接操作不需要通信(因为每个URL的排名将是 在与其链接列表相同的机器上)。我们还可以编写一个自定义分区器类,来将互相链接的页面进行分组(例如,按域名划分URL)。这两种优化方式,都可以通过在定义links时,调用partitionBy来完成:

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

在此次调用后,links和ranks之间的join操作会将每个URL的贡献汇总到其链接列表所在的计算机上,计算其中的新排名,并加入其链接。这种跨迭代的一致划分是专业框架(如Pregel)的主要优化之一。 RDD让用户直接表达这一目标。

阅读全文
0 0