mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 06:03:45 +08:00
mod
This commit is contained in:
230
极客时间专栏/Spark性能调优实战/通用性能调优篇/08 | 应用开发三原则:如何拓展自己的开发边界?.md
Normal file
230
极客时间专栏/Spark性能调优实战/通用性能调优篇/08 | 应用开发三原则:如何拓展自己的开发边界?.md
Normal 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。AQE(Adaptive 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("2020-01-01", "2020-01-02", "2020-01-03")
|
||||
val rootPath: String = _
|
||||
|
||||
//读取日志文件,去重、并展开userInterestList
|
||||
def createDF(rootPath: String, date: String): DataFrame = {
|
||||
val path: String = rootPath + date
|
||||
val df = spark.read.parquet(path)
|
||||
.distinct
|
||||
.withColumn("userInterest", explode(col("userInterestList")))
|
||||
df
|
||||
}
|
||||
|
||||
//提取字段、过滤,再次去重,把多天的结果用union合并
|
||||
val distinctItems: DataFrame = dates.map{
|
||||
case date: String =>
|
||||
val df: DataFrame = createDF(rootPath, date)
|
||||
.select("userId", "itemId", "userInterest", "accessFreq")
|
||||
.filter("accessFreq in ('High', 'Medium')")
|
||||
.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("2020-01-01", "2020-01-02", "2020-01-03")
|
||||
val rootPath: String = _
|
||||
|
||||
val filePaths: List[String] = dates.map(rootPath + _)
|
||||
|
||||
/**
|
||||
一次性调度所有文件
|
||||
先进行过滤和列剪枝
|
||||
然后再展开userInterestList
|
||||
最后统一去重
|
||||
*/
|
||||
val distinctItems = spark.read.parquet(filePaths: _*)
|
||||
.filter("accessFreq in ('High', 'Medium'))")
|
||||
.select("userId", "itemId", "userInterestList")
|
||||
.withColumn("userInterest", explode(col("userInterestList")))
|
||||
.select("userId", "itemId", "userInterest")
|
||||
.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("MD5")
|
||||
val sha256: MessageDigest = _ //其他哈希算法
|
||||
}
|
||||
|
||||
val df: DataFrame = _
|
||||
val ds: Dataset[Row] = df.map{
|
||||
case row: Row =>
|
||||
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("%02X".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 => {
|
||||
val util = new Util()
|
||||
val res = iterator.map{
|
||||
case row=>{
|
||||
val s: String = row.getString(0) + row.getString(1) + row.getString(2)
|
||||
val hashKey: String = util.md5.digest(s.getBytes).map("%02X".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个原则外,你觉得是否还有其他原则需要开发者特别留意?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
190
极客时间专栏/Spark性能调优实战/通用性能调优篇/09 | 调优一筹莫展,配置项速查手册让你事半功倍!(上).md
Normal file
190
极客时间专栏/Spark性能调优实战/通用性能调优篇/09 | 调优一筹莫展,配置项速查手册让你事半功倍!(上).md
Normal 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 API,Spark在运行时都会使用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) => (String) => Int = {
|
||||
(filePath) =>
|
||||
val source = Source.fromFile(filePath, "UTF-8")
|
||||
val lines = source.getLines().toArray
|
||||
source.close()
|
||||
val searchMap = lines.zip(0 until lines.size).toMap
|
||||
(interest) => searchMap.getOrElse(interest, -1)
|
||||
}
|
||||
val partFunc = findIndex(filePath)
|
||||
|
||||
//Dataset中的函数调用
|
||||
partFunc("体育-篮球-NBA-湖人")
|
||||
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,我们先读取包含用户兴趣的模板文件,然后根据模板内容构建兴趣到索引的映射字典。在对千亿样本做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用于执行分布式计算任务的内存空间自然就变少了,而且数据分析场景中常见的关联、排序和聚合等操作都会消耗执行内存,这部分内存空间变少,自然会影响到这类计算的执行效率。
|
||||
|
||||
其次,大量缓存引入的GC(Garbage 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参数?
|
||||
|
||||
期待在留言区看到你的思考和答案,也欢迎你把这份硬件资源配置项手册分享给更多的朋友,我们下一讲见!
|
||||
136
极客时间专栏/Spark性能调优实战/通用性能调优篇/10 |调优一筹莫展,配置项速查手册让你事半功倍!(下).md
Normal file
136
极客时间专栏/Spark性能调优实战/通用性能调优篇/10 |调优一筹莫展,配置项速查手册让你事半功倍!(下).md
Normal 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下面的配置项还是蛮多的,其中对执行性能贡献最大的,当属AQE(Adaptive 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过后数据大小为20GB,minPartitionNum设置为200,反推过来,每个分区的尺寸就是20GB / 200 = 100MB。再假设,advisoryPartitionSizeInBytes设置为200MB,最终的目标分区尺寸就是取(100MB,200MB)之间的最小值,也就是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。对于那些满足中位数条件的分区,必须要大于256MB,Saprk才会把这个分区最终判定为倾斜分区。假设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中数据倾斜的处理机制,你认为有哪些潜在的隐患?
|
||||
|
||||
期待在留言区看到你的思考和答案,也欢迎你把这份调优手册分享给你的朋友们,我们下一讲见!
|
||||
211
极客时间专栏/Spark性能调优实战/通用性能调优篇/11 | 为什么说Shuffle是一时无两的性能杀手?.md
Normal file
211
极客时间专栏/Spark性能调优实战/通用性能调优篇/11 | 为什么说Shuffle是一时无两的性能杀手?.md
Normal 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阶段的并行度保持一致。**换句话说,有多少个Task,Map阶段就会生产相应数量的数据文件和索引文件。
|
||||
|
||||
接下来,我带你用Spark来实现“仙女散花”的游戏,咱们一边做游戏,一边来分析Map阶段的中间文件是如何产生的。
|
||||
|
||||
### 用groupByKey实现“仙女散花”
|
||||
|
||||
在“仙女散花”的游戏中,黄老师要求大家把同一种花色的花朵**收集**到一起。那么,在Spark的分布式开发框架内,与这个游戏最相仿的计算过程非groupByKey莫属,所以我们不妨用groupByKey来实现游戏。
|
||||
|
||||
首先是flowers.txt文件:
|
||||
|
||||
```
|
||||
黄色花朵
|
||||
紫色花朵
|
||||
红色花朵
|
||||
橙色花朵
|
||||
青色花朵
|
||||
黄色花朵
|
||||
紫色花朵
|
||||
橙色花朵
|
||||
青色花朵
|
||||
......
|
||||
|
||||
|
||||
```
|
||||
|
||||
其次是同学小A接到需求后,用groupByKey实现“仙女散花”游戏的代码:
|
||||
|
||||
```
|
||||
val flowers = spark.sparkContext.textFile("flowers.txt")
|
||||
//黄老师给5个小同学分发花朵
|
||||
val flowersForKids = flowers.coalesce(5)
|
||||
val flowersKV = flowersForKids.map((_, 1))
|
||||
//黄小乙的两个步骤:大家先各自按颜色归类,然后再把归类后的花朵放到相应的课桌上
|
||||
flowersKV.groupByKey.collect
|
||||
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,代码步骤与游戏过程基本上一一对应。但是,读取完花朵文件之后,由于groupByKey是pairRDD算子,需要消费(Key,Value)形式的数据,因此我们需要对原始花朵数据做一次转换。以数据分区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中的数据记录按(目标分区ID,Key)进行排序,将所有数据溢出到临时文件,同时清空缓存;<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("flowers.txt")
|
||||
//黄老师给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大。因此,相比PartitionedPairBuffer,PartitionedAppendOnlyMap的存储效率要高得多,溢出数据到磁盘文件的频率也要低得多。
|
||||
|
||||
以此类推,最终合并的数据文件也会小很多。**依靠高效的内存数据结构、更少的磁盘文件、更小的文件尺寸,我们就能大幅降低了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. 当数据结构填满后,如果分片中还有未处理的数据记录,就对结构中的数据记录按(目标分区ID,Key)排序,将所有数据溢出到临时文件,同时清空数据结构;
|
||||
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配置项,你能把相关的配置项对号入座吗?
|
||||
|
||||
期待在留言区看到你的思考和讨论,我们下一讲见!
|
||||
154
极客时间专栏/Spark性能调优实战/通用性能调优篇/12 | 广播变量(一):克制Shuffle,如何一招制胜!.md
Normal file
154
极客时间专栏/Spark性能调优实战/通用性能调优篇/12 | 广播变量(一):克制Shuffle,如何一招制胜!.md
Normal 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 => 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 => 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把汇总好的全量数据分发给各个Executors,Executors将接收到的全量数据缓存到存储系统的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把汇总好的全量数据分发给各个Executors,Executors再将接收到的全量数据缓存到存储系统的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?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
@@ -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的两张表来说,任意一张表的尺寸小于10MB,Spark就在运行时采用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("t1")
|
||||
table2.createOrReplaceTempView("t2")
|
||||
|
||||
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内存会成为性能隐患。广播变量尺寸越大,额外引入的性能开销就会越多。更何况,如果广播变量大小超过8GB,Spark会直接抛异常中断任务执行。
|
||||
|
||||
**其次,从功能上来讲,并不是所有的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函数之间的区别是什么?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
137
极客时间专栏/Spark性能调优实战/通用性能调优篇/14 | CPU视角:如何高效地利用CPU?.md
Normal file
137
极客时间专栏/Spark性能调优实战/通用性能调优篇/14 | CPU视角:如何高效地利用CPU?.md
Normal 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/2,1/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数据结构,以(Key,Value)的方式来记录每个线程消耗的内存大小,并确保所有的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 Memory,M的取值也会跟着回撤。
|
||||
|
||||
另外,到目前为止,(1/N/2,1/N)上下限的计算我们用的都是线程总数N,线程总数N是固定的。N的取值含义是一个Executor内最大的并发度,更严格的计算公式是spark.executor.cores除以spark.task.cpus。但实际上,上下限公式的计算用的不是N,而是N~。N~的含义是Executor内当前的并发度,也就是Executor中当前并行执行的任务数。显然N~ <= N。
|
||||
|
||||
换句话说,尽管一个Executor中有N个CPU线程,但这N个线程不一定都在干活。在Spark任务调度的过程中,这N个线程不见得能同时拿到分布式任务,所以先拿到任务的线程就有机会申请到更多的内存。在某些极端的情况下,后拿到任务的线程甚至连一寸内存都申请不到。不过,随着任务执行和任务调度的推进,N~会迅速地趋近于N,CPU线程挂起和内存分配的情况也会逐渐得到改善。
|
||||
|
||||
就像黄小乙的补充条款中举的那个例子一样,当可耕种土地总面积为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利用率苦恼,欢迎你把这一讲转发给他,我们下一讲见!
|
||||
167
极客时间专栏/Spark性能调优实战/通用性能调优篇/15 | 内存视角(一):如何最大化内存的使用效率?.md
Normal file
167
极客时间专栏/Spark性能调优实战/通用性能调优篇/15 | 内存视角(一):如何最大化内存的使用效率?.md
Normal 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) => (String) => Int = {
|
||||
(filePath) =>
|
||||
val source = Source.fromFile(filePath, "UTF-8")
|
||||
val lines = source.getLines().toArray
|
||||
source.close()
|
||||
val searchMap = lines.zip(0 until lines.size).toMap
|
||||
(interest) => searchMap.getOrElse(interest, -1)
|
||||
}
|
||||
val partFunc = findIndex(filePath)
|
||||
|
||||
//Dataset中的函数调用
|
||||
partFunc("体育-篮球-NBA-湖人")
|
||||
|
||||
```
|
||||
|
||||
下面,咱们先一起回顾一下代码实现思路,再来分析它目前存在的性能隐患,最后去探讨优化它的方法。
|
||||
|
||||
首先,findIndex函数的主体逻辑比较简单,就是读取模板文件和构建Map映射,以及查找用户兴趣并返回索引。不过,findIndex函数被定义成了高阶函数。这样一来,当以模板文件为实参调用这个高阶函数的时候,我们会得到一个内置了Map查找字典的标量函数partFunc,最后在千亿样本上调用partFunc完成数据转换。**利用高阶函数,我们就避免了让Executor中的每一个Task去读取模板文件,以及从头构建Map字典这种执行低效的做法。**
|
||||
|
||||
在运行时,这个函数在Driver端会被封装到一个又一个的Task中去,随后Driver把这些Task分发到Executor,Executor接收到任务之后,交由线程池去执行(调度系统的内容可以回顾[第5讲)](https://time.geekbang.org/column/article/355028)。这个时候,每个Task就像是一架架小飞机,携带着代码“乘客”和数据“行李”,从Driver飞往Executor。Task小飞机在Executor机场着陆之后,代码“乘客”乘坐出租车或是机场大巴,去往JVM stack;数据“行李”则由专人堆放在JVM Heap,也就是我们常说的堆内内存。
|
||||
|
||||
回顾Label encoding中的findIndex函数不难发现,其中大部分都是代码“乘客”,唯一的数据“行李”是名为searchMap的Map字典。像这样用户自定义的数据结构,消耗的内存区域就是堆内内存的User Memory(Spark对内存区域的划分内容可以回顾一下[第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小飞机,都无需亲自携带数据“行李”,这些大件行李会由“联邦广播快递公司”派货机专门发往各个Executors,Driver和每个Executors之间,都有一班这样的货运专线。思路说完了,优化后的代码如下所示。
|
||||
|
||||
```
|
||||
/**
|
||||
广播变量实现方式
|
||||
*/
|
||||
//定义广播变量
|
||||
val source = Source.fromFile(filePath, "UTF-8")
|
||||
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("体育-篮球-NBA-湖人", -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. 对于内存规划的第一步来说,要精确地预估运行时每一个区域的内存消耗,很费时、费力,调优的成本很高。如果我们想省略掉第一步的精确计算,你知道有哪些方法能够粗略、快速地预估不同内存区域的消耗占比吗?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
190
极客时间专栏/Spark性能调优实战/通用性能调优篇/16 | 内存视角(二):如何有效避免Cache滥用?.md
Normal file
190
极客时间专栏/Spark性能调优实战/通用性能调优篇/16 | 内存视角(二):如何有效避免Cache滥用?.md
Normal 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是根据什么规则选中的这些倒霉蛋呢?这个规则叫作LRU(Least Recently Used),基于这个算法,最近访问频率最低的那个家伙就是倒霉蛋。因为[LRU](https://baike.baidu.com/item/LRU/1269842?fr=aladdin)是比较基础的数据结构算法,笔试、面试的时候经常会考,所以它的概念我就不多说了。
|
||||
|
||||
我们要知道的是,Spark是如何实现LRU的。这里,**Spark使用了一个巧妙的数据结构:LinkedHashMap,这种数据结构天然地支持LRU算法**。
|
||||
|
||||
LinkedHashMap使用两个数据结构来维护数据,一个是传统的HashMap,另一个是双向链表。HashMap的用途在于快速访问,根据指定的BlockId,HashMap以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_ONLY,MEMORY_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 > 0).select(col1, col2)
|
||||
cachedDF.select(col1, col2).filter(col2 > 100)
|
||||
|
||||
//Cache方式二
|
||||
df.select(col1, col2).filter(col2 > 0).cache
|
||||
//数据分析
|
||||
df.filter(col2 > 0).select(col1, col2)
|
||||
df.select(col1, col2).filter(col2 > 100)
|
||||
|
||||
//Cache方式三
|
||||
val cachedDF = df.select(col1, col2).cache
|
||||
//数据分析
|
||||
cachedDF.filter(col2 > 0).select(col1, col2)
|
||||
cachedDF.select(col1, col2).filter(col2 > 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而困扰,也欢迎你把这一讲转发给他。我们下一讲见!
|
||||
173
极客时间专栏/Spark性能调优实战/通用性能调优篇/17 | 内存视角(三):OOM都是谁的锅?怎么破?.md
Normal file
173
极客时间专栏/Spark性能调优实战/通用性能调优篇/17 | 内存视角(三):OOM都是谁的锅?怎么破?.md
Normal 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作业中,我们总能见到OOM(Out Of Memory,内存溢出)的身影。一旦出现OOM,作业就会中断,应用的业务功能也都无法执行。因此,及时处理OOM问题是我们日常开发中一项非常重要的工作。
|
||||
|
||||
但是,Spark报出的OOM问题可以说是五花八门,常常让人找不到头绪。比如,我们经常遇到,数据集按照尺寸估算本该可以完全放进内存,但Spark依然会报OOM异常。这个时候,不少同学都会参考网上的做法,把spark.executor.memory不断地调大、调大、再调大,直到内心崩溃也无济于事,最后只能放弃。
|
||||
|
||||
那么,当我们拿到OOM这个“烫手的山芋”的时候该怎么办呢?我们最先应该弄清楚的是“**到底哪里出现了OOM**”。只有准确定位出现问题的具体区域,我们的调优才能有的放矢。具体来说,这个“**哪里**”,我们至少要分3个方面去看。
|
||||
|
||||
- 发生OOM的LOC(Line 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来说,即便数据集不能完全缓存到MemoryStore,Spark也不会抛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 => dict.contains(word))
|
||||
keywords.map((_, 1)).reduceByKey(_ + _).collect
|
||||
|
||||
```
|
||||
|
||||
自定义的列表dict会随着Task分发到所有Executors,因此多个Task中的dict会对User Memory产生重复消耗。如果把dict尺寸记为#size,Executor线程池大小记为#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的内存配置。不过,相比Driver,User 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设置为3,spark.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表象?你能把它们归纳到我们今天讲的分类中吗?
|
||||
|
||||
期待在留言区看到你的思考和分享,我们下一讲见!
|
||||
140
极客时间专栏/Spark性能调优实战/通用性能调优篇/18 | 磁盘视角:如果内存无限大,磁盘还有用武之地吗?.md
Normal file
140
极客时间专栏/Spark性能调优实战/通用性能调优篇/18 | 磁盘视角:如果内存无限大,磁盘还有用武之地吗?.md
Normal 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能起到的作用完全等价。
|
||||
|
||||
咱们来举个例子。现在有这样一个业务需求:给定用户访问日志,分别统计不同用户的PV(Page Views,页面浏览量)、UV(Unique 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("userId").agg(count("page").alias("value")).withColumn("metrics", lit("PV"))
|
||||
val dfUV: DataFrame = df.groupBy("userId").agg(countDistinct("page").alias("value")).withColumn("metrics ", lit("UV"))
|
||||
|
||||
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($"userId")
|
||||
|
||||
val dfPV: DataFrame = df.groupBy("userId").agg(count("page").alias("value")).withColumn("metrics", lit("PV"))
|
||||
val dfUV: DataFrame = df.groupBy("userId").agg(countDistinct("page").alias("value")).withColumn("metrics ", lit("UV"))
|
||||
|
||||
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机制?为什么?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
121
极客时间专栏/Spark性能调优实战/通用性能调优篇/19 | 网络视角:如何有效降低网络开销?.md
Normal file
121
极客时间专栏/Spark性能调优实战/通用性能调优篇/19 | 网络视角:如何有效降低网络开销?.md
Normal 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:任务与数据是跨机架、甚至是跨DC(Data 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端聚合的思想并不过时。为什么这么说呢?下面,我通过一个小例子来你详细讲一讲。
|
||||
|
||||
在绝大多数2C(To 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. 对于不同的数据处理阶段,你还知道哪些降低网络开销的办法吗?
|
||||
|
||||
期待在留言区看到你的思考和答案,也欢迎你把这一讲转发出去,我们下一讲见!
|
||||
Reference in New Issue
Block a user