问题描述
在开发过程中,经常要判断一个元素是否在一个集合中。假设你现在要给项目添加IP黑名单功能,此时你手上有大约 1亿个恶意IP的数据集,有一个IP发起请求,你如何判断这个IP在不在你的黑名单中?
类似这种问题用Java自己的Collection和Map很难处理,因为它们存储元素本身,会造成内存不足,而我们只关心元素存不存,对于元素的值我们并不关心,具体值是什么并不重要。
解决方案
BloomFilter(布隆过滤器)
布隆过滤器可以用来判断某个元素是否在集合内,具有运行快速,内存占用小的特点,它是一个保存了很长的二级制向量,同时结合 Hash 函数实现的。而高效插入和查询的代价就是,它是一个基于概率的数据结构,只能告诉我们一个元素绝对不在集合内,对于存在集合内有一定的误判率。
fpp
因为布隆过滤器中总是会存在误判率,因为哈希碰撞是不可能百分百避免的。布隆过滤器对这种误判率称之为假阳性概率,即:False Positive Probability,简称为 fpp。
在实践中使用布隆过滤器时可以自己定义一个 fpp,然后就可以根据布隆过滤器的理论计算出需要多少个哈希函数和多大的位数组空间。需要注意的是这个 fpp 不能定义为 100%,因为无法百分保证不发生哈希碰撞。
下图表示向布隆过滤器中添加元素 https://blog.csdn.net/bookssea 和 https://www.abc.com 的过程,它使用了 func1 和 func2 两个简单的哈希函数。
对写入的数据做 H 次 hash 运算定位到数组中的位置,同时将数据改为 1 。当有数据查询时也是同样的方式定位到数组中。 一旦其中的有一位为 0 则认为数据肯定不存在于集合,否则数据可能存在于集合中。
通过其原理可以知道,可我们可以提高数组长度以及 hash 计算次数来降低误报率,但是相应的 CPU、内存的消耗也会相应的提高;这需要我们根据自己的业务需要去权衡选择。
布隆过滤器的特点
布隆过滤有以下2个特点:
- 只要返回数据不存在,则肯定不存在。
- 返回数据存在,但只能是大概率存在。
在有限的数组长度中存放大量的数据,即便是再完美的 Hash 算法也会有冲突,所以有可能两个完全不同的 A、B 两个数据最后定位到的位置是一模一样的。这时拿 B 进行查询时那自然就是误报了。
布隆过滤器中的数据可不可以删除
布隆过滤器判断一个元素存在就是判断对应位置是否为 1 来确定的,但是如果要删除掉一个元素是不能直接把 1 改成 0 的,因为这个位置可能存在其他元素,所以如果要支持删除,最简单的做法就是加一个计数器,就是说位数组的每个位如果不存在就是 0,存在几个元素就存具体的数字,而不仅仅只是存 1,那么这就有一个问题,本来存 1 就是一位就可以满足了,但是如果要存具体的数字比如说 2,那就需要 2 位了,所以带有计数器的布隆过滤器会占用更大的空间。
<dependency> <groupId>com.baqend</groupId> <artifactId>bloom-filter</artifactId> <version>1.0.7</version> </dependency>
新建一个带有计数器的布隆过滤器 CountingBloomFilter:
package com.lonelyWolf.redis.bloom; import orestes.bloomfilter.FilterBuilder; public class CountingBloomFilter { public static void main(String[] args) { orestes.bloomfilter.CountingBloomFilter<String> cbf = new FilterBuilder(10000, 0.01).countingBits(8).buildCountingBloomFilter(); cbf.add("zhangsan"); cbf.add("lisi"); cbf.add("wangwu"); System.out.println("是否存在王五:" + cbf.contains("wangwu")); //true cbf.remove("wangwu"); System.out.println("是否存在王五:" + cbf.contains("wangwu")); //false } }
构建布隆过滤器前面 2 个参数一个就是期望的元素数,一个就是 fpp 值,后面的 countingBits 参数就是计数器占用的大小,这里传了一个 8 位,即最多允许 255 次重复,如果不传的话这里默认是 16 位大小,即允许 65535次重复。
建议使用Guava自带的布隆过滤器,直接传入预期的数据量以及fpp,它会自动帮我们计算数组长度和哈希次数
布隆过滤器应该设计为多大?
假设在布隆过滤器里面有 k 个哈希函数,m 个比特位(也就是位数组长度),以及 n 个已插入元素,错误率会近似于 (1-ekn/m)k,所以你只需要先确定可能插入的数据集的容量大小 n,然后再调整 k 和 m 来为你的应用配置过滤器。
布隆过滤器应该使用多少个哈希函数?
对于给定的 m(比特位个数)和 n(集合元素个数),最优的 k(哈希函数个数)值为: (m/n)ln(2)
布隆过滤器的时间复杂度和空间复杂度?
对于一个 m(比特位个数)和 k(哈希函数个数)值确定的布隆过滤器,添加和判断操作的时间复杂度都是 O(k),这意味着每次你想要插入一个元素或者查询一个元素是否在集合中,只需要使用 k 个哈希函数对该元素求值,然后将对应的比特位标记或者检查对应的比特位即可。
Guava的布隆过滤器的实现
Guava有自带的布隆过滤器的实现
public class BloomFilterTest { public static void main(String[] args) { long star = System.currentTimeMillis(); BloomFilter<Integer> filter = BloomFilter.create( Funnels.integerFunnel(), //预计存放多少数据 10000000, //可以接受的误报率 0.01); for (int i = 0; i < 10000000; i++) { filter.put(i); } Assert.isTrue(filter.mightContain(1),"不存在"); Assert.isTrue(filter.mightContain(2),"不存在"); Assert.isTrue(filter.mightContain(3),"不存在"); Assert.isTrue(filter.mightContain(10000000),"不存在"); long end = System.currentTimeMillis(); System.out.println("执行时间:" + (end - star)); } }
BitMap
BitMap不会存在误判的情况,位图也是布隆过滤器的实现,但是占用内存空间随集合内最大元素的增大而增大。而布隆过滤器,因为其可能一个bit为多个元素作标识,这就保证了它的空间利用率。这2种方式根据业务进行选择。
以32位整型为例,它可以表示数字的个数为2^32. 可以申请一个位图,让每个整数对应的位图中的一个bit,这样2^32个数需要的位图的大小为512MB。具体实现的思路为:申请一个512MB的位图,并把所有的位都初始化为0;接着遍历所有的整数,对遍历到的数字,把相应的位置上的bit设置为1.最后判断待查找的数对应的位图上的值是多少,如果是0,那么表示这个数字不存在,如果是1,那么表示这个数字存在。
Java中有BitMap的实现类,BitSet
public class BitMapTest { public static void main(String[] args) { int[] array = {3, 8, 5, 7, 1}; BitSet bitSet = new BitSet(5); for (int i = 0; i < array.length; i++) { bitSet.set(array, true); } bitSet.stream().forEach(e -> System.out.println(e)); } }