本文目录导读:

- 第一步:先理解什么是缓存穿透
- 第二步:布隆过滤器的核心原理
- 第三步:如何用布隆过滤器防止缓存穿透
- 第四步:代码示例(基于Google Guava + Redis + Spring Boot)
- 第五步:布隆过滤器的优缺点
- 总结:何时使用布隆过滤器?
这是一个非常经典的分布式系统设计问题。布隆过滤器是防止缓存穿透的最有效手段之一。
下面我会用通俗的语言,结合一个极简的代码示例,详细解释其原理和实现步骤。
第一步:先理解什么是缓存穿透
正常流程:
- 客户端请求数据。
- 先去 Redis(缓存)里查。
- 缓存有:直接返回,很快。
- 缓存没有:去数据库查,把结果写回缓存,再返回。
缓存穿透: 是指缓存和数据库中都没有的数据被持续请求。
- 攻击者伪造了一个 ID = -1 的用户,或者一个不存在的 ID。
- 每次请求都直接打到了数据库。
- 如果并发极高(如秒杀、爬虫攻击),数据库会因为扛不住查询压力而宕机。
- 本质:缓存失去了“保护数据库”的作用。
第二步:布隆过滤器的核心原理
布隆过滤器本质上是一个很长的二进制位数组 和 多个哈希函数。
- 特性:
- 判断“肯定不存在”:非常准确,如果布隆过滤器说“不存在”,那这个数据绝对不存在。
- 判断“可能存在”:有一定误判率,如果布隆过滤器说“存在”,这个数据可能真的存在,也可能不存在(哈希冲突导致)。
- 工作流程:
- 添加数据:将数据(如用户ID)通过多个哈希函数计算出多个位置,把这些位置的位设置成 1。
- 查询数据:对查询的数据同样进行哈希计算,检查对应位是否全部为 1。
- 只要有任意一位是 0:数据一定不存在。
- 如果全部是 1:数据可能存在。
第三步:如何用布隆过滤器防止缓存穿透
这是一个标准的“双保险”流程:
架构图(文字版):
客户端 -> 布隆过滤器 -> 缓存 (Redis) -> 数据库 (MySQL)
(挡在第一道) (第二道) (最后防线)
具体步骤:
初始化(项目启动时) 预先将数据库中所有存在的业务ID(如用户ID、商品ID),全部加载到布隆过滤器中。
// 伪代码 - 初始化布隆过滤器
BloomFilter<Long> bloomFilter = BloomFilter.create(Funnels.longFunnel(), expectedInsertions, falsePositiveRate);
// 从数据库读取所有存在的ID,并存入布隆过滤器
List<Long> allUserIds = userMapper.getAllIds();
for (Long id : allUserIds) {
bloomFilter.put(id);
}
请求处理流程
-
第一步:拦截(核心步骤)
- 用户请求一个 ID。
- 先问布隆过滤器:
bloomFilter.mightContain(ID) - 如果是
false:布隆过滤器说“这个 ID 绝对不在数据库里”。- 直接拒绝:返回空结果或错误信息。不查缓存,不查数据库。
- 如果是
true:布隆过滤器说“这个 ID 可能存在”。
-
第二步:查询缓存
- 如果布隆过滤器放行,再去查 Redis。
- Redis 有,直接返回。
- Redis 没有,去查数据库。
-
第三步:查数据库并回写
- 数据库查到了:写回缓存,返回数据。
- 数据库没查到:这是一个罕见的“幽灵”情况(布隆过滤器误判导致),此时为了防止下一次同样请求再次穿透,可以在 Redis 中缓存一个空值(设置较短的过期时间)。
流程图:
请求来了(ID = -1)
|
v
布隆过滤器: mightContain(-1) ?
|
+-- false (不存在) --------> 直接返回 "无效ID" (保护了数据库)
|
+-- true (可能存在)
|
v
查缓存 (Redis)
|
+-- 有数据 -----> 返回 (正常)
|
+-- 无数据
|
v
查数据库 (MySQL)
|
+-- 有数据 -----> 回写Redis并返回
|
+-- 无数据 -----> 缓存一个空值到Redis (防止下一次穿透)
(设置较短的过期时间,如5分钟)
第四步:代码示例(基于Google Guava + Redis + Spring Boot)
@Service
public class UserService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private UserMapper userMapper;
// 1. 项目启动时初始化布隆过滤器(实际应该用@PostConstruct或者在启动类中执行)
private BloomFilter<Long> bloomFilter;
@PostConstruct
public void init() {
// 预计插入100万条数据,误判率设为1%
this.bloomFilter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01);
List<Long> allIds = userMapper.getAllUserIds();
allIds.forEach(id -> bloomFilter.put(id));
log.info("布隆过滤器初始化完成,共加载{}个ID", allIds.size());
}
public User getUserById(Long id) {
// 步骤1:布隆过滤器前置拦截
if (!bloomFilter.mightContain(id)) {
// 一定不存在,直接返回null(防止穿透)
return null;
}
// 步骤2:查缓存
String cacheKey = "user:" + id;
String json = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.hasText(json)) {
return JSON.parseObject(json, User.class);
}
// 步骤3:数据库查询
User user = userMapper.selectById(id);
// 步骤4:回写缓存(包括空值缓存)
if (user != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), Duration.ofHours(1)); // 正常数据1小时过期
return user;
} else {
// 数据库里也没有,但布隆过滤器说可能存在(误判)
// 为了防穿透,缓存一个空值,设置短过期时间
redisTemplate.opsForValue().set(cacheKey, "", Duration.ofMinutes(5));
return null;
}
}
}
第五步:布隆过滤器的优缺点
优点:
- 内存占用极小:1亿条数据,误判率1%时,大约只占 114 MB 内存。
- 查询速度极快:O(k) 复杂度,k 是哈希函数个数(通常几个到十几个)。
- 完全防止了无效请求到达数据库:这是最核心的价值。
缺点:
- 有误判率:可能把不存在的 ID 判断为存在(导致一次无效的缓存+数据库查询,但不会穿透到数据库导致崩溃)。
- 解决办法:接受,因为误判率可控(如0.01%)。
- 无法删除元素:标准的布隆过滤器不支持删除,如果数据库删掉了一个ID,布隆过滤器里对应的位无法清零(因为可能被多个ID复用)。
- 解决办法:
- 定时重建:每天凌晨低峰期,重新加载数据库全量ID到新的布隆过滤器,然后切换。
- 使用 Counting Bloom Filter(布谷鸟过滤器):支持删除,但更耗内存。
- 只做第一步拦截:即便有误判,后面的缓存空值机制也能兜底。
- 解决办法:
- 需要预热:项目重启后,需要先加载所有ID,否则新布隆过滤器是空的,正常请求也会被误拦。
何时使用布隆过滤器?
| 场景 | 推荐度 | 说明 |
|---|---|---|
| 高并发、数据在DB中持续存在(如有ID的字典表) | 强烈推荐 | 提前一次加载完所有ID,能完美防穿透。 |
| 动态新增、删除频繁的数据 | 谨慎使用 | 需要定时重建,或者配合布谷鸟过滤器。 |
| 数据量极小 | 用不着 | 直接缓存空值或加锁即可。 |
| 误判导致业务无法接受 | 不建议 | 比如实时对账,错判会导致一笔交易被拦截。 |
一句话总结: 布隆过滤器像一个“黑名单”守卫,说“不在”你就一定不在,它用极小的内存,把绝大部分无效请求挡在数据库之外,是防止缓存穿透的终极防线。