通俗易懂讲布隆过滤器

你们好,我是jack xu,不知道从何时开始,原本默默无闻的布隆过滤器一会儿名声大燥,在面试中面试官问到怎么避免缓存穿透,你的第一反应可能就是布隆过滤器,缓存穿透=布隆过滤器成了标配,但具体什么是布隆过滤器,怎么使用布隆过滤器不是很清楚,那今天咱们就来把他说清楚,讲明白。。java

缓存穿透

你们看下这幅图,用户可能进行了一次条件错误的查询,这时候redis是不存在的,按照常规流程就是去数据库找了,但是这是一次错误的条件查询,数据库固然也不会存在,也不会往redis里面写值,返回给用户一个空,这样的操做一次两次还好,但是次数多了还了得,我放redis原本就是为了挡一挡,减轻数据库的压力,如今redis变成了形同虚设,每次仍是去数据库查找了,这个就叫作缓存穿透,至关于redis不存在了,被击穿了,对于这种状况很好解决,咱们能够在redis缓存一个空字符串或者特殊字符串,好比&&,下次咱们去redis中查询的时候,当取到的值是空或者&&,咱们就知道这个值在数据库中是没有的,就不会在去数据库中查询,ps:这里缓存不存在key的时候必定要设置过时时间,否则当数据库已经新增了这一条记录的时候,这样会致使缓存和数据库不一致的状况web

上面这个是重复查询同一个不存在的值的状况,若是应用每次查询的不存在的值是不同的呢?即便你每次都缓存特殊字符串也没用,由于它的值不同,好比咱们的数据库用户id是111,112,113,114依次递增,可是别人要攻击你,故意拿-100,-936,-545这种乱七八糟的key来查询,这时候redis和数据库这种值都是不存在的,人家每次拿的key也不同,你就算缓存了也没用,这时候数据库的压力是至关大,比上面这种状况可怕的多,怎么办呢,这时候咱们今天的主角布隆过滤器就登场了。。面试

从一道面试题提及

问:如何在海量元素中(例如 10 亿无序、不定长、不重复)快速判断一个元素是否存在?好,咱们最简单的想法就是把这么多数据放到数据结构里去,好比List、Map、Tree,一搜不就出来了吗,好比map.get(),咱们假设一个元素1个字节的字段,10亿的数据大概须要 900G 的内存空间,这个对于普通的服务器来讲是承受不了的,固然面试官也不但愿听到你这个答案,由于太笨了吧,咱们确定是要用一种好的方法,巧妙的方法来解决,这里引入一种节省空间的数据结构,位图,他是一个有序的数组,只有两个值,0 和 1。0表明不存在,1表明存在。redis

有了这个屌炸天的东西,如今咱们还须要一个映射关系,你总得知道某个元素在哪一个位置上吧,而后在去看这个位置上是0仍是1,怎么解决这个问题呢,那就要用到哈希函数,用哈希函数有两个好处,第一是哈希函数不管输入值的长度是多少,获得的输出值长度是固定的,第二是他的分布是均匀的,若是全挤的一块去那还怎么区分,好比MD五、SHA-1这些就是常见的哈希算法。算法

咱们经过哈希函数计算之后就能够到相应的位置去找是否存在了,咱们看红色的线,24和147通过哈希函数获得的哈希值是同样的,咱们把这种状况叫作哈希冲突或者哈希碰撞。哈希碰撞是不可避免的,咱们能作的就是下降哈希碰撞的几率,第一种是能够扩大维数组的长度或者说位图容量,由于咱们的函数是分布均匀的,因此位图容量越大,在同一个位置发生哈希碰撞的几率就越小。可是越大的位图容量,意味着越多的内存消耗,因此咱们想一想能不能经过其余的方式来解决,第二种方式就是通过多几个哈希函数的计算,你想啊,24和147如今通过一次计算就碰撞了,那我通过5次,10次,100次计算还能碰撞的话那真的是缘分了,大家能够在一块儿了,但也不是越屡次哈希函数计算越好,由于这样很快就会填满位图,并且计算也是须要消耗时间,因此咱们须要在时间和空间上寻求一个平衡。。数据库

布隆过滤器

固然,这个事情早就有人研究过了,在 1970 年的时候,有一个叫作布隆的前辈对于判断海量元素中元素是否存在的问题进行了研究,也就是到底须要多大的位图容量和多少个哈希函数,它发表了一篇论文,提出的这个容器就叫作布隆过滤器。json

你们来看下这个图,咱们看集合里面3个元素,如今咱们要存了,好比说a,通过f1(a),f2(a),f3(a)通过三个哈希函数的计算,在相应的位置上存入1,元素b,c也是经过这三个函数计算放入相应的位置。当取的时候,元素a经过f1(a)函数计算,发现这个位置上是1,没问题,第二个位置也是1,第三个位置上也是 1,这时候咱们说这个a在布隆过滤器中是存在的,没毛病,同理咱们看下面的这个d,经过三次计算发现获得的结果也都是1,那么咱们能说d在布隆过滤器中是存在的吗,显然是不行的,咱们仔细看d获得的三个1实际上是f1(a),f1(b),f2(c)存进去的,并非d本身存进去的,这个仍是哈希碰撞致使的,咱们把这种原本不存在布隆过滤器中的元素误判为存在的状况叫作假阳性(False Positive Probability,FPP)。网页爬虫

咱们再来看另外一个元素,e 元素。咱们要判断它在容器里面是否存在,同样地要用这三个函数去计算。第一个位置是 1,第二个位置是 1,第三个位置是 0。那么e元素能不能判断是否在布隆过滤器中? 答案是确定的,e必定不存在。你想啊,若是e存在的话,他存进去的时候这三个位置都置为1,如今查出来有一个位置是0,证实他没存进去啊。。经过上面这张图加说明,咱们得出两个重要的结论数组

从容器的角度来讲:缓存

  • 若是布隆过滤器判断元素在集合中存在,不必定存在
  • 若是布隆过滤器判断不存在,必定不存在

从元素的角度来讲:

  • 若是元素实际存在,布隆过滤器必定判断存在
  • 若是元素实际不存在,布隆过滤器可能判断存在

小伙们请牢记

Guava实现布隆过滤器

java为何写的人多,基数大,由于是开源的,拥抱开源,框架多,轮子多,并且一个功能的轮子还不止一个,光序列化就有fastjson,jackson,gson,随你挑任你选,那布隆过滤器的轮子就是google提供的guava,咱们用代码来看一下使用方法

首先引入咱们的架包

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

这里先往布隆过滤器里面存放100万个元素,而后分别测试100个存在的元素和9900个不存在的元素他们的正确率和误判率

    //插入多少数据
    private static final int insertions = 1000000;

    //指望的误判率
    private static double fpp = 0.02;

    public static void main(String[] args) {

        //初始化一个存储string数据的布隆过滤器,默认误判率是0.03
        BloomFilter<String> bf = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), insertions, fpp);

        //用于存放全部实际存在的key,用因而否存在
        Set<String> sets = new HashSet<String>(insertions);

        //用于存放全部实际存在的key,用于取出
        List<String> lists = new ArrayList<String>(insertions);

        //插入随机字符串
        for (int i = 0; i < insertions; i++) {
            String uuid = UUID.randomUUID().toString();
            bf.put(uuid);
            sets.add(uuid);
            lists.add(uuid);
        }

        int rightNum = 0;
        int wrongNum = 0;

        for (int i = 0; i < 10000; i++) {
            // 0-10000之间,能够被100整除的数有100个(100的倍数)
            String data = i % 100 == 0 ? lists.get(i / 100) : UUID.randomUUID().toString();

            //这里用了might,看上去不是很自信,因此若是布隆过滤器判断存在了,咱们还要去sets中实锤
            if (bf.mightContain(data)) {
                if (sets.contains(data)) {
                    rightNum++;
                    continue;
                }
                wrongNum++;
            }
        }

        BigDecimal percent = new BigDecimal(wrongNum).divide(new BigDecimal(9900), 2, RoundingMode.HALF_UP);
        BigDecimal bingo = new BigDecimal(9900 - wrongNum).divide(new BigDecimal(9900), 2, RoundingMode.HALF_UP);
        System.out.println("在100W个元素中,判断100个实际存在的元素,布隆过滤器认为存在的:" + rightNum);
        System.out.println("在100W个元素中,判断9900个实际不存在的元素,误认为存在的:" + wrongNum + ",命中率:" + bingo + ",误判率:" + percent);
    }
复制代码

最后得出的结果

咱们看到这个结果正是印证了上面的结论,这100个真实存在元素在布隆过滤器中必定存在,另外9900个不存在的元素,布隆过滤器仍是判断了216个存在,这个就是误判,缘由上面也说过了,因此布隆过滤器不是万能的,可是他能帮咱们抵挡掉大部分不存在的数据已经很不错了,已经减轻数据库不少压力了,另外误判率0.02是在初始化布隆过滤器的时候咱们本身设的,若是不设默认是0.03,咱们本身设的时候千万不能设0!

Redis实现布隆过滤器

上面使用guava实现布隆过滤器是把数据放在本地内存中,咱们项目每每是分布式的,咱们还能够把数据放在redis中,用redis来实现布隆过滤器,这就须要咱们本身设计映射函数,本身度量二进制向量的长度,下面贴代码,你们能够直接拿来用的,已经通过测试了。。

/**
 * 布隆过滤器核心类
 *
 * @param <T>
 * @author jack xu
 */

public class BloomFilterHelper<T{
    private int numHashFunctions;
    private int bitSize;
    private Funnel<T> funnel;

    public BloomFilterHelper(int expectedInsertions) {
        this.funnel = (Funnel<T>) Funnels.stringFunnel(Charset.defaultCharset());
        bitSize = optimalNumOfBits(expectedInsertions, 0.03);
        numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, bitSize);
    }

    public BloomFilterHelper(Funnel<T> funnel, int expectedInsertions, double fpp) {
        this.funnel = funnel;
        bitSize = optimalNumOfBits(expectedInsertions, fpp);
        numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, bitSize);
    }

    public 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)));
    }
}
复制代码

这里在操做redis的位图bitmap,你可能只知道redis五种数据类型,string,list,hash,set,zset,没听过bitmap,可是没关系,你能够说他是一种新的数据类型,也能够说不是,由于他的本质仍是string,后面我也会专门写一篇文章来介绍数据类型以及在他们在互联网中的使用场景。。

/**
 * redis操做布隆过滤器
 *
 * @param <T>
 * @author xhj
 */

public class RedisBloomFilter<T{
    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 删除缓存的KEY
     *
     * @param key KEY
     */

    public void delete(String key) {
        redisTemplate.delete(key);
    }

    /**
     * 根据给定的布隆过滤器添加值,在添加一个元素的时候使用,批量添加的性能差
     *
     * @param bloomFilterHelper 布隆过滤器对象
     * @param key               KEY
     * @param value             值
     * @param <T>               泛型,能够传入任何类型的value
     */

    public <T> void add(BloomFilterHelper<T> bloomFilterHelper, String key, T value) {
        int[] offset = bloomFilterHelper.murmurHashOffset(value);
        for (int i : offset) {
            redisTemplate.opsForValue().setBit(key, i, true);
        }
    }

    /**
     * 根据给定的布隆过滤器添加值,在添加一批元素的时候使用,批量添加的性能好,使用pipeline方式(若是是集群下,请使用优化后RedisPipeline的操做)
     *
     * @param bloomFilterHelper 布隆过滤器对象
     * @param key               KEY
     * @param valueList         值,列表
     * @param <T>               泛型,能够传入任何类型的value
     */

    public <T> void addList(BloomFilterHelper<T> bloomFilterHelper, String key, List<T> valueList) {
        redisTemplate.executePipelined(new RedisCallback<Long>() {
            @Override
            public Long doInRedis(RedisConnection connection) throws DataAccessException {
                connection.openPipeline();
                for (T value : valueList) {
                    int[] offset = bloomFilterHelper.murmurHashOffset(value);
                    for (int i : offset) {
                        connection.setBit(key.getBytes(), i, true);
                    }
                }
                return null;
            }
        });
    }

    /**
     * 根据给定的布隆过滤器判断值是否存在
     *
     * @param bloomFilterHelper 布隆过滤器对象
     * @param key               KEY
     * @param value             值
     * @param <T>               泛型,能够传入任何类型的value
     * @return 是否存在
     */

    public <T> boolean contains(BloomFilterHelper<T> bloomFilterHelper, String key, T value) {
        int[] offset = bloomFilterHelper.murmurHashOffset(value);
        for (int i : offset) {
            if (!redisTemplate.opsForValue().getBit(key, i)) {
                return false;
            }
        }
        return true;
    }
}
复制代码

最后就是测试类了

    public static void main(String[] args) {
        RedisBloomFilter redisBloomFilter = new RedisBloomFilter();
        int expectedInsertions = 1000;
        double fpp = 0.1;
        redisBloomFilter.delete("bloom");
        BloomFilterHelper<CharSequence> bloomFilterHelper = new BloomFilterHelper<>(Funnels.stringFunnel(Charset.defaultCharset()), expectedInsertions, fpp);
        int j = 0;
        // 添加100个元素
        List<String> valueList = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            valueList.add(i + "");
        }
        long beginTime = System.currentTimeMillis();
        redisBloomFilter.addList(bloomFilterHelper, "bloom", valueList);
        long costMs = System.currentTimeMillis() - beginTime;
        log.info("布隆过滤器添加{}个值,耗时:{}ms"100, costMs);
        for (int i = 0; i < 1000; i++) {
            boolean result = redisBloomFilter.contains(bloomFilterHelper, "bloom", i + "");
            if (!result) {
                j++;
            }
        }
        log.info("漏掉了{}个,验证结果耗时:{}ms", j, System.currentTimeMillis() - beginTime);
    }
复制代码

注意这里用的是addList,他的底层是pipelining管道,而add方法的底层是一个个for循环的setBit,这样的速度效率是很慢的,可是他能有返回值,知道是否插入成功,而pipelining是不知道的,因此具体选择用哪种方法看你的业务场景,以及须要插入的速度决定。。

布隆过滤器工做位置

第一步是将数据库全部的数据加载到布隆过滤器。第二步当有请求来的时候先去布隆过滤器查询,若是bf说没有,第三步直接返回。若是bf说有,在往下走以前的流程。ps:另外guava的数据加载中只有put方法,小伙们能够想下布隆过滤器中数据删除和修改怎么办,为何没有delete的方法?

布隆过滤器的其余应用场景

  • 网页爬虫对URL去重,避免爬取相同的 URL 地址;
  • 反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱;
  • Google Chrome 使用布隆过滤器识别恶意 URL;
  • Medium 使用布隆过滤器避免推荐给用户已经读过的文章;
  • Google BigTable,Apache HBbase 和 Apache Cassandra使用布隆过滤器减小对不存在的行和列的查找。

好,布隆过滤器到这里就结束了,之后在面试中面试官在问到缓存击穿怎么办,我相信你应该可以回答的头头是道了,就像我这样通俗易懂的说出来便可,而后在工做中也能够应用,好比鉴权服务,当用户登陆的时候能够先用布隆过滤器判断下,而不是直接去redis、数据库查,最后原创不易,若是你以为写的不错,请点个赞哦。。

相关文章
相关标签/搜索