first commit
This commit is contained in:
124
专栏/零基础入门Spark/00开篇词入门Spark,你需要学会“三步走”.md
Normal file
124
专栏/零基础入门Spark/00开篇词入门Spark,你需要学会“三步走”.md
Normal file
@ -0,0 +1,124 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 入门Spark,你需要学会“三步走”
|
||||
你好,我是吴磊,欢迎和我一起入门学习Spark。
|
||||
|
||||
在过去的7年里,我一直在围绕着Spark来规划我的职业发展。2014年,Spark以“星火燎原”之势席卷了整个大数据生态圈,正是在那个时候,我结识了Spark。起初,怀揣着强烈的好奇心,我花了一个星期,用Spark重写了公司(IBM)的ETL任务。
|
||||
|
||||
让我颇为惊讶的是,Spark版本的ETL任务,在执行性能上提升了一个数量级。从那以后,我便深深地着迷于Spark,孜孜不倦、乐此不疲地学习、实践与Spark有关的一切,从官方文档到技术博客,从源代码再到最佳实践,从动手实验再到大规模应用,在这个过程里:
|
||||
|
||||
|
||||
在IBM,我用Spark Streaming构建了流处理应用,帮助业务人员去实时分析用户行为。
|
||||
在联想研究院,我用Spark SQL + Hive搭建的公司级数仓,服务于所有业务部门。
|
||||
在微博,我基于Spark MLlib来构建微博机器学习框架,配置化的开发框架让上百位算法工程师从繁重的数据处理、特征工程、样本工程中解脱出来,把宝贵的精力和时间投入到了算法研究与模型调优上来。
|
||||
在FreeWheel,在所有的机器学习项目中,我们使用Spark进行数据探索、数据处理、特征工程、样本工程与模型训练,将一个又一个机器学习项目落地到业务中。
|
||||
|
||||
|
||||
为了把Spark吃得更透,在日常的工作中,我热衷于把学过的知识、习得的技巧、踩过的坑、绕过的弯路付诸笔头。通过这种“学、用、写”不断迭代的学习方式,我把零散的开发技巧与知识点,逐渐地归纳成了结构化的知识体系。
|
||||
|
||||
在2021年的3月份,我与极客时间合作了《Spark性能调优实战》这一专栏,把我积累的与性能调优有关的技巧、心得、最佳实践分享给有需要的同学。
|
||||
|
||||
让我欣慰的是,专栏的内容受到了同学们的广泛好评,有不少同学反馈:采用专栏中的调优技巧,Spark作业的执行性能提升了好几倍。但同时,也有一些同学反馈:自己才入门大数据,专栏中的很多内容表示看不懂。
|
||||
|
||||
实际上,我身边也有不少同学,他们有的科班出身于机器学习、人工智能,有的准备从后端开发、DBA甚至是其他行业转型做大数据开发,有的想基于开源框架构建企业级数据仓库,都面临着如何快速入门Spark的难题。
|
||||
|
||||
“快”和“全”,让Spark成了互联网公司的标配
|
||||
|
||||
不过,你可能会好奇:“Spark还有那么火吗?会不会已经过时了?”实际上,历经十多年的发展,Spark已经由当初的“大数据新秀”成长为数据应用领域的中流砥柱。在数据科学与机器学习魔力象限当中,IT研究与咨询公司Gartner连续3年(2018 ~ 2020)将Databricks(Spark云原生商业版本)提名为Market Leader。
|
||||
|
||||
不仅如此,凭借其自身的诸多优势,Spark早已成为绝大多数互联网公司的标配。比如,字节跳动基于 Spark 构建数据仓库,服务着旗下几乎所有的产品线,包括抖音、今日头条、西瓜视频、火山视频;比如,美团早在2014年就引入了Spark,并逐渐将其覆盖到美团App、美团外卖、美团打车等核心产品;再比如,Netflix基于Spark构建端到端的机器学习流水线,围绕着Spark打造服务于超过两亿订阅用户的推荐引擎。
|
||||
|
||||
事实上,任何一家互联网公司,都离不开推荐、广告、搜索这3类典型业务场景。推荐与搜索帮助企业引流、提升用户体验、维持用户黏性、拓展用户增长,而广告业务则用于将流量变现,是互联网公司最重要的商业模式之一。而在这些业务场景背后的技术栈当中,你都能看到Spark的身影,它或是用于ETL与流处理、或是用于构建企业级数据分析平台、或是用于打造端到端的机器学习流水线。
|
||||
|
||||
那么,我们不禁要问:“在发展迅猛的数据应用领域,同类竞品可以说是层出不穷、日新月异,Spark何以傲视群雄,在鹰视狼顾的厮杀中脱颖而出,并能持久地立于不败之地?”在我看来,这主要是得益于Spark的两大优势:快、全。
|
||||
|
||||
快,有两个方面,一个是开发效率快,另一个是执行效率快。Spark支持多种开发语言,如Python、Java、Scala、R和SQL,同时提供了种类丰富的开发算子,如RDD、DataFrame、Dataset。这些特性让开发者能够像搭积木一样,信手拈来、驾轻就熟地完成数据应用开发。
|
||||
|
||||
在我的身边,有很多不具备大数据背景,却需要从零开始用Spark做开发的同学。最开始,他们往往需要“照葫芦画瓢”、参考别人的代码实现才能完成自己的工作。但是,经过短短3个月的强化练习之后,绝大多数同学都能够独当一面、熟练地实现各式各样的业务需求。而这,自然要归功于Spark框架本身超高的开发效率。
|
||||
|
||||
再者,凭借Spark Core和Spark SQL这两个并驾齐驱的计算引擎,我们开发出的数据应用并不需要太多的调整或是优化,就能享有不错的执行性能。
|
||||
|
||||
|
||||
|
||||
而这,主要得益于Spark社区对于底层计算引擎的持续打磨与优化,才让开发者能够把精力专注于业务逻辑实现,而不必关心框架层面的设计细节。
|
||||
|
||||
说完了Spark的“快”,接下来,我们再来说说它的“全”。全,指的是Spark在计算场景的支持上非常全面。我们知道,在数据应用领域,有如下几类计算场景,它们分别是批处理、流计算、数据分析、机器学习和图计算。
|
||||
|
||||
批处理作为大数据的基础,自然不必多说了。与以往任何时候都不同,今天的大数据处理,对于延迟性的要求越来越高,流处理的基本概念与工作原理,是每一个大数据从业者必备的“技能点”。而在人工智能火热的当下,数据分析与机器学习也是我们必须要关注的重中之重。
|
||||
|
||||
对于这几类计算场景,Spark提供了丰富的子框架予以支持。比如,针对流计算的Structured Streaming,用于数据分析的Spark SQL,服务于机器学习的Spark MLlib,等等。Spark全方位的场景支持,让开发者“足不出户”、在同一套计算框架之内,即可实现不同类型的数据应用,从而避免为了实现不同类型的数据应用,而疲于奔命地追逐各式各样的新技术、新框架。
|
||||
|
||||
|
||||
|
||||
不难发现,Spark集众多优势于一身,在互联网又有着极其深远的影响力,对于想要在数据应用领域有所建树的同学来说,Spark可以说是一门必修课。
|
||||
|
||||
不管你是专注于应用开发与二次开发的大数据工程师,还是越来越火热的数据分析师、数据科学家、以及机器学习算法研究员,Spark都是你必须要掌握的一项傍身之计。
|
||||
|
||||
不过,尽管Spark优势众多,但入门Spark却不是一件容易的事情。身边的同学经常有这样的感叹:
|
||||
|
||||
|
||||
网上的学习资料实在太多,但大部分都是零星的知识点,很难构建结构化的知识体系;
|
||||
Spark相关的书籍其实也不少,但多是按部就班、照本宣科地讲原理,看不下去;
|
||||
要想学习Spark,还要先学Scala,Scala语法晦涩难懂,直接劝退;
|
||||
开发算子太多了,记不住,来了新的业务需求,不知道该从哪里下手;
|
||||
……
|
||||
|
||||
|
||||
既然Spark是数据应用开发者在职业发展当中必需的一环,而入门Spark又有这样那样的难处和痛点,那么我们到底该如何入门Spark呢?
|
||||
|
||||
如何入门Spark?
|
||||
|
||||
如果把Spark比作是公路赛车的话,那么我们每一个开发者就是准备上车驾驶的赛车手。要想开好这辆赛车,那么第一步,我们首先要熟悉车辆驾驶的基本操作,比如挡位怎么挂,油门、离合、刹车踏板分别在什么地方,等等。
|
||||
|
||||
再者,为了发挥出赛车的性能优势,我们得了解赛车的工作原理,比如它的驱动系统、刹车系统等等。只有摸清了它的工作原理,我们才能灵活地操纵油、离、刹之间的排列组合。
|
||||
|
||||
最后,在掌握了赛车的基本操作和工作原理之后,对于不同的地形,比如公路、山路、沙漠等等,我们还要总结出针对不同驾驶场景的一般套路。遵循这样的三步走,我们才能从一个赛车小白,逐渐化身为资深赛车手。
|
||||
|
||||
和学习驾驶赛车一样,入门Spark也需要这样的“三步走”。第一步,就像是需要熟悉赛车的基本操作,我们需要掌握Spark常用的开发API与开发算子。毕竟,通过这些API与开发算子,我们才能启动并驱使Spark的分布式计算引擎。
|
||||
|
||||
接着,要想让Spark这台车子跑得稳,我们必须要深入理解它的工作原理才行。因此,在第二步,我会为你讲解Spark的核心原理。
|
||||
|
||||
第三步,就像是应对赛车的不同驾驶场景,我们需要了解并熟悉Spark不同的计算子框架(Spark SQL、Spark MLlib和Structured Streaming),来应对不同的数据应用场景,比如数据分析、机器学习和流计算。
|
||||
|
||||
|
||||
|
||||
与三步走相对应,我把这门课设计成了4个模块,其中第一个模块是基础知识模块,我会专注于三步走的前两步,也即熟悉开发API和吃透核心原理。在后面的三个模块中,我会依次讲解Spark应对不同数据场景的计算子框架,分别是Spark SQL、Spark MLlib和Structured Streaming。由于图计算框架GraphFrames在工业界的应用较少,因此咱们的课程不包含这部分内容的介绍。
|
||||
|
||||
这四个模块和“三步走”的关系如下图所示:
|
||||
|
||||
|
||||
|
||||
从图中你可以看到,由于在这三种子框架中,Spark SQL在扮演数据分析子框架这个角色的同时,还是Spark新一代的优化引擎,其他子框架都能共享Spark SQL带来的“性能红利”,所以我在讲解Spark SQL的时候,也会涉及一些第一步、第二步中的基本操作和原理介绍。
|
||||
|
||||
在这四个模块中,我们都会从一个小项目入手,由浅入深、循序渐进地讲解项目涉及的算子、开发API、工作原理与优化技巧。尽管每个项目给出的代码都是由Scala实现的,但你完全不用担心,我会对代码逐句地进行注释,提供“保姆级”的代码解释。
|
||||
|
||||
第一个模块是基础知识。
|
||||
|
||||
在这个模块中,我们会从一个叫作“Word Count”的小项目开始。以Word Count的计算逻辑为线索,我们会去详细地讲解RDD常用算子的含义、用法、注意事项与适用场景,让你一站式掌握RDD算子;我还会用一个又一个有趣的故事,以轻松诙谐、深入浅出的方式为你讲解Spark核心原理,包括RDD编程模型、Spark进程模型、调度系统、存储系统、Shuffle管理、内存管理等等,从而让你像读小说一样去弄懂Spark。
|
||||
|
||||
第二个模块在讲Spark SQL时,我首先会从“小汽车摇号”这个小项目入手,带你熟悉Spark SQL开发API。与此同时,依托这个小项目,我会为你讲解Spark SQL的核心原理与优化过程。最后,我们再重点介绍Spark SQL与数据分析有关的部分,如数据的转换、清洗、关联、分组、聚合、排序,等等。
|
||||
|
||||
在第三个模块,我们会学习Spark机器学习子框架:Spark MLlib。
|
||||
|
||||
在这个模块中,我们会从“房价预测”这个小项目入手,初步了解机器学习中的回归模型、以及Spark MLlib的基本用法。我还会为你介绍机器学习的一般场景,会带你一起,深入学习Spark MLlib丰富的特征处理函数,细数Spark MLlib都支持哪些模型与算法,并学习构建端到端的机器学习流水线。最后,我还会讲Spark + XGBoost集成,是如何帮助开发者应对大多数的回归与分类问题。
|
||||
|
||||
在课程的最后一部分,我们一起来学习Spark的流处理框架Structured Streaming。
|
||||
|
||||
在这个模块中,我们将重点讲解Structured Streaming如何同时保证语义一致性与数据一致性,以及如何应对流处理中的数据关联,并通过Kafka + Spark这对“Couple”的系统集成,来演示流处理中的典型计算场景。
|
||||
|
||||
经过“熟悉开发API、吃透核心原理与玩转子框架”这三步走之后,你就建立了属于自己的Spark知识体系,完全跨进了Spark应用开发的大门。
|
||||
|
||||
|
||||
|
||||
对于绝大多数的数据应用需求来说,我相信你都能够游刃有余地做到灵活应对,分分钟交付一个满足业务需求、运行稳定、且执行性能良好的分布式应用。
|
||||
|
||||
最后,欢迎你在这里畅所欲言,提出你的困惑和疑问,也欢迎多多给我留言,你们的鼓励是我的动力。三步走的路线已经规划完毕,让我们一起携手并进、轻松而又愉快地完成Spark的入门之旅吧!
|
||||
|
||||
掌握了Spark这项傍身之计,我坚信,它可以让你在笔试、面试或是日常的工作中脱颖而出,从而让Spark为你的职业发展增光添彩!
|
||||
|
||||
|
||||
|
||||
|
254
专栏/零基础入门Spark/01Spark:从“大数据的HelloWorld”开始.md
Normal file
254
专栏/零基础入门Spark/01Spark:从“大数据的HelloWorld”开始.md
Normal file
@ -0,0 +1,254 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 Spark:从“大数据的Hello World”开始
|
||||
你好,我是吴磊。
|
||||
|
||||
从这节课开始,我们先来学习Spark的“基础知识”模块,对Spark的概念和核心原理先做一个整体的了解。我并不会从RDD、DAG这些基本概念给你讲起。坦白地说,这些抽象的概念枯燥而又乏味,对于刚开始接触Spark的你来说,很难学进去。因此,我们不妨反其道而行之,先从实战入手,用一个小例子来直观地认识Spark,看看Spark都能做些什么。
|
||||
|
||||
这就好比我们学习一门新的编程语言,往往都是从“Hello World”开始。我还记得,刚刚学编程那会,屏幕上打印出的“Hello World”,足足让我兴奋了一整天,让我莫名地有一种“I can change the world”的冲动。
|
||||
|
||||
今天这一讲,我们就从“大数据的Hello World”开始,去学习怎么在Spark之上做应用开发。不过,“大数据的Hello World”并不是把字符串打印到屏幕上这么简单,而是要先对文件中的单词做统计计数,然后再打印出频次最高的5个单词,江湖人称“Word Count”。
|
||||
|
||||
之所以会选择Word Count,作为我们迈入Spark门槛的第一个项目,主要有两个原因,一是Word Count场景比较简单、容易理解;二是Word Count麻雀虽小,但五脏俱全,一个小小的Word Count,就能够牵引出Spark许多的核心原理,帮助我们快速入门。
|
||||
|
||||
好啦,话不多说,下面我们正式开启Word Count之旅。
|
||||
|
||||
准备工作
|
||||
|
||||
巧妇难为无米之炊,要做Word Count,我们得先把源文件准备好。
|
||||
|
||||
咱们做Word Count的初衷是学习Spark,因此源文件的内容无足轻重。这里我提取了Wikipedia中对Spark的介绍来做我们的源文件。我把它保存到了与课程配套的GitHub项目中,并把它命名为“wikiOfSpark.txt”。你可以从这里下载它。
|
||||
|
||||
为了跑通Word Count实例,我们还需要在本地(Local)部署Spark运行环境。这里的“本地”,指的是你手头能够获取到的任何计算资源,比如服务器、台式机,或是笔记本电脑。
|
||||
|
||||
在本地部署Spark运行环境非常简单,即便你从来没有和Spark打过交道,也不必担心。只需要下面这3个步骤,我们就可以完成Spark的本地部署了。
|
||||
|
||||
|
||||
下载安装包:从Spark官网下载安装包,选择最新的预编译版本即可;
|
||||
解压:解压Spark安装包到任意本地目录;
|
||||
配置:将“${解压目录}/bin”配置到PATH环境变量。
|
||||
|
||||
|
||||
我这里给你准备了一个本地部署的小视频,你可以直观地感受一下。
|
||||
|
||||
接下来,我们确认一下Spark是否部署成功。打开命令行终端,敲入“spark-shell –version”命令,如果该命令能成功地打印出Spark版本号,就表示我们大功告成了,就像这样:
|
||||
|
||||
|
||||
|
||||
在后续的实战中,我们会用spark-shell来演示Word Count的执行过程。spark-shell是提交Spark作业众多方式中的一种,我们在后续的课程中还会展开介绍,这里你不妨暂时把它当做是Spark中的Linux shell。spark-shell提供交互式的运行环境(REPL,Read-Evaluate-Print-Loop),以“所见即所得”的方式,让开发者在提交源代码之后,就可以迅速地获取执行结果。
|
||||
|
||||
不过,需要注意的是,spark-shell在运行的时候,依赖于Java和Scala语言环境。因此,为了保证spark-shell的成功启动,你需要在本地预装Java与Scala。好消息是,关于Java与Scala的安装,网上的资料非常丰富,你可以参考那些资料来进行安装,咱们在本讲就不再赘述Java与Scala的安装步骤啦。
|
||||
|
||||
梳理Word Count的计算步骤
|
||||
|
||||
做了一番准备之后,接下来,我们就可以开始写代码了。不过,在“下手”之前,咱们不妨一起梳理下Word Count的计算步骤,先做到心中有数,然后再垒代码也不迟。
|
||||
|
||||
之前我们提到,Word Count的初衷是对文件中的单词做统计计数,打印出频次最高的5个词汇。那么Word Count的第一步就很明显了,当然是得读取文件的内容,不然咱们统计什么呢?
|
||||
|
||||
我们准备好的文件是wikiOfSpark.txt,它以纯文本的方式记录了关于Spark的简单介绍,我摘取了其中的部分内容给你看一下:
|
||||
|
||||
|
||||
|
||||
我们知道,文件的读取往往是以行(Line)为单位的。不难发现,wikiOfSpark.txt的每一行都包含多个单词。
|
||||
|
||||
我们要是以“单词”作为粒度做计数,就需要对每一行的文本做分词。分词过后,文件中的每一句话,都被打散成了一个个单词。这样一来,我们就可以按照单词做分组计数了。这就是Word Count的计算过程,主要包含如下3个步骤:
|
||||
|
||||
|
||||
读取内容:调用Spark文件读取API,加载wikiOfSpark.txt文件内容;
|
||||
分词:以行为单位,把句子打散为单词;
|
||||
分组计数:按照单词做分组计数。
|
||||
|
||||
|
||||
明确了计算步骤后,接下来我们就可以调用Spark开发API,对这些步骤进行代码实现,从而完成Word Count的应用开发。
|
||||
|
||||
众所周知,Spark支持种类丰富的开发语言,如Scala、Java、Python,等等。你可以结合个人偏好和开发习惯,任意选择其中的一种进行开发。尽管不同语言的开发API在语法上有着细微的差异,但不论是功能方面、还是性能方面,Spark对于每一种语言的支持都是一致的。换句话说,同样是Word Count,你用Scala实现也行,用Python实现也可以,两份代码的执行结果是一致的。不仅如此,在同样的计算资源下,两份代码的执行效率也是一样的。
|
||||
|
||||
因此,就Word Count这个示例来说,开发语言不是重点,我们不妨选择Scala。你可能会说:“我本来对Spark就不熟,更没有接触过Scala,一上来就用Scala演示Spark应用代码,理解起来会不会很困难?”
|
||||
|
||||
其实大可不必担心,Scala语法比较简洁,Word Count的Scala实现不超过10行代码。再者,对于Word Count中的每一行Scala代码,我会带着你手把手、逐行地进行讲解和分析。我相信,跟着我过完一遍代码之后,你能很快地把它“翻译”成你熟悉的语言,比如Java或Python。另外,绝大多数的Spark 源码都是由 Scala 实现的,接触并了解一些Scala的基本语法,有利于你后续阅读、学习Spark源代码。
|
||||
|
||||
Word Count代码实现
|
||||
|
||||
选定了语言,接下来,我们就按照读取内容、分词、分组计数这三步来看看Word Count具体怎么实现。
|
||||
|
||||
第一步,读取内容
|
||||
|
||||
首先,我们调用SparkContext的textFile方法,读取源文件,也就是wikiOfSpark.txt,代码如下表所示:
|
||||
|
||||
import org.apache.spark.rdd.RDD
|
||||
|
||||
// 这里的下划线"_"是占位符,代表数据文件的根目录
|
||||
val rootPath: String = _
|
||||
val file: String = s"${rootPath}/wikiOfSpark.txt"
|
||||
|
||||
// 读取文件内容
|
||||
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
|
||||
|
||||
|
||||
在这段代码中,你可能会发现3个新概念,分别是spark、sparkContext和RDD。
|
||||
|
||||
其中,spark和sparkContext分别是两种不同的开发入口实例:
|
||||
|
||||
|
||||
spark是开发入口SparkSession实例(Instance),SparkSession在spark-shell中会由系统自动创建;
|
||||
sparkContext是开发入口SparkContext实例。
|
||||
|
||||
|
||||
在Spark版本演进的过程中,从2.0版本开始,SparkSession取代了SparkContext,成为统一的开发入口。换句话说,要开发Spark应用,你必须先创建SparkSession。关于SparkSession和SparkContext,我会在后续的课程做更详细的介绍,这里你只要记住它们是必需的开发入口就可以了。
|
||||
|
||||
我们再来看看RDD,RDD的全称是Resilient Distributed Dataset,意思是“弹性分布式数据集”。RDD是Spark对于分布式数据的统一抽象,它定义了一系列分布式数据的基本属性与处理方法。关于RDD的定义、内涵与作用,我们留到[下一讲]再去展开。
|
||||
|
||||
在这里,你不妨先简单地把RDD理解成“数组”,比如代码中的lineRDD变量,它的类型是RDD[String],你可以暂时把它当成元素类型是String的数组,数组的每个元素都是文件中的一行字符串。
|
||||
|
||||
获取到文件内容之后,下一步我们就要做分词了。
|
||||
|
||||
第二步,分词
|
||||
|
||||
“分词”就是把“数组”的行元素打散为单词。要实现这一点,我们可以调用RDD的flatMap方法来完成。flatMap操作在逻辑上可以分成两个步骤:映射和展平。
|
||||
|
||||
这两个步骤是什么意思呢?我们还是结合Word Count的例子来看:
|
||||
|
||||
// 以行为单位做分词
|
||||
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
|
||||
|
||||
|
||||
要把lineRDD的行元素转换为单词,我们得先用分隔符对每个行元素进行分割(Split),咱们这里的分隔符是空格。
|
||||
|
||||
分割之后,每个行元素就都变成了单词数组,元素类型也从String变成了Array[String],像这样以元素为单位进行转换的操作,统一称作“映射”。
|
||||
|
||||
映射过后,RDD类型由原来的RDD[String]变为RDD[Array[String]]。如果把RDD[String]看成是“数组”的话,那么RDD[Array[String]]就是一个“二维数组”,它的每一个元素都是单词。
|
||||
|
||||
|
||||
|
||||
为了后续对单词做分组,我们还需要对这个“二维数组”做展平,也就是去掉内层的嵌套结构,把“二维数组”还原成“一维数组”,如下图所示。
|
||||
|
||||
|
||||
|
||||
就这样,在flatMap算子的作用下,原来以行为元素的lineRDD,转换成了以单词为元素的wordRDD。
|
||||
|
||||
不过,值得注意的是,我们用“空格”去分割句子,有可能会产生空字符串。所以,在完成“映射”和“展平”之后,对于这样的“单词”,我们要把其中的空字符串都过滤掉,这里我们调用RDD的filter方法来过滤:
|
||||
|
||||
// 过滤掉空字符串
|
||||
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
|
||||
|
||||
|
||||
这样一来,我们在分词阶段就得到了过滤掉空字符串之后的单词“数组”,类型是RDD[String]。接下来,我们就可以准备做分组计数了。
|
||||
|
||||
第三步,分组计数
|
||||
|
||||
在RDD的开发框架下,聚合类操作,如计数、求和、求均值,需要依赖键值对(Key Value Pair)类型的数据元素,也就是(Key,Value)形式的“数组”元素。
|
||||
|
||||
因此,在调用聚合算子做分组计数之前,我们要先把RDD元素转换为(Key,Value)的形式,也就是把RDD[String]映射成RDD[(String, Int)]。
|
||||
|
||||
其中,我们统一把所有的Value置为1。这样一来,对于同一个的单词,在后续的计数运算中,我们只要对Value做累加即可,就像这样:
|
||||
|
||||
|
||||
|
||||
下面是对应的代码:
|
||||
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
|
||||
|
||||
|
||||
这样一来,RDD就由原来存储String元素的cleanWordRDD,转换为了存储(String,Int)的kvRDD。
|
||||
|
||||
完成了形式的转换之后,我们就该正式做分组计数了。分组计数其实是两个步骤,也就是先“分组”,再“计数”。下面,我们使用聚合算子reduceByKey来同时完成分组和计数这两个操作。
|
||||
|
||||
对于kvRDD这个键值对“数组”,reduceByKey先是按照Key(也就是单词)来做分组,分组之后,每个单词都有一个与之对应的Value列表。然后根据用户提供的聚合函数,对同一个Key的所有Value做reduce运算。
|
||||
|
||||
这里的reduce,你可以理解成是一种计算步骤或是一种计算方法。当我们给定聚合函数后,它会用折叠的方式,把包含多个元素的列表转换为单个元素值,从而统计出不同元素的数量。
|
||||
|
||||
在Word Count的示例中,我们调用reduceByKey实现分组计算的代码如下:
|
||||
|
||||
// 按照单词做分组计数
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
|
||||
|
||||
|
||||
可以看到,我们传递给reduceByKey算子的聚合函数是(x, y) => x + y,也就是累加函数。因此,在每个单词分组之后,reduce会使用累加函数,依次折叠计算Value列表中的所有元素,最终把元素列表转换为单词的频次。对于任意一个单词来说,reduce的计算过程都是一样的,如下图所示。
|
||||
|
||||
|
||||
|
||||
reduceByKey完成计算之后,我们得到的依然是类型为RDD[(String, Int)]的RDD。不过,与kvRDD不同,wordCounts元素的Value值,记录的是每个单词的统计词频。到此为止,我们就完成了Word Count主逻辑的开发与实现。
|
||||
|
||||
|
||||
|
||||
在程序的最后,我们还要把wordCounts按照词频做排序,并把词频最高的5个单词打印到屏幕上,代码如下所示。
|
||||
|
||||
// 打印词频最高的5个词汇
|
||||
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
|
||||
|
||||
|
||||
代码执行
|
||||
|
||||
应用开发完成之后,我们就可以把代码丢进已经准备好的本地Spark部署环境里啦。首先,我们打开命令行终端(Terminal),敲入“spark-shell”,打开交互式运行环境,如下图所示。
|
||||
|
||||
|
||||
|
||||
然后,把我们开发好的代码,依次敲入spark-shell。为了方便你操作,我把完整的代码实现整理到下面了:
|
||||
|
||||
import org.apache.spark.rdd.RDD
|
||||
|
||||
// 这里的下划线"_"是占位符,代表数据文件的根目录
|
||||
val rootPath: String = _
|
||||
val file: String = s"${rootPath}/wikiOfSpark.txt"
|
||||
|
||||
// 读取文件内容
|
||||
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
|
||||
|
||||
// 以行为单位做分词
|
||||
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
|
||||
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
|
||||
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
|
||||
// 按照单词做分组计数
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
|
||||
|
||||
// 打印词频最高的5个词汇
|
||||
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
|
||||
|
||||
|
||||
我们把上面的代码依次敲入到spark-shell之后,spark-shell最终会把词频最高的5个单词打印到屏幕上:
|
||||
|
||||
|
||||
|
||||
在Wikipedia的Spark介绍文本中,词频最高的单词分别是the、Spark、a、and和of,除了“Spark”之外,其他4个单词都是常用的停用词(Stop Word),因此它们几个高居榜首也就不足为怪了。
|
||||
|
||||
好啦,到此为止,我们在Spark之上,完成了“大数据领域Hello World”的开发与实现,恭喜你跨入大数据开发的大门!
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,我们围绕着Word Count,初步探索并体验了Spark应用开发。你首先需要掌握的是Spark的本地部署,从而可以通过spark-shell来迅速熟悉Spark,获得对Spark的“第一印象”。要在本地部署Spark,你需要遵循3个步骤:
|
||||
|
||||
|
||||
从Spark官网下载安装包,选择最新的预编译版本即可;
|
||||
解压Spark安装包到任意本地目录;
|
||||
将“${解压目录}/bin”配置到PATH环境变量。
|
||||
|
||||
|
||||
然后,我们一起分析并实现了入门Spark的第一个应用程序:Word Count。在我们的例子中,Word Count要完成的计算任务,是先对文件中的单词做统计计数,然后再打印出频次最高的5个单词。它的实现过程分为3个步骤:
|
||||
|
||||
|
||||
读取内容:调用Spark文件读取API,加载wikiOfSpark.txt文件内容;
|
||||
分词:以行为单位,把句子打散为单词;
|
||||
分组计数:按照单词做分组计数。
|
||||
|
||||
|
||||
也许你对RDD API还不熟悉,甚至从未接触过Scala,不过没关系,完成了这次“大数据的Hello World”开发之旅,你就已经踏上了新的征程。在接下来的课程里,让我们携手并肩,像探索新大陆一样,一层一层地剥开Spark的神秘面纱,加油!
|
||||
|
||||
每课一练
|
||||
|
||||
在Word Count的代码实现中,我们用到了多种多样的RDD算子,如map、filter、flatMap和reduceByKey,除了这些算子以外,你知道还有哪些常用的RDD算子吗?(提示,可以结合官网去查找)。
|
||||
|
||||
另外,你能说说,以上这些算子都有哪些共性或是共同点吗?
|
||||
|
||||
欢迎你把答案分享到评论区,我在评论区等你。
|
||||
|
||||
如果这一讲对你有帮助,也欢迎你分享给自己的朋友,我们下一讲再见!
|
||||
|
||||
|
||||
|
||||
|
195
专栏/零基础入门Spark/02RDD与编程模型:延迟计算是怎么回事?.md
Normal file
195
专栏/零基础入门Spark/02RDD与编程模型:延迟计算是怎么回事?.md
Normal file
@ -0,0 +1,195 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 RDD与编程模型:延迟计算是怎么回事?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们一起开发了一个Word Count小应用,并把它敲入到spark-shell中去执行。Word Count的计算步骤非常简单,首先是读取数据源,然后是做分词,最后做分组计数、并把词频最高的几个词汇打印到屏幕上。
|
||||
|
||||
如果你也动手实践了这个示例,你可能会发现,在spark-shell的REPL里,所有代码都是立即返回、瞬间就执行完毕了,相比之下,只有最后一行代码,花了好长时间才在屏幕上打印出the、Spark、a、and和of这几个单词。
|
||||
|
||||
针对这个现象,你可能会觉得很奇怪:“读取数据源、分组计数应该是最耗时的步骤,为什么它们瞬间就返回了呢?打印单词应该是瞬间的事,为什么这一步反而是最耗时的呢?”要解答这个疑惑,我们还是得从RDD说起。
|
||||
|
||||
什么是RDD
|
||||
|
||||
为什么非要从RDD说起呢?首先,RDD是构建Spark分布式内存计算引擎的基石,很多Spark核心概念与核心组件,如DAG和调度系统都衍生自RDD。因此,深入理解RDD有利于你更全面、系统地学习 Spark 的工作原理。
|
||||
|
||||
其次,尽管RDD API使用频率越来越低,绝大多数人也都已经习惯于DataFrame和Dataset API,但是,无论采用哪种API或是哪种开发语言,你的应用在Spark内部最终都会转化为RDD之上的分布式计算。换句话说,如果你想要对Spark作业有更好的把握,前提是你要对RDD足够了解。
|
||||
|
||||
既然RDD如此重要,那么它到底是什么呢?用一句话来概括,RDD是一种抽象,是Spark对于分布式数据集的抽象,它用于囊括所有内存中和磁盘中的分布式数据实体。
|
||||
|
||||
在[上一讲]中,我们把RDD看作是数组,咱们不妨延续这个思路,通过对比RDD与数组之间的差异认识一下RDD。
|
||||
|
||||
我列了一个表,做了一下RDD和数组对比,你可以先扫一眼:
|
||||
|
||||
|
||||
|
||||
我在表中从四个方面对数组和RDD进行了对比,现在我来详细解释一下。
|
||||
|
||||
首先,就概念本身来说,数组是实体,它是一种存储同类元素的数据结构,而RDD是一种抽象,它所囊括的是分布式计算环境中的分布式数据集。
|
||||
|
||||
因此,这两者第二方面的不同就是在活动范围,数组的“活动范围”很窄,仅限于单个计算节点的某个进程内,而RDD代表的数据集是跨进程、跨节点的,它的“活动范围”是整个集群。
|
||||
|
||||
至于数组和RDD的第三个不同,则是在数据定位方面。在数组中,承载数据的基本单元是元素,而RDD中承载数据的基本单元是数据分片。在分布式计算环境中,一份完整的数据集,会按照某种规则切割成多份数据分片。这些数据分片被均匀地分发给集群内不同的计算节点和执行进程,从而实现分布式并行计算。
|
||||
|
||||
通过以上对比,不难发现,数据分片(Partitions)是RDD抽象的重要属性之一。在初步认识了RDD之后,接下来咱们换个视角,从RDD的重要属性出发,去进一步深入理解RDD。要想吃透RDD,我们必须掌握它的4大属性:
|
||||
|
||||
|
||||
partitions:数据分片
|
||||
partitioner:分片切割规则
|
||||
dependencies:RDD依赖
|
||||
compute:转换函数
|
||||
|
||||
|
||||
如果单从理论出发、照本宣科地去讲这4大属性,未免过于枯燥、乏味、没意思!所以,我们从一个制作薯片的故事开始,去更好地理解RDD的4大属性。
|
||||
|
||||
从薯片的加工流程看RDD的4大属性
|
||||
|
||||
在很久很久以前,有个生产桶装薯片的工坊,工坊的规模较小,工艺也比较原始。为了充分利用每一颗土豆、降低生产成本,工坊使用 3 条流水线来同时生产 3 种不同尺寸的桶装薯片。3 条流水线可以同时加工 3 颗土豆,每条流水线的作业流程都是一样的,分别是清洗、切片、烘焙、分发和装桶。其中,分发环节用于区分小、中、大号 3 种薯片,3 种不同尺寸的薯片分别被发往第 1、2、3 条流水线。具体流程如下图所示。
|
||||
|
||||
|
||||
|
||||
好了,故事讲完了。那如果我们把每一条流水线看作是分布式运行环境的计算节点,用薯片生产的流程去类比 Spark 分布式计算,会有哪些有趣的发现呢?
|
||||
|
||||
显然,这里的每一种食材形态,如“带泥土豆”、“干净土豆”、“土豆片”等,都可以看成是一个个RDD。而薯片的制作过程,实际上就是不同食材形态的转换过程。
|
||||
|
||||
起初,工人们从麻袋中把“带泥土豆”加载到流水线,这些土豆经过清洗之后,摇身一变,成了“干净土豆”。接下来,流水线上的切片机再把“干净土豆”切成“土豆片”,然后紧接着把这些土豆片放进烤箱。最终,土豆片烤熟之后,就变成了可以放心食用的即食薯片。
|
||||
|
||||
通过分析我们不难发现,不同食材形态之间的转换过程,与Word Count中不同RDD之间的转换过程如出一辙。
|
||||
|
||||
所以接下来,我们就结合薯片的制作流程,去理解RDD的4大属性。
|
||||
|
||||
首先,咱们沿着纵向,也就是从上到下的方向,去观察上图中土豆工坊的制作工艺。
|
||||
|
||||
|
||||
|
||||
我们可以看到对于每一种食材形态来说,流水线上都有多个实物与之对应,比如,“带泥土豆”是一种食材形态,流水线上总共有3颗“脏兮兮”的土豆同属于这一形态。
|
||||
|
||||
如果把“带泥土豆”看成是RDD的话,那么RDD的partitions属性,囊括的正是麻袋里那一颗颗脏兮兮的土豆。同理,流水线上所有洗净的土豆,一同构成了“干净土豆”RDD的partitions属性。
|
||||
|
||||
我们再来看RDD的partitioner属性,这个属性定义了把原始数据集切割成数据分片的切割规则。在土豆工坊的例子中,“带泥土豆”RDD的切割规则是随机拿取,也就是从麻袋中随机拿取一颗脏兮兮的土豆放到流水线上。后面的食材形态,如“干净土豆”、“土豆片”和“即食薯片”,则沿用了“带泥土豆”RDD的切割规则。换句话说,后续的这些RDD,分别继承了前一个RDD的partitioner属性。
|
||||
|
||||
这里面与众不同的是“分发的即食薯片”。显然,“分发的即食薯片”是通过对“即食薯片”按照大、中、小号做分发得到的。也就是说,对于“分发的即食薯片”来说,它的partitioner属性,重新定义了这个RDD数据分片的切割规则,也就是把先前RDD的数据分片打散,按照薯片尺寸重新构建数据分片。
|
||||
|
||||
由这个例子我们可以看出,数据分片的分布,是由RDD的partitioner决定的。因此,RDD的partitions属性,与它的partitioner属性是强相关的。
|
||||
|
||||
横看成岭侧成峰,很多事情换个视角看,相貌可能会完全不同。所以接下来,我们横向地,也就是沿着从左至右的方向,再来观察土豆工坊的制作工艺。
|
||||
|
||||
|
||||
|
||||
不难发现,流水线上的每一种食材形态,都是上一种食材形态在某种操作下进行转换得到的。比如,“土豆片”依赖的食材形态是“干净土豆”,这中间用于转换的操作是“切片”这个动作。回顾Word Count当中RDD之间的转换关系,我们也会发现类似的现象。
|
||||
|
||||
|
||||
|
||||
在数据形态的转换过程中,每个RDD都会通过dependencies属性来记录它所依赖的前一个、或是多个RDD,简称“父RDD”。与此同时,RDD使用compute属性,来记录从父RDD到当前RDD的转换操作。
|
||||
|
||||
拿Word Count当中的wordRDD来举例,它的父RDD是lineRDD,因此,它的dependencies属性记录的是lineRDD。从lineRDD到wordRDD的转换,其所依赖的操作是flatMap,因此,wordRDD的compute属性,记录的是flatMap这个转换函数。
|
||||
|
||||
总结下来,薯片的加工流程,与RDD的概念和4大属性是一一对应的:
|
||||
|
||||
|
||||
不同的食材形态,如带泥土豆、土豆片、即食薯片等等,对应的就是RDD概念;
|
||||
同一种食材形态在不同流水线上的具体实物,就是 RDD 的 partitions 属性;
|
||||
食材按照什么规则被分配到哪条流水线,对应的就是 RDD 的 partitioner 属性;
|
||||
每一种食材形态都会依赖上一种形态,这种依赖关系对应的是 RDD 中的 dependencies 属性;
|
||||
不同环节的加工方法对应 RDD的 compute 属性。
|
||||
|
||||
|
||||
在你理解了RDD的4大属性之后,还需要进一步了解RDD的编程模型和延迟计算。编程模型指导我们如何进行代码实现,而延迟计算是Spark分布式运行机制的基础。只有搞明白编程模型与延迟计算,你才能流畅地在Spark之上做应用开发,在实现业务逻辑的同时,避免埋下性能隐患。
|
||||
|
||||
编程模型与延迟计算
|
||||
|
||||
你还记得我在上一讲的最后,给你留的一道思考题吗:map、filter、flatMap和reduceByKey这些算子,有哪些共同点?现在我们来揭晓答案:
|
||||
|
||||
首先,这4个算子都是作用(Apply)在RDD之上、用来做RDD之间的转换。比如,flatMap作用在lineRDD之上,把lineRDD转换为wordRDD。
|
||||
|
||||
其次,这些算子本身是函数,而且它们的参数也是函数。参数是函数、或者返回值是函数的函数,我们把这类函数统称为“高阶函数”(Higher-order Functions)。换句话说,这4个算子,都是高阶函数。
|
||||
|
||||
关于高阶函数的作用与优劣势,我们留到后面再去展开。这里,我们先专注在RDD算子的第一个共性:RDD转换。
|
||||
|
||||
RDD是Spark对于分布式数据集的抽象,每一个RDD都代表着一种分布式数据形态。比如lineRDD,它表示数据在集群中以行(Line)的形式存在;而wordRDD则意味着数据的形态是单词,分布在计算集群中。
|
||||
|
||||
理解了RDD,那什么是RDD转换呢?别着急,我来以上次Word Count的实现代码为例,来给你讲一下。以下是我们上次用的代码:
|
||||
|
||||
import org.apache.spark.rdd.RDD
|
||||
val rootPath: String = _
|
||||
val file: String = s"${rootPath}/wikiOfSpark.txt"
|
||||
// 读取文件内容
|
||||
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
|
||||
// 以行为单位做分词
|
||||
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
|
||||
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
|
||||
// 按照单词做分组计数
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
|
||||
// 打印词频最高的5个词汇
|
||||
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
|
||||
|
||||
|
||||
回顾Word Count示例,我们会发现,Word Count的实现过程,实际上就是不同RDD之间的一个转换过程。仔细观察我们会发现,Word Count示例中一共有4次RDD的转换,我来具体解释一下:
|
||||
|
||||
起初,我们通过调用textFile API生成lineRDD,然后用flatMap算子把lineRDD转换为wordRDD;-
|
||||
接下来,filter算子对wordRDD做过滤,并把它转换为不带空串的cleanWordRDD;-
|
||||
然后,为了后续的聚合计算,map算子把cleanWordRDD又转换成元素为(Key,Value)对的kvRDD;-
|
||||
最终,我们调用reduceByKey做分组聚合,把kvRDD中的Value从1转换为单词计数。
|
||||
|
||||
这4步转换的过程如下图所示:
|
||||
|
||||
|
||||
|
||||
我们刚刚说过,RDD代表的是分布式数据形态,因此,RDD到RDD之间的转换,本质上是数据形态上的转换(Transformations)。
|
||||
|
||||
在RDD的编程模型中,一共有两种算子,Transformations类算子和Actions类算子。开发者需要使用Transformations类算子,定义并描述数据形态的转换过程,然后调用Actions类算子,将计算结果收集起来、或是物化到磁盘。
|
||||
|
||||
在这样的编程模型下,Spark在运行时的计算被划分为两个环节。
|
||||
|
||||
|
||||
基于不同数据形态之间的转换,构建计算流图(DAG,Directed Acyclic Graph);
|
||||
通过Actions类算子,以回溯的方式去触发执行这个计算流图。
|
||||
|
||||
|
||||
换句话说,开发者调用的各类Transformations算子,并不立即执行计算,当且仅当开发者调用Actions算子时,之前调用的转换算子才会付诸执行。在业内,这样的计算模式有个专门的术语,叫作“延迟计算”(Lazy Evaluation)。
|
||||
|
||||
延迟计算很好地解释了本讲开头的问题:为什么Word Count在执行的过程中,只有最后一行代码会花费很长时间,而前面的代码都是瞬间执行完毕的呢?
|
||||
|
||||
这里的答案正是Spark的延迟计算。flatMap、filter、map这些算子,仅用于构建计算流图,因此,当你在spark-shell中敲入这些代码时,spark-shell会立即返回。只有在你敲入最后那行包含take的代码时,Spark才会触发执行从头到尾的计算流程,所以直观地看上去,最后一行代码是最耗时的。
|
||||
|
||||
Spark程序的整个运行流程如下图所示:
|
||||
|
||||
|
||||
|
||||
你可能会问:“在RDD的开发框架下,哪些算子属于Transformations算子,哪些算子是Actions算子呢?”
|
||||
|
||||
我们都知道,Spark有很多算子,Spark官网提供了完整的RDD算子集合,不过对于这些算子,官网更多地是采用一种罗列的方式去呈现的,没有进行分类,看得人眼花缭乱、昏昏欲睡。因此,我把常用的RDD算子进行了归类,并整理到了下面的表格中,供你随时查阅。
|
||||
|
||||
|
||||
|
||||
结合每个算子的分类、用途和适用场景,这张表格可以帮你更快、更高效地选择合适的算子来实现业务逻辑。对于表格中不熟悉的算子,比如aggregateByKey,你可以结合官网的介绍与解释,或是进一步查阅网上的相关资料,有的放矢地去深入理解。重要的算子,我们会在之后的课里详细解释。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,我们重点讲解了RDD的编程模型与延迟计算,并通过土豆工坊的类比介绍了什么是RDD。RDD是Spark对于分布式数据集的抽象,它用于囊括所有内存中和磁盘中的分布式数据实体。对于RDD,你要重点掌握它的4大属性,这是我们后续学习的重要基础:
|
||||
|
||||
|
||||
partitions:数据分片
|
||||
partitioner:分片切割规则
|
||||
dependencies:RDD依赖
|
||||
compute:转换函数
|
||||
|
||||
|
||||
深入理解RDD之后,你需要熟悉RDD的编程模型。在RDD的编程模型中,开发者需要使用Transformations类算子,定义并描述数据形态的转换过程,然后调用Actions类算子,将计算结果收集起来、或是物化到磁盘。
|
||||
|
||||
而延迟计算指的是,开发者调用的各类Transformations算子,并不会立即执行计算,当且仅当开发者调用Actions算子时,之前调用的转换算子才会付诸执行。
|
||||
|
||||
每课一练
|
||||
|
||||
对于Word Count的计算流图与土豆工坊的流水线工艺,尽管看上去毫不相关,风马牛不相及,不过,你不妨花点时间想一想,它们之间有哪些区别和联系?
|
||||
|
||||
欢迎你把答案分享到评论区,我在评论区等你,也欢迎你把这一讲分享给更多的朋友和同事,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
273
专栏/零基础入门Spark/03RDD常用算子(一):RDD内部的数据转换.md
Normal file
273
专栏/零基础入门Spark/03RDD常用算子(一):RDD内部的数据转换.md
Normal file
@ -0,0 +1,273 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 RDD常用算子(一):RDD内部的数据转换
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲的最后,我们用一张表格整理了Spark官网给出的RDD算子。想要在Spark之上快速实现业务逻辑,理解并掌握这些算子无疑是至关重要的。
|
||||
|
||||
因此,在接下来的几讲,我将带你一起梳理这些常见算子的用法与用途。不同的算子,就像是厨房里的炒勺、铲子、刀具和各式各样的锅碗瓢盆,只有熟悉了这些“厨具”的操作方法,才能在客人点餐的时候迅速地做出一桌好菜。
|
||||
|
||||
今天这一讲,我们先来学习同一个RDD内部的数据转换。掌握RDD常用算子是做好Spark应用开发的基础,而数据转换类算子则是基础中的基础,因此我们优先来学习这类RDD算子。
|
||||
|
||||
在这些算子中,我们重点讲解的就是map、mapPartitions、flatMap、filter。这4个算子几乎囊括了日常开发中99%的数据转换场景,剩下的mapPartitionsWithIndex,我把它留给你作为课后作业去探索。
|
||||
|
||||
|
||||
|
||||
俗话说,巧妇难为无米之炊,要想玩转厨房里的厨具,我们得先准备好米、面、油这些食材。学习RDD算子也是一样,要想动手操作这些算子,咱们得先有RDD才行。
|
||||
|
||||
所以,接下来我们就一起来看看RDD是怎么创建的。
|
||||
|
||||
创建RDD
|
||||
|
||||
在Spark中,创建RDD的典型方式有两种:
|
||||
|
||||
|
||||
通过SparkContext.parallelize在内部数据之上创建RDD;
|
||||
通过SparkContext.textFile等API从外部数据创建RDD。
|
||||
|
||||
|
||||
这里的内部、外部是相对应用程序来说的。开发者在Spark应用中自定义的各类数据结构,如数组、列表、映射等,都属于“内部数据”;而“外部数据”指代的,是Spark系统之外的所有数据形式,如本地文件系统或是分布式文件系统中的数据,再比如来自其他大数据组件(Hive、Hbase、RDBMS等)的数据。
|
||||
|
||||
第一种创建方式的用法非常简单,只需要用parallelize函数来封装内部数据即可,比如下面的例子:
|
||||
|
||||
import org.apache.spark.rdd.RDD
|
||||
val words: Array[String] = Array("Spark", "is", "cool")
|
||||
val rdd: RDD[String] = sc.parallelize(words)
|
||||
|
||||
|
||||
你可以在spark-shell中敲入上述代码,来直观地感受parallelize创建RDD的过程。通常来说,在Spark应用内定义体量超大的数据集,其实都是不太合适的,因为数据集完全由Driver端创建,且创建完成后,还要在全网范围内跨节点、跨进程地分发到其他Executors,所以往往会带来性能问题。因此,parallelize API的典型用法,是在“小数据”之上创建RDD。
|
||||
|
||||
要想在真正的“大数据”之上创建RDD,我们还得依赖第二种创建方式,也就是通过SparkContext.textFile等API从外部数据创建RDD。由于textFile API比较简单,而且它在日常的开发中出现频率比较高,因此我们使用textFile API来创建RDD。在后续对各类RDD算子讲解的过程中,我们都会使用textFile API从文件系统创建RDD。
|
||||
|
||||
为了保持讲解的连贯性,我们还是使用第一讲中的源文件wikiOfSpark.txt来创建RDD,代码实现如下所示:
|
||||
|
||||
import org.apache.spark.rdd.RDD
|
||||
val rootPath: String = _
|
||||
val file: String = s"${rootPath}/wikiOfSpark.txt"
|
||||
// 读取文件内容
|
||||
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
|
||||
|
||||
|
||||
好啦,创建好了RDD,我们就有了可以下锅的食材。接下来,咱们就要正式地走进厨房,把铲子和炒勺挥起来啦。
|
||||
|
||||
RDD内的数据转换
|
||||
|
||||
首先,我们先来认识一下map算子。毫不夸张地说,在所有的RDD算子中,map“出场”的概率是最高的。因此,我们必须要掌握map的用法与注意事项。
|
||||
|
||||
map:以元素为粒度的数据转换
|
||||
|
||||
我们先来说说map算子的用法:给定映射函数f,map(f)以元素为粒度对RDD做数据转换。其中f可以是带有明确签名的带名函数,也可以是匿名函数,它的形参类型必须与RDD的元素类型保持一致,而输出类型则任由开发者自行决定。
|
||||
|
||||
这种照本宣科的介绍听上去难免会让你有点懵,别着急,接下来我们用些小例子来更加直观地展示map的用法。
|
||||
|
||||
在[第一讲]的Word Count示例中,我们使用如下代码,把包含单词的RDD转换成元素为(Key,Value)对的RDD,后者统称为Paired RDD。
|
||||
|
||||
// 把普通RDD转换为Paired RDD
|
||||
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
|
||||
|
||||
|
||||
在上面的代码实现中,传递给map算子的形参,即:word => (word,1),就是我们上面说的映射函数f。只不过,这里f是以匿名函数的方式进行定义的,其中左侧的word表示匿名函数f的输入形参,而右侧的(word,1)则代表函数f的输出结果。
|
||||
|
||||
如果我们把匿名函数变成带名函数的话,可能你会看的更清楚一些。这里我用一段代码重新定义了带名函数f。
|
||||
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
|
||||
// 定义映射函数f
|
||||
def f(word: String): (String, Int) = {
|
||||
return (word, 1)
|
||||
}
|
||||
|
||||
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(f)
|
||||
|
||||
|
||||
可以看到,我们使用Scala的def语法,明确定义了带名映射函数f,它的计算逻辑与刚刚的匿名函数是一致的。在做RDD数据转换的时候,我们只需把函数f传递给map算子即可。不管f是匿名函数,还是带名函数,map算子的转换逻辑都是一样的,你不妨把以上两种实现方式分别敲入到spark-shell,去验证执行结果的一致性。
|
||||
|
||||
到这里为止,我们就掌握了map算子的基本用法。现在你就可以定义任意复杂的映射函数f,然后在RDD之上通过调用map(f)去翻着花样地做各种各样的数据转换。
|
||||
|
||||
比如,通过定义如下的映射函数f,我们就可以改写Word Count的计数逻辑,也就是把“Spark”这个单词的统计计数权重提高一倍:
|
||||
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
|
||||
// 定义映射函数f
|
||||
def f(word: String): (String, Int) = {
|
||||
if (word.equals("Spark")) { return (word, 2) }
|
||||
return (word, 1)
|
||||
}
|
||||
|
||||
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(f)
|
||||
|
||||
|
||||
尽管map算子足够灵活,允许开发者自由定义转换逻辑。不过,就像我们刚刚说的,map(f)是以元素为粒度对RDD做数据转换的,在某些计算场景下,这个特点会严重影响执行效率。为什么这么说呢?我们来看一个具体的例子。
|
||||
|
||||
比方说,我们把Word Count的计数需求,从原来的对单词计数,改为对单词的哈希值计数,在这种情况下,我们的代码实现需要做哪些改动呢?我来示范一下:
|
||||
|
||||
// 把普通RDD转换为Paired RDD
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
|
||||
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map{ word =>
|
||||
// 获取MD5对象实例
|
||||
val md5 = MessageDigest.getInstance("MD5")
|
||||
// 使用MD5计算哈希值
|
||||
val hash = md5.digest(word.getBytes).mkString
|
||||
// 返回哈希值与数字1的Pair
|
||||
(hash, 1)
|
||||
}
|
||||
|
||||
|
||||
由于map(f)是以元素为单元做转换的,那么对于RDD中的每一条数据记录,我们都需要实例化一个MessageDigest对象来计算这个元素的哈希值。
|
||||
|
||||
在工业级生产系统中,一个RDD动辄包含上百万甚至是上亿级别的数据记录,如果处理每条记录都需要事先创建MessageDigest,那么实例化对象的开销就会聚沙成塔,不知不觉地成为影响执行效率的罪魁祸首。
|
||||
|
||||
那么问题来了,有没有什么办法,能够让Spark在更粗的数据粒度上去处理数据呢?还真有,mapPartitions和mapPartitionsWithIndex这对“孪生兄弟”就是用来解决类似的问题。相比mapPartitions,mapPartitionsWithIndex仅仅多出了一个数据分区索引,因此接下来我们把重点放在mapPartitions上面。
|
||||
|
||||
mapPartitions:以数据分区为粒度的数据转换
|
||||
|
||||
按照介绍算子的惯例,我们还是先来说说mapPartitions的用法。mapPartitions,顾名思义,就是以数据分区为粒度,使用映射函数f对RDD进行数据转换。对于上述单词哈希值计数的例子,我们结合后面的代码,来看看如何使用mapPartitions来改善执行性能:
|
||||
|
||||
// 把普通RDD转换为Paired RDD
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
||||
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
|
||||
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.mapPartitions( partition => {
|
||||
// 注意!这里是以数据分区为粒度,获取MD5对象实例
|
||||
val md5 = MessageDigest.getInstance("MD5")
|
||||
val newPartition = partition.map( word => {
|
||||
// 在处理每一条数据记录的时候,可以复用同一个Partition内的MD5对象
|
||||
(md5.digest(word.getBytes()).mkString,1)
|
||||
})
|
||||
newPartition
|
||||
})
|
||||
|
||||
|
||||
可以看到,在上面的改进代码中,mapPartitions以数据分区(匿名函数的形参partition)为粒度,对RDD进行数据转换。具体的数据处理逻辑,则由代表数据分区的形参partition进一步调用map(f)来完成。你可能会说:“partition. map(f)仍然是以元素为粒度做映射呀!这和前一个版本的实现,有什么本质上的区别呢?”
|
||||
|
||||
仔细观察,你就会发现,相比前一个版本,我们把实例化MD5对象的语句挪到了map算子之外。如此一来,以数据分区为单位,实例化对象的操作只需要执行一次,而同一个数据分区中所有的数据记录,都可以共享该MD5对象,从而完成单词到哈希值的转换。
|
||||
|
||||
通过下图的直观对比,你会发现,以数据分区为单位,mapPartitions只需实例化一次MD5对象,而map算子却需要实例化多次,具体的次数则由分区内数据记录的数量来决定。
|
||||
|
||||
|
||||
|
||||
对于一个有着上百万条记录的RDD来说,其数据分区的划分往往是在百这个量级,因此,相比map算子,mapPartitions可以显著降低对象实例化的计算开销,这对于Spark作业端到端的执行性能来说,无疑是非常友好的。
|
||||
|
||||
实际上。除了计算哈希值以外,对于数据记录来说,凡是可以共享的操作,都可以用mapPartitions算子进行优化。这样的共享操作还有很多,比如创建用于连接远端数据库的Connections对象,或是用于连接Amazon S3的文件系统句柄,再比如用于在线推理的机器学习模型,等等,不一而足。你不妨结合实际工作场景,把你遇到的共享操作整理到留言区,期待你的分享。
|
||||
|
||||
相比mapPartitions,mapPartitionsWithIndex仅仅多出了一个数据分区索引,这个数据分区索引可以为我们获取分区编号,当你的业务逻辑中需要使用到分区编号的时候,不妨考虑使用这个算子来实现代码。除了这个额外的分区索引以外,mapPartitionsWithIndex在其他方面与mapPartitions是完全一样的。
|
||||
|
||||
介绍完map与mapPartitions算子之后,接下来,我们趁热打铁,再来看一个与这两者功能类似的算子:flatMap。
|
||||
|
||||
flatMap:从元素到集合、再从集合到元素
|
||||
|
||||
flatMap其实和map与mapPartitions算子类似,在功能上,与map和mapPartitions一样,flatMap也是用来做数据映射的,在实现上,对于给定映射函数f,flatMap(f)以元素为粒度,对RDD进行数据转换。
|
||||
|
||||
不过,与前两者相比,flatMap的映射函数f有着显著的不同。对于map和mapPartitions来说,其映射函数f的类型,都是(元素) => (元素),即元素到元素。而flatMap映射函数f的类型,是(元素) => (集合),即元素到集合(如数组、列表等)。因此,flatMap的映射过程在逻辑上分为两步:
|
||||
|
||||
|
||||
以元素为单位,创建集合;
|
||||
去掉集合“外包装”,提取集合元素。
|
||||
|
||||
|
||||
这么说比较抽象,我们还是来举例说明。假设,我们再次改变Word Count的计算逻辑,由原来统计单词的计数,改为统计相邻单词共现的次数,如下图所示:
|
||||
|
||||
|
||||
|
||||
对于这样的计算逻辑,我们该如何使用flatMap进行实现呢?这里我们先给出代码实现,然后再分阶段地分析flatMap的映射过程:
|
||||
|
||||
// 读取文件内容
|
||||
val lineRDD: RDD[String] = _ // 请参考第一讲获取完整代码
|
||||
// 以行为单位提取相邻单词
|
||||
val wordPairRDD: RDD[String] = lineRDD.flatMap( line => {
|
||||
// 将行转换为单词数组
|
||||
val words: Array[String] = line.split(" ")
|
||||
// 将单个单词数组,转换为相邻单词数组
|
||||
for (i <- 0 until words.length - 1) yield words(i) + "-" + words(i+1)
|
||||
})
|
||||
|
||||
|
||||
在上面的代码中,我们采用匿名函数的形式,来提供映射函数f。这里f的形参是String类型的line,也就是源文件中的一行文本,而f的返回类型是Array[String],也就是String类型的数组。在映射函数f的函数体中,我们先用split语句把line转化为单词数组,然后再用for循环结合yield语句,依次把单个的单词,转化为相邻单词词对。
|
||||
|
||||
注意,for循环返回的依然是数组,也即类型为Array[String]的词对数组。由此可见,函数f的类型是(String) => (Array[String]),也就是刚刚说的第一步,从元素到集合。但如果我们去观察转换前后的两个RDD,也就是lineRDD和wordPairRDD,会发现它们的类型都是RDD[String],换句话说,它们的元素类型都是String。
|
||||
|
||||
回顾map与mapPartitions这两个算子,我们会发现,转换前后RDD的元素类型,与映射函数f的类型是一致的。但在flatMap这里,却出现了RDD元素类型与函数类型不一致的情况。这是怎么回事呢?其实呢,这正是flatMap的“奥妙”所在,为了让你直观地理解flatMap的映射过程,我画了一张示意图,如下所示:
|
||||
|
||||
|
||||
|
||||
不难发现,映射函数f的计算过程,对应着图中的步骤1与步骤2,每行文本都被转化为包含相邻词对的数组。紧接着,flatMap去掉每个数组的“外包装”,提取出数组中类型为String的词对元素,然后以词对为单位,构建新的数据分区,如图中步骤3所示。这就是flatMap映射过程的第二步:去掉集合“外包装”,提取集合元素。
|
||||
|
||||
得到包含词对元素的wordPairRDD之后,我们就可以沿用Word Count的后续逻辑,去计算相邻词汇的共现次数。你不妨结合文稿中的代码与第一讲中Word Count的代码,去实现完整版的“相邻词汇计数统计”。
|
||||
|
||||
filter:过滤RDD
|
||||
|
||||
在今天的最后,我们再来学习一下,与map一样常用的算子:filter。filter,顾名思义,这个算子的作用,是对RDD进行过滤。就像是map算子依赖其映射函数一样,filter算子也需要借助一个判定函数f,才能实现对RDD的过滤转换。
|
||||
|
||||
所谓判定函数,它指的是类型为(RDD元素类型) => (Boolean)的函数。可以看到,判定函数f的形参类型,必须与RDD的元素类型保持一致,而f的返回结果,只能是True或者False。在任何一个RDD之上调用filter(f),其作用是保留RDD中满足f(也就是f返回True)的数据元素,而过滤掉不满足f(也就是f返回False)的数据元素。
|
||||
|
||||
老规矩,我们还是结合示例来讲解filter算子与判定函数f。
|
||||
|
||||
在上面flatMap例子的最后,我们得到了元素为相邻词汇对的wordPairRDD,它包含的是像“Spark-is”、“is-cool”这样的字符串。为了仅保留有意义的词对元素,我们希望结合标点符号列表,对wordPairRDD进行过滤。例如,我们希望过滤掉像“Spark-&”、“|-data”这样的词对。
|
||||
|
||||
掌握了filter算子的用法之后,要实现这样的过滤逻辑,我相信你很快就能写出如下的代码实现:
|
||||
|
||||
// 定义特殊字符列表
|
||||
val list: List[String] = List("&", "|", "#", "^", "@")
|
||||
|
||||
// 定义判定函数f
|
||||
def f(s: String): Boolean = {
|
||||
val words: Array[String] = s.split("-")
|
||||
val b1: Boolean = list.contains(words(0))
|
||||
val b2: Boolean = list.contains(words(1))
|
||||
return !b1 && !b2 // 返回不在特殊字符列表中的词汇对
|
||||
}
|
||||
|
||||
// 使用filter(f)对RDD进行过滤
|
||||
val cleanedPairRDD: RDD[String] = wordPairRDD.filter(f)
|
||||
|
||||
|
||||
掌握了filter算子的用法之后,你就可以定义任意复杂的判定函数f,然后在RDD之上通过调用filter(f)去变着花样地做数据过滤,从而满足不同的业务需求。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,到此为止,关于RDD内数据转换的几个算子,我们就讲完了,我们一起来做个总结。今天这一讲,你需要掌握map、mapPartitions、flatMap和filter这4个算子的作用和具体用法。
|
||||
|
||||
首先,我们讲了map算子的用法,它允许开发者自由地对RDD做各式各样的数据转换,给定映射函数f,map(f)以元素为粒度对RDD做数据转换。其中f可以是带名函数,也可以是匿名函数,它的形参类型必须与RDD的元素类型保持一致,而输出类型则任由开发者自行决定。
|
||||
|
||||
为了提升数据转换的效率,Spark提供了以数据分区为粒度的mapPartitions算子。mapPartitions的形参是代表数据分区的partition,它通过在partition之上再次调用map(f)来完成数据的转换。相比map,mapPartitions的优势是以数据分区为粒度初始化共享对象,这些共享对象在我们日常的开发中很常见,比如数据库连接对象、S3文件句柄、机器学习模型等等。
|
||||
|
||||
紧接着,我们介绍了flatMap算子。flatMap的映射函数f比较特殊,它的函数类型是(元素) => (集合),这里集合指的是像数组、列表这样的数据结构。因此,flatMap的映射过程在逻辑上分为两步,这一点需要你特别注意:
|
||||
|
||||
|
||||
以元素为单位,创建集合;
|
||||
去掉集合“外包装”,提取集合元素。
|
||||
|
||||
|
||||
最后,我们学习了filter算子,filter算子的用法与map很像,它需要借助判定函数f来完成对RDD的数据过滤。判定函数的类型必须是(RDD元素类型) => (Boolean),也就是形参类型必须与RDD的元素类型保持一致,返回结果类型则必须是布尔值。RDD中的元素是否能够得以保留,取决于判定函数f的返回值是True还是False。
|
||||
|
||||
虽然今天我们只学了4个算子,但这4个算子在日常开发中的出现频率非常之高。掌握了这几个简单的RDD算子,你几乎可以应对RDD中90%的数据转换场景。希望你对这几个算子多多加以练习,从而在日常的开发工作中学以致用。
|
||||
|
||||
每课一练
|
||||
|
||||
讲完了正课,我来给你留3个思考题:
|
||||
|
||||
|
||||
请你结合官网的介绍,自学mapPartitionsWithIndex算子。请你说一说,在哪些场景下可能会用到这个算子?
|
||||
|
||||
对于我们今天学过的4个算子,再加上没有详细解释的mapPartitionsWithIndex,你能说说,它们之间有哪些共性或是共同点吗?
|
||||
|
||||
你能说一说,在日常的工作中,还遇到过哪些可以在mapPartitions中初始化的共享对象呢?
|
||||
|
||||
|
||||
欢迎你在评论区回答这些练习题。你也可以把这一讲分享给更多的朋友或者同事,和他们一起讨论讨论,交流是学习的催化剂。我在评论区等你。
|
||||
|
||||
|
||||
|
||||
|
165
专栏/零基础入门Spark/04进程模型与分布式部署:分布式计算是怎么回事?.md
Normal file
165
专栏/零基础入门Spark/04进程模型与分布式部署:分布式计算是怎么回事?.md
Normal file
@ -0,0 +1,165 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 进程模型与分布式部署:分布式计算是怎么回事?
|
||||
你好,我是吴磊。
|
||||
|
||||
在[第2讲]的最后,我们留了一道思考题。Word Count的计算流图与土豆工坊的流水线工艺,二者之间有哪些区别和联系?如果你有点记不清了,可以看下后面的图回忆一下。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
我们先来说区别。首先,Word Count计算流图是一种抽象的流程图,而土豆工坊的流水线是可操作、可运行而又具体的执行步骤。然后,计算流图中的每一个元素,如lineRDD、wordRDD,都是“虚”的数据集抽象,而流水线上各个环节不同形态的食材,比如一颗颗脏兮兮的土豆,都是“实实在在”的实物。
|
||||
|
||||
厘清了二者之间的区别之后,它们之间的联系自然也就显而易见了。如果把计算流图看作是“设计图纸”,那么流水线工艺其实就是“施工过程”。前者是设计层面、高屋建瓴的指导意见,而后者是执行层面、按部就班的实施过程。前者是后者的基石,而后者是前者的具化。
|
||||
|
||||
你可能会好奇:“我们为什么非要弄清这二者之间的区别和联系呢?”原因其实很简单,分布式计算的精髓,在于如何把抽象的计算流图,转化为实实在在的分布式计算任务,然后以并行计算的方式交付执行。
|
||||
|
||||
今天这一讲,我们就来聊一聊,Spark是如何实现分布式计算的。分布式计算的实现,离不开两个关键要素,一个是进程模型,另一个是分布式的环境部署。接下来,我们先去探讨Spark的进程模型,然后再来介绍Spark都有哪些分布式部署方式。
|
||||
|
||||
进程模型
|
||||
|
||||
在Spark的应用开发中,任何一个应用程序的入口,都是带有SparkSession的main函数。SparkSession包罗万象,它在提供Spark运行时上下文的同时(如调度系统、存储系统、内存管理、RPC通信),也可以为开发者提供创建、转换、计算分布式数据集(如RDD)的开发API。
|
||||
|
||||
不过,在Spark分布式计算环境中,有且仅有一个JVM进程运行这样的main函数,这个特殊的JVM进程,在Spark中有个专门的术语,叫作“Driver”。
|
||||
|
||||
Driver最核心的作用在于,解析用户代码、构建计算流图,然后将计算流图转化为分布式任务,并把任务分发给集群中的执行进程交付运行。换句话说,Driver的角色是拆解任务、派活儿,而真正干活儿的“苦力”,是执行进程。在Spark的分布式环境中,这样的执行进程可以有一个或是多个,它们也有专门的术语,叫作“Executor”。
|
||||
|
||||
我把Driver和Executor的关系画成了一张图,你可以看看:
|
||||
|
||||
|
||||
|
||||
分布式计算的核心是任务调度,而分布式任务的调度与执行,仰仗的是Driver与Executors之间的通力合作。在后续的课程中,我们会深入讲解Driver如何与众多Executors协作完成任务调度,不过在此之前,咱们先要厘清Driver与Executors的关系,从而为后续的课程打下坚实的基础。
|
||||
|
||||
Driver与Executors:包工头与施工工人
|
||||
|
||||
简单来看,Driver与Executors的关系,就像是工地上包工头与施工工人们之间的关系。包工头负责“揽活儿”,拿到设计图纸之后负责拆解任务,把二维平面图,细化成夯土、打地基、砌墙、浇筑钢筋混凝土等任务,然后再把任务派发给手下的工人。工人们认领到任务之后,相对独立地去完成各自的任务,仅在必要的时候进行沟通与协调。
|
||||
|
||||
其实不同的建筑任务之间,往往是存在依赖关系的,比如,砌墙一定是在地基打成之后才能施工,同理,浇筑钢筋混凝土也一定要等到砖墙砌成之后才能进行。因此,Driver这个“包工头”的重要职责之一,就是合理有序地拆解并安排建筑任务。
|
||||
|
||||
再者,为了保证施工进度,Driver除了分发任务之外,还需要定期与每个Executor进行沟通,及时获取他们的工作进展,从而协调整体的执行进度。
|
||||
|
||||
一个篱笆三个桩,一个好汉三个帮。要履行上述一系列的职责,Driver自然需要一些给力的帮手才行。在Spark的Driver进程中,DAGScheduler、TaskScheduler和SchedulerBackend这三个对象通力合作,依次完成分布式任务调度的3个核心步骤,也就是:
|
||||
|
||||
|
||||
根据用户代码构建计算流图;-
|
||||
根据计算流图拆解出分布式任务;-
|
||||
将分布式任务分发到Executors中去。
|
||||
|
||||
|
||||
接收到任务之后,Executors调用内部线程池,结合事先分配好的数据分片,并发地执行任务代码。对于一个完整的RDD,每个Executors负责处理这个RDD的一个数据分片子集。这就好比是,对于工地上所有的砖头,甲、乙、丙三个工人分别认领其中的三分之一,然后拿来分别构筑东、西、北三面高墙。
|
||||
|
||||
好啦,到目前为止,关于Driver和Executors的概念,他们各自的职责以及相互之间的关系,我们有了最基本的了解。尽管对于一些关键对象,如上述DAGScheduler、TaskScheduler,我们还有待深入,但这并不影响咱们居高临下地去理解Spark进程模型。
|
||||
|
||||
不过,你可能会说:“一说到模型就总觉得抽象,能不能结合示例来具体说明呢?”接下来,我们还是沿用前两讲展示的Word Count示例,一起去探究spark-shell在幕后是如何运行的。
|
||||
|
||||
spark-shell执行过程解析
|
||||
|
||||
在第1讲,我们在本机搭建了Spark本地运行环境,并通过在终端敲入spark-shell进入交互式REPL。与很多其他系统命令一样,spark-shell有很多命令行参数,其中最为重要的有两类:一类是用于指定部署模式的master,另一类则用于指定集群的计算资源容量。
|
||||
|
||||
不带任何参数的spark-shell命令,实际上等同于下方这个命令:
|
||||
|
||||
spark-shell --master local[*]
|
||||
|
||||
|
||||
这行代码的含义有两层。第一层含义是部署模式,其中local关键字表示部署模式为Local,也就是本地部署;第二层含义是部署规模,也就是方括号里面的数字,它表示的是在本地部署中需要启动多少个Executors,星号则意味着这个数量与机器中可用CPU的个数相一致。
|
||||
|
||||
也就是说,假设你的笔记本电脑有4个CPU,那么当你在命令行敲入spark-shell的时候,Spark会在后台启动1个Driver进程和3个Executors进程。
|
||||
|
||||
那么问题来了,当我们把Word Count的示例代码依次敲入到spark-shell中,Driver进程和3个Executors进程之间是如何通力合作来执行分布式任务的呢?
|
||||
|
||||
为了帮你理解这个过程,我特意画了一张图,你可以先看一下整体的执行过程:
|
||||
|
||||
|
||||
|
||||
首先,Driver通过take这个Action算子,来触发执行先前构建好的计算流图。沿着计算流图的执行方向,也就是图中从上到下的方向,Driver以Shuffle为边界创建、分发分布式任务。
|
||||
|
||||
Shuffle的本意是扑克牌中的“洗牌”,在大数据领域的引申义,表示的是集群范围内跨进程、跨节点的数据交换。我们在专栏后续的内容中会对Shuffle做专门的讲解,这里我们不妨先用Word Count的例子,来简单地对Shuffle进行理解。
|
||||
|
||||
在reduceByKey算子之前,同一个单词,比如“spark”,可能散落在不用的Executors进程,比如图中的Executor-0、Executor-1和Executor-2。换句话说,这些Executors处理的数据分片中,都包含单词“spark”。
|
||||
|
||||
那么,要完成对“spark”的计数,我们需要把所有“spark”分发到同一个Executor进程,才能完成计算。而这个把原本散落在不同Executors的单词,分发到同一个Executor的过程,就是Shuffle。
|
||||
|
||||
大概理解了Shuffle后,我们回过头接着说Driver是怎么创建分布式任务的。对于reduceByKey之前的所有操作,也就是textFile、flatMap、filter、map等,Driver会把它们“捏合”成一份任务,然后一次性地把这份任务打包、分发给每一个Executors。
|
||||
|
||||
三个Executors接收到任务之后,先是对任务进行解析,把任务拆解成textFile、flatMap、filter、map这4个步骤,然后分别对自己负责的数据分片进行处理。
|
||||
|
||||
为了方便说明,我们不妨假设并行度为3,也就是原始数据文件wikiOfSpark.txt被切割成了3份,这样每个Executors刚好处理其中的一份。数据处理完毕之后,分片内容就从原来的RDD[String]转换成了包含键值对的RDD[(String, Int)],其中每个单词的计数都置位1。此时Executors会及时地向Driver汇报自己的工作进展,从而方便Driver来统一协调大家下一步的工作。
|
||||
|
||||
这个时候,要继续进行后面的聚合计算,也就是计数操作,就必须进行刚刚说的Shuffle操作。在不同Executors完成单词的数据交换之后,Driver继续创建并分发下一个阶段的任务,也就是按照单词做分组计数。
|
||||
|
||||
数据交换之后,所有相同的单词都分发到了相同的Executors上去,这个时候,各个Executors拿到reduceByKey的任务,只需要各自独立地去完成统计计数即可。完成计数之后,Executors会把最终的计算结果统一返回给Driver。
|
||||
|
||||
这样一来,spark-shell便完成了Word Count用户代码的计算过程。经过了刚才的分析,对于Spark进程模型、Driver与Executors之间的关联与联系,想必你就有了更清晰的理解和把握。
|
||||
|
||||
不过,到目前为止,对于Word Count示例和spark-shell的讲解,我们一直是在本地部署的环境中做展示。我们知道,Spark真正的威力,其实在于分布式集群中的并行计算。只有充分利用集群中每个节点的计算资源,才能充分发挥出Spark的性能优势。因此,我们很有必要去学习并了解Spark的分布式部署。
|
||||
|
||||
分布式环境部署
|
||||
|
||||
Spark支持多种分布式部署模式,如Standalone、YARN、Mesos、Kubernetes。其中Standalone是Spark内置的资源调度器,而YARN、Mesos、Kubernetes是独立的第三方资源调度与服务编排框架。
|
||||
|
||||
由于后三者提供独立而又完备的资源调度能力,对于这些框架来说,Spark仅仅是其支持的众多计算引擎中的一种。Spark在这些独立框架上的分布式部署步骤较少,流程比较简单,我们开发者只需下载并解压Spark安装包,然后适当调整Spark配置文件、以及修改环境变量就行了。
|
||||
|
||||
因此,对于YARN、Mesos、Kubernetes这三种部署模式,我们不做详细展开,我把它给你留作课后作业进行探索。今天这一讲,我们仅专注于Spark在Standalone模式下的分布式部署。
|
||||
|
||||
为了示意Standalone模式的部署过程,我这边在AWS环境中创建并启动了3台EC2计算节点,操作系统为Linux/CentOS。
|
||||
|
||||
需要指出的是,Spark分布式计算环境的部署,对于节点类型与操作系统本身是没有要求和限制的,但是在实际的部署中,请你尽量保持每台计算节点的操作系统是一致的,从而避免不必要的麻烦。
|
||||
|
||||
接下来,我就带你手把手地去完成Standalone模式的分布式部署。
|
||||
|
||||
Standalone在资源调度层面,采用了一主多从的主从架构,把计算节点的角色分为Master和Worker。其中,Master有且只有一个,而Worker可以有一到多个。所有Worker节点周期性地向Master汇报本节点可用资源状态,Master负责汇总、变更、管理集群中的可用资源,并对Spark应用程序中Driver的资源请求作出响应。
|
||||
|
||||
为了方便描述,我们把3台EC2的hostname分别设置为node0、node1、node2,并把node0选做Master节点,而把node1、node2选做Worker节点。
|
||||
|
||||
首先,为了实现3台机器之间的无缝通信,我们先来在3台节点之间配置无密码的SSH环境:
|
||||
|
||||
|
||||
|
||||
接下来,我们在所有节点上准备Java环境并安装Spark(其中步骤2的“sudo wget”你可以参考这里的链接),操作命令如下表所示:
|
||||
|
||||
|
||||
|
||||
在所有节点之上完成Spark的安装之后,我们就可以依次启动Master和Worker节点了,如下表所示:
|
||||
|
||||
|
||||
|
||||
集群启动之后,我们可以使用Spark自带的小例子,来验证Standalone分布式部署是否成功。首先,打开Master或是Worker的命令行终端,然后敲入下面这个命令:
|
||||
|
||||
MASTER=spark://node0:7077 $SPARK_HOME/bin/run-example org.apache.spark.examples.SparkPi
|
||||
|
||||
|
||||
如果程序能够成功计算Pi值,也就是3.14,如下图所示,那么说明咱们的Spark分布式计算集群已然就绪。你可以对照文稿里的截图,验证下你的环境是否也成功了。
|
||||
|
||||
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,我们提到,分布式计算的精髓在于,如何把抽象的计算流图,转化为实实在在的分布式计算任务,然后以并行计算的方式交付执行。而要想透彻理解分布式计算,你就需要掌握Spark进程模型。
|
||||
|
||||
进程模型的核心是Driver和Executors,我们需要重点理解它们之间的协作关系。任何一个Spark应用程序的入口,都是带有SparkSession的main函数,而在Spark的分布式计算环境中,运行这样main函数的JVM进程有且仅有一个,它被称为 “Driver”。
|
||||
|
||||
Driver最核心的作用,在于解析用户代码、构建计算流图,然后将计算流图转化为分布式任务,并把任务分发给集群中的Executors交付执行。接收到任务之后,Executors调用内部线程池,结合事先分配好的数据分片,并发地执行任务代码。
|
||||
|
||||
对于一个完整的RDD,每个Executors负责处理这个RDD的一个数据分片子集。每当任务执行完毕,Executors都会及时地与Driver进行通信、汇报任务状态。Driver在获取到Executors的执行进度之后,结合计算流图的任务拆解,依次有序地将下一阶段的任务再次分发给Executors付诸执行,直至整个计算流图执行完毕。
|
||||
|
||||
之后,我们介绍了Spark支持的分布式部署模式,主要有Standalone、YARN、Mesos、Kubernetes。其中,Standalone是Spark内置的资源调度器,而YARN、Mesos、Kubernetes是独立的第三方资源调度与服务编排框架。在这些部署模式中,你需要重点掌握Standalone环境部署的操作步骤。
|
||||
|
||||
每课一练
|
||||
|
||||
好,在这一讲的最后,我给你留两道作业,帮助你巩固今日所学。
|
||||
|
||||
|
||||
与take算子类似,collect算子用于收集计算结果,结合Spark进程模型,你能说一说,相比collect算子相比take算子来说都有哪些隐患吗?
|
||||
|
||||
如果你的生产环境中使用了YARN、Mesos或是Kubernetes,你不妨说一说,要完成Spark在这些独立框架下的分布式部署,都需要哪些必备的步骤?
|
||||
|
||||
|
||||
今天这一讲就到这里了,如果你在部署过程中遇到的什么问题,欢迎你在评论区提问。如果你觉得这一讲帮助到了你,也欢迎你分享给更多的朋友和同事,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
201
专栏/零基础入门Spark/05调度系统:如何把握分布式计算的精髓?.md
Normal file
201
专栏/零基础入门Spark/05调度系统:如何把握分布式计算的精髓?.md
Normal file
@ -0,0 +1,201 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 调度系统:如何把握分布式计算的精髓?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们通过“包工头与施工工人”的例子,初步认识了Spark进程模型中的Driver和Executors、以及它们之间的交互关系。Driver负责解析用户代码、构建计算流图,然后将计算流图转化为分布式任务,并把任务分发给集群中的Executors交付运行。
|
||||
|
||||
不过,你可能会好奇:“对于给定的用户代码和相应的计算流图,Driver是怎么把计算图拆解为分布式任务,又是按照什么规则分发给Executors的呢?还有,Executors具体又是如何执行分布式任务的呢?”
|
||||
|
||||
我们之前一再强调,分布式计算的精髓,在于如何把抽象的计算图,转化为实实在在的分布式计算任务,然后以并行计算的方式交付执行。深入理解分布式计算,是我们做好大数据开发的关键和前提,它能有效避免我们掉入“单机思维”的陷阱,同时也能为性能导向的开发奠定坚实基础。
|
||||
|
||||
而上面的这一系列问题,恰恰是我们吃透分布式计算的关键所在。因此,今天这一讲,我们就顺着这些问题,一起去深入探究Spark调度系统,进而弄清楚分布式计算的来龙去脉。
|
||||
|
||||
角色划分与斯巴克建筑集团
|
||||
|
||||
在上一讲,我们通过“包工头与施工工人”的类比、以及Word Count的示例,其实已经大致厘清了Spark分布式任务调度的核心环节与关键步骤。今天这一讲的核心任务,就是带你去深入其中的每一个环节,做到“既见森林、也见树木”。这里咱们不妨先把这些环节和涉及的组件梳理出来,从而让你在整体上有一个清晰的把握。
|
||||
|
||||
|
||||
|
||||
不难发现,表中的步骤与组件众多,要是照本宣科地去讲调度系统,先别说你可能看着看着就开始犯困了,就连我自己,也可能写着写着就睡着了。因此,咱们不妨把这些环节与组件融入到一个故事中去,让你像读小说一样,在捧腹之余弄懂Spark调度系统。
|
||||
|
||||
话说很久以前,美国有一家名扬海内外的建筑集团,名为“斯巴克(Spark)”。这家建筑集团规模庞大,设有一个总公司(Driver),和多个分公司(Executors)。斯巴克公司的主要服务对象是建筑设计师(开发者),建筑设计师负责提供设计图纸(用户代码、计算图),而斯巴克公司的主营业务是将图纸落地、建造起一栋栋高楼大厦。
|
||||
|
||||
要完成主营业务,集团公司需要招聘能够看懂图纸、并将其转化为建筑项目的架构师,因此斯巴克公司挖角了行业知名架构师“戴格”(DAGScheduler)。集团公司给戴格安排的职位是总公司的一把手,同时要求两位创始元老“塔斯克”和“拜肯德”全力配合戴格的工作。
|
||||
|
||||
听到这里,你肯定会问“塔斯克”和“拜肯德”是谁呢?
|
||||
|
||||
塔斯克(TaskScheduler)一毕业就加入了斯巴克公司,现任总公司施工经理,成功指挥完成了多个大大小小的工程项目,业绩非常突出,深得公司赏识。拜肯德(SchedulerBackend)和塔斯克在上大学的时候就是上下铺,关系好得穿一条裤子,现任总公司人力资源总监,负责与分公司协调、安排人力资源。从公司的安排来看,三位主管的分工还是比较明确的。
|
||||
|
||||
|
||||
|
||||
之所以说塔斯克(TaskScheduler)和拜肯德(SchedulerBackend)是公司元老,原因在于,在SparkContext / SparkSession的初始化中,TaskScheduler和SchedulerBackend是最早、且同时被创建的调度系统组件。这二者的关系非常微妙:SchedulerBackend在构造方法中引用TaskScheduler,而TaskScheduler在初始化时会引用SchedulerBackend。
|
||||
|
||||
值得一提的是,SchedulerBackend组件的实例化,取决于开发者指定的Spark MasterURL,也就是我们使用spark-shell(或是spark-submit)时指定的–master 参数,如“–master spark://ip:host”就代表Standalone 部署模式,“–master yarn”就代表YARN 模式等等。
|
||||
|
||||
不难发现,SchedulerBackend 与资源管理器(Standalone、YARN、Mesos等)强绑定,是资源管理器在 Spark 中的代理。其实硬件资源与人力资源一样,都是“干活儿的”。所以,如果我们用集团公司的人力资源来类比Spark集群的硬件资源,那么“拜肯德”就是名副其实的人力资源总监。
|
||||
|
||||
从全局视角来看,DAGScheduler是任务调度的发起者,DAGScheduler以TaskSet为粒度,向TaskScheduler提交任务调度请求。TaskScheduler在初始化的过程中,会创建任务调度队列,任务调度队列用于缓存 DAGScheduler提交的TaskSets。TaskScheduler结合SchedulerBackend提供的 WorkerOffer,按照预先设置的调度策略依次对队列中的任务进行调度。
|
||||
|
||||
|
||||
|
||||
简而言之,DAGScheduler手里有“活儿”,SchedulerBackend手里有“人力”,TaskScheduler的核心职能,就是把合适的“活儿”派发到合适的“人”的手里。由此可见,TaskScheduler承担的是承上启下、上通下达的关键角色,这也正是我们将“塔斯克”视为斯巴克建筑公司元老之一的重要原因。
|
||||
|
||||
那么,了解了这三个主管的角色职责,我们接下来就来详细说说,他们是怎么各自完成自己的工作的。
|
||||
|
||||
总架戴格:DAGScheduler
|
||||
|
||||
回到我们的故事里,戴格在两位元老的协助下,工作开展得还算顺利,然而,冰层之下,暗流涌动,作为一名空降的领导,戴老板还需亲自“露两手”,才能赢得平级的认可与信任。
|
||||
|
||||
作为集团公司的“总架”(总架构师),戴格的核心职责,是把计算图DAG拆分为执行阶段Stages,Stages指的是不同的运行阶段,同时还要负责把Stages转化为任务集合TaskSets,也就是把“建筑图纸”转化成可执行、可操作的“建筑项目”。
|
||||
|
||||
用一句话来概括从 DAG 到 Stages 的拆分过程,那就是:以 Actions 算子为起点,从后向前回溯 DAG,以 Shuffle 操作为边界去划分 Stages。
|
||||
|
||||
在[第2讲]介绍编程模型的时候,我们以Word Count为例,提到Spark作业的运行分为两个环节,第一个是以惰性的方式构建计算图,第二个则是通过Actions算子触发作业的从头计算:
|
||||
|
||||
|
||||
|
||||
对于图中的第二个环节,Spark在实际运行的过程中,会把它再细化为两个步骤。第一个步骤,就是以Shuffle为边界,从后向前以递归的方式,把逻辑上的计算图DAG,转化成一个又一个Stages。
|
||||
|
||||
|
||||
|
||||
我们还是以Word Count为例,Spark以take算子为起点,依次把DAG中的RDD划入到第一个Stage,直到遇到reduceByKey算子。由于reduceByKey算子会引入Shuffle,因此第一个Stage创建完毕,且只包含wordCounts这一个RDD。接下来,Spark继续向前回溯,由于未曾碰到会引入Shuffle的算子,因此它把“沿途”所有的RDD都划入了第二个Stage。
|
||||
|
||||
在Stages创建完毕之后,就到了触发计算的第二个步骤:Spark从后向前,以递归的方式,依次提请执行所有的Stages。
|
||||
|
||||
|
||||
|
||||
具体来说,在Word Count的例子中,DAGScheduler最先提请执行的是Stage1。在提交的时候,DAGScheduler发现Stage1依赖的父Stage,也就是Stage0,还没有执行过,那么这个时候它会把Stage1的提交动作压栈,转而去提请执行Stage0。当Stage0执行完毕的时候,DAGScheduler通过出栈的动作,再次提请执行Stage 1。
|
||||
|
||||
对于提请执行的每一个Stage,DAGScheduler根据Stage内RDD的partitions属性创建分布式任务集合TaskSet。TaskSet包含一个又一个分布式任务Task,RDD有多少数据分区,TaskSet就包含多少个Task。换句话说,Task与RDD的分区,是一一对应的。
|
||||
|
||||
你可能会问:“Task代表的是分布式任务,不过它具体是什么呢?”要更好地认识Task,我们不妨来看看它的关键属性。
|
||||
|
||||
|
||||
|
||||
在上表中,stageId、stageAttemptId标记了Task与执行阶段Stage的所属关系;taskBinary则封装了隶属于这个执行阶段的用户代码;partition就是我们刚刚说的RDD数据分区;locs属性以字符串的形式记录了该任务倾向的计算节点或是Executor ID。
|
||||
|
||||
不难发现,taskBinary、partition和locs这三个属性,一起描述了这样一件事情:Task应该在哪里(locs)为谁(partition)执行什么任务(taskBinary)。
|
||||
|
||||
到这里,我们讲完了戴格的职责,让我们来一起简单汇总一下,戴格指代的是DAGScheduler,DAGScheduler的主要职责有三个:
|
||||
|
||||
|
||||
根据用户代码构建DAG;
|
||||
以Shuffle为边界切割Stages;
|
||||
基于Stages创建TaskSets,并将TaskSets提交给TaskScheduler请求调度。
|
||||
|
||||
|
||||
现在,戴格不辱使命,完成了“建筑图纸”到“建筑项目”的转化,接下来,他需要把这些“活儿”下派给塔斯克,由塔斯克进一步完成任务的委派。
|
||||
|
||||
|
||||
|
||||
不过,对于塔斯克来说,要想把这些“活儿”委派出去,他得先摸清楚集团内有多少“适龄劳动力”才行。要做到这一点,他必须仰仗死党:拜肯德的帮忙。
|
||||
|
||||
拜肯德:SchedulerBackend
|
||||
|
||||
作为集团公司的人力资源总监,拜肯德的核心职责,就是实时汇总并掌握全公司的人力资源状况。前面我们讲了,全公司的人力资源对应的就是Spark的计算资源。对于集群中可用的计算资源,SchedulerBackend用一个叫做ExecutorDataMap的数据结构,来记录每一个计算节点中Executors的资源状态。
|
||||
|
||||
这里的ExecutorDataMap是一种HashMap,它的Key是标记 Executor 的字符串,Value是一种叫做ExecutorData的数据结构。ExecutorData用于封装Executor的资源状态,如RPC地址、主机地址、可用CPU核数和满配CPU核数等等,它相当于是对Executor做的“资源画像”。
|
||||
|
||||
|
||||
|
||||
有了ExecutorDataMap这本“人力资源小册子”,对内,SchedulerBackend可以就Executor做“资源画像”;对外,SchedulerBackend以WorkerOffer为粒度提供计算资源。其中,WorkerOffer封装了Executor ID、主机地址和CPU核数,它用来表示一份可用于调度任务的空闲资源。
|
||||
|
||||
显然,基于Executor资源画像,SchedulerBackend可以同时提供多个WorkerOffer用于分布式任务调度。WorkerOffer这个名字起得很传神,Offer的字面意思是公司给你提供的工作机会,到了Spark调度系统的上下文,它就变成了使用硬件资源的机会。
|
||||
|
||||
|
||||
|
||||
你可能会好奇,坐镇总公司的拜肯德,对于整个集团的人力资源,他是怎么做到足不出户就如数家珍的?一个篱笆三个桩,一个好汉三个帮。仅凭拜肯德一己之力,自然是力不从心,幕后功臣实际上是驻扎在分公司的一众小弟们:ExecutorBackend。
|
||||
|
||||
SchedulerBackend与集群内所有Executors中的ExecutorBackend保持周期性通信,双方通过LaunchedExecutor、RemoveExecutor、StatusUpdate等消息来互通有无、变更可用计算资源。拜肯德正是通过这些小弟发送的“信件”,来不停地更新自己手中的那本小册子,从而对集团人力资源了如指掌。
|
||||
|
||||
|
||||
|
||||
塔斯克:TaskScheduler
|
||||
|
||||
一把手戴格有“活儿”,三把手拜肯德出“人力”,接下来,终于轮到牵线搭桥的塔斯克出马了。作为施工经理,塔斯克的核心职责是,给定拜肯德提供的“人力”,遴选出最合适的“活儿”并派发出去。而这个遴选的过程,就是任务调度的核心所在,如下图步骤3所示:
|
||||
|
||||
|
||||
|
||||
那么问题来了,对于SchedulerBackend提供的一个个WorkerOffer,TaskScheduler是依据什么规则来挑选Tasks的呢?
|
||||
|
||||
用一句话来回答,对于给定的WorkerOffer,TaskScheduler是按照任务的本地倾向性,来遴选出TaskSet中适合调度的Tasks。这是什么意思呢?听上去比较抽象,我们还是从DAGScheduler在Stage内创建任务集TaskSet说起。
|
||||
|
||||
我们刚刚说过,Task与RDD的partitions是一一对应的,在创建Task的过程中,DAGScheduler会根据数据分区的物理地址,来为Task设置locs属性。locs属性记录了数据分区所在的计算节点、甚至是Executor进程ID。
|
||||
|
||||
举例来说,当我们调用textFile API从HDFS文件系统中读取源文件时,Spark会根据HDFS NameNode当中记录的元数据,获取数据分区的存储地址,例如node0:/rootPath/partition0-replica0,node1:/rootPath/partition0-replica1和node2:/rootPath/partition0-replica2。
|
||||
|
||||
那么,DAGScheduler在为该数据分区创建Task0的时候,会把这些地址中的计算节点记录到Task0的locs属性。
|
||||
|
||||
如此一来,当TaskScheduler需要调度Task0这个分布式任务的时候,根据Task0的locs属性,它就知道:“Task0所需处理的数据分区,在节点node0、node1、node2上存有副本,因此,如果WorkOffer是来自这3个节点的计算资源,那对Task0来说就是投其所好”。
|
||||
|
||||
从这个例子我们就能更好地理解,每个任务都是自带本地倾向性的,换句话说,每个任务都有自己的“调度意愿”。
|
||||
|
||||
回到斯巴克建筑集团的类比,就好比是某个“活儿”,并不是所有人都能干,而是只倾向于让某些人来做,因为他们更专业。比如砌墙这件事,更倾向于给工龄3年以上的瓦工来做;而吊顶,则更倾向于给经验超过5年的木工来做,诸如此类。
|
||||
|
||||
像上面这种定向到计算节点粒度的本地性倾向,Spark中的术语叫做NODE_LOCAL。除了定向到节点,Task还可以定向到进程(Executor)、机架、任意地址,它们对应的术语分别是PROCESS_LOCAL、RACK_LOCAL和ANY。
|
||||
|
||||
对于倾向PROCESS_LOCAL的Task来说,它要求对应的数据分区在某个进程(Executor)中存有副本;而对于倾向RACK_LOCAL的Task来说,它仅要求相应的数据分区存在于同一机架即可。ANY则等同于无定向,也就是Task对于分发的目的地没有倾向性,被调度到哪里都可以。
|
||||
|
||||
下图展示的是,TaskScheduler依据本地性倾向,依次进行任务调度的运行逻辑:
|
||||
|
||||
|
||||
|
||||
不难发现,从PROCESS_LOCAL、NODE_LOCAL、到RACK_LOCAL、再到ANY,Task的本地性倾向逐渐从严苛变得宽松。TaskScheduler接收到WorkerOffer之后,也正是按照这个顺序来遍历TaskSet中的Tasks,优先调度本地性倾向为PROCESS_LOCAL的Task,而NODE_LOCAL次之,RACK_LOCAL为再次,最后是ANY。
|
||||
|
||||
你可能会问:“Spark区分对待不同的本地倾向性,它的初衷和意图是什么呢?”实际上,不同的本地性倾向,本质上是用来区分计算(代码)与数据之间的关系。
|
||||
|
||||
Spark调度系统的核心思想,是“数据不动、代码动”。也就是说,在任务调度的过程中,为了完成分布式计算,Spark倾向于让数据待在原地、保持不动,而把计算任务(代码)调度、分发到数据所在的地方,从而消除数据分发引入的性能隐患。毕竟,相比分发数据,分发代码要轻量得多。
|
||||
|
||||
本地性倾向则意味着代码和数据应该在哪里“相会”,PROCESS_LOCAL是在JVM进程中,NODE_LOCAL是在节点内,RACK_LOCAL是不超出物理机架的范围,而ANY则代表“无所谓、不重要”。
|
||||
|
||||
|
||||
|
||||
好啦,到此为止,结合WorkerOffer与任务的本地性倾向,塔斯克TaskScheduler挑选出了适合调度的“活儿”:Tasks。接下来,TaskScheduler就把这些Tasks通过LaunchTask消息,发送给好基友SchedulerBackend。人力资源总监SchedulerBackend拿到这些活儿之后,同样使用LaunchTask消息,把活儿进一步下发给分公司的小弟:ExecutorBackend。
|
||||
|
||||
那么小弟ExecutorBackend拿到活之后,是怎么工作的呢?我们接着往下看吧!
|
||||
|
||||
付诸执行:ExecutorBackend
|
||||
|
||||
作为分公司的人力资源主管,ExecutorBackend拿到“活儿”之后,随即把活儿派发给分公司的建筑工人。这些工人,就是Executors线程池中一个又一个的CPU线程,每个线程负责处理一个Task。
|
||||
|
||||
每当Task处理完毕,这些线程便会通过ExecutorBackend,向Driver端的SchedulerBackend发送StatusUpdate事件,告知Task执行状态。接下来,TaskScheduler与SchedulerBackend通过接力的方式,最终把状态汇报给DAGScheduler,如图中步骤7、8、9所示:
|
||||
|
||||
|
||||
|
||||
对于同一个TaskSet当中的Tasks来说,当它们分别完成了任务调度与任务执行这两个环节时,也就是上图中步骤1到步骤9的计算过程,Spark调度系统就完成了DAG中某一个Stage的任务调度。
|
||||
|
||||
不过,故事到这里并未结束。我们知道,一个DAG会包含多个Stages,一个Stage的结束即宣告下一个Stage的开始,而这也是戴格起初将DAG划分为Stages的意义所在。只有当所有的Stages全部调度、执行完毕,才表示一个完整的Spark作业宣告结束。
|
||||
|
||||
路遥知马力,在一起合作了一个又一个建筑项目之后,空降老大戴格终于赢得了元老塔斯克和拜肯德的信任与认可,坐稳了斯巴克建筑集团的头把交椅。来日可期,戴格的前景一片光明。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,我们用斯巴克建筑集团的故事,介绍了Spark调度系统的工作原理。对于调度系统的工作流程,你需要掌握表格中的5个关键环节:
|
||||
|
||||
|
||||
|
||||
具体说来,任务调度分为如下5个步骤:
|
||||
|
||||
|
||||
DAGScheduler以Shuffle为边界,将开发者设计的计算图DAG拆分为多个执行阶段Stages,然后为每个Stage创建任务集TaskSet。-
|
||||
SchedulerBackend通过与Executors中的ExecutorBackend的交互来实时地获取集群中可用的计算资源,并将这些信息记录到ExecutorDataMap数据结构。-
|
||||
与此同时,SchedulerBackend根据ExecutorDataMap中可用资源创建WorkerOffer,以WorkerOffer为粒度提供计算资源。-
|
||||
对于给定WorkerOffer,TaskScheduler结合TaskSet中任务的本地性倾向,按照PROCESS_LOCAL、NODE_LOCAL、RACK_LOCAL和ANY的顺序,依次对TaskSet中的任务进行遍历,优先调度本地性倾向要求苛刻的Task。-
|
||||
被选中的Task由TaskScheduler传递给SchedulerBackend,再由SchedulerBackend分发到Executors中的ExecutorBackend。Executors接收到Task之后,即调用本地线程池来执行分布式任务。
|
||||
|
||||
|
||||
今天的内容就是这些,调度系统是分布式计算系统的核心,掌握了Spark任务调度的来龙去脉,你也就把握住了Spark分布式计算引擎的精髓,这会为你开发出高性能的Spark分布式应用打下坚实基础。
|
||||
|
||||
每课一练
|
||||
|
||||
课程的最后,我来给你留一道练习题。请你想一想,DAGScheduler如何得知一个Stage中所有的Tasks都已调度、执行完毕,然后才决定开始调度DAG中的下一个Stage?
|
||||
|
||||
欢迎你在评论区回答这个问题。如果你觉得这一讲对你有所帮助,也欢迎你把它分享给更多的朋友和同事。我在评论区等你,咱们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
156
专栏/零基础入门Spark/06Shuffle管理:为什么Shuffle是性能瓶颈?.md
Normal file
156
专栏/零基础入门Spark/06Shuffle管理:为什么Shuffle是性能瓶颈?.md
Normal file
@ -0,0 +1,156 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 Shuffle管理:为什么Shuffle是性能瓶颈?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们拜访了斯巴克国际建筑集团总公司,结识了Spark调度系统的三巨头:DAGScheduler、TaskScheduler和SchedulerBackend。相信你已经感受到,调度系统组件众多且运作流程精密而又复杂。
|
||||
|
||||
任务调度的首要环节,是DAGScheduler以Shuffle为边界,把计算图DAG切割为多个执行阶段Stages。显然,Shuffle是这个环节的关键。那么,我们不禁要问:“Shuffle是什么?为什么任务执行需要Shuffle操作?Shuffle是怎样一个过程?”
|
||||
|
||||
今天这一讲,我们转而去“拜访”斯巴克国际建筑集团的分公司,用“工地搬砖的任务”来理解Shuffle及其工作原理。由于Shuffle的计算几乎需要消耗所有类型的硬件资源,比如CPU、内存、磁盘与网络,在绝大多数的Spark作业中,Shuffle往往是作业执行性能的瓶颈,因此,我们必须要掌握Shuffle的工作原理,从而为Shuffle环节的优化打下坚实基础。
|
||||
|
||||
什么是Shuffle
|
||||
|
||||
我们先不急着给Shuffle下正式的定义,为了帮你迅速地理解Shuffle的含义,从而达到事半功倍的效果,我们不妨先去拜访斯巴克集团的分公司,去看看“工地搬砖”是怎么一回事。
|
||||
|
||||
斯巴克集团的各家分公司分别驻扎在不同的建筑工地,每家分公司的人员配置和基础设施都大同小异:在人员方面,各家分公司都有建筑工人若干、以及负责管理这些工人的工头。在基础设施方面,每家分公司都有临时搭建、方便存取建材的临时仓库,这些仓库配备各式各样的建筑原材料,比如混凝土砖头、普通砖头、草坪砖头等等。
|
||||
|
||||
咱们参观、考察斯巴克建筑集团的目的,毕竟还是学习Spark,因此我们得把分公司的人与物和Spark的相关概念对应上,这样才能方便你快速理解Spark的诸多组件与核心原理。
|
||||
|
||||
分公司的人与物和Spark的相关概念是这样对应的:
|
||||
|
||||
|
||||
|
||||
基于图中不同概念的对应关系,接下来,我们来看“工地搬砖”的任务。斯巴克建筑集团的3家分公司,分别接到3个不同的建筑任务。第一家分公司的建筑项目是摩天大厦,第二家分公司被要求在工地上建造一座“萌宠乐园”,而第三家分公司收到的任务是打造露天公园。为了叙述方便,我们把三家分公司分别称作分公司1、分公司2和分公司3。
|
||||
|
||||
显然,不同建筑项目对于建材的选型要求是有区别的,摩天大厦的建造需要刚性强度更高的混凝土砖头,同理,露天公园的建设需要透水性好的草坪砖头,而萌宠乐园使用普通砖头即可。
|
||||
|
||||
可是,不同类型的砖头,分别散落在3家公司的临时仓库中。为了实现资源的高效利用,每个分公司的施工工人们,都需要从另外两家把项目特需的砖头搬运过来。对于这个过程,我们把它叫作“搬砖任务”。
|
||||
|
||||
|
||||
|
||||
有了“工地搬砖”的直观对比,我们现在就可以直接给Shuffle下一个正式的定义了。
|
||||
|
||||
Shuffle的本意是扑克的“洗牌”,在分布式计算场景中,它被引申为集群范围内跨节点、跨进程的数据分发。在工地搬砖的任务中,如果我们把不同类型的砖头看作是分布式数据集,那么不同类型的砖头在各个分公司之间搬运的过程,与分布式计算中的Shuffle可以说是异曲同工。
|
||||
|
||||
要完成工地搬砖的任务,每位工人都需要长途跋涉到另外两家分公司,然后从人家的临时仓库把所需的砖头搬运回来。分公司之间相隔甚远,仅靠工人们一块砖一块砖地搬运,显然不现实。因此,为了提升搬砖效率,每位工人还需要借助货运卡车来帮忙。不难发现,工地搬砖的任务需要消耗大量的人力物力,可以说是劳师动众。
|
||||
|
||||
Shuffle的过程也是类似,分布式数据集在集群内的分发,会引入大量的磁盘I/O与网络I/O。在DAG的计算链条中,Shuffle环节的执行性能是最差的。你可能会问:“既然Shuffle的性能这么差,为什么在计算的过程中非要引入Shuffle操作呢?免去Shuffle环节不行吗?”
|
||||
|
||||
其实,计算过程之所以需要Shuffle,往往是由计算逻辑、或者说业务逻辑决定的。
|
||||
|
||||
比如,对于搬砖任务来说,不同的建筑项目就是需要不同的建材,只有这样才能满足不同的施工要求。再比如,在Word Count的例子中,我们的“业务逻辑”是对单词做统计计数,那么对单词“Spark”来说,在做“加和”之前,我们就是得把原本分散在不同Executors中的“Spark”,拉取到某一个Executor,才能完成统计计数的操作。
|
||||
|
||||
结合过往的工作经验,我们发现在绝大多数的业务场景中,Shuffle操作都是必需的、无法避免的。既然我们躲不掉Shuffle,那么接下来,我们就一起去探索,看看Shuffle到底是怎样的一个计算过程。
|
||||
|
||||
Shuffle工作原理
|
||||
|
||||
为了方便你理解,我们还是用Word Count的例子来做说明。在这个示例中,引入Shuffle操作的是reduceByKey算子,也就是下面这行代码(完整代码请回顾[第1讲])。
|
||||
|
||||
// 按照单词做分组计数
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
|
||||
|
||||
|
||||
我们先来直观地回顾一下这一步的计算过程,然后再去分析其中涉及的Shuffle操作:
|
||||
|
||||
|
||||
|
||||
如上图所示,以Shuffle为边界,reduceByKey的计算被切割为两个执行阶段。约定俗成地,我们把Shuffle之前的Stage叫作Map阶段,而把Shuffle之后的Stage称作Reduce阶段。在Map阶段,每个Executors先把自己负责的数据分区做初步聚合(又叫Map端聚合、局部聚合);在Shuffle环节,不同的单词被分发到不同节点的Executors中;最后的Reduce阶段,Executors以单词为Key做第二次聚合(又叫全局聚合),从而完成统计计数的任务。
|
||||
|
||||
不难发现,Map阶段与Reduce阶段的计算过程相对清晰明了,二者都是利用reduce运算完成局部聚合与全局聚合。在reduceByKey的计算过程中,Shuffle才是关键。
|
||||
|
||||
仔细观察上图你就会发现,与其说Shuffle是跨节点、跨进程的数据分发,不如说Shuffle是Map阶段与Reduce阶段之间的数据交换。那么问题来了,两个执行阶段之间,是如何实现数据交换的呢?
|
||||
|
||||
Shuffle中间文件
|
||||
|
||||
如果用一句来概括的话,那就是,Map阶段与Reduce阶段,通过生产与消费Shuffle中间文件的方式,来完成集群范围内的数据交换。换句话说,Map阶段生产Shuffle中间文件,Reduce阶段消费Shuffle中间文件,二者以中间文件为媒介,完成数据交换。
|
||||
|
||||
那么接下来的问题是,什么是Shuffle中间文件,它是怎么产生的,又是如何被消费的?
|
||||
|
||||
我把它的产生和消费过程总结在下图中了:
|
||||
|
||||
|
||||
|
||||
在上一讲介绍调度系统的时候,我们说过DAGScheduler会为每一个Stage创建任务集合TaskSet,而每一个TaskSet都包含多个分布式任务(Task)。在Map执行阶段,每个Task(以下简称Map Task)都会生成包含data文件与index文件的Shuffle中间文件,如上图所示。也就是说,Shuffle文件的生成,是以Map Task为粒度的,Map阶段有多少个Map Task,就会生成多少份Shuffle中间文件。
|
||||
|
||||
再者,Shuffle中间文件是统称、泛指,它包含两类实体文件,一个是记录(Key,Value)键值对的data文件,另一个是记录键值对所属Reduce Task的index文件。换句话说,index文件标记了data文件中的哪些记录,应该由下游Reduce阶段中的哪些Task(简称Reduce Task)消费。在上图中,为了方便示意,我们把首字母是S、i、c的单词分别交给下游的3个Reduce Task去消费,显然,这里的数据交换规则是单词首字母。
|
||||
|
||||
在Spark中,Shuffle环节实际的数据交换规则要比这复杂得多。数据交换规则又叫分区规则,因为它定义了分布式数据集在Reduce阶段如何划分数据分区。假设Reduce阶段有N个Task,这N个Task对应着N个数据分区,那么在Map阶段,每条记录应该分发到哪个Reduce Task,是由下面的公式来决定的。
|
||||
|
||||
P = Hash(Record Key) % N
|
||||
|
||||
|
||||
对于任意一条数据记录,Spark先按照既定的哈希算法,计算记录主键的哈希值,然后把哈希值对N取模,计算得到的结果数字,就是这条记录在Reduce阶段的数据分区编号P。换句话说,这条记录在Shuffle的过程中,应该被分发到Reduce阶段的P号分区。
|
||||
|
||||
熟悉了分区规则与中间文件之后,接下来,我们再来说一说中间文件是怎么产生的。
|
||||
|
||||
Shuffle Write
|
||||
|
||||
我们刚刚说过,Shuffle中间文件,是以Map Task为粒度生成的,我们不妨使用下图中的Map Task以及与之对应的数据分区为例,来讲解中间文件的生成过程。数据分区的数据内容如图中绿色方框所示:
|
||||
|
||||
|
||||
|
||||
在生成中间文件的过程中,Spark会借助一种类似于Map的数据结构,来计算、缓存并排序数据分区中的数据记录。这种Map结构的Key是(Reduce Task Partition ID,Record Key),而Value是原数据记录中的数据值,如图中的“内存数据结构”所示。
|
||||
|
||||
对于数据分区中的数据记录,Spark会根据我们前面提到的公式1逐条计算记录所属的目标分区ID,然后把主键(Reduce Task Partition ID,Record Key)和记录的数据值插入到Map数据结构中。当Map结构被灌满之后,Spark根据主键对Map中的数据记录做排序,然后把所有内容溢出到磁盘中的临时文件,如图中的步骤1所示。
|
||||
|
||||
随着Map结构被清空,Spark可以继续读取分区内容并继续向Map结构中插入数据,直到Map结构再次被灌满而再次溢出,如图中的步骤2所示。就这样,如此往复,直到数据分区中所有的数据记录都被处理完毕。
|
||||
|
||||
到此为止,磁盘上存有若干个溢出的临时文件,而内存的Map结构中留有部分数据,Spark使用归并排序算法对所有临时文件和Map结构剩余数据做合并,分别生成data文件、和与之对应的index文件,如图中步骤4所示。Shuffle阶段生成中间文件的过程,又叫Shuffle Write。
|
||||
|
||||
总结下来,Shuffle中间文件的生成过程,分为如下几个步骤:
|
||||
|
||||
|
||||
对于数据分区中的数据记录,逐一计算其目标分区,然后填充内存数据结构;-
|
||||
当数据结构填满后,如果分区中还有未处理的数据记录,就对结构中的数据记录按(目标分区 ID,Key)排序,将所有数据溢出到临时文件,同时清空数据结构;-
|
||||
重复前 2 个步骤,直到分区中所有的数据记录都被处理为止;-
|
||||
对所有临时文件和内存数据结构中剩余的数据记录做归并排序,生成数据文件和索引文件。
|
||||
|
||||
|
||||
到目前为止,我们熟悉了Spark在Map阶段生产Shuffle中间文件的过程,那么,在Reduce阶段,不同的Tasks又是如何基于这些中间文件,来定位属于自己的那部分数据,从而完成数据拉取呢?
|
||||
|
||||
Shuffle Read
|
||||
|
||||
首先,我们需要注意的是,对于每一个Map Task生成的中间文件,其中的目标分区数量是由Reduce阶段的任务数量(又叫并行度)决定的。在下面的示意图中,Reduce阶段的并行度是3,因此,Map Task的中间文件会包含3个目标分区的数据,而index文件,恰恰是用来标记目标分区所属数据记录的起始索引。
|
||||
|
||||
|
||||
|
||||
对于所有Map Task生成的中间文件,Reduce Task需要通过网络从不同节点的硬盘中下载并拉取属于自己的数据内容。不同的Reduce Task正是根据index文件中的起始索引来确定哪些数据内容是“属于自己的”。Reduce阶段不同于Reduce Task拉取数据的过程,往往也被叫做Shuffle Read。
|
||||
|
||||
好啦,到此为止,我们依次解答了本讲最初提到的几个问题:“什么是Shuffle?为什么需要Shuffle,以及Shuffle是如何工作的”。Shuffle是衔接不同执行阶段的关键环节,Shuffle的执行性能往往是Spark作业端到端执行效率的关键,因此,掌握Shuffle,是我们入门Spark的必经之路。希望今天的讲解,能帮你更好地认识Shuffle。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天的内容比较多,我们一起来做个总结。
|
||||
|
||||
首先,我们给Shuffle下了一个明确的定义,在分布式计算场景中,Shuffle指的是集群范围内跨节点、跨进程的数据分发。
|
||||
|
||||
我们在最开始提到,Shuffle的计算会消耗所有类型的硬件资源。具体来说,Shuffle中的哈希与排序操作会大量消耗CPU,而Shuffle Write生成中间文件的过程,会消耗宝贵的内存资源与磁盘I/O,最后,Shuffle Read阶段的数据拉取会引入大量的网络I/O。不难发现,Shuffle是资源密集型计算,因此理解Shuffle对开发者来说至关重要。
|
||||
|
||||
紧接着,我们介绍了Shuffle中间文件。Shuffle中间文件是统称,它包含两类文件,一个是记录(Key,Value)键值对的data文件,另一个是记录键值对所属Reduce Task的index文件。计算图DAG中的Map阶段与Reduce阶段,正是通过中间文件来完成数据的交换。
|
||||
|
||||
接下来,我们详细讲解了Shuffle Write过程中生成中间文件的详细过程,归纳起来,这个过程分为4个步骤:
|
||||
|
||||
|
||||
对于数据分区中的数据记录,逐一计算其目标分区,然后填充内存数据结构;-
|
||||
当数据结构填满后,如果分区中还有未处理的数据记录,就对结构中的数据记录按(目标分区 ID,Key)排序,将所有数据溢出到临时文件,同时清空数据结构;-
|
||||
重复前 2 个步骤,直到分区中所有的数据记录都被处理为止;-
|
||||
对所有临时文件和内存数据结构中剩余的数据记录做归并排序,生成数据文件和索引文件。
|
||||
|
||||
|
||||
最后,在Reduce阶段,Reduce Task通过index文件来“定位”属于自己的数据内容,并通过网络从不同节点的data文件中下载属于自己的数据记录。
|
||||
|
||||
每课一练
|
||||
|
||||
这一讲就到这里了,我在这给你留个思考题:
|
||||
|
||||
在Shuffle的计算过程中,中间文件存储在参数spark.local.dir设置的文件目录中,这个参数的默认值是/tmp,你觉得这个参数该如何设置才更合理呢?
|
||||
|
||||
欢迎你在评论区分享你的答案,我在评论区等你。如果这一讲对你有所帮助,你也可以分享给自己的朋友,我们下一讲见。
|
||||
|
||||
|
||||
|
||||
|
212
专栏/零基础入门Spark/07RDD常用算子(二):Spark如何实现数据聚合?.md
Normal file
212
专栏/零基础入门Spark/07RDD常用算子(二):Spark如何实现数据聚合?.md
Normal file
@ -0,0 +1,212 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 RDD常用算子(二):Spark如何实现数据聚合?
|
||||
你好,我是吴磊。
|
||||
|
||||
积累了一定的理论基础之后,今天我们继续来学习RDD常用算子。在[RDD常用算子(一)]那一讲,我们讲了四个算子map、mapPartitions、flatMap和filter,同时留了这样一道思考题:“这些算子之间,有哪些共同点?”
|
||||
|
||||
今天我们就来揭晓答案。首先,在功能方面,这4个算子都用于RDD内部的数据转换,而学习过Shuffle的工作原理之后,我们不难发现,这4个算子当中,没有任何一个算子,会引入Shuffle计算。
|
||||
|
||||
而今天我们要学习的几个算子则恰恰相反,它们都会引入繁重的Shuffle计算。这些算子分别是groupByKey、reduceByKey、aggregateByKey和sortByKey,也就是表格中加粗的部分。
|
||||
|
||||
我们知道,在数据分析场景中,典型的计算类型分别是分组、聚合和排序。而groupByKey、reduceByKey、aggregateByKey和sortByKey这些算子的功能,恰恰就是用来实现分组、聚合和排序的计算逻辑。
|
||||
|
||||
|
||||
|
||||
尽管这些算子看上去相比其他算子的适用范围更窄,也就是它们只能作用(Apply)在Paired RDD之上,所谓Paired RDD,它指的是元素类型为(Key,Value)键值对的RDD。
|
||||
|
||||
但是在功能方面,可以说,它们承担了数据分析场景中的大部分职责。因此,掌握这些算子的用法,是我们能够游刃有余地开发数据分析应用的重要基础。那么接下来,我们就通过一些实例,来熟悉并学习这些算子的用法。
|
||||
|
||||
我们先来说说groupByKey,坦白地说,相比后面的3个算子,groupByKey在我们日常开发中的“出镜率”并不高。之所以要先介绍它,主要是为后续的reduceByKey和aggregateByKey这两个重要算子做铺垫。
|
||||
|
||||
groupByKey:分组收集
|
||||
|
||||
groupByKey的字面意思是“按照Key做分组”,但实际上,groupByKey算子包含两步,即分组和收集。
|
||||
|
||||
具体来说,对于元素类型为(Key,Value)键值对的Paired RDD,groupByKey的功能就是对Key值相同的元素做分组,然后把相应的Value值,以集合的形式收集到一起。换句话说,groupByKey会把RDD的类型,由RDD[(Key, Value)]转换为RDD[(Key, Value集合)]。
|
||||
|
||||
这么说比较抽象,我们还是用一个小例子来说明groupByKey的用法。还是我们熟知的Word Count,对于分词后的一个个单词,假设我们不再统计其计数,而仅仅是把相同的单词收集到一起,那么我们该怎么做呢?按照老规矩,咱们还是先来给出代码实现:
|
||||
|
||||
import org.apache.spark.rdd.RDD
|
||||
|
||||
// 以行为单位做分词
|
||||
val cleanWordRDD: RDD[String] = _ // 完整代码请参考第一讲的Word Count
|
||||
// 把普通RDD映射为Paired RDD
|
||||
val kvRDD: RDD[(String, String)] = cleanWordRDD.map(word => (word, word))
|
||||
|
||||
// 按照单词做分组收集
|
||||
val words: RDD[(String, Iterable[String])] = kvRDD.groupByKey()
|
||||
|
||||
|
||||
结合前面的代码可以看到,相比之前的Word Count,我们仅需做两个微小的改动,即可实现新的计算逻辑。第一个改动,是把map算子的映射函数f,由原来的word => (word,1)变更为word => (word,word),这么做的效果,是把kvRDD元素的Key和Value都变成了单词。
|
||||
|
||||
紧接着,第二个改动,我们用groupByKey替换了原先的reduceByKey。相比reduceByKey,groupByKey的用法要简明得多。groupByKey是无参函数,要实现对Paired RDD的分组、收集,我们仅需在RDD之上调用groupByKey()即可。
|
||||
|
||||
尽管groupByKey的用法非常简单,但它的计算过程值得我们特别关注,下面我用一张示意图来讲解上述代码的计算过程,从而让你更加直观地感受groupByKey可能存在的性能隐患。
|
||||
|
||||
|
||||
|
||||
从图上可以看出,为了完成分组收集,对于Key值相同、但分散在不同数据分区的原始数据记录,Spark需要通过Shuffle操作,跨节点、跨进程地把它们分发到相同的数据分区。我们之前在[第6讲]中说了,Shuffle是资源密集型计算,对于动辄上百万、甚至上亿条数据记录的RDD来说,这样的Shuffle计算会产生大量的磁盘I/O与网络I/O开销,从而严重影响作业的执行性能。
|
||||
|
||||
虽然groupByKey的执行效率较差,不过好在它在应用开发中的“出镜率”并不高。原因很简单,在数据分析领域中,分组收集的使用场景很少,而分组聚合才是统计分析的刚需。
|
||||
|
||||
为了满足分组聚合多样化的计算需要,Spark提供了3种RDD算子,允许开发者灵活地实现计算逻辑,它们分别是reduceByKey、aggregateByKey和combineByKey。
|
||||
|
||||
reduceByKey我们并不陌生,第1讲的Word Count实现就用到了这个算子,aggregateByKey是reduceByKey的“升级版”,相比reduceByKey,aggregateByKey用法更加灵活,支持的功能也更加完备。
|
||||
|
||||
接下来,我们先来回顾reduceByKey,然后再对aggregateByKey进行展开。相比aggregateByKey,combineByKey仅在初始化方式上有所不同,因此,我把它留给你作为课后作业去探索。
|
||||
|
||||
reduceByKey:分组聚合
|
||||
|
||||
reduceByKey的字面含义是“按照Key值做聚合”,它的计算逻辑,就是根据聚合函数f给出的算法,把Key值相同的多个元素,聚合成一个元素。
|
||||
|
||||
在[第1讲]Word Count的实现中,我们使用了reduceByKey来实现分组计数:
|
||||
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
|
||||
|
||||
// 按照单词做分组计数
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x: Int, y: Int) => x + y)
|
||||
|
||||
|
||||
重温上面的这段代码,你有没有觉得reduceByKey与之前讲过的map、filter这些算子有一些相似的地方?没错,给定处理函数f,它们的用法都是“算子(f)”。只不过对于map来说,我们把f称作是映射函数,对filter来说,我们把f称作判定函数,而对于reduceByKey,我们把f叫作聚合函数。
|
||||
|
||||
在上面的代码示例中,reduceByKey的聚合函数是匿名函数:(x, y) => x + y。与map、filter等算子的用法一样,你也可以明确地定义带名函数f,然后再用reduceByKey(f)的方式实现同样的计算逻辑。
|
||||
|
||||
需要强调的是,给定RDD[(Key类型,Value类型)],聚合函数f的类型,必须是(Value类型,Value类型) => (Value类型)。换句话说,函数f的形参,必须是两个数值,且数值的类型必须与Value的类型相同,而f的返回值,也必须是Value类型的数值。
|
||||
|
||||
咱们不妨再举一个小例子,让你加深对于reduceByKey算子的理解。
|
||||
|
||||
接下来,我们把Word Count的计算逻辑,改为随机赋值、提取同一个Key的最大值。也就是在kvRDD的生成过程中,我们不再使用映射函数word => (word, 1),而是改为word => (word, 随机数),然后再使用reduceByKey算子来计算同一个word当中最大的那个随机数。
|
||||
|
||||
你可以先停下来,花点时间想一想这个逻辑该怎么实现,然后再来参考下面的代码:
|
||||
|
||||
import scala.util.Random._
|
||||
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, nextInt(100)))
|
||||
|
||||
// 显示定义提取最大值的聚合函数f
|
||||
def f(x: Int, y: Int): Int = {
|
||||
return math.max(x, y)
|
||||
}
|
||||
|
||||
// 按照单词提取最大值
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey(f)
|
||||
|
||||
|
||||
观察上面的代码片段,不难发现,reduceByKey算子的用法还是比较简单的,只需要先定义好聚合函数f,然后把它传给reduceByKey算子就行了。那么在运行时,上述代码的计算又是怎样的一个过程呢?
|
||||
|
||||
我把reduceByKey的计算过程抽象成了下图:
|
||||
|
||||
|
||||
|
||||
从图中你可以看出来,尽管reduceByKey也会引入Shuffle,但相比groupByKey以全量原始数据记录的方式消耗磁盘与网络,reduceByKey在落盘与分发之前,会先在Shuffle的Map阶段做初步的聚合计算。
|
||||
|
||||
比如,在数据分区0的处理中,在Map阶段,reduceByKey把Key同为Streaming的两条数据记录聚合为一条,聚合逻辑就是由函数f定义的、取两者之间Value较大的数据记录,这个过程我们称之为“Map端聚合”。相应地,数据经由网络分发之后,在Reduce阶段完成的计算,我们称之为“Reduce端聚合”。
|
||||
|
||||
你可能会说:“做了Map聚合又能怎样呢?相比groupByKey,reduceByKey带来的性能收益并不算明显呀!”确实,就上面的示意图来说,我们很难感受到reduceByKey带来的性能收益。不过,量变引起质变,在工业级的海量数据下,相比groupByKey,reduceByKey通过在Map端大幅削减需要落盘与分发的数据量,往往能将执行效率提升至少一倍。
|
||||
|
||||
应该说,对于大多数分组&聚合的计算需求来说,只要设计合适的聚合函数f,你都可以使用reduceByKey来实现计算逻辑。不过,术业有专攻,reduceByKey算子的局限性,在于其Map阶段与Reduce阶段的计算逻辑必须保持一致,这个计算逻辑统一由聚合函数f定义。当一种计算场景需要在两个阶段执行不同计算逻辑的时候,reduceByKey就爱莫能助了。
|
||||
|
||||
比方说,还是第1讲的Word Count,我们想对单词计数的计算逻辑做如下调整:
|
||||
|
||||
|
||||
在Map阶段,以数据分区为单位,计算单词的加和;
|
||||
而在Reduce阶段,对于同样的单词,取加和最大的那个数值。
|
||||
|
||||
|
||||
显然,Map阶段的计算逻辑是sum,而Reduce阶段的计算逻辑是max。对于这样的业务需求,reduceByKey已无用武之地,这个时候,就轮到aggregateByKey这个算子闪亮登场了。
|
||||
|
||||
aggregateByKey:更加灵活的聚合算子
|
||||
|
||||
老规矩,算子的介绍还是从用法开始。相比其他算子,aggregateByKey算子的参数比较多。要在Paired RDD之上调用aggregateByKey,你需要提供一个初始值,一个Map端聚合函数f1,以及一个Reduce端聚合函数f2,aggregateByKey的调用形式如下所示:
|
||||
|
||||
val rdd: RDD[(Key类型,Value类型)] = _
|
||||
rdd.aggregateByKey(初始值)(f1, f2)
|
||||
|
||||
|
||||
初始值可以是任意数值或是字符串,而聚合函数我们也不陌生,它们都是带有两个形参和一个输出结果的普通函数。就这3个参数来说,比较伤脑筋的,是它们之间的类型需要保持一致,具体来说:
|
||||
|
||||
|
||||
初始值类型,必须与f2的结果类型保持一致;
|
||||
f1的形参类型,必须与Paired RDD的Value类型保持一致;
|
||||
f2的形参类型,必须与f1的结果类型保持一致。
|
||||
|
||||
|
||||
不同类型之间的一致性描述起来比较拗口,咱们不妨结合示意图来加深理解:
|
||||
|
||||
|
||||
|
||||
熟悉了aggregateByKey的用法之后,接下来,我们用aggregateByKey这个算子来实现刚刚提到的“先加和,再取最大值”的计算逻辑,代码实现如下所示:
|
||||
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
|
||||
|
||||
// 显示定义Map阶段聚合函数f1
|
||||
def f1(x: Int, y: Int): Int = {
|
||||
return x + y
|
||||
}
|
||||
|
||||
// 显示定义Reduce阶段聚合函数f2
|
||||
def f2(x: Int, y: Int): Int = {
|
||||
return math.max(x, y)
|
||||
}
|
||||
|
||||
// 调用aggregateByKey,实现先加和、再求最大值
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.aggregateByKey(0) (f1, f2)
|
||||
|
||||
|
||||
怎么样?是不是很简单?结合计算逻辑的需要,我们只需要提前定义好两个聚合函数,同时保证参数之间的类型一致性,然后把初始值、聚合函数传入aggregateByKey算子即可。按照惯例,我们还是通过aggregateByKey在运行时的计算过程,来帮你深入理解算子的工作原理:
|
||||
|
||||
|
||||
|
||||
不难发现,在运行时,与reduceByKey相比,aggregateByKey的执行过程并没有什么两样,最主要的区别,还是Map端聚合与Reduce端聚合的计算逻辑是否一致。值得一提的是,与reduceByKey一样,aggregateByKey也可以通过Map端的初步聚合来大幅削减数据量,在降低磁盘与网络开销的同时,提升Shuffle环节的执行性能。
|
||||
|
||||
sortByKey:排序
|
||||
|
||||
在这一讲的最后,我们再来说说sortByKey这个算子,顾名思义,它的功能是“按照Key进行排序”。给定包含(Key,Value)键值对的Paired RDD,sortByKey会以Key为准对RDD做排序。算子的用法比较简单,只需在RDD之上调用sortByKey()即可:
|
||||
|
||||
val rdd: RDD[(Key类型,Value类型)] = _
|
||||
rdd.sortByKey()
|
||||
|
||||
|
||||
在默认的情况下,sortByKey按照Key值的升序(Ascending)对RDD进行排序,如果想按照降序(Descending)来排序的话,你需要给sortByKey传入false。总结下来,关于排序的规则,你只需要记住如下两条即可:
|
||||
|
||||
|
||||
升序排序:调用sortByKey()、或者sortByKey(true);
|
||||
降序排序:调用sortByKey(false)。
|
||||
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,我们介绍了数据分析场景中常用的4个算子,它们分别是groupByKey、reduceByKey、aggregateByKey和sortByKey,掌握这些算子的用法与原理,将为你游刃有余地开发数据分析应用打下坚实基础。
|
||||
|
||||
关于这些算子,你首先需要了解它们之间的共性。一来,这4个算子的作用范围,都是Paired RDD;二来,在计算的过程中,它们都会引入Shuffle。而Shuffle往往是Spark作业执行效率的瓶颈,因此,在使用这4个算子的时候,对于它们可能会带来的性能隐患,我们要做到心中有数。
|
||||
|
||||
再者,你需要掌握每一个算子的具体用法与工作原理。groupByKey是无参算子,你只需在RDD之上调用groupByKey()即可完成对数据集的分组和收集。但需要特别注意的是,以全量原始数据记录在集群范围内进行落盘与网络分发,会带来巨大的性能开销。因此,除非必需,你应当尽量避免使用groupByKey算子。
|
||||
|
||||
利用聚合函数f,reduceByKey可以在Map端进行初步聚合,大幅削减需要落盘与分发的数据量,从而在一定程度上能够显著提升Shuffle计算的执行效率。对于绝大多数分组&聚合的计算需求,只要聚合函数f设计得当,reduceByKey都能实现业务逻辑。reduceByKey也有其自身的局限性,那就是其Map阶段与Reduce阶段的计算逻辑必须保持一致。
|
||||
|
||||
对于Map端聚合与Reduce端聚合计算逻辑不一致的情况,aggregateByKey可以很好地满足这样的计算场景。aggregateByKey的用法是aggregateByKey(初始值)(Map端聚合函数,Reduce端聚合函数),对于aggregateByKey的3个参数,你需要保证它们之间类型的一致性。一旦类型一致性得到满足,你可以通过灵活地定义两个聚合函数,来翻着花样地进行各式各样的数据分析。
|
||||
|
||||
最后,对于排序类的计算需求,你可以通过调用sortByKey来进行实现。sortByKey支持两种排序方式,在默认情况下,sortByKey()按Key值的升序进行排序,sortByKey()与sortByKey(true)的效果是一样的。如果想按照降序做排序,你只需要调用sortByKey(false)即可。
|
||||
|
||||
到此为止,我们一起学习了RDD常用算子的前两大类,也就是数据转换和数据聚合。在日常的开发工作中,应该说绝大多数的业务需求,都可以通过这些算子来实现。
|
||||
|
||||
因此恭喜你,毫不夸张地说,学习到这里,你的一只脚已经跨入了Spark分布式应用开发的大门。不过,我们还不能骄傲,“学会”和“学好”之间还有一定的距离,在接下来的时间里,期待你和我一起继续加油,真正做到吃透Spark、玩转Spark!
|
||||
|
||||
每课一练
|
||||
|
||||
这一讲到这里就要结束了,今天的练习题是这样的:
|
||||
|
||||
学习过reduceByKey和aggregateByKey之后,你能说说它们二者之间的联系吗?你能用aggregateByKey来实现reduceByKey的功能吗?
|
||||
|
||||
欢迎你分享你的答案。如果这一讲对你有帮助,也欢迎你把这一讲分享给自己的朋友,和他一起来讨论一下本讲的练习题,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
210
专栏/零基础入门Spark/08内存管理:Spark如何使用内存?.md
Normal file
210
专栏/零基础入门Spark/08内存管理:Spark如何使用内存?.md
Normal file
@ -0,0 +1,210 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 内存管理:Spark如何使用内存?
|
||||
你好,我是吴磊。
|
||||
|
||||
在[第6讲],我们拜访了斯巴克建筑集团的分公司,熟悉了分公司的办公环境与人员配置,同时用“工地搬砖的任务”作类比,介绍了Spark Shuffle的工作原理。
|
||||
|
||||
今天这一讲,我们再次来到分公司,去看看斯巴克公司都在承接哪些建筑项目,以及这些项目是如何施工的。通过熟悉项目的施工过程,我们一起来学习Spark的内存管理。
|
||||
|
||||
|
||||
|
||||
相比其他大数据计算引擎,关于Spark的特性与优势,想必你听到最多的字眼,就是“内存计算”。合理而又充分地利用内存资源,是Spark的核心竞争力之一。因此,作为开发者,我们弄清楚Spark是如何使用内存的,就变得非常重要。
|
||||
|
||||
好啦,闲言少叙,请你戴好安全帽,跟我一起再次去拜访斯巴克集团分公司吧。不过,在正式“拜访”之前,我们还有一项准备工作要做,那就是先了解清楚Spark的内存区域是怎样划分的。
|
||||
|
||||
Spark内存区域划分
|
||||
|
||||
对于任意一个Executor来说,Spark会把内存分为4个区域,分别是Reserved Memory、User Memory、Execution Memory和Storage Memory。
|
||||
|
||||
|
||||
|
||||
其中,Reserved Memory固定为300MB,不受开发者控制,它是Spark预留的、用来存储各种 Spark 内部对象的内存区域;User Memory用于存储开发者自定义的数据结构,例如RDD算子中引用的数组、列表、映射等等。
|
||||
|
||||
Execution Memory用来执行分布式任务。分布式任务的计算,主要包括数据的转换、过滤、映射、排序、聚合、归并等环节,而这些计算环节的内存消耗,统统来自于Execution Memory。
|
||||
|
||||
Storage Memory用于缓存分布式数据集,比如RDD Cache、广播变量等等。关于广播变量的细节,我们留到第10讲再去展开。RDD Cache指的是RDD物化到内存中的副本。在一个较长的DAG中,如果同一个RDD被引用多次,那么把这个RDD缓存到内存中,往往会大幅提升作业的执行性能。我们在这节课的最后会介绍RDD Cache的具体用法。
|
||||
|
||||
不难发现,Execution Memory和Storage Memory这两块内存区域,对于Spark作业的执行性能起着举足轻重的作用。因此,在所有的内存区域中,Execution Memory和Storage Memory是最重要的,也是开发者最需要关注的。
|
||||
|
||||
在 Spark 1.6 版本之前,Execution Memory 和 Storage Memory的空间划分是静态的,一旦空间划分完毕,不同内存区域的用途与尺寸就固定了。也就是说,即便你没有缓存任何 RDD 或是广播变量,Storage Memory 区域的空闲内存也不能用来执行映射、排序或聚合等计算任务,宝贵的内存资源就这么白白地浪费掉了。
|
||||
|
||||
考虑到静态内存划分的弊端,在 1.6 版本之后,Spark 推出了统一内存管理模式,在这种模式下,Execution Memory 和 Storage Memory 之间可以相互转化。这是什么意思呢?接下来,我们一起走进斯巴克集团分公司,看看不同内存区域相互转化的逻辑。
|
||||
|
||||
不同内存区域的相互转化
|
||||
|
||||
刚一走进分公司的大门,我们就能看到工人们在工地上如火如荼的忙碌景象。走近一问,才知道他们承接了一个“集装箱改装活动房”的建筑项目。顾名思义,这个项目的具体任务,就是把集装箱改装成活动房。
|
||||
|
||||
活动房的制作过程并不复杂,只需一系列简单的步骤,就能把集装箱改装为小巧而又别致的活动房,这些步骤包括清洗、切割开窗、切割开门、刷漆、打隔断、布置家居、装饰点缀。活动房的制作在工地上完成,成功改装的活动房会被立即拉走,由货运卡车运往集团公司的物流集散地。
|
||||
|
||||
好了,介绍完集装箱改装活动房的项目,我们必须要交代一下这个项目与Spark之间的关联关系。毕竟,再有趣的故事,也是用来辅助咱们更好地学习Spark嘛。
|
||||
|
||||
项目中涉及的原材料、施工步骤与Spark之间的类比关系,我把它整理到了下面的这张表格中:-
|
||||
|
||||
|
||||
从表中可以看到,集装箱相当于是RDD数据源,而切割门窗等施工步骤,对应的正是各式各样的RDD算子。而工地用于提供施工场所,这与计算节点内存提供数据处理场所的作用如出一辙。这么看下来,集装箱改装活动房的项目,就可以看作是Spark作业,或者说是Spark应用。
|
||||
|
||||
接下来,我们来考察一下这个项目的施工过程。走近工地,我们发现工地上赫然划着一条红色的虚线,把工地一分为二。虚线的左侧,堆放着若干沾满泥土的集装箱,而工地的右侧,则是工人们在集装箱上叮叮当当地做着改装,有的集装箱已经开始布置家居,有的还在切割门窗。
|
||||
|
||||
|
||||
|
||||
看到地上的红线,我们不免好奇,走近前去问,工头为我们道清了原委。
|
||||
|
||||
按理说,像集装箱、家具这些生产资料都应该放在临时仓库(节点硬盘)的,工地(节点内存)原则上只用来进行改装操作。不过,工地离临时仓库还有一段距离,来回运输不太方便。
|
||||
|
||||
为了提升工作效率,工地被划分成两个区域。在上图中,红线左边的那块地叫作暂存区(Storage Memory),专门用来暂存建筑材料;而右边的那部分叫作操作区(Execution Memory),用来给工人改装集装箱、制作活动房。
|
||||
|
||||
之所以使用虚线标记,原因就在于,两块区域的尺寸大小并不是一成不变的,当一方区域有空地时,另一方可以进行抢占。
|
||||
|
||||
举例来说,假设操作区只有两个工人(CPU 线程)分别在改装集装箱,此时操作区空出来可以容纳两个物件的空地,那么这片空地就可以暂时用来堆放建筑材料,暂存区也因此得到了实质性的扩张。
|
||||
|
||||
|
||||
|
||||
不过,当有足够的工人可以扩大生产的时候,比如在原有两个工人在作业的基础上,又来了两个工人,此时共有4个工人可以同时制作活动房,那么红色虚线到蓝色实线之间的任何物件(比如上图的沙发和双人床),都需要腾出到临时仓库,腾空的区域交给新来的两个工人改装集装箱。毕竟,改装集装箱、制作活动房,才是项目的核心任务。
|
||||
|
||||
|
||||
|
||||
相反,如果暂存区堆放的物件比较少、留有空地,而工人又比较充裕,比如有6个工人可以同时进行改装,那么此时暂存区的空地就会被操作区临时征用,给工人用来制作活动房。这个时候,操作区实际上也扩大了。
|
||||
|
||||
|
||||
|
||||
当有更多的物件需要堆放到暂存区的时候,扩张的操作区相应地也需要收缩到红色虚线的位置。不过,对于红色实线与红色虚线之间的区域,我们必须要等到工人们把正在改装的活动房制作完毕(Task Complete),才能把这片区域归还给暂存区。
|
||||
|
||||
好啦,活动房的项目到这里就介绍完了。不难发现,操作区类比的是 Execution Memory,而暂存区其实就是 Storage Memory。Execution Memory 和 Storage Memory 之间的抢占规则,一共可以总结为 3 条:
|
||||
|
||||
|
||||
如果对方的内存空间有空闲,双方可以互相抢占;
|
||||
对于Storage Memory抢占的Execution Memory部分,当分布式任务有计算需要时,Storage Memory必须立即归还抢占的内存,涉及的缓存数据要么落盘、要么清除;
|
||||
对于Execution Memory抢占的Storage Memory部分,即便Storage Memory有收回内存的需要,也必须要等到分布式任务执行完毕才能释放。
|
||||
|
||||
|
||||
介绍完Execution Memory与Storage Memory之间的抢占规则之后,接下来,我们来看看不同内存区域的初始大小是如何设置的。
|
||||
|
||||
内存配置项
|
||||
|
||||
总体来说,Executor JVM Heap的划分,由图中的3个配置项来决定:
|
||||
|
||||
|
||||
|
||||
其中spark.executor.memory是绝对值,它指定了Executor进程的JVM Heap总大小。另外两个配置项,spark.memory.fraction和spark.memory.storageFraction都是比例值,它们指定了划定不同区域的空间占比。
|
||||
|
||||
spark.memory.fraction用于标记Spark处理分布式数据集的内存总大小,这部分内存包括Execution Memory和Storage Memory两部分,也就是图中绿色的矩形区域。(M – 300)* (1 – mf)刚好就是User Memory的区域大小,也就是图中蓝色区域的部分。
|
||||
|
||||
spark.memory.storageFraction则用来进一步区分Execution Memory和Storage Memory的初始大小。我们之前说过,Reserved Memory固定为300MB。(M – 300)* mf * sf是Storage Memory的初始大小,相应地,(M – 300)* mf * (1 – sf)就是Execution Memory的初始大小。
|
||||
|
||||
熟悉了以上3个配置项,作为开发者,我们就能有的放矢地去调整不同的内存区域,从而提升内存的使用效率。我们在前面提到,合理地使用RDD Cache往往能大幅提升作业的执行性能,因此在这一讲的最后,我们一起来学习一下RDD Cache的具体用法。
|
||||
|
||||
RDD Cache
|
||||
|
||||
在一个Spark作业中,计算图DAG中往往包含多个RDD,我们首先需要弄清楚什么时候对哪个RDD进行Cache,盲目地滥用Cache可不是明智之举。我们先说结论,当同一个RDD被引用多次时,就可以考虑对其进行Cache,从而提升作业的执行效率。
|
||||
|
||||
我们拿第1讲中的Word Count来举例,完整的代码如下所示:
|
||||
|
||||
import org.apache.spark.rdd.RDD
|
||||
|
||||
val rootPath: String = _
|
||||
val file: String = s"${rootPath}/wikiOfSpark.txt"
|
||||
|
||||
// 读取文件内容
|
||||
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
|
||||
|
||||
// 以行为单位做分词
|
||||
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
|
||||
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
|
||||
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
|
||||
|
||||
// 按照单词做分组计数
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
|
||||
|
||||
// 打印词频最高的5个词汇
|
||||
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
|
||||
|
||||
// 将分组计数结果落盘到文件
|
||||
val targetPath: String = _
|
||||
wordCounts.saveAsTextFile(targetPath)
|
||||
|
||||
|
||||
细心的你可能发现了,我们今天的代码,与第1讲中的代码实现不同。我们在最后追加了saveAsTextFile落盘操作,这样一来,wordCounts这个RDD在程序中被引用了两次。
|
||||
|
||||
如果你把这份代码丢进spark-shell去执行,会发现take和saveAsTextFile这两个操作执行得都很慢。这个时候,我们就可以考虑通过给wordCounts加Cache来提升效率。
|
||||
|
||||
那么问题来了,Cache该怎么加呢?很简单,你只需要在wordCounts完成定义之后,在这个RDD之上依次调用cache和count即可,如下所示:
|
||||
|
||||
// 按照单词做分组计数
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
|
||||
|
||||
wordCounts.cache// 使用cache算子告知Spark对wordCounts加缓存
|
||||
wordCounts.count// 触发wordCounts的计算,并将wordCounts缓存到内存
|
||||
|
||||
// 打印词频最高的5个词汇
|
||||
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
|
||||
|
||||
// 将分组计数结果落盘到文件
|
||||
val targetPath: String = _
|
||||
wordCounts.saveAsTextFile(targetPath)
|
||||
|
||||
|
||||
由于cache函数并不会立即触发RDD在内存中的物化,因此我们还需要调用count算子来触发这一执行过程。添加上面的两条语句之后,你会发现take和saveAsTextFile的运行速度明显变快了很多。强烈建议你在spark-shell中对比添加Cache前后的运行速度,从而直观地感受RDD Cache对于作业执行性能的提升。
|
||||
|
||||
在上面的例子中,我们通过在RDD之上调用cache来为其添加缓存,而在背后,cache函数实际上会进一步调用persist(MEMORY_ONLY)来完成计算。换句话说,下面的两条语句是完全等价的,二者的含义都是把RDD物化到内存。
|
||||
|
||||
wordCounts.cache
|
||||
wordCounts.persist(MEMORY_ONLY)
|
||||
|
||||
|
||||
就添加Cache来说,相比cache算子,persist算子更具备普适性,结合多样的存储级别(如这里的MEMORY_ONLY),persist算子允许开发者灵活地选择Cache的存储介质、存储形式以及副本数量。
|
||||
|
||||
Spark支持丰富的存储级别,每一种存储级别都包含3个最基本的要素。
|
||||
|
||||
|
||||
存储介质:数据缓存到内存还是磁盘,或是两者都有
|
||||
存储形式:数据内容是对象值还是字节数组,带 SER 字样的表示以序列化方式存储,不带 SER 则表示采用对象值
|
||||
副本数量:存储级别名字最后的数字代表拷贝数量,没有数字默认为 1 份副本。
|
||||
|
||||
|
||||
我把Spark支持的存储级别总结到了下表,其中打钩的地方,表示某种存储级别支持的存储介质与存储形式,你不妨看一看,做到心中有数。
|
||||
|
||||
|
||||
|
||||
通过上表对琳琅满目的存储级别进行拆解之后,我们就会发现,它们不过是存储介质、存储形式和副本数量这3类基本要素的排列组合而已。上表列出了目前Spark支持的所有存储级别,通过它,你可以迅速对比查找不同的存储级别,从而满足不同的业务需求。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,你需要掌握Executor JVM Heap的划分原理,并学会通过配置项来划分不同的内存区域。
|
||||
|
||||
具体来说,Spark把Executor内存划分为4个区域,分别是Reserved Memory、User Memory、Execution Memory和Storage Memory。
|
||||
|
||||
通过调整spark.executor.memory、spark.memory.fraction和spark.memory.storageFraction这3个配置项,你可以灵活地调整不同内存区域的大小,从而去适配Spark作业对于内存的需求。
|
||||
|
||||
|
||||
|
||||
再者,在统一内存管理模式下,Execution Memory与Storage Memory之间可以互相抢占,你需要弄清楚二者之间的抢占逻辑。总结下来,内存的抢占逻辑有如下3条:
|
||||
|
||||
|
||||
如果对方的内存空间有空闲,双方可以互相抢占;
|
||||
对于Storage Memory抢占的Execution Memory部分,当分布式任务有计算需要时,Storage Memory必须立即归还抢占的内存,涉及的缓存数据要么落盘、要么清除;
|
||||
对于Execution Memory抢占的Storage Memory部分,即便Storage Memory有收回内存的需要,也必须要等到分布式任务执行完毕才能释放。
|
||||
|
||||
|
||||
最后,我们介绍了RDD Cache的基本用法,当一个RDD在代码中的引用次数大于1时,你可以考虑通过给RDD加Cache来提升作业性能。具体做法是在RDD之上调用cache或是persist函数。
|
||||
|
||||
其中persist更具备普适性,你可以通过指定存储级别来灵活地选择Cache的存储介质、存储形式以及副本数量,从而满足不同的业务需要。
|
||||
|
||||
每课一练
|
||||
|
||||
好啦,这节课就到这里了,我们今天的练习题是这样的:
|
||||
|
||||
给定如下配置项设置,请你计算不同内存区域(Reserved、User、Execution、Storage)的空间大小。
|
||||
|
||||
|
||||
|
||||
欢迎你在评论区分享你的答案。如果这一讲对你有帮助,也欢迎你把这一讲分享给自己的朋友,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
287
专栏/零基础入门Spark/09RDD常用算子(三):数据的准备、重分布与持久化.md
Normal file
287
专栏/零基础入门Spark/09RDD常用算子(三):数据的准备、重分布与持久化.md
Normal file
@ -0,0 +1,287 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 RDD常用算子(三):数据的准备、重分布与持久化
|
||||
你好,我是吴磊。
|
||||
|
||||
在RDD常用算子的前两讲中,我们分别介绍了用于RDD内部转换与聚合的诸多算子,今天这一讲,我们继续来介绍表格中剩余部分的算子。
|
||||
|
||||
按照惯例,表格中的算子我们不会全都介绍,而是只挑选其中最常用、最具代表性的进行讲解。今天要讲的算子,我用加粗字体进行了高亮显示,你不妨先扫一眼,做到心中有数。
|
||||
|
||||
|
||||
|
||||
你可能会觉得,这些高亮显示的算子乍一看也没什么关联啊?但如果我们从数据生命周期的角度入手,给它们归归类,很容易就会发现这些算子分别隶属于生命周期的某个阶段。
|
||||
|
||||
|
||||
|
||||
结合上图,我们分别来看看每个算子所在的生命周期和它们实现的功能。
|
||||
|
||||
首先,在数据准备阶段,union与sample用于对不同来源的数据进行合并与拆分。
|
||||
|
||||
我们从左往右接着看,接下来是数据预处理环节。较为均衡的数据分布,对后面数据处理阶段提升CPU利用率更有帮助,可以整体提升执行效率。那这种均衡要怎么实现呢?没错,这时就要coalesce与repartition登场了,它们的作用就是重新调整RDD数据分布。
|
||||
|
||||
在数据处理完毕、计算完成之后,我们自然要对计算结果进行收集。Spark提供了两类结果收集算子,一类是像take、first、collect这样,把结果直接收集到Driver端;另一类则是直接将计算结果持久化到(分布式)文件系统,比如咱们这一讲会提到的saveAsTextFile。
|
||||
|
||||
好啦,清楚了我们今天要讲哪些算子,以及它们大致的定位与功用之后,接下来,我们就正式来讲讲这些算子的具体用法。
|
||||
|
||||
数据准备
|
||||
|
||||
首先,我们先来说说数据准备阶段的union和sample。
|
||||
|
||||
union
|
||||
|
||||
在我们日常的开发中,union非常常见,它常常用于把两个类型一致、但来源不同的RDD进行合并,从而构成一个统一的、更大的分布式数据集。例如,在某个数据分析场景中,一份数据源来自远端数据库,而另一份数据源来自本地文件系统,要将两份数据进行合并,我们就需要用到union这个操作。
|
||||
|
||||
具体怎么使用呢?我来举个例子。给定两个RDD:rdd1和rdd2,调用rdd1.union(rdd2)或是rdd1 union rdd2,其结果都是两个RDD的并集,具体代码如下:
|
||||
|
||||
// T:数据类型
|
||||
val rdd1: RDD[T] = _
|
||||
val rdd2: RDD[T] = _
|
||||
val rdd = rdd1.union(rdd2)
|
||||
// 或者rdd1 union rdd2
|
||||
|
||||
|
||||
需要特别强调的是,union操作能够成立的前提,就是参与合并的两个RDD的类型必须完全一致。也就是说,RDD[String]只能与RDD[String]合并到一起,却无法与除RDD[String]以外的任何RDD类型(如RDD[Int]、甚至是RDD[UserDefinedClass])做合并。
|
||||
|
||||
对于多个类型一致的RDD,我们可以通过连续调用union把所有数据集合并在一起。例如,给定类型一致的3个RDD:rdd1、rdd2和rdd3,我们可以使用如下代码把它们合并在一起。
|
||||
|
||||
// T:数据类型
|
||||
val rdd1: RDD[T] = _
|
||||
val rdd2: RDD[T] = _
|
||||
val rdd3: RDD[T] = _
|
||||
|
||||
val rdd = (rdd1.union(rdd2)).union(rdd3)
|
||||
// 或者 val rdd = rdd1 union rdd2 union rdd3
|
||||
|
||||
|
||||
不难发现,union的典型使用场景,是把多份“小数据”,合并为一份“大数据”,从而充分利用Spark分布式引擎的并行计算优势。
|
||||
|
||||
与之相反,在一般的数据探索场景中,我们往往只需要对一份数据的子集有基本的了解即可。例如,对于一份体量在TB级别的数据集,我们只想随机提取其部分数据,然后计算这部分子集的统计值(均值、方差等)。
|
||||
|
||||
那么,面对这类把“大数据”变成 “小数据”的计算需求,Spark又如何进行支持呢?这就要说到RDD的sample算子了。
|
||||
|
||||
sample
|
||||
|
||||
RDD的sample算子用于对RDD做随机采样,从而把一个较大的数据集变为一份“小数据”。相较其他算子,sample的参数比较多,分别是withReplacement、fraction和seed。因此,要在RDD之上完成数据采样,你需要使用如下的方式来调用sample算子:sample(withReplacement, fraction, seed)。
|
||||
|
||||
其中,withReplacement的类型是Boolean,它的含义是“采样是否有放回”,如果这个参数的值是true,那么采样结果中可能会包含重复的数据记录,相反,如果该值为false,那么采样结果不存在重复记录。
|
||||
|
||||
fraction参数最好理解,它的类型是Double,值域为0到1,其含义是采样比例,也就是结果集与原数据集的尺寸比例。seed参数是可选的,它的类型是Long,也就是长整型,用于控制每次采样的结果是否一致。光说不练假把式,我们还是结合一些示例,这样才能更好地理解sample算子的用法。
|
||||
|
||||
// 生成0到99的整型数组
|
||||
val arr = (0 until 100).toArray
|
||||
// 使用parallelize生成RDD
|
||||
val rdd = sc.parallelize(arr)
|
||||
|
||||
// 不带seed,每次采样结果都不同
|
||||
rdd.sample(false, 0.1).collect
|
||||
// 结果集:Array(11, 13, 14, 39, 43, 63, 73, 78, 83, 88, 89, 90)
|
||||
rdd.sample(false, 0.1).collect
|
||||
// 结果集:Array(6, 9, 10, 11, 17, 36, 44, 53, 73, 74, 79, 97, 99)
|
||||
|
||||
// 带seed,每次采样结果都一样
|
||||
rdd.sample(false, 0.1, 123).collect
|
||||
// 结果集:Array(3, 11, 26, 59, 82, 89, 96, 99)
|
||||
rdd.sample(false, 0.1, 123).collect
|
||||
// 结果集:Array(3, 11, 26, 59, 82, 89, 96, 99)
|
||||
|
||||
// 有放回采样,采样结果可能包含重复值
|
||||
rdd.sample(true, 0.1, 456).collect
|
||||
// 结果集:Array(7, 11, 11, 23, 26, 26, 33, 41, 57, 74, 96)
|
||||
rdd.sample(true, 0.1, 456).collect
|
||||
// 结果集:Array(7, 11, 11, 23, 26, 26, 33, 41, 57, 74, 96)
|
||||
|
||||
|
||||
我们的实验分为3组,前两组用来对比添加seed参数与否的差异,最后一组用于说明withReplacement参数的作用。
|
||||
|
||||
不难发现,在不带seed参数的情况下,每次调用sample之后的返回结果都不一样。而当我们使用同样的seed调用算子时,不论我们调用sample多少次,每次的返回结果都是一致的。另外,仔细观察第3组实验,你会发现结果集中有重复的数据记录,这是因为withReplacement被置为true,采样的过程是“有放回的”。
|
||||
|
||||
好啦,到目前为止,数据准备阶段常用的两个算子我们就讲完了。有了union和sample,你就可以随意地调整分布式数据集的尺寸,真正做到收放自如。
|
||||
|
||||
数据预处理
|
||||
|
||||
接下来,在数据预处理阶段,我们再来说说负责数据重分布的两个算子:repartition和coalesce。
|
||||
|
||||
在了解这两个算子之前,你需要先理解并行度这个概念。所谓并行度,它实际上就是RDD的数据分区数量。还记得吗?RDD的partitions属性,记录正是RDD的所有数据分区。因此,RDD的并行度与其partitions属性相一致。
|
||||
|
||||
开发者可以使用repartition算子随意调整(提升或降低)RDD的并行度,而coalesce算子则只能用于降低RDD并行度。显然,在数据分布的调整方面,repartition灵活度更高、应用场景更多,我们先对它进行介绍,之后再去看看coalesce有什么用武之地。
|
||||
|
||||
repartition
|
||||
|
||||
一旦给定了RDD,我们就可以通过调用repartition(n)来随意调整RDD并行度。其中参数n的类型是Int,也就是整型,因此,我们可以把任意整数传递给repartition。按照惯例,咱们还是结合示例熟悉一下repartition的用法。
|
||||
|
||||
// 生成0到99的整型数组
|
||||
val arr = (0 until 100).toArray
|
||||
// 使用parallelize生成RDD
|
||||
val rdd = sc.parallelize(arr)
|
||||
|
||||
rdd.partitions.length
|
||||
// 4
|
||||
|
||||
val rdd1 = rdd.repartition(2)
|
||||
rdd1.partitions.length
|
||||
// 2
|
||||
|
||||
val rdd2 = rdd.repartition(8)
|
||||
rdd2.partitions.length
|
||||
// 8
|
||||
|
||||
|
||||
首先,我们通过数组创建用于实验的RDD,从这段代码里可以看到,该RDD的默认并行度是4。在我们分别用2和8来调整RDD的并行度之后,通过计算RDD partitions属性的长度,我们发现新RDD的并行度分别被相应地调整为2和8。
|
||||
|
||||
看到这里,你可能还有疑问:“我们为什么需要调整RDD的并行度呢?2和8看上去也没什么实质性的区别呀”。
|
||||
|
||||
在RDD那一讲([第2讲]),我们介绍过,每个RDD的数据分区,都对应着一个分布式Task,而每个Task都需要一个CPU线程去执行。
|
||||
|
||||
因此,RDD的并行度,很大程度上决定了分布式系统中CPU的使用效率,进而还会影响分布式系统并行计算的执行效率。并行度过高或是过低,都会降低CPU利用率,从而白白浪费掉宝贵的分布式计算资源,因此,合理有效地设置RDD并行度,至关重要。
|
||||
|
||||
这时你可能会追问:“既然如此,那么我该如何合理地设置RDD的并行度呢?”坦白地说,这个问题并没有固定的答案,它取决于系统可用资源、分布式数据集大小,甚至还与执行内存有关。
|
||||
|
||||
不过,结合经验来说,把并行度设置为可用CPU的2到3倍,往往是个不错的开始。例如,可分配给Spark作业的Executors个数为N,每个Executors配置的CPU个数为C,那么推荐设置的并行度坐落在N_C_2到N_C_3这个范围之间。
|
||||
|
||||
尽管repartition非常灵活,你可以用它随意地调整RDD并行度,但是你也需要注意,这个算子有个致命的弊端,那就是它会引入Shuffle。
|
||||
|
||||
我们知道([第6讲]详细讲过),由于Shuffle在计算的过程中,会消耗所有类型的硬件资源,尤其是其中的磁盘I/O与网络I/O,因此Shuffle往往是作业执行效率的瓶颈。正是出于这个原因,在做应用开发的时候,我们应当极力避免Shuffle的引入。
|
||||
|
||||
但你可能会说:“如果数据重分布是刚需,而repartition又必定会引入Shuffle,我该怎么办呢?”如果你想增加并行度,那我们还真的只能仰仗repartition,Shuffle的问题自然也就无法避免。但假设你的需求是降低并行度,这个时候,我们就可以把目光投向repartition的孪生兄弟:coalesce。
|
||||
|
||||
coalesce
|
||||
|
||||
在用法上,coalesce与repartition一样,它也是通过指定一个Int类型的形参,完成对RDD并行度的调整,即coalesce (n)。那两者的用法到底有什么差别呢?我们不妨结合刚刚的代码示例,来对比coalesce与repartition。
|
||||
|
||||
// 生成0到99的整型数组
|
||||
val arr = (0 until 100).toArray
|
||||
// 使用parallelize生成RDD
|
||||
val rdd = sc.parallelize(arr)
|
||||
|
||||
rdd.partitions.length
|
||||
// 4
|
||||
|
||||
val rdd1 = rdd.repartition(2)
|
||||
rdd1.partitions.length
|
||||
// 2
|
||||
|
||||
val rdd2 = rdd.coalesce(2)
|
||||
rdd2.partitions.length
|
||||
// 2
|
||||
|
||||
|
||||
可以看到,在用法上,coalesce与repartition可以互换,二者的效果是完全一致的。不过,如果我们去观察二者的DAG,会发现同样的计算逻辑,却有着迥然不同的执行计划。
|
||||
|
||||
|
||||
|
||||
在RDD之上调用toDebugString,Spark可以帮我们打印出当前RDD的DAG。尽管图中的打印文本看上去有些凌乱,但你只要抓住其中的一个关键要点就可以了。
|
||||
|
||||
这个关键要点就是,在toDebugString的输出文本中,每一个带数字的小括号,比如rdd1当中的“(2)”和“(4)”,都代表着一个执行阶段,也就是DAG中的Stage。而且,不同的Stage之间,会通过制表符(Tab)缩进进行区分,比如图中的“(4)”显然要比“(2)”缩进了一段距离。
|
||||
|
||||
对于toDebugString的解读,你只需要掌握到这里就足够了。学习过调度系统之后,我们已经知道,在同一个DAG内,不同Stages之间的边界是Shuffle。因此,观察上面的打印文本,我们能够清楚地看到,repartition会引入Shuffle,而coalesce不会。
|
||||
|
||||
那么问题来了,同样是重分布的操作,为什么repartition会引入Shuffle,而coalesce不会呢?原因在于,二者的工作原理有着本质的不同。
|
||||
|
||||
给定RDD,如果用repartition来调整其并行度,不论增加还是降低,对于RDD中的每一条数据记录,repartition对它们的影响都是无差别的数据分发。
|
||||
|
||||
具体来说,给定任意一条数据记录,repartition的计算过程都是先哈希、再取模,得到的结果便是该条数据的目标分区索引。对于绝大多数的数据记录,目标分区往往坐落在另一个Executor、甚至是另一个节点之上,因此Shuffle自然也就不可避免。
|
||||
|
||||
coalesce则不然,在降低并行度的计算中,它采取的思路是把同一个Executor内的不同数据分区进行合并,如此一来,数据并不需要跨Executors、跨节点进行分发,因而自然不会引入Shuffle。
|
||||
|
||||
这里我还特意准备了一张示意图,更直观地为你展示repartition与coalesce的计算过程,图片文字双管齐下,相信你一定能够更加深入地理解repartition与coalesce之间的区别与联系。
|
||||
|
||||
|
||||
|
||||
好啦,到此为止,在数据预处理阶段,用于对RDD做重分布的两个算子我们就讲完了。掌握了repartition和coalesce这两个算子,结合数据集大小与集群可用资源,你就可以随意地对RDD的并行度进行调整,进而提升CPU利用率与作业的执行性能。
|
||||
|
||||
结果收集
|
||||
|
||||
预处理完成之后,数据生命周期的下一个阶段是数据处理,在这个环节,你可以使用RDD常用算子(二)[那一讲]介绍的各类算子,去对数据进行各式各样的处理,比如数据转换、数据过滤、数据聚合,等等。完成处理之后,我们自然要收集计算结果。
|
||||
|
||||
在结果收集方面,Spark也为我们准备了丰富的算子。按照收集路径区分,这些算子主要分为两类:第一类是把计算结果从各个Executors收集到Driver端,第二个类是把计算结果通过Executors直接持久化到文件系统。在大数据处理领域,文件系统往往指的是像HDFS或是S3这样的分布式文件系统。
|
||||
|
||||
first、take和collect
|
||||
|
||||
我们今天要介绍的第一类算子有first、take和collect,它们的用法非常简单,按照老规矩,我们还是使用代码示例进行讲解。这里我们结合第1讲的Word Count,分别使用first、take和collect这三个算子对不同阶段的RDD进行数据探索。
|
||||
|
||||
import org.apache.spark.rdd.RDD
|
||||
val rootPath: String = _
|
||||
val file: String = s"${rootPath}/wikiOfSpark.txt"
|
||||
// 读取文件内容
|
||||
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
|
||||
|
||||
lineRDD.first
|
||||
// res1: String = Apache Spark
|
||||
|
||||
// 以行为单位做分词
|
||||
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
|
||||
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
|
||||
|
||||
cleanWordRDD.take(3)
|
||||
// res2: Array[String] = Array(Apache, Spark, From)
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
|
||||
// 按照单词做分组计数
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
|
||||
|
||||
wordCounts.collect
|
||||
// res3: Array[(String, Int)] = Array((Because,1), (Open,1), (impl...
|
||||
|
||||
|
||||
其中,first用于收集RDD数据集中的任意一条数据记录,而take(n: Int)则用于收集多条记录,记录的数量由Int类型的参数n来指定。
|
||||
|
||||
不难发现,first与take的主要作用,在于数据探索。对于RDD的每一步转换,比如Word Count中从文本行到单词、从单词到KV转换,我们都可以用first或是take来获取几条计算结果,从而确保转换逻辑与预期一致。
|
||||
|
||||
相比之下,collect拿到的不是部分结果,而是全量数据,也就是把RDD的计算结果全量地收集到Driver端。在上面Word Count的例子中,我们可以看到,由于全量结果较大,屏幕打印只好做截断处理。
|
||||
|
||||
为了让你更深入地理解collect算子的工作原理,我把它的计算过程画在了后面的示意图中。
|
||||
|
||||
|
||||
|
||||
结合示意图,不难发现,collect算子有两处性能隐患,一个是拉取数据过程中引入的网络开销,另一个Driver的OOM(内存溢出,Out of Memory)。
|
||||
|
||||
网络开销很好理解,既然数据的拉取和搬运是跨进程、跨节点的,那么和Shuffle类似,这个过程必然会引入网络开销。
|
||||
|
||||
再者,通常来说,Driver端的预设内存往往在GB量级,而RDD的体量一般都在数十GB、甚至上百GB,因此,OOM的隐患不言而喻。collect算子尝试把RDD全量结果拉取到Driver,当结果集尺寸超过Driver预设的内存大小时,Spark自然会报OOM的异常(Exception)。
|
||||
|
||||
正是出于这些原因,我们在使用collect算子之前,务必要慎重。不过,你可能会问:“如果业务逻辑就是需要收集全量结果,而collect算子又不好用,那我该怎么办呢?”别着急,我们接着往下看。
|
||||
|
||||
saveAsTextFile
|
||||
|
||||
对于全量的结果集,我们还可以使用第二类算子把它们直接持久化到磁盘。在这类算子中,最具代表性的非saveAsTextFile莫属,它的用法非常简单,给定RDD,我们直接调用saveAsTextFile(path: String)即可。其中path代表的是目标文件系统目录,它可以是本地文件系统,也可以是HDFS、Amazon S3等分布式文件系统。
|
||||
|
||||
为了让你加深对于第二类算子的理解,我把它们的工作原理也整理到了下面的示意图中。可以看到,以saveAsTextFile为代表的算子,直接通过Executors将RDD数据分区物化到文件系统,这个过程并不涉及与Driver端的任何交互。
|
||||
|
||||
|
||||
|
||||
由于数据的持久化与Driver无关,因此这类算子天然地避开了collect算子带来的两个性能隐患。
|
||||
|
||||
好啦,到此为止,用于结果收集的算子我们就介绍完了,掌握了first、take、collect和saveAsTextFile等算子之后,你可以先用first、take等算子验证计算逻辑的正确性,然后再使用saveAsTextFile算子把全量结果持久化到磁盘,以备之后使用。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,我们介绍并讲解了很多RDD算子,这些算子可以分别归类到数据生命周期的不同阶段,算子与阶段的对应关系如下图所示。
|
||||
|
||||
|
||||
|
||||
在数据准备阶段,你可以使用union和sample来扩张或是缩小分布式数据集,需要特别注意的是,参与union的多个RDD在类型上必须保持一致。
|
||||
|
||||
在数据预处理阶段,你可以利用repartition和coalesce来调整RDD的并行度。RDD并行度对于CPU利用率至关重要,它在很大程度上决定着并行计算的执行效率。一般来说,给定Executors个数N,以及CPU/Executor配置个数C,那么我会推荐你把RDD的并行度设置在N_C_2到N_C_3之间。
|
||||
|
||||
最后,在结果收集阶段,你可以使用first、take、collect等算子来探索数据,这些算子可以用来验证计算过程中的转换逻辑是否与预期一致。当你确认计算逻辑准确无误之后,就可以使用saveAsTextFile等算子将全量结果集持久化到(分布式)文件系统。
|
||||
|
||||
到今天为止,我们用三讲的篇幅,学习了RDD开发API中的大部分算子。灵活地运用这些算子,你就能轻松应对日常开发中大部分的业务需求。为了方便你随时回顾、查阅,我把我们一起学过的这些算子整理到了后面的表格中,希望对你有所帮助。
|
||||
|
||||
|
||||
|
||||
每课一练
|
||||
|
||||
|
||||
给定3个RDD,除了使用rdd1 union rdd2 union rdd3把它们合并在一起之外,你认为还有其他更加优雅的写法吗?(提示:reduce)
|
||||
|
||||
相比repartition,coalesce有哪些可能的潜在隐患?(提示:数据分布)
|
||||
|
||||
|
||||
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多的同事、朋友,帮他理清RDD的常用算子。
|
||||
|
||||
|
||||
|
||||
|
0
专栏/零基础入门Spark/10广播变量&累加器:共享变量是用来做什么的?.md
Normal file
0
专栏/零基础入门Spark/10广播变量&累加器:共享变量是用来做什么的?.md
Normal file
136
专栏/零基础入门Spark/11存储系统:数据到底都存哪儿了?.md
Normal file
136
专栏/零基础入门Spark/11存储系统:数据到底都存哪儿了?.md
Normal file
@ -0,0 +1,136 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 存储系统:数据到底都存哪儿了?
|
||||
你好,我是吴磊。
|
||||
|
||||
感谢你在国庆假期仍然坚持学习,今天这一讲,我们来学习存储系统,与调度系统一样,它也是Spark重要的基础设施之一。不过,你可能会好奇:“掌握Spark应用开发,需要去了解这么底层的知识吗?”坦白地说,还真需要,为什么这么说呢?
|
||||
|
||||
我们前面学了Shuffle管理、RDD Cache和广播变量,这些功能与特性,对Spark作业的执行性能有着至关重要的影响。而想要实现这些功能,底层的支撑系统正是Spark存储系统。
|
||||
|
||||
学习和熟悉存储系统,不单单是为了完善我们的知识体系,它还能直接帮你更好地利用RDD Cache和广播变量这些特性。在未来,这些知识也能为你做Shuffle的调优奠定良好的基础。
|
||||
|
||||
既然存储系统这么重要,那要怎样高效快速地掌握它呢?本着学以致用的原则,我们需要先了解系统的服务对象,说白了就是存储系统是用来存什么东西的。
|
||||
|
||||
服务对象
|
||||
|
||||
笼统地说,Spark存储系统负责维护所有暂存在内存与磁盘中的数据,这些数据包括Shuffle中间文件、RDD Cache以及广播变量。
|
||||
|
||||
对于上述三类数据,我们并不陌生。我们先回顾一下什么是Shuffle中间文件,在Shuffle的计算过程中,Map Task在Shuffle Write阶段生产data与index文件。接下来,根据index文件提供的分区索引,Shuffle Read阶段的Reduce Task从不同节点拉取属于自己的分区数据。而Shuffle中间文件,指的正是两个阶段为了完成数据交换所仰仗的data与index文件。
|
||||
|
||||
RDD Cache指的是分布式数据集在内存或是磁盘中的物化,它往往有利于提升计算效率。广播变量[上一讲]我们刚刚介绍过,它的优势在于以Executors为粒度分发共享变量,从而大幅削减数据分发引入的网络与存储开销。
|
||||
|
||||
我们刚才对这三类数据做了简单回顾,如果你觉得哪里不是特别清楚的话,不妨翻回前面几讲再看一看,我们在第7、8、10这3讲分别对它们做了详细讲解。好啦,了解了存储系统服务的主要对象以后,接下来,我们来细数Spark存储系统都有哪些重要组件,看看它们之间又是如何协作的。
|
||||
|
||||
存储系统的构成
|
||||
|
||||
理论的学习总是枯燥而又乏味,为了让你更加轻松地掌握存储系统的核心组件,咱们不妨还是用斯巴克国际建筑集团的类比,来讲解Spark存储系统。
|
||||
|
||||
相比调度系统复杂的人事关系(戴格、塔斯克、拜肯德),存储系统的人员构成要简单得多。在内存管理[那一讲],我们把节点内存看作是施工工地,而把节点磁盘看作是临时仓库,那么显然,管理数据存储的组件,就可以看成是仓库管理员,简称库管。
|
||||
|
||||
布劳克家族
|
||||
|
||||
在斯巴克建筑集团,库管这个关键角色,一直以来都是由布劳克家族把持着。
|
||||
|
||||
布劳克家族在斯巴克集团的地位举足轻重,老布劳克(BlockManagerMaster)坐镇集团总公司(Driver),而他的子嗣们、小布劳克(BlockManager)则驻守在各个分公司(Executors)。
|
||||
|
||||
对集团公司建材与仓库的整体情况,老布劳克了如指掌,当然,这一切要归功于他众多的子嗣们。各家分公司的小布劳克,争先恐后地向老爸汇报分公司的建材状态与仓库状况。关于他们的父子关系,我整理到了下面的示意图中。
|
||||
|
||||
|
||||
|
||||
从上图我们能够看得出来,小布劳克与老布劳克之间的信息交换是双向的。不难发现,布劳克家族的家风是典型的“家长制”和“一言堂”。如果小布劳克需要获取其他分公司的状态,他必须要通过老布劳克才能拿到这些信息。
|
||||
|
||||
在前面的几讲中,我们把建材比作是分布式数据集,那么,BlockManagerMaster与BlockManager之间交换的信息,实际上就是Executors之上数据的状态。说到这里,你可能会问:“既然BlockManagerMaster的信息都来自于BlockManager,那么BlockManager又是从哪里获取到这些信息的呢?”要回答这个问题,我们还要从BlockManager的职责说起。
|
||||
|
||||
我们开头说过,存储系统的服务对象有3个:分别是Shuffle中间文件、RDD Cache以及广播变量,而BlockManager的职责,正是在Executors中管理这3类数据的存储、读写与收发。就存储介质来说,这3类数据所消耗的硬件资源各不相同。
|
||||
|
||||
具体来说,Shuffle中间文件消耗的是节点磁盘,而广播变量主要占用节点的内存空间,RDD Cache则是“脚踏两条船”,既可以消耗内存,也可以消耗磁盘。
|
||||
|
||||
|
||||
|
||||
不管是在内存、还是在磁盘,这些数据都是以数据块(Blocks)为粒度进行存取与访问的。数据块的概念与RDD数据分区(Partitions)是一致的,在RDD的上下文中,说到数据划分的粒度,我们往往把一份数据称作“数据分区”。而在存储系统的上下文中,对于细分的一份数据,我们称之为数据块。
|
||||
|
||||
有了数据块的概念,我们就可以进一步细化BlockManager的职责。BlockManager的核心职责,在于管理数据块的元数据(Meta data),这些元数据记录并维护数据块的地址、位置、尺寸以及状态。为了让你直观地感受一下元数据,我把它的样例放到了下面的示意图里,你可以看一看。
|
||||
|
||||
|
||||
|
||||
只有借助元数据,BlockManager才有可能高效地完成数据的存与取、收与发。这就回答了前面我提出的问题,BlockManager与数据状态有关的所有信息,全部来自于元数据的管理。那么接下来的问题是,结合这些元数据,BlockManager如何完成数据的存取呢?
|
||||
|
||||
不管是工地上,还是仓库里,这些场所都是尘土飞扬、人来人往,像存取建材这种事情,养尊处优的小布劳克自然不会亲力亲为。于是,他招募了两个帮手,来帮他打理这些脏活累活。
|
||||
|
||||
这两个帮手也都不是外人,一个是大表姐迈美瑞(MemoryStore),另一个是大表哥迪斯克(DiskStore)。顾名思义,MemoryStore负责内存中的数据存取,而相应地,DiskStore则负责磁盘中的数据访问。
|
||||
|
||||
好啦,到此为止,存储系统的重要角色已经悉数登场,我把他们整理到了下面的表格中。接下来,我们以RDD Cache和Shuffle中间文件的存取为例,分别说一说迈美瑞和迪斯克是如何帮助小布劳克来打理数据的。
|
||||
|
||||
|
||||
|
||||
MemoryStore:内存数据访问
|
||||
|
||||
大表姐迈美瑞秀外慧中,做起事情来井井有条。为了不辜负小布劳克的托付,迈美瑞随身携带着一本小册子,这本小册子密密麻麻,记满了关于数据块的详细信息。这个小册子,是一种特别的数据结构:LinkedHashMap[BlockId, MemoryEntry]。顾名思义,LinkedHashMap是一种Map,其中键值对的Key是BlockId,Value是MemoryEntry。
|
||||
|
||||
|
||||
|
||||
BlockId用于标记Block的身份,需要注意的是,BlockId不是一个仅仅记录Id的字符串,而是一种记录Block元信息的数据结构。BlockId这个数据结构记录的信息非常丰富,包括Block名字、所属RDD、Block对应的RDD数据分区、是否为广播变量、是否为Shuffle Block,等等。
|
||||
|
||||
MemoryEntry是对象,它用于承载数据实体,数据实体可以是某个RDD的数据分区,也可以是广播变量。存储在LinkedHashMap当中的MemoryEntry,相当于是通往数据实体的地址。
|
||||
|
||||
不难发现,BlockId和MemoryEntry一起,就像是居民户口簿一样,完整地记录了存取某个数据块所需的所有元信息,相当于“居民姓名”、“所属派出所”、“家庭住址”等信息。基于这些元信息,我们就可以像“查户口”一样,有的放矢、精准定向地对数据块进行存取访问。
|
||||
|
||||
val rdd: RDD[_] = _
|
||||
rdd.cache
|
||||
rdd.count
|
||||
|
||||
|
||||
以RDD Cache为例,当我们使用上述代码创建RDD缓存的时候,Spark会在后台帮我们做如下3件事情,这个过程我把它整理到了下面的示意图中,你可以看一看。
|
||||
|
||||
|
||||
以数据分区为粒度,计算RDD执行结果,生成对应的数据块;
|
||||
将数据块封装到MemoryEntry,同时创建数据块元数据BlockId;
|
||||
将(BlockId,MemoryEntry)键值对添加到“小册子”LinkedHashMap。
|
||||
|
||||
|
||||
|
||||
|
||||
随着RDD Cache过程的推进,LinkedHashMap当中的元素会越积越多,当迈美瑞的小册子完成记录的时候,Spark就可以通过册子上的“户口簿”来访问每一个数据块,从而实现对RDD Cache的读取与访问。
|
||||
|
||||
DiskStore:磁盘数据访问
|
||||
|
||||
说完大表姐,接下来,我们再来说说大表哥迪斯克。迪斯克的主要职责,是通过维护数据块与磁盘文件的对应关系,实现磁盘数据的存取访问。相比大表姐的一丝不苟、亲力亲为,迪斯克要“鸡贼”得多,他跟布劳克一样,都是甩手掌柜。
|
||||
|
||||
看到大表姐没日没夜地盯着自己的“小册子”,迪斯克可不想无脑地给布劳克卖命,于是他招募了一个帮手:DiskBlockManager,来帮他维护元数据。
|
||||
|
||||
有了DiskBlockManager这个帮手给他打理各种杂事,迪斯克这个家伙就可以哼着小曲、喝着咖啡,坐在仓库门口接待来来往往的施工工人就好了。这些工人有的存货,有的取货,但不论是干什么的,迪斯克会统一把他们打发到DiskBlockManager那里去,让DiskBlockManager告诉他们货物都存在哪些货架的第几层。
|
||||
|
||||
|
||||
|
||||
帮手DiskBlockManager是类对象,它的getFile方法以BlockId为参数,返回磁盘文件。换句话说,给定数据块,要想知道它存在了哪个磁盘文件,需要调用getFile方法得到答案。有了数据块与文件之间的映射关系,我们就可以轻松地完成磁盘中的数据访问。
|
||||
|
||||
以Shuffle为例,在Shuffle Write阶段,每个Task都会生成一份中间文件,每一份中间文件都包括带有data后缀的数据文件,以及带着index后缀的索引文件。那么对于每一份文件来说,我们都可以通过DiskBlockManager的getFile方法,来获取到对应的磁盘文件,如下图所示。
|
||||
|
||||
|
||||
|
||||
可以看到,获取data文件与获取index文件的流程是完全一致的,他们都是使用BlockId来调用getFile方法,从而完成数据访问。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,我们重点讲解了Spark存储系统。关于存储系统,你首先需要知道是,RDD Cache、Shuffle中间文件与广播变量这三类数据,是存储系统最主要的服务对象。
|
||||
|
||||
接着,我们介绍了存储系统的核心组件,它们是坐落在Driver端的BlockManagerMaster,以及“驻守”在Executors的BlockManager、MemoryStore和DiskStore。BlockManagerMaster与众多BlockManager之间通过心跳来完成信息交换,这些信息包括数据块的地址、位置、大小和状态,等等。
|
||||
|
||||
在Executors中,BlockManager通过MemoryStore来完成内存的数据存取。MemoryStore通过一种特殊的数据结构:LinkedHashMap来完成BlockId到MemoryEntry的映射。其中,BlockId记录着数据块的元数据,而MemoryEntry则用于封装数据实体。
|
||||
|
||||
与此同时,BlockManager通过DiskStore来实现磁盘数据的存取与访问。DiskStore并不直接维护元数据列表,而是通过DiskBlockManager这个对象,来完成从数据库到磁盘文件的映射,进而完成数据访问。
|
||||
|
||||
|
||||
|
||||
每课一练
|
||||
|
||||
LinkedHashMap是一种很特殊的数据结构,在今天这一讲,我们仅介绍了它在Map方面的功用。你可以试着自己梳理一下LinkedHashMap这种数据结构的特点与特性。
|
||||
|
||||
期待在留言区看到你的思考。如果这一讲对你有帮助,也推荐你转发给更多的同事、朋友。我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
171
专栏/零基础入门Spark/12基础配置详解:哪些参数会影响应用程序稳定性?.md
Normal file
171
专栏/零基础入门Spark/12基础配置详解:哪些参数会影响应用程序稳定性?.md
Normal file
@ -0,0 +1,171 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 基础配置详解:哪些参数会影响应用程序稳定性?
|
||||
你好,我是吴磊。
|
||||
|
||||
国庆假期即将结束,我们的基础模块也即将收尾。到目前为止,我们一起学习了RDD编程模型、Spark分布式部署、Spark工作原理,以及RDD常用算子。恭喜你,到这里,可以说你已经完全跨入了Spark分布式应用开发的大门。有了现在的知识储备,对于大多数的业务需求,我相信你都能很快地实现。
|
||||
|
||||
不过,快速用代码实现各式各样的业务需求,这还只是第一步。我们不光要让代码跑起来,还需要让代码跑得又快又稳。
|
||||
|
||||
要想做到这些,我们还需要配置项来帮忙。如果把Spark看作是一部F1赛车的话,那么配置项就是赛车车身的各项配置参数,如发动机缸数、最大转矩、车身轴距、悬挂方式、整车装备质量,等等。只有合理地配置车身参数,才能让车子本身的稳定性和性能得到保障,为选手的出色发挥奠定基础。
|
||||
|
||||
今天这一讲,我们就来说一说Spark都有哪些配置项,以及这些配置项的含义与作用。
|
||||
|
||||
配置项
|
||||
|
||||
打开Spark官网的Configuration页面,在这里你能找到全部的Spark配置项。
|
||||
|
||||
不过,让人沮丧的是,配置项数目过于庞大,种类繁多,有的需要设置true/false,有的则需要我们给出明确的数值,让人看上去眼花缭乱、无所适从。
|
||||
|
||||
|
||||
|
||||
那么问题来了,面对这么多的配置项,我们应该从哪里入手呢?别着急,既然我们的目的是让车子“跑得稳”、“跑得快”,那咱们不妨从这两个角度出发,来整理那些我们必须要掌握的配置项。
|
||||
|
||||
在这一讲,咱们先来梳理那些能让Spark跑得稳的配置项,而在后续介绍Spark SQL的时候,我们再去关注那些与“跑得快”有关的部分。
|
||||
|
||||
关于跑得稳这件事,你可能会有这样的疑问:“一般的车子,出厂就能开,并不需要特别调整什么车辆参数。同理,大部分Spark配置项都有默认值,开发者使用出厂设置、省去调参的麻烦,它不香吗?” 遗憾的是,对于大多数的应用场景来说,在默认的参数设置下,Spark还真就跑不起来。
|
||||
|
||||
以spark.executor.memory这个配置项为例,它用于指定Executor memory,也就是Executor可用内存上限。这个参数的默认值是1GB,显然,对于动辄上百GB、甚至上TB量级的工业级数据来说,这样的设置太低了,分布式任务很容易因为OOM(内存溢出,Out of memory)而中断。
|
||||
|
||||
你看,为了能让Spark跑得稳,咱们还是得花些心思。对于刚才说的情况,如果你以为直接把内存参数设置到上百GB,就可以一劳永逸,那未免有些草率。单纯从资源供给的角度去调整配置项参数,是一种“简单粗暴”的做法,并不可取。实际上,应用程序运行得稳定与否,取决于硬件资源供给与计算需要是否匹配。
|
||||
|
||||
这就好比是赛车组装,要得到一辆高性能的车子,我们并不需要每一个部件都达到“顶配”的要求,而是要让组装配件之间相互契合、匹配,才能让车子达到预期的马力输出。
|
||||
|
||||
因此,咱们不妨从硬件资源的角度切入,去探索开发者必须要关注的配置项都有哪些。既然上面我们用内存举例,而且关于内存的配置项,我们在内存管理那一讲简单提过,你可能还有一些印象,那么接下来,我们就从内存入手,说一说和它有关的配置项。
|
||||
|
||||
内存
|
||||
|
||||
说起内存,咱们不妨先来回顾一下Spark的内存划分。对于给定的Executor Memory,Spark将JVM Heap划分为4个区域,分别是Reserved Memory、User Memory、Execution Memory和Storage Memory,如下图所示。
|
||||
|
||||
不同内存区域的含义和它们的计算公式,我们在[第8讲]做过详细讲解,如果你印象不深了可以回顾一下,这里我们重点分析一下这些内存配置项数值的设置思路。
|
||||
|
||||
|
||||
|
||||
结合图解,其中Reserved Memory大小固定为300MB,其他3个区域的空间大小,则有3个配置项来划定,它们分别是spark.executor.memory、spark.memory.fraction、spark.memory.storageFraction。
|
||||
|
||||
为了后续叙述方便,我们分别把它们简称为M、mf和sf,其中大写的M是绝对值,而小写的mf和sf都是比例值,这一点需要你注意。
|
||||
|
||||
其中,M用于指定划分给Executor进程的JVM Heap大小,也即是Executor Memory。Executor Memory由Execution Memory、Storage Memory和User Memory“这三家”瓜分。
|
||||
|
||||
(M – 300)* mf划分给Execution Memory和Storage Memory,而User Memory空间大小由(M – 300)*(1 - mf)这个公式划定,它用于存储用户自定义的数据结构,比如,RDD算子中包含的各类实例化对象或是集合类型(如数组、列表等),都属于这个范畴。
|
||||
|
||||
因此,如果你的分布式应用,并不需要那么多自定义对象或集合数据,你应该把mf的值设置得越接近1越好,这样User Memory无限趋近于0,大面积的可用内存就可以都留给Execution Memory和Storage Memory了。
|
||||
|
||||
我们知道,在1.6版本之后,Spark推出了统一的动态内存管理模式,在对方资源未被用尽的时候,Execution Memory与Storage Memory之间可以互相进行抢占。不过,即便如此,我们仍然需要sf这个配置项来划定它们之间的那条虚线,从而明确告知Spark我们开发者更倾向于“偏袒”哪一方。
|
||||
|
||||
那么对于sf的设置,开发者该如何进行取舍呢?答案是看数据的复用频次。这是什么意思呢?我们分场景举例来说。
|
||||
|
||||
对于ETL(Extract、Transform、Load)类型的作业来说,数据往往都是按照既定的业务逻辑依序处理,其中绝大多数的数据形态只需访问一遍,很少有重复引用的情况。
|
||||
|
||||
因此,在ETL作业中,RDD Cache并不能起到提升执行性能的作用,那么自然我们也就没必要使用缓存了。在这种情况下,我们就应当把sf的值设置得低一些,压缩Storage Memory可用空间,从而尽量把内存空间留给Execution Memory。
|
||||
|
||||
相反,如果你的应用场景是机器学习、或是图计算,这些计算任务往往需要反复消耗、迭代同一份数据,处理方式就不一样了。在这种情况下,咱们要充分利用RDD Cache提供的性能优势,自然就要把sf这个参数设置得稍大一些,从而让Storage Memory有足够的内存空间,来容纳需要频繁访问的分布式数据集。
|
||||
|
||||
好啦,到此为止,对于内存的3个配置项,我们分别解读了它们的含义,以及设置的一般性原则。你需要根据你的应用场景,合理设置这些配置项,这样程序运行才会高速、稳定。学会了这些,内存配置项这一关,你基本上已经拿到80分了。而剩下的20分,需要你从日常开发的反复实践中去获取,期待你总结归纳出更多的配置经验。
|
||||
|
||||
在硬件资源方面,内存的服务对象是CPU。内存的有效配置,一方面是为了更好地容纳数据,另一方面,更重要的就是提升CPU的利用率。那说完内存,接下来,我们再来看看CPU。
|
||||
|
||||
CPU
|
||||
|
||||
与CPU直接相关的配置项,我们只需关注两个参数,它们分别是spark.executor.instances和spark.executor.cores。其中前者指定了集群内Executors的个数,而后者则明确了每个Executors可用的CPU Cores(CPU核数)。
|
||||
|
||||
我们知道,一个CPU Core在同一时间只能处理一个分布式任务,因此,spark.executor.instances与spark.executor.cores的乘积实际上决定了集群的并发计算能力,这个乘积,我们把它定义为“并发度”(Degree of concurrency)。
|
||||
|
||||
说到并发度,我们就不得不说另外一个概念:并行度(Degree of parallism)。相比并发度,并行度是一个高度相关、但又完全不同的概念。并行度用于定义分布式数据集划分的份数与粒度,它直接决定了分布式任务的计算负载。并行度越高,数据的粒度越细,数据分片越多,数据越分散。
|
||||
|
||||
这也就解释了,并行度为什么总是跟分区数量、分片数量、Partitions 这些属性相一致。举个例子,第9讲我们就说过,并行度对应着RDD的数据分区数量。
|
||||
|
||||
与并行度相关的配置项也有两个,分别是spark.default.parallelism和spark.sql.shuffle.partitions。其中前者定义了由SparkContext.parallelize API所生成RDD的默认并行度,而后者则用于划定Shuffle过程中Shuffle Read阶段(Reduce阶段)的默认并行度。
|
||||
|
||||
对比下来,并发度的出发点是计算能力,它与执行内存一起,共同构成了计算资源的供给水平,而并行度的出发点是数据,它决定着每个任务的计算负载,对应着计算资源的需求水平。一个是供给,一个是需求,供需的平衡与否,直接影响着程序运行的稳定性。
|
||||
|
||||
CPU、内存与数据的平衡
|
||||
|
||||
由此可见,所谓供需的平衡,实际上就是指CPU、内存与数据之间的平衡。那么问题来了,有没有什么量化的办法,来让三者之间达到供需之间平衡的状态呢?其实,只需要一个简单的公式,我们就可以轻松地做到这一点。
|
||||
|
||||
为了叙述方便,我们把由配置项spark.executor.cores指定的CPU Cores记为c,把Execution Memory内存大小记为m,还记得吗?m的尺寸由公式(M - 300)* mf *(1 - sf)给出。不难发现,c和m,一同量化了一个Executor的可用计算资源。
|
||||
|
||||
量化完资源供给,我们接着再来说数据。对于一个待计算的分布式数据集,我们把它的存储尺寸记为D,而把其并行度记录为P。给定D和P,不难推出,D/P就是分布式数据集的划分粒度,也就是每个数据分片的存储大小。
|
||||
|
||||
学习过调度系统,我们知道,在Spark分布式计算的过程中,一个数据分片对应着一个Task(分布式任务),而一个Task又对应着一个CPU Core。因此,把数据看作是计算的需求方,要想达到CPU、内存与数据这三者之间的平衡,我们必须要保证每个Task都有足够的内存,来让CPU处理对应的数据分片。
|
||||
|
||||
为此,我们要让数据分片大小与Task可用内存之间保持在同一量级,具体来说,我们可以使用下面的公式来进行量化。
|
||||
|
||||
D/P ~ m/c
|
||||
|
||||
|
||||
其中,波浪线的含义,是其左侧与右侧的表达式在同一量级。左侧的表达式D/P为数据分片大小,右侧的m/c为每个Task分到的可用内存。以这个公式为指导,结合分布式数据集的存储大小,我们就可以有的放矢、有迹可循地对上述的3类配置项进行设置或调整,也就是与CPU、内存和并行度有关的那几个配置项。
|
||||
|
||||
磁盘
|
||||
|
||||
说完了CPU和内存,接下来,我们再来说说磁盘。与前两者相比,磁盘的配置项相对要简单得多,值得我们关注的,仅有spark.local.dir这一个配置项,为了叙述方便,后续我们把它简称为ld。这个配置项的值可以是任意的本地文件系统目录,它的默认值是/tmp目录。
|
||||
|
||||
ld参数对应的目录用于存储各种各样的临时数据,如Shuffle中间文件、RDD Cache(存储级别包含“disk”),等等。这些临时数据,对程序能否稳定运行,有着至关重要的作用。
|
||||
|
||||
例如,Shuffle中间文件是Reduce阶段任务执行的基础和前提,如果中间文件丢失,Spark在Reduce阶段就会抛出“Shuffle data not found”异常,从而中断应用程序的运行。
|
||||
|
||||
既然这些临时数据不可或缺,我们就不能盲从默认选项了,而是有必要先考察下/tmp目录的情况。遗憾的是,ld参数默认的/tmp目录一来存储空间有限,二来该目录本身的稳定性也值得担忧。因此,在工业级应用中,我们通常都不能接受使用/tmp目录来设置ld配置项。
|
||||
|
||||
了解了ld这个配置项的作用之后,我们自然就能想到,应该把它设置到一个存储空间充沛、甚至性能更有保障的文件系统,比如空间足够大的SSD(Solid State Disk)文件系统目录。
|
||||
|
||||
好啦,到此为止,我们分别介绍了与CPU、内存、磁盘有关的配置项,以及它们的含义、作用与设置技巧。说到这里,你可能有些按捺不住:“这些配置项的重要性我已经get到了,那我应该在哪里设置它们呢?”接下来,我们继续来说说,开发者都可以通过哪些途径来设置配置项。
|
||||
|
||||
配置项的设置途径
|
||||
|
||||
为了满足不同的应用场景,Spark为开发者提供了3种配置项设置方式,分别是配置文件、命令行参数和SparkConf对象,这些方式都以(Key,Value)键值对的形式记录并设置配置项。
|
||||
|
||||
配置文件指的是spark-defaults.conf,这个文件存储在Spark安装目录下面的conf子目录。该文件中的参数设置适用于集群范围内所有的应用程序,因此它的生效范围是全局性的。对于任意一个应用程序来说,如果开发者没有通过其他方式设置配置项,那么应用将默认采用spark-defaults.conf中的参数值作为基础设置。
|
||||
|
||||
在spark-defaults.conf中设置配置项,你只需要用空格把配置项的名字和它的设置值分隔开即可。比如,以spark.executor.cores、spark.executor.memory和spark.local.dir这3个配置项为例,我们可以使用下面的方式对它们的值进行设置。
|
||||
|
||||
spark.executor.cores 2
|
||||
spark.executor.memory 4g
|
||||
spark.local.dir /ssd_fs/large_dir
|
||||
|
||||
|
||||
不过,在日常的开发工作中,不同应用对于资源的诉求是不一样的:有些需要更多的CPU Cores,有些则需要更高的并行度,凡此种种、不一而足,可谓是众口难调,这个时候,我们只依赖spark-defaults.conf来进行全局设置就不灵了。
|
||||
|
||||
为此,Spark为开发者提供了两种应用级别的设置方式,也即命令行参数和SparkConf对象,它们的生效范围仅限于应用本身,我们分别看看这两种方式具体怎么用。
|
||||
|
||||
先说命令行参数,它指的是在运行了spark-shell或是spark-submit命令之后,通过–conf关键字来设置配置项。我们知道,spark-shell用于启动交互式的分布式运行环境,而spark-submit则用于向Spark计算集群提交分布式作业。
|
||||
|
||||
还是以刚刚的3个配置项为例,以命令行参数的方式进行设置的话,你需要在提交spark-shell或是spark-submit命令的时候,以–conf Key=Value的形式对参数进行赋值。
|
||||
|
||||
spark-shell --master local[*] --conf spark.executor.cores=2 --conf spark.executor.memory=4g --conf spark.local.dir=/ssd_fs/large_dir
|
||||
|
||||
|
||||
不难发现,尽管这种方式能让开发者在应用级别灵活地设置配置项,但它的书写方式过于繁琐,每个配置项都需要以–conf作前缀。不仅如此,命令行参数的设置方式不利于代码管理,随着时间的推移,参数值的设置很可能会随着数据量或是集群容量的变化而变化,但是这个变化的过程却很难被记录并维护下来,而这无疑会增加开发者与运维同学的运维成本。
|
||||
|
||||
相比之下,不论是隔离性还是可维护性,SparkConf对象的设置方式都更胜一筹。在代码开发的过程中,我们可以通过定义SparkConf对象,并调用其set方法来对配置项进行设置。老规矩,还是用刚刚的CPU、内存和磁盘3个配置项来举例。
|
||||
|
||||
import org.apache.spark.SparkConf
|
||||
val conf = new SparkConf()
|
||||
conf.set("spark.executor.cores", "2")
|
||||
conf.set("spark.executor.memory", "4g")
|
||||
conf.set("spark.local.dir", "/ssd_fs/large_dir")
|
||||
|
||||
|
||||
好啦,到此为止,我们一起梳理了CPU、内存、磁盘的相关配置项,并重点强调了CPU、内存与数据之间的供需平衡。掌握了这些设置方法与要点之后,你不妨自己动手去试试这些配置项,可以拿之前的Word Count小例子练练手,巩固一下今天所学的内容。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,我们分别从CPU、内存和磁盘三个方面,讲解了影响应用程序稳定性的几个重要参数。你需要掌握它们的含义、作用还有适用场景,为了方便你记忆,我把它们整理到后面的表格里,你可以随时拿来参考。
|
||||
|
||||
|
||||
|
||||
熟悉了这些关键配置项之后,你还需要了解它们的设置方式。Spark提供了3种配置项设置途径,分别是spark-defaults.conf配置文件、命令行参数和SparkConf对象。其中第一种方式用于全局设置,而后两者的适用范围是应用本身。
|
||||
|
||||
对于这3种方式,Spark会按照“SparkConf对象 -> 命令行参数 -> 配置文件”的顺序,依次读取配置项的参数值。对于重复设置的配置项,Spark以前面的参数取值为准。
|
||||
|
||||
每课一练
|
||||
|
||||
请你粗略地过一遍Spark官网中的 Configuration页面,说一说,其中哪些配置项适合在spark-defaults.conf中进行设置,而哪些配置项使用SparkConf对象的方式来设置比较好?
|
||||
|
||||
欢迎你在留言区跟我交流。如果这一讲对你有帮助的话,也推荐你把这节课分享给有需要的的同事、朋友,我们下一讲见。
|
||||
|
||||
|
||||
|
||||
|
222
专栏/零基础入门Spark/13SparkSQL:让我们从“小汽车摇号分析”开始.md
Normal file
222
专栏/零基础入门Spark/13SparkSQL:让我们从“小汽车摇号分析”开始.md
Normal file
@ -0,0 +1,222 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 Spark SQL:让我们从“小汽车摇号分析”开始
|
||||
你好,我是吴磊。
|
||||
|
||||
在开篇词我们提出“入门Spark需要三步走”,到目前为止,我们携手并肩跨越了前面两步,首先恭喜你学到这里!熟练掌握了Spark常用算子与核心原理以后,你已经可以轻松应对大部分数据处理需求了。
|
||||
|
||||
不过,数据处理毕竟是比较基础的数据应用场景,就像赛车有着不同的驾驶场景,想成为Spark的资深赛车手,我们还要走出第三步——学习Spark计算子框架。只有完成这一步,我们才能掌握Spark SQL,Structured Streaming和Spark MLlib的常规开发方法,游刃有余地应对不同的数据应用场景,如数据分析、流计算和机器学习,等等。
|
||||
|
||||
|
||||
|
||||
那这么多子框架,从哪里入手比较好呢?在所有的子框架中,Spark SQL是代码量最多、Spark社区投入最大、应用范围最广、影响力最深远的那个。就子框架的学习来说,我们自然要从Spark SQL开始。
|
||||
|
||||
今天我们从一个例子入手,在实战中带你熟悉数据分析开发的思路和实现步骤。有了对Spark SQL的直观体验,我们后面几讲还会深入探讨Spark SQL的用法、特性与优势,让你逐步掌握Spark SQL的全貌。
|
||||
|
||||
业务需求
|
||||
|
||||
今天我们要讲的小例子,来自于北京市小汽车摇号。我们知道,为了限制机动车保有量,从2011年开始,北京市政府推出了小汽车摇号政策。随着摇号进程的推进,在2016年,为了照顾那些长时间没有摇中号码牌的“准司机”,摇号政策又推出了“倍率”制度。
|
||||
|
||||
所谓倍率制度,它指的是,结合参与摇号次数,为每个人赋予不同的倍率系数。有了倍率加持,大家的中签率就由原来整齐划一的基础概率,变为“基础概率 * 倍率系数”。参与摇号的次数越多,倍率系数越大,中签率也会相应得到提高。
|
||||
|
||||
不过,身边无数的“准司机”总是跟我说,其实倍率这玩意没什么用,背了8倍、10倍的倍率,照样摇不上!那么今天这一讲,咱们就来借着学习Spark SQL的机会,用数据来为这些还没摸过车的“老司机”答疑解惑,帮他们定量地分析一下,倍率与中签率之间,到底有没有关系?
|
||||
|
||||
准备工作
|
||||
|
||||
巧妇难为无米之炊,既然是做数据分析,那咱们得先有数据才行。我这边为你准备了2011年到2019年北京市小汽车的摇号数据,你可以通过这个地址,从网盘进行下载,提取码为ajs6。
|
||||
|
||||
这份数据的文件名是“2011-2019 小汽车摇号数据.tar.gz”,解压之后的目录结构如下图所示。
|
||||
|
||||
可以看到,根目录下有apply和lucky两个子目录,apply目录的内容是 2011-2019 年各个批次参与摇号的申请号码,而lucky目录包含的是各个批次中签的申请号码。为了叙述方便,我们把参与过摇号的人叫“申请者”,把中签的人叫“中签者”。apply和lucky的下一级子目录是各个摇号批次,而摇号批次目录下包含的是Parquet格式的数据文件。
|
||||
|
||||
|
||||
|
||||
数据下载、解压完成之后,接下来,我们再来准备运行环境。
|
||||
|
||||
咱们的小例子比较轻量,Scala版本的代码实现不会超过20行,再者摇号数据体量很小,解压之后的Parquet文件总大小也不超过4G。
|
||||
|
||||
选择这样的例子也是为了轻装上阵,避免你因为硬件限制而难以实验。想要把用于分析倍率的应用跑起来,你在笔记本或是PC上,通过启动本地spark-shell环境就可以。不过,如果条件允许的话,我还是鼓励你搭建分布式的物理集群。关于分布式集群的搭建细节,你可以参考[第4讲]。
|
||||
|
||||
好啦,准备好数据与运行环境之后,接下来,我们就可以步入正题,去开发探索倍率与中签率关系的数据分析应用啦。
|
||||
|
||||
数据探索
|
||||
|
||||
不过,先别忙着直接上手数据分析。在此之前,我们先要对数据模式(Data Schema)有最基本的认知,也就是源数据都有哪些字段,这些字段的类型和含义分别是什么,这一步就是我们常说的数据探索。
|
||||
|
||||
数据探索的思路是这样的:首先,我们使用SparkSession的read API读取源数据、创建DataFrame。然后,通过调用DataFrame的show方法,我们就可以轻松获取源数据的样本数据,从而完成数据的初步探索,代码如下所示。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
val rootPath: String = _
|
||||
// 申请者数据
|
||||
val hdfs_path_apply: String = s"${rootPath}/apply"
|
||||
// spark是spark-shell中默认的SparkSession实例
|
||||
// 通过read API读取源文件
|
||||
val applyNumbersDF: DataFrame = spark.read.parquet(hdfs_path_apply)
|
||||
// 数据打印
|
||||
applyNumbersDF.show
|
||||
|
||||
// 中签者数据
|
||||
val hdfs_path_lucky: String = s"${rootPath}/lucky"
|
||||
// 通过read API读取源文件
|
||||
val luckyDogsDF: DataFrame = spark.read.parquet(hdfs_path_lucky)
|
||||
// 数据打印
|
||||
luckyDogsDF.show
|
||||
|
||||
|
||||
看到这里,想必你已经眉头紧锁:“SparkSession?DataFrame?这些都是什么鬼?你好像压根儿也没有提到过这些概念呀!”别着急,对于这些关键概念,我们在后续的课程中都会陆续展开,今天这一讲,咱们先来“知其然”,“知其所以然”的部分咱们放到后面去讲。
|
||||
|
||||
对于SparkSession,你可以把它理解为是SparkContext的进阶版,是Spark(2.0版本以后)新一代的开发入口。SparkContext通过textFile API把源数据转换为RDD,而SparkSession通过read API把源数据转换为DataFrame。
|
||||
|
||||
而DataFrame,你可以把它看作是一种特殊的RDD。RDD我们已经很熟悉了,现在就把DataFrame跟RDD做个对比,让你先对DataFrame有个感性认识。
|
||||
|
||||
先从功能分析,与RDD一样,DataFrame也用来封装分布式数据集,它也有数据分区的概念,也是通过算子来实现不同DataFrame之间的转换,只不过DataFrame采用了一套与RDD算子不同的独立算子集。
|
||||
|
||||
再者,在数据内容方面,与RDD不同,DataFrame是一种带Schema的分布式数据集,因此,你可以简单地把DataFrame看作是数据库中的一张二维表。
|
||||
|
||||
最后,DataFrame背后的计算引擎是Spark SQL,而RDD的计算引擎是Spark Core,这一点至关重要。不过,关于计算引擎之间的差异,我们留到[下一讲]再去展开。
|
||||
|
||||
好啦,言归正传。简单了解了SparkSession与DataFrame的概念之后,我们继续来看数据探索。
|
||||
|
||||
把上述代码丢进spark-shell之后,分别在applyNumbersDF和luckyDogsDF这两个DataFrame之上调用show函数,我们就可以得到样本数据。可以看到,“这两张表”的Schema是一样的,它们都包含两个字段,一个是String类型的carNum,另一个是类型为Int的batchNum。
|
||||
|
||||
|
||||
|
||||
其中,carNum的含义是申请号码、或是中签号码,而batchNum则代表摇号批次,比如201906表示2019年的最后一批摇号,201401表示2014年的第一次摇号。
|
||||
|
||||
好啦,进行到这里,初步的数据探索工作就告一段落了。
|
||||
|
||||
业务需求实现
|
||||
|
||||
完成初步的数据探索之后,我们就可以结合数据特点(比如两张表的Schema完全一致,但数据内容的范畴不同),来实现最开始的业务需求:计算中签率与倍率之间的量化关系。
|
||||
|
||||
首先,既然是要量化中签率与倍率之间的关系,我们只需要关注那些中签者(lucky目录下的数据)的倍率变化就好了。而倍率的计算,要依赖apply目录下的摇号数据。因此,要做到仅关注中签者的倍率,我们就必须要使用数据关联这个在数据分析领域中最常见的操作。此外,由于倍率制度自2016年才开始推出,所以我们只需要访问2016年以后的数据即可。
|
||||
|
||||
基于以上这些分析,我们先把数据过滤与数据关联的代码写出来,如下所示。
|
||||
|
||||
// 过滤2016年以后的中签数据,且仅抽取中签号码carNum字段
|
||||
val filteredLuckyDogs: DataFrame = luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum")
|
||||
|
||||
// 摇号数据与中签数据做内关联,Join Key为中签号码carNum
|
||||
val jointDF: DataFrame = applyNumbersDF.join(filteredLuckyDogs, Seq("carNum"), "inner")
|
||||
|
||||
|
||||
在上面的代码中,我们使用filter算子对luckyDogsDF做过滤,然后使用select算子提取carNum字段。
|
||||
|
||||
紧接着,我们在applyNumbersDF之上调用join算子,从而完成两个DataFrame的数据关联。join算子有3个参数,你可以对照前面代码的第5行来理解,这里第一个参数用于指定需要关联的DataFrame,第二个参数代表Join Key,也就是依据哪些字段做关联,而第三个参数指定的是关联形式,比如inner表示内关联,left表示左关联,等等。
|
||||
|
||||
做完数据关联之后,接下来,我们再来说一说,倍率应该怎么统计。对于倍率这个数值,官方的实现略显粗暴,如果去观察 apply 目录下 2016 年以后各个批次的文件,你就会发现,所谓的倍率,实际上就是申请号码的副本数量。
|
||||
|
||||
比如说,我的倍率是8,那么在各个批次的摇号文件中,我的申请号码就会出现8次。是不是很粗暴?因此,要统计某个申请号码的倍率,我们只需要统计它在批次文件中出现的次数就可以达到目的。
|
||||
|
||||
按照批次、申请号码做统计计数,是不是有种熟悉的感觉?没错,这不就是我们之前学过的Word Count吗?它本质上其实就是一个分组计数的过程。不过,这一次,咱们不再使用reduceByKey这个RDD算子了,而是使用DataFrame的那套算子来实现,我们先来看代码。
|
||||
|
||||
val multipliers: DataFrame = jointDF.groupBy(col("batchNum"),col("carNum"))
|
||||
.agg(count(lit(1)).alias("multiplier"))
|
||||
|
||||
|
||||
分组计数
|
||||
|
||||
对照代码我给你分析下思路,我们先是用groupBy算子来按照摇号批次和申请号码做分组,然后通过agg和count算子把(batchNum,carNum)出现的次数,作为carNum在摇号批次batchNum中的倍率,并使用alias算子把倍率重命名为“multiplier”。
|
||||
|
||||
这么说可能有点绕,我们可以通过在multipliers之上调用show函数,来直观地观察这一步的计算结果。为了方便说明,我用表格的形式来进行示意。
|
||||
|
||||
|
||||
|
||||
可以看到,同一个申请号码,在不同批次中的倍率是不一样的。就像我们之前说的,随着摇号的次数增加,倍率也会跟着提升。不过,这里咱们要研究的是倍率与中签率的关系,所以只需要关心中签者是在多大的倍率下中签的就行。因此,对于同一个申请号码,我们只需要保留其中最大的倍率就可以了。
|
||||
|
||||
需要说明的是,取最大倍率的做法,会把倍率的统计基数变小,从而引入幸存者偏差。更严谨的做法,应该把中签者过往的倍率也都统计在内,这样倍率的基数才是准确的。不过呢,结合实验,幸存者偏差并不影响“倍率与中签率是否有直接关系”这一结论。因此,咱们不妨采用取最大倍率这种更加简便的做法。毕竟,学习Spark SQL,才是咱们的首要目标。
|
||||
|
||||
为此,我们需要“抹去”batchNum这个维度,按照carNum对multipliers做分组,并提取倍率的最大值,代码如下所示。
|
||||
|
||||
val uniqueMultipliers: DataFrame = multipliers.groupBy("carNum")
|
||||
.agg(max("multiplier").alias("multiplier"))
|
||||
|
||||
|
||||
分组聚合的方法跟前面差不多,我们还是先用groupBy做分组,不过这次仅用carNum一个字段做分组,然后使用agg和max算子来保留倍率最大值。经过这一步的计算之后,我们就得到了每个申请号码在中签之前的倍率系数:
|
||||
|
||||
|
||||
|
||||
可以看到,uniqueMultipliers这个DataFrame仅包含申请号码carNum和倍率multiplier这两个字段,且carNum字段不存在重复值,也就是说,在这份数据集中,一个申请号码,只有一个最大倍率与之对应。
|
||||
|
||||
好啦,到此为止,我们拿到了每一个中签者,在中签之前的倍率系数。接下来,结合这份数据,我们就可以统计倍率本身的分布情况。
|
||||
|
||||
具体来说,我们想知道的是,不同倍率之下的人数分布是什么样子的。换句话说,这一次,我们要按照倍率来对数据做分组,然后计算不同倍率下的统计计数。不用说,这次咱们还是得仰仗groupBy和agg这两个算子,代码如下所示。
|
||||
|
||||
val result: DataFrame = uniqueMultipliers.groupBy("multiplier")
|
||||
.agg(count(lit(1)).alias("cnt"))
|
||||
.orderBy("multiplier")
|
||||
|
||||
result.collect
|
||||
|
||||
|
||||
在最后一步,我们依然使用groupBy和agg算子如法炮制,得到按照倍率统计的人数分布之后,我们通过collect算子来收集计算结果,并同时触发上述的所有代码从头至尾交付执行。
|
||||
|
||||
计算结果result包含两个字段,一个是倍率,一个是持有该倍率的统计人数。如果把result结果数据做成柱状图的话,我们可以更加直观地观察到中签率与倍率之间的关系,如下图所示。
|
||||
|
||||
|
||||
|
||||
不难发现,不同倍率下的中签者人数,呈现出正态分布。也即是说,对于一个申请者来说,他/她有幸摇中的概率,并不会随着倍率的增加而线性增长。用身边那些“老司机”的话说,中签这件事,确实跟倍率的关系不大。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,我们一起动手,开发了“倍率的统计分布”这个数据分析应用,并解答了中签率与倍率之间是否存在关联关系这一难题。
|
||||
|
||||
尽管在实现的过程中,我们遇到了一些新概念和新的算子,但你不必担心,更不必着急。今天这节课,你只需要对Spark SQL框架下的应用开发有一个感性的认识就可以了。
|
||||
|
||||
在Spark SQL的开发框架下,我们通常是通过SparkSession的read API从源数据创建DataFrame。然后,以DataFrame为入口,在DataFrame之上调用各式各样的转换算子,如agg、groupBy、select、filter等等,对DataFrame进行转换,进而完成相应的数据分析。
|
||||
|
||||
为了后续试验方便,我把今天涉及的代码片段整理到了一起,你可以把它们丢进spark-shell去运行,观察每个环节的计算结果,体会不同算子的计算逻辑与执行结果之间的关系。加油,祝你好运!
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
val rootPath: String = _
|
||||
// 申请者数据
|
||||
val hdfs_path_apply: String = s"${rootPath}/apply"
|
||||
// spark是spark-shell中默认的SparkSession实例
|
||||
// 通过read API读取源文件
|
||||
val applyNumbersDF: DataFrame = spark.read.parquet(hdfs_path_apply)
|
||||
|
||||
// 中签者数据
|
||||
val hdfs_path_lucky: String = s"${rootPath}/lucky"
|
||||
// 通过read API读取源文件
|
||||
val luckyDogsDF: DataFrame = spark.read.parquet(hdfs_path_lucky)
|
||||
|
||||
// 过滤2016年以后的中签数据,且仅抽取中签号码carNum字段
|
||||
val filteredLuckyDogs: DataFrame = luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum")
|
||||
|
||||
// 摇号数据与中签数据做内关联,Join Key为中签号码carNum
|
||||
val jointDF: DataFrame = applyNumbersDF.join(filteredLuckyDogs, Seq("carNum"), "inner")
|
||||
|
||||
// 以batchNum、carNum做分组,统计倍率系数
|
||||
val multipliers: DataFrame = jointDF.groupBy(col("batchNum"),col("carNum"))
|
||||
.agg(count(lit(1)).alias("multiplier"))
|
||||
|
||||
// 以carNum做分组,保留最大的倍率系数
|
||||
val uniqueMultipliers: DataFrame = multipliers.groupBy("carNum")
|
||||
.agg(max("multiplier").alias("multiplier"))
|
||||
|
||||
// 以multiplier倍率做分组,统计人数
|
||||
val result: DataFrame = uniqueMultipliers.groupBy("multiplier")
|
||||
.agg(count(lit(1)).alias("cnt"))
|
||||
.orderBy("multiplier")
|
||||
|
||||
result.collect
|
||||
|
||||
|
||||
每课一练
|
||||
|
||||
|
||||
脑洞时间:你觉得汽车摇号的倍率制度应该怎样设计,才是最合理的?
|
||||
|
||||
请在你的Spark环境中把代码运行起来,并确认执行结果是否与result一致。
|
||||
|
||||
|
||||
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的朋友、同事。我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
191
专栏/零基础入门Spark/14台前幕后:DataFrame与SparkSQL的由来.md
Normal file
191
专栏/零基础入门Spark/14台前幕后:DataFrame与SparkSQL的由来.md
Normal file
@ -0,0 +1,191 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 台前幕后:DataFrame与Spark SQL的由来
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,结合“小汽车倍率分析”的例子,我们学习了在Spark SQL子框架下做应用开发的一般模式。我们先是使用SparkSession的read API来创建DataFrame,然后,以DataFrame为入口,通过调用各式各样的算子来完成不同DataFrame之间的转换,从而进行数据分析。
|
||||
|
||||
尽管我们说过,你可以把DataFrame看作是一种特殊的RDD,但你可能仍然困惑DataFrame到底跟RDD有什么本质区别。Spark已经有了RDD这个开发入口,为什么还要重复造轮子,整出个DataFrame来呢?
|
||||
|
||||
相信学完了上一讲,这些问题一定萦绕在你的脑海里,挥之不去。别着急,今天我们就来高屋建瓴地梳理一下DataFrame的来龙去脉,然后再追本溯源,看看帮助DataFrame崭露头角的幕后大佬Spark SQL又是怎么回事儿。
|
||||
|
||||
RDD之殇:优化空间受限
|
||||
|
||||
在RDD算子那一讲([第3讲]),我们曾经留过一道思考题,像map、mapPartitions、filter、flatMap这些算子,它们之间都有哪些共性?
|
||||
|
||||
今天,我们从一个全新的视角,来重新审视这个问题。先说结论,它们都是高阶函数(Higher-order Functions)。
|
||||
|
||||
所谓高阶函数,它指的是形参为函数的函数,或是返回类型为函数的函数。换句话说,高阶函数,首先本质上也是函数,特殊的地方在于它的形参和返回类型,这两者之中只要有一个是函数类型,那么原函数就属于高阶函数。
|
||||
|
||||
上面提到的这些算子,如map、filter,它们都需要一个辅助函数f来作为形参,通过调用map(f)、filter(f)才能完成计算。以map为例,我们需要函数f来明确对哪些字段做映射,以什么规则映射。filter也一样,我们需要函数f来指明以什么条件在哪些字段上过滤。
|
||||
|
||||
但是这样一来,Spark只知道开发者要做map、filter,但并不知道开发者打算怎么做map和filter。换句话说,对于Spark来说,辅助函数f是透明的。在RDD的开发框架下,Spark Core只知道开发者要“做什么”,而不知道“怎么做”。这让Spark Core两眼一抹黑,除了把函数f以闭包的形式打发到Executors以外,实在是没有什么额外的优化空间。而这,就是RDD之殇。
|
||||
|
||||
DataFrame横空出世
|
||||
|
||||
针对RDD优化空间受限的问题,Spark社区在1.3版本发布了DataFrame。那么,相比RDD,DataFrame到底有何不同呢?我们不妨从两个方面来对比它们的不同:一个是数据的表示形式(Data Representation),另一个是开发算子。
|
||||
|
||||
DataFrame与RDD一样,都是用来封装分布式数据集的。但在数据表示方面就不一样了,DataFrame是携带数据模式(Data Schema)的结构化数据,而RDD是不携带Schema的分布式数据集。恰恰是因为有了Schema提供明确的类型信息,Spark才能耳聪目明,有针对性地设计出更紧凑的数据结构,从而大幅度提升数据存储与访问效率。
|
||||
|
||||
在开发API方面,RDD算子多采用高阶函数,高阶函数的优势在于表达能力强,它允许开发者灵活地设计并实现业务逻辑。而DataFrame的表达能力却很弱,它定义了一套DSL算子(Domain Specific Language),如我们上一节课用到的select、filter、agg、groupBy,等等,它们都属于DSL算子。
|
||||
|
||||
DSL语言往往是为了解决某一类特定任务而设计,非图灵完备,因此在表达能力方面非常有限。DataFrame的算子大多数都是标量函数(Scalar Functions),它们的形参往往是结构化二维表的数据列(Columns)。
|
||||
|
||||
尽管DataFrame算子在表达能力方面更弱,但是DataFrame每一个算子的计算逻辑都是确定的,比如select用于提取某些字段,groupBy用于对数据做分组,等等。这些计算逻辑对Spark来说,不再是透明的,因此,Spark可以基于启发式的规则或策略,甚至是动态的运行时信息,去优化DataFrame的计算过程。
|
||||
|
||||
总结下来,相比RDD,DataFrame通过携带明确类型信息的Schema、以及计算逻辑明确的转换算子,为Spark引擎的内核优化打开了全新的空间。
|
||||
|
||||
幕后英雄:Spark SQL
|
||||
|
||||
那么问题来了,优化空间打开之后,真正负责优化引擎内核(Spark Core)的那个幕后英雄是谁?相信不用我说,你也能猜到,它就是Spark SQL。
|
||||
|
||||
想要吃透Spark SQL,我们先得弄清楚它跟Spark Core的关系。随着学习进程的推进,我们接触的新概念、知识点会越来越多,厘清Spark SQL与Spark Core的关系,有利于你构建系统化的知识体系和全局视角,从而让你在学习的过程中“既见树木、也见森林”。
|
||||
|
||||
首先,Spark Core特指Spark底层执行引擎(Execution Engine),它包括了我们在基础知识篇讲过的调度系统、存储系统、内存管理、Shuffle管理等核心功能模块。而Spark SQL则凌驾于Spark Core之上,是一层独立的优化引擎(Optimization Engine)。换句话说,Spark Core负责执行,而Spark SQL负责优化,Spark SQL优化过后的代码,依然要交付Spark Core来做执行。
|
||||
|
||||
|
||||
|
||||
再者,从开发入口来说,在RDD框架下开发的应用程序,会直接交付Spark Core运行。而使用DataFrame API开发的应用,则会先过一遍Spark SQL,由Spark SQL优化过后再交由Spark Core去做执行。
|
||||
|
||||
弄清二者的关系与定位之后,接下来的问题是:“基于DataFrame,Spark SQL是如何进行优化的呢?”要回答这个问题,我们必须要从Spark SQL的两个核心组件说起:Catalyst优化器和Tungsten。
|
||||
|
||||
先说Catalyst优化器,它的职责在于创建并优化执行计划,它包含3个功能模块,分别是创建语法树并生成执行计划、逻辑阶段优化和物理阶段优化。Tungsten用于衔接Catalyst执行计划与底层的Spark Core执行引擎,它主要负责优化数据结果与可执行代码。
|
||||
|
||||
|
||||
|
||||
接下来,我们结合上一讲“倍率分析”的例子,来说一说,那段代码在Spark SQL这一层,是如何被优化的。我把“倍率分析”完整的代码实现贴在了这里,你不妨先简单回顾一下。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
val rootPath: String = _
|
||||
// 申请者数据
|
||||
val hdfs_path_apply: String = s"${rootPath}/apply"
|
||||
// spark是spark-shell中默认的SparkSession实例
|
||||
// 通过read API读取源文件
|
||||
val applyNumbersDF: DataFrame = spark.read.parquet(hdfs_path_apply)
|
||||
|
||||
// 中签者数据
|
||||
val hdfs_path_lucky: String = s"${rootPath}/lucky"
|
||||
// 通过read API读取源文件
|
||||
val luckyDogsDF: DataFrame = spark.read.parquet(hdfs_path_lucky)
|
||||
|
||||
// 过滤2016年以后的中签数据,且仅抽取中签号码carNum字段
|
||||
val filteredLuckyDogs: DataFrame = luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum")
|
||||
|
||||
// 摇号数据与中签数据做内关联,Join Key为中签号码carNum
|
||||
val jointDF: DataFrame = applyNumbersDF.join(filteredLuckyDogs, Seq("carNum"), "inner")
|
||||
|
||||
// 以batchNum、carNum做分组,统计倍率系数
|
||||
val multipliers: DataFrame = jointDF.groupBy(col("batchNum"),col("carNum"))
|
||||
.agg(count(lit(1)).alias("multiplier"))
|
||||
|
||||
// 以carNum做分组,保留最大的倍率系数
|
||||
val uniqueMultipliers: DataFrame = multipliers.groupBy("carNum")
|
||||
.agg(max("multiplier").alias("multiplier"))
|
||||
|
||||
// 以multiplier倍率做分组,统计人数
|
||||
val result: DataFrame = uniqueMultipliers.groupBy("multiplier")
|
||||
.agg(count(lit(1)).alias("cnt"))
|
||||
.orderBy("multiplier")
|
||||
|
||||
result.collect
|
||||
|
||||
|
||||
Catalyst优化器
|
||||
|
||||
首先,我们先来说说Catalyst的优化过程。基于代码中DataFrame之间确切的转换逻辑,Catalyst会先使用第三方的SQL解析器ANTLR生成抽象语法树(AST,Abstract Syntax Tree)。AST由节点和边这两个基本元素构成,其中节点就是各式各样的操作算子,如select、filter、agg等,而边则记录了数据表的Schema信息,如字段名、字段类型,等等。
|
||||
|
||||
以下图“倍率分析”的语法树为例,它实际上描述了从源数据到最终计算结果之间的转换过程。因此,在Spark SQL的范畴内,AST语法树又叫作“执行计划”(Execution Plan)。
|
||||
|
||||
|
||||
|
||||
可以看到,由算子构成的语法树、或者说执行计划,给出了明确的执行步骤。即使不经过任何优化,Spark Core也能把这个“原始的”执行计划按部就班地运行起来。
|
||||
|
||||
不过,从执行效率的角度出发,这么做并不是最优的选择。为什么这么说呢?我们以图中绿色的节点为例,Scan用于全量扫描并读取中签者数据,Filter则用来过滤出摇号批次大于等于“201601”的数据,Select节点的作用则是抽取数据中的“carNum”字段。
|
||||
|
||||
还记得吗?我们的源文件是以Parquet格式进行存储的,而Parquet格式在文件层面支持“谓词下推”(Predicates Pushdown)和“列剪枝”(Columns Pruning)这两项特性。
|
||||
|
||||
谓词下推指的是,利用像“batchNum >= 201601”这样的过滤条件,在扫描文件的过程中,只读取那些满足条件的数据文件。又因为Parquet格式属于列存(Columns Store)数据结构,因此Spark只需读取字段名为“carNum”的数据文件,而“剪掉”读取其他数据文件的过程。
|
||||
|
||||
|
||||
|
||||
以中签数据为例,在谓词下推和列剪枝的帮助下,Spark Core只需要扫描图中绿色的文件部分。显然,这两项优化,都可以有效帮助Spark Core大幅削减数据扫描量、降低磁盘I/O消耗,从而显著提升数据的读取效率。
|
||||
|
||||
因此,如果能把3个绿色节点的执行顺序,从“Scan > Filter > Select”调整为“Filter > Select > Scan”,那么,相比原始的执行计划,调整后的执行计划能给Spark Core带来更好的执行性能。
|
||||
|
||||
像谓词下推、列剪枝这样的特性,都被称为启发式的规则或策略。而Catalyst优化器的核心职责之一,就是在逻辑优化阶段,基于启发式的规则和策略调整、优化执行计划,为物理优化阶段提升性能奠定基础。经过逻辑阶段的优化之后,原始的执行计划调整为下图所示的样子,请注意绿色节点的顺序变化。
|
||||
|
||||
|
||||
|
||||
经过逻辑阶段优化的执行计划,依然可以直接交付Spark Core去运行,不过在性能优化方面,Catalyst并未止步于此。
|
||||
|
||||
除了逻辑阶段的优化,Catalyst在物理优化阶段还会进一步优化执行计划。与逻辑阶段主要依赖先验的启发式经验不同,物理阶段的优化,主要依赖各式各样的统计信息,如数据表尺寸、是否启用数据缓存、Shuffle中间文件,等等。换句话说,逻辑优化更多的是一种“经验主义”,而物理优化则是“用数据说话”。
|
||||
|
||||
以图中蓝色的Join节点为例,执行计划仅交代了applyNumbersDF与filteredLuckyDogs这两张数据表需要做内关联,但是,它并没有交代清楚这两张表具体采用哪种机制来做关联。按照实现机制来分类,数据关联有3种实现方式,分别是嵌套循环连接(NLJ,Nested Loop Join)、排序归并连接(Sort Merge Join)和哈希连接(Hash Join)。
|
||||
|
||||
而按照数据分发方式来分类,数据关联又可以分为Shuffle Join和Broadcast Join这两大类。因此,在分布式计算环境中,至少有6种Join策略供Spark SQL来选择。对于这6种Join策略,我们以后再详细展开,这里你只需要了解不同策略在执行效率上有着天壤之别即可。
|
||||
|
||||
回到蓝色Join节点的例子,在物理优化阶段,Catalyst优化器需要结合applyNumbersDF与filteredLuckyDogs这两张表的存储大小,来决定是采用运行稳定但性能略差的Shuffle Sort Merge Join,还是采用执行性能更佳的Broadcast Hash Join。
|
||||
|
||||
不论Catalyst决定采用哪种Join策略,优化过后的执行计划,都可以丢给Spark Core去做执行。不过,Spark SQL优化引擎并没有就此打住,当Catalyst优化器完成它的“历史使命”之后,Tungsten会接过接力棒,在Catalyst输出的执行计划之上,继续打磨、精益求精,力求把最优的执行代码交付给底层的SparkCore执行引擎。
|
||||
|
||||
|
||||
|
||||
Tungsten
|
||||
|
||||
站在Catalyst这个巨人的肩膀上,Tungsten主要是在数据结构和执行代码这两个方面,做进一步的优化。数据结构优化指的是Unsafe Row的设计与实现,执行代码优化则指的是全阶段代码生成(WSCG,Whole Stage Code Generation)。
|
||||
|
||||
我们先来看看为什么要有Unsafe Row。对于DataFrame中的每一条数据记录,Spark SQL默认采用org.apache.spark.sql.Row对象来进行封装和存储。我们知道,使用Java Object来存储数据会引入大量额外的存储开销。
|
||||
|
||||
为此,Tungsten设计并实现了一种叫做Unsafe Row的二进制数据结构。Unsafe Row本质上是字节数组,它以极其紧凑的格式来存储DataFrame的每一条数据记录,大幅削减存储开销,从而提升数据的存储与访问效率。
|
||||
|
||||
以下表的Data Schema为例,对于包含如下4个字段的每一条数据记录来说,如果采用默认的Row对象进行存储的话,那么每条记录需要消耗至少60个字节。
|
||||
|
||||
|
||||
|
||||
但如果用Tungsten Unsafe Row数据结构进行存储的话,每条数据记录仅需消耗十几个字节,如下图所示。
|
||||
|
||||
|
||||
|
||||
说完了Unsafe Row的数据结构优化,接下来,我们再来说说WSCG:全阶段代码生成。所谓全阶段,其实就是我们在调度系统中学过的Stage。以图中的执行计划为例,标记为绿色的3个节点,在任务调度的时候,会被划分到同一个Stage。
|
||||
|
||||
|
||||
|
||||
而代码生成,指的是Tungsten在运行时把算子之间的“链式调用”捏合为一份代码。以上图3个绿色的节点为例,在默认情况下,Spark Core会对每一条数据记录都依次执行Filter、Select和Scan这3个操作。
|
||||
|
||||
经过了Tungsten的WSCG优化之后,Filter、Select和Scan这3个算子,会被“捏合”为一个函数f。这样一来,Spark Core只需要使用函数f来一次性地处理每一条数据,就能消除不同算子之间数据通信的开销,一气呵成地完成计算。
|
||||
|
||||
好啦,到此为止,分别完成Catalyst和Tungsten这两个优化环节之后,Spark SQL终于“心满意足”地把优化过的执行计划、以及生成的执行代码,交付给老大哥Spark Core。Spark Core拿到计划和代码,在运行时利用Tungsten Unsafe Row的数据结构,完成分布式任务计算。到此,我们这一讲的内容也就讲完了。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲涉及的内容很多,我们一起做个总结。
|
||||
|
||||
首先,在RDD开发框架下,Spark Core的优化空间受限。绝大多数RDD高阶算子所封装的封装的计算逻辑(形参函数f)对于Spark Core是透明的,Spark Core除了用闭包的方式把函数f分发到Executors以外,没什么优化余地。
|
||||
|
||||
而DataFrame的出现带来了新思路,它携带的Schema提供了丰富的类型信息,而且DataFrame算子大多为处理数据列的标量函数。DataFrame的这两个特点,为引擎内核的优化打开了全新的空间。在DataFrame的开发框架下,负责具体优化过程的正是Spark SQL。
|
||||
|
||||
Spark SQL,则是凌驾于Spark Core之上的一层优化引擎,它的主要职责,是在用户代码交付Spark Core之前,对用户代码进行优化。
|
||||
|
||||
|
||||
|
||||
Spark SQL由两个核心组件构成,分别是Catalyst优化器和Tungsten,其优化过程也分为Catalyst和Tungsten两个环节。
|
||||
|
||||
在Catalyst优化环节,Spark SQL首先把用户代码转换为AST语法树,又叫执行计划,然后分别通过逻辑优化和物理优化来调整执行计划。逻辑阶段的优化,主要通过先验的启发式经验,如谓词下推、列剪枝,对执行计划做优化调整。而物理阶段的优化,更多是利用统计信息,选择最佳的执行机制、或添加必要的计算节点。
|
||||
|
||||
Tungsten主要从数据结构和执行代码两个方面进一步优化。与默认的Java Object相比,二进制的Unsafe Row以更加紧凑的方式来存储数据记录,大幅提升了数据的存储与访问效率。全阶段代码生成消除了同一Stage内部不同算子之间的数据传递,把多个算子融合为一个统一的函数,并将这个函数一次性地作用(Apply)到数据之上,相比不同算子的“链式调用”,这会显著提升计算效率。
|
||||
|
||||
每课一练
|
||||
|
||||
学完这一讲之后,我们知道,只有DataFrame才能“享受”到Spark SQL的优化过程,而RDD只能直接交付Spark Core执行。那么,这是不是意味着,RDD开发框架会退出历史舞台,而我们之前学过的与RDD有关的知识点,如RDD概念、RDD属性、RDD算子,都白学了呢?
|
||||
|
||||
|
||||
|
||||
欢迎你在留言区和我交流讨论,也推荐你把这一讲的内容分享给更多朋友。
|
||||
|
||||
|
||||
|
||||
|
363
专栏/零基础入门Spark/15数据源与数据格式:DataFrame从何而来?.md
Normal file
363
专栏/零基础入门Spark/15数据源与数据格式:DataFrame从何而来?.md
Normal file
@ -0,0 +1,363 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 数据源与数据格式:DataFrame从何而来?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们重点讲解了DataFrame与Spark SQL的渊源,并提到,DataFrame是Spark SQL的重要入口。换句话说,通过创建DataFrame并沿用DataFrame开发API,我们才能充分利用Spark SQL优化引擎提供种种“性能红利”。显然,对于初学者来说,第一步的创建DataFrame就变得至关重要。
|
||||
|
||||
之前 [第13讲],我们做小汽车摇号倍率分析时,用了SparkSession的read API从Parquet文件创建DataFrame,其实创建DataFrame的方法还有很多。毫不夸张地说,DataFrame的创建途径异常丰富,为什么这么说呢?
|
||||
|
||||
如下图所示,Spark支持多种数据源,按照数据来源进行划分,这些数据源可以分为如下几个大类:Driver端自定义的数据结构、(分布式)文件系统、关系型数据库RDBMS、关系型数据仓库、NoSQL数据库,以及其他的计算引擎。
|
||||
|
||||
|
||||
|
||||
显然,要深入地介绍Spark与每一种数据源的集成并不现实,也没必要,咱们只需要把注意力放在那些最常用、最常见的集成方式即可。
|
||||
|
||||
这一讲,我会从Driver、文件系统与RDBMS三个方面,为你讲解5种常见的DataFrame创建方式,然后带你了解不同方式的使用场景跟优劣分析。
|
||||
|
||||
从Driver创建DataFrame
|
||||
|
||||
在Driver端,Spark可以直接从数组、元组、映射等数据结构创建DataFrame。使用这种方式创建的DataFrame通常数据量有限,因此这样的DataFrame往往不直接参与分布式计算,而是用于辅助计算或是数据探索。尽管如此,学习这部分知识点还是非常必要的,因为它可以帮我们更直观地理解DataFrame与RDD的关系。
|
||||
|
||||
还记得吗?在数据表示(Data Representation)上,相比RDD,DataFrame仅仅是多了一个Schema。甚至可以说,DataFrame就是带Schema的RDD。因此,创建DataFrame的第一种方法,就是先创建RDD,然后再给它“扣上”一顶Schema的“帽子”。
|
||||
|
||||
从本地数据结构创建RDD,我们用的是SparkContext的parallelize方法,而给RDD“扣帽子”,我们要用到SparkSession的createDataFrame方法。
|
||||
|
||||
createDataFrame方法
|
||||
|
||||
为了创建RDD,我们先来定义列表数据seq。seq的每个元素都是二元元组,元组第一个元素的类型是String,第二个元素的类型是Int。有了列表数据结构,接下来我们创建RDD,如下所示。
|
||||
|
||||
import org.apache.spark.rdd.RDD
|
||||
val seq: Seq[(String, Int)] = Seq(("Bob", 14), ("Alice", 18))
|
||||
val rdd: RDD[(String, Int)] = sc.parallelize(seq)
|
||||
|
||||
|
||||
有了RDD之后,我们来给它制作一顶“帽子”,也就是我们刚刚说的Schema。创建Schema,我们需要用到Spark SQL内置的几种类型,如StructType、StructField、StringType、IntegerType,等等。
|
||||
|
||||
其中,StructType用于定义并封装Schema,StructFiled用于定义Schema中的每一个字段,包括字段名、字段类型,而像StringType、IntegerType这些*Type类型,表示的正是字段类型。为了和RDD数据类型保持一致,Schema对应的元素类型应该是(StringType,IntegerType)。
|
||||
|
||||
import org.apache.spark.sql.types.{StringType, IntegerType, StructField, StructType}
|
||||
val schema:StructType = StructType( Array(
|
||||
StructField("name", StringType),
|
||||
StructField("age", IntegerType)
|
||||
))
|
||||
|
||||
|
||||
好啦,到此为止,我们有了RDD,也有了为它量身定做的“帽子”Schema。不过,在把帽子扣上去之前,我们还要先给RDD整理下“发型”。这是什么意思呢?
|
||||
|
||||
createDataFrame方法有两个形参,第一个参数正是RDD,第二个参数是Schema。createDataFrame要求RDD的类型必须是RDD[Row],其中的Row是org.apache.spark.sql.Row,因此,对于类型为RDD[(String, Int)]的rdd,我们需要把它转换为RDD[Row]。
|
||||
|
||||
import org.apache.spark.sql.Row
|
||||
val rowRDD: RDD[Row] = rdd.map(fileds => Row(fileds._1, fileds._2))
|
||||
|
||||
|
||||
“发型”整理好之后,我们就可以调用createDataFrame来创建DataFrame,代码如下所示。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
val dataFrame: DataFrame = spark.createDataFrame(rowRDD,schema)
|
||||
|
||||
|
||||
DataFrame创建好之后,别忘了验证它的可用性,我们可以通过调用show方法来做简单的数据探索,验证DataFrame创建是否成功。
|
||||
|
||||
dataFrame.show
|
||||
|
||||
/** 结果显示
|
||||
+----+---+
|
||||
| name| age|
|
||||
+----+---+
|
||||
| Bob| 14|
|
||||
| Alice| 18|
|
||||
+----+---+
|
||||
*/
|
||||
|
||||
|
||||
历尽千辛万苦,我们先是用Driver端数据结构创建RDD,然后再调用createDataFrame把RDD转化为DataFrame。你可能会说:“相比用parallelize创建RDD,用createDataFrame创建DataFrame的方法未免复杂了些,有没有更简便的方法呢?”我们接着往下看。
|
||||
|
||||
toDF方法
|
||||
|
||||
其实要把RDD转化为DataFrame,我们并不一定非要亲自制作Schema这顶帽子,还可以直接在RDD之后调用toDF方法来做到这一点。咱们先来看toDF函数的用法,然后再去分析,spark.implicits是如何帮我们轻松创建DataFrame的。
|
||||
|
||||
import spark.implicits._
|
||||
val dataFrame: DataFrame = rdd.toDF
|
||||
dataFrame.printSchema
|
||||
/** Schema显示
|
||||
root
|
||||
|-- _1: string (nullable = true)
|
||||
|-- _2: integer (nullable = false)
|
||||
*/
|
||||
|
||||
|
||||
可以看到,我们显示导入了spark.implicits包中的所有方法,然后通过在RDD之上调用toDF就能轻松创建DataFrame。实际上,利用spark.implicits,我们甚至可以跳过创建RDD这一步,直接通过seq列表来创建DataFrame。
|
||||
|
||||
import spark.implicits._
|
||||
val dataFrame: DataFrame = seq.toDF
|
||||
dataFrame.printSchema
|
||||
/** Schema显示
|
||||
root
|
||||
|-- _1: string (nullable = true)
|
||||
|-- _2: integer (nullable = false)
|
||||
*/
|
||||
|
||||
|
||||
是不是感觉这个方法很简洁、很轻松?不过,你可能会问:“既然有toDF这条捷径,一开始干嘛还要花功夫去学步骤繁琐的createDataFrame方法呢?”
|
||||
|
||||
网络上流行过这么一句话:“你的岁月静好,是有人在背后帮你负重前行。”toDF也是同样的道理,我们之所以能用toDF轻松创建DataFrame,关键在于spark.implicits这个包提供了各种隐式方法。
|
||||
|
||||
隐式方法是Scala语言中一类特殊的函数,这类函数不需要开发者显示调用,函数体中的计算逻辑在适当的时候会自动触发。正是它们在背后默默地帮我们用seq创建出RDD,再用createDataFrame方法把RDD转化为DataFrame。
|
||||
|
||||
从文件系统创建DataFrame
|
||||
|
||||
说完第一类数据源,接下来,我们再来看看Spark如何从文件系统创建DataFrame。
|
||||
|
||||
Spark支持多种文件系统,常见的有HDFS、Amazon S3、本地文件系统,等等。不过无论哪种文件系统,Spark都要通过SparkSession的read API来读取数据并创建DataFrame。所以接下来,我们需要先弄明白read API要怎样使用,都有哪些注意事项。
|
||||
|
||||
read API由SparkSession提供,它允许开发者以统一的形式来创建DataFrame,如下图所示。
|
||||
|
||||
|
||||
|
||||
可以看到,要使用read API创建DataFrame,开发者只需要调用SparkSession的read方法,同时提供3类参数即可。这3类参数分别是文件格式、加载选项和文件路径,它们分别由函数format、option和load来指定。
|
||||
|
||||
先来看第1类参数文件格式,它就是文件的存储格式,如CSV(Comma Separated Values)、Text、Parquet、ORC、JSON。Spark SQL支持种类丰富的文件格式,除了这里列出的几个例子外,Spark SQL还支持像Zip压缩文件、甚至是图片Image格式。
|
||||
|
||||
完整的格式支持,你可以参考下图,或是访问官网给出的列表。在后续的讲解中,我们还会挑选一些常用的数据格式来演示read API的具体用法。
|
||||
|
||||
|
||||
|
||||
文件格式决定了第2类参数加载选项的可选集合,也就是说,不同的数据格式,可用的选型有所不同。比如,CSV文件格式可以通过option(“header”, true),来表明CSV文件的首行为Data Schema,但其他文件格式就没有这个选型。之后讲到常见文件格式用法时,我们再对其加载选项做具体讲解。
|
||||
|
||||
值得一提的是,加载选项可以有零个或是多个,当需要指定多个选项时,我们可以用“option(选项1, 值1).option(选项2, 值2)”的方式来实现。
|
||||
|
||||
read API的第3类参数是文件路径,这个参数很好理解,它就是文件系统上的文件定位符。比如本地文件系统中的“/dataSources/wikiOfSpark.txt”,HDFS分布式文件系统中的“hdfs://hostname:port/myFiles/userProfiles.csv”,或是Amazon S3上的“s3://myBucket/myProject/myFiles/results.parquet”,等等。
|
||||
|
||||
了解了read API的一般用法之后,接下来,我们结合一些常见的数据格式,来进行举例说明。对于那些在这节课没有展开介绍的文件格式,你可以参考官网给出的用法来做开发。
|
||||
|
||||
从CSV创建DataFrame
|
||||
|
||||
以可读性好的纯文本方式来存储结构化数据,CSV文件格式的身影常见于数据探索、数据分析、机器学习等应用场景。经过上面的分析,我们知道,要从CSV文件成功地创建DataFrame,关键在于了解并熟悉与之有关的加载选项。那么我们就来看看,CSV格式都有哪些对应的option,它们的含义都是什么。
|
||||
|
||||
|
||||
|
||||
从上往下看,首先是“header”,header的设置值为布尔值,也即true或false,它用于指定CSV文件的首行是否为列名。如果是的话,那么Spark SQL将使用首行的列名来创建DataFrame,否则使用“_c”加序号的方式来命名每一个数据列,比如“_c0”、“_c1”,等等。
|
||||
|
||||
对于加载的每一列数据,不论数据列本身的含义是什么,Spark SQL都会将其视为String类型。例如,对于后面这个CSV文件,Spark SQL将“name”和“age”两个字段都视为String类型。
|
||||
|
||||
name,age
|
||||
alice,18
|
||||
bob,14
|
||||
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
val csvFilePath: String = _
|
||||
val df: DataFrame = spark.read.format("csv").option("header", true).load(csvFilePath)
|
||||
// df: org.apache.spark.sql.DataFrame = [name: string, age: string]
|
||||
df.show
|
||||
/** 结果打印
|
||||
+-----+---+
|
||||
| name| age|
|
||||
+-----+---+
|
||||
| alice| 18|
|
||||
| bob| 14|
|
||||
+-----+---+
|
||||
*/
|
||||
|
||||
|
||||
要想在加载的过程中,为DataFrame的每一列指定数据类型,我们需要显式地定义Data Schema,并在read API中通过调用schema方法,来将Schema传递给Spark SQL。Data Schema的定义我们讲createDataFrame函数的时候提过,咱们不妨一起来回顾一下。
|
||||
|
||||
定义Schema:
|
||||
|
||||
import org.apache.spark.sql.types.{StringType, IntegerType, StructField, StructType}
|
||||
val schema:StructType = StructType( Array(
|
||||
StructField("name", StringType),
|
||||
StructField("age", IntegerType)
|
||||
))
|
||||
|
||||
|
||||
调用schema方法来传递Data Schema:
|
||||
|
||||
val csvFilePath: String = _
|
||||
val df: DataFrame = spark.read.format("csv").schema(schema).option("header", true).load(csvFilePath)
|
||||
// df: org.apache.spark.sql.DataFrame = [name: string, age: int]
|
||||
|
||||
|
||||
可以看到,在使用schema方法明确了Data Schema以后,数据加载完成之后创建的DataFrame类型由原来的“[name: string, age: string]”,变为“[name: string, age: int]”。需要注意的是,并不是所有文件格式都需要schema方法来指定Data Schema,因此在read API的一般用法中,schema方法并不是必需环节。
|
||||
|
||||
|
||||
|
||||
好,我们接着说CSV格式的option选项。在“header”之后,第二个选项是“seq”,它是用于分隔列数据的分隔符,可以是任意字符串,默认值是逗号。常见的分隔符还有Tab、“|”,等等。
|
||||
|
||||
之后的“escape”和“nullValue”分别用于指定文件中的转义字符和空值,而“dateFormat”则用于指定日期格式,它的设置值是任意可以转换为Java SimpleDateFormat类型的字符串,默认值是“yyyy-MM-dd”。
|
||||
|
||||
最后一个选项是“mode”,它用来指定文件的读取模式,更准确地说,它明确了Spark SQL应该如何对待CSV文件中的“脏数据”。
|
||||
|
||||
所谓脏数据,它指的是数据值与预期数据类型不符的数据记录。比如说,CSV文件中有一列名为“age”数据,它用于记录用户年龄,数据类型为整型Int。那么显然,age列数据不能出现像“8.5”这样的小数、或是像“8岁”这样的字符串,这里的“8.5”或是“8岁”就是我们常说的脏数据。
|
||||
|
||||
在不调用schema方法来显示指定Data Schema的情况下,Spark SQL将所有数据列都看作是String类型。我们不难发现,mode选项的使用,往往会与schema方法的调用如影随形。
|
||||
|
||||
mode支持3个取值,分别是permissive、dropMalformed和failFast,它们的含义如下表所示。
|
||||
|
||||
|
||||
|
||||
可以看到,除了“failFast”模式以外,另外两个模式都不影响DataFrame的创建。以下面的CSV文件为例,要想剔除脏数据,也就是“cassie, six”这条记录,同时正常加载满足类型要求的“干净”数据,我们需要同时结合schema方法与mode选项来实现。
|
||||
|
||||
CSV文件内容:
|
||||
|
||||
name,age
|
||||
alice,18
|
||||
bob,14
|
||||
cassie, six
|
||||
|
||||
|
||||
调用schema方法来传递Data Schema:
|
||||
|
||||
val csvFilePath: String = _
|
||||
val df: DataFrame = spark.read.format("csv")
|
||||
.schema(schema)
|
||||
.option("header", true)
|
||||
.option("mode", "dropMalformed")
|
||||
.load(csvFilePath)
|
||||
// df: org.apache.spark.sql.DataFrame = [name: string, age: int]
|
||||
df.show
|
||||
/** 结果打印
|
||||
+-----+---+
|
||||
| name| age|
|
||||
+-----+---+
|
||||
| alice| 18|
|
||||
| bob| 14|
|
||||
+-----+---+
|
||||
*/
|
||||
|
||||
|
||||
好啦,关于从CSV文件创建DataFrame,我们就讲完了。不难发现,从CSV创建DataFrame,过程相对比较繁琐,开发者需要注意的细节也很多。不过,毕竟CSV简单直接、跨平台、可读性好、应用广泛,因此掌握这部分开发技巧,还是非常值得的。
|
||||
|
||||
从Parquet / ORC创建DataFrame
|
||||
|
||||
接下来,我们就来说说Parquet格式和ORC格式,相比从CSV创建DataFrame,这两个方法就没那么麻烦了。
|
||||
|
||||
Parquet与ORC,都是应用广泛的列存(Column-based Store)文件格式。顾名思义,列存,是相对行存(Row-based Store)而言的。
|
||||
|
||||
在传统的行存文件格式中,数据记录以行为单位进行存储。虽然这非常符合人类的直觉,但在数据的检索与扫描方面,行存数据往往效率低下。例如,在数据探索、数据分析等数仓应用场景中,我们往往仅需扫描数据记录的某些字段,但在行存模式下,我们必须要扫描全量数据,才能完成字段的过滤。
|
||||
|
||||
CSV就是典型的行存数据格式,以如下的内容为例,如果我们想要统计文件中女生的数量,那么我们不得不扫描每一行数据,判断gender的取值,然后才能决定是否让当前记录参与计数。
|
||||
|
||||
CSV文件内容:
|
||||
|
||||
name,age,gender
|
||||
alice,18,female
|
||||
bob,14,male
|
||||
|
||||
|
||||
列存文件则不同,它以列为单位,对数据进行存储,每一列都有单独的文件或是文件块。还是以上面的文件内容为例,如果采用列存格式的话,那么文件的存储方式将会变成下面的样子。
|
||||
|
||||
|
||||
|
||||
可以看到,数据按列存储,想要统计女生的数量,我们只需扫描gender列的数据文件,而不必扫描name与age字段的数据文件。相比行存,列存有利于大幅削减数据扫描所需的文件数量。
|
||||
|
||||
不仅如此,对于每一个列存文件或是文件块,列存格式往往会附加header和footer等数据结构,来记录列数据的统计信息,比如最大值、最小值、记录统计个数,等等。这些统计信息会进一步帮助提升数据访问效率,例如,对于max=“male”同时min=“male”的gender文件来说,在统计女生计数的时候,我们完全可以把这样的文件跳过,不进行扫描。
|
||||
|
||||
再者,很多列存格式往往在文件中记录Data Schema,比如Parquet和ORC,它们会利用Meta Data数据结构,来记录所存储数据的数据模式。这样一来,在读取类似列存文件时,我们无需再像读取CSV一样,去手工指定Data Schema,这些繁琐的步骤都可以省去。因此,使用read API来读取Parquet或是ORC文件,就会变得非常轻松,如下所示。
|
||||
|
||||
使用read API读取Parquet文件:
|
||||
|
||||
val parquetFilePath: String = _
|
||||
val df: DataFrame = spark.read.format("parquet").load(parquetFilePath)
|
||||
|
||||
|
||||
使用read API读取ORC文件:
|
||||
|
||||
val orcFilePath: String = _
|
||||
val df: DataFrame = spark.read.format("orc").load(orcFilePath)
|
||||
|
||||
|
||||
可以看到,在read API的用法中,我们甚至不需要指定任何option,只要有format和load这两个必需环节即可。是不是非常简单?
|
||||
|
||||
好啦,到此为止,我们梳理了如何从文件系统,在不同的数据格式下创建DataFrame。在这一讲的最后,我们再来简单地了解一下如何从关系型数据库创建DataFrame,毕竟,这个场景在我们日常的开发中还是蛮常见的。
|
||||
|
||||
从RDBMS创建DataFrame
|
||||
|
||||
使用read API读取数据库,就像是使用命令行连接数据库那么简单。而使用命令行连接数据库,我们往往需要通过参数来指定数据库驱动、数据库地址、用户名、密码等关键信息。read API也是一样,只不过,这些参数通通由option选项来指定,以MySQL为例,read API的使用方法如下。
|
||||
|
||||
使用read API连接数据库并创建DataFrame:
|
||||
|
||||
spark.read.format("jdbc")
|
||||
.option("driver", "com.mysql.jdbc.Driver")
|
||||
.option("url", "jdbc:mysql://hostname:port/mysql")
|
||||
.option("user", "用户名")
|
||||
.option("password","密码")
|
||||
.option("numPartitions", 20)
|
||||
.option("dbtable", "数据表名 ")
|
||||
.load()
|
||||
|
||||
|
||||
访问数据库,我们同样需要format方法来指定“数据源格式”,这里的关键字是“jdbc”。请注意,由于数据库URL通过option来指定,因此调用load方法不再需要传入“文件路径”,我们重点来关注option选项的设置。
|
||||
|
||||
与命令行一样,option选项同样需要driver、url、user、password这些参数,来指定数据库连接的常规设置。不过,毕竟调用read API的目的是创建DataFrame,因此,我们还需要指定“dbtable”选项来确定要访问哪个数据表。
|
||||
|
||||
有意思的是,除了将表名赋值给“dbtable”以外,我们还可以把任意的SQL查询语句赋值给该选项,这样在数据加载的过程中就能完成数据过滤,提升访问效率。例如,我们想从users表选出所有的女生数据,然后在其上创建DataFrame。
|
||||
|
||||
val sqlQuery: String = “select * from users where gender = ‘female’”
|
||||
spark.read.format("jdbc")
|
||||
.option("driver", "com.mysql.jdbc.Driver")
|
||||
.option("url", "jdbc:mysql://hostname:port/mysql")
|
||||
.option("user", "用户名")
|
||||
.option("password","密码")
|
||||
.option("numPartitions", 20)
|
||||
.option("dbtable", sqlQuery)
|
||||
.load()
|
||||
|
||||
|
||||
此外,为了提升后续的并行处理效率,我们还可以通过“numPartitions”选项来控制DataFrame的并行度,也即DataFrame的Partitions数量。
|
||||
|
||||
需要额外注意的是,在默认情况下,Spark安装目录并没有提供与数据库连接有关的任何Jar包,因此,对于想要访问的数据库,不论是MySQL、PostgreSQL,还是Oracle、DB2,我们都需要把相关Jar包手工拷贝到Spark安装目录下的Jars文件夹。与此同时,我们还要在spark-shell命令或是spark-submit中,通过如下两个命令行参数,来告诉Spark相关Jar包的访问地址。
|
||||
|
||||
|
||||
–driver-class-path mysql-connector-java-version.jar
|
||||
–jars mysql-connector-java-version.jar
|
||||
|
||||
|
||||
好啦,到此为止,这一讲的内容就全部讲完啦!今天的内容有点多,我们来一起总结一下。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,我们聚焦在DataFrame的创建方式上。Spark支持种类丰富的数据源与数据格式,我们今天的重点,是通过Driver、文件系统和关系型数据库,来创建DataFrame。
|
||||
|
||||
在Driver端,我们可以使用createDataFrame方法来创建DataFrame,需要注意的是,这种创建方式有两个前提条件。一是底层RDD的类型必须是RDD[Row],二是我们需要手工创建Data Schema。Schema的创建需要用到StructType、StructField等数据类型,你要牢记在心。
|
||||
|
||||
import org.apache.spark.sql.types.{StringType, IntegerType, StructField, StructType}
|
||||
val schema:StructType = StructType( Array(
|
||||
StructField("name", StringType),
|
||||
StructField("age", IntegerType)
|
||||
))
|
||||
|
||||
|
||||
除了这种比较繁琐的方式之外,我们还可以利用spark.implicits._提供的隐式方法,通过在RDD或是原始序列数据之上调用toDF方法,轻松创建DataFrame。
|
||||
|
||||
接着,使用SparkSession的read API,我们分别讲解了从CSV、Parquet、ORC和关系型数据库创建DataFrame的一般方法。read API调用的一般方法,需要你熟练掌握。
|
||||
|
||||
|
||||
|
||||
由于Parquet、ORC这类列存格式在文件中内置了Data Schema,因此,访问这类文件格式,只有format和load两个方法是必需的。
|
||||
|
||||
相比之下,读取CSV较为复杂。首先,为了指定Data Schema,开发者需要额外通过schema方法,来输入预定义的数据模式。再者,CSV的option选项比较多,你可以参考后面的表格,来更好地控制CSV数据的加载过程。
|
||||
|
||||
|
||||
|
||||
最后,我们学习了read API访问RDBMS的一般方法。与命令行的访问方式类似,你需要通过多个option选项,来指定数据库连接所必需的访问参数,如数据库驱动、URL地址、用户名、密码,等等。特别地,你还可以为“dbtable”选项指定表名或是查询语句,对数据的加载过程进行干预和控制。
|
||||
|
||||
每课一练
|
||||
|
||||
给定如下CSV文件,请你分别使用permissive, dropMalformed, failFast这3种mode,对比read API所创建的DataFrame之间的区别。
|
||||
|
||||
name,age
|
||||
alice,18
|
||||
bob,14
|
||||
cassie, six
|
||||
|
||||
|
||||
欢迎你在留言区跟我交流活动,也推荐你把这一讲的内容分享给更多的同事、朋友,跟他一起学习进步。
|
||||
|
||||
|
||||
|
||||
|
428
专栏/零基础入门Spark/16数据转换:如何在DataFrame之上做数据处理?.md
Normal file
428
专栏/零基础入门Spark/16数据转换:如何在DataFrame之上做数据处理?.md
Normal file
@ -0,0 +1,428 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 数据转换:如何在DataFrame之上做数据处理?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们学习了创建DataFrame的各种途径与方法,那么,有了DataFrame之后,我们该如何在DataFrame之上做数据探索、数据分析,以及各式各样的数据转换呢?在数据处理完毕之后,我们又该如何做数据展示与数据持久化呢?今天这一讲,我们就来解答这些疑问。
|
||||
|
||||
为了给开发者提供足够的灵活性,对于DataFrame之上的数据处理,Spark SQL支持两类开发入口:一个是大家所熟知的结构化查询语言:SQL,另一类是DataFrame开发算子。就开发效率与执行效率来说,二者并无优劣之分,选择哪种开发入口,完全取决于开发者的个人偏好与开发习惯。
|
||||
|
||||
与RDD类似,DataFrame支持种类繁多的开发算子,但相比SQL语言,DataFrame算子的学习成本相对要高一些。因此,本着先易后难的思路,咱们先来说说DataFrame中SQL语句的用法,然后再去理解DataFrame开发算子。
|
||||
|
||||
SQL语句
|
||||
|
||||
对于任意的DataFrame,我们都可以使用createTempView或是createGlobalTempView在Spark SQL中创建临时数据表。
|
||||
|
||||
两者的区别在于,createTempView创建的临时表,其生命周期仅限于SparkSession内部,而createGlobalTempView创建的临时表,可以在同一个应用程序中跨SparkSession提供访问。有了临时表之后,我们就可以使用SQL语句灵活地倒腾表数据。
|
||||
|
||||
通过后面这段代码,我为你演示了如何使用createTempView创建临时表。我们首先用toDF创建了一个包含名字和年龄的DataFrame,然后调用createTempView方法创建了临时表。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
import spark.implicits._
|
||||
|
||||
val seq = Seq(("Alice", 18), ("Bob", 14))
|
||||
val df = seq.toDF("name", "age")
|
||||
|
||||
df.createTempView("t1")
|
||||
val query: String = "select * from t1"
|
||||
// spark为SparkSession实例对象
|
||||
val result: DataFrame = spark.sql(query)
|
||||
|
||||
result.show
|
||||
|
||||
/** 结果打印
|
||||
+-----+---+
|
||||
| n ame| age|
|
||||
+-----+---+
|
||||
| Alice| 18|
|
||||
| Bob| 14|
|
||||
+-----+---+
|
||||
*/
|
||||
|
||||
|
||||
以上表为例,我们先是使用spark.implicits._隐式方法通过toDF来创建DataFrame,然后在其上调用createTempView来创建临时表“t1”。接下来,给定SQL查询语句“query”,我们可以通过调用SparkSession提供的sql API来提请执行查询语句,得到的查询结果被封装为新的DataFrame。
|
||||
|
||||
值得一提的是,与RDD的开发模式一样,DataFrame之间的转换也属于延迟计算,当且仅当出现Action类算子时,如上表中的show,所有之前的转换过程才会交付执行。
|
||||
|
||||
Spark SQL采用ANTLR语法解析器,来解析并处理SQL语句。我们知道,ANTLR是一款强大的、跨语言的语法解析器,因为它全面支持SQL语法,所以广泛应用于Oracle、Presto、Hive、ElasticSearch等分布式数据仓库和计算引擎。因此,像Hive或是Presto中的SQL查询语句,都可以平滑地迁移到Spark SQL。
|
||||
|
||||
不仅如此,Spark SQL还提供大量Built-in Functions(内置函数),用于辅助数据处理,如array_distinct、collect_list,等等。你可以浏览官网的Built-in Functions页面查找完整的函数列表。结合SQL语句以及这些灵活的内置函数,你就能游刃有余地应对数据探索、数据分析这些典型的数据应用场景。
|
||||
|
||||
SQL语句相对比较简单,学习路径短、成本低,你只要搞清楚如何把DataFrame转化为数据表,剩下的事就水到渠成了。接下来,我们把主要精力放在DataFrame支持的各类算子上,这些算子提供的功能,往往能大幅提升开发效率,让我们事半功倍。
|
||||
|
||||
DataFrame算子
|
||||
|
||||
不得不说,DataFrame支持的算子丰富而又全面,这主要源于DataFrame特有的“双面”属性。一方面,DataFrame来自RDD,与RDD具有同源性,因此RDD支持的大部分算子,DataFrame都支持。另一方面,DataFrame携带Schema,是结构化数据,因此它必定要提供一套与结构化查询同源的计算算子。
|
||||
|
||||
正是由于这样“双面”的特性,我们从下图可以看到,DataFrame所支持的算子,用“琳琅满目”来形容都不为过。
|
||||
|
||||
|
||||
|
||||
人类的大脑偏好结构化的知识,为了方便你记忆与理解,我把DataFrame上述两个方面的算子,进一步划分为6大类,它们分别是RDD同源类算子、探索类算子、清洗类算子、转换类算子、分析类算子和持久化算子。
|
||||
|
||||
你可能会困扰:“天呐!这么多算子要学,这不是逼我从入门到放弃吗?”别着急,上面这张图,你可以把它当作是“DataFrame算子脑图”,或是一本字典。在日常的开发中,思路枯竭的时候,你不妨把它翻出来,看看哪些算子能够帮你实现业务逻辑。
|
||||
|
||||
今天这一讲,我们也会根据这张“脑图”,重点讲解其中最常用、最关键的部分。
|
||||
|
||||
同源类算子
|
||||
|
||||
我们从DataFrame中的RDD同源类算子说起,这些算子在RDD算子那三讲做过详细的介绍,如果你对有哪个算子的作用或含义记不清了,不妨回看之前的三讲。我按照之前的分类,把这些算子整理成了一张表格。
|
||||
|
||||
|
||||
|
||||
探索类算子
|
||||
|
||||
接下来就是DataFrame的探索类算子。所谓探索,指的是数据探索,这类算子的作用,在于帮助开发者初步了解并认识数据,比如数据的模式(Schema)、数据的分布、数据的“模样”,等等,为后续的应用开发奠定基础。
|
||||
|
||||
对于常用的探索类算子,我把它们整理到了下面的表格中,你不妨先看一看,建立“第一印象”。
|
||||
|
||||
|
||||
|
||||
我们来依次“避轻就重”地说一说这些算子。首先,columns/schema/printSchema这3个算子类似,都可以帮我们获取DataFrame的数据列和Schema。尤其是printSchema,它以纯文本的方式将Data Schema打印到屏幕上,如下所示。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
import spark.implicits._
|
||||
|
||||
val employees = Seq((1, "John", 26, "Male"), (2, "Lily", 28, "Female"), (3, "Raymond", 30, "Male"))
|
||||
val employeesDF: DataFrame = employees.toDF("id", "name", "age", "gender")
|
||||
|
||||
employeesDF.printSchema
|
||||
|
||||
/** 结果打印
|
||||
root
|
||||
|-- id: integer (nullable = false)
|
||||
|-- name: string (nullable = true)
|
||||
|-- age: integer (nullable = false)
|
||||
|-- gender: string (nullable = true)
|
||||
*/
|
||||
|
||||
|
||||
了解数据模式之后,我们往往想知道数据具体长什么样子,对于这个诉求,show算子可以帮忙达成。在默认情况下,show会随机打印出DataFrame的20条数据记录。
|
||||
|
||||
employeesDF.show
|
||||
|
||||
/** 结果打印
|
||||
+---+-------+---+------+
|
||||
| id| name|age|gender|
|
||||
+---+-------+---+------+
|
||||
| 1| John| 26| Male|
|
||||
| 2| Lily| 28|Female|
|
||||
| 3|Raymond| 30| Male|
|
||||
+---+-------+---+------+
|
||||
*/
|
||||
|
||||
|
||||
看清了数据的“本来面目”之后,你还可以进一步利用describe去查看数值列的统计分布。比如,通过调用employeesDF.describe(“age”),你可以查看age列的极值、平均值、方差等统计数值。
|
||||
|
||||
初步掌握了数据的基本情况之后,如果你对当前DataFrame的执行计划感兴趣,可以通过调用explain算子来获得Spark SQL给出的执行计划。explain对于执行效率的调优来说,有着至关重要的作用,后续课程中我们还会结合具体的实例,来深入讲解explain的用法和释义,在这里,你仅需知道explain是用来查看执行计划的就好。
|
||||
|
||||
清洗类算子
|
||||
|
||||
完成数据探索以后,我们正式进入数据应用的开发阶段。在数据处理前期,我们往往需要对数据进行适当地“清洗”,“洗掉”那些不符合业务逻辑的“脏数据”。DataFrame提供了如下算子,来帮我们完成这些脏活儿、累活儿。
|
||||
|
||||
|
||||
|
||||
首先,drop算子允许开发者直接把指定列从DataFrame中予以清除。举个例子,对于上述的employeesDF,假设我们想把性别列清除,那么直接调用 employeesDF.drop(“gender”) 即可。如果要同时清除多列,只需要在drop算子中用逗号把多个列名隔开即可。
|
||||
|
||||
第二个是distinct,它用来为DataFrame中的数据做去重。还是以employeesDF为例,当有多条数据记录的所有字段值都相同时,使用distinct可以仅保留其中的一条数据记录。
|
||||
|
||||
接下来是dropDuplicates,它的作用也是去重。不过,与distinct不同的是,dropDuplicates可以指定数据列,因此在灵活性上更胜一筹。还是拿employeesDF来举例,这个DataFrame原本有3条数据记录,如果我们按照性别列去重,最后只会留下两条记录。其中,一条记录的gender列是“Male”,另一条的gender列为“Female”,如下所示。
|
||||
|
||||
employeesDF.show
|
||||
|
||||
/** 结果打印
|
||||
+---+-------+---+------+
|
||||
| id| name|age|gender|
|
||||
+---+-------+---+------+
|
||||
| 1| John| 26| Male|
|
||||
| 2| Lily| 28|Female|
|
||||
| 3|Raymond| 30| Male|
|
||||
+---+-------+---+------+
|
||||
*/
|
||||
|
||||
employeesDF.dropDuplicates("gender").show
|
||||
|
||||
/** 结果打印
|
||||
+---+----+---+------+
|
||||
| id|name|age|gender|
|
||||
+---+----+---+------+
|
||||
| 2|Lily| 28|Female|
|
||||
| 1|John| 26| Male|
|
||||
+---+----+---+------+
|
||||
*/
|
||||
|
||||
|
||||
表格中的最后一个算子是na,它的作用是选取DataFrame中的null数据,na往往要结合drop或是fill来使用。例如,employeesDF.na.drop用于删除DataFrame中带null值的数据记录,而employeesDF.na.fill(0) 则将DataFrame中所有的null值都自动填充为整数零。这两种用例在数据清洗的场景中都非常常见,因此,你需要牢牢掌握na.drop与na.fill的用法。
|
||||
|
||||
数据清洗过后,我们就得到了一份“整洁而又干净”的数据,接下来,可以放心大胆地去做各式各样的数据转换,从而实现业务逻辑需求。
|
||||
|
||||
转换类算子
|
||||
|
||||
转换类算子的主要用于数据的生成、提取与转换。转换类的算子的数量并不多,但使用方式非常灵活,开发者可以变着花样地变换数据。
|
||||
|
||||
|
||||
|
||||
首先,select算子让我们可以按照列名对DataFrame做投影,比如说,如果我们只关心年龄与性别这两个字段的话,就可以使用下面的语句来实现。
|
||||
|
||||
employeesDF.select("name", "gender").show
|
||||
|
||||
/** 结果打印
|
||||
+-------+------+
|
||||
| name|gender|
|
||||
+-------+------+
|
||||
| John| Male|
|
||||
| Lily|Female|
|
||||
|Raymond| Male|
|
||||
+-------+------+
|
||||
*/
|
||||
|
||||
|
||||
不过,虽然用起来比较简单,但select算子在功能方面不够灵活。在灵活性这方面,selectExpr做得更好。比如说,基于id和姓名,我们想把它们拼接起来生成一列新的数据。像这种需求,正是selectExpr算子的用武之地。
|
||||
|
||||
employeesDF.selectExpr("id", "name", "concat(id, '_', name) as id_name").show
|
||||
|
||||
/** 结果打印
|
||||
+---+-------+---------+
|
||||
| id| name| id_name|
|
||||
+---+-------+---------+
|
||||
| 1| John| 1_John|
|
||||
| 2| Lily| 2_Lily|
|
||||
| 3|Raymond|3_Raymond|
|
||||
+---+-------+---------+
|
||||
*/
|
||||
|
||||
|
||||
这里,我们使用concat这个函数,把id列和name列拼接在一起,生成新的id_name数据列。
|
||||
|
||||
接下来的where和withColumnRenamed这两个算子比较简单,where使用SQL语句对DataFrame做数据过滤,而withColumnRenamed的作用是字段重命名。
|
||||
|
||||
比如,想要过滤出所有性别为男的员工,我们就可以用employeesDF.where(“gender = ‘Male’”)来实现。如果打算把employeesDF当中的“gender”重命名为“sex”,就可以用withColumnRenamed来帮忙:employeesDF.withColumnRenamed(“gender”, “sex”)。
|
||||
|
||||
紧接着的是withColumn,虽然名字看上去和withColumnRenamed很像,但二者在功能上有着天壤之别。
|
||||
|
||||
withColumnRenamed是重命名现有的数据列,而withColumn则用于生成新的数据列,这一点上,withColumn倒是和selectExpr有着异曲同工之妙。withColumn也可以充分利用Spark SQL提供的Built-in Functions来灵活地生成数据。
|
||||
|
||||
比如,基于年龄列,我们想生成一列脱敏数据,隐去真实年龄,你就可以这样操作。
|
||||
|
||||
employeesDF.withColumn("crypto", hash($"age")).show
|
||||
|
||||
/** 结果打印
|
||||
+---+-------+---+------+-----------+
|
||||
| id| name|age|gender| crypto|
|
||||
+---+-------+---+------+-----------+
|
||||
| 1| John| 26| Male|-1223696181|
|
||||
| 2| Lily| 28|Female|-1721654386|
|
||||
| 3|Raymond| 30| Male| 1796998381|
|
||||
+---+-------+---+------+-----------+
|
||||
*/
|
||||
|
||||
|
||||
可以看到,我们使用内置函数hash,生成一列名为“crypto”的新数据,数据值是对应年龄的哈希值。有了新的数据列之后,我们就可以调用刚刚讲的drop,把原始的age字段丢弃掉。
|
||||
|
||||
表格中的最后一个算子是explode,这个算子很有意思,它的作用是展开数组类型的数据列,数组当中的每一个元素,都会生成一行新的数据记录。为了更好地演示explode的用法与效果,我们把employeesDF数据集做个简单的调整,给它加上一个interests兴趣字段。
|
||||
|
||||
val seq = Seq( (1, "John", 26, "Male", Seq("Sports", "News")),
|
||||
(2, "Lily", 28, "Female", Seq("Shopping", "Reading")),
|
||||
(3, "Raymond", 30, "Male", Seq("Sports", "Reading"))
|
||||
)
|
||||
|
||||
val employeesDF: DataFrame = seq.toDF("id", "name", "age", "gender", "interests")
|
||||
employeesDF.show
|
||||
|
||||
/** 结果打印
|
||||
+---+-------+---+------+-------------------+
|
||||
| id| name|age|gender| interests|
|
||||
+---+-------+---+------+-------------------+
|
||||
| 1| John| 26| Male| [Sports, News]|
|
||||
| 2| Lily| 28|Female|[Shopping, Reading]|
|
||||
| 3|Raymond| 30| Male| [Sports, Reading]|
|
||||
+---+-------+---+------+-------------------+
|
||||
*/
|
||||
|
||||
employeesDF.withColumn("interest", explode($"interests")).show
|
||||
|
||||
/** 结果打印
|
||||
+---+-------+---+------+-------------------+--------+
|
||||
| id| name|age|gender| interests|interest|
|
||||
+---+-------+---+------+-------------------+--------+
|
||||
| 1| John| 26| Male| [Sports, News]| Sports|
|
||||
| 1| John| 26| Male| [Sports, News]| News|
|
||||
| 2| Lily| 28|Female|[Shopping, Reading]|Shopping|
|
||||
| 2| Lily| 28|Female|[Shopping, Reading]| Reading|
|
||||
| 3|Raymond| 30| Male| [Sports, Reading]| Sports|
|
||||
| 3|Raymond| 30| Male| [Sports, Reading]| Reading|
|
||||
+---+-------+---+------+-------------------+--------+
|
||||
*/
|
||||
|
||||
|
||||
可以看到,我们多加了一个兴趣列,列数据的类型是数组,每个员工都有零到多个兴趣。
|
||||
|
||||
如果我们想把数组元素展开,让每个兴趣都可以独占一条数据记录。这个时候就可以使用explode,再结合withColumn,生成一列新的interest数据。这列数据的类型是单个元素的String,而不再是数组。有了新的interest数据列之后,我们可以再次利用drop算子,把原本的interests列抛弃掉。
|
||||
|
||||
数据转换完毕之后,我们就可以通过数据的关联、分组、聚合、排序,去做数据分析,从不同的视角出发去洞察数据。这个时候,我们还要依赖Spark SQL提供的多个分析类算子。
|
||||
|
||||
分析类算子
|
||||
|
||||
毫不夸张地说,前面的探索、清洗、转换,都是在为数据分析做准备。在大多数的数据应用中,数据分析往往是最为关键的那环,甚至是应用本身的核心目的。因此,熟练掌握分析类算子,有利于我们提升开发效率。
|
||||
|
||||
Spark SQL的分析类算子看上去并不多,但灵活组合使用,就会有“千变万化”的效果,让我们一起看看。
|
||||
|
||||
|
||||
|
||||
为了演示上述算子的用法,我们先来准备两张数据表:employees和salaries,也即员工信息表和薪水表。我们的想法是,通过对两张表做数据关联,来分析员工薪水的分布情况。
|
||||
|
||||
import spark.implicits._
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
// 创建员工信息表
|
||||
val seq = Seq((1, "Mike", 28, "Male"), (2, "Lily", 30, "Female"), (3, "Raymond", 26, "Male"))
|
||||
val employees: DataFrame = seq.toDF("id", "name", "age", "gender")
|
||||
|
||||
// 创建薪水表
|
||||
val seq2 = Seq((1, 26000), (2, 30000), (4, 25000), (3, 20000))
|
||||
val salaries:DataFrame = seq2.toDF("id", "salary")
|
||||
|
||||
employees.show
|
||||
|
||||
/** 结果打印
|
||||
+---+-------+---+------+
|
||||
| id| name|age|gender|
|
||||
+---+-------+---+------+
|
||||
| 1| Mike| 28| Male|
|
||||
| 2| Lily| 30|Female|
|
||||
| 3|Raymond| 26| Male|
|
||||
+---+-------+---+------+
|
||||
*/
|
||||
|
||||
salaries.show
|
||||
|
||||
/** 结果打印
|
||||
+---+------+
|
||||
| id|salary|
|
||||
+---+------+
|
||||
| 1| 26000|
|
||||
| 2| 30000|
|
||||
| 4| 25000|
|
||||
| 3| 20000|
|
||||
+---+------+
|
||||
*/
|
||||
|
||||
|
||||
那么首先,我们先用join算子把两张表关联起来,关联键(Join Keys)我们使用两张表共有的id列,而关联形式(Join Type)自然是内关联(Inner Join)。
|
||||
|
||||
val jointDF: DataFrame = salaries.join(employees, Seq("id"), "inner")
|
||||
|
||||
jointDF.show
|
||||
|
||||
/** 结果打印
|
||||
+---+------+-------+---+------+
|
||||
| id|salary| name|age|gender|
|
||||
+---+------+-------+---+------+
|
||||
| 1| 26000| Mike| 28| Male|
|
||||
| 2| 30000| Lily| 30|Female|
|
||||
| 3| 20000|Raymond| 26| Male|
|
||||
+---+------+-------+---+------+
|
||||
*/
|
||||
|
||||
|
||||
可以看到,我们在salaries之上调用join算子,join算子的参数有3类。第一类是待关联的数据表,在我们的例子中就是员工表employees。第二类是关联键,也就是两张表之间依据哪些字段做关联,我们这里是id列。第三类是关联形式,我们知道,关联形式有inner、left、right、anti、semi等等,这些关联形式我们下一讲再展开,这里你只需要知道Spark SQL支持这些种类丰富的关联形式即可。
|
||||
|
||||
数据完成关联之后,我们实际得到的仅仅是最细粒度的事实数据,也就是每个员工每个月领多少薪水。这样的事实数据本身并没有多少价值,我们往往需要从不同的维度出发,对数据做分组、聚合,才能获得更深入、更有价值的数据洞察。
|
||||
|
||||
比方说,我们想以性别为维度,统计不同性别下的总薪水和平均薪水,借此分析薪水与性别之间可能存在的关联关系。
|
||||
|
||||
val aggResult = fullInfo.groupBy("gender").agg(sum("salary").as("sum_salary"), avg("salary").as("avg_salary"))
|
||||
|
||||
aggResult.show
|
||||
|
||||
/** 数据打印
|
||||
+------+----------+----------+
|
||||
|gender|sum_salary|avg_salary|
|
||||
+------+----------+----------+
|
||||
|Female| 30000| 30000.0|
|
||||
| Male| 46000| 23000.0|
|
||||
+------+----------+----------+
|
||||
*/
|
||||
|
||||
|
||||
这里,我们先是使用groupBy算子按照“gender”列做分组,然后使用agg算子做聚合运算。在agg算子中,我们分别使用sum和avg聚合函数来计算薪水的总数和平均值。Spark SQL对于聚合函数的支持,我们同样可以通过Built-in Functions页面来进行检索。结合Built-in Functions提供的聚合函数,我们就可以灵活地对数据做统计分析。
|
||||
|
||||
得到统计结果之后,为了方便查看,我们还可以使用sort或是orderBy算子对结果集进行排序,二者在用法与效果上是完全一致的,如下表所示。
|
||||
|
||||
aggResult.sort(desc("sum_salary"), asc("gender")).show
|
||||
|
||||
/** 结果打印
|
||||
+------+----------+----------+
|
||||
|gender|sum_salary|avg_salary|
|
||||
+------+----------+----------+
|
||||
| Male| 46000| 23000.0|
|
||||
|Female| 30000| 30000.0|
|
||||
+------+----------+----------+
|
||||
*/
|
||||
|
||||
aggResult.orderBy(desc("sum_salary"), asc("gender")).show
|
||||
|
||||
/** 结果打印
|
||||
+------+----------+----------+
|
||||
|gender|sum_salary|avg_salary|
|
||||
+------+----------+----------+
|
||||
| Male| 46000| 23000.0|
|
||||
|Female| 30000| 30000.0|
|
||||
+------+----------+----------+
|
||||
*/
|
||||
|
||||
|
||||
可以看到,sort / orderBy支持按照多列进行排序,且可以通过desc和asc来指定排序方向。其中desc表示降序排序,相应地,asc表示升序排序。
|
||||
|
||||
好啦,到此为止,我们沿着数据的生命周期,分别梳理了生命周期不同阶段的Spark SQL算子,它们分别是探索类算子、清洗类算子、转换类算子和分析类算子。
|
||||
|
||||
|
||||
|
||||
所谓行百里者半九十,纵观整个生命周期,我们还剩下数据持久化这一个环节。对于最后的这个持久化环节,Spark SQL提供了write API,与上一讲介绍的read API相对应,write API允许开发者把数据灵活地物化为不同的文件格式。
|
||||
|
||||
持久化类算子
|
||||
|
||||
没有对比就没有鉴别,在学习write API之前,我们不妨先来回顾一下上一讲介绍的read API。
|
||||
|
||||
|
||||
|
||||
如上图所示,read API有3个关键点,一是由format指定的文件格式,二是由零到多个option组成的加载选项,最后一个是由load标记的源文件路径。
|
||||
|
||||
与之相对,write API也有3个关键环节,分别是同样由format定义的文件格式,零到多个由option构成的“写入选项”,以及由save指定的存储路径,如下图所示。
|
||||
|
||||
|
||||
|
||||
这里的format和save,与read API中的format和load是一一对应的,分别用于指定文件格式与存储路径。实际上,option选项也是类似的,除了mode以外,write API中的选项键与read API中的选项键也是相一致的,如seq用于指定CSV文件分隔符、dbtable用于指定数据表名、等等,你可以通过回顾[上一讲]来获取更多的option选项。
|
||||
|
||||
在read API中,mode选项键用于指定读取模式,如permissive, dropMalformed, failFast。但在write API中,mode用于指定“写入模式”,分别有Append、Overwrite、ErrorIfExists、Ignore这4种模式,它们的含义与描述如下表所示。
|
||||
|
||||
|
||||
|
||||
有了write API,我们就可以灵活地把DataFrame持久化到不同的存储系统中,为数据的生命周期画上一个圆满的句号。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,我们主要围绕数据的生命周期,学习了Spark SQL在不同数据阶段支持的处理算子,如下图所示。
|
||||
|
||||
|
||||
|
||||
图中涉及的算子很多,尽管大部分我们都举例讲过了,但要在短时间之内一下子掌握这么多内容,确实强人所难。不过,你不用担心,今天这一讲,最主要的目的,还是想让你对Spark SQL支持的算子有一个整体的把握。
|
||||
|
||||
至于每个算子具体是用来做什么的,在日后的开发工作中,你可以反复地翻看这一讲,结合实践慢慢地加深印象,这样学习更高效。我也强烈建议你空闲时把官网的Built-in Functions列表过一遍,对这些内置函数的功能做到心中有数,实现业务逻辑时才会手到擒来。
|
||||
|
||||
除了DataFrame本身支持的算子之外,在功能上,SQL完全可以实现同样的数据分析。给定DataFrame,你只需通过createTempView或是createGlobalTempView来创建临时表,然后就可以通过写SQL语句去进行数据的探索、倾斜、转换与分析。
|
||||
|
||||
最后,需要指出的是,DataFrame算子与SQL查询语句之间,并没有优劣之分,他们可以实现同样的数据应用,而且在执行性能方面也是一致的。因此,你可以结合你的开发习惯与偏好,自由地在两者之间进行取舍。
|
||||
|
||||
每课一练
|
||||
|
||||
在转换类算子中,我们举例介绍了explode这个算子,它的作用是按照以数组为元素的数据列,把一条数据展开(爆炸)成多条数据。结合这个算子的作用,你能否分析一下,explode操作是否会引入Shuffle计算呢?
|
||||
|
||||
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给有需要的朋友。
|
||||
|
||||
|
||||
|
||||
|
323
专栏/零基础入门Spark/17数据关联:不同的关联形式与实现机制该怎么选?.md
Normal file
323
专栏/零基础入门Spark/17数据关联:不同的关联形式与实现机制该怎么选?.md
Normal file
@ -0,0 +1,323 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 数据关联:不同的关联形式与实现机制该怎么选?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们学习了Spark SQL支持的诸多算子。其中数据关联(Join)是数据分析场景中最常见、最重要的操作。毫不夸张地说,几乎在所有的数据应用中,你都能看到数据关联的“身影”。因此,今天这一讲,咱们继续详细说一说Spark SQL对于Join的支持。
|
||||
|
||||
众所周知,Join的种类非常丰富。如果按照关联形式(Join Types)来划分,数据关联分为内关联、外关联、左关联、右关联,等等。对于参与关联计算的两张表,关联形式决定了结果集的数据来源。因此,在开发过程中选择哪种关联形式,是由我们的业务逻辑决定的。
|
||||
|
||||
而从实现机制的角度,Join又可以分为NLJ(Nested Loop Join)、SMJ(Sort Merge Join)和HJ(Hash Join)。也就是说,同样是内关联,我们既可以采用NLJ来实现,也可以采用SMJ或是HJ来实现。区别在于,在不同的计算场景下,这些不同的实现机制在执行效率上有着天壤之别。因此,了解并熟悉这些机制,对咱们开发者来说至关重要。
|
||||
|
||||
今天,我们就分别从这两个角度,来说一说Spark SQL当中数据关联的来龙去脉。
|
||||
|
||||
数据准备
|
||||
|
||||
为了让你更好地掌握新知识,我会通过一个个例子,为你说明Spark SQL数据关联的具体用法。在去介绍数据关联之前,咱们先把示例中会用到的数据准备好。
|
||||
|
||||
import spark.implicits._
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
// 创建员工信息表
|
||||
val seq = Seq((1, "Mike", 28, "Male"), (2, "Lily", 30, "Female"), (3, "Raymond", 26, "Male"), (5, "Dave", 36, "Male"))
|
||||
val employees: DataFrame = seq.toDF("id", "name", "age", "gender")
|
||||
|
||||
// 创建薪资表
|
||||
val seq2 = Seq((1, 26000), (2, 30000), (4, 25000), (3, 20000))
|
||||
val salaries:DataFrame = seq2.toDF("id", "salary")
|
||||
|
||||
|
||||
如上表所示,我们创建了两个DataFrame,一个用于存储员工基本信息,我们称之为员工表;另一个存储员工薪水,我们称之为薪资表。
|
||||
|
||||
数据准备好之后,我们有必要先弄清楚一些数据关联的基本概念。所谓数据关联,它指的是这样一个计算过程:给定关联条件(Join Conditions)将两张数据表以不同关联形式拼接在一起的过程。关联条件包含两层含义,一层是两张表中各自关联字段(Join Key)的选择,另一层是关联字段之间的逻辑关系。
|
||||
|
||||
在[上一讲]我们说到,Spark SQL同时支持DataFrame算子与SQL查询,因此咱们不妨结合刚刚准备好的数据,分别以这两者为例,来说明数据关联中的基本概念。
|
||||
|
||||
|
||||
|
||||
首先,约定俗成地,我们把主动参与Join的数据表,如上图中的salaries表,称作“左表”;而把被动参与关联的数据表,如employees表,称作是“右表”。
|
||||
|
||||
然后,我们来关注图中蓝色的部分。可以看到,两张表都选择id列作为关联字段,而两者的逻辑关系是“相等”。这样的一个等式,就构成了我们刚刚说的关联条件。接下来,我们再来看图中绿色的部分,inner指代的就是内关联的关联形式。
|
||||
|
||||
关联形式,是我们今天要学习的重点内容之一。接下来,我们还是一如既往地绕过SQL查询这种开发方式,以DataFrame算子这种开发模式为例,说一说Spark SQL都支持哪些关联形式,以及不同关联形式的效果是怎样的。
|
||||
|
||||
关联形式(Join Types)
|
||||
|
||||
在关联形式这方面,Spark SQL的支持比较全面,为了让你一上来就建立一个整体的认知,我把Spark SQL支持的Joint Types都整理到了如下的表格中,你不妨先粗略地过一遍。
|
||||
|
||||
|
||||
|
||||
结合已经准备好的数据,我们分别来说一说每一种关联形式的用法,以及它们各自的作用与效果。我们先从最简单、最基础、也是最常见的内关联说起。
|
||||
|
||||
内关联(Inner Join)
|
||||
|
||||
对于登记在册的员工,如果我们想获得他们每个人的薪资情况,就可以使用内关联来实现,如下所示。
|
||||
|
||||
// 内关联
|
||||
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "inner")
|
||||
|
||||
jointDF.show
|
||||
|
||||
/** 结果打印
|
||||
+---+------+---+-------+---+------+
|
||||
| id|salary| id| name|age|gender|
|
||||
+---+------+---+-------+---+------+
|
||||
| 1| 26000| 1| Mike| 28| Male|
|
||||
| 2| 30000| 2| Lily| 30|Female|
|
||||
| 3| 20000| 3|Raymond| 26| Male|
|
||||
+---+------+---+-------+---+------+
|
||||
*/
|
||||
|
||||
// 左表
|
||||
salaries.show
|
||||
|
||||
/** 结果打印
|
||||
+---+------+
|
||||
| id|salary|
|
||||
+---+------+
|
||||
| 1| 26000|
|
||||
| 2| 30000|
|
||||
| 4| 25000|
|
||||
| 3| 20000|
|
||||
+---+------+
|
||||
*/
|
||||
|
||||
// 右表
|
||||
employees.show
|
||||
|
||||
/** 结果打印
|
||||
+---+-------+---+------+
|
||||
| id| name|age|gender|
|
||||
+---+-------+---+------+
|
||||
| 1| Mike| 28| Male|
|
||||
| 2| Lily| 30|Female|
|
||||
| 3|Raymond| 26| Male|
|
||||
| 5| Dave| 36| Male|
|
||||
+---+-------+---+------+
|
||||
*/
|
||||
|
||||
|
||||
可以看到,基于join算子的一般用法,我们只要在第3个参数中指定“inner”这种关联形式,就可以使用内关联的方式,来达成两表之间的数据拼接。不过,如果仔细观察上面打印的关联结果集,以及原始的薪资表与员工表,你会发现,左表和右表的原始数据,并没有都出现在结果集当中。
|
||||
|
||||
例如,在原始的薪资表中,有一条id为4的薪资记录;而在员工表中,有一条id为5、name为“Dave”的数据记录。这两条数据记录,都没有出现在内关联的结果集中,而这正是“内关联”这种关联形式的作用所在。
|
||||
|
||||
内关联的效果,是仅仅保留左右表中满足关联条件的那些数据记录。以上表为例,关联条件是salaries(“id”) === employees(“id”),而在员工表与薪资表中,只有1、2、3这三个值同时存在于他们各自的id字段中。相应地,结果集中就只有id分别等于1、2、3的这三条数据记录。
|
||||
|
||||
理解了内关联的含义与效果之后,你再去学习其他的关联形式,比如说外关联,就会变得轻松许多。
|
||||
|
||||
外关联(Outer Join)
|
||||
|
||||
外关联还可以细分为3种形式,分别是左外关联、右外关联、以及全外关联。这里的左、右,对应的实际上就是左表、右表。
|
||||
|
||||
由简入难,我们先来说左外关联。要把salaries与employees做左外关联,我们只需要把“inner”关键字,替换为“left”、“leftouter”或是“left_outer”即可,如下所示。
|
||||
|
||||
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "left")
|
||||
|
||||
jointDF.show
|
||||
|
||||
/** 结果打印
|
||||
+---+------+----+-------+----+------+
|
||||
| id|salary| id| name| age|gender|
|
||||
+---+------+----+-------+----+------+
|
||||
| 1| 26000| 1| Mike| 28| Male|
|
||||
| 2| 30000| 2| Lily| 30|Female|
|
||||
| 4| 25000|null| null|null| null|
|
||||
| 3| 20000| 3|Raymond| 26| Male|
|
||||
+---+------+----+-------+----+------+
|
||||
*/
|
||||
|
||||
|
||||
不难发现,左外关联的结果集,实际上就是内关联结果集,再加上左表salaries中那些不满足关联条件的剩余数据,也即id为4的数据记录。值得注意的是,由于右表employees中并不存在id为4的记录,因此结果集中employees对应的所有字段值均为空值null。
|
||||
|
||||
没有对比就没有鉴别,为了更好地理解前面学的内关联、左外关联,我们再来看看右外关联的执行结果。为了计算右外关联,在下面的代码中,我们把“left”关键字,替换为“right”、“rightouter”或是“right_outer”。
|
||||
|
||||
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "right")
|
||||
|
||||
jointDF.show
|
||||
|
||||
/** 结果打印
|
||||
+----+------+---+-------+---+------+
|
||||
| id|salary| id| name|age|gender|
|
||||
+----+------+---+-------+---+------+
|
||||
| 1| 26000| 1| Mike| 28| Male|
|
||||
| 2| 30000| 2| Lily| 30|Female|
|
||||
| 3| 20000| 3|Raymond| 26| Male|
|
||||
|null| null| 5| Dave| 36| Male|
|
||||
+----+------+---+-------+---+------+
|
||||
*/
|
||||
|
||||
|
||||
仔细观察,你会发现,与左外关联相反,右外关联的结果集,恰恰是内关联的结果集,再加上右表employees中的剩余数据,也即id为5、name为“Dave”的数据记录。同样的,由于左表salaries并不存在id等于5的数据记录,因此,结果集中salaries相应的字段置空,以null值进行填充。
|
||||
|
||||
理解了左外关联与右外关联,全外关联的功用就显而易见了。全外关联的结果集,就是内关联的结果,再加上那些不满足关联条件的左右表剩余数据。要进行全外关联的计算,关键字可以取“full”、“outer”、“fullouter”、或是“full_outer”,如下表所示。
|
||||
|
||||
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "full")
|
||||
|
||||
jointDF.show
|
||||
|
||||
/** 结果打印
|
||||
+----+------+----+-------+----+------+
|
||||
| id|salary| id| name| age|gender|
|
||||
+----+------+----+-------+----+------+
|
||||
| 1| 26000| 1| Mike| 28| Male|
|
||||
| 3| 20000| 3|Raymond| 26| Male|
|
||||
|null| null| 5| Dave| 36| Male|
|
||||
| 4| 25000|null| null|null| null|
|
||||
| 2| 30000| 2| Lily| 30|Female|
|
||||
+----+------+----+-------+----+------+
|
||||
*/
|
||||
|
||||
|
||||
到这里,内、外关联的作用我们就讲完了。聪明的你可能早已发现,这里的“内”,它指的是,在关联结果中,仅包含满足关联条件的那些数据记录;而“外”,它的含义是,在关联计算的结果集中,还包含不满足关联条件的数据记录。而外关联中的“左”、“右”、“全”,恰恰是在表明,那些不满足关联条件的记录,来自于哪里。
|
||||
|
||||
弄清楚“内”、“外”、“左”、“右”这些说法的含义,能够有效地帮我们避免迷失在种类繁多、却又彼此相关的关联形式中。其实除了内关联和外关联,Spark SQL还支持左半关联和左逆关联,这两个关联又是用来做什么的呢?
|
||||
|
||||
左半/逆关联(Left Semi Join / Left Anti Join)
|
||||
|
||||
尽管名字听上去拗口,但它们的含义却很简单。我们先来说左半关联,它的关键字有“leftsemi”和“left_semi”。左半关联的结果集,实际上是内关联结果集的子集,它仅保留左表中满足关联条件的那些数据记录,如下表所示。
|
||||
|
||||
// 内关联
|
||||
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "inner")
|
||||
|
||||
jointDF.show
|
||||
|
||||
/** 结果打印
|
||||
+---+------+---+-------+---+------+
|
||||
| id|salary| id| name|age|gender|
|
||||
+---+------+---+-------+---+------+
|
||||
| 1| 26000| 1| Mike| 28| Male|
|
||||
| 2| 30000| 2| Lily| 30|Female|
|
||||
| 3| 20000| 3|Raymond| 26| Male|
|
||||
+---+------+---+-------+---+------+
|
||||
*/
|
||||
|
||||
// 左半关联
|
||||
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "leftsemi")
|
||||
|
||||
jointDF.show
|
||||
|
||||
/** 结果打印
|
||||
+---+------+
|
||||
| id|salary|
|
||||
+---+------+
|
||||
| 1| 26000|
|
||||
| 2| 30000|
|
||||
| 3| 20000|
|
||||
+---+------+
|
||||
*/
|
||||
|
||||
|
||||
为了方便你进行对比,我分别打印出了内关联与左半关联的计算结果。这里你需要把握左半关联的两大特点:首先,左半关联是内关联的一个子集;其次,它只保留左表salaries中的数据。这两个特点叠加在一起,很好地诠释了“左、半”这两个字。
|
||||
|
||||
有了左半关联的基础,左逆关联会更好理解一些。左逆关联同样只保留左表的数据,它的关键字有“leftanti”和“left_anti”。但与左半关联不同的是,它保留的,是那些不满足关联条件的数据记录,如下所示。
|
||||
|
||||
// 左逆关联
|
||||
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "leftanti")
|
||||
|
||||
jointDF.show
|
||||
|
||||
/** 结果打印
|
||||
+---+------+
|
||||
| id|salary|
|
||||
+---+------+
|
||||
| 4| 25000|
|
||||
+---+------+
|
||||
*/
|
||||
|
||||
|
||||
通过与上面左半关联的结果集做对比,我们一眼就能看出左逆关联和它的区别所在。显然,id为4的薪资记录是不满足关联条件salaries(“id”) === employees(“id”)的,而左逆关联留下的,恰恰是这些“不达标”的数据记录。
|
||||
|
||||
好啦,关于Spark SQL支持的关联形式,到这里我们就全部说完了。根据这些不同关联形式的特点与作用,再结合实际场景中的业务逻辑,相信你可以在日常的开发中做到灵活取舍。
|
||||
|
||||
关联机制(Join Mechanisms)
|
||||
|
||||
不过,从功能的角度出发,使用不同的关联形式来实现业务逻辑,可以说是程序员的一项必备技能。要在众多的开发者中脱颖而出,咱们还要熟悉、了解不同的关联机制。哪怕同样是内关联,不同的Join实现机制在执行效率方面差异巨大。因此,掌握不同关联机制的原理与特性,有利于我们逐渐培养出以性能为导向的开发习惯。
|
||||
|
||||
在这一讲的开头,我们提到Join有3种实现机制,分别是NLJ(Nested Loop Join)、SMJ(Sort Merge Join)和HJ(Hash Join)。接下来,我们以内关联为例,结合salaries和employees这两张表,来说说它们各自的实现原理与特性。
|
||||
|
||||
// 内关联
|
||||
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "inner")
|
||||
|
||||
jointDF.show
|
||||
|
||||
/** 结果打印
|
||||
+---+------+---+-------+---+------+
|
||||
| id|salary| id| name|age|gender|
|
||||
+---+------+---+-------+---+------+
|
||||
| 1| 26000| 1| Mike| 28| Male|
|
||||
| 2| 30000| 2| Lily| 30|Female|
|
||||
| 3| 20000| 3|Raymond| 26| Male|
|
||||
+---+------+---+-------+---+------+
|
||||
*/
|
||||
|
||||
|
||||
NLJ:Nested Loop Join
|
||||
|
||||
对于参与关联的两张表,如salaries和employees,按照它们在代码中出现的顺序,我们约定俗成地把salaries称作“左表”,而把employees称作“右表”。在探讨关联机制的时候,我们又常常把左表称作是“驱动表”,而把右表称为“基表”。
|
||||
|
||||
一般来说,驱动表的体量往往较大,在实现关联的过程中,驱动表是主动扫描数据的那一方。而基表相对来说体量较小,它是被动参与数据扫描的那一方。
|
||||
|
||||
在NLJ的实现机制下,算法会使用外、内两个嵌套的for循环,来依次扫描驱动表与基表中的数据记录。在扫描的同时,还会判定关联条件是否成立,如内关联例子中的salaries(“id”) === employees(“id”)。如果关联条件成立,就把两张表的记录拼接在一起,然后对外进行输出。
|
||||
|
||||
|
||||
|
||||
在实现的过程中,外层的 for 循环负责遍历驱动表的每一条数据,如图中的步骤 1 所示。对于驱动表中的每一条数据记录,内层的 for 循环会逐条扫描基表的所有记录,依次判断记录的id字段值是否满足关联条件,如步骤 2 所示。
|
||||
|
||||
不难发现,假设驱动表有 M 行数据,而基表有 N 行数据,那么 NLJ 算法的计算复杂度是 O(M * N)。尽管NLJ的实现方式简单、直观、易懂,但它的执行效率显然很差。
|
||||
|
||||
SMJ:Sort Merge Join
|
||||
|
||||
鉴于NLJ低效的计算效率,SMJ应运而生。Sort Merge Join,顾名思义,SMJ的实现思路是先排序、再归并。给定参与关联的两张表,SMJ先把他们各自排序,然后再使用独立的游标,对排好序的两张表做归并关联。
|
||||
|
||||
|
||||
|
||||
具体计算过程是这样的:起初,驱动表与基表的游标都会先锚定在各自的第一条记录上,然后通过对比游标所在记录的id字段值,来决定下一步的走向。对比结果以及后续操作主要分为 3 种情况:
|
||||
|
||||
|
||||
满足关联条件,两边的id值相等,那么此时把两边的数据记录拼接并输出,然后把驱动表的游标滑动到下一条记录;
|
||||
不满足关联条件,驱动表id值小于基表的id值,此时把驱动表的游标滑动到下一条记录;
|
||||
不满足关联条件,驱动表id值大于基表的id值,此时把基表的游标滑动到下一条记录。
|
||||
|
||||
|
||||
基于这 3 种情况,SMJ不停地向下滑动游标,直到某张表的游标滑到尽头,即宣告关联结束。对于驱动表的每一条记录,由于基表已按id字段排序,且扫描的起始位置为游标所在位置,因此,SMJ算法的计算复杂度为 O(M + N)。
|
||||
|
||||
然而,计算复杂度的降低,仰仗的其实是两张表已经事先排好了序。但是我们知道,排序本身就是一项很耗时的操作,更何况,为了完成归并关联,参与 Join 的两张表都需要排序。
|
||||
|
||||
因此,SMJ的计算过程我们可以用“先苦后甜”来形容。苦,指的是要先花费时间给两张表做排序,而甜,指的则是有序表的归并关联能够享受到线性的计算复杂度。
|
||||
|
||||
HJ:Hash Join
|
||||
|
||||
考虑到SMJ对于排序的苛刻要求,后来又有人推出了HJ算法。HJ的设计初衷是以空间换时间,力图将基表扫描的计算复杂度降低至O(1)。
|
||||
|
||||
|
||||
|
||||
具体来说,HJ的计算分为两个阶段,分别是Build阶段和Probe阶段。在Build阶段,在基表之上,算法使用既定的哈希函数构建哈希表,如上图的步骤 1 所示。哈希表中的Key是id字段应用(Apply)哈希函数之后的哈希值,而哈希表的 Value 同时包含了原始的Join Key(id字段)和Payload。
|
||||
|
||||
在Probe阶段,算法依次遍历驱动表的每一条数据记录。首先使用同样的哈希函数,以动态的方式计算Join Key的哈希值。然后,算法再用哈希值去查询刚刚在Build阶段创建好的哈希表。如果查询失败,则说明该条记录与基表中的数据不存在关联关系;相反,如果查询成功,则继续对比两边的Join Key。如果Join Key一致,就把两边的记录进行拼接并输出,从而完成数据关联。
|
||||
|
||||
好啦,到此为止,对于Join的3种实现机制,我们暂时说到这里。对于它们各自的实现原理,想必你已经有了充分的把握。至于这3种机制都适合哪些计算场景,以及Spark SQL如何利用这些机制在分布式环境下做数据关联,我们留到[下一讲]再去展开。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,我们重点介绍了数据关联中的关联形式(Join Types)与实现机制(Join Mechanisms)。掌握了不同的关联形式,我们才能游刃有余地满足不断变化的业务需求。而熟悉并理解不同实现机制的工作原理,则有利于培养我们以性能为导向的开发习惯。
|
||||
|
||||
Spark SQL支持的关联形式多种多样,为了方便你查找,我把它们的含义与效果统一整理到了如下的表格中。在日后的开发工作中,当你需要区分并确认不同的关联形式时,只要回顾这张表格,就能迅速得到结论。
|
||||
|
||||
|
||||
|
||||
在此之后,我们又介绍了Join的3种实现机制,它们分别是Nested Loop Join、Sort Merge Join和Hash Join。这3种实现机制的工作原理,我也整理成了表格,方便你随时查看。
|
||||
|
||||
|
||||
|
||||
每课一练
|
||||
|
||||
对于Join的3种实现机制,也即Nested Loop Join、Sort Merge Join和Hash Join,结合其实现原理,你能猜一猜,它们可能的适用场景都有哪些吗?或者换句话说,在什么样的情况下,更适合使用哪种实现机制来进行数据关联?
|
||||
|
||||
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给身边的同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
143
专栏/零基础入门Spark/18数据关联优化:都有哪些Join策略,开发者该如何取舍?.md
Normal file
143
专栏/零基础入门Spark/18数据关联优化:都有哪些Join策略,开发者该如何取舍?.md
Normal file
@ -0,0 +1,143 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 数据关联优化:都有哪些Join策略,开发者该如何取舍?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们分别从关联形式与实现机制这两个方面,对数据分析进行了讲解和介绍。对于不同关联形式的用法和实现机制的原理,想必你已经了然于胸。不过,在大数据的应用场景中,数据的处理往往是在分布式的环境下进行的,在这种情况下,数据关联的计算还要考虑网络分发这个环节。
|
||||
|
||||
我们知道,在分布式环境中,Spark支持两类数据分发模式。一类是我们在[第7讲]学过的Shuffle,Shuffle通过中间文件来完成Map阶段与Reduce阶段的数据交换,因此它会引入大量的磁盘与网络开销。另一类是我们在[第10讲]介绍的广播变量(Broadcast Variables),广播变量在Driver端创建,并由Driver分发到各个Executors。
|
||||
|
||||
因此,从数据分发模式的角度出发,数据关联又可以分为Shuffle Join和Broadcast Join这两大类。将两种分发模式与Join本身的3种实现机制相结合,就会衍生出分布式环境下的6种Join策略。
|
||||
|
||||
那么,对于这6种Join策略,Spark SQL是如何支持的呢?它们的优劣势与适用场景都有哪些?开发者能否针对这些策略有的放矢地进行取舍?今天这一讲,咱们就来聊聊这些话题。
|
||||
|
||||
Join实现机制的优势对比
|
||||
|
||||
首先,我们先来说一说不同Join实现机制本身的一些特性与适用场景,从而为后续的讨论打好基础。需要说明的是,咱们这里说的Join实现机制,指的是算法层面的工作原理,不同的算法有着不同的适用场景与复杂度,我们需要对它们有足够认识并有所区分。
|
||||
|
||||
我们知道,Join支持3种实现机制,它们分别是Hash Join、Sort Merge Join和Nested Loop Join。三者之中,Hash Join的执行效率最高,这主要得益于哈希表O(1)的查找效率。不过,在Probe阶段享受哈希表的“性能红利”之前,Build阶段得先在内存中构建出哈希表才行。因此,Hash Join这种算法对于内存的要求比较高,适用于内存能够容纳基表数据的计算场景。
|
||||
|
||||
相比之下,Sort Merge Join就没有内存方面的限制。不论是排序、还是合并,SMJ都可以利用磁盘来完成计算。所以,在稳定性这方面,SMJ更胜一筹。
|
||||
|
||||
而且与Hash Join相比,SMJ的执行效率也没有差太多,前者是O(M),后者是O(M + N),可以说是不分伯仲。当然,O(M + N)的复杂度得益于SMJ的排序阶段。因此,如果准备参与Join的两张表是有序表,那么这个时候采用SMJ算法来实现关联简直是再好不过了。
|
||||
|
||||
与前两者相比,Nested Loop Join看上去有些多余,嵌套的双层for循环带来的计算复杂度最高:O(M * N)。不过,尺有所短寸有所长,执行高效的HJ和SMJ只能用于等值关联,也就是说关联条件必须是等式,像salaries(“id”) < employees(“id”)这样的关联条件,HJ和SMJ是无能为力的。相反,NLJ既可以处理等值关联(Equi Join),也可以应付不等值关联(Non Equi Join),可以说是数据关联在实现机制上的最后一道防线。
|
||||
|
||||
Shuffle Join与Broadcast Join
|
||||
|
||||
分析完不同Join机制的优缺点之后,接下来,我们再来说说分布式环境下的Join策略。与单机环境不同,在分布式环境中,两张表的数据各自散落在不同的计算节点与Executors进程。因此,要想完成数据关联,Spark SQL就必须先要把Join Keys相同的数据,分发到同一个Executors中去才行。
|
||||
|
||||
我们还是用上一讲的员工信息和薪资表来举例,如果我们打算对salaries和employees两张表按照id列做关联,那么,对于id字段值相同的薪资数据与员工数据,我们必须要保证它们坐落在同样的Executors进程里,Spark SQL才能利用刚刚说的HJ、SMJ、以及NLJ,以Executors(进程)为粒度并行地完成数据关联。
|
||||
|
||||
换句话说,以Join Keys为基准,两张表的数据分布保持一致,是Spark SQL执行分布式数据关联的前提。而能满足这个前提的途径只有两个:Shuffle与广播。这里我额外提醒一下,Shuffle和广播变量我们在前面的课程有过详细的介绍,如果你记不太清了,不妨翻回去看一看。
|
||||
|
||||
回到正题,开篇咱们说到,如果按照分发模式来划分,数据关联可以分为Shuffle Join和Broadcast Join两大类。通常来说,在执行性能方面,相比Shuffle Join,Broadcast Join往往会更胜一筹。为什么这么说呢?
|
||||
|
||||
接下来,我们就一起来分析分析,这两大类Join在分布式环境下的执行过程,然后再来回答这个问题。理解了执行过程,你自然就能解答这个问题了。
|
||||
|
||||
Shuffle Join
|
||||
|
||||
在没有开发者干预的情况下,Spark SQL默认采用Shuffle Join来完成分布式环境下的数据关联。对于参与Join的两张数据表,Spark SQL先是按照如下规则,来决定不同数据记录应当分发到哪个Executors中去:
|
||||
|
||||
|
||||
根据Join Keys计算哈希值
|
||||
将哈希值对并行度(Parallelism)取模
|
||||
|
||||
|
||||
由于左表与右表在并行度(分区数)上是一致的,因此,按照同样的规则分发数据之后,一定能够保证id字段值相同的薪资数据与员工数据坐落在同样的Executors中。
|
||||
|
||||
|
||||
|
||||
如上图所示,颜色相同的矩形代表Join Keys相同的数据记录,可以看到,在Map阶段,数据分散在不同的Executors当中。经过Shuffle过后,Join Keys相同的记录被分发到了同样的Executors中去。接下来,在Reduce阶段,Reduce Task就可以使用HJ、SMJ、或是NLJ算法在Executors内部完成数据关联的计算。
|
||||
|
||||
Spark SQL之所以在默认情况下一律采用Shuffle Join,原因在于Shuffle Join的“万金油”属性。也就是说,在任何情况下,不论数据的体量是大是小、不管内存是否足够,Shuffle Join在功能上都能够“不辱使命”,成功地完成数据关联的计算。然而,有得必有失,功能上的完备性,往往伴随着的是性能上的损耗。
|
||||
|
||||
学习过 [Shuffle的原理](第6讲)之后,不用我多说,Shuffle的弊端想必你早已烂熟于心。我们知道,从CPU到内存,从磁盘到网络,Shuffle的计算几乎需要消耗所有类型的硬件资源。尤其是磁盘和网络开销,这两座大山往往是应用执行的性能瓶颈。
|
||||
|
||||
那么问题来了,除了Shuffle Join这种“万金油”式的Join策略,开发者还有没有其他效率更高的选择呢?答案当然是肯定的,Broadcast Join就是用来克制Shuffle的“杀手锏”。
|
||||
|
||||
Broadcast Join
|
||||
|
||||
在广播变量那一讲(第10讲),我们讲过把用户数据结构封装为广播变量的过程。实际上,Spark不仅可以在普通变量上创建广播变量,在分布式数据集(如RDD、DataFrame)之上也可以创建广播变量。这样一来,对于参与Join的两张表,我们可以把其中较小的一个封装为广播变量,然后再让它们进行关联。
|
||||
|
||||
光说思路你可能体会不深,我们还是结合例子理解。以薪资表和员工表为例,只要对代码稍加改动,我们就能充分利用广播变量的优势。
|
||||
|
||||
更改后的代码如下所示。
|
||||
|
||||
import org.apache.spark.sql.functions.broadcast
|
||||
|
||||
// 创建员工表的广播变量
|
||||
val bcEmployees = broadcast(employees)
|
||||
|
||||
// 内关联,PS:将原来的employees替换为bcEmployees
|
||||
val jointDF: DataFrame = salaries.join(bcEmployees, salaries("id") === employees("id"), "inner")
|
||||
|
||||
|
||||
在Broadcast Join的执行过程中,Spark SQL首先从各个Executors收集employees表所有的数据分片,然后在Driver端构建广播变量bcEmployees,构建的过程如下图实线部分所示。
|
||||
|
||||
|
||||
|
||||
可以看到,散落在不同Executors内花花绿绿的矩形,代表的正是employees表的数据分片。这些数据分片聚集到一起,就构成了广播变量。接下来,如图中虚线部分所示,携带着employees表全量数据的广播变量bcEmployees,被分发到了全网所有的Executors当中去。
|
||||
|
||||
在这种情况下,体量较大的薪资表数据只要“待在原地、保持不动”,就可以轻松关联到跟它保持之一致的员工表数据了。通过这种方式,Spark SQL成功地避开了Shuffle这种“劳师动众”的数据分发过程,转而用广播变量的分发取而代之。
|
||||
|
||||
尽管广播变量的创建与分发同样需要消耗网络带宽,但相比Shuffle Join中两张表的全网分发,因为仅仅通过分发体量较小的数据表来完成数据关联,Spark SQL的执行性能显然要高效得多。这种小投入、大产出,用极小的成本去博取高额的性能收益,可以说是“四两拨千斤”!
|
||||
|
||||
Spark SQL支持的Join策略
|
||||
|
||||
不论是Shuffle Join,还是Broadcast Join,一旦数据分发完毕,理论上可以采用HJ、SMJ和NLJ这3种实现机制中的任意一种,完成Executors内部的数据关联。因此,两种分发模式,与三种实现机制,它们组合起来,总共有6种分布式Join策略,如下图所示。
|
||||
|
||||
|
||||
|
||||
虽然组合起来选择多样,但你也不必死记硬背,抓住里面的规律才是关键,我们一起来分析看看。
|
||||
|
||||
在这6种Join策略中,Spark SQL支持其中的5种来应对不用的关联场景,也即图中蓝色的5个矩形。对于等值关联(Equi Join),Spark SQL优先考虑采用Broadcast HJ策略,其次是Shuffle SMJ,最次是Shuffle HJ。对于不等值关联(Non Equi Join),Spark SQL优先考虑Broadcast NLJ,其次是Shuffle NLJ。
|
||||
|
||||
|
||||
|
||||
不难发现,不论是等值关联、还是不等值关联,只要Broadcast Join的前提条件成立,Spark SQL一定会优先选择Broadcast Join相关的策略。那么问题来了,Broadcast Join的前提条件是什么呢?
|
||||
|
||||
回顾Broadcast Join的工作原理图,我们不难发现,Broadcast Join得以实施的基础,是被广播数据表(图中的表2)的全量数据能够完全放入Driver的内存、以及各个Executors的内存,如下图所示。
|
||||
|
||||
|
||||
|
||||
另外,为了避免因广播表尺寸过大而引入新的性能隐患,Spark SQL要求被广播表的内存大小不能超过8GB。
|
||||
|
||||
好,这里我们简单总结一下。只要被广播表满足上述两个条件,我们就可以利用SQL Functions中的broadcast函数来创建广播变量,进而利用Broadcast Join策略来提升执行性能。
|
||||
|
||||
当然,在Broadcast Join前提条件不成立的情况下,Spark SQL就会退化到Shuffle Join的策略。在不等值的数据关联中,Spark SQL只有Shuffle NLJ这一种选择,因此咱们无需赘述。
|
||||
|
||||
但在等值关联的场景中,Spark SQL有Shuffle SMJ和Shuffle HJ这两种选择。尽管如此,Shuffle SMJ与Shuffle HJ的关系,就像是关羽和周仓的关系。周仓虽说武艺也不错,但他向来只是站在关公后面提刀。大战在即,刘备仰仗的自然是站在前面的关羽,而很少启用后面的周仓。在Shuffle SMJ与Shuffle HJ的取舍上,Spark SQL也是如此。
|
||||
|
||||
学习过Shuffle之后,我们知道,Shuffle在Map阶段往往会对数据做排序,而这恰恰正中SMJ机制的下怀。对于已经排好序的两张表,SMJ的复杂度是O(M + N),这样的执行效率与HJ的O(M)可以说是不相上下。再者,SMJ在执行稳定性方面,远胜于HJ,在内存受限的情况下,SMJ可以充分利用磁盘来顺利地完成关联计算。因此,考虑到Shuffle SMJ的诸多优势,Shuffle HJ就像是关公后面的周仓,Spark SQL向来对之视而不见,所以对于HJ你大概知道它的作用就行。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,到此为止,今天的课程就全部讲完了,我们一起来做个总结。首先,我们一起分析、对比了单机环境中不同Join机制的优劣势,我把它们整理到了下面的表格中,供你随时查看。
|
||||
|
||||
|
||||
|
||||
在分布式环境中,要想利用上述机制完成数据关联,Spark SQL首先需要把两张表中Join Keys一致的数据分发到相同的Executors中。
|
||||
|
||||
因此,数据分发是分布式数据关联的基础和前提。Spark SQL支持Shuffle和广播两种数据分发模式,相应地,Join也被分为Shuffle Join和Broadcast Join,其中Shuffle Join是默认的关联策略。关于两种策略的优劣势对比,我也整理到了如下的表格中,供你参考。
|
||||
|
||||
|
||||
|
||||
结合三种实现机制和两种数据分发模式,Spark SQL支持5种分布式Join策略。对于这些不同的Join策略,Spark SQL有着自己的选择偏好,我把它整理到了如下的表格中,供你随时查看。
|
||||
|
||||
其中,Broadcast Join的生效前提,是基表能够放进内存,且存储尺寸小于8GB。只要前提条件成立,Spark SQL就会优先选择Broadcast Join。
|
||||
|
||||
|
||||
|
||||
每课一练
|
||||
|
||||
在6种分布式Join策略中,Spark SQL唯独没有支持Broadcast SMJ,你能想一想,为什么Spark SQL没有选择支持这种Join策略吗?提示一下,你可以从SMJ与HJ的执行效率入手做分析。
|
||||
|
||||
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
188
专栏/零基础入门Spark/19配置项详解:哪些参数会影响应用程序执行性能?.md
Normal file
188
专栏/零基础入门Spark/19配置项详解:哪些参数会影响应用程序执行性能?.md
Normal file
@ -0,0 +1,188 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 配置项详解:哪些参数会影响应用程序执行性能?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们学习了Broadcast Join这种执行高效的Join策略。要想触发Spark SQL选择这类Join策略,可以利用SQL Functions中的broadcast函数来强制广播基表。在这种情况下,Spark SQL会完全“尊重”开发者的意愿,只要基表小于8GB,它就会竭尽全力地去尝试进行广播并采用Broadcast Join策略。
|
||||
|
||||
除了这种比较“强势”的做法,我们还可以用另一种比较温和方式,来把选择权“下放”给Spark SQL,让它自己来决定什么时候选择Broadcast Join,什么时候回退到Shuffle Join。这种温和的方式,就是配置项设置。在第12讲,我们掌握了Spark常规配置项,今天这一讲,咱们来说一说与Spark SQL有关的那些配置项。
|
||||
|
||||
不过,打开Spark官网的 Configuration页面,你会发现,这里有上百个配置项,与Spark SQL相关的有好几十个,看得人眼花缭乱、头晕目眩。实际上,绝大多数配置项仅需采用默认值即可,并不需要我们过多关注。因此,我们把目光和注意力聚集到Join策略选择和AQE上。
|
||||
|
||||
Join策略的重要性不必多说,AQE(Adaptive Query Execution)是Spark 3.0推出的新特性,它帮助Spark SQL在运行时动态地调整执行计划,更加灵活地优化作业的执行性能。
|
||||
|
||||
Broadcast Join
|
||||
|
||||
接下来,我们先来说说,如何使用配置项来“温和”地让Spark SQL选择Broadcast Join。对于参与Join的两张表来说,我们把其中尺寸较小的表称作基表。
|
||||
|
||||
如果基表的存储尺寸小于广播阈值,那么无需开发者显示调用broadcast函数,Spark SQL同样会选择Broadcast Join的策略,在基表之上创建广播变量,来完成两张表的数据关联。
|
||||
|
||||
那么问题来了,广播阈值是什么,它是怎么定义的呢?广播阈值实际上就是一个标记存储尺寸的数值,它可以是10MB、也可是1GB,等等。广播阈值由如下配置项设定,只要基表小于该配置项的设定值,Spark SQL就会自动选择Broadcast Join策略。
|
||||
|
||||
|
||||
|
||||
如上表所示,广播阈值的默认值为10MB。一般来说,在工业级应用中,我们往往把它设置到2GB左右,即可有效触发Broadcast Join。广播阈值有了,要比较它与基表存储尺寸谁大谁小,Spark SQL还要还得事先计算好基表的存储尺寸才行。那问题来了,Spark SQL依据什么来计算这个数值呢?
|
||||
|
||||
这个问题要分两种情况讨论:如果基表数据来自文件系统,那么Spark SQL用来与广播阈值对比的基准,就是基表在磁盘中的存储大小。如果基表数据来自DAG计算的中间环节,那么Spark SQL将参考DataFrame执行计划中的统计值,跟广播阈值做对比,如下所示。
|
||||
|
||||
val df: DataFrame = _
|
||||
// 先对分布式数据集加Cache
|
||||
df.cache.count
|
||||
|
||||
// 获取执行计划
|
||||
val plan = df.queryExecution.logical
|
||||
|
||||
// 获取执行计划对于数据集大小的精确预估
|
||||
val estimated: BigInt = spark
|
||||
.sessionState
|
||||
.executePlan(plan)
|
||||
.optimizedPlan
|
||||
.stats
|
||||
.sizeInBytes
|
||||
|
||||
|
||||
讲到这里,你也许会有点不耐烦:“何必这么麻烦,又要设置配置项,又要提前预估基表大小,真是麻烦!还不如用上一讲提到的broadcast函数来得干脆!”
|
||||
|
||||
从开发者的角度看来,确实broadcast函数用起来更方便一些。不过,广播阈值加基表预估的方式,除了为开发者提供一条额外的调优途径外,还为Spark SQL的动态优化奠定了基础。
|
||||
|
||||
所谓动态优化,自然是相对静态优化来说的。在3.0版本之前,对于执行计划的优化,Spark SQL仰仗的主要是编译时(运行时之前)的统计信息,如数据表在磁盘中的存储大小,等等。
|
||||
|
||||
因此,在3.0版本之前,Spark SQL所有的优化机制(如Join策略的选择)都是静态的,它没有办法在运行时动态地调整执行计划,从而顺应数据集在运行时此消彼长的变化。
|
||||
|
||||
举例来说,在Spark SQL的逻辑优化阶段,两张大表的尺寸都超过了广播阈值,因此Spark SQL在物理优化阶段,就不得不选择Shuffle Join这种次优的策略。
|
||||
|
||||
但实际上,在运行时期间,其中一张表在Filter过后,剩余的数据量远小于广播阈值,完全可以放进广播变量。可惜此时“木已成舟”,静态优化机制没有办法再将Shuffle Join调整为Broadcast Join。
|
||||
|
||||
AQE
|
||||
|
||||
为了弥补静态优化的缺陷、同时让Spark SQL变得更加智能,Spark社区在3.0版本中推出了AQE机制。
|
||||
|
||||
AQE的全称是Adaptive Query Execution,翻译过来是“自适应查询执行”。它包含了3个动态优化特性,分别是Join策略调整、自动分区合并和自动倾斜处理。
|
||||
|
||||
或许是Spark社区对于新的优化机制偏向于保守,AQE机制默认是未开启的,要想充分利用上述的3个特性,我们得先把spark.sql.adaptive.enabled修改为true才行。
|
||||
|
||||
|
||||
|
||||
好啦,成功开启了AQE机制之后,接下来,我们就结合相关的配置项,来聊一聊这些特性都解决了哪些问题,以及它们是如何工作的。
|
||||
|
||||
Join策略调整
|
||||
|
||||
我们先来说说Join策略调整,如果用一句话来概括,Join策略调整指的就是Spark SQL在运行时动态地将原本的Shuffle Join策略,调整为执行更加高效的Broadcast Join。
|
||||
|
||||
具体来说,每当DAG中的Map阶段执行完毕,Spark SQL就会结合Shuffle中间文件的统计信息,重新计算Reduce阶段数据表的存储大小。如果发现基表尺寸小于广播阈值,那么Spark SQL就把下一阶段的Shuffle Join调整为Broadcast Join。
|
||||
|
||||
不难发现,这里的关键是Shuffle,以及Shuffle的中间文件。事实上,不光是Join策略调整这个特性,整个AQE机制的运行,都依赖于DAG中的Shuffle环节。
|
||||
|
||||
所谓巧妇难为无米之炊,要做到动态优化,Spark SQL必须要仰仗运行时的执行状态,而Shuffle中间文件,则是这些状态的唯一来源。
|
||||
|
||||
举例来说,通过Shuffle中间文件,Spark SQL可以获得诸如文件尺寸、Map Task数据分片大小、Reduce Task分片大小、空文件占比之类的统计信息。正是利用这些统计信息,Spark SQL才能在作业执行的过程中,动态地调整执行计划。
|
||||
|
||||
我们结合例子进一步来理解,以Join策略调整为例,给定如下查询语句,假设salaries表和employees表的存储大小都超过了广播阈值,在这种情况下,对于两张表的关联计算,Spark SQL只能选择Shuffle Join策略。
|
||||
|
||||
不过实际上,employees按照年龄过滤之后,剩余的数据量是小于广播阈值的。这个时候,得益于AQE机制的Join策略调整,Spark SQL能够把最初制定的Shuffle Join策略,调整为Broadcast Join策略,从而在运行时加速执行性能。
|
||||
|
||||
select * from salaries inner join employees
|
||||
on salaries.id = employees.id
|
||||
where employees.age >= 30 and employees.age < 45
|
||||
|
||||
|
||||
你看,在这种情况下,广播阈值的设置、以及基表过滤之后数据量的预估,就变得非常重要。原因在于,这两个要素决定了Spark SQL能否成功地在运行时充分利用AQE的Join策略调整特性,进而在整体上优化执行性能。因此,我们必须要掌握广播阈值的设置方法,以及数据集尺寸预估的方法。
|
||||
|
||||
介绍完Join策略调整,接下来,我们再来说说AQE机制的另外两个特性:自动分区合并与自动倾斜处理,它们都是对于Shuffle本身的优化策略。
|
||||
|
||||
我们先来说说,自动分区合并与自动倾斜处理都在尝试解决什么问题。我们知道,Shuffle的计算过程分为两个阶段:Map阶段和Reduce阶段。Map阶段的数据分布,往往由分布式文件系统中的源数据决定,因此数据集在这个阶段的分布,是相对均匀的。
|
||||
|
||||
Reduce阶段的数据分布则不同,它是由Distribution Key和Reduce阶段并行度决定的。并行度也就是分区数目,这个概念咱们在之前的几讲反复强调,想必你并不陌生。
|
||||
|
||||
而Distribution Key则定义了Shuffle分发数据的依据,对于reduceByKey算子来说,Distribution Key就是Paired RDD的Key;而对于repartition算子来说,Distribution Key就是传递给repartition算子的形参,如repartition($“Column Name”)。
|
||||
|
||||
在业务上,Distribution Key往往是user_id、item_id这一类容易产生倾斜的字段,相应地,数据集在Reduce阶段的分布往往也是不均衡的。
|
||||
|
||||
数据的不均衡往往体现在两个方面,一方面是一部分数据分区的体量过小,而另一方面,则是少数分区的体量极其庞大。AQE机制的自动分区合并与自动倾斜处理,正是用来应对数据不均衡的这两个方面。
|
||||
|
||||
自动分区合并
|
||||
|
||||
了解了自动分区合并的用武之地,接下来,我们来说说,Spark SQL具体如何做到把Reduce阶段过小的分区合并到一起。要弄清楚分区合并的工作原理,我们首先得搞明白:“分区合并从哪里开始?又到哪里结束呢?”
|
||||
|
||||
具体来说,Spark SQL怎么判断一个数据分区是不是足够小、它到底需不需要被合并?再者,既然是对多个分区做合并,那么自然就存在一个收敛条件。原因很简单,如果一直不停地合并下去,那么整个数据集就被捏合成了一个超级大的分区,并行度也会下降至1,显然,这不是我们想要的结果。
|
||||
|
||||
|
||||
|
||||
事实上,Spark SQL采用了一种相对朴素的方法,来实现分区合并。具体来说,Spark SQL事先并不去判断哪些分区是不是足够小,而是按照分区的编号依次进行扫描,当扫描过的数据体量超过了“目标尺寸”时,就进行一次合并。而这个目标尺寸,由以下两个配置项来决定。
|
||||
|
||||
|
||||
|
||||
其中,开发者可以通过第一个配置项spark.sql.adaptive.advisoryPartitionSizeInBytes来直接指定目标尺寸。第二个参数用于限制Reduce阶段在合并之后的并行度,避免因为合并导致并行度过低,造成CPU资源利用不充分。
|
||||
|
||||
结合数据集大小与最低并行度,我们可以反推出来每个分区的平均大小,假设我们把这个平均大小记作是#partitionSize。那么,实际的目标尺寸,取advisoryPartitionSizeInBytes设定值与#partitionSize之间较小的那个数值。
|
||||
|
||||
确定了目标尺寸之后,Spark SQL就会依序扫描数据分区,当相邻分区的尺寸之和大于目标尺寸的时候,Spark SQL就把扫描过的分区做一次合并。然后,继续使用这种方式,依次合并剩余的分区,直到所有分区都处理完毕。
|
||||
|
||||
自动倾斜处理
|
||||
|
||||
没有对比就没有鉴别,分析完自动分区合并如何搞定数据分区过小、过于分散的问题之后,接下来,我们再来说一说,自动倾斜处理如何应对那些倾斜严重的大分区。
|
||||
|
||||
经过上面的分析,我们不难发现,自动分区合并实际上包含两个关键环节,一个是确定合并的目标尺寸,一个是依次扫描合并。与之相对应,自动倾斜处理也分为两步,第一步是检测并判定体量较大的倾斜分区,第二步是把这些大分区拆分为小分区。要做到这两步,Spark SQL需要依赖如下3个配置项。
|
||||
|
||||
|
||||
|
||||
其中,前两个配置项用于判定倾斜分区,第3个配置项advisoryPartitionSizeInBytes我们刚刚学过,这个参数除了用于合并小分区外,同时还用于拆分倾斜分区,可以说是“一菜两吃”。
|
||||
|
||||
下面我们重点来讲一讲,Spark SQL如何利用前两个参数来判定大分区的过程。
|
||||
|
||||
首先,Spark SQL对所有数据分区按照存储大小做排序,取中位数作为基数。然后,将中位数乘以skewedPartitionFactor指定的比例系数,得到判定阈值。凡是存储尺寸大于判定阈值的数据分区,都有可能被判定为倾斜分区。
|
||||
|
||||
为什么说“有可能”,而不是“一定”呢?原因是,倾斜分区的判定,还要受到skewedPartitionThresholdInBytes参数的限制,它是判定倾斜分区的最低阈值。也就是说,只有那些尺寸大于skewedPartitionThresholdInBytes设定值的“候选分区”,才会最终判定为倾斜分区。
|
||||
|
||||
为了更好地理解这个判定的过程,我们来举个例子。假设数据表salaries有3个分区,大小分别是90MB、100MB和512MB。显然,这3个分区的中位数是100MB,那么拿它乘以比例系数skewedPartitionFactor(默认值为5),得到判定阈值为100MB * 5 = 500MB。因此,在咱们的例子中,只有最后一个尺寸为512MB的数据分区会被列为“候选分区”。
|
||||
|
||||
接下来,Spark SQL还要拿512MB与skewedPartitionThresholdInBytes作对比,这个参数的默认值是256MB。
|
||||
|
||||
显然,512MB比256MB要大得多,这个时候,Spark SQL才会最终把最后一个分区,判定为倾斜分区。相反,假设我们把skewedPartitionThresholdInBytes这个参数调大,设置为1GB,那么最后一个分区就不满足最低阈值,因此也就不会被判定为倾斜分区。
|
||||
|
||||
倾斜分区判定完毕之后,下一步,就是根据advisoryPartitionSizeInBytes参数指定的目标尺寸,对大分区进行拆分。假设我们把这个参数的值设置为256MB,那么刚刚512MB的大分区就会被拆成两个小分区(512MB / 2 = 256MB)。拆分之后,salaries表就由3个分区变成了4个分区,每个数据分区的尺寸,都不超过256MB。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,到此为止,与Spark SQL相关的重要配置项,我们就讲到这里。今天的内容很多,我们一起来总结一下。
|
||||
|
||||
首先,我们介绍了广播阈值这一概念,它的作用在于,当基表尺寸小于广播阈值时,Spark SQL将自动选择Broadcast Join策略来完成关联计算。
|
||||
|
||||
然后,我们分别介绍了AQE(Adaptive Query Execution)机制的3个特性,分别是Join策略调整、自动分区合并、以及自动倾斜处理。与Spark SQL的静态优化机制不同,AQE结合Shuffle中间文件提供的统计信息,在运行时动态地调整执行计划,从而达到优化作业执行性能的目的。
|
||||
|
||||
所谓Join策略调整,它指的是,结合过滤之后的基表尺寸与广播阈值,Spark SQL在运行时动态地将原本的Shuffle Join策略,调整为Broadcast Join策略的过程。基表尺寸的预估,可以使用如下方法来获得。
|
||||
|
||||
val df: DataFrame = _
|
||||
// 先对分布式数据集加Cache
|
||||
df.cache.count
|
||||
|
||||
// 获取执行计划
|
||||
val plan = df.queryExecution.logical
|
||||
|
||||
// 获取执行计划对于数据集大小的精确预估
|
||||
val estimated: BigInt = spark
|
||||
.sessionState
|
||||
.executePlan(plan)
|
||||
.optimizedPlan
|
||||
.stats
|
||||
.sizeInBytes
|
||||
|
||||
|
||||
自动分区合并与自动倾斜处理,实际上都是用来解决Shuffle过后,数据分布不均匀的问题。自动分区合并的作用,在于合并过小的数据分区,从而避免Task粒度过细、任务调度开销过高的问题。与之相对,自动倾斜处理,它的用途在于拆分过大的数据分区,从而避免个别Task负载过高而拖累整个作业的执行性能。
|
||||
|
||||
不论是广播阈值,还是AQE的诸多特性,我们都可以通过调节相关的配置项,来影响Spark SQL的优化行为。为了方便你回顾、查找这些配置项,我整理了如下表格,供你随时参考。
|
||||
|
||||
|
||||
|
||||
每课一练
|
||||
|
||||
结合AQE必须要依赖Shuffle中间文件这一特点,你能说一说,AQE有什么不尽如人意之处吗?(提示:从Shuffle的两个计算阶段出发,去思考这个问题)
|
||||
|
||||
欢迎你在留言区跟我交流讨论,也推荐你把这一讲分享给更多的同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
0
专栏/零基础入门Spark/20Hive+Spark强强联合:分布式数仓的不二之选.md
Normal file
0
专栏/零基础入门Spark/20Hive+Spark强强联合:分布式数仓的不二之选.md
Normal file
198
专栏/零基础入门Spark/21SparkUI(上):如何高效地定位性能问题?.md
Normal file
198
专栏/零基础入门Spark/21SparkUI(上):如何高效地定位性能问题?.md
Normal file
@ -0,0 +1,198 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
21 Spark UI(上):如何高效地定位性能问题?
|
||||
你好,我是吴磊。
|
||||
|
||||
到目前为止,我们完成了基础知识和Spark SQL这两个模块的学习,这也就意味着,我们完成了Spark入门“三步走”中的前两步,首先恭喜你!在学习的过程中,我们逐渐意识到,Spark Core与Spark SQL作为Spark并驾齐驱的执行引擎与优化引擎,承载着所有类型的计算负载,如批处理、流计算、数据分析、机器学习,等等。
|
||||
|
||||
那么显然,Spark Core与Spark SQL运行得是否稳定与高效,决定着Spark作业或是应用的整体“健康状况”。不过,在日常的开发工作中,我们总会遇到Spark应用运行失败、或是执行效率未达预期的情况。对于这类问题,想找到根本原因(Root Cause),我们往往需要依赖Spark UI来获取最直接、最直观的线索。
|
||||
|
||||
如果我们把失败的、或是执行低效的Spark应用看作是“病人”的话,那么Spark UI中关于应用的众多度量指标(Metrics),就是这个病人的“体检报告”。结合多样的Metrics,身为“大夫”的开发者即可结合经验来迅速地定位“病灶”。
|
||||
|
||||
今天这一讲,让我们以小汽车摇号中“倍率与中签率分析”的应用(详细内容你可以回顾[第13讲])为例,用图解的方式,一步步地去认识Spark UI,看一看它有哪些关键的度量指标,这些指标都是什么含义,又能为开发者提供哪些洞察(Insights)?
|
||||
|
||||
这里需要说明的是,Spark UI的讲解涉及到大量的图解、代码与指标释义,内容庞杂。因此,为了减轻你的学习负担,我按照Spark UI的入口类型(一级入口、二级入口)把Spark UI拆成了上、下两讲。一级入口比较简单、直接,我们今天这一讲,先来讲解这一部分,二级入口的讲解留到下一讲去展开。
|
||||
|
||||
准备工作
|
||||
|
||||
在正式开始介绍Spark UI之前,我们先来简单交代一下图解案例用到的环境、配置与代码。你可以参考这里给出的细节,去复现“倍率与中签率分析”案例Spark UI中的每一个界面,然后再结合今天的讲解,以“看得见、摸得着”的方式,去更加直观、深入地熟悉每一个页面与度量指标。
|
||||
|
||||
当然,如果你手头一时没有合适的执行环境,也不要紧。咱们这一讲的特点,就是图多,后面我特意准备了大量的图片和表格,带你彻底了解Spark UI。
|
||||
|
||||
由于小汽车摇号数据体量不大,因此在计算资源方面,我们的要求并不高,“倍率与中签率分析”案例用到的资源如下所示:
|
||||
|
||||
|
||||
|
||||
接下来是代码,在[小汽车摇号应用开发]那一讲,我们一步步地实现了“倍率与中签率分析”的计算逻辑,这里咱们不妨一起回顾一下。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
val rootPath: String = _
|
||||
// 申请者数据
|
||||
val hdfs_path_apply: String = s"${rootPath}/apply"
|
||||
// spark是spark-shell中默认的SparkSession实例
|
||||
// 通过read API读取源文件
|
||||
val applyNumbersDF: DataFrame = spark.read.parquet(hdfs_path_apply)
|
||||
|
||||
// 中签者数据
|
||||
val hdfs_path_lucky: String = s"${rootPath}/lucky"
|
||||
// 通过read API读取源文件
|
||||
val luckyDogsDF: DataFrame = spark.read.parquet(hdfs_path_lucky)
|
||||
|
||||
// 过滤2016年以后的中签数据,且仅抽取中签号码carNum字段
|
||||
val filteredLuckyDogs: DataFrame = luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum")
|
||||
|
||||
// 摇号数据与中签数据做内关联,Join Key为中签号码carNum
|
||||
val jointDF: DataFrame = applyNumbersDF.join(filteredLuckyDogs, Seq("carNum"), "inner")
|
||||
|
||||
// 以batchNum、carNum做分组,统计倍率系数
|
||||
val multipliers: DataFrame = jointDF.groupBy(col("batchNum"),col("carNum"))
|
||||
.agg(count(lit(1)).alias("multiplier"))
|
||||
|
||||
// 以carNum做分组,保留最大的倍率系数
|
||||
val uniqueMultipliers: DataFrame = multipliers.groupBy("carNum")
|
||||
.agg(max("multiplier").alias("multiplier"))
|
||||
|
||||
// 以multiplier倍率做分组,统计人数
|
||||
val result: DataFrame = uniqueMultipliers.groupBy("multiplier")
|
||||
.agg(count(lit(1)).alias("cnt"))
|
||||
.orderBy("multiplier")
|
||||
|
||||
result.collect
|
||||
|
||||
|
||||
今天我们在此基础上做一点变化,为了方便展示StorageTab页面内容,我们这里“强行”给applyNumbersDF 和luckyDogsDF这两个DataFrame都加了Cache。对于引用数量为1的数据集,实际上是没有必要加Cache的,这一点还需要你注意。
|
||||
|
||||
回顾完代码之后,再来看看配置项。为了让Spark UI能够展示运行中以及执行完毕的应用,我们还需要设置如下配置项并启动History Server。
|
||||
|
||||
|
||||
|
||||
// SPARK_HOME表示Spark安装目录
|
||||
${SPAK_HOME}/sbin/start-history-server.sh
|
||||
|
||||
|
||||
好啦,到此为止,一切准备就绪。接下来,让我们启动spark-shell,并提交“倍率与中签率分析”的代码,然后把目光转移到Host1的8080端口,也就是Driver所在节点的8080端口。
|
||||
|
||||
Spark UI 一级入口
|
||||
|
||||
今天的故事,要从Spark UI的入口开始,其实刚才说的8080端口正是Spark UI的入口,我们可以从这里进入Spark UI。
|
||||
|
||||
打开Spark UI,首先映入眼帘的是默认的Jobs页面。Jobs页面记录着应用中涉及的Actions动作,以及与数据读取、移动有关的动作。其中,每一个Action都对应着一个Job,而每一个Job都对应着一个作业。我们一会再去对Jobs页面做展开,现在先把目光集中在Spark UI最上面的导航条,这里罗列着Spark UI所有的一级入口,如下图所示。
|
||||
|
||||
|
||||
|
||||
导航条最左侧是Spark Logo以及版本号,后面则依次罗列着6个一级入口,每个入口的功能与作用我整理到了如下的表格中,你可以先整体过一下,后面我们再挨个细讲。
|
||||
|
||||
|
||||
|
||||
形象点说,这6个不同的入口,就像是体检报告中6大类不同的体检项,比如内科、外科、血常规,等等。接下来,让我们依次翻开“体检报告”的每一个大项,去看看“倍率与中签率分析”这个家伙的体质如何。
|
||||
|
||||
不过,本着由简入难的原则,咱们并不会按照Spark UI罗列的顺序去查看各个入口,而是按照Executors > Environment > Storage > SQL > Jobs > Stages的顺序,去翻看“体检报告”。
|
||||
|
||||
其中,前3个入口都是详情页,不存在二级入口;而后3个入口都是预览页,都需要访问二级入口,才能获取更加详细的内容。显然,相比预览页,详情页来得更加直接。接下来,让我们从Executors开始,先来了解一下应用的计算负载。
|
||||
|
||||
Executors
|
||||
|
||||
Executors Tab的主要内容如下,主要包含“Summary”和“Executors”两部分。这两部分所记录的度量指标是一致的,其中“Executors”以更细的粒度记录着每一个Executor的详情,而第一部分“Summary”是下面所有Executors度量指标的简单加和。
|
||||
|
||||
|
||||
|
||||
我们一起来看一下,Spark UI都提供了哪些Metrics,来量化每一个Executor的工作负载(Workload)。为了叙述方便,我们以表格的形式说明这些Metrics的含义与作用。
|
||||
|
||||
|
||||
|
||||
不难发现,Executors页面清清楚楚地记录着每一个Executor消耗的数据量,以及它们对CPU、内存与磁盘等硬件资源的消耗。基于这些信息,我们可以轻松判断不同Executors之间是否存在负载不均衡的情况,进而判断应用中是否存在数据倾斜的隐患。
|
||||
|
||||
对于Executors页面中每一个Metrics的具体数值,它们实际上是Tasks执行指标在Executors粒度上的汇总。因此,对于这些Metrics的释义,咱们留到Stages二级入口再去展开,这里暂时不做一一深入。你不妨结合“倍率与中签率分析”的应用,去浏览一下不同Metrics的具体数值,先对这些数字有一个直观上的感受。
|
||||
|
||||
实际上,这些具体的数值,并没有什么特别之处,除了RDD Blocks和Complete Tasks这两个Metrics。细看一下这两个指标,你会发现,RDD Blocks是51(总数),而Complete Tasks(总数)是862。
|
||||
|
||||
之前讲RDD并行度的时候,我们说过,RDD并行度就是RDD的分区数量,每个分区对应着一个Task,因此RDD并行度与分区数量、分布式任务数量是一致的。可是,截图中的51与862,显然不在一个量级,这是怎么回事呢?
|
||||
|
||||
这里我先买个关子,把它给你留作思考题,你不妨花些时间,去好好想一想。如果没想清楚也没关系,我们在评论区会继续讨论这个问题。
|
||||
|
||||
Environment
|
||||
|
||||
接下来,我们再来说说Environment。顾名思义,Environment页面记录的是各种各样的环境变量与配置项信息,如下图所示。
|
||||
|
||||
|
||||
|
||||
为了让你抓住主线,我并没有给你展示Environment页面所包含的全部信息,就类别来说,它包含5大类环境信息,为了方便叙述,我把它们罗列到了下面的表格中。
|
||||
|
||||
|
||||
|
||||
显然,这5类信息中,Spark Properties是重点,其中记录着所有在运行时生效的Spark配置项设置。通过Spark Properties,我们可以确认运行时的设置,与我们预期的设置是否一致,从而排除因配置项设置错误而导致的稳定性或是性能问题。
|
||||
|
||||
Storage
|
||||
|
||||
说完Executors与Environment,我们来看一级入口的最后一个详情页:Storage。
|
||||
|
||||
|
||||
|
||||
Storage详情页,记录着每一个分布式缓存(RDD Cache、DataFrame Cache)的细节,包括缓存级别、已缓存的分区数、缓存比例、内存大小与磁盘大小。
|
||||
|
||||
在[第8讲],我们介绍过Spark支持的不同缓存级别,它是存储介质(内存、磁盘)、存储形式(对象、序列化字节)与副本数量的排列组合。对于DataFrame来说,默认的级别是单副本的Disk Memory Deserialized,如上图所示,也就是存储介质为内存加磁盘,存储形式为对象的单一副本存储方式。
|
||||
|
||||
|
||||
|
||||
Cached Partitions与Fraction Cached分别记录着数据集成功缓存的分区数量,以及这些缓存的分区占所有分区的比例。当Fraction Cached小于100%的时候,说明分布式数据集并没有完全缓存到内存(或是磁盘),对于这种情况,我们要警惕缓存换入换出可能会带来的性能隐患。
|
||||
|
||||
后面的Size in Memory与Size in Disk,则更加直观地展示了数据集缓存在内存与硬盘中的分布。从上图中可以看到,由于内存受限(3GB/Executor),摇号数据几乎全部被缓存到了磁盘,只有584MB的数据,缓存到了内存中。坦白地说,这样的缓存,对于数据集的重复访问,并没有带来实质上的性能收益。
|
||||
|
||||
基于Storage页面提供的详细信息,我们可以有的放矢地设置与内存有关的配置项,如spark.executor.memory、spark.memory.fraction、spark.memory.storageFraction,从而有针对性对Storage Memory进行调整。
|
||||
|
||||
SQL
|
||||
|
||||
接下来,我们继续说一级入口的SQL页面。当我们的应用包含DataFrame、Dataset或是SQL的时候,Spark UI的SQL页面,就会展示相应的内容,如下图所示。
|
||||
|
||||
|
||||
|
||||
具体来说,一级入口页面,以Actions为单位,记录着每个Action对应的Spark SQL执行计划。我们需要点击“Description”列中的超链接,才能进入到二级页面,去了解每个执行计划的详细信息。这部分内容,我们留到下一讲的二级入口详情页再去展开。
|
||||
|
||||
Jobs
|
||||
|
||||
同理,对于Jobs页面来说,Spark UI也是以Actions为粒度,记录着每个Action对应作业的执行情况。我们想要了解作业详情,也必须通过“Description”页面提供的二级入口链接。你先有个初步认识就好,下一讲我们再去展开。
|
||||
|
||||
|
||||
|
||||
相比SQL页面的3个Actions:save(保存计算结果)、count(统计申请编号)、count(统计中签编号),结合前面的概览页截图你会发现,Jobs页面似乎凭空多出来很多Actions。
|
||||
|
||||
主要原因在于,在Jobs页面,Spark UI会把数据的读取、访问与移动,也看作是一类“Actions”,比如图中Job Id为0、1、3、4的那些。这几个Job,实际上都是在读取源数据(元数据与数据集本身)。
|
||||
|
||||
至于最后多出来的、Job Id为7的save,你不妨结合最后一行代码,去想想问什么。这里我还是暂时卖个关子,留给你足够的时间去思考,咱们评论区见。
|
||||
|
||||
result05_01.write.mode("Overwrite").format("csv").save(s"${rootPath}/results/result05_01")
|
||||
|
||||
|
||||
Stages
|
||||
|
||||
我们知道,每一个作业,都包含多个阶段,也就是我们常说的Stages。在Stages页面,Spark UI罗列了应用中涉及的所有Stages,这些Stages分属于不同的作业。要想查看哪些Stages隶属于哪个Job,还需要从Jobs的Descriptions二级入口进入查看。
|
||||
|
||||
|
||||
|
||||
Stages页面,更多地是一种预览,要想查看每一个Stage的详情,同样需要从“Description”进入Stage详情页(下一讲详细展开)。
|
||||
|
||||
好啦,到此为止,对于导航条中的不同页面,我们都做了不同程度的展开。简单汇总下来,其中Executors、Environment、Storage是详情页,开发者可以通过这3个页面,迅速地了解集群整体的计算负载、运行环境,以及数据集缓存的详细情况;而SQL、Jobs、Stages,更多地是一种罗列式的展示,想要了解其中的细节,还需要进入到二级入口。
|
||||
|
||||
正如开篇所说,二级入口的讲解,我们留到下一讲再去探讨,敬请期待。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,今天的课程,到这里就讲完啦。今天的内容比较多,涉及的Metrics纷繁而又复杂,仅仅听一遍我的讲解,还远远不够,还需要你结合日常的开发,去多多摸索与体会,加油!
|
||||
|
||||
今天这一讲,我们从简单、直接的一级入口入手,按照“Executors -> Environment -> Storage -> SQL -> Jobs -> Stages”的顺序,先后介绍了一级入口的详情页与概览页。对于这些页面中的内容,我把需要重点掌握的部分,整理到了如下表格,供你随时参考。
|
||||
|
||||
|
||||
|
||||
每课一练
|
||||
|
||||
今天的思考题,我们在课程中已经提过了。一个是在Executors页面,为什么RDD Blocks与Complete Tasks的数量不一致。第二个是,在Jobs页面,为什么最后会多出来一个save Action?
|
||||
|
||||
欢迎你在留言区跟我交流探讨,也欢迎推荐你把这一讲分享给有需要的朋友、同事。
|
||||
|
||||
|
||||
|
||||
|
187
专栏/零基础入门Spark/22SparkUI(下):如何高效地定位性能问题?.md
Normal file
187
专栏/零基础入门Spark/22SparkUI(下):如何高效地定位性能问题?.md
Normal file
@ -0,0 +1,187 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 Spark UI(下):如何高效地定位性能问题?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们一起梳理了Spark UI的一级入口。其中Executors、Environment、Storage是详情页,开发者可以通过这3个页面,迅速地了解集群整体的计算负载、运行环境,以及数据集缓存的详细情况。不过SQL、Jobs、Stages,更多地是一种罗列式的展示,想要了解其中的细节,还需要进入到二级入口。
|
||||
|
||||
沿用之前的比喻,身为“大夫”的开发者想要结合经验,迅速定位“病灶”,离不开各式各样的指标项。而今天要讲的二级入口,相比一级入口,内容更加丰富、详尽。要想成为一名“临床经验丰富”的老医生,咱们先要做到熟练解读这些度量指标。
|
||||
|
||||
|
||||
|
||||
所谓二级入口,它指的是,通过一次超链接跳转才能访问到的页面。对于SQL、Jobs和Stages这3类入口来说,二级入口往往已经提供了足够的信息,基本覆盖了“体检报告”的全部内容。因此,尽管Spark UI也提供了少量的三级入口(需要两跳才能到达的页面),但是这些隐藏在“犄角旮旯”的三级入口,往往并不需要开发者去特别关注。
|
||||
|
||||
接下来,我们就沿着SQL -> Jobs -> Stages的顺序,依次地去访问它们的二级入口,从而针对全局DAG、作业以及执行阶段,获得更加深入的探索与洞察。
|
||||
|
||||
SQL详情页
|
||||
|
||||
在SQL Tab一级入口,我们看到有3个条目,分别是count(统计申请编号)、count(统计中签编号)和save。前两者的计算过程,都是读取数据源、缓存数据并触发缓存的物化,相对比较简单,因此,我们把目光放在save这个条目上。
|
||||
|
||||
|
||||
|
||||
点击图中的“save at:27”,即可进入到该作业的执行计划页面,如下图所示。
|
||||
|
||||
|
||||
|
||||
为了聚焦重点,这里我们仅截取了部分的执行计划,想要获取完整的执行计划,你可以通过访问这里来获得。为了方便你阅读,这里我手绘出了执行计划的示意图,供你参考,如下图所示。
|
||||
|
||||
|
||||
|
||||
可以看到,“倍率与中签率分析”应用的计算过程,非常具有代表性,它涵盖了数据分析场景中大部分的操作,也即过滤、投影、关联、分组聚合和排序。图中红色的部分为Exchange,代表的是Shuffle操作,蓝色的部分为Sort,也就是排序,而绿色的部分是Aggregate,表示的是(局部与全局的)数据聚合。
|
||||
|
||||
无疑,这三部分是硬件资源的主要消费者,同时,对于这3类操作,Spark UI更是提供了详细的Metrics来刻画相应的硬件资源消耗。接下来,咱们就重点研究一下这3类操作的度量指标。
|
||||
|
||||
Exchange
|
||||
|
||||
下图中并列的两个Exchange,对应的是示意图中SortMergeJoin之前的两个Exchange。它们的作用是对申请编码数据与中签编码数据做Shuffle,为数据关联做准备。
|
||||
|
||||
|
||||
|
||||
可以看到,对于每一个Exchange,Spark UI都提供了丰富的Metrics来刻画Shuffle的计算过程。从Shuffle Write到Shuffle Read,从数据量到处理时间,应有尽有。为了方便说明,对于Metrics的解释与释义,我以表格的方式进行了整理,供你随时查阅。
|
||||
|
||||
|
||||
|
||||
结合这份Shuffle的“体检报告”,我们就能以量化的方式,去掌握Shuffle过程的计算细节,从而为调优提供更多的洞察与思路。
|
||||
|
||||
为了让你获得直观感受,我还是举个例子说明。比方说,我们观察到过滤之后的中签编号数据大小不足10MB(7.4MB),这时我们首先会想到,对于这样的大表Join小表,Spark SQL选择了SortMergeJoin策略是不合理的。
|
||||
|
||||
基于这样的判断,我们完全可以让Spark SQL选择BroadcastHashJoin策略来提供更好的执行性能。至于调优的具体方法,想必不用我多说,你也早已心领神会:要么用强制广播,要么利用Spark 3.x版本提供的AQE特性。
|
||||
|
||||
你不妨结合本讲开头的代码,去完成SortMergeJoin到BroadcastHashJoin策略转换的调优,期待你在留言区分享你的调优结果。
|
||||
|
||||
Sort
|
||||
|
||||
接下来,我们再来说说Sort。相比Exchange,Sort的度量指标没那么多,不过,他们足以让我们一窥Sort在运行时,对于内存的消耗,如下图所示。
|
||||
|
||||
|
||||
|
||||
按照惯例,我们还是先把这些Metrics整理到表格中,方便后期查看。
|
||||
|
||||
|
||||
|
||||
可以看到,“Peak memory total”和“Spill size total”这两个数值,足以指导我们更有针对性地去设置spark.executor.memory、spark.memory.fraction、spark.memory.storageFraction,从而使得Execution Memory区域得到充分的保障。
|
||||
|
||||
以上图为例,结合18.8GB的峰值消耗,以及12.5GB的磁盘溢出这两条信息,我们可以判断出,当前3GB的Executor Memory是远远不够的。那么我们自然要去调整上面的3个参数,来加速Sort的执行性能。
|
||||
|
||||
Aggregate
|
||||
|
||||
与Sort类似,衡量Aggregate的度量指标,主要记录的也是操作的内存消耗,如图所示。
|
||||
|
||||
|
||||
|
||||
可以看到,对于Aggregate操作,Spark UI也记录着磁盘溢出与峰值消耗,即Spill size和Peak memory total。这两个数值也为内存的调整提供了依据,以上图为例,零溢出与3.2GB的峰值消耗,证明当前3GB的Executor Memory设置,对于Aggregate计算来说是绰绰有余的。
|
||||
|
||||
到此为止,我们分别介绍了Exchange、Sort和Aggregate的度量指标,并结合“倍率与中签率分析”的例子,进行了简单的调优分析。
|
||||
|
||||
纵观“倍率与中签率分析”完整的DAG,我们会发现它包含了若干个Exchange、Sort、Aggregate以及Filter和Project。结合上述的各类Metrics,对于执行计划的观察与洞见,我们需要以统筹的方式,由点到线、由局部到全局地去进行。
|
||||
|
||||
Jobs详情页
|
||||
|
||||
接下来,我们再来说说Jobs详情页。Jobs详情页非常的简单、直观,它罗列了隶属于当前Job的所有Stages。要想访问每一个Stage的执行细节,我们还需要通过“Description”的超链接做跳转。
|
||||
|
||||
|
||||
|
||||
Stages详情页
|
||||
|
||||
实际上,要访问Stage详情,我们还有另外一种选择,那就是直接从Stages一级入口进入,然后完成跳转。因此,Stage详情页也归类到二级入口。接下来,我们以Id为10的Stage为例,去看一看详情页都记录着哪些关键信息。
|
||||
|
||||
在所有二级入口中,Stage详情页的信息量可以说是最大的。点进Stage详情页,可以看到它主要包含3大类信息,分别是Stage DAG、Event Timeline与Task Metrics。
|
||||
|
||||
其中,Task Metrics又分为“Summary”与“Entry details”两部分,提供不同粒度的信息汇总。而Task Metrics中记录的指标类别,还可以通过“Show Additional Metrics”选项进行扩展。
|
||||
|
||||
|
||||
|
||||
Stage DAG
|
||||
|
||||
接下来,我们沿着“Stage DAG -> Event Timeline -> Task Metrics”的顺序,依次讲讲这些页面所包含的内容。
|
||||
|
||||
首先,我们先来看最简单的Stage DAG。点开蓝色的“DAG Visualization”按钮,我们就能获取到当前Stage的DAG,如下图所示。
|
||||
|
||||
|
||||
|
||||
之所以说Stage DAG简单,是因为咱们在SQL二级入口,已经对DAG做过详细的说明。而Stage DAG仅仅是SQL页面完整DAG的一个子集,毕竟,SQL页面的DAG,针对的是作业(Job)。因此,只要掌握了作业的DAG,自然也就掌握了每一个Stage的DAG。
|
||||
|
||||
Event Timeline
|
||||
|
||||
与“DAG Visualization”并列,在“Summary Metrics”之上,有一个“Event Timeline”按钮,点开它,我们可以得到如下图所示的可视化信息。
|
||||
|
||||
|
||||
|
||||
Event Timeline,记录着分布式任务调度与执行的过程中,不同计算环节主要的时间花销。图中的每一个条带,都代表着一个分布式任务,条带由不同的颜色构成。其中不同颜色的矩形,代表不同环节的计算时间。
|
||||
|
||||
为了方便叙述,我还是用表格形式帮你梳理了这些环节的含义与作用,你可以保存以后随时查看。
|
||||
|
||||
|
||||
|
||||
理想情况下,条带的大部分应该都是绿色的(如图中所示),也就是任务的时间消耗,大部分都是执行时间。不过,实际情况并不总是如此,比如,有些时候,蓝色的部分占比较多,或是橙色的部分占比较大。
|
||||
|
||||
在这些情况下,我们就可以结合Event Timeline,来判断作业是否存在调度开销过大、或是Shuffle负载过重的问题,从而有针对性地对不同环节做调优。
|
||||
|
||||
比方说,如果条带中深蓝的部分(Scheduler Delay)很多,那就说明任务的调度开销很重。这个时候,我们就需要参考公式:D / P ~ M / C,来相应地调整CPU、内存与并行度,从而减低任务的调度开销。其中,D是数据集尺寸,P为并行度,M是Executor内存,而C是Executor的CPU核数。波浪线~表示的是,等式两边的数值,要在同一量级。
|
||||
|
||||
再比如,如果条带中黄色(Shuffle Write Time)与橙色(Shuffle Read Time)的面积较大,就说明任务的Shuffle负载很重,这个时候,我们就需要考虑,有没有可能通过利用Broadcast Join来消除Shuffle,从而缓解任务的Shuffle负担。
|
||||
|
||||
Task Metrics
|
||||
|
||||
说完Stage DAG与Event Timeline,最后,我们再来说一说Stage详情页的重头戏:Task Metrics。
|
||||
|
||||
之所以说它是重头戏,在于Task Metrics以不同的粒度,提供了详尽的量化指标。其中,“Tasks”以Task为粒度,记录着每一个分布式任务的执行细节,而“Summary Metrics”则是对于所有Tasks执行细节的统计汇总。我们先来看看粗粒度的“Summary Metrics”,然后再去展开细粒度的“Tasks”。
|
||||
|
||||
Summary Metrics
|
||||
|
||||
首先,我们点开“Show Additional Metrics”按钮,勾选“Select All”,让所有的度量指标都生效,如下图所示。这么做的目的,在于获取最详尽的Task执行信息。
|
||||
|
||||
|
||||
|
||||
可以看到,“Select All”生效之后,Spark UI打印出了所有的执行细节。老规矩,为了方便叙述,我还是把这些Metrics整理到表格中,方便你随时查阅。其中,Task Deserialization Time、Result Serialization Time、Getting Result Time、Scheduler Delay与刚刚表格中的含义相同,不再赘述,这里我们仅整理新出现的Task Metrics。
|
||||
|
||||
|
||||
|
||||
对于这些详尽的Task Metrics,难能可贵地,Spark UI以最大最小(max、min)以及分位点(25%分位、50%分位、75%分位)的方式,提供了不同Metrics的统计分布。这一点非常重要,原因在于,这些Metrics的统计分布,可以让我们非常清晰地量化任务的负载分布。
|
||||
|
||||
换句话说,根据不同Metrics的统计分布信息,我们就可以轻而易举地判定,当前作业的不同任务之间,是相对均衡,还是存在严重的倾斜。如果判定计算负载存在倾斜,那么我们就要利用AQE的自动倾斜处理,去消除任务之间的不均衡,从而改善作业性能。
|
||||
|
||||
在上面的表格中,有一半的Metrics是与Shuffle直接相关的,比如Shuffle Read Size / Records,Shuffle Remote Reads,等等。
|
||||
|
||||
这些Metrics我们在介绍SQL详情的时候,已经详细说过了。另外,Duration、GC Time、以及Peak Execution Memory,这些Metrics的含义,要么已经讲过,要么过于简单、无需解释。因此,对于这3个指标,咱们也不再多着笔墨。
|
||||
|
||||
这里特别值得你关注的,是Spill(Memory)和Spill(Disk)这两个指标。Spill,也即溢出数据,它指的是因内存数据结构(PartitionedPairBuffer、AppendOnlyMap,等等)空间受限,而腾挪出去的数据。Spill(Memory)表示的是,这部分数据在内存中的存储大小,而Spill(Disk)表示的是,这些数据在磁盘中的大小。
|
||||
|
||||
因此,用Spill(Memory)除以Spill(Disk),就可以得到“数据膨胀系数”的近似值,我们把它记为Explosion ratio。有了Explosion ratio,对于一份存储在磁盘中的数据,我们就可以估算它在内存中的存储大小,从而准确地把握数据的内存消耗。
|
||||
|
||||
Tasks
|
||||
|
||||
介绍完粗粒度的Summary Metrics,接下来,我们再来说说细粒度的“Tasks”。实际上,Tasks的不少指标,与Summary是高度重合的,如下图所示。同理,这些重合的Metrics,咱们不再赘述,你可以参考Summary的部分,来理解这些Metrics。唯一的区别,就是这些指标是针对每一个Task进行度量的。
|
||||
|
||||
|
||||
|
||||
按照惯例,咱们还是把Tasks中那些新出现的指标,整理到表格中,以备后续查看。
|
||||
|
||||
|
||||
|
||||
可以看到,新指标并不多,这里最值得关注的,是Locality level,也就是本地性级别。在调度系统中,我们讲过,每个Task都有自己的本地性倾向。结合本地性倾向,调度系统会把Tasks调度到合适的Executors或是计算节点,尽可能保证“数据不动、代码动”。
|
||||
|
||||
Logs与Errors属于Spark UI的三级入口,它们是Tasks的执行日志,详细记录了Tasks在执行过程中的运行时状态。一般来说,我们不需要深入到三级入口去进行Debug。Errors列提供的报错信息,往往足以让我们迅速地定位问题所在。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,今天的课程,到这里就讲完啦。今天这一讲,我们分别学习了二级入口的SQL、Jobs与Stages。每个二级入口的内容都很丰富,提前知道它们所涵盖的信息,对我们寻找、启发与探索性能调优的思路非常有帮助。
|
||||
|
||||
到此为止,关于Spark UI的全部内容就讲完啦。Spark UI涉及的Metrics纷繁而又复杂,一次性记住确实有难度,所以通过这一讲,你只要清楚各级入口怎么找到,知道各个指标能给我们提供什么信息就好了。当然,仅仅跟着我去用“肉眼”学习一遍只是第一步,之后还需要你结合日常的开发,去多多摸索与体会,加油!
|
||||
|
||||
最后的最后,还是想提醒你,由于我们的应用是通过spark-shell提交的,因此节点8080端口的Spark UI会一直展示应用的“体检报告”。在我们退出spark-shell之后,节点8080端口的内存也随即消失(404 Page not found)。
|
||||
|
||||
要想再次查看应用的“体检报告”,需要移步至节点的18080端口,这里是Spark History Server的领地,它收集了所有(已执行完毕)应用的“体检报告”,并同样使用Spark UI的形式进行展示,切记切记。
|
||||
|
||||
每课一练
|
||||
|
||||
今天的思考题,需要你发散思维。学习过Spark UI之后,请你说一说,都可以通过哪些途径,来定位数据倾斜问题?
|
||||
|
||||
欢迎你把Spark UI使用的心得体会,分享到课后的评论区,我们一起讨论,共同进步!也推荐你把这一讲分享更多同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
276
专栏/零基础入门Spark/23SparkMLlib:从“房价预测”开始.md
Normal file
276
专栏/零基础入门Spark/23SparkMLlib:从“房价预测”开始.md
Normal file
@ -0,0 +1,276 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 Spark MLlib:从“房价预测”开始
|
||||
你好,我是吴磊。
|
||||
|
||||
从今天这一讲开始,我们进入课程的第三个模块:Spark MLlib机器学习。在数据科学、机器学习与人工智能火热的当下,积累一些机器学习的知识储备,有利于我们拓展视野,甚至为职业发展提供新的支点。
|
||||
|
||||
在这个模块中,我们首先从一个“房价预测”的小项目入手,来初步了解机器学习以及Spark MLlib的基本用法。接下来,我们会着重讲解机器学习的两个关键环节:特征工程与模型调优,在深入学习Spark MLlib的同时,进一步优化“房价预测”的模型效果,从而让房价的预测越来越准。
|
||||
|
||||
熟悉了关键环节之后,我们再去探讨,在Spark MLlib的框架之下,高效构建机器学习流水线的一般方法。好啦,话不多说,让我们先来一起看看“房价预测”这个小项目吧。
|
||||
|
||||
为兼顾项目的权威性与代表性,这里我选择了Kaggle(数据科学竞赛平台)的“House Prices - Advanced Regression Techniques”竞赛项目。这个项目的要求是,给定房屋的79个属性特征以及历史房价,训练房价预测模型,并在测试集上验证模型的预测效果。
|
||||
|
||||
数据准备
|
||||
|
||||
虽然项目的要求相当清晰明了,不过你可能会说:“我没有机器学习背景,上面提到这些什么特征啊、模型啊,还有测试集、效果验证,我都没有概念,那接下来的课程,要怎么学呢?”别担心,随着课程的推进,我会逐渐把这些概念给你讲清楚。
|
||||
|
||||
接下来,我们先直观了解一下项目中的房屋数据。
|
||||
|
||||
房屋数据记录着美国爱荷华州2006年到2010年的房屋交易数据,其中包含着79个房屋属性以及当时的成交价格,你可以通过竞赛项目的data页面进行下载。
|
||||
|
||||
数据下载、解压之后,我们会得到4个文件,分别是data_description.txt、train.csv、test.csv和sample_submission.csv。这4个文件的体量很小,总大小不超过5MB,它们的内容与含义如下表所示。
|
||||
|
||||
|
||||
|
||||
其中,train.csv与test.csv的Schema完全一致,都包含79个房屋属性字段以及一个交易价格字段,描述文件则详细地记录着79个字段的含义与取值范围。二者的唯一区别在于用途,train.csv用于训练模型,而test.csv用于验证模型效果。-
|
||||
sample_submission.csv文件则用于提交比赛结果,由于咱们暂时不打算参赛,因此这个文件可以暂时忽略。
|
||||
|
||||
说到这里,我们又提到了与机器学习有关的一些术语,如“训练数据”、“测试数据”、“模型效果”,等等。为了照顾缺少机器学习背景的同学,接下来,我们对机器做一个简单的介绍。
|
||||
|
||||
机器学习简介
|
||||
|
||||
不过,在去正式介绍机器学习之前,我们不妨先来想一想人类学习的过程,然后再来看看,在学习这方面,机器与人类有哪些相似之处。
|
||||
|
||||
每个人在成长的过程中,或是通过书本,或是结合过往的经历,都在不断地吸取经验教训,从而总结出为人处世、待人接物的一般原则,然后再将这些原则应用到余下的人生中去。人类学习与成长的过程,大抵如此。
|
||||
|
||||
|
||||
|
||||
实际上,机器学习的过程也是类似的。基于历史数据,机器会根据一定的算法,尝试从历史数据中挖掘并捕捉出一般规律。然后,再把找到的规律应用到新产生的数据中,从而实现在新数据上的预测与判断。
|
||||
|
||||
|
||||
|
||||
好啦,对于机器学习有了基本的认知之后,接下来, 我们就给它下一个正式的定义,从而以更加严谨的方式,来认识机器学习。
|
||||
|
||||
所谓机器学习(Machine Learning),它指的是这样一种计算过程:对于给定的训练数据(Training samples),选择一种先验的数据分布模型(Models),然后借助优化算法(Learning Algorithms)自动地持续调整模型参数(Model Weights / Parameters),从而让模型不断逼近训练数据的原始分布。
|
||||
|
||||
这个持续调整模型参数的过程称为“模型训练”(Model Training)。模型的训练依赖于优化算法,基于过往的计算误差(Loss),优化算法以不断迭代的方式,自动地对模型参数进行调整。由于模型训练是一个持续不断的过程,那么自然就需要一个收敛条件(Convergence Conditions),来终结模型的训练过程。一旦收敛条件触发,即宣告模型训练完毕。
|
||||
|
||||
模型训练完成之后,我们往往会用一份新的数据集(Testing samples),去测试模型的预测能力,从而验证模型的训练效果,这个过程,我们把它叫作“模型测试”(Model Testing)。
|
||||
|
||||
说到这里,你的大脑可能快被各种各样的机器学习术语挤爆了,不要紧,我们结合房价预测的例子,来更好地理解这些概念。
|
||||
|
||||
回顾房价预测项目的4个数据文件,其中的train.csv就是我们说的训练数据(Training samples),它用于训练机器学习模型。相应地,test.csv是测试数据(Testing samples),它用于验证我们模型的训练效果。
|
||||
|
||||
更严谨地说,测试数据用于考察模型的泛化能力(Generalization),也就是说,对于一份模型从来没有“看见过”的数据,我们需要知道,模型的预测能力与它在训练数据上的表现是否一致。
|
||||
|
||||
train.csv和test.csv这两个文件的Schema完全一致,都包含81个字段,除了其中的79个房屋属性与1个交易价格外,还包含一个ID字段。在房价预测这个项目中,我们的任务是事先选定一个数据分布模型(Models),然后在训练数据上对它进行训练(Model Training),模型参数收敛之后,再用训练好的模型,去测试集上查看它的训练效果。
|
||||
|
||||
房价预测
|
||||
|
||||
理论总是没有实战来的更直接,接下来,我们就来借助Spark MLlib机器学习框架,去完成“房价预测”这个机器学习项目的实现。与此同时,随着项目的推进,我们再结合具体实现来深入理解刚刚提到的基本概念与常用术语。
|
||||
|
||||
模型选型
|
||||
|
||||
那么都有哪些模型可供我们选择呢?对于房价预测的项目,我们又该选择其中哪一个呢?像这种如何挑选合适模型的问题,我们统一把它称作“模型选型”。
|
||||
|
||||
在机器学习领域,模型的种类非常多,不仅如此,模型的分类方法也各有不同。按照拟合能力来分类,有线性模型与非线性模型之分;按照预测标的来划分,有回归、分类、聚类、挖掘之分;按照模型复杂度来区分,模型可以分为经典算法与深度学习;按照模型结构来说,又可以分为广义线性模型、树模型、神经网络,等等。如此种种,不一而足。
|
||||
|
||||
不过,咱们学习的重点是入门机器学习、入门Spark MLlib,因此,关于机器学习的模型与算法部分,我们留到第24讲再去展开。在这里,你只要知道有“模型选型”这回事就可以了。
|
||||
|
||||
在“房价预测”这个项目中,我们的预测标的(Label)是房价,而房价是连续的数值型字段,因此我们需要回归模型(Regression Model)来拟合数据。再者,在所有的模型中,线性模型是最简单的,因此,本着由浅入深的原则,在第一版的实现中,咱们不妨选定线性回归模型(Linear Regression),来拟合房价与房屋属性之间的线性关系。
|
||||
|
||||
数据探索
|
||||
|
||||
要想准确地预测房价,我们得先确定,在与房屋相关的属性中,哪些因素对于房价的影响最大。在模型训练的过程中,我们需要选择那些影响较大的因素,而剔除那些影响较小的干扰项。
|
||||
|
||||
结合这里用到的例子,对房价来说,房屋的建筑面积一定是一个很重要的因素。相反,街道的路面类型(水泥路面、沥青路面还是方砖路面),对房价的影响就没那么重要了。
|
||||
|
||||
在机器学习领域中,与预测标的相关的属性,统称为“数据特征”(Features),而选择有效特征的过程,我们称之为“特征选择”(Features Selection)。在做特性选择之前,我们自然免不了先对数据做一番初步的探索,才有可能得出结论。
|
||||
|
||||
具体的探索过程是这样的。首先,我们使用SparkSession的read API,从train.csv文件创建DataFrame,然后调用show与printSchema函数,来观察数据的样本构成与Schema。
|
||||
|
||||
由于数据字段较多,不方便把打印出的数据样本和Schema堆放在文稿中,因此这一步的探索我把它留给你试验,你不妨把下面的代码敲入到spark-shell,观察一下数据到底“长什么模样”。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
val rootPath: String = _
|
||||
val filePath: String = s"${rootPath}/train.csv"
|
||||
|
||||
// 从CSV文件创建DataFrame
|
||||
val trainDF: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
|
||||
|
||||
trainDF.show
|
||||
trainDF.printSchema
|
||||
|
||||
|
||||
通过观察数据,我们会发现房屋的属性非常丰富,包括诸如房屋建筑面积、居室数量、街道路面情况、房屋类型(公寓还是别墅)、基础设施(水、电、燃气)、生活周边(超市、医院、学校)、地基类型(砖混还是钢混)、地下室面积、地上面积、厨房类型(开放还是封闭)、车库面积与位置、最近一次交易时间,等等。
|
||||
|
||||
数据提取
|
||||
|
||||
按道理来说,要遴选那些对房价影响较大的特征,我们需要计算每一个特征与房价之间的相关性。不过,在第一版的实现中,咱们重点关注Spark MLlib的基本用法,暂时不看重模型效果。
|
||||
|
||||
所以,咱们不妨一切从简,只选取那些数值型特征(这类特征简单直接,适合上手),如建筑面积、地上面积、地下室面积和车库面积,即”LotArea”,“GrLivArea”,“TotalBsmtSF”和”GarageArea”,如下表所示。严谨的特征选择,我们留到下一讲的特征工程再去展开。
|
||||
|
||||
import org.apache.spark.sql.types.IntegerType
|
||||
|
||||
// 提取用于训练的特征字段与预测标的(房价SalePrice)
|
||||
val selectedFields: DataFrame = trainDF.select("LotArea", "GrLivArea", "TotalBsmtSF", "GarageArea", "SalePrice")
|
||||
|
||||
// 将所有字段都转换为整型Int
|
||||
val typedFields = selectedFields
|
||||
.withColumn("LotAreaInt",col("LotArea").cast(IntegerType)).drop("LotArea")
|
||||
.withColumn("GrLivAreaInt",col("GrLivArea").cast(IntegerType)).drop("GrLivArea")
|
||||
.withColumn("TotalBsmtSFInt",col("TotalBsmtSF").cast(IntegerType)).drop("TotalBsmtSF")
|
||||
.withColumn("GarageAreaInt",col("GarageArea").cast(IntegerType)).drop("GarageArea")
|
||||
.withColumn("SalePriceInt",col("SalePrice").cast(IntegerType)).drop("SalePrice")
|
||||
|
||||
typedFields.printSchema
|
||||
|
||||
/** 结果打印
|
||||
root
|
||||
|-- LotAreaInt: integer (nullable = true)
|
||||
|-- GrLivAreaInt: integer (nullable = true)
|
||||
|-- TotalBsmtSFInt: integer (nullable = true)
|
||||
|-- GarageAreaInt: integer (nullable = true)
|
||||
|-- SalePriceInt: integer (nullable = true)
|
||||
*/
|
||||
|
||||
|
||||
从CSV创建DataFrame,所有字段的类型默认都是String,而模型在训练的过程中,只能消费数值型数据。因此,我们这里还要做一下类型转换,把所有字段都转换为整型。
|
||||
|
||||
准备训练样本
|
||||
|
||||
好啦,数据准备就绪,接下来,我们就可以借助Spark MLlib框架,开启机器学习的开发之旅。首先,第一步,我们把准备用于训练的多个特征字段,捏合成一个特征向量(Feature Vectors),如下所示。
|
||||
|
||||
import org.apache.spark.ml.feature.VectorAssembler
|
||||
|
||||
// 待捏合的特征字段集合
|
||||
val features: Array[String] = Array("LotAreaInt", "GrLivAreaInt", "TotalBsmtSFInt", "GarageAreaInt")
|
||||
|
||||
// 准备“捏合器”,指定输入特征字段集合,与捏合后的特征向量字段名
|
||||
val assembler = new VectorAssembler().setInputCols(features).setOutputCol("features")
|
||||
|
||||
// 调用捏合器的transform函数,完成特征向量的捏合
|
||||
val featuresAdded: DataFrame = assembler.transform(typedFields)
|
||||
.drop("LotAreaInt")
|
||||
.drop("GrLivAreaInt")
|
||||
.drop("TotalBsmtSFInt")
|
||||
.drop("GarageAreaInt")
|
||||
|
||||
featuresAdded.printSchema
|
||||
|
||||
/** 结果打印
|
||||
root
|
||||
|-- SalePriceInt: integer (nullable = true)
|
||||
|-- features: vector (nullable = true) // 注意,features的字段类型是Vector
|
||||
*/
|
||||
|
||||
|
||||
捏合完特征向量之后,我们就有了用于模型训练的训练样本(Training Samples),它包含两类数据,一类正是特征向量features,另一类是预测标的SalePriceInt。
|
||||
|
||||
接下来,我们把训练样本成比例地分成两份,一份用于模型训练,剩下的部分用于初步验证模型效果。
|
||||
|
||||
val Array(trainSet, testSet) = featuresAdded.randomSplit(Array(0.7, 0.3))
|
||||
|
||||
|
||||
将训练样本拆分为训练集和验证集
|
||||
|
||||
模型训练
|
||||
|
||||
训练样本准备就绪,接下来,我们就可以借助Spark MLlib来构建线性回归模型了。实际上,使用Spark MLlib构建并训练模型,非常简单直接,只需3个步骤即可搞定。
|
||||
|
||||
第一步是导入相关的模型库,在Spark MLlib中,线性回归模型由LinearRegression类实现。第二步是创建模型实例,并指定模型训练所需的必要信息。第三步是调用模型的fit函数,同时提供训练数据集,开始训练。
|
||||
|
||||
import org.apache.spark.ml.regression.LinearRegression
|
||||
|
||||
// 构建线性回归模型,指定特征向量、预测标的与迭代次数
|
||||
val lr = new LinearRegression()
|
||||
.setLabelCol("SalePriceInt")
|
||||
.setFeaturesCol("features")
|
||||
.setMaxIter(10)
|
||||
|
||||
// 使用训练集trainSet训练线性回归模型
|
||||
val lrModel = lr.fit(trainSet)
|
||||
|
||||
|
||||
可以看到,在第二步,我们先是创建LinearRegression实例,然后通过setLabelCol函数和setFeaturesCol函数,来分别指定预测标的字段与特征向量字段,也即“SalePriceInt”和“features”。紧接着,我们调用setMaxIter函数来指定模型训练的迭代次数。
|
||||
|
||||
这里,我有必要给你解释一下迭代次数这个概念。在前面介绍机器学习时,我们提到,模型训练是一个持续不断的过程,训练过程会反复扫描同一份数据,从而以迭代的方式,一次又一次地更新模型中的参数(Parameters,也叫作权重,Weights),直到模型的预测效果达到一定的标准,才能结束训练。
|
||||
|
||||
关于这个标准的制定,来自于两个方面。一方面是对于预测误差的要求,当模型的预测误差小于预先设定的阈值时,模型迭代即可收敛、结束训练。另一个方面就是对于迭代次数的要求,也就是说,不论预测误差是多少,只要达到了预先设定的迭代次数,模型训练即宣告结束。
|
||||
|
||||
说到这里,你可能会眉头紧锁:“又出现了些新概念,模型迭代、模型参数,模型的训练到底是一个什么样的过程呢?”为了让你更好地理解模型训练,我来给你举个生活化的例子。
|
||||
|
||||
实际上,机器学习中的模型训练,与我们生活中使用微波炉的过程别无二致。假设我们手头上有一款老式的微波炉,微波炉上只有两个旋钮,一个控制温度,另一个控制加热时长。
|
||||
|
||||
现在,我们需要烘烤一块馅饼,来当晚饭充饥。晚饭只有一块馅饼,听上去确实是惨了些,不过咱们对于口感的要求还是蛮高的,我们想要得到一块外面焦脆、里面柔嫩的馅饼。
|
||||
|
||||
|
||||
|
||||
如上图所示,对于烹饪经验为0的我们来说,想要得到一张烘烤完美的馅饼,只能一次次地准备馅饼胚子、一次次把它们送进微波炉,然后不断尝试不同的温度与时长组合,直到烘焙出外焦里嫩的美味馅饼,才会得到最佳的温度与时长组合。
|
||||
|
||||
在确定了成功的温度与时长组合之后,当我们需要再次烘烤其他类似食物(比如肉饼、披萨)的时候,就可以把它们送进微波炉,然后直接按下开启键就可以了。
|
||||
|
||||
模型训练也是类似的,我们一次次地把训练数据,“喂给”模型算法,一次次地调整模型参数,直到把预测误差降低到一定的范围、或是模型迭代达到一定的次数,即宣告训练结束。当有新的数据需要预测时,我们就把它喂给训练好的模型,模型就能生成预测结果。
|
||||
|
||||
不过,与我们不停地手动调节“温度”与“时长”旋钮不同,模型权重的调整,依赖的往往是一种叫作“梯度下降”(Gradient Descend)的优化算法。在模型的每一次迭代中,梯度下降算法会自动地调整模型权重,而不需要人为的干预。这个优化算法咱们留到第24讲模型训练那里再展开。
|
||||
|
||||
不难发现,在上面馅饼烘焙这个生活化的例子中,相比模型训练,馅饼胚子实际上就是训练数据,微波炉就是模型算法,温度与时长就是模型参数,预测误差就是实际口感与期望口感之间的差距,而尝试的烘焙次数就是迭代次数。关于馅饼烘焙与模型训练的对比,我把它整理到了下图中,你可以看看。
|
||||
|
||||
|
||||
|
||||
熟悉了与模型训练相关的基本概念之后,我们再来回顾一下刚刚的线性回归训练代码。除了表中的3个setXXX函数以外,关于模型定义的更多选项,你可以参考官网中的开发API来获取完整内容。模型定义好之后,我们就可以通过调用fit函数,来完成模型的训练过程。
|
||||
|
||||
import org.apache.spark.ml.regression.LinearRegression
|
||||
|
||||
// 构建线性回归模型,指定特征向量、预测标的与迭代次数
|
||||
val lr = new LinearRegression()
|
||||
.setLabelCol("SalePriceInt")
|
||||
.setFeaturesCol("features")
|
||||
.setMaxIter(10)
|
||||
|
||||
// 使用训练集trainSet训练线性回归模型
|
||||
val lrModel = lr.fit(trainSet)
|
||||
|
||||
|
||||
模型效果评估
|
||||
|
||||
模型训练好之后,我们需要对模型的效果进行验证、评估,才能判定模型的“好”、“坏”。这就好比,馅饼烤熟之后,我们得亲自尝一尝,才能知道它的味道跟我们期待的口感是否一致。
|
||||
|
||||
首先,我们先来看看,模型在训练集上的表现怎么样。在线性回归模型的评估中,我们有很多的指标,用来量化模型的预测误差。其中最具代表性的要数RMSE(Root Mean Squared Error),也就是均方根误差。我们可以通过在模型上调用summary函数,来获取模型在训练集上的评估指标,如下所示。
|
||||
|
||||
val trainingSummary = lrModel.summary
|
||||
println(s"RMSE: ${trainingSummary.rootMeanSquaredError}")
|
||||
|
||||
/** 结果打印
|
||||
RMSE: 45798.86
|
||||
*/
|
||||
|
||||
|
||||
在训练集的数据分布中,房价的值域在(34900,755000)之间,因此,45798.86的预测误差还是相当大的。这说明我们得到的模型,甚至没有很好地拟合训练数据。换句话说,训练得到的模型,处在一个“欠拟合”的状态。
|
||||
|
||||
这其实很好理解,一方面,咱们的模型过于简单,线性回归的拟合能力本身就非常有限。
|
||||
|
||||
再者,在数据方面,我们目前仅仅使用了4个字段(LotAreaInt,GrLivAreaInt,TotalBsmtSFInt,GarageAreaInt)。房价影响因素众多,仅用4个房屋属性,是很难准确地预测房价的。所以在后面的几讲中,我们还会继续深入研究特征工程与模型选型对于模型拟合能力的影响。
|
||||
|
||||
面对这种欠拟合的情况,我们自然还需要进一步调试、优化这个模型。在后续的几讲中,我们会分别从特征工程与模型调优这两个角度出发,去逐步完善我们的“房价预测”模型,拭目以待吧!
|
||||
|
||||
重点回顾
|
||||
|
||||
今天的内容比较多,我们一起来做个总结。今天这一讲,我们主要围绕着“房价预测”这个小项目,分别介绍了机器学习的基本概念,以及如何借助Spark MLlib框架,完成机器学习开发。
|
||||
|
||||
|
||||
|
||||
首先,你需要掌握机器学习是怎样的一个计算过程。所谓机器学习(Machine Learning),它指的是这样一种计算过程。对于给定的训练数据(Training samples),选择一种先验的数据分布模型(Models),然后借助优化算法(Learning Algorithms)自动地持续调整模型参数(Model Weights / Parameters),从而让模型不断逼近训练数据的原始分布。
|
||||
|
||||
然后,在Spark MLlib子框架下,你需要掌握机器学习开发的基本流程和关键步骤,我把这些步骤整理到了如下的表格中,方便你随时回顾。
|
||||
|
||||
|
||||
|
||||
今天这一讲,我们采用了“机器学习基础知识”与“Spark MLlib开发流程”相交叉的方式,来同时讲解机器学习本身与Spark MLlib子框架。对于机器学习背景较为薄弱的同学来说,学习今天的内容可能有些挑战。
|
||||
|
||||
不过,你不用担心,对于本讲中挖下的“坑”,我们在后续的几讲中,都会陆续补上,力争让你系统掌握机器学习的开发方法与常规套路。
|
||||
|
||||
每日一练
|
||||
|
||||
请按照这一讲的行文顺序,整理从加载数据到模型训练、模型评估的所有代码。然后,请你从Kaggle(数据科学竞赛平台)的“House Prices - Advanced Regression Techniques”竞赛项目下载训练数据,完成从数据加载到模型训练的整个过程。
|
||||
|
||||
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友,一起动手试试从数据加载到模型训练的整个过程。
|
||||
|
||||
|
||||
|
||||
|
369
专栏/零基础入门Spark/24特征工程(上):有哪些常用的特征处理函数?.md
Normal file
369
专栏/零基础入门Spark/24特征工程(上):有哪些常用的特征处理函数?.md
Normal file
@ -0,0 +1,369 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
24 特征工程(上):有哪些常用的特征处理函数?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们一起构建了一个简单的线性回归模型,来预测美国爱荷华州的房价。从模型效果来看,模型的预测能力非常差。不过,事出有因,一方面线性回归的拟合能力有限,再者,我们使用的特征也是少的可怜。
|
||||
|
||||
要想提升模型效果,具体到我们“房价预测”的案例里就是把房价预测得更准,我们需要从特征和模型两个方面着手,逐步对模型进行优化。
|
||||
|
||||
在机器学习领域,有一条尽人皆知的“潜规则”:Garbage in,garbage out。它的意思是说,当我们喂给模型的数据是“垃圾”的时候,模型“吐出”的预测结果也是“垃圾”。垃圾是一句玩笑话,实际上,它指的是不完善的特征工程。
|
||||
|
||||
特征工程不完善的成因有很多,比如数据质量参差不齐、特征字段区分度不高,还有特征选择不到位、不合理,等等。
|
||||
|
||||
作为初学者,我们必须要牢记一点:特征工程制约着模型效果,它决定了模型效果的上限,也就是“天花板”。而模型调优,仅仅是在不停地逼近这个“天花板”而已。因此,提升模型效果的第一步,就是要做好特征工程。
|
||||
|
||||
为了减轻你的学习负担,我把特征工程拆成了上、下两篇。我会用两讲的内容,带你了解在Spark MLlib的开发框架下,都有哪些完善特征工程的方法。总的来说,我们需要学习6大类特征处理方法,今天这一讲,我们先来学习前3类,下一讲再学习另外3类。
|
||||
|
||||
课程安排
|
||||
|
||||
打开Spark MLlib特征工程页面,你会发现这里罗列着数不清的特征处理函数,让人眼花缭乱。作为初学者,看到这么长的列表,更是会感到无所适从。
|
||||
|
||||
|
||||
|
||||
不过,你别担心,对于列表中的函数,结合过往的应用经验,我会从特征工程的视角出发,把它们分门别类地进行归类。
|
||||
|
||||
|
||||
|
||||
如图所示,从原始数据生成可用于模型训练的训练样本(这个过程又叫“特征工程”),我们有很长的路要走。通常来说,对于原始数据中的字段,我们会把它们分为数值型(Numeric)和非数值型(Categorical)。之所以要这样区分,原因在于字段类型不同,处理方法也不同。
|
||||
|
||||
在上图中,从左到右,Spark MLlib特征处理函数可以被分为如下几类,依次是:
|
||||
|
||||
|
||||
预处理
|
||||
特征选择
|
||||
归一化
|
||||
离散化
|
||||
Embedding
|
||||
向量计算
|
||||
|
||||
|
||||
除此之外,Spark MLlib还提供了一些用于自然语言处理(NLP,Natural Language Processing)的初级函数,如图中左上角的虚线框所示。作为入门课,这部分不是咱们今天的重点,如果你对NLP感兴趣的话,可以到官网页面了解详情。
|
||||
|
||||
我会从每个分类里各挑选一个最具代表性的函数(上图中字体加粗的函数),结合“房价预测”项目为你深入讲解。至于其他的处理函数,跟同一分类中我们讲到的函数其实是大同小异的。所以,只要你耐心跟着我学完这部分内容,自己再结合官网进一步探索其他处理函数时,也会事半功倍。
|
||||
|
||||
特征工程
|
||||
|
||||
接下来,咱们就来结合上一讲的“房价预测”项目,去探索Spark MLlib丰富而又强大的特征处理函数。
|
||||
|
||||
在上一讲,我们的模型只用到了4个特征,分别是”LotArea”,“GrLivArea”,“TotalBsmtSF”和”GarageArea”。选定这4个特征去建模,意味着我们做了一个很强的先验假设:房屋价格仅与这4个房屋属性有关。显然,这样的假设并不合理。作为消费者,在决定要不要买房的时候,绝不会仅仅参考这4个房屋属性。
|
||||
|
||||
爱荷华州房价数据提供了多达79个房屋属性,其中一部分是数值型字段,如记录各种尺寸、面积、大小、数量的房屋属性,另一部分是非数值型字段,比如房屋类型、街道类型、建筑日期、地基类型,等等。
|
||||
|
||||
显然,房价是由这79个属性当中的多个属性共同决定的。机器学习的任务,就是先找出这些“决定性”因素(房屋属性),然后再用一个权重向量(模型参数)来量化不同因素对于房价的影响。
|
||||
|
||||
预处理:StringIndexer
|
||||
|
||||
由于绝大多数模型(包括线性回归模型)都不能直接“消费”非数值型数据,因此,咱们的第一步,就是把房屋属性中的非数值字段,转换为数值字段。在特征工程中,对于这类基础的数据转换操作,我们统一把它称为预处理。
|
||||
|
||||
我们可以利用Spark MLlib提供的StringIndexer完成预处理。顾名思义,StringIndexer的作用是,以数据列为单位,把字段中的字符串转换为数值索引。例如,使用StringIndexer,我们可以把“车库类型”属性GarageType中的字符串转换为数字,如下图所示。
|
||||
|
||||
|
||||
|
||||
StringIndexer的用法比较简单,可以分为三个步骤:
|
||||
|
||||
|
||||
第一步,实例化StringIndexer对象;
|
||||
第二步,通过setInputCol和setOutputCol来指定输入列和输出列;
|
||||
第三步,调用fit和transform函数,完成数据转换。
|
||||
|
||||
|
||||
接下来,我们就结合上一讲的“房价预测”项目,使用StringIndexer对所有的非数值字段进行转换,从而演示并学习它的用法。
|
||||
|
||||
首先,我们读取房屋源数据并创建DataFrame。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
// 这里的下划线"_"是占位符,代表数据文件的根目录
|
||||
val rootPath: String = _
|
||||
val filePath: String = s"${rootPath}/train.csv"
|
||||
|
||||
val sourceDataDF: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
|
||||
|
||||
|
||||
然后,我们挑选出所有的非数值字段,并使用StringIndexer对其进行转换。
|
||||
|
||||
// 导入StringIndexer
|
||||
import org.apache.spark.ml.feature.StringIndexer
|
||||
|
||||
// 所有非数值型字段,也即StringIndexer所需的“输入列”
|
||||
val categoricalFields: Array[String] = Array("MSSubClass", "MSZoning", "Street", "Alley", "LotShape", "LandContour", "Utilities", "LotConfig", "LandSlope", "Neighborhood", "Condition1", "Condition2", "BldgType", "HouseStyle", "OverallQual", "OverallCond", "YearBuilt", "YearRemodAdd", "RoofStyle", "RoofMatl", "Exterior1st", "Exterior2nd", "MasVnrType", "ExterQual", "ExterCond", "Foundation", "BsmtQual", "BsmtCond", "BsmtExposure", "BsmtFinType1", "BsmtFinType2", "Heating", "HeatingQC", "CentralAir", "Electrical", "KitchenQual", "Functional", "FireplaceQu", "GarageType", "GarageYrBlt", "GarageFinish", "GarageQual", "GarageCond", "PavedDrive", "PoolQC", "Fence", "MiscFeature", "MiscVal", "MoSold", "YrSold", "SaleType", "SaleCondition")
|
||||
|
||||
// 非数值字段对应的目标索引字段,也即StringIndexer所需的“输出列”
|
||||
val indexFields: Array[String] = categoricalFields.map(_ + "Index").toArray
|
||||
|
||||
// 将engineeringDF定义为var变量,后续所有的特征工程都作用在这个DataFrame之上
|
||||
var engineeringDF: DataFrame = sourceDataDF
|
||||
|
||||
// 核心代码:循环遍历所有非数值字段,依次定义StringIndexer,完成字符串到数值索引的转换
|
||||
for ((field, indexField) <- categoricalFields.zip(indexFields)) {
|
||||
|
||||
// 定义StringIndexer,指定输入列名、输出列名
|
||||
val indexer = new StringIndexer()
|
||||
.setInputCol(field)
|
||||
.setOutputCol(indexField)
|
||||
|
||||
// 使用StringIndexer对原始数据做转换
|
||||
engineeringDF = indexer.fit(engineeringDF).transform(engineeringDF)
|
||||
|
||||
// 删除掉原始的非数值字段列
|
||||
engineeringDF = engineeringDF.drop(field)
|
||||
}
|
||||
|
||||
|
||||
尽管代码看上去很多,但我们只需关注与StringIndexer有关的部分即可。我们刚刚介绍了StringIndexer用法的三个步骤,咱们不妨把这些步骤和上面的代码对应起来,这样可以更加直观地了解StringIndexer的具体用法。
|
||||
|
||||
|
||||
|
||||
以“车库类型”GarageType字段为例,我们先初始化一个StringIndexer实例。然后,把GarageType传入给它的setInputCol函数。接着,把GarageTypeIndex传入给它的setOutputCol函数。
|
||||
|
||||
注意,GarageType是原始字段,也就是engineeringDF这个DataFrame中原本就包含的数据列,而GarageTypeIndex是StringIndexer即将生成的数据列,目前的engineeringDF暂时还不包含这个字段。
|
||||
|
||||
最后,我们在StringIndexer之上,依次调用fit和transform函数来生成输出列,这两个函数的参数都是待转换的DataFrame,在我们的例子中,这个DataFrame是engineeringDF。
|
||||
|
||||
转换完成之后,你会发现engineeringDF中多了一个新的数据列,也就是GarageTypeIndex这个字段。而这一列包含的数据内容,就是与GarageType数据列对应的数值索引,如下所示。
|
||||
|
||||
engineeringDF.select("GarageType", "GarageTypeIndex").show(5)
|
||||
|
||||
/** 结果打印
|
||||
+----------+---------------+
|
||||
|GarageType|GarageTypeIndex|
|
||||
+----------+---------------+
|
||||
| Attchd| 0.0|
|
||||
| Attchd| 0.0|
|
||||
| Attchd| 0.0|
|
||||
| Detchd| 1.0|
|
||||
| Attchd| 0.0|
|
||||
+----------+---------------+
|
||||
only showing top 5 rows
|
||||
*/
|
||||
|
||||
|
||||
可以看到,转换之后GarageType字段中所有的“Attchd”都被映射为0,而所有“Detchd”都被转换为1。实际上,剩余的“CarPort”、“BuiltIn”等字符串,也都被转换成了对应的索引值。
|
||||
|
||||
为了对DataFrame中所有的非数值字段都进行类似的处理,我们使用for循环来进行遍历,你不妨亲自动手去尝试运行上面的完整代码,并进一步验证(除GarageType以外的)其他字段的转换也是符合预期的。
|
||||
|
||||
|
||||
|
||||
好啦,到此为止,我们以StringIndexer为例,跑通了Spark MLlib的预处理环节,拿下了特征工程的第一关,恭喜你!接下来,我们再接再厉,一起去挑战第二道关卡:特征选择。
|
||||
|
||||
特征选择:ChiSqSelector
|
||||
|
||||
特征选择,顾名思义,就是依据一定的标准,对特征字段进行遴选。
|
||||
|
||||
以房屋数据为例,它包含了79个属性字段。在这79个属性当中,不同的属性对于房价的影响程度是不一样的。显然,像房龄、居室数量这类特征,远比供暖方式要重要得多。特征选择,就是遴选出像房龄、居室数量这样的关键特征,然后进行建模,而抛弃对预测标的(房价)无足轻重的供暖方式。
|
||||
|
||||
不难发现,在刚刚的例子中,我们是根据日常生活经验作为遴选特征字段的标准。实际上,面对数量众多的候选特征,业务经验往往是特征选择的重要出发点之一。在互联网的搜索、推荐与广告等业务场景中,我们都会尊重产品经理与业务专家的经验,结合他们的反馈来初步筛选出候选特征集。
|
||||
|
||||
与此同时,我们还会使用一些统计方法,去计算候选特征与预测标的之间的关联性,从而以量化的方式,衡量不同特征对于预测标的重要性。
|
||||
|
||||
统计方法在验证专家经验有效性的同时,还能够与之形成互补,因此,在日常做特征工程的时候,我们往往将两者结合去做特征选择。
|
||||
|
||||
|
||||
|
||||
业务经验因场景而异,无法概述,因此,咱们重点来说一说可以量化的统计方法。统计方法的原理并不复杂,本质上都是基于不同的算法(如Pearson系数、卡方分布),来计算候选特征与预测标的之间的关联性。不过,你可能会问:“我并不是统计学专业的,做特征选择,是不是还要先去学习这些统计方法呢?”
|
||||
|
||||
别担心,其实并不需要。Spark MLlib框架为我们提供了多种特征选择器(Selectors),这些Selectors封装了不同的统计方法。要做好特征选择,我们只需要搞懂Selectors该怎么用,而不必纠结它背后使用的到底是哪些统计方法。
|
||||
|
||||
以ChiSqSelector为例,它所封装的统计方法是卡方检验与卡方分布。即使你暂时还不清楚卡方检验的工作原理,也并不影响我们使用ChiSqSelector来轻松完成特征选择。
|
||||
|
||||
接下来,咱们还是以“房价预测”的项目为例,说一说ChiSqSelector的用法与注意事项。既然是量化方法,这就意味着Spark MLlib的Selectors只能用于数值型字段。要使用ChiSqSelector来选择数值型字段,我们需要完成两步走:
|
||||
|
||||
|
||||
第一步,使用VectorAssembler创建特征向量;
|
||||
第二步,基于特征向量,使用ChiSqSelector完成特征选择。
|
||||
|
||||
|
||||
VectorAssembler原本属于特征工程中向量计算的范畴,不过,在Spark MLlib框架内,很多特征处理函数的输入参数都是特性向量(Feature Vector),比如现在要讲的ChiSqSelector。因此,这里我们先要对VectorAssembler做一个简单的介绍。
|
||||
|
||||
VectorAssembler的作用是,把多个数值列捏合为一个特征向量。以房屋数据的三个数值列“LotFrontage”、“BedroomAbvGr”、“KitchenAbvGr”为例,VectorAssembler可以把它们捏合为一个新的向量字段,如下图所示。
|
||||
|
||||
|
||||
|
||||
VectorAssembler的用法很简单,初始化VectorAssembler实例之后,调用setInputCols传入待转换的数值字段列表(如上图中的3个字段),使用setOutputCol函数来指定待生成的特性向量字段,如上图中的“features”字段。接下来,我们结合代码,来演示VectorAssembler的具体用法。
|
||||
|
||||
// 所有数值型字段,共有27个
|
||||
val numericFields: Array[String] = Array("LotFrontage", "LotArea", "MasVnrArea", "BsmtFinSF1", "BsmtFinSF2", "BsmtUnfSF", "TotalBsmtSF", "1stFlrSF", "2ndFlrSF", "LowQualFinSF", "GrLivArea", "BsmtFullBath", "BsmtHalfBath", "FullBath", "HalfBath", "BedroomAbvGr", "KitchenAbvGr", "TotRmsAbvGrd", "Fireplaces", "GarageCars", "GarageArea", "WoodDeckSF", "OpenPorchSF", "EnclosedPorch", "3SsnPorch", "ScreenPorch", "PoolArea")
|
||||
|
||||
// 预测标的字段
|
||||
val labelFields: Array[String] = Array("SalePrice")
|
||||
|
||||
import org.apache.spark.sql.types.IntegerType
|
||||
|
||||
// 将所有数值型字段,转换为整型Int
|
||||
for (field <- (numericFields ++ labelFields)) {
|
||||
engineeringDF = engineeringDF.withColumn(s"${field}Int",col(field).cast(IntegerType)).drop(field)
|
||||
}
|
||||
|
||||
import org.apache.spark.ml.feature.VectorAssembler
|
||||
|
||||
// 所有类型为Int的数值型字段
|
||||
val numericFeatures: Array[String] = numericFields.map(_ + "Int").toArray
|
||||
|
||||
// 定义并初始化VectorAssembler
|
||||
val assembler = new VectorAssembler()
|
||||
.setInputCols(numericFeatures)
|
||||
.setOutputCol("features")
|
||||
|
||||
// 在DataFrame应用VectorAssembler,生成特征向量字段"features"
|
||||
engineeringDF = assembler.transform(engineeringDF)
|
||||
|
||||
|
||||
代码内容较多,我们把目光集中到最下面的两行。首先,我们定义并初始化VectorAssembler实例,将包含有全部数值字段的数组numericFeatures传入给setInputCols函数,并使用setOutputCol函数指定输出列名为“features”。然后,通过调用VectorAssembler的transform函数,完成对engineeringDF的转换。
|
||||
|
||||
转换完成之后,engineeringDF就包含了一个字段名为“features”的数据列,它的数据内容,就是拼接了所有数值特征的特征向量。
|
||||
|
||||
好啦,特征向量准备完毕之后,我们就可以基于它来做特征选择了。还是先上代码。
|
||||
|
||||
import org.apache.spark.ml.feature.ChiSqSelector
|
||||
import org.apache.spark.ml.feature.ChiSqSelectorModel
|
||||
|
||||
// 定义并初始化ChiSqSelector
|
||||
val selector = new ChiSqSelector()
|
||||
.setFeaturesCol("features")
|
||||
.setLabelCol("SalePriceInt")
|
||||
.setNumTopFeatures(20)
|
||||
|
||||
// 调用fit函数,在DataFrame之上完成卡方检验
|
||||
val chiSquareModel = selector.fit(engineeringDF)
|
||||
|
||||
// 获取ChiSqSelector选取出来的入选特征集合(索引)
|
||||
val indexs: Array[Int] = chiSquareModel.selectedFeatures
|
||||
|
||||
import scala.collection.mutable.ArrayBuffer
|
||||
|
||||
val selectedFeatures: ArrayBuffer[String] = ArrayBuffer[String]()
|
||||
|
||||
// 根据特征索引值,查找数据列的原始字段名
|
||||
for (index <- indexs) {
|
||||
selectedFeatures += numericFields(index)
|
||||
}
|
||||
|
||||
|
||||
首先,我们定义并初始化ChiSqSelector实例,分别通过setFeaturesCol和setLabelCol来指定特征向量和预测标的。毕竟,ChiSqSelector所封装的卡方检验,需要将特征与预测标的进行关联,才能量化每一个特征的重要性。
|
||||
|
||||
接下来,对于全部的27个数值特征,我们需要告诉ChiSqSelector要从中选出多少个进行建模。这里我们传递给setNumTopFeatures的参数是20,也就是说,ChiSqSelector需要帮我们从27个特征中,挑选出对房价影响最重要的前20个特征。
|
||||
|
||||
ChiSqSelector实例创建完成之后,我们通过调用fit函数,对engineeringDF进行卡方检验,得到卡方检验模型chiSquareModel。访问chiSquareModel的selectedFeatures变量,即可获得入选特征的索引值,再结合原始的数值字段数组,我们就可以得到入选的原始数据列。
|
||||
|
||||
听到这里,你可能已经有点懵了,不要紧,结合下面的示意图,你可以更加直观地熟悉ChiSqSelector的工作流程。这里我们还是以“LotFrontage”、“BedroomAbvGr”、“KitchenAbvGr”这3个字段为例,来进行演示。
|
||||
|
||||
|
||||
|
||||
可以看到,对房价来说,ChiSqSelector认为前两个字段比较重要,而厨房个数没那么重要。因此,在selectedFeatures这个数组中,ChiSqSelector记录了0和1这两个索引,分别对应着原始的“LotFrontage”和“BedroomAbvGr”这两个字段。
|
||||
|
||||
|
||||
|
||||
好啦,到此为止,我们以ChiSqSelector为代表,学习了Spark MLlib框架中特征选择的用法,打通了特征工程的第二关。接下来,我们继续努力,去挑战第三道关卡:归一化。
|
||||
|
||||
归一化:MinMaxScaler
|
||||
|
||||
归一化(Normalization)的作用,是把一组数值,统一映射到同一个值域,而这个值域通常是[0, 1]。也就是说,不管原始数据序列的量级是105,还是10-5,归一化都会把它们统一缩放到[0, 1]这个范围。
|
||||
|
||||
这么说可能比较抽象,我们拿“LotArea”、“BedroomAbvGr”这两个字段来举例。其中,“LotArea”的含义是房屋面积,它的单位是平方英尺,量级在105,而“BedroomAbvGr”的单位是个数,它的量级是101。
|
||||
|
||||
假设我们采用Spark MLlib提供的MinMaxScaler对房屋数据做归一化,那么这两列数据都会被统一缩放到[0, 1]这个值域范围,从而抹去单位不同带来的量纲差异。
|
||||
|
||||
你可能会问:“为什么要做归一化呢?去掉量纲差异的动机是什么呢?原始数据它不香吗?”
|
||||
|
||||
原始数据很香,但原始数据的量纲差异不香。当原始数据之间的量纲差异较大时,在模型训练的过程中,梯度下降不稳定、抖动较大,模型不容易收敛,从而导致训练效率较差。相反,当所有特征数据都被约束到同一个值域时,模型训练的效率会得到大幅提升。关于模型训练与模型调优,我们留到下一讲再去展开,这里你先理解归一化的必要性即可。
|
||||
|
||||
既然归一化这么重要,那具体应该怎么实现呢?其实很简单,只要一个函数就可以搞定。
|
||||
|
||||
Spark MLlib支持多种多样的归一化函数,如StandardScaler、MinMaxScaler,等等。尽管这些函数的算法各有不同,但效果都是一样的。
|
||||
|
||||
我们以MinMaxScaler为例看一看,对于任意的房屋面积ei,MinMaxScaler使用如下公式来完成对“LotArea”字段的归一化。
|
||||
|
||||
|
||||
|
||||
其中,max和min分别是目标值域的上下限,默认为1和0,换句话说,目标值域为[0, 1]。而Emax和Emin分别是“LotArea”这个数据列中的最大值和最小值。使用这个公式,MinMaxScaler就会把“LotArea”中所有的数值都映射到[0, 1]这个范围。
|
||||
|
||||
接下来,我们结合代码,来演示MinMaxScaler的具体用法。
|
||||
|
||||
与很多特征处理函数(如刚刚讲过的ChiSqSelector)一样,MinMaxScaler的输入参数也是特征向量,因此,MinMaxScaler的用法,也分为两步走:
|
||||
|
||||
|
||||
第一步,使用VectorAssembler创建特征向量;
|
||||
|
||||
第二步,基于特征向量,使用MinMaxScaler完成归一化。
|
||||
|
||||
// 所有类型为Int的数值型字段
|
||||
// val numericFeatures: Array[String] = numericFields.map(_ + “Int”).toArray
|
||||
|
||||
// 遍历每一个数值型字段
|
||||
for (field <- numericFeatures) {
|
||||
|
||||
// 定义并初始化VectorAssembler
|
||||
val assembler = new VectorAssembler()
|
||||
.setInputCols(Array(field))
|
||||
.setOutputCol(s”${field}Vector”)
|
||||
|
||||
// 调用transform把每个字段由Int转换为Vector类型
|
||||
engineeringData = assembler.transform(engineeringData)
|
||||
}
|
||||
|
||||
|
||||
在第一步,我们使用for循环遍历所有数值型字段,依次初始化VectorAssembler实例,把字段由Int类型转为Vector向量类型。接下来,在第二步,我们就可以把所有向量传递给MinMaxScaler去做归一化了。可以看到,MinMaxScaler的用法,与StringIndexer的用法很相似。
|
||||
|
||||
import org.apache.spark.ml.feature.MinMaxScaler
|
||||
|
||||
// 锁定所有Vector数据列
|
||||
val vectorFields: Array[String] = numericFeatures.map(_ + "Vector").toArray
|
||||
|
||||
// 归一化后的数据列
|
||||
val scaledFields: Array[String] = vectorFields.map(_ + "Scaled").toArray
|
||||
|
||||
// 循环遍历所有Vector数据列
|
||||
for (vector <- vectorFields) {
|
||||
|
||||
// 定义并初始化MinMaxScaler
|
||||
val minMaxScaler = new MinMaxScaler()
|
||||
.setInputCol(vector)
|
||||
.setOutputCol(s"${vector}Scaled")
|
||||
// 使用MinMaxScaler,完成Vector数据列的归一化
|
||||
engineeringData = minMaxScaler.fit(engineeringData).transform(engineeringData)
|
||||
}
|
||||
|
||||
|
||||
首先,我们创建一个MinMaxScaler实例,然后分别把原始Vector数据列和归一化之后的数据列,传递给函数setInputCol和setOutputCol。接下来,依次调用fit与transform函数,完成对目标字段的归一化。
|
||||
|
||||
这段代码执行完毕之后,engineeringData(DataFrame)就包含了多个后缀为“Scaled”的数据列,这些数据列的内容,就是对应原始字段的归一化数据,如下所示。
|
||||
|
||||
|
||||
|
||||
好啦,到此为止,我们以MinMaxScaler为代表,学习了Spark MLlib框架中数据归一化的用法,打通了特征工程的第三关。
|
||||
|
||||
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,今天的内容讲完啦,我们一起来做个总结。今天这一讲,我们主要围绕特征工程展开,你需要掌握特征工程不同环节的特征处理方法,尤其是那些最具代表性的特征处理函数。
|
||||
|
||||
从原始数据到生成训练样本,特征工程可以被分为如下几个环节,我们今天重点讲解了其中的前三个环节,也就是预处理、特征选择和归一化。
|
||||
|
||||
|
||||
|
||||
针对不同环节,Spark MLlib框架提供了丰富的特征处理函数。作为预处理环节的代表,StringIndexer负责对非数值型特征做初步处理,将模型无法直接消费的字符串转换为数值。
|
||||
|
||||
特征选择的动机,在于提取与预测标的关联度更高的特征,从而精简模型尺寸、提升模型泛化能力。特征选择可以从两方面入手,业务出发的专家经验和基于数据的统计分析。
|
||||
|
||||
|
||||
|
||||
Spark MLlib基于不同的统计方法,提供了多样的特征选择器(Feature Selectors),其中ChiSqSelector以卡方检验为基础,选择相关度最高的前N个特征。
|
||||
|
||||
归一化的目的,在于去掉不同特征之间量纲的影响,避免量纲不一致而导致的梯度下降震荡、模型收敛效率低下等问题。归一化的具体做法,是把不同特征都缩放到同一个值域。在这方面,Spark MLlib提供了多种归一化方法供开发者选择。
|
||||
|
||||
在下一讲,我们将继续离散化、Embedding和向量计算这3个环节的学习,最后还会带你整体看一下各环节优化过后的模型效果,敬请期待。
|
||||
|
||||
每课一练
|
||||
|
||||
对于我们今天讲解的特征处理函数,如StringIndexer、ChiSqSelector、MinMaxScaler,你能说说它们之间的区别和共同点吗?
|
||||
|
||||
欢迎你在留言区跟我交流互动,也推荐你把今天的内容转发给更多同事和朋友,跟他一起交流特征工程相关的内容。
|
||||
|
||||
|
||||
|
||||
|
231
专栏/零基础入门Spark/25特征工程(下):有哪些常用的特征处理函数?.md
Normal file
231
专栏/零基础入门Spark/25特征工程(下):有哪些常用的特征处理函数?.md
Normal file
@ -0,0 +1,231 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
25 特征工程(下):有哪些常用的特征处理函数?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们提到,典型的特征工程包含如下几个环节,即预处理、特征选择、归一化、离散化、Embedding和向量计算,如下图所示。
|
||||
|
||||
|
||||
|
||||
在上一讲,我们着重讲解了其中的前3个环节,也就是预处理、特征选择和归一化。按照之前的课程安排,今天这一讲,咱们继续来说说剩下的离散化、Embedding与向量计算。
|
||||
|
||||
特征工程是机器学习的重中之重,只要你耐心学下去,必然会不虚此行。这一讲的最后,我还会对应用了6种不同特征工程的模型性能加以对比,帮你深入理解特征工程中不同环节的作用与效果。
|
||||
|
||||
特征工程
|
||||
|
||||
|
||||
|
||||
在上一讲,我们打卡到了“第三关”:归一化。因此,接下来,我们先从“第四关”:离散化说起。
|
||||
|
||||
离散化:Bucketizer
|
||||
|
||||
与归一化一样,离散化也是用来处理数值型字段的。离散化可以把原本连续的数值打散,从而降低原始数据的多样性(Cardinality)。举例来说,“BedroomAbvGr”字段的含义是居室数量,在train.csv这份数据样本中,“BedroomAbvGr”包含从1到8的连续整数。
|
||||
|
||||
现在,我们根据居室数量,把房屋粗略地划分为小户型、中户型和大户型。
|
||||
|
||||
|
||||
|
||||
不难发现,“BedroomAbvGr”离散化之后,数据多样性由原来的8降低为现在的3。那么问题来了,原始的连续数据好好的,为什么要对它做离散化呢?离散化的动机,主要在于提升特征数据的区分度与内聚性,从而与预测标的产生更强的关联。
|
||||
|
||||
就拿“BedroomAbvGr”来说,我们认为一居室和两居室对于房价的影响差别不大,同样,三居室和四居室之间对于房价的影响,也是微乎其微。
|
||||
|
||||
但是,小户型与中户型之间,以及中户型与大户型之间,房价往往会出现跃迁的现象。换句话说,相比居室数量,户型的差异对于房价的影响更大、区分度更高。因此,把“BedroomAbvGr”做离散化处理,目的在于提升它与预测标的之间的关联性。
|
||||
|
||||
那么,在Spark MLlib的框架下,离散化具体该怎么做呢?与其他环节一样,Spark MLlib提供了多个离散化函数,比如Binarizer、Bucketizer和QuantileDiscretizer。我们不妨以Bucketizer为代表,结合居室数量“BedroomAbvGr”这个字段,来演示离散化的具体用法。老规矩,还是先上代码为敬。
|
||||
|
||||
// 原始字段
|
||||
val fieldBedroom: String = "BedroomAbvGrInt"
|
||||
// 包含离散化数据的目标字段
|
||||
val fieldBedroomDiscrete: String = "BedroomDiscrete"
|
||||
// 指定离散区间,分别是[负无穷, 2]、[3, 4]和[5, 正无穷]
|
||||
val splits: Array[Double] = Array(Double.NegativeInfinity, 3, 5, Double.PositiveInfinity)
|
||||
|
||||
import org.apache.spark.ml.feature.Bucketizer
|
||||
|
||||
// 定义并初始化Bucketizer
|
||||
val bucketizer = new Bucketizer()
|
||||
// 指定原始列
|
||||
.setInputCol(fieldBedroom)
|
||||
// 指定目标列
|
||||
.setOutputCol(fieldBedroomDiscrete)
|
||||
// 指定离散区间
|
||||
.setSplits(splits)
|
||||
|
||||
// 调用transform完成离散化转换
|
||||
engineeringData = bucketizer.transform(engineeringData)
|
||||
|
||||
|
||||
不难发现,Spark MLlib提供的特征处理函数,在用法上大同小异。首先,我们创建Bucketizer实例,然后将数值型字段BedroomAbvGrInt作为参数传入setInputCol,同时使用setOutputCol来指定用于保存离散数据的新字段BedroomDiscrete。
|
||||
|
||||
离散化的过程是把连续值打散为离散值,但具体的离散区间如何划分,还需要我们通过在setSplits里指定。离散区间由浮点型数组splits提供,从负无穷到正无穷划分出了[负无穷, 2]、[3, 4]和[5, 正无穷]这三个区间。最终,我们调用Bucketizer的transform函数,对engineeringData做离散化。
|
||||
|
||||
离散化前后的数据对比,如下图所示。
|
||||
|
||||
|
||||
|
||||
好啦,到此为止,我们以Bucketizer为代表,学习了Spark MLlib框架中数据离散化的用法,轻松打通了特征工程的第四关。
|
||||
|
||||
|
||||
|
||||
Embedding
|
||||
|
||||
实际上,Embedding是一个非常大的话题,随着机器学习与人工智能的发展,Embedding的方法也是日新月异、层出不穷。从最基本的热独编码到PCA降维,从Word2Vec到Item2Vec,从矩阵分解到基于深度学习的协同过滤,可谓百花齐放、百家争鸣。更有学者提出:“万物皆可Embedding”。那么问题来了,什么是Embedding呢?
|
||||
|
||||
Embedding是个英文术语,如果非要找一个中文翻译对照的话,我觉得“向量化”(Vectorize)最合适。Embedding的过程,就是把数据集合映射到向量空间,进而把数据进行向量化的过程。这句话听上去有些玄乎,我换个更好懂的说法,Embedding的目标,就是找到一组合适的向量,来刻画现有的数据集合。
|
||||
|
||||
以GarageType字段为例,它有6个取值,也就是说我们总共有6种车库类型。那么对于这6个字符串来说,我们该如何用数字化的方式来表示它们呢?毕竟,模型只能消费数值,不能直接消费字符串。
|
||||
|
||||
|
||||
|
||||
一种方法是采用预处理环节的StringIndexer,把字符串转换为连续的整数,然后让模型去消费这些整数。在理论上,这么做没有任何问题。但从模型的效果出发,整数的表达方式并不合理。为什么这么说呢?
|
||||
|
||||
我们知道,连续整数之间,是存在比较关系的,比如1 < 3,6 > 5,等等。但是原始的字符串之间,比如,“Attchd”与“Detchd”并不存在大小关系,如果强行用0表示“Attchd”、用1表示“Detchd”,逻辑上就会出现“Attchd”<“Detchd”的悖论。
|
||||
|
||||
因此,预处理环节的StringIndexer,仅仅是把字符串转换为数字,转换得到的数值是不能直接喂给模型做训练。我们需要把这些数字进一步向量化,才能交给模型去消费。那么问题来了,对于StringIndexer输出的数值,我们该怎么对他们进行向量化呢?这就要用到Embedding了。
|
||||
|
||||
作为入门课,咱们不妨从最简单的热独编码(One Hot Encoding)开始,去认识Embedding并掌握它的基本用法。我们先来说说,热独编码,是怎么一回事。相比照本宣科说概念,咱们不妨以GarageType为例,从示例入手,你反而更容易心领神会。
|
||||
|
||||
|
||||
|
||||
首先,通过StringIndexer,我们把GarageType的6个取值分别映射为0到5的六个数值。接下来,使用热独编码,我们把每一个数值都转化为一个向量。
|
||||
|
||||
向量的维度为6,与原始字段(GarageType)的多样性(Cardinality)保持一致。换句话说,热独编码的向量维度,就是原始字段的取值个数。
|
||||
|
||||
仔细观察上图的六个向量,只有一个维度取值为1,其他维度全部为0。取值为1的维度与StringIndexer输出的索引相一致。举例来说,字符串“Attchd”被StringIndexer映射为0,对应的热独向量是[1, 0, 0, 0, 0, 0]。向量中索引为0的维度取值为1,其他维度全部取0。
|
||||
|
||||
不难发现,热独编码是一种简单直接的Embedding方法,甚至可以说是“简单粗暴”。不过,在日常的机器学习开发中,“简单粗暴”的热独编码却颇受欢迎。
|
||||
|
||||
接下来,我们还是从“房价预测”的项目出发,说一说热独编码的具体用法。
|
||||
|
||||
在预处理环节,我们已经用StringIndexer把非数值字段全部转换为索引字段,接下来,我们再用OneHotEncoder,把索引字段进一步转换为向量字段。
|
||||
|
||||
import org.apache.spark.ml.feature.OneHotEncoder
|
||||
|
||||
// 非数值字段对应的目标索引字段,也即StringIndexer所需的“输出列”
|
||||
// val indexFields: Array[String] = categoricalFields.map(_ + "Index").toArray
|
||||
|
||||
// 热独编码的目标字段,也即OneHotEncoder所需的“输出列”
|
||||
val oheFields: Array[String] = categoricalFields.map(_ + "OHE").toArray
|
||||
|
||||
// 循环遍历所有索引字段,对其进行热独编码
|
||||
for ((indexField, oheField) <- indexFields.zip(oheFields)) {
|
||||
val oheEncoder = new OneHotEncoder()
|
||||
.setInputCol(indexField)
|
||||
.setOutputCol(oheField)
|
||||
engineeringData= oheEncoder.transform(engineeringData)
|
||||
}
|
||||
|
||||
|
||||
可以看到,我们循环遍历所有非数值特征,依次创建OneHotEncoder实例。在实例初始化的过程中,我们把索引字段传入给setInputCol函数,把热独编码目标字段传递给setOutputCol函数。最终通过调用OneHotEncoder的transform,在engineeringData之上完成转换。
|
||||
|
||||
好啦,到此为止,我们以OneHotEncoder为代表,学习了Spark MLlib框架中Embedding的用法,初步打通了特征工程的第五关。
|
||||
|
||||
尽管还有很多其他Embedding方法需要我们进一步探索,不过从入门的角度来说,OneHotEncoder完全可以应对大部分机器学习应用。
|
||||
|
||||
|
||||
|
||||
向量计算
|
||||
|
||||
打通第五关之后,特征工程“这套游戏”还剩下最后一道关卡:向量计算。
|
||||
|
||||
向量计算,作为特征工程的最后一个环节,主要用于构建训练样本中的特征向量(Feature Vectors)。在Spark MLlib框架下,训练样本由两部分构成,第一部分是预测标的(Label),在“房价预测”的项目中,Label是房价。
|
||||
|
||||
而第二部分,就是特征向量,在形式上,特征向量可以看作是元素类型为Double的数组。根据前面的特征工程流程图,我们不难发现,特征向量的构成来源多种多样,比如原始的数值字段、归一化或是离散化之后的数值字段、以及向量化之后的特征字段,等等。
|
||||
|
||||
Spark MLlib在向量计算方面提供了丰富的支持,比如前面介绍过的、用于集成特征向量的VectorAssembler,用于对向量做剪裁的VectorSlicer,以元素为单位做乘法的ElementwiseProduct,等等。灵活地运用这些函数,我们可以随意地组装特征向量,从而构建模型所需的训练样本。
|
||||
|
||||
在前面的几个环节中(预处理、特征选择、归一化、离散化、Embedding),我们尝试对数值和非数值类型特征做各式各样的转换,目的在于探索可能对预测标的影响更大的潜在因素。
|
||||
|
||||
接下来,我们使用VectorAssembler将这些潜在因素全部拼接在一起、构建特征向量,从而为后续的模型训练准备好训练样本。
|
||||
|
||||
import org.apache.spark.ml.feature.VectorAssembler
|
||||
|
||||
/**
|
||||
入选的数值特征:selectedFeatures
|
||||
归一化的数值特征:scaledFields
|
||||
离散化的数值特征:fieldBedroomDiscrete
|
||||
热独编码的非数值特征:oheFields
|
||||
*/
|
||||
|
||||
val assembler = new VectorAssembler()
|
||||
.setInputCols(selectedFeatures ++ scaledFields ++ fieldBedroomDiscrete ++ oheFields)
|
||||
.setOutputCol("features")
|
||||
|
||||
engineeringData = assembler.transform(engineeringData)
|
||||
|
||||
|
||||
转换完成之后,engineeringData这个DataFrame就包含了一列名为“features”的新字段,这个字段的内容,就是每条训练样本的特征向量。接下来,我们就可以像上一讲那样,通过setFeaturesCol和setLabelCol来指定特征向量与预测标的,定义出线性回归模型。
|
||||
|
||||
// 定义线性回归模型
|
||||
val lr = new LinearRegression()
|
||||
.setFeaturesCol("features")
|
||||
.setLabelCol("SalePriceInt")
|
||||
.setMaxIter(100)
|
||||
|
||||
// 训练模型
|
||||
val lrModel = lr.fit(engineeringData)
|
||||
|
||||
// 获取训练状态
|
||||
val trainingSummary = lrModel.summary
|
||||
// 获取训练集之上的预测误差
|
||||
println(s"Root Mean Squared Error (RMSE) on train data: ${trainingSummary.rootMeanSquaredError}")
|
||||
|
||||
|
||||
好啦,到此为止,我们打通了特征工程所有关卡,恭喜你!尽管不少关卡还有待我们进一步去深入探索,但这并不影响我们从整体上把握特征工程,构建结构化的知识体系。对于没讲到的函数与技巧,你完全可以利用自己的碎片时间,借鉴这两节课我给你梳理的学习思路,来慢慢地将它们补齐,加油!
|
||||
|
||||
|
||||
|
||||
通关奖励:模型效果对比
|
||||
|
||||
学习过VectorAssembler的用法之后,你会发现,特征工程任一环节的输出,都可以用来构建特征向量,从而用于模型训练。在介绍特征工程的部分,我们花了大量篇幅,介绍不同环节的作用与用法。
|
||||
|
||||
你可能会好奇:“这些不同环节的特征处理,真的会对模型效果有帮助吗?毕竟,折腾了半天,我们还是要看模型效果的”。
|
||||
|
||||
没错,特征工程的最终目的,是调优模型效果。接下来,通过将不同环节输出的训练样本喂给模型,我们来对比不同特征处理方法对应的模型效果。
|
||||
|
||||
|
||||
|
||||
不同环节对应的代码地址如下:
|
||||
|
||||
|
||||
调优对比基准-
|
||||
特征工程-调优1-
|
||||
特征工程-调优2-
|
||||
特征工程-调优3-
|
||||
特征工程-调优4-
|
||||
特征工程-调优5-
|
||||
特征工程-调优6
|
||||
|
||||
|
||||
可以看到,随着特征工程的推进,模型在训练集上的预测误差越来越小,这说明模型的拟合能力越来越强,而这也就意味着,特征工程确实有助于模型性能的提升。
|
||||
|
||||
对应特征工程不同环节的训练代码,我整理到了最后的“代码地址”那一列。强烈建议你动手运行这些代码,对比不同环节的特征处理方法,以及对应的模型效果。
|
||||
|
||||
当然,我们在评估模型效果的时候,不能仅仅关注它的拟合能力,更重要的是模型的泛化能力。拟合能力强,只能说明模型在训练集上的预测误差足够小;而泛化能力,量化的是模型在测试集上的预测误差。换句话说,泛化能力的含义是,模型在一份“未曾谋面”的数据集上表现如何。
|
||||
|
||||
这一讲,咱们的重点是特征工程,因此暂时忽略了模型在测试集上的表现。从下一讲的模型训练开始,对于模型效果,我们将同时关注模型这两方面的能力:拟合与泛化。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,今天的内容讲完啦,我们一起来做个总结。今天这一讲,我们主要围绕着特征工程中的离散化、Embedding和向量计算展开,你需要掌握其中最具代表性的特征处理函数。
|
||||
|
||||
到此为止,Spark MLlib特征工程中涉及的6大类特征处理函数,我们就都讲完了。为了让你对他们有一个整体上的把握,同时能够随时回顾不同环节的作用与效果,我把每一个大类的特点、以及咱们讲过的处理函数,都整理到了如下的表格中,供你参考。
|
||||
|
||||
|
||||
|
||||
今天的内容很多,需要我们多花时间去消化。受2/8理论的支配,在机器学习实践中,特征工程往往会花费我们80%的时间和精力。由于特征工程制约着模型效果的上限,因此,尽管特征工程的步骤繁多、过程繁琐,但是我们千万不能在这个环节偷懒,一定要认真对待。
|
||||
|
||||
这也是为什么我们分为上、下两部分来着重讲解特征工程,从概览到每一个环节,从每一个环节的作用到它包含的具体方法。数据质量构筑了模型效果的天花板,特征工程道阻且长,然而行则将至,让我们一起加油!
|
||||
|
||||
每课一练
|
||||
|
||||
结合上一讲,对于我们介绍过的所有特征处理函数,如StringIndexer、ChiSqSelector、MinMaxScaler、Bucketizer、OneHotEncoder和VectorAssembler,你能说说他们之间的区别和共同点吗?
|
||||
|
||||
欢迎你在留言区记录你的收获与思考,也欢迎你向更多同事、朋友分享今天的内容,说不定就能帮他解决特征工程方面的问题。
|
||||
|
||||
|
||||
|
||||
|
133
专栏/零基础入门Spark/26模型训练(上):决策树系列算法详解.md
Normal file
133
专栏/零基础入门Spark/26模型训练(上):决策树系列算法详解.md
Normal file
@ -0,0 +1,133 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
26 模型训练(上):决策树系列算法详解
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们重点介绍了机器学习中的特征工程,以及Spark MLlib框架支持的特征处理函数。基于线性回归模型,我们对比了不同特征处理方法下的模型效果。一般来说,线性模型的模型容量比较有限,它仅适合拟合特征向量与预测标的之间存在线性关系的场景。
|
||||
|
||||
但在实际应用中,线性关系少之又少,就拿“房价预测”的项目来说,不同的房屋属性与房价之间,显然不是单纯的线性关系。这也是为什么在房价预测的任务上,线性回归模型的预测误差一直高居不下。因此,为了提升房价预测的准确度,我们有必要从模型选型的角度,着手去考虑采用其他类型的模型算法,尤其是非线性模型。
|
||||
|
||||
Spark MLlib框架支持种类丰富的模型算法,为了在减轻你学习负担的同时,尽量全面地覆盖其中的内容,我把模型训练分为了上、中、下三讲。今天这一讲,我们专注在决策树系列算法的讲解。
|
||||
|
||||
后面两讲我再结合房屋预测和电影推荐场景,带你在实践中掌握Spark MLlib模型算法,从而让你在不同的场景下得心应手地开展模型选型与模型调优。
|
||||
|
||||
课程安排
|
||||
|
||||
因为模型训练的部分内容比较丰富,为了让你有一个清晰的学习计划,咱们还是先来交代一下课程安排。在机器学习领域,如果按照“样本是否存在预测标的(Label)”为标准,机器学习问题可以分为监督学习(Supervised Learning)与非监督学习(Unsupervised Learning)。Spark MLlib同时支持这两大类机器学习算法,如下图所示。
|
||||
|
||||
|
||||
|
||||
可以看到,在Spark MLlib开发框架下,按照使用场景不同,监督学习又被细分为回归(Regression)、分类(Classification)和协同过滤(Collaborative Filtering);而非监督学习被细分为聚类(Clustering)与频繁项集(Frequency Patterns)。
|
||||
|
||||
不同的分类下,Spark MLlib支持的模型算法多样而又庞杂。如果逐一讲解每种算法的原理和用法,不但枯燥乏味,而且容易遗忘。所以,对于每个分类,我都会精选一个最具代表性的算法,再结合实例进行讲解,这样你学完之后印象会更加深刻。
|
||||
|
||||
|
||||
|
||||
与5个子分类相对应,模型训练课程的实例也有5个,分别是房价预测、房屋分类、电影推荐1、房屋聚类、电影推荐2。根据数据来源的不同,这5个实例又可以分为两类,如下图所示。
|
||||
|
||||
为了照顾基础薄弱的同学,我们需要先搞清楚决策树、GBDT(Gradient-boosted Decision Trees)和RF(Random Forest)这些前置知识。学完这节课之后,你会发现一个很有趣的现象,这些知识点背后的原理跟人类的决策过程惊人的相似,但相比人类经验,机器又能青出于蓝。
|
||||
|
||||
好啦,让我们正式开始今天的学习。
|
||||
|
||||
决策树系列算法
|
||||
|
||||
马上就是“双十一”了,你可能很想血拼一把,但一摸自己的钱包,理智又占领了高地。试想一下,预算有限的情况下,你会如何挑选一款手机呢?我们往往会结合价位、品牌、评价等一系列因素考量,最后做出决策。
|
||||
|
||||
其实这个依据不同决定性因素来构建决策路径的过程,放在机器学习里,就是决策树。接下来,我们用严谨一点的术语再描述一下什么是决策树。
|
||||
|
||||
决策树(Decision Trees)是一种根据样本特征向量而构建的树形结构。决策树由节点(Nodes)与有向边(Vertexes)组成,其中节点又分为两类,一类是内部节点,一类是叶子节点。内部节点表示的是样本特征,而叶子节点代表分类。
|
||||
|
||||
举例来说,假设我们想根据“居室数量”和“房屋面积”这两个特征,把房屋分为5类。那么,我们就可以构建一个决策树,来做到这一点,如下图所示。
|
||||
|
||||
|
||||
|
||||
其中,椭圆形代表的是内部节点,每个内部节点都包含一个特征,并同时拥有两条有向边。每条有向边,都表示一组特征取值。比方说,图中决策树的根节点(顶端的内部节点)包含的特征是“居室数量”,左边的有向边,表示的是居室数量小于4的数据样本;而右边的有向边,代表的是居室数量大于等于4的数据样本。
|
||||
|
||||
就这样,原始的房屋样本被一分为二,按照居室数量被“劈”成了两份。“劈”到左侧的样本,继续按照“房屋面积”是否小于6作区分;而“劈”到右侧的样本,则按照“房屋面积”是否小于10来做进一步的区分。就这样,根据不同特征的不同取值范围,数据样本一层一层地被加以区分,直到圆形节点,也即叶子节点为止。
|
||||
|
||||
叶子节点表示数据样本的分类,图中的5个圆形即代表5个叶子节点。每个叶子节点中,都包含若干的数据样本,显然,掉落到同一个叶子节点的样本,同属于一个分类。
|
||||
|
||||
不难发现,在上面的决策树中,结合“居室数量”和“房屋面积”这两个特征的不同取值,原始的数据样本被划分成了“不重不漏”的5份子集,如下图所示。
|
||||
|
||||
|
||||
|
||||
基于这5份样本子集,我们就有能力去解决分类或是回归问题。假设数据样本中的标签列(Label)是“房屋质量”,数据样本,按照房屋质量的取值,被分为差、一般、好、很好和极好。
|
||||
|
||||
决策树中的5个叶子节点,对应的就是Label的5个不同取值。因此,凡是掉落在蓝色圆形节点的样本,它的房屋质量都是“差”,同理,凡是掉落在黄色圆形节点的样本,对应的房屋质量都是“极好”。如此一来,我们便按照“房屋质量”完成了对原始样本的分类过程。
|
||||
|
||||
实际上,回归过程也是类似的。如果数据样本中的标签不再是离散的“房屋质量”,而是连续的“房屋价格”,那么我们同样可以利用决策树来完成回归预测。假设我们用100条数据样本,来构建上面的决策树,并假设每个叶子节点都包含20条数据样本。
|
||||
|
||||
那么,当有一条新的数据样本需要预测房价的时候,我们只需要让它遍历决策树,然后看看它掉落到哪一个叶子节点中去。假设它掉落到了Set3这个节点,那么要预测这条样本的房价,我们就取Set3中那20条样本的房价均值。
|
||||
|
||||
好啦,到此为止,我们介绍了什么是决策树,怎么用决策树来预测新的数据样本。不难发现,决策树的推理过程,与人类的决策过程非常相似。
|
||||
|
||||
人类也常常“货比三家”,结合生活经验,根据一些关键因素做出决策。说到这里,你可能会好奇:“我做决定的时候,往往是结合生活经验,那么模型算法是依据什么,来构建决策树的呢?它怎么知道,哪些特征是决定性因素,而哪些特征又没什么用呢?”
|
||||
|
||||
用一句话来概括,数据样本的纯度,决定了模型算法选择哪些特征作为内部节点,同时也决定着决策树何时收敛。所谓样本纯度,简单地说,就是标签的多样性(Cardinality)。对于一个集合中的样本,如果样本的标签都一样,也即标签的多样性为1,那么我们就说这个集合的样本纯度很高。
|
||||
|
||||
相反,如果这个集合中的样本标签取值非常多,多样性非常高,那么我们就说这个集合的样本纯度很低。在数学上,我们可以用信息熵来量化样本的纯度(或者说标签多样性),不过作为入门课,咱们暂时不必深究,只要从概念上理解样本的纯度就好。
|
||||
|
||||
模型算法在构建决策树的时候,会去遍历每一个特征,并考察每个特征的“提纯”能力。所谓“提纯”,就是把原始样本结合特征进行区分之后,两个样本子集在纯度上有所提升。换句话说,经过候选特征分割后的样本子集,其纯度越高,就代表候选特征的“提纯”能力越高。
|
||||
|
||||
正是基于这样的逻辑,模型算法依次筛选“提纯”能力最高、次高、第三高的特征,逐级地去构建决策树,直到收敛为止。对于收敛条件,一方面我们可以人为地设置纯度阈值,另一方面,我们也可以通过设定树的深度(Depth、Levels)来进行限制。
|
||||
|
||||
在理想情况下,我们期望决策树每个叶子节点的纯度,尽可能地接近于0(用信息熵来量化),也即每个节点的标签都是一样的。但在实际工作中,我们很难做到这一点。不仅如此,一般来说,一棵决策树的拟合能力是相当有限的,它很难把样本的纯度提升得足够高。
|
||||
|
||||
这时就要说到GBDT(Gradient-boosted Decision Trees)和RF(Random Forest)这两种算法了,尽管它们的设计思想各不相同,但本质上都是为了进一步提升数据样本的纯度。
|
||||
|
||||
Random Forest
|
||||
|
||||
Random Forest,又叫“随机森林”,它的设计思想是“三个臭皮匠、赛过诸葛亮”。既然一棵树的拟合能力有限,那么就用多棵树来“凑数儿”,毕竟,老话说得好:人多出韩信。
|
||||
|
||||
举例来说,我们想结合多个特征,来对房屋质量进行分类。对于给定的数据样本,随机森林算法会训练多棵决策树,树与树之间是相互独立的,彼此之间不存在任何依赖关系。对于每一棵树,算法会随机选择部分样本与部分特征,来进行决策树的构建,这也是随机森林命名中“随机”一词的由来。
|
||||
|
||||
|
||||
|
||||
以上图为例,随机森林算法构建了3棵决策树,第一棵用到了“居室数量”和“房屋面积”这两个特征,而第二棵选择了“建筑年龄”、“装修情况”和“房屋类型”三个特征,最后一棵树选择的是“是否带泳池”、“房屋面积”、“装修情况”和“厨房数量”四个特征。
|
||||
|
||||
每棵树都把遍历的样本分为5个类别,每个类别都包含部分样本。当有新的数据样本需要预测房屋质量时,我们把数据样本同时“喂给”随机森林的3棵树,预测结果取决于3棵树各自的输出结果。
|
||||
|
||||
假设样本经过第一棵树的判别之后,掉落在了Set3;经过第二棵树的“决策”之后,掉落在了Set2;而经过第三棵树的判定之后,归类到了Set3,那么样本最终的预测结果就是Set3。也即按照“少数服从多数”的原则,随机森林最终的预测结果,会取所有决策树结果中的大多数。回归问题也是类似,最简单的办法,就是取所有决策树判定结果的均值。
|
||||
|
||||
GBDT
|
||||
|
||||
接下来,我们再说说GBDT(Gradient-boosted Decision Trees)。与随机森林类似,GBDT也是用多棵决策树来拟合数据样本,但是,树与树之间是有依赖关系的,每一棵树的构建,都是基于前一棵树的训练结果。因此,与随机森林不同,GBDT的设计思想是“站在前人的肩膀上看得更远”,如下图所示。
|
||||
|
||||
|
||||
|
||||
具体来说,在GBDT的训练过程中,每一棵树的构建,都是基于上一棵树输出的“样本残差”。如下图所示,预测值与真实值(Ground Truth)之间的差值,即是样本残差。后面决策树的拟合目标,不再是原始的房屋价格,而是这个样本残差。
|
||||
|
||||
|
||||
|
||||
以此类推,后续的决策树,都会基于上一棵树的残差去做拟合,从而使得预测值与真实值之间的误差越来越小,并最终趋近于0。不难发现,只要GBDT训练的决策树足够多,预测误差就可以足够小,因此,GBDT的拟合能力是非常强的。
|
||||
|
||||
不过,与此同时,我们要提防GBDT的过拟合问题,在训练集上过分拟合,往往会导致模型在测试集上的表现不尽如人意。解决过拟合的思路,就是让模型由复杂变得简单,要做到这一点,我们可以通过限制决策树的数量与深度,来降低GBDT模型的复杂度。
|
||||
|
||||
好啦,到此为止,我们学习了决策树,以及由决策树衍生的随机森林与GBDT算法。光说不练假把式,在下一讲,我们就以房价预测和房屋分类为例,体会一下在Spark MLlib的框架下,具体要如何应用这些算法解决实际问题。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,到此为止,我们今天的内容就全部讲完啦。让我们一起来做个总结。
|
||||
|
||||
首先,你需要知道,Spark MLlib开发框架都支持哪些模型算法,我把这些模型算法、以及算法的分类整理到了下面的脑图中,供你随时参考。
|
||||
|
||||
|
||||
|
||||
你需要掌握决策树系列算法的特点与基本原理。其中,决策树系列算法,既可以用于解决分类问题,也可以解决回归问题。相比线性模型,树模型拥有更强的非线性拟合能力,而且树模型具备良好的可解释性,它的工作原理非常符合人类的思考方式。随机森林与GBDT,是衍生自决策树的两类集成类算法。
|
||||
|
||||
随机森林的设计思想是“三个臭皮匠、赛过诸葛亮”,通过在多棵树上随机选取训练样本与特征,随机森林将多个简单模型集成在一起,用投票的方式共同来决定最终的预测结果。
|
||||
|
||||
而GBDT的思想是“站在前人的肩膀上看得更远”,它也是基于多棵树的集成模型。与随机森林不同,在GBDT中,树与树之间是存在依赖关系的。每一棵树的训练,都是基于前一棵树拟合的样本残差,从而使得预测值不断地逼近真实值。GBDT的特点是拟合能力超强,但同时要注意决策树过深、过多而带来的过拟合隐患。
|
||||
|
||||
每课一练
|
||||
|
||||
结合今天的课程内容,你能说说GBDT与Random Forest模型算法各自的优缺点吗?
|
||||
|
||||
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
247
专栏/零基础入门Spark/27模型训练(中):回归、分类和聚类算法详解.md
Normal file
247
专栏/零基础入门Spark/27模型训练(中):回归、分类和聚类算法详解.md
Normal file
@ -0,0 +1,247 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
27 模型训练(中):回归、分类和聚类算法详解
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们学习了决策树系列算法,包括决策树、GBDT和随机森林。今天这一讲,我们来看看在Spark MLlib框架下,如何将这些算法应用到实际的场景中。
|
||||
|
||||
你还记得我们给出的Spark MLlib模型算法“全景图”么?对于这张“全景图”,我们会时常回顾它。一方面,它能为我们提供“全局视角”,再者,有了它,我们就能够轻松地把学习过的内容对号入座,从而对于学习的进展,做到心中有数。
|
||||
|
||||
|
||||
|
||||
今天这一讲,我们会结合房屋预测场景,一起学习回归、分类与聚类中的典型算法在Spark MLlib框架下的具体用法。掌握这些用法之后,针对同一类机器学习问题(回归、分类或是聚类),你就可以在其算法集合中,灵活、高效地做算法选型。
|
||||
|
||||
房屋预测场景
|
||||
|
||||
在这个场景中,我们有3个实例,分别是房价预测、房屋分类和房屋聚类。房价预测我们并不陌生,在前面的学习中,我们一直在尝试把房价预测得更准。
|
||||
|
||||
房屋分类,它指的是,给定离散标签(Label),如“OverallQual”(房屋质量),结合房屋属性特征,将所有房屋分类到相应的标签取值,如房屋质量的“好、中、差”三类。
|
||||
|
||||
而房屋聚类,它指的是,在不存在标签的情况下,根据房屋特征向量,结合“物以类聚”的思想,将相似的房屋聚集到一起,形成聚类。
|
||||
|
||||
房价预测
|
||||
|
||||
在特征工程的两讲中,我们一直尝试使用线性模型来拟合房价,但线性模型的拟合能力相当有限。决策树系列模型属于非线性模型,在拟合能力上,更胜一筹。经过之前的讲解,想必你对Spark MLlib框架下模型训练的“套路”,已经了然于胸,模型训练基本上可以分为3个环节:
|
||||
|
||||
|
||||
准备训练样本
|
||||
定义模型,并拟合训练数据
|
||||
验证模型效果
|
||||
|
||||
|
||||
除了模型定义,第一个与第三个环节实际上是通用的。不论我们采用哪种模型,训练样本其实都大同小异,度量指标(不论是用于回归的RMSE,还是用于分类的AUC)本身也与模型无关。因此,今天这一讲,我们把重心放在第二个环节,对于代码实现,我们在文稿中也只粘贴这一环节的代码,其他环节的代码,你可以参考特征工程的两讲的内容。
|
||||
|
||||
[上一讲]我们学过了决策树系列模型及其衍生算法,也就是随机森林与GBDT算法。这两种算法既可以解决分类问题,也可以用来解决回归问题。既然GBDT擅长拟合残差,那么我们不妨用它来解决房价预测的(回归)问题,而把随机森林留给后面的房屋分类。
|
||||
|
||||
要用GBDT来拟合房价,我们首先还是先来准备训练样本。
|
||||
|
||||
// numericFields代表数值字段,indexFields为采用StringIndexer处理后的非数值字段
|
||||
val assembler = new VectorAssembler()
|
||||
.setInputCols(numericFields ++ indexFields)
|
||||
.setOutputCol("features")
|
||||
|
||||
// 创建特征向量“features”
|
||||
engineeringDF = assembler.transform(engineeringDF)
|
||||
|
||||
import org.apache.spark.ml.feature.VectorIndexer
|
||||
|
||||
// 区分离散特征与连续特征
|
||||
val vectorIndexer = new VectorIndexer()
|
||||
.setInputCol("features")
|
||||
.setOutputCol("indexedFeatures")
|
||||
// 设定区分阈值
|
||||
.setMaxCategories(30)
|
||||
|
||||
// 完成数据转换
|
||||
engineeringDF = vectorIndexer.fit(engineeringDF).transform(engineeringDF)
|
||||
|
||||
|
||||
我们之前已经学过了VectorAssembler的用法,它用来把多个字段拼接为特征向量。你可能已经发现,在VectorAssembler之后,我们使用了一个新的特征处理函数对engineeringDF进一步做了转换,这个函数叫作VectorIndexer。它是用来干什么的呢?
|
||||
|
||||
简单地说,它用来帮助决策树系列算法(如GBDT、随机森林)区分离散特征与连续特征。连续特征也即数值型特征,数值之间本身是存在大小关系的。而离散特征(如街道类型)在经过StringIndexer转换为数字之后,数字与数字之间会引入原本并不存在的大小关系(具体你可以回看[第25讲])。
|
||||
|
||||
这个问题要怎么解决呢?首先,对于经过StringIndexer处理过的离散特征,VectorIndexer会进一步对它们编码,抹去数字之间的比较关系,从而明确告知GBDT等算法,该特征为离散特征,数字与数字之间相互独立,不存在任何关系。
|
||||
|
||||
VectorIndexer对象的setMaxCategories方法,用于设定阈值,该阈值用于区分离散特征与连续特征,我们这里设定的阈值为30。这个阈值有什么用呢?凡是多样性(Cardinality)大于30的特征,后续的GBDT模型会把它们看作是连续特征,而多样性小于30的特征,GBDT会把它们当作是离散特征来进行处理。
|
||||
|
||||
说到这里,你可能会问:“对于一个特征,区分它是连续的、还是离散的,有这么重要吗?至于这么麻烦吗?”
|
||||
|
||||
还记得在决策树基本原理中,特征的“提纯”能力这个概念吗?对于同样一份数据样本,同样一个特征,连续值与离散值的“提纯”能力可能有着天壤之别。还原特征原本的“提纯”能力,将为决策树的合理构建,打下良好的基础。
|
||||
|
||||
好啦,样本准备好之后,接下来,我们就要定义并拟合GBDT模型了。
|
||||
|
||||
import org.apache.spark.ml.regression.GBTRegressor
|
||||
|
||||
// 定义GBDT模型
|
||||
val gbt = new GBTRegressor()
|
||||
.setLabelCol("SalePriceInt")
|
||||
.setFeaturesCol("indexedFeatures")
|
||||
// 限定每棵树的最大深度
|
||||
.setMaxDepth(5)
|
||||
// 限定决策树的最大棵树
|
||||
.setMaxIter(30)
|
||||
|
||||
// 区分训练集、验证集
|
||||
val Array(trainingData, testData) = engineeringDF.randomSplit(Array(0.7, 0.3))
|
||||
|
||||
// 拟合训练数据
|
||||
val gbtModel = gbt.fit(trainingData)
|
||||
|
||||
|
||||
可以看到,我们通过定义GBTRegressor来定义GBDT模型,其中setLabelCol、setFeaturesCol都是老生常谈的方法了,不再赘述。值得注意的是setMaxDepth和setMaxIter,这两个方法用于避免GBDT模型出现过拟合的情况,前者限定每棵树的深度,而后者直接限制了GBDT模型中决策树的总体数目。后面的训练过程,依然是调用模型的fit方法。
|
||||
|
||||
到此为止,我们介绍了如何通过定义GBDT模型,来拟合房价。后面的效果评估环节,鼓励你结合[第23讲]的模型验证部分,去自行尝试,加油!
|
||||
|
||||
房屋分类
|
||||
|
||||
接下来,我们再来说说房屋分类。我们知道,在“House Prices - Advanced Regression Techniques”竞赛项目中,数据集总共有79个字段。在之前,我们一直把售价SalePrice当作是预测标的,也就是Label,而用其他字段构建特征向量。
|
||||
|
||||
现在,我们来换个视角,把房屋质量OverallQual看作是Label,让售价SalePrice作为普通字段去参与构建特征向量。在房价预测的数据集中,房屋质量是离散特征,它的取值总共有10个,如下图所示。
|
||||
|
||||
|
||||
|
||||
如此一来,我们就把先前的回归问题(预测连续值),转换成了分类问题(预测离散值)。不过,不管是什么机器学习问题,模型训练都离不开那3个环节:
|
||||
|
||||
|
||||
准备训练样本
|
||||
定义模型,并拟合训练数据
|
||||
验证模型效果
|
||||
|
||||
|
||||
在训练样本的准备上,除了把预测标的从SalePrice替换为OverallQual,我们完全可以复用刚刚使用GBDT来预测房价的代码实现。
|
||||
|
||||
// Label字段:"OverallQual"
|
||||
val labelField: String = "OverallQual"
|
||||
|
||||
import org.apache.spark.sql.types.IntegerType
|
||||
engineeringDF = engineeringDF
|
||||
.withColumn("indexedOverallQual", col(labelField).cast(IntegerType))
|
||||
.drop(labelField)
|
||||
|
||||
|
||||
接下来,我们就可以定义随机森林模型、并拟合训练数据。实际上,除了类名不同,RandomForestClassifier在用法上与GBDT的GBTRegressor几乎一模一样,如下面的代码片段所示。
|
||||
|
||||
import org.apache.spark.ml.regression.RandomForestClassifier
|
||||
|
||||
// 定义随机森林模型
|
||||
val rf= new RandomForestClassifier ()
|
||||
// Label不再是房价,而是房屋质量
|
||||
.setLabelCol("indexedOverallQual")
|
||||
.setFeaturesCol("indexedFeatures")
|
||||
// 限定每棵树的最大深度
|
||||
.setMaxDepth(5)
|
||||
// 限定决策树的最大棵树
|
||||
.setMaxIter(30)
|
||||
|
||||
// 区分训练集、验证集
|
||||
val Array(trainingData, testData) = engineeringDF.randomSplit(Array(0.7, 0.3))
|
||||
|
||||
// 拟合训练数据
|
||||
val rfModel = rf.fit(trainingData)
|
||||
|
||||
|
||||
模型训练好之后,在第三个环节,我们来初步验证模型效果。
|
||||
|
||||
需要注意的是,衡量模型效果时,回归与分类问题,各自有一套不同的度量指标。毕竟,回归问题预测的是连续值,我们往往用不同形式的误差(如RMSE、MAE、MAPE,等等)来评价回归模型的好坏。而分类问题预测的是离散值,因此,我们通常采用那些能够评估分类“纯度”的指标,比如说准确度、精准率、召回率,等等。
|
||||
|
||||
|
||||
|
||||
这里,我们以Accuracy(准确度)为例,来评估随机森林模型的拟合效果,代码如下所示。
|
||||
|
||||
import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator
|
||||
|
||||
// 在训练集上做推理
|
||||
val trainPredictions = rfModel.transform(trainingData)
|
||||
|
||||
// 定义分类问题的评估对象
|
||||
val evaluator = new MulticlassClassificationEvaluator()
|
||||
.setLabelCol("indexedOverallQual")
|
||||
.setPredictionCol("prediction")
|
||||
.setMetricName("accuracy")
|
||||
|
||||
// 在训练集的推理结果上,计算Accuracy度量值
|
||||
val accuracy = evaluator.evaluate(trainPredictions)
|
||||
|
||||
|
||||
好啦,到此为止,我们以房价预测和房屋分类为例,分别介绍了如何在Spark MLlib框架下去应对回归问题与分类问题。分类与回归,是监督学习中最典型的两类模型算法,是我们必须要熟悉并掌握的。接下来,让我们以房屋聚类为例,说一说非监督学习。
|
||||
|
||||
房屋聚类
|
||||
|
||||
与监督学习相对,非监督学习,泛指那些数据样本中没有Label的机器学习问题。
|
||||
|
||||
以房屋数据为例,整个数据集包含79个字段。如果我们把“SalePrice”和“OverallQual”这两个字段抹掉,那么原始数据集就变成了不带Label的数据样本。你可能会好奇:“对于这些没有Label的样本,我们能拿他们做些什么呢?”
|
||||
|
||||
其实能做的事情还真不少,基于房屋数据,我们可以结合“物以类聚”的思想,使用K-means算法把他们进行分门别类的处理。再者,在下一讲电影推荐的例子中,我们还可以基于频繁项集算法,挖掘出不同电影之间共现的频次与关联规则,从而实现推荐。
|
||||
|
||||
今天我们先来讲K-mean,结合数据样本的特征向量,根据向量之间的相对距离,K-means算法可以把所有样本划分为K个类别,这也是算法命名中“K”的由来。举例来说,图中的每个点,都代表一个向量,给定不同的K值,K-means划分的结果会随着K的变化而变化。
|
||||
|
||||
|
||||
|
||||
在Spark MLlib的开发框架下,我们可以轻而易举地对任意向量做聚类。
|
||||
|
||||
首先,在模型训练的第一个环节,我们先把训练样本准备好。注意,这一次,我们去掉了“SalePrice”和“OverallQual”这两个字段。
|
||||
|
||||
import org.apache.spark.ml.feature.VectorAssembler
|
||||
|
||||
val assembler = new VectorAssembler()
|
||||
// numericFields包含连续特征,oheFields为离散特征的One hot编码
|
||||
.setInputCols(numericFields ++ oheFields)
|
||||
.setOutputCol("features")
|
||||
|
||||
|
||||
接下来,在第二个环节,我们来定义K-means模型,并使用刚刚准备好的样本,去做模型训练。可以看到,模型定义非常简单,只需实例化KMeans对象,并通过setK指定K值即可。
|
||||
|
||||
import org.apache.spark.ml.clustering.KMeans
|
||||
|
||||
val kmeans = new KMeans().setK(20)
|
||||
|
||||
val Array(trainingSet, testSet) = engineeringDF
|
||||
.select("features")
|
||||
.randomSplit(Array(0.7, 0.3))
|
||||
|
||||
val model = kmeans.fit(trainingSet)
|
||||
|
||||
|
||||
这里,我们准备把不同的房屋划分为20个不同的类别。完成训练之后,我们同样需要对模型效果进行评估。由于数据样本没有Label,因此,先前回归与分类的评估指标,不适合像K-means这样的非监督学习算法。
|
||||
|
||||
K-means的设计思想是“物以类聚”,既然如此,那么同一个类别中的向量应该足够地接近,而不同类别中向量之间的距离,应该越远越好。因此,我们可以用距离类的度量指标(如欧氏距离)来量化K-means的模型效果。
|
||||
|
||||
import org.apache.spark.ml.evaluation.ClusteringEvaluator
|
||||
|
||||
val predictions = model.transform(trainingSet)
|
||||
|
||||
// 定义聚类评估器
|
||||
val evaluator = new ClusteringEvaluator()
|
||||
|
||||
// 计算所有向量到分类中心点的欧氏距离
|
||||
val euclidean = evaluator.evaluate(predictions)
|
||||
|
||||
|
||||
好啦,到此为止,我们使用非监督学习算法K-means,根据房屋向量,对房屋类型进行了划分。不过你要注意,使用这种方法划分出的类型,是没有真实含义的,比如它不能代表房屋质量,也不能代表房屋评级。既然如此,我们用K-means忙活了半天,图啥呢?
|
||||
|
||||
尽管K-means的结果没有真实含义,但是它以量化的形式,刻画了房屋之间的相似性与差异性。你可以这样来理解,我们用K-means为房屋生成了新的特征,相比现有的房屋属性,这个生成的新特征(Generated Features)往往与预测标的(如房价、房屋类型)有着更强的关联性,所以让这个新特性参与到监督学习的训练,就有希望优化/提升监督学习的模型效果。
|
||||
|
||||
|
||||
|
||||
好啦,到此为止,结合房价预测、房屋分类和房屋聚类三个实例,我们成功打卡了回归、分类和聚类这三类模型算法。恭喜你!离Spark MLlib模型算法通关,咱们还有一步之遥。在下一讲,我们会结合电影推荐的场景,继续学习两个有趣的模型算法:协同过滤与频繁项集。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,你首先需要掌握K-means算法的基本原理。聚类的设计思想,是“物以类聚、人以群分”,给定任意向量集合,K-means都可以把它划分为K个子集合,从而完成聚类。
|
||||
|
||||
K-means的计算主要依赖向量之间的相对距离,它的计算结果,一方面可以直接用于划分“人群”、“种群”,另一方面可以拿来当做生成特征,去参与到监督学习的训练中去。
|
||||
|
||||
此外,你需要掌握GBTRegressor和RandomForestClassifier的一般用法。其中,setLabelCol与setFeaturesCol分别用于指定模型的预测标的与特征向量。而setMaxDepth与setMaxIter分别用于设置模型的超参数,也即最大树深与最大迭代次数(决策树的数量),从而避免模型出现过拟合的情况。
|
||||
|
||||
每课一练
|
||||
|
||||
对于房价预测与房屋分类这两个场景,你觉得在它们之间,有代码(尤其是特征工程部分的代码)复用的必要和可能性吗?
|
||||
|
||||
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
260
专栏/零基础入门Spark/28模型训练(下):协同过滤与频繁项集算法详解.md
Normal file
260
专栏/零基础入门Spark/28模型训练(下):协同过滤与频繁项集算法详解.md
Normal file
@ -0,0 +1,260 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
28 模型训练(下):协同过滤与频繁项集算法详解
|
||||
你好,我是吴磊。
|
||||
|
||||
如果你平时爱刷抖音,或者热衷看电影,不知道有没有过这样的体验:这类影视App你用得越久,它就好像会读心术一样,总能给你推荐对胃口的内容。其实这种迎合用户喜好的推荐,离不开机器学习中的推荐算法。
|
||||
|
||||
今天是咱们模型训练的最后一讲,在今天这一讲,我们就结合两个有趣的电影推荐场景,为你讲解Spark MLlib支持的协同过滤与频繁项集算法。与上一讲一样,咱们还是先来贴出下面这张“全景图”,方便你对学过和即将要学的知识做到心中有数。
|
||||
|
||||
|
||||
|
||||
电影推荐场景
|
||||
|
||||
今天这一讲,咱们结合Kaggle竞赛中的MovieLens数据集,使用不同算法来构建简易的电影推荐引擎。尽管MovieLens数据集包含了多个文件,但课程中主要用到的,是ratings.csv这个文件。文件中的每条数据条目,记录的都是用户对于电影的打分,如下表所示。
|
||||
|
||||
|
||||
|
||||
其中第一列userId为用户ID,movieId表示电影ID,而rating就是用户对于电影的评分。像这样,同时存有用户与物品(电影)信息的二维表,我们把它们统称为“交互矩阵”,或是“共现矩阵”。你可能会疑惑,通过这么一份简单的二维表,我们能干些什么呢?
|
||||
|
||||
可别小瞧这份数据,与合适的模型算法搭配在一起,我就能根据它们构建初具模样的推荐引擎。在Spark MLlib框架下,至少有两种模型算法可以做到这一点,一个是协同过滤(Collaborative Filtering),另一个是频繁项集(Frequency Patterns)。其中,前者天生就是用来做推荐用的,而后者是一种常规的非监督学习算法,你可以结合数据特点,把这个算法灵活运用于推荐场景。
|
||||
|
||||
协同过滤
|
||||
|
||||
我们先说协同过滤,从字面上来说,“过滤”是目的,而“协同”是方式、方法。简单地说,协同过滤的目标,就是从物品集合(比如完整的电影候选集)中,“过滤”出那些用户可能感兴趣的物品子集。而“协同”,它指的是,利用群体行为(全部用户与全部物品的交互历史)来实现过滤。
|
||||
|
||||
这样说有些绕,实际上,协同过滤的核心思想很简单,就是“相似的人倾向于喜好相似的物品集”。
|
||||
|
||||
交互矩阵看上去简单,但其中隐含着大量的相似性信息,只要利用合适的模型算法,我们就能挖掘出用户与用户之间的相似性、物品与物品之间的相似性,以及用户与物品之间的相似性。一旦这些相似性可以被量化,我们自然就可以基于相似性去做推荐了。思路是不是很简单?
|
||||
|
||||
那么问题来了,这些相似性,该怎么量化呢?答案是:矩阵分解。
|
||||
|
||||
|
||||
|
||||
在数学上,给定维度为(M,N)的交互矩阵C,我们可以把它分解为两个矩阵U与I的乘积。其中,我们可以把U称作“用户矩阵”,它的维度为(M,K);而I可以看作是“物品矩阵”,它的维度是(K,N)。
|
||||
|
||||
在用户矩阵与物品矩阵中,K是超参数,它是由开发者人为设定的。不难发现,对于用户矩阵U中的每一行, 都可以看作是用户的Embedding,也即刻画用户的特征向量。同理,物品矩阵中的每一列,也都可以看作是物品的Embedding,也即刻画物品的特征向量。
|
||||
|
||||
正所谓,万物皆可Embedding。对于任何事物,一旦它们被映射到同一个向量空间,我们就可以使用欧氏距离或是余弦夹角等方法,来计算他们向量之间的相似度,从而实现上述各种相似性(用户与用户、物品与物品、用户与物品)的量化。
|
||||
|
||||
基于相似度计算,我们就可以翻着花样地去实现各式各样的推荐。比方说,对于用户A来说,首先搜索与他/她最相似的前5个用户,然后把这些用户喜欢过的物品(电影)推荐给用户A,这样的推荐方式,又叫基于用户相似度的推荐。
|
||||
|
||||
再比如,对于用户A喜欢过的物品,我们搜索与这些物品最相似的前5个物品,然后把这些搜索到的物品,再推荐给用户A,这叫做基于物品相似度的推荐。
|
||||
|
||||
甚至,在一些情况下,我们还可以直接计算用户A与所有物品之间的相似度,然后把排名靠前的5个物品,直接推荐给用户A。
|
||||
|
||||
基于上述逻辑,我们还可以反其道而行之,从物品的视角出发,给物品(电影)推荐用户。不难发现,一旦完成Embedding的转换过程,我们就可以根据相似度计算来灵活地设计推荐系统。
|
||||
|
||||
那么,接下来的问题是,在Spark MLlib的框架下,我们具体要怎么做,才能从原始的互动矩阵,获得分解之后的用户矩阵、物品矩阵,进而获取到用户与物品的Embedding,并最终设计出简易的推荐引擎呢?
|
||||
|
||||
按照惯例,我们还是先上代码,用代码来演示这个过程。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
// rootPath表示数据集根目录
|
||||
val rootPath: String = _
|
||||
val filePath: String = s"${rootPath}/ratings.csv"
|
||||
|
||||
var data: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
|
||||
|
||||
// 类型转换
|
||||
import org.apache.spark.sql.types.IntegerType
|
||||
import org.apache.spark.sql.types.FloatType
|
||||
|
||||
// 把ID类字段转换为整型,把Rating转换为Float类型
|
||||
data = data.withColumn(s"userIdInt",col("userId").cast(IntegerType)).drop("userId")
|
||||
data = data.withColumn(s"movieIdInt",col("movieId").cast(IntegerType)).drop("movieId")
|
||||
data = data.withColumn(s"ratingFloat",col("rating").cast(IntegerType)).drop("rating")
|
||||
|
||||
// 切割训练与验证数据集
|
||||
val Array(trainingData, testData) = data.randomSplit(Array(0.8, 0.2))
|
||||
|
||||
|
||||
第一步,还是准备训练样本,我们从ratings.csv创建DataFrame,然后对相应字段做类型转换,以备后面使用。第二步,我们定义并拟合模型,完成协同过滤中的矩阵分解。
|
||||
|
||||
import org.apache.spark.ml.recommendation.ALS
|
||||
|
||||
// 基于ALS(Alternative Least Squares,交替最小二乘)构建模型,完成矩阵分解
|
||||
val als = new ALS()
|
||||
.setUserCol("userIdInt")
|
||||
.setItemCol("movieIdInt")
|
||||
.setRatingCol("ratingFloat")
|
||||
.setMaxIter(20)
|
||||
|
||||
val alsModel = als.fit(trainingData)
|
||||
|
||||
|
||||
值得一提的是,在Spark MLlib的框架下,对于协同过滤的实现,Spark并没有采用解析解的方式(数学上严格的矩阵分解),而是用了一种近似的方式来去近似矩阵分解。这种方式,就是ALS(Alternative Least Squares,交替最小二乘)。
|
||||
|
||||
具体来说,给定交互矩阵C,对于用户矩阵U与物品矩阵I,Spark先给U设定一个初始值,然后假设U是不变的,在这种情况下,Spark把物品矩阵I的优化,转化为回归问题,不停地去拟合I,直到收敛。然后,固定住物品矩阵I,再用回归的思路去优化用户矩阵U,直至收敛。如此反复交替数次,U和I都逐渐收敛到最优解,Spark即宣告训练过程结束。
|
||||
|
||||
因为Spark把矩阵分解转化成了回归问题,所以我们可以用回归相关的度量指标来衡量ALS模型的训练效果,如下所示。
|
||||
|
||||
import org.apache.spark.ml.evaluation.RegressionEvaluator
|
||||
|
||||
val evaluator = new RegressionEvaluator()
|
||||
// 设定度量指标为RMSE
|
||||
.setMetricName("rmse")
|
||||
.setLabelCol("ratingFloat")
|
||||
.setPredictionCol("prediction")
|
||||
|
||||
val predictions = alsModel.transform(trainingData)
|
||||
// 计算RMSE
|
||||
val rmse = evaluator.evaluate(predictions)
|
||||
|
||||
|
||||
验证过模型效果之后,接下来,我们就可以放心地从模型当中,去获取训练好的用户矩阵U和物品矩阵I。这两个矩阵中,保存的正是用户Embedding与物品Embedding。
|
||||
|
||||
alsModel.userFactors
|
||||
// org.apache.spark.sql.DataFrame = [id: int, features: array<float>]
|
||||
|
||||
alsModel.userFactors.show(1)
|
||||
/** 结果打印
|
||||
+---+--------------------+
|
||||
| id| features|
|
||||
+---+--------------------+
|
||||
| 10|[0.53652495, -1.0...|
|
||||
+---+--------------------+
|
||||
*/
|
||||
|
||||
alsModel.itemFactors
|
||||
// org.apache.spark.sql.DataFrame = [id: int, features: array<float>]
|
||||
|
||||
alsModel.itemFactors.show(1)
|
||||
/** 结果打印
|
||||
+---+--------------------+
|
||||
| id| features|
|
||||
+---+--------------------+
|
||||
| 10|[1.1281404, -0.59...|
|
||||
+---+--------------------+
|
||||
*/
|
||||
|
||||
|
||||
就像我们之前说的,有了用户与物品的Embedding,我们就可以灵活地设计推荐引擎。如果我们想偷懒的话,还可以利用Spark MLlib提供的API来做推荐。具体来说,我们可以通过调用ALS Model的相关方法,来实现向用户推荐物品,或是向物品推荐用户,如下所示。
|
||||
|
||||
// 为所有用户推荐10部电影
|
||||
val userRecs = alsModel.recommendForAllUsers(10)
|
||||
|
||||
// 为每部电影推荐10个用户
|
||||
val movieRecs = alsModel.recommendForAllItems(10)
|
||||
|
||||
// 为指定用户推荐10部电影
|
||||
val users = data.select(als.getUserCol).distinct().limit(3)
|
||||
val userSubsetRecs = alsModel.recommendForUserSubset(users, 10)
|
||||
|
||||
// 为指定电影推荐10个用户
|
||||
val movies = data.select(als.getItemCol).distinct().limit(3)
|
||||
val movieSubSetRecs = alsModel.recommendForItemSubset(movies, 10)
|
||||
|
||||
|
||||
好啦,到此为止,我们介绍了协同过滤的核心思想与工作原理,并使用Spark MLlib提供的ALS算法,实现了一个简单的电影推荐引擎。接下来,我们再来想一想,还有没有其他的思路来打造一个不一样的推荐引擎。
|
||||
|
||||
频繁项集
|
||||
|
||||
频繁项集(Frequency Patterns),是一种经典的数据挖掘算法,我们可以把它归类到非监督学习的范畴。频繁项集可以挖掘数据集中那些经常“成群结队”出现的数据项,并尝试在它们之间建立关联规则(Association Rules),从而为决策提供支持。
|
||||
|
||||
举例来说,基于对上百万条交易记录的统计分析,蔬果超市发现(“葱”,“姜”,“蒜”)这三种食材经常一起出现。换句话说,购买了“葱”、“姜”的人,往往也会再买上几头蒜,或是买了大葱的人,结账前还会再把姜、蒜也捎上。
|
||||
|
||||
在这个购物篮的例子中,(“葱”,“姜”,“蒜”)就是频繁项(Frequency Itemset),也即经常一起共现的数据项集合。而像(“葱”、“姜”->“蒜”)和(“葱”->“姜”、“蒜”)这样的关联关系,就叫做关联规则。
|
||||
|
||||
不难发现,基于频繁项与关联规则,我们能够提供简单的推荐能力。以刚刚的(“葱”,“姜”,“蒜”)为例,对于那些手中提着大葱、准备结账的人,精明的导购员完全可以向他/她推荐超市新上的河北白皮蒜或是山东大生姜。
|
||||
|
||||
回到电影推荐的场景,我们同样可以基于历史,挖掘出频繁项和关联规则。比方说,电影(“八佰”、“金刚川”、“长津湖”)是频繁项,而(“八佰”、“金刚川”->“长津湖”)之间存在着关联关系。那么,对于看过“八佰”和“金刚川”的人,我们更倾向于判断他/她大概率也会喜欢“长津湖”,进而把这部电影推荐给他/她。
|
||||
|
||||
那么,基于MovieLens数据集,在Spark MLlib的开发框架下,我们该如何挖掘其中的频繁项与关联规则呢?
|
||||
|
||||
首先第一步,是数据准备。在蔬果超市的例子中,超市需要以交易为单位,收集顾客曾经一起购买过的各种蔬果。为了在MovieLens数据集上计算频繁项集,我们也需要以用户为粒度,收集同一个用户曾经看过的所有电影集合,如下图所示。
|
||||
|
||||
|
||||
|
||||
要完成这样的转换,我们只需要一行代码即可搞定。
|
||||
|
||||
// data是从ratings.csv创建的DataFrame
|
||||
val movies: DataFrame = data
|
||||
// 按照用户分组
|
||||
.groupBy("userId")
|
||||
// 收集该用户看过的所有电影,把新的集合列命名为movieSeq
|
||||
.agg(collect_list("movieId").alias("movieSeq"))
|
||||
// 只保留movieSeq这一列,去掉其他列
|
||||
.select("movieSeq")
|
||||
|
||||
// movies: org.apache.spark.sql.DataFrame = [movieSeq: array<string>]
|
||||
|
||||
movies.show(1)
|
||||
/** 结果打印
|
||||
+--------------------+
|
||||
| movieSeq|
|
||||
+--------------------+
|
||||
|[151, 172, 236, 2...|
|
||||
+--------------------+
|
||||
*/
|
||||
|
||||
|
||||
数据准备好之后,接下来,我们就可以借助Spark MLlib框架来完成频繁项集的计算。
|
||||
|
||||
import org.apache.spark.ml.fpm.FPGrowth
|
||||
|
||||
val fpGrowth = new FPGrowth()
|
||||
// 指定输入列
|
||||
.setItemsCol("movieSeq")
|
||||
// 超参数,频繁项最小支持系数
|
||||
.setMinSupport(0.1)
|
||||
// 超参数,关联规则最小信心系数
|
||||
.setMinConfidence(0.1)
|
||||
|
||||
val model = fpGrowth.fit(movies)
|
||||
|
||||
|
||||
可以看到,定义并拟合频繁项集模型,还是比较简单的,用法上与其他模型算法大同小异。不过,这里有两个超参数需要特别关注,一个是由setMinSupport设置的最小支持系数,另一个是由setMinConfidence指定的最小信心系数。
|
||||
|
||||
最小支持系数,它用来设定频繁项的“选拔阈值”,这里我们把它设置为0.1。这是什么意思呢?
|
||||
|
||||
举例来说,在MovieLens数据集中,总共有7120个用户,相应地,movies这个DataFrame中,就有7120条电影集合数据。对于(“八佰”、“金刚川”、“长津湖”)这个组合来说,当且仅当它出现的次数大于712(7120 * 0.1),这个组合才会被算法判定为频繁项。换句话说,最小支持系数越高,算法挖掘出的频繁项越少、越可靠,反之越多。
|
||||
|
||||
相应地,最小信心系数,是用来约束关联规则的,例子中的取值也是0.1。我们再来举例说明,假设在7120条电影集合数据中,(“八佰”、“金刚川”)这对组合一起出现过1000次,那么要想(“八佰”、“金刚川”->“长津湖”)这条关联规则成立,则(“八佰”、“金刚川”、“长津湖”)这个组合必须至少出现过100次(1000 * 0.1)。同理,最小信心系数越高,算法挖掘出的关联规则越少、越可靠,反之越多。
|
||||
|
||||
模型训练好之后,我们就可以从中获取经常出现的频繁项与关联规则,如下所示。
|
||||
|
||||
model.freqItemsets.show(1)
|
||||
/** 结果打印
|
||||
+--------------------+----+
|
||||
| items|freq|
|
||||
+--------------------+----+
|
||||
|[318, 593, 356, 296]|1465|
|
||||
+--------------------+----+
|
||||
*/
|
||||
|
||||
model.associationRules.show(1)
|
||||
/** 结果打印
|
||||
+--------------------+----------+------------------+
|
||||
| antecedent|consequent| confidence|
|
||||
+--------------------+----------+------------------+
|
||||
|[592, 780, 480, 593]| [296]|0.8910463861920173|
|
||||
+--------------------+----------+------------------+
|
||||
*/
|
||||
|
||||
|
||||
基于关联规则,我们就可以提供初步的推荐功能。比方说,对于看过(592、780、480、593)这四部电影的用户,我们可以把ID为296的电影推荐给他/她。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,到此为止,模型训练的上、中、下三讲,我们就全部讲完啦!这三讲的内容较多,涉及的算法也很多,为了让你对他们有一个整体的把握,我把这些算法的分类、原理、特点与适用场景,都整理到了如下的表格中,供你随时回顾。
|
||||
|
||||
|
||||
|
||||
不难发现,机器学习的场景众多,不同的场景下,又有多种不同的算法供我们选择。掌握这些算法的原理与特性,有利于我们高效地进行模型选型与模型训练,从而去解决不同场景下的特定问题。
|
||||
|
||||
对于算法的调优与应用,还需要你结合日常的实践去进一步验证、巩固,也欢迎你在留言区分享你的心得与体会,让我们一起加油!
|
||||
|
||||
每课一练
|
||||
|
||||
对于本讲介绍的两种推荐思路(协同过滤与频繁项集),你能说说他们各自的优劣势吗?
|
||||
|
||||
你有什么学习收获或者疑问,都可以跟我交流,咱们留言区见。
|
||||
|
||||
|
||||
|
||||
|
330
专栏/零基础入门Spark/29SparkMLlibPipeline:高效开发机器学习应用.md
Normal file
330
专栏/零基础入门Spark/29SparkMLlibPipeline:高效开发机器学习应用.md
Normal file
@ -0,0 +1,330 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
29 Spark MLlib Pipeline:高效开发机器学习应用
|
||||
你好,我是吴磊。
|
||||
|
||||
前面我们一起学习了如何在Spark MLlib框架下做特征工程与模型训练。不论是特征工程,还是模型训练,针对同一个机器学习问题,我们往往需要尝试不同的特征处理方法或是模型算法。
|
||||
|
||||
结合之前的大量实例,细心的你想必早已发现,针对同一问题,不同的算法选型在开发的过程中,存在着大量的重复性代码。
|
||||
|
||||
以GBDT和随机森林为例,它们处理数据的过程是相似的,原始数据都是经过StringIndexer、VectorAssembler和VectorIndexer这三个环节转化为训练样本,只不过GBDT最后用GBTRegressor来做回归,而随机森林用RandomForestClassifier来做分类。
|
||||
|
||||
|
||||
|
||||
不仅如此,在之前验证模型效果的时候我们也没有闭环,仅仅检查了训练集上的拟合效果,并没有在测试集上进行推理并验证。如果我们尝试去加载新的测试数据集,那么所有的特征处理过程,都需要在测试集上重演一遍。无疑,这同样会引入大量冗余的重复代码。
|
||||
|
||||
那么,有没有什么办法,能够避免上述的重复开发,让Spark MLlib框架下的机器学习开发更加高效呢?答案是肯定的,今天这一讲,我们就来说说Spark MLlib Pipeline,看看它如何帮助开发者大幅提升机器学习应用的开发效率。
|
||||
|
||||
Spark MLlib Pipeline
|
||||
|
||||
什么是Spark MLlib Pipeline呢?简单地说,Pipeline是一套基于DataFrame的高阶开发API,它让开发者以一种高效的方式,来打造端到端的机器学习流水线。这么说可能比较抽象,我们不妨先来看看,Pipeline都有哪些核心组件,它们又提供了哪些功能。
|
||||
|
||||
Pipeline的核心组件有两类,一类是Transformer,我们不妨把它称作“转换器”,另一类是Estimator,我把它叫作“模型生成器”。我们之前接触的各类特征处理函数,实际上都属于转换器,比如StringIndexer、MinMaxScaler、Bucketizer、VectorAssembler,等等。而前面3讲提到的模型算法,全部都是Estimator。
|
||||
|
||||
|
||||
|
||||
Transformer
|
||||
|
||||
我们先来说说Transformer,数据转换器。在形式上,Transformer的输入是DataFrame,输出也是DataFrame。结合特定的数据处理逻辑,Transformer基于原有的DataFrame数据列,去创建新的数据列,而新的数据列中,往往包含着不同形式的特征。
|
||||
|
||||
以StringIndexer为例,它的转换逻辑很简单,就是把字符串转换为数值。在创建StringIndexer实例的时候,我们需要使用setInputCol(s)和setOutputCol(s)方法,来指定原始数据列和期待输出的数据列,而输出数据列中的内容就是我们需要的特征,如下图所示。
|
||||
|
||||
|
||||
|
||||
结合图示可以看到,Transformer消费原有DataFrame的数据列,然后把生成的数据列再追加到该DataFrame,就会生成新的DataFrame。换句话说,Transformer并不是“就地”(Inline)修改原有的DataFrame,而是基于它去创建新的DataFrame。
|
||||
|
||||
实际上,每个Transformer都实现了setInputCol(s)和setOutputCol(s)这两个(接口)方法。除此之外,Transformer还提供了transform接口,用于封装具体的转换逻辑。正是基于这些核心接口,Pipeline才能把各式各样的Transformer拼接在一起,打造出了特征工程流水线。
|
||||
|
||||
一般来说,在一个机器学习应用中,我们往往需要多个Transformer来对数据做各式各样的转换,才能生成所需的训练样本。在逻辑上,多个基于同一份原始数据生成的、不同“版本”数据的DataFrame,它们会同时存在于系统中。
|
||||
|
||||
不过,受益于Spark的惰性求值(Lazy Evaluation)设计,应用在运行时并不会出现多份冗余数据重复占用内存的情况。
|
||||
|
||||
不过,为了开发上的遍历,我们还是会使用var而不是用val来命名原始的DataFrame。原因很简单,如果用val的话,我们需要反复使用新的变量名,来命名新生成的DataFrame。关于这部分开发小细节,你可以通过回顾[上一讲]的代码来体会。
|
||||
|
||||
Estimator
|
||||
|
||||
接下来,我们来说说Estimator。相比Transformer,Estimator要简单得多,它实际上就是各类模型算法,如GBDT、随机森林、线性回归,等等。Estimator的核心接口,只有一个,那就是fit,中文可以翻译成“拟合”。
|
||||
|
||||
Estimator的作用,就是定义模型算法,然后通过拟合DataFrame所囊括的训练样本,来生产模型(Models)。这也是为什么我把Estimator称作是“模型生成器”。
|
||||
|
||||
不过,有意思的是,虽然模型算法是Estimator,但是Estimator生产的模型,却是不折不扣的Transformer。
|
||||
|
||||
要搞清楚为什么模型是Transformer,我们得先弄明白模型到底是什么。所谓机器学习模型,它本质上就是一个参数(Parameters,又称权重,Weights)矩阵,外加一个模型结构。模型结构与模型算法有关,比如决策树结构、GBDT结构、神经网络结构,等等。
|
||||
|
||||
模型的核心用途就是做推断(Inference)或者说预测。给定数据样本,模型可以推断房价、推断房屋类型,等等。在Spark MLlib框架下,数据样本往往是由DataFrame封装的,而模型推断的结果,还是保存在(新的)DataFrame中,结果的默认列名是“predictions”。
|
||||
|
||||
其实基于训练好的推理逻辑,通过增加“predictions”列,把一个DataFrame转化成一个新的DataFrame,这不就是Transformer在做的事情吗?而这,也是为什么在模型算法上,我们调用的是fit方法,而在做模型推断时,我们在模型上调用的是transform方法。
|
||||
|
||||
构建Pipeline
|
||||
|
||||
好啦,了解了Transformer和Estimator之后,我们就可以基于它们去构建Pipeline,来打造端到端的机器学习流水线。实际上,一旦Transformer、Estimator准备就绪,定义Pipeline只需一行代码就可以轻松拿下,如下所示。
|
||||
|
||||
import org.apache.spark.ml.Pipeline
|
||||
|
||||
// 像之前一样,定义各种特征处理对象与模型算法
|
||||
val stringIndexer = _
|
||||
val vectorAssembler = _
|
||||
val vectorIndexer = _
|
||||
val gbtRegressor = _
|
||||
|
||||
// 将所有的Transformer、Estimator依序放入数组
|
||||
val stages = Array(stringIndexer, vectorAssembler, vectorIndexer, gbtRegressor)
|
||||
|
||||
// 定义Spark MLlib Pipeline
|
||||
val newPipeline = new Pipeline()
|
||||
.setStages(stages)
|
||||
|
||||
|
||||
可以看到,要定义Pipeline,只需创建Pipeline实例,然后把之前定义好的Transformer、Estimator纷纷作为参数,传入setStages方法即可。需要注意的是,一个Pipeline可以包含多个Transformer和Estimator,不过,Pipeline的最后一个环节,必须是Estimator,切记。
|
||||
|
||||
到此为止,Pipeline的作用、定义以及核心组件,我们就讲完了。不过,你可能会说:“概念是讲完了,不过我还是不知道Pipeline具体怎么用,以及它到底有什么优势?”别着急,光说不练假把式,接下来,我们就结合GBDT与随机森林的例子,来说说Pipeline的具体用法,以及怎么用它帮你大幅度提升开发效率。
|
||||
|
||||
首先,我们来看看,在一个机器学习应用中,Pipeline如何帮助我们提高效率。在上一讲,我们用GBDT来拟合房价,并给出了代码示例。
|
||||
|
||||
现在,咱们把代码稍微调整一下,用Spark MLlib Pipeline来实现模型训练。第一步,我们还是先从文件创建DataFrame,然后把数值型字段与非数值型字段区分开,如下所示。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
// rootPath为房价预测数据集根目录
|
||||
val rootPath: String = _
|
||||
val filePath: String = s"${rootPath}/train.csv"
|
||||
|
||||
// 读取文件,创建DataFrame
|
||||
var engineeringDF: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
|
||||
|
||||
// 所有数值型字段
|
||||
val numericFields: Array[String] = Array("LotFrontage", "LotArea", "MasVnrArea", "BsmtFinSF1", "BsmtFinSF2", "BsmtUnfSF", "TotalBsmtSF", "1stFlrSF", "2ndFlrSF", "LowQualFinSF", "GrLivArea", "BsmtFullBath", "BsmtHalfBath", "FullBath", "HalfBath", "BedroomAbvGr", "KitchenAbvGr", "TotRmsAbvGrd", "Fireplaces", "GarageCars", "GarageArea", "WoodDeckSF", "OpenPorchSF", "EnclosedPorch", "3SsnPorch", "ScreenPorch", "PoolArea")
|
||||
|
||||
// Label字段
|
||||
val labelFields: Array[String] = Array("SalePrice")
|
||||
|
||||
import org.apache.spark.sql.types.IntegerType
|
||||
|
||||
for (field <- (numericFields ++ labelFields)) {
|
||||
engineeringDF = engineeringDF
|
||||
.withColumn(s"${field}Int",col(field).cast(IntegerType))
|
||||
.drop(field)
|
||||
}
|
||||
|
||||
|
||||
数据准备好之后,接下来,我们就可以开始着手,为Pipeline的构建打造零件:依次定义转换器Transformer和模型生成器Estimator。在上一讲,我们用StringIndexer把非数值字段转换为数值字段,这一讲,咱们也依法炮制。
|
||||
|
||||
import org.apache.spark.ml.feature.StringIndexer
|
||||
|
||||
// 所有非数值型字段
|
||||
val categoricalFields: Array[String] = Array("MSSubClass", "MSZoning", "Street", "Alley", "LotShape", "LandContour", "Utilities", "LotConfig", "LandSlope", "Neighborhood", "Condition1", "Condition2", "BldgType", "HouseStyle", "OverallQual", "OverallCond", "YearBuilt", "YearRemodAdd", "RoofStyle", "RoofMatl", "Exterior1st", "Exterior2nd", "MasVnrType", "ExterQual", "ExterCond", "Foundation", "BsmtQual", "BsmtCond", "BsmtExposure", "BsmtFinType1", "BsmtFinType2", "Heating", "HeatingQC", "CentralAir", "Electrical", "KitchenQual", "Functional", "FireplaceQu", "GarageType", "GarageYrBlt", "GarageFinish", "GarageQual", "GarageCond", "PavedDrive", "PoolQC", "Fence", "MiscFeature", "MiscVal", "MoSold", "YrSold", "SaleType", "SaleCondition")
|
||||
|
||||
// StringIndexer期望的输出列名
|
||||
val indexFields: Array[String] = categoricalFields.map(_ + "Index").toArray
|
||||
|
||||
// 定义StringIndexer实例
|
||||
val stringIndexer = new StringIndexer()
|
||||
// 批量指定输入列名
|
||||
.setInputCols(categoricalFields)
|
||||
// 批量指定输出列名,输出列名与输入列名,必须要一一对应
|
||||
.setOutputCols(indexFields)
|
||||
.setHandleInvalid("keep")
|
||||
|
||||
|
||||
在上一讲,定义完StringIndexer实例之后,我们立即拿它去对engineeringDF做转换。不过在构建Pipeline的时候,我们不需要这么做,只需要把这个“零件”定义好即可。接下来,我们来打造下一个零件:VectorAssembler。
|
||||
|
||||
import org.apache.spark.ml.feature.VectorAssembler
|
||||
|
||||
// 转换为整型的数值型字段
|
||||
val numericFeatures: Array[String] = numericFields.map(_ + "Int").toArray
|
||||
|
||||
val vectorAssembler = new VectorAssembler()
|
||||
/** 输入列为:数值型字段 + 非数值型字段
|
||||
注意,非数值型字段的列名,要用indexFields,
|
||||
而不能用原始的categoricalFields,不妨想一想为什么?
|
||||
*/
|
||||
.setInputCols(numericFeatures ++ indexFields)
|
||||
.setOutputCol("features")
|
||||
.setHandleInvalid("keep")
|
||||
|
||||
|
||||
与上一讲相比,VectorAssembler的定义并没有什么两样。
|
||||
|
||||
下面,我们继续来打造第三个零件:VectorIndexer,它用于帮助模型算法区分连续特征与离散特征。
|
||||
|
||||
import org.apache.spark.ml.feature.VectorIndexer
|
||||
|
||||
val vectorIndexer = new VectorIndexer()
|
||||
// 指定输入列
|
||||
.setInputCol("features")
|
||||
// 指定输出列
|
||||
.setOutputCol("indexedFeatures")
|
||||
// 指定连续、离散判定阈值
|
||||
.setMaxCategories(30)
|
||||
.setHandleInvalid("keep")
|
||||
|
||||
|
||||
到此为止,Transformer就全部定义完了,原始数据经过StringIndexer、VectorAssembler和VectorIndexer的转换之后,会生成新的DataFrame。在这个最新的DataFrame中,会有多个由不同Transformer生成的数据列,其中“indexedFeatures”列包含的数据内容即是特征向量。
|
||||
|
||||
结合DataFrame一路携带过来的“SalePriceInt”列,特征向量与预测标的终于结合在一起了,就是我们常说的训练样本。有了训练样本,接下来,我们就可以着手定义Estimator。
|
||||
|
||||
import org.apache.spark.ml.regression.GBTRegressor
|
||||
|
||||
val gbtRegressor = new GBTRegressor()
|
||||
// 指定预测标的
|
||||
.setLabelCol("SalePriceInt")
|
||||
// 指定特征向量
|
||||
.setFeaturesCol("indexedFeatures")
|
||||
// 指定决策树的数量
|
||||
.setMaxIter(30)
|
||||
// 指定决策树的最大深度
|
||||
.setMaxDepth(5)
|
||||
|
||||
|
||||
好啦,到这里,Pipeline所需的零件全部打造完毕,零件就位,只欠组装。我们需要通过Spark MLlib提供的“流水线工艺”,把所有零件组装成Pipeline。
|
||||
|
||||
import org.apache.spark.ml.Pipeline
|
||||
|
||||
val components = Array(stringIndexer, vectorAssembler, vectorIndexer, gbtRegressor)
|
||||
|
||||
val pipeline = new Pipeline()
|
||||
.setStages(components)
|
||||
|
||||
|
||||
怎么样,是不是很简单?接下来的问题是,有了Pipeline,我们都能用它做些什么呢?
|
||||
|
||||
// Pipeline保存地址的根目录
|
||||
val savePath: String = _
|
||||
|
||||
// 将Pipeline物化到磁盘,以备后用(复用)
|
||||
pipeline.write
|
||||
.overwrite()
|
||||
.save(s"${savePath}/unfit-gbdt-pipeline")
|
||||
|
||||
// 划分出训练集和验证集
|
||||
val Array(trainingData, validationData) = engineeringDF.randomSplit(Array(0.7, 0.3))
|
||||
|
||||
// 调用fit方法,触发Pipeline计算,并最终拟合出模型
|
||||
val pipelineModel = pipeline.fit(trainingData)
|
||||
|
||||
|
||||
首先,我们可以把Pipeline保存下来,以备后用,至于怎么复用,我们待会再说。再者,把之前准备好的训练样本,传递给Pipeline的fit方法,即可触发整条Pipeline从头至尾的计算逻辑,从各式各样的数据转换,到最终的模型训练,一步到位。-
|
||||
Pipeline fit方法的输出结果,即是训练好的机器学习模型。我们最开始说过,模型也是Transformer,它可以用来推断预测结果。
|
||||
|
||||
看到这里,你可能会说:“和之前的代码实现相比,Pipeline也没有什么特别之处,无非是用Pipeline API把之前的环节拼接起来而已”。其实不然,基于构建好的Pipeline,我们可以在不同范围对其进行复用。对于机器学习应用来说,我们既可以在作业内部实现复用,也可以在作业之间实现复用,从而大幅度提升开发效率。
|
||||
|
||||
作业内的代码复用
|
||||
|
||||
在之前的模型训练过程中,我们仅仅在训练集与验证集上评估了模型效果。实际上,在工业级应用中,我们最关心的,是模型在测试集上的泛化能力。就拿Kaggle竞赛来说,对于每一个机器学习项目,Kaggle都会同时提供train.csv和test.csv两个文件。
|
||||
|
||||
其中train.csv是带标签的,用于训练模型,而test.csv是不带标签的。我们需要对test.csv中的数据做推断,然后把预测结果提交到Kaggle线上平台,平台会结合房屋的实际价格来评判我们的模型,到那时我们才能知道,模型对于房价的预测到底有多准(或是有多不准)。
|
||||
|
||||
要完成对test.csv的推断,我们需要把原始数据转换为特征向量,也就是把“粗粮”转化为“细粮”,然后才能把它“喂给”模型。
|
||||
|
||||
在之前的代码实现中,要做到这一点,我们必须把之前加持到train.csv的所有转换逻辑都重写一遍,比如StringIndexer、VectorAssembler和VectorIndexer。毫无疑问,这样的开发方式是极其低效的,更糟的是,手工重写很容易会造成测试样本与训练样本不一致,而这样的不一致是机器学习应用中的大忌。
|
||||
|
||||
不过,有了Pipeline,我们就可以省去这些麻烦。首先,我们把test.csv加载进来并创建DataFrame,然后把数值字段从String转为Int。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
val rootPath: String = _
|
||||
val filePath: String = s"${rootPath}/test.csv"
|
||||
|
||||
// 加载test.csv,并创建DataFrame
|
||||
var testData: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
|
||||
|
||||
// 所有数值型字段
|
||||
val numericFields: Array[String] = Array("LotFrontage", "LotArea", "MasVnrArea", "BsmtFinSF1", "BsmtFinSF2", "BsmtUnfSF", "TotalBsmtSF", "1stFlrSF", "2ndFlrSF", "LowQualFinSF", "GrLivArea", "BsmtFullBath", "BsmtHalfBath", "FullBath", "HalfBath", "BedroomAbvGr", "KitchenAbvGr", "TotRmsAbvGrd", "Fireplaces", "GarageCars", "GarageArea", "WoodDeckSF", "OpenPorchSF", "EnclosedPorch", "3SsnPorch", "ScreenPorch", "PoolArea")
|
||||
|
||||
// 所有非数值型字段
|
||||
val categoricalFields: Array[String] = Array("MSSubClass", "MSZoning", "Street", "Alley", "LotShape", "LandContour", "Utilities", "LotConfig", "LandSlope", "Neighborhood", "Condition1", "Condition2", "BldgType", "HouseStyle", "OverallQual", "OverallCond", "YearBuilt", "YearRemodAdd", "RoofStyle", "RoofMatl", "Exterior1st", "Exterior2nd", "MasVnrType", "ExterQual", "ExterCond", "Foundation", "BsmtQual", "BsmtCond", "BsmtExposure", "BsmtFinType1", "BsmtFinType2", "Heating", "HeatingQC", "CentralAir", "Electrical", "KitchenQual", "Functional", "FireplaceQu", "GarageType", "GarageYrBlt", "GarageFinish", "GarageQual", "GarageCond", "PavedDrive", "PoolQC", "Fence", "MiscFeature", "MiscVal", "MoSold", "YrSold", "SaleType", "SaleCondition")
|
||||
|
||||
import org.apache.spark.sql.types.IntegerType
|
||||
|
||||
// 注意,test.csv没有SalePrice字段,也即没有Label
|
||||
for (field <- (numericFields)) {
|
||||
testData = testData
|
||||
.withColumn(s"${field}Int",col(field).cast(IntegerType))
|
||||
.drop(field)
|
||||
}
|
||||
|
||||
|
||||
接下来,我们只需要调用Pipeline Model的transform方法,就可以对测试集做推理。还记得吗?模型是Transformer,而transform是Transformer用于数据转换的统一接口。
|
||||
|
||||
val predictions = pipelineModel.transform(testData)
|
||||
|
||||
|
||||
有了Pipeline,我们就可以省去StringIndexer、VectorAssembler这些特征处理函数的重复定义,在提升开发效率的同时,消除样本不一致的隐患。除了在同一个作业内部复用Pipeline之外,我们还可以在不同的作业之间对其进行复用,从而进一步提升开发效率。
|
||||
|
||||
作业间的代码复用
|
||||
|
||||
对于同一个机器学习问题,我们往往会尝试不同的模型算法,以期获得更好的模型效果。例如,对于房价预测,我们既可以用GBDT,也可以用随机森林。不过,尽管模型算法不同,但是它们的训练样本往往是类似的,甚至是完全一样的。如果每尝试一种模型算法,就需要从头处理一遍数据,这未免过于低效,也容易出错。
|
||||
|
||||
有了Pipeline,我们就可以把算法选型这件事变得异常简单。还是拿房价预测来举例,之前我们尝试使用GBTRegressor来训练模型,这一次,咱们来试试RandomForestRegressor,也即使用随机森林来解决回归问题。按照惯例,我们还是结合代码来进行讲解。
|
||||
|
||||
import org.apache.spark.ml.Pipeline
|
||||
|
||||
val savePath: String = _
|
||||
|
||||
// 加载之前保存到磁盘的Pipeline
|
||||
val unfitPipeline = Pipeline.load(s"${savePath}/unfit-gbdt-pipeline")
|
||||
|
||||
// 获取Pipeline中的每一个Stage(Transformer或Estimator)
|
||||
val formerStages = unfitPipeline.getStages
|
||||
|
||||
// 去掉Pipeline中最后一个组件,也即Estimator:GBTRegressor
|
||||
val formerStagesWithoutModel = formerStages.dropRight(1)
|
||||
|
||||
import org.apache.spark.ml.regression.RandomForestRegressor
|
||||
|
||||
// 定义新的Estimator:RandomForestRegressor
|
||||
val rf = new RandomForestRegressor()
|
||||
.setLabelCol("SalePriceInt")
|
||||
.setFeaturesCol("indexedFeatures")
|
||||
.setNumTrees(30)
|
||||
.setMaxDepth(5)
|
||||
|
||||
// 将老的Stages与新的Estimator拼接在一起
|
||||
val stages = formerStagesWithoutModel ++ Array(rf)
|
||||
|
||||
// 重新定义新的Pipeline
|
||||
val newPipeline = new Pipeline()
|
||||
.setStages(stages)
|
||||
|
||||
|
||||
首先,我们把之前保存下来的Pipeline,重新加载进来。然后,用新的RandomForestRegressor替换原来的GBTRegressor。最后,再把原有的Stages和新的Estimator拼接在一起,去创建新的Pipeline即可。接下来,只要调用fit方法,就可以触发新Pipeline的运转,并最终拟合出新的随机森林模型。
|
||||
|
||||
// 像之前一样,从train.csv创建DataFrame,准备数据
|
||||
var engineeringDF = _
|
||||
|
||||
val Array(trainingData, testData) = engineeringDF.randomSplit(Array(0.7, 0.3))
|
||||
|
||||
// 调用fit方法,触发Pipeline运转,拟合新模型
|
||||
val pipelineModel = newPipeline.fit(trainingData)
|
||||
|
||||
|
||||
可以看到,短短的几行代码,就可以让我们轻松地完成模型选型。到此,Pipeline在开发效率与容错上的优势,可谓一览无余。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天的内容就讲完啦,今天这一讲,我们一起学习了Spark MLlib Pipeline。你需要理解Pipeline的优势所在,并掌握它的核心组件与具体用法。Pipeline的核心组件是Transformer与Estimator。
|
||||
|
||||
其中,Transformer完成从DataFrame到DataFrame的转换,基于固有的转换逻辑,生成新的数据列。Estimator主要是模型算法,它基于DataFrame中封装的训练样本,去生成机器学习模型。将若干Transformer与Estimator拼接在一起,通过调用Pipeline的setStages方法,即可完成Pipeline的创建。
|
||||
|
||||
Pipeline的核心优势在于提升机器学习应用的开发效率,并同时消除测试样本与训练样本之间不一致这一致命隐患。Pipeline可用于作业内的代码复用,或是作业间的代码复用。
|
||||
|
||||
在同一作业内,Pipeline能够轻松地在测试集之上,完成数据推断。而在作业之间,开发者可以加载之前保存好的Pipeline,然后用“新零件”替换“旧零件”的方式,在复用大部分处理逻辑的同时,去打造新的Pipeline,从而实现高效的模型选型过程。
|
||||
|
||||
在今后的机器学习开发中,我们要充分利用Pipeline提供的优势,来降低开发成本,从而把主要精力放在特征工程与模型调优上。
|
||||
|
||||
到此为止,Spark MLlib模块的全部内容,我们就讲完了。
|
||||
|
||||
在这个模块中,我们主要围绕着特征工程、模型训练和机器学习流水线等几个方面,梳理了Spark MLlib子框架为开发者提供的种种能力。换句话说,我们知道了Spark MLlib能做哪些事情、擅长做哪些事情。如果我们能够做到对这些能力了如指掌,在日常的机器学习开发中,就可以灵活地对其进行取舍,从而去应对不断变化的业务需求,加油!
|
||||
|
||||
每日一练
|
||||
|
||||
我们今天一直在讲Pipeline的优势,你能说一说,Pipeline有哪些可能的劣势吗?
|
||||
|
||||
欢迎你在留言区和我交流互动,也推荐你把这一讲分享给更多同事、朋友,说不定就能让他进一步理解Pipeline。
|
||||
|
||||
|
||||
|
||||
|
226
专栏/零基础入门Spark/30StructuredStreaming:从“流动的WordCount”开始.md
Normal file
226
专栏/零基础入门Spark/30StructuredStreaming:从“流动的WordCount”开始.md
Normal file
@ -0,0 +1,226 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
30 Structured Streaming:从“流动的Word Count”开始
|
||||
你好,我是吴磊。
|
||||
|
||||
从今天这一讲开始,我们将进入流计算的学习模块。与以往任何时候都不同,今天的大数据处理,对于延迟性的要求越来越高,因此流处理的基本概念与工作原理,是每一个大数据从业者必备的“技能点”。
|
||||
|
||||
在这个模块中,按照惯例,我们还是从一个可以迅速上手的实例开始,带你初步认识Spark的流处理框架Structured Streaming。然后,我们再从框架所提供的能力、特性出发,深入介绍Structured Streaming工作原理、最佳实践以及开发注意事项,等等。
|
||||
|
||||
在专栏的第一个模块,我们一直围绕着Word Count在打转,也就是通过从文件读取内容,然后以批处理的形式,来学习各式各样的数据处理技巧。而今天这一讲我们换个花样,从一个“流动的Word Count”入手,去学习一下在流计算的框架下,Word Count是怎么做的。
|
||||
|
||||
环境准备
|
||||
|
||||
要上手今天的实例,你只需要拥有Spark本地环境即可,并不需要分布式的物理集群。
|
||||
|
||||
不过,咱们需要以“流”的形式,为Spark提供输入数据,因此,要完成今天的实验,我们需要开启两个命令行终端。一个用于启动spark-shell,另一个用于开启Socket端口并输入数据,如下图所示。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
流动的Word Count
|
||||
|
||||
环境准备好之后,接下来,我们具体来说一说,什么是“流动的Word Count”。
|
||||
|
||||
所谓没有对比就没有鉴别,为了说清楚“流动的Word Count”,咱们不妨拿批处理版本的Word Count作对比。在之前的Word Count中,数据以文件(wikiOfSpark.txt)的形式,一次性地“喂给”Spark,从而触发一次Job计算。而在“流动的Word Count”里,数据以行为粒度,分批地“喂给”Spark,每一行数据,都会触发一次Job计算。
|
||||
|
||||
具体来说,我们使用netcat工具,向本地9999端口的Socket地址发送数据行。而Spark流处理应用,则时刻监听着本机的9999端口,一旦接收到数据条目,就会立即触发计算逻辑的执行。当然,在我们的示例中,这里的计算逻辑,就是Word Count。计算执行完毕之后,流处理应用再把结果打印到终端(Console)上。
|
||||
|
||||
与批处理不同,只要我们不人为地中断流处理应用,理论上它可以一直运行到永远。以“流动的Word Count”为例,只要我们不强制中断它,它就可以一直监听9999端口,接收来自那里的数据,并以实时的方式处理它。
|
||||
|
||||
好啦,弄清楚我们要做的事情之后,接下来,我们一起来一步一步地实现它。
|
||||
|
||||
首先第一步,我们在第二个用来输入数据的终端敲入命令“nc -lk 9999”,也就是使用netcat工具,开启本机9999端口的Socket地址。一般来说,大多数操作系统都预装了netcat工具,因此,不论你使用什么操作系统,应该都可以成功执行上述命令。
|
||||
|
||||
|
||||
|
||||
命令敲击完毕之后,光标会在屏幕上一直闪烁,这表明操作系统在等待我们向Socket地址发送数据。我们暂且把它搁置在这里,等一会流处理应用实现完成之后,再来处理它。
|
||||
|
||||
接下来第二步,我们从第一个终端进入spark-shell本地环境,然后开始开发流处理应用。首先,我们先导入DataFrame,并指定应用所需监听的主机与端口号。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
// 设置需要监听的本机地址与端口号
|
||||
val host: String = "127.0.0.1"
|
||||
val port: String = "9999"
|
||||
|
||||
|
||||
数据加载
|
||||
|
||||
然后是数据加载环节,我们通过SparkSession的readStream API来创建DataFrame。
|
||||
|
||||
// 从监听地址创建DataFrame
|
||||
var df: DataFrame = spark.readStream
|
||||
.format("socket")
|
||||
.option("host", host)
|
||||
.option("port", port)
|
||||
.load()
|
||||
|
||||
|
||||
仔细观察上面的代码,你有没有觉得特别眼熟呢?没错,readStream API,与SparkSession的read API看上去几乎一模一样。
|
||||
|
||||
|
||||
|
||||
可以看到,与read API类似,readStream API也由3类最基本的要素构成,也就是:
|
||||
|
||||
|
||||
format:指定流处理的数据源头类型
|
||||
option:与数据源头有关的若干选项
|
||||
load:将数据流加载进Spark
|
||||
|
||||
|
||||
流计算场景中,有3个重要的基础概念,需要我们重点掌握。它们依次是Source、流处理引擎与Sink。其中,Source是流计算的数据源头,也就是源源不断地产生数据的地方。与之对应,Sink指的是数据流向的目的地,也就是数据要去向的地方,后面我们讲到writeSteam API的时候,再去展开。
|
||||
|
||||
|
||||
|
||||
而流处理引擎是整个模块的学习重点,后续我们还会深入讨论。它的作用显而易见:在数据流动过程中实现数据处理,保证数据完整性与一致性。这里的数据处理,包括我们Spark SQL模块讲过的各种操作类型,比如过滤、投影、分组、聚合、排序,等等。
|
||||
|
||||
现在,让我们先把注意力放到readStream API与Source上来。通过readStream API的format函数,我们可以指定不同类型的数据源头。在Structured Streaming框架下,Spark主要支持3类数据源,分别是Socket、File和Kafka。
|
||||
|
||||
其中,Socket类型主要用于开发试验或是测试应用的连通性,这也是这一讲中我们采用Socket作为数据源的原因。File指的是文件系统,Spark可以通过监听文件夹,把流入文件夹的文件当作数据流来对待。而在实际的工业级应用中,Kafka + Spark的组合最为常见,因此在本模块的最后,我们会单独开辟一篇,专门讲解Kafka与Spark集成的最佳实践。
|
||||
|
||||
通过format指定完数据源之后,还需要使用零到多个option,来指定数据源的具体地址、访问权限等信息。以咱们代码中的Socket为例,我们需要明确主机地址与端口地址。
|
||||
|
||||
// 从监听地址创建DataFrame
|
||||
var df: DataFrame = spark.readStream
|
||||
.format("socket")
|
||||
.option("host", host)
|
||||
.option("port", port)
|
||||
.load()
|
||||
|
||||
|
||||
一切准备就绪之后,我们就可以通过load,来创建DataFrame,从而把数据流源源不断地加载进Spark系统。
|
||||
|
||||
数据处理
|
||||
|
||||
有了DataFrame在手,我们就可以使用之前学习过的各类DataFrame算子,去实现Word Count的计算逻辑。这一步比较简单,你不妨先自己动手试试,然后再接着往下看。
|
||||
|
||||
/**
|
||||
使用DataFrame API完成Word Count计算
|
||||
*/
|
||||
|
||||
// 首先把接收到的字符串,以空格为分隔符做拆分,得到单词数组words
|
||||
df = df.withColumn("words", split($"value", " "))
|
||||
|
||||
// 把数组words展平为单词word
|
||||
.withColumn("word", explode($"words"))
|
||||
|
||||
// 以单词word为Key做分组
|
||||
.groupBy("word")
|
||||
|
||||
// 分组计数
|
||||
.count()
|
||||
|
||||
|
||||
首先,需要说明的是,我们从Socket创建的DataFrame,默认只有一个“value”列,它以行为粒度,存储着从Socket接收到数据流。比方说,我们在第二个终端(也就是netcat界面),敲入两行数据,分别是“Apache Spark”和“Spark Logo”。那么在“value”列中,就会有两行数据与之对应,同样是“Apache Spark”和“Spark Logo”。
|
||||
|
||||
对于“value”列,我们先是用空格把它拆分为数组words,然后再用explode把words展平为单词word,接下来就是对单词word做分组计数。这部分处理逻辑比较简单,你很容易就可以上手,鼓励你尝试其他不同的算子,来实现同样的逻辑。
|
||||
|
||||
数据输出
|
||||
|
||||
数据处理完毕之后,与readStream API相对应,我们可以调用writeStream API来把处理结果写入到Sink中。在Structured Streaming框架下,Spark支持多种Sink类型,其中有Console、File、Kafka和Foreach(Batch)。对于这几种Sink的差异与特点,我们留到[下一讲]再去展开。
|
||||
|
||||
|
||||
|
||||
这里我们先来说说Console,Console就是我们常说的终端,选择Console作为Sink,Spark会把结果打印到终端。因此,Console往往与Socket配合,用于开发实验与测试连通性,代码实现如下所示。
|
||||
|
||||
/**
|
||||
将Word Count结果写入到终端(Console)
|
||||
*/
|
||||
|
||||
df.writeStream
|
||||
// 指定Sink为终端(Console)
|
||||
.format("console")
|
||||
|
||||
// 指定输出选项
|
||||
.option("truncate", false)
|
||||
|
||||
// 指定输出模式
|
||||
.outputMode("complete")
|
||||
//.outputMode("update")
|
||||
|
||||
// 启动流处理应用
|
||||
.start()
|
||||
// 等待中断指令
|
||||
.awaitTermination()
|
||||
|
||||
|
||||
可以看到,writeStream API看上去与DataFrame的write API也是极为神似。
|
||||
|
||||
|
||||
|
||||
其中,format用于指定Sink类型,option则用于指定与Sink类型相关的输出选项,比如与Console相对应的“truncate”选项,用来表明输出内容是否需要截断。在write API中,我们最终通过调用save把数据保持到指定路径,而在writeStream API里,我们通过start来启动端到端的流计算。
|
||||
|
||||
所谓端到端的流计算,它指的就是我们在“流动的Word Count”应用中实现的3个计算环节,也即从数据源不断地加载数据流,以Word Count的计算逻辑处理数据,并最终把计算结果打印到Console。
|
||||
|
||||
整个计算过程持续不断,即便netcat端没有任何输入,“流动的Word Count”应用也会一直运行,直到我们强制应用退出为止。而这,正是函数awaitTermination的作用,顾名思义,它的目的就是在“等待用户中断”。
|
||||
|
||||
对于writeStream API与write API的不同,除了刚刚说的start和awaitTermination以外,细心的你想必早已发现,writeStream API多了一个outputMode函数,它用来指定数据流的输出模式。
|
||||
|
||||
想要理解这个函数,就要清楚数据流的输出模式都有哪些。我们先来说一说Structured Streaming都支持哪些输出模式,然后再用“流动的Word Count”的执行结果,来直观地进行对比说明。
|
||||
|
||||
一般来说,Structured Streaming支持3种Sink输出模式,也就是:
|
||||
|
||||
|
||||
Complete mode:输出到目前为止处理过的全部内容
|
||||
Append mode:仅输出最近一次作业的计算结果
|
||||
Update mode:仅输出内容有更新的计算结果
|
||||
|
||||
|
||||
当然,这3种模式并不是在任何场景下都适用。比方说,在我们“流动的Word Count”示例中,Append mode就不适用。原因在于,对于有聚合逻辑的流处理来说,开发者必须要提供Watermark,才能使用Append mode。
|
||||
|
||||
后面第32讲我们还会继续学习Watermark和Sink的三种输出模式,这里你有个大致印象就好。
|
||||
|
||||
执行结果
|
||||
|
||||
到目前为止,“流动的Word Count”应用代码已全部开发完毕,接下来,我们先让它跑起来,感受一下流计算的魅力。然后,我们再通过将outputMode中的“complete”替换为“update”,直观对比一下它们的特点和区别。
|
||||
|
||||
要运行“流动的Word Count”,首先第一步,我们把刚刚实现的所有代码,依次敲入第一个终端的spark-shell。全部录入之后,等待一会,你应该会看到如下的画面:
|
||||
|
||||
|
||||
|
||||
当出现“Batch: 0”字样后,这表明我们的流处理应用已经成功运行,并在9999端口等待数据流的录入。接下来,我们切换到第二个终端,也就是开启netcat的终端界面,然后,依次逐行(注意!依次逐行!)输入下面的文本内容,每行数据录入之间,请间隔3~5秒。
|
||||
|
||||
|
||||
|
||||
然后,我们再把屏幕切换到spark-shell终端,你会看到Spark跑了4批作业,执行结果分别如下。
|
||||
|
||||
|
||||
|
||||
可以看到,在Complete mode下,每一批次的计算结果,都会包含系统到目前为止处理的全部数据内容。你可以通过对比每个批次与前面批次的差异,来验证这一点。
|
||||
|
||||
接下来,我们在spark-shell终端,输入强制中断命令(ctrl + D或ctrl + C),退出spark-shell。然后再次在终端敲入“spark-shell”命令,再次进入spark-shell本地环境,并再次录入“流动的Word Count”代码。不过,这一次,在代码的最后,我们把writeStream中的outputMode,由原来的“complete”改为“update”。
|
||||
|
||||
代码录入完毕之后,我们再切回到netcat终端,并重新录入刚刚的4条数据,然后观察第一个终端spark-shell界面的执行结果。
|
||||
|
||||
|
||||
|
||||
对比之下一目了然,可以看到在Update mode下,每个批次仅输出内容有变化的数据记录。所谓有变化,也就是,要么单词是第一次在本批次录入,计数为1,要么单词是重复录入,计数有所变化。你可以通过观察不同批次的输出,以及对比Update与Complete不同模式下的输出结果,来验证这一点。
|
||||
|
||||
好啦,到目前为止,我们一起开发了一个流处理小应用:“流动的Word Count”,并一起查看了它在不同输出模式下的计算结果。恭喜你!学到这里,可以说,你的一只脚已经跨入了Spark流计算的大门。后面还有很多精彩的内容,有待我们一起去发掘,让我们一起加油!
|
||||
|
||||
重点回顾
|
||||
|
||||
今天这一讲,你需要掌握如下几点。首先,你需要熟悉流计算场景中3个重要的基本概念,也就是Source、流处理引擎和Sink,如下图所示。
|
||||
|
||||
|
||||
|
||||
再者,对于Source与Sink,你需要知道,在Structured Streaming框架下,Spark都能提供哪些具体的支持。以Source为例,Spark支持Socket、File和Kafka,而对于Sink,Spark支持Console、File、Kafka和Foreach(Batch)。
|
||||
|
||||
之后我们结合一个流处理小应用,借此熟悉了在Structured Streaming框架下,流处理应用开发的一般流程。一般来说,我们通过readStream API从不同类型的Source读取数据流、并创建DataFrame,然后使用DataFrame算子处理数据,如数据的过滤、投影、分组、聚合等,最终通过writeStream API将处理结果,写入到不同形式的Sink中去。
|
||||
|
||||
最后,对于结果的输出,我们需要了解,在不同的场景下,Structured Streaming支持不同的输出模式。输出模式主要有3种,分别是Complete mode、Append mode和Update mode。其中,Complete mode输出到目前为止处理过的所有数据,而Update mode仅输出在当前批次有所更新的数据内容。
|
||||
|
||||
每课一练
|
||||
|
||||
在运行“流动的Word Count”的时候,我们强调依次逐行输入数据内容,请你把示例给出的4行数据,一次性地输入netcat(拷贝&粘贴),然后观察Structured Streaming给出的结果,与之前相比,有什么不同?
|
||||
|
||||
欢迎你在留言区跟我交流互动,也推荐你把今天的内容分享给更多同事、朋友,一起动手搭建这个Word Count流计算应用。
|
||||
|
||||
|
||||
|
||||
|
171
专栏/零基础入门Spark/31新一代流处理框架:Batchmode和Continuousmode哪家强?.md
Normal file
171
专栏/零基础入门Spark/31新一代流处理框架:Batchmode和Continuousmode哪家强?.md
Normal file
@ -0,0 +1,171 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
31 新一代流处理框架:Batch mode和Continuous mode哪家强?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们通过“流动的Word Count”示例,初步结识了Structured Streaming,并学习了流处理开发三要素,也就是Source、流处理引擎与Sink。
|
||||
|
||||
|
||||
|
||||
今天这一讲,让我们把目光集中到Structured Streaming,也就是流处理引擎本身。Structured Streaming与Spark MLlib并列,是Spark重要的子框架之一。值得一提的是,Structured Streaming天然能够享受Spark SQL提供的处理能力与执行性能,同时也能与其他子框架无缝衔接。因此,基于Structured Streaming这个新一代框架开发的流处理应用,天然具备优良的执行性能与良好的扩展性。
|
||||
|
||||
知己知彼,百战百胜。想要灵活应对不同的实时计算需求,我们就要先了解Structured Streaming的计算模型长啥样,搞清楚它如何应对容错、保持数据一致性。我们先从计算模型说起。
|
||||
|
||||
计算模型
|
||||
|
||||
当数据像水流一样,源源不断地流进Structured Streaming引擎的时候,引擎并不会自动地依次消费并处理这些数据,它需要一种叫做Trigger的机制,来触发数据在引擎中的计算。
|
||||
|
||||
换句话说,Trigger机制,决定了引擎在什么时候、以怎样的方式和频率去处理接收到的数据流。Structured Streaming支持4种Trigger,如下表所示。
|
||||
|
||||
|
||||
|
||||
要为流处理设置Trigger,我们只需基于writeStream API,调用trigger函数即可。Trigger的种类比较多,一下子深入细节,容易让你难以把握重点,所以现在你只需要知道Structured Streaming支持种类繁多的Trigger即可。
|
||||
|
||||
我们先把注意力,放在计算模型上面。对于流数据,Structured Streaming支持两种计算模型,分别是Batch mode和Continuous mode。所谓计算模型,本质上,它要解决的问题,就是Spark以怎样的方式,来对待并处理流数据。
|
||||
|
||||
这是什么意思呢?没有对比就没有鉴别,咱们不妨通过对比讲解Batch mode和Continuous mode,来深入理解计算模型的含义。
|
||||
|
||||
Batch mode
|
||||
|
||||
我们先来说说Batch mode,所谓Batch mode,它指的是Spark将连续的数据流,切割为离散的数据微批(Micro-batch),也即小份的数据集。
|
||||
|
||||
形象一点说,Batch mode就像是“抽刀断水”,两刀之间的水量,就是一个Micro-batch。而每一份Micro-batch,都会触发一个Spark Job,每一个Job会包含若干个Tasks。学习过基础知识与Spark SQL模块之后,我们知道,这些Tasks最终会交由Spark SQL与Spark Core去做优化与执行。
|
||||
|
||||
|
||||
|
||||
在这样的计算模型下,不同种类的Trigger,如Default、Fixed interval以及One-time,无非是在以不同的方式控制Micro-batch切割的粒度罢了。
|
||||
|
||||
比方说,在Default Trigger下,Spark会根据数据流的流入速率,自行决定切割粒度,无需开发者关心。而如果开发者想要对切割粒度进行人为的干预,则可以使用Fixed interval Trigger,来明确定义Micro-batch切割的时间周期。例如,Trigger.ProcessingTime(“5 seconds”),表示的是,每隔5秒钟,切割一个Micro-batch。
|
||||
|
||||
Continuous mode
|
||||
|
||||
与Batch mode不同,Continuous mode并不切割数据流,而是以事件/消息(Event / Message)为粒度,用连续的方式来处理数据。这里的事件或是消息,指代的是原始数据流中最细粒度的数据形式,它可以是一个单词、一行文本,或是一个画面帧。
|
||||
|
||||
以“流动的Word Count”为例,Source中的事件/消息就是一个个英文单词。说到这里,你可能会有疑问:“在Batch mode下,Structured Streaming不也是连续地创建Micro-batch吗?数据同样是不丢不漏,Continuous mode与Batch mode有什么本质上的区别吗?”
|
||||
|
||||
|
||||
|
||||
一图胜千言,对比两种计算模型的示意图,我们可以轻松地发现它们之间的差异所在。在Continuous mode下,Structured Streaming使用一个常驻作业(Long running job)来处理数据流(或者说服务)中的每一条消息。
|
||||
|
||||
那么问题来了,相比每个Micro-batch触发一个作业,Continuous mode选择采用常驻作业来进行服务,有什么特别的收益吗?或者换句话说,这两种不同的计算模型,各自都有哪些优劣势呢?
|
||||
|
||||
用一句话来概括,Batch mode吞吐量大、延迟高(秒级),而Continuous mode吞吐量低、延迟也更低(毫秒级)。吞吐量指的是单位时间引擎处理的消息数量,批量数据能够更好地利用Spark分布式计算引擎的优势,因此Batch mode在吞吐量自然更胜一筹。
|
||||
|
||||
而要回答为什么Continuous mode能够在延迟方面表现得更加出色,我们还得从Structured Streaming的容错机制说起。
|
||||
|
||||
容错机制
|
||||
|
||||
对于任何一个流处理引擎来说,容错都是一项必备的能力。所谓容错,它指的是,在计算过程中出现错误(作业层面、或是任务层面,等等)的时候,流处理引擎有能力恢复被中断的计算过程,同时保证数据上的不重不漏,也即保证数据处理的一致性。
|
||||
|
||||
从数据一致性的角度出发,这种容错的能力,可以划分为3种水平:
|
||||
|
||||
|
||||
At most once:最多交付一次,数据存在丢失的风险;
|
||||
At least once:最少交付一次,数据存在重复的可能;
|
||||
Exactly once:交付且仅交付一次,数据不重不漏。
|
||||
|
||||
|
||||
|
||||
|
||||
这里的交付,指的是数据从Source到Sink的整个过程。对于同一条数据,它可能会被引擎处理一次或(在有作业或是任务失败的情况下)多次,但根据容错能力的不同,计算结果最终可能会交付给Sink零次、一次或是多次。
|
||||
|
||||
聊完基本的容错概念之后,我们再说回Structured Streaming。就Structured Streaming的容错能力来说,Spark社区官方的说法是:“结合幂等的Sink,Structured Streaming能够提供Exactly once的容错能力”。
|
||||
|
||||
实际上,这句话应该拆解为两部分。在数据处理上,结合容错机制,Structured Streaming本身能够提供“At least once”的处理能力。而结合幂等的Sink,Structured Streaming可以实现端到端的“Exactly once”容错水平。
|
||||
|
||||
比方说,应用广泛的Kafka,在Producer级别提供跨会话、跨分区的幂等性。结合Kafka这样的Sink,在端到端的处理过程中,Structured Streaming可以实现“Exactly once”,保证数据的不重不漏。
|
||||
|
||||
不过,在 Structured Streaming 自身的容错机制中,为了在数据处理上做到“At least once”,Batch mode 与 Continuous mode 这两种不同的计算模型,分别采用了不同的实现方式。而容错实现的不同,正是导致两种计算模型在延迟方面差异巨大的重要因素之一。
|
||||
|
||||
接下来,我们就来说一说,Batch mode 与 Continuous mode 分别如何做容错。
|
||||
|
||||
Batch mode容错
|
||||
|
||||
在Batch mode下,Structured Streaming利用Checkpoint机制来实现容错。在实际处理数据流中的Micro-batch之前,Checkpoint机制会把该Micro-batch的元信息全部存储到开发者指定的文件系统路径,比如HDFS或是Amazon S3。这样一来,当出现作业或是任务失败时,引擎只需要读取这些事先记录好的元信息,就可以恢复数据流的“断点续传”。
|
||||
|
||||
要指定Checkpoint目录,只需要在writeStream API的option选项中配置checkpointLocation即可。我们以上一讲的“流动的Word Count”为例,代码只需要做如下修改即可。
|
||||
|
||||
df.writeStream
|
||||
// 指定Sink为终端(Console)
|
||||
.format("console")
|
||||
|
||||
// 指定输出选项
|
||||
.option("truncate", false)
|
||||
|
||||
// 指定Checkpoint存储地址
|
||||
.option("checkpointLocation", "path/to/HDFS")
|
||||
|
||||
// 指定输出模式
|
||||
.outputMode("complete")
|
||||
//.outputMode("update")
|
||||
|
||||
// 启动流处理应用
|
||||
.start()
|
||||
// 等待中断指令
|
||||
.awaitTermination()
|
||||
|
||||
|
||||
在Checkpoint存储目录下,有几个子目录,分别是offsets、sources、commits和state,它们所存储的内容,就是各个Micro-batch的元信息日志。对于不同子目录所记录的实际内容,我把它们整理到了下面的图解中,供你随时参考。
|
||||
|
||||
-
|
||||
对于每一个Micro-batch来说,在它被Structured Streaming引擎实际处理之前,Checkpoint机制会先把它的元信息记录到日志文件,因此,这些日志文件又被称为Write Ahead Log(WAL日志)。
|
||||
|
||||
换句话说,当源数据流进Source之后,它需要先到Checkpoint目录下进行“报道”,然后才会被Structured Streaming引擎处理。毫无疑问,“报道”这一步耽搁了端到端的处理延迟,如下图所示。
|
||||
|
||||
|
||||
|
||||
除此之外,由于每个Micro-batch都会触发一个Spark作业,我们知道,作业与任务的频繁调度会引入计算开销,因此也会带来不同程度的延迟。在运行模式与容错机制的双重加持下,Batch mode的延迟水平往往维持在秒这个量级,在最好的情况下能达到几百毫秒左右。
|
||||
|
||||
Continuous mode容错
|
||||
|
||||
相比Batch mode,Continuous mode下的容错没那么复杂。在Continuous mode下,Structured Streaming利用Epoch Marker机制,来实现容错。
|
||||
|
||||
因为Continuous mode天然没有微批,所以不会涉及到微批中的延迟,到达Source中的消息可以立即被Structured Streaming引擎消费并处理。但这同时也带来一个问题,那就是引擎如何把当前的处理进度做持久化,从而为失败重试提供可能。
|
||||
|
||||
为了解决这个问题,Spark引入了Epoch Marker机制。所谓Epoch Marker,你可以把它理解成是水流中的“游标”,这些“游标”随着水流一起流动。每个游标都是一个Epoch Marker,而游标与游标之间的水量,就是一个Epoch,开发者可以通过如下语句来指定Epoch间隔。
|
||||
|
||||
writeStream.trigger(continuous = "1 second")
|
||||
|
||||
|
||||
以表格中的代码为例,对于Source中的数据流,Structured Streaming每隔1秒,就会安插一个Epoch Marker,而两个Epoch Marker之间的数据,就称为一个Epoch。你可能会问:“Epoch Marker的概念倒是不难理解,不过它有什么用呢?”
|
||||
|
||||
在引擎处理并交付数据的过程中,每当遇到Epoch Marker的时候,引擎都会把对应Epoch中最后一条消息的Offset写入日志,从而实现容错。需要指出的是,日志的写入是异步的,因此这个过程不会对数据的处理造成延迟。
|
||||
|
||||
有意思的是,对于这个日志的称呼,网上往往也把它叫作Write Ahead Log。不过我觉得这么叫可能不太妥当,原因在于,准备写入日志的消息,都已经被引擎消费并处理过了。Batch mode会先写日志、后处理数据,而Continuous mode不一样,它是先处理数据、然后再写日志。所以,把Continuous mode的日志称作是“Write After Log”,也许更合适一些。
|
||||
|
||||
我们还是用对比的方法来加深理解,接下来,我们同样通过消息到达Source与Structured Streaming引擎的时间线,来示意Continuous mode下的处理延迟。
|
||||
|
||||
|
||||
|
||||
可以看到,消息从Source产生之后,可以立即被Structured Streaming引擎消费并处理,因而在延迟性方面,能够得到更好的保障。而Epoch Marker则会帮助引擎识别当前最新处理的消息,从而把相应的Offset记录到日志中,以备失败重试。
|
||||
|
||||
重点回顾
|
||||
|
||||
到此为止,今天的内容就全部讲完了,我们一起来做个总结。
|
||||
|
||||
今天这一讲,我们学习了Structured Streaming中两种不同的计算模型——Batch mode与Continuous mode。只有了解了它们各自在吞吐量、延迟性和容错等方面的特点,在面对日常工作中不同的流计算场景时,我们才能更好地做出选择。
|
||||
|
||||
在Batch mode下,Structured Streaming会将数据流切割为一个个的Micro-batch。对于每一个Micro-batch,引擎都会创建一个与之对应的作业,并将作业交付给Spark SQL与Spark Core付诸优化与执行。
|
||||
|
||||
Batch mode的特点是吞吐量大,但是端到端的延迟也比较高,延迟往往维持在秒的量级。Batch mode的高延迟,一方面来自作业调度本身,一方面来自它的容错机制,也就是Checkpoint机制需要预写WAL(Write Ahead Log)日志。
|
||||
|
||||
要想获得更低的处理延迟,你可以采用Structured Streaming的Continuous mode计算模型。在Continuous mode下,引擎会创建一个Long running job,来负责消费并服务来自Source的所有消息。
|
||||
|
||||
在这种情况下,Continuous mode天然地避开了频繁生成、调度作业而引入的计算开销。与此同时,利用Epoch Marker,通过先处理数据、后记录日志的方式,Continuous mode进一步消除了容错带来的延迟影响。
|
||||
|
||||
尺有所短、寸有所长,Batch mode在吞吐量上更胜一筹,而Continuous mode在延迟性方面则能达到毫秒级。
|
||||
|
||||
不过,需要特别指出的是,到目前为止,在Continuous mode下,Structured Streaming仅支持非聚合(Aggregation)类操作,比如map、filter、flatMap,等等。而聚合类的操作,比如“流动的Word Count”中的分组计数,Continuous mode暂时是不支持的,这一点难免会限制Continuous mode的应用范围,需要你特别注意。
|
||||
|
||||
每课一练
|
||||
|
||||
Batch mode通过预写WAL日志来实现容错,请你脑洞一下,有没有可能参考Continuous mode中先处理数据、后记录日志的方式,把Batch mode中写日志的动作,也挪到数据消费与处理之后呢?
|
||||
|
||||
欢迎你在留言区跟我交流讨论,也推荐你把这一讲的内容分享给更多朋友。
|
||||
|
||||
|
||||
|
||||
|
247
专栏/零基础入门Spark/33流计算中的数据关联:流与流、流与批.md
Normal file
247
专栏/零基础入门Spark/33流计算中的数据关联:流与流、流与批.md
Normal file
@ -0,0 +1,247 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
33 流计算中的数据关联:流与流、流与批
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们提到,Structured Streaming会复用Spark SQL所提供的一切数据处理能力,比如数据抽取、过滤、分组聚合、关联、排序,等等。不过,在这些常规的数据处理类型中,有一类操作需要我们特别关注,它就是数据关联(Joins)。
|
||||
|
||||
这主要是出于两方面的原因,一来,数据关联的应用非常普遍,可以说是数据应用中“出场率”最高的操作类型之一;再者,与批处理中的数据关联不同,流计算中的数据关联,还需要考虑到流处理过程中固有的一些限制,比如说时间窗口、数据延迟容忍度、输出模式,等等。
|
||||
|
||||
因此,今天这一讲,我们专门来说一说Structured Streaming中的数据关联。我们先盘点好Structured Streaming的技能树,看看它都支持哪些种类的数据关联。之后再用一个短视频推荐的例子上手试验一下,总结出不同类型数据关联的适用场景以及注意事项。
|
||||
|
||||
流计算中的数据关联
|
||||
|
||||
我们知道,如果按照关联形式来划分的话,数据关联可以分为Inner Join、Left Join、Right Join、Semi Join、Anti Join,等等。如果按照实现方式来划分的话,可以分为Nested Loop Join、Sort Merge Join和Hash Join。而如果考虑分布式环境下数据分发模式的话,Join又可以分为Shuffle Join和Broadcast Join。
|
||||
|
||||
对于上述的3种分类标准,它们之间是相互正交的,我们在Spark SQL学习模块介绍过它们各自的适用场景与优劣势(记不清的可以回顾第[17]、[18]讲)。
|
||||
|
||||
而在流计算的场景下,按照数据来源的不同,数据关联又可以分为“流批关联”与“双流关联”。所谓“流批关联”(Stream-Static Join),它指的是,参与关联的一张表,来自离线批数据,而另一张表的来源,是实时的数据流。换句话说,动态的实时数据流可以与静态的离线数据关联在一起,为我们提供多角度的数据洞察。
|
||||
|
||||
而“双流关联”(Stream-Stream Join),顾名思义,它的含义是,参与关联的两张表,都来自于不同的数据流,属于动态数据与动态数据之间的关联计算,如下图所示。
|
||||
|
||||
|
||||
|
||||
显然,相对于关联形式、实现方式和分发模式,数据来源的分类标准与前三者也是相互正交的。我们知道,基于前3种分类标准,数据关联已经被划分得足够细致。再加上一种正交的分类标准,数据关联的划分,只会变得更为精细。
|
||||
|
||||
更让人头疼的是,在Structured Streaming流计算框架下,“流批关联”与“双流关联”,对于不同的关联形式,有着不同的支持与限制。而这,也是我们需要特别关注流处理中数据关联的原因之一。
|
||||
|
||||
接下来,我们就分别对“流批关联”和“双流关联”进行展开,说一说它们支持的功能与特性,以及可能存在的限制。本着由简入难的原则,我们先来介绍“流批关联”,然后再去说“双流关联”。
|
||||
|
||||
流批关联
|
||||
|
||||
为了更好地说明流批关联,咱们不妨从一个实际场景入手。在短视频流行的当下,推荐引擎扮演着极其重要的角色,而要想达到最佳的推荐效果,推荐引擎必须依赖用户的实时反馈。
|
||||
|
||||
所谓实时反馈,其实就是我们习以为常的点赞、评论、转发等互动行为,不过,这里需要突出的,是一个“实时性”、或者说“及时性”。毕竟,在选择越来越多的今天,用户的兴趣与偏好,也在随着时间而迁移、变化,捕捉用户最近一段时间的兴趣爱好更加重要。
|
||||
|
||||
假设,现在我们需要把离线的用户属性和实时的用户反馈相关联,从而建立用户特征向量。显然,在这个特征向量中,我们既想包含用户自身的属性字段,如年龄、性别、教育背景、职业,等等,更想包含用户的实时互动信息,比如1小时内的点赞数量、转发数量,等等,从而对用户进行更为全面的刻画。
|
||||
|
||||
一般来说,实时反馈来自线上的数据流,而用户属性这类数据,往往存储在离线数据仓库或是分布式文件系统。因此,用户实时反馈与用户属性信息的关联,正是典型的流批关联场景。
|
||||
|
||||
那么,针对刚刚说的短视频场景,我们该如何把离线用户属性与线上用户反馈“合二为一”呢?为了演示流批关联的过程与用法,咱们自然需要事先把离线数据与线上数据准备好。本着一切从简的原则,让你仅用笔记本电脑就能复现咱们课程中的实例,这里我们使用本地文件系统来存放离线的用户属性。
|
||||
|
||||
而到目前为止,对于数据流的生成,我们仅演示过Socket的用法。实际上,除了用于测试的Socket以外,Structured Streaming还支持Kafka、文件等Source作为数据流的来源。为了尽可能覆盖更多知识点,这一讲咱们不妨通过文件的形式来模拟线上的用户反馈。
|
||||
|
||||
还记得吗?Structured Streaming通过readStream API来创建各式各样的数据流。要以文件的方式创建数据流,我们只需将文件格式传递给format函数,然后启用相应的option即可,如下所示。关于readStream API的一般用法,你可以回顾“流动的Word Count”([第30讲])。
|
||||
|
||||
var streamingDF: DataFrame = spark.readStream
|
||||
.format("csv")
|
||||
.option("header", true)
|
||||
.option("path", s"${rootPath}/interactions")
|
||||
.schema(actionSchema)
|
||||
.load
|
||||
|
||||
|
||||
对于这段代码片段来说,需要你特别注意两个地方。一个是format函数,它的形参是各式各样的文件格式,如CSV、Parquet、ORC,等等。第二个地方,是指定监听地址的option选项,也就是option(“path”, s”${rootPath}/interactions”)。
|
||||
|
||||
该选项指定了Structured Streaming需要监听的文件系统目录,一旦有新的数据内容进入该目录,Structured Streaming便以流的形式,把新数据加载进来。
|
||||
|
||||
需要说明的是,上面的代码并不完整,目的是让你先对文件形式的Source建立初步认识。随着后续讲解的推进,待会我们会给出完整版代码,并详细讲解其中的每一步。
|
||||
|
||||
要用文件的形式模拟数据流的生成,我们只需将包含用户互动行为的文件,依次拷贝到Structured Streaming的监听目录即可,在我们的例子中,也就是interactions目录。
|
||||
|
||||
|
||||
|
||||
如上图的步骤1所示,我们事先把用户反馈文件,保存到临时的staging目录中,然后依次把文件拷贝到interactions目录,即可模拟数据流的生成。而用户属性信息本身就是离线数据,因此,我们把相关数据文件保存到userProfile目录即可,如图中步骤3所示。
|
||||
|
||||
对于上面的流批关联计算过程,在给出代码实现之前,咱们不妨先来了解一下数据,从而更好地理解后续的代码内容。离线的用户属性比较简单,仅包含id、name、age与gender四个字段,文件内容如下所示。
|
||||
|
||||
|
||||
|
||||
线上的用户反馈相对复杂一些,分别包含userId、videoId、event、eventTime等字段。前两个字段分别代表用户ID与短视频ID,而event是互动类型,包括Like(点赞)、Comment(评论)、Forward(转发)三个取值,eventTime则代表产生互动的时间戳,如下所示。
|
||||
|
||||
|
||||
|
||||
除了上面的interactions0.csv以外,为了模拟数据流的生成,我还为你准备了interactions1.csv、interactions2.csv两个文件,它们的Schema与interactions0.csv完全一致,内容也大同小异。对于这3个文件,我们暂时把它们缓存在staging目录下。
|
||||
|
||||
好啦,数据准备好之后,接下来,我们就可以从批数据与流数据中创建DataFrame,并实现两者的关联,达到构建用户特征向量的目的。首先,我们先来加载数据。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
import org.apache.spark.sql.types.StructType
|
||||
|
||||
// 保存staging、interactions、userProfile等文件夹的根目录
|
||||
val rootPath: String = _
|
||||
|
||||
// 使用read API读取离线数据,创建DataFrame
|
||||
val staticDF: DataFrame = spark.read
|
||||
.format("csv")
|
||||
.option("header", true)
|
||||
.load(s"${rootPath}/userProfile/userProfile.csv")
|
||||
|
||||
// 定义用户反馈文件的Schema
|
||||
val actionSchema = new StructType()
|
||||
.add("userId", "integer")
|
||||
.add("videoId", "integer")
|
||||
.add("event", "string")
|
||||
.add("eventTime", "timestamp")
|
||||
|
||||
// 使用readStream API加载数据流,注意对比readStream API与read API的区别与联系
|
||||
var streamingDF: DataFrame = spark.readStream
|
||||
// 指定文件格式
|
||||
.format("csv")
|
||||
.option("header", true)
|
||||
// 指定监听目录
|
||||
.option("path", s"${rootPath}/interactions")
|
||||
// 指定数据Schema
|
||||
.schema(actionSchema)
|
||||
.load
|
||||
|
||||
|
||||
为了方便你把代码与计算流程对应上,这里我再一次把流批关联示意图贴在了下面。上述代码,对应的是下图中的步骤2与步骤3,也就是流数据与批数据的加载。
|
||||
|
||||
|
||||
|
||||
从代码中,我们不难发现,readStream API与read API的用法,几乎如出一辙,不仅如此,二者的返回类型都是DataFrame。因此,流批关联在用法上,与普通的DataFrame之间的关联,看上去并没有什么不同,如下所示。
|
||||
|
||||
// 互动数据分组、聚合,对应流程图中的步骤4
|
||||
streamingDF = streamingDF
|
||||
// 创建Watermark,设置最大容忍度为30分钟
|
||||
.withWatermark("eventTime", "30 minutes")
|
||||
// 按照时间窗口、userId与互动类型event做分组
|
||||
.groupBy(window(col("eventTime"), "1 hours"), col("userId"), col("event"))
|
||||
// 记录不同时间窗口,用户不同类型互动的计数
|
||||
.count
|
||||
|
||||
/**
|
||||
流批关联,对应流程图中的步骤5
|
||||
可以看到,与普通的两个DataFrame之间的关联,看上去没有任何差别
|
||||
*/
|
||||
val jointDF: DataFrame = streamingDF.join(staticDF, streamingDF("userId") === staticDF("id"))
|
||||
|
||||
|
||||
除了在用法上没有区别以外,普通DataFrame数据关联中适用的优化方法,同样适用于流批关联。比方说,对于streamingDF来说,它所触发的每一个Micro-batch,都会扫描一次staticDF所封装的离线数据。
|
||||
|
||||
显然,在执行效率方面,这并不是一种高效的做法。结合Spark SQL模块学到的Broadcast Join的优化方法,我们完全可以在staticDF之上创建广播变量,然后把流批关联原本的Shuffle Join转变为Broadcast Join来提升执行性能。这个优化技巧仅涉及几行代码的修改,因此,我把它留给你作为课后作业去练习。
|
||||
|
||||
完成流批关联之后,我们还需要把计算结果打印到终端,Console是Structured Streaming支持的Sink之一,它可以帮我们确认计算结果与预期是否一致,如下所示。
|
||||
|
||||
jointDF.writeStream
|
||||
// 指定Sink为终端(Console)
|
||||
.format("console")
|
||||
// 指定输出选项
|
||||
.option("truncate", false)
|
||||
// 指定输出模式
|
||||
.outputMode("update")
|
||||
// 启动流处理应用
|
||||
.start()
|
||||
// 等待中断指令
|
||||
.awaitTermination()
|
||||
|
||||
|
||||
上面这段代码,想必你并不陌生,咱们在之前的几讲中,都是指定Console为输出Sink,这里的操作没什么不同。
|
||||
|
||||
好啦,到此为止,流批关联实例的完整代码就是这些了。接下来,让我们把代码敲入本地环境的spark-shell,然后依次把staging文件夹中的interactions*.csv拷贝到interactions目录之下,来模拟数据流的生成,从而触发流批关联的计算。代码与数据的全部内容,你可以通过这里的GitHub地址进行下载。
|
||||
|
||||
这里,我贴出部分计算结果供你参考。下面的截图,是我们把interactions0.csv文件拷贝到interactions目录之后得到的结果,你可以在你的环境下去做验证,同时继续把剩下的两个文件拷贝到监听目录,来进一步观察流批关联的执行效果。
|
||||
|
||||
|
||||
|
||||
双流关联
|
||||
|
||||
了解了流批关联之后,我们再来说说“双流关联”。显然,与流批关联相比,双流关联最主要的区别是数据来源的不同。除此之外,在双流关联中,事件时间的处理尤其关键。为什么这么说呢?
|
||||
|
||||
学过上一讲之后,我们知道,在源源不断的数据流当中,总会有Late Data产生。Late Data需要解决的主要问题,就是其是否参与当前批次的计算。
|
||||
|
||||
毫无疑问,数据关联是一种最为常见的计算。因此,在双流关联中,我们应该利用Watermark机制,明确指定两条数据流各自的Late Data“容忍度”,从而避免Structured Streaming为了维护状态数据而过度消耗系统资源。Watermark的用法很简单,你可以通过回顾[上一讲]来进行复习。
|
||||
|
||||
说到这里,你可能会问:“什么是状态数据?而维护状态数据,又为什么会过度消耗系统资源呢?”一图胜千言,咱们不妨通过下面的示意图,来说明状态数据的维护,会带来哪些潜在的问题和隐患。
|
||||
|
||||
|
||||
|
||||
假设咱们有两个数据流,一个是短视频发布的数据流,其中记录着短视频相关的元信息,如ID、Name等等。另一个数据流是互动流,也就是用户对于短视频的互动行为。其实在刚刚的流批关联例子中,我们用到数据流也是互动流,这个你应该不会陌生。
|
||||
|
||||
现在,我们想统计短视频在发布一段时间(比如1个小时、6个小时、12个小时,等等)之后,每个短视频的热度。所谓热度,其实就是转评赞等互动行为的统计计数。
|
||||
|
||||
要做到这一点,咱们可以先根据短视频ID把两个数据流关联起来,然后再做统计计数。上图演示的是,两条数据流在Micro-batch模式下的关联过程。为了直击要点,咱们把注意力放在ID=1的短视频上。
|
||||
|
||||
显然,在视频流中,短视频的发布有且仅有一次,即便是内容完全相同的短视频,在数据的记录上也会有不同的ID值。而在互动流中,ID=1的数据条目会有多个,而且会分布在不同的Micro-batch中。事实上,只要视频没有下线,随着时间的推移,互动流中总会夹带着ID=1的互动行为数据。
|
||||
|
||||
为了让视频流中ID=1的记录,能够与互动流的数据关联上,我们需要一直把视频流中批次0的全部内容,缓存在内存中,从而去等待“迟到”的ID=1的互动流数据。像视频流这种,为了后续计算而不得不缓存下来的数据,我们就把它称作为“状态数据”。显然,状态数据在内存中积压的越久、越多,内存的压力就越大。
|
||||
|
||||
在双流关联中,除了要求两条数据流要添加Watermark机之外,为了进一步限制状态数据的尺寸,Structured Streaming还要求在关联条件中,对于事件时间加以限制。这是什么意思呢?咱们还是结合视频流与互动流的示例,通过代码来解读。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
import org.apache.spark.sql.types.StructType
|
||||
|
||||
// 保存staging、interactions、userProfile等文件夹的根目录
|
||||
val rootPath: String = _
|
||||
|
||||
// 定义视频流Schema
|
||||
val postSchema = new StructType().add("id", "integer").add("name", "string").add("postTime", "timestamp")
|
||||
// 监听videoPosting目录,以实时数据流的方式,加载新加入的文件
|
||||
val postStream: DataFrame = spark.readStream.format("csv").option("header", true).option("path", s"${rootPath}/videoPosting").schema(postSchema).load
|
||||
// 定义Watermark,设置Late data容忍度
|
||||
val postStreamWithWatermark = postStream.withWatermark("postTime", "5 minutes")
|
||||
|
||||
// 定义互动流Schema
|
||||
val actionSchema = new StructType().add("userId", "integer").add("videoId", "integer").add("event", "string").add("eventTime", "timestamp")
|
||||
// 监听interactions目录,以实时数据流的方式,加载新加入的文件
|
||||
val actionStream: DataFrame = spark.readStream.format("csv").option("header", true).option("path", s"${rootPath}/interactions").schema(actionSchema).load
|
||||
// 定义Watermark,设置Late data容忍度
|
||||
val actionStreamWithWatermark = actionStream.withWatermark("eventTime", "1 hours")
|
||||
|
||||
// 双流关联
|
||||
val jointDF: DataFrame = actionStreamWithWatermark
|
||||
.join(postStreamWithWatermark,
|
||||
expr("""
|
||||
// 设置Join Keys
|
||||
videoId = id AND
|
||||
// 约束Event time
|
||||
eventTime >= postTime AND
|
||||
eventTime <= postTime + interval 1 hour
|
||||
"""))
|
||||
|
||||
|
||||
代码的前两部分比较简单,分别是从监听文件夹读取新增的文件内容,依次创建视频流和互动流,并在两条流上设置Watermark机制。这些内容之前已经学过,不再重复,咱们把重点放在最后的双流关联代码上。
|
||||
|
||||
可以看到,在关联条件中,除了要设置关联的主外键之外,还必须要对两张表各自的事件时间进行约束。其中,postTime是视频流的事件时间,而eventTime是互动流的事件时间。上述代码的含义是,对于任意发布的视频流,我们只关心它一小时以内的互动行为,一小时以外的互动数据,将不再参与关联计算。
|
||||
|
||||
这样一来,在Watermark机制的“保护”之下,事件时间的限制进一步降低了状态数据需要在内存中保存的时间,从而降低系统资源压力。简言之,对于状态数据的维护,有了Watermark机制与事件时间的限制,可谓是加了“双保险”。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,到这里,我们今天的内容就讲完啦,咱们一起来做个总结。首先,我们要知道,根据数据流的来源不同,Structured Streaming支持“流批关联”和“双流关联”两种关联模式。
|
||||
|
||||
流批关联统一了流处理与批处理,二者的统一,使得Structured Streaming有能力服务于更广泛的业务场景。流批关联的用法相对比较简单,通过readStream API与read API分别读取实时流数据与离线数据,然后按照一般Join语法完成数据关联。
|
||||
|
||||
在今天的演示中,我们用到了File这种形式的Source,你需要掌握File Source的一般用法。具体来说,你需要通过readStream API的format函数来指定文件格式,然后通过option指定监听目录。一旦有新的文件移动到监听目录,Spark便以数据流的形式加载新数据。
|
||||
|
||||
对于双流关联来说,我们首先需要明白,在这种模式下,Structured Streaming需要缓存并维护状态数据。状态数据的维护,主要是为了保证计算逻辑上的一致性。为了让满足条件的Late data同样能够参与计算,Structured Streaming需要一直在内存中缓存状态数据。毫无疑问,状态数据的堆积,会对系统资源带来压力与隐患。
|
||||
|
||||
为了减轻这样的压力与隐患,在双流关联中,一来,我们应该对参与关联的两条数据流设置Watermark机制,再者,在语法上,Structured Streaming在关联条件中,会强制限制事件时间的适用范围。在这样的“双保险”机制下,开发者即可将状态数据维护带来的性能隐患限制在可控的范围内,从而在实现业务逻辑的同时,保证应用运行稳定。
|
||||
|
||||
课后练习题
|
||||
|
||||
今天的题目有两道。
|
||||
|
||||
第一道题目是,我在流批关联那里用interactions0.csv文件给你演示了数据关联操作/请你动手在你的环境下去做验证,同时继续把剩下的两个文件(interactions1.csv、interactions2.csv两个文件)拷贝到监听目录,来进一步观察流批关联的执行效果。
|
||||
|
||||
第二道题目是,在双流关联中,我们需要Watermark和关联条件,来同时约束状态数据维护的成本与开销。那么,在流批关联中,我们是否也需要同样的约束呢?为什么?
|
||||
|
||||
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
0
专栏/零基础入门Spark/34Spark+Kafka:流计算中的“万金油”.md
Normal file
0
专栏/零基础入门Spark/34Spark+Kafka:流计算中的“万金油”.md
Normal file
91
专栏/零基础入门Spark/用户故事小王:保持空杯心态,不做井底之蛙.md
Normal file
91
专栏/零基础入门Spark/用户故事小王:保持空杯心态,不做井底之蛙.md
Normal file
@ -0,0 +1,91 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
用户故事 小王:保持空杯心态,不做井底之蛙
|
||||
你好,我是小王,是一名大数据开发者,目前在一家通信运营商公司从事开发与运维工作,从业已4年有余。
|
||||
|
||||
从我的经验看,如果某位工程师从事的不是大数据底层开发或顶层架构,而是业务开发,那么这位工程师所用到的大数据框架,主要分为三大块:数据采集、数据存储、数据计算。
|
||||
|
||||
而这些框架中,出镜率很高,生态发展很繁荣,而且工作中常用,面试中常问的框架,Apache Spark也必然数一数二。
|
||||
|
||||
我之前是如何学习Spark的?
|
||||
|
||||
作为平平无奇的普通开发者,我平时学习新东西的套路,总结起来就是三板斧:第一步,先请教老员工这个框架在架构图中的所处位置和核心作用;第二步,去网上找入门视频或博客资料,快速刷完,在心中描绘出一个大致轮廓,做到心中有数;第三步,去官网细读文档,跟着文档写代码,在代码中梳理开发流程和弄清细节。
|
||||
|
||||
|
||||
|
||||
我之前学习Spark也是用这种三板斧的思路来学习的。正如专栏的[开篇词]所说,我在“经过短短3个月的强化练习之后,已经能够独当一面,熟练地实现各式各样的业务需求。而这,自然要归功于 Spark 框架本身超高的开发效率”。
|
||||
|
||||
到这里,我自认为我已经是一名Spark初级工程师了。在我通读了Spark官网文档并付诸代码后,我甚至以Spark中级工程师自居了。
|
||||
|
||||
最近我通过考核,成为了公司的内训师,公司为了实现经验沉淀和知识共享,内训师们需要录制结合公司业务的实战课程。
|
||||
|
||||
为了不误人子弟,我意识到自己还得好好巩固下自己的Spark知识。这里不得不说到前面三板斧的第二步,也就是刷资料这步是相对坎坷的。尽管网上的资料林林总总,内容虽多,但“天下文章一大抄”,不同的博客网站里总能看到一模一样的文章。有的文章作者水平一般,讲得读者昏昏欲睡事小,给读者灌输了错误知识事大。
|
||||
|
||||
所以在这个过程中,想找到优质的资料相对较难,费力劳心。幸运的是遇到了这个专栏,其中的内容也超出了我的预期,给我带来了很多启发。
|
||||
|
||||
学习专栏有什么收获?
|
||||
|
||||
在仔细研读了《零基础入门Spark》专栏后,我才发现我错得离谱,我可能离“初级”都还差得远呢。在阅读此专栏的过程中,“这就触及我的知识盲区了”这个表情包不停地在我脑海中闪现。
|
||||
|
||||
|
||||
|
||||
天呐,发现自己的盲区让我心中一紧,感叹“基础不牢,地动山摇”。
|
||||
|
||||
因为我从来没有思考过RDD与数组的异同,而将新知识与熟悉的知识做对比,往往是get新知识的捷径;我也从来没有将算子分门别类地分组整理过,其实做好整理,可以让你在开发时不假思索、信手拈来;我也从来没试过对RDD的重要属性,DAG计算流图做生活化联想,而这个技巧可以延长我们的记忆曲线,尤其适合记忆这类概念性知识点……
|
||||
|
||||
通过这个专栏,这些从没深入思考过的点逐渐被点亮。除了新知识的理解,Spark的几大核心系统也相当重要。
|
||||
|
||||
比如,调度系统的流转过程及其三大组件的各自职责,这部分内容掌握了,我们才能把握住分布式计算的精髓。再比如说内存、存储系统,把这些组件吃透,也正好是我们写出高性能代码的重要前提。
|
||||
|
||||
要想自己独自弄明白这些重要的点,最直接的方法自然是阅读源码。但是对于资质平平无奇的我来说,阅读源码可谓是“蜀道难”。不过面对这样的困难,这个专栏刚好提供了很有效的学习线索,仿佛武当梯云纵,让我们更有可能登高望远。
|
||||
|
||||
在这个专栏里,吴老师并没有像其他课程那样,按照Spark的模块按部就班地讲述,而是通过一个入门case,去将底层知识串联起来讲述,以高屋建瓴之势,述底层架构之蓝图。别担心听不懂,因为吴老师对这些知识点做了生活化联想,对应到工厂流水线、对应到建筑工地,寓教于乐。
|
||||
|
||||
不要小看类比、联想的威力,相比干涩的名词,生活化联想可以有效规避死记硬背,让你出口成章,口吐莲花;关键是能让你理解更透彻,达成“既见树木又见森林”中的“见森林”。
|
||||
|
||||
有了“见森林”的底子后,当我再去阅读源码时,因为心里有了一条相对清晰的线索,按图索骥,所以知道哪里该重点阅读,哪里是里程碑,也不再惧怕阅读源码这件事了。
|
||||
|
||||
不知道你听没听过诺贝尔物理学奖获得者费曼的学习理论,也就是大名鼎鼎的费曼学习法,其中一个步骤是“用最简单的语言把一件事讲清楚,简单到小朋友也能听得懂”。而生活化联想的学习方式,也恰好与此学习方法的理念不谋而合。
|
||||
|
||||
在学习《零基础入门Spark》这个专栏的过程中,我有一个小小的感悟:相对于真正0基础的同学来说,我认为有经验的同学,反而可能会在学习的过程中更难一点。因为他的脑海中可能对某些知识点已经建立了刻板印象或错误认知,遇到冲突的时候,得先清空脑海中的既有知识。这好比得先清空自己杯子里的茶水,才能接纳老禅师斟的新鲜茶水。
|
||||
|
||||
我是怎样学习专栏的?
|
||||
|
||||
在我看来,学习方法只是手段,把知识学到手才是目的。这里我聊聊我的个人经验,仅供参考。
|
||||
|
||||
吴老师讲得再清楚,知识也是吴老师的。所以我在学习的过程中,一直坚持自己写笔记,做好自己的内化。由于资质平平无奇,一篇文章我得阅读三四遍甚至更多,才能领会文章的思想。
|
||||
|
||||
我的具体操作如下:
|
||||
|
||||
第一遍,逐字仔细阅读,遇到问题我会努力克制住“马上去搜索、提问”的坏毛病,坚持把第一遍读完,建立大纲。
|
||||
|
||||
第二遍,带着大纲和问题再次阅读文章和评论区,说不定答案就藏在被我忽视的上下文的细节中,也说不定,会在评论区看到其他同学有着相似的疑问及大家的讨论交流(顺便说一句,评论区可是拓展认知边界的好地方,正所谓他山之石可以攻玉)。
|
||||
|
||||
第三遍,把标题抄下来,关掉文章,看自己能否对着标题,把相关的知识点罗列出来,以及每个知识点需要注意的事项。
|
||||
|
||||
文稿后面的内容来自我的学习笔记,供你做个参考。这三张图分别梳理了调度系统、并行度与并行任务以及Spark存储相关的知识网络。
|
||||
|
||||
其实画图也好,记录笔记也罢,关键就是帮助自己把知识之间的逻辑关系建立起来。如果你在整理过程中遇到卡壳的地方,不妨再去阅读课程和官网资料查漏补缺。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
在这样的学习、消化梳理之后,我还会将这些知识落到写代码上,或者跟读源码上,避免纸上谈兵。三四遍下来,“既见树木又见森林”中的“见树木”这个小目标也达成了。
|
||||
|
||||
对普通人来说,事业成功的原因99%以上源于work with great people。吴老师就是这位the great people,这个专栏就是the great thing。我很庆幸阅读了吴老师的这门课程。把好东西牢牢抱在怀里的那种感觉你知道吗?这么好的东西,咱可不能暴殄天物。
|
||||
|
||||
俗话说“最好的偷懒方式就是不偷懒”,无数次的经验告诉我们,偷过的懒都会加倍还回来。既然精进Spark是大数据工程师躲不掉的事情,那么咱们就踏踏实实、按部就班地学习、行动起来吧。
|
||||
|
||||
纸上得来终觉浅,绝知此事要躬行。只有“躬行”了,专栏里的知识才会缓缓流进你的大脑里,当你用双手在键盘辛勤耕耘的时候,再从你飞舞的指尖上流出,编织成优雅美丽的代码。
|
||||
|
||||
保持空杯心态,不做井底之蛙。希望我们可以一起精进技术,学以致用,加油!
|
||||
|
||||
|
||||
|
||||
|
71
专栏/零基础入门Spark/结束语进入时间裂缝,持续学习.md
Normal file
71
专栏/零基础入门Spark/结束语进入时间裂缝,持续学习.md
Normal file
@ -0,0 +1,71 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 进入时间裂缝,持续学习
|
||||
你好,我是吴磊。
|
||||
|
||||
时间过得真快,不知不觉,就到了要和你说再见的时候。首先要感谢你们的支持和陪伴,坦白地说,现在的我,有些恍惚,不敢相信专栏已经结束了。
|
||||
|
||||
从7月份开始筹备《零基础入门Spark》这门专栏以来,赶稿子、改稿子、录制音频、回复留言,这些任务已经变成了我每天的日常。回忆起这4个月的经历,我脑海中闪现出的第一个词语,就是“夹缝中求生存”。
|
||||
|
||||
为什么说是夹缝中求生存呢?作为一名有家庭的中年职场男来说,工作与家庭的双重“夹击”,让原本就有限的时间和精力变得更加“捉襟见肘”。工作的重要性不言而喻,它是我们个人发展的基础,自然需要全力以赴。而咱们国人也讲究“家事如天”,所以家里再小的事情,优先级也远超任何其他事情。
|
||||
|
||||
毫无疑问,一天下来,工作与家庭就占据了大部分时间。这样算下来,如果把时间比作是一面墙的话,那么一天24小时,留给我专心写专栏的时间,就像是墙上的一道夹缝或是一道裂缝。
|
||||
|
||||
记忆最深刻的,是11月初的那两周。由于工作的原因(党政机关),妻子需要集中封闭两周,她和我们处于完全失联的状态。那么自然,照顾娃的生活起居的“重任”,就落到了我的肩上。在“闭关”前,妻子甚至特意为我这个“大老粗”,列出了一份详细的清单,上面洋洋洒洒地记录着每一天的日常。还没生儿育女的同学就别看了,容易劝退。
|
||||
|
||||
|
||||
|
||||
古语云:“取乎其上,得乎其中;取乎其中,得乎其下”。看到妻子列出的这份“取乎其上”的清单,我就知道,以我对于闺女脾气的了解、以及我那粗线条的性格,我一定会把这份清单执行得“得乎其下”。
|
||||
|
||||
原因很简单,这份清单的最佳候选人应当是全职奶爸,而我显然并不是。因此,在执行层面,免不了要打折扣。我只好围绕着“让娃吃饱、穿暖、不着急、不生气、不生病”的大原则,尽可能地参考妻子给出的Best Practices,来个“曲线救国”。
|
||||
|
||||
举例来说,为了让娃睡个安稳觉,也为了我自己能早点赶稿子,我只好祭出独创的“扛娃入睡”大法。我会扛着她在屋子里左晃右晃、溜达来溜达去,最后小心地把她移到床上。
|
||||
|
||||
说真的,保证娃儿在哄睡过程中不被上下文(体感温度,光线变化,声波抖动等)的切换惊醒,是一件比拆装炸弹还要精细的作业。孩子是天生的多功能传感器,能够捕获外界多种信号源,而且捕获信号后她回调什么函数(仅仅翻个身,还是哭喊出来)来响应你,全看造化。
|
||||
|
||||
哄睡是个技术活儿,更是个体力活。这之后,我基本上已经是腰酸、背痛、腿抽筋,就差瘫倒在地上了。可问题是,时间都被工作和带娃占据了,稿子什么时候写呢?
|
||||
|
||||
熬夜的方案看似可行,牺牲睡眠时间,来赶稿子。但是,我不敢这么做,并不是我不肯吃熬夜的苦,而是我要保证内容生产的质量,而且也担心第二天因为精力涣散、在送娃的路上有所闪失。
|
||||
|
||||
所以在多个重要且紧急的事情同时压在身上的时候,我会更加注重睡眠质量,只有保持精力充沛,才有可能“多进程工作”。
|
||||
|
||||
在常规时间被占满的情况下,我只好钻进时间裂缝,也就是利用零散时间完成片段。对我来说,能利用的碎片化时间,就是上下班的地铁通勤。从家到公司,每天来回大概要两个小时,除去换乘的时间,满打满算,还剩一个多小时让我可以用拇指在手机上码字。
|
||||
|
||||
尽管地铁上的环境嘈杂而又拥挤,不过我发现,人在压力之下反而更容易专注,那段时间,我每天在地铁上都能码出800字左右的片段。
|
||||
|
||||
为了让你更轻松地学会Spark,我还会主动思考有什么生活化的比喻或是类比。专栏里工地搬砖、斯巴克集团的故事,以及玻璃杯牛奶等等例子,不少都来自通勤时的灵光一闪。到了晚上,或是周末,我会把一周积累的片段,系统化地进行整理、配图、配代码、加注释,并最终编辑成一篇完整的文稿。
|
||||
|
||||
现代人的工作和生活节奏都很快,我们的时间被切割得不成样子。人们总是拿时间过于细碎作为拒绝学习的理由:“上下班通勤不过 2 个小时,中间还要换乘几次,思路总被打断,根本没法集中注意力学什么东西,还不如刷刷视频呢!”
|
||||
|
||||
然而实际上,系统化的知识体系与碎片化的内容摄取,并不冲突。构建知识体系,确实需要大段的、集中的时间,但是一旦建立,体系内的一个个知识点,完全可以利用碎片化的 20-30 分钟来搞定——番茄时间以 25 分钟为单位还是有科学依据的。
|
||||
|
||||
以Spark MLlib为例,经过那个模块的学习,想必你会觉得,Spark MLlib支持的特征处理函数和模型算法好多啊,数量多到让人想从入门到放弃的地步。但是,在一番梳理之下,我们不难发现,不同的特征处理函数也好,模型算法也好,它们都可以被归类到某一个范畴中去。至于不同的类别之间的区别与联系,咱们在课程中都做了系统化的梳理。
|
||||
|
||||
因此,要想掌握Spark MLlib,其实咱们不需要每天刻意抽出大段的时间去学习。不太谦虚地说,专栏里的Spark MLlib模块,已经足够系统化,从范畴划分到适用场景,从基础分类到典型案例解析。
|
||||
|
||||
通过这样的“分类指南”,咱们已经掌握了Spark MLlib的主要脉络。接下来我们需要做的事情,就是利用碎片化的时间,钻进时间裂缝,去学习每一个具体的函数或是模型算法,为已有的知识体系添砖加瓦。
|
||||
|
||||
Spark MLlib模块如此,Spark整体的学习也道理相通。关键在于,上了一天班,累得跟三孙子似的,你是否还愿意钻进时间裂缝、利用一切空余时间,以水滴石穿的毅力、持之以恒地完善你的知识体系。
|
||||
|
||||
|
||||
|
||||
实际上,像这种时间裂缝,并不仅仅是被动的通勤时间,在工作中,我们完全可以根据需要,主动地把时间切割为一个又一个裂缝。在每一个裂缝中,我们只专注于一个事件,不接受任何干扰。
|
||||
|
||||
比方说,在每天工作的8小时里,我们可以切割出来多个不连续的coding time,在这些时间里,我们不理会任何的即时消息,只醉心于编写代码。当然,从这8小时中我们也可以切割出来多个meeting裂缝,这时我们暂且不管是不是还有个bug需要修复,只专注于讨论、沟通以及如何说服对方。
|
||||
|
||||
总之,时间裂缝的核心要义就是专注,100%专注于一件事情。这其实有点像CPU的工作原理,CPU的时钟周期是固定的,每个时钟周期,实际上只能处理一个任务。串行的工作方式,看上去很笨,但是一天下来,你会发现这颗CPU实际上做了许多的事情。
|
||||
|
||||
拿我自己来说,在过去的4个月里,时间裂缝还帮我读完了一本书《清醒思考的艺术》、完成了一门极客时间课程[《技术管理实战36讲》]。在忙碌于产出的同时,还能有持续性的输入,我心里会觉得非常踏实,也会觉得很开心。
|
||||
|
||||
做一名坚定的(技术内容)生产者,是我为自己设立的长期目标。而要想持续地输出高质量的内容,持续学习必不可少。水,柔弱而又刚强,充满变化,能适应万物的形状,且从不向困难屈服。李小龙就曾经说过:“Be water, my friend”。
|
||||
|
||||
让我们抓住每一个成长精进的契机,进入时间裂缝,持续学习,与君共勉。
|
||||
|
||||
最后,我还给你准备了一份毕业问卷,题目不多,两分钟左右就能填好,期待你能畅所欲言,谢谢。
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user