引言
首先要说Spark并不是一种解决问题的框架,而是这个框架的具体实现,而论文中提出的新框架的名字叫做RDD(Resilient Distributed Datasets 弹性分布式数据集),众所周知分布式计算框架例如Map/Reduce在实际中应用如此广泛,为什么要重新设计一个新的框架呢?原因在paper中有所提到,原因就是在很多的计算场景下用户会对相同的数据集进行多次不同的查询或者其他操作,但是现有的计算框架都是把中间数据直接存放在一个可靠稳定的系统中,比如分布式文件系统,如果这部分数据存储在内存中的话无疑可以提高效率,当然既然是一个分布式环境,容错的处理也是必不可少且困难的,因为庞大的数据集很难让我们去进行数据冗余,Spark就很精彩的完成了这些事情。这篇文章主要阐述了Spark的容错,RDD实现以及相关API。
RDD
RDD的第二个D是数据集的意思,所以这其实也是一种对内存的抽象罢了,其中存储着所有的基本数据以及容错和转换所必要的元数据。
正式来说,RDD是一个只读,分区记录(联想map/Reduce中map的输入)的分布式数据集,它只能基于在稳定物理存储中的数据集和其他已有的RDD上执行确定性操作来创建,基于物理存储创建就是根据分布式文件系统的某个节点上的数据创建或者直接输入数据创建,后者则依赖于RDD的特有API,我们称这类操作为 transformations ,这些操作可以把一个RDD经过某些改变转换成另一个RDD。另一类操作我们称为 action ,这类操作可以从一个RDD的数据集中导出某些值,返回给调用的程序,我们可以设定RDD的数据存储于内存中,这样就加速了这个过程。
这里有一点非常重要,就是RDD的转换其实是惰性
的,也就是说不是每一个transformations
都会创建新的RDD,这些所有的操作都会被记录下来,等到第一次执行action
的时候会把前面所有的操作组成一个AUG(有向无环图),当然这个过程由调度器执行,paper中对调度器的描述很简单,很难看出其具体实现,也希望知道的朋友可以在评论区交流心得
线方框表示RDD,实心矩形表示分区(黑色表示该分区被缓存)。要在RDD G上执行一个动作,调度器根据宽依赖创建一组stage,并在每个stage内部将具有窄依赖的转换流水线化(pipeline)。 本例不用再执行stage 1,因为B已经存在于缓存中了,所以只需要运行2和3。
从以上描述我们也可以看出,对于一个RDD来说,有一些数据是必不可缺的:
- RDD分区信息(分区模式和具体的存放位置)
- 父RDD的依赖,也就是如何可以在父节点失效时推算出父节点(容错)
- 记录经过哪些计算可以从父节点推算出自己
第一条是RDD本身的数据所在,后两条是为了容错。
API
我们来看看Spark的RDD中支持的操作有哪些:
transformations |
map(f : T ) U) : RDD[T] ) RDD[U] filter(f : T ) Bool) : RDD[T] ) RDD[T] flatMap(f : T ) Seq[U]) : RDD[T] ) RDD[U] sample(fraction : Float) : RDD[T] ) RDD[T] (Deterministic sampling) groupByKey() : RDD[(K, V)] ) RDD[(K, Seq[V])] reduceByKey(f : (V; V) ) V) : RDD[(K, V)] ) RDD[(K, V)] union() : (RDD[T]; RDD[T]) ) RDD[T] join() : (RDD[(K, V)]; RDD[(K, W)]) ) RDD[(K, (V, W))] cogroup() : (RDD[(K, V)]; RDD[(K, W)]) ) RDD[(K, (Seq[V], Seq[W]))] crossProduct() : (RDD[T]; RDD[U]) ) RDD[(T, U)] mapValues(f : V ) W) : RDD[(K, V)] ) RDD[(K, W)] (Preserves partitioning) sort(c : Comparator[K]) : RDD[(K, V)] ) RDD[(K, V)] partitionBy(p : Partitioner[K]) : RDD[(K, V)] ) RDD[(K, V)] |
action |
count() : RDD[T] ) Long collect() : RDD[T] ) Seq[T] reduce(f : (T; T) ) T) : RDD[T] ) T lookup(k : K) : RDD[(K, V)] ) Seq[V] (On hash/range partitioned RDDs) save(path : String) : Outputs RDD to a storage system, e.g., HDFS |
当然以上只是关于transformations
和action
的操作,还有一些其他的操作,比如cache操作可以直接缓存RDD的数据,提高action操作和生成时用到这个RDD的RDD创建操作的效率。
还有一些RDD的内部接口:
操作 | 含义 |
---|---|
partitions() | 返回一组Partition对象 |
preferredLocations§ | 根据数据存放的位置,返回分区p在哪些节点访问更快 |
dependencies() | 返回一组依赖 |
iterator(p, parentIters) | 按照父分区的迭代器,逐个计算分区p的元素 |
partitioner() | 返回RDD是否hash/range分区的元数据信息(可以根据这个的返回值使得另一个RDD与其分区方式相同。这样可以提升一些transformations 操作的效率(join在分区相同时产生窄依赖)) |
容错
RDD的容错机制的实现是非常精彩的,但这些都基于RDD有一个粗粒度的转换接口,也就是说对于一个数据集的更新是依赖于一个相同的操作的,同时RDD是不可改变的,这样我们就可以通过记录这一系列操作来打到容错的目的,而不需要去记录所有的数据,因为这种分布式计算框架中数据是非常庞大的,如果采用冗余来容错会导致效率大幅度下降(Spark和MapReduce的容错都很巧妙)。
我们提到了RDD通过记录一系列对父RDD的操作来容错,这样我们就可以通过重新执行一遍来获取最新的数据,这里的操作可以分为两种,及窄依赖(narrow dependencies)和宽依赖(wide dependencies),如下:
窄依赖
:子RDD的每个分区依赖于常数个父分区(即与数据规模无关)宽依赖
:子RDD的每个分区依赖于所有父RDD分区。例如,map产生窄依赖,而join则是宽依赖(除非父RDD被哈希分区)
显然这种区分是有意义的,首先容错时窄依赖能够更有效的恢复,因为在一个分区(分布式文件系统上的一个节点)宕机只需要重演丢失分区的父分区,二宽依赖则可能需要重演全部的父分区,因为这个丢失的分区依赖于全部的父分区;其次在真正执行transformations 时计算的过程也不一样,窄依赖允许在一个集群节点上以流水线的方式(pipeline)计算所有父分区。例如,逐个元素地执行map、然后filter操作;而宽依赖则需要首先计算好所有父分区数据。
粗粒度的转换使得我们可以使用一种简介高效的手段来实现容错,不过这也限定了Spark的编程模型,即面向批量分析应用,不适合异步细粒度的更新状态的应用。
总结
对于个人认为论文很多地方都是泛泛而谈,并没有详细介绍,其实让人一头雾水,比如调度器的原理和运作方式,它到底是以一个怎样的状态在Spark中运行?driver程序的作用除了运行Worker以外还有什么用?这些也只能更深入的学习才可以理解了。学习了这篇论文以后收获就是对于容错有了更深的理解,其次对于Spark也有了一定的认识,是用一种巧妙的抽象解决了MapReduce的缺点。
其实论文看完就是知道了Spark的基础理论部分,知道它的优势,被创造的原因,实现的基本原理。距离掌握还差着十万八千里。其次至少就现在所看的文章来说RDD 本身在 Spark 生态中也渐渐变得落伍,而转向使用SparkSQL了,此方面也有几篇论文,以后从事这个方向的话会去详细了解的,现在先打好基础啦。
参考:
- 论文《Resilient Distributed Datasets: A Fault-Tolerant Abstraction for
In-Memory Cluster Computing》 - 博文《Spark SQL 论文简述》
- 知乎《Spark特点及缺点?》
- 博文《Spark在美团的实践》
- 博文《Spark RDD 论文简析》