This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,230 @@
<audio id="audio" title="08 | 应用开发三原则:如何拓展自己的开发边界?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/07/4a/07da051828d90b59a9efc1bd6dc6594a.mp3"></audio>
你好,我是吴磊。
从今天开始我们就进入通用性能调优篇的学习了。这一篇我们会从基本的开发原则、配置项、Shuffle以及硬件资源这四个方面去学习一些通用的调优方法和技巧它们会适用于所有的计算场景。
今天这一讲,我们先从应用开发的角度入手,去探讨开发阶段应该遵循的基础原则。**如果能在开发阶段就打好基础、防患于未然,业务应用的执行性能往往会有个不错的起点**。开发阶段就像学生时代的考卷,虽然有很难的拔高题,但只要我们稳扎稳打,答好送分的基础题,成绩往往不会太差。
这些“基础题”对应的就是工作中一些“常规操作”比如Filter + Coalesce和用mapPartitions代替map以及用ReduceByKey代替GroupByKey等等。我相信你在日常的开发工作中肯定已经积累了不少。但是据我观察很多同学在拿到这些技巧之后都会不假思索地“照葫芦画瓢”。不少同学反馈“试了之后怎么没效果啊算了反正能试的都试了我也实在没有别的调优思路了就这样吧”。
那么,这种情况该怎么办呢?我认为,最重要的原因可能是你积累的这些“常规操作”还没有形成体系。结合以往的开发经验,我发现这些“常规操作”可以归纳为三类:
- 坐享其成
- 能省则省、能拖则拖
- 跳出单机思维
话不多说,接下来,我就来和你好好聊一聊。
## 原则一:坐享其成
站在巨人的肩膀上才能看得更远所以在绞尽脑汁去尝试各种调优技巧之前我们应该尽可能地充分利用Spark为我们提供的“性能红利”如钨丝计划、AQE、SQL functions等等。**我把这类原则称作“坐享其成”意思是说我们通过设置相关的配置项或是调用相应的API去充分享用Spark自身带来的性能优势**。
那么,我们都可以利用哪些现成的优势呢?
### 如何利用钨丝计划的优势?
首先我们可以利用Databricks在2015年启动的“钨丝计划Project Tungsten”。它的优势是可以通过对数据模型与算法的优化把Spark应用程序的执行性能提升一个数量级。那这是怎么做到的呢这就要从它的数据结构说起了。
**在数据结构方面Tungsten自定义了紧凑的二进制格式。**这种数据结构在存储效率方面相比JVM对象存储高出好几个数量级。另外由于数据结构本身就是紧凑的二进制形式因此它天然地避免了Java对象序列化与反序列化引入的计算开销。
基于定制化的二进制数据结构,**Tungsten利用Java Unsafe API开辟堆外Off Heap Memory内存来管理对象**。堆外内存有两个天然的优势一是对于内存占用的估算更精确二来不需要像JVM Heap那样反复执行垃圾回收。
最后,在运行时,**Tungsten用全阶段代码生成Whol Stage Code Generation取代火山迭代模型**这不仅可以减少虚函数调用和降低内存访问频率还能提升CPU cache命中率做到大幅压缩CPU idle时间从而提升CPU利用率。
[Databricks官方对比实验](https://databricks.com/session/spark-sql-another-16x-faster-after-tungsten)显示开启Tungsten前后应用程序的执行性能可以提升16倍**因此你看,哪怕<strong><strong>咱们**</strong>什么都不做只要开发的业务应用能够利用到Tungsten提供的种种特性Spark就能让应用的执行性能有所保障</strong>。对于咱们开发者来说,这么大的便宜,干吗不占呢?
### 如何利用AQE的优势
除了钨丝计划我们最应该关注Spark 3.0版本发布的新特性——AQE。AQEAdaptive Query Execution全称“自适应查询执行”它可以在Spark SQL优化的过程中动态地调整执行计划。
我们知道Spark SQL的优化过程可以大致分为语法分析、语义解析、逻辑计划和物理计划这几个环节。在3.0之前的版本中Spark仅仅在编译时基于规则和策略遍历AST查询语法树来优化逻辑计划一旦基于最佳逻辑计划选定物理执行计划Spark就会严格遵照物理计划的步骤去机械地执行计算。
**而AQE可以让Spark在运行时的不同阶段结合实时的运行时状态周期性地动态调整前面的逻辑计划然后根据再优化的逻辑计划重新选定最优的物理计划从而调整运行时后续阶段的执行方式。**
<img src="https://static001.geekbang.org/resource/image/4c/17/4cdd21d991c290a12e34d5dbfbdf1f17.jpg" alt="" title="Spark SQL端到端优化流程">
你可能会问“听上去这么厉害那AQE具体都有哪些改进呢”AQE主要带来了3个方面的改进分别是自动分区合并、数据倾斜和Join策略调整。我们一一来看。
**首先,自动分区合并很好理解**我们拿Filter与Coalesce来举例。分布式数据集过滤之后难免有些数据分片的内容所剩无几甚至为空所以为了避免多余的调度开销我们经常会用Coalesce去做手工的分区合并。
另外在Shuffle的计算过程中同样也存在分区合并的需求。
<img src="https://static001.geekbang.org/resource/image/4a/a6/4a12bc05971799da422e942754c72fa6.jpg" alt="" title="Spark支持AQE前后">
以上图为例我们可以看到数据表原本有2个分区Shuffle之后在Reduce阶段产生5个数据分区。由于数据分布不均衡其中3个分区的数据量很少。对CPU来说这3个小分区产生的调度开销会是一笔不小的浪费。在Spark支持AQE以前开发者对此无能为力。现在呢AQE会自动检测过小的数据分区并对它们自动合并根本不需要我们操心了。
**其次是数据倾斜Data Skew它在数据分析领域中很常见**如果处理不当很容易导致OOM问题。
比方说我们要分析每一个微博用户的历史行为。那么不论是发博量还是互动频次普通用户与头部用户明星、网红、大V、媒体会相差好几个数量级。这个时候按照用户ID进行分组分析就会产生数据倾斜的问题而且同一Executor中的执行任务基本上是平均分配可用内存的。因此一边是平均的内存供给一边是有着数量级之差的数据处理需求数据倾斜严重的Task报出OOM错误也就不足为怪了。
以往处理数据倾斜问题的时候往往需要我们在应用中手动“加盐”也就是强行给倾斜的Key添加随机前缀通过把Key打散来均衡数据在不同节点上的分布。现在在数据关联Joins的场景中如果AQE发现某张表存在倾斜的数据分片就会自动对它做加盐处理同时对另一张表的数据进行复制。除此之外开发者在自行盐化之前还需要先统计每一个Key的倾斜情况再决定盐化的幅度。不过自从有了AQE这些糟心事交给它搞定就好了。
**最后Join策略调整也不难理解。**当两个有序表要进行数据关联的时候Spark SQL在优化过程中总会选择Sort Merge Join的实现方式。但有一种情况是其中一个表在排序前需要对数据进行过滤过滤后的表小到足可以由广播变量容纳。这个时候Broadcast Join比Sort Merge Join的效率更高。但是3.0版本之前的优化过程是静态的做不到动态切换Join方式。
针对这种情况AQE会根据运行时的统计数据去动态地调整Join策略把之前敲定的Sort Merge Join改为Broadcast Join从而改善应用的执行性能。
说了这么多,对于这些天然的优势,我们到底怎么才能利用好呢?首先,**想要利用好Tungsten的优势你只要抛弃RDD API采用DataFrame或是Dataset API进行开发就可了**,是不是很简单?
不过,**AQE功能默认是关闭的如果我们想要充分利用自动分区合并、自动数据倾斜处理和Join策略调整需要把相关的配置项打开**,具体的操作如下表所示。
<img src="https://static001.geekbang.org/resource/image/bb/75/bbc4f9f8a39990b2c85d9d9bc46e2e75.jpeg" alt="">
总的来说通过钨丝计划和AQE我们完全可以实现低投入、高产出这其实就是坐享其成的核心原则。除此之外类似的技巧还有用SQL functions或特征转换算子去取代UDF等等。我非常希望你能在开发过程中去主动探索、汇总这些可以拿来即用的技巧如果有成果也期待你在留言区分享。
## 原则二:能省则省、能拖则拖
在很多数据处理场景中,为了快速实现业务需求,我往往会对数据清洗、过滤、提取、关联和聚合等多种操作排列组合来完成开发。这些排列组合的执行性能参差不齐、有好有坏,那我们该怎么找到性能更好的实现方式呢?
这个时候,我们就可以使用第二个原则:**“能省则省、能拖则拖”。省的是数据处理量**,因为节省数据量就等于节省计算负载,更低的计算负载自然意味着更快的处理速度;**拖的是Shuffle操作**因为对于常规数据处理来说计算步骤越靠后需要处理的数据量越少Shuffle操作执行得越晚需要落盘和分发的数据量就越少更低的磁盘与网络开销自然意味着更高的执行效率。
实现起来我们可以分3步进行
- **尽量把能节省数据扫描量和数据处理量的操作往前推;**
- **尽力消灭掉Shuffle省去数据落盘与分发的开销**
- **如果不能干掉Shuffle尽可能地把涉及Shuffle的操作拖到最后去执行**。
接下来,我们再通过一个例子来对这个原则加深理解。
这次的业务背景很简单我们想要得到两个共现矩阵一个是物品、用户矩阵另一个是物品、用户兴趣矩阵。得到这两个矩阵之后我们要尝试用矩阵分解的方法去计算物品、用户和用户兴趣这3个特征的隐向量Latent Vectors也叫隐式向量这些隐向量最终会用来构建机器学习模型的特征向量Feature Vectors
基于这样的业务背景代码需要实现的功能是读取用户访问日志然后构建出这两个矩阵。访问日志以天为单位保存在Parquet格式的文件中每条记录包含用户ID、物品ID、用户兴趣列表、访问时间、用户属性和物品属性等多个字段。我们需要读取日志记录先用distinct对记录去重然后用explode将兴趣列表展开为单个兴趣接着提取相关字段最后按照用户访问频次对记录进行过滤并再次去重最终就得到了所需的共现矩阵。
拿到这样的业务需求之后你会怎么实现呢同学小A看完之后二话不说就实现了如下的代码
```
val dates: List[String] = List(&quot;2020-01-01&quot;, &quot;2020-01-02&quot;, &quot;2020-01-03&quot;)
val rootPath: String = _
//读取日志文件去重、并展开userInterestList
def createDF(rootPath: String, date: String): DataFrame = {
val path: String = rootPath + date
val df = spark.read.parquet(path)
.distinct
.withColumn(&quot;userInterest&quot;, explode(col(&quot;userInterestList&quot;)))
df
}
//提取字段、过滤再次去重把多天的结果用union合并
val distinctItems: DataFrame = dates.map{
case date: String =&gt;
val df: DataFrame = createDF(rootPath, date)
.select(&quot;userId&quot;, &quot;itemId&quot;, &quot;userInterest&quot;, &quot;accessFreq&quot;)
.filter(&quot;accessFreq in ('High', 'Medium')&quot;)
.distinct
df
}.reduce(_ union _)
```
我们不妨来一起分析一下这段代码其中主要的操作有4个用distinct去重、用explode做列表展开、用select提取字段和用filter过滤日志记录。因为后3个操作全部是在Stage内完成去内存计算只有distinct会引入Shuffle所以我们要重点关注它。distinct一共被调用了两次一次是读取日志内容之后去重另一次是得到所需字段后再次去重。
首先我们把目光集中到第一个distinct操作上**在createDF函数中读取日志记录之后立即调用distinct去重**。要知道日志记录中包含了很多的字段distinct引入的Shuffle操作会触发所有数据记录以及记录中所有字段在网络中全量分发但我们最终需要的是用户粘性达到一定程度的数据记录而且只需要其中的用户ID、物品ID和用户兴趣这3个字段。因此这个distinct实际上在集群中分发了大量我们并不需要的数据这无疑是一个巨大的浪费。
接着我们再来看第二个distinct操作**对数据进行展开、抽取、过滤之后,再对记录去重**。这次的去重和第一次大不相同它涉及的Shuffle操作所分发的数据记录没有一条是多余的记录中仅包含共现矩阵必需的那几个字段。
这个时候我们发现两个distinct操作都是去重目的一样但是第二个distinct操作比第一个更精准开销也更少所以我们可以去掉第一个distinct操作。
这样一来我们也就消灭了一个会引入全量数据分发的Shuffle操作这个改进对执行性能自然大有裨益。不过按下葫芦浮起瓢把第一个distinct干掉之后紧随其后的explode就浮出了水面。尽管explode不会引入Shuffle但在内存中展开兴趣列表的时候它还是会夹带着很多如用户属性、物品属性等等我们并不需要的字段。
因此我们得把过滤和列剪枝这些可以节省数据访问量的操作尽可能地往前推把计算开销较大的操作如Shuffle尽量往后拖从而在整体上降低数据处理的负载和开销。基于这些分析我们就有了改进版的代码实现如下所示。
```
val dates: List[String] = List(&quot;2020-01-01&quot;, &quot;2020-01-02&quot;, &quot;2020-01-03&quot;)
val rootPath: String = _
val filePaths: List[String] = dates.map(rootPath + _)
/**
一次性调度所有文件
先进行过滤和列剪枝
然后再展开userInterestList
最后统一去重
*/
val distinctItems = spark.read.parquet(filePaths: _*)
.filter(&quot;accessFreq in ('High', 'Medium'))&quot;)
.select(&quot;userId&quot;, &quot;itemId&quot;, &quot;userInterestList&quot;)
.withColumn(&quot;userInterest&quot;, explode(col(&quot;userInterestList&quot;)))
.select(&quot;userId&quot;, &quot;itemId&quot;, &quot;userInterest&quot;)
.distinct
```
在这份代码中所有能减少数据访问量的操作如filter、select全部被推到最前面会引入Shuffle的distinct算子则被拖到了最后面。经过实验对比两版代码在运行时的执行性能相差一倍。因此你看遵循“能省则省、能拖则拖”的开发原则往往能帮你避开很多潜在的性能陷阱。
## 原则三:跳出单机思维模式
那么,开发者遵循上述的两个原则去实现业务逻辑,是不是就万事大吉、高枕无忧了呢?当然不是,我们再来看下面的例子。
为了生成训练样本我们需要对两张大表进行关联。根据“能省则省、能拖则拖”原则我们想把其中一张表变小把Shuffle Join转换为Broadcast Join这样一来就可以把Shuffle的环节省掉了。
尽管两张表的尺寸都很大但右表的Payload只有一列其他列都是Join keys所以只要我们把Join keys干掉右表完全可以放到广播变量里。但是直接干掉Join keys肯定不行因为左右表数据关联是刚需。那么我们能否换个方式把它们关联在一起呢
受Hash Join工作原理的启发我们想到可以把所有的Join keys拼接在一起然后用哈希算法生成一个固定长度的字节序列把它作为新的Join key。这样一来右表中原始的Join keys就可以拿掉右表的尺寸也可以大幅削减小到可以放到广播变量里。同时新的Join key还能保证左右表中数据的关联关系保持不变一举两得。
为了对拼接的Join keys进行哈希运算我们需要事先准备好各种哈希算法然后再转换左、右表。接到这样的需求之后同学小A立马在右表上调用了map算子并且在map算子内通过实例化Util类获取哈希算法最后在拼接的Join keys上进行哈希运算完成了转换。具体的代码如下所示。
```
import java.security.MessageDigest
class Util {
val md5: MessageDigest = MessageDigest.getInstance(&quot;MD5&quot;)
val sha256: MessageDigest = _ //其他哈希算法
}
val df: DataFrame = _
val ds: Dataset[Row] = df.map{
case row: Row =&gt;
val util = new Util()
val s: String = row.getString(0) + row.getString(1) + row.getString(2)
val hashKey: String = util.md5.digest(s.getBytes).map(&quot;%02X&quot;.format(_)).mkString
(hashKey, row.getInt(3))
}
```
仔细观察我们发现这份代码其实还有可以优化的空间。要知道map算子所囊括的计算是以数据记录Data Record为操作粒度的。换句话说分布式数据集涉及的每一个数据分片中的每一条数据记录都会触发map算子中的计算逻辑。因此我们必须谨慎对待map算子中涉及的计算步骤。很显然map算子之中应该仅仅包含与数据转换有关的计算逻辑与数据转换无关的计算都应该提炼到map算子之外。
反观上面的代码map算子内与数据转换直接相关的操作是拼接Join keys和计算哈希值。但是实例化Util对象仅仅是为了获取哈希函数而已与数据转换无关因此我们需要把它挪到map算子之外。
只是一行语句而已,我们至于这么较真吗?还真至于,这个实例化的动作每条记录都会触发一次,如果整个数据集有千亿条样本,就会有千亿次的实例化操作!差之毫厘谬以千里,一个小小的计算开销在规模化效应之下会被放大无数倍,演化成不容小觑的性能问题。
```
val ds: Dataset[Row] = df.mapPartitions(iterator =&gt; {
val util = new Util()
val res = iterator.map{
case row=&gt;{
val s: String = row.getString(0) + row.getString(1) + row.getString(2)
val hashKey: String = util.md5.digest(s.getBytes).map(&quot;%02X&quot;.format(_)).mkString
(hashKey, row.getInt(3)) }}
res
})
```
类似这种忽视实例化Util操作的行为还有很多比如在循环语句中反复访问RDD用临时变量缓存数据转换的中间结果等等。这种**不假思索地直入面向过程编程,忽略或无视分布式数据实体的编程模式,我们把它叫做单机思维模式。**我们在RDD那一讲也说过单机思维模式会让开发者在分布式环境中无意识地引入巨大的计算开销。
但你可能会说:“单机思维模式随处可见,防不胜防,我们该怎么跳出去呢?”
冰冻三尺、非一日之寒,既然是一种思维模式,那么它自然不是一天、两天就能形成的,想要摆脱它自然也不是一件容易的事情。不过,关于跳出单机思维,我这里也有个小技巧要分享给你。当然,这可能需要一点想象力。
你还记得土豆工坊吗?每当你准备开发应用的时候,你都可以在脑海里构造一个土豆工坊,把你需要定义的分布式数据集,安置到工坊流水线上合适的位置。当你需要处理某个数据集的时候,不妨花点时间想一想,得到当前这种土豆形态都需要哪些前提。持续地在脑海里构造土豆工坊,可以持续地加深你对分布式计算过程的理解。假以时日,我相信你一定能摆脱单机思维模式!
## 小结
在日常的开发工作中遵循这3个原则不仅能让你的应用在性能方面有个好的起点还能让你有意无意地去探索、拓展更多的调优技巧从而由点及面地积累调优经验。
首先遵循“坐享其成”的原则你就可以通过设置相关的配置项或是调用相应的API充分享用Spark自身带来的性能优势。比如使用DataFrame或是Dataset API做开发你就可以坐享Catalyst和Tungsten的各种优化机制。再比如使用Parquet、ORC等文件格式去坐享谓词下推带来的数据读取效率。
其次如果你能够坚持“能省则省、能拖则拖”尽量把节省数据扫描量和数据处理量的操作往前推尽可能地把涉及Shuffle的操作拖延到最后去执行甚至是彻底消灭Shuffle你自然能够避开很多潜在的性能陷阱。
最后,在日常的开发工作中,我们要谨防单机思维模式,摆脱单机思维模式有利于培养我们以性能为导向的开发习惯。我们可以在开发应用的过程中运用想象力,在脑海中构造一个土豆工坊。把每一个分布式数据集都安插到工坊的流水线上。在尝试获取数据集结果的时候,结合我们在原理篇讲解的调度系统、存储系统和内存管理,去进一步想象要得到计算结果,整个工坊都需要做哪些事情,会带来哪些开销。
最后的最后,我们再来说说归纳这件事的意义和价值。我们之所以把各种开发技巧归纳为开发原则,一方面是遵循这些原则,你能在不知不觉中避开很多性能上的坑。但更重要的是,从这些原则出发,向外推演,**我们往往能发现更多的开发技巧,从而能拓展自己的“常规操作”边界,做到举一反三,真正避免“调优思路枯竭”的窘境**。
## 每日一练
1. 针对我们今天讲的3个原则你还能想到哪些案例
1. 除了这3个原则外你觉得是否还有其他原则需要开发者特别留意
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,190 @@
<audio id="audio" title="09 | 调优一筹莫展,配置项速查手册让你事半功倍!(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b2/ed/b2a6a36f9ec0e6aa326c07848313afed.mp3"></audio>
你好,我是吴磊。
对于Spark性能调优来说应用开发和配置项设置是两个最主要也最常用的入口。但在日常的调优工作中每当我们需要从配置项入手寻找调优思路的时候一打开Spark官网的Configuration页面映入眼帘的就是上百个配置项。它们有的需要设置True或False有的需要给定明确的数值才能使用。这难免让我们蒙头转向、无所适从。
所以我经常在想如果能有一份Spark配置项手册上面分门别类地记录着与性能调优息息相关的配置项就好了肯定能省去不少麻烦。
那么,接下来的两讲,我们就来一起汇总这份手册。这份手册可以让你在寻找调优思路的时候,迅速地定位可能会用到的配置项,不仅有章可循,还能不丢不漏,真正做到事半功倍!
## 配置项的分类
事实上能够显著影响执行性能的配置项屈指可数更何况在Spark分布式计算环境中计算负载主要由Executors承担Driver主要负责分布式调度调优空间有限因此对Driver端的配置项我们不作考虑**我们要汇总的配置项都围绕Executors展开**。那么结合过往的实践经验以及对官网全量配置项的梳理我把它们划分为3类分别是硬件资源类、Shuffle类和Spark SQL大类。
为什么这么划分呢?我们一一来说。
**首先硬件资源类包含的是与CPU、内存、磁盘有关的配置项。**我们说过,调优的切入点是瓶颈,定位瓶颈的有效方法之一,就是从硬件的角度出发,观察某一类硬件资源的负载与消耗,是否远超其他类型的硬件,而且调优的过程收敛于所有硬件资源平衡、无瓶颈的状态,所以掌握资源类配置项就至关重要了。这类配置项设置得是否得当,决定了应用能否打破瓶颈,来平衡不同硬件的资源利用率。
**其次Shuffle类是专门针对Shuffle操作的。**在绝大多数场景下Shuffle都是性能瓶颈。因此我们需要专门汇总这些会影响Shuffle计算过程的配置项。同时Shuffle的调优难度也最高汇总Shuffle配置项能帮我们在调优的过程中锁定搜索范围充分节省时间。
**最后Spark SQL早已演化为新一代的底层优化引擎。**无论是在Streaming、Mllib、Graph等子框架中还是在PySpark中只要你使用DataFrame APISpark在运行时都会使用Spark SQL做统一优化。因此我们需要梳理出一类配置项去充分利用Spark SQL的先天性能优势。
我们一再强调硬件资源的平衡才是性能调优的关键所以今天这一讲我们就先从硬件资源类入手去汇总应该设置的配置项。在这个过程中我会带你搞清楚这些配置项的定义与作用是什么以及它们的设置能解决哪些问题让你为资源平衡打下基础。下一讲我们再来讲Shuffle类和Spark SQL大类。
## 哪些配置项与CPU设置有关
首先我们先来说说与CPU有关的配置项**主要包括spark.cores.max、spark.executor.cores和spark.task.cpus这三个参数**。它们分别从集群、Executor和计算任务这三个不同的粒度指定了用于计算的CPU个数。开发者通过它们就可以明确有多少CPU资源被划拨给Spark用于分布式计算。
为了充分利用划拨给Spark集群的每一颗CPU准确地说是每一个CPU核CPU Core你需要设置与之匹配的并行度并行度用spark.default.parallelism和spark.sql.shuffle.partitions这两个参数设置。对于没有明确分区规则的RDD来说我们用spark.default.parallelism定义其并行度spark.sql.shuffle.partitions则用于明确指定数据关联或聚合操作中Reduce端的分区数量。
说到并行度Parallelism就不得不提并行计算任务Paralleled Tasks这两个概念关联紧密但含义大相径庭有不少同学经常把它们弄混。
并行度指的是分布式数据集被划分为多少份,从而用于分布式计算。换句话说,**并行度的出发点是数据,它明确了数据划分的粒度**。并行度越高数据的粒度越细数据分片越多数据越分散。由此可见像分区数量、分片数量、Partitions这些概念都是并行度的同义词。
并行计算任务则不同,它指的是在任一时刻整个集群能够同时计算的任务数量。换句话说,**它的出发点是计算任务、是CPU由与CPU有关的三个参数共同决定**。具体说来Executor中并行计算任务数的上限是spark.executor.cores与spark.task.cpus的商暂且记为#Executor-tasks整个集群的并行计算任务数自然就是#Executor-tasks乘以集群内Executors的数量记为#Executors。因此,最终的数值是:#Executor-tasks * #Executors
我们不难发现,**并行度决定了数据粒度,数据粒度决定了分区大小,分区大小则决定着每个计算任务的内存消耗**。在同一个Executor中多个同时运行的计算任务“基本上”是平均瓜分可用内存的每个计算任务能获取到的内存空间是有上限的因此并行计算任务数会反过来制约并行度的设置。你看这两个家伙还真是一对相爱相杀的冤家
至于到底该怎么平衡并行度与并行计算任务两者之间的关系我们留到后面的课程去展开。这里咱们只要记住和CPU设置有关配置项的含义、区别与作用就行了。
<img src="https://static001.geekbang.org/resource/image/23/ff/234e6b62ff32394f99055c9385988aff.jpeg" alt="" title="与CPU有关的配置项">
## 哪些配置项与内存设置有关?
说完CPU咱们接着说说与内存管理有关的配置项。我们知道在管理模式上Spark分为堆内内存与堆外内存。
堆外内存又分为两个区域Execution Memory和Storage Memory。要想要启用堆外内存我们得先把参数spark.memory.offHeap.enabled置为true然后用spark.memory.offHeap.size指定堆外内存大小。堆内内存也分了四个区域也就是Reserved Memory、User Memory、Execution Memory和Storage Memory。
内存的基础配置项主要有5个它们的含义如下表所示
<img src="https://static001.geekbang.org/resource/image/67/50/67528832632c392d7e2e4c89a872b350.jpeg" alt="" title="与内存有关的配置项">
简单来说,**这些配置项决定了我们刚才说的这些区域的大小**,这很好理解。工具有了,但很多同学在真正设置内存区域大小的时候还会有各种各样的疑惑,比如说:
- 内存空间是有限的,该把多少内存划分给堆内,又该把多少内存留给堆外呢?
- 在堆内内存里该怎么平衡User Memory和Spark用于计算的内存空间
- 在统一内存管理模式下该如何平衡Execution Memory和Storage Memory
别着急,接下来,咱们一个一个来解决。
### 堆外与堆内的平衡
相比JVM堆内内存off heap堆外内存有很多优势如更精确的内存占用统计和不需要垃圾回收机制以及不需要序列化与反序列化。你可能会说“既然堆外内存这么厉害那我们干脆把所有内存都划分给它不就得了”先别急着下结论我们先一起来看一个例子。
用户表1记录着用户数据每个数据条目包含4个字段整型的用户ID、String类型的姓名、整型的年龄和Char类型的性别。如果现在要求你用字节数组来存储每一条用户记录你该怎么办呢
<img src="https://static001.geekbang.org/resource/image/13/83/136d2f0618d374f2622e1985984ca783.jpeg" alt="" title="用户表1简单数据模式">
我们一起来做一下。首先除姓名外其它3个字段都是定长数据类型因此可以直接安插到字节数组中。对于变长数据类型如String由于我们事先并不知道每个用户的名字到底有多长因此为了把name字段也用字节数组的形式存储我们只能曲线救国先记录name字段的在整个字节数组内的偏移量再记录它的长度最后把完整的name字符串安插在字节数组的末尾如下图所示。
<img src="https://static001.geekbang.org/resource/image/51/2c/516c0e41e6757193533c8dfa33f9912c.jpg" alt="" title="用字节数组存储用户记录">
尽管存储String类型的name字段麻烦一些但我们总算成功地用字节数组容纳了每一条用户记录。OK大功告成
你可能会问“做这个小实验的目的是啥呢”事实上Spark开辟的堆外内存就是以这样的方式来存储应用数据的。**正是基于这种紧凑的二进制格式相比JVM堆内内存Spark通过Java Unsafe API在堆外内存中的管理才会有那么多的优势。**
不过成也萧何败也萧何字节数组自身的局限性也很难突破。比如说如果用户表1新增了兴趣列表字段类型为List[String]如用户表2所示。这个时候如果我们仍然采用字节数据的方式来存储每一条用户记录不仅越来越多的指针和偏移地址会让字段的访问效率大打折扣而且指针越多内存泄漏的风险越大数据访问的稳定性就值得担忧了。
<img src="https://static001.geekbang.org/resource/image/07/92/07d010f82edf0bec630fd9ed57ba8892.png" alt="" title="用户表2复杂数据模式">
因此当数据模式Data Schema开始变得复杂时Spark直接管理堆外内存的成本将会非常高。
那么针对有限的内存资源我们该如何平衡JVM堆内内存与off heap堆外内存的划分我想你心中也该有了答案。**对于需要处理的数据集如果数据模式比较扁平而且字段多是定长数据类型就更多地使用堆外内存。相反地如果数据模式很复杂嵌套结构或变长字段很多就更多采用JVM堆内内存会更加稳妥。**
### User Memory与Spark可用内存如何分配
接下来我们再来说说User Memory。我们都知道参数spark.memory.fraction的作用是明确Spark可支配内存占比换句话说就是在所有的堆内空间中有多大比例的内存可供Spark消耗。相应地1 - spark.memory.fraction就是User Memory在堆内空间的占比。
因此,**spark.memory.fraction参数决定着两者如何瓜分堆内内存它的系数越大Spark可支配的内存越多User Memory区域的占比自然越小**。spark.memory.fraction的默认值是0.6也就是JVM堆内空间的60%会划拨给Spark支配剩下的40%划拨给User Memory。
那么User Memory都用来存啥呀需要预留那么大的空间吗简单来说User Memory存储的主要是开发者自定义的数据结构这些数据结构往往用来协助分布式数据集的处理。
举个例子还记得调度系统那一讲Label Encoding的例子吗
```
/**
实现方式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;)
```
在这个例子中我们先读取包含用户兴趣的模板文件然后根据模板内容构建兴趣到索引的映射字典。在对千亿样本做Lable Encoding的时候这个字典可以快速查找兴趣字符串并返回对应索引来辅助完成数据处理。像这样的映射字典就是所谓的自定义数据结构这部分数据都存储在User Memory内存区域。
因此,**当在JVM内平衡Spark可用内存和User Memory时你需要考虑你的应用中类似的自定义数据结构多不多、占比大不大然后再相应地调整两块内存区域的相对占比**。如果应用中自定义的数据结构很少不妨把spark.memory.fraction配置项调高让Spark可以享用更多的内存空间用于分布式计算和缓存分布式数据集。
## Execution Memory该如何与Storage Memory平衡
最后咱们再来说说Execution Memory与Storage Memory的平衡。在内存管理那一讲我给你讲了一个黄四郎地主招租的故事并用故事中的占地协议类比了执行内存与缓存内存之间的竞争关系。执行任务与RDD缓存共享Spark可支配内存但是,执行任务在抢占方面有更高的优先级。
因此通常来说在统一内存管理模式下spark.memory.storageFraction的设置就显得没那么紧要因为无论这个参数设置多大执行任务还是有机会抢占缓存内存而且一旦完成抢占就必须要等到任务执行结束才会释放。
不过,凡事都没有绝对,**如果你的应用类型是“缓存密集型”,如机器学习训练任务,就很有必要通过调节这个参数来保障数据的全量缓存**。这类计算任务往往需要反复遍历同一份分布式数据集数据缓存与否对任务的执行效率起着决定性作用。这个时候我们就可以把参数spark.memory.storageFraction调高然后有意识地在应用的最开始把缓存灌满再基于缓存数据去实现计算部分的业务逻辑。
但在这个过程中,**你要特别注意RDD缓存与执行效率之间的平衡**。为什么这么说呢?
首先RDD缓存占用的内存空间多了Spark用于执行分布式计算任务的内存空间自然就变少了而且数据分析场景中常见的关联、排序和聚合等操作都会消耗执行内存这部分内存空间变少自然会影响到这类计算的执行效率。
其次大量缓存引入的GCGarbage Collection垃圾回收负担对执行效率来说是个巨大的隐患。
你还记得黄四郎要招租的土地分为托管田和自管田吗托管田由黄四郎派人专门打理土地秋收后的翻土、整平等杂务为来年种下一茬庄稼做准备。堆内内存的垃圾回收也是一个道理JVM大体上把Heap堆内内存分为年轻代和老年代。年轻代存储生命周期较短、引用次数较低的对象老年代则存储生命周期较长、引用次数高的对象。因此像RDD cache这种一直缓存在内存里的数据一定会被JVM安排到老年代。
年轻代的垃圾回收工作称为Young GC老年代的垃圾回收称为Full GC。每当老年代可用内存不足时都会触发JVM执行Full GC。在Full GC阶段JVM会抢占应用程序执行线程强行征用计算节点中所有的CPU线程也就是“集中力量办大事”。当所有CPU线程都被拿去做垃圾回收工作的时候应用程序的执行只能暂时搁置。只有等Full GC完事之后把CPU线程释放出来应用程序才能继续执行。这种Full GC征用CPU线程导致应用暂停的现象叫做“Stop the world”。
因此Full GC对于应用程序的伤害远大于Young GC并且GC的效率与对象个数成反比对象个数越多GC效率越差。这个时候对于RDD这种缓存在老年代中的数据就很容易引入Full GC问题。
一般来说为了提升RDD cache访问效率很多同学都会采用以对象值的方式把数据缓存到内存因为对象值的存储方式避免了数据存取过程中序列化与反序列化的计算开销。我们在RDD/DataFrame/Dataset之上调用cache方法的时候默认采用的就是这种存储方式。
但是采用对象值的方式缓存数据不论是RDD还是DataFrame、Dataset每条数据样本都会构成一个对象要么是开发者自定义的Case class要么是Row对象。换句话说老年代存储的对象个数基本等于你的样本数。因此当你的样本数大到一定规模的时候你就需要考虑大量的RDD cache可能会引入的Full GC问题了。
基于上面的分析,我们不难发现,**在打算把大面积的内存空间用于RDD cache之前你需要衡量这么做可能会对执行效率产生的影响**。
你可能会说:“我的应用就是缓存密集型,确实需要把数据缓存起来,有什么办法来平衡执行效率吗?”办法还是有的。
**首先,你可以放弃对象值的缓存方式,改用序列化的缓存方式,序列化会把多个对象转换成一个字节数组。**这样,对象个数的问题就得到了初步缓解。
**其次我们可以调节spark.rdd.compress这个参数。**RDD缓存默认是不压缩的启用压缩之后缓存的存储效率会大幅提升有效节省缓存内存的占用从而把更多的内存空间留给分布式任务执行。
通过这两类调整开发者在享用RDD数据访问效率的同时还能够有效地兼顾应用的整体执行效率可谓是两全其美。不过有得必有失尽管这两类调整优化了内存的使用效率但都是以引入额外的计算开销、牺牲CPU为代价的。这也就是我们一直强调的性能调优的过程本质上就是不断地平衡不同硬件资源消耗的过程。
## 哪些配置项与磁盘设置有关?
在存储系统那一讲我们简单提到过spark.local.dir这个配置项这个参数允许开发者设置磁盘目录该目录用于存储RDD cache落盘数据块和Shuffle中间文件。
通常情况下spark.local.dir会配置到本地磁盘中容量比较宽裕的文件系统毕竟这个目录下会存储大量的临时文件我们需要足够的存储容量来保证分布式任务计算的稳定性。不过如果你的经费比较充裕有条件在计算节点中配备足量的SSD存储甚至是更多的内存资源完全可以把SSD上的文件系统目录或是内存文件系统添加到spark.local.dir配置项中去从而提供更好的I/O性能。
## 小结
掌握硬件资源类的配置项是我们打破性能瓶颈,以及平衡不同硬件资源利用率的必杀技。具体来说,我们可以分成两步走。
**第一步理清CPU、内存和磁盘这三个方面的性能配置项都有什么以及它们的含义。**因此,我把硬件资源类配置项的含义都汇总在了一个表格中,方便你随时查看。有了这份手册,在针对硬件资源进行配置项调优时,你就能够做到不重不漏。
<img src="https://static001.geekbang.org/resource/image/c9/0a/c9cfdb17e3ec6e8d022ff8e91e4a170a.jpg" alt="">
**第二步,重点理解这些配置项的作用,以及可以解决的问题。**
首先对于CPU类配置项我们要重点理解并行度与并行计算任务数的区别。并行度从数据的角度出发明确了数据划分的粒度并行度越高数据粒度越细数据越分散CPU资源利用越充分但同时要提防数据粒度过细导致的调度系统开销。
并行计算任务数则不同,它从计算的角度出发,强调了分布式集群在任一时刻并行处理的能力和容量。并行度与并行计算任务数之间互相影响、相互制约。
其次对于内存类配置项我们要知道怎么设置它们来平衡不同内存区域的方法。这里我们主要搞清楚3个问题就可以了
1. 在平衡堆外与堆内内存的时候,我们要重点考察数据模式。如果数据模式比较扁平,而且定长字段较多,应该更多地使用堆外内存。相反地,如果数据模式比较复杂,应该更多地利用堆内内存
1. 在平衡可支配内存和User memory的时候我们要重点考察应用中自定义的数据结构。如果数据结构较多应该保留足够的User memory空间。相反地如果数据结构较少应该让Spark享有更多的可用内存资源
1. 在平衡Execution memory与Storage memory的时候如果RDD缓存是刚需我们就把spark.memory.storageFraction调大并且在应用中优先把缓存灌满再把计算逻辑应用在缓存数据之上。除此之外我们还可以同时调整spark.rdd.compress和spark.memory.storageFraction来缓和Full GC的冲击
## 每日一练
1. 并行度设置过大会带来哪些弊端?
1. 在Shuffle的计算过程中有哪些Spark内置的数据结构可以充分利用堆外内存资源
1. 堆外与堆内的取舍,你还能想到其他的制约因素吗?
1. 如果内存资源足够丰富有哪些方式可以开辟内存文件系统用于配置spark.local.dir参数
期待在留言区看到你的思考和答案,也欢迎你把这份硬件资源配置项手册分享给更多的朋友,我们下一讲见!

View File

@@ -0,0 +1,136 @@
<audio id="audio" title="10 |调优一筹莫展,配置项速查手册让你事半功倍!(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c3/f0/c3f6e29f6f5f983d7dd75f3ddaec28f0.mp3"></audio>
你好,我是吴磊。
上一讲我们讲了硬件资源类的配置项。这一讲我们继续说说Shuffle类和Spark SQL大类都有哪些配置项它们的含义和作用以及它们能解决的问题。同时和上一讲一样我们今天讲到的配置项也全部会围绕Executors展开。
## Shuffle类配置项
首先我们来说说Shuffle类。纵观Spark官网的[Configuration页面](http://spark.apache.org/docs/latest/configuration.html)你会发现能调节Shuffle执行性能的配置项真是寥寥无几。其实这也很好理解因为一旦Shuffle成为应用中不可或缺的一环想要优化Shuffle本身的性能我们能做的微乎其微。
不过我们也不是完全束手无策。我们知道Shuffle的计算过程分为Map和Reduce这两个阶段。其中Map阶段执行映射逻辑并按照Reducer的分区规则将中间数据写入到本地磁盘Reduce阶段从各个节点下载数据分片并根据需要实现聚合计算。
那么我们就可以通过spark.shuffle.file.buffer和spark.reducer.maxSizeInFlight这两个配置项来分别调节Map阶段和Reduce阶段读写缓冲区的大小。具体该怎么做呢我们一一来看。
<img src="https://static001.geekbang.org/resource/image/ab/50/ab65b81ffe85e61b186b1fb3e8620750.jpeg" alt="" title="缓冲区相关配置项">
首先在Map阶段计算结果会以中间文件的形式被写入到磁盘文件系统。同时为了避免频繁的I/O操作Spark会把中间文件存储到写缓冲区Write Buffer。**这个时候我们可以通过设置spark.shuffle.file.buffer来扩大写缓冲区的大小缓冲区越大能够缓存的落盘数据越多Spark需要刷盘的次数就越少I/O效率也就能得到整体的提升。**
其次在Reduce阶段因为Spark会通过网络从不同节点的磁盘中拉取中间文件它们又会以数据块的形式暂存到计算节点的读缓冲区Read Buffer。缓冲区越大可以暂存的数据块越多在数据总量不变的情况下拉取数据所需的网络请求次数越少单次请求的网络吞吐越高网络I/O的效率也就越高。**这个时候我们就可以通过spark.reducer.maxSizeInFlight配置项控制Reduce端缓冲区大小来调节Shuffle过程中的网络负载。**
事实上对Shuffle计算过程的优化牵扯到了全部的硬件资源包括CPU、内存、磁盘和网络。因此我们上一讲汇总的关于CPU、内存和硬盘的配置项也同样可以作用在Map和Reduce阶段的内存计算过程上。
除此之外Spark还提供了一个叫做**spark.shuffle.sort.bypassMergeThreshold**的配置项去处理一种特殊的Shuffle场景。
<img src="https://static001.geekbang.org/resource/image/49/71/49yy9eff7b5521f0d51f9451252e0a71.jpeg" alt="" title="Reduce端相关配置项">
自1.6版本之后Spark统一采用Sort shuffle manager来管理Shuffle操作在Sort shuffle manager的管理机制下无论计算结果本身是否需要排序Shuffle计算过程在Map阶段和Reduce阶段都会引入排序操作。
这样的实现机制对于repartition、groupBy这些操作就不太公平了这两个算子一个是对原始数据集重新划分分区另一个是对数据集进行分组压根儿就没有排序的需求。所以Sort shuffle manager实现机制引入的排序步骤反而变成了一种额外的计算开销。
因此,**在不需要聚合也不需要排序的计算场景中我们就可以通过设置spark.shuffle.sort.bypassMergeThreshold的参数来改变Reduce端的并行度**默认值是200。当Reduce端的分区数小于这个设置值的时候我们就能避免Shuffle在计算过程引入排序。
## Spark SQL大类配置项
接下来我们再来说说Spark SQL的相关配置项。在官网的[Configuration页面](http://spark.apache.org/docs/latest/configuration.html)中Spark SQL下面的配置项还是蛮多的其中对执行性能贡献最大的当属AQEAdaptive query execution自适应查询引擎引入的那3个特性了也就是自动分区合并、自动数据倾斜处理和Join策略调整。因此关于Spark SQL的配置项咱们围绕着这3个特性去汇总。
首先我们要知道,**AQE功能默认是禁用的想要使用这些特性我们需要先通过配置项spark.sql.adaptive.enabled来开启AQE**,具体的操作如下:
<img src="https://static001.geekbang.org/resource/image/b3/5b/b39cdde3cd466b5fba4cdyy01386eb5b.jpeg" alt="" title="启用AQE的配置项">
因为这3个特性的原理我们在开发原则那一讲说过这里我会先带你简单回顾一下然后我们重点来讲这些环节对应的配置项有哪些。
### 哪些配置项与自动分区合并有关?
分区合并的场景用一句概括就是在Shuffle过程中因为数据分布不均衡导致Reduce阶段存在大量的小分区这些小分区的数据量非常小调度成本很高。
那么问题来了AQE是如何判断某个分区是不是足够小到底需不需要合并的呢另外既然是对多个分区进行合并自然就存在一个收敛条件的问题如果一直不停地合并下去整个分布式数据集最终就会合并为一个超级大的分区。简单来说就是“分区合并从哪里开始又到哪里结束呢
<img src="https://static001.geekbang.org/resource/image/da/4f/dae9dc8b90c2d5e0cf77180ac056a94f.jpg" alt="" title="分区合并示意图">
我们一起来看一下AQE分区合并的工作原理。如上图所示对于所有的数据分区无论大小AQE按照分区编号从左到右进行扫描边扫描边记录分区尺寸当相邻分区的尺寸之和大于“目标尺寸”时AQE就把这些扫描过的分区进行合并。然后继续向右扫描并采用同样的算法按照目标尺寸合并剩余分区直到所有分区都处理完毕。
总的来说就是,**AQE事先并不判断哪些分区足够小而是按照分区编号进行扫描当扫描量超过“目标尺寸”时就合并一次**。我们发现,这个过程中的关键就是“目标尺寸”的确定,它的大小决定了合并之后分布式数据集的分散程度。
那么“目标尺寸”由什么来决定的呢Spark提供了两个配置项来共同决定分区合并的“目标尺寸”它们分别是spark.sql.adaptive.advisoryPartitionSizeInBytes和spark.sql.adaptive.coalescePartitions.minPartitionNum。
<img src="https://static001.geekbang.org/resource/image/62/95/620fde8bd55cd9e937ebc31060936395.jpeg" alt="" title="AQE自动分区合并相关配置项">
其中第一个参数advisoryPartitionSizeInBytes是开发者建议的目标尺寸第二个参数minPartitionNum的含义是合并之后的最小分区数假设它是200就说明合并之后的分区数量不能小于200。这个参数的目的就是避免并行度过低导致CPU资源利用不充分。
结合Shuffle后的数据集尺寸和最小分区数限制我们可以反推出来每个分区的平均大小咱们暂且把它记为#partitionSize。分区合并的目标尺寸取advisoryPartitionSizeInBytes与#partitionSize之间的最小值
这么说比较抽象我们来举个例子。假设Shuffle过后数据大小为20GBminPartitionNum设置为200反推过来每个分区的尺寸就是20GB / 200 = 100MB。再假设advisoryPartitionSizeInBytes设置为200MB最终的目标分区尺寸就是取100MB200MB之间的最小值也就是100MB。因此你看并不是你指定了advisoryPartitionSizeInBytes是多少Spark就会完全尊重你的意见我们还要考虑minPartitionNum的设置。
### 哪些配置项与自动数据倾斜处理有关?
再来说说数据倾斜在数据关联Data Joins的场景中当AQE检测到倾斜的数据分区时会自动进行拆分操作把大分区拆成多个小分区从而避免单个任务的数据处理量过大。不过Spark 3.0版本发布的AQE暂时只能在Sort Merge Join中自动处理数据倾斜其他的Join实现方式如Shuffle Join还有待支持。
那么AQE如何判定数据分区是否倾斜呢它又是怎么把大分区拆分成多个小分区的
<img src="https://static001.geekbang.org/resource/image/3c/d6/3cd86b383909ecb2577d3839edfe2dd6.jpeg" alt="" title="AQE数据倾斜处理相关配置项">
首先,**分区尺寸必须要大于spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes参数的设定值才有可能被判定为倾斜分区。然后AQE统计所有数据分区大小并排序取中位数作为放大基数尺寸大于中位数一定倍数的分区会被判定为倾斜分区中位数的放大倍数也是由参数spark.sql.adaptive.skewJoin.skewedPartitionFactor控制。**
接下来我们还是通过一个例子来理解。假设数据表A有3个分区分区大小分别是80MB、100MB和512MB。显然这些分区按大小个排序后的中位数是100MB因为skewedPartitionFactor的默认值是5倍所以大于100MB * 5 = 500MB的分区才有可能被判定为倾斜分区。在我们的例子中只有最后一个尺寸是512MB的分区符合这个条件。
这个时候Spark还不能完全判定它就是倾斜分区还要看skewedPartitionThresholdInBytes配置项这个参数的默认值是256MB。对于那些满足中位数条件的分区必须要大于256MBSaprk才会把这个分区最终判定为倾斜分区。假设skewedPartitionThresholdInBytes设定为1GB那在我们的例子中512MB那个大分区Spark也不会把它看成是倾斜分区自然也就不能享受到AQE对于数据倾斜的优化处理。
检测到倾斜分区之后接下来就是对它拆分拆分的时候还会用到advisoryPartitionSizeInBytes参数。假设我们将这个参数的值设置为256MB那么刚刚那个512MB的倾斜分区会以256MB为粒度拆分成多份因此这个大分区会被拆成 2 个小分区( 512MB / 256MB =2。拆分之后原来的数据表就由3个分区变成了4个分区每个分区的尺寸都不大于256MB。
### 哪些配置项与Join策略调整有关
最后咱们再来说说数据关联Joins。数据关联可以说是数据分析领域中最常见的操作Spark SQL中的Join策略调整它实际上指的是把会引入Shuffle的Join方式如Hash Join、Sort Merge Join“降级”Demote为Broadcast Join。
**Broadcast Join的精髓在于“以小博大”它以广播的方式将小表的全量数据分发到集群中所有的Executors大表的数据不需要以Join keys为基准去Shuffle就可以与小表数据原地进行关联操作。**Broadcast Join以小表的广播开销为杠杆博取了因消除大表Shuffle而带来的巨大性能收益。可以说Broadcast Join把“杠杆原理”应用到了极致。
在Spark发布AQE之前开发者可以利用spark.sql.autoBroadcastJoinThreshold配置项对数据关联操作进行主动降级。这个参数的默认值是10MB参与Join的两张表中只要有一张数据表的尺寸小于10MB二者的关联操作就可以降级为Broadcast Join。为了充分利用Broadcast Join“以小博大”的优势你可以考虑把这个参数值调大一些2GB左右往往是个不错的选择。
<img src="https://static001.geekbang.org/resource/image/d0/61/d082a82b24e2f5ecc484d849abe2e361.jpeg" alt="" title="AQE推出之前Join策略相关配置项">
不过autoBroadcastJoinThreshold这个参数虽然好用但是有两个让人头疼的短板。
一是可靠性较差。尽管开发者明确设置了广播阈值而且小表数据量在阈值以内但Spark对小表尺寸的误判时有发生导致Broadcast Join降级失败。
二来预先设置广播阈值是一种静态的优化机制它没有办法在运行时动态对数据关联进行降级调整。一个典型的例子是两张大表在逻辑优化阶段都不满足广播阈值此时Spark SQL在物理计划阶段会选择Shuffle Joins。但在运行时期间其中一张表在Filter操作之后有可能出现剩余的数据量足够小小到刚好可以降级为Broadcast Join。在这种情况下静态优化机制就是无能为力的。
AQE很好地解决了这两个头疼的问题。**首先AQE的Join策略调整是一种动态优化机制对于刚才的两张大表AQE会在数据表完成过滤操作之后动态计算剩余数据量当数据量满足广播条件时AQE会重新调整逻辑执行计划在新的逻辑计划中把Shuffle Joins降级为Broadcast Join。再者运行时的数据量估算要比编译时准确得多因此AQE的动态Join策略调整相比静态优化会更可靠、更稳定。**
<img src="https://static001.geekbang.org/resource/image/db/5b/db78a727a8fc6fc6da0cdecaa5ba755b.jpeg" alt="" title="AQE推出之后Join策略相关配置项">
不过启用动态Join策略调整还有个前提也就是要满足nonEmptyPartitionRatioForBroadcastJoin参数的限制。这个参数的默认值是0.2大表过滤之后非空的数据分区占比要小于0.2才能成功触发Broadcast Join降级。
这么说有点绕我们来举个例子。假设大表过滤之前有100个分区Filter操作之后有85个分区内的数据因为不满足过滤条件在过滤之后都变成了没有任何数据的空分区另外的15个分区还保留着满足过滤条件的数据。这样一来这张大表过滤之后的非空分区占比是 15 / 100 = 15%因为15%小于0.2所以这个例子中的大表会成功触发Broadcast Join降级。
相反如果大表过滤之后非空分区占比大于0.2那么剩余数据量再小AQE也不会把Shuffle Joins降级为Broadcast Join。因此如果你想要充分利用Broadcast Join的优势可以考虑把这个参数适当调高。
## 小结
今天这一讲我们深入探讨了Shuffle类和Spark SQL大类两类配置项以及每个配置项可以解决的问题。
对于Shuffle类我们要知道在Shuffle过程中对于不需要排序和聚合的操作我们可以通过控制spark.shuffle.sort.bypassMergeThreshold参数来避免Shuffle执行过程中引入的排序环节从而避免没必要的计算开销。
对于Spark SQL大类我们首先要知道AQE默认是禁用状态要充分利用AQE提供的3个特性就是自动分区合并、数据倾斜处理和Join策略调整我们需要把spark.sql.adaptive.enabled置为true。
除此之外AQE的3个特性各自都有相对应的配置项需要我们单独调整。
<li>
AQE中的自动分区合并过程与我们预想的不太一样。QE事先并不判断哪些分区足够小而是按照分区编号进行扫描当扫描量超过“目标尺寸”时就合并一次。目标尺寸由advisoryPartitionSizeInBytes和coalescePartitions.minPartitionNum两个参数共同决定。
</li>
<li>
AQE能够自动处理Sort Merge Join场景中的数据倾斜问题。首先根据所有分区大小的中位数以及放大倍数skewedPartitionFactor来检测倾斜分区然后以advisoryPartitionSizeInBytes为粒度对倾斜分区进行拆分。
</li>
<li>
AQE动态Join策略调整可以在运行时将Shuffle Joins降级为Broadcast Join同时运行时的数据量估算要比编译时准确得多因此相比静态优化会更可靠。不过需要我们注意的是Shuffle过后非空分区占比要小于nonEmptyPartitionRatioForBroadcastJoin才能触发Broadcast Join的降级优化。
</li>
好啦经过这两讲的学习我们一起汇总出了Spark中与性能调优息息相关的所有配置项为了方便你快速查阅我把它们合并在了一张文稿的表格中希望你能在工作中好好利用起来。
<img src="https://static001.geekbang.org/resource/image/31/6a/31356505a2c36bac10de0e06d7e4526a.jpg" alt="">
## 每日一练
1. AQE的分区合并算法略显简单粗暴如果让你来重新实现分区合并特性的话你都有哪些思路呢
1. AQE中数据倾斜的处理机制你认为有哪些潜在的隐患
期待在留言区看到你的思考和答案,也欢迎你把这份调优手册分享给你的朋友们,我们下一讲见!

View File

@@ -0,0 +1,211 @@
<audio id="audio" title="11 | 为什么说Shuffle是一时无两的性能杀手" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c7/c0/c78d924b9db6931ec74a05c826e069c0.mp3"></audio>
你好,我是吴磊。
一提到Shuffle你能想到什么我想很多人的第一反应都是应用中最顽固、最难解决的性能瓶颈。
在之前的课程中我们也不止一次地提到Shuffle尤其是在开发原则那一讲我还建议你遵循“能省则省、能拖则拖”的原则在应用中尽量去避免Shuffle如果受业务逻辑所限确实不能避免就尽可能地把Shuffle往后拖。
那么为什么我们一谈Shuffle就色变提到它就避之唯恐不及呢今天这一讲我就通过实现一个仙女散花游戏的过程来和你深入探讨Shuffle是如何工作的说说它为什么是分布式应用一时无两的性能杀手。毕竟只有了解Shuffle的工作原理我们才能更好地避免它。
## 如何理解Shuffle
假设你的老板今天给你安排一个开发任务让你用Spark去实现一个游戏需求。这个实现需求来自一个小故事仙女散花。
很久以前燕山脚下有个小村庄村子里有所“七色光”小学方圆百里的孩子都来这里上学。这一天一年级2班的黄老师和班里的五个孩子正在做一个游戏叫做“仙女散花”。
黄老师的背包里装满了五种不同颜色的花朵,五种颜色分别是红、橙、黄、紫、青。她把背包里的花朵随机地分发给五个小同学:小红、橙橙、黄小乙、阿紫和小青。花朵发完之后,每个同学分到的花朵数量差不多,颜色各有不同。
接着黄老师开始宣布游戏规则“你们要一起协作在最短的时间内把花朵按照颜色收集在一起。游戏完成的标志是课桌上有5堆花朵每堆花朵的颜色都是一样的。”
<img src="https://static001.geekbang.org/resource/image/7c/yc/7cb3906ecd19fa0521c4a161f079dyyc.jpg" alt="" title="仙女散花的游戏">
大家跃跃欲试黄小乙说“先别急我们来制定个策略。先在前面摆上5张课桌然后每个人需要做两件事情先把自己手里的花朵按照颜色归类分为5堆再把分好颜色的花朵分别放到相应的课桌上”于是几个小同学按照黄小乙说的策略不一会儿就完成了游戏。
事实上仙女散花的游戏过程和Shuffle的工作流程大同小异。当然Shuffle过程中涉及的步骤和环节要比“仙女散花”复杂一些。
Shuffle的本意是“洗牌”在分布式计算环境中它有两个阶段。一般来说前一个阶段叫做“Map阶段”后一个阶段叫做“Reduce阶段”。当然也有人把它们叫做Shuffle Write阶段和Shuffle Read阶段。
<img src="https://static001.geekbang.org/resource/image/c4/d7/c4062c2a0d6fd31b425034a8f53159d7.jpg" alt="" title="仙女散花中的Map和Reduce阶段">
**在仙女散花的游戏中从老师分发花朵到5个小同学把花朵按照颜色归类对应的是Shuffle的Map阶段而大家把归类的花朵分发到相应的课桌这个过程类似于Shuffle的Reduce阶段。**
接下来我们就借助这个故事来深入探讨Shuffle的两个阶段Map阶段和Reduce阶段。
自2.0版本之后Spark将Shuffle操作统一交由Sort shuffle manager来管理。因此今天这一讲我们专注于Sort shuffle manager实现的Shuffle分发过程。
## Map阶段是如何输出中间文件的
以终为始、以结果为导向的学习往往效率更高在深入探讨Map阶段如何生产数据之前我们不妨先来明确**Map阶段的输出到底是什么**
之前我们也说过Map阶段最终生产的数据会以中间文件的形式物化到磁盘中这些中间文件就存储在spark.local.dir设置的文件目录里。中间文件包含两种类型一类是后缀为data的数据文件存储的内容是Map阶段生产的待分发数据另一类是后缀为index的索引文件它记录的是数据文件中不同分区的偏移地址。这里的分区是指Reduce阶段的分区**因此分区数量与Reduce阶段的并行度保持一致。**
<img src="https://static001.geekbang.org/resource/image/85/c5/85f8e918e65792527889a71e2ca55dc5.jpg" alt="" title="Map阶段输出的数据文件和索引文件">
这样一来我们就可以把问题进一步聚焦在Spark在Map阶段是如何生产这些中间文件的。不过我们首先需要明确的是Map阶段每一个Task的执行流程都是一样的每个Task最终都会生成一个数据文件和一个索引文件。**因此中间文件的数量与Map阶段的并行度保持一致。**换句话说有多少个TaskMap阶段就会生产相应数量的数据文件和索引文件。
接下来我带你用Spark来实现“仙女散花”的游戏咱们一边做游戏一边来分析Map阶段的中间文件是如何产生的。
### 用groupByKey实现“仙女散花”
在“仙女散花”的游戏中,黄老师要求大家把同一种花色的花朵**收集**到一起。那么在Spark的分布式开发框架内与这个游戏最相仿的计算过程非groupByKey莫属所以我们不妨用groupByKey来实现游戏。
首先是flowers.txt文件
```
黄色花朵
紫色花朵
红色花朵
橙色花朵
青色花朵
黄色花朵
紫色花朵
橙色花朵
青色花朵
......
```
其次是同学小A接到需求后用groupByKey实现“仙女散花”游戏的代码
```
val flowers = spark.sparkContext.textFile(&quot;flowers.txt&quot;)
//黄老师给5个小同学分发花朵
val flowersForKids = flowers.coalesce(5)
val flowersKV = flowersForKids.map((_, 1))
//黄小乙的两个步骤:大家先各自按颜色归类,然后再把归类后的花朵放到相应的课桌上
flowersKV.groupByKey.collect
```
我们可以看到代码步骤与游戏过程基本上一一对应。但是读取完花朵文件之后由于groupByKey是pairRDD算子需要消费KeyValue形式的数据因此我们需要对原始花朵数据做一次转换。以数据分区0为例数据的转换过程如下图所示你不妨把数据分区0理解为是黄老师分发给小红的花朵。
<img src="https://static001.geekbang.org/resource/image/19/e5/199ef31535ccabdcb2fa172cafb036e5.jpg" alt="" title="将原始数据转换为pairRDD">
基于pairRDD的Key也就是花朵的颜色Map Task就可以计算每条数据记录在Reduce阶段的目标分区目标分区也就是游戏中的课桌。在黄小乙制定的策略中哪种花放到哪张桌子是大家事先商定好的但在Spark中每条数据记录应该分发到哪个目标分区是由Key的哈希值决定的。
**目标分区计算好之后Map Task会把每条数据记录和它的目标分区放到一个特殊的数据结构里这个数据结构叫做“PartitionedPairBuffer”**,它本质上就是一种数组形式的缓存结构。它是怎么存储数据记录的呢?
每条数据记录都会占用数组中相邻的两个元素空间第一个元素是目标分区Key第二个元素是Value。假设PartitionedPairBuffer的大小是4也就是最多只能存储4条数据记录。那么如果我们还以数据分区0为例小红的前4枚花朵在PartitionedPairBuffer中的存储状态就会如下所示。
<img src="https://static001.geekbang.org/resource/image/36/28/36fd9b5318e82bf1a3264fb558c0e128.jpg" alt="" title="PartitionedPairBuffer存储小红的前4枚花朵">
对我们来说最理想的情况当然是PartitionedPairBuffer足够大大到足以容纳Map Task所需处理的所有数据。不过现实总是很骨感每个Task分到的内存空间是有限的PartitionedPairBuffer自然也不能保证能容纳分区中的所有数据。因此**Spark需要一种计算机制来保障在数据总量超出可用内存的情况下依然能够完成计算。这种机制就是排序、溢出、归并。**
就拿大小为4的PartitionedPairBuffer来说数据分区0里面有16朵花对应着16条数据记录它们至少要分4批才能依次完成处理。在处理下一批数据之前Map Task得先把PartitionedPairBuffer中已有的数据腾挪出去腾挪的方式简单粗暴Map Task直接把数据溢出到磁盘中的临时文件。
不过在溢出之前对于PartitionedPairBuffer中已有的数据Map Task会先按照数据记录的第一个元素也就是目标分区 + Key进行排序。也就是说尽管数据暂时溢出到了磁盘但是临时文件中的数据也是有序的。
就这样PartitionedPairBuffer腾挪了一次又一次数据分区0里面的花朵处理了一批又一批直到所有的花朵都被处理完。分区0有16朵花PartitionedPairBuffer的大小是4因此PartitionedPairBuffer总共被腾挪了3次生成了3个临时文件每个临时文件中包含4条数据记录。16条数据有12条分散在3个文件中还有4条缓存在PartitionedPairBuffer里。
到此为止我们离Map阶段生产的、用于在网络中分发数据的中间文件仅有一步之遥了。还记得吗Map阶段生产的中间文件有两类一类是数据文件另一类是索引文件。分散在3个临时文件和PartitionedPairBuffer里的数据记录就是生成这两类文件的输入源。最终Map Task用归并排序的算法将4个输入源中的数据写入到数据文件和索引文件中去如下图所示。
<img src="https://static001.geekbang.org/resource/image/47/9a/4778e6e7ba922b101936a4c983b3ed9a.jpg" alt="" title="归并临时文件,生成最终的中间文件">
好了到目前为止我们用groupByKey实现了“仙女散花”的游戏详细讲解了Map阶段生产中间文件的过程。虽然Map阶段的计算步骤很多但其中最主要的环节可以归结为4步
<strong>1. 对于分片中的数据记录逐一计算其目标分区并将其填充到PartitionedPairBuffer<br>
2. PartitionedPairBuffer填满后如果分片中还有未处理的数据记录就对Buffer中的数据记录按目标分区IDKey进行排序将所有数据溢出到临时文件同时清空缓存<br>
3. 重复步骤1、2直到分片中所有的数据记录都被处理<br>
4. 对所有临时文件和PartitionedPairBuffer归并排序最终生成数据文件和索引文件。</strong>
不难发现仙女散花其实就是个分组、收集的游戏。应该说用Spark来实现分组、收集类的游戏还是比较简单的那么如果把仙女散花变成是“分组、统计”的游戏我们该怎么做呢
### “仙女散花”游戏升级
5个小同学完成游戏之后离下课时间还早。因此黄老师调整了游戏规则“你们五个人还是一起协作这次要在最短的时间内统计不同花色花朵的数量。”
小红迫不及待地说:“很简单!还是按照刚才的策略,先把花朵分好堆,然后我们五个人分别去课桌上数数就好啦!”
<img src="https://static001.geekbang.org/resource/image/d2/11/d2f80245237d63a5b8977107320c2811.jpg" alt="" title="小红的主意">
黄小乙皱了皱眉,说道:“别急,新的游戏规则也是有时间限制的,我想了一个和你差不多的办法,一共分三步:第一步,每个人把手里不同花色花朵的数量先算出来;第二步,我们只需要把花朵的数量写到相应的桌子上;第三步,我们分别对五张课桌上的数字求和。这样就能完成得更快了”
<img src="https://static001.geekbang.org/resource/image/df/e3/df99bcca853e4948718617672ebddce3.jpg" alt="" title="黄小乙的主意">
### 用reduceByKey实现升级后的仙女散花
如果我们想用Spark来实现升级后的游戏该怎么办呢其实很简单只要把groupByKey换成reduceByKey就好了。
```
val flowers = spark.sparkContext.textFile(&quot;flowers.txt&quot;)
//黄老师给5个小同学分发花朵
val flowersForKids = flowers.coalesce(5)
val flowersKV = flowersForKids.map((_, 1))
//黄小乙的两个步骤:大家先各自按颜色计数,然后再按照课桌统一计数
flowersKV.reduceByKey(_ + _).collect
```
接下来我们来分析一下reduceByKey的Map阶段计算相比groupByKey有何不同。就Map端的计算步骤来说reduceByKey与刚刚讲的groupByKey一样都是先填充内存数据结构然后排序溢出最后归并排序。
区别在于,在计算的过程中,**reduceByKey采用一种叫做PartitionedAppendOnlyMap的数据结构来填充数据记录**。这个数据结构是一种Map而**Map的Value值是可累加、可更新的**。因此PartitionedAppendOnlyMap非常适合聚合类的计算场景如计数、求和、均值计算、极值计算等等。
<img src="https://static001.geekbang.org/resource/image/a3/a4/a3e397dd3ce348f10eae76f809f37ca4.jpg" alt="" title="大小为4的PartitionedAppendOnlyMap">
在上图中4个KV对的Value值是扫描到数据分区0当中青色花朵之前的状态。在PartitionedAppendOnlyMap中由于Value是可累加、可更新的因此这种数据结构可以容纳的花朵数量一定比4大。因此相比PartitionedPairBufferPartitionedAppendOnlyMap的存储效率要高得多溢出数据到磁盘文件的频率也要低得多。
以此类推,最终合并的数据文件也会小很多。**依靠高效的内存数据结构、更少的磁盘文件、更小的文件尺寸我们就能大幅降低了Shuffle过程中的磁盘和网络开销。**
事实上相比groupByKey、collect_list这些收集类算子聚合类算子reduceByKey、aggregateByKey等在执行性能上更占优势。**因此,我们要避免在聚合类的计算需求中,引入收集类的算子。**虽然这种做法不妨碍业务逻辑实现,但在性能调优上可以说是大忌。
## Reduce阶段是如何进行数据分发的
最后我们再来说说Reduce阶段在“仙女散花”的游戏里每个人把自己的花朵归好类之后主动地把不同颜色的花朵放到相应的课桌上这个过程实际上就是Shuffle过程中的数据分发。不过与课桌被动地接收花朵不同的是Shuffle在Reduce阶段是主动地从Map端的中间文件中拉取数据。
<img src="https://static001.geekbang.org/resource/image/78/eb/78d2b2e4ee2aba1a0473367f96da7aeb.jpg" alt="" title="Map阶段输出的数据文件和索引文件">
刚刚讲过每个Map Task都会生成如上图所示的中间文件文件中的分区数与Reduce阶段的并行度一致。换句话说每个Map Task生成的数据文件都包含所有Reduce Task所需的部分数据。因此任何一个Reduce Task要想完成计算必须先从所有Map Task的中间文件里去拉取属于自己的那部分数据。索引文件正是用于帮助判定哪部分数据属于哪个Reduce Task。
**Reduce Task通过网络拉取中间文件的过程实际上就是不同Stages之间数据分发的过程**。在“仙女散花”的游戏中5个孩子与5张课桌之间需要往返25人次。如果让100个孩子把100种颜色的花朵分别收集到100张课桌上那么这100个孩子与100张课桌之间就需要10000人次的往返显然Shuffle中数据分发的网络开销会随着Map Task与Reduce Task的线性增长呈指数级爆炸。
Reduce Task将拉取到的数据块填充到读缓冲区然后按照任务的计算逻辑不停地消费、处理缓冲区中的数据记录如下图所示。
<img src="https://static001.geekbang.org/resource/image/2c/eb/2c4ec7fb70bfd103f70f24e56yya91eb.jpg" alt="" title="Reduce阶段的计算过程">
我们可以看到Reduce阶段用圆圈标记的1、2、3、4与Map阶段的四个步骤一模一样。没错因即是果、果即是因当我们说某个Stage是Map阶段或是Reduce阶段的时候我们的出发点或者说锚点就是Shuffle。对于上图的Shuffle 0来说Stage 0是Map阶段Stage 1是Reduce阶段。但是对于后面的Shuffle 1来说Stage 1就变成了Map 阶段。因此你看当我们把视角拉宽Map和Reduce这两个看似对立的东西其实有很多地方都是相通的。
## 性能杀手
想必经过上面两个阶段的分析你已经对Shuffle为何会成为性能瓶颈有了比较直观的感受。这里我再带你总结一下。
**首先对于Shuffle来说它需要消耗所有的硬件资源**
<li>
无论是PartitionedPairBuffer、PartitionedAppendOnlyMap这些内存数据结构还是读写缓冲区都会消耗宝贵的内存资源
</li>
<li>
由于内存空间有限因此溢出的临时文件会引入大量磁盘I/O而且Map阶段输出的中间文件也会消耗磁盘
</li>
<li>
呈指数级增长的跨节点数据分发,带来的网络开销更是不容小觑。
</li>
**其次Shuffle消耗的不同硬件资源之间很难达到平衡。**磁盘和网络的消耗是Shuffle中必需的环节。但是磁盘与网络的处理延迟相比CPU和内存要相差好几个数量级。以下表为例如果以CPU L1缓存的处理延迟为基准把单位从纳秒校准到秒我们就会惊讶地发现当CPU、内存以秒为单位处理数据时磁盘和网络的处理延迟是以天、月为单位的
<img src="https://static001.geekbang.org/resource/image/64/50/64c9d3fc8524ba81ca048ab29dd55350.jpeg" alt="">
正是基于Shuffle的这些特点我们才会“谈虎色变”一提到Shuffle就避之唯恐不及强调能省则省、能拖则拖。
## 小结
这一讲我借助实现仙女散花这个游戏的需求带你直观地认识Shuffle的计算过程。Shuffle有两个计算阶段Map阶段和Reduce阶段。我们要重点掌握Map阶段的计算流程我把它总结为4步
1. 对于分片中的数据记录逐一计算其目标分区然后填充内存数据结构PartitionedPairBuffer或PartitionedAppendOnlyMap
1. 当数据结构填满后如果分片中还有未处理的数据记录就对结构中的数据记录按目标分区IDKey排序将所有数据溢出到临时文件同时清空数据结构
1. 重复前2个步骤直到分片中所有的数据记录都被处理
1. 对所有临时文件和内存数据结构中剩余的数据记录做归并排序,最终生成数据文件和索引文件。
在Reduce阶段我们要注意Reduce Task通过网络拉取中间文件的过程实际上就是不同Stages之间数据分发的过程。并且Shuffle中数据分发的网络开销会随着Map Task与Reduce Task的线性增长呈指数级爆炸。
最后从硬件资源的角度来看Shuffle对每一种硬件资源都非常地渴求尤其是内存、磁盘和网络。由于不同硬件资源之间的处理延迟差异巨大我们很难在Shuffle过程中平衡CPU、内存、磁盘和网络之间的计算开销。因此对于Shuffle我们避之唯恐不及要能省则省、能拖则拖。
## 每日一练
1. 以小红分到的花朵数据分区0为例你能推导出reduceByKey中Map阶段的每个环节吗提示PartitionedAppendOnlyMap需要多少次溢出到磁盘临时文件每一个临时文件中的内容是什么最终生成的中间文件内容分别是什么和groupByKey生成的中间文件一样吗
1. Map阶段和Reduce阶段有不少环节都涉及数据缓存、数据存储结合上一讲介绍的Spark配置项你能把相关的配置项对号入座吗
期待在留言区看到你的思考和讨论,我们下一讲见!

View File

@@ -0,0 +1,154 @@
<audio id="audio" title="12 | 广播变量克制Shuffle如何一招制胜" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/58/00/58250a56bfe46f8d349b6209525fbe00.mp3"></audio>
你好,我是吴磊。
在数据分析领域数据关联Joins是Shuffle操作的高发区二者如影随从。可以说有Joins的地方就有Shuffle。
我们说过面对Shuffle开发者应当“能省则省、能拖则拖”。我们已经讲过了怎么拖拖指的就是把应用中会引入Shuffle的操作尽可能地往后面的计算步骤去拖。那具体该怎么省呢
在数据关联场景中广播变量就可以轻而易举地省去Shuffle。所以今天这一讲我们就先说一说广播变量的含义和作用再说一说它是如何帮助开发者省去Shuffle操作的。
## 如何理解广播变量?
接下来咱们借助一个小例子来讲一讲广播变量的含义与作用。这个例子和Word Count有关它可以说是分布式编程里的Hello world了Word Count就是用来统计文件中全部单词的你肯定已经非常熟悉了所以我们例子中的需求增加了一点难度我们要对指定列表中给定的单词计数。
```
val dict = List(“spark”, “tune”)
val words = spark.sparkContext.textFile(“~/words.csv”)
val keywords = words.filter(word =&gt; dict.contains(word))
keywords.map((_, 1)).reduceByKey(_ + _).collect
```
按照这个需求同学小A实现了如上的代码一共有4行我们逐一来看。第1行在Driver端给定待查单词列表dict第2行以textFile API读取分布式文件内容包含一列存储的是常见的单词第3行用列表dict中的单词过滤分布式文件内容只保留dict中给定的单词第4行调用reduceByKey对单词进行累加计数。
<img src="https://static001.geekbang.org/resource/image/ba/39/ba45d47a910ccb92861b1fd153b36839.jpg" alt="" title="数据结构dict随着Task一起分发到Executors">
学习过调度系统之后我们知道第一行代码定义的dict列表连带后面的3行代码会一同打包到Task里面去。这个时候Task就像是一架架小飞机携带着这些“行李”飞往集群中不同的Executors。对于这些“行李”来说代码的“负重”较轻可以忽略不计而数据的负重占了大头成了最主要的负担。
你可能会说“也还好吧dict列表又不大也没什么要紧的”。但是如果我们假设这个例子中的并行度是10000那么Driver端需要通过网络分发总共10000份dict拷贝。这个时候集群内所有的Executors需要消耗大量内存来存储这10000份的拷贝对宝贵的网络和内存资源来说这已经是一笔不小的浪费了。更何况如果换做一个更大的数据结构Task分发所引入的网络与内存开销会更可怕。
换句话说统计计数的业务逻辑还没有开始执行Spark就已经消耗了大量的网络和存储资源这简直不可理喻。因此我们需要对示例中的代码进行优化从而跳出这样的窘境。
但是,在着手优化之前,我们不妨先来想一想,现有的问题是什么,我们要达到的目的是什么。结合刚刚的分析,我们不难发现,**Word Count的核心痛点在于数据结构的分发和存储受制于并行并且是以Task为粒度的因此往往频次过高。痛点明确了调优的目的也就清晰了我们需要降低数据结构分发的频次**。
要达到这个目的我们首先想到的就是降低并行度。不过牵一发而动全身并行度一旦调整其他与CPU、内存有关的配置项都要跟着适配这难免把调优变复杂了。实际上要降低数据结构的分发频次我们还可以考虑广播变量。
**广播变量是一种分发机制它一次性封装目标数据结构以Executors为粒度去做数据分发。**换句话说在广播变量的工作机制下数据分发的频次等同于集群中的Executors个数。通常来说集群中的Executors数量都远远小于Task数量相差两到三个数量级是常有的事。那么对于第一版的Word Count实现如果我们使用广播变量的话会有哪些变化呢
代码的改动很简单主要有两个改动第一个改动是用broadcast封装dict列表第二个改动是在访问dict列表的地方改用broadcast.value替代。
```
val dict = List(“spark”, “tune”)
val bc = spark.sparkContext.broadcast(dict)
val words = spark.sparkContext.textFile(“~/words.csv”)
val keywords = words.filter(word =&gt; bc.value.contains(word))
keywords.map((_, 1)).reduceByKey(_ + _).collect
```
你可能会说:“这个改动看上去也没什么呀!”别着急,我们先来分析一下,改动之后的代码在运行时都有哪些变化。
**在广播变量的运行机制下封装成广播变量的数据由Driver端以Executors为粒度分发每一个Executors接收到广播变量之后将其交给BlockManager管理**。由于广播变量携带的数据已经通过专门的途径存储到BlockManager中因此分发到Executors的Task不需要再携带同样的数据。
这个时候你可以把广播变量想象成一架架专用货机专门为Task这些小飞机运送“大件行李”。Driver与每一个Executors之间都开通一条这样的专用货机航线统一运载负重较大的“数据行李”。有了专用货机来帮忙Task小飞机只需要携带那些负重较轻的代码就好了。等这些Task小飞机在Executors着陆它们就可以到Executors的公用仓库BlockManager里去提取它们的“大件行李”。
<img src="https://static001.geekbang.org/resource/image/2c/f7/2cfe084a106a01bf14a63466fa2146f7.jpg" alt="" title="用广播变量封装dict列表">
总之在广播变量的机制下dict列表数据需要分发和存储的次数锐减。我们假设集群中有20个Executors不过任务并行度还是10000那么Driver需要通过网络分发的dict列表拷贝就会由原来的10000份减少到20份。同理集群范围内所有Executors需要存储的dict拷贝也由原来的10000份减少至20份。这个时候引入广播变量后的开销只是原来Task分发的1/500
## 广播分布式数据集
那在刚刚的示例代码中广播变量封装的是Driver端创建的普通变量字符串列表。除此之外**广播变量也可以封装分布式数据集**。
我们来看这样一个例子。在电子商务领域中,开发者往往用事实表来存储交易类数据,用维度表来存储像物品、用户这样的描述性数据。事实表的特点是规模庞大,数据体量随着业务的发展不断地快速增长。维度表的规模要比事实表小很多,数据体量的变化也相对稳定。
假设用户维度数据以Parquet文件格式存储在HDFS文件系统中业务部门需要我们读取用户数据并创建广播变量以备后用我们该怎么做呢很简单几行代码就可以搞定
```
val userFile: String = “hdfs://ip:port/rootDir/userData”
val df: DataFrame = spark.read.parquet(userFile)
val bc_df: Broadcast[DataFrame] = spark.sparkContext.broadcast(df)
```
首先我们用Parquet API读取HDFS分布式数据文件生成DataFrame然后用broadcast封装DataFrame。从代码上来看这种实现方式和封装普通变量没有太大差别它们都调用了broadcast API只是传入的参数不同。
但如果不从开发的视角来看,转而去观察运行时广播变量的创建过程的话,我们就会发现,分布式数据集与普通变量之间的差异非常显著。
从普通变量创建广播变量由于数据源就在Driver端因此只需要Driver把数据分发到各个Executors再让Executors把数据缓存到BlockManager就好了。
但是,从分布式数据集创建广播变量就要复杂多了,具体的过程如下图所示。
<img src="https://static001.geekbang.org/resource/image/8a/6c/8ac91a174803b97966289ff51938106c.jpg" alt="" title="从分布式数据集创建广播变量的过程">
与普通变量相比分布式数据集的数据源不在Driver端而是来自所有的Executors。Executors中的每个分布式任务负责生产全量数据集的一部分也就是图中不同的数据分区。因此步骤1就是**Driver从所有的Executors拉取这些数据分区然后在本地构建全量数据。**步骤2与从普通变量创建广播变量的过程类似。 **Driver把汇总好的全量数据分发给各个ExecutorsExecutors将接收到的全量数据缓存到存储系统的BlockManager中**
不难发现,相比从普通变量创建广播变量,从分布式数据集创建广播变量的网络开销更大。原因主要有二:一是,前者比后者多了一步网络通信;二是,前者的数据体量通常比后者大很多。
## 如何用广播变量克制Shuffle
你可能会问“Driver从Executors拉取DataFrame的数据分片揉成一份全量数据然后再广播出去抛开网络开销不说来来回回得费这么大劲图啥呢”这是一个好问题因为以广播变量的形式缓存分布式数据集正是克制Shuffle杀手锏。
### Shuffle Joins
为什么这么说呢?我还是拿电子商务场景举例。有了用户的数据之后,为了分析不同用户的购物习惯,业务部门要求我们对交易表和用户表进行数据关联。这样的数据关联需求在数据分析领域还是相当普遍的。
```
val transactionsDF: DataFrame = _
val userDF: DataFrame = _
transactionsDF.join(userDF, Seq(“userID”), “inner”)
```
因为需求非常明确同学小A立即调用Parquet数据源API读取分布式文件创建交易表和用户表的DataFrame然后调用DataFrame的Join方法以userID作为Join keys用内关联Inner Join的方式完成了两表的数据关联。
在分布式环境中交易表和用户表想要以userID为Join keys进行关联就必须要确保一个前提交易记录和与之对应的用户信息在同一个Executors内。也就是说如果用户黄小乙的购物信息都存储在Executor 0而个人属性信息缓存在Executor 2那么在分布式环境中这两种信息必须要凑到同一个进程里才能实现关联计算。
在不进行任何调优的情况下Spark默认采用Shuffle Join的方式来做到这一点。Shuffle Join的过程主要有两步。
**第一步就是对参与关联的左右表分别进行Shuffle**Shuffle的分区规则是先对Join keys计算哈希值再把哈希值对分区数取模。由于左右表的分区数是一致的因此Shuffle过后一定能够保证userID相同的交易记录和用户数据坐落在同一个Executors内。
<img src="https://static001.geekbang.org/resource/image/b1/28/b1b2a574eb7ef33e2315f547ecdc0328.jpg" alt="" title="Shuffle Join中左右表的数据分发">
Shuffle完成之后**第二步就是在同一个Executors内Reduce task就可以对userID一致的记录进行关联操作**。但是由于交易表是事实表数据体量异常庞大对TB级别的数据进行Shuffle想想都觉得可怕因此上面对两个DataFrame直接关联的代码还有很大的调优空间。我们该怎么做呢话句话说对于分布式环境中的数据关联来说要想确保交易记录和与之对应的用户信息在同一个Executors中我们有没有其他办法呢
### 克制Shuffle的方式
还记得之前业务部门要求我们把用户表封装为广播变量,以备后用吗?现在它终于派上用场了!
```
import org.apache.spark.sql.functions.broadcast
val transactionsDF: DataFrame = _
val userDF: DataFrame = _
val bcUserDF = broadcast(userDF)
transactionsDF.join(bcUserDF, Seq(“userID”), “inner”)
```
Driver从所有Executors收集userDF所属的所有数据分片在本地汇总用户数据然后给每一个Executors都发送一份全量数据的拷贝。既然每个Executors都有userDF的**全量数据**这个时候交易表的数据分区待在原地、保持不动就可以轻松地关联到一致的用户数据。如此一来我们不需要对数据体量巨大的交易表进行Shuffle同样可以在分布式环境中完成两张表的数据关联。
<img src="https://static001.geekbang.org/resource/image/b3/2a/b3c5ab392c2303bf7923488623b4022a.jpg" alt="" title="Broadcast Join将小表广播避免大表Shuffle
">
利用广播变量我们成功地避免了海量数据在集群内的存储、分发节省了原本由Shuffle引入的磁盘和网络开销大幅提升运行时执行性能。当然采用广播变量优化也是有成本的毕竟广播变量的创建和分发也是会带来网络开销的。但是相比大表的全网分发小表的网络开销几乎可以忽略不计。这种小投入、大产出用极小的成本去博取高额的性能收益真可以说是“四两拨千斤”
## 小结
在数据关联场景中广播变量是克制Shuffle的杀手锏。掌握了它我们就能以极小的成本获得高额的性能收益。关键是我们要掌握两种创建广播变量的方式。
第一种从普通变量创建广播变量。在广播变量的运行机制下普通变量存储的数据封装成广播变量由Driver端以Executors为粒度进行分发每一个Executors接收到广播变量之后将其交由BlockManager管理。
第二种从分布式数据集创建广播变量这就要比第一种方式复杂一些了。第一步Driver需要从所有的Executors拉取数据分片然后在本地构建全量数据第二步Driver把汇总好的全量数据分发给各个ExecutorsExecutors再将接收到的全量数据缓存到存储系统的BlockManager中。
结合这两种方式我们在做数据关联的时候把Shuffle Joins转换为Broadcast Joins就可以用小表广播来代替大表的全网分发真正做到克制Shuffle。
## 每日一练
1. Spark广播机制现有的实现方式是存在隐患的在数据量较大的情况下Driver可能会成为瓶颈你能想到更好的方式来重新实现Spark的广播机制吗提示[SPARK-17556](https://issues.apache.org/jira/browse/SPARK-17556)
1. 在什么情况下不适合把Shuffle Joins转换为Broadcast Joins
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,163 @@
<audio id="audio" title="13 | 广播变量如何让Spark SQL选择Broadcast Joins" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c2/a2/c29116aab5bca8d1fba33ba649297ea2.mp3"></audio>
你好,我是吴磊。
上一讲我们说到在数据关联场景中广播变量是克制Shuffle的杀手锏用Broadcast Joins取代Shuffle Joins可以大幅提升执行性能。但是很多同学只会使用默认的广播变量不会去调优。那么我们该怎么保证Spark在运行时优先选择Broadcast Joins策略呢
今天这一讲我就围绕着数据关联场景从配置项和开发API两个方面帮你梳理出两类调优手段让你能够游刃有余地运用广播变量。
## 利用配置项强制广播
我们先来从配置项的角度说一说有哪些办法可以让Spark优先选择Broadcast Joins。在Spark SQL配置项那一讲我们提到过spark.sql.autoBroadcastJoinThreshold这个配置项。它的设置值是存储大小默认是10MB。它的含义是**对于参与Join的两张表来说任意一张表的尺寸小于10MBSpark就在运行时采用Broadcast Joins的实现方式去做数据关联。**另外AQE在运行时尝试动态调整Join策略时也是基于这个参数来判定过滤后的数据表是否足够小从而把原本的Shuffle Joins调整为Broadcast Joins。
<img src="https://static001.geekbang.org/resource/image/69/89/697cccb272fc8863f40bb7c465f53c89.jpeg" alt="">
为了方便你理解我来举个例子。在数据仓库中我们经常会看到两张表一张是订单事实表为了方便我们把它记成Fact另一张是用户维度表记成Dim。事实表体量很大在100GB量级维度表很小在1GB左右。两张表的Schema如下所示
```
//订单事实表Schema
orderID: Int
userID: Int
trxDate: Timestamp
productId: Int
price: Float
volume: Int
//用户维度表Schema
userID: Int
name: String
age: Int
gender: String
```
当Fact表和Dim表基于userID做关联的时候由于两张表的尺寸大小都远超spark.sql.autoBroadcastJoinThreshold参数的默认值10MB因此Spark不得不选择Shuffle Joins的实现方式。但如果我们把这个参数的值调整为2GB因为Dim表的尺寸比2GB小所以Spark在运行时会选择把Dim表封装到广播变量里并采用Broadcast Join的方式来完成两张表的数据关联。
显然对于绝大多数的Join场景来说autoBroadcastJoinThreshold参数的默认值10MB太低了因为现在企业的数据体量都在TB甚至PB级别。因此想要有效地利用Broadcast Joins我们需要把参数值调大一般来说2GB左右是个不错的选择。
现在我们已经知道了,**使用广播阈值配置项让Spark优先选择Broadcast Joins的关键就是要确保至少有一张表的存储尺寸小于广播阈值**。
但是在设置广播阈值的时候不少同学都跟我抱怨“我的数据量明明小于autoBroadcastJoinThreshold参数设定的广播阈值为什么Spark SQL在运行时并没有选择Broadcast Joins呢
详细了解后我才知道,这些同学所说的数据量,**其实指的是数据表在磁盘上的存储大小**,比如用`ls`或是`du -sh`等系统命令查看文件得到的结果。要知道,同一份数据在内存中的存储大小往往会比磁盘中的存储大小膨胀数倍,甚至十数倍。这主要有两方面原因。
一方面为了提升存储和访问效率开发者一般采用Parquet或是ORC等压缩格式把数据落盘。这些高压缩比的磁盘数据展开到内存之后数据量往往会翻上数倍。
另一方面受限于对象管理机制在堆内内存中JVM往往需要比数据原始尺寸更大的内存空间来存储对象。
我们来举个例子字符串“abcd”按理说只需要消耗4个字节但是JVM在堆内存储这4个字符串总共需要消耗48个字节那在运行时一份看上去不大的磁盘数据展开到内存翻上个4、5倍并不稀奇。因此如果你按照磁盘上的存储大小去配置autoBroadcastJoinThreshold广播阈值大概率也会遇到同样的窘境。
**那么问题来了,有什么办法能准确地预估一张表在内存中的存储大小呢?**
首先我们要避开一个坑。我发现有很多资料推荐用Spark内置的SizeEstimator去预估分布式数据集的存储大小。结合多次实战和踩坑经验咱们必须要指出**SizeEstimator的估算结果不准确**。因此,你可以直接跳过这种方法,这也能节省你调优的时间和精力。
我认为比较靠谱的办法是:**第一步把要预估大小的数据表缓存到内存比如直接在DataFrame或是Dataset上调用cache方法第二步读取Spark SQL执行计划的统计数据**。这是因为Spark SQL在运行时就是靠这些统计数据来制定和调整执行策略的。
```
val df: DataFrame = _
df.cache.count
val plan = df.queryExecution.logical
val estimated: BigInt = spark
.sessionState
.executePlan(plan)
.optimizedPlan
.stats
.sizeInBytes
```
你可能会说“这种办法虽然精确但是这么做实际上已经是在运行时进行调优了。把数据先缓存到内存再去计算它的存储尺寸当然更准确了。”没错采用这种计算方式调优所需花费的时间和精力确实更多但在很多情况下尤其是Shuffle Joins的执行效率让你痛不欲生的时候这样的付出是值得的。
## 利用API强制广播
既然数据量的预估这么麻烦有没有什么办法不需要配置广播阈值就可以让Spark SQL选择Broadcast Joins还真有而且办法还不止一种。
开发者可以通过Join Hints或是SQL functions中的broadcast函数来强制Spark SQL在运行时采用Broadcast Joins的方式做数据关联。下面我就来分别讲一讲它们的含义和作用以及该如何使用。必须要说明的是这两种方式是等价的并无优劣之分只不过有了多样化的选择之后你就可以根据自己的偏好和习惯来灵活地进行开发。
### 用Join Hints强制广播
Join Hints中的Hints表示“提示”它指的是在开发过程中使用特殊的语法明确告知Spark SQL在运行时采用哪种Join策略。一旦你启用了Join Hints不管你的数据表是不是满足广播阈值Spark SQL都会尽可能地尊重你的意愿和选择使用Broadcast Joins去完成数据关联。
我们来举个例子假设有两张表一张表的内存大小在100GB量级另一张小一些2GB左右。在广播阈值被设置为2GB的情况下并没有触发Broadcast Joins但我们又不想花费时间和精力去精确计算小表的内存占用到底是多大。在这种情况下我们就可以用Join Hints来帮我们做优化仅仅几句提示就可以帮我们达到目的。
```
val table1: DataFrame = spark.read.parquet(path1)
val table2: DataFrame = spark.read.parquet(path2)
table1.createOrReplaceTempView(&quot;t1&quot;)
table2.createOrReplaceTempView(&quot;t2&quot;)
val query: String = “select /*+ broadcast(t2) */ * from t1 inner join t2 on t1.key = t2.key”
val queryResutls: DataFrame = spark.sql(query)
```
你看在上面的代码示例中只要在SQL结构化查询语句里面加上一句`/*+ broadcast(t2) */`提示我们就可以强制Spark SQL对小表t2进行广播在运行时选择Broadcast Joins的实现方式。提示语句中的关键字除了使用broadcast外我们还可以用broadcastjoin或者mapjoin它们实现的效果都一样。
如果你不喜欢用SQL结构化查询语句尤其不想频繁地在Spark SQL上下文中注册数据表你也可以在DataFrame的DSL语法中使用Join Hints。
```
table1.join(table2.hint(“broadcast”), Seq(“key”), “inner”)
```
在上面的DSL语句中我们只要在table2上调用hint方法然后指定broadcast关键字就可以同样达到强制广播表2的效果。
总之Join Hints让开发者可以灵活地选择运行时的Join策略对于熟悉业务、了解数据的同学来说Join Hints允许开发者把专家经验凌驾于Spark SQL的优化引擎之上更好地服务业务。
不过Join Hints也有个小缺陷。如果关键字拼写错误Spark SQL在运行时并不会显示地抛出异常而是默默地忽略掉拼写错误的hints假装它压根不存在。因此在使用Join Hints的时候需要我们在编译时自行确认Debug和纠错。
### 用broadcast函数强制广播
如果你不想等到运行时才发现问题想让编译器帮你检查类似的拼写错误那么你可以使用强制广播的第二种方式broadcast函数。这个函数是类库org.apache.spark.sql.functions中的broadcast函数。调用方式非常简单比Join Hints还要方便只需要用broadcast函数封装需要广播的数据表即可如下所示。
```
import org.apache.spark.sql.functions.broadcast
table1.join(broadcast(table2), Seq(“key”), “inner”)
```
你可能会问“既然开发者可以通过Join Hints和broadcast函数强制Spark SQL选择Broadcast Joins那我是不是就可以不用理会广播阈值的配置项了”其实还真不是。我认为**以广播阈值配置为主,以强制广播为辅**,往往是不错的选择。
**广播阈值的设置更多的是把选择权交给Spark SQL尤其是在AQE的机制下动态Join策略调整需要这样的设置在运行时做出选择。强制广播更多的是开发者以专家经验去指导Spark SQL该如何选择运行时策略。**二者相辅相成并不冲突开发者灵活地运用就能平衡Spark SQL优化策略与专家经验在应用中的比例。
## 广播变量不是银弹
不过虽然我们一直在强调数据关联场景中广播变量是克制Shuffle的杀手锏但广播变量并不是银弹。
就像有的同学会说“开发者有这么多选项甚至可以强制Spark选择Broadcast Joins那我们是不是可以把所有Join操作都用Broadcast Joins来实现”答案当然是否定的广播变量不能解决所有的数据关联问题。
**首先从性能上来讲Driver在创建广播变量的过程中需要拉取分布式数据集所有的数据分片。**在这个过程中网络开销和Driver内存会成为性能隐患。广播变量尺寸越大额外引入的性能开销就会越多。更何况如果广播变量大小超过8GBSpark会直接抛异常中断任务执行。
**其次从功能上来讲并不是所有的Joins类型都可以转换为Broadcast Joins。**一来Broadcast Joins不支持全连接Full Outer Joins二来在所有的数据关联中我们不能广播基表。或者说即便开发者强制广播基表也无济于事。比如说在左连接Left Outer Join我们只能广播右表在右连接Right Outer Join我们只能广播左表。在下面的代码中即便我们强制用broadcast函数进行广播Spark SQL在运行时还是会选择Shuffle Joins。
```
import org.apache.spark.sql.functions.broadcast
broadcast (table1).join(table2, Seq(“key”), “left”)
table1.join(broadcast(table2), Seq(“key”), “right”)
```
## 小结
这一讲我们总结了2种方法让Spark SQL在运行时能够选择Broadcast Joins策略分别是设置配置项和用API强制广播。
**首先设置配置项主要是设置autoBroadcastJoinThreshold配置项。**开发者通过这个配置项指示Spark SQL优化器。只要参与Join的两张表中有一张表的尺寸小于这个参数值就在运行时采用Broadcast Joins的实现方式。
为了让Spark SQL采用Broadcast Joins开发者要做的就是让数据表在内存中的尺寸小于autoBroadcastJoinThreshold参数的设定值。
除此之外在设置广播阈值的时候因为磁盘数据展开到内存的时候存储大小会成倍增加往往导致Spark SQL无法采用Broadcast Joins的策略。因此我们在做数据关联的时候还要先预估一张表在内存中的存储大小。一种精确的预估方法是先把DataFrame缓存然后读取执行计划的统计数据。
**其次用API强制广播有两种方法分别是设置Join Hints和用broadcast函数。**设置Join Hints的方法就是在SQL结构化查询语句里面加上一句“/*+ broadcast(某表) */”的提示就可以了这里的broadcast关键字也可以换成broadcastjoin或者mapjoin。另外你也可以在DataFrame的DSL语法中使用调用hint方法指定broadcast关键字来达到同样的效果。设置broadcast函数的方法非常简单只要用broadcast函数封装需要广播的数据表就可以了。
总的来说不管是设置配置项还是用API强制广播都有各自的优缺点所以**以广播阈值配置为主、强制广播为辅**,往往是一个不错的选择。
最后,不过,我们也要注意,广播变量不是银弹,它并不能解决所有的数据关联问题,所以在日常的开发工作中,你要注意避免滥用广播。
## 每日一练
1. 除了broadcast关键字外在Spark 3.0版本中Join Hints还支持哪些关联类型和关键字
1. DataFrame可以用sparkContext.broadcast函数来广播吗它和org.apache.spark.sql.functions.broadcast函数之间的区别是什么
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,137 @@
<audio id="audio" title="14 | CPU视角如何高效地利用CPU" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d4/a7/d4b6804fe989d90006f17622c93af8a7.mp3"></audio>
你好,我是吴磊。
在日常的开发与调优工作中总有同学向我抱怨“为什么我的应用CPU利用率这么低偌大的集群CPU利用率才10%”确实较低的CPU利用率不仅对宝贵的硬件资源来说是一种非常大的浪费也会让应用端到端的执行性能很难达到令人满意的效果。那么在分布式应用开发中我们到底该如何高效地利用CPU
我们说过,性能调优的最终目的,是在**所有参与计算的硬件资源之间寻求协同与平衡**让硬件资源达到一种平衡、无瓶颈的状态。对于CPU来说最需要协同和平衡的硬件资源非内存莫属。原因主要有两方面一方面在处理延迟方面只有内存能望其项背另一方面在主板上内存通过数据总线直接向CPU寄存器供给数据。因此理顺它们之间的关系可以为性能调优奠定更好的基础。
那么今天这一讲我们就从硬件资源平衡的角度入手去分析CPU与内存到底该如何合作。
## CPU与内存的平衡本质上是什么
我们知道Spark将内存分成了Execution Memory和Storage Memory两类分别用于分布式任务执行和RDD缓存。其中RDD缓存虽然最终占用的是Storage Memory但在RDD展开Unroll之前计算任务消耗的还是Execution Memory。**因此Spark中CPU与内存的平衡其实就是CPU与执行内存之间的协同与配比。**
要想平衡CPU与执行内存之间的协同和配比我们需要使用3类配置参数它们分别控制着并行度、执行内存大小和集群的并行计算能力。只有它们设置得当CPU和执行内存才能同时得到充分利用。否则CPU与执行内存之间的平衡就会被打破要么CPU工作不饱和要么OOM内存溢出。
想要知道这3类参数都包含哪些具体的配置项以及它们到底是怎么配置的我们需要先弄清楚一些基础知识也就是并行计算的线程之间是如何瓜分执行内存的。为了帮助你理解我先来给你讲个故事。
## 黄小乙的如意算盘:并行计算的线程如何瓜分执行内存?
还记得地主招租的故事吗?与张麻子签订占地协议之后,黄小乙就开始盘算,自己分得的那块田地怎么经营才最划算。
他心想:“这么一大块地,我亲自种肯定划不来。一,我没有张麻子勤快;二,不管是种棉花还是咖啡都很耗时、费力,面朝黄土背朝天,我可耽误不起那功夫!不如,我把土地转让出去,让别人来种,我只管收购、销售,赚到的差价也够我吃穿不愁了!”
于是,他打定主意,贴出了一张告示。
<img src="https://static001.geekbang.org/resource/image/d2/11/d27f69b8aca76677700914873fce7911.jpg" alt="" title="黄小乙的告示">
告示贴出去不到三天十里八村的人都赶来承让土地他们大部分都是吃苦耐劳的庄稼汉一心想凭借这次机会改善生活所以每个人恨不能把500顷的田地全都承让过来。
黄小乙见状,心中大喜,认为不仅自己的土地很快就可以被种满,还能名正言顺地去抢占张麻子的那块地。不过,也不能光图规模,还要确保棉花、咖啡的产出质量,更重要的是得想个办法让这种运作模式可持续。
于是,黄小乙追加了一项补充条款:“鉴于老乡们参与热情高涨,公平起见,我又制定了新的土地转让规则:**首先,每位老乡能够获得的土地面积有上下限,它的具体数值由可耕种土地总面积和申请人数共同决定;其次,土地转让权的有效时间与农作物生长周期一致,一旦作物丰收,承让人需让出土地,如有意愿再次耕种需重新申请。**”
<img src="https://static001.geekbang.org/resource/image/18/54/182c868dc11e99b6dyy4cd5536711b54.jpg" alt="" title="土地转让规则">
比如说现阶段可耕种土地总面积已由500顷扩张为800顷这是黄小乙就抢占了张麻子的地之后的土地总面积如果有400位老乡申请土地转让权那么每位老乡最高可得2顷800/400的土地最低可得1顷800/400/2土地。也就是说如果老乡人数为N那么每位老乡能够获得的土地面积会在1/N/21/N之间浮动。
这个规定大伙儿都心服口服没过多久800顷土地就全部转让完了。一笔多赢的买卖让大伙都能各取所需也让老谋深算的黄四郎都不禁心挑大指感叹道“真是长江水后浪催前浪一代新人换旧人
好啦,故事到这里暂时告一段落,但是黄小乙这份如意算盘和今天要讲的内容有什么关系呢?
我们讲过黄小乙租赁的土地类比的是内存区域中的Execution Memory。**在今天的故事里黄小乙招募的棉农和咖啡农对应的就是Executor线程池中一个又一个执行分布式任务的线程。土地出让规则对应的就是任务并发过程中多个线程抢占内存资源时需要遵循的基本逻辑。**
那么执行内存抢占规则就是在同一个Executor中当有多个记为N线程尝试抢占执行内存时需要遵循2条基本原则
- 执行内存总大小记为M为两部分之和一部分是Execution Memory初始大小另一部分是Storage Memory剩余空间
- 每个线程分到的可用内存有一定的上下限下限是M/N/2上限是M/N也就是均值
## 三足鼎立:并行度、并发度与执行内存
理清了线程与执行内存的关系之后我们再来说说与并发度、执行内存和并行度这三者对应的3类配置项分别是什么以及它们如何影响CPU与计算内存之间的平衡。
### 3类配置项
我们讲过,并行度指的是为了实现分布式计算,分布式数据集被划分出来的份数。**并行度明确了数据划分的粒度:并行度越高,数据的粒度越细,数据分片越多,数据越分散。**
并行度可以通过两个参数来设置,**分别是spark.default.parallelism和spark.sql.shuffle.partitions**。前者用于设置RDD的默认并行度后者在Spark SQL开发框架下指定了Shuffle Reduce阶段默认的并行度。
那什么是并发度呢?我们在[配置项那一讲](https://time.geekbang.org/column/article/357342)提到过Executor的线程池大小由参数spark.executor.cores决定每个任务在执行期间需要消耗的线程数由spark.task.cpus配置项给定。两者相除得到的商就是并发度也就是同一时间内一个Executor内部可以同时运行的最大任务数量。又因为spark.task.cpus默认数值为1并且通常不需要调整所以**并发度基本由spark.executor.cores参数敲定**。
就Executor的线程池来说尽管线程本身可以复用但每个线程在同一时间只能计算一个任务每个任务负责处理一个数据分片。因此**在运行时,线程、任务与分区是一一对应的关系**。
分布式任务由Driver分发到Executor后Executor将Task封装为TaskRunner然后将其交给可回收缓存线程池newCachedThreadPool。线程池中的线程领取到TaskRunner之后向Execution Memory申请内存然后开始执行任务。
如果我们把棉农、咖啡农类比CPU线程那么TaskRunner就可以理解为锄具Task要处理的数据分片可以理解为作物种子。有了锄具和种子之后老乡们得去黄小乙那儿申请块地才能开始耕种。
最后我们再来说说执行内存。黄小乙的地就是执行内存堆内执行内存的初始值由很多参数共同决定具体的计算公式是spark.executor.memory * spark.memory.fraction * (1 - spark.memory.storageFraction)。相比之下堆外执行内存的计算稍微简单一些spark.memory.offHeap.size * (1 - spark.memory.storageFraction)。
除此之外在统一内存管理模式下在Storage Memory没有被RDD缓存占满的情况下执行任务可以动态地抢占Storage Memory。因此在计算可用于分配给执行任务的内存总量时还要把有希望抢占过来的这部分内存空间考虑进来。这也是为什么黄小乙的可耕种土地总面积会从最开始的500顷逐渐扩展到800顷。
由此可见,**可分配的执行内存总量会随着缓存任务和执行任务的此消彼长,而动态变化。但无论怎么变,可用的执行内存总量,都不会低于配置项设定的初始值**。
好啦搞明白并行度、并发度和执行内存的概念以及各自的配置项之后我们再通过两个经常影响CPU利用率的例子来说说它们是怎么影响CPU与计算内存之间的平衡的由此总结出提升CPU利用率的办法。
### CPU低效原因之一线程挂起
在给定执行内存总量M和线程总数N的情况下为了保证每个线程都有机会拿到适量的内存去处理数据Spark用HashMap数据结构KeyValue的方式来记录每个线程消耗的内存大小并确保所有的Value值都不超过M/N。在一些极端情况下有些线程申请不到所需的内存空间能拿到的内存合计还不到M/N/2。这个时候Spark就会把线程挂起直到其他线程释放了足够的内存空间为止。
你可能会问“既然每个线程能拿到的内存上限是M/N也就是内存总量对线程数取平均值为什么还会出现有的线程连M/N/2都拿不到呢这在数学上也不成立呀”这是个好问题。这种情况的出现源于3方面的变化和作用
- **动态变化的执行内存总量M**
- **动态变化的并发度N~**
- **分布式数据集的数据分布**
首先动态变化的执行内存总量M我们刚刚已经说过了。M的下限是Execution Memory初始值上限是spark.executor.memory * spark.memory.fraction划定的所有内存区域。在应用刚刚开始执行的时候M的取值就是这个上限但随着RDD缓存逐渐填充Storage MemoryM的取值也会跟着回撤。
另外到目前为止1/N/21/N上下限的计算我们用的都是线程总数N线程总数N是固定的。N的取值含义是一个Executor内最大的并发度更严格的计算公式是spark.executor.cores除以spark.task.cpus。但实际上上下限公式的计算用的不是N而是N~。N~的含义是Executor内当前的并发度也就是Executor中当前并行执行的任务数。显然N~ &lt;= N。
换句话说尽管一个Executor中有N个CPU线程但这N个线程不一定都在干活。在Spark任务调度的过程中这N个线程不见得能同时拿到分布式任务所以先拿到任务的线程就有机会申请到更多的内存。在某些极端的情况下后拿到任务的线程甚至连一寸内存都申请不到。不过随着任务执行和任务调度的推进N~会迅速地趋近于NCPU线程挂起和内存分配的情况也会逐渐得到改善。
就像黄小乙的补充条款中举的那个例子一样当可耕种土地总面积为800顷的时候如果有400位老乡申请土地转让权那么每位老乡最多可得800/400=2顷土地最低可得800/400/2=1顷土地。
但如果这400位老乡不是同时来的而是分两批来的每批来200人的话就会出现问题。按照他的规则先来的这200位老乡每人最多可得800/200 = 4顷土地。咱们前面说了每个申请的老乡都想通过这次机会发点小财于是这200位老乡每人都申请了4顷地黄小乙的地一下子就被分光了后来的200位老乡就没地可种了他们只能等到第一批老乡的棉花和咖啡丰收了再重新一起申请土地转让权。
假设第一批老乡同时大丰收,按照黄小乙转让规则的第一条,第一批老乡要交出土地使用权,如果想要继续耕种的话,就得和第二批老乡一起重新申请。在这种情况下,上下限的计算才是黄小乙最开始举例的那种算法。
第三个影响任务并发度和内存分配的因素是分布式数据集的分布情况。在刚才的例子中如果第一批老乡每人只申请2顷土地那么第二批老乡来了之后依然有地可种。每人申请多大的土地取决于他手里有多少农作物种子我们之前把每个Task需要处理的数据分片比作是作物种子那么数据分片的数据量决定了执行任务需要申请多少内存。**如果分布式数据集的并行度设置得当,因任务调度滞后而导致的线程挂起问题就会得到缓解**。
### CPU低效原因之二调度开销
线程挂起的问题得到缓解CPU利用率就会有所改善。既然如此是不是把并行度设置到最大每个数据分片就都能足够小小到每个CPU线程都能申请到内存线程不再挂起就万事大吉了呢
当然不是,并行度足够大,确实会让数据分片更分散、数据粒度更细,因此,每个执行任务所需消耗的内存更少。**但是,数据过于分散会带来严重的副作用:调度开销骤增。**
对于每一个分布式任务Dirver会将其封装为TaskDescription然后分发给各个Executor。TaskDescription包含着与任务运行有关的所有信息如任务ID、尝试ID、要处理的数据分片ID、开发者添加的本地文件和Jar包、任务属性、序列化的任务代码等等。Executor接收到TaskDescription之后首先需要对TaskDescription反序列化才能读取任务信息然后将任务代码再反序列化得到可执行代码最后再结合其他任务信息创建TaskRunner。
因此你看每个任务的调度与执行都需要Executor消耗CPU去执行上述一系列的操作步骤。数据分片与线程、执行任务一一对应**当数据过于分散分布式任务数量会大幅增加但每个任务需要处理的数据量却少之又少就CPU消耗来说相比花在数据处理上的比例任务调度上的开销几乎与之分庭抗礼**。显然在这种情况下CPU的有效利用率也是极低的。
### 如何优化CPU利用率
你可能会说“这也太尴尬了并行度低了不行容易让CPU线程挂起高了也不行调度开销太大CPU有效利用率也不高。高也不行、低也不行那我该怎么办呢
咱们不妨来算笔账。我们还是拿黄小乙的如意算盘来举例如果400个老乡同时来申请他的800顷地那么每个老乡能分到1到2顷土地不等。相应地每位老乡需要购买的种子应该刚好够种满1到2顷地。因为买多了种不下买少了还亏。假设洼子村农产品交易市场的种子总量刚好够种1000顷地从卖家的视角出发这些种子应该售卖1000/2 =500到1000/1 = 1000次才能赚到最多的钱。
因此在给定Executor线程池和执行内存大小的时候我们可以参考上面的算法**去计算一个能够让数据分片平均大小在M/N/2, M/N之间的并行度这往往是个不错的选择**。
总的来说对CPU利用率来说并行度、并发度与执行内存的关系就好像是一尊盛满沸水的三足鼎三足齐平则万事大吉但凡哪一方瘸腿儿鼎内的沸水就会倾出伤及无辜。
<img src="https://static001.geekbang.org/resource/image/4a/ce/4a5dc54813346924ec5611f6d1fa8fce.jpg" alt="" title="三足鼎立">
## 小结
今天这一讲我们从CPU与执行内存平衡的角度通过梳理Executor并行度、并发度和执行内存之间的关系以及它们对CPU利用率的影响总结出了有效提升CPU利用率的方法。
首先在一个Executor中每个CPU线程能够申请到的内存比例是有上下限的最高不超过1/N最低不少于1/N/2其中N代表线程池大小。
其次在给定线程池大小和执行内存的时候并行度较低、数据分片较大容易导致CPU线程挂起线程频繁挂起不利于提升CPU利用率而并行度过高、数据过于分散会让调度开销更显著也利于提升CPU利用率。
最后在给定执行内存M、线程池大小N和数据总量D的时候想要有效地提升CPU利用率我们就要计算出最佳并行度P计算方法是让数据分片的平均大小D/P坐落在M/N/2, M/N区间。这样在运行时我们的CPU利用率往往不会太差。
## 每日一练
1. 从Executor并发度、执行内存大小和分布式任务并行度出发你认为在什么情况下会出现OOM的问题
1. 由于执行内存总量M是动态变化的并发任务数N~也是动态变化的,因此每个线程申请内存的上下限也是动态调整的,你知道这个调整周期以什么为准?
期待在留言区看到你的思考和答案如果你的朋友也在为提高CPU利用率苦恼欢迎你把这一讲转发给他我们下一讲见

View File

@@ -0,0 +1,167 @@
<audio id="audio" title="15 | 内存视角(一):如何最大化内存的使用效率?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c9/f8/c93e6ee7032d921fa2dc8572ce1byyf8.mp3"></audio>
你好,我是吴磊。
上一讲我们说想要提升CPU利用率最重要的就是合理分配执行内存但是执行内存只是Spark内存分区的一部分。因此想要合理分配执行内存我们必须先从整体上合理划分好Spark所有的内存区域。
可在实际开发应用的时候身边有不少同学向我抱怨“Spark划分不同内存区域的原理我都知道但我还是不知道不同内存区域的大小该怎么设置纠结来、纠结去。最后所有跟内存有关的配置项我还是保留了默认值。”
这种不能把原理和实践结合起来的情况很常见所以今天这一讲我就从熟悉的Label Encoding实例出发**一步步带你去分析不同情况下,不同内存区域的调整办法,**帮你归纳出最大化内存利用率的常规步骤。这样,你在调整内存的时候,就能结合应用的需要,做到有章可循、有的放矢。
## 从一个实例开始
我们先来回顾一下[第5讲](https://time.geekbang.org/column/article/355028)中讲过的Label Encoding。在Label Encoding的业务场景中我们需要对用户兴趣特征做Encoding。依据模板中兴趣字符串及其索引位置我们的任务是把千亿条样本中的用户兴趣转换为对应的索引值。模板文件的内容示例如下所示。
```
//模板文件
//用户兴趣
体育-篮球-NBA-湖人
军事-武器-步枪-AK47
```
实现的代码如下所示注意啦这里的代码是第5讲中优化后的版本。
```
/**
输入参数:模板文件路径,用户兴趣字符串
返回值:用户兴趣字符串对应的索引值
*/
//函数定义
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;)
```
下面,咱们先一起回顾一下代码实现思路,再来分析它目前存在的性能隐患,最后去探讨优化它的方法。
首先findIndex函数的主体逻辑比较简单就是读取模板文件和构建Map映射以及查找用户兴趣并返回索引。不过findIndex函数被定义成了高阶函数。这样一来当以模板文件为实参调用这个高阶函数的时候我们会得到一个内置了Map查找字典的标量函数partFunc最后在千亿样本上调用partFunc完成数据转换。**利用高阶函数我们就避免了让Executor中的每一个Task去读取模板文件以及从头构建Map字典这种执行低效的做法。**
在运行时这个函数在Driver端会被封装到一个又一个的Task中去随后Driver把这些Task分发到ExecutorExecutor接收到任务之后交由线程池去执行调度系统的内容可以回顾[第5讲](https://time.geekbang.org/column/article/355028)。这个时候每个Task就像是一架架小飞机携带着代码“乘客”和数据“行李”从Driver飞往Executor。Task小飞机在Executor机场着陆之后代码“乘客”乘坐出租车或是机场大巴去往JVM stack数据“行李”则由专人堆放在JVM Heap也就是我们常说的堆内内存。
回顾Label encoding中的findIndex函数不难发现其中大部分都是代码“乘客”唯一的数据“行李”是名为searchMap的Map字典。像这样用户自定义的数据结构消耗的内存区域就是堆内内存的User MemorySpark对内存区域的划分内容可以回顾一下[第7讲](https://time.geekbang.org/column/article/355662))。
### User Memory性能隐患
回顾到这里你觉得findIndex函数有没有性能隐患呢你可以先自己思考一下有了答案之后再来看我下面的分析。
答案当然是“有”。首先,每架小飞机都携带这么一份数据“大件行李”,自然需要消耗更多的“燃油”,这里的“燃油”指的**是Task分发过程中带来的网络开销**。其次因为每架小飞机着陆之后都会在Executor的“旅客行李专区”User Memory寄存上这份同样的数据“行李”所以**User Memory需要确保有足够的空间可以寄存所有旅客的行李也就是大量的重复数据**。
那么User Memory到底需要准备出多大的内存空间才行呢我们不妨来算一算。这样的计算并不难只需要用飞机架次乘以行李大小就可以了。
用户自定义的数据结构往往是用于辅助函数完成计算任务的,所以函数执行完毕之后,它携带的数据结构的生命周期也就告一段落。**因此在Task的数量统计上我们不必在意一个Executor总共需要处理多少个Task只需要关注它在同一时间可以并行处理的Task数量也就是Executor的线程池大小即可**。
我们说过Executor线程池大小由spark.executor.cores和spark.task.cpus这两个参数的商spark.executor.cores/spark.task.cpus决定我们暂且把这个商记作#threads
接下来是估算数据“行李”大小由于searchMap并不是分布式数据集因此我们不必采用先Cache再提取Spark执行计划统计信息的方式。对于这样的Java数据结构我们完全可以在REPL中通过Java的常规方法估算数据存储大小估算得到的searchMap大小记为#size
好啦现在我们可以算出User Memory至少需要提供#threads * #size这么大的内存空间才能支持分布式任务完成计算。但是对于User Memory内存区域来说使用#threads * #size的空间去重复存储同样的数据,本身就是降低了内存的利用率。那么,我们该怎么省掉#threads * #size的内存消耗呢
## 性能调优
学习过广播变量之后,想必你头脑中已经有了思路。没错,咱们可以尝试使用广播变量,来对示例中的代码进行优化。
仔细观察findIndex函数我们不难发现函数的核心计算逻辑有两点。一是读取模板文件、创建Map映射字典二是以给定字符串对字典进行查找并返回查找结果。显然千亿样本转换的核心需求是其中的第二个环节。既然如此我们完全可以把创建好的Map字典封装成广播变量然后分发到各个Executors中去。
有了广播变量的帮忙凡是发往同一个Executor的Task小飞机都无需亲自携带数据“行李”这些大件行李会由“联邦广播快递公司”派货机专门发往各个ExecutorsDriver和每个Executors之间都有一班这样的货运专线。思路说完了优化后的代码如下所示。
```
/**
广播变量实现方式
*/
//定义广播变量
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
val bcSearchMap = sparkSession.sparkContext.broadcast(searchMap)
//在Dataset中访问广播变量
bcSearchMap.value.getOrElse(&quot;体育-篮球-NBA-湖人&quot;, -1)
```
上面代码的实现思路很简单第一步还是读取模板文件、创建Map字典第二步把Map字典封装为广播变量。这样一来在对千亿样本进行转换时我们直接通过bcSearchMap.value读取广播变量内容然后通过调用Map字典的getOrElse方法来获取用户兴趣对应的索引值。
相比最开始的第一种实现方式,第二种实现方式的代码改动还是比较小的,那这一版代码对内存的消耗情况有什么改进呢?
我们发现Task小飞机的代码“乘客”换人了**小飞机之前需要携带函数findIndex现在则换成了一位“匿名的乘客”**一个读取广播变量并调用其getOrElse方法的匿名函数。由于这位“匿名的乘客”将大件行李托运给了“联邦广播快递公司”的专用货机因此Task小飞机着陆后没有任何“行李”需要寄存到User Memory。换句话说优化后的版本不会对User Memory内存区域进行占用所以第一种实现方式中#threads * #size的内存消耗就可以省掉了
### Storage Memory规划
这样一来,原来的内存消耗转嫁到了广播变量身上。但是,广播变量也会消耗内存,这会不会带来新的性能隐患呢?那我们就来看看,广播变量消耗的具体是哪块内存区域。
[回顾存储系统那一讲](https://time.geekbang.org/column/article/355081)我们说过Spark存储系统主要有3个服务对象分别是Shuffle中间文件、RDD缓存和广播变量。它们都由Executor上的BlockManager进行管理对于数据在内存和磁盘中的存储BlockManager利用MemoryStore和DiskStore进行抽象和封装。
那么广播变量所携带的数据内容会物化到MemoryStore中去以Executor为粒度为所有Task提供唯一的一份数据拷贝。MemoryStore产生的内存占用会被记入到Storage Memory的账上。**因此广播变量消耗的就是Storage Memory内存区域**。
接下来我们再来盘算一下第二种实现方式究竟需要耗费多少内存空间。由于广播变量的分发和存储以Executor为粒度因此每个Executor消耗的内存空间就是searchMap一份数据拷贝的大小。searchMap的大小我们刚刚计算过就是#size
明确了Storage Memory内存区域的具体消耗之后我们自然可以根据公式spark.executor.memory 300MB* spark.memory.fraction * spark.memory.storageFraction去有针对性地调节相关的内存配置项。
## 内存规划两步走
现在,咱们在两份不同的代码实现下,分别定量分析了不同内存区域的消耗与占用。对于这些消耗做到心中有数,我们自然就能够相应地去调整相关的配置项参数。基于这样的思路,想要最大化内存利用率,我们需要遵循两个步骤:
- **预估内存占用**
- **调整内存配置项**
我们以堆内内存为例来讲一讲内存规划的两步走具体该如何操作。我们都知道堆内内存划分为Reserved Memory、User Memory、Storage Memory和Execution Memory这4个区域。预留内存固定为300MB不用理会其他3个区域需要你去规划。
### 预估内存占用
首先,我们来说内存占用的预估,主要分为三步。
第一步计算User Memory的内存消耗。我们先汇总应用中包含的自定义数据结构并估算这些对象的总大小#size,然后**用#size乘以Executor的线程池大小即可得到User Memory区域的内存消耗#User**。
第二步计算Storage Memory的内存消耗。我们先汇总应用中涉及的广播变量和分布式数据集缓存分别估算这两类对象的总大小分别记为#bc#cache。另外我们把集群中的Executors总数记作#E。这样,**每个Executor中Storage Memory区域的内存消耗的公式就是#Storage = #bc + #cache / #E**。
第三步计算执行内存的消耗。学习上一讲我们知道执行内存的消耗与多个因素有关。第一个因素是Executor线程池大小#threads,第二个因素是数据分片大小,而数据分片大小取决于数据集尺寸#dataset和并行度#N。因此,**每个Executor中执行内存的消耗的计算公式为#Execution = #threads * #dataset / #N**。
### 调整内存配置项
得到这3个内存区域的预估大小#User#Storage#Execution之后调整相关的内存配置项就是一件水到渠成的事情由公式spark.executor.memory 300MB* spark.memory.fraction * spark.memory.storageFraction可知这里我们也可以分为3步。
首先,根据定义,**spark.memory.fraction可以由公式#Storage + #Execution/#User + #Storage + #Execution)计算得到**。
同理,**spark.memory.storageFraction的数值应该参考#Storage/#Storage + #Execution**。
最后对于Executor堆内内存总大小spark.executor.memory的设置我们自然要参考4个内存区域的总消耗也就是**300MB + #User + #Storage + #Execution。不过,我们要注意,利用这个公式计算的前提是,不同内存区域的占比与不同类型的数据消耗一致**。
总的来说,在内存规划的两步走中,第一步预估不同区域的内存占比尤为关键,因为第二步中参数的调整完全取决于第一步的预估结果。如果你按照这两个步骤去设置相关的内存配置项,相信你的应用在运行时就能够充分利用不同的内存区域,避免出现因参数设置不当而导致的内存浪费现象,从而在整体上提升内存利用率。
## 小结
合理划分Spark所有的内存区域是同时提升CPU与内存利用率的基础。因此掌握内存规划很重要在今天这一讲我们把内存规划归纳为两步走。
第一步是预估内存占用。
- 求出User Memory区域的内存消耗公式为#User=#size乘以Executor线程池的大小
- 求出每个Executor中Storage Memory区域的内存消耗公式为#Storage = #bc + #cache / #E
- 求出执行内存区域的内存消耗,公式为:#Execution = #threads * #dataset / #N
第二步是调整内存配置项根据公式得到的3个内存区域的预估大小#User#Storage#Execution去调整spark.executor.memory 300MB* spark.memory.fraction * spark.memory.storageFraction公式中涉及的所有配置项。
- spark.memory.fraction可以由公式#Storage + #Execution/#User + #Storage + #Execution)计算得到。
- spark.memory.storageFraction的数值应该参考#Storage/#Storage + #Execution)。
- spark.executor.memory的设置可以通过公式300MB + #User + #Storage + #Execution得到
这里,我还想多说几句,**内存规划两步走终归只是手段,它最终要达到的效果和目的,是确保不同内存区域的占比与不同类型的数据消耗保持一致,从而实现内存利用率的最大化**。
## 每日一练
1. 你知道估算Java对象存储大小的方法有哪些吗不同的方法又有哪些优、劣势呢
1. 对于内存规划的第一步来说,要精确地预估运行时每一个区域的内存消耗,很费时、费力,调优的成本很高。如果我们想省略掉第一步的精确计算,你知道有哪些方法能够粗略、快速地预估不同内存区域的消耗占比吗?
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,190 @@
<audio id="audio" title="16 | 内存视角如何有效避免Cache滥用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/08/c18f7e82b1cf5c6e9dc9764148c63a08.mp3"></audio>
你好,我是吴磊。
在Spark的应用开发中有效利用Cache往往能大幅提升执行性能。
但某一天有位同学却和我说自己加了Cache之后执行性能反而变差了。仔细看了这位同学的代码之后我吓了一跳。代码中充斥着大量的`.cache`无论是RDD还是DataFrame但凡有分布式数据集的地方后面几乎都跟着个`.cache`。显然Cache滥用是执行性能变差的始作俑者。
实际上在有些场景中Cache是灵丹妙药而在另一些场合大肆使用Cache却成了饮鸩止渴。那Cache到底该在什么时候用、怎么用都有哪些注意事项呢今天这一讲我们先一起回顾Cache的工作原理再来回答这些问题。
## Cache的工作原理
在[存储系统](https://time.geekbang.org/column/article/355081)那一讲我们其实介绍过RDD的缓存过程只不过当时的视角是以MemoryStore为中心目的在于理解存储系统的工作原理今天咱们把重点重新聚焦到缓存上来。
Spark的Cache机制主要有3个方面需要我们掌握它们分别是
- 缓存的存储级别:它限定了数据缓存的存储介质,如内存、磁盘等
- 缓存的计算过程从RDD展开到分片以Block的形式存储于内存或磁盘的过程
- 缓存的销毁过程:缓存数据以主动或是被动的方式,被驱逐出内存或是磁盘的过程
下面,我们一一来看。
### 存储级别
Spark中的Cache支持很多种存储级别比如MEMORY_AND_DISK_SER_2、MEMORY_ONLY等等。这些长得差不多的字符串我们该怎么记忆和区分呢其实**每一种存储级别都包含3个基本要素**。
- 存储介质:内存还是磁盘,或是两者都有。
- 存储形式对象值还是序列化的字节数组带SER字样的表示以序列化方式存储不带SER则表示采用对象值。
- 副本数量存储级别名字最后的数字代表拷贝数量没有数字默认为1份副本。
<img src="https://static001.geekbang.org/resource/image/4e/e2/4ecdfd4b62b1c6e151d029c38088yye2.jpeg" alt="" title="Cache存储级别">
当我们对五花八门的存储级别拆解之后就会发现它们不过是存储介质、存储形式和副本数量这3类不同基本元素的排列组合而已。我在上表中列出了目前Spark支持的所有存储级别你可以通过它加深理解。
尽管缓存级别多得让人眼花缭乱,但实际上**最常用的只有两个MEMORY_ONLY和MEMORY_AND_DISK它们分别是RDD缓存和DataFrame缓存的默认存储级别**。在日常的开发工作中当你在RDD和DataFrame之上调用`.cache`函数时Spark默认采用的就是MEMORY_ONLY和MEMORY_AND_DISK。
### 缓存的计算过程
在MEMORY_AND_DISK模式下Spark会优先尝试把数据集全部缓存到内存内存不足的情况下再把剩余的数据落盘到本地。MEMORY_ONLY则不管内存是否充足而是一股脑地把数据往内存里塞即便内存不够也不会落盘。不难发现**这两种存储级别都是先尝试把数据缓存到内存**。数据在内存中的存储过程我们在[第6讲](https://time.geekbang.org/column/article/355081)中讲过了,这里我们再一起回顾一下。
<img src="https://static001.geekbang.org/resource/image/8f/e2/8fc350146a7yyb0448303d7f1f094be2.jpg" alt="" title="分布式数据集缓存到内存">
无论是RDD还是DataFrame它们的数据分片都是以迭代器Iterator的形式存储的。因此要把数据缓存下来我们先得把迭代器展开成实实在在的数据值这一步叫做Unroll如步骤1所示。展开的对象值暂时存储在一个叫做ValuesHolder的数据结构里然后转换为MemoryEntry。转换的实现方式是toArray因此它不产生额外的内存开销这一步转换叫做Transfer如步骤2所示。最终MemoryEntry和与之对应的BlockID以Key、Value的形式存储到哈希字典LinkedHashMap如图中的步骤3所示。
当分布式数据集所有的数据分片都从Unroll到Transfer再到注册哈希字典之后数据在内存中的缓存过程就宣告完毕。
### 缓存的销毁过程
但是很多情况下应用中数据缓存的需求会超过Storage Memory区域的空间供给。虽然缓存任务可以抢占Execution Memory区域的空间但“出来混迟早是要还的”随着执行任务的推进缓存任务抢占的内存空间还是要“吐”出来。这个时候Spark就要执行缓存的销毁过程。
你不妨把Storage Memory想象成一家火爆的网红餐厅待缓存的数据分片是一位又一位等待就餐的顾客。当需求大于供给顾客数量远超餐位数量的时候Spark自然要制定一些规则来合理地“驱逐”那些尸位素餐的顾客把位置腾出来及时服务那些排队等餐的人。
那么问题来了Spark基于什么规则“驱逐”顾客呢接下来我就以同时缓存多个分布式数据集的情况为例带你去分析一下在内存受限的情况下会发生什么。
我们用一张图来演示这个过程假设MemoryStore中存有4个RDD/Data Frame的缓存数据这4个分布式数据集各自缓存了一些数据分片之后Storage Memory区域就被占满了。当RDD1尝试把第6个分片缓存到MemoryStore时却发现内存不足塞不进去了。
这种情况下,**Spark就会逐一清除一些“尸位素餐”的MemoryEntry来释放内存从而获取更多的可用空间来存储新的数据分片**。这个过程叫做Eviction它的中文翻译还是蛮形象的就叫做驱逐也就是把MemoryStore中那些倒霉的MemoryEntry驱逐出内存。
<img src="https://static001.geekbang.org/resource/image/b7/14/b73308328ef549579d02c72afb2ab114.jpg" alt="" title="多个分布式数据集同时缓存到内存">
回到刚才的问题Spark是根据什么规则选中的这些倒霉蛋呢这个规则叫作LRULeast Recently Used基于这个算法最近访问频率最低的那个家伙就是倒霉蛋。因为[LRU](https://baike.baidu.com/item/LRU/1269842?fr=aladdin)是比较基础的数据结构算法,笔试、面试的时候经常会考,所以它的概念我就不多说了。
我们要知道的是Spark是如何实现LRU的。这里**Spark使用了一个巧妙的数据结构LinkedHashMap这种数据结构天然地支持LRU算法**。
LinkedHashMap使用两个数据结构来维护数据一个是传统的HashMap另一个是双向链表。HashMap的用途在于快速访问根据指定的BlockIdHashMap以O(1)的效率返回MemoryEntry。双向链表则不同它主要用于维护元素也就是BlockId和MemoryEntry键值对的访问顺序。凡是被访问过的元素无论是插入、读取还是更新都会被放置到链表的尾部。因此链表头部保存的刚好都是“最近最少访问”的元素。
如此一来当内存不足需要驱逐缓存的数据块时Spark只利用LinkedHashMap就可以做到按照“最近最少访问”的原则去依次驱逐缓存中的数据分片了。
除此之外在存储系统那一讲有同学问MemoryStore为什么使用LinkedHashMap而不用普通的Map来存储BlockId和MemoryEntry的键值对。我刚才说的就是答案了。
回到图中的例子当RDD1试图缓存第6个数据分片但可用内存空间不足时Spark 会对LinkedHashMap从头至尾扫描边扫描边记录MemoryEntry大小当倒霉蛋的总大小超过第6个数据分片时Spark停止扫描。
有意思的是,**倒霉蛋的选取规则遵循“兔子不吃窝边草”同属一个RDD的MemoryEntry不会被选中**。就像图中的步骤4展示的一样第一个蓝色的MemoryEntry会被跳过紧随其后打叉的两个MemoryEntry被选中。
因此总结下来在清除缓存的过程中Spark遵循两个基本原则
- LRU按照元素的访问顺序优先清除那些“最近最少访问”的BlockId、MemoryEntry键值对
- 兔子不吃窝边草在清除的过程中同属一个RDD的MemoryEntry拥有“赦免权”
### 退化为MapReduce
尽管有缓存销毁这个环节的存在Storage Memory内存空间也总会耗尽MemoryStore也总会“驱无可驱”。这个时候MEMORY_ONLY模式就会放弃剩余的数据分片。比如在Spark UI上你时常会看到Storage Tab中的缓存比例低于100%。而我们从Storage Tab也可以观察到在MEMORY_AND_DISK模式下数据集在内存和磁盘中各占一部分比例。
这是因为对于MEMORY_AND_DISK存储级别来说当内存不足以容纳所有的RDD数据分片的时候Spark会把尚未展开的RDD分片通过DiskStore缓存到磁盘中。DiskStore的工作原理我们在存储系统那一讲有过详细介绍你可以回去看一看我建议你结合DiskStore的知识把RDD分片在磁盘上的缓存过程推导出来。
因此,**相比MEMORY_ONLYMEMORY_AND_DISK模式能够保证数据集100%地物化到存储介质**。对于计算链条较长的RDD或是DataFrame来说把数据物化到磁盘也是值得的。但是我们也不能逢RDD、DataFrame就调用`.cache`因为在最差的情况下Spark的内存计算就会退化为Hadoop MapReduce根据磁盘的计算模式。
比如说你用DataFrame API开发应用计算过程涉及10次DataFrame之间的转换每个DataFrame都调用`.cache`进行缓存。由于Storage Memory内存空间受限MemoryStore最多只能容纳两个DataFrame的数据量。因此MemoryStore会有8次以DataFrame为粒度的换进换出。最终MemoryStore存储的是访问频次最高的DataFrame数据分片其他的数据分片全部被驱逐到了磁盘上。也就是说平均下来至少有8次DataFrame的转换都会将计算结果落盘这不就是Hadoop的MapReduce计算模式吗
当然咱们考虑的是最差的情况但这也能让我们体会到滥用Cache可能带来的隐患和危害了。
## Cache的用武之地
既然滥用Cache危害无穷那在什么情况下适合使用Cache呢我建议你在做决策的时候遵循以下2条基本原则
- 如果RDD/DataFrame/Dataset在应用中的引用次数为1就坚决不使用Cache
- 如果引用次数大于1且运行成本占比超过30%应当考虑启用Cache
第一条很好理解,我们详细说说第二条。这里咱们定义了一个新概念:**运行成本占比。它指的是计算某个分布式数据集所消耗的总时间与作业执行时间的比值**。我们来举个例子假设我们有个数据分析的应用端到端的执行时间为1小时。应用中有个DataFrame被引用了2次从读取数据源经过一系列计算到生成这个DataFrame需要花费12分钟那么这个DataFrame的运行成本占比应该算作12 * 2 / 60 = 40%。
你可能会说“作业执行时间好算直接查看Spark UI就好了DataFrame的运行时间怎么算呢”这里涉及一个小技巧我们可以从现有应用中 把DataFrame的计算逻辑单拎出来然后利用Spark 3.0提供的Noop来精确地得到DataFrame的运行时间。假设df是那个被引用2次的DataFrame我们就可以把df依赖的所有代码拷贝成一个新的作业然后在df上调用Noop去触发计算。Noop的作用很巧妙它只触发计算而不涉及落盘与数据存储因此新作业的执行时间刚好就是DataFrame的运行时间。
```
//利用noop精确计算DataFrame运行时间
df.write
.format(“noop”)
.save()
```
你可能会觉得每次计算占比会很麻烦但只要你对数据源足够了解、对计算DataFrame的中间过程心中有数了之后其实不必每次都去精确地计算运行成本占比尝试几次你就能对分布式数据集的运行成本占比估摸得八九不离十了。
## Cache的注意事项
弄清楚了应该什么时候使用Cache之后我们再来说说Cache的注意事项。
首先,我们都知道,`.cache`是惰性操作,因此在调用`.cache`之后需要先用Action算子触发缓存的物化过程。但是我发现很多同学在选择Action算子的时候很随意first、take、show、count中哪个顺手就用哪个。
这肯定是不对的,**这4个算子中只有count才会触发缓存的完全物化而first、take和show这3个算子只会把涉及的数据物化**。举个例子show默认只产生20条结果如果我们在.cache之后调用show算子它只会缓存数据集中这20条记录。
选择好了算子之后我们再来讨论一下怎么Cache这个问题。你可能会说“这还用说吗在RDD、DataFrame后面调用`.cache`不就得了”。还真没这么简单我出一道选择题来考考你如果给定包含数十列的DataFrame df和后续的数据分析你应该采用下表中的哪种Cache方式
```
val filePath: String = _
val df: DataFrame = spark.read.parquet(filePath)
//Cache方式一
val cachedDF = df.cache
//数据分析
cachedDF.filter(col2 &gt; 0).select(col1, col2)
cachedDF.select(col1, col2).filter(col2 &gt; 100)
//Cache方式二
df.select(col1, col2).filter(col2 &gt; 0).cache
//数据分析
df.filter(col2 &gt; 0).select(col1, col2)
df.select(col1, col2).filter(col2 &gt; 100)
//Cache方式三
val cachedDF = df.select(col1, col2).cache
//数据分析
cachedDF.filter(col2 &gt; 0).select(col1, col2)
cachedDF.select(col1, col2).filter(col2 &gt; 100)
```
我们都知道由于Storage Memory内存空间受限因此Cache应该遵循**最小公共子集原则**也就是说开发者应该仅仅缓存后续操作必需的那些数据列。按照这个原则实现方式1应当排除在外毕竟df是一张包含数十列的宽表。
我们再来看第二种Cache方式方式2缓存的数据列是`col1``col2`,且`col2`数值大于0。第一条分析语句只是把`filter``select`调换了顺序;第二条语句`filter`条件限制`col2`数值要大于100那么这个语句的结果就是缓存数据的子集。因此乍看上去两条数据分析语句在逻辑上刚好都能利用缓存的数据内容。
但遗憾的是这两条分析语句都会跳过缓存数据分别去磁盘上读取Parquet源文件然后从头计算投影和过滤的逻辑。这是为什么呢究其缘由是**Cache Manager要求两个查询的Analyzed Logical Plan必须完全一致才能对DataFrame的缓存进行复用**。
Analyzed Logical Plan是比较初级的逻辑计划主要负责AST查询语法树的语义检查确保查询中引用的表、列等元信息的有效性。像谓词下推、列剪枝这些比较智能的推理要等到制定Optimized Logical Plan才会生效。因此即使是同一个查询语句仅仅是调换了`select``filter`的顺序在Analyzed Logical Plan阶段也会被判定为不同的逻辑计划。
因此为了避免因为Analyzed Logical Plan不一致造成的Cache miss我们应该采用第三种实现方式把我们想要缓存的数据赋值给一个变量凡是在这个变量之上的分析操作都会完全复用缓存数据。你看缓存的使用可不仅仅是调用`.cache`那么简单。
除此之外我们也应当及时清理用过的Cache尽早腾出内存空间供其他数据集消费从而尽量避免Eviction的发生。一般来说我们会用.unpersist来清理弃用的缓存数据它是.cache的逆操作。unpersist操作支持同步、异步两种模式
- 异步模式调用unpersist()或是unpersist(False)
- 同步模式调用unpersist(True)
在异步模式下Driver把清理缓存的请求发送给各个Executors之后会立即返回并且继续执行用户代码比如后续的任务调度、广播变量创建等等。在同步模式下Driver发送完请求之后会一直等待所有Executors给出明确的结果缓存清除成功还是失败。各个Executors清除缓存的效率、进度各不相同Driver要等到最后一个Executor返回结果才会继续执行Driver侧的代码。显然同步模式会影响Driver的工作效率。因此通常来说在需要主动清除Cache的时候我们往往采用异步的调用方式也就是调用unpersist()或是unpersist(False)。
## 小结
想要有效避免Cache的滥用我们必须从Cache的工作原理出发先掌握Cache的3个重要机制分别是存储级别、缓存计算和缓存的销毁过程。
对于存储级别来说实际开发中最常用到的有两个MEMORY_ONLY和MEMORY_AND_DISK它们分别是RDD缓存和DataFrame缓存的默认存储级别。
对于缓存计算来说它分为3个步骤第一步是Unroll把RDD数据分片的Iterator物化为对象值第二步是Transfer把对象值封装为MemoryEntry第三步是把BlockId、MemoryEntry价值对注册到LinkedHashMap数据结构。
另外当数据缓存需求远大于Storage Memory区域的空间供给时Spark利用LinkedHashMap数据结构提供的特性会遵循LRU和兔子不吃窝边草这两个基本原则来清除内存空间
- LRU按照元素的访问顺序优先清除那些“最近最少访问”的BlockId、MemoryEntry键值对
- 兔子不吃窝边草在清除的过程中同属一个RDD的MemoryEntry拥有“赦免权”
其次我们要掌握使用Cache的一般性原则和注意事项我把它们总结为3条
- 如果RDD/DataFrame/Dataset在应用中的引用次数为1我们就坚决不使用Cache
- 如果引用次数大于1且运行成本占比超过30%我们就考虑启用Cache其中运行成本占比的计算可以利用Spark 3.0推出的noop功能
- Action算子要选择count才能完全物化缓存数据以及在调用Cache的时候我们要把待缓存数据赋值给一个变量。这样一来只要是在这个变量之上的分析操作都会完全复用缓存数据。
## 每日一练
1. 你能结合DiskStore的知识推导出MEMORY_AND_DISK模式下RDD分片缓存到磁盘的过程吗
1. 你觉得为什么Eviction规则要遵循“兔子不吃窝边草”呢如果允许同一个RDD的MemoryEntry被驱逐有什么危害吗
1. 对于DataFrame的缓存复用Cache Manager为什么没有采用根据Optimized Logical Plan的方式你觉得难点在哪里如果让你实现Cache Manager的话你会怎么做
期待在留言区看到你的思考和答案如果你的朋友也正在为怎么使用Cache而困扰也欢迎你把这一讲转发给他。我们下一讲见

View File

@@ -0,0 +1,173 @@
<audio id="audio" title="17 | 内存视角OOM都是谁的锅怎么破" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/e9/86ab8c3bc234f9c90684b1bf5d8bffe9.mp3"></audio>
你好,我是吴磊。
无论是批处理、流计算还是数据分析、机器学习只要是在Spark作业中我们总能见到OOMOut Of Memory内存溢出的身影。一旦出现OOM作业就会中断应用的业务功能也都无法执行。因此及时处理OOM问题是我们日常开发中一项非常重要的工作。
但是Spark报出的OOM问题可以说是五花八门常常让人找不到头绪。比如我们经常遇到数据集按照尺寸估算本该可以完全放进内存但Spark依然会报OOM异常。这个时候不少同学都会参考网上的做法把spark.executor.memory不断地调大、调大、再调大直到内心崩溃也无济于事最后只能放弃。
那么当我们拿到OOM这个“烫手的山芋”的时候该怎么办呢我们最先应该弄清楚的是“**到底哪里出现了OOM**”。只有准确定位出现问题的具体区域,我们的调优才能有的放矢。具体来说,这个“**哪里**”我们至少要分3个方面去看。
- 发生OOM的LOCLine Of Code也就是代码位置在哪
- OOM发生在Driver端还是在Executor端
- 如果是发生在Executor端OOM到底发生在哪一片内存区域
定位出错代码的位置非常重要但也非常简单我们只要利用Stack Trace就能很快找到抛出问题的LOC。因此更**关键的是我们要明确出问题的到底是Driver端还是Executor端以及是哪片内存区域**。Driver和Executor产生OOM的病灶不同我们自然需要区别对待。
所以今天这一讲我们就先来说说Driver端的OOM问题和应对方法。由于内存在Executor端被划分成了不同区域因此对于Executor端怪相百出的OOM我们还要结合案例来分类讨论。最后我会带你整理出一套应对OOM的“武功秘籍”让你在面对OOM的时候能够见招拆招、有的放矢
## Driver端的OOM
我们先来说说Driver端的OOM。Driver的主要职责是任务调度同时参与非常少量的任务计算因此Driver的内存配置一般都偏低也没有更加细分的内存区域。
因为Driver的内存就是囫囵的那么一块所以Driver端的OOM问题自然不是调度系统的毛病只可能来自它涉及的计算任务主要有两类
- 创建小规模的分布式数据集使用parallelize、createDataFrame等API创建数据集
- 收集计算结果通过take、show、collect等算子把结果收集到Driver端
因此Driver端的OOM逃不出2类病灶
- **创建的数据集超过内存上限**
- **收集的结果集超过内存上限**
第一类病灶不言自明咱们不细说了。看到第二类病灶想必你第一时间想到的就是万恶的collect。确实说到OOM就不得不提collect。collect算子会从Executors把全量数据拉回到Driver端因此如果结果集尺寸超过Driver内存上限它自然会报OOM。
由开发者直接调用collect算子而触发的OOM问题其实很好定位比较难定位的是间接调用collect而导致的OOM。那么间接调用collect是指什么呢还记得广播变量的工作原理吗
<img src="https://static001.geekbang.org/resource/image/b3/2a/b3c5ab392c2303bf7923488623b4022a.jpg" alt="" title="广播变量的创建与分发">
广播变量在创建的过程中需要先把分布在所有Executors的数据分片拉取到Driver端然后在Driver端构建广播变量最后Driver端把封装好的广播变量再分发给各个Executors。第一步的数据拉取其实就是用collect实现的。如果Executors中数据分片的总大小超过Driver端内存上限也会报OOM。在日常的调优工作中你看到的表象和症状可能是
```
java.lang.OutOfMemoryError: Not enough memory to build and broadcast
```
但实际的病理却是Driver端内存受限没有办法容纳拉取回的结果集。找到了病因再去应对Driver端的OOM就很简单了。我们只要对结果集尺寸做适当的预估然后再相应地增加Driver侧的内存配置就好了。调节Driver端侧内存大小我们要用到spark.driver.memory配置项预估数据集尺寸可以用“先Cache再查看执行计划”的方式示例代码如下。
```
val df: DataFrame = _
df.cache.count
val plan = df.queryExecution.logical
val estimated: BigInt = spark
.sessionState
.executePlan(plan)
.optimizedPlan
.stats
.sizeInBytes
```
## Executor端的OOM
我们再来说说Executor端的OOM。我们知道执行内存分为4个区域Reserved Memory、User Memory、Storage Memory和Execution Memory。这4个区域中都有哪些区域会报OOM异常呢哪些区域压根就不存在OOM的可能呢
**在Executors中与任务执行有关的内存区域才存在OOM的隐患**。其中Reserved Memory大小固定为300MB因为它是硬编码到源码中的所以不受用户控制。而对于Storage Memory来说即便数据集不能完全缓存到MemoryStoreSpark也不会抛OOM异常额外的数据要么落盘MEMORY_AND_DISK、要么直接放弃MEMORY_ONLY
因此当Executors出现OOM的问题我们可以先把Reserved Memory和Storage Memory排除然后锁定Execution Memory和User Memory去找毛病。
### User Memory的OOM
在内存管理那一讲我们说过User Memory用于存储用户自定义的数据结构如数组、列表、字典等。因此如果这些数据结构的总大小超出了User Memory内存区域的上限你可能就会看到下表示例中的报错。
```
java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf
java.lang.OutOfMemoryError: Java heap space at java.lang.reflect.Array.newInstance
```
如果你的数据结构是用于分布式数据转换在计算User Memory内存消耗时你就需要考虑Executor的线程池大小。还记得下面的这个例子吗
```
val dict = List(“spark”, “tune”)
val words = spark.sparkContext.textFile(“~/words.csv”)
val keywords = words.filter(word =&gt; dict.contains(word))
keywords.map((_, 1)).reduceByKey(_ + _).collect
```
自定义的列表dict会随着Task分发到所有Executors因此多个Task中的dict会对User Memory产生重复消耗。如果把dict尺寸记为#sizeExecutor线程池大小记为#threads那么dict对User Memory的总消耗就是#size * #threads。一旦总消耗超出User Memory内存上限自然就会产生OOM问题。
<img src="https://static001.geekbang.org/resource/image/ba/39/ba45d47a910ccb92861b1fd153b36839.jpg" alt="" title="用户数据在任务中的分发">
那么解决User Memory 端 OOM的思路和Driver端的并无二致也是先对数据结构的消耗进行预估然后相应地扩大User Memory的内存配置。不过相比DriverUser Memory内存上限的影响因素更多总大小由spark.executor.memory * 1 - spark.memory.fraction计算得到。
### Execution Memory的OOM
要说OOM的高发区非Execution Memory莫属。久行夜路必撞鬼在分布式任务执行的过程中Execution Memory首当其冲因此出错的概率相比其他内存区域更高。关于Execution Memory的OOM我发现不少同学都存在这么一个误区只要数据量比执行内存小就不会发生OOM相反就会有一定的几率触发OOM问题。
实际上,**数据量并不是决定OOM与否的关键因素数据分布与Execution Memory的运行时规划是否匹配才是**。这么说可能比较抽象你还记得黄小乙的如意算盘吗为了提高老乡们种地的热情和积极性他制定了个转让协议所有老乡申请的土地面积介于1/N/2和1/N之间。因此如果有的老乡贪多求快买的种子远远超过1/N上限能够容纳的数量这位老乡多买的那部分种子都会被白白浪费掉。
同样的我们可以把Execution Memory看作是
土地,把分布式数据集看作是种子,一旦**分布式任务的内存请求超出1/N这个上限**Execution Memory就会出现OOM问题。而且相比其他场景下的OOM问题Execution Memory的OOM要复杂得多它不仅仅与内存空间大小、数据分布有关还与Executor线程池和运行时任务调度有关。
抓住了引起OOM问题最核心的原因对于Execution Memory OOM的诸多表象我们就能从容应对了。下面我们就来看两个平时开发中常见的实例数据倾斜和数据膨胀。为了方便说明在这两个实例中计算节点的硬件配置是一样的都是2个CPU core每个core有两个线程内存大小为1GB并且spark.executor.cores设置为3spark.executor.memory设置为900MB。
根据配置项那一讲我们说过的不同内存区域的计算公式在默认配置下我们不难算出Execution Memory和Storage Memory内存空间都是180MB。而且因为我们的例子里没有RDD缓存所以Execution Memory内存空间上限是360MB。
#### 实例1数据倾斜
我们先来看第一个数据倾斜的例子。节点在Reduce阶段拉取数据分片3个Reduce Task对应的数据分片大小分别是100MB和300MB。显然第三个数据分片存在轻微的数据倾斜。由于Executor线程池大小为3因此每个Reduce Task最多可获得360MB * 1 / 3 = 120MB的内存空间。Task1、Task2获取到的内存空间足以容纳分片1、分片2因此可以顺利完成任务。
<img src="https://static001.geekbang.org/resource/image/bb/e2/bbd4052de37200a7152646668f88a5e2.jpg" alt="" title="数据倾斜导致OOM">
Task3的数据分片大小远超内存上限即便Spark在Reduce阶段支持Spill和外排120MB的内存空间也无法满足300MB数据最基本的计算需要如PairBuffer和AppendOnlyMap等数据结构的内存消耗以及数据排序的临时内存消耗等等。
这个例子的表象是数据倾斜导致OOM但实质上是Task3的内存请求超出1/N上限。因此针对以这个案例为代表的数据倾斜问题我们至少有2种调优思路
- **消除数据倾斜让所有的数据分片尺寸都不大于100MB**
- **调整Executor线程池、内存、并行度等相关配置提高1/N上限到300MB**
每一种思路都可以衍生出许多不同的方法就拿第2种思路来说要满足1/N的上限最简单地我们可以把spark.executor.cores设置成1也就是Executor线程池只有一个线程“并行”工作。这个时候每个任务的内存上限都变成了360MB容纳300MB的数据分片绰绰有余。
当然线程池大小设置为1是不可取的刚刚只是为了说明调优的灵活性。延续第二个思路你需要去平衡多个方面的配置项在充分利用CPU的前提下解决OOM的问题。比如
- 维持并发度、并行度不变增大执行内存设置提高1/N上限到300MB
- 维持并发度、执行内存不变使用相关配置项来提升并行度将数据打散让所有的数据分片尺寸都缩小到100MB以内
关于线程池、内存和并行度之间的平衡与设置我在CPU视角那一讲做过详细的介绍你可以去回顾一下。至于怎么消除数据倾斜你可以好好想想再把你的思路分享出来。
#### 实例2数据膨胀
我们再来看第二个数据膨胀的例子。节点在Map阶段拉取HDFS数据分片3个Map Task对应的数据分片大小都是100MB。按照之前的计算每个Map Task最多可获得120MB的执行内存不应该出现OOM问题才对。
<img src="https://static001.geekbang.org/resource/image/63/cb/639c65b2ce9213295feedd3634e261cb.jpg" alt="" title="数据膨胀导致OOM">
尴尬的地方在于磁盘中的数据进了JVM之后会膨胀。在我们的例子中数据分片加载到JVM Heap之后翻了3倍原本100MB的数据变成了300MB因此OOM就成了一件必然会发生的事情。
在这个案例中表象是数据膨胀导致OOM但本质上还是Task2和Task3的内存请求超出1/N上限。因此针对以这个案例为代表的数据膨胀问题我们还是有至少2种调优思路
- **把数据打散提高数据分片数量、降低数据粒度让膨胀之后的数据量降到100MB左右**
- **加大内存配置结合Executor线程池调整提高1/N上限到300MB**
## 小结
想要高效解决五花八门的OOM问题最重要的就是准确定位问题出现的区域这样我们的调优才能有的放矢我建议你按照两步进行。
首先定位OOM发生的代码位置你通过Stack Trace就能很快得到答案。
其次定位OOM是发生在Driver端还是在Executor端**。**如果是发生在Executor端再定位具体发生的区域。
发生在Driver端的OOM可以归结为两类
- 创建的数据集超过内存上限
- 收集的结果集超过内存上限
应对Driver端OOM的常规方法是先适当预估结果集尺寸然后再相应增加Driver侧的内存配置。
发生在Executors侧的OOM只和User Memory和Execution Memory区域有关因为它们都和任务执行有关。其中User Memory区域OOM的产生的原因和解决办法与Driver别无二致你可以直接参考。
而Execution Memory区域OOM的产生的原因是数据分布与Execution Memory的运行时规划不匹配也就是分布式任务的内存请求超出了1/N上限。解决Execution Memory区域OOM问题的思路总的来说可以分为3类
- 消除数据倾斜让所有的数据分片尺寸都小于1/N上限
- 把数据打散提高数据分片数量、降低数据粒度让膨胀之后的数据量降到1/N以下
- 加大内存配置结合Executor线程池调整提高1/N上限
## 每日一练
1. 数据膨胀导致OOM的例子中为什么Task1能获取到300MB的内存空间提示可以回顾CPU视角那一讲去寻找答案。
1. 在日常开发中你还遇到过哪些OOM表象你能把它们归纳到我们今天讲的分类中吗
期待在留言区看到你的思考和分享,我们下一讲见!

View File

@@ -0,0 +1,140 @@
<audio id="audio" title="18 | 磁盘视角:如果内存无限大,磁盘还有用武之地吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7a/f2/7a1fb1f2318d5b0f278faa769a4bc2f2.mp3"></audio>
你好,我是吴磊。
我们都知道Spark的优势在于内存计算。一提到“内存计算”我们的第一反应都是执行效率高但如果听到“基于磁盘的计算”就会觉得性能肯定好不到哪儿去。甚至有的人会想如果Spark的内存无限大就好了这样我们就可以把磁盘完全抛弃掉。当然这个假设大概率不会成真而且这种一刀切的思维也不正确。
如果内存无限大我们确实可以通过一些手段让Spark作业在执行的过程中免去所有的落盘动作。但是无限大内存引入的大量Full GC停顿Stop The World很有可能让应用的执行性能相比有磁盘操作的时候更差。这就不符合我们一再强调的**调优的最终目的是在不同的硬件资源之间寻求平衡了**。
所以今天这一讲我们就来说说磁盘在Spark任务执行的过程中都扮演哪些重要角色它功能方面的作用以及性能方面的价值。掌握它们可以帮助我们更合理地利用磁盘以成本优势平衡不同硬件资源的计算负载。
## 磁盘在功能上的作用
在Spark当中磁盘都用在哪些地方呢在Shuffle那一讲我们说过在Map阶段Spark根据计算是否需要聚合分别采用PartitionedPairBuffer和PartitionedAppendOnlyMap两种不同的内存数据结构来缓存分片中的数据记录。分布式计算往往涉及海量数据因此这些数据结构通常都没办法装满分区中的所有数据。在内存受限的情况下溢出机制可以保证任务的顺利执行不会因为内存空间不足就立即报OOM异常。
<img src="https://static001.geekbang.org/resource/image/68/4e/688d5453a9431c53d0a75c7a4yy3e44e.jpg" alt="" title="溢出数据到磁盘避免频繁的OOM">
以“仙女散花”的游戏为例我们用groupByKey去收集不同花色的花朵。在PartitionedPairBuffer大小为4的情况下当小红拿到的花朵数量超过4朵的时候其余花朵要想进入内存Spark就必须把PartitionedPairBuffer中的内容暂时溢出到临时文件把内存空间让出来才行。**这就是磁盘在功能上的第一个作用:溢出临时文件。**
当分区中的最后一批数据加载到PartitionedPairBuffer之后它会和之前溢出到磁盘的临时文件一起做归并计算最终得到Shuffle的数据文件和索引文件也会存储到磁盘上也就是我们常说的Shuffle中间文件。**这就是磁盘的在功能上的第二个作用存储Shuffle中间文件。**
除此之外,**磁盘的第三个作用就是缓存分布式数据集。也就是说,凡是带**DISK**字样的存储模式,都会把内存中放不下的数据缓存到磁盘**。这些缓存数据还有刚刚讲的临时文件、中间文件都会存储到spark.local.dir参数对应的文件系统目录中。
## 性能上的价值
在配置项那一讲我们说过把spark.local.dir这个参数配置到SDD或者其他访问效率更高的存储系统中可以提供更好的 I/O 性能。除此之外,磁盘复用还能给执行性能带来更好的提升。所谓**磁盘复用它指的是Shuffle Write阶段产生的中间文件被多次计算重复利用的过程**。下面,我就通过两个例子给你详细讲讲,磁盘复用的常见应用和它的收益。
### 失败重试中的磁盘复用
我们经常说在没有RDD Cache的情况下一旦某个计算环节出错就会触发整条DAG从头至尾重新计算这个过程又叫失败重试。严格来说这种说法是不准确的。因为失败重试的计算源头并不是整条DAG的“头”而是与触发点距离最新的Shuffle的中间文件。
<img src="https://static001.geekbang.org/resource/image/35/86/35c13d9f2eba5d23dabe05249ccb9486.jpg" alt="" title="磁盘复用与蓄水池">
我们以文稿示意图中的DAG为例子HDFS源数据经过两次转换之后分别得到RDD1和RDD2。RDD2在Shuffle之后再进行两次计算分成得到RDD3和RDD4。
不幸的是在计算RDD4的过程中有些任务失败了。在失败重试的时候Spark确实会从RDD4向前回溯但是有了磁盘复用机制的存在它并不会一直回溯到HDFS源数据而是直接回溯到已经物化到节点的RDD3的“数据源”也就是RDD2在Shuffle Write阶段输出到磁盘的中间文件。因此**磁盘复用的收益之一就是缩短失败重试的路径,在保障作业稳定性的同时提升执行性能**。
为了方便你理解我们不妨把DAG中的流水线计算想象成是干渠灌溉黄土高坡上的麦田一年到头也喝不到几滴雨水完全依靠人工干渠进行灌溉。当水电站开闸放水的时候水会沿着干渠一路向东流进支渠去滋养如饥似渴的麦苗。
一个水电站往往服务方圆百里大大小小的村子如果每次灌溉都等着水电站开闸放水遇上大旱的年头水还没流到支渠麦苗就都旱死了。要是能沿着干渠每隔一段距离就修建一座蓄水池那么附近的村民就能就近灌溉了。在这个干渠灌溉的类比中水电站的水是HDFS数据源头蓄水池就是Shuffle中间文件就近取水、就近灌溉就是磁盘复用机制。
### ReuseExchange机制下的磁盘复用
你可能会说“磁盘复用也没什么嘛无非是在失败重试的时候抄个近道、少走些弯路。在任务不出错的情况下是利用不到这项优势的。”没错所以我们再来说说磁盘复用的另一种形式ReuseExchange机制。ReuseExchange是Spark SQL众多优化策略中的一种它指的是**相同或是相似的物理计划可以共享Shuffle计算的中间结果**也就是我们常说的Shuffle中间文件。ReuseExchange机制可以帮我们削减I/O开销甚至节省Shuffle来大幅提升执行性能。
那我们该怎么有效利用ReuseExchange机制呢在数据仓库场景下为了得到数据报表或是可视化图表用户往往需要执行多个相似的查询甚至会把同样的查询语句执行多次。在这种情况下ReuseExchange策略在执行效率方面会带来非常大的收益。
<img src="https://static001.geekbang.org/resource/image/5f/98/5f390cdb366edc25329956c11e773f98.jpg" alt="" title="同样或相似的查询利用ReuseExchange缩短执行路径">
即便是在没有DataFrame Cache的情况下相同或是相似的查询也可以利用ReuseExchange策略在缩短执行路径的同时消除额外的Shuffle计算。从数据复用的角度来说ReuseExchange和DISK_ONLY模式的DataFrame Cache能起到的作用完全等价。
咱们来举个例子。现在有这样一个业务需求给定用户访问日志分别统计不同用户的PVPage Views页面浏览量、UVUnique Views网站独立访客然后再把两项统计结果合并起来以备后用。其中用户日志包含用户ID、访问时间、页面地址等主要字段。业务需求不仅明确也很简单我们很快就能把代码写出来。
```
//版本1分别计算PV、UV然后合并
// Data schema (userId: String, accessTime: Timestamp, page: String)
val filePath: String = _
val df: DataFrame = spark.read.parquet(filePath)
val dfPV: DataFrame = df.groupBy(&quot;userId&quot;).agg(count(&quot;page&quot;).alias(&quot;value&quot;)).withColumn(&quot;metrics&quot;, lit(&quot;PV&quot;))
val dfUV: DataFrame = df.groupBy(&quot;userId&quot;).agg(countDistinct(&quot;page&quot;).alias(&quot;value&quot;)).withColumn(&quot;metrics &quot;, lit(&quot;UV&quot;))
val resultDF: DataFrame = dfPV.Union(dfUV)
// Result样例
| userId | metrics | value |
| user0 | PV | 25 |
| user0 | UV | 12 |
```
代码逻辑是先读取用户日志然后在同一个DataFrame之上分别调用count和countDistinct计算PV、UV最后把PU、UV对应的两个DataFrame合并在一起。
虽然代码实现起来简单直接但是如果我们在resultDF之上调用explain或是通过Spark UI去查看物理计划就会发现尽管count和countDistinct是基于同一份数据源计算的但这两个操作的执行路径是完全独立的。它们各自扫描Parquet源文件并且通过Shuffle完成计算在Shuffle之前会先在Map端做本地聚合Shuffle之后会在Reduce端再进行全局聚合。
<img src="https://static001.geekbang.org/resource/image/dd/28/dd150e0863812522a6f2ee9102678928.jpg" alt="" title="Parquet文件扫描两次、Shuffle两次">
对于绝大多数的合并场景来说计算流程大抵如此。显然这样的做法是极其低效的尤其是在需要合并多个数据集的时候重复的数据扫描和分发就会引入更多的性能开销。那么有没有什么办法让同一份数据源的多个算子只读取一次Parquet文件且只做一次Shuffle呢
做了这么半天铺垫答案自然是“有”。针对版本1中的代码我们稍作调整就可以充分利用ReuseExchange策略来做优化。
```
//版本2分别计算PV、UV然后合并
// Data schema (userId: String, accessTime: Timestamp, page: String)
val filePath: String = _
val df: DataFrame = spark.read.parquet(filePath).repartition($&quot;userId&quot;)
val dfPV: DataFrame = df.groupBy(&quot;userId&quot;).agg(count(&quot;page&quot;).alias(&quot;value&quot;)).withColumn(&quot;metrics&quot;, lit(&quot;PV&quot;))
val dfUV: DataFrame = df.groupBy(&quot;userId&quot;).agg(countDistinct(&quot;page&quot;).alias(&quot;value&quot;)).withColumn(&quot;metrics &quot;, lit(&quot;UV&quot;))
val resultDF: DataFrame = dfPV.Union(dfUV)
// Result样例
| userId | metrics | value |
| user0 | PV | 25 |
| user0 | UV | 12 |
```
需要调整的部分仅仅是数据源读取其他部分的代码保持不变。在用Parquet API读取用户日志之后我们追加一步重分区操作也就是以userId为分区键调用repartition算子。
经过这个微小的改动之后我们重新在resultDF之上调用explain或是查看Spark UI会发现在新的物理计划中count或是countDistinct分支出现了ReuseExchange字样也就是其中一方复用了另一方的Exchange结果。
<img src="https://static001.geekbang.org/resource/image/00/b2/008e691de73eefc6daa4886017fa33b2.jpg" alt="" title="ReuseExchange">
通过观察执行计划不难发现ReuseExchange带来的收益相当可观不仅是**数据源只需要扫描一遍而且作为“性能瓶颈担当”的Shuffle也只发生了一次**。
另外你可能也会发现复用Shuffle中间结果的是两个不完全相同的查询一个是用count做统计计数另一个是用countDistinct做去重计数。你看两个相似的查询通过ReuseExchange数据复用达到了使用DISK_ONLY缓存的等价效果。换句话说你不需要手动调用persist(DISK_ONLY),也不需要忍受磁盘缓存的计算过程,就可以享受它带来的收益。这惊不惊喜、意不意外?
你可能会问“既然ReuseExchange机制这么好用满足什么条件才能触发Spark SQL去选择这个执行策略呢”事实上触发条件至少有2个
- **多个查询所依赖的分区规则要与Shuffle中间数据的分区规则保持一致**
- **多个查询所涉及的字段Attributes要保持一致**
对于第一个条件我们在案例中已经演示过了两个查询都用userId分组这就要求所依赖的数据必须要按照userId做分区。这也是为什么我们在版本2的代码中会添加以userId为分区键的repartition算子只有这样Shuffle中间结果的分区规则才能和查询所需的分区规则保持一致。
仔细观察count和countDistinct两个查询所涉及的字段我们会发现它们完全一致。实际上如果我们把count语句中的`count("page")`改为`count("*")`也并不影响PV的计算但是看似无关痛痒的改动会导致第二个条件不能满足从而无法利用ReuseExchange机制来提升执行性能。版本2中的`count("page")`改为`count("*")`之后物理计划会回退到版本1我把其中的变化留给你作为课后作业去对比。
## 小结
磁盘虽然在处理延迟上远不如内存,但在性能调优中依然不可或缺。理解磁盘在功能上和性能上的价值,可以帮助我们更合理地利用磁盘,以成本优势平衡不同硬件资源的计算负载。
从功能上看磁盘在Spark中主要有3方面的作用分别是溢出临时文件、缓存分布式数据集和存储Shuffle中间文件。这3方面功能在提升作业稳定性的同时也为执行效率的提升打下了基础。
从性能上看利用好磁盘复用机制可以极大地提高应用的执行性能。磁盘复用指的是Shuffle Write阶段产生的中间文件被多次计算重复利用的过程。磁盘复用有两种用途一个是失败重试另一个是ReuseExchange机制。其中失败重试指的就是任务失败之后尝试重头计算。这个过程中磁盘复用缩短了失败重试的路径在保障作业稳定性的同时提升执行性能。
ReuseExchange策略指的是相同或是相似的物理计划可以共享Shuffle计算的中间结果。ReuseExchange对于执行性能的贡献相当可观它可以让基于同一份数据源的多个算子只读取一次Parquet文件并且只做一次Shuffle来大幅削减磁盘与网络开销。
不过要想让Spark SQL在优化阶段选择ReuseExchange业务应用必须要满足2个条件
- 多个查询所依赖的分区规则要与Shuffle中间数据的分区规则保持一致
- 多个查询所涉及的字段要保持一致
## 每日一练
1. 请你把count计算中的`count("page")`改为`count("*")`,以此来观察物理计划的变化,并在留言区说出你的观察
1. 为了触发ReuseExchange机制生效我们按照userId对数据集做重分区结合这一点你不妨想一想在哪些情况下不适合采用ReuseExchange机制为什么
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,121 @@
<audio id="audio" title="19 | 网络视角:如何有效降低网络开销?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/28/08/284c4b55081d32685eeab80b82558308.mp3"></audio>
你好,我是吴磊。
在平衡不同硬件资源的时候相比CPU、内存、磁盘网络开销无疑是最拖后腿的那一个这一点在处理延迟上表现得非常明显。
下图就是不同硬件资源的处理延迟对比结果,我们可以看到最小的处理单位是纳秒。你可能对纳秒没什么概念,所以为了方便对比,我把纳秒等比放大到秒。这样,其他硬件资源的处理延迟也会跟着放大。最后一对比我们会发现,网络延迟是以天为单位的!
<img src="https://static001.geekbang.org/resource/image/c1/a9/c1e4926d3748bdc98a5317fcf4e5b2a9.png" alt="" title="不同硬件资源处理延迟对比">
因此,要想维持硬件资源之间的平衡,尽可能地降低网络开销是我们在性能调优中必须要做的。今天这一讲,我就按照数据进入系统的时间顺序,也就是数据读取、数据处理和数据传输的顺序,带你去分析和总结数据生命周期的不同阶段有效降低网络开销的方法。
## 数据读写
对于绝大多数应用来说第一步操作都是从分布式文件系统读取数据源。Spark支持的数据源种类非常丰富涉及的存储格式和存储系统可以说是五花八门。
<img src="https://static001.geekbang.org/resource/image/2a/c3/2a1e6190f6e746e97661bf6f09941cc3.jpeg" alt="" title="存储格式与存储系统">
这么多存储格式和外部存储系统交叉在一起又会有无数种组合,并且每一种组合都有它的应用场景。那么,我们该怎么判断网络开销会出现在哪些场景下呢?其实,**不管是什么文件格式,也不管是哪种存储系统,访问数据源是否会引入网络开销,取决于任务与数据的本地性关系,也就是任务的本地性级别**它一共有4种
- PROCESS_LOCAL任务与数据同在一个JVM进程中
- NODE_LOCAL任务与数据同在一个计算节点数据可能在磁盘上或是另一个JVM进程中
- RACK_LOCAL任务与数据不在同一节点但在同一个物理机架上
- ANY任务与数据是跨机架、甚至是跨DCData Center数据中心的关系
根据定义我们很容易判断出不同本地性级别下的计算任务是否会引入磁盘或网络开销结果如下表所示。从表格中我们不难发现从PROCESS_LOCAL到ANY数据访问效率是逐级变差的。在读取数据源阶段数据还未加载到内存任务没有办法调度到PROCESS_LOCAL级别。因此这个阶段我们能够调度的最佳级别是NODE_LOCAL。
<img src="https://static001.geekbang.org/resource/image/15/36/1532cde0f3956d77e44byy7138453736.jpeg" alt="" title="不同本地性级别与磁盘、网络开销的关系">
根据NODE_LOCAL的定义在这个级别下调度的目标节点至少在磁盘上存有Spark计算任务所需的数据分片。这也就意味着在集群部署上Spark集群与外部存储系统在物理上是紧紧耦合在一起的。相反如果Spark集群与存储集群在物理上是分开的那么任务的本地性级别只能退化到RACK_LOCAL甚至是ANY来通过网络获取所需的数据分片。
**因此对于Spark加HDFS和Spark加MongoDB来说是否会引入网络开销完全取决于它们的部署模式。物理上紧耦合在NODE_LOCAL级别下Spark用磁盘I/O替代网络开销获取数据物理上分离网络开销就无法避免。**
除此之外,物理上的隔离与否同样会影响数据的写入效率。当数据处理完毕,需要将处理结果落盘到外部存储的时候,紧耦合模式下的数据写入会把数据分片落盘到本地节点,避免网络开销。
值得一提的是在企业的私有化DC中更容易定制化集群的部署方式大家通常采用紧耦合的方式来提升数据访问效率。但是在公有云环境中计算集群在物理上往往和存储系统隔离因此数据源的读取只能走网络。
通过上面的分析对于数据读写占比较高的业务场景我们就可以通过在集群的部署模式上做规划从而在最开始部署Spark集群的时候就提前做好准备。
## 数据处理
数据读取完成后,就进入数据处理环节了。那在数据处理的过程中,都有哪些技巧能够帮助减少网络开销呢?
### 能省则省
说起数据处理中的网络开销我猜你最先想到的操作就是Shuffle。Shuffle作为大多数计算场景的“性能瓶颈担当”确实是网络开销的罪魁祸首。根据“能省则省”的开发原则我们自然要想尽办法去避免Shuffle。在数据关联的场景中省去Shuffle最好的办法就是把Shuffle Joins转化为Broadcast Joins。关于这方面的调优技巧我们在广播变量那几讲有过详细的讲解你可以翻回去看一看。尽管广播变量的创建过程也会引入网络传输但是两害相权取其轻相比Shuffle的网络开销广播变量的开销算是小巫见大巫了。
遵循“能省则省”的原则把Shuffle消除掉自然是最好的。如果实在没法避免Shuffle我们要尽可能地在计算中多使用Map端聚合去减少需要在网络中分发的数据量。这方面的典型做法就是用reduceByKey、aggregateByKey替换groupByKey不过在RDD API使用频率越来越低的当下这个调优技巧实际上早就名存实亡了。但是Map端聚合的思想并不过时。为什么这么说呢下面我通过一个小例子来你详细讲一讲。
在绝大多数2CTo Consumer的业务场景中我们都需要刻画用户画像。我们的小例子就是“用户画像”中的一环给定用户表按照用户群组统计兴趣列表要求兴趣列表内容唯一也就是不存在重复的兴趣项用户表的Schema如下表所示。
<img src="https://static001.geekbang.org/resource/image/80/83/80ff54fe0683b8e7aeea408539998383.jpeg" alt="" title="用户表Schema示例">
要获取群组兴趣列表我们应该先按照groupId分组收集群组内所有用户的兴趣列表然后再把列表中的兴趣项展平最后去重得到内容唯一的兴趣列表。应该说思路还是蛮简单的我们先来看第一版实现代码。
```
val filePath: String = _
val df = spark.read.parquent(filePath)
df.groupBy(“groupId”)
.agg(array_distinct(flatten(collect_list(col(“interestList”)))))
```
这版实现分别用collect_list、flatten和array_distinct来做兴趣列表的收集、展平和去重操作它完全符合业务逻辑。不过见到“收集”类的操作比如groupByKey以及这里的collect_list我们应该本能地提高警惕。因为这类操作会把最细粒度的全量数据在全网分发。相比其他算子这类算子引入的网络开销最大。
那**我们是不是可以把它们提前到Map端从而减少Shuffle中需要分发的数据量呢**当然可以。比如对于案例中的收集操作我们可以在刚开始收集兴趣列表的时候就在Map端做一次去重然后去查找DataFrame开发API看看有没有与collect_list对应的Map端聚合算子。
**因此在数据处理环节我们要遵循“能省则省”的开发原则主动削减计算过程中的网络开销。对于数据关联场景我们要尽可能地把Shuffle Joins转化为Broadcast Joins来消除Shuffle。如果确实没法避免Shuffle我们可以在计算中多使用Map端聚合减少需要在网络中分发的数据量。**
除了Shuffle之外还有个操作也会让数据在网络中分发这个操作很隐蔽我们经常注意不到它它就是多副本的RDD缓存。
比如说在实时流处理这样的场景下对于系统的高可用性应用的要求比较高因此你可能会用“_2”甚至是“_3”的存储模式在内存和磁盘中缓存多份数据拷贝。当数据副本数大于1的时候本地数据分片就会通过网络被拷贝到其他节点从而产生网络开销。虽然这看上去只是存储模式字符串的一个微小改动但在运行时它会带来很多意想不到的开销。因此如果你的应用对高可用特性没有严格要求我建议你尽量不要滥用多副本的RDD缓存
## 数据传输
最后就到了数据传输的环节。我们知道在落盘或是在网络传输之前数据都是需要先进行序列化的。在Spark中有两种序列化器供开发者选择分别是Java serializer和Kryo Serializer。Spark官方和网上的技术博客都会推荐你使用Kryo Serializer来提高效率通常来说Kryo Serializer相比Java serializer在处理效率和存储效率两个方面都会胜出数倍。因此在数据分发之前使用Kryo Serializer对其序列化会进一步降低网络开销。
不过经常有同学向我抱怨“为什么我用了Kryo Serializer序列化之后的数据尺寸反而比Java serializer的更大呢”注意啦这里我要提醒你**对于一些自定义的数据结构来说如果你没有明确把这些类型向Kryo Serializer注册的话虽然它依然会帮你做序列化的工作但它序列化的每一条数据记录都会带一个类名字这个类名字是通过反射机制得到的会非常长。在上亿的样本中存储开销自然相当可观。**
那该怎么向Kryo Serializer注册自定义类型呢其实非常简单**我们只需要在SparkConf之上调用registerKryoClasses方法就好了**,代码示例如下所示。
```
//向Kryo Serializer注册类型
val conf = new SparkConf().setMaster(“”).setAppName(“”)
conf.registerKryoClasses(Array(
classOf[Array[String]],
classOf[HashMap[String, String]],
classOf[MyClass]
))
```
另外与Kryo Serializer有关的配置项我也把它们汇总到了下面的表格中方便你随时查找。其中spark.serializer可以明确指定Spark采用Kryo Serializer序列化器。而spark.kryo.registrationRequired就比较有意思了如果我们把它设置为True当Kryo Serializer遇到未曾注册过的自定义类型的时候它就不会再帮你做序列化的工作而是抛出异常并且中断任务执行。这么做的好处在于在开发和调试阶段它能帮我们捕捉那些忘记注册的类型。
<img src="https://static001.geekbang.org/resource/image/82/64/821f4c71a21bf22c30187ca7fc3be064.jpeg" alt="" title="与Kryo Serializer有关的配置项">
为了方便你理解我们不妨把Java serializer和Kryo Serializer比作是两个不同的搬家公司。
Java Serializer是老牌企业市场占有率高而且因为用户体验很好所以非常受欢迎。只要你出具家庭住址Java Serializer会派专人到家里帮你打包你并不需要告诉他们家里都有哪些物件他们对不同种类的物品有一套自己的打包标准可以帮你省去很多麻烦。不过Java Serializer那套打包标准过于刻板不仅打包速度慢封装出来的包裹往往个头超大、占地儿你必须租用最大号的货车才能把家里所有的物品都装下。
Kryo Serializer属于市场新贵在打包速度和包裹尺寸方面都远胜Java Serializer。Kryo Serializer会以最紧凑的方式打包一寸空间也不浪费因此所有包裹用一辆小货车就能装下。但是订阅Kryo Serializer的托管服务之前用户需要提供详尽的物品明细表因此很多用户都嫌麻烦Kryo Serializer市场占有率也就一直上不去。
好啦现在你是不是对Java Serializer和Kryo Serializer有了更深的理解了由此可见如果你想要不遗余力去削减数据传输过程中的网络开销就可以尝试使用Kryo Serializer来做数据的序列化。相反要是你觉得开发成本才是核心痛点那采用默认的Java Serializer也未尝不可。
## 小结
这一讲,面对数据处理不同阶段出现的网络开销,我带你总结出了有效降低它的办法。
首先在数据读取阶段要想获得NODE_LOCAL的本地级别我们得让Spark集群与外部存储系统在物理上紧紧耦合在一起。这样Spark就可以用磁盘I/O替代网络开销获取数据否则本地级别就会退化到RACK_LOCAL或者ANY那网络开销就无法避免。
其次在数据处理阶段我们应当遵循“能省则省”的开发原则在适当的场景用Broadcast Joins来避免Shuffle引入的网络开销。如果确实没法避免Shuffle我们可以在计算中多使用Map端聚合减少需要在网络中分发的数据量。另外如果应用对于高可用的要求不高那我们应该尽量避免副本数量大于1的存储模式避免副本跨节点拷贝带来的额外开销。
最后在数据通过网络分发之前我们可以利用Kryo Serializer序列化器提升序列化字节的存储效率从而有效降低在网络中分发的数据量整体上减少网络开销。需要注意的为了充分利用Kryo Serializer序列化器的优势开发者需要明确注册自定义的数据类型否则效果可能适得其反。
## 每日一练
1. 对于文中Map端聚合的示例你知道和collect_list对应的Map端聚合算子是什么吗
1. 你还能想到哪些Map端聚合的计算场景
1. 对于不同的数据处理阶段,你还知道哪些降低网络开销的办法吗?
期待在留言区看到你的思考和答案,也欢迎你把这一讲转发出去,我们下一讲见!