1. 布隆过滤器解决什么问题
缓存穿透的典型场景是:请求的 key 在 Redis 没有,在 MySQL 也没有。攻击者如果不断请求不存在的 ID,请求就会绕过缓存打到数据库。
布隆过滤器可以在请求进入缓存和数据库之前先做一次判断:
请求 userId
-> 布隆过滤器判断:一定不存在
-> 直接返回空结果,避免访问 Redis 和 MySQL
布隆过滤器的核心特性是:
布隆过滤器判断不存在,那就一定不存在;判断存在,只能说明可能存在。
| 项目 | 说明 |
|---|---|
| 目的 | 用较低内存成本拦截明显不存在的数据 |
| 方式 | 不保存完整数据,只用 bit 标记和多次 hash 判断元素是否可能存在 |

2. 原理拆开看
布隆过滤器由一个 bit 数组和多个 hash 函数组成。bit array 本质上是一段连续 bit 空间,用来记录某些 hash 位置是否被置为 1。
2.1 写入过程
写入一个元素时:
- 对元素做多次 hash。
- 得到多个 bit 下标。
- 把这些位置都设置为 1。
2.2 判断过程
判断一个元素是否存在时:
- 同样做多次 hash。
- 检查这些 bit 是否都是 1。
- 只要有一个 bit 是 0,就说明这个元素一定没加入过。
- 如果全是 1,只能说明可能加入过,因为多个元素可能 hash 到同一批位置。
误判的核心来源就是 hash 冲突。多个不同元素可能把同一批 bit 置为 1,导致一个从未写入的元素在查询时也命中所有 bit。
实际分析误判率时,通常假设多个 hash 函数近似独立。工程实现里可以用多个 seed、双重 hash 或成熟库来逼近这个假设。

3. 优缺点
| 项目 | 说明 |
|---|---|
| 优点 | 空间效率高,判断速度快 |
| 缺点 | 有误判率,普通布隆过滤器不支持删除 |
| 适合 | 缓存穿透防护、黑名单预判、已读判重 |
| 不适合 | 需要 100% 精确存在判断、需要频繁删除的场景 |
误判是指:一个实际不存在的元素,被判断为可能存在。误判会导致请求继续查 Redis 或 MySQL,但不会把真实存在的数据误判成不存在。
这个误判也叫 false positive。布隆过滤器不会产生 false negative:只要它判断不存在,就可以认为一定不存在,前提是写入链路没有漏写。
普通布隆过滤器不能删除,是因为多个元素可能共享同一个 bit。把某个 bit 改回 0,可能会影响其他元素。需要删除能力时,可以考虑 Counting Bloom Filter 或换数据结构。
4. Redis 怎么实现布隆过滤器
有两种方式:
| 方式 | 说明 |
|---|---|
| RedisBloom 模块 | 官方生态模块,提供 BF.ADD、BF.EXISTS 等命令 |
| 手写 Bitmap | 用 Redis String 的 SETBIT、GETBIT 自己实现 |
如果生产环境允许加载模块,RedisBloom 更完整,底层仍然是 bitset 加概率数据结构,只是它把容量、误判率、扩容等细节封装成更可靠的命令。这里用 Bitmap 手写一个简化版,便于理解底层原理。
5. 用 SETBIT 写入
写入元素:
setbit bloom:user 12345 1
setbit bloom:user 67890 1
setbit bloom:user 13579 1
判断元素:
getbit bloom:user 12345
getbit bloom:user 67890
getbit bloom:user 13579
真实代码里不能只有一个 hash,否则误判率会很高。应该用多个 hash 函数算出多个 offset。单 hash 更接近简单 Bitmap 标记,冲突一多就会快速失去过滤价值。
6. Java 简化实现
以下是一个便于理解的简化版。生产环境要使用更稳定的 hash 算法,并根据预估数据量和误判率计算 bit 数组大小。
public class RedisBloomFilter {
private static final String KEY = "bf:user";
private static final long SIZE = 1L << 28;
private static final int[] SEEDS = {3, 7, 11, 13, 31, 37};
private final StringRedisTemplate redisTemplate;
public RedisBloomFilter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void add(String value) {
for (int seed : SEEDS) {
long offset = hash(value, seed);
redisTemplate.opsForValue().setBit(KEY, offset, true);
}
}
public boolean mightContain(String value) {
for (int seed : SEEDS) {
long offset = hash(value, seed);
Boolean bit = redisTemplate.opsForValue().getBit(KEY, offset);
if (!Boolean.TRUE.equals(bit)) {
return false;
}
}
return true;
}
private long hash(String value, int seed) {
long result = 0;
for (int i = 0; i < value.length(); i++) {
result = seed * result + value.charAt(i);
}
return (SIZE - 1) & result;
}
}
这里的 hash 只是演示用的非 cryptographic hash,不适合安全场景,也不适合直接用于对抗恶意输入。生产环境应使用成熟实现,例如 RedisBloom、Guava BloomFilter,或经过验证的 hash 方案。
这里的 SIZE 必须是业务评估后的值。数据量越大、bit 数组越小、hash 函数越少,误判率越高。
误判率可以用下面的近似公式理解:
p ≈ (1 - e^(-kn/m))^k
其中:
| 参数 | 含义 |
|---|---|
| m | bit array 长度 |
| n | 预计写入元素数量 |
| k | hash 函数数量 |
| p | 期望误判率 |
在给定 n 和 p 后,可以反推需要的 bit 数和 hash 函数数量。常见近似公式是:
m = -n * ln(p) / (ln2)^2
k = (m / n) * ln2
所以 SIZE = 1L << 28 和 SEEDS 数量都不应该拍脑袋设置。SEEDS 越多不一定越好,hash 次数太多会增加 Redis 往返和 CPU 成本;太少又会提高误判率。
7. 启动时初始化
可以在应用启动时把数据库已有 ID 加载进布隆过滤器:
@Component
public class UserBloomInitializer {
private final UserMapper userMapper;
private final RedisBloomFilter bloomFilter;
public UserBloomInitializer(UserMapper userMapper, RedisBloomFilter bloomFilter) {
this.userMapper = userMapper;
this.bloomFilter = bloomFilter;
}
@PostConstruct
public void init() {
List<Long> ids = userMapper.selectAllIds();
for (Long id : ids) {
bloomFilter.add(String.valueOf(id));
}
}
}
如果数据量很大,不建议每个应用实例启动都全量加载。全量加载会带来 cold start delay,也可能在发布时集中打爆 MySQL 和 Redis。可以由单独任务构建布隆过滤器,业务实例只使用。
如果通过 MQ 或定时任务重建布隆过滤器,本质上属于 eventual consistency rebuild。重建期间要考虑新老过滤器切换、增量数据补偿和失败回滚。
8. 查询链路怎么接
查询用户详情:
public UserDTO getUser(Long userId) {
String id = String.valueOf(userId);
if (!bloomFilter.mightContain(id)) {
return null;
}
String key = "user:" + userId;
UserDTO cache = redisCache.get(key);
if (cache != null) {
return cache;
}
UserDO user = userMapper.selectById(userId);
if (user == null) {
return null;
}
UserDTO dto = convert(user);
redisCache.set(key, dto, 10, TimeUnit.MINUTES);
return dto;
}
如果布隆过滤器判断不存在,直接返回空结果。判断可能存在,再走 Redis 和 MySQL。
这条链路解决的是 cache penetration pattern,也就是大量不存在 key 穿透缓存打到数据库。mightContain 不能作为强一致判断依据,它只适合做访问前置过滤。
9. 新增数据时怎么办
新增用户成功后,要同步加入布隆过滤器:
@Transactional(rollbackFor = Exception.class)
public Long createUser(CreateUserCommand command) {
UserDO user = command.toEntity();
userMapper.insert(user);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
bloomFilter.add(String.valueOf(user.getId()));
}
});
return user.getId();
}
不要在事务提交前写布隆过滤器。否则数据库事务回滚后,布隆过滤器里可能已经留下这个 ID,后续查询会多打一次 Redis 或 MySQL。
这里仍然存在一个小窗口:数据库提交成功,但布隆过滤器写入失败。严格一点可以把新增 ID 投递到 MQ,由消费者重试写入布隆过滤器。
MQ 消费者要保证幂等。同一个 ID 重复加入布隆过滤器没有副作用,因此可以安全重试。真正要避免的是 DB 已提交但消息丢失、消费者长时间失败,导致新数据被过滤器误判为不存在。
10. 和缓存空对象的区别
缓存空对象也是解决缓存穿透的常用方法:
set user:404 "__NULL__" ex 60
对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 缓存空对象 | 简单,能挡住重复请求 | 大量随机 key 会污染 Redis |
| 布隆过滤器 | 空间效率高,适合海量 ID 判断 | 有误判率,普通版本不支持删除 |
缓存空对象的风险是 key explosion:如果攻击者持续构造随机 ID,即使每个空对象 TTL 很短,也可能在短时间制造大量空 key。
布隆过滤器更适合 large-scale key filtering。实际项目可以组合使用:布隆过滤器挡住明显不存在的 ID,少量漏进来的不存在请求再短 TTL 缓存空对象。
11. 扩容和删除能力
普通布隆过滤器不容易动态扩容。容量设计过小后,bit array 会越来越满,误判率持续升高。要解决这个问题,可以使用 Scalable Bloom Filter,或者重新构建一个更大的过滤器并做平滑切换。
普通布隆过滤器也不支持安全删除。如果业务必须删除元素,可以考虑 Counting Bloom Filter,用计数器替代单个 bit。但它会显著增加内存成本,也要处理计数溢出和并发更新问题。
12. 本文小结
布隆过滤器可以用三层模型理解:
| 层次 | 作用 |
|---|---|
| Bloom Filter | probabilistic membership structure,判断元素是否可能存在 |
| Redis | storage backend,保存 bitset 或 RedisBloom 结构 |
| Hash | mapping layer,把元素映射到多个 bit offset |
它适合做缓存穿透防护和海量 ID 前置过滤,但不适合强一致存在判断、审计、计费、频繁删除和需要动态扩容的场景。工程落地时,核心不是只会写 SETBIT,而是先明确容量、误判率、初始化、增量补偿和重建方案。