TiSpark 是 PingCAP 为解决用户复杂 OLAP 需求而推出的产品。它通过 Spark 提供的拓展机制与内置的 TiKV Client Java,在 Spark 之上直连 TiKV 进行读写,具有事务性读取、事务性写入与删除等能力。其中在事务性读取中基于 Spark Extension 实现了下推(详情可见 TiSpark 用户指南)。
为了帮助读者更好地理解、运用 TiSpark,本文将详细介绍 TiSpark 中下推相关的知识,包括 Spark 中的下推含义,实现原理,及其拓展接口、TiSpark 下推策略和下推支持列表。
本文作者:施宇航,PingCAP 研发工程师。

理解 Spark 的下推
TiSpark 本质是 Spark 的 connector,想要了解 TiSpark 是如何下推的必须先理解 Spark 中的下推。
了解 Spark SQL
首先简单了解一下 Spark SQL 的执行过程,这有助于理解下推原理的介绍。
Spark SQL 的核心是 Catalyst,它会依次进行 SQL 的解析,校验,优化,选择物理计划。最终生成可执行 RDD,交由 Spark Core 执行任务。

在这个过程中,Spark SQL 会被解析为一颗树。树的节点称为 TreeNode,它有多个实现类,可以用来表示逻辑计划树与物理计划树中各种类型的节点。这里不过多展开,我们只需要知道由 TreeNode 组成的一颗树可以表示一条 SQL:如过滤条件会被解析为 Filter 算子节点。

Spark 中的下推
下推是一种经典的 SQL 优化手段,它会尽量将一些算子推向靠近数据源的位置,以减少上层所需处理的数据量,最终达到加快查询速度的目的。常见的下推优化有:谓词下推,聚合下推,映射下推。
在分布式计算引擎 Spark 中,下推的含义如出一辙,但需要注意在 Spark 中其实有两步下推优化:
-
逻辑计划优化阶段:会尽量将算子推向靠近数据源的方向,但不会推向数据源
-
物理计划生成阶段:将算子推到数据源,Spark 可能不会再处理该算子
举个例子,考虑如下 SQL:
select * from A join B on A.id = B.id where A.a>10 and B.b<100;
上文提到 SQL 会被解析为一颗逻辑计划树
- filter 表示 where 条件
- join 表示 join 操作
- scan_a 与 scan_b 表示从数据源 A,B 表拉取数据

第一步:在逻辑计划下推优化后,过滤条件会被下推到更靠近数据源的位置,这样 join 所需处理的数据就会更少

对应的 SQL 可如下表示:
select * from (select * from A where A.a>10) a join (select * from B where B.b<100) b on a.id = b.id
第二步:在物理计划生成时,过滤条件还可能被彻底下推到数据源。也就是说 Spark 无需处理 Filter 了,数据源返回就已完成过滤。

Spark 下推原理
该小节代码基于 Spark 3.2
逻辑计划下推优化
Spark 首先会在逻辑计划优化时进行下推优化。
在 Catalyst 的逻辑计划优化阶段,会应用各种优化规则,其中就包括了下推优化的规则。这里就对应了上文所说的逻辑计划层的下推优化。以谓词下推优化 PushDownPredicates 为例
object PushDownPredicates extends Rule[LogicalPlan] with PredicateHelper {
def apply(plan: LogicalPlan): LogicalPlan = plan transform {
CombineFilters.applyLocally
.orElse(PushPredicateThroughNonJoin.applyLocally)
.orElse(PushPredicateThroughJoin.applyLocally)
}
}
CombineFilters 用于合并过滤条件。PushPredicateThroughNonJoin 和 PushPredicateThroughJoin 则用于分别处理不包含 join 和包含 join 时的谓词下推。由于这部分下推不是本文重点,我们不再赘述其具体实现,感兴趣的同学可以直接参考 Spark 源码。
物理计划下推数据源
在完成逻辑计划阶段的下推优化后,Spark 会基于下推结果,在生成物理计划时再进行下推数据源的优化。TiSpark 主要涉及此时的下推,我们重点阐述这部分的原理。
下推接口
在 Spark 中,提供了 DataSource API 接口用于拓展数据源,其中包含了下推接口用于指定需要下推到数据源的算子。以 Spark 3.2.1 的谓词下推为例,其接口如下:
@Evolving
public interface SupportsPushDownFilters extends ScanBuilder {
Filter[] pushFilters(Filter[] filters);
Filter[] pushedFilters();
}
- Filter[] pushFilters(Filter[] filters):入参是从 Catalyst expression 解析来的所有过滤条件,它是经过了第一步逻辑计划优化之后的结果。出参是 Spark 无法下推到数据源的过滤条件,被称为 postScanFilters
- Filter[] pushedFilters():出参是能下推到数据源的过滤条件,被称为 pushedFilters
其中 postScanFilters 和 pushedFilters 中允许有相同的 filter,此时数据源和 Spark 都会进行过滤操作。在 Spark 中 parquet row group filter 就是有相同 filter 的一个例子
下推原理
那么当我们实现该接口,Spark 又是如何运作的呢?我们可以简单将其归纳为两步:
- 第一步:根据此接口,保留无法下推到数据源的 Filter
- 第二步:根据此接口,最终生成物理计划时,在获取数据源数据的 Scan 算子中处理下推部分的 Filter。这一步中,由于不同的数据源会有不同处理,当我们自定义拓展数据源时,一般由我们自己实现。
先来看第一步,第一步发生在 catalyst 的优化阶段,由 V2ScanRelationPushDown 完成。
其简化过的核心代码如下:
object V2ScanRelationPushDown extends Rule[LogicalPlan] with PredicateHelper {
import DataSourceV2Implicits._
def apply(plan: LogicalPlan): LogicalPlan = {
applyColumnPruning(pushDownAggregates(pushDownFilters(createScanBuilder(plan))))
}
private def pushDownFilters(plan: LogicalPlan) = plan.transform {
case Filter(condition, sHolder: ScanBuilderHolder) =>
val (pushedFilters, postScanFiltersWithoutSubquery) = PushDownUtils.pushFilters(
sHolder.builder, normalizedFiltersWithoutSubquery)
val filterCondition = postScanFilters.reduceLeftOption(And)
filterCondition.map(Filter(_, sHolder)

最低0.47元/天 解锁文章
878

被折叠的 条评论
为什么被折叠?



