Apache Hudi重磅特性解讀之存量表高效遷移機制

leesf發表於2020-07-13

1. 摘要

隨著Apache Hudi變得越來越流行,一個挑戰就是使用者如何將存量的歷史表遷移到Apache Hudi,Apache Hudi維護了記錄級別的後設資料以便提供upserts增量拉取的核心能力。為利用Hudi的upsert增量拉取能力,使用者需要重寫整個資料集讓其成為Hudi表。此RFC提供一個無需重寫整張表的高效遷移機制。

2. 背景

為了更好的瞭解此RFC,讀者需要了解一些Hudi基礎知識

2.1 記錄級別後設資料

上圖展示了Hudi中每條記錄的組織結構,每條記錄有5個Hudi後設資料欄位:

  • _hoodie_commit_time : 最新記錄提交時間
  • _hoodie_commit_seqno : 在增量拉取中用於在單次攝取中建立多個視窗。
  • _hoodie_record_key : Hudi記錄主鍵,用來處理更新和刪除
  • _hoodie_partition_path : 分割槽路徑
  • _hoodie_file_name : 儲存記錄的檔名

2.2. 當前引導(Bootstrap)方案

Hudi提供了內建HDFSParquetImporter工具來完成一次性遷移整個資料集到Hudi中,當然也可以通過Spark Datasource API來進行一次簡單的讀取和寫入。

一旦遷移完成,那麼就可以按照普通方式寫入Hudi資料集,具體可參考這裡。更多詳細討論可參考這裡,其中包括部分遷移方案。總而言之現在大致有兩種遷移方案。

2.2.1 遷移新分割槽至Hudi

Apache Hudi分割槽可以和其他非Hudi分割槽共存,這種情況下會在Apache Hudi查詢引擎側做處理以便處理這種混合分割槽,這可以讓使用者使用Hudi來管理新的分割槽,同時保持老的分割槽不變。在上述示例中,歷史分割槽從Jan 1 2020到Nov 30 2019為非Hudi格式,從Dec 01 2019開始的新分割槽為Hudi格式。由於歷史分割槽不被Hudi管理,因此這些分割槽也無法使用Hudi提供的能力,但這在append-only型別資料集場景下非常完美。

2.2.2 將資料集重寫至Hudi

如果使用者需要使用Apache Hudi來管理資料集的所有分割槽,那麼需要重新整個資料集至Hudi,因為Hudi為每條記錄維護後設資料資訊和索引資訊,所以此過程是必須的。要麼一次性重新整個資料集,要麼將資料集切分為多個分割槽,然後載入。更詳細的討論可參考這裡

2.3 重寫資料集至Hudi

即便是一次性操作,但對於大規模資料遷移而言也是非常有挑戰的。大規模事實表通常有大量的列,巢狀列也是比較常見情況,重寫整個資料集會導致非常高的IO和佔用太多計算資源。

提供一個高效遷移歷史存量表機制對使用者使用Apache Hudi非常關鍵,此RFC就提供了這樣一種機制。

3. 方案

下圖展示了每條記錄的組織結構,為了方便理解,我們使用行格式進行展示,雖然實際使用的列存,另外假設下圖中使用了BloomIndex。

正如上圖所示,Apache Hudi檔案主要包含了三部分。

  1. 對於每條記錄,Hudi維護了5個後設資料欄位,索引從0 ~ 4。

  2. 對於每條記錄,原始資料列代表了記錄(原始資料)。

  3. 另外檔案Footer存放索引資訊。

原始資料表通常包含很多列,而(1)和(3)讓Hudi的parquet檔案變得比較特別。

為了方便討論,我們將(1)和(3)稱為Hudi骨架,Hudi骨架包含了額外的後設資料資訊以支援Hudi原語。

一個想法是解耦Hudi骨架和實際資料(2),Hudi骨架可以儲存在Hudi檔案中,而實際資料儲存在外部非Hudi檔案中(即保持之前的parquet檔案不動)。

只要Hudi能夠理解新的檔案格式,那麼引導一個存量表就只需要生成Hudi骨架檔案即可。對生產環境中表進行了粗略測試,該表包含3500個分割槽,25W個檔案,超過600億條資料。新的引導過程使用500個executor,每個executor為1核和4G記憶體,總耗時1個小時。老的引導過程使用超過4倍的executor(2000個),總耗時差不多24小時。

4. 新引導過程

新的引導過程包含如下步驟。首先假設parquet資料集(名為fact_events)需要遷移至Hudi資料集,資料集根路徑為/user/hive/warehouse/fact_events,並且是基於日期的分割槽,在每個分割槽內有很多parquet檔案,如下圖所示。

假設使用者使用新的引導機制引導至新的Hudi資料集名為fact_events_hudi,路徑為/user/hive/warehouse/fact_events_hudi

  1. 使用者在原始資料集上停止所有寫操作。

  2. 使用者使用DeltaStreamer或者獨立工具開始啟動引導,使用者需要提供如下引導引數

    • 原始(非Hudi)資料集位置。
    • 生成Hudi鍵的列。
    • 遷移的併發度。
    • 新的Hudi資料集位置。
  3. 引導時Hudi會掃描原始表位置(/user/hive/warehouse/fact_events)的分割槽和檔案,進行如下操作 :

    • 在新資料集位置建立Hudi分割槽,在上述示例中,將會在/user/hive/warehouse/fact_events_hudi路徑建立日期分割槽。
    • 生成唯一的檔案ID並以此為每個原始parquet檔案生成Hudi骨架檔案,同時會使用一個特殊的commit,稱為BOOTSTRAP_COMMIT。下面我們假設BOOTSTRAP_COMMIT對應的timestamp為000000000,例如一個原始parquet檔案為/user/hive/warehouse/fact_events/year=2015/month=12/day=31/file1.parquet,假設新生成的檔案ID為h1,所以相應的骨架檔案為/user/hive/warehouse/fact_events_hudi/year=2015/month=12/day=31/h1_1-0-1_000000000.parquet.
    • 生成一個特殊的bootstrap索引,該索引將生成的骨架檔案對映到對應的原始parquet檔案。
    • 使用Hudi timeline狀態變更進行原子性提交,也支援回滾操作。
  4. 如果開啟了Hive同步,那麼會建立一張Hudi型別的Hive外表,並指向/user/hive/warehouse/fact_events_hudi路徑。

  5. 隨後的寫操作將作用在Hudi資料集上。

4.1 引導(Bootstrap)索引

索引用於對映Hudi骨架檔案和原始包含資料的parquet檔案。該資訊會作為Hudi file-system view並用於生成FileSlice,Bootstrap索引和CompactionPlan類似,但與CompactionPlan不同的是引導索引可能更大,因此需要一種高效讀寫的檔案格式。

Hudi的file-system view是物理檔名到FileGroup和FileSlice的抽象,可以支援到分割槽級別的API,因此Bootstrap索引一定需要提供快速查詢單個分割槽索引資訊的能力。

一個合適的儲存結構為Hadoop Map檔案,包含兩種型別檔案:

  • 引導日誌:順序檔案,每一個條目包含單個分割槽內索引資訊,對於分割槽下引導索引的變更只需要在日誌檔案中順序新增新的條目即可。
  • 索引引導日誌:包含Hudi分割槽、檔名和offset的順序檔案,offset表示引導索引日誌條目對應Hudi分割槽的位置。

基於上述結構,遷移過程中使用Spark併發度可以控制遷移時的日誌檔案數量,並相應提升生成引導索引的速度。Hudi的Reader和Writer都需要載入分割槽的引導索引,索引引導日誌中每個分割槽對應一個條目,並可被讀取至記憶體或RocksDB中。

Hudi Cleaner會移除舊的不再需要的FileSlice,由於Hudi骨架是FileSlice的一部分,因此也適用於Clean。無論何時FileSlice被清理,即便清理對正確性不是必須的,引導索引都需要進行相應的更新,這會保證狀態的一致性並減少引導索引的大小。為支援ACID,Hudi Timeline也支援類似的MVCC機制,以便保證引導索引的最新狀態,同時隔離更新和併發讀取。

4.2 Upsert支援及讀取場景

本節將介紹Hudi為支援這種新的檔案儲存和在引導的分割槽上支援Hudi原語的抽象。

一個FileSlice代表一個Hudi檔案的所有快照,其包含一個基礎檔案和一個或多個delta增量檔案。我們將引導索引資訊封裝在FlieSlice級別,所以一個FileSlice可以提供外部原始資料位置資訊。

在Hudi中我們實現了file-system view的抽象,即將物理檔案對映為FileSlice。此抽象也會讓FileSlice包含抽象,引導索引項(骨架檔案到外部檔案對映),以便上層引擎可以以一致的方式處理外部原始資料檔案。

基於這個模型,如果我們對fact_events_hudi表的分割槽更新了1 - K條記錄,將會有如下步驟。

  • 假設upsert操作對應的時間為C1C1大於BOOTSTRAP_COMMIT (000000000)。
  • 假設使用BloomIndex,將會直接在Hudi骨架檔案查詢索引,假設Hudi骨架檔案h1有所有的記錄。
  • 在下面的描述中,常規Hudi檔案表示一個Hudi Parquet檔案,幷包含記錄級別的後設資料欄位資訊,同時包含索引,即包含前面所述的(1),(2),(3)。對於Copy-On-Write型別表,在引導寫入階段中生成了最新的FileSlice,對應的檔案ID為h1,會讀取位於/user/hive/warehouse/fact_events路徑的外部原始檔案,Hudi MergeHandle將會並行讀取外部檔案和Hudi後設資料檔案,然後合併記錄成為一個新的常規Hudi檔案,並生成對應檔案ID為h1的新版本。

  • 對於Merge-On-Read型別表,攝入僅僅寫入增量日誌檔案,然後進行Compaction,類似Copy-On-Write模式下生成一個新的常規Hudi檔案

為整合查詢引擎,Hudi自定義實現了InputFormat,這些InputFormat將會識別特殊的索引提交併會合並Hudi的後設資料欄位和外部Parquet表中的實際資料欄位,提供常規Hudi檔案。注意只會從Parquet檔案中讀取投影欄位。下圖展示了查詢引擎是如何工作的。

4.3 要求

對於任何Hudi資料集,都需要提供RecordKey的唯一鍵約束,因此,查詢列時需要考慮到原始資料的唯一性,否則不能保證對與重複key對應的記錄進行正確的upsert。

5. Data Source支援

此部分說明如何整合Hudi引導表和Spark DataSource,Copy-On-Write表可以按照如下步驟使用Hudi資料來源讀取。

val df = spark.read.format("hudi").load("s3://<bucket>/table1/")
val df = spark.read.format("hudi").load("s3://<bucket>/table1/partition1/")

注意:這裡也可以傳遞路徑模式以保持相容性,但必須自定義對模式的處理。

5.1 COW快照查詢

這裡的想法是實現一個新的Spark RelationshipSpark RDD用來掃描和讀取引導表。自定義Relation將實現PruneFilteredScan允許支援過濾器下推和列剪裁。對於RDD,每個分割槽將是資料檔案+可選的骨架檔案組合,這些組合將被髮送到一個任務,以執行合併並返回結果。

下面的程式碼框架將提供實現的高層次概要,API簽名可能會隨著我們實現而改變。

package org.apache.hudi.skeleton 
2.   
3.  import org.apache.spark.rdd.RDD 
4.  import org.apache.spark.sql.{Row, SQLContext} 
5.  import org.apache.spark.sql.sources.{BaseRelation, Filter, PrunedFilteredScan} 
6.  import org.apache.spark.sql.types.StructType 
7.   
8.  case class HudiBootstrapTableState(files: List[HudiBootstrapSplit]) 
9.   
10. case class HudiBootstrapSplit(dataFile: String, 
11.                               skeletonFile: String) 
12.  
13. class HudiBootstrapRelation(val sqlContext: SQLContext, 
14.                             val basePath: String, 
15.                             val optParams: Map[String, String], 
16.                             val userSchema: StructType) 
17.                             extends BaseRelation with PrunedFilteredScan { 
18.  
19.   override def schema: StructType = ??? 
20.  
21.   override def buildScan(requiredColumns: Array[String], 
22.                          filters: Array[Filter]): RDD[Row] = { 
23.     // Perform the following steps here: 
24.     // 1. Perform file system listing to form HudiBootstrapTableState which would 
25.     //    maintain a mapping of Hudi skeleton files to External data files 
26.     // 
27.     // 2. Form the HudiBootstrapRDD and return it 
28.  
29.     val tableState = HudiBootstrapTableState(List()) 
30.     new HudiBootstrapRDD(tableState, sqlContext.sparkSession).map(_.asInstanceOf[Row]) 
31.   } 
32. }
 
 
1.  package org.apache.hudi.skeleton 
2.   
3.  import org.apache.spark.{Partition, TaskContext} 
4.  import org.apache.spark.rdd.RDD 
5.  import org.apache.spark.sql.SparkSession 
6.  import org.apache.spark.sql.catalyst.InternalRow 
7.   
8.  class HudiBootstrapRDD(table: HudiBootstrapTableState,                        
9.                         spark: SparkSession)  
10.                        extends RDD[InternalRow](spark.sparkContext, Nil) { 
11.  
12.   override def compute(split: Partition, context: TaskContext): Iterator[InternalRow] = { 
13.     // This is the code that gets executed at each spark task. We will perform 
14.     // the following tasks here: 
15.     // - From the HudiBootstrapPartition, obtain the data and skeleton file paths 
16.     // - If the skeleton file exists (bootstrapped partition), perform the merge 
17.     //   and return a merged iterator 
18.     // - If the skeleton file does not exist (non-bootstrapped partition), read 
19.     //   only the data file and return an iterator 
20.     // - For reading parquet files, build reader using ParquetFileFormat which 
21.     //   returns an Iterator[InternalRow].
22.     // - Merge the readers for skeleton and data files and return a single
23.     //   Iterator[InternalRow]
24.     // - Investigate and implement passing of filters and required schema down 
25.     //   for pruning and filtering optimizations that ParquetFileFormat provides. 
26.   } 
27.  
28.   override protected def getPartitions: Array[Partition] = { 
29.     // Form the partitions i.e. HudiBootstrapPartition from HudiBootstrapTableState. 
30.     // Each spark task would handle one partition. Here we can do one of the 
31.     // following mappings: 
32.     // - Map one HudiBootstrapSplit to one partition, so that each task would 
33.     //   perform merging of just one split i.e. data file and skeleton 
34.     // - Map multiple HudiBootstrapSplit to one partition, so that each task 
35.     //   would perform merging of multiple splits i.e. multiple data/skeleton files 
36.      
37.     table.files.zipWithIndex.map(file =>  
38.       HudiBootstrapPartition(file._1, file._2)).toArray 
39.   } 
40. } 
41.  
42. case class HudiBootstrapPartition(split: HudiBootstrapSplit, 
43.                                   index: Int) extends Partition

優勢

  • 不需要對Spark程式碼做任何修改。
  • 提供一種控制檔案列表邏輯的方法,以列出骨架檔案,然後將它們對映到相應的外部資料檔案。
  • 提供對每個分割槽內容和計算邏輯的控制。
  • 相同的設計也可應用於Merge-On-Read表。

缺點

  • 不支援檔案切片,這可能會影響讀取效能。每個任務只處理一個骨架+資料檔案的合併。但目前還沒有一種方法來切分骨架+資料檔案,以便能夠以完全相同的行偏移量切分它們,然後在以後合併它們。即使使用InputFormat列合併邏輯,我們也必須禁用檔案切片,並且每個切片都將對映到一個檔案。因此,從某種意義上說,我們會遵循類似的方法。

5.2 COW增量查詢

對於增量查詢,我們必須使用類似的邏輯來重新設計當前在Hudi程式碼中實現的IncrementalRelation。我們可能使用相同快照查詢的RDD實現。

6. 總結

此功能對資料庫備份場景非常有用,無需重寫整張原始Parquet表,利用更少的資源就可以完成原始Parquet表到Hudi表的轉化,此功能將在0.6.0版本(下個版本)釋出,敬請期待。

相關文章