一节课轻松通关 Spark

大数据跟我学系列文章007-三节课轻松通关 Spark (一)

文章目录


前言

本文为课程《 Spark实战44讲》笔记,本着“只有亲身实践过并整理成体系才属于自己真正掌握的知识” 的理念写出本篇文章,后续每天更新,持续关注,欢迎留言讨论~。

第01讲: MapReduce:计算框架和编程模型

比较重要的内容 MapReduce,说它基础,是因为它诞生的时间实在是太久远了,并不是什么新东西,说它重要则是因为基于它的提出衍生出很多重要的技术,比如我们关心的 Spark。

今天的内容主要有以下几点:

Google 的三驾马车;

MapReduce 编程模型与 MapReduce 计算框架;

并发与并行;

如何理解分布式计算框架的编程接口与背后的工程实现。

Google 的三驾马车

USNew 把计算机科学分为 4 个领域:人工智能、编程语言、系统以及理论。其中的系统领域有两大顶级会议,一个是 ODSI(USENIX conference on Operating Systems Design and Implementation),另一个是 SOSP(ACM Symposium on Operating Systems Principles),这两个会议在业界的分量非常重,如果把近几十年关于这两个会议的重要论文收录到一本书,就可以看作是操作系统和分布式系统的一本教科书。

从 2003 年到 2006 年,Google 分别在 ODSI 与 SOSP 发表了 3 篇论文,引起了业界对于分布式系统的广泛讨论,这三篇论文分别是:

SOSP2003:The Google File System;

ODSI2004:MapReduce: Simplifed Data Processing on Large Clusters;

ODSI2006:Bigtable: A Distributed Storage System for Structured Data。

在 2006 年,Google 首席执行官施密特提出了云计算这个词语,Google 的这 3 篇论文也被称为 Google 的三驾马车,代表 Google 大数据处理的基石、云计算的基础。不过值得注意的是,虽然 Google 作为业界领军者经常会将自己的技术开源出来,但是客观地讲,Google 开源出来的技术并不是内部使用的最新技术,中间甚至会有代差,这也侧面反映出 Google 的技术实力。

第 1 篇论文主要讨论分布式文件系统,第 2 篇论文主要讨论的分布式计算框架,第 3 篇论文则主要讨论分布式数据存储。这 3 篇论文揭开了分布式系统神秘的面纱,为大数据处理技术做出了重要的贡献。 有了这 3 篇论文的理论基础与后续的一系列文章,再加上开源社区强大的实践能力,Hadoop、HBase、Spark 等很快走上了台前,大数据技术开始呈现出一个百花齐放的状态。

MapReduce 编程模型与 MapReduce 计算框架

在发表的第 2 篇文章中,Google 很明确地表示 MapReduce 是其实现的一个分布式计算框架,其编程模型名为 MapReduce。开源社区基于这篇论文的内容,照猫画虎地实现了一个分布式计算框架,也叫作 MapReduce。但一些书籍和网上的资料在提到 MapReduce 的时候并未说明,容易造成困惑。其实 Google 拿编程模型的名字直接作为计算框架的名字这种例子还有很多,比如 Google Dataflow。而 MapReduce 有两个含义,一般来说,在说到计算框架时,我们指的是开源社区的 MapReduce 计算框架,但随着新一代计算框架如 Spark、Flink 的崛起,开源社区的 MapReduce 计算框架在生产环境中使用得越来越少,逐渐退出舞台。

MapReduce 的第二个含义是一种编程模型,这种编程模型来源于古老的函数式编程思想,在 Lisp 等比较老的语言中也有相应的实现,并随着计算机 CPU 单核性能以及核心数量的飞速提升在分布式计算中焕发出新的生机。

MapReduce 模型将数据处理方式抽象为 map 和 reduce,其中 map 也叫映射,顾名思义,它表现的是数据的一对一映射,通常完成数据转换的工作,如下图所示:

reduce 被称为归约,它表示另外一种映射方式,通常完成聚合的工作,如下图所示:

圆角框可以看成是一个集合,里面的方框可以看成某条要处理的数据,箭头表示映射的方式和要执行的自定义函数,运用 MapReduce 编程思想,我们可以实现以下内容:

将数据集(输入数据)抽象成集合;

将数据处理过程用 map 与 reduce 进行表示;

在自定义函数中实现自己的逻辑。

这样就可以实现从输入数据到结果数据的处理流程(映射)了。

并发与并行

一般来说,底层的东西越简单,那么上层的东西变化就越复杂,对于 MapReduce 编程模型来说,map 与 reduce 的组合加上用户定义函数,对于业务的表现力是非常强的。这里举一个分组聚合的例子,如下图所示:

map 端的用户自定义函数与 map 算子对原始数据人名进行了转换,生成了组标签:性别,reduce 端的自定义函数与 reduce 算子对数据按照标签进行了聚合(汇总)。

MapReduce 认为,再复杂的数据处理流程也无非是这两种映射方式的组合,例如 map + map + reduce,或者 reduce 后面接 map,等等,在我展示出的这张图里你可以看到相对复杂的一种组合形式:

很多支持函数式编程的语言,对于语言本身自带的集合数据结构,都会提供 map、reduce 算子。现在,我们可以很容易的将第一个圆角方框想象成一个数十条数据的集合,它是内存中的集合变量,那么要实现上图中的变换,对于计算机来说,难度并不大,就算数据量再大些,我们也可以考虑将不同方框和计算流程交给同一台计算机的 CPU 不同的核心进行计算,这就是我们说的并行和并发。

如何理解分布式计算框架的编程接口与背后的工程实现

现在你可以想象下,随着数据集继续增大,要处理的数据(上图中开始的集合)超过了计算内存的大小,那么就算是逻辑非常简单的流程,也要考虑中间结果的存储。比如计算过程涉及到硬盘和内存之前的数据交换等等之类的工程实现的问题,虽然在这个过程中上面 3 步并没有发生变化,但是背后实现的系统复杂度大大提高了。

我们可以再发挥想象,将上图中的圆角框想象成一个极其巨大的数据集,而方框想象成大数据集的一部分,我们会发现,对于从输入数据到结果数据的映射需求来说,前面 3 步仍然适用,只是这个集合变得非常大。

但是由于数据量的急剧扩大,相比于刚才的第 2 种情况,背后工程实现的复杂度会成倍增加,当整个数据集的容量和计算量达到 1 台计算机能处理的极限的时候,我们就会想办法把图中方框所代表的数据集分别交给不同的计算机来完成,那么如何调度计算机,如何实现 reduce 过程中不同计算机之间的数据传输等问题,就是 Spark 基于 MapReduce 编程模型的分布式实现,这也是我们常常所说的分布式计算。

从上图可以看出,在 reduce 过程中,会涉及到数据在不同计算机之间进行传输,这也是 MapReduce 模型下的分布式实现的一个关键点,后面我们会讲到 Spark 是如何做的。

看到这里,你可能对分布式运算有一个感性的认识,以小见大,函数式语言本身就提供了类似于 map、reduce 的操作,如下图第 1、2 行代码:

1、2 行是函数式编程语言 Scala 对于集合的处理,3、4 行是 Spark 对集合的处理,逻辑同样是对集合元素都加 1 再过滤掉小于等于 1 的元素并求和。对于 Spark 来说,处理几十 GB到几十 TB 的数据集,第2行代码或者说第4行代码同样适用,只是 list 变得比较特殊,它不是只存在于一台计算机的内存里,而是存在于多台计算机的磁盘和内存上。

现在,我们可以这样理解基于 MapReduce 编程模型的分布式计算框架,其编程接口与普通函数式语言的数据处理并没有什么不同(甚至可以说完全一样),但是背后的工程实现千差万别,而像 Spark、MapReduce 这样的框架,它们的目标都是尽力为用户提供尽可能简单的编程接口以及高效地工程实践。从这个角度上来讲,我们可以把 Spark 看成是一种分布式计算编程语言,它的终极目标是希望达到这样一种体验:让用户处理海量数据集时与处理内存中的集合变量并没有什么不同。

MapReduce 这种思想或者编程模型已经出现几十年了,不变的是思想,变得是使用场景和实现方法。我相信未来一定会有效率优于 Spark 的计算框架出现,就像 Spark 优于普通的编程语言一样。

总结

本课时的主要目的是在深入讲解 Spark 之前,对 Spark 之前的技术、范式、抽象进行一个简单的讲解,为后面的学习打下基础。

第02讲:Hadoop:集群的操作系统

Google 在 2004~2006 年发表了被称为 Google 三驾马车的 3 篇论文,这在开源社区可谓是一石激起千层浪。很快,基于论文的开源实现就问世了,其中第 1 篇论文的 GFS 和第 2 篇论文的 MapReduce 开源实现为 HDFS 与 MapReduce,统称为 Hadoop,第 3 篇 Bigtable 论文开源实现为 HBase,本课时不展开讨论。

Hadoop 的出现,对于坐拥数据而苦于无法分析的用户来说,无疑是久旱逢甘霖,加之那段时间移动互联网的流行,数据呈几何倍数增长,Hadoop 在很大程度上解决了数据处理的痛点。在很长的一段时间里,Hadoop 是大数据处理的事实标准,直到现在,很多公司的大数据处理架构也是围绕 Hadoop 而建的。

基于此,本课时主要讨论以下几个问题:

Hadoop 1.0

Hadoop 2.0

Hadoop 生态圈与发行版

Hadoop 大数据平台

Hadoop 的趋势

Hadoop 1.0

Hadoop 从问世至今一共经历了 3 个大版本,分别是 1.0、2.0 与最新的 3.0,其中最有代表性的是 1.0 与 2.0,3.0 相比于 2.0 变化不大。Hadoop 1.0 的架构也比较简单,基本就是按照论文中的框架实现,其架构如下图所示:

其中,下层是 GFS 的开源实现 HDFS(Hadoop 分布式文件系统),上层则是分布式计算框架 MapReduce,这样一来,分布式计算框架基于分布式文件系统,看似非常合理。但是,在使用的过程中,这个架构还是会出现不少问题,主要有 3 点:

主节点可靠性差,没有热备;

提交 MapReduce 作业过多的情况下,调度将成为整个分布式计算的瓶颈;

资源利用率低,并且不能支持其他类型的分布式计算框架。

第 1 点是小问题,涉及到对系统可用性方面的改造,但是第 2 点与第 3 点提到的问题就比较犀利了。

第 2 个问题在于,Hadoop 1.0 的分布式计算框架 MapReduce 并没有将资源管理和作业调度这两个组件分开,造成当同时有多个作业提交的时候,资源调度器会不堪重负,导致资源利用率过低;第 3 个问题则是不支持异构的计算框架,这是什么意思呢?其实当时 Spark 已经问世了,但是如果这个集群部署了 Hadoop 1.0,那么想要运行 Spark 作业就必须另外再部署一个集群,这样无疑是对资源的浪费,很不合理,不过这也没办法,因为这属于直接套用论文造成的历史遗留问题。

Hadoop 2.0

基于这些问题,社区开始着手 Hadoop 2.0 的开发,Hadoop 2.0 最大的改动就是引入了资源管理与调度系统 YARN,代替了原有的计算框架,而计算框架则变成了类似于 YARN 的用户,如下图:

YARN 将集群内的所有计算资源抽象成一个资源池,资源池的维度有两个:CPU 和内存。同样是基于 HDFS,我们可以认为 YARN 管理计算资源,HDFS 管理存储资源。上层的计算框架地位也大大降低,变成了 YARN 的一个用户,另外,YARN 采取了双层调度的设计,大大减轻了调度器的负担,我会在后续课程详细讲解,这里不展开讨论。

Hadoop 2.0 基本上改进了 Hadoop 的重大缺陷,此外 YARN 可以兼容多个计算框架,如 Spark、Storm、MapReduce 等,HDFS 也变成了很多系统底层存储,Hadoop 以一种兼收并蓄的态度网罗了一大批大数据开源技术组件,逐渐形成了一个庞大的生态圈,如下图所示(该图只展示了一部分组件)。在当时,如果你要想搭建一个大数据平台,绝对无法绕过 Hadoop。

Hadoop 生态圈与发行版

Hadoop 生态圈的各个组件包含了 Hadoop 的核心组件,如 HDFS、YARN。在计算层也有了更多的选择,如支持 SQL 的 Hive、Impala,以及 Pig、Spark、Storm 等。还有些工具类的组件,比如负责批量数据抽取的 Sqoop,负责流式数据传输的 Flume,负责分布式一致性的 Zookeeper。此外,还有一些运维类组件,例如负责部署的 Ambari、集群监控的 ganglia 等。这些组件看似繁杂,但都是一个生产环境的所必需的。所以在当时,将如此多的组件集成到一个平台,会有很多各式各样的问题。

很快有公司注意到了这个问题中的商机,其中做的最好的是 Cloudera 和 Hortonworks 这两家公司,它们核心产品就是将上述 Hadoop 生态圈中最常用到的开源组件打包为一个 Hadoop 发行版,Clouera 的叫 CDH,Hortonworks 的叫 HDP,这个发行版中的所有组件不会有兼容性等其他莫名其妙的问题,供用户免费使用,当然也为那些技术实力不强的公司准备了收费版。

在 Hadoop 最鼎盛的阶段,几乎所有公司的大数据平台都使用了这两家公司的 Hadoop 发行版,Cloudera 也得到了资本市场的认可,一度估值 50 亿美金,但随着 Hadoop 的没落,Cloudera 在上市后,股价一直缩水,最后与同样是上市公司的 Hortonworks 进行了合并,合并后的股价仅有 20 亿美金。值得一提的是,Hadoop 之父 Doug Cutting 也是是Cloudera 公司的成员。

Hadoop 大数据平台

学习大数据的时候,你可能习惯把 Hadoop 与原有的应用开发那一套进行类比,但会发现没办法完全对应上,例如Hadoop确实能够用来存储数据,那么Hadoop就是数据库了吗?而很多文章在提到Hadoop的时候,有时会用大数据平台、数据仓库、分布式数据库、分布式计算框架等字眼,看似合理,但又不完全正确,让人非常迷惑。

这里,我对 Hadoop 做一个简单的解释。举例来说,在做传统应用开发的时候,我们不会过多的关注磁盘驱动器,这是因为文件系统已经帮我们进行了抽象,我们只需要使用文件系统 API 就可以操作磁盘驱动器。

同样的,我们在开发应用时也无需关注 CPU 的使用时间,操作系统和编程语言已经帮我们做好了抽象和隔离。所以在提到大数据平台的时候,我们要知道它首先是一个分布式系统,换言之底层是由一组计算机构成的,也就是一个集群。所谓大数据平台,相当于把这个集群抽象成一台计算机,而隔离了底层的细节,让用户使用这个平台时,不会感觉到自己在使用一个分布式系统,而像是在使用一台计算机,很轻松地就可以让整个集群为他所用。为了加深印象,我们可以来对比下两条命令:

hadoop dfs -ls /
ls /

条命令是浏览 Hadoop 文件系统(HDFS)的根目录,第二条命令是浏览 Linux 本地文件系统的根目录,如果不进行说明的话,无法看出第一条命令基于分布式文件系统,此外,这么对比的话,可以看到基于集群,Hadoop 为用户提供了一套类似 Liunx 的环境。

因此,Hadoop 可以理解为是一个计算机集群的操作系统,而 Spark、MapReduce 只是这个操作系统支持的编程语言而已,HDFS 是基于所有计算机文件系统之上的文件系统抽象。同理,YARN 是基于所有计算机资源管理与调度系统之上的资源管理与调度系统抽象,Hadoop 是基于所有计算机的操作系统之上的操作系统抽象。所以如果你一定要进行比较的话,Hadoop 应该和操作系统相比较。

Hadoop 的趋势

在 Hadoop 2.0 时期,Hadoop 的存在感还是非常强的,但是就像普通计算机一样,编程语言的热度始终要大于操作系统。随着计算框架的百花齐放,一些新的资源管理与调度系统问世,例如 Kubernets 和 Mesos,Hadoop 的存在感越来越低,在大数据平台中越来越底层,有些大数据平台甚至只采用 HDFS,其余都按照需求选取其他技术组件。

此外,一些计算框架本身就自带生态,如 Spark 的 BDAS,这就逐渐造成了一种现象:Hadoop 的热度越来越低,而分布式计算框架的热度越来越高,就像 Java 的热度肯定比 Linux 高,这也符合计算机的发展规律。

在现在的环境下,采用 HDFS+YARN 的方式作为自己底层大数据平台,仍然能满足绝大多数需求,也是最方便的解决方案。在十年前,Hadoop 就让大家享受到了阿姆达尔定律的红利,它的功劳还是需要被大家所铭记。在后面很长一段时间里,Hadoop 在大数据技术领域里仍然会占有一席之地。

总结

本节课主要讲解了 Hadoop 的架构及其一些关键组件等概念性的东西,但最后的比喻很有意思,作为集群的操作系统,Hadoop 短时间不会,未来也很难退出大数据的舞台。

第03讲:如何设计与实现统一资源管理与调度系统

Hadoop 2.0 与 Hadoop 1.0 最大的变化就是引入了 YARN,而 Spark 在很多情况下,往往也是基于 YARN 运行,所以,相比于分布式文件系统 HDFS,YARN 是一个比较关键的组件,承担着计算资源管理与调度的工作,所以本课时将对其进行深入讨论,先务虚再务实,主要内容如下:

统一资源管理与调度系统的设计;

统一资源管理与调度系统的实现——YARN。

统一资源管理与调度系统的设计

YARN 的全称是 Yet Another Resource Negotiator,直译过来是:另一种资源协调者,但是它的标准名称是统一资源管理与调度系统,这个名称比较抽象,当遇到这种抽象的名词时,我喜欢把概念拆开来看,那么这个名称一共包含 3 个词:统一、资源管理、调度。

来看看第 1 个词语:统一

对于大数据计算框架来说,统一指的是资源并不会与计算框架绑定,对于所有计算框架来说,所有资源都是无差别的,也就是说这个系统可以支持多种计算框架,但这是狭义的统一,我们理解到这里就可以了。而广义上的统一,是指资源针对所有应用来说都是无差别的,包括长应用、短应用、数据库、后端服务,等等。

来看看第 2 个词语:资源管理

对于资源管理来说,最重要的是了解对于这个系统,什么才是它的资源,或者说是资源的维度,常见的有 CPU、内存、磁盘、网络带宽等,对于 YARN 来说,资源的维度有两个:CPU 和内存。这也是大数据计算框架最需要的资源。

最后一个词语:调度

说到调度,就没那么简单了。目前的宏观调度机制一共有 3 种:集中式调度器(Monolithic Scheduler)、双层调度器(Two-Level Scheduler)和状态共享调度器(Shared-State Scheduler),我们一个一个来说:

集中式调度器(Monolithic Scheduler)

集中式调度器全局只有一个中央调度器,计算框架的资源申请全部提交给中央调度器来满足,所有的调度逻辑都由中央调度器来实现。所以调度系统在高并发作业的情况下,容易出现性能瓶颈,如下图所示,红色的方块是集群资源信息,调度器拥有全部的集群资源信息(蓝色方块),集中式调度器的实现就是 Hadoop MapReduce 的 JobTracker,实际的资源利用率只有 70% 左右,甚至更低。Jobtracker 有多不受欢迎呢,从 Hadoop 2.0 中 YARN 的名字就可以看出:另一种资源协调器,你细品。这种在多个计算作业同时申请资源的时候,中央调度器实际上是没有并发的,完全是顺序执行。

双层调度器(Two-Level Scheduler)

顾名思义,双层调度器将整个调度工作划分为两层:中央调度器和框架调度器。中央调度器管理集群中所有资源的状态,它拥有集群所有的资源信息,按照一定策略(例如 FIFO、Fair、Capacity、Dominant Resource Fair)将资源粗粒度地分配给框架调度器,各个框架调度器收到资源后再根据应用申请细粒度将资源分配给容器执行具体的计算任务。在这种双层架构中,每个框架调度器看不到整个集群的资源,只能看到中央调度器给自己的资源,如图所示:

紫色和绿色的圆圈所在的方框是框架调度器,可以看到中央调度器把全部资源的两个子集分别交给了两个框架调度器,注意看,这两个子集是没有重合的,这种机制类似于并发中的悲观并发。

状态共享调度器

状态共享调度器是由 Google 的 Omega 调度系统所提出的一种新范型,与谷歌的其他论文不同,Omega 这篇论文对详细设计语焉不详,只简单说了下大体原理和与其他调度范型的比较。

状态共享式调度大大弱化了中央调度器,它只需保存一份集群使用信息,就是图中间的蓝色方块,取而代之的是各个框架调度器,每个调度器都能获取集群的全部信息,并采用乐观锁控制并发。Omega 与双层调度器的不同在于严重弱化了中央调度器,每个框架内部会不断地从主调度器更新集群信息并保存一份,而框架对资源的申请则会在该份信息上进行,一旦框架做出决策,就会将该信息同步到主调度。资源竞争过程是通过事务进行的,从而保证了操作的原子性。由于决策是在自己的私有数据上做出的,并通过原子事务提交,系统保证只有一个胜出者,这是一种类似于 MVCC 的乐观并发机制,可以增加系统的整体并发性能,但是调度公平性有所不足。对于这种调度范式你可以不用深究,这里介绍主要是为了知识的完整性。

统一资源管理与调度系统的实现:YARN

前面一直在务虚,现在让我们来务实一下。YARN 是 Hadoop 2.0 引入的统一资源管理和调度系统,也很具有代表性,目前 Spark on YARN 这种模式也在大量使用,所以接下来,我们来讨论下 YARN。

简单来看看 YARN 的架构图,YARN 的架构是典型的主从架构,主节点是 ResourceManger,也是我们前面说的主调度器,所有的资源的空闲和使用情况都由 ResourceManager 管理。ResourceManager 也负责监控任务的执行,从节点是 NodeManager,主要负责管理 Container 生命周期,监控资源使用情况等 ,Container 是 YARN 的资源表示模型,Task 是计算框架的计算任务,会运行在 Container 中,ApplicationMaster 可以暂时认为是二级调度器,比较特殊的是它同样运行在 Container 中。

我们来看看 YARN 启动一个 MapReduce 作业的流程,如图所示:

第 1 步:客户端向 ResourceManager 提交自己的应用,这里的应用就是指 MapReduce 作业。

第 2 步:ResourceManager 向 NodeManager 发出指令,为该应用启动第一个 Container,并在其中启动 ApplicationMaster。

第 3 步:ApplicationMaster 向 ResourceManager 注册。

第 4 步:ApplicationMaster 采用轮询的方式向 ResourceManager 的 YARN Scheduler 申领资源。

第 5 步:当 ApplicationMaster 申领到资源后(其实是获取到了空闲节点的信息),便会与对应 NodeManager 通信,请求启动计算任务。

第 6 步:NodeManager 会根据资源量大小、所需的运行环境,在 Container 中启动任务。

第 7 步:各个任务向 ApplicationMaster 汇报自己的状态和进度,以便让 ApplicationMaster 掌握各个任务的执行情况。

第 8 步:应用程序运行完成后,ApplicationMaster 向 ResourceManager 注销并关闭自己。

结合这 8 步,再结合前面的调度范式,相信你已经对 YARN 的这种机制有了更深刻的理解。上面这 8 步,有些时候面试官会喜欢问,记住就行了。

好了,到现在为止,我都没有说明 YARN 到底属于哪一种调度范式,现在绝大多数资料与网上的文章都将 YARN 归为双层调度,这个说法准确吗?ApplicationMaster 与前面讲的框架调度器(二级调度器)很像,回答这个问题有点复杂,涉及到对调度范式得深刻理解,我简单讲下,你可以看看前面那种双层调度的范式图:

首先可以看到最下面的一个单词是 offers,还有蓝色和绿色的箭头方向,这说明什么问题呢?主调度器拥有整个集群资源的的状态,通过 Offer(主动提供,而不是被动请求)方式通知每个二级调度器有哪些可用的资源。每个二级调度器根据自己的需求决定是否占有提供的资源,决定占有后,该分区内的资源由二级调度器全权负责。

这句话怎么理解呢?如果你将集群资源看成一个整体,那么这种方式可以认为是预先将整个资源进行动态分区。作业则向二级调度器申请资源,可以多个作业共用一个二级调度器,此外,每个二级调度器和主调器都可以配置不同的调度算法模块。那么从这个点上来说,YARN 离真正的双层调度还有些差距,但和前面讲的 JobTracker 相比,已经是很大的进步了,并显著提升了调度性能,某度程度上,也可以说是一种双层调度,或者更准确地说,两次调度。所以如果在面试中,遇到这个问题,除非你和面试官都真的完全理解了 YARN 和双层调度的距离,否则还是回答 YARN 是双层调度吧。

由于 Spark 与 MapReduce 相比,是一种 DAG 计算框架,包含一系列的计算任务,比较特殊,所以 Spark 自己实现了一个集中式调度器 Driver,用来调用作业内部的计算任务。申请到的资源可以看成是申请分区资源,在该分区内,所有资源由 Driver 全权使用,以客户端方式提交的 Spark on Yarn 这种方式可以看成是 Driver 首先在资源管理和调度系统中注册为框架调度器(二级调度器),接收到需要得资源后,再开始进行作业调度。那么这种方式可以认为是一种曲线救国的双层调度实现方式,这个我们后面会讲到。

小结

本课时先介绍了三种调度范式:集中式、双层与状态共享,其中最常用的是双层调度模型,后面介绍了目前比较常用的一种统一资源管理与调度系统 YARN,Spark on YARN 也是非常常见的一种部署模式。由于 YARN 目前支持计算框架而不支持应用服务,可以看出,离真正的统一还有距离。此外,在绝大多数场景中,YARN 几乎都不会让你感觉到它的存在,所以对于分析师来说,本课时的内容你只需要了解就可以了。

第04讲:解析 Spark 数据处理与分析场景

在讲解具体技术之前,先来谈谈数据处理的场景,以及 Spark 在这些场景与流程中发挥的作用。技术要在特定的场景下才能发挥作用,那么在数据科学与数据工程中,有哪些场景呢,下面来看看这张图:

sp04.png

这张图从 3 个维度对目前常见的场景进行了分类,需要说明的是,由于场景这个概念不是一个易于定义且标准化的概念,所以并没有一个严谨且全面的分类方法,如图所示的分类方法并不十分严谨,其中的概念也有重合和交叉,但基本包含了数据处理中的所有场景,重合或者交叉之处会在后面说明。接下来,我们逐个进行分析。

按照大数据的作业类型
在数据工程与数据科学中,很大一部分数据处理任务都可以被称为批处理(Batch Processing),所谓批处理,就是对数据进行批量处理,一次性对一定量的数据进行处理,根据数据量的大小,批处理从开始到结束的时间从数十秒到数小时都有可能,当然如果时间花费太长,还是会考虑优化、切分等,因为这样作业执行失败的成本太高了。批处理任务的输入和输出通常都是一批数据,在数据工程中常见的ETL场景中,经常会从数据库中抽取一部分数据进行去重后写入到存储系统,另外机器学习中训练模型都是典型的批处理。对于批处理来说,最大的缺点是数据处理任务延迟较长,无法与在线系统进行实时对接,但对于每条数据来说,消耗的计算成本是最低的。

而与批处理相对应的是流处理(Streaming Processing),与静止在某个系统中的批量数据不同,流处理在处理数据时数据是动态的,源源不断的,而且数据蕴含的价值会随着时间的流逝降低,所以需要对数据流进行实时处理。

流处理在数据工程领域运用比较广泛,一般都与在线系统对接,比如实时数据分析、业务系统的消息流转等。但在数据科学中,几乎没有流处理的场景,除了个别如在线训练这种比较特殊的应用。由于流处理可以认为是数据一到来就进行处理,所以对于每条数据来说,虽然延迟很低,但消耗计算成本是最高的。

这里举一个批处理与流处理结合的场景,比如模型分析师用机器学习算法对一批数据进行训练,得到一个模型,测试完毕后,数据工程师会将这个模型部署到线上环境对数据流进行实时预测。这也是数据科学与数据工程相结合的一个场景。

按照需求确定性
对于数据工程师与数据科学家来说,想要了解新的数据集最好的方式就是按照自己的习惯对数据进行一些查询处理,虽然这些查询处理的目的与方式都不同,比如数据科学家可能关注的是某一列的分布,从而发现一些有趣的东西,而数据工程师则关心的是某一列的异常值,进而修改自己的处理逻辑。但是这类查询处理都有一个共同点就是不确定,有可能根据数据集的不同而不同,也有可能根据用户的不同而不同,甚至下一个查询是基于上一个查询的结果,这类查询我们称之为数据探索。数据探索的第一个特点是不确定,而第二个特点是时间不能太长,如果太长的话,就会严重影响数据探索的效率也达不到探索的效果了。

与需求不确定的数据探索相对应的就是需求确定的数据处理任务,这类任务一般都会定时、定期运行,是公司、组织以及流程中的一部分,比如数据预处理、按照分析需求生成报表等等,通常再开发这类数据处理任务之前,会进行数据探索。

按照结果响应时间
在这个维度下,按照结果响应时间分类,可以分为两类:

可以在线响应;
不能在线响应。
第 1 类通常指的是基于数据库操作或者是基于支持某种查询语言的工具(例如SQL)进行操作,并且实时返回结果,主要有两类:OLTP和OLAP,OLTP(Online Transaction Processing)通常指的是业务系统中常见的事务处理,对应数据库的增删改查操作,OLAP(Online Analytic Processing)主要指的是在线分析处理,对应数据库的查询操作,但不仅限于数据库,主要帮助分析人员可以迅速地、一致地、可交互地查询数据,也被称作交互式查询。

OLAP 与 OLTP 代表对数据处理两种截然不同的方式,但它们有个共同点,就是在线,这里在线意味着查询返回的结果不能太长,并且一般要能够支持在线应用,所以可以统称为在线处理。通常来说事务处理与分析处理分别代表了写优化与读优化两种方向,很难完全共存。

目前业界提出了一个新的场景和解决方案 HTAP(Hybrid Transaction and Analytical Process,混合事务与分析处理)系统,例如 TiDB 和阿里云的 AnalyticDB,既可以进行事务处理也可以进行分析处理,这里不展开介绍。

对于不能在线响应的场景,也就是第2类,这里笼统的称为离线计算或者离线处理,这里注意离线处理与在线处理界限并不是绝对的,对于同一个场景,如果全方位的进行优化,例如提升大幅度提升计算能力或者对数据进行预处理等,那么可以让原有的离线处理场景变为在线处理场景。

前面说到,这种分类方法存在一些概念的交叉与重合,很容易想到的,例如在数据探索中,会非常频繁地进行 OLAP,那么这类操作,我们一般称为即席(ad-hoc 查询)查询。在数据探索中,通常也可以忍受进行 1 分钟(或者更多)时间的批处理;数据处理任务中有可能有批处理,也有可能有流处理。

在上面的图中,除了 Spark 一般不会用于在线处理部分(OLTP、OLAP与HTAP)之外,在其他所有场景下,都能够很好的满足企业与用户的需求,但值得一提的是 Spark 与 OLAP 并不是完全没有关系,这里举一个例子:

在历史订单数据库中,保存了极其巨量的数据(从过去到现在的所有订单),而用户只关心历史某个品类的月度销量数据,但是由于原始数据过于巨大,所以导致普通的查询及其缓慢,在这里,可以用 Spark 将数据从数据库抽取出来并按照时间与品类维度进行转换和汇总(批处理),处理后的数据的大小与原始数据相比可能是上万倍的差距,用户就能很容易地进行在线分析了。

小结
本课时主要是对数据处理场景进行一个简单的梳理,并介绍了一些概念,通过这种方式告诉大家 Spark 能做什么。另外,在讲到批处理与流处理的时候,提到了处理成本与延迟性的二律背反,这里大家留个印象,在流处理的章节中,会进一步提炼。

第05讲:如何选择 Spark 编程语言以及部署 Spark

从下个模块开始,我们就会进入 Spark 的学习中,在正式开始学习 Spark 之前,首先需要选择自己要使用的 Spark 编程语言,了解如何部署 Spark,另外再根据选择搭建一个简单、方便的 Spark 运行环境。

本课时的主要内容有 3 块:

Spark 编程语言种类,如何选择 Spark 编程语言;
部署 Spark;
如何安装 Spark 的学习环境。
Spark 的编程语言
Spark 在诞生之初就提供了多种编程语言接口:Scala、Java、Python 和 SQL,在后面的版本中又加入了 R 语言编程接口。对于 Spark 来说,虽然其内核是由 Scala 编写而成,但编程语言从来就不是它的重点,从 Spark 提供这么多的编程接口来说,Spark 鼓励不同背景的人去使用它完成自己的数据探索工作。尽管如此,不同编程语言在开发效率、执行效率等方面还是有些不同,我将目前 Spark 各种编程语言优缺点罗列如下:

类型 开发效率 执行效率 成熟度 支持类型
Scala 编译型 中 高 高 原生支持
Java 编译型 低 高 高 原生支持
Python 解释型 高 中 中 PySpark
R 解释型 高 中 低 SparkR
SQL 解释型 高 高 高 原生支持
现在我们对每个语言的优缺点进行详细的分析:

Scala 作为 Spark 的开发语言当然得到了原生支持,也非常成熟,它简洁的语法也能显著提高开发效率;
Java 也是 Spark 原生支持的开发语言,但是 Java 语法冗长且不支持函数式编程(1.8 以后支持),导致它的 API 设计得冗余且不合理,再加上需要编译执行,Java 开发效率无疑是最低的,但 Java 程序员基数特别大,Java API 对于这些用户来说无疑很友好;
Python 与 R 语言都是解释型脚本语言,不用编译直接运行,尤其是 Python 更以简洁著称,开发效率自不必说,此外 Python 与 R 语言本身也支持函数式编程,这两种语言在开发 Spark 作业时也是非常自然,但由于其执行原理是计算任务在每个节点安装的 Python 或 R 的环境中执行,结果通过管道输出给 Spark执行者,所以效率要比 Scala 与 Java 低;
SQL 是 Spark 原生支持的开发语言,从各个维度上来说都是最优的,所以一般情况下,用 Spark SQL 解决问题是最优选择。
如果你才刚开始学习 Spark,那么一开始最好选择一门自己最熟悉的语言,这样 Spark 的学习曲线会比较平缓。如果从零开始,建议在 Scala 与 Python 中间选择,Scala 作为 Spark 的原生开发语言,如果想要深入了解 Spark 有必要掌握。

Python 开发速度方面的优势可以赋予开发人员极强的数据工程实践能力与数据科学探索能力,加上 Python 在数据科学领域的广泛应用,可以更好地发挥 Spark 在数据处理方面的优势。

基于以上原因,本专栏绝大多数例子,都会用 Scala 和 Python 语言实现。简言之,如果你是大数据工程师,以前比较熟悉 Java,那么建议选 Scala,除此之外,尤其是分析师,选 Python。

这里要特别说明的是,Spark 是由 Scala 开发而成,对于Java、Scala 编程接口来说,在执行计算任务时,还是由集群中每个节点的 JVM(Scala 也是 JVM 语言)完成。但是如果采用 Python、R 语言编程接口,那么执行过程是由集群中每个节点的 Python 与 R 进程计算并通过管道回传给 JVM,所以性能上会有所损失。

部署 Spark
Spark 的编程语言,都属于 Spark 表现层面的东西,程序写好了,如何让 Spark 这个分布式架构运行起来,还有些工作要做,总结起来还需要 2 步:

  1. 选择统一资源管理与调度系统
    我们在 03 课时介绍了统一资源管理与调度系统,作为计算框架的一员,同样的,Spark 也需要运行在某个统一资源管理与调度系统,目前 Spark 支持的统一资源管理与调度系统有:

Spark standalone
YARN
Mesos
Kubernetes
本地操作系统
Spark standalone 这种模式类似于前面讲的 Hadoop 1.0 的 MapReduce,由于缺点不少,基本不太适合在生产环境使用;Kubernetes 则是直到最新的 Spark 2.4.5 版本才支持;如果 Spark 运行在本地操作系统上,那么这就是我们说的伪分布模式,特别适合学习以及分析师用来处理中等数据量的数据,性能也还不错,当然这里指的是对单机性能而言。那么目前虽然支持 Spark on YARN 模式是目前最普遍的,但是 Mesos 才是 Spark 最先支持的平台,这里简单讲讲 Spark 是如何运行在 Mesos 上,你可以借此复习下前面的知识:

主要分 5 步:

SparkContext 在 Mesos master 中注册为框架调度器。
Mesos slave 持续同步以向 Mesos master 发送资源信息。
一个或者多个资源供给将信息发送给 SparkContext(下发资源)。
SparkContext 接收资源供给。
在 Mesos slave 上启动计算任务。
sp1.png

一般来说,无论你基于公司的大数据平台进行开发还是分析,底层的统一资源管理与调度系统是什么对于工程师和分析师来说是无需关心的,对于代码来说没有任何不同,区别只体现在一些提交参数上。

  1. 提交 Spark 作业
    前面提到,如果大数据平台使用了统一资源管理与调度系统,那么上层的计算框架就变成了这个资源系统的用户。这样做的结果是直接简化了计算框架的部署。对于部署计算框架这个问题,你可以用客户端/服务端,也就是 C/S 这种模式来理解。

我们把大数据平台看成是一个服务端,那么相应的就会有一些客户端,也就是一些节点,比如在 Hadoop 中,我们把这些客户端称为 Hadoop 客户端,你可以通过客户端访问 HDFS 或者提交作业。

所以,这些客户端也会有一份相应的安装包,按照客户端进行配置,Spark 也不例外,我们只需在客户端节点部署一份 Spark 安装包,并且正确配置,以YARN为例,需要你将YARN的配置文件复制到Spark客户端的配置文件夹下,就可以从该节点向大数据平台提交作业。提交的作业就会在集群中被调度为计算任务。

如何安装Spark的学习环境
在学习之前,一定要准备一个便于学习和调试的环境,本课时我将带领你根据自己的需要搭建一个学习环境,也就是前面说的伪分布模式。对于选择 Scala 的用户来说,以伪分布模式运行 Spark 是一件很简单的事情,只需要在下面链接下载预编译好的 Spark 安装包,将里面的 jar 包导入到项目空间中就可以了,这个项目就可以作为你的学习环境,每次写好的代码也可以马上运行并得到结果。

http://spark.apache.org/downloads.html

还有一种方法,你可以用 Maven 项目来进行管理,这当然更好,我更推荐这种。

但对于 Python 用户来说,会稍微麻烦一点,这里将其总结为 5 步:

安装 Anaconda
用 Anaconda 安装 Jupyter notebook
用 Anaconda 安装 PySpark
运行 Jupyter notebook
运行测试代码
这个过程大概需要 15 分钟,现在我们开始吧。

  1. 安装 Anaconda
    安装之前可以先卸载以前安装的 Python,这样统一由 Anaconda 进行管理。Anaconda 是包管理器和环境管理器,对于 Python 数据分析师来说是必备软件之一,我们可以在官网根据不同的操作系统下载对应版本(都选择 Python 3.7):

image.png
安装完成后,我们就可以在控制台使用 pip 命令了。

  1. 安装 Jupyter
    Jupyter notebook 是一个交互式的 Web 笔记本应用,可以支持多种编程语言,事实上 Anaconda+Jupyter notebook 已成为数据分析的标准环境。那么 Jupyter notebook 还有一个非常适合的场景,就是教育,它的笔记本特性可以非常好地将学习过程固化。由于前面我们已经安装好了 Anaconda,所以安装 Jupyter notebook 只需要执行下面这两条命令即可:

复制代码
pip install --upgrade pip
pip install jupyter
3. 安装 PySpark
现在通过 Anaconda 安装 PySpark 已经很方便了,只需要在控制台执行如下命令:

复制代码
pip install -U -i https://pypi.tuna.tsinghua.edu.cn/simple pyspark
我在里面换了一个清华的源,国内用户会快一点。另外要注意的是,直接执行这条命令有可能会安装失败,Windows 用户需要以管理员身份运行控制台,再执行命令。如下图:

image (1).png
Mac 用户可以用 sudo 前缀执行该条命令,如下:

复制代码
sudo pip install -U -i https://pypi.tuna.tsinghua.edu.cn/simple pyspark
4. 启动 Jupyter notebook
前面几步完成后,我们就可启动 Jupyter notebook。在控制台执行以下命令:

复制代码
jupyter notebook --ip=0.0.0.0 --notebook-dir=‘E:\JupyterWorkspace’
需要注意文件夹要事先创建好,这个就是你的笔记本文件夹。启动后,浏览器会弹出,可以在控制台里面找到Jupyter notebook的链接,如下图所示:

image (2).png

将链接复制到浏览器中,就可以使用 Jupyter notebook 了,如下图所示:

image (3).png

  1. 运行测试代码
    为了测试安装的结果,我们新建一个笔记本,在单元格中写入如下代码:

复制代码
from pyspark.sql import SparkSession
from pyspark.sql.functions import col

##初始化
spark = SparkSession.builder.master(“local[*]”).appName(“Test”).getOrCreate()

0 + 1 + 2 + 3 + 4

spark.range(0, 5).select(col(“id”).cast(“double”)).agg({‘id’: ‘sum’}).show()

关闭

spark.stop()
代码的作用是 Spark 对元素为 0~4 的数组进行求和处理,运行代码,Jupyter notebook 会展示运行结果。如下图所示:

image (4).png

这里特别说明的是,代码中的参数 local[*] 指明了 Spark 基于本地操作系统运行,如果基于 YARN、Mesos 或者 Kubernetes,只需要对应修改该参数即可。

小结
本课时主要介绍了 Spark 编程语言以及选择建议,并结合第 3 课时的内容,介绍了 Spark 部署与提交作业的方式,并帮助你搭建一个方便的学习 Spark 的环境。

学完了本课时的内容,这里还有一个小问题留给你:前面说到伪分布模式的参数为 local[*],那么是否可以将 * 替换为具体数字,如果可以,会引起怎样的变化呢?

第06讲:Spark 抽象、架构与运行环境

“Spark 抽象、架构与运行环境”的学习。从这个模块开始,我们会开始学习 Spark 的具体技术,本模块的内容主要包含两部分:

Spark 背后的工程实现;
Spark 的基础编程接口。
注意,本模块的内容对于工程师来说比较重要,需要扎实掌握。

我将从 3 个方面对本课时的内容进行讲解,主要是:

Spark 架构;
Spark 抽象;
Spark 运行环境。
Spark 架构
前面讲过,在生产环境中,Spark 往往作为统一资源管理平台的用户,向统一资源管理平台提交作业,作业提交成功后,Spark 的作业会被调度成计算任务,在资源管理系统的容器中运行。在集群运行中的 Spark 架构是典型的主从架构,如下面这张图所示。这里稍微插一句,所有分布式架构无外乎两种,一种是主从架构(master/slave),另一种是点对点架构(p2p)。

我们先来看看 Spark 架构,在运行时,Driver 无疑是主节点,而 Executor 是从节点,当然,这 3 个 Executor 分别运行在资源管理系统中的 3 个容器中。

1.png

在 Spark 的架构中,Driver 主要负责作业调度工作,Executor 主要负责执行具体的作业计算任务,Driver 中的 SparkSession 组件,是 Spark 2.0 引入的一个新的组件,曾经我们熟悉的 SparkContext、SqlContext、HiveContext 都是 SparkSession 的成员变量。

因此,用户编写的 Spark 代码是从新建 SparkSession 开始的。其中 SparkContext 的作用是连接用户编写的代码与运行作业调度以及任务分发的代码。当用户提交作业启动一个 Driver 时,会通过 SparkContext 向集群发送命令,Executor 会遵照指令执行任务。一旦整个执行过程完成,Driver 就会结束整个作业。这么说稍微有点抽象,你可以通过下面这张图更细致的感受这个过程。

2.png

比起前面那张图,该图更像是调大了放大镜倍数的展示结果,能让我们将 Driver 与 Executor 之间的运行过程看得更加清楚。

首先,Driver 会根据用户编写的代码生成一个计算任务的有向无环图(Directed Acyclic Graph,DAG),这个有向无环图是 Spark 区别 Hadoop MapReduce 的重要特征;
接着,DAG 会根据 RDD(弹性分布式数据集,图中第 1 根虚线和第 2 根虚线中间的圆角方框)之间的依赖关系被 DAG Scheduler 切分成由 Task 组成的 Stage,这里的 Task 就是我们所说的计算任务,注意这个 Stage 不要翻译为阶段,这是一个专有名词,它表示的是一个计算任务的集合;
最后 TaskScheduler 会通过 ClusterManager 将 Task 调度到 Executor 上执行。
可以看到,Spark 并不会直接执行用户编写的代码,而用户代码的作用只是告诉 Spark 要做什么,也就是一种“声明”。看到这里,或许你可以大致明白 Spark 的执行流程,但可能对于一些概念还是会有些不清楚,这个问题,我们将通过下面的内容进行解答。

Spark 抽象
当用户编写好代码向集群提交时,一个作业就产生了,作业的英文是 job,在 YARN 中,则喜欢把作业叫 application,它们是一个意思。Driver 会根据用户的代码生成一个有向无环图,下面这张图就是根据用户逻辑生成的一个有向无环图。

3.png

仔细看这张图,可以大概反推出计算逻辑:A 和 C 都是两张表,在分别进行分组聚合和筛选的操作后,做了一次 join 操作。

在上图中,灰色的方框就是我们所说的分区(partition),它和计算任务是一一对应的,也就是说,有多少个分区,就有多少个计算任务,显然的,一个作业,会有多个计算任务,这也是分布式计算的意义所在,我们可以通过设置分区数量来控制每个计算任务的计算量。在 DAG 中,每个计算任务的输入就是一个分区,一些相关的计算任务所构成的任务集合可以被看成一个 Stage,这里"相关"指的是某个标准,我们后面会讲到。RDD 则是分区的集合(图中 A、B、C、D、E),用户只需要操作 RDD 就可以构建出整个 DAG,从某种意义上来说,它就是为了掩盖上面的概念而存在的。

在明白上面的概念后,我们来看看 Executor,一个 Executor 同时只能执行一个计算任务,但一个 Worker(物理节点)上可以同时运行多个 Executor。Executor 的数量决定了同时处理任务的数量,一般来说,分区数远大于 Executor 数量才是合理的。

所以同一个作业,在计算逻辑不变的情况下,分区数和 Executor 的数量很大程度上决定了作业运行的时间。

Spark 运行环境
在上个课时讲到如何部署 Spark 时,已经讲到了 Spark 的运行环境,这里主要聊聊如何基于某个运行环境初始化 SparkSession。我们先来看看 Scala 版本,我们在前面准备好的 Scala 项目中,写下如下代码:

复制代码
import org.apache.spark.sql.SparkSession

val spark = SparkSession
.builder()
.master(“yarn-client”)
.appName(“New SS”)
.config(“spark.executor.instances”, “10”)
.config(“spark.executor.memory”, “10g”)
.getOrCreate()

import spark.implicits._
执行到这里,SparkSession 就初始化完成了,后面用户就可以开始实现自己的数据处理逻辑,不过你可能已经注意到了,在代码中,我们通过配置指明了 Spark 运行环境时的 YARN,并且是以 yarn-client 的方式提交作业(YARN 还支持 yarn-cluster 的方式,区别在与前者 Driver 运行在客户端,后者 Driver 运行在 YARN 的 Container 中)。

另外值得注意的一点是,我们一共申请了 10 个 Executor,每个 10g,不难算出一共 100g。按照前面的结论,是不是改成 100 个 Executor,每个 1g,作业执行速度会大大提升呢?这个问题的答案是不确定。因为在总量不变的情况下,每个 Executor 的资源减少为原来的十分之一,那么 Executor 有可能无法胜任单个计算任务的计算量(或许能,但是完成速度大大降低),这样你就不得不提升分区数来降低每个计算任务的计算量,所以完成作业的总时间有可能保持不变,也有可能还会增加,当然,也有可能降低。

看到这里,你可能已经对作业的性能调参有点感觉了,其实和机器学习的调参类似,都是在一定约束下(这里就是资源),通过超参数的改变,来实现某个目标(作业执行时间)的最优化。当然,这里要特别说明的是,此处为了简化,只考虑了 Executor 的资源,没有考虑 Driver 所需的资源,另外资源也简化为一个维度:内存,而没有考虑另一个维度 CPU。

最后来看看 Python 版代码:

复制代码
from pyspark.sql import SparkSession

spark = SparkSession
.builder
.master(“yarn-client”)
.appName(“New SS”)
.config(“spark.executor.instances”, “10”)
.config(“spark.executor.memory”, “10g”)
.getOrCreate()
小结
本课时主要向你介绍了 Spark 的架构,以及作业执行的原理,第 2 部分向你介绍了 Spark 的几个关键抽象,也就是关键概念,最后一部分,我们做了一点点实践:初始化 SparkSession,为后面的学习打下基础。最后再留一个思考题,如何通过配置指定每个Executor所需的CPU资源。

另外,在上面的代码中,我们都是通过硬编码来指定配置,同 Java 一样,Spark 也支持直接通过命令行参数进行配置,而这也是官方推荐的。

第07讲:Spark 核心数据结构:弹性分布式数据集 RDD

Spark 核心数据结构:弹性分布式数据集 RDD”的学习,今天的课程内容有两个:RDD 的核心概念以及实践环节:如何创建 RDD。

RDD 的核心概念
RDD 是 Spark 最核心的数据结构,RDD(Resilient Distributed Dataset)全称为弹性分布式数据集,是 Spark 对数据的核心抽象,也是最关键的抽象,它实质上是一组分布式的 JVM 不可变对象集合,不可变决定了它是只读的,所以 RDD 在经过变换产生新的 RDD 时,(如下图中 A-B),原有 RDD 不会改变。

弹性主要表现在两个方面:

在面对出错情况(例如任意一台节点宕机)时,Spark 能通过 RDD 之间的依赖关系恢复任意出错的 RDD(如 B 和 D 可以算出最后的 RDD),RDD 就像一块海绵一样,无论怎么挤压,都像海绵一样完整;
在经过转换算子处理时,RDD 中的分区数以及分区所在的位置随时都有可能改变。
图片1.png

每个 RDD 都有如下几个成员:

分区的集合;
用来基于分区进行计算的函数(算子);
依赖(与其他 RDD)的集合;
对于键-值型的 RDD 的散列分区器(可选);
对于用来计算出每个分区的地址集合(可选,如 HDFS 上的块存储的地址)。
如下图所示,RDD_0 根据 HDFS 上的块地址生成,块地址集合是 RDD_0 的成员变量,RDD_1由 RDD_0 与转换(transform)函数(算子)转换而成,该算子其实是 RDD_0 内部成员。从这个角度上来说,RDD_1 依赖于 RDD_0,这种依赖关系集合也作为 RDD_1 的成员变量而保存。

图片2.png

在 Spark 源码中,RDD 是一个抽象类,根据具体的情况有不同的实现,比如 RDD_0 可以是 MapPartitionRDD,而 RDD_1 由于产生了 Shuffle(数据混洗,后面的课时会讲到),则是 ShuffledRDD。

下面我们来看一下 RDD 的源码,你也可以和前面对着看看:

复制代码
// 表示RDD之间的依赖关系的成员变量
@transient private var deps: Seq[Dependency[]]
// 分区器成员变量
@transient val partitioner: Option[Partitioner] = None
// 该RDD所引用的分区集合成员变量
@transient private var partitions
: Array[Partition] = null
// 得到该RDD与其他RDD之间的依赖关系
protected def getDependencies: Seq[Dependency[]] = deps
// 得到该RDD所引用的分区
protected def getPartitions: Array[Partition]
// 得到每个分区地址
protected def getPreferredLocations(split: Partition): Seq[String] = Nil
// distinct算子
def distinct(numPartitions: Int)(implicit ord: Ordering[T] = null): RDD[T] =
withScope {
map(x => (x, null)).reduceByKey((x, y) => x, numPartitions).map(
._1)
}
其中,你需要特别注意这一行代码:

复制代码
@transient private var partitions_ : Array[Partition] = null
它说明了一个重要的问题,RDD 是分区的集合,本质上还是一个集合,所以在理解时,你可以用分区之类的概念去理解,但是在使用时,就可以忘记这些,把其当做是一个普通的集合。为了再加深你的印象,我们来理解下模块 1 中 01 课时的 4 行代码:

复制代码
val list: List[Int] = List(1,2,3,4,5)
println(list.map(x => x + 1).filter { x => x > 1}.reduce(_ + ))

val list: List[Int] = spark.sparkContext.parallelize(List(1,2,3,4,5))
println(list.map(x => x + 1).filter { x => x > 1}.reduce(
+ _))
实践环节:创建 RDD
我一直强调,Spark 编程是一件不难的工作,而事实也确实如此。在上一课时我们讲解了创建 SparkSession 的代码,现在我们可以通过已有的 SparkSession 直接创建 RDD。在创建 RDD 之前,我们可以将 RDD 的类型分为以下几类:

并行集合;
从 HDFS 中读取;
从外部数据源读取;
PairRDD。
了解了 RDD 的类型,接下来我们逐个讲解它们的内容:

并行化集合
这种 RDD 纯粹是为了学习,将内存中的集合变量转换为 RDD,没太大实际意义。

复制代码
//val spark: SparkSession = …
val rdd = spark.sparkcontext.parallelize(Seq(1, 2, 3))
从 HDFS 中读取
这种生成 RDD 的方式是非常常用的,

复制代码
//val spark: SparkSession = …
val rdd = spark.sparkcontext.textFile(“hdfs://namenode:8020/user/me/wiki.txt”)
从外部数据源读取
Spark 从 MySQL 中读取数据返回的 RDD 类型是 JdbcRDD,顾名思义,是基于 JDBC 读取数据的,这点与 Sqoop 是相似的,但不同的是 JdbcRDD 必须手动指定数据的上下界,也就是以 MySQL 表某一列的最值作为切分分区的依据。

复制代码
//val spark: SparkSession = …
val lowerBound = 1
val upperBound = 1000
val numPartition = 10
val rdd = new JdbcRDD(spark.sparkcontext,() => {
Class.forName(“com.mysql.jdbc.Driver”).newInstance()
DriverManager.getConnection(“jdbc:mysql://localhost:3306/db”, “root”, “123456”)
},
“SELECT content FROM mysqltable WHERE ID >= ? AND ID <= ?”,
lowerBound,
upperBound,
numPartition,
r => r.getString(1)
)
既然是基于 JDBC 进行读取,那么所有支持 JDBC 的数据库都可以通过这种方式进行读取,也包括支持 JDBC 的分布式数据库,但是你需要注意的是,从代码可以看出,这种方式的原理是利用多个 Executor 同时查询互不交叉的数据范围,从而达到并行抽取的目的。但是这种方式的抽取性能受限于 MySQL 的并发读性能,单纯提高 Executor 的数量到某一阈值后,再提升对性能影响不大。

上面介绍的是通过 JDBC 读取数据库的方式,对于 HBase 这种分布式数据库来说,情况有些不同,HBase 这种分布式数据库,在数据存储时也采用了分区的思想,HBase 的分区名为 Region,那么基于 Region 进行导入这种方式的性能就会比上面那种方式快很多,是真正的并行导入。

复制代码
//val spark: SparkSession = …
val sc = spark.sparkcontext
val tablename = “your_hbasetable”
val conf = HBaseConfiguration.create()
conf.set(“hbase.zookeeper.quorum”, “zk1,zk2,zk3”)
conf.set(“hbase.zookeeper.property.clientPort”, “2181”)
conf.set(TableInputFormat.INPUT_TABLE, tablename)
val rdd= sc.newAPIHadoopRDD(conf, classOf[TableInputFormat],
classOf[org.apache.hadoop.hbase.io.ImmutableBytesWritable],
classOf[org.apache.hadoop.hbase.client.Result])
// 利用HBase API解析出行键与列值
rdd_three.foreach{case (_,result) => {
val rowkey = Bytes.toString(result.getRow)
val value1 = Bytes.toString(result.getValue(“cf”.getBytes,“c1”.getBytes))
}
值得一提的是 HBase 有一个第三方组件叫 Phoenix,可以让 HBase 支持 SQL 和 JDBC,在这个组件的配合下,第一种方式也可以用来抽取 HBase 的数据,此外,Spark 也可以读取 HBase 的底层文件 HFile,从而直接绕过 HBase 读取数据。说这么多,无非是想告诉你,读取数据的方法有很多,可以根据自己的需求进行选择。

通过第三方库的支持,Spark 几乎能够读取所有的数据源,例如 Elasticsearch,所以你如果要尝试的话,尽量选用 Maven 来管理依赖。

PairRDD
PairRDD 与其他 RDD 并无不同,只不过它的数据类型是 Tuple2[K,V],表示键值对,因此这种 RDD 也被称为 PairRDD,泛型为 RDD[(K,V)],而普通 RDD 的数据类型为 Int、String 等。这种数据结构决定了 PairRDD 可以使用某些基于键的算子,如分组、汇总等。PairRDD 可以由普通 RDD 转换得到:

复制代码
//val spark: SparkSession = …
val a = spark.sparkcontext.textFile("/user/me/wiki").map(x => (x,x))
小结
本课时带你学习完了 Spark 最核心的概念 RDD,本质上它可以看成是一个分布式的数据集合,它的目的就是隔离分布式数据集的复杂性,你也自己尝试了几种类型的 RDD。在实际情况中,大家经常会遇到从外部数据源读取成为RDD,如果理解了读取的本质,那么无论是什么数据源都能够轻松应对了。

这里我要给你留个思考题:如何指定你创建的 RDD 的分区数?

第08讲:算子:如何构建你的数据管道?

想你基本已经了解进入编程环节前需要掌握的内容,那么本课时就带你正式进入 Spark 编程的学习。

Spark 编程风格主要有函数式,核心是基于数据处理的需求,用算子与 RDD 构建出一个数据管道,管道的开始是输入,管道的末尾是输出。而管道就是声明的处理逻辑,可以说是描述了一种映射方式。

不同种类的算子
RDD 算子主要分为两类,一类为转换(transform)算子,一类为行动(action)算子,转换算子主要负责改变 RDD 中数据、切分 RDD 中数据、过滤掉某些数据等,并按照一定顺序组合。Spark 会将转换算子放入一个计算的有向无环图中,并不立刻执行,当 Driver 请求某些数据时,才会真正提交作业并触发计算,而行动算子就会触发 Driver 请求数据。这种机制与函数式编程思想的惰性求值类似。这样设计的原因首先是避免无谓的计算开销,更重要的是 Spark 可以了解所有执行的算子,从而设定并优化执行计划。

RDD 转换算子大概有 20~30 多个,按照 DAG 中分区与分区之间的映射关系来分组,有如下 3 类:

一对一,如 map;

多对一,如 union;

多对多,如 groupByKey。

而按照 RDD 的结构可以分为两种:

Value 型 RDD;

Key-Value 型 RDD(PairRDD)。

按照转换算子的用途,我将其分为以下 4 类:

通用类;

数学/统计类;

集合论与关系类;

数据结构类。

在介绍算子时,并没有刻意区分 RDD 和 Pair RDD,你可以根据 RDD 的泛型来做判断,此外,通常两个功能相似的算子,如 groupBy 与 groupByKey,底层都是先将 Value 型 RDD 转换成 Key Value 型 RDD,再直接利用 Key Value 型 RDD 完成转换功能,故不重复介绍。

在学习算子的时候,你千万不要觉得这是一个多么高深的东西,首先,对于声明式编程来说,编程本身难度不会太大。其次,我在这里给你交个底,几乎所有的算子,都可以用 map、reduce、filter 这三个算子通过组合进行实现,你在学习完本课时之后,可以试着自己做做。下面,我们就选取这 4 类中有代表性、常用的算子进行介绍。

  1. 通用类
    这一类可以满足绝大多数需要,特别适合通用分析型需求。

复制代码
def map[U: ClassTag](f: T => U): RDD[U]
def map(self, f, preservesPartitioning=False)
这里第 1 行为算子的 Scala 版,第 2 行为算子的 Python 版,后面同理,不再做特别说明。

map 算子是最常用的转换算子,它的作用是将原 RDD 分区中 T 类型的数据元素转换成 U 类型,并返回为一个新 RDD。map 算子会作用于分区内的每个元素,如下图所示。

1.png

当然 T 和 U 也可以是同一个类型,具体的转换逻辑由自定义函数 f 完成,可能你会不太适应这种函数直接作为算子的参数,下面以 map 算子为例:

复制代码

rdd = sc.parallelize([“b”, “a”, “c”])
sorted(rdd.map(lambda x: (x, 1)).collect())
[(‘a’, 1), (‘b’, 1), (‘c’, 1)]
这是 Python 版的,逻辑很简单,对单词进行处理,注意看这里不光是用函数作为参数,而且是用 Python 的匿名函数 lambda 表达式的写法,这种匿名声明的方式是比较常用的。如果是 Scala 版的,匿名函数的写法则更为简单:

复制代码
rdd.map { x => (x,1) }.collect()
Scala 还有种更简单的写法:

复制代码
rdd.map ((_,1)).collect()
这种写法我是这样看的,这么写当然更为简洁,但是如果你暂时理解不了,没必要去深究它,直接用上一种写法就好了。

复制代码
def filter(f: T => Boolean): RDD[T]
def filter(self, f)
filter算子可以通过用户自定义规则过滤掉某些数据,f 返回值为 true 则保留,false 则丢弃,如图:

2.png

该算子作用之后,可能会造成大量零碎分区,不利于后面计算过程,需要在这之前进行合并。

复制代码
def reduceByKey(func: (V, V) => V): RDD[(K, V)]
def reduceByKey(self, func, numPartitions=None, partitionFunc=portable_hash)
reduceByKey 算子执行的是归约操作,针对相同键的数据元素两两进行合并。在合并之前,reduceByKey 算子需要将相同键的元素分发到一个分区中去,分发规则可以自定义,分发的分区数量也可以自定义,所以该算子还可以接收分区器或者分区数作为参数,分区器在没有指定时,采用的是 RDD 内部的哈希分区器,如下图:

3.png

复制代码
def groupByKey(): RDD[(K, Iterable[V])]
def groupByKey(self, numPartitions=None, partitionFunc=portable_hash)
groupByKey 在统计分析中很常用到,是分组计算的前提,它默认按照哈希分区器进行分发,将同一个键的数据元素放入到一个迭代器中供后面的汇总操作做准备,它的可选参数分区数、分区器,如下图:

4.png

复制代码
def flatMap[U: ClassTag](f: T => TraversableOnce[U]): RDD[U]
def flatMap(self, f, preservesPartitioning=False)
flatMap 算子的字面意思是“展平”,flatMap 算子的函数 f 的作用是将 T 类型的数据元素转换为元素类型为 U 的集合,如果处理过程到此为止,我们将 RDD_1 的一个分区看成一个集合的话,分区数据结构相当于集合的集合,由于集合的集合是有层次的 你可以理解为一个年级有多个班级,而这种数据结构就不是“平”的,所以 flatMap 算子还做了一个操作:将集合的集合合并为一个集合。如下图:

5.png

  1. 数学/统计类
    这类算子实现的是某些常用的数学或者统计功能,如分层抽样等。

复制代码
def sampleByKey(withReplacement: Boolean, fractions: Map[K, Double], seed: Long = Utils.random.nextLong): RDD[(K, V)]
def sampleByKey(self, withReplacement, fractions, seed=None)
分层抽样是将数据元素按照不同特征分成不同的组,然后从这些组中分别抽样数据元素。Spark 内置了实现这一功能的算子 sampleByKey,withReplacement 参数表示此次抽样是重置抽样还是不重置抽样,所谓重置抽样就是“有放回的抽样”,单次抽样后会放回。fractions 是每个键的抽样比例,以 Map 的形式提供。seed 为随机数种子,一般设置为当前时间戳。

  1. 集合论与关系类
    这类算子主要实现的是像连接数据集这种功能和其他关系代数的功能,如交集、差集、并集、笛卡儿积等。

复制代码
def cogroup[W](other: RDD[(K, W)]): RDD[(K, (Iterable[V], Iterable[W]))]
def cogroup(self, other, numPartitions=None)
cogroup 算子是很多算子的基础,如 intersection、join 等。简单来说,cogroup 算子相当于多个数据集一起做 groupByKey 操作,生成的 Pair RDD 的数据元素类型为 (K, (Iterable[V], Iterable[W])),其中第 1 个迭代器为当前键在 RDD_0 中的分组结果,第 2 个迭代器为 RDD_1 的结果,如下图:

6.png

复制代码
def union(other: RDD[T]): RDD[T]
def union(self, other)
union 算子将两个同类型的 RDD 合并为一个 RDD,类似于求并集的操作。如下图:

7.png

  1. 数据结构类
    这类算子主要改变的是 RDD 中底层的数据结构,即 RDD 中的分区。在这些算子中,你可以直接操作分区而不需要访问这些分区中的元素。在 Spark 应用中,当你需要更高效地控制集群中的分区和分区的分发时,这些算子会非常有用。通常,我们会根据集群状态、数据规模和使用方式有针对性地对数据进行重分区,这样可以显著提升性能。默认情况下,RDD 使用散列分区器对集群中的数据进行分区。一般情况下,集群中的单个节点会有多个数据分区。数据分区数一般取决于数据量和集群节点数。如果作业中的某个计算任务地输入在本地,我们将其称为数据的本地性,计算任务会尽可能地根据本地性优先选择本地数据。

复制代码
def partitionBy(partitioner: Partitioner): RDD[(K, V)]:
def partitionBy(self, numPartitions, partitionFunc=portable_hash)
partitionBy 会按照传入的分发规则对 RDD 进行重分区,分发规则由自定义分区器实现。

复制代码
def coalesce(numPartitions: Int, shuffle: Boolean = false, partitionCoalescer: Option [Partition Coalescer] = Option.empty)(implicit ord: Ordering[T] = null): RDD[T]
def repartition(num Partitions: Int)(implicit ord: Ordering[T] = null): RDD[T]
coalesce 会试图将 RDD 中分区数变为用户设定的分区数(numPartitions),从而调整作业的并行程度。如果用户设定的分区数(100)小于 RDD 原有分区数(1000),则会进行本地合并,而不会进行 Shuffle;如果用户设定的分区数大于 RDD 原有分区数,则不会触发操作。如果需要增大分区数,则需要将 shuffle 参数设定为 true,这样数据就会通过散列分区器将数据进行分发,以达到增加分区的效果。

还有一种情况,当用户设置分区数为 1 时,如果 shuffle 参数为 false,会对某些节点造成极大的性能负担,用户可以设置 shuffle 参数为 true 来汇总分区的上游计算过程并行执行。repartition 是 coalesce 默认开启 shuffle 的简单封装。另外,你应该能够注意到,大部分转换算子,都提供了 numPartitions 这个可选参数,意味着在作业流程的每一步,你都可以细粒度地控制作业的并行度,从而提高执行时的性能,但这里你需要注意,提交作业后 Executor 的数量是一定的。

行动算子
行动算子从功能上来说作为一个触发器,会触发提交整个作业并开始执行。从代码上来说,它与转换算子的最大不同之处在于:转换算子返回的还是 RDD,行动算子返回的是非 RDD 类型的值,如整数,或者根本没有返回值。

行动算子可以分为 Driver 和分布式两类。

Driver:这种算子返回值通常为 Driver 内部的内存变量,如 collect、count、countByKey 等。这种算子会在远端 Executor 执行计算完成后将结果数据传回 Driver。这种算子的缺点是,如果返回的数据太大,很容易会突破 Driver 内存限制,因此使用这种算子作为作业结束需要谨慎,主要还是用于调试与开发场景。

分布式:与前一类算子将结果回传到 Driver 不同,这类算子会在集群中的节点上“就地”分布式执行,如 saveAsTextFile。这是一种最常用的分布式行动算子。

我们先来看看第一种:

复制代码
def reduce(f: (T, T) => T): T
def reduce(self, f)
与转换算子 reduce 类似,会用函数参数两两进行归约,直到最后一个值,返回值类型与 RDD 元素相同。

复制代码
def foreach(f: T => Unit): Unit
def foreach(self, f)
foreach 算子迭代 RDD 中的每个元素,并且可以自定义输出操作,通过用户传入的函数,可以实现打印、插入到外部存储、修改累加器等迭代所带来的副作用。

还有一些算子类型如 count、reduce、max 等,从字面意思也很好理解,就不逐个介绍了。

以下算子为分布式类型的行动算子:

复制代码
def saveAsTextFile(path: String): Unit
def saveAsTextFile(self, path, compressionCodecClass=None)
该算子会将 RDD 输出到外部文件系统中,例如 HDFS。这个在实际应用中也比较常用。

下面来看看几个比较特殊的行动算子,在计算过程中,用户可能会经常使用到同一份数据,此时就可以用到 Spark 缓存技术,也就是利用缓存算子将 RDD 进行缓存,从而加速 Spark 作业的执行速度。Spark 缓存算子也属于行动算子,也就是说会触发整个作业开始计算,想要缓存数据,你可以使用 cache 或者 persist 算子,它们是行动算子中仅有的两个返回值为 RDD 的算子。事实上,Spark 缓存技术是加速 Spark 作业执行的关键技术之一,尤其是在迭代计算的场景,效果非常好。

缓存需要尽可能地将数据放入内存。如果没有足够的内存,那么驻留在内存的当前数据就有可能被移除,例如 LRU 策略;如果数据量本身已经超过可用内存容量,这时由于磁盘会代替内存存储数据,性能会下降。

复制代码
def persist(newLevel: StorageLevel): this.type
def cache(): this.type
def unpersist(blocking: Boolean = true): this.type
其中,cache() = persist(MEMORY_ONLY),Spark 在作业执行过程中会采用 LRU 策略来更新缓存,如果用户想要手动移除缓存的话,也可以采用 unpersist 算子手动释放缓存。其中 persist 可以选择存储级别,选项如下:

8.png

如果内存足够大,使用 MEMORY_ONLY 无疑是性能最好的选择,想要节省点空间的话,可以采取 MEMORY_ONLY_SER,可以序列化对象使其所占空间减少一点。DISK是在重算的代价特别昂贵时的不得已的选择。MEMORY_ONLY_2 与 MEMORY_AND_DISK_2 拥有最佳的可用性,但是会消耗额外的存储空间。

小结
本课时介绍了常用的转换算子与行动算子,其中最核心的当然属于通用类的转换算子,其余算子也是非常常用的。另外没有覆盖到的,你可以在使用中通过查阅文档进行学习,这也是学习 Spark 的必经之路。

另外,前面讲到 RDD 是一种分布式集合,那么对于复杂的计算,如果用集合这种形式的数据结构完成计算,未免太复杂、太底层了,也不利于代码的可读性,所以在第 3 个模块中,会学习 Spark 的高级编程接口 DataFrame 和 Spark SQL,如果你是工程师的话,RDD 与算子这种数据处理方式无疑是需要掌握的。

练习题
最后,鉴于本课时的内容需要实践加以巩固,所以特地给你留了 3 道习题:

练习题 1:join 算子其实是 cogroup 和 flatMap 算子组合实现的,现在你自己能实现 join 算子吗?当然你也可以阅读 Spark 源码找到 join 算子的实现方法,也算你对!

练习题 2:用 Spark 算子实现对 1TB 的数据进行排序?这个问题,在第 11 课时,会揭晓答案,但现在还是需要你进行编码。

练习题 3:

复制代码
SELECT a, COUNT(b) FROM t WHERE c > 100 GROUP BY b
这是一句很简单的 SQL,你能用算子将它实现吗?

这 3 道题,你一定要仔细思考,并动手编码,最后别忘了一定要用行动算子触发计算。最后再次强调,由于本课时由专栏的方式呈现,需要你在理解专栏后自己再消化一遍,而要吃透本课时的内容,唯一的办法就是动手编码,所以上面 3 道题请你务必完成。

最后再来回顾一下今天的内容:我们介绍了 Spark 常用的转换算子和行动算子,简言之,转换算子用来声明逻辑,而行动算子用来触发计算。如果你对那种匿名函数作为参数的算子还有些陌生,没关系,下个课时我会对这种编程风格进行解构,让你彻底地理解这种编程风格。

第09讲:函数式编程思想:你用什么声明,你在声明什么

“函数式编程:你用什么声明?你在声明什么?”,在上个课时我们学习了 Spark 编程,Spark 编程风格是典型的函数式,很多开发人员在接触到这种编程风格时会有些陌生,本课时的主要目的就对这种编程风格进行一种解构,破除这种风格的神秘感,从理论上降低这种编程风格的难度。当然了,由于函数式编程包含了丰富的理论与实践,所以我们无法面面俱到的进行讲解,而是以标题的两个问题作为切入点来达到本课时的目的,本课时的主要内容有:

函数式编程与声明式编程。
你用什么声明?
你在声明什么?
函数式编程语言的特点。
函数式编程与命令式编程
在 Spark 诞生之初,就有人诟病为什么 AMP 实验室选了一个如此小众的语言:Scala,很多人还将原因归结为这是由于学院派的高冷,但事实证明,选择 Scala 是非常正确的,Scala 很多特性与 Spark 本身理念非常契合,可以说天生一对。Scala 背后所代表的函数式编程思想也变得越来越为人所知。函数式编程思想早在 50 多年前就被提出,但当时的硬件性能太弱,并不能发挥出这种思想的优势。目前多核 CPU 大行其道,函数式编程在并发方面的优势也逐渐显示出了威力。这就好像在 Java 在被发明之初,总是有人说消耗内存太多、运行速度太慢,但是随着硬件性能的翻倍,Java 无疑是一种非常好的选择。

函数式编程属于声明式编程的一种,与声明式编程相对的是命令式编程,命令式编程是按照“程序是一系列改变状态的命令”来建模的一种建模风格,而函数式编程思想是“程序是表达式和变换,以数学方程的形式建立模型,并且尽可能避免可变状态”。函数式编程会有一些类别的操作(算子),如映射、过滤或者归约,每一种都有不同的函数作为代表,如 filter、map、reduce。这些函数实现的是低阶变换(这里就是前面讲的算子),而用户定义的函数将作为这些函数的参数(这里可以理解为高阶变换)来实现整个方程。

命令式编程将计算机程序看成动作的序列,程序运行的过程就是求解的过程,这就好比,阅读一段命令式编程风格的代码,如果不阅读到最后一行,一般来说无法确定程序的目的,这和题目求解过程有异曲同工之妙,在命令式编程中,解题过程由状态的转换来完成,而状态就是我们经常说的变量。而函数式编程则是从结果入手,用户通过函数定义了从最初输入到最终输出的映射关系,从这个角度上来说,用户编写代码描述了用户的最终结果(我想要什么),而并不关心(或者说不需要关心)求解过程,所以函数式编程绝对不会去操作某个具体的值,也就无所谓变量了。

举一个声明式编程的例子,这是用户编写的代码:

复制代码
SELECT class_no, COUNT(*) FROM student_info GROUP BY class_no
由于 SQL 是很典型的声明式编程,用户只需要告诉 SQL 引擎统计每个班的人数,至于底层是怎么执行的,用户不需要关心。在《数据库系统概论》(第五版)提到:数据库会把用户提交的 SQL 查询转化为等价的扩展关系代数表达式,用户用函数式编程的思想进行编码,其实就是直接描述这个关系代数表达式。

说了这么多函数式与声明式,让我们来看个例子,有一个数据清洗的任务,需要将姓氏集合中的单字符姓名(脏数据)去掉,并将首字母大写,最后再拼成一个逗号分隔的字符串,先来看看命令式的实现(Python 版):

复制代码
family_names = [“ann”,“bob”,“c”,“david”]
clean_family_names = []
for i in range(len(family_names)):
family_name = family_names[i]
if (len(family_name) > 1):
clean_family_names.append(family_name.capitalize())
print clean_family_names
再来看看函数式(Scala 版)的实现:

复制代码
val familyNames = List(“ann”,“bob”,“c”,“david”)
println(
familyNames.filter(p => p.length() > 1).
map(f => f.capitalize).
reduce((a,b) => a + “,” + b).toString()
)
从这个例子中我们可以看出,在命令式编程的版本中,只执行了一次循环,在函数式编程的版本里,循环执行了 3 次(filter、map、reduce),每一次只完成一种逻辑(用户编写的匿名函数),从性能上来说,当然前者更为优秀,这说明了在硬件性能羸弱时,函数式的缺点会被放大,但我们也看到了,在函数式编程的版本中不用维护外部状态 i,这种方式对于并行计算场景非常友好。

你用什么声明
在开始学习编程时,我相信当你看到这个代码时,内心某种程度上是抗拒的:

复制代码
x = x + 1
原因很简单,这个等式在数学上是不成立的。随着我们对某种编程语言理解程度加深,这种感觉很快就消失了。当提到函数式编程,我们还是习惯于将函数式编程中的“函数式”与某种编程语言的函数进行对应理解,但很遗憾,这么做对于理解函数式没什么帮助。而我们最初的感觉才是对的,也就是说,这里的函数式是指我们早已选择刻意遗忘的数学含义上的函数 f(x)。

其实,第 1 个问题的答案已经呼之欲出了。你用什么声明?很显然,函数式就是用函数进行声明的一种编程风格。这里的函数指的是数学含义上的函数。

我们回过头来,看看 08 课时中的算子组成的管道。前面讲过,可以这样来理解:算子实现的是低阶变换,而用户定义的函数将作为这些算子的参数,也就是高阶函数(比如map 算子的函数参数)。这么理解当然没错,但是还是不够直接,我们可以试着从我们初中数学就学过的复合函数的角度来理解。

在严格的函数式编程中,所有函数都遵循数学函数的定义,必须有自变量(入参),必须有因变量(返回值)。用户定义的逻辑以高阶函数(high order)的形式体现,即用户可以将自定义函数以参数形式传入其他低阶函数中。其实从数学的角度上来说,这是很自然的,如下是一个数学表达式:

复制代码
y = sqrt (x + b)
括号中的函数 f1:x + b 作为参数传给函数 f2 = sqrt(x) ,这是初中的复合函数的用法。相对于高阶函数,函数式语言一般会提供一些低阶函数用于构建整个流程,这些低阶函数都是无副作用的,非常适合并行计算。高阶函数可以让用户专注于业务逻辑,而不需要去费心构建整个数据流。

到了这一步,我相信你能够重新看待你编写的 Spark 代码,你其实就是在用f(x)来声明你计算逻辑,只是这个f(x)可以看成是一个比较复杂的复合函数而已。你编写的代码就是在描述这个f(x),所以说,严格意义上,一个作业的所有代码,都可以用一行代码写完,也就是一个等式来表示。

你在声明什么
这个问题很有意思,我先不正面回答,先来和你聊聊一个小学奥数的话题:定义新运算,定义新运算是小学奥赛的一个考点,也就是说,同学们,只要你小学上过奥数,那么大概率应该是学习过这个内容的,我们来看看百度百科对定义新运算的定义:

定义新运算是指用一个符号和已知运算表达式表示一种新的运算。定义新运算是一种特别设计的计算形式,它使用一些特殊的运算符号,这是与四则运算中的加减乘除符号是不一样的。新定义的算式中有括号的,要先算括号里的,但它在没有转化前,是不适合于各种运算的。

小学六年级奥数中体现的问题,解题方法较简单。解答定义新运算,关键是要正确地理解新定义运算的算式含义。然后严格按照新定义运算的计算程序,将数值代入,转化为常规的四则运算算式进行计算。

定义新运算是一种特殊设计的运算形式,它使用的是一些特殊的运算符号,如:*、Δ 等,这是与四则运算中的加减乘除不同的。 这里有一个问题:当 a≥b=b 时 ab=bxb 当 a<b=a 时 ab=a 当 x=2 时,求 (1x)-(3x) 的值。

解题方法如下:
3△2=3+2+6=11
5△5=5+5+25=35
设 ab=﹙a+b﹚÷3
则 6﹙5*4﹚=3

相信你能够完全理解上面所说的内容。这里注意,前面一直说的算子,它的英文是 operator,大多数资料翻译为算子,看起来信达雅,但其实掩盖了 operator 的本质,operator 如果直译应该被翻译为运算符。你编写的 Spark 代码与新运算的定义不谋而合,也是用一些已知的运算符(Spark 算子)在定义一种新运算。

现在,结合用运算符和定义新运算的概念,我想你应该能够回答这个问题:你在声明什么?答案很简单:用运算符定义一种新运算。

函数式编程语言的一些特点
作为函数式编程思想的实现,函数式编程语言都有一些有趣的共性,另外,如果你把 Spark 看成一门函数式编程语言的话,你会发现,这些共性仍然存在。

低阶函数与核心数据结构
如果使用低阶函数与高阶函数来完成我们的程序,这时其实就是将程序控制权让位于语言,而我们专注于业务逻辑。这样做的好处还在于,有利于程序优化,享受免费的性能提升午餐。比如语言开发者专注于优化低阶函数,而应用开发者则专注于优化高阶函数。低阶函数是复用的,因此当低阶函数性能提升时,程序不需要改一行代码就能免费获得性能提升。此外,函数式编程语言通常只提供几种核心数据结构,供开发者选择,它希望开发者能基于这些简单的数据结构组合出复杂的数据结构,这与低阶函数的思想是一致的,很多函数式编程语言的特性会着重优化低阶函数与核心数据结构。但这与面向对象的命令式编程是不一样的,在 OOP 中,面向对象编程的语言鼓励开发者针对具体问题建立专门的数据结构。

通过前几个课时的内容可以看到,Spark 的核心数据结构只有一个,就是 RDD,而其他函数式编程语言,如 Scala,核心数据结构也非常少。

惰性求值
惰性求值(lazy evaluation)是函数式编程语言常见的一种特性,通常指尽量延后求解表达式的值,这样可以对开销大的计算按需计算,利用惰性求值的特性可以构建无限大的集合。惰性求值可以用闭包来实现。Spark 也是采用了惰性求值来触发计算。

函数记忆
由于在函数式编程中,函数本身是无状态的,因此给定入参,一定能得到一定的结果。基于此,函数式语言会对函数进行记忆或者缓存,以斐波那契数列举例,首先用尾递归来实现对斐波那契数列求和,Python 代码如下:

复制代码
def Fibonacci ( n ):
if n == 0 :
res = 0
elif num == 1:
res = 1
else:
res = Fibonacci ( n - 1 ) + Fibonacci ( n - 2 )
return res
当 n 等于 4 时,程序执行过程是:

复制代码
Fibonacci (4)
Fibonacci (3)
Fibonacci (2)
Fibonacci (1)
Fibonacci (0)
Fibonacci (1)
Fibonacci (2)
Fibonacci (1)
Fibonacci (0)
为了求 Fibonacci (4),我们执行了 1 次 Fibonacci(3),2 次 Fibonacci(2),3 次 Fibonacci(1),2 次 Fibonacci(0),一共 8 次计算,在函数式编程语言中,执行过程是这样的:

复制代码
Fibonacci (4)
Fibonacci (3)
Fibonacci (2)
Fibonacci (1)
Fibonacci (0)
一共只用 4 次计算就可求得 Fibonacci(4),后面执行的 Fibonacci(0)、Fibonacci(1) 由于函数式编程语言已经缓存了结果,因此不会重复计算。

在这里,你可以与 cache 算子代表的缓存机制联系起来,Spark 允许用户主动缓存。

小结
本课时的内容偏理论,但是我相信能够引起你一些深入思考,有些时候,务实之后的务虚会收获更多。这里给你留一个思考题:用函数式风格的代码实现 a - b 的逻辑,逻辑非常简单,难点在于用函数式风格来表达。考虑这道题的特殊性,可能大多数不习惯这么表达,所以我把答案附在下面。

复制代码
List(5).zip(List(4)).map(f => {f._1 - f.2}).foreach(println())
你可以思考下这么写的优点与缺点,欢迎与我在学习群中互动。

第10讲:共享变量:如何在数据管道中使用中间结果

如何用共享变量在数据管道中使用中间结果。共享变量是 Spark 中进阶特性之一,一共有两种:

  • 广播变量;
  • 累加器。
    这两种变量可以认为是在用算子定义的数据管道外的两个全局变量,供所有计算任务使用。在 Spark 作业中,用户编写的高阶函数会在集群中的 Executor 里执行,这些 Executor 可能会用到相同的变量,这些变量被复制到每个 Executor 中,而 Executor 对变量的更新不会传回 Driver。

在计算任务中支持通用的可读写变量一般是低效的,即便如此,Spark 还是提供了两类共享变量:广播变量(broadcast variable)与累加器(accumulator)。当然,对于分布式变量,如果不加限制会出现一致性的问题,所以共享变量是两种非常特殊的变量。

  • 广播变量:只读;
  • 累加器:只能增加。

广播变量

广播变量类似于 MapReduce 中的 DistributeFile,通常来说是一份不大的数据集,一旦广播变量在 Driver 中被创建,整个数据集就会在集群中进行广播,能让所有正在运行的计算任务以只读方式访问。广播变量支持一些简单的数据类型,如整型、集合类型等,也支持很多复杂数据类型,如一些自定义的数据类型。

广播变量为了保证数据被广播到所有节点,使用了很多办法。这其实是一个很重要的问题,我们不能期望 100 个或者 1000 个 Executor 去连接 Driver,并拉取数据,这会让 Driver 不堪重负。Executor 采用的是通过 HTTP 连接去拉取数据,类似于 BitTorrent 点对点传输。这样的方式更具扩展性,避免了所有 Executor 都去向 Driver 请求数据而造成 Driver 故障。

Spark 广播机制运作方式是这样的:Driver 将已序列化的数据切分成小块,然后将其存储在自己的块管理器 BlockManager 中,当 Executor 开始运行时,每个 Executor 首先从自己的内部块管理器中试图获取广播变量,如果以前广播过,那么直接使用;如果没有,Executor 就会从 Driver 或者其他可用的 Executor 去拉取数据块。一旦拿到数据块,就会放到自己的块管理器中。供自己和其他需要拉取的 Executor 使用。这就很好地防止了 Driver 单点的性能瓶颈,如下图所示。
在这里插入图片描述
下面来看看如何在 Spark 作业中创建、使用广播变量。代码如下:

scala> val rdd_one = sc.parallelize(Seq(1,2,3))

rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[101] at

parallelize at <console>:25

    scala> val i = 5

    i: Int = 5

scala> val bi = sc.broadcast(i)

bi: org.apache.spark.broadcast.Broadcast[Int] = Broadcast(147)

scala> bi.value

res166: Int = 5

scala> rdd_one.take(5)

res164: Array[Int] = Array(1, 2, 3)

scala> rdd_one.map(j => j + bi.value).take(5)

res165: Array[Int] = Array(6, 7, 8)

在用户定义的高阶函数中,可以直接使用广播变量的引用。下面看一个集合类型的广播变量:

scala> val rdd_one = sc.parallelize(Seq(1,2,3))

    rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[109] at

parallelize at <console>:25

scala> val m = scala.collection.mutable.HashMap(1 -> 2, 2 -> 3, 3 -> 4)

    m: scala.collection.mutable.HashMap[Int,Int] = Map(2 -> 3, 1 -> 2, 3 -> 4)

scala> val bm = sc.broadcast(m)

bm:

org.apache.spark.broadcast.Broadcast[scala.collection.mutable.HashMap[Int,I

nt]] = Broadcast(178)

scala> rdd_one.map(j => j * bm.value(j)).take(5)

res191: Array[Int] = Array(2, 6, 12)

该例中,元素乘以元素对应值得到最后结果。广播变量会持续占用内存,当我们不需要的时候,可以用 unpersist 算子将其移除,这时,如果计算任务又用到广播变量,那么就会重新拉取数据,如下:

    ...

scala> val rdd_one = sc.parallelize(Seq(1,2,3))

rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[101] at

parallelize at <console>:25

scala> val k = 5

k: Int = 5

scala> val bk = sc.broadcast(k)

bk: org.apache.spark.broadcast.Broadcast[Int] = Broadcast(163)

scala> rdd_one.map(j => j + bk.value).take(5)

res184: Array[Int] = Array(6, 7, 8)

scala> bk.unpersist

scala> rdd_one.map(j => j + bk.value).take(5)

res186: Array[Int] = Array(6, 7, 8)

你还可以使用 destroy 方法彻底销毁广播变量,调用该方法后,如果计算任务中又用到广播变量,则会抛出异常:

scala> val rdd_one = sc.parallelize(Seq(1,2,3))

rdd_one: org.apache.spark.rdd.RDD[Int] = ParallelCollectionRDD[101] at

parallelize at <console>:25

scala> val k = 5

k: Int = 5

scala> val bk = sc.broadcast(k)

bk: org.apache.spark.broadcast.Broadcast[Int] = Broadcast(163)

scala> rdd_one.map(j => j + bk.value).take(5)

res184: Array[Int] = Array(6, 7, 8)

scala> bk.destroy

scala> rdd_one.map(j => j + bk.value).take(5)

17/05/27 14:07:28 ERROR Utils: Exception encountered

org.apache.spark.SparkException: Attempted to use Broadcast(163) after it

was destroyed (destroy at <console>:30)

at org.apache.spark.broadcast.Broadcast.assertValid(Broadcast.scala:144)

at

org.apache.spark.broadcast.TorrentBroadcast$$anonfun$writeObject$1.apply$mc

V$sp(TorrentBroadcast.scala:202)

at org.apache.spark.broadcast.TorrentBroadcast$$anonfun$wri

广播变量在一定数据量范围内可以有效地使作业避免 Shuffle,使计算尽可能本地运行,Spark 的 Map 端连接操作就是用广播变量实现的。
为了让你更好地理解上面那句话的意思,我再举一个比较典型的场景,我们希望对海量的日志进行校验,日志可以简单认为是如下的格式:
表 A:校验码,内容

也就是说,我们需要根据校验码的不同,对内容采取不同规则的校验,而检验码与校验规则的映射则存储在另外一个数据库:
表 B:校验码,规则

这样,情况就比较清楚了,如果不考虑广播变量,我们有这么两种做法:

  • 1.直接使用 map 算子,在 map 算子中的自定义函数中去查询数据库,那么有多少行,就要查询多少次数据库,这样性能非常差。
  • 2.先将表 B 查出来转化为 RDD,使用 join 算子进行连接操作后,再使用 map 算子进行处理,这样做性能会比前一种方式好很多,但是会引起大量的 Shuffle 操作,对资源消耗不小。
###表A

tableA = spark.sparkcontext.textFrom('/path')

###广播表B

validateTable = spark.sparkcontext.broadcast(queryTable())

###验证函数,在验证函数中会取得对应的校验规则进行校验

def validate(validateNo,validateTable ):

......

##统计校验结果

validateResult = tableA.map(validate).reduceByKey((lambda x , y: x + y))

....

这样,相当于先将小表进行广播,广播到每个 Executor 的内存中,供 map 函数使用,这就避免了 Shuffle,虽然语义上还是 join(小表放内存),但无论是资源消耗还是执行时间,都要远优于前面两种方式。

累加器

与广播变量只读不同,累加器是一种只能进行增加操作的共享变量。如果你想知道记录中有多少错误数据,一种方法是针对这种错误数据编写额外逻辑,另一种方式是使用累加器。用法如下:

    ...

scala> val acc1 = sc.longAccumulator("acc1")

acc1: org.apache.spark.util.LongAccumulator = LongAccumulator(id: 10355,

name: Some(acc1), value: 0)

scala> val someRDD = tableRDD.map(x => {
    
    acc1.add(1); x})

someRDD: org.apache.spark.rdd.RDD[String] = MapPartitionsRDD[99] at map at

<console>:29

scala> acc1.value

res156: Long = 0 /*there has been no action on the RDD so accumulator did

not get incremented*/

scala> someRDD.count

res157: Long = 351

scala> acc1.value

res158: Long = 351

scala> acc1

res145: org.apache.spark.util.LongAccumulator = LongAccumulator(id: 10355,

name: Some(acc1), value: 351)

上面这个例子用 SparkContext 初始化了一个长整型的累加器。LongAccumulator 方法会将累加器变量置为 0。行动算子 count 触发计算后,累加器在 map 函数中被调用,其值会一直增加,最后定格为 351。Spark 内置的累加器有如下几种。

  • LongAccumulator:长整型累加器,用于求和、计数、求均值的 64 位整数。
  • DoubleAccumulator:双精度型累加器,用于求和、计数、求均值的双精度浮点数。
  • CollectionAccumulator[T]:集合型累加器,可以用来收集所需信息的集合。

所有这些累加器都是继承自 AccumulatorV2,如果这些累加器还是不能满足用户的需求,Spark 允许自定义累加器。如果需要某两列进行汇总,无疑自定义累加器比直接编写逻辑要方便很多,例如:
在这里插入图片描述
这个表只有两列,需要统计 A 列与 B 列的汇总值。下面来看看根据上面的逻辑如何实现一个自定义累加器。代码如下:

	import org.apache.spark.util.AccumulatorV2

	import org.apache.spark.SparkConf

	import org.apache.spark.SparkContext

	import org.apache.spark.SparkConf

	 

	// 构造一个保存累加结果的类

	case class SumAandB(A: Long, B: Long)

	 

	class FieldAccumulator extends AccumulatorV2[SumAandB,SumAandB] {
    
    

	

	private var A:Long = 0L

	private var B:Long = 0L

	    // 如果A和B同时为0,则累加器值为0

	    override def isZero: Boolean = A == 0 && B == 0L

	    // 复制一个累加器

	    override def copy(): FieldAccumulator = {
    
    

	        val newAcc = new FieldAccumulator

	        newAcc.A = this.A

	        newAcc.B = this.B

	        newAcc

	    }

	    // 重置累加器为0

	    override def reset(): Unit = {
    
     A = 0 ; B = 0L }

	    // 用累加器记录汇总结果

	    override def add(v: SumAandB): Unit = {
    
    

	        A += v.A

	        B += v.B

	    }

	    // 合并两个累加器

	    override def merge(other: AccumulatorV2[SumAandB, SumAandB]): Unit = {
    
    

	        other match {
    
    

	        case o: FieldAccumulator => {
    
    

	            A += o.A

	            B += o.B}

	        case _ =>

	        }

	    }

	    // 当Spark调用时返回结果

	    override def value: SumAandB = SumAandB(A,B)

	}

凡是有关键字 override 的方法,均是重载实现自己逻辑的方法。累加器调用方式如下:

package com.spark.examples.rdd

 

import org.apache.spark.SparkConf

import org.apache.spark.SparkContext

 

class Driver extends App{
    
    



  val conf = new SparkConf

  val sc = new SparkContext(conf)

  val filedAcc = new FieldAccumulator

  sc.register(filedAcc, " filedAcc ")

  // 过滤掉表头

  val tableRDD = sc.textFile("table.csv").filter(_.split(",")(0) != "A")

  tableRDD.map(x => {
    
    

     val fields = x.split(",")

     val a = fields(1).toInt

     val b = fields(2).toLong

     filedAcc.add(SumAandB (a, b))

     x

  }).count

}

最后计数器的结果为(3100, 31)。

小结

本课时主要介绍了 Spark 的两种共享变量,注意体会广播变量最后介绍的 map 端 join 的场景,这在实际使用中非常普遍。另外广播变量的大小,按照我的经验,要根据 Executor 和 Worker 资源来确定,几十兆、一个 G 的广播变量在大多数情况不会有什么问题,如果资源充足,那么1G~10G 以内问题也不大。

最后我要给你留一个思考题,请你对数据集进行空行统计。你可以先用普通算子完成后,再用累加器的方式完成,并比较两者的执行效率 ,如果有条件的,可以在生产环境中用真实数据集比较下两者之间的差异,差异会更明显。

第11讲:计算框架的分布式实现:剖析 Spark Shuffle 原理

今天我将为你讲解计算框架的分布式实现:剖析 Spark Shuffle 原理。我们在前面几个课时,或多或少地提到了 Shuffle, Shuffle 一般被翻译为数据混洗,是类 MapReduce 分布式计算框架独有的机制,也是这类分布式计算框架最重要的执行机制。本课时主要从两个层面讲解 Shuffle,主要分为:

逻辑层面;
物理层面。
逻辑层面主要从 RDD 的血统机制出发,从 DAG 的角度来讲解 Shuffle,另外也会讲解 Spark 容错机制,而物理层面是从执行角度来剖析 Shuffle 是如何发生的。

RDD 血统与 Spark 容错
在 DAG 中,最初的 RDD 被称为基础 RDD,后续生成的 RDD 都是由算子以及依赖关系生成的,也就是说,无论哪个 RDD 出现问题,都可以由这种依赖关系重新计算而成。这种依赖关系被称为 RDD 血统(lineage)。血统的表现形式主要分为宽依赖(wide dependency)与窄依赖(narrow dependency),如下图所示:

图片1.png

窄依赖的准确定义是:子 RDD 中的分区与父 RDD 中的分区只存在一对一的映射关系,而宽依赖则是子 RDD 中的分区与父 RDD 中的分区存在一对多的映射关系,那么从这个角度来说,map、 filter、 union 等就是窄依赖,而 groupByKey、 coGroup 就是典型的宽依赖,如下图所示:

图片2.png

图片3.png

宽依赖还有个名字,叫 Shuffle 依赖,也就是说宽依赖必然会发生 Shuffle 操作,在前面也提到过 Shuffle 也是划分 Stage 的依据。而窄依赖由于不需要发生 Shuffle,所有计算都是在分区所在节点完成,它类似于 MapReduce 中的 ChainMapper。所以说,在你自己的 DAG 中,如果你选取的算子形成了宽依赖,那么就一定会触发 Shuffle。

当 RDD 中的某个分区出现故障,那么只需要按照这种依赖关系重新计算即可,窄依赖最简单,只涉及某个节点内的计算,而宽依赖,则会按照依赖关系由父分区计算而得到,如下图所示:

图片4.png

如果 P1_0 分区发生故障,那么按照依赖关系,则需要 P0_0 与 P0_1 的分区重算,如果 P0_0与 P0_1 没有持久化,就会不断回溯,直到找到存在的父分区为止。当计算逻辑复杂时,就会引起依赖链过长,这样重算的代价会极其高昂,所以用户可以在计算过程中,适时调用 RDD 的 checkpoint 方法,保存当前算好的中间结果,这样依赖链就会大大缩短。RDD 的血统机制就是 RDD 的容错机制。

Spark 的容错主要分为资源管理平台的容错和 Spark 应用的容错, Spark 应用是基于资源管理平台运行,所以资源管理平台的容错也是 Spark 容错的一部分,如 YARN 的 ResourceManager HA 机制。在 Spark 应用执行的过程中,可能会遇到以下几种失败的情况:

Driver 报错;
Executor 报错;
Task 执行失败。
Driver 执行失败是 Spark 应用最严重的一种情况,标志整个作业彻底执行失败,需要开发人员手动重启 Driver;Executor 报错通常是因为 Executor 所在的机器故障导致,这时 Driver 会将执行失败的 Task 调度到另一个 Executor 继续执行,重新执行的 Task 会根据 RDD 的依赖关系继续计算,并将报错的 Executor 从可用 Executor 的列表中去掉;Spark 会对执行失败的 Task 进行重试,重试 3 次后若仍然失败会导致整个作业失败。在这个过程中,Task 的数据恢复和重新执行都用到了 RDD 的血统机制。

Spark Shuffle
很多算子都会引起 RDD 中的数据进行重分区,新的分区被创建,旧的分区被合并或者被打碎,在重分区的过程中,如果数据发生了跨节点移动,就被称为 Shuffle,在 Spark 中, Shuffle 负责将 Map 端(这里的 Map 端可以理解为宽依赖的左侧)的处理的中间结果传输到 Reduce 端供 Reduce 端聚合(这里的 Reduce 端可以理解为宽依赖的右侧),它是 MapReduce 类型计算框架中最重要的概念,同时也是很消耗性能的步骤。Shuffle 体现了从函数式编程接口到分布式计算框架的实现。与 MapReduce 的 Sort-based Shuffle 不同,Spark 对 Shuffle 的实现方式有两种:Hash Shuffle 与 Sort-based Shuffle,这其实是一个优化的过程。在较老的版本中,Spark Shuffle 的方式可以通过 spark.shuffle.manager 配置项进行配置,而在最新的 Spark 版本中,已经去掉了该配置,统一称为 Sort-based Shuffle。

Hash Shuffle
在 Spark 1.6.3 之前, Hash Shuffle 都是 Spark Shuffle 的解决方案之一。 Shuffle 的过程一般分为两个部分:Shuffle Write 和 Shuffle Fetch,前者是 Map 任务划分分区、输出中间结果,而后者则是 Reduce 任务获取到的这些中间结果。Hash Shuffle 的过程如下图所示:

图片5.png

在图中,Shuffle Write 发生在一个节点上,该节点用来执行 Shuffle 任务的 CPU 核数为 2,每个核可以同时执行两个任务,每个任务输出的分区数与 Reducer(这里的 Reducer 指的是 Reduce 端的 Executor)数相同,即为 3,每个分区都有一个缓冲区(bucket)用来接收结果,每个缓冲区的大小由配置 spark.shuffle.file.buffer.kb 决定。这样每个缓冲区写满后,就会输出到一个文件段(filesegment),而 Reducer 就会去相应的节点拉取文件。这样的实现很简单,但是问题也很明显。主要有两个:

生成的中间结果文件数太大。理论上,每个 Shuffle 任务输出会产生 R 个文件( R为Reducer 的个数),而 Shuffle 任务的个数往往由 Map 任务个数 M 决定,所以总共会生成 M * R 个中间结果文件,而往往在一个作业中 M 和 R 都是很大的数字,在大型作业中,经常会出现文件句柄数突破操作系统限制。
缓冲区占用内存空间过大。单节点在执行 Shuffle 任务时缓存区大小消耗为 m * R * spark.shuffle.file.buffer.kb,m 为该节点运行的 Shuffle 任务数,如果一个核可以执行一个任务,m 就与 CPU 核数相等。这对于动辄有 32、64 物理核的服务器来说,是比不小的内存开销。
为了解决第一个问题, Spark 推出过 File Consolidation 机制,旨在通过共用输出文件以降低文件数,如下图所示:

图片6.png

每当 Shuffle 任务输出时,同一个 CPU 核心处理的 Map 任务的中间结果会输出到同分区的一个文件中,然后 Reducer 只需一次性将整个文件拿到即可。这样,Shuffle 产生的文件数为 C(CPU 核数)* R。 Spark 的 FileConsolidation 机制默认开启,可以通过 spark.shuffle.consolidateFiles 配置项进行配置。

Sort-based Shuffle
在 Spark 先后引入了 Hash Shuffle 与 FileConsolidation 后,还是无法根本解决中间文件数太大的问题,所以 Spark 在 1.2 之后又推出了与 MapReduce 一样(你可以参照《Hadoop 海量数据处理》(第 2 版)的 Shuffle 相关章节)的 Shuffle 机制: Sort-based Shuffle,才真正解决了 Shuffle 的问题,再加上 Tungsten 计划的优化, Spark 的 Sort-based Shuffle 比 MapReduce 的 Sort-based Shuffle 青出于蓝。如下图所示:

图片7.png

每个 Map 任务会最后只会输出两个文件(其中一个是索引文件),其中间过程采用的是与 MapReduce 一样的归并排序,但是会用索引文件记录每个分区的偏移量,输出完成后,Reducer 会根据索引文件得到属于自己的分区,在这种情况下,Shuffle 产生的中间结果文件数为 2 * M(M 为 Map 任务数)。

在基于排序的 Shuffle 中, Spark 还提供了一种折中方案——Bypass Sort-based Shuffle,当 Reduce 任务小于 spark.shuffle.sort.bypassMergeThreshold 配置(默认 200)时,Spark Shuffle 开始按照 Hash Shuffle 的方式处理数据,而不用进行归并排序,只是在 Shuffle Write 步骤的最后,将其合并为 1 个文件,并生成索引文件。这样实际上还是会生成大量的中间文件,只是最后合并为 1 个文件并省去排序所带来的开销,该方案的准确说法是 Hash Shuffle 的Shuffle Fetch 优化版。

Spark 在1.5 版本时开始了 Tungsten 计划,也在 1.5.0、 1.5.1、 1.5.2 的时候推出了一种 tungsten-sort 的选项,这是一种成果应用,类似于一种实验,该类型 Shuffle 本质上还是给予排序的 Shuffle,只是用 UnsafeShuffleWriter 进行 Map 任务输出,并采用了要在后面介绍的 BytesToBytesMap 相似的数据结构,把对数据的排序转化为对指针数组的排序,能够基于二进制数据进行操作,对 GC 有了很大提升。但是该方案对数据量有一些限制,随着 Tungsten 计划的逐渐成熟,该方案在 1.6 就消失不见了。

从上面整个过程的变化来看, Spark Shuffle 也是经过了一段时间才趋于成熟和稳定,这也正像学习的过程,不用一蹴而就,贵在坚持。

习题讲解
最后我们在这里公布 08 课时的第 2 道练习题答案,从这道题你可以看出 Shuffle 的方式,对性能影响非常大。

首先来回顾下这道题:
练习题 2:用 Spark 算子实现对 1TB 的数据进行排序?

关于这道题的解法,你可能很自然地想到了归并排序的原理,首先每个分区对自己分区进行排序,最后汇总到一个分区内进行全排序,如下图所示:

图片8.png

可想而知,最后 1TB 的数据都会汇总到 1 个 Executor,就算这个 Executor 分配到的资源再充足,面对这种情况,无疑也是以失败告终。所以这道题的解法应该是另一种方案,首先数据会按照键的区间进行分发,也就是 Shuffle,如 [0,100000]、 [100000,200000)和 [200000,300000],每个分区没有交集。照此规则分发后,分区内再进行排序,就可以在满足性能要求的前提下完成全排序,如下图:

图片9.png

这种方式的全排序无疑实现了计算的并行化,很多测试性能的场景也用这种方式对 1TB 的数据进行排序,目前世界纪录是腾讯在 2016 年达到的 98.8 秒。对于这种排序方式,Spark 也将其封装为 sortByKey 算子,它采用的分区器则是 RangePartitioner。

小结
Spark Shuffle 是 Spark 最重要的机制,作为大数据工程师的你,有必要深入了解,Shuffle机制也是面试喜欢问到的一个问题,前面三张关于Shuffle的图大家一定要吃透。另外, Spark 作业的性能问题往往出现在 Shuffle 上,在上个课时中,我们也是通过广播变量而避免了 Shuffle,从而得到性能的提升,所以掌握了 Spark Shuffle 能帮助你有针对性地进行调优。

最后给你出一个思考题:还是在 08 课时第 2 道思考题的基础上,如果数据并没有均匀分布,那么很可能某个分区的数据会异常多,同样会导致作业失败,对于这种数据倾斜的情况,你认为有没有办法避免呢?

第12讲:如何处理结构化数据:DataFrame 、Dataset和Spark SQL

本课时我们来学习如何处理结构化数据:DataFrame、Dataset 和 Spark SQL。由于本课时是专栏的第 3 模块:Spark 高级编程的第 1 课,在开始今天的课程之前,首先对上一个模块进行一个总结。

模块回顾

在第 2 模块里,我们学习了 Spark 核心数据结构 RDD 和算子,以及 Spark 相关的一些底层原理。可以看到 RDD 将大数据集抽象为集合,这掩盖了分布式数据集的复杂性,而函数式编程风格的算子也能满足不同的数据处理逻辑。但是,RDD + 算子的组合,对于普通分析师来说还是不太友好,他们习惯于“表”的概念而非“集合”,而使用基于集合完成数据处理的逻辑更像是程序员们的思维方式。对于数据处理逻辑,分析师们更习惯用 SQL 而非算子来表达。所以,Spark 借鉴了 Python 数据分析库 pandas 中 DataFrame 的概念,推出了 DataFrame、Dataset 与 Spark SQL。

在数据科学领域中,DataFrame 抽象了矩阵,如 R、pandas 中的 DataFrame;在数据工程领域,如 Spark SQL 中,DataFrame 更多地代表了关系型数据库中的表,这样就可以利用简单易学的 SQL 来进行数据分析;在 Spark 中,我们既可以用 Spark SQL + DataFrame 的组合实现海量数据分析,也可用 DataFrame + MLlib(Spark 机器学习库)的组合实现海量数据挖掘。

在计算机领域中,高级往往意味着简单、封装程度高,而与之对应的通常是复杂、底层。对于 Spark 编程来说,RDD + 算子的组合无疑是比较底层的,而 DataFrame + Spark SQL 的组合无论从学习成本,还是从性能开销上来说,都显著优于前者组合,所以无论是分析师还是程序员,这种方式才是使用 Spark 的首选。 此外,对于分析师来说,DataFrame 对他们来说并不陌生,熟悉的概念也能让他们快速上手。

本课时的主要内容有 4点:

  • DataFrame、Dataset 的起源与演变
  • DataFrame API
  • Dataset API
  • Spark SQL

这里特别说明的是,由于 DataFrame API 的 Scala 版本与 Python 版本大同小异,差异极小,所以本课时的代码以 Scala 版本为主。

DataFrame、Dataset 的起源与演变
DataFrame 在 Spark 1.3 被引入,它的出现取代了 SchemaRDD,Dataset 最开始在 Spark 1.6 被引入,当时还属于实验性质,在 2.0 版本时正式成为 Spark 的一部分,并且在 Spark 2.0 中,DataFrame API 与 Dataset API 在形式上得到了统一。

Dataset API 提供了类型安全的面向对象编程接口。Dataset 可以通过将表达式和数据字段暴露给查询计划程序和 Tungsten 的快速内存编码,从而利用 Catalyst 优化器。但是,现在 DataFrame 和 Dataset 都作为 Apache Spark 2.0 的一部分,其实 DataFrame 现在是 Dataset Untyped API 的特殊情况。更具体地说:

复制代码
DataFrame = Dataset[Row]
下面这张图比较清楚地表示了 DataFrame 与 Dataset 的变迁与关系。

2.png

由于 Python 不是类型安全的语言,所以 Spark Python API 没有 Dataset API,而只提供了 DataFrame API。当然,Java 和 Scala 就没有这种问题。

DataFrame API
DataFrame 与 Dataset API 提供了简单的、统一的并且更富表达力的 API ,简言之,与 RDD 与算子的组合相比,DataFrame 与 Dataset API 更加高级,所以这也是为什么我将这个模块命名为 Spark 高级编程。

DataFrame 不仅可以使用 SQL 进行查询,其自身也具有灵活的 API 可以对数据进行查询,与 RDD API 相比,DataFrame API 包含了更多的应用语义,所谓应用语义,就是能让计算框架知道你的目标的信息,这样计算框架就能更有针对性地对作业进行优化,本课时主要介绍如何创建DataFrame 以及如何利用 DataFrame 进行查询。

1、创建DataFrame
DataFrame 目前支持多种数据源、文件格式,如 Json、CSV 等,也支持由外部数据库直接读取数据生成,此外还支持由 RDD 通过类型反射生成,甚至还可以通过流式数据源生成,这在下个模块会详细介绍。DataFrame API 非常标准,创建 DataFrame 都通过 read 读取器进行读取。下面列举了如何读取几种常见格式的文件。

读取 Json 文件。Json 文件如下:

复制代码
{“name”:“Michael”}
{“name”:“Andy”, “age”:30}
{“name”:“Justin”, “age”:19}

val df = spark.read.json(“examples/src/main/resources/people.json”)
我们可以利用初始化好的 SparkSession(spark)读取 Json 格式文件。

读取 CSV 文件:

复制代码
val df = spark.read.csv(“examples/src/main/resources/people.csv”)
从 Parquet 格式文件中生成:

复制代码
val df = spark.read.parquet(“examples/src/main/resources/people.csv”)
从 ORC 格式文件中生成:

复制代码
val df = spark.read.orc(“examples/src/main/resources/people.csv”)
关于 ORC 与 Parquet 文件格式会在后面详细介绍。
从文本中生成:

复制代码
val df = spark.read.text(“examples/src/main/resources/people.csv”)
通过 JDBC 连接外部数据库读取数据生成

复制代码
val df = spark.read
.format(“jdbc”)
.option(“url”, “jdbc:postgresql:dbserver”)
.option(“dbtable”, “schema.tablename”)
.option(“user”, “username”)
.option(“password”, “password”)
.load()
上面的代码表示通过 JDBC 相关配置,读取数据。

通过 RDD 反射生成。此种方法是字符串反射为 DataFrame 的 Schema,再和已经存在的 RDD 一起生成 DataFrame,代码如下所示:

复制代码
import spark.implicits._
val schemaString = “id f1 f2 f3 f4”
// 通过字符串转换和类型反射生成schema
val fields = schemaString.split(" “).map(fieldName => StructField(fieldName, StringType, nullable = true))
val schema = StructType(fields)
// 需要将RDD转化为RDD[Row]类型
val rowRDD = spark.sparkContext.textFile(textFilePath).map(_.split(”,")).map(attributes =>
Row(attributes(0),
attributes(1),
attributes(2),
attributes(3),
attributes(4).trim)
)
// 生成DataFrame
val df = spark.createDataFrame(rowRDD, schema)
注意这种方式需要隐式转换,需在转换前写上第一行:

复制代码
import spark.implicits._
DataFrame 初始化完成后,可以通过 show 方法来查看数据,Json、Parquet、ORC 等数据源是自带 Schema 的,而那些无 Schema 的数据源,DataFrame 会自己生成 Schema。

Json 文件生成的 DataFrame 如下:

image (5).png

CSV 文件生成的 DataFrame 如下:

image (6).png

2、查询
完成初始化的工作之后就可以使用 DataFrame API 进行查询了,DataFrame API 主要分为两种风格,一种依然是 RDD 算子风格,如 reduce、groupByKey、map、flatMap 等,另外一种则是 SQL 风格,如 select、where 等。

2.1 算子风格

我们简单选取几个有代表性的 RDD 算子风格的 API,具体如下:

复制代码
def groupByKey[K: Encoder](func: T => K): KeyValueGroupedDataset[K, T]
与 RDD 算子版作用相同,返回类型为 Dataset。

复制代码
def map[U : Encoder](func: T => U): Dataset[U]
与 RDD 算子版作用相同,返回类型为 Dataset。

复制代码
def flatMap[U : Encoder](func: T => TraversableOnce[U]): Dataset[U]
与 RDD 算子版作用相同,返回类型为 Dataset。
这些算子用法大同小异,但都需要传入 Encoder 参数,这可以通过隐式转换解决,在调用算子前,需加上:

复制代码
import spark.implicits._
2.2 SQL风格

这类 API 的共同之处就是支持将部分 SQL 语法的字符串作为参数直接传入。

select 和 where

复制代码
def select(cols: Column*): DataFrame
def where(conditionExpr: String): Dataset[T]
条件查询,例如:

复制代码
df.select(“age”).where(“name is not null and age > 10”).foreach(println(_))
groupBy

复制代码
def groupBy(col1: String, cols: String*): RelationalGroupedDataset
分组统计。例如:

复制代码
df.select(“name”,“age”).groupBy(“age”).count().foreach(println(_))
此外,某些 RDD 算子风格的 API 也可以传入部分 SQL 语法的字符串,如 filter。例如:

复制代码
df.select(“age”, “name”).filter(“age > 10”).foreach(println(_))
join

复制代码
def join(right: Dataset[_], usingColumns: Seq[String], joinType: String): DataFrame
DataFrame API 还支持最普遍的连接操作,代码如下:

复制代码
val leftDF = …
val rightDF = …
leftDF.join(rightDF, leftDF(“pid”) === rightDF(“fid”), “left_outer”).foreach(println(_))
其中 joinType 参数支持常用的连接类型,选项有 inner、cross、outer、full、full_outer、left、left_outer、right、right_outer、left_semi 和 left_anti,其中 cross 表示笛卡儿积,这在实际使用中比较少见;left_semi 是左半连接,是 Spark 对标准 SQL 中的 in 关键字的变通实现;left_anti 是 Spark 对标准 SQL 中的 not in 关键字的变通实现。
除了 groupBy 这种分组方式,DataFrame 还支持一些特别的分组方式如 pivot、rollup、cube 等,以及常用的分析函数,先来看一个数据集:

复制代码
{“name”:“Michael”, “grade”:92, “subject”:“Chinese”, “year”:“2017”}

{“name”:“Andy”, “grade”:87, “subject”:“Chinese”, “year”:“2017”}
{“name”:“Justin”, “grade”:75, “subject”:“Chinese”, “year”:“2017”}

{“name”:“Berta”, “grade”:62, “subject”:“Chinese”, “year”:“2017”}

{“name”:“Michael”, “grade”:96, “subject”:“math”, “year”:“2017”}

{“name”:“Andy”, “grade”:98, “subject”:“math”, “year”:“2017”}

{“name”:“Justin”, “grade”:78, “subject”:“math”, “year”:“2017”}

{“name”:“Berta”, “grade”:87, “subject”:“math”, “year”:“2017”}

{“name”:“Michael”, “grade”:87, “subject”:“Chinese”, “year”:“2016”}

{“name”:“Andy”, “grade”:90, “subject”:“Chinese”, “year”:“2016”}

{“name”:“Justin”, “grade”:76, “subject”:“Chinese”, “year”:“2016”}

{“name”:“Berta”, “grade”:74, “subject”:“Chinese”, “year”:“2016”}

{“name”:“Michael”, “grade”:68, “subject”:“math”, “year”:“2016”}

{“name”:“Andy”, “grade”:95, “subject”:“math”, “year”:“2016”}

{“name”:“Justin”, “grade”:87, “subject”:“math”, “year”:“2016”}

{“name”:“Berta”, “grade”:81, “subject”:“math”, “year”:“2016”}

{“name”:“Michael”, “grade”:95, “subject”:“Chinese”, “year”:“2015”}

{“name”:“Andy”, “grade”:91, “subject”:“Chinese”, “year”:“2015”}

{“name”:“Justin”, “grade”:85, “subject”:“Chinese”, “year”:“2015”}

{“name”:“Berta”, “grade”:77, “subject”:“Chinese”, “year”:“2015”}

{“name”:“Michael”, “grade”:63, “subject”:“math”, “year”:“2015”}

{“name”:“Andy”, “grade”:99, “subject”:“math”, “year”:“2015”}

{“name”:“Justin”, “grade”:79, “subject”:“math”, “year”:“2015”}

{“name”:“Berta”, “grade”:85, “subject”:“math”, “year”:“2015”}
以上是某班学生 3 年的成绩单,一共有 3 个维度,即 name、subject 和 year,度量为 grade,也就是成绩,因此,这个 DataFrame 可以看成三维数据立方体,如下图所示。

1.png

现在需要统计每个学生各科目 3 年的平均成绩,该操作可以通过下面的方式实现:

复制代码
dfSG.groupBy(“name”,“subject”).avg(“grade”)
但是,这种形式使结果数据集只有两列——name 和 subject,不利于进一步分析,而利用 DataFrame 的数据透视 pivot 功能无疑更加方便。 pivot 功能在 pandas、Excel 等分析工具已得到了广泛应用,用户想使用透视功能,需要指定分组规则、需要透视的列以及聚合的维度列。所谓“透视”比较形象,即在分组结果上,对每一组进行“透视”,透视的结果会导致每一组基于透视列展开,最后再根据聚合操作进行聚合,统计每个学生每科 3 年平均成绩实现如下:

复制代码
dfSG.groupBy(“name”).pivot(“subject”).avg(“grade”).show()
结果如下:

image (8).png

除了 groupBy 之外,DataFrame 还提供 rollup 和 cube 的方式进行分组聚合,如下:

复制代码
def rollup(col1: String, cols: String*): RelationalGroupedDataset
rollup 也是用来进行分组统计,只不过分组逻辑有所不同,假设 rollup(A,B,C),其中 A、B、C 分别为 3 列,那么会先对 A、B、C 进行分组,然后依次对 A、B 进行分组、对 A 进行分组、对全表进行分组,执行:

复制代码
dfSG.rollup(“name”, “subject”).avg(“grade”).show()
结果如下:

image (9).png

可以看到,除了按照 name + subject 的组合键进行分组,还分别对每个人进行了分组,如 Michael,null,此外还将全表分为了一组,如 null,null。

复制代码
def cube(col1: String, cols: String*): RelationalGroupedDataset
cube 与 rollup 类似,分组依据有所不同,仍以 cube(A,B,C) 为例,分组依据分别是 (A,B,C)、(A,B)、(A,C)、(B,C)、(A)、(B)、©、全表,执行:

复制代码
dfSG.cube(“name”, “subject”).avg(“grade”).show()
结果如下:

image (10).png

可以看到与 rollup 不同,这里还分别对每个科目进行分组,如 null、math。

在实际使用中,你应该尽量选用并习惯于用 SQL 风格的算子完成开发任务,SQL 风格的查询 API 不光表现力强,另外也非常易读。

3、写出
与创建 DataFrame 的 read 读取器相对应,写出为 write 输出器 API。下面列举了如何输出几种常见格式的文件。

写出为 Json 文件:

复制代码
df.select(“age”, “name”).filter(“age > 10”).write.json("/your/output/path")
写出为 Parquet 文件:

复制代码
df.select(“age”, “name”).filter(“age > 10”).write.parquet("/your/output/path")
写出为 ORC 文件:

复制代码
df.select(“age”, “name”).filter(“age > 10”).write.orc("/your/output/path")
写出为文本文件:

复制代码
df.select(“age”, “name”).filter(“age > 10”).write.text("/your/output/path")
写出为 CSV 文件:

复制代码
val saveOptions = Map(“header” -> “true”, “path” -> “csvout”)
df.select(“age”, “name”).filter(“age > 10”)
.write
.format(“com.databricks.spark.csv”)
.mode(SaveMode.Overwrite)
.options(saveOptions)
.save()
我们还可以在保存时对格式已经输出的方式进行设定,例如本例中是保留表头,并且输出方式是 Overwrite,输出方式有 Append、ErrorIfExist、Ignore、Overwrite,分别代表追加到已有输出路径中、如果输出路径存在则报错、存在则停止、存在则覆盖。

写出到关系型数据库:

复制代码
val prop = new java.util.Properties
prop.setProperty(“user”,“spark”)
prop.setProperty(“password”,“123”)
df.write.mode(SaveMode.Append).jdbc(“jdbc:mysql://localhost:3306/test”,“tablename”,prop)
写出到关系型数据库同样基于 JDBC ,用此种方式写入关系型数据库,表名可以不存在。

Dataset API
从本质上来说,DataFrame 只是 Dataset 的一种特殊情况,在 Spark 2.x 中已经得到了统一:

复制代码
DataFrame = Dataset[Row]
因此,在使用 DataFrame API 的过程中,很容易就会自动转换为 Dataset[String]、Dataset[Int] 等类型。除此之外,用户还可以自定义类型。下面来看看 DataFrame 转成 Dataset 的例子,下面是一个 Json 文件,记录了学生的单科成绩:

复制代码
{“name”:“Michael”, “grade”:92, “subject”:“Chinese”}

{“name”:“Andy”, “grade”:87, “subject”:“Chinese”}
{“name”:“Justin”, “grade”:75, “subject”:“Chinese”}

{“name”:“Berta”, “grade”:62, “subject”:“Chinese”}

{“name”:“Michael”, “grade”:96, “subject”:“math”}

{“name”:“Andy”, “grade”:98, “subject”:“math”}

{“name”:“Justin”, “grade”:78, “subject”:“math”}

{“name”:“Berta”, “grade”:87, “subject”:“math”}
代码如下:

复制代码
// 首先定义StudentGrade类
case class StudentGrade(name: String, subject: String, grade: Long)
// 生成DataFrame
val dfSG = spark.read.json(“data/examples/target/scala-2.11/classes/student_grade.json”)
// 方法1:通过map函数手动转换为Dataset[StudentGrade]类型
val dsSG: Dataset[StudentGrade] = dfSG.map(a => StudentGrade(a.getAsString,a.getAsString,a.getAsLong))
// 方法2:使用DataFrame的as函数进行转换
val dsSG2: Dataset[StudentGrade] = dfSG.as[StudentGrade]
// 方法3:通过RDD转换而成(基于同样内容的CSV文件)
val dsSG3 = spark.sparkContext.
textFile(“data/examples/target/scala-2.11/classes/student_grade.csv”).
map[StudentGrade](row => {
val fields = row.split(",")
StudentGrade(
fields(0).toString(),
fields(1).toString(),
fields(2).toLong
)
}).toDS

// 求每科的平均分
dsSG3.groupBy(“subject”).mean(“grade”).foreach(println(_))
以上 3 种方法都可以将 DataFrame 转换为 Dataset 。转换完成后,就可以使用其 API 对数据进行分析,使用方式与 DataFrame 并无不同。
Spark SQL
在实际工作中,使用频率最高的当属 Spark SQL,通常一个大数据处理项目中,70% 的数据处理任务都是由 Spark SQL 完成,它贯穿于数据预处理、数据转换和最后的数据分析。由于 SQL 的学习成本低、用户基数大、函数丰富,Spark SQL 也通常是使用 Spark 最方便的方式。此外,由于 SQL 包含了丰富的应用语义,所以 Catalyst 优化器带来的性能巨大提升也使 Spark SQL 成为编写 Spark 作业的最佳方式。接下来我将为你介绍 Spark SQL 的使用。

从使用层面上来讲,要想用好 Spark SQL,只需要编写 SQL 就行了,本课时的最后简单介绍了下 SQL 的常用语法,方便没有接触过 SQL 的同学快速入门。

1、创建临时视图
想使用 Spark SQL,可以先创建临时视图,相当于数据库中的表,这可以通过已经存在的 DataFrame、Dataset 直接生成;也可以直接从 Hive 元数据库中获取元数据信息直接进行查询。先来看看创建临时视图:

复制代码
case class StudentGrade(name: String, subject: String, grade: Long)
// 生成DataFrame
val dfSG = spark.read.json(“data/examples/target/scala-2.11/classes/student_grade.json”)
// 生成Dataset
val dsSG = dfSG.map(
a => StudentGrade(
a.getAsString,
a.getAsString,
a.getAsLong
)
)

// 创建临时视图
dfSG.createOrReplaceTempView(“student_grade_df”)
dsSG.createOrReplaceTempView(“student_grade_ds”)
// 计算每科的平均分
spark.sql(“SELECT subject, AVG(grade) FROM student_grade_df GROUP BY subject”).show()
spark.sql(“SELECT subject, AVG(grade) FROM student_grade_ds GROUP BY subject”).show()
对于 Dataset 来说,对象类型的数据结构会作为临时视图的元数据,在 SQL 中可以直接使用。

2、使用Hive元数据
随着 Spark 越来越流行,有很多情况,需要将 Hive 作业改写成 Spark SQL 作业,Spark SQL 可以通过 hive-site.xml 文件的配置,直接读取 Hive 元数据。这样,改写的工作量就小了很多,代码如下:

复制代码
val spark = SparkSession
.builder()
.master(“local[*]”)
.appName(“Hive on Spark”)
.enableHiveSupport()
.getOrCreate()
// 直接查询
spark.sql(…………)
代码中通过 enableHiveSupport 方法开启对 Hive 的支持,但需要将 Hive 配置文件 hive-site.xml 复制到 Spark 的配置文件夹下。

3、查询语句
Spark 的 SQL 语法源于 Presto (一种支持 SQL 的大规模并行处理技术,适合 OLAP),在源码中我们可以看见,Spark 的 SQL 解析引擎直接采用了 Presto 的 SQL 语法文件。查询是 Spark SQL 的核心功能,Spark SQL 的查询语句模式如下:

复制代码
[ WITH with_query [, …] ]
SELECT [ ALL | DISTINCT ] select_expr [, …]
[ FROM from_item [, …] ]
[ WHERE condition ]
[ GROUP BY expression [, …] ]
[ HAVING condition]
[ UNION [ ALL | DISTINCT ] select ]
[ ORDER BY expression [ ASC | DESC ] [, …] ]
[ LIMIT count ]
其中 from_item 为以下之一:

复制代码
table_name [ [ AS ] alias [ ( column_alias [, …] ) ] ]
from_item join_type from_item [ ON join_condition | USING ( join_column [, …] ) ]
该模式基本涵盖了 Spark SQL 中查询语句的各种写法。

3.1 SELECT 与 FROM 子句

SELECT 与 FROM 是构成查询语句的最小单元,SELECT 后面跟列名表示要查询的列,或者用 * 表示所有列,FROM 后面跟表名,示例如下:

复制代码
SELECT name, grade FROM student_grade t;
在使用过程中,对列名和表名都可以赋予别名,这里对 student_grade 赋予别名 t,此外我们还可以对某一列用关键字 DISTINCT 进行去重,默认为 ALL,表示不去重:

复制代码
SELECT COUNT( DISTINCT name) FROM student_grade;
上面这条 SQL 代表统计有多少学生参加了考试。

3.2 WHERE 子句

WHERE 子句经常和 SELECT 配合使用,用来过滤参与查询的数据集,WHERE 后面一般会由运算符组合成谓词表达式(返回值为 True 或者 False ),例如:

复制代码
SELECT * FROM student_grade WHERE grade > 90;
SELECT * FROM student_grade WHERE name IS NOT NULL;
SELECT * FROM student_grade WHERE name LIKE “*ndy”;
常见的运算符还有 !=、<> 等,此外还可以用逻辑运算符:AND、OR 组合谓词表达式进行查询,例如:

复制代码
SELECT * FROM student_grade WHERE grade > 90 AND name IS NOT NULL
3.3 GROUP BY 子句

GROUP BY 子句用于对 SELECT 语句的输出进行分组,分组中是匹配值的数据行。GROUP BY 子句支持指定列名或列序号(从 1 开始)表达式。以下查询是等价的,都会对 subject 列进行分组,第一个查询使用列序号,第二个查询使用列名:

复制代码
SELECT avg(grade), subject FROM student_grade GROUP BY 2;
SELECT avg(grade), subject FROM student_grade GROUP BY subject;
使用 GROUP BY 子句时需注意,出现在 SELECT 后面的列,要么同时出现在 GROUP BY 后面,要么就在聚合函数中。

3.4 HAVING 子句

HAVING 子句与聚合函数以及 GROUP BY 子句配合使用,用来过滤分组统计的结果。HAVING 子句去掉不满足条件的分组。在分组和聚合计算完成后,HAVING 对分组进行过滤。例如以下查询会过滤掉平均分大于 90 分的科目:

复制代码
SELECT subject,AVG(grade)
FROM student_grade
GROUP BY subject
HAVING AVG(grade) < 90;
3.5 UNION 子句

UNION 子句用于将多个查询语句的结果合并为一个结果集:

复制代码
query UNION [ALL | DISTINCT] query
参数 ALL 或 DISTINCT 可以控制最终结果集包含哪些行。如果指定参数 ALL,则包含全部行,即使行完全相同;如果指定参数 DISTINCT ,则合并结果集,结果集只有唯一不重复的行;如果不指定参数,执行时默认使用 DISTINCT。下面这句 SQL 是将两个班级的成绩进行合并:

复制代码
SELECT * FROM student_grade_class1
UNION ALL
SELECT * FROM student_grade_class2;
多个 UNION 子句会从左向右执行,除非用括号明确指定顺序。

3.6 ORDER BY 子句
ORDER BY 子句按照一个或多个输出表达式对结果集排序:

复制代码
ORDER BY expression [ ASC | DESC ] [ NULLS { FIRST | LAST } ] [, …]
每个表达式由列名或列序号(从 1 开始)组成。ORDER BY 子句作为查询的最后一步,在 GROUP BY 和 HAVING 子句之后。ASC 为默认升序,DESC 为降序。下面这句 SQL 会对结果进行过滤,并按照平均分进行排序,注意这里使用了列别名:

复制代码
SELECT subject,AVG(grade) avg
FROM student_grade
GROUP BY subject
HAVING AVG(grade) < 90
ORDER BY avg DESC;
3.7 LIMIT 子句

LIMIT 子句限制结果集的行数,这在查询大表时很有用。以下示例为对单科成绩进行排序并只返回前 3 名的记录:

复制代码
SELECT * FROM student_grade
WHERE subject = ‘math’
ORDER BY grade DESC
LIMIT 3;
3.8 JOIN 子句

JOIN 操作可以将多个有关联的表进行关联查询,下面这句 SQL 是查询数学成绩在 90 分以上的学生的院系,其中院系信息可以从学生基础信息表内,通过姓名连接得到:

复制代码
SELECT g.*, a.department
FROM student_grade g
JOIN student_basic b
ON g.name = b.name
WHERE g.subject = ‘math’ and grade > 90
在这句 SQL 中,表 g 被称为驱动表或是左表,表 b 被称为右表。如前所述,Spark 支持多种连接类型。

小结
本课时主要介绍了 DataFrame、Dataset 与 Spark SQL,相对于 RDD 与算子,这种数据处理方式无疑对分析师来说更为友好,如果前面完成了习题,那么你的体会要更加深刻,而且这也是 Spark 官方推荐的 Spark API,无论是从性能还是从开发效率来说都是全方位领先于 RDD 与算子的组合,这也是很好理解的,举个例子,学习了 Python 后,想用 Python 直接进行数据分析无疑是很不方便的,Python 的 pandas 库则很好地解决了这个问题。

由于 Spark 对于 SQL 支持得非常好,而 pandas 在这方面没那么强大,所以,在某些场景,你可以选择 Spark SQL 来代替 pandas,这有时对于分析师来说非常好用。

最后给你出一个思考题,表中存有某一年某只股票的历史价格,表结构如下:

股票 id,时间戳,成交价格

问题是:请用一句 SQL 计算这只股票最长连续价格上涨天数,这里解释一个概念,如果某天的开盘价小于当天的收盘价,就认为这只股票当天是上涨的。这个需求看起来简单,但是用 SQL 写出来还是比较复杂的,需要用到子查询等内容,如果你能够完成这个思考题,我相信你能很好地使用 Spark SQL。

第13讲:如何使用用户自定义函数?

内容是:用户自定义函数。在上个课时,你了解了 DataFrame、Dataset 和 Spark SQL,如果说 Spark 隐藏了分布式计算的复杂性,那么可以认为 DataFrame、Dataset 和 Spark SQL 比它要更近一步,用统一而简洁的接口隐藏了数据分析的复杂性。

在本课时,我们会主要介绍在上个课时中没有详细讲解的函数与自定义函数。在实际使用中,函数和自定义函数的使用频率非常高,可以说,对于复杂的需求,如果用好了函数,那么事情会简单许多,反之,则会事倍功半。

本课时的主要内容有:

窗口函数
函数
用户自定义函数
窗口函数
首先,我们来看下窗口函数,窗口函数可以使用户针对某个范围的数据进行聚合操作,如:

累积和
差值
加权移动平均
可以想象一个窗口在全量数据集上进行滑动,用户可以自定义在窗口中的操作,如下图所示。

1.png

使用窗口函数,首先需要定义窗口,DataFrame 提供了 API 定义窗口,以及窗口中的计算逻辑,还是以学生成绩为例,现在需要得出每个学生单科最佳成绩以及成绩所在的年份,这个需求就要用到窗口中的 row_number 函数,row_number 函数可以根据窗口中的数据生成行号,首先来定义窗口:

复制代码
import org.apache.spark.sql.expressions.Window
import org.apache.spark.sql.functions._

val window = Window
.partitionBy(“name”,“subject”)
.orderBy(desc(“grade”))
上面的代码定义了窗口的范围:按照每个人的姓名与科目的组合进行开窗,并控制了数据在窗口中的顺序:按照 grade 降序进行排序,row_number 函数就可以作用在这个窗口上,对每个人每个科目成绩赋予行号,代码如下:

复制代码
dfSG.select(
col(“name”),
col(“subject”),
col(“year”),
col(“grade”),
row_number().over(window)
.show()
结果如下:

image (3).png

最后只需要从这张表中过滤出 row_number 等于 1 的数据即可。

此外,DataFrame 还提供了 rowsBetween 和 rangeBetween 来进一步定义窗口范围,其中 rowsBetween 是通过物理行号进行控制,rangeBetween 是通过逻辑条件来对窗口进行控制,来看一个简单的例子,一份两个字段的样例数据:

复制代码
{“key”:“1”, “num”:2}
{“key”:“1”, “num”:2}
{“key”:“1”, “num”:3}
{“key”:“1”, “num”:4}
{“key”:“1”, “num”:5}
{“key”:“1”, “num”:6}
{“key”:“2”, “num”:2}
{“key”:“2”, “num”:2}
{“key”:“2”, “num”:3}
{“key”:“2”, “num”:4}
{“key”:“2”, “num”:5}
{“key”:“2”, “num”:6}
现在通过窗口函数对相同 key 的 num 字段做累加计算。代码如下:

复制代码
val windowSlide = Window
.partitionBy(“key”)
.orderBy(“num”)
.rangeBetween(Window.currentRow + 2,Window.currentRow + 20)

val dfWin = spark.read.json(“json/window.json”)

dfWin
.select(col(“key”),sum(“num”).over(windowSlide))
.sort(“key”)
.show()
在 rangeBetween 中,定义的窗口是当前行的 num 值 +2 到当前行的 num 值 +20 这个区间中的数据,如下所示:

复制代码
{“key”:“1”, “num”:2} 窗口为[4,22] 累加和为4 + 5 + 6 = 15

{“key”:“1”, “num”:2} 窗口为[4,22] 累加和为4 + 5 + 6 = 15

{“key”:“1”, “num”:3} 窗口为[5,23] 累加和为5 + 6 = 11

{“key”:“1”, “num”:4} 窗口为[6,24] 累加和为6

{“key”:“1”, “num”:5} 窗口为[8,25] 累加和为null

{“key”:“1”, “num”:6} 窗口为[8,26] 累加和为null

{“key”:“2”, “num”:1} 窗口为[3,21] 累加和为12

{“key”:“2”, “num”:2} 窗口为[4,22] 累加和为12

{“key”:“2”, “num”:5} 窗口为[7,25] 累加和为7

{“key”:“2”, “num”:7} 窗口为[9,27] 累加和为null
rangeBetween 通过字段的值定义了参与计算的逻辑窗口大小,也可以使用 rowsBetween 通过行号来指定参与计算的物理窗口,如下所示:

复制代码
val windowSlide = Window
.partitionBy(“key”)
.orderBy(“num”)
.rowsBetween(Window.currentRow - 1,Window.currentRow + 1)

dfWin
.select(col(“key”),sum(“num”).over(windowSlide))
.sort(“key”)
.show()
代码中定义的窗口由当前行、当前行的前一行、当前行的后一行组成,也就是说窗口大小为 3,计算结果如下:

复制代码
{“key”:“1”, “num”:2} 累加和为2 + 2 = 4
{“key”:“1”, “num”:2} 累加和为2 + 2 + 3 = 7
{“key”:“1”, “num”:3} 累加和为2 + 3 + 4 = 9
{“key”:“1”, “num”:4} 累加和为3 + 4 + 5 = 12
{“key”:“1”, “num”:5} 累加和为4 + 5 + 6 = 15
{“key”:“1”, “num”:6} 累加和为5 + 6 = 11
{“key”:“2”, “num”:1} 累加和为1 + 2 = 3
{“key”:“2”, “num”:2} 累加和为1 + 2 + 5 = 8
{“key”:“2”, “num”:5} 累加和为2 + 5 + 7 = 14
{“key”:“2”, “num”:7} 累加和为5 + 7 = 12

函数
在需要对数据进行分析的时候,我们经常会使用到函数,Spark SQL 提供了丰富的函数供用户选择,基本涵盖了大部分的日常使用。下面介绍一些常用函数:

  1. 转换函数
    cast(value AS type) → type

它显式转换一个值的类型。可以将字符串类型的值转为数字类型,反过来转换也可以,在转换失败的时候,会返回 null。这个函数非常常用。

  1. 数学函数
    log(double base, Column a)

求与以 base 为底的 a 的对数。

factorial(Column e)

返回 e 的阶乘。

  1. 字符串函数
    split(Column str,String pattern)

根据正则表达式 pattern 匹配结果作为依据来切分字符串 str。

substring(Column str,int pos,int len)

返回字符串 str 中,起始位置为 pos,长度为 len 的字符串。

concat(Column… exprs)

连接多个字符串列,形成一个单独的字符串。

translate(Column src,String matchingString,String replaceString)

在字符串 src 中,用 replaceString 替换 mathchingString。

字符串函数也是非常常用的函数类型。

  1. 二进制函数
    bin(Column e)

返回输入内容 e 的二进制值。

base64(Column e)

计算二进制列e的 base64 编码,并以字符串返回。

  1. 日期时间函数
    current_date()

获取当前日期

current_timestamp()

获取当前时间戳

date_format(Column dateExpr,String format)

将日期/时间戳/字符串形式的时间列,按 format 指定的格式表示,并以字符串返回。

  1. 正则表达式函数
    regexp_extract(Column e,String exp,int groupIdx)

首先在 e 中匹配正则表达式 exp,按照 groupIdx 的值返回结果,groupIdx 默认值为 1,返回第 1 个匹配成功的内容,0 表示返回全部匹配成功的内容。

regexp_replace(Column e,String pattern,String replacement)

用 replacement 替换在 e 中根据 pattern 匹配成功的字符串。

  1. JSON 函数
    get_json_object(Column e,String path)

解析 JSON 字符串 e,返回 path 指定的值。

  1. URL 函数
    parse_url(string urlString, string partToExtract [, stringkeyToExtract])

该函数专门用来解析 URL,提取其中的信息,partToExtract 的选项包含 HOST、PATH、QUERY、REF、PROTOCOL、AUTHORITY、USEINFO,函数会根据选项提取出相应的信息。

  1. 聚合函数
    countDistinct(Column expr,Column… exprs)

返回一列数据或一组数据中不重复项的个数。expr 为返回 column 的表达式。

avg(Column e)

返回 e 列的平均数。

count(Column e)

返回 e 列的行数。

max(Column e)

返回 e 中的最大值

sum(Column e)

返回 e 中所有数据之和

skewness(Column e)

返回 e 列的偏度。

stddev_samp(Column e)

stddev(Column e)

返回 e 的样本标准差。

var_samp(Column e)

variance(Column e)

返回 e 的样本方差。

var_pop(Column e)

返回 e 的总体方差。

这类函数顾名思义,作用于很多行,所以往往与统计分析相关。

  1. 窗口函数
    row_number()

对窗口中的数据依次赋予行号。

rank()

与 row_number 函数类似,也是对窗口中的数据依次赋予行号,但是 rank 函数考虑到了 over 子句中排序字段值相同的情况,如下表所示。

2.png
dense_rank()

与 row_number 函数类似,也是对窗口中的数据依次赋予行号,但是 dense_rank 函数考虑到over 子句中排序字段值相同的情况,并保证了序号连续。

ntile(n)

将每一个窗口中的数据放入 n 个桶中,用 1-n 的数字加以区分。

在实际开发过程中,大量的需求都可以直接通过函数以及函数的组合完成,一般来说,函数的丰富程度往往超乎你的想象,所以在面临新需求时,建议首先查阅文档,看看有没有函数可以利用,如果实在不行,我们才会使用用户自定义函数(User Defined Function)。

Spark SQL 的函数文档目前我没有发现特别全面的,所以我通常就会直接阅读源码,源码列出了所有的函数,如下:

https://github.com/apache/spark/blob/6646b3e13e46b220a33b5798ef266d8a14f3c85b/sql/core/src/main/scala/org/apache/spark/sql/functions.scala

用户自定义函数
DataFrame API 支持用户自定义函数,自定义函数有两种:UDF 和 UDAF,前者是类似于 map操作的行处理,一行输入一行输出,后者是聚合处理,多行输入,一行输出,先来看看 UDF,下面的代码会开发一个根据得分显示分数等级的函数 level:

复制代码
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import scala.reflect.api.materializeTypeTag

object MyUDF {

def main(args: Array[String]): Unit = {

 val spark = SparkSession
.builder
.master("local[2]")
.appName("Test")
.getOrCreate()
import spark.implicits._

val dfSG = spark.read.json("examples/target/scala-2.11/classes/student_grade.json")

def level(grade: Int): String = {
if(grade >= 85)
     "A"
   else if(grade < 85 & grade >= 75)
     "B"
   else if(grade < 75 & grade >= 60)
     "C"
   else if(grade < 60)
     "D"  
   else
     "ERROR"
}

val myUDF = udf(level _)

dfSG.select(col("name"),myUDF(col("grade"))).show()

}

}
接下来看看 UDAF,UDAF 是用户自定义聚合函数,分为两种:un-type UDAF 和 safe-type UDAF,前者是与 DataFrame 配合使用,后者只能用于 Dataset,UDAF 需要实现 UserDefinedAggregateFunction 抽象类,本例实现了一个求某列最大值的 UDAF,代码如下:

复制代码
import org.apache.spark.sql.expressions._
import org.apache.spark.sql.types._
import org.apache.spark.sql.Row
import org.apache.spark.sql.functions._
import org.apache.spark.sql.SparkSession

object MyMaxUDAF extends UserDefinedAggregateFunction {

//指定输入的类型
override def inputSchema: StructType
= StructType(Array(StructField(“input”, IntegerType, true)))

//指定中间输出的类型,可指定多个
override def bufferSchema: StructType
= StructType(Array(StructField(“max”, IntegerType, true)))

//指定最后输出的类型
override def dataType: DataType = IntegerType
override def deterministic: Boolean = true

//初始化中间结果
override def initialize(buffer: MutableAggregationBuffer): Unit
= {buffer(0) = 0}

//实现作用在每个分区的结果
override def update(buffer: MutableAggregationBuffer, input: Row): Unit = {
val temp = input.getAsInt
val current = buffer.getAsInt
if(temp > current)
buffer(0) = temp
}

//合并多个分区的结果
override def merge(buffer1: MutableAggregationBuffer, buffer2: Row): Unit = {
if(buffer1.getAsInt < buffer2.getAsInt)
buffer1(0) = buffer2.getAsInt
}

//返回最后的结果
override def evaluate(buffer: Row): Any = buffer.getAsInt
}

object MyMaxUDAFDriver extends App{

val spark = SparkSession
.builder
.master(“local[2]”)
.appName(“Test”)
.getOrCreate()
import spark.implicits._

val dfSG = spark.read.json(“examples/target/scala-2.11/classes/student_grade.json”)

dfSG.select(MyMaxUDAF(col(“grade”))).show()
}
可以从代码看到 UDAF 的逻辑,还是类似于 MapReduce 的思想,先通过 update 函数处理每个分区,最后再通过 merge 函数汇总结果。

Dataset 的 UDAF 对应的是 safe-type UDAF,这种类型的 UDAF 只有 Dataset 能够使用,因为 Dataset 是类型安全的。使用方式和 un-type UDAF 类似,也是先要结合自己聚合的逻辑实现 Aggregator 抽象类,最后再通过 Dataset API 调用,此处实现一个求学生成绩平均值的 UDAF,代码如下:

复制代码
import org.apache.spark.sql.Encoders
import org.apache.spark.sql.Encoder
import org.apache.spark.sql.expressions._
import org.apache.spark.sql.types._
import org.apache.spark.sql.functions._
import scala.reflect.api.materializeTypeTag
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.Dataset

case class StudentGrade(name: String, subject: String, grade: Long)

case class Average(var sum: Long, var count: Long)

//这里定义的三个类型分别是输入类型、中间结果类型、输出类型
object MyAvgUDAF extends Aggregator[StudentGrade,Average,Double]{

//初始中间状态
def zero: Average = Average(0L,0L)

//更新中间状态
def reduce(buffer: Average, sg: StudentGrade): Average = {
  buffer.sum += sg.grade
  buffer.count += 1
  buffer
}

//合并状态
def merge(b1: Average, b2: Average): Average = {
  b1.sum += b2.sum
  b1.count += b2.count
  b1
}

//得到最后结果
def finish(reduction: Average): Double = reduction.sum / reduction.count

//为中间结果指定编译器
def bufferEncoder: Encoder[Average] = Encoders.product

//为输出结果指定编译器
def outputEncoder: Encoder[Double] = Encoders.scalaDouble

}
通过Dataset API调用:
object MyAvgUDAFDriver extends App{

val spark = SparkSession
.builder
.master("local[2]")
//.config("spark.reducer.maxSizeInFlight", "128M")
.appName("Test")
.getOrCreate()
import spark.implicits._

//读取数据
val dfSG = spark.read.json("examples/target/scala-2.11/classes/student_grade.json")
//生成Dataset
val dsSG: Dataset[StudentGrade] = dfSG.map(a => StudentGrade(a.getAs[String](0),a.getAs[String](1),a.getAs[Long](2)))
//注册UDAF
val MyAvg = MyAvgUDAF.toColumn.name("MyAvg")
//查询
dsSG.select(MyAvg).show()

}
自定义函数注册以后,同样可以在 Spark SQL 中使用。

小结
最后来做一个小结,这不光针对本课时,而是一个阶段性小结,现在我们学习了 RDD API、DataFrame API 和 Dataset API,对于数据处理来说,它们都能胜任,那么在实际使用中应该如何选择呢。

一般来说,在任何情况下,都不推荐使用 RDD 算子,原因如下:

在某种抽象层面上来说,使用 RDD 算子编程相当于直接使用汇编语言或者机器代码进行编程;
RDD算子与 SQL、DataFrame API 和 Dataset API 相比,更偏向于如何做,而非做什么,这样优化的空间很少;
RDD 语言不如 SQL 语法友好。
此外,在其他情况,应优先考虑 Dataset,因为静态类型的特点会使计算更加迅速,但用户必须使用静态语言才行,如 Java 与 Scala,像 Python 这种动态语言是没有 Dataset API 的。

下图是用户用不同语言基于 RDD API 和 DataFrame API 开发的应用性能对比,可以看到 Python + RDD API 的组合是远远落后其他组合的,此外,RDD API 开发应用的性能整体要明显落后于 DataFrame API 开发的应用性能。从开发速度和性能上来说,DataFrame + SQL 无疑是最好选择。

3.png

最后,还是留一个思考题,最开始我们举了一个窗口函数的例子,后面在介绍 Spark SQL 中也学习了窗口函数,你能用 SQL 的窗口函数实现同样的逻辑吗?

第14讲:列式存储:针对查询场景的极致优化

在前 2 个课时,我们学习了如何用 DataFrame + SQL 的方式对数据进行分析与处理,需要实践的内容比较多,学习起来未免比较辛苦,那么本课时我们来聊聊列式存储这个比较轻松又实用的话题。在本课时的标题中,提到了查询场景和极致优化,也就是说,如果你的业务场景只是查询(这意味着没有增删改),那么列式存储将带来极其可观的性能提升。

本课时的主要内容有:

Google Dremel
列式存储的实现
对比测试
Google Dremel
Google 在 2004-2006 年期间发表了著名的“三驾马车”论文,开启了大数据时代。在 2010 年,Google 又发表了 3 篇论文,被称为 Google 的“新三驾马车”,可见其分量之重,其中一篇《Dremel: Interactive Analysis of Web-Scale Datasets》,提出了列式存储与多级执行树,文中介绍了 Google 运用 Dremel 分析来自互联网的千亿条级别数据的实践。

与论文中提出的列式存储相比,行式存储可以看成是一个行的集合,其中每一行都要求对齐,哪怕某个字段为空(下图中的左半部分),而列式存储则可以看成一个列的集合(下图中的右半部分)。列式存储的优点很明显,主要有以下 4 点:

查询时可以只读取涉及的列(选择操作),并且列可以直接作为索引,非常高效,而行式存储则必须读入整行。
列式存储的投影操作非常高效。
在数据稀疏的情况下,压缩率比行式存储高很多,甚至可以考虑将相关的表进行预先连接,来完全避免投影操作。
因为可以直接作用于某一列上,聚合分析非常迅速。
行式存储一般擅长的是插入与更新操作,而列式存储一般适用于数据为只读的场景。对于结构化数据,列式存储并不陌生。因此,列式存储技术经常用于传统数据仓库中。下图分别展示了行式存储和列式存储的区别。

1.png

在文章中,Dremel 在一开始就指出其面对的是只读的嵌套数据,而嵌套数据属于半结构化数据,例如 JSON、XML,所以 Dremel 的创新之处在于提出了一种支持嵌套数据的列式存储,而如今互联网上的数据又正好多是嵌套结构。下图左边是一个嵌套的 Schema,而右边的 r1、r2 为两条样例记录:

2.png

3.png

4.png

这个 Schema 其实可以转换为一个树形结构,如下图所示。

5.png

该树结构有 6 个叶子节点,可以看到叶子节点其实就是 Schema 中的基本数据类型,如果将这种嵌套结构的数据展平,那么展平后的表应该有 6 列。如果要应用列式存储来存储这种嵌套结构,还需要解决一个问题,我们看到 r1、r2 的数据结构还是差别非常大,所以需要标识出哪些列的值组成一条完整的记录,但我们不可能为每条记录都维护一个树结构。Google 提出的 record shredding and assembly algorithm 算法很好地解决了这个问题,该算法规定,在保存字段值时,还需要额外存储两个数字,分别表示 Repetition level(r)和 Definition level(d)其中,Repetition level 值记录了当前值属于哪一条记录以及它处于该记录的什么位置;另外对于 repeated 和 optional 类型的列,可能一条记录中某一列是没有值的,如果不进行标识就会导致本该属于下一条记录的值被当作当前记录的一部分,对于这种情况就需要用 Definition level 来标识这种情况,通过 Striping & Assembly 算法我们可以将一整条记录还原出来,如下图所示。这样就能用尽可能少的存储空间来表达复杂的嵌套数据格式了。

6.png

7.png

Dremel 的另外一个组成部分是查询执行树,利用这种架构,Dremel 可以用很低的延迟分析大量数据,这使得 Dremel 非常适合进行交互式分析。Dremel 有很多种开源实现,与 Dremel 一样,它主要分为两部分,一个实现了 Dremel 的嵌套列式存储,如 Apache Parquet、Apache ORC,还有一些实现了 Dremel 的查询执行架构,也就是多级执行树,如 Apache Impala、Aapche Drill 与 Presto。

这里要特别说明的是,多级执行树这种技术与 Spark 这种 MapReduce 类型的计算框架完全不同,它类似于一种大规模并行处理,希望以较低的延迟完成查询,所以并行程度要远远大于 Spark,但是每个执行者的性能要远远弱于 Spark。如果把 Spark 看成是对 CPU 核心的抽象,那么多级执行树可以看成是对线程的抽象。基于此,多级执行树 + 列式存储的组合往往用于 OLAP 的场景。

Parquet 和 ORC 这两种数据格式和 Json 一样都是自描述数据格式,Spark 很早就支持由 Parquet、ORC 格式的数据直接生成 DataFrame。**在课时 12 中曾讲到,我们可以非常方便地通过 read 读取器和 write 写入器读取和生成 Parquet 和 ORC 文件。**列式存储在选择、投影操作的性能优化提升非常明显,此外,Dremel 的高压缩比率也对 Spark 这种 I/O 密集型作业非常友好。在目前 Hadoop、Spark 体系的数据仓库中,已经很少采用 CSV、TEXT 这种格式了。

列式存储的实现
Apache Parquet
Apache Parquet 是 Dremel 的开源实现,它最先是由 Twitter 与 Cloudera 合作开发并开源,和 Impala 配合使用。Parquet 支持几乎 Hadoop 生态圈的所有项目,与数据处理框架、数据结构以及编程语言无关。

Apache ORC
Apache ORC(OptimizedRC file)来源于 RC(RecordColumnar file)格式,但目前已基本取代 RC 格式。ORC 提供 ACID 支持、也提供不同级别的索引,如布隆过滤器、列统计信息(数量、最值等),和 Parquet 一样,它也是自描述的数据格式,但与 Parquet 不同的是,ORC 支持多种复杂数据结构,如集合、映射等。ORC 与 Presto 配合使用,效果非常好。

Apache CarbonData
CarbonData 是华为开源的一种列式存储格式,是专门为海量数据分析和处理而生的。CarbonData 于 2016 年开源,目前发展非常迅猛,与 Apache Kylin 并列为由国人主导的两个Apache 顶级项目。它的设计初衷源于,在很多时候,对于同样一份数据,处理方式是不同的,比如以下几种处理方式:

全表扫描,或者选取几列进行过滤;

随机访问,如行键值查询,要求低延迟;

ad-hoc 交互式分析,如多维聚合分析、上卷、下钻、切片等。

不同的处理方式对于数据格式的需求侧重点是不同的,但 CarbonData 旨在为大数据多样化的分析需求提供一种统一的数据格式。CarbonData 的设计目标为:

支持低延迟访问多种数据访问类型;
允许在压缩编码过的数据上进行快速查询;
确保存储空间的高效性;
很好地支持 Hadoop 生态系统;
读最优化的列式存储;
利用多级索引实现低延迟;
支持利用列组来获得基于行的优点;
能够对聚合的延迟解码进行字典编码。
如下图所示,这是一个 CarbonData 数据文件,也是 HDFS 上的一个数据块,每个文件由 File Header、File Footer 与若干个 Blocklet 组成,其中 File Header 保存了文件版本号、Schema 以及更新时间戳;File Footer 包含了一些统计信息(每个 Blocklet 的最值)、多维索引等。一个 Blocklet 的默认大小为 64MB,包含多个 Column Page Group,Blocklet 可以看成一个表的水平切片,这个表有多少列,就有多少个 Column Page Group,在一个 Column Page Group 中,一列被分为若干个连续文件,每一个文件被称为 Page,一个 Page 默认为 32000 行,如下图所示。

8.png

这里要特别说明的是,CarbonData 在设计理念上没有采取 Dremel 提出的嵌套的列式存储,而是引入了索引和元数据的设计,但仍然属于列式存储格式。

对比测试
使用列式存储对 Spark 性能提升的影响是非常巨大的,下面是一份测试结果,包含了对于同样一份数据(368.4G),各种数据格式压缩率的对比,以及一些计算作业耗时的对比:

TEXT Parquet ORC CarbonData
压缩后大小 368.4G 298G 148.4G 145.8G
压缩率 100% 19.11% 59.72% 60.42%
TEXT Parquet ORC CarbonData
Count 67s 116s 119s 6s
Group By 138s 75s 71s 92s
Join 231s 172s 140s 95s
可以看到 ORC 的压缩率最高,而 CarbonData 在 Spark 批处理这种场景下,性能表现得非常好,是一种非常有前景的技术。

列式存储的压缩率如此之高,从本课时的第一张图也可以看出原因,列式存储作为列的集合,空间几乎没有多余的浪费。如此高的压缩效率也带来了一个优化思路:可以将若干相关的表预先进行连接,连接而成的表可以看成是一张稀疏的宽表,这张宽表对分析来说就非常友好了,但由于采用了列式存储,所以宽表所占的空间并不是指数上涨而是线性增加。在这种场景下,列式存储使得空间换时间成为可能。

小结
目前,列式存储在数据分析领域非常火,比如最近大热的俄罗斯开源列式分析数据库 ClickHouse。Spark 在很早就支持列式存储,而列式存储的使用带来的性能提升是十分巨大的,至于选取哪种列式存储,你可以根据具体的性能表现与存储空间综合进行考虑,不过 ORC 这种格式在绝大多数场景都能胜任。

这里给你留一个思考题,为什么 CarbonData 的 count 性能会远远超过 Dremel 系列的技术,如 ORC 和 Parquet 呢?大家可以从本文中找到答案。

第15讲:如何对 Spark 进行全方位性能调优?

相信你们已经能够比较熟练地编写 Spark 作业,但如何在生产环境中让 Spark 作业稳定且快速地运行是另外一个问题,本课时将回答这个问题。

让 Spark 作业在海量数据面前稳定且快速地运行,这就需要对 Spark 进行性能调优。调优 Spark 是一个持续的过程,随着你对 Spark、数据本身、业务场景愈发了解,调优的思路也会更加多样,这是一个持续累积的过程。能够有针对性地对 Spark 作业进行调优是一名有经验的大数据工程师的必备技能。本课时将会从硬件、资源管理平台与使用方式 3 个维度介绍如何对 Spark 进行性能调优。在介绍调优之前,我们先来看看如何查看 Spark 的作业日志。

日志收集
如果作业执行报错或者速度异常,通常需要查看 Spark 作业日志,Spark 日志通常是排错的唯一根据,更是作业调优的好帮手。查看日志的时候,需要注意的是 Spark 作业是一个分布式执行的过程,所以日志也是分布式的,联想到 Spark 的架构,Spark 的日志也分为两个级别:

Driver
Executor
一般来说,小错误通常可以从 Driver 日志中定位,但是复杂一点的问题,还是要从 Executor 的执行情况来判断。如果我们选取 yarn-client 的模式执行,日志会输出到客户端,我们直接查看即可,非常方便。我们可以用下面这种方式收集,如下:

复制代码
nohup ./bin/spark-submit
–class org.apache.spark.examples.SparkPi
–master yarn
–deploy-mode client
–executor-memory 20G
–num-executors 50
/path/to/examples.jar
1000 >> o &
其中 nohup 和 & 表示后台执行,>> o 表示将日志输出到文件 o 中。

查看 Executor 的日志需要先将散落在各个节点(Container)的日志收集汇总成一个文件。以 YARN 平台为例:

复制代码
yarn logs -applicationId application_1552880376963_0002 >> o
application_1552880376963_0002 是 Spark 作业 id,当汇聚为一个文件后,我们就可以对其进行查看了。打开文件,我们发现这份日志是这样组织的:
container_0

WARN…
ERROR…
…(日志内容)

container_1

…(日志内容)

这也非常好理解,本来就是从各个 Container 中收集并拼接的,但是这种方式给我们定位造成了一定障碍。阅读这样的日志,最重要的是找到最开始报错的那一句日志,因为一旦作业报错,几乎会造成所有 Container 报错,但大部分错误日志都对定位原因没有什么帮助。所以拿到这份日志要做的第一件事是利用时间戳和 ERROR 标记定位最初的错误日志。这种方式通常可以直接解决一半以上的报错问题。

硬件配置与资源管理平台
构建 Spark 集群的硬件只需普通的商用 PC Server 即可,由于 Spark 作业对内存需求巨大,建议配置高性能 CPU、大内存的服务器,以下是建议配置:

内存:256G

CPU:Intel E5-2640v4

硬盘:3T * 8

该 CPU 是双路 6 核心,且具有超线程技术,所以一个 CPU 相当于有 2 * 6 * 2 = 24 核心。对于交换机的选择,通常,如果在生产环境使用,那么无论集群规模大小,都应该直接考虑万兆交换机,对于上千的集群,还需要多台交换机进行堆叠才能满足需求。

Spark 基于资源管理平台运行,该平台对于 Spark 来说就像一个资源池一样,资源池的大小取决于每个物理节点有多少资源供资源管理平台调度。一般来说,每台节点应预留 20% 的资源保证操作系统与其他服务稳定运行,对于前面提到的机器配置,加入资源池的内存为 200G,CPU 为 20 核。假设使用 YARN 作为资源管理平台,相关配置如下:

复制代码
yarn.nodemanager.resource.memory-mb = 200G
yarn.nodemanager.resource.cpu-vcores = 20
假设 YARN 集群中有 10 个 NodeManager 节点,那么总共的资源池大小为 2000G、200 核。在 Spark 作业运行时,用户可以通过集群监控页面来查看集群 CPU 使用率,如果发现 CPU 使用率一直维持在偏低的水平,可以尝试将 yarn.nodemanager.resource.cpu-vcores 改大。内存与 CPU 资源设置应该维持一个固定的比例,如 1:5,这样在提交作业时,也按照这个比例来申请资源,可以提高集群整体资源利用率。

一般来说,YARN 集群中会运行各种各样的作业,这样资源利用率会比较高,但是也经常造成 Spark 作业在需要时申请不到资源,这时可以采取 YARN 的新特性:基于标签的调度,在某些节点上打上相应的标签,来实现部分资源的隔离。

这部分内容对于工程师与分析师来说,一般接触不到,属于大数据运维工程师职责的范畴,但是对于调优来说非常重要,有必要了解。

参数调优与应用调优
本课时主要从使用层面来介绍调优,其中会涉及参数调优、应用调优甚至代码调优。

  1. 提高作业并行度
    在作业并行程度不高的情况下,最有效的方式就是提高作业并行程度。在 Spark 作业划分中,一个 Executor 只能同时执行一个 Task ,一个计算任务的输入是一个分区(partition),所以改变并行程度只有一个办法,就是提高同时运行 Executor 的个数。通常集群的资源总量是一定的,这样 Executor 数量增加,必然会导致单个 Executor 所分得的资源减少,这样的话,在每个分区不变的情况下,有可能会引起性能方面的问题,所以,我们可以增大分区数来降低每个分区的大小,从而避免这个问题。

RDD 一开始的分区数与该份数据在 HDFS 上的数据块数量一致,后面我们可以通过 coalesce 与 repartition 算子进行重分区,这其实改变的是 Map 端的分区数,如果想改变 Reduce 端的分区数,有两个办法,一个是修改配置 spark.default.parallelism,该配置设定所有 Reduce 端的分区数,会对所有 Shuffle 过程生效,此外还可以直接在算子中将分区数作为参数传入,绝大多数算子都有分区数参数的重载版本,如 groupByKey(600) 等。在 Shuffle 过程中,Shuffle 相关的算子会构建一个哈希表,Reduce 任务有时会因为这个表过大而造成内存溢出,这时就可以试着增大并行程度。

  1. 提高 Shuffle 性能
    Shuffle 是 Spark 作业中关键的一环,也是性能调优的重点,先来看看 Spark 参数中与 Shuffle 性能有关的有哪些:

复制代码
spark.shuffle.file.buffer
spark.reducer.maxSizeInFlight
spark.shuffle.compress
根据课时 11 的内容,第 1 个配置是 Map 端输出的中间结果的缓冲区大小,默认 32K,第二个配置是 Map 端输出的中间结果的文件大小,默认为 48M,该文件还会与其他文件进行合并。第三个配置是 Map 端输出是否开启压缩,默认开启。缓冲区当然越大,写入性能越高,所以有条件可以增大缓冲区大小,可以提升 Shuffle Write 的性能,但该参数实际消耗的内存为 C * spark.shuffle.file.buffer,其中 C 为执行该任务的核数。在 Shuffle Read 的过程中,Reduce Task 所在的 Executor 会按照 spark.reducer.maxSizeInFlight 的设置大小去拉取文件,这需要创建内存缓冲区来接收,在内存足够大的情况下,可以考虑提高 spark.reducer.maxSizeInFlight 的值来提升 Shuffle Read 的效率。spark.shuffle.compress 配置项默认为 true,表示会对 Map 端输出进行压缩。

Spark Shuffle 会将中间结果写入到 spark.local.dir 配置的目录下,可以将该目录配置多路磁盘目录,以提升写入性能。

  1. 内存管理
    Spark 作业中内存主要有两个用途:计算和存储。计算是指在 Shuffle,连接,排序和聚合等操作中用于执行计算任务的内存,而存储指的是用于跨集群缓存和传播数据的内存。在 Spark 中,这两块共享一个统一的内存区域(M),如下图所示:

图片1.png

用计算内存时,存储部分可以获取所有可用内存,反之亦然。如有必要,计算内存也可以将数据从存储区移出,但会在总存储内存使用量下降到特定阈值(R)时才执行。换句话说,R 决定了 M 内的一个分区,在这个分区中,数据不会被移出。由于实际情况的复杂性,存储区一般不会去占用计算区。

这样设计是为了对那些不使用缓存的作业可以尽可能地使用全部内存;而需要使用缓存的作业也会有一个区域始终用来缓存数据,这样用户就可以不需要知道其背后复杂原理,自己根据实际内存需求来调节 M 与 R 的值,以达到最好效果。下面是决定 M 与 R 的两个配置:

spark.memory.fraction,该配置表示 M 占 JVM 堆空间的比例,默认为 0.6,剩下 0.4 用于存储用户数据结构、Spark 中的内部元数据并防止在应对稀疏数据和异常大的数据时出现 OutOfMemory 的错误;
spark.memory.storageFraction,该配置表示 R 占 M 的比例,默认为 0.5,这部分缓存的数据不会被移出。
上面两个默认值基本满足绝大多数作业的使用,在特殊情况可以考虑设置 spark.memory.fraction 的值以适配 JVM 老年代的空间大小,默认 JVM 老年代在不经过设置的情况下占 JVM 的 2/3,所以这个值是合理的。

Spark Executor 除了堆内存以外,还有非堆内存空间,这部分通过参数spark.yarn.executor.memoryoverhead 进行配置,最小为 384MB,默认为 Executor 内存的 10%。所以整个Executor JVM 消耗的内存为:

复制代码
spark.yarn.executor.memoryoverhead + spark.executor.memory
其中:

复制代码
M = spark.executor.memory * spark.memory.fraction
R = M * spark.memory.storageFraction
此外,Spark 还有可能会用到堆外内存 O:

复制代码
O = spark.memory.offHeap.size
所以整个 Spark 的内存管理布局如下图所示:

图片2.png

用户需要知道每个部分的大小应如何调节,这样才能针对场景进行调优。这其实是 Spark 实现的一种比较简化且粗粒度的内存调节方案。如果用户想要更精细地调整内存的每个区域,就需要在参数中 spark.executor.extraClassPath 配置 Java 选项了,这种方式只针对富有经验的工程师,对于普通用户来说不太友好。

  1. 序列化
    序列化是以时间换空间的一种内存取舍方式,其根本原因还是内存比较吃紧,我们可以优先选择对象数组或者基本类型而不是那些集合类型来实现自己的数据结构,fastutil 包提供了与 Java 标准兼容的集合类型。除此之外,还应该避免使用大量小对象与指针嵌套的结构。我们可以考虑使用数据 ID 或者枚举对象来代替字符串键。如果内存小于 32GB,可以设置 Java 选项 -XX:+UseCompressedOops 来压缩指针为 4 字节,以上是需要用到序列化之前可以做的调优工作,以节省内存。

对于大对象来说,可以使用 RDD 的 persist 算子并选取 MEMORY_ONLY_SER 级别进行存储,更好的方式则是以序列化的方式进行存储。这相当于用时间换空间,因为反序列化时会造成访问时间过慢,如果想用序列化的方式存储数据,推荐使用 Kyro 格式,它比原生的 Java 序列化框架性能优秀(官方介绍,性能提升 10 倍)。Spark 2.0 已经开始用 Kyro 序列化 shuffle 中传输的字符串等基础类型数据了。

要想使用 Kyro 序列化库,要将需要序列化的类在 Kyro 中注册方可使用。使用步骤如下。

编写一个注册器,实现 KyroRegister 接口,在 Kyro 中注册你需要使用的类:
复制代码
public static class YourKryoRegistrator implements KryoRegistrator {
public void registerClasses(Kryo kryo) {
在Kryo序列化库中注册自定义的类
kryo.register(YourClass.class, new FieldSerializer(kryo, YourClass.class));
}
}
设置序列化工具并配置注册器:
复制代码
……
spark.config(“spark.serializer”, “org.apache.spark.serializer.KryoSerializer”)
spark.config(“spark.kryo.registrator”, YourKryoRegistrator.class.getName())
5. JVM垃圾回收(GC)调优
通常来说,那种只读取 RDD 一次,然后对其进行各种操作的作业不太会引起 GC 问题。当 Java 需要将老对象释放,为新对象腾出空间时,需要追踪所有 Java 对象,然后在其中找出没有使用的那些对象。GC 的成本与 Java 对象数量成正比,所以使用较少对象的数据结构会大大减轻 GC 压力,如直接使用整型数组,而不选用链表。通常在出现 GC 问题的时候,序列化缓存是首先应该尝试的方法。

由于执行计算任务需要的内存和缓存 RDD 的内存互相干扰,GC 也可能成为问题。这可以控制分配给 RDD 缓存空间来缓解这个问题。

GC 调优的第 1 步是搞清楚 GC 的频率和花费的时间,这可以通过添加 Java 选项来完成:

复制代码
-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
在 Spark 运行时,一旦发生 GC,就会被记录到日志里。

为了进一步调优 JVM,先来看看 JVM 如何管理内存。Java 的堆空间被划分为 2 个区域:年轻代、老年代,顾名思义,年轻代会保存一些短生命周期对象,而老年代会保存长生命周期对象。年轻代又被划分为 3 个区域:一个 Eden 区,两个 Supervisor 区,如下图所示。简单来说,GC 过程是这样的:当 Eden 区被填满后,会触发 minor GC,然后 Eden 区和 Supvisor1 区还存活的对象被复制到 Supervisor2 区,如果某个对象太老或者 Supervisor2 区已满,则会将对象复制到老年代中,当老年代快满了,则会触发 full GC。

图片3.png

在 Spark 中,GC 调优的目的是确保只有长生命周期的对象才会保存到老年代中,年轻代有充足的空间来存储短生命周期对象。这会有助于避免执行 full GC 来回收任务执行期间生成的临时对象,有以下几个办法:

通过收集到的 GC 统计信息来检查是否有过多 GC,如果任务在完成之前多次触发 full GC,则意味着没有足够的内存可用于执行任务;
如果 minor GC 次数过多,但并没有 major GC,可以为 Eden 区分配更多的内存来缓解,可以将 Eden 区大小预估为每个任务需要的内存空间,如果 Eden 区的大小为 E,则可以使用选项 -Xmn = 4 / 3 * E 来设置年轻代大小,增加的 1/3 为 Supervisor 区;
如果通过收集到的 GC 统计信息,发现老年代快满了,可以通过 spark.memory.fraction 来减少用于缓存的内存空间;少缓存一点总比执行缓慢好,也可以考虑减少年轻代的大小,这可以通过设置 -Xmn 来实现,也可以设置 JVM 的 NewRatio 参数,该参数表示老年代与年轻代之间的比值,许多 JVM 默认为 2(2:1),这意味着老年代占堆空间的 2/3。该比例应该要大于 spark.memory.fraction 所设置的比例;
在某些 GC 是瓶颈的情况下,可以通过 -XX:+UseG1GC 开启 G1GC,可以提高 GC 性能,对较大的堆,可能需要增加 G1 区大小,使用 -XX:G1HeapRegionSize=n 来进行设置;
如果任务从 HDFS 读取数据,则可以以此来估计任务使用的内存量,解压缩块大小通常是 HDFS 数据块大小(假设为 256MB)的 2~3 倍,如果希望有足够 3~4 个任务内存空间,则 Eden 区大小为 4 * 3 * 256MB;每当我们对 Java 选项做出调整后,要通过监控工具来查看 GC 花费的时间和频率是否有变化。可以通过在作业中设置 spark.executor.extraJavaOptions选项来指定执行程序的 GC 选项以及 JVM 内存各个区域的精确大小,但不能设置 JVM 堆大小,该项只能通过 --executor-memory 或者 spark.executor.memory 来进行设置。
6. 将经常被使用的数据进行缓存
如果某份数据经常会被使用,可以尝试用 cache 算子将其缓存,有时效果极好。

  1. 使用广播变量避免 Hash 连接操作
    在进行连接操作时,可以尝试将小表通过广播变量进行广播,从而避免 Shuffle,这种方式也被称为 Map 端连接。

  2. 聚合 filter 算子产生的大量小分区数据
    在使用 filter 算子后,通常数据会被打碎成很多个小分区,这会影响后面的执行操作,可以先对后面的数据用 coalesce 算子进行一次合并。

  3. 根据场景选用高性能算子
    很多算子都能达到相同的效果,但是性能差异却比较大,例如在聚合操作时,选择 reduceByKey 无疑比 groupByKey 更好;在 map 函数初始化性能消耗太大或者单条记录很大时,mapPartition 算子比 map 算子表现更好;在去重时,distinct 算子比 groupBy 算子表现更好。

  4. 数据倾斜
    数据倾斜是数据处理作业中的一个非常常见也是非常难以处理的一个问题。 正常情况下,数据通常都会出现数据倾斜的问题,只是情况轻重有别而已。数据倾斜的症状是大量数据集中到一个或者几个任务里,导致这几个任务会严重拖慢整个作业的执行速度,严重时甚至会导致整个作业执行失败。如下图所示:

Lark20200602-171237.png

可以看到 Task A 处理了绝大多数数据,其他任务执行完成后,需要等待此任务执行完成,作业才算完成。对于这种情况,可以采取以下几种办法处理:

过滤掉脏数据
很多情况下,数据倾斜通常是由脏数据引起的,这个时候需要将脏数据过滤。

提高作业的并行度
这种方式从根本上仍然不能消除数据倾斜,只是尽可能地将数据分散到多个任务中去,这种方案只能提升作业的执行速度,但是不能解决数据倾斜的问题。

广播变量
可以将小表进行广播,避免了 Shuffle 的过程,这样就使计算相对均匀地分布在每个 Map 任务中,但是对于数据倾斜严重的情况,还是会出现作业执行缓慢的情况。

将不均匀的数据进行单独处理
在连接操作的时候,可以先从大表中将集中分布的连接键找出来,与小表单独处理,再与剩余数据连接的结果做合并。处理方法为:如果大表的数据存在数据倾斜,而小表不存在这种情况,可以将大表中存在倾斜的数据提取出来,并将小表中对应的数据提取出来,这时可以将小表中的数据扩充 n 倍,而大表中的每条数据则打上一个 n 以内的随机数作为新键,而小表中的数据则根据扩容批次作为新键,如下图所示:

图片4.png

这种方式可以将倾斜的数据打散,从而避免数据倾斜。

对于那种分组统计的任务,可以通过两阶段聚合的方案来解决,首先将数据打上一个随机的键值,并根据键的哈希值进行分发,将数据均匀的分散到多个任务中去,然后在每个任务中按照真实的键值做局部聚合,最后再按照真实的键值分发一次,得到最后的结果,如下图所示,这样,最后一次分发的数据已经是聚合过后的数据,就不会出现数据倾斜的情况。这种方法虽然能够解决数据倾斜的问题但只适合聚合计算的场景。

图片5.png

小结
本课时介绍了如何从几个方面对 Spark 作业进行调优。调优之前,看作业日志是基本功,这个没有什么捷径,只能多看。调优这个话题是一个很个性化的问题。对于离线计算任务,时间当然很重要,但不一定是最重要的。通常来说,对于实时处理,时间通常是唯一优化目标,如果执行时间有优化的空间,当然会不遗余力地进行优化。但是,对于离线计算任务,如 Spark 作业,作业执行时间并没有那么重要。通常,这类作业都是在夜深人静的晚上执行,1 小时与 90 分钟真的差异就那么大吗,不一定,所以对于离线计算作业来说,作业执行时间并不是最重要的,开发效率同样重要。

这里给你留一个思考题:通过 Spark Web UI 查看作业的执行情况。

未来的趋势,一定是 Spark 越来越智能,越来越简单,Spark 希望开发人员专注于业务,而对框架则无须过多关注。

第16讲:Tungsten 和 Hydrogen:Spark 性能提升与优化计划

在前面的课时中,我们学习了 Spark 的用法和原理,今天这个课时主要介绍 Spark 两个比较重要的优化提升项目,从这两个项目中可以看出 Spark 的优化思路。

这节课与前面的课时有所不同,主要介绍一些比较细的优化思路,其中很多也与开发数据库的思路不谋而合,你可以换换脑筋,无法完全理解也没关系,可以作为阅读一些论文、参与开源社区讨论的基础。

Tungsten 项目
Tungsten 项目产生的原因是由于固态硬盘和万兆交换机的普及和应用,I/O 性能的大幅提升使得 CPU 和内存成了大数据处理中的新瓶颈。例如一个中等规模的集群(50~100台),在某些大型作业执行过程中,网络 I/O 和硬盘 I/O 经常会接近其性能理论值,而 CPU 的使用率却很难长期维持在一个很高水平。基于此,Spark 开发团队希望开发一个新的 Spark 核心执行引擎来尽可能地压榨出 CPU 和内存的性能极限。2015 年,Tungsten 项目诞生了。

1.内存管理
Tungsten 旨在利用应用程序语义显式管理内存,消除 JVM 对象模型和垃圾回收的开销。Spark 选择 JVM 来负责内存管理,JVM 的垃圾回收器(Garbage Collector,GC)会不停地监控某个对象是否还有活跃的引用,如果没有,垃圾回收器会回收该对象并释放为其分配的内存。而对对象的引用通常存在于堆中的某些对象里或者作为变量存放于栈里,前者存活时间较长,后者存活时间较短。

另外,JVM 对象开销一向是很大的,例如字符串采用 UTF-8 编码,还有一些对象 header 等信息,这样就容易引起内存不足,降低 CPU 访问数据的吞吐量。JVM 的垃圾回收器是一个非常复杂的组件,同时它的设计思路和面对的场景通常都是针对在线事务处理(OLTP)系统,如交易系统,而 Spark 的使用场景则偏向于在线分析处理(OLAP)系统和离线计算系统,这两种场景对于性能需求差别非常大,因此利用 JVM 的垃圾回收器来应对 Spark 面对的场景,必然无法令人满意。

Tungsten 的目的就是摆脱 JVM 的垃圾回收器,自己管理内存。尽管在过去十几年中,对于那些普通用途的字节码,JVM 的垃圾回收器在预测对象生命周期方面取得了很好的效果,但是 Spark 比谁都清楚哪些数据需要留在内存中,哪些需要从内存中移除,这种情况下选择 JVM 管理内存,无疑不是最好的选择,这也是利用应用语义显式管理内存的意义所在,因此,Tungsten 绕过了 JVM 提供的安全内存托管系统,而使用了 sun.misc.Unsafe 包中的类,它允许 Tungsten 自主管理其内存。使用 Unsafe 类构建的数据结构在存储和访问性能上也大大优于 JVM 对象模型。

2.缓存感知计算
现代计算机系统使用 64 位地址指针指向 64 位内存块。而 Tungsten 也总是使用 8 字节的数据集来和 64 位内存块对齐。在 CPU 内核和内存之间,有一个 L1、L2 和 L3 高速分层存储,它们随着 CPU 数量增加而增加。通常,L3 在所有核心之间共享。如果你的 CPU 内核要求将某个主存储器地址加载到 CPU 内核的寄存器(寄存器是 CPU 内核中的一个存储区),那么首先会在 L1~L3 缓存中检查是否包含请求的内存地址。我们将与这种地址相关联的数据称为存储器页。如果是这种情况,则略过主存储器访问,并且从 L1,L2 或 L3 高速缓存中直接加载该页。否则,该页从主存储器加载,会导致更高的延迟。延迟太高,CPU 内核会等待(或执行其他工作)多个 CPU 时钟周期,直到主存储器页被传送到 CPU 内核的寄存器中。此外,该页也被放入所有高速缓存中,并且如果它们是满的,则从高速缓存中删除较不频繁访问的存储器页。因此我们得出两个结论:

在计算过程中多次访问存储页,缓存才有意义。
由于缓存远小于主存,它们只包含主存储器页的子集,因此,为了从缓存中受益,需要一个暂时结束的访问模式,因为如果在计算任务的末期才访问同一个页面,那么它可能已经从缓存中被去除掉了。
基于上述结论,缓存淘汰和预取策略十分关键。当然,现代计算机系统不仅使用最近最少使用算法(Least Recently Use,LRU)从缓存中删除缓存的存储页,还会保留下那些虽然缓存时间长但很有可能被再次请求的存储页。另外,现代 CPU 还会预测将来的存储页请求,从而将该存储页预取至缓存中。不管怎样,应始终避免随机存储访问模式,通常越顺序存储访问执行得越快。

那么我们应该如何避免随机存储访问模式呢?让我们来看看 java.util.HashMap 。顾名思义,键(key)对象的散列值(value)会被用来将对象分组到桶中。散列(hash)的副作用是:哪怕键值差别非常细微,散列值也会不一样,并会导致被分组到相应的桶中。每个桶可以被看成指向存储在映射表中的链表指针(pointer)。这些指针指向的是随机内存区域。因此,顺序扫描是不可能的,如下图所示:

图片1.png

你可能会发现这些指针指向的对象都位于主存储(Java 堆)的随机区域中。为了提升顺序扫描性能,Tungsten 采取了不同的办法:指针不仅存储目标值内存地址,还会保存键本身。在前面,我们已经了解了 UnsafeRow 的概念,8 字节的存储区域用来保存两个整型值,例如,键和指向值的指针。这种存储布局如下图所示。

图片2.png

这样,就可以运行具有顺序存储访问模式的排序算法(如快速排序)。当排序时,键和指针的组合存储区域会被到处移动,存储值的地方却不会变。虽然这些值可以随机分布在存储器中,但是键和指针的组合存储区域被以顺序布局,如下图所示。

图片3.png

3.代码生成
下面这段代码的逻辑很简单,可以理解为做了一个向量的内积,i、j 都来自某个向量。

复制代码
val i = 23
val j = 5
var z = ix+jy
假设 x 和 y 都来自表中的某一行。现在,假设将表达式应用到表中的每一行中,而这个表有数十亿行,JVM 只能执行这个表达式数十亿次,这是一个非常大的开销。因此 Tungsten 实际做的是将这个表达式转换为字节码,并将其发送到执行者线程中。
你可能知道,每个类在 JVM 上执行的都是字节码,这是针对不同微处理器架构的机器代码的一个中间层,这是 Java 的特点之一。因此 JVM 的工作流如下:

1、Java 源代码被编译为字节码;

2、Java 字节码被 JVM 翻译;

3、JVM 将字节码转换成特定平台的机器指令,并将其发送到目标 CPU。

目前,还没有人想过在运行时直接生成字节码,这就是代码生成想要实现的。Tungsten 分析将要被执行的任务生成由人编写的,在 JVM 上执行的特定的高性能字节码,而不是依赖预编译组件。

Tungsten 还有助于加速序列化与反序列化对象,JVM 提供的原生框架性能较差。而分布式数据处理框架的性能瓶颈通常在 Shuffle 阶段,在这个阶段中,数据通过网络传输,对象的序列化与反序列化是主要瓶颈(而不是 I/O 带宽),它同时也增加了 CPU 负担。因此提高这里的性能有助于消除计算瓶颈。

4、Catalyst 优化器
Catalyst 优化器是 Spark SQL 的重要组成部分,它是一个函数式可扩展的查询优化器,贯穿于查询计划的生成到最后执行计划的生成,对 Spark SQL 优化起到了至关重要的作用,在Tungsten 中,Catalyst 优化器也得到了优化和提升。在关系型数据库系统中,通常认为查询优化器是其最为复杂的核心组件,Spark SQL 也是如此,在 Catalyst 优化器的帮助下,Spark 开发者只需要编写简单的 SQL 就能驱动非常复杂的查询作业,并能获得最佳性能表现。

那么 Catalyst 优化器是如何工作的?下面这张图展示了优化器核心组件和顺序调优的过程:

图片4.png

首先,无论是 DataFrame API、Dataset API 还是 Spark SQL,它们都会被转换为 ULEP(Unresolved Logical Execution Plan,未解析的逻辑执行计划)。ULEP 本质上就是一棵 SQL 语法树,生成了 ULEP 后还不能直接执行,而是通过一系列工作对 ULEP 进行处理。当ULEP 在数据目录(Catalog)中补齐了字段类型、列名等时,就会成为 RLEP(Resolved Logical Execution Plan,解析好的逻辑执行计划)。

RLEP 会经过多次转换生成 Optimised Logical Plan(优化的逻辑计划),该计划不会包含如何计算的描述,而只包含必须被计算的内容。根据一些策略,优化的 LEP 会转化为 PEP(Physical Execution Plans,物理执行计划)。PEP 是完全解析的执行计划。这意味着一个 PEP 包含生成期望结果的详细指令。生成 PEP 的策略会对连接算法进行优化。此外,对那些在一个 RDD 上执行的多个操作,会根据规则简化为一个复杂操作。在生成了很多 PEP 后(它们都会返回相同的结果),最好的选择是基于启发式算法来最小化执行时间。最后,执行操作会作用于 RDD 上。

在数据源支持的情况下,某些操作可以被下压到数据源,如过滤(谓词)或者属性选择(投影)。谓词下压的主要思路是,某些抽象语法树的部分不由 Spark 来执行,而是由数据源本身来执行,这样就减少了 Spark 与数据库的数据传输。

从 Tungsten 可以看出,使用 DataFrame、Dataset 和 Spark SQL 处理数据,可以看成是一种从底层高度优化的 RDD 执行方案。 这种优化是全方位的,不仅仅体现在执行计划上,还体现存储、计算方式上。

Hydrogen 项目
Hydrogen 项目与 Tungsten 项目一样,都是对 Spark 有巨大提升的前沿探索项目,Hydrogen 项目从 Spark 2.3 开始,历经 Spark 2.4 以及 Spark 3.0。Hydrogen 项目出现的背景是,目前机器学习框架与深度学习框架开始井喷,而 Spark 的野心在于一统整个数据科学领域,所以也乐见其成,Spark 对这些框架的态度是“拥抱机器学习生态系统,并将其视为一等公民”,由此,Spark 需要将涉及数据预处理以及模型训练等整个流程深度地与这些机器学习、深度学习框架进行集成,这也是 Hydrogen 项目的目标。

为了实现这个目标,也就是高效地支持绝大部分机器学习框架,Spark 面临两大挑战:数据交换与执行模型。我们来看看 Hydrogen 项目是如何解决的。

1、数据交换
数据交换指的是在 Spark 与机器学习框架之间高吞吐地传输数据。Spark 提出了一种用户自定义函数(UDF),用来执行用户任意的代码。这种 UDF 通常用来与机器学习框架进行集成,例如使用 TensorFlow 对测试数据进行预测。UDF 支持各种语言,如 Scala、Python、R 等,UDF 可以很方便地使 Spark 与机器学习框架进行集成,用户可以在 UDF 中写一段代码来调用机器学习库。在使用 UDF 时,我们可以采用一次一行的方式执行,如下图所示:

图片5.png

上图中包含一个简单的 Python UDF,对输入进行 +1 操作,它将对每一行的第一列进行 +1 操作,数据首先被 Spark 一次一行地读取,并在 Spark 中进行列切分,将第一列发送给 Python 进程,Python 进程接收到输入以后,对输入进行 +1 操作,并返回给 Spark,Spark 得到结果并将其和原来的两列拼接成新的一行,也就是上图中右边的那一行。Spark 一共要执行 3 次操作,直到所有数据读取完毕。如果我们深入分析这种交换方式,会发现这种交换方式的性能非常糟糕,原因是大部分的时间花在了 Spark 将数据传输给 Python,Python 又把数据传输给 Spark 上,据统计,92% 的 CPU 周期被浪费了。这当然不是我们想要的,来看看下一种交换方式:向量化的数据传输,如下图所示:

图片6.png

与一次一行的数据交换方式不同,我们采取了列式存储的小批量传输,也就是说,数据本来就是按列存储,如 ORC 或者 Parquet 这种格式,而非按行存储。Spark 会选取第一列(需要进行 +1 的列)的一个切片发送给 Python 进程,而 Python 收到的则会是一个 numpy 数组或者 panda 序列,在 UDF 中我们可以直接通过向量化操作对向量进行 +1 操作,这种计算无疑是高效的,例如 numpy 底层的数组操作由 C 语言编写,效率较原生 Python 大大提升。Spark 得到结果会按照固有的列式存储格式发送给下游。

向量化的数据交换方式在两个环节性能都有提升,其一是与 Python 进程的数据传输,其二是 UDF 的执行效率,根据 Databricks 的测试结果,整体效率较一次一行的数据交换方式有 3~240 倍的提升,效果极其明显。

2、执行模型
执行模型要解决的是,一旦 Spark 与机器学习框架进行深度融合,就会导致它与计算模型之间天生的不相容性。如果不解决这个问题,那么“一等公民”始终是一句空话。Spark 的计算模型是高度并行的,作业被划分为任务,任务与任务之间相互独立,没有依赖,如下图左边所示。

而常见的分布式机器学习框架的执行模型通常是统一调度,互相协调的,这是为了优化通信,在模型训练过程中,任务之间通常会有高吞吐和大带宽的数据交互,如下图右边所示。

图片7.png

这两种模式看起来没有什么冲突,但是一旦某个任务失败,Spark 只需重新执行该任务即可,但分布式机器学习框架通常会执行所有相关的任务。在 Hydrogen 项目的第 2 部分,Spark 在一个更高的层次提出了一种带有同步栅的执行模型(及其配套的 API),统一了 Spark 与机器学习框架的执行模型,如下图所示。

图片8.png

在这种模型中,Spark 将整个作业切分成 3 个 Stage,其中虚线表示的就是同步栅,在每个 Stage 中,并行的方式可以不同,以 Stage 2 的并行方式为例,一旦某个任务失败,将会重新执行所有任务。这种执行模型很好地融合了 Spark 与机器学习框架。

从 Hydrogen 项目的这两个部分来看,Hydrogen 项目的关键词是融合,数据交换从数据边界的层面进行了融合,而执行引擎在执行逻辑上将两种不同的分布式计算理念进行了融合,从上图中可以看出数据交换是执行引擎的基础,Stage 之间的数据交换就是利用了 Hydrogen 的数据交换的能力。

在 Spark 后面版本的迭代中,Hydrogen 项目的主要内容就是 SPARK-24579。SPARK-24579 的主要内容是标准化 Spark 和人工智能、深度学习框架,如 TensorFlow、MXNet 之间的数据交换过程,并优化其传输性能。SPARK-24579 的出发点在于,目前大数据与人工智能的结合是很多业务与应用成功的关键,而这两个领域的顶级开源社区也多次尝试整合,但由于 Spark SQL、DataFrame、Structured Streaming 的日趋成熟,Spark 仍然是大数据社区的首选,因此人工智能框架如何与 Spark 进行集成是整合的关键。当然,目前已经存在一些解决方案,如 TensorFlowOnSpark、TensorFrames 等,但是还没有一种标准化传输方案,所以性能优化只能根据具体情况来实现,SPARK-24579 所探讨的正是如何降低整个过程的复杂性:标准化 Spark 和人工智能、深度学习框架之间的数据交换接口。这样,人工智能、深度学习框架就可以利用 Spark 从任何地方加载数据,而无须花费额外的精力来构建复杂的数据解决方案。

在 JIRA 上我们还可以通过 Hydrogen 项目的标签对 issue 进行过滤,目前有 3 个没有关闭的史诗级 issue,除了SPARK-24579、SPARK-24374,还有一个 SPARK-24615,如下图所示,该 issue 也是 Hydrogen 项目的一个重要改进,将会为 Spark 添加原生的 GPU 调度支持。

image (1).png

目前,GPU 已经广泛应用于分布式深度学习与训练加速,但通常用户需要用 Spark 加载大量数据,最新的 Spark 版本已经在 YARN 和 Kubernetes 中支持 GPU 了,虽然如此,但是 Spark 本身并不知道它们暴露的 GPU,所以 Spark 用户无法正常请求和调度,SPARK-24615 将会为这类训练加速任务添加调度支持,该 issue 的目标如下。

让 Spark 3.0 在 Standalone 模式、YARN 模式和 Kubernetes 模式中具有 GPU 感知能力。
保证普通作业的调度性能。
未来,该 issue 希望达到的目标如下。

GPU 计算卡的细粒度调度。
将 GPU 计算卡和它的内存看成一个不可分割的单元。
支持 TPU。
支持 Mesos。
该 issue 想要做到的是在资源层面实现 Spark 和人工智能框架的融合和统一,同步栅模型则在执行层面上实现 Spark 和人工智能框架的融合和统一。

小结
本课时主要介绍了 Spark 开发过程中的两个重要大型优化项目,这两个项目很大程度上体现了 Spark 设计者的思路,并影响着 Spark 未来的发展方向,其中可以看到,向量化执行和代码生成都是数据库引擎的优化技术,说明 Spark 借鉴了这一部分思想为己用。

本课时的内容偏理论,所以这里给你留一个任务,你可以去 Spark Jira 看板上跟踪 Hydrogen 项目的最新进展以及 Spark 未来的发展方向,链接如下:

https://issues.apache.org/jira/projects/SPARK/。

你不要小看了这个过程,未来你也可以向社区提出自己所需的 issue,提出 issue 也是参与社区的一种重要形式。

第17讲:实战:探索葡萄牙银行电话调查的结果

本课时我们进入实战课程的演练:探索葡萄牙银行电话调查的结果。本课时主要用真实数据构建了一个数据探索场景,这既不是一个项目也不是一个应用,只是一个探索的过程。

这个过程在实际应用中是非常常见的,无论是对分析师还是工程师来说,对数据的探索都是必要的,为此,Spark 也创新地开发了 Spark Shell 应用,让编译型语言 Scala 用起来像脚本语言一样,提高了实践效率。

数据(下载链接为:https://pan.baidu.com/s/1up25t-HQF16Sx4-naC0rJw,密码为 jzke)来源于葡萄牙银行电话调查的结果,本课时会通过一些分析手段逐步对数据展开探索,直到用户得到想要的信息。本课时的内容完全是实践,没有理论,希望你动手一起做。由于案例中用到了 Dataset API,故采用 Scala 版本。

下面这段代码读取了葡萄牙银行通过电话访问进行市场调查得到的数据集,并统计了数据条数:

复制代码
import org.apache.spark.sql.types._
import org.apache.spark.sql.{SparkSession}
import org.apache.spark.sql.functions._

case class Call(age: Double, job: String, marital: String, edu: String,
credit_default: String, housing: String, loan: String,
contact: String, month: String, day: String,
dur: Double, campaign: Double, pdays: Double,
prev: Double,pout: String, emp_var_rate: Double,
cons_price_idx: Double, cons_conf_idx: Double, euribor3m: Double,
nr_employed: Double, deposit: String)

//葡萄牙银行通过电话访问进行市场调查得到数据集,以下为21个字段
//受访者年龄
val age = StructField(“age”, DataTypes.IntegerType)
//受访者职业
val job = StructField(“job”, DataTypes.StringType)
//婚姻状态
val marital = StructField(“marital”, DataTypes.StringType)
//受教育程度
val edu = StructField(“edu”, DataTypes.StringType)
//是否信贷违约
val credit_default = StructField(“credit_default”, DataTypes.StringType)
//是否有房屋贷款
val housing = StructField(“housing”, DataTypes.StringType)
//是否有个人贷款
val loan = StructField(“loan”, DataTypes.StringType)
//联系类型(移动电话或座机)
val contact = StructField(“contact”, DataTypes.StringType)
//当天访谈的月份
val month = StructField(“month”, DataTypes.StringType)
//当天访谈时间的是星期几
val day = StructField(“day”, DataTypes.StringType)
//最后一次电话联系持续时间
val dur = StructField(“dur”, DataTypes.DoubleType)
//此次访谈的电话联系的次数
val campaign = StructField(“campaign”, DataTypes.DoubleType)
//距离早前访谈最后一次电话联系的天数
val pdays = StructField(“pdays”, DataTypes.DoubleType)
//早前访谈电话联系的次数
val prev = StructField(“prev”, DataTypes.DoubleType)
//早前访谈的结果,成功或失败
val pout = StructField(“pout”, DataTypes.StringType)
//就业变化率(季度指标)
val emp_var_rate = StructField(“emp_var_rate”, DataTypes.DoubleType)
//消费者物价指数(月度指标)
val cons_price_idx = StructField(“cons_price_idx”, DataTypes.DoubleType)
//消费者信心指数(月度指标)
val cons_conf_idx = StructField(“cons_conf_idx”, DataTypes.DoubleType)
//欧元银行间3月拆借率
val euribor3m = StructField(“euribor3m”, DataTypes.DoubleType)
//员工数量(季度指标)
val nr_employed = StructField(“nr_employed”, DataTypes.DoubleType)
//目标变量,是否会定期存款
val deposit = StructField(“deposit”, DataTypes.StringType)

val fields = Array(age, job, marital,
edu, credit_default, housing,
loan, contact, month,
day, dur, campaign,
pdays, prev, pout,
emp_var_rate, cons_price_idx, cons_conf_idx,
euribor3m, nr_employed, deposit)

val schema = StructType(fields)

val spark = SparkSession
.builder()
.appName(“data exploration”)
.master(“local”)
.getOrCreate()
import spark.implicits._

//该数据集中的记录有些字段没用采集到数据为unknown
val df = spark
.read
.schema(schema)
.option(“sep”, “;”)
.option(“header”, true)
.csv("./bank/bank-additional-full.csv")

println(df.count())
运行之后的结果为:41188。接下来我们再来根据婚姻情况统计各类人群的数量和缺失值的数量。

复制代码
//该数据集将bank-additional-full.csv中原本是unknown的字段置为null
val dm = spark
.read
.schema(schema)
.option(“sep”, “;”)
.option(“header”, true)
.csv("./bank/bank-additional-full-missing.csv")
//根据婚姻情况统计各类人群的数量和缺失值的数量
dm.groupBy(“marital”).count().show()
结果为:

Drawing 0.png

现在我们再根据职业统计各类人群的数量和缺失值的数量:

复制代码
//根据职业统计各类人群的数量和缺失值的数量
dm.groupBy(“job”).count().show()
结果为:

Drawing 1.png

接下来根据教育情况统计各类人群的数量和缺失值的数量:

复制代码
//根据教育情况统计各类人群的数量和缺失值的数量
dm.groupBy(“edu”).count().show()
结果为:

Drawing 2.png

下面我们选取数值类字段作为数据子集,进行描述性统计:

复制代码
//选数值类字段作为数据子集,进行描述性统计(包括频次统计,平均值,标准差,最小值,最大值)
val dsSubset=dm.select(“age”,“dur”,“campaign”,“prev”,“deposit”).cache()
//通过描述性统计,可以对数据进行快速地检查。比如,频次统计可以检查数据的有效行数,年龄的平均值和范围可以判断数据样本是不是符合预期。通过均值和方差可以对数据进行更深入地分析,比如,假设数据服从正态分布,年龄的均值和标准差表明了受访者的年龄大多在30~50 之间。
dsSubset.describe().show()
结果为:

Drawing 3.png

下面这段代码判断了变量间的相关性:

复制代码
//判断变量间相关性,计算变量间的协方差和相关系数,协方差表示两变量的变化方向相同或相反。age和dur的协方差为-2.3391469421265874,表示随着受访者的年龄增加,上一次访问时长减少。
println(dsSubset.stat.cov(“age”,“dur”))
结果为:-2.3391469421265874。

接下来计算相关系数:

复制代码
//相关系数(Pearson系数)表示变量间的相关程度。age和dur的相关系数为-8.657050101409117E-4,呈较弱的负相关性。
println(dsSubset.stat.corr(“age”,“dur”))
结果为:8.657050101409117E-4。

下面计算每个年龄段的婚姻状态分布:

复制代码
//交叉表,通过交叉表可以知道在每个年龄段的婚姻状态分布
ds.stat.crosstab(“age”,“marital”).orderBy(“age_marital”).show(20)
结果为:

Drawing 4.png

下面这段代码展示了所有受访人的学历背景出现频率超过 0.3 的学历:

复制代码
//所有受访人的学历背景出现频率超过0.3的学历
println(ds.stat.freqItems(Seq(“edu”),0.3).collect()(0))
结果为:
[WrappedArray(high.school, university.degree, professional.course)]。

下面计算受访用户年龄的分位数:

复制代码
//四分位数,第三个参数0.0表示相对误差
df.stat.approxQuantile(“age”,Array(0.25,0.5,0.75),0.0)
.foreach(println)
结果为:

Drawing 5.png

接下来则需要根据定期存款意愿将客户分组,并进一步进行分析:

复制代码
//聚合函数分析
//根据定期存款意愿将客户分组,并统计各组客户的客户总数,此次访谈的电话联系的平均次数,最后一次电话联系的平均持续时间,早前访谈电话联系的平均次数
dsSubset
.groupBy(“deposit”)
.agg(count(“age”).name(“Total customers”),
round(avg(“campaign”),2).name(“Avgcalls(curr)”),
round(avg(“dur”),2).name(“Avg dur”), round(avg(“prev”),2).name(“AvgCalls(prev)”)).withColumnRenamed(“value”,“TDSubscribed?”)
.show()
结果为:

Drawing 6.png

这段代码根据年龄将客户分组,并进一步进行分析:

复制代码
//根据年龄将客户分组,并统计各组客户的客户总数,此次访谈的电话联系的平均次数,最后一次电话联系的平均持续时间,早前访谈电话联系的平均次数
dsSubset
.groupBy(“age”)
.agg(count(“age”).name(“Total customers”),
round(avg(“campaign”),2).name(“Avgcalls(curr)”),
round(avg(“dur”),2).name(“Avg dur”),
round(avg(“prev”),2).name(“AvgCalls(prev)”)).orderBy(“age”)
.show()
结果为:

Drawing 7.png

小结
数据分析师通过上面这个过程,就可以对数据大致的质量、分布、基本统计信息有了一个基本的印象,这样探索的目的也就达到了。在很多情况下,一些分析师在数据量不大的情况下也喜欢用 Spark 来分析数据,这得益于 DataFrame API 与 Datasets API 的简洁与功能完善。这份数据中,有趣的地方还有很多,你不妨用 Python DataFrame API 与 Spark SQL 尽情探索吧。

第18讲:流处理:什么是流处理?以及你必须考虑的消息送达保证问题

本课时是流处理模块的第一课时,通过前面的模块实战,相信你对 Spark 数据处理能力已经有了一个感性的认识。在本模块,将着重介绍另一类处理场景:流处理与相应的解决办法。

本课时的主要内容有:

什么是流处理;
消息送达保证。
什么是流处理
在前面说到,Spark 为大数据处理提供了一整套解决方案,当然流处理也在其中。大数据的 4V 特征之一就是“Velocity(速度)”,它说明数据产生和流动的速度与以往不可同日而语,但数据所蕴含的价值却会随着时间的流逝而迅速降低,如监测预警、实时反欺诈、实时风险管理、网络攻击、计算广告等业务场景,如何快速而精确地捕获数据中所蕴含的价值是流处理面临的挑战。

流处理的概念从一诞生起,就得到了业界的热捧。从最开始雅虎 S4,到 Apache Storm、Spark Streaming,再到目前的 Structured Streaming、Flink、Apex,开源社区起到了决定性作用。纵观整个流处理的发展历程,似乎并没有一种技术像 Spark、MapReduce 那样在各自的时期独霸业界,更多的是用户根据自己的需求采用不同的技术,这或多或少都对功能和性能进行了一定取舍,这也是流处理技术非常有意思的一点,详细的内容我们将在后面进行介绍。

在开始介绍流处理相关概念以及具体技术前,先用一个例子让你对流处理有一个感性认识。还是以单词计数为例,输入的数据不再是 HDFS 上的文本文件,而来自网络套接字的数据流。

代码如下:

复制代码
import org.apache.spark.SparkConf
import org.apache.spark.streaming.StreamingContext
import org.apache.spark.streaming.Seconds

object SparkStreamingWordCount {

def main(args: Array[String]): Unit = {

val conf = new SparkConf()
.setMaster("local[2]")
.setAppName("SparkStreamingWordCount")

val ssc = new StreamingContext(conf, Seconds(1))

// Spark Streming将套接字作为其输入源
val lines = ssc.socketTextStream("localhost", 9999)
// 按照空格切分生成单词集合
val words = lines.flatMap(_.split(" "))
val pairs = words.map(word => (word, 1))
// 按单词计数
val wordCounts = pairs.reduceByKey(_ + _)
wordCounts.print()

ssc.start()
ssc.awaitTermination()

}
}
如上所示,我们以 local 方式启动一个 Spark Streaming 作业,监听 9999 端口的输入,并按单词进行计数。运行此作业,会发现 Spark Streaming 已经在等待 9999 端口的输入了,如下图所示:

image.png

接下来还需要对 9999 端口进行输入,我们选用 netcat 命令(nc,在 Windows 环境需要额外安装),打开命令行执行下面的命令:

复制代码
Windows: nc -l -p 9999
Linux: nc -lk 9999
然后我们就可以在命令行界面下输入 hello world,这时 Spark Streaming 会实时输出:

image (1).png

这样,一个简单的流处理作业就完成了。注意,在以 local 方式提交时必须用 local[n] 的形式,且 n 需要大于 2。当 n = 1 时,Spark Streaming 的 Receiver 会消耗掉所有的计算资源,从而无法开始真正的业务处理。

下面是同样逻辑的 Python 版代码:

复制代码
from pyspark import SparkContext
from pyspark.streaming import StreamingContext
sc = SparkContext(“local[2]”, “SparkStreamingWordCountPython”)
ssc = StreamingContext(sc, 1)
lines = ssc.socketTextStream(“localhost”, 9999)
words = lines.flatMap(lambda line: line.split(" "))
pairs = words.map(lambda word: (word, 1))
wordCounts = pairs.reduceByKey(lambda x, y: x + y)
wordCounts.pprint()
ssc.start()
消息送达保证
在上一个例子中,数据(消息)在计算节点随着处理过程在不停流动,计算节点从它的上游收到数据,经过处理又发向下游计算节点,在这个过程中,如何保证当前节点一定能收到上游计算节点发送的处理结果是一个非常重要的问题,因为它直接影响了流处理结果的正确性。我们也称其为消息送达保证(delivery guarantee)问题,对于消息送达保证,业界一般有以下 3 种语义。

至少送达一次(at least once),下游节点一定会收到一次上游节点发过来的消息,但也可能会接收到重复的消息。
至多送达一次(at most once),下游节点不一定会收到上游节点发来的消息。这意味着,有可能上游节点发送的消息,下游节点丢失了,但上游节点不会重发。
恰好送达一次(exact once),下游节点一定会且只会收到一次上游节点发来的消息。这当然是所有用户最希望的,但对于某些用户来说可能不是必需的。
对于这 3 种解决方案,当然流处理技术应该以恰好送达一次为目标进行设计,因为如果没有这种消息送达保证,那么对于支付场景、计算广告场景(点击计费)等来说就无法保证结果的正确性,这对于业务方来说是不可接受的。但也有很多时候,业务场景可能只需要至少送达一次的保证,那么这个时候就需要进行取舍。

在谈论消息送达保证这个话题时,其实不能孤立地看待它,我们可以将其看成两个问题:即发送消息的可靠性保障和消费消息的可靠性保障。前者指的其实是可靠发送,而后者指的是可靠接收并处理。想要达到“恰好送达一次”的效果,需要这两者同时满足。很多系统声称自己提供“恰好一次”的解决方案,但当我们仔细研究其原理时,会发现并不准确,因为它们没有解释消费者(下游计算节点,消息接收者)或者生产者(上游计算节点,消息发送者)在发送或接收失败时,还如何能保证消息“恰好一次”地传递。

在大数据架构中,经常会使用到消息队列,从某种意义上说,消息队列是一种最简单的流处理系统,先以 Kafka 为例,来理解下消息送达保证的实现。Kafka 的数据流如下图所示。

18.png

如前所述,我们将其分为两个阶段:生产者生产消息,消费者消费消息。只有这两个阶段都满足了“恰好一次”的语义,整个过程才能满足“恰好一次”的语义。我们先从生产者的角度来看,目前的 Kafka 采用异步方式发送消息,当消息被提交后,Kafka 会异步将其发送给 Broker,发送成功后会回调发送 ACK,如果没有则重试,直到收到 ACK 为止,消息会有一个主键,在Broker 端会做幂等处理,不会导致数据出现重复,这样就算在提交过程中,出现了网络故障,导致消息不能及时发送,最终还是能保证发送消息“恰好一次”的语义效果。我们现在再从消费者的角度来看,所有副本都保存有相同的日志以及偏移量(offset),假设由消费者控制偏移量在日志中的位置。当消费者读取了几条数据时,有下面两种情况:

读取消息,然后在日志中保存偏移量位置,最后处理消息。但有可能消费者保存了偏移量位置之后,在处理消息输出之前崩溃了。在这种情况下,接管处理的进程会在已保存的位置开始,即使该位置之前有几个消息尚未处理。这就达到了“至多一次”的效果,在消费者处理失败消息的情况下,不进行处理。
读取消息,处理消息,最后保存消息的位置。在这种情况下,可能消费进程处理消息之后,在保存偏移位置之前崩溃了。当新的进程重新接管时,将接收已经被处理的前几个消息。这就达到了“至少一次”的效果。
如果想实现“恰好一次”的语义,那么需要做的其实是将输出处理消费数据的结果和修改偏移量这两个操作放在一个事务里,就可以保证“恰好一次”语义,最完美的解决方案是采取经典的两段式提交,但很多消费者不支持两段式提交,并且两段式提交会影响性能表现。有一个变通的办法就是将消费者的输出与消费者的偏移量存储在一个地方,这就避免了分布式事务,当然这会引起一些不必要的麻烦,更常见的会使用幂等输出,来实现“恰好一次”的效果。

这里假设 Kafka Broker 永远是可用的,不会有丢数据的风险,考虑到日志的副本机制,这个假设是合理的。

上图其实展示了一个最简单的流处理实例,也说明了其实如果要很完美地做到“恰好一次”,需要从两个方面努力:发送消息可靠性和接收处理消息可靠性。而接收消息可靠性要做到“恰好一次”,往往又需要对消息的状态进行持久化(此处指的是偏移量)。

对于上图的数据流程,站在消费者的角度,其实是一个输入-处理-输出的过程,这里其实涉及 3 个部分:数据源(数据输入)、计算框架(处理)和数据存储(数据输出),而当我们说到 Storm、Spark Streaming 实现“恰好一次”消息送达语义时,都只是停留在计算框架这个层面,但是一旦涉及整个流程,那么问题就必须重新审视了,这也提出了一个新的问题:端到端(end-to-end)的消息送达保证,而只有解决了端到端的消息送达保证,才是真正解决了“恰好一次的消息送达语义”。

小结
在企业和组织里,流处理的场景越来越普遍。本课时通过一个小例子介绍了流处理,最后引出了一个流处理中非常重要的问题:消息一致性,端到端的一致性通常来说涉及输入-处理-输出的全过程。在后面的课时,我们可以看到 Spark 是如何解决这个问题的。

最后给你留一个思考题:

如果希望统计一段时间内(比如说 5s)每个单词的数量,上面的程序应该怎么写?

第19讲:批处理还是流处理:Spark Streaming 抽象、架构与使用方法

spark Streaming 是 Spark 0.7 推出的流处理库,代表 Spark 正式进入流处理领域,距今已有快 6 年的时间。在这段时间中,随着 Spark 不断完善,Spark Streaming 在业界已得到广泛应用,应该算是目前最主要的流处理解决方案之一。随着 Spark 2.2 的 Structured Streaming 正式推出,Spark 下一代流处理技术已经呼之欲出。但是由于目前的客观情况,Structured Streaming 的成熟度还不太高,大量的流处理应用不可能也没有必要马上迁移至 Structured Streaming,所以 Spark Streaming 在今后一段时间还将继续活跃。另外,它的架构和它的抽象也值得我们学习和深思。在本节我们将会学习 Spark Streaming,主要内容有:

Spark Streaming 关键抽象与架构;

转换算子。

关键抽象与架构
要想深入理解 Spark Streaming,首先还是要了解 Spark Streaming 的关键抽象 DStream,DStream 意指 Discretized Stream(离散化流),它大体上来说是一个 RDD 流(序列),其元素(RDD)可以理解为从输入流生成的批。在流处理的过程中,用户其实就是在对 DStream 进行各种变换,最后再输出。如下图所示,可以看出 Spark Streaming 的输入是连续的,经过 Spark Streaming 接收后,会变成一个 RDD 序列,之后的处理逻辑是基于 DStream 来操作的。

image

DStream 的生成依据是按照时间间隔切分,该时间间隔的数据会生成一个微批(mini-batch),即一个 RDD,所以该间隔也被称为批次间隔,DStream 里面持有对所有产生的 RDD 的引用,虽然 RDD 和 DStream 非常像,在种类上基本都是一一对应的,如 UnionDStream 与 UnionRDD,但是 DStream 还是和 RDD 有本质不同,如下图所示。

image

具体表现为:

复制代码
RDD = DStream @ batch T
DStream = RDD range (tn-1,tn)
RDD 是 DStream 中某个批次的数据,而 DStream 代表了一段时间所产生的 RDD。所以通过这种方式,Spark Streaming 把对连续流的处理,变成了对批序列 DStream 的处理。我们会在 StreamingContext 设置批次间隔大小,一般大于 200ms。

如果我们仔细思考的话,会发现,这种抽象将连续的流看成了某一时刻静止的批,所以在提到 Spark Streaming 中的 RDD 时,一定要注意它还有个时间维度,最准确的说法是某段时间内的 RDD。

最后,可以看到 Spark Streaming 对流的抽象本质上还是流,只是处理是基于批来处理的。这与 Structured Streaming 来说是不同的,我们在后面会讲到。

Spark Streaming 在架构上与 Spark 离线计算架构非常相似,主要分为 Driver 与 Executor,同样可以运行在 Yarn、Mesos 上,也能够以 standalone 和 local 模式运行。它们之间的关系仍旧是 Driver 负责调度,Executor 执行任务。如下图所示。

image

在 Driver 中,有几个关键模块,SparkStreamingContext 、DStreamGraph、JobScheduler、Checkpoint、ReceiverTracker,下面我将就这几个模块分别介绍。

SparkStreamingContext:SparkStreamingContext是一开始在用户代码中初始化完成的。它主要的工作是对作业进行一些配置,例如 DStream 切分的批次间隔(Duration),以及与其他模块进行交互,如 DStreamGraph 和 JobScheduler 等。

DStreamGraph:既然 Spark Streaming 最后还是对批的处理,那么批处理中根据计算逻辑生成的 RDD DAG 也是存在的,它由 DStreamGraph 生成。DStreamGraph 维护了输入 DStream 与输出 DStream 的实例,还会通过 generateJobs 方法生成一个作业集合(RDD DAG),它会由 JobScheduler 调度启动执行任务。

JobScheduler:JobScheduler 顾名思义是 Spark Streaming 的作业调度器,在创建 SparkStreamingContext 的同时,JobScheduler 也会作为它的一部分被创建,所有的任务都是最后由它调度 Executor 来执行。

Checkpoint:在 Spark 中,任何一个 RDD 丢失,都可以通过依赖关系重新计算得到, Checkpoint 是 Spark Streaming 容错机制的核心,会定时对已算好的中间结果以及其他中间状态进行存储,避免了依赖链过长的问题。这样就算某个 DStream 丢失了,也不用从头开始计算,只需从最近的依赖关系开始计算即可。

ReceiverTracker:ReceiverTracker 通过 Executor 上的 ReceiverSupvisor 来管理所有的 Receiver。主要功能是把需要计算的数据发送给 Executor。当 Executor 接收完毕后,也会将数据块的元数据上报给 ReceiverTracker。

从上面这几个组件的关系上来说,SparkStreamingContext 负责与其他组件交互,DStreamGraph 与 JobScheduler 负责调度,Checkpoint 负责容错,ReceiverTracker 负责与 Executor 进行数据交互。

Executor 是具体的任务执行者,其中重要的组件有 Receiver、ReceiverSupvisor、ReceiveredBlockHandler,ReceiverTracker 会和 Executor 通信,启动 ReceiverSupvisor 实例,ReceiverSupvisor 会马上启动 Receiver 开始接收数据。Receiver 接收到数据后,用 ReceiverdBlockHandler 以块的方式写到 Executor 的磁盘或者内存,对应的实现是 BlockManagerBasedBlockHandler 和 WriteAheadLogBasedBlockHandler,前者是根据 Executor 的 StorageLevel 写到相应的存储层,后者会先进行预写日志(Write Ahead Log),其中,后者能对流式数据源提供更好的容错性。数据接收完毕后,会根据调度开始计算任务。

Spark Streaming 的作业初始化与提交和 Spark SQL 作业有些不同,我们还是通过初始化 SparkSession 的方式得到 StreamingContext 的引用,再对其设置一个关键参数:批次间隔后,就可以进行数据接收和数据处理的动作。

无状态的转换算子
基于上面的抽象,对流进行处理与批处理就没什么不同了,我们只着眼于此刻正在处理的这个时间范围内的 RDD,所以数据处理方式与批处理并没有什么不同,算子也与批处理没多大区别,算子作用与数据流中的每个 RDD,这类算子我们称之为无状态算子,如下图所示:

image

我们在使用无状态算子时,仍然要注意,每次处理的结果都隐含着“这是…时间范围内数据的处理结果”的含义。

这类算子与前面介绍的转换算子没什么不同,如 map、mapPartitions、reduceByKey、reduce、flatmap、glom、filter、repartition、union 等等,这里就不重复描述了。

有状态的转换算子
在实际工作场景中,默认的时间间隔很难满足流处理的业务需要,比如想对 DStream 中的某几个 RDD 进行操作,或者是想保存一些中间结果做增量计算,就需要运用到另一类转换算子:有状态的转换算子。

有状态的转换算子主要分为两种,一种是基于时间窗口,另一种是基于整个时间跨度。本课时将对其进行介绍。

基于时间窗口的概念其实早就深植于 Spark Streaming 中,我们在设置批次间隔时间(如 1 s)时,本质上就是设置了一个时间窗口,在用户代码中的计算逻辑其实是作用在每一个在该批次间隔中形成的 RDD 上,上一个 RDD 和这一个 RDD 的计算结果不会互相影响。当我们需要对若干批次的数据处理结果进行聚合的时候,就需要设置一个更大的时间窗口,如下图所示。

image

时间窗口是由批次间隔组成的有限时间跨度,基于窗口的操作对窗口中所有数据进行处理。此外,窗口是一个逻辑的概念,它可以进行滑动,图中所示的窗口跨度为 3,滑动步长为 2,滑动意味着每隔多少时间,窗口会被触发一次,每批次数据与窗口的对应关系为一对多,意味着某个批次的数据可以存在于多个窗口中。这里注意窗口间隔与滑动步长都必须是 DStream 批次间隔的整数倍。

基于窗口的转换算子主要有 slice、window、countByWindow、reduceByWindow、reduceByKeyAndWindow、countByValueAndWindow 等。

slice 算子
def slice(interval: Interval): Seq[RDD[T]] 和 def slice(fromTime: Time, toTime: Time): Seq[RDD[T]]:slice 算子返回该时间跨度内的 RDD 集合,批次间隔可以用 Interval 进行定义,也可以用起始时间与结束时间来定义,注意,开始时间与结束时间需要是批次间隔的倍数,否则系统会自动进行取整。该算子相当于在整个 DStream 流中截取了一段。

window 算子
window(windowDuration: Duration): DStream[T] 和 window(windowDuration: Duration, slideDuration: Duration): DStream[T]:window 算子定义了窗口的属性,如跨度(windowDuration)和滑动步长(slideDuration),并返回一个新的 DStream,默认的滑动步长为批次间隔。当我们通过 window 算子定义了滑动窗口以后,可以用使用 join 算子进行连接操作,例:

复制代码
import org.apache.spark.streaming.StreamingContext
import org.apache.spark.streaming.Seconds
import org.apache.spark.streaming.dstream.ConstantInputDStream
import org.apache.spark.sql.SparkSession
import org.apache.spark.streaming.dstream.DStream.toPairDStreamFunctions

object SparkStreamingJoin {

   def main(args: Array[String]): Unit = {

     val spark = SparkSession
     .builder
     .master("local[2]")
     .appName("SparkStreamingJoin")
     .getOrCreate()

     val sc = spark.sparkContext

     val ssc = new StreamingContext(sc, batchDuration = Seconds(2))

     val leftData = sc.parallelize(0 to 3)

     val leftStream = new ConstantInputDStream(ssc, leftData)
 
     val rightData = sc.parallelize(0 to 2)
 
     val rightStream = new ConstantInputDStream(ssc, rightData)

     // 连接的DStream窗口需要有相同的Duration或者其中一个DStream的Duration是另一个的整数倍
     val rightWindow = rightStream.map(f => (f,f)).window(Seconds(2),Seconds(4))

     val leftWindow = leftStream.map(f => (f,f)).window(Seconds(6),Seconds(4))

     leftWindow.join(rightWindow).print()

     ssc.start()

     ssc.awaitTermination()

 }

}
这里的 join 要求两个窗口的滑动步长必须一致。

window 算子很重要的用法是与无状态算子配合使用,使其结果满足需要的时间跨度限制。

reduceByWindow 算子
● def reduceByWindow(reduceFunc: (T, T) => T,windowDuration: Duration,slideDuration: Duration): DStream[T]和def reduceByWindow(reduceFunc: (T, T) => T,invReduceFunc: (T, T) => T,windowDuration: Duration,slideDuration: Duration): DStream[T]:按照 reduceFunc 的逻辑对滑动窗口中的数据进行聚合。

后一个 reduceByWindow 是前一个的重载版本,不同之处在于增加了反函数(invReduceFunc)作为参数。反函数存在的作用在于优化那些增量计算的逻辑,如下图所示,假设 reduceFunc 的作用是对窗口内数据进行累计求和,那么在没有 invReduceFunc 的情况下,计算逻辑是 DStream 每个批次的 RDD 先按照 reduceFunc 的逻辑做一次 reduce,然后在达到窗口触发条件时再做一次同样逻辑的 reduce 操作,但是我们可以发现两个窗口互相重叠的时间区间的数据(此处为 RDD@time3)在之前的窗口已经聚合过了,是没有必要再重新计算的,而反函数版本的 reduceByWindow 则针对此处做了优化,反函数的根本作用在于求重复计算部分的值(此处为 RDD@time3)。

image

反函数的参数 (a, b) 所代表的含义为:a 为之前的时间窗口(此处为 window@time3)聚合的结果,b 为之前的时间窗口与当前时间窗口没有重叠部分的聚合结果(此处为 RDD@time1 与 RDD@time2),那么按照反函数的作用,正确反函数应该为 (a,b) => a - b,Spark Streaming 最后再将反函数的计算结果(RDD@time3)与当前时间窗口剩余的数据(此处为 RDD@time4 与 RDD@time5)进行聚合,得到当前窗口的聚合结果(此处为 window@time5)。不难发现,在时间窗口本身跨度很大,且两个时间窗口互相重叠的部分也很大时,反函数版本的 reduceByWindow在计算时性能会大大优于普通版本的 reduceByWindow。

reduceByKeyAndWindow算子
def reduceByKeyAndWindow(reduceFunc: (V, V) => V, windowDuration: Duration): DStream[(K, V)]、def reduceByKeyAndWindow(reduceFunc: (V, V) => V, windowDuration: Duration,slideDuration: Duration,partitioner: Partitioner): DStream[(K, V)]和def reduceByKeyAndWindow(reduceFunc: (V, V) => V, invReduceFunc: (V, V) => V,windowDuration: Duration,slideDuration: Duration = self.slideDuration,numPartitions: Int = ssc.sc.defaultParallelism,filterFunc: ((K, V)) => Boolean = null): DStream[(K, V)]:通过 reduceFunc 的化简逻辑,reduceByKey 的算子会根据 K 对窗口的数据进行分组聚合,返回化简结果。同时,还可以指定 reduce 任务的个数与 Shuffle 的逻辑。另外 reduceByBeyAndWindow 也有反函数的版本。

countByWindow 算子
def countByWindow(windowDuration: Duration, slideDuration: Duration): DStream[Long]:countByWindow 算子计算滑动窗口中数据的数量。

下面我们来看看该算子的实现:

复制代码
def countByWindow(windowDuration: Duration,slideDuration: Duration): DStream[Long] = ssc.withScope {
this.map(_ => 1L).reduceByWindow(_ + _, _ - _, windowDuration, slideDuration)
}
可以看到 countByWindow 先将每行数据转换成 1,最后再用 reduceByWindow 进行累计求和,它默认就采取了反函数的版本,这也是官方推荐的。

countByValueAndWindow 算子
def countByValueAndWindow(windowDuration: Duration, slideDuration: Duration, numPartitions: Int = ssc.sc.defaultParallelism)(implicit ord: Ordering[T] = null): DStream[(T, Long)]:对每个滑动窗口的数据执行 countByValue 的操作。底层实现也是调用了 reduceByKeyAndWindow 的反函数版本。

介绍了基于窗口的转换算子,我们发现基于窗口的转换操作还是有其局限性,当我们想要对某个键的状态进行整个时间段追踪时,基于窗口就不是那么方便了。

所以我们还需要另外一种有状态的转换操作:mapWithState 与 updateStateByKey,mapWithState 是 Spark 1.6 以后的新特性,官方宣称性能是 updateStateByKey 的十倍,可以认为是 updateStateByKey 的升级版。这两种算子类似于定义一个全局累加器,每个批次的数据处理结果都会将其更新,这样就能得到整个时间段下该 key 的状态值(中间结果)。以 wordcount 为例:

复制代码
import org.apache.spark.SparkConf
import org.apache.spark.streaming._
import org.apache.spark.streaming.dstream.DStream.toPairDStreamFunctions
import org.apache.spark.sql.SparkSession

object StatefulNetworkWordCount {

  def main(args: Array[String]) {

    val spark = SparkSession
     .builder
     .master("local[2]")
     .appName("StatefulNetworkWordCount")
     .getOrCreate()

    val sc = spark.sparkContext

    val ssc = new StreamingContext(sc, Seconds(1))
    ssc.checkpoint(".")
 
    val initialRDD = ssc.sparkContext.parallelize(List(("hello", 1), ("world", 1)))
 
    val lines = ssc.socketTextStream(args(0), args(1).toInt)
    val words = lines.flatMap(_.split(" "))
    val wordDstream = words.map(x => (x, 1))
 
    // 该函数定义了状态更新的逻辑
    val mappingFunc = (word: String, one: Option[Int], state: State[Int]) => {
      val sum = one.getOrElse(0) + state.getOption.getOrElse(0)
      val output = (word, sum)
      state.update(sum)
      output
    }
 
    val stateDstream = wordDstream.mapWithState(
      StateSpec.function(mappingFunc).initialState(initialRDD))

    stateDstream.print()

    ssc.start()
    ssc.awaitTermination()

  }

}
例子中的核心就是 mappingFunc 的函数,定义了状态更新的逻辑。updateStateByKey 的用法大同小异,也是通过定义状态更新函数来体现状态的变化。用法如下:

复制代码
……
val updateFunc = (values: Seq[Int], state: Option[Int]) => {
val currentCount = values.sum
val previousCount = state.getOrElse(0)
Some(currentCount + previousCount)
}

val newUpdateFunc = (iterator: Iterator[(String, Seq[Int], Option[Int])]) => {
iterator.flatMap(t => updateFunc(t._2, t._3).map(s => (t._1, s)))
}
 
val stateDstream = wordDstream.updateStateByKey[Int](newUpdateFunc,  new HashPartitioner(ssc.sparkContext.defaultParallelism), true, initialRDD)

这两种实现同样功能的算子在性能上差异巨大的原因在于,updateStateByKey 的原理是将上次计算结果与新批次数据采用 cogroup 操作再进行聚合,而 mapWithState 则是通过维护一个中间状态表,存储上一次计算的结果与当前批次的计算结果,所以直接进行聚合处理即可。

小结
本课时介绍了 Spark Streaming 的关键抽象与架构,Spark Streaming 沿用了 RDD 原有的抽象,将流处理变成了连续的微批处理。如果把原有的数据处理看成是一维的,那么流处理无疑是二维的:它加入了一个很重要的时间维度,并且处理的需求往往与时间维度紧密相关,这就使得虽然 Spark Streaming 仍然使用了 RDD 的抽象,但转换算子分为了有状态和无状态之分。

最后给你留一个思考题:

如何每 2 分钟统计一次最近 5 分钟出现过的每个单词的数量?

第20讲:如何在生产环境中使用 Spark Streaming

上一课时中,我们学习了 Spark Streaming 的抽象、架构以及数据处理方式,但是流处理的输入是动态的数据源,假设在出错是常态的情况下,如何在动态的数据流中仍然兼顾恰好一次的消息送达保证(结果正确性),是生产环境中必须考虑的问题。

本课时的主要内容有:

输入和输出

容错与结果正确性

输入和输出
Spark Streaming 作为一个流处理系统,对接了很多输入源,除了一些实验性质的输入源,如ConstantInputDStream(每批次都为常数集合)、socketTextStream(监听套接字作为输入)、textFileStream(本地文件作为输入,常常用来监控文件夹),在生产环境中用得最多的还是 Kafka 这类消息队列。在本课时中,我们选用 Kafka 0.8 版本,介绍 Spark Streaming 与 Kafka 集成,这是在生产环境中最常见的一种情况。

在 Spark 中,为连接 Kafka 提供了两种方式,即基于 Receiver 的方式和 Kafka Direct API。这两种方式在使用上大同小异,但原理却截然不同,先来看看基于 Receiver 的方式:

复制代码

val kafkaParams = Map[String, Object](
“bootstrap.servers” -> “localhost:9092,anotherhost:9092”,
“key.deserializer” -> classOf[StringDeserializer],
“value.deserializer” -> classOf[StringDeserializer],
“group.id” -> “groupId”,
“auto.offset.reset” -> “latest”,
“enable.auto.commit” -> (true: java.lang.Boolean)
)

val topics = Array(“topicA”, “topicB”)
val messages = KafkaUtils.createDirectStream[String, String](
ssc,
PreferConsistent,
Subscribe[String, String](topics, kafkaParams)
)

messages.map(record => (record.key, record.value))
用这种方式来与 Kafka 集成,配置中设置了 enable.auto.commit 为 true,表明自己不需要维护 offset,而是由 Kafka 自己来维护(在 Kafka 0.10 后,默认的 offset 存储位置改为了 Kafka,实际上就是 Kafka 的一个 topic),Kafka 消费者会周期性地(默认为 5s)去修改偏移量。这种方式接收的数据都保存在 Receiver 中,一旦出现意外,数据就有可能丢失,要想避免丢失的情况,就必须采用 WAL(Write Ahead Log,预写日志)机制,在数据写入内存前先进行持久化。

现在我们来试想一种情况,数据从 Kafka 取出后,进行了 WAL,在这个时候,Driver 与 Executor 因为某种原因宕机,这时最新偏移量还没来得及提交,那么在 Driver 恢复后,会从记录的偏移量继续消费数据并处理 WAL 的数据,这样一来,被 WAL 持久化的数据就会被重复计算一次。因此,开启了 WAL 后,这样的容错机制最多只能实现“至少一次”的消息送达语义。而且开启 WAL 后,增加了 I/O 开销,降低了 Spark Streaming 的吞吐量,还会产生冗余存储。这个过程如下图所示。

11.png

如果业务场景对“恰好一次”的消息送达语义有着强烈的需求,那么基于 Receiver 的方式是无法满足的,基于 Spark Streaming 提供的 Direct API 形式,克服了这一缺点。Direct API 是 Spark 1.3 后增加的新特性,相比基于 Receiver 方法的“间接”,这种方式更加“直接”。

在这种方式中,消费者会定期轮询 Kafka,得到在每个 topic 中每个分区的最新偏移量,根据这个偏移量来确定每个批次的范围,这个信息会记录在 Checkpoint 中,当作业启动时,会根据这个范围用消费者 API 直接获取数据。这样的话,就相当于把 Kafka 变成了一个文件系统,而 offset 的范围就是文件地址,Direct API 用这种方式将流式数据源变成了静态数据源,再利用 Spark 本身的 DAG 容错机制,使所有计算失败的数据均可溯源,从而实现了“恰好一次”的消息送达语义。**请注意,Direct API 不需要采用WAL预写日志机制,因为所有数据都相当于在 Kafka 中被持久化了,作业恢复后直接从 Kafka 读取即可,**如下图所示:

12.png

这种方式带来的优点显而易见,不仅克服了 WAL 带来的效率缺陷,还简化了并行性,使用 Direct API,Spark Streaming 会为每个 Kafka 的分区创建对应的 RDD 分区,这样就不需要使用 ssc.union() 方法来进行合并了,这也便于理解和调优。另外,这样的架构还保证了输入-处理阶段的“恰好一次”的消息送达语义,这就类似于消息的“回放”,虽然目前 Kafka 本身不支持消息回放,但用这种方式间接地实现了消息回放的功能。下面我们来看一个使用 Direct API 的完整例子:

复制代码
import org.apache.spark.SparkConf
import org.apache.spark.streaming.StreamingContext
import org.apache.spark.streaming.Seconds
import org.apache.spark.streaming.kafka010.LocationStrategies
import org.apache.spark.streaming.kafka010.KafkaUtils
import org.apache.spark.streaming.kafka010.ConsumerStrategies
import org.apache.spark.streaming.kafka010.HasOffsetRanges
import org.apache.spark.streaming.kafka010.CanCommitOffsets
import org.apache.spark.sql.SparkSession

object SparkStreamingKafkaDirexct {

def main(args: Array[String]) {

val spark = SparkSession
.builder
.master("local[2]")
.appName("SparkStreamingKafkaDirexct")
.getOrCreate()

val sc = spark.sparkContext

val ssc = new StreamingContext(sc, batchDuration = Seconds(2))

// Kafka的topic
val topics = args(2)

val topicsSet: Set[String] = topics.split(",").toSet

// Kafka配置参数
val kafkaParams: Map[String, Object] = Map[String, String](
  "metadata.broker.list" -> "kafka01:9092,kafka02:9092,kafka03:9092",
  "group.id" -> "apple_sample",
  "serializer.class" -> "kafka.serializer.StringEncoder", 
  // 自动将偏移重置为最新的偏移,如果是第一次启动程序,应该为smallest,从头开始读
  "auto.offset.reset" -> "latest"
) 

// 用Kafka Direct API直接读数据
val messages = KafkaUtils.createDirectStream[String, String](
    ssc,
    LocationStrategies.PreferConsistent,
    ConsumerStrategies.Subscribe[String, String](topicsSet, kafkaParams)
 )

// 在该批次数据处理完之后,将该offset提交给Kafka,“...”代表用户自己定义的处理逻辑
messages.map(...).foreachRDD(mess => { 
   // 获取offset集合
   val offsetsList = mess.asInstanceOf[HasOffsetRanges].offsetRanges
     asInstanceOf[CanCommitOffsets].commitAsync(offsetsList)
   }
)

ssc.start()
ssc.awaitTermination()

}

}
从上面这段代码中我们可以发现,首先关闭了自动提交偏移量,改由手动维护。然后再从最新的偏移量开始生成 RDD,经过各种转换算子处理后输出结果,最后用 commitAsync 异步向 Kafka 提交最新的偏移量。一旦使用了 Direct API,用户需要追踪到结果数据输出完成后,再提交偏移量的改动,否则会造成不确定的影响。使用这种方式,无法在事务层面保证处理-输出这个阶段做到“恰好一次”,因此只能采用输出幂等的方式来达到同样的效果。

如果想要在事务的层面,让处理-输出这个阶段做到“恰好一次”,那么可以将 Kafka 的偏移量与最终结果存储在同一个数据库实例上,这就需要修改代码,一开始,需要从外部数据库上获取最新的偏移量:

复制代码

//从外部数据库获取偏移量
val fromOffsets: Map[TopicPartition, Long] = setFromOffsets(offsetList)

// 用最新的offset得到初始化RDD
val messages = KafkaUtils.createDirectStream[String, String](ssc,
LocationStrategies.PreferConsistent,
ConsumerStrategies.Subscribe[String, String](topicsSet, kafkaParams, fromOffsets))
在最后输出的操作里,由于偏移量与最终数据处理结果要保存到同一个数据库,因此可以利用外部数据库的事务特性,完成最后的工作:

复制代码
messages.map(…).foreachRDD(mess => {
// 获取offset集合
val offsetsList = mess.asInstanceOf[HasOffsetRanges].offsetRanges
// 将修改offset与输出最后结果作为一个事务提交
// transaction{
// yourUpdateTheOffset(offsetsList)
// yourOutputToDatabase(mess)
// }
}
)
这样一来,Spark Streaming 才算是真正实现了端到端的消息送达保证。

在实际开发中,将偏移量和输出结果存储到同一个外部数据库的方式用得并不多,因为这会使业务数据与消息数据耦合在一起,结构不够优雅,反而幂等输出更加流行。

最后,来看看 Spark Streaming 的输出操作。Spark Streaming 也是懒加载模式,同样需要类似于 RDD 的行动算子才能真正开始运行,在 Spark Streaming 中,我们称其为输出算子,一共有下面这几种。

print():打印 DStream 中每个批次的前十个元素。

saveAsTextFiles(prefix, [suffix]):将 DStream 中的内容保存为文本文件。

saveAsObjectFiles(prefix, [suffix]):将 DStream 中的内容保存为 Java 序列化对象的 SequenceFile。

saveAsHadoopFiles(prefix, [suffix]):将 DStream 中的内容保存为 Hadoop 序列化格式(Writable)的文件,可以指定 K、V 类型。

foreachRDD(func):该算子是 Spark Streaming 独有的,与 transform 算子类似,都是直接可以操作 RDD,我们可以利用该算子来做一些处理工作,例如生成 Parquet 文件写入 HDFS、将数据插入到外部数据库中,如 HBase、Elasticsearch。

容错与结果正确性
介绍了 Spark Streaming 的架构、用法之后,在本课时中,将会讨论 Spark Streaming 的容错机制,以及结果的正确性保证。要想 Spark Streaming 应用能够全天候无间断地运行,需要利用 Spark 自带的 Checkpoint 容错机制。Checkpoint 会在 Spark Streaming 运行过程中,周期性地保存一些作业相关信息,这样才能让 Spark Streaming 作业从故障(例如系统故障、JVM 崩溃等)中恢复。值得注意的是,作为 Checkpoint 的存储系统,是必须保证高可用的,常见的如 HDFS 就很可靠,更优的选择则是 Alluxio。

Checkpoint 主要保存了以下两类信息,其中元数据检查点主要用来恢复 Driver 程序,数据检查点主要用来恢复 Executor 程序。下面我们来分别介绍一下这两类信息:

元数据检查点。元数据主要包括。

配置:创建该 Spark Streaming 应用的配置。

DStream 算子:Spark Streaming 作业中定义的算子。

未完成的批次:那些还在作业队列里未完成的批次。

Checkpoint 会周期性地将这些信息保存至外部可靠存储(如 HDFS、Alluxio)。

数据检查点。

将中间生成的 RDD 保存到可靠的外部存储中。我们在上一节中讨论过,如果要使用状态管理的算子,如 updateStateByKey、mapWithState 等,就必须开启 Checkpoint 机制,因为这类算子必须保存中间结果,以供下次计算使用。另外,我们知道 Spark 本身的容错机制是依靠 RDD DAG 的依赖关系通过计算恢复的,但是这也会造成依赖链过长、恢复时间过长的问题,因此我们必须周期性地存储中间结果(状态)至可靠的外部存储来缩短依赖链。我们也可以手动调用 DStream 的 checkpoint 算子进行缓存。如下:

复制代码
val ds: DStream[Int] = …
val cds: DStream[Int] = ds.checkpoint(Seconds(5))
Checkpoint 机制会按照设置的间隔对 DStream 进行持久化。如果需要启用 Checkpoint 机制,需要对代码做如下改动:

复制代码
val checkpointDirectory = “/your_cp_path”

def functionToCreateContext(): StreamingContext = {
val conf = new SparkConf().setMaster(“local[*]”).setAppName(“Checkpoint”)
val ssc = new StreamingContext(conf,Seconds(1))
val lines = ssc.socketTextStream(“localhost”,9999)
ssc.checkpoint(checkpointDirectory)
ssc
}

val context = StreamingContext.getOrCreate(checkpointDirectory,
functionToCreateContext _)
StreamingContext 需要以 getOrCreate 的方式初始化。这样就能保证,如果从故障中恢复,会获取到上一个检查点的信息。

如果数据源是文件,那么上面的方法可以保证完全不丢数据,因为所有的状态都可以根据持久化的数据源复现出来,但如果是流式数据源,想要保证不丢数据是很困难的。因为当 Driver 出故障的时候,有可能接收的数据会丢失,并且不能找回。为了解决这个问题,Spark 1.2 之后引入了预写日志(WAL),Spark Streaming WAL 指的是接收到数据后,在数据处理之前,先对数据进行持久化,完成这个工作的是 BlockManagerBasedBlockHandler 类的实现类WriteAheadLog BasedBlockHandler 。如果开启了 WAL,那么数据会先进行持久化再写到 Executor 的内存中。这样即使内存数据丢失了,在 Driver 恢复后,丢失的数据还是会被处理,这就实现了“至少一次”的消息送达语义。打开 WAL 的方式为:设置spark.streaming.receiver.writeAheadLog.enable 为 true。这种方法其实是对 Receiver 的容错。

那么 Checkpoint 就以这种形式完成了 Driver、Executor 和 Receiver 的容错。下面我们来讨论一下 Spark Streaming 计算结果的正确性。

先来看看消息送达保证,Spark Streaming 框架本身实现“恰好一次”的消息送达语义比较容易,因为 Spark Streaming 本质上还是进行的批处理,所以它只需在批的层面通过 BatchId 追踪数据处理情况,这和 Spark 是完全一致的,因此它完全能够保证一个批只被处理一次,当一个批没有被成功处理时,肯定就是发生了故障,这时 Checkpoint 机制能够保证从最近持久化的中间结果与待执行的计算任务(DStreamGraph)开始重新计算,保证数据只被处理一次,从而得到正确的结果,该阶段可以认为是处理阶段的消息送达保证。

在流处理场景下,容错问题与结果正确性问题不能孤立地来看待,而是需要考虑在出现故障的情况下如何能够保证结果的正确性。

小结
在本课时中,将流式处理流程抽象为输入-处理-输出,而基于这个流程,又将流程拆分为三个部分:

输入-处理

处理

处理-输出

而这每个部分,都需要假设错误会经常发生的情况下,还要保证“恰好一次”的消息送达保证,才是真正的端到端的消息送达保证,这也是生产环境中必须考虑的问题。 本课时从三个部分出发,给出了 Spark Streaming 的答案,其中处理-输出这个过程,通常会以幂等的方式解决,这也是在生产环境中非常常用的做法。

最后给你留一个思考题:

用偏移量与最终数据处理结果保存到同一个数据库,这么做的缺点是什么?

第21讲:统一批处理与流处理:Dataflow

在本模块前面的课时中,我们已经学习了 Spark Streaming 的架构、原理、用法以及生产环境中需要考虑的问题。对于 Spark Streaming 的学习,我们已经告一段落了。在学习 Spark 最新的流处理套件 Structured Streaming 之前,你有必要来看看一种新的计算模型或者范式:Dataflow,它也是 Structured Streaming、Flink、Apex 等最新技术的理论基础,从这种新的计算模型中,我们能发现不少有趣且非常重要的内容。

本课时的主要内容有:

Google MillWheel 系统

Dataflow 模型

Google MillWheel 系统
Google MillWheel(水磨轮转)系统来源于谷歌公司在 2013 年发表的一篇论文:“MillWheel: Fault-Tolerant Stream Processing atInternet Scale”,它致力于构建一种低延迟、大规模的流处理系统,用户只需定义计算拓扑和应用代码,系统会自动管理持久化状态以及连续的数据流,所有的这一切都在框架的容错保证之下。换句话说,Google MillWheel 系统是一种高吞吐、低延迟、数据不重不丢且具有容错性保证的分布式流处理框架,并且还提供了晚到和乱序数据的解决方案,可以说是下一代流处理系统的雏形。Spark 2.2 正式发布的 Structured Streaming 和 Flink 中都可以看到 Google MillWheel 的影子。Google MillWheel 的最大特色是对乱序数据与晚到数据的处理方法。

乱序和晚到数据处理方式的提出,体现了业界对于实时数据处理结果正确性不断提升的要求。在解释晚到数据之前,需要先了解两个与时间相关的概念。

事件时间(event time),事件时间指的是事件发生的时间,在消息诞生时就被系统记录在消息中。

处理时间(processing time),处理时间指的是在数据管道中处理数据时,该消息被数据处理系统观察到的时间,是数据处理系统的时间,这里并没有假设分布式系统中时钟是同步的。

在现实情况中,由于网络存在延迟、处理本身需要时间,以及数据管道内部的性能消耗等原因,会导致同一条数据的这两个时间存在差异,如下图所示:

Drawing 0.png

斜线表示的是理想情况,处理时间与事件时间完全相等,曲线表示的是实际情况,通常我们将其称之为时间域倾斜。曲线所代表的数据就是晚到数据和乱序数据,晚到数据和乱序数据一定是用户希望按照数据的事件时间顺序来处理数据才有的概念,如果只是按照处理时间来处理,晚到和乱序就无从说起了。

基于此,Google MillWheel 提出了一种低水位(low watermark)机制,作为一种解决方案。我们先来看看低水位的定义,如下图所示。

s1.png

A 和 C 是流处理计算拓扑中的两个计算单元(可以简单理解为 Spark DAG 中的两个 Executor ),C 是 A 的上游,会将数据源源不断地发送给 A,A 会维护一个时间戳,这个时间戳就是上文提到的低水位,它本质是一个边界,代表不会有晚于这个时间的数据发送给 A,如上图所示。换句话说,低水位后到达的数据很有可能已经丢失了,也没必要参与计算,流处理系统可以略过这些数据或是由应用自行处理,据谷歌表示,这部分丢掉的数据占整体数据的 0.001% 左右,考虑到晚到和乱序已经成为数据流的常态,系统也不可能无休止地等下去,这个误差还是可以接受的。

低水位不是一成不变的,就像真实情况中的水位一样,它会随着 A 处理过的数据的事件时间变化而变化。在 Google MillWheel 模型中,对低水位是这样定义的:

对于一个 C→A 的拓扑片段:
A 的低水位 = min( A 接收到但还未被处理完毕的最老的数据的事件时间,C 的低水位);
如果没有输入流,则低水位的值与最大事件时间相等。

从下图中可以看到,横轴上方是待处理的数据,横轴下方是处理完毕的数据,低水位就是最后一条待处理数据事件后的时间戳,它会随着数据流向前推进。

Drawing 2.png

在谷歌 MillWheel 中,可以看到低水位本质上是 A 的一个可变状态。 得到了低水位的值以后,就可以根据该值来判断是否触发计算,以窗口计算为例,如下图所示。

Drawing 3.png

窗口是天然存在的,时长为 5 min,窗口和窗口之间没有重叠。在 A 中接收到的数据根据其事件时间分布在这 4 个窗口中,随着水位不断上涨,当低水位超过第一个窗口的结束时间(12:05)时,根据低水位的定义,有理由相信属于该窗口的数据已经全部到达,这时就可以触发该窗口进行计算,并且在窗口中,可以根据事件时间进行顺序处理,而在低水位之后的数据,虽然事件时间属于第一个窗口(12:00-12:05),但不会触发任何计算,也就不会体现在结果中。低水位可以认为是谷歌公司提出的一个基准,它给出了一个可以容忍数据晚到的最大极限,在低水位之前到达的数据(乱序数据)会参与计算,低水位后到达的数据(晚到数据)将被丢弃。

Google Dataflow 模型
谷歌公司的“MillWheel: Fault-Tolerant Stream Processing at Internet Scale”这篇论文着重介绍了 Google MillWheel 系统如何实现,讨论范围只限于流处理的范畴,可以看成是一篇解决某个具体问题的论文。又过了两年,几乎是发表 MillWheel 的原班人马又发表了一篇论文

“The Dataflow Model: A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-Scale, Unbounded, Out-of-Order Data Processing”,这篇论文与 MillWheel 不同,抽象程度非常高,提出了 Dataflow 模型,在一个很高的层次统一了流处理和批处理的计算模型,把这两类问题变成了一个简单的选择题。Structured Streaming 和 Flink 在设计上很大程度借鉴了 Dataflow 模型的设计思想,从这个层次上来说,这两种技术没有什么不同。

作为和数据打交道的工程师,我们不能把无边界数据集切分成有边界数据集,等待一个批次完整后再做处理。相反地,我们应该假设自己永远无法知道数据流是否终结,是否有序,数据何时会变完整。 唯一应该确信的是,新的数据会源源不断,老的数据可能会被撤销或更新,能够让我们应对这个挑战唯一可行的方法是通过一个通用抽象模型,在数据处理的结果准确性、延迟程度和处理成本(这里的处理成本指的是每条数据的处理成本)之间进行取舍。这也是 Dataflow 模型的最大贡献。

这篇论文提出,对于无边界、乱序的数据,可以按照数据本身的特性、事件时间的顺序计算结果;通过以下 4 个维度对数据流进行解构,使用户可以透明地、灵活地组合它们:

计算什么(what);

根据事件时间,哪些数据会参与计算(where);

什么时候触发计算(when);

早期的计算结果如何被修正(how)。

此外,计算逻辑不再和处理的数据类型相关,也就是说,无论处理什么数据,用户只需要编写一套代码。具体来说,Dataflow 包含了:

窗口模型,可以支持非对齐窗口,提供创建并使用基于事件时间窗口的一整套 API,对于 Spark Streaming 有状态和无状态的概念都可以用窗口轻松地进行表达;

触发器模型,可以根据数据流的特征来决定何时输出计算结果的模型,并且提供了一组强有力,且足够灵活的 API 来描述触发语义,比如由低水位触发就是一种触发语义;

增量计算模型,能够将数据变化体现到上述的窗口模型和触发器模型中;

可扩展实现,基于 MillWheel 和 FlumeJava 的可扩展实现;

一系列核心原则,指导 Dataflow 设计的核心原则。

这些元素分别解答了上面四个问题:what、where、when、how。

从 Google MillWheel 系统来看,它对无边界的乱序和晚到数据处理提出了一种解决方法,下面通过几个例子来看看如何将这种解决方法泛化,并推广到不同场景中去。假设输入如下图所示。

Drawing 4.png

其中,竖轴是处理时间,也就是系统观察到数据的时间,横轴是事件时间,每条数据的值如圆圈数字所示,曲折的曲线是实际的低水位,而直线虚线是理想情况的低水位,可以看到值为 9 的数据落后于水位线,其他数据也存在不同程度的乱序。

下图是我们按照传统批处理来构建求和需求的数据管道。在传统的批处理中,并没有水位线的概念,但是在 Dataflow 的语义中,批处理也引入了水位线的概念。可以看到,在所有数据到来之前,水位线一直不动,直到系统收集到了所有数据,计算发生(下图长方形上沿),水位线开始以平行于事件时间的方向迅速移动,直到无穷远,得到结果 51。这也可以理解为,流处理系统等待所有数据到来后,再开始处理,这样水位线变化和批处理是完全一样的。

Drawing 5.png

再回到无边界数据中,如果我们采用一个全局窗口,以 MillWheel 那样的方式触发,那么用户永远也等不到结果出现,因为窗口会不断变大,所以必须采用一种新的触发方式,抑或采用别的方式进行开窗操作。

如下图所示,采取的是一种基于处理时间定期触发的方式,不断修正之前计算的结果,从触发计算的结果可以看到,每次计算包含原来窗口的计算结果并进行累计求和,这样延迟为 1 分钟,但是用户只能在最后一分钟才能得到完全正确的结果。

Drawing 6.png

下面我们基于事件时间开窗,还是采用批处理的方式,如下图所示:

Drawing 7.png

同样,传统的批处理引擎会等待所有数据到来后,再根据事件时间处理,同样也是在水位线到达窗口后触发计算产生结果。现在再来考虑下在基于事件时间的固定窗口下进行微批处理,以一分钟为一个批次。系统每分钟会对窗口中的数据进行处理,而没有像上一个例子中,等待所有数据到来后再处理。每个批次开始时,水位线会从批次开始的时间迅速上升到批次结束的时间,如下图所示。这样每个批次完成后,系统会达到一个新的水位线。我们可以看到在 12:08 的时候,微批处理方式下,3 个窗口已经分别有结果输出,而反观批处理的方式,还没有触发计算,微批处理选择了低延迟和结果的最终准确性,而批处理则选择了最高的延迟和最好的准确性。

Drawing 8.png

现在我们运用像 MillWheel 这样的流处理引擎来基于事件时间的固定窗口进行实验,如下图所示,该实验类似于 MillWheel 的执行机制。

Drawing 9.png

可以看到,当水位线一旦越过固定窗口的结束时间,就会触发计算,但是与 MillWheel 不同的是,虽然值为 9 的数据落后于水位线,但是在这里仍然触发了窗口的计算,这也是 Dataflow 的设计原则之一:永远不要依赖任何数据完整性标记。 从上图中可以看到,只有第一个窗口被触发了两次,其余窗口都只被触发了一次,这种计算方式需要等待水位线漫过窗口才会触发,因此整体上的延迟可能比微批处理系统还要差,这就是单纯依赖水位线可能引起的问题:水位线可能太慢。

那么很自然的,如果我们想降低整体延迟的话,可以考虑将微批的定期触发与 MillWheel 的水位线触发结合起来:系统会周期性地触发,并且在水位线漫过窗口时也会触发。这样整个系统的平均延迟会比微批处理系统更低,因为数据一旦到达就可能会被处理,周期性地触发也会不断进行,它是系统延迟的下限。如下图所示,这种混合的方式在结果准确性、延迟程度和处理成本之间做出了一个适合大部分需求的取舍。

Drawing 10.png

Google Dataflow 精彩的地方在于,无论我们面对的数据类型和处理方式是什么,最后都转化为对结果准确性、延迟程度和处理成本之间的取舍。 取舍也意味着三者不可兼得,像这种“不可能三角”,在实际情况中比较普遍,例如分布式系统的 CAP 原则,宏观经济学中的蒙代尔三角,或许这就是自然界中的普遍规律。但是“不可能三角”并不意味着必须取二舍一,更多情况下是在偏重两点的情况下对三者进行权衡,比如上图中混合触发的方式并没有完全放弃结果准确性、延迟程度和处理成本中的任一点。

在论文中,Dataflow 也介绍了其简洁而表现力丰富的 API,以上图为例,代码如下:

复制代码
PCollection<KV<String, Integer>> output = input
.apply(Window.into(FixedWindows.of(2, MINUTES))
.trigger(SequenceOf(
RepeatUntil(
AtPeriod(1, MINUTE),
AtWatermark()),
Repeat(AtWatermark())))
.accumulating())
.apply(Sum.integersPerKey());
在这段声明式代码中,可以看到我们定义了窗口的类型(固定窗口)、长度(2 min)和触发的机制(每分钟触发一次;水位线触发;迟到数据触发),另外 accumulating 方法控制了触发的模式,触发模式有 3 种,即累加(Accumulating)、丢弃(Discarding)、累加和撤回(Accumulating & Retracting)。Accumulating 表示窗口一旦触发后,窗口中的数据会被保留,该窗口下一次的触发结果在上一次结果的基础上更新。

你还可以选择 Discarding,该选项表示窗口触发后,窗口的数据会被丢弃,这样窗口每次计算的结果是互相独立的。此外,还可以选择 Accumulating & Retracting,该选项表示窗口的下一次触发会撤回上一次计算的结果重新进行计算,Sum.integersPerKey() 定义了窗口聚合的逻辑。用这种简单的声明方式,我们可以轻易地定义上图中触发计算的逻辑。这套 API 也很好地实现了 what、where、when、how 这四个问题的答案。

我们来看看 Dataflow 在业务场景的应用,如在支付场景中,它们采用的是 Accumulating & Retracting 触发模式加定时触发;还有一些统计场景,在这种场景下,我们希望能够在一个可以接受的时间范围内得到一个相对完整的结果,所以采用了水位线触发,如图 E 所示;而在推荐场景,结果的及时性比基于完备数据的结果有意义得多,所以在这种场景我们采用了处理时间定时触发,如图 D 所示;最后一个是异常检测,异常检测比较适合由数据驱动来进行触发计算,因为一旦异常发生,系统应该马上做出回应,同时这个场景也使用了组合触发器。

小结
本课时主要介绍了 Dataflow 模型,而本课时的内容可以算是整个课程中最重要的理论,目前最新的大数据技术 Spark 和 Flink,从设计理念上都是 Dataflow 的实现,从某种程度上来说,这两种技术也没什么不同,即使目前略有差异,最后也会殊途同归。请你一定要花时间将 Dataflow 这部分的内容吃透,那么再学习 Structured Streaming 和 Flink 就会显得异常的轻松。

Spark Streaming 是基于 RDD 与算子的组合进行编程,批处理也一样,那么流处理是否也有 DataFrame + SQL 组合,它们又是如何和本课时的内容结合呢,下个课时我将为你解答这些问题。

最后给你留一个思考题:

在上一课时,我们将端到端的过程拆分为:输入- 处理 - 输出,那么在处理阶段,Dataflow 是如何实现“恰好一次”的消息送达语义呢?

第22讲:新一代流式计算框架:Structured Streaming

作为 Spark Streaming 的技术继任者,Structured Streaming 出现得其实有点晚,但是得益于 Spark 庞大的用户群体和社区,Structured Streaming 仍然是实时处理领域一种非常有竞争力和前景的技术。

从前面的 Spark Streaming 可以看到,Spark Streaming 仍然采用 RDD 与算子的组合进行编程。这其实是 Spark 官方不推荐的,它的缺点也显而易见,而 Structured Streaming 的改进是由内而外的,它不仅参考了 MillWheel 系统与 Dataflow 模型,并且还引入 DataFrame 与 SQL 作为自己的编程接口。

本课时的主要内容有:

Structured Streaming 的关键抽象与架构

操作

输入与输出

Structured Streaming 抽象与架构
前面提过,Spark Streaming 的本质是将数据流抽象成流,以微批的方式进行处理,而 Structured Streaming 则不同,它采用的是 Google Dataflow 的思想,将数据流抽象成无边界表(unbounded table),如下图所示:

image (42).png

这其实是将流抽象成了批,在这种抽象中,实时的数据流会不停追加到下图这张表中。我们通过对输入表查询的方式得到流处理的结果,并生成结果表。在每个触发间隔,新的数据将追加到输入表中,最终触发计算将会更新结果表,如下图所示:

image (43).png

第二行的方块表示结果表(Result Table),第三行的方块表示输出的外部存储,结果表更新到外部存储的方式,也就是输出模式,有 3 种,分别是完全模式、追加模式与更新模式。

完全模式:整个更新的结果表将被写入外部存储,由存储连接器来决定如何写入整张表。上图即为完全模式下的输出结果,一般用来调试。

追加模式:只将上次触发后追加到结果表的数据写到外部存储。这种模式适用于不希望修改已经存在于结果表的数据场景。这是默认的输出模式。在这种模式下,窗口内定时触发生成的中间结果会保存到中间状态,最后一次触发生成的结果才会写到结果表。

更新模式:只将上次触发后更新到结果表的数据写到外部存储。这与完全模式不同,它只输出上次触发后变更的结果记录。如果窗口中不包含聚合逻辑,则意味着不需要修改以前结果表中的数据,其实与追加模式无异。

这些其实就是对应的Dataflow的触发模式。

作为 MillWheel 和 Dataflow 的实现者,实现端到端恰好一次的消息送达保证是必不可少的,为了实现这一目标,Structured Streaming 引入了 Source、Sink 和 StreamExecution 等组件,可以准确地追踪处理进度,以便从重启或重新处理中处理任何类型的故障。每个流式数据源(Source)都具有偏移量(类似于 Kafka 的偏移量)追踪读取进度,以便在故障发生后进行数据回放。在每次触发计算时,执行引擎(StreamExecution)使用检查点和预写日志机制记录来记录数据源偏移量。数据输出(Sink)允许自定义,如设计成幂等效果或者采取两段式提交。结合可重放的数据源与可靠的数据输出,Structured Streaming 可以在任何时候实现端到端的恰好一次消息送达保证。

Structured Streaming 不会保存整个无边界表的数据。它会从数据源读取数据,以增量处理的方式来更新结果,然后丢弃这些源数据,只会保留更新结果所需的最小化中间状态数据(如中间计数)。Structured Streaming 支持基于事件时间处理数据,对于晚到和乱序数据的处理,也同样引入了水位机制,后面会详细介绍。

Structured Streaming 也遵循 Driver 和 Executor 的主从架构,其功能与前面介绍的无异:Driver 负责调度,Executor 负责执行,如下图所示。其中 Driver 和前面介绍的 Driver 差别不大,SQLExecution 变成了 StreamExecution,Executor 多了 StateStore,但是取消了和 Receiver 相关的组件,这是一个比较大的变化,代表接收数据的方式变了。下面我将就这两个组件展开讲解。

x1.png

1、 StreamExecution
StreamExecution 是 Structured Streaming 的执行引擎,是 StreamingQuery 接口的实现,在用户的代码中被初始化,start() 方法是这个过程的入口,如下面的代码所示:

复制代码

val lines = spark
.readStream
.format(“socket”)
.option(“host”, “localhost”)
.option(“port”, 9999)
.load()

val query: QueryExecution = wordCounts.map(…).writeStream
.trigger(ProcessingTime(2.seconds))
.outputMode(“complete”)
.format(“console”)
.start()
query.awaitTermination()
StreamExecution 有几个比较重要的成员变量:Trigger、LogicalPlan、Sink 和 Source。其中 Trigger 表示用户设定的触发间隔,它决定了一次处理的数据大小,LogicalPlan 代表执行计划,从用户代码的 start 方法开始,就进入了 StreamExecution。首先,可以看见这里设置触发器为每 2 分钟(处理时间)处理一次,用户从 Source 中取得相应的数据;接着根据用户定义的运算逻辑和 Trigger 得到优化过后的 IncrementalExecution,最后再交给 Sink 的 addBatch 方法触发整个过程执行;执行完成后,会通知 Source 修改相应的偏移量。其中 IncrementalExecution 是 Structured Streaming 独有的,它体现了 Structured Streaming 的执行方式是增量微批,主要针对前面 3 种输出模式进行优化。 Source、Sink 和 StreamExecution 抽象了整个流处理过程。从上面的这个过程可以看出,StreamExecution 存在的目的还是要将流式数据转化为 DataFrame 的执行计划并执行,其实最后还是由 SQLExecution 负责执行,也就是与批处理同样的执行引擎。这样的好处就在于无缝对接了 DataFrame 与 Tungsten 巨大的性能优化,并且统一了流处理和批处理的计算引擎。

再来看看 query 初始化的代码,与上一课时中 Dataflow 的样例代码非常相似,在这段代码中我们定义了 Trigger、输出模式、处理逻辑。在处理逻辑中,还可以定义窗口和水位,完全是一种 Dataflow 思想的体现。其中,Trigger 组件的选项有以下几个。

未指定(默认)。如果没有指定触发器,默认的触发逻辑是微批处理,一旦前一个微批完成处理,将立即生成下一个微批进行处理。

固定间隔的微批触发器。将以固定间隔进行触发,固定间隔长度由用户设置(默认的触发器实际上固定间隔长度为 0,即不固定)。如果先前的微批在当前间隔完成,则等到该间隔结束再开始下一个微批处理;如果前一个微批处理需要的时间超过了间隔长度,那么下一个微批将在前一个微批完成后立即执行,而不是等待下一个间隔边界;如果没有数据,则不会触发新的微批处理。固定间隔的微批触发器设定如下:

复制代码
.trigger(Trigger.ProcessingTime(2000))
一次性微批触发器。查询只会触发一次针对所有可用数据的微批处理,然后自行停止。**这在希望定期启动集群来处理上一个时间段里累积的所有数据,然后停止集群的场景里非常有用。**在某些情况下,可以显著减少性能开销。

复制代码
.trigger(Trigger.Once())
固定检查点间隔的连续触发器。固定检查点间隔的连续触发器是 Spark 新加入的流执行模式——连续处理的体现。连续处理是 Spark 2.3 中引入的实验性质的流执行模式,可以实现约 1ms 的端到端延迟,并且实现了“至少一次”消息送达保证,而默认的微批处理引擎虽然实现了“恰好一次”的消息送达保证,但是延迟最少也要 100ms 左右。对于某些类型的查询,我们可以通过只修改触发器而不修改应用逻辑来启用该特性。如下:

复制代码
.trigger(Trigger.Continuous(“1 second”)) // 只需修改这一行
参数 1 秒指的是检查点间隔,意味着执行引擎每秒会记录一次执行进度,得到的检查点文件将采用和微批执行引擎兼容的格式,所以我们可以在不同触发器之间任意切换,例如以微批触发模式启动的查询,可以以连续触发模式重新启动。无论何时切换,用户都可以获得“至少一次”的消息传递保证。目前连续触发器只支持特定的操作、数据源和输出。

从触发器的多样性来看,Dataflow 中的触发器功能无疑更加强大,既提供了固定周期触发器(AtPeriod),也可以基于水位(AtWatermark)等,但按照 Structured Streaming 的发展速度,相信很快能够实现更多类型的触发器。

2、 StateStore
StateStore 的作用是作为数据流转的状态存储(持久化)。它本质是一个分布式、高可用、分版本的键值存储,提供 get、put、remove 等增删改查操作。它的分片逻辑是算子编号(operatorId)加分区编号(partitionId)。

操作
既然使用了 DataFrame 与 SQL,因此从使用上来说,无论是普通的转换 API 还是 Spark SQL,都是完全一样的,甚至一些封装过的批处理的代码都可以直接复用。那么按照 Dataflow的理论,这就是 what 部分,我们在本节还要解决:

根据事件时间,哪些数据会参与计算(where);

什么时候触发计算(when);

早期的计算结果如何被修正(how)。

前面其实已经介绍过相应的内容,where 由窗口来指定,when 由 Trigger 组件来指定,how 由 watermark 机制来解决。

下面这个例子包含了 what、where、when、how 的声明:

复制代码

val windowDuration = “3000 seconds”
val slideDuration = “1000 seconds”

val lines = spark.readStream
.format(“socket”)
.option(“host”, “localhost”)
.option(“port”, 9999)
.option(“includeTimestamp”, true)
.load()

val words = lines.as[(String, TimestampType)].flatMap(line =>
line._1.split(" ").map(word => (word, line._2))
).toDF(“word”, “timestamp”)

val windowedCounts = words.groupBy(
window($“timestamp”, windowDuration, slideDuration),
$“word”)
.count().orderBy(“window”)

上面这段代码的计算逻辑如下图所示:

image (44).png

上图中,下方的结果表显示了 5 分钟滑动步长以及 10 分钟大小的窗口聚合结果。窗口跨度也是结果表的一个字段,该字段名为 window,从图中虚线箭头可以看出,触发器(Trigger)采用的是固定间隔触发,每 5 分钟触发一次。聚合的时间窗口是以数据的事件时间为准的,那么这样一定会存在晚到和乱序数据的问题。与 Google Dataflow 模型类似,Structured Streaming 也基于水位机制对晚到和乱序数据提供了相应处理机制,如下图所示:

image (45).png

当 12:04 的晚到数据在 12:11 到达时,它实际应该落在 12:00-12:10 的窗口里。在下一次触发时间(12:15)到来时,会更新结果表中的结果。Structured Streaming 会将这种类似聚合需求长时间维护在一个中间状态以便处理晚到数据,但是,如果对于长期运行的应用,对晚到数据无限期等待的代价或许会很大,这时系统应该有一种方法知道什么时候应该丢弃那些过时的中间结果,这样应用也不用再去处理这种晚到数据了。 和 Google Dataflow 一样,Structured Streaming 也采用了水位机制,旨在让计算引擎追踪数据中当前的事件时间,并尝试清除过时的中间状态。在 Structured Streaming 中,水位定义为当前最大的事件时间减去晚到时间最大容忍值。对一个 T 时刻的特定窗口,计算单元会维护其结果状态,当计算单元接收到的晚到数据,满足观察到的最大事件时间减去晚到时间最大容忍值,大于 T 的条件时,都允许晚到数据修改结果状态。换句话说,在水位线前面的数据会被处理,后面的数据则会被丢弃,如下图所示。

image (46).png

坐标轴中的虚线表示了至今为止计算单元观察到的最大事件时间;浅色实心圆点代表正常处理的数据;深色实心圆点代表了晚到数据,但是在可以容忍的晚到范围之内,而空心圆点数据表示晚到且在不能容忍的时间范围之内。判断是否可以容忍的依据是阶梯形实线(当前最大事件时间减去最大容忍晚到时间),也就是上面说的水位,在每个触发间隔前,会重新计算水位。晚到数据以这种机制被计算、被丢弃,最后更新结果表。

上图是更新输出模式,所以在每个触发点都会输出结果表,晚到数据会在后面的触发点再更新到结果表。

开启 Structured Streaming 的水位机制很简单,只需在代码中加上 withWatermark:

复制代码
val windowedCounts = words
// 最大容忍时间是5分钟,指定水位基于的列名timestamp
.withWatermark(“timestamp”, “5 minmutes”)
.groupBy(
window(
$“timestamp”,
windowDuration,
slideDuration),
$“word”)
.count()
想要使用水位机制需要满足一定条件,具体如下。

输出模式必须是更新模式和追加模式。在这两种模式中,会导致结果输出有所差别,在更新模式中,晚到的数据会被处理后再修改结果表,如图f所示,但是在追加模式中,由于不能修改结果表的内容,因此只有在水位超过了窗口结束时间后的下一个触发点才会触发聚合操作,也就是说,在追加模式中,是等所有晚到时间小于最大容忍时间的数据都接收到后,再进行聚合操作,并一次性输出到结果表,如下图所示,这两种模式的差别主要考虑的是接收器的差别,有些接收器不能进行修改,如文件系统。

聚合操作必须有事件时间列,或者有一个基于事件时间列的窗口。

水位机制与聚合操作中使用的时间列必须相同。

水位机制必须在聚合操作之前被调用。

image (47).png

输入与输出
在 Structured Streaming 中,输入被统一抽象为 Source,与 Spark Streaming 中各种不同的输入源不同,输出也被统一抽象为 Sink,这在 Spark Streaming 并没有进行抽象,这么做的好处不言而喻,有了统一的输出抽象,要实现端到端级别的“恰好一次”消息送达语义的实现就不再那么困难。也就是说,Structured Streaming 将整个流处理过程抽象为“Source + StreamExecution + Sink”,对应我们前面讲的“输入-处理-输出”过程,这样 Structured Streaming 就有能力为用户提供端到端级别“恰好一次”消息送达语义的实现。

下面分别来看看 Source 和 Sink 组件。

  1. Source
    Source 抽象了流式数据从接收到处理之间的过程,它是一个接口,其定义非常简单,如下面的代码所示:

复制代码
trait Source {
def schema: StructType
def getOffset: Option[Offset]
def getBatch(start: Option[Offset], end: Offset): DataFrame
def commit(end: Offset) : Unit = {}
def stop(): Unit
}
从这个接口可以看出,无论是哪种数据源,都必须实现 getOffset 方法,这意味着在 Structured Streaming 中,所有数据源都可以向 Spark Streaming 中 Kafka Direct API 那样通过维护偏移量的方式进行容错(回放)。所有的数据源皆是如此,也就意味着,Source 完全抛弃了基于 Receiver 的数据接收方式,省事省心。
目前 Source 的实现类有 KafkaSource 和 FileStreamSource,分别代表了 Kafka 数据源和 HDFS 数据源,还提供了用于测试的控制台数据源、Socket 数据源和 Rate 数据源(Rate 数据源能以指定速率生成数据,生成的每条数据都带有时间戳和消息 ID)的实现类。KafkaSource 很好理解,它本身的数据结构中就自带 offset,可以很好地和接口中 getOffset 相匹配,而 HDFS 的 offset 概念稍微复杂一点,每当调用 getOffset 方法时,HDFS 都会扫描文件夹下的文件,将最新的一些文件进行聚合成批,再赋予一个递增数字,FileStreamSource 对象中会维护一个名为 seenFiles 的 HashMap,用来保存已经扫描过的文件,判断依据是文件年龄和文件名。不管是哪种偏移量(除了 TextSocketSource),最后都会被 StreamExecution 预写日志 WAL 以便进行回放。这与 Spark Streaming 中介绍的思路是完全一样的,只不过 Structured Streaming 已经在这个环节中做到了对用户完全透明并泛化到所有数据源。

下面来看一个从 KafkaSource 中读取数据的例子,在这个例子中,会根据Kafka地址读取消息并将其转换为结构化数据:

复制代码

val spark = SparkSession
.builder
.master(“local[2]”)
.appName(“StructuredNetworkWordCount”)
.getOrCreate()

import spark.implicits._

val df = spark
.readStream
.format(“kafka”)
.option(“kafka.bootstrap.servers”, “host1:port1,host2:port2”)
.option(“subscribe”, “topicA”)
.load()

df.selectExpr(“CAST(key AS STRING)”, “CAST(value AS STRING)”).as[(String, String)]

2. Sink
Sink 是 Structured Streaming 对输出过程的抽象,目的也是实现对用户透明的容错处理。与Source 对应,Sink 也有 FileStreamSink、KafkaSink、ForeachSink 等,分别对应的输出为 HDFS、Kafka 和自定义 Sink,此外还提供了用于测试的控制台输出和内存输出的实现类。Sink 接口非常简单,如下面的代码所示:

复制代码
trait Sink {
def addBatch(batchId: Long, data: DataFrame): Unit
}
其中只定义了一个方法 addBatch,整个计算过程都是由这个方法触发执行的,现在来深入了解下这个方法,以 KafkaSink 为例:

复制代码
override def addBatch(batchId: Long, data: DataFrame): Unit = {
if (batchId <= latestBatchId) {
logInfo(s"Skipping already committed batch $batchId")
} else {
KafkaWriter.write(sqlContext.sparkSession,
data.queryExecution, executorKafkaParams, topic)
latestBatchId = batchId
}
}
在这个方法中,先判断了是否是重复提交的数据,然后由 KafkaWriter 的 write() 方法进行写入,写入完成后,更新最新的 batchId。接下来我们来看看 write() 方法:

复制代码
def write(
sparkSession: SparkSession,
queryExecution: QueryExecution,
kafkaParameters: ju.Map[String, Object],
topic: Option[String] = None): Unit = {
val schema = queryExecution.analyzed.output
validateQuery(queryExecution, kafkaParameters, topic)
queryExecution.toRdd.foreachPartition { iter =>
val writeTask = new KafkaWriteTask(kafkaParameters, schema, topic)
Utils.tryWithSafeFinally(block = writeTask.execute(iter))(
finallyBlock = writeTask.close())
}
在这个方法中,由 queryExecution 的 toRDD 方法触发计算开始,在最后 foreachPartition 操作中完成写入 Kafka 的操作。最后的写入操作是在 KafkaWriteTask 的 execute 方法完成的,如下面的代码所示:

复制代码
def execute(iterator: Iterator[InternalRow]): Unit = {
producer = CachedKafkaProducer.getOrCreate(producerConfiguration)
while (iterator.hasNext && failedWrite == null) {
val currentRow = iterator.next()
val projectedRow = projection(currentRow)
val topic = projectedRow.getUTF8String(0)
val key = projectedRow.getBinary(1)
val value = projectedRow.getBinary(2)
if (topic == null) {
throw new NullPointerException(s"null topic present in the data. Use the " +
s"${KafkaSourceProvider.TOPIC_OPTION_KEY} option for setting a default topic.")
}
// 由上面构建好的topic、key和value生成最后待插入的消息
val record = new ProducerRecord[Array[Byte], Array[Byte]](topic.toString, key, value)
val callback = new Callback() {
override def onCompletion(recordMetadata: RecordMetadata, e: Exception): Unit = {
if (failedWrite == null && e != null) {
failedWrite = e
}
}
}
producer.send(record, callback)
}
}
插入完成后,按照前面课时的思路,还要更新 Source 的偏移量,这由 StreamExecution 中的 batchCommitLog.add(currentBatchId) 完成。整个过程与 Spark Streaming 中实现的过程大同小异,不同的是这个过程更加透明,用户不用直接维护偏移量,比起 Spark Streaming 的各种 API 调用,更加优雅。但是目前的版本,Spark Streaming 中的问题仍然存在,还是无法在事务层面保证处理-输出的这个环节做到完美的“恰好一次”,只能做到“至少一次”,需要通过使输出幂等来实现,或者使用 Structured Streaming 提供的去重操作:

复制代码
// 参数为去重列名
streamingDf.dropDuplicates(“uniquecolumn”)
目前 FileStreamSink 提供了幂等保证,用户不用自己实现幂等逻辑,而 ForeachSink 则提供了自定义的 Writer,因此是否幂等取决于用户自己是否实现,目前 ForeachWriter 只有 Scala 和 Java 接口。ForeachWriter 代码如下:

复制代码
abstract class ForeachWriter[T] extends Serializable {
def open(partitionId: Long,version: Long): Boolean
def process(value: T): Unit
def close(errorOrNull: Throwable): Unit
}
ForeachWriter 是一个抽象类,任何继承它的类必须重写 open、process 和 close 这 3 个方法,这 3 个方法会在 Executor 上依次被调用:

open 方法的参数 partitionId 代表了输出分区ID;

version 是一个单调递增的 ID,随着每次触发而增加,我们可以通过两个参数判断这一批数据是否继续输出。如果返回值为 true,就调用 process 方法;如果返回值为 false,则不会调用 process 方法。

每当调用 open 方法时,close 方法也会被调用(除非 JVM 因为某些错误而退出),因此我们可以在 open 方法中打开外部存储连接,并在 close 方法中关闭外部存储连接。

小结
本课时介绍了 Structured Streaming 的相关内容,可以看到 Structured Streaming 忠实地复刻了 Dataflow 思路与实现,但是在声明计算逻辑环节复用了 Spark的DataFrame + Spark SQL,使得流处理编程变得十分简单,相信你也有相同的体会。另外,这样一来的话,Spark编程的批处理与流处理在形式上就得到了完美的统一,相信你在完成了本模块的实践课时后,体会会更深。

最后给你留一个思考题:

在流处理中,连接操作涉及两个数据源的操作,会相对比较复杂,其中每个数据源都有可能是静态的数据,或者是动态的数据流,那么 Structured Streaming 是如何处理连接操作的呢,它支持哪些类型的连接,又有什么限制呢?

第23讲:如何对 Spark 流处理进行性能调优?

从硬件、框架的使用和配置这三个维度介绍性能调优,最后再介绍流处理中出现得最多的一个问题场景:反压。

本课时的主要内容有:

硬件优化

使用层面的优化

配置优化

硬件优化
Spark Streaming 与 Spark 离线计算相比,I/O 并没有那么密集,整体负载也低于 Spark 离线计算,数据基本存放于 Executor 的内存中。但是,它对于 CPU 的要求相对较高,例如更低的延迟(较小的批次间隔)、大量微批作业的同时提交与处理。但是对基于时间窗口的操作以及对状态进行操作的算子来说,需要在内存中将这部分数据缓存,如果时间窗口跨度较长的话,需要的内存也会比较高,像 updateStateByKey 这种算子,更需要全程追踪状态,这也需要耗费不少内存,因此 Spark Streaming 集群的硬件配置也可参照离线计算型的配置。

使用层面的优化
对于使用层面的下列优化,有些是使用技巧,有些是在某些场景下得到的经验,你可以根据自己的需求选择。

  1. 批次间隔
    虽然说 Spark Streaming 号称可以达到毫秒级(理论上 50ms)的延迟,但是在设置批次间隔时,一般不会低于 0.5s,否则大量的作业同时提交会引起负载过高。这个值可以通过反复实验来得到,我们可以先将该值设置为比较大的值,比如 10s,如果作业很快就完成了,我们可以减小批次间隔,直到 Spark Streaming 在这个时间段内刚好处理完上一批的数据,此时的批次间隔就是比较合适的了。

  2. 窗口大小与滑动步长
    这两个配置的值同样对性能有巨大影响,当性能低下时,可以考虑减小窗口大小和增加滑动步长。

  3. updateStateByKey 与 mapWithState
    在绝大多数情况下,使用 mapWithState 而不用 updateStateByKey,实践证明,前者的延迟表现和能够同时维护的 key 数量都远远优于后者。

  4. mapPartition 与 map
    在与外部数据库交互,如写操作时,使用 mapPartition 而不要使用 map 算子,mapPartition 会在处理每个分区时连接一次数据库,而不像 map 每条数据连接一次数据库,性能优势明显。

  5. reduceByKey/aggregateByKey 与 groupByKey
    前者的聚合性能要明显优于后者,因此尽量使用 reduceByKey/aggregateByKey。

  6. 反函数
    在前面的课时中,我们介绍了 reduceByKeyAndWindow 的反函数重载版本,这对于跨度很大的时间窗口,且滑动窗口与上一个时间窗口有较大重合部分的场景来说尤其有用。这里解释下反函数的由来,从滑动时间窗口的原理上来说,如果:

image (7).png
公式 1

则:

image (8).png
公式 2

如果把 S* 重叠部分的计算结果 看成自变量,S *该滑动窗口的处理结果 看成因变量,那么就可以认为公式 2 是公式 1 的反函数。反函数的主要作用是避免重复计算。

  1. 序列化
    采用 Kyro 进行序列化,可以改善 GC。

  2. 数据处理的并行程度
    可以通过增大计算的并行度来提升性能,如 reduceByKey、join 等,如果不指定,并行度为配置项 spark.default.parallelism 的值,如果遇到数据倾斜还可以使用 repartition。

  3. filter 与 coalesce
    与离线计算相似,在 filter 算子作用后,会产生大量零碎的分区,不利于计算,可以在后面接 coalesce 或者 repartition 算子将其进行合并或者重分。

  4. 将 Checkpoint 存储到 Alluxio
    使用 Alluxio 作为 Spark Streaming 的 Checkpoint 存储介质,这有助于提高读写 Checkpoint 的性能。

  5. 资源调度
    如果使用统一资源管理平台,那么批处理作业与流处理作业有可能会运行在同一个节点的不同容器中。如果批处理作业负载较高,就会对流处理作业造成较大影响,建议分离部署。如果从提高资源利用率的角度出发,确实需要部署在一个集群,那么建议采用 Hadoop 2.6 以后引入的新特性:基于标签的调度(Label based scheduling),使流处理计算作业得到稳定且独立的计算资源。

  6. 缓存数据与清除数据
    与 Spark 离线计算一样,需要重复计算的数据需要用 cache 算子进行缓存。但是,这些缓存会不断占用内存,可以设置 spark.streaming.unpersist 为 true,让 Spark 来决定哪些数据需要缓存,否则需要手动控制,这样通常性能开销还会大一点。

配置优化
配置方面的优化具体如下

  1. JVM GC
    在 Executor 的堆足够大(大概 30GB 以上)时,使用 G1 GC 代替 CMS GC,否则采用 Parallel GC,如下所示:

–conf “spark.executor.extraJavaOptions=-XX:+UseG1GC”

–conf “spark.executor.extraJavaOptions=-XX:+UseParallelGC”

  1. spark.streaming.blockInterval
    该参数设置了 Receiver 的接收块间隔时间,默认为 200ms。对于大多数 Receiver,接收的数据在存储到 Spark 的 Executor 之前,会先聚合成块的形式,每个块就是一个分区,也就是说,每个批次间隔的数据中,块的数量决定了后面类似 map 算子所处理的任务数,这也影响了数据处理的并行程度。一个批次的数据块的数量(分区数)的计算公式为:batch interval /spark.streaming.blockInterval,分子为我们设置的批次间隔,假设为 2s,那么每个批次会有 2000/200=10 个数据块。如果这个数字低于节点的 CPU 核数,说明没有充分发挥 CPU 的能力,那么可以考虑降低 spark.streaming.blockInterval 的值,但是一般也不推荐低于 50 ms。

  2. 反压
    反压在流处理场景里面比较常见,是每个流处理框架必须考虑的问题。反压的实质是,当每批数据处理时间大于批次间隔时间时,长久以往,数据会在 Executor 中的内存中迅速累积,内存会很快溢出,如果设定持久化存储基本为硬盘,则会出现大量磁盘 I/O,增加延迟。

防止反压的关键是做好流量控制,如果一味地限制 Receiver 接收数据的速度,会降低整个集群的资源利用率。Spark Streaming 在 1.5 之后引入了反压机制,可以通过 spark.streaming. backpressure.enabled 来开启,开启后系统会根据每一批次作业调度与完成的情况让系统按照处理数据的速率来接收数据。实际上,就是限制 Receiver 接收数据的速度,上限由 spark.streaming. receiver.maxRate 设置,如果以 Kafka Direct 方式接收的话,上限由 spark.streaming.kafka.maxRatePerPartition 来配置。开启反压机制后,资源利用率肯定会有所下降,因此 spark.streaming.backpressure.enabled 默认关闭。

Spark Streaming 是利用 PID(proportional-integral-derivative)算法来确定新的数据接收速率的,开启反压机制后的速率公式为(单位:条/秒):

image (9).png

其中,VnewRate 为下一批次的接收速率;VlatestRate 为在上一批次中所确定当前批次的接收速率;Verror 为 VlatestRate 减去当前批次的实际处理速率;VhistoricalError 为当前批次等待调度的时间乘以当前批次的处理速率再除以批次间隔;dError 为Verror 减去上一批次的 Verror 的差,除以当前批次完成的时间,减去上一批次完成的时间的结果。Kproportional、Kintegral、Kderivative 为 PID 算法的 3 个重要的调适参数。

小结
与 Spark 批处理调优一样,流处理调优也是一个与业务紧密结合的问题,不光需要对原理、参数、配置非常熟悉,还需要大量的实践。

最后给你留一个思考题:

在很多场景中,为了提高资源利用率,很多时候,集群中既长驻着流处理作业也跑着批处理作业,这样做有什么不好的影响呢?

第24讲:实战:如何对股票交易实时价格进行分析?

在流处理这个模块中,我们已经学习了 Spark 的两种流处理解决方案,在本课时中,我们将进行一个略微复杂的实践,也是我们本模块的实践环节。

在本模块中,我们将对实时股票价格数据进行处理。我们将在本课时中计算一个在股票分析中的比较常见的指标:CCI。

实时股票价格数据蕴含着巨大的价值,如何能在交易过程中敏锐地捕捉到机会非常重要,所以这就是一个非常典型的流处理应用场景。下面介绍一个股票交易实时分析应用:计算分钟级 CCI。

CCI(Commodity Channel Index),也被称为顺势指标。它最早用于期货市场的判断,后期才运用于股票市场的研判,并被广泛使用。与大多数单一利用股票的收盘价、开盘价、最高价或最低价而发明出的各种技术分析指标不同,CCI 指标是根据统计学原理,引进价格与固定期间的股价平均区间的偏离程度的概念,强调股价平均绝对偏差在股市技术分析中的重要性,是一种比较独特的技术指标。CCI 有事件时间区间的概念,很适合用 Structured Streaming 来完成。

CCI 有日 CCI、周 CCI、年 CCI 以及分钟 CCI 等很多种类型。本例主要实现的是 30 分钟 CCI(一个周期为 30 分钟),其计算公式为:

image (3).png

其中

image (4).png

这个公式为给定 30 分钟内的最高价、最低价和收盘价的平均值,SMA 是 N 个周期的 pt 的移动平均值,MD 是 pt 的平均离差,本例中 N = 3。

再来看看数据,目前股票的实时数据来源渠道有很多,如 Wind、大智慧等,都提供了自己的接口。假定数据已经被实时拉取并灌入到消息队列中,在这个过程中,有可能会出现数据晚到和乱序的现象。为了结果的准确,在处理时需要考虑这些情况,来看一条数据样例:

000002.SZ, 1502126681, 22.71, 21.54, 22.32, 22.17

其中每个字段分别是股票代码、事件时间戳、现价、买入价、卖出价、成交均价。

下面我们来看看 CCI 的计算方式,SMA、MD 都需要 pt 序列计算得到,而从公式可以看到计算 pt 需要先得到 30 分钟内的最高价、最低价和收盘价。当 pt 序列按照时间被保存到数据库后,那么计算 CCI 就非常容易了,一个应用定期进行查询并计算即可,所以计算 CCI 的核心是计算 pt。

下面的代码采用 Structured Streaming 对数据流进行处理,得到最高价、最低价和收盘价并求其均值,从而得到 pt 序列。其中,我们需要开发一个求收盘价的 UDAF,然后还要开发一个输出到 HBase 的 Sink,正好可以把我们前面学到的知识用上。

复制代码
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import org.apache.spark.sql.types.TimestampType
import org.apache.spark.sql.streaming.Trigger
import java.sql.Timestamp

object StockCCICompute {
  
  def main(args: Array[String]): Unit = {
    
    val spark = SparkSession
      .builder
      .appName("StockCCICompute")
      .getOrCreate()
    
    //分别设置window长度、容忍最大晚到时间和触发间隔
    val windowDuration = "30 minutes"
    val waterThreshold = "5 minutes"
    val triggerTime = "1 minutes"

    import spark.implicits._

    spark.readStream
    .format("kafka")
    .option("kafka.bootstrap.servers", "broker1:port1,broker2:port2")
    .option("subscribe", "stock")
    .load()
    .selectExpr("CAST(key AS STRING)", "CAST(value AS STRING)")
    .as[(String, String)]
    //解析数据
    .map(f => {
        val companyNo = f._1
        val infos = f._2.split(",")
        (f._1,infos(0),infos(1),infos(2),infos(3),infos(4))
    })
    .toDF("companyno","timestamp","price","bidprice","sellpirce","avgprice")
    .selectExpr(
        "CAST(companyno AS STRING)",
        "CAST(timestamp AS TIMESTAMP[DF1] )",
        "CAST(price AS DOUBLE)",
        "CAST(bidprice AS DOUBLE)",
        "CAST(sellpirce AS DOUBLE)",
        "CAST(avgprice AS DOUBLE)")
    .as[(String,Timestamp,Double,Double,Double,Double)]
    //设定水位
    .withWatermark("timestamp", waterThreshold)
    .groupBy(
          window($"timestamp", 
              windowDuration), 
              $"companyno")
    //求出最高价、最低价和收盘价,其中收盘价需要自己开发UDAF
    .agg(
          max(col("price")).as("max_price"),
          min(col("price")).as("min_price"),
          ClosePriceUDAF(col("price").as("latest_price")))
    .writeStream
    .outputMode("append")
    .trigger(Trigger.ProcessingTime(triggerTime))
    //输出到HBase中
    .foreach(HBaseWriter)
    .start()
    .awaitTermination()

  }
}

代码中选取了 append 模式,所以分析应用不用处理结果发生变化的情况。另外代码风格特意采用了 Dataflow 的数据管道式,本例中的数据处理的逻辑是完全可以应用于批处理的。开发的 UDAF 目的是求出收盘价,也就是窗口内时间戳最大的那一条,代码如下:

复制代码
import org.apache.spark.sql.expressions._
import org.apache.spark.sql.types._
import org.apache.spark.sql.Row
import org.apache.spark.sql.functions._
import java.sql.Timestamp

object ClosePriceUDAF extends UserDefinedAggregateFunction {

  //指定输入的类型
  override def inputSchema: StructType 
    = StructType(Array(StructField[DF1] ("price", DoubleType, true)))
  
  //中间结果只需要两个字段:价格、时间
  override def bufferSchema: StructType 
    = StructType(
        Array(StructField("latestprice", DoubleType, true),
              StructField("timestamp", TimestampType, true)))

  //指定最后输出的类型
  override def dataType: DataType = DoubleType
  override def deterministic: Boolean = true
  
  //初始化中间结果
  override def initialize(buffer: MutableAggregationBuffer): Unit 
    = {
      buffer(0) = 0D
      buffer(1) = 0L
    }
  
  //更新时间晚的价格为中间
  override def update(buffer: MutableAggregationBuffer, input: Row): Unit = {
    
    val priceNow = input.getAs[Double]("price")
    val timestampNow = input.getAs[Timestamp]("timestamp")
    val timestampBuf = buffer.getAs[Timestamp]("timestamp")
    
    if(timestampNow.after(timestampBuf)){
      buffer(0) = priceNow
      buffer(1) = timestampNow
    }
  }
 
  //合并中间结果
  override def merge(buffer1: MutableAggregationBuffer, buffer2: Row): Unit = {
     
     val buffer1Timestamp = buffer1.getAs[Timestamp]("timestamp")
     val buffer2Timestamp = buffer2.getAs[Timestamp]("timestamp")
     
     if(buffer2Timestamp.after(buffer1Timestamp)){
       buffer1(0) = buffer2.getAs[Double]("price")
       buffer1(1) = buffer2Timestamp
     }
  }
  
  //返回最后的结果
  override def evaluate(buffer: Row): Any = buffer.getAs[Double]("price")
}

最后为了保证结果的正确性,需要实现自定义 Writer。这也是选取 HBase 的原因,因为插入到 HBase 的操作天然就具有幂等性(重复 Put 会覆盖之前的值),所以可以实现端到端的恰好一次的消息送达的效果,代码如下:

复制代码
import org.apache.spark.sql.ForeachWriter
import org.apache.spark.sql.Row
import org.apache.hadoop.hbase.HBaseConfiguration
import org.apache.hadoop.hbase.client.ConnectionFactory
import org.apache.hadoop.hbase.client.Connection
import org.apache.hadoop.hbase.TableName
import org.apache.hadoop.hbase.client[DF1] .Put
import org.apache.hadoop.hbase.util.Bytes

object HBaseWriter extends ForeachWriter[Row] {
  
  var conn: Connection  = null
  
  //初始化HBase连接
  def open(partitionId: Long, version: Long): Boolean = {
     
     val conf = HBaseConfiguration.create()
     conn = ConnectionFactory.createConnection(conf)

     true
  }
  
  //获取结果表中的字段,并以窗口标识为行键,插入HBase中
  def process(row: Row): Unit = {
      
      //window字段作为rowkey供分析应用查询
      val window = row.getAs[String]("window")
      val maxPrice = row.getAs[Double]("max_price")
      val minPrice = row.getAs[Double]("min_price")
      val latestPrice = row.getAs[Double]("latest_price")
      
      val table = conn.getTable(TableName.valueOf("CCI"))
      val put = new Put(Bytes.toBytes("window"))
      
      //列族为cf,列名分别为max_price、min_price、latest_price
      put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("max_price"), Bytes.toBytes(maxPrice))
      put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("min_price"), Bytes.toBytes(minPrice))
      put.addColumn(Bytes.toBytes("cf"), Bytes.toBytes("latest_price"), Bytes.toBytes(latestPrice))
      table.put(put)
      table.close()
  }  
  
  //关闭连接
  def close(errorOrNull: Throwable): Unit = {
      conn.close()
  }

}

Structured Streaming 用窗口起点–窗口终点作为 window 字段的值,也就是窗口唯一标识。在入库时,该值作为行键方便应用查询。在数据入库后,分析应用可以用窗口标识进行查询,例如用 12:00-12:30、12:30-13:00、13:00-13:30 这三个值分别发起三次查询,从而得到这些窗口的最高价、最低价和收盘价,再分别计算出 pt,最后就能得到当前周期的 CCI。

如果你看到这里,就会发现这个应用的开发过程以及它所需要的组件还是比较复杂的,除了上面的开发过程,我们还需要开发一个后端查询应用才能计算出 CCI,这也是流处理通常是属于数据工程领域而非数据科学领域。

最后值得注意的是,从代码 spark.readStream 开始到最后处理过程的完成,其实是一行代码,这一行代码稍加改动也可直接用于批处理,这也是 Spark 统一编程接口的一种体现。

本课时的内容就到这里,下个课时我们将进入下一个模块的学习,我将为你讲解什么是图:图模式,图相关技术与使用场景。

第25讲: 什么是图:图模式,图相关技术与使用场景

在本模块中,我们将学习 Spark 如何处理图,也就是 Spark 的图挖掘套件 GraphX。虽然图这种数据结构在最近几年中,越来越多地出现在业务场景中,但平心而论,图的使用频率相比前面所学的内容还没有那么频繁。但是,一旦有这方面的需求,无论是工程师还是科学家,都可以用 Spark 提供的解决方案很好地完成任务,甚至可以说是“屠龙技”也不为过,经过本模块的学习之后,相信你也会有这样的感受。

本课时主要围绕图这种核心结构介绍,分为以下三个部分:

图结构

图存储

图相关计算场景和技术

图结构
图是一种较线性表和树更为复杂的数据结构,线性表和树分别表现的是一对一和一对多的关系,而图则表达的是多对多的关系。如下图所示,G1 是一个简单的图,其中 V1、V2、V3、V4 被称作顶点(Vertex),任意两个顶点之间的通路被称为边(Edge),它可以由(V1、V2)有序对来表示,这时称 G1 为有向图,意味着边是有方向的,若以无序对来表示图中一条边,则该图为无向图,如 G2。

2.png

在 G1 中,与顶点相关联的边的数量被称为顶点的度(Degree)。其中,以顶点为起点的边的数量被称为该顶点的出度(OutDegree),以顶点为终点的边的数量被称为该顶点的入度(InDegree)。

以 G1 中的 V1 举例,V1 的度为 3,其中出度为 2,入度为 1。在无向图 G2 中,如果任意两个顶点之间是连通的,则称 G2 为连通图(Connected Graph)。在有向图中 G1 中,如果任意两个顶点 Vm、Vn 且 m ≠ n,从 Vm 到 Vn 以及从 Vn 到 Vm 之间都存在通路,则称 G1 为强连通图(Strongly Connected Graph)。任意两个顶点之间若存在通路,则称为路径(Path),用一个顶点序列表示,若第一个顶点和最后一个顶点相同,则称为回路或者环(Cycle)。

图存储
由于图的结构比较复杂,所以无法存储在顺序映像的存储结构中,本课时中,我将为你介绍常用的几种图存储结构。

邻接矩阵
邻接矩阵是表示顶点之间相邻关系的矩阵。一般用二维数组存储,若图有 n 个顶点,则矩阵大小为 n * n,下面我用邻接矩阵来表示 G1、G2 :

image (8).png

image (9).png

其中矩阵的行标为起点,列标为终点。可以看到无向图必为对称矩阵,这一点可以在存储时进行优化。

邻接表
邻接表表示图的一种链式存储结构,在邻接表中,会对每个顶点建立一个单链表,每个单链表中保存了所有依附于该顶点的边,每个单链表中的结点由三个域组成:邻接点域、链域和数据域。其中,邻接点域指向与该顶点相邻的顶点,链域指向下一个与该顶点相关联的顶点,数据域保存了边相关的属性信息。用邻接表来表示 G1 ,则如下图所示:

3.png

对于无向图,V1 链表的结点个数就是 V1 的度数。而对于有向图来说,则只是 V1 的出度,如果想统计入度,则比较麻烦,需要遍历整个图。这种情况,可以通过建立一个逆邻接表来解决。在逆邻接表中,链域保存的是指向该顶点的下一个顶点,G1 的逆邻接表如下图所示:

图片

如果是无向图 G2,邻接表则如下图所示:

4.png

邻接多重表
虽然邻接表是一种很有效的存储结构,但对于无向图来说,当同一条边(Vm、Vn)存在于第 m 个链表与第 n 个链表中时,对于修改图来说并不是特别方便。因此,进行这一类操作的无向图一般采用邻接多重表。在邻接多重表中,分为两个表:顶点表和边表,边表结构如下所示:

5.png

其中,mark 为标志域,可以用来标记该条边是否被搜索过;ivex 和 jvex 为该条边的起点和终点,ilink 指向下一条顶点 ivex 的出边;jlink 指向下一条以顶点 jvex 的入边,info 保存和边相关的信息,顶点表结构如下所示。

6.png

其中,data 域保存了和该顶点相关的信息,firstedge 域指向该顶点的第一条出边,下图中是 G2 以邻接多重表的形式进行存储:

7.png

比较流行的 Neo4j 图数据库就是采取邻接多重表的结构保存数据的。

十字链表
邻接表只体现了出度,虽然我们可以用逆邻接表来弥补入度信息,但是否存在一种将邻接表和逆邻接表结合起来的数据结构呢?这就是十字链表。十字链表分为顶点表和边表这两个表。边表结构如下所示:

8.png

其中 tailvex 和 headvex 为边的起点和终点,hlink 指向下一条顶点 tailvex 的出边;tlink 指向下一条以headvex为顶点的入边,顶点表结构如下所示:

9.png

其中 data 域保存了和该顶点相关的信息,firstIn 和 firstOut 是两个指针域,分别指向该顶点的第一条入边和第一条出边,如下图所示,该结构代表 G2 以十字链表的形式存储:

Lark20200707-152616.png

十字链表与邻接多重表的不同之处在于顶点表多了表示第一条入边的指针域。

边集数组
边集数组是一种利用一维数组存储图中所有边的图表示方法。该数组中每个元素都用来存储一条边的起点、终点(对于无向图,可选定边的任一端点为起点或终点)和边属性。此外,边集数组通常包括一个边数组和一个顶点数组。这种方式非常适合将数据进行分区,所以 GraphX 以及很多图数据库都采取这种方式存储数据。

图相关计算场景和技术
图面对的计算场景与普通的数据处理场景类似,主要可以分为 3 类:

OLTP

OLAP

离线处理

OLTP 和 OLAP 对应事务和分析场景,这两类场景通常由图数据库负责,它的特点是对实时性要求很高,比如在图中新增若干顶点并新增与之相连的边,这属于事务。再比如,查询以某个顶点为中心的,三度以内的顶点,这属于查询。这两类操作目前主流的图数据库都能很好地完成。

Neo4j 与 Cypher
Drawing 8.png

Neo4j 是一个比较老牌的开源图数据库,目前在业界的使用也较为广泛,它提供了一种简单易学的查询语言 Cypher。Neo4j 采取类似于邻接多重表的数据结构存储数据,查询与插入速度较快,但由于没有分布式版本,图容量有限,而且一旦图变得非常大,如数十亿顶点,数百亿边,查询速度将变得缓慢。Neo4j 分为社区版和企业版,企业版有一些高级功能,但需要授权,非常昂贵,动辄数十万一年。

Apache TinkerPop与 Gremlin
Drawing 9.png

TinkerPop 是一个开源的、面向集成的图计算框架,它包含一系列的组件,上图中每一个卡通形象都代表一个组件,分别是:Blueprints、Pipes、Frames、Furnace、Rexster 和 Gremlin。

中间的小人代表 Gremlin,它是 TinkerPop 的图查询语言,所有支持 TinkerPop 的系统都可以互相集成,例如原生的 TinkerPop 是用内存作为图存储,这无疑达不到生产环境的标准。你可以使用 MySQL 作为存储,也可以使用 NoSQL 分布式数据库,如 Cassandra、HBase、BerkeleyDB 等。索引也可以采用 Elasticsearch 或者 Lucene。使用 NoSQL 数据库作为存储引擎,只是实现了存储分布式,解决了图容量问题,但是没有实现查询分布式,使用 NoSQL 数据库作为存储引擎的产品有 Titan、JanusGraph。

TinkerPop 的核心是集成,如下图所示,用户可以根据自己需要,选择不同的组件,博采众家之长,并实现 TinkerPop 要求的标准,从而构建出一个图数据库,从这个意义上来说,TinkerPop 确实不单是一个数据库,而是一个框架。

Drawing 10.png

值得一提的是,和关系型数据库一样,得益于社区和资本的助力,国产图数据库的发展也非常快,TigerGraph、Nebula 也非常值得你关注。

再来看看离线处理,这类场景通常代表一些比较复杂的分析和算法,如基于图的聚类,PageRank 算法等,这类计算任务对于图数据库来说就很难胜任了,主要由一些图挖掘技术来负责,这里我列出了两个比较常见的技术类型:

Pregel
Pregel 是 Google 于 2010 年在 SIGMOD 会议上发表的《Pregel: A System for Large-Scale Graph Processing》论文中提到的海量并行图挖掘的抽象框架,Pregel 与 Dremel 一样,是 Google 新三驾马车之一,它基于 BSP 模型(Bulk Synchronous Parallel,整体同步并行计算模型),将计算分为若干个超步(super step),在超步内,通过消息来传播顶点之间的状态。Pregel 可以看成是同步计算,即等所有顶点完成处理后再进行下一轮的超步,它的代表作就是 Spark 基于 Pregel 论文实现的海量并行图挖掘框架 GraphX,类似的还有 Yahoo 基于 MapReduce 实现的 Giraph 框架。

NetworkX
NetworkX 是一个用 Python 语言开发的图论与复杂网络建模工具,内置了常用的图与复杂网络分析算法,可以很方便地进行复杂网络数据分析、仿真建模等工作。用 NetworkX 可以很轻易地计算出基于图的一些指标和特征,这在传统方法中是非常困难的。NetworkX 与 GraphX 都是图计算工具。不同的是,GraphX 偏向于超大规模的图处理,例如千万顶点级别,而 NetworkX 处理的数据规模通常在百万顶点以内,十万顶点以内的图,算法效率最好。

GraphX 与 NetworkX 相比,最主要的区别在于处理数据的容量,GraphX 处理的数据量可以随着计算能力的增加而线性增长,十亿百亿顶点规模的图对于 GraphX 来说不在话下,但是由于 NetworkX 是单机计算包,所以图的容量最好不要超过百万顶点。此外,NetwrokX 对算法的封装程度很高,开箱即用,GraphX则更加底层。

小结
由于图结构的特殊性,没有接触过的同学未免会对这个概念有些陌生,本课时主要讲解了图存储和相关计算场景和对应的技术类型,主要是为后面的学习做铺垫,值得注意的是,图存储中介绍的 5 种数据结构非常底层,后面提到的技术对于图数据本身的存储无外乎这几种。

这里给你留一个思考题:

前面提到,Neo4j 采用的是邻接多重表作为自己的存储结构,那么 Tinkerpop 采用的是哪种存储结构呢?它们各自又有哪些优点呢?

第38讲:数据仓库与商业智能系统架构剖析

开始今天的课程前,先来看看上节课的思考题:树的最大深度对随机森林模型效果有什么影响呢?答案是树的最大深度可以用来控制模型的过拟合。

从这个模块开始,我们将进入到项目实战练习。在那之前,我们先来简要介绍下要实现的系统架构及相关的理论知识,这样在实现的过程中,大家能够更加清楚地领会整个过程,达到先务虚再务实、务虚是为了更好地务实的效果。

本课时的主要内容有:

数据仓库

数据立方体与多维分析

商业智能系统

数据仓库
数据仓库的特征
按照数据仓库系统构造方面的领衔设计师 William H.Inmon 的说法,“数据仓库是一个面向主题的、集成的、时变的、非易失的数据集合,支持管理者的决策过程”,它指出了数据仓库的四个主要特征,下面我们分别进行解释。

面向主题的(subject-oriented):数据仓库围绕一些重要的主题,如顾客、供应商、产品和销售组织。数据仓库关注决策者的数据建模和分析,而不是单位的日常操作和事物处理。因此数据仓库通常排除对于决策无用的数据,只提供特定主题的简明视图。

集成的(integrated):通常构造数据仓库是将多个异构数据源,如关系数据库、一般文件和联机事务处理记录集成在一起。它使用数据清理和数据集成技术,以确保命名约定、编码结构、属性度量等的一致性。

时变的(time-variant):数据仓库从历史的角度(例如,过去 5~10 年)提供信息。数据仓库中的关键结构都隐式或显式地包含时间元素。

非易失的(nonvolatile):数据仓库总是物理地分离存放数据,这些数据源于操作环境下的应用数据。由于这种分离,数据仓库不需要事务处理、恢复和并发控制机制。数据的易失性在于操作型系统是一次访问和处理一条记录,可以对操作环境中的数据进行更新。但是数据仓库中的数据呈现出非常不同的特性,数据仓库中的数据通常是一次性加载,但在数据仓库环境中并不进行一般意义上的数据更新。通常,它只需要两种数据访问操作:数据的初始化加载和数据访问。

业务数据库与数据仓库的区别
操作数据库系统的主要任务是执行联机事务和查询处理,这种系统称作联机事务处理(OLTP)系统。它涵盖了组织的大部分日常操作,如购物、库存、工资等,也被称作业务系统。

数据仓库系统在数据分析和决策方面为用户提供服务,这种系统称作联机分析处理(OLAP)系统。

两种系统的主要区别概述如下:

用户和系统的面向性:OLTP 是面向客户的,用于办事员、客户和信息技术专业人员的事务和查询处理。OLAP 是面向市场的,用于知识工人(包括经理、主管和分析人员)的数据分析。

数据内容:OLTP 系统管理的是当前数据,通常这种数据太琐碎,很难用于决策。OLAP 系统管理的则是大量的历史数据,提供汇总和聚集机制,并在不同的粒度层上存储和管理信息,这些特点使得数据更容易用于有根据的决策。

视图:OLTP 系统主要关注一个企业或部门内部的当前数据,而不涉及历史数据或不同组织的数据。相比之下,由于组织的演变,OLAP 系统常常跨越数据库模式的多个版本。OLAP 系统还要处理来自不同组织的信息,以及由多个数据库集成的信息。由于数据量巨大,OLAP 系统的数据也存放在多个存储介质上。

访问模式:OLTP 系统主要由短的原子事务组成,这种系统需要并发控制和恢复机制。然而,对 OLAP 系统的访问大部分是只读操作(由于大部分数据仓库存放历史数据,而不是最新数据),尽管许多访问可能是复杂的查询。

其他区别还包括数据库大小、操作的频繁程度、性能度量等,在这里就不再展开。

既然操作数据库存放了大量数据,你可能会奇怪“为什么不直接在这种数据库上进行联机分析处理(OLAP),而是另外花费时间和资源去构造分离的数据仓库?”。分离的主要原因是有助于提高两个系统的性能。操作数据库是为已知的任务和负载设计的,如使用的主键索引,检索特定的记录,优化“定制的”查询。而数据仓库的查询通常是复杂的,涉及大量数据汇总级的计算,可能需要特殊的基于多维视图的数据组织、存取方法和实现方法。在操作数据库上处理 OLAP 查询,可能会大大降低操作任务的性能。

此外,操作数据库支持多事务的并发处理,需要并发控制和恢复机制(例如,加锁和记日志),以确保一致性和事务的鲁棒性。通常,OLAP 查询只需要对汇总和聚集数据记录进行只读访问。如果将并发控制和恢复机制用于这种 OLAP 操作,就会危害并行事务的运行,从而大大降低 OLTP 系统的吞吐量。

最后,数据仓库与操作数据库分离是由于这两种系统中数据的结构、内容和用法都不相同。决策支持需要历史数据,而操作数据库一般不维护历史数据。在这种情况下,操作数据库中的数据尽管很丰富,但对于决策是远非完整的。决策支持需要整合来自异构源的数据(例如,聚集和汇总),产生高质量的、纯净的和集成的数据。操作数据库只维护详细的原始数据(如事务),这些数据在进行分析之前需要整理。由于两种系统提供大不相同的功能,需要不同类型的数据,因此需要维护分离的数据库。

数据集市
在数据仓库架构中,还存在一种形态,叫数据集市。数据集市往往服务于一组特定群体的分析需求(如会计部分或者信贷部门)。有些数据集市是独立的,也就是说它可以独立于数据仓库存在,而直接由业务数据库的历史应用创建。但更多的情况是,数据集市往往作为数据仓库之上的一个面向分析应用,换言之,数据集市的用户往往是直接和业务相关的分析应用。

数据立方体与多维分析
限于篇幅,这里就不和大家详细介绍数据仓库与数据集市建模的方法论,而是介绍一种数据结构和分析方法,在后面的项目中,你可以看到我们是如何将这种结构和方法融入项目中去的。

我们先来介绍什么是数据立方体。以下表为例,表中一共有四列,其中 sales_count 被称为度量,而其余三个字段被称为维度,整个这张表就可以看成是一个数据立方体,表格维度就可以看成立方体的维度。

sales_count sales_area sales_season sales_item
1223 四川 春 自行车
3244 四川 夏 自行车
3242 四川 秋 自行车
1555 四川 冬 自行车
3333 北京 春 自行车
4444 北京 夏 自行车
5555 北京 秋 自行车
3333 北京 冬 自行车
2312 四川 春 运动水壶
3233 四川 夏 运动水壶
3222 四川 秋 运动水壶
1110 四川 冬 运动水壶
2323 北京 春 运动水壶
3243 北京 夏 运动水壶
1121 北京 秋 运动水壶
2343 北京 冬 运动水壶
为了能够可视化,这里特意用了三个维度,数据立方体可以看成下面的坐标系:

Drawing 0.png

观察数据会发现 sales_area 的值有两个:北京、四川;sales_season 的值有四个:春、夏、秋、冬;sales_item 的值有两个:运动水壶和自行车。我们可以将这些值看成坐标轴上的刻度,则这个大的立方体就可以被分割为 2 * 4 * 2 个子立方体。

基于数据立方体的维度,我们就可以进行多维分析,比如分析季节维度下的均值、总量等等。具体的实现则是用 SQL 的 GROUP BY 子句 + 对应的聚合函数完成,这其实也是多维分析中常见的上卷操作。

多维分析中常见的操作包括: 切片,切块,旋转,上卷,下钻。我们 以下面这个数据立方体为例,来看下它们分别指的是什么:

Drawing 1.png

切片(Slice)和切块(Dice):如下图所示,切片是在数据立方体的某一维度上选定一个维成员,而切块是对两个或多个维执行进行选择。

Drawing 2.png

旋转(Pivot):如下图所示,旋转指改变报表或页面的展示方向。对于使用者来说,就是个视图操作,而从 SQL 模拟语句的角度来说,就是改变 SELECT 后面字段的顺序而已。

Drawing 3.png

下钻(Drill-down)和上卷(Roll-up):下钻指将某些维度进行细分, 比如将省份维度下钻到城市维度,或是将时间维度从季度的粒度下钻到月份,如下图中将时间维度细分为 4 月、5 月、6 月。上卷可以理解为"无视"某些维度,比如只基于省份和品类进行聚合分析,如下图所示。

Drawing 4.png

基于数据仓库的多维分析通常广泛运用在商业智能系统中,下面我们来看看商业智能系统。

商业智能系统
哪里有数据,哪里就有数据挖掘应用,这句话用来形容商业智能再合适不过了。数据仓库解决了存储问题,而 OLAP 技术提供了挖掘手段,企业自然而然会想到将数据利用起来,商业智能就是最好的途径。

**商业智能(Business Intelligence,BI)**是一个统称,指的是用于支持制定业务决策的技能、流程、技术、应用和实践。商业智能是将企业中现有的数据转化为知识,以帮助企业做出明智的业务经营决策的工具。具体来说,它通过对商业信息进行搜集、管理和分析,旨在使企业的各级决策者获得知识或洞察力(Insight),从而促使他们做出对企业更有利的决策。从技术层面上讲,商业智能不是什么新技术,它只是数据仓库、OLAP 等技术的综合运用。

大多数的数据仓库是为了挖掘某种商业价值而创建的,但是商业智能和数据仓库之间的区别在于,商业智能的定位是生成可向业务用户交付的产品,而数据仓库只对数据进行结构化的存储和组织,所以数据仓库需要 OLAP 技术,才能向商业智能转换。

下图演示了商业智能系统、数据仓库和 OLAP 的关系:

1.png

可以看出,商业智能系统通过对数据仓库的数据进行数据选择、抽取、加载后,使用数据挖掘方法提取知识,再用 BI 报表将知识呈现给决策者供其参考。

一款优秀的商业智能系统应该满足以下四个特性:准确、及时、价值高和可操作。准确性的意义是数据是可信的,及时性意味着数据可定期获取,价值高表示对商业用户有用,可操作性是指信息可以用于业务决策过程。

总结
本课时详细讲解了后续实战项目中会用到的概念,但是并没有面面俱到,还有星形模型、雪花模型、数据仓库建模方法论等概念,详细讲解需要太多的理论延伸,对于我们后面的项目来说没有必要,但如果你有兴趣,可以多钻研。

不难看出,由于数据集市需要面向业务并且实时分析,数据立方体的结构非常适合它,而商业智能系统的中心正是能够支持 OLAP 分析、报表查询的数据集市。在后面我们会按照这种思路深入实战学习,本课时中最重要的是理解数据立方体和多维分析方法,这也是本节课的课后思考题。

第39讲:作为 Yelp 运营负责人,如何根据数据进行决策?

本模块中,我为你设计了一个基于真实数据的项目,也就是前面提到的商业智能系统,可以让你对 Spark 有更形象的认识,并对前面学习的知识进行巩固。为了便于理解,该模块简化了业务复杂度和技术复杂度,作为你的第一个 Spark 项目是比较合适的。

本课时的主要内容是:

美国的大众点评网 Yelp 和 Yelp 2016 Dataset Challenge 数据集介绍。

作为 Yelp 运营负责人,你希望知道什么?

Yelp 与数据集介绍
Yelp 由前 PayPal 员工罗素·西蒙斯和杰里米·斯托普尔曼于 2004 年在美国旧金山成立,公司发展迅速,得到几轮融资支持,2010 年营业额达到 3 千万美元,以及 450 万评论量。2009 年到 2012 年,其在欧洲和亚洲的业务也蓬勃发展。

这是一个著名的商户点评网站,与我们的大众点评网类似,美国各地用户只要注册就能在 Yelp 网站上给商户打分、评论,还能和其他用户交流感想体验。Yelp 上的商户包括各地餐馆、购物中心、酒店、旅游等。

就像 Yelp 的 slogan 一样,这家公司看重的是真实客户的真实评价,尤其注重把一小部分热衷点评的用户吸引过来,同时给予优质客户奖励。这种形式提高了 Yelp 网站上各种信息的可信度,也把有深入体验的优质用户和那些走马观花的用户区分开了。

Yelp 在 2016 年公开了其内部业务数据集(Yelp 2016 Dataset Challenge),供数据科学竞赛爱好者使用,Kaggle 也收录了该数据集(下载地址:https://www.kaggle.com/yelp-dataset/yelp-dataset)。由于是真实数据集,其中有不少有趣的内容,我们这次的项目也是基于该数据集。

下面我们对数据集进行一个介绍,它包括以下几个表:

业务表 (businesss)

评价表 (reviews)

小贴士表 (tips)

用户信息表 (user information)

签到表 (check-ins)

数据集共 10G 左右,包含 668 万条评论。它来自 19 万个商业机构,涵盖了 10 个都市区域,有大概 100 多万个属性标签。此外,Yelp 还提供了一些图像数据,比如商户的图片数据,由于在本项目中用不到,故不对其进行介绍。

business 表
以下代码是以 Json 格式表示的 business 表中的一条数据,代表了一个商业组织的相关数据(例如某个餐馆)。

复制代码
{
// string, 22 character unique string business id
“business_id”: “tnhfDv5Il8EaGSXZGiuQGg”,
// string, the business’s name
“name”: “Garaje”,
// string, the full address of the business
“address”: “475 3rd St”,
// string, the city
“city”: “San Francisco”,
// string, 2 character state code, if applicable
“state”: “CA”,
// string, the postal code
“postal code”: “94107”,
// float, latitude
“latitude”: 37.7817529521,
// float, longitude
“longitude”: -122.39612197,
// float, star rating, rounded to half-stars
“stars”: 4.5,
// integer, number of reviews
“review_count”: 1198,
// integer, 0 or 1 for closed or open, respectively
“is_open”: 1,
// object, business attributes to values. note: some attribute values might be objects
“attributes”: {
“RestaurantsTakeOut”: true,
“BusinessParking”: {
“garage”: false,
“street”: true,
“validated”: false,
“lot”: false,
“valet”: false
},
},
// an array of strings of business categories
“categories”: [
“Mexican”,
“Burgers”,
“Gastropubs”
],
// an object of key day to value hours, hours are using a 24hr clock
“hours”: {
“Monday”: “10:00-21:00”,
“Tuesday”: “10:00-21:00”,
“Friday”: “10:00-21:00”,
“Wednesday”: “10:00-21:00”,
“Thursday”: “10:00-21:00”,
“Sunday”: “11:00-18:00”,
“Saturday”: “10:00-21:00”
}
}
为了让你对这些字段有更直观的认识,我截取了 Yelp 网站商家主页的部分内容,如下图所示,网页内容可以很容易地和上述数据对应。

Drawing 0.png
Drawing 1.png

结合网页内容,我们可以更形象地观察上面的代码,它包含商家的开业时间、评论条数、地理位置,以及属性值与类目信息等。

review 表
以下代码是以 Json 格式表示的 review 表中的一条数据,代表了一个用户(user)对一个商业机构(busniess)的一条评论。

复制代码
{
// string, 22 character unique review id
“review_id”: “zdSx_SD6obEhz9VrW9uAWA”,
// string, 22 character unique user id, maps to the user in user.json
“user_id”: “Ha3iJu77CxlrFm-vQRs_8g”,
// string, 22 character business id, maps to business in business.json
“business_id”: “tnhfDv5Il8EaGSXZGiuQGg”,
// integer, star rating
“stars”: 4,
// string, date formatted YYYY-MM-DD
“date”: “2016-03-09”,
// string, the review itself
“text”: “Great place to hang out after work: the prices are decent, and the ambience is fun. It’s a bit loud, but very lively. The staff is friendly, and the food is good. They have a good selection of drinks.”,
// integer, number of useful votes received
“useful”: 0,
// integer, number of funny votes received
“funny”: 0,
// integer, number of cool votes received
“cool”: 0
}
结合网页中与评论相关的内容进行观察,如下图所示:

Drawing 2.png

评论表的一条数据包含完整的评论文本数据,包括撰写评论的 user_id 和撰写评论的对象 business_id。结合网页,同样会让你有更形象的感知。

user 表
以下代码是以 Json 格式表示的 user 表中的一条数据,代表了一个用户的基本情况。

复制代码
{
// string, 22 character unique user id, maps to the user in user.json
“user_id”: “Ha3iJu77CxlrFm-vQRs_8g”,
// string, the user’s first name
“name”: “Sebastien”,
// integer, the number of reviews they’ve written
“review_count”: 56,
// string, when the user joined Yelp, formatted like YYYY-MM-DD
“yelping_since”: “2011-01-01”,
// array of strings, an array of the user’s friend as user_ids
“friends”: [
“wqoXYLWmpkEH0YvTmHBsJQ”,
“KUXLLiJGrjtSsapmxmpvTA”,
“6e9rJKQC3n0RSKyHLViL-Q”
],
// integer, number of useful votes sent by the user
“useful”: 21,
// integer, number of funny votes sent by the user
“funny”: 88,
// integer, number of cool votes sent by the user
“cool”: 15,
// integer, number of fans the user has
“fans”: 1032,
// array of integers, the years the user was elite
“elite”: [
2012,
2013
],
// float, average rating of all reviews
“average_stars”: 4.31,
// integer, number of hot compliments received by the user
“compliment_hot”: 339,
// integer, number of more compliments received by the user
“compliment_more”: 668,
// integer, number of profile compliments received by the user
“compliment_profile”: 42,
// integer, number of cute compliments received by the user
“compliment_cute”: 62,
// integer, number of list compliments received by the user
“compliment_list”: 37,
// integer, number of note compliments received by the user
“compliment_note”: 356,
// integer, number of plain compliments received by the user
“compliment_plain”: 68,
// integer, number of cool compliments received by the user
“compliment_cool”: 91,
// integer, number of funny compliments received by the user
“compliment_funny”: 99,
// integer, number of writer compliments received by the user
“compliment_writer”: 95,
// integer, number of photo compliments received by the user
“compliment_photos”: 50
}
而与数据对应的网站用户主页部分如下图所示:

Drawing 3.png

一条用户数据包括该用户的朋友以及与该用户关联的所有元数据。

tip 表
以下代码是以 Json 格式表示的 tip 表中的一条数据,代表了一条简短的评论。

复制代码
{
// string, text of the tip
“text”: “Secret menu - fried chicken sando is da bombbbbbb Their zapatos are good too.”,
// string, when the tip was written, formatted like YYYY-MM-DD
“date”: “2013-09-20”,
// integer, how many compliments it has
“compliment_count”: 172,
// string, 22 character business id, maps to business in business.json
“business_id”: “tnhfDv5Il8EaGSXZGiuQGg”,
// string, 22 character unique user id, maps to the user in user.json
“user_id”: “49JhAJh8vSQ-vM4Aourl0g”
}
与数据对应的网站小贴士页如下图所示:

Drawing 4.png

这种小贴士的对象同样是商家,但小贴士比评论更简短,并且倾向于传达快速的建议。

checkin 表
以下代码是以 Json 格式表示的 checkin 表中的一条数据,代表了一个商户的签到情况,包括商户的 business_id 及顾客的签到时间。

复制代码
{
// string, 22 character business id, maps to business in business.json
“business_id”: “tnhfDv5Il8EaGSXZGiuQGg”
// string which is a comma-separated list of timestamps for each checkin, each with format YYYY-MM-DD HH:MM:SS
“date”: “2016-04-26 19:49:16, 2016-08-30 18:36:57, 2016-10-15 02:45:18, 2016-11-18 01:54:50, 2017-04-20 18:39:06, 2017-05-03 17:58:02”
}
从 Yelp 运营负责人的视角分析
高质量的评论是 Yelp 的灵魂,作为 Yelp 的运营负责人,你希望能从评论中发现些有趣的东西,从而更好地帮助商家,活跃用户。

从数据方体的角度来看,review 表中 stars、useful、funny、cool 字段就是度量,而 busniess 表中的 city、state 字段以及 review 表中的 date 字段都可以作为时间与空间维度。通过数据方体与多维分析,能够很好地对数据进行分析。

Yelp 的商业智能系统希望构建一个关于评论的数据方体,并进行相应的分析与可视化。通过这种方法,将得到以商业机构为主体的相关“业务知识”,以辅助运营人员更好地决策。例如评分更高的商业主体有哪些特征,以及有哪些特征是我们平时所忽略的,等等。详细方法在之后的课程会展开。

这里注意,为了简化,本项目只会构建一个数据方体,但是在一般规模的生产环境中,商业智能系统需要构建的数据方体的数量非常多,基础数据也远远不止我们这个项目中的 5 张表。

需要特别说明的一点是,通常在数据仓库或者商业智能系统项目开始时,你并不会直接获取到上面下载的 Yelp 的数据集,我们需要的数据往往在业务数据库中,所以下个课时,我们将模拟将数据从业务数据库中导出的过程。

总结
作为项目开始的第一个课时,我主要带你熟悉数据集、数据结构,了解商业智能系统的分析需求。为了便于理解,无论是对数据集还是分析需求都做了大量简化。如果是真实情况,每个环节都会增加上百倍的复杂性。所以近期课程都需要你反复巩固,以便更好地运用到工作中。

第40讲:如何获取业务数据库的数据

在前面的内容中,我们特意说过在现实情况中,数据并不会被轻易获取,通常数据都在业务数据库中,需要将其抽取,进行下一步的转换操作。这一步在真实环境中会花费大量时间,尤其是数据量特别大的情况,因为通常会涉及巨量的读写性能消耗。

在不同的业务场景下,业务数据库通常会有不同的选择,主要分为两类:关系型数据库和NoSQL 数据库。

关系数据库:是创建在关系模型基础上的数据库,借助集合代数等数学概念和方法来处理数据库中的数据。现实世界中的各种实体以及实体之间的各种联系均用关系模型来表示。关系型数据库主要使用 SQL 作为自己的查询语言。

NOSQL(Not Only SQL):是对不同于传统关系数据库的数据库管理系统的统称。该系统允许部分数据使用 SQL 系统存储,允许其他数据使用 NOSQL 系统存储。其数据存储可以不需要固定的表格模式以及元数据,也经常会避免使用 SQL 的连接操作,一般有分布式、高可用、高可扩展的特征。

本课时的主要内容有:

项目架构

关系型数据库的数据导出

NoSQL 数据库的数据导出

项目架构
在开始本课时的主要内容前,我们先来看看整个项目的架构,也让你可以更好地了解这个项目的设计与规划。

下图从下至上是系统的分层设计,最下面是数据源,也就是我们的业务数据库。它通过数据导入层,导入到我们的大数据平台中,这个大数据平台通常由 Hadoop 生态构建,通过数据转换层进行转换与处理,处理后生成的数据集合可以看成数据集市,最后由 BI 应用访问数据集市层进行报表生成。可以看到数据从下往上地单向流动。

1.png

关系型数据库的数据导出
关系型数据库的导出过程比较简单,Spark 也有现成的方案,在之前的课程中,我们学习过 read 读取器 API,可以很方便地从支持 JDBC 的数据库中拉取数据,如下面的代码所示:

复制代码
import org.apache.spark.sql.SparkSession
object BICubing {

def main(args: Array[String]): Unit = {

val spark = SparkSession.builder().appName("BI-CUBING")
.master("local")
.getOrCreate()  

//busniess表
val busniess = spark.read.format("jdbc")
.option("driver","com.mysql.jdbc.Driver")
.option("url", "jdbc:mysql://master:3306/ttable")
.option("dbtable", "busniess")
.option("user", "root")
.option("password", "123456")
.load()

//user表
val user = spark.read.format("jdbc")
.option("driver","com.mysql.jdbc.Driver")
.option("url", "jdbc:mysql://master:3306/ttable")
.option("dbtable", "user")
.option("user", "root")
.option("password", "123456")
.load()

//checkin表
val checkin = spark.read.format("jdbc")
.option("driver","com.mysql.jdbc.Driver")
.option("url", "jdbc:mysql://master:3306/ttable")
.option("dbtable", "chenckin")
.option("user", "root")
.option("password", "123456")
.load()

//tip表
val tip = spark.read.format("jdbc")
.option("driver","com.mysql.jdbc.Driver")
.option("url", "jdbc:mysql://master:3306/ttable")
.option("dbtable", "tip")
.option("user", "root")
.option("password", "123456")
.load()

//review表
val review = spark.read.format("jdbc")
.option("driver","com.mysql.jdbc.Driver")
.option("url", "jdbc:mysql://master:3306/ttable")
.option("dbtable", "review")
.option("user", "root")
.option("password", "123456")
.load()

busniess.createOrReplaceTempView("busniess")
user.createOrReplaceTempView("user")
tip.createOrReplaceTempView("tip")
checkin.createOrReplaceTempView("checkin")
review.createOrReplaceTempView("review")    

}

}
从代码中可以看到,读取出来的数据可直接进行转换与处理,这个过程需要比较长的时间,主要取决于数据量的大小与数据库的读性能。由于数据库不是分布式的,它的读性能至关重要。

NoSQL 数据库的数据导出
NoSQL 数据库主要以开源软件为主,产品丰富,使用广泛。在很多场景下,它已经取代了关系型数据库,其中比较有代表性的就是 MongoDB。

MongoDB 是一个文档数据库,它将数据存储在类似 JSON 的文档中。这是认识数据的最自然方法,比传统的行/列模型更具表现力和功能。它的主要特点有:

丰富的 JSON 文档

是处理数据最自然、有效的方式。

支持数组和嵌套对象作为值。

允许灵活和动态的模式。

强大的查询语言

丰富且富有表现力的查询语言,无论你的文档中有多少个嵌套,都可以按任何字段进行过滤和排序。

支持聚合和其他现代用例,例如基于地理的搜索、图形搜索和文本搜索。

查询本身就是 JSON,因此很容易组合,不再需要连接字符串来动态生成 SQL 查询。

类似关系型数据库的一些特性

具有快照隔离的分布式多文档 ACID 事务。

支持查询连接。

从上面可以看出,MongoDB 的底层数据结构和 JSON 非常类似,所以 MongoDB 也提供直接将数据导出为 JSON 格式的工具,我们可以使用 mongoexport 命令将文档集合导出为 json 文件。导出命令如下面的代码所示:

复制代码
mongoexport -d dbtable -c busniess --json -o /yourpath/busniess.json
mongoexport -d dbtable -c review --json -o /yourpath/review.json
mongoexport -d dbtable -c tip --json -o /yourpath/tip.json
mongoexport -d dbtable -c user --json -o /yourpath/user.json
mongoexport -d dbtable -c checkin --json -o /yourpath/checkin.json
导出完成后,大多数情况下,还需要将其上传到 Hadoop 的文件系统 HDFS 中,HDFS 提供了 put 命令,非常方便,命令如以下代码所示:

复制代码
hadoop dfs -put /yourpath/busniess.json /dw/bi/busniess.json
hadoop dfs -put /yourpath/review.json /dw/bi
hadoop dfs -put /yourpath/tip.json /dw/bi/
hadoop dfs -put /yourpath/user.json /dw/bi/
hadoop dfs -put /yourpath/checkin.json /dw/bi/
第一个路径地址是本地文件地址,也就是 MongoDB 导出的地址,第二个路径地址为上传到 HDFS 的文件夹地址。

上传完成后,还需要用 Spark 读取器 API 进行读取,以便后续的转换与处理,代码如下:

复制代码
import org.apache.spark.sql.SparkSession
object BICubing {

def main(args: Array[String]): Unit = {

val spark = SparkSession.builder().appName("BI-CUBING")
.master("local")
.getOrCreate()  

val busniess = spark.read.format("json").load("/dw/bi/busniess.json")
val user = spark.read.format("json").load("/dw/bi/user.json")
val checkin = spark.read.format("json").load("/dw/bi/checkin.json")
val review = spark.read.format("json").load("/dw/bi/review.json")
val tip = spark.read.format("json").load("/dw/bi/tip.json")
busniess.createOrReplaceTempView("busniess")
user.createOrReplaceTempView("user")
tip.createOrReplaceTempView("tip")
checkin.createOrReplaceTempView("checkin")
review.createOrReplaceTempView("review")

}

}
总结
数据导出过程非常耗时且重要,是 ETL 的重要组成部分,也是数据分析的基础。通常来说,在生产环境中,你的业务数据库不止有一种,所以这个过程有可能非常复杂。本课时完成的是分层设计中的数据导入层,使用的是数据源层的数据。

另外,大家可以看到,在 NoSQL 数据库的数据导出的过程中,既有命令行,也有 Spark 代码,两者交替进行,所以你还需要将这些过程整合到一个过程中去,这部分我们将在下个课时介绍。

本节课的内容看似简单,但要运用到实际情况中还需反复练习,所以我们暂不介绍更多,希望你在课后努力消化,有问题欢迎留言。

第41讲:如何构建数据立方体

前面的课时中,我们已经将数据从业务数据库中导出到大数据平台里,可以简单地将导出后的数据集合视为数据仓库。当然在实际情况中,数据也不可能这么合适,这个时候就需要进行数据清洗与转换,在这之后,数据集合才能被称其为数据仓库,这个过程就是我们前面提到的 ETL。

从数据源到数据集市的整个过程如下图所示。而我们今天要学习的是下一个步骤:为数据仓库中的数据构建数据立方体,也就是图中的 Cubing 操作。生成的数据立方体就是实际意义上的数据集市。

1.png

本课时的主要内容有:

构建数据立方体

整合脚本

调度平台

构建数据立方体
这节课,我们继续用 Yelp 内部业务数据集(Yelp 2016 Dataset Challenge)中的数据进行演示。

我们用其中的 busniess 表与 review 表构建一个数据立方体, 用来分析有关评论的信息,代码如下:

复制代码
//接上个课时的代码
val sqlStr = “SELECT r.cool, r.funny, r.stars, r.userful, l.city, l.state, r.date FROM business l JOIN review r ON l.business_id = r.business_id”
//执行SQL
spark.sql(sqlStr).write.orc("/yourpath/dm")
用连接操作,将 busniess 表和 review 表连接在一起,并过滤 掉了无关字段,例如 review 表中的 text 字段,该字段是一大段文本,占用空间非常大,过滤掉这个字段可以显著提高查询性能。不过这里要特别说明的是,我们可以用一些人工智能技术将这个字段处理为结构化的数据,从而便于后续处理,这也是 ETL 的一种。其中要强调的是,最后保存到文件系统时,我们采用了 ORC 格式(Optimized Row Columnar,是一种 Hadoop 生态圈中的列式存储格式),这对于数据立方体来说非常友好,ORC 格式可以显著降低连接后的数据集大小。总之,在生成数据立方体的同时,需要考虑如何优化查询性能。

同样,在一个较大型的组织中,一般会同时构建成百上千个数据立方体,于是选择 ORC 格式储存就显得尤为重要。因为在一个正常的商业智能系统的项目中,一般来说 ETL 和构建数据立方体会占整个开发时间的 70%-80%,而真正的分析只需要很少的时间。

根据“第 39 课时|作为 Yelp 运营负责人,如何根据数据进行决策”的内容我们已经知道,该数据立方体的维度主要分为地点(city、state)和时间(date),度量分别为 cool、funny、stars、useful 字段。

整合脚本
数据立方体构建完成后,需要将数据导出与构建数据立方体的过程整合,以便能够一次性完成整个过程。

以业务数据库 MongoDB 为例,我们先把 MongoDB 数据导出并上传到 HDFS 的过程拆开,分别写成两个脚本。

第一个脚本如以下代码所示,是负责将 MongoDB 中的数据导出到本地文件系统的 mongoexport.sh:

复制代码
#!/bin/bash
mongoexport -d dbtable -c busniess --json -o /yourpath/busniess.json
mongoexport -d dbtable -c review --json -o /yourpath/review.json
mongoexport -d dbtable -c tip --json -o /yourpath/tip.json
mongoexport -d dbtable -c user --json -o /yourpath/user.json
mongoexport -d dbtable -c checkin --json -o /yourpath/checkin.json
第二个脚本是将导出的文件上传到 HDFS 的脚本 upload.sh,如以下代码所示:

复制代码
#!/bin/bash
hadoop dfs -put /yourpath/busniess.json /dw/bi/busniess.json
hadoop dfs -put /yourpath/review.json /dw/bi
hadoop dfs -put /yourpath/tip.json /dw/bi/
hadoop dfs -put /yourpath/user.json /dw/bi/
hadoop dfs -put /yourpath/checkin.json /dw/bi/
然后我们需要将 Spark 代码打包为一个 jar 包,并编写一条提交 Spark 作业的命令,如下面的代码所示:

复制代码
spark-submit --name Cubing --class com.yelp.BICubing --master yarn-cluster --executor-memory 2G --num-executors 10 /yourpath/cubing.jar
经过上述操作,所有的过程就都脚本化了。现在我们需要调度这三个脚本,使它们在一定的时间以一定的顺序执行,这就需要用到调度平台。

调度平台
前面提到过,在一个较大型的组织中,一般会同时构建成百上千个数据立方体,每天定时运行的作业多达数千个,作业与作业之间的依赖错综复杂,并非像我们演示的项目一样,会按照顺序执行。如果没有一个统一的作业调度工具的话,难以管理如此复杂的工作流。

在生产环境的集群中,一般都会配备一个调度器,也称为调度中心,负责集群所有的作业调度工作。Hadoop 生态圈有 Java 编写的 Apache Oozie,但由于其功能的局限性,尤其不能表现有向无环图的作业依赖关系,已经逐渐退出历史舞台。

目前比较成熟的调度器有 Airbnb 的 Airflow 和 LinkedIn 的 Azkaban。本节课采用 Airflow 作为系统的调度中心。

Airflow 是用 Python 实现任务管理、调度、监控工作流的平台。与 Ozzie 的 XML 配置文件相比,Airflow 的理念是“配置即代码”,对于描述工作流、判断触发条件等过程,全部采用 Python 脚本,编写工作流就像在写脚本一样,能更快捷地在线上做功能扩展。Airflow 充分利用 Python 的灵巧轻便,是一款非常好用的调度器。

下面的图片是 Airflow 的主界面,DAG 选项列出了所有工作流的配置,可以看到每份配置都是一份 Python 代码文件。

Drawing 1.png

下图是以可视化的形式展现数据处理的工作流:

Drawing 2.png

我们可以在线编辑配置代码文件,下图是一份代码文件:

Drawing 3.png

Airflow 的作业调度配置文件就是一个 Python 脚本。在脚本中,可以用 Python 灵活地定义计算作业工作流。编写完成后,需要将该脚本放置在位于 Airflow 安装目录下、airflow.cfg 文件中配置项 dags_folder 指定的目录下,放置完成即可生效。根据本课时的内容,一共需要配置 3 个作业:MongoExport、Upload 和 BICubing,配置文件如下:

复制代码
“”"
Code that goes along with the Airflow tutorial located at:
https://github.com/airbnb/airflow/blob/master/airflow/example_dags/tutorial.py
“”"
from airflow import DAG
from airflow.operators.bash_operator import BashOperator
from datetime import datetime, timedelta

定义调度参数

default_args = {
‘owner’: ‘yourname’,
‘depends_on_past’: False,
‘start_date’: datetime(2018, 9, 28),
‘email’: [‘[email protected]’],
‘email_on_failure’: False,
‘email_on_retry’: False,
‘retries’: 1,
‘retry_delay’: timedelta(minutes=5),
# ‘queue’: ‘bash_queue’,
# ‘pool’: ‘backfill’,
# ‘priority_weight’: 10,
# ‘end_date’: datetime(2020, 1, 1),
}

dag = DAG(‘dag-datalake’, default_args=default_args)

定义3个作业

task1 = BashOperator(
task_id=‘MongoExport’,
bash_command=’./mongoexport.sh’,
dag=dag
)

task2 = BashOperator(
task_id=‘Upload’,
bash_command=’./upload.sh’,
dag=dag
)

task3 = BashOperator(
task_id=‘Cubing’,
bash_command=‘spark-submit --name Cubing --class com.yelp.BICubing --master yarn-cluster --executor-memory 2G --num-executors 10 /yourpath/cubing.jar’,
dag=dag
)

定义作业执行顺序

task2.set_upstream(task1)
task3.set_upstream(task2)
在脚本里,我们定义了 3 个计算作业及其执行命令,最后定义了作业之间的依赖关系,这会决定作业的执行顺序。定义的方法非常简单,每个作业只需要指定自己的上游作业即可:

复制代码
task2.set_upstream(task1)
task3.set_upstream(task2)
这样定义的计算作业的工作流如下图所示:

Drawing 4.png

我们还可以用如下代码的方式,构造一个作业依赖多个作业的情况:

复制代码
task1.set_upstream(task2,task3)
定义的结果如下图所示:

Drawing 5.png

定义完成后,用户可以配置执行时间,作业将会自动运行。

总结
在本课时中,我们学习了如何构建数据立方体,在生成数据立方体的同时,也就成功构建了数据集市。另外,我们还引入了开源调度平台 Airflow,完成了整个过程的调度与定时执行,在生产环境中,调度平台的使用非常必要。从这个课时开始,我们引入了开源软件来满足我们的需求,这在大数据开发中非常常见。在下个课时中,我们将使用 Airbnb 开源的另一个软件来满足分析需求与可视化需求。

本节课仍然不留额外的思考题,希望你反复巩固内容,以便应对更复杂的项目。

第42讲:如何通过 OLAP 与报表呈现结果

在上个课时里,我们已经成功构建了数据立方体,在结果呈现之前只剩下最后两步:多维分析和结果可视化。在一个商业智能系统中,最后两步是呈现给客户的关键,如果是自己开发,需要大量的工作。在这个课时里,我们将介绍两种开源技术 Superset 和 Presto,以满足实时分析和可视化的需求。其中 Presto 可以用来完成我们对数据立方体进行多维分析的需求,而 Superset 则可以用来将 Presto 返回的结果进行可视化呈现。两者之间本身也能很好地集成,非常适合我们的项目。

Presto
Presto 是一种用于大数据的高性能分布式SQL查询引擎,其架构允许用户查询各种数据源,如 Hadoop、AWS S3、Alluxio、MySQL、Cassandra、Kafka 和 MongoDB。你甚至可以在单个查询中查询来自多个数据源的数据。

Presto 是Apache 许可证下发布的社区驱动的开源软件。它是 Dremel 的实现,与 ORC 配合通常会有非常好的效果。从前面的课时内容可以得知,得益于 Dremel 架构,Presto 是非常好的 OLAP 与 Ad-hoc 查询工具,并且支持 JDBC。

它最初是Facebook为数据分析师设计和开发的,用于在 Apache Hadoop 中的大型数据仓库上运行交互式查询。这意味着,它可以直接对 HDFS 上的数据进行分析,这是一个非常有用的特性。联想到数据立方体,可以发现这是一个逻辑的概念。在大数据概念出现以前,数据立方体通常通过事实表 + 维度表,也就是星型模型的方式呈现(如“第 38 课时 | 数据仓库与商业智能系统架构剖析”中的表格所示)。这种方式的数据冗余性很低,但是每次进行分析都需要连接操作,可以说是用时间换空间。

但是有了列式存储以后,这个问题迎刃而解,虽然数据冗余性很高,但实际占用空间并不大,对于后续的多维分析来说又非常友好,无疑比星型模型更优。另外,Presto 可以直接操作 HDFS 上的数据,也就无需将数据立方体再次导出,少了很多工作量。而传统的数据集市往往基于数据库,这样就需要将数据再次导出到数据库中。

此外,还有一些需要简要了解的内容。

Presto 的架构与使用集群计算(MPP)的传统数据库管理系统非常类似,它可以视为一个协调器节点,与多个工作节点同步工作。客户端提交已解析和计划的 SQL 语句,然后将并行任务安排给工作节点。工作节点一同处理来自数据源的行,并生成返回给客户端的结果。同在每个查询上使用 Hadoop 的MapReduce机制的原始 Apache Hive 执行模型相比,Presto 不会将中间结果写入磁盘,从而明显提高速度。

Presto 是用Java 语言编写的,单个 Presto 查询可以组合来自多个源的数据。Presto 提供数据源的连接器,包括 Alluxio、Hadoop 分布式文件系统、Amazon S3 中的文件、MySQL、PostgreSQL、Microsoft SQL Server、Amazon Redshift、Apache Kudu、Apache Phoenix、Apache Kafka、Apache Cassandra、Apache Accumulo、MongoDB 和 Redis。与其他只支持 Hadoop 特定发行版的工具(如 Cloudera Impala)不同,Presto 可以使用任何风格的 Hadoop,也可以不用 Hadoop。它支持计算和存储的分离,可以在本地和云中部署。

由于 Presto 支持 JDBC,所以在使用上与任何支持 JDBC 的 SQL 工具来说并没有任何不同,这点我们在后面可以看到。

Superset
Superset 是由 Airbnb 开源出来的企业级商业智能工具,目前属于 Apache 孵化器,但是其关注度已经超越了很多 Apache 顶级项目。简单来说,Superset 主要提供以下几方面的功能:

与数据源和 OLAP 工具进行集成。

配置数据立方体。

Ad-hoc 查询。

以仪表盘和卡片的形式提供的可视化解决方案。

权限管理。

下面结合本项目对 Superset 进行介绍。

首先登录 Superset 应用:

Drawing 0.png

登录后进入仪表盘页面,如下图:

Drawing 1.png

可以看到这里还没有一个存在的仪表盘,我们先不进行创建,而是进入配置数据源的页面,点击 Source 选项卡,选择 Database,并新增一条记录,如下图:

Drawing 2.png
Drawing 3.png

此时,通过 Presto 的 JDBC 就能非常容易地将 Presto 与 Superset 进行集成,在红色方框内填入 JDBC URL,如下图:

Drawing 4.png

配置完成数据源后,我们就可以进入到 SQL Lab 对数据进行查询了,如下图:

Drawing 5.png

在 SQL Lab 中,我们可以对数据源进行探索,也就是前面所说的 Ad-hoc 查询,如下图:

Drawing 6.png

在对 Superset 有了一个大致了解后,下面学习 Superset 最重要的功能:仪表盘(Dashboard)和卡片(Chart)。

仪表盘和卡片
仪表盘是商业智能系统面向用户的最终产物,是用户查看一组报表分析结果的地方,如下图:

Drawing 7.png

上图的仪表盘里有 4 个报表,每个报表是一个卡片。卡片与仪表盘是灵活的多对多关系,可以对不同的仪表盘进行复用,非常方便。

我们先创建好一个仪表盘,即来到 Dashboard 页面,新建一个 Dashboard,如下图:

Drawing 8.png

接着生成卡片,从 SQL Lab 的 Explore 按钮开始:

Drawing 9.png

生成过程也就是 Superset 多维分析的实现,所以,这里需要特别注意的是,在点击 Explore 之前,文本框中 SQL 查询的结果(不需要执行)代表数据立方体。也就是说,不管你是否预先构建好了数据立方体,这里都需要用 SQL 进行描述。对于本课程中的项目来说,当然非常简单,只需要一个普通的 SELECT 查询即可,如果没有预先构建立方体,这里可能就需要进行连接操作。

点击 Explore 后,会进入多维分析的配置页面,如下图:

Drawing 10.png

在红色方框内,我们可以按照前面介绍的多维分析方法选择要分析的维度、统计的指标,例如求和、求均值、求最值等。配置完成后,系统会自动匹配可视化方案,如果没有时间序列,一般用饼图,如果有时间序列,则用柱状图或者折线图。点击Save 按钮,就可以将其保存为一个卡片,在保存的时候,可以选择与 MyYelp 仪表盘进行关联。我们可以按照这种方式,生成卡片并将其以仪表盘的形式呈现给用户,自此,整个商业智能系统就算完成了。

总结
本课时主要介绍了两个开源工具 Presto 与 Superset,这两个工具对整个系统的完成度提升来说,效果很大,但如果自己要实现 Superset 这种 BI 工具,可想而知工作量是巨大的。另外, Superset 除了支持常规的 JDBC 的 SQL 工具外,还支持很多开源的分析工具,如 Kylin、Druid 等。最后要说明的是,本课时选用 Presto,并不是因为其查询性能快,而是因为它可以直接操作 HDFS 上的数据。

第43讲:两个简化了的重要问题:数据更新和数据实时性

在前面的内容中,我们完成了一个商业智能系统,为了方便理解,我做了简化。但是无论从业务复杂性还是技术复杂性来说,我们在课程中模拟的系统与真实环境还有很大的差距。所以这节课,我选取了两个在真实环境中比较常见的问题:数据更新和数据实时性,进行更具体的讨论。

其中,我会围绕数据更新展开详细介绍,以便帮助你根据自己的业务需求进行调整,然后对数据实时性进行简要介绍,帮助你理解真实环境中的复杂数据变化。

本课时的主要内容有:

数据更新

数据实时性

数据更新
通常来说,在一个大数据项目中,如果 ETL(Extract-Transform-Load,抽取、转换和加载,通常也叫数据清洗)所花费的时间占整个项目开发时间的 60% 以下,那么项目失败的风险会比较大。这说明数据清洗在整个项目中的重要性,也指出在实际情况中,数据清洗的工作量通常也非常大。

数据清洗的需求主要和具体业务相关,但是有个问题是每个项目都会面对的,就是如何处理数据更新环节。数据更新是每个数据仓库或商业系统项目都会考虑的问题,这是因为我们的数据源,也就是业务数据库每天都会出现增删改查的操作,如果将这些修改操作按照我们的要求与数据仓库进行同步,会非常麻烦。另外,大数据出现以后,对数据更新的频率也提出了更高的要求,以前一个月同步一次更新还可以接受,现在即使要求每天同步更新,也非常正常。

基于定期同步的需要,便引入了全量数据和增量数据的概念,这里我们假设同步周期为 t 来进行分析。

全量数据就是迄今为止存在于数据仓库中的数据,这部分数据是在前面课时中,通过我们开发的转换任务脚本转换而成,但是这些任务只会在系统上线时执行一次,执行完成后,全量数据的处理工作就已经完成了。所以在执行完成后,后面会以 t为周期对更新数据进行处理,这样才能保证系统的结果是最新的。

增量数据和全量数据不同的是,它以日志的形式进行推送,无法直接进行分析,而全量数据虽然可以直接进行分析,却无法捕捉到最新变化。所以在对更新数据进行处理时,比较推荐的解决办法是将增量数据与全量数据进行合并。

合并的方式如下图所示,其中 n 是天数的序号。

Lark20200908-183426.png

也就是说,当前的全量数据和增量数据会生成一份新的全量数据,周而复始。新的全量数据既可以直接进行分析又包含了最新的信息。数据仓库中的每一张表,每天(或者是每个周期)都需要执行这个过程。

而在实际情况中,我们需要根据具体的业务需求进行合并。例如针对订单数据库的情况,每一条订单数据都会存在多个状态,如创建、支付、发货、收货等,如果只保存最新的状态,会漏掉很多有用的信息。在这种情况下,我们在合并时,需要注意保存每个订单的每个状态,用这种方式保存的表称为拉链表,即它反映了表中每个实体的变化过程。而如果需要反映每条记录的修改,就像增量数据中的日志那样,那么保存的表就称为流水表。

以 t 为一天为例,根据合并过程可以看出,在每个合并周期,即每一天后,都会生成一个新版本的全量数据。我们可以采取分区表的方式来保存这些新版本的全量数据,即将每一个新的全量数据称为整个分区表中的一个分区。

这只是一种更新方式,你在面对自己的业务需求时,需要根据具体情况进行调整。业务数据库通常会由于历史原因出现各种各样的问题,这些都会在数据同步与更新的时候暴露出来,需要特别引起重视。

数据实时性
数据实时性的问题与数据更新息息相关,对于商业智能系统的使用方来说,会倾向于要求数据更新的频率越快越好,最好能接近于实时。一旦数据更新同步接近于实时,后续的数据处理当然也需要匹配到相应速度,否则意义不大,而且最终给用户呈现的报表也需要实时体现最新的数据。我们目前的方法做不到这个效果,而如果要数据分析结果体现实时数据,则需要采用一种新的架构,这也是我们下节课的内容。

此外,你也可以结合“第 21 课时|统一批处理与流处理:Dataflow”中的 Dataflow进行理解。如果需要得到最好的实时性,那么势必要对结果的正确性有所舍弃,而如果我们希望用户看到的结果在大多数情况下是正确的,就需要忍受相应的延迟。

所以在某种程度上,数据更新和数据实时性是一个问题。

总结
本课时主要讨论了两个生产环境的需求与场景,在真实的工作环境中,需要一个个地解决问题,经验就会慢慢积累,也要多熟悉开源技术,这样会让你事倍功半。

下节课我们要学习的是最后一个课时“另一种并行:Lambda 架构和 Kappa 架构”。完成下节课的学习后,我们的最后一个模块:商业智能系统实战就完成了。下个课时也可以看成是对这个课时提出的两个问题的解答,学习完下个课时,你将会对大数据处理架构有更加完整、清晰的认识。

在这里,我还想再次强调的是,整体实战模块的内容看上去相对简单,但需要你在课后练习的部分较多。随着我们的课程接近尾声,我也更加想要听到你在实践过程中遇到的问题,你的每一个反馈,对我而言都是最重要的。所以还是欢迎你来留言,与我一起讨论学习 Spark 课程遇到的问题。

第44讲:另一种并行:Lambda 架构与 Kappa 架构

在上个课时中,我提出了一个问题:数据仓库如何实时获取更新的数据,并将结果融合到最新的报表中,也就是提供统一的查询方式呢?本课时我会介绍一种新的架构模式 Lambda,它能很好地解决上一课时的问题。

本课时的主要内容有:

Lambda 架构

Kappa 架构

Lambda 架构
在前面的项目中,我们对全量数据进行了一次性处理,也就是批处理。但在真实环境下,数据经常实时更新,一般来说会想到运用流处理技术。但事实上,这两种技术都不能完全满足我们的需求。

流处理通常应对的是低吞吐、低延迟的场景,而批处理应对的则是高吞吐、高延迟的场景。对于 Hadoop 架构下的数据仓库来说,虽然能够对全量数据进行批量处理,但处理完的数据并不能真实反映外部系统的情况,会有一定时延。而流处理平台虽然能对当前时间的窗口数据进行很好的处理,但得到的结果只能反映当前视图。

那是否有一种架构能够将这两种数据处理方式的优点相结合,以达到我们的要求呢?答案就是下面介绍的 Lambda 架构。

Lambda 架构是由 Storm 的创始人 Nathan Marz 提出的一种实时大数据系统方法论,它借鉴了很多函数式编程思想。在 Lambda 架构中,首先将整个系统分为 3 层,即批处理层(Batch Layer)、速度层(Speed Layer)与服务层(Serving Layer),如下图所示。其中,服务层分别依赖速度层与批处理层,而批处理层与速度层之间没有依赖。

Lark20200910-190544.png

批处理层
Lambda 架构的批处理层会对数据进行一次性处理。从数学层面来说,会通过下面的等式来对所有数据进行分析:

复制代码
query = function(all data)
但等式若要成立却很困难,很难有一种技术可以直接对全量数据进行处理并马上返回结果,那么退而求其次的方案是:基于数据仓库的思路对全量数据进行批处理,然后对批处理的结果视图进行查询,也就是说新的等式应该如下所示。

复制代码
batch view = function(all data)
query = function(batch view)
这么说或许有些抽象,但用传统数据仓库与数据集市的星形模型可以很好地解释,如下图所示。

Lark20200910-190009.png

星形模型的维表与事实表就是我们所说的批处理视图(在项目中对应我们生成的 ORC 格式的数据立方体),而处理过程可以视作某种函数。在进行查询的时候,无须扫描数据仓库中的所有数据,只需对星形模型所代表的数据方体进行上卷、下钻、切片等操作。比如,如果需要查询某个商品在某个时间段,以及某个地区的销量,只需要在数据集市中用按照时间维度、地区维度以及商品维度进行聚合即可。

但是我们要看到这种方式的缺陷:以这种方式进行处理的时候,在这段时间内收集到的数据不会被包含到批处理视图中,所以查询结果的时效性会很差。此外,数据仓库中的数据被称为主数据集,它表示到目前为止所有可用的数据。而到现在为止,批处理的工作就是基于主数据集生成批处理视图,从而忽略了上述时间内收集到的数据。

服务层
既然批处理的工作只是生成批处理视图,那么我们还需要将其加载并提供查询服务,这就是服务层的工作。服务层是一个数据库,它会保存批处理视图,当新一批次的批处理完成后,新的批处理视图会替换旧的批处理视图,于是新的批处理视图已经包含了新数据的处理结果。

服务层的数据库面对的场景很特殊,在该场景下,只有读查询而完全没有随机写操作,但包含批量写入(在加载新的批处理视图时的一次性操作)。这样一来,服务层的数据库就避免了随机写所带来的各种问题,如一致性等。一般来说,要想保证整个架构的良好扩展性,每个组件都需要有很好的扩展性,所以服务层数据库一般选择分布式数据库,如 HBase。

速度层
当批处理层计算出批处理视图并加载到服务层后,这段时间产生的新数据是没有体现在查询结果中的。于是就需要速度层来对这部分数据进行流处理。

也就是说,速度层负责处理两次批处理之间产生的新数据,但是流处理的模式与批处理不同,它并不是只做一次计算,而是持续不断地处理。所以速度层做的是增量计算,与服务层的批处理视图没有随机写入不同,速度层的流处理视图包含大量随机写入操作,并混合着部分的读操作。

流处理的工作就是处理新数据并更新至实时视图。在下一次批处理完成后,这个间隔产生的实时视图就可以完全丢弃,因为最新的批处理视图已经包含了这部分的结果,而速度层则又从零开始构建实时视图,如此循环往复。从使用场景上来看,实时视图的数据库比批处理视图的数据库的复杂程度要高很多。

我们也可以用下面的等式来概括速度层:

复制代码
realtime view = function(realtime view,new data)
从等式中可以看出,新的实时视图需要老实时视图作为输入,这就是平时常说的增量计算,也是速度层很大一部分读操作的来源。

Lambda 架构
通过前面三小节的内容,我们已经了解了批处理层、服务层与速度层。可以用如下的数学公式来总结整个操作过程:

复制代码
batch view = function(all data)
realtime view = function(realtime view,new data)
query = function(batch view, realtime view)
这也解答了本节开头的一个问题:如何实时地对全量数据进行分析?答案是,需要对批处理视图和实时视图进行查询并合并,才能得到正确的、实时的结果。这也是Lambda 架构的操作流程,如下图所示。

Lark20200910-190005.png

这个架构有个优点,一旦批处理层重算生成了新的批处理视图,当前实时视图的结果立刻可以丢弃。也就是说,如果当前的实时视图有什么问题的话,只需要丢弃掉当前的实时视图,并在几小时内(甚至更短的时间),整个系统就可以重新回到正常状态。

整个系统的结果的正确性建立在算法能很好地支持增量计算的基础上。如果算法无法支持增量计算,那么速度层只能采取近似处理,而由于批处理层可以实现精确算法,整个系统仍然具有最终准确性。可以说,Lambda 架构在性能和准确性上做出了很好的权衡,每隔一段时间,批处理层会纠正速度层的错误,速度层的不准确只是暂时的,但这个特性可以给你极大的灵活性。

Nathan Marz 创造 Lambda 架构这个术语来描述一种可扩展、容错数据处理的通用模式,在这个模式中,最重要的是速度层与批处理层。有趣的是,虽然这个架构为什么被命名为 Lambda(λ)我们并不清楚,但从λ 的形状也可以得到解释。λ 的形状似乎暗示了批处理层与速度层通过服务层进行合并的过程,如下图所示,这多少与 Lambda 架构本身有一些契合。从某种层面上来说,Lambda 架构也体现了一种并行计算的思路。

Drawing 3.png

Lambda 架构的原则
Nathan Marz 总结了 Lambda 架构的原则,主要有 3 条。

容错性:Lambda 架构的每个组件都应该具有很好的容错性,在任何情况下,都不会存在数据丢失的情况,此外,对于那些数据处理的错误,也要能很好地恢复。

数据不可变:这一点其实是 Lambda 的灵魂。Lambda 架构之所有能够如上述般架构,最重要的原因是数据不可变,这样的系统具有很小的复杂性,也更易于管理。它只允许查询和插入数据,不允许删除与更改数据。

重算:重算可以使架构充满灵活性,由于主数据集总是可用的,因此总可以重新定义计算来满足新需求,这意味着主数据集并没有和什么模式绑定在一起。

Kappa 架构
前面已经介绍了 Lambda 架构的理论和实现,它的优点有容错性、可扩展性,低延迟读取等,但它的最大缺点在于需要维护两套业务逻辑相同的代码。而当时还是 LinkedIn 的高级技术主管 Jay Kreps 提出了一种类 Lambda 架构的新架构 Kappa 架构,可以改善 Lambda 架构的不足。

Kappa 架构与 Lambda 架构很相似,但它的批处理层被删除,只保留速度层。这样做主要是想避免不得不从头计算一个批处理视图的过程,而是尝试将几乎所有的计算都放在速度层。通过这样的设计,它避免了 Lambda 架构的最大问题:必须对同样的业务需求进行两次实现(批处理层与速度层)。

下图是Kappa 和 Lambda 架构的并排比较。你可以清楚地看到,在 Kappa 架构中唯一缺少的部分是批处理层。

Drawing 4.png

与 Lambda 架构相比,Kappa 架构只会在必要时才对历史数据进行重复计算,而不会定时计算,所有计算都由流计算引擎完成,所以代码只用维护一份。平时数据通常保存在消息队列中,计算的时候从中读取即可,需要一天就读一天,需要全量就读全量,读取后利用流处理系统进行处理,然后输出到服务层,新旧版本输出的结果互不相关,如下图所示。

Lark20200910-185956.png

目前 Kappa 架构最大的缺点在于:由于只使用流处理系统作为计算引擎,所以它的数据处理能力有限,无法对全量的历史数据进行很好的分析。

那么在实际情况中,该如何对Lambda 架构和 Kappa 架构进行选择呢?在下面的表格中,我详细对比了两种架构的数据处理能力、机器开销等,你可以根据需求进行选择。

Lark20200910-190002.png

总结
总体而言,Lambda 架构是在批处理、流处理技术共同作用下的实时大数据架构,它在某种程度上解决了我们上一课时的问题:如何实时获取数据进行处理,并提供统一的查询方式。值得一提的是搜索引擎就是典型的 Lambda 架构,很多开源大数据分析平台也采用了 Lambda 架构,如 Druid 等。而这一课时介绍的两种常见的实时大数据架构,也算是对整个实践模块的升华。

到目前为止,所有的课程就结束了。但别急着走,我还会更新一个彩蛋:“如何成为 Spark Contributor”,向你介绍如何向Spark贡献代码。此外还会在结束语中介绍一些业界比较新颖的架构和理念,也是对整个课程的总结。

猜你喜欢

转载自blog.csdn.net/weixin_45091011/article/details/114157328