去重方法-精确去重(Kylin的去重)

文章目录

      • 去重方法
      • 精确去重的原理
      • RoaringBitmap
        • 实现思路
        • 小桶的类型
        • 与bitmap的性能对比
      • 全局字典介绍
      • Trie树与AppendTrie树

去重方法

在 OLAP 数据分析领域,去重计数(count distinct)是非常常见的需求(这可以作为一个度量),根据去重结果的要求分为近似去重精确去重(在Kylin中,可以自行选择):

  • Kylin的近似去重计数是基于HLL(HyperLogLog)来实现。

    简单来说,每个需要被计数的值都会经过特定Hash函数的计算,将得到的哈希值放入到byte数组中,最后根据特定算法对byte数据的内容进行统计,就可以得到近似的去重结果。

    这种方式的好处是,不论数据有多少,byte数组的大小是有理论上限的,通常不会超过128KB,存储压力非常小。也就是说能够满足超大数据集和快速响应的要求。

    但最大的问题就是结果是非精确的,这是因为保存的都是经过hash计算的值,而一旦使用hash就一定会有冲突,这就是导致结果不精确的直接原因。此外在实践中发现,hash函数的计算是计算密集型的任务,需要大量的CPU资源。

  • 对于精确去重,最常用的处理方法是 bit map 方法。对于整型数据,我们可以将这些整数直接保存到 bit map 中。但除了整型之外,还有其他类型,如 String,为了实现精确的重复数据删除,我们首先需要对这些数据建立一个字典进行统一映射,然后使用 bit map 方法进行统计。

    目前业界有很多成熟的bitmap库可用,比如RoaringBitmap,ConciseSet等,其中RoaringBitmap的读写速度最快,存储效率也很高,目前已经在Spark,Drill等开源项目中使用,因此Kylin也选择了RoaringBitmap。经过试验,证明了想法是可行的,通过实现一种新的基于bitmap的去重计算指标,确实能够实现对Int型数据的精确去重计数。在百万量级的数据量下,存储的结果可以控制在几兆,同时查询能够在秒级内完成,且可以支持任意粒度的上卷聚合计算。

精确去重的原理

精确去重指标实现的两个核心难点:

  1. 支持任意粒度的上卷聚合
  2. 支持String等非Int类型数据

难点的解决方案:

  1. 前面提到,为了支持任意粒度的上卷聚合,我们需要保留明细数据,而计算机存储的最小单位是bit,所以采用了Bitmap来存储Kylin的精确去重指标。Kylin在实际工程中采用的是业界中广泛使用, 性难最优的RoaringBitmap库。
  2. RoaringBitmap仅支持Int类型数据,为了支持String等非Int类型数据,我们需要一个String到Int的映射,而且要求所有Segment中同一个String始终映射到同一个Int。 为什么呢? 假如UUID列的String “A” 在Segment1中映射到Int 1,但是String “A” 却在Segment2中映射到INT 2,那么当UUID列需要跨Segment1和Segment2去重时,显然就会出错。 所以我们引入了全局字典,来保证String等非Int类型数据始终映射到同一个Int值。

无需上卷的精确去重查询优化

前面提到,为了支持任意粒度的上卷聚合,我们使用Bitmap存储精确去重指标。所以在查询时,我们需要先从HBase端将整个Bitmap传输给Kylin的QueryServer,Kylin的QueryServr再根据Bitmap计算出 去重值。但是在实际的使用场景中,用户的一些甚至多数精确去重查询是不需要上卷聚合的, 比如用户的Cube按照dt列分区,且已经预计算好(A,dt)的Cuboid,那么下面的SQL查询时在HBase端和Kylin的QueryServer端都是无需聚合的:

RoaringBitmap

RoaringBitmap是高效压缩位图,简称RBM。

RBM的历史并不长,它于2016年由S. Chambi、D. Lemire、O. Kaser等人在论文《Better bitmap performance with Roaring bitmaps》与《Consistently faster and smaller compressed bitmaps with Roaring》中提出.

实现思路

  • 将 32bit int(无符号的)类型数据 划分为 2^16 个桶,即最多可能有216=65536个桶,论文内称为container。用container来存放一个数值的低16位;
  • 在存储和查询数值时,将数值 k 划分为高 16 位和低 16 位,取高 16 位值找到对应的桶,然后在将低 16 位值存放在相应的 Container 中(存储时如果找不到就会新建一个)

比如要将31这个数放进roarigbitmap中,它的16进制为:0000001F,前16位为0000,后16为001F。

所以先需要根据前16位的值:0,找到它对应的通的编号为0,然后根据后16位的值:31,确定这个值应该放到桶中的哪一个位置,如下图所示。

去重方法-精确去重(Kylin的去重)_第1张图片

小桶的类型

在roaringbitmap中共有3种小桶:

  • arraycontainer(数组容器)
  • bitmapcontainer(位图容器)
  • runcontainer(行程步长容器)

(1)arraycontainer

当ArrayContainer(其中每一个元素的类型为 short int 占两个字节,且里面的元素都是按从大到小的顺序排列的)的容量超过4096(即8k)后,会自动转成BitmapContainer(这个所占空间始终都是8k)存储。

4096这个阈值很聪明,低于它时ArrayContainer比较省空间,高于它时BitmapContainer比较省空间。也就是说ArrayContainer存储稀疏数据,BitmapContainer存储稠密数据,可以最大限度地避免内存浪费。

去重方法-精确去重(Kylin的去重)_第2张图片

(2)bitmapcontainer

这个容器就是位图,只不过这里位图的位数为216(65536)个,也就是2^16个bit, 所占内存就是8kb。然后每一位用0,1表示这个数不存在或者存在,如下图:

在这里插入图片描述

(3)runcontainer

RunContainer中的Run指的是行程长度压缩算法(Run Length Encoding),对连续数据有比较好的压缩效果。

它的原理是,对于连续出现的数字,只记录初始数字和后续数量。即:

  • 对于数列11,它会压缩为11,0;
  • 对于数列11,12,13,14,15,它会压缩为11,4;
  • 对于数列11,12,13,14,15,21,22,它会压缩为11,4,21,1;

不过这种容器不常用,所以在使用的时候需要自行调用相关的转换函数来判断是不是需要将arraycontiner,或bitmapcontainer转换为runcontainer。

这种压缩算法的性能和数据的连续性(紧凑性)关系极为密切,对于连续的100个short,它能从200字节压缩为4字节,但对于完全不连续的100个short,编码完之后反而会从200字节变为400字节。

如果要分析RunContainer的容量,我们可以做下面两种极端的假设:

  • 最好情况,即只存在一个数据或只存在一串连续数字,那么只会存储2个short,占用4字节
  • 最坏情况,0~65535的范围内填充所有的奇数位(或所有偶数位),需要存储65536个short,128kb

(4)读取性能

增删改查的时间复杂度方面,BitmapContainer只涉及到位运算且可以根据下标直接寻址,显然为O(1)。而ArrayContainer和RunContainer都需要用二分查找在有序数组中定位元素,故为O(logN)。

  • ArrayContainer一直线性增长,在达到4096后就完全比不上BitmapContainer了
  • BitmapContainer是一条横线,始终占用8kb
  • RunContainer比较奇葩,因为和数据的连续性关系太大,因此只能画出一个上下限范围。不管数据量多少,下限始终是4字节;上限在最极端的情况下可以达到128kb。

空间占用(即序列化时写出的字节流长度)方面,BitmapContainer是恒定为8KB的。ArrayContainer的空间占用与基数(c)有关,为(2 + 2c)B;RunContainer的则与它存储的连续序列数(r)有关,为(2 + 4r)B。

与bitmap的性能对比

roaringbitmap除了比bitmap占用内存少之外,其并集和交集操作的速度也要比bitmap的快,原因如下:

  • 计算上的优化

    • 对于roaringbitmap本质上是将大块的bitmap分成各个小块,其中每个小块在需要存储数据的时候才会存在。所以当进行交集或并集运算的时候,roaringbitmap只需要去计算存在的一些块而不需要像bitmap那样对整个大的块进行计算。如果块内非常稀疏,那么只需要对这些小整数列表进行集合的 AND、OR 运算,这样的话计算量还能继续减轻。这里既不是用空间换时间,也没有用时间换空间,而是用逻辑的复杂度同时换取了空间和时间。

    • 同时在roaringbitmap中32位长的数据,被分割成高 16 位和低 16 位,高 16 位表示块偏移,低16位表示块内位置,单个块可以表达 64k 的位长,也就是 8K 字节。这样可以保证单个块都可以全部放入 L1 Cache,可以显著提升性能。

  • 程序逻辑上的优化:

    • roaringbitmap维护了排好序的一级索引以及有序的arraycontainer,当进行交集操作的时候,只需要根据一级索引中对应的值来获取需要合并的容器,而不需要合并的容器则不需要对其进行操作直接过滤掉。
    • 当进行合并的arraycontainer中数据个数相差过大的时候采用基于二分查找的方法对arraycontainer求交集,避免不必要的线性合并花费的时间开销。
    • roaingbitmap在做并集的时候同样根据一级索引只对相同的索引的容器进行合并操作,而索引不同的直接添加到新的roaringbitmap上即可,不需要遍历容器。
    • roaringbitmap在合并容器的时候会先预测结果,生成对应的容器,避免不必要的容器转换操作。

全局字典介绍

在Apache Kylin的现有实现中,Cube的每个Segment都会创建独立的字典,这种方式会导致相同数据在不同Segment字典中被映射成不同的值,这会导致最终的去重结果出错。

为此,在 Cube 的增量构建过程中,为了避免由于对不同时间段分别构造字典而导致最终去重结果出现错误,一个 Cube 中的所有 segments 将使用同一个字典,即全局字典。全局字典的意义是保证所有Value映射到全局唯一且连续的Int ID

具体来说,根据字典的资源路径(元数据名+库名+表名+列名)可以从元数据中获取同一个字典实例,后续的数据追加也是基于这个唯一的字典实例创建的builder进行的。

全局字典最重要的意义是支持精确去重指标跨Segment上卷,但在某些应用场景下,用户的确不需要Segment上卷。 比如用户只需要按天进行去重,或者Cube本身就是不分区的(每次全量构建)。 针对这一点,新增了一种SegmentAppendTrieDictBuilder(前面提到的Segment Dictionary ),底层的数据结构依然还是AppendTrieDictionary,只是每次构建时Working目录不是Copy最新的Version目录,而是从空Working目录开始构建,同时字典的元数据也需要重新初始化。由于SegmentAppendTrieDictBuilder是segment粒度的,也不需要分布式锁,所以可以并发构建。使用SegmentAppendTrieDictBuilder后构建和加载时的内存问题也基本不会再有。

原理:

  • 每个构建任务都将生成一个新的全局字典;
  • 每个新的构建任务的字典会根据版本号保存,旧的全局字典会逐渐删除;
  • 一个全局字典包含一个元数据文件和多个字典文件,每个字典文件称为一个 bucket (bucket);
  • 每个 bucket 被划分为两个映射(Map),并将这两个映射组合成一个完整的映射关系。

去重方法-精确去重(Kylin的去重)_第3张图片

Kylin引入了桶这一概念,可以理解为在处理数据的时候,将数据分到若干个桶(即多个分区)中进行并行处理。 第一次构建字典的时候会对每个桶内的值从1开始编码,在所有桶的编码完成之后再根据每个桶的offset值进行整体字典值的分配。在代码中两次编码是通过两个HashMap进行存储的,其中一个存储桶内相对的字典值,另一个存储所有桶之间绝对的字典值

下图所示的是编号为1的桶多次构建任务中,桶内字典的传递,每一次构建都会为桶创建一个新的版本(即v1, v2, v3等),加入版本控制的原因后面会有解释。Curr(current)和Prev(Previous)是一个桶内的两个HashMap,分别存储着当前桶内字典的相对(Relative)编码值和之前已经构建的所有字典值的绝对(Absolute)编码值。

去重方法-精确去重(Kylin的去重)_第4张图片

构建步骤:

  • 通过 Spark 创建平表并获取需精确去重列的 distinct 值
  • 根据确定去重后的字面值数量来确认分片数, 并且根据需求判断是否需要扩容
  • 将数据分配(repartition)到多个分片(Partition)中,分别进行编码, 存储到各自的字典文件中
  • 为当前构建任务分配版本号
  • 保存字典文件和 metadata数据(桶数量和桶的 offset 值)
  • 根据条件判断需要删除旧版本

初次构建:

  • 计算桶的大小:取需要构建字典的数量处理 单个桶阈值 和 桶数量默认值 的最大值。
  • 创建桶并分配数据进行编码
  • 生成meta文件记录桶的offsets

以下是相关配置项及其默认值:

kylin.dictionary.globalV2-min-hash-partitions=10
kylin.dictionary.globalV2-threshold-bucket-size=500000

去重方法-精确去重(Kylin的去重)_第5张图片

非初次构建:

  • 根据字典数量确定桶是否需要扩容
  • 已编码的字典值对扩容后的桶进行重新分配
  • 读取之前最新版本的字典数据,并分配到各个桶中
  • 将新的值分配到桶中
  • 前一次构建的字典值不会改变

去重方法-精确去重(Kylin的去重)_第6张图片

Trie树与AppendTrie树

在全局字典中,映射关系有很多种实现方式,比如基于格式的编码,hash编码等,其中空间和性能效率都比较高,且通用性更强的是基于Trie树的字典编码方式。目前Apache Kylin之前已经实现了TrieDictionary,用于维度值的编码。

Trie树又名前缀树,是是一种有序树,一个节点的所有子孙都有相同的前缀,也就是这个节点对应的字符串,根节点对应空字符串,而每个字符串对应的编码值由对应节点在树中的位置来决定。图1是一棵典型的Trie树示意图,注意并不是每个节点都有对应的字符串值。

在构造Trie树时,将每个值依次加入到树中,可以分为三种情况,如图所示:

去重方法-精确去重(Kylin的去重)_第7张图片

上述的是Trie树在构建过程中的内存模型,当所有数据加入之后,需要将整棵Trie树的数据序列化并持久化。具体的格式如图所示:

去重方法-精确去重(Kylin的去重)_第8张图片

从上图中可以按照,整棵树按照广度优先的顺序依次序列化,其中childOffsetnValuesBeneath的长度是可变的,这是为了根据整棵树的大小尽可能使用更短的数据,降低存储量。此外需要注意到,childOffset的最高两位是标志位,分别标识当前节点是否是最后一个child,以及当前节点是否对应了原始数据的一个值。

当需要从字典中检索某个值的映射id时,直接从序列化后的数据读取即可,不需要反序列化整棵Trie树,这样能在保证检索速度的同时保持较低的内存用量。整个过程如图所示。

去重方法-精确去重(Kylin的去重)_第9张图片

通过上述对Trie树的分析可以看到,Trie树的效率很高,但有一个问题,Trie树的内容是不可变的。也就是说,当一颗Trie树构建完成后,不能再追加新的数据进去,也不能删除现有数据,这是因为每个原始数据对应的映射id是由对应节点在树中的位置决定的,一旦树的结构发生变化,那么会导致部分原始数据的映射id发生变化,从而导致错误的结果。为此,我们需要对Trie树进行改造,使之能够持续追加数据,也就是AppendTrie树。

下图是AppendTrie的序列化格式,可以看到和传统Trie树相比,主要区别在于不保留每个节点的子value数,转而将节点对应的映射id保存到序列化数据中,这样虽然增大了存储空间,但使得整棵树是可以追加的,而且同时也导致了AppendTrieDictionary只能根据Value查找Id,不能根据Id查找Value。

去重方法-精确去重(Kylin的去重)_第10张图片

经过改造,AppendTrie树已经满足了我们的需求,能够支持我们实现对所有数据的统一编码,进而支持精确去重计数。但从实际情况来看,当统计的数据量超过千万时,整颗树的内存占用和序列化数据都会变得很大,因此需要考虑分片,将整颗树拆成多颗子树,通过控制每颗子树的大小,来实现整棵树的容量扩展。为了实现这个目标,当一棵树的大小超过设定阈值后,需要通过分裂算法将一棵树分裂成两棵子树,下图展示了整个过程。

去重方法-精确去重(Kylin的去重)_第11张图片

在一棵AppendTrie树是由多棵子树构成的基础上,我们很容易想到通过LRU之类的算法来控制所有子树的加载和淘汰行为。为此我们基于guava的LoadingCache实现了特定的数据结构CachedTreeMap。这种map继承于TreeMap,同时value通过LoadingCache管理,可以根据策略加载或换出,从而在保证功能的前提下降低了整体的内存占用。

此外,为了支持字典数据的读写并发,数据持久化采用mvcc的理念,每次构建持久化的结果作为一个版本,版本一旦生成就不可再更改,后续更新必须复制版本数据后进行,并持久化为更新的版本。每次读取时则选择当前最新的版本读取,这样就避免了读写冲突。同时设置了基于版本个数和存活时间的版本淘汰机制,保证不会占用过多存储。

经过上述的改进和优化后,AppendTrie树完全达到了我们的要求,可以对所有类型的数据统一做映射编码,支持的数据量可以到几十亿甚至更多,且保持内存占用量的可控,这就是我们的可追加通用字典AppendTrieDictionary。

分布式环境下数据一致性的保证:

  • MVCC: 全局字典最终是持久化在HDFS目录上,为了避免读写冲突,我们采用了MVCC,当读AppendTrieDictionary时,永远只读取最新的Version目录;当写AppendTrieDictionary时,会将最新的Verion目录copy到working目录,修改完成且通过正确性校验后,会将working目录rename为新的Version目录。
  • 分布式锁:通过分布式锁,我们保证了在多JobServer的多Segment并发构建下,1个Kylin集群在同一时刻只会有1个线程可以修改AppendTrieDictionary。

参考:

  • https://blog.bcmeng.com/post/kylin-distinct-count-global-dict.html
  • https://hexiaoqiao.github.io/blog/2016/11/27/exact-count-and-global-dictionary-of-apache-kylin/

你可能感兴趣的:(大数据,kylin,大数据)