背景
如果我们使用缓存,那样会带来缓存三大问题,缓存穿透、缓存雪崩、缓存击穿。这里针对缓存穿透并使用布隆过滤器解决。
缓存穿透就是有心用户利用缓存和数据库都必不存在的数据来发送恶意请求,从而绕过缓存,直接访问数据库,最终导致数据库崩溃的问题。
这是一个通用的问题,关键就在于我们怎么知道请求的 key 在我们的数据库里面是否存在,如果数据量特别大的话,我们怎么去快速判断。
这个问题是如何在海量元素中(例如 10 亿无序、不定长、不重复)快速判断一个元素是否存在。
这个问题涉及两个关键点:海量数据、快速判断。
如果我们直接把这些元素的值放到基本的数据结构Set里面,会十分占用空间。
所以,我们存储这几十亿个元素,不能直接存值,我们应该找到一种最简单的最节省空间的数据结构,用来标记这个元素有没有出现。这个东西我们就把它叫做位图,他是一个有序的数组,只有两个值,0 和 1。0 代表不存在,1 代表存在。
要让这个数组标记这些元素是否存在,必须有一个映射方法。
这个映射方法需要符合以下基本要求:
1)因为值长度是不固定的,所以希望不同长度的输入,可以得到固定长度的输出。
2)转换成下标的时候,希望在这个有序数组里面是分布均匀的,不然的话全部挤到一对去了,我也没法判断到底哪个元素存了,哪个元素没存。
结合上面两个要求,使用分布性优良哈希函数加上相应取模方法,可以得到相应下标。
具体如上图,数据经过哈希计算并取模得到相应的数组下标,并把该下标值置1,表示存在。
然后到时判断数据是否存在时,只需要把数据用相应的函数计算出下标,再查看对应数据元素是否为1,1则存在,0则不存在。
由于会出现哈希碰撞,此时YaoMing和Kobe Bryant计算出了相同的下标。所以此时使用该方法判断数据是否存在就会出现误差,比如假如Kobe Bryant实际上是不存在,但是YaoMing数据已经把下标6的元素置1了,然后Kobe Bryant经过运算得到下标为6,此时他去查看6元素是否为1,因为YaoMing已经把他置1了,所以会判断Kobe Bryant是存在,但是实际上它是不存在的。
因为哈希冲突会导致判断处弱,所以要尽量减少哈希冲突的概率。方法有:
- 增大位图数组的容量,因为我们的函数是分布均匀的,所以,位图容量越大,在同一个位置发生哈希碰撞的概率就越小,但是位图数组容量增大意味着会增大内存的消耗,所以不能不讲道理地扩大位图容量,应该是在错误率和位图容量中平衡取值。
- 如果数据经过一次哈希计算,得到的相同下标的概率比较高,所以可以计算多次呢? 原来只用一个哈希函数,现在对于每一个要存储的元素都用多个哈希函数计算,这样每次计算出来的下标都相同的概率就小得多了。但是 我们也不讲道理地使用很多次的哈希计算函数,因为很多次的哈希计算会消耗掉cpu的性能,和延长判断速度。
所以总的来说,我们既要节省空间,又要很高的计算效率,就必须在位图容量和函数个数之间找到一个最佳的平衡。
对于如何取得平衡,这个事情早就有人研究过了,在 1970 年的时候,有一个叫做布隆的前辈对于判断海量元素中元素是否存在的问题进行了研究,也就是到底需要多大的位图容量和多少个哈希函数,它发表了一篇论文,提出的这个容器就叫做布隆过滤器。
但是无论如果也不可能达到100%正确率,除非使用绝对均匀的下标算法和绝对大于元素个数且随时扩容的位数组。
所以,这个是布隆过滤器的一个很重要的特性,因为哈希碰撞不可避免,所以它会存在一定的误判率。这种把本来不存在布隆过滤器中的元素误判为存在的情况,我们把它叫做假阳性(False Positive Probability,FPP)。
布隆过滤器的原理就是跟上面讲到的原理是一样的。
布隆过滤器的特点:
容器角度:
- 如果布隆过滤器判断结果为元素存在,那么该元素实际上元素不一定会存在,由于哈希碰撞,所以会存在一定误判率,上面已经说明了。
- 如果布隆过滤器判断结果为元素不存在,那么他就一定不存在,因为无论哈希碰撞啥的,只要该元素计算出下标值对应数组元素值为0,那么该元素就必定不存在,自己想想就好,只可意会不可言传。
- 布隆过滤器是不支持删除元素的,因为如果位图的某个位被多个元素占用着,那么如果删除其中一个元素是否能将该位置0能,置0的话会影响到其他元素,不置0就等于没删除。
元素角度:
- 如果元素实际不存在,布隆过滤器可能判断存在。
- 如果元素实际存在,布隆过滤器一定判断存在。
利用第二个特性,我们就能解决持续从数据库查询不存在的值的问题,把要查询的值先过布隆过滤器,判断是否存在,存在就走redis缓存,不存在就直接返回,并且配合缓存空值,可以有效解决缓存穿透问题,虽然存在一定误差,但是在业务范围内允许接受。
- 第一步先查询数据库数据并加入到布隆过滤器中。
- 请求发送过来布隆过滤器判断是否命中,命中就走缓存,之后接着看是否走数据库还是直接从缓存获取返回。
- 如果布隆过滤器miss,就直接返回,不走cache了。
布隆过滤器实战:
谷歌的 Guava 里面就提供了一个现成的布隆过滤器。
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.1-jre</version>
</dependency>
/**
* @author YeHaocong
* @decription
* 测试布隆过滤器的正确判断和误判
*
* 往布隆过滤器里面存放100万个元素
* 测试100个存在的元素和9900个不存在的元素
*
*/
public class BloomFilterDemo {
//元素个数 100万
private static final int insertions = 1000000;
public static void main(String[] args) {
//创建一个布隆过滤器,第二个值是元素的个数
// 初始化一个存储string数据的布隆过滤器,初始化大小为100W
// 默认误判率是0.03
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8),insertions,0.03D);
// 用于存放所有实际存在的key,判断key是否存在,这个可快速判断key是否存在
Set<String> set = new HashSet<>(insertions);
// 用于存放所有实际存在的key,可以取出使用,这个可供使用下标取出
List<String> list = new ArrayList<>(insertions);
//插入数据
for (int i = 0;i<insertions;i++){
String uuid = UUID.randomUUID().toString();
bloomFilter.put(uuid);
set.add(uuid);
list.add(uuid);
}
int right = 0; // 正确判断的次数
int wrong = 0; // 错误判断的次数
for (int i = 0; i < 10000; i++) {
// 可以被100整除的时候,取一个存在的数。否则随机生成一个UUID
// 0-10000之间,可以被100整除的数有100个(100的倍数)
//这里就是实现100个存在key,9900个不存在key。
String data = i % 100 == 0 ? list.get(i / 100) : UUID.randomUUID().toString();
//bloomFilter.mightContain(data) 布隆过滤器提供的方法用于判断数据是否命中
if (bloomFilter.mightContain(data)) {
if (set.contains(data)) {
// 判断存在实际存在的时候,命中
right++;
continue;
}
// 判断存在却不存在的时候,错误
wrong++;
}
}
//计算命中率和误判率
NumberFormat percentFormat =NumberFormat.getPercentInstance();
percentFormat.setMaximumFractionDigits(2); //最大小数位数
float percent = (float) wrong / 9900;
float bingo = (float) (9900 - wrong) / 9900;
System.out.println("在100W个元素中,判断100个实际存在的元素,布隆过滤器认为存在的:"+right);
System.out.println("在100W个元素中,判断9900个实际不存在的元素,误认为存在的:"+wrong+"" +
",命中率:" + percentFormat.format(bingo) + ",误判率:" + percentFormat.format(percent) );
long numOfBits = optimalNumOfBits(insertions,0.03D);
System.out.println("100w个元素,误判率为3%的情况下,位图容量为:"+(numOfBits/8.0/1024/1024)+"MB");
System.out.println("100w个元素,误判率为3%的情况下,哈希函数个数为:"+(optimalNumOfHashFunctions(insertions,numOfBits))+"个");
}
//下面两个方法是BloomFilter的方法,只是在里面是包权限,这里就直接复制出来用了。
/**
* 计算出哈希函数个数
* @param expectedInsertions 期望元素个数
* @param numOfBits 位图容量
* @return
*/
static int optimalNumOfHashFunctions(long expectedInsertions, long numOfBits) {
return Math.max(1, (int)Math.round((double)numOfBits / (double)expectedInsertions * Math.log(2.0D)));
}
/**
* 计算出位图容量
* @param expectedInsertions 期望元素个数
* @param fpp 误判率
* @return
*/
static long optimalNumOfBits(long expectedInsertions, double fpp) {
if (fpp == 0.0D) {
fpp = 4.9E-324D;
}
return (long)((double)(-expectedInsertions) * Math.log(fpp) / (Math.log(2.0D) * Math.log(2.0D)));
}
}
连续三次的执行结果,误判率都在3%作用,因为默认的误判率为3%。并且使用0.87MB的位图容量加5个哈希函数就可以达到100w数据的快速判断是否存在。只是存在3%的误判率。
可以指定误判率:
//最后一个参数就是误判率,这里设置的是0.1 10%。
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8),insertions,0.1D);
布隆过滤器会根据元素个数和误判率来自动跳转哈希函数个数和位数组的容量。
总结
实际上海量元素快速判断是否存在、布隆过滤器是一个通用技术,而解决缓存穿透只是他适合使用的其中一个场景。
还有其他场景:
比如爬数据的爬虫,爬过的 url 我们不需要重复爬,那么在几十亿的 url 里面,怎么判断一个 url 是不是已经爬过了?
还有我们的邮箱服务器,发送垃圾邮件的账号我们把它们叫做 spamer,在这么多的邮箱账号里面,怎么判断一个账号是不是 spamer 等等一些场景,我们都可以用到布隆过滤器。
海量数据的白名单定位等等。
布隆过滤器的优点是海量数据、快速判断,缺点是存在一定的误判率。
存储10亿个UUID,使用的内存对比。
public class Obj1 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
long insertions = 1000000000;
long g = UUID.randomUUID().toString().getBytes().length*insertions;
System.out.println("用set存储10亿个UUID,消耗内存:"+(g/1024.0/1024.0/1024.0) + "GB");
long numOfBits = BloomFilterDemo.optimalNumOfBits(insertions,0.03D);
System.out.println("10亿个元素,误判率为3%的情况下,位图容量为:"+(numOfBits/8.0/1024/1024/1024)+"GB");
System.out.println("10亿个元素,误判率为3%的情况下,哈希函数个数为:"+(BloomFilterDemo.optimalNumOfHashFunctions(insertions,numOfBits))+"个");
}
}
结果:
用set存储,需要33.527GB内存,用布隆过滤器只需0.849GB+5个哈希函数计算+3%的误判率,差了39.5倍,但是要牺牲一定准确性。并且布隆过滤器的位图增长只会与元素个数有关,与元素的大小没有关系,而用set存储的话,还与元素的大小有关,假如每个元素达到1kb大小,那结果不堪设想。