Redis——布隆过滤器

第一次听到布隆是从英雄联盟中,本篇讲的是布隆过滤器,是Redis避免缓存穿透的防御利器; 

1. 布隆过滤器的基本概念

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

——《布隆过滤器——维基百科》

简单来说就是:当我们通过布隆过滤器判断一个元素在不在集合中时,如果布隆过滤器返回的是在集合中,那么集合中可能有这个元素,但因为误判率也可能没有这个元素;如果布隆过滤器返回的不存在于集合中,那么集合中是一定不存在这个元素的;常见的使用场景就是作为避免Redis缓存穿透的利器;

2. Redis缓存穿透

举个例子,你的数据库新增一条记录一般都会自增主键加1,主键id是从1开始然后自增的,为了保证查询性能,我们每新增/更新一条记录就把它放入Redis中,并设置过期时间;

如果Hacker知道你的接口是通过id查询的,就拿负数去查询,这个时候,会发现缓存里面没这个数据,然后又去数据库查询一次发现也没有;一个请求这样,100个,1000个,10000个的这样的无效请求呢?每次请求都会绕过Redis而直接走到DB查询,这样你的DB基本上就扛不住了,也就是Redis缓存看起来被"穿透"了,大量请求可能把DB打挂掉;

解决思路是,如果在查询缓存之前,先判断当前请求所带的id是否在有效的id集合中,如果判断集合里没这个id就不去查了,直接return一个数据为空不就好了嘛。

所以问题回到了——如何高效判断一个元素是否在集合内

2. 判断一个元素是否在集合内

如果想判断一个元素是不是在一个集合里,一般想到的是将所有元素保存起来,然后通过比较确定;Java中最常见的就是HashSet(HashMap);

2.1 消耗空间分析

(1)传统方案:将集合M的用散列表保存,然后对数x判断是否在散列表中即可;内存计算:假设使用int类型来保存,那么10亿个数消耗的内存 = 4byte(int)*10亿 ≈ 381M。

(2)位图方案:由于我们只关心数x是否存在于集合中,因此我们通标记0和1来标记,通下标来表示数;即开辟空间使用byte数组来保存,那么10亿个数消耗的内存 = 1byte*10亿 ≈ 95M。

上面看去视乎消耗不大,那么我们现在将数据量扩大10倍,即有100亿个数,此时传统方案大约需要3.7G;使用位图的方案大约需要0.9G;

想想我们服务器的内存才多少个G,而这个还仅仅只是这个Java程序中的一个Java对象,同时其他外在因素都未考虑进去,而我们这仅仅是系统中一个小小的判断是否存在的功能;无论是使用位图方案还是传统方案都不是太符合要求;

其实,不要觉得10亿数据很多;假如公司的一个业务有1000万用户,平均每个用户一天产生10条数据,一个月(30天)就是3亿条数据,那么一年就是36亿条数据;而这只是一个业务功能中的一个表,如果有多个的话...

2.2 HashMap的问题

讲述布隆过滤器的原理之前,我们先思考一下,通常你判断某个元素是否存在用的是什么?应该蛮多人回答HashMap吧,确实可以将值映射到HashMap的Key,然后可以在O(1)的时间复杂度内返回结果,效率奇高;但是HashMap的实现也有缺点,例如存储容量占比高,考虑到负载因子的存在,通常空间是不能被用满的,而一旦你的值很多例如上亿的时候,那HashMap占据的内存大小就变得很可观了;

还比如说你的数据集存储在远程服务器上,本地服务接受输入,而数据集非常大不可能一次性读进内存构建HashMap的时候,也会存在问题;

2.3 BloomFilter基本思想

将所有元素保存起来,然后通过比较确定,链表,树等数据结构都是这种思路;但是随着集合中元素的增加,通过上面的内存消耗分析,我们知道需要的存储空间越来越大,检索速度也越来越慢;不过世界上还有一种叫作散列表(又叫哈希表,Hashtable)的数据结构;它可以通过一个Hash函数将一个元素映射成一个位阵列(BitArray)中的一个点;这样一来,我们只要看看这个点是不是1就知道可以集合中有没有它了;这就是布隆过滤器的基本思想;

Hash面临的问题就是冲突;

传统的散列表设计使用1个hash的话,会出现hash冲突;因此在布隆过滤器中选择使用多种不同的hash算法得到多个hash值,有多个hash值来定位一个数的位置,这样就可以有效的减小hash冲突的概率了;

假设Hash函数是良好的,如果我们的位阵列长度为m个点,那么如果我们想将冲突率降低到例如1%,这个散列表就只能容纳m/100个元素;显然这就不叫空间有效了(Space-efficient);解决方法也简单,就是使用多个Hash;

但是由于其散列表的基础特性依旧没有突破,因此依然会存在误判的可能(从图中可以很容易的发现),这也就就是布隆过滤器的特性总结一句话就是:

"告诉你没有那是真的没有;告诉你有可能是有,也可能是没有。你细品...像不像你问你女票的时候的情况,区别就布隆过滤器说没有那就是真的没有 :)"

因此布隆过滤器适合的场景是对误判有一定容忍度的场景,比如说:垃圾邮箱、钓鱼网址、URL地址判重(比如爬虫)、海量图库判重、推荐算法(比如新闻资讯这些推荐给用户,但是需要将用户看过的去掉,如果通过数据库里面的历史记录来去重,时间久了数据量会很大...)

引入布隆过滤器后,也会带来新的问题,导致将系统业务复杂化。正所谓 ‘鱼与熊掌不可兼得’;因此需要仔细思量。

3. BloomFilter的基本原理

布隆过滤器的原理是:(1)当一个元素加入集合时,通过K个散列函数将这个元素映射成一个位数组中的K个点把它们置为1;(2)检索时,让当前元素依次通过K个散列函数计算,我们只要看看这K个点是不是都是1就(大约)知道集合中有没有它了,即可以反推:如果这K个点中有有任何一个0,则被检元素一定不在;如果都是1,则被检元素E很可能在。这就是布隆过滤器的基本原理;

可以看下面的图,上面是S集合的元素分别通过函数hi(i=1,2,3)找到落在序列B上的3个点,然后将这些点置为1;下面判断d、e是否在集合中时,分别用d、e计算出点位,判断是否全是1(存在0则可以直接返回false);

总结一下,步骤如下:

1. 首先需要k个hash函数,每个函数可以把key散列成为1个整数;
2. 初始化时,需要一个长度为n比特的数组,每个比特位初始化为0;
3. 某个key加入集合时,用k个hash函数计算出k个散列值,并把数组中对应的比特位置为1;
4. 判断某个key是否在集合时,用k个hash函数计算出k个散列值,并查询数组中对应的比特位,如果所有的比特位都是1,认为在集合中;

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

4. BloomFilter的优缺点

优点

相比于其它的数据结构,布隆过滤器在空间和时间方面都有巨大的优势;布隆过滤器存储空间和插入/查询时间都是常数。另外,Hash 函数相互之间没有关系,方便由硬件并行实现;布隆过滤器不需要存储元素本身,在某些对保密要求非常严格的场合有优势;

缺点

bloom filter之所以能做到在时间和空间上的效率比较高,是因为牺牲了判断的准确率、删除的便利性;布隆过滤器的缺点和优点一样明显。误算率(False Positive)是其中之一。随着存入的元素数量增加,误算率随之增加。但是如果元素数量太少,则使用散列表足矣。

另外,删除困难也是缺点之一;一般情况下不能从布隆过滤器中删除元素;一个放入容器的元素映射到bit数组的k个位置上是1,删除的时候不能简单的直接置为0,可能会影响其他元素的判断;

我们很容易想到把位列阵变成整数数组(位点命中时不仅仅置为1,而是存放1的数量),每插入一个元素相应的计数器加1,这样删除元素时将计数器减掉就可以了;然而要保证安全的删除元素并非如此简单。首先我们必须保证删除的元素的确在布隆过滤器里面;这一点单凭这个过滤器是无法保证的;另外计数器回绕也会造成问题。目前可以采用Counting Bloom Filter;

5. 误判率False Positives概率推导

这一部分涉及数学知识,纯个人兴趣,可以略过;

(1)假设Hash函数以等概率条件选择并现在开始设置BitArray中的某一位,令:m是该位数组的大小(长度),k是Hash函数的个数;那么对于位数组BitArray中任意某一位P1,在进行1次元素插入时通过1次Hash操作使得P1置位"1"的概率是:

\frac{1}{m}

那么位数组中P1在进行元素插入时的1次Hash操作中没有被置位"1"的概率是:

1-\frac{1}{m}

那么在所有k次Hash操作后该位都没有被置 "1" 的概率是:

(1-\frac{1}{m})^{k}

以上是进行1次元素插入时的概率计算,如果我们插入了n个元素,那么某一位P1仍然为 "0" 的概率是:

(1-\frac{1}{m})^{kn}

从而,在插入了n个元素的条件下,那么某一位P1"1" 的概率是:

1-(1-\frac{1}{m})^{kn}

现在,检测某一元素是否在该集合中;即计算——"集合中插入了n个元素后,某个元素经过k个Hash函数计算后的k个位置(上一步计算的是针对某一位置P1),这k个位置都被设置为1"的概率,这个概率是针对任意元素的,也就是说该方法可能会使算法错误的认为某一原本不在集合中的元素却被检测为在该集合中False Positives),给予上一步的结果,该概率由以下公式确定:

(1-[1-\frac{1}{m}]^{kn})^{k} \approx (1-e^{-kn/m})^{k}

其实,上述结果是在假定由每个Hash计算出需要设置的位(bit)的位置是相互独立为前提计算出来的,不难看出,随着m(位数组大小)的增加,假正例(False Positives)的概率会下降,同时随着插入元素个数n的增加,FalsePositives的概率又会上升

(1)BitArray大小的选择

对于给定的False Positives概率 p,如何选择最优的位数组大小m由以下公式确定:

m = -\frac{n\cdot lnp}{(ln2)^{2}}

上式表明,位数组的大小m最好与插入元素的个数n成线性关系;

(2)哈希函数个数k的选择

对于给定的m,n,如何选择Hash函数个数k由以下公式确定:

k = \frac{m}{n}\cdot ln2

上式表明,Hash函数个数k与位数组的大小m成正比,与插入元素的个数n成反比;

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

6. BloomFilter的使用

6.1 Java本地内存使用布隆过滤器

引入依赖:

<dependency>
  <groupId>com.google.guava</groupId>
  <artifactId>guava</artifactId>
  <version>29.0-jre</version>
</dependency>

测试类:

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
 
public class BloomFilterTest {
 
    /** 预计插入的数据 */
    private static Integer expectedInsertions = 10000000;
    /** 误判率 */
    private static Double fpp = 0.01;
    /** 布隆过滤器 */
    private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(),expectedInsertions,fpp);
 
    public static void main(String[] args) {
        // 插入 1千万数据
        for (int i = 0; i < expectedInsertions; i++) {
            bloomFilter.put(i);
        }
 
        // 从10000000开始,用1千万数据测试误判率
        int count = 0;
        for (int i = expectedInsertions; i < expectedInsertions *2; i++) {
            if (bloomFilter.mightContain(i)) {
                count++;
            }
        }
        System.out.println("一共误判了:" + count);
 
    }
 
}

测试结果:

一共误判了:100055 //大概是expectedInsertions(1千万)的0.01,这与我们设置的 p = 0.01非常接近。

参数说明

在guava包中的BloomFilter源码中,构造一个BloomFilter对象有四个参数:

  • Funnel funnel:数据类型,由Funnels类指定即可
  • long expectedInsertions:预期插入的值的数量
  • fpp:错误率
  • BloomFilter.Strategy:hash算法

通过断点BloomFilter类中的构造函数,发现我们调整expectedInsertions和误判率p时,位数组BitArray的大小m(numBits)和Hash函数的个数k(numHashFunctions)都会自适应变化;

6.2 Java集成Redis使用布隆过滤器

Redis经常会被问道缓存击穿问题,比较优秀的解决办法是使用布隆过滤器,也有使用空对象解决的,但是最好的办法肯定是布隆过滤器,我们可以通过布隆过滤器来判断元素是否存在,避免缓存和数据库都不存在的数据进行查询访问;在如下的代码中只要通过bloomFilter.contains(xxx)即可,我这里演示的还是误判率;

引入依赖:

<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson-spring-boot-starter</artifactId>
  <version>3.16.0</version>
</dependency>

测试类:

import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
 
public class RedisBloomFilterTest {
 
    /** 预计插入的数据 */
    private static Integer expectedInsertions = 10000;
    /** 误判率 */
    private static Double fpp = 0.01;
 
    public static void main(String[] args) {
        // Redis连接配置,无密码
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.211.108:6379");
        // config.useSingleServer().setPassword("123456");
 
        // 初始化布隆过滤器
        RedissonClient client = Redisson.create(config);
        RBloomFilter<Object> bloomFilter = client.getBloomFilter("user");
        bloomFilter.tryInit(expectedInsertions,fpp);
 
        // 布隆过滤器增加元素
        for (Integer i = 0; i < expectedInsertions; i++) {
            bloomFilter.add(i);
        }
 
        // 统计元素
        int count = 0;
        for (int i = expectedInsertions; i < expectedInsertions*2; i++) {
            if (bloomFilter.contains(i)) {
                count++;
            }
        }
        System.out.println("误判次数" + count);
 
    }
 
}

7. BloomFilter的应用场景

根据布隆过滤器的特性,它可以告诉我们 “某个元素一定不存在集合中或者可能存在集合中”,也就是说布隆过滤器说这个数不存在则一定不存,布隆过滤器说这个数存在可能不存在(误判);以下是它的常见的应用场景:

  • 解决Redis缓存穿透问题(面试重点)
  • 邮件过滤,使用布隆过滤器来做邮件黑名单过滤
  • 对爬虫网址进行过滤,爬过的不再爬
  • 解决新闻推荐过的不再推荐(类似抖音刷过的往下滑动不再刷到)
  • HBase\RocksDB\LevelDB等数据库内置布隆过滤器,用于判断数据是否存在,可以减少数据库的IO请求

总结

布隆过滤器主要是在Redis防止缓存穿透的时候引出来的,文章里面还是写的比较复杂了,其实在面试中只要回答其基本原理和思想,还有就是知道他的使用场景即可;


参考:

布隆过滤器详解 - 简书

Redis-避免缓存穿透的利器之BloomFilter - 掘金

布隆过滤器 - 简书

布隆(Bloom Filter)过滤器——全面讲解

相关文章

文章浏览阅读752次。关系型数据库关系型数据库是一个结构化的...
文章浏览阅读687次,点赞2次,收藏5次。商城系统中,抢购和秒...
文章浏览阅读1.4k次。MongoTemplate开发spring-data-mongodb...
文章浏览阅读887次,点赞10次,收藏19次。1.背景介绍1. 背景...
文章浏览阅读819次。MongoDB连接失败记录_edentialmechanisn...
文章浏览阅读470次。mongodb抽取数据到ES,使用ELK内部插件无...