first commit
This commit is contained in:
104
专栏/Spark性能调优实战/00开篇词Spark性能调优,你该掌握这些“套路”.md
Normal file
104
专栏/Spark性能调优实战/00开篇词Spark性能调优,你该掌握这些“套路”.md
Normal file
@ -0,0 +1,104 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 Spark性能调优,你该掌握这些“套路”
|
||||
你好,我是吴磊,欢迎和我一起探索 Spark 应用的性能优化。
|
||||
|
||||
2020年6月,Spark正式发布了新版本,从2.4直接跨越到了3.0。这次大版本升级的亮点就在于性能优化,它添加了诸如自适应查询执行(AQE)、动态分区剪裁(DPP)、扩展的Join Hints等新特性。
|
||||
|
||||
其实,在3.0版本发布之前,Spark就已然成为了分布式数据处理技术的事实标准。在数据科学与机器学习魔力象限中,Gartner更是连续3年(2018~2020)将Databricks(Spark云原生商业版本)提名为Market Leader。
|
||||
|
||||
|
||||
|
||||
自然而然地,Spark也成为了各大头部互联网公司的标配,在海量数据处理上,扮演着不可或缺的关键角色。比如,字节跳动基于Spark构建的数据仓库去服务几乎所有的产品线,包括抖音、今日头条、西瓜视频、火山视频等。再比如,百度基于Spark推出BigSQL,为海量用户提供次秒级的即席查询。
|
||||
|
||||
可以预见的是,这次版本升级带来的新特性,会让Spark在未来5到10年继续雄霸大数据生态圈。
|
||||
|
||||
而说到我和Spark的缘分,可以追溯到2014年。一次偶然的机会,我参与到了Spark的调研。深入研究之后,我对Spark高效的执行性能深深着迷,这也让我未来的职业发展都和Spark紧紧地绑在了一起。就这样,我从数据分析、数据挖掘领域,转移到了现在的商业智能和机器学习领域,一直不停地尝试探索数据中蕴含的核心价值。
|
||||
|
||||
目前,我在FreeWheel带领团队负责机器学习的应用与落地。我们所有已落地和正在启动的项目,都在使用Spark进行数据探索、数据处理、数据分析、特征工程和样本工程。除此之外,我们还会经常基于Spark在海量数据上进行模型训练和模型推理。
|
||||
|
||||
如果你也和我一样,正沿着数据规划自己的职业路径,那么精通Spark也必定是你的职业目标之一。
|
||||
|
||||
精通Spark,你需要一把叫做“性能调优”的万能钥匙
|
||||
|
||||
目前,Spark有海量批处理、实时流计算、图计算、数据分析和机器学习这5大应用场景,不论你打算朝哪个方向深入,性能调优都是你职业进阶必须要跨越的一步。
|
||||
|
||||
|
||||
|
||||
为什么这么说呢?原因很简单,对于这5大场景来说,提升执行性能是刚需。
|
||||
|
||||
图计算和机器学习往往需要上百次迭代才能收敛,如果没有性能保障,这类作业不可能完成计算。流计算和数据分析对于响应实时性的要求非常高,没有高效的执行性能,不可能做到在亚秒级完成处理。
|
||||
|
||||
相比其他场景,批处理对于执行效率的要求是最低的,但是,在日增数据量以TB、甚至PB为单位计数的当下,想要在小时级别完成海量数据处理,不做性能调优简直是天方夜谭。
|
||||
|
||||
因此,我认为这5大场景就像是5扇门,每扇门背后都别有洞天,而性能调优就像是一把“万能钥匙”。有了这把钥匙在手,你才能如入无人之境,去探索更广阔的世界。
|
||||
|
||||
为什么性能调优不能“照葫芦画瓢”?
|
||||
|
||||
其实,我身边很多开发人员也都意识到了这一点,他们会去网上搜集一些教程进行学习。但是,目前关于Spark性能调优的资料,大都不是很系统,或者只是在讲一些常规的调优技巧和方法。而对于一些大神分享的调优手段,我们往往“照葫芦画瓢”做出来的东西,也总是达不到预期的效果,比如:
|
||||
|
||||
|
||||
明明都是内存计算,为什么我用了RDD/DataFrame Cache,性能反而更差了?
|
||||
网上吹得神乎其神的调优手段,为啥到了我这就不好使呢?
|
||||
并行度设置得也不低,为啥我的CPU利用率还是上不去?
|
||||
节点内存几乎全都划给Spark用了,为啥我的应用还是OOM?
|
||||
|
||||
|
||||
这些问题看似简单,但真不是一两句话就能说得清的。这需要我们深入Spark的核心原理,不断去尝试每一个API、算子,设置不同的配置参数,最终找出最佳的排列组合。
|
||||
|
||||
那么问题来了,这该怎么做呢?接下来,我就和你分享一下,我是怎么学习性能调优的。
|
||||
|
||||
刚刚接触Spark那会儿,我觉得它的开发效率是真高啊!MapReduce上千行代码才能实现的业务功能,Spark几十行代码就搞定了!
|
||||
|
||||
后来,随着客户要求的不断提高,以及我个人的“较真行为”,为了让应用能跑得更快,我几乎把所有的RDD API查了个遍,仔细研究每一个算子的含义和运行原理,汇总不同算子的适用场景,总结哪些算子会引入Shuffle,对比同类功能算子的差异与优劣势,比如map和mapPartitions,再比如groupByKey、reduceByKey和aggregateByKey。
|
||||
|
||||
除此之外,Spark官网的Configuration页面我也查阅了无数次,汇总与性能有关的配置项,不停地做对比实验,比较不同参数配置下的执行性能。遇到与认知不符的实验结果,就再回去反复咀嚼Spark的核心原理,从RDD和调度系统,到内存管理和存储系统,再到内存计算和Shuffle,如此往复,乐此不疲。
|
||||
|
||||
虽然失败的次数非常多,但成倍的性能提升带来的惊喜让我久久不能忘怀。后来,我就把我的经验分享给身边的同事,在帮助他们进行调优的过程中,我也有意识地把我接触到的案例整理了起来,从点到线、从线到面,我逐渐摸清了性能调优的脉络,最终总结出一套关于性能调优的方法论,也因此建立起了以性能为导向的开发习惯。
|
||||
|
||||
|
||||
|
||||
遵循这套方法论,开发者可以按图索骥地去开展性能调优工作,做到有的放矢、事半功倍。我希望在这个专栏里把它分享给你。
|
||||
|
||||
学得快,也要学得好
|
||||
|
||||
结合方法论,我把专栏划分为了3个部分:原理篇、性能篇和实战篇。
|
||||
|
||||
原理篇:聚焦Spark底层原理,打通性能调优的任督二脉
|
||||
|
||||
Spark的原理非常多,但我会聚焦于那些与性能调优息息相关的核心概念,包括RDD、DAG、调度系统、存储系统和内存管理。我会力求用最贴切的故事和类比、最少的篇幅,让你在最短的时间内掌握这5大概念的核心原理,为后续的性能调优打下坚实的基础。
|
||||
|
||||
性能篇:实际案例驱动,多角度解读,全方位解析性能调优技巧
|
||||
|
||||
我们刚才说了,Spark的应用场景非常多,主要分为海量批处理、实时流计算、图计算、数据分析和机器学习这5个。但在所有的子框架里,Spark对Spark SQL的倾斜和倚重也是有目共睹,所以性能篇我主要分两部分来讲。
|
||||
|
||||
一部分是讲解性能调优的通用技巧,包括应用开发的基本原则、配置项的设置、Shuffle的优化,以及资源利用率的提升。首先,我会从常见的例子入手,教你怎么在不改变代码逻辑的情况下快速提升执行能。其次,我会带你去归纳与执行效率相关的配置项。接着,针对Shuffle、数据关联这些典型场景,我们一起去分析有效的应对策略。最后,我们再从硬件视角出发,带你探讨如何最大化资源利用率,在整体上提升Spark的执行性能。
|
||||
|
||||
虽然,不同应用场景的开发API和运行原理都有所不同,但是性能调优的本质和方法论是一样的。因此,这类技巧不限定应用场景,适用于所有Spark子框架。
|
||||
|
||||
另一部分我会专注于数据分析领域,借助如Tungsten、AQE这样的Spark内置优化项和数据关联这样的典型场景,来和你聊聊Spark SQL中的调优方法和技巧。
|
||||
|
||||
首先,我会带你深入挖掘Tungsten、Catalyst优化器和Spark 3.0发布的诸多新特性,充分利用Spark已有的优化机制,让性能调优站到一个更高的起点上。接着,我会借助数据分析的典型场景案例,如数据清洗、数据关联、数据转换等等,带你case-by-case地去归纳调优的思路与方法。
|
||||
|
||||
值得一提的是,随着所有子框架的开发API陆续迁移到DataFrame,我们在每个子框架之上开发的应用,都将受益于Spark SQL的性能提升。换句话说,尽管这部分调优技巧围绕数据分析领域展开,但其中的思路和方法,也同样适用于其他子框架。
|
||||
|
||||
实战篇:打造属于自己的分布式应用
|
||||
|
||||
在实战篇,为了帮助你实践我们的方法论和调优技巧。我会以2011~2019的《北京市汽油车摇号》数据为例,手把手教你打造一个分布式应用,带你从不同角度洞察汽油车摇号的趋势和走向。我相信,通过这个实战案例,你对性能调优技巧和思路的把控肯定会有一个“质的飞跃”。
|
||||
|
||||
除此之外,我还会不定期地针对一些热点话题进行加餐:比如和Flink、Presto相比,Spark有哪些优势;再比如Spark的一些新特性,以及业界对于Spark的新探索。这也能帮助我们更好地面对变化,把握先机。
|
||||
|
||||
|
||||
|
||||
最后我想说,我一直希望把学习变成一件有趣又轻松的事情,所以在这个专栏里,我会用一个个小故事和实例,来帮助你理解Spark的核心原理,引导你建立以性能为导向的开发思维,以及从不同视角汇总性能调优的方法和技巧,让你像读小说一样去弄懂Spark。
|
||||
|
||||
我也期望,你能像小说里的主人公一样,利用“调优技巧和方法论”这本武功秘籍,一路过五关、斩六将,打败现实中层出不穷的开发问题,在职业发展中更上一层楼。
|
||||
|
||||
最后,欢迎你在这里畅所欲言,提出你的困惑和疑问,也欢迎多多给我留言,你们的鼓励是我的动力。让我们一起拿起性能调优这把万能钥匙,去开启全新的Spark职业生涯吧!
|
||||
|
||||
|
||||
|
||||
|
155
专栏/Spark性能调优实战/01性能调优的必要性:Spark本身就很快,为啥还需要我调优?.md
Normal file
155
专栏/Spark性能调优实战/01性能调优的必要性:Spark本身就很快,为啥还需要我调优?.md
Normal file
@ -0,0 +1,155 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 性能调优的必要性:Spark本身就很快,为啥还需要我调优?
|
||||
你好,我是吴磊。
|
||||
|
||||
在日常的开发工作中,我发现有个现象很普遍。很多开发者都认为Spark的执行性能已经非常强了,实际工作中只要按部就班地实现业务功能就可以了,没有必要进行性能调优。
|
||||
|
||||
你是不是也这么认为呢?确实,Spark的核心竞争力就是它的执行性能,这主要得益于Spark基于内存计算的运行模式和钨丝计划的锦上添花,以及Spark SQL上的专注与发力。
|
||||
|
||||
但是,真如大家所说,开发者只要把业务逻辑实现了就万事大吉了吗?这样,咱们先不急于得出结论,你先跟着我一起看两个日常开发中常见的例子,最后我们再来回答这个问题。
|
||||
|
||||
在数据应用场景中,ETL(Extract Transform Load)往往是打头阵的那个,毕竟源数据经过抽取和转换才能用于探索和分析,或者是供养给机器学习算法进行模型训练,从而挖掘出数据深层次的价值。我们今天要举的两个例子,都取自典型ETL端到端作业中常见的操作和计算任务。
|
||||
|
||||
开发案例1:数据抽取
|
||||
|
||||
第一个例子很简单:给定数据条目,从中抽取特定字段。这样的数据处理需求在平时的ETL作业中相当普遍。想要实现这个需求,我们需要定义一个函数extractFields:它的输入参数是Seq[Row]类型,也即数据条目序列;输出结果的返回类型是Seq[(String, Int)],也就是(String, Int)对儿的序列;函数的计算逻辑是从数据条目中抽取索引为2的字符串和索引为4的整型。
|
||||
|
||||
应该说这个业务需求相当简单明了,实现起来简直是小菜一碟。在实际开发中,我观察到有不少同学一上来就迅速地用下面的方式去实现,干脆利落,代码写得挺快,功能也没问题,UT、功能测试都能过。
|
||||
|
||||
//实现方案1 —— 反例
|
||||
val extractFields: Seq[Row] => Seq[(String, Int)] = {
|
||||
(rows: Seq[Row]) => {
|
||||
var fields = Seq[(String, Int)]()
|
||||
rows.map(row => {
|
||||
fields = fields :+ (row.getString(2), row.getInt(4))
|
||||
})
|
||||
fields
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在上面这个函数体中,是先定义一个类型是Seq[(String, Int)]的变量fields,变量类型和函数返回类型完全一致。然后,函数逐个遍历输入参数中的数据条目,抽取数据条目中索引是2和4的字段并且构建二元元组,紧接着把元组追加到最初定义的变量fields中。最后,函数返回类型是Seq[(String, Int)]的变量fields。
|
||||
|
||||
乍看上去,这个函数似乎没什么问题。特殊的地方在于,尽管这个数据抽取函数很小,在复杂的ETL应用里是非常微小的一环,但在整个ETL作业中,它会在不同地方被频繁地反复调用。如果我基于这份代码把整个ETL应用推上线,就会发现ETL作业端到端的执行效率非常差,在分布式环境下完成作业需要两个小时,这样的速度难免有点让人沮丧。
|
||||
|
||||
想要让ETL作业跑得更快,我们自然需要做性能调优。可问题是我们该从哪儿入手呢?既然extractFields这个小函数会被频繁地调用,不如我们从它下手好了,看看有没有可能给它“减个肥、瘦个身”。重新审视函数extractFields的类型之后,我们不难发现,这个函数从头到尾无非是从Seq[Row]到Seq[(String, Int)]的转换,函数体的核心逻辑就是字段提取,只要从Seq[Row]可以得到Seq[(String, Int)],目的就达到了。
|
||||
|
||||
要达成这两种数据类型之间的转换,除了利用上面这种开发者信手拈来的过程式编程,我们还可以用函数式的编程范式。函数式编程的原则之一就是尽可能地在函数体中避免副作用(Side effect),副作用指的是函数对于状态的修改和变更,比如上例中extractFields函数对于fields变量不停地执行追加操作就属于副作用。
|
||||
|
||||
基于这个想法,我们就有了第二种实现方式,如下所示。与第一种实现相比,它最大的区别在于去掉了fields变量。之后,为了达到同样的效果,我们在输入参数Seq[Row]上直接调用map操作逐一地提取特定字段并构建元组,最后通过toSeq将映射转换为序列,干净利落,一气呵成。
|
||||
|
||||
//实现方案2 —— 正例
|
||||
val extractFields: Seq[Row] => Seq[(String, Int)] = {
|
||||
(rows: Seq[Row]) =>
|
||||
rows.map(row => (row.getString(2), row.getInt(4))).toSeq
|
||||
}
|
||||
|
||||
|
||||
|
||||
你可能会问:“两份代码实现无非是差了个中间变量而已,能有多大差别呢?看上去不过是代码更简洁了而已。”事实上,我基于第二份代码把ETL作业推上线后,就惊奇地发现端到端执行性能提升了一倍!从原来的两个小时缩短到一个小时。两份功能完全一样的代码,在分布式环境中的执行性能竟然有着成倍的差别。因此你看,在日常的开发工作中,仅仅专注于业务功能实现还是不够的,任何一个可以进行调优的小环节咱们都不能放过。
|
||||
|
||||
开发案例2:数据过滤与数据聚合
|
||||
|
||||
你也许会说:“你这个例子只是个例吧?更何况,这个例子里的优化,仅仅是编程范式的调整,看上去和Spark似乎也没什么关系啊!”不要紧,我们再来看第二个例子。第二个例子会稍微复杂一些,我们先来把业务需求和数据关系交代清楚。
|
||||
|
||||
/**
|
||||
(startDate, endDate)
|
||||
e.g. ("2021-01-01", "2021-01-31")
|
||||
*/
|
||||
val pairDF: DataFrame = _
|
||||
|
||||
/**
|
||||
(dim1, dim2, dim3, eventDate, value)
|
||||
e.g. ("X", "Y", "Z", "2021-01-15", 12)
|
||||
*/
|
||||
val factDF: DataFrame = _
|
||||
|
||||
// Storage root path
|
||||
val rootPath: String = _
|
||||
|
||||
|
||||
|
||||
在这个案例中,我们有两份数据,分别是pairDF和factDF,数据类型都是DataFrame。第一份数据pairDF的Schema包含两个字段,分别是开始日期和结束日期。第二份数据的字段较多,不过最主要的字段就两个,一个是Event date事件日期,另一个是业务关心的统计量,取名为Value。其他维度如dim1、dim2、dim3主要用于数据分组,具体含义并不重要。从数据量来看,pairDF的数据量很小,大概几百条记录,factDF数据量很大,有上千万行。
|
||||
|
||||
对于这两份数据来说,具体的业务需求可以拆成3步:
|
||||
|
||||
|
||||
对于pairDF中的每一组时间对,从factDF中过滤出Event date落在其间的数据条目;
|
||||
从dim1、dim2、dim3和Event date 4个维度对factDF分组,再对业务统计量Value进行汇总;
|
||||
将最终的统计结果落盘到Amazon S3。
|
||||
|
||||
|
||||
针对这样的业务需求,不少同学按照上面的步骤按部就班地进行了如下的实现。接下来,我就结合具体的代码来和你说说其中的计算逻辑。
|
||||
|
||||
//实现方案1 —— 反例
|
||||
def createInstance(factDF: DataFrame, startDate: String, endDate: String): DataFrame = {
|
||||
val instanceDF = factDF
|
||||
.filter(col("eventDate") > lit(startDate) && col("eventDate") <= lit(endDate))
|
||||
.groupBy("dim1", "dim2", "dim3", "event_date")
|
||||
.agg(sum("value") as "sum_value")
|
||||
instanceDF
|
||||
}
|
||||
|
||||
pairDF.collect.foreach{
|
||||
case (startDate: String, endDate: String) =>
|
||||
val instance = createInstance(factDF, startDate, endDate)
|
||||
val outPath = s"${rootPath}/endDate=${endDate}/startDate=${startDate}"
|
||||
instance.write.parquet(outPath)
|
||||
}
|
||||
|
||||
|
||||
首先,他们是以factDF、开始时间和结束时间为形参定义createInstance函数。在函数体中,先根据Event date对factDF进行过滤,然后从4个维度分组汇总统计量,最后将汇总结果返回。定义完createInstance函数之后,收集pairDF到Driver端并逐条遍历每一个时间对,然后以factDF、开始时间、结束时间为实参调用createInstance函数,来获取满足过滤要求的汇总结果。最后,以Parquet的形式将结果落盘。
|
||||
|
||||
同样地,这段代码从功能的角度来说没有任何问题,而且从线上的结果来看,数据的处理逻辑也完全符合预期。不过,端到端的执行性能可以说是惨不忍睹,在16台机型为C5.4xlarge AWS EC2的分布式运行环境中,基于上面这份代码的ETL作业花费了半个小时才执行完毕。
|
||||
|
||||
没有对比就没有伤害,在同一份数据集之上,采用下面的第二种实现方式,仅用2台同样机型的EC2就能让ETL作业在15分钟以内完成端到端的计算任务。两份代码的业务功能和计算逻辑完全一致,执行性能却差了十万八千里。
|
||||
|
||||
//实现方案2 —— 正例
|
||||
val instances = factDF
|
||||
.join(pairDF, factDF("eventDate") > pairDF("startDate") && factDF("eventDate") <= pairDF("endDate"))
|
||||
.groupBy("dim1", "dim2", "dim3", "eventDate", "startDate", "endDate")
|
||||
.agg(sum("value") as "sum_value")
|
||||
|
||||
instances.write.partitionBy("endDate", "startDate").parquet(rootPath)
|
||||
|
||||
|
||||
那么问题来了,这两份代码到底差在哪里,是什么导致它们的执行性能差别如此之大。我们不妨先来回顾第一种实现方式,嗅一嗅这里面有哪些不好的代码味道。
|
||||
|
||||
我们都知道,触发Spark延迟计算的Actions算子主要有两类:一类是将分布式计算结果直接落盘的操作,如DataFrame的write、RDD的saveAsTextFile等;另一类是将分布式结果收集到Driver端的操作,如first、take、collect。
|
||||
|
||||
显然,对于第二类算子来说,Driver有可能形成单点瓶颈,尤其是用collect算子去全量收集较大的结果集时,更容易出现性能问题。因此,在第一种实现方式中,我们很容易就能嗅到collect这里的调用,味道很差。
|
||||
|
||||
尽管collect这里味道不好,但在我们的场景里,pairDF毕竟是一份很小的数据集,才几百条数据记录而已,全量搜集到Driver端也不是什么大问题。
|
||||
|
||||
最要命的是collect后面的foreach。要知道,factDF是一份庞大的分布式数据集,尽管createInstance的逻辑仅仅是对factDF进行过滤、汇总并落盘,但是createInstance函数在foreach中会被调用几百次,pairDF中有多少个时间对,createInstance就会被调用多少次。对于Spark中的DAG来说,在没有缓存的情况下,每一次Action的触发都会导致整条DAG从头到尾重新执行。
|
||||
|
||||
明白了这一点之后,我们再来仔细观察这份代码,你品、你细品,目不转睛地盯着foreach和createInstance中的factDF,你会惊讶地发现:有着上千万行数据的factDF被反复扫描了几百次!而且,是全量扫描哟!吓不吓人?可不可怕?这么分析下来,ETL作业端到端执行效率低下的始作俑者,是不是就暴露无遗了?
|
||||
|
||||
反观第二份代码,factDF和pairDF用pairDF.startDate < factDF.eventDate <= pairDF.endDate的不等式条件进行数据关联。在Spark中,不等式Join的实现方式是Nested Loop Join。尽管Nested Loop Join是所有Join实现方式(Merge Join,Hash Join,Broadcast Join等)中性能最差的一种,而且这种Join方式没有任何优化空间,但factDF与pairDF的数据关联只需要扫描一次全量数据,仅这一项优势在执行效率上就可以吊打第一份代码实现。
|
||||
|
||||
小结
|
||||
|
||||
今天,我们分析了两个案例,这两个案例都来自数据应用的ETL场景。第一个案例讲的是,在函数被频繁调用的情况下,函数里面一个简单变量所引入的性能开销被成倍地放大。第二个例子讲的是,不恰当的实现方式导致海量数据被反复地扫描成百上千次。
|
||||
|
||||
通过对这两个案例进行分析和探讨,我们发现,对于Spark的应用开发,绝不仅仅是完成业务功能实现就高枕无忧了。Spark天生的执行效率再高,也需要你针对具体的应用场景和运行环境进行性能调优。
|
||||
|
||||
而性能调优的收益显而易见:一来可以节约成本,尤其是按需付费的云上成本,更短的执行时间意味着更少的花销;二来可以提升开发的迭代效率,尤其是对于从事数据分析、数据科学、机器学习的同学来说,更高的执行效率可以更快地获取数据洞察,更快地找到模型收敛的最优解。因此你看,性能调优不是一件锦上添花的事情,而是开发者必须要掌握的一项傍身技能。
|
||||
|
||||
那么,对于Spark的性能调优,你准备好了吗?生活不止眼前的苟且,让我们来一场说走就走的性能调优之旅吧。来吧!快上车!扶稳坐好,系好安全带,咱们准备发车了!
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
日常工作中,你还遇到过哪些功能实现一致、但性能大相径庭的案例吗?
|
||||
我们今天讲的第二个案例中的正例代码,你觉得还有可能进一步优化吗?
|
||||
|
||||
|
||||
期待在留言区看到你分享,也欢迎把你对开发案例的思考写下来,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
128
专栏/Spark性能调优实战/02性能调优的本质:调优的手段五花八门,该从哪里入手?.md
Normal file
128
专栏/Spark性能调优实战/02性能调优的本质:调优的手段五花八门,该从哪里入手?.md
Normal file
@ -0,0 +1,128 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 性能调优的本质:调优的手段五花八门,该从哪里入手?
|
||||
你好,我是吴磊。
|
||||
|
||||
上节课,我们探讨了性能调优的必要性,结论是:尽管Spark自身运行高效,但作为开发者,我们仍然需要对应用进行性能调优。
|
||||
|
||||
那么问题来了,性能调优该怎么做呢?面对成百上千行应用代码、近百个Spark配置项,我们该从哪里入手呢?我认为,要想弄清性能调优怎么入手,必须先得搞明白性能调优的本质是什么。
|
||||
|
||||
所以今天这节课,咱们就从一个先入为主的调优反例入手,带你一起探讨并归纳性能调优的本质是什么,最终帮你建立起系统化的性能调优方法论。
|
||||
|
||||
先入为主的反例
|
||||
|
||||
在典型的ETL场景中,我们经常需要对数据进行各式各样的转换,有的时候,因为业务需求太复杂,我们往往还需要自定义UDF(User Defined Functions)来实现特定的转换逻辑。但是,无论是Databricks的官方博客,还是网上浩如烟海的Spark技术文章,都警告我们尽量不要自定义UDF来实现业务逻辑,要尽可能地使用Spark内置的SQL functions。
|
||||
|
||||
在日常的工作中,我发现这些警告被反复地用于Code review中,Code reviewer在审查代码的时候,一旦遇到自定义的UDF,就提示开发的同学用SQL functions去重写业务逻辑,这几乎成了一种条件反射。
|
||||
|
||||
甚至,开发的同学也觉得非常有道理。于是,他们花费大量时间用SQL functions重构业务代码。但遗憾的是,这么做之后ETL作业端到端的执行性能并没有什么显著的提升。这种情况就是所谓的投入时间与产出不成正比的窘境:调优的时间没少花,却没啥效果。
|
||||
|
||||
之所以会出现这种情况,我觉得主要原因在于Code reviewer对于性能调优的理解还停留在照本宣科的层次,没有形成系统化的方法论。要建立系统化的方法论,我们就必须去探究性能调优的本质到底是什么。否则,开发者就像是掉进迷宫的小仓鼠,斗志昂扬地横冲直撞,但就是找不到出口。
|
||||
|
||||
既然性能调优的本质这么重要,那么它到底是什么呢?
|
||||
|
||||
性能调优的本质
|
||||
|
||||
探究任何一个事物的本质,都离不开从个例观察、现象归因到总结归纳的过程。
|
||||
|
||||
咱们不妨先来分析分析上面的反例,为什么用SQL functions重构UDF并没有像书本上说的那么奏效?
|
||||
|
||||
是因为这条建议本身就不对吗?肯定不是。通过对比查询计划,我们能够明显看到UDF与SQL functions的区别。Spark SQL的Catalyst Optimizer能够明确感知SQL functions每一步在做什么,因此有足够的优化空间。相反,UDF里面封装的计算逻辑对于Catalyst Optimizer来说就是个黑盒子,除了把UDF塞到闭包里面去,也没什么其他工作可做的。
|
||||
|
||||
那么,是因为UDF相比SQL functions其实并没有性能开销吗?也不是。我们可以做个性能单元测试,从你的ETL应用中随意挑出一个自定义的UDF,尝试用SQL functions去重写,然后准备单元测试数据,最后在单机环境下对比两种不同实现的运行时间。通常情况下,UDF实现相比SQL functions会慢3%到5%不等。所以你看,UDF的性能开销还是有的。
|
||||
|
||||
既然如此,我们在使用SQL functions优化UDF的时候,为什么没有显著提升端到端ETL应用的整体性能呢?
|
||||
|
||||
根据木桶理论,最短的木板决定了木桶的容量,因此,对于一只有短板的木桶,其他木板调节得再高也无济于事,最短的木板才是木桶容量的瓶颈。对于ETL应用这支木桶来说,UDF到SQL functions的调优之所以对执行性能的影响微乎其微,根本原因在于它不是那块最短的木板。换句话说,ETL应用端到端执行性能的瓶颈不是开发者自定义的UDF。
|
||||
|
||||
结合上面的分析,性能调优的本质我们可以归纳为4点。
|
||||
|
||||
|
||||
性能调优不是一锤子买卖,补齐一个短板,其他板子可能会成为新的短板。因此,它是一个动态、持续不断的过程。
|
||||
性能调优的手段和方法是否高效,取决于它针对的是木桶的长板还是瓶颈。针对瓶颈,事半功倍;针对长板,事倍功半。
|
||||
性能调优的方法和技巧,没有一定之规,也不是一成不变,随着木桶短板的此消彼长需要相应的动态切换。
|
||||
性能调优的过程收敛于一种所有木板齐平、没有瓶颈的状态。
|
||||
|
||||
|
||||
基于对性能调优本质的理解,我们就能很好地解释日常工作中一些常见的现象。比如,我们经常发现,明明是同样一种调优方法,在你那儿好使,在我这儿却不好使。再比如,从网上看到某位Spark大神呕心沥血总结的调优心得,你拿过来一条一条地试,却发现效果并没有博客中说的那么显著。这也并不意味着那位大神的最佳实践都是空谈,更可能是他总结的那些点并没有触达到你的瓶颈。
|
||||
|
||||
定位性能瓶颈的途径有哪些?
|
||||
|
||||
你可能会问:“既然调优的关键在于瓶颈,我该如何定位性能瓶颈呢?”我觉得,至少有两种途径:一是先验的专家经验,二是后验的运行时诊断。下面,我们一一来看。
|
||||
|
||||
所谓专家经验是指在代码开发阶段、或是在代码Review阶段,凭借过往的实战经验就能够大致判断哪里可能是性能瓶颈。显然,这样的专家并不好找,一名开发者要经过大量的积累才能成为专家,如果你身边有这样的人,一定不要放过他!
|
||||
|
||||
但你也可能会说:“我身边要是有这样的专家,就不用来订阅这个专栏了。”没关系,咱们还有第二种途径:运行时诊断。
|
||||
|
||||
运行时诊断的手段和方法应该说应有尽有、不一而足。比如:对于任务的执行情况,Spark UI提供了丰富的可视化面板,来展示DAG、Stages划分、执行计划、Executor负载均衡情况、GC时间、内存缓存消耗等等详尽的运行时状态数据;对于硬件资源消耗,开发者可以利用Ganglia或者系统级监控工具,如top、vmstat、iostat、iftop等等来实时监测硬件的资源利用率;特别地,针对GC开销,开发者可以将GC log导入到JVM可视化工具,从而一览任务执行过程中GC的频率和幅度。
|
||||
|
||||
对于这两种定位性能瓶颈的途径来说,专家就好比是老中医,经验丰富、火眼金睛,往往通过望、闻、问、切就能一眼定位到瓶颈所在;而运行时诊断更像是西医中的各种检测仪器和设备,如听诊器、X光、CT扫描仪,需要通过量化的指标才能迅速定位问题。二者并无优劣之分,反而是结合起来的效率更高。就像一名医术高超、经验丰富的大夫,手里拿着你的血液化验和B超结果,对于病灶的判定自然更有把握。
|
||||
|
||||
你可能会说:“尽管有这两种途径,但是就瓶颈定位来说,我还是不知道具体从哪里切入呀!”结合过往的调优经验,我认为,从硬件资源消耗的角度切入,往往是个不错的选择。我们都知道,从硬件的角度出发,计算负载划分为计算密集型、内存密集型和IO密集型。如果我们能够明确手中的应用属于哪种类型,自然能够缩小搜索范围,从而更容易锁定性能瓶颈。
|
||||
|
||||
不过,在实际开发中,并不是所有负载都能明确划分出资源密集类型。比如说,Shuffle、数据关联这些数据分析领域中的典型场景,它们对CPU、内存、磁盘与网络的要求都很高,任何一个环节不给力都有可能形成瓶颈。因此,就性能瓶颈定位来说,除了从硬件资源的视角出发,我们还需要关注典型场景。
|
||||
|
||||
性能调优的方法与手段
|
||||
|
||||
假设,通过运行时诊断我们成功地定位到了性能瓶颈所在,那么,具体该如何调优呢?Spark性能调优的方法和手段丰富而又庞杂,如果一条一条地去罗列,不仅看上去让人昏昏欲睡,更不利于形成系统化的调优方法论。就像医生在治疗某个病症的时候,往往会结合内服、外用甚至是外科手术,这样多管齐下的方式来达到药到病除的目的。
|
||||
|
||||
因此,在我看来, Spark的性能调优可以从应用代码和Spark配置项这2个层面展开。
|
||||
|
||||
应用代码层面指的是从代码开发的角度,我们该如何取舍,如何以性能为导向进行应用开发。在上一讲中我们已经做过对比,哪怕是两份功能完全一致的代码,在性能上也会有很大的差异。因此我们需要知道,开发阶段都有哪些常规操作、常见误区,从而尽量避免在代码中留下性能隐患。
|
||||
|
||||
Spark配置项想必你并不陌生,Spark官网上罗列了近百个配置项,看得人眼花缭乱,但并不是所有的配置项都和性能调优息息相关,因此我们需要对它们进行甄别、归类。
|
||||
|
||||
总的来说,在日常的调优工作中,我们往往从应用代码和Spark配置项这2个层面出发。所谓:“问题从代码中来,解决问题要到代码中去”,在应用代码层面进行调优,其实就是一个捕捉和移除性能BUG的过程。Spark配置项则给予了开发者极大的灵活度,允许开发者通过配置项来调整不同硬件的资源利用率,从而适配应用的运行时负载。
|
||||
|
||||
对于应用代码和Spark配置项层面的调优方法与技巧,以及这些技巧在典型场景和硬件资源利用率调控中的综合运用,我会在《性能篇》逐一展开讲解,从不同层面、不同场景、不同视角出发,归纳出一套调优的方法与心得,力图让你能够按图索骥、有章可循地去开展性能调优。
|
||||
|
||||
性能调优的终结
|
||||
|
||||
性能调优的本质告诉我们:性能调优是一个动态、持续不断的过程,在这个过程中,调优的手段需要随着瓶颈的此消彼长而相应地切换。那么问题来了,性能调优到底什么时候是个头儿呢?就算是持续不断地,也总得有个收敛条件吧?总不能一直这么无限循环下去。
|
||||
|
||||
在我看来,性能调优的最终目的,是在所有参与计算的硬件资源之间寻求协同与平衡,让硬件资源达到一种平衡、无瓶颈的状态。
|
||||
|
||||
我们以大数据服务公司Qubole的案例为例,他们最近在Spark上集成机器学习框架XGBoost来进行模型训练,在相同的硬件资源、相同的数据源、相同的计算任务中对比不同配置下的执行性能。
|
||||
|
||||
从下表中我们就可以清楚地看到,执行性能最好的训练任务并不是那些把CPU利用率压榨到100%,以及把内存设置到最大的配置组合,而是那些硬件资源配置最均衡的计算任务。
|
||||
|
||||
|
||||
|
||||
小结
|
||||
|
||||
只有理解Spark性能调优的本质,形成系统化的方法论,我们才能避免投入时间与产出不成正比的窘境。
|
||||
|
||||
性能调优的本质,我总结为4点:
|
||||
|
||||
|
||||
性能调优不是一锤子买卖,补齐一个短板,其他板子可能会成为新的短板。因此,它是一个动态、持续不断的过程;
|
||||
性能调优的手段和方法是否高效,取决于它针对的是木桶的长板还是瓶颈。针对瓶颈,事半功倍;针对长板,事倍功半;
|
||||
性能调优的方法和技巧,没有一定之规,也不是一成不变,随着木桶短板的此消彼长需要相应地动态切换;
|
||||
性能调优的过程收敛于一种所有木板齐平、没有瓶颈的状态。
|
||||
|
||||
|
||||
系统化的性能调优方法论,我归纳为4条:
|
||||
|
||||
|
||||
通过不同的途径如专家经验或运行时诊断来定位性能瓶颈;
|
||||
从不同场景(典型场景)、不同视角(硬件资源)出发,综合运用不同层面(应用代码、Spark配置项)的调优手段和方法;
|
||||
随着性能瓶颈的此消彼长,动态灵活地在不同层面之间切换调优方法;
|
||||
让性能调优的过程收敛于不同硬件资源在运行时达到一种平衡、无瓶颈的状态。
|
||||
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
你还遇到过哪些“照本宣科”的调优手段?
|
||||
|
||||
你认为,对于性能调优的收敛状态,即硬件资源彼此之间平衡、无瓶颈的状态,需要量化吗?如何量化呢?
|
||||
|
||||
|
||||
关于性能调优,你还有哪些看法?欢迎在评论区留言,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
159
专栏/Spark性能调优实战/03RDD:为什么你必须要理解弹性分布式数据集?.md
Normal file
159
专栏/Spark性能调优实战/03RDD:为什么你必须要理解弹性分布式数据集?.md
Normal file
@ -0,0 +1,159 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 RDD:为什么你必须要理解弹性分布式数据集?
|
||||
你好,我是吴磊。
|
||||
|
||||
从今天开始,我们进入原理篇的学习。我会以性能调优为导向,给你详细讲讲Spark中的核心概念RDD和DAG,以及重要组件调度系统、存储系统和内存管理。这节课,咱们先来说说RDD。
|
||||
|
||||
RDD可以说是Spark中最基础的概念了,使用Spark的开发者想必对RDD都不陌生,甚至提起RDD,你的耳朵可能都已经听出茧子了。不过,随着Spark开发API的演进和发展,现在上手开发基本都是DataFrame或Dataset API。所以很多初学者会认为,“反正RDD API基本都没人用了,我也没必要弄明白RDD到底是什么。”
|
||||
|
||||
真的是这样的吗?当然不是。
|
||||
|
||||
RDD为何如此重要
|
||||
|
||||
首先,RDD作为Spark对于分布式数据模型的抽象,是构建Spark分布式内存计算引擎的基石。很多Spark核心概念与核心组件,如DAG和调度系统都衍生自RDD。因此,深入理解RDD有利于你更全面、系统地学习Spark的工作原理。
|
||||
|
||||
其次,尽管RDD API使用频率越来越低,绝大多数人也都已经习惯于DataFrame和Dataset API,但是,无论采用哪种API或是哪种开发语言,你的应用在Spark内部最终都会转化为RDD之上的分布式计算。换句话说,如果你想要在运行时判断应用的性能瓶颈,前提是你要对RDD足够了解。还记得吗?定位性能瓶颈是Spark性能调优的第一步。
|
||||
|
||||
不仅如此,对于RDD不求甚解还有可能带来潜在的性能隐患,接下来,我们就从一个反例入手,一起来分析一下。
|
||||
|
||||
还记得,我们在第1讲中讲过的数据过滤与聚合的反例吗?通过这个例子我们明白了性能调优的必要性。那这个例子和RDD有什么关系呢?
|
||||
|
||||
别着急,我们先来回顾一下这个案例中的代码实现,去挖掘开发者采用这种实现方式的深层原因。
|
||||
|
||||
//实现方案1 —— 反例
|
||||
def createInstance(factDF: DataFrame, startDate: String, endDate: String): DataFrame = {
|
||||
val instanceDF = factDF
|
||||
.filter(col("eventDate") > lit(startDate) && col("eventDate") <= lit(endDate))
|
||||
.groupBy("dim1", "dim2", "dim3", "event_date")
|
||||
.agg("sum(value) as sum_value")
|
||||
instanceDF
|
||||
}
|
||||
|
||||
pairDF.collect.foreach{
|
||||
case (startDate: String, endDate: String) =>
|
||||
val instance = createInstance(factDF, startDate, endDate)
|
||||
val outPath = s"${rootPath}/endDate=${endDate}/startDate=${startDate}"
|
||||
instance.write.parquet(outPath)
|
||||
}
|
||||
|
||||
|
||||
|
||||
在这段代码中,createInstance的主要逻辑是按照时间条件对factDF进行过滤,返回汇总的业务统计量,然后pairDF循环遍历每一对开始时间和结束时间,循环调用createInstance获取汇总结果并落盘。我们在第1课中分析过,这份代码的主要问题在于囊括上千万行数据的factDF被反复扫描了几百次,而且是全量扫描,从而拖垮了端到端的执行性能。
|
||||
|
||||
那么,我们不禁要问:开发者究竟为什么会想到用这种低效的方式去实现业务逻辑呢?或者说,是什么内驱因素让开发者自然而然地采用这种实现方式呢?
|
||||
|
||||
让我们跳出Spark、跳出这个专栏,把自己置身于一间教室内:黑板前,老师正在讲解《XX语言编程》,旁边是你的同学,他边听老师讲课,边翻看着桌上的课本。这个场景熟不熟悉?亲不亲切?回想一下,老师讲的、书本上教的和我们示例中的代码,是不是极其类似?
|
||||
|
||||
没错!我们的大脑,已经习惯了for循环,习惯了用函数处理变量、封装计算逻辑,习惯了面向过程的编程模式。在分布式计算出现以前,我们都是这么开发的,老师也是这么讲的,书本上也是这么教的,没毛病。
|
||||
|
||||
因此我认为,开发者之所以会选择上面的实现方式,根本原因在于他把factDF当成了一个普通变量,一个与createInstance函数中startDate、endDate同等地位的形参,他并没有意识到,factDF实际上是一个庞大的、横跨所有计算节点的分布式数据集合,更没有意识到,在分布式运行环境中,外面的for循环会导致这个庞大的数据集被反复地全量扫描。
|
||||
|
||||
这种对于分布式计算认知方面的缺失,究其缘由,还是我们对Spark核心概念RDD的理解不够透彻。所以你看,深入理解RDD还是很有必要的,对于RDD一知半解,极有可能在应用开发的过程中,不知不觉地留下潜在的性能隐患。
|
||||
|
||||
深入理解RDD
|
||||
|
||||
既然RDD如此重要,它究竟是什么呢?2010年,在一个夜黑风高的夜晚,Matei等人发表了一篇名为《Spark: Cluster Computing with Working Sets》的论文并首次提出了RDD的概念。RDD,全称Resilient Distributed Datasets,翻译过来就是弹性分布式数据集。本质上,它是对于数据模型的抽象,用于囊括所有内存中和磁盘中的分布式数据实体。
|
||||
|
||||
如果就这么从理论出发、照本宣科地讲下去,未免过于枯燥、乏味、没意思!不如,我先来给你讲个故事。
|
||||
|
||||
从薯片的加工流程看RDD
|
||||
|
||||
在很久很久以前,有个生产桶装薯片的工坊,工坊的规模较小,工艺也比较原始。为了充分利用每一颗土豆、降低生产成本,工坊使用 3 条流水线来同时生产 3 种不同尺寸的桶装薯片。3 条流水线可以同时加工 3 颗土豆,每条流水线的作业流程都是一样的,分别是清洗、切片、烘焙、分发和装桶。其中,分发环节用于区分小、中、大号 3 种薯片,3种不同尺寸的薯片分别被发往第1、2、3条流水线。具体流程如下图所示。
|
||||
|
||||
|
||||
|
||||
看得出来,这家工坊制作工艺虽然简单,倒也蛮有章法。从头至尾,除了分发环节,3 条流水线没有任何交集。在分发环节之前,每条流水线都是专心致志、各顾各地开展工作:把土豆食材加载到流水线上,再进行清洗、切片、烘焙;在分发环节之后,3 条流水线也是各自装桶,互不干涉、互不影响。流水线的作业方式提供了较强的容错能力,如果某个加工环节出错,工人们只需要往出错的流水线上重新加载一颗新的土豆,整个流水线就能够恢复生产。
|
||||
|
||||
好了,故事讲完了。如果我们把每一条流水线看作是分布式运行环境的计算节点,用薯片生产的流程去类比Spark分布式计算,会有哪些有趣的发现呢?
|
||||
|
||||
仔细观察,我们发现:刚从地里挖出来的土豆食材、清洗过后的干净土豆、生薯片、烤熟的薯片,流水线上这些食材的不同形态,就像是Spark中RDD对于不同数据集合的抽象。
|
||||
|
||||
沿着流水线的纵深方向,也就是图中从左向右的方向,每一种食材形态都是在前一种食材之上用相应的加工方法进行处理得到的。每种食材形态都依赖于前一种食材,这就像是RDD中dependencies属性记录的依赖关系,而不同环节的加工方法,对应的刚好就是RDD的compute属性。
|
||||
|
||||
横看成岭侧成峰,再让我们从横向的角度来重新审视上面的土豆加工流程,也就是图中从上至下的方向,让我们把目光集中在流水线开端那3颗带泥的土豆上。这3颗土豆才从地里挖出来,是原始的食材形态,正等待清洗。如图所示,我们把这种食材形态标记为potatosRDD,那么,这里的每一颗土豆就是RDD中的数据分片,3颗土豆一起对应的就是RDD的partitions属性。
|
||||
|
||||
|
||||
|
||||
带泥土豆经过清洗、切片和烘焙之后,按照大小个儿被分发到下游的3条流水线上,这3条流水线上承载的RDD记为shuffledBakedChipsRDD。很明显,这个RDD对于partitions的划分是有讲究的,根据尺寸的不同,即食薯片会被划分到不同的数据分片中。像这种数据分片划分规则,对应的就是RDD中的partitioner属性。 在分布式运行环境中,partitioner属性定义了RDD所封装的分布式数据集如何划分成数据分片。
|
||||
|
||||
总的来说,我们发现,薯片生产的流程和Spark分布式计算是一一对应的,一共可以总结为6点:
|
||||
|
||||
|
||||
土豆工坊的每条流水线就像是分布式环境中的计算节点;
|
||||
不同的食材形态,如带泥的土豆、土豆切片、烘烤的土豆片等等,对应的就是RDD;
|
||||
每一种食材形态都会依赖上一种形态,如烤熟的土豆片依赖上一个步骤的生土豆切片。这种依赖关系对应的就是RDD中的dependencies属性;
|
||||
不同环节的加工方法对应RDD的compute属性;
|
||||
同一种食材形态在不同流水线上的具体实物,就是RDD的partitions属性;
|
||||
食材按照什么规则被分配到哪条流水线,对应的就是RDD的partitioner属性。
|
||||
|
||||
|
||||
不知道土豆工坊的类比,有没有帮你逐渐勾勒出RDD的本来面貌呢?话付前言,接下来,咱们来一本正经地聊聊RDD。
|
||||
|
||||
RDD的核心特征和属性
|
||||
|
||||
通过刚才的例子,我们知道RDD具有4大属性,分别是partitions、partitioner、dependencies和compute属性。正因为有了这4大属性的存在,让RDD具有分布式和容错性这两大最突出的特性。要想深入理解RDD,我们不妨从它的核心特性和属性入手。
|
||||
|
||||
首先,我们来看partitions、partitioner属性。
|
||||
|
||||
在分布式运行环境中,RDD封装的数据在物理上散落在不同计算节点的内存或是磁盘中,这些散落的数据被称“数据分片”,RDD的分区规则决定了哪些数据分片应该散落到哪些节点中去。RDD的partitions属性对应着RDD分布式数据实体中所有的数据分片,而partitioner属性则定义了划分数据分片的分区规则,如按哈希取模或是按区间划分等。
|
||||
|
||||
不难发现,partitions和partitioner属性刻画的是RDD在跨节点方向上的横向扩展,所以我们把它们叫做RDD的“横向属性”。
|
||||
|
||||
然后,我们再来说说dependencies和compute属性。
|
||||
|
||||
在Spark中,任何一个 RDD 都不是凭空产生的,每个 RDD 都是基于某种计算逻辑从某个“数据源”转换而来。RDD的dependencies属性记录了生成RDD 所需的“数据源”,术语叫做父依赖(或父RDD),compute方法则封装了从父 RDD到当前RDD转换的计算逻辑。
|
||||
|
||||
基于数据源和转换逻辑,无论RDD有什么差池(如节点宕机造成部分数据分片丢失),在dependencies属性记录的父RDD之上,都可以通过执行compute封装的计算逻辑再次得到当前的RDD,如下图所示。
|
||||
|
||||
|
||||
|
||||
由dependencies和compute属性提供的容错能力,为Spark分布式内存计算的稳定性打下了坚实的基础,这也正是RDD命名中Resilient的由来。接着观察上图,我们不难发现,不同的RDD通过dependencies和compute属性链接在一起,逐渐向纵深延展,构建了一张越来越深的有向无环图,也就是我们常说的DAG。
|
||||
|
||||
由此可见,dependencies属性和compute属性负责RDD在纵深方向上的延展,因此我们不妨把这两个属性称为“纵向属性”。
|
||||
|
||||
总的来说,RDD的4大属性又可以划分为两类,横向属性和纵向属性。其中,横向属性锚定数据分片实体,并规定了数据分片在分布式集群中如何分布;纵向属性用于在纵深方向构建DAG,通过提供重构RDD的容错能力保障内存计算的稳定性。
|
||||
|
||||
同时,为了帮助你记忆,我把这4大核心属性的基本概念和分类总结在了如下的表格中,你可以看一看。
|
||||
|
||||
|
||||
|
||||
除此之外,我还想再多说两句。在这节课开头的反例中,我们分析了开发者采用foreach语句循环遍历分布式数据集的深层次原因。这种不假思索地直入面向过程编程、忽略或无视分布式数据实体的编程模式,我将其称为单机思维模式。
|
||||
|
||||
在学习了RDD横向的partitions属性和纵向的dependencies属性之后,如果你能把它们牢记于心,那么在频繁调用或引用这个RDD之前,你自然会想到它所囊括的数据集合,很有可能在全节点范围内被反复扫描、反复计算。这种下意识的反思会驱使你尝试探索其他更优的实现方式,从而跳出单机思维模式。因此,深入理解RDD,也有利于你跳出单机思维模式,避免在应用代码中留下性能隐患。
|
||||
|
||||
小结
|
||||
|
||||
今天,我带你学习了RDD的重要性,以及它的2大核心特性和4大属性。
|
||||
|
||||
首先,深入理解RDD对开发者来说有百利而无一害,原因有如下3点:
|
||||
|
||||
|
||||
Spark很多核心概念都衍生自RDD,弄懂RDD,有利于你全面地学习Spark;
|
||||
牢记RDD的关键特性和核心属性,有利于你在运行时更好地定位性能瓶颈,而瓶颈定位,恰恰是性能调优的前提;
|
||||
深入理解RDD有利于你跳出单机思维模式,避免在应用代码中留下性能隐患。
|
||||
|
||||
|
||||
关于RDD的特性与核心属性,只要你把如下2点牢记于心,我相信在不知不觉中你自然会绕过很多性能上的坑:
|
||||
|
||||
|
||||
横向属性partitions和partitioner锚定数据分片实体,并且规定了数据分片在分布式集群中如何分布;
|
||||
纵向属性dependencies和compute用于在纵深方向构建DAG,通过提供重构RDD的容错能力保障内存计算的稳定性。
|
||||
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
在日常的开发工作中,你遇到过“单机思维模式”吗?有哪些呢?
|
||||
|
||||
除了我们今天讲的4大属性,RDD还有个很重要的属性:preferredLocations。按照经验,你认为在哪些情况下,preferredLocations很重要,会提升I/O效率,又在哪些环境中不起作用呢?为什么?
|
||||
|
||||
|
||||
期待在留言区看到你的思考,也欢迎你分享工作中遇到过的“单机思维模式”,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
134
专栏/Spark性能调优实战/04DAG与流水线:到底啥叫“内存计算”?.md
Normal file
134
专栏/Spark性能调优实战/04DAG与流水线:到底啥叫“内存计算”?.md
Normal file
@ -0,0 +1,134 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 DAG与流水线:到底啥叫“内存计算”?
|
||||
你好,我是吴磊。
|
||||
|
||||
在日常的开发工作中,我发现有两种现象很普遍。
|
||||
|
||||
第一种是缓存的滥用。无论是RDD,还是DataFrame,凡是能产生数据集的地方,开发同学一律用cache进行缓存,结果就是应用的执行性能奇差无比。开发同学也很委屈:“Spark不是内存计算的吗?为什么把数据缓存到内存里去,性能反而更差了?”
|
||||
|
||||
第二种现象是关于Shuffle的。我们都知道,Shuffle是Spark中的性能杀手,在开发应用时要尽可能地避免Shuffle操作。不过据我观察,很多初学者都没有足够的动力去重构代码来避免Shuffle,这些同学的想法往往是:“能把业务功能实现就不错了,费了半天劲去重写代码就算真的消除了Shuffle,能有多大的性能收益啊。”
|
||||
|
||||
以上这两种现象可能大多数人并不在意,但往往这些细节才决定了应用执行性能的优劣。在我看来,造成这两种现象的根本原因就在于,开发者对Spark内存计算的理解还不够透彻。所以今天,我们就来说说Spark的内存计算都有哪些含义?
|
||||
|
||||
第一层含义:分布式数据缓存
|
||||
|
||||
一提起Spark的“内存计算”的含义,你的第一反应很可能是:Spark允许开发者将分布式数据集缓存到计算节点的内存中,从而对其进行高效的数据访问。没错,这就是内存计算的第一层含义:众所周知的分布式数据缓存。
|
||||
|
||||
RDD cache确实是Spark分布式计算引擎的一大亮点,也是对业务应用进行性能调优的诸多利器之一,很多技术博客甚至是Spark官网,都在不厌其烦地强调RDD cache对于应用执行性能的重要性。
|
||||
|
||||
正因为考虑到这些因素,很多开发者才会在代码中不假思索地滥用cache机制,也就是我们刚刚提到的第一个现象。但是,这些同学都忽略了一个重要的细节:只有需要频繁访问的数据集才有必要cache,对于一次性访问的数据集,cache不但不能提升执行效率,反而会产生额外的性能开销,让结果适得其反。
|
||||
|
||||
之所以会忽略这么重要的细节,背后深层次的原因在于,开发者对内存计算的理解仅仅停留在缓存这个层面。因此,当业务应用的执行性能出现问题时,只好死马当活马医,拼命地抓住cache这根救命稻草,结果反而越陷越深。
|
||||
|
||||
接下来,我们就重点说说内存计算的第二层含义:Stage内部的流水线式计算模式。
|
||||
|
||||
在Spark中,内存计算有两层含义:第一层含义就是众所周知的分布式数据缓存,第二层含义是Stage内的流水线式计算模式。关于RDD缓存的工作原理,我会在后续的课程中为你详细介绍,今天咱们重点关注内存计算的第二层含义就可以了。
|
||||
|
||||
第二层含义:Stage内的流水线式计算模式
|
||||
|
||||
很显然,要弄清楚内存计算的第二层含义,咱们得从DAG的Stages划分说起。在这之前,我们先来说说什么是DAG。
|
||||
|
||||
什么是DAG?
|
||||
|
||||
DAG全称Direct Acyclic Graph,中文叫有向无环图。顾名思义,DAG 是一种“图”。我们知道,任何一种图都包含两种基本元素:顶点(Vertex)和边(Edge),顶点通常用于表示实体,而边则代表实体间的关系。在Spark的DAG中,顶点是一个个RDD,边则是RDD之间通过dependencies属性构成的父子关系。
|
||||
|
||||
从理论切入去讲解DAG,未免枯燥乏味,所以我打算借助上一讲土豆工坊的例子,来帮助你直观地认识DAG。上一讲,土豆工坊成功地实现了同时生产 3 种不同尺寸的桶装“原味”薯片。但是,在将“原味”薯片推向市场一段时间以后,工坊老板发现季度销量直线下滑,不由得火往上撞、心急如焚。此时,工坊的工头儿向他建议:“老板,咱们何不把流水线稍加改造,推出不同风味的薯片,去迎合市场大众的多样化选择?”然后,工头儿把改装后的效果图交给老板,老板看后甚是满意。
|
||||
|
||||
|
||||
|
||||
不过,改造流水线可是个大工程,为了让改装工人能够高效协作,工头儿得把上面的改造设想抽象成一张施工流程图。有了这张蓝图,工头儿才能给负责改装的工人们分工,大伙儿才能拧成一股绳、劲儿往一处使。在上一讲中,我们把食材形态类比成RDD,把相邻食材形态的关系看作是RDD间的依赖,那么显然,流水线的施工流程图就是DAG。
|
||||
|
||||
|
||||
|
||||
因为DAG中的每一个顶点都由RDD构成,对应到上图中就是带泥的土豆potatosRDD,清洗过的土豆cleanedPotatosRDD,以及调料粉flavoursRDD等等。DAG的边则标记了不同RDD之间的依赖与转换关系。很明显,上图中DAG的每一条边都有指向性,而且整张图不存在环结构。
|
||||
|
||||
那DAG是怎么生成的呢?
|
||||
|
||||
我们都知道,在Spark的开发模型下,应用开发实际上就是灵活运用算子实现业务逻辑的过程。开发者在分布式数据集如RDD、 DataFrame或Dataset之上调用算子、封装计算逻辑,这个过程会衍生新的子RDD。与此同时,子RDD会把dependencies属性赋值到父RDD,把compute属性赋值到算子封装的计算逻辑。以此类推,在子RDD之上,开发者还会继续调用其他算子,衍生出新的RDD,如此往复便有了DAG。
|
||||
|
||||
因此,从开发者的视角出发,DAG的构建是通过在分布式数据集上不停地调用算子来完成的。
|
||||
|
||||
Stages的划分
|
||||
|
||||
现在,我们知道了什么是DAG,以及DAG是如何构建的。不过,DAG毕竟只是一张流程图,Spark需要把这张流程图转化成分布式任务,才能充分利用分布式集群并行计算的优势。这就好比土豆工坊的施工流程图毕竟还只是蓝图,是工头儿给老板画的一张“饼”,工头儿得想方设法把它转化成实实在在的土豆加工流水线,让流水线能够源源不断地生产不同风味的薯片,才能解决老板的燃眉之急。
|
||||
|
||||
简单地说,从开发者构建DAG,到DAG转化的分布式任务在分布式环境中执行,其间会经历如下4个阶段:
|
||||
|
||||
|
||||
回溯DAG并划分Stages
|
||||
在Stages中创建分布式任务
|
||||
分布式任务的分发
|
||||
分布式任务的执行
|
||||
|
||||
|
||||
刚才我们说了,内存计算的第二层含义在stages内部,因此这一讲我们只要搞清楚DAG是怎么划分Stages就够了。至于后面的3个阶段更偏向调度系统的范畴,所以我会在下一讲给你讲清楚其中的来龙去脉。
|
||||
|
||||
如果用一句话来概括从DAG到Stages的转化过程,那应该是:以Actions算子为起点,从后向前回溯DAG,以Shuffle操作为边界去划分Stages。
|
||||
|
||||
接下来,我们还是以土豆工坊为例来详细说说这个过程。既然DAG是以Shuffle为边界去划分Stages,我们不妨先从上帝视角出发,看看在土豆工坊设计流程图的DAG中,都有哪些地方需要执行数据分发的操作。当然,在土豆工坊,数据就是各种形态的土豆和土豆片儿。
|
||||
|
||||
|
||||
|
||||
仔细观察上面的设计流程图,我们不难发现,有两个地方需要分发数据。第一个地方是薯片经过烘焙烤熟之后,把即食薯片按尺寸大小分发到下游的流水线上,这些流水线会专门处理固定型号的薯片,也就是图中从bakedChipsRDD到flavouredBakedChipsRDD的那条线。同理,不同的调料粉也需要按照风味的不同分发到下游的流水线上,用于和固定型号的即食薯片混合,也就是图中从flavoursRDD到flavouredBakedChipsRDD那条分支。
|
||||
|
||||
同时,我们也能发现,土豆工坊的DAG应该划分3个Stages出来,如图中所示。其中,Stage 0包含四个RDD,从带泥土豆potatosRDD到即食薯片bakedChipsRDD。Stage 1比较简单,它只有一个RDD,就是封装调味粉的flavoursRDD。Stage 2包含两个RDD,一个是加了不同风味的即食薯片flavouredBakedChipsRDD,另一个表示组装成桶已经准备售卖的桶装薯片bucketChipsRDD。
|
||||
|
||||
你可能会问:“费了半天劲,把DAG变成Stages有啥用呢?”还真有用!内存计算的第二层含义,就隐匿于从DAG划分出的一个又一个Stages之中。不过,要弄清楚Stage内的流水线式计算模式,我们还是得从Hadoop MapReduce的计算模型说起。
|
||||
|
||||
Stage中的内存计算
|
||||
|
||||
基于内存的计算模型并不是凭空产生的,而是根据前人的教训和后人的反思精心设计出来的。这个前人就是Hadoop MapReduce,后人自然就是Spark。
|
||||
|
||||
|
||||
|
||||
MapReduce提供两类计算抽象,分别是Map和Reduce:Map抽象允许开发者通过实现map 接口来定义数据处理逻辑;Reduce抽象则用于封装数据聚合逻辑。MapReduce计算模型最大的问题在于,所有操作之间的数据交换都以磁盘为媒介。例如,两个Map操作之间的计算,以及Map与Reduce操作之间的计算都是利用本地磁盘来交换数据的。不难想象,这种频繁的磁盘I/O必定会拖累用户应用端到端的执行性能。
|
||||
|
||||
那么,这和Stage内的流水线式计算模式有啥关系呢?我们再回到土豆工坊的例子中,把目光集中在即食薯片分发之前,也就是刚刚划分出来的Stage 0。这一阶段包含3个处理操作,即清洗、切片和烘焙。按常理来说,流水线式的作业方式非常高效,带泥土豆被清洗过后,会沿着流水线被传送到切片机,切完的生薯片会继续沿着流水线再传送到烘焙烤箱,整个过程一气呵成。如果把流水线看作是计算节点内存的话,那么清洗、切片和烘焙这3个操作都是在内存中完成计算的。
|
||||
|
||||
|
||||
|
||||
你可能会说:“内存计算也不过如此,跟MapReduce相比,不就是把数据和计算都挪到内存里去了吗?”事情可能并没有你想象的那么简单。
|
||||
|
||||
在土豆工坊的例子里,Stage 0中的每个加工环节都会生产出中间食材,如清洗过的土豆、土豆片、即食薯片。我们刚刚把流水线比作内存,这意味着每一个算子计算得到的中间结果都会在内存中缓存一份,以备下一个算子运算,这个过程与开发者在应用代码中滥用RDD cache简直如出一辙。如果你曾经也是逢RDD便cache,应该不难想象,采用这种计算模式,Spark的执行性能不见得比MapReduce强多少,尤其是在Stages中的算子数量较多的时候。
|
||||
|
||||
既然不是简单地把数据和计算挪到内存,那Stage内的流水线式计算模式到底长啥样呢?在Spark中,流水线计算模式指的是:在同一Stage内部,所有算子融合为一个函数,Stage的输出结果由这个函数一次性作用在输入数据集而产生。这也正是内存计算的第二层含义。下面,我们用一张图来直观地解释这一计算模式。
|
||||
|
||||
|
||||
|
||||
如图所示,在上面的计算流程中,如果你把流水线看作是内存,每一步操作过后都会生成临时数据,如图中的clean和slice,这些临时数据都会缓存在内存里。但在下面的内存计算中,所有操作步骤如clean、slice、bake,都会被捏合在一起构成一个函数。这个函数一次性地作用在“带泥土豆”上,直接生成“即食薯片”,在内存中不产生任何中间数据形态。
|
||||
|
||||
因此你看,所谓内存计算,不仅仅是指数据可以缓存在内存中,更重要的是让我们明白了,通过计算的融合来大幅提升数据在内存中的转换效率,进而从整体上提升应用的执行性能。
|
||||
|
||||
这个时候,我们就可以回答开头提出的第二个问题了:费劲去重写代码、消除Shuffle,能有多大的性能收益?
|
||||
|
||||
由于计算的融合只发生在Stages内部,而Shuffle是切割Stages的边界,因此一旦发生Shuffle,内存计算的代码融合就会中断。但是,当我们对内存计算有了多方位理解以后,就不会一股脑地只想到用cache去提升应用的执行性能,而是会更主动地想办法尽量避免Shuffle,让应用代码中尽可能多的部分融合为一个函数,从而提升计算效率。
|
||||
|
||||
小结
|
||||
|
||||
这一讲,我们以两个常见的现象为例,探讨了Spark内存计算的含义。
|
||||
|
||||
在Spark中,内存计算有两层含义:第一层含义就是众所周知的分布式数据缓存,第二层含义是Stage内的流水线式计算模式。
|
||||
|
||||
对于第二层含义,我们需要先搞清楚DAG和Stages划分,从开发者的视角出发,DAG的构建是通过在分布式数据集上不停地调用算子来完成的,DAG以Actions算子为起点,从后向前回溯,以Shuffle操作为边界,划分出不同的Stages。
|
||||
|
||||
最后,我们归纳出内存计算更完整的第二层含义:同一Stage内所有算子融合为一个函数,Stage的输出结果由这个函数一次性作用在输入数据集而产生。
|
||||
|
||||
每日一练
|
||||
|
||||
今天的内容重在理解,我希望你能结合下面两道思考题来巩固一下。
|
||||
|
||||
|
||||
我们今天说了,DAG以Shuffle为边界划分Stages,那你知道Spark是根据什么来判断一个操作是否会引入Shuffle的呢?
|
||||
|
||||
在Spark中,同一Stage内的所有算子会融合为一个函数。你知道这一步是怎么做到的吗?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,如果对内存计算还有很多困惑,也欢迎你写在留言区,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
227
专栏/Spark性能调优实战/05调度系统:“数据不动代码动”到底是什么意思?.md
Normal file
227
专栏/Spark性能调优实战/05调度系统:“数据不动代码动”到底是什么意思?.md
Normal file
@ -0,0 +1,227 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 调度系统:“数据不动代码动”到底是什么意思?
|
||||
你好,我是吴磊。
|
||||
|
||||
在日常的开发与调优工作中,为了充分利用硬件资源,我们往往需要手工调节任务并行度来提升CPU利用率,控制任务并行度的参数是Spark的配置项:spark.default.parallelism。增加并行度确实能够充分利用闲置的CPU线程,但是,parallelism数值也不宜过大,过大反而会引入过多的调度开销,得不偿失。
|
||||
|
||||
这个调优技巧可以说是老生常谈了,网上到处都可以搜得到。那你知道为什么parallelism数值过大调度开销会呈指数级增长吗?调度开销具体又是指什么呢?以及,如果不想一个数值一个数值的尝试,parallelism数值究竟该怎么设置,才能以最少的时间获得最好的效果?如果你还没有答案,或者说还没有把握答对,接下来你就要好好听我讲。
|
||||
|
||||
这一讲,我会通过一个机器学习案例,来和你一起聊聊调度系统是什么,它是怎么工作的,从而帮助你摆脱调优总是停留在知其然、不知其所以然的尴尬境地。
|
||||
|
||||
案例:对用户兴趣特征做Label Encoding
|
||||
|
||||
在机器学习应用中,特征工程几乎占据了算法同学80%的时间和精力,毕竟,一份质量优良的训练样本限定了模型效果的上限和天花板,我们要讲的案例就来自特征工程中一个典型的处理场景:Label Encoding(标签编码)。
|
||||
|
||||
什么是Label encoding呢?模型特征按照是否连续可以分为两类:连续性数值特征和离散型特征,离散型特征往往以字符串的形式存在,比如用户兴趣特征就包括体育、政治、军事和娱乐等。对于很多机器学习算法来说,字符串类型的数据是不能直接消费的,需要转换为数值才行,例如把体育、政治、军事、娱乐映射为0、1、2、3,这个过程在机器学习领域有个术语就叫Label encoding。
|
||||
|
||||
我们这一讲的案例,就是要对用户兴趣特征做Label encoding,简单来说就是以固定的模板把字符串转换为数值,然后将千亿条样本中的用户兴趣转换为对应的索引值。固定模板是离线模型训练与线上模型服务之间的文件接口,内容仅包含用户兴趣这一列,字符串已按事先约定好的规则进行排序。我们需要注意的是,用户兴趣包含4个层级,因此这个模板文件较大,记录数达到万级别。
|
||||
|
||||
//模板文件
|
||||
//用户兴趣
|
||||
体育-篮球-NBA-湖人
|
||||
军事-武器-步枪-AK47
|
||||
|
||||
|
||||
那具体怎么转换呢?例如,我们可以将用户兴趣“体育-篮球-NBA-湖人”映射为0,将兴趣“军事-武器-步枪-AK47”映射为1,以此类推。应该说,需求还是相当明确的,我身边的同学们拿到需求之后,奔儿都没打,以迅雷不及掩耳之势就实现了如下的处理函数。
|
||||
|
||||
/**
|
||||
实现方式1
|
||||
输入参数:模板文件路径,用户兴趣字符串
|
||||
返回值:用户兴趣字符串对应的索引值
|
||||
*/
|
||||
|
||||
//函数定义
|
||||
def findIndex(templatePath: String, interest: String): Int = {
|
||||
val source = Source.fromFile(filePath, "UTF-8")
|
||||
val lines = source.getLines().toArray
|
||||
source.close()
|
||||
val searchMap = lines.zip(0 until lines.size).toMap
|
||||
searchMap.getOrElse(interest, -1)
|
||||
}
|
||||
|
||||
//Dataset中的函数调用
|
||||
findIndex(filePath, "体育-篮球-NBA-湖人")
|
||||
|
||||
|
||||
我们可以看到这个函数有两个形参,一个是模板文件路径,另一个是训练样本中的用户兴趣。处理函数首先读取模板文件,然后根据文件中排序的字符串构建一个从兴趣到索引的Map映射,最后在这个Map中查找第二个形参传入的用户兴趣,如果能找到则返回对应的索引,找不到的话则返回-1。
|
||||
|
||||
这段代码看上去似乎没什么问题,同学们基于上面的函数对千亿样本做Label encoding,在20台机型为C5.4xlarge AWS EC2的分布式集群中花费了5个小时。坦白说,这样的执行性能,我是不能接受的。你可能会说:“需求就是这个样子,还能有什么别的办法呢?”我们不妨来看另外一种实现方式。
|
||||
|
||||
/**
|
||||
实现方式2
|
||||
输入参数:模板文件路径,用户兴趣字符串
|
||||
返回值:用户兴趣字符串对应的索引值
|
||||
*/
|
||||
|
||||
//函数定义
|
||||
val findIndex: (String) => (String) => Int = {
|
||||
(filePath) =>
|
||||
val source = Source.fromFile(filePath, "UTF-8")
|
||||
val lines = source.getLines().toArray
|
||||
source.close()
|
||||
val searchMap = lines.zip(0 until lines.size).toMap
|
||||
(interest) => searchMap.getOrElse(interest, -1)
|
||||
}
|
||||
val partFunc = findIndex(filePath)
|
||||
|
||||
//Dataset中的函数调用
|
||||
partFunc("体育-篮球-NBA-湖人")
|
||||
|
||||
|
||||
同学们基于第二种方式对相同的数据集做Label encoding之后,在10台同样机型的分布式集群中花了不到20分钟就把任务跑完了。可以说,执行性能的提升是显而易见的。那么,两份代码有什么区别呢?
|
||||
|
||||
我们可以看到,相比于第一份代码,第二份代码的函数体内没有任何变化,还是先读取模板文件、构建Map映射、查找用户兴趣,最后返回索引。最大的区别就是第二份代码对高阶函数的使用,具体来说有2点:
|
||||
|
||||
|
||||
处理函数定义为高阶函数,形参是模板文件路径,返回结果是从用户兴趣到索引的函数;
|
||||
封装千亿样本的Dataset所调用的函数,不是第一份代码中的findIndex,而是用模板文件调用findIndex得到的partFunc,partFunc是形参为兴趣、结果为索引的普通标量函数。
|
||||
|
||||
|
||||
那么,高阶函数真有这么神奇吗?其实,性能的提升并不是高阶函数的功劳,而是调度系统在起作用。
|
||||
|
||||
Spark的调度系统是如何工作的?
|
||||
|
||||
Spark调度系统的核心职责是,先将用户构建的DAG转化为分布式任务,结合分布式集群资源的可用性,基于调度规则依序把分布式任务分发到执行器。这个过程听上去就够复杂的了,为了方便你理解,我们还是先来讲一个小故事。
|
||||
|
||||
土豆工坊流水线升级
|
||||
|
||||
在学完了内存计算的第二层含义之后,土豆工坊的老板决定对土豆加工流水线做升级,来提高工坊的生产效率和灵活性。
|
||||
|
||||
这里,我们先对内存计算的第二层含义做个简单地回顾,它指的是同一Stage中的所有操作会被捏合为一个函数,这个函数一次性会被地应用到输入数据上,并且一次性地产生计算结果。
|
||||
|
||||
升级之前的土豆加工流程DAG被切分为3个执行阶段Stage,它们分别是Stage 0、Stage 1、Stage 2。其中,Stage 0产出即食薯片,Stage 1分发调味品,Stage 2则产出不同尺寸、不同风味的薯片。我们重点关注Stage 0,Stage 0有3个加工环节,分别是清洗、切片和烘焙。这3个环节需要3种不同的设备,即清洗机、切片机和烤箱。
|
||||
|
||||
|
||||
|
||||
工坊有3条流水线,每种设备都需要3套,在成本方面要花不少钱呢,因此工坊老板一直绞尽脑汁想把设备方面的成本降下来。
|
||||
|
||||
此时,工头儿建议:“老板,我听说市场上有一种可编程的土豆加工设备,它是个黑盒子并且只有输入口和输出口,从外面看不见里面的操作流程。不过黑盒子受程序控制,给定输入口的食材,我们可以编写程序控制黑盒子的输出。有了这个可编程设备,咱们不但省了钱,将来还可以灵活地扩充产品线。比方想生产各种风味的薯条或是土豆泥,只需要更换一份程序加载到黑盒子里就行啦!”
|
||||
|
||||
老板听后大喜,决定花钱购入可编程土豆加工设备,替换并淘汰现有的清洗机、切片机和烤箱。
|
||||
|
||||
于是,工坊的加工流水线就变成了如下的样子。工人们的工作也从按照DAG流程图的关键步骤,在流水线上安装相应的设备,变成了把关键步骤编写相应的程序加载到黑盒内。这样一来,这家工坊的生产力也从作坊式的生产方式,升级到了现代化流水线的作业模式。
|
||||
|
||||
|
||||
|
||||
那么,这个故事跟我们今天要讲的调度系统有什么关系呢?事实上,Spark调度系统的工作流程包含如下5个步骤:
|
||||
|
||||
1. 将DAG拆分为不同的运行阶段Stages;-
|
||||
2. 创建分布式任务Tasks和任务组TaskSet;-
|
||||
3. 获取集群内可用的硬件资源情况;-
|
||||
4. 按照调度规则决定优先调度哪些任务/组;-
|
||||
5. 依序将分布式任务分发到执行器Executor。
|
||||
|
||||
除了第4步以外,其他几步和土豆工坊流水线上的关键步骤都是一一对应的,它们的对应关系如下:
|
||||
|
||||
|
||||
|
||||
现在,你可能会觉得用故事来记这几个步骤好像多此一举,但当我们学完了所有的原理之后,再回过头来把故事的主线串联起来,你就会惊喜地发现,所有的原理你都能轻松地记住和理解,这可比死记硬背的效率要高得多。
|
||||
|
||||
调度系统中的核心组件有哪些?
|
||||
|
||||
接下来,我们深入到流程中的每一步去探究Spark调度系统是如何工作的。不过在此之前,我们得先弄清楚调度系统都包含哪些关键组件,不同组件之间如何交互,它们分别担任了什么角色,才能更好地理解流程中的每一步。
|
||||
|
||||
Spark调度系统包含3个核心组件,分别是DAGScheduler、TaskScheduler和SchedulerBackend。这3个组件都运行在Driver进程中,它们通力合作将用户构建的DAG转化为分布式任务,再把这些任务分发给集群中的Executors去执行。不过,它们的名字都包含Scheduler,光看名字还真是丈二和尚摸不着头脑,所以我把它们和调度系统流程中5个步骤的对应关系总结在了下表中,你可以看一看。
|
||||
|
||||
|
||||
|
||||
1. DAGScheduler
|
||||
|
||||
DAGScheduler的主要职责有二:一是把用户DAG拆分为Stages,如果你不记得这个过程可以回顾一下上一讲的内容;二是在Stage内创建计算任务Tasks,这些任务囊括了用户通过组合不同算子实现的数据转换逻辑。然后,执行器Executors接收到Tasks,会将其中封装的计算函数应用于分布式数据分片,去执行分布式的计算过程。
|
||||
|
||||
不过,如果我们给集群中处于繁忙或者是饱和状态的Executors分发了任务,执行效果会大打折扣。因此,在分发任务之前,调度系统得先判断哪些节点的计算资源空闲,然后再把任务分发过去。那么,调度系统是怎么判断节点是否空闲的呢?
|
||||
|
||||
2. SchedulerBackend
|
||||
|
||||
SchedulerBackend就是用来干这个事的,它是对于资源调度器的封装与抽象,为了支持多样的资源调度模式如Standalone、YARN和Mesos,SchedulerBackend提供了对应的实现类。在运行时,Spark根据用户提供的MasterURL,来决定实例化哪种实现类的对象。MasterURL就是你通过各种方式指定的资源管理器,如–master spark://ip:host(Standalone 模式)、–master yarn(YARN 模式)。
|
||||
|
||||
对于集群中可用的计算资源,SchedulerBackend会用一个叫做ExecutorDataMap的数据结构,来记录每一个计算节点中Executors的资源状态。ExecutorDataMap是一种HashMap,它的Key是标记Executor的字符串,Value是一种叫做ExecutorData的数据结构,ExecutorData用于封装Executor的资源状态,如RPC地址、主机地址、可用CPU核数和满配CPU核数等等,它相当于是对Executor做的“资源画像”。
|
||||
|
||||
|
||||
|
||||
总的来说,对内,SchedulerBackend用ExecutorData对Executor进行资源画像;对外,SchedulerBackend以WorkerOffer为粒度提供计算资源,WorkerOffer封装了Executor ID、主机地址和CPU核数,用来表示一份可用于调度任务的空闲资源。显然,基于Executor资源画像,SchedulerBackend可以同时提供多个WorkerOffer用于分布式任务调度。WorkerOffer这个名字起得蛮有意思,Offer的字面意思是公司给你提供的工作机会,结合Spark调度系统的上下文,就变成了使用硬件资源的机会。
|
||||
|
||||
好了,到此为止,要调度的计算任务有了,就是DAGScheduler通过Stages创建的Tasks;可用于调度任务的计算资源也有了,即SchedulerBackend提供的一个又一个WorkerOffer。如果从供需的角度看待任务调度,DAGScheduler就是需求端,SchedulerBackend就是供给端。
|
||||
|
||||
3. TaskScheduler
|
||||
|
||||
左边有需求,右边有供给,如果把Spark调度系统看作是一个交易市场的话,那么中间还需要有个中介来帮它们对接意愿、撮合交易,从而最大限度地提升资源配置的效率。在Spark调度系统中,这个中介就是TaskScheduler。TaskScheduler的职责是,基于既定的规则与策略达成供需双方的匹配与撮合。
|
||||
|
||||
|
||||
|
||||
显然,TaskScheduler的核心是任务调度的规则和策略,TaskScheduler的调度策略分为两个层次,一个是不同Stages之间的调度优先级,一个是Stages内不同任务之间的调度优先级。
|
||||
|
||||
首先,对于两个或多个Stages,如果它们彼此之间不存在依赖关系、互相独立,在面对同一份可用计算资源的时候,它们之间就会存在竞争关系。这个时候,先调度谁、或者说谁优先享受这份计算资源,大家就得基于既定的规则和协议照章办事了。
|
||||
|
||||
对于这种Stages之间的任务调度,TaskScheduler提供了2种调度模式,分别是FIFO(先到先得)和FAIR(公平调度)。 FIFO非常好理解,在这种模式下,Stages按照被创建的时间顺序来依次消费可用计算资源。这就好比在二手房交易市场中,两个人同时看中一套房子,不管两个人各自愿意出多少钱,谁最先交定金,中介就优先给谁和卖家撮合交易。
|
||||
|
||||
你可能会说:“这不合常理啊!如果第二个人愿意出更多的钱,卖家自然更乐意和他成交。”没错,考虑到开发者的意愿度,TaskScheduler提供了FAIR公平调度模式。在这种模式下,哪个Stages优先被调度,取决于用户在配置文件fairscheduler.xml中的定义。
|
||||
|
||||
在配置文件中,Spark允许用户定义不同的调度池,每个调度池可以指定不同的调度优先级,用户在开发过程中可以关联不同作业与调度池的对应关系,这样不同Stages的调度就直接和开发者的意愿挂钩,也就能享受不同的优先级待遇。对应到二手房交易的例子中,如果第二个人乐意付30%的高溢价,中介自然乐意优先撮合他与卖家的交易。
|
||||
|
||||
说完了不同Stages之间的调度优先级,我们再来说说同一个Stages内部不同任务之间的调度优先级,Stages内部的任务调度相对来说简单得多。当TaskScheduler接收到来自SchedulerBackend的WorkerOffer后,TaskScheduler会优先挑选那些满足本地性级别要求的任务进行分发。众所周知,本地性级别有4种:Process local < Node local < Rack local < Any。从左到右分别是进程本地性、节点本地性、机架本地性和跨机架本地性。从左到右,计算任务访问所需数据的效率越来越差。
|
||||
|
||||
进程本地性表示计算任务所需的输入数据就在某一个Executor进程内,因此把这样的计算任务调度到目标进程内最划算。同理,如果数据源还未加载到Executor进程,而是存储在某一计算节点的磁盘中,那么把任务调度到目标节点上去,也是一个不错的选择。再次,如果我们无法确定输入源在哪台机器,但可以肯定它一定在某个机架上,本地性级别就会退化到Rack local。
|
||||
|
||||
DAGScheduler划分Stages、创建分布式任务的过程中,会为每一个任务指定本地性级别,本地性级别中会记录该任务有意向的计算节点地址,甚至是Executor进程ID。换句话说,任务自带调度意愿,它通过本地性级别告诉TaskScheduler自己更乐意被调度到哪里去。
|
||||
|
||||
既然计算任务的个人意愿这么强烈,TaskScheduler作为中间商,肯定要优先满足人家的意愿。这就像一名码农想要租西二旗的房子,但是房产中介App推送的结果都是东三环国贸的房子,那么这个中介的匹配算法肯定有问题。
|
||||
|
||||
由此可见,Spark调度系统的原则是尽可能地让数据呆在原地、保持不动,同时尽可能地把承载计算任务的代码分发到离数据最近的地方,从而最大限度地降低分布式系统中的网络开销。毕竟,分发代码的开销要比分发数据的代价低太多,这也正是“数据不动代码动”这个说法的由来。
|
||||
|
||||
总的来说,TaskScheduler根据本地性级别遴选出待计算任务之后,先对这些任务进行序列化。然后,交给SchedulerBackend,SchedulerBackend根据ExecutorData中记录的RPC地址和主机地址,再将序列化的任务通过网络分发到目的主机的Executor中去。最后,Executor接收到任务之后,把任务交由内置的线程池,线程池中的多线程则并发地在不同数据分片之上执行任务中封装的数据处理函数,从而实现分布式计算。
|
||||
|
||||
性能调优案例回顾
|
||||
|
||||
知道了调度系统是如何工作的,我们就可以回过头来说说开头Label encoding的开发案例中,2种实现方式的差别到底在哪儿了。我们先来回顾案例中处理函数的主要计算步骤:
|
||||
|
||||
|
||||
读取并遍历模板文件内容,建立从字符串到数值的字典;
|
||||
根据样本中的用户兴趣,查找字典并返回兴趣字符串对应的数值索引。
|
||||
|
||||
|
||||
2种实现方式的本质区别在于,函数中2个计算步骤的分布式计算过程不同。在第1种实现方式中,函数是一个接收两个形参的普通标量函数,Dataset调用这个函数在千亿级样本上做Label encoding。
|
||||
|
||||
在Spark任务调度流程中,该函数在Driver端交由DAGScheduler打包为Tasks,经过TaskScheduler调度给SchedulerBackend,最后由SchedulerBackend分发到集群中的Executors中去执行。这意味着集群中的每一个Executors都需要执行函数中封装的两个计算步骤,要知道,第一个步骤中遍历文件内容并建立字典的计算开销还是相当大的。
|
||||
|
||||
反观第2种实现方式,2个计算步骤被封装到一个高阶函数中。用户代码先在Driver端用模板文件调用这个高阶函数,完成第一步计算建立字典的过程,同时输出一个只带一个形参的标量函数,这个标量函数内携带了刚刚建好的映射字典。最后,Dataset将这个标量函数作用于千亿样本之上做Label encoding。
|
||||
|
||||
发现区别了吗?在第2种实现中,函数的第一步计算只在Driver端计算一次,分发给集群中所有Executors的任务中封装的是携带了字典的标量函数。然后在Executors端,Executors在各自的数据分片上调用该函数,省去了扫描模板文件、建立字典的开销。最后,我们只需要把样本中的用户兴趣传递进去,函数就能以O(1)的查询效率返回数值结果。
|
||||
|
||||
对于一个有着成百上千Executors的分布式集群来说,这2种不同的实现方式带来的性能差异还是相当可观的。因此,如果你能把Spark调度系统的工作原理牢记于心,我相信在代码开发或是review的过程中,你都能够意识到第一个计算步骤会带来的性能问题。这种开发过程中的反思,其实就是在潜移默化地建立以性能为导向的开发习惯。
|
||||
|
||||
小结
|
||||
|
||||
今天这一讲,我们先通过一个机器学的案例对比了2种实现方式的性能差异,知道了对于调度系统一知半解,很有可能在开发过程中引入潜在的性能隐患。为此,我梳理了调度系统工作流程的5个主要步骤:
|
||||
|
||||
|
||||
将DAG拆分为不同的运行阶段Stages;
|
||||
创建分布式任务Tasks和任务组TaskSet;
|
||||
获取集群内可用硬件资源情况;
|
||||
按照调度规则决定优先调度哪些任务/组;
|
||||
依序将分布式任务分发到执行器Executor;
|
||||
|
||||
|
||||
结合这5个步骤,我们深入分析了Spark调度系统的工作原理,我们可以从核心职责和核心原则这两方面来归纳:
|
||||
|
||||
|
||||
Spark调度系统的核心职责是,先将用户构建的DAG转化为分布式任务,结合分布式集群资源的可用性,基于调度规则依序把分布式任务分发到执行器Executors;
|
||||
Spark调度系统的核心原则是,尽可能地让数据呆在原地、保持不动,同时尽可能地把承载计算任务的代码分发到离数据最近的地方(Executors或计算节点),从而最大限度地降低分布式系统中的网络开销。
|
||||
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
DAGScheduler在创建Tasks的过程中,是如何设置每一个任务的本地性级别?
|
||||
在计算与存储分离的云计算环境中,Node local本地性级别成立吗?你认为哪些情况下成立?哪些情况下不成立?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,如果你的朋友也正急需搞清楚调度系统的工作原理,也欢迎你把这一讲转发给他,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
139
专栏/Spark性能调优实战/06存储系统:空间换时间,还是时间换空间?.md
Normal file
139
专栏/Spark性能调优实战/06存储系统:空间换时间,还是时间换空间?.md
Normal file
@ -0,0 +1,139 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 存储系统:空间换时间,还是时间换空间?
|
||||
你好,我是吴磊。
|
||||
|
||||
今天,我们来学习Spark的存储系统,它和我们上一讲学过的调度系统一样,都是Spark分布式计算引擎的基础设施之一。
|
||||
|
||||
你可能会问:“在日常的开发工作中,除了业务逻辑实现,我真的需要去关心这么底层的东西吗?”确实,存储系统离开发者比较远。不过,如果把目光落在存储系统所服务的对象上,你很可能会改变这种看法。
|
||||
|
||||
接下来,咱们就先来看看Spark 存储系统都为谁服务,再去探讨它有哪些重要组件,以及它是如何工作的,带你一次性摸透存储系统。
|
||||
|
||||
Spark存储系统是为谁服务的?
|
||||
|
||||
Spark 存储系统用于存储 3个方面的数据,分别是RDD 缓存、Shuffle 中间文件、广播变量。我们一个一个来说。
|
||||
|
||||
RDD缓存指的是将RDD以缓存的形式物化到内存或磁盘的过程。对于一些计算成本和访问频率都比较高的RDD来说,缓存有两个好处:一是通过截断DAG,可以降低失败重试的计算开销;二是通过对缓存内容的访问,可以有效减少从头计算的次数,从整体上提升作业端到端的执行性能。
|
||||
|
||||
而要说起Shuffle中间文件,我们就不得不提Shuffle这个话题。在很多场景中,Shuffle都扮演着性能瓶颈的角色,解决掉Shuffle引入的问题之后,执行性能往往能有立竿见影的提升。因此,凡是与Shuffle有关的环节,你都需要格外地重视。
|
||||
|
||||
关于Shuffle的工作原理,我们后面会详细来讲。这里,咱们先简单理解一下Shuffle的计算过程就可以了。它的计算过程可以分为2个阶段:
|
||||
|
||||
|
||||
Map阶段:Shuffle writer按照Reducer的分区规则将中间数据写入本地磁盘;
|
||||
Reduce 阶段:Shuffle reader从各个节点下载数据分片,并根据需要进行聚合计算。
|
||||
|
||||
|
||||
Shuffle中间文件实际上就是Shuffle Map阶段的输出结果,这些结果会以文件的形式暂存于本地磁盘。在Shuffle Reduce阶段,Reducer通过网络拉取这些中间文件用于聚合计算,如求和、计数等。在集群范围内,Reducer想要拉取属于自己的那部分中间数据,就必须要知道这些数据都存储在哪些节点,以及什么位置。而这些关键的元信息,正是由Spark存储系统保存并维护的。因此你看,没有存储系统,Shuffle是玩不转的。
|
||||
|
||||
最后,我们再来说说广播变量。在日常开发中,广播变量往往用于在集群范围内分发访问频率较高的小数据。利用存储系统,广播变量可以在Executors进程范畴内保存全量数据。这样一来,对于同一Executors内的所有计算任务,应用就能够以Process local的本地性级别,来共享广播变量中携带的全量数据了。
|
||||
|
||||
总的来说,这3个服务对象是Spark应用性能调优的有力“抓手”,而它们又和存储系统有着密切的联系,因此想要有效运用这3个方面的调优技巧,我们就必须要对存储系统有足够的理解。
|
||||
|
||||
存储系统的基本组件有哪些?
|
||||
|
||||
与调度系统类似,Spark存储系统是一个囊括了众多组件的复合系统,如BlockManager、BlockManagerMaster、MemoryStore、DiskStore和DiskBlockManager等等。
|
||||
|
||||
不过,家有千口、主事一人,BlockManager是其中最为重要的组件,它在Executors端负责统一管理和协调数据的本地存取与跨节点传输。这怎么理解呢?我们可以从2方面来看。
|
||||
|
||||
|
||||
对外,BlockManager与Driver端的BlockManagerMaster通信,不仅定期向BlockManagerMaster汇报本地数据元信息,还会不定时按需拉取全局数据存储状态。另外,不同Executors的BlockManager之间也会以Server/Client模式跨节点推送和拉取数据块。
|
||||
|
||||
对内,BlockManager通过组合存储系统内部组件的功能来实现数据的存与取、收与发。
|
||||
|
||||
|
||||
那么,对于RDD缓存、Shuffle中间文件和广播变量这3个服务对象来说,BlockManager又是如何存储的呢?Spark存储系统提供了两种存储抽象:MemoryStore和DiskStore。BlockManager正是利用它们来分别管理数据在内存和磁盘中的存取。
|
||||
|
||||
其中,广播变量的全量数据存储在Executors进程中,因此它由MemoryStore管理。Shuffle中间文件往往会落盘到本地节点,所以这些文件的落盘和访问就要经由DiskStore。相比之下,RDD缓存会稍微复杂一些,由于RDD缓存支持内存缓存和磁盘缓存两种模式,因此我们要视情况而定,缓存在内存中的数据会封装到MemoryStore,缓存在磁盘上的数据则交由DiskStore管理。
|
||||
|
||||
有了MemoryStore和DiskStore,我们暂时解决了数据“存在哪儿”的问题。但是,这些数据该以“什么形式”存储到MemoryStore和DiskStore呢?对于数据的存储形式,Spark存储系统支持两种类型:对象值(Object Values)和字节数组(Byte Array)。它们之间可以相互转换,其中,对象值压缩为字节数组的过程叫做序列化,而字节数组还原成原始对象值的过程就叫做反序列化。
|
||||
|
||||
形象点来说,序列化的字节数组就像是从宜家家具超市购买的待组装板材,对象值则是将板材根据说明书组装而成的各种桌椅板凳。显而易见,对象值这种存储形式的优点是拿来即用、所见即所得,缺点是所需的存储空间较大、占地儿。相比之下,序列化字节数组的空间利用率要高得多。不过要是你着急访问里面的数据对象,还得进行反序列化,有点麻烦。
|
||||
|
||||
由此可见,对象值和字节数组二者之间存在着一种博弈关系,也就是所谓的“以空间换时间”和“以时间换空间”,两者之间该如何取舍,我们还是要看具体的应用场景。核心原则就是:如果想省地儿,你可以优先考虑字节数组;如果想以最快的速度访问对象,还是对象值更直接一些。 不过,这种选择的烦恼只存在于 MemoryStore 之中,而DiskStore只能存储序列化后的字节数组,毕竟,凡是落盘的东西,都需要先进行序列化。
|
||||
|
||||
透过RDD缓存看MemoryStore
|
||||
|
||||
知道了存储系统有哪些核心的组件,下面,我们接着来说说MemoryStore和DiskStore这两个组件是怎么管理内存和磁盘数据的。
|
||||
|
||||
刚刚我们提到,MemoryStore同时支持存储对象值和字节数组这两种不同的数据形式,并且统一采用MemoryEntry数据抽象对它们进行封装。
|
||||
|
||||
MemoryEntry有两个实现类:DeserializedMemoryEntry和SerializedMemoryEntry,分别用于封装原始对象值和序列化之后的字节数组。DeserializedMemoryEntry用 Array[T]来存储对象值序列,其中T是对象类型,而SerializedMemoryEntry使用ByteBuffer来存储序列化后的字节序列。
|
||||
|
||||
得益于MemoryEntry对于对象值和字节数组的统一封装,MemoryStore能够借助一种高效的数据结构来统一存储与访问数据块:LinkedHashMap[BlockId, MemoryEntry],即 Key 为BlockId,Value 是MemoryEntry的链式哈希字典。在这个字典中,一个Block对应一个MemoryEntry。显然,这里的MemoryEntry既可以是DeserializedMemoryEntry,也可以是 SerializedMemoryEntry。有了这个字典,我们通过BlockId即可方便地查找和定位MemoryEntry,实现数据块的快速存取。
|
||||
|
||||
概念这么多,命名也这么相似,是不是看起来就让人“头大”?别着急,接下来,咱们以RDD缓存为例,来看看存储系统是如何利用这些数据结构,把RDD封装的数据实体缓存到内存里去。
|
||||
|
||||
在RDD的语境下,我们往往用数据分片(Partitions/Splits)来表示一份分布式数据,但在存储系统的语境下,我们经常会用数据块(Blocks)来表示数据存储的基本单元。在逻辑关系上,RDD的数据分片与存储系统的Block一一对应,也就是说一个RDD数据分片会被物化成一个内存或磁盘上的Block。
|
||||
|
||||
因此,如果用一句话来概括缓存RDD的过程,就是将RDD计算数据的迭代器(Iterator)进行物化的过程,流程如下所示。具体来说,可以分成三步走。
|
||||
|
||||
|
||||
|
||||
既然要把数据内容缓存下来,自然得先把RDD的迭代器展开成实实在在的数据值才行。因此,第一步就是通过调用putIteratorAsValues或是putIteratorAsBytes方法,把RDD迭代器展开为数据值,然后把这些数据值暂存到一个叫做ValuesHolder的数据结构里。这一步,我们通常把它叫做“Unroll”。
|
||||
|
||||
第二步,为了节省内存开销,我们可以在存储数据值的ValuesHolder上直接调用toArray或是toByteBuffer操作,把ValuesHolder转换为MemoryEntry数据结构。注意啦,这一步的转换不涉及内存拷贝,也不产生额外的内存开销,因此Spark官方把这一步叫做“从Unroll memory到Storage memory的Transfer(转移)”。
|
||||
|
||||
第三步,这些包含RDD数据值的MemoryEntry和与之对应的BlockId,会被一起存入Key 为BlockId、Value 是MemoryEntry引用的链式哈希字典中。因此,LinkedHashMap[BlockId, MemoryEntry]缓存的是关于数据存储的元数据,MemoryEntry才是真正保存RDD数据实体的存储单元。换句话说,大面积占用内存的不是哈希字典,而是一个又一个的MemoryEntry。
|
||||
|
||||
总的来说,RDD数据分片、Block和MemoryEntry三者之间是一一对应的,当所有的RDD数据分片都物化为MemoryEntry,并且所有的(Block ID, MemoryEntry)对都记录到LinkedHashMap字典之后,RDD就完成了数据缓存到内存的过程。
|
||||
|
||||
这里,你可能会问:“如果内存空间不足以容纳整个RDD怎么办?”很简单,强行把大RDD塞进有限的内存空间肯定不是明智之举,所以Spark会按照LRU策略逐一清除字典中最近、最久未使用的Block,以及其对应的MemoryEntry。相比频繁的展开、物化、换页所带来的性能开销,缓存下来的部分数据对于RDD高效访问的贡献可以说微乎其微。
|
||||
|
||||
透过Shuffle看DiskStore
|
||||
|
||||
相比MemoryStore,DiskStore就相对简单很多,因为它并不需要那么多的中间数据结构才能完成数据的存取。DiskStore中数据的存取本质上就是字节序列与磁盘文件之间的转换,它通过putBytes方法把字节序列存入磁盘文件,再通过getBytes方法将文件内容转换为数据块。
|
||||
|
||||
不过,要想完成两者之间的转换,像数据块与文件的对应关系、文件路径等等这些元数据是必不可少的。MemoryStore采用链式哈希字典来维护类似的元数据,DiskStore这个狡猾的家伙并没有亲自维护这些元数据,而是请了DiskBlockManager这个给力的帮手。
|
||||
|
||||
DiskBlockManager的主要职责就是,记录逻辑数据块Block与磁盘文件系统中物理文件的对应关系,每个Block都对应一个磁盘文件。同理,每个磁盘文件都有一个与之对应的Block ID,这就好比货架上的每一件货物都有唯一的 ID 标识。
|
||||
|
||||
DiskBlockManager在初始化的时候,首先根据配置项spark.local.dir在磁盘的相应位置创建文件目录。然后,在spark.local.dir指定的所有目录下分别创建子目录,子目录的个数由配置项spark.diskStore.subDirectories控制,它默认是64。所有这些目录均用于存储通过DiskStore进行物化的数据文件,如RDD缓存文件、Shuffle中间结果文件等。
|
||||
|
||||
|
||||
|
||||
接下来,我们再以Shuffle中间文件为例,来说说DiskStore与DiskBlockManager的交互过程。
|
||||
|
||||
Spark默认采用SortShuffleManager来管理Stages间的数据分发,在Shuffle write过程中,有3类结果文件:temp_shuffle_XXX、shuffle_XXX.data和shuffle_XXX.index。Data文件存储分区数据,它是由temp文件合并而来的,而index文件记录data文件内不同分区的偏移地址。Shuffle中间文件具体指的就是data文件和index文件,temp文件作为暂存盘文件最终会被删除。
|
||||
|
||||
在Shuffle write的不同阶段,Shuffle manager通过BlockManager调用DiskStore的putBytes方法将数据块写入文件。文件由DiskBlockManager创建,文件名就是putBytes方法中的Block ID,这些文件会以“temp_shuffle”或“shuffle”开头,保存在spark.local.dir目录下的子目录里。
|
||||
|
||||
在Shuffle read阶段,Shuffle manager再次通过BlockManager调用DiskStore的getBytes方法,读取data文件和index文件,将文件内容转化为数据块,最终这些数据块会通过网络分发到Reducer端进行聚合计算。
|
||||
|
||||
小结
|
||||
|
||||
掌握存储系统是我们进行Spark性能调优的关键一步,我们可以分为三步来掌握。
|
||||
|
||||
第一步,我们要明确存储系统的服务对象,分别是RDD缓存、Shuffle和广播变量。
|
||||
|
||||
|
||||
RDD缓存:一些计算成本和访问频率较高的RDD,可以以缓存的形式物化到内存或磁盘中。这样一来,既可以避免DAG频繁回溯的计算开销,也能有效提升端到端的执行性能
|
||||
Shuffle:Shuffle中间文件的位置信息,都是由Spark存储系统保存并维护的,没有存储系统,Shuffle是玩不转的
|
||||
广播变量:利用存储系统,广播变量可以在Executors进程范畴内保存全量数据,让任务以Process local的本地性级别,来共享广播变量中携带的全量数据。
|
||||
|
||||
|
||||
第二步,我们要搞清楚存储系统的两个重要组件:MemoryStore和DiskStore。其中,MemoryStore用来管理数据在内存中的存取,DiskStore用来管理数据在磁盘中的存取。
|
||||
|
||||
对于存储系统的3个服务对象来说,广播变量由MemoryStore管理,Shuffle中间文件的落盘和访问要经由DiskStore,而RDD缓存因为会同时支持内存缓存和磁盘缓存两种模式,所以两种组件都有可能用到。
|
||||
|
||||
最后,我们要理解MemoryStore和DiskStore的工作原理。
|
||||
|
||||
MemoryStore支持对象值和字节数组,统一采用MemoryEntry数据抽象对它们进行封装。对象值和字节数组二者之间存在着一种博弈关系,所谓的“以空间换时间”和“以时间换空间”,两者的取舍还要看具体的应用场景。
|
||||
|
||||
DiskStore则利用DiskBlockManager维护的数据块与磁盘文件的对应关系,来完成字节序列与磁盘文件之间的转换。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
结合RDD数据存储到MemoryStore的过程,你能推演出通过MemoryStore通过getValues/getBytes方法去访问RDD缓存内容的过程吗?
|
||||
参考RDD缓存存储的过程,你能推演出广播变量存入MemoryStore的流程吗?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和讨论,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
169
专栏/Spark性能调优实战/07内存管理基础:Spark如何高效利用有限的内存空间?.md
Normal file
169
专栏/Spark性能调优实战/07内存管理基础:Spark如何高效利用有限的内存空间?.md
Normal file
@ -0,0 +1,169 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 内存管理基础:Spark如何高效利用有限的内存空间?
|
||||
你好,我是吴磊。
|
||||
|
||||
对于Spark这样的内存计算引擎来说,内存的管理与利用至关重要。业务应用只有充分利用内存,才能让执行性能达到最优。
|
||||
|
||||
那么,你知道Spark是如何使用内存的吗?不同的内存区域之间的关系是什么,它们又是如何划分的?今天这一讲,我就结合一个有趣的小故事,来和你深入探讨一下Spark内存管理的基础知识。
|
||||
|
||||
内存的管理模式
|
||||
|
||||
在管理方式上,Spark会区分堆内内存(On-heap Memory)和堆外内存(Off-heap Memory)。这里的“堆”指的是JVM Heap,因此堆内内存实际上就是Executor JVM的堆内存;堆外内存指的是通过Java Unsafe API,像C++那样直接从操作系统中申请和释放内存空间。
|
||||
|
||||
其中,堆内内存的申请与释放统一由JVM代劳。比如说,Spark需要内存来实例化对象,JVM负责从堆内分配空间并创建对象,然后把对象的引用返回,最后由Spark保存引用,同时记录内存消耗。反过来也是一样,Spark申请删除对象会同时记录可用内存,JVM负责把这样的对象标记为“待删除”,然后再通过垃圾回收(Garbage Collection,GC)机制将对象清除并真正释放内存。
|
||||
|
||||
|
||||
|
||||
在这样的管理模式下,Spark对内存的释放是有延迟的,因此,当Spark尝试估算当前可用内存时,很有可能会高估堆内的可用内存空间。
|
||||
|
||||
堆外内存则不同,Spark通过调用Unsafe的allocateMemory和freeMemory方法直接在操作系统内存中申请、释放内存空间,这听上去是不是和C++管理内存的方式很像呢?这样的内存管理方式自然不再需要垃圾回收机制,也就免去了它带来的频繁扫描和回收引入的性能开销。更重要的是,空间的申请与释放可以精确计算,因此Spark对堆外可用内存的估算会更精确,对内存的利用率也更有把握。
|
||||
|
||||
为了帮助你更轻松地理解这个过程,我来给你讲一个小故事。
|
||||
|
||||
地主招租(上):土地划分
|
||||
|
||||
很久以前,燕山脚下有一个小村庄,村里有个地主,名叫黄四郎,四郎家有良田千顷,方圆数百里都是他的田地。黄四郎养尊处优,自然不会亲自下地种田,不过这么多田地也不能就这么荒着。于是,他想了个办法,既不用亲自动手又能日进斗金:收租子!
|
||||
|
||||
黄四郎虽然好吃懒做,但在管理上还是相当有一套的,他把田地划分为两块,一块叫“托管田”,另一块叫“自管田”。
|
||||
|
||||
我们知道,庄稼丰收之后,田地需要翻土、整平、晾晒,来年才能种下一茬庄稼。那么,托管田指的就是丰收之后,由黄四郎派专人帮你搞定翻土、整平这些琐事,不用你操心。相应的,自管田的意思就是庄稼你自己种,秋收之后的田地也得你自己收拾。
|
||||
|
||||
毫无疑问,对租户来说托管田更省心一些,自管田更麻烦。当然了,相比自管田,托管田的租金自然更高。
|
||||
|
||||
|
||||
|
||||
那么,这个故事中黄四郎的托管田就是内存管理中的堆内内存,自管田类比的则是堆外内存,田地的翻土、整平这些操作实际上就是JVM中的GC。这样类比起来是不是更好理解了呢?
|
||||
|
||||
内存区域的划分
|
||||
|
||||
故事先讲到这儿,让我们暂时先回到Spark的内存管理上。现在,我们知道了Spark内存管理有堆内和堆外两种模式,那Spark又是怎么划分内存区域的呢?
|
||||
|
||||
我们先来说说堆外内存。Spark把堆外内存划分为两块区域:一块用于执行分布式任务,如Shuffle、Sort和Aggregate等操作,这部分内存叫做Execution Memory;一块用于缓存RDD和广播变量等数据,它被称为Storage Memory。
|
||||
|
||||
堆内内存的划分方式和堆外差不多,Spark也会划分出用于执行和缓存的两份内存空间。不仅如此,Spark在堆内还会划分出一片叫做User Memory的内存空间,它用于存储开发者自定义数据结构。
|
||||
|
||||
|
||||
|
||||
除此之外,Spark在堆内还会预留出一小部分内存空间,叫做Reserved Memory,它被用来存储各种Spark内部对象,例如存储系统中的BlockManager、DiskBlockManager等等。
|
||||
|
||||
对于性能调优来说,我们在前三块内存的利用率上有比较大的发挥空间,因为业务应用主要消耗的就是它们,也即Execution memory、Storage memory和User memory。而预留内存我们却动不得,因为这块内存仅服务于Spark内部对象,业务应用不会染指。
|
||||
|
||||
好了,不同内存区域的划分与计算,我也把它们总结到了下面的表格中,方便你随时查阅。
|
||||
|
||||
|
||||
|
||||
执行与缓存内存
|
||||
|
||||
在所有的内存区域中,最重要的无疑是缓存内存和执行内存,而内存计算的两层含义也就是数据集缓存和Stage内的流水线计算,对应的就是Storage Memory和Execution Memory。
|
||||
|
||||
在Spark 1.6版本之前,Execution Memory和Storage Memory内存区域的空间划分是静态的,一旦空间划分完毕,不同内存区域的用途就固定了。也就是说,即便你没有缓存任何RDD或是广播变量,Storage Memory区域的空闲内存也不能用来执行Shuffle中的映射、排序或聚合等操作,因此宝贵的内存资源就被这么白白地浪费掉了。
|
||||
|
||||
考虑到静态内存划分潜在的空间浪费,在1.6版本之后,Spark推出了统一内存管理模式。统一内存管理指的是Execution Memory和Storage Memory之间可以相互转化,尽管两个区域由配置项spark.memory.storageFraction划定了初始大小,但在运行时,结合任务负载的实际情况,Storage Memory区域可能被用于任务执行(如Shuffle),Execution Memory区域也有可能存储RDD缓存。
|
||||
|
||||
但是,我们都知道,执行任务相比缓存任务,在内存抢占上有着更高的优先级。那你有没有想过这是为什么呢?接下来,就让我们带着“打破砂锅问到底”的精神,去探索其中更深层次的原因。
|
||||
|
||||
首先,执行任务主要分为两类:一类是Shuffle Map阶段的数据转换、映射、排序、聚合、归并等操作;另一类是Shuffle Reduce阶段的数据排序和聚合操作。它们所涉及的数据结构,都需要消耗执行内存。
|
||||
|
||||
我们可以先假设,执行任务与缓存任务在内存抢占上遵循“公正、公平和公开”的三原则。也就是说,不论谁抢占了对方的内存,当对方有需要时都会立即释放。比如说,刚开始双方的预设比例是五五开,但因为缓存任务在应用中比较靠后的位置,所以执行任务先占据了80%的内存空间,当缓存任务追赶上来之后,执行任务就需要释放30%的内存空间还给缓存任务。
|
||||
|
||||
这种情况下会发生什么?假设集群范围内总共有80个CPU,也就是集群在任意时刻的并行计算能力是80个分布式任务。在抢占了80%内存的情况下,80个CPU可以充分利用,每个CPU的计算负载都是比较饱满的,计算完一个任务,再去计算下一个任务。
|
||||
|
||||
但是,由于有30%的内存要归还给缓存任务,这意味着有30个并行的执行任务没有内存可用。也就是说会有30个CPU一直处在I/O wait的状态,没法干活!宝贵的CPU计算资源就这么白白地浪费掉了,简直是暴殄天物。
|
||||
|
||||
因此,相比于缓存任务,执行任务的抢占优先级一定要更高。说了这么多,我们为什么要弄清楚其中的原因呢?我认为,只有弄清楚抢占优先级的背后逻辑,我们才能理解为什么要同时调节CPU和内存的相关配置,也才有可能做到不同硬件资源之间的协同与平衡,这也是我们进行性能调优要达到的最终效果。
|
||||
|
||||
不过,即使执行任务的抢占优先级更高,但它们在抢占内存的时候一定也要遵循某些规则。那么,这些规则具体是什么呢?下面,咱们就接着以地主招租的故事为例,来说说Execution memory和Storage memory之间有哪些有趣的规则。
|
||||
|
||||
地主招租(下):租地协议
|
||||
|
||||
黄四郎招租的告示贴出去没多久,村子里就有两个年富力强的小伙子来租种田地。一个叫黄小乙,是黄四郎的远房亲戚,前不久来投奔黄四郎。另一个叫张麻子,虽是八辈贫农,小日子过得也算是蒸蒸日上。张麻子打算把田地租过来种些小麦、玉米这样的庄稼。黄小乙就不这么想,这小子挺有商业头脑,他把田地租过来准备种棉花、咖啡这类经济作物。
|
||||
|
||||
两个人摩拳擦掌都想干出一番事业,恨不得把黄四郎的地全都包圆!地不愁租,黄四郎自然是满心欢喜,但烦恼也接踵而至:“既要照顾小乙这孩子,又不能打击麻子的积极性,得想个万全之策”。
|
||||
|
||||
于是,他眼珠一转,计上心来:“按理说呢,咱们丈量土地之后,应该在你们中间划一道实线,好区分田地的归属权。不过呢,毕竟麻子你是本村的,小乙远道而来,远来即是客嘛!咱们对小乙还是得多少照顾着点”。张麻子心生不悦:“怎么照顾?”
|
||||
|
||||
黄四郎接着说:“很简单,把实线改为虚线,多劳者多得。原本呢,你们应该在分界线划定的那片田地里各自劳作。不过呢,你们二人的进度各不相同嘛,所以,勤奋的人,自己的田地种满了之后,可以跨过分界线,去占用对方还在空着的田地。”
|
||||
|
||||
黄小乙不解地问:“四舅,这不是比谁种得快吗?也没对我特殊照顾啊!”张麻子眉间也拧了个疙瘩:“如果种得慢的人后来居上,想要把被占的田地收回去,到时候该怎么办呢?”
|
||||
|
||||
黄四郎得意道:“刚才说了,咱们多多照顾小乙。所以如果麻子勤快、干活也快,先占了小乙的地,种上了小麦、玉米,小乙后来居上,想要收回自己的地,那么没说的,麻子得把多占的地让出来。不管庄稼熟没熟,麻子都得把地铲平,还给人家小乙种棉花、咖啡”。
|
||||
|
||||
|
||||
|
||||
黄四郎偷眼看了看两人的反应,继续说:“反过来,如果小乙更勤快,先占了麻子的地,麻子后来居上,想要收回,这个时候,咱们就得多照顾照顾小乙。小乙有权继续占用麻子的地,直到地上种的棉花、咖啡都丰收了,再把多占的地让出来。你们二位看怎么样?”
|
||||
|
||||
黄小乙听了大喜。张麻子虽然心里不爽,但也清楚黄四郎和黄小乙之间的亲戚关系,也不好再多说什么,心想:“反正我勤快些,先把地种满也就是了”。于是,三方击掌为誓,就此达成协议。
|
||||
|
||||
好啦,地主招租的故事到这里就讲完了。不难发现,黄小乙的地类比的是Execution Memory,张麻子的地其实就是Storage Memory。他们之间的协议其实就是Execution Memory和Storage Memory之间的抢占规则,一共可以总结为3条:
|
||||
|
||||
|
||||
如果对方的内存空间有空闲,双方就都可以抢占;
|
||||
对于RDD缓存任务抢占的执行内存,当执行任务有内存需要时,RDD缓存任务必须立即归还抢占的内存,涉及的RDD缓存数据要么落盘、要么清除;
|
||||
对于分布式计算任务抢占的Storage Memory内存空间,即便RDD缓存任务有收回内存的需要,也要等到任务执行完毕才能释放。
|
||||
|
||||
|
||||
同时,我也把这个例子中的关键内容和Spark之间的对应关系总结在了下面,希望能帮助你加深印象。
|
||||
|
||||
|
||||
|
||||
从代码看内存消耗
|
||||
|
||||
说完了理论,接下来,咱们再从实战出发,用一个小例子来直观地感受一下,应用中代码的不同部分都消耗了哪些内存区域。
|
||||
|
||||
示例代码很简单,目的是读取words.csv文件,然后对其中指定的单词进行统计计数。
|
||||
|
||||
val dict: List[String] = List(“spark”, “scala”)
|
||||
val words: RDD[String] = sparkContext.textFile(“~/words.csv”)
|
||||
val keywords: RDD[String] = words.filter(word => dict.contains(word))
|
||||
keywords.cache
|
||||
keywords.count
|
||||
keywords.map((_, 1)).reduceByKey(_ + _).collect
|
||||
|
||||
|
||||
整个代码片段包含6行代码,咱们从上到下逐一分析。
|
||||
|
||||
首先,第一行定义了dict字典,这个字典在Driver端生成,它在后续的RDD调用中会随着任务一起分发到Executor端。第二行读取words.csv文件并生成RDD words。第三行很关键,用dict字典对words进行过滤,此时dict已分发到Executor端,Executor将其存储在堆内存中,用于对words数据分片中的字符串进行过滤。Dict字典属于开发者自定义数据结构,因此,Executor将其存储在User Memory区域。
|
||||
|
||||
接着,第四行和第五行用cache和count对keywords RDD进行缓存,以备后续频繁访问,分布式数据集的缓存占用的正是Storage Memory内存区域。在最后一行代码中,我们在keywords上调用reduceByKey对单词分别计数。我们知道,reduceByKey算子会引入Shuffle,而Shuffle过程中所涉及的内部数据结构,如映射、排序、聚合等操作所仰仗的Buffer、Array和HashMap,都会消耗Execution Memory区域中的内存。
|
||||
|
||||
不同代码与其消耗的内存区域,我都整理到了下面的表格中,方便你查看。
|
||||
|
||||
|
||||
|
||||
小结
|
||||
|
||||
深入理解内存管理的机制,有助于我们充分利用应用的内存,提升其执行性能。今天,我们重点学习了内存管理的基础知识。
|
||||
|
||||
首先是内存的管理方式。Spark区分堆内内存和堆外内存:对于堆外内存来说,Spark通过调用Java Unsafe的allocateMemory和freeMemory方法,直接在操作系统内存中申请、释放内存空间,管理成本较高;对于堆内内存来说,无需Spark亲自操刀而是由JVM代理。但频繁的JVM GC对执行性能来说是一大隐患。另外,Spark对堆内内存占用的预估往往不够精确,高估可用内存往往会为OOM埋下隐患。
|
||||
|
||||
其次是统一内存管理,以及Execution Memory和Storage Memory之间的抢占规则。它们就像黄四郎招租故事中黄小乙和张麻子的田地,抢占规则就像他们之间的占地协议,主要可以分为3条:
|
||||
|
||||
|
||||
如果对方的内存空间有空闲,那么双方都可以抢占;
|
||||
对RDD缓存任务抢占的执行内存,当执行任务有内存需要时,RDD缓存任务必须立即归还抢占的内存,其中涉及的RDD缓存数据要么落盘、要么清除;
|
||||
对分布式计算任务抢占的Storage Memory内存空间,即便RDD缓存任务有收回内存的需要,也要等到任务执行完毕才能释放。
|
||||
|
||||
|
||||
最后是不同代码对不同内存区域的消耗。内存区域分为Reserved Memory、User Memory、Execution Memory和Storage Memory。其中,Reserved Memory用于存储Spark内部对象,User Memory用于存储用户自定义的数据结构,Execution Memory用于分布式任务执行,而Storage Memory则用来容纳RDD缓存和广播变量。
|
||||
|
||||
好了,这些就是内存管理的基础知识。当然了,与内存相关的话题还有很多,比如内存溢出、RDD缓存、内存利用率,以及执行内存的并行计算等等。在性能调优篇,我还会继续从内存视角出发,去和你探讨这些话题。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
你知道启用off-heap之后,Spark有哪些计算环节可以利用到堆外内存?你能列举出一些例子吗?
|
||||
相比堆内内存,为什么在堆外内存中,Spark对于内存占用量的预估更准确?
|
||||
结合我在下面给定的配置参数,你能分别计算不同内存区域(Reserved、User、Execution、Storage)的具体大小吗?
|
||||
|
||||
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
236
专栏/Spark性能调优实战/08应用开发三原则:如何拓展自己的开发边界?.md
Normal file
236
专栏/Spark性能调优实战/08应用开发三原则:如何拓展自己的开发边界?.md
Normal file
@ -0,0 +1,236 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 应用开发三原则:如何拓展自己的开发边界?
|
||||
你好,我是吴磊。
|
||||
|
||||
从今天开始,我们就进入通用性能调优篇的学习了。这一篇,我们会从基本的开发原则、配置项、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官方对比实验显示,开启Tungsten前后,应用程序的执行性能可以提升16倍!因此你看,哪怕咱们什么都不做,只要开发的业务应用能够利用到Tungsten提供的种种特性,Spark就能让应用的执行性能有所保障。对于咱们开发者来说,这么大的便宜,干吗不占呢?
|
||||
|
||||
如何利用AQE的优势?
|
||||
|
||||
除了钨丝计划,我们最应该关注Spark 3.0版本发布的新特性——AQE。AQE(Adaptive Query Execution)全称“自适应查询执行”,它可以在Spark SQL优化的过程中动态地调整执行计划。
|
||||
|
||||
我们知道,Spark SQL的优化过程可以大致分为语法分析、语义解析、逻辑计划和物理计划这几个环节。在3.0之前的版本中,Spark仅仅在编译时基于规则和策略遍历AST查询语法树,来优化逻辑计划,一旦基于最佳逻辑计划选定物理执行计划,Spark就会严格遵照物理计划的步骤去机械地执行计算。
|
||||
|
||||
而AQE可以让Spark在运行时的不同阶段,结合实时的运行时状态,周期性地动态调整前面的逻辑计划,然后根据再优化的逻辑计划,重新选定最优的物理计划,从而调整运行时后续阶段的执行方式。
|
||||
|
||||
|
||||
|
||||
你可能会问:“听上去这么厉害,那AQE具体都有哪些改进呢?”AQE主要带来了3个方面的改进,分别是自动分区合并、数据倾斜和Join策略调整。我们一一来看。
|
||||
|
||||
首先,自动分区合并很好理解,我们拿Filter与Coalesce来举例。分布式数据集过滤之后,难免有些数据分片的内容所剩无几,甚至为空,所以为了避免多余的调度开销,我们经常会用Coalesce去做手工的分区合并。
|
||||
|
||||
另外,在Shuffle的计算过程中,同样也存在分区合并的需求。
|
||||
|
||||
|
||||
|
||||
以上图为例,我们可以看到,数据表原本有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策略调整,需要把相关的配置项打开,具体的操作如下表所示。
|
||||
|
||||
|
||||
|
||||
总的来说,通过钨丝计划和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,你自然能够避开很多潜在的性能陷阱。
|
||||
|
||||
最后,在日常的开发工作中,我们要谨防单机思维模式,摆脱单机思维模式有利于培养我们以性能为导向的开发习惯。我们可以在开发应用的过程中运用想象力,在脑海中构造一个土豆工坊。把每一个分布式数据集都安插到工坊的流水线上。在尝试获取数据集结果的时候,结合我们在原理篇讲解的调度系统、存储系统和内存管理,去进一步想象要得到计算结果,整个工坊都需要做哪些事情,会带来哪些开销。
|
||||
|
||||
最后的最后,我们再来说说归纳这件事的意义和价值。我们之所以把各种开发技巧归纳为开发原则,一方面是遵循这些原则,你能在不知不觉中避开很多性能上的坑。但更重要的是,从这些原则出发,向外推演,我们往往能发现更多的开发技巧,从而能拓展自己的“常规操作”边界,做到举一反三,真正避免“调优思路枯竭”的窘境。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
针对我们今天讲的3个原则,你还能想到哪些案例?
|
||||
除了这3个原则外,你觉得是否还有其他原则需要开发者特别留意?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
202
专栏/Spark性能调优实战/09调优一筹莫展,配置项速查手册让你事半功倍!(上).md
Normal file
202
专栏/Spark性能调优实战/09调优一筹莫展,配置项速查手册让你事半功倍!(上).md
Normal file
@ -0,0 +1,202 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 调优一筹莫展,配置项速查手册让你事半功倍!(上)
|
||||
你好,我是吴磊。
|
||||
|
||||
对于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设置有关配置项的含义、区别与作用就行了。
|
||||
|
||||
|
||||
|
||||
哪些配置项与内存设置有关?
|
||||
|
||||
说完CPU,咱们接着说说与内存管理有关的配置项。我们知道,在管理模式上,Spark分为堆内内存与堆外内存。
|
||||
|
||||
堆外内存又分为两个区域,Execution Memory和Storage Memory。要想要启用堆外内存,我们得先把参数spark.memory.offHeap.enabled置为true,然后用spark.memory.offHeap.size指定堆外内存大小。堆内内存也分了四个区域,也就是Reserved Memory、User Memory、Execution Memory和Storage Memory。
|
||||
|
||||
内存的基础配置项主要有5个,它们的含义如下表所示:
|
||||
|
||||
|
||||
|
||||
简单来说,这些配置项决定了我们刚才说的这些区域的大小,这很好理解。工具有了,但很多同学在真正设置内存区域大小的时候还会有各种各样的疑惑,比如说:
|
||||
|
||||
|
||||
内存空间是有限的,该把多少内存划分给堆内,又该把多少内存留给堆外呢?
|
||||
在堆内内存里,该怎么平衡User Memory和Spark用于计算的内存空间?
|
||||
在统一内存管理模式下,该如何平衡Execution Memory和Storage Memory?
|
||||
|
||||
|
||||
别着急,接下来,咱们一个一个来解决。
|
||||
|
||||
堆外与堆内的平衡
|
||||
|
||||
相比JVM堆内内存,off heap堆外内存有很多优势,如更精确的内存占用统计和不需要垃圾回收机制,以及不需要序列化与反序列化。你可能会说:“既然堆外内存这么厉害,那我们干脆把所有内存都划分给它不就得了?”先别急着下结论,我们先一起来看一个例子。
|
||||
|
||||
用户表1记录着用户数据,每个数据条目包含4个字段,整型的用户ID、String类型的姓名、整型的年龄和Char类型的性别。如果现在要求你用字节数组来存储每一条用户记录,你该怎么办呢?
|
||||
|
||||
|
||||
|
||||
我们一起来做一下。首先,除姓名外其它3个字段都是定长数据类型,因此可以直接安插到字节数组中。对于变长数据类型如String,由于我们事先并不知道每个用户的名字到底有多长,因此,为了把name字段也用字节数组的形式存储,我们只能曲线救国:先记录name字段的在整个字节数组内的偏移量,再记录它的长度,最后把完整的name字符串安插在字节数组的末尾,如下图所示。
|
||||
|
||||
|
||||
|
||||
尽管存储String类型的name字段麻烦一些,但我们总算成功地用字节数组容纳了每一条用户记录。OK,大功告成!
|
||||
|
||||
你可能会问:“做这个小实验的目的是啥呢?”事实上,Spark开辟的堆外内存就是以这样的方式来存储应用数据的。正是基于这种紧凑的二进制格式,相比JVM堆内内存,Spark通过Java Unsafe API在堆外内存中的管理,才会有那么多的优势。
|
||||
|
||||
不过,成也萧何败也萧何,字节数组自身的局限性也很难突破。比如说,如果用户表1新增了兴趣列表字段,类型为List[String],如用户表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、内存和磁盘这三个方面的性能配置项都有什么,以及它们的含义。因此,我把硬件资源类配置项的含义都汇总在了一个表格中,方便你随时查看。有了这份手册,在针对硬件资源进行配置项调优时,你就能够做到不重不漏。
|
||||
|
||||
|
||||
|
||||
第二步,重点理解这些配置项的作用,以及可以解决的问题。
|
||||
|
||||
首先,对于CPU类配置项,我们要重点理解并行度与并行计算任务数的区别。并行度从数据的角度出发,明确了数据划分的粒度,并行度越高,数据粒度越细,数据越分散,CPU资源利用越充分,但同时要提防数据粒度过细导致的调度系统开销。
|
||||
|
||||
并行计算任务数则不同,它从计算的角度出发,强调了分布式集群在任一时刻并行处理的能力和容量。并行度与并行计算任务数之间互相影响、相互制约。
|
||||
|
||||
其次,对于内存类配置项,我们要知道怎么设置它们来平衡不同内存区域的方法。这里我们主要搞清楚3个问题就可以了:
|
||||
|
||||
|
||||
在平衡堆外与堆内内存的时候,我们要重点考察数据模式。如果数据模式比较扁平,而且定长字段较多,应该更多地使用堆外内存。相反地,如果数据模式比较复杂,应该更多地利用堆内内存
|
||||
在平衡可支配内存和User memory的时候,我们要重点考察应用中自定义的数据结构。如果数据结构较多,应该保留足够的User memory空间。相反地,如果数据结构较少,应该让Spark享有更多的可用内存资源
|
||||
在平衡Execution memory与Storage memory的时候,如果RDD缓存是刚需,我们就把spark.memory.storageFraction调大,并且在应用中优先把缓存灌满,再把计算逻辑应用在缓存数据之上。除此之外,我们还可以同时调整spark.rdd.compress和spark.memory.storageFraction来缓和Full GC的冲击
|
||||
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
并行度设置过大会带来哪些弊端?
|
||||
在Shuffle的计算过程中,有哪些Spark内置的数据结构可以充分利用堆外内存资源?
|
||||
堆外与堆内的取舍,你还能想到其他的制约因素吗?
|
||||
如果内存资源足够丰富,有哪些方式可以开辟内存文件系统,用于配置spark.local.dir参数?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,也欢迎你把这份硬件资源配置项手册分享给更多的朋友,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
144
专栏/Spark性能调优实战/10调优一筹莫展,配置项速查手册让你事半功倍!(下).md
Normal file
144
专栏/Spark性能调优实战/10调优一筹莫展,配置项速查手册让你事半功倍!(下).md
Normal file
@ -0,0 +1,144 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 调优一筹莫展,配置项速查手册让你事半功倍!(下)
|
||||
你好,我是吴磊。
|
||||
|
||||
上一讲,我们讲了硬件资源类的配置项。这一讲,我们继续说说Shuffle类和Spark SQL大类都有哪些配置项,它们的含义和作用,以及它们能解决的问题。同时,和上一讲一样,我们今天讲到的配置项也全部会围绕Executors展开。
|
||||
|
||||
Shuffle类配置项
|
||||
|
||||
首先,我们来说说Shuffle类。纵观Spark官网的Configuration页面,你会发现能调节Shuffle执行性能的配置项真是寥寥无几。其实这也很好理解,因为一旦Shuffle成为应用中不可或缺的一环,想要优化Shuffle本身的性能,我们能做的微乎其微。
|
||||
|
||||
不过,我们也不是完全束手无策。我们知道,Shuffle的计算过程分为Map和Reduce这两个阶段。其中,Map阶段执行映射逻辑,并按照Reducer的分区规则,将中间数据写入到本地磁盘;Reduce阶段从各个节点下载数据分片,并根据需要实现聚合计算。
|
||||
|
||||
那么,我们就可以通过spark.shuffle.file.buffer和spark.reducer.maxSizeInFlight这两个配置项,来分别调节Map阶段和Reduce阶段读写缓冲区的大小。具体该怎么做呢?我们一一来看。
|
||||
|
||||
|
||||
|
||||
首先,在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场景。
|
||||
|
||||
|
||||
|
||||
自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页面中,Spark SQL下面的配置项还是蛮多的,其中对执行性能贡献最大的,当属AQE(Adaptive query execution,自适应查询引擎)引入的那3个特性了,也就是自动分区合并、自动数据倾斜处理和Join策略调整。因此,关于Spark SQL的配置项,咱们围绕着这3个特性去汇总。
|
||||
|
||||
首先我们要知道,AQE功能默认是禁用的,想要使用这些特性,我们需要先通过配置项spark.sql.adaptive.enabled来开启AQE,具体的操作如下:
|
||||
|
||||
|
||||
|
||||
因为这3个特性的原理我们在开发原则那一讲说过,这里我会先带你简单回顾一下,然后我们重点来讲,这些环节对应的配置项有哪些。
|
||||
|
||||
哪些配置项与自动分区合并有关?
|
||||
|
||||
分区合并的场景用一句概括就是,在Shuffle过程中,因为数据分布不均衡,导致Reduce阶段存在大量的小分区,这些小分区的数据量非常小,调度成本很高。
|
||||
|
||||
那么问题来了,AQE是如何判断某个分区是不是足够小,到底需不需要合并的呢?另外,既然是对多个分区进行合并,自然就存在一个收敛条件的问题,如果一直不停地合并下去,整个分布式数据集最终就会合并为一个超级大的分区。简单来说,就是:“分区合并从哪里开始,又到哪里结束呢?”
|
||||
|
||||
|
||||
|
||||
我们一起来看一下AQE分区合并的工作原理。如上图所示,对于所有的数据分区,无论大小,AQE按照分区编号从左到右进行扫描,边扫描边记录分区尺寸,当相邻分区的尺寸之和大于“目标尺寸”时,AQE就把这些扫描过的分区进行合并。然后,继续向右扫描,并采用同样的算法,按照目标尺寸合并剩余分区,直到所有分区都处理完毕。
|
||||
|
||||
总的来说就是,AQE事先并不判断哪些分区足够小,而是按照分区编号进行扫描,当扫描量超过“目标尺寸”时,就合并一次。我们发现,这个过程中的关键就是“目标尺寸”的确定,它的大小决定了合并之后分布式数据集的分散程度。
|
||||
|
||||
那么,“目标尺寸”由什么来决定的呢?Spark提供了两个配置项来共同决定分区合并的“目标尺寸”,它们分别是spark.sql.adaptive.advisoryPartitionSizeInBytes和spark.sql.adaptive.coalescePartitions.minPartitionNum。
|
||||
|
||||
|
||||
|
||||
其中,第一个参数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如何判定数据分区是否倾斜呢?它又是怎么把大分区拆分成多个小分区的?
|
||||
|
||||
|
||||
|
||||
首先,分区尺寸必须要大于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,Spark才会把这个分区最终判定为倾斜分区。假设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左右往往是个不错的选择。
|
||||
|
||||
|
||||
|
||||
不过,autoBroadcastJoinThreshold这个参数虽然好用,但是有两个让人头疼的短板。
|
||||
|
||||
一是可靠性较差。尽管开发者明确设置了广播阈值,而且小表数据量在阈值以内,但Spark对小表尺寸的误判时有发生,导致Broadcast Join降级失败。
|
||||
|
||||
二来,预先设置广播阈值是一种静态的优化机制,它没有办法在运行时动态对数据关联进行降级调整。一个典型的例子是,两张大表在逻辑优化阶段都不满足广播阈值,此时Spark SQL在物理计划阶段会选择Shuffle Joins。但在运行时期间,其中一张表在Filter操作之后,有可能出现剩余的数据量足够小,小到刚好可以降级为Broadcast Join。在这种情况下,静态优化机制就是无能为力的。
|
||||
|
||||
AQE很好地解决了这两个头疼的问题。首先,AQE的Join策略调整是一种动态优化机制,对于刚才的两张大表,AQE会在数据表完成过滤操作之后动态计算剩余数据量,当数据量满足广播条件时,AQE会重新调整逻辑执行计划,在新的逻辑计划中把Shuffle Joins降级为Broadcast Join。再者,运行时的数据量估算要比编译时准确得多,因此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个特性各自都有相对应的配置项,需要我们单独调整。
|
||||
|
||||
|
||||
AQE中的自动分区合并过程与我们预想的不太一样。QE事先并不判断哪些分区足够小,而是按照分区编号进行扫描,当扫描量超过“目标尺寸”时就合并一次。目标尺寸由advisoryPartitionSizeInBytes和coalescePartitions.minPartitionNum两个参数共同决定。
|
||||
|
||||
AQE能够自动处理Sort Merge Join场景中的数据倾斜问题。首先根据所有分区大小的中位数,以及放大倍数skewedPartitionFactor来检测倾斜分区,然后以advisoryPartitionSizeInBytes为粒度对倾斜分区进行拆分。
|
||||
|
||||
AQE动态Join策略调整可以在运行时将Shuffle Joins降级为Broadcast Join,同时,运行时的数据量估算要比编译时准确得多,因此相比静态优化会更可靠。不过,需要我们注意的是,Shuffle过后非空分区占比要小于nonEmptyPartitionRatioForBroadcastJoin才能触发Broadcast Join的降级优化。
|
||||
|
||||
|
||||
好啦,经过这两讲的学习,我们一起汇总出了Spark中与性能调优息息相关的所有配置项,为了方便你快速查阅,我把它们合并在了一张文稿的表格中,希望你能在工作中好好利用起来。
|
||||
|
||||
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
AQE的分区合并算法略显简单粗暴,如果让你来重新实现分区合并特性的话,你都有哪些思路呢?
|
||||
AQE中数据倾斜的处理机制,你认为有哪些潜在的隐患?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,也欢迎你把这份调优手册分享给你的朋友们,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
215
专栏/Spark性能调优实战/11为什么说Shuffle是一时无两的性能杀手?.md
Normal file
215
专栏/Spark性能调优实战/11为什么说Shuffle是一时无两的性能杀手?.md
Normal file
@ -0,0 +1,215 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 为什么说Shuffle是一时无两的性能杀手?
|
||||
你好,我是吴磊。
|
||||
|
||||
一提到Shuffle,你能想到什么?我想很多人的第一反应都是应用中最顽固、最难解决的性能瓶颈。
|
||||
|
||||
在之前的课程中,我们也不止一次地提到Shuffle,尤其是在开发原则那一讲,我还建议你遵循“能省则省、能拖则拖”的原则,在应用中尽量去避免Shuffle,如果受业务逻辑所限确实不能避免,就尽可能地把Shuffle往后拖。
|
||||
|
||||
那么,为什么我们一谈Shuffle就色变,提到它就避之唯恐不及呢?今天这一讲,我就通过实现一个仙女散花游戏的过程,来和你深入探讨Shuffle是如何工作的,说说它为什么是分布式应用一时无两的性能杀手。毕竟,只有了解Shuffle的工作原理,我们才能更好地避免它。
|
||||
|
||||
如何理解Shuffle?
|
||||
|
||||
假设,你的老板今天给你安排一个开发任务,让你用Spark去实现一个游戏需求。这个实现需求来自一个小故事:仙女散花。
|
||||
|
||||
很久以前,燕山脚下有个小村庄,村子里有所“七色光”小学,方圆百里的孩子都来这里上学。这一天,一年级2班的黄老师和班里的五个孩子正在做一个游戏,叫做“仙女散花”。
|
||||
|
||||
黄老师的背包里装满了五种不同颜色的花朵,五种颜色分别是红、橙、黄、紫、青。她把背包里的花朵随机地分发给五个小同学:小红、橙橙、黄小乙、阿紫和小青。花朵发完之后,每个同学分到的花朵数量差不多,颜色各有不同。
|
||||
|
||||
接着,黄老师开始宣布游戏规则:“你们要一起协作,在最短的时间内,把花朵按照颜色收集在一起。游戏完成的标志是,课桌上有5堆花朵,每堆花朵的颜色都是一样的。”
|
||||
|
||||
|
||||
|
||||
大家跃跃欲试,黄小乙说:“先别急,我们来制定个策略。先在前面摆上5张课桌,然后每个人需要做两件事情,先把自己手里的花朵按照颜色归类分为5堆,再把分好颜色的花朵,分别放到相应的课桌上!”于是,几个小同学按照黄小乙说的策略,不一会儿就完成了游戏。
|
||||
|
||||
事实上,仙女散花的游戏过程和Shuffle的工作流程大同小异。当然,Shuffle过程中涉及的步骤和环节,要比“仙女散花”复杂一些。
|
||||
|
||||
Shuffle的本意是“洗牌”,在分布式计算环境中,它有两个阶段。一般来说,前一个阶段叫做“Map阶段”,后一个阶段叫做“Reduce阶段”。当然,也有人把它们叫做Shuffle Write阶段和Shuffle Read阶段。
|
||||
|
||||
|
||||
|
||||
在仙女散花的游戏中,从老师分发花朵,到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阶段的并行度保持一致。
|
||||
|
||||
|
||||
|
||||
这样一来,我们就可以把问题进一步聚焦在,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理解为是黄老师分发给小红的花朵。
|
||||
|
||||
|
||||
|
||||
基于pairRDD的Key,也就是花朵的颜色,Map Task就可以计算每条数据记录在Reduce阶段的目标分区,目标分区也就是游戏中的课桌。在黄小乙制定的策略中,哪种花放到哪张桌子是大家事先商定好的,但在Spark中,每条数据记录应该分发到哪个目标分区,是由Key的哈希值决定的。
|
||||
|
||||
目标分区计算好之后,Map Task会把每条数据记录和它的目标分区,放到一个特殊的数据结构里,这个数据结构叫做“PartitionedPairBuffer”,它本质上就是一种数组形式的缓存结构。它是怎么存储数据记录的呢?
|
||||
|
||||
每条数据记录都会占用数组中相邻的两个元素空间,第一个元素是(目标分区,Key),第二个元素是Value。假设PartitionedPairBuffer的大小是4,也就是最多只能存储4条数据记录。那么,如果我们还以数据分区0为例,小红的前4枚花朵在PartitionedPairBuffer中的存储状态就会如下所示。
|
||||
|
||||
|
||||
|
||||
对我们来说,最理想的情况当然是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个输入源中的数据写入到数据文件和索引文件中去,如下图所示。
|
||||
|
||||
|
||||
|
||||
好了,到目前为止,我们用groupByKey实现了“仙女散花”的游戏,详细讲解了Map阶段生产中间文件的过程。虽然Map阶段的计算步骤很多,但其中最主要的环节可以归结为4步:
|
||||
|
||||
1. 对于分片中的数据记录,逐一计算其目标分区,并将其填充到PartitionedPairBuffer;-
|
||||
2. PartitionedPairBuffer填满后,如果分片中还有未处理的数据记录,就对Buffer中的数据记录按(目标分区ID,Key)进行排序,将所有数据溢出到临时文件,同时清空缓存;-
|
||||
3. 重复步骤1、2,直到分片中所有的数据记录都被处理;-
|
||||
4. 对所有临时文件和PartitionedPairBuffer归并排序,最终生成数据文件和索引文件。
|
||||
|
||||
不难发现,仙女散花其实就是个分组、收集的游戏。应该说,用Spark来实现分组、收集类的游戏还是比较简单的,那么,如果把仙女散花变成是“分组、统计”的游戏,我们该怎么做呢?
|
||||
|
||||
“仙女散花”游戏升级
|
||||
|
||||
5个小同学完成游戏之后,离下课时间还早。因此,黄老师调整了游戏规则:“你们五个人还是一起协作,这次要在最短的时间内,统计不同花色花朵的数量。”
|
||||
|
||||
小红迫不及待地说:“很简单!还是按照刚才的策略,先把花朵分好堆,然后我们五个人分别去课桌上数数就好啦!”
|
||||
|
||||
|
||||
|
||||
黄小乙皱了皱眉,说道:“别急,新的游戏规则也是有时间限制的,我想了一个和你差不多的办法,一共分三步:第一步,每个人把手里不同花色花朵的数量先算出来;第二步,我们只需要把花朵的数量写到相应的桌子上;第三步,我们分别对五张课桌上的数字求和。这样就能完成得更快了”
|
||||
|
||||
|
||||
|
||||
用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非常适合聚合类的计算场景,如计数、求和、均值计算、极值计算等等。
|
||||
|
||||
|
||||
|
||||
在上图中,4个KV对的Value值,是扫描到数据分区0当中青色花朵之前的状态。在PartitionedAppendOnlyMap中,由于Value是可累加、可更新的,因此这种数据结构可以容纳的花朵数量一定比4大。因此,相比PartitionedPairBuffer,PartitionedAppendOnlyMap的存储效率要高得多,溢出数据到磁盘文件的频率也要低得多。
|
||||
|
||||
以此类推,最终合并的数据文件也会小很多。依靠高效的内存数据结构、更少的磁盘文件、更小的文件尺寸,我们就能大幅降低了Shuffle过程中的磁盘和网络开销。
|
||||
|
||||
事实上,相比groupByKey、collect_list这些收集类算子,聚合类算子(reduceByKey、aggregateByKey等)在执行性能上更占优势。因此,我们要避免在聚合类的计算需求中,引入收集类的算子。虽然这种做法不妨碍业务逻辑实现,但在性能调优上可以说是大忌。
|
||||
|
||||
Reduce阶段是如何进行数据分发的?
|
||||
|
||||
最后,我们再来说说Reduce阶段,在“仙女散花”的游戏里,每个人把自己的花朵归好类之后,主动地把不同颜色的花朵放到相应的课桌上,这个过程实际上就是Shuffle过程中的数据分发。不过,与课桌被动地接收花朵不同的是,Shuffle在Reduce阶段是主动地从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将拉取到的数据块填充到读缓冲区,然后按照任务的计算逻辑不停地消费、处理缓冲区中的数据记录,如下图所示。
|
||||
|
||||
|
||||
|
||||
我们可以看到,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来说,它需要消耗所有的硬件资源:
|
||||
|
||||
|
||||
无论是PartitionedPairBuffer、PartitionedAppendOnlyMap这些内存数据结构,还是读写缓冲区,都会消耗宝贵的内存资源;
|
||||
|
||||
由于内存空间有限,因此溢出的临时文件会引入大量磁盘I/O,而且,Map阶段输出的中间文件也会消耗磁盘;
|
||||
|
||||
呈指数级增长的跨节点数据分发,带来的网络开销更是不容小觑。
|
||||
|
||||
|
||||
其次,Shuffle消耗的不同硬件资源之间很难达到平衡。磁盘和网络的消耗是Shuffle中必需的环节。但是,磁盘与网络的处理延迟相比CPU和内存要相差好几个数量级。以下表为例,如果以CPU L1缓存的处理延迟为基准,把单位从纳秒校准到秒,我们就会惊讶地发现,当CPU、内存以秒为单位处理数据时,磁盘和网络的处理延迟是以天、月为单位的!
|
||||
|
||||
|
||||
|
||||
正是基于Shuffle的这些特点,我们才会“谈虎色变”,一提到Shuffle就避之唯恐不及,强调能省则省、能拖则拖。
|
||||
|
||||
小结
|
||||
|
||||
这一讲,我借助实现仙女散花这个游戏的需求,带你直观地认识Shuffle的计算过程。Shuffle有两个计算阶段,Map阶段和Reduce阶段。我们要重点掌握Map阶段的计算流程,我把它总结为4步:
|
||||
|
||||
|
||||
对于分片中的数据记录,逐一计算其目标分区,然后填充内存数据结构(PartitionedPairBuffer或PartitionedAppendOnlyMap);
|
||||
当数据结构填满后,如果分片中还有未处理的数据记录,就对结构中的数据记录按(目标分区ID,Key)排序,将所有数据溢出到临时文件,同时清空数据结构;
|
||||
重复前2个步骤,直到分片中所有的数据记录都被处理;
|
||||
对所有临时文件和内存数据结构中剩余的数据记录做归并排序,最终生成数据文件和索引文件。
|
||||
|
||||
|
||||
在Reduce阶段我们要注意,Reduce Task通过网络拉取中间文件的过程,实际上就是不同Stages之间数据分发的过程。并且,Shuffle中数据分发的网络开销,会随着Map Task与Reduce Task的线性增长,呈指数级爆炸。
|
||||
|
||||
最后,从硬件资源的角度来看,Shuffle对每一种硬件资源都非常地渴求,尤其是内存、磁盘和网络。由于不同硬件资源之间的处理延迟差异巨大,我们很难在Shuffle过程中平衡CPU、内存、磁盘和网络之间的计算开销。因此,对于Shuffle我们避之唯恐不及,要能省则省、能拖则拖。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
以小红分到的花朵(数据分区0)为例,你能推导出reduceByKey中Map阶段的每个环节吗?(提示:PartitionedAppendOnlyMap需要多少次溢出到磁盘临时文件?每一个临时文件中的内容是什么?最终生成的中间文件,内容分别是什么?和groupByKey生成的中间文件一样吗?)
|
||||
Map阶段和Reduce阶段有不少环节都涉及数据缓存、数据存储,结合上一讲介绍的Spark配置项,你能把相关的配置项对号入座吗?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和讨论,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
153
专栏/Spark性能调优实战/12广播变量(一):克制Shuffle,如何一招制胜!.md
Normal file
153
专栏/Spark性能调优实战/12广播变量(一):克制Shuffle,如何一招制胜!.md
Normal file
@ -0,0 +1,153 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 广播变量(一):克制Shuffle,如何一招制胜!
|
||||
你好,我是吴磊。
|
||||
|
||||
在数据分析领域,数据关联(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对单词进行累加计数。
|
||||
|
||||
|
||||
|
||||
学习过调度系统之后,我们知道,第一行代码定义的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里去提取它们的“大件行李”。
|
||||
|
||||
|
||||
|
||||
总之,在广播变量的机制下,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就好了。
|
||||
|
||||
但是,从分布式数据集创建广播变量就要复杂多了,具体的过程如下图所示。
|
||||
|
||||
|
||||
|
||||
与普通变量相比,分布式数据集的数据源不在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内。
|
||||
|
||||
|
||||
|
||||
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,同样可以在分布式环境中,完成两张表的数据关联。
|
||||
|
||||
|
||||
|
||||
利用广播变量,我们成功地避免了海量数据在集群内的存储、分发,节省了原本由Shuffle引入的磁盘和网络开销,大幅提升运行时执行性能。当然,采用广播变量优化也是有成本的,毕竟广播变量的创建和分发,也是会带来网络开销的。但是,相比大表的全网分发,小表的网络开销几乎可以忽略不计。这种小投入、大产出,用极小的成本去博取高额的性能收益,真可以说是“四两拨千斤”!
|
||||
|
||||
小结
|
||||
|
||||
在数据关联场景中,广播变量是克制Shuffle的杀手锏。掌握了它,我们就能以极小的成本,获得高额的性能收益。关键是我们要掌握两种创建广播变量的方式。
|
||||
|
||||
第一种,从普通变量创建广播变量。在广播变量的运行机制下,普通变量存储的数据封装成广播变量,由Driver端以Executors为粒度进行分发,每一个Executors接收到广播变量之后,将其交由BlockManager管理。
|
||||
|
||||
第二种,从分布式数据集创建广播变量,这就要比第一种方式复杂一些了。第一步,Driver需要从所有的Executors拉取数据分片,然后在本地构建全量数据;第二步,Driver把汇总好的全量数据分发给各个Executors,Executors再将接收到的全量数据缓存到存储系统的BlockManager中。
|
||||
|
||||
结合这两种方式,我们在做数据关联的时候,把Shuffle Joins转换为Broadcast Joins,就可以用小表广播来代替大表的全网分发,真正做到克制Shuffle。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
Spark广播机制现有的实现方式是存在隐患的,在数据量较大的情况下,Driver可能会成为瓶颈,你能想到更好的方式来重新实现Spark的广播机制吗?(提示:SPARK-17556)
|
||||
在什么情况下,不适合把Shuffle Joins转换为Broadcast Joins?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
161
专栏/Spark性能调优实战/13广播变量(二):如何让SparkSQL选择BroadcastJoins?.md
Normal file
161
专栏/Spark性能调优实战/13广播变量(二):如何让SparkSQL选择BroadcastJoins?.md
Normal file
@ -0,0 +1,161 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 广播变量(二):如何让Spark SQL选择Broadcast Joins?
|
||||
你好,我是吴磊。
|
||||
|
||||
上一讲我们说到,在数据关联场景中,广播变量是克制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。
|
||||
|
||||
|
||||
|
||||
为了方便你理解,我来举个例子。在数据仓库中,我们经常会看到两张表:一张是订单事实表,为了方便,我们把它记成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强制广播都有各自的优缺点,所以,以广播阈值配置为主、强制广播为辅,往往是一个不错的选择。
|
||||
|
||||
最后,不过,我们也要注意,广播变量不是银弹,它并不能解决所有的数据关联问题,所以在日常的开发工作中,你要注意避免滥用广播。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
除了broadcast关键字外,在Spark 3.0版本中,Join Hints还支持哪些关联类型和关键字?
|
||||
DataFrame可以用sparkContext.broadcast函数来广播吗?它和org.apache.spark.sql.functions.broadcast函数之间的区别是什么?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
151
专栏/Spark性能调优实战/14CPU视角:如何高效地利用CPU?.md
Normal file
151
专栏/Spark性能调优实战/14CPU视角:如何高效地利用CPU?.md
Normal file
@ -0,0 +1,151 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 CPU视角:如何高效地利用CPU?
|
||||
你好,我是吴磊。
|
||||
|
||||
在日常的开发与调优工作中,总有同学向我抱怨:“为什么我的应用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类参数都包含哪些具体的配置项,以及它们到底是怎么配置的,我们需要先弄清楚一些基础知识,也就是并行计算的线程之间是如何瓜分执行内存的。为了帮助你理解,我先来给你讲个故事。
|
||||
|
||||
黄小乙的如意算盘:并行计算的线程如何瓜分执行内存?
|
||||
|
||||
还记得地主招租的故事吗?与张麻子签订占地协议之后,黄小乙就开始盘算,自己分得的那块田地怎么经营才最划算。
|
||||
|
||||
他心想:“这么一大块地,我亲自种肯定划不来。一,我没有张麻子勤快;二,不管是种棉花还是咖啡都很耗时、费力,面朝黄土背朝天,我可耽误不起那功夫!不如,我把土地转让出去,让别人来种,我只管收购、销售,赚到的差价也够我吃穿不愁了!”
|
||||
|
||||
于是,他打定主意,贴出了一张告示。
|
||||
|
||||
|
||||
|
||||
告示贴出去不到三天,十里八村的人都赶来承让土地,他们大部分都是吃苦耐劳的庄稼汉,一心想凭借这次机会改善生活,所以每个人恨不能把500顷的田地全都承让过来。
|
||||
|
||||
黄小乙见状,心中大喜,认为不仅自己的土地很快就可以被种满,还能名正言顺地去抢占张麻子的那块地。不过,也不能光图规模,还要确保棉花、咖啡的产出质量,更重要的是得想个办法让这种运作模式可持续。
|
||||
|
||||
于是,黄小乙追加了一项补充条款:“鉴于老乡们参与热情高涨,公平起见,我又制定了新的土地转让规则:首先,每位老乡能够获得的土地面积有上下限,它的具体数值由可耕种土地总面积和申请人数共同决定;其次,土地转让权的有效时间与农作物生长周期一致,一旦作物丰收,承让人需让出土地,如有意愿再次耕种需重新申请。”
|
||||
|
||||
|
||||
|
||||
比如说,现阶段可耕种土地总面积已由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阶段默认的并行度。
|
||||
|
||||
那什么是并发度呢?我们在配置项那一讲提到过,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利用率来说,并行度、并发度与执行内存的关系就好像是一尊盛满沸水的三足鼎,三足齐平则万事大吉,但凡哪一方瘸腿儿,鼎内的沸水就会倾出伤及无辜。
|
||||
|
||||
|
||||
|
||||
小结
|
||||
|
||||
今天这一讲,我们从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利用率往往不会太差。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
从Executor并发度、执行内存大小和分布式任务并行度出发,你认为在什么情况下会出现OOM的问题?
|
||||
由于执行内存总量M是动态变化的,并发任务数N~也是动态变化的,因此每个线程申请内存的上下限也是动态调整的,你知道这个调整周期以什么为准?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,如果你的朋友也在为提高CPU利用率苦恼,欢迎你把这一讲转发给他,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
177
专栏/Spark性能调优实战/15内存视角(一):如何最大化内存的使用效率?.md
Normal file
177
专栏/Spark性能调优实战/15内存视角(一):如何最大化内存的使用效率?.md
Normal file
@ -0,0 +1,177 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 内存视角(一):如何最大化内存的使用效率?
|
||||
你好,我是吴磊。
|
||||
|
||||
上一讲我们说,想要提升CPU利用率,最重要的就是合理分配执行内存,但是,执行内存只是Spark内存分区的一部分。因此,想要合理分配执行内存,我们必须先从整体上合理划分好Spark所有的内存区域。
|
||||
|
||||
可在实际开发应用的时候,身边有不少同学向我抱怨:“Spark划分不同内存区域的原理我都知道,但我还是不知道不同内存区域的大小该怎么设置,纠结来、纠结去。最后,所有跟内存有关的配置项,我还是保留了默认值。”
|
||||
|
||||
这种不能把原理和实践结合起来的情况很常见,所以今天这一讲,我就从熟悉的Label Encoding实例出发,一步步带你去分析不同情况下,不同内存区域的调整办法,帮你归纳出最大化内存利用率的常规步骤。这样,你在调整内存的时候,就能结合应用的需要,做到有章可循、有的放矢。
|
||||
|
||||
从一个实例开始
|
||||
|
||||
我们先来回顾一下第5讲中讲过的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讲)。这个时候,每个Task就像是一架架小飞机,携带着代码“乘客”和数据“行李”,从Driver飞往Executor。Task小飞机在Executor机场着陆之后,代码“乘客”乘坐出租车或是机场大巴,去往JVM stack;数据“行李”则由专人堆放在JVM Heap,也就是我们常说的堆内内存。
|
||||
|
||||
回顾Label encoding中的findIndex函数不难发现,其中大部分都是代码“乘客”,唯一的数据“行李”是名为searchMap的Map字典。像这样用户自定义的数据结构,消耗的内存区域就是堆内内存的User Memory(Spark对内存区域的划分内容可以回顾一下第7讲)。
|
||||
|
||||
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规划
|
||||
|
||||
这样一来,原来的内存消耗转嫁到了广播变量身上。但是,广播变量也会消耗内存,这会不会带来新的性能隐患呢?那我们就来看看,广播变量消耗的具体是哪块内存区域。
|
||||
|
||||
回顾存储系统那一讲,我们说过,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得到。
|
||||
|
||||
|
||||
这里,我还想多说几句,内存规划两步走终归只是手段,它最终要达到的效果和目的,是确保不同内存区域的占比与不同类型的数据消耗保持一致,从而实现内存利用率的最大化。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
你知道估算Java对象存储大小的方法有哪些吗?不同的方法又有哪些优、劣势呢?
|
||||
对于内存规划的第一步来说,要精确地预估运行时每一个区域的内存消耗,很费时、费力,调优的成本很高。如果我们想省略掉第一步的精确计算,你知道有哪些方法能够粗略、快速地预估不同内存区域的消耗占比吗?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
210
专栏/Spark性能调优实战/16内存视角(二):如何有效避免Cache滥用?.md
Normal file
210
专栏/Spark性能调优实战/16内存视角(二):如何有效避免Cache滥用?.md
Normal file
@ -0,0 +1,210 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 内存视角(二):如何有效避免Cache滥用?
|
||||
你好,我是吴磊。
|
||||
|
||||
在Spark的应用开发中,有效利用Cache往往能大幅提升执行性能。
|
||||
|
||||
但某一天,有位同学却和我说,自己加了Cache之后,执行性能反而变差了。仔细看了这位同学的代码之后,我吓了一跳。代码中充斥着大量的.cache,无论是RDD,还是DataFrame,但凡有分布式数据集的地方,后面几乎都跟着个.cache。显然,Cache滥用是执行性能变差的始作俑者。
|
||||
|
||||
实际上,在有些场景中,Cache是灵丹妙药,而在另一些场合,大肆使用Cache却成了饮鸩止渴。那Cache到底该在什么时候用、怎么用,都有哪些注意事项呢?今天这一讲,我们先一起回顾Cache的工作原理,再来回答这些问题。
|
||||
|
||||
Cache的工作原理
|
||||
|
||||
在存储系统那一讲,我们其实介绍过RDD的缓存过程,只不过当时的视角是以MemoryStore为中心,目的在于理解存储系统的工作原理,今天咱们把重点重新聚焦到缓存上来。
|
||||
|
||||
Spark的Cache机制主要有3个方面需要我们掌握,它们分别是:
|
||||
|
||||
|
||||
缓存的存储级别:它限定了数据缓存的存储介质,如内存、磁盘等
|
||||
缓存的计算过程:从RDD展开到分片以Block的形式,存储于内存或磁盘的过程
|
||||
缓存的销毁过程:缓存数据以主动或是被动的方式,被驱逐出内存或是磁盘的过程
|
||||
|
||||
|
||||
下面,我们一一来看。
|
||||
|
||||
存储级别
|
||||
|
||||
Spark中的Cache支持很多种存储级别,比如MEMORY_AND_DISK_SER_2、MEMORY_ONLY等等。这些长得差不多的字符串我们该怎么记忆和区分呢?其实,每一种存储级别都包含3个基本要素。
|
||||
|
||||
|
||||
存储介质:内存还是磁盘,或是两者都有。
|
||||
存储形式:对象值还是序列化的字节数组,带SER字样的表示以序列化方式存储,不带SER则表示采用对象值。
|
||||
副本数量:存储级别名字最后的数字代表拷贝数量,没有数字默认为1份副本。
|
||||
|
||||
|
||||
|
||||
|
||||
当我们对五花八门的存储级别拆解之后就会发现,它们不过是存储介质、存储形式和副本数量这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讲中讲过了,这里我们再一起回顾一下。
|
||||
|
||||
|
||||
|
||||
无论是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驱逐出内存。
|
||||
|
||||
|
||||
|
||||
回到刚才的问题,Spark是根据什么规则选中的这些倒霉蛋呢?这个规则叫作LRU(Least Recently Used),基于这个算法,最近访问频率最低的那个家伙就是倒霉蛋。因为LRU是比较基础的数据结构算法,笔试、面试的时候经常会考,所以它的概念我就不多说了。
|
||||
|
||||
我们要知道的是,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的时候,我们要把待缓存数据赋值给一个变量。这样一来,只要是在这个变量之上的分析操作都会完全复用缓存数据。
|
||||
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
你能结合DiskStore的知识,推导出MEMORY_AND_DISK模式下RDD分片缓存到磁盘的过程吗?
|
||||
你觉得,为什么Eviction规则要遵循“兔子不吃窝边草”呢?如果允许同一个RDD的MemoryEntry被驱逐,有什么危害吗?
|
||||
对于DataFrame的缓存复用,Cache Manager为什么没有采用根据Optimized Logical Plan的方式,你觉得难点在哪里?如果让你实现Cache Manager的话,你会怎么做?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,如果你的朋友也正在为怎么使用Cache而困扰,也欢迎你把这一讲转发给他。我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
191
专栏/Spark性能调优实战/17内存视角(三):OOM都是谁的锅?怎么破?.md
Normal file
191
专栏/Spark性能调优实战/17内存视角(三):OOM都是谁的锅?怎么破?.md
Normal file
@ -0,0 +1,191 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 内存视角(三):OOM都是谁的锅?怎么破?
|
||||
你好,我是吴磊。
|
||||
|
||||
无论是批处理、流计算,还是数据分析、机器学习,只要是在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是指什么呢?还记得广播变量的工作原理吗?
|
||||
|
||||
|
||||
|
||||
广播变量在创建的过程中,需要先把分布在所有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问题。
|
||||
|
||||
|
||||
|
||||
那么,解决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,因此可以顺利完成任务。
|
||||
|
||||
|
||||
|
||||
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问题才对。
|
||||
|
||||
|
||||
|
||||
尴尬的地方在于,磁盘中的数据进了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上限
|
||||
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
数据膨胀导致OOM的例子中,为什么Task1能获取到300MB的内存空间?(提示:可以回顾CPU视角那一讲去寻找答案。)
|
||||
在日常开发中,你还遇到过哪些OOM表象?你能把它们归纳到我们今天讲的分类中吗?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和分享,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
150
专栏/Spark性能调优实战/18磁盘视角:如果内存无限大,磁盘还有用武之地吗?.md
Normal file
150
专栏/Spark性能调优实战/18磁盘视角:如果内存无限大,磁盘还有用武之地吗?.md
Normal file
@ -0,0 +1,150 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 磁盘视角:如果内存无限大,磁盘还有用武之地吗?
|
||||
你好,我是吴磊。
|
||||
|
||||
我们都知道,Spark的优势在于内存计算。一提到“内存计算”,我们的第一反应都是:执行效率高!但如果听到“基于磁盘的计算”,就会觉得性能肯定好不到哪儿去。甚至有的人会想,如果Spark的内存无限大就好了,这样我们就可以把磁盘完全抛弃掉。当然,这个假设大概率不会成真,而且这种一刀切的思维也不正确。
|
||||
|
||||
如果内存无限大,我们确实可以通过一些手段,让Spark作业在执行的过程中免去所有的落盘动作。但是,无限大内存引入的大量Full GC停顿(Stop The World),很有可能让应用的执行性能,相比有磁盘操作的时候更差。这就不符合我们一再强调的,调优的最终目的是在不同的硬件资源之间寻求平衡了。
|
||||
|
||||
所以今天这一讲,我们就来说说磁盘在Spark任务执行的过程中都扮演哪些重要角色,它功能方面的作用,以及性能方面的价值。掌握它们可以帮助我们更合理地利用磁盘,以成本优势平衡不同硬件资源的计算负载。
|
||||
|
||||
磁盘在功能上的作用
|
||||
|
||||
在Spark当中,磁盘都用在哪些地方呢?在Shuffle那一讲我们说过,在Map阶段,Spark根据计算是否需要聚合,分别采用PartitionedPairBuffer和PartitionedAppendOnlyMap两种不同的内存数据结构来缓存分片中的数据记录。分布式计算往往涉及海量数据,因此这些数据结构通常都没办法装满分区中的所有数据。在内存受限的情况下,溢出机制可以保证任务的顺利执行,不会因为内存空间不足就立即报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的中间文件。
|
||||
|
||||
|
||||
|
||||
我们以文稿示意图中的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策略在执行效率方面会带来非常大的收益。
|
||||
|
||||
|
||||
|
||||
即便是在没有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端再进行全局聚合。
|
||||
|
||||
|
||||
|
||||
对于绝大多数的合并场景来说,计算流程大抵如此。显然,这样的做法是极其低效的,尤其是在需要合并多个数据集的时候,重复的数据扫描和分发就会引入更多的性能开销。那么,有没有什么办法,让同一份数据源的多个算子只读取一次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结果。
|
||||
|
||||
|
||||
|
||||
通过观察执行计划不难发现,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中间数据的分区规则保持一致
|
||||
多个查询所涉及的字段要保持一致
|
||||
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
请你把count计算中的count("page")改为count("*"),以此来观察物理计划的变化,并在留言区说出你的观察
|
||||
为了触发ReuseExchange机制生效,我们按照userId对数据集做重分区,结合这一点,你不妨想一想,在哪些情况下,不适合采用ReuseExchange机制?为什么?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
129
专栏/Spark性能调优实战/19网络视角:如何有效降低网络开销?.md
Normal file
129
专栏/Spark性能调优实战/19网络视角:如何有效降低网络开销?.md
Normal file
@ -0,0 +1,129 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 网络视角:如何有效降低网络开销?
|
||||
你好,我是吴磊。
|
||||
|
||||
在平衡不同硬件资源的时候,相比CPU、内存、磁盘,网络开销无疑是最拖后腿的那一个,这一点在处理延迟上表现得非常明显。
|
||||
|
||||
下图就是不同硬件资源的处理延迟对比结果,我们可以看到最小的处理单位是纳秒。你可能对纳秒没什么概念,所以为了方便对比,我把纳秒等比放大到秒。这样,其他硬件资源的处理延迟也会跟着放大。最后一对比我们会发现,网络延迟是以天为单位的!
|
||||
|
||||
|
||||
|
||||
因此,要想维持硬件资源之间的平衡,尽可能地降低网络开销是我们在性能调优中必须要做的。今天这一讲,我就按照数据进入系统的时间顺序,也就是数据读取、数据处理和数据传输的顺序,带你去分析和总结数据生命周期的不同阶段有效降低网络开销的方法。
|
||||
|
||||
数据读写
|
||||
|
||||
对于绝大多数应用来说,第一步操作都是从分布式文件系统读取数据源。Spark支持的数据源种类非常丰富,涉及的存储格式和存储系统可以说是五花八门。
|
||||
|
||||
|
||||
|
||||
这么多存储格式和外部存储系统交叉在一起又会有无数种组合,并且每一种组合都有它的应用场景。那么,我们该怎么判断网络开销会出现在哪些场景下呢?其实,不管是什么文件格式,也不管是哪种存储系统,访问数据源是否会引入网络开销,取决于任务与数据的本地性关系,也就是任务的本地性级别,它一共有4种:
|
||||
|
||||
|
||||
PROCESS_LOCAL:任务与数据同在一个JVM进程中
|
||||
NODE_LOCAL:任务与数据同在一个计算节点,数据可能在磁盘上或是另一个JVM进程中
|
||||
RACK_LOCAL:任务与数据不在同一节点,但在同一个物理机架上
|
||||
ANY:任务与数据是跨机架、甚至是跨DC(Data Center,数据中心)的关系
|
||||
|
||||
|
||||
根据定义我们很容易判断出,不同本地性级别下的计算任务是否会引入磁盘或网络开销,结果如下表所示。从表格中我们不难发现,从PROCESS_LOCAL到ANY,数据访问效率是逐级变差的。在读取数据源阶段,数据还未加载到内存,任务没有办法调度到PROCESS_LOCAL级别。因此,这个阶段我们能够调度的最佳级别是NODE_LOCAL。
|
||||
|
||||
|
||||
|
||||
根据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如下表所示。
|
||||
|
||||
|
||||
|
||||
要获取群组兴趣列表,我们应该先按照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遇到未曾注册过的自定义类型的时候,它就不会再帮你做序列化的工作,而是抛出异常,并且中断任务执行。这么做的好处在于,在开发和调试阶段,它能帮我们捕捉那些忘记注册的类型。
|
||||
|
||||
|
||||
|
||||
为了方便你理解,我们不妨把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序列化器的优势,开发者需要明确注册自定义的数据类型,否则效果可能适得其反。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
对于文中Map端聚合的示例,你知道和collect_list对应的Map端聚合算子是什么吗?
|
||||
你还能想到哪些Map端聚合的计算场景?
|
||||
对于不同的数据处理阶段,你还知道哪些降低网络开销的办法吗?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,也欢迎你把这一讲转发出去,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
124
专栏/Spark性能调优实战/20RDD和DataFrame:既生瑜,何生亮?.md
Normal file
124
专栏/Spark性能调优实战/20RDD和DataFrame:既生瑜,何生亮?.md
Normal file
@ -0,0 +1,124 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 RDD和DataFrame:既生瑜,何生亮?
|
||||
你好,我是吴磊。
|
||||
|
||||
从今天开始,我们进入Spark SQL性能调优篇的学习。在这一篇中,我会先带你学习Spark SQL已有的优化机制,如Catalyst、Tungsten这些核心组件,以及AQE、DPP等新特性。深入理解这些内置的优化机制,会让你在开发应用之初就有一个比较高的起点。然后,针对数据分析中的典型场景,如数据关联,我们再去深入探讨性能调优的方法和技巧。
|
||||
|
||||
今天这一讲,我们先来说说RDD和DataFrame的渊源。这也是面试的时候,面试官经常会问的。比如说:“Spark 3.0大版本发布,Spark SQL的优化占比将近50%;而像PySpark、Mllib和Streaming的优化占比都不超过10%,Graph的占比几乎可以忽略不计。这是否意味着Spark社区逐渐放弃了其他计算领域,只专注于数据分析?”
|
||||
|
||||
|
||||
|
||||
这个问题的标准答案是:“Spark SQL取代Spark Core,成为新一代的引擎内核,所有其他子框架如Mllib、Streaming和Graph,都可以共享Spark SQL的性能优化,都能从Spark社区对于Spark SQL的投入中受益。”不过,面试官可没有那么好对付,一旦你这么说,他/她可能会追问:“为什么需要Spark SQL这个新一代引擎内核?Spark Core有什么问题吗?Spark SQL解决了Spark Core的哪些问题?怎么解决的?”
|
||||
|
||||
面对这一连串“箭如雨发”的追问,你还能回答出来吗?接下来,我就从RDD的痛点说起,一步一步带你探讨DataFrame出现的必然性,Spark Core的局限性,以及它和Spark SQL的关系。
|
||||
|
||||
RDD之痛:优化空间受限
|
||||
|
||||
自从Spark社区在1.3版本发布了DataFrame,它就开始代替RDD,逐渐成为开发者的首选。我们知道,新抽象的诞生一定是为了解决老抽象不能搞定的问题。那么,这些问题都是什么呢?下面,我们就一起来分析一下。
|
||||
|
||||
在RDD的开发框架下,我们调用RDD算子进行适当的排列组合,就可以很轻松地实现业务逻辑。我把这些使用频繁的RDD算子总结到了下面的表格里,你可以看一看。
|
||||
|
||||
|
||||
|
||||
表格中高亮显示的就是RDD转换和聚合算子,它们都是高阶函数。高阶函数指的是形参包含函数的函数,或是返回结果包含函数的函数。为了叙述方便,我们把那些本身是高阶函数的RDD算子,简称“高阶算子”。
|
||||
|
||||
对于这些高阶算子,开发者需要以Lambda函数的形式自行提供具体的计算逻辑。以map为例,我们需要明确对哪些字段做映射,以什么规则映射。再以filter为例,我们需要指明以什么条件在哪些字段上过滤。
|
||||
|
||||
但这样一来,Spark只知道开发者要做map、filter,但并不知道开发者打算怎么做map和filter。也就是说,在RDD的开发模式下,Spark Core只知道“做什么”,而不知道“怎么做”。这会让Spark Core两眼一抹黑,除了把Lambda函数用闭包的形式打发到Executors以外,实在是没有什么额外的优化空间。
|
||||
|
||||
对于Spark Core来说,优化空间受限最主要的影响,莫过于让应用的执行性能变得低下。一个典型的例子,就是相比Java或者Scala,PySpark实现的应用在执行性能上相差悬殊。原因在于,在RDD的开发模式下,即便是同一个应用,不同语言实现的版本在运行时也会有着天壤之别。
|
||||
|
||||
|
||||
|
||||
当我们使用Java或者Scala语言做开发时,所有的计算都在JVM进程内完成,如图中左侧的Spark计算节点所示。
|
||||
|
||||
而当我们在PySpark上做开发的时候,只能把由RDD算子构成的计算代码,一股脑地发送给Python进程。Python进程负责执行具体的脚本代码,完成计算之后,再把结果返回给Executor进程。由于每一个Task都需要一个Python进程,如果RDD的并行度为#N,那么整个集群就需要#N个这样的Python进程与Executors交互。不难发现,其中的任务调度、数据计算和数据通信等开销,正是PySpark性能低下的罪魁祸首。
|
||||
|
||||
DataFrame应运而生
|
||||
|
||||
针对优化空间受限这个核心问题,Spark社区痛定思痛,在2013年在1.3版本中发布了DataFrame。那么,DataFrame的特点是什么,它和RDD又有什么不同呢?
|
||||
|
||||
首先,用一句话来概括,DataFrame就是携带数据模式(Data Schema)的结构化分布式数据集,而RDD是不带Schema的分布式数据集。因此,从数据表示(Data Representation)的角度来看,是否携带Schema是它们唯一的区别。带Schema的数据表示形式决定了DataFrame只能封装结构化数据,而RDD则没有这个限制,所以除了结构化数据,它还可以封装半结构化和非结构化数据。
|
||||
|
||||
其次,从开发API上看,RDD算子多是高阶函数,这些算子允许开发者灵活地实现业务逻辑,表达能力极强。
|
||||
|
||||
DataFrame的表达能力却很弱。一来,它定义了一套DSL(Domain Specific Language)算子,如select、filter、agg、groupBy等等。由于DSL语言是为解决某一类任务而专门设计的计算机语言,非图灵完备,因此,语言表达能力非常有限。二来,DataFrame中的绝大多数算子都是标量函数(Scalar Functions),它们的形参往往是结构化的数据列(Columns),表达能力也很弱。
|
||||
|
||||
你可能会问:“相比RDD,DataFrame的表示和表达能力都变弱了,那它是怎么解决RDD优化空间受限的核心痛点呢?”
|
||||
|
||||
当然,仅凭DataFrame在API上的改动就想解决RDD的核心痛点,比登天还难。DataFrame API最大的意义在于,它为Spark引擎的内核优化打开了全新的空间。
|
||||
|
||||
首先,DataFrame中Schema所携带的类型信息,让Spark可以根据明确的字段类型设计定制化的数据结构,从而大幅提升数据的存储和访问效率。其次,DataFrame中标量算子确定的计算逻辑,让Spark可以基于启发式的规则和策略,甚至是动态的运行时信息去优化DataFrame的计算过程。
|
||||
|
||||
Spark SQL智能大脑
|
||||
|
||||
那么问题来了,有了DataFrame API,负责引擎内核优化的那个幕后英雄是谁?为了支持DataFrame开发模式,Spark从1.3版本开始推出Spark SQL。Spark SQL的核心组件有二,其一是Catalyst优化器,其二是Tungsten。关于Catalyst和Tungsten的特性和优化过程,我们在后面的两讲再去展开,今天这一讲,咱们专注在它们和DataFrame的关系。
|
||||
|
||||
Catalyst:执行过程优化
|
||||
|
||||
我们先来说说Catalyst的优化过程。当开发者通过Actions算子触发DataFrame的计算请求时,Spark内部会发生一系列有趣的事情。
|
||||
|
||||
首先,基于DataFrame确切的计算逻辑,Spark会使用第三方的SQL解析器ANTLR来生成抽象语法树(AST,Abstract Syntax Tree)。既然是树,就会有节点和边这两个基本的构成元素。节点记录的是标量算子(如select、filter)的处理逻辑,边携带的是数据信息:关系表和数据列,如下图所示。这样的语法树描述了从源数据到DataFrame结果数据的转换过程。
|
||||
|
||||
|
||||
|
||||
在Spark中,语法树还有个别名叫做“Unresolved Logical Plan”。它正是Catalyst优化过程的起点。之所以取名“Unresolved”,是因为边上记录的关系表和数据列仅仅是一些字符串,还没有和实际数据对应起来。举个例子,Filter之后的那条边,输出的数据列是joinKey和payLoad。这些字符串的来源是DataFrame的DSL查询,Catalyst并不确定这些字段名是不是有效的,更不知道每个字段都是什么类型。
|
||||
|
||||
因此,Catalyst做的第一步优化,就是结合DataFrame的Schema信息,确认计划中的表名、字段名、字段类型与实际数据是否一致。这个过程也叫做把“Unresolved Logical Plan”转换成“Analyzed Logical Plan”。
|
||||
|
||||
|
||||
|
||||
基于解析过后的“Analyzed Logical Plan”,Catalyst才能继续做优化。利用启发式的规则和执行策略,Catalyst最终把逻辑计划转换为可执行的物理计划。总之,Catalyst的优化空间来源DataFrame的开发模式。
|
||||
|
||||
Tungsten:数据结构优化
|
||||
|
||||
说完Catalyst,我接着再来说说Tungsten。在开发原则那一讲,我们提到过Tungsten使用定制化的数据结构Unsafe Row来存储数据,Unsafe Row的优点是存储效率高、GC效率高。Tungsten之所以能够设计这样的数据结构,仰仗的也是DataFrame携带的Schema。Unsafe Row我们之前讲过,这里我再带你简单回顾一下。
|
||||
|
||||
|
||||
|
||||
Tungsten是用二进制字节序列来存储每一条用户数据的,因此在存储效率上完胜Java Object。比如说,如果我们要存储上表中的数据,用Java Object来存储会消耗100个字节数,而使用Tungsten仅需要不到20个字节,如下图所示。
|
||||
|
||||
|
||||
|
||||
但是,要想实现上图中的二进制序列,Tungsten必须要知道数据条目的Schema才行。也就是说,它需要知道每一个字段的数据类型,才能决定在什么位置安放定长字段、安插Offset,以及存放变长字段的数据值。DataFrame刚好能满足这个前提条件。
|
||||
|
||||
我们不妨想象一下,如果数据是用RDD封装的,Tungsten还有可能做到这一点吗?当然不可能。这是因为,虽然RDD也带类型,如RDD[Int]、RDD[(Int, String)],但如果RDD中携带的是开发者自定义的数据类型,如RDD[User]或是RDD[Product],Tungsten就会两眼一抹黑,完全不知道你的User和Product抽象到底是什么。成也萧何、败也萧何,RDD的通用性是一柄双刃剑,在提供开发灵活性的同时,也让引擎内核的优化变得无比困难。
|
||||
|
||||
总的来说,基于DataFrame简单的标量算子和明确的Schema定义,借助Catalyst优化器和Tungsten,Spark SQL有能力在运行时构建起一套端到端的优化机制。这套机制运用启发式的规则与策略,以及运行时的执行信息,将原本次优、甚至是低效的查询计划转换为高效的执行计划,从而提升端到端的执行性能。因此,在DataFrame的开发框架下,不论你使用哪种开发语言,开发者都能共享Spark SQL带来的性能福利。
|
||||
|
||||
|
||||
|
||||
最后,我们再来回顾最开始提到的面试题:“从2.0版本至今,Spark对于其他子框架的完善与优化,相比Spark SQL占比很低。这是否意味着,Spark未来的发展重心是数据分析,其他场景如机器学习、流计算会逐渐边缘化吗?”
|
||||
|
||||
最初,Spark SQL确实仅仅是运行SQL和DataFrame应用的子框架,但随着优化机制的日趋完善,Spark SQL逐渐取代Spark Core,演进为新一代的引擎内核。到目前为止,所有子框架的源码实现都已从RDD切换到DataFrame。因此,和PySpark一样,像Streaming、Graph、Mllib这些子框架实际上都是通过DataFrame API运行在Spark SQL之上,它们自然可以共享Spark SQL引入的种种优化机制。
|
||||
|
||||
形象地说,Spark SQL就像是Spark的智能大脑,凡是通过DataFrame这双“眼睛”看到的问题,都会经由智能大脑这个指挥中心,统筹地进行分析与优化,优化得到的行动指令,最终再交由Executors这些“四肢”去执行。
|
||||
|
||||
小结
|
||||
|
||||
今天,我们围绕RDD的核心痛点,探讨了DataFrame出现的必然性,Spark Core的局限性,以及它和Spark SQL的关系,对Spark SQL有了更深刻的理解。
|
||||
|
||||
RDD的核心痛点是优化空间有限,它指的是RDD高阶算子中封装的函数对于Spark来说完全透明,因此Spark对于计算逻辑的优化无从下手。
|
||||
|
||||
相比RDD,DataFrame是携带Schema的分布式数据集,只能封装结构化数据。DataFrame的算子大多数都是普通的标量函数,以消费数据列为主。但是,DataFrame更弱的表示能力和表达能力,反而为Spark引擎的内核优化打开了全新的空间。
|
||||
|
||||
根据DataFrame简单的标量算子和明确的Schema定义,借助Catalyst优化器和Tungsten,Spark SQL有能力在运行时,构建起一套端到端的优化机制。这套机制运用启发式的规则与策略和运行时的执行信息,将原本次优、甚至是低效的查询计划转换为高效的执行计划,从而提升端到端的执行性能。
|
||||
|
||||
在DataFrame的开发模式下,所有子框架、以及PySpark,都运行在Spark SQL之上,都可以共享Spark SQL提供的种种优化机制,这也是为什么Spark历次发布新版本、Spark SQL占比最大的根本原因。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
Java Object在对象存储上为什么会有比较大的开销?JVM需要多少个字节才能存下字符串“abcd”?
|
||||
在DataFrame的开发框架下, PySpark中还有哪些操作是“顽固分子”,会导致计算与数据在JVM进程与Python进程之间频繁交互?(提示:参考RDD的局限性,那些对Spark透明的计算逻辑,Spark是没有优化空间的)
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
200
专栏/Spark性能调优实战/21Catalyst逻辑计划:你的SQL语句是怎么被优化的?(上).md
Normal file
200
专栏/Spark性能调优实战/21Catalyst逻辑计划:你的SQL语句是怎么被优化的?(上).md
Normal file
@ -0,0 +1,200 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
21 Catalyst逻辑计划:你的SQL语句是怎么被优化的?(上)
|
||||
你好,我是吴磊。
|
||||
|
||||
上一讲我们说,Spark SQL已经取代Spark Core成为了新一代的内核优化引擎,所有Spark子框架都能共享Spark SQL带来的性能红利,所以在Spark历次发布的新版本中,Spark SQL占比最大。因此,Spark SQL的优化过程是我们必须要掌握的。
|
||||
|
||||
Spark SQL端到端的完整优化流程主要包括两个阶段:Catalyst优化器和Tungsten。其中,Catalyst优化器又包含逻辑优化和物理优化两个阶段。为了把开发者的查询优化到极致,整个优化过程的运作机制设计得都很精密,因此我会用三讲的时间带你详细探讨。
|
||||
|
||||
下图就是这个过程的完整图示,你可以先通过它对优化流程有一个整体的认知。然后随着我的讲解,逐渐去夯实其中的关键环节、重要步骤和核心知识点,在深入局部优化细节的同时,把握全局优化流程,做到既见树木、也见森林。
|
||||
|
||||
|
||||
|
||||
今天这一讲,我们先来说说Catalyst优化器逻辑优化阶段的工作原理。
|
||||
|
||||
案例:小Q变身记
|
||||
|
||||
我们先来看一个例子,例子来自电子商务场景,业务需求很简单:给定交易事实表transactions和用户维度表users,统计不同用户的交易额,数据源以Parquet的格式存储在分布式文件系统。因此,我们要先用Parquet API读取源文件。
|
||||
|
||||
val userFile: String = _
|
||||
val usersDf = spark.read.parquet(userFile)
|
||||
usersDf.printSchema
|
||||
/**
|
||||
root
|
||||
|-- userId: integer (nullable = true)
|
||||
|-- name: string (nullable = true)
|
||||
|-- age: integer (nullable = true)
|
||||
|-- gender: string (nullable = true)
|
||||
|-- email: string (nullable = true)
|
||||
*/
|
||||
val users = usersDf
|
||||
.select("name", "age", "userId")
|
||||
.filter($"age" < 30)
|
||||
.filter($"gender".isin("M"))
|
||||
|
||||
val txFile: String = _
|
||||
val txDf = spark.read.parquet(txFile)
|
||||
txDf.printSchema
|
||||
/**
|
||||
root
|
||||
|-- itemId: integer (nullable = true)
|
||||
|-- userId: integer (nullable = true)
|
||||
|-- price: float (nullable = true)
|
||||
|-- quantity: integer (nullable = true)
|
||||
*/
|
||||
|
||||
val result = txDF.select("price", "volume", "userId")
|
||||
.join(users, Seq("userId"), "inner")
|
||||
.groupBy(col("name"), col("age")).agg(sum(col("price") * col("volume")).alias("revenue"))
|
||||
|
||||
result.write.parquet("_")
|
||||
|
||||
|
||||
代码示例如上图所示,为了实现业务逻辑,我们对过滤之后的用户表与交易表做内关联,然后再按照用户分组去计算交易额。不难发现,这个计算逻辑实际上就是星型数仓中典型的关联查询。为了叙述方便,我们给这个关联查询起个名字:小Q。小Q的计算需要两个输入源,一个是交易表,另一个是过滤之后的用户表。今天这一讲,我们就去追随小Q,看看它在Catalyst的逻辑优化阶段都会发生哪些变化。
|
||||
|
||||
|
||||
|
||||
Catalyst逻辑优化阶段分为两个环节:逻辑计划解析和逻辑计划优化。在逻辑计划解析中,Catalyst把“Unresolved Logical Plan”转换为“Analyzed Logical Plan”;在逻辑计划优化中,Catalyst基于一些既定的启发式规则(Heuristics Based Rules),把“Analyzed Logical Plan”转换为“Optimized Logical Plan”。
|
||||
|
||||
|
||||
|
||||
因为“Unresolved Logical Plan”是Catalyst优化的起点,所以在进入Catalyst优化器之前,小Q先是改头换面,从代码中的查询语句,摇身变成了“Unresolved Logical Plan”。
|
||||
|
||||
|
||||
|
||||
逻辑计划解析
|
||||
|
||||
小Q成功进入Catalyst优化器之后,就要开始执行逻辑计划解析,也就是要从“Unresolved Logical Plan”转换为“Analyzed Logical Plan”。那么,具体该怎么做呢?
|
||||
|
||||
从“小Q启程”那张图我们不难发现,“Unresolved Logical Plan”携带的信息相当有限,它只包含查询语句从DSL语法变换成AST语法树的信息。需要说明的是,不论是逻辑计划还是物理计划,执行的次序都是自下向上。因此,图中逻辑计划的计算顺序是从全表扫描到按性别过滤,每个步骤的含义都是准备“做什么”。
|
||||
|
||||
例如,在计划的最底层,Relation节点“告诉”Catalyst:“你需要扫描一张表,这张表有4个字段,分别是ABCD,文件格式是Parquet”。但这些信息对于小Q的优化还远远不够,我们还需要知道这张表的Schema是啥?字段的类型都是什么?字段名是否真实存在?数据表中的字段名与计划中的字段名是一致的吗?
|
||||
|
||||
因此,在逻辑计划解析环节,Catalyst就是要结合DataFrame的Schema信息,来确认计划中的表名、字段名、字段类型与实际数据是否一致。完成确认之后,Catalyst会生成“Analyzed Logical Plan”。这个时候,小Q就会从“Unresolved Logical Plan”转换成“Analyzed Logical Plan”。
|
||||
|
||||
从下图中我们能够看到,逻辑计划已经完成了一致性检查,并且可以识别两张表的字段类型,比如userId的类型是int,price字段的类型是double等等。
|
||||
|
||||
|
||||
|
||||
逻辑计划优化
|
||||
|
||||
对于现在的小Q来说,如果我们不做任何优化,直接把它转换为物理计划也可以。但是,这种照搬开发者的计算步骤去制定物理计划的方式,它的执行效率往往不是最优的。
|
||||
|
||||
为什么这么说呢?在运行时,Spark会先全量扫描Parquet格式的用户表,然后遴选出userId、name、age、gender四个字段,接着分别按照年龄和性别对数据进行过滤。
|
||||
|
||||
对于这样的执行计划来说,最开始的全量扫描显然是一种浪费。原因主要有两方面:一方面,查询实际上只涉及4个字段,并不需要email这一列数据;另一方面,字段age和gender上带有过滤条件,我们完全可以利用这些过滤条件减少需要扫描的数据量。
|
||||
|
||||
由此可见,对于同样一种计算逻辑,实现方式可以有多种,按照不同的顺序对算子做排列组合,我们就可以演化出不同的实现方式。最好的方式是,我们遵循“能省则省、能拖则拖”的开发原则,去选择所有实现方式中最优的那个。
|
||||
|
||||
同样,在面对这种“选择题”的时候,Catalyst也有一套自己的“原则”和逻辑。因此,生成“Analyzed Logical Plan”之后,Catalyst并不会止步于此,它会基于一套启发式的规则,把“Analyzed Logical Plan”转换为“Optimized Logical Plan”。
|
||||
|
||||
|
||||
|
||||
那么问题来了,Catalyst都有哪些既定的规则和逻辑呢?基于这些规则,Catalyst又是怎么做转换的呢?别着急,我们一个一个来解答,咱们先来说说Catalyst的优化规则,然后再去探讨逻辑计划的转换过程。
|
||||
|
||||
Catalyst的优化规则
|
||||
|
||||
和Catalyst相比,咱们总结出的开发原则简直就是小巫见大巫,为什么这么说呢?在新发布的Spark 3.0版本中,Catalyst总共有81条优化规则(Rules),这81条规则会分成27组(Batches),其中有些规则会被收纳到多个分组里。因此,如果不考虑规则的重复性,27组算下来总共会有129个优化规则。
|
||||
|
||||
对于如此多的优化规则,我们该怎么学呢?实际上,如果从优化效果的角度出发,这些规则可以归纳到以下3个范畴:
|
||||
|
||||
|
||||
谓词下推(Predicate Pushdown)
|
||||
列剪裁(Column Pruning)
|
||||
常量替换 (Constant Folding)
|
||||
|
||||
|
||||
首先,我们来说说谓词下推谓词下推主要是围绕着查询中的过滤条件做文章。其中,“谓词”指代的是像用户表上“age < 30”这样的过滤条件,“下推”指代的是把这些谓词沿着执行计划向下,推到离数据源最近的地方,从而在源头就减少数据扫描量。换句话说,让这些谓词越接近数据源越好。
|
||||
|
||||
不过,在下推之前,Catalyst还会先对谓词本身做一些优化,比如像OptimizeIn规则,它会把“gender in ‘M’”优化成“gender = ‘M’”,也就是把谓词in替换成等值谓词。再比如,CombineFilters规则,它会把“age < 30”和“gender = ‘M’”这两个谓词,捏合成一个谓词:“age != null AND gender != null AND age
|
||||
|
||||
完成谓词本身的优化之后,Catalyst再用PushDownPredicte优化规则,把谓词推到逻辑计划树最下面的数据源上。对于Parquet、ORC这类存储格式,结合文件注脚(Footer)中的统计信息,下推的谓词能够大幅减少数据扫描量,降低磁盘I/O开销。
|
||||
|
||||
再来说说列剪裁。列剪裁就是扫描数据源的时候,只读取那些与查询相关的字段。以小Q为例,用户表的Schema是(userId、name、age、gender、email),但是查询中压根就没有出现过email的引用,因此,Catalyst会使用 ColumnPruning规则,把email这一列“剪掉”。经过这一步优化,Spark在读取Parquet文件的时候就会跳过email这一列,从而节省I/O开销。
|
||||
|
||||
不难发现,谓词下推与列剪裁的优化动机,其实和“能省则省”的原则一样。核心思想都是用尽一切办法,减少需要扫描和处理的数据量,降低后续计算的负载。
|
||||
|
||||
最后一类优化是常量替换,它的逻辑比较简单。假设我们在年龄上加的过滤条件是“age < 12 + 18”,Catalyst会使用ConstantFolding规则,自动帮我们把条件变成“age < 30”。再比如,我们在select语句中,掺杂了一些常量表达式,Catalyst也会自动地用表达式的结果进行替换。
|
||||
|
||||
到此为止,咱们从功用和效果的角度,探讨了Catalyst逻辑优化规则的3大范畴。你可能说:“拢共就做了这么3件事,至于兴师动众地制定81条规则吗?”我们划分这3大范畴,主要是为了叙述和理解上的方便。实际上,对于开发者写出的五花八门、千奇百怪的查询语句,正是因为Catalyst不断丰富的优化规则,才让这些查询都能够享有不错的执行性能。如果没有这些优化规则的帮忙,小Q的执行性能一定会惨不忍睹。
|
||||
|
||||
最终,被Catalyst优化过后的小Q,就从“Analyzed Logical Plan”转换为“Optimized Logical Plan”,如下图所示。我们可以看到,谓词下推和列剪裁都体现到了Optimized Logical Plan中。
|
||||
|
||||
|
||||
|
||||
Catalys的优化过程
|
||||
|
||||
接下来,我继续来回答刚刚提出的第二个问题:基于这么多优化规则,Catalyst具体是怎么把“Analyzed Logical Plan”转换成“Optimized Logical Plan”的呢?其实,不管是逻辑计划(Logical Plan)还是物理计划(Physical Plan),它们都继承自QueryPlan。
|
||||
|
||||
QueryPlan的父类是TreeNode,TreeNode就是语法树中对于节点的抽象。TreeNode有一个名叫children的字段,类型是Seq[TreeNode],利用TreeNode类型,Catalyst可以很容易地构建一个树结构。
|
||||
|
||||
除了children字段,TreeNode还定义了很多高阶函数,其中最值得关注的是一个叫做transformDown的方法。transformDown的形参,正是Catalyst定义的各种优化规则,方法的返回类型还是TreeNode。另外,transformDown是个递归函数,参数的优化规则会先作用(Apply)于当前节点,然后依次作用到children中的子节点,直到整棵树的叶子节点。
|
||||
|
||||
总的来说,从“Analyzed Logical Plan”到“Optimized Logical Plan”的转换,就是从一个TreeNode生成另一个TreeNode的过程。Analyzed Logical Plan的根节点,通过调用transformDown方法,不停地把各种优化规则作用到整棵树,直到把所有27组规则尝试完毕,且树结构不再发生变化为止。这个时候,生成的TreeNode就是Optimized Logical Plan。
|
||||
|
||||
为了把复杂问题简单化,我们使用Expression,也就是表达式来解释一下这个过程。因为Expression本身也继承自TreeNode,所以明白了这个例子,TreeNode之间的转换我们也就清楚了。
|
||||
|
||||
//Expression的转换
|
||||
import org.apache.spark.sql.catalyst.expressions._
|
||||
val myExpr: Expression = Multiply(Subtract(Literal(6), Literal(4)), Subtract(Literal(1), Literal(9)))
|
||||
val transformed: Expression = myExpr transformDown {
|
||||
case BinaryOperator(l, r) => Add(l, r)
|
||||
case IntegerLiteral(i) if i > 5 => Literal(1)
|
||||
case IntegerLiteral(i) if i < 5 => Literal(0)
|
||||
}
|
||||
|
||||
|
||||
首先,我们定义了一个表达式:((6 - 4)*(1 - 9)),然后我们调用这个表达式的transformDown高阶函数。在高阶函数中,我们提供了一个用case定义的匿名函数。显然,这是一个偏函数(Partial Functions),你可以把这个匿名函数理解成“自定义的优化规则”。在这个优化规则中,我们仅考虑3种情况:
|
||||
|
||||
|
||||
对于所有的二元操作符,我们都把它转化成加法操作
|
||||
对于所有大于5的数字,我们都把它变成1
|
||||
对于所有小于5的数字,我们都把它变成0
|
||||
|
||||
|
||||
虽然我们的优化规则没有任何实质性的意义,仅仅是一种转换规则而已,但是这并不妨碍你去理解Catalyst中TreeNode之间的转换。当我们把这个规则应用到表达式((6 - 4)*(1 - 9))之后,得到的结果是另外一个表达式((1 + 0)+(0 + 1)),下面的示意图直观地展示了这个过程。
|
||||
|
||||
|
||||
|
||||
从“Analyzed Logical Plan”到“Optimized Logical Plan”的转换,与示例中表达式的转换过程如出一辙。最主要的区别在于,Catalyst的优化规则要复杂、精密得多。
|
||||
|
||||
Cache Manager优化
|
||||
|
||||
从“Analyzed Logical Plan”到“Optimized Logical Plan”的转换,Catalyst除了使用启发式的规则以外,还会利用Cache Manager做进一步的优化。
|
||||
|
||||
这里的Cache指的就是我们常说的分布式数据缓存。想要对数据进行缓存,你可以调用DataFrame的.cache或.persist,或是在SQL语句中使用“cache table”关键字。
|
||||
|
||||
Cache Manager其实很简单,它的主要职责是维护与缓存有关的信息。具体来说,Cache Manager维护了一个Mapping映射字典,字典的Key是逻辑计划,Value是对应的Cache元信息。
|
||||
|
||||
当Catalyst尝试对逻辑计划做优化时,会先尝试对Cache Manager查找,看看当前的逻辑计划或是逻辑计划分支,是否已经被记录在Cache Manager的字典里。如果在字典中可以查到当前计划或是分支,Catalyst就用InMemoryRelation节点来替换整个计划或是计划的一部分,从而充分利用已有的缓存数据做优化。
|
||||
|
||||
小结
|
||||
|
||||
今天这一讲,我们主要探讨了Catalyst优化器的逻辑优化阶段。这个阶段包含两个环节:逻辑计划解析和逻辑计划优化。
|
||||
|
||||
在逻辑计划解析环节,Catalyst结合Schema信息,对于仅仅记录语句字符串的Unresolved Logical Plan,验证表名、字段名与实际数据的一致性。解析后的执行计划称为Analyzed Logical Plan。
|
||||
|
||||
在逻辑计划优化环节,Catalyst会同时利用3方面的力量优化Analyzed Logical Plan,分别是AQE、Cache Manager和启发式的规则。它们当中,Catalyst最倚重的是启发式的规则。
|
||||
|
||||
尽管启发式的规则多达81项,但我们把它们归纳为3大范畴:谓词下推、列剪裁和常量替换。我们要重点掌握谓词下推和列剪裁,它们的优化动机和“能省则省”的开发原则一样,核心思想都是用尽一切办法,减少需要扫描和处理的数据量,降低后续计算的负载。
|
||||
|
||||
针对所有的优化规则,Catalyst优化器会通过调用TreeNode中的transformDown高阶函数,分别把它们作用到逻辑计划的每一个节点上,直到逻辑计划的结构不再改变为止,这个时候生成的逻辑计划就是Optimized Logical Plan。
|
||||
|
||||
最后,Cache Manager的作用是提供逻辑计划与数据缓存的映射关系,当现有逻辑计划或是分支出现在Cache Manager维护的映射字典的时候,Catalyst可以充分利用已有的缓存数据来优化。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
既然Catalyst在逻辑优化阶段有81条优化规则,我们还需要遵循“能省则省、能拖则拖”的开发原则吗?
|
||||
你能说说Spark为什么用偏函数,而不是普通函数来定义Catalyst的优化规则吗?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
186
专栏/Spark性能调优实战/22Catalyst物理计划:你的SQL语句是怎么被优化的(下)?.md
Normal file
186
专栏/Spark性能调优实战/22Catalyst物理计划:你的SQL语句是怎么被优化的(下)?.md
Normal file
@ -0,0 +1,186 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 Catalyst物理计划:你的SQL语句是怎么被优化的(下)?
|
||||
你好,我是吴磊。
|
||||
|
||||
上一讲我们说了,Catalyst优化器的逻辑优化过程包含两个环节:逻辑计划解析和逻辑计划优化。逻辑优化的最终目的就是要把Unresolved Logical Plan从次优的Analyzed Logical Plan最终变身为执行高效的Optimized Logical Plan。
|
||||
|
||||
但是,逻辑优化的每一步仅仅是从逻辑上表明Spark SQL需要“做什么”,并没有从执行层面说明具体该“怎么做”。因此,为了把逻辑计划交付执行,Catalyst还需要把Optimized Logical Plan转换为物理计划。物理计划比逻辑计划更具体,它明确交代了Spark SQL的每一步具体该怎么执行。
|
||||
|
||||
|
||||
|
||||
今天这一讲,我们继续追随小Q的脚步,看看它经过Catalyst的物理优化阶段之后,还会发生哪些变化。
|
||||
|
||||
优化Spark Plan
|
||||
|
||||
物理阶段的优化是从逻辑优化阶段输出的Optimized Logical Plan开始的,因此我们先来回顾一下小Q的原始查询和Optimized Logical Plan。
|
||||
|
||||
|
||||
val userFile: String = _
|
||||
val usersDf = spark.read.parquet(userFile)
|
||||
usersDf.printSchema
|
||||
/**
|
||||
root
|
||||
|-- userId: integer (nullable = true)
|
||||
|-- name: string (nullable = true)
|
||||
|-- age: integer (nullable = true)
|
||||
|-- gender: string (nullable = true)
|
||||
|-- email: string (nullable = true)
|
||||
*/
|
||||
val users = usersDf
|
||||
.select("name", "age", "userId")
|
||||
.filter($"age" < 30)
|
||||
.filter($"gender".isin("M"))
|
||||
|
||||
val txFile: String = _
|
||||
val txDf = spark.read.parquet(txFile)
|
||||
txDf.printSchema
|
||||
/**
|
||||
root
|
||||
|-- txId: integer (nullable = true)
|
||||
|-- userId: integer (nullable = true)
|
||||
|-- price: float (nullable = true)
|
||||
|-- volume: integer (nullable = true)
|
||||
*/
|
||||
|
||||
val result = txDF.select("price", "volume", "userId")
|
||||
.join(users, Seq("userId"), "inner")
|
||||
.groupBy(col("name"), col("age")).agg(sum(col("price") * col("volume")).alias("revenue"))
|
||||
|
||||
result.write.parquet("_")
|
||||
|
||||
|
||||
|
||||
两表关联的查询语句经过转换之后,得到的Optimized Logical Plan如下图所示。注意,在逻辑计划的根节点,出现了“Join Inner”字样,Catalyst优化器明确了这一步需要做内关联。但是,怎么做内关联,使用哪种Join策略来进行关联,Catalyst并没有交代清楚。因此,逻辑计划本身不具备可操作性。
|
||||
|
||||
|
||||
|
||||
为了让查询计划(Query Plan)变得可操作、可执行,Catalyst的物理优化阶段(Physical Planning)可以分为两个环节:优化Spark Plan和生成Physical Plan。
|
||||
|
||||
|
||||
在优化Spark Plan的过程中,Catalyst基于既定的优化策略(Strategies),把逻辑计划中的关系操作符一一映射成物理操作符,生成Spark Plan。
|
||||
在生成Physical Plan过程中,Catalyst再基于事先定义的Preparation Rules,对Spark Plan做进一步的完善、生成可执行的Physical Plan。
|
||||
|
||||
|
||||
那么问题来了,在优化Spark Plan的过程中,Catalyst都有哪些既定的优化策略呢?从数量上来说,Catalyst有14类优化策略,其中有6类和流计算有关,剩下的8类适用于所有的计算场景,如批处理、数据分析、机器学习和图计算,当然也包括流计算。因此,我们只需了解这8类优化策略。
|
||||
|
||||
|
||||
|
||||
所有优化策略在转换方式上都大同小异,都是使用基于模式匹配的偏函数(Partial Functions),把逻辑计划中的操作符平行映射为Spark Plan中的物理算子。比如,BasicOperators策略直接把Project、Filter、Sort等逻辑操作符平行地映射为物理操作符。其他策略的优化过程也类似,因此,在优化Spark Plan这一环节,咱们只要抓住一个“典型”策略,掌握它的转换过程即可。
|
||||
|
||||
那我们该抓谁做“典型”呢?我觉得,这个“典型”至少要满足两个标准:一,它要在我们的应用场景中非常普遍;二,它的取舍对于执行性能的影响最为关键。以这两个标准去遴选上面的8类策略,我们分分钟就能锁定JoinSelection。接下来,我们就以JoinSelection为例,详细讲解这一环节的优化过程。
|
||||
|
||||
如果用一句话来概括JoinSelection的优化过程,就是结合多方面的信息,来决定在物理优化阶段采用哪种Join策略。那么问题来了,Catalyst都有哪些Join策略?
|
||||
|
||||
Catalyst都有哪些Join策略?
|
||||
|
||||
结合Joins的实现机制和数据的分发方式,Catalyst在运行时总共支持5种Join策略,分别是Broadcast Hash Join(BHJ)、Shuffle Sort Merge Join(SMJ)、Shuffle Hash Join(SHJ)、Broadcast Nested Loop Join(BNLJ)和Shuffle Cartesian Product Join(CPJ)。
|
||||
|
||||
|
||||
|
||||
通过上表中5种Join策略的含义,我们知道,它们是来自2种数据分发方式(广播和Shuffle)与3种Join实现机制(Hash Joins、Sort Merge Joins和Nested Loop Joins)的排列组合。那么,在JoinSelection的优化过程中,Catalyst会基于什么逻辑,优先选择哪种Join策略呢?
|
||||
|
||||
|
||||
|
||||
JoinSelection如何决定选择哪一种Join策略?
|
||||
|
||||
逻辑其实很简单,Catalyst总会尝试优先选择执行效率最高的策略。具体来说,在选择join策略的时候,JoinSelection会先判断当前查询是否满足BHJ所要求的先决条件:如果满足就立即选中BHJ;如果不满足,就继续判断当前查询是否满足SMJ的先决条件。以此类推,直到最终选无可选,用CPJ来兜底。
|
||||
|
||||
那么问题来了,这5种Join策略都需要满足哪些先决条件呢?换句话说,JoinSelection做决策时都要依赖哪些信息呢?
|
||||
|
||||
总的来说,这些信息分为两大类,第一类是“条件型”信息,用来判决5大Join策略的先决条件。第二类是“指令型”信息,也就是开发者提供的Join Hints。
|
||||
|
||||
我们先来说“条件型”信息,它包含两种。第一种是Join类型,也就是是否等值、连接形式等,这种信息的来源是查询语句本身。第二种是内表尺寸,这些信息的来源就比较广泛了,可以是Hive表之上的ANALYZE TABLE语句,也可以是Spark对于Parquet、ORC、CSV等源文件的尺寸预估,甚至是来自AQE的动态统计信息。
|
||||
|
||||
5大Join策略对于这些信息的要求,我都整理到了下面的表格里,你可以看一看。
|
||||
|
||||
|
||||
|
||||
指令型信息也就是Join Hints,它的种类非常丰富,它允许我们把个人意志凌驾于Spark SQL之上。比如说,如果我们对小Q的查询语句做了如下的调整,JoinSelection在做Join策略选择的时候就会优先尊重我们的意愿,跳过SMJ去选择排序更低的SHJ。具体的代码示例如下:
|
||||
|
||||
val result = txDF.select("price", "volume", "userId")
|
||||
.join(users.hint("shuffle_hash"), Seq("userId"), "inner")
|
||||
.groupBy(col("name"), col("age")).agg(sum(col("price") *
|
||||
col("volume")).alias("revenue"))
|
||||
|
||||
|
||||
熟悉了JoinSelection选择Join策略的逻辑之后,我们再来看小Q是怎么选择的。小Q是典型的星型查询,也就是事实表与维度表之间的数据关联,其中维表还带过滤条件。在决定采用哪种Join策略的时候,JoinSelection优先尝试判断小Q是否满足BHJ的先决条件。
|
||||
|
||||
显然,小Q是等值的Inner Join,因此表格中BHJ那一行的前两个条件小Q都满足。但是,内表users尺寸较大,超出了广播阈值的默认值10MB,不满足BHJ的第三个条件。因此,JoinSelection不得不忍痛割爱、放弃BHJ策略,只好退而求其次,沿着表格继续向下,尝试判断小Q是否满足SMJ的先决条件。
|
||||
|
||||
SMJ的先决条件很宽松,查询语句只要是等值Join就可以。小Q自然是满足这个条件的,因此JoinSelection最终给小Q选定的Join策略就是SMJ。下图是小Q优化过后的Spark Plan,从中我们可以看到,查询计划的根节点正是SMJ。
|
||||
|
||||
|
||||
|
||||
现在我们知道了Catalyst都有哪些Join策略,JoinSelection如何对不同的Join策略做选择。小Q也从Optimized Logical Plan摇身一变,转换成了Spark Plan,也明确了在运行时采用SMJ来做关联计算。不过,即使小Q在Spark Plan中已经明确了每一步该“怎么做”,但是,Spark还是做不到把这样的查询计划转化成可执行的分布式任务,这又是为什么呢?
|
||||
|
||||
生成Physical Plan
|
||||
|
||||
原来,Shuffle Sort Merge Join的计算需要两个先决条件:Shuffle和排序。而Spark Plan中并没有明确指定以哪个字段为基准进行Shuffle,以及按照哪个字段去做排序。
|
||||
|
||||
因此,Catalyst需要对Spark Plan做进一步的转换,生成可操作、可执行的Physical Plan。具体怎么做呢?我们结合Catalyst物理优化阶段的流程图来详细讲讲。
|
||||
|
||||
|
||||
|
||||
从上图中我们可以看到,从Spark Plan到Physical Plan的转换,需要几组叫做Preparation Rules的规则。这些规则坚守最后一班岗,负责生成Physical Plan。那么,这些规则都是什么,它们都做了哪些事情呢?我们一起来看一下。
|
||||
|
||||
|
||||
|
||||
Preparation Rules有6组规则,这些规则作用到Spark Plan之上就会生成Physical Plan,而Physical Plan最终会由Tungsten转化为用于计算RDD的分布式任务。
|
||||
|
||||
小Q的查询语句很典型,也很简单,并不涉及子查询,更不存在Python UDF。因此,在小Q的例子中,我们并不会用到子查询、数据复用或是Python UDF之类的规则,只有EnsureRequirements和CollapseCodegenStages这两组规则会用到小Q的Physical Plan转化中。
|
||||
|
||||
实际上,它们也是结构化查询中最常见、最常用的两组规则。今天,我们先来重点说说EnsureRequirements规则的含义和作用。至于CollapseCodegenStages规则,它实际上就是Tungsten的WSCG功能,我们下一讲再详细说。
|
||||
|
||||
EnsureRequirements规则
|
||||
|
||||
EnsureRequirements翻译过来就是“确保满足前提条件”,这是什么意思呢?对于执行计划中的每一个操作符节点,都有4个属性用来分别描述数据输入和输出的分布状态。
|
||||
|
||||
|
||||
|
||||
EnsureRequirements规则要求,子节点的输出数据要满足父节点的输入要求。这又怎么理解呢?
|
||||
|
||||
|
||||
|
||||
我们以小Q的Spark Plan树形结构图为例,可以看到:图中左右两个分支分别表示扫描和处理users表和transactions表。在树的最顶端,根节点SortMergeJoin有两个Project子节点,它们分别用来表示users表和transactions表上的投影数据。这两个Project的outputPartitioning属性和outputOrdering属性分别是Unknow和None。因此,它们输出的数据没有按照任何列进行Shuffle或是排序。
|
||||
|
||||
但是,SortMergeJoin对于输入数据的要求很明确:按照userId分成200个分区且排好序,而这两个Project子节点的输出显然并没有满足父节点SortMergeJoin的要求。这个时候,EnsureRequirements规则就要介入了,它通过添加必要的操作符,如Shuffle和排序,来保证SortMergeJoin节点对于输入数据的要求一定要得到满足,如下图所示。
|
||||
|
||||
|
||||
|
||||
在两个Project节点之后,EnsureRequirements规则分别添加了Exchange和Sort节点。其中Exchange节点代表Shuffle操作,用来满足SortMergeJoin对于数据分布的要求;Sort表示排序,用于满足SortMergeJoin对于数据有序的要求。
|
||||
|
||||
添加了必需的节点之后,小Q的Physical Plan已经相当具体了。这个时候,Spark可以通过调用Physical Plan的doExecute方法,把结构化查询的计算结果,转换成RDD[InternalRow],这里的InternalRow,就是Tungsten设计的定制化二进制数据结构,这个结构我们在内存视角(一)有过详细的讲解,你可以翻回去看看。通过调用RDD[InternalRow]之上的Action算子,Spark就可以触发Physical Plan从头至尾依序执行。
|
||||
|
||||
最后,我们再来看看小Q又发生了哪些变化。
|
||||
|
||||
|
||||
|
||||
首先,我们看到EnsureRequirements规则在两个分支的顶端分别添加了Exchange和Sort操作,来满足根节点SortMergeJoin的计算需要。其次,如果你仔细观察的话,会发现Physical Plan中多了很多星号“*”,这些星号的后面还带着括号和数字,如图中的“*(3)”、“*(1)”。这种星号“*”标记表示的就是WSCG,后面的数字代表Stage编号。因此,括号中数字相同的操作,最终都会被捏合成一份“手写代码”,也就是我们下一讲要说的Tungsten的WSCG。
|
||||
|
||||
至此,小Q从一个不考虑执行效率的“叛逆少年”,就成长为了一名执行高效的“专业人士”,Catalyst这位人生导师在其中的作用功不可没。
|
||||
|
||||
小结
|
||||
|
||||
为了把逻辑计划转换为可以交付执行的物理计划,Spark SQL物理优化阶段包含两个环节:优化Spark Plan和生成Physical Plan。
|
||||
|
||||
在优化Spark Plan这个环节,Catalyst基于既定的策略把逻辑计划平行映射为Spark Plan。策略很多,我们重点掌握JoinSelection策略就可以,它被用来在运行时选择最佳的Join策略。JoinSelection按照BHJ > SMJ > SHJ > BNLJ > CPJ的顺序,依次判断查询语句是否满足每一种Join策略的先决条件进行“择优录取”。
|
||||
|
||||
如果开发者不满足于JoinSelection默认的选择顺序,也就是BHJ > SMJ > SHJ > BNLJ > CPJ,还可以通过在SQL或是DSL语句中引入Join hints,来明确地指定Join策略,从而把自己的意愿凌驾于Catalyst之上。不过,需要我们注意的是,要想让指定的Join策略在运行时生效,查询语句也必须要满足其先决条件才行。
|
||||
|
||||
在生成Physical Plan这个环节,Catalyst基于既定的几组Preparation Rules,把优化过后的Spark Plan转换成可以交付执行的物理计划,也就是Physical Plan。在这些既定的Preparation Rules当中,你需要重点掌握EnsureRequirements规则。
|
||||
|
||||
EnsureRequirements用来确保每一个操作符的输入条件都能够得到满足,在必要的时候,会把必需的操作符强行插入到Physical Plan中。比如对于Shuffle Sort Merge Join来说,这个操作符对于子节点的数据分布和顺序都是有明确要求的,因此,在子节点之上,EnsureRequirements会引入新的操作符如Exchange和Sort。
|
||||
|
||||
每日一练
|
||||
|
||||
3种Join实现方式和2种网络分发模式,明明应该有6种Join策略,为什么Catalyst没有支持Broadcast Sort Merge Join策略?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
169
专栏/Spark性能调优实战/23钨丝计划:Tungsten给开发者带来了哪些福报?.md
Normal file
169
专栏/Spark性能调优实战/23钨丝计划:Tungsten给开发者带来了哪些福报?.md
Normal file
@ -0,0 +1,169 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 钨丝计划:Tungsten给开发者带来了哪些福报?
|
||||
你好,我是吴磊。
|
||||
|
||||
通过前两讲的学习,我们知道在Spark SQL这颗智能大脑中,“左脑”Catalyst优化器负责把查询语句最终转换成可执行的Physical Plan。但是,把Physical Plan直接丢给Spark去执行并不是最优的选择,最优的选择是把它交给“右脑”Tungsten再做一轮优化。
|
||||
|
||||
Tungsten又叫钨丝计划,它主要围绕内核引擎做了两方面的改进:数据结构设计和全阶段代码生成(WSCG,Whole Stage Code Generation)。
|
||||
|
||||
今天这一讲,我们就来说说Tungsten的设计初衷是什么,它的两方面改进到底解决了哪些问题,以及它给开发者到底带来了哪些性能红利。
|
||||
|
||||
Tungsten在数据结构方面的设计
|
||||
|
||||
相比Spark Core,Tungsten在数据结构方面做了两个比较大的改进,一个是紧凑的二进制格式Unsafe Row,另一个是内存页管理。我们一个一个来说。
|
||||
|
||||
Unsafe Row:二进制数据结构
|
||||
|
||||
Unsafe Row是一种字节数组,它可以用来存储下图所示Schema为(userID,name,age,gender)的用户数据条目。总的来说,所有字段都会按照Schema中的顺序安放在数组中。其中,定长字段的值会直接安插到字节中,而变长字段会先在Schema的相应位置插入偏移地址,再把字段长度和字段值存储到靠后的元素中。更详细的例子我们在第9讲说过,你可以去看看。
|
||||
|
||||
|
||||
|
||||
那么,这种存储方式有什么优点呢?我们不妨用逆向思维来思考这个问题,如果采用JVM传统的对象方式来存储相同Schema的数据条目会发生什么。
|
||||
|
||||
JVM至少需要6个对象才能存储一条用户数据。其中,GenericMutableRow用于封装一条数据,Array用于存储实际的数据值。Array中的每个元素都是一个对象,如承载整型的BoxedInteger、承载字符串的String等等。这样的存储方式有两个明显的缺点。
|
||||
|
||||
首先,存储开销大。我们拿类型是String的name来举例,如果一个用户的名字叫做“Mike”,它本应该只占用4个字节,但在JVM的对象存储中,“Mike”会消耗总共48个字节,其中包括12个字节的对象头信息、8字节的哈希编码、8字节的字段值存储和另外20个字节的其他开销。从4个字节到48个字节,存储开销可见一斑。
|
||||
|
||||
其次,在JVM堆内内存中,对象数越多垃圾回收效率越低。因此,一条数据记录用一个对象来封装是最好的。但是,我们从下图中可以看到,JVM需要至少6个对象才能存储一条数据记录。如果你的样本数是1千亿的话,这意味着JVM需要管理6千亿的对象,GC的压力就会陡然上升。
|
||||
|
||||
|
||||
|
||||
我们反过来再看UnsafeRow,字节数组的存储方式在消除存储开销的同时,仅用一个数组对象就能轻松完成一条数据的封装,显著降低GC压力,可以说是一举两得。由此可见,Unsafe Row带来的潜在性能收益还是相当可观的。不过,Tungsten并未止步于此,为了统一堆外与堆内内存的管理,同时进一步提升数据存储效率与GC效率,Tungsten还推出了基于内存页的内存管理模式。
|
||||
|
||||
基于内存页的内存管理
|
||||
|
||||
为了统一管理Off Heap和On Heap内存空间,Tungsten定义了统一的128位内存地址,简称Tungsten地址。Tungsten地址分为两部分:前64位预留给Java Object,后64位是偏移地址Offset。但是,同样是128位的Tungsten地址,Off Heap和On Heap两块内存空间在寻址方式上截然不同。
|
||||
|
||||
对于On Heap空间的Tungsten地址来说,前64位存储的是JVM堆内对象的引用或者说指针,后64位Offset存储的是数据在该对象内的偏移地址。而Off Heap空间则完全不同,在堆外的空间中,由于Spark是通过Java Unsafe API直接管理操作系统内存,不存在内存对象的概念,因此前64位存储的是null值,后64位则用于在堆外空间中直接寻址操作系统的内存空间。
|
||||
|
||||
显然,在Tungsten模式下,管理On Heap会比Off Heap更加复杂。这是因为,在On Heap内存空间寻址堆内数据必须经过两步:第一步,通过前64位的Object引用来定位JVM对象;第二步,结合Offset提供的偏移地址在堆内内存空间中找到所需的数据。
|
||||
|
||||
JVM对象地址与偏移地址的关系,就好比是数组的起始地址与数组元素偏移地址之间的关系。给定起始地址和偏移地址之后,系统就可以迅速地寻址到数据元素。因此,在上面的两个步骤中,如何通过Object引用来定位JVM对象就是关键了。接下来,我们就重点解释这个环节。
|
||||
|
||||
|
||||
|
||||
如上图所示,Tungsten使用一种叫做页表(Page Table)的数据结构,来记录从Object引用到JVM对象地址的映射。页表中记录的是一个又一个内存页(Memory Page),内存页实际上就是一个JVM对象而已。只要给定64位的Object引用,Tungsten就能通过页表轻松拿到JVM对象地址,从而完成寻址。
|
||||
|
||||
那么,Tungsten使用这种方式来管理内存有什么收益呢?我们不妨以常用的HashMap数据结构为例,来对比Java标准库(java.util.HashMap)和Tungsten模式下的HashMap。
|
||||
|
||||
|
||||
|
||||
Java标准库采用数组加链表的方式来实现HashMap,如上图所示,数组元素存储Hash code和链表头。链表节点存储3个元素,分别是Key引用、Value引用和下一个元素的地址。一般来说,如果面试官要求你实现一个HashMap,我们往往也会采用这种实现方式。
|
||||
|
||||
但是,这种实现方式会带来两个弊端。
|
||||
|
||||
首先是存储开销和GC负担比较大。结合上面的示意图我们不难发现,存储数据的对象值只占整个HashMap一半的存储空间,另外一半的存储空间用来存储引用和指针,这50%的存储开销还是蛮大的。而且我们发现,图中每一个Key、Value和链表元素都是JVM对象。假设,我们用HashMap来存储一百万条数据条目,那么JVM对象的数量至少是三百万。由于JVM的GC效率与对象数量成反比,因此java.util.HashMap的实现方式对于GC并不友好。
|
||||
|
||||
其次,在数据访问的过程中,标准库实现的HashMap容易降低CPU缓存命中率,进而降低CPU利用率。链表这种数据结构的特点是,对写入友好,但访问低效。用链表存储数据的方式确实很灵活,这让JVM可以充分利用零散的内存区域,提升内存利用率。但是,在对链表进行全量扫描的时候,这种零散的存储方式会引入大量的随机内存访问(Random Memory Access)。相比顺序访问,随机内存访问会大幅降低CPU cache命中率。
|
||||
|
||||
|
||||
|
||||
那么,针对以上几个弊端,Tungsten又是怎么解决的呢?我们从存储开销、GC效率和CPU cache命中率分别来看。
|
||||
|
||||
首先,Tungsten放弃了链表的实现方式,使用数组加内存页的方式来实现HashMap。数组中存储的元素是Hash code和Tungsten内存地址,也就是Object引用外加Offset的128位地址。Tungsten HashMap使用128位地址来寻址数据元素,相比java.util.HashMap大量的链表指针,在存储开销上更低。
|
||||
|
||||
其次,Tungsten HashMap的存储单元是内存页,内存页本质上是Java Object,一个内存页可以存储多个数据条目。因此,相比标准库中的HashMap,使用内存页大幅缩减了存储所需的对象数量。比如说,我们需要存储一百万条数据记录,标准库的HashMap至少需要三百万的JVM对象才能存下,而Tungsten HashMap可能只需要几个或是十几个内存页就能存下。对比下来,它们所需的JVM对象数量可以说是天壤之别,显然,Tungsten的实现方式对于GC更加友好。
|
||||
|
||||
再者,内存页本质上是JVM对象,其内部使用连续空间来存储数据,内存页加偏移量可以精准地定位到每一个数据元素。因此,在需要扫描HashMap全量数据的时候,得益于内存页中连续存储的方式,内存的访问方式从原来的随机访问变成了顺序读取(Sequential Access)。顺序内存访问会大幅提升CPU cache利用率,减少CPU中断,显著提升CPU利用率。
|
||||
|
||||
如何理解WSCG?
|
||||
|
||||
接下来,我们再说说WSCG。首先,WSCG到底是什么?这就要提到内存计算的第二层含义了,它指的是在同一个Stage内部,把多个RDD的compute函数捏合成一个,然后把这一个函数一次性地作用在输入数据上。不过,这种捏合方式采用的是迭代器嵌套的方式。例如,土豆工坊中对于Stage0的处理,也就是下图中的fuse函数。它仅仅是clean、slice、bake三个函数的嵌套,并没有真正融合为一个函数。
|
||||
|
||||
|
||||
|
||||
WSCG指的是基于同一Stage内操作符之间的调用关系,生成一份“手写代码”,真正把所有计算融合为一个统一的函数。
|
||||
|
||||
什么是火山迭代模型?
|
||||
|
||||
那么,我们真的有必要把三个函数体融合成一个函数,甚至生成一份“手写代码”吗?迭代器嵌套的函数调用难道还不够吗?坦白说,迭代器嵌套还真不够。原因在于,迭代器嵌套的计算模式会涉及两种操作,一个是内存数据的随机存取,另一个是虚函数调用(next)。这两种操作都会降低CPU的缓存命中率,影响CPU的工作效率。这么说比较抽象,我们来举个小例子。
|
||||
|
||||
|
||||
|
||||
假设,现在有一张市民表,我们要从中统计在北京的人数。对应的语法树非常简单,从左到右,分别是数据扫描、过滤、投影和聚合。语法树先是经过“左脑”Catalyst优化器转换为Physical Plan,然后交付执行。Tungsten出现以前,Spark在运行时采用火山迭代模型来执行计算。这里,咱们需要先简单地介绍一下火山迭代模型(Volcano Iteration Model,以下简称VI模型)。
|
||||
|
||||
VI模型这种计算模式依托AST语法树,对所有操作符(如过滤、投影)的计算进行了统一封装,所有操作符都要实现VI模型的迭代器抽象。简单来说就是,所有操作符都需要实现hasNext和next方法。因此,VI模型非常灵活、扩展能力很强,任何一个算子只要实现了迭代器抽象,都可以加入到语法树当中参与计算。另外,为了方便操作符之间的数据交换,VI模型对所有操作符的输出也做了统一的封装。
|
||||
|
||||
那么,如果上面的查询使用VI模型去执行计算的话,都需要经过哪些步骤呢?对于数据源中的每条数据条目,语法树当中的每个操作符都需要完成如下步骤:
|
||||
|
||||
|
||||
从内存中读取父操作符的输出结果作为输入数据
|
||||
调用hasNext、next方法,以操作符逻辑处理数据,如过滤、投影、聚合等等
|
||||
将处理后的结果以统一的标准形式输出到内存,供下游算子消费
|
||||
|
||||
|
||||
因此,任意两个操作符之间的交互都会涉及我们最开始说的两个步骤,也就是内存数据的随机存取和虚函数调用,而它们正是CPU有效利用率低下的始作俑者。
|
||||
|
||||
WSCG的优势是什么?
|
||||
|
||||
Tungsten引入WSCG机制,正是为了消除VI模型引入的计算开销。这是怎么做到的呢?接下来,咱们还是以市民表的查询为例,先来直观地感受一下WSCG的优势。
|
||||
|
||||
|
||||
|
||||
对于刚刚的查询语句,WSCG会结合AST语法树中不同算子的调用关系,生成如上图所示的“手写代码”。在这份手写代码中,我们把数据端到端的计算逻辑(过滤、投影、聚合)一次性地进行了实现。
|
||||
|
||||
这样一来,我们利用手写代码的实现方式不仅消除了操作符,也消除了操作符的虚函数调用,更没有不同算子之间的数据交换,计算逻辑完全是一次性地应用到数据上。而且,代码中的每一条指令都是明确的,可以顺序加载到CPU寄存器,源数据也可以顺序地加载到CPU的各级缓存中,从而大幅提升了CPU的工作效率。
|
||||
|
||||
当然,WSCG在运行时生成的代码和我们这里举例的手写代码在形式上还有差别。不过,这也并不影响我们对于WSCG特性和优势的理解。看到这里,你可能会问:“WSCG不就是运行时的代码重构吗?”没错,本质上,WSCG机制的工作过程就是基于一份“性能较差的代码”,在运行时动态地(On The Fly)重构出一份“性能更好的代码”。
|
||||
|
||||
WSCG是如何在运行时动态生成代码的?
|
||||
|
||||
问题来了,WSCG是怎么在运行时动态生成代码的呢?
|
||||
|
||||
我们还是以刚刚市民表的查询为例,语法树从左到右有Scan、Filter、Project和Aggregate4个节点。不过,因为Aggregate会引入Shuffle、切割Stage,所以这4个节点会产生两个Stage。又因为WSCG是在一个Stage内部生成手写代码,所以,我们把目光集中到前三个操作符Scan、Filter和Project构成的Stage。
|
||||
|
||||
|
||||
|
||||
上一讲中我们说了,Spark Plan在转换成Physical Plan之前,会应用一系列的Preparation Rules。这其中很重要的一环就是CollapseCodegenStages规则,它的作用正是尝试为每一个Stages生成“手写代码”。
|
||||
|
||||
总的来说,手写代码的生成过程分为两个步骤:
|
||||
|
||||
|
||||
从父节点到子节点,递归调用doProduce,生成代码框架
|
||||
从子节点到父节点,递归调用doConsume,向框架填充每一个操作符的运算逻辑
|
||||
|
||||
|
||||
这么说比较抽象,咱们以上面的第一个Stage为例,来直观地看看这个代码生成的过程。
|
||||
|
||||
|
||||
|
||||
首先,在Stage顶端节点也就是Project之上,添加WholeStageCodeGen节点。WholeStageCodeGen节点通过调用doExecute来触发整个代码生成过程的计算。doExecute会递归调用子节点的doProduce函数,直到遇到Shuffle Boundary为止。这里,Shuffle Boundary指的是Shuffle边界,要么是数据源,要么是上一个Stage的输出。在叶子节点(也就是Scan)调用的doProduce函数会先把手写代码的框架生成出来,如图中右侧蓝色部分的代码。
|
||||
|
||||
然后,Scan中的doProduce会反向递归调用每个父节点的doConsume函数。不同操作符在执行doConsume函数的过程中,会把关系表达式转化成Java代码,然后把这份代码像做“完形填空”一样,嵌入到刚刚的代码框架里。比如图中橘黄色的doConsume生成的if语句,其中包含了判断地区是否为北京的条件,以及紫色的doConsume生成了获取必需字段userId的Java代码。
|
||||
|
||||
就这样,Tungsten利用CollapseCodegenStages规则,经过两层递归调用把Catalyst输出的Spark Plan加工成了一份“手写代码”,并把这份手写代码会交付给DAGScheduler。拿到代码之后,DAGScheduler再去协调自己的两个小弟TaskScheduler和SchedulerBackend,完成分布式任务调度。
|
||||
|
||||
小结
|
||||
|
||||
Tungsten是Spark SQL的“右脑”,掌握它的特性和优势对SparkSQL的性能调优来说至关重要。具体来说,我们可以从它对内核引擎的两方面改进入手:数据结构设计和WSCG。
|
||||
|
||||
在数据结构方面,我们要掌握Tungsten的两项改进。
|
||||
|
||||
首先,Tungsten设计了UnsafeRow二进制字节序列来取代JVM对象的存储方式。这不仅可以提升CPU的存储效率,还能减少存储数据记录所需的对象个数,从而改善GC效率。
|
||||
|
||||
其次,为了统一管理堆内与堆外内存,Tungsten设计了128位的内存地址,其中前64位存储Object引用,后64位为偏移地址。
|
||||
|
||||
在堆内内存的管理上,基于Tungsten内存地址和内存页的设计机制,相比标准库,Tungsten实现的数据结构(如HashMap)使用连续空间来存储数据条目,连续内存访问有利于提升CPU缓存命中率,从而提升CPU工作效率。由于内存页本质上是Java Object,内存页管理机制往往能够大幅削减存储数据所需的对象数量,因此对GC非常友好的。
|
||||
|
||||
对于Tungsten的WSCG,我们要掌握它的概念和优势。
|
||||
|
||||
首先,WSCG指的是基于同一Stage内操作符之间的调用关系,生成一份“手写代码”,来把所有计算融合为一个统一的函数。本质上,WSCG机制的工作过程,就是基于一份“性能较差的代码”,在运行时动态地重构出一份“性能更好的代码”。
|
||||
|
||||
更重要的是,“手写代码”解决了VI计算模型的两个核心痛点:操作符之间频繁的虚函数调用,以及操作符之间数据交换引入的内存随机访问。手写代码中的每一条指令都是明确的,可以顺序加载到CPU寄存器,源数据也可以顺序地加载到CPU的各级缓存中,因此,CPU的缓存命中率和工作效率都会得到大幅提升。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
针对排序操作,你认为Tungsten在数据结构方面有哪些改进呢?
|
||||
你认为表达式代码生成(Expression Codegen)和全阶段代码生成(Whole Stage Codegen)有什么区别和联系呢?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
164
专栏/Spark性能调优实战/24Spark3.0(一):AQE的3个特性怎么才能用好?.md
Normal file
164
专栏/Spark性能调优实战/24Spark3.0(一):AQE的3个特性怎么才能用好?.md
Normal file
@ -0,0 +1,164 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
24 Spark 3.0(一):AQE的3个特性怎么才能用好?
|
||||
你好,我是吴磊。
|
||||
|
||||
目前,距离Spark 3.0版本的发布已经将近一年的时间了,这次版本升级添加了自适应查询执行(AQE)、动态分区剪裁(DPP)和扩展的 Join Hints 等新特性。利用好这些新特性,可以让我们的性能调优如虎添翼。因此,我会用三讲的时间和你聊聊它们。今天,我们先来说说AQE。
|
||||
|
||||
我发现,同学们在使用AQE的时候总会抱怨说:“AQE的开关打开了,相关的配置项也设了,可应用性能还是没有提升。”这往往是因为我们对于AQE的理解不够透彻,调优总是照葫芦画瓢,所以这一讲,我们就先从AQE的设计初衷说起,然后说说它的工作原理,最后再去探讨怎样才能用好AQE。
|
||||
|
||||
Spark为什么需要AQE?
|
||||
|
||||
在2.0版本之前,Spark SQL仅仅支持启发式、静态的优化过程,就像我们在第21、22、23三讲介绍的一样。
|
||||
|
||||
启发式的优化又叫RBO(Rule Based Optimization,基于规则的优化),它往往基于一些规则和策略实现,如谓词下推、列剪枝,这些规则和策略来源于数据库领域已有的应用经验。也就是说,启发式的优化实际上算是一种经验主义。
|
||||
|
||||
经验主义的弊端就是不分青红皂白、胡子眉毛一把抓,对待相似的问题和场景都使用同一类套路。Spark社区正是因为意识到了RBO的局限性,因此在2.2版本中推出了CBO(Cost Based Optimization,基于成本的优化)。
|
||||
|
||||
CBO的特点是“实事求是”,基于数据表的统计信息(如表大小、数据列分布)来选择优化策略。CBO支持的统计信息很丰富,比如数据表的行数、每列的基数(Cardinality)、空值数、最大值、最小值和直方图等等。因为有统计数据做支持,所以CBO选择的优化策略往往优于RBO选择的优化规则。
|
||||
|
||||
但是,CBO也面临三个方面的窘境:“窄、慢、静”。窄指的是适用面太窄,CBO仅支持注册到Hive Metastore的数据表,但在大量的应用场景中,数据源往往是存储在分布式文件系统的各类文件,如Parquet、ORC、CSV等等。
|
||||
|
||||
慢指的是统计信息的搜集效率比较低。对于注册到Hive Metastore的数据表,开发者需要调用ANALYZE TABLE COMPUTE STATISTICS语句收集统计信息,而各类信息的收集会消耗大量时间。
|
||||
|
||||
静指的是静态优化,这一点与RBO一样。CBO结合各类统计信息制定执行计划,一旦执行计划交付运行,CBO的使命就算完成了。换句话说,如果在运行时数据分布发生动态变化,CBO先前制定的执行计划并不会跟着调整、适配。
|
||||
|
||||
AQE到底是什么?
|
||||
|
||||
考虑到RBO和CBO的种种限制,Spark在3.0版本推出了AQE(Adaptive Query Execution,自适应查询执行)。如果用一句话来概括,AQE是Spark SQL的一种动态优化机制,在运行时,每当Shuffle Map阶段执行完毕,AQE都会结合这个阶段的统计信息,基于既定的规则动态地调整、修正尚未执行的逻辑计划和物理计划,来完成对原始查询语句的运行时优化。
|
||||
|
||||
从定义中,我们不难发现,AQE优化机制触发的时机是Shuffle Map阶段执行完毕。也就是说,AQE优化的频次与执行计划中Shuffle的次数一致。反过来说,如果你的查询语句不会引入Shuffle操作,那么Spark SQL是不会触发AQE的。对于这样的查询,无论你怎么调整AQE相关的配置项,AQE也都爱莫能助。
|
||||
|
||||
对于AQE的定义,我相信你还有很多问题,比如,AQE依赖的统计信息具体是什么?既定的规则和策略具体指什么?接下来,我们一一来解答。
|
||||
|
||||
首先,AQE赖以优化的统计信息与CBO不同,这些统计信息并不是关于某张表或是哪个列,而是Shuffle Map阶段输出的中间文件。学习过Shuffle的工作原理之后,我们知道,每个Map Task都会输出以data为后缀的数据文件,还有以index为结尾的索引文件,这些文件统称为中间文件。每个data文件的大小、空文件数量与占比、每个Reduce Task对应的分区大小,所有这些基于中间文件的统计值构成了AQE进行优化的信息来源。
|
||||
|
||||
其次,结合Spark SQL端到端优化流程图我们可以看到,AQE从运行时获取统计信息,在条件允许的情况下,优化决策会分别作用到逻辑计划和物理计划。
|
||||
|
||||
|
||||
|
||||
AQE既定的规则和策略主要有4个,分为1个逻辑优化规则和3个物理优化策略。我把这些规则与策略,和相应的AQE特性,以及每个特性仰仗的统计信息,都汇总到了如下的表格中,你可以看一看。
|
||||
|
||||
|
||||
|
||||
如何用好AQE?
|
||||
|
||||
那么,AQE是如何根据Map阶段的统计信息以及这4个规则与策略,来动态地调整和修正尚未执行的逻辑计划和物理计划的呢?这就要提到AQE的三大特性,也就是Join策略调整、自动分区合并,以及自动倾斜处理,我们需要借助它们去分析AQE动态优化的过程。它们的基本概念我们在第9讲说过,这里我再带你简单回顾一下。
|
||||
|
||||
|
||||
Join策略调整:如果某张表在过滤之后,尺寸小于广播变量阈值,这张表参与的数据关联就会从Shuffle Sort Merge Join降级(Demote)为执行效率更高的Broadcast Hash Join。
|
||||
自动分区合并:在Shuffle过后,Reduce Task数据分布参差不齐,AQE将自动合并过小的数据分区。
|
||||
自动倾斜处理:结合配置项,AQE自动拆分Reduce阶段过大的数据分区,降低单个Reduce Task的工作负载。
|
||||
|
||||
|
||||
接下来,我们就一起来分析这3个特性的动态优化过程。
|
||||
|
||||
Join策略调整
|
||||
|
||||
我们先来说说Join策略调整,这个特性涉及了一个逻辑规则和一个物理策略,它们分别是DemoteBroadcastHashJoin和OptimizeLocalShuffleReader。
|
||||
|
||||
DemoteBroadcastHashJoin规则的作用,是把Shuffle Joins降级为Broadcast Joins。需要注意的是,这个规则仅适用于Shuffle Sort Merge Join这种关联机制,其他机制如Shuffle Hash Join、Shuffle Nested Loop Join都不支持。对于参与Join的两张表来说,在它们分别完成Shuffle Map阶段的计算之后,DemoteBroadcastHashJoin会判断中间文件是否满足如下条件:
|
||||
|
||||
|
||||
中间文件尺寸总和小于广播阈值spark.sql.autoBroadcastJoinThreshold
|
||||
空文件占比小于配置项spark.sql.adaptive.nonEmptyPartitionRatioForBroadcastJoin
|
||||
|
||||
|
||||
只要有任意一张表的统计信息满足这两个条件,Shuffle Sort Merge Join就会降级为Broadcast Hash Join。说到这儿,你可能会问:“既然DemoteBroadcastHashJoin逻辑规则可以把Sort Merge Join转换为Broadcast Join,那同样用来调整Join策略的OptimizeLocalShuffleReader规则又是干什么用的呢?看上去有些多余啊!”
|
||||
|
||||
不知道你注意到没有,我一直强调,AQE依赖的统计信息来自于Shuffle Map阶段生成的中间文件。这意味什么呢?这就意味着AQE在开始优化之前,Shuffle操作已经执行过半了!
|
||||
|
||||
我们来举个例子,现在有两张表:事实表Order和维度表User,它们的查询语句和初始的执行计划如下。
|
||||
|
||||
//订单表与用户表关联
|
||||
select sum(order.price * order.volume), user.id
|
||||
from order inner join user
|
||||
on order.userId = user.id
|
||||
where user.type = ‘Head Users’
|
||||
group by user.id
|
||||
|
||||
|
||||
由于两张表大都到超过了广播阈值,因此Spark SQL在最初的执行计划中选择了Sort Merge Join。AQE需要同时结合两个分支中的Shuffle(Exchange)输出,才能判断是否可以降级为Broadcast Join,以及用哪张表降级。这就意味着,不论大表还是小表都要完成Shuffle Map阶段的计算,并且把中间文件落盘,AQE才能做出决策。
|
||||
|
||||
|
||||
|
||||
你可能会说:“根本不需要大表做Shuffle呀,AQE只需要去判断小表Shuffle的中间文件就好啦”。可问题是,AQE可分不清哪张是大表、哪张是小表。在Shuffle Map阶段结束之前,数据表的尺寸大小对于AQE来说是“透明的”。因此,AQE必须等待两张表都完成Shuffle Map的计算,然后统计中间文件,才能判断降级条件是否成立,以及用哪张表做广播变量。
|
||||
|
||||
在常规的Shuffle计算流程中,Reduce阶段的计算需要跨节点访问中间文件拉取数据分片。如果遵循常规步骤,即便AQE在运行时把Shuffle Sort Merge Join降级为Broadcast Join,大表的中间文件还是需要通过网络进行分发。这个时候,AQE的动态Join策略调整也就失去了实用价值。原因很简单,负载最重的大表Shuffle计算已经完成,再去决定切换到Broadcast Join已经没有任何意义。
|
||||
|
||||
在这样的背景下,OptimizeLocalShuffleReader物理策略就非常重要了。既然大表已经完成Shuffle Map阶段的计算,这些计算可不能白白浪费掉。采取OptimizeLocalShuffleReader策略可以省去Shuffle常规步骤中的网络分发,Reduce Task可以就地读取本地节点(Local)的中间文件,完成与广播小表的关联操作。
|
||||
|
||||
不过,需要我们特别注意的是,OptimizeLocalShuffleReader物理策略的生效与否由一个配置项决定。这个配置项是spark.sql.adaptive.localShuffleReader.enabled,尽管它的默认值是True,但是你千万不要把它的值改为False。否则,就像我们刚才说的,AQE的Join策略调整就变成了形同虚设。
|
||||
|
||||
说到这里,你可能会说:“这么看,AQE的Join策略调整有些鸡肋啊!毕竟Shuffle计算都已经过半,Shuffle Map阶段的内存消耗和磁盘I/O是半点没省!”确实,Shuffle Map阶段的计算开销是半点没省。但是,OptimizeLocalShuffleReader策略避免了Reduce阶段数据在网络中的全量分发,仅凭这一点,大多数的应用都能获益匪浅。因此,对于AQE的Join策略调整,我们可以用一个成语来形容:“亡羊补牢、犹未为晚”。
|
||||
|
||||
自动分区合并
|
||||
|
||||
接下来,我们再来说说自动分区合并。分区合并的原理比较简单,在Reduce阶段,当Reduce Task从全网把数据分片拉回,AQE按照分区编号的顺序,依次把小于目标尺寸的分区合并在一起。目标分区尺寸由以下两个参数共同决定。这部分我们在第10讲详细讲过,如果不记得,你可以翻回去看一看。
|
||||
|
||||
|
||||
spark.sql.adaptive.advisoryPartitionSizeInBytes,由开发者指定分区合并后的推荐尺寸。
|
||||
spark.sql.adaptive.coalescePartitions.minPartitionNum,分区合并后,分区数不能低于该值。
|
||||
|
||||
|
||||
|
||||
|
||||
除此之外,我们还要注意,在Shuffle Map阶段完成之后,AQE优化机制被触发,CoalesceShufflePartitions策略“无条件”地被添加到新的物理计划中。读取配置项、计算目标分区大小、依序合并相邻分区这些计算逻辑,在Tungsten WSCG的作用下融合进“手写代码”于Reduce阶段执行。
|
||||
|
||||
自动倾斜处理
|
||||
|
||||
与自动分区合并相反,自动倾斜处理的操作是“拆”。在Reduce阶段,当Reduce Task所需处理的分区尺寸大于一定阈值时,利用OptimizeSkewedJoin策略,AQE会把大分区拆成多个小分区。倾斜分区和拆分粒度由以下这些配置项决定。关于它们的含义与作用,我们在第10讲说过,你可以再翻回去看一看。
|
||||
|
||||
|
||||
spark.sql.adaptive.skewJoin.skewedPartitionFactor,判定倾斜的膨胀系数
|
||||
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes,判定倾斜的最低阈值
|
||||
spark.sql.adaptive.advisoryPartitionSizeInBytes,以字节为单位,定义拆分粒度
|
||||
|
||||
|
||||
自动倾斜处理的拆分操作也是在Reduce阶段执行的。在同一个Executor内部,本该由一个Task去处理的大分区,被AQE拆成多个小分区并交由多个Task去计算。这样一来,Task之间的计算负载就可以得到平衡。但是,这并不能解决不同Executors之间的负载均衡问题。
|
||||
|
||||
我们来举个例子,假设有个Shuffle操作,它的Map阶段有3个分区,Reduce阶段有4个分区。4个分区中的两个都是倾斜的大分区,而且这两个倾斜的大分区刚好都分发到了Executor 0。通过下图,我们能够直观地看到,尽管两个大分区被拆分,但横向来看,整个作业的主要负载还是落在了Executor 0的身上。Executor 0的计算能力依然是整个作业的瓶颈,这一点并没有因为分区拆分而得到实质性的缓解。
|
||||
|
||||
|
||||
|
||||
另外,在数据关联的场景中,对于参与Join的两张表,我们暂且把它们记做数据表1和数据表2,如果表1存在数据倾斜,表2不倾斜,那在关联的过程中,AQE除了对表1做拆分之外,还需要对表2对应的数据分区做复制,来保证关联关系不被破坏。
|
||||
|
||||
|
||||
|
||||
在这样的运行机制下,如果两张表都存在数据倾斜怎么办?这个时候,事情就开始变得逐渐复杂起来了。对于上图中的表1和表2,我们假设表1还是拆出来两个分区,表2因为倾斜也拆出来两个分区。这个时候,为了不破坏逻辑上的关联关系,表1、表2拆分出来的分区还要各自复制出一份,如下图所示。
|
||||
|
||||
|
||||
|
||||
如果现在问题变得更复杂了,左表拆出M个分区,右表拆出N各分区,那么每张表最终都需要保持M x N份分区数据,才能保证关联逻辑的一致性。当M和N逐渐变大时,AQE处理数据倾斜所需的计算开销将会面临失控的风险。
|
||||
|
||||
总的来说,当应用场景中的数据倾斜比较简单,比如虽然有倾斜但数据分布相对均匀,或是关联计算中只有一边倾斜,我们完全可以依赖AQE的自动倾斜处理机制。但是,当我们的场景中数据倾斜变得复杂,比如数据中不同Key的分布悬殊,或是参与关联的两表都存在大量的倾斜,我们就需要衡量AQE的自动化机制与手工处理倾斜之间的利害得失。关于手工处理倾斜,我们留到第28讲再去展开。
|
||||
|
||||
小结
|
||||
|
||||
AQE是Spark SQL的一种动态优化机制,它的诞生解决了RBO、CBO,这些启发式、静态优化机制的局限性。想要用好AQE,我们就要掌握它的特点,以及它支持的三种优化特性的工作原理和使用方法。
|
||||
|
||||
如果用一句话来概括AQE的定义,就是每当Shuffle Map阶段执行完毕,它都会结合这个阶段的统计信息,根据既定的规则和策略动态地调整、修正尚未执行的逻辑计划和物理计划,从而完成对原始查询语句的运行时优化。也因此,只有当你的查询语句会引入Shuffle操作的时候,Spark SQL才会触发AQE。
|
||||
|
||||
AQE支持的三种优化特性分别是Join策略调整、自动分区合并和自动倾斜处理。
|
||||
|
||||
关于Join策略调整,我们首先要知道DemoteBroadcastHashJoin规则仅仅适用于Shuffle Sort Merge Join这种关联机制,对于其他Shuffle Joins类型,AQE暂不支持把它们转化为Broadcast Joins。其次,为了确保AQE的Join策略调整正常运行,我们要确保spark.sql.adaptive.localShuffleReader.enabled配置项始终为开启状态。
|
||||
|
||||
关于自动分区合并,我们要知道,在Shuffle Map阶段完成之后,结合分区推荐尺寸与分区数量限制,AQE会自动帮我们完成分区合并的计算过程。
|
||||
|
||||
关于AQE的自动倾斜处理我们要知道,它只能以Task为粒度缓解数据倾斜,并不能解决不同Executors之间的负载均衡问题。针对场景较为简单的倾斜问题,比如关联计算中只涉及单边倾斜,我们完全可以依赖AQE的自动倾斜处理机制。但是,当数据倾斜问题变得复杂的时候,我们需要衡量AQE的自动化机制与手工处理倾斜之间的利害得失。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
我们知道,AQE依赖的统计信息来源于Shuffle Map阶段输出的中间文件。你觉得,在运行时,AQE还有其他渠道可以获得同样的统计信息吗?
|
||||
AQE的自动倾斜处理机制只能以Task为粒度来平衡工作负载,如果让你重新实现这个机制,你有什么更好的办法能让AQE以Executors为粒度做到负载均衡吗?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
126
专栏/Spark性能调优实战/25Spark3.0(二):DPP特性该怎么用?.md
Normal file
126
专栏/Spark性能调优实战/25Spark3.0(二):DPP特性该怎么用?.md
Normal file
@ -0,0 +1,126 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
25 Spark 3.0(二):DPP特性该怎么用?
|
||||
你好,我是吴磊。
|
||||
|
||||
DPP(Dynamic Partition Pruning,动态分区剪裁)是Spark 3.0版本中第二个引人注目的特性,它指的是在星型数仓的数据关联场景中,可以充分利用过滤之后的维度表,大幅削减事实表的数据扫描量,从整体上提升关联计算的执行性能。
|
||||
|
||||
今天这一讲,我们就通过一个电商场景下的例子,来说说什么是分区剪裁,什么是动态分区剪裁,它的作用、用法和注意事项,让你一次就学会怎么用好DPP。
|
||||
|
||||
分区剪裁
|
||||
|
||||
我们先来看这个例子。在星型(Start Schema)数仓中,我们有两张表,一张是订单表orders,另一张是用户表users。显然,订单表是事实表(Fact),而用户表是维度表(Dimension)。业务需求是统计所有头部用户贡献的营业额,并按照营业额倒序排序。那这个需求该怎么实现呢?
|
||||
|
||||
首先,我们来了解一下两张表的关键字段,看看查询语句应该怎么写。
|
||||
|
||||
// 订单表orders关键字段
|
||||
userId, Int
|
||||
itemId, Int
|
||||
price, Float
|
||||
quantity, Int
|
||||
|
||||
// 用户表users关键字段
|
||||
id, Int
|
||||
name, String
|
||||
type, String //枚举值,分为头部用户和长尾用户
|
||||
|
||||
|
||||
|
||||
给定上述数据表,我们只需把两张表做内关联,然后分组、聚合、排序,就可以实现业务逻辑,具体的查询语句如下。
|
||||
|
||||
select (orders.price * order.quantity) as income, users.name
|
||||
from orders inner join users on orders.userId = users.id
|
||||
where users.type = ‘Head User’
|
||||
group by users.name
|
||||
order by income desc
|
||||
|
||||
|
||||
看到这样的查询语句,再结合Spark SQL那几讲学到的知识,我们很快就能画出它的逻辑执行计划。
|
||||
|
||||
|
||||
|
||||
由于查询语句中事实表上没有过滤条件,因此,在执行计划的左侧,Spark SQL选择全表扫描的方式来投影出userId、price和quantity这些字段。相反,维度表上有过滤条件users.type = ‘Head User’,因此,Spark SQL可以应用谓词下推规则,把过滤操作下推到数据源之上,来减少必需的磁盘I/O开销。
|
||||
|
||||
虽然谓词下推已经很给力了,但如果用户表支持分区剪裁(Partition Pruning),I/O效率的提升就会更加显著。那什么是分区剪裁呢?实际上,分区剪裁是谓词下推的一种特例,它指的是在分区表中下推谓词,并以文件系统目录为单位对数据集进行过滤。分区表就是通过指定分区键,然后使用partitioned by语句创建的数据表,或者是使用partitionBy语句存储的列存文件(如Parquet、ORC等)。
|
||||
|
||||
相比普通数据表,分区表特别的地方就在于它的存储方式。对于分区键中的每一个数据值,分区表都会在文件系统中创建单独的子目录来存储相应的数据分片。拿用户表来举例,假设用户表是分区表,且以type字段作为分区键,那么用户表会有两个子目录,前缀分别是“Head User”和“Tail User”。数据记录被存储于哪个子目录完全取决于记录中type字段的值,比如:所有type字段值为“Head User”的数据记录都被存储到前缀为“Head User”的子目录。同理,所有type字段值为“Tail User”的数据记录,全部被存放到前缀为“Tail User”的子目录。
|
||||
|
||||
不难发现,如果过滤谓词中包含分区键,那么Spark SQL对分区表做扫描的时候,是完全可以跳过(剪掉)不满足谓词条件的分区目录,这就是分区剪裁。例如,在我们的查询语句中,用户表的过滤谓词是“users.type = ‘Head User’”。假设用户表是分区表,那么对于用户表的数据扫描,Spark SQL可以完全跳过前缀为“Tail User”的子目录。
|
||||
|
||||
|
||||
|
||||
通过与谓词下推作对比,我们可以直观地感受分区剪裁的威力。如图所示,上下两行分别表示用户表在不做分区和做分区的情况下,Spark SQL对于用户表的数据扫描。在不做分区的情况下,用户表所有的数据分片全部存于同一个文件系统目录,尽管Parquet格式在注脚(Footer)中提供了type字段的统计值,Spark SQL可以利用谓词下推来减少需要扫描的数据分片,但由于很多分片注脚中的type字段同时包含‘Head User’和‘Tail User’(第一行3个浅绿色的数据分片),因此,用户表的数据扫描仍然会涉及4个数据分片。
|
||||
|
||||
相反,当用户表本身就是分区表时,由于type字段为‘Head User’的数据记录全部存储到前缀为‘Head User’的子目录,也就是图中第二行浅绿色的文件系统目录,这个目录中仅包含两个type字段全部为‘Head User’的数据分片。这样一来,Spark SQL可以完全跳过其他子目录的扫描,从而大幅提升I/O效率。
|
||||
|
||||
你可能会说:“既然分区剪裁这么厉害,那么我是不是也可以把它应用到事实表上去呢?毕竟事实表的体量更大,相比维度表,事实表上I/O效率的提升空间更大。”没错,如果事实表本身就是分区表,且过滤谓词中包含分区键,那么Spark SQL同样会利用分区剪裁特性来大幅减少数据扫描量。
|
||||
|
||||
不过,对于实际工作中的绝大多数关联查询来说,事实表都不满足分区剪裁所需的前提条件。比如说,要么事实表不是分区表,要么事实表上没有过滤谓词,或者就是过滤谓词不包含分区键。就拿电商场景的例子来说,查询中压根就没有与订单表相关的过滤谓词。因此,即便订单表本身就是分区表,Spark SQL也没办法利用分区剪裁特性。
|
||||
|
||||
对于这样的关联查询,我们是不是只能任由Spark SQL去全量扫描事实表呢?要是在以前,我们还没什么办法。不过,有了Spark 3.0推出的DPP特性之后,情况就大不一样了。
|
||||
|
||||
动态分区剪裁
|
||||
|
||||
我们刚才说了,DPP指的是在数据关联的场景中,Spark SQL利用维度表提供的过滤信息,减少事实表中数据的扫描量、降低I/O开销,从而提升执行性能。那么,DPP是怎么做到这一点的呢?它背后的逻辑是什么?为了方便你理解,我们还用刚刚的例子来解释。
|
||||
|
||||
|
||||
|
||||
首先,过滤条件users.type = ‘Head User’会帮助维度表过滤一部分数据。与此同时,维度表的ID字段也顺带着经过一轮筛选,如图中的步骤1所示。经过这一轮筛选之后,保留下来的ID值,仅仅是维度表ID全集的一个子集。
|
||||
|
||||
然后,在关联关系也就是orders.userId = users.id的作用下,过滤效果会通过users的ID字段传导到事实表的userId字段,也就是图中的步骤2。这样一来,满足关联关系的userId值,也是事实表userId全集中的一个子集。把满足条件的userId作为过滤条件,应用(Apply)到事实表的数据源,就可以做到减少数据扫描量,提升I/O效率,如图中的步骤3所示。
|
||||
|
||||
DPP正是基于上述逻辑,把维度表中的过滤条件,通过关联关系传导到事实表,从而完成事实表的优化。虽然DPP的运作逻辑非常清晰,但并不是所有的数据关联场景都可以享受到DPP的优化机制,想要利用DPP来加速事实表数据的读取和访问,数据关联场景还要满足三个额外的条件。
|
||||
|
||||
首先,DPP是一种分区剪裁机制,它是以分区为单位对事实表进行过滤。结合刚才的逻辑,维度表上的过滤条件会转化为事实表上Join Key的过滤条件。具体到我们的例子中,就是orders.userId这个字段。显然,DPP生效的前提是事实表按照orders.userId这一列预先做好了分区。因此,事实表必须是分区表,而且分区字段(可以是多个)必须包含Join Key。
|
||||
|
||||
其次,过滤效果的传导,依赖的是等值的关联关系,比如orders.userId = users.id。因此,DPP仅支持等值Joins,不支持大于、小于这种不等值关联关系。
|
||||
|
||||
此外,DPP机制得以实施还有一个隐含的条件:维度表过滤之后的数据集要小于广播阈值。
|
||||
|
||||
拿维度表users来说,满足过滤条件users.type = ‘Head User’的数据集,要能够放进广播变量,DPP优化机制才能生效。为什么会这样呢?这就要提到DPP机制的实现原理了。
|
||||
|
||||
结合刚才对于DPP实现逻辑的分析和推导,我们不难发现,实现DPP机制的关键在于,我们要让处理事实表的计算分支,能够拿到满足过滤条件的Join Key列表,然后用这个列表来对事实表做分区剪裁。那么问题来了,用什么办法才能拿到这个列表呢?
|
||||
|
||||
Spark SQL选择了一种“一箭双雕”的做法:使用广播变量封装过滤之后的维度表数据。具体来说,在维度表做完过滤之后,Spark SQL在其上构建哈希表(Hash Table),这个哈希表的Key就是用于关联的Join Key。在我们的例子中,Key就是满足过滤users.type = ‘Head User’条件的users.id;Value是投影中需要引用的数据列,在之前订单表与用户表的查询中,这里的引用列就是users.name。
|
||||
|
||||
|
||||
|
||||
哈希表构建完毕之后,Spark SQL将其封装到广播变量中,这个广播变量的作用有二。第一个作用就是给事实表用来做分区剪裁,如图中的步骤1所示,哈希表中的Key Set刚好可以用来给事实表过滤符合条件的数据分区。
|
||||
|
||||
第二个作用就是参与后续的Broadcast Join数据关联,如图中的步骤2所示。这里的哈希表,本质上就是Hash Join中的Build Table,其中的Key、Value,记录着数据关联中所需的所有字段,如users.id、users.name,刚好拿来和事实表做Broadcast Hash Join。
|
||||
|
||||
因此你看,鉴于Spark SQL选择了广播变量的实现方式,要想有效利用DPP优化机制,我们就必须要确保,过滤后的维度表刚好能放到广播变量中去。也因此,我们必须要谨慎对待配置项spark.sql.autoBroadcastJoinThreshold。
|
||||
|
||||
小结
|
||||
|
||||
这一讲,我们围绕动态分区剪裁,学习了谓词下推和分区剪裁的联系和区别,以及动态分区剪裁的定义、特点和使用方法。
|
||||
|
||||
相比于谓词下推,分区剪裁往往能更好地提升磁盘访问的I/O效率。
|
||||
|
||||
这是因为,谓词下推操作往往是根据文件注脚中的统计信息完成对文件的过滤,过滤效果取决于文件中内容的“纯度”。分区剪裁则不同,它的分区表可以把包含不同内容的文件,隔离到不同的文件系统目录下。这样一来,包含分区键的过滤条件能够以文件系统目录为粒度对磁盘文件进行过滤,从而大幅提升磁盘访问的I/O效率。
|
||||
|
||||
而动态分区剪裁这个功能主要用在星型模型数仓的数据关联场景中,它指的是在运行的时候,Spark SQL利用维度表提供的过滤信息,来减少事实表中数据的扫描量、降低I/O开销,从而提升执行性能。
|
||||
|
||||
动态分区剪裁运作的背后逻辑,是把维度表中的过滤条件,通过关联关系传导到事实表,来完成事实表的优化。在数据关联的场景中,开发者要想利用好动态分区剪裁特性,需要注意3点:
|
||||
|
||||
|
||||
事实表必须是分区表,并且分区字段必须包含Join Key
|
||||
动态分区剪裁只支持等值Joins,不支持大于、小于这种不等值关联关系
|
||||
维度表过滤之后的数据集,必须要小于广播阈值,因此,开发者要注意调整配置项spark.sql.autoBroadcastJoinThreshold
|
||||
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
如果让你重写DPP实现机制,你有可能把广播阈值的限制去掉吗?(提示:放弃使用Broadcast Hash Join的关联方式,但仍然用广播变量来做分区剪裁。)
|
||||
要让事实表拿到满足条件的Join Key列表,除了使用广播变量之外,你觉得还有其他的方法和途径吗?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
173
专栏/Spark性能调优实战/26JoinHints指南:不同场景下,如何选择Join策略?.md
Normal file
173
专栏/Spark性能调优实战/26JoinHints指南:不同场景下,如何选择Join策略?.md
Normal file
@ -0,0 +1,173 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
26 Join Hints指南:不同场景下,如何选择Join策略?
|
||||
你好,我是吴磊。
|
||||
|
||||
在数据分析领域,数据关联可以说是最常见的计算场景了。因为使用的频率很高,所以Spark为我们准备了非常丰富的关联形式,包括Inner Join、Left Join、Right Join、Anti Join、Semi Join等等。
|
||||
|
||||
搞懂不同关联形式的区别与作用,可以让我们快速地实现业务逻辑。不过,这只是基础,要想提高数据关联场景下Spark应用的执行性能,更为关键的是我们要能够深入理解Join的实现原理。
|
||||
|
||||
所以今天这一讲,我们先来说说,单机环境中Join都有哪几种实现方式,它们的优劣势分别是什么。理解了这些实现方式,我们再结合它们一起探讨,分布式计算环境中Spark都支持哪些Join策略。对于不同的Join策略,Spark是怎么做取舍的。
|
||||
|
||||
Join的实现方式详解
|
||||
|
||||
到目前为止,数据关联总共有3种Join实现方式。按照出现的时间顺序,分别是嵌套循环连接(NLJ,Nested Loop Join )、排序归并连接(SMJ,Shuffle Sort Merge Join)和哈希连接(HJ,Hash Join)。接下来,我们就借助一个数据关联的场景,来分别说一说这3种Join实现方式的工作原理。
|
||||
|
||||
假设,现在有事实表orders和维度表users。其中,users表存储用户属性信息,orders记录着用户的每一笔交易。两张表的Schema如下:
|
||||
|
||||
// 订单表orders关键字段
|
||||
userId, Int
|
||||
itemId, Int
|
||||
price, Float
|
||||
quantity, Int
|
||||
|
||||
// 用户表users关键字段
|
||||
id, Int
|
||||
name, String
|
||||
type, String //枚举值,分为头部用户和长尾用户
|
||||
|
||||
|
||||
我们的任务是要基于这两张表做内关联(Inner Join),同时把用户名、单价、交易额等字段投影出来。具体的SQL查询语句如下表:
|
||||
|
||||
//SQL查询语句
|
||||
select orders.quantity, orders.price, orders.userId, users.id, users.name
|
||||
from orders inner join users on orders.userId = users.id
|
||||
|
||||
|
||||
那么,对于这样一个关联查询,在3种不同的Join实现方式下,它是如何完成计算的呢?
|
||||
|
||||
NLJ的工作原理
|
||||
|
||||
对于参与关联的两张数据表,我们通常会根据它们扮演的角色来做区分。其中,体量较大、主动扫描数据的表,我们把它称作外表或是驱动表;体量较小、被动参与数据扫描的表,我们管它叫做内表或是基表。那么,NLJ是如何关联这两张数据表的呢?
|
||||
|
||||
NLJ是采用“嵌套循环”的方式来实现关联的。也就是说,NLJ会使用内、外两个嵌套的for循环依次扫描外表和内表中的数据记录,判断关联条件是否满足,比如例子中的orders.userId = users.id,如果满足就把两边的记录拼接在一起,然后对外输出。
|
||||
|
||||
|
||||
|
||||
在这个过程中,外层的for循环负责遍历外表中的每一条数据,如图中的步骤1所示。而对于外表中的每一条数据记录,内层的for循环会逐条扫描内表的所有记录,依次判断记录的Join Key是否满足关联条件,如步骤2所示。假设,外表有M行数据,内表有N行数据,那么NLJ算法的计算复杂度是O(M * N)。不得不说,尽管NLJ实现方式简单而又直接,但它的执行效率实在让人不敢恭维。
|
||||
|
||||
SMJ的工作原理
|
||||
|
||||
正是因为NLJ极低的执行效率,所以在它推出之后没多久之后,就有人用排序、归并的算法代替NLJ实现了数据关联,这种算法就是SMJ。SMJ的思路是先排序、再归并。具体来说,就是参与Join的两张表先分别按照Join Key做升序排序。然后,SMJ会使用两个独立的游标对排好序的两张表完成归并关联。
|
||||
|
||||
|
||||
|
||||
SMJ刚开始工作的时候,内外表的游标都会先锚定在两张表的第一条记录上,然后再对比游标所在记录的Join Key。对比结果以及后续操作主要分为3种情况:
|
||||
|
||||
|
||||
外表Join Key等于内表Join Key,满足关联条件,把两边的数据记录拼接并输出,然后把外表的游标滑动到下一条记录
|
||||
外表Join Key小于内表Join Key,不满足关联条件,把外表的游标滑动到下一条记录
|
||||
外表Join Key大于内表Join Key,不满足关联条件,把内表的游标滑动到下一条记录
|
||||
|
||||
|
||||
SMJ正是基于这3种情况,不停地向下滑动游标,直到某张表的游标滑到头,即宣告关联结束。对于SMJ中外表的每一条记录,由于内表按Join Key升序排序,且扫描的起始位置为游标所在位置,因此SMJ算法的计算复杂度为O(M + N)。
|
||||
|
||||
不过,SMJ计算复杂度的降低,仰仗的是两张表已经事先排好序。要知道,排序本身就是一项非常耗时的操作,更何况,为了完成归并关联,参与Join的两张表都需要排序。因此,SMJ的计算过程我们可以用“先苦后甜”来形容。苦的是要先花费时间给两张表做排序,甜的是有序表的归并关联能够享受到线性的计算复杂度。
|
||||
|
||||
HJ的工作原理
|
||||
|
||||
考虑到SMJ对排序的要求比较苛刻,所以后来又有人提出了效率更高的关联算法:HJ。HJ的设计初衷非常明确:把内表扫描的计算复杂度降低至O(1)。把一个数据集合的访问效率提升至O(1),也只有Hash Map能做到了。也正因为Join的关联过程引入了Hash计算,所以它叫HJ。
|
||||
|
||||
|
||||
|
||||
HJ的计算分为两个阶段,分别是Build阶段和Probe阶段。在Build阶段,基于内表,算法使用既定的哈希函数构建哈希表,如上图的步骤1所示。哈希表中的Key是Join Key应用(Apply)哈希函数之后的哈希值,表中的Value同时包含了原始的Join Key和Payload。
|
||||
|
||||
在Probe阶段,算法遍历每一条数据记录,先是使用同样的哈希函数,以动态的方式(On The Fly)计算Join Key的哈希值。然后,用计算得到的哈希值去查询刚刚在Build阶段创建好的哈希表。如果查询失败,说明该条记录与维度表中的数据不存在关联关系;如果查询成功,则继续对比两边的Join Key。如果Join Key一致,就把两边的记录进行拼接并输出,从而完成数据关联。
|
||||
|
||||
分布式环境下的Join
|
||||
|
||||
掌握了这3种最主要的数据关联实现方式的工作原理之后,在单机环境中,无论是面对常见的Inner Join、Left Join、Right Join,还是不常露面的Anti Join、Semi Join,你都能对数据关联的性能调优做到游刃有余了。
|
||||
|
||||
不过,你也可能会说:“Spark毕竟是个分布式系统,光学单机实现有什么用呀?”
|
||||
|
||||
所谓万变不离其宗,实际上,相比单机环境,分布式环境中的数据关联在计算环节依然遵循着NLJ、SMJ和HJ这3种实现方式,只不过是增加了网络分发这一变数。在Spark的分布式计算环境中,数据在网络中的分发主要有两种方式,分别是Shuffle和广播。那么,不同的网络分发方式,对于数据关联的计算又都有哪些影响呢?
|
||||
|
||||
如果采用Shuffle的分发方式来完成数据关联,那么外表和内表都需要按照Join Key在集群中做全量的数据分发。因为只有这样,两个数据表中Join Key相同的数据记录才能分配到同一个Executor进程,从而完成关联计算,如下图所示。
|
||||
|
||||
|
||||
|
||||
如果采用广播机制的话,情况会大有不同。在这种情况下,Spark只需要把内表(基表)封装到广播变量,然后在全网进行分发。由于广播变量中包含了内表的全量数据,因此体量较大的外表只要“待在原地、保持不动”,就能轻松地完成关联计算,如下图所示。
|
||||
|
||||
|
||||
|
||||
不难发现,结合Shuffle、广播这两种网络分发方式和NLJ、SMJ、HJ这3种计算方式,对于分布式环境下的数据关联,我们就能组合出6种Join策略,如下图所示。
|
||||
|
||||
|
||||
|
||||
这6种Join策略,对应图中6个青色圆角矩形,从上到下颜色依次变浅,它们分别是Cartesian Product Join、Shuffle Sort Merge Join和Shuffle Hash Join。也就是采用Shuffle机制实现的NLJ、SMJ和HJ,以及Broadcast Nested Loop Join、Broadcast Sort Merge Join和Broadcast Hash Join。
|
||||
|
||||
从执行性能来说,6种策略从上到下由弱变强。相比之下,CPJ的执行效率是所有实现方式当中最差的,网络开销、计算开销都很大,因而在图中的颜色也是最深的。BHJ是最好的分布式数据关联机制,网络开销和计算开销都是最小的,因而颜色也最浅。此外,你可能也注意到了,Broadcast Sort Merge Join被标记成了灰色,这是因为Spark并没有选择支持Broadcast + Sort Merge Join这种组合方式。
|
||||
|
||||
那么问题来了,明明是6种组合策略,为什么Spark偏偏没有支持这一种呢?要回答这个问题,我们就要回过头来对比SMJ与HJ实现方式的差异与优劣势。
|
||||
|
||||
相比SMJ,HJ并不要求参与Join的两张表有序,也不需要维护两个游标来判断当前的记录位置,只要基表在Build阶段构建的哈希表可以放进内存,HJ算法就可以在Probe阶段遍历外表,依次与哈希表进行关联。
|
||||
|
||||
当数据能以广播的形式在网络中进行分发时,说明被分发的数据,也就是基表的数据足够小,完全可以放到内存中去。这个时候,相比NLJ、SMJ,HJ的执行效率是最高的。因此,在可以采用HJ的情况下,Spark自然就没有必要再去用SMJ这种前置开销比较大的方式去完成数据关联。
|
||||
|
||||
Spark如何选择Join策略?
|
||||
|
||||
那么,在不同的数据关联场景中,对于这5种Join策略来说,也就是CPJ、BNLJ、SHJ、SMJ以及BHJ,Spark会基于什么逻辑取舍呢?我们来分两种情况进行讨论,分别是等值Join,和不等值Join。
|
||||
|
||||
等值Join下,Spark如何选择Join策略?
|
||||
|
||||
等值Join是指两张表的Join Key是通过等值条件连接在一起的。在日常的开发中,这种Join形式是最常见的,如t1 inner join t2 on t1.id = t2.id。
|
||||
|
||||
在等值数据关联中,Spark会尝试按照BHJ > SMJ > SHJ的顺序依次选择Join策略。在这三种策略中,执行效率最高的是BHJ,其次是SHJ,再次是SMJ。其中,SMJ和SHJ策略支持所有连接类型,如全连接、Anti Join等等。BHJ尽管效率最高,但是有两个前提条件:一是连接类型不能是全连接(Full Outer Join);二是基表要足够小,可以放到广播变量里面去。
|
||||
|
||||
那为什么SHJ比SMJ执行效率高,排名却不如SMJ靠前呢?这是个非常好的问题。我们先来说结论,相比SHJ,Spark优先选择SMJ的原因在于,SMJ的实现方式更加稳定,更不容易OOM。
|
||||
|
||||
回顾HJ的实现机制,在Build阶段,算法根据内表创建哈希表。在Probe阶段,为了让外表能够成功“探测”(Probe)到每一个Hash Key,哈希表要全部放进内存才行。坦白说,这个前提还是蛮苛刻的,仅这一点要求就足以让Spark对其望而却步。要知道,在不同的计算场景中,数据分布的多样性很难保证内表一定能全部放进内存。
|
||||
|
||||
而且在Spark中,SHJ策略要想被选中必须要满足两个先决条件,这两个条件都是对数据尺寸的要求。首先,外表大小至少是内表的3倍。其次,内表数据分片的平均大小要小于广播变量阈值。第一个条件的动机很好理解,只有当内外表的尺寸悬殊到一定程度时,HJ的优势才会比SMJ更显著。第二个限制的目的是,确保内表的每一个数据分片都能全部放进内存。
|
||||
|
||||
和SHJ相比,SMJ没有这么多的附加条件,无论是单表排序,还是两表做归并关联,都可以借助磁盘来完成。内存中放不下的数据,可以临时溢出到磁盘。单表排序的过程,我们可以参考Shuffle Map阶段生成中间文件的过程。在做归并关联的时候,算法可以把磁盘中的有序数据用合理的粒度,依次加载进内存完成计算。这个粒度可大可小,大到以数据分片为单位,小到逐条扫描。
|
||||
|
||||
正是考虑到这些因素,相比SHJ,Spark SQL会优先选择SMJ。事实上,在配置项spark.sql.join.preferSortMergeJoin默认为True的情况下,Spark SQL会用SMJ策略来兜底,确保作业执行的稳定性,压根就不会打算去尝试SHJ。开发者如果想通过配置项来调整Join策略,需要把这个参数改为False,这样Spark SQL才有可能去尝试SHJ。
|
||||
|
||||
不等值Join下,Spark如何选择Join策略?
|
||||
|
||||
接下来,我们再来说说不等值Join,它指的是两张表的Join Key是通过不等值条件连接在一起的。不等值Join其实我们在以前的例子中也见过,比如像查询语句t1 inner join t2 on t1.date > t2.beginDate and t1.date <= t2.endDate,其中的关联关系是依靠不等式连接在一起的。
|
||||
|
||||
由于不等值Join只能使用NLJ来实现,因此Spark SQL可选的Join策略只剩下BNLJ和CPJ。在同一种计算模式下,相比Shuffle,广播的网络开销更小。显然,在两种策略的选择上,Spark SQL一定会按照BNLJ > CPJ的顺序进行尝试。当然,BNLJ生效的前提自然是内表小到可以放进广播变量。如果这个条件不成立,那么Spark SQL只好委曲求全,使用笨重的CPJ策略去完成关联计算。
|
||||
|
||||
开发者能做些什么?
|
||||
|
||||
最后,我们再来聊聊,面对上述的5种Join策略,开发者还能做些什么呢?通过上面的分析,我们不难发现,Spark SQL对于这些策略的取舍也基于一些既定的规则。所谓计划赶不上变化,预置的规则自然很难覆盖多样且变化无常的计算场景。因此,当我们掌握了不同Join策略的工作原理,结合我们对于业务和数据的深刻理解,完全可以自行决定应该选择哪种Join策略。
|
||||
|
||||
|
||||
|
||||
在最新发布的3.0版本中,Spark为开发者提供了多样化的Join Hints,允许你把专家经验凌驾于Spark SQL的选择逻辑之上。在满足前提条件的情况下,如等值条件、连接类型、表大小等等,Spark会优先尊重开发者的意愿,去选取开发者通过Join Hints指定的Join策略。关于Spark 3.0支持的Join Hints关键字,以及对应的适用场景,我把它们总结到了如上的表格中,你可以直接拿来参考。
|
||||
|
||||
简单来说,你可以使用两种方式来指定Join Hints,一种是通过SQL结构化查询语句,另一种是使用DataFrame的DSL语言,都很方便。至于更全面的讲解,你可以去第13讲看看,这里我就不多说了。
|
||||
|
||||
小结
|
||||
|
||||
这一讲,我们从数据关联的实现原理,到Spark SQL不同Join策略的适用场景,掌握这些关键知识点,对于数据关联场景中的性能调优至关重要。
|
||||
|
||||
首先,你需要掌握3种Join实现机制的工作原理。为了方便你对比,我把它们总结在了下面的表格里。
|
||||
|
||||
|
||||
|
||||
掌握了3种关联机制的实现原理,你就能更好地理解Spark SQL的Join策略。结合数据的网络分发方式(Shuffle和广播),Spark SQL支持5种Join策略,按照执行效率排序就是BHJ > SHJ > SMJ > BNLJ > CPJ。同样,为了方便对比,你也可以直接看下面的表格。
|
||||
|
||||
|
||||
|
||||
最后,当你掌握了不同Join策略的工作原理,结合对于业务和数据的深刻理解,实际上你可以自行决定应该选择哪种Join策略,不必完全依赖Spark SQL的判断。
|
||||
|
||||
Spark为开发者提供了多样化的Join Hints,允许你把专家经验凌驾于Spark SQL的选择逻辑之上。比如,当你确信外表比内表大得多,而且内表数据分布均匀,使用SHJ远比默认的SMJ效率高得多的时候,你就可以通过指定Join Hints来强制Spark SQL按照你的意愿去选择Join策略。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
如果关联的场景是事实表Join事实表,你觉得我们今天讲的Sort Merge Join实现方式还适用吗?如果让你来设计算法的实现步骤,你会怎么做?
|
||||
你觉得,不等值Join可以强行用Sort Merge Join和Hash Join两种机制来实现吗?为什么?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
213
专栏/Spark性能调优实战/27大表Join小表:广播变量容不下小表怎么办?.md
Normal file
213
专栏/Spark性能调优实战/27大表Join小表:广播变量容不下小表怎么办?.md
Normal file
@ -0,0 +1,213 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
27 大表Join小表:广播变量容不下小表怎么办?
|
||||
你好,我是吴磊。
|
||||
|
||||
在数据分析领域,大表Join小表的场景非常普遍。不过,大小是个相对的概念,通常来说,大表与小表尺寸相差3倍以上,我们就将其归类为“大表Join小表”的计算场景。因此,大表Join小表,仅仅意味着参与关联的两张表尺寸悬殊。
|
||||
|
||||
对于大表Join小表这种场景,我们应该优先考虑BHJ,它是Spark支持的5种Join策略中执行效率最高的。BHJ处理大表Join小表时的前提条件是,广播变量能够容纳小表的全量数据。但是,如果小表的数据量超过广播阈值,我们又该怎么办呢?
|
||||
|
||||
今天这一讲,我们就结合3个真实的业务案例,来聊一聊这种情况的解决办法。虽然这3个案例不可能覆盖“大表Join小表”场景中的所有情况,但是,分析并汇总案例的应对策略和解决办法,有利于我们在调优的过程中开阔思路、发散思维,从而避免陷入“面对问题无所适从”的窘境。
|
||||
|
||||
案例1:Join Key远大于Payload
|
||||
|
||||
在第一个案例中,大表100GB、小表10GB,它们全都远超广播变量阈值(默认10MB)。因为小表的尺寸已经超过8GB,在大于8GB的数据集上创建广播变量,Spark会直接抛出异常,中断任务执行,所以Spark是没有办法应用BHJ机制的。那我们该怎么办呢?先别急,我们来看看这个案例的业务需求。
|
||||
|
||||
这个案例来源于计算广告业务中的流量预测,流量指的是系统中一段时间内不同类型用户的访问量。这里有三个关键词,第一个是“系统”,第二个是“一段时间”,第三个是“用户类型”。时间粒度好理解,就是以小时为单位去统计流量。用户类型指的是采用不同的维度来刻画用户,比如性别、年龄、教育程度、职业、地理位置。系统指的是流量来源,比如平台、站点、频道、媒体域名。
|
||||
|
||||
在系统和用户的维度组合之下,流量被细分为数以百万计的不同“种类”。比如,来自XX平台XX站点的在校大学生的访问量,或是来自XX媒体XX频道25-45岁女性的访问量等等。
|
||||
|
||||
我们知道,流量预测本身是个时序问题,它和股价预测类似,都是基于历史、去预测未来。在我们的案例中,为了预测上百万种不同的流量,咱们得先为每种流量生成时序序列,然后再把这些时序序列喂给机器学习算法进行模型训练。
|
||||
|
||||
统计流量的数据源是线上的访问日志,它记录了哪类用户在什么时间访问了哪些站点。要知道,我们要构建的,是以小时为单位的时序序列,但由于流量的切割粒度非常细致,因此有些种类的流量不是每个小时都有访问量的,如下图所示。
|
||||
|
||||
|
||||
|
||||
我们可以看到,在过去的24小时中,某种流量仅在20-24点这5个时段有数据记录,其他时段无记录,也就是流量为零。在这种情况下,我们就需要用“零”去补齐缺失时段的序列值。那么我们该怎么补呢?
|
||||
|
||||
因为业务需求是填补缺失值,所以在实现层面,我们不妨先构建出完整的全零序列,然后以系统、用户和时间这些维度组合为粒度,用统计流量去替换全零序列中相应位置的“零流量”。这个思路描述起来比较复杂,用图来理解会更直观、更轻松一些。
|
||||
|
||||
|
||||
|
||||
首先,我们生成一张全零流量表,如图中左侧的“负样本表”所示。这张表的主键是划分流量种类的各种维度,如性别、年龄、平台、站点、小时时段等等。表的Payload只有一列,也即访问量,在生成“负样本表”的时候,这一列全部置零。
|
||||
|
||||
然后,我们以同样的维度组合统计日志中的访问量,就可以得到图中右侧的“正样本表”。不难发现,两张表的Schema完全一致,要想获得完整的时序序列,我们只需要把外表以“左连接(Left Outer Join)”的形式和内表做关联就好了。具体的查询语句如下:
|
||||
|
||||
//左连接查询语句
|
||||
select t1.gender, t1.age, t1.city, t1.platform, t1.site, t1.hour, coalesce(t2.access, t1.access) as access
|
||||
from t1 left join t2 on
|
||||
t1.gender = t2.gender and
|
||||
t1.age = t2.age and
|
||||
t1.city = t2.city and
|
||||
t1.platform = t2.platform and
|
||||
t1.site = t2.site and
|
||||
t1.hour = t2.hour
|
||||
|
||||
|
||||
使用左连接的方式,我们刚好可以用内表中的访问量替换外表中的零流量,两表关联的结果正是我们想要的时序序列。“正样本表”来自访问日志,只包含那些存在流量的时段,而“负样本表”是生成表,它包含了所有的时段。因此,在数据体量上,负样本表远大于正样本表,这是一个典型的“大表Join小表”场景。尽管小表(10GB)与大表(100GB)相比,在尺寸上相差一个数量级,但两者的体量都不满足BHJ的先决条件。因此,Spark只好退而求其次,选择SMJ(Shuffle Sort Merge Join)的实现方式。
|
||||
|
||||
我们知道,SMJ机制会引入Shuffle,将上百GB的数据在全网分发可不是一个明智的选择。那么,根据“能省则省”的开发原则,我们有没有可能“省去”这里的Shuffle呢?要想省去Shuffle,我们只有一个办法,就是把SMJ转化成BHJ。你可能会说:“都说了好几遍了,小表的尺寸10GB远超广播阈值,我们还能怎么转化呢?”
|
||||
|
||||
办法总比困难多,我们先来反思,关联这两张表的目的是什么?目的是以维度组合(Join Keys)为基准,用内表的访问量替换掉外表的零值。那么,这两张表有哪些特点呢?首先,两张表的Schema完全一致。其次,无论是在数量、还是尺寸上,两张表的Join Keys都远大于Payload。那么问题来了,要达到我们的目的,一定要用那么多、那么长的Join Keys做关联吗?
|
||||
|
||||
答案是否定的。在上一讲,我们介绍过Hash Join的实现原理,在Build阶段,Hash Join使用哈希算法生成哈希表。在Probe阶段,哈希表一方面可以提供O(1)的查找效率,另一方面,在查找过程中,Hash Keys之间的对比远比Join Keys之间的对比要高效得多。受此启发,我们为什么不能计算Join Keys的哈希值,然后把生成的哈希值当作是新的Join Key呢?
|
||||
|
||||
|
||||
|
||||
我们完全可以基于现有的Join Keys去生成一个全新的数据列,它可以叫“Hash Key”。生成的方法分两步:
|
||||
|
||||
|
||||
把所有Join Keys拼接在一起,把性别、年龄、一直到小时拼接成一个字符串,如图中步骤1、3所示
|
||||
使用哈希算法(如MD5或SHA256)对拼接后的字符串做哈希运算,得到哈希值即为“Hash Key”,如上图步骤2、4所示
|
||||
|
||||
|
||||
在两张表上,我们都进行这样的操作。如此一来,在做左连接的时候,为了把主键一致的记录关联在一起,我们不必再使用数量众多、冗长的原始Join Keys,用单一的生成列Hash Key就可以了。相应地,SQL查询语句也变成了如下的样子。
|
||||
|
||||
//调整后的左连接查询语句
|
||||
select t1.gender, t1.age, t1.city, t1.platform, t1.site, t1.hour, coalesce(t2.access, t1.access) as access
|
||||
from t1 left join t2 on
|
||||
t1.hash_key = t2. hash_key
|
||||
|
||||
|
||||
添加了这一列之后,我们就可以把内表,也就是“正样本表”中所有的Join Keys清除掉,大幅缩减内表的存储空间,上图中的步骤5演示了这个过程。当内表缩减到足以放进广播变量的时候,我们就可以把SMJ转化为BHJ,从而把SMJ中的Shuffle环节彻底省掉。
|
||||
|
||||
这样一来,清除掉Join Keys的内表的存储大小就变成了1.5GB。对于这样的存储量级,我们完全可以使用配置项或是强制广播的方式,来完成Shuffle Join到Broadcast Join的转化,具体的转化方法你可以参考广播变量那一讲(第13讲)。
|
||||
|
||||
案例1说到这里,其实已经基本解决了,不过这里还有一个小细节需要我们特别注意。案例1优化的关键在于,先用Hash Key取代Join Keys,再清除内表冗余数据。Hash Key实际上是Join Keys拼接之后的哈希值。既然存在哈希运算,我们就必须要考虑哈希冲突的问题。
|
||||
|
||||
哈希冲突我们都很熟悉,它指的就是不同的数据源经过哈希运算之后,得到的哈希值相同。在案例1当中,如果我们为了优化引入的哈希计算出现了哈希冲突,就会破坏原有的关联关系。比如,本来两个不相等的Join Keys,因为哈希值恰巧相同而被关联到了一起。显然,这不是我们想要的结果。
|
||||
|
||||
消除哈希冲突隐患的方法其实很多,比如“二次哈希”,也就是我们用两种哈希算法来生成Hash Key数据列。两条不同的数据记录在两种不同哈希算法运算下的结果完全相同,这种概率几乎为零。
|
||||
|
||||
|
||||
|
||||
案例2:过滤条件的Selectivity较高
|
||||
|
||||
除了Join Keys远大于Payload的情况会导致我们无法选择BHJ,还有一种情况是过滤条件的Selectivity较高。这个案例来源于电子商务场景,在星型(Start Schema)数仓中,我们有两张表,一张是订单表orders,另一张是用户表users。订单表是事实表(Fact),而用户表是维度表(Dimension)。
|
||||
|
||||
这个案例的业务需求很简单,是统计所有头部用户贡献的营业额,并按照营业额倒序排序。订单表和用户表的Schema如下表所示。
|
||||
|
||||
// 订单表orders关键字段
|
||||
userId, Int
|
||||
itemId, Int
|
||||
price, Float
|
||||
quantity, Int
|
||||
|
||||
// 用户表users关键字段
|
||||
id, Int
|
||||
name, String
|
||||
type, String //枚举值,分为头部用户和长尾用户
|
||||
|
||||
|
||||
|
||||
给定上述数据表,我们只需把两张表做内关联,然后分组、聚合、排序,就可以实现业务逻辑,具体的查询语句如下所示。
|
||||
|
||||
//查询语句
|
||||
select (orders.price * order.quantity) as revenue, users.name
|
||||
from orders inner join users on orders.userId = users.id
|
||||
where users.type = ‘Head User’
|
||||
group by users.name
|
||||
order by revenue desc
|
||||
|
||||
|
||||
在这个案例中,事实表的存储容量在TB量级,维度表是20GB左右,也都超过了广播阈值。其实,这样的关联场景在电子商务、计算广告以及推荐搜索领域都很常见。
|
||||
|
||||
对于两张表都远超广播阈值的关联场景来说,如果我们不做任何调优的,Spark就会选择SMJ策略计算。在10台C5.4xlarge AWS EC2的分布式环境中,SMJ要花费将近5个小时才完成两张表的关联计算。这样的执行效率,我们肯定无法接受,但我们又能做哪些优化呢?你不妨先花上两分钟去想一想,然后再来一起跟我去分析。
|
||||
|
||||
仔细观察上面的查询语句,我们发现这是一个典型的星型查询,也就是事实表与维度表关联,且维表带过滤条件。维表上的过滤条件是users.type = ‘Head User’,即只选取头部用户。而通常来说,相比普通用户,头部用户的占比很低。换句话说,这个过滤条件的选择性(Selectivity)很高,它可以帮助你过滤掉大部分的维表数据。在我们的案例中,由于头部用户占比不超过千分之一,因此过滤后的维表尺寸很小,放进广播变量绰绰有余。
|
||||
|
||||
这个时候我们就要用到AQE了,我们知道AQE允许Spark SQL在运行时动态地调整Join策略。我们刚好可以利用这个特性,把最初制定的SMJ策略转化为BHJ策略(千万别忘了,AQE默认是关闭的,要想利用它提供的特性,我们得先把spark.sql.adaptive.enabled配置项打开)。
|
||||
|
||||
不过,即便过滤条件的选择性很高,在千分之一左右,过滤之后的维表还是有20MB大小,这个尺寸还是超过了默认值广播阈值10MB。因此,我们还需要把广播阈值spark.sql.autoBroadcastJoinThreshold调高一些,比如1GB,AQE才会把SMJ降级为BHJ。做了这些调优之后,在同样的集群规模下,作业的端到端执行时间从之前的5个小时缩减为30分钟。
|
||||
|
||||
让作业的执行性能提升了一个数量级之后,我们的调优就结束了吗?在调优的本质那一讲,我们一再强调,随着木桶短板的此消彼长,调优是一个不断持续的过程。在这个过程中,我们需要因循瓶颈的变化,动态地切换调优方法,去追求一种所有木板齐平、没有瓶颈的状态。
|
||||
|
||||
那么,当我们用动态Join策略,把SMJ策略中Shuffle引入的海量数据分发这块短板补齐之后,还有没有“新晋”的短板需要修理呢?
|
||||
|
||||
对于案例中的这种星型关联,我们还可以利用DPP机制来减少事实表的扫描量,进一步减少I/O开销、提升性能。和AQE不同,DPP并不需要开发者特别设置些什么,只要满足条件,DPP机制会自动触发。
|
||||
|
||||
但是想要使用DPP做优化,还有3个先决条件需要满足:
|
||||
|
||||
|
||||
DPP仅支持等值Joins,不支持大于或者小于这种不等值关联关系
|
||||
维表过滤之后的数据集,必须要小于广播阈值,因此开发者要注意调整配置项spark.sql.autoBroadcastJoinThreshold
|
||||
事实表必须是分区表,且分区字段(可以是多个)必须包含Join Key
|
||||
|
||||
|
||||
我们可以直接判断出查询满足前两个条件,满足第一个条件很好理解。满足第二个条件是因为,经过第一步AQE的优化之后,广播阈值足够大,足以容纳过滤之后的维表。那么,要想利用DPP机制,我们必须要让orders成为分区表,也就是做两件事情:
|
||||
|
||||
|
||||
创建一张新的订单表orders_new,并指定userId为分区键
|
||||
把原订单表orders的全部数据,灌进这张新的订单表orders_new
|
||||
|
||||
|
||||
//查询语句
|
||||
select (orders_new.price * orders_new.quantity) as revenue, users.name
|
||||
from orders_new inner join users on orders_new.userId = users.id
|
||||
where users.type = ‘Head User’
|
||||
group by users.name
|
||||
order by revenue desc
|
||||
|
||||
|
||||
用orders_new表替换orders表之后,在同样的分布式环境下,查询时间就从30分钟进一步缩短到了15分钟。
|
||||
|
||||
你可能会说:“为了利用DPP,重新建表、灌表,这样也需要花费不少时间啊!这不是相当于把运行时间从查询转嫁到建表、灌数了吗?”你说的没错,确实是这么回事。如果为了查询效果,临时再去修改表结构、迁移数据确实划不来,属于“临时抱佛脚”。因此,为了最大限度地利用DPP,在做数仓规划的时候,开发者就应该结合常用查询与典型场景,提前做好表结构的设计,这至少包括Schema、分区键、存储格式等等。
|
||||
|
||||
案例3:小表数据分布均匀
|
||||
|
||||
在上面的两个案例中,我们都是遵循“能省则省”的开发原则,想方设法地把Shuffle Joins切换为Broadcast Joins,从而消除Shuffle。但是,总有那么一些“顽固”的场景,无论我们怎么优化,也没办法做到这一点。那么对于这些“顽固分子”,我们该怎么办呢?
|
||||
|
||||
我们知道,如果关联场景不满足BHJ条件,Spark SQL会优先选择SMJ策略完成关联计算。但是在上一讲我们说到,当参与Join的两张表尺寸相差悬殊且小表数据分布均匀的时候,SHJ往往比SMJ的执行效率更高。原因很简单,小表构建哈希表的开销要小于两张表排序的开销。
|
||||
|
||||
我们还是以上一个案例的查询为例,不过呢,这次我们把维表的过滤条件去掉,去统计所有用户贡献的营业额。在10台C5.4xlarge AWS EC2的分布式环境中,去掉过滤条件的SMJ花费了将近7个小时才完成两张表的关联计算。
|
||||
|
||||
//查询语句
|
||||
select (orders.price * order.quantity) as revenue, users.name
|
||||
from orders inner join users on orders.userId = users.id
|
||||
group by users.name
|
||||
order by revenue desc
|
||||
|
||||
|
||||
由于维表的查询条件不复存在,因此案例2中的两个优化方法,也就是AQE Join策略调整和DPP机制,也都失去了生效的前提。这种情况下,我们不妨使用Join Hints来强制Spark SQL去选择SHJ策略进行关联计算,调整后的查询语句如下表所示。
|
||||
|
||||
//添加Join hints之后的查询语句
|
||||
select /*+ shuffle_hash(orders) */ (orders.price * order.quantity) as revenue, users.name
|
||||
from orders inner join users on orders.userId = users.id
|
||||
group by users.name
|
||||
order by revenue desc
|
||||
|
||||
|
||||
将Join策略调整为SHJ之后,在同样的集群规模下,作业的端到端执行时间从之前的7个小时缩减到5个小时,相比调优前,我们获得了将近30%的性能提升。
|
||||
|
||||
需要注意的是,SHJ要想成功地完成计算、不抛OOM异常,需要保证小表的每个数据分片都能放进内存。这也是为什么,我们要求小表的数据分布必须是均匀的。如果小表存在数据倾斜的问题,那么倾斜分区的OOM将会是一个大概率事件,SHJ的计算也会因此而中断。
|
||||
|
||||
小结
|
||||
|
||||
今天这一讲,我们从3个案例出发,探讨并解锁了不同场景下“大表Join小表”的优化思路和应对方法。
|
||||
|
||||
首先,我们定义了什么是“大表Join小表”。一般来说,参与Join的两张表在尺寸上相差3倍以上,就可以看作是“大表Join小表”的计算场景。
|
||||
|
||||
其次,我们讲了3个大表Join小表场景下,无法选择BHJ的案例。
|
||||
|
||||
第一个案例是Join Keys远大于Payload的数据关联,我们可以使用映射方法(如哈希运算),用较短的字符串来替换超长的Join Keys,从而大幅缩减小表的存储空间。如果缩减后的小表,足以放进广播变量,我们就可以将SMJ转换为BHJ,=来消除繁重的Shuffle计算。需要注意的是,映射方法要能够有效地避免“映射冲突”的问题,避免出现不同的Join Keys被映射成同一个数值。
|
||||
|
||||
第二个案例是,如果小表携带过滤条件,且过滤条件的选择性很高,我们可以通过开启AQE的Join策略调整特性,在运行时把SMJ转换为BHJ,从而大幅提升执行性能。这里有两点需要我们特别注意:一是,为了能够成功完成转换,我们需要确保过滤之后的维表尺寸小于广播阈值;二是,如果大表本身是按照Join Keys进行分区的话,那么我们还可以充分利用DPP机制,来进一步缩减大表扫描的I/O开销,从而提升性能。
|
||||
|
||||
第三个案例是,如果小表不带过滤条件,且尺寸远超广播阈值。如果小表本身的数据分布比较均匀,我们可以考虑使用Join hints强行要求Spark SQL在运行时选择SHJ关联策略。一般来说,在“大表Join小表”的场景中,相比SMJ,SHJ的执行效率会更好一些。背后的原因在于,小表构建哈希表的开销,要小于两张表排序的开销。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
对于案例1,我们的核心思路是用哈希值来替代超长的Join Keys,除了用哈希值以外,你觉得还有其他的思路或是办法,去用较短的字符串来取代超长的Join Keys吗?
|
||||
对于案例2,利用AQE Join策略调整和DDP机制的关键,是确保过滤后的维表小于广播阈值。你能说说,都有哪些方法可以用来计算过滤后的维表大小吗?
|
||||
对于案例3,假设20GB的小表存在数据倾斜,强行把SMJ转化为SHJ会抛OOM异常。这个时候,你认为还有可能继续优化吗?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
175
专栏/Spark性能调优实战/28大表Join大表(一):什么是“分而治之”的调优思路?.md
Normal file
175
专栏/Spark性能调优实战/28大表Join大表(一):什么是“分而治之”的调优思路?.md
Normal file
@ -0,0 +1,175 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
28 大表Join大表(一):什么是“分而治之”的调优思路?
|
||||
你好,我是吴磊。
|
||||
|
||||
上一讲,我们探讨了“大表Join小表”场景的调优思路和应对方法。那么,除了大表Join小表的场景,数据分析领域有没有“大表Join大表”的场景呢?确实是有的,它指的是参与Join的两张体量较大的事实表,尺寸相差在3倍以内,且全部无法放进广播变量。
|
||||
|
||||
但是通常来说,在数据分析领域,用一张大表去关联另一张大表,这种做法在业内是极其不推荐的。甚至毫不客气地说,“大表Join大表”是冒天下之大不韪,犯了数据分析的大忌。如果非要用“大表Join大表”才能实现业务逻辑、完成数据分析,这说明数据仓库在设计之初,开发者考虑得不够完善、看得不够远。
|
||||
|
||||
不过,你可能会说:“我刚入职的时候,公司的数仓就已经定型了,这又不是我的锅,我也只能随圆就方。”为了应对这种情况,今天这一讲我们就来说说,当你不得不面对“大表Join大表”的时候,还有哪些调优思路和技巧。
|
||||
|
||||
要应对“大表Join大表”的计算场景,我们主要有两种调优思路。一种叫做“分而治之”,另一种我把它统称为“负隅顽抗”。今天这一讲,我们先来说说“分而治之”,“负隅顽抗”我们留到下一讲再去展开。
|
||||
|
||||
值得一提的是,即便你不需要去应对“大表Join大表”这块烫手的山芋,“分而治之”与“负隅顽抗”所涉及的调优思路与方法,也非常值得我们花时间去深入了解,因为这些思路与方法的可迁移性非常强,学习过后你会发现,它们完全可以拿来去应对其他的应用场景。
|
||||
|
||||
话不多说,我们直接开始今天的课程吧!
|
||||
|
||||
如何理解“分而治之”?
|
||||
|
||||
“分而治之”的调优思路是把“大表Join大表”降级为“大表Join小表”,然后使用上一讲中“大表Join小表”的调优方法来解决性能问题。它的核心思想是,先把一个复杂任务拆解成多个简单任务,再合并多个简单任务的计算结果。那么,“大表Join大表”的场景是如何应用“分而治之”的计算思想的呢?
|
||||
|
||||
首先,我们要根据两张表的尺寸大小区分出外表和内表。一般来说,内表是尺寸较小的那一方。然后,我们人为地在内表上添加过滤条件,把内表划分为多个不重复的完整子集。接着,我们让外表依次与这些子集做关联,得到部分计算结果。最后,再用Union操作把所有的部分结果合并到一起,得到完整的计算结果,这就是端到端的关联计算。整个“分而治之”的计算过程如下:
|
||||
|
||||
|
||||
|
||||
如何保证内表拆分的粒度足够细?
|
||||
|
||||
采用“分而治之”的核心目的在于,将“大表Join大表”转化为“大表Join小表”,因此“分而治之”中一个关键的环节就是内表拆分,我们要求每一个子表的尺寸相对均匀,且都小到可以放进广播变量。只有这样,原本的Shuffle Join才能转化成一个又一个的Broadcast Joins,原本的海量数据Shuffle才能被消除,我们也才能因此享受到性能调优的收益。相反,如果内表拆分不能满足上述条件,我们就“白忙活”了。
|
||||
|
||||
拆分的关键在于拆分列的选取,为了让子表足够小,拆分列的基数(Cardinality)要足够大才行。这么说比较抽象,我们来举几个例子。假设内表的拆分列是“性别”,性别的基数是2,取值分别是“男”和“女”。我们根据过滤条件 “性别 = 男”和“性别 = 女”把内表拆分为两份,显然,这样拆出来的子表还是很大,远远超出广播阈值。
|
||||
|
||||
你可能会说:“既然性别的基数这么低,不如我们选择像身份证号这种基数大的数据列。”身份证号码的基数确实足够大,就是全国的人口数量。但是,身份证号码这种基数比较大的字符串充当过滤条件有两个缺点:一,不容易拆分,开发成本太高;二,过滤条件很难享受到像谓词下推这种Spark SQL的内部优化机制。
|
||||
|
||||
既然基数低也不行、高也不行,那到底什么样的基数合适呢?通常来说,在绝大多数的数仓场景中,事实表上都有与时间相关的字段,比如日期或是更细致的时间戳。这也是很多事实表在建表的时候,都是以日期为粒度做分区存储的原因。因此,选择日期作为拆分列往往是个不错的选择,既能享受到Spark SQL分区剪裁(Partition Pruning)的性能收益,同时开发成本又很低。
|
||||
|
||||
如何避免外表的重复扫描?
|
||||
|
||||
内表拆分之后,外表就要分别和所有的子表做关联,尽管每一个关联都变成了“大表Join小表”并转化为BHJ,但是在Spark的运行机制下,每一次关联计算都需要重新、重头扫描外表的全量数据。毫无疑问,这样的操作是让人无法接受的。这就是“分而治之”中另一个关键的环节:外表的重复扫描。
|
||||
|
||||
|
||||
|
||||
我们以上图为例,内表被拆分为4份,原本两个大表的Shuffle Join,被转化为4个Broadcast Joins。外表分别与4个子表做关联,所有关联的结果集最终通过Union合并到一起,完成计算。对于这4个关联来说,每一次计算都需要重头扫描一遍外表。换句话说,外表会被重复扫描4次。显然,外表扫描的次数取决于内表拆分的份数。
|
||||
|
||||
我们刚刚说到,内表的拆分需要足够细致,才能享受到性能调优带来的收益,而这往往意味着,内表拆分的份数成百上千、甚至成千上万。在这样的数量级之下,重复扫描外表带来的开销是巨大的。
|
||||
|
||||
要解决数据重复扫描的问题,办法其实不止一种,我们最容易想到的就是Cache。确实,如果能把外表的全量数据缓存到内存中,我们就不必担心重复扫描的问题,毕竟内存的计算延迟远低于磁盘。但是,我们面临的情况是外表的数据量非常地庞大,往往都是TB级别起步,想要把TB体量的数据全部缓存到内存,这要求我们的计算集群在资源配置上要足够的强悍,再说直白一点,你要有足够的预算去配置足够大的内存。
|
||||
|
||||
要是集群没这么强悍,老板也不给批预算去扩容集群内存,我们该怎么办呢?
|
||||
|
||||
我们还是要遵循“分而治之”的思想,既然内表可以“分而治之”,外表为什么不可以呢?对于外表参与的每一个子关联,在逻辑上,我们完全可以只扫描那些与内表子表相关的外表数据,并不需要每次都扫描外表的全量数据。如此一来,在效果上,外表的全量数据仅仅被扫描了一次。你可能会说:“说得轻巧,逻辑上是没问题,但是具体怎么做到外表的“分而治之”呢?”
|
||||
|
||||
这事要是搁到以前还真是没什么操作空间,但是,学习过Spark 3.0的DPP机制之后,我们就可以利用DPP来对外表进行“分而治之”。
|
||||
|
||||
|
||||
|
||||
假设外表的分区键包含Join Keys,那么,每一个内表子表都可以通过DPP机制,帮助与之关联的外表减少数据扫描量。如上图所示,步骤1、2、3、4分别代表外表与4个不同子表的关联计算。以步骤1为例,在DPP机制的帮助下,要完成关联计算,外表只需要扫描与绿色子表对应的分区数据即可,如图中的两个绿色分区所示。同理,要完成步骤4的关联计算,外表只需要扫描与紫色子表对应的分区即可,如图中左侧用紫色标记的两个数据分区。
|
||||
|
||||
不难发现,每个子查询只扫描外表的一部分、一个子集,所有这些子集加起来,刚好就是外表的全量数据。因此,利用“分而治之”的调优技巧,端到端的关联计算仅需对外表做一次完整的全量扫描即可。如此一来,在把原始的Shuffle Join转化为多个Broadcast Joins之后,我们并没有引入额外的性能开销。毫无疑问,查询经过这样的调优过后,执行效率一定会有较大幅度的提升。
|
||||
|
||||
但是,你可能会说:“说了半天,都是一些思路和理论,要实现“分而治之”,代码该怎么写呢?”接下来,我们就结合一个小例子一起去实战一下“分而治之”的优化思路。
|
||||
|
||||
“分而治之”调优思路实战
|
||||
|
||||
这个实战例子来自于一个跨境电商,这家电商在全球范围内交易大型组装设备,这些设备的零部件来自于全球不同地区的不同供货商,因此一个设备订单往往包含多个零部件明细。这家电商使用orders表和transactions表来分别记录订单和交易明细,两张表的关键字段如下表所示。
|
||||
|
||||
//orders表的关键字段
|
||||
orderId: Int
|
||||
customerId: Int
|
||||
status: String
|
||||
date: Date //分区键
|
||||
|
||||
//lineitems表的关键字段
|
||||
orderId: Int //分区键
|
||||
txId: Int
|
||||
itemId: Int
|
||||
price: Float
|
||||
quantity: Int
|
||||
|
||||
|
||||
|
||||
orders和transactions都是事实表,体量都在TB级别。基于这两张事实表,这家电商每隔一段时间,就会计算上一个季度所有订单的交易额,业务代码如下所示。
|
||||
|
||||
//统计订单交易额的代码实现
|
||||
val txFile: String = _
|
||||
val orderFile: String = _
|
||||
|
||||
val transactions: DataFrame = spark.read.parquent(txFile)
|
||||
val orders: DataFrame = spark.read.parquent(orderFile)
|
||||
|
||||
transactions.createOrReplaceTempView("transactions")
|
||||
orders.createOrReplaceTempView("orders")
|
||||
|
||||
val query: String = "
|
||||
select sum(tx.price * tx.quantity) as revenue, o.orderId
|
||||
from transactions as tx inner join orders as o
|
||||
on tx.orderId = o.orderId
|
||||
where o.status = 'COMPLETE'
|
||||
and o.date between '2020-01-01' and '2020-03-31'
|
||||
group by o.orderId
|
||||
"
|
||||
|
||||
val outFile: String = _
|
||||
spark.sql(query).save.parquet(outFile)
|
||||
|
||||
|
||||
|
||||
不难发现,在两张表的关联计算中,transactions的角色是外表,自然 orders的角色就是内表。需要指出的是,即便内表中有不少过滤条件,如订单状态为“完成”且成交日期满足一定范围,但过滤之后的内表仍然在百GB量级,难以放入广播变量。因此,这两张大表的关联计算,自然会退化到Shuffle Joins的实现机制。
|
||||
|
||||
那么,如果用“分而治之”的思路来做优化,代码应该怎么改呢?“分而治之”有两个关键因素,也就是内表拆分和外表重复扫描。我们不妨从这两个因素出发来调整原来的代码。
|
||||
|
||||
首先,内表拆分是否合理完全取决于拆分列的选取,而候选拆分列要同时满足基数适中、子表分布均匀,并且子表尺寸小于广播阈值等多个条件。纵观orders表的所有关键字段,只有date字段能够同时满足这些条件。因此,我们可以使用date字段,以天为单位对orders表做拆分,那么原代码中的查询语句需要作如下调整。
|
||||
|
||||
//以date字段拆分内表
|
||||
val query: String = "
|
||||
select sum(tx.price * tx.quantity) as revenue, o.orderId
|
||||
from transactions as tx inner join orders as o
|
||||
on tx.orderId = o.orderId
|
||||
where o.status = 'COMPLETE'
|
||||
and o.date = '2020-01-01'
|
||||
group by o.orderId
|
||||
"
|
||||
|
||||
|
||||
|
||||
你可能会说:“这不对吧,业务需求是计算一个季度的交易额,查询这么改不是只计算一天的量吗?”别着急,代码的调整还差一步:外表重复扫描。内表拆分之后,外表自然要依次与所有的子表做关联,最终把全部子关联的结果合并到一起,才算是完成“分而治之”的实现。
|
||||
|
||||
//循环遍历dates、完成“分而治之”的计算
|
||||
val dates: Seq[String] = Seq("2020-01-01", "2020-01-02", … "2020-03-31")
|
||||
|
||||
for (date <- dates) {
|
||||
|
||||
val query: String = s"
|
||||
select sum(tx.price * tx.quantity) as revenue, o.orderId
|
||||
from transactions as tx inner join orders as o
|
||||
on tx.orderId = o.orderId
|
||||
where o.status = 'COMPLETE'
|
||||
and o.date = ${date}
|
||||
group by o.orderId
|
||||
"
|
||||
|
||||
val file: String = s"${outFile}/${date}"
|
||||
spark.sql(query).save.parquet(file)
|
||||
}
|
||||
|
||||
|
||||
再次调整后的代码如上表所示,我们利用一个简单的for循环来遍历日期,从而让外表依次与子表做关联,并把子关联的计算结果直接写到outFile根目录下的子目录。代码的改动还是很简单的。不过,细心的你可能会发现:“这种写法,不是我们一直要极力避免的单机思维模式吗?”没错,单纯从写法上来看,这份代码的“单机思维”味道非常浓厚。
|
||||
|
||||
不过,对于“单机思维模式”的理解,我们不能仅仅停留在形式或是表面上。所谓单机思维模式,它指的是开发者不假思索地直入面向过程编程,忽略或无视分布式数据实体的编程模式。但在刚刚整理调优思路的过程中,我们一直把外表的重复扫描牢记于心,并想到通过利用DPP机制来避免它。因此,虽然我们使用了for循环,但并不会在运行时引入分布式数据集的重复扫描。
|
||||
|
||||
总的来说,在这个案例中,利用“分而治之”的调优方法,我们可以把所有“大表Join大表”的关联查询转化为“大表Join小表”,把原始的Shuffle Join转化为多个Broadcast Joins,而且Broadcast Joins又可以有效应对关联中的数据倾斜问题,可以说是一举两得。
|
||||
|
||||
小结
|
||||
|
||||
“大表Join大表”的第一种调优思路是“分而治之”,我们要重点掌握它的调优思路以及两个关键环节的优化处理。
|
||||
|
||||
“分而治之”的核心思想是通过均匀拆分内表的方式 ,把一个复杂而又庞大的Shuffle Join转化为多个Broadcast Joins,它的目的是,消除原有Shuffle Join中两张大表所引入的海量数据分发,大幅削减磁盘与网络开销的同时,从整体上提升作业端到端的执行性能。
|
||||
|
||||
在“分而治之”的调优过程中,内表的拆分最为关键,因为它肩负着Shuffle Join能否成功转化为Broadcast Joins的重要作用。而拆分的关键在于拆分列的选取。为了兼顾执行性能与开发效率,拆分列的基数要足够大,这样才能让子表小到足以放进广播变量,但同时,拆分列的基数也不宜过大,否则实现“分而治之”的开发成本就会陡然上升。通常来说,日期列往往是个不错的选择。
|
||||
|
||||
为了避免在调优的过程中引入额外的计算开销,我们要特别注意外表的重复扫描问题。针对外表的重复扫描,我们至少有两种应对方法。第一种是将外表全量缓存到内存,不过这种方法对于内存空间的要求较高,不具备普适性。第二种是利用Spark 3.0版本推出的DPP特性,在数仓设计之初,就以Join Key作为分区键,对外表做分区存储。
|
||||
|
||||
当我们做好了内表拆分,同时也避免了外表的重复扫描,我们就可以把原始的Shuffle Join转化为多个Broadcast Joins,在消除海量数据在全网分发的同时,避免引入额外的性能开销。那么毫无疑问,查询经过“分而治之”的调优过后,作业端到端的执行性能一定会得到大幅提升。
|
||||
|
||||
每日一练
|
||||
|
||||
在大表数据分布均匀的情况下,如果我们采用“分而治之”的调优技巧,要避免外表的重复扫描,除了采用缓存或是DPP机制以外,还有哪些其他办法?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
259
专栏/Spark性能调优实战/29大表Join大表(二):什么是负隅顽抗的调优思路?.md
Normal file
259
专栏/Spark性能调优实战/29大表Join大表(二):什么是负隅顽抗的调优思路?.md
Normal file
@ -0,0 +1,259 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
29 大表Join大表(二):什么是负隅顽抗的调优思路?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们说了应对“大表Join大表”的第一种调优思路是分而治之,也就是把一个庞大而又复杂的Shuffle Join转化为多个轻量的Broadcast Joins。这一讲,我们接着来讲第二种调优思路:负隅顽抗。
|
||||
|
||||
负隅顽抗指的是,当内表没法做到均匀拆分,或是外表压根就没有分区键,不能利用DPP,只能依赖Shuffle Join,去完成大表与大表的情况下,我们可以采用的调优方法和手段。这类方法比较庞杂,适用的场景各不相同。从数据分布的角度出发,我们可以把它们分两种常见的情况来讨论,分别是数据分布均匀和数据倾斜。
|
||||
|
||||
我们先来说说,在数据分布均匀的情况下,如何应对“大表Join大表”的计算场景。
|
||||
|
||||
数据分布均匀
|
||||
|
||||
在第27讲的最后,我们说过,当参与关联的大表与小表满足如下条件的时候,Shuffle Hash Join的执行效率,往往比Spark SQL默认的Shuffle Sort Merge Join更好。
|
||||
|
||||
|
||||
两张表数据分布均匀。
|
||||
内表所有数据分片,能够完全放入内存。
|
||||
|
||||
|
||||
实际上,这个调优技巧同样适用于“大表Join大表”的场景,原因其实很简单,这两个条件与数据表本身的尺寸无关,只与其是否分布均匀有关。不过,为了确保Shuffle Hash Join计算的稳定性,我们需要特别注意上面列出的第二个条件,也就是内表所有的数据分片都能够放入内存。
|
||||
|
||||
那么问题来了,我们怎么确保第二个条件得以成立呢?其实,只要处理好并行度、并发度与执行内存之间的关系,我们就可以让内表的每一个数据分片都恰好放入执行内存中。简单来说,就是先根据并发度与执行内存,计算出可供每个Task消耗的内存上下限,然后结合分布式数据集尺寸与上下限,倒推出与之匹配的并行度。更详细的内容你可以去看看第14讲。
|
||||
|
||||
那我们该如何强制Spark SQL在运行时选择Shuffle Hash Join机制呢?答案就是利用Join Hints。这个技巧我们讲过很多次了,所以这里,我直接以上一讲中的查询为例,把它的使用方法写在了下面,方便你复习。
|
||||
|
||||
//查询语句中使用Join hints
|
||||
select /*+ shuffle_hash(orders) */ sum(tx.price * tx.quantity) as revenue, o.orderId
|
||||
from transactions as tx inner join orders as o
|
||||
on tx.orderId = o.orderId
|
||||
where o.status = ‘COMPLETE’
|
||||
and o.date between ‘2020-01-01’ and ‘2020-03-31’
|
||||
group by o.orderId
|
||||
|
||||
|
||||
数据倾斜
|
||||
|
||||
接下来,我们再说说,当参与Join的两张表存在数据倾斜问题的时候,我们该如何应对“大表Join大表”的计算场景。对于“大表Join大表”的数据倾斜问题,根据倾斜位置的不同,我们可以分为3种情况来讨论。
|
||||
|
||||
|
||||
|
||||
其实,不管哪种表倾斜,它们的调优技巧都是类似的。因此,我们就以第一种情况为例,也就是外表倾斜、内表分布均匀的情况,去探讨数据倾斜的应对方法。
|
||||
|
||||
以Task为粒度解决数据倾斜
|
||||
|
||||
学过AQE之后,要应对数据倾斜,想必你很快就会想到AQE的特性:自动倾斜处理。给定如下配置项参数,Spark SQL在运行时可以将策略OptimizeSkewedJoin插入到物理计划中,自动完成Join过程中对于数据倾斜的处理。
|
||||
|
||||
|
||||
spark.sql.adaptive.skewJoin.skewedPartitionFactor,判定倾斜的膨胀系数。
|
||||
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes,判定倾斜的最低阈值。
|
||||
spark.sql.adaptive.advisoryPartitionSizeInBytes,以字节为单位定义拆分粒度。
|
||||
|
||||
|
||||
|
||||
|
||||
Join过程中的自动倾斜处理如上图所示,当AQE检测到外表存在倾斜分区之后,它会以spark.sql.adaptive.advisoryPartitionSizeInBytes配置的数值为拆分粒度,把倾斜分区拆分为多个数据分区。与此同时,AQE还需要对内表中对应的数据分区进行复制,来保护两表之间的关联关系。
|
||||
|
||||
有了AQE的自动倾斜处理特性,在应对数据倾斜问题的时候,我们确实能够大幅节省开发成本。不过,天下没有免费的午餐,AQE的倾斜处理是以Task为粒度的,这意味着原本Executors之间的负载倾斜并没有得到根本改善。这到底是什么意思呢?
|
||||
|
||||
|
||||
|
||||
我们来举个例子,假设某张表在Shuffle过后有两个倾斜分区如上图,它们又刚好都被Shuffle到了同一个执行器:Executor 0。在AQE的自动倾斜处理机制下,两个倾斜分区分别被拆分变成了4个尺寸适中的数据分区。如此一来,Executor 0中所有Task的计算负载都得到了平衡。但是,相比Executor 1,Executor 0整体的计算负载还是那么多,并没有因为AQE的自动处理而得到任何缓解。
|
||||
|
||||
以Executor为粒度解决数据倾斜
|
||||
|
||||
你也许会说:“哪会那么凑巧,倾斜的分区刚好全都落在同一个Executor上?”确实,刚才的例子主要是为了帮你解释清楚倾斜粒度这个概念,如果实际应用中倾斜分区在集群中的分布比较平均的话,AQE的自动倾斜处理机制确实就是开发者的“灵丹妙药”。
|
||||
|
||||
然而,凡事总有个万一,我们在探讨调优方案的时候,还是要考虑周全:如果你的场景就和咱们的例子一样,倾斜分区刚好落在集群中少数的Executors上,你该怎么办呢?答案是:“分而治之”和“两阶段Shuffle”。
|
||||
|
||||
这里的分而治之与上一讲的分而治之在思想上是一致的,都是以任务分解的方式来解决复杂问题。区别在于我们今天要讲的,是以Join Key是否倾斜为依据来拆解子任务。具体来说,对于外表中所有的Join Keys,我们先按照是否存在倾斜把它们分为两组。一组是存在倾斜问题的Join Keys,另一组是分布均匀的Join Keys。因为给定两组不同的Join Keys,相应地我们把内表的数据也分为两份。
|
||||
|
||||
|
||||
|
||||
那么,分而治之的含义就是,对于内外表中两组不同的数据,我们分别采用不同的方法做关联计算,然后通过Union操作,再把两个关联计算的结果集做合并,最终得到“大表Join大表”的计算结果,整个过程如上图所示。
|
||||
|
||||
对于Join Keys分布均匀的数据部分,我们可以沿用把Shuffle Sort Merge Join转化为Shuffle Hash Join的方法。对于Join Keys存在倾斜问题的数据部分,我们就需要借助“两阶段Shuffle”的调优技巧,来平衡Executors之间的工作负载。那么,什么是“两阶段Shuffle”呢?
|
||||
|
||||
如何理解“两阶段Shuffle”?
|
||||
|
||||
用一句话来概括,“两阶段Shuffle”指的是,通过“加盐、Shuffle、关联、聚合”与“去盐化、Shuffle、聚合”这两个阶段的计算过程,在不破坏原有关联关系的前提下,在集群范围内以Executors为粒度平衡计算负载 。
|
||||
|
||||
|
||||
|
||||
我们先来说说第一阶段,也就是“加盐、Shuffle、关联、聚合”的计算过程。显然,这个阶段的计算分为4个步骤,其中最为关键的就是第一步的加盐。加盐来源于单词Salting,听上去挺玄乎,实际上就是给倾斜的Join Keys添加后缀。加盐的核心作用就是把原本集中倾斜的Join Keys打散,在进行Shuffle操作的时候,让原本应该分发到某一个Executor的倾斜数据,均摊到集群中的多个Executors上,从而以这种方式来消除倾斜、平衡Executors之间的计算负载。
|
||||
|
||||
对于加盐操作,我们首先需要确定加盐的粒度,来控制数据打散的程度,粒度越高,加盐后的数据越分散。由于加盐的初衷是以Executors为粒度平衡计算负载,因此通常来说,取Executors总数#N作为加盐粒度往往是一种不错的选择。其次,为了保持内外表的关联关系不被破坏,外表和内表需要同时做加盐处理,但处理方法稍有不同。
|
||||
|
||||
外表的处理称作“随机加盐”,具体的操作方法是,对于任意一个倾斜的Join Key,我们都给它加上1到#N之间的一个随机后缀。以Join Key = ‘黄小乙’来举例,假设N = 5,那么外表加盐之后,原先Join Key = ‘黄小乙’的所有数据记录,就都被打散成了Join Key为(‘黄小乙_1’,‘黄小乙_2’,‘黄小乙_3’,‘黄小乙_4’,‘黄小乙_5’)的数据记录。
|
||||
|
||||
|
||||
|
||||
内表的处理称为“复制加盐”,具体的操作方法是,对于任意一个倾斜的Join Key,我们都把原数据复制(#N – 1)份,从而得到#N份数据副本。对于每一份副本,我们为其Join Key追加1到#N之间的固定后缀,让它与打散后的外表数据保持一致。对于刚刚Join Key = ‘黄小乙’的例子来说,在内表中,我们需要把‘黄小乙’的数据复制4份,然后依次为每份数据的Join Key追加1到5的固定后缀,如下图所示。
|
||||
|
||||
|
||||
|
||||
内外表分别加盐之后,数据倾斜问题就被消除了。这个时候,我们就可以使用常规优化方法,比如,将Shuffle Sort Merge Join转化为Shuffle Hash Join,去继续执行Shuffle、关联和聚合操作。到此为止,“两阶段Shuffle” 的第一阶段执行完毕,我们得到了初步的聚合结果,这些结果是以打散的Join Keys为粒度进行计算得到的。
|
||||
|
||||
|
||||
|
||||
我们刚刚说,第一阶段加盐的目的在于将数据打散、平衡计算负载。现在我们已经得到了数据打散之后初步的聚合结果,离最终的计算结果仅有一步之遥。不过,为了还原最初的计算逻辑,我们还需要把之前加上的“盐粒”再去掉。
|
||||
|
||||
|
||||
|
||||
第二阶段的计算包含“去盐化、Shuffle、聚合”这3个步骤。首先,我们把每一个Join Key的后缀去掉,这一步叫做“去盐化”。然后,我们按照原来的Join Key再做一遍Shuffle和聚合计算,这一步计算得到的结果,就是“分而治之”当中倾斜部分的计算结果。
|
||||
|
||||
经过“两阶段Shuffle”的计算优化,我们终于得到了倾斜部分的关联结果。将这部分结果与“分而治之”当中均匀部分的计算结果合并,我们就能完成存在倾斜问题的“大表Join大表”的计算场景。
|
||||
|
||||
以Executors为粒度的调优实战
|
||||
|
||||
应该说,以Executors为粒度平衡计算负载的优化过程,是我们学习过的调优技巧中最复杂的。因此,咱们有必要结合实际的应用案例,来详细讲解具体的实现方法。为了方便你对不同的调优方法做对比,我们不妨以上一讲跨境电商的场景为例来讲。
|
||||
|
||||
咱们先来回顾一下这家电商的业务需求,给定orders和transactions两张体量都在TB级别的事实表,每隔一段时间就计算一次上一个季度所有订单的交易额,具体的业务代码如下所示。
|
||||
|
||||
//统计订单交易额的代码实现
|
||||
val txFile: String = _
|
||||
val orderFile: String = _
|
||||
|
||||
val transactions: DataFrame = spark.read.parquent(txFile)
|
||||
val orders: DataFrame = spark.read.parquent(orderFile)
|
||||
|
||||
transactions.createOrReplaceTempView(“transactions”)
|
||||
orders.createOrReplaceTempView(“orders”)
|
||||
|
||||
val query: String = “
|
||||
select sum(tx.price * tx.quantity) as revenue, o.orderId
|
||||
from transactions as tx inner join orders as o
|
||||
on tx.orderId = o.orderId
|
||||
where o.status = ‘COMPLETE’
|
||||
and o.date between ‘2020-01-01’ and ‘2020-03-31’
|
||||
group by o.orderId
|
||||
”
|
||||
|
||||
val outFile: String = _
|
||||
spark.sql(query).save.parquet(outFile)
|
||||
|
||||
|
||||
|
||||
对于这样一个查询语句,我们该如何实现刚刚说过的优化过程呢?首先,我们先遵循“分而治之”的思想,把内外表的数据分为两个部分。第一部分包含所有存在倾斜问题的Join Keys及其对应的Payloads,第二部分保留的是分布均匀的Join Keys和相应的Payloads。假设我们把所有倾斜的orderId,也就是Join Key保存在数组skewOrderIds中,而把分布均匀的orderId保持在数组evenOrderIds中,我们就可以使用这两个数组,把内外表各自拆分为两部分。
|
||||
|
||||
//根据Join Keys是否倾斜、将内外表分别拆分为两部分
|
||||
import org.apache.spark.sql.functions.array_contains
|
||||
|
||||
//将Join Keys分为两组,存在倾斜的、和分布均匀的
|
||||
val skewOrderIds: Array[Int] = _
|
||||
val evenOrderIds: Array[Int] = _
|
||||
|
||||
val skewTx: DataFrame = transactions.filter(array_contains(lit(skewOrderIds),$"orderId"))
|
||||
val evenTx: DataFrame = transactions.filter(array_contains(lit(evenOrderIds),$"orderId"))
|
||||
|
||||
val skewOrders: DataFrame = orders.filter(array_contains(lit(skewOrderIds),$"orderId"))
|
||||
val evenOrders: DataFrame = orders.filter(array_contains(lit(evenOrderIds),$"orderId"))
|
||||
|
||||
|
||||
拆分完成之后,我们就可以延续“分而治之”的思想,分别对这两部分应用不同的调优技巧。对于分布均匀的部分,我们把Shuffle Sort Merge Join转化为Shuffle Hash Join。
|
||||
|
||||
//将分布均匀的数据分别注册为临时表
|
||||
evenTx.createOrReplaceTempView(“evenTx”)
|
||||
evenOrders.createOrReplaceTempView(“evenOrders”)
|
||||
|
||||
val evenQuery: String = “
|
||||
select /*+ shuffle_hash(orders) */ sum(tx.price * tx.quantity) as revenue, o.orderId
|
||||
from evenTx as tx inner join evenOrders as o
|
||||
on tx.orderId = o.orderId
|
||||
where o.status = ‘COMPLETE’
|
||||
and o.date between ‘2020-01-01’ and ‘2020-03-31’
|
||||
group by o.orderId
|
||||
”
|
||||
val evenResults: DataFrame = spark.sql(evenQuery)
|
||||
|
||||
|
||||
对于存在倾斜的部分,我们要祭出“两阶段Shuffle”的杀手锏。首先,在第一阶段,我们需要给两张表分别加盐,对外表(交易表)做“随机加盐”,对内表(订单表)做“复制加盐”。
|
||||
|
||||
import org.apache.spark.sql.functions.udf
|
||||
|
||||
//定义获取随机盐粒的UDF
|
||||
val numExecutors: Int = _
|
||||
val rand = () => scala.util.Random.nextInt(numExecutors)
|
||||
val randUdf = udf(rand)
|
||||
|
||||
//第一阶段的加盐操作。注意:保留orderId字段,用于后期第二阶段的去盐化
|
||||
|
||||
//外表随机加盐
|
||||
val saltedSkewTx = skewTx.withColumn(“joinKey”, concat($“orderId”, lit(“_”), randUdf()))
|
||||
|
||||
//内表复制加盐
|
||||
var saltedskewOrders = skewOrders.withColumn(“joinKey”, concat($“orderId”, lit(“_”), lit(1)))
|
||||
for (i <- 2 to numExecutors) {
|
||||
saltedskewOrders = saltedskewOrders union skewOrders.withColumn(“joinKey”, concat($“orderId”, lit(“_”), lit(i)))
|
||||
}
|
||||
|
||||
|
||||
两张表分别做完加盐处理之后,我们就可以使用与之前类似的查询语句,对它们执行后续的Shuffle、关联与聚合等操作。
|
||||
|
||||
//将加盐后的数据分别注册为临时表
|
||||
saltedSkewTx.createOrReplaceTempView(“saltedSkewTx”)
|
||||
saltedskewOrders.createOrReplaceTempView(“saltedskewOrders”)
|
||||
|
||||
val skewQuery: String = “
|
||||
select /*+ shuffle_hash(orders) */ sum(tx.price * tx.quantity) as initialRevenue, o.orderId, o.joinKey
|
||||
from saltedSkewTx as tx inner join saltedskewOrders as o
|
||||
on tx.joinKey = o.joinKey
|
||||
where o.status = ‘COMPLETE’
|
||||
and o.date between ‘2020-01-01’ and ‘2020-03-31’
|
||||
group by o.joinKey
|
||||
”
|
||||
//第一阶段加盐、Shuffle、关联、聚合后的初步结果
|
||||
val skewInitialResults: DataFrame = spark.sql(skewQuery)
|
||||
|
||||
|
||||
得到第一阶段的初步结果之后,我们就可以开始执行第二阶段的计算了,也就是“去盐化、Shuffle与聚合”这三个操作。去盐化的目的实际上就是把计算的粒度,从加盐的joinKey恢复为原来的orderId。由于在最初加盐的时候,我们对orderId字段进行了保留,因此在第二阶段的计算中,我们只要在orderId字段之上执行聚合操作,就能达到我们想要的“去盐化”效果。
|
||||
|
||||
val skewResults: DataFrame = skewInitialResults.select(“initialRevenue”, “orderId”)
|
||||
.groupBy(col(“orderId”)).agg(sum(col(“initialRevenue”)).alias(“revenue”))
|
||||
|
||||
|
||||
在完成了第二阶段的计算之后,我们拿到了“两阶段Shuffle”的计算结果。最终,只需要把这份结果与先前均匀部分的关联结果进行合并,我们就能实现以Executors为粒度平衡计算负载的优化过程。
|
||||
|
||||
evenResults union skewResults
|
||||
|
||||
|
||||
执行性能与开发成本的博弈
|
||||
|
||||
你可能会说:“我的天呐!为了优化这个场景的计算,这得花多大的开发成本啊!又是分而治之,又是两阶段Shuffle的,这么大的开发投入真的值得吗?”
|
||||
|
||||
这个问题非常好。我们要明确的是,分而治之外加两阶段Shuffle的调优技巧的初衷,是为了解决AQE无法以Executors为粒度平衡计算负载的问题。因此,这项技巧取舍的关键就在于,Executors之间的负载倾斜是否构成整个关联计算的性能瓶颈。如果这个问题的答案是肯定的,我们的投入就是值得的。
|
||||
|
||||
小结
|
||||
|
||||
今天这一讲,你需要掌握以Shuffle Join的方式去应对“大表Join大表”的计算场景。数据分布不同,应对方法也不尽相同。
|
||||
|
||||
当参与Join的两张表数据分布比较均匀,而且内表的数据分片能够完全放入内存,Shuffle Hash Join的计算效率往往高于Shuffle Sort Merge Join,后者是Spark SQL默认的关联机制。你可以使用关键字“shuffle_hash”的Join Hints,强制Spark SQL在运行时选择Shuffle Hash Join实现机制。对于内表数据分片不能放入内存的情况,你可以结合“三足鼎立”的调优技巧,调整并行度、并发度与执行内存这三类参数,来满足这一前提条件。
|
||||
|
||||
当参与Join的两张表存在数据倾斜时,如果倾斜的情况在集群内的Executors之间较为均衡,那么最佳的处理方法就是,利用AQE提供的自动倾斜处理机制。你只需要设置好以下三个参数,剩下的事情交给AQE就好了。
|
||||
|
||||
|
||||
spark.sql.adaptive.skewJoin.skewedPartitionFactor,判定倾斜的膨胀系数。
|
||||
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes,判定倾斜的最低阈值。
|
||||
spark.sql.adaptive.advisoryPartitionSizeInBytes,以字节为单位,定义拆分粒度。
|
||||
|
||||
|
||||
但是,如果倾斜问题仅集中在少数的几个Executors中,而且这些负载过高的Executors已然成为性能瓶颈,我们就需要采用“分而治之”外加“两阶段Shuffle”的调优技巧去应对。“分而治之”指的是根据Join Keys的倾斜与否,将内外表的数据分为两部分分别处理。其中,均匀的部分可以使用Shuffle Hash Join来完成计算,倾斜的部分需要用“两阶段Shuffle”进行处理。
|
||||
|
||||
两阶段Shuffle的关键在于加盐和去盐化。加盐的目的是打散数据分布、平衡Executors之间的计算负载,从而消除Executors单点瓶颈。去盐化的目的是还原原始的关联逻辑。尽管两阶段Shuffle的开发成本较高,但只要获得的性能收益足够显著,我们的投入就是值得的。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
当尝试将Join Keys是否倾斜作为“分而治之”的划分依据时,你觉得我们该依据什么标准把Join Keys划分为倾斜组和非倾斜组呢?
|
||||
无论是AQE的自动倾斜处理,还是开发者的“两阶段Shuffle”,本质上都是通过“加盐”与“去盐化”的两步走,在维持关联关系的同时平衡不同粒度下的计算负载。那么,这种“加盐”与“去盐化”的优化技巧,是否适用于所有的关联场景?如果不是,都有哪些场景没办法利用AQE的自动倾斜处理,或是我们的“两阶段Shuffle”呢?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
269
专栏/Spark性能调优实战/30应用开发:北京市小客车(汽油车)摇号趋势分析.md
Normal file
269
专栏/Spark性能调优实战/30应用开发:北京市小客车(汽油车)摇号趋势分析.md
Normal file
@ -0,0 +1,269 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
30 应用开发:北京市小客车(汽油车)摇号趋势分析
|
||||
你好,我是吴磊。
|
||||
|
||||
如果你也在北京生活,那小汽车摇号这件事大概率也和你息息相关。我身边很多人也一直都和我抱怨说:“小汽车摇号这件事太难了,遥遥无期,完全看不到希望,感觉还没买彩票靠谱呢”。
|
||||
|
||||
实不相瞒,我自己也在坚持小汽车摇号,在享受8倍概率的情况下,还是没能中签。因此,包括我在内的很多人都想知道,为什么摇号这么费劲?一个人平均需要参与多少次摇号才会中签?中签率的变化趋势真的和官方宣布的一致吗?倍率这玩意儿,真的能提高中签的概率吗?
|
||||
|
||||
这些问题,我们都能通过开发一个北京市小汽车摇号趋势分析的应用来解答。我会用两讲的时间带你完成这个应用的开发,在这个过程中,我们可以把前面学过的原理篇、通用调优篇和Spark SQL调优篇的大部分知识都上手实践一遍,是不是一听就很期待呢?
|
||||
|
||||
话不多说,我们赶紧开始吧!
|
||||
|
||||
课前准备
|
||||
|
||||
既然是做开发,那我们就需要做一些准备工作。准备工作分为3部分,分别是准备数据、准备开发环境和准备运行环境。
|
||||
|
||||
准备数据
|
||||
|
||||
应用所需的数据,我已经帮你准备好,也上传到了网盘,你可以点击这个地址,输入提取码ajs6 进行下载。
|
||||
|
||||
数据的文件名是“2011-2019小汽车摇号数据.tar.gz”,解压之后的目录结构如下图所示。根目录下有apply和lucky两个子目录,apply的目录内容是2011-2019年各个批次参与摇号的申请编号,lucky目录包含的是各个批次中签的申请编号。方便起见,我们把参与过摇号的人叫“申请者”,把中签的人叫“中签者”。apply和lucky的下一级子目录是各个摇号批次,而摇号批次目录下包含的是Parquet格式的数据分片。-
|
||||
|
||||
|
||||
apply和lucky两个子目录,在逻辑上分别对应着事实表和维度表,也可以叫做“申请者表”和“中签者表”。两张表的Schema都是(batchNum,carNum),也就是(摇号批次,申请编号)。总之,事实表和维度表在存储方式上都做了分区设计,且分区键都是batchNum。
|
||||
|
||||
准备开发环境
|
||||
|
||||
数据下载、解压完成之后,然后我们再来准备开发环境。首先,我们来说说开发语言。要完成“趋势分析应用”的开发,你可以结合个人偏好,使用Python、Java、Scala三种语言中的任意一种。由于我本人习惯使用Scala做开发,因此整个项目的代码都是用Scala实现的。如果你是Java或是Python开发者,也完全不必担心,结合后续应用逻辑的讲解与Scala版本的参考实现,我相信你也很快能完成应用的开发。
|
||||
|
||||
“趋势分析应用”非常轻量,Scala版本的参考实现不超过200行代码。因此,只使用Sublime甚至是VI这样的纯文本编辑器,我们也能很快实现。不过,为了提高开发效率,以及方便后续应用的打包和部署,我还是推荐你使用集成式的IDE,比如IntelliJ IDEA、Eclipse 、IntelliJ PyCharm等等。IDE的选取原则和开发语言一样,只要选择自己最顺手的就行了。
|
||||
|
||||
准备运行环境
|
||||
|
||||
最后是运行环境,由于咱们的应用比较轻量,而且数据量较小,解压之后的Parquet文件总大小还不超4GB,因此,你甚至可以用手里的笔记本电脑或是台式PC,就可以把应用从头到尾地跑通。选择“轻装上阵”主要是考虑到,不少同学可能不方便搭建分布式的物理集群,我们要确保这部分同学不会因为硬件的限制而不能参与实战。
|
||||
|
||||
不过,毕竟咱们这两讲的初衷和重点是性能调优实践,网络开销优化是其中关键的一环。因此,有条件的同学,我还是鼓励你搭建分布式的物理集群,或是采用云原生的分布式环境。一来这样的分布式环境更接近实际工作中的真实情况,二来调优前后的性能差异会更加地显著,有利于你加深理解不同调优技巧的作用和效果。
|
||||
|
||||
我这边选择了3台物理节点,它们的资源配置分别如下。其实,为了跑通应用和做性能对比,你并不需要这么强悍的机器配置。我这么做主要是贪图执行效率,因为想要说明不同调优技巧的作用与功效,我只需要拿到调优前后的对比结果就可以了,这样的配置可以减少我的等待时间。
|
||||
|
||||
|
||||
|
||||
应用开发
|
||||
|
||||
准备好了数据、开发环境和执行环境之后,我们就步入正题,开始进行“趋势分析应用”的开发。为了解答大家关于小汽车摇号的种种困惑,在这个应用中,我们主要分析如下几个案例:
|
||||
|
||||
|
||||
2011到2019年底,总共有多少人参与摇号
|
||||
摇号次数的总分布情况,以及申请者的分布情况和中签者的分布情况分别是什么
|
||||
中签率的变化趋势是什么
|
||||
中签率是否发生过较大变化,怎么对它做局部洞察
|
||||
倍率高的人是否更容易中签
|
||||
|
||||
|
||||
接下来,我们就来一一厘清这些案例的计算逻辑,并进行代码实现。
|
||||
|
||||
案例1:人数统计
|
||||
|
||||
首先,我们需要对数据有一个基本的认知。我们先从最简单的统计计数开始,也就是统计一下截至到2019年底,参与摇号的总人次和幸运的中签者人数。应该说,这样的需求非常简单,我们只需使用Parquet API读取源文件、创建DataFrame,然后调用count就可以了。
|
||||
|
||||
val rootPath: String = _
|
||||
|
||||
// 申请者数据(因为倍率的原因,每一期,同一个人,可能有多个号码)
|
||||
val hdfs_path_apply = s"${rootPath}/apply"
|
||||
val applyNumbersDF = spark.read.parquet(hdfs_path_apply)
|
||||
applyNumbersDF.count
|
||||
// 中签者数据
|
||||
val hdfs_path_lucky = s"${rootPath}/lucky"
|
||||
val luckyDogsDF = spark.read.parquet(hdfs_path_lucky)
|
||||
luckyDogsDF.count
|
||||
|
||||
|
||||
|
||||
把这段代码丢到spark-shell,或是打包部署到分布式环境去运行,我们很快就能够得到计算结果。截至到2019年底,摇号总人次为381972118,也就是3.8亿人次;中签的人数是1150828,也就是115万人。你可能会好奇:“摇号总人次为什么会有这么高的数量级?”
|
||||
|
||||
这其实并不奇怪。首先,同一个人可能参与多个批次的摇号,比如我就至少参加了60个批次的摇号(苦啊!)。再者,从2016年开始,小汽车摇号有了倍率这个概念。倍率的设计初衷,是让申请者的中签概率随着参与批次数量的增加而成比例地增加。也就是说,参与了60次摇号的人比仅参与10次摇号的人的中签概率更高。不过,官方对于倍率的实现略显简单粗暴。如果你去观察apply目录下2016年以后的批次文件就会发现,所谓的倍率实际上就是申请编号的副本数量。
|
||||
|
||||
正是出于以上两个原因,摇号总人次的体量才会有3.8亿人次。如果我们把倍率这个因素去掉,实际的摇号体量会是什么量级呢?
|
||||
|
||||
val applyDistinctDF = applyNumbersDF.select("batchNum", "carNum").distinct
|
||||
applyDistinctDF.count
|
||||
|
||||
|
||||
以(batchNum,carNum)为粒度进行去重计数,我们就能得到实际的摇号体量是135009819,也就是1.35亿人次。这意味着,从2011年到2019年这9年的时间里,有1.35亿人次参与了一项“抽奖游戏”,但是仅有115万人幸运中奖,摇号之难可见一斑。
|
||||
|
||||
案例2:摇号次数分布
|
||||
|
||||
接下来,我们进一步向下追踪(Drill Down),挖掘一下不同人群摇号次数的分布,也就是统计所有申请者累计参与了多少次摇号,所有中签者摇了多少次号才能幸运地摇中签。对于这两个统计计算,我们需要消除倍率的影响。也就是说,同一个申请编号在同一个批次中应该只保留一份副本。因此,我们需要使用去重之后的“申请者表”:applyDistinctDF。
|
||||
|
||||
场景1:参与摇号的申请者
|
||||
|
||||
首先,我们先来分析所有申请者的分布情况,当然也包括中签者。根据刚刚介绍的“业务需求”,我们很快就能写出相应的查询语句。
|
||||
|
||||
val result02_01 = applyDistinctDF
|
||||
.groupBy(col("carNum"))
|
||||
.agg(count(lit(1)).alias("x_axis"))
|
||||
.groupBy(col("x_axis"))
|
||||
.agg(count(lit(1)).alias("y_axis"))
|
||||
.orderBy("x_axis")
|
||||
|
||||
result02_01.write.format("csv").save("_")
|
||||
|
||||
|
||||
将上述代码付诸执行,我们会得到如下图所示的计算结果。其中,横坐标代表申请者参与过的摇号批次次数,纵坐标是对应的参与人数。从2011年到2013年,摇号是每月一次的。而从2014开始,摇号是每两个月一次的。因此,截至到2019年底,总共有72(12 * 3 + 6 * 6)次摇号。所以,我们看到横坐标的值域是从1到72,1表示摇过1次的人,72就比较惨了,它表示摇过72次的人。
|
||||
|
||||
|
||||
|
||||
从图中我们不难发现,随着摇号次数的逐级递增,人数分布基本上呈现出了逐级递减的趋势。那这意味着什么呢?这意味着每隔两个月就会有新人从驾校毕业,加入到庞大的摇号大军中来。仔细观察上图的左半部分我们会发现,摇号次数凡是遇到3的倍数,对应的人数往往比其“左邻右舍”多出甚至两倍,这是为什么呢?
|
||||
|
||||
我们刚刚说过,从2014年开始,摇号是每两个月进行一次。因此,摇号次数相差3则意味着两次摇号之间的时间差是半年左右。比如说,摇了3次的人就比摇了6次的人晚半年加入摇号大军。那么,半年意味着什么呢?我们不妨脑洞一下,尽管每个月都有从驾校毕业的学员,但是,寒暑假往往是大批量“生产”学员的高峰时期,而寒暑假恰好相差半年左右。你觉得我这个推测合理吗?
|
||||
|
||||
场景2:幸运的中签者
|
||||
|
||||
接下来,我们再来看看,那些中签的幸运儿们到底有多幸运?要想得到中签者的摇号次数,我们需要把applyDistinctDF和luckyDogsDF两张表做内关联,然后再做分组、聚合,代码实现如下表所示。
|
||||
|
||||
val result02_02 = applyDistinctDF
|
||||
.join(luckyDogsDF.select("carNum"), Seq("carNum"), "inner")
|
||||
.groupBy(col("carNum")).agg(count(lit(1)).alias("x_axis"))
|
||||
.groupBy(col("x_axis")).agg(count(lit(1)).alias("y_axis"))
|
||||
.orderBy("x_axis")
|
||||
|
||||
result02_02.write.format("csv").save("_")
|
||||
|
||||
|
||||
|
||||
将上述代码付诸执行,我们会得到如下图所示的计算结果,其中横纵坐标的含义与场景1一样,分别是摇号批次数和对应的人数分布。我们发现,随着摇号次数的逐级递增,人数的分布完全是单调递减的。也就是说,摇号的次数越多,中签者的数量越少。我能想到的一个原因是,摇号的次数越高,对应的参与人数就越少,这一点在场景1已经得到了验证。这个其实也不难理解,能一直坚持摇60次以上的玩家,真的都是骨灰级玩家。那么,参与的人基数小,中签者的数量自然就更少。-
|
||||
|
||||
|
||||
不过,如果假设申请者两个月摇一次号,那么我们就会得出一个非常扎心的结论:摇号中签的人往往不需要等待太长的时间,绝大多数都是在2-3年内摇中了购车资格,因为前半部分的总数占到了绝大多数。而等待3年以上才摇上号的人,反而成了幸运儿群体中的“少数派”。这不禁让我想起了当年大家开玩笑的那句话:“你要是人品够用,早就该摇上了。超过3年还没摇上,就说明你人品余额不足,摇号这件事以后也就不用指望了”。
|
||||
|
||||
案例3:中签率的变化趋势
|
||||
|
||||
从摇号次数的分布来看,申请者和中签者的变化趋势是一致的,那这是否意味着二者相除之后的比例是稳定的呢?二者的商实际上就是中签率。接下来,我们就去探究一下中签率的变化趋势。要计算中签率,我们需要分别统计每一个摇号批次中的申请者和中签者人数,然后再把它们做关联、聚合,代码实现如下所示。
|
||||
|
||||
// 统计每批次申请者的人数
|
||||
val apply_denominator = applyDistinctDF
|
||||
.groupBy(col("batchNum"))
|
||||
.agg(count(lit(1)).alias("denominator"))
|
||||
|
||||
// 统计每批次中签者的人数
|
||||
val lucky_molecule = luckyDogsDF
|
||||
.groupBy(col("batchNum"))
|
||||
.agg(count(lit(1)).alias("molecule"))
|
||||
|
||||
val result03 = apply_denominator
|
||||
.join(lucky_molecule, Seq("batchNum"), "inner")
|
||||
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
|
||||
.orderBy("batchNum")
|
||||
|
||||
result03.write.format("csv").save("_")
|
||||
|
||||
|
||||
我们得到的中签率示意图如下所示。其中,横坐标为各个摇号批次,从201101到201906,也就是从2011年的第一批到2019年的第72批,纵坐标就是中签率。从中我们可以很直观地看到,随着摇号批次的推进,中签率呈锐减的趋势。201101批次的中签率在9.4%左右,不到10%。而201906批次的中签率为1.9‰,也就是千分之一点九。这么看来,1000个人里面能摇上号的还凑不够两个人,这也难怪摇号如此之难了。
|
||||
|
||||
|
||||
|
||||
案例4:中签率局部洞察
|
||||
|
||||
第4个案例与案例3的区别在于,我们只关注2018年的中签率变化趋势。这样做的原因有二:一来,通过计算和对比,我发现2018年的中签率相比2017年几乎经历了“断崖式”的下跌,因此我想给2018年一个特写;二来,只关注2018年的数据,可以让我们有机会对比启用AQE Join策略调整前后的性能差异。
|
||||
|
||||
基于案例3的代码实现,要关注2018年,我们只需要在batchNum之上添加个过滤条件就好了。
|
||||
|
||||
// 筛选出2018年的中签数据,并按照批次统计中签人数
|
||||
val lucky_molecule_2018 = luckyDogsDF
|
||||
.filter(col("batchNum").like("2018%"))
|
||||
.groupBy(col("batchNum"))
|
||||
.agg(count(lit(1)).alias("molecule"))与
|
||||
|
||||
// 通过与筛选出的中签数据按照批次做关联,计算每期的中签率
|
||||
val result04 = apply_denominator
|
||||
.join(lucky_molecule_2018, Seq("batchNum"), "inner")
|
||||
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
|
||||
.orderBy("batchNum")
|
||||
|
||||
result04.write.format("csv").save("_")
|
||||
|
||||
|
||||
|
||||
|
||||
结合案例3与案例4的执行结果,我们至少有两点发现。第一点,2018年内各批次中签率下降较为平缓,从201801批次的2.3‰下降至201806批次的2.1‰,整体下降幅度不超过10%。第二点,2017年最后一个批次,也就是201706批次的中签率在4.9‰左右,而201801批次的中签率为2.3‰,在短短两个月之内,中签率惨遭“腰斩”,并在接下来的两年里,一路阴跌,最终在201906批次破掉2‰。
|
||||
|
||||
案例5:倍率分析
|
||||
|
||||
那么,在中签率如此之低的情况下,倍率这玩意还有意义吗?接下来,我们先去探索倍率的分布情况,然后再去观察,不同倍率的人群,他们的中签比例是怎样分布的。
|
||||
|
||||
场景1:不同倍率下的中签人数
|
||||
|
||||
我们先来统计一下,那些有幸中签的人分别是在多大的倍率下中签的。从2016年开始,才有倍率这个概念,因此,对于倍率的统计,我们只需要关注2016年以后的摇号数据即可。对于同一个中签者,他在不同批次的倍率可能是不同的,我们只需要拿到其中最大的倍率参与统计就可以了。原因很简单,最大的倍率就是她/他中签之前的倍率。
|
||||
|
||||
另外,倍率的计算需要依赖原始的多副本摇号数据,所以这里我们不能再使用去重的摇号数据,而应该用包含重复申请编号的applyNumbersDF表。基于这样的逻辑,我们的代码实现如下。
|
||||
|
||||
val result05_01 = applyNumbersDF
|
||||
.join(luckyDogsDF.filter(col("batchNum") >= "201601")
|
||||
.select("carNum"), Seq("carNum"), "inner")
|
||||
.groupBy(col("batchNum"),col("carNum"))
|
||||
.agg(count(lit(1)).alias("multiplier"))
|
||||
.groupBy("carNum")
|
||||
.agg(max("multiplier").alias("multiplier"))
|
||||
.groupBy("multiplier")
|
||||
.agg(count(lit(1)).alias("cnt"))
|
||||
.orderBy("multiplier")
|
||||
|
||||
result05_01.write.format("csv").save("_")
|
||||
|
||||
|
||||
中签者的倍率分布如下图所示。其中,横坐标为中签者的倍率,更准确地说,是中签者在参与的摇号批次中最大的副本数量,纵坐标是人数分布。通过观察执行结果我们不难发现,中签者的倍率呈现明显的正态分布。因此,从这张图我们可以得到初步结论:要想摇中车牌号,你并不需要很高的倍率。换句话说,对于中签这件事来说,倍率的作用和贡献并不是线性递增的。
|
||||
|
||||
|
||||
|
||||
不过,和案例2类似,这里同样存在一个基数的问题。也就是说,倍率高的人本来就少,其中的中签者数量自然也少。因此,我们还要结合申请者的倍率分布,去计算不同倍率下的中签比例,才能更加完备地对倍率的作用下结论。
|
||||
|
||||
场景2:不同倍率下的中签比例
|
||||
|
||||
对倍率分布有了初步认知之后,我们再来计算不同倍率人群的中签比例,去探究倍率本身对于中签的贡献究竟有多大。有了场景1中签者的倍率分布,我们只需要去计算申请者的倍率分布,然后把两份数据做关联、聚合,就可以得到我们想要的结果。
|
||||
|
||||
// Step01: 过滤出2016-2019申请者数据,统计出每个申请者在每一期内的倍率,并在所有批次中选取最大的倍率作为申请者的最终倍率,最终算出各个倍率下的申请人数
|
||||
val apply_multiplier_2016_2019 = applyNumbersDF
|
||||
.filter(col("batchNum") >= "201601")
|
||||
.groupBy(col("batchNum"), col("carNum"))
|
||||
.agg(count(lit(1)).alias("multiplier"))
|
||||
.groupBy("carNum")
|
||||
.agg(max("multiplier").alias("multiplier"))
|
||||
.groupBy("multiplier")
|
||||
.agg(count(lit(1)).alias("apply_cnt"))
|
||||
|
||||
// Step02: 将各个倍率下的申请人数与各个倍率下的中签人数左关联,并求出各个倍率下的中签率
|
||||
val result05_02 = apply_multiplier_2016_2019
|
||||
.join(result05_01.withColumnRenamed("cnt", "lucy_cnt"), Seq("multiplier"), "left")
|
||||
.na.fill(0)
|
||||
.withColumn("ratio", round(col("lucy_cnt")/col("apply_cnt"), 5))
|
||||
.orderBy("multiplier")
|
||||
|
||||
result05_02.write.format("csv").save("_")
|
||||
|
||||
|
||||
不同倍率下的中签比例如下图所示。其中横坐标为倍率,纵坐标有两个。蓝色柱状图体代表中签人数,它的分布与场景1的分布是一致的;绿色柱状条表示的是中签比例,它表示在同一个倍率下,中签人数与申请人数的比值。
|
||||
|
||||
|
||||
|
||||
与中签人数一样,中签比例在不同的倍率下,也呈现出了正态分布。有了这份数据做补充,我们可以夯实场景1中得出的结论。也就是,倍率对中签的贡献极其有限。这个结论很好地解释了,为什么摇号很久,倍率很高的人也难以中签。
|
||||
|
||||
到此为止,通过以上几个案例的分析,我们就对摇号次数分布、中签率变化趋势、倍率分布与中签比例有了答案。
|
||||
|
||||
小结
|
||||
|
||||
今天这一讲,我们重点开发了一个趋势分析应用,来解答北京市小汽车摇号的各个问题。这个应用主要实现了5个案例,分别是摇号次数分布、中签率变化趋势、中签率的大变动、倍率分布与中签比例。为了方便理解,我把它们要解决的问题、答案、主要的实现思路都总结在了下面的脑图中,你可以看一看。
|
||||
|
||||
|
||||
|
||||
至于这5个案例的代码实现和执行结果,我把它们都上传到了公用的GitHub仓库,你可以从这个地址获取完整内容。
|
||||
|
||||
当然,目前的代码肯定存在很多可以优化的地方,至于怎么优化,我先卖个关子,下一讲再详细来说。
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
如果让你来实现小汽车摇号的倍率机制,你觉得怎么实现才更严谨呢?
|
||||
基于这份2011-2019的小汽车摇号数据,你还能想到哪些有意思的洞察、视角和案例,值得我们进一步去探索呢?
|
||||
你认为,倍率对于中签的贡献和作用微乎其微的原因是什么呢?
|
||||
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
353
专栏/Spark性能调优实战/31性能调优:手把手带你提升应用的执行性能.md
Normal file
353
专栏/Spark性能调优实战/31性能调优:手把手带你提升应用的执行性能.md
Normal file
@ -0,0 +1,353 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
31 性能调优:手把手带你提升应用的执行性能
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们一起完成了小汽车摇号趋势分析的应用开发,解决了5个案例。今天这一讲,我们逐一对这5个案例做性能调优,一起把专栏中学过的知识和技巧应用到实战中去。
|
||||
|
||||
由于趋势分析应用中的案例较多,为了方便对比每一个案例调优前后的性能效果,我们先来对齐并统一性能对比测试的方法论。
|
||||
|
||||
首先,我们的性能对比测试是以案例为粒度的,也就是常说的Case By Case。然后,在每一个案例中,我们都有对比基准(Baseline)。对比基准的含义是,在不采取任何调优方法的情况下,直接把代码交付执行得到的运行时间。之后,对于每一个案例,我们会采取一种或多种调优方法做性能优化,每一种调优方法都有与之对应的运行时间。最终,我们将不同调优方法的运行时间与对比基准做横向比较,来观察调优前后的性能差异,并分析性能提升/下降的背后原因。
|
||||
|
||||
话不多说,我们直接开始今天的课程吧!
|
||||
|
||||
运行环境
|
||||
|
||||
既然调优效果主要由执行时间来体现,那在开始调优之前,我们有必要先来交代一下性能测试采用的硬件资源和配置项设置。硬件资源如下表所示。
|
||||
|
||||
|
||||
|
||||
为了避免因为实验本身而等待太长的时间,我使用了比较强悍的机器资源。实际上,为了跑通应用,完成性能对比测试,你使用笔记本也可以。而且为了给后续调优留出足够空间,除了必需的运行资源设置以外,其他配置项全部保留了默认值,具体的资源配置如下表所示。
|
||||
|
||||
|
||||
|
||||
另外,由于调优方法中涉及AQE和DPP这些Spark 3.0新特性,因此,我建议你使用3.0及以上的Spark版本来部署运行环境,我这边采用的版本号是Spark 3.1.1。
|
||||
|
||||
接下来,我们就Case By Case地去回顾代码实现,分别分析5个案例的优化空间、可能的调优方法、方法的效果,以及它们与对比基准的性能差异。
|
||||
|
||||
案例1的性能调优:人数统计
|
||||
|
||||
首先,我们先来回顾案例1。案例1的意图是统计摇号总人次、中签者人数,以及去掉倍率影响之后的摇号总人次,代码如下所示。
|
||||
|
||||
val rootPath: String = _
|
||||
|
||||
// 申请者数据(因为倍率的原因,每一期,同一个人,可能有多个号码)
|
||||
val hdfs_path_apply = s"${rootPath}/apply"
|
||||
val applyNumbersDF = spark.read.parquet(hdfs_path_apply)
|
||||
applyNumbersDF.count
|
||||
// 中签者数据
|
||||
val hdfs_path_lucky = s"${rootPath}/lucky"
|
||||
val luckyDogsDF = spark.read.parquet(hdfs_path_lucky)
|
||||
luckyDogsDF.count
|
||||
|
||||
// 申请者数据(去掉倍率的影响)
|
||||
val applyDistinctDF = applyNumbersDF.select("batchNum", "carNum").distinct
|
||||
applyDistinctDF.count
|
||||
|
||||
|
||||
从上面的代码实现中,我们不难发现,短短的几行代码共有3个Actions,也就是3个不同数据集上的count操作,这3个Actions会触发3个Spark Jobs。其中,前2个Jobs都是读取数据源之后立即计数,没什么优化空间。第3个Job是在applyNumbersDF之上做去重,然后再统计计数。结合上一讲对于不同案例的讲解,我们知道,applyNumbersDF、luckyDogsDF和applyDistinctDF这3个数据集,在后续的案例中会被反复引用。
|
||||
|
||||
因为上述3个数据集的引用次数过于频繁,所以我们甚至都不用去计算“运行成本占比”,就可以判定:利用Cache一定有利于提升执行性能。
|
||||
|
||||
|
||||
使用Cache的一般性原则:-
|
||||
如果RDD/DataFrame/Dataset在应用中的引用次数为1,那么坚决不使用Cache-
|
||||
如果引用次数大于1,且运行成本占比超过30%,应当考虑启用Cache
|
||||
|
||||
|
||||
因此,对于第3个Job,我们可以利用Cache机制来提升执行性能。调优方法很简单,我们只需在applyNumbersDF.count之前添加一行代码:applyNumbersDF.cache。
|
||||
|
||||
由于这个案例中性能对比测试的关注点是第3个Job,那为了方便横向对比,我们先把不相干的Jobs和代码去掉,整理之后的对比基准和调优代码如下表所示。
|
||||
|
||||
|
||||
|
||||
然后,我们把这两份代码分别打包、部署和执行,并记录applyDistinctDF.count作业的执行时间,来完成性能对比测试,我把执行结果记录到了下面的表格中。
|
||||
|
||||
|
||||
|
||||
从中我们可以看到,相较于对比基准,调优之后的执行性能提升了20%。坦白地说,这样的提升是我们意料之中的。毕竟前者消耗的是磁盘I/O,而调优之后计数作业直接从内存获取数据。
|
||||
|
||||
案例2的性能调优:摇号次数分布
|
||||
|
||||
接下来,我们再来分析案例2。案例2分为两个场景,第一个场景用于统计申请者摇号批次数量的分布情况,第二个场景也是类似,不过它的出发点是中签者,主要用来解答“中签者通常需要摇多少次号才能中签”这类问题。
|
||||
|
||||
场景1:参与摇号的申请者
|
||||
|
||||
我们先来回顾一下场景1的代码实现。仔细研读代码,我们不难发现,场景1是典型的单表Shuffle,而且是两次Shuffle。第一次Shuffle操作是以数据列“carNum”为基准做分组计数,第二次Shuffle是按照“x_axis”列再次做分组计数。
|
||||
|
||||
val result02_01 = applyDistinctDF
|
||||
.groupBy(col("carNum"))
|
||||
.agg(count(lit(1)).alias("x_axis"))
|
||||
.groupBy(col("x_axis"))
|
||||
.agg(count(lit(1)).alias("y_axis"))
|
||||
.orderBy("x_axis")
|
||||
|
||||
result02_01.write.format("csv").save("_")
|
||||
|
||||
|
||||
因此,场景1的计算实际上就是2次Word Count而已,只不过第一次的Word是“carNum”,而第二次的Word是“x_axis”。那么,对于这样的“Word Count”,我们都有哪些调优思路呢?
|
||||
|
||||
在配置项调优那一讲,我们专门介绍了Shuffle调优的一些常规方法,比如调整读写缓冲区大小、By Pass排序操作等等。除此之外,我们的脑子里一定要有一根弦:Shuffle的本质是数据的重新分发,凡是有Shuffle操作的地方都需要关注数据分布。所以对于过小的数据分片,我们要有意识地对其进行合并。再者,在案例1中我们提到,applyNumbersDF、luckyDogsDF和applyDistinctDF在后续的案例中会被反复引用,因此给applyDistinctDF加Cache也是一件顺理成章的事情。
|
||||
|
||||
调优的思路这么多,那为了演示每一种调优方法的提升效果,我会从常规操作、数据分区合并、加Cache这3个方向出发,分别对场景1进行性能调优。不过,需要说明的是,咱们这么做的目的,一来是为了开阔调优思路,二来是为了复习之前学习过的调优技巧。
|
||||
|
||||
当然了,在实际工作中,我们一般没有时间和精力像现在这样,一个方法、一个方法去尝试。那么,效率最高的做法应该是遵循我们一直强调的调优方法论,也就是先去应对木桶的短板、消除瓶颈,优先解决主要矛盾,然后在时间、精力允许的情况下,再去应对次短的木板。
|
||||
|
||||
那么问题来了,你认为上述3种调优思路分别应对的是哪些“木板”?这些“木板”中哪一块是最短的?你又会优先采用哪种调优技巧?接下来,我们就带着这些问题,依次对场景1做调优。
|
||||
|
||||
思路1:Shuffle常规优化
|
||||
|
||||
刚刚咱们提到,Shuffle的常规优化有两类:一类是By Pass排序操作,一类是调整读写缓冲区。而By Pass排序有两个前提条件:一是计算逻辑不涉及聚合或排序;二是Reduce阶段的并行度要小于参数spark.shuffle.sort.bypassMergeThreshold的设置值。显然,场景1不符合要求,计算逻辑既包含聚合也包含排序。所以,我们就只有调整读写缓冲区这一条路可走了。
|
||||
|
||||
实际上,读写缓冲区的调优也是有前提的,因为这部分内存消耗会占用Execution Memory内存区域,所以提高缓冲区大小的前提是Execution Memory内存比较充裕。由于咱们使用的硬件资源比较强劲,而且小汽车摇号数据整体体量偏小,因此咱们还是有一些“资本”对读写缓冲区做调优的。具体来说,我们需要调整如下两个配置项:
|
||||
|
||||
|
||||
spark.shuffle.file.buffer,Map阶段写入缓冲区大小
|
||||
spark.reducer.maxSizeInFlight,Reduce阶段读缓冲区大小
|
||||
|
||||
|
||||
由于读写缓冲区都是以Task为粒度进行设置的,因此调整这两个参数的时我们要小心一点,一般来说50%往往是个不错的开始,对比基准与优化设置如下表所示。
|
||||
|
||||
|
||||
|
||||
两组对比实验的运行时间,我记录到了下面的表格中。从中我们不难发现,上述两个参数的调整,对于作业端到端执行性能的影响不大。不过,这种参数调了半天,执行效率并没有显著提升的场景,肯定让你似曾相识。这个时候,最好的办法就是我们继续借助“木桶短板”“瓶颈”以及“调优方法论”,去尝试其他的调优思路。
|
||||
|
||||
|
||||
|
||||
思路2:数据分区合并
|
||||
|
||||
接着,我们再来说第二个思路,数据分区合并。首先,咱们先来一起分析一下,场景1到底存不存在数据分片过小的问题。为了方便分析,我们再来回顾一遍代码。因为场景1的计算基于数据集applyDistinctDF,所以要回答刚刚的问题,我们需要结合数据集applyDistinctDF的存储大小,以及Shuffle计算过后Reduce阶段的并行度一起来看。
|
||||
|
||||
val result02_01 = applyDistinctDF
|
||||
.groupBy(col("carNum"))
|
||||
.agg(count(lit(1)).alias("x_axis"))
|
||||
|
||||
.groupBy(col("x_axis"))
|
||||
.agg(count(lit(1)).alias("y_axis"))
|
||||
|
||||
.orderBy("x_axis")
|
||||
|
||||
result02_01.write.format("csv").save("_")
|
||||
|
||||
|
||||
并行度由配置项spark.sql.shuffle.partitions决定,其默认大小为200,也就是200个数据分区。而对于数据集存储大小的估算,我们需要用到下面的函数。
|
||||
|
||||
def sizeNew(func: => DataFrame, spark: => SparkSession): String = {
|
||||
|
||||
val result = func
|
||||
|
||||
val lp = result.queryExecution.logical
|
||||
|
||||
val size = spark.sessionState.executePlan(lp).optimizedPlan.stats.sizeInBytes
|
||||
|
||||
"Estimated size: " + size/1024 + "KB"
|
||||
|
||||
}
|
||||
|
||||
|
||||
给定DataFrame,sizeNew函数可以返回该数据集在内存中的精确大小。把applyDistinctDF作为实参,调用sizeNew函数,返回的估算尺寸为2.6 GB。将数据集尺寸除以并行度,我们就能得到Reduce阶段每个数据分片的存储大小,也就是13 MB(也就是2.6 GB / 200)。通常来说,数据分片的尺寸大小在200 MB左右为宜,13 MB的分片尺寸显然过小。
|
||||
|
||||
在调度系统那一讲(第5讲),我们说过,如果需要处理的数据分片过小,相较于数据处理,Task调度开销将变得异常显著,而这样会导致CPU利用降低,执行性能变差。因此,为了提升CPU利用率进而提升整体的执行效率,我们需要对过小的数据分片做合并。这个时候,AQE的自动分区合并特性就可以帮我们做这件事情。
|
||||
|
||||
不过,要想充分利用AQE的自动分区合并特性,我们还需要对相关的配置项进行调整。这里,你直接看场景1是怎么设置这些配置项的就可以了。
|
||||
|
||||
|
||||
|
||||
一旦开启AQE机制,自动分区合并特性会自动生效。表格中的配置项有两个需要我们特别注意,一个是最小分区数minPartitionNum,另一个是合并之后的目标尺寸advisoryPartitionSizeInBytes。
|
||||
|
||||
我们先来看最小分区数,也就是minPartitionNum。minPartitionNum的含义,指的是分区合并之后的分区数量,不能低于这个参数设置的数值。由于我们计算集群配置的Executors个数为6,为了保证每个CPU都不闲着、有活儿干,我们不妨把minPartitionNum也设置为6。
|
||||
|
||||
接下来是分区合并的目标尺寸,我们刚刚说过,分区大小的经验值在200 MB左右,因此我们不妨把advisoryPartitionSizeInBytes设置为200 MB。不过,为了对比不同分区大小对于执行性能的影响,我们可以多做几组实验。
|
||||
|
||||
配置项调整前后的几组实验效果对比如下,可以看到,调优后的运行时间有所缩短,这说明分区合并对于提升CPU利用率和作业的整体执行性能是有帮助的。仔细观察下表,我们至少有3点洞察。
|
||||
|
||||
|
||||
|
||||
|
||||
并行度过高、数据分片过小,CPU调度开销会变大,执行性能也变差。
|
||||
分片粒度划分在200 MB左右时,执行性能往往是最优的。
|
||||
并行度过低、数据分片过大,CPU数据处理开销也会过大,执行性能会锐减。
|
||||
|
||||
|
||||
思路3:加Cache
|
||||
|
||||
最后一个思路是加Cache,这个调优技巧使用起来非常简单,我们在案例1已经做过演示,因此,这里直接给出优化代码和运行结果。
|
||||
|
||||
|
||||
|
||||
可以看到,利用Cache机制做优化,作业执行性能提升得非常显著。
|
||||
|
||||
|
||||
|
||||
到此为止,我们尝试了3种调优方法来对场景1做性能优化,分别是Shuffle读写缓冲区调整、数据分区合并,以及加Cache。第1种方法针对的是,Shuffle过程中磁盘与网络的请求次数;第2种方法的优化目标,是提升Reduce阶段的CPU利用率;第3种方法针对的是,数据集在磁盘中的重复扫描与重复计算。
|
||||
|
||||
实际上,根本不需要做定量分析,仅从定性我们就能看出,数据集的重复扫描与计算的开销最大。因此,在实际工作中,对于类似的“多选题”,我们自然要优先选择能够消除瓶颈的第3种方法。
|
||||
|
||||
场景2:幸运的中签者
|
||||
|
||||
完成了场景1单表Shuffle的优化之后,接下来,我们再来看看场景2,场景2的业务目标是获取中签者的摇号次数分布。我们先来回顾场景2的代码实现,场景2的计算涉及一次数据关联,两次分组、聚合,以及最终的排序操作。不难发现,除了关联计算外,其他计算步骤与场景1如出一辙。因此,对于场景2的优化,我们专注在第一步的数据关联,后续优化沿用场景1的调优方法即可。
|
||||
|
||||
val result02_02 = applyDistinctDF
|
||||
.join(luckyDogsDF.select("carNum"), Seq("carNum"), "inner")
|
||||
.groupBy(col("carNum")).agg(count(lit(1)).alias("x_axis"))
|
||||
.groupBy(col("x_axis")).agg(count(lit(1)).alias("y_axis"))
|
||||
.orderBy("x_axis")
|
||||
|
||||
result02_02.write.format("csv").save("_")
|
||||
|
||||
|
||||
参与关联的两张表分别是applyDistinctDF和luckyDogsDF,其中applyDistinctDF是去重之后的摇号数据,luckyDogsDF包含的是中签者的申请编号与批次号。applyDistinctDF包含1.35条数据记录,而luckyDogsDF仅仅包含115万条数据记录。很显然,二者之间的数据关联属于数仓中常见的“大表Join小表”。
|
||||
|
||||
遇到“大表Join小表”的计算场景,我们最先应该想到的调优技巧一定是广播变量。毕竟,我们一直都在不遗余力地强调Broadcast Joins的优势与收益。在这里,我再强调一次,你一定要掌握使用广播变量优化数据关联的调优技巧。毫不夸张地说,广播变量是“性价比”最高的调优技巧,且没有之一。
|
||||
|
||||
要利用广播变量来优化applyDistinctDF与luckyDogsDF的关联计算,我们需要做两件事情。第一件,估算luckyDogsDF数据表在内存中的存储大小。第二件,设置广播阈值配置项spark.sql.autoBroadcastJoinThreshold。
|
||||
|
||||
对于分布式数据集的尺寸预估,我们还是使用sizeNew函数,把luckyDogsDF作为实参,调用sizeNew函数,返回的估算尺寸为18.5MB。有了这个参考值,我们就可以设置广播阈值了。要把applyDistinctDF与luckyDogsDF的关联计算转化为Broadcast Join,只要让广播阈值大于18.5MB就可以,我们不妨把这个参数设置为20MB。
|
||||
|
||||
|
||||
|
||||
我把配置项调整前后的实验结果记录到了如下表格,显然,相比默认的Shuffle Sort Merge Join实现机制,Broadcast Join的执行性能更胜一筹。
|
||||
|
||||
|
||||
|
||||
案例3的性能调优:中签率的变化趋势
|
||||
|
||||
案例3的业务目标是洞察中签率的变化趋势,我们先来回顾代码。要计算中签率,我们需要分两步走。第一步,按照摇号批次,也就是batchNum分别对applyDistinctDF和luckyDogsDF分组,然后分别对分组内的申请者和中签者做统计计数。第二步,通过数据关联将两类计数做除法,最终得到每个批次的中签率。
|
||||
|
||||
// 统计每批次申请者的人数
|
||||
val apply_denominator = applyDistinctDF
|
||||
.groupBy(col("batchNum"))
|
||||
.agg(count(lit(1)).alias("denominator"))
|
||||
|
||||
// 统计每批次中签者的人数
|
||||
val lucky_molecule = luckyDogsDF
|
||||
.groupBy(col("batchNum"))
|
||||
.agg(count(lit(1)).alias("molecule"))
|
||||
|
||||
val result03 = apply_denominator
|
||||
.join(lucky_molecule, Seq("batchNum"), "inner")
|
||||
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
|
||||
.orderBy("batchNum")
|
||||
|
||||
result03.write.format("csv").save("_")
|
||||
|
||||
|
||||
由于2011年到2019年总共有72个摇号批次,因此第一步计算得到结果集,也就是apply_denominator和lucky_molecule各自有72条数据记录。显然,两个如此之小的数据集做关联不存在什么调优空间。
|
||||
|
||||
因此,对于案例3来说,调优的关键在于第一步涉及的两个单表Shuffle。关于单表Shuffle的调优思路与技巧,我们在案例2的场景1做过详细的分析与讲解,因此,applyDistinctDF和luckyDogsDF两张表的Shuffle优化就留给你作为课后练习了。
|
||||
|
||||
案例4的性能调优:中签率局部洞察
|
||||
|
||||
与案例3不同,案例4只关注2018年的中签率变化趋势,我们先来回顾案例4的代码实现。
|
||||
|
||||
// 筛选出2018年的中签数据,并按照批次统计中签人数
|
||||
val lucky_molecule_2018 = luckyDogsDF
|
||||
.filter(col("batchNum").like("2018%"))
|
||||
.groupBy(col("batchNum"))
|
||||
.agg(count(lit(1)).alias("molecule"))
|
||||
|
||||
// 通过与筛选出的中签数据按照批次做关联,计算每期的中签率
|
||||
val result04 = apply_denominator
|
||||
.join(lucky_molecule_2018, Seq("batchNum"), "inner")
|
||||
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
|
||||
.orderBy("batchNum")
|
||||
|
||||
result04.write.format("csv").save("_")
|
||||
|
||||
|
||||
从代码实现来看,案例4相比案例3唯一的改动,就是在luckyDogsDF做统计计数之前增加了摇号批次的过滤条件,也就是filter(col(“batchNum”).like(“2018%”))。你可能会说:“案例4的改动可以说是微乎其微,它的调优空间和调优方法应该和案例3没啥区别”。还真不是,还记得Spark 3.0推出的DPP新特性吗?添加在luckyDogsDF表上的这个不起眼的过滤谓词,恰恰让DPP有了用武之地。
|
||||
|
||||
在DPP那一讲,我们介绍过开启DPP的3个前提条件:
|
||||
|
||||
|
||||
事实表必须是分区表,且分区字段(可以是多个)必须包含Join Key
|
||||
DPP仅支持等值Joins,不支持大于、小于这种不等值关联关系
|
||||
维表过滤之后的数据集,必须要小于广播阈值,因此,你要注意调整配置项spark.sql.autoBroadcastJoinThreshold
|
||||
|
||||
|
||||
那么,这3个前提条件是怎么影响案例4的性能调优的呢?
|
||||
|
||||
首先,在上一讲,我们介绍过摇号数据的目录结构,apply和lucky目录下的数据都按照batchNum列做了分区存储。因此,案例4中参与关联的数据表applyDistinctDF和luckyDogsDF都是分区表,且分区键batchNum刚好是二者做关联计算的Join Key。其次,案例4中的关联计算显然是等值Join。
|
||||
|
||||
最后,我们只要保证lucky_molecule_2018结果集小于广播阈值就可以触发DPP机制。2018年只有6次摇号,也就是说,分组计数得到的lucky_molecule_2018只有6条数据记录,这么小的“数据集”完全可以放进广播变量。
|
||||
|
||||
如此一来,案例4满足了DPP所有的前提条件,利用DPP机制,我们就可以减少applyDistinctDF的数据扫描量,从而在整体上提升作业的执行性能。
|
||||
|
||||
|
||||
|
||||
DPP的核心作用在于降低事实表applyDistinctDF的磁盘扫描量,因此案例4的调优办法非常简单,只要把最初加在applyDistinctDF之上的Cache去掉即可,如上表右侧所示。同时,为了公平起见,对比基准不应该仅仅是让DPP失效的测试用例,而应该是applyDistinctDF加Cache的测试用例。与此同时,我们直接对比DPP的磁盘读取效率与Cache的内存读取效率,也能加深对DPP机制的认知与理解。
|
||||
|
||||
把上述两个测试用例交付执行,运行结果如下。可以看到,相较对比基准,在DPP机制的作用下,案例4端到端的执行性能有着将近5倍的提升。由此可见,数据集加Cache之后的内存读取,远不如DPP机制下的磁盘读取更高效。
|
||||
|
||||
|
||||
|
||||
案例5的性能调优:倍率分析
|
||||
|
||||
案例5也包含两个场景,场景1的业务目标是计算不同倍率下的中签人数,场景2与场景1相比稍有不同,它的目的是计算不同倍率下的中签比例。
|
||||
|
||||
尽管两个场景的计算逻辑有区别,但是调优思路与方法是一致的。因此,在案例5中,我们只需要对场景1的性能优化进行探讨、分析与对比,我们先来回顾一下场景1的代码实现。
|
||||
|
||||
val result05_01 = applyNumbersDF
|
||||
.join(luckyDogsDF.filter(col("batchNum") >= "201601")
|
||||
.select("carNum"), Seq("carNum"), "inner")
|
||||
.groupBy(col("batchNum"),col("carNum"))
|
||||
.agg(count(lit(1)).alias("multiplier"))
|
||||
.groupBy("carNum")
|
||||
.agg(max("multiplier").alias("multiplier"))
|
||||
.groupBy("multiplier")
|
||||
.agg(count(lit(1)).alias("cnt"))
|
||||
.orderBy("multiplier")
|
||||
|
||||
result05_01.write.format("csv").save("_")
|
||||
|
||||
|
||||
仔细研读代码之后,我们发现场景1的计算分为如下几个环节:
|
||||
|
||||
|
||||
大表与小表的关联计算,且小表带过滤条件
|
||||
按batchNum列做统计计数
|
||||
按carNum列取最大值
|
||||
按multiplier列做统计计数
|
||||
|
||||
|
||||
在这4个环节当中,关联计算涉及的数据扫描量和数据处理量最大。因此,这一环节是案例5执行效率的关键所在。另外,除了关联计算环节,其他3个环节都属于单表Shuffle优化的范畴,这3个环节的优化可以参考案例2场景1的调优思路与技巧,咱们也不多说了。因此,对于案例5的性能优化,我们重点关注第一个环节,也就是applyNumbersDF与luckyDogsDF的关联计算。
|
||||
|
||||
仔细观察第一个环节的关联计算,我们发现关联条件中的Join Key是carNum,而carNum并不是applyNumbersDF与luckyDogsDF两张表的分区键,因此,在这个关联查询中,我们没有办法利用DPP机制去做优化。
|
||||
|
||||
不过,applyNumbersDF与luckyDogsDF的内关联是典型的“大表Join小表”,对于这种场景,我们至少有两种方法可以将低效的SMJ转化为高效的BHJ。
|
||||
|
||||
第一种办法是计算原始数据集luckyDogsDF的内存存储大小,确保其小于广播阈值,从而利用Spark SQL的静态优化机制将SMJ转化为BHJ。第二种方法是确保过滤后的luckyDogsDF小于广播阈值,这样我们就可以利用Spark SQL的AQE机制来动态地将SMJ转化为BHJ。
|
||||
|
||||
接下来,我们分别使用这两种方法来做优化,比较它们之间,以及它们与对比基准之间的性能差异。在案例2场景2中,我们计算过luckyDogsDF在内存中的存储大小是18.5MB,因此,通过适当调节spark.sql.autoBroadcastJoinThreshold,我们就可以灵活地在两种调优方法之间进行切换。
|
||||
|
||||
|
||||
|
||||
将3种测试用例付诸执行,在执行效率方面,SMJ毫无悬念是最差的,而AQE的动态优化介于SMJ与Spark SQL的静态转化之间。毕竟,AQE的Join策略调整是一种“亡羊补牢、犹未为晚”的优化机制,在把SMJ调整为BHJ之前,参与Join的两张表的Shuffle计算已经执行过半。因此,它的执行效率一定比Spark SQL的静态优化更差。尽管如此,AQE动态调整过后的BHJ还是比默认的SMJ要高效得多,而这也体现了AQE优化机制的价值所在。
|
||||
|
||||
|
||||
|
||||
小结
|
||||
|
||||
今天这一讲,我们结合以前学过的知识点与调优技巧,以小汽车摇号为例Case By Case地做性能优化。涉及的优化技巧有Shuffle读写缓冲区调整、加Cache、预估数据集存储大小、Spark SQL静态优化、AQE动态优化(自动分区合并与Join策略调整)以及DPP机制。为了方便你对比,我把它们总结在了一张脑图里。不过,我更希望你能自己总结一下,这样才能记得更好。
|
||||
|
||||
|
||||
|
||||
最后我想说,很遗憾我们没能在这个实战里,把专栏中所有的调优技巧付诸实践,这主要是因为小汽车摇号应用相对比较简单,很难覆盖所有的计算与优化场景。对于那些未能付诸实战的调优技巧,只能靠你在平时的工作中去实践了。
|
||||
|
||||
不过,专栏的留言区和咱们的读者群,会一直为你敞开,尽管我不能做到立即回复,但我可以承诺的是,对于你的留言,我只会迟到、绝不缺席!
|
||||
|
||||
每日一练
|
||||
|
||||
|
||||
你能参考案例2场景1,完成案例3中applyDistinctDF和luckyDogsDF两张表的单表Shuffle优化吗?
|
||||
你能参考案例5场景1,综合运用AQE、Broadcast Join等调优技巧,对案例5场景2做性能优化吗?
|
||||
|
||||
|
||||
期待在留言区看到你的优化结果,也欢迎你随时提问,我们下一讲见!
|
||||
|
||||
|
||||
|
||||
|
199
专栏/Spark性能调优实战/SparkUI(上)深入解读Spark作业的“体检报告”.md
Normal file
199
专栏/Spark性能调优实战/SparkUI(上)深入解读Spark作业的“体检报告”.md
Normal file
@ -0,0 +1,199 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
Spark UI(上)深入解读Spark作业的“体检报告”
|
||||
你好,我是吴磊。
|
||||
|
||||
好久不见了,距离专栏结束有不少时间了,不过这期间我一直在关注着同学们的留言。今天我就带着你的期待又来了。
|
||||
|
||||
在性能调优的本质那一讲(第2讲),我们说过性能调优方法论。
|
||||
|
||||
其中的第一条,就是根据专家经验或是运行时的诊断,来定位性能瓶颈。作为Spark内置的运行时监控界面,Spark UI是我们必须要掌握的重要工具。而且随着课程的推进,有不少同学在后台反馈,希望我补充Spark UI的课程内容。
|
||||
|
||||
鉴于以上两点,我用加餐的形式,把Spark UI的内容补充到课程中,希望对你有所帮助。
|
||||
|
||||
在日常的开发工作中,我们总会遇到Spark应用运行失败、或是执行效率未达预期的情况。对于这样的问题,想找到根本原因(Root Cause),就可以通过Spark UI来获取最直接、最直观的线索,在全面地审查Spark应用的同时,迅速定位问题所在。
|
||||
|
||||
如果我们把失败的、或是执行低效的Spark应用看作是“病人”的话,那么Spark UI中关于应用的众多度量指标(Metrics),就是这个病人的“体检报告”。结合多样的Metrics,身为“大夫”的开发者即可结合经验来迅速地定位“病灶”。
|
||||
|
||||
今天这一讲,让我们以小汽车摇号中“倍率计算”的应用(详细内容你可以回顾第30讲)为例,用图解的方式,一步步地去认识Spark UI,看一看它有哪些关键的度量指标,这些指标都是什么含义,又能为开发者提供哪些洞察(Insights)?
|
||||
|
||||
这里需要说明的是,对于Spark UI的介绍与讲解,涉及到大量的图解、代码与指标释义,内容庞杂。因此,为了减轻你的学习负担,我按照Spark UI的入口类型(一级入口、二级入口)把Spark UI拆成了上、下两讲。一级入口比较简单、直接,我们今天这一讲,先来讲解这一部分,二级入口的讲解留到下一讲去展开。
|
||||
|
||||
准备工作
|
||||
|
||||
在正式开始介绍Spark UI之前,我们先来简单交代一下图解案例用到的环境、配置与代码。你可以参考这里给出的细节,去复现“倍率计算”案例Spark UI中的每一个界面,然后再结合今天的讲解,以“看得见、摸得着”的方式,去更加直观、深入地熟悉每一个页面与度量指标。
|
||||
|
||||
当然,如果你手头一时没有合适的执行环境,也不要紧。咱们这一讲的特点,就是图多,后面我特意准备了大量的图片和表格,带你彻底了解Spark UI。
|
||||
|
||||
由于小汽车摇号数据体量不大,因此在计算资源方面,我们的要求并不高,“倍率计算”案例用到的资源如下所示:
|
||||
|
||||
|
||||
|
||||
接下来是代码,在小汽车摇号应用开发那一讲,我们一步步地实现了“倍率计算”的计算逻辑,这里咱们不妨一起回顾一下。
|
||||
|
||||
// HDFS根目录地址
|
||||
val rootPath: String = "hdfs://hostname:9000"
|
||||
|
||||
// 申请者数据
|
||||
val hdfs_path_apply = s"${rootPath}/2011-2019小汽车摇号数据/apply"
|
||||
val applyNumbersDF = spark.read.parquet(hdfs_path_apply)
|
||||
// 创建Cache并触发Cache计算
|
||||
applyNumbersDF.cache.count()
|
||||
|
||||
// 中签者数据
|
||||
val hdfs_path_lucky = s"${rootPath}/2011-2019小汽车摇号数据/lucky"
|
||||
val luckyDogsDF = spark.read.parquet(hdfs_path_lucky)
|
||||
//创建Cache并触发Cache计算
|
||||
luckyDogsDF.cache.count()
|
||||
|
||||
val result05_01 = applyNumbersDF
|
||||
// 按照carNum做关联
|
||||
.join(luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum"), Seq("carNum"), "inner")
|
||||
.groupBy(col("batchNum"),col("carNum"))
|
||||
.agg(count(lit(1)).alias("multiplier"))
|
||||
.groupBy("carNum")
|
||||
// 取最大倍率
|
||||
.agg(max("multiplier").alias("multiplier"))
|
||||
.groupBy("multiplier")
|
||||
// 按照倍率做分组计数
|
||||
.agg(count(lit(1)).alias("cnt"))
|
||||
// 按照倍率排序
|
||||
.orderBy("multiplier")
|
||||
|
||||
result05_01.write.mode("Overwrite").format("csv").save(s"${rootPath}/results/result05_01")
|
||||
|
||||
|
||||
今天我们在此基础上做一点变化,为了展示StorageTab页面内容,我们这里“强行”给applyNumbersDF 和luckyDogsDF这两个DataFrame都加了Cache。我们知道,对于引用数量为1的数据集,实际上是没有必要加Cache的,这一点还需要你注意。
|
||||
|
||||
回顾完代码之后,再来看看配置项。为了让Spark UI能够展示运行中以及执行完毕的应用,我们还需要设置如下配置项并启动History Server。
|
||||
|
||||
|
||||
|
||||
// SPARK_HOME表示Spark安装目录
|
||||
${SPAK_HOME}/sbin/start-history-server.sh
|
||||
|
||||
|
||||
好啦,到此为止,一切准备就绪。接下来,让我们启动spark-shell,并提交“倍率计算”的代码,然后把目光转移到Host1的8080端口,也就是Driver所在节点的8080端口。
|
||||
|
||||
Spark UI 一级入口
|
||||
|
||||
今天的故事,要从Spark UI的入口开始,其实刚才说的8080端口正是Spark UI的入口,我们可以从这里进入Spark UI。
|
||||
|
||||
打开Spark UI,首先映入眼帘的是默认的Jobs页面。Jobs页面记录着应用中涉及的Actions动作,以及与数据读取、移动有关的动作。其中,每一个Action都对应着一个Job,而每一个Job都对应着一个作业。我们一会再去对Jobs页面做展开,现在先把目光集中在Spark UI最上面的导航条,这里罗列着Spark UI所有的一级入口,如下图所示。
|
||||
|
||||
|
||||
|
||||
可以看到,导航条最左侧是Spark Logo以及版本号,后面则依次罗列着6个一级入口,每个入口的功能与作用我整理到了如下的表格中,你可以先整体过一下,后面我们再挨个细讲。
|
||||
|
||||
|
||||
|
||||
形象点说,这6个不同的入口,就像是体检报告中6大类不同的体检项,比如内科、外科、血常规,等等。接下来,让我们依次翻开“体检报告”的每一个大项,去看看“倍率计算”这个家伙的体质如何。
|
||||
|
||||
不过,本着由简入难的原则,咱们并不会按照Spark UI罗列的顺序去查看各个入口,而是按照Executors > Environment > Storage > SQL > Jobs > Stages的顺序,去翻看“体检报告”。
|
||||
|
||||
其中,前3个入口都是详情页,不存在二级入口;而后3个入口都是预览页,都需要访问二级入口,才能获取更加详细的内容。显然,相比预览页,详情页来得更加直接。接下来,让我们从Executors开始,先来了解一下应用的计算负载。
|
||||
|
||||
Executors
|
||||
|
||||
Executors Tab的主要内容如下,主要包含“Summary”和“Executors”两部分。这两部分所记录的度量指标是一致的,其中“Executors”以更细的粒度记录着每一个Executor的详情,而第一部分“Summary”是下面所有Executors度量指标的简单加和。
|
||||
|
||||
|
||||
|
||||
我们一起来看一下,Spark UI都提供了哪些Metrics,来量化每一个Executor的工作负载(Workload)。为了叙述方便,我们以表格的形式说明这些Metrics的含义与作用。
|
||||
|
||||
|
||||
|
||||
不难发现,Executors页面清清楚楚地记录着每一个Executor消耗的数据量,以及它们对CPU、内存与磁盘等硬件资源的消耗。基于这些信息,我们可以轻松判断不同Executors之间是否存在负载不均衡的情况,进而判断应用中是否存在数据倾斜的隐患。
|
||||
|
||||
对于Executors页面中每一个Metrics的具体数值,它们实际上是Tasks执行指标在Executors粒度上的汇总。因此,对于这些Metrics的释义,咱们留到Stages二级入口再去展开,这里暂时不做一一深入。你不妨结合“倍率计算”的应用,去浏览一下不同Metrics的具体数值,先对这些数字有一个直观上的感受。
|
||||
|
||||
实际上,这些具体的数值,并没有什么特别之处,除了RDD Blocks和Complete Tasks这两个Metrics。细看一下这两个指标,你会发现,RDD Blocks是51(总数),而Complete Tasks(总数)是862。
|
||||
|
||||
之前讲RDD并行度的时候,我们说过,RDD并行度就是RDD的分区数量,每个分区对应着一个Task,因此RDD并行度与分区数量、分布式任务数量是一致的。可是,截图中的51与862,显然不在一个量级,这是怎么回事呢?
|
||||
|
||||
这里我先买个关子,把它给你留作思考题,你不妨花些时间,去好好想一想。如果没想清楚也没关系,我们在评论区会继续讨论这个问题。
|
||||
|
||||
Environment
|
||||
|
||||
接下来,我们再来说说Environment。顾名思义,Environment页面记录的是各种各样的环境变量与配置项信息,如下图所示。
|
||||
|
||||
|
||||
|
||||
为了让你抓住主线,我并没有给你展示Environment页面所包含的全部信息,就类别来说,它包含5大类环境信息,为了方便叙述,我把它们罗列到了下面的表格中。
|
||||
|
||||
|
||||
|
||||
显然,这5类信息中,Spark Properties是重点,其中记录着所有在运行时生效的Spark配置项设置。通过Spark Properties,我们可以确认运行时的设置,与我们预期的设置是否一致,从而排除因配置项设置错误而导致的稳定性或是性能问题。
|
||||
|
||||
Storage
|
||||
|
||||
说完Executors与Environment,我们来看一级入口的最后一个详情页:Storage。
|
||||
|
||||
|
||||
|
||||
Storage详情页,记录着每一个分布式缓存(RDD Cache、DataFrame Cache)的细节,包括缓存级别、已缓存的分区数、缓存比例、内存大小与磁盘大小。
|
||||
|
||||
在第16讲,我们介绍过Spark支持的不同缓存级别,它是存储介质(内存、磁盘)、存储形式(对象、序列化字节)与副本数量的排列组合。对于DataFrame来说,默认的级别是单副本的Disk Memory Deserialized,如上图所示,也就是存储介质为内存加磁盘,存储形式为对象的单一副本存储方式。
|
||||
|
||||
|
||||
|
||||
Cached Partitions与Fraction Cached分别记录着数据集成功缓存的分区数量,以及这些缓存的分区占所有分区的比例。当Fraction Cached小于100%的时候,说明分布式数据集并没有完全缓存到内存(或是磁盘),对于这种情况,我们要警惕缓存换入换出可能会带来的性能隐患。
|
||||
|
||||
后面的Size in Memory与Size in Disk,则更加直观地展示了数据集缓存在内存与硬盘中的分布。从上图中可以看到,由于内存受限(3GB/Executor),摇号数据几乎全部被缓存到了磁盘,只有584MB的数据,缓存到了内存中。坦白地说,这样的缓存,对于数据集的重复访问,并没有带来实质上的性能收益。
|
||||
|
||||
基于Storage页面提供的详细信息,我们可以有的放矢地设置与内存有关的配置项,如spark.executor.memory、spark.memory.fraction、spark.memory.storageFraction,从而有针对性对Storage Memory进行调整。
|
||||
|
||||
SQL
|
||||
|
||||
接下来,我们继续说一级入口的SQL页面。当我们的应用包含DataFrame、Dataset或是SQL的时候,Spark UI的SQL页面,就会展示相应的内容,如下图所示。
|
||||
|
||||
|
||||
|
||||
具体来说,一级入口页面,以Actions为单位,记录着每个Action对应的Spark SQL执行计划。我们需要点击“Description”列中的超链接,才能进入到二级页面,去了解每个执行计划的详细信息。这部分内容,我们留到下一讲的二级入口详情页再去展开。
|
||||
|
||||
Jobs
|
||||
|
||||
同理,对于Jobs页面来说,Spark UI也是以Actions为粒度,记录着每个Action对应作业的执行情况。我们想要了解作业详情,也必须通过“Description”页面提供的二级入口链接。你先有个初步认识就好,下一讲我们再去展开。
|
||||
|
||||
-
|
||||
相比SQL页面的3个Actions:save(保存计算结果)、count(统计申请编号)、count(统计中签编号)。
|
||||
|
||||
结合前面的概览页截图你会发现,Jobs页面似乎凭空多出来很多Actions。主要原因在于,在Jobs页面,Spark UI会把数据的读取、访问与移动,也看作是一类“Actions”,比如图中Job Id为0、1、3、4的那些。这几个Job,实际上都是在读取源数据(元数据与数据集本身)。
|
||||
|
||||
至于最后多出来的、Job Id为7的save,你不妨结合最后一行代码,去想想问什么。这里我还是暂时卖个关子,留给你足够的时间去思考,咱们评论区见。
|
||||
|
||||
result05_01.write.mode("Overwrite").format("csv").save(s"${rootPath}/results/result05_01")
|
||||
|
||||
|
||||
Stages
|
||||
|
||||
我们知道,每一个作业,都包含多个阶段,也就是我们常说的Stages。在Stages页面,Spark UI罗列了应用中涉及的所有Stages,这些Stages分属于不同的作业。要想查看哪些Stages隶属于哪个Job,还需要从Jobs的Descriptions二级入口进入查看。
|
||||
|
||||
|
||||
|
||||
Stages页面,更多地是一种预览,要想查看每一个Stage的详情,同样需要从“Description”进入Stage详情页(下一讲详细展开)。
|
||||
|
||||
好啦,到此为止,对于导航条中的不同页面,我们都做了不同程度的展开。简单汇总下来,其中Executors、Environment、Storage是详情页,开发者可以通过这3个页面,迅速地了解集群整体的计算负载、运行环境,以及数据集缓存的详细情况;而SQL、Jobs、Stages,更多地是一种罗列式的展示,想要了解其中的细节,还需要进入到二级入口。
|
||||
|
||||
正如开篇所说,二级入口的讲解,我们留到下一讲再去探讨,敬请期待。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,今天的课程,到这里就讲完啦。今天的内容比较多,涉及的Metrics纷繁而又复杂,仅仅听一遍我的讲解,还远远不够,还需要你结合日常的开发,去多多摸索与体会,加油!
|
||||
|
||||
今天这一讲,我们从简单、直接的一级入口入手,按照“Executors -> Environment -> Storage -> SQL -> Jobs -> Stages”的顺序,先后介绍了一级入口的详情页与概览页。对于这些页面中的内容,我把需要重点掌握的部分,整理到了如下表格,供你随时参考。
|
||||
|
||||
|
||||
|
||||
每课一练
|
||||
|
||||
今天的思考题,我们在课程中已经提过了。一个是在Executors页面,为什么RDD Blocks与Complete Tasks的数量不一致。第二个是,在Jobs页面,为什么最后会多出来一个save Action?
|
||||
|
||||
欢迎你在留言区跟我交流探讨,也欢迎推荐你把这一讲分享给有需要的朋友、同事。
|
||||
|
||||
|
||||
|
||||
|
189
专栏/Spark性能调优实战/SparkUI(下):深入解读Spark作业的“体检报告”.md
Normal file
189
专栏/Spark性能调优实战/SparkUI(下):深入解读Spark作业的“体检报告”.md
Normal file
@ -0,0 +1,189 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
Spark UI(下):深入解读Spark作业的“体检报告”
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们一起梳理了Spark UI的一级入口。其中Executors、Environment、Storage是详情页,开发者可以通过这3个页面,迅速地了解集群整体的计算负载、运行环境,以及数据集缓存的详细情况。而SQL、Jobs、Stages,更多地是一种罗列式的展示,想要了解其中的细节,还需要进入到二级入口。
|
||||
|
||||
沿用之前的比喻,身为“大夫”的开发者想要结合经验,迅速定位“病灶”,离不开各式各样的指标项。而今天要讲的二级入口,相比一级入口,内容会更加丰富、详尽。要想成为一名“临床经验丰富”的老医生,咱们先要做到熟练解读这些度量指标。
|
||||
|
||||
|
||||
|
||||
所谓二级入口,它指的是,通过一次超链接跳转才能访问到的页面。对于SQL、Jobs和Stages这3类入口来说,二级入口往往已经提供了足够的信息,基本覆盖了“体检报告”的全部内容。因此,尽管Spark UI也提供了少量的三级入口(需要两跳才能到达的页面),但是这些隐藏在“犄角旮旯”的三级入口,往往并不需要开发者去特别关注。
|
||||
|
||||
接下来,我们就沿着SQL -> Jobs -> Stages的顺序,依次地去访问它们的二级入口,从而针对全局DAG、作业以及执行阶段,获得更加深入的探索与洞察。
|
||||
|
||||
SQL详情页
|
||||
|
||||
在SQL Tab一级入口,我们看到有3个条目,分别是count(统计申请编号)、count(统计中签编号)和save。前两者的计算过程,都是读取数据源、缓存数据并触发缓存的物化,相对比较简单,因此,我们把目光放在save这个条目上。
|
||||
|
||||
|
||||
|
||||
点击图中的“save at :27”,即可进入到该作业的执行计划页面,如下图所示。
|
||||
|
||||
|
||||
|
||||
为了聚焦重点,这里我们仅截取了部分的执行计划,想要获取完整的执行计划,你可以通过访问这里来获得。为了方便你阅读,这里我手绘出了执行计划的示意图,供你参考,如下图所示。
|
||||
|
||||
|
||||
|
||||
可以看到,“倍率计算”应用的计算过程,非常具有代表性,它涵盖了数据分析场景中大部分的操作,也即过滤、投影、关联、分组聚合和排序。图中红色的部分为Exchange,代表的是Shuffle操作,蓝色的部分为Sort,也就是排序,而绿色的部分是Aggregate,表示的是(局部与全局的)数据聚合。
|
||||
|
||||
无疑,这三部分是硬件资源的主要消费者,同时,对于这3类操作,Spark UI更是提供了详细的Metrics来刻画相应的硬件资源消耗。接下来,咱们就重点研究一下这3类操作的度量指标。
|
||||
|
||||
Exchange
|
||||
|
||||
下图中并列的两个Exchange,对应的是示意图中SortMergeJoin之前的两个Exchange。它们的作用是对申请编码数据与中签编码数据做Shuffle,为数据关联做准备。
|
||||
|
||||
|
||||
|
||||
可以看到,对于每一个Exchange,Spark UI都提供了丰富的Metrics来刻画Shuffle的计算过程。从Shuffle Write到Shuffle Read,从数据量到处理时间,应有尽有。为了方便说明,对于Metrics的解释与释义,我以表格的方式进行了整理,供你随时查阅。
|
||||
|
||||
|
||||
|
||||
结合这份Shuffle的“体检报告”,我们就能以量化的方式,去掌握Shuffle过程的计算细节,从而为调优提供更多的洞察与思路。
|
||||
|
||||
为了让你获得直观感受,我还是举个例子说明。比方说,我们观察到过滤之后的中签编号数据大小不足10MB(7.4MB),这时我们首先会想到,对于这样的大表Join小表,Spark SQL选择了SortMergeJoin策略是不合理的。
|
||||
|
||||
基于这样的判断,我们完全可以让Spark SQL选择BroadcastHashJoin策略来提供更好的执行性能。至于调优的具体方法,想必不用我多说,你也早已心领神会:要么用强制广播,要么利用3.x版本提供的AQE特性。
|
||||
|
||||
你不妨结合本讲开头的代码,去完成SortMergeJoin到BroadcastHashJoin策略转换的调优,期待你在留言区分享你的调优结果。
|
||||
|
||||
Sort
|
||||
|
||||
接下来,我们再来说说Sort。相比Exchange,Sort的度量指标没那么多,不过,他们足以让我们一窥Sort在运行时,对于内存的消耗,如下图所示。
|
||||
|
||||
|
||||
|
||||
按照惯例,我们还是先把这些Metrics整理到表格中,方便后期查看。
|
||||
|
||||
|
||||
|
||||
可以看到,“Peak memory total”和“Spill size total”这两个数值,足以指导我们更有针对性地去设置spark.executor.memory、spark.memory.fraction、spark.memory.storageFraction,从而使得Execution Memory区域得到充分的保障。
|
||||
|
||||
以上图为例,结合18.8GB的峰值消耗,以及12.5GB的磁盘溢出这两条信息,我们可以判断出,当前3GB的Executor Memory是远远不够的。那么我们自然要去调整上面的3个参数,来加速Sort的执行性能。
|
||||
|
||||
Aggregate
|
||||
|
||||
与Sort类似,衡量Aggregate的度量指标,主要记录的也是操作的内存消耗,如图所示。
|
||||
|
||||
|
||||
|
||||
可以看到,对于Aggregate操作,Spark UI也记录着磁盘溢出与峰值消耗,即Spill size和Peak memory total。这两个数值也为内存的调整提供了依据,以上图为例,零溢出与3.2GB的峰值消耗,证明当前3GB的Executor Memory设置,对于Aggregate计算来说是绰绰有余的。
|
||||
|
||||
到此为止,我们分别介绍了Exchange、Sort和Aggregate的度量指标,并结合“倍率计算”的例子,进行了简单的调优分析。
|
||||
|
||||
纵观“倍率计算”完整的DAG,我们会发现它包含了若干个Exchange、Sort、Aggregate以及Filter和Project。结合上述的各类Metrics,对于执行计划的观察与洞见,我们需要以统筹的方式,由点到线、由局部到全局地去进行。
|
||||
|
||||
Jobs详情页
|
||||
|
||||
接下来,我们再来说说Jobs详情页。Jobs详情页非常的简单、直观,它罗列着隶属于当前Job的所有Stages。要想访问每一个Stage的执行细节,我们还需要通过“Description”的超链接做跳转。
|
||||
|
||||
|
||||
|
||||
Stages详情页
|
||||
|
||||
实际上,要访问Stage详情,我们还有另外一种选择,那就是直接从Stages一级入口进入,然后完成跳转。因此,Stage详情页也归类到二级入口。接下来,我们以Id为10的Stage为例,去看一看详情页都记录着哪些关键信息。
|
||||
|
||||
在所有二级入口中,Stage详情页的信息量可以说是最大的。点进Stage详情页,可以看到它主要包含3大类信息,分别是Stage DAG、Event Timeline与Task Metrics。
|
||||
|
||||
其中,Task Metrics又分为“Summary”与“Entry details”两部分,提供不同粒度的信息汇总。而Task Metrics中记录的指标类别,还可以通过“Show Additional Metrics”选项进行扩展。
|
||||
|
||||
|
||||
|
||||
Stage DAG
|
||||
|
||||
接下来,我们沿着“Stage DAG -> Event Timeline -> Task Metrics”的顺序,依次讲讲这些页面所包含的内容。
|
||||
|
||||
首先,我们先来看最简单的Stage DAG。点开蓝色的“DAG Visualization”按钮,我们就能获取到当前Stage的DAG,如下图所示。
|
||||
|
||||
|
||||
|
||||
之所以说Stage DAG简单,是因为咱们在SQL二级入口,已经对DAG做过详细的说明。而Stage DAG仅仅是SQL页面完整DAG的一个子集,毕竟,SQL页面的DAG,针对的是作业(Job)。因此,只要掌握了作业的DAG,自然也就掌握了每一个Stage的DAG。
|
||||
|
||||
Event Timeline
|
||||
|
||||
与“DAG Visualization”并列,在“Summary Metrics”之上,有一个“Event Timeline”按钮,点开它,我们可以得到如下图所示的可视化信息。
|
||||
|
||||
|
||||
|
||||
Event Timeline,记录着分布式任务调度与执行的过程中,不同计算环节主要的时间花销。图中的每一个条带,都代表着一个分布式任务,条带由不同的颜色构成。其中不同颜色的矩形,代表不同环节的计算时间。
|
||||
|
||||
为了方便叙述,我还是用表格形式帮你梳理了这些环节的含义与作用,你可以保存以后随时查看。
|
||||
|
||||
|
||||
|
||||
理想情况下,条带的大部分应该都是绿色的(如图中所示),也就是任务的时间消耗,大部分都是执行时间。不过,实际情况并不总是如此,比如,有些时候,蓝色的部分占比较多,或是橙色的部分占比较大。
|
||||
|
||||
在这些情况下,我们就可以结合Event Timeline,来判断作业是否存在调度开销过大、或是Shuffle负载过重的问题,从而有针对性地对不同环节做调优。
|
||||
|
||||
比方说,如果条带中深蓝的部分(Scheduler Delay)很多,那就说明任务的调度开销很重。这个时候,我们就需要参考“三足鼎立”的调优技巧,去相应地调整CPU、内存与并行度,从而减低任务的调度开销。
|
||||
|
||||
再比如,如果条带中黄色(Shuffle Write Time)与橙色(Shuffle Read Time)的面积较大,就说明任务的Shuffle负载很重,这个时候,我们就需要考虑,有没有可能通过利用Broadcast Join来消除Shuffle,从而缓解任务的Shuffle负担。
|
||||
|
||||
Task Metrics
|
||||
|
||||
说完Stage DAG与Event Timeline,最后,我们再来说一说Stage详情页的重头戏:Task Metrics。
|
||||
|
||||
之所以说它是重头戏,在于Task Metrics以不同的粒度,提供了详尽的量化指标。其中,“Tasks”以Task为粒度,记录着每一个分布式任务的执行细节,而“Summary Metrics”则是对于所有Tasks执行细节的统计汇总。我们先来看看粗粒度的“Summary Metrics”,然后再去展开细粒度的“Tasks”。
|
||||
|
||||
Summary Metrics
|
||||
|
||||
首先,我们点开“Show Additional Metrics”按钮,勾选“Select All”,让所有的度量指标都生效,如下图所示。这么做的目的,在于获取最详尽的Task执行信息。
|
||||
|
||||
|
||||
|
||||
可以看到,“Select All”生效之后,Spark UI打印出了所有的执行细节。老规矩,为了方便叙述,我还是把这些Metrics整理到表格中,方便你随时查阅。其中,Task Deserialization Time、Result Serialization Time、Getting Result Time、Scheduler Delay与刚刚表格中的含义相同,不再赘述,这里我们仅整理新出现的Task Metrics。
|
||||
|
||||
|
||||
|
||||
对于这些详尽的Task Metrics,难能可贵地,Spark UI以最大最小(max、min)以及分位点(25%分位、50%分位、75%分位)的方式,提供了不同Metrics的统计分布。这一点非常重要,原因在于,这些Metrics的统计分布,可以让我们非常清晰地量化任务的负载分布。
|
||||
|
||||
换句话说,根据不同Metrics的统计分布信息,我们就可以轻而易举地判定,当前作业的不同任务之间,是相对均衡,还是存在严重的倾斜。如果判定计算负载存在倾斜,那么我们就要利用“手工加盐”或是AQE的自动倾斜处理,去消除任务之间的不均衡,从而改善作业性能。
|
||||
|
||||
在上面的表格中,有一半的Metrics是与Shuffle直接相关的,比如Shuffle Read Size / Records,Shuffle Remote Reads,等等。
|
||||
|
||||
这些Metrics我们在介绍SQL详情的时候,已经详细说过了。另外,Duration、GC Time、以及Peak Execution Memory,这些Metrics的含义,要么已经讲过,要么过于简单、无需解释。因此,对于这3个指标,咱们也不再多着笔墨。
|
||||
|
||||
这里特别值得你关注的,是Spill(Memory)和Spill(Disk)这两个指标。Spill,也即溢出数据,它指的是因内存数据结构(PartitionedPairBuffer、AppendOnlyMap,等等)空间受限,而腾挪出去的数据。Spill(Memory)表示的是,这部分数据在内存中的存储大小,而Spill(Disk)表示的是,这些数据在磁盘中的大小。
|
||||
|
||||
因此,用Spill(Memory)除以Spill(Disk),就可以得到“数据膨胀系数”的近似值,我们把它记为Explosion ratio。有了Explosion ratio,对于一份存储在磁盘中的数据,我们就可以估算它在内存中的存储大小,从而准确地把握数据的内存消耗。
|
||||
|
||||
Tasks
|
||||
|
||||
介绍完粗粒度的Summary Metrics,接下来,我们再来说说细粒度的“Tasks”。实际上,Tasks的不少指标,与Summary是高度重合的,如下图所示。同理,这些重合的Metrics,咱们不再赘述,你可以参考Summary的部分,来理解这些Metrics。唯一的区别,就是这些指标是针对每一个Task进行度量的。
|
||||
|
||||
|
||||
|
||||
按照惯例,咱们还是把Tasks中那些新出现的指标,整理到表格中,以备后续查看。
|
||||
|
||||
|
||||
|
||||
可以看到,新指标并不多,这里最值得关注的,是Locality level,也就是本地性级别。在调度系统中,我们讲过,每个Task都有自己的本地性倾向。结合本地性倾向,调度系统会把Tasks调度到合适的Executors或是计算节点,尽可能保证“数据不动、代码动”。
|
||||
|
||||
Logs与Errors属于Spark UI的三级入口,它们是Tasks的执行日志,详细记录了Tasks在执行过程中的运行时状态。一般来说,我们不需要深入到三级入口去进行Debug。Errors列提供的报错信息,往往足以让我们讯速地定位问题所在。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,今天的课程,到这里就讲完啦。今天这一讲,我们分别学习了二级入口的SQL、Jobs与Stages。每个二级入口的内容都很丰富,提前知道它们所涵盖的信息,对我们寻找、启发与探索性能调优的思路非常有帮助。
|
||||
|
||||
到此为止,关于Spark UI的全部内容就讲完啦。Spark UI涉及的Metrics纷繁而又复杂,仅仅跟着我去用“肉眼”学习一遍只是第一步,之后还需要你结合日常的开发,去多多摸索与体会,加油!
|
||||
|
||||
也欢迎你把Spark UI使用的心得体会,分享到课后的评论区,我们一起讨论,共同进步!
|
||||
|
||||
最后的最后,还是想提醒你,由于我们的应用是通过spark-shell提交的,因此节点8080端口的Spark UI会一直展示应用的“体检报告”。在我们退出spark-shell之后,节点8080端口的内存也随即消失(404 Page not found)。
|
||||
|
||||
要想再次查看应用的“体检报告”,需要移步至节点的18080端口,这里是Spark History Server的领地,它收集了所有(已执行完毕)应用的“体检报告”,并同样使用Spark UI的形式进行展示,切记切记。
|
||||
|
||||
每课一练
|
||||
|
||||
今天的思考题,需要你发散思维。学习过Spark UI之后,请你说一说,都可以通过哪些途径,来定位数据倾斜问题?
|
||||
|
||||
欢迎你在留言区和我交流探讨,也欢迎推荐你把这一讲分享给有需要的朋友、同事。
|
||||
|
||||
|
||||
|
||||
|
17
专栏/Spark性能调优实战/期末考试“Spark性能调优”100分试卷等你来挑战!.md
Normal file
17
专栏/Spark性能调优实战/期末考试“Spark性能调优”100分试卷等你来挑战!.md
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
期末考试 “Spark性能调优”100分试卷等你来挑战!
|
||||
你好,我是吴磊。
|
||||
|
||||
《Spark性能调优实战》这门课程到这里就正式完结了。在这个过程中,我看到了很多同学的留言和反馈,非常感谢你一直以来的认真学习和支持!
|
||||
|
||||
为了帮助你检验自己的学习效果,我特别给你准备了一套结课测试题。这套测试题共有 20 道题目,包括10道单选题和10道多选题,满分 100 分,核心考点都出自前面讲到的所有重要知识,希望可以帮助你进行一场自测。快点击下面按钮,开始测试吧!
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
85
专栏/Spark性能调优实战/结束语在时间面前,做一个笃定学习的人.md
Normal file
85
专栏/Spark性能调优实战/结束语在时间面前,做一个笃定学习的人.md
Normal file
@ -0,0 +1,85 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 在时间面前,做一个笃定学习的人
|
||||
你好,我是吴磊。
|
||||
|
||||
时间犹如白驹过隙,不知不觉,就到了要和你说再见的时候。当编辑对我说:“老师,这周要把结束语赶出来哟”,我忽然愣住了,觉得有些恍惚和不真实,并没有像往常那样脱口而出:“好嘞,没问题!”因为我似乎已经习惯了赶稿子,也习惯了回答评论区的问题,习惯了和大家互动,更习惯了在群里插科打诨……
|
||||
|
||||
但是,无论再怎么习惯,我们的专栏也在今天正式结束了。在这里,我想借着这最后的机会,由衷地感谢屏幕前的你、感谢在听音频的你。感谢你一直以来的陪伴和督促,感谢你参与评论区的讨论,感谢你及时地纠正专栏中的错误,更感谢你对于专栏的包容和理解。
|
||||
|
||||
同时,我也希望在这个专栏中,我们不仅能共同探讨快速、高效掌握Spark性能的方法,还能把同样的学习技巧迁移到其他的领域。毕竟,高效的学习效率,永远可以让我们快人一步。
|
||||
|
||||
我们到底该如何学习?
|
||||
|
||||
说起学习这件事,我们听到最多的,就是“学习是一件反人类的事情”,或是“学习的过程就是成长,而成长本来就是痛苦的”。似乎一提到学习,我们就要随时准备“头悬梁,锥刺股”。那真是这样吗?
|
||||
|
||||
坦白地说,关于学习的“痛苦学说”,我不是很认同。原因很简单,我们有很多的方法与技巧可以让学习这件事变得有趣,而且事半功倍。那么,对我来说,3个提升学习效率最有效的方法,分别是生产导向的思维方式、“235”的学习原则和“生活化联想”的学习技巧。
|
||||
|
||||
生产者思维
|
||||
|
||||
我们先来说说生产者思维。如果把形形色色的信息、知识、原理、理论看作是商品的话,那么毫无疑问,热爱学习的我们就是这些“商品”的消费者,我们花费大量的时间、精力和注意力,尝试从这些商品中获取价值。
|
||||
|
||||
然而,商品的消费是单向而又被动的,这就好比你从收音机或是电视中被动地收听、收看那些提前烧制好的节目。无论节目的设计与制作多么精良,作为消费者,我们能够消化和吸收的程度都是有限的。
|
||||
|
||||
这个时候,我们可以采用以产出内容为导向的学习方式,它会比被动式的学习方式效率更高。就拿技术博客来说,输出内容的过程,其实是帮你发现漏洞和不足的过程,写着写着发现卡住了、进行不下去了,往往意味着这部分知识点或是原理你掌握得不够牢固,需要回炉另造。因此,带着目的、有针对性地学习,往往会事半功倍。以输出为导向去获取输入,就是我们常说的生产者思维。
|
||||
|
||||
不过你可能会说:“就算生产者思维确实能够帮助我提升学习效率,可是我真的不知道,生产这件事该从哪里下手”。如果你已经很久没有动过笔了,那么不妨从做笔记开始。做笔记至少有两点收益:一来,它可以强化记忆,也就是俗话常说的“好记性不如烂笔头”;二来,笔记本身就是一种产品形态,或者说是产品的初级形态,它就是最原始的生产资料。
|
||||
|
||||
在我们有了生产资料的原始积累之后,生产的过程就会变得很轻松,只要对它们稍作加工,比如调整上下文排列、理顺逻辑关系、变换呈现形式,我们就可以“换着花样”地交付各式各样的产品,比如技术博客、公众号图文,甚至是极客时间上的专栏课程等等。这些产品形态,依然没有逃脱笔记的范畴,它们都是笔记的“变形”。
|
||||
|
||||
因此,关于生产这件事,如果你暂时还没有思绪,那么我强烈推荐你从做笔记下手。古人云:“读书破万卷,下笔如有神”。对于生产,我们也可以说“笔记做到家,输出如有神”。
|
||||
|
||||
“235”原则
|
||||
|
||||
“235”原则指的是要想充分掌握一项技能或是本领,理论学习占两成,交流讨论占三成,动手实践占五成。按照“235”的指导原则,讨论和实践要比理论学习更重要。
|
||||
|
||||
实践对于学习的贡献不言自明,“实践出真知”“实践是检验真理的唯一标准”,这些老生常谈的道理强调的都是实践的重要性。不过,在这三个学习要素当中,要说投入产出比最高的,当属交流讨论。为什么这么说呢?这就要说到学习这件事情的本质了。你可能会说:“学习自然是获取知识、习得技能、掌握本领。”但在我看来,学习的本质是一个不断做熵减、消除不确定性的过程。
|
||||
|
||||
熵减听上去过于玄乎,我们来举个例子。假设有两个房间,每个房间都有5个男生和5个女生,男女比例都是1:1,如果按照性别来统计的话,那么这两个房间的信息熵都是1。然后,我们让第一个房间中所有的女生都移步到第二个房间,同时让第二个房间里所有的男生都移步到第一个房间。交换完成之后,每个房间的信息熵都降低至0。这就是一个熵减的过程,它消除了房间内“性别”这个属性的不确定性。
|
||||
|
||||
|
||||
|
||||
学习的过程也类似。对于一项新技术,它是什么?谁创造了它?它解决了什么问题?它的组成要素都有哪些?它是如何工作的?它的适用场景都有哪些?每一个问题都是一个未知变量,所有这些未知变量交织在一起,让这项技术的熵无限趋近于1。而学习的过程,正是以蚕食鲸吞的方式,将一个个未知变量转化为已知变量的过程。在这个过程中,新技术的熵逐级递减,不断趋近于零。
|
||||
|
||||
在学习的过程中,不同的方法对熵减的贡献有所不同。交流讨论对于熵减的贡献是指数级的,它是最高效的熵减途径。而这,也是我为什么一直鼓励你参与课后讨论。通过参与讨论,你可以把一些工作中未曾遇到的问题和解决办法,内化为自己的一部分,在拓展认知边界的同时,加深对于技术本身的理解。
|
||||
|
||||
|
||||
|
||||
而另外两种途径,也就是理论学习和动手实践,它们对于熵减的贡献都是线性而平缓的。你可能会说:“动手实操才应该是贡献最高的呀!”实践固然重要,但你不妨回想一下过往的工作经历,对于某一项技术成百上千条的Code Paths来说,你真正触达到的,总共能有几条呢?
|
||||
|
||||
无论我们多么投入、多么勤奋,我们的认知边界总是有限的。而要拓展认知边界,只有通过不断地分享、交流和讨论,才能够做到,这也就是所谓的“他山之石,可以攻玉”。
|
||||
|
||||
生活化联想
|
||||
|
||||
生活化联想指的是把技术中的原理和知识点映射到生活中来,也就是运用生活化的类比、拟人、比喻等手法,让那些枯燥的原理和知识与生活产生连接。利用“生活化联想”的学习技巧,我们能够很快地吸收新知识。更重要的是,这个技巧可以延长我们的记忆曲线,让我们记得更久。
|
||||
|
||||
《人月神话》的作者Frederick Brooks曾经说过:“任何一种软件架构的设计,都逃不脱人类社会的组织结构”。同理,对于层出不穷的技术、琳琅满目的知识点,我们也都能在生活中找到它们的投影。因此,在日常的学习中,我们就可以放心大胆地大开脑洞,勇敢地去尝试把你的所见所学,与生活中有意思的场景建立连接。一旦你开始这么做了,你就会发现,相比同龄人,上帝似乎为你多开了一扇门,那扇门的后面就是绚烂多彩而又有趣的新世界。
|
||||
|
||||
而这,也是我为什么会不遗余力、绞尽脑汁、挖空心思地,尝试用一个又一个类比、一个又一个故事,去为你讲解Spark核心概念、关键知识点以及它的工作原理。我的初衷朴素而又简单,就是想让你既能轻松、迅速地消化、吸收,又能把那些枯燥的理论知识记得更牢固、更持久。
|
||||
|
||||
建立技术与生活这二者之间的连接,自然少不了要消耗精力和能量,去激活大脑中更多的神经元。但是,你千万别觉得这个过程很痛苦,相反,它其实会让你有一种莫名的期待与兴奋。因为你知道,一旦连接建立完成,那么这条连接两端的东西,技术也好、生活也罢,就都内化成为你的一部分了。
|
||||
|
||||
有了生产者思维,遵循“235”原则,同时结合“生活化联想”的学习技巧,我相信,你的学习效率一定会与日俱增。
|
||||
|
||||
不见不散
|
||||
|
||||
最后,我还是想发自内心地和你说一声:“谢谢!”谢谢你陪我走完了这趟Spark调优之旅。对我来说,陪伴即是最大的认可。
|
||||
|
||||
你知道,我既不是Spark Committer,也不是Spark Contributor,只不过是一个对Spark有着无限热情和多年实战经验的开发老兵。我能做的也 仅仅是把我的所学、所见、经验、积累、思路与技巧,像榨汁一样从我的脑海中一滴不剩、毫无保留地压榨出来,再用专栏的形式呈现给你。
|
||||
|
||||
才浅学疏,我个人的能力与视野是极其有限的,专栏的内容难免有所疏漏。我真的觉得,就这个专栏来说,我不过是个发起者,就像是一个GitHub项目的创建者,而专栏的勘误、丰富、打磨与完善,实际上仰仗的是每一个像你一样参与订阅的开发者。
|
||||
|
||||
因此,尽管我们的专栏结束了,但是,我们学习并不会止步于此。在接下来的一段时间,我会在GitHub中创建一个名为potatoes,也就是“土豆”的项目,把专栏中涉及的代码、数据、结果,以及常见问题等内容汇总到这个项目中去。
|
||||
|
||||
不仅如此,结合你的需要,我还会持续不断地向其中添加诸如笔试面试题、工作机会、职业发展等内容,把potatoes项目打造成我们共有的Spark私塾。通过这个私塾,我们不仅能进一步深入学习Spark,还可以建立更多的连接、拓展人脉,并触达更多的机会与可能。项目的地址是:https://github.com/wulei-bj-cn/potatoes.git,我在那里等你,不见不散!
|
||||
|
||||
天行健,君子以自强不息。持续学习,与君共勉。
|
||||
|
||||
最后的最后,我还为你准备了一份毕业调查问卷,题目不多,希望你能花两分钟的时间填一下。一起走过了这些时间,期待听到你对我和这个课程的反馈和建议!
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user