BloomFilter & Count-Min sketch 数据结构分析

54 阅读7分钟

从小我们就学过,抛一次硬币是正面的概率是50%, 如果抛10次硬币,每次都是正面的概率就是0.510=0.00097656250.5^{10}=0.0009765625,而10次里至少有一次正面的概率是 10.510=0.99902343751-0.5^{10}=0.9990234375

换个角度来说,即使我们抛10次硬币都是正面的概率只有 0.0009765625, 单次抛硬币是正面的概率也很高了,是0.000976562110=0.50.000976562^{\frac{1}{10}} = 0.5

可见,即使是单次概率50%,重复多次后也可以得到一个大到接近100%或者小到接近于0的概率

再引申一下,单次的概率是50%都已经能达到这样的效果,如果我们抛一枚特殊的硬币,抛10次都是正面的概率是95%, 就更容易通过重复得到一个极大或极小的概率值了。计算:单次抛硬币是正面的概率就是0.95110=0.99488380310.95^{\frac{1}{10}} = 0.9948838031, 更接近于1了。 而如果抛10次都是正面的概率是99%, 那么单次抛出是正面的概率是0.99110=0.998995471290.99^{\frac{1}{10}} = 0.99899547129,越来越无限接近于1。

这便是BloomFilter 和 Count-Min sketch 这两个数据结构的数学思想。它通过极小的精确度的损失,获得了极大的性能提升。

注:本文不会太多涉及精确的数学推导和证明,仅从纯实用角度讨论。会用就行。

BloomFilter

它用来高效地表示“某个值一定不存在,或可能存在”。我们主要用它来判断“一定不存在”,比如为了避免向数据库发起多次对同一个值的插入请求导致重复插入;或缓存查询miss需要回源时,先快速判断一下这个值在数据库里是否存在,避免对数据库造成额外的负载。

假设有一个长度为10的bit数组: [0 0 0 0 0 0 0 0 0 0​], 又有一个hash函数,可以对任一字符串生成三个范围在[0,10)的随机数(其实就是三个不同的hash函数)。

hash("apple") -> 1,2,3 把数组内对应位置设为1, 数组现在变成 [ 0 1 1 1 0 0 0 0 0 0 ] ​

hash("orange") -> 3,5,7, 把数组内对应位置设为1, 数组现在变成 [ 0 1 1 1 0 1 0 1 0 0 ] ​

假设我们需要查询 "sky" 这个词是否存在,而它的 hash("sky") = 1 5 8。 我们发现1和5位置上都是 1,而8位置上是0,并非都是1, 就认为它不存在。如果某个hash后的每个位置上都是1,就认为“可能存在”

上述便是 BloomFIlter 的使用过程。

可以看到,Bloom 里多个key写入时, 是可能会重复写到同一个值的,也是就说,会有一定概率的误差。 还以是上面的例子,现在 1,2,3,5,7 都是1. 假设这时有一个hash值是 1,5,7 的字符串,也会被判断为存在。同时我们又很自然地能得到一个结论,如果这个bit数组越长,出错的概率就越低。

这个误差的概率是多少呢?假设hash生成的三个索引值,每个索引都有10%的概率已经被覆盖了,那么这三个索引都被覆盖过的概率就是 0.13=0.0010.1^3 = 0.001。用前面抛硬币的例子来类比一下就是:假设每一次是正面的概率都是10%,求扔三次都是正面的概率。 可见,为了降低误差的概率,需要1. 让 bit数组长度 / hash值 的比值变大;2. 让hash生成的索引值增加(即使用更多个hash function)。假设hash生成的索引值是6个,且每次的错误率是5%, 那么错误率就会降低到 0.056=0.0000000156250.05^{6} = 0.000000015625,已经很接近于0了。

具体需要多长的bit数组,需要几个hash function呢?可以根据自己能接受的容错率和预估的元素数进行计算:

假设 容错率 = E, 预估元素数 = n。 需要求最低的bit数组长度 m 和 hash function的数量k。有如下公式:

m=nlnE(ln2)2m = - \frac{n ln{E}}{{(ln2)^2}}

k=mnln2k = \frac{m}{n}ln2

比如需要容错率<0.02, 预估元素数10000, 套入公式得到 m = 81423, k = 6。

可见,10000个元素的BloomFilter只需要81000 bit, 这在内存占用上是非常合算的。与hash table对比一下,如果使用hash table, 假设每个key=64bit, 并假设 hash table 需要2.5倍的空间,这10000个元素就需要占1600000bit。

Count-Min sketch

使用场景:用来进行高性能的粗略计数,也就是"Counting"操作。比如统计每个元素大体上出现过多少次。与上面更广人为知的BloomFilter相近,它的计数有一定错误率,但换来的是相比使用普通方法能大量节省更多内存。

Count-Min sketch 最广为人知的使用场景就是在 TinyLFU中,用来统计每个元素出现的频率。

它的实现方法是这样的:

假设有3个不同的hash function, 每个hash function对应一个int数组,假设数组长度为7,现在的数据结构如下:

h1: [0,0,0,0,0,0,0]
h2: [0,0,0,0,0,0,0]
h3: [0,0,0,0,0,0,0]

现在插入一个元素"Apple", 假设三个hash function进行hash的结果分别是 1,3,5。就在每个hash function对应数组的对应下标位置+1。现在结果是

h1: [0,1,0,0,0,0,0]
h2: [0,0,0,1,0,0,0]
h3: [0,0,0,0,0,1,0]

再插入一个元素"Banana", 假设三个hash function的hash结果分别是3,4,5。现在结果是

h1: [0,1,0,1,0,0,0]
h2: [0,0,0,1,1,0,0]
h3: [0,0,0,0,0,2,0]

依此,继续插入,假设插入了无数个数值后的结果(仅是示例,随便写的):

h1: [3,20,12,9,8,12,36]
h1: [4,20,18,25,8,12,2]
h1: [5,20,3,9,8,12,2]

现在假设我想看"Apple" 插入了多少次,再次通过hash function找到 1,3,5, 现在这三个位置上的数字分别是[20, 25, 12],最大是25。也就可以认为 Apple 最多被插入了25次。

很明显,一旦有hash值的冲突,这个数据结构统计到的频率就是不准的。而数组越长,越不容易冲突。

因此,与BloomFilter类似,在使用CM Sketch时,我们也需要确定hash function的数量k, 以及数组长度m。这里直接给出现成公式。

假设 能容忍的错误率 = E, 预估元素数 = n, 能容忍的错误范围 =R (假设估计值=c,准确值=c0,那么估计值的取值范围一定满足c<=c0<=c+R )。 需要求最低的bit数组长度 m 和 hash function的数量k。有如下公式:

m=enRm = \frac{en}{R} (e为自然对数), k=ln(1E)k = ln(\frac{1}{E})

比如有10000个元素,能容忍0.02的错误率,能容忍的错误范围=10, 那么m=2780, k=

hash 选择

无论是BloomFilter还是CM Sketch, 都需要有多个hash function。但要注意的是,这里的hash function只要能实现输入一个字符串,比较平均分布地生成一个在给定范围内的固定随机数即可。因此没必要使用MD5、SHA1这类, 使用murmur, fnv等,生成一个uint32或uint64,再按长度取模,就行了。

而对于多个hash function, 可以只使用同一个hash算法再进行区分。 可使用在hash时增加不同前后缀的方式,比如 hash1 = hash(key+"_1"), hash2 = hash(key+"_2"); 也可以给每一个hash函数分配一个随机数,hash后的结果与随机数进行一次xor操作, 比如hash1 = hash(key) xor randNumber1, hash2 = hash(key) xor randNumber2。