一. 简介
Bloom Filter是一种空间效率很高的随机数据结构,它利用位数组很简洁地表示一个集合,并能判断一个元素是否属于这个集合。
Bloom Filter的这种高效是有一定代价的:在判断一个元素是否属于某个集合时,有可能会把不属于这个集合的元素误认为属于这个集合(false positive 假阳性)。
因此,Bloom Filter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter通过极少的错误换取了存储空间的极大节省。
二. 应用场景
在已知大量数据的前提下,经常要判断一个元素是否在一个集合中,且不要求完全精确。
- 字处理软件中,需要检查一个英语单词是否拼写正确。
- 网络爬虫里,一个网址是否被访问过。
- 某个请求是否有缓存数据。
Bloom Filter不是开创了这些场景的解决问题,而是这些场景数据量积累到一定程度可以用Bloom Filter来进行优化。
三. 原理
3.1 设计思路
3.1.1 HashMap瓶颈
我们说想要实现快速查找功能,第一个想到的数据结构就是HashMap。
但因为数据量很大的情况,HashMap占用的资源太大了,就算包装的是int在java中也要占用32bit。
并且我们只想实现快速查找key,并不需要Value,使用HashMap结构有些臃肿。
3.1.1 借鉴位图设计
说到减少占用空间,位图是一种常见的思路。
我们可以借鉴位图的思想,采用二进制数组下标表示数据映射,数组中的二进制表示映射是否存在,存在是1,不存在就是0。
那么问题又来了,没有HashMap的链表结构,如何解决hash冲突呢,比如张三,和李四的hash值一样,仅靠对应的位置是1无法确定是张三还是李四。
3.1.2 多哈希散列
针对这个问题,我们使用多个hash函数,k1冲突了,k2不冲突依旧能保证数据准确性。
查找时同理去过多个hash函数,如果全命中就认为存在。
这里如果hash函数的数量少,可能会有一些样本在两个函数的表现恰好都是1,这种就属于false positive 假阳性,理论上讲,hash函数数量越多,hash算法匹配重合度越低,false positive rate 也就越低,效果就越好。
3.2 图解流程
位数组 + 多个hash函数 初始状态时,Bloom Filter是一个包含m位的位数组,每一位都置为0。
为了表达S={x1, x2,…,xn}这样一个n个元素的集合,Bloom Filter使用k个相互独立的哈希函数(Hash Function),它们分别将集合中的每个元素映射到{1,…,m}的范围中。
对任意一个元素x,第i个哈希函数映射的位置hi(x)就会被置为1(1≤i≤k)。注意,如果一个位置多次被置为1,那么只有第一次会起作用,后面几次将没有任何效果。
在下图中,有两个哈希函数选中同一个位置(从左边数第五位)。
在判断y是否属于这个集合时,我们对y应用k次哈希函数,如果所有hi(y)的位置都是1(1≤i≤k),那么我们就认为y是集合中的元素,否则就认为y不是集合中的元素。
下图中y1就不是集合中的元素。y2或者属于这个集合,或者刚好是一个false positive。
3.3 Bloom Filter调优
基于上面的理论可知,布隆过滤器根据实际的业务场景还有调优空间。
比如:减少错误率,最优的哈希函数个数,位数组的最佳大小等等。
推导方式要有一定数学基础,这里不过多扩展,有意请看传送门。
四. 方案落地
4.1 需求--排除垃圾邮件
公众电子邮件(email)提供商,现在需要过滤来自发送垃圾邮件的人的垃圾邮件。
由于那些发送者不停地在注册新的地址,全世界少说也有几十亿个发垃圾邮件的地址。
4.2 传统方案的弊端
如果我们用HashMap来存储那些垃圾邮件的email地址,假设存储一个email地址需要八字节。
为了保证Hash Table在 key/value 查找模式中的优势,一般,其存储效率不会超过50%。
因此一个 email 地址需要占用十六个字节。一亿个地址大约要 1.6GB, 即十六亿字节的内存。因此存贮几十亿个邮件地址可能需要上百 GB 的内存。
4.3 使用Bloom Filter
假定我们存储一亿个电子邮件地址,我们先建立一个十六亿二进制(比特)。
然后将这十六亿个二进制位全部设置为零。对于每一个电子邮件地址X,我们用八个不同的随机数产生器(F1,F2, ...,F8) 产生八个信息指纹(f1, f2, ..., f8)。再用一个随机数产生器G把这八个信息指纹映射到[1 ~十六亿]中的八个自然数 g1, g2, ...,g8。
现在我们把这八个位置的二进制位全部设置为一。当我们对这一亿个 email 地址都进行这样的处理后。一个针对这些 email 地址的布隆过滤器就建成了。
从原来的16亿字节变成了16亿比特,只需要哈希表 1/8 到 1/4 的大小就能解决同样的问题。
4.4 代码实现
public class BloomFilterApply {
public static void main(String[] args) {
String value = "xxxxxxx@168.cn";
SimpleBloomFilter filter = new SimpleBloomFilter();
System.out.println(filter.contains(value));
filter.add(value);
System.out.println(filter.contains(value));
}
static class SimpleBloomFilter {
private static final int DEFAULT_SIZE = 2 << 24;
// hash种子,hash函数用
private static final int[] seeds = new int[]{5, 7, 11, 13, 31, 37, 61};
// 初始化位数组
private BitSet bits = new BitSet(DEFAULT_SIZE);
private SimpleHash[] func = new SimpleHash[seeds.length];
public SimpleBloomFilter() {
// 初始化hash函数组
for (int i = 0; i < seeds.length; i++) {
func[i] = new SimpleHash(DEFAULT_SIZE, seeds[i]);
}
}
public void add(String value) {
// 遍历每个hash函数,设置对应的hash槽位
for (SimpleHash f : func) {
// BitSet true => 1 false => 0, 默认 0
bits.set(f.hash(value), true);
}
}
public boolean contains(String value) {
if (value == null) {
return false;
}
boolean ret = true;
// 遍历每个hash函数,观察是否命中
for (SimpleHash f : func) {
// 只要一个不满足是false,结果就一定是false
ret = ret && bits.get(f.hash(value));
}
return ret;
}
/**
* hash函数
*/
public static class SimpleHash {
private int cap;
private int seed;
public SimpleHash(int cap, int seed) {
this.cap = cap;
this.seed = seed;
}
public int hash(String value) {
int result = 0;
int len = value.length();
for (int i = 0; i < len; i++) {
result = seed * result + value.charAt(i);
}
return (cap - 1) & result;
}
}
}
}
4.5 补偿机制
由于Bloom Filter有误判的概率,有可能某个好的邮件地址正巧对应八个都被设置成一的二进制位。有极小的可能将一个不在黑名单中的电子邮件地址判定为在黑名单中。
在上面的例子中,误识概率在万分之一以下。 常见的补救办法是在建立一个小的白名单,存储那些可能别误判的邮件地址。