布隆过滤器是一种数据结构,具有快速插入和查找的特性,能确定某个字符串一定存在或者可能存在。布隆过滤器有着高效的空间利用率,它不存储具体数据,只存储数据的关键标识,所以占用的空间较小。它的查询结果可能会存在一定误差,但是误差总体可控,同时不支持删除操作。布隆过滤器的应用场景丰富,在任何仅需要知道数据是否存在,并不关心具体数据内容的场景都可以使用布隆过滤器,例如在网页爬虫中URL去重防止重爬、可以应用在缓存系统中,避免缓存穿透等问题、在安全领域,也可以使用它来快速判断一个请求是否属于黑名单ip,防止恶意攻击等。
布隆过滤器拥有的快速插入和查找的特性是否很像散列表?普通散列表一般依赖数组实现,而布隆过滤器为了节省空间,使用的Bitmap
结构,即位图。
位图本质上其实也是散列表的一种实现,不同的是位图节省空间体现在它利用二进制位存储数据状态。我们知道在ASCII编码中,一个英文字符占用一个字节(Byte),也就是8位(bit)。若是UTF8编码,中文或者特殊字符可能会占用更多字节。例如存在一个数组如下:
Integer[] array = new Integer[5]{0,1,3,5,7};
以上的数组中,不考虑数组对象本身占用的空间,只计算元素空间,每个元素若只占8位,存储这5个元素就要占用40位。假如用二进制位存储这5个元素,则只需要8位即可。
如上图,对应槽位的二进制位中,0代表不存在元素,1表示存在元素,当我们需要查询是否存在某个数字时,只需要看对应槽位的值是不是1即可,这样只需要一个8位空间即可表示0,1,3,5,7这几个元素了。
上面说过,布隆过滤器是依赖位图实现的,它的原理是在位图的基础之上,定义了若干个散列函数。当需要向布隆过滤器中插入数据时,首先利用这几个散列函数分别计算出散列值,并且将位图上对应槽位的值设置成1,如果已经是1的话就不做任何操作。需要检查某个数据是否存在时也是同理,计算出要检查的数据的多个散列值,并且检查位图中对应槽位是否全都为1,但凡有一位不为1就可以断定值不存在,都为1的话值可能存在。
为什么是可能存在呢?随着计算的数据越来越多,查询的结果也会慢慢出现一定几率误差。因为散列函数存在结果碰撞的问题,两个不同的值通过散列函数计算出的结果有概率相同。所以当某个值即便从来没有被插入过,通过这多个散列函数计算的出的槽位也可能和之前值相同,所以会误判为已存在。为了降低误差几率,需要按照具体需求调整散列函数的算法\个数和位图大小,确保通过散列函数计算出来的槽位足够均匀分布到位图上。
布隆过滤器不支持删除操作,是因为在位图中每个槽位只存在两种状态,即0和1。一个槽位被设置为1,但无法确定它被多少个关键字,通过哪些散列函数设置了多少次1,只知道它被置为了1,所以无法删除。
Google提供的guava工具里面包含了布隆过滤器的实现。
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>30.0-jre</version> </dependency>
public static void main(String[] args) { Integer size = 1000000; BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8), size, new Double(0.001)); for (Integer i = 0; i < size; i++) { bloomFilter.put(String.valueOf(i)); } Integer count = 0; for (Integer i = size; i < size * 2; i++) { if (bloomFilter.mightContain(String.valueOf(i))) { count++; } } System.out.println("误判率:" + count / new Double(size)); }
误判率:0.00101 Process finished with exit code 0
测试结果和我们配置的参数大致吻合。
缓存穿透问题的产生,是当有大量的恶意流量去请求系统中不存在的数据时,缓存中没有对应数据,导致请求直接去查数据库,使数据库承受压力。而将布隆过滤器放在缓存之前则可以避免此问题,每当有流量来请求数据时会先在布隆过滤器中查询,请求是否是系统中的正常数据,如果是则放行流量去查缓存或数据库,否则直接忽略请求或者执行其他处理策略。