布隆过滤器原理和使用

转载:https://zhuanlan.zhihu.com/p/692179021

一、背景介绍

在互联网中,我们经常遇到需要在大量数据中判断目标数据是否存在的情况。例如,在网络爬虫中,我们需要判断某个网址是否已经被访问过。为了实现这一功能,通常需要使用一个容器来存储已访问过的网址。如果将这些数据直接存储在磁盘中,每次判断都要进行磁盘查询,这将导致大量的IO操作,效率较低。因此,我们希望将这些数据保存在内存中。在数据量较小的情况下,可以使用Redis来存储这些数据。但是,当数据量超过上千万时,将会消耗几GB甚至几十GB的内存空间。然而,对于仅需要记录数据是否存在的情况而言,这样使用大量内存显然是浪费的。为了解决这个问题,我们可以使用布隆过滤器(Bloom Filter)。布隆过滤器是一种占用空间少且时间效率高的工具。

二、认识布隆过滤器

2.1 布隆过滤器简介

布隆过滤器(Bloom Filter)是1970年由布隆提出的,它实质上是一个很长的二进制向量和一系列随机映射函数 (Hash函数)。

作用:它是一个空间效率高的概率型数据结构,用来告诉你:一个元素一定不存在或者可能存在

2.2 优点

  • 相比于其它的数据结构,布隆过滤器在空间和时间方面都有巨大的优势。布隆过滤器存储空间和插入/查询时间都是常数(即hash函数的个数)。

  • Hash 函数相互之间没有关系,方便由硬件并行实现。

  • 布隆过滤器不需要存储元素本身,在某些对保密要求非常严格的场合有优势。

  • 布隆过滤器可以表示全集,其它任何数据结构都不能。解释:布隆过滤器(Bloom Filter)可以“表示全集”的说法,源于其独特的概率特性。具体来说,布隆过滤器并不存储元素本身,而是通过哈希函数映射到一个位数组,形成一个“模糊但紧凑”的数据表示。因此,它具备以下特性,使得可以“表示全集”

    1. 误判但无遗漏:

      • 布隆过滤器可以确定某个元素“可能存在”或“一定不存在”。
      • 对于所有可能的输入,布隆过滤器都能做出判断,因此在逻辑上,它可以表示整个可能的输入集合(即“全集”)。
      • 由于存在哈希碰撞,可能会误判一个不存在的元素为“存在”,但不会漏掉已加入的元素。
    2. 有限存储表示无限集合:

      • 传统数据结构(如哈希表、集合)需要存储元素本身,无法表示“无限集合”。
      • 布隆过滤器通过固定大小的位数组和哈希函数,即可用于查询任意元素是否在集合中,而不必存储所有元素,从而“间接表示全集”。
    3. 其他数据结构的局限性:

      • 哈希表、数组、链表等都必须存储具体的元素,因此它们只能存储有限大小的集合,而无法表示“全集”。
      • 逻辑上,“全集”意味着可以对任意元素进行查询,而传统数据结构如果不包含某个元素,就无法返回任何有效结果。

    结论:布隆过滤器通过概率方法“模糊地”表示全集,虽然存在误判,但它仍然能处理理论上无限可能的输入,这是其他数据结构无法做到的。因此,在某种意义上,可以说“布隆过滤器可以表示全集,而其他数据结构不能”。

2.3 缺点

  • 有误判率存在。
  • 不支持删除。

2.4 适用场景

  • 预防缓存穿透:布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在。
  • 网络爬虫:布隆过滤器可以用来去重已经爬取过的URL。
  • 邮箱的垃圾邮件过滤。
  • 黑白名单。

三、 布隆过滤器原理

3.1 结构

布隆过滤器实现原理就是一个超大位数的数组和多个不同Hash算法函数。假设位数组的长度为 m,哈希函数的个数为 k。如下图,一个长度16位的数组,3个不同Hash算法函数,数组里面存储的是 bit 位,只放 0 和 1,初始为 0。

不同Hash算法函数

指定长度数组

3.2 添加元素

将要添加的元素分别通过k个哈希函数计算得到k个哈希值,这k个hash值对应位数组上的k个位置,然后将这k个位置设置为1。

我们添加一个data1和data2两个元素,两个元素根据三个hash算法函数计算出的值,需要说明一点三个值可能会存在相同的值。

其中data1计算出1、8、13三值,我们把数组中对应的位置设置为1。

Hash1(data1)=1
Hash2(data1)=8
Hash3(data1)=13

如图:

data2计算出2、5、13三值,我们把数组中对应的位置设置为1

Hash1(data2)=2
Hash2(data2)=5
Hash3(data2)=13

如图:

我们发现data1和data2经过hash函数后,出现了一个相同值,这种是正常的,也正是因为这种情况的存在,需要多个函数来保证每个元素尽可能对应数组位置的唯一性,可以看下两个元素在一起的效果。

如图:

当不同元素在不同或者相同的hash函数计算后,得到同一个值,依旧只需要这个位置保持1即可。

3.3 查询元素

将要查询的元素分别通过k个哈希函数计算得到k个哈希值,这k个hash值对应位数组上的k个位置。如果这k个位置中有一个位置为0,则此元素一定不存在集合中。如果这k个位置全部为1,则这个元素可能存在。

我们在刚才添加过data1和data2两个元素的布隆过滤器查询以下三种元素,data1已添加到布隆过滤器元素,data3和data4都是未添加到布隆过滤器元素。

查询data1先根据添加时的三个hash函数计算分别对应值,值分别是1、8、13,然后查询数组中这三个位置的值是否为1。

Hash1(data1)=1
Hash2(data1)=8
Hash3(data1)=13

如图:

我们可以看到数组中1、8、13这三个位置都是1,data1可能存在于该布隆过滤器。我们从添加的角度来看,我们知道data1是一定存在于该布隆过滤器的,为什么还要是说可能呢,是因为查询出来三个位置都为1不能代表这个三个1都是同一个元素添加的,下面我们看下元素data3的查询。

查询data3先根据添加时的三个hash函数计算分别对应值,值分别是2、8、13,然后查询数组中这三个位置的值是否为1。

Hash1(data3)=2
Hash2(data3)=8
Hash3(data3)=13

如图:

我们已知的该布隆过滤器我们没有添加给data3,为什么data3查询出来三个位置的值都为1呢。我们可以看到data3所命中的位置分别是data2添加时把位置2赋值的1,和data1添加时把位置8和位置13赋值的1,都是由其他元素改变的位置对应的值,所以命中位置全部为1。这个元素可能存在。

我们查询一下data4,看下命中位置不全为0的数据。查询data4先根据添加时的三个hash函数计算分别对应值,值分别是2、8、13,然后查询数组中这三个位置的值是否为1。

Hash1(data4)=1
Hash2(data4)=8
Hash3(data4)=12

如图:

我们可以看到data4元素的hash函数3计算之后的值是12,数组位置12的值是0,没有元素在位置12赋值过1。如果data4存在于该布隆过滤器,则一定在添加data4时会把位置12赋值1,此时位置12还是0,则说明该布隆过滤器未添加过data4元素,所以位置中有一个位置为0。则此元素一定不存在布隆过滤器中。

四、布隆过滤器误判率

刚才查询时我们发现data3没有添加过到布隆过滤器,却在布隆过滤器查询到了,这种情况就是布隆过滤器误判了。那可以不存在误判或者减少误判吗?事实上误判是一定存在的,我们可以尽可能减小误判。下面说下如何得到误判率。

4.1 参数

m:布隆过滤器的bit长度。
n:插入过滤器的元素个数。
k:哈希函数的个数。

4.2 误判率公式(推导过程自查)

1、布隆过滤器(Bloom Filter)的误差率指的是误判率(False Positive Rate, FPR),即某个元素实际上不在集合中,但布隆过滤器错误地判断它存在的概率。

误判率的计算公式为:

其中:

  • k:哈希函数的数量
  • n:插入的元素个数
  • m:布隆过滤器的位数组大小
  • e:自然常数(约等于 2.718)

由误判率公式可知,在k一定的情况下,当n增加时,误判率增加,m增加时,误判率减少

五、实现方式

5.1 Guava实现

guava是谷歌开源工具类,其中就有能直接实现布隆过滤器的方法,不需要重复造轮子。

方法名功能参数返回值
put添加元素put(T object)boolean
mightContain检查元素是否存在mightContain(T object)boolean
copy根据此实例创建一个新的BloomFiltecopy()BloomFilter
approximateElementCount已添加到Bloom过滤器的元素的数量approximateElementCount()long
expectedFpp返回元素存在的错误概率expectedFpp()double
isCompatible确定给定的Bloom筛选器是否与此Bloom筛选器兼容isCompatible(BloomFilter that)boolean
putAll通过执行的逐位OR将此Bloom过滤器与另一个Bloom过滤器组合putAll(BloomFilter that)void

引入依赖

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

测试代码

 private static void GuavaBloomFilter() {
    // 创建布隆过滤器对象
    BloomFilter bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()),EXPECTED_INSERTIONS,FALSE_PROBABILITY);
    // 向过滤器中添加元素
    bloomFilter.put("element001");
    bloomFilter.put("element003");
    // 判断元素是否存在
    System.out.println(bloomFilter.mightContain("element001"));//true
    System.out.println(bloomFilter.mightContain("element002"));//false
    // 已添加到Bloom过滤器的元素的数量
    System.out.println(bloomFilter.approximateElementCount());// 2
    // 返回元素存在的错误概率
    System.out.println(bloomFilter.expectedFpp());
}

5.2 Redis实现

  • 开源Redisson(RBloomFilter)。
  • Redis 4.0 官方提供布隆过滤器插件。
  • 通过Redis提供的bitMap自己实现。

5.2.1 开源Redisson方式

Redisson方法

方法名功能参数返回值
add添加元素add(T object)boolean
contains检查元素是否存在contains(T object)boolean
count已添加到Bloom过滤器的元素的数量count()long
getExpectedInsertions返回的预期插入元素的个数getExpectedInsertions()long
getFalseProbability返回元素存在的错误概率getFalseProbability()double
getHashIterations返回每个元素使用的哈希迭代次数getHashIterations()int
getSize返回此实例所需Redis内存的位数getSize()long
tryInit初始化Bloom筛选器参数tryInit(long expectedInsertions, double falseProbability)boolean
delete删除对象delete()boolean

引入依赖

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.22.1</version>
</dependency>

测试代码

private static void RedissonBloomFilter() {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://" + REDIS_IP + ":" + REDIS_PORT);
    config.useSingleServer().setPassword(REDIS_PASSWORD);
    // 获取客户端
    RedissonClient redissonClient = Redisson.create(config);
    RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);
    // 初始化布隆过滤器:预期插入量为100000000L,预期错误概率为1%
    bloomFilter.tryInit(EXPECTED_INSERTIONS, FALSE_PROBABILITY);
    // 插入数据
    bloomFilter.add("element001");
    bloomFilter.add("element003");

    // 判断下面元素是否在布隆过滤器中
    System.out.println(bloomFilter.contains("element002"));//false
    System.out.println(bloomFilter.contains("element001"));//true

    // 已添加到Bloom过滤器的元素的数量
    System.out.println(bloomFilter.count());//2
    // 预期插入元素的个数
    System.out.println(bloomFilter.getExpectedInsertions());//1000000
    // 元素存在的错误概率
    System.out.println(bloomFilter.getFalseProbability());//0.01
    // 每个元素使用的哈希迭代次数
    System.out.println(bloomFilter.getHashIterations());
    // 实例所需Redis内存的位数
    System.out.println(bloomFilter.getSize());
}

5.2.2 Redis 4.0 官方提供布隆过滤器插件

基础命令

命令功能参数
BF.RESERVE创建一个大小为capacity,错误率为error_rate的空的BloomBF.RESERVE {key} {error_rate} {capacity} [EXPANSION {expansion}] [NONSCALING]
BF.ADD向key指定的Bloom中添加一个元素itomBF.ADD {key} {item}
BF.MADD向key指定的Bloom中添加多个元案BF.MADD {key} {item ...}
BF.INSERT向key指定的Bloom中添加多个元素,添加时可以指定大小和错误率,且可以控制在Bloom不存在的时候是否自动创建BF.INSERT {key} [CAPACITY {cap}] [ERROR {error}] [EXPANSION {expansion}] [NOCREATE] [NONSCALING] ITEMS {item ...}
BF.EXISTS检查一个元秦是否可能存在于key指定的Bloom中BF.EXISTS {key} {item}
BF.MEXISTS同时检查多个元素是否可能存在于key指定的Bloom中BF.MEXISTS {key} {item ...}
BF.SCANDUMP对Bloom进行增量持久化操作BF.SCANDUMP {key} {iter}
BF.LOADCHUNK加载SCANDUMP持久化的Bloom数据BF.LOADCHUNK {key} {iter} {data}
http://BF.INFO查询key指定的Bloom的信息http://BF.INFO {key}
BF.DEBUG查看BloomFilter的内部详细信息(如每层的元素个数,错误率等)BF.DEBUG (key}

引入依赖

    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>4.2.0</version>
    </dependency>

测试代码

 private static void RedisBloomFilter() {
    // 建立连接
    BloomFilterCommands bloomFilterCommands = new JedisPooled(REDIS_IP, REDIS_PORT, "", REDIS_PASSWORD);
    // 构建布隆过滤器参数
    BFReserveParams bfReserveParams = new BFReserveParams();
    bfReserveParams.expansion(2);

    // 创建一个过滤器
    String test = bloomFilterCommands.bfReserve(BLOOM_FILTER_NAME, FALSE_PROBABILITY, EXPECTED_INSERTIONS, bfReserveParams);

    // 向过滤器中添加元素
    bloomFilterCommands.bfAdd(BLOOM_FILTER_NAME, "element001");
    bloomFilterCommands.bfAdd(BLOOM_FILTER_NAME, "element003");

    // 判断元素是否存在
    System.out.println(bloomFilterCommands.bfExists(BLOOM_FILTER_NAME, "element001"));//true
    System.out.println(bloomFilterCommands.bfExists(BLOOM_FILTER_NAME, "element002"));//false
}

5.2.3 通过Redis提供的bitMap自己实现

自定义方法

方法名功能参数返回值
add添加元素add(String key, String element, int expireSec)boolean
contains检查元素是否存在contains(String key, String element)boolean
getExpectedInsertions返回的预期插入元素的个数getExpectedInsertions()long
getFalseProbability返回元素存在的错误概率getFalseProbability()double
getNumHashFunctions返回每个元素使用的哈希迭代次数getNumHashFunctions()int
getBitmapLength返回Bitmap长度getBitmapLength()long
BloomFilterUtils创建Bloom对象BloomFilterUtils(long expectedInsertions, double falseProbability)BloomFilterUtils

测试代码

  public class BloomFilterUtils {

    private static final String BF_KEY_PREFIX = "bf_";

    private long numApproxElements;
    private double falseProbability;
    // hash个数
    private int numHashFunctions;
    // 数组长度
    private int bitmapLength;

    private JedisResourcePool jedisResourcePool;

    /**
     * 构造布隆过滤器。注意:在同一业务场景下,三个参数务必相同
     *
     * @param numApproxElements 预估元素数量
     * @param fpp               可接受的最大误差
     * @param jedisResourcePool Codis专用的Jedis连接池
     */
    public BloomFilterUtils(Long numApproxElements, double fpp, JedisResourcePool jedisResourcePool) {
        this.numApproxElements = numApproxElements;
        this.falseProbability = fpp;
        this.jedisResourcePool = jedisResourcePool;
        // 数组长度 m = (n * lnp)/ln2^2
        bitmapLength = (int) (-numApproxElements * Math.log(fpp) / (Math.log(2) * Math.log(2)));
        // hash个数 k = (n / m ) * ln2
        numHashFunctions = Math.max(1, (int) Math.round((double) bitmapLength / numApproxElements * Math.log(2)));
    }

    /**
     * 取得预估元素数量
     */
    public long getExpectedInsertions() {
        return numApproxElements;
    }

    /**
     * 返回元素存在的错误概率
     */
    public double getFalseProbability() {
        return falseProbability;
    }

    /**
     * 取得自动计算的最优哈希函数个数
     */
    public int getNumHashFunctions() {
        return numHashFunctions;
    }

    /**
     * 取得自动计算的最优Bitmap长度
     */
    public int getBitmapLength() {
        return bitmapLength;
    }

    /**
     * 计算一个元素值哈希后映射到Bitmap的哪些bit上
     *
     * @param element 元素值
     * @return bit下标的数组
     */
    private long[] getBitIndices(String element) {
        long[] indices = new long[numHashFunctions];

        // 元素  使用MurMurHash3 128位Hash算法转换值
        byte[] bytes = Hashing.murmur3_128()
                .hashObject(element, Funnels.stringFunnel(Charset.forName("UTF-8")))
                .asBytes();

        // 低8位转Long值
        long hash1 = Longs.fromBytes(
                bytes[7], bytes[6], bytes[5], bytes[4], bytes[3], bytes[2], bytes[1], bytes[0]
        );
        // 高8位转Long值
        long hash2 = Longs.fromBytes(
                bytes[15], bytes[14], bytes[13], bytes[12], bytes[11], bytes[10], bytes[9], bytes[8]
        );

        long combinedHash = hash1;
        // 双重哈希进行散列
        for (int i = 0; i  < numHashFunctions; i++) {
            indices[i] = (combinedHash & Long.MAX_VALUE) % bitmapLength;
            combinedHash += hash2;
        }
        return indices;
    }


    /**
     * 插入元素
     *
     * @param key       原始Redis键,会自动加上'bf_'前缀
     * @param element   元素值,字符串类型
     * @param expireSec 过期时间(秒)
     */
    public void add(String key, String element, int expireSec) {
        if (key == null || element == null) {
            throw new RuntimeException("键值均不能为空");
        }
        String actualKey = BF_KEY_PREFIX.concat(key);

        try (Jedis jedis = jedisResourcePool.getResource()) {
            try (Pipeline pipeline = jedis.pipelined()) {
                // 遍历元素所有hash结果的bit位置
                for (long index : getBitIndices(element)) {
                    pipeline.setbit(actualKey, index, true);
                }
                pipeline.syncAndReturnAll();
            }
            jedis.expire(actualKey, expireSec);
        }
    }

    /**
     * 检查元素在集合中是否(可能)存在
     *
     * @param key     原始Redis键,会自动加上'bf_'前缀
     * @param element 元素值,字符串类型
     */
    public boolean contains(String key, String element) {
        if (key == null || element == null) {
            throw new RuntimeException("键值均不能为空");
        }
        String actualKey = BF_KEY_PREFIX.concat(key);
        boolean result = false;

        try (Jedis jedis = jedisResourcePool.getResource()) {
            // 遍历元素所有hash结果的bit位置
            try (Pipeline pipeline = jedis.pipelined()) {
                for (long index : getBitIndices(element)) {
                    pipeline.getbit(actualKey, index);
                }
                result = !pipeline.syncAndReturnAll().contains(false);
            }
        }
        return result;
    }

    public static void main(String[] args) {
        String path = Path.getCurrentPath() + "/config/zzjodis.properties";
        ConfigReadUtil configReadUtil = new ConfigReadUtil(path);
        try {
            JedisResourcePool jedisResourcePool = RoundRobinJedisPool.
                    create()
                    .curatorClient(configReadUtil.getString("jodisZkStr"), 5000)
                    .zkProxyDir(configReadUtil.getString("zkProxyDir"))
                    .team(configReadUtil.getString("team"))
                    .connectionTimeoutMs(configReadUtil.getInt("connectionTimeoutMs"))
                    .soTimeoutMs(configReadUtil.getInt("soTimeoutMs"))
                    .appKey(configReadUtil.getString("appKey"))
                    .password("".equals(configReadUtil.getString("password")) ? null : configReadUtil.getString("password"))
                    .build();
            BloomFilterUtils bloomFilterUtils = new BloomFilterUtils(10000, 0.01, jedisResourcePool);
            bloomFilterUtils.add("filter01", "element001", 30 * 60);
            System.out.println(bloomFilterUtils.contains("filter01", "element001"));  // true
            System.out.println(bloomFilterUtils.contains("filter01", "element002"));  // false
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

布隆过滤器选择

实现方式储存位置适用场景备注
Guava机器单机只需要机器内存不需要其他资源
Redissonredis分布式连接Redis即可使用
Redis插件redis分布式需要对redis集群进行设置支持布隆过滤器插件
Redis的bitMapredis分布式需要自己实现添加和查询

对于分布式服务环境,Guava方式不适合使用,而Redis插件需要复杂的配置和高成本支持。相比之下,Redisson连接Redis并进行插入和查询的方式更适合当前场景,因此最终选择了Redisson方式。

布隆过滤器初始化问题

例如:600万用户情况下,初始化 数据量和 错误率如何设置?

600 万用户 的情况下,布隆过滤器的 初始化数据量(位数组大小)和错误率(误判率) 需要合理设置,以在 存储空间、查询速度和误判率 之间取得平衡。

不同误判率下的计算

误判率 p位数组大小 m每个元素占用位数 m/n最优哈希函数个数 k
1% (0.01)5.16 × 10⁷ (~ 6.45 MB)8.6 bits6.2 ≈ 6
0.1% (0.001)8.59 × 10⁷ (~ 10.74 MB)14.3 bits10
0.01% (0.0001)1.13 × 10⁸ (~ 13.9 MB)18.9 bits13

说明

  • 误判率越低,所需的位数组就越大。
  • k 取整数,通常取 6~10 之间。

推荐参数

  • 若存储空间有限,查询性能要求高 → 误判率设为 0.01 (1%),约 6.45MB 空间,6 个哈希函数。
  • 若存储空间允许,希望误判率更低 → 误判率设为 0.001 (0.1%),约 10.74MB 空间,10 个哈希函数。

总结

  • 存储空间 m 取 50 100 MB 以内
  • 误判率 p 设定在 0.01 ~ 0.001 之间
  • 哈希函数 k 设定为 6 ~ 10
    这些参数可以在 存储、查询速度和准确率 之间达到良好平衡。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值