Bloom filter(布隆过滤器)学习与使用总结

一、简介

当我们使用主流数据结构如 Lists, Maps, Sets, Trees等等时,我可以得到确切的结果,无论这个数据存在或是不存在。概率数据结构能够提供一种基于内存的,快速的查找出一种可能而非确切的结果。这种概率数据结构就是布隆过滤器(Bloom filter)。
布隆过滤器可以检查值是“可能在集合中”还是“绝对不在集合中”。

定义

Bloom过滤器实质上由长度为m的位向量或位列表(仅包含0或1位值的列表)组成,最初所有值均设置为0,如下所示:

在这里插入图片描述
在哈希表中,我们使用单个哈希函数,因此仅获得单个索引作为输出。
但是对于Bloom过滤器,我们将使用多个哈希函数,这将为我们提供多个索引。

插入操作

在这里插入图片描述
如上图,对于给定的输入“geeks”,我们的3个散列函数将提供3个不同的输出1、4和7。我们已对其进行了标记。
在这里插入图片描述
对于另一个输入“nerd”,哈希函数给我们3、4和5。您可能已经注意到,索引“ 4”已经被先前的“geeks”输入标记了。(你肯定又疑问,继续看)

查找

我们已经用两个输入填充了位向量,现在我们可以检查它是否存在。我们该怎么做?
我们将使用3个哈希函数对 要搜索的值 进行哈希处理,然后查看保留的结果是什么。
比如,搜索“cat”,我们的哈希函数这次给了我们1、3和7。如下图:

在这里插入图片描述
而且我们可以看到所有索引都已标记为1。这意味着我们可以说,“也许’cat’已经插入到我们的列表中了”。但事实并非如此。所以,出了什么问题?
实际上,没有错。问题是,“误报”(false positive)就是这种情况。
布隆过滤器告诉我们,似乎之前已经插入了“cat”,因为应该用“cat”标记的索引已经被标记了(尽管是由于其他不同的数据)。
那么,是的,它有什么帮助?
好吧,让我们考虑一下,如果“猫”给我们的输出是1,6,7而不是1,3,7。那会发生什么呢?我们可以看到,在3个索引中,有6个是“ 0”,这意味着它没有被任何先前的输入标记。这显然意味着以前从未插入过“cat”,如果是的话,则不可能有6成为“ 0”,对吧?这样,bloom过滤器可以“确定”地判断列表中是否没有数据。
简而言之:
如果我们搜索一个值,并且发现该值的任何哈希索引为“ 0”,那么该值肯定不在列表中。
如果所有散列索引均为“ 1”,则“也许”搜索到的值在列表中。

特性

1)一个元素如果判断结果为存在的时候元素不一定存在,但是判断结果为不存在的时候则一定不存在。也就是说布隆过滤器只能判断数据是否一定不存在,而无法判断数据是否一定存在。
2) 布隆过滤器可以添加元素,但是不能删除元素。因为删掉元素会导致误判率增加。

优点

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

由于存放的不是完整的数据,所以占用的内存很少,而且新增,查询速度够快。

缺点

但是布隆过滤器的缺点和优点一样明显。误算率是其中之一。随着存入的元素数量增加,误算率随之增加。随着数据的增加,误判率随之增加;无法做到删除数据;只能判断数据是否一定不存在,而无法判断数据是否一定存在。
但是如果元素数量太少,则使用散列表足矣。
另外,一般情况下不能从布隆过滤器中删除元素。我们很容易想到把位数组变成整数数组,每插入一个元素相应的计数器加 1, 这样删除元素时将计数器减掉就可以了。然而要保证安全地删除元素并非如此简单。首先我们必须保证删除的元素的确在布隆过滤器里面。这一点单凭这个过滤器是无法保证的。另外计数器回绕也会造成问题。

二、布隆过滤器使用场景和实例

在程序的世界中,布隆过滤器是程序员的一把利器,利用它可以快速地解决项目中一些比较棘手的问题。
如网页 URL 去重、垃圾邮件识别、大集合中重复元素的判断和缓存穿透等问题。
布隆过滤器的典型应用有:

数据库防止穿库

Google Bigtable,HBase 和 Cassandra 以及 Postgresql 使用BloomFilter来减少不存在的行或列的磁盘查找。避免代价高昂的磁盘查找会大大提高数据库查询操作的性能。

业务场景中判断用户是否阅读过某视频或文章

比如抖音或头条,当然会导致一定的误判,但不会让用户看到重复的内容。

缓存宕机、缓存击穿场景

一般判断用户是否在缓存中,如果在则直接返回结果,不在则查询db,如果来一波冷数据,会导致缓存大量击穿,造成雪崩效应,这时候可以用布隆过滤器当缓存的索引,只有在布隆过滤器中,才去查询缓存,如果没查询到,则穿透到db。如果不在布隆器中,则直接返回。

WEB拦截器

如果相同请求则拦截,防止重复被攻击。用户第一次请求,将请求参数放入布隆过滤器中,当第二次请求时,先判断请求参数是否被布隆过滤器命中。可以提高缓存命中率。Squid 网页代理缓存服务器在 cache digests 中就使用了布隆过滤器。Google Chrome浏览器使用了布隆过滤器加速安全浏览服务

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

缓存穿透:当请求数据库中不存在的数据,这时候所有的请求都会打到数据库上,这种情况就是缓存穿透。如果当请求较多的话,这将会严重浪费数据库资源甚至导致数据库假死。
我们经常会把一部分数据放在Redis等缓存,比如产品详情。这样有查询请求进来,我们可以根据产品Id直接去缓存中取数据,而不用读取数据库,这是提升性能最简单,最普遍,也是最有效的做法。一般的查询请求流程是这样的:先查缓存,有缓存的话直接返回,如果缓存中没有,再去数据库查询,然后再把数据库取出来的数据放入缓存,一切看起来很美好。但是如果现在有大量请求进来,而且都在请求一个不存在的产品Id,会发生什么?既然产品Id都不存在,那么肯定没有缓存,没有缓存,那么大量的请求都怼到数据库,数据库的压力一下子就上来了,还有可能把数据库打死。
所以缓存穿透就是当请求数据库中不存在的数据,这时候所有的请求都会打到数据库上,这种情况就是缓存穿透。如果当请求较多的话,这将会严重浪费数据库资源甚至导致数据库假死。
虽然有很多办法都可以解决这问题,但是 “布隆过滤器”就可以解决(缓解)缓存穿透问题。至于为什么说是“缓解”,看下去你就明白了。

这种技术在缓存之前再加一层屏障,里面存储目前数据库中存在的所有key。
当业务系统有查询请求的时候,首先去BloomFilter中查询该key是否存在。若不存在,则说明数据库中也不存在该数据,因此缓存都不要查了,直接返回null。若存在,则继续执行后续的流程,先前往缓存中查询,缓存中没有的话再前往数据库中的查询。

redis伪代码:
    String get(String key){
        String value = redis.get(key);
        if (value == null) {
            if (!bloomFilter.mightContain(key)) {
                //不存在则返回
                return null;
            } else {
                //可能存在则查数据库
                value = db.get(key);
                redis.set(key, value);
            }
        }
        return value;
    }

大量数据,判断给定的是否在其中

现在有大量的数据,而这些数据的大小已经远远超出了服务器的内存,现在再给你一个数据,如何判断给你的数据在不在其中。如果服务器的内存足够大,那么用HashMap是一个不错的解决方案,理论上的时间复杂度可以达到O(1),但是现在数据的大小已经远远超出了服务器的内存,所以无法使用HashMap,这个时候就可以使用“布隆过滤器”来解决这个问题。但是还是同样的,会有一定的“误判率”。

检查用户输入的密码是否为弱密码

比如123456这种被成为是弱密码。
可以在启动项目的时候,将弱密码库中的所有数据查出来放到Bloom filter中,
当校验是否为弱密码时,先去Bloom filter中校验,如果不存在,则表明为非弱密码,如果存在,由于会出现误报(false positive)情况,这个时候,可以去库里面查询是否真的存在然后返回结果。这样可以大大提供效率。
如下图表示如密码校验流程:
在这里插入图片描述

三、实践

Guava 实现 BloomFilter

关于布隆过滤器,我们不需要自己实现,谷歌已经帮我们实现好了。

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

guava 的布隆过滤器,封装的非常好,使用起来非常简洁方便。
例: 预估数据量1w,错误率需要减小到万分之一。使用如下代码进行创建。

    public static void main(String[] args) {
    // 1.创建符合条件的布隆过滤器
    // 预期数据量10000,错误率0.0001
    BloomFilter<CharSequence> bloomFilter =
            BloomFilter.create(Funnels.stringFunnel(
                                    Charset.forName("utf-8")),10000, 0.0001);
    // 2.将一部分数据添加进去
    for (int i = 0; i < 5000; i++) {
        bloomFilter.put("" + i);
    }
    System.out.println("数据写入完毕");
    // 3.测试结果
    for (int i = 0; i < 10000; i++) {
        if (bloomFilter.mightContain("" + i)) {
            System.out.println(i + "存在");
        } else {
            System.out.println(i + "不存在");
        }
    }
}

Redis 实现 BloomFilter

我们使用guava实现布隆过滤器是把数据放在本地内存中,无法实现布隆过滤器的共享,我们还可以把数据放在redis中,用 redis来实现布隆过滤器,我们要使用的数据结构是bitmap,你可能会有疑问,redis支持五种数据结构:String,List,Hash,Set,ZSet,没有bitmap呀。没错,实际上bitmap的本质还是String。
要用redis来实现布隆过滤器,我们需要自己设计映射函数,自己度量二进制向量的长度,这对我来说,无疑是一个不可能完成的任务,只能借助搜索引擎,下面直接放出代码把。

public class RedisMain {
    static final int expectedInsertions = 100;//要插入多少数据
    static final double fpp = 0.01;//期望的误判率

    //bit数组长度
    private static long numBits;

    //hash函数数量
    private static int numHashFunctions;

    static {
        numBits = optimalNumOfBits(expectedInsertions, fpp);
        numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits);
    }

    public static void main(String[] args) {
        Jedis jedis = new Jedis("192.168.0.109", 6379);
        for (int i = 0; i < 100; i++) {
            long[] indexs = getIndexs(String.valueOf(i));
            for (long index : indexs) {
                jedis.setbit("codebear:bloom", index, true);
            }
        }
        for (int i = 0; i < 100; i++) {
            long[] indexs = getIndexs(String.valueOf(i));
            for (long index : indexs) {
                Boolean isContain = jedis.getbit("codebear:bloom", index);
                if (!isContain) {
                    System.out.println(i + "肯定没有重复");
                }
            }
            System.out.println(i + "可能重复");
        }
    }

    /**
     * 根据key获取bitmap下标
     */
    private static long[] getIndexs(String key) {
        long hash1 = hash(key);
        long hash2 = hash1 >>> 16;
        long[] result = new long[numHashFunctions];
        for (int i = 0; i < numHashFunctions; i++) {
            long combinedHash = hash1 + i * hash2;
            if (combinedHash < 0) {
                combinedHash = ~combinedHash;
            }
            result[i] = combinedHash % numBits;
        }
        return result;
    }

    private static long hash(String key) {
        Charset charset = Charset.forName("UTF-8");
        return Hashing.murmur3_128().hashObject(key, Funnels.stringFunnel(charset)).asLong();
    }

    //计算hash函数个数
    private static int optimalNumOfHashFunctions(long n, long m) {
        return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
    }

    //计算bit数组长度
    private static long optimalNumOfBits(long n, double p) {
        if (p == 0) {
            p = Double.MIN_VALUE;
        }
        return (long) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
    }
}

运行结果:

88可能重复
89可能重复
90可能重复
91可能重复
92可能重复
93可能重复
94可能重复
95可能重复
96可能重复
97可能重复
98可能重复
99可能重复

==不过Redis 官方提供的布隆过滤器到了 Redis 4.0 提供了插件功能。==布隆过滤器作为一个插件加载到 Redis Server 中,给 Redis 提供了强大的布隆去重功能。
在已安装 Redis 的前提下,安装 RedisBloom,有两种方式
直接编译进行安装

git clone https://github.com/RedisBloom/RedisBloom.git
cd RedisBloom
make     #编译 会生成一个rebloom.so文件
redis-server --loadmodule /path/to/rebloom.so   #运行redis时加载布隆过滤器模块
redis-cli    # 启动连接容器中的 redis 客户端验证

使用Docker进行安装

docker pull redislabs/rebloom:latest # 拉取镜像
docker run -p 6379:6379 --name redis-redisbloom redislabs/rebloom:latest #运行容器
docker exec -it redis-redisbloom bash
redis-cli    

使用
布隆过滤器基本指令:

  • bf.add 添加元素到布隆过滤器
  • bf.exists 判断元素是否在布隆过滤器
  • bf.madd 添加多个元素到布隆过滤器,bf.add 只能添加一个
  • bf.mexists 判断多个元素是否在布隆过滤器
127.0.0.1:6379> bf.add user Tom
(integer) 1
127.0.0.1:6379> bf.add user John
(integer) 1
127.0.0.1:6379> bf.exists user Tom
(integer) 1
127.0.0.1:6379> bf.exists user Linda
(integer) 0
127.0.0.1:6379> bf.madd user Barry Jerry Mars
1) (integer) 1
2) (integer) 1
3) (integer) 1
127.0.0.1:6379> bf.mexists user Barry Linda
1) (integer) 1
2) (integer) 0

我们只有这几个参数,肯定不会有误判,当元素逐渐增多时,就会有一定的误判了。
上面使用的布隆过滤器只是默认参数的布隆过滤器,它在我们第一次 add 的时候自动创建。
Redis 还提供了自定义参数的布隆过滤器,bf.reserve 过滤器名 error_rate initial_size

  • error_rate:允许布隆过滤器的错误率,这个值越低过滤器的位数组的大小越大,占用空间也就越大
  • initial_size:布隆过滤器可以储存的元素个数,当实际存储的元素个数超过这个值之后,过滤器的准确率会下降
    但是这个操作需要在 add 之前显式创建。如果对应的 key 已经存在,bf.reserve 会报错
127.0.0.1:6379> bf.reserve user 0.01 100
(error) ERR item exists
127.0.0.1:6379> bf.reserve topic 0.01 1000
OK

Java 的 Redis 客户端比较多,有些还没有提供指令扩展机制,已知的 Redisson 和 lettuce 是可以使用布隆过滤器的,我们这里用 Redisson

public class RedissonBloomFilterDemo {

    public static void main(String[] args) {

        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        RedissonClient redisson = Redisson.create(config);

        RBloomFilter<String> bloomFilter = redisson.getBloomFilter("user");
        // 初始化布隆过滤器,预计统计元素数量为55000000,期望误差率为0.03
        bloomFilter.tryInit(55000000L, 0.03);
        bloomFilter.add("Tom");
        bloomFilter.add("Jack");
        System.out.println(bloomFilter.count());   //2
        System.out.println(bloomFilter.contains("Tom"));  //true
        System.out.println(bloomFilter.contains("Linda"));  //false
    }
}

四、实战进阶

假如现在收集了10亿个邮箱黑名单,当用户注册或者登录请求的时候,我们需要判断用户是否属于这10亿个黑名单邮箱中,如果存在则需要对该用户进行拦截。
这个需求咋一听很简单,无非就是yes or no的判断逻辑,难点在于数据量很大,而且巨大,如何把这个10亿个元素存储起来都是一个问题,还要在这10亿个元素之间去检索。通过BloomFilter的学习,我们就可以选择使用布隆过滤器来存储我们的10亿条数据,

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnel;
import com.google.common.hash.PrimitiveSink;
import org.apache.commons.codec.Charsets;

public class BloomDemo {

    private static final BloomFilter<String> dealIdBloomFilter =
            BloomFilter.create(new Funnel<String>() {

        private static final long serialVersionUID = 1L;

        @Override
        public void funnel(String arg0, PrimitiveSink arg1) {

            arg1.putString(arg0, Charsets.UTF_8);
        }

    }, 1024*1024*32,0.0001);


    public static void main(String[] args) {
        dealIdBloomFilter.put("123");
        dealIdBloomFilter.put("456");


        if (dealIdBloomFilter.mightContain("123")){
            System.out.println("yes");
        }
    }
}

其中put方法是往布隆过滤器中添加元素,mightContain方法是检测元素是否在布隆过滤器中。

单机版本的布隆过滤器是没有任何问题的,也可以实现我们的需求,但是我们在生产环境都是集群部署的,也就意味着每台机器我们都需要存储10亿条记录,如果遇到系统发布,那还得重新初始化存储一份,为此我们通过guava和redis实现了分布式版本的布隆过滤器,代码如下:

import com.google.common.base.Preconditions;
import com.google.common.hash.Funnel;
import com.google.common.hash.Hashing;
import org.springframework.data.redis.core.RedisTemplate;

public class RedisBloom<T> {

    private int numHashFunctions;

    private int bitSize;

    private Funnel<T> funnel;

    private RedisTemplate redisTemplate;

    private RedisBloom(){

    }

    /**
     *
     * @param funnel
     * @param expectedInsertions 预计元数总量大小
     * @param fpp 允许的误差,例如 0.0001,允许万分之一的误差
     * @param redisTemplate
     */
    public RedisBloom(Funnel<T> funnel, int expectedInsertions, double fpp, RedisTemplate redisTemplate) {
        Preconditions.checkArgument(funnel != null, "funnel不能为空");
        this.funnel = funnel;
        bitSize = optimalNumOfBits(expectedInsertions, fpp);
        numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, bitSize);
        this.redisTemplate = redisTemplate;
    }

    private int[] murmurHashOffset(T value) {
        int[] offset = new int[numHashFunctions];

        long hash64 = Hashing.murmur3_128().hashObject(value, funnel).asLong();
        int hash1 = (int) hash64;
        int hash2 = (int) (hash64 >>> 32);
        for (int i = 1; i <= numHashFunctions; i++) {
            int nextHash = hash1 + i * hash2;
            if (nextHash < 0) {
                nextHash = ~nextHash;
            }
            offset[i - 1] = nextHash % bitSize;
        }

        return offset;
    }

    /**
     * 计算bit数组长度
     */
    private int optimalNumOfBits(long n, double p) {
        if (p == 0) {
            p = Double.MIN_VALUE;
        }
        return (int) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
    }

    /**
     * 计算hash方法执行次数
     */
    private int optimalNumOfHashFunctions(long n, long m) {
        return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
    }


    /**
     * 根据给定的布隆过滤器添加值
     */
    public void addByBloomFilter(String key, T value) {
        int[] offset = this.murmurHashOffset(value);
        for (int i : offset) {
            redisTemplate.opsForValue().setBit(key, i, true);
        }
    }

    /**
     * 根据给定的布隆过滤器判断值是否存在
     */
    public  boolean includeByBloomFilter(String key, T value) {
        int[] offset = murmurHashOffset(value);
        for (int i : offset) {
            if (!redisTemplate.opsForValue().getBit(key, i)) {
                return false;
            }
        }
        return true;
    }

}

RedisBloom是实现功能的关键,包含了计算bitmap的核心算法,其实大部分代码都是来源于Guava库里面的BloomFilterStrategies类,但是因为这个类是专门为Guava的BloomFilter类使用的,所以没有对外暴露一些重要的算法逻辑。

再来看怎么结合redis一起使用BloomFilter,addByBloomFilter,往redis里面添加元素includeByBloomFilter,检查元素是否在redis bloomFilter里面。

测试用例如下:

import com.google.common.hash.Funnel;
import com.google.common.hash.PrimitiveSink;
import org.apache.commons.codec.Charsets;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.junit4.SpringRunner;
import javax.annotation.PostConstruct;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class RedisBloomTest {

    @Autowired
    private RedisTemplate redisTemplate;


    private static  RedisBloom<String> redisBloom;

    @PostConstruct
    public void  init(){
        redisBloom = new RedisBloom<>(new Funnel<String>() {
            @Override
            public void funnel(String s, PrimitiveSink primitiveSink) {
                primitiveSink.putString(s, Charsets.UTF_8);
            }
        }, 1024, 0.0001, redisTemplate);

    }

    @Test
    public void testBloom() {

        String key = "black:list";

        redisBloom.addByBloomFilter(key, "1@qq.com");
        redisBloom.addByBloomFilter(key, "2@qq.com");


        boolean isExist = redisBloom.includeByBloomFilter(key, "1@qq.com");
        boolean isExist2 = redisBloom.includeByBloomFilter(key, "2@qq.com");
        boolean isExist3 = redisBloom.includeByBloomFilter(key, "3@qq.com");


        System.out.println(isExist);
        System.out.println(isExist2);
        System.out.println(isExist3);

    }
}

运行结果:

true
true
false

原文链接: https://blog.csdn.net/weixin_43927773/article/details/108381632

  • 2
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值