深入理解布隆过滤器:从原理到 Guava 实战

134 阅读4分钟

布隆过滤器:从原理到 Guava 实战

目录


为什么需要布隆过滤器?

场景引入:

  • 缓存穿透:防止对不存在的数据的请求直接打到数据库或后端服务。

  • 爬虫去重:判断一个 URL 是否已经被爬取过。

  • 推荐系统:过滤掉已经推荐过的内容或用户已经浏览过的内容。

  • 黑名单/白名单:快速判断一个用户 ID 或 IP 地址是否在黑/白名单中。

布隆过滤器的核心原理

数据结构:一个很长的byte 数组

  • 从原理上来讲就是一个很长的byte 数组。如:[0, 0, 0, 0, 0, 0, 0, 0, ...]

工作流程:添加与查询

1. 添加元素 (add): 当你需要将一个元素(例如,一个URL、一个用户ID)添加到布隆过滤器中时,会执行以下步骤:

  • 哈希计算:将待添加的元素输入到所有的 k 个哈希函数中。

    • 例如,对于元素 X,你会计算出 h1​(X),h2​(X),…,hk​(X)。
  • 映射到位数组:每个哈希函数的结果都会得到一个在位数组范围内的索引(通过取模运算)。

    • 假设计算出 k 个索引为 idx1​,idx2​,…,idxk​。
  • 设置位:将位数组中对应这些索引位置的位设置为 1。

    • bit_array[idx_1] = 1, bit_array[idx_2] = 1, …, `bit_array[idx_k] = 1$。
    • 即使某个位置已经被设置为 1(因为其他元素也映射到了这个位置),操作也不会改变其值,仍然保持为 1。

2. 查询元素 (mightContain):

  • 哈希计算:同样,将待查询的元素输入到所有的 k 个哈希函数中,计算出 k 个哈希值。

    • 例如,对于元素 Y,你会计算出 h1​(Y),h2​(Y),…,hk​(Y)。
  • 检查位数组:根据这些哈希值,得到 k 个对应的位数组索引,然后检查位数组中这些位置上的位。

    • 检查 bit_array[idx_1], bit_array[idx_2], …, bit_array[idx_k] 的值。
  • 判断结果

    • 如果所有这 k 个位置上的位都是 1,那么布隆过滤器判断该元素可能存在于集合中。请注意,这里是“可能”,因为这有可能是由于哈希冲突导致这些位被其他已存在的元素设置为了 1。这就是误判 (False Positive) 的来源。
    • 如果其中任何一个位置上的位是 0,那么布隆过滤器可以确定该元素一定不存在于集合中。因为如果该元素曾经被添加过,那么所有对应的位都应该已经被设置为 1。

误判率:福兮祸所伏

  • 当查询一个不存在的元素时,如果它经过哈希计算后的所有位置,都因为其他元素的存在而被标记为了 1,就会发生误判。
  • 它绝不会漏报(False Negative),但可能会误报(False Positive)。 通俗讲 认为不存在的元素一定不存在 认为存在的元素不一定存在

Guava 实战:在 Java 中使用布隆过滤器

引入 Guava 依赖

maven坐标

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

示例代码

java代码:

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.StandardCharsets;

public class GuavaBloomFilterExample {

    public static void main(String[] args) {
        // 1. 创建布隆过滤器
        // 参数1: Funnel, 用于将元素转换为字节序列,以便进行哈希计算
        //      Funnels.unencodedCharsFunnel() 适用于字符串
        //      Funnels.integerFunnel() 适用于整数
        //      Funnels.byteArrayFunnel() 适用于字节数组
        // 参数2: expectedInsertions, 预期的插入元素数量
        // 参数3: fpp (false positive probability), 可接受的误判率 (0.0 到 1.0 之间)
        //       例如,0.01 表示 1% 的误判率
        BloomFilter<String> bloomFilter = BloomFilter.create(
            Funnels.stringFunnel(StandardCharsets.UTF_8), // 将字符串转换为字节
            1000, // 预计插入1000个元素
            0.01 // 期望的误判率为 1%
        );

        // 2. 添加元素
        System.out.println("--- 添加元素 ---");
        bloomFilter.put("apple");
        bloomFilter.put("banana");
        bloomFilter.put("orange");
        System.out.println("已添加: apple, banana, orange");

        // 3. 查询元素
        System.out.println("\n--- 查询元素 ---");
        System.out.println("是否存在 'apple'? " + bloomFilter.mightContain("apple"));   // 应该为 true
        System.out.println("是否存在 'grape'? " + bloomFilter.mightContain("grape"));   // 应该为 false (也可能因误判为 true)
        System.out.println("是否存在 'watermelon'? " + bloomFilter.mightContain("watermelon")); // 应该为 false (也可能因误判为 true)
        System.out.println("是否存在 'banana'? " + bloomFilter.mightContain("banana")); // 应该为 true

        // 4. 计算当前的误判率 (近似值) - Guava没有直接提供获取当前fpp的方法,需要自己计算或观察
        // 可以通过添加大量不存在的元素来测试误判率
        int falsePositives = 0;
        int testElements = 10000;
        for (int i = 0; i < testElements; i++) {
            String element = "test" + i;
            if (!bloomFilter.mightContain(element) && element.contains("test")) { //确保是没添加过的元素
                // 这是一个真正不存在的元素,如果mightContain返回false,则是正确判断
            } else if (bloomFilter.mightContain(element) && !element.contains("test")) { //确保是没添加过的元素
                 // 如果布隆过滤器报告存在,但实际上不存在,这就是一个误判
                 falsePositives++;
            }
        }
}