一、概念
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否在一个集合中。它使用位数组(bit array)和一系列哈希函数(hash functions)来减少存储空间。布隆过滤器能够告诉你某个元素“可能”在集合中,或者“一定”不在集合中。但是,它不能告诉你某个元素“一定”在集合中,因为可能存在哈希冲突。
包含以下基本概念:
1.位数组:布隆过滤器使用一个位数组(bit array)来存储信息。这个数组中的所有位都初始化为0。
2.哈希函数:布隆过滤器使用多个哈希函数(通常为k个哈希函数,其中k是一个较小的整数)来计算输入元素的哈希值。每个哈希函数都会将输入元素映射到位数组的一个位置。
3.添加元素:当要添加一个元素到布隆过滤器时,使用所有的哈希函数计算该元素的哈希值,并将位数组中对应的位设置为1。
4.查询元素:当要查询一个元素是否在布隆过滤器中时,使用相同的哈希函数计算该元素的哈希值,并检查位数组中对应的位是否都为1。如果所有的位都为1,则该元素“可能”在集合中;如果有任何一个位为0,则该元素“一定”不在集合中。总之,不存在的一定不存在,存在的可能存在(false if always false, true maybe not true)。
5.误判率:布隆过滤器判断一个元素在集合中,但实际上该元素不在集合中的概率称为误判率(假阳性率)。产生误判率的原因主要是哈希碰撞产生的。
二、原理
1、数据结构
布隆过滤器是由一个固定大小的二进制向量或者位图(bitmap)和一系列映射函数组成的。
对于长度为n的位数集合,初始状态时,所有的位置都被设置为0。如图:
位数组中的每个元素只占用1bit,且数组中的元素只能为0或1。如申请1000w个元素的位数组,只占用 10000000 Bit /8 = 1250000 Byte(B) = 125000B / 1024 KB ≈ 1220KB = 1220KB / 1024 ≈ 1.19M 的空间。从上面的计算来看,布隆过滤器位数组占用很少空间。
2、添加元素
1.使用过滤器中的hash函数对元素值计算,得到取模后的值(多少hash函数 多少hash值)。
2.根据hash值,在位数组中将下标对应的值设置为1。
添加元素 “baidu”,hash值为1、5、7,如下图:
再添加一个元素“xinlang”,hash值为 3、5、8 如下图:
需注意,下标5这个bit位,因两个元素的hash后都返回了5这个hash值,因此baidu的5bit位被xinlang的覆盖。
3、查询元素
1.对元素进行添加时相同的hash函数计算。
2.得到hash值后,判断位数组中每个bit位上是否都为1,若都为1,则元素存在过滤器中。若有一个不为1,则该元素不在过滤器中。
如元素“cunzai”是否存在,hash后返回 1、5、8三个值,如下图:
三个hash值对应的bit位都为1,则“cunzai”可能存在。
为什么是可能存在,而不是一定存在呢,因hash函数是散列函数,本身会有碰撞的情况发生。可产生以下几种:
情况1:一个字符串可能是 “chongtu” 经过相同的三个映射函数运算得到的三个点跟 “xinlang” 是一样的,这种情况下我们就说出现了误判。
情况2: “chongtu” 经过运算得到三个点位上的 1 是两个不同的变量经过运算后得到的,这也不能证明字符串 “chongtu” 是一定存在的。
如下图:
根据上面的情况。不同字符串可能hash后值一样,导致bit位值覆盖。可以通过适当增加位数组大小和hash函数数量降低碰撞概率,从而降低误判率。
过滤器判断元素存在,小概率存在误判。判断元素不存在,则一定不存在。
4、删除元素
过滤器无法删除元素,因存在bit位值覆盖情况,若是删除元素的话,会导致被覆盖的bit位值变成0。那其他元素在这个bit位上值也是0。判断其他元素时 也会显示不存在。就会出现误判情况。
5、位数组大小、容错率、hash函数个数、元素数关系
字母说明:
n:需要过滤的元素数(Number of items in the filter)
m:过滤器的位数组大小(Number of bits in the filter)
p:假阳性概率/误判容错率/误判概率(Probability of false positives, fraction between 0 and 1 or a number indicating 1-in-p)
k:hash函数数量(Number of hash functions)
计算公式:
n = ceil(m / (-k / log(1 - exp(log(p) / k))))
p = pow(1 - exp(-k / (m / n)), k)
m = ceil((n * log(p)) / log(1 / pow(2, log(2))));
k = round((m / n) * log(2));
三、优点
1. 空间效率高:只需一个位数组和若干哈希函数,不存储数据本身,仅存储hash结果取模运算后的位标记。因此空间占有率很低。
2. 时间效率高:添加和查询元素的平均时间复杂度为O(1),及常数时间复杂度。
3. 支持海量数据场景下高效判断元素是否存在。
四、缺点
1. 不存储数据本身,只能添加无法进行删除,因为删除数据会导致误判率增加。
2. 由于存在hash碰撞,存在误判的可能性,可能将不存在的数据误判为存在,但误判只会给出“可能存在”判断。
3. 容器快满时,hash碰撞的概率会变大,插入、查询的错误率也会增加。
4. 由于错误率影响hash函数的个数,当hash函数越多,每次插入、查询需做的hash操作越多,会增加CPU的计算。
五、使用场景
1. 缓存击穿:一般数据存储在缓存中,不存在查询数据库。若一大批不存在数据过来,会导致缓存大量击穿,造成雪崩效应,同时也会使数据库压力过大,出现宕机。可以通过布隆过滤器做缓存索引,存在查询缓存,没查到再查询数据库。不存在,直接返回。但会有一定误判。
2. 网页爬虫:通过过滤器对已爬过的URL进行存储,再进行下一次爬取时,可以判断URL是否爬取过。
3. 恶意请求攻击:WEB请求拦截,如相同请求或IP拦截,防止重复攻击。将请求特征如IP、URL模式、参数等放到布隆过滤器,若所有对应为都被设置过,那该请求为恶意的,需进行拦截或下一步处理。因存在误判情况,可通过增加位数和hash函数的数量来降低误判率,但会增加内存空间和CPU计算成本。也可对误判的通过正则进行二次验证。
4. 黑白名单:针对对不同用户是否存在白名单或黑名单,虽有一定误判,但能在一定程度上能快速实现处理判断。
六、java实现
1、java+guava实现
1.1、依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.0.1-jre</version>
</dependency>
1.2、demo测试
//创建一个存储100w数据的布隆过滤器,用不存在的一定量数据测试误判率
public class Test{
public void BloomTest(){
//开始时间
long startTime = System.currentTimeMillis();
//初始化误判次数
BigDecimal count = new BigDecimal("0");
//百分比换算
BigDecimal mult = new BigDecimal("100");
//测试数据长度 用于计算误判率
BigDecimal testCount = new BigDecimal("100000");
//用于计数的常量
BigDecimal one = new BigDecimal("1");
//第一个参数为数据类型,第二个为数组长度,第三个为误判率
BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 1000000L, 0.01);
for(int i = 1; i <= 1000000; i++ ){
bloomFilter.put(i);
}
//测试10w个不存在的数据
for(int i = 2000000; i<= 2100000; i++){
boolean mightContain = bloomFilter.mightContain(i);
if(mightContain){
count = count.add(one);
}
}
System.out.println("总耗时:" + (System.currentTimeMillis() - startTime) + "ms");
System.out.println("误判个数:" + count);
System.out.println("误判率:" + (count.divide(testCount).multiply(mult)) + "%");
}
}
运行结果如下:
总耗时:257ms
误判个数:1004
误判率:1.00400%
误判个数为1004,符合我们设定的0.01的误判率。
注意:不是误判率越小越好,设置的越小 进行的哈希次数越多,哈希函数执行次数越多耗时大。
所以根据业务需要,设置合理的误判率。就像hashMap负载因子为0.75一样,为1哈希冲突验证。小于0.5冲突变小了,但空间利用率也下降了。
1.3、项目使用
/**
* 通过实现CommandLineRunner接口 让实现类在项目(容器)启动后加载,在run方法中加载
* 或通过 @PostConstruct注解方式进行初始化加载 @PostConstruct注解的方法,会在构造方法之后执行;
* 加载顺序为:Constructor > @Autowired > @PostConstruct > 静态方法;
*/
@configuration
@Log4j2
public class BloomInit implements CommandLineRunner{
@Resource
private ActivityMapper activityMapper;
@Override
public void run(String... args) throws Exception{
this.bloomInit();
}
/**
* 初始化布隆过滤器
*/
@Bean
public BloomFilter bloomInit(){
// 初始化布隆过滤器,设置数据类型,数组长度和误差值
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), 1000000L, 0.01);
// 获取要装入过滤器的数据
List<ActivityInfo> activityInfos = activityMapper.getSkillActivityGoods();
//将活动信息装载到过滤器
for(ActivityInfo activity: activityInfos){
bloomFilter.put(Constants.SKLL + activity.getActivityId());
}
log.info("完成布隆过滤器装载");
return bloomFilter;
}
}
/**
* 在获取活动信息的地方加一层布隆过滤器,先从过滤器中获取值,若存在则放行,不存在直接返回,
* 有效解决了请求直接击穿redis,直接访问数据库造成不必要的压力。
*
*/
public class activityService{
@Resource
private BloomFilter bloomFilter;
@Resource
private RedisTemplate redisTemplate;
@Resource
private ActivityMapper activityMapper;
public ActivityInfo getActivityInfo(String activityId){
boolean contains = bloomFilter.mightContains(Constants.SKLL + activityId);
if(!contains){
return null;
}
// 从redis 获取
String activity = redisTemplate.opsForValue().get(Constants.SKLL + activityId);
if(activity == null){
// 从数据库中获取
ActivityInfo activityInfo = activityMapper.getSkillActivityByActivityId(activityId);
redisTemplate.opsForValue().set(Constants.SKLL + activityId, JSON.toString(activityInfo), 24, TimeUnti.HOURS);
return activityInfo;
}
return JSONObject.parseObject(activity, ActivityInfo.class);
}
}
2、java+redis实现
2.1、依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.13.1</version>
</dependency>
2.2、demo测试
public class Test{
@Resource
private RedissonClient redisson;
/**
* 测试创建布隆过滤器
*/
public void testRedisson(){
//创建一个 ceshi_name 的布隆过滤器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("ceshi_name");
//设置 过滤器估算Bit位数为 1000,容错率为0.03,通过初始化方法会计算bit位的总位数以及0.03容错率下需要的hash函数个数。
bloomFilter.tryInit(1000, 0.03);
for(int i = 0 ; i< 1000; i++){
bloomFilter.add("name" + i);
}
System.out.println("name 1 是否存在:" + bloomFilter.contains("name" + 1));
System.out.println("xiaoming 是否存在:" + bloomFilter.contains("xiaoming"));
System.out.println("预计插入数量:" + bloomFilter.getExpectedInsertions());
System.out.println("容错率:" + bloomFilter.getFalseProbabilty());
System.out.println("hash函数个数:" + bloomFilter.getHashInterations());
System.out.println("插入对象个数:" + bloomFilter.count());
}
}
运行结果如下:
name 1 是否存在:true
xiaoming 是否存在:false
预计插入数量:10000
容错率:0.03
hash函数个数:5
插入对象个数:10000