06 Spark 之 RDD

这篇总结来自极客时间专栏《大规模数据处理实践》的 13-14 节。

Spark 中最基本的数据抽象是 RDD(Resilient Distributed Dataset),然后 Spark 在 RDD 上封装了很多的数据操作,使得数据处理变得十分简洁、高效。

为什么需要新的数据抽象模型

传统 MR 运行缓慢的原因是因为中间结果需要缓存磁盘防止丢失,这样由于 IO 及伴随的序列化操作大大提高了数据延迟,这个应该怎么解决呢?

我们肯定是想在保证之前系统的稳定性、错误恢复和扩展性的基础上尽可能减少 IO 操作,一个可行的设想就是使用分布式内存存储计算结果,而 RDD 就是一个基于分布式内存的数据抽象

RDD 的定义

RDD 表示已经被分区、不可变的,并能被并行操作的数据集合。

分区

一个 RDD 包含的数据被存储在系统的不同节点,这是并行的前提。在物理存储中,每个分区指向一个存放在内存或硬盘的数据块,这些数据块是可以存在不同的节点上的。

每个分区都有一个它在该 RDD 的 index,通过 RDD 的 id 和这个 RDD 的 index 就可以唯一确定对应数据块的编号。

不可变性

它表示每个 RDD 一旦创建就是可读的,它所包含的分区信息是不可以被改变的。

但可以对 RDD 做转换操作,这样会生成一个新的 RDD。

并行操作

分区特性使得 RDD 天然支持并发操作。

RDD 结构

前面介绍完 RDD 的特性,这里看下 RDD 的数据结构,知道了 RDD 是如何被设计的,如果未来我们也要设计一个分布式数据集的数据结构,这个非常有帮助。

RDD 的数据结构(图片来自极客时间)
  • SparkContext 是所有 Spark 功能的入口,它代表了与 Spark 节点的连接,可以用户创建 RDD 对象等,一个线程只有一个 SparkContext;
  • SparkConf 则是一些参数配置信息;
  • Partitions:它代表 RDD 中数据的逻辑结构,每个 Partition 会映射到某个节点内存或硬盘的一个数据块;
  • Partitioner:决定 RDD 的分区方式,目前主要是 Hash Partitioner、Range Partitioner,这个比较好理解。

依赖关系

每个 RDD 都会存储它的依赖关系,通过它就可以知道它是由哪个 RDD 通过什么操作转换过来的。

Spark 目前支持两种依赖关系:

  1. 窄依赖:每个父 RDD 的分区只会有一个子 RDD 与之对应(可以理解为:一对一、多对一这种情况,比如:map、filter 算子);
  2. 宽依赖:每个父 RDD 的分区可以与多个 RDD 对应(比如:join、groupBy 算子)。

为什么要区分窄依赖和宽依赖呢?

  1. 窄依赖可以支持在同一个节点上链式执行多条命令(多个算子合并到同一个 task 执行,减少数据 shuffle),但宽依赖大部分情况都会涉及到跨节点的 shuffle;
  2. 从容错的角度看,窄依赖的失败恢复更有效,它只需要重新计算丢失的父分区即可,而宽依赖会牵扯到 RDD 各级的多个父分区。

checkpoint

这个主要是为容错考虑,如果一个 RDD 依赖比较长,中间又有多个 RDD 出现故障,进行恢复会非常耗时,checkpoint 的引入就是为了优化这种情况。

这个思想很简单:对于一些计算过程比较耗时的 RDD,我们可以将其缓存至硬盘或 HDFS 中,并标记这个 RDD 有被 checkpoint 处理过,并且清空它的所有依赖关系。同时,新建一个依赖于 CheckpointRDD 的依赖关系,CheckpointRDD 可以直接从硬盘读取 RDD 和生成新的分区信息。

从这个实现,也可以看出,Spark 这个容错模型原生还是为有限数据集考虑,放在批处理模型就很难适配,它需要去支持 Watermark,需要从 Core 层支持,架构改动会非常大。

Storage Level 存储级别

RDD 持久化常用的存储级别有:

  1. MEMORY_ONLY:只缓存在内存中,如果内存空间不够则不缓存多余的部分,默认设置;
  2. MEMORY_AND_DISK:缓存在内存中,空间不足时则缓存在硬盘中;
  3. DISK_ONLY:只缓存在硬盘中;
  4. MEMORY_ONLY_2/MEMORY_AND_DISK_2:与上面相同,只不过这个会在集群中两个节点建立副本。

迭代函数 Iterator

表示 RDD 怎样通过父 RDD 计算得到的,它会首先判断缓存中是否有想要计算的 RDD,如果有就直接读取,如果没有,就查找计算的 RDD 是否被检查点处理过,如果有,就直接读取,如果没有,就调用计算函数(compute)向上递归,查找父 RDD 进行计算。

RDD 支持的操作

转换操作

将一个 RDD 转换为另一个 RDD。

  1. Map:
  2. Filter;
  3. mapPartitions;
  4. groupByKey;

动作操作

动作则是通过计算返回一个结果。

  1. collect:返回 RDD 的所有函数;
  2. Reduce:根据一个输入函数聚合起来;
  3. Count:返回 RDD 中元素的个数;
  4. CountByKey:针对 kv 类型的数据,返回每个 key 的计数。

惰性计算的原因

Spark 直到遇到一个动作操作,才会触发操作,这样做的原因是:

  1. 主要是为了提高执行效率,举个例子,假设你要做的操作是 first(),但读取的数据非常大,如果不知道后面要做什么操作,会先把数据读取一遍,然后再做 first 操作,这样非常消耗存储空间,有了惰性计算后,在读取的时候只需要读取第一条数据即可。

RDD 持久化

在一个作业中,如果要对一个 RDD 经常做操作,可以使用 persist() 方法将其持久化下来,否则每次操作都需要从一开始的 RDD 计算了。

它与 checkpoint 的区别是,它在持久化时,会存储相关的依赖关系,如果有分区损坏,可以重新计算,RDD 的持久化主要是为了避免重复计算,而 checkpoint 主要是为了数据恢复。

总结

RDD 的设计,为后面数据处理奠定了基础,其他的数据处理也都是在这个基础上做的,这个抽象对上层算子的设计非常重要,其实也有点解耦的意思,让算子更专注于计算,数据存储由 RDD 来做,存储相关的事情 算子 也不需要去 care,如果有时间准备看下 RDD 的那篇论文,到时候再来总结一篇文章。

有兴趣的同学,可以通过下面的链接购买,会有一些优惠

二维码扫描购买有一定优惠

推荐阅读更多精彩内容