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

View File

@@ -0,0 +1,114 @@
<audio id="audio" title="20 | RDD和DataFrame既生瑜何生亮" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/d3/b18a3ab97535d123ed3088231be686d3.mp3"></audio>
你好,我是吴磊。
从今天开始我们进入Spark SQL性能调优篇的学习。在这一篇中我会先带你学习Spark SQL已有的优化机制如Catalyst、Tungsten这些核心组件以及AQE、DPP等新特性。深入理解这些内置的优化机制会让你在开发应用之初就有一个比较高的起点。然后针对数据分析中的典型场景如数据关联我们再去深入探讨性能调优的方法和技巧。
今天这一讲我们先来说说RDD和DataFrame的渊源。这也是面试的时候面试官经常会问的。比如说“Spark 3.0大版本发布Spark SQL的优化占比将近50%而像PySpark、Mllib和Streaming的优化占比都不超过10%Graph的占比几乎可以忽略不计。这是否意味着Spark社区逐渐放弃了其他计算领域只专注于数据分析
[<img src="https://static001.geekbang.org/resource/image/47/75/479cd67e687a3a7cdc805b55b5bdef75.jpg" alt="">](https://databricks.com/blog/2020/06/18/introducing-apache-spark-3-0-now-available-in-databricks-runtime-7-0.html)
这个问题的标准答案是“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算子总结到了下面的表格里你可以看一看。
[<img src="https://static001.geekbang.org/resource/image/79/d3/790eaa488a4db5950a11da89643232d3.jpeg" alt="">](https://spark.apache.org/docs/latest/rdd-programming-guide.html)
表格中高亮显示的就是RDD转换和聚合算子它们都是高阶函数。高阶函数指的是形参包含函数的函数或是返回结果包含函数的函数。为了叙述方便我们把那些本身是高阶函数的RDD算子简称“高阶算子”。
**对于这些高阶算子开发者需要以Lambda函数的形式自行提供具体的计算逻辑。**以map为例我们需要明确对哪些字段做映射以什么规则映射。再以filter为例我们需要指明以什么条件在哪些字段上过滤。
但这样一来Spark只知道开发者要做map、filter但并不知道开发者打算怎么做map和filter。也就是说**在RDD的开发模式下Spark Core只知道“做什么”而不知道“怎么做”。**这会让Spark Core两眼一抹黑除了把Lambda函数用闭包的形式打发到Executors以外实在是没有什么额外的优化空间。
**对于Spark Core来说优化空间受限最主要的影响莫过于让应用的执行性能变得低下。**一个典型的例子就是相比Java或者ScalaPySpark实现的应用在执行性能上相差悬殊。原因在于在RDD的开发模式下即便是同一个应用不同语言实现的版本在运行时也会有着天壤之别。
<img src="https://static001.geekbang.org/resource/image/09/f0/095e9191b93b911c463ca88dba6755f0.jpg" alt="" title="不同语言的运行时计算过程">
当我们使用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结果数据的转换过程。
<img src="https://static001.geekbang.org/resource/image/1c/6f/1c0a5e8c1ccdc5eb6ecc29cc45d3f96f.jpg" alt="" title="AST语法树示意图">
在Spark中语法树还有个别名叫做“Unresolved Logical Plan”。它正是Catalyst优化过程的起点。之所以取名“Unresolved”是因为边上记录的关系表和数据列仅仅是一些字符串还没有和实际数据对应起来。举个例子Filter之后的那条边输出的数据列是joinKey和payLoad。这些字符串的来源是DataFrame的DSL查询Catalyst并不确定这些字段名是不是有效的更不知道每个字段都是什么类型。
因此,**Catalyst做的第一步优化就是结合DataFrame的Schema信息确认计划中的表名、字段名、字段类型与实际数据是否一致**。这个过程也叫做把“Unresolved Logical Plan”转换成“Analyzed Logical Plan”。
<img src="https://static001.geekbang.org/resource/image/f3/72/f3ffb5fc43ae3c9bca44c1f4f8b7e872.jpg" alt="" title="Spark SQL的端到端优化过程">
基于解析过后的“Analyzed Logical Plan”Catalyst才能继续做优化。利用启发式的规则和执行策略Catalyst最终把逻辑计划转换为可执行的物理计划。总之Catalyst的优化空间来源DataFrame的开发模式。
### Tungsten数据结构优化
说完Catalyst我接着再来说说Tungsten。在开发原则那一讲我们提到过Tungsten使用定制化的数据结构Unsafe Row来存储数据Unsafe Row的优点是存储效率高、GC效率高。Tungsten之所以能够设计这样的数据结构仰仗的也是DataFrame携带的Schema。Unsafe Row我们之前讲过这里我再带你简单回顾一下。
<img src="https://static001.geekbang.org/resource/image/75/23/75ab3ca00411e1aa3933f3b0b1b3de23.jpeg" alt="" title="结构化的二维表">
Tungsten是用二进制字节序列来存储每一条用户数据的因此在存储效率上完胜Java Object。比如说如果我们要存储上表中的数据用Java Object来存储会消耗100个字节数而使用Tungsten仅需要不到20个字节如下图所示。
<img src="https://static001.geekbang.org/resource/image/20/02/20230c764200cfde05dedec1cae6b702.jpg" alt="" title="二进制字节序列">
但是要想实现上图中的二进制序列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带来的性能福利。
<img src="https://static001.geekbang.org/resource/image/50/fc/505dbb1462dbc1f927fa1f4a2daabcfc.jpg" alt="" title="不同开发模式下的分布式执行过程">
最后我们再来回顾最开始提到的面试题“从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占比最大的根本原因。
## 每日一练
1. Java Object在对象存储上为什么会有比较大的开销JVM需要多少个字节才能存下字符串“abcd”
1. 在DataFrame的开发框架下 PySpark中还有哪些操作是“顽固分子”会导致计算与数据在JVM进程与Python进程之间频繁交互(提示参考RDD的局限性那些对Spark透明的计算逻辑Spark是没有优化空间的)
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,190 @@
<audio id="audio" title="21 | Catalyst逻辑计划你的SQL语句是怎么被优化的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c8/eb/c8cc811db682f755dc4ea73e993a0aeb.mp3"></audio>
你好,我是吴磊。
上一讲我们说Spark SQL已经取代Spark Core成为了新一代的内核优化引擎所有Spark子框架都能共享Spark SQL带来的性能红利所以在Spark历次发布的新版本中Spark SQL占比最大。因此Spark SQL的优化过程是我们必须要掌握的。
Spark SQL端到端的完整优化流程主要包括两个阶段Catalyst优化器和Tungsten。其中Catalyst优化器又包含逻辑优化和物理优化两个阶段。为了把开发者的查询优化到极致整个优化过程的运作机制设计得都很精密因此我会用三讲的时间带你详细探讨。
下图就是这个过程的完整图示,你可以先通过它对优化流程有一个整体的认知。然后随着我的讲解,逐渐去夯实其中的关键环节、重要步骤和核心知识点,在深入局部优化细节的同时,把握全局优化流程,做到既见树木、也见森林。
<img src="https://static001.geekbang.org/resource/image/f3/72/f3ffb5fc43ae3c9bca44c1f4f8b7e872.jpg" alt="" title="Spark SQL的优化过程">
今天这一讲我们先来说说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(&quot;name&quot;, &quot;age&quot;, &quot;userId&quot;)
.filter($&quot;age&quot; &lt; 30)
.filter($&quot;gender&quot;.isin(&quot;M&quot;))
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(&quot;price&quot;, &quot;volume&quot;, &quot;userId&quot;)
.join(users, Seq(&quot;userId&quot;), &quot;inner&quot;)
.groupBy(col(&quot;name&quot;), col(&quot;age&quot;)).agg(sum(col(&quot;price&quot;) * col(&quot;volume&quot;)).alias(&quot;revenue&quot;))
result.write.parquet(&quot;_&quot;)
```
代码示例如上图所示为了实现业务逻辑我们对过滤之后的用户表与交易表做内关联然后再按照用户分组去计算交易额。不难发现这个计算逻辑实际上就是星型数仓中典型的关联查询。为了叙述方便我们给这个关联查询起个名字小Q。小Q的计算需要两个输入源一个是交易表另一个是过滤之后的用户表。今天这一讲我们就去追随小Q看看它在Catalyst的逻辑优化阶段都会发生哪些变化。
<img src="https://static001.geekbang.org/resource/image/20/00/205a81e95f483ce66ed019b4120ec000.jpg" alt="" title="小Q的计算逻辑">
**Catalyst逻辑优化阶段分为两个环节逻辑计划解析和逻辑计划优化。在逻辑计划解析中Catalyst把“Unresolved Logical Plan”转换为“Analyzed Logical Plan”在逻辑计划优化中Catalyst基于一些既定的启发式规则Heuristics Based Rules把“Analyzed Logical Plan”转换为“Optimized Logical Plan”。**
<img src="https://static001.geekbang.org/resource/image/0e/a3/0efa02fd8eda5a69c794871ea77030a3.jpg" alt="" title="Catalyst逻辑优化阶段">
因为“Unresolved Logical Plan”是Catalyst优化的起点所以在进入Catalyst优化器之前小Q先是改头换面从代码中的查询语句摇身变成了“Unresolved Logical Plan”。
<img src="https://static001.geekbang.org/resource/image/ff/80/fff906004736005de9c83cbfc09d8380.png" alt="" title="小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等等。
<img src="https://static001.geekbang.org/resource/image/ac/7b/ac83f9d0cdab8655a5fffc9125a93f7b.png" alt="" title="小Q再变身Analyzed Logical Plan">
## 逻辑计划优化
对于现在的小Q来说如果我们不做任何优化直接把它转换为物理计划也可以。但是这种照搬开发者的计算步骤去制定物理计划的方式它的执行效率往往不是最优的。
为什么这么说呢在运行时Spark会先全量扫描Parquet格式的用户表然后遴选出userId、name、age、gender四个字段接着分别按照年龄和性别对数据进行过滤。
对于这样的执行计划来说最开始的全量扫描显然是一种浪费。原因主要有两方面一方面查询实际上只涉及4个字段并不需要email这一列数据另一方面字段age和gender上带有过滤条件我们完全可以利用这些过滤条件减少需要扫描的数据量。
由此可见,**对于同样一种计算逻辑,实现方式可以有多种,按照不同的顺序对算子做排列组合,我们就可以演化出不同的实现方式。最好的方式是,我们遵循“能省则省、能拖则拖”的开发原则,去选择所有实现方式中最优的那个**。
同样在面对这种“选择题”的时候Catalyst也有一套自己的“原则”和逻辑。因此生成“Analyzed Logical Plan”之后Catalyst并不会止步于此它会基于一套启发式的规则把“Analyzed Logical Plan”转换为“Optimized Logical Plan”。
<img src="https://static001.geekbang.org/resource/image/a0/29/a0391155f57085266a7703b0cb788329.jpg" alt="" title="逻辑优化环节">
那么问题来了Catalyst都有哪些既定的规则和逻辑呢基于这些规则Catalyst又是怎么做转换的呢别着急我们一个一个来解答咱们先来说说Catalyst的优化规则然后再去探讨逻辑计划的转换过程。
### Catalyst的优化规则
和Catalyst相比咱们总结出的开发原则简直就是小巫见大巫为什么这么说呢在新发布的Spark 3.0版本中Catalyst总共有81条优化规则Rules这81条规则会分成27组Batches其中有些规则会被收纳到多个分组里。因此如果不考虑规则的重复性27组算下来总共会有129个优化规则。
对于如此多的优化规则我们该怎么学呢实际上如果从优化效果的角度出发这些规则可以归纳到以下3个范畴
- **谓词下推Predicate Pushdown**
- **列剪裁Column Pruning**
- **常量替换 Constant Folding**
首先,我们来说说谓词下推谓词下推主要是围绕着查询中的过滤条件做文章。其中,**“谓词”指代的是像用户表上“age &lt; 30”这样的过滤条件“下推”指代的是把这些谓词沿着执行计划向下推到离数据源最近的地方从而在源头就减少数据扫描量**。换句话说,让这些谓词越接近数据源越好。
不过在下推之前Catalyst还会先对谓词本身做一些优化比如像OptimizeIn规则它会把“gender in M”优化成“gender = M也就是把谓词in替换成等值谓词。再比如CombineFilters规则它会把“age &lt; 30”和“gender = M”这两个谓词捏合成一个谓词“age != null AND gender != null AND age &lt;30 AND gender = M”。
完成谓词本身的优化之后Catalyst再用PushDownPredicte优化规则把谓词推到逻辑计划树最下面的数据源上。对于Parquet、ORC这类存储格式结合文件注脚Footer中的统计信息下推的谓词能够大幅减少数据扫描量降低磁盘I/O开销。
再来说说列剪裁。**列剪裁就是扫描数据源的时候,只读取那些与查询相关的字段。**以小Q为例用户表的Schema是userId、name、age、gender、email但是查询中压根就没有出现过email的引用因此Catalyst会使用 ColumnPruning规则把email这一列“剪掉”。经过这一步优化Spark在读取Parquet文件的时候就会跳过email这一列从而节省I/O开销。
不难发现,谓词下推与列剪裁的优化动机,其实和“能省则省”的原则一样。核心思想都是用尽一切办法,减少需要扫描和处理的数据量,降低后续计算的负载。
最后一类优化是常量替换它的逻辑比较简单。假设我们在年龄上加的过滤条件是“age &lt; 12 + 18”Catalyst会使用ConstantFolding规则自动帮我们把条件变成“age &lt; 30”。再比如我们在select语句中掺杂了一些常量表达式Catalyst也会自动地用表达式的结果进行替换。
到此为止咱们从功用和效果的角度探讨了Catalyst逻辑优化规则的3大范畴。你可能说“拢共就做了这么3件事至于兴师动众地制定81条规则吗”我们划分这3大范畴主要是为了叙述和理解上的方便。实际上对于开发者写出的五花八门、千奇百怪的查询语句正是因为Catalyst不断丰富的优化规则才让这些查询都能够享有不错的执行性能。如果没有这些优化规则的帮忙小Q的执行性能一定会惨不忍睹。
最终被Catalyst优化过后的小Q就从“Analyzed Logical Plan”转换为“Optimized Logical Plan”如下图所示。我们可以看到谓词下推和列剪裁都体现到了Optimized Logical Plan中。
<img src="https://static001.geekbang.org/resource/image/72/df/7223829502eeeca0fbfb721c6a3b61df.png" alt="" title="小Q再变身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 Plan”到“Optimized Logical Plan”的转换就是从一个TreeNode生成另一个TreeNode的过程。**Analyzed Logical Plan的根节点通过调用transformDown方法不停地把各种优化规则作用到整棵树直到把所有27组规则尝试完毕且树结构不再发生变化为止。这个时候生成的TreeNode就是Optimized Logical Plan。
为了把复杂问题简单化我们使用Expression也就是表达式来解释一下这个过程。因为Expression本身也继承自TreeNode所以明白了这个例子TreeNode之间的转换我们也就清楚了。
```
//Expression的转换
import org.apache.spark.sql.catalyst.expressions._
val myExpr: Expression = Multiply(Subtract(Literal(6), Literal(4)), Subtract(Literal(1), Literal(9)))
val transformed: Expression = myExpr transformDown {
case BinaryOperator(l, r) =&gt; Add(l, r)
case IntegerLiteral(i) if i &gt; 5 =&gt; Literal(1)
case IntegerLiteral(i) if i &lt; 5 =&gt; 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下面的示意图直观地展示了这个过程。
<img src="https://static001.geekbang.org/resource/image/ea/1f/ea21ec9387e55e94d763d9ee0c4a4b1f.jpg" alt="" title="自顶向下对执行计划进行转换">
从“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可以充分利用已有的缓存数据来优化。
## 每日一练
1. 既然Catalyst在逻辑优化阶段有81条优化规则我们还需要遵循“能省则省、能拖则拖”的开发原则吗
1. 你能说说Spark为什么用偏函数而不是普通函数来定义Catalyst的优化规则吗
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,180 @@
<audio id="audio" title="22 | Catalyst物理计划你的SQL语句是怎么被优化的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/c8/347036fd05bda898814e9448ab70bec8.mp3"></audio>
你好,我是吴磊。
上一讲我们说了Catalyst优化器的逻辑优化过程包含两个环节逻辑计划解析和逻辑计划优化。逻辑优化的最终目的就是要把Unresolved Logical Plan从次优的Analyzed Logical Plan最终变身为执行高效的Optimized Logical Plan。
但是逻辑优化的每一步仅仅是从逻辑上表明Spark SQL需要“做什么”并没有从执行层面说明具体该“怎么做”。因此为了把逻辑计划交付执行Catalyst还需要把Optimized Logical Plan转换为物理计划。物理计划比逻辑计划更具体它明确交代了Spark SQL的每一步具体该怎么执行。
<img src="https://static001.geekbang.org/resource/image/53/fd/534dd788609386c14d9e977866301dfd.jpg" alt="" title="物理计划阶段">
今天这一讲我们继续追随小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(&quot;name&quot;, &quot;age&quot;, &quot;userId&quot;)
.filter($&quot;age&quot; &lt; 30)
.filter($&quot;gender&quot;.isin(&quot;M&quot;))
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(&quot;price&quot;, &quot;volume&quot;, &quot;userId&quot;)
.join(users, Seq(&quot;userId&quot;), &quot;inner&quot;)
.groupBy(col(&quot;name&quot;), col(&quot;age&quot;)).agg(sum(col(&quot;price&quot;) * col(&quot;volume&quot;)).alias(&quot;revenue&quot;))
result.write.parquet(&quot;_&quot;)
```
两表关联的查询语句经过转换之后得到的Optimized Logical Plan如下图所示。注意在逻辑计划的根节点出现了“Join Inner”字样Catalyst优化器明确了这一步需要做内关联。但是怎么做内关联使用哪种Join策略来进行关联Catalyst并没有交代清楚。因此逻辑计划本身不具备可操作性。
<img src="https://static001.geekbang.org/resource/image/72/df/7223829502eeeca0fbfb721c6a3b61df.png" alt="" title="小Q变身Optimized Logical Plan">
**为了让查询计划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类优化策略。
<img src="https://static001.geekbang.org/resource/image/51/56/51ca111dfb9ebd60e2443c86e9b0cb56.jpeg" alt="" title="Catalyst物理优化阶段的14个优化策略">
所有优化策略在转换方式上都大同小异都是使用基于模式匹配的偏函数Partial Functions把逻辑计划中的操作符平行映射为Spark Plan中的物理算子。比如BasicOperators策略直接把Project、Filter、Sort等逻辑操作符平行地映射为物理操作符。其他策略的优化过程也类似因此在优化Spark Plan这一环节咱们只要抓住一个“典型”策略掌握它的转换过程即可。
那我们该抓谁做“典型”呢?我觉得,**这个“典型”至少要满足两个标准:一,它要在我们的应用场景中非常普遍;二,它的取舍对于执行性能的影响最为关键。**以这两个标准去遴选上面的8类策略我们分分钟就能锁定JoinSelection。接下来我们就以JoinSelection为例详细讲解这一环节的优化过程。
如果用一句话来概括JoinSelection的优化过程就是结合多方面的信息来决定在物理优化阶段采用哪种Join策略。那么问题来了Catalyst都有哪些Join策略
### Catalyst都有哪些Join策略
结合Joins的实现机制和数据的分发方式Catalyst在运行时总共支持5种Join策略分别是Broadcast Hash JoinBHJ、Shuffle Sort Merge JoinSMJ、Shuffle Hash JoinSHJ、Broadcast Nested Loop JoinBNLJ和Shuffle Cartesian Product JoinCPJ
<img src="https://static001.geekbang.org/resource/image/39/fb/39642808b292abb0b5b37ea69bfb19fb.jpeg" alt="" title="5种Join策略及其含义">
通过上表中5种Join策略的含义我们知道它们是来自2种数据分发方式广播和Shuffle与3种Join实现机制Hash Joins、Sort Merge Joins和Nested Loop Joins的排列组合。那么在JoinSelection的优化过程中Catalyst会基于什么逻辑优先选择哪种Join策略呢
<img src="https://static001.geekbang.org/resource/image/e9/48/e9bf1720ac13289a9e49e0f33a334548.jpg" alt="" title="数据分发方式与Join实现机制的排列组合">
### JoinSelection如何决定选择哪一种Join策略
逻辑其实很简单,**Catalyst总会尝试优先选择执行效率最高的策略。**具体来说在选择join策略的时候JoinSelection会先判断当前查询是否满足BHJ所要求的先决条件如果满足就立即选中BHJ如果不满足就继续判断当前查询是否满足SMJ的先决条件。以此类推直到最终选无可选用CPJ来兜底。
那么问题来了这5种Join策略都需要满足哪些先决条件呢换句话说JoinSelection做决策时都要依赖哪些信息呢
总的来说,**这些信息分为两大类第一类是“条件型”信息用来判决5大Join策略的先决条件。第二类是“指令型”信息也就是开发者提供的Join Hints。**
我们先来说“条件型”信息它包含两种。第一种是Join类型也就是是否等值、连接形式等这种信息的来源是查询语句本身。第二种是内表尺寸这些信息的来源就比较广泛了可以是Hive表之上的ANALYZE TABLE语句也可以是Spark对于Parquet、ORC、CSV等源文件的尺寸预估甚至是来自AQE的动态统计信息。
5大Join策略对于这些信息的要求我都整理到了下面的表格里你可以看一看。
<img src="https://static001.geekbang.org/resource/image/99/aa/99f3e75bbc090dfb162c0b241515ddaa.jpeg" alt="" title="5种Join策略的先决条件">
指令型信息也就是Join Hints它的种类非常丰富它允许我们把个人意志凌驾于Spark SQL之上。比如说如果我们对小Q的查询语句做了如下的调整JoinSelection在做Join策略选择的时候就会优先尊重我们的意愿跳过SMJ去选择排序更低的SHJ。具体的代码示例如下
```
val result = txDF.select(&quot;price&quot;, &quot;volume&quot;, &quot;userId&quot;)
.join(users.hint(&quot;shuffle_hash&quot;), Seq(&quot;userId&quot;), &quot;inner&quot;)
.groupBy(col(&quot;name&quot;), col(&quot;age&quot;)).agg(sum(col(&quot;price&quot;) *
col(&quot;volume&quot;)).alias(&quot;revenue&quot;))
```
熟悉了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。
<img src="https://static001.geekbang.org/resource/image/73/a5/7312de5cf3yy06d6bc252c5923f163a5.png" alt="" title="小Q再变身Spark Plan">
现在我们知道了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物理优化阶段的流程图来详细讲讲。
<img src="https://static001.geekbang.org/resource/image/53/fd/534dd788609386c14d9e977866301dfd.jpg" alt="" title="物理计划阶段">
从上图中我们可以看到从Spark Plan到Physical Plan的转换需要几组叫做Preparation Rules的规则。这些规则坚守最后一班岗负责生成Physical Plan。那么这些规则都是什么它们都做了哪些事情呢我们一起来看一下。
<img src="https://static001.geekbang.org/resource/image/18/f7/187a85d53d585c5b3656353e3304fdf7.jpeg" alt="" title="Preparation Rules">
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个属性用来分别描述数据输入和输出的分布状态。
<img src="https://static001.geekbang.org/resource/image/f8/yf/f8cae1364372a2a8c034a5ab00850yyf.jpeg" alt="" title="描述输入、输出要求的4个属性">
EnsureRequirements规则要求子节点的输出数据要满足父节点的输入要求。这又怎么理解呢
<img src="https://static001.geekbang.org/resource/image/05/00/05467eecb3c983d4fc4a3db8a0e7e600.jpg" alt="" title="Project没有满足SortMergeJoin的Requirements">
我们以小Q的Spark Plan树形结构图为例可以看到图中左右两个分支分别表示扫描和处理users表和transactions表。在树的最顶端根节点SortMergeJoin有两个Project子节点它们分别用来表示users表和transactions表上的投影数据。这两个Project的outputPartitioning属性和outputOrdering属性分别是Unknow和None。因此它们输出的数据没有按照任何列进行Shuffle或是排序。
但是SortMergeJoin对于输入数据的要求很明确按照userId分成200个分区且排好序而这两个Project子节点的输出显然并没有满足父节点SortMergeJoin的要求。这个时候**EnsureRequirements规则就要介入了它通过添加必要的操作符如Shuffle和排序来保证SortMergeJoin节点对于输入数据的要求一定要得到满足**,如下图所示。
<img src="https://static001.geekbang.org/resource/image/a8/15/a8c45d1d6ecb6a120205252e21b1b715.jpg" alt="" title="EnsureRequirements规则添加Exchange和Sort操作">
在两个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又发生了哪些变化。
<img src="https://static001.geekbang.org/resource/image/65/33/656e29b2d25549488087fc1a4af8cd33.png" alt="" title="小Q再变身Physical Plan">
首先我们看到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 &gt; SMJ &gt; SHJ &gt; BNLJ &gt; CPJ的顺序依次判断查询语句是否满足每一种Join策略的先决条件进行“择优录取”。
如果开发者不满足于JoinSelection默认的选择顺序也就是BHJ &gt; SMJ &gt; SHJ &gt; BNLJ &gt; 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,155 @@
<audio id="audio" title="23 | 钨丝计划Tungsten给开发者带来了哪些福报" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6e/f9/6e560587b8b4b997a7635d740b8035f9.mp3"></audio>
你好,我是吴磊。
通过前两讲的学习我们知道在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讲](https://time.geekbang.org/column/article/357342)说过,你可以去看看。
<img src="https://static001.geekbang.org/resource/image/51/2c/516c0e41e6757193533c8dfa33f9912c.jpg" alt="" title="二进制字节数组">
那么这种存储方式有什么优点呢我们不妨用逆向思维来思考这个问题如果采用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的压力就会陡然上升。
<img src="https://static001.geekbang.org/resource/image/fd/69/fd00cf1364c800659a7d492cd25c6569.jpg" alt="" title="JVM存储数据条目">
我们反过来再看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对象就是关键了。接下来我们就重点解释这个环节。
<img src="https://static001.geekbang.org/resource/image/90/47/904dc1d1846dddffe363e834ce892347.jpg" alt="" title="堆外、堆内不同的寻址方式">
如上图所示Tungsten使用一种叫做页表Page Table的数据结构来记录从Object引用到JVM对象地址的映射。页表中记录的是一个又一个内存页Memory Page内存页实际上就是一个JVM对象而已。只要给定64位的Object引用Tungsten就能通过页表轻松拿到JVM对象地址从而完成寻址。
那么Tungsten使用这种方式来管理内存有什么收益呢我们不妨以常用的HashMap数据结构为例来对比Java标准库java.util.HashMap和Tungsten模式下的HashMap。
<img src="https://static001.geekbang.org/resource/image/1b/84/1bc7f9553dfe7yyb51a641f51093c284.jpg" alt="" title="java.util.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命中率。
<img src="https://static001.geekbang.org/resource/image/4e/df/4e28d831e2b6f368f63907b82c5493df.jpg" alt="" title="Tungsten HashMap">
那么针对以上几个弊端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三个函数的嵌套并没有真正融合为一个函数。
<img src="https://static001.geekbang.org/resource/image/03/03/03052d8fc98dcf1740ec4a7c29234403.jpg" alt="" title="内存计算的第二层含义">
**WSCG指的是基于同一Stage内操作符之间的调用关系生成一份“手写代码”真正把所有计算融合为一个统一的函数**
### 什么是火山迭代模型?
那么,我们真的有必要把三个函数体融合成一个函数,甚至生成一份“手写代码”吗?迭代器嵌套的函数调用难道还不够吗?坦白说,迭代器嵌套还真不够。原因在于,迭代器嵌套的计算模式会涉及两种操作,**一个是内存数据的随机存取另一个是虚函数调用next**。这两种操作都会降低CPU的缓存命中率影响CPU的工作效率。这么说比较抽象我们来举个小例子。
<img src="https://static001.geekbang.org/resource/image/f9/e6/f9350a3f71d20a11391a1101bf392be6.jpg" alt="" title="SQL查询与语法树">
假设现在有一张市民表我们要从中统计在北京的人数。对应的语法树非常简单从左到右分别是数据扫描、过滤、投影和聚合。语法树先是经过“左脑”Catalyst优化器转换为Physical Plan然后交付执行。Tungsten出现以前Spark在运行时采用火山迭代模型来执行计算。这里咱们需要先简单地介绍一下火山迭代模型Volcano Iteration Model以下简称VI模型
VI模型这种计算模式依托AST语法树对所有操作符如过滤、投影的计算进行了统一封装所有操作符都要实现VI模型的迭代器抽象。简单来说就是所有操作符都需要实现hasNext和next方法。因此VI模型非常灵活、扩展能力很强任何一个算子只要实现了迭代器抽象都可以加入到语法树当中参与计算。另外为了方便操作符之间的数据交换VI模型对所有操作符的输出也做了统一的封装。
那么如果上面的查询使用VI模型去执行计算的话都需要经过哪些步骤呢对于数据源中的每条数据条目语法树当中的每个操作符都需要完成如下步骤
1. 从内存中读取父操作符的输出结果作为输入数据
1. 调用hasNext、next方法以操作符逻辑处理数据如过滤、投影、聚合等等
1. 将处理后的结果以统一的标准形式输出到内存,供下游算子消费
因此任意两个操作符之间的交互都会涉及我们最开始说的两个步骤也就是内存数据的随机存取和虚函数调用而它们正是CPU有效利用率低下的始作俑者。
### WSCG的优势是什么
Tungsten引入WSCG机制正是为了消除VI模型引入的计算开销。这是怎么做到的呢接下来咱们还是以市民表的查询为例先来直观地感受一下WSCG的优势。
<img src="https://static001.geekbang.org/resource/image/53/e7/5389b8bd80748dcc706b1c3c95ddbce7.jpg" alt="" title="手写代码示例">
对于刚刚的查询语句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。
<img src="https://static001.geekbang.org/resource/image/f9/fa/f97a63a915d6e093b622002fba4010fa.jpg" alt="" title="语法树的第一个Stage">
上一讲中我们说了Spark Plan在转换成Physical Plan之前会应用一系列的Preparation Rules。这其中很重要的一环就是CollapseCodegenStages规则它的作用正是尝试为每一个Stages生成“手写代码”。
总的来说,手写代码的生成过程分为两个步骤:
- **从父节点到子节点递归调用doProduce生成代码框架**
- **从子节点到父节点递归调用doConsume向框架填充每一个操作符的运算逻辑**
这么说比较抽象咱们以上面的第一个Stage为例来直观地看看这个代码生成的过程。
<img src="https://static001.geekbang.org/resource/image/68/2d/68cfc6aec121511303ccec179bd4a32d.jpg" alt="" title="WSCG时序图">
首先在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的缓存命中率和工作效率都会得到大幅提升。
## 每日一练
1. 针对排序操作你认为Tungsten在数据结构方面有哪些改进呢
1. 你认为表达式代码生成Expression Codegen和全阶段代码生成Whole Stage Codegen有什么区别和联系呢
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,148 @@
<audio id="audio" title="24 | Spark 3.0AQE的3个特性怎么才能用好" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2a/bf/2af3e348a38ac4a50180f0303e1aa7bf.mp3"></audio>
你好,我是吴磊。
目前距离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从运行时获取统计信息在条件允许的情况下优化决策会分别作用到逻辑计划和物理计划。**
<img src="https://static001.geekbang.org/resource/image/f3/72/f3ffb5fc43ae3c9bca44c1f4f8b7e872.jpg" alt="" title="AQE在Spark SQL中的位置与作用">
**AQE既定的规则和策略主要有4个分为1个逻辑优化规则和3个物理优化策略。**我把这些规则与策略和相应的AQE特性以及每个特性仰仗的统计信息都汇总到了如下的表格中你可以看一看。
<img src="https://static001.geekbang.org/resource/image/1c/d5/1cfef782e6dfecce3c9252c6181388d5.jpeg" alt="">
## 如何用好AQE
那么AQE是如何根据Map阶段的统计信息以及这4个规则与策略来动态地调整和修正尚未执行的逻辑计划和物理计划的呢这就要提到AQE的三大特性也就是Join策略调整、自动分区合并以及自动倾斜处理我们需要借助它们去分析AQE动态优化的过程。它们的基本概念我们在[第9讲](https://time.geekbang.org/column/article/357342)说过,这里我再带你简单回顾一下。
- 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才能做出决策。
<img src="https://static001.geekbang.org/resource/image/c3/b3/c3d611282c56687342d3ea459242bdb3.jpg" alt="" title="Sort Merge Join执行计划左侧是事实表的执行分支右侧是维度表的执行分支">
你可能会说“根本不需要大表做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分区合并后分区数不能低于该值。
<img src="https://static001.geekbang.org/resource/image/da/4f/dae9dc8b90c2d5e0cf77180ac056a94f.jpg" alt="">
除此之外我们还要注意在Shuffle Map阶段完成之后AQE优化机制被触发CoalesceShufflePartitions策略“无条件”地被添加到新的物理计划中。读取配置项、计算目标分区大小、依序合并相邻分区这些计算逻辑在Tungsten WSCG的作用下融合进“手写代码”于Reduce阶段执行。
### 自动倾斜处理
与自动分区合并相反自动倾斜处理的操作是“拆”。在Reduce阶段当Reduce Task所需处理的分区尺寸大于一定阈值时利用OptimizeSkewedJoin策略AQE会把大分区拆成多个小分区。倾斜分区和拆分粒度由以下这些配置项决定。关于它们的含义与作用我们在[第10讲](https://time.geekbang.org/column/article/357342)说过,你可以再翻回去看一看。
- 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的计算能力依然是整个作业的瓶颈这一点并没有因为分区拆分而得到实质性的缓解。
<img src="https://static001.geekbang.org/resource/image/f4/72/f4fe3149112466174bdefcc0ee573d72.jpg" alt="" title="Executors之间的负载平衡还有待优化">
另外在数据关联的场景中对于参与Join的两张表我们暂且把它们记做数据表1和数据表2如果表1存在数据倾斜表2不倾斜那在关联的过程中AQE除了对表1做拆分之外还需要对表2对应的数据分区做复制来保证关联关系不被破坏。
<img src="https://static001.geekbang.org/resource/image/33/a9/33a112480b1c1bf8b21d26412a7857a9.jpg" alt="" title="数据关联场景中的自动倾斜处理">
在这样的运行机制下如果两张表都存在数据倾斜怎么办这个时候事情就开始变得逐渐复杂起来了。对于上图中的表1和表2我们假设表1还是拆出来两个分区表2因为倾斜也拆出来两个分区。这个时候为了不破坏逻辑上的关联关系表1、表2拆分出来的分区还要各自复制出一份如下图所示。
<img src="https://static001.geekbang.org/resource/image/e7/c8/e7b8012644228f3e3f7daa60c225bdc8.jpg" alt="" title="两边倾斜">
如果现在问题变得更复杂了左表拆出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的自动化机制与手工处理倾斜之间的利害得失。
## 每日一练
1. 我们知道AQE依赖的统计信息来源于Shuffle Map阶段输出的中间文件。你觉得在运行时AQE还有其他渠道可以获得同样的统计信息吗
1. AQE的自动倾斜处理机制只能以Task为粒度来平衡工作负载如果让你重新实现这个机制你有什么更好的办法能让AQE以Executors为粒度做到负载均衡吗
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,118 @@
<audio id="audio" title="25 | Spark 3.0DPP特性该怎么用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/7d/c0a38cc0269edd30330ee25f7985a87d.mp3"></audio>
你好,我是吴磊。
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那几讲学到的知识我们很快就能画出它的逻辑执行计划。
<img src="https://static001.geekbang.org/resource/image/b2/5b/b200746c2c4e462b96f9a2031b7f285b.jpg" alt="" title="逻辑计划">
由于查询语句中事实表上没有过滤条件因此在执行计划的左侧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”的子目录。
<img src="https://static001.geekbang.org/resource/image/ee/d9/ee84e71580dc5fc61d0a542fdfca57d9.jpg" alt="" title="谓词下推与分区剪裁">
通过与谓词下推作对比我们可以直观地感受分区剪裁的威力。如图所示上下两行分别表示用户表在不做分区和做分区的情况下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是怎么做到这一点的呢它背后的逻辑是什么为了方便你理解我们还用刚刚的例子来解释。
<img src="https://static001.geekbang.org/resource/image/a6/b2/a683004565a3dcc1abb72922319d67b2.jpg" alt="" title="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。
<img src="https://static001.geekbang.org/resource/image/6f/fb/6f7803451b72e07c6cf2d3e1cae583fb.jpg" alt="" title="DPP的物理计划">
哈希表构建完毕之后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
## 每日一练
1. 如果让你重写DPP实现机制你有可能把广播阈值的限制去掉吗提示放弃使用Broadcast Hash Join的关联方式但仍然用广播变量来做分区剪裁。
1. 要让事实表拿到满足条件的Join Key列表除了使用广播变量之外你觉得还有其他的方法和途径吗
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,165 @@
<audio id="audio" title="26 | Join Hints指南不同场景下如何选择Join策略" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f7/74/f7a89165651427b15236ae4f70449a74.mp3"></audio>
你好,我是吴磊。
在数据分析领域数据关联可以说是最常见的计算场景了。因为使用的频率很高所以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如果满足就把两边的记录拼接在一起然后对外输出。
<img src="https://static001.geekbang.org/resource/image/be/13/be0774ffca24f9c20caa2ef6bd88d013.jpg" alt="" title="Nested Loop Join示意图">
在这个过程中外层的for循环负责遍历外表中的每一条数据如图中的步骤1所示。而对于外表中的每一条数据记录内层的for循环会逐条扫描内表的所有记录依次判断记录的Join Key是否满足关联条件如步骤2所示。假设外表有M行数据内表有N行数据那么**NLJ算法的计算复杂度是O(M * N)**。不得不说尽管NLJ实现方式简单而又直接但它的执行效率实在让人不敢恭维。
### SMJ的工作原理
正是因为NLJ极低的执行效率所以在它推出之后没多久之后就有人用排序、归并的算法代替NLJ实现了数据关联这种算法就是SMJ。**SMJ的思路是先排序、再归并。**具体来说就是参与Join的两张表先分别按照Join Key做升序排序。然后SMJ会使用两个独立的游标对排好序的两张表完成归并关联。
<img src="https://static001.geekbang.org/resource/image/e2/b2/e2a8f8d1b2572ff456fa83a3f25ccbb2.jpg" alt="" title="Sort Merge Join示意图">
SMJ刚开始工作的时候内外表的游标都会先锚定在两张表的第一条记录上然后再对比游标所在记录的Join Key。对比结果以及后续操作主要分为3种情况
1. 外表Join Key等于内表Join Key满足关联条件把两边的数据记录拼接并输出然后把外表的游标滑动到下一条记录
1. 外表Join Key小于内表Join Key不满足关联条件把外表的游标滑动到下一条记录
1. 外表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。
<img src="https://static001.geekbang.org/resource/image/5c/e4/5c81d814591eba9d08e6a3174ffe22e4.jpg" alt="">
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进程从而完成关联计算如下图所示。
<img src="https://static001.geekbang.org/resource/image/b1/28/b1b2a574eb7ef33e2315f547ecdc0328.jpg" alt="">
如果采用广播机制的话情况会大有不同。在这种情况下Spark只需要把内表基表封装到广播变量然后在全网进行分发。由于广播变量中包含了内表的**全量数据**,因此体量较大的外表只要“待在原地、保持不动”,就能轻松地完成关联计算,如下图所示。
<img src="https://static001.geekbang.org/resource/image/b3/2a/b3c5ab392c2303bf7923488623b4022a.jpg" alt="">
不难发现结合Shuffle、广播这两种网络分发方式和NLJ、SMJ、HJ这3种计算方式对于分布式环境下的数据关联我们就能组合出6种Join策略如下图所示。
<img src="https://static001.geekbang.org/resource/image/e9/48/e9bf1720ac13289a9e49e0f33a334548.jpg" alt="">
这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 &gt; SMJ &gt; 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 &gt; t2.beginDate and t1.date &lt;= t2.endDate**,其中的关联关系是依靠不等式连接在一起的。
**由于不等值Join只能使用NLJ来实现因此Spark SQL可选的Join策略只剩下BNLJ和CPJ。**在同一种计算模式下相比Shuffle广播的网络开销更小。显然在两种策略的选择上Spark SQL一定会按照BNLJ &gt; CPJ的顺序进行尝试。当然BNLJ生效的前提自然是内表小到可以放进广播变量。如果这个条件不成立那么Spark SQL只好委曲求全使用笨重的CPJ策略去完成关联计算。
## 开发者能做些什么?
最后我们再来聊聊面对上述的5种Join策略开发者还能做些什么呢通过上面的分析我们不难发现Spark SQL对于这些策略的取舍也基于一些既定的规则。所谓计划赶不上变化预置的规则自然很难覆盖多样且变化无常的计算场景。因此当我们掌握了不同Join策略的工作原理结合我们对于业务和数据的深刻理解完全可以自行决定应该选择哪种Join策略。
<img src="https://static001.geekbang.org/resource/image/94/b8/9436f0f9352ffa381b238be57d2ecdb8.jpeg" alt="">
在最新发布的3.0版本中Spark为开发者提供了多样化的Join Hints允许你把专家经验凌驾于Spark SQL的选择逻辑之上。**在满足前提条件的情况下如等值条件、连接类型、表大小等等Spark会优先尊重开发者的意愿去选取开发者通过Join Hints指定的Join策略。**关于Spark 3.0支持的Join Hints关键字以及对应的适用场景我把它们总结到了如上的表格中你可以直接拿来参考。
简单来说你可以使用两种方式来指定Join Hints一种是通过SQL结构化查询语句另一种是使用DataFrame的DSL语言都很方便。至于更全面的讲解你可以去[第13讲](https://time.geekbang.org/column/article/360837)看看,这里我就不多说了。
## 小结
这一讲我们从数据关联的实现原理到Spark SQL不同Join策略的适用场景掌握这些关键知识点对于数据关联场景中的性能调优至关重要。
首先你需要掌握3种Join实现机制的工作原理。为了方便你对比我把它们总结在了下面的表格里。
<img src="https://static001.geekbang.org/resource/image/86/bc/86bb13a5b7b96da4f5128df8b54b96bc.jpeg" alt="">
掌握了3种关联机制的实现原理你就能更好地理解Spark SQL的Join策略。结合数据的网络分发方式Shuffle和广播Spark SQL支持5种Join策略按照执行效率排序就是BHJ &gt; SHJ &gt; SMJ &gt; BNLJ &gt; CPJ。同样为了方便对比你也可以直接看下面的表格。
<img src="https://static001.geekbang.org/resource/image/7b/9e/7be7f01b383463f804a2db74a68d5e9e.jpeg" alt="">
最后当你掌握了不同Join策略的工作原理结合对于业务和数据的深刻理解实际上你可以自行决定应该选择哪种Join策略不必完全依赖Spark SQL的判断。
Spark为开发者提供了多样化的Join Hints允许你把专家经验凌驾于Spark SQL的选择逻辑之上。比如当你确信外表比内表大得多而且内表数据分布均匀使用SHJ远比默认的SMJ效率高得多的时候你就可以通过指定Join Hints来强制Spark SQL按照你的意愿去选择Join策略。
## 每日一练
1. 如果关联的场景是事实表Join事实表你觉得我们今天讲的Sort Merge Join实现方式还适用吗如果让你来设计算法的实现步骤你会怎么做
1. 你觉得不等值Join可以强行用Sort Merge Join和Hash Join两种机制来实现吗为什么
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,211 @@
<audio id="audio" title="27 | 大表Join小表广播变量容不下小表怎么办" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/24/c1fdd889257ce9275a0b65b601604924.mp3"></audio>
你好,我是吴磊。
在数据分析领域大表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岁女性的访问量等等。
我们知道,流量预测本身是个时序问题,它和股价预测类似,都是基于历史、去预测未来。在我们的案例中,为了预测上百万种不同的流量,咱们得先为每种流量生成时序序列,然后再把这些时序序列喂给机器学习算法进行模型训练。
统计流量的数据源是线上的访问日志,它记录了哪类用户在什么时间访问了哪些站点。要知道,我们要构建的,是以小时为单位的时序序列,但由于流量的切割粒度非常细致,因此有些种类的流量不是每个小时都有访问量的,如下图所示。
<img src="https://static001.geekbang.org/resource/image/7e/9f/7e8fc62ca38cb1a0a557b6b7eb1af99f.jpg" alt="" title="某种流量在过去24小时的记录情况">
我们可以看到在过去的24小时中某种流量仅在20-24点这5个时段有数据记录其他时段无记录也就是流量为零。在这种情况下我们就需要用“零”去补齐缺失时段的序列值。那么我们该怎么补呢
**因为业务需求是填补缺失值,所以在实现层面,我们不妨先构建出完整的全零序列,然后以系统、用户和时间这些维度组合为粒度,用统计流量去替换全零序列中相应位置的“零流量”。**这个思路描述起来比较复杂,用图来理解会更直观、更轻松一些。
<img src="https://static001.geekbang.org/resource/image/90/fe/90d03196d4c71c133344f913e4f69dfe.jpg" alt="">
首先我们生成一张全零流量表如图中左侧的“负样本表”所示。这张表的主键是划分流量种类的各种维度如性别、年龄、平台、站点、小时时段等等。表的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呢
<img src="https://static001.geekbang.org/resource/image/1c/69/1c93475b8d2d5d18d6b49fb7a258db69.jpg" alt="">
我们完全可以基于现有的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讲](https://time.geekbang.org/column/article/360837))。
案例1说到这里其实已经基本解决了不过这里还有一个小细节需要我们特别注意。**案例1优化的关键在于先用Hash Key取代Join Keys再清除内表冗余数据。Hash Key实际上是Join Keys拼接之后的哈希值。既然存在哈希运算我们就必须要考虑哈希冲突的问题。**
哈希冲突我们都很熟悉它指的就是不同的数据源经过哈希运算之后得到的哈希值相同。在案例1当中如果我们为了优化引入的哈希计算出现了哈希冲突就会破坏原有的关联关系。比如本来两个不相等的Join Keys因为哈希值恰巧相同而被关联到了一起。显然这不是我们想要的结果。
消除哈希冲突隐患的方法其实很多比如“二次哈希”也就是我们用两种哈希算法来生成Hash Key数据列。两条不同的数据记录在两种不同哈希算法运算下的结果完全相同这种概率几乎为零。
<img src="https://static001.geekbang.org/resource/image/bb/bf/bb79544467e98f9d2b6f13a437bb2dbf.jpg" alt="">
## 案例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. 对于案例1我们的核心思路是用哈希值来替代超长的Join Keys除了用哈希值以外你觉得还有其他的思路或是办法去用较短的字符串来取代超长的Join Keys吗
1. 对于案例2利用AQE Join策略调整和DDP机制的关键是确保过滤后的维表小于广播阈值。你能说说都有哪些方法可以用来计算过滤后的维表大小吗
1. 对于案例3假设20GB的小表存在数据倾斜强行把SMJ转化为SHJ会抛OOM异常。这个时候你认为还有可能继续优化吗
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,175 @@
<audio id="audio" title="28 | 大表Join大表什么是“分而治之”的调优思路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/77/16c685ef1db790239b965a861c080277.mp3"></audio>
你好,我是吴磊。
上一讲我们探讨了“大表Join小表”场景的调优思路和应对方法。那么除了大表Join小表的场景数据分析领域有没有“大表Join大表”的场景呢确实是有的它指的是参与Join的两张体量较大的事实表尺寸相差在3倍以内且全部无法放进广播变量。
但是通常来说在数据分析领域用一张大表去关联另一张大表这种做法在业内是极其不推荐的。甚至毫不客气地说“大表Join大表”是冒天下之大不韪犯了数据分析的大忌。如果非要用“大表Join大表”才能实现业务逻辑、完成数据分析这说明数据仓库在设计之初开发者考虑得不够完善、看得不够远。
不过你可能会说“我刚入职的时候公司的数仓就已经定型了这又不是我的锅我也只能随圆就方。”为了应对这种情况今天这一讲我们就来说说当你不得不面对“大表Join大表”的时候还有哪些调优思路和技巧。
要应对“大表Join大表”的计算场景我们主要有两种调优思路。**一种叫做“分而治之”,另一种我把它统称为“负隅顽抗”。**今天这一讲,我们先来说说“分而治之”,“负隅顽抗”我们留到下一讲再去展开。
值得一提的是即便你不需要去应对“大表Join大表”这块烫手的山芋“分而治之”与“负隅顽抗”所涉及的调优思路与方法也非常值得我们花时间去深入了解因为这些思路与方法的可迁移性非常强学习过后你会发现它们完全可以拿来去应对其他的应用场景。
话不多说,我们直接开始今天的课程吧!
## 如何理解“分而治之”?
“分而治之”的调优思路是把“大表Join大表”降级为“大表Join小表”然后使用上一讲中“大表Join小表”的调优方法来解决性能问题。它的核心思想是**先把一个复杂任务拆解成多个简单任务,再合并多个简单任务的计算结果**。那么“大表Join大表”的场景是如何应用“分而治之”的计算思想的呢
首先我们要根据两张表的尺寸大小区分出外表和内表。一般来说内表是尺寸较小的那一方。然后我们人为地在内表上添加过滤条件把内表划分为多个不重复的完整子集。接着我们让外表依次与这些子集做关联得到部分计算结果。最后再用Union操作把所有的部分结果合并到一起得到完整的计算结果这就是端到端的关联计算。整个“分而治之”的计算过程如下
<img src="https://static001.geekbang.org/resource/image/b7/36/b7f69a554c2e5745625ea1aa969e0136.jpg" alt="" title="大表Join大表的“分而治之”">
## 如何保证内表拆分的粒度足够细?
采用“分而治之”的核心目的在于将“大表Join大表”转化为“大表Join小表”因此**“分而治之”中一个关键的环节就是内表拆分,我们要求每一个子表的尺寸相对均匀,且都小到可以放进广播变量**。只有这样原本的Shuffle Join才能转化成一个又一个的Broadcast Joins原本的海量数据Shuffle才能被消除我们也才能因此享受到性能调优的收益。相反如果内表拆分不能满足上述条件我们就“白忙活”了。
**拆分的关键在于拆分列的选取**为了让子表足够小拆分列的基数Cardinality要足够大才行。这么说比较抽象我们来举几个例子。假设内表的拆分列是“性别”性别的基数是2取值分别是“男”和“女”。我们根据过滤条件 “性别 = 男”和“性别 = 女”把内表拆分为两份,显然,这样拆出来的子表还是很大,远远超出广播阈值。
你可能会说“既然性别的基数这么低不如我们选择像身份证号这种基数大的数据列。”身份证号码的基数确实足够大就是全国的人口数量。但是身份证号码这种基数比较大的字符串充当过滤条件有两个缺点不容易拆分开发成本太高过滤条件很难享受到像谓词下推这种Spark SQL的内部优化机制。
既然基数低也不行、高也不行那到底什么样的基数合适呢通常来说在绝大多数的数仓场景中事实表上都有与时间相关的字段比如日期或是更细致的时间戳。这也是很多事实表在建表的时候都是以日期为粒度做分区存储的原因。因此选择日期作为拆分列往往是个不错的选择既能享受到Spark SQL分区剪裁Partition Pruning的性能收益同时开发成本又很低。
## 如何避免外表的重复扫描?
内表拆分之后外表就要分别和所有的子表做关联尽管每一个关联都变成了“大表Join小表”并转化为BHJ但是在Spark的运行机制下每一次关联计算都需要重新、重头扫描外表的全量数据。毫无疑问这样的操作是让人无法接受的。这就是“分而治之”中另一个关键的环节外表的重复扫描。
<img src="https://static001.geekbang.org/resource/image/9f/9c/9fab5a256d544ef2b1f895c4990f4e9c.jpg" alt="" title="外表的重复扫描">
我们以上图为例内表被拆分为4份原本两个大表的Shuffle Join被转化为4个Broadcast Joins。外表分别与4个子表做关联所有关联的结果集最终通过Union合并到一起完成计算。对于这4个关联来说每一次计算都需要重头扫描一遍外表。换句话说外表会被重复扫描4次。显然外表扫描的次数取决于内表拆分的份数。
我们刚刚说到,内表的拆分需要足够细致,才能享受到性能调优带来的收益,而这往往意味着,内表拆分的份数成百上千、甚至成千上万。在这样的数量级之下,重复扫描外表带来的开销是巨大的。
要解决数据重复扫描的问题办法其实不止一种我们最容易想到的就是Cache。确实如果能把外表的全量数据缓存到内存中我们就不必担心重复扫描的问题毕竟内存的计算延迟远低于磁盘。但是我们面临的情况是外表的数据量非常地庞大往往都是TB级别起步想要把TB体量的数据全部缓存到内存这要求我们的计算集群在资源配置上要足够的强悍再说直白一点你要有足够的预算去配置足够大的内存。
要是集群没这么强悍,老板也不给批预算去扩容集群内存,我们该怎么办呢?
我们还是要遵循“分而治之”的思想,既然内表可以“分而治之”,外表为什么不可以呢?**对于外表参与的每一个子关联,在逻辑上,我们完全可以只扫描那些与内表子表相关的外表数据,并不需要每次都扫描外表的全量数据。**如此一来,在效果上,外表的全量数据仅仅被扫描了一次。你可能会说:“说得轻巧,逻辑上是没问题,但是具体怎么做到外表的“分而治之”呢?”
这事要是搁到以前还真是没什么操作空间但是学习过Spark 3.0的DPP机制之后我们就可以利用DPP来对外表进行“分而治之”。
<img src="https://static001.geekbang.org/resource/image/fa/23/fa4bfb52cb42928f15b1dc7c37c30b23.jpg" alt="" title="外表的“分而治之”">
假设外表的分区键包含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(&quot;transactions&quot;)
orders.createOrReplaceTempView(&quot;orders&quot;)
val query: String = &quot;
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
&quot;
val outFile: String = _
spark.sql(query).save.parquet(outFile)
```
不难发现在两张表的关联计算中transactions的角色是外表自然 orders的角色就是内表。需要指出的是即便内表中有不少过滤条件如订单状态为“完成”且成交日期满足一定范围但过滤之后的内表仍然在百GB量级难以放入广播变量。因此这两张大表的关联计算自然会退化到Shuffle Joins的实现机制。
那么,如果用“分而治之”的思路来做优化,代码应该怎么改呢?“分而治之”有两个关键因素,也就是内表拆分和外表重复扫描。我们不妨从这两个因素出发来调整原来的代码。
首先内表拆分是否合理完全取决于拆分列的选取而候选拆分列要同时满足基数适中、子表分布均匀并且子表尺寸小于广播阈值等多个条件。纵观orders表的所有关键字段只有date字段能够同时满足这些条件。因此我们可以使用date字段以天为单位对orders表做拆分那么原代码中的查询语句需要作如下调整。
```
//以date字段拆分内表
val query: String = &quot;
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
&quot;
```
你可能会说:“这不对吧,业务需求是计算一个季度的交易额,查询这么改不是只计算一天的量吗?”别着急,代码的调整还差一步:外表重复扫描。内表拆分之后,外表自然要依次与所有的子表做关联,最终把全部子关联的结果合并到一起,才算是完成“分而治之”的实现。
```
//循环遍历dates、完成“分而治之”的计算
val dates: Seq[String] = Seq(&quot;2020-01-01&quot;, &quot;2020-01-02&quot;, … &quot;2020-03-31&quot;)
for (date &lt;- dates) {
val query: String = s&quot;
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
&quot;
val file: String = s&quot;${outFile}/${date}&quot;
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 @@
<audio id="audio" title="29 | 大表Join大表什么是负隅顽抗的调优思路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/31/9a/3180e43c575532f3e5308f096166bf9a.mp3"></audio>
你好,我是吴磊。
在上一讲我们说了应对“大表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讲](https://time.geekbang.org/column/article/362710)。
那我们该如何强制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种情况来讨论。
<img src="https://static001.geekbang.org/resource/image/be/73/beb46de87f456924fc1414b93f8c0a73.jpeg" alt="" title="大表Join大表数据倾斜的3种情况">
其实,不管哪种表倾斜,它们的调优技巧都是类似的。因此,我们就以第一种情况为例,也就是外表倾斜、内表分布均匀的情况,去探讨数据倾斜的应对方法。
### 以Task为粒度解决数据倾斜
学过AQE之后要应对数据倾斜想必你很快就会想到AQE的特性自动倾斜处理。给定如下配置项参数Spark SQL在运行时可以将策略OptimizeSkewedJoin插入到物理计划中自动完成Join过程中对于数据倾斜的处理。
- spark.sql.adaptive.skewJoin.skewedPartitionFactor判定倾斜的膨胀系数。
- spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes判定倾斜的最低阈值。
- spark.sql.adaptive.advisoryPartitionSizeInBytes以字节为单位定义拆分粒度。
<img src="https://static001.geekbang.org/resource/image/33/a9/33a112480b1c1bf8b21d26412a7857a9.jpg" alt="" title="AQE中的自动倾斜处理">
Join过程中的自动倾斜处理如上图所示当AQE检测到外表存在倾斜分区之后它会以spark.sql.adaptive.advisoryPartitionSizeInBytes配置的数值为拆分粒度把倾斜分区拆分为多个数据分区。与此同时AQE还需要对内表中对应的数据分区进行复制来保护两表之间的关联关系。
有了AQE的自动倾斜处理特性在应对数据倾斜问题的时候我们确实能够大幅节省开发成本。不过天下没有免费的午餐AQE的倾斜处理是以Task为粒度的这意味着原本Executors之间的负载倾斜并没有得到根本改善。这到底是什么意思呢
<img src="https://static001.geekbang.org/resource/image/f4/72/f4fe3149112466174bdefcc0ee573d72.jpg" alt="" title="以Task为粒度的负载均衡">
我们来举个例子假设某张表在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相应地我们把内表的数据也分为两份。
<img src="https://static001.geekbang.org/resource/image/c2/e2/c22de99104b0a9cb0d5cfdffebd42ee2.jpg" alt="" title="分而治之按照Join Keys是否倾斜对数据进行分组">
那么分而治之的含义就是对于内外表中两组不同的数据我们分别采用不同的方法做关联计算然后通过Union操作再把两个关联计算的结果集做合并最终得到“大表Join大表”的计算结果整个过程如上图所示。
对于Join Keys分布均匀的数据部分我们可以沿用把Shuffle Sort Merge Join转化为Shuffle Hash Join的方法。对于Join Keys存在倾斜问题的数据部分我们就需要借助“两阶段Shuffle”的调优技巧来平衡Executors之间的工作负载。那么什么是“两阶段Shuffle”呢
#### 如何理解“两阶段Shuffle”
用一句话来概括“两阶段Shuffle”指的是通过“加盐、Shuffle、关联、聚合”与“去盐化、Shuffle、聚合”这两个阶段的计算过程在不破坏原有关联关系的前提下在集群范围内以Executors为粒度平衡计算负载 。
<img src="https://static001.geekbang.org/resource/image/34/21/348ddabcd5f9980de114ae9d5b96d321.jpg" alt="" title="两阶段Shuffle">
我们先来说说第一阶段也就是“加盐、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的数据记录。
<img src="https://static001.geekbang.org/resource/image/cd/4f/cd1858531a08371047481120b0c3544f.jpg" alt="" title="外表加盐">
内表的处理称为“复制加盐”具体的操作方法是对于任意一个倾斜的Join Key我们都把原数据复制#N 1从而得到#N份数据副本。对于每一份副本我们为其Join Key追加1到#N之间的固定后缀让它与打散后的外表数据保持一致。对于刚刚Join Key = 黄小乙的例子来说在内表中我们需要把黄小乙的数据复制4份然后依次为每份数据的Join Key追加1到5的固定后缀如下图所示。
<img src="https://static001.geekbang.org/resource/image/8d/22/8d843fb98d834df38080a68064522322.jpg" alt="" title="内表加盐">
内外表分别加盐之后数据倾斜问题就被消除了。这个时候我们就可以使用常规优化方法比如将Shuffle Sort Merge Join转化为Shuffle Hash Join去继续执行Shuffle、关联和聚合操作。到此为止“两阶段Shuffle” 的第一阶段执行完毕我们得到了初步的聚合结果这些结果是以打散的Join Keys为粒度进行计算得到的。
<img src="https://static001.geekbang.org/resource/image/8d/22/8d843fb98d834df38080a68064522322.jpg" alt="" title="一阶段Shuffle、关联、聚合">
我们刚刚说,第一阶段加盐的目的在于将数据打散、平衡计算负载。现在我们已经得到了数据打散之后初步的聚合结果,离最终的计算结果仅有一步之遥。不过,为了还原最初的计算逻辑,我们还需要把之前加上的“盐粒”再去掉。
<img src="https://static001.geekbang.org/resource/image/36/53/36d1829e2b550a3079707eee9712d253.jpg" alt="" title="二阶段Shuffle、聚合">
第二阶段的计算包含“去盐化、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),$&quot;orderId&quot;))
val evenTx: DataFrame = transactions.filter(array_contains(lit(evenOrderIds),$&quot;orderId&quot;))
val skewOrders: DataFrame = orders.filter(array_contains(lit(skewOrderIds),$&quot;orderId&quot;))
val evenOrders: DataFrame = orders.filter(array_contains(lit(evenOrderIds),$&quot;orderId&quot;))
```
拆分完成之后我们就可以延续“分而治之”的思想分别对这两部分应用不同的调优技巧。对于分布均匀的部分我们把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 = () =&gt; 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 &lt;- 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的开发成本较高但只要获得的性能收益足够显著我们的投入就是值得的。
## 每日一练
1. 当尝试将Join Keys是否倾斜作为“分而治之”的划分依据时你觉得我们该依据什么标准把Join Keys划分为倾斜组和非倾斜组呢
1. 无论是AQE的自动倾斜处理还是开发者的“两阶段Shuffle”本质上都是通过“加盐”与“去盐化”的两步走在维持关联关系的同时平衡不同粒度下的计算负载。那么这种“加盐”与“去盐化”的优化技巧是否适用于所有的关联场景如果不是都有哪些场景没办法利用AQE的自动倾斜处理或是我们的“两阶段Shuffle”呢
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,273 @@
<audio id="audio" title="30| 应用开发:北京市小客车(汽油车)摇号趋势分析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bd/c9/bd4545a922eabc59373807dcbdd999c9.mp3"></audio>
你好,我是吴磊。
如果你也在北京生活,那小汽车摇号这件事大概率也和你息息相关。我身边很多人也一直都和我抱怨说:“小汽车摇号这件事太难了,遥遥无期,完全看不到希望,感觉还没买彩票靠谱呢”。
实不相瞒我自己也在坚持小汽车摇号在享受8倍概率的情况下还是没能中签。因此包括我在内的很多人都想知道为什么摇号这么费劲一个人平均需要参与多少次摇号才会中签中签率的变化趋势真的和官方宣布的一致吗倍率这玩意儿真的能提高中签的概率吗
这些问题我们都能通过开发一个北京市小汽车摇号趋势分析的应用来解答。我会用两讲的时间带你完成这个应用的开发在这个过程中我们可以把前面学过的原理篇、通用调优篇和Spark SQL调优篇的大部分知识都上手实践一遍是不是一听就很期待呢
话不多说,我们赶紧开始吧!
## 课前准备
既然是做开发那我们就需要做一些准备工作。准备工作分为3部分分别是准备数据、准备开发环境和准备运行环境。
### 准备数据
应用所需的数据,我已经帮你准备好,也上传到了网盘,你可以点击[这个地址](https://pan.baidu.com/s/1Vys1Z1mofQFoU52ye7SKuw),输入提取码**ajs6** 进行下载。
数据的文件名是“2011-2019小汽车摇号数据.tar.gz”解压之后的目录结构如下图所示。根目录下有apply和lucky两个子目录apply的目录内容是2011-2019年各个批次参与摇号的申请编号lucky目录包含的是各个批次中签的申请编号。方便起见我们把参与过摇号的人叫“申请者”把中签的人叫“中签者”。apply和lucky的下一级子目录是各个摇号批次而摇号批次目录下包含的是Parquet格式的数据分片。<br>
<img src="https://static001.geekbang.org/resource/image/74/4f/7472040477c9b7756102890f2819284f.jpg" alt="" title="数据的文件目录结构">
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台物理节点它们的资源配置分别如下。其实为了跑通应用和做性能对比你并不需要这么强悍的机器配置。我这么做主要是贪图执行效率因为想要说明不同调优技巧的作用与功效我只需要拿到调优前后的对比结果就可以了这样的配置可以减少我的等待时间。
<img src="https://static001.geekbang.org/resource/image/51/f5/514bfaa1696ce723fcdcbbef2e72fff5.jpeg" alt="" title="硬件资源配置">
## 应用开发
准备好了数据、开发环境和执行环境之后,我们就步入正题,开始进行“趋势分析应用”的开发。为了解答大家关于小汽车摇号的种种困惑,在这个应用中,我们主要分析如下几个案例:
- 2011到2019年底总共有多少人参与摇号
- 摇号次数的总分布情况,以及申请者的分布情况和中签者的分布情况分别是什么
- 中签率的变化趋势是什么
- 中签率是否发生过较大变化,怎么对它做局部洞察
- 倍率高的人是否更容易中签
接下来,我们就来一一厘清这些案例的计算逻辑,并进行代码实现。
### 案例1人数统计
首先我们需要对数据有一个基本的认知。我们先从最简单的统计计数开始也就是统计一下截至到2019年底参与摇号的总人次和幸运的中签者人数。应该说这样的需求非常简单我们只需使用Parquet API读取源文件、创建DataFrame然后调用count就可以了。
```
val rootPath: String = _
// 申请者数据(因为倍率的原因,每一期,同一个人,可能有多个号码)
val hdfs_path_apply = s&quot;${rootPath}/apply&quot;
val applyNumbersDF = spark.read.parquet(hdfs_path_apply)
applyNumbersDF.count
// 中签者数据
val hdfs_path_lucky = s&quot;${rootPath}/lucky&quot;
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(&quot;batchNum&quot;, &quot;carNum&quot;).distinct
applyDistinctDF.count
```
batchNumcarNum为粒度进行去重计数我们就能得到实际的摇号体量是135009819也就是1.35亿人次。这意味着从2011年到2019年这9年的时间里有1.35亿人次参与了一项“抽奖游戏”但是仅有115万人幸运中奖摇号之难可见一斑。
### 案例2摇号次数分布
接下来我们进一步向下追踪Drill Down挖掘一下不同人群摇号次数的分布也就是统计所有申请者累计参与了多少次摇号所有中签者摇了多少次号才能幸运地摇中签。对于这两个统计计算我们需要消除倍率的影响。也就是说同一个申请编号在同一个批次中应该只保留一份副本。因此我们需要使用去重之后的“申请者表”applyDistinctDF。
#### 场景1参与摇号的申请者
首先,我们先来分析所有申请者的分布情况,当然也包括中签者。根据刚刚介绍的“业务需求”,我们很快就能写出相应的查询语句。
```
val result02_01 = applyDistinctDF
.groupBy(col(&quot;carNum&quot;))
.agg(count(lit(1)).alias(&quot;x_axis&quot;))
.groupBy(col(&quot;x_axis&quot;))
.agg(count(lit(1)).alias(&quot;y_axis&quot;))
.orderBy(&quot;x_axis&quot;)
result02_01.write.format(&quot;csv&quot;).save(&quot;_&quot;)
```
将上述代码付诸执行我们会得到如下图所示的计算结果。其中横坐标代表申请者参与过的摇号批次次数纵坐标是对应的参与人数。从2011年到2013年摇号是每月一次的。而从2014开始摇号是每两个月一次的。因此截至到2019年底总共有7212 * 3 + 6 * 6次摇号。所以我们看到横坐标的值域是从1到721表示摇过1次的人72就比较惨了它表示摇过72次的人。
<img src="https://static001.geekbang.org/resource/image/d2/1c/d2e28f14e580916c8b0e1b37db9e7f1c.jpeg" alt="">
从图中我们不难发现随着摇号次数的逐级递增人数分布基本上呈现出了逐级递减的趋势。那这意味着什么呢这意味着每隔两个月就会有新人从驾校毕业加入到庞大的摇号大军中来。仔细观察上图的左半部分我们会发现摇号次数凡是遇到3的倍数对应的人数往往比其“左邻右舍”多出甚至两倍这是为什么呢
我们刚刚说过从2014年开始摇号是每两个月进行一次。因此摇号次数相差3则意味着两次摇号之间的时间差是半年左右。比如说摇了3次的人就比摇了6次的人晚半年加入摇号大军。那么半年意味着什么呢我们不妨脑洞一下尽管每个月都有从驾校毕业的学员但是寒暑假往往是大批量“生产”学员的高峰时期而寒暑假恰好相差半年左右。你觉得我这个推测合理吗
#### 场景2幸运的中签者
接下来我们再来看看那些中签的幸运儿们到底有多幸运要想得到中签者的摇号次数我们需要把applyDistinctDF和luckyDogsDF两张表做内关联然后再做分组、聚合代码实现如下表所示。
```
val result02_02 = applyDistinctDF
.join(luckyDogsDF.select(&quot;carNum&quot;), Seq(&quot;carNum&quot;), &quot;inner&quot;)
.groupBy(col(&quot;carNum&quot;)).agg(count(lit(1)).alias(&quot;x_axis&quot;))
.groupBy(col(&quot;x_axis&quot;)).agg(count(lit(1)).alias(&quot;y_axis&quot;))
.orderBy(&quot;x_axis&quot;)
result02_02.write.format(&quot;csv&quot;).save(&quot;_&quot;)
```
将上述代码付诸执行我们会得到如下图所示的计算结果其中横纵坐标的含义与场景1一样分别是摇号批次数和对应的人数分布。我们发现随着摇号次数的逐级递增人数的分布完全是单调递减的。也就是说摇号的次数越多中签者的数量越少。我能想到的一个原因是摇号的次数越高对应的参与人数就越少这一点在场景1已经得到了验证。这个其实也不难理解能一直坚持摇60次以上的玩家真的都是骨灰级玩家。那么参与的人基数小中签者的数量自然就更少。<br>
<img src="https://static001.geekbang.org/resource/image/f8/4b/f8e20abc26316a4e7ab9abda09c0344b.jpeg" alt="">
不过如果假设申请者两个月摇一次号那么我们就会得出一个非常扎心的结论摇号中签的人往往不需要等待太长的时间绝大多数都是在2-3年内摇中了购车资格因为前半部分的总数占到了绝大多数。而等待3年以上才摇上号的人反而成了幸运儿群体中的“少数派”。这不禁让我想起了当年大家开玩笑的那句话“你要是人品够用早就该摇上了。超过3年还没摇上就说明你人品余额不足摇号这件事以后也就不用指望了”。
### 案例3中签率的变化趋势
从摇号次数的分布来看,申请者和中签者的变化趋势是一致的,那这是否意味着二者相除之后的比例是稳定的呢?二者的商实际上就是中签率。接下来,我们就去探究一下中签率的变化趋势。要计算中签率,我们需要分别统计每一个摇号批次中的申请者和中签者人数,然后再把它们做关联、聚合,代码实现如下所示。
```
// 统计每批次申请者的人数
val apply_denominator = applyDistinctDF
.groupBy(col(&quot;batchNum&quot;))
.agg(count(lit(1)).alias(&quot;denominator&quot;))
// 统计每批次中签者的人数
val lucky_molecule = luckyDogsDF
.groupBy(col(&quot;batchNum&quot;))
.agg(count(lit(1)).alias(&quot;molecule&quot;))
val result03 = apply_denominator
.join(lucky_molecule, Seq(&quot;batchNum&quot;), &quot;inner&quot;)
.withColumn(&quot;ratio&quot;, round(col(&quot;molecule&quot;)/col(&quot;denominator&quot;), 5))
.orderBy(&quot;batchNum&quot;)
result03.write.format(&quot;csv&quot;).save(&quot;_&quot;)
```
我们得到的中签率示意图如下所示。其中横坐标为各个摇号批次从201101到201906也就是从2011年的第一批到2019年的第72批纵坐标就是中签率。从中我们可以很直观地看到随着摇号批次的推进中签率呈锐减的趋势。201101批次的中签率在9.4%左右不到10%。而201906批次的中签率为1.9‰也就是千分之一点九。这么看来1000个人里面能摇上号的还凑不够两个人这也难怪摇号如此之难了。
<img src="https://static001.geekbang.org/resource/image/92/13/92c3ae9b999f697e6b568a5107f45713.jpeg" alt="">
### 案例4中签率局部洞察
第4个案例与案例3的区别在于我们只关注2018年的中签率变化趋势。这样做的原因有二一来通过计算和对比我发现2018年的中签率相比2017年几乎经历了“断崖式”的下跌因此我想给2018年一个特写二来只关注2018年的数据可以让我们有机会对比启用AQE Join策略调整前后的性能差异。
基于案例3的代码实现要关注2018年我们只需要在batchNum之上添加个过滤条件就好了。
```
// 筛选出2018年的中签数据并按照批次统计中签人数
val lucky_molecule_2018 = luckyDogsDF
.filter(col(&quot;batchNum&quot;).like(&quot;2018%&quot;))
.groupBy(col(&quot;batchNum&quot;))
.agg(count(lit(1)).alias(&quot;molecule&quot;))与
// 通过与筛选出的中签数据按照批次做关联,计算每期的中签率
val result04 = apply_denominator
.join(lucky_molecule_2018, Seq(&quot;batchNum&quot;), &quot;inner&quot;)
.withColumn(&quot;ratio&quot;, round(col(&quot;molecule&quot;)/col(&quot;denominator&quot;), 5))
.orderBy(&quot;batchNum&quot;)
result04.write.format(&quot;csv&quot;).save(&quot;_&quot;)
```
<img src="https://static001.geekbang.org/resource/image/2d/de/2db2d2751c7930b271b8e456d2f5abde.jpeg" alt="">
结合案例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(&quot;batchNum&quot;) &gt;= &quot;201601&quot;)
.select(&quot;carNum&quot;), Seq(&quot;carNum&quot;), &quot;inner&quot;)
.groupBy(col(&quot;batchNum&quot;),col(&quot;carNum&quot;))
.agg(count(lit(1)).alias(&quot;multiplier&quot;))
.groupBy(&quot;carNum&quot;)
.agg(max(&quot;multiplier&quot;).alias(&quot;multiplier&quot;))
.groupBy(&quot;multiplier&quot;)
.agg(count(lit(1)).alias(&quot;cnt&quot;))
.orderBy(&quot;multiplier&quot;)
result05_01.write.format(&quot;csv&quot;).save(&quot;_&quot;)
```
中签者的倍率分布如下图所示。其中,横坐标为中签者的倍率,更准确地说,是中签者在参与的摇号批次中最大的副本数量,纵坐标是人数分布。通过观察执行结果我们不难发现,中签者的倍率呈现明显的正态分布。因此,从这张图我们可以得到初步结论:要想摇中车牌号,你并不需要很高的倍率。换句话说,对于中签这件事来说,倍率的作用和贡献并不是线性递增的。
<img src="https://static001.geekbang.org/resource/image/5d/93/5d75f65cd115d560b1b3d19cd78eb393.jpeg" alt="">
不过和案例2类似这里同样存在一个基数的问题。也就是说倍率高的人本来就少其中的中签者数量自然也少。因此我们还要结合申请者的倍率分布去计算不同倍率下的中签比例才能更加完备地对倍率的作用下结论。
#### 场景2不同倍率下的中签比例
对倍率分布有了初步认知之后我们再来计算不同倍率人群的中签比例去探究倍率本身对于中签的贡献究竟有多大。有了场景1中签者的倍率分布我们只需要去计算申请者的倍率分布然后把两份数据做关联、聚合就可以得到我们想要的结果。
```
// Step01: 过滤出2016-2019申请者数据统计出每个申请者在每一期内的倍率并在所有批次中选取最大的倍率作为申请者的最终倍率最终算出各个倍率下的申请人数
val apply_multiplier_2016_2019 = applyNumbersDF
.filter(col(&quot;batchNum&quot;) &gt;= &quot;201601&quot;)
.groupBy(col(&quot;batchNum&quot;), col(&quot;carNum&quot;))
.agg(count(lit(1)).alias(&quot;multiplier&quot;))
.groupBy(&quot;carNum&quot;)
.agg(max(&quot;multiplier&quot;).alias(&quot;multiplier&quot;))
.groupBy(&quot;multiplier&quot;)
.agg(count(lit(1)).alias(&quot;apply_cnt&quot;))
// Step02: 将各个倍率下的申请人数与各个倍率下的中签人数左关联,并求出各个倍率下的中签率
val result05_02 = apply_multiplier_2016_2019
.join(result05_01.withColumnRenamed(&quot;cnt&quot;, &quot;lucy_cnt&quot;), Seq(&quot;multiplier&quot;), &quot;left&quot;)
.na.fill(0)
.withColumn(&quot;ratio&quot;, round(col(&quot;lucy_cnt&quot;)/col(&quot;apply_cnt&quot;), 5))
.orderBy(&quot;multiplier&quot;)
result05_02.write.format(&quot;csv&quot;).save(&quot;_&quot;)
```
不同倍率下的中签比例如下图所示。其中横坐标为倍率纵坐标有两个。蓝色柱状图体代表中签人数它的分布与场景1的分布是一致的绿色柱状条表示的是中签比例它表示在同一个倍率下中签人数与申请人数的比值。
<img src="https://static001.geekbang.org/resource/image/6e/ab/6e302d72dd4d036c12a5fdd5d4fa04ab.jpeg" alt="">
与中签人数一样中签比例在不同的倍率下也呈现出了正态分布。有了这份数据做补充我们可以夯实场景1中得出的结论。也就是倍率对中签的贡献极其有限。这个结论很好地解释了为什么摇号很久倍率很高的人也难以中签。
到此为止,通过以上几个案例的分析,我们就对摇号次数分布、中签率变化趋势、倍率分布与中签比例有了答案。
## 小结
今天这一讲我们重点开发了一个趋势分析应用来解答北京市小汽车摇号的各个问题。这个应用主要实现了5个案例分别是摇号次数分布、中签率变化趋势、中签率的大变动、倍率分布与中签比例。为了方便理解我把它们要解决的问题、答案、主要的实现思路都总结在了下面的脑图中你可以看一看。
<img src="https://static001.geekbang.org/resource/image/7f/7e/7f3c8538de358de9fc772ee14387947e.png" alt="">
至于这5个案例的代码实现和执行结果我把它们都上传到了公用的GitHub仓库你可以从[这个地址](https://github.com/wulei-bj-cn/potatoes.git)获取完整内容。
当然,目前的代码肯定存在很多可以优化的地方,至于怎么优化,我先卖个关子,下一讲再详细来说。
## 每日一练
1. 如果让你来实现小汽车摇号的倍率机制,你觉得怎么实现才更严谨呢?
1. 基于这份2011-2019的小汽车摇号数据你还能想到哪些有意思的洞察、视角和案例值得我们进一步去探索呢
1. 你认为,倍率对于中签的贡献和作用微乎其微的原因是什么呢?
期待在留言区看到你的思考和答案,我们下一讲见!

View File

@@ -0,0 +1,351 @@
<audio id="audio" title="31 | 性能调优:手把手带你提升应用的执行性能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f5/99/f5c23813169b99110a402537cbed1f99.mp3"></audio>
你好,我是吴磊。
在上一讲我们一起完成了小汽车摇号趋势分析的应用开发解决了5个案例。今天这一讲我们逐一对这5个案例做性能调优一起把专栏中学过的知识和技巧应用到实战中去。
由于趋势分析应用中的案例较多,为了方便对比每一个案例调优前后的性能效果,我们先来对齐并统一性能对比测试的方法论。
首先我们的性能对比测试是以案例为粒度的也就是常说的Case By Case。然后在每一个案例中我们都有对比基准Baseline。**对比基准的含义是,在不采取任何调优方法的情况下,直接把代码交付执行得到的运行时间。**之后,对于每一个案例,我们会采取一种或多种调优方法做性能优化,每一种调优方法都有与之对应的运行时间。最终,我们将不同调优方法的运行时间与对比基准做横向比较,来观察调优前后的性能差异,并分析性能提升/下降的背后原因。
话不多说,我们直接开始今天的课程吧!
## 运行环境
既然调优效果主要由执行时间来体现,那在开始调优之前,我们有必要先来交代一下性能测试采用的硬件资源和配置项设置。硬件资源如下表所示。
<img src="https://static001.geekbang.org/resource/image/ef/07/efa374437bb4df18b80145133a1cd807.jpeg" alt="" title="硬件资源配置">
为了避免因为实验本身而等待太长的时间,我使用了比较强悍的机器资源。实际上,为了跑通应用,完成性能对比测试,你使用笔记本也可以。而且为了给后续调优留出足够空间,除了必需的运行资源设置以外,其他配置项全部保留了默认值,具体的资源配置如下表所示。
<img src="https://static001.geekbang.org/resource/image/9a/f2/9a98c20b0d391910fa88a6df4a2641f2.jpeg" alt="" title="资源配置项设置">
另外由于调优方法中涉及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&quot;${rootPath}/apply&quot;
val applyNumbersDF = spark.read.parquet(hdfs_path_apply)
applyNumbersDF.count
// 中签者数据
val hdfs_path_lucky = s&quot;${rootPath}/lucky&quot;
val luckyDogsDF = spark.read.parquet(hdfs_path_lucky)
luckyDogsDF.count
// 申请者数据(去掉倍率的影响)
val applyDistinctDF = applyNumbersDF.select(&quot;batchNum&quot;, &quot;carNum&quot;).distinct
applyDistinctDF.count
```
从上面的代码实现中我们不难发现短短的几行代码共有3个Actions也就是3个不同数据集上的count操作这3个Actions会触发3个Spark Jobs。其中前2个Jobs都是读取数据源之后立即计数没什么优化空间。第3个Job是在applyNumbersDF之上做去重然后再统计计数。结合上一讲对于不同案例的讲解我们知道applyNumbersDF、luckyDogsDF和applyDistinctDF这3个数据集在后续的案例中会被反复引用。
因为上述3个数据集的引用次数过于频繁所以我们甚至都不用去计算“运行成本占比”就可以判定利用Cache一定有利于提升执行性能。
>
<p>使用Cache的一般性原则<br>
如果RDD/DataFrame/Dataset在应用中的引用次数为1那么坚决不使用Cache<br>
如果引用次数大于1且运行成本占比超过30%应当考虑启用Cache</p>
因此对于第3个Job我们可以利用Cache机制来提升执行性能。调优方法很简单我们只需在applyNumbersDF.count之前添加一行代码applyNumbersDF.cache。
由于这个案例中性能对比测试的关注点是第3个Job那为了方便横向对比我们先把不相干的Jobs和代码去掉整理之后的对比基准和调优代码如下表所示。
<img src="https://static001.geekbang.org/resource/image/ef/97/eff724754dca03dd1c937ab8ec003c97.png" alt="" title="对比基准与调优代码">
然后我们把这两份代码分别打包、部署和执行并记录applyDistinctDF.count作业的执行时间来完成性能对比测试我把执行结果记录到了下面的表格中。
<img src="https://static001.geekbang.org/resource/image/1f/fb/1fdcca072cd48e694b9fa507e9bcd4fb.jpeg" alt="" title="性能对比">
从中我们可以看到相较于对比基准调优之后的执行性能提升了20%。坦白地说这样的提升是我们意料之中的。毕竟前者消耗的是磁盘I/O而调优之后计数作业直接从内存获取数据。
## 案例2的性能调优摇号次数分布
接下来我们再来分析案例2。案例2分为两个场景第一个场景用于统计申请者摇号批次数量的分布情况第二个场景也是类似不过它的出发点是中签者主要用来解答“中签者通常需要摇多少次号才能中签”这类问题。
### 场景1参与摇号的申请者
我们先来回顾一下场景1的代码实现。仔细研读代码我们不难发现场景1是典型的单表Shuffle而且是两次Shuffle。第一次Shuffle操作是以数据列“carNum”为基准做分组计数第二次Shuffle是按照“x_axis”列再次做分组计数。
```
val result02_01 = applyDistinctDF
.groupBy(col(&quot;carNum&quot;))
.agg(count(lit(1)).alias(&quot;x_axis&quot;))
.groupBy(col(&quot;x_axis&quot;))
.agg(count(lit(1)).alias(&quot;y_axis&quot;))
.orderBy(&quot;x_axis&quot;)
result02_01.write.format(&quot;csv&quot;).save(&quot;_&quot;)
```
因此场景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%往往是个不错的开始,对比基准与优化设置如下表所示。
<img src="https://static001.geekbang.org/resource/image/52/ea/5219a7da76db953d42f485aa334b15ea.jpeg" alt="" title="对比基准与优化设置">
两组对比实验的运行时间,我记录到了下面的表格中。从中我们不难发现,上述两个参数的调整,对于作业端到端执行性能的影响不大。不过,这种参数调了半天,执行效率并没有显著提升的场景,肯定让你似曾相识。这个时候,最好的办法就是我们继续借助“木桶短板”“瓶颈”以及“调优方法论”,去尝试其他的调优思路。
<img src="https://static001.geekbang.org/resource/image/52/f1/52910d40292653054040bb58950380f1.jpeg" alt="" title="性能对比">
#### 思路2数据分区合并
接着我们再来说第二个思路数据分区合并。首先咱们先来一起分析一下场景1到底存不存在数据分片过小的问题。为了方便分析我们再来回顾一遍代码。因为场景1的计算基于数据集applyDistinctDF所以要回答刚刚的问题我们需要结合数据集applyDistinctDF的存储大小以及Shuffle计算过后Reduce阶段的并行度一起来看。
```
val result02_01 = applyDistinctDF
.groupBy(col(&quot;carNum&quot;))
.agg(count(lit(1)).alias(&quot;x_axis&quot;))
.groupBy(col(&quot;x_axis&quot;))
.agg(count(lit(1)).alias(&quot;y_axis&quot;))
.orderBy(&quot;x_axis&quot;)
result02_01.write.format(&quot;csv&quot;).save(&quot;_&quot;)
```
并行度由配置项spark.sql.shuffle.partitions决定其默认大小为200也就是200个数据分区。而对于数据集存储大小的估算我们需要用到下面的函数。
```
def sizeNew(func: =&gt; DataFrame, spark: =&gt; SparkSession): String = {
val result = func
val lp = result.queryExecution.logical
val size = spark.sessionState.executePlan(lp).optimizedPlan.stats.sizeInBytes
&quot;Estimated size: &quot; + size/1024 + &quot;KB&quot;
}
```
给定DataFramesizeNew函数可以返回该数据集在内存中的精确大小。把applyDistinctDF作为实参调用sizeNew函数返回的估算尺寸为2.6 GB。将数据集尺寸除以并行度我们就能得到Reduce阶段每个数据分片的存储大小也就是13 MB也就是2.6 GB / 200。通常来说数据分片的尺寸大小在200 MB左右为宜13 MB的分片尺寸显然过小。
在调度系统那一讲第5讲我们说过如果需要处理的数据分片过小相较于数据处理Task调度开销将变得异常显著而这样会导致CPU利用降低执行性能变差。因此为了提升CPU利用率进而提升整体的执行效率我们需要对过小的数据分片做合并。这个时候AQE的自动分区合并特性就可以帮我们做这件事情。
不过要想充分利用AQE的自动分区合并特性我们还需要对相关的配置项进行调整。这里你直接看场景1是怎么设置这些配置项的就可以了。
<img src="https://static001.geekbang.org/resource/image/13/7a/1395a687a08dc14291d835e5f5a91e7a.jpeg" alt="" title="对比基准与优化设置">
一旦开启AQE机制自动分区合并特性会自动生效。表格中的配置项有两个需要我们特别注意一个是最小分区数minPartitionNum另一个是合并之后的目标尺寸advisoryPartitionSizeInBytes。
我们先来看最小分区数也就是minPartitionNum。minPartitionNum的含义指的是分区合并之后的分区数量不能低于这个参数设置的数值。由于我们计算集群配置的Executors个数为6为了保证每个CPU都不闲着、有活儿干我们不妨把minPartitionNum也设置为6。
接下来是分区合并的目标尺寸我们刚刚说过分区大小的经验值在200 MB左右因此我们不妨把advisoryPartitionSizeInBytes设置为200 MB。不过为了对比不同分区大小对于执行性能的影响我们可以多做几组实验。
配置项调整前后的几组实验效果对比如下可以看到调优后的运行时间有所缩短这说明分区合并对于提升CPU利用率和作业的整体执行性能是有帮助的。仔细观察下表我们至少有3点洞察。
<img src="https://static001.geekbang.org/resource/image/08/80/088372fe46a2f2b1839962c5c7232c80.jpeg" alt="" title="性能对比">
- **并行度过高、数据分片过小CPU调度开销会变大执行性能也变差。**
- **分片粒度划分在200 MB左右时执行性能往往是最优的。**
- **并行度过低、数据分片过大CPU数据处理开销也会过大执行性能会锐减。**
#### 思路3加Cache
最后一个思路是加Cache这个调优技巧使用起来非常简单我们在案例1已经做过演示因此这里直接给出优化代码和运行结果。
<img src="https://static001.geekbang.org/resource/image/95/0e/951e97585829030d57e4371bef07690e.jpeg" alt="" title="对比基准与调优代码">
可以看到利用Cache机制做优化作业执行性能提升得非常显著。
<img src="https://static001.geekbang.org/resource/image/3a/4d/3af1af36af6f51e9776ff990c602ab4d.jpeg" alt="" title="性能对比">
到此为止我们尝试了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(&quot;carNum&quot;), Seq(&quot;carNum&quot;), &quot;inner&quot;)
.groupBy(col(&quot;carNum&quot;)).agg(count(lit(1)).alias(&quot;x_axis&quot;))
.groupBy(col(&quot;x_axis&quot;)).agg(count(lit(1)).alias(&quot;y_axis&quot;))
.orderBy(&quot;x_axis&quot;)
result02_02.write.format(&quot;csv&quot;).save(&quot;_&quot;)
```
参与关联的两张表分别是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。
<img src="https://static001.geekbang.org/resource/image/f5/4f/f5f7bee0dcf83197b57674194047eb4f.jpeg" alt="" title="对比基准与优化设置">
我把配置项调整前后的实验结果记录到了如下表格显然相比默认的Shuffle Sort Merge Join实现机制Broadcast Join的执行性能更胜一筹。
<img src="https://static001.geekbang.org/resource/image/b8/4d/b81a0ffc8f2a2ab26fca1f31c7ac7d4d.png" alt="" title="性能对比">
## 案例3的性能调优中签率的变化趋势
案例3的业务目标是洞察中签率的变化趋势我们先来回顾代码。要计算中签率我们需要分两步走。第一步按照摇号批次也就是batchNum分别对applyDistinctDF和luckyDogsDF分组然后分别对分组内的申请者和中签者做统计计数。第二步通过数据关联将两类计数做除法最终得到每个批次的中签率。
```
// 统计每批次申请者的人数
val apply_denominator = applyDistinctDF
.groupBy(col(&quot;batchNum&quot;))
.agg(count(lit(1)).alias(&quot;denominator&quot;))
// 统计每批次中签者的人数
val lucky_molecule = luckyDogsDF
.groupBy(col(&quot;batchNum&quot;))
.agg(count(lit(1)).alias(&quot;molecule&quot;))
val result03 = apply_denominator
.join(lucky_molecule, Seq(&quot;batchNum&quot;), &quot;inner&quot;)
.withColumn(&quot;ratio&quot;, round(col(&quot;molecule&quot;)/col(&quot;denominator&quot;), 5))
.orderBy(&quot;batchNum&quot;)
result03.write.format(&quot;csv&quot;).save(&quot;_&quot;)
```
由于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(&quot;batchNum&quot;).like(&quot;2018%&quot;))
.groupBy(col(&quot;batchNum&quot;))
.agg(count(lit(1)).alias(&quot;molecule&quot;))
// 通过与筛选出的中签数据按照批次做关联,计算每期的中签率
val result04 = apply_denominator
.join(lucky_molecule_2018, Seq(&quot;batchNum&quot;), &quot;inner&quot;)
.withColumn(&quot;ratio&quot;, round(col(&quot;molecule&quot;)/col(&quot;denominator&quot;), 5))
.orderBy(&quot;batchNum&quot;)
result04.write.format(&quot;csv&quot;).save(&quot;_&quot;)
```
从代码实现来看案例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的数据扫描量从而在整体上提升作业的执行性能。
<img src="https://static001.geekbang.org/resource/image/40/ba/40663f7678edb280174663eabec114ba.jpeg" alt="" title="对比基准与DPP优化">
DPP的核心作用在于**降低事实表applyDistinctDF的磁盘扫描量**因此案例4的调优办法非常简单只要把最初加在applyDistinctDF之上的Cache去掉即可如上表右侧所示。同时为了公平起见对比基准不应该仅仅是让DPP失效的测试用例而应该是applyDistinctDF加Cache的测试用例。与此同时我们直接对比DPP的磁盘读取效率与Cache的内存读取效率也能加深对DPP机制的认知与理解。
把上述两个测试用例交付执行运行结果如下。可以看到相较对比基准在DPP机制的作用下案例4端到端的执行性能有着将近5倍的提升。由此可见数据集加Cache之后的内存读取远不如DPP机制下的磁盘读取更高效。
<img src="https://static001.geekbang.org/resource/image/83/1a/831514d82a07d6ded4986c83660byy1a.jpeg" alt="" title="性能对比">
## 案例5的性能调优倍率分析
案例5也包含两个场景场景1的业务目标是计算不同倍率下的中签人数场景2与场景1相比稍有不同它的目的是计算不同倍率下的中签比例。
尽管两个场景的计算逻辑有区别但是调优思路与方法是一致的。因此在案例5中我们只需要对场景1的性能优化进行探讨、分析与对比我们先来回顾一下场景1的代码实现。
```
val result05_01 = applyNumbersDF
.join(luckyDogsDF.filter(col(&quot;batchNum&quot;) &gt;= &quot;201601&quot;)
.select(&quot;carNum&quot;), Seq(&quot;carNum&quot;), &quot;inner&quot;)
.groupBy(col(&quot;batchNum&quot;),col(&quot;carNum&quot;))
.agg(count(lit(1)).alias(&quot;multiplier&quot;))
.groupBy(&quot;carNum&quot;)
.agg(max(&quot;multiplier&quot;).alias(&quot;multiplier&quot;))
.groupBy(&quot;multiplier&quot;)
.agg(count(lit(1)).alias(&quot;cnt&quot;))
.orderBy(&quot;multiplier&quot;)
result05_01.write.format(&quot;csv&quot;).save(&quot;_&quot;)
```
仔细研读代码之后我们发现场景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我们就可以灵活地在两种调优方法之间进行切换。
<img src="https://static001.geekbang.org/resource/image/ce/6c/cedbf6c7c5d8c18d4bd3b0fc30bb9a6c.jpeg" alt="" title="对比基准与优化设置">
将3种测试用例付诸执行在执行效率方面SMJ毫无悬念是最差的而AQE的动态优化介于SMJ与Spark SQL的静态转化之间。毕竟AQE的Join策略调整是一种“亡羊补牢、犹未为晚”的优化机制在把SMJ调整为BHJ之前参与Join的两张表的Shuffle计算已经执行过半。因此它的执行效率一定比Spark SQL的静态优化更差。尽管如此AQE动态调整过后的BHJ还是比默认的SMJ要高效得多而这也体现了AQE优化机制的价值所在。
<img src="https://static001.geekbang.org/resource/image/82/0c/8236c8d518534f2b9caa49d921878b0c.jpeg" alt="" title="性能对比">
## 小结
今天这一讲我们结合以前学过的知识点与调优技巧以小汽车摇号为例Case By Case地做性能优化。涉及的优化技巧有Shuffle读写缓冲区调整、加Cache、预估数据集存储大小、Spark SQL静态优化、AQE动态优化自动分区合并与Join策略调整以及DPP机制。为了方便你对比我把它们总结在了一张脑图里。不过我更希望你能自己总结一下这样才能记得更好。
<img src="https://static001.geekbang.org/resource/image/5d/eb/5dd4d43670ceee45d9fdbc38f100f9eb.jpeg" alt="">
最后我想说,很遗憾我们没能在这个实战里,把专栏中所有的调优技巧付诸实践,这主要是因为小汽车摇号应用相对比较简单,很难覆盖所有的计算与优化场景。对于那些未能付诸实战的调优技巧,只能靠你在平时的工作中去实践了。
不过,专栏的留言区和咱们的读者群,会一直为你敞开,尽管我不能做到立即回复,但我可以承诺的是,对于你的留言,我只会迟到、绝不缺席!
## 每日一练
1. 你能参考案例2场景1完成案例3中applyDistinctDF和luckyDogsDF两张表的单表Shuffle优化吗
1. 你能参考案例5场景1综合运用AQE、Broadcast Join等调优技巧对案例5场景2做性能优化吗
期待在留言区看到你的优化结果,也欢迎你随时提问,我们下一讲见!