mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-10 19:54:28 +08:00
del
This commit is contained in:
148
极客时间专栏/geek/Spark性能调优实战/原理篇/03 | RDD:为什么你必须要理解弹性分布式数据集?.md
Normal file
148
极客时间专栏/geek/Spark性能调优实战/原理篇/03 | RDD:为什么你必须要理解弹性分布式数据集?.md
Normal file
@@ -0,0 +1,148 @@
|
||||
<audio id="audio" title="03 | RDD:为什么你必须要理解弹性分布式数据集?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/0b/a9yy044522671a353a9a506d86d6b80b.mp3"></audio>
|
||||
|
||||
你好,我是吴磊。
|
||||
|
||||
从今天开始,我们进入原理篇的学习。我会以性能调优为导向,给你详细讲讲Spark中的核心概念RDD和DAG,以及重要组件调度系统、存储系统和内存管理。这节课,咱们先来说说RDD。
|
||||
|
||||
RDD可以说是Spark中最基础的概念了,使用Spark的开发者想必对RDD都不陌生,甚至提起RDD,你的耳朵可能都已经听出茧子了。不过,随着Spark开发API的演进和发展,现在上手开发基本都是DataFrame或Dataset API。所以很多初学者会认为,“反正RDD API基本都没人用了,我也没必要弄明白RDD到底是什么。”
|
||||
|
||||
真的是这样的吗?当然不是。
|
||||
|
||||
## RDD为何如此重要
|
||||
|
||||
首先,RDD作为Spark对于分布式数据模型的抽象,是构建Spark分布式内存计算引擎的基石。很多Spark核心概念与核心组件,如DAG和调度系统都衍生自RDD。**因此,深入理解RDD有利于你更全面、系统地学习Spark的工作原理。**
|
||||
|
||||
其次,尽管RDD API使用频率越来越低,绝大多数人也都已经习惯于DataFrame和Dataset API,但是,无论采用哪种API或是哪种开发语言,你的应用在Spark内部最终都会转化为RDD之上的分布式计算。换句话说,**如果你想要在运行时判断应用的性能瓶颈,前提是你要对RDD足够了解**。还记得吗?定位性能瓶颈是Spark性能调优的第一步。
|
||||
|
||||
不仅如此,对于RDD不求甚解还有可能带来潜在的性能隐患,接下来,我们就从一个反例入手,一起来分析一下。
|
||||
|
||||
还记得,我们在第1讲中讲过的数据过滤与聚合的反例吗?通过这个例子我们明白了性能调优的必要性。那这个例子和RDD有什么关系呢?
|
||||
|
||||
别着急,我们先来回顾一下这个案例中的代码实现,去挖掘开发者采用这种实现方式的深层原因。
|
||||
|
||||
```
|
||||
//实现方案1 —— 反例
|
||||
def createInstance(factDF: DataFrame, startDate: String, endDate: String): DataFrame = {
|
||||
val instanceDF = factDF
|
||||
.filter(col("eventDate") > lit(startDate) && col("eventDate") <= lit(endDate))
|
||||
.groupBy("dim1", "dim2", "dim3", "event_date")
|
||||
.agg("sum(value) as sum_value")
|
||||
instanceDF
|
||||
}
|
||||
|
||||
pairDF.collect.foreach{
|
||||
case (startDate: String, endDate: String) =>
|
||||
val instance = createInstance(factDF, startDate, endDate)
|
||||
val outPath = s"${rootPath}/endDate=${endDate}/startDate=${startDate}"
|
||||
instance.write.parquet(outPath)
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,createInstance的主要逻辑是按照时间条件对factDF进行过滤,返回汇总的业务统计量,然后pairDF循环遍历每一对开始时间和结束时间,循环调用createInstance获取汇总结果并落盘。我们在[第1课](https://time.geekbang.org/column/article/352035)中分析过,这份代码的主要问题在于囊括上千万行数据的factDF被反复扫描了几百次,而且是全量扫描,从而拖垮了端到端的执行性能。
|
||||
|
||||
那么,我们不禁要问:开发者究竟为什么会想到用这种低效的方式去实现业务逻辑呢?或者说,是什么内驱因素让开发者自然而然地采用这种实现方式呢?
|
||||
|
||||
让我们跳出Spark、跳出这个专栏,把自己置身于一间教室内:黑板前,老师正在讲解《XX语言编程》,旁边是你的同学,他边听老师讲课,边翻看着桌上的课本。这个场景熟不熟悉?亲不亲切?回想一下,老师讲的、书本上教的和我们示例中的代码,是不是极其类似?
|
||||
|
||||
没错!我们的大脑,已经习惯了for循环,习惯了用函数处理变量、封装计算逻辑,习惯了面向过程的编程模式。在分布式计算出现以前,我们都是这么开发的,老师也是这么讲的,书本上也是这么教的,没毛病。
|
||||
|
||||
因此我认为,开发者之所以会选择上面的实现方式,根本原因在于他把factDF当成了一个普通变量,一个与createInstance函数中startDate、endDate同等地位的形参,他并没有意识到,factDF实际上是一个庞大的、横跨所有计算节点的分布式数据集合,更没有意识到,在分布式运行环境中,外面的for循环会导致这个庞大的数据集被反复地全量扫描。
|
||||
|
||||
这种对于分布式计算认知方面的缺失,究其缘由,还是我们对Spark核心概念RDD的理解不够透彻。所以你看,深入理解RDD还是很有必要的,**对于RDD一知半解,极有可能在应用开发的过程中,不知不觉地留下潜在的性能隐患**。
|
||||
|
||||
## 深入理解RDD
|
||||
|
||||
既然RDD如此重要,它究竟是什么呢?2010年,在一个夜黑风高的夜晚,Matei等人发表了一篇名为《Spark: Cluster Computing with Working Sets》的论文并首次提出了RDD的概念。RDD,全称Resilient Distributed Datasets,翻译过来就是弹性分布式数据集。本质上,它是对于数据模型的抽象,用于囊括所有内存中和磁盘中的分布式数据实体。
|
||||
|
||||
如果就这么从理论出发、照本宣科地讲下去,未免过于枯燥、乏味、没意思!不如,我先来给你讲个故事。
|
||||
|
||||
### 从薯片的加工流程看RDD
|
||||
|
||||
在很久很久以前,有个生产桶装薯片的工坊,工坊的规模较小,工艺也比较原始。为了充分利用每一颗土豆、降低生产成本,工坊使用 3 条流水线来同时生产 3 种不同尺寸的桶装薯片。3 条流水线可以同时加工 3 颗土豆,每条流水线的作业流程都是一样的,分别是清洗、切片、烘焙、分发和装桶。其中,分发环节用于区分小、中、大号 3 种薯片,3种不同尺寸的薯片分别被发往第1、2、3条流水线。具体流程如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/6e/3c7ff059e50f3dc70bf4yy082c31956e.jpg" alt="" title="RDD的生活化类比">
|
||||
|
||||
看得出来,这家工坊制作工艺虽然简单,倒也蛮有章法。从头至尾,除了分发环节,3 条流水线没有任何交集。在分发环节之前,每条流水线都是专心致志、各顾各地开展工作:把土豆食材加载到流水线上,再进行清洗、切片、烘焙;在分发环节之后,3 条流水线也是各自装桶,互不干涉、互不影响。流水线的作业方式提供了较强的容错能力,如果某个加工环节出错,工人们只需要往出错的流水线上重新加载一颗新的土豆,整个流水线就能够恢复生产。
|
||||
|
||||
好了,故事讲完了。如果我们把每一条流水线看作是分布式运行环境的计算节点,用薯片生产的流程去类比Spark分布式计算,会有哪些有趣的发现呢?
|
||||
|
||||
仔细观察,我们发现:**刚从地里挖出来的土豆食材、清洗过后的干净土豆、生薯片、烤熟的薯片,流水线上这些食材的不同形态,就像是Spark中RDD对于不同数据集合的抽象**。
|
||||
|
||||
沿着流水线的纵深方向,也就是图中从左向右的方向,每一种食材形态都是在前一种食材之上用相应的加工方法进行处理得到的。**每种食材形态都依赖于前一种食材,这就像是RDD中dependencies属性记录的依赖关系,而不同环节的加工方法,对应的刚好就是RDD的compute属性。**
|
||||
|
||||
横看成岭侧成峰,再让我们从横向的角度来重新审视上面的土豆加工流程,也就是图中从上至下的方向,让我们把目光集中在流水线开端那3颗带泥的土豆上。这3颗土豆才从地里挖出来,是原始的食材形态,正等待清洗。如图所示,我们把这种食材形态标记为potatosRDD,那么,**这里的每一颗土豆就是RDD中的数据分片,3颗土豆一起对应的就是RDD的partitions属性**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a2/c8/a2f8bc7bf10c31fedb85196f33f44fc8.jpg" alt="">
|
||||
|
||||
带泥土豆经过清洗、切片和烘焙之后,按照大小个儿被分发到下游的3条流水线上,这3条流水线上承载的RDD记为shuffledBakedChipsRDD。很明显,这个RDD对于partitions的划分是有讲究的,根据尺寸的不同,即食薯片会被划分到不同的数据分片中。**像这种数据分片划分规则,对应的就是RDD中的partitioner属性。** 在分布式运行环境中,partitioner属性定义了RDD所封装的分布式数据集如何划分成数据分片。
|
||||
|
||||
总的来说,我们发现,薯片生产的流程和Spark分布式计算是一一对应的,一共可以总结为6点:
|
||||
|
||||
- 土豆工坊的每条流水线就像是分布式环境中的计算节点;
|
||||
- 不同的食材形态,如带泥的土豆、土豆切片、烘烤的土豆片等等,对应的就是RDD;
|
||||
- 每一种食材形态都会依赖上一种形态,如烤熟的土豆片依赖上一个步骤的生土豆切片。这种依赖关系对应的就是RDD中的dependencies属性;
|
||||
- 不同环节的加工方法对应RDD的compute属性;
|
||||
- 同一种食材形态在不同流水线上的具体实物,就是RDD的partitions属性;
|
||||
- 食材按照什么规则被分配到哪条流水线,对应的就是RDD的partitioner属性。
|
||||
|
||||
不知道土豆工坊的类比,有没有帮你逐渐勾勒出RDD的本来面貌呢?话付前言,接下来,咱们来一本正经地聊聊RDD。
|
||||
|
||||
### RDD的核心特征和属性
|
||||
|
||||
通过刚才的例子,我们知道RDD具有4大属性,**分别是partitions、partitioner、dependencies和compute属性。正因为有了这4大属性的存在,让RDD具有分布式和容错性这两大最突出的特性。**要想深入理解RDD,我们不妨从它的核心特性和属性入手。
|
||||
|
||||
**首先,我们来看partitions、partitioner属性。**
|
||||
|
||||
在分布式运行环境中,RDD封装的数据在物理上散落在不同计算节点的内存或是磁盘中,这些散落的数据被称“数据分片”,RDD的分区规则决定了哪些数据分片应该散落到哪些节点中去。RDD的partitions属性对应着RDD分布式数据实体中所有的数据分片,而partitioner属性则定义了划分数据分片的分区规则,如按哈希取模或是按区间划分等。
|
||||
|
||||
不难发现,partitions和partitioner属性刻画的是RDD在跨节点方向上的横向扩展,所以我们把它们叫做RDD的“横向属性”。
|
||||
|
||||
**然后,我们再来说说dependencies和compute属性。**
|
||||
|
||||
在Spark中,任何一个 RDD 都不是凭空产生的,每个 RDD 都是基于某种计算逻辑从某个“数据源”转换而来。RDD的dependencies属性记录了生成RDD 所需的“数据源”,术语叫做父依赖(或父RDD),compute方法则封装了从父 RDD到当前RDD转换的计算逻辑。
|
||||
|
||||
基于数据源和转换逻辑,无论RDD有什么差池(如节点宕机造成部分数据分片丢失),在dependencies属性记录的父RDD之上,都可以通过执行compute封装的计算逻辑再次得到当前的RDD,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/91/fba28ce0c70b4c5553505911663aa491.jpg" alt="" title="基于dependencies和compute属性得到当前RDD">
|
||||
|
||||
由dependencies和compute属性提供的容错能力,为Spark分布式内存计算的稳定性打下了坚实的基础,这也正是RDD命名中Resilient的由来。接着观察上图,我们不难发现,不同的RDD通过dependencies和compute属性链接在一起,逐渐向纵深延展,构建了一张越来越深的有向无环图,也就是我们常说的DAG。
|
||||
|
||||
由此可见,dependencies属性和compute属性负责RDD在纵深方向上的延展,因此我们不妨把这两个属性称为“纵向属性”。
|
||||
|
||||
总的来说,**RDD的4大属性又可以划分为两类,横向属性和纵向属性。其中,横向属性锚定数据分片实体,并规定了数据分片在分布式集群中如何分布;纵向属性用于在纵深方向构建DAG,通过提供重构RDD的容错能力保障内存计算的稳定性**。
|
||||
|
||||
同时,为了帮助你记忆,我把这4大核心属性的基本概念和分类总结在了如下的表格中,你可以看一看。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/1e/ca6ef660c2b7f3777e244a535020191e.jpeg" alt="">
|
||||
|
||||
除此之外,我还想再多说两句。在这节课开头的反例中,我们分析了开发者采用foreach语句循环遍历分布式数据集的深层次原因。**这种不假思索地直入面向过程编程、忽略或无视分布式数据实体的编程模式,我将其称为单机思维模式**。
|
||||
|
||||
在学习了RDD横向的partitions属性和纵向的dependencies属性之后,如果你能把它们牢记于心,那么在频繁调用或引用这个RDD之前,你自然会想到它所囊括的数据集合,很有可能在全节点范围内被反复扫描、反复计算。这种下意识的反思会驱使你尝试探索其他更优的实现方式,从而跳出单机思维模式。因此,**深入理解RDD,也有利于你跳出单机思维模式,避免在应用代码中留下性能隐患**。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我带你学习了RDD的重要性,以及它的2大核心特性和4大属性。
|
||||
|
||||
首先,深入理解RDD对开发者来说有百利而无一害,原因有如下3点:
|
||||
|
||||
- Spark很多核心概念都衍生自RDD,弄懂RDD,有利于你全面地学习Spark;
|
||||
- 牢记RDD的关键特性和核心属性,有利于你在运行时更好地定位性能瓶颈,而瓶颈定位,恰恰是性能调优的前提;
|
||||
- **深入理解RDD有利于你跳出单机思维模式,避免在应用代码中留下性能隐患。**
|
||||
|
||||
关于RDD的特性与核心属性,只要你把如下2点牢记于心,我相信在不知不觉中你自然会绕过很多性能上的坑:
|
||||
|
||||
- 横向属性partitions和partitioner锚定数据分片实体,并且规定了数据分片在分布式集群中如何分布;
|
||||
- 纵向属性dependencies和compute用于在纵深方向构建DAG,通过提供重构RDD的容错能力保障内存计算的稳定性。
|
||||
|
||||
## 每日一练
|
||||
|
||||
<li>
|
||||
在日常的开发工作中,你遇到过“单机思维模式”吗?有哪些呢?
|
||||
</li>
|
||||
<li>
|
||||
除了我们今天讲的4大属性,RDD还有个很重要的属性:preferredLocations。按照经验,你认为在哪些情况下,preferredLocations很重要,会提升I/O效率,又在哪些环境中不起作用呢?为什么?
|
||||
</li>
|
||||
|
||||
期待在留言区看到你的思考,也欢迎你分享工作中遇到过的“单机思维模式”,我们下节课见!
|
||||
125
极客时间专栏/geek/Spark性能调优实战/原理篇/04 | DAG与流水线:到底啥叫“内存计算”?.md
Normal file
125
极客时间专栏/geek/Spark性能调优实战/原理篇/04 | DAG与流水线:到底啥叫“内存计算”?.md
Normal file
@@ -0,0 +1,125 @@
|
||||
<audio id="audio" title="04 | DAG与流水线:到底啥叫“内存计算”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/36/d0/36c661ed2b3bd4e4ac5248a09db312d0.mp3"></audio>
|
||||
|
||||
你好,我是吴磊。
|
||||
|
||||
在日常的开发工作中,我发现有两种现象很普遍。
|
||||
|
||||
第一种是缓存的滥用。无论是RDD,还是DataFrame,凡是能产生数据集的地方,开发同学一律用cache进行缓存,结果就是应用的执行性能奇差无比。开发同学也很委屈:“Spark不是内存计算的吗?为什么把数据缓存到内存里去,性能反而更差了?”
|
||||
|
||||
第二种现象是关于Shuffle的。我们都知道,Shuffle是Spark中的性能杀手,在开发应用时要尽可能地避免Shuffle操作。不过据我观察,很多初学者都没有足够的动力去重构代码来避免Shuffle,这些同学的想法往往是:“能把业务功能实现就不错了,费了半天劲去重写代码就算真的消除了Shuffle,能有多大的性能收益啊。”
|
||||
|
||||
以上这两种现象可能大多数人并不在意,但往往这些细节才决定了应用执行性能的优劣。在我看来,造成这两种现象的根本原因就在于,开发者对Spark内存计算的理解还不够透彻。所以今天,我们就来说说Spark的内存计算都有哪些含义?
|
||||
|
||||
## 第一层含义:分布式数据缓存
|
||||
|
||||
一提起Spark的“内存计算”的含义,你的第一反应很可能是:Spark允许开发者将分布式数据集缓存到计算节点的内存中,从而对其进行高效的数据访问。没错,这就是内存计算的**第一层含义:众所周知的分布式数据缓存。**
|
||||
|
||||
RDD cache确实是Spark分布式计算引擎的一大亮点,也是对业务应用进行性能调优的诸多利器之一,很多技术博客甚至是Spark官网,都在不厌其烦地强调RDD cache对于应用执行性能的重要性。
|
||||
|
||||
正因为考虑到这些因素,很多开发者才会在代码中不假思索地滥用cache机制,也就是我们刚刚提到的第一个现象。但是,这些同学都忽略了一个重要的细节:只有需要频繁访问的数据集才有必要cache,对于一次性访问的数据集,cache不但不能提升执行效率,反而会产生额外的性能开销,让结果适得其反。
|
||||
|
||||
之所以会忽略这么重要的细节,背后深层次的原因在于,开发者对内存计算的理解仅仅停留在缓存这个层面。因此,当业务应用的执行性能出现问题时,只好死马当活马医,拼命地抓住cache这根救命稻草,结果反而越陷越深。
|
||||
|
||||
接下来,我们就重点说说内存计算的第二层含义:**Stage内部的流水线式计算模式。**
|
||||
|
||||
**在Spark中,内存计算有两层含义:第一层含义就是众所周知的分布式数据缓存,第二层含义是Stage内的流水线式计算模式**。关于RDD缓存的工作原理,我会在后续的课程中为你详细介绍,今天咱们重点关注内存计算的第二层含义就可以了。
|
||||
|
||||
## 第二层含义:Stage内的流水线式计算模式
|
||||
|
||||
很显然,要弄清楚内存计算的第二层含义,咱们得从DAG的Stages划分说起。在这之前,我们先来说说什么是DAG。
|
||||
|
||||
### 什么是DAG?
|
||||
|
||||
DAG全称Direct Acyclic Graph,中文叫有向无环图。顾名思义,DAG 是一种“图”。我们知道,任何一种图都包含两种基本元素:顶点(Vertex)和边(Edge),顶点通常用于表示实体,而边则代表实体间的关系。**在Spark的DAG中,顶点是一个个RDD,边则是RDD之间通过dependencies属性构成的父子关系。**
|
||||
|
||||
从理论切入去讲解DAG,未免枯燥乏味,所以我打算借助上一讲土豆工坊的例子,来帮助你直观地认识DAG。上一讲,土豆工坊成功地实现了同时生产 3 种不同尺寸的桶装“原味”薯片。但是,在将“原味”薯片推向市场一段时间以后,工坊老板发现季度销量直线下滑,不由得火往上撞、心急如焚。此时,工坊的工头儿向他建议:“老板,咱们何不把流水线稍加改造,推出不同风味的薯片,去迎合市场大众的多样化选择?”然后,工头儿把改装后的效果图交给老板,老板看后甚是满意。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/86/3a7f115eaa6c2c307f80e3616e7e9c86.jpg" alt="" title="土豆工坊流水线效果图">
|
||||
|
||||
不过,改造流水线可是个大工程,为了让改装工人能够高效协作,工头儿得把上面的改造设想抽象成一张施工流程图。有了这张蓝图,工头儿才能给负责改装的工人们分工,大伙儿才能拧成一股绳、劲儿往一处使。在上一讲中,我们把食材形态类比成RDD,把相邻食材形态的关系看作是RDD间的依赖,那么显然,流水线的施工流程图就是DAG。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/75/25a9c00533032886c00c23a351ac9a75.jpg" alt="" title="DAG:土豆工坊流水线的设计流程图">
|
||||
|
||||
因为DAG中的每一个顶点都由RDD构成,对应到上图中就是带泥的土豆potatosRDD,清洗过的土豆cleanedPotatosRDD,以及调料粉flavoursRDD等等。DAG的边则标记了不同RDD之间的依赖与转换关系。很明显,上图中DAG的每一条边都有指向性,而且整张图不存在环结构。
|
||||
|
||||
那DAG是怎么生成的呢?
|
||||
|
||||
我们都知道,在Spark的开发模型下,应用开发实际上就是灵活运用算子实现业务逻辑的过程。开发者在分布式数据集如RDD、 DataFrame或Dataset之上调用算子、封装计算逻辑,这个过程会衍生新的子RDD。与此同时,子RDD会把dependencies属性赋值到父RDD,把compute属性赋值到算子封装的计算逻辑。以此类推,在子RDD之上,开发者还会继续调用其他算子,衍生出新的RDD,如此往复便有了DAG。
|
||||
|
||||
因此,**从开发者的视角出发,DAG的构建是通过在分布式数据集上不停地调用算子来完成的**。
|
||||
|
||||
### Stages的划分
|
||||
|
||||
现在,我们知道了什么是DAG,以及DAG是如何构建的。不过,DAG毕竟只是一张流程图,Spark需要把这张流程图转化成分布式任务,才能充分利用分布式集群并行计算的优势。这就好比土豆工坊的施工流程图毕竟还只是蓝图,是工头儿给老板画的一张“饼”,工头儿得想方设法把它转化成实实在在的土豆加工流水线,让流水线能够源源不断地生产不同风味的薯片,才能解决老板的燃眉之急。
|
||||
|
||||
简单地说,从开发者构建DAG,到DAG转化的分布式任务在分布式环境中执行,其间会经历如下4个阶段:
|
||||
|
||||
- 回溯DAG并划分Stages
|
||||
- 在Stages中创建分布式任务
|
||||
- 分布式任务的分发
|
||||
- 分布式任务的执行
|
||||
|
||||
刚才我们说了,内存计算的第二层含义在stages内部,因此这一讲我们只要搞清楚DAG是怎么划分Stages就够了。至于后面的3个阶段更偏向调度系统的范畴,所以我会在下一讲给你讲清楚其中的来龙去脉。
|
||||
|
||||
如果用一句话来概括从DAG到Stages的转化过程,那应该是:**以Actions算子为起点,从后向前回溯DAG,以Shuffle操作为边界去划分Stages**。
|
||||
|
||||
接下来,我们还是以土豆工坊为例来详细说说这个过程。既然DAG是以Shuffle为边界去划分Stages,我们不妨先从上帝视角出发,看看在土豆工坊设计流程图的DAG中,都有哪些地方需要执行数据分发的操作。当然,在土豆工坊,数据就是各种形态的土豆和土豆片儿。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/5f/3fcb3e400db91198a7499c016ccfb45f.jpg" alt="" title="DAG以Shuffle为边界划分出3个Stages">
|
||||
|
||||
仔细观察上面的设计流程图,我们不难发现,有两个地方需要分发数据。第一个地方是薯片经过烘焙烤熟之后,把即食薯片按尺寸大小分发到下游的流水线上,这些流水线会专门处理固定型号的薯片,也就是图中从bakedChipsRDD到flavouredBakedChipsRDD的那条线。同理,不同的调料粉也需要按照风味的不同分发到下游的流水线上,用于和固定型号的即食薯片混合,也就是图中从flavoursRDD到flavouredBakedChipsRDD那条分支。
|
||||
|
||||
同时,我们也能发现,土豆工坊的DAG应该划分3个Stages出来,如图中所示。其中,Stage 0包含四个RDD,从带泥土豆potatosRDD到即食薯片bakedChipsRDD。Stage 1比较简单,它只有一个RDD,就是封装调味粉的flavoursRDD。Stage 2包含两个RDD,一个是加了不同风味的即食薯片flavouredBakedChipsRDD,另一个表示组装成桶已经准备售卖的桶装薯片bucketChipsRDD。
|
||||
|
||||
你可能会问:“费了半天劲,把DAG变成Stages有啥用呢?”还真有用!内存计算的第二层含义,就隐匿于从DAG划分出的一个又一个Stages之中。不过,要弄清楚Stage内的流水线式计算模式,我们还是得从Hadoop MapReduce的计算模型说起。
|
||||
|
||||
### Stage中的内存计算
|
||||
|
||||
基于内存的计算模型并不是凭空产生的,而是根据前人的教训和后人的反思精心设计出来的。这个前人就是Hadoop MapReduce,后人自然就是Spark。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/d7/fbb396536260f43c8764a8e6452a4fd7.jpg" alt="" title="Hadoop MapReduce的计算模型">
|
||||
|
||||
MapReduce提供两类计算抽象,分别是Map和Reduce:Map抽象允许开发者通过实现map 接口来定义数据处理逻辑;Reduce抽象则用于封装数据聚合逻辑。MapReduce计算模型最大的问题在于,所有操作之间的数据交换都以磁盘为媒介。例如,两个Map操作之间的计算,以及Map与Reduce操作之间的计算都是利用本地磁盘来交换数据的。不难想象,这种频繁的磁盘I/O必定会拖累用户应用端到端的执行性能。
|
||||
|
||||
那么,这和Stage内的流水线式计算模式有啥关系呢?我们再回到土豆工坊的例子中,把目光集中在即食薯片分发之前,也就是刚刚划分出来的Stage 0。这一阶段包含3个处理操作,即清洗、切片和烘焙。按常理来说,流水线式的作业方式非常高效,带泥土豆被清洗过后,会沿着流水线被传送到切片机,切完的生薯片会继续沿着流水线再传送到烘焙烤箱,整个过程一气呵成。如果把流水线看作是计算节点内存的话,那么清洗、切片和烘焙这3个操作都是在内存中完成计算的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/3b/6e9863b69aca6072b81e6d8e6826903b.jpg" alt="" title="Stage 0包含清洗、切片、烘焙3个操作">
|
||||
|
||||
你可能会说:“内存计算也不过如此,跟MapReduce相比,不就是把数据和计算都挪到内存里去了吗?”事情可能并没有你想象的那么简单。
|
||||
|
||||
在土豆工坊的例子里,Stage 0中的每个加工环节都会生产出中间食材,如清洗过的土豆、土豆片、即食薯片。我们刚刚把流水线比作内存,这意味着每一个算子计算得到的中间结果都会在内存中缓存一份,以备下一个算子运算,这个过程与开发者在应用代码中滥用RDD cache简直如出一辙。如果你曾经也是逢RDD便cache,应该不难想象,采用这种计算模式,Spark的执行性能不见得比MapReduce强多少,尤其是在Stages中的算子数量较多的时候。
|
||||
|
||||
既然不是简单地把数据和计算挪到内存,那Stage内的流水线式计算模式到底长啥样呢?在Spark中,**流水线计算模式指的是:在同一Stage内部,所有算子融合为一个函数,Stage的输出结果由这个函数一次性作用在输入数据集而产生**。这也正是内存计算的第二层含义。下面,我们用一张图来直观地解释这一计算模式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/03/03052d8fc98dcf1740ec4a7c29234403.jpg" alt="" title="内存计算的第二层含义">
|
||||
|
||||
如图所示,在上面的计算流程中,如果你把流水线看作是内存,每一步操作过后都会生成临时数据,如图中的clean和slice,这些临时数据都会缓存在内存里。但在下面的内存计算中,所有操作步骤如clean、slice、bake,都会被捏合在一起构成一个函数。这个函数一次性地作用在“带泥土豆”上,直接生成“即食薯片”,在内存中不产生任何中间数据形态。
|
||||
|
||||
**因此你看,所谓内存计算,不仅仅是指数据可以缓存在内存中,更重要的是让我们明白了,通过计算的融合来大幅提升数据在内存中的转换效率,进而从整体上提升应用的执行性能。**
|
||||
|
||||
这个时候,我们就可以回答开头提出的第二个问题了:费劲去重写代码、消除Shuffle,能有多大的性能收益?
|
||||
|
||||
由于计算的融合只发生在Stages内部,而Shuffle是切割Stages的边界,因此一旦发生Shuffle,内存计算的代码融合就会中断。但是,当我们对内存计算有了多方位理解以后,就不会一股脑地只想到用cache去提升应用的执行性能,而是会更主动地想办法尽量避免Shuffle,让应用代码中尽可能多的部分融合为一个函数,从而提升计算效率。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲,我们以两个常见的现象为例,探讨了Spark内存计算的含义。
|
||||
|
||||
在Spark中,内存计算有两层含义:第一层含义就是众所周知的分布式数据缓存,第二层含义是Stage内的流水线式计算模式。
|
||||
|
||||
对于第二层含义,我们需要先搞清楚DAG和Stages划分,从开发者的视角出发,DAG的构建是通过在分布式数据集上不停地调用算子来完成的,DAG以Actions算子为起点,从后向前回溯,以Shuffle操作为边界,划分出不同的Stages。
|
||||
|
||||
最后,我们归纳出内存计算更完整的第二层含义:同一Stage内所有算子融合为一个函数,Stage的输出结果由这个函数一次性作用在输入数据集而产生。
|
||||
|
||||
## 每日一练
|
||||
|
||||
今天的内容重在理解,我希望你能结合下面两道思考题来巩固一下。
|
||||
|
||||
<li>
|
||||
我们今天说了,DAG以Shuffle为边界划分Stages,那你知道Spark是根据什么来判断一个操作是否会引入Shuffle的呢?
|
||||
</li>
|
||||
<li>
|
||||
在Spark中,同一Stage内的所有算子会融合为一个函数。你知道这一步是怎么做到的吗?
|
||||
</li>
|
||||
|
||||
期待在留言区看到你的思考和答案,如果对内存计算还有很多困惑,也欢迎你写在留言区,我们下一讲见!
|
||||
215
极客时间专栏/geek/Spark性能调优实战/原理篇/05 | 调度系统:“数据不动代码动”到底是什么意思?.md
Normal file
215
极客时间专栏/geek/Spark性能调优实战/原理篇/05 | 调度系统:“数据不动代码动”到底是什么意思?.md
Normal file
@@ -0,0 +1,215 @@
|
||||
<audio id="audio" title="05 | 调度系统:“数据不动代码动”到底是什么意思?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/31/d0/316b87552e53e2338e78bbd42c8fe2d0.mp3"></audio>
|
||||
|
||||
你好,我是吴磊。
|
||||
|
||||
在日常的开发与调优工作中,为了充分利用硬件资源,我们往往需要手工调节任务并行度来提升CPU利用率,控制任务并行度的参数是Spark的配置项:spark.default.parallelism。增加并行度确实能够充分利用闲置的CPU线程,但是,parallelism数值也不宜过大,过大反而会引入过多的调度开销,得不偿失。
|
||||
|
||||
这个调优技巧可以说是老生常谈了,网上到处都可以搜得到。那你知道为什么parallelism数值过大调度开销会呈指数级增长吗?调度开销具体又是指什么呢?以及,如果不想一个数值一个数值的尝试,parallelism数值究竟该怎么设置,才能以最少的时间获得最好的效果?如果你还没有答案,或者说还没有把握答对,接下来你就要好好听我讲。
|
||||
|
||||
这一讲,我会通过一个机器学习案例,来和你一起聊聊调度系统是什么,它是怎么工作的,从而帮助你摆脱调优总是停留在知其然、不知其所以然的尴尬境地。
|
||||
|
||||
## 案例:对用户兴趣特征做Label Encoding
|
||||
|
||||
在机器学习应用中,特征工程几乎占据了算法同学80%的时间和精力,毕竟,一份质量优良的训练样本限定了模型效果的上限和天花板,我们要讲的案例就来自特征工程中一个典型的处理场景:Label Encoding(标签编码)。
|
||||
|
||||
什么是Label encoding呢?模型特征按照是否连续可以分为两类:连续性数值特征和离散型特征,离散型特征往往以字符串的形式存在,比如用户兴趣特征就包括体育、政治、军事和娱乐等。对于很多机器学习算法来说,字符串类型的数据是不能直接消费的,需要转换为数值才行,例如把体育、政治、军事、娱乐映射为0、1、2、3,这个过程在机器学习领域有个术语就叫Label encoding。
|
||||
|
||||
我们这一讲的案例,就是要对用户兴趣特征做Label encoding,简单来说就是以固定的模板把字符串转换为数值,然后将千亿条样本中的用户兴趣转换为对应的索引值。固定模板是离线模型训练与线上模型服务之间的文件接口,内容仅包含用户兴趣这一列,字符串已按事先约定好的规则进行排序。我们需要注意的是,用户兴趣包含4个层级,因此这个模板文件较大,记录数达到万级别。
|
||||
|
||||
```
|
||||
//模板文件
|
||||
//用户兴趣
|
||||
体育-篮球-NBA-湖人
|
||||
军事-武器-步枪-AK47
|
||||
|
||||
```
|
||||
|
||||
那具体怎么转换呢?例如,我们可以将用户兴趣“体育-篮球-NBA-湖人”映射为0,将兴趣“军事-武器-步枪-AK47”映射为1,以此类推。应该说,需求还是相当明确的,我身边的同学们拿到需求之后,奔儿都没打,以迅雷不及掩耳之势就实现了如下的处理函数。
|
||||
|
||||
```
|
||||
/**
|
||||
实现方式1
|
||||
输入参数:模板文件路径,用户兴趣字符串
|
||||
返回值:用户兴趣字符串对应的索引值
|
||||
*/
|
||||
|
||||
//函数定义
|
||||
def findIndex(templatePath: String, interest: String): Int = {
|
||||
val source = Source.fromFile(filePath, "UTF-8")
|
||||
val lines = source.getLines().toArray
|
||||
source.close()
|
||||
val searchMap = lines.zip(0 until lines.size).toMap
|
||||
searchMap.getOrElse(interest, -1)
|
||||
}
|
||||
|
||||
//Dataset中的函数调用
|
||||
findIndex(filePath, "体育-篮球-NBA-湖人")
|
||||
|
||||
```
|
||||
|
||||
我们可以看到这个函数有两个形参,一个是模板文件路径,另一个是训练样本中的用户兴趣。处理函数首先读取模板文件,然后根据文件中排序的字符串构建一个从兴趣到索引的Map映射,最后在这个Map中查找第二个形参传入的用户兴趣,如果能找到则返回对应的索引,找不到的话则返回-1。
|
||||
|
||||
这段代码看上去似乎没什么问题,同学们基于上面的函数对千亿样本做Label encoding,在20台机型为C5.4xlarge AWS EC2的分布式集群中花费了5个小时。坦白说,这样的执行性能,我是不能接受的。你可能会说:“需求就是这个样子,还能有什么别的办法呢?”我们不妨来看另外一种实现方式。
|
||||
|
||||
```
|
||||
/**
|
||||
实现方式2
|
||||
输入参数:模板文件路径,用户兴趣字符串
|
||||
返回值:用户兴趣字符串对应的索引值
|
||||
*/
|
||||
|
||||
//函数定义
|
||||
val findIndex: (String) => (String) => Int = {
|
||||
(filePath) =>
|
||||
val source = Source.fromFile(filePath, "UTF-8")
|
||||
val lines = source.getLines().toArray
|
||||
source.close()
|
||||
val searchMap = lines.zip(0 until lines.size).toMap
|
||||
(interest) => searchMap.getOrElse(interest, -1)
|
||||
}
|
||||
val partFunc = findIndex(filePath)
|
||||
|
||||
//Dataset中的函数调用
|
||||
partFunc("体育-篮球-NBA-湖人")
|
||||
|
||||
```
|
||||
|
||||
同学们基于第二种方式对相同的数据集做Label encoding之后,在10台同样机型的分布式集群中花了不到20分钟就把任务跑完了。可以说,执行性能的提升是显而易见的。那么,两份代码有什么区别呢?
|
||||
|
||||
我们可以看到,相比于第一份代码,第二份代码的函数体内没有任何变化,还是先读取模板文件、构建Map映射、查找用户兴趣,最后返回索引。最大的区别就是第二份代码对高阶函数的使用,具体来说有2点:
|
||||
|
||||
1. 处理函数定义为高阶函数,形参是模板文件路径,返回结果是从用户兴趣到索引的函数;
|
||||
1. 封装千亿样本的Dataset所调用的函数,不是第一份代码中的findIndex,而是用模板文件调用findIndex得到的partFunc,partFunc是形参为兴趣、结果为索引的普通标量函数。
|
||||
|
||||
那么,高阶函数真有这么神奇吗?其实,性能的提升并不是高阶函数的功劳,而是调度系统在起作用。
|
||||
|
||||
## Spark的调度系统是如何工作的?
|
||||
|
||||
Spark调度系统的核心职责是,**先将用户构建的DAG转化为分布式任务,结合分布式集群资源的可用性,基于调度规则依序把分布式任务分发到执行器**。这个过程听上去就够复杂的了,为了方便你理解,我们还是先来讲一个小故事。
|
||||
|
||||
### 土豆工坊流水线升级
|
||||
|
||||
在学完了内存计算的第二层含义之后,土豆工坊的老板决定对土豆加工流水线做升级,来提高工坊的生产效率和灵活性。
|
||||
|
||||
这里,我们先对内存计算的第二层含义做个简单地回顾,它指的是**同一Stage中的所有操作会被捏合为一个函数,这个函数一次性会被地应用到输入数据上,并且一次性地产生计算结果**。
|
||||
|
||||
升级之前的土豆加工流程DAG被切分为3个执行阶段Stage,它们分别是Stage 0、Stage 1、Stage 2。其中,Stage 0产出即食薯片,Stage 1分发调味品,Stage 2则产出不同尺寸、不同风味的薯片。我们重点关注Stage 0,Stage 0有3个加工环节,分别是清洗、切片和烘焙。这3个环节需要3种不同的设备,即清洗机、切片机和烤箱。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/5f/3fcb3e400db91198a7499c016ccfb45f.jpg" alt="" title="土豆工坊加工流程的3个执行阶段">
|
||||
|
||||
工坊有3条流水线,每种设备都需要3套,在成本方面要花不少钱呢,因此工坊老板一直绞尽脑汁想把设备方面的成本降下来。
|
||||
|
||||
此时,工头儿建议:“老板,我听说市场上有一种可编程的土豆加工设备,它是个黑盒子并且只有输入口和输出口,从外面看不见里面的操作流程。不过黑盒子受程序控制,给定输入口的食材,我们可以编写程序控制黑盒子的输出。有了这个可编程设备,咱们不但省了钱,将来还可以灵活地扩充产品线。比方想生产各种风味的薯条或是土豆泥,只需要更换一份程序加载到黑盒子里就行啦!”
|
||||
|
||||
老板听后大喜,决定花钱购入可编程土豆加工设备,替换并淘汰现有的清洗机、切片机和烤箱。
|
||||
|
||||
于是,工坊的加工流水线就变成了如下的样子。工人们的工作也从按照DAG流程图的关键步骤,在流水线上安装相应的设备,变成了把关键步骤编写相应的程序加载到黑盒内。这样一来,这家工坊的生产力也从作坊式的生产方式,升级到了现代化流水线的作业模式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/46/dc4f5f39a166ca93080c5a7c0ea0d446.jpg" alt="" title="演进的土豆加工流水线">
|
||||
|
||||
那么,这个故事跟我们今天要讲的调度系统有什么关系呢?事实上,Spark调度系统的工作流程包含如下5个步骤:
|
||||
|
||||
**1. 将DAG拆分为不同的运行阶段Stages;**<br>
|
||||
**2. 创建分布式任务Tasks和任务组TaskSet;**<br>
|
||||
**3. 获取集群内可用<strong><strong>的**</strong>硬件资源情况;</strong><br>
|
||||
**4. 按照调度规则决定优先调度哪些任务/组;**<br>
|
||||
**5. 依序将分布式任务分发到执行器Executor。**
|
||||
|
||||
除了第4步以外,其他几步和土豆工坊流水线上的关键步骤都是一一对应的,它们的对应关系如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/83/635462108ee2fc09991708f0856bcb83.jpg" alt="">
|
||||
|
||||
现在,你可能会觉得用故事来记这几个步骤好像多此一举,但当我们学完了所有的原理之后,再回过头来把故事的主线串联起来,你就会惊喜地发现,所有的原理你都能轻松地记住和理解,这可比死记硬背的效率要高得多。
|
||||
|
||||
### 调度系统中的核心组件有哪些?
|
||||
|
||||
接下来,我们深入到流程中的每一步去探究Spark调度系统是如何工作的。不过在此之前,我们得先弄清楚调度系统都包含哪些关键组件,不同组件之间如何交互,它们分别担任了什么角色,才能更好地理解流程中的每一步。
|
||||
|
||||
Spark调度系统包含3个核心组件,分别是DAGScheduler、TaskScheduler和SchedulerBackend。这3个组件都运行在Driver进程中,它们通力合作将用户构建的DAG转化为分布式任务,再把这些任务分发给集群中的Executors去执行。不过,它们的名字都包含Scheduler,光看名字还真是丈二和尚摸不着头脑,所以我把它们和调度系统流程中5个步骤的对应关系总结在了下表中,你可以看一看。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/46/52/46bb66fed5d52b09407d66881cf0df52.jpeg" alt="">
|
||||
|
||||
#### 1. DAGScheduler
|
||||
|
||||
DAGScheduler的主要职责有二:一是把用户DAG拆分为Stages,如果你不记得这个过程可以回顾一下[上一讲](https://time.geekbang.org/column/article/353808)的内容;二是在Stage内创建计算任务Tasks,这些任务囊括了用户通过组合不同算子实现的数据转换逻辑。然后,执行器Executors接收到Tasks,会将其中封装的计算函数应用于分布式数据分片,去执行分布式的计算过程。
|
||||
|
||||
不过,如果我们给集群中处于繁忙或者是饱和状态的Executors分发了任务,执行效果会大打折扣。因此,**在分发任务之前,调度系统得先判断哪些节点的计算资源空闲,然后再把任务分发过去**。那么,调度系统是怎么判断节点是否空闲的呢?
|
||||
|
||||
#### 2. SchedulerBackend
|
||||
|
||||
SchedulerBackend就是用来干这个事的,它是对于资源调度器的封装与抽象,为了支持多样的资源调度模式如Standalone、YARN和Mesos,SchedulerBackend提供了对应的实现类。在运行时,Spark根据用户提供的MasterURL,来决定实例化哪种实现类的对象。MasterURL就是你通过各种方式指定的资源管理器,如--master spark://ip:host(Standalone 模式)、--master yarn(YARN 模式)。
|
||||
|
||||
对于集群中可用的计算资源,SchedulerBackend会用一个叫做ExecutorDataMap的数据结构,来记录每一个计算节点中Executors的资源状态。ExecutorDataMap是一种HashMap,它的Key是标记Executor的字符串,Value是一种叫做ExecutorData的数据结构,ExecutorData用于封装Executor的资源状态,如RPC地址、主机地址、可用CPU核数和满配CPU核数等等,它相当于是对Executor做的“资源画像”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/a9/a7f8d49bbf1f8b0a125ffca87f079aa9.jpg" alt="" title="ExecutorDataMap映射表">
|
||||
|
||||
总的来说,对内,SchedulerBackend用ExecutorData对Executor进行资源画像;对外,SchedulerBackend以WorkerOffer为粒度提供计算资源,WorkerOffer封装了Executor ID、主机地址和CPU核数,用来表示一份可用于调度任务的空闲资源。显然,基于Executor资源画像,SchedulerBackend可以同时提供多个WorkerOffer用于分布式任务调度。WorkerOffer这个名字起得蛮有意思,Offer的字面意思是公司给你提供的工作机会,结合Spark调度系统的上下文,就变成了使用硬件资源的机会。
|
||||
|
||||
好了,到此为止,要调度的计算任务有了,就是DAGScheduler通过Stages创建的Tasks;可用于调度任务的计算资源也有了,即SchedulerBackend提供的一个又一个WorkerOffer。如果从供需的角度看待任务调度,DAGScheduler就是需求端,SchedulerBackend就是供给端。
|
||||
|
||||
#### 3. TaskScheduler
|
||||
|
||||
左边有需求,右边有供给,如果把Spark调度系统看作是一个交易市场的话,那么中间还需要有个中介来帮它们对接意愿、撮合交易,从而最大限度地提升资源配置的效率。在Spark调度系统中,这个中介就是TaskScheduler。**TaskScheduler的职责是,基于既定的规则与策略达成供需双方的匹配与撮合**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/yy/82e86e1b3af101100015bcfd81f0f7yy.jpg" alt="" title="Spark分布式任务调度流程">
|
||||
|
||||
显然,TaskScheduler的核心是任务调度的规则和策略,**TaskScheduler的调度策略分为两个层次,一个是不同Stages之间的调度优先级,一个是Stages内不同任务之间的调度优先级**。
|
||||
|
||||
首先,对于两个或多个Stages,如果它们彼此之间不存在依赖关系、互相独立,在面对同一份可用计算资源的时候,它们之间就会存在竞争关系。这个时候,先调度谁、或者说谁优先享受这份计算资源,大家就得基于既定的规则和协议照章办事了。
|
||||
|
||||
**对于这种Stages之间的任务调度,TaskScheduler提供了2种调度模式,分别是FIFO(先到先得)和FAIR(公平调度)。** FIFO非常好理解,在这种模式下,Stages按照被创建的时间顺序来依次消费可用计算资源。这就好比在二手房交易市场中,两个人同时看中一套房子,不管两个人各自愿意出多少钱,谁最先交定金,中介就优先给谁和卖家撮合交易。
|
||||
|
||||
你可能会说:“这不合常理啊!如果第二个人愿意出更多的钱,卖家自然更乐意和他成交。”没错,考虑到开发者的意愿度,TaskScheduler提供了FAIR公平调度模式。在这种模式下,哪个Stages优先被调度,取决于用户在配置文件fairscheduler.xml中的定义。
|
||||
|
||||
在配置文件中,Spark允许用户定义不同的调度池,每个调度池可以指定不同的调度优先级,用户在开发过程中可以关联不同作业与调度池的对应关系,这样不同Stages的调度就直接和开发者的意愿挂钩,也就能享受不同的优先级待遇。对应到二手房交易的例子中,如果第二个人乐意付30%的高溢价,中介自然乐意优先撮合他与卖家的交易。
|
||||
|
||||
说完了不同Stages之间的调度优先级,我们再来说说同一个Stages内部不同任务之间的调度优先级,Stages内部的任务调度相对来说简单得多。**当TaskScheduler接收到来自SchedulerBackend的WorkerOffer后,TaskScheduler会优先挑选那些满足本地性级别要求的任务进行分发**。众所周知,本地性级别有4种:Process local < Node local < Rack local < Any。从左到右分别是进程本地性、节点本地性、机架本地性和跨机架本地性。从左到右,计算任务访问所需数据的效率越来越差。
|
||||
|
||||
进程本地性表示计算任务所需的输入数据就在某一个Executor进程内,因此把这样的计算任务调度到目标进程内最划算。同理,如果数据源还未加载到Executor进程,而是存储在某一计算节点的磁盘中,那么把任务调度到目标节点上去,也是一个不错的选择。再次,如果我们无法确定输入源在哪台机器,但可以肯定它一定在某个机架上,本地性级别就会退化到Rack local。
|
||||
|
||||
DAGScheduler划分Stages、创建分布式任务的过程中,会为每一个任务指定本地性级别,本地性级别中会记录该任务有意向的计算节点地址,甚至是Executor进程ID。换句话说,**任务自带调度意愿,它通过本地性级别告诉TaskScheduler自己更乐意被调度到哪里去**。
|
||||
|
||||
既然计算任务的个人意愿这么强烈,TaskScheduler作为中间商,肯定要优先满足人家的意愿。这就像一名码农想要租西二旗的房子,但是房产中介App推送的结果都是东三环国贸的房子,那么这个中介的匹配算法肯定有问题。
|
||||
|
||||
由此可见,**Spark调度系统的原则是尽可能地让数据呆在原地、保持不动,同时尽可能地把承载计算任务的代码分发到离数据最近的地方,从而最大限度地降低分布式系统中的网络开销**。毕竟,分发代码的开销要比分发数据的代价低太多,这也正是“数据不动代码动”这个说法的由来。
|
||||
|
||||
总的来说,TaskScheduler根据本地性级别遴选出待计算任务之后,先对这些任务进行序列化。然后,交给SchedulerBackend,SchedulerBackend根据ExecutorData中记录的RPC地址和主机地址,再将序列化的任务通过网络分发到目的主机的Executor中去。最后,Executor接收到任务之后,把任务交由内置的线程池,线程池中的多线程则并发地在不同数据分片之上执行任务中封装的数据处理函数,从而实现分布式计算。
|
||||
|
||||
## 性能调优案例回顾
|
||||
|
||||
知道了调度系统是如何工作的,我们就可以回过头来说说开头Label encoding的开发案例中,2种实现方式的差别到底在哪儿了。我们先来回顾案例中处理函数的主要计算步骤:
|
||||
|
||||
1. 读取并遍历模板文件内容,建立从字符串到数值的字典;
|
||||
1. 根据样本中的用户兴趣,查找字典并返回兴趣字符串对应的数值索引。
|
||||
|
||||
**2种实现方式的本质区别在于,函数中2个计算步骤的分布式计算过程不同。在第1种实现方式中,函数是一个接收两个形参的普通标量函数,Dataset调用这个函数在千亿级样本上做Label encoding。**
|
||||
|
||||
在Spark任务调度流程中,该函数在Driver端交由DAGScheduler打包为Tasks,经过TaskScheduler调度给SchedulerBackend,最后由SchedulerBackend分发到集群中的Executors中去执行。这意味着集群中的每一个Executors都需要执行函数中封装的两个计算步骤,要知道,第一个步骤中遍历文件内容并建立字典的计算开销还是相当大的。
|
||||
|
||||
反观第2种实现方式,2个计算步骤被封装到一个高阶函数中。用户代码先在Driver端用模板文件调用这个高阶函数,完成第一步计算建立字典的过程,同时输出一个只带一个形参的标量函数,这个标量函数内携带了刚刚建好的映射字典。最后,Dataset将这个标量函数作用于千亿样本之上做Label encoding。
|
||||
|
||||
发现区别了吗?在第2种实现中,函数的第一步计算只在Driver端计算一次,分发给集群中所有Executors的任务中封装的是携带了字典的标量函数。然后在Executors端,Executors在各自的数据分片上调用该函数,省去了扫描模板文件、建立字典的开销。最后,我们只需要把样本中的用户兴趣传递进去,函数就能以O(1)的查询效率返回数值结果。
|
||||
|
||||
对于一个有着成百上千Executors的分布式集群来说,这2种不同的实现方式带来的性能差异还是相当可观的。因此,如果你能把Spark调度系统的工作原理牢记于心,我相信在代码开发或是review的过程中,你都能够意识到第一个计算步骤会带来的性能问题。**这种开发过程中的反思,其实就是在潜移默化地建立以性能为导向的开发习惯**。
|
||||
|
||||
## 小结
|
||||
|
||||
今天这一讲,我们先通过一个机器学的案例对比了2种实现方式的性能差异,知道了对于调度系统一知半解,很有可能在开发过程中引入潜在的性能隐患。为此,我梳理了调度系统工作流程的5个主要步骤:
|
||||
|
||||
1. 将DAG拆分为不同的运行阶段Stages;
|
||||
1. 创建分布式任务Tasks和任务组TaskSet;
|
||||
1. 获取集群内可用硬件资源情况;
|
||||
1. 按照调度规则决定优先调度哪些任务/组;
|
||||
1. 依序将分布式任务分发到执行器Executor;
|
||||
|
||||
结合这5个步骤,我们深入分析了Spark调度系统的工作原理,我们可以从核心职责和核心原则这两方面来归纳:
|
||||
|
||||
1. Spark调度系统的核心职责是,先将用户构建的DAG转化为分布式任务,结合分布式集群资源的可用性,基于调度规则依序把分布式任务分发到执行器Executors;
|
||||
1. Spark调度系统的核心原则是,尽可能地让数据呆在原地、保持不动,同时尽可能地把承载计算任务的代码分发到离数据最近的地方(Executors或计算节点),从而最大限度地降低分布式系统中的网络开销。
|
||||
|
||||
## 每日一练
|
||||
|
||||
1. DAGScheduler在创建Tasks的过程中,是如何设置每一个任务的本地性级别?
|
||||
1. 在计算与存储分离的云计算环境中,Node local本地性级别成立吗?你认为哪些情况下成立?哪些情况下不成立?
|
||||
|
||||
期待在留言区看到你的思考和答案,如果你的朋友也正急需搞清楚调度系统的工作原理,也欢迎你把这一讲转发给他,我们下一讲见!
|
||||
126
极客时间专栏/geek/Spark性能调优实战/原理篇/06 | 存储系统:空间换时间,还是时间换空间?.md
Normal file
126
极客时间专栏/geek/Spark性能调优实战/原理篇/06 | 存储系统:空间换时间,还是时间换空间?.md
Normal file
@@ -0,0 +1,126 @@
|
||||
<audio id="audio" title="06 | 存储系统:空间换时间,还是时间换空间?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e5/f1/e5f7d4a520fe56df55ba6356abe0f5f1.mp3"></audio>
|
||||
|
||||
你好,我是吴磊。
|
||||
|
||||
今天,我们来学习Spark的存储系统,它和我们上一讲学过的调度系统一样,都是Spark分布式计算引擎的基础设施之一。
|
||||
|
||||
你可能会问:“在日常的开发工作中,除了业务逻辑实现,我真的需要去关心这么底层的东西吗?”确实,存储系统离开发者比较远。不过,如果把目光落在存储系统所服务的对象上,你很可能会改变这种看法。
|
||||
|
||||
接下来,咱们就先来看看Spark 存储系统都为谁服务,再去探讨它有哪些重要组件,以及它是如何工作的,带你一次性摸透存储系统。
|
||||
|
||||
## Spark存储系统是为谁服务的?
|
||||
|
||||
Spark 存储系统用于存储 3个方面的数据,**分别是RDD 缓存、Shuffle 中间文件、广播变量。我们一个一个来说。**
|
||||
|
||||
RDD缓存指的**是将RDD以缓存的形式物化到内存或磁盘的过程**。对于一些计算成本和访问频率都比较高的RDD来说,缓存有两个好处:一是通过截断DAG,可以降低失败重试的计算开销;二是通过对缓存内容的访问,可以有效减少从头计算的次数,从整体上提升作业端到端的执行性能。
|
||||
|
||||
而要说起Shuffle中间文件,我们就不得不提Shuffle这个话题。在很多场景中,Shuffle都扮演着性能瓶颈的角色,解决掉Shuffle引入的问题之后,执行性能往往能有立竿见影的提升。因此,凡是与Shuffle有关的环节,你都需要格外地重视。
|
||||
|
||||
关于Shuffle的工作原理,我们后面会详细来讲。这里,咱们先简单理解一下Shuffle的计算过程就可以了。它的计算过程可以分为2个阶段:
|
||||
|
||||
- **Map阶段**:Shuffle writer按照Reducer的分区规则将中间数据写入本地磁盘;
|
||||
- **Reduce 阶段**:Shuffle reader从各个节点下载数据分片,并根据需要进行聚合计算。
|
||||
|
||||
Shuffle中间文件实际上就是Shuffle Map阶段的输出结果,这些结果会以文件的形式暂存于本地磁盘。在Shuffle Reduce阶段,Reducer通过网络拉取这些中间文件用于聚合计算,如求和、计数等。在集群范围内,Reducer想要拉取属于自己的那部分中间数据,就必须要知道这些数据都存储在哪些节点,以及什么位置。而这些关键的元信息,正是由Spark存储系统保存并维护的。因此你看,**没有存储系统,Shuffle是玩不转的。**
|
||||
|
||||
最后,我们再来说说广播变量。在日常开发中,广播变量往往用于在集群范围内分发访问频率较高的小数据。**利用存储系统,广播变量可以在Executors进程范畴内保存全量数据。**这样一来,对于同一Executors内的所有计算任务,应用就能够以Process local的本地性级别,来共享广播变量中携带的全量数据了。
|
||||
|
||||
总的来说,**这3个服务对象是Spark应用性能调优的有力“抓手”,而它们又和存储系统有着密切的联系,因此想要有效运用这3个方面的调优技巧,我们就必须要对存储系统有足够的理解。**
|
||||
|
||||
## 存储系统的基本组件有哪些?
|
||||
|
||||
与调度系统类似,Spark存储系统是一个囊括了众多组件的复合系统,如BlockManager、BlockManagerMaster、MemoryStore、DiskStore和DiskBlockManager等等。
|
||||
|
||||
不过,家有千口、主事一人,**BlockManager是其中最为重要的组件,它在Executors端负责统一管理和协调数据的本地存取与跨节点传输**。这怎么理解呢?我们可以从2方面来看。
|
||||
|
||||
<li>
|
||||
对外,BlockManager与Driver端的BlockManagerMaster通信,不仅定期向BlockManagerMaster汇报本地数据元信息,还会不定时按需拉取全局数据存储状态。另外,不同Executors的BlockManager之间也会以Server/Client模式跨节点推送和拉取数据块。
|
||||
</li>
|
||||
<li>
|
||||
对内,BlockManager通过组合存储系统内部组件的功能来实现数据的存与取、收与发。
|
||||
</li>
|
||||
|
||||
那么,对于RDD缓存、Shuffle中间文件和广播变量这3个服务对象来说,BlockManager又是如何存储的呢?**Spark存储系统提供了两种存储抽象:MemoryStore和DiskStore。BlockManager正是利用它们来分别管理数据在内存和磁盘中的存取。**
|
||||
|
||||
其中,广播变量的全量数据存储在Executors进程中,因此它由MemoryStore管理。Shuffle中间文件往往会落盘到本地节点,所以这些文件的落盘和访问就要经由DiskStore。相比之下,RDD缓存会稍微复杂一些,由于RDD缓存支持内存缓存和磁盘缓存两种模式,因此我们要视情况而定,缓存在内存中的数据会封装到MemoryStore,缓存在磁盘上的数据则交由DiskStore管理。
|
||||
|
||||
有了MemoryStore和DiskStore,我们暂时解决了数据“存在哪儿”的问题。但是,这些数据该以“什么形式”存储到MemoryStore和DiskStore呢?**对于数据的存储形式,Spark存储系统支持两种类型:对象值(Object Values)和字节数组(Byte Array)**。它们之间可以相互转换,其中,对象值压缩为字节数组的过程叫做序列化,而字节数组还原成原始对象值的过程就叫做反序列化。
|
||||
|
||||
形象点来说,序列化的字节数组就像是从宜家家具超市购买的待组装板材,对象值则是将板材根据说明书组装而成的各种桌椅板凳。显而易见,对象值这种存储形式的优点是拿来即用、所见即所得,缺点是所需的存储空间较大、占地儿。相比之下,序列化字节数组的空间利用率要高得多。不过要是你着急访问里面的数据对象,还得进行反序列化,有点麻烦。
|
||||
|
||||
**由此可见,对象值和字节数组二者之间存在着一种博弈关系,**也就是所谓的“以空间换时间”和“以时间换空间”,两者之间该如何取舍,我们还是要看具体的应用场景。**核心原则就是:如果想省地儿,你可以优先考虑字节数组;如果想以最快的速度访问对象,还是对象值更直接一些。** 不过,这种选择的烦恼只存在于 MemoryStore 之中,而DiskStore只能存储序列化后的字节数组,毕竟,凡是落盘的东西,都需要先进行序列化。
|
||||
|
||||
## 透过RDD缓存看MemoryStore
|
||||
|
||||
知道了存储系统有哪些核心的组件,下面,我们接着来说说MemoryStore和DiskStore这两个组件是怎么管理内存和磁盘数据的。
|
||||
|
||||
刚刚我们提到,**MemoryStore同时支持存储对象值和字节数组这两种不同的数据形式,并且统一采用MemoryEntry数据抽象对它们进行封装**。
|
||||
|
||||
MemoryEntry有两个实现类:DeserializedMemoryEntry和SerializedMemoryEntry,分别用于封装原始对象值和序列化之后的字节数组。DeserializedMemoryEntry用 Array[T]来存储对象值序列,其中T是对象类型,而SerializedMemoryEntry使用ByteBuffer来存储序列化后的字节序列。
|
||||
|
||||
得益于MemoryEntry对于对象值和字节数组的统一封装,MemoryStore能够借助一种高效的数据结构来统一存储与访问数据块:LinkedHashMap[BlockId, MemoryEntry],即 Key 为BlockId,Value 是MemoryEntry的链式哈希字典。在这个字典中,一个Block对应一个MemoryEntry。显然,这里的MemoryEntry既可以是DeserializedMemoryEntry,也可以是 SerializedMemoryEntry。有了这个字典,我们通过BlockId即可方便地查找和定位MemoryEntry,实现数据块的快速存取。
|
||||
|
||||
概念这么多,命名也这么相似,是不是看起来就让人“头大”?别着急,接下来,咱们以RDD缓存为例,来看看存储系统是如何利用这些数据结构,把RDD封装的数据实体缓存到内存里去。
|
||||
|
||||
在RDD的语境下,我们往往用数据分片(Partitions/Splits)来表示一份分布式数据,但在存储系统的语境下,我们经常会用数据块(Blocks)来表示数据存储的基本单元。**在逻辑关系上,RDD的数据分片与存储系统的Block一一对应,也就是说一个RDD数据分片会被物化成一个内存或磁盘上的Block。**
|
||||
|
||||
因此,如果用一句话来概括缓存RDD的过程,就是将RDD计算数据的迭代器(Iterator)进行物化的过程,流程如下所示。具体来说,可以分成三步走。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1y/0b/1yy5fd9f111f4cab0edc7cf582bd2b0b.jpg" alt="" title="利用MemoryStore在内存中缓存RDD数据内容">
|
||||
|
||||
既然要把数据内容缓存下来,自然得先把RDD的迭代器展开成实实在在的数据值才行。因此,**第一步就是通过调用putIteratorAsValues或是putIteratorAsBytes方法,把RDD迭代器展开为数据值,然后把这些数据值暂存到一个叫做ValuesHolder的数据结构里。**这一步,我们通常把它叫做“Unroll”。
|
||||
|
||||
**第二步,为了节省内存开销,我们可以在存储数据值的ValuesHolder上直接调用toArray或是toByteBuffer操作,把ValuesHolder转换为MemoryEntry数据结构。**注意啦,这一步的转换不涉及内存拷贝,也不产生额外的内存开销,因此Spark官方把这一步叫做“从Unroll memory到Storage memory的Transfer(转移)”。
|
||||
|
||||
**第三步,这些包含RDD数据值的MemoryEntry和与之对应的BlockId,会被一起存入Key 为BlockId、Value 是MemoryEntry引用的链式哈希字典中。**因此,LinkedHashMap[BlockId, MemoryEntry]缓存的是关于数据存储的元数据,MemoryEntry才是真正保存RDD数据实体的存储单元。换句话说,大面积占用内存的不是哈希字典,而是一个又一个的MemoryEntry。
|
||||
|
||||
总的来说,RDD数据分片、Block和MemoryEntry三者之间是一一对应的,当所有的RDD数据分片都物化为MemoryEntry,并且所有的(Block ID, MemoryEntry)对都记录到LinkedHashMap字典之后,RDD就完成了数据缓存到内存的过程。
|
||||
|
||||
这里,你可能会问:“如果内存空间不足以容纳整个RDD怎么办?”很简单,强行把大RDD塞进有限的内存空间肯定不是明智之举,所以Spark会按照LRU策略逐一清除字典中最近、最久未使用的Block,以及其对应的MemoryEntry。相比频繁的展开、物化、换页所带来的性能开销,缓存下来的部分数据对于RDD高效访问的贡献可以说微乎其微。
|
||||
|
||||
## 透过Shuffle看DiskStore
|
||||
|
||||
相比MemoryStore,DiskStore就相对简单很多,因为它并不需要那么多的中间数据结构才能完成数据的存取。**DiskStore中数据的存取本质上就是字节序列与磁盘文件之间的转换**,它通过putBytes方法把字节序列存入磁盘文件,再通过getBytes方法将文件内容转换为数据块。
|
||||
|
||||
不过,要想完成两者之间的转换,像数据块与文件的对应关系、文件路径等等这些元数据是必不可少的。MemoryStore采用链式哈希字典来维护类似的元数据,DiskStore这个狡猾的家伙并没有亲自维护这些元数据,而是请了DiskBlockManager这个给力的帮手。
|
||||
|
||||
**DiskBlockManager的主要职责就是,记录逻辑数据块Block与磁盘文件系统中物理文件的对应关系,每个Block都对应一个磁盘文件。**同理,每个磁盘文件都有一个与之对应的Block ID,这就好比货架上的每一件货物都有唯一的 ID 标识。
|
||||
|
||||
DiskBlockManager在初始化的时候,首先根据配置项spark.local.dir在磁盘的相应位置创建文件目录。然后,在spark.local.dir指定的所有目录下分别创建子目录,子目录的个数由配置项spark.diskStore.subDirectories控制,它默认是64。所有这些目录均用于存储通过DiskStore进行物化的数据文件,如RDD缓存文件、Shuffle中间结果文件等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/4f/1eccayy6d9b7348ceea3cf3b12913a4f.jpg" alt="" title="DiskStore 中数据的存与取">
|
||||
|
||||
接下来,我们再以Shuffle中间文件为例,来说说DiskStore与DiskBlockManager的交互过程。
|
||||
|
||||
**Spark默认采用SortShuffleManager来管理Stages间的数据分发,在Shuffle write过程中,有3类结果文件:temp_shuffle_XXX、shuffle_XXX.data和shuffle_XXX.index。**Data文件存储分区数据,它是由temp文件合并而来的,而index文件记录data文件内不同分区的偏移地址。Shuffle中间文件具体指的就是data文件和index文件,temp文件作为暂存盘文件最终会被删除。
|
||||
|
||||
在Shuffle write的不同阶段,Shuffle manager通过BlockManager调用DiskStore的putBytes方法将数据块写入文件。文件由DiskBlockManager创建,文件名就是putBytes方法中的Block ID,这些文件会以“temp_shuffle”或“shuffle”开头,保存在spark.local.dir目录下的子目录里。
|
||||
|
||||
在Shuffle read阶段,Shuffle manager再次通过BlockManager调用DiskStore的getBytes方法,读取data文件和index文件,将文件内容转化为数据块,最终这些数据块会通过网络分发到Reducer端进行聚合计算。
|
||||
|
||||
## 小结
|
||||
|
||||
掌握存储系统是我们进行Spark性能调优的关键一步,我们可以分为三步来掌握。
|
||||
|
||||
第一步,我们要明确存储系统的服务对象,分别是RDD缓存、Shuffle和广播变量。
|
||||
|
||||
- RDD缓存:一些计算成本和访问频率较高的RDD,可以以缓存的形式物化到内存或磁盘中。这样一来,既可以避免DAG频繁回溯的计算开销,也能有效提升端到端的执行性能
|
||||
- Shuffle:Shuffle中间文件的位置信息,都是由Spark存储系统保存并维护的,没有存储系统,Shuffle是玩不转的
|
||||
- 广播变量:利用存储系统,广播变量可以在Executors进程范畴内保存全量数据,让任务以Process local的本地性级别,来共享广播变量中携带的全量数据。
|
||||
|
||||
第二步,我们要搞清楚存储系统的两个重要组件:MemoryStore和DiskStore。其中,MemoryStore用来管理数据在内存中的存取,DiskStore用来管理数据在磁盘中的存取。
|
||||
|
||||
对于存储系统的3个服务对象来说,广播变量由MemoryStore管理,Shuffle中间文件的落盘和访问要经由DiskStore,而RDD缓存因为会同时支持内存缓存和磁盘缓存两种模式,所以两种组件都有可能用到。
|
||||
|
||||
最后,我们要理解MemoryStore和DiskStore的工作原理。
|
||||
|
||||
MemoryStore支持对象值和字节数组,统一采用MemoryEntry数据抽象对它们进行封装。对象值和字节数组二者之间存在着一种博弈关系,所谓的“以空间换时间”和“以时间换空间”,两者的取舍还要看具体的应用场景。
|
||||
|
||||
DiskStore则利用DiskBlockManager维护的数据块与磁盘文件的对应关系,来完成字节序列与磁盘文件之间的转换。
|
||||
|
||||
## 每日一练
|
||||
|
||||
1. 结合RDD数据存储到MemoryStore的过程,你能推演出通过MemoryStore通过getValues/getBytes方法去访问RDD缓存内容的过程吗?
|
||||
1. 参考RDD缓存存储的过程,你能推演出广播变量存入MemoryStore的流程吗?
|
||||
|
||||
期待在留言区看到你的思考和讨论,我们下一讲见!
|
||||
157
极客时间专栏/geek/Spark性能调优实战/原理篇/07 | 内存管理基础:Spark如何高效利用有限的内存空间?.md
Normal file
157
极客时间专栏/geek/Spark性能调优实战/原理篇/07 | 内存管理基础:Spark如何高效利用有限的内存空间?.md
Normal file
@@ -0,0 +1,157 @@
|
||||
<audio id="audio" title="07 | 内存管理基础:Spark如何高效利用有限的内存空间?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/df/e2/df630a379c7b79d8b6146768byye8ae2.mp3"></audio>
|
||||
|
||||
你好,我是吴磊。
|
||||
|
||||
对于Spark这样的内存计算引擎来说,内存的管理与利用至关重要。业务应用只有充分利用内存,才能让执行性能达到最优。
|
||||
|
||||
那么,你知道Spark是如何使用内存的吗?不同的内存区域之间的关系是什么,它们又是如何划分的?今天这一讲,我就结合一个有趣的小故事,来和你深入探讨一下Spark内存管理的基础知识。
|
||||
|
||||
## 内存的管理模式
|
||||
|
||||
在管理方式上,Spark会区分**堆内内存**(On-heap Memory)和**堆外内存**(Off-heap Memory)。这里的“堆”指的是JVM Heap,因此堆内内存实际上就是Executor JVM的堆内存;堆外内存指的是通过Java Unsafe API,像C++那样直接从操作系统中申请和释放内存空间。
|
||||
|
||||
**其中,堆内内存的申请与释放统一由JVM代劳。**比如说,Spark需要内存来实例化对象,JVM负责从堆内分配空间并创建对象,然后把对象的引用返回,最后由Spark保存引用,同时记录内存消耗。反过来也是一样,Spark申请删除对象会同时记录可用内存,JVM负责把这样的对象标记为“待删除”,然后再通过垃圾回收(Garbage Collection,GC)机制将对象清除并真正释放内存。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/ca/6b7c27e8b2e02e2698a031ff871313ca.jpg" alt="" title="JVM堆内内存的申请与释放">
|
||||
|
||||
在这样的管理模式下,Spark对内存的释放是有延迟的,因此,当Spark尝试估算当前可用内存时,很有可能会高估堆内的可用内存空间。
|
||||
|
||||
**堆外内存则不同,Spark通过调用Unsafe的allocateMemory和freeMemory方法直接在操作系统内存中申请、释放内存空间**,这听上去是不是和C++管理内存的方式很像呢?这样的内存管理方式自然不再需要垃圾回收机制,也就免去了它带来的频繁扫描和回收引入的性能开销。更重要的是,空间的申请与释放可以精确计算,因此Spark对堆外可用内存的估算会更精确,对内存的利用率也更有把握。
|
||||
|
||||
为了帮助你更轻松地理解这个过程,我来给你讲一个小故事。
|
||||
|
||||
### 地主招租(上):土地划分
|
||||
|
||||
很久以前,燕山脚下有一个小村庄,村里有个地主,名叫黄四郎,四郎家有良田千顷,方圆数百里都是他的田地。黄四郎养尊处优,自然不会亲自下地种田,不过这么多田地也不能就这么荒着。于是,他想了个办法,既不用亲自动手又能日进斗金:收租子!
|
||||
|
||||
黄四郎虽然好吃懒做,但在管理上还是相当有一套的,他把田地划分为两块,一块叫“托管田”,另一块叫“自管田”。
|
||||
|
||||
我们知道,庄稼**丰收之后,田地需要翻土、整平、晾晒**,来年才能种下一茬庄稼。那么,托管田指的就是丰收之后,由黄四郎派专人帮你搞定翻土、整平这些琐事,不用你操心。相应的,自管田的意思就是庄稼你自己种,秋收之后的田地也得你自己收拾。
|
||||
|
||||
**毫无疑问,对租户来说托管田更省心一些,自管田更麻烦。**当然了,相比自管田,托管田的租金自然更高。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/31/b1a5fbe3701051126cb4e92yyfaeea31.jpg" alt="" title="托管田与自管田">
|
||||
|
||||
那么,这个故事中黄四郎的托管田就是内存管理中的堆内内存,自管田类比的则是堆外内存,田地的翻土、整平这些操作实际上就是JVM中的GC。这样类比起来是不是更好理解了呢?
|
||||
|
||||
## 内存区域的划分
|
||||
|
||||
故事先讲到这儿,让我们暂时先回到Spark的内存管理上。现在,我们知道了Spark内存管理有堆内和堆外两种模式,那Spark又是怎么划分内存区域的呢?
|
||||
|
||||
我们先来说说堆外内存。Spark把堆外内存划分为两块区域:一块用于执行分布式任务,如Shuffle、Sort和Aggregate等操作,这部分内存叫做**Execution Memory**;一块用于缓存RDD和广播变量等数据,它被称为**Storage Memory**。
|
||||
|
||||
堆内内存的划分方式和堆外差不多,Spark也会划分出用于执行和缓存的两份内存空间。不仅如此,Spark在堆内还会划分出一片叫做**User Memory**的内存空间,它用于存储开发者自定义数据结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a4/8c/a4b793f305410ee12964740a4958ba8c.jpg" alt="" title="不同内存区域的划分">
|
||||
|
||||
除此之外,Spark在堆内还会预留出一小部分内存空间,叫做**Reserved Memory**,它被用来存储各种Spark内部对象,例如存储系统中的BlockManager、DiskBlockManager等等。
|
||||
|
||||
对于性能调优来说,我们在前三块内存的利用率上有比较大的发挥空间,因为业务应用主要消耗的就是它们,也即Execution memory、Storage memory和User memory。而预留内存我们却动不得,因为这块内存仅服务于Spark内部对象,业务应用不会染指。
|
||||
|
||||
好了,不同内存区域的划分与计算,我也把它们总结到了下面的表格中,方便你随时查阅。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/87/19aae02eb53ba1ec3f4141cb662b7d87.jpeg" alt="">
|
||||
|
||||
### 执行与缓存内存
|
||||
|
||||
在所有的内存区域中,最重要的无疑是缓存内存和执行内存,而内存计算的两层含义也就是数据集缓存和Stage内的流水线计算,对应的就是Storage Memory和Execution Memory。
|
||||
|
||||
在Spark 1.6版本之前,Execution Memory和Storage Memory内存区域的空间划分是静态的,一旦空间划分完毕,不同内存区域的用途就固定了。也就是说,即便你没有缓存任何RDD或是广播变量,Storage Memory区域的空闲内存也不能用来执行Shuffle中的映射、排序或聚合等操作,因此宝贵的内存资源就被这么白白地浪费掉了。
|
||||
|
||||
考虑到静态内存划分潜在的空间浪费,在1.6版本之后,Spark推出了统一内存管理模式。**统一内存管理指的是Execution Memory和Storage Memory之间可以相互转化**,尽管两个区域由配置项spark.memory.storageFraction划定了初始大小,但在运行时,结合任务负载的实际情况,Storage Memory区域可能被用于任务执行(如Shuffle),Execution Memory区域也有可能存储RDD缓存。
|
||||
|
||||
但是,我们都知道,执行任务相比缓存任务,在内存抢占上有着更高的优先级。那你有没有想过这是为什么呢?接下来,就让我们带着“打破砂锅问到底”的精神,去探索其中更深层次的原因。
|
||||
|
||||
首先,执行任务主要分为两类:**一类是Shuffle Map阶段的数据转换、映射、排序、聚合、归并等操作;另一类是Shuffle Reduce阶段的数据排序和聚合操作。它们所涉及的数据结构,都需要消耗执行内存**。
|
||||
|
||||
我们可以先假设,执行任务与缓存任务在内存抢占上遵循“公正、公平和公开”的三原则。也就是说,不论谁抢占了对方的内存,当对方有需要时都会立即释放。比如说,刚开始双方的预设比例是五五开,但因为缓存任务在应用中比较靠后的位置,所以执行任务先占据了80%的内存空间,当缓存任务追赶上来之后,执行任务就需要释放30%的内存空间还给缓存任务。
|
||||
|
||||
这种情况下会发生什么?假设集群范围内总共有80个CPU,也就是集群在任意时刻的并行计算能力是80个分布式任务。在抢占了80%内存的情况下,80个CPU可以充分利用,每个CPU的计算负载都是比较饱满的,计算完一个任务,再去计算下一个任务。
|
||||
|
||||
但是,由于有30%的内存要归还给缓存任务,这意味着有30个并行的执行任务没有内存可用。也就是说会有30个CPU一直处在I/O wait的状态,没法干活!宝贵的CPU计算资源就这么白白地浪费掉了,简直是暴殄天物。
|
||||
|
||||
因此,相比于缓存任务,执行任务的抢占优先级一定要更高。说了这么多,我们为什么要弄清楚其中的原因呢?我认为,只有弄清楚抢占优先级的背后逻辑,我们才能理解为什么要同时调节CPU和内存的相关配置,也才有可能做到不同硬件资源之间的协同与平衡,这也是我们进行性能调优要达到的最终效果。
|
||||
|
||||
不过,即使执行任务的抢占优先级更高,但它们在抢占内存的时候一定也要遵循某些规则。那么,这些规则具体是什么呢?下面,咱们就接着以地主招租的故事为例,来说说Execution memory和Storage memory之间有哪些有趣的规则。
|
||||
|
||||
### 地主招租(下):租地协议
|
||||
|
||||
黄四郎招租的告示贴出去没多久,村子里就有两个年富力强的小伙子来租种田地。一个叫黄小乙,是黄四郎的远房亲戚,前不久来投奔黄四郎。另一个叫张麻子,虽是八辈贫农,小日子过得也算是蒸蒸日上。张麻子打算把田地租过来种些小麦、玉米这样的庄稼。黄小乙就不这么想,这小子挺有商业头脑,他把田地租过来准备种棉花、咖啡这类经济作物。
|
||||
|
||||
两个人摩拳擦掌都想干出一番事业,恨不得把黄四郎的地全都包圆!地不愁租,黄四郎自然是满心欢喜,但烦恼也接踵而至:“既要照顾小乙这孩子,又不能打击麻子的积极性,得想个万全之策”。
|
||||
|
||||
于是,他眼珠一转,计上心来:“按理说呢,咱们丈量土地之后,应该在你们中间划一道实线,好区分田地的归属权。不过呢,毕竟麻子你是本村的,小乙远道而来,远来即是客嘛!咱们对小乙还是得多少照顾着点”。张麻子心生不悦:“怎么照顾?”
|
||||
|
||||
黄四郎接着说:“**很简单,把实线改为虚线,多劳者多得**。原本呢,你们应该在分界线划定的那片田地里各自劳作。不过呢,你们二人的进度各不相同嘛,所以,**勤奋的人,自己的田地种满了之后,可以跨过分界线,去占用对方还在空着的田地**。”
|
||||
|
||||
黄小乙不解地问:“四舅,这不是比谁种得快吗?也没对我特殊照顾啊!”张麻子眉间也拧了个疙瘩:“如果种得慢的人后来居上,想要把被占的田地收回去,到时候该怎么办呢?”
|
||||
|
||||
黄四郎得意道:“刚才说了,咱们多多照顾小乙。所以**如果麻子勤快、干活也快,先占了小乙的地,种上了小麦、玉米,小乙后来居上,想要收回自己的地,那么没说的,麻子得把多占的地让出来。不管庄稼熟没熟,麻子都得把地铲平,还给人家小乙种棉花、咖啡**”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/92/cc/92a3a3b0d69935a9f9770675ed6428cc.jpg" alt="" title="黄小乙与张麻子的占地协议">
|
||||
|
||||
黄四郎偷眼看了看两人的反应,继续说:“**反过来,如果小乙更勤快,先占了麻子的地,麻子后来居上,想要收回,这个时候,咱们就得多照顾照顾小乙。小乙有权继续占用麻子的地,直到地上种的棉花、咖啡都丰收了,再把多占的地让出来**。你们二位看怎么样?”
|
||||
|
||||
黄小乙听了大喜。张麻子虽然心里不爽,但也清楚黄四郎和黄小乙之间的亲戚关系,也不好再多说什么,心想:“反正我勤快些,先把地种满也就是了”。于是,三方击掌为誓,就此达成协议。
|
||||
|
||||
好啦,地主招租的故事到这里就讲完了。不难发现,黄小乙的地类比的是Execution Memory,张麻子的地其实就是Storage Memory。他们之间的协议其实就是Execution Memory和Storage Memory之间的抢占规则,一共可以总结为3条:
|
||||
|
||||
- **如果对方的内存空间有空闲,双方就都可以抢占;**
|
||||
- **对于RDD缓存任务抢占的执行内存,当执行任务有内存需要时,RDD缓存任务必须立即归还抢占的内存,涉及的RDD缓存数据要么落盘、要么清除;**
|
||||
- **对于分布式计算任务抢占的Storage Memory内存空间,即便RDD缓存任务有收回内存的需要,也要等到任务执行完毕才能释放。**
|
||||
|
||||
同时,我也把这个例子中的关键内容和Spark之间的对应关系总结在了下面,希望能帮助你加深印象。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/21/692d78990aa9481b56bcc28522e9df21.jpg" alt="" title="地主招租与Spark的类比关系">
|
||||
|
||||
## 从代码看内存消耗
|
||||
|
||||
说完了理论,接下来,咱们再从实战出发,用一个小例子来直观地感受一下,应用中代码的不同部分都消耗了哪些内存区域。
|
||||
|
||||
示例代码很简单,目的是读取words.csv文件,然后对其中指定的单词进行统计计数。
|
||||
|
||||
```
|
||||
val dict: List[String] = List(“spark”, “scala”)
|
||||
val words: RDD[String] = sparkContext.textFile(“~/words.csv”)
|
||||
val keywords: RDD[String] = words.filter(word => dict.contains(word))
|
||||
keywords.cache
|
||||
keywords.count
|
||||
keywords.map((_, 1)).reduceByKey(_ + _).collect
|
||||
|
||||
```
|
||||
|
||||
整个代码片段包含6行代码,咱们从上到下逐一分析。
|
||||
|
||||
首先,第一行定义了dict字典,这个字典在Driver端生成,它在后续的RDD调用中会随着任务一起分发到Executor端。第二行读取words.csv文件并生成RDD words。**第三行很关键,用dict字典对words进行过滤,此时dict已分发到Executor端,Executor将其存储在堆内存中,用于对words数据分片中的字符串进行过滤。Dict字典属于开发者自定义数据结构,因此,Executor将其存储在User Memory区域。**
|
||||
|
||||
接着,第四行和第五行用cache和count对keywords RDD进行缓存,以备后续频繁访问,分布式数据集的缓存占用的正是Storage Memory内存区域。在最后一行代码中,我们在keywords上调用reduceByKey对单词分别计数。我们知道,reduceByKey算子会引入Shuffle,而Shuffle过程中所涉及的内部数据结构,如映射、排序、聚合等操作所仰仗的Buffer、Array和HashMap,都会消耗Execution Memory区域中的内存。
|
||||
|
||||
不同代码与其消耗的内存区域,我都整理到了下面的表格中,方便你查看。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a0/ff/a05f77a27aaaf21d9d064aa1ca1be3ff.jpeg" alt="">
|
||||
|
||||
## 小结
|
||||
|
||||
深入理解内存管理的机制,有助于我们充分利用应用的内存,提升其执行性能。今天,我们重点学习了内存管理的基础知识。
|
||||
|
||||
**首先是内存的管理方式。**Spark区分堆内内存和堆外内存:对于堆外内存来说,Spark通过调用Java Unsafe的allocateMemory和freeMemory方法,直接在操作系统内存中申请、释放内存空间,管理成本较高;对于堆内内存来说,无需Spark亲自操刀而是由JVM代理。但频繁的JVM GC对执行性能来说是一大隐患。另外,Spark对堆内内存占用的预估往往不够精确,高估可用内存往往会为OOM埋下隐患。
|
||||
|
||||
**其次是统一内存管理,以及Execution Memory和Storage Memory之间的抢占规则**。它们就像黄四郎招租故事中黄小乙和张麻子的田地,抢占规则就像他们之间的占地协议,主要可以分为3条:
|
||||
|
||||
- 如果对方的内存空间有空闲,那么双方都可以抢占;
|
||||
- 对RDD缓存任务抢占的执行内存,当执行任务有内存需要时,RDD缓存任务必须立即归还抢占的内存,其中涉及的RDD缓存数据要么落盘、要么清除;
|
||||
- 对分布式计算任务抢占的Storage Memory内存空间,即便RDD缓存任务有收回内存的需要,也要等到任务执行完毕才能释放。
|
||||
|
||||
**最后是不同代码对不同内存区域的消耗。**内存区域分为Reserved Memory、User Memory、Execution Memory和Storage Memory。其中,Reserved Memory用于存储Spark内部对象,User Memory用于存储用户自定义的数据结构,Execution Memory用于分布式任务执行,而Storage Memory则用来容纳RDD缓存和广播变量。
|
||||
|
||||
好了,这些就是内存管理的基础知识。当然了,与内存相关的话题还有很多,比如内存溢出、RDD缓存、内存利用率,以及执行内存的并行计算等等。在性能调优篇,我还会继续从内存视角出发,去和你探讨这些话题。
|
||||
|
||||
## 每日一练
|
||||
|
||||
1. 你知道启用off-heap之后,Spark有哪些计算环节可以利用到堆外内存?你能列举出一些例子吗?
|
||||
1. 相比堆内内存,为什么在堆外内存中,Spark对于内存占用量的预估更准确?
|
||||
1. 结合我在下面给定的配置参数,你能分别计算不同内存区域(Reserved、User、Execution、Storage)的具体大小吗?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/66/fdb2fb17120e4d047d5ccd28d1434b66.jpeg" alt="">
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
Reference in New Issue
Block a user