数据结构&算法分布式缓存中间件

Bloom布隆过滤器

2020-01-16  本文已影响0人  suxin1932
假设遇到这样一个问题:
一个网站有 20 亿 url 存在一个黑名单中,这个黑名单要怎么存?
若此时随便输入一个 url,你如何快速判断该 url 是否在这个黑名单中?
并且需在给定内存空间(比如:500M)内快速判断出。

#方案1:
可能很多人首先想到的会是使用 HashSet,因为 HashSet基于 HashMap,理论上时间复杂度为:O(1)。
达到了快速的目的,但是空间复杂度呢?
URL字符串通过Hash得到一个Integer的值,Integer占4个字节,那20亿个URL理论上需要:
20亿*4/1024/1024/1024=7.45G的内存,不满足空间复杂度的要求。

#方案2:
可以初始化一个很长的一个Bit数组,将数值对应的Bit位置为true,然后根据是true还是false判断对应位置的数值是否存在。
例如现在有数值0、3、63,我们可以初始化一个长度为64的Bit数组,
将0、3、63置为true,然后通过get()方法查看对应位置的数值是否存在。

#方案2局限
BitSet有两个比较局限的地方:
>> 当样本分布极度不均匀的时候,BitSet会造成很大空间上的浪费。
举个例子,比如你有5个数,分别是1、2、3、4、999,
那么这个BitSet至少得有1000位,中间的位置很多就被浪费掉了。
>> BitSet只面向数字比较,并且还得是正数,其他类型需要先转换成int类型,
但是转换过程中难免会出现重复,BitSet的准确性就会降低。

#针对以上两个问题,解决思路就是:
分布不均匀的情况可以通过hash函数,将元素都映射到一个区间范围内,
减少大段区间闲置造成的浪费。然后可以把其他类型映射成整数,
映射时可以多hash几次同时扩大数组的范围,减少hash冲突的概率。

#方案3: 布隆过滤器
>>>>>>>>>>> 这里就引出本文要介绍的“布隆过滤器”。

先说结论

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

布隆过滤器的原理有一个很好的比喻,那就是在冬天一片白雪覆盖的地面上,
如果你从上面走过,就会留下你的脚印。

如果地面上有你的脚印,那么就可以大概率断定你来过这个地方,
但是也不一定,也许别人的鞋正好和你穿的一模一样。
可是如果地面上没有你的脚印,那么就可以 100% 断定你没来过这个地方。

1.基本概念

布隆过滤器(英语:Bloom Filter)是1970年由布隆提出的。
它实际上是一个很长的二进制矢量和一系列随机映射函数。
布隆过滤器可以用于检索一个元素是否在一个集合中。
它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。

#布隆过滤器的本质
布隆过滤器本质是一个位数组, 位数组就是数组的每个元素都只占用1bit 。
每个元素只能是 0 或者 1。
这样申请一个 10000 个元素的位数组只占用 10000 / 8 = 1250 B 的空间。
布隆过滤器除了一个位数组,还有 K 个哈希函数。
当一个元素加入布隆过滤器中的时候,会进行如下操作:
>> 使用 K 个哈希函数对元素值进行 K 次计算,得到 K 个哈希值。
>> 根据得到的哈希值,在位数组中把对应下标的值置为 1。

#作用
特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。

举个例子

布隆过滤器是一个 bit 向量或者说 bit 数组
布隆过滤器初始态.png
如果我们要映射一个值到布隆过滤器中,我们需要使用多个不同的哈希函数生成多个哈希值(假设为3个),
并对每个生成的哈希值指向的 bit 位置 1。

例如针对值 “tianmao” 和三个不同的哈希函数分别生成了哈希值 1、3、5。
针对值 “tianmao” 和三个不同的哈希函数分别生成了哈希值 1、3、5.png
现在再存一个值 “tencent”,如果哈希函数返回 3、4、8。
值得注意的是,3 这个 bit 位由于两个值的哈希函数都返回了这个 bit 位,因此它被覆盖了。
现在再存一个值 “tencent”,如果哈希函数返回 3、4、8.png
现在我们如果想查询 “zhongxing” 这个值是否存在,哈希函数返回了 1、2、8三个值,
结果我们发现 2 这个 bit 位上的值为 0,说明没有任何一个值映射到这个 bit 位上,
因此我们可以很确定地说 “zhongxing” 这个值不存在。

而当我们需要查询 “tianmao” 这个值是否存在的话,那么哈希函数必然会返回 1、3、5,
然后我们检查发现这三个 bit 位上的值均为 1,那么我们可以说 “baidu” 存在了么?
答案是不可以,只能是 “tianmao” 这个值可能存在。这是为什么呢?
因为随着增加的值越来越多,被置为 1 的 bit 位也会越来越多,
这样某个值 “shangtang” 即使没有被存储过,但是万一哈希函数返回的三个 bit 位都被其他值置位了 1 ,
那么程序还是会判断 “shangtang” 这个值存在。

1.1 优缺点

#优点
>> 相比于其它的数据结构,布隆过滤器在空间和时间方面都有巨大的优势。
布隆过滤器存储空间和插入/查询时间都是常数(O(k))。
>> 散列函数相互之间没有关系,方便由硬件并行实现。
>> 布隆过滤器不需要存储元素本身,在某些对保密要求非常严格的场合有优势。
>> 布隆过滤器可以表示全集,其它任何数据结构都不能;
>> k和m相同,使用同一组散列函数的两个布隆过滤器的交并差运算可以使用位操作进行。

#缺点
>> 误算率。随着存入的元素数量增加,误算率随之增加。但是如果元素数量太少,则使用散列表足矣。
>> 一般情况下不能从布隆过滤器中删除元素. 我们很容易想到把位数组变成整数数组,
每插入一个元素相应的计数器加1, 这样删除元素时将计数器减掉就可以了。
然而要保证安全地删除元素并非如此简单。
首先我们必须保证删除的元素的确在布隆过滤器里面. 这一点单凭这个过滤器是无法保证的。
另外计数器回绕也会造成问题。
>> 在降低误算率方面,有不少工作,使得出现了很多布隆过滤器的变种。
布隆过滤器可以支持 add 和 isExist 操作,那么 delete 操作可以么,答案是不可以.
上图中的 bit 位 3 被两个值共同覆盖的话,一旦你删除其中一个值例如 “tencent” 而将其置位 0,
那么下次判断另一个值例如 “tianmao” 是否存在的话,会直接返回 false,而实际上你并没有删除它。

如何解决这个问题,答案是计数删除。
但是计数删除需要存储一个数值,而不是原先的 bit 位,会增大占用的内存大小。
这样的话,增加一个值就是将对应索引槽上存储的值加一,删除则是减一,判断是否存在则是看值是否大于0。

1.2 如何选择哈希函数个数和布隆过滤器长度

这里可以找个靠谱的图, 看看官网或github哪里有没有

很显然,过小的布隆过滤器很快所有的 bit 位均为 1,那么查询任何值都会返回“可能存在”,起不到过滤的目的了。
布隆过滤器的长度会直接影响误报率,布隆过滤器越长其误报率越小。

#哈希函数的个数 (与错误率负相关)
哈希函数越多则布隆过滤器 bit 位置为 1 的速度越快,且布隆过滤器的效率越低;
但是如果太少的话,那我们的误报率会变高。
错误率: 允许布隆过滤器的错误率,值越低过滤器的位数组的大小越大,占用空间也就越大。

1.3 适用场景

1、黑名单 
2、URL去重(如爬取网页信息时)
3、单词拼写检查 
4、Key-Value缓存系统的Key校验 
5、ID校验,比如订单系统查询某个订单ID是否存在,如果不存在就直接返回。
6、Goolge在BigTable中就使用了BloomFilter,以避免在硬盘中寻找不存在的条目。
7、垃圾邮件过滤

3.Guava的BloomFilter (适合单机场景)

3.1 源码解析

3.2 应用示例

package com.zy.eureka.filter;

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

/**
 * Bloom Filter可以理解为是一个m位的数组,它有k个相互独立的哈希函数。
 * 每当新来一个元素时,会使用这些哈希函数分别计算,将对应位置置为1。
 * 查询时也要进行k次哈希运算,如果对应的k个位置都为1,则表明元素可能存在。
 */
public class GuavaBloomFilter {

    /**
     * fpp: 默认的误判率是 3%, 实际设置的误判率越低, 所要求的内存越大
     * expectedInsertions: 参数设置过大, 可能导致: java.lang.OutOfMemoryError: Java heap space
     */
    private static final BloomFilter<Integer> BLOOM_FILTER = BloomFilter.create(Funnels.integerFunnel(), 1024 * 1024 * 64, 0.0001d);

    public static boolean keyExists(Integer key) {
        return BLOOM_FILTER.mightContain(key);
    }

    public static synchronized void put(Integer key) {
        BLOOM_FILTER.put(key);
    }

}

// 注意事项
// 正确估计预期插入数量是很关键的一个参数。当插入的数量接近或高于预期值的时候,
// 布隆过滤器将会填满,这样的话,它会产生很多无用的误报点。

测试

    public static void main(String[] args) {
        for (int i = 0; i < 10000; i++) {
            GuavaBloomFilter.put(i);
        }
        System.out.println(GuavaBloomFilter.keyExists(675));
    }

4. Redis中的布隆过滤器 (适合分布式场景)

https://github.com/RedisBloom/RedisBloom (官网 4.0开始支持)
https://oss.redislabs.com/redisbloom/ (文档)
http://www.dragonwins.com/domains/getteched/bbc/literature/Bloom70.pdf (论文)

redis 在 4.0 的版本中加入了 module 功能,布隆过滤器可以通过 module 的形式添加到 redis 中,
所以使用 redis 4.0 以上的版本可以通过加载 module 来使用 redis 中的布隆过滤器。

// redis 布隆过滤器主要就两个命令:
>> bf.add 添加元素到布隆过滤器中:bf.add urls https://www.baidu.com。
>> bf.exists 判断某个元素是否在过滤器中:bf.exists urls https://www.baidu.com。。

// 上面说过布隆过滤器存在误判的情况,在 redis 中有两个值决定布隆过滤器的准确率:
>> error_rate :允许布隆过滤器的错误率,这个值越低过滤器的位数组的大小越大,占用空间也就越大。
>> initial_size :布隆过滤器可以储存的元素个数,当实际存储的元素个数超过这个值之后,过滤器的准确率会下降。

// redis 中有一个命令可以来设置这两个值:
bf.reserve urls 0.01 100

// 三个参数的含义:
>> 第一个值是过滤器的名字。
>> 第二个值为 error_rate 的值。
>> 第三个值为 initial_size 的值。
// 使用这个命令要注意一点:
执行这个命令之前过滤器的名字应该不存在,如果执行之前就存在会报错:(error) ERR item exists 

使用场景1

比如我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,
它每次推荐时要去重,去掉那些已经看过的内容。
问题来了,新闻客户端推荐系统如何实现推送去重的?
Redis 官方提供的布隆过滤器到了 Redis 4.0 提供了插件功能之后才正式登场。

倘若数据量较小的情况下可以通过 查询数据库,过滤掉已经推送过的信息,剩下的就是没有被推送的了。

当用户量很大,每个用户看过的新闻又很多的情况下,推荐系统的去重工作在性能上跟的上么?

如果历史记录存储在关系数据库里,去重就需要频繁地对数据库进行 exists 查询,
当系统并发量很高时,数据库是很难扛住压力的。

你可能又想到了缓存,但是如此多的历史记录全部缓存起来,那得浪费多大存储空间啊?
而且这个存储空间是随着时间线性增长,你撑得住一个月,你能撑得住几年么?
但是不缓存的话,性能又跟不上,这该怎么办?

这时,布隆过滤器 (Bloom Filter) 闪亮登场了,它就是专门用来解决这种去重问题的。

使用场景2

比如业务系统的用户状态查询接口,现在一个新用户过来了,
它会先去缓存里查询有没有这个用户的状态数据。

因为是新用户,所以肯定缓存里没有。然后它就要去查数据库,结果数据库也没有。
如果这样的新用户大批量瞬间涌入,那么可以预见数据库的压力会比较大,会存在大量的空查询。

我们非常希望 Redis 里面有这样的一个 set,它存放了所有用户的 id,
这样通过查询这个 set 集合就知道是不是新用户来了。

当用户量非常庞大的时候,维护这样的一个集合需要的存储空间是很大的。

这时候就可以使用布隆过滤器,它相当于一个 set,
但是呢又不同于 set,它需要的存储空间要小的多。

比如你存储一个用户 id 需要 64 个字节,而布隆过滤器存储一个用户 id 只需要 1个字节多点。
但是呢它存的不是用户 id,而是用户 id 的指纹,所以会存在一定的小概率误判,它是一个具备模糊过滤能力的容器。

当它说用户 id 不在容器中时,那么就肯定不在。
当它说用户 id 在容器里时,99% 的概率下它是正确的,还有 1% 的概率它产生了误判。

不过在这个案例中,这个误判并不会产生问题,误判的代价只是缓存穿透而已。

相当于有 1% 的新用户没有得到布隆过滤器的保护直接穿透到数据库查询,
而剩下的 99% 的新用户都可以被布隆过滤器有效的挡住,避免了缓存穿透。

参考资源
https://en.wikipedia.org/wiki/Bloom_filter (bloom过滤器wiki)
https://www.jianshu.com/p/2104d11ee0a2 (bloom原理)
https://segmentfault.com/a/1190000016721700 (redis中的布隆过滤器)
https://baijiahao.baidu.com/s?id=1611754128562106165&wfr=spider&for=pc (redis中布隆过滤器数据估计)
http://pages.cs.wisc.edu/~cao/papers/summary-cache/node8.html
https://my.oschina.net/LucasZhu/blog/1813110 (bloom过滤器算法)

上一篇下一篇

猜你喜欢

热点阅读