Redis_25_Redis中的布隆过滤器

文章目录

一、前言

从布隆过滤器和计数过滤器和布谷鸟过滤器,都是过滤器,都是判断集合中是否存放元素的算法,像对于传统的hash算法有改进,都是为redis服务的,知道本质就好。

面试中,一般问到缓存穿透,或者海量数据去重这个时候引出布隆过滤器,作为面试加分项

二、避免缓存击穿的利器之BloomFilter

2.1 Bloom Filter 概念

名称由来:布隆过滤器是一个音译词语,英文为 Bloom Filter,音译为布隆过滤器;
作用:用于检索一个元素是否在一个集合中;
本质:本质上是一个很长的二进制向量和一系列随机映射函数;
优点:空间效率和查询时间都远远超过一般的哈希算法;
缺点:有一定的误识别率和删除困难。

2.2 布隆过滤器适合的五个场景

用于检索一个元素是否在一个集合中
两个应用方向:缓存穿透、海量数据去重

1、布隆过滤器作为判断方法用来 防止数据库穿库,处理缓存穿透

BloomFilter可以用来减少不存在的行或列的磁盘查找。避免代价高昂的磁盘查找会大大提高数据库查询操作的性能。 如同一开始的业务场景。如果数据量较大,不方便放在缓存中。需要对请求做拦截防止穿库。

简单来说就是你数据库的id都是1开始然后自增的,那我知道你接口是通过id查询的,我就拿负数去查询,这个时候,会发现缓存里面没这个数据,我又去数据库查也没有,一个请求这样,100个,1000个,10000个呢?你的DB基本上就扛不住了,如果在缓存里面加上布隆过滤器,是不是就知道这个负数不存在了,你判断没这个数据就不去查了,直接return一个数据为空不就好了嘛。

缓存击穿和缓存穿透
缓存穿透与缓存击穿异同:
相同点:都是数据库承受不了巨大压力,导致崩溃。
不同点:
缓存穿透是指redis没作用,形同虚设,每一次访问都要查询数据库,导致崩溃,这是技术上可以解决的,redis中记录一个(x,null)键值对;我们的业务中经常会遇到穿库的问题,通常可以通过缓存解决。 如果数据维度比较多,结果数据集合比较大时,缓存的效果就不明显了,表现为缓存穿透。 因此为了解决穿库的问题,我们引入Bloom Filter。
缓存击穿是指redis作用了,但是数据量实在是太大了,实在是承受不了这么大的数据量,数据库连带redis缓存一起崩溃,这时在固定的硬件成本下,缓存、数据库软件方面已经达到理论上的最优了,技术上解决不了。

2、布隆过滤器作为判断方法用来作为 缓存宕机时的应急方式

缓存宕机的场景,使用布隆过滤器会造成一定程度的误判。原因是除了Bloom Filter 本身有误判率,宕机之前的缓存不一定能覆盖到所有DB中的数据,当宕机后用户请求了一个以前从未请求的数据,这个时候就会产生误判。当然,缓存宕机时使用布隆过滤器作为应急的方式,这种情况应该也是可以忍受的。

3、布隆过滤器作为判断方式用来作为 WEB拦截器 拦截相同请求防止DOS攻击

拦截相同请求防止DOS攻击。用户第一次请求,将请求参数放入BloomFilter中,当第二次请求时,先判断请求参数是否被BloomFilter命中。可以提高缓存命中率

4、布隆过滤器作为判断方式用来作为 chrome恶意地址检测

chrome 浏览器检查是否是恶意地址。 首先针对本地BloomFilter检查任何URL,并且仅当BloomFilter返回肯定结果时才对所执行的URL进行全面检查(并且用户警告,如果它也返回肯定结果)。

5、布隆过滤器作为判断方式用来实现 比特币加速

bitcoin 使用BloomFilter来加速钱包同步。

6、海量数据去重方向:cerberus在收集监控数据的时候, 有的系统的监控项量会很大, 需要检查一个监控项的名字是否已经被记录到db过了, 如果没有的话就需要写入db,如果记录过db就不用记录。

7、海量数据去重方向:爬虫过滤已抓到的url就不再抓,可用bloom filter过滤

8、空间效率高方向:垃圾邮件过滤。如果用哈希表,每存储一亿个 email地址,就需要 1.6GB的内存(用哈希表实现的具体办法是将每一个 email地址对应成一个八字节的信息指纹,然后将这些信息指纹存入哈希表,由于哈希表的存储效率一般只有 50%,因此一个 email地址需要占用十六个字节。一亿个地址大约要 1.6GB,即十六亿字节的内存)。因此存贮几十亿个邮件地址可能需要上百 GB的内存。而Bloom Filter只需要哈希表 1/8到 1/4 的大小就能解决同样的问题。

2.3 引入布隆过滤器:从不使用redis到使用redis,redis中如何最小空间存储

2.3.1 不使用redis

我们先看看一般业务缓存流程:

在这里插入图片描述

解释上图:
第一步,查询缓存;
第二步,缓存不命中再查询数据库,查到数据后,将查询结果放在缓存中;所以,即使mysql缓存中不存在数据,也需要创建一个缓存,用来防止穿库。
第三步,MySQL缓存命中,直接返回结果,不用再查库了。
注意:在缓存中不存在数据情况下,新插入缓存的数据,时间可以设置相对较短,防止因为主从同步等问题,导致问题被放大。

这个流程中存在薄弱的问题是,当用户量太大时,我们会缓存大量数据空数据,并且一旦来一波冷用户,会造成雪崩效应。 对于这种情况,我们产生第二个版本流程:redis过滤冷用户缓存流程

2.3.2 使用redis:过滤大量冷用户

在这里插入图片描述

解释上图:解释一下为什么加一层redis就可以过滤大量冷数据
第一步,将数据库里面中命中的用户放在redis的set类型中(即将mysql缓存中的数据放到redis中,让redis来当这个缓存),设置不过期。 这样相当把redis当作数据库的索引,只要查询redis,就可以知道是否数据存在。
第二步,redis中不存在就可以直接返回结果。解释:Redis中的数据就是mysql缓存给的,所以,redis不存在,就是冷数据,直接返回没有,就是这加入redis的关键,处理了大量冷数据。
第三步,redis中存在,缓存命中,就返回结果。
第四步,redis中存在,缓存不命中,就到数据库里面找到,并更新缓存,然后返回结果。

2.3.3 使用redis的三个问题:在缓存上面加一层redis带来的三个问题

问题1:redis本身可以做缓存,为什么不直接返回数据呢?分两种情况?
回答1:这里需要区分业务场景,
第一种:结果数据少,我们是可以直接使用redis作为缓存,直接返回数据。
第二种:结果比较大,就不太适合用redis存放了。比如用户生成的内容,一个评论里面可能存在上万字,业务字段多。

问题2:如果数据量比较大,单个set,会有性能问题?如何处理?
回答2:在redis中,bigkey 危害比较大,无论是扩容或缩容带来的内存申请释放, 还是查询命令使用不当导致大量数据返回,都会影响redis的稳定。redis中,解决bigkey 方法很简单。我们可以使用hash函数来分桶,将数据分散到多个key中(一个好的hash就是要足够散列、足够分散、足够平均),减少单个key的大小,同时不影响查询效率。

问题3:业务不重要,将全量数据放在redis中,占用服务器大量内存,投入产出不成比例?如何减少redis中大数据量占用的内存大小?一句话小结:使用布隆过滤器作为判断redis中是否存在元素的方法,这样存放的元素就可以占用最小的空间。
回答3:如果redis存储占用内存太大,我们需要减少内存使用,如何减少内存的使用,答案就是使用布隆过滤器算法作为判断集合中是否元素存在,可以以最小的时间成本和空间成本实现最大数据量的判断。解释:redis像一个集合,整个业务就是验证请求的参数是否在集合中,如果redis中存在就返回存在,如果redis中不存在就返回不存在。
现在两个问题:redis如何实现一个集合?如何实现用最小的空间成本对集合中的元素判断是否存在?
第一个问题:
redis中实现set用了两种结构:intset和hash table。 非数字或者大量数字时都会退化成hash table。
第二个问题:既然redis中的两个接口inset和hashtable,在数据量大的时候都会退化成hashtable,那么是否好的算法可以节省hash table的大小呢?答案就是布隆过滤器, 它实际上是一个很长的二进制向量和一系列随机映射函数。 布隆过滤器可以用于检索一个元素是否在一个集合中。 它的优点是空间效率和查询时间都远远超过一般的算法, 缺点是有一定的误识别率和删除困难。

现在我们已经成功引入布隆过滤器,由于redis就像存放了mysql的索引,所以布隆过滤器的作用就是作为判断redis中是否存在元素的方法,这样存放的元素就可以占用最小的空间,现在我们来介绍布隆过滤器。

2.4 Bloom Filter 原理 + Bloom Filter优点

2.4.1 布隆过滤器的原理

布隆过滤器的原理:

第一步:插入集合:当一个元素被加入集合时,通过K个散列函数将这个元素映射成一个位数组中的K个点,把它们置为1。

第二步:检索集合:我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。

注意:Bloom Filter跟单哈希函数Bit-Map不同之处在于:Bloom Filter使用了k个哈希函数,每个字符串跟k个bit对应。从而降低了冲突的概率。

2.4.2 布隆过滤器的原理深入,为什么检索集合时,如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在?

对于布隆过滤器:

简单的说一下就是我们先把我们数据库的数据都加载到我们的过滤器中,比如数据库的id现在有:1、2、3

那就用id:1 为例子,他在上图中经过三次hash之后,把三次原本值0的地方改为1

下次我进来查询如果id也是1 那我就把1拿去三次hash 发现跟上面的三个位置完全一样,那就能证明过滤器中有1的

反之如果不一样就说明不存在了

2.4.3 BloomFilter原理

我们常见的将业务字段拼接之后md5,放在一个集合中。 md5生成一个固定长度的128bit的串。 如果我们用bitmap来表示,则需要

2^128 = 340282366920938463463374607431768211456 bit

判断一个值在不在,就变成在这个bitmap中判断所在位是否为1。 由于这个2^128bit太大了,因此我们只能分配有限的空间来存储。 比如:

import crc32

def BloomFilter(sample, size, hash_size=1):
    # 构造一个hash函数,将输入数据散列到size一个位置上
    hash = lambda x:crc32(str(x).encode())%size
    collision, s = 0, set()
    for i in range(sample):
        k = set()
        for j in range(hash_size):
            k.add(hash(i+j*size/hash_size))
        # 只有所有散列结果k都在s中,才认为i重复
        if not k - s:
            collision += 1
            continue
        # 将散列结果k更新到集合s中
        s |= k
    return collision

当只有一个hash函数时:很容易发生冲突。

布隆过滤器作为一个判断集合中是否存在元素的算法。
第一个,设计一个足够好的hash算法,足够散列,足够平均,使哈希冲突的概率尽可能小。
第二个,一个哈希算法终究是不够的,使用多个哈希算法,使哈希冲突的概率尽可能小。
两个点都是一个目的,降低哈希冲突。

在这里插入图片描述

解释上图:
可以看到上面1和2的hash结果都是7,发生冲突。 如果增加hash函数,会发生什么情况?
在这里插入图片描述

我们使用更多的hash函数和更大的数据集合来测试。得到下面这张表

在这里插入图片描述

由此可以看到当增加hash方法能够有效的降低碰撞机率。 比较好的数据如下:

在这里插入图片描述

但是增加了hash方法之后,会降低空间的使用效率。当集合占用总体空间达到25%的时候, 增加hash 的效果已经不明显
在这里插入图片描述

上面的使用多个hash方法来降低碰撞就是BloomFilter的核心思想。

布隆过滤器的核心:不存储数据的本身。
布隆过滤器算法优点(因为不存储数据本身,所以数据空间小,检索快,带来了优点):
数据空间小,不用存储数据本身,刚刚说了2^128太大了。
布隆过滤器算法本身缺点(因为不存储数据本身,所以误差和难以删除,带来了缺点):
(1)元素可以添加到集合中,但不能被删除。
(2)匹配结果只能是“绝对不在集合中”,并不能保证匹配成功的值已经在集合中。
(3)当集合快满时,即接近预估最大容量时,误报的概率会变大。
(4)数据占用空间放大。一般来说,对于1%的误报概率,每个元素少于10比特,与集合中的元素的大小或数量无关。 查询过程变慢,hash函数增多,导致每次匹配过程,需要查找多个位(hash个数)来确认是否存在。
小结:对于BloomFilter的优点来说,缺点都可以忽略。毕竟只需要kN的存储空间就能存储N个元素。空间效率十分优秀。

2.5 Bloom Filter的缺点

bloom filter之所以能做到在时间和空间上的效率比较高,是因为牺牲了判断的准确率、删除的便利性

布隆过滤器缺点一:存在误判,可能要查到的元素并没有在容器中,但是hash之后得到的k个位置上值都是1(即上面的说的,全为1,可能元素存在)。
解决方式:如果bloom filter中存储的是黑名单,那么可以通过建立一个白名单来存储可能会误判的元素。

布隆过滤器缺点二:删除困难。一个放入容器的元素映射到bit数组的k个位置上全是1,删除的时候不能简单的直接置为0,简单的全部设置为0,可能会影响其他元素的判断。
解决方法:可以采用Counting Bloom Filter。

2.6 Bloom Filter 实现与使用

2.6.1 Guava:提供了一种Bloom Filter的实现

布隆过滤器有许多实现与优化,Guava中就提供了一种Bloom Filter的实现。

在使用bloom filter时,绕不过的两点是预估数据量n以及期望的误判率fpp,
在实现bloom filter时,绕不过的两点就是hash函数的选取以及bit数组的大小。

对于一个确定的场景,我们预估要存的数据量为n,期望的误判率为fpp,然后需要计算我们需要的Bit数组的大小m,以及hash函数的个数k,并选择hash函数

(1)Bit数组大小选择
根据预估数据量n以及误判率fpp,bit数组大小的m的计算方式:
(2)哈希函数选择
​ 由预估数据量n以及bit数组长度m,可以得到一个hash函数的个数k:

​ 哈希函数的选择对性能的影响应该是很大的,一个好的哈希函数就是要足够平均散列,避免哈希冲突,要能近似等概率的将字符串映射到各个Bit。选择k个不同的哈希函数比较麻烦,一种简单的方法是选择一个哈希函数,然后送入k个不同的参数。

哈希函数个数k、位数组大小m、加入的字符串数量n的关系可以参考

2.6.2 布隆过滤器的使用

要使用BloomFilter,需要引入guava包(guava是一个布隆过滤器的实现,):

 <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>23.0</version>
 </dependency>    

测试分两步:

1、往过滤器中放一百万个数,然后去验证这一百万个数是否能通过过滤器

2、另外找一万个数,去检验漏网之鱼的数量

/**
 * 测试布隆过滤器(可用于redis缓存穿透)
 * 
 * @author 敖丙
 */
public class TestBloomFilter {

    private static int total = 1000000;
    private static BloomFilter<Integer> bf = BloomFilter.create(Funnels.integerFunnel(), total);
//    private static BloomFilter<Integer> bf = BloomFilter.create(Funnels.integerFunnel(), total, 0.001);

    public static void main(String[] args) {
        // 初始化1000000条数据到过滤器中
        for (int i = 0; i < total; i++) {
            bf.put(i);
        }

        // 匹配已在过滤器中的值,是否有匹配不上的
        for (int i = 0; i < total; i++) {
            if (!bf.mightContain(i)) {
                System.out.println("有坏人逃脱了~~~");
            }
        }

        // 匹配不在过滤器中的10000个值,有多少匹配出来
        int count = 0;
        for (int i = total; i < total + 10000; i++) {
            if (bf.mightContain(i)) {
                count++;
            }
        }
        System.out.println("误伤的数量:" + count);
    }

}

运行结果表示,遍历这一百万个在过滤器中的数时,都被识别出来了。一万个不在过滤器中的数,误伤了320个,错误率是0.03(320/100000=0.032)左右。

看下BloomFilter的源码:

public static <T> BloomFilter<T> create(Funnel<? super T> funnel, int expectedInsertions) {
        return create(funnel, (long) expectedInsertions);
    }  

    public static <T> BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions) {
        return create(funnel, expectedInsertions, 0.03); // FYI, for 3%, we always get 5 hash functions
    }

    public static <T> BloomFilter<T> create(
          Funnel<? super T> funnel, long expectedInsertions, double fpp) {
        return create(funnel, expectedInsertions, fpp, BloomFilterStrategies.MURMUR128_MITZ_64);
    }

    static <T> BloomFilter<T> create(
      Funnel<? super T> funnel, long expectedInsertions, double fpp, Strategy strategy) {
     ......
    }

BloomFilter一共四个create方法,不过最终都是走向第四个,我们就看第四个create方法,这个方法有四个参数,看一下每个参数的含义:

funnel:数据类型(一般是调用Funnels工具类中的)

expectedInsertions:期望插入的值的个数

fpp 错误率(默认值为0.03)

strategy 哈希算法(我也不懂啥意思)

2.6.3 Bloom Filter的应用

在最后一个create方法中,设置一个断点:

对于普通的布隆过滤器:上面的numBits,表示存一百万个int类型数字,需要的位数为7298440,700多万位。

对于普通哈希表:理论上存一百万个数,一个int是4字节32位,需要3200万位。如果使用HashMap去存,按HashMap50%的存储效率,需要6400万位。

可以看出BloomFilter的存储空间很小,只有HashMap的1/10左右(700万位和6400万位)。

回忆:
注意1:布隆过滤器的优点:空间效率和查询时间都远远超过一般的哈希算法;
注意2:bloom filter之所以能做到在时间和空间上的效率比较高,是因为牺牲了判断的准确率、删除的便利性

2.6.4 错误率越大,所需空间和时间越小,错误率越小,所需空间和时间越大

上面的numHashFunctions,表示需要5个函数去存这些数字

使用第三个create方法,我们设置下错误率:

private static BloomFilter bf = BloomFilter.create(Funnels.integerFunnel(), total, 0.0003);

再运行看看:

此时误伤的数量为4,错误率为0.04%左右。

当错误率设为0.0003时,所需要的位数为16883499,1600万位,需要12个函数

所以,错误率越大,所需空间和时间越小,错误率越小,所需空间和时间越大,这与我们的认知是一致的。

2.7 进阶:计数过滤器(Counting Filter),解决布隆过滤器的删除困难的问题

计数过滤器的本质:阵列位置(桶)从1个位扩展到n个位过滤器
提供了一种在BloomFilter上实现删除操作的方法,而无需重新重新创建过滤器。在计数滤波器中,阵列位置(桶)从 1个位 扩展到 n个位计数器。实际上,常规布隆过滤器也可以被视为计数过滤器,只是桶大小为一位。
计数过滤器的crud操作:
插入操作被扩展为递增每个桶的值,
查找操作检查每个所需的桶是否为非零。
删除操作被扩展为递减每个桶的值。

计数过滤器问题1:
存储桶的算术溢出是一个问题,为了解决这个问题,存储桶应该足够大以使这种情况很少见。如果确实发生,则增量和减量操作必须将存储区设置为最大可能值,以便保留BloomFilter的属性。

计数过滤器问题2:
计数器的大小通常为3或4位(传统布隆过滤器是1位)。因此,计算布隆过滤器的空间比静态布隆过滤器多3到4倍。解决方式:一种特殊的数据结构允许删除但使用比静态BloomFilter更少的空间。

计数过滤器问题3:
计数过滤器的另一个问题是可扩展性有限。由于无法扩展计数布隆过滤器表,因此必须事先知道要同时存储在过滤器中的最大键数。一旦超过表的设计容量,随着插入更多密钥,误报率将迅速增长。

解决1:空间小
一种基于d-left散列的数据结构,它在功能上是等效的,但使用的空间大约是计算BloomFilter的一半。此数据结构中不会出现可伸缩性问题。一旦超出设计容量,就可以将密钥重新插入到双倍大小的新哈希表中。

解决2:插入和删除
节省空间的变体,可用于通过支持插入和删除来实现计数过滤器。

解决3:误报和删除
一种基于变量增量的新通用方法,该方法显着提高了计算布隆过滤器及其变体的误报概率,同时仍支持删除。与计数布隆过滤器不同,在每个元素插入时,散列计数器以散列变量增量而不是单位增量递增。要查询元素,需要考虑计数器的确切值,而不仅仅是它们的正面性。如果由计数器值表示的总和不能由查询元素的相应变量增量组成,则可以将否定答案返回给查询。

三、从布隆过滤器到布谷鸟过滤器

从布隆过滤器和计数过滤器和布谷鸟过滤器,都是过滤器,都是判断集合中是否存放元素的算法,像对于传统的hash算法有改进,都是为redis服务的,知道本质就好。

3.1 布谷鸟过滤器引入:解决布隆过滤器不能删除元素的问题

为了解决布隆过滤器不能删除元素的问题,布谷鸟过滤器横空出世。

论文《Cuckoo Filter:Better Than Bloom》作者将布谷鸟过滤器和布隆过滤器进行了深入的对比。相比布谷鸟过滤器而言布隆过滤器有以下不足:查询性能弱、空间利用效率低、不支持反向操作(删除)以及不支持计数。

问题1:查询性能弱是因为布隆过滤器需要使用多个 hash 函数探测位图中多个不同的位点,这些位点在内存上跨度很大,会导致 CPU 缓存行命中率低。

问题2:空间效率低是因为在相同的误判率下,布谷鸟过滤器的空间利用率要明显高于布隆,空间上大概能节省 40% 多。不过布隆过滤器并没有要求位图的长度必须是 2 的指数,而布谷鸟过滤器必须有这个要求。从这一点出发,似乎布隆过滤器的空间伸缩性更强一些。

问题3:不支持反向删除操作这个问题着实是击中了布隆过滤器的软肋。在一个动态的系统里面元素总是不断的来也是不断的走。布隆过滤器就好比是印迹,来过来就会有痕迹,就算走了也无法清理干净。比如你的系统里本来只留下 1kw 个元素,但是整体上来过了上亿的流水元素,布隆过滤器很无奈,它会将这些流失的元素的印迹也会永远存放在那里。随着时间的流失,这个过滤器会越来越拥挤,直到有一天你发现它的误判率太高了,不得不进行重建。

布谷鸟过滤器在论文里声称自己解决了这个问题,它可以有效支持反向删除操作。而且将它作为一个重要的卖点,诱惑你们放弃布隆过滤器改用布谷鸟过滤器。

但是经过我一段时间的调查研究发现,布谷鸟过滤器并没有它声称的那么美好。它支持的反向删除操作非常鸡肋,以至于你根本没办法使用这个功能。在向读者具体说明这个问题之前,还是先给读者仔细讲解一下布谷鸟过滤器的原理。

布谷鸟哈希对于布隆哈希的改进
1、布隆哈希查询性能弱:没有太大改进,布隆哈希查询效率不低,布谷鸟哈希就是直接hash生成下表,然后到数组中按位查找,效率O(1),效率有提升。
2、布隆哈希空间利用效率低:瞎说
分为两种情况,布谷鸟哈希空间效率和布隆哈希空间效率比较
情况一:支持删除操作情况下,布谷鸟过滤器空间效率比布隆过滤器低
布谷鸟过滤器的所谓的支持删除操作,就是让要插入的相同元素格式小于等于 (kn+1),即"如果想要让布谷鸟过滤器支持删除操作,那么就必须不能允许插入操作多次插入同一个元素,确保每一个元素不会被插入多次(kb+1)。这里的 k 是指 hash 函数的个数 2,b 是指单个位置上的座位数,这里我们是 4"
如果两个位置的 8 个座位 都存储了同一个元素,那么空间浪费也是很严重的,空间效率直接被砍得只剩下 1/8,这样的空间效率根本无法与布隆过滤器抗衡了。
情况二:不支持删除操作情况下,布谷鸟过滤器,空间要求为2的指数
如果不谈论删除操作,那么布谷鸟过滤器单纯从空间效率上来说还是有一定的可比性的这确实比布隆过滤器做的要好一点,但是布谷鸟过滤器这必须的 2 的指数的空间需求又再次让空间效率打了个折扣。
3、不支持反向操作(即只有正向添加,没有反向删除):布谷鸟哈希的删除操作有特定的限制条件,而且这个条件在实践中很难满足,所以删除操作很鸡肋,没什么用
接上面,布谷鸟哈希支持删除的前提是,让要插入的相同元素格式小于等于 (kn+1)
4、不支持计数:瞎说
计数过滤器就是支持计数的,布隆过滤器如果删除操作鸡肋,计数照样困难

3.2 布谷鸟过滤器原理:从布谷鸟开始

布谷鸟哈希

布谷鸟过滤器源于布谷鸟哈希算法,布谷鸟哈希算法源于生活 —— 那个热爱「鸠占鹊巢」的布谷鸟。布谷鸟喜欢滥交(自由),从来不自己筑巢。它将自己的蛋产在别人的巢里,让别人来帮忙孵化。待小布谷鸟破壳而出之后,因为布谷鸟的体型相对较大,它又将养母的其它孩子(还是蛋)从巢里挤走 —— 从高空摔下夭折了。

最简单的布谷鸟哈希结构是一维数组结构,会有两个 hash 算法将新来的元素映射到数组的两个位置。如果两个位置中有一个位置为空,那么就可以将元素直接放进去。但是如果这两个位置都满了,它就不得不「鸠占鹊巢」,随机踢走一个,然后自己霸占了这个位置。

p1 = hash1(x) % l
p2 = hash2(x) % l

不同于布谷鸟的是,布谷鸟哈希算法会帮这些受害者(被挤走的蛋)寻找其它的窝(金手指:后面布隆过滤器的add()源码你就可以看到)。因为每一个元素都可以放在两个位置(金手指:因为两个hash生成了两个下标,所以两个位置),只要任意一个有空位置,就可以塞进去。所以这个伤心的被挤走的蛋会看看自己的另一个位置有没有空,如果空了,自己挪过去也就皆大欢喜了。但是如果这个位置也被别人占了呢?好,那么它会再来一次「鸠占鹊巢」,将受害者的角色转嫁给别人。然后这个新的受害者还会重复这个过程直到所有的蛋都找到了自己的巢为止。

正如鲁迅的那句名言「占自己的巢,让别人滚蛋去吧!」

3.3 布谷鸟哈希的两个问题及其处理

3.3.1 布谷鸟哈希的两个问题

第一个问题:如果数组太拥挤了,连续踢来踢去几百次还没有停下来,这时候会严重影响插入效率。这时候布谷鸟哈希会设置一个阈值,当连续占巢行为超出了某个阈值,就认为这个数组已经几乎满了。这时候就需要对它进行扩容,重新放置所有元素。

第二个问题:可能会存在挤兑循环。比如两个不同的元素,hash 之后的两个位置正好相同,这时候它们一人一个位置没有问题。但是这时候来了第三个元素,它 hash 之后的位置也和它们一样,很明显,这时候会出现挤兑的循环。不过让三个不同的元素经过两次 hash 后位置还一样,这样的概率并不是很高,除非你的 hash 算法太挫了。

布谷鸟哈希算法对待这种挤兑循环的态度就是认为数组太拥挤了,需要扩容(实际上并不是这样)。

3.3.2 布谷鸟哈希的两个问题的解决

3.3.2.1 第一个问题:减少哈希冲突:增加hash函数+一个位置多个座位

上面的布谷鸟哈希算法的平均空间利用率并不高,大概只有 50%。到了这个百分比,就会很快出现连续挤兑次数超出阈值。这样的哈希算法价值并不明显,所以需要对它进行改良。

改良的方案之一是增加 hash 函数,让每个元素不止有两个巢,而是三个巢、四个巢。这样可以大大降低碰撞的概率,将空间利用率提高到 95%左右。

另一个改良方案是在数组的每个位置上挂上多个座位,这样即使两个元素被 hash 在了同一个位置,也不必立即「鸠占鹊巢」,因为这里有多个座位,你可以随意坐一个。除非这多个座位都被占了,才需要进行挤兑。很明显这也会显著降低挤兑次数。这种方案的空间利用率只有 85%左右,但是查询效率会很高,同一个位置上的多个座位在内存空间上是连续的,可以有效利用 CPU 高速缓存。

所以更加高效的方案是将上面的两个改良方案融合起来,比如使用 4 个 hash 函数,每个位置上放 2 个座位。这样既可以得到时间效率,又可以得到空间效率。这样的组合甚至可以将空间利用率提到高 99%,这是非常了不起的空间效率。

3.3.2.2 第二个问题:特殊的 hash 函数(布谷鸟过滤器使用特殊的hash函数处理挤兑循环问题,彻底解决这个问题)

布谷鸟过滤器

布谷鸟过滤器和布谷鸟哈希结构一样,它也是一维数组,但是不同于布谷鸟哈希的是,布谷鸟哈希会存储整个元素,而布谷鸟过滤器中只会存储元素的指纹信息(几个bit,类似于布隆过滤器)(金手指:这是布谷鸟过滤器和布谷鸟哈希的唯一本质区别,原因:布谷鸟哈希只是一种理论,布谷鸟过滤器是一种实际算法,实际算法兼顾到空间效率,所以只存储指纹,几个bit)。这里过滤器牺牲了数据的精确性换取了空间效率。正是因为存储的是元素的指纹信息,所以会存在误判率,这点和布隆过滤器如出一辙。

首先布谷鸟过滤器还是只会选用两个 hash 函数,但是每个位置可以放置多个座位。这两个 hash 函数选择的比较特殊,因为过滤器中只能存储指纹信息。当这个位置上的指纹被挤兑之后,它需要计算出另一个对偶位置。而计算这个对偶位置是需要元素本身的,我们来回忆一下前面的哈希位置计算公式。

fp = fingerprint(x)
p1 = hash1(x) % l
p2 = hash2(x) % l

我们知道了 p1 和 x 的指纹,是没办法直接计算出 p2 的。

特殊的 hash 函数(布谷鸟过滤器使用特殊的hash函数处理挤兑循环问题,彻底解决这个问题)

布谷鸟过滤器巧妙的地方就在于设计了一个独特的 hash 函数,使得可以根据 p1 和 元素指纹 直接计算出 p2,而不需要完整的 x 元素。

fp = fingerprint(x)
p1 = hash(x)
p2 = p1 ^ hash(fp)  // 异或

从上面的公式中可以看出,当我们知道 fp 和 p1,就可以直接算出 p2。同样如果我们知道 p2 和 fp,也可以直接算出 p1 —— 对偶性。

p1 = p2 ^ hash(fp)

所以我们根本不需要知道当前的位置是 p1 还是 p2,只需要将当前的位置和 hash(fp) 进行异或计算就可以得到对偶位置。而且只需要确保 hash(fp) != 0 就可以确保 p1 != p2,如此就不会出现自己踢自己导致死循环的问题。

也许你会问为什么这里的 hash 函数不需要对数组的长度取模呢?实际上是需要的,但是布谷鸟过滤器强制数组的长度必须是 2 的指数,所以对数组的长度取模等价于取 hash 值的最后 n 位。在进行异或运算时,忽略掉低 n 位 之外的其它位就行。将计算出来的位置 p 保留低 n 位就是最终的对偶位置。

// l = power(2, 8)
p_ = p & 0xff

3.4 布谷鸟哈希定义+增加+删除+查找

数据结构(金手指:这个struct就是布谷鸟过滤器,或者说bucket数组就是布谷鸟过滤器)

简单起见,我们假定指纹占用一个字节,每个位置有 4 个 座位。

type bucket [4]byte  // 一个桶,4个座位
type cuckoo_filter struct {
  buckets [size]bucket // 一维数组,存放的都是指纹,几个bit,布谷鸟过滤是实际,布谷鸟哈希是理论
  nums int  // 容纳的元素的个数
  kick_max  // 最大挤兑次数
}

插入算法

插入需要考虑到最坏的情况,那就是挤兑循环。所以需要设置一个最大的挤兑上限

def insert(x):
  fp = fingerprint(x)
  p1 = hash(x)    // 注意:自己设置的hash()是用来计算位置的,哈希的结果是buckets数组的下标 add delete
  p2 = p1 ^ hash(fp)     // 注意:自己设置的hash()是用来计算位置的,哈希的结果是buckets数组的下标 add delete
  // 尝试加入第一个位置
  if !buckets[p1].full():
    buckets[p1].add(fp)   // 你看,存放的都是指纹,几个bit,布谷鸟过滤是实际,布谷鸟哈希是理论
    // 注意:自己设置的hash()是用来计算位置的,哈希的结果是buckets数组的下标 add delete
    nums++
    return true
  // 尝试加入第二个位置
  if !buckets[p2].full():
    buckets[p2].add(fp)   // 你看,存放的都是指纹,几个bit,布谷鸟过滤是实际,布谷鸟哈希是理论
    // 注意:自己设置的hash()是用来计算位置的,哈希的结果是buckets数组的下标 add delete
    nums++
    return true
  // 随机挤兑一个位置
  p = rand(p1, p2)
  c = 0
  while c < kick_max:
    // 挤兑
    old_fp = buckets[p].replace_with(fp)   // 你看,存放的都是指纹,几个bit,布谷鸟过滤是实际,布谷鸟哈希是理论
    fp = old_fp   // 记录下old_fp,用来将原来的计算对偶位置和插入对偶位置
    // 注意:自己设置的hash()是用来计算位置的,哈希的结果是buckets数组的下标 add delete
    // 计算对偶位置
    p = p ^ hash(fp)  
    // 尝试加入对偶位置 
    if !buckets[p].full():
      buckets[p].add(fp)    // 你看,存放的都是指纹,几个bit,布谷鸟过滤是实际,布谷鸟哈希是理论
      nums++
      return true   // 一定要为之前的元素找到存放的位置,才算完成,才能返回true  good
    c++
  return false

插入算法的三种情况(结构体中包含nums buckets kick_max,要使用到这三个,nums要++,buckets要获取数组元素,kick_max要重试次数)
1、散列函数中的第一个位置,插入元素并设置nums,涉及到buckets的byte数组和nums++
2、第一个位置插入fulll,散列函数中的第二个位置,插入元素并设置nums,涉及到buckets的byte数组和nums++
3、第一个位置和第二个位置都full,一定要插进去,就只能鸠占鹊巢,在kick_max次数下,完成鸠占鹊巢,注意,一定要为之前的元素找到存放的位置,才算完成,才能返回true
注意:自己设置的hash()是用来计算位置的,哈希的结果是buckets数组的下标

查找算法

查找非常简单,在两个 hash 位置的桶里找一找有没有自己的指纹就 ok 了。

def contains(x):
  fp = fingerprint(x)
  p1 = hash(x)
  p2 = p1 ^ hash(fp)
  return buckets[p1].contains(fp) || buckets[p2].contains(fp)

解释查找算法:(结构体中包含nums buckets kick_max,要使用到这一个,nums不需要加减,buckets要获取数组元素,kick_max不需要重试次数)
1、p1和p2中的两个哈希所指向的位置,任何一个存放指纹,就是返回为true,两个都没有找到,就返回为false,所以这里用|| 或;
2、哈希算法hash()是自己设计的,要设计一个好的,足够散列,避免哈希冲突

删除算法

删除算法和查找算法差不多,也很简单,在两个桶里把自己的指纹抹去就 ok 了。

def delete(x):
  fp = fingerprint(x)
  // 注意:自己设置的hash()是用来计算位置的,哈希的结果是buckets数组的下标 add delete
  p1 = hash(x)   // 第一,hash
  p2 = p1 ^ hash(fp)
  ok = buckets[p1].delete(fp) || buckets[p2].delete(fp)  // 第二,delete
  if ok:
    nums--
  return ok

解释删除算法(结构体中包含nums buckets kick_max,要使用到这两个,nums需要–,buckets要获取数组元素,kick_max不需要重试次数)
p1和p2中的两个哈希所指向的位置,任何一个找到了指纹,删除buckets数组中的元素,就返回为true,两个都没有找到,就返回为false

3.5 布谷鸟过滤器一个明显的弱点:布谷鸟过滤器确保每一个元素不会被插入多次(kb+1)

引入:布谷鸟过滤器确保每一个元素不会被插入多次(kb+1)

so far so good!布谷鸟过滤器看起来很完美啊!删除功能和获取元素个数的功能都具备,比布隆过滤器强大多了,而且似乎逻辑也非常简单,上面寥寥数行代码就完事了。如果插入操作返回了 false,那就意味着需要扩容了,这也非常显而易见。

but! 考虑一下,如果布谷鸟过滤器对同一个元素进行多次连续的插入会怎样?

根据上面的逻辑,毫无疑问,这个元素的指纹会霸占两个位置上的所有座位 —— 8个座位。这 8 个座位上的值都是一样的,都是这个元素的指纹。如果继续插入,则会立即出现挤兑循环。从 p1 槽挤向 p2 槽,又从 p2 槽挤向 p1 槽。

也许你会想到,能不能在插入之前做一次检查,询问一下过滤器中是否已经存在这个元素了?这样确实可以解决问题,插入同样的元素也不会出现挤兑循环了。但是删除的时候会出现高概率的误删。因为不同的元素被 hash 到同一个位置的可能性还是很大的,而且指纹只有一个字节,256 种可能,同一个位置出现相同的指纹可能性也很大。如果两个元素的 hash 位置相同,指纹相同,那么这个插入检查会认为它们是相等的。

插入 x,检查时会认为包含 y。因为这个检查机制会导致只会存储一份指纹(x 的指纹)。那么删除 y 也等价于删除 x。这就会导致较高的误判率。

论文中明确告诉我们如果想要让布谷鸟过滤器支持删除操作,那么就必须不能允许插入操作多次插入同一个元素,确保每一个元素不会被插入多次(kb+1)。这里的 k 是指 hash 函数的个数 2,b 是指单个位置上的座位数,这里我们是 4。

在现实世界的应用中,确保一个元素不被插入指定的次数那几乎是不可能做到的。如果你觉得可以做到,请思考一下要如何做!你是不是还得维护一个外部的字典来记录每个元素的插入次数呢?这个外部字典的存储空间怎么办?

因为不能完美的支持删除操作,所以也就无法较为准确地估计内部的元素数量。

证明:布谷鸟过滤器确保每一个元素不会被插入多次(kb+1)

下面我们使用开源的布谷鸟过滤器库来证明一下上面的推论

go get github.com/seiflotfy/cuckoofilter

这个布谷鸟过滤器对每个元素存储的指纹信息为一个字节,同一个位置会有 4 个座位。我们尝试向里面插入 15 次同一个元素。

package main

import (
    "fmt"
    "github.com/seiflotfy/cuckoofilter"
)

func main() {
    cf := cuckoo.NewFilter(100000)
    for i := 0; i < 15; i++ {
        var ok = cf.Insert([]byte("geeky ogre"))
        fmt.Println(ok)
    }
}

-------
true
true
true
true
true
true
true
true
false
false
false
false
false
false
false

我们发现插入它最多只能插入 8 次同一个元素。后面每一次返回 false 都会经过上百次的挤兑循环直到触碰了最大挤兑次数。

3.6 布谷鸟哈希对于布隆哈希的改进

布谷鸟哈希对于布隆哈希的改进
1、布隆哈希查询性能弱:没有太大改进,布隆哈希查询效率不低,布谷鸟哈希就是直接hash生成下表,然后到数组中按位查找,效率O(1),效率有提升。
2、布隆哈希空间利用效率低:瞎说
分为两种情况,布谷鸟哈希空间效率和布隆哈希空间效率比较
情况一:支持删除操作情况下,布谷鸟过滤器空间效率比布隆过滤器低
布谷鸟过滤器的所谓的支持删除操作,就是让要插入的相同元素格式小于等于 (kn+1),即"如果想要让布谷鸟过滤器支持删除操作,那么就必须不能允许插入操作多次插入同一个元素,确保每一个元素不会被插入多次(kb+1)。这里的 k 是指 hash 函数的个数 2,b 是指单个位置上的座位数,这里我们是 4"
如果两个位置的 8 个座位 都存储了同一个元素,那么空间浪费也是很严重的,空间效率直接被砍得只剩下 1/8,这样的空间效率根本无法与布隆过滤器抗衡了。
情况二:不支持删除操作情况下,布谷鸟过滤器,空间要求为2的指数
如果不谈论删除操作,那么布谷鸟过滤器单纯从空间效率上来说还是有一定的可比性的这确实比布隆过滤器做的要好一点,但是布谷鸟过滤器这必须的 2 的指数的空间需求又再次让空间效率打了个折扣。
3、不支持反向操作(即只有正向添加,没有反向删除):布谷鸟哈希的删除操作有特定的限制条件,而且这个条件在实践中很难满足,所以删除操作很鸡肋,没什么用
接上面,布谷鸟哈希支持删除的前提是,让要插入的相同元素格式小于等于 (kn+1)
4、不支持计数:瞎说
计数过滤器就是支持计数的,布隆过滤器如果删除操作鸡肋,计数照样困难

四、面试金手指

以后总结

五、小结

布隆过滤器到计数过滤器,再到布谷鸟过滤,结束了。

天天打码,天天进步!!

  • 7
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

祖母绿宝石

打赏一下

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值