first commit

This commit is contained in:
张乾
2024-10-16 06:37:41 +08:00
parent 633f45ea20
commit 206fad82a2
3590 changed files with 680090 additions and 0 deletions

View 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将DatabricksSpark云原生商业版本提名为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职业生涯吧

View File

@ -0,0 +1,155 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 性能调优的必要性Spark本身就很快为啥还需要我调优
你好,我是吴磊。
在日常的开发工作中我发现有个现象很普遍。很多开发者都认为Spark的执行性能已经非常强了实际工作中只要按部就班地实现业务功能就可以了没有必要进行性能调优。
你是不是也这么认为呢确实Spark的核心竞争力就是它的执行性能这主要得益于Spark基于内存计算的运行模式和钨丝计划的锦上添花以及Spark SQL上的专注与发力。
但是,真如大家所说,开发者只要把业务逻辑实现了就万事大吉了吗?这样,咱们先不急于得出结论,你先跟着我一起看两个日常开发中常见的例子,最后我们再来回答这个问题。
在数据应用场景中ETLExtract 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 JoinHash JoinBroadcast Join等中性能最差的一种而且这种Join方式没有任何优化空间但factDF与pairDF的数据关联只需要扫描一次全量数据仅这一项优势在执行效率上就可以吊打第一份代码实现
小结
今天我们分析了两个案例这两个案例都来自数据应用的ETL场景第一个案例讲的是在函数被频繁调用的情况下函数里面一个简单变量所引入的性能开销被成倍地放大第二个例子讲的是不恰当的实现方式导致海量数据被反复地扫描成百上千次
通过对这两个案例进行分析和探讨我们发现对于Spark的应用开发绝不仅仅是完成业务功能实现就高枕无忧了Spark天生的执行效率再高也需要你针对具体的应用场景和运行环境进行性能调优
而性能调优的收益显而易见一来可以节约成本尤其是按需付费的云上成本更短的执行时间意味着更少的花销二来可以提升开发的迭代效率尤其是对于从事数据分析数据科学机器学习的同学来说更高的执行效率可以更快地获取数据洞察更快地找到模型收敛的最优解因此你看性能调优不是一件锦上添花的事情而是开发者必须要掌握的一项傍身技能
那么对于Spark的性能调优你准备好了吗生活不止眼前的苟且让我们来一场说走就走的性能调优之旅吧来吧快上车扶稳坐好系好安全带咱们准备发车了
每日一练
日常工作中你还遇到过哪些功能实现一致但性能大相径庭的案例吗
我们今天讲的第二个案例中的正例代码你觉得还有可能进一步优化吗
期待在留言区看到你分享也欢迎把你对开发案例的思考写下来我们下节课见

View File

@ -0,0 +1,128 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 性能调优的本质:调优的手段五花八门,该从哪里入手?
你好,我是吴磊。
上节课我们探讨了性能调优的必要性结论是尽管Spark自身运行高效但作为开发者我们仍然需要对应用进行性能调优。
那么问题来了性能调优该怎么做呢面对成百上千行应用代码、近百个Spark配置项我们该从哪里入手呢我认为要想弄清性能调优怎么入手必须先得搞明白性能调优的本质是什么。
所以今天这节课,咱们就从一个先入为主的调优反例入手,带你一起探讨并归纳性能调优的本质是什么,最终帮你建立起系统化的性能调优方法论。
先入为主的反例
在典型的ETL场景中我们经常需要对数据进行各式各样的转换有的时候因为业务需求太复杂我们往往还需要自定义UDFUser 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配置项的调优手段和方法
随着性能瓶颈的此消彼长,动态灵活地在不同层面之间切换调优方法;
让性能调优的过程收敛于不同硬件资源在运行时达到一种平衡、无瓶颈的状态。
每日一练
你还遇到过哪些“照本宣科”的调优手段?
你认为,对于性能调优的收敛状态,即硬件资源彼此之间平衡、无瓶颈的状态,需要量化吗?如何量化呢?
关于性能调优,你还有哪些看法?欢迎在评论区留言,我们下节课见!

View 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 所需的“数据源”术语叫做父依赖或父RDDcompute方法则封装了从父 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效率又在哪些环境中不起作用呢为什么
期待在留言区看到你的思考,也欢迎你分享工作中遇到过的“单机思维模式”,我们下节课见!

View 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和ReduceMap抽象允许开发者通过实现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内的所有算子会融合为一个函数。你知道这一步是怎么做到的吗
期待在留言区看到你的思考和答案,如果对内存计算还有很多困惑,也欢迎你写在留言区,我们下一讲见!

View 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得到的partFuncpartFunc是形参为兴趣、结果为索引的普通标量函数。
那么,高阶函数真有这么神奇吗?其实,性能的提升并不是高阶函数的功劳,而是调度系统在起作用。
Spark的调度系统是如何工作的
Spark调度系统的核心职责是先将用户构建的DAG转化为分布式任务结合分布式集群资源的可用性基于调度规则依序把分布式任务分发到执行器。这个过程听上去就够复杂的了为了方便你理解我们还是先来讲一个小故事。
土豆工坊流水线升级
在学完了内存计算的第二层含义之后,土豆工坊的老板决定对土豆加工流水线做升级,来提高工坊的生产效率和灵活性。
这里我们先对内存计算的第二层含义做个简单地回顾它指的是同一Stage中的所有操作会被捏合为一个函数这个函数一次性会被地应用到输入数据上并且一次性地产生计算结果。
升级之前的土豆加工流程DAG被切分为3个执行阶段Stage它们分别是Stage 0、Stage 1、Stage 2。其中Stage 0产出即食薯片Stage 1分发调味品Stage 2则产出不同尺寸、不同风味的薯片。我们重点关注Stage 0Stage 0有3个加工环节分别是清洗、切片和烘焙。这3个环节需要3种不同的设备即清洗机、切片机和烤箱。
工坊有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和MesosSchedulerBackend提供了对应的实现类。在运行时Spark根据用户提供的MasterURL来决定实例化哪种实现类的对象。MasterURL就是你通过各种方式指定的资源管理器master spark://ip:hostStandalone 模式master yarnYARN 模式)。
对于集群中可用的计算资源SchedulerBackend会用一个叫做ExecutorDataMap的数据结构来记录每一个计算节点中Executors的资源状态。ExecutorDataMap是一种HashMap它的Key是标记Executor的字符串Value是一种叫做ExecutorData的数据结构ExecutorData用于封装Executor的资源状态如RPC地址、主机地址、可用CPU核数和满配CPU核数等等它相当于是对Executor做的“资源画像”。
总的来说对内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根据本地性级别遴选出待计算任务之后先对这些任务进行序列化然后交给SchedulerBackendSchedulerBackend根据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本地性级别成立吗你认为哪些情况下成立哪些情况下不成立
期待在留言区看到你的思考和答案如果你的朋友也正急需搞清楚调度系统的工作原理也欢迎你把这一讲转发给他我们下一讲见

View 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 为BlockIdValue 是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
相比MemoryStoreDiskStore就相对简单很多因为它并不需要那么多的中间数据结构才能完成数据的存取。DiskStore中数据的存取本质上就是字节序列与磁盘文件之间的转换它通过putBytes方法把字节序列存入磁盘文件再通过getBytes方法将文件内容转换为数据块。
不过要想完成两者之间的转换像数据块与文件的对应关系、文件路径等等这些元数据是必不可少的。MemoryStore采用链式哈希字典来维护类似的元数据DiskStore这个狡猾的家伙并没有亲自维护这些元数据而是请了DiskBlockManager这个给力的帮手。
DiskBlockManager的主要职责就是记录逻辑数据块Block与磁盘文件系统中物理文件的对应关系每个Block都对应一个磁盘文件。同理每个磁盘文件都有一个与之对应的Block ID这就好比货架上的每一件货物都有唯一的 ID 标识。
DiskBlockManager在初始化的时候首先根据配置项spark.local.dir在磁盘的相应位置创建文件目录。然后在spark.local.dir指定的所有目录下分别创建子目录子目录的个数由配置项spark.diskStore.subDirectories控制它默认是64。所有这些目录均用于存储通过DiskStore进行物化的数据文件如RDD缓存文件、Shuffle中间结果文件等。
接下来我们再以Shuffle中间文件为例来说说DiskStore与DiskBlockManager的交互过程。
Spark默认采用SortShuffleManager来管理Stages间的数据分发在Shuffle write过程中有3类结果文件temp_shuffle_XXX、shuffle_XXX.data和shuffle_XXX.index。Data文件存储分区数据它是由temp文件合并而来的而index文件记录data文件内不同分区的偏移地址。Shuffle中间文件具体指的就是data文件和index文件temp文件作为暂存盘文件最终会被删除。
在Shuffle write的不同阶段Shuffle manager通过BlockManager调用DiskStore的putBytes方法将数据块写入文件。文件由DiskBlockManager创建文件名就是putBytes方法中的Block ID这些文件会以“temp_shuffle”或“shuffle”开头保存在spark.local.dir目录下的子目录里。
在Shuffle read阶段Shuffle manager再次通过BlockManager调用DiskStore的getBytes方法读取data文件和index文件将文件内容转化为数据块最终这些数据块会通过网络分发到Reducer端进行聚合计算。
小结
掌握存储系统是我们进行Spark性能调优的关键一步我们可以分为三步来掌握。
第一步我们要明确存储系统的服务对象分别是RDD缓存、Shuffle和广播变量。
RDD缓存一些计算成本和访问频率较高的RDD可以以缓存的形式物化到内存或磁盘中。这样一来既可以避免DAG频繁回溯的计算开销也能有效提升端到端的执行性能
ShuffleShuffle中间文件的位置信息都是由Spark存储系统保存并维护的没有存储系统Shuffle是玩不转的
广播变量利用存储系统广播变量可以在Executors进程范畴内保存全量数据让任务以Process local的本地性级别来共享广播变量中携带的全量数据。
第二步我们要搞清楚存储系统的两个重要组件MemoryStore和DiskStore。其中MemoryStore用来管理数据在内存中的存取DiskStore用来管理数据在磁盘中的存取。
对于存储系统的3个服务对象来说广播变量由MemoryStore管理Shuffle中间文件的落盘和访问要经由DiskStore而RDD缓存因为会同时支持内存缓存和磁盘缓存两种模式所以两种组件都有可能用到。
最后我们要理解MemoryStore和DiskStore的工作原理。
MemoryStore支持对象值和字节数组统一采用MemoryEntry数据抽象对它们进行封装。对象值和字节数组二者之间存在着一种博弈关系所谓的“以空间换时间”和“以时间换空间”两者的取舍还要看具体的应用场景。
DiskStore则利用DiskBlockManager维护的数据块与磁盘文件的对应关系来完成字节序列与磁盘文件之间的转换。
每日一练
结合RDD数据存储到MemoryStore的过程你能推演出通过MemoryStore通过getValues/getBytes方法去访问RDD缓存内容的过程吗
参考RDD缓存存储的过程你能推演出广播变量存入MemoryStore的流程吗
期待在留言区看到你的思考和讨论,我们下一讲见!

View 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 CollectionGC机制将对象清除并真正释放内存。
在这样的管理模式下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区域可能被用于任务执行如ShuffleExecution Memory区域也有可能存储RDD缓存。
但是,我们都知道,执行任务相比缓存任务,在内存抢占上有着更高的优先级。那你有没有想过这是为什么呢?接下来,就让我们带着“打破砂锅问到底”的精神,去探索其中更深层次的原因。
首先执行任务主要分为两类一类是Shuffle Map阶段的数据转换、映射、排序、聚合、归并等操作另一类是Shuffle Reduce阶段的数据排序和聚合操作。它们所涉及的数据结构都需要消耗执行内存。
我们可以先假设执行任务与缓存任务在内存抢占上遵循“公正、公平和公开”的三原则。也就是说不论谁抢占了对方的内存当对方有需要时都会立即释放。比如说刚开始双方的预设比例是五五开但因为缓存任务在应用中比较靠后的位置所以执行任务先占据了80%的内存空间当缓存任务追赶上来之后执行任务就需要释放30%的内存空间还给缓存任务。
这种情况下会发生什么假设集群范围内总共有80个CPU也就是集群在任意时刻的并行计算能力是80个分布式任务。在抢占了80%内存的情况下80个CPU可以充分利用每个CPU的计算负载都是比较饱满的计算完一个任务再去计算下一个任务。
但是由于有30%的内存要归还给缓存任务这意味着有30个并行的执行任务没有内存可用。也就是说会有30个CPU一直处在I/O wait的状态没法干活宝贵的CPU计算资源就这么白白地浪费掉了简直是暴殄天物。
因此相比于缓存任务执行任务的抢占优先级一定要更高。说了这么多我们为什么要弄清楚其中的原因呢我认为只有弄清楚抢占优先级的背后逻辑我们才能理解为什么要同时调节CPU和内存的相关配置也才有可能做到不同硬件资源之间的协同与平衡这也是我们进行性能调优要达到的最终效果。
不过即使执行任务的抢占优先级更高但它们在抢占内存的时候一定也要遵循某些规则。那么这些规则具体是什么呢下面咱们就接着以地主招租的故事为例来说说Execution memory和Storage memory之间有哪些有趣的规则。
地主招租(下):租地协议
黄四郎招租的告示贴出去没多久,村子里就有两个年富力强的小伙子来租种田地。一个叫黄小乙,是黄四郎的远房亲戚,前不久来投奔黄四郎。另一个叫张麻子,虽是八辈贫农,小日子过得也算是蒸蒸日上。张麻子打算把田地租过来种些小麦、玉米这样的庄稼。黄小乙就不这么想,这小子挺有商业头脑,他把田地租过来准备种棉花、咖啡这类经济作物。
两个人摩拳擦掌都想干出一番事业,恨不得把黄四郎的地全都包圆!地不愁租,黄四郎自然是满心欢喜,但烦恼也接踵而至:“既要照顾小乙这孩子,又不能打击麻子的积极性,得想个万全之策”。
于是,他眼珠一转,计上心来:“按理说呢,咱们丈量土地之后,应该在你们中间划一道实线,好区分田地的归属权。不过呢,毕竟麻子你是本村的,小乙远道而来,远来即是客嘛!咱们对小乙还是得多少照顾着点”。张麻子心生不悦:“怎么照顾?”
黄四郎接着说:“很简单,把实线改为虚线,多劳者多得。原本呢,你们应该在分界线划定的那片田地里各自劳作。不过呢,你们二人的进度各不相同嘛,所以,勤奋的人,自己的田地种满了之后,可以跨过分界线,去占用对方还在空着的田地。”
黄小乙不解地问:“四舅,这不是比谁种得快吗?也没对我特殊照顾啊!”张麻子眉间也拧了个疙瘩:“如果种得慢的人后来居上,想要把被占的田地收回去,到时候该怎么办呢?”
黄四郎得意道:“刚才说了,咱们多多照顾小乙。所以如果麻子勤快、干活也快,先占了小乙的地,种上了小麦、玉米,小乙后来居上,想要收回自己的地,那么没说的,麻子得把多占的地让出来。不管庄稼熟没熟,麻子都得把地铲平,还给人家小乙种棉花、咖啡”。
黄四郎偷眼看了看两人的反应,继续说:“反过来,如果小乙更勤快,先占了麻子的地,麻子后来居上,想要收回,这个时候,咱们就得多照顾照顾小乙。小乙有权继续占用麻子的地,直到地上种的棉花、咖啡都丰收了,再把多占的地让出来。你们二位看怎么样?”
黄小乙听了大喜。张麻子虽然心里不爽,但也清楚黄四郎和黄小乙之间的亲戚关系,也不好再多说什么,心想:“反正我勤快些,先把地种满也就是了”。于是,三方击掌为誓,就此达成协议。
好啦地主招租的故事到这里就讲完了。不难发现黄小乙的地类比的是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的具体大小吗
期待在留言区看到你的思考和答案,我们下一讲见!

View 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。AQEAdaptive 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个原则外你觉得是否还有其他原则需要开发者特别留意
期待在留言区看到你的思考和答案,我们下一讲见!

View 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 APISpark在运行时都会使用Spark SQL做统一优化。因此我们需要梳理出一类配置项去充分利用Spark SQL的先天性能优势。
我们一再强调硬件资源的平衡才是性能调优的关键所以今天这一讲我们就先从硬件资源类入手去汇总应该设置的配置项。在这个过程中我会带你搞清楚这些配置项的定义与作用是什么以及它们的设置能解决哪些问题让你为资源平衡打下基础。下一讲我们再来讲Shuffle类和Spark SQL大类。
哪些配置项与CPU设置有关
首先我们先来说说与CPU有关的配置项主要包括spark.cores.max、spark.executor.cores和spark.task.cpus这三个参数。它们分别从集群、Executor和计算任务这三个不同的粒度指定了用于计算的CPU个数。开发者通过它们就可以明确有多少CPU资源被划拨给Spark用于分布式计算。
为了充分利用划拨给Spark集群的每一颗CPU准确地说是每一个CPU核CPU Core你需要设置与之匹配的并行度并行度用spark.default.parallelism和spark.sql.shuffle.partitions这两个参数设置。对于没有明确分区规则的RDD来说我们用spark.default.parallelism定义其并行度spark.sql.shuffle.partitions则用于明确指定数据关联或聚合操作中Reduce端的分区数量。
说到并行度Parallelism就不得不提并行计算任务Paralleled Tasks这两个概念关联紧密但含义大相径庭有不少同学经常把它们弄混。
并行度指的是分布式数据集被划分为多少份从而用于分布式计算。换句话说并行度的出发点是数据它明确了数据划分的粒度。并行度越高数据的粒度越细数据分片越多数据越分散。由此可见像分区数量、分片数量、Partitions这些概念都是并行度的同义词。
并行计算任务则不同它指的是在任一时刻整个集群能够同时计算的任务数量。换句话说它的出发点是计算任务、是CPU由与CPU有关的三个参数共同决定。具体说来Executor中并行计算任务数的上限是spark.executor.cores与spark.task.cpus的商暂且记为#Executor-tasks整个集群的并行计算任务数自然就是#Executor-tasks乘以集群内Executors的数量记为#Executors。因此,最终的数值是:#Executor-tasks * #Executors
我们不难发现并行度决定了数据粒度数据粒度决定了分区大小分区大小则决定着每个计算任务的内存消耗。在同一个Executor中多个同时运行的计算任务“基本上”是平均瓜分可用内存的每个计算任务能获取到的内存空间是有上限的因此并行计算任务数会反过来制约并行度的设置。你看这两个家伙还真是一对相爱相杀的冤家
至于到底该怎么平衡并行度与并行计算任务两者之间的关系我们留到后面的课程去展开。这里咱们只要记住和CPU设置有关配置项的含义、区别与作用就行了。
哪些配置项与内存设置有关?
说完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用于执行分布式计算任务的内存空间自然就变少了而且数据分析场景中常见的关联、排序和聚合等操作都会消耗执行内存这部分内存空间变少自然会影响到这类计算的执行效率。
其次大量缓存引入的GCGarbage Collection垃圾回收负担对执行效率来说是个巨大的隐患。
你还记得黄四郎要招租的土地分为托管田和自管田吗托管田由黄四郎派人专门打理土地秋收后的翻土、整平等杂务为来年种下一茬庄稼做准备。堆内内存的垃圾回收也是一个道理JVM大体上把Heap堆内内存分为年轻代和老年代。年轻代存储生命周期较短、引用次数较低的对象老年代则存储生命周期较长、引用次数高的对象。因此像RDD cache这种一直缓存在内存里的数据一定会被JVM安排到老年代。
年轻代的垃圾回收工作称为Young GC老年代的垃圾回收称为Full GC。每当老年代可用内存不足时都会触发JVM执行Full GC。在Full GC阶段JVM会抢占应用程序执行线程强行征用计算节点中所有的CPU线程也就是“集中力量办大事”。当所有CPU线程都被拿去做垃圾回收工作的时候应用程序的执行只能暂时搁置。只有等Full GC完事之后把CPU线程释放出来应用程序才能继续执行。这种Full GC征用CPU线程导致应用暂停的现象叫做“Stop the world”。
因此Full GC对于应用程序的伤害远大于Young GC并且GC的效率与对象个数成反比对象个数越多GC效率越差。这个时候对于RDD这种缓存在老年代中的数据就很容易引入Full GC问题。
一般来说为了提升RDD cache访问效率很多同学都会采用以对象值的方式把数据缓存到内存因为对象值的存储方式避免了数据存取过程中序列化与反序列化的计算开销。我们在RDD/DataFrame/Dataset之上调用cache方法的时候默认采用的就是这种存储方式。
但是采用对象值的方式缓存数据不论是RDD还是DataFrame、Dataset每条数据样本都会构成一个对象要么是开发者自定义的Case class要么是Row对象。换句话说老年代存储的对象个数基本等于你的样本数。因此当你的样本数大到一定规模的时候你就需要考虑大量的RDD cache可能会引入的Full GC问题了。
基于上面的分析我们不难发现在打算把大面积的内存空间用于RDD cache之前你需要衡量这么做可能会对执行效率产生的影响。
你可能会说:“我的应用就是缓存密集型,确实需要把数据缓存起来,有什么办法来平衡执行效率吗?”办法还是有的。
首先,你可以放弃对象值的缓存方式,改用序列化的缓存方式,序列化会把多个对象转换成一个字节数组。这样,对象个数的问题就得到了初步缓解。
其次我们可以调节spark.rdd.compress这个参数。RDD缓存默认是不压缩的启用压缩之后缓存的存储效率会大幅提升有效节省缓存内存的占用从而把更多的内存空间留给分布式任务执行。
通过这两类调整开发者在享用RDD数据访问效率的同时还能够有效地兼顾应用的整体执行效率可谓是两全其美。不过有得必有失尽管这两类调整优化了内存的使用效率但都是以引入额外的计算开销、牺牲CPU为代价的。这也就是我们一直强调的性能调优的过程本质上就是不断地平衡不同硬件资源消耗的过程。
哪些配置项与磁盘设置有关?
在存储系统那一讲我们简单提到过spark.local.dir这个配置项这个参数允许开发者设置磁盘目录该目录用于存储RDD cache落盘数据块和Shuffle中间文件。
通常情况下spark.local.dir会配置到本地磁盘中容量比较宽裕的文件系统毕竟这个目录下会存储大量的临时文件我们需要足够的存储容量来保证分布式任务计算的稳定性。不过如果你的经费比较充裕有条件在计算节点中配备足量的SSD存储甚至是更多的内存资源完全可以把SSD上的文件系统目录或是内存文件系统添加到spark.local.dir配置项中去从而提供更好的I/O性能。
小结
掌握硬件资源类的配置项是我们打破性能瓶颈,以及平衡不同硬件资源利用率的必杀技。具体来说,我们可以分成两步走。
第一步理清CPU、内存和磁盘这三个方面的性能配置项都有什么以及它们的含义。因此我把硬件资源类配置项的含义都汇总在了一个表格中方便你随时查看。有了这份手册在针对硬件资源进行配置项调优时你就能够做到不重不漏。
第二步,重点理解这些配置项的作用,以及可以解决的问题。
首先对于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参数
期待在留言区看到你的思考和答案,也欢迎你把这份硬件资源配置项手册分享给更多的朋友,我们下一讲见!

View 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下面的配置项还是蛮多的其中对执行性能贡献最大的当属AQEAdaptive 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过后数据大小为20GBminPartitionNum设置为200反推过来每个分区的尺寸就是20GB / 200 = 100MB。再假设advisoryPartitionSizeInBytes设置为200MB最终的目标分区尺寸就是取100MB200MB之间的最小值也就是100MB。因此你看并不是你指定了advisoryPartitionSizeInBytes是多少Spark就会完全尊重你的意见我们还要考虑minPartitionNum的设置。
哪些配置项与自动数据倾斜处理有关?
再来说说数据倾斜在数据关联Data Joins的场景中当AQE检测到倾斜的数据分区时会自动进行拆分操作把大分区拆成多个小分区从而避免单个任务的数据处理量过大。不过Spark 3.0版本发布的AQE暂时只能在Sort Merge Join中自动处理数据倾斜其他的Join实现方式如Shuffle Join还有待支持。
那么AQE如何判定数据分区是否倾斜呢它又是怎么把大分区拆分成多个小分区的
首先分区尺寸必须要大于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。对于那些满足中位数条件的分区必须要大于256MBSpark才会把这个分区最终判定为倾斜分区。假设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中数据倾斜的处理机制你认为有哪些潜在的隐患
期待在留言区看到你的思考和答案,也欢迎你把这份调优手册分享给你的朋友们,我们下一讲见!

View 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阶段的并行度保持一致。换句话说有多少个TaskMap阶段就会生产相应数量的数据文件和索引文件。
接下来我带你用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算子需要消费KeyValue形式的数据因此我们需要对原始花朵数据做一次转换。以数据分区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中的数据记录按目标分区IDKey进行排序将所有数据溢出到临时文件同时清空缓存-
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大。因此相比PartitionedPairBufferPartitionedAppendOnlyMap的存储效率要高得多溢出数据到磁盘文件的频率也要低得多。
以此类推最终合并的数据文件也会小很多。依靠高效的内存数据结构、更少的磁盘文件、更小的文件尺寸我们就能大幅降低了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
当数据结构填满后如果分片中还有未处理的数据记录就对结构中的数据记录按目标分区IDKey排序将所有数据溢出到临时文件同时清空数据结构
重复前2个步骤直到分片中所有的数据记录都被处理
对所有临时文件和内存数据结构中剩余的数据记录做归并排序,最终生成数据文件和索引文件。
在Reduce阶段我们要注意Reduce Task通过网络拉取中间文件的过程实际上就是不同Stages之间数据分发的过程。并且Shuffle中数据分发的网络开销会随着Map Task与Reduce Task的线性增长呈指数级爆炸。
最后从硬件资源的角度来看Shuffle对每一种硬件资源都非常地渴求尤其是内存、磁盘和网络。由于不同硬件资源之间的处理延迟差异巨大我们很难在Shuffle过程中平衡CPU、内存、磁盘和网络之间的计算开销。因此对于Shuffle我们避之唯恐不及要能省则省、能拖则拖。
每日一练
以小红分到的花朵数据分区0为例你能推导出reduceByKey中Map阶段的每个环节吗提示PartitionedAppendOnlyMap需要多少次溢出到磁盘临时文件每一个临时文件中的内容是什么最终生成的中间文件内容分别是什么和groupByKey生成的中间文件一样吗
Map阶段和Reduce阶段有不少环节都涉及数据缓存、数据存储结合上一讲介绍的Spark配置项你能把相关的配置项对号入座吗
期待在留言区看到你的思考和讨论,我们下一讲见!

View 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把汇总好的全量数据分发给各个ExecutorsExecutors将接收到的全量数据缓存到存储系统的BlockManager中。
不难发现,相比从普通变量创建广播变量,从分布式数据集创建广播变量的网络开销更大。原因主要有二:一是,前者比后者多了一步网络通信;二是,前者的数据体量通常比后者大很多。
如何用广播变量克制Shuffle
你可能会问“Driver从Executors拉取DataFrame的数据分片揉成一份全量数据然后再广播出去抛开网络开销不说来来回回得费这么大劲图啥呢”这是一个好问题因为以广播变量的形式缓存分布式数据集正是克制Shuffle杀手锏。
Shuffle Joins
为什么这么说呢?我还是拿电子商务场景举例。有了用户的数据之后,为了分析不同用户的购物习惯,业务部门要求我们对交易表和用户表进行数据关联。这样的数据关联需求在数据分析领域还是相当普遍的。
val transactionsDF: DataFrame = _
val userDF: DataFrame = _
transactionsDF.join(userDF, Seq(“userID”), “inner”)
因为需求非常明确同学小A立即调用Parquet数据源API读取分布式文件创建交易表和用户表的DataFrame然后调用DataFrame的Join方法以userID作为Join keys用内关联Inner Join的方式完成了两表的数据关联。
在分布式环境中交易表和用户表想要以userID为Join keys进行关联就必须要确保一个前提交易记录和与之对应的用户信息在同一个Executors内。也就是说如果用户黄小乙的购物信息都存储在Executor 0而个人属性信息缓存在Executor 2那么在分布式环境中这两种信息必须要凑到同一个进程里才能实现关联计算。
在不进行任何调优的情况下Spark默认采用Shuffle Join的方式来做到这一点。Shuffle Join的过程主要有两步。
第一步就是对参与关联的左右表分别进行ShuffleShuffle的分区规则是先对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把汇总好的全量数据分发给各个ExecutorsExecutors再将接收到的全量数据缓存到存储系统的BlockManager中。
结合这两种方式我们在做数据关联的时候把Shuffle Joins转换为Broadcast Joins就可以用小表广播来代替大表的全网分发真正做到克制Shuffle。
每日一练
Spark广播机制现有的实现方式是存在隐患的在数据量较大的情况下Driver可能会成为瓶颈你能想到更好的方式来重新实现Spark的广播机制吗提示SPARK-17556
在什么情况下不适合把Shuffle Joins转换为Broadcast Joins
期待在留言区看到你的思考和答案,我们下一讲见!

View 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的两张表来说任意一张表的尺寸小于10MBSpark就在运行时采用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内存会成为性能隐患。广播变量尺寸越大额外引入的性能开销就会越多。更何况如果广播变量大小超过8GBSpark会直接抛异常中断任务执行。
其次从功能上来讲并不是所有的Joins类型都可以转换为Broadcast Joins。一来Broadcast Joins不支持全连接Full Outer Joins二来在所有的数据关联中我们不能广播基表。或者说即便开发者强制广播基表也无济于事。比如说在左连接Left Outer Join我们只能广播右表在右连接Right Outer Join我们只能广播左表。在下面的代码中即便我们强制用broadcast函数进行广播Spark SQL在运行时还是会选择Shuffle Joins。
import org.apache.spark.sql.functions.broadcast
broadcast (table1).join(table2, Seq(“key”), “left”)
table1.join(broadcast(table2), Seq(“key”), “right”)
小结
这一讲我们总结了2种方法让Spark SQL在运行时能够选择Broadcast Joins策略分别是设置配置项和用API强制广播。
首先设置配置项主要是设置autoBroadcastJoinThreshold配置项。开发者通过这个配置项指示Spark SQL优化器。只要参与Join的两张表中有一张表的尺寸小于这个参数值就在运行时采用Broadcast Joins的实现方式。
为了让Spark SQL采用Broadcast Joins开发者要做的就是让数据表在内存中的尺寸小于autoBroadcastJoinThreshold参数的设定值。
除此之外在设置广播阈值的时候因为磁盘数据展开到内存的时候存储大小会成倍增加往往导致Spark SQL无法采用Broadcast Joins的策略。因此我们在做数据关联的时候还要先预估一张表在内存中的存储大小。一种精确的预估方法是先把DataFrame缓存然后读取执行计划的统计数据。
其次用API强制广播有两种方法分别是设置Join Hints和用broadcast函数。设置Join Hints的方法就是在SQL结构化查询语句里面加上一句“/*+ broadcast(某表) */”的提示就可以了这里的broadcast关键字也可以换成broadcastjoin或者mapjoin。另外你也可以在DataFrame的DSL语法中使用调用hint方法指定broadcast关键字来达到同样的效果。设置broadcast函数的方法非常简单只要用broadcast函数封装需要广播的数据表就可以了。
总的来说不管是设置配置项还是用API强制广播都有各自的优缺点所以以广播阈值配置为主、强制广播为辅往往是一个不错的选择。
最后,不过,我们也要注意,广播变量不是银弹,它并不能解决所有的数据关联问题,所以在日常的开发工作中,你要注意避免滥用广播。
每日一练
除了broadcast关键字外在Spark 3.0版本中Join Hints还支持哪些关联类型和关键字
DataFrame可以用sparkContext.broadcast函数来广播吗它和org.apache.spark.sql.functions.broadcast函数之间的区别是什么
期待在留言区看到你的思考和答案,我们下一讲见!

View 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/21/N之间浮动。
这个规定大伙儿都心服口服没过多久800顷土地就全部转让完了。一笔多赢的买卖让大伙都能各取所需也让老谋深算的黄四郎都不禁心挑大指感叹道“真是长江水后浪催前浪一代新人换旧人
好啦,故事到这里暂时告一段落,但是黄小乙这份如意算盘和今天要讲的内容有什么关系呢?
我们讲过黄小乙租赁的土地类比的是内存区域中的Execution Memory。在今天的故事里黄小乙招募的棉农和咖啡农对应的就是Executor线程池中一个又一个执行分布式任务的线程。土地出让规则对应的就是任务并发过程中多个线程抢占内存资源时需要遵循的基本逻辑。
那么执行内存抢占规则就是在同一个Executor中当有多个记为N线程尝试抢占执行内存时需要遵循2条基本原则
执行内存总大小记为M为两部分之和一部分是Execution Memory初始大小另一部分是Storage Memory剩余空间
每个线程分到的可用内存有一定的上下限下限是M/N/2上限是M/N也就是均值
三足鼎立:并行度、并发度与执行内存
理清了线程与执行内存的关系之后我们再来说说与并发度、执行内存和并行度这三者对应的3类配置项分别是什么以及它们如何影响CPU与计算内存之间的平衡。
3类配置项
我们讲过,并行度指的是为了实现分布式计算,分布式数据集被划分出来的份数。并行度明确了数据划分的粒度:并行度越高,数据的粒度越细,数据分片越多,数据越分散。
并行度可以通过两个参数来设置分别是spark.default.parallelism和spark.sql.shuffle.partitions。前者用于设置RDD的默认并行度后者在Spark SQL开发框架下指定了Shuffle Reduce阶段默认的并行度。
那什么是并发度呢我们在配置项那一讲提到过Executor的线程池大小由参数spark.executor.cores决定每个任务在执行期间需要消耗的线程数由spark.task.cpus配置项给定。两者相除得到的商就是并发度也就是同一时间内一个Executor内部可以同时运行的最大任务数量。又因为spark.task.cpus默认数值为1并且通常不需要调整所以并发度基本由spark.executor.cores参数敲定。
就Executor的线程池来说尽管线程本身可以复用但每个线程在同一时间只能计算一个任务每个任务负责处理一个数据分片。因此在运行时线程、任务与分区是一一对应的关系。
分布式任务由Driver分发到Executor后Executor将Task封装为TaskRunner然后将其交给可回收缓存线程池newCachedThreadPool。线程池中的线程领取到TaskRunner之后向Execution Memory申请内存然后开始执行任务。
如果我们把棉农、咖啡农类比CPU线程那么TaskRunner就可以理解为锄具Task要处理的数据分片可以理解为作物种子。有了锄具和种子之后老乡们得去黄小乙那儿申请块地才能开始耕种。
最后我们再来说说执行内存。黄小乙的地就是执行内存堆内执行内存的初始值由很多参数共同决定具体的计算公式是spark.executor.memory * spark.memory.fraction * (1 - spark.memory.storageFraction)。相比之下堆外执行内存的计算稍微简单一些spark.memory.offHeap.size * (1 - spark.memory.storageFraction)。
除此之外在统一内存管理模式下在Storage Memory没有被RDD缓存占满的情况下执行任务可以动态地抢占Storage Memory。因此在计算可用于分配给执行任务的内存总量时还要把有希望抢占过来的这部分内存空间考虑进来。这也是为什么黄小乙的可耕种土地总面积会从最开始的500顷逐渐扩展到800顷。
由此可见,可分配的执行内存总量会随着缓存任务和执行任务的此消彼长,而动态变化。但无论怎么变,可用的执行内存总量,都不会低于配置项设定的初始值。
好啦搞明白并行度、并发度和执行内存的概念以及各自的配置项之后我们再通过两个经常影响CPU利用率的例子来说说它们是怎么影响CPU与计算内存之间的平衡的由此总结出提升CPU利用率的办法。
CPU低效原因之一线程挂起
在给定执行内存总量M和线程总数N的情况下为了保证每个线程都有机会拿到适量的内存去处理数据Spark用HashMap数据结构KeyValue的方式来记录每个线程消耗的内存大小并确保所有的Value值都不超过M/N。在一些极端情况下有些线程申请不到所需的内存空间能拿到的内存合计还不到M/N/2。这个时候Spark就会把线程挂起直到其他线程释放了足够的内存空间为止。
你可能会问“既然每个线程能拿到的内存上限是M/N也就是内存总量对线程数取平均值为什么还会出现有的线程连M/N/2都拿不到呢这在数学上也不成立呀”这是个好问题。这种情况的出现源于3方面的变化和作用
动态变化的执行内存总量M
动态变化的并发度N~
分布式数据集的数据分布
首先动态变化的执行内存总量M我们刚刚已经说过了。M的下限是Execution Memory初始值上限是spark.executor.memory * spark.memory.fraction划定的所有内存区域。在应用刚刚开始执行的时候M的取值就是这个上限但随着RDD缓存逐渐填充Storage MemoryM的取值也会跟着回撤。
另外到目前为止1/N/21/N上下限的计算我们用的都是线程总数N线程总数N是固定的。N的取值含义是一个Executor内最大的并发度更严格的计算公式是spark.executor.cores除以spark.task.cpus。但实际上上下限公式的计算用的不是N而是N~。N的含义是Executor内当前的并发度也就是Executor中当前并行执行的任务数。显然N <= N。
换句话说尽管一个Executor中有N个CPU线程但这N个线程不一定都在干活。在Spark任务调度的过程中这N个线程不见得能同时拿到分布式任务所以先拿到任务的线程就有机会申请到更多的内存。在某些极端的情况下后拿到任务的线程甚至连一寸内存都申请不到。不过随着任务执行和任务调度的推进N~会迅速地趋近于NCPU线程挂起和内存分配的情况也会逐渐得到改善。
就像黄小乙的补充条款中举的那个例子一样当可耕种土地总面积为800顷的时候如果有400位老乡申请土地转让权那么每位老乡最多可得800/400=2顷土地最低可得800/400/2=1顷土地。
但如果这400位老乡不是同时来的而是分两批来的每批来200人的话就会出现问题。按照他的规则先来的这200位老乡每人最多可得800/200 = 4顷土地。咱们前面说了每个申请的老乡都想通过这次机会发点小财于是这200位老乡每人都申请了4顷地黄小乙的地一下子就被分光了后来的200位老乡就没地可种了他们只能等到第一批老乡的棉花和咖啡丰收了再重新一起申请土地转让权。
假设第一批老乡同时大丰收,按照黄小乙转让规则的第一条,第一批老乡要交出土地使用权,如果想要继续耕种的话,就得和第二批老乡一起重新申请。在这种情况下,上下限的计算才是黄小乙最开始举例的那种算法。
第三个影响任务并发度和内存分配的因素是分布式数据集的分布情况。在刚才的例子中如果第一批老乡每人只申请2顷土地那么第二批老乡来了之后依然有地可种。每人申请多大的土地取决于他手里有多少农作物种子我们之前把每个Task需要处理的数据分片比作是作物种子那么数据分片的数据量决定了执行任务需要申请多少内存。如果分布式数据集的并行度设置得当因任务调度滞后而导致的线程挂起问题就会得到缓解。
CPU低效原因之二调度开销
线程挂起的问题得到缓解CPU利用率就会有所改善。既然如此是不是把并行度设置到最大每个数据分片就都能足够小小到每个CPU线程都能申请到内存线程不再挂起就万事大吉了呢
当然不是,并行度足够大,确实会让数据分片更分散、数据粒度更细,因此,每个执行任务所需消耗的内存更少。但是,数据过于分散会带来严重的副作用:调度开销骤增。
对于每一个分布式任务Dirver会将其封装为TaskDescription然后分发给各个Executor。TaskDescription包含着与任务运行有关的所有信息如任务ID、尝试ID、要处理的数据分片ID、开发者添加的本地文件和Jar包、任务属性、序列化的任务代码等等。Executor接收到TaskDescription之后首先需要对TaskDescription反序列化才能读取任务信息然后将任务代码再反序列化得到可执行代码最后再结合其他任务信息创建TaskRunner。
因此你看每个任务的调度与执行都需要Executor消耗CPU去执行上述一系列的操作步骤。数据分片与线程、执行任务一一对应当数据过于分散分布式任务数量会大幅增加但每个任务需要处理的数据量却少之又少就CPU消耗来说相比花在数据处理上的比例任务调度上的开销几乎与之分庭抗礼。显然在这种情况下CPU的有效利用率也是极低的。
如何优化CPU利用率
你可能会说“这也太尴尬了并行度低了不行容易让CPU线程挂起高了也不行调度开销太大CPU有效利用率也不高。高也不行、低也不行那我该怎么办呢
咱们不妨来算笔账。我们还是拿黄小乙的如意算盘来举例如果400个老乡同时来申请他的800顷地那么每个老乡能分到1到2顷土地不等。相应地每位老乡需要购买的种子应该刚好够种满1到2顷地。因为买多了种不下买少了还亏。假设洼子村农产品交易市场的种子总量刚好够种1000顷地从卖家的视角出发这些种子应该售卖1000/2 =500到1000/1 = 1000次才能赚到最多的钱。
因此在给定Executor线程池和执行内存大小的时候我们可以参考上面的算法去计算一个能够让数据分片平均大小在M/N/2, M/N之间的并行度这往往是个不错的选择。
总的来说对CPU利用率来说并行度、并发度与执行内存的关系就好像是一尊盛满沸水的三足鼎三足齐平则万事大吉但凡哪一方瘸腿儿鼎内的沸水就会倾出伤及无辜。
小结
今天这一讲我们从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利用率苦恼欢迎你把这一讲转发给他我们下一讲见

View 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分发到ExecutorExecutor接收到任务之后交由线程池去执行调度系统的内容可以回顾第5讲。这个时候每个Task就像是一架架小飞机携带着代码“乘客”和数据“行李”从Driver飞往Executor。Task小飞机在Executor机场着陆之后代码“乘客”乘坐出租车或是机场大巴去往JVM stack数据“行李”则由专人堆放在JVM Heap也就是我们常说的堆内内存。
回顾Label encoding中的findIndex函数不难发现其中大部分都是代码“乘客”唯一的数据“行李”是名为searchMap的Map字典。像这样用户自定义的数据结构消耗的内存区域就是堆内内存的User MemorySpark对内存区域的划分内容可以回顾一下第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小飞机都无需亲自携带数据“行李”这些大件行李会由“联邦广播快递公司”派货机专门发往各个ExecutorsDriver和每个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对象存储大小的方法有哪些吗不同的方法又有哪些优、劣势呢
对于内存规划的第一步来说,要精确地预估运行时每一个区域的内存消耗,很费时、费力,调优的成本很高。如果我们想省略掉第一步的精确计算,你知道有哪些方法能够粗略、快速地预估不同内存区域的消耗占比吗?
期待在留言区看到你的思考和答案,我们下一讲见!

View 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是根据什么规则选中的这些倒霉蛋呢这个规则叫作LRULeast Recently Used基于这个算法最近访问频率最低的那个家伙就是倒霉蛋。因为LRU是比较基础的数据结构算法笔试、面试的时候经常会考所以它的概念我就不多说了。
我们要知道的是Spark是如何实现LRU的。这里Spark使用了一个巧妙的数据结构LinkedHashMap这种数据结构天然地支持LRU算法。
LinkedHashMap使用两个数据结构来维护数据一个是传统的HashMap另一个是双向链表。HashMap的用途在于快速访问根据指定的BlockIdHashMap以O(1)的效率返回MemoryEntry。双向链表则不同它主要用于维护元素也就是BlockId和MemoryEntry键值对的访问顺序。凡是被访问过的元素无论是插入、读取还是更新都会被放置到链表的尾部。因此链表头部保存的刚好都是“最近最少访问”的元素。
如此一来当内存不足需要驱逐缓存的数据块时Spark只利用LinkedHashMap就可以做到按照“最近最少访问”的原则去依次驱逐缓存中的数据分片了。
除此之外在存储系统那一讲有同学问MemoryStore为什么使用LinkedHashMap而不用普通的Map来存储BlockId和MemoryEntry的键值对。我刚才说的就是答案了。
回到图中的例子当RDD1试图缓存第6个数据分片但可用内存空间不足时Spark 会对LinkedHashMap从头至尾扫描边扫描边记录MemoryEntry大小当倒霉蛋的总大小超过第6个数据分片时Spark停止扫描。
有意思的是倒霉蛋的选取规则遵循“兔子不吃窝边草”同属一个RDD的MemoryEntry不会被选中。就像图中的步骤4展示的一样第一个蓝色的MemoryEntry会被跳过紧随其后打叉的两个MemoryEntry被选中。
因此总结下来在清除缓存的过程中Spark遵循两个基本原则
LRU按照元素的访问顺序优先清除那些“最近最少访问”的BlockId、MemoryEntry键值对
兔子不吃窝边草在清除的过程中同属一个RDD的MemoryEntry拥有“赦免权”
退化为MapReduce
尽管有缓存销毁这个环节的存在Storage Memory内存空间也总会耗尽MemoryStore也总会“驱无可驱”。这个时候MEMORY_ONLY模式就会放弃剩余的数据分片。比如在Spark UI上你时常会看到Storage Tab中的缓存比例低于100%。而我们从Storage Tab也可以观察到在MEMORY_AND_DISK模式下数据集在内存和磁盘中各占一部分比例。
这是因为对于MEMORY_AND_DISK存储级别来说当内存不足以容纳所有的RDD数据分片的时候Spark会把尚未展开的RDD分片通过DiskStore缓存到磁盘中。DiskStore的工作原理我们在存储系统那一讲有过详细介绍你可以回去看一看我建议你结合DiskStore的知识把RDD分片在磁盘上的缓存过程推导出来。
因此相比MEMORY_ONLYMEMORY_AND_DISK模式能够保证数据集100%地物化到存储介质。对于计算链条较长的RDD或是DataFrame来说把数据物化到磁盘也是值得的。但是我们也不能逢RDD、DataFrame就调用.cache因为在最差的情况下Spark的内存计算就会退化为Hadoop MapReduce根据磁盘的计算模式。
比如说你用DataFrame API开发应用计算过程涉及10次DataFrame之间的转换每个DataFrame都调用.cache进行缓存。由于Storage Memory内存空间受限MemoryStore最多只能容纳两个DataFrame的数据量。因此MemoryStore会有8次以DataFrame为粒度的换进换出。最终MemoryStore存储的是访问频次最高的DataFrame数据分片其他的数据分片全部被驱逐到了磁盘上。也就是说平均下来至少有8次DataFrame的转换都会将计算结果落盘这不就是Hadoop的MapReduce计算模式吗
当然咱们考虑的是最差的情况但这也能让我们体会到滥用Cache可能带来的隐患和危害了。
Cache的用武之地
既然滥用Cache危害无穷那在什么情况下适合使用Cache呢我建议你在做决策的时候遵循以下2条基本原则
如果RDD/DataFrame/Dataset在应用中的引用次数为1就坚决不使用Cache
如果引用次数大于1且运行成本占比超过30%应当考虑启用Cache
第一条很好理解我们详细说说第二条。这里咱们定义了一个新概念运行成本占比。它指的是计算某个分布式数据集所消耗的总时间与作业执行时间的比值。我们来举个例子假设我们有个数据分析的应用端到端的执行时间为1小时。应用中有个DataFrame被引用了2次从读取数据源经过一系列计算到生成这个DataFrame需要花费12分钟那么这个DataFrame的运行成本占比应该算作12 * 2 / 60 = 40%。
你可能会说“作业执行时间好算直接查看Spark UI就好了DataFrame的运行时间怎么算呢”这里涉及一个小技巧我们可以从现有应用中 把DataFrame的计算逻辑单拎出来然后利用Spark 3.0提供的Noop来精确地得到DataFrame的运行时间。假设df是那个被引用2次的DataFrame我们就可以把df依赖的所有代码拷贝成一个新的作业然后在df上调用Noop去触发计算。Noop的作用很巧妙它只触发计算而不涉及落盘与数据存储因此新作业的执行时间刚好就是DataFrame的运行时间。
//利用noop精确计算DataFrame运行时间
df.write
.format(“noop”)
.save()
你可能会觉得每次计算占比会很麻烦但只要你对数据源足够了解、对计算DataFrame的中间过程心中有数了之后其实不必每次都去精确地计算运行成本占比尝试几次你就能对分布式数据集的运行成本占比估摸得八九不离十了。
Cache的注意事项
弄清楚了应该什么时候使用Cache之后我们再来说说Cache的注意事项。
首先,我们都知道,.cache是惰性操作因此在调用.cache之后需要先用Action算子触发缓存的物化过程。但是我发现很多同学在选择Action算子的时候很随意first、take、show、count中哪个顺手就用哪个。
这肯定是不对的这4个算子中只有count才会触发缓存的完全物化而first、take和show这3个算子只会把涉及的数据物化。举个例子show默认只产生20条结果如果我们在.cache之后调用show算子它只会缓存数据集中这20条记录。
选择好了算子之后我们再来讨论一下怎么Cache这个问题。你可能会说“这还用说吗在RDD、DataFrame后面调用.cache不就得了”。还真没这么简单我出一道选择题来考考你如果给定包含数十列的DataFrame df和后续的数据分析你应该采用下表中的哪种Cache方式
val filePath: String = _
val df: DataFrame = spark.read.parquet(filePath)
//Cache方式一
val cachedDF = df.cache
//数据分析
cachedDF.filter(col2 > 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而困扰也欢迎你把这一讲转发给他。我们下一讲见

View File

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

View 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能起到的作用完全等价。
咱们来举个例子。现在有这样一个业务需求给定用户访问日志分别统计不同用户的PVPage Views页面浏览量、UVUnique Views网站独立访客然后再把两项统计结果合并起来以备后用。其中用户日志包含用户ID、访问时间、页面地址等主要字段。业务需求不仅明确也很简单我们很快就能把代码写出来。
//版本1分别计算PV、UV然后合并
// Data schema (userId: String, accessTime: Timestamp, page: String)
val filePath: String = _
val df: DataFrame = spark.read.parquet(filePath)
val dfPV: DataFrame = df.groupBy("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机制为什么
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@ -0,0 +1,129 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 网络视角:如何有效降低网络开销?
你好,我是吴磊。
在平衡不同硬件资源的时候相比CPU、内存、磁盘网络开销无疑是最拖后腿的那一个这一点在处理延迟上表现得非常明显。
下图就是不同硬件资源的处理延迟对比结果,我们可以看到最小的处理单位是纳秒。你可能对纳秒没什么概念,所以为了方便对比,我把纳秒等比放大到秒。这样,其他硬件资源的处理延迟也会跟着放大。最后一对比我们会发现,网络延迟是以天为单位的!
因此,要想维持硬件资源之间的平衡,尽可能地降低网络开销是我们在性能调优中必须要做的。今天这一讲,我就按照数据进入系统的时间顺序,也就是数据读取、数据处理和数据传输的顺序,带你去分析和总结数据生命周期的不同阶段有效降低网络开销的方法。
数据读写
对于绝大多数应用来说第一步操作都是从分布式文件系统读取数据源。Spark支持的数据源种类非常丰富涉及的存储格式和存储系统可以说是五花八门。
这么多存储格式和外部存储系统交叉在一起又会有无数种组合并且每一种组合都有它的应用场景。那么我们该怎么判断网络开销会出现在哪些场景下呢其实不管是什么文件格式也不管是哪种存储系统访问数据源是否会引入网络开销取决于任务与数据的本地性关系也就是任务的本地性级别它一共有4种
PROCESS_LOCAL任务与数据同在一个JVM进程中
NODE_LOCAL任务与数据同在一个计算节点数据可能在磁盘上或是另一个JVM进程中
RACK_LOCAL任务与数据不在同一节点但在同一个物理机架上
ANY任务与数据是跨机架、甚至是跨DCData 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端聚合的思想并不过时。为什么这么说呢下面我通过一个小例子来你详细讲一讲。
在绝大多数2CTo 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端聚合的计算场景
对于不同的数据处理阶段,你还知道哪些降低网络开销的办法吗?
期待在留言区看到你的思考和答案,也欢迎你把这一讲转发出去,我们下一讲见!

View 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或者ScalaPySpark实现的应用在执行性能上相差悬殊。原因在于在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的表达能力却很弱。一来它定义了一套DSLDomain Specific Language算子如select、filter、agg、groupBy等等。由于DSL语言是为解决某一类任务而专门设计的计算机语言非图灵完备因此语言表达能力非常有限。二来DataFrame中的绝大多数算子都是标量函数Scalar Functions它们的形参往往是结构化的数据列Columns表达能力也很弱。
你可能会问“相比RDDDataFrame的表示和表达能力都变弱了那它是怎么解决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来生成抽象语法树ASTAbstract 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优化器和TungstenSpark 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对于计算逻辑的优化无从下手。
相比RDDDataFrame是携带Schema的分布式数据集只能封装结构化数据。DataFrame的算子大多数都是普通的标量函数以消费数据列为主。但是DataFrame更弱的表示能力和表达能力反而为Spark引擎的内核优化打开了全新的空间。
根据DataFrame简单的标量算子和明确的Schema定义借助Catalyst优化器和TungstenSpark SQL有能力在运行时构建起一套端到端的优化机制。这套机制运用启发式的规则与策略和运行时的执行信息将原本次优、甚至是低效的查询计划转换为高效的执行计划从而提升端到端的执行性能。
在DataFrame的开发模式下所有子框架、以及PySpark都运行在Spark SQL之上都可以共享Spark SQL提供的种种优化机制这也是为什么Spark历次发布新版本、Spark SQL占比最大的根本原因。
每日一练
Java Object在对象存储上为什么会有比较大的开销JVM需要多少个字节才能存下字符串“abcd”
在DataFrame的开发框架下 PySpark中还有哪些操作是“顽固分子”会导致计算与数据在JVM进程与Python进程之间频繁交互(提示参考RDD的局限性那些对Spark透明的计算逻辑Spark是没有优化空间的)
期待在留言区看到你的思考和答案,我们下一讲见!

View 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 RulesAnalyzed 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的类型是intprice字段的类型是double等等
逻辑计划优化
对于现在的小Q来说如果我们不做任何优化直接把它转换为物理计划也可以但是这种照搬开发者的计算步骤去制定物理计划的方式它的执行效率往往不是最优的
为什么这么说呢在运行时Spark会先全量扫描Parquet格式的用户表然后遴选出userIdnameagegender四个字段接着分别按照年龄和性别对数据进行过滤
对于这样的执行计划来说最开始的全量扫描显然是一种浪费原因主要有两方面一方面查询实际上只涉及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 < 30gender = M”这两个谓词捏合成一个谓词“age != null AND gender != null AND age
完成谓词本身的优化之后Catalyst再用PushDownPredicte优化规则把谓词推到逻辑计划树最下面的数据源上对于ParquetORC这类存储格式结合文件注脚Footer中的统计信息下推的谓词能够大幅减少数据扫描量降低磁盘I/O开销
再来说说列剪裁列剪裁就是扫描数据源的时候只读取那些与查询相关的字段以小Q为例用户表的Schema是userIdnameagegenderemail但是查询中压根就没有出现过email的引用因此Catalyst会使用 ColumnPruning规则把email这一列剪掉经过这一步优化Spark在读取Parquet文件的时候就会跳过email这一列从而节省I/O开销
不难发现谓词下推与列剪裁的优化动机其实和能省则省的原则一样核心思想都是用尽一切办法减少需要扫描和处理的数据量降低后续计算的负载
最后一类优化是常量替换它的逻辑比较简单假设我们在年龄上加的过滤条件是age < 12 + 18Catalyst会使用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的父类是TreeNodeTreeNode就是语法树中对于节点的抽象TreeNode有一个名叫children的字段类型是Seq[TreeNode]利用TreeNode类型Catalyst可以很容易地构建一个树结构
除了children字段TreeNode还定义了很多高阶函数其中最值得关注的是一个叫做transformDown的方法transformDown的形参正是Catalyst定义的各种优化规则方法的返回类型还是TreeNode另外transformDown是个递归函数参数的优化规则会先作用Apply于当前节点然后依次作用到children中的子节点直到整棵树的叶子节点
总的来说Analyzed Logical PlanOptimized 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的优化规则吗
期待在留言区看到你的思考和答案,我们下一讲见!

View 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策略直接把ProjectFilterSort等逻辑操作符平行地映射为物理操作符其他策略的优化过程也类似因此在优化Spark Plan这一环节咱们只要抓住一个典型策略掌握它的转换过程即可
那我们该抓谁做典型我觉得这个典型至少要满足两个标准它要在我们的应用场景中非常普遍它的取舍对于执行性能的影响最为关键以这两个标准去遴选上面的8类策略我们分分钟就能锁定JoinSelection接下来我们就以JoinSelection为例详细讲解这一环节的优化过程
如果用一句话来概括JoinSelection的优化过程就是结合多方面的信息来决定在物理优化阶段采用哪种Join策略那么问题来了Catalyst都有哪些Join策略
Catalyst都有哪些Join策略
结合Joins的实现机制和数据的分发方式Catalyst在运行时总共支持5种Join策略分别是Broadcast Hash JoinBHJShuffle Sort Merge JoinSMJShuffle Hash JoinSHJBroadcast Nested Loop JoinBNLJ和Shuffle Cartesian Product JoinCPJ
通过上表中5种Join策略的含义我们知道它们是来自2种数据分发方式广播和Shuffle与3种Join实现机制Hash JoinsSort 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对于ParquetORCCSV等源文件的尺寸预估甚至是来自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策略
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@ -0,0 +1,169 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 钨丝计划Tungsten给开发者带来了哪些福报
你好,我是吴磊。
通过前两讲的学习我们知道在Spark SQL这颗智能大脑中“左脑”Catalyst优化器负责把查询语句最终转换成可执行的Physical Plan。但是把Physical Plan直接丢给Spark去执行并不是最优的选择最优的选择是把它交给“右脑”Tungsten再做一轮优化。
Tungsten又叫钨丝计划它主要围绕内核引擎做了两方面的改进数据结构设计和全阶段代码生成WSCGWhole Stage Code Generation
今天这一讲我们就来说说Tungsten的设计初衷是什么它的两方面改进到底解决了哪些问题以及它给开发者到底带来了哪些性能红利。
Tungsten在数据结构方面的设计
相比Spark CoreTungsten在数据结构方面做了两个比较大的改进一个是紧凑的二进制格式Unsafe Row另一个是内存页管理。我们一个一个来说。
Unsafe Row二进制数据结构
Unsafe Row是一种字节数组它可以用来存储下图所示Schema为userIDnameagegender的用户数据条目。总的来说所有字段都会按照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有什么区别和联系呢
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@ -0,0 +1,164 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 Spark 3.0AQE的3个特性怎么才能用好
你好,我是吴磊。
目前距离Spark 3.0版本的发布已经将近一年的时间了这次版本升级添加了自适应查询执行AQE、动态分区剪裁DPP和扩展的 Join Hints 等新特性。利用好这些新特性可以让我们的性能调优如虎添翼。因此我会用三讲的时间和你聊聊它们。今天我们先来说说AQE。
我发现同学们在使用AQE的时候总会抱怨说“AQE的开关打开了相关的配置项也设了可应用性能还是没有提升。”这往往是因为我们对于AQE的理解不够透彻调优总是照葫芦画瓢所以这一讲我们就先从AQE的设计初衷说起然后说说它的工作原理最后再去探讨怎样才能用好AQE。
Spark为什么需要AQE
在2.0版本之前Spark SQL仅仅支持启发式、静态的优化过程就像我们在第21、22、23三讲介绍的一样。
启发式的优化又叫RBORule Based Optimization基于规则的优化它往往基于一些规则和策略实现如谓词下推、列剪枝这些规则和策略来源于数据库领域已有的应用经验。也就是说启发式的优化实际上算是一种经验主义。
经验主义的弊端就是不分青红皂白、胡子眉毛一把抓对待相似的问题和场景都使用同一类套路。Spark社区正是因为意识到了RBO的局限性因此在2.2版本中推出了CBOCost 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版本推出了AQEAdaptive 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需要同时结合两个分支中的ShuffleExchange输出才能判断是否可以降级为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为粒度做到负载均衡吗
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@ -0,0 +1,126 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 Spark 3.0DPP特性该怎么用
你好,我是吴磊。
DPPDynamic 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 PruningI/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 UserTail 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.idValue是投影中需要引用的数据列在之前订单表与用户表的查询中这里的引用列就是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列表除了使用广播变量之外你觉得还有其他的方法和途径吗
期待在留言区看到你的思考和答案,我们下一讲见!

View 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实现方式。按照出现的时间顺序分别是嵌套循环连接NLJNested Loop Join 、排序归并连接SMJShuffle Sort Merge Join和哈希连接HJHash 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实现方式的差异与优劣势。
相比SMJHJ并不要求参与Join的两张表有序也不需要维护两个游标来判断当前的记录位置只要基表在Build阶段构建的哈希表可以放进内存HJ算法就可以在Probe阶段遍历外表依次与哈希表进行关联。
当数据能以广播的形式在网络中进行分发时说明被分发的数据也就是基表的数据足够小完全可以放到内存中去。这个时候相比NLJ、SMJHJ的执行效率是最高的。因此在可以采用HJ的情况下Spark自然就没有必要再去用SMJ这种前置开销比较大的方式去完成数据关联。
Spark如何选择Join策略
那么在不同的数据关联场景中对于这5种Join策略来说也就是CPJ、BNLJ、SHJ、SMJ以及BHJSpark会基于什么逻辑取舍呢我们来分两种情况进行讨论分别是等值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靠前呢这是个非常好的问题。我们先来说结论相比SHJSpark优先选择SMJ的原因在于SMJ的实现方式更加稳定更不容易OOM。
回顾HJ的实现机制在Build阶段算法根据内表创建哈希表。在Probe阶段为了让外表能够成功“探测”Probe到每一个Hash Key哈希表要全部放进内存才行。坦白说这个前提还是蛮苛刻的仅这一点要求就足以让Spark对其望而却步。要知道在不同的计算场景中数据分布的多样性很难保证内表一定能全部放进内存。
而且在Spark中SHJ策略要想被选中必须要满足两个先决条件这两个条件都是对数据尺寸的要求。首先外表大小至少是内表的3倍。其次内表数据分片的平均大小要小于广播变量阈值。第一个条件的动机很好理解只有当内外表的尺寸悬殊到一定程度时HJ的优势才会比SMJ更显著。第二个限制的目的是确保内表的每一个数据分片都能全部放进内存。
和SHJ相比SMJ没有这么多的附加条件无论是单表排序还是两表做归并关联都可以借助磁盘来完成。内存中放不下的数据可以临时溢出到磁盘。单表排序的过程我们可以参考Shuffle Map阶段生成中间文件的过程。在做归并关联的时候算法可以把磁盘中的有序数据用合理的粒度依次加载进内存完成计算。这个粒度可大可小大到以数据分片为单位小到逐条扫描。
正是考虑到这些因素相比SHJSpark 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两种机制来实现吗为什么
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@ -0,0 +1,213 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 大表Join小表广播变量容不下小表怎么办
你好,我是吴磊。
在数据分析领域大表Join小表的场景非常普遍。不过大小是个相对的概念通常来说大表与小表尺寸相差3倍以上我们就将其归类为“大表Join小表”的计算场景。因此大表Join小表仅仅意味着参与关联的两张表尺寸悬殊。
对于大表Join小表这种场景我们应该优先考虑BHJ它是Spark支持的5种Join策略中执行效率最高的。BHJ处理大表Join小表时的前提条件是广播变量能够容纳小表的全量数据。但是如果小表的数据量超过广播阈值我们又该怎么办呢
今天这一讲我们就结合3个真实的业务案例来聊一聊这种情况的解决办法。虽然这3个案例不可能覆盖“大表Join小表”场景中的所有情况但是分析并汇总案例的应对策略和解决办法有利于我们在调优的过程中开阔思路、发散思维从而避免陷入“面对问题无所适从”的窘境。
案例1Join 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只好退而求其次选择SMJShuffle 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调高一些比如1GBAQE才会把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小表”的场景中相比SMJSHJ的执行效率会更好一些。背后的原因在于小表构建哈希表的开销要小于两张表排序的开销。
每日一练
对于案例1我们的核心思路是用哈希值来替代超长的Join Keys除了用哈希值以外你觉得还有其他的思路或是办法去用较短的字符串来取代超长的Join Keys吗
对于案例2利用AQE Join策略调整和DDP机制的关键是确保过滤后的维表小于广播阈值。你能说说都有哪些方法可以用来计算过滤后的维表大小吗
对于案例3假设20GB的小表存在数据倾斜强行把SMJ转化为SHJ会抛OOM异常。这个时候你认为还有可能继续优化吗
期待在留言区看到你的思考和答案,我们下一讲见!

View 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机制以外还有哪些其他办法
期待在留言区看到你的思考和答案我们下一讲见

View 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 1Executor 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
期待在留言区看到你的思考和答案我们下一讲见

View 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都是batchNumcarNum也就是摇号批次申请编号。总之事实表和维度表在存储方式上都做了分区设计且分区键都是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
batchNumcarNum为粒度进行去重计数我们就能得到实际的摇号体量是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年底总共有7212 * 3 + 6 * 6次摇号。所以我们看到横坐标的值域是从1到721表示摇过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的小汽车摇号数据你还能想到哪些有意思的洞察、视角和案例值得我们进一步去探索呢
你认为,倍率对于中签的贡献和作用微乎其微的原因是什么呢?
期待在留言区看到你的思考和答案,我们下一讲见!

View 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做调优。
思路1Shuffle常规优化
刚刚咱们提到Shuffle的常规优化有两类一类是By Pass排序操作一类是调整读写缓冲区。而By Pass排序有两个前提条件一是计算逻辑不涉及聚合或排序二是Reduce阶段的并行度要小于参数spark.shuffle.sort.bypassMergeThreshold的设置值。显然场景1不符合要求计算逻辑既包含聚合也包含排序。所以我们就只有调整读写缓冲区这一条路可走了。
实际上读写缓冲区的调优也是有前提的因为这部分内存消耗会占用Execution Memory内存区域所以提高缓冲区大小的前提是Execution Memory内存比较充裕。由于咱们使用的硬件资源比较强劲而且小汽车摇号数据整体体量偏小因此咱们还是有一些“资本”对读写缓冲区做调优的。具体来说我们需要调整如下两个配置项
spark.shuffle.file.bufferMap阶段写入缓冲区大小
spark.reducer.maxSizeInFlightReduce阶段读缓冲区大小
由于读写缓冲区都是以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"
}
给定DataFramesizeNew函数可以返回该数据集在内存中的精确大小。把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做性能优化吗
期待在留言区看到你的优化结果,也欢迎你随时提问,我们下一讲见!

View 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个Actionssave保存计算结果、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
欢迎你在留言区跟我交流探讨,也欢迎推荐你把这一讲分享给有需要的朋友、同事。

View 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为数据关联做准备。
可以看到对于每一个ExchangeSpark UI都提供了丰富的Metrics来刻画Shuffle的计算过程。从Shuffle Write到Shuffle Read从数据量到处理时间应有尽有。为了方便说明对于Metrics的解释与释义我以表格的方式进行了整理供你随时查阅。
结合这份Shuffle的“体检报告”我们就能以量化的方式去掌握Shuffle过程的计算细节从而为调优提供更多的洞察与思路。
为了让你获得直观感受我还是举个例子说明。比方说我们观察到过滤之后的中签编号数据大小不足10MB7.4MB这时我们首先会想到对于这样的大表Join小表Spark SQL选择了SortMergeJoin策略是不合理的。
基于这样的判断我们完全可以让Spark SQL选择BroadcastHashJoin策略来提供更好的执行性能。至于调优的具体方法想必不用我多说你也早已心领神会要么用强制广播要么利用3.x版本提供的AQE特性。
你不妨结合本讲开头的代码去完成SortMergeJoin到BroadcastHashJoin策略转换的调优期待你在留言区分享你的调优结果。
Sort
接下来我们再来说说Sort。相比ExchangeSort的度量指标没那么多不过他们足以让我们一窥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 / RecordsShuffle Remote Reads等等。
这些Metrics我们在介绍SQL详情的时候已经详细说过了。另外Duration、GC Time、以及Peak Execution Memory这些Metrics的含义要么已经讲过要么过于简单、无需解释。因此对于这3个指标咱们也不再多着笔墨。
这里特别值得你关注的是SpillMemory和SpillDisk这两个指标。Spill也即溢出数据它指的是因内存数据结构PartitionedPairBuffer、AppendOnlyMap等等空间受限而腾挪出去的数据。SpillMemory表示的是这部分数据在内存中的存储大小而SpillDisk表示的是这些数据在磁盘中的大小。
因此用SpillMemory除以SpillDisk就可以得到“数据膨胀系数”的近似值我们把它记为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之后请你说一说都可以通过哪些途径来定位数据倾斜问题
欢迎你在留言区和我交流探讨,也欢迎推荐你把这一讲分享给有需要的朋友、同事。

View File

@ -0,0 +1,17 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
期末考试 “Spark性能调优”100分试卷等你来挑战
你好,我是吴磊。
《Spark性能调优实战》这门课程到这里就正式完结了。在这个过程中我看到了很多同学的留言和反馈非常感谢你一直以来的认真学习和支持
为了帮助你检验自己的学习效果,我特别给你准备了一套结课测试题。这套测试题共有 20 道题目包括10道单选题和10道多选题满分 100 分,核心考点都出自前面讲到的所有重要知识,希望可以帮助你进行一场自测。快点击下面按钮,开始测试吧!

View File

@ -0,0 +1,85 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 在时间面前,做一个笃定学习的人
你好,我是吴磊。
时间犹如白驹过隙,不知不觉,就到了要和你说再见的时候。当编辑对我说:“老师,这周要把结束语赶出来哟”,我忽然愣住了,觉得有些恍惚和不真实,并没有像往常那样脱口而出:“好嘞,没问题!”因为我似乎已经习惯了赶稿子,也习惯了回答评论区的问题,习惯了和大家互动,更习惯了在群里插科打诨……
但是,无论再怎么习惯,我们的专栏也在今天正式结束了。在这里,我想借着这最后的机会,由衷地感谢屏幕前的你、感谢在听音频的你。感谢你一直以来的陪伴和督促,感谢你参与评论区的讨论,感谢你及时地纠正专栏中的错误,更感谢你对于专栏的包容和理解。
同时我也希望在这个专栏中我们不仅能共同探讨快速、高效掌握Spark性能的方法还能把同样的学习技巧迁移到其他的领域。毕竟高效的学习效率永远可以让我们快人一步。
我们到底该如何学习?
说起学习这件事,我们听到最多的,就是“学习是一件反人类的事情”,或是“学习的过程就是成长,而成长本来就是痛苦的”。似乎一提到学习,我们就要随时准备“头悬梁,锥刺股”。那真是这样吗?
坦白地说关于学习的“痛苦学说”我不是很认同。原因很简单我们有很多的方法与技巧可以让学习这件事变得有趣而且事半功倍。那么对我来说3个提升学习效率最有效的方法分别是生产导向的思维方式、“235”的学习原则和“生活化联想”的学习技巧。
生产者思维
我们先来说说生产者思维。如果把形形色色的信息、知识、原理、理论看作是商品的话,那么毫无疑问,热爱学习的我们就是这些“商品”的消费者,我们花费大量的时间、精力和注意力,尝试从这些商品中获取价值。
然而,商品的消费是单向而又被动的,这就好比你从收音机或是电视中被动地收听、收看那些提前烧制好的节目。无论节目的设计与制作多么精良,作为消费者,我们能够消化和吸收的程度都是有限的。
这个时候,我们可以采用以产出内容为导向的学习方式,它会比被动式的学习方式效率更高。就拿技术博客来说,输出内容的过程,其实是帮你发现漏洞和不足的过程,写着写着发现卡住了、进行不下去了,往往意味着这部分知识点或是原理你掌握得不够牢固,需要回炉另造。因此,带着目的、有针对性地学习,往往会事半功倍。以输出为导向去获取输入,就是我们常说的生产者思维。
不过你可能会说:“就算生产者思维确实能够帮助我提升学习效率,可是我真的不知道,生产这件事该从哪里下手”。如果你已经很久没有动过笔了,那么不妨从做笔记开始。做笔记至少有两点收益:一来,它可以强化记忆,也就是俗话常说的“好记性不如烂笔头”;二来,笔记本身就是一种产品形态,或者说是产品的初级形态,它就是最原始的生产资料。
在我们有了生产资料的原始积累之后,生产的过程就会变得很轻松,只要对它们稍作加工,比如调整上下文排列、理顺逻辑关系、变换呈现形式,我们就可以“换着花样”地交付各式各样的产品,比如技术博客、公众号图文,甚至是极客时间上的专栏课程等等。这些产品形态,依然没有逃脱笔记的范畴,它们都是笔记的“变形”。
因此,关于生产这件事,如果你暂时还没有思绪,那么我强烈推荐你从做笔记下手。古人云:“读书破万卷,下笔如有神”。对于生产,我们也可以说“笔记做到家,输出如有神”。
“235”原则
“235”原则指的是要想充分掌握一项技能或是本领理论学习占两成交流讨论占三成动手实践占五成。按照“235”的指导原则讨论和实践要比理论学习更重要。
实践对于学习的贡献不言自明,“实践出真知”“实践是检验真理的唯一标准”,这些老生常谈的道理强调的都是实践的重要性。不过,在这三个学习要素当中,要说投入产出比最高的,当属交流讨论。为什么这么说呢?这就要说到学习这件事情的本质了。你可能会说:“学习自然是获取知识、习得技能、掌握本领。”但在我看来,学习的本质是一个不断做熵减、消除不确定性的过程。
熵减听上去过于玄乎我们来举个例子。假设有两个房间每个房间都有5个男生和5个女生男女比例都是11如果按照性别来统计的话那么这两个房间的信息熵都是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我在那里等你不见不散
天行健,君子以自强不息。持续学习,与君共勉。
最后的最后,我还为你准备了一份毕业调查问卷,题目不多,希望你能花两分钟的时间填一下。一起走过了这些时间,期待听到你对我和这个课程的反馈和建议!