本文隶属于专栏《1000个问题搞定大数据技术体系》,该专栏为笔者原创,引用请注明来源,不足和错误之处请在评论区帮忙指出,谢谢!
本专栏目录结构和参考文献请见1000个问题搞定大数据技术体系
Spark RDD 论文详解(一)摘要和介绍
Spark RDD 论文详解(二)RDDs
Spark RDD 论文详解(三)Spark 编程接口
Spark RDD 论文详解(四)表达 RDDs
Spark RDD 论文详解(五)实现
Spark RDD 论文详解(六)评估
Spark RDD 论文详解(七)讨论
Spark RDD 论文详解(八)相关工作和结尾
这里分享一下 RDD 论文下载链接:
Resilient Distributed Datasets: A Fault-tolerant Abstraction for In-memory Cluster Computing
这节主要讲述 RDDs 的概要
首先定义 RDDs 以及介绍 RDDs 在 spark 中的编程接口
然后对 RDDs 和细粒度共享内存抽象进行的对比
最后我们讨论了 RDD 模型的限制性
一个 RDD 是一个只读,可分区的数据集。
我们通过对稳定的存储系统或者其他的 RDDs 进行操作可以创建出一个新的 RDDs。
为了区别开 RDDs 的其他操作,我们称这些操作为 transformations
,比如 map,filter 以及 join 等都是 transformations
操作。
虽然各个RDD是不可变的,但是可以通过多个RDD表示数据集的多个版本来实现可变状态。 为了更容易的描述血缘(
lineage
)关系图,我们使RDD不可变。但是我们让数据抽象基于版本化的数据集并且可以通过血缘关系图来追踪这些版本,这实际上是等效的。
RDDs 并不要始终需要被具象化。
为了计算稳定存储里面的分区数据,一个 RDD 是有足够的信息知道自己是从哪个数据集计算而来的(就是所谓的 lineage
)。
这是一个非常强大的属性:实际上,如果一个 RDD 不能在失败后重新构建,那么程序就不能引用它。
最后,用户可以控制 RDDs 的两个方面:数据存储和分区。
对于需要复用的 RDD,用户可以明确的选择一个数据存储策略(比如内存缓存)。
他们也可以基于一个元素的 key 来为 RDD 所有的元素在机器节点间进行数据分区。
这样非常利于数据分布优化,比如给两个数据集进行相同的 hash 分区,然后进行 join,可以提高 join 的性能。
RDD 的初衷就是为了数据复用,所以 Spark 团队就设计了这个分布式内存抽象。
RDD 代表的其实就是大数据中最核心的资产——数据,这个数据可以存储在磁盘或者内存中,但是优先会考虑存储在内存中。当然用户可以自己控制它存储在哪里?是否需要序列化?是否需要保存多个副本?
RDD 代表的数据是只读不可变的,但是数据一定要不断变化才能满足用户的需求,所以,给这些数据加上版本就好了,这些随着版本变化不断生成的一连串的数据实际上就构成了一条血缘链路。
RDD 如果每个版本的数据都保存,会带来很多存储和性能上的损耗,同时也是没有必要的,没有不管什么版本的数据都会用,用户很多时候只关心结果数据有没有到达预期而已,最多,有时候考虑到数据复用来提升一下性能,“我已经计算过的数据总不能让我重新计算一次吧?”,所以才需要把一些重复计算的数据物化。
RDD 除了存储实际的数据以外,如果能够存储上游的血缘关系就更好了,因为这样,就算我当前的 RDD 因为什么原因丢失了,有了血缘关系,我能通过其他 RDD 的数据加上转换逻辑还能重新生成这个 RDD。
关于 RDD 的概念中其实最令人难以理解的是这个 “弹性”。
根据徐文浩老师在《大数据经典论文解读》中的说法,这个弹性体现在两个方面:
- 第一个是数据存储上。数据不再是存放在硬盘上的,而是可以缓存在内存中。只有当内存不足的时候,才会把它们换出到硬盘上。同时,数据的持久化,也支持硬盘、序列化后的内存存储,以及反序列化后 Java 对象的内存存储三种形式。每一种都比另一种需要占用更多的内存,但是计算速度会更快。
- 第二个是选择把什么数据输出到硬盘上。Spark 会根据数据计算的 Lineage,来判断某一个 RDD 对于前置数据是宽依赖,还是窄依赖的。如果是宽依赖的,意味着一个节点的故障,可能会导致大量的数据要进行重新计算,乃至数据网络传输的需求。那么,它就会把数据计算的中间结果存储到硬盘上。
根据王家林老师在《Spark大数据商业实战三部曲:内核解密商业案例性能调优》中的说法,弹性可以体现在 7 个方面:
- 自动进行内存和磁盘数据存储的转换
- 基于 Lineage(血统)的高效容错机制
- Task 如果失败,会自动进行特定次数的重试
- Stage 如果失败,会自动进行特定次数的重试
- checkpoint 和 persist(检查点和持久化),可主动或被动触发
- 数据调度弹性,DAGScheduler、TaskScheduler 和资源管理无关
- 数据分片的高度弹性(coalesce)
我比较赞同徐文浩老师的观点,个人认为王家林老师的说法有点牵强,3 到 7 其实应该属于 Spark 这个框架体现出来的弹性,如果只是单纯的讨论弹性分布式数据集 RDD 的弹性,其实 1 和 2 就已经足够了。顺便说一句,如果 3 到 7 都算弹性,我自己也可以加几个,比方说,Spark 的统一内存模型中执行内存和存储内存实际上是具有弹性的,它们之间可以相互借用,还比如,Spark 的 Sort Shuffle 是具备弹性能力的,可以视排序聚合序列化等因素自动切换成 Tungsten Sort Shuffle,还比如,Spark 3.0 的 AQE,join 策略自动调整、自动分区合并、数据倾斜自动处理,这些其实都是弹性的体现。
RDD 的数据为了支持庞大的数据量和可伸缩性,支持可分区是必然的结果。
那数据就不可避免的存放在不同的节点上面,那此时就有了新问题了:
如果所有的数据都放到同一个节点上面去执行该有多爽!
因为这样就可以告别特别特别损耗性能的网络开销了。
但是大数据的需求决定了有时候把所有数据汇总到一起得到一个最终的结果是不可避免的,也就是聚合不可避免。
此时网络开销也不可避免。
显而易见,网络传输的数据越少越好,那么怎么能使得网络传输数据减少呢?
此时,一个开发原则就被提了出来:
大家可以思考下,遵循了上面的开发原则,网络实际传输的数据量是不是少了很多呢。
这个开发原则参考自吴磊老师的专栏《Spark 性能调优实战》
08 | 应用开发三原则:如何拓展自己的开发边界?
Spark 和 DryadLINQ 和 FlumeJava 一样通过集成编程语言 API 来暴露 RDDs,这样的话,每一个数据集就代表一个对象,我们可以调用这个对象中的方法来操作这个对象。
编程者可以通过对稳定存储的数据进行转换操作(即 transformations
,比如 map 和 filter 等)来得到一个或者多个 RDDs。
然后可以对这些 RDDs 进行 actions
操作,这些操作可以返回结果给应用,也可以将结果数据写入到存储系统中。
actions
包括:
和 DryadLINQ 一样,spark 在定义 RDDs 的时候并不会真正的计算,而是要等到对这个 RDDs 触发了 actions
操作才会真正的触发计算,这个称之为 RDDs 的lazy
特性,所以我们可以先对 transformations
进行组装一系列的 pipelines
,然后再计算。
另外,编程者可以通过调用 RDDs 的 persist
方法来缓存后续需要复用的 RDDs。
Spark 默认是将缓存数据放在内存中,但是如果内存不足的话则会写入到磁盘中。
用户可以通过 persist
的参数来调整缓存策略,比如只将数据存储在磁盘中或者复制备份数据到多台机器。
最后,用户可以为每一个 RDDs 的缓存设置优先级,这样可以决定哪些内存数据会先被溢出到磁盘。
lazy
(延迟加载/懒加载)实际上在程序开发中很多地方都能见到,比如 Java 领域中的著名框架 - Spring
就利用了懒加载机制可以实现指定的 bean 不在启动时立即创建,而是在后续第一次用到时才创建,从而减轻在启动过程中对时间和内存的消耗。
Scala
中甚至有个 lazy
关键字来定义惰性变量,实现延迟加载(懒加载)。 惰性变量只能是不可变变量,并且只有在调用惰性变量时,才会去实例化这个变量。
Spark
中的 RDD
同样具有 lazy
的特性,一方面,用户使用 RDD 实际上不关心内部到底怎么转换的,他们只关心输出结果是否复合预期,而只有 action 操作才能触发输出,另一方面,如果每一步操作都实际执行会使得调度系统“不堪重负”,而且不方便实现一些优化操作。
transformations 操作实际上对应的就是 RDD 内部的转换,无非就是从一个 RDD 转换成另一个 RDD,都是 RDD 类型,而 action 操作是将 RDD 输出为用户想要具体的非 RDD 类型。
比方说,原始数据总共有 100 条,但是我只需要其中 10 条数据
rdd.map(...).filter(...).count()
如果按照上面每一步都操作的话,实际上另外的 90 条完全没必要来进行 map
转换的,因为最后都会过滤掉,所以完全可以将 filter
操作优化到 map
之前,这样可以减少大量的CPU、内存和磁盘消耗。
假设一个 web 服务正发生了大量的错误,然后运维人员想从存储在 HDFS 中的几 TB 的日志中找出错误的原因。
运维人员可以通过 spark 将日志中的错误信息加载到分布式的内存中,然后对这些内存中的数据进行交互式查询。
她首先需要写下面的 scala 代码:
line = spark.textFile("hdfs://..")
errors = lines.filter(_.startsWith("ERROR"))
errors.persist()
第一行表示从一个 HDFS 文件(许多行的文件数据集)上定义了一个 RDD,第二行表示基于前面定义的 RDD 进行过滤数据。第三行将过滤后的 RDD 结果存储在内存中,以达到多个对这个共享 RDD 的查询。需要注意的事,filter 的参数是 scala 语法中的闭包。
到目前为止,集群上还没有真正的触发计算。
然而,用户可以对RDD进行action操作,比如对错误信息的计数:
errors.count()
用户也可以继续对 RDD 进行 transformations
操作,然后计算其结果,比如:
//对错误中含有 ”MySQL” 单词的数据进行计数
errors.filters(_.contains("MySQL")).count()
//返回错误信息中含有 "HDFS" 字样的信息中的时间字段的值(假设每行数据的字段是以 tab 来切分的,时间字段是第 3 个字段)
errors.filter(_.contains("HDFS"))
.map(_.split("\t")(3))
.collect()
在对 errors 第一次做 action
操作的后,spark 会将 errors 的所有分区的数据存储在内存中,这样后面对 errors 的计算速度会有很大的提升。
需要注意的是,像 lines 这种基础数据的 RDD 是不会存储在内存中的。
因为包含错误信息的数据可能只是整个日志数据的一小部分,所以将包含错误数据的日志放在内存中是比较合理的。
最后,为了说明我们的模型是如何达到容错的,我们在图一中展示了第三个查询的血缘关系图(lineage graph
)。
图一:我们例子中第三个查询的血缘关系图,其中方框表示 RDDs,箭头表示转换
在这个查询中,我们以对 lines 进行过滤后的 errors 开始,然后在对 errors 进行了 filter 和 map 操作,最后做了 action
操作即 collect。
Spark 会最后两个 transformations
组成一个 pipeline
,然后将这个 pipeline
分解成一系列的 task
,最后将这些 task
调度到含有 errors 缓存数据的机器上进行执行。
此外,如果 errors 的一个分区的数据丢失了,spark 会对 lines 的相对应的分区应用 filter 函数来重新创建 errors 这个分区的数据。
为了理解作为分布式内存抽象的 RDDs 的好处,我们在下面的表一中用 RDDs 和分布式共享内存系统(Distributed shared memory 即 DSM)进行了对比。
概念 | RDDs | Distribute shared memory(分布式共享内存) |
---|---|---|
Reads | 粗粒度或者细粒度 | 细粒度 |
Writes | 粗粒度 | 细粒度 |
数据一致性 | 不重要的(因为RDD是不可变的) | 取决于app 或者 runtime |
容错 | 利用lineage达到细粒度且低延迟的容错 | 需要应用checkpoints(就是需要写磁盘)并且需要程序回滚 |
计算慢的任务 | 可以利用备份的任务来解决 | 很难做到 |
计算数据的位置 | 基于数据本地性自动实现 | 取决于app(runtime是以透明为目标的) |
内存不足时的行为 | 和已经存在的数据流处理系统一样,写磁盘 | 非常糟糕的性能(需要内存的交换?) |
在所有的 DSM 系统中,应用从一个全局的地址空间中的任意位置中读写数据。
需要注意的是,依据这个定义,我们所说的 DSM 系统不仅包含了传统的共享内存系统,还包含了对共享状态的细粒度写操作的其他系统(比如 Piccolo),以及分布式数据库。
DSM 是一个很普遍的抽象,但是这个普遍性使得它在商用集群中实现高效且容错的系统比较困难。
RDDs 只能通过粗粒度的转换被创建(或者被写),然而 DSM 允许对每一个内存位置进行读写,这个是 RDDs 和 DSM 最主要的区别。
注意在RDD上读取仍然可以进行细粒度。 例如,应用程序可以将RDD视为一个庞大的只读查找表。
这样使都 RDDs在 应用中大量写数据受到了限制,但是可以使的容错变的更加高效。
特别是,RDDs 不需要发生非常耗时的 checkpoint 操作,因为它可以根据 lineage 进行恢复数据。
在某些应用程序中,使用很长的血缘链路来为RDD设置检查点,这依然很有用处,我们在第5.4节中将会讨论。 但是,这可以在后台完成,因为RDD是不可变的,并且不需要在 DSM 中拍摄整个应用程序的快照。
而且,只有丢掉了数据的分区才会需要重新计算,并不需要回滚整个程序,并且这些重新计算的任务是在多台机器上并行运算的。
RDDs 的第二个好处是:它不变的特性可以使得它可以和 MapReduce 一样,任务执行很慢,那就运行备份任务来缓解节点计算慢的问题。
在 DSM 中,备份任务是很难实现的,因为原始任务和备份任务可能会同时更新访问同一个内存地址和接口。
最后,RDDs 比 DSM 多提供了两个好处。
第一,在对 RDDs 进行大量写操作的过程中,我们可以根据数据的本地性来调度 task 以提高性能。
第二,如果在 scan-based 的操作中,且这个时候内存不足以存储这个 RDDs,那么 RDDs 可以慢慢的从内存中清理掉。在内存中存储不下的分区数据会被写到磁盘中,且提供了和现有并行数据处理系统相同的性能保证。
图二:这个是 Spark 运行时的图,用户写的 driver 端程序启动多个 workers,这些 workers 可以从分布式文件系统中读取数据块并且可以将计算出来的 RDD 分区数据存放在内存中。
经过上面的讨论介绍,我们知道 RDDs 非常适合将相同操作应用在整个数据集所有元素上的批处理应用。
在这些场景下,RDDs 可以利用血缘关系图来高效的记住每一个 transformations 的步骤,并且不需要记录大量的数据就可以恢复丢失的分区数据。
RDDs 不太适合用于需要异步且细粒度更新共享状态的应用,比如一个 web 应用或者数据递增的 web 爬虫应用的存储系统。
对于这些应用,使用传统的纪录更新日志以及对数据进行 checkpoint 会更加高效。
比如使用数据库、RAMCloud、Percolator 以及 Piccolo。
我们的目标是给批量分析提供一个高效的编程模型,对于这些异步的应用需要其他的特殊系统来实现。
RDD 的数据模型就决定了 Spark 实际上只适合批处理,而 Spark Streaming
通过微积分思想使用微批处理来替代流处理实际上只适合对实时性要求不高的场景(一般最快是 0.5 秒左右),同样的,Spark Structured Streaming
通过将流处理看成往一张无限大的表上面不断添加一行一行的数据,但是底层还是逃脱不了 RDD 的限制(因为 Spark 的这两种流处理框架底层都是 RDD), 所以,从某种角度上来说,除非 RDD 的数据模型参考 Google 的 DataFlow
模型(这个可以自行百度)做出了某种改变,不然单纯的流数据处理领域永远也无法超过 Flink。
实际上,很多刚学大数据的新人就在学习 Spark 和 Flink 之间难以抉择,Spark 目前来讲只是流数据处理领域比不上 Flink,但是在 批处理/机器学习领域 让 Flink 也是望尘莫及的。