一、背景
一個需求,需要同步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的大致流程如下圖。
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
並修改部分邏輯,在DebeziumJsonDeserializationSchema
的deserialize(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目錄。StreamWriteFunction
在flushBucket
和flushRemaining
方法最後調用this.eventGateway.sendEventToCoordinator(event);
將org.apache.hudi.sink.event.WriteMetadataEvent
發到StreamWriteOperatorCoordinator
。StreamWriteOperatorCoordinator
在org.apache.hudi.sink.common.WriteOperatorFactory
的getCoordinatorProvider
方法中實例化,也是傳入的初始配置,爲了能保存最新的元數據,所以也要將schema發過去,在StreamWriteOperatorCoordinator
中主要使用WriteMetadataEvent
的writeStatuses
成員變量,所以將schema存在writeStatuses
中。
4.4 StreamWriteOperatorCoordinator
- Function功能說明
主要邏輯在notifyCheckpointComplete
方法中,即每次checkpoint完成後執行,總體分爲2部分,commitInstant和hive同步。
commitInstant方法中,從eventBuffer
中讀取WriteMetadataEvent
的writeStatus
,若前面的步驟中真的有數據處理,這裏獲取到的writeResults
不爲空,則調用doCommit
方法提交相關信息。
如果commitInstant真的提交了數據,返回true,則會調用syncHiveIfEnabled
方法執行hive同步操作。最終其實調用到HiveSyncTool
的syncHoodieTable
方法,從這個方法可以看到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>
,更新元數據,後續的CompactionPlanEvent
和CompactionCommiEvent
也可帶上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/