Spark3 新特性之DPP
DPP
DPP(Dynamic Partition Pruning,动态分区剪裁),它指的是在星型数仓的数据关联场景中,可以充分利用过滤之后的维度表,大幅削减事实表的数据扫描量,从整体上提升关联计算的执行性能。
分区剪裁
在星型(Start Schema)数仓中,我们有两张表,一张是订单表 orders,另一张是用户表 users。订单表是事实表(Fact),而用户表是维度表(Dimension)。业务需求是统计所有头部用户贡献的营业额,并按照营业额倒序排序。
// 订单表orders关键字段
userId, Int
itemId, Int
price, Float
quantity, Int
// 用户表users关键字段
id, Int
name, String
type, String //枚举值,分为头部用户和长尾用户
-- 查询sql
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
由于查询语句中事实表(orders)上没有过滤条件,Spark SQL 全表扫描,维度表(users)上有过滤条件 users.type = ‘Head User’,Spark SQL 可以谓词下推,把过滤操作下推到数据源之上,来减少必需的磁盘 I/O 开销。
虽然谓词下推已经很给力了,但如果用户表支持分区剪裁(Partition Pruning),I/O 效 率的提升就会更加显著。那什么是分区剪裁呢?实际上,分区剪裁是谓词下推的一种特例,它指的是在分区表中下推谓词,谓词是分区目录。分区表是通过指定分区键,然后使用 partitioned by 语句创建的数据表,或者是使用 partitionBy 语句存储的列存文件(如 Parquet、ORC 等)。
分区表分不同的目录存储数据。假设用户表是分区表,且以 type 字段作为分区键,那么用户表会有两个子目录,分别是“Head User”和“Tail User”。
**如果过滤谓词中包含分区键,那么 Spark SQL 对分区表做扫描的时候,是完全可以跳过(剪掉)不满足谓词条件的分区目录,这就是分区剪裁。**例如,在我们的查询语句中,用户表的过滤谓词是“users.type = ‘Head User’”。假设用户表是分区表,那么对于用户表的数据扫描,Spark SQL 可以完全跳过前缀为“Tail User”的子目录。
对于实际工作中的绝大多数关联查询来说,事实表都不满足分区剪裁所需的前提条件。比如说,要么事实表不是分区表,要么事实表上没有过滤谓词,或者就是过滤谓词不包含分区键。
动态分区剪裁
DPP 指的是在数据关联的场景中,Spark SQL 利用维度表提供的过滤信息,减少事实表中数据的扫描量、降低 I/O 开销,从而提升执行性能。
首先,过滤条件 users.type = ‘Head User’会帮助维度表过滤一部分数据。维度表保留下来的 ID 值,仅仅是维度表 ID 全集的一个子集,如图中的步骤 1 所示。
然后,在关联关系 orders.userId = users.id 的作用下,过滤效果会通过 users 的 ID 字段传导到事实表的 userId 字段,也就是图中的步骤 2。满足关联关系的 userId 值,是事实表 userId 全集中的一个子集。
之后,把满足条件的 userId 作为过滤条件, 应用(Apply)到事实表的数据源,就可以做到减少数据扫描量,提升 I/O 效率,如图中的步骤 3 所示。
DPP 正是基于上述逻辑,把维度表中的过滤条件,通过关联关系传导到事实表,从而完成事实表的优化。
数据关联使用DPP需要满足三个额外的条件:
- 事实表必须是分区表,而且分区字段(可以是多个)必须包含Join Key
- DPP 仅支持等值 Joins
- 维度表过滤之后的数据集要小于广播阈值
为什么需要满足 “维度表过滤之后的数据集要小于广播阈值” ?
实现 DPP 机制的关键在于,需要让处理事实表的计算分支,能够拿到满足过滤条件的 Join Key 列表,然后用这个列表来对事实表做分区剪裁。那么问题来了,用什么办法才能拿到这个列表呢?
Spark SQL 选择了一种“一箭双雕”的做法:使用广播变量封装过滤之后的维度表数据。 在维度表做完过滤之后,Spark SQL 在其上构建哈希表(Hash Table),这个哈希表的 Key 是用于关联的 Join Key。在我们的例子中,Key 是满足过滤 users.type = ‘Head User’条件的 users.id,Value是users.name。
哈希表构建完毕之后,Spark SQL 将其封装到广播变量中,这个广播变量的作用有二:
第 一个作用:给事实表用来做分区剪裁,哈希表中的 Key Set 用来给事实表过滤符合条件的数据分区;
第二个作用:参与后续的 Broadcast Join 数据关联,这里的哈希表,本质上就是 Hash Join 中的 Build Table,其中的 Key、Value,记录着数据关联中所需的所有字段,如 users.id、users.name,刚好拿来和事实表做 Broadcast Hash Join。
Spark SQL 选择了广播变量来有效利用 DPP 优化机制, 必须要谨慎设置spark.sql.autoBroadcastJoinThreshold
。
DPP的“硬伤”,Join Keys往往是高基数(cardinality)的字段,比如userId;而分区键往往要选择低基数的字段,否则数据的存储就会非常的分散。需要在存储效率和DPP之间做权衡,如果查询效率是第一优先级,那么我们其实还是可以强行对cardinality较高的Join Key做分区键。但如果相反,存储效率最大的concern,那么也就只好放弃取Join Key做分区键,放弃DPP优化机制。