一、Spark介绍
MapReduce 的缺陷
第一,MapReduce 模型的抽象层次低,大量的底层逻辑都需要开发者手工完成。
第二,只提供 Map 和 Reduce 两个操作。
第三,在 Hadoop 中,每一个 Job 的计算结果都会存储在 HDFS 文件存储系统中,所以每一步计算都要进行硬盘的读取和写入,大大增加了系统的延迟。
第四,只支持批数据处理,欠缺对流数据处理的支持。
Spark 的优势
Spark 最基本的数据抽象叫作弹性分布式数据集(Resilient Distributed Dataset, RDD),它代表一个可以被分区(partition)的只读数据集,它内部可以有很多分区,每个分区又有大量的数据记录(record)。
相对于 Hadoop 的 MapReduce 会将中间数据存放到硬盘中,Spark 会把中间数据缓存在内存中,从而减少了很多由于硬盘读写而导致的延迟,大大加快了处理速度。
二、弹性分布式数据集 RDD
为什么需要新的数据抽象模型?
传统的 MapReduce 框架之所以运行速度缓慢,很重要的原因就是有向无环图的中间计算结果需要写入硬盘这样的稳定存储介质中来防止运行结果丢失。
因此,很多研究人员试图提出一个新的分布式存储方案,不仅保持之前系统的稳定性、错误恢复和可扩展性,还要尽可能地减少硬盘 I/O 操作。
一个可行的设想就是在分布式内存中,存储中间计算的结果,因为对内存的读写操作速度远快于硬盘。而 RDD 就是一个基于分布式内存的数据抽象,它不仅支持基于工作集的应用,同时具有数据流模型的特点。
RDD 表示已被分区、不可变的,并能够被并行操作的数据集合。
分区
顾名思义,分区代表同一个 RDD 包含的数据被存储在系统的不同节点中,这也是它可以被并行处理的前提。
逻辑上,我们可以认为 RDD 是一个大的数组。数组中的每个元素代表一个分区(Partition)。
在物理存储中,每个分区指向一个存放在内存或者硬盘中的数据块(Block),而这些数据块是独立的,它们可以被存放在系统中的不同节点。
RDD 中的每个分区存有它在该 RDD 中的 index。通过 RDD 的 ID 和分区的 index 可以唯一确定对应数据块的编号,从而通过底层存储层的接口中提取到数据进行处理。
在集群中,各个节点上的数据块会尽可能地存放在内存中,只有当内存没有空间时才会存入硬盘。这样可以最大化地减少硬盘读写的开销。
虽然 RDD 内部存储的数据是只读的,但是,我们可以去修改(例如通过 repartition 转换操作)并行计算单元的划分结构,也就是分区的数量。
不可变性
不可变性代表每一个 RDD 都是只读的,它所包含的分区信息不可以被改变。既然已有的 RDD 不可以被改变,我们只可以对现有的 RDD 进行转换(Transformation)操作,得到新的 RDD 作为中间计算的结果。从某种程度上讲,RDD 与函数式编程的 Collection 很相似。
并行操作
由于单个 RDD 的分区特性,使得它天然支持并行操作,即不同节点上的数据可以被分别处理,然后产生一个新的 RDD。
RDD 的结构
每一个 RDD 里都会包括分区信息、所依赖的父 RDD 以及通过怎样的转换操作才能由父 RDD 得来等信息。
实际上 RDD 的结构远比你想象的要复杂,让我们来看一个 RDD 的简易结构示意图:
SparkContext 是所有 Spark 功能的入口,它代表了与 Spark 节点的连接,可以用来创建 RDD 对象以及在节点中的广播变量等。一个线程只有一个 SparkContext。SparkConf 则是一些参数配置信息。
Partitions 前文中我已经提到过,它代表 RDD 中数据的逻辑结构,每个 Partition 会映射到某个节点内存或硬盘的一个数据块。
Partitioner 决定了 RDD 的分区方式,目前有两种主流的分区方式:Hash partitioner 和 Range partitioner。Hash,顾名思义就是对数据的 Key 进行散列分区,Range 则是按照 Key 的排序进行均匀分区。此外我们还可以创建自定义的 Partitioner。
依赖关系
Dependencies 是 RDD 中最重要的组件之一。如前文所说,Spark 不需要将每个中间计算结果进行数据复制以防数据丢失,因为每一步产生的 RDD 里都会存储它的依赖关系,即它是通过哪个 RDD 经过哪个转换操作得到的。
细心的读者会问这样一个问题,父 RDD 的分区和子 RDD 的分区之间是否是一对一的对应关系呢?Spark 支持两种依赖关系:窄依赖(Narrow Dependency)和宽依赖(Wide Dependency)。
窄依赖就是父 RDD 的分区可以一一对应到子 RDD 的分区,宽依赖就是父 RDD 的每个分区可以被多个子 RDD 的分区使用。
显然,窄依赖允许子 RDD 的每个分区可以被并行处理产生,而宽依赖则必须等父 RDD 的所有分区都被计算好之后才能开始处理。
如上图所示,一些转换操作如 map、filter 会产生窄依赖关系,而 Join、groupBy 则会生成宽依赖关系。
这很容易理解,因为 map 是将分区里的每一个元素通过计算转化为另一个元素,一个分区里的数据不会跑到两个不同的分区。而 groupBy 则要将拥有所有分区里有相同 Key 的元素放到同一个目标分区,而每一个父分区都可能包含各种 Key 的元素,所以它可能被任意一个子分区所依赖。
Spark 之所以要区分宽依赖和窄依赖是出于以下两点考虑:
窄依赖可以支持在同一个节点上链式执行多条命令,例如在执行了 map 后,紧接着执行 filter。相反,宽依赖需要所有的父分区都是可用的,可能还需要调用类似 MapReduce 之类的操作进行跨节点传递。
从失败恢复的角度考虑,窄依赖的失败恢复更有效,因为它只需要重新计算丢失的父分区即可,而宽依赖牵涉到 RDD 各级的多个父分区。
检查点(Checkpoint)、存储级别(Storage Level)和迭代函数(Iterator)
如果任意一个 RDD 在相应的节点丢失,你只需要从上一步的 RDD 出发再次计算,便可恢复该 RDD。
但是,如果一个 RDD 的依赖链比较长,而且中间又有多个 RDD 出现故障的话,进行恢复可能会非常耗费时间和计算资源。
而**检查点(Checkpoint)**的引入,就是为了优化这些情况下的数据恢复。
很多数据库系统都有检查点机制,在连续的 transaction 列表中记录某几个 transaction 后数据的内容,从而加快错误恢复。
RDD 中的检查点的思想与之类似。
在计算过程中 ,对于一些计算过程比较耗时的 RDD,我们可以将它缓存至硬盘或 HDFS 中,标记这个 RDD 有被检查点处理过,并且清空它的所有依赖关系。同时,给它新建一个依赖于 CheckpointRDD 的依赖关系,CheckpointRDD 可以用来从硬盘中读取 RDD 和生成新的分区信息。
这样,当某个子 RDD 需要错误恢复时,回溯至该 RDD,发现它被检查点记录过,就可以直接去硬盘中读取这个 RDD,而无需再向前回溯计算。
**存储级别(Storage Level)**是一个枚举类型,用来记录 RDD 持久化时的存储级别,常用的有以下几个:
- MEMORY_ONLY:只缓存在内存中,如果内存空间不够则不缓存多出来的部分。这是 RDD 存储级别的默认值。
- MEMORY_AND_DISK:缓存在内存中,如果空间不够则缓存在硬盘中。
- DISK_ONLY:只缓存在硬盘中。
- MEMORY_ONLY_2 和 MEMORY_AND_DISK_2 等:与上面的级别功能相同,只不过每个分区在集群中两个节点上建立副本。
这就是我们在前文提到过的,Spark 相比于 Hadoop 在性能上的提升。我们可以随时把计算好的 RDD 缓存在内存中,以便下次计算时使用,这大幅度减小了硬盘读写的开销。
**迭代函数(Iterator)和计算函数(Compute)**是用来表示 RDD 怎样通过父 RDD 计算得到的。
迭代函数会首先判断缓存中是否有想要计算的 RDD,如果有就直接读取,如果没有,就查找想要计算的 RDD 是否被检查点处理过。如果有,就直接读取,如果没有,就调用计算函数向上递归,查找父 RDD 进行计算。
到现在,相信你已经对弹性分布式数据集的基本结构有了初步了解。但是光理解 RDD 的结构是远远不够的,我们的终极目标是使用 RDD 进行数据处理。
要使用 RDD 进行数据处理,你需要先了解一些 RDD 的数据操作。
RDD 的转换操作
RDD 的数据操作分为两种:转换(Transformation)和动作(Action)。
顾名思义,转换是用来把一个 RDD 转换成另一个 RDD,而动作则是通过计算返回一个结果。
不难想到,之前举例的 map、filter、groupByKey 等都属于转换操作。
Map
map 是最基本的转换操作。
与 MapReduce 中的 map 一样,它把一个 RDD 中的所有数据通过一个函数,映射成一个新的 RDD,任何原 RDD 中的元素在新 RDD 中都有且只有一个元素与之对应。
在这一讲中提到的所有的操作,我都会使用代码举例,帮助你更好地理解。
rdd = sc.parallelize(["b", "a", "c"])
rdd2 = rdd.map(lambda x: (x, 1)) // [('b', 1), ('a', 1), ('c', 1)]
Filter
filter 这个操作,是选择原 RDD 里所有数据中满足某个特定条件的数据,去返回一个新的 RDD。如下例所示,通过 filter,只选出了所有的偶数。
rdd = sc.parallelize([1, 2, 3, 4, 5])
rdd2 = rdd.filter(lambda x: x % 2 == 0) // [2, 4]
mapPartitions
mapPartitions 是 map 的变种。不同于 map 的输入函数是应用于 RDD 中每个元素,mapPartitions 的输入函数是应用于 RDD 的每个分区,也就是把每个分区中的内容作为整体来处理的,所以输入函数的类型是 Iterator[T] => Iterator[U]。
rdd = sc.parallelize([1, 2, 3, 4], 2)
def f(iterator): yield sum(iterator)
rdd2 = rdd.mapPartitions(f) // [3, 7]
在 mapPartitions 的例子中,我们首先创建了一个有两个分区的 RDD。mapPartitions 的输入函数是对每个分区内的元素求和,所以返回的 RDD 包含两个元素:1+2=3 和 3+4=7。
groupByKey
groupByKey 和 SQL 中的 groupBy 类似,是把对象的集合按某个 Key 来归类,返回的 RDD 中每个 Key 对应一个序列。
rdd = sc.parallelize([("a", 1), ("b", 1), ("a", 2)])
rdd.groupByKey().collect()
//"a" [1, 2]
//"b" [1]
在此,我们只列举这几个常用的、有代表性的操作,对其他转换操作感兴趣的同学可以去自行查阅官方的 API 文档。
RDD 的动作操作
让我们再来看几个常用的动作操作。
Collect
RDD 中的动作操作 collect 与函数式编程中的 collect 类似,它会以数组的形式,返回 RDD 的所有元素。需要注意的是,collect 操作只有在输出数组所含的数据数量较小时使用,因为所有的数据都会载入到程序的内存中,如果数据量较大,会占用大量 JVM 内存,导致内存溢出。
rdd = sc.parallelize(["b", "a", "c"])
rdd.map(lambda x: (x, 1)).collect() // [('b', 1), ('a', 1), ('c', 1)]
实际上,上述转换操作中所有的例子,最后都需要将 RDD 的元素 collect 成数组才能得到标记好的输出。
Reduce
与 MapReduce 中的 reduce 类似,它会把 RDD 中的元素根据一个输入函数聚合起来。
from operator import add
sc.parallelize([1, 2, 3, 4, 5]).reduce(add) // 15
Count
Count 会返回 RDD 中元素的个数。
sc.parallelize([2, 3, 4]).count() // 3
CountByKey
仅适用于 Key-Value pair 类型的 RDD,返回具有每个 key 的计数的 <Key, Count> 的 map。
rdd = sc.parallelize([("a", 1), ("b", 1), ("a", 1)])
sorted(rdd.countByKey().items()) // [('a', 2), ('b', 1)]
讲到这,你可能会问了,为什么要区分转换和动作呢? 虽然转换是生成新的 RDD,动作是把 RDD 进行计算生成一个结果,它们本质上不都是计算吗?
这是因为,所有转换操作都很懒,它只是生成新的 RDD,并且记录依赖关系。但是 Spark 并不会立刻计算出新 RDD 中各个分区的数值。直到遇到一个动作时,数据才会被计算,并且输出结果给 Driver。
比如,在之前的例子中,你先对 RDD 进行 map 转换,再进行 collect 动作,这时 map 后生成的 RDD 不会立即被计算。只有当执行到 collect 操作时,map 才会被计算。而且,map 之后得到的较大的数据量并不会传给 Driver,只有 collect 动作的结果才会传递给 Driver。
这种惰性求值的设计优势是什么呢?让我们来看这样一个例子。
假设,你要从一个很大的文本文件中筛选出包含某个词语的行,然后返回第一个这样的文本行。你需要先读取文件 textFile() 生成 rdd1,然后使用 filter() 方法生成 rdd2,最后是行动操作 first(),返回第一个元素。
读取文件的时候会把所有的行都存储起来,但我们马上就要筛选出只具有特定词组的行了,等筛选出来之后又要求只输出第一个。这样是不是太浪费存储空间了呢?确实。
所以实际上,Spark 是在行动操作 first() 的时候开始真正的运算:只扫描第一个匹配的行,不需要读取整个文件。所以,惰性求值的设计可以让 Spark 的运算更加高效和快速。
让我们总结一下 Spark 执行操作的流程吧。
Spark 在每次转换操作的时候,使用了新产生的 RDD 来记录计算逻辑,这样就把作用在 RDD 上的所有计算逻辑串起来,形成了一个链条。当对 RDD 进行动作时,Spark 会从计算链的最后一个 RDD 开始,依次从上一个 RDD 获取数据并执行计算逻辑,最后输出结果。
RDD 的持久化(缓存)
每当我们对 RDD 调用一个新的 action 操作时,整个 RDD 都会从头开始运算。因此,如果某个 RDD 会被反复重用的话,每次都从头计算非常低效,我们应该对多次使用的 RDD 进行一个持久化操作。
Spark 的 persist() 和 cache() 方法支持将 RDD 的数据缓存至内存或硬盘中,这样当下次对同一 RDD 进行 Action 操作时,可以直接读取 RDD 的结果,大幅提高了 Spark 的计算效率。
rdd = sc.parallelize([1, 2, 3, 4, 5])
rdd1 = rdd.map(lambda x: x+5)
rdd2 = rdd1.filter(lambda x: x % 2 == 0)
rdd2.persist()
count = rdd2.count() // 3
first = rdd2.first() // 6
rdd2.unpersist()
在文中的代码例子中你可以看到,我们对 RDD2 进行了多个不同的 action 操作。由于在第四行我把 RDD2 的结果缓存在内存中,所以无论是 count 还是 first,Spark 都无需从一开始的 rdd 开始算起了。
在缓存 RDD 的时候,它所有的依赖关系也会被一并存下来。所以持久化的 RDD 有自动的容错机制。如果 RDD 的任一分区丢失了,通过使用原先创建它的转换操作,它将会被自动重算。
持久化可以选择不同的存储级别。正如我们讲 RDD 的结构时提到的一样,有 MEMORY_ONLY,
MEMORY_AND_DISK,DISK_ONLY 等。cache() 方法会默认取 MEMORY_ONLY 这一级别。
三、Spark SQL:Spark数据查询的利器
Spark SQL 的架构
Spark SQL 本质上是一个库。它运行在 Spark 的核心执行引擎之上。
如上图所示,它提供类似于 SQL 的操作接口,允许数据仓库应用程序直接获取数据,允许使用者通过命令行操作来交互地查询数据,还提供两个 API:DataFrame API 和 DataSet API。
Java、Python 和 Scala 的应用程序可以通过这两个 API 来读取和写入 RDD。
此外,应用程序还可以直接操作 RDD。
使用 Spark SQL 会让开发者觉得好像是在操作一个关系型数据库一样,而不是在操作 RDD。这是它优于原生的 RDD API 的地方。
DataSet
DataSet,顾名思义,就是数据集的意思,它是 Spark 1.6 新引入的接口。
同弹性分布式数据集类似,DataSet 也是不可变分布式的数据单元,它既有与 RDD 类似的各种转换和动作函数定义,而且还享受 Spark SQL 优化过的执行引擎,使得数据搜索效率更高。
DataSet 支持的转换和动作也和 RDD 类似,比如 map、filter、select、count、show 及把数据写入文件系统中。
同样地,DataSet 上的转换操作也不会被立刻执行,只是先生成新的 DataSet,只有当遇到动作操作,才会把之前的转换操作一并执行,生成结果。
所以,DataSet 的内部结构包含了逻辑计划,即生成该数据集所需要的运算。
当动作操作执行时,Spark SQL 的查询优化器会优化这个逻辑计划,并生成一个可以分布式执行的、包含分区信息的物理计划。
那么,DataSet 和 RDD 的区别是什么呢?
通过之前的叙述,我们知道 DataSet API 是 Spark SQL 的一个组件。那么,你应该能很容易地联想到,DataSet 也具有关系型数据库中表的特性。DataSet 所描述的数据都被组织到有名字的列中,就像关系型数据库中的表一样。
DataFrame
DataFrame 可以被看作是一种特殊的 DataSet。它也是关系型数据库中表一样的结构化存储机制,也是分布式不可变的数据结构。
但是,它的每一列并不存储类型信息,所以在编译时并不能发现类型错误。DataFrame 每一行的类型固定为 Row,他可以被当作 DataSet[Row] 来处理,我们必须要通过解析才能获取各列的值。
所以,对于 DataSet 我们可以用类似 people.name 来访问一个人的名字,而对于 DataFrame 我们一定要用类似 people.get As [String] (“name”) 来访问。
RDD、DataFrame、DataSet 对比
不变性与分区
由于 DataSet 和 DataFrame 都是基于 RDD 的,所以它们都拥有 RDD 的基本特性,在此不做赘述。而且我们可以通过简单的 API 在 DataFrame 或 Dataset 与 RDD 之间进行无缝切换。
性能
DataFrame 和 DataSet 的性能要比 RDD 更好。Spark 程序运行时,Spark SQL 中的查询优化器会对语句进行分析,并生成优化过的 RDD 在底层执行。
举个例子,如果我们想先对一堆数据进行 GroupBy 再进行 Filter 操作,这无疑是低效的,因为我们并不需要对所有数据都 GroupBy。
如果用 RDD API 实现这一语句,在执行时它只会机械地按顺序执行。而如果用 DataFrame/DataSet API,Spark SQL 的 Catalyst 优化器会将 Filter 操作和 GroupBy 操作调换顺序,从而提高执行效率。
下图反映了这一优化过程。
错误检测
RDD 和 DataSet 都是类型安全的,而 DataFrame 并不是类型安全的。这是因为它不存储每一列的信息如名字和类型。
使用 DataFrame API 时,我们可以选择一个并不存在的列,这个错误只有在代码被执行时才会抛出。如果使用 DataSet API,在编译时就会检测到这个错误。