通過Z-Order技術加速Hudi大規模資料集分析方案

leesf發表於2021-06-20

1. 背景

多維分析是大資料分析的一個典型場景,這種分析一般帶有過濾條件。對於此類查詢,尤其是在高基欄位的過濾查詢,理論上只我們對原始資料做合理的佈局,結合相關過濾條件,查詢引擎可以過濾掉大量不相關資料,只需讀取很少部分需要的資料。例如我們在入庫之前對相關欄位做排序,這樣生成的每個檔案相關欄位的min-max值是不存在交叉的,查詢引擎下推過濾條件給資料來源結合每個檔案的min-max統計資訊,即可過濾掉大量不相干資料。 上述技術即我們通常所說的data clustering 和 data skip。直接排序可以在單個欄位上產生很好的效果,如果多欄位直接排序那麼效果會大大折扣的,Z-Order可以較好的解決多欄位排序問題。

本文基於Apache Spark 以及 Apache Hudi 結合Z-order技術介紹如何更好的對原始資料做佈局, 減少不必要的I/O,進而提升查詢速度。具體提案可參考Hudi RFC-28:Support Z-order curve

2. Z-Order介紹

Z-Order是一種可以將多維資料壓縮到一維的技術,在時空索引以及影像方面使用較廣。Z曲線可以以一條無限長的一維曲線填充任意維度的空間,對於資料庫的一條資料來說,我們可以將其多個要排序的欄位看作是資料的多個維度,z曲線可以通過一定的規則將多維資料對映到一維資料上,構建z-value 進而可以基於該一維資料進行排序。z-value的對映規則保證了排序後那些在多維維度臨近的資料在一維曲線上仍然可以彼此臨近。

wiki定義:假設存在一個二維座標對(x, y),這些座標對於於一個二維平面上,使用Z排序,我們可以將這些座標對壓縮到一維。

當前在delta lake的商業版本實現了基於Z-Order的data Clustering技術,開源方面Spark/Hive/Presto 均未有對Z-Order的支援。

3. 具體實現

我們接下來分2部分介紹如何在Hudi中使用Z-Order:

  1. z-value的生成和排序
  2. 與Hudi結合

3.1 z-value的生成和排序

這部分是Z-Order策略的核心,這部分邏輯是公用的,同樣適用其他框架。

Z-Order的關鍵在於z-value的對映規則。wiki上給出了基於位交叉的技術,每個維度值的位元位交叉出現在最終的z-value裡。例如假設我們想計算二維座標(x=97, y=214)的z-value,我們可以按如下步驟進行

第一步:將每一維資料用bits表示

x value:01100001
y value:11010110

第二步:從y的最左側bit開始,我們將x和y按位做交叉,即可得到z 值,如下所示

z-value: 1011011000101001

對於多維資料,我們可以採用同樣的方法對每個維度的bit位做按位交叉形成 z-value,一旦我們生成z-values 我們即可用該值做排序,基於z值的排序自然形成z階曲線對多個參與生成z值的維度都有良好的聚合效果。

上述生成z-value的方法看起來非常好,但在實際生產環境上我們要使用位交叉技術產生z-value 還需解決如下問題:

  1. 上述介紹是基於多個unsigned int型別的遞增資料,通過位交叉生成z-value的。實際上的資料型別多種多樣,如何處理其他型別資料
  2. 不同型別的維度值轉成bit位表示,長度不一致如何處理
  3. 如何選擇資料型別合理的儲存z-value,以及相應的z值排序策略

針對上述問題,我們採用兩種策略生成z值。

3.1.1 基於對映策略的z值生成方法

第一個問題:對不同的資料型別採用不同的轉換策略

  • 無符號型別整數: 直接轉換成bits位表示

  • Int型別的資料: 直接轉成二進位制表示會有問題,因為java裡面負數的二進位制表示最高位(符號位)為1,而正整數的二進位制表示最高位為0(如下圖所示), 直接轉換後會出現負數大於正數的現象。

十進位制 二進位制
0 0000 0000
1 0000 0001
2 0000 0010
126 0111 1110
127 0111 1111
-128 1000 0000
-127 1000 0001
-126 1000 0010
-2 1111 1110
-1 1111 1111

對於這個問題,我們可以直接將二進位制的最高位反轉,就可以保證轉換後的詞典順序和原值相同。如下圖

十進位制 二進位制 最高位反轉 最高位反轉後十進位制
0 0000 0000 1000 0000 128
1 0000 0001 1000 0001 129
2 0000 0010 1000 0010 130
126 0111 1110 1111 1110 254
127 0111 1111 1111 1111 255
-128 1000 0000 0000 0000 0
-127 1000 0001 0000 0001 1
-126 1000 0010 0000 0010 2
-2 1111 1110 0111 1110 126
-1 1111 1111 0111 1111 127
  • Long型別的資料:轉換方式和Int型別一樣,轉成二進位制形式並將最高位反轉
  • Double、Float型別的資料: 轉成Long型別,之後轉成二進位制形式並將最高位反轉
  • Decimal/Date/TimeStamp型別資料:轉換成long型別,然後直接用二進位制表示。
  • UTF-8 String型別的資料:String型別的資料 直接用二進位制表示即可保持原來的自然序, 但是字串是不定長的無法直接用來做位交叉。 我們採用如下策略處理string型別大於8bytes的字串截斷成8bytes, 不足8bytes的string 填充成8bytes。
  • null值處理:
    • 數值型別的null直接變成該數值型別的最大值,之後按上述步驟轉換;
    • String型別null 直接變成空字串之後再做轉換;

第二個問題:生成的二進位制值統一按64位對齊即可

第三個問題:可以用Array[Byte]來儲存z值(參考Amazon的DynamoDB 可以限制該陣列的長度位1024)。對於 Array[Byte]型別的資料排序,hbase的rowkey 排序器可以直接拿來解決這個問題

基於對映策略的z值生成方法,方便快捷很容易理解,但是有一定缺陷:

  1. 參與生成z-value的欄位理論上需要是從0開始的正整數,這樣才能生成很好的z曲線。 真實的資料集中 是不可能有這麼完美的情況出現的, zorder的效果將會打折扣。比如x 欄位取值(0, 1, 2), y欄位取值(100, 200, 300), 用x, y生成的z-value只是完整z曲線的一部分,對其做z值排序的效果和直接用x排序的效果是一樣的; 再比如x的基數值遠遠低於y的基數值時採用上述策略排序效果基本和按y值排序是一樣的,真實效果還不如先按x排序再按y排序。

  2. String型別的處理, 上述策略對string型別是取前8個位元組的參與z值計算, 這將導致精度丟失。 當出現字串都是相同字串字首的情況就無法處理了,比如"https://www.baidu.com" , "https://www.google.com" 這兩個字串前8個位元組完全一樣, 對這樣的資料擷取前8個位元組參與z值計算沒有任何意義。

上述策略出現缺陷的主要原因是資料的分佈並不總是那麼好導致。有一種簡單的方案可以解決上述問題: 對參與z值計算的所有維度值做全域性Rank,用Rank值代替其原始值參與到z值計算中,由於Rank值一定是從0開始的正整數,完全符合z值構建條件,較好的解決上述問題。 在實驗中我們發現這種用Rank值的方法確實很有效,但是z值生成效率極低,計算引擎做全域性Rank的代價是非常高的,基於Rank的方法效率瓶頸在於要做全域性Rank計算,那麼我們可不可以對原始資料做取樣減少資料量,用取樣後的資料計算z值呢,答案是肯定的。

/** Generates z-value*/

val newRDD = df.rdd.map { row =>
  val values = zFields.map { case (index, field) =>
    field.dataType match {
      case LongType =>
        ZOrderingUtil.longTo8Byte(row.getLong(index))
      case DoubleType =>
        ZOrderingUtil.doubleTo8Byte(row.getDouble(index))
      case IntegerType =>
        ZOrderingUtil.intTo8Byte(row.getInt(index))
      case FloatType =>
        ZOrderingUtil.doubleTo8Byte(row.getFloat(index).toDouble)
      case StringType =>
        ZOrderingUtil.utf8To8Byte(row.getString(index))
      case DateType =>
        ZOrderingUtil.longTo8Byte(row.getDate(index).getTime)
      case TimestampType =>
        ZOrderingUtil.longTo8Byte(row.getTimestamp(index).getTime)
      case ByteType =>
        ZOrderingUtil.byteTo8Byte(row.getByte(index))
      case ShortType =>
        ZOrderingUtil.intTo8Byte(row.getShort(index).toInt)
      case d: DecimalType =>
        ZOrderingUtil.longTo8Byte(row.getDecimal(index).longValue())
      case _ =>
        null
    }
  }.filter(v => v != null).toArray
  val zValues = ZOrderingUtil.interleaveMulti8Byte(values)
  Row.fromSeq(row.toSeq ++ Seq(zValues))
}.sortBy(x => ZorderingBinarySort(x.getAs[Array[Byte]](fieldNum)))

3.1.2 基於RangeBounds的z-value生成策略

在介紹基於RangeBounds的z-value生成策略之前先看看Spark的排序過程,Spark排序大致分為2步

  1. 對輸入資料的key做sampling來估計key的分佈,按指定的分割槽數切分成range並排序。計算出來的rangeBounds是一個長度為numPartition - 1 的陣列,該陣列裡面每個元素表示一個分割槽內key值的上界/下界。
  2. shuffle write 過程中,每個輸入的key應該分到哪個分割槽內,由第一步計算出來的rangeBounds來確定。每個分割槽內的資料雖然沒有排序,但是注意rangeBounds是有序的因此分割槽之間巨集觀上看是有序的,故只需對每個分割槽內資料做好排序即可保證資料全域性有序。

參考Spark的排序過程,我們可以這樣做

  1. 對每個參與Z-Order的欄位篩選規定個數(類比分割槽數)的Range並對進行排序,並計算出每個欄位的RangeBounds;
  2. 實際對映過程中每個欄位對映為該資料所在rangeBounds的中的下標,然後參與z-value的計算。可以看出由於區間下標是從0開始遞增的正整數,完全滿足z值生成條件;並且String型別的欄位對映問題也被一併解決了。基於RangeBounds的z值生成方法,很好的解決了第一種方法所面臨的缺陷。由於多了一步取樣生成RangeBounds的過程,其效率顯然不如第一種方案,我們實現了上述兩種z值生成方法以供選擇。
/** Generates z-value */

val indexRdd = internalRdd.mapPartitionsInternal { iter =>
  val bounds = boundBroadCast.value
  val origin_Projections = sortingExpressions.map { se =>
    UnsafeProjection.create(Seq(se), outputAttributes)
  }

  iter.map { unsafeRow =>
    val interleaveValues = origin_Projections.zip(origin_lazyGeneratedOrderings).zipWithIndex.map { case ((rowProject, lazyOrdering), index) =>
      val row = rowProject(unsafeRow)
      val decisionBound = new DecisionBound(sampleRdd, lazyOrdering)
      if (row.isNullAt(0)) {
        bounds(index).length + 1
      } else {
        decisionBound.getBound(row, bounds(index).asInstanceOf[Array[InternalRow]])
      }
    }.toArray.map(ZOrderingUtil.toBytes(_))
    val zValues = ZOrderingUtil.interleaveMulti4Byte(interleaveValues)
    val mutablePair = new MutablePair[InternalRow, Array[Byte]]()

    mutablePair.update(unsafeRow, zValues)
  }
}.sortBy(x => ZorderingBinarySort(x._2), numPartitions = fileNum).map(_._1)

3.2 與Hudi結合

與Hudi的結合大致分為兩部分

3.2.1 表資料的Z排序重組

這塊相對比較簡單,藉助Hudi內部的Clustering機制結合上述z值的生成排序策略我們可以直接完成Hudi表資料的資料重組,這裡不再詳細介紹。

3.2.2 收集儲存統計資訊

這塊其實RFC27已經在做了,感覺有點重複工作我們簡單介紹下我們的實現,資料完成z重組後,我們需要對重組後的每個檔案都收集參與z值計算的各個欄位的min/max/nullCount 的統計資訊。對於統計資訊收集,可以通過讀取Parquet檔案或者通過SparkSQL收集

  • 讀取Parquet檔案收集統計資訊
/** collect statistic info*/

val sc = df.sparkSession.sparkContext
val serializableConfiguration = new SerializableConfiguration(conf)
val numParallelism = inputFiles.size/3
val previousJobDescription = sc.getLocalProperty(SparkContext.SPARK_JOB_DESCRIPTION)
try {
  val description = s"Listing parquet column statistics"
  sc.setJobDescription(description)
  sc.parallelize(inputFiles, numParallelism).mapPartitions { paths =>
    val hadoopConf = serializableConfiguration.value
    paths.map(new Path(_)).flatMap { filePath =>
      val blocks = ParquetFileReader.readFooter(hadoopConf, filePath).getBlocks().asScala
      blocks.flatMap(b => b.getColumns().asScala.
        map(col => (col.getPath().toDotString(),
          FileStats(col.getStatistics().minAsString(), col.getStatistics().maxAsString(), col.getStatistics.getNumNulls.toInt))))
        .groupBy(x => x._1).mapValues(v => v.map(vv => vv._2)).
        mapValues(value => FileStats(value.map(_.minVal).min, value.map(_.maxVal).max, value.map(_.num_nulls).max)).toSeq.
        map(x => ColumnFileStats(filePath.getName(), x._1, x._2.minVal, x._2.maxVal, x._2.num_nulls))
    }.filter(p => cols.contains(p.colName))
  }.collect()
} finally {
  sc.setJobDescription(previousJobDescription)
}
  • 通過SparkSQL方式收集統計資訊
/** collect statistic info*/

val inputFiles = df.inputFiles
val conf = df.sparkSession.sparkContext.hadoopConfiguration
val values = cols.flatMap(c => Seq( min(col(c)).as(c + "_minValue"), max(col(c)).as(c + "_maxValue"), count(c).as(c + "_noNullCount")))
val valueCounts = count("*").as("totalNum")
val projectValues = Seq(col("file")) ++ cols.flatMap(c =>
  Seq(col(c + "_minValue"), col(c + "_maxValue"), expr(s"totalNum - ${c + "_noNullCount"}").as(c + "_num_nulls")))


val result = df.select(input_file_name() as "file", col("*"))
  .groupBy($"file")
  .agg(valueCounts,  values: _*).select(projectValues:_*)
result

之後將這些資訊儲存在Hudi表裡面的hoodie目錄下的index目錄下,然後供Spark查詢使用。

3.2.3 應用到Spark查詢

為將統計資訊應用Spark查詢,需修改HudiIndex的檔案過濾邏輯,將DataFilter轉成對Index表的過濾,選出候選要讀取的檔案,返回給查詢引擎,具體步驟如下。

  1. 將索引表載入到 IndexDataFrame
  2. 使用原始查詢過濾器為 IndexDataFrame 構建資料過濾器
  3. 查詢 IndexDataFrame 選擇候選檔案
  4. 使用這些候選檔案來重建 HudiMemoryIndex

通過min/max值和null計數資訊為 IndexDataFrame 構建資料過濾器,由於z排序後參與z值計算的各個欄位在每個檔案裡面的min/max值很大概率不交叉,因此對Index表的過濾可以過濾掉大量的檔案。

/** convert filter */

def createZindexFilter(condition: Expression): Expression = {

  val minValue = (colName: Seq[String]) =>
    col(UnresolvedAttribute(colName) + "_minValue").expr
  val maxValue = (colName: Seq[String]) =>
    col(UnresolvedAttribute(colName) + "_maxValue").expr
  val num_nulls = (colName: Seq[String]) =>
    col(UnresolvedAttribute(colName) + "_num_nulls").expr

  condition match {
    case EqualTo(attribute: AttributeReference, value: Literal) =>
      val colName = HudiMergeIntoUtils.getTargetColNameParts(attribute)
      And(LessThanOrEqual(minValue(colName), value), GreaterThanOrEqual(maxValue(colName), value))
    case EqualTo(value: Literal, attribute: AttributeReference) =>
      val colName = HudiMergeIntoUtils.getTargetColNameParts(attribute)
      And(LessThanOrEqual(minValue(colName), value), GreaterThanOrEqual(maxValue(colName), value))

    case equalNullSafe @ EqualNullSafe(_: AttributeReference, _ @ Literal(null, _)) =>
      val colName = HudiMergeIntoUtils.getTargetColNameParts(equalNullSafe.left)
      EqualTo(num_nulls(colName), equalNullSafe.right)
.......

4. 測試結果

我們採用databrick的測試樣例https://help.aliyun.com/document_detail/168137.html?spm=a2c4g.11186623.6.563.53c258ccmqvYfy 進行了測試

測試資料量和資源使用大小和databrick保持一致。唯一區別是我們只生成了10000個檔案,原文是100w個檔案。 測試結果表明zorder加速比還說很可觀的,另外Z-Order的效果隨著檔案數的增加會越來越好,我們後續也會在100w檔案級別測試。

表名稱 時間(s)
conn_random_parquet 89.3
conn_zorder 19.4
conn_zorder_only_ip 18.2

相關文章