作者:吴文池
背景
hudi在数据聚集方面,支持使用zorder对数据进行重排。
做zorder排序主要流程分为三步:
-对于用户指定的每个zorder字段,生成对应的z值。
-把所有zorder字段生成的z值进行比特位的交叉组值,生成最终的z值。
-根据最终z值,对所有数据进行排序。
在上面第一步中,每个字段生成自己的z值方式主要有两种:
直接的值映射方式。该方式实现简单,容易理解,但也有缺陷:
-参与生成z值的字段理论上需要是从0开始的正整数,这样才能生成很好的z曲线,但真实的数据集中基本不可能出现,那么zorder的效果将会打折扣。
对于一些前缀相同的数据,例如url字段大多数都以:http://www.开头,那么取前面几位做排序将毫无意义。
-先对数据采样,根据采样数据对所有数据进行分区,最后使用该数据所对应的分区号作为该数据的z值。这种方式可以很好的解决方式一的两个问题:
分区一定是从0开始的整数。
对于前缀相同的数据,也能够根据字符串大小,将其很好的分配到不同分区中,得到不同的z值。
下面主要介绍第二种方式采样分区流程。
(代码以hudi的master分支、commit 22c45a7704cf4d5ec6fb56ee7cc1bf17d826315d 为准)
采样分区流程
整体流程图
(采样分区总体流程图,里面一些详细的计算流程分析可参考下面代码分析)
代码分析
获取采样结果
// 该函数主要对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)
}
}
获取分区边界
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 / partitionsvar 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
}扩充分区边界
// 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) }
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。