8.应用开发三原则:如何拓展自己的开发边界?
通用的调优方法和技巧 主要包含:开发原则、配置项、Shuffle 以及硬件资源这四个方面
比如 Filter + Coalesce 和用 mapPartitions 代替 map,以及用 ReduceByKey 代替 GroupByKey 等等。我相信,你在日常的开发工作中肯定已经积累了不少。但是据我观察,很多同学在拿到这些技巧之后,都会不假思索地“照葫芦画瓢”。不少同学反馈:“试了之后怎么没效果啊?算了,反正能试的都试了,我也实在没有别的调优思路了,就这样吧”。
那么,这种情况该怎么办呢?我认为,最重要的原因可能是你积累的这些“常规操作”还没有形成体系。结合以往的开发经验,我发现这些“常规操作”可以归纳为三类:坐享其成
- 坐享其成
- 能省则省、能拖则拖跳出
- 单机思维
原则一:坐享其成
我们应该尽可能地充分利用 Spark 为我们提供的“性能红利”,如钨丝计划、AQE、SQL functions 等等。我把这类原则称作“坐享其成”,意思是说我们通过设置相关的配置项,或是调用相应的 API 去充分享用 Spark 自身带来的性能优势。
钨丝计划的优势?
Databricks 官方对比实验显示,开启 Tungsten 前后, 应用程序的执行性能可以提升 16 倍! 因此你看,哪怕咱们什么都不做,只要开发的业务应用能够利用到 Tungsten 提供的种种特性,Spark 就能让应用的执行性能有所保障。
- 在数据结构方面, Tungsten 自定义了紧凑的二进制格式 。这种数据结构在存储效率方面,相比 JVM 对象存储高出好几个数量级。另外,由于数据结构本身就是紧凑的二进制形式,因此它天然地避免了 Java 对象序列化与反序列化引入的计算开销。
- 基于定制化的二进制数据结构,Tungsten 利用 Java Unsafe API 开辟堆外(Off Heap Memory)内存来管理对象 。堆外内存有两个天然的优势:一是对于内存占用的估算更精确,二来不需要像 JVM Heap 那样反复执行垃圾回收。是需要在sparkconf中开启堆外内存。
- **Tungsten 用全阶段代码生成(Whol Stage Code Generation)取代火山迭代模型,**这不仅可以减少虚函数调用和降低内存访问频率,还能提升 CPU cache 命中率,做到大幅压缩 CPU 空闲时间,从而提升 CPU 利用率。
AQE 的优势?
除了钨丝计划,我们最应该关注 Spark 3.0 版本发布的新特性——AQE。AQE(Adaptive Query Execution)全称“自适应查询执行”,它可以在 Spark SQL 优化的过程中动态地调整执行计划。
Spark SQL 的优化过程可以大致分为语法分析、语义解析、逻辑计划和物理计划这几个环节。在 3.0 之前的版本中,Spark 仅仅在编译时基于规则和策略遍历 AST 查询语法树,来优化逻辑计划,一旦基于最佳逻辑计划选定物理执行计划,Spark 就会严格遵照物理计划的步骤去机械地执行计算。
而 AQE 可以让 Spark 在运行时的不同阶段,结合实时的运行时状态,周期性地动态调整前面的逻辑计划,然后根据再优化的逻辑计划,重新选定最优的物理计划,从而调整运行时后续阶段的执行方式。
AQE 主要带来了 3 个方面的改进
AQE 主要带来了 3 个方面的改进,分别是自动分区合并、数据倾斜和 Join 策略调整。
主要是根据实际计算情款可以动态的调整分区,数据倾斜和Join
1. 自动分区合并
自动分区合并很好理解,我们拿 Filter 与 Coalesce 来举例。分布式数据集过滤之后,难免有些数据分片的内容所剩无几,甚至为空,所以为了避免多余的调度开销,我们经常会用 Coalesce 去做手工的分区合并。
另外,在 Shuffle 的计算过程中,同样也存在分区合并的需求。
以上图为例,我们可以看到,数据表原本有 2 个分区,Shuffle 之后在 Reduce 阶段产生 5 个数据分区。由于数据分布不均衡,其中 3 个分区的数据量很少。对 CPU 来说,这 3 个小分区产生的调度开销会是一笔不小的浪费。在 Spark 支持 AQE 以前,开发者对此无能为力。现在呢,AQE 会自动检测过小的数据分区,并对它们自动合并,根本不需要我们操心了。
2.数据倾斜(Data Skew)
数据倾斜(Data Skew),它在数据分析领域中很常见,如果处理不当,很容易导致 OOM 问题。
比方说,我们要分析每一个微博用户的历史行为。那么,不论是发博量还是互动频次,普通用户与头部用户(明星、网红、大 V、媒体)会相差好几个数量级。这个时候,按照用户 ID 进行分组分析就会产生数据倾斜的问题,而且,同一 Executor 中的执行任务基本上是平均分配可用内存的。因此,一边是平均的内存供给,一边是有着数量级之差的数据处理需求,数据倾斜严重的 Task 报出 OOM 错误也就不足为怪了。
以往处理数据倾斜问题的时候,往往需要我们在应用中手动“加盐”,也就是强行给倾斜的 Key 添加随机前缀,通过把 Key 打散来均衡数据在不同节点上的分布。现在,在数据关联(Joins)的场景中,如果 AQE 发现某张表存在倾斜的数据分片,就会自动对它做加盐处理,同时对另一张表的数据进行复制。除此之外,开发者在自行盐化之前,还需要先统计每一个 Key 的倾斜情况再决定盐化的幅度。不过,自从有了 AQE,这些糟心事交给它搞定就好了。
3. Join 策略调整
当两个有序表要进行数据关联的时候,Spark SQL 在优化过程中总会选择 Sort Merge Join 的实现方式。但有一种情况是,其中一个表在排序前需要对数据进行过滤,过滤后的表小到足可以由广播变量容纳。这个时候,Broadcast Join 比 Sort Merge Join 的效率更高。但是,3.0 版本之前的优化过程是静态的,做不到动态切换 Join 方式。
针对这种情况,AQE 会根据运行时的统计数据,去动态地调整 Join 策略,把之前敲定的 Sort Merge Join 改为 Broadcast Join,从而改善应用的执行性能。
如何利用Tungsten(钨丝计划)和AQE 功能
- Tungsten(钨丝计划): 想要利用好 Tungsten 的优势,你只要抛弃 RDD API,采用 DataFrame 或是 Dataset API 进行开发就可了。
- AQE 功能: AQE 功能默认是关闭的,如果我们想要充分利用自动分区合并、自动数据倾斜处理和 Join 策略调整,需要把相关的配置项打开。
总的来说,通过钨丝计划和 AQE,我们完全可以实现低投入、高产出,这其实就是坐享其成的核心原则。除此之外,类似的技巧还有用 SQL functions 或特征转换算子去取代 UDF 等等。
原则二:能省则省、能拖则拖
“能省则省、能拖则拖”。省的是数据处理量,因为节省数据量就等于节省计算负载,更低的计算负载自然意味着更快的处理速度;拖的是 Shuffle 操作,因为对于常规数据处理来说,计算步骤越靠后,需要处理的数据量越少,Shuffle 操作执行得越晚,需要落盘和分发的数据量就越少,更低的磁盘与网络开销自然意味着更高的执行效率。
实现起来我们可以分 3 步进行:
- 尽量把能节省数据扫描量和数据处理量的操作往前推;
- 尽力消灭掉 Shuffle,省去数据落盘与分发的开销;
- 如果不能干掉 Shuffle,尽可能地把涉及 Shuffle 的操作拖到最后去执行。
实例
这次的业务背景很简单,我们想要得到两个共现矩阵,一个是物品、用户矩阵,另一个是物品、用户兴趣矩阵。得到这两个矩阵之后,我们要尝试用矩阵分解的方法去计算物品、用户和用户兴趣这 3 个特征的隐向量(Latent Vectors,也叫隐式向量),这些隐向量最终会用来构建机器学习模型的特征向量(Feature Vectors)。
基于这样的业务背景,代码需要实现的功能是读取用户访问日志,然后构建出这两个矩阵。访问日志以天为单位保存在 Parquet 格式的文件中,每条记录包含用户 ID、物品 ID、用户兴趣列表、访问时间、用户属性和物品属性等多个字段。我们需要读取日志记录,先用 distinct 对记录去重,然后用 explode 将兴趣列表展开为单个兴趣,接着提取相关字段,最后按照用户访问频次对记录进行过滤并再次去重,最终就得到了所需的共现矩阵。
优化前的代码
val dates: List[String] = List("2020-01-01", "2020-01-02", "2020-01-03")
val rootPath: String = _
//读取日志文件,去重、并展开userInterestList
def createDF(rootPath: String, date: String): DataFrame = {
val path: String = rootPath + date
val df = spark.read.parquet(path)
.distinct
.withColumn("userInterest", explode(col("userInterestList")))
df
}
//提取字段、过滤,再次去重,把多天的结果用union合并
val distinctItems: DataFrame = dates.map{
case date: String =>
val df: DataFrame = createDF(rootPath, date)
.select("userId", "itemId", "userInterest", "accessFreq")
.filter("accessFreq in ('High', 'Medium')")
.distinct
df
}.reduce(_ union _)
这段代码,其中主要的操作有 4 个:用 distinct 去重、用 explode 做列表展开、用 select 提取字段和用 filter 过滤日志记录。因为后 3 个操作全部是在 Stage 内完成去内存计算,只有 distinct 会引入 Shuffle,所以我们要重点关注它。distinct 一共被调用了两次,一次是读取日志内容之后去重,另一次是得到所需字段后再次去重。
第一个 distinct 操作上:在 createDF 函数中读取日志记录之后,立即调用 distinct 去重。要知道,日志记录中包含了很多的字段,distinct 引入的 Shuffle 操作会触发所有数据记录,以及记录中所有字段在网络中全量分发,但我们最终需要的是用户粘性达到一定程度的数据记录,而且只需要其中的用户 ID、物品 ID 和用户兴趣这 3 个字段。因此,这个 distinct 实际上在集群中分发了大量我们并不需要的数据,这无疑是一个巨大的浪费。
第二个 distinct 操作:对数据进行展开、抽取、过滤之后,再对记录去重。这次的去重和第一次大不相同,它涉及的 Shuffle 操作所分发的数据记录没有一条是多余的,记录中仅包含共现矩阵必需的那几个字段。
两个 distinct 操作都是去重,目的一样,但是第二个 distinct 操作比第一个更精准,开销也更少,所以我们可以去掉第一个 distinct 操作。也就消灭了一个会引入全量数据分发的 Shuffle 操作,这个改进对执行性能自然大有裨益。
尽管 explode 不会引入 Shuffle,但在内存中展开兴趣列表的时候,它还是会夹带着很多如用户属性、物品属性等等我们并不需要的字段。
因此,我们得把过滤和列剪枝这些可以节省数据访问量的操作尽可能地往前推,把计算开销较大的操作如 Shuffle 尽量往后拖,从而在整体上降低数据处理的负载和开销。基于这些分析,我们就有了改进版的代码实现,如下所示。
优化后的代码
val dates: List[String] = List("2020-01-01", "2020-01-02", "2020-01-03")
val rootPath: String = _
val filePaths: List[String] = dates.map(rootPath + _)
/**
一次性调度所有文件
先进行过滤和列剪枝
然后再展开userInterestList
最后统一去重
*/
val distinctItems = spark.read.parquet(filePaths: _*)
.filter("accessFreq in ('High', 'Medium'))")
.select("userId", "itemId", "userInterestList")
.withColumn("userInterest", explode(col("userInterestList")))
.select("userId", "itemId", "userInterest")
.distinct
在这份代码中,所有能减少数据访问量的操作如 filter、select 全部被推到最前面,会引入 Shuffle 的 distinct 算子则被拖到了最后面。经过实验对比,两版代码在运行时的执行性能相差一倍。因此你看,遵循“能省则省、能拖则拖”的开发原则,往往能帮你避开很多潜在的性能陷阱。
原则三:跳出单机思维模式
import java.security.MessageDigest
class Util {
val md5: MessageDigest = MessageDigest.getInstance("MD5")
val sha256: MessageDigest = _ //其他哈希算法
}
val df: DataFrame = _
val ds: Dataset[Row] = df.map{
case row: Row =>
val util = new Util()
val s: String = row.getString(0) + row.getString(1) + row.getString(2)
val hashKey: String = util.md5.digest(s.getBytes).map("%02X".format(_)).mkString
(hashKey, row.getInt(3))
}
仔细观察,我们发现这份代码其实还有可以优化的空间。要知道,map 算子所囊括的计算是以数据记录(Data Record)为操作粒度的。换句话说,分布式数据集涉及的每一个数据分片中的每一条数据记录,都会触发 map 算子中的计算逻辑。因此,我们必须谨慎对待 map 算子中涉及的计算步骤。很显然,map 算子之中应该仅仅包含与数据转换有关的计算逻辑,与数据转换无关的计算,都应该提炼到 map 算子之外。
上面的代码,map 算子内与数据转换直接相关的操作,是拼接 Join keys 和计算哈希值。但是,实例化 Util 对象仅仅是为了获取哈希函数而已,与数据转换无关,因此我们需要把它挪到 map 算子之外。
只是一行语句而已,我们至于这么较真吗?还真至于,这个实例化的动作每条记录都会触发一次,如果整个数据集有千亿条样本,就会有千亿次的实例化操作!差之毫厘谬以千里,一个小小的计算开销在规模化效应之下会被放大无数倍,演化成不容小觑的性能问题。
val ds: Dataset[Row] = df.mapPartitions(iterator => {
val util = new Util()
val res = iterator.map{
case row=>{
val s: String = row.getString(0) + row.getString(1) + row.getString(2)
val hashKey: String = util.md5.digest(s.getBytes).map("%02X".format(_)).mkString
(hashKey, row.getInt(3)) }}
res
})
类似这种忽视实例化 Util 操作的行为还有很多,比如在循环语句中反复访问 RDD,用临时变量缓存数据转换的中间结果等等。
其他:
1、 Spark默认使用的是Java serialization序列化方式,我们可以考虑使用Kryo serialization序列化的方式,不过会有一些限制,比如不是支持所有的序列化类型,需要手动注册要序列化的类。
2、 尽量使用占用空间小的数据结构。比如,能使用基本数据类型的就用基本数据类型,不要用对应的包装类(int——>Integer),能用int的就不要用String,String占用的空间要大的多。
- 开启AQE之后,就不用手动处理数据倾斜了?完全的扔给Spark是嘛?
一般的倾斜可以交给aqe,不过aqe处理倾斜本身也有局限性
9.配置项设置调优
配置项的分类
事实上,能够显著影响执行性能的配置项屈指可数,更何况在 Spark 分布式计算环境中,计算负载主要由 Executors 承担,Driver 主要负责分布式调度,调优空间有限,因此对 Driver 端的配置项我们不作考虑,我们要汇总的配置项都围绕 Executors 展开。
把Spark配置项划分为 3 类,分别是硬件资源类、Shuffle 类和 Spark SQL 大类。
1. 硬件资源
**硬件资源类包含的是与 CPU、内存、磁盘有关的配置项。**我们说过,调优的切入点是瓶颈,**定位瓶颈的有效方法之一,就是从硬件的角度出发,观察某一类硬件资源的负载与消耗,是否远超其他类型的硬件,**而且调优的过程收敛于所有硬件资源平衡、无瓶颈的状态,所以掌握资源类配置项就至关重要了。这类配置项设置得是否得当,决定了应用能否打破瓶颈,来平衡不同硬件的资源利用率。
CPU 设置有关配置
并行度太高可能会造成任务调度耗时超过任务处理耗时,如果不进行后续分区合并,还有会造成小文件问题(比如写入Hive)
提交任务时命令脚本中指定
内存配置项
在管理模式上,Spark 分为堆内内存与堆外内存。
- Execution Memory:用于执行分布式任务,如 Shuffle、Sort 和 Aggregate 等操作;
- Storage Memory:用于缓存 RDD 和广播变量等数据;
对于单个Execution 使用对内内存计算:
Execution Memory + Storage Memory = spark.Execution.Memory * sprak.memory.fraction
User Memory = spark.Execution.Memory * (1 - sprak.memory.fraction)
Storage Memory = (Execution Memory + Storage Memory ) * spark.memory.storageFraction
Execution Memory = (Execution Memory + Storage Memory ) * (1- spark.memory.storageFraction )
堆外与堆内的平衡
相比 JVM 堆内内存,off heap 堆外内存有很多优势,如更精确的内存占用统计和不需要垃圾回收机制,以及不需要序列化与反序列化。
如何平衡 JVM 堆内内存与 off heap 堆外内存的划分:
对于需要处理的数据集,如果数据模式比较扁平,而且字段多是定长数据类型,就更多地使用堆外内存。相反地,如果数据模式很复杂,嵌套结构或变长字段很多,数据模式(Data Schema)开始变得复杂时,Spark 直接管理堆外内存的成本将会非常高,就更多采用 JVM 堆内内存会更加稳妥。
User Memory 与 Spark 可用内存如何分配?
Execution Memory + Storage Memory = spark.Execution.Memory * sprak.memory.fraction
User Memory = spark.Execution.Memory * (1 - sprak.memory.fraction)
User Memory = spark.Execution.Memory - Execution Memory + Storage Memory
spark.memory.fraction 参数决定着两者(User Memory 与 Spark )如何瓜分堆内内存,它的系数越大,Spark 可支配的内存越多,User Memory 区域的占比自然越小。1 - spark.memory.fraction 就是 User Memory 在堆内空间的占比。
spark.memory.fraction 的默认值是 0.6,也就是 JVM 堆内空间的 60% 会划拨给 Spark 支配,剩下的 40% 划拨给 User Memory。
1. 如果你用RDD封装这些自定义类型,比如RDD[Student],那么,数据集消耗的是Execution memory。
2. 相反,如果你是在处理分布式数据集的函数中,new Student来辅助计算过程,那么这个对象,是放在User memory里面的。
Execution Memory 该如何与 Storage Memory 平衡?
Execution Memory + Storage Memory = spark.Execution.Memory * sprak.memory.fraction
Storage Memory = (Execution Memory + Storage Memory ) * spark.memory.storageFraction
Execution Memory = (Execution Memory + Storage Memory ) * (1- spark.memory.storageFraction )
与磁盘设置有关配置项
spark.local.dir
2. Shuffle
Shuffle 类是专门针对 Shuffle 操作的。**在绝大多数场景下,Shuffle 都是性能瓶颈。**因此,我们需要专门汇总这些会影响 Shuffle 计算过程的配置项。同时,Shuffle 的调优难度也最高,汇总 Shuffle 配置项能帮我们在调优的过程中锁定搜索范围,充分节省时间。
Shuffle 成为应用中不可或缺的一环,想要通过配置优化 Shuffle 本身的性能,我们能做的微乎其微。
Shuffle 的计算过程分为 Map 和 Reduce 这两个阶段。其中,Map 阶段执行映射逻辑,并按照 Reducer 的分区规则,将中间数据写入到本地磁盘;Reduce 阶段从各个节点下载数据分片,并根据需要实现聚合计算。
可以通过 spark.shuffle.file.buffer 和 spark.reducer.maxSizeInFlight 这两个配置项,来分别调节 Map 阶段和 Reduce 阶段读写缓冲区的大小。
3. Spark SQL
Spark SQL 早已演化为新一代的底层优化引擎。无论是在 Streaming、Mllib、Graph 等子框架中,还是在 PySpark 中,只要你使用 DataFrame API,Spark 在运行时都会使用 Spark SQL 做统一优化。因此,我们需要梳理出一类配置项,去充分利用 Spark SQL 的先天性能优势。
与自动分区合并有关配置项
与自动数据倾斜处理有关配置项
与 Join 策略调整有关配置项
数据关联(Joins)可以说是数据分析领域中最常见的操作,Spark SQL 中的 Join 策略调整,它实际上指的是,把会引入 Shuffle 的 Join 方式,如 Hash Join、Sort Merge Join,“降级”(Demote)为 Broadcast Join。
问题:
- 如果df1.join(df2),df1用的是hash partitioner并且分区数是3,这种情况在reduce端参数spark.sql.shuffle.partitions会生效吗?还是以df1的分区为准?
- 只有计算中涉及Joins或是聚合,spark.sql.shuffle.partitions