天天看点

布隆过滤器之概念、原理、实现、运用

作者:Esgoon

概念

在认识布隆过滤器之前,我们先来看看经常听到的缓存穿透问题。以Redis缓存为例,数据调用流程如下。

布隆过滤器之概念、原理、实现、运用

当调用端查询缓存时,通常有下面的流程:

① 首先查询Redis缓存,如果数据存在即返回;

② 如果查询Redis结果不存在该数据,继续查询数据库(DB);

③ 数据库DB中存在相应的数据,将数据写入Redis,同时返回给调用端;

④ 数据库DB中也不存在需要查询的数据,返回空或null。

在极端情况下(恶意的攻击或传参错误的情况,如根据ID查询缓存,实际业务数据ID为1、2、3,但调用端传入的ID参数为a、b、c),上面的第①、③两种情况一直不满足条件,此时大量的请求只进行第②、④两种情况的操作。相当于每次请求都查询数据库,而缓存变为透明,形同虚设,这种情况就叫做缓存穿透。

在缓存穿透情况下,很显然数据库负载和压力增大,系统处于风险之中。怎么避免缓存穿透情况发生呢? 常见的办法是使用布隆过滤器。

布隆过滤器(Bloom Filter)是由Howard Bloom在1970年提出的二进制向量数据结构,它具有空间和时间效率,被用来检测一个元素是不是集合中的一个成员。

原理

我们沿着这一思路思考:如何在海量元素中(因为我们的业务数据可能是海量的)快速判断一个元素是否存在?

很容易想到的办法是将这些元素加入到List、Map等集合中,然后通过取值判断。这种办法存在的问题是显而易见的,因为数据量太大,必然导致占用内存空间大且检索数据变慢。

因此,我们需要考虑一种节省空间的数据结构。我们的需求是仅仅判断是否存在(Ture or False),而不需要存具体的元素,可以使用位图(BitMap)算法。Bit-map的基本思想就是用一个bit位来标记某个元素对应的Value。由于采用了Bit为单位来存储数据,从而大大节省了存储空间。

具体做法是,我们使用一个Hash函数(比如MD5、SHA这些常见的Hash算法)将元素映射到大小为n的bit数组中,并置相应位置为1,即将真实的数据元素与bit数组元素建立映射关系。

用Hash函数建立这种映射关系是因为Hash算法有以下特点:

  • 无论真实数据的内容多长,得到的Hash值的长度是固定的
  • 相同数据的Hash值一定是相同的
  • Hash值的分布是均匀的

这些特点保证了将真实数据建立到bit数组的映射关系后能均匀分布,且可以进行进行比较两个数据元素是否相同。但同时会带来另外一个问题:哈希碰撞,即不同的数据经过Hash函数计算得到了相同的Hash值。哈希碰撞是不可避免的,我们只能尽量降低哈希碰撞的概率。

布隆过滤器之概念、原理、实现、运用

哈希碰撞

降低哈希碰撞的概率有两种做法,其一是扩大bit数组的容量。因为Hash值的均匀分布,容量越大碰撞的概率月底,但这意味着占用更多的内存空间。其二是对同一个数据经过多个不同的Hash函数计算,用计算出来的多个Hash值标识一个数据元素。显然,越多的Hash函数计算意味着性能下降,占用的计算时间越长。这是一个典型的空间与时间平衡问题。

布隆过滤器之概念、原理、实现、运用

布隆过滤器原理示意图

如上图所示,f1,f2,f3为三个Hash函数。分别对数据集DataSet中的元素进行Hash计算,并将数据元素映射到bit数组。当查找一个元素是否存在于数据集合中,也同样通过这三个Hash函数计算,对应bit数组中三个位置都为1,即表明元素存在。由于存在哈希碰撞,例如我们查找数据X,得到bit数组中第3、5、8位标识为1,但其中第3、5位的1是对数据元素a的标识,第8位的1实际是对元素c的标识。显然X不存在于元素集合,比对的结果是返回True,这种情况称为假阳性,导致布隆过滤器误判。

再来看查找数据Y,其中f3(Y)映射到bit数组中的标识为0,所以可以肯定Y元素一定不存在于数据集合中。

由此,可以得出两个结论:

  • 如果布隆过滤器判断数据存在于数据集合中,有可能实际并不存在(假阳性)
  • 如果布隆过滤器判断数据不存在于数据集合中,则一定不存在

实现

实际使用中,可以自行设计布隆过滤器,也可以使用开源工具自带的布隆过滤器。首先介绍Google 开源的 Guava 工具包中自带的布隆过滤器。

引入依赖:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.1-jre</version>
    <!-- or, for Android: -->
    <!-- <version>31.1-android</version> -->
</dependency>           

示例代码:

public class BloomFilterTest {
    public static void main(String[] args) {
        BloomFilter<String> filter = BloomFilter.create(
            Funnels.stringFunnel(Charset.defaultCharset()), 1000, 0.001);

        filter.put("ab");
        filter.put("cd");
        filter.put("ef");
        filter.put("gh");
        System.out.println(filter.mightContain("xy"));
        System.out.println(filter.mightContain("ab"));
        System.out.println(filter.mightContain("ac"));
    }
}           

运行程序,控制图输出如下结果:

false

true

false

其中BloomFilter.create函数的2、3个参数,是设置布隆过滤器的数据量容量和误判率。上面设置的误判率是0.001,表示1000个元素的判断可能会出现1次误判(假阳性)。

使用Guava自带的布隆过滤器缺陷就是不能进行删除操作,而且只能单机环境使用。在分布式环境下,Redis中也可以使用布隆过滤器,Redis v4.0 之后有了 Module 功能,可以使用官方推荐的第三方布隆过滤器插件https://github.com/RedisBloom/RedisBloom。

布隆过滤器之概念、原理、实现、运用

RedisBloom

再回到文章之前提出的问题,如何使用布隆过滤器解决缓存穿透问题? 具体流程如下。

布隆过滤器之概念、原理、实现、运用

布隆过滤器解决缓存穿透问题

① 将数据库所有的数据加载到布隆过滤器。

② 当有请求来的时候先去布隆过滤器查询。

③ 如果布隆过滤器查询无数据,则表示数据库肯定不存在这样的数据,直接返回空或null。

④ 如果布隆过滤器查询有所请求的数据,再继续查Redis缓存。

⑤ Redis查询有数据,返回。

⑥ Redis查询无数据,查DB。

⑦ DB查询有数据,写入Redis并返回。

⑧ DB查询无数据,返回空或null。

运用

布隆过滤器有着广泛的运用。当遇到数据量大,又需要快速检索一个数据是否存在的场景,可以考虑布隆过滤器,如下:

  • 解决缓存穿透问题;
  • 邮件过滤,使用布隆过滤器实现邮件黑名单过滤;
  • 恶意URL过滤;
  • 推荐过的新闻不重复推荐。

继续阅读