Java实现布隆过滤器的几种方式总结

 更新时间:2023年07月11日 09:50:03   作者:怪 咖@  
这篇文章给大家总结了几种Java实现布隆过滤器的方式,手动硬编码实现,引入Guava实现,引入hutool实现,通过redis实现等几种方式,文中有详细的代码和图解,需要的朋友可以参考下

一、前言

讲个使用场景,比如我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的?

  1. 你会想到服务器记录了用户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那些已经存在的记录。问题是当用户量很大,每个用户看过的新闻又很多的情况下,这种方式,推荐系统的去重工作在性能上跟的上么?

  1. 实际上,如果历史记录存储在关系数据库里,去重就需要频繁地对数据库进行 exists 查询,当系统并发量很高时,数据库是很难扛住压力的

  2. 你可能又想到了缓存,但是如此多的历史记录全部缓存起来,那得浪费多大存储空间啊?而且这个存储空间是随着时间线性增长,你撑得住一个月,你能撑得住几年么?但是不缓存的话,性能又跟不上,这该怎么办?

这时,布隆过滤器 (Bloom Filter) 闪亮登场了,它就是专门用来解决这种去重问题的。它在起到去重的同时,在空间上还能节省 90% 以上,只是稍微有那么点不精确,也就是有一定的误判概率。

二、什么是布隆过滤器?

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

三、布隆过滤器原理

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

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

注:图中是三个散列函数,实际当中不一定是三个,所以上面用的k。

如下图所示,两个不同的值,经过相同的哈希运算后,可能会得出同样的值。即下图中,hello和你好 经过哈希运算后,得出的下标都为2,把位2上的值改为1。所以,无法判断位2上的值为1是谁的值。

同时,如果只存储了"你好"未存储"hello",当查询hello时,经过哈希运算得出值为2,去位2中查看,得知值为1,得出结论"hello"可能存在于过滤器中,即发生了误判。

误判可以通过增多哈希函数进行降低。哈希函数越多,误判率越低。同时,布隆过滤器查找和插入的时间复杂度都为O(k),k为哈希函数的个数。所以,哈希函数越多,时间复杂度越高。具体如何选择,需要根据数据量的多少进行。

优点:

  • 占用空间小,因为他是不存储实际数据的。
  • 保密性非常好,不存储原始数据,别人也不知道0和1是什么。
  • 他底层是基于位数组的,基于数组的特性查询和插入是非常快的。

缺点:

  • 由于上文中提到的数据经过哈希计算后值相同的原因,一般情况下不能从布隆过滤器中删除元素。
  • 存在误判,本身不在里面可能经过hash计算会认为存在。

四、布隆过滤器使用场景

综上,我们可以得出:布隆过滤器可以判断指定的元素一定不存在或者可能存在! 打个比方,当它说不认识你时,肯定就不认识;当它说见过你时,可能根本就没见过面,不过因为你的脸跟它认识的人中某脸比较相似 (某些熟脸的系数组合),所以误判以前见过你。

套在上面的新闻推荐使用场景中,布隆过滤器能准确过滤掉那些已经看过的内容,那些没有看过的新内容,它也会过滤掉极小一部分 (误判),但是绝大多数新内容它都能准确识别。这样就可以完全保证推荐给用户的内容都是无重复的。

一般有如下几种使用场景:

  • 解决Redis缓存穿透
  • 在爬虫系统中,我们需要对 URL 进行去重,已经爬过的网页就可以不用爬了。但是URL 太多了,几千万几个亿,如果用一个集合装下这些 URL 地址那是非常浪费空间的。这时候就可以考虑使用布隆过滤器。它可以大幅降低去重存储消耗,只不过也会使得爬虫系统错过少量的页面。
  • 邮箱系统的垃圾邮件过滤功能也普遍用到了布隆过滤器,因为用了这个过滤器,所以平时也会遇到某些正常的邮件被放进了垃圾邮件目录中
  • 新闻推荐、文章推荐等等。

五、空间占用估计

布隆过滤器有两个参数,第一个是预计元素的数量 n,第二个是错误率 f。公式根据这两个输入得到两个输出,第一个输出是位数组的长度 l,也就是需要的存储空间大小 (bit),第二个输出是 hash 函数的最佳数量 k。hash 函数的数量也会直接影响到错误率,最佳的数量会有最低的错误率。

  • k=0.7*(l/n) # 约等于
  • f=0.6185^(l/n) # ^ 表示次方计算,也就是 math.pow

从公式中可以看出

  1. 位数组相对越长 (l/n),错误率 f 越低,这个和直观上理解是一致的
  2. 位数组相对越长 (l/n),hash 函数需要的最佳数量也越多,影响计算效率
  3. 当一个元素平均需要 1 个字节 (8bit) 的指纹空间时 (l/n=8),错误率大约为 2%
  4. 错误率为 10%,一个元素需要的平均指纹空间为 4.792 个 bit,大约为 5bit
  5. 错误率为 1%,一个元素需要的平均指纹空间为 9.585 个 bit,大约为 10bit
  6. 错误率为 0.1%,一个元素需要的平均指纹空间为 14.377 个 bit,大约为 15bit

你也许会想,如果一个元素需要占据 15 个 bit,那相对 set 集合的空间优势是不是就没有那么明显了?这里需要明确的是,set 中会存储每个元素的内容,而布隆过滤器仅仅存储元素的指纹。元素的内容大小就是字符串的长度,它一般会有多个字节,甚至是几十个上百个字节,每个元素本身还需要一个指针被 set 集合来引用,这个指针又会占去 4 个字节或 8 个字节,取决于系统是 32bit 还是 64bit。而指纹空间只有接近 2 个字节,所以布隆过滤器的空间优势还是非常明显的。

如果读者觉得公式计算起来太麻烦,也没有关系,有很多现成的网站已经支持计算空间占用的功能了,我们只要把参数输进去,就可以直接看到结果,比如 布隆计算器。(Bloom Filter Calculator (krisives.github.io))

六、实际元素超出时,误判率会怎样变化

当实际元素超出预计元素时,错误率会有多大变化,它会急剧上升么,还是平缓地上升,这就需要另外一个公式,引入参数 t 表示实际元素和预计元素的倍数 t

  • f=(1-0.5t)k # 极限近似,k 是 hash 函数的最佳数量

当 t 增大时,错误率,f 也会跟着增大,分别选择错误率为 10%,1%,0.1% 的 k 值,画出它的曲线进行直观观察。

从这个图中可以看出曲线还是比较陡峭的

  1. 错误率为 10% 时,倍数比为 2 时,错误率就会升至接近 40%,这个就比较危险了
  2. 错误率为 1% 时,倍数比为 2 时,错误率升至 15%,也挺可怕的
  3. 错误率为 0.1%,倍数比为 2 时,错误率升至 5%,也比较悬了

得出结论:使用时不要让实际元素远大于初始化大小,当实际元素开始超出初始化大小时,应该对布隆过滤器进行重建,重新分配一个 size 更大的过滤器,再将所有的历史元素批量 add 进去 (这就要求我们在其它的存储器中记录所有的历史元素)。因为 error_rate 不会因为数量超出就急剧增加,这就给我们重建过滤器提供了较为宽松的时间。

七、布隆过滤器实现方式

1、手动硬编码实现

public class MyBloomFilter {
    /**
     * 位数组大小 33554432
     */
    private static final int DEFAULT_SIZE = 2 << 24;
    /**
     * 通过这个数组创建多个Hash函数
     */
    private static final int[] SEEDS = new int[]{6, 18, 64, 89, 126, 189, 223};
    /**
     * 初始化位数组,数组中的元素只能是 0 或者 1
     */
    private BitSet bits = new BitSet(DEFAULT_SIZE);
    /**
     * Hash函数数组
     */
    private MyHash[] myHashes = new MyHash[SEEDS.length];
    /**
     * 初始化多个包含 Hash 函数的类数组,每个类中的 Hash 函数都不一样
     */
    public MyBloomFilter() {
        // 初始化多个不同的 Hash 函数
        for (int i = 0; i < SEEDS.length; i++) {
            myHashes[i] = new MyHash(DEFAULT_SIZE, SEEDS[i]);
        }
    }
    /**
     * 添加元素到位数组
     */
    public void add(Object value) {
        for (MyHash myHash : myHashes) {
            bits.set(myHash.hash(value), true);
        }
    }
    /**
     * 判断指定元素是否存在于位数组
     */
    public boolean contains(Object value) {
        boolean result = true;
        for (MyHash myHash : myHashes) {
            result = result && bits.get(myHash.hash(value));
        }
        return result;
    }
    /**
     * 自定义 Hash 函数
     */
    private class MyHash {
        private int cap;
        private int seed;
        MyHash(int cap, int seed) {
            this.cap = cap;
            this.seed = seed;
        }
        /**
         * 计算 Hash 值
         */
        int hash(Object obj) {
            return (obj == null) ? 0 : Math.abs(seed * (cap - 1) & (obj.hashCode() ^ (obj.hashCode() >>> 16)));
        }
    }
    public static void main(String[] args) {
        long capacity = 10000000L;
        System.out.println(2 << 24);
        MyBloomFilter myBloomFilter = new MyBloomFilter();
        //put值进去
        for (long i = 0; i < capacity; i++) {
            myBloomFilter.add(i);
        }
        // 统计误判次数
        int count = 0;
        // 我在数据范围之外的数据,测试相同量的数据,判断错误率是不是符合我们当时设定的错误率
        for (long i = capacity; i < capacity * 2; i++) {
            if (myBloomFilter.contains(i)) {
                count++;
            }
        }
        System.out.println(count);
    }
}

2、引入 Guava 实现

引入Guava的依赖:

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

代码实现:

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
public class GuavaBloomFilter {
    public static void main(String[] args) {
        // 预期插入数量
        long capacity = 10000L;
        // 错误比率
        double errorRate = 0.01;
        //创建BloomFilter对象,需要传入Funnel对象,预估的元素个数,错误率
        BloomFilter<Long> filter = BloomFilter.create(Funnels.longFunnel(), capacity, errorRate);
        //        BloomFilter<String> filter = BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf-8")), 10000, 0.0001);
        //put值进去
        for (long i = 0; i < capacity; i++) {
            filter.put(i);
        }
        // 统计误判次数
        int count = 0;
        // 我在数据范围之外的数据,测试相同量的数据,判断错误率是不是符合我们当时设定的错误率
        for (long i = capacity; i < capacity * 2; i++) {
            if (filter.mightContain(i)) {
                count++;
            }
        }
        System.out.println(count);
    }
}

输出结果:

假如数据为10000容错率为0.01,统计出来的误判个数是87。

3、引入 hutool 实现

引入hutool 的依赖:

<dependency>
	<groupId>cn.hutool</groupId>
	<artifactId>hutool-all</artifactId>
	<version>5.8.20</version>
</dependency>

代码实现:

import cn.hutool.bloomfilter.BitMapBloomFilter;
public class HutoolBloomFilter {
    public static void main(String[] args) {
        // 一旦数量过大很容易出现内存异常:Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        int capacity = 1000;
        // 初始化
        BitMapBloomFilter filter = new BitMapBloomFilter(capacity);
        for (int i = 0; i < capacity; i++) {
            filter.add(String.valueOf(i));
        }
        System.out.println("存入元素为=={" + capacity + "}");
        // 统计误判次数
        int count = 0;
        // 我在数据范围之外的数据,测试相同量的数据,判断错误率是不是符合我们当时设定的错误率
        for (int i = capacity; i < capacity * 2; i++) {
            if (filter.contains(String.valueOf(i))) {
                count++;
            }
        }
        System.out.println("误判元素为=={" + count + "}");
    }
}

hutool 的布隆过滤器不支持 指定 错误比率,并且内存占用太高了,个人不建议使用。

4、通过redis实现布隆过滤器

Redis实现布隆过滤器的代码详解_Redis_脚本之家 (jb51.net)

八、使用建议

比起容错率RedisBloom还是够可以的。 10000的长度0.01的容错,只有58个误判!比Guava 还要强,并且Guava 他并没有做持久化。

以上就是Java实现布隆过滤器的几种方式总结的详细内容,更多关于Java实现布隆过滤器的资料请关注脚本之家其它相关文章!

相关文章

  • Spring Security实现禁止用户重复登陆的配置原理

    Spring Security实现禁止用户重复登陆的配置原理

    这篇文章主要介绍了Spring Security实现禁止用户重复登陆的配置原理,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2019-12-12
  • 使用springboot单元测试对weblistener的加载测试

    使用springboot单元测试对weblistener的加载测试

    这篇文章主要介绍了使用springboot单元测试对weblistener的加载测试,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2021-10-10
  • Spring处理@Async导致的循环依赖失败问题的方案详解

    Spring处理@Async导致的循环依赖失败问题的方案详解

    这篇文章主要为大家详细介绍了SpringBoot中的@Async导致循环依赖失败的原因及其解决方案,文中的示例代码讲解详细,感兴趣的可以学习一下
    2022-07-07
  • 基于Java回顾之集合的总结概述

    基于Java回顾之集合的总结概述

    在这篇文章里,我们关注Java中的集合(Collection)。集合是编程语言中基础的一部分,Java自JDK早期,就引入了Java Collection Framework。设计JCF的那个人,后来还写了一本书,叫《Effective Java》
    2013-05-05
  • Java实现学生选课管理系统

    Java实现学生选课管理系统

    这篇文章主要为大家详细介绍了Java实现学生选课管理系统,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-07-07
  • Java日常练习题,每天进步一点点(2)

    Java日常练习题,每天进步一点点(2)

    下面小编就为大家带来一篇Java基础的几道练习题(分享)。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧,希望可以帮到你
    2021-07-07
  • MyBatis简介与配置MyBatis+Spring+MySql的方法

    MyBatis简介与配置MyBatis+Spring+MySql的方法

    MyBatis 是一个可以自定义SQL、存储过程和高级映射的持久层框架。这篇文章主要介绍了MyBatis简介与配置MyBatis+Spring+MySql的方法,需要的朋友可以参考下
    2017-04-04
  • JSONObject按put顺序排放与输出方式

    JSONObject按put顺序排放与输出方式

    这篇文章主要介绍了JSONObject按put顺序排放与输出方式,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2021-08-08
  • 命令提示符编译java的方法(必看篇)

    命令提示符编译java的方法(必看篇)

    下面小编就为大家带来一篇命令提示符编译java的方法(必看篇)。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-05-05
  • java数据结构之实现双向链表的示例

    java数据结构之实现双向链表的示例

    这篇文章主要介绍了java数据结构实现双向链表的示例,需要的朋友可以参考下
    2014-03-03

最新评论