使用布隆过滤器解决缓存穿透、集合判重

1,102 阅读5分钟

引子

最近深入学习Redis中,刚好在看到使用布隆过滤器来解决缓存穿透问题,觉得挺好用的,所以记录分享下。


布隆过滤器介绍

布隆过滤器是一个占用空间小,效率很高的概率型数据结构。由一个很长的二进制向量和一系列随机映射函数构成。专门用来检测集合中是否存在特定的元素。优点是空间和时间都远远超过一般算法,缺点是有一定的误判率。


产生契机

回想一下,平时我们在检测集合中是否存在某元素时,都会采用比较的算法。以下情况:

  • 使用线性表存储,查找时间复杂度为O(n)
  • 用平衡树(如AVL树,红黑树)存储,时间复杂度为O(logn)
  • 使用哈希表存储,并用链地址法与平衡BST来解决哈希冲突(JDK8中HashMap实现方法)

总而言之,当集合中元素的数据极多时,不仅查找变得很慢,而且在空间上也会占用到无法想象的地步。恰好布隆就是解决这个矛盾的利器。


原理

布隆是由一个长度为m比特的位数组(BitArray) 和 k个哈希函数(num Hash Functions)组成的数据结构。位数组初始化为0。  当要插入一个元素时,将其数据分别输入k个哈希函数,产生k个哈希值。以哈希值为位数组中的下标,将所有k个对应的比特置为1。 当要查询(判断是否存在)一个元素时,同样将其数据输入哈希函数,然后检查对应k个比特。如果有任意一个比特为0,表明该元素一定不在集合中。如果所有比特都为1,表明该集合有很大的可能性在集合中。为什么是很大可能性呢?因为一个比特位被置为1可能受到其他元素的影响。如图所示:


简单来说:

  • 布隆过滤器说某个元素在,可能会被误判
  • 布隆过滤器说某个元素不在,那么就一定不在


优缺点&用途

优点:

  • 不需要存储数据,只用比特来表示,相对于其他传统占用空间有巨大优势,并且还保密数据
  • 时间效率也很高
  • 哈希函数互相独立,可以在硬件指令层面并行计算

缺点:

  • 存在误判率,不适用于任何要求百分百准确率的情景
  • 只能插入和查询元素,不能删除元素。原因和产生误判的原因是相同的。

所以,布隆在查询准确度上没有那么严格,而对时间、空间效率要求较高的场景非常适合。比如用在缓存系统上,防止缓存穿透。


布隆过滤器在Guava中实现

Guava中,布隆过滤器的实现主要涉及两个类,BloomFilter 和 BloomFilterStrategies,首先看下BloomFilter的成员变量。这里用到的是Guava的18.0版本。

/** guava实现的设置每个bit位的bit数组 */
  private final BitArray bits;
  /** hash函数的个数 */
  private final int numHashFunctions;
  /** guava中将对象转换为byte的通道 */
  private final Funnel<? super T> funnel;
  /**
   * 将byte转换为n个bit的策略,也是bloomfilter hash映射的具体实现
   */
  private final Strategy strategy;

  •  BitArray 是定义在BloomFilterStrategies中的内部类,封装了布隆过滤器底层bit数组的操作。
  • numHashFunctions表示哈希函数的个数。
  • Funnel,它和PrimitiveSink配套使用,能将任意类型的对象转化成Java基本数据类型,默认用java.nio.ByteBuffer实现,最终均转化为byte数组。
  • Strategy是定义在BloomFilter类内部的接口,代码如下,主要有2个方法,put和mightContain

创建布隆过滤器,BloomFilter并没有公有的构造函数,只有一个私有构造函数,对外提供两个create方法,缺省情况下误判率为3%,采用BloomFilterStrategies.MURMUR128_MITZ_64的实现。

BloomFilterStrategies.MURMUR128_MITZ_64Strategy两个实现之一,Guava以枚举方式提供这两个实现。

二者对应了32位哈希映射函数,和64位哈希映射函数,后者使用Hashing.murmur3_128() 生成的128位,有更大的空间,不过原理都是想通的。 

看下它的put方法,用两个hash函数来模拟多个hash函数的情况,这是布隆过滤器的一种优化。

public <T> boolean put(T object, Funnel<? super T> funnel,    int numHashFunctions, BitArray bits) {  
long bitSize = bits.bitSize(); 
//murmur3_128对输入的funnel计算得到128位的哈希值,funnel将object转换为byte数组,
 byte[] bytes = Hashing.murmur3_128().hashObject(object, funnel).getBytesInternal();  
//高低位hash
long hash1 = lowerEight(bytes);  
long hash2 = upperEight(bytes);  
boolean bitsChanged = false;  
long combinedHash = hash1;  
//进行k次循环,每次循环都用hash1与hash2的复合哈希做散列,然后对m取模,将位数组中的对应比特设为1。
for (int i = 0; i < numHashFunctions; i++) {    
// Make the combined hash positive and indexable    
bitsChanged |= bits.set((combinedHash & Long.MAX_VALUE) % bitSize);   
 combinedHash += hash2;  }  return bitsChanged;}

这里注意两点:

  • 在循环中实际上应用了双重哈希(double hashing)的思想,即可以用两个哈希函数来模拟k个,其中i为步长:

    这种方法在开放定址的哈希表中,也经常用来减少冲突。

  • 哈希值有可能为负数,而负数是不能在位数组中定位的。所以哈希值需要与Long.MAX_VALUE做bitwise AND,直接将其最高位(符号位)置为0,就变成正数了。


put方法中,先是将索引位置上的二进制置为1,然后用bitsChanged记录插入结果,如果返回true表明没有重复插入成功,而mightContain方法则是将索引位置上的数值取出,并判断是否为0,只要其中出现一个0,那么立即判断为不存在。  

public <T> boolean mightContain(T object, Funnel<? super T> funnel,    int numHashFunctions, BitArray bits) {
  long bitSize = bits.bitSize();  
byte[] bytes = Hashing.murmur3_128().hashObject(object, funnel).getBytesInternal();  
long hash1 = lowerEight(bytes);  
long hash2 = upperEight(bytes);  
long combinedHash = hash1;  for (int i = 0; i < numHashFunctions; i++) {
    // Make the combined hash positive and indexable    
//不同之处,set转换为get,来判断是否存在
if (!bits.get((combinedHash & Long.MAX_VALUE) % bitSize)) {      return false;    }    
combinedHash += hash2;  }  return true;}

总结

本文讲解了布隆过滤器产生,设计原理和场景分析。另外也简单阅读Guava中BloomFilter的相关源码,了解布隆过滤器技术要点。辛苦大家。。缓解眼疲劳