一文理解 Apache Spark DataSource V2 诞生背景及入门实战

五年总结:过往记忆大数据原创精选,欢迎收藏转发。

Data Source API 定义如何从存储系统进行读写的相关 API 接口,比如 Hadoop 的 InputFormat/OutputFormat,Hive 的 Serde 等。这些 API 非常适合用在 Spark 中使用 RDD 编程的时候使用。使用这些 API 进行编程虽然能够解决我们的问题,但是对用户来说使用成本还是挺高的,而且 Spark 也不能对其进行优化。为了解决这些问题,Spark 1.3 版本开始引入了 Data Source API V1,通过这个 API 我们可以很方便的读取各种来源的数据,而且 Spark 使用 SQL 组件的一些优化引擎对数据源的读取进行优化,比如列裁剪、过滤下推等等。

如果想及时了解Spark、Hadoop或者HBase相关的文章,欢迎关注微信公众号:过往记忆大数据

Data Source API V1 为我们抽象了一系列的接口,使用这些接口可以实现大部分的场景,这些接口如下(参见 org.apache.spark.sql.sources.interfaces.scala 文件):

常见的读取 JSON、CSV、JDBC、Kafka 以及最近开源的 Detla Lake 等都是通过 Data Source API V1 实现的。这个版本的 Data Source API 有以下几个优点:

  • 接口实现非常简单
  • 能够满足大部分的使用场景

但是随着 Spark 的不断发展,以及使用的用户越来越多,这个版本的 Data Source API 开始暴露出一些问题。

Data Source API V1 不足

部分接口依赖 SQLContext 和 DataFrame

一般而言,Data Source API 应该是比较底层的 API,但是这个版本的 Data Source API 依赖了上层的 API,比如 SQLContext、DataFrame 以及 RDD 等。在 Spark 2.0 中,SQLContext 已经被遗弃了,逐渐被 SparkSession 替代,同理,DataFrame 也被 Dataset API 取代。但是 Spark 无法更新数据源 API 以反映这些变化。我们可以看到高层次的 API 随着时间的推移而发展。较低层次的数据源 API 依赖于高层次的 API 不是一个好主意。扩展能力有限,难以下推其他算子当前数据源 API 仅支持 filter 下推和列修剪(参见上面的 PrunedFilteredScan 接口的 buildScan 方法)。如果我们想添加其他优化, 比如添加 limiy 优化,那么我们需要添加其他接口:buildScan(limit)

buildScan(limit, requiredCols)

buildScan(limit, filters)

buildScan(limit, requiredCols, filters)

这样下去对我们来说是一个噩梦!缺乏对列式存储读取的支持从上面的 buildScan API 可以看出,Spark 数据源进支持以行式的形式读取数据。即使 Spark 内部引擎支持列式数据表示,它也不会暴露给数据源。但是我们知道使用列式数据进行分析会有很多性能提升,所以 Spark 完全没必要读取列式数据的时候把其转换成行式,然后再再 Spark 里面转换成列式进行分析。缺乏分区和排序信息物理存储信息(例如,分区和排序)不会从数据源传递到 Spark 计算引擎,因此不会在 Spark 优化器中使用。这对于像 HBase/Cassandra 这些针对分区访问进行了优化的数据库来说并不友好。在 Data Source V1 API 中,当 Spark 从这些数据源读取数据时,它不会尝试将处理与分区相关联,这将导致性能不佳。写操作不支持事务当前的写接口非常通用。它的构建主要是为了支持在 HDFS 等系统中存储数据。但是像数据库这样更复杂的 Sink 需要更多地控制数据写入。例如,当数据部分写入数据库并且作业出现异常时,Spark 数据源接口将不会清理这些行。这个在 HDFS 写文件不存在这个问题,因为写 HDFS 文件时,如果写成功将生成一个名为 _SUCCESS 的文件,但是这种机制在数据库中是不存在的。在这种情况下,会导致数据库里面的数据出现不一致的状态。这种情况通常可以引入事务进行处理,但是 Data Source V1 版本不支持这个功能。不支持流处理越来越多的场景需要流式处理,但是 DataSource API V1 不支持这个功能,这导致想 Kafka 这样的数据源不得不调用一些专用的内部 API 或者独自实现。正是因为 DataSource API V1 的这些缺点和不足,引入 DataSource API V2 势在必行。Data Source API V2为了解决 Data Source V1 的一些问题,从 Apache Spark 2.3.0 版本开始,社区引入了 Data Source API V2,在保留原有的功能之外,还解决了 Data Source API V1 存在的一些问题,比如不再依赖上层 API,扩展能力增强。Data Source API V2 对应的 ISSUE 可以参见 SPARK-15689。本文以最新的 Apache Spark 2.4.3 版本进行介绍,这个版本的 Data Source API V2 主要抽象出以下几个接口:

如果想及时了解Spark、Hadoop或者HBase相关的文章,欢迎关注微信公众号:过往记忆大数据

这些抽象出来的类全部存放在 sql 模块中 core 的 org.apache.spark.sql.sources.v2 包里面,咋一看好像类的数目比之前要多了,但是功能、扩展性却比之前要好很多的。从上面的包目录组织结构可以看出,Data Source API V2 支持读写、流数据写、微批处理读(比如 KafkaSource 就用到这个了)以及 ContinuousRead(continuous stream processing)等多种方式读。在 reader 包里面有 SupportsPushDownFilters、SupportsPushDownRequiredColumns、SupportsReportPartitioning、SupportsReportStatistics 以及 SupportsScanColumnarBatch,分别对应的含义是算子下推、列裁剪、数据分区、统计信息以及批量列扫描等。

为了加深大家对 Data Source API V2 的印象,本文将介绍使用 Data Source API V2 编写一个读取 MySQL 数据的程序。

实现 ReadSupport 接口

为了使用 Data Source API V2,我们肯定是需要使用到 Data Source API V2 包里面相关的类库,对于读取程序,我们只需要实现 ReadSupport 相关接口就行,如下:

我们定义了一个 DefaultSource 的类,实现了 ReadSupport 接口,并使用 DataSourceV2 标记这是一个 Data Source API V2 的程序。注意,Data Source API V2 的程序必须实现 ReadSupport 或 WriteSupport 接口中的一个或两个,分别代表读和写的逻辑。这里为了简便起见,我们只实现了 ReadSupport 接口。

实现读 MySQL 相关操作前面我们实现了 ReadSupport 接口,并重写了 createReader 方法。这里我们需要实现 DataSourceReader 接口相关的操作,如下:

DataSourceReader 接口我们需要分别实现 readSchema 和 planInputPartitions 方法,分别代表我们程序需要读取的列相关信息,以及每个分区拆分及读取逻辑等。细心的同学肯定可以想到,读取操作不是可以弄一些算子下推,列裁剪相关的优化吗?没错,由于 DataSource V2 的优化,我们可以在这里加上 SupportsPushDownFilters、SupportsPushDownRequiredColumns、SupportsReportPartitioning 等相关的优化,完整的程序如下:

上面程序我们加上了列裁剪和算子下推。其中 pushedFilters 和 pushFilters 方法分别代码可以推下去的过滤以及不可以推下去的过滤。具体那些可以推下去,哪些不可以推下去是根据我们自己实现的。比如本例中只支持下推等于(EqualTo)、大于(GreaterThan)以及不为空(IsNotNull)的过滤条件,其他不支持。pruneColumns 这个方法就是列裁剪,就是我们 Spark SQL 中需要使用到的列,比如 select id, name from iteblog where age > 10 and state != 1 这条 SQL 列裁剪需要的列为 id、name 以及 state,其他的列不需要读取到 Spark 层面上来。

大家再仔细思路可以看出,DataSource V2 把每种优化都写到单独的一个接口里面,这样我们需要哪个优化就可以加哪个,这样就可以排列组合出很多种用法,这明显比 DataSource V1 版本的 PrunedFilteredScan 要灵活很多。假如我们需要将 limit 下推,我们只需要定义一个类似于 SupportsPushDownLimit 接口即可,非常的灵活。

最后一个需要我们实现的就是分片读取,在 DataSource V1 里面缺乏分区的支持,而 DataSource V2 支持完整的分区处理,也就是上面的 planInputPartitions 方法。在那里我们可以定义使用几个分区读取数据源的数据。比如如果是 TextInputFormat,我们可以读取到对应文件的 splits 个数,然后每个 split 构成这里的一个分区,使用一个 Task 读取。为了简便起见,我这里使用了只使用了一个分区,也就是 List[InputPartition[InternalRow]](MySQLInputPartition(requiredSchema, supportedFilters.toArray, options)).asJava。

分区读取实现

到这里,我们需要定义每个分区具体是如何读取的,这里就是真实的数据读取实现逻辑,比如本文例子的实现如下:

具体分区读取是需要实现 InputPartitionReader 接口的,大家可以看到,这里面就是真正的 MySQL 查询 SQL 的拼接,以及我们平时参见的 MySQL 数据查询方法。仔细的同学可以看出拼接的 SQL 中 where 条件里面的就是我们的算子下推逻辑;而 select 部分就是我们的列裁剪部分。

使用 DataSource V2

到这里,我们已经使用 DataSource V2 API 定义了一个读取 MySQL 的类库,我们可以像正常 Spark 类库一样使用这个类库,如下:

这条 SQL 没有使用到 select,所以会使用到表中所有的列,并且以为我们已经支持大于等算子下推,所以 id > 10 这个应该是会下推到 MySQL 端执行的,具体的执行计划如下:

从上面可以清晰看到 id > 10 已经下推了,见 Filters: [isnotnull(id#0), (id#0 > 10)]。对应拼接出来的 SQL 为

SELECT ID,ip,count,times,total FROM search_info WHERE `id` IS NOT NULL AND `id` > 10

在看下下面的测试:

对应的执行计划如下:

从上面的 Physical Plan 可以看出,count#2 >= 10 这个并没有推到数据源执行,以为我们这个例子里面没有实现大于等于算子的下推。本例我们使用了 select,并且指定了 id、ip 列,再加上没有推到 MySQL 端的列,所以这次执行只需要获取 id、ip 以及 count 三列即可,最后拼接后的 SQL 如下:SELECT ID,ip,count FROM search_info WHERE `count` IS NOT NULL AND `id` IS NOT NULL AND `id` > 10好了,DataSource API V2 的 demo 到这里就介绍的差不多了。目前 DataSource API V2 还在不断演化中,不同版本的 API 可能和这里介绍的不一样,比如 Spark 2.3.x 支持分区的 API 是 createDataReaderFactories,而 Spark 2.4.x 是 planInputPartitions,详见 SPARK-24073。同时,Apache Spark DataSource API V2 是一个比较大的 Feature ,虽然早在 Spark 2.3 版本中已经引入了,但是其实还有很多功能未发布,内置的各种数据源实现基本上都是基于 DataSource API V1 实现的;而且在 Apache Spark 2.x 版本中也不是很稳定,关于 Spark DataSource API V2 版本的稳定性工作以及新功能可以分别参见 SPARK-25186 以及 SPARK-22386。Spark DataSource API V2 最终稳定版以及新功能将会随着年底和 Apache Spark 3.0.0 版本一起发布,其也算是 Apache Spark 3.0.0 版本的一大新功能。

发布于 2019-09-18