作者:吴文池
背景
hudi在数据聚集方面,支持使用zorder对数据进行重排。

做zorder排序主要流程分为三步:

-对于用户指定的每个zorder字段,生成对应的z值。
-把所有zorder字段生成的z值进行比特位的交叉组值,生成最终的z值。
-根据最终z值,对所有数据进行排序。

在上面第一步中,每个字段生成自己的z值方式主要有两种:

直接的值映射方式。该方式实现简单,容易理解,但也有缺陷:
-参与生成z值的字段理论上需要是从0开始的正整数,这样才能生成很好的z曲线,但真实的数据集中基本不可能出现,那么zorder的效果将会打折扣。
对于一些前缀相同的数据,例如url字段大多数都以:http://www.开头,那么取前面几位做排序将毫无意义。
-先对数据采样,根据采样数据对所有数据进行分区,最后使用该数据所对应的分区号作为该数据的z值。这种方式可以很好的解决方式一的两个问题:
分区一定是从0开始的整数。
对于前缀相同的数据,也能够根据字符串大小,将其很好的分配到不同分区中,得到不同的z值。
下面主要介绍第二种方式采样分区流程。

(代码以hudi的master分支、commit 22c45a7704cf4d5ec6fb56ee7cc1bf17d826315d 为准)

采样分区流程
整体流程图
(采样分区总体流程图,里面一些详细的计算流程分析可参考下面代码分析)
image.png

代码分析

  1. 获取采样结果
    // 该函数主要对rdd进行采样,并返回采样结果(采样结果包含采样的数据和该数据对应的权重)
    def getRangeBounds(): ArrayBuffer[(K, Float)] = {

    // zEncodeNum:即目的分区个数,用户可配置,配置项为:hoodie.layout.optimize.build.curve.sample.size
    // 默认分区个数为200000
    if (zEncodeNum <= 1) {
    ArrayBuffer.empty[(K, Float)]
    } else {

    // samplePointsPerPartitionHint:每个分区采样个数,默认为20
    // sampleSize:需要采样的个数,最多为1e6个
    val sampleSize = math.min(samplePointsPerPartitionHint.toDouble * zEncodeNum, 1e6)

    // rdd.partitions:rdd的分区个数
    // 这里假设每个分区的数据都比较平衡,且每个分区的数据量都比较多,
    // 所以多采集一些(因为实际上会出现分区不平衡的情况,虽然后面会对数据量大的分区重新采集,但还是会可能出现采样个数不够的情况),
    // 并算出平均每个分区需要采样的个数
    val sampleSizePerPartition = math.ceil(3.0 * sampleSize / rdd.partitions.length).toInt

    // rdd.map(_._1):是获取只保留了用户指定的zorder字段的rdd
    // sketch 即开始对每个分区做采样,在下一节(对每个分区进行采样)详细说明
    // 返回值:
    // numItems:该rdd数据总个数
    // sketched:每个分区采集到的数据
    val (numItems, sketched) = sketch(rdd.map(_._1), sampleSizePerPartition)


if (numItems == 0L) {
  ArrayBuffer.empty[(K, Float)]
} else {

  // 如果分区包含的内容远远超过平均样本数(单个分区记录数*fraction > sampleSizePartition),我们将从中重新进行抽样
  // 以确保从该分区收集足够的样本。
  // 计算 采样个数占总体个数的比例,为下面判断分区是否平衡提供依据
  val fraction: Double = math.min(sampleSize / math.max(numItems, 1L), 1.0)
  
  // 记录最终采集出来的所有数据
  // K: 采集到的数据
  // Float: 该数据对应的比重
  val candidates = ArrayBuffer.empty[(K, Float)]
  
  // 记录数据不平衡分区,后续对这些分区重新采样
  val imbalancedPartitions = mutable.Set.empty[Int]
  
  // sketched :
  //   _1: 分区号
  //   _2: 该分区数据总个数
  //   _3: 该分区采集的数据
  sketched.foreach { case (idx, n, sample) =>
    
    // 判断是否是不平衡的分区
    // 这里可以把 fraction 和 sampleSizePerPartion 计算公式代回来化简一下,
    // 最终变成:(rdd.partitions.length * n) / numItems > 3
    // 说明,把当前分区内数据量看成平均数据量的话,比实际总量大3倍
    if (fraction * n > sampleSizePerPartition) {
      imbalancedPartitions += idx
    } else {
      
      // 计算当前采样数据的权重 = 当前分区数据总数 / 当前分区采样总数
      // 这里计算的权重,是为了后面确定分区边界
      val weight = (n.toDouble / sample.length).toFloat
      
      // 将该数据放到最终的结果集中
      for (key <- sample) {
        candidates += ((key, weight))
      }
    }
  }
  
  if (imbalancedPartitions.nonEmpty) {
    // 对不平衡的分区重新采样,同时重新计算采样数据的权重
    val imbalanced = new PartitionPruningRDD(rdd.map(_._1), imbalancedPartitions.contains)
    val seed = byteswap32(-rdd.id - 1)
    
    // 重新采样权重
    val reSampled = imbalanced.sample(withReplacement = false, fraction, seed).collect()
    
    // 计算权重
    val weight = (1.0 / fraction).toFloat
    
    // 将该数据放到最终的结果集中
    candidates ++= reSampled.map(x => (x, weight))
  }
  
  // 返回采样结果集
  candidates
}

}
}
1.1 对每个分区进行采样
def sketch[K: ClassTag](

  rdd: RDD[K],
  sampleSizePerPartition: Int): (Long, Array[(Int, Long, Array[K])]) = {

val shift = rdd.id

// 对每个分区进行采样
val sketched = rdd.mapPartitionsWithIndex { (idx, iter) =>
  
  // 准备随机种子
  val seed = byteswap32(idx ^ (shift << 16))
  
  // 使用蓄水池采样方法对该分区内数据进行采样
  // sample:该分区采集到的数据
  // n:该分区总数据量
  val (sample, n) = SamplingUtils.reservoirSampleAndCount(
    iter, sampleSizePerPartition, seed)
  
  // idx: 当前分区编号
  // n: 当前分区总输入数据个数
  // sample: 当前分区采样集合
  Iterator((idx, n, sample))
}.collect()

// numItems 是所有分区总共输入数据个数,即当前rdd数据总量,为了后面计算采样数据的权重
val numItems = sketched.map(_._2).sum

// 返回结果
// numItems:当前rdd数据总量,为了后面计算采样数据的权重
// sketched:每个分区采集到的数据
(numItems, sketched)

1.2 蓄水池采样方法
该算法主要处理的场景是:给定一个数据流,数据流长度N很大,且N直到处理完所有数据之前都不可知,请问如何在只遍历一遍数据(O(N))的情况下,能够随机选取出k个数据。

算法比较简单,主要分为两步:

使用源数据前k条数据,对结果集进行初始化。
遍历源数据k之后的数据,计算一个随机值,如果该随机值小于k,则替换结果集中的数据。
def reservoirSampleAndCount[T: ClassTag](

  input: Iterator[T],
  k: Int,
  seed: Long = Random.nextLong())
: (Array[T], Long) = {
  
// input: 输入数据集,即rdd中的某个分区
// k: 需要采集数据的个数
// seed: 给定的种子
  
// 结果集,即采样到的数据  
val reservoir = new Array[T](k)

var i = 0
// 先使用数据的前k个数据,对结果集做初始化,
while (i < k && input.hasNext) {
  val item = input.next()
  reservoir(i) = item
  i += 1
}

if (i < k) {

  // 数据量不够,则直接返回
  val trimReservoir = new Array[T](i)
  System.arraycopy(reservoir, 0, trimReservoir, 0, i)
  (trimReservoir, i)
} else {
  
  // 数据量足够,则开始做随机替换
  // l:记录数据总量
  var l = i.toLong
  val rand = new XORShiftRandom(seed)
  while (input.hasNext) {
    val item = input.next()
    l += 1

    // 对结果集中的数据做随机替换
    // 如果随机出来的数据在结果集范围内,则替换该数据
    val replacementIndex = (rand.nextDouble() * l).toLong
    if (replacementIndex < k) {
      reservoir(replacementIndex.toInt) = item
    }
  }
  
  // 返回采集结果和数据总量
  (reservoir, l)
}

}

  1. 获取分区边界
    def determineBound[K : Ordering : ClassTag](
    candidates: ArrayBuffer[(K, Float)],
    partitions: Int, ordering: Ordering[K]): Array[K] = {

    // candidates:采集到的数据
    // K:当前采样的数据
    // Float:当前采样数据的权重
    // partitions:需要得到的分区个数(即用户配置的所需分区个数)
    // ordering:排序

    // 将candidate按照第一个字段进行排序,其实这里也就只有一个字段
    val ordered = candidates.sortBy(_._1)(ordering)

    // 采样样品个数
    val numCandidates = ordered.size

    // 计算总权重
    val sumWeights = ordered.map(_._2.toDouble).sum

    // 每个分区的平均权重
    val step = sumWeights / partitions

    var cumWeight = 0.0
    var target = step

    // 记录边界结果集
    val bounds = ArrayBuffer.empty[K]

    var i = 0
    var j = 0
    var previousBound = Option.empty[K]

    // 遍历已排序的candidate,累加其权重cumWeight,每当权重达到一个分区的
    // 平均权重step,就获取一个key作为分区的间隔符,最后返回所有获取到的分隔符
    // 注:在算边界时,会跳过重复数据
    while ((i < numCandidates) && (j < partitions - 1)) {
    val (key, weight) = ordered(i)

    // 累加当前分区的权重
    cumWeight += weight
    if (cumWeight >= target) {

     
     // 跳过重复数据
     if (previousBound.isEmpty || ordering.gt(key, previousBound.get)) {
       
       // 添加一个分区
       bounds += key
       target += step
       j += 1
       previousBound = Some(key)
     }

    }
    i += 1
    }

    // 返回边界
    bounds.toArray
    }

  2. 扩充分区边界
    // sampleBounds:即上面返回的分区边界
    // 因为zorder可以指定多个字段,每个字段都会确定一个边界,这里计算所有字段确定出来的边界长度的最大值
    val maxLength = sampleBounds.map(_.length).max

    // 遍历每个分区边界,如果有分区边界长度很少的话,则对该分区边界扩充
    val expandSampleBoundsWithFactor = sampleBounds.map { bound =>

     
     val fillFactor: Int = maxLength / bound.size
     if (bound.isInstanceOf[Array[Long]] && fillFactor > 1) {
       
       // 当前边界长度太小,则进行对当前分区边界进行扩充
       val newBound = new Array[Double](bound.length * fillFactor)
       val longBound = bound.asInstanceOf[Array[Long]]
       for (i <- 0 to bound.length - 1) {
         for (j <- 0 to fillFactor - 1) {
           
           // 扩充的大小,就是上面 fillFactor 的倒数
           // 例如 fillFactor=3,则扩充的大小为0.33,当一个边界值为2时,则会扩充2个边界,为:2.33、2.66和3
           newBound(j + i*(fillFactor)) = longBound(i) + (j + 1) * (1 / fillFactor.toDouble)
         }
       }
       (newBound, fillFactor)
     } else {
       (bound, 0)
     }

    }


滴普科技DEEPEXI
46 声望11 粉丝

滴普科技成立于2018年,是专业的数据智能服务商。滴普科技基于数据智能技术,以客户价值为驱动,为企业提供基于流批一体、湖仓一体的实时数据存储与计算、数据处理与分析、数据资产管理等服务。