文章目录
一、Bloom Filter存在的意义
为了说明Bloom Filter存在的重要意义,举一个例子,也是我学习Bloom Filter的原因:
假如我们要写一个爬取微博全站信息的爬虫,由于网络中的链接关系错综复杂,爬虫在微博网站爬取信息的时候肯定会抓取到已经采集过的页面的链接。这样爬虫就会形成一个闭环,找不到出口,重复采集已经采集过的信息,造成资源浪费,最终导致程序崩溃。很明显这不是我们想要的结果!
那么如何解决这个问题呢?就需要对新抓取到的链接进行判重,如果该链接已经采集过则丢弃,如果没有采集过则加入待采集队列。这样便能保证不会重复采集信息,加快采集效率。
在爬虫领域,去重的规模一般都比较大。去重主要考虑两个点:去重的数据量、去重的速度。为了保持较快的去重速度,一般选择在内存中去重。主要的去重方案如下:
- 如果数据量不大时,可以直接放在内存里进行去重,例如python可以使用集合(set)去重。
- 如果数据需要持久化并且去重,可以使用redis的string数据结构。
- 如果数据量比较大,可以存入mysql数据库,在数据库中进行去重,缺点是mysql数据中数据比较多时查询比较慢导致去重速度远不如内存级去重。
- 如果数据量比较大而且去重速度要求比较高,可以用不同的加密算法先将数据压缩成16/32/40个字符,再使用python的集合或者redis的string数据结构两种方法进行数据去重。
- 如果数据量达到亿级甚至更高时,内存容量有限已经无法存储这么多的数据,此时可以使用Bloom Filter将去重对象映射到几个内存位,通过几个位的0/1值来判断该对象是否已经存在。把Bloom Filter和redis结合起来便可以实现大规模持久化去重。
由此可见,在进行大数据量去重时,Bloom Filter占有举足轻重的地位,具有重要的意义。
二、Bloom Filter算法原理
Bloom Filter实际上就是申请一段内存,然后用hash函数把去重对象映射到该段内存中对应的位上,把该位对应值置1。但是有一个缺点就是hash函数可能会把不同的对象映射到相同的位上导致冲突。若要降低冲突发生的概率,可以把内存的长度加长,但是对于大数据量去重来说,这种方法依然很费内存,因此不推荐。
Bloom Filter解决冲突率高的有效方法就是使用多个hash函数把去重对象映射到多个位上。具体解释如下:
我们知道hash函数有一定的几率出现冲突,假设冲突的概率为P1,其实P1是一个很小的概率,但是当待去重数据量很大时P1也就跟着变大导致冲突变多。Bloom Filter使用多个hash函数冲突概率分别为P1,P2…Pn,不同的hash函数处理同一个去重对象是独立的,所以使用多个hash函数后的冲突概率通过乘法得到为P1P2…Pn。这样冲突的概率就变得很小很小了。
废话不多说,看算法的具体操作:
-
预操作
创建一个m位BitSet,先将所有位初始化位0,然后选择k个不同的hash函数。第i个hash函数对字符串str的处理结果记为h(i, str),且h(i, str)的范围是0~m-1。
-
Add操作
下面是每个字符串的处理过程,首先是将字符串str记录到BitSet的过程:
对于字符串str,分别计算h(1, str)、h(2, str)…h(k, str)。然后将BitSet的第h(1, str)、h(2, str)…h(k, str)位设为1。
这样就把字符串str映射到BitSet中的k个二进制位上了。
-
Check操作
根据上图,我们对每一个字符串采用同样的操作。
下面是检查字符串str是否被BitSet记录过的过程:
- 对于字符串str,分别计算h(1, str)、h(2, str)…h(k, str),然后检查BitSet的第h(1, str)、h(2, str)…h(k, str)位是否为1,若其中任何一位不为1则可以判定str一定没有被记录过。若全部都是1,则认定字符串str被记录过,即已经存在。
- 其实若一个字符串str对应的Bit全为1实际上是不能100%的肯定该字符串被Bloom Filter记录过的。因为hash函数可能冲突的原因,正好该字符串的所有位被其他字符串所对应。这种把字符串划分错的情况称为wrong position。
-
Delete操作
字符串加入了就不能删除了,因为删除会影响到其他字符串。实在需要删除字符串的可以使用Counting Bloom Filter(CBF),这是一种基于Bloom Filter的变体,CBF把基于Bloom Filter的每一个Bit改为一个计数器,这样就可以实现删除字符串的功能了。
三、Bloom Filter的优化
考虑到Boom Filter上面的指标,总结一下有以下几个:
m:BitSet位数
n:插入字符串个数
k:hash函数个数
当然,hash函数也是重要的影响因素
从表格来看m/n越大越准,k越大越准。具体设计参考如下:
哈希函数选择
- 哈希函数的选择对性能的影响应该是很大的,一个好的哈希函数要能近似等概率的将字符串映射到各个Bit。
- 选择k个不同的哈希函数比较麻烦,一种简单的方法是选择一个哈希函数,然后送入k个不同的参数。
参数设计
相信大家对于 Bloom Filter 的工作原理都有了一个基本的了解,现在我们来看看在Bloom Filter 中涉及到的一些参数指标:
- 欲插入Bloom Filter中的元素数目: n
- Bloom Filter误判率: P(true)
- BitSet数组的大小: m
- Hash Function的数目: k
欲插入Bloom Filter中的元素数目 n 是我们在实际应用中可以提前获取或预估的;Bloom Filter的误判率 P(true) 则是我们提前设定的可以接受的容错率。所以在设计Bloom Filter过程中,最关键的参数就是BitArray数组的大小 m 和 Hash Function的数目 k,下面将给出这两个关键参数的设定依据、方法。
误判率P(true)
向Bloom Filter插入一个元素时,其一个Hash Function会将BitArray中的某Bit置为1,故对于任一Bit而言,其被置为1的概率 P 1 = 1 − 1 m P1\;=\;1\;-\;\frac1m P1=1−m1,那么其依然是0的概率 P 0 = 1 − P 1 = 1 − 1 m P0\;=\;1\;-\;P1\;=\;1\;-\;\frac1m P0=1−P1=1−m1;易知插入一个元素时,其 k 个Hash Function 都未将该Bit置为1的概率 P 0 1 = ( 1 − 1 m ) k P0^1\;=\;(1\;-\;\frac1m)^k P01=(1−m1)k 。则向Bloom Filter 插入全部n个元素后,该Bit依然为0的概率即为 P 0 n = ( 1 − 1 m ) k n P0^n\;=\;(1\;-\;\frac1m)^{kn} P0n=(1−m1)kn ,反之,该Bit为1的概率则为 P 1 n = 1 − P 0 n = 1 − ( 1 − 1 m ) k n P1^n\;=\;1\;-\;P0^n\;=\;1\;-\;(1\;-\;\frac1m)^{kn} P1n=1−P0n=1−(1−m1)kn 。
由前文可知,判定一个元素存在于Bloom Filter,要求k个Hash Function的哈希值对应的Bit的值均为1。据此,我们可以计算出其误判率 P(true): P ( t r u e ) = ( P 1 n ) k = [ 1 − ( 1 − 1 m ) k n ] k P(true)\;\;=\;(P1^n)^k\;=\;\lbrack1\;-\;(1\;-\;\frac1m)^{kn}\rbrack^k P(true)=(P1n)k=[1−(1−m1)kn]k 。
根据基本极限 lim x → ∞ ( 1 − 1 x ) − x = e \lim_{x\rightarrow\infty}(1\;-\;\frac1x)^{-x}\;=\;e limx→∞(1−x1)−x=e
可知: P ( t r u e ) ≈ ( 1 − e − n k m ) k P(true)\;\approx\;(1\;-\;e^\frac{-nk}m)^k P(true)≈(1−em−nk)k
从上式可以看出,当BitSet数组的大小m增大或欲插入Bloom Filter中的元素数目n减小时,均可以使得误判率P(true)下降。
Hash Function的数目 k
前文已经看到Hash Function数目k的增加可以减小误判率P(true),但是随着Hash Function数目k的继续增加,反而会使误判率P(true)上升,即误判率是一个关于Hash Function数目k的凸函数。所以当k在极值点时,此时误判率即为最小值。
f ( k ) = ( 1 − e − n k m ) k 令 a = e n m , 则 有 : f ( k ) = ( 1 − a − k ) k f(k)\;=\;(1\;-\;e^\frac{-nk}m)^k\;令\;a=e^\frac nm,\mathrm{则有}:f(k)\;=\;(1\;-\;a^{-k})^k f(k)=(1−em−nk)k令a=emn,则有:f(k)=(1−a−k)k
分别对上式两边,先取对数,再对k求一次导,可有:
1 f ( k ) f ( k ) ′ = ln ( 1 − a − k ) + k a − k ln ( a ) 1 − a − k \frac1{f(k)}f(k)'\;=\;\ln\left(1\;-\;a^{-k}\right)\;+\;\frac{ka^{-k}\ln\left(a\right)}{1\;-\;a^{-k}} f(k)1f(k)′=ln(1−a−k)+1−a−kka−kln(a)
易知,当k取极值点时,有 f(k)′=0,故将其带入上式即可求出k
ln ( 1 − a − k ) + k a − k ln ( a ) 1 − a − k = 0 \ln\left(1\;-\;a^{-k}\right)\;+\;\frac{ka^{-k}\ln\left(a\right)}{1\;-\;a^{-k}}\;=\;0 ln(1−a−k)+1−a−kka−kln(a)=0
e − k n m = 1 2 e^\frac{-kn}m\;=\;\frac12 em−kn=21
k = m n ln ( 2 ) ≈ 0.7 m n k\;=\;\frac mn\ln\left(2\right)\;\approx\;0.7\frac mn k=nmln(2)≈0.7nm
此时,我们即可以利用上式的结果,通过m和n来确定最优的Hash Function数目k
BitSet数组的大小 m
如何确定BitSet数组的大小 m 呢?这里,我们联立 P(true)、k 的公式,即可解出 m
P ( t r u e ) ≈ ( 1 − e − n k m ) k P(true)\;\approx\;(1\;-\;e^\frac{-nk}m)^k P(true)≈(1−em−nk)k
k = m n ln ( 2 ) k\;=\;\frac mn\ln\left(2\right) k=nmln(2)
联立后得:
P ( t r u e ) = ( 1 − e − ln ( 2 ) ) m n ln ( 2 ) = 1 2 m n ln ( 2 ) ≈ 0.618 5 m n P(true)\;=\;(1\;-\;e^{-\ln\left(2\right)})^{\frac mn\ln\left(2\right)}\;=\;\frac12^{\frac mn\ln\left(2\right)}\;\approx\;0.6185^\frac mn P(true)=(1−e−ln(2))nmln(2)=21nmln(2)≈0.6185nm
对上式求解,可得:
ln ( P ( t r u e ) ) = m n ln ( 2 ) ln ( 1 2 ) \ln\left(P(true)\right)\;=\;\frac mn\ln\left(2\right)\ln\left(\frac12\right) ln(P(true))=nmln(2)ln(21)
四、python代码实现
import redis
from hashlib import md5
class SimpleHash(object):
def __init__(self, cap, seed):
self.cap = cap
self.seed = seed
def hash(self, value):
ret = 0
for i in range(len(value)):
ret += self.seed * ret + ord(value[i])
return (self.cap - 1) & ret
class BloomFilter(object):
def __init__(self, host='localhost', port=6379, db=0, blockNum=1, key='bloomfilter'):
"""
:param host: the host of Redis
:param port: the port of Redis
:param db: witch db in Redis
:param blockNum: one blockNum for about 90,000,000; if you have more strings for filtering, increase it.
:param key: the key's name in Redis
"""
self.server = redis.Redis(host=host, port=port, db=db)
self.bit_size = 1 << 31 # Redis的String类型最大容量为512M,现使用256M
self.seeds = [5, 7, 11, 13, 31, 37, 61]
self.key = key
self.blockNum = blockNum
self.hashfunc = []
for seed in self.seeds:
self.hashfunc.append(SimpleHash(self.bit_size, seed))
def isContains(self, str_input):
if not str_input:
return False
m5 = md5()
m5.update(str_input.encode('utf-8'))
str_input = m5.hexdigest()
ret = True
name = self.key + str(int(str_input[0:2], 16) % self.blockNum)
for f in self.hashfunc:
loc = f.hash(str_input)
ret = ret & self.server.getbit(name, loc)
return ret
def insert(self, str_input):
m5 = md5()
m5.update(str_input.encode('utf-8'))
str_input = m5.hexdigest()
name = self.key + str(int(str_input[0:2], 16) % self.blockNum)
for f in self.hashfunc:
loc = f.hash(str_input)
self.server.setbit(name, loc, 1)
if __name__ == '__main__':
""" 第一次运行时会显示 not exists!,之后再运行会显示 exists! """
bf = BloomFilter()
if bf.isContains('http://www.tencent.com'): # 判断字符串是否存在
print('exists!')
else:
print('not exists!')
bf.insert('http://www.tencent.com')
说明:
- Bloom Filter算法如何使用位去重,这个百度上有很多解释。简单点说就是有几个seeds,现在申请一段内存空间,一个seed可以和字符串哈希映射到这段内存上的一个位,几个位都为1即表示该字符串已经存在。插入的时候也是,将映射出的几个位都置为1。
- 需要提醒一下的是Bloom Filter算法会有漏失概率,即不存在的字符串有一定概率被误判为已经存在。这个概率的大小与seeds的数量、申请的内存大小、去重对象的数量有关。上面有一张表,m表示内存大小(多少个位),n表示去重对象的数量,k表示seed的个数。例如我代码中申请了256M,即1<<31(m=2^31,约21.5亿),seed设置了7个。看k=7那一列,当漏失率为8.56e-05时,m/n值为23。所以n = 21.5/23 = 0.93(亿),表示漏失概率为8.56e-05时,256M内存可满足0.93亿条字符串的去重。同理当漏失率为0.000112时,256M内存可满足0.98亿条字符串的去重。
- 基于redis的Bloom Filter去重,其实就是利用了redis的String数据结构,但redis一个String最大只能是512M,所以如果去重的数据量大,需要申请多个去重块(代码中blockNum即表示去重块的数量)。
- 代码中使用了MD5加密压缩,将字符串压缩到了32个字符(也可用hashlib.sha1()压缩成40个字符)。它有两个作用,一是Bloom Filter对一个很长的字符串哈希映射的时候会出错,经常误判为已存在,压缩后就不再有这个问题;二是压缩后的字符为 0~f 共16种可能,我截取了前两个字符,再根据blockNum将字符串指定到不同的去重块进行去重。
总结
基于redis的Bloom Filter去重,既用上了Bloom Filter的海量去重能力,又用上了redis的可持久化能力,基于redis也方便分布式机器的去重。在使用的过程中,要预算好待去重的数据量,则根据上面的表,适当地调整seed的数量和blockNum数量(seed越少肯定去重速度越快,但漏失率越大)。
欢迎关注我的微信公众号:丸子打豆豆