mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 14:13:46 +08:00
mod
This commit is contained in:
@@ -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或者Scala,PySpark实现的应用在执行性能上相差悬殊。原因在于,在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的表达能力却很弱。一来,它定义了一套DSL(Domain Specific Language)算子,如select、filter、agg、groupBy等等。由于DSL语言是为解决某一类任务而专门设计的计算机语言,非图灵完备,因此,语言表达能力非常有限。二来,DataFrame中的绝大多数算子都是标量函数(Scalar Functions),它们的形参往往是结构化的数据列(Columns),表达能力也很弱。
|
||||
|
||||
你可能会问:“相比RDD,DataFrame的表示和表达能力都变弱了,那它是怎么解决RDD优化空间受限的核心痛点呢?”
|
||||
|
||||
当然,仅凭DataFrame在API上的改动就想解决RDD的核心痛点,比登天还难。**DataFrame API最大的意义在于,它为Spark引擎的内核优化打开了全新的空间。**
|
||||
|
||||
首先,DataFrame中Schema所携带的类型信息,让Spark可以根据明确的字段类型设计定制化的数据结构,从而大幅提升数据的存储和访问效率。其次,DataFrame中标量算子确定的计算逻辑,让Spark可以基于启发式的规则和策略,甚至是动态的运行时信息去优化DataFrame的计算过程。
|
||||
|
||||
## Spark SQL智能大脑
|
||||
|
||||
那么问题来了,有了DataFrame API,负责引擎内核优化的那个幕后英雄是谁?为了支持DataFrame开发模式,Spark从1.3版本开始推出Spark SQL。**Spark SQL的核心组件有二,其一是Catalyst优化器,其二是Tungsten。**关于Catalyst和Tungsten的特性和优化过程,我们在后面的两讲再去展开,今天这一讲,咱们专注在它们和DataFrame的关系。
|
||||
|
||||
### Catalyst:执行过程优化
|
||||
|
||||
我们先来说说Catalyst的优化过程。当开发者通过Actions算子触发DataFrame的计算请求时,Spark内部会发生一系列有趣的事情。
|
||||
|
||||
首先,基于DataFrame确切的计算逻辑,Spark会使用第三方的SQL解析器ANTLR来生成抽象语法树(AST,Abstract Syntax Tree)。既然是树,就会有节点和边这两个基本的构成元素。节点记录的是标量算子(如select、filter)的处理逻辑,边携带的是数据信息:关系表和数据列,如下图所示。这样的语法树描述了从源数据到DataFrame结果数据的转换过程。
|
||||
|
||||
<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优化器和Tungsten,Spark 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对于计算逻辑的优化无从下手。
|
||||
|
||||
相比RDD,DataFrame是携带Schema的分布式数据集,只能封装结构化数据。DataFrame的算子大多数都是普通的标量函数,以消费数据列为主。但是,DataFrame更弱的表示能力和表达能力,反而为Spark引擎的内核优化打开了全新的空间。
|
||||
|
||||
根据DataFrame简单的标量算子和明确的Schema定义,借助Catalyst优化器和Tungsten,Spark SQL有能力在运行时,构建起一套端到端的优化机制。这套机制运用启发式的规则与策略和运行时的执行信息,将原本次优、甚至是低效的查询计划转换为高效的执行计划,从而提升端到端的执行性能。
|
||||
|
||||
在DataFrame的开发模式下,所有子框架、以及PySpark,都运行在Spark SQL之上,都可以共享Spark SQL提供的种种优化机制,这也是为什么Spark历次发布新版本、Spark SQL占比最大的根本原因。
|
||||
|
||||
## 每日一练
|
||||
|
||||
1. Java Object在对象存储上为什么会有比较大的开销?JVM需要多少个字节才能存下字符串“abcd”?
|
||||
1. 在DataFrame的开发框架下, PySpark中还有哪些操作是“顽固分子”,会导致计算与数据在JVM进程与Python进程之间频繁交互?(提示:参考RDD的局限性,那些对Spark透明的计算逻辑,Spark是没有优化空间的)
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
@@ -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("name", "age", "userId")
|
||||
.filter($"age" < 30)
|
||||
.filter($"gender".isin("M"))
|
||||
|
||||
val txFile: String = _
|
||||
val txDf = spark.read.parquet(txFile)
|
||||
txDf.printSchema
|
||||
/**
|
||||
root
|
||||
|-- itemId: integer (nullable = true)
|
||||
|-- userId: integer (nullable = true)
|
||||
|-- price: float (nullable = true)
|
||||
|-- quantity: integer (nullable = true)
|
||||
*/
|
||||
|
||||
val result = txDF.select("price", "volume", "userId")
|
||||
.join(users, Seq("userId"), "inner")
|
||||
.groupBy(col("name"), col("age")).agg(sum(col("price") * col("volume")).alias("revenue"))
|
||||
|
||||
result.write.parquet("_")
|
||||
|
||||
```
|
||||
|
||||
代码示例如上图所示,为了实现业务逻辑,我们对过滤之后的用户表与交易表做内关联,然后再按照用户分组去计算交易额。不难发现,这个计算逻辑实际上就是星型数仓中典型的关联查询。为了叙述方便,我们给这个关联查询起个名字:小Q。小Q的计算需要两个输入源,一个是交易表,另一个是过滤之后的用户表。今天这一讲,我们就去追随小Q,看看它在Catalyst的逻辑优化阶段都会发生哪些变化。
|
||||
|
||||
<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的类型是int,price字段的类型是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 < 30”这样的过滤条件,“下推”指代的是把这些谓词沿着执行计划向下,推到离数据源最近的地方,从而在源头就减少数据扫描量**。换句话说,让这些谓词越接近数据源越好。
|
||||
|
||||
不过,在下推之前,Catalyst还会先对谓词本身做一些优化,比如像OptimizeIn规则,它会把“gender in ‘M’”优化成“gender = ‘M’”,也就是把谓词in替换成等值谓词。再比如,CombineFilters规则,它会把“age < 30”和“gender = ‘M’”这两个谓词,捏合成一个谓词:“age != null AND gender != null AND age <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 < 12 + 18”,Catalyst会使用ConstantFolding规则,自动帮我们把条件变成“age < 30”。再比如,我们在select语句中,掺杂了一些常量表达式,Catalyst也会自动地用表达式的结果进行替换。
|
||||
|
||||
到此为止,咱们从功用和效果的角度,探讨了Catalyst逻辑优化规则的3大范畴。你可能说:“拢共就做了这么3件事,至于兴师动众地制定81条规则吗?”我们划分这3大范畴,主要是为了叙述和理解上的方便。实际上,对于开发者写出的五花八门、千奇百怪的查询语句,正是因为Catalyst不断丰富的优化规则,才让这些查询都能够享有不错的执行性能。如果没有这些优化规则的帮忙,小Q的执行性能一定会惨不忍睹。
|
||||
|
||||
最终,被Catalyst优化过后的小Q,就从“Analyzed Logical Plan”转换为“Optimized Logical Plan”,如下图所示。我们可以看到,谓词下推和列剪裁都体现到了Optimized Logical Plan中。
|
||||
|
||||
<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的父类是TreeNode,TreeNode就是语法树中对于节点的抽象。TreeNode有一个名叫children的字段,类型是Seq[TreeNode],利用TreeNode类型,Catalyst可以很容易地构建一个树结构。
|
||||
|
||||
除了children字段,TreeNode还定义了很多高阶函数,其中最值得关注的是一个叫做transformDown的方法。transformDown的形参,正是Catalyst定义的各种优化规则,方法的返回类型还是TreeNode。另外,transformDown是个递归函数,参数的优化规则会先作用(Apply)于当前节点,然后依次作用到children中的子节点,直到整棵树的叶子节点。
|
||||
|
||||
**总的来说,从“Analyzed Logical Plan”到“Optimized Logical Plan”的转换,就是从一个TreeNode生成另一个TreeNode的过程。**Analyzed Logical Plan的根节点,通过调用transformDown方法,不停地把各种优化规则作用到整棵树,直到把所有27组规则尝试完毕,且树结构不再发生变化为止。这个时候,生成的TreeNode就是Optimized Logical Plan。
|
||||
|
||||
为了把复杂问题简单化,我们使用Expression,也就是表达式来解释一下这个过程。因为Expression本身也继承自TreeNode,所以明白了这个例子,TreeNode之间的转换我们也就清楚了。
|
||||
|
||||
```
|
||||
//Expression的转换
|
||||
import org.apache.spark.sql.catalyst.expressions._
|
||||
val myExpr: Expression = Multiply(Subtract(Literal(6), Literal(4)), Subtract(Literal(1), Literal(9)))
|
||||
val transformed: Expression = myExpr transformDown {
|
||||
case BinaryOperator(l, r) => Add(l, r)
|
||||
case IntegerLiteral(i) if i > 5 => Literal(1)
|
||||
case IntegerLiteral(i) if i < 5 => Literal(0)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
首先,我们定义了一个表达式:((6 - 4)*(1 - 9)),然后我们调用这个表达式的transformDown高阶函数。在高阶函数中,我们提供了一个用case定义的匿名函数。显然,这是一个偏函数(Partial Functions),你可以把这个匿名函数理解成“自定义的优化规则”。在这个优化规则中,我们仅考虑3种情况:
|
||||
|
||||
- 对于所有的二元操作符,我们都把它转化成加法操作
|
||||
- 对于所有大于5的数字,我们都把它变成1
|
||||
- 对于所有小于5的数字,我们都把它变成0
|
||||
|
||||
虽然我们的优化规则没有任何实质性的意义,仅仅是一种转换规则而已,但是这并不妨碍你去理解Catalyst中TreeNode之间的转换。当我们把这个规则应用到表达式((6 - 4)*(1 - 9))之后,得到的结果是另外一个表达式((1 + 0)+(0 + 1)),下面的示意图直观地展示了这个过程。
|
||||
|
||||
<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的优化规则吗?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
@@ -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("name", "age", "userId")
|
||||
.filter($"age" < 30)
|
||||
.filter($"gender".isin("M"))
|
||||
|
||||
val txFile: String = _
|
||||
val txDf = spark.read.parquet(txFile)
|
||||
txDf.printSchema
|
||||
/**
|
||||
root
|
||||
|-- txId: integer (nullable = true)
|
||||
|-- userId: integer (nullable = true)
|
||||
|-- price: float (nullable = true)
|
||||
|-- volume: integer (nullable = true)
|
||||
*/
|
||||
|
||||
val result = txDF.select("price", "volume", "userId")
|
||||
.join(users, Seq("userId"), "inner")
|
||||
.groupBy(col("name"), col("age")).agg(sum(col("price") * col("volume")).alias("revenue"))
|
||||
|
||||
result.write.parquet("_")
|
||||
|
||||
|
||||
```
|
||||
|
||||
两表关联的查询语句经过转换之后,得到的Optimized Logical Plan如下图所示。注意,在逻辑计划的根节点,出现了“Join Inner”字样,Catalyst优化器明确了这一步需要做内关联。但是,怎么做内关联,使用哪种Join策略来进行关联,Catalyst并没有交代清楚。因此,逻辑计划本身不具备可操作性。
|
||||
|
||||
<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 Join(BHJ)、Shuffle Sort Merge Join(SMJ)、Shuffle Hash Join(SHJ)、Broadcast Nested Loop Join(BNLJ)和Shuffle Cartesian Product Join(CPJ)。
|
||||
|
||||
<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("price", "volume", "userId")
|
||||
.join(users.hint("shuffle_hash"), Seq("userId"), "inner")
|
||||
.groupBy(col("name"), col("age")).agg(sum(col("price") *
|
||||
col("volume")).alias("revenue"))
|
||||
|
||||
```
|
||||
|
||||
熟悉了JoinSelection选择Join策略的逻辑之后,我们再来看小Q是怎么选择的。小Q是典型的星型查询,也就是事实表与维度表之间的数据关联,其中维表还带过滤条件。在决定采用哪种Join策略的时候,JoinSelection优先尝试判断小Q是否满足BHJ的先决条件。
|
||||
|
||||
显然,小Q是等值的Inner Join,因此表格中BHJ那一行的前两个条件小Q都满足。但是,内表users尺寸较大,超出了广播阈值的默认值10MB,不满足BHJ的第三个条件。因此,JoinSelection不得不忍痛割爱、放弃BHJ策略,只好退而求其次,沿着表格继续向下,尝试判断小Q是否满足SMJ的先决条件。
|
||||
|
||||
SMJ的先决条件很宽松,查询语句只要是等值Join就可以。小Q自然是满足这个条件的,因此JoinSelection最终给小Q选定的Join策略就是SMJ。下图是小Q优化过后的Spark Plan,从中我们可以看到,查询计划的根节点正是SMJ。
|
||||
|
||||
<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 > SMJ > SHJ > BNLJ > CPJ的顺序,依次判断查询语句是否满足每一种Join策略的先决条件进行“择优录取”。
|
||||
|
||||
如果开发者不满足于JoinSelection默认的选择顺序,也就是BHJ > SMJ > SHJ > BNLJ > CPJ,还可以通过在SQL或是DSL语句中引入Join hints,来明确地指定Join策略,从而把自己的意愿凌驾于Catalyst之上。不过,需要我们注意的是,要想让指定的Join策略在运行时生效,查询语句也必须要满足其先决条件才行。
|
||||
|
||||
在生成Physical Plan这个环节,Catalyst基于既定的几组Preparation Rules,把优化过后的Spark Plan转换成可以交付执行的物理计划,也就是Physical Plan。在这些既定的Preparation Rules当中,你需要重点掌握EnsureRequirements规则。
|
||||
|
||||
EnsureRequirements用来确保每一个操作符的输入条件都能够得到满足,在必要的时候,会把必需的操作符强行插入到Physical Plan中。比如对于Shuffle Sort Merge Join来说,这个操作符对于子节点的数据分布和顺序都是有明确要求的,因此,在子节点之上,EnsureRequirements会引入新的操作符如Exchange和Sort。
|
||||
|
||||
## 每日一练
|
||||
|
||||
3种Join实现方式和2种网络分发模式,明明应该有6种Join策略,为什么Catalyst没有支持Broadcast Sort Merge Join策略?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
@@ -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又叫钨丝计划,它主要围绕内核引擎做了两方面的改进:数据结构设计和全阶段代码生成(WSCG,Whole Stage Code Generation)。
|
||||
|
||||
今天这一讲,我们就来说说Tungsten的设计初衷是什么,它的两方面改进到底解决了哪些问题,以及它给开发者到底带来了哪些性能红利。
|
||||
|
||||
## Tungsten在数据结构方面的设计
|
||||
|
||||
相比Spark Core,Tungsten在数据结构方面做了两个比较大的改进,一个是紧凑的二进制格式Unsafe Row,另一个是内存页管理。我们一个一个来说。
|
||||
|
||||
### Unsafe Row:二进制数据结构
|
||||
|
||||
Unsafe Row是一种字节数组,它可以用来存储下图所示Schema为(userID,name,age,gender)的用户数据条目。总的来说,所有字段都会按照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)有什么区别和联系呢?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
@@ -0,0 +1,148 @@
|
||||
<audio id="audio" title="24 | Spark 3.0(一):AQE的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三讲介绍的一样。
|
||||
|
||||
启发式的优化又叫RBO(Rule Based Optimization,基于规则的优化),它往往基于一些规则和策略实现,如谓词下推、列剪枝,这些规则和策略来源于数据库领域已有的应用经验。也就是说,**启发式的优化实际上算是一种经验主义**。
|
||||
|
||||
经验主义的弊端就是不分青红皂白、胡子眉毛一把抓,对待相似的问题和场景都使用同一类套路。Spark社区正是因为意识到了RBO的局限性,因此在2.2版本中推出了CBO(Cost Based Optimization,基于成本的优化)。
|
||||
|
||||
**CBO的特点是“实事求是”**,基于数据表的统计信息(如表大小、数据列分布)来选择优化策略。CBO支持的统计信息很丰富,比如数据表的行数、每列的基数(Cardinality)、空值数、最大值、最小值和直方图等等。因为有统计数据做支持,所以CBO选择的优化策略往往优于RBO选择的优化规则。
|
||||
|
||||
但是,**CBO也面临三个方面的窘境:“窄、慢、静”**。窄指的是适用面太窄,CBO仅支持注册到Hive Metastore的数据表,但在大量的应用场景中,数据源往往是存储在分布式文件系统的各类文件,如Parquet、ORC、CSV等等。
|
||||
|
||||
慢指的是统计信息的搜集效率比较低。对于注册到Hive Metastore的数据表,开发者需要调用ANALYZE TABLE COMPUTE STATISTICS语句收集统计信息,而各类信息的收集会消耗大量时间。
|
||||
|
||||
静指的是静态优化,这一点与RBO一样。CBO结合各类统计信息制定执行计划,一旦执行计划交付运行,CBO的使命就算完成了。换句话说,如果在运行时数据分布发生动态变化,CBO先前制定的执行计划并不会跟着调整、适配。
|
||||
|
||||
## AQE到底是什么?
|
||||
|
||||
考虑到RBO和CBO的种种限制,Spark在3.0版本推出了AQE(Adaptive Query Execution,自适应查询执行)。如果用一句话来概括,**AQE是Spark SQL的一种动态优化机制,在运行时,每当Shuffle Map阶段执行完毕,AQE都会结合这个阶段的统计信息,基于既定的规则动态地调整、修正尚未执行的逻辑计划和物理计划,来完成对原始查询语句的运行时优化**。
|
||||
|
||||
从定义中,我们不难发现,**AQE优化机制触发的时机是Shuffle Map阶段执行完毕。也就是说,AQE优化的频次与执行计划中Shuffle的次数一致**。反过来说,如果你的查询语句不会引入Shuffle操作,那么Spark SQL是不会触发AQE的。对于这样的查询,无论你怎么调整AQE相关的配置项,AQE也都爱莫能助。
|
||||
|
||||
对于AQE的定义,我相信你还有很多问题,比如,AQE依赖的统计信息具体是什么?既定的规则和策略具体指什么?接下来,我们一一来解答。
|
||||
|
||||
**首先,AQE赖以优化的统计信息与CBO不同,这些统计信息并不是关于某张表或是哪个列,而是Shuffle Map阶段输出的中间文件。**学习过Shuffle的工作原理之后,我们知道,每个Map Task都会输出以data为后缀的数据文件,还有以index为结尾的索引文件,这些文件统称为中间文件。每个data文件的大小、空文件数量与占比、每个Reduce Task对应的分区大小,所有这些基于中间文件的统计值构成了AQE进行优化的信息来源。
|
||||
|
||||
**其次,结合Spark SQL端到端优化流程图我们可以看到,AQE从运行时获取统计信息,在条件允许的情况下,优化决策会分别作用到逻辑计划和物理计划。**
|
||||
|
||||
<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需要同时结合两个分支中的Shuffle(Exchange)输出,才能判断是否可以降级为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为粒度做到负载均衡吗?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
@@ -0,0 +1,118 @@
|
||||
<audio id="audio" title="25 | Spark 3.0(二):DPP特性该怎么用?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/7d/c0a38cc0269edd30330ee25f7985a87d.mp3"></audio>
|
||||
|
||||
你好,我是吴磊。
|
||||
|
||||
DPP(Dynamic Partition Pruning,动态分区剪裁)是Spark 3.0版本中第二个引人注目的特性,它指的是在星型数仓的数据关联场景中,可以充分利用过滤之后的维度表,大幅削减事实表的数据扫描量,从整体上提升关联计算的执行性能。
|
||||
|
||||
今天这一讲,我们就通过一个电商场景下的例子,来说说什么是分区剪裁,什么是动态分区剪裁,它的作用、用法和注意事项,让你一次就学会怎么用好DPP。
|
||||
|
||||
## 分区剪裁
|
||||
|
||||
我们先来看这个例子。在星型(Start Schema)数仓中,我们有两张表,一张是订单表orders,另一张是用户表users。显然,订单表是事实表(Fact),而用户表是维度表(Dimension)。业务需求是统计所有头部用户贡献的营业额,并按照营业额倒序排序。那这个需求该怎么实现呢?
|
||||
|
||||
首先,我们来了解一下两张表的关键字段,看看查询语句应该怎么写。
|
||||
|
||||
```
|
||||
// 订单表orders关键字段
|
||||
userId, Int
|
||||
itemId, Int
|
||||
price, Float
|
||||
quantity, Int
|
||||
|
||||
// 用户表users关键字段
|
||||
id, Int
|
||||
name, String
|
||||
type, String //枚举值,分为头部用户和长尾用户
|
||||
|
||||
|
||||
```
|
||||
|
||||
给定上述数据表,我们只需把两张表做内关联,然后分组、聚合、排序,就可以实现业务逻辑,具体的查询语句如下。
|
||||
|
||||
```
|
||||
select (orders.price * order.quantity) as income, users.name
|
||||
from orders inner join users on orders.userId = users.id
|
||||
where users.type = ‘Head User’
|
||||
group by users.name
|
||||
order by income desc
|
||||
|
||||
```
|
||||
|
||||
看到这样的查询语句,再结合Spark SQL那几讲学到的知识,我们很快就能画出它的逻辑执行计划。
|
||||
|
||||
<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 Pruning),I/O效率的提升就会更加显著。那什么是分区剪裁呢?实际上,分区剪裁是谓词下推的一种特例,它指的是在分区表中下推谓词,并以文件系统目录为单位对数据集进行过滤。分区表就是通过指定分区键,然后使用partitioned by语句创建的数据表,或者是使用partitionBy语句存储的列存文件(如Parquet、ORC等)。
|
||||
|
||||
相比普通数据表,分区表特别的地方就在于它的存储方式。对于分区键中的每一个数据值,分区表都会在文件系统中创建单独的子目录来存储相应的数据分片。拿用户表来举例,假设用户表是分区表,且以type字段作为分区键,那么用户表会有两个子目录,前缀分别是“Head User”和“Tail User”。数据记录被存储于哪个子目录完全取决于记录中type字段的值,比如:所有type字段值为“Head User”的数据记录都被存储到前缀为“Head User”的子目录。同理,所有type字段值为“Tail User”的数据记录,全部被存放到前缀为“Tail User”的子目录。
|
||||
|
||||
不难发现,**如果过滤谓词中包含分区键,那么Spark SQL对分区表做扫描的时候,是完全可以跳过(剪掉)不满足谓词条件的分区目录,这就是分区剪裁。**例如,在我们的查询语句中,用户表的过滤谓词是“users.type = ‘Head User’”。假设用户表是分区表,那么对于用户表的数据扫描,Spark SQL可以完全跳过前缀为“Tail User”的子目录。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/d9/ee84e71580dc5fc61d0a542fdfca57d9.jpg" alt="" title="谓词下推与分区剪裁">
|
||||
|
||||
通过与谓词下推作对比,我们可以直观地感受分区剪裁的威力。如图所示,上下两行分别表示用户表在不做分区和做分区的情况下,Spark SQL对于用户表的数据扫描。在不做分区的情况下,用户表所有的数据分片全部存于同一个文件系统目录,尽管Parquet格式在注脚(Footer)中提供了type字段的统计值,Spark SQL可以利用谓词下推来减少需要扫描的数据分片,但由于很多分片注脚中的type字段同时包含‘Head User’和‘Tail User’(第一行3个浅绿色的数据分片),因此,用户表的数据扫描仍然会涉及4个数据分片。
|
||||
|
||||
相反,当用户表本身就是分区表时,由于type字段为‘Head User’的数据记录全部存储到前缀为‘Head User’的子目录,也就是图中第二行浅绿色的文件系统目录,这个目录中仅包含两个type字段全部为‘Head User’的数据分片。这样一来,Spark SQL可以完全跳过其他子目录的扫描,从而大幅提升I/O效率。
|
||||
|
||||
你可能会说:“既然分区剪裁这么厉害,那么我是不是也可以把它应用到事实表上去呢?毕竟事实表的体量更大,相比维度表,事实表上I/O效率的提升空间更大。”没错,如果事实表本身就是分区表,且过滤谓词中包含分区键,那么Spark SQL同样会利用分区剪裁特性来大幅减少数据扫描量。
|
||||
|
||||
不过,对于实际工作中的绝大多数关联查询来说,事实表都不满足分区剪裁所需的前提条件。比如说,要么事实表不是分区表,要么事实表上没有过滤谓词,或者就是过滤谓词不包含分区键。就拿电商场景的例子来说,查询中压根就没有与订单表相关的过滤谓词。因此,即便订单表本身就是分区表,Spark SQL也没办法利用分区剪裁特性。
|
||||
|
||||
对于这样的关联查询,我们是不是只能任由Spark SQL去全量扫描事实表呢?要是在以前,我们还没什么办法。不过,有了Spark 3.0推出的DPP特性之后,情况就大不一样了。
|
||||
|
||||
## 动态分区剪裁
|
||||
|
||||
我们刚才说了,DPP指的是在数据关联的场景中,Spark SQL利用维度表提供的过滤信息,减少事实表中数据的扫描量、降低I/O开销,从而提升执行性能。那么,DPP是怎么做到这一点的呢?它背后的逻辑是什么?为了方便你理解,我们还用刚刚的例子来解释。
|
||||
|
||||
<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.id;Value是投影中需要引用的数据列,在之前订单表与用户表的查询中,这里的引用列就是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列表,除了使用广播变量之外,你觉得还有其他的方法和途径吗?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
@@ -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实现方式。按照出现的时间顺序,分别是嵌套循环连接(NLJ,Nested Loop Join )、排序归并连接(SMJ,Shuffle Sort Merge Join)和哈希连接(HJ,Hash Join)。接下来,我们就借助一个数据关联的场景,来分别说一说这3种Join实现方式的工作原理。
|
||||
|
||||
假设,现在有事实表orders和维度表users。其中,users表存储用户属性信息,orders记录着用户的每一笔交易。两张表的Schema如下:
|
||||
|
||||
```
|
||||
// 订单表orders关键字段
|
||||
userId, Int
|
||||
itemId, Int
|
||||
price, Float
|
||||
quantity, Int
|
||||
|
||||
// 用户表users关键字段
|
||||
id, Int
|
||||
name, String
|
||||
type, String //枚举值,分为头部用户和长尾用户
|
||||
|
||||
```
|
||||
|
||||
我们的任务是要基于这两张表做内关联(Inner Join),同时把用户名、单价、交易额等字段投影出来。具体的SQL查询语句如下表:
|
||||
|
||||
```
|
||||
//SQL查询语句
|
||||
select orders.quantity, orders.price, orders.userId, users.id, users.name
|
||||
from orders inner join users on orders.userId = users.id
|
||||
|
||||
```
|
||||
|
||||
那么,对于这样一个关联查询,在3种不同的Join实现方式下,它是如何完成计算的呢?
|
||||
|
||||
### NLJ的工作原理
|
||||
|
||||
对于参与关联的两张数据表,我们通常会根据它们扮演的角色来做区分。其中,体量较大、主动扫描数据的表,我们把它称作外表或是驱动表;体量较小、被动参与数据扫描的表,我们管它叫做内表或是基表。那么,NLJ是如何关联这两张数据表的呢?
|
||||
|
||||
**NLJ是采用“嵌套循环”的方式来实现关联的。**也就是说,NLJ会使用内、外两个嵌套的for循环依次扫描外表和内表中的数据记录,判断关联条件是否满足,比如例子中的orders.userId = users.id,如果满足就把两边的记录拼接在一起,然后对外输出。
|
||||
|
||||
<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实现方式的差异与优劣势。
|
||||
|
||||
相比SMJ,HJ并不要求参与Join的两张表有序,也不需要维护两个游标来判断当前的记录位置,只要基表在Build阶段构建的哈希表可以放进内存,HJ算法就可以在Probe阶段遍历外表,依次与哈希表进行关联。
|
||||
|
||||
当数据能以广播的形式在网络中进行分发时,说明被分发的数据,也就是基表的数据足够小,完全可以放到内存中去。这个时候,相比NLJ、SMJ,HJ的执行效率是最高的。因此,在可以采用HJ的情况下,Spark自然就没有必要再去用SMJ这种前置开销比较大的方式去完成数据关联。
|
||||
|
||||
## Spark如何选择Join策略?
|
||||
|
||||
那么,在不同的数据关联场景中,对于这5种Join策略来说,也就是CPJ、BNLJ、SHJ、SMJ以及BHJ,Spark会基于什么逻辑取舍呢?我们来分两种情况进行讨论,分别是等值Join,和不等值Join。
|
||||
|
||||
### 等值Join下,Spark如何选择Join策略?
|
||||
|
||||
等值Join是指两张表的Join Key是通过等值条件连接在一起的。在日常的开发中,这种Join形式是最常见的,如t1 inner join t2 on **t1.id = t2.id**。
|
||||
|
||||
**在等值数据关联中,Spark会尝试按照BHJ > SMJ > SHJ的顺序依次选择Join策略。**在这三种策略中,执行效率最高的是BHJ,其次是SHJ,再次是SMJ。其中,SMJ和SHJ策略支持所有连接类型,如全连接、Anti Join等等。BHJ尽管效率最高,但是有两个前提条件:一是连接类型不能是全连接(Full Outer Join);二是基表要足够小,可以放到广播变量里面去。
|
||||
|
||||
那为什么SHJ比SMJ执行效率高,排名却不如SMJ靠前呢?这是个非常好的问题。我们先来说结论,相比SHJ,Spark优先选择SMJ的原因在于,SMJ的实现方式更加稳定,更不容易OOM。
|
||||
|
||||
回顾HJ的实现机制,在Build阶段,算法根据内表创建哈希表。在Probe阶段,为了让外表能够成功“探测”(Probe)到每一个Hash Key,哈希表要全部放进内存才行。坦白说,这个前提还是蛮苛刻的,仅这一点要求就足以让Spark对其望而却步。要知道,在不同的计算场景中,数据分布的多样性很难保证内表一定能全部放进内存。
|
||||
|
||||
而且在Spark中,SHJ策略要想被选中必须要满足两个先决条件,这两个条件都是对数据尺寸的要求。**首先,外表大小至少是内表的3倍。其次,内表数据分片的平均大小要小于广播变量阈值。**第一个条件的动机很好理解,只有当内外表的尺寸悬殊到一定程度时,HJ的优势才会比SMJ更显著。第二个限制的目的是,确保内表的每一个数据分片都能全部放进内存。
|
||||
|
||||
和SHJ相比,SMJ没有这么多的附加条件,无论是单表排序,还是两表做归并关联,都可以借助磁盘来完成。内存中放不下的数据,可以临时溢出到磁盘。单表排序的过程,我们可以参考Shuffle Map阶段生成中间文件的过程。在做归并关联的时候,算法可以把磁盘中的有序数据用合理的粒度,依次加载进内存完成计算。这个粒度可大可小,大到以数据分片为单位,小到逐条扫描。
|
||||
|
||||
正是考虑到这些因素,相比SHJ,Spark SQL会优先选择SMJ。事实上,在配置项spark.sql.join.preferSortMergeJoin默认为True的情况下,Spark SQL会用SMJ策略来兜底,确保作业执行的稳定性,压根就不会打算去尝试SHJ。开发者如果想通过配置项来调整Join策略,需要把这个参数改为False,这样Spark SQL才有可能去尝试SHJ。
|
||||
|
||||
### 不等值Join下,Spark如何选择Join策略?
|
||||
|
||||
接下来,我们再来说说不等值Join,它指的是两张表的Join Key是通过不等值条件连接在一起的。不等值Join其实我们在以前的例子中也见过,比如像查询语句t1 inner join t2 on **t1.date > t2.beginDate and t1.date <= t2.endDate**,其中的关联关系是依靠不等式连接在一起的。
|
||||
|
||||
**由于不等值Join只能使用NLJ来实现,因此Spark SQL可选的Join策略只剩下BNLJ和CPJ。**在同一种计算模式下,相比Shuffle,广播的网络开销更小。显然,在两种策略的选择上,Spark SQL一定会按照BNLJ > CPJ的顺序进行尝试。当然,BNLJ生效的前提自然是内表小到可以放进广播变量。如果这个条件不成立,那么Spark SQL只好委曲求全,使用笨重的CPJ策略去完成关联计算。
|
||||
|
||||
## 开发者能做些什么?
|
||||
|
||||
最后,我们再来聊聊,面对上述的5种Join策略,开发者还能做些什么呢?通过上面的分析,我们不难发现,Spark SQL对于这些策略的取舍也基于一些既定的规则。所谓计划赶不上变化,预置的规则自然很难覆盖多样且变化无常的计算场景。因此,当我们掌握了不同Join策略的工作原理,结合我们对于业务和数据的深刻理解,完全可以自行决定应该选择哪种Join策略。
|
||||
|
||||
<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 > SHJ > SMJ > BNLJ > 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两种机制来实现吗?为什么?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
@@ -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小表”场景中的所有情况,但是,分析并汇总案例的应对策略和解决办法,有利于我们在调优的过程中开阔思路、发散思维,从而避免陷入“面对问题无所适从”的窘境。
|
||||
|
||||
## 案例1:Join 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只好退而求其次,选择SMJ(Shuffle Sort Merge Join)的实现方式。
|
||||
|
||||
我们知道,SMJ机制会引入Shuffle,将上百GB的数据在全网分发可不是一个明智的选择。那么,根据“能省则省”的开发原则,我们有没有可能“省去”这里的Shuffle呢?要想省去Shuffle,我们只有一个办法,就是把SMJ转化成BHJ。你可能会说:“都说了好几遍了,小表的尺寸10GB远超广播阈值,我们还能怎么转化呢?”
|
||||
|
||||
办法总比困难多,我们先来反思,关联这两张表的目的是什么?目的是以维度组合(Join Keys)为基准,用内表的访问量替换掉外表的零值。那么,这两张表有哪些特点呢?**首先,两张表的Schema完全一致。其次,无论是在数量、还是尺寸上,两张表的Join Keys都远大于Payload。**那么问题来了,要达到我们的目的,一定要用那么多、那么长的Join Keys做关联吗?
|
||||
|
||||
答案是否定的。在上一讲,我们介绍过Hash Join的实现原理,在Build阶段,Hash Join使用哈希算法生成哈希表。在Probe阶段,哈希表一方面可以提供O(1)的查找效率,另一方面,在查找过程中,Hash Keys之间的对比远比Join Keys之间的对比要高效得多。受此启发,我们为什么不能计算Join Keys的哈希值,然后把生成的哈希值当作是新的Join Key呢?
|
||||
|
||||
<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调高一些,比如1GB,AQE才会把SMJ降级为BHJ。做了这些调优之后,在同样的集群规模下,作业的端到端执行时间从之前的5个小时缩减为30分钟。
|
||||
|
||||
让作业的执行性能提升了一个数量级之后,我们的调优就结束了吗?在调优的本质那一讲,我们一再强调,随着木桶短板的此消彼长,调优是一个不断持续的过程。在这个过程中,我们需要因循瓶颈的变化,动态地切换调优方法,去追求一种所有木板齐平、没有瓶颈的状态。
|
||||
|
||||
那么,当我们用动态Join策略,把SMJ策略中Shuffle引入的海量数据分发这块短板补齐之后,还有没有“新晋”的短板需要修理呢?
|
||||
|
||||
**对于案例中的这种星型关联,我们还可以利用DPP机制来减少事实表的扫描量,进一步减少I/O开销、提升性能。**和AQE不同,DPP并不需要开发者特别设置些什么,只要满足条件,DPP机制会自动触发。
|
||||
|
||||
但是想要使用DPP做优化,还有3个先决条件需要满足:
|
||||
|
||||
- DPP仅支持等值Joins,不支持大于或者小于这种不等值关联关系
|
||||
- 维表过滤之后的数据集,必须要小于广播阈值,因此开发者要注意调整配置项spark.sql.autoBroadcastJoinThreshold
|
||||
- 事实表必须是分区表,且分区字段(可以是多个)必须包含Join Key
|
||||
|
||||
我们可以直接判断出查询满足前两个条件,满足第一个条件很好理解。满足第二个条件是因为,经过第一步AQE的优化之后,广播阈值足够大,足以容纳过滤之后的维表。那么,要想利用DPP机制,我们必须要让orders成为分区表,也就是做两件事情:
|
||||
|
||||
- 创建一张新的订单表orders_new,并指定userId为分区键
|
||||
- 把原订单表orders的全部数据,灌进这张新的订单表orders_new
|
||||
|
||||
```
|
||||
//查询语句
|
||||
select (orders_new.price * orders_new.quantity) as revenue, users.name
|
||||
from orders_new inner join users on orders_new.userId = users.id
|
||||
where users.type = ‘Head User’
|
||||
group by users.name
|
||||
order by revenue desc
|
||||
|
||||
```
|
||||
|
||||
用orders_new表替换orders表之后,在同样的分布式环境下,查询时间就从30分钟进一步缩短到了15分钟。
|
||||
|
||||
你可能会说:“为了利用DPP,重新建表、灌表,这样也需要花费不少时间啊!这不是相当于把运行时间从查询转嫁到建表、灌数了吗?”你说的没错,确实是这么回事。如果为了查询效果,临时再去修改表结构、迁移数据确实划不来,属于“临时抱佛脚”。因此,为了最大限度地利用DPP,在做数仓规划的时候,开发者就应该结合常用查询与典型场景,提前做好表结构的设计,这至少包括Schema、分区键、存储格式等等。
|
||||
|
||||
## 案例3:小表数据分布均匀
|
||||
|
||||
在上面的两个案例中,我们都是遵循“能省则省”的开发原则,想方设法地把Shuffle Joins切换为Broadcast Joins,从而消除Shuffle。但是,总有那么一些“顽固”的场景,无论我们怎么优化,也没办法做到这一点。那么对于这些“顽固分子”,我们该怎么办呢?
|
||||
|
||||
我们知道,如果关联场景不满足BHJ条件,Spark SQL会优先选择SMJ策略完成关联计算。但是在上一讲我们说到,**当参与Join的两张表尺寸相差悬殊且小表数据分布均匀的时候,SHJ往往比SMJ的执行效率更高**。原因很简单,小表构建哈希表的开销要小于两张表排序的开销。
|
||||
|
||||
我们还是以上一个案例的查询为例,不过呢,这次我们把维表的过滤条件去掉,去统计所有用户贡献的营业额。在10台C5.4xlarge AWS EC2的分布式环境中,去掉过滤条件的SMJ花费了将近7个小时才完成两张表的关联计算。
|
||||
|
||||
```
|
||||
//查询语句
|
||||
select (orders.price * order.quantity) as revenue, users.name
|
||||
from orders inner join users on orders.userId = users.id
|
||||
group by users.name
|
||||
order by revenue desc
|
||||
|
||||
```
|
||||
|
||||
由于维表的查询条件不复存在,因此案例2中的两个优化方法,也就是AQE Join策略调整和DPP机制,也都失去了生效的前提。**这种情况下,我们不妨使用Join Hints来强制Spark SQL去选择SHJ策略进行关联计算**,调整后的查询语句如下表所示。
|
||||
|
||||
```
|
||||
//添加Join hints之后的查询语句
|
||||
select /*+ shuffle_hash(orders) */ (orders.price * order.quantity) as revenue, users.name
|
||||
from orders inner join users on orders.userId = users.id
|
||||
group by users.name
|
||||
order by revenue desc
|
||||
|
||||
```
|
||||
|
||||
将Join策略调整为SHJ之后,在同样的集群规模下,作业的端到端执行时间从之前的7个小时缩减到5个小时,相比调优前,我们获得了将近30%的性能提升。
|
||||
|
||||
需要注意的是,**SHJ要想成功地完成计算、不抛OOM异常,需要保证小表的每个数据分片都能放进内存**。这也是为什么,我们要求小表的数据分布必须是均匀的。如果小表存在数据倾斜的问题,那么倾斜分区的OOM将会是一个大概率事件,SHJ的计算也会因此而中断。
|
||||
|
||||
## 小结
|
||||
|
||||
今天这一讲,我们从3个案例出发,探讨并解锁了不同场景下“大表Join小表”的优化思路和应对方法。
|
||||
|
||||
首先,我们定义了什么是“大表Join小表”。一般来说,参与Join的两张表在尺寸上相差3倍以上,就可以看作是“大表Join小表”的计算场景。
|
||||
|
||||
其次,我们讲了3个大表Join小表场景下,无法选择BHJ的案例。
|
||||
|
||||
第一个案例是Join Keys远大于Payload的数据关联,我们可以使用映射方法(如哈希运算),用较短的字符串来替换超长的Join Keys,从而大幅缩减小表的存储空间。如果缩减后的小表,足以放进广播变量,我们就可以将SMJ转换为BHJ,=来消除繁重的Shuffle计算。需要注意的是,映射方法要能够有效地避免“映射冲突”的问题,避免出现不同的Join Keys被映射成同一个数值。
|
||||
|
||||
第二个案例是,如果小表携带过滤条件,且过滤条件的选择性很高,我们可以通过开启AQE的Join策略调整特性,在运行时把SMJ转换为BHJ,从而大幅提升执行性能。这里有两点需要我们特别注意:一是,为了能够成功完成转换,我们需要确保过滤之后的维表尺寸小于广播阈值;二是,如果大表本身是按照Join Keys进行分区的话,那么我们还可以充分利用DPP机制,来进一步缩减大表扫描的I/O开销,从而提升性能。
|
||||
|
||||
第三个案例是,如果小表不带过滤条件,且尺寸远超广播阈值。如果小表本身的数据分布比较均匀,我们可以考虑使用Join hints强行要求Spark SQL在运行时选择SHJ关联策略。一般来说,在“大表Join小表”的场景中,相比SMJ,SHJ的执行效率会更好一些。背后的原因在于,小表构建哈希表的开销,要小于两张表排序的开销。
|
||||
|
||||
## 每日一练
|
||||
|
||||
1. 对于案例1,我们的核心思路是用哈希值来替代超长的Join Keys,除了用哈希值以外,你觉得还有其他的思路或是办法,去用较短的字符串来取代超长的Join Keys吗?
|
||||
1. 对于案例2,利用AQE Join策略调整和DDP机制的关键,是确保过滤后的维表小于广播阈值。你能说说,都有哪些方法可以用来计算过滤后的维表大小吗?
|
||||
1. 对于案例3,假设20GB的小表存在数据倾斜,强行把SMJ转化为SHJ会抛OOM异常。这个时候,你认为还有可能继续优化吗?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
@@ -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("transactions")
|
||||
orders.createOrReplaceTempView("orders")
|
||||
|
||||
val query: String = "
|
||||
select sum(tx.price * tx.quantity) as revenue, o.orderId
|
||||
from transactions as tx inner join orders as o
|
||||
on tx.orderId = o.orderId
|
||||
where o.status = 'COMPLETE'
|
||||
and o.date between '2020-01-01' and '2020-03-31'
|
||||
group by o.orderId
|
||||
"
|
||||
|
||||
val outFile: String = _
|
||||
spark.sql(query).save.parquet(outFile)
|
||||
|
||||
|
||||
```
|
||||
|
||||
不难发现,在两张表的关联计算中,transactions的角色是外表,自然 orders的角色就是内表。需要指出的是,即便内表中有不少过滤条件,如订单状态为“完成”且成交日期满足一定范围,但过滤之后的内表仍然在百GB量级,难以放入广播变量。因此,这两张大表的关联计算,自然会退化到Shuffle Joins的实现机制。
|
||||
|
||||
那么,如果用“分而治之”的思路来做优化,代码应该怎么改呢?“分而治之”有两个关键因素,也就是内表拆分和外表重复扫描。我们不妨从这两个因素出发来调整原来的代码。
|
||||
|
||||
首先,内表拆分是否合理完全取决于拆分列的选取,而候选拆分列要同时满足基数适中、子表分布均匀,并且子表尺寸小于广播阈值等多个条件。纵观orders表的所有关键字段,只有date字段能够同时满足这些条件。因此,我们可以使用date字段,以天为单位对orders表做拆分,那么原代码中的查询语句需要作如下调整。
|
||||
|
||||
```
|
||||
//以date字段拆分内表
|
||||
val query: String = "
|
||||
select sum(tx.price * tx.quantity) as revenue, o.orderId
|
||||
from transactions as tx inner join orders as o
|
||||
on tx.orderId = o.orderId
|
||||
where o.status = 'COMPLETE'
|
||||
and o.date = '2020-01-01'
|
||||
group by o.orderId
|
||||
"
|
||||
|
||||
|
||||
```
|
||||
|
||||
你可能会说:“这不对吧,业务需求是计算一个季度的交易额,查询这么改不是只计算一天的量吗?”别着急,代码的调整还差一步:外表重复扫描。内表拆分之后,外表自然要依次与所有的子表做关联,最终把全部子关联的结果合并到一起,才算是完成“分而治之”的实现。
|
||||
|
||||
```
|
||||
//循环遍历dates、完成“分而治之”的计算
|
||||
val dates: Seq[String] = Seq("2020-01-01", "2020-01-02", … "2020-03-31")
|
||||
|
||||
for (date <- dates) {
|
||||
|
||||
val query: String = s"
|
||||
select sum(tx.price * tx.quantity) as revenue, o.orderId
|
||||
from transactions as tx inner join orders as o
|
||||
on tx.orderId = o.orderId
|
||||
where o.status = 'COMPLETE'
|
||||
and o.date = ${date}
|
||||
group by o.orderId
|
||||
"
|
||||
|
||||
val file: String = s"${outFile}/${date}"
|
||||
spark.sql(query).save.parquet(file)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
再次调整后的代码如上表所示,我们利用一个简单的for循环来遍历日期,从而让外表依次与子表做关联,并把子关联的计算结果直接写到outFile根目录下的子目录。代码的改动还是很简单的。不过,细心的你可能会发现:“这种写法,不是我们一直要极力避免的单机思维模式吗?”没错,单纯从写法上来看,这份代码的“单机思维”味道非常浓厚。
|
||||
|
||||
不过,对于“单机思维模式”的理解,我们不能仅仅停留在形式或是表面上。所谓单机思维模式,它指的是开发者不假思索地直入面向过程编程,忽略或无视分布式数据实体的编程模式。但在刚刚整理调优思路的过程中,我们一直把外表的重复扫描牢记于心,并想到通过利用DPP机制来避免它。因此,虽然我们使用了for循环,但并不会在运行时引入分布式数据集的重复扫描。
|
||||
|
||||
总的来说,在这个案例中,利用“分而治之”的调优方法,我们可以把所有“大表Join大表”的关联查询转化为“大表Join小表”,把原始的Shuffle Join转化为多个Broadcast Joins,而且Broadcast Joins又可以有效应对关联中的数据倾斜问题,可以说是一举两得。
|
||||
|
||||
## 小结
|
||||
|
||||
“大表Join大表”的第一种调优思路是“分而治之”,我们要重点掌握它的调优思路以及两个关键环节的优化处理。
|
||||
|
||||
“分而治之”的核心思想是通过均匀拆分内表的方式 ,把一个复杂而又庞大的Shuffle Join转化为多个Broadcast Joins,它的目的是,消除原有Shuffle Join中两张大表所引入的海量数据分发,大幅削减磁盘与网络开销的同时,从整体上提升作业端到端的执行性能。
|
||||
|
||||
在“分而治之”的调优过程中,内表的拆分最为关键,因为它肩负着Shuffle Join能否成功转化为Broadcast Joins的重要作用。而拆分的关键在于拆分列的选取。为了兼顾执行性能与开发效率,拆分列的基数要足够大,这样才能让子表小到足以放进广播变量,但同时,拆分列的基数也不宜过大,否则实现“分而治之”的开发成本就会陡然上升。通常来说,日期列往往是个不错的选择。
|
||||
|
||||
为了避免在调优的过程中引入额外的计算开销,我们要特别注意外表的重复扫描问题。针对外表的重复扫描,我们至少有两种应对方法。第一种是将外表全量缓存到内存,不过这种方法对于内存空间的要求较高,不具备普适性。第二种是利用Spark 3.0版本推出的DPP特性,在数仓设计之初,就以Join Key作为分区键,对外表做分区存储。
|
||||
|
||||
当我们做好了内表拆分,同时也避免了外表的重复扫描,我们就可以把原始的Shuffle Join转化为多个Broadcast Joins,在消除海量数据在全网分发的同时,避免引入额外的性能开销。那么毫无疑问,查询经过“分而治之”的调优过后,作业端到端的执行性能一定会得到大幅提升。
|
||||
|
||||
## 每日一练
|
||||
|
||||
在大表数据分布均匀的情况下,如果我们采用“分而治之”的调优技巧,要避免外表的重复扫描,除了采用缓存或是DPP机制以外,还有哪些其他办法?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
@@ -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 1,Executor 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),$"orderId"))
|
||||
val evenTx: DataFrame = transactions.filter(array_contains(lit(evenOrderIds),$"orderId"))
|
||||
|
||||
val skewOrders: DataFrame = orders.filter(array_contains(lit(skewOrderIds),$"orderId"))
|
||||
val evenOrders: DataFrame = orders.filter(array_contains(lit(evenOrderIds),$"orderId"))
|
||||
|
||||
```
|
||||
|
||||
拆分完成之后,我们就可以延续“分而治之”的思想,分别对这两部分应用不同的调优技巧。对于分布均匀的部分,我们把Shuffle Sort Merge Join转化为Shuffle Hash Join。
|
||||
|
||||
```
|
||||
//将分布均匀的数据分别注册为临时表
|
||||
evenTx.createOrReplaceTempView(“evenTx”)
|
||||
evenOrders.createOrReplaceTempView(“evenOrders”)
|
||||
|
||||
val evenQuery: String = “
|
||||
select /*+ shuffle_hash(orders) */ sum(tx.price * tx.quantity) as revenue, o.orderId
|
||||
from evenTx as tx inner join evenOrders as o
|
||||
on tx.orderId = o.orderId
|
||||
where o.status = ‘COMPLETE’
|
||||
and o.date between ‘2020-01-01’ and ‘2020-03-31’
|
||||
group by o.orderId
|
||||
”
|
||||
val evenResults: DataFrame = spark.sql(evenQuery)
|
||||
|
||||
```
|
||||
|
||||
对于存在倾斜的部分,我们要祭出“两阶段Shuffle”的杀手锏。首先,在第一阶段,我们需要给两张表分别加盐,对外表(交易表)做“随机加盐”,对内表(订单表)做“复制加盐”。
|
||||
|
||||
```
|
||||
import org.apache.spark.sql.functions.udf
|
||||
|
||||
//定义获取随机盐粒的UDF
|
||||
val numExecutors: Int = _
|
||||
val rand = () => scala.util.Random.nextInt(numExecutors)
|
||||
val randUdf = udf(rand)
|
||||
|
||||
//第一阶段的加盐操作。注意:保留orderId字段,用于后期第二阶段的去盐化
|
||||
|
||||
//外表随机加盐
|
||||
val saltedSkewTx = skewTx.withColumn(“joinKey”, concat($“orderId”, lit(“_”), randUdf()))
|
||||
|
||||
//内表复制加盐
|
||||
var saltedskewOrders = skewOrders.withColumn(“joinKey”, concat($“orderId”, lit(“_”), lit(1)))
|
||||
for (i <- 2 to numExecutors) {
|
||||
saltedskewOrders = saltedskewOrders union skewOrders.withColumn(“joinKey”, concat($“orderId”, lit(“_”), lit(i)))
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
两张表分别做完加盐处理之后,我们就可以使用与之前类似的查询语句,对它们执行后续的Shuffle、关联与聚合等操作。
|
||||
|
||||
```
|
||||
//将加盐后的数据分别注册为临时表
|
||||
saltedSkewTx.createOrReplaceTempView(“saltedSkewTx”)
|
||||
saltedskewOrders.createOrReplaceTempView(“saltedskewOrders”)
|
||||
|
||||
val skewQuery: String = “
|
||||
select /*+ shuffle_hash(orders) */ sum(tx.price * tx.quantity) as initialRevenue, o.orderId, o.joinKey
|
||||
from saltedSkewTx as tx inner join saltedskewOrders as o
|
||||
on tx.joinKey = o.joinKey
|
||||
where o.status = ‘COMPLETE’
|
||||
and o.date between ‘2020-01-01’ and ‘2020-03-31’
|
||||
group by o.joinKey
|
||||
”
|
||||
//第一阶段加盐、Shuffle、关联、聚合后的初步结果
|
||||
val skewInitialResults: DataFrame = spark.sql(skewQuery)
|
||||
|
||||
```
|
||||
|
||||
得到第一阶段的初步结果之后,我们就可以开始执行第二阶段的计算了,也就是“去盐化、Shuffle与聚合”这三个操作。去盐化的目的实际上就是把计算的粒度,从加盐的joinKey恢复为原来的orderId。由于在最初加盐的时候,我们对orderId字段进行了保留,因此在第二阶段的计算中,我们只要在orderId字段之上执行聚合操作,就能达到我们想要的“去盐化”效果。
|
||||
|
||||
```
|
||||
val skewResults: DataFrame = skewInitialResults.select(“initialRevenue”, “orderId”)
|
||||
.groupBy(col(“orderId”)).agg(sum(col(“initialRevenue”)).alias(“revenue”))
|
||||
|
||||
```
|
||||
|
||||
在完成了第二阶段的计算之后,我们拿到了“两阶段Shuffle”的计算结果。最终,只需要把这份结果与先前均匀部分的关联结果进行合并,我们就能实现以Executors为粒度平衡计算负载的优化过程。
|
||||
|
||||
```
|
||||
evenResults union skewResults
|
||||
|
||||
```
|
||||
|
||||
#### 执行性能与开发成本的博弈
|
||||
|
||||
你可能会说:“我的天呐!为了优化这个场景的计算,这得花多大的开发成本啊!又是分而治之,又是两阶段Shuffle的,这么大的开发投入真的值得吗?”
|
||||
|
||||
这个问题非常好。我们要明确的是,分而治之外加两阶段Shuffle的调优技巧的初衷,是为了解决AQE无法以Executors为粒度平衡计算负载的问题。因此,这项技巧取舍的关键就在于,Executors之间的负载倾斜是否构成整个关联计算的性能瓶颈。如果这个问题的答案是肯定的,我们的投入就是值得的。
|
||||
|
||||
## 小结
|
||||
|
||||
今天这一讲,你需要掌握以Shuffle Join的方式去应对“大表Join大表”的计算场景。数据分布不同,应对方法也不尽相同。
|
||||
|
||||
当参与Join的两张表数据分布比较均匀,而且内表的数据分片能够完全放入内存,Shuffle Hash Join的计算效率往往高于Shuffle Sort Merge Join,后者是Spark SQL默认的关联机制。你可以使用关键字“shuffle_hash”的Join Hints,强制Spark SQL在运行时选择Shuffle Hash Join实现机制。对于内表数据分片不能放入内存的情况,你可以结合“三足鼎立”的调优技巧,调整并行度、并发度与执行内存这三类参数,来满足这一前提条件。
|
||||
|
||||
当参与Join的两张表存在数据倾斜时,如果倾斜的情况在集群内的Executors之间较为均衡,那么最佳的处理方法就是,利用AQE提供的自动倾斜处理机制。你只需要设置好以下三个参数,剩下的事情交给AQE就好了。
|
||||
|
||||
- spark.sql.adaptive.skewJoin.skewedPartitionFactor,判定倾斜的膨胀系数。
|
||||
- spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes,判定倾斜的最低阈值。
|
||||
- spark.sql.adaptive.advisoryPartitionSizeInBytes,以字节为单位,定义拆分粒度。
|
||||
|
||||
但是,如果倾斜问题仅集中在少数的几个Executors中,而且这些负载过高的Executors已然成为性能瓶颈,我们就需要采用“分而治之”外加“两阶段Shuffle”的调优技巧去应对。“分而治之”指的是根据Join Keys的倾斜与否,将内外表的数据分为两部分分别处理。其中,均匀的部分可以使用Shuffle Hash Join来完成计算,倾斜的部分需要用“两阶段Shuffle”进行处理。
|
||||
|
||||
两阶段Shuffle的关键在于加盐和去盐化。加盐的目的是打散数据分布、平衡Executors之间的计算负载,从而消除Executors单点瓶颈。去盐化的目的是还原原始的关联逻辑。尽管两阶段Shuffle的开发成本较高,但只要获得的性能收益足够显著,我们的投入就是值得的。
|
||||
|
||||
## 每日一练
|
||||
|
||||
1. 当尝试将Join Keys是否倾斜作为“分而治之”的划分依据时,你觉得我们该依据什么标准把Join Keys划分为倾斜组和非倾斜组呢?
|
||||
1. 无论是AQE的自动倾斜处理,还是开发者的“两阶段Shuffle”,本质上都是通过“加盐”与“去盐化”的两步走,在维持关联关系的同时平衡不同粒度下的计算负载。那么,这种“加盐”与“去盐化”的优化技巧,是否适用于所有的关联场景?如果不是,都有哪些场景没办法利用AQE的自动倾斜处理,或是我们的“两阶段Shuffle”呢?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
273
极客时间专栏/Spark性能调优实战/Spark SQL 性能调优篇/30| 应用开发:北京市小客车(汽油车)摇号趋势分析.md
Normal file
273
极客时间专栏/Spark性能调优实战/Spark SQL 性能调优篇/30| 应用开发:北京市小客车(汽油车)摇号趋势分析.md
Normal 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都是(batchNum,carNum),也就是(摇号批次,申请编号)。总之,事实表和维度表在存储方式上都做了分区设计,且分区键都是batchNum。
|
||||
|
||||
### 准备开发环境
|
||||
|
||||
数据下载、解压完成之后,然后我们再来准备开发环境。首先,我们来说说开发语言。要完成“趋势分析应用”的开发,你可以结合个人偏好,使用Python、Java、Scala三种语言中的任意一种。由于我本人习惯使用Scala做开发,因此整个项目的代码都是用Scala实现的。如果你是Java或是Python开发者,也完全不必担心,结合后续应用逻辑的讲解与Scala版本的参考实现,我相信你也很快能完成应用的开发。
|
||||
|
||||
“趋势分析应用”非常轻量,Scala版本的参考实现不超过200行代码。因此,只使用Sublime甚至是VI这样的纯文本编辑器,我们也能很快实现。不过,为了提高开发效率,以及方便后续应用的打包和部署,我还是推荐你使用集成式的IDE,比如IntelliJ IDEA、Eclipse 、IntelliJ PyCharm等等。IDE的选取原则和开发语言一样,只要选择自己最顺手的就行了。
|
||||
|
||||
### 准备运行环境
|
||||
|
||||
最后是运行环境,由于咱们的应用比较轻量,而且数据量较小,解压之后的Parquet文件总大小还不超4GB,因此,你甚至可以用手里的笔记本电脑或是台式PC,就可以把应用从头到尾地跑通。选择“轻装上阵”主要是考虑到,不少同学可能不方便搭建分布式的物理集群,我们要确保这部分同学不会因为硬件的限制而不能参与实战。
|
||||
|
||||
不过,毕竟咱们这两讲的初衷和重点是性能调优实践,网络开销优化是其中关键的一环。因此,有条件的同学,我还是鼓励你搭建分布式的物理集群,或是采用云原生的分布式环境。一来这样的分布式环境更接近实际工作中的真实情况,二来调优前后的性能差异会更加地显著,有利于你加深理解不同调优技巧的作用和效果。
|
||||
|
||||
我这边选择了3台物理节点,它们的资源配置分别如下。其实,为了跑通应用和做性能对比,你并不需要这么强悍的机器配置。我这么做主要是贪图执行效率,因为想要说明不同调优技巧的作用与功效,我只需要拿到调优前后的对比结果就可以了,这样的配置可以减少我的等待时间。
|
||||
|
||||
<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"${rootPath}/apply"
|
||||
val applyNumbersDF = spark.read.parquet(hdfs_path_apply)
|
||||
applyNumbersDF.count
|
||||
// 中签者数据
|
||||
val hdfs_path_lucky = s"${rootPath}/lucky"
|
||||
val luckyDogsDF = spark.read.parquet(hdfs_path_lucky)
|
||||
luckyDogsDF.count
|
||||
|
||||
|
||||
```
|
||||
|
||||
把这段代码丢到spark-shell,或是打包部署到分布式环境去运行,我们很快就能够得到计算结果。截至到2019年底,摇号总人次为381972118,也就是3.8亿人次;中签的人数是1150828,也就是115万人。你可能会好奇:“摇号总人次为什么会有这么高的数量级?”
|
||||
|
||||
这其实并不奇怪。首先,同一个人可能参与多个批次的摇号,比如我就至少参加了60个批次的摇号(苦啊!)。再者,从2016年开始,小汽车摇号有了倍率这个概念。倍率的设计初衷,是让申请者的中签概率随着参与批次数量的增加而成比例地增加。也就是说,参与了60次摇号的人比仅参与10次摇号的人的中签概率更高。不过,官方对于倍率的实现略显简单粗暴。如果你去观察apply目录下2016年以后的批次文件就会发现,所谓的倍率实际上就是申请编号的副本数量。
|
||||
|
||||
正是出于以上两个原因,摇号总人次的体量才会有3.8亿人次。如果我们把倍率这个因素去掉,实际的摇号体量会是什么量级呢?
|
||||
|
||||
```
|
||||
val applyDistinctDF = applyNumbersDF.select("batchNum", "carNum").distinct
|
||||
applyDistinctDF.count
|
||||
|
||||
```
|
||||
|
||||
以(batchNum,carNum)为粒度进行去重计数,我们就能得到实际的摇号体量是135009819,也就是1.35亿人次。这意味着,从2011年到2019年这9年的时间里,有1.35亿人次参与了一项“抽奖游戏”,但是仅有115万人幸运中奖,摇号之难可见一斑。
|
||||
|
||||
### 案例2:摇号次数分布
|
||||
|
||||
接下来,我们进一步向下追踪(Drill Down),挖掘一下不同人群摇号次数的分布,也就是统计所有申请者累计参与了多少次摇号,所有中签者摇了多少次号才能幸运地摇中签。对于这两个统计计算,我们需要消除倍率的影响。也就是说,同一个申请编号在同一个批次中应该只保留一份副本。因此,我们需要使用去重之后的“申请者表”:applyDistinctDF。
|
||||
|
||||
#### 场景1:参与摇号的申请者
|
||||
|
||||
首先,我们先来分析所有申请者的分布情况,当然也包括中签者。根据刚刚介绍的“业务需求”,我们很快就能写出相应的查询语句。
|
||||
|
||||
```
|
||||
val result02_01 = applyDistinctDF
|
||||
.groupBy(col("carNum"))
|
||||
.agg(count(lit(1)).alias("x_axis"))
|
||||
.groupBy(col("x_axis"))
|
||||
.agg(count(lit(1)).alias("y_axis"))
|
||||
.orderBy("x_axis")
|
||||
|
||||
result02_01.write.format("csv").save("_")
|
||||
|
||||
```
|
||||
|
||||
将上述代码付诸执行,我们会得到如下图所示的计算结果。其中,横坐标代表申请者参与过的摇号批次次数,纵坐标是对应的参与人数。从2011年到2013年,摇号是每月一次的。而从2014开始,摇号是每两个月一次的。因此,截至到2019年底,总共有72(12 * 3 + 6 * 6)次摇号。所以,我们看到横坐标的值域是从1到72,1表示摇过1次的人,72就比较惨了,它表示摇过72次的人。
|
||||
|
||||
<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("carNum"), Seq("carNum"), "inner")
|
||||
.groupBy(col("carNum")).agg(count(lit(1)).alias("x_axis"))
|
||||
.groupBy(col("x_axis")).agg(count(lit(1)).alias("y_axis"))
|
||||
.orderBy("x_axis")
|
||||
|
||||
result02_02.write.format("csv").save("_")
|
||||
|
||||
|
||||
```
|
||||
|
||||
将上述代码付诸执行,我们会得到如下图所示的计算结果,其中横纵坐标的含义与场景1一样,分别是摇号批次数和对应的人数分布。我们发现,随着摇号次数的逐级递增,人数的分布完全是单调递减的。也就是说,摇号的次数越多,中签者的数量越少。我能想到的一个原因是,摇号的次数越高,对应的参与人数就越少,这一点在场景1已经得到了验证。这个其实也不难理解,能一直坚持摇60次以上的玩家,真的都是骨灰级玩家。那么,参与的人基数小,中签者的数量自然就更少。<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("batchNum"))
|
||||
.agg(count(lit(1)).alias("denominator"))
|
||||
|
||||
// 统计每批次中签者的人数
|
||||
val lucky_molecule = luckyDogsDF
|
||||
.groupBy(col("batchNum"))
|
||||
.agg(count(lit(1)).alias("molecule"))
|
||||
|
||||
val result03 = apply_denominator
|
||||
.join(lucky_molecule, Seq("batchNum"), "inner")
|
||||
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
|
||||
.orderBy("batchNum")
|
||||
|
||||
result03.write.format("csv").save("_")
|
||||
|
||||
```
|
||||
|
||||
我们得到的中签率示意图如下所示。其中,横坐标为各个摇号批次,从201101到201906,也就是从2011年的第一批到2019年的第72批,纵坐标就是中签率。从中我们可以很直观地看到,随着摇号批次的推进,中签率呈锐减的趋势。201101批次的中签率在9.4%左右,不到10%。而201906批次的中签率为1.9‰,也就是千分之一点九。这么看来,1000个人里面能摇上号的还凑不够两个人,这也难怪摇号如此之难了。
|
||||
|
||||
<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("batchNum").like("2018%"))
|
||||
.groupBy(col("batchNum"))
|
||||
.agg(count(lit(1)).alias("molecule"))与
|
||||
|
||||
// 通过与筛选出的中签数据按照批次做关联,计算每期的中签率
|
||||
val result04 = apply_denominator
|
||||
.join(lucky_molecule_2018, Seq("batchNum"), "inner")
|
||||
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
|
||||
.orderBy("batchNum")
|
||||
|
||||
result04.write.format("csv").save("_")
|
||||
|
||||
```
|
||||
|
||||
<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("batchNum") >= "201601")
|
||||
.select("carNum"), Seq("carNum"), "inner")
|
||||
.groupBy(col("batchNum"),col("carNum"))
|
||||
.agg(count(lit(1)).alias("multiplier"))
|
||||
.groupBy("carNum")
|
||||
.agg(max("multiplier").alias("multiplier"))
|
||||
.groupBy("multiplier")
|
||||
.agg(count(lit(1)).alias("cnt"))
|
||||
.orderBy("multiplier")
|
||||
|
||||
result05_01.write.format("csv").save("_")
|
||||
|
||||
```
|
||||
|
||||
中签者的倍率分布如下图所示。其中,横坐标为中签者的倍率,更准确地说,是中签者在参与的摇号批次中最大的副本数量,纵坐标是人数分布。通过观察执行结果我们不难发现,中签者的倍率呈现明显的正态分布。因此,从这张图我们可以得到初步结论:要想摇中车牌号,你并不需要很高的倍率。换句话说,对于中签这件事来说,倍率的作用和贡献并不是线性递增的。
|
||||
|
||||
<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("batchNum") >= "201601")
|
||||
.groupBy(col("batchNum"), col("carNum"))
|
||||
.agg(count(lit(1)).alias("multiplier"))
|
||||
.groupBy("carNum")
|
||||
.agg(max("multiplier").alias("multiplier"))
|
||||
.groupBy("multiplier")
|
||||
.agg(count(lit(1)).alias("apply_cnt"))
|
||||
|
||||
// Step02: 将各个倍率下的申请人数与各个倍率下的中签人数左关联,并求出各个倍率下的中签率
|
||||
val result05_02 = apply_multiplier_2016_2019
|
||||
.join(result05_01.withColumnRenamed("cnt", "lucy_cnt"), Seq("multiplier"), "left")
|
||||
.na.fill(0)
|
||||
.withColumn("ratio", round(col("lucy_cnt")/col("apply_cnt"), 5))
|
||||
.orderBy("multiplier")
|
||||
|
||||
result05_02.write.format("csv").save("_")
|
||||
|
||||
```
|
||||
|
||||
不同倍率下的中签比例如下图所示。其中横坐标为倍率,纵坐标有两个。蓝色柱状图体代表中签人数,它的分布与场景1的分布是一致的;绿色柱状条表示的是中签比例,它表示在同一个倍率下,中签人数与申请人数的比值。
|
||||
|
||||
<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. 你认为,倍率对于中签的贡献和作用微乎其微的原因是什么呢?
|
||||
|
||||
期待在留言区看到你的思考和答案,我们下一讲见!
|
||||
351
极客时间专栏/Spark性能调优实战/Spark SQL 性能调优篇/31 | 性能调优:手把手带你提升应用的执行性能.md
Normal file
351
极客时间专栏/Spark性能调优实战/Spark SQL 性能调优篇/31 | 性能调优:手把手带你提升应用的执行性能.md
Normal 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"${rootPath}/apply"
|
||||
val applyNumbersDF = spark.read.parquet(hdfs_path_apply)
|
||||
applyNumbersDF.count
|
||||
// 中签者数据
|
||||
val hdfs_path_lucky = s"${rootPath}/lucky"
|
||||
val luckyDogsDF = spark.read.parquet(hdfs_path_lucky)
|
||||
luckyDogsDF.count
|
||||
|
||||
// 申请者数据(去掉倍率的影响)
|
||||
val applyDistinctDF = applyNumbersDF.select("batchNum", "carNum").distinct
|
||||
applyDistinctDF.count
|
||||
|
||||
```
|
||||
|
||||
从上面的代码实现中,我们不难发现,短短的几行代码共有3个Actions,也就是3个不同数据集上的count操作,这3个Actions会触发3个Spark Jobs。其中,前2个Jobs都是读取数据源之后立即计数,没什么优化空间。第3个Job是在applyNumbersDF之上做去重,然后再统计计数。结合上一讲对于不同案例的讲解,我们知道,applyNumbersDF、luckyDogsDF和applyDistinctDF这3个数据集,在后续的案例中会被反复引用。
|
||||
|
||||
因为上述3个数据集的引用次数过于频繁,所以我们甚至都不用去计算“运行成本占比”,就可以判定:利用Cache一定有利于提升执行性能。
|
||||
|
||||
>
|
||||
<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("carNum"))
|
||||
.agg(count(lit(1)).alias("x_axis"))
|
||||
.groupBy(col("x_axis"))
|
||||
.agg(count(lit(1)).alias("y_axis"))
|
||||
.orderBy("x_axis")
|
||||
|
||||
result02_01.write.format("csv").save("_")
|
||||
|
||||
```
|
||||
|
||||
因此,场景1的计算实际上就是2次Word Count而已,只不过第一次的Word是“carNum”,而第二次的Word是“x_axis”。那么,对于这样的“Word Count”,我们都有哪些调优思路呢?
|
||||
|
||||
在配置项调优那一讲,我们专门介绍了Shuffle调优的一些常规方法,比如调整读写缓冲区大小、By Pass排序操作等等。除此之外,我们的脑子里一定要有一根弦:**Shuffle的本质是数据的重新分发,凡是有Shuffle操作的地方都需要关注数据分布。所以对于过小的数据分片,我们要有意识地对其进行合并**。再者,在案例1中我们提到,applyNumbersDF、luckyDogsDF和applyDistinctDF在后续的案例中会被反复引用,**因此给applyDistinctDF加Cache也是一件顺理成章的事情**。
|
||||
|
||||
调优的思路这么多,那为了演示每一种调优方法的提升效果,我会从常规操作、数据分区合并、加Cache这3个方向出发,分别对场景1进行性能调优。不过,需要说明的是,咱们这么做的目的,一来是为了开阔调优思路,二来是为了复习之前学习过的调优技巧。
|
||||
|
||||
当然了,在实际工作中,我们一般没有时间和精力像现在这样,一个方法、一个方法去尝试。那么,效率最高的做法应该是遵循我们一直强调的调优方法论,也就是**先去应对木桶的短板、消除瓶颈,优先解决主要矛盾,然后在时间、精力允许的情况下,再去应对次短的木板**。
|
||||
|
||||
那么问题来了,你认为上述3种调优思路分别应对的是哪些“木板”?这些“木板”中哪一块是最短的?你又会优先采用哪种调优技巧?接下来,我们就带着这些问题,依次对场景1做调优。
|
||||
|
||||
#### 思路1:Shuffle常规优化
|
||||
|
||||
刚刚咱们提到,Shuffle的常规优化有两类:一类是By Pass排序操作,一类是调整读写缓冲区。而By Pass排序有两个前提条件:一是计算逻辑不涉及聚合或排序;二是Reduce阶段的并行度要小于参数spark.shuffle.sort.bypassMergeThreshold的设置值。显然,场景1不符合要求,计算逻辑既包含聚合也包含排序。所以,我们就只有调整读写缓冲区这一条路可走了。
|
||||
|
||||
实际上,读写缓冲区的调优也是有前提的,因为这部分内存消耗会占用Execution Memory内存区域,所以提高缓冲区大小的前提是Execution Memory内存比较充裕。由于咱们使用的硬件资源比较强劲,而且小汽车摇号数据整体体量偏小,因此咱们还是有一些“资本”对读写缓冲区做调优的。具体来说,我们需要调整如下两个配置项:
|
||||
|
||||
- **spark.shuffle.file.buffer,Map阶段写入缓冲区大小**
|
||||
- **spark.reducer.maxSizeInFlight,Reduce阶段读缓冲区大小**
|
||||
|
||||
由于读写缓冲区都是以Task为粒度进行设置的,因此调整这两个参数的时我们要小心一点,一般来说50%往往是个不错的开始,对比基准与优化设置如下表所示。
|
||||
|
||||
<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("carNum"))
|
||||
.agg(count(lit(1)).alias("x_axis"))
|
||||
|
||||
.groupBy(col("x_axis"))
|
||||
.agg(count(lit(1)).alias("y_axis"))
|
||||
|
||||
.orderBy("x_axis")
|
||||
|
||||
result02_01.write.format("csv").save("_")
|
||||
|
||||
```
|
||||
|
||||
并行度由配置项spark.sql.shuffle.partitions决定,其默认大小为200,也就是200个数据分区。而对于数据集存储大小的估算,我们需要用到下面的函数。
|
||||
|
||||
```
|
||||
def sizeNew(func: => DataFrame, spark: => SparkSession): String = {
|
||||
|
||||
val result = func
|
||||
|
||||
val lp = result.queryExecution.logical
|
||||
|
||||
val size = spark.sessionState.executePlan(lp).optimizedPlan.stats.sizeInBytes
|
||||
|
||||
"Estimated size: " + size/1024 + "KB"
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
给定DataFrame,sizeNew函数可以返回该数据集在内存中的精确大小。把applyDistinctDF作为实参,调用sizeNew函数,返回的估算尺寸为2.6 GB。将数据集尺寸除以并行度,我们就能得到Reduce阶段每个数据分片的存储大小,也就是13 MB(也就是2.6 GB / 200)。通常来说,数据分片的尺寸大小在200 MB左右为宜,13 MB的分片尺寸显然过小。
|
||||
|
||||
在调度系统那一讲(第5讲),我们说过,如果需要处理的数据分片过小,相较于数据处理,Task调度开销将变得异常显著,而这样会导致CPU利用降低,执行性能变差。因此,为了提升CPU利用率进而提升整体的执行效率,我们需要对过小的数据分片做合并。这个时候,AQE的自动分区合并特性就可以帮我们做这件事情。
|
||||
|
||||
不过,要想充分利用AQE的自动分区合并特性,我们还需要对相关的配置项进行调整。这里,你直接看场景1是怎么设置这些配置项的就可以了。
|
||||
|
||||
<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("carNum"), Seq("carNum"), "inner")
|
||||
.groupBy(col("carNum")).agg(count(lit(1)).alias("x_axis"))
|
||||
.groupBy(col("x_axis")).agg(count(lit(1)).alias("y_axis"))
|
||||
.orderBy("x_axis")
|
||||
|
||||
result02_02.write.format("csv").save("_")
|
||||
|
||||
```
|
||||
|
||||
参与关联的两张表分别是applyDistinctDF和luckyDogsDF,其中applyDistinctDF是去重之后的摇号数据,luckyDogsDF包含的是中签者的申请编号与批次号。applyDistinctDF包含1.35条数据记录,而luckyDogsDF仅仅包含115万条数据记录。很显然,二者之间的数据关联属于数仓中常见的“大表Join小表”。
|
||||
|
||||
遇到“大表Join小表”的计算场景,我们最先应该想到的调优技巧一定是广播变量。毕竟,我们一直都在不遗余力地强调Broadcast Joins的优势与收益。在这里,我再强调一次,你一定要掌握使用广播变量优化数据关联的调优技巧。毫不夸张地说,广播变量是“性价比”最高的调优技巧,且没有之一。
|
||||
|
||||
要利用广播变量来优化applyDistinctDF与luckyDogsDF的关联计算,我们需要做两件事情。**第一件,估算luckyDogsDF数据表在内存中的存储大小。第二件,设置广播阈值配置项spark.sql.autoBroadcastJoinThreshold。**
|
||||
|
||||
对于分布式数据集的尺寸预估,我们还是使用sizeNew函数,把luckyDogsDF作为实参,调用sizeNew函数,返回的估算尺寸为18.5MB。有了这个参考值,我们就可以设置广播阈值了。要把applyDistinctDF与luckyDogsDF的关联计算转化为Broadcast Join,只要让广播阈值大于18.5MB就可以,我们不妨把这个参数设置为20MB。
|
||||
|
||||
<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("batchNum"))
|
||||
.agg(count(lit(1)).alias("denominator"))
|
||||
|
||||
// 统计每批次中签者的人数
|
||||
val lucky_molecule = luckyDogsDF
|
||||
.groupBy(col("batchNum"))
|
||||
.agg(count(lit(1)).alias("molecule"))
|
||||
|
||||
val result03 = apply_denominator
|
||||
.join(lucky_molecule, Seq("batchNum"), "inner")
|
||||
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
|
||||
.orderBy("batchNum")
|
||||
|
||||
result03.write.format("csv").save("_")
|
||||
|
||||
```
|
||||
|
||||
由于2011年到2019年总共有72个摇号批次,因此第一步计算得到结果集,也就是apply_denominator和lucky_molecule各自有72条数据记录。显然,两个如此之小的数据集做关联不存在什么调优空间。
|
||||
|
||||
因此,对于案例3来说,调优的关键在于第一步涉及的两个单表Shuffle。关于单表Shuffle的调优思路与技巧,我们在案例2的场景1做过详细的分析与讲解,因此,applyDistinctDF和luckyDogsDF两张表的Shuffle优化就留给你作为课后练习了。
|
||||
|
||||
## 案例4的性能调优:中签率局部洞察
|
||||
|
||||
与案例3不同,案例4只关注2018年的中签率变化趋势,我们先来回顾案例4的代码实现。
|
||||
|
||||
```
|
||||
// 筛选出2018年的中签数据,并按照批次统计中签人数
|
||||
val lucky_molecule_2018 = luckyDogsDF
|
||||
.filter(col("batchNum").like("2018%"))
|
||||
.groupBy(col("batchNum"))
|
||||
.agg(count(lit(1)).alias("molecule"))
|
||||
|
||||
// 通过与筛选出的中签数据按照批次做关联,计算每期的中签率
|
||||
val result04 = apply_denominator
|
||||
.join(lucky_molecule_2018, Seq("batchNum"), "inner")
|
||||
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
|
||||
.orderBy("batchNum")
|
||||
|
||||
result04.write.format("csv").save("_")
|
||||
|
||||
```
|
||||
|
||||
从代码实现来看,案例4相比案例3唯一的改动,就是在luckyDogsDF做统计计数之前增加了摇号批次的过滤条件,也就是filter(col("batchNum").like("2018%"))。你可能会说:“案例4的改动可以说是微乎其微,它的调优空间和调优方法应该和案例3没啥区别”。还真不是,还记得Spark 3.0推出的DPP新特性吗?添加在luckyDogsDF表上的这个不起眼的过滤谓词,恰恰让DPP有了用武之地。
|
||||
|
||||
在DPP那一讲,我们介绍过开启DPP的3个前提条件:
|
||||
|
||||
- 事实表必须是分区表,且分区字段(可以是多个)必须包含Join Key
|
||||
- DPP仅支持等值Joins,不支持大于、小于这种不等值关联关系
|
||||
- 维表过滤之后的数据集,必须要小于广播阈值,因此,你要注意调整配置项spark.sql.autoBroadcastJoinThreshold
|
||||
|
||||
那么,这3个前提条件是怎么影响案例4的性能调优的呢?
|
||||
|
||||
首先,在上一讲,我们介绍过摇号数据的目录结构,apply和lucky目录下的数据都按照batchNum列做了分区存储。因此,案例4中参与关联的数据表applyDistinctDF和luckyDogsDF都是分区表,且分区键batchNum刚好是二者做关联计算的Join Key。其次,案例4中的关联计算显然是等值Join。
|
||||
|
||||
最后,我们只要保证lucky_molecule_2018结果集小于广播阈值就可以触发DPP机制。2018年只有6次摇号,也就是说,分组计数得到的lucky_molecule_2018只有6条数据记录,这么小的“数据集”完全可以放进广播变量。
|
||||
|
||||
如此一来,案例4满足了DPP所有的前提条件,利用DPP机制,我们就可以减少applyDistinctDF的数据扫描量,从而在整体上提升作业的执行性能。
|
||||
|
||||
<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("batchNum") >= "201601")
|
||||
.select("carNum"), Seq("carNum"), "inner")
|
||||
.groupBy(col("batchNum"),col("carNum"))
|
||||
.agg(count(lit(1)).alias("multiplier"))
|
||||
.groupBy("carNum")
|
||||
.agg(max("multiplier").alias("multiplier"))
|
||||
.groupBy("multiplier")
|
||||
.agg(count(lit(1)).alias("cnt"))
|
||||
.orderBy("multiplier")
|
||||
|
||||
result05_01.write.format("csv").save("_")
|
||||
|
||||
```
|
||||
|
||||
仔细研读代码之后,我们发现场景1的计算分为如下几个环节:
|
||||
|
||||
- 大表与小表的关联计算,且小表带过滤条件
|
||||
- 按batchNum列做统计计数
|
||||
- 按carNum列取最大值
|
||||
- 按multiplier列做统计计数
|
||||
|
||||
在这4个环节当中,关联计算涉及的数据扫描量和数据处理量最大。因此,这一环节是案例5执行效率的关键所在。另外,除了关联计算环节,其他3个环节都属于单表Shuffle优化的范畴,这3个环节的优化可以参考案例2场景1的调优思路与技巧,咱们也不多说了。因此,对于案例5的性能优化,我们重点关注第一个环节,也就是applyNumbersDF与luckyDogsDF的关联计算。
|
||||
|
||||
仔细观察第一个环节的关联计算,我们发现关联条件中的Join Key是carNum,而carNum并不是applyNumbersDF与luckyDogsDF两张表的分区键,因此,在这个关联查询中,我们没有办法利用DPP机制去做优化。
|
||||
|
||||
不过,applyNumbersDF与luckyDogsDF的内关联是典型的“大表Join小表”,对于这种场景,我们至少有两种方法可以将低效的SMJ转化为高效的BHJ。
|
||||
|
||||
**第一种办法是计算原始数据集luckyDogsDF的内存存储大小,确保其小于广播阈值,从而利用Spark SQL的静态优化机制将SMJ转化为BHJ。第二种方法是确保过滤后的luckyDogsDF小于广播阈值,这样我们就可以利用Spark SQL的AQE机制来动态地将SMJ转化为BHJ。**
|
||||
|
||||
接下来,我们分别使用这两种方法来做优化,比较它们之间,以及它们与对比基准之间的性能差异。在案例2场景2中,我们计算过luckyDogsDF在内存中的存储大小是18.5MB,因此,通过适当调节spark.sql.autoBroadcastJoinThreshold,我们就可以灵活地在两种调优方法之间进行切换。
|
||||
|
||||
<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做性能优化吗?
|
||||
|
||||
期待在留言区看到你的优化结果,也欢迎你随时提问,我们下一讲见!
|
||||
Reference in New Issue
Block a user