在海量或高并发应用场景,有一种需求是确认某条记录是否已经存在。例如对搜索引擎而言,它需要快速判断某个网页是否已经收录,通常引擎要存储几十上百亿个网页,要在如此巨量的数据中查找一条记录,用大海捞针来形容确实不为过。
海量或是高并发场景应对的需求总是有一种“要马跑的尽可能快,又要马吃尽可能少的草”的味道。如果面试中有这类问题,面试官总是期待你拿出的方案既要使用尽可能少的内存,然后速度又要尽可能的快。好在还真有方法能满足这些需求,针对“一条记录在海量数据中是否存在”这样的要求,布隆过滤器就能很好的满足内存需求少,查询速度快。
要实现布隆过滤器,我们需要两个条件,一个是需要含有m个元素的数组,一个是含有k个哈希函数的集合。由于我们只需要确定“是否存在”因此数组使用bit就行,而每个哈希函数,它们输出的结果是0到m-1之间的整数。需要注意的是,一条记录并不会只对应m个bit中的某一个,而是对于其中的k个bit,k是我们预先定义好的常数,也就是说无论记录的内容有多长,对网页而言,记录的内容即使url,无论这个url是10个字节还是1024个字节,我们都只用k个bit来对应它。
当需要插入一条记录时,我们使用k个哈希函数来计算出k个下标,然后在数组中将对应下标的bit设置为1。有一个难点是,如果哈希函数给出相同结果怎么办,在理论上哈希函数产生的结果发送碰撞无法避免,但是我们可以尽肯能降低碰撞发送的概率,因此我们可以采用以下做法:
1,使用哈希函数生成器,例如函数H(i)被调用后,它返回一个哈希函数,输入的参数i不同,它就能产生不同的哈希函数。在布隆过滤器初始化时,我们就需要使用这样的生成器生成k个不同的哈希函数。
2,使用双重或三重哈希。这里我们将采用双重哈希,这两个哈希函数分别名为Murmur和Fowler-Noll-Vo ,这两个函数有某些特定性质使得他们生成的结果会尽可能少的产生碰撞。我们使用这两个哈希函数来实现步骤1,也就是用这两个哈希函数来构建哈希函数生成器,假设我们要从生成器中创建第i个哈希函数,那么可以执行的步骤就是:
h i ( k e y ) = m u r m u r h a s h ( k e y ) + i + ∗ f n v 1 ( k e y ) + i ∗ i {h}_{i}(key)=murmurhash(key)+i+*fnv1(key)+i*i hi(key)=murmurhash(key)+i+∗fnv1(key)+i∗i
这里murmurshash对应的就是Murmur哈希,fnv1对应的就是Foler-Noll-Vo哈希,h(i)就是从生成器中创建出来的第i个哈希函数。对python而言,它们都有相应的包可以直接调用,例如要使用murmur哈希函数,使用如下命令进行安装:
pip install mmh3
对fnv1哈希函数而言,使用如下命令进行安装:
pip install fnvhash
接着我们先实现布隆过滤器的初始化代码:
def init_bloom_filter(records, min_size):
size = max(2 * len(records), min_size)
bloom_filter = BloomFilter(size)
for record in records:
bloom_filter.insert(record)
return bloom_filter
初始化布隆过滤器时,需要传入当记录,同时给定过滤器所支持的最小记录数。接下来我们看看给定一条记录,如何查询该记录是否已经存在:
def check_record(bloom_filter, record):
if bloom_filter.contains(record):
return True
return False
def add_record(bloom_filter, record):
bloom_filter.insert(record)
接下来我们需要做的就是实现布隆过滤器。由于我们需要在m个bit中取出k个以便用来对应某一条记录,因此我们需要完成如下工作:
1,根据下标i来读取或写入给定比特位
2,给定一条记录,确定对应的k个比特位
3,使用k个哈希函数生成k个比特位对应的下标。
我们使用byte数组来作为布隆过滤器的底层数组,但是一个Byte对应8个bits因此要找到下标为k的bit,我们需要做一些转换运算:
BITS_IN_BYTE = 8
def find_bit_coordinates(index):
bit_index = math.floor(index / BITS_IN_BYTE)
bit_offset = index % BITS_IN_BYTE
return bit_index, bit_offset
def read_bits(bits_array, index):
# 获取给定比特位的位置然后读取
element, bit = find_bit_coordinates(index)
return (bits_array[element] & (1 << bit)) >> bit
def write_bits(bits_array, index):
element, bit = find_bit_coordinates(index)
bits_array[element] = bits_array[element] | (1 << bit)
上面实现的函数分别是根据下标查找指定比特位,读取和写入指定比特位,由于我们使用byte array来作为布隆过滤器的底层数组,而一个byte对应8个bit,所以在查找时我们需要除以8和对8求余,我们实验一下上面实现的函数:
if __name__ == '__main__':
bits_array = bytearray([157, 25, 44, 204])
'''
在数组中查找下标为19的比特位,element=2, bit=3,返回结果为1
'''
bit_read = read_bits(bits_array, 19)
print("bit read for offset {} is {}".format(19, bit_read))
'''
将下标为15的比特位设置为1,于是数组内容会变成:
[157, 153, 44, 204]
'''
write_bits(bits_array, 15)
for byte in bits_array:
print("byte is : {}".format(byte))
上面代码运行后可以发现结果跟预料的一致。接下来的工作是,给定一条记录后,我们需要找到该记录对应的k个比特位,并将它们设置为1.前面我们使用两个哈希函数进行组合来生成k个哈希函数,于是我们使用 h i {h}_{i} hi来分别生成k个比特位对应的下标,相应实现如下:
def key_to_positions(k, num_bits, seed, record):
bit_positions = []
for i in range(k):
hm = mmh3.hash(record, seed)
hf = fnv1a_32(str.encode(record))
bit_positions.append((hm + i * hf + i*i) % num_bits)
return bit_positions
bit_positions对应k个比特位的下标,在理论上bit_positions数组中存在相同下标的概率会非常小,有了这些基础函数后,我们就可以定义布隆过滤器的实现:
import math
import mmh3
from fnvhash import fnv1a_32
from random import randrange
from numpy import log as ln
class BloomFilter:
def __init__(self, max_size, max_tolerance=0.01, seed=randrange(1024)):
self.size = 0
self.max_size = max_size
self.seed = seed
#为了确保给定精确度我们需要设置底层数组的长度
self.num_bits = -math.ceil(max_size * ln(max_tolerance) / ln(2) / ln(2))
self.k = -math.ceil(ln(max_tolerance) /ln(2))
self.bits_array = bytearray({})
num_elements = math.ceil(self.num_bits / BITS_IN_BYTE)
for i in range(num_elements):
self.bits_array.append(0)
在上面代码中有几点需要解释一下,布隆过滤器不是一个完全精准的过滤器,它存在一定的概率会出错,也就是某个记录已经存储了,但它可能会返回说记录不存在,这种出错的概率对应代码中的max_tolerance,要想出错的概率越小,那么要求数组的元素就得越大,同时记录对应的比特位数,也就是数值k也需要相应增大,出错的概率和数组大小的相关性可以从数学上精确计算出来。
数学理论先放在一边,我们先看看如何判定一个记录是否已经存储,假设给定两个字符串:str1 = “hello”, str2 = “world”,k = 3,于是我们分别用构造的3个哈希函数计算他们对应的bit,如果第一个字符串计算的结果为: h 0 ( s t r 1 ) h_{0}(str1) h0(str1)=9, h 1 ( s t r 1 ) h_{1}(str1) h1(str1) = 2,