This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View 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(&quot;eventDate&quot;) &gt; lit(startDate) &amp;&amp; col(&quot;eventDate&quot;) &lt;= lit(endDate))
.groupBy(&quot;dim1&quot;, &quot;dim2&quot;, &quot;dim3&quot;, &quot;event_date&quot;)
.agg(&quot;sum(value) as sum_value&quot;)
instanceDF
}
pairDF.collect.foreach{
case (startDate: String, endDate: String) =&gt;
val instance = createInstance(factDF, startDate, endDate)
val outPath = s&quot;${rootPath}/endDate=${endDate}/startDate=${startDate}&quot;
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 所需的“数据源”术语叫做父依赖或父RDDcompute方法则封装了从父 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>
期待在留言区看到你的思考,也欢迎你分享工作中遇到过的“单机思维模式”,我们下节课见!

View 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和ReduceMap抽象允许开发者通过实现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>
期待在留言区看到你的思考和答案,如果对内存计算还有很多困惑,也欢迎你写在留言区,我们下一讲见!

View 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, &quot;UTF-8&quot;)
val lines = source.getLines().toArray
source.close()
val searchMap = lines.zip(0 until lines.size).toMap
searchMap.getOrElse(interest, -1)
}
//Dataset中的函数调用
findIndex(filePath, &quot;体育-篮球-NBA-湖人&quot;)
```
我们可以看到这个函数有两个形参一个是模板文件路径另一个是训练样本中的用户兴趣。处理函数首先读取模板文件然后根据文件中排序的字符串构建一个从兴趣到索引的Map映射最后在这个Map中查找第二个形参传入的用户兴趣如果能找到则返回对应的索引找不到的话则返回-1。
这段代码看上去似乎没什么问题同学们基于上面的函数对千亿样本做Label encoding在20台机型为C5.4xlarge AWS EC2的分布式集群中花费了5个小时。坦白说这样的执行性能我是不能接受的。你可能会说“需求就是这个样子还能有什么别的办法呢”我们不妨来看另外一种实现方式。
```
/**
实现方式2
输入参数:模板文件路径,用户兴趣字符串
返回值:用户兴趣字符串对应的索引值
*/
//函数定义
val findIndex: (String) =&gt; (String) =&gt; Int = {
(filePath) =&gt;
val source = Source.fromFile(filePath, &quot;UTF-8&quot;)
val lines = source.getLines().toArray
source.close()
val searchMap = lines.zip(0 until lines.size).toMap
(interest) =&gt; searchMap.getOrElse(interest, -1)
}
val partFunc = findIndex(filePath)
//Dataset中的函数调用
partFunc(&quot;体育-篮球-NBA-湖人&quot;)
```
同学们基于第二种方式对相同的数据集做Label encoding之后在10台同样机型的分布式集群中花了不到20分钟就把任务跑完了。可以说执行性能的提升是显而易见的。那么两份代码有什么区别呢
我们可以看到相比于第一份代码第二份代码的函数体内没有任何变化还是先读取模板文件、构建Map映射、查找用户兴趣最后返回索引。最大的区别就是第二份代码对高阶函数的使用具体来说有2点
1. 处理函数定义为高阶函数,形参是模板文件路径,返回结果是从用户兴趣到索引的函数;
1. 封装千亿样本的Dataset所调用的函数不是第一份代码中的findIndex而是用模板文件调用findIndex得到的partFuncpartFunc是形参为兴趣、结果为索引的普通标量函数。
那么,高阶函数真有这么神奇吗?其实,性能的提升并不是高阶函数的功劳,而是调度系统在起作用。
## Spark的调度系统是如何工作的
Spark调度系统的核心职责是**先将用户构建的DAG转化为分布式任务结合分布式集群资源的可用性基于调度规则依序把分布式任务分发到执行器**。这个过程听上去就够复杂的了,为了方便你理解,我们还是先来讲一个小故事。
### 土豆工坊流水线升级
在学完了内存计算的第二层含义之后,土豆工坊的老板决定对土豆加工流水线做升级,来提高工坊的生产效率和灵活性。
这里,我们先对内存计算的第二层含义做个简单地回顾,它指的是**同一Stage中的所有操作会被捏合为一个函数这个函数一次性会被地应用到输入数据上并且一次性地产生计算结果**。
升级之前的土豆加工流程DAG被切分为3个执行阶段Stage它们分别是Stage 0、Stage 1、Stage 2。其中Stage 0产出即食薯片Stage 1分发调味品Stage 2则产出不同尺寸、不同风味的薯片。我们重点关注Stage 0Stage 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和MesosSchedulerBackend提供了对应的实现类。在运行时Spark根据用户提供的MasterURL来决定实例化哪种实现类的对象。MasterURL就是你通过各种方式指定的资源管理器如--master spark://ip:hostStandalone 模式)、--master yarnYARN 模式)。
对于集群中可用的计算资源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 &lt; Node local &lt; Rack local &lt; Any。从左到右分别是进程本地性、节点本地性、机架本地性和跨机架本地性。从左到右计算任务访问所需数据的效率越来越差。
进程本地性表示计算任务所需的输入数据就在某一个Executor进程内因此把这样的计算任务调度到目标进程内最划算。同理如果数据源还未加载到Executor进程而是存储在某一计算节点的磁盘中那么把任务调度到目标节点上去也是一个不错的选择。再次如果我们无法确定输入源在哪台机器但可以肯定它一定在某个机架上本地性级别就会退化到Rack local。
DAGScheduler划分Stages、创建分布式任务的过程中会为每一个任务指定本地性级别本地性级别中会记录该任务有意向的计算节点地址甚至是Executor进程ID。换句话说**任务自带调度意愿它通过本地性级别告诉TaskScheduler自己更乐意被调度到哪里去**。
既然计算任务的个人意愿这么强烈TaskScheduler作为中间商肯定要优先满足人家的意愿。这就像一名码农想要租西二旗的房子但是房产中介App推送的结果都是东三环国贸的房子那么这个中介的匹配算法肯定有问题。
由此可见,**Spark调度系统的原则是尽可能地让数据呆在原地、保持不动同时尽可能地把承载计算任务的代码分发到离数据最近的地方从而最大限度地降低分布式系统中的网络开销**。毕竟,分发代码的开销要比分发数据的代价低太多,这也正是“数据不动代码动”这个说法的由来。
总的来说TaskScheduler根据本地性级别遴选出待计算任务之后先对这些任务进行序列化。然后交给SchedulerBackendSchedulerBackend根据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本地性级别成立吗你认为哪些情况下成立哪些情况下不成立
期待在留言区看到你的思考和答案,如果你的朋友也正急需搞清楚调度系统的工作原理,也欢迎你把这一讲转发给他,我们下一讲见!

View 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 为BlockIdValue 是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
相比MemoryStoreDiskStore就相对简单很多因为它并不需要那么多的中间数据结构才能完成数据的存取。**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频繁回溯的计算开销也能有效提升端到端的执行性能
- ShuffleShuffle中间文件的位置信息都是由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的流程吗
期待在留言区看到你的思考和讨论,我们下一讲见!

View 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 CollectionGC机制将对象清除并真正释放内存。
<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区域可能被用于任务执行如ShuffleExecution 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 =&gt; 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="">
期待在留言区看到你的思考和答案,我们下一讲见!