谈谈Spark Sql中的join

spark中的join

  • 关联形式

按照关联形式划分,数据关联分为 内关联、外关联、左关联、右关联等等。对于参与的关联表来说,其关联形式决定的了数据的存在结果,所以选择关联形式,是由业务逻辑决定的。

  • 实现机制

对于Join的实现方式,Join 又可以分为 NLJ(Nested Loop Join)、SMJ(Sort Merge Join)和 HJ(Hash Join)。也就是说,同样是内关联,我们既可以采用 NLJ 来实现,也可以采用 SMJ 或是 HJ 来实现。区别在于其执行效率在不同的计算场景下是不同的。

通过一个例子来说一说spark sql的join。

数据来源

import spark.implicits._
import org.apache.spark.sql.DataFrame
 
// 创建员工信息表
val seq = Seq((1, "Mike", 28, "Male"), (2, "Lily", 30, "Female"), (3, "Raymond", 26, "Male"), (5, "Dave", 36, "Male"))
val employees: DataFrame = seq.toDF("id", "name", "age", "gender")
 
// 创建薪资表
val seq2 = Seq((1, 26000), (2, 30000), (4, 25000), (3, 20000))
val salaries:DataFrame = seq2.toDF("id", "salary")
复制代码

如上述代码,创建了两个data frame。一个用于存储员工信息,叫做员工表,另一个存储员工薪水,叫做薪资表。

有了数据,就要对其进行关联。所谓数据关联,它指的是这样一个计算过程:给定关联条件(Join Conditions)将两张数据表以不同关联形式拼接在一起的过程。关联条件包含两层含义,一层是两张表中各自关联字段(Join Key)的选择,另一层是关联字段之间的逻辑关系。

我们知道Spark sQL同时支持DataFrame算子和sql查询。如下图所示为这两种方式的关联数据的写法:

image.png

我们将主动参与Join的数据表,叫做左表(salaries),被动参与关联的表叫做右表(employees)。

从代码中我们可以看出两者的逻辑其实是相等的,都是以id 列作为关联字段,关联条件是相等,关联形式inner。

关联形式(Join Types)

在关联形式这方面,Spark SQL 的支持比较全面,下表为Spark SQL 支持的 Joint Types。

image.png

内关联(Inner Join)

对于登记在册的员工,如果我们想获得他们每个人的薪资情况,就可以使用内关联来实现,如下所示。


// 内关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "inner")
 
jointDF.show
 
/** 结果打印
+---+------+---+-------+---+------+
| id|salary| id| name|age|gender|
+---+------+---+-------+---+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 3| 20000| 3|Raymond| 26| Male|
+---+------+---+-------+---+------+
*/
 
// 左表
salaries.show
 
/** 结果打印
+---+------+
| id|salary|
+---+------+
| 1| 26000|
| 2| 30000|
| 4| 25000|
| 3| 20000|
+---+------+
*/
 
// 右表
employees.show
 
/** 结果打印
+---+-------+---+------+
| id| name|age|gender|
+---+-------+---+------+
| 1| Mike| 28| Male|
| 2| Lily| 30|Female|
| 3|Raymond| 26| Male|
| 5| Dave| 36| Male|
+---+-------+---+------+
*/
复制代码

对于dataframe这种算子,join中的第三个参数用于指定关联方式。

对于内关联来说,内关联的效果,是仅仅保留左右表中满足关联条件的那些数据记录。以上表为例,关联条件是 salaries(“id”) === employees(“id”),而在员工表与薪资表中,只有 1、2、3 这三个值同时存在于他们各自的 id 字段中。相应地,结果集中就只有 id 分别等于 1、2、3 的这三条数据记录。

外关联(Outer Join)

外关联还可以细分为 3 种形式,分别是左外关联、右外关联、以及全外关联。这里的左、右,对应的实际上就是左表、右表。

左联

对于dataframe API来说,只需要把"inner"换成相关的关联方式就行,如"left",上表。


val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "left")
 
jointDF.show
 
/** 结果打印
+---+------+----+-------+----+------+
| id|salary| id| name| age|gender|
+---+------+----+-------+----+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 4| 25000|null| null|null| null|
| 3| 20000| 3|Raymond| 26| Male|
+---+------+----+-------+----+------+
*/
复制代码

左外关联的结果集,实际上就是内关联结果集,再加上左表 salaries 中那些不满足关联条件的剩余数据,也即 id 为 4 的数据记录。值得注意的是,由于右表 employees 中并不存在 id 为 4 的记录,因此结果集中 employees 对应的所有字段值均为空值 null。

右联

对于dataframe API来说,只需要把"inner"换成相关的关联方式就行,如"right",上表。


val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "right")
 
jointDF.show
 
/** 结果打印
+----+------+---+-------+---+------+
| id|salary| id| name|age|gender|
+----+------+---+-------+---+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 3| 20000| 3|Raymond| 26| Male|
|null| null| 5| Dave| 36| Male|
+----+------+---+-------+---+------+
*/
复制代码

与左外关联相反,右外关联的结果集,恰恰是内关联的结果集,再加上右表 employees 中的剩余数据,也即 id 为 5、name 为“Dave”的数据记录。同样的,由于左表 salaries 并不存在 id 等于 5 的数据记录,因此,结果集中 salaries 相应的字段置空,以 null 值进行填充。

全关联

对于dataframe API来说,只需要把"inner"换成相关的关联方式就行,如"full",上表。


val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "full")
 
jointDF.show
 
/** 结果打印
+----+------+----+-------+----+------+
| id|salary| id| name| age|gender|
+----+------+----+-------+----+------+
| 1| 26000| 1| Mike| 28| Male|
| 3| 20000| 3|Raymond| 26| Male|
|null| null| 5| Dave| 36| Male|
| 4| 25000|null| null|null| null|
| 2| 30000| 2| Lily| 30|Female|
+----+------+----+-------+----+------+
*/
复制代码

全外关联的结果集,就是内关联的结果,再加上那些不满足关联条件的左右表剩余数据。

也可以理解为, 内关联为交集,左关联为左包含,右关联为右包含,全关联为并集。

左半 / 逆关联(Left Semi Join / Left Anti Join)

对于dataframe API来说,只需要把"inner"换成相关的关联方式就行,如“leftsemi”和“left_semi”,上表。

左半关联的结果集,实际上是内关联结果集的子集,它仅保留左表中满足关联条件的那些数据记录。


// 内关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "inner")
 
jointDF.show
 
/** 结果打印
+---+------+---+-------+---+------+
| id|salary| id| name|age|gender|
+---+------+---+-------+---+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 3| 20000| 3|Raymond| 26| Male|
+---+------+---+-------+---+------+
*/
 
// 左半关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "leftsemi")
 
jointDF.show
 
/** 结果打印
+---+------+
| id|salary|
+---+------+
| 1| 26000|
| 2| 30000|
| 3| 20000|
+---+------+
*/
复制代码

左逆关联同样只保留左表的数据,它的关键字有“leftanti”和“left_anti”。与左半关联不同的是,它保留的,是那些不满足关联条件的数据记录,

// 左逆关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "leftanti")
 
jointDF.show
 
/** 结果打印
+---+------+
| id|salary|
+---+------+
| 4| 25000|
+---+------+
*/
复制代码

右半关联和右逆半关联与其类似。

关联机制(Join Mechanisms)

Join 有 3 种实现机制,分别是 NLJ(Nested Loop Join)、SMJ(Sort Merge Join)和 HJ(Hash Join)。以内联为例,进行分析


// 内关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "inner")
 
jointDF.show
 
/** 结果打印
+---+------+---+-------+---+------+
| id|salary| id| name|age|gender|
+---+------+---+-------+---+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 3| 20000| 3|Raymond| 26| Male|
+---+------+---+-------+---+------+
*/
复制代码

NLJ:Nested Loop Join

对于参与关联的两张表,如 salaries 和 employees,我们把 salaries 称作“左表”,而把 employees 称作“右表”。在探讨关联机制的时候,我们又常常把左表称作是“驱动表”,而把右表称为“基表”。

一般来说,驱动表的体量往往较大,在实现关联的过程中,驱动表是主动扫描数据的那一方。而基表相对来说体量较小,它是被动参与数据扫描的那一方。

在NLJ的实现机制下,算法会使用外、内两个嵌套的for循环,来依次扫描驱动表和基表中的数据记录。在扫描的同时,还会判定关联条件是否成立,如内关联例子中的salaries(“id”) === employees(“id”)。如果关联条件成立,就把两张表的记录拼接在一起,然后对外进行输出。

image.png 在实现的过程中,外层的 for 循环负责遍历驱动表的每一条数据,如图中的步骤 1 所示。对于驱动表中的每一条数据记录,内层的 for 循环会逐条扫描基表的所有记录,依次判断记录的 id 字段值是否满足关联条件,如步骤 2 所示。

类似暴力解法,假设驱动表有 M 行数据,而基表有 N 行数据,那么 NLJ 算法的计算复杂度是 O(M * N)。尽管 NLJ 的实现方式简单、直观、易懂,但它的执行效率显然很差。

SMJ:Sort Merge Join

鉴于NLJ低效的计算效率,SMJ应运而生。Sort Merge Join,SMJ 的实现思路是先排序、再归并。给定参与关联的两张表,SMJ 先把他们各自排序,然后再使用独立的游标,对排好序的两张表做归并关联。

image.png 具体的计算过程为:起初,驱动表与基表的游标都会先锚定在各自的第一条记录上,然后通过对比游标所在记录的 id 字段值,来决定下一步的走向。对比结果以及后续操作主要分为 3 种情况:

  • 满足关联条件,两边的 id 值相等,那么此时把两边的数据记录拼接并输出,然后把驱动表的游标滑动到下一条记录;
  • 不满足关联条件,驱动表 id 值小于基表的 id 值,此时把驱动表的游标滑动到下一条记录;
  • 不满足关联条件,驱动表 id 值大于基表的 id 值,此时把基表的游标滑动到下一条记录。

基于这 3 种情况,SMJ 不停地向下滑动游标,直到某张表的游标滑到尽头,即宣告关联结束。对于驱动表的每一条记录,由于基表已按 id 字段排序,且扫描的起始位置为游标所在位置,因此,SMJ 算法的计算复杂度为 O(M + N)。

但是其计算复杂度低的原因是由于其事先将两张表已经排序好了。但是排序本身就是一项很耗时的操作。

因此,SMJ 的计算过程我们可以用“先苦后甜”来形容。苦,指的是要先花费时间给两张表做排序,而甜,指的则是有序表的归并关联能够享受到线性的计算复杂度。

HJ:Hash Join

考虑到 SMJ 对于排序的苛刻要求,后来又有人推出了 HJ 算法。HJ 的设计初衷是以空间换时间,力图将基表扫描的计算复杂度降低至 O(1)。 image.png

具体来说:HJ 的计算分为两个阶段,分别是 Build 阶段和 Probe 阶段。在 Build 阶段,在基表之上,算法使用既定的哈希函数构建哈希表,如上图的步骤 1 所示。哈希表中的 Key 是 id 字段(需要关联的字段)应用(Apply)哈希函数之后的哈希值,而哈希表的 Value 同时包含了原始的 Join Key(id 字段)和 Payload。

在 Probe 阶段,算法依次遍历驱动表的每一条数据记录。首先使用同样的哈希函数,以动态的方式计算 Join Key 的哈希值。然后,算法再用哈希值去查询刚刚在 Build 阶段创建好的哈希表。如果查询失败,则说明该条记录与基表中的数据不存在关联关系;相反,如果查询成功,则继续对比两边的 Join Key。如果 Join Key 一致,就把两边的记录进行拼接并输出,从而完成数据关联。

Join 实现机制的优势对比

在大数据的应用场景中,数据的处理往往是在分布式的环境下进行的,在这种情况下,数据关联的计算还要考虑网络分发这个环节。在分布式环境中,Spark 支持两类数据分发模式。一类是Shuffle,Shuffle 通过中间文件来完成 Map 阶段与 Reduce 阶段的数据交换,因此它会引入大量的磁盘与网络开销。另一类是广播变量(Broadcast Variables),广播变量在 Driver 端创建,并由 Driver 分发到各个 Executors。

因此,从数据分发模式的角度出发,数据关联又可以分为 Shuffle Join 和 Broadcast Join 这两大类。将两种分发模式与 Join 本身的 3 种实现机制相结合,就会衍生出分布式环境下的 6 种 Join 策略。

Join 支持 3 种实现机制,它们分别是 Hash Join、Sort Merge Join 和 Nested Loop Join。三者之中,Hash Join 的执行效率最高,这主要得益于哈希表 O(1) 的查找效率。不过,在 Probe 阶段享受哈希表的“性能红利”之前,Build 阶段得先在内存中构建出哈希表才行。因此,Hash Join 这种算法对于内存的要求比较高,适用于内存能够容纳基表数据的计算场景。

相比之下,Sort Merge Join 就没有内存方面的限制。不论是排序、还是合并,SMJ 都可以利用磁盘来完成计算。所以,在稳定性这方面,SMJ 更胜一筹。

而且与 Hash Join 相比,SMJ 的执行效率也没有差太多,前者是 O(M),后者是 O(M + N),可以说是不分伯仲。当然,O(M + N) 的复杂度得益于 SMJ 的排序阶段。因此,如果准备参与 Join 的两张表是有序表,那么这个时候采用 SMJ 算法来实现关联简直是再好不过了。

与前两者相比,Nested Loop Join 看上去有些多余,嵌套的双层 for 循环带来的计算复杂度最高:O(M * N)。但是执行高效的 HJ 和 SMJ 只能用于等值关联,也就是说关联条件必须是等式,像 salaries(“id”) < employees(“id”) 这样的关联条件,HJ 和 SMJ 是无能为力的。相反,NLJ 既可以处理等值关联(Equi Join),也可以应付不等值关联(Non Equi Join),可以说是数据关联在实现机制上的最后一道防线。

Shuffle Join 与 Broadcast Join

通过了解了不同 Join 机制的优缺点之后,需要理解分布式环境下的 Join 策略。与单机环境不同,在分布式环境中,两张表的数据各自散落在不同的计算节点与 Executors 进程。因此,要想完成数据关联,Spark SQL 就必须先要把 Join Keys 相同的数据,分发到同一个 Executors 中去才行。

以上最初的例子为例,对 salaries 和 employees 两张表按照 id 列做关联,那么,对于 id 字段值相同的薪资数据与员工数据,我们必须要保证它们坐落在同样的 Executors 进程里,Spark SQL 才能利用刚刚说的 HJ、SMJ、以及 NLJ,以 Executors(进程)为粒度并行地完成数据关联。也就是说以 Join Keys 为基准,两张表的数据分布保持一致,是 Spark SQL 执行分布式数据关联的前提。而能满足这个前提的途径只有两个:Shuffle 与广播。

shuffle join

在没有开发者干预的情况下,Spark SQL 默认采用 Shuffle Join 来完成分布式环境下的数据关联。对于参与 Join 的两张数据表,Spark SQL 先是按照如下规则,来决定不同数据记录应当分发到哪个 Executors 中去:

  • 根据 Join Keys 计算哈希值
  • 将哈希值对并行度(Parallelism)取模

由于左表与右表在并行度(分区数)上是一致的,因此,按照同样的规则分发数据之后,一定能够保证 id 字段值相同的薪资数据与员工数据坐落在同样的 Executors 中。

image.png

其原理为将Map阶段分散在不同的Executor中的Joinkeys相同的数据记录经过shuffle后,Join Keys 相同的记录被分发到了同样的 Executors 中去。reduce阶段的reduce Task就可以使用HJ、SMJ、或是 NLJ 算法在 Executors 内部完成数据关联的计算。

spark Sql默认一律采用Shuffle Join,原因在于 Shuffle Join 的“万金油”属性。在任何情况下,不论数据的体量是大是小、不管内存是否足够,Shuffle Join 在功能上都能够“不辱使命”,成功地完成数据关联的计算。但是其性能会有损耗。

我们知道从 CPU 到内存,从磁盘到网络,Shuffle 的计算几乎需要消耗所有类型的硬件资源。尤其是磁盘和网络开销,这往往是应用执行的性能瓶颈。所以为了提高效率采用Broadcast Join

Broadcast Join

其原理为对于参与 Join 的两张表,我们可以把其中较小的一个封装为广播变量,然后再让它们进行关联。

还是结合上面代码的例子


import org.apache.spark.sql.functions.broadcast
 
// 创建员工表的广播变量
val bcEmployees = broadcast(employees)
 
// 内关联,PS:将原来的employees替换为bcEmployees
val jointDF: DataFrame = salaries.join(bcEmployees, salaries("id") === employees("id"), "inner")
复制代码

在Broadcast Join 的执行过程中,Spark Sql首先从各个Executors 收集 employees 表所有的数据分片,然后在Driver端构建广播变量bcEmployees,构建的过程如下图实线部分所示

image.png 其过程为散落在不同 Executors 内的employees 表的数据分片聚集到一起,形成了广播变量。接下来,如图中虚线部分所示,携带着 employees 表全量数据的广播变量 bcEmployees,被分发到了全网所有的 Executors 当中去。

在这种情况下,体量较大的薪资表数据只要“待在原地、保持不动”,就可以轻松关联到跟它保持之一致的员工表数据了。通过这种方式,Spark SQL 成功地避开了 Shuffle 这种“劳师动众”的数据分发过程,转而用广播变量的分发取而代之。

尽管广播变量的创建与分发同样需要消耗网络带宽,但相比 Shuffle Join 中两张表的全网分发,因为仅仅通过分发体量较小的数据表来完成数据关联,Spark SQL 的执行性能显然要高效得多。

Spark SQL 支持的 Join 策略

不论是 Shuffle Join,还是 Broadcast Join,一旦数据分发完毕,理论上可以采用 HJ、SMJ 和 NLJ 这 3 种实现机制中的任意一种,完成 Executors 内部的数据关联。因此,两种分发模式,与三种实现机制,它们组合起来,总共有 6 种分布式 Join 策略,如下图所示。

image.png 在这 6 种 Join 策略中,Spark SQL 支持其中的 5 种来应对不用的关联场景,也即图中蓝色的 5 个矩形。对于等值关联(Equi Join),Spark SQL 优先考虑采用 Broadcast HJ 策略,其次是 Shuffle SMJ,最次是 Shuffle HJ。对于不等值关联(Non Equi Join),Spark SQL 优先考虑 Broadcast NLJ,其次是 Shuffle NLJ。

image.png

不难发现,不论是等值关联、还是不等值关联,只要 Broadcast Join 的前提条件成立,Spark SQL 一定会优先选择 Broadcast Join 相关的策略。但是其可以进行的条件是被广播数据表(图中的表 2)的全量数据能够完全放入 Driver 的内存、以及各个 Executors 的内存。

为了避免因广播表尺寸过大而引入新的性能隐患,Spark SQL 要求被广播表的内存大小不能超过 8GB。

总结:

只要被广播表满足上述两个条件,我们就可以利用 SQL Functions 中的 broadcast 函数来创建广播变量,进而利用 Broadcast Join 策略来提升执行性能。

在 Broadcast Join 前提条件不成立的情况下,Spark SQL 就会退化到 Shuffle Join 的策略,不等值的数据关联中,Spark SQL 只有 Shuffle NLJ 这一种选择。

但在等值关联的场景中,Spark SQL 有 Shuffle SMJ 和 Shuffle HJ 这两种选择。

学习过 Shuffle 之后,我们知道,Shuffle 在 Map 阶段往往会对数据做排序,而这恰恰正中 SMJ 机制的下怀。对于已经排好序的两张表,SMJ 的复杂度是 O(M + N),这样的执行效率与 HJ 的 O(M) 可以说是不相上下。再者,SMJ 在执行稳定性方面,远胜于 HJ,在内存受限的情况下,SMJ 可以充分利用磁盘来顺利地完成关联计算。因此,考虑到 Shuffle SMJ 的诸多优势,Spark Sql多数进行采用。

下表对比了单机环境中不同不同Join机制的优劣势,

image.png 在分布式环境中,要想利用上述机制完成数据关联,Spark SQL 首先需要把两张表中 Join Keys 一致的数据分发到相同的 Executors 中。因此,数据分发是分布式数据关联的基础和前提。Spark SQL 支持 Shuffle 和广播两种数据分发模式,Join 也被分为 Shuffle Join 和 Broadcast Join,其中 Shuffle Join 是默认的关联策略。关于两种策略的优劣势对比,如下表

image.png

结合三种实现机制和两种数据分发模式,Spark SQL 支持 5 种分布式 Join 策略。对于这些不同的 Join 策略,进行了整理 如下表:

前提 Broadcast Join 的生效前提,是基表能够放进内存,且存储尺寸小于 8GB。只要前提条件成立,Spark SQL 就会优先选择 Broadcast Join。

image.png

考虑,为什么不采用Broadcast SMJ

因为在Broadcas Join中实现的前提是被广播的表能够存放到内存中,而HJ相比较于SMJ,并不要求参与Join的两张表有序,也不需要维护两个游标来判断当前的记录位置,只要在 Build 阶段构建的哈希表可以放进内存就行。这个时候,相比 NLJ、SMJ,HJ 的执行效率是最高的。因此,当Broadcast Join 的前提条件存在,在可以采用 HJ 的情况下,Spark 自然就没有必要再去用 SMJ 这种前置开销(排序)比较大的方式去完成数据关联。

猜你喜欢

转载自juejin.im/post/7109809074660704287