注册

布隆过滤器(附Java代码)

一、前言


想象这样一个场景:你的电商系统每天有1000万次商品查询,但其中90%是无效ID(比如用户手误输入或恶意爬虫)。每次查询都要穿透缓存打到数据库,数据库压力山大!


// 传统做法:每次都要查数据库
public Product queryProduct(Long id) {
Product product = cache.get(id); // Redis缓存
if (product == null) {
product = db.query(id); // 90%的无效请求打到数据库!
if (product != null) {
cache.set(id, product);
}
}
return product;
}

布隆过滤器(Bloom Filter) 就是解决这类问题的利器:它用极小的空间快速判断“某个元素一定不存在”或“可能存在”,从而在查询前提前拦截无效请求,保护后端数据库。



典型应用场景:



  • 防止缓存穿透(无效Key反复查询数据库)
  • 网页爬虫URL去重
  • 邮箱系统黑名单过滤
  • 推荐系统已读内容过滤


二、核心原理


2.1 生活化类比:图书馆的“借书登记表”


假设图书馆有100万个座位,管理员用一张超大登记表(位数组)记录谁来过:


座位号12345678910...
状态0101001001...


  • 张三来借书,管理员用3个不同规则计算他的“专属座位”:

    • 规则1:姓名笔画数 % 10 → 座位2
    • 规则2:身-份-证后3位 % 10 → 座位4
    • 规则3:生日数字和 % 10 → 座位7


  • 管理员把2、4、7号座位标记为1
  • 下次张三再来,只要所有对应座位都是1,就认为“他可能来过”;只要有一个是0,就确定“他没来过”


核心特性:



  • 绝不漏判:如果元素不存在,100%能判断出来(座位有0)
  • 可能误判:如果所有座位都是1,可能是别人“撞座位”了(误判率可控)
  • 无法删除:座位一旦标1,不能随便改回0(否则会影响其他元素)


2.2 技术原理图解


元素 "nontee" 

├─ 哈希函数1 → 位置 15 → 位数组[15] = 1
├─ 哈希函数2 → 位置 87 → 位数组[87] = 1
└─ 哈希函数3 → 位置 203 → 位数组[203] = 1

查询 "nontee"

├─ 哈希函数1 → 位置 15 → 检查位数组[15] == 1? ✓
├─ 哈希函数2 → 位置 87 → 检查位数组[87] == 1? ✓
└─ 哈希函数3 → 位置 203 → 检查位数组[203] == 1? ✓

结论:可能存在(需二次确认)

2.3 关键参数:如何控制误判率?


布隆过滤器有3个核心参数:


参数说明影响
m位数组大小(bit)越大误判率越低,但占空间
n预期插入元素数量超过预期会导致误判率飙升
k哈希函数个数最佳值 ≈ (m/n) * ln2

误判率公式

P≈(1−e−kn/m)kP \approx (1 - e^{-kn/m})^kP(1ekn/m)k



经验法则:每存储1个元素,分配10bit空间,误判率约1%

例如:存储100万个元素 → 需要 100万 × 10 bit ≈ 1.25MB(传统HashSet需几十MB!)



三、代码实战


3.1 手写简易版布隆过滤器(理解原理必备)


import java.util.BitSet;
import java.util.MissingResourceException;

/**
* 简易布隆过滤器实现(教学用途)
* 特点:使用3个哈希函数,固定大小位数组
*/

public class SimpleBloomFilter {
// 位数组(实际存储结构)
private final BitSet bitSet;
// 位数组大小(单位:bit)
private final int bitSize;
// 哈希函数种子(用于生成不同哈希值)
private final int[] seeds = {7, 11, 13};

/**
* 构造函数
* @param expectedSize 预期元素数量
* @param falsePositiveRate 期望误判率(如0.01表示1%)
*/

public SimpleBloomFilter(int expectedSize, double falsePositiveRate) {
// 根据公式计算最佳位数组大小: m = - (n * ln(p)) / (ln2)^2
this.bitSize = (int) (-expectedSize * Math.log(falsePositiveRate) / Math.pow(Math.log(2), 2));
this.bitSet = new BitSet(bitSize);
System.out.println(">>> 布隆过滤器初始化完成 | 位数组大小: " + bitSize + " bit (" + bitSize / 8 / 1024 + " KB)");
}

/**
* 插入元素
* @param value 待插入的字符串
*/

public void put(String value) {
if (value == null) return;

// 对每个哈希函数计算位置并设置bit为1
for (int seed : seeds) {
int position = hash(value, seed) % bitSize;
bitSet.set(position); // 设置该位置为1
}
}

/**
* 判断元素是否存在
* @param value 待查询字符串
* @return true: 可能存在 | false: 一定不存在
*/

public boolean mightContain(String value) {
if (value == null) return false;

// 检查所有哈希位置是否都为1
for (int seed : seeds) {
int position = hash(value, seed) % bitSize;
if (!bitSet.get(position)) {
// 只要有一个位置是0,元素一定不存在
return false;
}
}
// 所有位置都是1,元素可能存在(有误判可能)
return true;
}

/**
* 简易哈希函数(教学用,实际生产需用更均匀的哈希)
* @param value 原始字符串
* @param seed 种子值(不同种子产生不同哈希)
* @return 哈希值
*/

private int hash(String value, int seed) {
int result = 0;
for (int i = 0; i < value.length(); i++) {
result = seed * result + value.charAt(i);
}
// 处理负数
return (result < 0) ? -result : result;
}

// ===== 测试代码 =====
public static void main(String[] args) {
// 创建过滤器:预计10000个元素,误判率1%
SimpleBloomFilter bloomFilter = new SimpleBloomFilter(10000, 0.01);

// 插入1000个用户ID
for (int i = 0; i < 1000; i++) {
bloomFilter.put("user_" + i);
}

// 测试1:查询存在的元素
System.out.println("\n【测试1】查询存在的元素:");
System.out.println("user_500 是否存在? " + bloomFilter.mightContain("user_500")); // true

// 测试2:查询不存在的元素(大概率返回false)
System.out.println("\n【测试2】查询不存在的元素:");
System.out.println("user_99999 是否存在? " + bloomFilter.mightContain("user_99999")); // false

// 测试3:演示误判(多次测试可能触发)
System.out.println("\n【测试3】误判演示(多次运行可能触发):");
int falsePositiveCount = 0;
for (int i = 10000; i < 20000; i++) {
if (bloomFilter.mightContain("user_" + i)) {
falsePositiveCount++;
}
}
System.out.println("在10000次不存在查询中,误判次数: " + falsePositiveCount);
System.out.printf("实际误判率: %.2f%%\n", (double) falsePositiveCount / 10000 * 100);
}
}

运行输出示例


>>> 布隆过滤器初始化完成 | 位数组大小: 95851 bit (11 KB)

【测试1】查询存在的元素:
user_500 是否存在? true

【测试2】查询不存在的元素:
user_99999 是否存在? false

【测试3】误判演示(多次运行可能触发):
在10000次不存在查询中,误判次数: 98
实际误判率: 0.98%

3.2 工业级实战:Guava布隆过滤器(生产环境推荐)


Google Guava库提供了经过优化的布隆过滤器实现,强烈推荐生产环境使用


<!-- Maven依赖 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version> <!-- 使用最新稳定版 -->
</dependency>

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

/**
* Guava布隆过滤器实战:防止缓存穿透
*/

public class CachePenetrationDefense {

// 创建布隆过滤器:预计100万个用户ID,误判率1%
private static final BloomFilter<String> userIdFilter =
BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8), // 指定字符串编码
1_000_000, // 预期元素数量
0.01 // 误判率
);

// 模拟数据库
private static final List<String> database = new ArrayList<>();

static {
// 预热:将真实用户ID加入布隆过滤器
for (int i = 1; i <= 10000; i++) {
String userId = "user_" + i;
database.add(userId);
userIdFilter.put(userId); // 关键:插入过滤器
}
System.out.println(">>> 布隆过滤器预热完成,共加载 " + database.size() + " 个用户ID");
}

/**
* 安全查询用户(带布隆过滤器防护)
*/

public static String queryUserSafe(String userId) {
// 第一步:布隆过滤器快速判断
if (!userIdFilter.mightContain(userId)) {
System.out.println("[BloomFilter] 拦截无效请求: " + userId + "(一定不存在)");
return null; // 直接返回,不查数据库!
}

// 第二步:可能存在,查缓存/数据库(此处简化为直接查库)
System.out.println("[BloomFilter] 通过初筛: " + userId + "(可能存在,继续查询)");
if (database.contains(userId)) {
System.out.println("[DB] 查询成功: " + userId);
return userId;
} else {
// 注意:这里可能是误判!需要记录日志分析
System.out.println("[WARN] 布隆过滤器误判: " + userId);
return null;
}
}

public static void main(String[] args) {
// 场景1:查询真实用户(应成功)
queryUserSafe("user_500");

System.out.println("\n---------- 分隔线 ----------\n");

// 场景2:查询无效用户(应被拦截)
queryUserSafe("hacker_999999");

System.out.println("\n---------- 分隔线 ----------\n");

// 场景3:压力测试 - 10万次无效请求
long startTime = System.currentTimeMillis();
int blockedCount = 0;
for (int i = 0; i < 100000; i++) {
if (queryUserSafe("invalid_" + i) == null) {
blockedCount++;
}
}
long costTime = System.currentTimeMillis() - startTime;
System.out.printf("\n【压力测试结果】拦截 %d 次无效请求,耗时 %d ms,QPS: %.0f\n",
blockedCount, costTime, 100000.0 / costTime * 1000);
}
}

输出示例


>>> 布隆过滤器预热完成,共加载 10000 个用户ID

[BloomFilter] 通过初筛: user_500(可能存在,继续查询)
[DB] 查询成功: user_500

---------- 分隔线 ----------

[BloomFilter] 拦截无效请求: hacker_999999(一定不存在)

---------- 分隔线 ----------

[BloomFilter] 拦截无效请求: invalid_0(一定不存在)
[BloomFilter] 拦截无效请求: invalid_1(一定不存在)
...

【压力测试结果】拦截 100000 次无效请求,耗时 125 ms,QPS: 800000


关键结论:10万次无效请求0次打到数据库,全部被布隆过滤器在微秒级拦截!



四、踩坑指南


坑1:误判率设置过低导致空间爆炸


// 错误示范:追求0.001%误判率,空间需求暴增10倍!
BloomFilter.create(Funnels.stringFunnel(UTF_8), 1_000_000, 0.00001);

// 正确做法:根据业务容忍度选择
// - 缓存穿透防护:1%~5% 足够(拦截95%+无效请求)
// - 金融风控:0.1% 以下(宁可多占空间也不能漏判)

坑2:元素数量超过预期,误判率飙升


布隆过滤器必须预估元素数量!超过预期后:



  • 位数组1的比例越来越高
  • 误判率呈指数级上升

// 危险操作:插入远超预期的元素
BloomFilter<String> filter = BloomFilter.create(..., 10000, 0.01);
for (int i = 0; i < 100000; i++) { // 插入10万,超预期10倍!
filter.put("user_" + i);
}
// 此时误判率可能高达50%以上!

解决方案



  1. 预留20%~30%余量
  2. 使用可扩展布隆过滤器(如RedisBloom模块)
  3. 定期重建过滤器(适用于数据有生命周期的场景)

坑3:试图“删除”元素


布隆过滤器原生不支持删除!因为:



  • 多个元素可能共享同一个bit位
  • 删除时无法判断该bit是否被其他元素占用

// 错误想法:想删除user_100
filter.delete("user_100"); // 不存在此方法!

// 正确方案:
// 1. 使用Counting Bloom Filter(计数布隆过滤器,空间翻倍)
// 2. 业务层标记删除(如数据库加is_deleted字段)
// 3. 定期重建过滤器(适用于短期数据)

坑4:哈希函数选择不当导致分布不均


手写实现时,劣质哈希函数会导致:



  • 位数组某些区域密集,某些区域稀疏
  • 实际误判率远高于理论值

// 劣质哈希:只用字符串长度,碰撞率极高!
int hash = value.length() % bitSize;

// 推荐方案:
// 1. 生产环境直接用Guava(内部使用MurmurHash3,分布均匀)
// 2. 手写时用多个质数种子组合

五、总结


特性说明适用场景
空间效率100万元素仅需1.25MB内存敏感场景(如嵌入式设备)
查询速度O(k) 常数时间(k为哈希函数数)高并发拦截场景
误判特性可能误判“存在”,但绝不误判“不存在”适合做“存在性”初筛
不可删除原生不支持删除操作适合只增不减的数据集
持久化可序列化位数组到磁盘/Redis重启后快速恢复

最佳实践三板斧



  1. 用Guava别手写:除非教学/特殊需求,生产环境直接用Guava
  2. 预估要留余量:预期数量 × 1.3 作为初始化参数
  3. 组合使用更安全:布隆过滤器 + 缓存 + 数据库 三级防护

作者:Nontee22
来源:juejin.cn/post/7607112994062712859

0 个评论

要回复文章请先登录注册