mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-20 08:03:43 +08:00
del
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
<audio id="audio" title="12 | 我们为什么需要Spark?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8d/e8/8d9f4b613775dd6db5634650012602e8.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“我们为什么需要Spark”。
|
||||
|
||||
也许你之前没有做过大规模数据处理的项目,但是Spark这个词我相信你一定有所耳闻。
|
||||
|
||||
Spark是当今最流行的分布式大规模数据处理引擎,被广泛应用在各类大数据处理场景。
|
||||
|
||||
2009年,美国加州大学伯克利分校的AMP实验室开发了Spark。2013年,Spark成为Apache软件基金会旗下的孵化项目。
|
||||
|
||||
而现在,Spark已经成为了该基金会管理的项目中最活跃的一个。Spark社区也是成长迅速,不仅有数以千计的个人贡献者在不断地开发维护,还有很多大公司也加入了这个开源项目,如Databricks、IBM和华为。
|
||||
|
||||
在技术不断高速更迭的程序圈,一个新工具的出现与流行,必然是因为它满足了很大一部分人长期未被满足的需求,或是解决了一个长期让很多人难受的痛点。
|
||||
|
||||
所以,在学一个新技术之前,你有必要先了解这门技术出现的意义。这样,你才能更好地理解:它是应用到什么场景的?与同类工具相比,它的优缺点是什么?什么时候用它比其它工具好(或差)?……
|
||||
|
||||
至少理解了这些,你才好说自己是真正掌握了这个工具,否则只能说是浅尝辄止,半生不熟。
|
||||
|
||||
学习Spark同样是如此。
|
||||
|
||||
我们首先要问自己,既然已经有了看似很成熟的Hadoop和MapReduce,为什么我们还需要Spark?它能帮我们解决什么实际问题?相比于MapReduce,它的优势又是什么?
|
||||
|
||||
## MapReduce的缺陷
|
||||
|
||||
MapReduce通过简单的Map和Reduce的抽象提供了一个编程模型,可以在一个由上百台机器组成的集群上并发处理大量的数据集,而把计算细节隐藏起来。各种各样的复杂数据处理都可以分解为Map或Reduce的基本元素。
|
||||
|
||||
这样,复杂的数据处理可以分解为由多个Job(包含一个Mapper和一个Reducer)组成的有向无环图(DAG),然后每个Mapper和Reducer放到Hadoop集群上执行,就可以得出结果。
|
||||
|
||||
我们在第一讲中讲到过MapReduce被硅谷一线公司淘汰的两大主要原因:高昂的维护成本、时间性能“达不到”用户的期待。不过除此之外,MapReduce还存在诸多局限。
|
||||
|
||||
第一,MapReduce模型的抽象层次低,大量的底层逻辑都需要开发者手工完成。
|
||||
|
||||
打个比方,写MapReduce的应用就好比用汇编语言去编写一个复杂的游戏。如果你是开发者,你会习惯用汇编语言,还是使用各种高级语言如Java、C++的现有框架呢?
|
||||
|
||||
第二,只提供Map和Reduce两个操作。
|
||||
|
||||
很多现实的数据处理场景并不适合用这个模型来描述。实现复杂的操作很有技巧性,也会让整个工程变得庞大以及难以维护。
|
||||
|
||||
举个例子,两个数据集的Join是很基本而且常用的功能,但是在MapReduce的世界中,需要对这两个数据集做一次Map和Reduce才能得到结果。这样框架对于开发者非常不友好。正如第一讲中提到的,维护一个多任务协调的状态机成本很高,而且可扩展性非常差。
|
||||
|
||||
第三,在Hadoop中,每一个Job的计算结果都会存储在HDFS文件存储系统中,所以每一步计算都要进行硬盘的读取和写入,大大增加了系统的延迟。
|
||||
|
||||
由于这一原因,MapReduce对于迭代算法的处理性能很差,而且很耗资源。因为迭代的每一步都要对HDFS进行读写,所以每一步都需要差不多的等待时间。
|
||||
|
||||
第四,只支持批数据处理,欠缺对流数据处理的支持。
|
||||
|
||||
因此,在Hadoop推出后,有很多人想办法对Hadoop进行优化,其中发展到现在最成熟的就是Spark。
|
||||
|
||||
接下来,就让我们看一下Spark是如何对上述问题进行优化的。
|
||||
|
||||
## Spark的优势
|
||||
|
||||
Spark最基本的数据抽象叫作弹性分布式数据集(Resilient Distributed Dataset, RDD),它代表一个可以被分区(partition)的只读数据集,它内部可以有很多分区,每个分区又有大量的数据记录(record)。
|
||||
|
||||
RDD是Spark最基本的数据结构。Spark定义了很多对RDD的操作。对RDD的任何操作都可以像函数式编程中操作内存中的集合一样直观、简便,使得实现数据处理的代码非常简短高效。这些我们会在这一模块中的后续文章中仔细阐述。
|
||||
|
||||
Spark提供了很多对RDD的操作,如Map、Filter、flatMap、groupByKey和Union等等,极大地提升了对各种复杂场景的支持。开发者既不用再绞尽脑汁挖掘MapReduce模型的潜力,也不用维护复杂的MapReduce状态机。
|
||||
|
||||
相对于Hadoop的MapReduce会将中间数据存放到硬盘中,Spark会把中间数据缓存在内存中,从而减少了很多由于硬盘读写而导致的延迟,大大加快了处理速度。
|
||||
|
||||
Databricks团队曾经做过一个实验,他们用Spark排序一个100TB的静态数据集仅仅用时23分钟。而之前用Hadoop做到的最快记录也用了高达72分钟。此外,Spark还只用了Hadoop所用的计算资源的1/10,耗时只有Hadoop的1/3。
|
||||
|
||||
这个例子充分体现出Spark数据处理的最大优势——速度。
|
||||
|
||||
在某些需要交互式查询内存数据的场景中,Spark的性能优势更加明显。
|
||||
|
||||
根据Databricks团队的结果显示,Spark的处理速度是Hadoop的100倍。即使是对硬盘上的数据进行处理,Spark的性能也达到了Hadoop的10倍。
|
||||
|
||||
由于Spark可以把迭代过程中每一步的计算结果都缓存在内存中,所以非常适用于各类迭代算法。
|
||||
|
||||
Spark第一次启动时需要把数据载入到内存,之后的迭代可以直接在内存里利用中间结果做不落地的运算。所以,后期的迭代速度快到可以忽略不计。在当今机器学习和人工智能大热的环境下,Spark无疑是更好的数据处理引擎。
|
||||
|
||||
下图是在Spark和Hadoop上运行逻辑回归算法的运行时间对比。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/3d/54e4df946206a4a2168a25af8814843d.png" alt="">
|
||||
|
||||
可以看出,Hadoop做每一次迭代运算的时间基本相同,而Spark除了第一次载入数据到内存以外,别的迭代时间基本可以忽略。
|
||||
|
||||
在任务(task)级别上,Spark的并行机制是多线程模型,而MapReduce是多进程模型。
|
||||
|
||||
多进程模型便于细粒度控制每个任务占用的资源,但会消耗较多的启动时间。
|
||||
|
||||
而Spark同一节点上的任务以多线程的方式运行在一个JVM进程中,可以带来更快的启动速度、更高的CPU利用率,以及更好的内存共享。
|
||||
|
||||
从前文中你可以看出,Spark作为新的分布式数据处理引擎,对MapReduce进行了很多改进,使得性能大大提升,并且更加适用于新时代的数据处理场景。
|
||||
|
||||
但是,Spark并不是一个完全替代Hadoop的全新工具。
|
||||
|
||||
因为Hadoop还包含了很多组件:
|
||||
|
||||
- 数据存储层:分布式文件存储系统HDFS,分布式数据库存储的HBase;
|
||||
- 数据处理层:进行数据处理的MapReduce,负责集群和资源管理的YARN;
|
||||
- 数据访问层:Hive、Pig、Mahout……
|
||||
|
||||
从狭义上来看,Spark只是MapReduce的替代方案,大部分应用场景中,它还要依赖于HDFS和HBase来存储数据,依赖于YARN来管理集群和资源。
|
||||
|
||||
当然,Spark并不是一定要依附于Hadoop才能生存,它还可以运行在Apache Mesos、Kubernetes、standalone等其他云平台上。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/0c/bc01239280bb853ca1d00c0fb3a8150c.jpg" alt="">
|
||||
|
||||
此外,作为通用的数据处理平台,Spark有五个主要的扩展库,分别是支持结构化数据的Spark SQL、处理实时数据的Spark Streaming、用于机器学习的MLlib、用于图计算的GraphX、用于统计分析的SparkR。
|
||||
|
||||
这些扩展库与Spark核心API高度整合在一起,使得Spark平台可以广泛地应用在不同数据处理场景中。
|
||||
|
||||
## 小结
|
||||
|
||||
通过今天的学习,我们了解了Spark相较于MapReduce的主要优势,那就是快、易于开发及维护,和更高的适用性。我们还初步掌握了Spark系统的架构。
|
||||
|
||||
MapReduce作为分布式数据处理的开山鼻祖,虽然有很多缺陷,但是它的设计思想不仅没有过时,而且还影响了新的数据处理系统的设计,如Spark、Storm、Presto、Impala等。
|
||||
|
||||
Spark并没有全新的理论基础,它是一点点地在工程和学术的结合基础上做出来的。可以说,它站在了Hadoop和MapReduce两个巨人的肩膀上。在这一模块中,我们会对Spark的架构、核心概念、API以及各个扩展库进行深入的讨论,并且结合常见的应用例子进行实战演练,从而帮助你彻底掌握这一当今最流行的数据处理平台。
|
||||
|
||||
## 思考题
|
||||
|
||||
你认为有哪些MapReduce的缺点是在Spark框架中依然存在的?用什么思路可以解决?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<audio id="audio" title="13 | 弹性分布式数据集:Spark大厦的地基(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/47/79/4761378fe34794f16fede8d1dca6f779.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“弹性分布式数据集”。
|
||||
|
||||
上一讲中提到,Spark最基本的数据抽象是弹性分布式数据集(Resilient Distributed Dataset, 下文用RDD代指)。
|
||||
|
||||
Spark基于RDD定义了很多数据操作,从而使得数据处理的代码十分简洁、高效。所以,要想深入学习Spark,我们必须首先理解RDD的设计思想和特性。
|
||||
|
||||
## 为什么需要新的数据抽象模型?
|
||||
|
||||
传统的MapReduce框架之所以运行速度缓慢,很重要的原因就是有向无环图的中间计算结果需要写入硬盘这样的稳定存储介质中来防止运行结果丢失。
|
||||
|
||||
而每次调用中间计算结果都需要要进行一次硬盘的读取,反复对硬盘进行读写操作以及潜在的数据复制和序列化操作大大提高了计算的延迟。
|
||||
|
||||
因此,很多研究人员试图提出一个新的分布式存储方案,不仅保持之前系统的稳定性、错误恢复和可扩展性,还要尽可能地减少硬盘I/O操作。
|
||||
|
||||
一个可行的设想就是在分布式内存中,存储中间计算的结果,因为对内存的读写操作速度远快于硬盘。而RDD就是一个基于分布式内存的数据抽象,它不仅支持基于工作集的应用,同时具有数据流模型的特点。
|
||||
|
||||
## RDD的定义
|
||||
|
||||
弹性分布式数据集是英文直译的名字,乍一看这个名字相信你会不知所云。如果你去Google或者百度搜索它的定义,你会得到如下结果:
|
||||
|
||||
**RDD表示已被分区、不可变的,并能够被并行操作的数据集合。**
|
||||
|
||||
这个定义很不直观,我认识的很多Spark初学者在查阅了很多资料后还是对RDD一头雾水,很难理解这个抽象的概念。接下来,让我们一起来对这个晦涩的概念抽丝剥茧,见其真义。
|
||||
|
||||
在上述定义以及RDD的中文译名中,我们不难发现,RDD有以下基本特性:分区、不可变和并行操作。接下来让我分别讲解这些特点。
|
||||
|
||||
### 分区
|
||||
|
||||
顾名思义,分区代表同一个RDD包含的数据被存储在系统的不同节点中,这也是它可以被并行处理的前提。
|
||||
|
||||
逻辑上,我们可以认为RDD是一个大的数组。数组中的每个元素代表一个分区(Partition)。
|
||||
|
||||
在物理存储中,每个分区指向一个存放在内存或者硬盘中的数据块(Block),而这些数据块是独立的,它们可以被存放在系统中的不同节点。
|
||||
|
||||
所以,RDD只是抽象意义的数据集合,分区内部并不会存储具体的数据。下图很好地展示了RDD的分区逻辑结构:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2f/9e/2f9ec57cdedf65be382a8ec09826029e.jpg" alt="">
|
||||
|
||||
RDD中的每个分区存有它在该RDD中的index。通过RDD的ID和分区的index可以唯一确定对应数据块的编号,从而通过底层存储层的接口中提取到数据进行处理。
|
||||
|
||||
在集群中,各个节点上的数据块会尽可能地存放在内存中,只有当内存没有空间时才会存入硬盘。这样可以最大化地减少硬盘读写的开销。
|
||||
|
||||
虽然 RDD 内部存储的数据是只读的,但是,我们可以去修改(例如通过 repartition 转换操作)并行计算单元的划分结构,也就是分区的数量。
|
||||
|
||||
### 不可变性
|
||||
|
||||
不可变性代表每一个RDD都是只读的,它所包含的分区信息不可以被改变。既然已有的RDD不可以被改变,我们只可以对现有的RDD进行**转换**(Transformation)操作,得到新的RDD作为中间计算的结果。从某种程度上讲,RDD与函数式编程的Collection很相似。
|
||||
|
||||
```
|
||||
lines = sc.textFile("data.txt")
|
||||
lineLengths = lines.map(lambda s: len(s))
|
||||
totalLength = lineLengths.reduce(lambda a, b: a + b)
|
||||
|
||||
```
|
||||
|
||||
在上述的简单例子中,我们首先读入文本文件data.txt,创建了第一个RDD lines,它的每一个元素是一行文本。然后调用map函数去映射产生第二个RDD lineLengths,每个元素代表每一行简单文本的字数。最后调用reduce函数去得到第三个RDD totalLength,它只有一个元素,代表整个文本的总字数。
|
||||
|
||||
那么这样会带来什么好处呢?显然,对于代表中间结果的RDD,我们需要记录它是通过哪个RDD进行哪些转换操作得来,即**依赖关系**,而不用立刻去具体存储计算出的数据本身。
|
||||
|
||||
这样做有助于提升Spark的计算效率,并且使错误恢复更加容易。
|
||||
|
||||
试想,在一个有N步的计算模型中,如果记载第N步输出RDD的节点发生故障,数据丢失,我们可以从第N-1步的RDD出发,再次计算,而无需重复整个N步计算过程。这样的容错特性也是RDD为什么是一个“弹性”的数据集的原因之一。后边我们会提到RDD如何存储这样的依赖关系。
|
||||
|
||||
### 并行操作
|
||||
|
||||
由于单个RDD的分区特性,使得它天然支持并行操作,即不同节点上的数据可以被分别处理,然后产生一个新的RDD。
|
||||
|
||||
## RDD的结构
|
||||
|
||||
通过上述讲解,我们了解了RDD的基本特性——分区、不可变和并行计算。而且,我们还提到每一个RDD里都会包括分区信息、所依赖的父RDD以及通过怎样的转换操作才能由父RDD得来等信息。
|
||||
|
||||
实际上RDD的结构远比你想象的要复杂,让我们来看一个RDD的简易结构示意图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/1c/8cae25f4d16a34be77fd3e84133d6a1c.png" alt="">
|
||||
|
||||
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)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/e1/5eed459f5f1960e2526484dc014ed5e1.jpg" alt="">
|
||||
|
||||
窄依赖就是父RDD的分区可以一一对应到子RDD的分区,宽依赖就是父RDD的每个分区可以被多个子RDD的分区使用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/f9/989682681b344d31c61b02368ca227f9.jpg" alt="">
|
||||
|
||||
显然,窄依赖允许子RDD的每个分区可以被并行处理产生,而宽依赖则必须等父RDD的所有分区都被计算好之后才能开始处理。
|
||||
|
||||
如上图所示,一些转换操作如map、filter会产生窄依赖关系,而Join、groupBy则会生成宽依赖关系。
|
||||
|
||||
这很容易理解,因为map是将分区里的每一个元素通过计算转化为另一个元素,一个分区里的数据不会跑到两个不同的分区。而groupBy则要将拥有所有分区里有相同Key的元素放到同一个目标分区,而每一个父分区都可能包含各种Key的元素,所以它可能被任意一个子分区所依赖。
|
||||
|
||||
Spark之所以要区分宽依赖和窄依赖是出于以下两点考虑:
|
||||
|
||||
<li>
|
||||
窄依赖可以支持在同一个节点上链式执行多条命令,例如在执行了 map 后,紧接着执行 filter。相反,宽依赖需要所有的父分区都是可用的,可能还需要调用类似 MapReduce 之类的操作进行跨节点传递。
|
||||
</li>
|
||||
<li>
|
||||
从失败恢复的角度考虑,窄依赖的失败恢复更有效,因为它只需要重新计算丢失的父分区即可,而宽依赖牵涉到 RDD 各级的多个父分区。
|
||||
</li>
|
||||
|
||||
## 小结
|
||||
|
||||
弹性分布式数据集作为Spark的基本数据抽象,相较于Hadoop/MapReduce的数据模型而言,各方面都有很大的提升。
|
||||
|
||||
首先,它的数据可以尽可能地存在内存中,从而大大提高的数据处理的效率;其次它是分区存储,所以天然支持并行处理;而且它还存储了每一步骤计算结果之间的依赖关系,从而大大提升了数据容错性和错误恢复的正确率,使Spark更加可靠。
|
||||
|
||||
下一讲,我们会继续深入研究RDD的容错机制、任务执行机制,以及Spark定义在RDD上的各种转换与动作操作。
|
||||
|
||||
## 思考题
|
||||
|
||||
窄依赖是指父RDD的每一个分区都可以唯一对应子RDD中的分区,那么是否意味着子RDD中的一个分区只可以对应父RDD中的一个分区呢?如果子RDD的一个分区需要由父RDD中若干个分区计算得来,是否还算窄依赖?
|
||||
|
||||
最后,欢迎你把对弹性分布式数据集的疑问写在留言区,与我和其他同学一起讨论。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<audio id="audio" title="14 | 弹性分布式数据集:Spark大厦的地基(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b3/91/b30d9e8a1bd4d680e9724e5530535c91.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
上一讲我们介绍了弹性分布式数据集(RDD)的定义、特性以及结构,并且深入讨论了依赖关系(Dependencies)。
|
||||
|
||||
今天让我们一起来继续学习RDD的其他特性。
|
||||
|
||||
## RDD的结构
|
||||
|
||||
首先,我来介绍一下RDD结构中其他的几个知识点:检查点(Checkpoint)、存储级别( Storage Level)和迭代函数(Iterator)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/1c/8cae25f4d16a34be77fd3e84133d6a1c.png" alt="">
|
||||
|
||||
通过上一讲,你应该已经知道了,基于RDD的依赖关系,如果任意一个RDD在相应的节点丢失,你只需要从上一步的RDD出发再次计算,便可恢复该RDD。
|
||||
|
||||
但是,如果一个RDD的依赖链比较长,而且中间又有多个RDD出现故障的话,进行恢复可能会非常耗费时间和计算资源。
|
||||
|
||||
而检查点(Checkpoint)的引入,就是为了优化这些情况下的数据恢复。
|
||||
|
||||
很多数据库系统都有检查点机制,在连续的transaction列表中记录某几个transaction后数据的内容,从而加快错误恢复。
|
||||
|
||||
RDD中的检查点的思想与之类似。
|
||||
|
||||
在计算过程中,对于一些计算过程比较耗时的RDD,我们可以将它缓存至硬盘或HDFS中,标记这个RDD有被检查点处理过,并且清空它的所有依赖关系。同时,给它新建一个依赖于CheckpointRDD的依赖关系,CheckpointRDD可以用来从硬盘中读取RDD和生成新的分区信息。
|
||||
|
||||
这样,当某个子RDD需要错误恢复时,回溯至该RDD,发现它被检查点记录过,就可以直接去硬盘中读取这个RDD,而无需再向前回溯计算。
|
||||
|
||||
存储级别(Storage Level)是一个枚举类型,用来记录RDD持久化时的存储级别,常用的有以下几个:
|
||||
|
||||
<li>
|
||||
MEMORY_ONLY:只缓存在内存中,如果内存空间不够则不缓存多出来的部分。这是RDD存储级别的默认值。
|
||||
</li>
|
||||
<li>
|
||||
MEMORY_AND_DISK:缓存在内存中,如果空间不够则缓存在硬盘中。
|
||||
</li>
|
||||
<li>
|
||||
DISK_ONLY:只缓存在硬盘中。
|
||||
</li>
|
||||
<li>
|
||||
MEMORY_ONLY_2和MEMORY_AND_DISK_2等:与上面的级别功能相同,只不过每个分区在集群中两个节点上建立副本。
|
||||
</li>
|
||||
|
||||
这就是我们在前文提到过的,Spark相比于Hadoop在性能上的提升。我们可以随时把计算好的RDD缓存在内存中,以便下次计算时使用,这大幅度减小了硬盘读写的开销。
|
||||
|
||||
迭代函数(Iterator)和计算函数(Compute)是用来表示RDD怎样通过父RDD计算得到的。
|
||||
|
||||
迭代函数会首先判断缓存中是否有想要计算的RDD,如果有就直接读取,如果没有,就查找想要计算的RDD是否被检查点处理过。如果有,就直接读取,如果没有,就调用计算函数向上递归,查找父RDD进行计算。
|
||||
|
||||
到现在,相信你已经对弹性分布式数据集的基本结构有了初步了解。但是光理解RDD的结构是远远不够的,我们的终极目标是使用RDD进行数据处理。
|
||||
|
||||
要使用RDD进行数据处理,你需要先了解一些RDD的数据操作。
|
||||
|
||||
在[第12讲](http://time.geekbang.org/column/article/94410)中,我曾经提过,相比起MapReduce只支持两种数据操作,Spark支持大量的基本操作,从而减轻了程序员的负担。
|
||||
|
||||
接下来,让我们进一步了解基于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的结果缓存在内存中,所以Spark无需从一开始的rdd开始算起了(持久化处理过的RDD只有第一次有action操作时才会从源头计算,之后就把结果存储下来,所以在这个例子中,count需要从源头开始计算,而first不需要)。
|
||||
|
||||
在缓存RDD的时候,它所有的依赖关系也会被一并存下来。所以持久化的RDD有自动的容错机制。如果RDD的任一分区丢失了,通过使用原先创建它的转换操作,它将会被自动重算。
|
||||
|
||||
持久化可以选择不同的存储级别。正如我们讲RDD的结构时提到的一样,有MEMORY_ONLY,MEMORY_AND_DISK,DISK_ONLY等。cache()方法会默认取MEMORY_ONLY这一级别。
|
||||
|
||||
## 小结
|
||||
|
||||
Spark在每次转换操作的时候使用了新产生的 RDD 来记录计算逻辑,这样就把作用在 RDD 上的所有计算逻辑串起来形成了一个链条,但是并不会真的去计算结果。当对 RDD 进行动作Action时,Spark 会从计算链的最后一个RDD开始,利用迭代函数(Iterator)和计算函数(Compute),依次从上一个RDD获取数据并执行计算逻辑,最后输出结果。
|
||||
|
||||
此外,我们可以通过将一些需要复杂计算和经常调用的RDD进行持久化处理,从而提升计算效率。
|
||||
|
||||
## 思考题
|
||||
|
||||
对RDD进行持久化操作和记录Checkpoint,有什么区别呢?
|
||||
|
||||
欢迎你把对弹性分布式数据集的疑问写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<audio id="audio" title="15 | Spark SQL:Spark数据查询的利器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/83/48/837681afb98c553e15107a4483e1df48.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
上一讲中,我介绍了弹性分布式数据集的特性和它支持的各种数据操作。
|
||||
|
||||
不过在实际的开发过程中,我们并不是总需要在RDD的层次进行编程。
|
||||
|
||||
就好比编程刚发明的年代,工程师只能用汇编语言,到后来才慢慢发展出高级语言,如Basic、C、Java等。使用高级语言大大提升了开发者的效率。
|
||||
|
||||
同样的,Spark生态系统也提供很多库,让我们在不同的场景中使用。
|
||||
|
||||
今天,让我们来一起探讨Spark最常用的数据查询模块——Spark SQL。
|
||||
|
||||
## Spark SQL 发展历史
|
||||
|
||||
几年前,Hadoop/MapReduce在企业生产中的大量使用,HDFS上积累了大量数据。
|
||||
|
||||
由于MapReduce对于开发者而言使用难度较大,大部分开发人员最熟悉的还是传统的关系型数据库。
|
||||
|
||||
为了方便大多数开发人员使用Hadoop,Hive应运而生。
|
||||
|
||||
Hive提供类似SQL的编程接口,HQL语句经过语法解析、逻辑计划、物理计划转化成MapReduce程序执行,使得开发人员很容易对HDFS上存储的数据进行查询和分析。
|
||||
|
||||
在Spark刚问世的时候,Spark团队也开发了一个Shark来支持用SQL语言来查询Spark的数据。
|
||||
|
||||
Shark的本质就是Hive,它修改了Hive的内存管理模块,大幅优化了运行速度,是Hive的10倍到100倍之多。
|
||||
|
||||
但是,Shark对于Hive的依赖严重影响了Spark的发展。Spark想要定义的是一个统一的技术栈和完整的生态,不可能允许有这样的外在依赖。
|
||||
|
||||
试想,如果Spark想发布新的功能还需要等Hive的更新,那么势必会很难执行。此外,依赖于Hive还制约了Spark各个组件的相互集成,Shark也无法利用Spark的特性进行深度优化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/75/68a739ff869d714a32c7760d1a439a75.png" alt="">
|
||||
|
||||
所以,2014年7月1日,Spark团队就将Shark交给Hive进行管理,转而开发了SparkSQL。
|
||||
|
||||
SparkSQL摒弃了Shark的(将SQL语句转化为Spark RDD的)执行引擎,换成自己团队重新开发的执行引擎。
|
||||
|
||||
Spark SQL不仅将关系型数据库的处理模式和Spark的函数式编程相结合,还兼容多种数据格式,包括Hive、RDD、JSON文件、CSV文件等。
|
||||
|
||||
可以说,Spark SQL的问世大大加快了Spark生态的发展。
|
||||
|
||||
## Spark SQL的架构
|
||||
|
||||
Spark SQL本质上是一个库。它运行在Spark的核心执行引擎之上。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/13/3bdb29b1d697e3530d1efbd05e694e13.png" alt="">
|
||||
|
||||
如上图所示,它提供类似于SQL的操作接口,允许数据仓库应用程序直接获取数据,允许使用者通过命令行操作来交互地查询数据,还提供两个API:DataFrame API和DataSet API。
|
||||
|
||||
Java、Python和Scala的应用程序可以通过这两个API来读取和写入RDD。
|
||||
|
||||
此外,正如我们在上一讲介绍的,应用程序还可以直接操作RDD。
|
||||
|
||||
使用Spark SQL会让开发者觉得好像是在操作一个关系型数据库一样,而不是在操作RDD。这是它优于原生的RDD API的地方。
|
||||
|
||||
与基本的Spark RDD API不同,Spark SQL提供的接口为Spark提供了关于数据结构和正在执行的计算的更多信息。
|
||||
|
||||
在内部,Spark SQL使用这些额外的信息来执行额外的优化。虽然Spark SQL支持多种交互方式,但是在计算结果时均使用相同的执行引擎。
|
||||
|
||||
这种统一意味着开发人员可以轻松地在不同的API之间来回切换,基于这些API提供了表达给定转换的最自然的方式。
|
||||
|
||||
接下来让我们进一步了解DataSet和DataFrame。
|
||||
|
||||
## 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所描述的数据都被组织到有名字的列中,就像关系型数据库中的表一样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/e3/5a6fd6e91c92c166d279711bf9c761e3.png" alt="">
|
||||
|
||||
如上图所示,左侧的RDD虽然以People为类型参数,但Spark框架本身不了解People类的内部结构。所有的操作都以People为单位执行。
|
||||
|
||||
而右侧的DataSet却提供了详细的结构信息与每列的数据类型。
|
||||
|
||||
这让Spark SQL可以清楚地知道该数据集中包含哪些列,每列的名称和类型各是什么。也就是说,DataSet提供数据表的schema信息。这样的结构使得DataSet API的执行效率更高。
|
||||
|
||||
试想,如果我们要查询People的年龄信息,Spark SQL执行的时候可以依靠查询优化器仅仅把需要的那一列取出来,其他列的信息根本就不需要去读取了。所以,有了这些信息以后在编译的时候能够做更多的优化。
|
||||
|
||||
其次,由于DataSet存储了每列的数据类型。所以,在程序编译时可以执行类型检测。
|
||||
|
||||
## DataFrame
|
||||
|
||||
DataFrame可以被看作是一种特殊的DataSet。它也是关系型数据库中表一样的结构化存储机制,也是分布式不可变的数据结构。
|
||||
|
||||
但是,它的每一列并不存储类型信息,所以在编译时并不能发现类型错误。DataFrame每一行的类型固定为Row,他可以被当作DataSet[Row]来处理,我们必须要通过解析才能获取各列的值。
|
||||
|
||||
所以,对于DataSet我们可以用类似people.name来访问一个人的名字,而对于DataFrame我们一定要用类似people.get As [String] (“name”)来访问。
|
||||
|
||||
## RDD、DataFrame、DataSet对比
|
||||
|
||||
学习Spark到现在,我们已经接触了三种基本的数据结构:RDD、DataFrame和DataSet。接下来你的表格中,你可以看到它们的异同点,思考一下怎样在实际工程中选择。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/ef/40691757146e1b480e08969e676644ef.png" alt="">
|
||||
|
||||
### 发展历史
|
||||
|
||||
从发展历史上来看,RDD API在第一代Spark中就存在,是整个Spark框架的基石。
|
||||
|
||||
接下来,为了方便熟悉关系型数据库和SQL的开发人员使用,在RDD的基础上,Spark创建了DataFrame API。依靠它,我们可以方便地对数据的列进行操作。
|
||||
|
||||
DataSet最早被加入Spark SQL是在Spark 1.6,它在DataFrame的基础上添加了对数据的每一列的类型的限制。
|
||||
|
||||
在Spark 2.0中,DataFrame和DataSet被统一。DataFrame作为DataSet[Row]存在。在弱类型的语言,如Python中,DataFrame API依然存在,但是在Java中,DataFrame API已经不复存在了。
|
||||
|
||||
### 不变性与分区
|
||||
|
||||
由于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操作调换顺序,从而提高执行效率。
|
||||
|
||||
下图反映了这一优化过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/42/d6162126bae14517aa163b3885c13a42.png" alt="">
|
||||
|
||||
### 错误检测
|
||||
|
||||
RDD和DataSet都是类型安全的,而DataFrame并不是类型安全的。这是因为它不存储每一列的信息如名字和类型。
|
||||
|
||||
使用DataFrame API时,我们可以选择一个并不存在的列,这个错误只有在代码被执行时才会抛出。如果使用DataSet API,在编译时就会检测到这个错误。
|
||||
|
||||
## 小结
|
||||
|
||||
DataFrame和DataSet是Spark SQL提供的基于RDD的结构化数据抽象。
|
||||
|
||||
它既有RDD不可变、分区、存储依赖关系等特性,又拥有类似于关系型数据库的结构化信息。
|
||||
|
||||
所以,基于DataFrame和DataSet API开发出的程序会被自动优化,使得开发人员不需要操作底层的RDD API来进行手动优化,大大提升开发效率。
|
||||
|
||||
但是RDD API对于非结构化的数据处理有独特的优势,比如文本流数据,而且更方便我们做底层的操作。所以在开发中,我们还是需要根据实际情况来选择使用哪种API。
|
||||
|
||||
## 思考题
|
||||
|
||||
什么场景适合使用DataFrame API,什么场景适合使用DataSet API?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<audio id="audio" title="16 | Spark Streaming:Spark的实时流计算API" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ad/bd/ad03ca21670e43c66a1f2808c29dfdbd.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的内容是“Spark Streaming”。
|
||||
|
||||
通过上一讲的内容,我们深入了解了Spark SQL API。通过它,我们可以像查询关系型数据库一样查询Spark的数据,并且对原生数据做相应的转换和动作。
|
||||
|
||||
但是,无论是DataFrame API还是DataSet API,都是基于批处理模式对静态数据进行处理的。比如,在每天某个特定的时间对一天的日志进行处理分析。
|
||||
|
||||
在第二章中你已经知道了,批处理和流处理是大数据处理最常见的两个场景。那么作为当下最流行的大数据处理平台之一,Spark是否支持流处理呢?
|
||||
|
||||
答案是肯定的。
|
||||
|
||||
早在2013年,Spark的流处理组件Spark Streaming就发布了。之后经过好几年的迭代与改进,现在的Spark Streaming已经非常成熟,在业界应用十分广泛。
|
||||
|
||||
今天就让我们一起揭开Spark Streaming的神秘面纱,让它成为我们手中的利器。
|
||||
|
||||
## Spark Streaming的原理
|
||||
|
||||
Spark Streaming的原理与微积分的思想很类似。
|
||||
|
||||
在大学的微积分课上,你的老师一定说过,微分就是无限细分,积分就是对无限细分的每一段进行求和。它本质上把一个连续的问题转换成了无限个离散的问题。
|
||||
|
||||
比如,用微积分思想求下图中阴影部分S的面积。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1c/eb/1cef18cc51ef652c90d05c170c04e7eb.png" alt="">
|
||||
|
||||
我们可以把S无限细分成无数个小矩形,因为矩形的宽足够短,所以它顶端的边近似是一个直线。这样,把容易计算的矩形面积相加,就得到不容易直接计算的不规则图形面积。
|
||||
|
||||
你知道,流处理的数据是一系列连续不断变化,且无边界的。我们永远无法预测下一秒的数据是什么样。Spark Streaming用时间片拆分了无限的数据流,然后对每一个数据片用类似于批处理的方法进行处理,输出的数据也是一块一块的。如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2e/a2/2e5d3fdbe0bb09a7f2cf219df1d41ca2.png" alt="">
|
||||
|
||||
Spark Streaming提供一个对于流数据的抽象DStream。DStream可以由来自Apache Kafka、Flume或者HDFS的流数据生成,也可以由别的DStream经过各种转换操作得来。讲到这里,你是不是觉得内容似曾相识?
|
||||
|
||||
没错,底层DStream也是由很多个序列化的RDD构成,按时间片(比如一秒)切分成的每个数据单位都是一个RDD。然后,Spark核心引擎将对DStream的Transformation操作变为针对Spark中对 RDD的Transformation操作,将RDD经过操作变成中间结果保存在内存中。
|
||||
|
||||
之前的DataFrame和DataSet也是同样基于RDD,所以说RDD是Spark最基本的数据抽象。就像Java里的基本数据类型(Primitive Type)一样,所有的数据都可以用基本数据类型描述。
|
||||
|
||||
也正是因为这样,无论是DataFrame,还是DStream,都具有RDD的不可变性、分区性和容错性等特质。
|
||||
|
||||
所以,Spark是一个高度统一的平台,所有的高级API都有相同的性质,它们之间可以很容易地相互转化。Spark的野心就是用这一套工具统一所有数据处理的场景。
|
||||
|
||||
由于Spark Streaming将底层的细节封装起来了,所以对于开发者来说,只需要操作DStream就行。接下来,让我们一起学习DStream的结构以及它支持的转换操作。
|
||||
|
||||
## DStream
|
||||
|
||||
下图就是DStream的内部形式,即一个连续的RDD序列,每一个RDD代表一个时间窗口的输入数据流。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/66/ac/66b4562bcbd4772160f0f5766b59b5ac.png" alt="">
|
||||
|
||||
对DStream的转换操作,意味着对它包含的每一个RDD进行同样的转换操作。比如下边的例子。
|
||||
|
||||
```
|
||||
sc = SparkContext(master, appName)
|
||||
ssc = StreamingContext(sc, 1)
|
||||
lines = sc.socketTextStream("localhost", 9999)
|
||||
words = lines.flatMap(lambda line: line.split(" "))
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/72/b4/72d05c02bf547f5c993fb0b3349343b4.png" alt="">
|
||||
|
||||
首先,我们创建了一个lines的DStream,去监听来自本机9999端口的数据流,每一个数据代表一行文本。然后,对lines进行flatMap的转换操作,把每一个文本行拆分成词语。
|
||||
|
||||
本质上,对一个DStream进行flatMap操作,就是对它里边的每一个RDD进行flatMap操作,生成了一系列新的RDD,构成了一个新的代表词语的DStream。
|
||||
|
||||
正因为DStream和RDD的关系,RDD支持的所有转换操作,DStream都支持,比如map、flatMap、filter、union等。这些操作我们在前边学习RDD时都详细介绍过,在此不做赘述。
|
||||
|
||||
此外,DStream还有一些特有操作,如滑动窗口操作,我们可以一起探讨。
|
||||
|
||||
### 滑动窗口操作
|
||||
|
||||
任何Spark Streaming的程序都要首先创建一个**StreamingContext**的对象,它是所有Streaming操作的入口。
|
||||
|
||||
比如,我们可以通过StreamingContext来创建DStream。前边提到的例子中,lines这个DStream就是由名为sc的StreamingContext创建的。
|
||||
|
||||
StreamingContext中最重要的参数是批处理的**时间间隔**,即把流数据细分成数据块的粒度。
|
||||
|
||||
这个时间间隔决定了流处理的延迟性,所以,需要我们根据需求和资源来权衡间隔的长度。上边的例子中,我们把输入的数据流以秒为单位划分,每一秒的数据会生成一个RDD进行运算。
|
||||
|
||||
有些场景中,我们需要每隔一段时间,统计过去某个时间段内的数据。比如,对热点搜索词语进行统计,每隔10秒钟输出过去60秒内排名前十位的热点词。这是流处理的一个基本应用场景,很多流处理框架如Apache Flink都有原生的支持。所以,Spark也同样支持滑动窗口操作。
|
||||
|
||||
从统计热点词这个例子,你可以看出滑动窗口操作有两个基本参数:
|
||||
|
||||
- 窗口长度(window length):每次统计的数据的时间跨度,在例子中是60秒;
|
||||
- 滑动间隔(sliding interval):每次统计的时间间隔,在例子中是10秒。
|
||||
|
||||
显然,由于Spark Streaming流处理的最小时间单位就是StreamingContext的时间间隔,所以这两个参数一定是它的整数倍。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/93/e3/933bd108299c65a3eb00329f345119e3.png" alt="">
|
||||
|
||||
最基本的滑动窗口操作是window,它可以返回一个新的DStream,这个DStream中每个RDD代表一段时间窗口内的数据,如下例所示。
|
||||
|
||||
```
|
||||
windowed_words = words.window(60, 10)
|
||||
|
||||
```
|
||||
|
||||
windowed_words代表的就是热词统计例子中我们所需的DStream,即它里边每一个数据块都包含过去60秒内的词语,而且这样的块每10秒钟就会生成一个。
|
||||
|
||||
此外,Spark Streaming还支持一些“进阶”窗口操作。如countByWindow、reduceByWindow、reduceByKeyAndWindow和countByValueAndWindow,在此不做深入讨论。
|
||||
|
||||
## Spark Streaming的优缺点
|
||||
|
||||
讲了这么多Spark Streaming,不管内部实现也好,支持的API也好,我们还并不明白它的优势是什么,相比起其他流处理框架的缺点是什么。只有明白了这些,才能帮助我们在实际工作中决定是否使用Spark Streaming。
|
||||
|
||||
首先,Spark Streaming的优点很明显,由于它的底层是基于RDD实现的,所以RDD的优良特性在它这里都有体现。
|
||||
|
||||
比如,数据容错性,如果RDD 的某些分区丢失了,可以通过依赖信息重新计算恢复。
|
||||
|
||||
再比如运行速度,DStream同样也能通过persist()方法将数据流存放在内存中。这样做的好处是遇到需要多次迭代计算的程序时,速度优势十分明显。
|
||||
|
||||
而且,Spark Streaming是Spark生态的一部分。所以,它可以和Spark的核心引擎、Spark SQL、MLlib等无缝衔接。换句话说,对实时处理出来的中间数据,我们可以立即在程序中无缝进行批处理、交互式查询等操作。这个特点大大增强了Spark Streaming的优势和功能,使得基于Spark Streaming的应用程序很容易扩展。
|
||||
|
||||
而Spark Streaming的主要缺点是实时计算延迟较高,一般在秒的级别。这是由于Spark Streaming不支持太小的批处理的时间间隔。
|
||||
|
||||
在第二章中,我们讲过准实时和实时系统,无疑Spark Streaming是一个准实时系统。别的流处理框架,如Storm的延迟性就好很多,可以做到毫秒级。
|
||||
|
||||
## 小结
|
||||
|
||||
Spark Streaming,作为Spark中的流处理组件,把连续的流数据按时间间隔划分为一个个数据块,然后对每个数据块分别进行批处理。
|
||||
|
||||
在内部,每个数据块就是一个RDD,所以Spark Streaming有RDD的所有优点,处理速度快,数据容错性好,支持高度并行计算。
|
||||
|
||||
但是,它的实时延迟相比起别的流处理框架比较高。在实际工作中,我们还是要具体情况具体分析,选择正确的处理框架。
|
||||
|
||||
## 思考题
|
||||
|
||||
如果想要优化一个Spark Streaming程序,你会从哪些角度入手?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<audio id="audio" title="17 | Structured Streaming:如何用DataFrame API进行实时数据分析?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ab/95/ab8e8b0950a7caa0fa58710a0d077395.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
上一讲中,我们介绍了Spark中的流处理库Spark Streaming。它将无边界的流数据抽象成DStream,按特定的时间间隔,把数据流分割成一个个RDD进行批处理。所以,DStream API与RDD API高度相似,也拥有RDD的各种性质。
|
||||
|
||||
在第15讲中,我们比较过RDD和DataSet/DataFrame。你还记得DataSet/DataFrame的优点吗?你有没有想过,既然已经有了RDD API,我们为什么还要引入DataSet/DataFrame呢?
|
||||
|
||||
让我们来回顾一下DataSet/DataFrame的优点(为了方便描述,下文中我们统一用DataFrame来代指DataSet和DataFrame):
|
||||
|
||||
- DataFrame 是**高级API**,提供类似于**SQL**的query接口,方便熟悉关系型数据库的开发人员使用;
|
||||
- **Spark SQL执行引擎会自动优化DataFrame程序**,而用RDD API开发的程序本质上需要工程师自己构造RDD的DAG执行图,所以依赖于工程师自己去优化。
|
||||
|
||||
那么我们自然会想到,如果可以拥有一个基于DataFrame API的流处理模块,作为工程师的我们就不需要去用相对low level的DStream API去处理无边界数据,这样会大大提升我们的开发效率。
|
||||
|
||||
基于这个思想,2016年,Spark在其2.0版本中推出了结构化流数据处理的模块Structured Streaming。
|
||||
|
||||
Structured Streaming是基于Spark SQL引擎实现的,依靠Structured Streaming,在开发者眼里,流数据和静态数据没有区别。我们完全可以像批处理静态数据那样去处理流数据。随着流数据的持续输入,Spark SQL引擎会帮助我们持续地处理新数据,并且更新计算结果。
|
||||
|
||||
今天,就让我们来一起学习Structured Streaming的原理以及应用。
|
||||
|
||||
## Structured Streaming模型
|
||||
|
||||
流数据处理最基本的问题就是如何对不断更新的无边界数据建模。
|
||||
|
||||
之前讲的Spark Streaming就是把流数据按一定的时间间隔分割成许多个小的数据块进行批处理。在Structured Streaming的模型中,我们要把数据看成一个无边界的关系型的数据表。每一个数据都是表中的一行,不断会有新的数据行被添加到表里来。我们可以对这个表做任何类似批处理的查询,Spark会帮我们不断对新加入的数据进行处理,并更新计算结果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/37/bb1845be9f34ef7d232a509f90ae0337.jpg" alt="">
|
||||
|
||||
与Spark Streaming类似,Structured Streaming也是将输入的数据流按照时间间隔(以一秒为例)划分成数据段。每一秒都会把新输入的数据添加到表中,Spark也会每秒更新输出结果。输出结果也是表的形式,输出表可以写入硬盘或者HDFS。
|
||||
|
||||
这里我要介绍一下Structured Streaming的三种输出模式。
|
||||
|
||||
1. 完全模式(Complete Mode):整个更新过的输出表都被写入外部存储;
|
||||
1. 附加模式(Append Mode):上一次触发之后新增加的行才会被写入外部存储。如果老数据有改动则不适合这个模式;
|
||||
1. 更新模式(Update Mode):上一次触发之后被更新的行才会被写入外部存储。
|
||||
|
||||
需要注意的是,Structured Streaming并不会完全存储输入数据。每个时间间隔它都会读取最新的输入,进行处理,更新输出表,然后把这次的输入删除。Structured Streaming只会存储更新输出表所需要的信息。
|
||||
|
||||
Structured Streaming的模型在根据事件时间(Event Time)处理数据时十分方便。
|
||||
|
||||
我们在第六讲中曾经讲过事件时间和处理时间(Processing Time)的区别。这里我再简单说一下。事件时间指的是事件发生的时间,是数据本身的属性;而处理时间指的是Spark接收到数据的时间。
|
||||
|
||||
很多情况下,我们需要基于事件时间来处理数据。比如说,统计每个小时接到的订单数量,一个订单很有可能在12:59被创建,但是到了13:01才被处理。
|
||||
|
||||
在Structured Streaming的模型中,由于每个数据都是输入数据表中的一行,那么事件时间就是行中的一列。依靠DataFrame API提供的类似于SQL的接口,我们可以很方便地执行基于时间窗口的查询。
|
||||
|
||||
## Streaming DataFrame API
|
||||
|
||||
在Structured Streaming发布以后,DataFrame既可以代表静态的有边界数据,也可以代表无边界数据。之前对静态DataFrame的各种操作同样也适用于流式DataFrame。接下来,让我们看几个例子。
|
||||
|
||||
### 创建DataFrame
|
||||
|
||||
SparkSession.readStream()返回的DataStreamReader可以用于创建流DataFrame。它支持多种类型的数据流作为输入,比如文件、Kafka、socket等。
|
||||
|
||||
```
|
||||
socketDataFrame = spark
|
||||
.readStream
|
||||
.format("socket")
|
||||
.option("host", "localhost")
|
||||
.option("port", 9999)
|
||||
.load()
|
||||
|
||||
```
|
||||
|
||||
上边的代码例子创建了一个DataFrame,用来监听来自localhost:9999的数据流。
|
||||
|
||||
### 基本的查询操作
|
||||
|
||||
流DataFrame同静态DataFrame一样,不仅支持类似SQL的查询操作(如select和where等),还支持RDD的转换操作(如map和filter)。 让我们一起来看下面的例子。
|
||||
|
||||
假设我们已经有一个DataFrame代表一个学生的数据流,即每个数据是一个学生,每个学生有名字(name)、年龄(age)、身高(height)和年级(grade)四个属性,我们可以用DataFrame API去做类似于SQL的Query。
|
||||
|
||||
```
|
||||
df = … // 这个DataFrame代表学校学生的数据流,schema是{name: string, age: number, height: number, grade: string}
|
||||
df.select("name").where("age > 10") // 返回年龄大于10岁的学生名字列表
|
||||
df.groupBy("grade").count() // 返回每个年级学生的人数
|
||||
df.sort_values([‘age’], ascending=False).head(100) //返回100个年龄最大的学生
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,通过第二行我们可以得到所有年龄在10岁以上的学生名字,第三行可以得到每个年级学生的人数,第四行得到100个年龄最大的学生信息。此外,DataFrame还支持很多基本的查询操作,在此不做赘述。
|
||||
|
||||
我们还可以通过isStreaming函数来判断一个DataFrame是否代表流数据。
|
||||
|
||||
```
|
||||
df.isStreaming()
|
||||
|
||||
```
|
||||
|
||||
###
|
||||
|
||||
基于事件时间的时间窗口操作
|
||||
|
||||
在学习Spark Streaming的时间窗口操作时,我们举过一个例子,是每隔10秒钟输出过去60秒的前十热点词。这个例子是基于处理时间而非事件时间的。
|
||||
|
||||
现在让我们设想一下,如果数据流中的每个词语都有一个时间戳代表词语产生的时间,那么要怎样实现,每隔10秒钟输出过去60秒内产生的前十热点词呢?你可以看看下边的代码。
|
||||
|
||||
```
|
||||
words = ... #这个DataFrame代表词语的数据流,schema是 { timestamp: Timestamp, word: String}
|
||||
|
||||
|
||||
windowedCounts = words.groupBy(
|
||||
window(words.timestamp, "1 minute", "10 seconds"),
|
||||
words.word
|
||||
).count()
|
||||
.sort(desc("count"))
|
||||
.limit(10)
|
||||
|
||||
```
|
||||
|
||||
基于词语的生成时间,我们创建了一个窗口长度为1分钟,滑动间隔为10秒的window。然后,把输入的词语表根据window和词语本身聚合起来,并统计每个window内每个词语的数量。之后,再根据词语的数量进行排序,只返回前10的词语。
|
||||
|
||||
在Structured Streaming基于时间窗口的聚合操作中,groupBy是非常常用的。
|
||||
|
||||
### 输出结果流
|
||||
|
||||
当经过各种SQL查询操作之后,我们创建好了代表最终结果的DataFrame。下一步就是开始对输入数据流的处理,并且持续输出结果。
|
||||
|
||||
我们可以用Dataset.writeStream()返回的DataStreamWriter对象去输出结果。它支持多种写入位置,如硬盘文件、Kafka、console和内存等。
|
||||
|
||||
```
|
||||
query = wordCounts
|
||||
.writeStream
|
||||
.outputMode("complete")
|
||||
.format("csv")
|
||||
.option("path", "path/to/destination/dir")
|
||||
.start()
|
||||
|
||||
|
||||
query.awaitTermination()
|
||||
|
||||
```
|
||||
|
||||
在上面这个代码例子中,我们选择了完全模式,把输出结果流写入了CSV文件。
|
||||
|
||||
## Structured Streaming与Spark Streaming对比
|
||||
|
||||
接下来,让我们对比一下Structured Streaming和上一讲学过的Spark Streaming。看看同为流处理的组件的它们各有什么优缺点。
|
||||
|
||||
### 简易度和性能
|
||||
|
||||
Spark Streaming提供的DStream API与RDD API很类似,相对比较低level。
|
||||
|
||||
当我们编写 Spark Streaming 程序的时候,本质上就是要去构造RDD的DAG执行图,然后通过 Spark Engine 运行。这样开发者身上的担子就很重,很多时候要自己想办法去提高程序的处理效率。这不是Spark作为一个数据处理框架想看到的。对于好的框架来说,开发者只需要专注在业务逻辑上,而不用操心别的配置、优化等繁杂事项。
|
||||
|
||||
Structured Streaming提供的DataFrame API就是这么一个相对高level的API,大部分开发者都很熟悉关系型数据库和SQL。**这样的数据抽象可以让他们用一套统一的方案去处理批处理和流处理**,不用去关心具体的执行细节。
|
||||
|
||||
而且,DataFrame API是在Spark SQL的引擎上执行的,Spark SQL有非常多的优化功能,比如执行计划优化和内存管理等,所以Structured Streaming的应用程序性能很好。
|
||||
|
||||
### 实时性
|
||||
|
||||
在上一讲中我们了解到,Spark Streaming是准实时的,它能做到的最小延迟在一秒左右。
|
||||
|
||||
虽然Structured Streaming用的也是类似的微批处理思想,每过一个时间间隔就去拿来最新的数据加入到输入数据表中并更新结果,但是相比起Spark Streaming来说,它更像是实时处理,能做到用更小的时间间隔,最小延迟在100毫秒左右。
|
||||
|
||||
而且在Spark 2.3版本中,Structured Streaming引入了连续处理的模式,可以做到真正的毫秒级延迟,这无疑大大拓展了Structured Streaming的应用广度。不过现在连续处理模式还有很多限制,让我们期待它的未来吧。
|
||||
|
||||
### 对事件时间的支持
|
||||
|
||||
就像我们在前边讲过的,Structured Streaming对基于事件时间的处理有很好的支持。
|
||||
|
||||
由于Spark Streaming是把数据按接收到的时间切分成一个个RDD来进行批处理,所以它很难基于数据本身的产生时间来进行处理。如果某个数据的处理时间和事件时间不一致的话,就容易出问题。比如,统计每秒的词语数量,有的数据先产生,但是在下一个时间间隔才被处理,这样几乎不可能输出正确的结果。
|
||||
|
||||
Structured Streaming还有很多其他优点。比如,它有更好的容错性,保证了端到端exactly once的语义等等。所以综合来说,Structured Streaming是比Spark Streaming更好的流处理工具。
|
||||
|
||||
## 思考题
|
||||
|
||||
在基于事件时间的窗口操作中,Structured Streaming是怎样处理晚到达的数据,并且返回正确结果的呢?
|
||||
|
||||
比如,在每十分钟统计词频的例子中,一个词语在1:09被生成,在1:11被处理,程序在1:10和1:20都输出了对应的结果,在1:20输出时为什么可以把这个词语统计在内?这样的机制有没有限制?
|
||||
|
||||
欢迎你把自己的答案写在留言区,与我和其他同学一起讨论。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<audio id="audio" title="18 | Word Count:从零开始运行你的第一个Spark应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1c/49/1cac8efe5213fc3a8d73395841dba749.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我们来从零开始运行你的第一个Spark应用。
|
||||
|
||||
我们先来回顾一下模块三的学习路径。
|
||||
|
||||
首先,我们由浅入深地学习了Spark的基本数据结构RDD,了解了它这样设计的原因,以及它所支持的API。
|
||||
|
||||
之后,我们又学习了Spark SQL的DataSet/DataFrame API,了解到它不仅提供类似于SQL query的接口,大大提高了开发者的工作效率,还集成了Catalyst优化器,可以提升程序的性能。
|
||||
|
||||
这些API应对的都是批处理的场景。
|
||||
|
||||
再之后,我们学习了Spark的流处理模块:Spark Streaming和Structured Streaming。两者都是基于微批处理(Micro batch processing)的思想,将流数据按时间间隔分割成小的数据块进行批处理,实时更新计算结果。
|
||||
|
||||
其中Structured Streaming也是使用DataSet/DataFrame API,这套API在某种程度上统一了批处理和流处理,是当前Spark最流行的工具,我们必需要好好掌握。
|
||||
|
||||
虽然学习了这么多API以及它们的应用,但是大部分同学还没有从零开始写一个完整的Spark程序,可能更没有运行Spark程序的经历。纸上谈兵并不能帮助我们在工作生活中用Spark解决实际问题。所以,今天我就和你一起做个小练习,从在本地安装Spark、配置环境开始,为你示范怎样一步步解决之前提到数次的统计词频(Word Count)的问题。
|
||||
|
||||
通过今天的学习,你可以收获:
|
||||
|
||||
- 怎样安装Spark以及其他相关的模块;
|
||||
- 知道什么是SparkContext、SparkSession;
|
||||
- 一个完整的Spark程序应该包含哪些东西;
|
||||
- 用RDD、DataFrame、Spark Streaming如何实现统计词频。
|
||||
|
||||
这一讲中,我们使用的编程语言是Python,操作系统是Mac OS X。
|
||||
|
||||
在这一讲以及之前文章的例子中,我们都是用Python作为开发语言。虽然原生的Spark是用Scala实现,但是在大数据处理领域中,我个人最喜欢的语言是Python。因为它非常简单易用,应用非常广泛,有很多的库可以方便我们开发。
|
||||
|
||||
当然Scala也很棒,作为一个函数式编程语言,它很容易用链式表达对数据集进行各种处理,而且它的运行速度是最快的,感兴趣的同学可以去学习一下。
|
||||
|
||||
虽然Spark还支持Java和R,但是我个人不推荐你使用。用Java写程序实在有些冗长,而且速度上没有优势。
|
||||
|
||||
操作系统选Mac OS X是因为我个人喜欢使用Macbook,当然Linux/Ubuntu也很棒。
|
||||
|
||||
## 安装Spark
|
||||
|
||||
首先,我们来简单介绍一下如何在本地安装Spark,以及用Python实现的Spark库——PySpark。
|
||||
|
||||
在前面的文章中,我们了解过,Spark的job都是JVM(Java Virtual Machine)的进程,所以在安装运行Spark之前,我们需要确保已经安装Java Developer Kit(JDK)。在命令行终端中输入:
|
||||
|
||||
```
|
||||
java -version
|
||||
|
||||
```
|
||||
|
||||
如果命令行输出了某个Java的版本,那么说明你已经有JDK或者JRE在本地。如果显示无法识别这个命令,那么说明你还没有安装JDK。这时,你可以去[Oracle的官网](https://www.oracle.com/technetwork/java/javase/downloads/index.html)去下载安装JDK,然后配置好环境变量。
|
||||
|
||||
同样,我们需要确保Python也已经被安装在本地了。在命令行输入“Python”或者“Python3”,如果可以成功进入交互式的Python Shell,就说明已经安装了Python。否则,需要去[Python官网](https://www.python.org/downloads/)下载安装Python。这里,我推荐你使用Python3而不是Python2。
|
||||
|
||||
我们同样可以在本地预装好Hadoop。Spark可以脱离Hadoop运行,不过有时我们也需要依赖于HDFS和YARN。所以,这一步并不是必须的,你可以自行选择。
|
||||
|
||||
接下来我们就可以安装Spark。首先去[Spark官网](https://spark.apache.org/downloads.html)的下载界面。在第一个下拉菜单里选择最新的发布,第二个菜单最好选择与Hadoop 2.7兼容的版本。因为有时我们的Spark程序会依赖于HDFS和YARN,所以选择最新的Hadoop版本比较好。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/28/e934ae8f6f3f2394e1d14153953f4328.png" alt="">
|
||||
|
||||
下载好之后,解压缩Spark安装包,并且把它移动到/usr/local目录下,在终端中输入下面的代码。
|
||||
|
||||
```
|
||||
$ tar -xzf ~/Dowmloads/spark-2.4.3-bin-hadoop2.7.tg
|
||||
$ mv spark-2.4.3-bin-hadoop2.7.tgz /usr/local/spark
|
||||
|
||||
```
|
||||
|
||||
经过上述步骤,从官网下载并安装Spark的文件,这样我们便完成了Spark的安装。但是,Spark也是要进行相应的环境变量配置的。你需要打开环境变量配置文件。
|
||||
|
||||
```
|
||||
vim ~/.bash_profile
|
||||
|
||||
```
|
||||
|
||||
并在最后添加一段代码。
|
||||
|
||||
```
|
||||
export SPARK_HOME=/usr/local/spark
|
||||
export PATH=$PATH:$SPARK_HOME/bin
|
||||
|
||||
```
|
||||
|
||||
这样,所需的步骤都做完之后,我们在命令行控制台输入PySpark,查看安装情况。如果出现下面的欢迎标志,就说明安装完毕了。
|
||||
|
||||
```
|
||||
Welcome to
|
||||
____ __
|
||||
/ __/__ ___ _____/ /__
|
||||
_\ \/ _ \/ _ `/ __/ '_/
|
||||
/__ / .__/\_,_/_/ /_/\_\ version 2.4.3
|
||||
/_/
|
||||
|
||||
Using Python version 2.7.10 (default, Oct 6 2017 22:29:07)
|
||||
SparkSession available as 'spark'.
|
||||
>>>
|
||||
|
||||
```
|
||||
|
||||
## 基于RDD API的Word Count程序
|
||||
|
||||
配置好所需的开发环境之后,下一步就是写一个Python程序去统计词语频率。我们都知道这个程序的逻辑应该是如下图所示的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/2e/b0b16243323bb871959e9a86b803992e.jpg" alt="">
|
||||
|
||||
对于中间的先map再reduce的处理,我相信通过前面的学习,所有同学都可以用RDD或者DataFrame实现。
|
||||
|
||||
但是,我们对于Spark程序的入口是什么、如何用它读取和写入文件,可能并没有了解太多。所以,接下来让我们先接触一下Spark程序的入口。
|
||||
|
||||
在Spark 2.0之前,**SparkContext**是所有Spark任务的入口,它包含了Spark程序的基本设置,比如程序的名字、内存大小、并行处理的粒度等,Spark的驱动程序需要利用它来连接到集群。
|
||||
|
||||
无论Spark集群有多少个节点做并行处理,每个程序只可以有唯一的SparkContext,它可以被SparkConf对象初始化。
|
||||
|
||||
```
|
||||
conf = SparkConf().setAppName(appName).setMaster(master)
|
||||
sc = SparkContext(conf=conf)
|
||||
|
||||
```
|
||||
|
||||
这个appName参数是一个在集群UI上展示应用程序的名称,master参数是一个Spark、Mesos 或YARN的集群URL,对于本地运行,它可以被指定为“local”。
|
||||
|
||||
在统计词频的例子中,我们需要通过SparkContext对象来读取输入文件,创建一个RDD,如下面的代码所示。
|
||||
|
||||
```
|
||||
text_file = sc.textFile("file://…...") //替换成实际的本地文件路径。
|
||||
|
||||
```
|
||||
|
||||
这里的text_file是一个RDD,它里面的每一个数据代表原文本文件中的一行。
|
||||
|
||||
在这些版本中,如果要使用Spark提供的其他库,比如SQL或Streaming,我们就需要为它们分别创建相应的context对象,才能调用相应的API,比如的DataFrame和DStream。
|
||||
|
||||
```
|
||||
hc = HiveContext(sc)
|
||||
ssc = StreamingContext(sc)
|
||||
|
||||
```
|
||||
|
||||
在Spark 2.0之后,随着新的DataFrame/DataSet API的普及化,Spark引入了新的**SparkSession**对象作为所有Spark任务的入口。
|
||||
|
||||
SparkSession不仅有SparkContext的所有功能,它还集成了所有Spark提供的API,比如DataFrame、Spark Streaming和Structured Streaming,我们再也不用为不同的功能分别定义Context。
|
||||
|
||||
在统计词频的例子中,我们可以这样初始化SparkSession以及创建初始RDD。
|
||||
|
||||
```
|
||||
spark = SparkSession
|
||||
.builder
|
||||
.appName(appName)
|
||||
.getOrCreate()
|
||||
text_file = spark.read.text("file://….").rdd.map(lambda r: r[0])
|
||||
|
||||
```
|
||||
|
||||
由于SparkSession的普适性,我推荐你尽量使用它作为你们Spark程序的入口。随后的学习中,我们会逐渐了解怎样通过它调用DataFrame和Streaming API。
|
||||
|
||||
让我们回到统计词频的例子。在创建好代表每一行文本的RDD之后,接下来我们便需要两个步骤。
|
||||
|
||||
1. 把每行的文本拆分成一个个词语;
|
||||
1. 统计每个词语的频率。
|
||||
|
||||
对于第一步,我们可以用flatMap去把行转换成词语。对于第二步,我们可以先把每个词语转换成(word, 1)的形式,然后用reduceByKey去把相同词语的次数相加起来。这样,就很容易写出下面的代码了。
|
||||
|
||||
```
|
||||
counts = lines.flatMap(lambda x: x.split(' '))
|
||||
.map(lambda x: (x, 1))
|
||||
.reduceByKey(add)
|
||||
|
||||
```
|
||||
|
||||
这里counts就是一个包含每个词语的(word,count)pair的RDD。
|
||||
|
||||
相信你还记得,只有当碰到action操作后,这些转换动作才会被执行。所以,接下来我们可以用collect操作把结果按数组的形式返回并输出。
|
||||
|
||||
```
|
||||
output = counts.collect()
|
||||
for (word, count) in output:
|
||||
print("%s: %i" % (word, count))
|
||||
spark.stop() // 停止SparkSession
|
||||
|
||||
```
|
||||
|
||||
## 基于DataFrame API的Word Count程序
|
||||
|
||||
讲完基于RDD API的Word Count程序,接下来让我们学习下怎样用DataFrame API来实现相同的效果。
|
||||
|
||||
在DataFrame的世界中,我们可以把所有的词语放入一张表,表中的每一行代表一个词语,当然这个表只有一列。我们可以对这个表用一个groupBy()操作把所有相同的词语聚合起来,然后用count()来统计出每个group的数量。
|
||||
|
||||
但是问题来了,虽然Scala和Java支持对DataFrame进行flatMap操作,但是Python并不支持。那么要怎样把包含多个词语的句子进行分割和拆分呢?这就要用到两个新的操作——explode和split。split是pyspark.sql.functions库提供的一个函数,它作用于DataFrame的某一列,可以把列中的字符串按某个分隔符分割成一个字符串数组。
|
||||
|
||||
explode同样是pyspark.sql.functions库提供的一个函数,通俗点的翻译是“爆炸”,它也作用于DataFrame的某一列,可以为列中的数组或者map中每一个元素创建一个新的Row。
|
||||
|
||||
由于之前代码中创建的df_lines这个DataFrame中,每一行只有一列,每一列都是一个包含很多词语的句子,我们可以先对这一列做split,生成一个新的列,列中每个元素是一个词语的数组;再对这个列做explode,可以把数组中的每个元素都生成一个新的Row。这样,就实现了类似的flatMap功能。这个过程可以用下面的三个表格说明。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c9/55/c9ebf1f324a73539a2a57ce8151e4455.png" alt="">
|
||||
|
||||
接下来我们只需要对Word这一列做groupBy,就可以统计出每个词语出现的频率,代码如下。
|
||||
|
||||
```
|
||||
from pyspark.sql import SparkSession
|
||||
from pyspark.sql.functions import *
|
||||
|
||||
if __name__ == "__main__":
|
||||
spark = SparkSession
|
||||
.builder
|
||||
.appName(‘WordCount’)
|
||||
.getOrCreate()
|
||||
lines = spark.read.text("sample.txt")
|
||||
wordCounts = lines
|
||||
.select(explode(split(lines.value, " "))
|
||||
.alias("word"))
|
||||
.groupBy("word")
|
||||
.count()
|
||||
wordCounts.show()
|
||||
|
||||
spark.stop()
|
||||
|
||||
```
|
||||
|
||||
从这个例子,你可以很容易看出使用DataSet/DataFrame API的便利性——我们不需要创建(word, count)的pair来作为中间值,可以直接对数据做类似SQL的查询。
|
||||
|
||||
## 小结
|
||||
|
||||
通过今天的学习,我们掌握了如何从零开始创建一个简单的Spark的应用程序,包括如何安装Spark、如何配置环境、Spark程序的基本结构等等。
|
||||
|
||||
## 实践题
|
||||
|
||||
希望你可以自己动手操作一下,这整个过程只需要跑通一次,以后就可以脱离纸上谈兵,真正去解决实际问题。
|
||||
|
||||
欢迎你在留言中反馈自己动手操作的效果。
|
||||
|
||||
如果你跑通了,可以在留言中打个卡。如果遇到了问题,也请你在文章中留言,与我和其他同学一起讨论。
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<audio id="audio" title="19 | 综合案例实战:处理加州房屋信息,构建线性回归模型" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/70/484a40fdfd3932021002db99892e6f70.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“综合案例实战:处理加州房屋信息,构建线性回归模型”。
|
||||
|
||||
通过之前的学习,我们对Spark各种API的基本用法有了一定的了解,还通过统计词频的实例掌握了如何从零开始写一个Spark程序。那么现在,让我们从一个真实的数据集出发,看看如何用Spark解决实际问题。
|
||||
|
||||
## 数据集介绍
|
||||
|
||||
为了完成今天的综合案例实战,我使用的是美国加州1990年房屋普查的数据集。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a9/5c/a9c1d749f2d1c43261a043aa77056f5c.png" alt="">
|
||||
|
||||
数据集中的每一个数据都代表着一块区域内房屋和人口的基本信息,总共包括9项:
|
||||
|
||||
1. 该地区中心的纬度(latitude)
|
||||
1. 该地区中心的经度(longitude)
|
||||
1. 区域内所有房屋屋龄的中位数(housingMedianAge)
|
||||
1. 区域内总房间数(totalRooms)
|
||||
1. 区域内总卧室数(totalBedrooms)
|
||||
1. 区域内总人口数(population)
|
||||
1. 区域内总家庭数(households)
|
||||
1. 区域内人均收入中位数(medianIncome)
|
||||
1. 该区域房价的中位数(medianHouseValue)
|
||||
|
||||
也就是说,我们可以把每一个数据看作一个地区,它含有9项我们关心的信息,也就是上面提到的9个指标。比如下面这个数据:
|
||||
|
||||
```
|
||||
-122.230000,37.880000,41.000000,880.000000,129.000000,322.000000,126.000000,8.325200,452600.000000'
|
||||
|
||||
```
|
||||
|
||||
这个数据代表该地区的经纬度是(-122.230000,37.880000),这个地区房屋历史的中位数是41年,所有房屋总共有880个房间,其中有129个卧室。这个地区内共有126个家庭和322位居民,人均收入中位数是8.3252万,房价中位数是45.26万。
|
||||
|
||||
这里的地域单位是美国做人口普查的最小地域单位,平均一个地域单位中有1400多人。在这个数据集中共有两万多个这样的数据。显然,这样小的数据量我们并“不需要”用Spark来处理,但是,它可以起到一个很好的示例作用。这个数据集可以从[网上](http://www.dcc.fc.up.pt/~ltorgo/Regression/cal_housing.html)下载到。这个数据集是在1997年的一篇学术论文中创建的,感兴趣的同学可以去亲自下载,并加以实践。
|
||||
|
||||
那么我们今天的目标是什么呢?就是用已有的数据,构建一个**线性回归模型**,来预测房价。
|
||||
|
||||
我们可以看到,前8个属性都可能对房价有影响。这里,我们假设这种影响是线性的,我们就可以找到一个类似**A=b**B+c**C+d**D+…+i**I**的公式,A代表房价,B到I分别代表另外八个属性。这样,对于不在数据集中的房子,我们可以套用这个公式来计算出一个近似的房价。由于专栏的定位是大规模数据处理专栏,所以我们不会细讲统计学的知识。如果你对统计学知识感兴趣,或者还不理解什么是线性回归的话,可以去自行学习一下。
|
||||
|
||||
## 进一步了解数据集
|
||||
|
||||
每当我们需要对某个数据集进行处理时,不要急着写代码。你一定要先观察数据集,了解它的特性,并尝试对它做一些简单的预处理,让数据的可读性更好。这些工作我们最好在Spark的交互式Shell上完成,而不是创建python的源文件并执行。因为,在Shell上我们可以非常直观而简便地看到每一步的输出。
|
||||
|
||||
首先,让我们把数据集读入Spark。
|
||||
|
||||
```
|
||||
from pyspark.sql import SparkSession
|
||||
|
||||
# 初始化SparkSession和SparkContext
|
||||
spark = SparkSession.builder
|
||||
.master("local")
|
||||
.appName("California Housing ")
|
||||
.config("spark.executor.memory", "1gb")
|
||||
.getOrCreate()
|
||||
sc = spark.sparkContext
|
||||
|
||||
# 读取数据并创建RDD
|
||||
rdd = sc.textFile('/Users/yourName/Downloads/CaliforniaHousing/cal_housing.data')
|
||||
|
||||
# 读取数据每个属性的定义并创建RDD
|
||||
header = sc.textFile('/Users/yourName/Downloads/CaliforniaHousing/cal_housing.domain')
|
||||
|
||||
```
|
||||
|
||||
这样,我们就把房屋信息数据和每个属性的定义读入了Spark,并创建了两个相应的RDD。你还记得吧?RDD是有一个惰性求值的特性的,所以,我们可以用collect()函数来把数据输出在Shell上。
|
||||
|
||||
```
|
||||
header.collect()
|
||||
|
||||
[u'longitude: continuous.', u'latitude: continuous.', u'housingMedianAge: continuous. ', u'totalRooms: continuous. ', u'totalBedrooms: continuous. ', u'population: continuous. ', u'households: continuous. ', u'medianIncome: continuous. ', u'medianHouseValue: continuous. ']
|
||||
|
||||
```
|
||||
|
||||
这样,我们就得到了每个数据所包含的信息,这和我们前面提到的9个属性的顺序是一致的,而且它们都是连续的值,而不是离散的。你需要注意的是,collect()函数会把所有数据都加载到内存中,如果数据很大的话,有可能会造成内存泄漏,所以要小心使用。平时比较常见的方法是用take()函数去只读取RDD中的某几个元素。
|
||||
|
||||
由于RDD中的数据可能会比较大,所以接下来让我们读取它的前两个数据。
|
||||
|
||||
```
|
||||
rdd.take(2)
|
||||
|
||||
[u'-122.230000,37.880000,41.000000,880.000000,129.000000,322.000000,126.000000,8.325200,452600.000000', u'-122.220000,37.860000,21.000000,7099.000000,1106.000000,2401.000000,1138.000000,8.301400,358500.000000']
|
||||
|
||||
```
|
||||
|
||||
由于我们是用SparkContext的textFile函数去创建RDD,所以每个数据其实是一个大的字符串,各个属性之间用逗号分隔开来。这不利于我们之后的处理,因为我们可能会需要分别读取每个对象的各个属性。所以,让我们用map函数把大字符串分隔成数组,这会方便我们的后续操作。
|
||||
|
||||
```
|
||||
rdd = rdd.map(lambda line: line.split(","))
|
||||
rdd.take(2)
|
||||
|
||||
[[u'-122.230000', u'37.880000', u'41.000000', u'880.000000', u'129.000000', u'322.000000', u'126.000000', u'8.325200', u'452600.000000'], [u'-122.220000', u'37.860000', u'21.000000', u'7099.000000', u'1106.000000', u'2401.000000', u'1138.000000', u'8.301400', u'358500.000000']]
|
||||
|
||||
```
|
||||
|
||||
我们在前面学过,Spark SQL的DataFrame API在查询结构化数据时更方便使用,而且性能更好。在这个例子中你可以看到,数据的schema是定义好的,我们需要去查询各个列,所以DataFrame API显然更加适用。所以,我们需要先把RDD转换为DataFrame。
|
||||
|
||||
具体来说,就是需要把之前用数组代表的对象,转换成为Row对象,再用toDF()函数转换成DataFrame。
|
||||
|
||||
```
|
||||
from pyspark.sql import Row
|
||||
|
||||
df = rdd.map(lambda line: Row(longitude=line[0],
|
||||
latitude=line[1],
|
||||
housingMedianAge=line[2],
|
||||
totalRooms=line[3],
|
||||
totalBedRooms=line[4],
|
||||
population=line[5],
|
||||
households=line[6],
|
||||
medianIncome=line[7],
|
||||
medianHouseValue=line[8])).toDF()
|
||||
|
||||
```
|
||||
|
||||
现在我们可以用show()函数打印出这个DataFrame所含的数据表。
|
||||
|
||||
```
|
||||
df.show()
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/24/de91764e7e7cc3d143a8217400ec0524.png" alt="">
|
||||
|
||||
这里每一列的数据格式都是string,但是,它们其实都是数字,所以我们可以通过cast()函数把每一列的类型转换成float。
|
||||
|
||||
```
|
||||
def convertColumn(df, names, newType)
|
||||
for name in names:
|
||||
df = df.withColumn(name, df[name].cast(newType))
|
||||
return df
|
||||
|
||||
columns = ['households', 'housingMedianAge', 'latitude', 'longitude', 'medianHouseValue', 'medianIncome', 'population', 'totalBedRooms', 'totalRooms']
|
||||
|
||||
df = convertColumn(df, columns, FloatType())
|
||||
|
||||
```
|
||||
|
||||
转换成数字有很多优势。比如,我们可以按某一列,对所有对象进行排序,也可以计算平均值等。比如,下面这段代码就可以统计出所有建造年限各有多少个房子。
|
||||
|
||||
```
|
||||
df.groupBy("housingMedianAge").count().sort("housingMedianAge",ascending=False).show()
|
||||
|
||||
```
|
||||
|
||||
## 预处理
|
||||
|
||||
通过上面的数据分析,你可能会发现这些数据还是不够直观。具体的问题有:
|
||||
|
||||
1. 房价的值普遍都很大,我们可以把它调整成相对较小的数字;
|
||||
1. 有的属性没什么意义,比如所有房子的总房间数和总卧室数,我们更加关心的是平均房间数;
|
||||
1. 在我们想要构建的线性模型中,房价是结果,其他属性是输入参数。所以我们需要把它们分离处理;
|
||||
1. 有的属性最小值和最大值范围很大,我们可以把它们标准化处理。
|
||||
|
||||
对于第一点,我们观察到大多数房价都是十万起的,所以可以用withColumn()函数把所有房价都除以100000。
|
||||
|
||||
```
|
||||
df = df.withColumn("medianHouseValue", col("medianHouseValue")/100000)
|
||||
|
||||
```
|
||||
|
||||
对于第二点,我们可以添加如下三个新的列:
|
||||
|
||||
- 每个家庭的平均房间数:roomsPerHousehold
|
||||
- 每个家庭的平均人数:populationPerHousehold
|
||||
- 卧室在总房间的占比:bedroomsPerRoom
|
||||
|
||||
当然,你们可以自由添加你们觉得有意义的列,这里的三个是我觉得比较典型的。同样,用withColumn()函数可以容易地新建列。
|
||||
|
||||
```
|
||||
df = df.withColumn("roomsPerHousehold", col("totalRooms")/col("households"))
|
||||
.withColumn("populationPerHousehold", col("population")/col("households"))
|
||||
.withColumn("bedroomsPerRoom", col("totalBedRooms")/col("totalRooms"))
|
||||
|
||||
```
|
||||
|
||||
同样,有的列是我们并不关心的,比如经纬度,这个数值很难有线性的意义。所以我们可以只留下重要的信息列。
|
||||
|
||||
```
|
||||
df = df.select("medianHouseValue",
|
||||
"totalBedRooms",
|
||||
"population",
|
||||
"households",
|
||||
"medianIncome",
|
||||
"roomsPerHousehold",
|
||||
"populationPerHousehold",
|
||||
"bedroomsPerRoom")
|
||||
|
||||
```
|
||||
|
||||
对于第三点,最简单的办法就是把DataFrame转换成RDD,然后用map()函数把每个对象分成两部分:房价和一个包含其余属性的列表,然后在转换回DataFrame。
|
||||
|
||||
```
|
||||
from pyspark.ml.linalg import DenseVector
|
||||
|
||||
input_data = df.rdd.map(lambda x: (x[0], DenseVector(x[1:])))
|
||||
df = spark.createDataFrame(input_data, ["label", "features"])
|
||||
|
||||
```
|
||||
|
||||
我们重新把两部分重新标记为“label”和“features”,label代表的是房价,features代表包括其余参数的列表。
|
||||
|
||||
对于第四点,数据的标准化我们可以借助Spark的机器学习库Spark ML来完成。Spark ML也是基于DataFrame,它提供了大量机器学习的算法实现、数据流水线(pipeline)相关工具和很多常用功能。由于本专栏的重点是大数据处理,所以我们并没有介绍Spark ML,但是我强烈推荐同学们有空去了解一下它。
|
||||
|
||||
在这个AI和机器学习的时代,我们不能落伍。
|
||||
|
||||
```
|
||||
from pyspark.ml.feature import StandardScaler
|
||||
|
||||
standardScaler = StandardScaler(inputCol="features", outputCol="features_scaled")
|
||||
scaler = standardScaler.fit(df)
|
||||
scaled_df = scaler.transform(df)
|
||||
|
||||
```
|
||||
|
||||
在第二行,我们创建了一个StandardScaler,它的输入是features列,输出被我们命名为features_scaled。第三、第四行,我们把这个scaler对已有的DataFrame进行处理,让我们看下代码块里显示的输出结果。
|
||||
|
||||
```
|
||||
scaled_df.take(1)
|
||||
|
||||
[Row(label=4.526, features=DenseVector([129.0, 322.0, 126.0, 8.3252, 6.9841, 2.5556, 0.1466]), features_scaled=DenseVector([0.3062, 0.2843, 0.3296, 4.3821, 2.8228, 0.2461, 2.5264]))]
|
||||
|
||||
```
|
||||
|
||||
我们可以清楚地看到,这一行新增了一个features_scaled的列,它里面每个数据都是标准化过的,我们应该用它,而非features来训练模型。
|
||||
|
||||
## 创建模型
|
||||
|
||||
上面的预处理都做完后,我们终于可以开始构建线性回归模型了。
|
||||
|
||||
首先,我们需要把数据集分为训练集和测试集,训练集用来训练模型,测试集用来评估模型的正确性。DataFrame的randomSplit()函数可以很容易的随机分割数据,这里我们将80%的数据用于训练,剩下20%作为测试集。
|
||||
|
||||
```
|
||||
train_data, test_data = scaled_df.randomSplit([.8,.2],seed=123)
|
||||
|
||||
```
|
||||
|
||||
用Spark ML提供的LinearRegression功能,我们可以很容易得构建一个线性回归模型,如下所示。
|
||||
|
||||
```
|
||||
from pyspark.ml.regression import LinearRegression
|
||||
|
||||
lr = LinearRegression(featuresCol='features_scaled', labelCol="label", maxIter=10, regParam=0.3, elasticNetParam=0.8)
|
||||
linearModel = lr.fit(train_data)
|
||||
|
||||
```
|
||||
|
||||
LinearRegression可以调节的参数还有很多,你可以去[官方API文档](https://spark.apache.org/docs/latest/api/python/pyspark.ml.html#pyspark.ml.regression.LinearRegression)查阅,这里我们只是示范一下。
|
||||
|
||||
## 模型评估
|
||||
|
||||
现在有了模型,我们终于可以用linearModel的transform()函数来预测测试集中的房价,并与真实情况进行对比。代码如下所示。
|
||||
|
||||
```
|
||||
predicted = linearModel.transform(test_data)
|
||||
predictions = predicted.select("prediction").rdd.map(lambda x: x[0])
|
||||
labels = predicted.select("label").rdd.map(lambda x: x[0])
|
||||
predictionAndLabel = predictions.zip(labels).collect()
|
||||
|
||||
```
|
||||
|
||||
我们用RDD的zip()函数把预测值和真实值放在一起,这样可以方便地进行比较。比如让我们看一下前两个对比结果。
|
||||
|
||||
```
|
||||
predictionAndLabel.take(2)
|
||||
|
||||
[(1.4491508524918457, 1.14999), (1.5831547768979277, 0.964)]
|
||||
|
||||
```
|
||||
|
||||
这里可以看出,我们的模型预测的结果有些偏小,这可能有多个因素造成。最直接的原因就是房价与我们挑选的列并没有强线性关系,而且我们使用的参数也可能不够准确。
|
||||
|
||||
这一讲我只是想带着你一起体验下处理真实数据集和解决实际问题的感觉,想要告诉你的是这种通用的思想,并帮助你继续熟悉Spark各种库的用法,并不是说房价一定就是由这些参数线性决定了。感兴趣的同学可以去继续优化,或者尝试别的模型。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们通过一个真实的数据集,通过以下步骤解决了一个实际的数据处理问题:
|
||||
|
||||
1. 观察并了解数据集
|
||||
1. 数据清洗
|
||||
1. 数据的预处理
|
||||
1. 训练模型
|
||||
1. 评估模型
|
||||
|
||||
其实这里还可以有与“优化与改进”相关的内容,这里没有去阐述是因为我们的首要目的依然是熟悉与使用Spark各类API。相信通过今天的学习,你初步了解了数据处理问题的一般思路,并强化了对RDD、DataFrame和机器学习API的使用。
|
||||
|
||||
## 实践与思考题
|
||||
|
||||
今天请你下载这个数据集,按文章的介绍去动手实践一次。如果有时间的话,还可以对这个过程的优化和改进提出问题并加以解决。
|
||||
|
||||
欢迎你在留言板贴出自己的idea。如果你觉得有所收获,也欢迎你把文章分享给朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<audio id="audio" title="20 | 流处理案例实战:分析纽约市出租车载客信息" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/88/95/8881f9078fc69c0f39af9f0558f86495.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“流处理案例实战:分析纽约市出租车载客信息”。
|
||||
|
||||
在上一讲中,我们结合加州房屋信息的真实数据集,构建了一个基本的预测房价的线性回归模型。通过这个实例,我们不仅学习了处理大数据问题的基本流程,而且还进一步熟练了对RDD和DataFrame API的使用。
|
||||
|
||||
你应该已经发现,上一讲的实例是一个典型的批处理问题,因为处理的数据是静态而有边界的。今天让我们来一起通过实例,更加深入地学习用Spark去解决实际的流处理问题。
|
||||
|
||||
相信你还记得,在前面的章节中我们介绍过Spark两个用于流处理的组件——Spark Streaming和Structured Streaming。其中Spark Streaming是Spark 2.0版本前的的流处理库,在Spark 2.0之后,集成了DataFrame/DataSet API的Structured Streaming成为Spark流处理的主力。
|
||||
|
||||
今天就让我们一起用Structured Streaming对纽约市出租车的载客信息进行处理,建立一个实时流处理的pipeline,实时输出各个区域内乘客小费的平均数来帮助司机决定要去哪里接单。
|
||||
|
||||
## 数据集介绍
|
||||
|
||||
今天的数据集是纽约市2009~2015年出租车载客的信息。每一次出行包含了两个事件,一个事件代表出发,另一个事件代表到达。每个事件都有11个属性,它的schema如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/90/4ae9c7d353925f84d36bf7280f2b5b90.jpg" alt="">
|
||||
|
||||
这部分数据有个不太直观的地方,那就是同一次出行会有两个记录,而且代表出发的事件没有任何意义,因为到达事件已经涵盖了所有必要的信息。现实世界中的数据都是这样复杂,不可能像学校的测试数据一样简单直观,所以处理之前,我们要先对数据进行清洗,只留下必要的信息。
|
||||
|
||||
这个数据还包含有另外一部分信息,就是所有出租车的付费信息,它有8个属性,schema如下所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/de/8ef443617788243f4546116fffb40ede.jpg" alt="">
|
||||
|
||||
这个数据集可以从[网上](https://training.ververica.com/setup/taxiData.html)下载到,数据集的规模在100MB左右,它只是节选了一部分出租车的载客信息,所以在本机运行就可以了。详细的纽约出租车数据集超过了500GB,同样在[网上](https://www1.nyc.gov/site/tlc/about/tlc-trip-record-data.page)可以下载,感兴趣的同学可以下载来实践一下。
|
||||
|
||||
## 流数据输入
|
||||
|
||||
你可能要问,这个数据同样是静态、有边界的,为什么要用流处理?
|
||||
|
||||
因为我们手里没有实时更新的流数据源。我也没有权限去公开世界上任何一个上线产品的数据流。所以,这里只能将有限的数据经过Kafka处理,输出为一个伪流数据,作为我们要构建的pipeline的输入。
|
||||
|
||||
在模块二中,我们曾经初步了解过Apache Kafka,知道它是基于Pub/Sub模式的流数据处理平台。由于我们的专栏并不涉及Apache Kafka的具体内容,所以我在这里就不讲如何把这个数据输入到Kafka并输出的细节了。你只要知道,在这个例子中,Consumer是之后要写的Spark流处理程序,这个消息队列有两个Topic,一个包含出行的地理位置信息,一个包含出行的收费信息。Kafka会**按照时间顺序**,向这两个Topic中发布事件,从而模拟一个实时的流数据源。
|
||||
|
||||
相信你还记得,写Spark程序的第一步就是创建SparkSession对象,并根据输入数据创建对应的RDD或者DataFrame。你可以看下面的代码。
|
||||
|
||||
```
|
||||
from pyspark.sql import SparkSession
|
||||
|
||||
spark = SparkSession.builder
|
||||
.appName("Spark Structured Streaming for taxi ride info")
|
||||
.getOrCreate()
|
||||
|
||||
rides = spark
|
||||
.readStream
|
||||
.format("kafka")
|
||||
.option("kafka.bootstrap.servers", "localhost:xxxx") //取决于Kafka的配置
|
||||
.option("subscribe", "taxirides")
|
||||
.option("startingOffsets", "latest")
|
||||
.load()
|
||||
.selectExpr("CAST(value AS STRING)")
|
||||
|
||||
fares = spark
|
||||
.readStream
|
||||
.format("kafka")
|
||||
.option("kafka.bootstrap.servers", "localhost:xxxx")
|
||||
.option("subscribe", "taxifares")
|
||||
.option("startingOffsets", "latest")
|
||||
.load()
|
||||
.selectExpr("CAST(value AS STRING)
|
||||
|
||||
```
|
||||
|
||||
在这段代码里,我们创建了两个Streaming DataFrame,并订阅了对应的Kafka topic,一个代表出行位置信息,另一个代表收费信息。Kafka对数据没有做任何修改,所以流中的每一个数据都是一个长String,属性之间是用逗号分割的。
|
||||
|
||||
```
|
||||
417986,END,2013-01-02 00:43:52,2013-01-02 00:39:56,-73.984528,40.745377,-73.975967,40.765533,1,2013007646,2013007642
|
||||
|
||||
```
|
||||
|
||||
## 数据清洗
|
||||
|
||||
现在,我们要开始做数据清洗了。要想分离出我们需要的位置和付费信息,我们首先要把数据分割成一个个属性,并创建对应的DataFrame中的列。为此,我们首先要根据数据类型创建对应的schema。
|
||||
|
||||
```
|
||||
ridesSchema = StructType([
|
||||
StructField("rideId", LongType()), StructField("isStart", StringType()),
|
||||
StructField("endTime", TimestampType()), StructField("startTime", TimestampType()),
|
||||
StructField("startLon", FloatType()), StructField("startLat", FloatType()),
|
||||
StructField("endLon", FloatType()), StructField("endLat", FloatType()),
|
||||
StructField("passengerCnt", ShortType()), StructField("taxiId", LongType()),
|
||||
StructField("driverId", LongType())])
|
||||
|
||||
faresSchema = StructType([
|
||||
StructField("rideId", LongType()), StructField("taxiId", LongType()),
|
||||
StructField("driverId", LongType()), StructField("startTime", TimestampType()),
|
||||
StructField("paymentType", StringType()), StructField("tip", FloatType()),
|
||||
StructField("tolls", FloatType()), StructField("totalFare", FloatType())])
|
||||
|
||||
```
|
||||
|
||||
接下来,我们将每个数据都用逗号分割,并加入相应的列。
|
||||
|
||||
```
|
||||
def parse_data_from_kafka_message(sdf, schema):
|
||||
from pyspark.sql.functions import split
|
||||
assert sdf.isStreaming == True, "DataFrame doesn't receive streaming data"
|
||||
col = split(sdf['value'], ',')
|
||||
for idx, field in enumerate(schema):
|
||||
sdf = sdf.withColumn(field.name, col.getItem(idx).cast(field.dataType))
|
||||
return sdf.select([field.name for field in schema])
|
||||
|
||||
rides = parse_data_from_kafka_message(rides, ridesSchema)
|
||||
fares = parse_data_from_kafka_message(fares, faresSchema)
|
||||
|
||||
```
|
||||
|
||||
在上面的代码中,我们定义了函数parse_data_from_kafka_message,用来把Kafka发来的message根据schema拆成对应的属性,转换类型,并加入到DataFrame的表中。
|
||||
|
||||
正如我们之前提到的,读入的数据包含了一些无用信息。
|
||||
|
||||
首先,所有代表出发的事件都已被删除,因为到达事件已经包含了出发事件的所有信息,而且只有到达之后才会付费。
|
||||
|
||||
其次,出发地点和目的地在纽约范围外的数据,也可以被删除。因为我们的目标是找出纽约市内小费较高的地点。DataFrame的filter函数可以很容易地做到这些。
|
||||
|
||||
```
|
||||
MIN_LON, MAX_LON, MIN_LAT, MAX_LAT = -73.7, -74.05, 41.0, 40.5
|
||||
rides = rides.filter(
|
||||
rides["startLon"].between(MIN_LON, MAX_LON) &
|
||||
rides["startLat"].between(MIN_LAT, MAX_LAT) &
|
||||
rides["endLon"].between(MIN_LON, MAX_LON) &
|
||||
rides["endLat"].between(MIN_LAT, MAX_LAT))
|
||||
rides = rides.filter(rides["isStart"] == "END")
|
||||
|
||||
```
|
||||
|
||||
上面的代码中首先定义了纽约市的经纬度范围,然后把所有起点和终点在这个范围之外的数据都过滤掉了。最后,把所有代表出发事件的数据也移除掉。
|
||||
|
||||
当然,除了前面提到的清洗方案,可能还会有别的可以改进的地方,比如把不重要的信息去掉,例如乘客数量、过路费等,你可以自己思考一下。
|
||||
|
||||
## Stream-stream Join
|
||||
|
||||
我们的目标是找出小费较高的地理区域,而现在收费信息和地理位置信息还在两个DataFrame中,无法放在一起分析。那么要用怎样的方式把它们联合起来呢?
|
||||
|
||||
你应该还记得,DataFrame本质上是把数据当成一张关系型的表。在我们这个例子中,rides所对应的表的键值(Key)是rideId,其他列里我们关心的就是起点和终点的位置;fares所对应的表键值也是rideId,其他列里我们关心的就是小费信息(tips)。
|
||||
|
||||
说到这里,你可能会自然而然地想到,如果可以像关系型数据表一样,根据共同的键值rideId把两个表inner join起来,就可以同时分析这两部分信息了。但是这里的DataFrame其实是两个数据流,Spark可以把两个流Join起来吗?
|
||||
|
||||
答案是肯定的。在Spark 2.3中,流与流的Join(Stream-stream join)被正式支持。这样的Join难点就在于,在任意一个时刻,流数据都不是完整的,流A中后面还没到的数据有可能要和流B中已经有的数据Join起来再输出。为了解决这个问题,我们就要引入**数据水印**(Watermark)的概念。
|
||||
|
||||
数据水印定义了我们可以对数据延迟的最大容忍限度。
|
||||
|
||||
比如说,如果定义水印是10分钟,数据A的事件时间是1:00,数据B的事件时间是1:10,由于数据传输发生了延迟,我们在1:15才收到了A和B,那么我们将只处理数据B并更新结果,A会被无视。在Join操作中,好好利用水印,我们就知道什么时候可以不用再考虑旧数据,什么时候必须把旧数据保留在内存中。不然,我们就必须把所有旧数据一直存在内存里,导致数据不断增大,最终可能会内存泄漏。
|
||||
|
||||
在这个例子中,为什么我们做这样的Join操作需要水印呢?
|
||||
|
||||
这是因为两个数据流并不保证会同时收到同一次出行的数据,因为收费系统需要额外的时间去处理,而且这两个数据流是独立的,每个都有可能产生数据延迟。所以要对时间加水印,以免出现内存中数据无限增长的情况。
|
||||
|
||||
那么下一个问题就是,究竟要对哪个时间加水印,出发时间还是到达时间?
|
||||
|
||||
前面说过了,我们其实只关心到达时间,所以对rides而言,我们只需要对到达时间加水印。但是,在fares这个DataFrame里并没有到达时间的任何信息,所以我们没法选择,只能对出发时间加水印。因此,我们还需要额外定义一个时间间隔的限制,出发时间和到达时间的间隔要在一定的范围内。具体内容你可以看下面的代码。
|
||||
|
||||
```
|
||||
faresWithWatermark = fares
|
||||
.selectExpr("rideId AS rideId_fares", "startTime", "totalFare", "tip")
|
||||
.withWatermark("startTime", "30 minutes")
|
||||
|
||||
ridesWithWatermark = rides
|
||||
.selectExpr("rideId", "endTime", "driverId", "taxiId", "startLon", "startLat", "endLon", "endLat")
|
||||
.withWatermark("endTime", "30 minutes")
|
||||
|
||||
joinDF = faresWithWatermark
|
||||
.join(ridesWithWatermark,
|
||||
expr("""
|
||||
rideId_fares = rideId AND
|
||||
endTime > startTime AND
|
||||
endTime <= startTime + interval 2 hours
|
||||
""")
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我们对fares和rides分别加了半小时的水印,然后把两个DataFrame根据rideId和时间间隔的限制Join起来。这样,joinDF就同时包含了地理位置和付费信息。
|
||||
|
||||
接下来,就让我们开始计算实时的小费最高区域。
|
||||
|
||||
## 计算结果并输出
|
||||
|
||||
到现在为止,我们还没有处理地点信息。原生的经纬度信息显然并没有很大用处。我们需要做的是把纽约市分割成几个区域,把数据中所有地点的经纬度信息转化成区域信息,这样司机们才可以知道大概哪个地区的乘客比较可能给高点的小费。
|
||||
|
||||
纽约市的区域信息以及坐标可以从网上找到,这部分处理比较容易。每个接收到的数据我们都可以判定它在哪个区域内,然后对joinDF增加一个列“area”来代表终点的区域。现在,让我们假设area已经加到现有的DataFrame里。接下来我们需要把得到的信息告诉司机了。
|
||||
|
||||
还记得第16讲和第17讲中提到的滑动窗口操作吗?这是流处理中常见的输出形式,即输出每隔一段时间内,特定时间窗口的特征值。在这个例子中,我们可以每隔10分钟,输出过去半小时内每个区域内的平均小费。这样的话,司机可以每隔10分钟查看一下数据,决定下一步去哪里接单。这个查询(Query)可以由以下代码产生。
|
||||
|
||||
```
|
||||
tips = joinDF
|
||||
.groupBy(
|
||||
window("endTime", "30 minutes", "10 minutes"),
|
||||
"area")
|
||||
.agg(avg("tip"))
|
||||
|
||||
```
|
||||
|
||||
最后,我们把tips这个流式DataFrame输出。
|
||||
|
||||
```
|
||||
query.writeStream
|
||||
.outputMode("append")
|
||||
.format("console")
|
||||
.option("truncate", False
|
||||
.start()
|
||||
.awaitTermination()
|
||||
|
||||
```
|
||||
|
||||
你可能会问,为什么我们不可以把输出结果按小费多少进行排序呢?
|
||||
|
||||
这是因为两个流的inner-join只支持附加输出模式(Append Mode),而现在Structured Streaming不支持在附加模式下进行排序操作。希望将来Structured Streaming可以提供这个功能,但是现在,司机们只能扫一眼所有的输出数据来大概判断哪个地方的小费最高了。
|
||||
|
||||
## 小结
|
||||
|
||||
流处理和批处理都是非常常见的应用场景,而且相较而言流处理更加复杂,对延迟性要求更高。今天我们再次通过一个实例帮助你了解要如何利用Structured Streaming对真实数据集进行流处理。Spark最大的好处之一就是它拥有统一的批流处理框架和API,希望你在课下要进一步加深对DataSet/DataFrame的熟练程度。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的主题是“案例实战”,不过我留的是思考题,而不是实践题。因为我不确定你是否会使用Kafka。如果你的工作中会接触到流数据,那么你可以参考今天这个案例的思路和步骤来解决问题,多加练习以便熟悉Spark的使用。如果你还没有接触过流数据,但却想往这方面发展的话,我就真的建议你去学习一下Kafka,这是个能帮助我们更好地做流处理应用开发和部署的利器。
|
||||
|
||||
现在,来说一下今天的思考题吧。
|
||||
|
||||
1. 为什么流的Inner-Join不支持完全输出模式?
|
||||
1. 对于Inner-Join而言,加水印是否是必须的? Outer-Join呢?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。
|
||||
|
||||
如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<audio id="audio" title="21 | 深入对比Spark与Flink:帮你系统设计两开花" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/ab/fe113271d8348a35f9157255f5abc8ab.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“深入对比Spark与Flink”。
|
||||
|
||||
相信通过这一模块前9讲的学习,你对Spark已经有了基本的认识。现在,我们先来回顾整个模块,理清一下思路。
|
||||
|
||||
首先,从MapReduce框架存在的问题入手,我们知道了Spark的主要优点,比如用内存运算来提高性能;提供很多High-level API;开发者无需用map和reduce两个操作实现复杂逻辑;支持流处理等等。
|
||||
|
||||
接下来,我们学习了Spark的数据抽象——RDD。RDD是整个Spark的核心概念,所有的新API在底层都是基于RDD实现的。但是RDD是否就是完美无缺的呢?显然不是,它还是很底层,不方便开发者使用,而且用RDD API写的应用程序需要大量的人工调优来提高性能。
|
||||
|
||||
Spark SQL提供的DataFrame/DataSet API就解决了这个问题,它提供类似SQL的查询接口,把数据看成关系型数据库的表,提升了熟悉关系型数据库的开发者的工作效率。这部分内容都是专注于数据的批处理,那么我们很自然地就过渡到下一个问题:Spark是怎样支持流处理的呢?
|
||||
|
||||
那就讲到了Spark Streaming和新的Structured Streaming,这是Spark的流处理组件,其中Structured Streaming也可以使用DataSet/DataFrame API,这就实现了Spark批流处理的统一。
|
||||
|
||||
通过这个简单的回顾我们发现,Spark的发布,和之后各个版本新功能的发布,并不是开发人员拍脑袋的决定,每个新版本发布的功能都是在解决旧功能的问题。在如此多的开源工作者的努力下,Spark生态系统才有今天的规模,成为了当前最流行的大数据处理框架之一。
|
||||
|
||||
在开篇词中我就提到过,我希望你能通过这个专栏建立自己的批判性思维,遇到一个新的技术,多问为什么,而不是盲目的接受和学习。只有这样我们才能不随波逐流,成为这个百花齐放的技术时代的弄潮儿。
|
||||
|
||||
所以,这里我想问你一个问题,Spark有什么缺点?
|
||||
|
||||
这个缺点我们之前已经提到过一个——无论是Spark Streaming还是Structured Streaming,Spark流处理的实时性还不够,所以无法用在一些对实时性要求很高的流处理场景中。
|
||||
|
||||
这是因为Spark的流处理是基于所谓微批处理(Micro-batch processing)的思想,即它把流处理看作是批处理的一种特殊形式,每次接收到一个时间间隔的数据才会去处理,所以天生很难在实时性上有所提升。
|
||||
|
||||
虽然在Spark 2.3中提出了连续处理模型(Continuous Processing Model),但是现在只支持很有限的功能,并不能在大的项目中使用。Spark还需要做出很大的努力才能改进现有的流处理模型。
|
||||
|
||||
想要在流处理的实时性上提升,就不能继续用微批处理的模式,而要想办法实现真正的流处理,即每当有一条数据输入就立刻处理,不做等待。那么当今时代有没有这样的流处理框架呢?
|
||||
|
||||
Apache Flink就是其中的翘楚。它采用了基于操作符(Operator)的连续流模型,可以做到微秒级别的延迟。今天我就带你一起了解一下这个流行的数据处理平台,并将Flink与Spark做深入对比,方便你在今后的实际项目中做出选择。
|
||||
|
||||
## Flink核心模型简介
|
||||
|
||||
Flink中最核心的数据结构是Stream,它代表一个运行在多个分区上的并行流。
|
||||
|
||||
在Stream上同样可以进行各种转换操作(Transformation)。与Spark的RDD不同的是,Stream代表一个数据流而不是静态数据的集合。所以,它包含的数据是随着时间增长而变化的。而且Stream上的转换操作都是逐条进行的,即每当有新的数据进来,整个流程都会被执行并更新结果。这样的基本处理模式决定了Flink会比Spark Streaming有更低的流处理延迟性。
|
||||
|
||||
当一个Flink程序被执行的时候,它会被映射为Streaming Dataflow,下图就是一个Streaming Dataflow的示意图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c4/b5/c49f4155d91c58050d8c7a2896bbc9b5.jpg" alt="">
|
||||
|
||||
在图中,你可以看出Streaming Dataflow包括Stream和Operator(操作符)。转换操作符把一个或多个Stream转换成多个Stream。每个Dataflow都有一个输入数据源(Source)和输出数据源(Sink)。与Spark的RDD转换图类似,Streaming Dataflow也会被组合成一个有向无环图去执行。
|
||||
|
||||
在Flink中,程序天生是并行和分布式的。一个Stream可以包含多个分区(Stream Partitions),一个操作符可以被分成多个操作符子任务,每一个子任务是在不同的线程或者不同的机器节点中独立执行的。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/58/e90778ee8f3cf092d80b73dca59a8658.jpg" alt="">
|
||||
|
||||
从上图你可以看出,Stream在操作符之间传输数据的形式有两种:一对一和重新分布。
|
||||
|
||||
- 一对一(One-to-one):Stream维护着分区以及元素的顺序,比如上图从输入数据源到map间。这意味着map操作符的子任务处理的数据和输入数据源的子任务生产的元素的数据相同。你有没有发现,它与RDD的窄依赖类似。
|
||||
- 重新分布(Redistributing):Stream中数据的分区会发生改变,比如上图中map与keyBy之间。操作符的每一个子任务把数据发送到不同的目标子任务。
|
||||
|
||||
## Flink的架构
|
||||
|
||||
当前版本Flink的架构如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/72/8a/7279dcfede45e83e1f8a9ff28cca178a.png" alt="">
|
||||
|
||||
我们可以看到,这个架构和[第12讲](https://time.geekbang.org/column/article/94410)中介绍的Spark架构比较类似,都分为四层:存储层、部署层、核心处理引擎、high-level的API和库。
|
||||
|
||||
从存储层来看,Flink同样兼容多种主流文件系统如HDFS、Amazon S3,多种数据库如HBase和多种数据流如Kafka和Flume。
|
||||
|
||||
从部署层来看,Flink不仅支持本地运行,还能在独立集群或者在被 YARN 或 Mesos 管理的集群上运行,也能部署在云端。
|
||||
|
||||
核心处理引擎就是我们刚才提到的分布式Streaming Dataflow,所有的高级API及应用库都会被翻译成包含Stream和Operator的Dataflow来执行。
|
||||
|
||||
Flink提供的两个核心API就是DataSet API和DataStream API。你没看错,名字和Spark的DataSet、DataFrame非常相似。顾名思义,DataSet代表有界的数据集,而DataStream代表流数据。所以,DataSet API是用来做批处理的,而DataStream API是做流处理的。
|
||||
|
||||
也许你会问,Flink这样基于流的模型是怎样支持批处理的?在内部,DataSet其实也用Stream表示,静态的有界数据也可以被看作是特殊的流数据,而且DataSet与DataStream可以无缝切换。所以,Flink的核心是DataStream。
|
||||
|
||||
DataSet和DataStream都支持各种基本的转换操作如map、filter、count、groupBy等,让我们来看一个用DataStream实现的统计词频例子。
|
||||
|
||||
```
|
||||
public class WindowWordCount {
|
||||
public static void main(String[] args) throws Exception {
|
||||
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
|
||||
|
||||
|
||||
DataStream<Tuple2<String, Integer>> dataStream = env
|
||||
.socketTextStream("localhost", 9999)
|
||||
.flatMap(new Splitter())
|
||||
.keyBy(0)
|
||||
.timeWindow(Time.seconds(5))
|
||||
.sum(1);
|
||||
|
||||
|
||||
dataStream.print();
|
||||
env.execute("Window WordCount");
|
||||
}
|
||||
|
||||
|
||||
public static class Splitter implements FlatMapFunction<String, Tuple2<String, Integer>> {
|
||||
@Override
|
||||
public void flatMap(String sentence, Collector<Tuple2<String, Integer>> out) {
|
||||
for (String word: sentence.split(" ")) {
|
||||
out.collect(new Tuple2<String, Integer>(word, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里我是用Java来示范的,因为Flink就是用Java开发的,所以它对Java有原生的支持。此外,也可以用Scala来开发Flink程序,在1.0版本后更是支持了Python。
|
||||
|
||||
在这个例子中,我们首先创建了一个Splitter类,来把输入的句子拆分成(词语,1)的对。在主程序中用StreamExecutionEnvironment创建DataStream,来接收本地Web Socket的文本流,并进行了4步操作。
|
||||
|
||||
1. 用flatMap把输入文本拆分成(词语,1)的对;
|
||||
1. 用keyBy把相同的词语分配到相同的分区;
|
||||
1. 设好5秒的时间窗口;
|
||||
1. 对词语的出现频率用sum求和。
|
||||
|
||||
可以看出,DataStream的使用方法和RDD比较相似,都是把程序拆分成一系列的转换操作并分布式地执行。
|
||||
|
||||
在DataSet和DataStream之上,有更高层次的Table API。Table API和Spark SQL的思想类似,是关系型的API,用户可以像操作SQL数据库表一样的操作数据,而不需要通过写Java代码、操作DataStream/DataSet的方式进行数据处理,更不需要手动优化代码的执行逻辑。
|
||||
|
||||
此外,Table API同样统一了Flink的批处理和流处理。
|
||||
|
||||
## Flink和Spark对比
|
||||
|
||||
通过前面的学习,我们了解到,Spark和Flink都支持批处理和流处理,接下来让我们对这两种流行的数据处理框架在各方面进行对比。
|
||||
|
||||
首先,这两个数据处理框架有很多相同点。
|
||||
|
||||
- 都基于内存计算;
|
||||
- 都有统一的批处理和流处理API,都支持类似SQL的编程接口;
|
||||
- 都支持很多相同的转换操作,编程都是用类似于Scala Collection API的函数式编程模式;
|
||||
- 都有完善的错误恢复机制;
|
||||
- 都支持Exactly once的语义一致性。
|
||||
|
||||
当然,它们的不同点也是相当明显,我们可以从4个不同的角度来看。
|
||||
|
||||
**从流处理的角度来讲**,Spark基于微批量处理,把流数据看成是一个个小的批处理数据块分别处理,所以延迟性只能做到秒级。而Flink基于每个事件处理,每当有新的数据输入都会立刻处理,是真正的流式计算,支持毫秒级计算。由于相同的原因,Spark只支持基于时间的窗口操作(处理时间或者事件时间),而Flink支持的窗口操作则非常灵活,不仅支持时间窗口,还支持基于数据本身的窗口,开发者可以自由定义想要的窗口操作。
|
||||
|
||||
**从SQL功能的角度来讲**,Spark和Flink分别提供SparkSQL和Table API提供SQL交互支持。两者相比较,Spark对SQL支持更好,相应的优化、扩展和性能更好,而 Flink 在 SQL 支持方面还有很大提升空间。
|
||||
|
||||
**从迭代计算的角度来讲**,Spark对机器学习的支持很好,因为可以在内存中缓存中间计算结果来加速机器学习算法的运行。但是大部分机器学习算法其实是一个有环的数据流,在Spark中,却是用无环图来表示。而Flink支持在运行时间中的有环数据流,从而可以更有效的对机器学习算法进行运算。
|
||||
|
||||
**从相应的生态系统角度来讲**,Spark的社区无疑更加活跃。Spark可以说有着Apache旗下最多的开源贡献者,而且有很多不同的库来用在不同场景。而Flink由于较新,现阶段的开源社区不如Spark活跃,各种库的功能也不如Spark全面。但是Flink还在不断发展,各种功能也在逐渐完善。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们从Spark存在的一个缺点——无法高效应对低延迟的流处理场景入手,一起学习了另一个主流流数据处理框架Flink,还对比了这两个框架的异同,相信现在你对两个框架肯定有了更多的认识。
|
||||
|
||||
我经常被问到的一个问题是:Spark和Flink到底该选哪一个?对于这个问题,我们还是要分一下场景。
|
||||
|
||||
对于以下场景,你可以选择Spark。
|
||||
|
||||
1. 数据量非常大而且逻辑复杂的批数据处理,并且对计算效率有较高要求(比如用大数据分析来构建推荐系统进行个性化推荐、广告定点投放等);
|
||||
1. 基于历史数据的交互式查询,要求响应较快;
|
||||
1. 基于实时数据流的数据处理,延迟性要求在在数百毫秒到数秒之间。
|
||||
|
||||
Spark完美满足这些场景的需求, 而且它可以一站式解决这些问题,无需用别的数据处理平台。
|
||||
|
||||
由于Flink是为了提升流处理而创建的平台,所以它适用于各种需要非常低延迟(微秒到毫秒级)的实时数据处理场景,比如实时日志报表分析。
|
||||
|
||||
而且Flink用流处理去模拟批处理的思想,比Spark用批处理去模拟流处理的思想扩展性更好,所以我相信将来Flink会发展的越来越好,生态和社区各方面追上Spark。比如,阿里巴巴就基于Flink构建了公司范围内全平台使用的数据处理平台Blink,美团、饿了么等公司也都接受Flink作为数据处理解决方案。
|
||||
|
||||
可以说,Spark和Flink都在某种程度上统一了批处理和流处理,但也都有一些不足。下一模块中,让我们来一起学习一个全新的、完全统一批流处理的数据处理平台——Apache Beam,到时候我们会对Spark的优缺点有更加深入的认识。
|
||||
|
||||
## 思考题
|
||||
|
||||
除了高延迟的流处理这一缺点外,你认为Spark还有什么不足?可以怎样改进?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user