Code前端首页关于Code前端联系我们

布隆过滤器 - 一种先进而经典的数据结构

terry 2年前 (2023-09-27) 阅读数 97 #数据结构与算法

布隆过滤器是一种先进而经典的数据结构。

你可能没想到:布隆过滤器被用于RocketMQ、Hbase、Cassandra、LevelDB和RocksDB等知名项目中。

对于后端程序员来说,学习和了解布隆滤镜是非常有必要的。来,我们一起欣赏一下布隆的滤镜设计之美。 布隆过滤器——精巧而且经典的数据结构

1 缓存穿透

首先看一个商品服务查询详情界面:

public Product queryProductById (Long id){
   // 查询缓存
   Product product = queryFromCache(id);
   if(product != null) {
     return product ;
   }
   // 从数据库查询
   product = queryFromDataBase(id);
   if(product != null) {
       saveCache(id , product);
   }
   return product;
}
布隆过滤器——精巧而且经典的数据结构

假设这个商品既没有缓存,也没有在数据库中,那么就没有办法回写数据缓存 ,当有大量的请求访问服务时,数据库的压力会极大。

这是一个典型的缓存穿透场景。

为了解决这个问题,我们通常可以将过期时间较短的零值持有者写入分布式缓存中,但这会占用较多的存储空间,而且不划算。

问题的本质是:“如何以最小的成本找出某个元素是否在集合中?”

我们的主角布隆滤镜出现。您可以轻松平衡时间和空间两个维度

2 原理分析

布隆滤波器(英文:Bloomer Filter)是布隆在1970年提出的。它基本上是一个长的二元向量和一系列 任意映射函数

Yao 过滤器可用于检索集合中是否存在某个元素。优点是空间效率查询时间远大于一般算法。缺点是有一定的误识别和清除难度。

布隆过滤器的原理:当一个元素被添加到集合中时,该元素通过K个哈希函数被分配到一个位数组中的K个点,并将它们设置为1。检索时,我们只需要看到如果这些点都为 1 就知道(大约)它是否在集合中:如果这些 点中的任何一个有 0,那么被检查的 元素一定不存在 ;如果都是1,那么被检查的元素可能在中。

简单来说,准备一个长度为m的位数组,将所有元素初始化为0,使用k个哈希函数对元素进行k次哈希运算,对len(m)取余得到k个位置和m得到。 in对应位置设置为1。 布隆过滤器——精巧而且经典的数据结构

如上图所示,位数组长度为8,哈希函数个数为3,连续保留两个元素x和y。两个元素经过三个哈希函数生成三个哈希值,分配到位数组中的不同位置并设置为1。元素x分配到位数组的第0、4、7位,元素y 被分配给位数组的第 1 位、第 4 位和第 6 位。

存储元素x后,位数组的第4位被设置为1,处理元素y时,第4位被覆盖并被设置为1。

随着布隆过滤器存储更多元素将有越来越多的位设置为1。即使不存储元素x,假设有哈希函数,分配给位数组的三个位都被其他值设置为1。对于布隆过滤器机制来说,元素x的值也存在,这意味着布隆过滤器有一定的收视率

▍ 误报率

布隆 过滤器包含以下四个属性:

  • k:哈希函数数量
  • m:位数组长度
  • n:插入元素数量
  • p :误报百分比

如果位数组的长度太小,很快所有位都会被设置为1,并且每个检索到的值都会返回“可能存在”,从而达不到过滤效果。位数组的长度越大,误报率越小。

同时,还必须考虑哈希函数的数量。哈希函数的数量越多,检索速度越慢,误报率越小。反之,误报率越高。 布隆过滤器——精巧而且经典的数据结构

从图中我们可以看到,在相同的位数组长度下,随着哈希函数中个体数量的增加,误判率显着下降。

误报率 p 的公式为 布隆过滤器——精巧而且经典的数据结构

  1. 第 k 个哈希函数的给定位未设置为 1 的概率为 布隆过滤器——精巧而且经典的数据结构
  2. 插入 n 个元素后给定位仍为 0 的概率是 布隆过滤器——精巧而且经典的数据结构
  3. 则插入n个元素后给定位位置为1的概率为布隆过滤器——精巧而且经典的数据结构
  4. 。错误估计的总百分比为布隆过滤器——精巧而且经典的数据结构。如果m足够大,错误估计的百分比就会更小。该公式约等于布隆过滤器——精巧而且经典的数据结构

。通过估计布隆过滤器的误判次数p和需要插入的元素数量n,我们可以分别推导出最合适的位数组长度m和哈希函数的数量k。

布隆过滤器——精巧而且经典的数据结构

▍布隆滤镜支持移除吗?

布隆 过滤器并不真正支持删除元素,因为多个元素可以散列到 布隆 过滤器的同一位置。如果立即删除该位置的元素,会影响其他元素的判断。

▍时间和空间效率

布隆过滤器的空间复杂度为O(m),插入和检索的时间复杂度均为O(k)。存储空间、插入和查找时间不会随着元素数量的增加而增加。空间和时间效率非常高。

▍哈希函数类型

Murmur3,FNV系列和Jenkins等非加密哈希函数比较适合,因为Murmur3算法简单,可以平衡速度和随机分布。许多开源产品经常使用它作为哈希函数。

3 Guava 实现

Google Guava 是一个由 Google 开发和维护的开源 Java 开发库。它包含许多基本工具类,例如字符串处理、集合、并发工具、I/O 和数学函数等。经过?

static <T> BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions, double fpp, BloomFilter.Strategy strategy) {
    // 省略部分前置验证代码 
    // 位数组长度
    long numBits = optimalNumOfBits(expectedInsertions, fpp);
    // 哈希函数次数
    int numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits);
    try {
      return new BloomFilter<T>(
                    new LockFreeBitArray(numBits), 
                    numHashFunctions, 
                    funnel,
                    strategy
      );
    } catch (IllegalArgumentException e) {
      throw new IllegalArgumentException("Could not create BloomFilter of " + numBits + " bits", e);
    }
}
//计算位数组长度
//n:插入的数据条目数量
//p:期望误判率
@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)));
}

// 计算哈希次数
@VisibleForTesting
static int optimalNumOfHashFunctions(long n, long m) {
    // (m / n) * log(2), but avoid truncation due to division!
    return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
}

Guava计算出的位数组长度和哈希数与原理分析部分所示的公式相对应。

事情是这样的:布隆过滤器如何判断元素的存在?方法名称

是 Google 特有的。 “可能含有”的中文意思是:“可能存在”。 方法的返回值为 true ,则该元素可能存在,但如果返回值为 false ,则该元素可能不存在。

public <T extends @Nullable Object> boolean mightContain(
    @ParametricNullness T object,
    //Funnel 是一个接口,用于将任意类型的对象转换为字节流,
    //以便用于布隆过滤器的哈希计算。
    Funnel<? super T> funnel,  
    //用于计算哈希值的哈希函数的数量
    int numHashFunctions,
    //位数组实例,用于存储布隆过滤器的位集
    LockFreeBitArray bits) {
  long bitSize = bits.bitSize();
  //使用 MurmurHash3 哈希函数计算对象 object 的哈希值,
  //并将其转换为一个 byte 数组。
  byte[] bytes = Hashing.murmur3_128().hashObject(object, funnel).getBytesInternal();
  long hash1 = lowerEight(bytes);
  long hash2 = upperEight(bytes);

  long combinedHash = hash1;
  for (int i = 0; i < numHashFunctions; i++) {
    // Make the combined hash positive and indexable
    // 计算哈希值的索引,并从位数组中查找索引处的位。
    // 如果索引处的位为 0,表示对象不在布隆过滤器中,返回 false。
    if (!bits.get((combinedHash & Long.MAX_VALUE) % bitSize)) {
      return false;
    }
    // 将 hash2 加到 combinedHash 上,用于计算下一个哈希值的索引。
    combinedHash += hash2;
  }
  return true;
}

3 Redisson实现

Redisson是一个用Java编写的Redis客户端,它实现了分布式对象和服务,包括集合、映射、锁、队列等。Redisson的API简单易用,让使用变得更加简单和方便在分布式环境中更高效地使用Redis。 ? ?

public boolean tryInit(long expectedInsertions, double falseProbability) {
    // 位数组大小
    size = optimalNumOfBits(expectedInsertions, falseProbability);
    // 哈希函数次数
    hashIterations = optimalNumOfHashFunctions(expectedInsertions, size);
    CommandBatchService executorService = new CommandBatchService(commandExecutor);
    // 执行 Lua脚本,生成配置
    executorService.evalReadAsync(configName, codec, RedisCommands.EVAL_VOID,
            "local size = redis.call('hget', KEYS[1], 'size');" +
                    "local hashIterations = redis.call('hget', KEYS[1], 'hashIterations');" +
                    "assert(size == false and hashIterations == false, 'Bloom filter config has been changed')",
                    Arrays.<Object>asList(configName), size, hashIterations);
    executorService.writeAsync(configName, StringCodec.INSTANCE,
                                            new RedisCommand<Void>("HMSET", new VoidReplayConvertor()), configName,
            "size", size, "hashIterations", hashIterations,
            "expectedInsertions", expectedInsertions, "falseProbability", BigDecimal.valueOf(falseProbability).toPlainString());
    try {
        executorService.execute();
    } catch (RedisException e) {
    }
    return true;
}
布隆过滤器——精巧而且经典的数据结构Bf配置信息

Redisson在初始化布隆过滤器时,会创建Hash数据结构的一个key,用于存储布隆过滤器的四个核心属性。

那么Redisson 布隆 过滤器是如何存储元素的呢?

public boolean add(T object) {
    long[] hashes = hash(object);
    while (true) {
        int hashIterations = this.hashIterations;
        long size = this.size;
        long[] indexes = hash(hashes[0], hashes[1], hashIterations, size);
        CommandBatchService executorService = new CommandBatchService(commandExecutor);
        addConfigCheck(hashIterations, size, executorService);
        //创建 bitset 对象, 然后调用setAsync方法,该方法的参数是索引。
        RBitSetAsync bs = createBitSet(executorService);
        for (int i = 0; i < indexes.length; i++) {
            bs.setAsync(indexes[i]);
        }
        try {
            List<Boolean> result = (List<Boolean>) executorService.execute().getResponses();
            for (Boolean val : result.subList(1, result.size()-1)) {
                if (!val) {
                    return true;
                }
            }
            return false;
        } catch (RedisException e) {
        }
    }
}

从源码中我们发现Redisson布隆过滤器管理的对象是bitMap(位图)

在Redis中,位图本质上是一种字符串数据类型。 Redis 中的字符串类型值最多可以存储 512 MB 的内容。每个字符串由多个字节组成,每个字节由 8 个复合位组成。位图结构使用“位”来实现存储。它通过将位设置为0或1来达到数据访问的目的。存储限制为2^32,我们可以使用getbit/setbit命令来处理这个位数组。

为了方便大家理解,我做了一个简单的测试。 布隆过滤器——精巧而且经典的数据结构

通过Redisson API创建键mybitset 的位图,将索引3、5、6、8位设置为1,右边的二进制值就出来了也完全匹配。

4 个实用点

通过 Guava 和 Redisson 创建和使用布隆过滤器相对容易。下面我们来讨论一下实际的考虑因素。

1。缓存渗透场景

首先我们需要初始化布隆过滤器,然后当用户询问时,判断过滤器是否包含该元素。如果元素不包含该元素,则直接返回不存在。

如果存在,则从缓存中请求数据。如果缓存中没有,则查询数据库并将其写回到缓存中,最后放回前端。 布隆过滤器——精巧而且经典的数据结构

2。移除元素的场景

在实际场景中,不仅会添加元素,还会有移除元素的场景,比如移除产品。

在这部分原理分析中,我们已经知道: 布隆过滤器实际上并不支持删除元素,因为多个元素可以哈希到布隆过滤器的同一位置。如果立即删除该位置的元素,会影响其他元素的判断

我们有两种解决方案:

▍计数布隆过滤器

计数布隆过滤器是布隆过滤器的扩展。标准布隆过滤器位数组的每一位都扩展为一个小计数器。 (Counter),插入元素时,对应的k(k为哈希函数的个数)Counter值加1,删除元素时,对应的k个Counter值减1。 布隆过滤器——精巧而且经典的数据结构

计数布隆过滤器虽然可以解决布隆过滤器无法移除元素的问题,但它引入了另一个问题:“更多的资源被占用,很多情况下会造成巨大的空间浪费导致”。

▍定期重建布隆过滤器

从技术角度来看: 定期重建布隆过滤器这个方案可行可靠,也比较简单。布隆过滤器——精巧而且经典的数据结构

  1. 定时任务触发整个商品查询;
  2. 新增布隆滤镜添加产品编号;
  3. 任务完成,更改产品布隆滤镜的分配(从旧A改为新B);
  4. 产品服务根据布隆过滤器的分配,选择新的布隆过滤器B进行相关搜索;
  5. 选择适当的时间移除旧的布隆过滤器 A。

5 摘要

Yao 过滤器 是一个长 二元向量 和一组 随机分配函数 对于 检索元素是否在集合中 位于。

空间效率查询时间两者都比一般算法大很多,但存在一定比例的误估(函数返回荣誉)是真的,表示该元素可能存在,函数返回false,该元素可能不存在)。

布隆过滤器的四个核心属性:

  • k:哈希函数数量
  • m:位数组长度
  • n:插入元素数量
  • p:误报率

In在Java世界中,通过Guava和Redisson创建和使用布隆过滤器非常容易。

布隆过滤器无法去除元素,但我们可以通过两种方案达到去除元素的效果:计数布隆过滤器定期重建布隆过滤器

为什么这么多开源项目都使用布隆滤镜?

由于设计精巧简洁,因此在工程中非常容易实现,效率高。虽然存在一定比例的误判,但软件设计不就是一种权衡吗?

出自勇哥《Java实用知识》,作者勇哥


参考:

https://hackernoon.com/probabilistic-data-structs-bloom-filter-5374112a7832

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门