spark调优与工作实践

532 阅读51分钟

原理

RDD

RDD(Resilient Distributed Datasets)弹性分布式数据集,作为 Spark 对于分布式数据模型的抽象,是构建 Spark 分布式内存计算引擎的基石。

DAG与流水线:什么是内存计算

什么是 DAG

DAG 全称 Direct Acyclic Graph,中文叫有向无环图。从开发者的视角出发,DAG 的构建是通过在分布式数据集上不停地调用算子来完成的。

从开发者构建 DAG,到 DAG 转化的分布式任务在分布式环境中执行,其间会经历如下 4 个阶段:

  • 回溯 DAG 并划分 Stages:以 Actions 算子为起点,从后向前回溯 DAG,以 Shuffle 操作为边界去划分 Stages。
  • 在 Stages 中创建分布式任务
  • 分布式任务的分发
  • 分布式任务的执行

内存计算含义?

  1. 第一层含义:分布式数据缓存

RDD cache

  1. 第二层含义:Stage 内的流水线式计算模式

MapReduce的Map与Map,Map 与 Reduce 操作之间的计算都是利用本地磁盘来交换数据的,IO频繁拖累性能。在 Spark 中,流水线计算模式指的是:在同一 Stage 内部,所有算子融合为一个函数,Stage 的输出结果由这个函数一次性作用在输入数据集而产生,在内存中不产生任何中间数据形态。

所谓内存计算,不仅仅是指数据可以缓存在内存中,通过计算的融合来大幅提升数据在内存中的转换效率,进而从整体上提升应用的执行性能。

调度系统

流程

Spark 调度系统的核心职责是,先将用户构建的 DAG 转化为分布式任务,结合分布式集群资源的可用性,基于调度规则依序把分布式任务分发到执行器。

Spark 调度系统的工作流程包含如下 5 个步骤:

核心组件

Spark 调度系统包含 3 个核心组件,分别是 DAGScheduler、TaskScheduler 和 SchedulerBackend。这 3 个组件都运行在 Driver 进程中

  1. DAGScheduler

一是把用户 DAG 拆分为 Stages,二是在 Stage 内创建计算任务 Tasks。

  1. SchedulerBackend

是对于资源调度器的封装与抽象,为了支持多样的资源调度模式如 Standalone、YARN 和 Mesos。ExecutorData 用于封装 Executor 的资源状态,如 RPC 地址、主机地址、可用 CPU 核数和满配 CPU 核数等等,它相当于是对Executor 做的“资源画像”。

  1. TaskScheduler

TaskScheduler 的职责是,基于既定的规则与策略达成供需双方的匹配与撮合。调度策略分为两个层次:

  • 不同 Stages 之间的调度优先级:对于两个或多个 Stages,如果它们彼此之间不存在依赖关系,TaskScheduler 提供了 2 种调度模式,分别是 FIFO(先到先得)和 FAIR(公平调度)。
  • 同一个Stages 内不同任务之间的调度优先级:当 TaskScheduler 接收到来自 SchedulerBackend 的 WorkerOffer 后,TaskScheduler 会优先挑选那些满足本地性级别要求的任务进行分发。本地性级别有 4 种:Process local < Node local < Rack local < Any。

存储系统

存储什么数据(服务对象)

Spark 存储系统用于存储 3 个方面的数据,分别是 RDD 缓存、Shuffle 中间文件、广播变量。

  1. RDD缓存:

RDD 缓存指的是将 RDD 以缓存的形式物化到内存或磁盘的过程。对于一些计算成本和访问频率都比较高的 RDD 来说,缓存有两个好处:

  • 一是通过截断 DAG,可以降低失败重试的计算开销;
  • 二是通过对缓存内容的访问,可以有效减少从头计算的次数。
  1. Shuffle 中间文件:

Shuffle 计算过程可以分为 2 个阶段:

  • Map 阶段:Shuffle writer 按照 Reducer 的分区规则将中间数据写入本地磁盘;
  • Reduce 阶段:Shuffle reader 从各个节点下载数据分片,并根据需要进行聚合计算。

Shuffle 中间文件实际上就是 Shuffle Map 阶段的输出结果,这些结果会以文件的形式暂存于本地磁盘。Reducer 想要拉取属于自己的那部分中间数据,就必须要知道这些数据都存储在哪些节点,以及什么位置。而这些关键的元信息,正是由 Spark 存储系统保存并维护的。

  1. 广播变量:

广播变量往往用于在集群范围内分发访问频率较高的小数据。利用存储系统,广播变量可以在 Executors 进程范畴内保存全量数据。这样一来,对于同一 Executors 内的所有计算任务,应用就能够以 Process local 的本地性级别,来共享广播变量中携带的全量数据了。

基本组件

Spark 存储系统组件包括,BlockManager、BlockManagerMaster、MemoryStore、DiskStore 和 DiskBlockManager 等等。

BlockManager 和 BlockManagerMaster

BlockManager是其中最为重要的组件,它在 Executors 端负责统一管理和协调数据的本地存取与跨节点传输。我们可以从 2 方面来看。

  1. 对外,BlockManager 与 Driver 端的 BlockManagerMaster 通信,不仅定期向 BlockManagerMaster 汇报本地数据元信息,还会不定时按需拉取全局数据存储状态。另外,不同 Executors 的 BlockManager 之间也会以 Server/Client 模式跨节点推送和拉取数据块。
  2. 对内,BlockManager 通过组合存储系统内部组件的功能来实现数据的存与取、收与发。

MemoryStore 和 DiskStore

广播变量MemoryStore 管理
Shuffle中间文件DiskStore管理
RDD缓存MemoryStore(对象+字节数组)或者 DiskStore(字节数组)

DiskBlockManager

DiskStore 中数据的存取本质上就是字节序列与磁盘文件之间的转换。要想完成两者之间的转换,像数据块与文件的对应关系、文件路径等等这些元数据是必不可少的。

MemoryStore 采用链式哈希字典来维护类似的元数据, DiskStore 并没有亲自维护这些元数据,而是请利用 DiskBlockManager。DiskBlockManager 的主要职责就是,记录逻辑数据块 Block 与磁盘文件系统中物理文件的对应关系,每个 Block 都对应一个磁盘文件。

内存管理

内存管理模式

Spark 会区分堆内内存(On-heap Memory)和堆外内存(Off-heap Memory)。这里的“堆”指的是 JVM Heap,因此堆内内存实际上就是 Executor JVM 的堆内存;堆外内存指的是通过 Java Unsafe API,像 C++ 那样直接从操作系统中申请和释放内存空间。

  • 堆内内存的申请与释放统一由 JVM 代劳。在这样的管理模式下,Spark 对内存的释放是有延迟的,因此,当 Spark 尝试估算当前可用内存时,很有可能会高估堆内的可用内存空间。

  • 堆外内存则不同,Spark 通过调用 Unsafe 的 allocateMemory 和 freeMemory 方法直接在操作系统内存中申请、释放内存空间。空间的申请与释放可以精确计算,因此 Spark 对堆外可用内存的估算会更精确,对内存的利用率也更有把握。

内存区域划分

  • Execution Memory:用于执行分布式任务,如 Shuffle、Sort 和 Aggregate 等操作。
  • Storage Memory:用于缓存 RDD ,广播变量等数据。
  • User Memory:用于存储开发者自定义数据结构
  • Reserved Memory:用来存储各种 Spark 内部对象,例如存储系统中的 BlockManager、DiskBlockManager 等等。

执行与缓存内存

在 1.6 版本之后,Spark 推出了统一内存管理模式。统一内存管理指的是 Execution Memory 和 Storage Memory 之间可以相互转化,尽管两个区域由配置项 spark.memory.storageFraction 划定了初始大小,但在运行时,结合任务负载的实际情况,Storage Memory 区域可能被用于任务执行(如 Shuffle),Execution Memory 区域也有可能存储 RDD 缓存。

Execution Memory 和 Storage Memory 之间的抢占规则,一共可以总结为 3 条:

  • 如果对方的内存空间有空闲,双方就都可以抢占;
  • 对于 RDD 缓存任务抢占的执行内存,当执行任务有内存需要时,RDD 缓存任务必须立即归还抢占的内存,涉及的 RDD 缓存数据要么落盘、要么清除;
  • 对于分布式计算任务抢占的 Storage Memory 内存空间,即便 RDD 缓存任务有收回内存的需要,也要等到任务执行完毕才能释放。

通用性能调优

配置项速查手册

计算负载主要由 Executors 承担,Driver 主要负责分布式调度,调优空间有限,因此对 Driver 端的配置项我们不作考虑,我们要汇总的配置项都围绕 Executors 展开。我把它们划分为 3 类,分别是硬件资源类、Shuffle 类和 Spark SQL 大类。首先,硬件资源类包含的是与 CPU、内存、磁盘有关的配置项。其次,Shuffle 类是专门针对 Shuffle 操作的。最后,Spark SQL 早已演化为新一代的底层优化引擎。

硬件资源类

哪些配置项与 CPU 设置有关?

  • 并行度

指的是分布式数据集被划分为多少份,从而用于分布式计算。换句话说,并行度的出发点是数据,它明确了数据划分的粒度。并行度越高,数据的粒度越细,数据分片越多,数据越分散。由此可见,像分区数量、分片数量、Partitions 这些概念都是并行度的同义词。

  • 并行计算任务

它指的是在任一时刻整个集群能够同时计算的任务数量(并不是所有分区同时都能有task在处理)。换句话说,它的出发点是计算任务、是 CPU,由与 CPU 有关的三个参数共同决定。具体说来,Executor 中并行计算任务数的上限是 spark.executor.cores 与 spark.task.cpus 的商,暂且记为 #Executor-tasks,整个集群的并行计算任务数自然就是 #Executor-tasks 乘以集群内 Executors 的数量,记为 #Executors。因此,最终的数值是:#Executor-tasks * #Executors

并行度决定了数据粒度,数据粒度决定了分区大小,分区大小则决定着每个计算任务的内存消耗。在同一个 Executor 中,多个同时运行的计算任务“基本上”是平均瓜分可用内存的,每个计算任务能获取到的内存空间是有上限的,因此并行计算任务数会反过来制约并行度的设置

哪些配置项与内存设置有关?

内存的基础配置项主要有 5 个,它们的含义如下表所示:

堆外与堆内的平衡

  • 正是基于这种紧凑的二进制格式,相比 JVM 堆内内存,Spark 通过 Java Unsafe API 在堆外内存中的管理,才会有那么多的优势。

  • 如果用户表 1 新增了兴趣列表字段,类型为 List[String],如用户表 2 所示。这个时候,如果我们仍然采用字节数据的方式来存储每一条用户记录,不仅越来越多的指针和偏移地址会让字段的访问效率大打折扣,而且,指针越多,内存泄漏的风险越大,数据访问的稳定性就值得担忧了。

对于需要处理的数据集,

  1. 如果数据模式比较扁平,而且字段多是定长数据类型,就更多地使用堆外内存。
  2. 相反地,如果数据模式很复杂,嵌套结构或变长字段很多,就更多采用 JVM 堆内内存会更加稳妥。

User Memory 与 Spark 可用内存如何分配?

spark.memory.fraction 参数决定着两者如何瓜分堆内内存,它的系数越大,Spark 可支配的内存越多,User Memory 区域的占比自然越小。spark.memory.fraction 的默认值是 0.6,也就是 JVM 堆内空间的 60% 会划拨给 Spark 支配,剩下的 40% 划拨给 User Memory。

当在 JVM 内平衡 Spark 可用内存和 User Memory 时,你需要考虑你的应用中类似的自定义数据结构多不多、占比大不大?然后再相应地调整两块内存区域的相对占比。

Execution Memory 该如何与 Storage Memory 平衡?

通常来说,在统一内存管理模式下,spark.memory.storageFraction 的设置就显得没那么紧要,因为无论这个参数设置多大,执行任务还是有机会抢占缓存内存,而且一旦完成抢占,就必须要等到任务执行结束才会释放。

如果你的应用类型是“缓存密集型”,如机器学习训练任务,就很有必要通过调节这个参数来保障数据的全量缓存。在打算把大面积的内存空间用于 RDD cache 之前,你需要衡量会对执行效率产生的影响,因为会引起Full GC。java Full GC 征用 CPU 线程导致应用暂停的现象叫做“Stop the world”。解决方法:

  1. 你可以放弃对象值的缓存方式,改用序列化的缓存方式,序列化会把多个对象转换成一个字节数组。
  2. 我们可以调节 spark.rdd.compress 这个参数。RDD 缓存默认是不压缩的,启用压缩之后,缓存的存储效率会大幅提升,有效节省缓存内存的占用,从而把更多的内存空间留给分布式任务执行。

哪些配置项与磁盘设置有关?

spark.local.dir 这个配置项,这个参数允许开发者设置磁盘目录,该目录用于存储 RDD cache 落盘数据块和 Shuffle 中间文件。通常情况下,spark.local.dir 会配置到本地磁盘中容量比较宽裕的文件系统,毕竟这个目录下会存储大量的临时文件,我们需要足够的存储容量来保证分布式任务计算的稳定性。

Shuffle 类配置项

  • 在 Map 阶段,计算结果会以中间文件的形式被写入到磁盘文件系统。同时,为了避免频繁的 I/O 操作,Spark 会把中间文件存储到写缓冲区(Write Buffer)。这个时候,我们可以通过设置 spark.shuffle.file.buffer 来扩大写缓冲区的大小,缓冲区越大,能够缓存的落盘数据越多,Spark 需要刷盘的次数就越少,I/O 效率也就能得到整体的提升。
  • 在 Reduce 阶段,因为 Spark 会通过网络从不同节点的磁盘中拉取中间文件,它们又会以数据块的形式暂存到计算节点的读缓冲区(Read Buffer)。缓冲区越大,可以暂存的数据块越多,在数据总量不变的情况下,拉取数据所需的网络请求次数越少,单次请求的网络吞吐越高,网络 I/O 的效率也就越高。这个时候,我们就可以通过 spark.reducer.maxSizeInFlight 配置项控制 Reduce 端缓冲区大小,来调节 Shuffle 过程中的网络负载。

  • 在 Shuffle 过程中,对于不需要排序和聚合的操作,我们可以通过控制 spark.shuffle.sort.bypassMergeThreshold 参数,来避免 Shuffle 执行过程中引入的排序环节,从而避免没必要的计算开销。

Spark SQL 大类配置项

Spark SQL 的相关配置项对执行性能影响最大的,当属AQE(Adaptive query execution,自适应查询引擎)引入的那 3 个特性了,也就是自动分区合并、自动数据倾斜处理和 Join 策略调整。

AQE 功能默认是禁用的,想要使用这些特性,我们需要先通过配置项 spark.sql.adaptive.enabled 来开启 AQE。

哪些配置项与自动分区合并有关?

  • AQE 事先并不判断哪些分区足够小,而是按照分区编号进行扫描,当扫描量超过“目标尺寸”时,就合并一次。

  • 第一个参数 advisoryPartitionSizeInBytes 是开发者建议的目标尺寸,第二个参数 minPartitionNum 的含义是合并之后的最小分区数,假设它是 200,就说明合并之后的分区数量不能小于 200。这个参数的目的就是避免并行度过低导致 CPU 资源利用不充分。

哪些配置项与自动数据倾斜处理有关?

  • 分区尺寸必须要大于 spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes 参数的设定值,才有可能被判定为倾斜分区。
  • 然后,AQE 统计所有数据分区大小并排序,取中位数作为放大基数,尺寸大于中位数一定倍数的分区会被判定为倾斜分区,中位数的放大倍数也是由参数 spark.sql.adaptive.skewJoin.skewedPartitionFactor 控制。

哪些配置项与 Join 策略调整有关?

Broadcast Join 的精髓在于“以小博大”,它以广播的方式将小表的全量数据分发到集群中所有的 Executors,大表的数据不需要以 Join keys 为基准去 Shuffle,就可以与小表数据原地进行关联操作。

  • 在 Spark 发布 AQE 之前,开发者可以利用 spark.sql.autoBroadcastJoinThreshold 配置项对数据关联操作进行主动降级。这个参数的默认值是 10MB,参与 Join 的两张表中只要有一张数据表的尺寸小于 10MB,二者的关联操作就可以降级为 Broadcast Join。为了充分利用 Broadcast Join“以小博大”的优势,你可以考虑把这个参数值调大一些,2GB 左右往往是个不错的选择。
  • 不过,autoBroadcastJoinThreshold 这个参数虽然好用,但是有两个让人头疼的短:
  1. 一是可靠性较差。尽管开发者明确设置了广播阈值,而且小表数据量在阈值以内, Spark 对小表尺寸的误判时有发生,导致 Broadcast Join 降级失败。
  2. 二来,预先设置广播阈值是一种静态的优化机制,它没有办法在运行时动态对数据关联进行降级调整。

  • AQE 很好地解决了这两个头疼的问题:
  1. 首先,AQE 的 Join 策略调整是一种动态优化机制,对于两张大表,AQE 会在数据表完成过滤操作之后动态计算剩余数据量,当数据量满足广播条件时,AQE 会重新调整逻辑执行计划,在新的逻辑计划中把 Shuffle Joins 降级为 Broadcast Join。
  2. 再者,运行时的数据量估算要比编译时准确得多,因此 AQE 的动态 Join 策略调整相比静态优化会更可靠、更稳定。

启用动态 Join 策略调整还有个前提,也就是要满足nonEmptyPartitionRatioForBroadcastJoin 参数的限制。这个参数的默认值是 0.2,大表过滤之后,非空的数据分区占比要小于 0.2,才能成功触发 Broadcast Join 降级。

Shuffle

Shuffle 的两个阶段:Map 阶段和 Reduce 阶段。自 2.0 版本之后,Spark 将 Shuffle 操作统一交由 Sort shuffle manager 来管理。

Map 阶段是如何输出中间文件的?

Map 阶段最终生产的数据会以中间文件的形式物化到磁盘中,这些中间文件就存储在 spark.local.dir 设置的文件目录里。中间文件包含两种类型:

  • 一类是后缀为 data 的数据文件,存储的内容是 Map 阶段生产的待分发数据;
  • 另一类是后缀为 index 的索引文件,它记录的是数据文件中不同分区的偏移地址。这里的分区是指 Reduce 阶段的分区,因此,分区数量与 Reduce 阶段的并行度保持一致。

Map 阶段每一个 Task 的执行流程都是一样的,每个 Task 最终都会生成一个数据文件和一个索引文件。因此,中间文件的数量与 Map 阶段的并行度(分区数量)保持一致。换句话说,有多少个 Task,Map 阶段就会生产相应数量的数据文件和索引文件。即,Map分区数量,task数量,文件数量,一一对应。

单个数据和索引文件的计算方式:

groupByKey 虽然 Map 阶段的计算步骤很多,但其中最主要的环节可以归结为 4 步:

  1. 对于分片中的数据记录,逐一计算其目标分区,并将其填充到 PartitionedPairBuffer;
  2. PartitionedPairBuffer 填满后,如果分片中还有未处理的数据记录,就对 Buffer 中的数据记录按(目标分区 ID,Key)进行排序,将所有数据溢出到临时文件,同时清空缓存;
  3. 重复步骤 1、2,直到分片中所有的数据记录都被处理;
  4. 对所有临时文件和 PartitionedPairBuffer 归并排序,最终生成数据文件和索引文件。

reduceByKey

区别group by key 在于,reduceByKey 采用一种叫做 PartitionedAppendOnlyMap 的数据结构来填充数据记录。这个数据结构是一种 Map,而 Map 的 Value 值是可累加、可更新的。因此,PartitionedAppendOnlyMap 非常适合聚合类的计算场景,如计数、求和、均值计算、极值计算等等。

相比 groupByKey、collect_list 这些收集类 算子聚合类算子(reduceByKey、aggregateByKey 等)在执行性能上更占优势。

Reduce 阶段是如何进行数据分发的?

Shuffle 在 Reduce 阶段是主动地从 Map 端的中间文件中拉取数据。索引文件正是用于帮助判定哪部分数据属于哪个 Reduce Task。

广播变量

在 join 场景中,广播变量就可以轻而易举地省去 Shuffle。广播变量是一种分发机制,它一次性封装目标数据结构,以 Executors 为粒度去做数据分发。

广播变量克制Shuffle Join

在广播变量的运行机制下,封装成广播变量的数据,由 Driver 端以 Executors 为粒度分发,每一个 Executors 接收到广播变量之后,将其交给 BlockManager 管理。

广播分布式数据集

  • 步骤 1 就是 Driver 从所有的 Executors 拉取这些数据分区,然后在本地构建全量数据。
  • 步骤 2 Driver 把汇总好的全量数据分发给各个 Executors,Executors 将接收到的全量数据缓存到存储系统的 BlockManager 中。

克制shuffle Join,分发全量数据到每个 executor:

如何让Spark SQL选择Broadcast Joins?

利用配置项强制广播

  • 使用广播阈值配置项让 Spark 优先选择 Broadcast Joins 的关键,就是要确保至少有一张表的存储尺寸小于广播阈值。
  • 那么问题来了,有什么办法能准确地预估一张表在内存中的存储大小呢?第一步,把要预估大小的数据表缓存到内存,比如直接在 DataFrame 或是 Dataset 上调用 cache 方法;第二步,读取 Spark SQL 执行计划的统计数据。

利用 API 强制广播

用 API 强制广播有两种方法,分别是设置 Join Hints 和用 broadcast 函数。

  • 设置 Join Hints 的方法就是在 SQL 结构化查询语句里面加上一句“/*+ broadcast(某表) */”的提示就可以了。

  • 设置 broadcast 函数的方法非常简单,只要用 broadcast 函数封装需要广播的数据表就可以了

CPU视角

CPU 与内存的平衡本质上是什么?

Spark 中 CPU 与内存的平衡,其实就是 CPU 与执行内存之间的协同与配比。执行内存抢占规则就是,在同一个 Executor 中,当有多个(记为 N)线程尝试抢占执行内存时,需要遵循 2 条基本原则:

  1. 执行内存总大小(记为 M)为两部分之和,一部分是 Execution Memory 初始大小,另一部分是 Storage Memory 剩余空间。
  2. 每个线程分到的可用内存有一定的上下限,下限是 M/N/2,上限是 M/N,也就是均值。

三足鼎立:并行度、并发度与执行内存

  • 并行度:两个参数,分别是 spark.default.parallelism 和 spark.sql.shuffle.partitions。前者用于设置 RDD 的默认并行度,后者在 Spark SQL 开发框架下,指定了 Shuffle Reduce 阶段默认的并行度。

  • 并发度:Executor 的线程池大小由参数 spark.executor.cores 决定,每个任务在执行期间需要消耗的线程数由 spark.task.cpus 配置项给定,两者相除得到的商就是并发度。spark.task.cpus 默认数值为 1,并发度基本由 spark.executor.cores 参数敲定。

  • 执行内存:

    • 堆内执行内存:spark.executor.memory * spark.memory.fraction * (1 - spark.memory.storageFraction)
    • 堆外执行内存:spark.memory.offHeap.size * (1 - spark.memory.storageFraction)。

CPU优化

CPU 低效原因之一: 线程 挂起

在一些极端情况下,有些线程申请不到所需的内存空间,能拿到的内存合计还不到 M/N/2。这个时候,Spark 就会把线程挂起,直到其他线程释放了足够的内存空间为止。源于 3 方面的变化和作用:

  • 动态变化的执行内存总量 M

    • M 的下限是 Execution Memory 初始值,上限是 spark.executor.memory * spark.memory.fraction 划定的所有内存区域。在应用刚刚开始执行的时候,M 的取值就是这个上限,但随着 RDD 缓存逐渐填充 Storage Memory,M 的取值也会跟着回撤。
  • 动态变化的并发度 N~

    • N~ 的含义是 Executor 内当前的并发度,也就是 Executor 中当前并行执行的任务数。显然 N~ <= N。如果前面的线程把执行内存申请过大,后面的线程就要等待执行内存释放才能执行。
  • 分布式数据集的数据分布

    • 数据分片的数据量决定了执行任务需要申请多少内存,数据量越大,需要的执行内存就越多。如果分布式数据集的并行度设置得当,因任务调度滞后而导致的线程挂起问题就会得到缓解。

CPU 低效原因之二:调度开销

Executor 接收到 TaskDescription 之后,首先需要对 TaskDescription 反序列化才能读取任务信息,然后将任务代码再反序列化得到可执行代码,最后再结合其他任务信息创建 TaskRunner。这是一个大的开销。

如果数据过于分散,分布式任务数量会大幅增加,但每个任务需要处理的数据量却少之又少,任务调度上的开销的cpu消耗与数据处理的开销 分庭抗礼。

如何优化 CPU 利用率?

  • 在给定执行内存 M、线程池大小 N 和数据总量 D 的时候,想要有效地提升 CPU 利用率,我们就要计算出最佳并行度 P,计算方法是让数据分片的平均大小 D/P 坐落在(M/N/2, M/N)区间。这样,在运行时,我们的 CPU 利用率往往不会太差。

  • D/ P = (M/N/2, M/N)

内存视角

如何最大化内存利用率?(配置项)

一些自定义数据结构会放在task里面分发到executor上,存到User Memory里,每个task都会存一份。这样很耗费内存,可以选择存到广播变量里面,广播变量消耗的就是 Storage Memory 内存区域。

想要最大化内存利用率,我们需要遵循两个步骤:预估内存占用调整内存配置项

预估内存占用

  • 第一步,计算 User Memory 的内存消耗。我们先汇总应用中包含的自定义数据结构,并估算这些对象的总大小 #size,然后用 #size 乘以 Executor 的线程池大小,即可得到 User Memory 区域的内存消耗 #User。
  • 第二步,计算 Storage Memory 的内存消耗。我们先汇总应用中涉及的广播变量和分布式数据集缓存,分别估算这两类对象的总大小,分别记为 #bc、#cache。另外,我们把集群中的 Executors 总数记作 #E。这样,每个 Executor 中 Storage Memory 区域的内存消耗的公式就是:#Storage = #bc + #cache / #E。
  • 第三步,计算执行内存的消耗。学习上一讲,我们知道执行内存的消耗与多个因素有关。第一个因素是 Executor 线程池大小 #threads,第二个因素是数据分片大小,而数据分片大小取决于数据集尺寸 #dataset 和并行度 #N。因此,每个 Executor 中执行内存的消耗的计算公式为:#Execution = #threads * #dataset / #N。

调整内存配置项

  • 首先,根据定义,spark.memory.fraction 可以由公式(#Storage + #Execution)/(#User + #Storage + #Execution)计算得到。
  • 同理,spark.memory.storageFraction 的数值应该参考(#Storage)/(#Storage + #Execution)。
  • 最后,对于 Executor 堆内内存总大小 spark.executor.memory 的设置,我们自然要参考 4 个内存区域的总消耗,也就是 300MB + #User + #Storage + #Execution。

如何有效避免Cache滥用?

  • 缓存的存储级别

    • 最常用的只有两个:MEMORY_ONLY 和 MEMORY_AND_DISK,它们分别是 RDD 缓存和 DataFrame 缓存的默认存储级别。
  • 缓存的计算过程:

    • 这两种存储级别都是先尝试把数据缓存到内存。分布式数据集所有的数据分片都从 Unroll 到 Transfer,再到注册LinkedHashMap ,key -BlockID,value - MemoryEntry。
  • 缓存的销毁过程:

    • LinkedHashMap,这种数据结构天然地支持 LRU 算法。

    • Spark 遵循两个基本原则:

      • LRU:按照元素的访问顺序,优先清除那些“最近最少访问”的 BlockId、MemoryEntry 键值对
      • 兔子不吃窝边草:在清除的过程中,同属一个 RDD 的 MemoryEntry 拥有“赦免权”

什么情况适合使用 Cache ?

  • 如果 RDD/DataFrame/Dataset 在应用中的引用次数为 1,就坚决不使用 Cache
  • 如果引用次数大于 1,且运行成本占比超过 30%,应当考虑启用 Cache。运行成本占比。它指的是计算某个分布式数据集所消耗的总时间与作业执行时间的比值。

注意事项

  • .cache是惰性操作,因此在调用.cache之后,需要先用 Action 算子触发缓存的物化过程。这 4 个算子中只有 count 才会触发缓存的完全物化,而 first、take 和 show 这 3 个算子只会把涉及的数据物化。
  • Action 算子要选择 count 才能完全物化缓存数据,以及在调用 Cache 的时候,我们要把待缓存数据赋值给一个变量。这样一来,只要是在这个变量之上的分析操作都会完全复用缓存数据。

OOM都是谁的锅?怎么破?

Driver 端的 OOM

Driver 端的 OOM 逃不出 2 类病灶:

  • 创建的数据集超过内存上限

    • collect 算子会从 Executors 把全量数据拉回到 Driver 端,因此,如果结果集尺寸超过 Driver 内存上限,它自然会报 OOM。
  • 收集的结果集超过内存上限

    • 广播变量在创建的过程中,需要先把分布在所有 Executors 的数据分片拉取到 Driver 端,然后在 Driver 端构建广播变量。如果 Executors 中数据分片的总大小超过 Driver 端内存上限也会报 OOM。

Executor 端的 OOM

在 Executors 中,与任务执行有关的内存区域才存在 OOM 的隐患。其中,Reserved Memory 大小固定为 300MB,因为它是硬编码到源码中的,所以不受用户控制。而对于 Storage Memory 来说,即便数据集不能完全缓存到 MemoryStore,Spark 也不会抛 OOM 异常,额外的数据要么落盘(MEMORY_AND_DISK)、要么直接放弃(MEMORY_ONLY)。

  • User Memory 的 OOM

    • 自定义的列表 dict 会随着 Task 分发到所有 Executors,因此多个 Task 中的 dict 会对 User Memory 产生重复消耗。
  • Execution Memory 的 OOM

    • 数据量并不是决定 OOM 与否的关键因素,数据分布与 Execution Memory 的运行时规划是否匹配才是。一旦分布式任务的内存请求超出 1/N 这个上限,Execution Memory 就会出现 OOM 问题。

    • 数据倾斜

      • 数据倾斜导致 OOM,但实质上是 Task3 的内存请求超出 1/N 上限。至少有 2 种调优思路:1. 消除数据倾斜,让所有的数据分片尺寸都不大于 100MB。2.调整 Executor 线程池、内存、并行度等相关配置,提高 1/N 上限到 300MB
    • 数据膨胀

      • 磁盘中的数据进了 JVM 之后会膨胀。数据分片加载到 JVM Heap 之后翻了 3 倍,原本 100MB 的数据变成了 300MB,因此OOM。2 种调优思路:1. 把数据打散,提高数据分片数量、降低数据粒度,让膨胀之后的数据量降到 100MB 左右。2. 加大内存配置,结合 Executor 线程池调整,提高 1/N 上限到 300MB

磁盘视角

磁盘功能作用

  1. 溢出临时文件:在 Map 阶段,聚合分布式计算往往涉及海量数据,因此PartitionedPairBuffer 和PartitionedAppendOnlyMap这些数据结构通常都没办法装满分区中的所有数据。在内存受限的情况下,溢出机制可以保证任务的顺利执行,不会因为内存空间不足就立即报 OOM 异常。
  2. 存储 Shuffle 中间文件:当分区中的最后一批数据加载到 PartitionedPairBuffer 之后,它会和之前溢出到磁盘的临时文件一起做归并计算,最终得到 Shuffle 的数据文件和索引文件也会存储到磁盘上。
  3. 缓存分布式数据集:凡是带DISK字样的存储模式,都会把内存中放不下的数据缓存到磁盘。

磁盘性能价值

  • 把 spark.local.dir 这个参数配置到 SDD 或者其他访问效率更高的存储系统中可以提供更好的 I/O 性能。

  • 磁盘复用,它指的是 Shuffle Write 阶段产生的中间文件被多次计算重复利用的过程。

    • 失败重试中的磁盘复用:收益之一就是缩短失败重试的路径,在保障作业稳定性的同时提升执行性能。
    • ReuseExchange 机制下的磁盘复用:ReuseExchange 是 Spark SQL 众多优化策略中的一种,它指的是相同或是相似的物理计划可以共享 Shuffle 计算的中间结果,也就是我们常说的 Shuffle 中间文件。
    • 触发条件至少有 2 个:1.多个查询所依赖的分区规则要与 Shuffle 中间数据的分区规则保持一致 2.多个查询所涉及的字段(Attributes)要保持一致

网络视角

数据读写

对于 Spark 加 HDFS 和 Spark 加 MongoDB 来说,是否会引入网络开销完全取决于它们的部署模式。物理上紧耦合,在 NODE_LOCAL 级别下,Spark 用磁盘 I/O 替代网络开销获取数据;物理上分离,网络开销就无法避免。

数据处理

我们要遵循“能省则省”的开发原则,主动削减计算过程中的网络开销。

  • 对于数据关联场景,我们要尽可能地把 Shuffle Joins 转化为 Broadcast Joins 来消除 Shuffle。
  • 如果确实没法避免 Shuffle,我们可以在计算中多使用 Map 端聚合,减少需要在网络中分发的数据量。
  • 如果应用对于高可用的要求不高,那我们应该尽量避免副本数量大于 1 的存储模式,避免副本跨节点拷贝带来的额外开销。

数据传输

  • 在数据通过网络分发之前,我们可以利用 Kryo Serializer 序列化器,提升序列化字节的存储效率,从而有效降低在网络中分发的数据量,整体上减少网络开销。
  • 需要注意的,为了充分利用 Kryo Serializer 序列化器的优势,开发者需要明确注册自定义的数据类型,否则效果可能适得其反。

Spark SQL 性能调优

Catalyst逻辑计划

Catalyst 逻辑优化阶段分为两个环节:逻辑计划解析和逻辑计划优化。

  1. 在逻辑计划解析中,Catalyst 把“Unresolved Logical Plan”转换为“Analyzed Logical Plan”;
  2. 在逻辑计划优化中,Catalyst 基于一些既定的启发式规则(Heuristics Based Rules),把“Analyzed Logical Plan”转换为“Optimized Logical Plan”。

逻辑计划解析

  • “Unresolved Logical Plan”携带的信息相当有限,它只包含查询语句从 DSL 语法变换成 AST 语法树的信息。
  • 在逻辑计划解析环节,Catalyst 结合 Dataframe Schema 信息,对于仅仅记录语句字符串的 Unresolved Logical Plan,验证表名、字段名与实际数据的一致性。解析后的执行计划称为 Analyzed Logical Plan。

逻辑计划优化

  • Catalyst 生成“Analyzed Logical Plan”之后,它会基于一套启发式的规则,把“Analyzed Logical Plan”转换为“Optimized Logical Plan”。

Catalyst 的优化规则

Spark 3.0 版本中,Catalyst 总共有 81 条优化规则(Rules),这 81 条规则会分成 27 组(Batches),其中有些规则会被收纳到多个分组里。因此,如果不考虑规则的重复性,27 组算下来总共会有 129 个优化规则。这些规则可以归纳到以下 3 个范畴:

  • 谓词下推(Predicate Pushdown)

    • “谓词”指代的是像用户表上“age < 30”这样的过滤条件,“下推”指代的是把这些谓词沿着执行计划向下,推到离数据源最近的地方,从而在源头就减少数据扫描量。
    • 在下推之前,Catalyst 还会先对谓词本身做一些优化,比如像 OptimizeIn 规则,它会把“gender in ‘M’”优化成“gender = ‘M’”,也就是把谓词 in 替换成等值谓词。
    • 完成谓词本身的优化之后,Catalyst 再用 PushDownPredicte 优化规则,把谓词推到逻辑计划树最下面的数据源上。
  • 列剪裁(Column Pruning)

    • 列剪裁就是扫描数据源的时候,只读取那些与查询相关的字段。以小 Q 为例,用户表的 Schema 是(userId、name、age、gender、email),但是查询中压根就没有出现过 email 的引用,因此,Catalyst 会使用 ColumnPruning 规则,把 email 这一列“剪掉”。经过这一步优化,Spark 在读取 Parquet 文件的时候就会跳过 email 这一列,从而节省 I/O 开销。
  • 常量替换 (Constant Folding)

    • 假设我们在年龄上加的过滤条件是“age < 12 + 18”,Catalyst 会使用 ConstantFolding 规则,自动帮我们把条件变成“age < 30”。再比如,我们在 select 语句中,掺杂了一些常量表达式,Catalyst 也会自动地用表达式的结果进行替换。

Catalys 的优化过程

总的来说,从“Analyzed Logical Plan”到“Optimized Logical Plan”的转换,就是从一个 TreeNode 生成另一个 TreeNode 的过程。Analyzed Logical Plan 的根节点,通过调用 transformDown 方法,不停地把各种优化规则作用到整棵树,直到把所有 27 组规则尝试完毕,且树结构不再发生变化为止。这个时候,生成的 TreeNode 就是 Optimized Logical Plan。

Cache Manager 优化

Cache Manager 其实很简单,它的主要职责是维护与缓存有关的信息。具体来说,Cache Manager 维护了一个 Mapping 映射字典,字典的 Key 是逻辑计划,Value 是对应的 Cache 元信息。

当 Catalyst 尝试对逻辑计划做优化时,会先尝试对 Cache Manager 查找,看看当前的逻辑计划或是逻辑计划分支,是否已经被记录在 Cache Manager 的字典里。如果在字典中可以查到当前计划或是分支,Catalyst 就用 InMemoryRelation 节点来替换整个计划或是计划的一部分,从而充分利用已有的缓存数据做优化。

Catalyst物理计划

优化 Spark 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。

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)。

3 种 Join 实现方式和 2 种网络分发模式:

JoinSelection 如何决定选择哪一种 Join 策略?

生成 Physical Plan

  • 从 Spark Plan 到 Physical Plan 的转换,需要几组叫做 Preparation Rules 的规则。
  • Preparation Rules 有 6 组规则,这些规则作用到 Spark Plan 之上就会生成 Physical Plan,而 Physical Plan 最终会由 Tungsten 转化为用于计算 RDD 的分布式任务。

EnsureRequirements 规则

EnsureRequirements 规则要求,子节点的输出数据要满足父节点的输入要求。EnsureRequirements 用来确保每一个操作符的输入条件都能够得到满足,在必要的时候,会把必需的操作符强行插入到 Physical Plan 中。比如对于 Shuffle Sort Merge Join 来说,这个操作符对于子节点的数据分布和顺序都是有明确要求的(按照 userId 分成 200 个分区且排好序),因此,在子节点之上,EnsureRequirements 会引入新的操作符如 Exchange 和 Sort。

钨丝计划

Tungsten 又叫钨丝计划,它主要围绕内核引擎做了两方面的改进:数据结构设计全阶段代码生成(WSCG,Whole Stage Code Generation)。

Tungsten 在数据结构方面的设计

Tungsten 在数据结构方面做了两个比较大的改进,一个是紧凑的二进制格式 Unsafe Row,另一个是内存页管理。

Unsafe Row:二进制数据结构

  • Tungsten 设计了 UnsafeRow 二进制字节序列来取代 JVM 对象的存储方式。这不仅可以提升 CPU 的存储效率,还能减少存储数据记录所需的对象个数,从而改善 GC 效率。
  • 在 JVM 堆内内存中,对象数越多垃圾回收效率越低。字节数组的存储方式在消除存储开销的同时,仅用一个数组对象就能轻松完成一条数据的封装,显著降低 GC 压力。

基于内存页的内存管理

  • 为了统一管理堆内与堆外内存,Tungsten 设计了 128 位的内存地址,其中前 64 位存储 Object 引用,后 64 位为偏移地址。
  • 对于 On Heap 空间的 Tungsten 地址来说,前 64 位存储的是 JVM 堆内对象的引用或者说指针,后 64 位 Offset 存储的是数据在该对象内的偏移地址。
  • 而 Off Heap 空间则完全不同,在堆外的空间中,由于 Spark 是通过 Java Unsafe API 直接管理操作系统内存,不存在内存对象的概念,因此前 64 位存储的是 null 值,后 64 位则用于在堆外空间中直接寻址操作系统的内存空间。

  • Tungsten 放弃了链表的实现方式,使用数组+内存页的方式来实现 HashMap。数组中存储的元素是 Hash code 和 Tungsten 内存地址,也就是 Object 引用外加 Offset 的 128 位地址。Tungsten HashMap 使用 128 位地址来寻址数据元素,相比 java.util.HashMap 大量的链表指针,在存储开销上更低。

  • 在堆内内存的管理上,基于 Tungsten 内存地址和内存页的设计机制,相比标准库,

    • Tungsten 实现的数据结构(如 HashMap)使用连续空间来存储数据条目,连续内存访问有利于提升 CPU 缓存命中率,从而提升 CPU 工作效率。
    • 由于内存页本质上是 Java Object,内存页管理机制往往能够大幅削减存储数据所需的对象数量,因此对 GC 非常友好的。

WSCG

  • WSCG 指的是基于同一 Stage 内操作符之间的调用关系,生成一份“手写代码”,来把所有计算融合为一个统一的函数。本质上,WSCG 机制的工作过程,就是基于一份“性能较差的代码”,在运行时动态地重构出一份“性能更好的代码”。

  • 更重要的是,“手写代码”解决了 VI 计算模型的两个核心痛点:

    • 操作符之间频繁的虚函数调用
    • 操作符之间数据交换引入的内存随机访问。
    •   手写代码中的每一条指令都是明确的,可以顺序加载到 CPU 寄存器,源数据也可以顺序地加载到 CPU 的各级缓存中,因此,CPU 的缓存命中率和工作效率都会得到大幅提升。

Spark 3.0 - AQE

  • AQE 是 Spark SQL 的一种动态优化机制,在运行时,每当 Shuffle Map 阶段执行完毕,AQE 都会结合这个阶段的统计信息,基于既定的规则动态地调整、修正尚未执行的逻辑计划和物理计划,来完成对原始查询语句的运行时优化。
  • AQE 优化机制触发的时机是 Shuffle Map 阶段执行完毕。也就是说,AQE 优化的频次与执行计划中 Shuffle 的次数一致。
  • 首先,AQE 赖以优化的统计信息与 CBO 不同,这些统计信息并不是关于某张表或是哪个列,而是 Shuffle Map 阶段输出的中间文件。
  • 其次,结合 Spark SQL 端到端优化流程图我们可以看到,AQE 从运行时获取统计信息,在条件允许的情况下,优化决策会分别作用到逻辑计划和物理计划。

  • AQE 既定的规则和策略主要有 4 个,分为 1 个逻辑优化规则和 3 个物理优化策略。

3 个特性的动态优化过程:

  • Join 策略调整:如果某张表在过滤之后,尺寸小于广播变量阈值,这张表参与的数据关联就会从 Shuffle Sort Merge Join 降级(Demote)为执行效率更高的 Broadcast Hash Join。
  • 自动分区合并:在 Shuffle 过后,Reduce Task 数据分布参差不齐,AQE 将自动合并过小的数据分区。
  • 自动倾斜处理:结合配置项,AQE 自动拆分 Reduce 阶段过大的数据分区,降低单个 Reduce Task 的工作负载。

Join 策略调整

join 策略调整,这个特性涉及了一个逻辑规则和一个物理策略,它们分别是 DemoteBroadcastHashJoin 和 OptimizeLocalShuffleReader。

  • DemoteBroadcastHashJoin

    • DemoteBroadcastHashJoin 规则的作用,是把 Shuffle Joins 降级为 Broadcast Joins。需要注意的是,这个规则仅适用于 Shuffle Sort Merge Join 这种关联机制,其他机制如 Shuffle Hash Join、Shuffle Nested Loop Join 都不支持。

    • DemoteBroadcastHashJoin 会判断中间文件是否满足如下两个条件:

      • 中间文件尺寸总和小于广播阈值 spark.sql.autoBroadcastJoinThreshold
      • 空文件占比小于配置项 spark.sql.adaptive.nonEmptyPartitionRatioForBroadcastJoin

AQE 依赖的统计信息来自于 Shuffle Map 阶段生成的中间文件。这就意味着 AQE 在开始优化之前,Shuffle 操作已经执行过半了。不论大表还是小表都要完成 Shuffle Map 阶段的计算,并且把中间文件落盘,AQE 才能做出决策。

  • OptimizeLocalShuffleReader

    • 在这样的背景下,OptimizeLocalShuffleReader 物理策略就非常重要了。既然大表已经完成 Shuffle Map 阶段的计算,这些计算可不能白白浪费掉。采取 OptimizeLocalShuffleReader 策略可以省去 Shuffle 常规步骤中的网络分发,Reduce Task 可以就地读取本地节点(Local)的中间文件,完成与广播小表的关联操作。
    • OptimizeLocalShuffleReader 物理策略的生效与否由一个配置项决定。这个配置项是 spark.sql.adaptive.localShuffleReader.enabled,尽管它的默认值是 True

自动分区合并

  • 在 Reduce 阶段,当 Reduce Task 从全网把数据分片拉回,AQE 按照分区编号的顺序,依次把小于目标尺寸的分区合并在一起。

  • 目标分区尺寸由以下两个参数共同决定:

    • spark.sql.adaptive.advisoryPartitionSizeInBytes,由开发者指定分区合并后的推荐尺
    • spark.sql.adaptive.coalescePartitions.minPartitionNum,分区合并后,分区数不能低于该值。

自动倾斜处理

自动倾斜处理的拆分操作也是在 Reduce 阶段执行的。

  • spark.sql.adaptive.skewJoin.skewedPartitionFactor,判定倾斜的膨胀系数
  • spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes,判定倾斜的最低阈值
  • spark.sql.adaptive.advisoryPartitionSizeInBytes,以字节为单位,定义拆分粒度

问题1: 不能解决不同 Executors 之间的负载均衡问题,倾斜的分区到了一个executor。

问题2: 在数据关联的场景中,对于参与 Join 的两张表,我们暂且把它们记做数据表 1 和数据表 2,如果表 1 存在数据倾斜,表 2 不倾斜,那在关联的过程中,AQE 除了对表 1 做拆分之外,还需要对表 2 对应的数据分区做复制,来保证关联关系不被破坏。

如果现在问题变得更复杂了,左表拆出 M 个分区,右表拆出 N 个分区,那么每张表最终都需要保持 M x N 份分区数据,才能保证关联逻辑的一致性。当 M 和 N 逐渐变大时,AQE 处理数据倾斜所需的计算开销将会面临失控的风险。

总的来说,当应用场景中的数据倾斜比较简单,比如虽然有倾斜但数据分布相对均匀,或是关联计算中只有一边倾斜,我们完全可以依赖 AQE 的自动倾斜处理机制。但是,当我们的场景中数据倾斜变得复杂,比如数据中不同 Key 的分布悬殊,或是参与关联的两表都存在大量的倾斜,我们就需要衡量 AQE 的自动化机制与手工处理倾斜之间的利害得失。

Spark 3.0 - DPP

time.geekbang.org/column/arti…

分区剪裁

  • DPP(Dynamic Partition Pruning,动态分区剪裁)是 Spark 3.0 版本中第二个引人注目的特性,它指的是在星型 数仓 的数据关联场景中,可以充分利用过滤之后的维度表,大幅削减事实表的数据扫描量,从整体上提升关联计算的执行性能。
  • 分区表特别的地方就在于它的存储方式。对于分区键中的每一个数据值,分区表都会在文件系统中创建单独的子目录来存储相应的数据分片。如果过滤谓词中包含分区键,那么 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 效率。

动态分区剪裁

  • DPP 指的是在数据关联的场景中,Spark SQL 利用维度表提供的过滤信息,减少事实表中数据的扫描量、降低 I/O 开销,从而提升执行性能。

开发者要想利用好动态分区剪裁特性,需要注意 3 点:

  1. 事实表必须是分区表,并且分区字段必须包含 Join Key

  2. 动态分区剪裁只支持等值 Joins,不支持大于、小于这种不等值关联关系

  3. 维度表过滤之后的数据集,必须要小于广播阈值,因此,开发者要注意调整配置项 spark.sql.autoBroadcastJoinThreshold

Spark 3.0 - Join Hints

  • Spark 并没有选择支持 Broadcast + Sort Merge Join 这种组合方式。当数据能以广播的形式在网络中进行分发时,说明被分发的数据,也就是基表的数据足够小,完全可以放到内存中去。这个时候,相比 NLJ、SMJ,HJ 的执行效率是最高的。因此,在可以采用 HJ 的情况下,Spark 自然就没有必要再去用 SMJ 这种前置开销比较大的方式去完成数据关联。

Spark 如何选择 Join 策略?

Join Hints

在满足前提条件的情况下,如等值条件、连接类型、表大小等等,Spark 会优先尊重开发者的意愿,去选取开发者通过 Join Hints 指定的 Join 策略。

大表Join小表

  • 我们讲 3 个大表 Join 小表场景下,无法选择 BHJ 的案例。

案例 1:Join Key 远大于 Payload

  • 第一个案例是 Join Keys 远大于 Payload 的数据关联,我们可以使用映射方法(如哈希运算),用较短的字符串来替换超长的 Join Keys,从而大幅缩减小表的存储空间。如果缩减后的小表,足以放进广播变量,我们就可以将 SMJ 转换为 BHJ,来消除繁重的 Shuffle 计算。
  • 需要注意的是,映射方法要能够有效地避免“映射冲突”的问题,避免出现不同的 Join Keys 被映射成同一个数值。消除哈希冲突隐患的方法其实很多,比如“二次哈希”,也就是我们用两种哈希算法来生成 Hash Key 数据列。两条不同的数据记录在两种不同哈希算法运算下的结果完全相同,这种概率几乎为零。

案例 2:过滤条件的 Selectivity 较高

  • 第二个案例是,如果小表携带过滤条件,且过滤条件的选择性很高,我们可以通过开启 AQE 的 Join 策略调整特性,在运行时把 SMJ 转换为 BHJ,从而大幅提升执行性能。

  • 这里有两点需要我们特别注意:

    • 一是,为了能够成功完成转换,我们需要确保过滤之后的维表尺寸小于广播阈值;
    • 二是,如果大表本身是按照 Join Keys 进行分区的话,那么我们还可以充分利用 DPP 机制,来进一步缩减大表扫描的 I/O 开销,从而提升性能。

案例 3:小表数据分布均匀

  • 第三个案例是,如果小表不带过滤条件,且尺寸远超广播阈值。如果小表本身的数据分布比较均匀,我们可以考虑使用 Join hints 强行要求 Spark SQL 在运行时选择 SHJ 关联策略。

  • SHJ 要想成功地完成计算、不抛 OOM 异常,需要保证小表的每个数据分片都能放进内存。这也是为什么,我们要求小表的数据分布必须是均匀的。如果小表存在数据倾斜的问题,那么倾斜分区的 OOM 将会是一个大概率事件,SHJ 的计算也会因此而中断。

  • 一般来说,在“大表 Join 小表”的场景中,相比 SMJ,SHJ 的执行效率会更好一些。背后的原因在于,小表构建 哈希 表的开销,要小于两张表排序的开销。

大表Join大表

分而治之

  • “分而治之”的调优思路是把“大表 Join 大表”降级为“大表 Join 小表”,然后用“大表 Join 小表”的调优方法来解决性能问题。它的核心思想是,先把一个复杂任务拆解成多个简单任务,再合并多个简单任务的计算结果。

如何拆分内表?

  • “分而治之”中一个关键的环节就是内表拆分,我们要求每一个子表的尺寸相对均匀,且都小到可以放进广播变量。
  • 拆分的关键在于拆分列的选取。为了让子表足够小,拆分列的基数(Cardinality)要足够大才行,所以性别不行。但是,身份证号码这种基数比较大的字符串充当过滤条件有两个缺点:一,不容易拆分,开发成本太高;二,过滤条件很难享受到像谓词下推这种 Spark SQL 的内部优化机制。因此,选择日期作为拆分列往往是个不错的选择,既能享受到 Spark SQL 分区剪裁(Partition Pruning)的性能收益,同时开发成本又很低。

如何避免外表的重复扫描?

  • 对于外表参与的每一个子关联,在逻辑上,我们完全可以只扫描那些与内表子表相关的外表数据,并不需要每次都扫描外表的全量数据。
  • Spark 3.0 的 DPP 机制之后,我们就可以利用 DPP 来对外表进行“分而治之”,在数仓设计之初,就以 Join Key 作为分区键,对外表做分区存储。假设外表的分区键包含 Join Keys,那么,每一个内表子表都可以通过 DPP 机制,帮助与之关联的外表减少数据扫描量。

负隅顽抗

分而治之,也就是把一个庞大而又复杂的 Shuffle Join 转化为多个轻量的 Broadcast Joins。

负隅顽抗指的是,当内表没法做到均匀拆分,或是外表压根就没有分区键,不能利用 DPP,因此不能使用Broadcast Join,只能依赖 Shuffle Join。

数据分布均匀

小表join大表的案例3,同样适用于大表join大表,因为条件就是数据均匀。

  • 当参与关联的大表与小表满足如下条件的时候,Shuffle Hash Join 的执行效率,往往比 Spark SQL 默认的 Shuffle Sort Merge Join 更好。

    • 两张表数据分布均匀。
    • 内表所有数据分片,能够完全放入内存。(只要处理好并行度、并发度与执行内存之间的关系,我们就可以让内表的每一个数据分片都恰好放入执行内存中)
  • 如何强制 Spark SQL 在运行时选择 Shuffle Hash Join 机制呢?答案就是利用 Join Hints。

数据倾斜

以 Task 为粒度解决数据倾斜

  • AQE 的特性:自动倾斜处理。给定如下配置项参数,Spark SQL 在运行时可以将策略 OptimizeSkewedJoin 插入到物理计划中,自动完成 Join 过程中对于数据倾斜的处理。与此同时,AQE 还需要对内表中对应的数据分区进行复制,来保护两表之间的关联关系。

    • spark.sql.adaptive.skewJoin.skewedPartitionFactor,判定倾斜的膨胀系数。
    • spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes,判定倾斜的最低阈值。
    • spark.sql.adaptive.advisoryPartitionSizeInBytes,以字节为单位定义拆分粒度。
  • AQE 的倾斜处理是以 Task 为粒度的,这意味着原本 Executors 之间的负载倾斜并没有得到根本改善。

以 Executor 为粒度解决数据倾斜

倾斜分区刚好落在集群中少数的 Executors 上,你该怎么办呢?答案是:“分而治之”和“两阶段 Shuffle”。

分而治之

  • 含义就是,对于内外表中两组不同的数据,我们分别采用不同的方法做关联计算,然后通过 Union 操作,再把两个关联计算的结果集做合并,最终得到“大表 Join 大表”的计算结果

“两阶段 Shuffle”

两阶段 Shuffle 的关键在于加盐和去盐化。

  • 加盐的目的是打散数据分布、平衡 Executors 之间的计算负载,从而消除 Executors 单点瓶颈。
  • 去盐化的目的是还原原始的关联逻辑。

  1. 第一阶段,也就是“加盐、Shuffle、关联、聚合”的计算过程。

    1. 外表的处理称作“随机加盐”,具体的操作方法是,对于任意一个倾斜的 Join Key,我们都给它加上 1 到 Executors 总数 #N 之间的一个随机后缀。
    2. 内表的处理称为“复制加盐”,具体的操作方法是,对于任意一个倾斜的 Join Key,我们都把原数据复制(#N – 1)份,从而得到 #N 份数据副本。
  2. 第二阶段的计算包含“去盐化、Shuffle、聚合”这 3 个步骤。

    1. 首先,我们把每一个 Join Key 的后缀去掉,这一步叫做“去盐化”。
    2. 然后,我们按照原来的 Join Key 再做一遍 Shuffle 和聚合计算,这一步计算得到的结果,就是“分而治之”当中倾斜部分的计算结果。

refer

zhuanlan.zhihu.com/p/428400306