缓存穿透这个问题,大家应该都听过。就是恶意请求大量不存在的数据,直接打到数据库上。布隆过滤器就是解决这个问题的利器。
先说个生活里的例子
想象你是一个图书馆管理员。有人问:"你有没有这本书?"
你可以:
- 搜遍整个图书馆(太慢)
- 内存里放一份所有书名的列表(太占内存)
或者你可以用布隆过滤器------一种特殊的数据结构,它会告诉你:
- "一定没有" ------ 如果过滤器说没有,那这本书确实不存在
- "可能有" ------ 如果过滤器说有,这本书可能存在(但也可能是误判)
这就是布隆过滤器的核心特点:它绝不会把不存在的东西说成"一定有",但可能把存在的东西误判为"可能有"。
布隆过滤器怎么工作的
本质上就是一个 bit 数组 + 多个哈希函数:
假设我们有 10 个 bit 位置的数组, 初始都是 0:
index: 0 1 2 3 4 5 6 7 8 9
value: 0 0 0 0 0 0 0 0 0 0
我们用 3 个哈希函数: h1, h2, h3
现在要添加 "user:100" 这个 key:
- h1("user:100") = 2 → bit[2] = 1
- h2("user:100") = 5 → bit[5] = 1
- h3("user:100") = 7 → bit[7] = 1
添加 "order:200":
- h1("order:200") = 1 → bit[1] = 1
- h2("order:200") = 5 → bit[5] 已经 = 1 了
- h3("order:200") = 7 → bit[7] 已经 = 1 了
现在的数组:
index: 0 1 2 3 4 5 6 7 8 9
value: 0 1 1 0 0 1 0 1 0 0
现在查询 "user:100" 是否存在:
- h1 → bit[2] = 1 ✓
- h2 → bit[5] = 1 ✓
- h3 → bit[7] = 1 ✓
→ 三次都命中,返回 "可能存在" (true)
查询 "user:999" 是否存在:
- h1 → bit[?] = ?
- 如果任何一次不命中,直接返回 "一定不存在" (false)
Redis 里的布隆过滤器
Redis 4.0 之后支持了 Bloom Filter 模块,用起来很简单:
bash
# 添加元素到 bloom filter
BF.ADD user:bloom user:100
BF.ADD user:bloom user:200
# 判断元素是否存在
BF.EXISTS user:bloom user:100
# 返回 1,表示可能存在
BF.EXISTS user:bloom user:999
# 返回 0,表示一定不存在
Java 里用 Redisson:
java
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("user:bloom");
// 初始化,预期插入 100000 个元素,期望误判率 1%
bloomFilter.tryInit(100000, 0.01);
// 添加
bloomFilter.add("user:100");
bloomFilter.add("user:200");
// 判断
boolean mightExist = bloomFilter.contains("user:100"); // true
boolean definitelyNot = bloomFilter.contains("user:999"); // false
常见使用场景
1. 缓存穿透
java
public User getUser(Long userId) {
String cacheKey = "user:" + userId;
// 先查布隆过滤器
if (!bloomFilter.contains(cacheKey)) {
// 布隆过滤器说一定不存在,直接返回 null
return null;
}
// 过滤器说可能存在,再查缓存
User user = redis.get(cacheKey);
if (user != null) {
return user;
}
// 查数据库
user = userMapper.selectById(userId);
if (user != null) {
redis.set(cacheKey, user);
bloomFilter.add(cacheKey);
}
return user;
}
2. 用户点赞/浏览记录去重
java
// 判断用户是否点赞过
public boolean hasUserLiked(Long userId, Long articleId) {
String key = "liked:" + userId;
return bloomFilter.contains(key + ":" + articleId);
}
// 用户点赞时记录
public void userLike(Long userId, Long articleId) {
String key = "liked:" + userId + ":" + articleId;
if (!bloomFilter.contains(key)) {
// 实际写入数据库
likeMapper.insert(userId, articleId);
bloomFilter.add(key);
}
}
3. 邮箱/手机号去重注册
java
// 注册前检查
public Result register(String email) {
if (bloomFilter.contains("email:" + email)) {
// 可能重复,但需要再查数据库确认
if (userMapper.existsByEmail(email)) {
return Result.fail("邮箱已被注册");
}
}
// 执行注册逻辑...
}
4. 商品 SKU 过滤
电商秒杀场景,滤掉无效的 SKU 请求:
java
public Result seckill(Long userId, Long skuId) {
// 有效 SKU 在布隆过滤器里,不存在直接拒绝
if (!bloomFilter.contains("sku:" + skuId)) {
return Result.fail("无效商品");
}
// 继续处理秒杀逻辑...
}
误判率与空间占用
布隆过滤器的误判率和两个因素有关:
| 元素数量 | bit 位数 | 哈希函数数量 | 误判率 |
|---|---|---|---|
| 10000 | 100000 | 7 | ~1% |
| 10000 | 100000 | 3 | ~1% |
| 10000 | 200000 | 7 | ~0.1% |
空间占用计算:
假设 10 亿条数据,误判率 1%:
- 每个元素需要 ≈ 10 bit
- 总空间 ≈ 10 * 10亿 / 8 ≈ 1.25GB
对比用 HashSet 存同样的数据:
- 每个元素需要 ≈ 16 bytes
- 总空间 ≈ 16 * 10亿 ≈ 16GB
布隆过滤器空间占用只有 HashSet 的 1/10 左右。
Guava 版布隆过滤器
不需要 Redis,直接用 Guava:
xml
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
java
// 创建布隆过滤器,期望 1000000 个元素,误判率 1%
BloomFilter<String> filter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
1000000,
0.01
);
// 添加
filter.put("user:100");
// 判断
if (filter.mightContain("user:100")) {
// 可能存在,再查数据库确认
User user = userMapper.selectById(100);
if (user != null) {
return user;
}
}
注意事项
-
布隆过滤器不支持删除 - 因为删除一个元素可能会影响其他元素的判断。如果需要删除,可以用布隆过滤器的变体:Counting Bloom Filter 或者直接把删除的元素也加入一个 "已删除" 的过滤器里
-
误判率不能为 0 - 布隆过滤器的数学原理决定了它一定有误判率,只能通过增加位数来降低
-
适用于"查多为写少"的场景 - 读请求远多于写请求,或者数据量特别大的场景
总结
| 特点 | 说明 |
|---|---|
| 空间效率 | 比 HashSet 节省约 10 倍空间 |
| 查询效率 | O(k),k = 哈希函数数量,通常 3-10 个 |
| 不支持删除 | 删除会影响其他元素 |
| 误判不可避免 | 只能说"可能存在",不能说"一定存在" |
| 零漏报 | 说"不存在"就一定不存在 |
布隆过滤器不是什么场景都能用,但如果你的场景恰好满足"允许少量误判"+"查询远多于写入",用它准没错。