Spark 编程模型有两个主要的抽象,第一个是弹性数据集 RDD(Resilient Distributed Dataset),第二个是共享变量:广播变量和累加器。首先了解以下 RDD。
Spark RDD
基于 Spark 的大数据计算平台,建立在统一的抽象 RDD 之上,是一种具有容错性的基于内存的数据集抽象计算方法。一个 RDD 本质上相当于数据的一个元数据结构,存储数据分区,逻辑结构映射关系和依赖转换关系,具有以下特征(内部属性):
- 分区(partition),有一个数据分片列表,将数据进行切分后并行计算,是数据集的原子组成部分。
- 函数(compute),计算每个分片的函数,即在父 RDD 上执行什么操作的到一个可遍历的结果。
- 依赖(dependency),每个 RDD 对父 RDD 的依赖列表,通过依赖关系描述血缘(lineage)。源 RDD 没有依赖。
- 优先位置(可选),每一个分片的优先计算位置,如 HDFS 上分片所属 Block 的所在位置是优先计算位置。
- 分区策略(可选),描述分区策略、分区数和数据存放位置。类似与 MapReduce 的 Paritioner 接口,对 Key/Value 的数据集 RDD,根据 Key 决定数据所在分区(如 Hash 值)。
RDD 依赖
RDD 一个很重要的属性是对父 RDD 的依赖,主要分为两种:窄依赖(narrow denependcy)和宽依赖(wide dependency)。
窄依赖
窄依赖指父 RDD 的每个分区最多被一个子 RDD 分区使用(一个子 RDD 可以使用多个父 RDD 的分区),如:map、filter、union 等算子。
如上图,对数据进行协同划分(co-partitioned)的 Join 也属于窄依赖,多个父 RDD 分区的所有 key 落入子 RDD 的同一个分区。
宽依赖
宽依赖指子 RDD 的每个分区依赖于父 RDD 的所有或多个分区,如:groupby、partition 等算子。
如上图,未对数据进行协同划分(co-partitioned)的 Join 属于宽依赖,一个父 RDD 分区的 key 落入子 RDD 的多个分区。
区分依赖关系
对于数据的计算,窄依赖的 RDD 可以在集群上的一个节点以 pipeline 的方式计算,不要在多个节点间交换数据;宽依赖的 RDD 会涉及数据的混合,需要计算好父分区的数据,然后在节点之间进行 Shuffle。
对于数据的恢复,窄依赖能够有效的进行节点恢复,只需要重新计算丢失 RDD 分区的所有父分区,不同节点之间可以并行计算;宽依赖单个节点的丢失可能导致 RDD 的所有祖先重新计算。
创建 RDD
既然 RDD 是 Spark 计算的核心,那么 RDD 的创建就非常的重要,支持三种创建方式:
- 将一个集合对象并行化成为 RDD
- 从 Hadoop 文件系统作为数据创建 RDD
- 父 RDD 经过转换得到新的 RDD
val sc: SparkContext = ...
// 从集合创建
val data = Array(1,2,3,4,5)
val distData: RDD[Int] = sc.parallelize(data)
// 从文件系统创建
val distFile: RDD[String] = sc.textFile("dfs://text.txt")
// 创建新的 RDD
val newRdd: RDD[Int] = distFile.map(s => s.length)
.reduce((a, b) => a + b)
RDD 转换
RDD 提供了一个抽象的分布式数据架构,我们不必关系底层数据的分布式特性,应用逻辑可以表达为一系列转换处理。应用逻辑可以使用操作算子来表达,分为两大类算子:Transformation 和 Action。
Spark DAG 根据 Action 算子来划分执行 Stage,之后会详细介绍 Spark 的执行流程。目前只需要直到这两类算子分别包括哪些具体的算子。
Transformation(转换)算子
可以理解为对一个数据集生成另一个数据集的操作,是数据集的逻辑操作,并没有真正的计算。
常用的基础转换操作
操作 | 说明 |
---|---|
map(func) | 每个元素经过func 函数转换后,形成一个新的数据集返回 |
filter(func) | 每个元素经过func 函数,保留返回值为 true 的元素,形成一个新的数据集返回 |
flatMap(func) | 类似于map ,每一个输入元素,会被映射为0到多个输出元素(Seq) |
mapPartitions(func) | 类似于map ,针对数据集的每个分区执行转换 |
mapPartitionsWithIndex(func) | 类似于mapPartitions ,额外提供一个代表分区的索引的整数值 |
sample(withReplacement, fraction, seed) | 对数据集中的元素,随机抽样 |
union(otherDataset) | 两个数据集的元素合并,形成一个新的数据集返回 |
distinct([numTasks]) | 返回一个原数据集去重后的数据集 |
repartition(numPartitions) | 在原数据集上随机 Shuffle,创造出更多或更少的分区 |
针对 Key/Value 类型的数据转换操作,在一个(K,V)对组成的数据集上使用
操作 | 说明 |
---|---|
groupByKey([numTasks]) | K相同的所有V组成序列返回(K,Iterable[V])对数据集 |
reduceByKey(func,[numTasks]) | K相同的V使用func 函数依次聚合返回(K,V)数据集 |
sortByKey([ascending], [numTasks]) | 返回一个以K排序的(K,V)对数据集 |
join(otherDataset, [numTasks]) | 参数是(K,W)对数据集,返回一个(K,(V,W))对数据集 |
groupWith(otherDataset, [numTasks]) | 参数是(K,W)对数据集,返回一个(K,Iterable[V],Iterable[W])) |
Action(执行)算子
Action 算子会触发一次真正的计算,Action 以及与前一个 Action 之间的所有 Transformation 组成一个 Job 进行计算。
常用的执行操作
操作 | 说明 |
---|---|
reduce(func) | 通过函数func 聚集数据集中的所有元素 |
collect() | 在 Driver 的程序中,以数组的形式,返回数据集的所有元素。 通常会返回一个足够小的数据子集再使用,否则可能出现 OOM |
count() | 返回数据集的元素个数 |
take(n) | 在 Driver 的程序中,返回一个数组,由数据集的前n个元素组成 |
first() | 在 Driver 的程序中,返回数据集的第一个元素。 效果类似于take(1) |
countByKey() | 在一个(K,V)对组成的数据集,对每个K进行计数。返回(K, Int)的 Hashmap |
foreach(func) | 在数据集的每一个元素上,运行函数func |
存储执行操作,数据允许保存到本地文件系统、HDFS或者任何其他 Hadoop 支持的文件系统
操作 | 说明 |
---|---|
saveAsTextFile(path) | 将数据集的元素,作为文本文件保存。 Spark 将会调用每个元素的 toString 方法,并将它转换为文件中的一行文本 |
saveAsSequenceFile(path) | 将数据集的元素,以 Sequencefile 的格式保存。 数据集元素必须是 Key/Value 类型,并都实现了 Writable 接口(或隐式转换为 Writable) |
saveAsObjectFile(path) | 使用 Java 序列化将数据集元素写入一个简单格式中,可以用 SparkContext 的 objectfile() 方法加载 |
RDD 控制操作
控制操作主要包括:故障恢复、数据持久化和移除数据。其中缓存操作 Cache/Persist 是惰性的,进行执行(Action)操作时才会执行;Unpersist 是即时的,会立即释放内存。Checkpoint 会直接将 RDD 持久化到磁盘或 HDFS,与 Cache/Persist 的区别是,Checkpoint 的 RDD 不会因为作业的结束而消除,可以被后续作业直接读取。
RDD 故障恢复
实现分布式数据集容错方法有两种:数据检查点和记录更新。
RDD 采用记录更新的方式,由于记录所有更新的成本很高,所以,RDD 只支持粗颗粒变换,只记录单个块(分区)上执行的单个操作,然后将每个 RDD 的变换序列(血统 lineage)存储下来,即每个 RDD 都包含了它是如何由其他 RDD 变换过来的以及如何重建某一块数据的信息。因此 RDD 的容错机制又称“血统”容错。
要实现这种“血统”容错机制,最大的难题就是如何表达父 RDD 和子 RDD 之间的依赖关系。上面介绍过,依赖关系可以分两种,窄依赖和宽依赖。
计算时,窄依赖可以在某个计算节点上直接通过计算父 RDD 的某块数据计算得到子 RDD 对应的某块数据;宽依赖则要等到父 RDD 所有数据都计算完成之后之后才能计算子RDD。
数据丢失时,对于窄依赖只需要重新计算丢失的那一块数据来恢复;对于宽依赖则要将祖先 RDD 中的所有数据块全部重新计算来恢复。
所以在“血统”链特别是有宽依赖的时候,需要在适当的时机设置数据检查点,减少数据恢复的工作量。
RDD 持久化
在不同转换操作之间,将过程数据数据缓存在内存中,实现快速重用或故障快速恢复。分为主动持久化和自动持久化。
主动持久化,目的是 RDD 重用,能够实现快速处理。可以使用 persist
方法标记一个持久化 RDD,一旦执行 Action 操作,持久化开始生效,在内存中(或其他介质,可以是磁盘)保存计算结果。
自动持久化,不需要用户主动调用方法,Spark 会自动保存一些 Shuffle 操作的中间结果(如 reduceByKey),为了避免 Shuffle 过程中一个节点崩溃导致所有输入重新计算。
存储等级
RDD 的缓存粒度为整个 RDD 对象,可是用不通的级别进行持久化,persist
方法支持传递 StorageLevel
对象选择存储等级,默认是 MEMORY_ONLY
。
StorageLevel | 说明 |
---|---|
MEMORY_ONLY | 使用未序列化的Java对象格式,将数据保存在内存中。如果内存不够存放所有的数据,则数据可能就不会进行持久化,默认的持久化策略 |
MEMORY_AND_DISK | 使用未序列化的Java对象格式,优先尝试将数据保存在内存中。如果内存不够存放所有的数据,会将数据写入磁盘文件中。不会立刻输出到磁盘 |
MEMORY_ONLY_SER | RDD的每个partition会被序列化成一个字节数组,节省空间,读取时间更占CPU |
MEMORY_AND_DISK_SER | 序列化存储,超出部分写入磁盘文件中 |
DISK_ONLY | 使用未序列化的Java对象格式,将数据全部写入磁盘文件中 |
MEMORY_ONLY_2 | 对于上述任意一种持久化策略,如果加上后缀_2,代表的是将每个持久化的数据,都复制一份副本,并将副本保存到其他节点上 |
OFF_HEAP | RDD序列化存储在Tachyon |
持久化时,一旦设置了就不能改变,必须先去持久化 unpersist()
。
如何选择存储级别?
如果默认能满足使用默认的
如果不能与 MEMORY_ONLY
很好的契合,建议使用 MEMORY_ONLY_SER
尽可能不要存储数据到磁盘上,除非数据集函数计算量特别大,或者它过滤了大量数据,否则从新计算一个分区的速度和从磁盘中读取差不多
如果想拥有快速故障恢复能力,可以使用复制存储级别(_2)
可以自定义存储级别(如复制因子为3)
RDD 与分布式共享内存(DSM)的区别
RDD 是一种分布式的内存抽象,DSM 是一种通用的内存数据抽象。
- | RDD | DSM
-- | --- | ----
读 | 批量或细粒度读 | 细粒度读
写 | 批量转换操作 | 细粒度转换
一致性 |RDD 不可改 | 取决于应用程序
容错性 | 细粒度、低开销使用 Lineage | 需要检查点和回滚
落后任务的处理 | 任务备份,重新调度 | 无
任务安排 | 基于数据存放位置自动实现 | 取决于应用程序
RDD 与 DSM 主要区别在于,不仅可以通过批量转换创建 RDD,还可以对任意内存位置读写。RDD 限制应用执行批量写操作,有利于实现有效的容错。由于 RDD 可以使用血统(Lineage)来恢复分区,基本没有检查点开销。失效时只需要统计丢失的 RDD 分区,在不同节点上并行恢复。
RDD 与 DSM 比较有两个优势:
- 对于 RDD 的批量操作,根据数据存放位置调度数据,提高性能
- 对于扫描类型操作,如果内存不足以缓存完整的 RDD,可以进行部分缓存,其余部分缓存在磁盘上
References:
《Spark 核心技术与高级应用》
《Spark大数据处理: 技术、应用与性能优化》