first commit

This commit is contained in:
张乾
2024-10-16 11:19:41 +08:00
parent e0146c37db
commit b2fae18d7e
71 changed files with 12539 additions and 0 deletions

View 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将DatabricksSpark云原生商业版本提名为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还要先学ScalaScala语法晦涩难懂直接劝退
开发算子太多了,记不住,来了新的业务需求,不知道该从哪里下手;
……
既然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为你的职业发展增光添彩

View 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提供交互式的运行环境REPLRead-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实例InstanceSparkSession在spark-shell中会由系统自动创建
sparkContext是开发入口SparkContext实例。
在Spark版本演进的过程中从2.0版本开始SparkSession取代了SparkContext成为统一的开发入口。换句话说要开发Spark应用你必须先创建SparkSession。关于SparkSession和SparkContext我会在后续的课程做更详细的介绍这里你只要记住它们是必需的开发入口就可以了。
我们再来看看RDDRDD的全称是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类型的数据元素也就是KeyValue形式的“数组”元素。
因此在调用聚合算子做分组计数之前我们要先把RDD元素转换为KeyValue的形式也就是把RDD[String]映射成RDD[(String, Int)]。
其中我们统一把所有的Value置为1。这样一来对于同一个的单词在后续的计数运算中我们只要对Value做累加即可就像这样
下面是对应的代码:
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
这样一来RDD就由原来存储String元素的cleanWordRDD转换为了存储StringInt的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元素转换为KeyValue的形式
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算子吗提示可以结合官网去查找
另外,你能说说,以上这些算子都有哪些共性或是共同点吗?
欢迎你把答案分享到评论区,我在评论区等你。
如果这一讲对你有帮助,也欢迎你分享给自己的朋友,我们下一讲再见!

View 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分片切割规则
dependenciesRDD依赖
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元素转换为KeyValue的形式
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又转换成元素为KeyValue对的kvRDD-
最终我们调用reduceByKey做分组聚合把kvRDD中的Value从1转换为单词计数。
这4步转换的过程如下图所示
我们刚刚说过RDD代表的是分布式数据形态因此RDD到RDD之间的转换本质上是数据形态上的转换Transformations
在RDD的编程模型中一共有两种算子Transformations类算子和Actions类算子。开发者需要使用Transformations类算子定义并描述数据形态的转换过程然后调用Actions类算子将计算结果收集起来、或是物化到磁盘。
在这样的编程模型下Spark在运行时的计算被划分为两个环节。
基于不同数据形态之间的转换构建计算流图DAGDirected 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分片切割规则
dependenciesRDD依赖
compute转换函数
深入理解RDD之后你需要熟悉RDD的编程模型。在RDD的编程模型中开发者需要使用Transformations类算子定义并描述数据形态的转换过程然后调用Actions类算子将计算结果收集起来、或是物化到磁盘。
而延迟计算指的是开发者调用的各类Transformations算子并不会立即执行计算当且仅当开发者调用Actions算子时之前调用的转换算子才会付诸执行。
每课一练
对于Word Count的计算流图与土豆工坊的流水线工艺尽管看上去毫不相关风马牛不相及不过你不妨花点时间想一想它们之间有哪些区别和联系
欢迎你把答案分享到评论区,我在评论区等你,也欢迎你把这一讲分享给更多的朋友和同事,我们下一讲见!

View 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算子的用法给定映射函数fmap(f)以元素为粒度对RDD做数据转换。其中f可以是带有明确签名的带名函数也可以是匿名函数它的形参类型必须与RDD的元素类型保持一致而输出类型则任由开发者自行决定。
这种照本宣科的介绍听上去难免会让你有点懵别着急接下来我们用些小例子来更加直观地展示map的用法。
在[第一讲]的Word Count示例中我们使用如下代码把包含单词的RDD转换成元素为KeyValue对的RDD后者统称为Paired RDD。
// 把普通RDD转换为Paired RDD
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
在上面的代码实现中传递给map算子的形参word => word1就是我们上面说的映射函数f。只不过这里f是以匿名函数的方式进行定义的其中左侧的word表示匿名函数f的输入形参而右侧的word1则代表函数f的输出结果。
如果我们把匿名函数变成带名函数的话可能你会看的更清楚一些。这里我用一段代码重新定义了带名函数f。
// 把RDD元素转换为KeyValue的形式
// 定义映射函数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元素转换为KeyValue的形式
// 定义映射函数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这对“孪生兄弟”就是用来解决类似的问题。相比mapPartitionsmapPartitionsWithIndex仅仅多出了一个数据分区索引因此接下来我们把重点放在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的文件系统句柄再比如用于在线推理的机器学习模型等等不一而足。你不妨结合实际工作场景把你遇到的共享操作整理到留言区期待你的分享。
相比mapPartitionsmapPartitionsWithIndex仅仅多出了一个数据分区索引这个数据分区索引可以为我们获取分区编号当你的业务逻辑中需要使用到分区编号的时候不妨考虑使用这个算子来实现代码。除了这个额外的分区索引以外mapPartitionsWithIndex在其他方面与mapPartitions是完全一样的。
介绍完map与mapPartitions算子之后接下来我们趁热打铁再来看一个与这两者功能类似的算子flatMap。
flatMap从元素到集合、再从集合到元素
flatMap其实和map与mapPartitions算子类似在功能上与map和mapPartitions一样flatMap也是用来做数据映射的在实现上对于给定映射函数fflatMap(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做各式各样的数据转换给定映射函数fmap(f)以元素为粒度对RDD做数据转换。其中f可以是带名函数也可以是匿名函数它的形参类型必须与RDD的元素类型保持一致而输出类型则任由开发者自行决定。
为了提升数据转换的效率Spark提供了以数据分区为粒度的mapPartitions算子。mapPartitions的形参是代表数据分区的partition它通过在partition之上再次调用map(f)来完成数据的转换。相比mapmapPartitions的优势是以数据分区为粒度初始化共享对象这些共享对象在我们日常的开发中很常见比如数据库连接对象、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中初始化的共享对象呢
欢迎你在评论区回答这些练习题。你也可以把这一讲分享给更多的朋友或者同事,和他们一起讨论讨论,交流是学习的催化剂。我在评论区等你。

View 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在这些独立框架下的分布式部署都需要哪些必备的步骤
今天这一讲就到这里了,如果你在部署过程中遇到的什么问题,欢迎你在评论区提问。如果你觉得这一讲帮助到了你,也欢迎你分享给更多的朋友和同事,我们下一讲再见。

View 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拆分为执行阶段StagesStages指的是不同的运行阶段同时还要负责把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。
对于提请执行的每一个StageDAGScheduler根据Stage内RDD的partitions属性创建分布式任务集合TaskSet。TaskSet包含一个又一个分布式任务TaskRDD有多少数据分区TaskSet就包含多少个Task。换句话说Task与RDD的分区是一一对应的。
你可能会问“Task代表的是分布式任务不过它具体是什么呢”要更好地认识Task我们不妨来看看它的关键属性。
在上表中stageId、stageAttemptId标记了Task与执行阶段Stage的所属关系taskBinary则封装了隶属于这个执行阶段的用户代码partition就是我们刚刚说的RDD数据分区locs属性以字符串的形式记录了该任务倾向的计算节点或是Executor ID。
不难发现taskBinary、partition和locs这三个属性一起描述了这样一件事情Task应该在哪里locs为谁partition执行什么任务taskBinary
到这里我们讲完了戴格的职责让我们来一起简单汇总一下戴格指代的是DAGSchedulerDAGScheduler的主要职责有三个
根据用户代码构建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提供的一个个WorkerOfferTaskScheduler是依据什么规则来挑选Tasks的呢
用一句话来回答对于给定的WorkerOfferTaskScheduler是按照任务的本地倾向性来遴选出TaskSet中适合调度的Tasks。这是什么意思呢听上去比较抽象我们还是从DAGScheduler在Stage内创建任务集TaskSet说起。
我们刚刚说过Task与RDD的partitions是一一对应的在创建Task的过程中DAGScheduler会根据数据分区的物理地址来为Task设置locs属性。locs属性记录了数据分区所在的计算节点、甚至是Executor进程ID。
举例来说当我们调用textFile API从HDFS文件系统中读取源文件时Spark会根据HDFS NameNode当中记录的元数据获取数据分区的存储地址例如node0:/rootPath/partition0-replica0node1:/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、再到ANYTask的本地性倾向逐渐从严苛变得宽松。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为粒度提供计算资源。-
对于给定WorkerOfferTaskScheduler结合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
欢迎你在评论区回答这个问题。如果你觉得这一讲对你有所帮助,也欢迎你把它分享给更多的朋友和同事。我在评论区等你,咱们下一讲见!

View 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中间文件是统称、泛指它包含两类实体文件一个是记录KeyValue键值对的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 IDRecord Key而Value是原数据记录中的数据值如图中的“内存数据结构”所示。
对于数据分区中的数据记录Spark会根据我们前面提到的公式1逐条计算记录所属的目标分区ID然后把主键Reduce Task Partition IDRecord Key和记录的数据值插入到Map数据结构中。当Map结构被灌满之后Spark根据主键对Map中的数据记录做排序然后把所有内容溢出到磁盘中的临时文件如图中的步骤1所示。
随着Map结构被清空Spark可以继续读取分区内容并继续向Map结构中插入数据直到Map结构再次被灌满而再次溢出如图中的步骤2所示。就这样如此往复直到数据分区中所有的数据记录都被处理完毕。
到此为止磁盘上存有若干个溢出的临时文件而内存的Map结构中留有部分数据Spark使用归并排序算法对所有临时文件和Map结构剩余数据做合并分别生成data文件、和与之对应的index文件如图中步骤4所示。Shuffle阶段生成中间文件的过程又叫Shuffle Write。
总结下来Shuffle中间文件的生成过程分为如下几个步骤
对于数据分区中的数据记录,逐一计算其目标分区,然后填充内存数据结构;-
当数据结构填满后,如果分区中还有未处理的数据记录,就对结构中的数据记录按(目标分区 IDKey排序将所有数据溢出到临时文件同时清空数据结构-
重复前 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中间文件是统称它包含两类文件一个是记录KeyValue键值对的data文件另一个是记录键值对所属Reduce Task的index文件。计算图DAG中的Map阶段与Reduce阶段正是通过中间文件来完成数据的交换。
接下来我们详细讲解了Shuffle Write过程中生成中间文件的详细过程归纳起来这个过程分为4个步骤
对于数据分区中的数据记录,逐一计算其目标分区,然后填充内存数据结构;-
当数据结构填满后,如果分区中还有未处理的数据记录,就对结构中的数据记录按(目标分区 IDKey排序将所有数据溢出到临时文件同时清空数据结构-
重复前 2 个步骤,直到分区中所有的数据记录都被处理为止;-
对所有临时文件和内存数据结构中剩余的数据记录做归并排序,生成数据文件和索引文件。
最后在Reduce阶段Reduce Task通过index文件来“定位”属于自己的数据内容并通过网络从不同节点的data文件中下载属于自己的数据记录。
每课一练
这一讲就到这里了,我在这给你留个思考题:
在Shuffle的计算过程中中间文件存储在参数spark.local.dir设置的文件目录中这个参数的默认值是/tmp你觉得这个参数该如何设置才更合理呢
欢迎你在评论区分享你的答案,我在评论区等你。如果这一讲对你有所帮助,你也可以分享给自己的朋友,我们下一讲见。

View 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它指的是元素类型为KeyValue键值对的RDD。
但是在功能方面,可以说,它们承担了数据分析场景中的大部分职责。因此,掌握这些算子的用法,是我们能够游刃有余地开发数据分析应用的重要基础。那么接下来,我们就通过一些实例,来熟悉并学习这些算子的用法。
我们先来说说groupByKey坦白地说相比后面的3个算子groupByKey在我们日常开发中的“出镜率”并不高。之所以要先介绍它主要是为后续的reduceByKey和aggregateByKey这两个重要算子做铺垫。
groupByKey分组收集
groupByKey的字面意思是“按照Key做分组”但实际上groupByKey算子包含两步即分组和收集。
具体来说对于元素类型为KeyValue键值对的Paired RDDgroupByKey的功能就是对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 => word1变更为word => wordword这么做的效果是把kvRDD元素的Key和Value都变成了单词。
紧接着第二个改动我们用groupByKey替换了原先的reduceByKey。相比reduceByKeygroupByKey的用法要简明得多。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的“升级版”相比reduceByKeyaggregateByKey用法更加灵活支持的功能也更加完备。
接下来我们先来回顾reduceByKey然后再对aggregateByKey进行展开。相比aggregateByKeycombineByKey仅在初始化方式上有所不同因此我把它留给你作为课后作业去探索。
reduceByKey分组聚合
reduceByKey的字面含义是“按照Key值做聚合”它的计算逻辑就是根据聚合函数f给出的算法把Key值相同的多个元素聚合成一个元素。
在[第1讲]Word Count的实现中我们使用了reduceByKey来实现分组计数
// 把RDD元素转换为KeyValue的形式
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元素转换为KeyValue的形式
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聚合又能怎样呢相比groupByKeyreduceByKey带来的性能收益并不算明显呀”确实就上面的示意图来说我们很难感受到reduceByKey带来的性能收益。不过量变引起质变在工业级的海量数据下相比groupByKeyreduceByKey通过在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端聚合函数f2aggregateByKey的调用形式如下所示
val rdd: RDD[(Key类型Value类型)] = _
rdd.aggregateByKey(初始值)(f1, f2)
初始值可以是任意数值或是字符串而聚合函数我们也不陌生它们都是带有两个形参和一个输出结果的普通函数。就这3个参数来说比较伤脑筋的是它们之间的类型需要保持一致具体来说
初始值类型必须与f2的结果类型保持一致
f1的形参类型必须与Paired RDD的Value类型保持一致
f2的形参类型必须与f1的结果类型保持一致。
不同类型之间的一致性描述起来比较拗口,咱们不妨结合示意图来加深理解:
熟悉了aggregateByKey的用法之后接下来我们用aggregateByKey这个算子来实现刚刚提到的“先加和再取最大值”的计算逻辑代码实现如下所示
// 把RDD元素转换为KeyValue的形式
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进行排序”。给定包含KeyValue键值对的Paired RDDsortByKey会以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算子。
利用聚合函数freduceByKey可以在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的功能吗
欢迎你分享你的答案。如果这一讲对你有帮助,也欢迎你把这一讲分享给自己的朋友,和他一起来讨论一下本讲的练习题,我们下一讲再见。

View 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元素转换为KeyValue的形式
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函数实际上会进一步调用persistMEMORY_ONLY来完成计算。换句话说下面的两条语句是完全等价的二者的含义都是把RDD物化到内存。
wordCounts.cache
wordCounts.persist(MEMORY_ONLY)
就添加Cache来说相比cache算子persist算子更具备普适性结合多样的存储级别如这里的MEMORY_ONLYpersist算子允许开发者灵活地选择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的空间大小。
欢迎你在评论区分享你的答案。如果这一讲对你有帮助,也欢迎你把这一讲分享给自己的朋友,我们下一讲再见。

View 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这个操作。
具体怎么使用呢我来举个例子。给定两个RDDrdd1和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个RDDrdd1、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我该怎么办呢”如果你想增加并行度那我们还真的只能仰仗repartitionShuffle的问题自然也就无法避免。但假设你的需求是降低并行度这个时候我们就可以把目光投向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之上调用toDebugStringSpark可以帮我们打印出当前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元素转换为KeyValue的形式
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
相比repartitioncoalesce有哪些可能的潜在隐患提示数据分布
欢迎你在留言区跟我交流互动也推荐你把这一讲分享给更多的同事、朋友帮他理清RDD的常用算子。

View 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是BlockIdValue是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
BlockIdMemoryEntry键值对添加到“小册子”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这种数据结构的特点与特性。
期待在留言区看到你的思考。如果这一讲对你有帮助,也推荐你转发给更多的同事、朋友。我们下一讲见!

View 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 MemorySpark将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的设置开发者该如何进行取舍呢答案是看数据的复用频次。这是什么意思呢我们分场景举例来说。
对于ETLExtract、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 CoresCPU核数
我们知道一个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这个配置项的作用之后我们自然就能想到应该把它设置到一个存储空间充沛、甚至性能更有保障的文件系统比如空间足够大的SSDSolid State Disk文件系统目录。
好啦到此为止我们分别介绍了与CPU、内存、磁盘有关的配置项以及它们的含义、作用与设置技巧。说到这里你可能有些按捺不住“这些配置项的重要性我已经get到了那我应该在哪里设置它们呢”接下来我们继续来说说开发者都可以通过哪些途径来设置配置项。
配置项的设置途径
为了满足不同的应用场景Spark为开发者提供了3种配置项设置方式分别是配置文件、命令行参数和SparkConf对象这些方式都以KeyValue键值对的形式记录并设置配置项。
配置文件指的是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对象的方式来设置比较好
欢迎你在留言区跟我交流。如果这一讲对你有帮助的话,也推荐你把这节课分享给有需要的的同事、朋友,我们下一讲见。

View File

@ -0,0 +1,222 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 Spark SQL让我们从“小汽车摇号分析”开始
你好,我是吴磊。
在开篇词我们提出“入门Spark需要三步走”到目前为止我们携手并肩跨越了前面两步首先恭喜你学到这里熟练掌握了Spark常用算子与核心原理以后你已经可以轻松应对大部分数据处理需求了。
不过数据处理毕竟是比较基础的数据应用场景就像赛车有着不同的驾驶场景想成为Spark的资深赛车手我们还要走出第三步——学习Spark计算子框架。只有完成这一步我们才能掌握Spark SQLStructured 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
看到这里想必你已经眉头紧锁“SparkSessionDataFrame这些都是什么鬼你好像压根儿也没有提到过这些概念呀”别着急对于这些关键概念我们在后续的课程中都会陆续展开今天这一讲咱们先来“知其然”“知其所以然”的部分咱们放到后面去讲。
对于SparkSession你可以把它理解为是SparkContext的进阶版是Spark2.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算子把batchNumcarNum出现的次数作为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一致。
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的朋友、同事。我们下一讲见!

View 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。那么相比RDDDataFrame到底有何不同呢我们不妨从两个方面来对比它们的不同一个是数据的表示形式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的计算过程。
总结下来相比RDDDataFrame通过携带明确类型信息的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去做执行。
弄清二者的关系与定位之后接下来的问题是“基于DataFrameSpark 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生成抽象语法树ASTAbstract 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种实现方式分别是嵌套循环连接NLJNested 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的设计与实现执行代码优化则指的是全阶段代码生成WSCGWhole 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算子都白学了呢
欢迎你在留言区和我交流讨论,也推荐你把这一讲的内容分享给更多朋友。

View 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相比RDDDataFrame仅仅是多了一个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用于定义并封装SchemaStructFiled用于定义Schema中的每一个字段包括字段名、字段类型而像StringType、IntegerType这些*Type类型表示的正是字段类型。为了和RDD数据类型保持一致Schema对应的元素类型应该是StringTypeIntegerType
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类参数文件格式它就是文件的存储格式如CSVComma 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
欢迎你在留言区跟我交流活动,也推荐你把这一讲的内容分享给更多的同事、朋友,跟他一起学习进步。

View 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计算呢
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给有需要的朋友。

View File

@ -0,0 +1,323 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 数据关联:不同的关联形式与实现机制该怎么选?
你好,我是吴磊。
在上一讲我们学习了Spark SQL支持的诸多算子。其中数据关联Join是数据分析场景中最常见、最重要的操作。毫不夸张地说几乎在所有的数据应用中你都能看到数据关联的“身影”。因此今天这一讲咱们继续详细说一说Spark SQL对于Join的支持。
众所周知Join的种类非常丰富。如果按照关联形式Join Types来划分数据关联分为内关联、外关联、左关联、右关联等等。对于参与关联计算的两张表关联形式决定了结果集的数据来源。因此在开发过程中选择哪种关联形式是由我们的业务逻辑决定的。
而从实现机制的角度Join又可以分为NLJNested Loop Join、SMJSort Merge Join和HJHash 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种实现机制分别是NLJNested Loop Join、SMJSort Merge Join和HJHash 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|
+---+------+---+-------+---+------+
*/
NLJNested Loop Join
对于参与关联的两张表如salaries和employees按照它们在代码中出现的顺序我们约定俗成地把salaries称作“左表”而把employees称作“右表”。在探讨关联机制的时候我们又常常把左表称作是“驱动表”而把右表称为“基表”。
一般来说,驱动表的体量往往较大,在实现关联的过程中,驱动表是主动扫描数据的那一方。而基表相对来说体量较小,它是被动参与数据扫描的那一方。
在NLJ的实现机制下算法会使用外、内两个嵌套的for循环来依次扫描驱动表与基表中的数据记录。在扫描的同时还会判定关联条件是否成立如内关联例子中的salaries(“id”) === employees(“id”)。如果关联条件成立,就把两张表的记录拼接在一起,然后对外进行输出。
在实现的过程中,外层的 for 循环负责遍历驱动表的每一条数据,如图中的步骤 1 所示。对于驱动表中的每一条数据记录,内层的 for 循环会逐条扫描基表的所有记录依次判断记录的id字段值是否满足关联条件如步骤 2 所示。
不难发现,假设驱动表有 M 行数据,而基表有 N 行数据,那么 NLJ 算法的计算复杂度是 O(M * N)。尽管NLJ的实现方式简单、直观、易懂但它的执行效率显然很差。
SMJSort 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的计算过程我们可以用“先苦后甜”来形容。苦指的是要先花费时间给两张表做排序而甜指的则是有序表的归并关联能够享受到线性的计算复杂度。
HJHash Join
考虑到SMJ对于排序的苛刻要求后来又有人推出了HJ算法。HJ的设计初衷是以空间换时间力图将基表扫描的计算复杂度降低至O(1)。
具体来说HJ的计算分为两个阶段分别是Build阶段和Probe阶段。在Build阶段在基表之上算法使用既定的哈希函数构建哈希表如上图的步骤 1 所示。哈希表中的Key是id字段应用Apply哈希函数之后的哈希值而哈希表的 Value 同时包含了原始的Join Keyid字段和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结合其实现原理你能猜一猜它们可能的适用场景都有哪些吗或者换句话说在什么样的情况下更适合使用哪种实现机制来进行数据关联
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给身边的同事、朋友。

View File

@ -0,0 +1,143 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 数据关联优化都有哪些Join策略开发者该如何取舍
你好,我是吴磊。
在上一讲,我们分别从关联形式与实现机制这两个方面,对数据分析进行了讲解和介绍。对于不同关联形式的用法和实现机制的原理,想必你已经了然于胸。不过,在大数据的应用场景中,数据的处理往往是在分布式的环境下进行的,在这种情况下,数据关联的计算还要考虑网络分发这个环节。
我们知道在分布式环境中Spark支持两类数据分发模式。一类是我们在[第7讲]学过的ShuffleShuffle通过中间文件来完成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才能利用刚刚说的HJSMJ以及NLJ以Executors进程为粒度并行地完成数据关联
换句话说以Join Keys为基准两张表的数据分布保持一致是Spark SQL执行分布式数据关联的前提而能满足这个前提的途径只有两个Shuffle与广播这里我额外提醒一下Shuffle和广播变量我们在前面的课程有过详细的介绍如果你记不太清了不妨翻回去看一看
回到正题开篇咱们说到如果按照分发模式来划分数据关联可以分为Shuffle Join和Broadcast Join两大类通常来说在执行性能方面相比Shuffle JoinBroadcast 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就可以使用HJSMJ或是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不仅可以在普通变量上创建广播变量在分布式数据集如RDDDataFrame之上也可以创建广播变量这样一来对于参与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一旦数据分发完毕理论上可以采用HJSMJ和NLJ这3种实现机制中的任意一种完成Executors内部的数据关联因此两种分发模式与三种实现机制它们组合起来总共有6种分布式Join策略如下图所示
虽然组合起来选择多样但你也不必死记硬背抓住里面的规律才是关键我们一起来分析看看
在这6种Join策略中Spark SQL支持其中的5种来应对不用的关联场景也即图中蓝色的5个矩形对于等值关联Equi JoinSpark SQL优先考虑采用Broadcast HJ策略其次是Shuffle SMJ最次是Shuffle HJ对于不等值关联Non Equi JoinSpark 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的执行效率入手做分析
欢迎你在留言区跟我交流互动也推荐你把这一讲分享给更多同事朋友

View 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策略的重要性不必多说AQEAdaptive 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_iditem_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个分区大小分别是90MB100MB和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策略来完成关联计算
然后我们分别介绍了AQEAdaptive 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的两个计算阶段出发去思考这个问题
欢迎你在留言区跟我交流讨论也推荐你把这一讲分享给更多的同事朋友

View 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个Actionssave保存计算结果、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
欢迎你在留言区跟我交流探讨,也欢迎推荐你把这一讲分享给有需要的朋友、同事。

View 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为数据关联做准备。
可以看到对于每一个ExchangeSpark UI都提供了丰富的Metrics来刻画Shuffle的计算过程。从Shuffle Write到Shuffle Read从数据量到处理时间应有尽有。为了方便说明对于Metrics的解释与释义我以表格的方式进行了整理供你随时查阅。
结合这份Shuffle的“体检报告”我们就能以量化的方式去掌握Shuffle过程的计算细节从而为调优提供更多的洞察与思路。
为了让你获得直观感受我还是举个例子说明。比方说我们观察到过滤之后的中签编号数据大小不足10MB7.4MB这时我们首先会想到对于这样的大表Join小表Spark SQL选择了SortMergeJoin策略是不合理的。
基于这样的判断我们完全可以让Spark SQL选择BroadcastHashJoin策略来提供更好的执行性能。至于调优的具体方法想必不用我多说你也早已心领神会要么用强制广播要么利用Spark 3.x版本提供的AQE特性。
你不妨结合本讲开头的代码去完成SortMergeJoin到BroadcastHashJoin策略转换的调优期待你在留言区分享你的调优结果。
Sort
接下来我们再来说说Sort。相比ExchangeSort的度量指标没那么多不过他们足以让我们一窥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 / RecordsShuffle Remote Reads等等。
这些Metrics我们在介绍SQL详情的时候已经详细说过了。另外Duration、GC Time、以及Peak Execution Memory这些Metrics的含义要么已经讲过要么过于简单、无需解释。因此对于这3个指标咱们也不再多着笔墨。
这里特别值得你关注的是SpillMemory和SpillDisk这两个指标。Spill也即溢出数据它指的是因内存数据结构PartitionedPairBuffer、AppendOnlyMap等等空间受限而腾挪出去的数据。SpillMemory表示的是这部分数据在内存中的存储大小而SpillDisk表示的是这些数据在磁盘中的大小。
因此用SpillMemory除以SpillDisk就可以得到“数据膨胀系数”的近似值我们把它记为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使用的心得体会分享到课后的评论区我们一起讨论共同进步也推荐你把这一讲分享更多同事、朋友。

View 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)
模型效果评估
模型训练好之后,我们需要对模型的效果进行验证、评估,才能判定模型的“好”、“坏”。这就好比,馅饼烤熟之后,我们得亲自尝一尝,才能知道它的味道跟我们期待的口感是否一致。
首先我们先来看看模型在训练集上的表现怎么样。在线性回归模型的评估中我们有很多的指标用来量化模型的预测误差。其中最具代表性的要数RMSERoot Mean Squared Error也就是均方根误差。我们可以通过在模型上调用summary函数来获取模型在训练集上的评估指标如下所示。
val trainingSummary = lrModel.summary
println(s"RMSE: ${trainingSummary.rootMeanSquaredError}")
/** 结果打印
RMSE: 45798.86
*/
在训练集的数据分布中房价的值域在34900755000之间因此45798.86的预测误差还是相当大的。这说明我们得到的模型,甚至没有很好地拟合训练数据。换句话说,训练得到的模型,处在一个“欠拟合”的状态。
这其实很好理解,一方面,咱们的模型过于简单,线性回归的拟合能力本身就非常有限。
再者在数据方面我们目前仅仅使用了4个字段LotAreaIntGrLivAreaIntTotalBsmtSFIntGarageAreaInt。房价影响因素众多仅用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”竞赛项目下载训练数据完成从数据加载到模型训练的整个过程。
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友,一起动手试试从数据加载到模型训练的整个过程。

View File

@ -0,0 +1,369 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 特征工程(上):有哪些常用的特征处理函数?
你好,我是吴磊。
在上一讲,我们一起构建了一个简单的线性回归模型,来预测美国爱荷华州的房价。从模型效果来看,模型的预测能力非常差。不过,事出有因,一方面线性回归的拟合能力有限,再者,我们使用的特征也是少的可怜。
要想提升模型效果,具体到我们“房价预测”的案例里就是把房价预测得更准,我们需要从特征和模型两个方面着手,逐步对模型进行优化。
在机器学习领域有一条尽人皆知的“潜规则”Garbage ingarbage out。它的意思是说当我们喂给模型的数据是“垃圾”的时候模型“吐出”的预测结果也是“垃圾”。垃圾是一句玩笑话实际上它指的是不完善的特征工程。
特征工程不完善的成因有很多,比如数据质量参差不齐、特征字段区分度不高,还有特征选择不到位、不合理,等等。
作为初学者,我们必须要牢记一点:特征工程制约着模型效果,它决定了模型效果的上限,也就是“天花板”。而模型调优,仅仅是在不停地逼近这个“天花板”而已。因此,提升模型效果的第一步,就是要做好特征工程。
为了减轻你的学习负担我把特征工程拆成了上、下两篇。我会用两讲的内容带你了解在Spark MLlib的开发框架下都有哪些完善特征工程的方法。总的来说我们需要学习6大类特征处理方法今天这一讲我们先来学习前3类下一讲再学习另外3类。
课程安排
打开Spark MLlib特征工程页面你会发现这里罗列着数不清的特征处理函数让人眼花缭乱。作为初学者看到这么长的列表更是会感到无所适从。
不过,你别担心,对于列表中的函数,结合过往的应用经验,我会从特征工程的视角出发,把它们分门别类地进行归类。
如图所示从原始数据生成可用于模型训练的训练样本这个过程又叫“特征工程”我们有很长的路要走。通常来说对于原始数据中的字段我们会把它们分为数值型Numeric和非数值型Categorical。之所以要这样区分原因在于字段类型不同处理方法也不同。
在上图中从左到右Spark MLlib特征处理函数可以被分为如下几类依次是
预处理
特征选择
归一化
离散化
Embedding
向量计算
除此之外Spark MLlib还提供了一些用于自然语言处理NLPNatural 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实际上剩余的CarPortBuiltIn等字符串也都被转换成了对应的索引值
为了对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的作用是把多个数值列捏合为一个特征向量以房屋数据的三个数值列LotFrontageBedroomAbvGrKitchenAbvGr为例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的工作流程这里我们还是以LotFrontageBedroomAbvGrKitchenAbvGr这3个字段为例来进行演示
可以看到对房价来说ChiSqSelector认为前两个字段比较重要而厨房个数没那么重要因此在selectedFeatures这个数组中ChiSqSelector记录了0和1这两个索引分别对应着原始的LotFrontageBedroomAbvGr这两个字段
好啦到此为止我们以ChiSqSelector为代表学习了Spark MLlib框架中特征选择的用法打通了特征工程的第二关接下来我们继续努力去挑战第三道关卡归一化
归一化MinMaxScaler
归一化Normalization的作用是把一组数值统一映射到同一个值域而这个值域通常是[0, 1]也就是说不管原始数据序列的量级是105还是10-5归一化都会把它们统一缩放到[0, 1]这个范围
这么说可能比较抽象我们拿LotAreaBedroomAbvGr这两个字段来举例其中LotArea的含义是房屋面积它的单位是平方英尺量级在105BedroomAbvGr的单位是个数它的量级是101
假设我们采用Spark MLlib提供的MinMaxScaler对房屋数据做归一化那么这两列数据都会被统一缩放到[0, 1]这个值域范围从而抹去单位不同带来的量纲差异
你可能会问为什么要做归一化呢去掉量纲差异的动机是什么呢原始数据它不香吗
原始数据很香但原始数据的量纲差异不香当原始数据之间的量纲差异较大时在模型训练的过程中梯度下降不稳定抖动较大模型不容易收敛从而导致训练效率较差相反当所有特征数据都被约束到同一个值域时模型训练的效率会得到大幅提升关于模型训练与模型调优我们留到下一讲再去展开这里你先理解归一化的必要性即可
既然归一化这么重要那具体应该怎么实现呢其实很简单只要一个函数就可以搞定
Spark MLlib支持多种多样的归一化函数如StandardScalerMinMaxScaler等等尽管这些函数的算法各有不同但效果都是一样的
我们以MinMaxScaler为例看一看对于任意的房屋面积eiMinMaxScaler使用如下公式来完成对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函数完成对目标字段的归一化
这段代码执行完毕之后engineeringDataDataFrame就包含了多个后缀为Scaled的数据列这些数据列的内容就是对应原始字段的归一化数据如下所示
好啦到此为止我们以MinMaxScaler为代表学习了Spark MLlib框架中数据归一化的用法打通了特征工程的第三关
重点回顾
好啦今天的内容讲完啦我们一起来做个总结今天这一讲我们主要围绕特征工程展开你需要掌握特征工程不同环节的特征处理方法尤其是那些最具代表性的特征处理函数
从原始数据到生成训练样本特征工程可以被分为如下几个环节我们今天重点讲解了其中的前三个环节也就是预处理特征选择和归一化
针对不同环节Spark MLlib框架提供了丰富的特征处理函数作为预处理环节的代表StringIndexer负责对非数值型特征做初步处理将模型无法直接消费的字符串转换为数值
特征选择的动机在于提取与预测标的关联度更高的特征从而精简模型尺寸提升模型泛化能力特征选择可以从两方面入手业务出发的专家经验和基于数据的统计分析
Spark MLlib基于不同的统计方法提供了多样的特征选择器Feature Selectors其中ChiSqSelector以卡方检验为基础选择相关度最高的前N个特征
归一化的目的在于去掉不同特征之间量纲的影响避免量纲不一致而导致的梯度下降震荡模型收敛效率低下等问题归一化的具体做法是把不同特征都缩放到同一个值域在这方面Spark MLlib提供了多种归一化方法供开发者选择
在下一讲我们将继续离散化Embedding和向量计算这3个环节的学习最后还会带你整体看一下各环节优化过后的模型效果敬请期待
每课一练
对于我们今天讲解的特征处理函数如StringIndexerChiSqSelectorMinMaxScaler你能说说它们之间的区别和共同点吗
欢迎你在留言区跟我交流互动也推荐你把今天的内容转发给更多同事和朋友跟他一起交流特征工程相关的内容

View 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 < 36 > 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%的时间和精力由于特征工程制约着模型效果的上限因此尽管特征工程的步骤繁多过程繁琐但是我们千万不能在这个环节偷懒一定要认真对待
这也是为什么我们分为上下两部分来着重讲解特征工程从概览到每一个环节从每一个环节的作用到它包含的具体方法数据质量构筑了模型效果的天花板特征工程道阻且长然而行则将至让我们一起加油
每课一练
结合上一讲对于我们介绍过的所有特征处理函数如StringIndexerChiSqSelectorMinMaxScalerBucketizerOneHotEncoder和VectorAssembler你能说说他们之间的区别和共同点吗
欢迎你在留言区记录你的收获与思考也欢迎你向更多同事朋友分享今天的内容说不定就能帮他解决特征工程方面的问题

View 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个实例又可以分为两类如下图所示。
为了照顾基础薄弱的同学我们需要先搞清楚决策树、GBDTGradient-boosted Decision Trees和RFRandom 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用信息熵来量化也即每个节点的标签都是一样的。但在实际工作中我们很难做到这一点。不仅如此一般来说一棵决策树的拟合能力是相当有限的它很难把样本的纯度提升得足够高。
这时就要说到GBDTGradient-boosted Decision Trees和RFRandom Forest这两种算法了尽管它们的设计思想各不相同但本质上都是为了进一步提升数据样本的纯度。
Random Forest
Random Forest又叫“随机森林”它的设计思想是“三个臭皮匠、赛过诸葛亮”。既然一棵树的拟合能力有限那么就用多棵树来“凑数儿”毕竟老话说得好人多出韩信。
举例来说,我们想结合多个特征,来对房屋质量进行分类。对于给定的数据样本,随机森林算法会训练多棵决策树,树与树之间是相互独立的,彼此之间不存在任何依赖关系。对于每一棵树,算法会随机选择部分样本与部分特征,来进行决策树的构建,这也是随机森林命名中“随机”一词的由来。
以上图为例随机森林算法构建了3棵决策树第一棵用到了“居室数量”和“房屋面积”这两个特征而第二棵选择了“建筑年龄”、“装修情况”和“房屋类型”三个特征最后一棵树选择的是“是否带泳池”、“房屋面积”、“装修情况”和“厨房数量”四个特征。
每棵树都把遍历的样本分为5个类别每个类别都包含部分样本。当有新的数据样本需要预测房屋质量时我们把数据样本同时“喂给”随机森林的3棵树预测结果取决于3棵树各自的输出结果。
假设样本经过第一棵树的判别之后掉落在了Set3经过第二棵树的“决策”之后掉落在了Set2而经过第三棵树的判定之后归类到了Set3那么样本最终的预测结果就是Set3。也即按照“少数服从多数”的原则随机森林最终的预测结果会取所有决策树结果中的大多数。回归问题也是类似最简单的办法就是取所有决策树判定结果的均值。
GBDT
接下来我们再说说GBDTGradient-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模型算法各自的优缺点吗
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的同事、朋友。

View 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分别用于设置模型的超参数也即最大树深与最大迭代次数决策树的数量从而避免模型出现过拟合的情况。
每课一练
对于房价预测与房屋分类这两个场景,你觉得在它们之间,有代码(尤其是特征工程部分的代码)复用的必要和可能性吗?
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的同事、朋友。

View File

@ -0,0 +1,260 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 模型训练(下):协同过滤与频繁项集算法详解
你好,我是吴磊。
如果你平时爱刷抖音或者热衷看电影不知道有没有过这样的体验这类影视App你用得越久它就好像会读心术一样总能给你推荐对胃口的内容。其实这种迎合用户喜好的推荐离不开机器学习中的推荐算法。
今天是咱们模型训练的最后一讲在今天这一讲我们就结合两个有趣的电影推荐场景为你讲解Spark MLlib支持的协同过滤与频繁项集算法。与上一讲一样咱们还是先来贴出下面这张“全景图”方便你对学过和即将要学的知识做到心中有数。
电影推荐场景
今天这一讲咱们结合Kaggle竞赛中的MovieLens数据集使用不同算法来构建简易的电影推荐引擎。尽管MovieLens数据集包含了多个文件但课程中主要用到的是ratings.csv这个文件。文件中的每条数据条目记录的都是用户对于电影的打分如下表所示。
其中第一列userId为用户IDmovieId表示电影ID而rating就是用户对于电影的评分。像这样同时存有用户与物品电影信息的二维表我们把它们统称为“交互矩阵”或是“共现矩阵”。你可能会疑惑通过这么一份简单的二维表我们能干些什么呢
可别小瞧这份数据与合适的模型算法搭配在一起我就能根据它们构建初具模样的推荐引擎。在Spark MLlib框架下至少有两种模型算法可以做到这一点一个是协同过滤Collaborative Filtering另一个是频繁项集Frequency Patterns。其中前者天生就是用来做推荐用的而后者是一种常规的非监督学习算法你可以结合数据特点把这个算法灵活运用于推荐场景。
协同过滤
我们先说协同过滤,从字面上来说,“过滤”是目的,而“协同”是方式、方法。简单地说,协同过滤的目标,就是从物品集合(比如完整的电影候选集)中,“过滤”出那些用户可能感兴趣的物品子集。而“协同”,它指的是,利用群体行为(全部用户与全部物品的交互历史)来实现过滤。
这样说有些绕,实际上,协同过滤的核心思想很简单,就是“相似的人倾向于喜好相似的物品集”。
交互矩阵看上去简单,但其中隐含着大量的相似性信息,只要利用合适的模型算法,我们就能挖掘出用户与用户之间的相似性、物品与物品之间的相似性,以及用户与物品之间的相似性。一旦这些相似性可以被量化,我们自然就可以基于相似性去做推荐了。思路是不是很简单?
那么问题来了,这些相似性,该怎么量化呢?答案是:矩阵分解。
在数学上给定维度为MN的交互矩阵C我们可以把它分解为两个矩阵U与I的乘积。其中我们可以把U称作“用户矩阵”它的维度为MK而I可以看作是“物品矩阵”它的维度是KN
在用户矩阵与物品矩阵中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
// 基于ALSAlternative Least Squares交替最小二乘构建模型完成矩阵分解
val als = new ALS()
.setUserCol("userIdInt")
.setItemCol("movieIdInt")
.setRatingCol("ratingFloat")
.setMaxIter(20)
val alsModel = als.fit(trainingData)
值得一提的是在Spark MLlib的框架下对于协同过滤的实现Spark并没有采用解析解的方式数学上严格的矩阵分解而是用了一种近似的方式来去近似矩阵分解。这种方式就是ALSAlternative Least Squares交替最小二乘
具体来说给定交互矩阵C对于用户矩阵U与物品矩阵ISpark先给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条电影集合数据。对于“八佰”、“金刚川”、“长津湖”这个组合来说当且仅当它出现的次数大于7127120 * 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的电影推荐给他/她。
重点回顾
好啦,到此为止,模型训练的上、中、下三讲,我们就全部讲完啦!这三讲的内容较多,涉及的算法也很多,为了让你对他们有一个整体的把握,我把这些算法的分类、原理、特点与适用场景,都整理到了如下的表格中,供你随时回顾。
不难发现,机器学习的场景众多,不同的场景下,又有多种不同的算法供我们选择。掌握这些算法的原理与特性,有利于我们高效地进行模型选型与模型训练,从而去解决不同场景下的特定问题。
对于算法的调优与应用,还需要你结合日常的实践去进一步验证、巩固,也欢迎你在留言区分享你的心得与体会,让我们一起加油!
每课一练
对于本讲介绍的两种推荐思路(协同过滤与频繁项集),你能说说他们各自的优劣势吗?
你有什么学习收获或者疑问,都可以跟我交流,咱们留言区见。

View 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。相比TransformerEstimator要简单得多它实际上就是各类模型算法如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就全部定义完了原始数据经过StringIndexerVectorAssembler和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的所有转换逻辑都重写一遍比如StringIndexerVectorAssembler和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我们就可以省去StringIndexerVectorAssembler这些特征处理函数的重复定义在提升开发效率的同时消除样本不一致的隐患除了在同一个作业内部复用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中的每一个StageTransformer或Estimator
val formerStages = unfitPipeline.getStages
// 去掉Pipeline中最后一个组件也即EstimatorGBTRegressor
val formerStagesWithoutModel = formerStages.dropRight(1)
import org.apache.spark.ml.regression.RandomForestRegressor
// 定义新的EstimatorRandomForestRegressor
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

View 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的差异与特点我们留到[下一讲]再去展开。
这里我们先来说说ConsoleConsole就是我们常说的终端选择Console作为SinkSpark会把结果打印到终端。因此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而对于SinkSpark支持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流计算应用。

View 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社区官方的说法是“结合幂等的SinkStructured Streaming能够提供Exactly once的容错能力”。
实际上这句话应该拆解为两部分。在数据处理上结合容错机制Structured Streaming本身能够提供“At least once”的处理能力。而结合幂等的SinkStructured 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 LogWAL日志
换句话说当源数据流进Source之后它需要先到Checkpoint目录下进行“报道”然后才会被Structured Streaming引擎处理。毫无疑问“报道”这一步耽搁了端到端的处理延迟如下图所示。
除此之外由于每个Micro-batch都会触发一个Spark作业我们知道作业与任务的频繁调度会引入计算开销因此也会带来不同程度的延迟。在运行模式与容错机制的双重加持下Batch mode的延迟水平往往维持在秒这个量级在最好的情况下能达到几百毫秒左右。
Continuous mode容错
相比Batch modeContinuous 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机制需要预写WALWrite 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中写日志的动作也挪到数据消费与处理之后呢
欢迎你在留言区跟我交流讨论,也推荐你把这一讲的内容分享给更多朋友。

View 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和关联条件来同时约束状态数据维护的成本与开销。那么在流批关联中我们是否也需要同样的约束呢为什么
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友。

View 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是大数据工程师躲不掉的事情那么咱们就踏踏实实、按部就班地学习、行动起来吧。
纸上得来终觉浅,绝知此事要躬行。只有“躬行”了,专栏里的知识才会缓缓流进你的大脑里,当你用双手在键盘辛勤耕耘的时候,再从你飞舞的指尖上流出,编织成优雅美丽的代码。
保持空杯心态,不做井底之蛙。希望我们可以一起精进技术,学以致用,加油!

View 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”。
让我们抓住每一个成长精进的契机,进入时间裂缝,持续学习,与君共勉。
最后,我还给你准备了一份毕业问卷,题目不多,两分钟左右就能填好,期待你能畅所欲言,谢谢。