目前对多Catalog的支持俨然成为计算引擎的标配,因为在OLAP场景,跨数据源的联合查询是一大刚需。但是,传统的计算引擎如Hive、Spark2对多Catalog支持能力很弱,也许是受Flink、Presto(Trino)的步步紧逼,Hive3也开始支持多Catalog,但是仅限于存储层面和API层面,还没有推进到SQL层。Spark相比Hive要进展好得多,在多Catalog的实现方式上甚至成为Flink实现多Catalog的借鉴对象。本文来研究下Spark3多Catalog的用法。

首先我们从简单的Spark查询开始。

SparkConf sparkConf = new SparkConf()
.set("hive.metastore.uris",hiveMetastoreURI)
.set("spark.sql.warehouse.dir",warehousePath)
.setMaster("local[*]") // spark-submit should remove this
.setAppName(getClass().getSimpleName());

SparkSession sparkSession = SparkSession.builder().config(sparkConf).enableHiveSupport().getOrCreate();
sparkSession.sql("show databases").show();

如上示例,Spark3 仅通过配置hive.metastore.uris 地址,我们就可以访问Hive Metastore的元数据,进行SQL查询等操作,最新Spark3 基于Hive Metastore Client 2.3.9版本开发,能够兼容HMS 2.x和3.x,但是使用有个限制:无法访问HMS3.x 非默认Catalog中的元数据。(此处所说的Catalog是HMS 存储模型中的Catalog,非本文说的Spark Catlalog)。

如果要访问HMS3.x 非默认Catalog中的元数据,就需要指定HMS的高版本jars路径,Spark3 提供了加载外部jars的方式,这里以Hive 3.1.2为例:

SparkConf sparkConf = new SparkConf()
.set("hive.metastore.uris",hiveMetastoreURI)
.set("spark.sql.warehouse.dir",warehousePath)
.set("spark.sql.hive.metastore.version", "3.1.2")
.set("metastore.catalog.default", defaultCatalogName)
.set("spark.sql.hive.metastore.jars", String.format("%s/lib/*", hiveHome))
.setMaster("local[*]") // spark-submit should remove this
.setAppName(getClass().getSimpleName());

SparkSession sparkSession = SparkSession.builder().config(sparkConf).enableHiveSupport().getOrCreate();
sparkSession.sql("show databases").show();

示例中通过metastore.catalog.default指定了当前要访问的HMS catalog,此时Spark就能够获取到defaultCatalogName指定的catalog 下的HMS元数据。

除了可以配置上述HMS地址参数外,还可以配置更多参数,而参数的名称和值,跟使用Hive、Hadoop参数一样的标准参数。以Spark 操作Iceberg为例:

SparkConf sparkConf = new SparkConf()
.set("spark.sql.warehouse.dir",warehousePath)
.set("spark.sql.catalog.spark_catalog",org.apache.iceberg.spark.SparkSessionCatalog")
.set("spark.sql.catalog.spark_catalog.type", "hive")
.set("spark.sql.catalog.spark_catalog.default-namespace", "default")
.set("spark.sql.catalog.spark_catalog.uri",hiveMetastoreURI)
//配置项是Hadoop标准参数,此处配置以spark.hadoop.为前缀
.set("spark.hadoop.hive.metastore.uris", hiveMetastoreURI)
.config("spark.hadoop.fs.s3a.access.key", "<access.key>")
.config("spark.hadoop.fs.s3a.secret.key", "<secret.key>")
.config("spark.hadoop.spark.hadoop.fs.s3a.endpoint", "http://minio.ip-address:port")
.config("spark.hadoop.fs.s3a.connection.ssl.enabled", "false")
.config("spark.hadoop.fs.s3a.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem")
.config("spark.hadoop.fs.s3a.aws.credentials.provider", "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider")

该示例演示了使用单个MinIO集群的Iceberg配置,由示例可见,配置参数分两个部分:

  • spark.sql.catalog.<catalog_name>.为前缀的Iceberg特有参数; 所有该前缀的参数,将被归属到该catalog名下,即私有化;
  • spark.hadoop.为前缀(非必需),参数名称和值同 Hadoop。所有该前缀的参数,属于共享参数。

该示例有个问题:只能访问HMS3.x 的默认hive catalog下面的元数据,即使通过metastore.catalog.default设置了也不起作用,因为Spark 3中内置的Hive client是HMS 2.x。现在我们访问HMS3.x中非默认catalog,怎么办呢?是不是像上面指定高版本hms version和jars 路径是不是可以了呢?像下面这样:

String anotherCatalogMappingName = "another_catalog";

SparkConf sparkConf = new SparkConf()
        .set("spark.sql.catalog." + anotherCatalogMappingName, "org.apache.iceberg.spark.SparkCatalog")
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".type", "hive")
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".default-namespace", "default")
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".uri", hiveMetastoreURI)
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".warehouse", warehouse)
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".hadoop.fs.s3a.access.key", "<access.key>")
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".hadoop.fs.s3a.secret.key", "<secret.key>")
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".hadoop.fs.s3a.endpoint", "http://minio.ip-address:port")
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".hadoop.metastore.catalog.default", defaultCatalogName)
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".hadoop."+METASTOREURIS.varname, hiveMetastoreURI)
        .set(METASTOREURIS.varname, hiveMetastoreURI)
        .set("metastore.catalog.default", defaultCatalogName)
        .set("spark.sql.hive.metastore.version", "3.1.2")
        .set("spark.sql.hive.metastore.jars", String.format("%s/lib/*", hiveHome))
        .set("spark.default.parallelism", "1")
        .set("parquet.metadata.read.parallelism", "1")// set parallelism to read metadata
        .set("spark.hadoop.fs.s3a.connection.ssl.enabled", "false")
        .set("spark.hadoop.fs.s3a.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem")
        .set("spark.hadoop.fs.s3a.aws.credentials.provider", "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider")
        .set("spark.sql.iceberg.handle-timestamp-without-timezone", "true") // support read data of timestamp-without-timezone
        .set("spark.sql.warehouse.dir", "warehouse")
        .setMaster("local[*]") // spark-submit should remove this
        .setAppName(this.getClass().getSimpleName());

很不幸,spark.sql.hive.metastore.version和spark.sql.hive.metastore.jars对Iceberg无效,因为Iceberg并不像Spark那样根据该参数动态加载jars。看来只好使出绝招了:替换这个内置的低版本的Hive jars了。但,新的问题又来了:高版本的HMS client跟低版本的HMS 服务不兼容,因为高版本的HMS client多了catalog参数,无法读取到低版本HMS服务的元数据。这里,我们使用了新的SparkCatalog实现类,目的是为了注册新的catalog name,因为SparkSessionCatalog要求catalog name 固定为spark_catalog,且不能重复。

假如默认spark_catalog的HMS是低版本的HMS,another_catalog为高版本的HMS,此时执行如下join SQL将始终返回空集:

select * from test_low_hms_db.test_hive_table,another_catalog.high_hms_db.test_hive_Iceberg_table

所以,针对版本不一致的多个HMS如果同时使用,目前是无解的,很多引擎都有这个问题,比如Flink、Trino等。为此,我们给出的解决方案是在HMS之上提供Proxy层,能够把HMS里面的catalog暴露出来,通过标准的thrift uri来访问,同时也屏蔽了不同版本的接口差异问题。

我们继续看一种新的使用场景:假如有两个MinIO集群,都是使用Iceberg表格式,此时如果要联合查询两个集群,根据上面的案例,可以这样使用:

String anotherHiveMetastoreURI = "thrift://another-ip:another-port";

SparkConf sparkConf = new SparkConf()
        .set("spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.SparkSessionCatalog")
        .set("spark.sql.catalog.spark_catalog.type", "hive")
        .set("spark.sql.catalog.spark_catalog.default-namespace", defaultDatabase)
        .set("spark.sql.catalog.spark_catalog.uri", hiveMetastoreURI)
        .set("spark.sql.catalog.spark_catalog.warehouse", warehouse)
        .set("spark.sql.catalog.spark_catalog.hadoop.fs.s3a.access.key", "<access.key>")
        .set("spark.sql.catalog.spark_catalog.hadoop.fs.s3a.secret.key", "<secret.key>")
        .set("spark.sql.catalog.spark_catalog.hadoop.fs.s3a.endpoint", "http://minio-ip-address:port")
        .set("spark.sql.catalog.spark_catalog.hadoop.metastore.catalog.default", defaultCatalogName)
        .set("spark.default.parallelism", "1")
        .set(METASTOREURIS.varname, hiveMetastoreURI)
        .set("metastore.catalog.default", defaultCatalogName)
        .set("spark.sql.catalog." + anotherCatalogMappingName, "org.apache.iceberg.spark.SparkCatalog")
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".type", "hive")
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".default-namespace", "default")
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".uri", anotherHiveMetastoreURI)
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".warehouse", warehouse)
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".hadoop.fs.s3a.access.key", "<another.access.key>")
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".hadoop.fs.s3a.secret.key", "<another.secret.key>")
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".hadoop.fs.s3a.endpoint", "http://another-minio.ip-address:another.port")
        .set("spark.sql.catalog." + anotherCatalogMappingName + ".hadoop.metastore.catalog.default", "another_catalog")

此时多个catalog怎么用呢?

可以通过全路径名称来访问表,比如:

descible table spark_catalog.database_1.table_1;
descible table another_catalog.database_2.table_2;
select * from spark_catalog.database_1.table_1,another_catalog.database_2.table_2;

如果觉得全路径使用麻烦,还可以设置默认当前catalog:

set spark.sql.defaultCatalog=another_catalog;
SHOW CURRENT NAMESPACE;
show databases;

这样,我们可以操作任意跨Catalog的联邦查询呢?仍然不行!因为Iceberg 的配置项 spark.sql.catalog.spark_catalog.uri 能够区分不同的HMS示例,但是从不同HMS示例返回的元数据,如不同HMS示例的Table 存储位置,客户端(Hive Client或者Spark Client)如何区分属于哪个HMS示例返回的呢?在HDFS层面,它根据FileSystem schema信息缓存了当前访问的FileSystem信息,导致两个Minio访问时候总是拿到第一次访问的集群信息。此时简单的办法就是禁用HDFS的缓存,尽管这会带来一些副作用。