一種Hudi on Flink動態同步元數據變化的方法

一、背景

一個需求,需要同步MySQL數據到Hive,包括DDL與DML,所以需要動態同步元數據變化。

二、官方Schema Evolution例子

從Hudi官方文檔Schema Evolution(https://hudi.apache.org/docs/next/schema_evolution)可知通過Hudi可實現源端添加列、int到long列類型轉換等DDL操作同步到目標端,且該文檔提供了一個Spark+Hudi寫數據的例子,先定義一個Schema,寫入了3條數據;然後定義newSchema,比schema多一個newField字段,且intToLong字段類型由Integer變爲了Long,又upsert了3條數據到同一個Hudi表中。最終查詢出該Hudi表的結構與newSchema一致,即使用新的schema寫數據,實現了元數據的更新。

三、Flink + Hudi實現Schema Evolution

由於多種原因(省略一萬字)選擇了Flink+Hudi,且爲了實現一些邏輯更自由,選擇了DataStream API而不是Flink SQL,在羣裏從@玉兆大佬處瞭解到Hudi中使用DataStream API操作的類org.apache.hudi.streamer.HoodieFlinkStreamer。通過兩次啓動任務傳入的修改後的Avro Schema,也能實現官方文檔Schema Evolution中例子類似的功能。

但是按這種方式,只能通過重新定義schema並重啓Flink任務,才能將源表新增的列同步到目標Hive表中,無法在啓動任務後自動同步schema中未定義的源表中新增的列。所以需要對HoodieFlinkStreamer的功能進行改進。先說方案,經過對HoodieFlinkStreamer分析,其中用到的一些主要Function(Source、Map、Sink等)都是在最初定義時傳入參數配置類,要麼在構造方法中、要麼在open方法中,根據配置(包含schema)生成deserialer、converter、writeClient等對數據進行反序列化、轉換、保存的實例,由於是根據最初schema生成的實例,即使數據中有新增的字段,轉換後新增的字段也沒有保留。所以採用的方式是,在各個Function處理數據的方法中,判斷如果數據中的schema與當前Function中的schema不一致(一種簡單的優化),就使用數據中的schema重新生成這些deserialer、converter、writeClient,這樣數據經過處理後,就有新增的字段。方法很簡單,主要是需要了解一下HoodieFlinkStreamer的處理流程。

四、HoodieFlinkStreamer流程淺析及擴展方法

調試環境:可先在Idea中建個Flink Demo/QuickStart項目,導入Hudi的hudi-flink-bundle模塊、及對應的Hadoop、Hive相關依賴。先在Idea中操作方便調試、以及分析依賴衝突等問題。當前使用分支release-0.10.0,Hive版本2.1.1-cdh6.3.0,hadoop版本3.0.0-cdh6.3.0。

擴展可將流程中關鍵的類(及必須依賴的類)從Hudi源碼拷貝出來(或集成,視各類的依賴關係而定),修改相應邏輯,再使用修改後的類作爲處理函數

經過億些分析及調試後,梳理出HoodieFlinkStreamer的大致流程如下圖。
HoodieFlinkStreamer流程

 

4.1 FlinkKafkaConsumer

  • Function功能說明

數據來源,從Kafka中讀取數據,HoodieFlinkStreamer類中,指定的反序列化類爲org.apache.flink.formats.json.JsonRowDataDeserializationSchema,將Json數據轉換爲org.apache.flink.table.data.RowData

  • 處理邏輯擴展

對source函數擴展,主要就是修改反序列化類,數據可選擇Debezium發送到Kafka中的帶Schema格式的Json數據,反序列化類使用org.apache.flink.formats.json.debezium.DebeziumJsonDeserializationSchema並修改部分邏輯,在DebeziumJsonDeserializationSchemadeserialize(byte[] message, Collector<RowData> out)方法中,先通過message獲取到數據中的schema,再參考構造方法重新生成成員變量this.jsonDeserializer、this.metadataConverters。

  • 輸出擴展

DebeziumJsonDeserializationSchema本身輸出的是實現了RowData接口的org.apache.flink.table.data.GenericRowData(邏輯在emitRow方法中),改爲通過複製或繼承GenericRowData定義的SchemaWithRowData,添加一個字符串成員變量保存當前數據的schema,以便下游的函數能根據schema重新生成數據處理實例。

4.2 RowDataToHoodieFunction

  • Function功能說明

將DataStream轉換爲後面HudiAPI操作需要的DataStream。

  • 處理邏輯擴展

O map(I i)方法中,首先根據上游發來的SchemaWithRowData中的schema,參考open方法重新生成this.converter等成員變量。

RowDataToHoodieFunction類中有一個org.apache.flink.configuration.Configuration類型的成員變量config,保存了任務配置的參數,後面流程中函數基本都有這個成員變量,且很多函數也從該配置中讀取schema信息,所以在更新schema時,可以首先設置this.config.setString(FlinkOptions.SOURCE_AVRO_SCHEMA, schema);(任務啓動通過--source-avro-schema傳入參數,所以schema存在config的FlinkOptions.SOURCE_AVRO_SCHEMA這個key中)。後續不再贅述。

  • 輸出擴展

與前面類似,爲了讓下游函數獲取到schema,在toHoodieRecord方法中,修改返回值爲繼承了HoodieRecord類的帶有schema信息的自定義類SchemaWithHoodieRecord

4.3 StreamWriteFunction

(其實前面還有個BucketAssignerFunction,看起來沒有直接修改或轉換當前從流中接收到的數據的各字段值,只是設置了location。也添加了更新schema邏輯,重新生成了bucketAssigner成員變量。)

  • Function功能說明

將流中的數據寫入HDFS。數據緩存在this.buckets中,由bufferRecord方法的註釋可知,緩存的記錄數大於FlinkOptions.WRITE_BATCH_SIZE配置的值、或緩衝區大小大於FlinkOptions.WRITE_TASK_MAX_SIZE時,調用flushBucket將緩存的數據寫入文件。

在每次checkpoint時,snapshotState方法也會調用flushRemaining方法將緩存的記錄寫入文件。

  • 處理邏輯擴展

仍然在processElement方法中,首先通過接收到的SchemaWithHoodieRecord中的schema信息,更新this.writeClient,先關閉再重新生成。

  • 輸出擴展

Hudi官方代碼中,只在processElement中調用了bufferRecord(所以圖中畫的虛線)。爲了讓下游的compact和clean函數接收到新的schema,可直接轉發:out.collect(value);(可優化,比如只傳schema)

改到當前Function爲止,數據已經能寫入文件(MOR表的log文件、COW表的parquet文件),但是在Hive中查詢不出來

結合StreamWriteFunction類的註釋,及一些日誌,及一些調試分析。瞭解到數據寫入文件後,會通知StreamWriteOperatorCoordinator保存hudi表相關參數,如提交instant更新Timeline相關記錄,相關元數據等,應該就是操作hudi表目錄下的.hoodie目錄。StreamWriteFunctionflushBucketflushRemaining方法最後調用this.eventGateway.sendEventToCoordinator(event);org.apache.hudi.sink.event.WriteMetadataEvent發到StreamWriteOperatorCoordinatorStreamWriteOperatorCoordinatororg.apache.hudi.sink.common.WriteOperatorFactorygetCoordinatorProvider方法中實例化,也是傳入的初始配置,爲了能保存最新的元數據,所以也要將schema發過去,在StreamWriteOperatorCoordinator中主要使用WriteMetadataEventwriteStatuses成員變量,所以將schema存在writeStatuses中。

4.4 StreamWriteOperatorCoordinator

  • Function功能說明

主要邏輯在notifyCheckpointComplete方法中,即每次checkpoint完成後執行,總體分爲2部分,commitInstant和hive同步。

commitInstant方法中,從eventBuffer中讀取WriteMetadataEventwriteStatus,若前面的步驟中真的有數據處理,這裏獲取到的writeResults不爲空,則調用doCommit方法提交相關信息。

如果commitInstant真的提交了數據,返回true,則會調用syncHiveIfEnabled方法執行hive同步操作。最終其實調用到HiveSyncToolsyncHoodieTable方法,從這個方法可以看到Hive同步支持的一些功能,自動建數據庫、自動建數據表、自動建分區;元數據同步功能通過將hudi表最新的提交中的元數據從Hive metastore查出的表的元數據對比,如果不同則將元數據變化同步到Hive metastore中。

  • 處理邏輯擴展

上面提到,commitInstant方法中,如果writeResults不爲空,則會調用doCommit方法,所以在調用doCommit之前添加更新schema的邏輯,從自定義的SchemaWithWriteStatus中讀取schema,參考start方法的邏輯重新生成writeClient。

StreamWriteOperatorCoordinator修改後,在之前數據已經寫入文件的基礎上,新增的字段、修改的類型已經能同步到hudi表(指.hoodie目錄)及hive metastore中,在Hive中COW表也能正常查詢。但是對於MOR表,新增的字段只是寫到了增量日誌文件中,讀優化表(_ro)查不到新增字段的數據,所以還要修改Compaction處理類。

4.5 Compaction及Clean類

如上面流程圖,compaction分三步:生成壓縮計劃、執行壓縮、提交壓縮執行結果。都是類似的操作,先是CompactionPlanOperator接收到DataStream<SchemaWithHoodieRecord>,更新元數據,後續的CompactionPlanEventCompactionCommiEvent也可帶上schema,更新各自的table、writeClient等成員。CleanFunction也類似。

到目前爲止,MOR表的讀優化表在Hive也能查詢到新增列的數據,歷史parquet文件中沒有新增字段,查詢結果中新增字段爲null。但是實時表(_rt)查詢還有點問題。如果查詢rt表涉及歷史的parquet文件(沒有新增字段,至於爲什麼肯定是Parquet文件,後面會說到通過調試發現,如果以後發現有其他情況再補充),則會報類似這樣的錯誤:

"Field new_col2 not found in log schema. Query cannot proceed! Derived Schema Fields: ..."

五、MOR rt表查詢bug解決

5.1 分析

在Hudi源碼中搜索該報錯信息,找到兩個位置,實時表對應的位置是org.apache.hudi.hadoop.utils.HoodieRealtimeRecordReaderUtils#generateProjectionSchema

  public static Schema generateProjectionSchema(Schema writeSchema, Map<String, Schema.Field> schemaFieldsMap,
                                                List<String> fieldNames) {
    /**
     * ......
     */
    List<Schema.Field> projectedFields = new ArrayList<>();
    for (String fn : fieldNames) {
      Schema.Field field = schemaFieldsMap.get(fn.toLowerCase());
      if (field == null) {
        throw new HoodieException("Field " + fn + " not found in log schema. Query cannot proceed! "
            + "Derived Schema Fields: " + new ArrayList<>(schemaFieldsMap.keySet()));
      } else {
        projectedFields.add(new Schema.Field(field.name(), field.schema(), field.doc(), field.defaultVal()));
      }
    }

    Schema projectedSchema = Schema.createRecord(writeSchema.getName(), writeSchema.getDoc(),
        writeSchema.getNamespace(), writeSchema.isError());
    projectedSchema.setFields(projectedFields);
    return projectedSchema;
  }

遍歷了fieldNames,如果schemaFieldsMap中找不到這個字段則報錯,所以fieldNames包含新增的字段,schemaFieldsMap爲歷史parquet文件中讀取出來的字段信息,

該方法在org.apache.hudi.hadoop.realtime.AbstractRealtimeRecordReader#init 中被調用。源碼中也寫了一個TODO還沒有DO:

    // TODO(vc): In the future, the reader schema should be updated based on log files & be able
    // to null out fields not present before

即未來基於日誌文件更新reader schema,並且會支持將新增的字段置爲空值。

AbstractRealtimeRecordReader#init方法中看到,HoodieRealtimeRecordReaderUtils#generateProjectionSchema的fieldNames參數從jobConf中讀取,包含新增的字段。通過調試HiveServer2,發現jobConf的properties中以下幾個key的值包含字段信息(new_col2爲新增字段):

hive.io.file.readcolumn.names -> _hoodie_commit_time,_hoodie_commit_seqno,_hoodie_record_key,_hoodie_partition_path,_hoodie_file_name,id,first_name,last_name,alias,new_col,new_col2

schema.evolution.columns -> _hoodie_commit_time,_hoodie_commit_seqno,_hoodie_record_key,_hoodie_partition_path,_hoodie_file_name,id,first_name,last_name,alias,new_col,new_col2

serialization.ddl -> struct customers1_rt { string _hoodie_commit_time, string _hoodie_commit_seqno, string _hoodie_record_key, string _hoodie_partition_path, string _hoodie_file_name, i32 id, string first_name, string last_name, string alias, double new_col, double new_col2}

schema.evolution.columns.types -> string,string,string,string,string,int,string,string,string,double,double

fieldNames參數就使用到了hive.io.file.readcolumn.names的值。schema.evolution.columns中包含了新增字段,且與之對應的schema.evolution.columns.types中包含了字段的類型(看起來是Hive中的類型)。所以嘗試將HoodieRealtimeRecordReaderUtils#generateProjectionSchema中拋異常的位置改爲在projectedFields中仍然添加一個字段,默認值爲null,字段schema通過schema.evolution.columns.types中的類型轉換而來。

經過測試,即使不解決這個bug,更新一下歷史的數據也可以,但是實際情況中肯定不會用這種方法

5.2 修改

AbstractRealtimeRecordReader#init方法中調用HoodieRealtimeRecordReaderUtils#generateProjectionSchema的位置改成:

readerSchema = HoodieRealtimeRecordReaderUtils.generateProjectionSchema(writerSchema, schemaFieldsMap, projectionFields,
          jobConf.get("schema.evolution.columns"), jobConf.get("schema.evolution.columns.types"));

HoodieRealtimeRecordReaderUtils#generateProjectionSchema改爲:

public static Schema generateProjectionSchema(Schema writeSchema, Map<String, Schema.Field> schemaFieldsMap,
                                                List<String> fieldNames, String csColumns, String csColumnTypes) {
    /**
     * ...
     */
    List<Schema.Field> projectedFields = new ArrayList<>();
    Map<String, Schema.Field> fieldMap = getFieldMap(csColumns, csColumnTypes);
    for (String fn : fieldNames) {
      Schema.Field field = schemaFieldsMap.get(fn.toLowerCase());
      if (field == null) {
//        throw new HoodieException("Field " + fn + " not found in log schema. Query cannot proceed! "
//            + "Derived Schema Fields: " + new ArrayList<>(schemaFieldsMap.keySet()));
        projectedFields.add(fieldMap.get(fn));
      } else {
        projectedFields.add(new Schema.Field(field.name(), field.schema(), field.doc(), field.defaultVal()));
      }
    }

    Schema projectedSchema = Schema.createRecord(writeSchema.getName(), writeSchema.getDoc(),
        writeSchema.getNamespace(), writeSchema.isError());
    projectedSchema.setFields(projectedFields);
    return projectedSchema;
  }

其中getFieldMap方法爲:

  private static Map<String, Schema.Field> getFieldMap(String csColumns, String csColumnTypes) {
    LOG.info(String.format("columns:%s\ntypes:%s", csColumns, csColumnTypes));
    Map<String, Schema.Field> result = new HashMap<>();
    String[] columns = csColumns.split(",");
    String[] types = csColumnTypes.split(",");
    for (int i = 0; i < columns.length; i++) {
      String columnName = columns[i];
      result.put(columnName, new Schema.Field(columnName,toSchema(types[i]), null, null));
    }
    return result;
  }

  private static Schema toSchema(String hiveSqlType) {
    switch (hiveSqlType.toLowerCase()) {
      case "boolean":
        return Schema.create(Schema.Type.BOOLEAN);
      case "byte":
      case "short":
      case "integer":
        return Schema.create(Schema.Type.INT);
      case "long":
        return Schema.create(Schema.Type.LONG);
      case "float":
        return Schema.create(Schema.Type.FLOAT);
      case "double":
      case "decimal":
        return Schema.create(Schema.Type.DOUBLE);
      case "binary":
        return Schema.create(Schema.Type.BYTES);
      case "string":
      case "char":
      case "varchar":
      case "date":
      case "timestamp":
      default:
        return Schema.create(Schema.Type.STRING);

    }
  }

修改後,重新將依賴包部署到Hive中,rt表也能正常查詢。從parquet文件中讀取的數據,新增的字段則顯示的空值

六、總結

通過這種方法,實現了元數據動態同步到Hive。

https://www.pudn.com/news/6228cfe39ddf223e1ad14f23.html

https://hudi.apache.org/docs/next/schema_evolution/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章