1、如果查询的数据在缓存中和数据库中不存在,带来的额外的IO操作和开销怎么办?
2、如果用户频繁基于一个key进行请求该怎么处理?如何从大量的、无规则、不重复的元素中快速判断某元素是否存在?
现在我们来看看上面二个问题怎么解决?针对大量频繁不存在的数据查询,我们可以在缓存数据库中设置key=null这样的键值对来防止缓存穿透。如果存在大量这种恶意查询我们都得这么做吗?设置key=null键值对显然不是个好办法,下面我们来讨论讨论另一种解决办法。
当然我们可以想到在请求Redis之前加一层filter:
首先我们需要考虑两点:
- 对于存储key的容器占用空间要小
- 可以快速查找
首先占用空间最小想着在计算的存储单位中占用空间最小的单位肯定是位(bit)啦,所以使用使用bit数组肯定满足第一个要求了,并且使用数组也可以使用下标查找,查找的时间复杂度是O(1),满足我们的要求。
怎样快速查找,当然要借助算法了:怎样将查询的key与bit数组的下标进行映射。通过这个算法我们可以根据查询的每个key与bit数组当中的值进行一一映射。
(1)确定性(计算多次的结果一致)
(2)允许任意的输入且保证固定长度的输出
满足上面两个要求的算法即可。毫无疑问,hash算法无疑是最好的选择。
如上图所示,用hash算法同样也存在一个问题,可能存在hash碰撞的问题,当我们将马云经过hash计算存到数组当中时,然而在查询张无忌(张无忌此时并没有在这个bit数组中)时经过hash计算张无忌这个人是存在的。因此怎样解决这个问题?
想完全杜绝这种hash碰撞是不可能的,只能想办法降低碰撞的概率:可以设计足够长的bit数组,同时设计多个hash函数计算需要存储的值的位置。
上图就是将每个key经过三个不同hash算法计算后插入bit数组的位置状态。可以推断出一个结论: - 这个过滤器能够查询到key说明这个key不一定存在
- 这个过滤器不能够查询到key说明这个key一定不存在
下面我们来看一下怎么设计这个过滤器。
- 首先存储的key的空间要足够小,最小的单位是bit位,但是java最小的存储单位是byte,因此我们只能用byte来等价转换bit数组了(1byte=8bit)。
- 怎样才能快速查找。一是我们需要选择数组这种可以快速查找的数据结构,二是我们需要选择合适的hash算法对bit数组和查询的值做映射。
像下图这样。
比如我们现在需要修改bit数组中的某个值:
(1) set(11,true):也就是将下标位置是11的值置为1,也就是byte[1]的值应该怎样变化呢?
11/8 得到byte数组下标位置是[1] —>byte[1]+2^4
(2)set(15,false):也就是将下标位置是15的值置为0,byte[1]的值该怎样变化?
15/8 得到byte数组下标位置是[1] —>byte[1]-(2^0)
不知道上面两个转换各位看明白了没有,这个感觉不难相信各位都能看明白(狗头保命)…
那么问题来了?在插入或查询这个bit数组的时候我们每次都需要这么麻烦的转换吗?答案当然不是,JDK早已为我们提供了相关工具java.util.BitSet,下面我们就来看一下这个工具的使用。
根据查看BitSet相关源码我们可以发现它是采用了long类型来映射bit数组,也就是一个long相当于64位长度的bit数组。
private void initWords(int nbits) {
words = new long[wordIndex(nbits-1) + 1];
}
现在我们设置下标位置是7的值为true,代码如下。
BitSet bitSet = new BitSet(128);
bitSet.set(7, true);
我们继续看一下这个set方法:
public void set(int bitIndex, boolean value) {
if (value)
set(bitIndex);
else
clear(bitIndex);
}
可以看到值为true时调用的set(bitIndex)
方法,值为false时调用的clear(bitIndex)
,继续跟进set方法。
public void set(int bitIndex) {
if (bitIndex < 0)
throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
int wordIndex = wordIndex(bitIndex);
expandTo(wordIndex);
words[wordIndex] |= (1L << bitIndex); // Restores invariants
checkInvariants();
}
其他的都不用看,主要是words[wordIndex] |= (1L << bitIndex);
这一行语句,我们来分析分析什么意思。
此时bitIndex=7,bit数组的长度是128。为了简化,我们用下面步骤来表示。
- 现在假定这个bit数组是01***01010110(总共是128位)
- 1L=00***00000001,然后1L<<7就是向左移位7位得到00***10000000
- 然后再将00***10000000与原来的值做或运算,见下图(啰嗦一句:
与运算两边都是1结果才为1,或运算只要有一边是1结果就是1)。
可以看到原bit数组除了第8位变化之外其他值都保持不变,这不就是我们要的效果吗,看到这一幕有没有很熟悉,这不就是我们上面修改bit数组中的某个值的操作吗,在上面我们需要找出在byte数组中的位置然后再找出这8位中需要修改的值的位置。这一连串的操作全都被这一行移位运算语句替代了,有没有感觉到很巧妙,反正我是被惊讶到了。
set方法我们搞清楚了,我们现在将值设置成false看一下。
BitSet bitSet = new BitSet(128);
bitSet.set(6, false);
这次是跟进clear方法看看将bit数组的值置为false的操作。
public void clear(int bitIndex) {
if (bitIndex < 0)
throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
int wordIndex = wordIndex(bitIndex);
if (wordIndex >= wordsInUse)
return;
words[wordIndex] &= ~(1L << bitIndex);
recalculateWordsInUse();
checkInvariants();
}
还是其他的都不用管,只需要看 words[wordIndex] &= ~(1L << bitIndex);
这一行语句。
1、还是假定这个bit数组是01***01010110(总共是128位)
此时bitIndex=6。bit数组的长度是128。为了简化,我们用下面步骤来表示。
- 还是假定这个bit数组是01***01010110(总共是128位)
- 1L=00***00000001,然后1L<<6就是向左移位6位得到00***01000000
- 对00***01000000取反得到11***10111111
- 然后再将11***10111111与原来的值做与运算,见下图。
可以看到第7位的1变为0了。
看到这里不知道大家有没得一个疑问?反正我是有了,就是上面在做与运算和或运算的时候,比如第一个将bit数组下标位置是7的值改成1。在进行或运算时上下两个值都是从右往左进行或运算。也就是说bit数组的值的下标需要从右往左开始,见下图。
可以看到0-63是long[0],64-127是long[1],那么可能又有疑问了,既然是从右往左开始,为什么不是下面这个样子。
其实吧,这样也可以,只是算起来太麻烦了,如果像这样的话那我们的long数组的下标也得从后往前数才能进行正确计算了,这样的话使用起来太不方便了。还有一个知识点就是我们用的移位运算是循环移位运算,什么意思,看下面:
对长整型1做左移65位运算的结果是?
可以看到,上面000***010就是最终移位的结果,这不刚好和1L<<1的结果相同吗。也是因为这个特性我们才能利用上面的移位运算进行正确的计算。
说完了上面这么多,我们下面开始手写一个布隆过滤器。 首先我们需要明确几个概念,并且记住下面两个公式(这两个公式是布隆老先生在1970年的论文中提出,大家如果想深究可以去查阅一下相关资料):
n:bit数组存储元素的个数
p:希望达到的误判率
m:bit数组的长度
k:hash函数的个数
手写BloomFilter的完整代码如下:
package com.qzwang;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.Charset;
import java.util.BitSet;
/**
* BloomFilter
*/
public class BloomFilterDemo {
//预期存储的数据量
private int n;
//误判率
private double p;
//bit[] length
private int m;
//hash函数个数
private int k;
//bit数组
private BitSet bitMap;
//初始化需要用到预期存储的元素个数和希望达到的误判率
public BloomFilterDemo(int n, double p) {
this.n = n;
this.p = p;
}
/**
* 向bit数组添加元素
*
* @param element
*/
public void addElement(String element) {
//第一次使用的时候才初始化
if (bitMap == null) {
init();
}
int[] posArr = getIndexes(element);
for (int tempPos : posArr) {
bitMap.set(tempPos, true);
}
}
/**
* 判断元素是否存在于bit数组中
*
* @param element
* @return
*/
public boolean isExist(String element) {
int[] posArr = getIndexes(element);
boolean flag = true;
for (int temPos : posArr) {
flag = flag && bitMap.get(temPos);
}
return flag;
}
/**
* 初始化过程,这里需要用到那两个公式
*/
private synchronized void init() {
if (this.m == 0) {
this.m = (int) ((-n * Math.log(this.p)) / (Math.log(2) * Math.log(2)));
}
if (this.k == 0) {
this.k = Math.max(1, (int) Math.round((this.m * Math.log(2)) / this.n));
}
if (bitMap == null) {
bitMap = new BitSet(this.n);
}
System.out.println("this.m is " + this.m);
System.out.println("this.k is " + this.k);
}
/**
* 获取k个hash函数计算element的值的下标数组
*
* @param element
*/
private int[] getIndexes(String element) {
int[] retArr = new int[this.k];
for (int i = 0; i < this.k; i++) {
retArr[i] = HashUtil.MD5Hash(element + i) % this.m;
}
return retArr;
}
/**
* 测试布隆过滤器,先添加1000000个元素再在2000000个元素中查找这1000000个元素,
* 因为前1000000个元素肯定都是存在的,所以我们可以看后1000000个元素有多少能够命中
* 从而来测试它的误判率是否达标。
*
*/
private static void testBloomFilter() {
BloomFilterDemo bloomFilterDemo = new BloomFilterDemo(1000000, 0.0003);
for (int i = 0; i < 1000000; i++) {
bloomFilterDemo.addElement("abc" + i);
}
int count = 0;
for (int i = 0; i < 2000000; i++) {
if (bloomFilterDemo.isExist("abc" + i)) {
count++;
}
}
System.out.println("count=" + count);
}
public static void main(String[] args) {
testBloomFilter();
}
}
其中计算hash的工具类HashUtil的代码如下:
package com.qzwang;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class HashUtil {
/**
* hash函数,MD5
*/
public static int MD5Hash(String key) {
MessageDigest md5 = null;
try {
md5 = MessageDigest.getInstance("md5");
byte[] bytes = key.getBytes();
md5.update(bytes);
BigInteger bi = new BigInteger(md5.digest());
return Math.abs(bi.intValue());
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return -1;
}
}
然后我们可以在main函数中调用testBloomFilter方法进行测试。
可以看到有334个元素是被误判了,与我们起初设置的0.0003误判率很接近,基本满足要求。
当然我们在实际使用过程中也不用这么麻烦每次都需要自己手写一个,早已有人为我们造好了轮子我们只需要拿来用就行了。在maven中引入下面依赖包。
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>21.0</version>
</dependency>
<dependency>
<groupId>com.baqend</groupId>
<artifactId>bloom-filter</artifactId>
<version>1.0.7</version>
</dependency>
然后写一个测试方法试试Google的bloom过滤器。
private static void testGuavaBloomFilter() {
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.forName("UTF-8")), 1000000, 0.0003);
for (int i = 0; i < 1000000; i++) {
bloomFilter.put("abc" + i);
}
int count = 0;
for (int i = 0; i < 2000000; i++) {
if (bloomFilter.mightContain("abc" + i)) {
count++;
}
}
System.out.println("count=" + count);
}
可以看到结果也在0.0003附近,跟我们自己手写的差不多,上面两个计算结果不一样是因为我们用到的hash函数不一样。
完整代码在这里
好了,说到这里就差不多就快结束了,现在我们来总结一下什么是布隆过滤器,布隆过滤器实际上就是一种小单位(bit)映射大单位s(int、long等)的数据结构,同时巧妙的利用移位运算可以快速的插入、查询这两个重要操作,它可以根据查询的结果告诉你某个值一定不存在或不一定存在。