一、问题背景
假设一家浏览器公司收集了若干个包含违法内容的网站,构成一个url的黑名单。每次用户访问一个网址时,都要先判断是不是命中了黑名单,如果是则拦截。这里,黑名单库不需要执行删除操作,这是讨论布隆过滤器的关键。
1.1 问题本质
设计一个集合的数据结构,这个结构支持添加、查询的功能,不需要支持删除功能。
1.2 传统做法
显而易见,hashset是最直接的做法。添加和查询的时间代价都很低,但是问题在于空间复杂度。假设黑名单中有100亿个url,每个url大小是64B。那么所需要的内存空间就是大约640GB。这需要平台提供至少10个拥有64GB内存的服务器,代价比较大。
布隆过滤器通过付出牺牲一定准确率的代价,大幅度降低了空间复杂度。
二、布隆过滤器
2.1 第一类错误和第二类错误
上文提到,布隆过滤器不能做到查询的准确率100%。那么我们首先需要区分一下查询错误的类型。
第一类错误:用户访问了黑名单里的url,但是系统没有识别到,允许用户访问。
第二类错误:用户访问了合法的url,但是系统误杀了,不允许用户访问。
布隆过滤器可以避免第一类错误,第二类错误无法避免。但是,布隆过滤器可以控制第二类错误发生的概率小于任意。
从业务的视角理解,第一类错误是绝对不能容忍的,因为它可能会导致浏览器不合规、公司被举报等问题。但是第二类错误,只要能够有效控制错误率,比如在万分之一以下,那么损失相对是比较小的。比如用户登不了某个网站,可以换一个功能类似的网站。这种损失既是可控的,同时还能节省大量的空间成本,对于业务方也是可以接受的。
2.2 二进制数组
为了介绍布隆过滤器,我们首先需要介绍bit array的概念。我们平常看到的数组多数是基础或引用类型的,比如int[10],本质上存储在计算机中的是10*32个0-1值;long[10],本质上存储在计算机中的是10*64个0-1值。
现在我们希望引入一种二进制数组的概念,即bit[100],共有100个元素,每一个元素都是0-1值。虽然java没有这种结构,但我们可以通过基础类型int,充分的利用位运算来实现。
class BitArray{
private int[] intArray;
private int numIndex;
private int bitIndex;
// 初始化一个长度为length的位数组
BitArray(int length){
intArray = new int[length / 32 + 1];
}
// 得到第i位的状态
private int get(int index){
numIndex = index / 32;
bitIndex = index & 32;
return (intArray[numIndex] >> bitIndex) & 1;
}
// 把位数组的第i位变成1
private void changeTo1(int index){
numIndex = index / 32;
bitIndex = index & 32;
intArray[numIndex] = intArray[numIndex] | (1 << bitIndex);
}
// 把位数组的第i位变成0
private void changeTo0(int index){
numIndex = index / 32;
bitiNDEX = index % 32;
intArray[numIndex] = intArray[numIndex] & (~(1 << bitIndex));
}
}
这里我们如何理解这个intArray呢?比如说一个数组int[10],数组中的每个数都能唯一对应一个32位的二进制数,那么这个数组就可以表征一个二进制数组bit[320]。通过位运算,我们可以实现数组的修改和查询操作。
2.3 布隆过滤器的机制
布隆过滤器,采用二进制数组来表征黑名单库,具体操作如下:
初始化:新建一个长度为m的二进制数组,再设计k个独立的哈希函数 。
添加操作:对于一个黑名单url,分别经过k个哈希函数+模m的操作映射成0到m-1之间的值。于是,我们把二进制数组的这k个位置的值变成1。
(注:哈希函数的随机性保证了,100亿个url经过映射后,会等概率的分布在0到m-1)
查询操作:对于一个陌生的url访问,同样经过k个哈希函数+取模的操作,拿到0到m-1之间的k个值。如果二进制数组的这k个位置均为1,视为命中黑名单;否则视为未命中。
3 布隆过滤器的性能分析
3.1 两类错误
第一类错误可以完全避免。因为如果一个url在黑名单里,那么它一定曾经执行过添加操作,二进制数组中它对应的k个位置一定都是1,不可能会被漏掉。
第二类错误是有可能发生的。比如说,黑名单中只有2个url,共有3个哈希函数,一个映射成第1、2、3位,另一个映射成第3、6、9位。新访问的url和这两个都不一样,但是它经过哈希映射后对应的是第2、3、6位,因为这3位都已经变成1,所以会被误识别为命中。
3.2 第二类错误率与k、m的关系
设黑名单的网址数量为n,哈希函数的数量为k,二进制数组的长度为m,第二类错误的概率为P。
固定n和k,那么m越大,不同的黑名单url经过哈希映射后,发生碰撞的概率就越低,因此P也就越低。P关于m是递减的趋势。
固定n和m,那么随着k的增大,查询匹配的难度会逐渐上升。在执行查询操作时,如果只有1个哈希函数,那么一个正常的url,只需要命中一个1的位置,就会被误判。而如果有多个哈希函数,则需要同时满足多个位置同时凑巧都为1才会误判,误判率会变低。因此误判率P在k较小时会呈现递减趋势;但是如果k变得非常大,那么经过一系列添加操作之后,很可能二进制数组中的m位都变成1了,P会变得很高。因此P关于k是先递减后递增的趋势。
3.3 性能分析
固定样本量n和要求的错误率P,那么所需要的m和k可通过如下公式计算:
假设n为100亿,P为万分之一,经过公式计算,我们可以得到m,再计算出对应的存储空间为26G,相较于传统方法的640G,有了极大的改善,而我们只付出了万分之一的错误率的代价。
值得注意的是,布隆过滤器的所需空间是不取决于样本本身的数据量的,不管一条url的大小是64B还是32B都没有关系,我们只需要哈希函数能够接受64B大小的输入即可,经过映射加取模之后,一律对应m维的二进制数组。这也是性能提升的重要因素之一。