Doris BITMAP/HLL 去重🤔

1,741 阅读8分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

BITMAP原理

中文叫做位图算法,是连续的二进制位(bit),一位代表一个独立数据点,用于对大量整形数据做去重和查询。

举个例子,给定一块长度为10的bitmap,想要依次插入整形数据4,2,1,3。我们需要怎么做呢?

  1. 给定长度是10的bitmap,每一个bit位分别对应着从0到9的10个整型数。此时bitmap的所有位都是0。

image.png 2. 把整型数4存入bitmap,对应存储的位置就是下标为4的位置,将此bit置为1。

image.png

  1. 把整型数2存入bitmap,对应存储的位置就是下标为2的位置,将此bit置为1。

image.png

  1. 把整型数1存入bitmap,对应存储的位置就是下标为1的位置,将此bit置为1。

image.png 5. 把整型数3存入bitmap,对应存储的位置就是下标为3的位置,将此bit置为1。

image.png

这样的存储方式在数据稠密的时候,非常节省空间,但是在数据稀疏的时候,会有极大的浪费。

对于32位整形数据,一般的bitmap需要2^32bit,即512MB。

Doris中使用RoaringBitmap,其主要思路是:将32位无符号整数按照高16位分桶,即最多可能有216=65536个桶,论文内称为container。

存储数据时,按照数据的高16位找到container(找不到就会新建一个),再将低16位放入container中。也就是说,一个RBM就是很多container的集合。

为了方便理解,照搬论文中的示例图,如下所示。

image.png

图中示出了三个container:

  • 高16位为0000H的container,存储有前1000个62的倍数。
  • 高16位为0001H的container,存储有[216, 216+100)区间内的100个数。
  • 高16位为0002H的container,存储有[2×216, 3×216)区间内的所有偶数,共215个。

container是RBM新创造的概念,自然也是提高效率的核心。为了更高效地存储和查询数据,不同情况下会采用不同类型的container,下面深入讲解一下container的细节。

  • ArrayContainer

当桶内数据的基数不大于4096时,会采用它来存储,其本质上是一个unsigned short类型的有序数组。数组初始长度为4,随着数据的增多会自动扩容(但最大长度就是4096)。另外还维护有一个计数器,用来实时记录基数。

上图中的前两个container基数都没超过4096,所以均为ArrayContainer。

  • BitmapContainer

当桶内数据的基数大于4096时,会采用它来存储,其本质就是上一节讲过的普通位图,用长度固定为1024的unsigned long型数组表示,亦即位图的大小固定为216位(8KB)。它同样有一个计数器。

上图中的第三个container基数远远大于4096,所以要用BitmapContainer存储。

  • RunContainer

RunContainer在图中并未示出。它使用可变长度的unsigned short数组存储用行程长度编码(RLE)压缩后的数据。举个例子,连续的整数序列11, 12, 13, 14, 15, 27, 28, 29会被RLE压缩为两个二元组11, 4, 27, 2,表示11后面紧跟着4个连续递增的值,27后面跟着2个连续递增的值。

由此可见,RunContainer的压缩效果可好可坏。考虑极端情况:如果所有数据都是连续的,那么最终只需要4字节;如果所有数据都不连续(比如全是奇数或全是偶数),那么不仅不会压缩,还会膨胀成原来的两倍大。所以,RBM引入RunContainer是作为其他两种container的折衷方案,从存储上做了压缩优化,但是也降低了读取性能。

对于uint64,RBM简单的将32位RBM组合起来,用uint64前32位进行索引。所以请尽可能使用uint32进行去重,以获得更好的性能。

HyperLogLog原理

简称HLL,它是 LogLog 算法的升级版,作用是能够提供不精确的去重计数。其数学基础为伯努利试验

假设硬币拥有正反两面,一次的上抛至落下,最终出现正反面的概率都是50%。一直抛硬币,直到它出现正面为止,我们记录为一次完整的试验。

那么对于多次的伯努利试验,假设这个多次为n次。就意味着出现了n次的正面。假设每次伯努利试验所经历了的抛掷次数为k。第一次伯努利试验,次数设为k1,以此类推,第n次对应的是kn。

其中,对于这n次伯努利试验中,必然会有一个最大的抛掷次数k,例如抛了12次才出现正面,那么称这个为k_max,代表抛了最多的次数。

伯努利试验容易得出有以下结论:

  • n 次伯努利过程的投掷次数都不大于 k_max。
  • n 次伯努利过程,至少有一次投掷次数等于 k_max

最终结合极大似然估算的方法,发现在n和k_max中存在估算关联:n = 2 ^ k_max。当我们只记录了k_max时,即可估算总共有多少条数据,也就是基数。

假设试验结果如下:

  • 第1次试验: 抛了3次才出现正面,此时 k=3,n=1
  • 第2次试验: 抛了2次才出现正面,此时 k=2,n=2
  • 第3次试验: 抛了6次才出现正面,此时 k=6,n=3
  • 第n次试验:抛了12次才出现正面,此时我们估算, n = 2^12

取上面例子中前三组试验,那么 k_max = 6,最终 n=3,我们放进估算公式中去,明显: 3 ≠ 2^6 。也即是说,当试验次数很小的时候,这种估算方法的误差是很大的。

这三组试验,我们称为一轮的估算。如果只是进行一轮的话,当 n 足够大的时候,估算的误差率会相对减少,但仍然不够小。

那么是否可以进行多轮呢?例如进行 100 轮或者更多轮次的试验,然后再取每轮的 k_max,再取平均数,即: k_mx/100。最终再估算出 n。下面是LogLog的估算公式:

image.png

上面公式中,DV_LL对应的就是n,constant是修正因子,它的具体值是不定的,可以根据实际情况而分支设置。m代表的是试验的轮数。头上有一横的R就是平均数:(k_max_1 + ... + k_max_m)/m。

这种通过增加试验轮次,再取k_max平均数的算法优化就是LogLog的做法。而 HyperLogLog和LogLog的区别就是,它采用的不是平均数,而是调和平均数。调和平均数比平均数的好处就是不容易受到大的数值的影响。

比如求平均工资,A的是1000/月,B的30000/月。

  • 平均数:(1000 + 30000) / 2 = 15500
  • 调和平均数:2/(1/1000 + 1/30000) ≈ 1935.484

明显地,调和平均数比平均数的效果是要更好的。下面是调和平均数的计算方式,∑ 是累加符号。

image.png

下面是 HyperLogLog 的结合了调和平均数的估算公式: image.png 对于输入的数据,HyperLogLog首先将数据转为比特串,例如输入5,便转为:101。为什么要这样转化呢?

是因为要和抛硬币对应上,比特串中,0 代表了反面,1 代表了正面,如果一个数据最终被转化了 10010000,那么从右往左,从低位往高位看,我们可以认为,首次出现 1 的时候,就是正面。

那么基于上面的估算结论,我们可以通过多次抛硬币实验的最大抛到正面的次数来预估总共进行了多少次实验,同样也就可以根据存入数据中,转化后的出现了 1 的最大的位置 k_max 来估算存入了多少数据。

然后将试验结果进行分桶,也就是分多少轮。抽象到计算机存储中,由位(bit)组成长度为 L 的大数组 S ,将 S 平均分为 m 组,注意这个 m 组,就是对应多少轮,然后每组所占有的比特个数是平均的,设为 p。

  • L = S.length
  • L = m * p

在Doris中,HyperLogLog设置为:m = 16384(占16385字节),p = 6(占8位),L = 16384 * 6。

总共桶数:2 ^ 14 = 16384,每个桶可以表达的最大k_max:2 ^ 6 - 1 = 63。

具体存入时,对于64位整形数字,有如下计算:

  • 低14位对应于所在桶的标号。假设为00 0000 0000 0010,那么会被放入编号为 2 的桶。
  • 高50位对应于k_max。假设第一次出现1的位置是在第 50 位(最高位),6 位二进制编码为110010 。

每个桶记录放入最大的值作为k_max(最大就是110010),最后由DV_HLL公式计算出总的基数。

可见DV_HLL公式所需的计算量还是不小的,经验建议要达到秒级查询最好HLL运算不超过1000次。