布隆过滤器详解

这是我参与11月更文挑战的第5天,活动详情查看:2021最后一次更文挑战

当接到一个集合内去重的需求时,我们第一个想到的就是HashSet。确实,通过hashCode()equals()方法,HashSet可以实现高效快速严格地避免集合内的元素重复。

但是,当集合元素数量过大时,HashSet所占内存就会变得非常大,这么大的HashSet集合显然是不可取的。这时候就需要布隆过滤器了。

概念

布隆过滤器本质上是一种比较巧妙的概率型数据结构(probabilistic data structure),特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”

数据结构

布隆过滤器由一个BitMap组成。所谓的BitMap,本质上就是一个数组。与寻常数组不同的是,BitMap一个数组元素占一个bit,这一特性决定了BitMap能够极大地节省空间

image-20211104095005829

当使用布隆过滤器时,每添加一个元素都会进行以下操作:

  1. 对需要添加的元素进行n种hash计算
  2. 将hash计算的结果对应的BitMap位进行置1操作。

image-20211104095925330

而对于查询操作来说(判断某元素是否存在),将会进行以下操作:

  1. 对需要查询的元素进行n种hash计算
  2. 查询hash计算的结果对应的BitMap位是否为1。若都为1,则代表该元素有可能存在;若存在非1位,则代表该元素一定不存在

误判

需要注意的是,布隆过滤器无法确定元素存在,只能确定元素不存在。出现的原因是多个输入经过哈希之后在相同的bit位置1了,这样就无法判断究竟是哪个输入产生的,因此误判的根源在于相同的 bit 位被多次映射且置 1。

这种情况也造成了布隆过滤器的删除问题,即布隆过滤器不存在删除操作。因为布隆过滤器的每一个 bit 并不是独占的,很有可能多个元素共享了某一位。如果我们直接删除这一位的话,会影响其他的元素。

实现

自实现

通过上面的原理,我们可以自己实现一个布隆过滤器,示例如下:

package com.example.springtest;
​
import java.util.BitSet;
​
public class BloomFilter {
​
    // 容量为1左移30位 1073741824
    private static final int SIZE = 1 << 30;
    // 布隆过滤器底层数据结构
    private  BitSet bitSet = new BitSet(SIZE);
    // 自定义哈希函数的seed
    private static final int[] seeds = {3,  37, 61};
    // 自定义哈希函数集
    private static HashFunction[] functions = new HashFunction[seeds.length];
​
​
    public BloomFilter() {
        for (int i = 0; i < functions.length; i++) {
            HashFunction hashFunction = new HashFunction(SIZE, seeds[i]);
            functions[i] = hashFunction;
        }
    }
​
    /**
     * @Description 往布隆过滤器添加元素
     * @Param [s]
     * @return void
     **/
    public  void add(String s){
        for (HashFunction function : functions) {
            int hash = function.hash(s);
            bitSet.set(hash,true);
        }
    }
​
    /**
     * @Description 判断元素是否存在
     * @Param [s]
     * @return boolean
     **/
    public  boolean contains(String s){
        int containsCount = 0;
        for (HashFunction function : functions) {
            int hash = function.hash(s);
            boolean b = bitSet.get(hash);
            if (b){
                containsCount++;
            }
        }
        // 判断是否每位都是1
        // 若是则可能存在,若不是则一定不存在
        if (containsCount >= functions.length){
            return true;
        }else {
            return false;
        }
    }
}
​
class HashFunction {
​
    private int size;
    private int seed;
​
    public HashFunction(int size, int seed) {
        this.size = size;
        this.seed = seed;
    }
​
    public int hash(String value) {
        int result = 0;
        int len = value.length();
        for (int i = 0; i < len; i++) {
            result = seed * result + value.charAt(i);
        }
        int r = (size - 1) & result;
        return (size - 1) & result;
    }
}
复制代码

测试代码如下:

public class BloomDemo {
    public static void main(String[] args) {
        // 往布隆过滤器内添加100个元素
        BloomFilter bloomFilter = new BloomFilter();
        for (int i = 0; i < 100; i++) {
            bloomFilter.add(String.valueOf(i));
        }
        // 验证
        System.out.println("30:"+bloomFilter.contains(String.valueOf(30)));
        System.out.println("71:"+bloomFilter.contains(String.valueOf(71)));
        System.out.println("93:"+bloomFilter.contains(String.valueOf(93)));
        System.out.println("3:"+bloomFilter.contains(String.valueOf(3)));
        System.out.println("149:"+bloomFilter.contains(String.valueOf(149)));
        System.out.println("179:"+bloomFilter.contains(String.valueOf(179)));
    }
}
复制代码

执行结果:

image-20211104105553077

现有实现

其实对于布隆过滤器,已有现成的更好的实现。

以guava为例

        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
        </dependency>
复制代码

BloomFilter.create()即可创建一个布隆过滤器

        BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 100000, 0.01);
复制代码

其中:

Funnel是Guava中定义的一个接口,它和PrimitiveSink配套使用,主要是把任意类型的数据转化成HashCode (byte数组)。Guava预定义了一些原生类型的Funnel,如String、Long、Integer等等。

100000是预计布隆过滤器元素的容量。

0.01是允许的误差率,需小于1。

通过以上的参数,BloomFilter将会自行计算出适合的容量。

  /**
   * Computes m (total bits of Bloom filter) which is expected to achieve, for the specified
   * expected insertions, the required false positive probability.
   *
   * <p>See http://en.wikipedia.org/wiki/Bloom_filter#Probability_of_false_positives for the
   * formula.
   *
   * @param n expected insertions (must be positive)
   * @param p false positive rate (must be 0 < p < 1)
   */
  @VisibleForTesting
  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)));
  }
复制代码

Redis实现

而在分布式环境中,使用Redis实现布隆过滤器更为适合。

安装

在Redis中,布隆过滤器以插件的形式提供,使用前需要安装。

  1. 下载安装包

    wget https://github.com/RedisBloom/RedisBloom/archive/refs/tags/v2.2.6.tar.gz
    复制代码
  2. 解压并编译

    tar -zxf v2.2.6.tar.gz
    cd RedisBloom-2.2.6/
    make
    复制代码
  3. 在redis.conf中配置该模块

    ################################## MODULES #####################################
    ​
    # Load modules at startup. If the server is not able to load modules
    # it will abort. It is possible to use multiple loadmodule directives.
    #
    # loadmodule /path/to/my_module.so
    # loadmodule /path/to/other_module.so
    loadmodule /usr/local/RedisBloom-2.2.6/redisbloom.so
    ​
    复制代码
  4. 启动redis,注意指定配置文件启动

    redis-server 
    复制代码

使用

RedisBloom的基础使用命令如下:

命令 格式 说明
bf.reserve bf.reserve {key} {error_rate} {initial_size} 创建一个大小为initial_size位向量长度,错误率为error_rate的空的Bloom过滤器
bf.add bf.add{key} {item} 向key指定的Bloom中添加一个元素item
bf.madd bf.madd {key} {item} {item2} {item3} … 一次添加多个元素
bf.exists bf.exists {key} {item} 查询元素是否存在
bf.mexists bf.mexists {key} {item} {item2} {item3} … 检查多个元素是否存在
bf.info bf.info {key} 查询key指定的Bloom的信息
bf.debug bf.debug {key} 查看BloomFilter的内部详细信息(如每层的元素个数、错误率等)
cf.reserve cf.reserve {key} {initial_size} 创建一个initial_size位向量长度的空的Bloom过滤器
cf.add cf.add {key} {item} 向key指定的Bloom中添加一个元素item
cf.exists cf.exists {key} {item} 检查该元素是否存在
bf.scandump bf.scandump {key} {iter} (key:布隆过滤器的名字,iter:首次调用传值0,或者上次调用此命令返回的结果值)对Bloom进行增量持久化操作(增量保存)
bf.localchunk bf.localchunk {key} {iter} {data} 加载SCANDUMP持久化的Bloom数据(key:目标布隆过滤器的名字,iter:SCANDUMP返回的迭代器的值,和data一一对应,data:SCANDUMP返回的数据块(data chunk))

使用示例如下:

# 初始化一个布隆过滤器,每个参数分表代表 key 错误率 预计容量
127.0.0.1:6379> bf.reserve BFTest 0.01 10000
OK
# 添加元素
127.0.0.1:6379> bf.add BFTest 1
(integer) 1
127.0.0.1:6379> bf.add BFTest 2
(integer) 1
# 批量添加元素
127.0.0.1:6379> bf.madd BFTest 4 5 6 7
1) (integer) 1
2) (integer) 1
3) (integer) 1
4) (integer) 1
# 判断元素是否存在
127.0.0.1:6379> BF.EXISTS BFTest 1
(integer) 1
127.0.0.1:6379> BF.EXISTS BFTest 2
(integer) 1
127.0.0.1:6379> BF.EXISTS BFTest 3
(integer) 0
复制代码

应用场景

布隆过滤器的典型应用有:

  • 防止数据库穿库。 Google Bigtable,HBase 和 Cassandra 以及 Postgresql 使用BloomFilter来减少不存在的行或列的磁盘查找。避免代价高昂的磁盘查找会大大提高数据库查询操作的性能。
  • 防止缓存穿透。缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。使用布隆过滤器能够避免频繁查询不存在的数据,减轻数据库的压力。
  • 业务场景中判断用户是否阅读过某视频或文章,比如抖音或头条,当然会导致一定的误判,但不会让用户看到重复的内容。
  • WEB拦截器,如果相同请求则拦截,防止重复被攻击。用户第一次请求,将请求参数放入布隆过滤器中,当第二次请求时,先判断请求参数是否被布隆过滤器命中。可以提高缓存命中率。Squid 网页代理缓存服务器在 cache digests 中就使用了布隆过滤器。Google Chrome浏览器使用了布隆过滤器加速安全浏览服务