Z-Order加速Hudi大规模数据集方案分析


Posted in Servers onMarch 31, 2022

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:

  • z-value的生成和排序
  • 与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 还需解决如下问题:

  • 上述介绍是基于多个unsigned int类型的递增数据,通过位交叉生成z-value的。实际上的数据类型多种多样,如何处理其他类型数据
  • 不同类型的维度值转成bit位表示,长度不一致如何处理
  • 如何选择数据类型合理的保存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值生成方法,方便快捷很容易理解,但是有一定缺陷:

参与生成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排序。

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步

  • 对输入数据的key做sampling来估计key的分布,按指定的分区数切分成range并排序。计算出来的rangeBounds是一个长度为numPartition - 1 的数组,该数组里面每个元素表示一个分区内key值的上界/下界。
  • shuffle write 过程中,每个输入的key应该分到哪个分区内,由第一步计算出来的rangeBounds来确定。每个分区内的数据虽然没有排序,但是注意rangeBounds是有序的因此分区之间宏观上看是有序的,故只需对每个分区内数据做好排序即可保证数据全局有序。

参考Spark的排序过程,我们可以这样做

  • 对每个参与Z-Order的字段筛选规定个数(类比分区数)的Range并对进行排序,并计算出每个字段的RangeBounds;
  • 实际映射过程中每个字段映射为该数据所在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表的过滤,选出候选要读取的文件,返回给查询引擎,具体步骤如下。

  • 将索引表加载到 IndexDataFrame
  • 使用原始查询过滤器为 IndexDataFrame 构建数据过滤器
  • 查询 IndexDataFrame 选择候选文件
  • 使用这些候选文件来重建 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

以上就是Z-Order加速Hudi大规模数据集方案分析的详细内容,更多关于Z-Order加速Hudi大规模数据集的资料请关注三水点靠木其它相关文章!

Servers 相关文章推荐
Nginx反向代理多个服务器的实现方法
Mar 31 Servers
Nginx 502 Bad Gateway错误原因及解决方案
Mar 31 Servers
Nginx的反向代理实例详解
Mar 31 Servers
Nginx域名转发使用场景代码实例
Mar 31 Servers
小程序后台PHP版本部署运行 LNMP+WNMP
Apr 01 Servers
nginx配置虚拟主机的详细步骤
Jul 21 Servers
Nginx性能优化之Gzip压缩设置详解(最大程度提高页面打开速度)
Feb 12 Servers
nginx负载功能+nfs服务器功能解析
Feb 28 Servers
Nginx速查手册及常见问题
Apr 07 Servers
阿里云ECS云服务器快照的概念以及如何使用
Apr 21 Servers
Linux中一对多配置日志服务器的详细步骤
Jul 23 Servers
Nginx跨域问题解析与解决
Aug 05 Servers
深入解析Apache Hudi内核文件标记机制
Apache Hudi数据布局黑科技降低一半查询时间
Apache Hudi集成Spark SQL操作hide表
Nginx工作模式及代理配置的使用细节
nginx常用配置conf的示例代码详解
Mar 21 #Servers
Nginx设置HTTPS的方法步骤 443证书配置方法
nginx共享内存的机制详解
You might like
不用iconv库的gb2312与utf-8的互换函数
2006/10/09 PHP
php学习笔记 数组遍历实现代码
2011/06/09 PHP
php提示Call-time pass-by-reference has been deprecated in的解决方法[已测]
2012/05/06 PHP
浅谈PHP的exec()函数无返回值排查方法(必看)
2017/03/31 PHP
IE iframe的onload方法分析小结
2010/01/07 Javascript
在标题栏显示新消息提示,很多公司项目中用到这个方法
2011/11/04 Javascript
利用javascript打开模态对话框(示例代码)
2014/01/11 Javascript
完美兼容各大浏览器的jQuery仿新浪图文淡入淡出间歇滚动特效
2014/11/12 Javascript
javascript框架设计读书笔记之字符串的扩展和修复
2014/12/02 Javascript
jQuery选择器源码解读(五):tokenize的解析过程
2015/03/31 Javascript
基于jQuery+PHP+Mysql实现在线拍照和在线浏览照片
2015/09/06 Javascript
JavaScript对象数组排序实例方法浅析
2016/06/15 Javascript
vue.js入门教程之基础语法小结
2016/09/01 Javascript
浅谈js之字面量、对象字面量的访问、关键字in的用法
2016/11/20 Javascript
有关suggest快速删除后仍然出现下拉列表的bug问题
2016/12/02 Javascript
详解用webpack2.0构建vue2.0超详细精简版
2017/04/05 Javascript
vue使用stompjs实现mqtt消息推送通知
2017/06/22 Javascript
jQuery实现文字超过1行、2行或规定的行数时自动加省略号的方法
2018/03/28 jQuery
seajs下require书写约定实例分析
2018/05/16 Javascript
JS基于封装函数实现的表格分页完整示例
2018/06/26 Javascript
JavaScript函数apply()和call()用法与异同分析
2018/08/10 Javascript
vue解决弹出蒙层滑动穿透问题的方法
2018/09/22 Javascript
Vue 使用beforeEach实现登录状态检查功能
2019/10/31 Javascript
[01:52]2014DOTA2西雅图邀请赛 V社开大会你不知道的小秘密
2014/07/08 DOTA
Python 初始化多维数组代码
2008/09/06 Python
Python 数据结构之队列的实现
2017/01/22 Python
Python sorted函数详解(高级篇)
2018/09/18 Python
Django 内置权限扩展案例详解
2019/03/04 Python
使用python list 查找所有匹配元素的位置实例
2019/06/11 Python
python2.7的flask框架之引用js&css等静态文件的实现方法
2019/08/22 Python
python安装gdal的两种方法
2019/10/29 Python
python3实现在二叉树中找出和为某一值的所有路径(推荐)
2019/12/26 Python
tensorflow之读取jpg图像长和宽实例
2020/06/18 Python
行政文员岗位职责
2013/11/08 职场文书
买房子个人收入证明
2014/10/12 职场文书
夫妻分居协议书范本(有子女版)
2014/11/01 职场文书