在AWS Glue中使用Apache Hudi

leesf發表於2021-05-09

1. Glue與Hudi簡介

  • AWS Glue

AWS Glue是Amazon Web Services(AWS)雲平臺推出的一款無伺服器(Serverless)的大資料分析服務。對於不瞭解該產品的讀者來說,可以用一句話概括其實質:Glue是一個無伺服器的全託管的Spark執行環境,只需提供Spark程式程式碼即可執行Spark作業,無需維護叢集。

  • Apach Hudi

Apache Hudi最早由Uber設計開發,後提交給Apache孵化器,2020年5月,Hudi正式升級為Apache的頂級專案。Hudi是一個資料湖平臺,支援增量資料處理,其提供的更新插入增量查詢兩大操作原語很好地彌補了傳統大資料處理引擎(如Spark、Hive等)在這方面的缺失,因而受到廣泛關注並開始流行。此外,Hudi在設計理念上非常注意與現有大資料生態的融合,它能以相對透明和非侵入的方式融入到Spark、Flink計算框架中,並且支援了流式讀寫,有望成為未來資料湖的統一儲存層(同時支援批流讀寫)。

2. 整合的可行性分析

鑑於Hudi的日益流行,很多正在使用Glue或者為搭建無伺服器資料湖進行技術選型的團隊對Glue與Hudi的整合非常關心,如果兩者可以成功地整合在一起,團隊就可以建設出支援增量資料處理的無伺服器架構的新一代資料湖。

但是,AWS Glue的產品團隊從未就支援Hudi給出過官方保證,雖然從“Glue核心是Spark”這一事實進行推斷,理論上Glue是可以與Hudi整合的,但由於Glue沒有使用Hive的Metastore,而是依賴自己的後設資料儲存服務Glue Catalog,這會讓Glue在同步Hudi後設資料時遇到不小的麻煩。

本文將在程式碼驗證的基礎之上,詳細介紹如何在Glue裡使用Hudi,對整合過程中發現的各種問題和錯誤給出解釋和應對方案。我們希望通過本文的介紹,給讀者在資料湖建設的技術選型上提供新的靈感和方向。無論如何,一個支援增量資料處理的無伺服器架構的資料湖是非常吸引人的!

注:本文討論和編寫的程式程式碼基於的都是Glue 2.0(基於Spark 2.4.3)和Hudi 0.8.0,兩者均為當前(2021年4月)各自的最新版本。

3. 在Glue作業中使用Hudi

現在,我們來演示如何在Glue中建立並執行一個基於Hudi的作業。我們假定讀者具有一定的Glue使用經驗,因此不對Glue的基本操作進行解釋。

3.1. 資源列表

在開始之前,我們把本文使用的各類資源彙總如下,便於讀者統一下載。

3.1.1. 示例程式

為配合本文的講解,我們專門編寫了一個示例程式並存放在Github上,詳情如下:

專案名稱 Repository地址
glue-hudi-integration-example https://github.com/bluishglc/glue-hudi-integration-example

3.1.2. 依賴JAR包

執行程式需要使用到Hudi和Spark的兩個Jar包,由於包檔案較大,無法存放在Github的Repository裡,建議大家從Maven的中心庫下載,以下是連結資訊:

Jar包 下載連結
hudi-spark-bundle_2.11-0.8.0.jar https://search.maven.org/remotecontent?filepath=org/apache/hudi/hudi-spark-bundle_2.11/0.8.0/hudi-spark-bundle_2.11-0.8.0.jar
spark-avro_2.11-2.4.3.jar https://search.maven.org/remotecontent?filepath=org/apache/spark/spark-avro_2.11/2.4.3/spark-avro_2.11-2.4.3.jar

3.2. 建立基於Hudi的Glue作業

根據Hudi官方給出的整合原生Spark的方式(連結:https://hudi.apache.org/docs/quick-start-guide.html#setup-spark-shell):

spark-shell \
  --packages org.apache.hudi:hudi-spark-bundle_2.11:0.8.0,org.apache.spark:spark-avro_2.11:2.4.3 \
  --conf 'spark.serializer=org.apache.spark.serializer.KryoSerializer'

可知,將Hudi載入到Spark執行環境中需要完成兩個關鍵動作:

  1. 在Spark執行環境引入Hudi的Jar包: hudi-spark-bundle_2.11-0.8.0.jarspark-avro_2.11-2.4.3.jar
  2. 在Spark中配置Hudi需要的Kyro序列化器:spark.serializer=org.apache.spark.serializer.KryoSerializer

由此,不難推理出Glue整合Hudi的方法,即以Glue的方式實現上述兩個操作。下面我們進入實操環節。

3.2.1. 建立桶並上傳程式和依賴包

首先,在S3上建立一個供本示例使用的桶,取名glue-hudi-integration-example。要注意的是:為避免桶名衝突,你應該定義並使用自己的桶,並在後續操作中將所有出現glue-hudi-integration-example的配置替換為自己的桶名。然後,從Github檢出專門為本文編寫的Glue讀寫Hudi的示例程式(地址參考3.1.1節),將專案中的GlueHudiReadWriteExample.scala檔案上傳到新建的桶裡。同時,下載hudi-spark-bundle_2.11-0.8.0.jarspark-avro_2.11-2.4.3.jar兩個Jar包(地址參考3.1.2節),並同樣上傳到新建的桶裡。操作完成後,S3上的glue-hudi-integration-example桶應該包含內容:

3.2.2. 新增作業

接下來,進入Glue控制檯,新增一個作業,在“新增作業”嚮導中進行如下配置:

  • 在“配置作業屬性”環節,向“名稱”輸入框中填入作業名稱:glue-hudi-integration-example
  • 在“IAM角色”下拉選單中選擇一個IAM角色,要注意的是這個角色必須要有讀寫glue-hudi-integration-example桶和訪問Glue服務的許可權,如果沒有現成的合適角色,需要去IAM控制檯建立一個,本處不再贅述;
  • “Glue version”這一項選“Spark 2.4, Scala 2 with improved job startup times (Glue Version 2.0)”;
  • “此作業執行”處選“您提供的現成指令碼”;
  • “Scala類名”和“儲存指令碼所在的S3路徑”兩別填入com.github.GlueHudiReadWriteExamples3://glue-hudi-integration-example/GlueHudiReadWriteExample.scala

如下圖所示:

然後向下滾動進入到“安全配置、指令碼庫和作業引數(可選)”環節,在“從屬JAR路徑”的輸入框中將前面上傳到桶裡的兩個依賴Jar包的S3路徑(記住,中間要使用逗號分隔):

s3://glue-hudi-integration-example/hudi-spark-bundle_2.11-0.8.0.jar,s3://glue-hudi-integration-example/spark-avro_2.11-2.4.3.jar

貼上進去。如下圖所示:

這裡是前文提及的整合Hudi的兩個關鍵性操作中的第一個:將Hudi的Jar包引入到Glue的類路徑中。這與在spark-shell命令列中配置package引數效果是等價的:

--packages org.apache.hudi:hudi-spark-bundle_2.11:0.8.0,org.apache.spark:spark-avro_2.11:2.4.3

再接下來,在“作業引數”環節,新增一個作業引數:

鍵名 取值
--bucketName glue-hudi-integration-example

如下圖所示:

我們需要把S3桶的名稱以“作業引數”的形式傳給示例程式,以便其可以拼接出Hudi資料集的完整路徑,這個值會在讀寫Hudi資料集時使用,因為Hudi資料集會被寫到這個桶裡。

最後,在“目錄選項”中勾選Use Glue data catalog as the Hive metastore,啟用Glue Catalog:

全部操作完成後,點選“下一步”,再點選“儲存並編輯指令碼”就會進入到指令碼編輯頁面,頁面將會展示上傳的GlueHudiReadWriteExample.scala這個類的原始碼。

3.3. 在Glue作業中讀寫Hudi資料集

接下來,我們從程式設計角度看一下如何在Glue中使用Hudi,具體就是以GlueHudiReadWriteExample.scala這個類的實現為主軸,介紹幾個重要的技術細節。

首先,需要我們得先了解一下GlueHudiReadWriteExample.scala這個類的主線邏輯,即main方法中的操作:

def main(sysArgs: Array[String]): Unit = {

  init(sysArgs)

  val sparkImplicits = spark.implicits
  import sparkImplicits._

  // Step 1: build a dataframe with 2 user records, then write as
  // hudi format, but won't create table in glue catalog
  val users1 = Seq(
    User(1, "Tom", 24, System.currentTimeMillis()),
    User(2, "Bill", 32, System.currentTimeMillis())
  )
  val dataframe1 = users1.toDF
  saveUserAsHudiWithoutHiveTableSync(dataframe1)

  // Step 2: read just saved hudi dataset, and print each records
  val dataframe2 = readUserFromHudi()
  val users2 = dataframe2.as[User].collect().toSeq
  println("printing user records in dataframe2...")
  users2.foreach(println(_))

  // Step 3: append 2 new user records, one is updating Bill's age from 32 to 33,
  // the other is a new user whose name is 'Rose'. This time, we will enable
  // hudi hive syncing function, and a table named `user` will be created on
  // default database, this action is done by hudi automatically based on
  // the metadata of hudi user dataset.
  val users3 = users2 ++ Seq(
    User(2, "Bill", 33, System.currentTimeMillis()),
    User(3, "Rose", 45, System.currentTimeMillis())
  )
  val dataframe3 = users3.toDF
  saveUserAsHudiWithHiveTableSync(dataframe3)

  // Step 4: since a table is created automatically, now, we can query user table
  // immediately, and print returned user records, printed messages should show:
  // Bill's is updated, Rose's record is inserted, this demoed UPSERT feature of hudi!
  val dataframe4 = spark.sql("select * from user")
  val users4 = dataframe4.as[User].collect().toSeq
  println("printing user records in dataframe4...")
  users4.foreach(println(_))

  commit()
}

作為一份示例性質的程式碼,main方法的邏輯是“為了演示”而設計的,一共分成了四步操作:

  • 第一步,構建一個包含兩條User資料的Dataframe,取名dataframe1,然後將其以Hudi格式儲存到S3上,但並不會同步後設資料(也就是不會自動建表);
  • 第二步,以Hudi格式讀取剛剛儲存的資料集,得到本例的第二個Dataframe:dataframe2,此時它應該包含前面建立的兩條User資料;
  • 第三步,在dataframe2的基礎上再追加兩條User資料,一條是針對現有資料Bill使用者的更新資料,另一條Rose使用者的是新增資料,進而得到第三個dataframe3,然後將其再次以Hudi格式寫回去,但是與上次不同的是,這一次程式將使用Hudi的後設資料同步功能,將User資料集的後設資料同步到Glue Catalog,一張名為user的表將會被自動建立出來;
  • 第四步,為了驗證後設資料是否同步成功,以及更新和插入的資料是否正確地處理,這次改用SQL查詢user表,得到第四個Dataframe:dataframe4,其不但應該包含資料,且更新和插入資料都必須是正確的。

以下是main方法的具體實現:

def main(sysArgs: Array[String]): Unit = {

  init(sysArgs)

  val sparkImplicits = spark.implicits
  import sparkImplicits._

  // Step 1: build a dataframe with 2 user records, then write as
  // hudi format, but won't create table in glue catalog
  val users1 = Seq(
    User(1, "Tom", 24, System.currentTimeMillis()),
    User(2, "Bill", 32, System.currentTimeMillis())
  )
  val dataframe1 = users1.toDF
  saveUserAsHudiWithoutHiveTableSync(dataframe1)

  // Step 2: read just saved hudi dataset, and print each records
  val dataframe2 = readUserFromHudi()
  val users2 = dataframe2.as[User].collect().toSeq
  println("printing user records in dataframe2...")
  users2.foreach(println(_))

  // Step 3: append 2 new user records, one is updating Bill's age from 32 to 33,
  // the other is a new user whose name is 'Rose'. This time, we will enable
  // hudi hive syncing function, and a table named `user` will be created on
  // default database, this action is done by hudi automatically based on
  // the metadata of hudi user dataset.
  val users3 = users2 ++ Seq(
    User(2, "Bill", 33, System.currentTimeMillis()),
    User(3, "Rose", 45, System.currentTimeMillis())
  )
  val dataframe3 = userS3.toDF
  saveUserAsHudiWithHiveTableSync(dataframe3)

  // Step 4: since a table is created automatically, now, we can query user table
  // immediately, and print returned user records, printed messages should show:
  // Bill's is updated, Rose's record is inserted, this demoed UPSERT feature of hudi!
  val dataframe4 = spark.sql("select * from user")
  val users4 = dataframe4.as[User].collect().toSeq
  println("printing user records in dataframe4...")
  users4.foreach(println(_))

  commit()
}

main在開始時呼叫了一個init函式,改函式會完成一些必要初始化工作,如:解析並獲取作業引數,建立GlueContextSparkSession例項等。其中有一處程式碼需要特別說明,即類檔案的第90-92行,也就是下面程式碼中的第10-12行:

/**
 * 1. Parse job params
 * 2. Create SparkSession instance with given configs
 * 3. Init glue job
 *
 * @param sysArgs all params passing from main method
 */
def init(sysArgs: Array[String]): Unit = {
  ...
  val conf = new SparkConf()
  // This is required for hudi
  conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
  ...
}

該處程式碼正是前文提及的整合Hudi的第二個關鍵性操作:在Spark中配置Hudi需要的Kyro序列化器:spark.serializer=org.apache.spark.serializer.KryoSerializer。如果沒有配置該項,程式將會報出如下錯誤:

org.apache.hudi.exception.HoodieException : hoodie only support org.apache.spark.serializer.KryoSerializer as spark.serializer

下面,我們要把關注重點放在Glue是如何讀寫Hudi資料集的,也就是readUserFromHudisaveUserAsHudiWithoutHiveTableSync兩個方法的實現。首先看一下較為簡單的讀取操作:

/**
 * Read user records from Hudi, and return a dataframe.
 *
 * @return The dataframe of user records
 */
def readUserFromHudi(): DataFrame = {
  spark
    .read
    .format("hudi")
    .option(DataSourceReadOptions.QUERY_TYPE_OPT_KEY, DataSourceReadOptions.QUERY_TYPE_SNAPSHOT_OPT_VAL)
    .load(userTablePath)
}

因為程式碼中設定了

option(DataSourceReadOptions.QUERY_TYPE_OPT_KEY, DataSourceReadOptions.QUERY_TYPE_SNAPSHOT_OPT_VAL)

所以該方法使用的是Hudi最簡單也是最常用的一種讀取方式:快照讀取,即:讀取當前資料集最新狀態的快照。關於讀取Hudi資料集的更多內容,請參考Hudi官方文件:https://hudi.apache.org/docs/querying_data.html 。接下來是寫操作:

/**
 * Save a user dataframe as hudi dataset, but WON'T SYNC its metadata to glue catalog,
 * In other words, no table will be created after saving.
 *
 * @param dataframe The dataframe to be saved
 */
def saveUserAsHudiWithoutHiveTableSync(dataframe: DataFrame) = {

  val hudiOptions = Map[String, String](
    HoodieWriteConfig.TABLE_NAME -> userTableName,
    DataSourceWriteOptions.OPERATION_OPT_KEY -> DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL,
    DataSourceWriteOptions.TABLE_TYPE_OPT_KEY -> DataSourceWriteOptions.COW_TABLE_TYPE_OPT_VAL,
    DataSourceWriteOptions.RECORDKEY_FIELD_OPT_KEY -> userRecordKeyField,
    DataSourceWriteOptions.PRECOMBINE_FIELD_OPT_KEY -> userPrecombineField,
    DataSourceWriteOptions.KEYGENERATOR_CLASS_OPT_KEY -> classOf[NonpartitionedKeyGenerator].getName
  )

  dataframe
    .write
    .format("hudi")
    .options(hudiOptions)
    .mode(SaveMode.Append)
    .save(userTablePath)
}

寫操作中大部分的程式碼都是在對Hudi進行一些必要的配置,這些配置包括:

  • 指定表名;
  • 指定寫操作的型別:是UPSERT,INSERT還是DELETE等;
  • 指定Hudi在比對新舊資料時要使用的兩個關鍵欄位的名稱:RECORDKEY_FIELD_OPT_KEYPRECOMBINE_FIELD_OPT_KEY
  • 指定為記錄生成key的策略(一個Class)

這些都是Hudi的基本配置,本文不再一一解釋,請讀者參考Hudi的官方文件:https://hudi.apache.org/docs/writing_data.html

3.4. 將Hudi後設資料同步到Glue Catalog

上述讀寫操作並沒有同步後設資料,在實際應用中,大多數情況下,開發者會開啟Hudi的Hive Sync功能,讓Hudi將其後設資料對映到Hive Metastore中,自動建立Hive表,這是一個很有用的操作。不過,對於Glue來說,這個問題就比較棘手了,基於筆者的使用經歷,早期遇到的大部分問題都出在了同步後設資料上,究其原因,主要是因為Glue使用了自己的後設資料服務Glue Catalog,而Hudi的後設資料同步是面向Hive Metastore的。那這是否意味著Hudi就不能把後設資料同步到Glue上呢?幸運的是,在經過各種嘗試和摸索之後,我們還是順利地完成了這項工作,這為Hudi在Glue上的應用鋪平了道路。

在介紹具體操作之前,我們先了解一下Hudi同步後設資料到Hive的基本操作。根據官方文件: https://hudi.apache.org/docs/configurations.html#hive-sync-options給出的說明,標準的Hudi Hive Sync配置應該是這樣的:

首先是最基本的三項:

DataSourceWriteOptions.HIVE_SYNC_ENABLED_OPT_KEY -> "true"
DataSourceWriteOptions.HIVE_DATABASE_OPT_KEY -> "your-target-database"
DataSourceWriteOptions.HIVE_TABLE_OPT_KEY -> "your-target-table"

這三項很容易理解,就是告訴Hudi要開啟Hive Sync,同時指定同步到Hive的什麼庫裡的什麼表。如果你要同步的是一張分割槽表,還需要追加以下幾項:

DataSourceWriteOptions.KEYGENERATOR_CLASS_OPT_KEY -> classOf[ComplexKeyGenerator].getName
DataSourceWriteOptions.HIVE_PARTITION_EXTRACTOR_CLASS_OPT_KEY -> classOf[MultiPartKeysValueExtractor].getName
DataSourceWriteOptions.HIVE_STYLE_PARTITIONING_OPT_KEY -> "true"
DataSourceWriteOptions.PARTITIONPATH_FIELD_OPT_KEY -> "your-partition-path-field"
DataSourceWriteOptions.HIVE_PARTITION_FIELDS_OPT_KEY -> "your-hive-partition-field"

這些配置項主要在告訴Hudi資料集的分割槽資訊,以便Hudi能正確地將分割槽相關的後設資料也同步到Hive Metastore中。現在,我們看一下在Glue中要怎樣實現後設資料同步,也就是示例程式碼中的saveUserAsHudiWithHiveTableSync方法:

/**
 * Save a user dataframe as hudi dataset, but also SYNC its metadata to glue catalog,
 * In other words, after saving, a table named `default.user` will be created automatically by hudi hive sync
 * tool on Glue Catalog!
 *
 * @param dataframe The dataframe to be saved
 */
def saveUserAsHudiWithHiveTableSync(dataframe: DataFrame) = {

  val hudiOptions = Map[String, String](
    HoodieWriteConfig.TABLE_NAME -> userTableName,
    DataSourceWriteOptions.OPERATION_OPT_KEY -> DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL,
    DataSourceWriteOptions.TABLE_TYPE_OPT_KEY -> DataSourceWriteOptions.COW_TABLE_TYPE_OPT_VAL,
    DataSourceWriteOptions.RECORDKEY_FIELD_OPT_KEY -> userRecordKeyField,
    DataSourceWriteOptions.PRECOMBINE_FIELD_OPT_KEY -> userPrecombineField,
    DataSourceWriteOptions.KEYGENERATOR_CLASS_OPT_KEY -> classOf[NonpartitionedKeyGenerator].getName,
    DataSourceWriteOptions.HIVE_PARTITION_EXTRACTOR_CLASS_OPT_KEY -> classOf[NonPartitionedExtractor].getName,
    // Register hudi dataset as hive table (sync meta data)
    DataSourceWriteOptions.HIVE_SYNC_ENABLED_OPT_KEY -> "true",
    DataSourceWriteOptions.HIVE_USE_JDBC_OPT_KEY -> "false", // For glue, it is required to disable sync via hive jdbc!
    DataSourceWriteOptions.HIVE_DATABASE_OPT_KEY -> "default",
    DataSourceWriteOptions.HIVE_TABLE_OPT_KEY -> userTableName
  )

  dataframe
    .write
    .format("hudi")
    .options(hudiOptions)
    .mode(SaveMode.Append)
    .save(userTablePath)
}

該方法的實現在saveUserAsHudiWithoutHiveTableSync的基礎之上,追加了四個與同步後設資料相關的配置項,基中三項是前面提到的必填項,唯獨:

DataSourceWriteOptions.HIVE_USE_JDBC_OPT_KEY -> "false"

是前面沒有提到的,而這一項配置是在Glue下同步後設資料至關重要的。如果不進行此項配置,我們一定會遇到這樣一個錯誤:

Cannot create hive connection jdbc:hive2://localhost:10000/

這是因為:Hudi的Hive Sync預設是通過JDBC連線HiveServer2執行建表操作的,而jdbc:hive2://localhost:10000/是Hudi配置的預設Hive JDBC連線字串(這個字串當然是可修改的,對應配置項為hive_sync.jdbc_url)。由於在Glue裡沒有Hive Metastore和HiverServer2,所以報錯是必然的。

那為什麼在禁用JDBC方式連線Hive Metastore之後,就可以同步了呢?通過檢視Hudi的原始碼可知,當HIVE_USE_JDBC_OPT_KEY被置為false時,Hudi會轉而使用一個專職的IMetaStoreClient去與對應的Metastore進行互動。在Hudi同步後設資料的主要實現類org.apache.hudi.hive.HoodieHiveClient中,維護著一個私有成員變數private IMetaStoreClient client,Hudi就是使用這個Client去和Metastore互動的,在HoodieHiveClient中有多處程式碼都是先判斷是否開啟了JDBC,如果是true,則通過JDBC做互動,如果是false,就使用Client,例如org.apache.hudi.hive.HoodieHiveClient#getTableSchema方法就是依此邏輯實現的:

public class HoodieHiveClient extends AbstractSyncHoodieClient {
    ...
    private IMetaStoreClient client;
    ...
    public Map<String, String> getTableSchema(String tableName) {
      if (syncConfig.useJdbc) {
         ...
      } else {
         return getTableSchemaUsingMetastoreClient(tableName);
      }
    }
    ...
}

而在Glue這一側,由於其使用了自己的Metastore:Glue Catalog,為了和上層Hive相關的基礎設施進行相容,Glue提供了一個自己的IMetaStoreClient實現用於與Glue Catalog互動,這個實現就是com.amazonaws.glue.catalog.metastore.AWSCatalogMetastoreClient(參考:https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/master/aws-glue-datacatalog-hive2-client/src/main/java/com/amazonaws/glue/catalog/metastore/AWSCatalogMetastoreClient.java):

public class AWSCatalogMetastoreClient implements IMetaStoreClient {
  ...
}

該類實現了IMetaStoreClient介面。所以只要使用的是AWSCatalogMetastoreClient這個客戶端,就能用Hive Metastore的互動方式和Glue Catalog進行互動(這得感謝Hive設計了IMetaStoreClient這個介面,而不是給出一個實現類)。在Spark中,有spark.hadoop.hive.metastore.client.factory.class這樣一項配置,顧名思義,這一配置就是告訴Spark使用哪一個工廠類來生產Hive Metastore的Client了,所以你應該大概率猜到了,在Glue裡,這個配置應該是被修改了,配置的應該是某個Glue自己實現的工廠類,用於專門生產AWSCatalogMetastoreClient。是的,的確如此,在Glue裡這一項是這樣配置的:

spark.hadoop.hive.metastore.client.factory.class=com.amazonaws.glue.catalog.metastore.AWSGlueDataCatalogHiveClientFactory

從Github AwsLab釋出的Glue Catalog的部分原始碼中,可以找到這個類的實現(地址:https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore/blob/master/aws-glue-datacatalog-spark-client/src/main/java/com/amazonaws/glue/catalog/metastore/AWSGlueDataCatalogHiveClientFactory.java):

public class AWSGlueDataCatalogHiveClientFactory implements HiveMetaStoreClientFactory {

  @Override
  public IMetaStoreClient createMetaStoreClient(
      HiveConf conf,
      HiveMetaHookLoader hookLoader
  ) throws MetaException {
    AWSCatalogMetastoreClient client = new AWSCatalogMetastoreClient(conf, hookLoader);
    return client;
  }

}

和我們的猜測完全一致。所以,梳理下來整件事情是這樣的:當禁用Hive JDBC之後,Hudi會轉而使用一個客戶端(即某個IMetaStoreClient介面的實現類)與Metastore進行互動,而在Glue環境裡,Glue提供了一個遵循IMetaStoreClient介面規範,但卻是與Glue Catalog 進行互動的客戶端類AWSCatalogMetastoreClient。這樣,Hudi就能通過這個客戶端與Glue Catalog進行透明互動了!

最後,讓我們來執行一下這個作業,看一看輸出的日誌和同步出的資料表。回到Glue控制檯,在前面停留的“指令碼編輯”頁面上,點選“執行作業”按鈕,即可執行作業了。在作業執行結束後,可以在“日誌”Tab頁看到程式列印的資訊,如下圖所示:

其中dataframe4的資料很好地體現了Hudi的UPSERT能力,程式按照我們期望的邏輯執行出了結果:Bill的年齡從32更新為了33,新增的Rose使用者也出現在了結果集中。於此同時,在Glue控制檯的Catalog頁面上,也會看到同步出來的user表:

以及列資訊:

它的輸入/輸出格式以及5個_hoodie開頭的列名清楚地表明這是一張基於Hudi後設資料對映出來的表。

5. 常見錯誤

5.1. hoodie only support KryoSerializer as spark.serializer

該問題在3.2節已經提及,是由於沒有配置spark.serializer=org.apache.spark.serializer.KryoSerializer所致,請參考前文。

5.2. Cannot create hive connection jdbc:hive2://localhost:10000/

該問題在3.3節已經提及,須在Hudi中禁用Hive JDBC,請參考前文。

5.3. Got runtime exception when hive syncing ...

這是一個非常棘手的問題,筆者曾在這個問題上耽誤了不少時間,並研究了Hudi同步後設資料的大部分程式碼,坦率地說,目前它的觸發機制還不是非常確定,主要原因是在Glue這種無伺服器環境下不方便進行遠端DEBUG,只能通過日誌進行分析。一個大概率的懷疑方向是:在整個SparkSession的上下文中,由於某一次Hudi的讀寫操作沒能正確地關閉並釋放IMetaStoreClient例項,導致後面需要再使用該Client同步後設資料時,其已經不可用。不過,相比尚不確定的起因,其解決方案是非常清晰和確定的,即:在出錯的位置前追加一行程式碼:

Hive.closeCurrent()

這一操作非常有效,它主動銷燬了繫結在當前執行緒上的org.apache.hadoop.hive.ql.metadata.Hive例項,該類的例項是存放在一個ThreadLocal變數裡的,而它本身又會包含一個IMetaStoreClient例項,所以Hive例項中的Metastore客戶端也是一個執行緒只維護一個例項。而上述程式碼顯式地關閉並釋放了當前的Client(即主動關閉並釋放已經無法再使用的Client例項),這會促使Hudi在下一次同步後設資料時重建新的Client例項。

關於這一問題更深入的分析和研究,可參考筆者的另一篇文章《AWS Glue整合Apache Hudi同步後設資料深度歷險(各類錯誤的填坑方案)》

5.4. Failed to check if database exists ...

該問題與上一個問題是一樣的,只是處在異常堆疊的不同位置上,解決辦法同上。

6. 結語

雖然本文篇幅較長,但是從GlueHudiReadWriteExample.scala這個類的實現上不難看出,只要一次性做好幾處關鍵配置,在Glue中使用Hudi其實與在Spark原生環境中使用Hudi幾乎是無異的,這意味著兩者可以平滑地整合並各自持續升級。如此一來,Glue + Hudi的技術選型將非常具有競爭力,前者是一個無伺服器架構的Spark計算環境,主打零運維和極致的成本控制,後者則為新一代資料湖提供更新插入、增量查詢和併發控制等功能性支援,兩者的成功結合是一件令人激動的事情,我想再次引用文章開始時使用的一句話作為結尾:無論如何,一個支援增量資料處理的無伺服器架構的資料湖是非常吸引人的!


關於作者:耿立超,架構師,15年IT系統開發和架構經驗,對大資料、企業級應用架構、SaaS、分散式儲存和領域驅動設計有豐富的實踐經驗,熱衷函數語言程式設計。對Hadoop/Spark 生態系統有深入和廣泛的瞭解,參與過Hadoop商業發行版的開發,曾帶領團隊建設過數個完備的企業資料平臺,個人技術部落格:https://laurence.blog.csdn.net/ 作者著有《大資料平臺架構與原型實現:資料中臺建設實戰》一書,該書已在京東和噹噹上線。

相關文章