当面临高并发查询时,如果用户或恶意脚本疯狂请求数据库中根本不存在的资源 ID,这些请求会穿透缓存直接打到数据库上,导致数据库瞬间压力飙升甚至宕机。这就是经典的缓存穿透问题。
本文将结合实际项目代码,分享如何通过 “布隆过滤器 + Redis 白名单 + 空对象缓存兜底” 的多级缓存策略,优雅且彻底地解决这一痛点。
什么是布隆过滤器 + 多级缓存?
布隆过滤器
布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于一个集合中。
多级缓存架构
多级缓存策略是一种分层缓存设计,通常包括:
- 第一级 :布隆过滤器(拦截 99% 的无效请求)
- 第二级 :Redis 空对象缓存(拦截布隆过滤器的 1% 误判请求)
- 第三级 :Redis 白名单 Set(精确判断有效资源)
- 第四级 :数据库(最终兜底并进行缓存预热)
为什么需要布隆过滤器 + 多级缓存?
传统缓存逻辑的痛点
传统的缓存逻辑是:先查 Redis,Redis 没有再查 DB,DB 查到了写入 Redis 并返回,DB 没有则直接返回。 如果有人恶意遍历 如-1, -2, 9999999等根本不存在的id,Redis 永远查不到,请求全部落到 DB。
解决方案
- 只用 Redis 缓存空对象:如果恶意攻击使用海量不同的无效 ID,Redis 会缓存大量空对象,最终导致内存撑爆。
- 只用布隆过滤器:布隆过滤器可以极其高效地判断一个 ID “绝对不存在”或“可能存在”。但它存在一定的误判率,即它认为存在的 ID,数据库里其实没有。
- 最终的方案就是通过三道防线来解决:
- 第一道防线:布隆过滤器拦截大多数请求。
- 第二道防线:空对象缓存缓存那布隆过滤器放过的小部分请求。
- 第三道防线:Redis 白名单 Set缓存真正存在的ID进行放行。
核心代码实现
在项目中,我们在 UserAppointmentServiceImpl.java 中落地了这套多级缓存策略。
初始化布隆过滤器
在 Spring 容器启动时,利用 @PostConstruct 初始化 Redisson 提供的布隆过滤器,并预热数据库中已有的资源 ID。
// 布隆过滤器Key
private static final String BLOOM_FILTER_RESOURCE = "bloom:resource";
private RBloomFilter<Long> resourceBloomFilter;
@PostConstruct
public void init() {
// 1. 初始化布隆过滤器
resourceBloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_RESOURCE);
// 2. 初始化预期插入量为 10000,误判率为 0.03 (3%)
resourceBloomFilter.tryInit(10000L, 0.03);
// 3. 启动时预加载一次(将数据库所有可用资源ID加载到布隆和 Redis Set 中)
preprocessResources("RESOURCE");
}核心逻辑实现
当用户发起预约或查询请求时,我们严格按照防线顺序进行拦截。
@Override
public void submitAppointment(AppointmentDTO dto) {
String type = dto.getType();
Long resourceId = dto.getResourceId();
String nullCacheKey = "campus:null:cache:" + type + ":" + resourceId;
String redisKey = "campus:resource:available"; // Redis 白名单 Set Key
// ================= 多级缓存防穿透校验 =================
// 防线 0:先检查【空对象缓存】(针对布隆误判或已确认不存在的ID)
if (Boolean.TRUE.equals(redisTemplate.hasKey(nullCacheKey))) {
throw new RuntimeException("该资源不存在或已下架");
}
// 防线 1:【布隆过滤器】拦截(高效拦截绝大多数不存在的ID)
// 如果布隆说没有,那绝对没有,直接拒绝!
if (!resourceBloomFilter.contains(resourceId)) {
throw new RuntimeException("该资源不存在或已下架");
}
// 防线 2:检查【Redis 白名单 Set】
String idStr = resourceId.toString();
Boolean exists = redisTemplate.opsForSet().isMember(redisKey, idStr);
if (Boolean.FALSE.equals(exists)) {
// 如果布隆说有,但 Redis Set 说没有 -> 可能是 Redis 缓存过期,也可能是布隆误判
// 尝试懒加载预热(从数据库刷新最新有效资源到 Redis Set)
preprocessResources(type);
// 再次检查 Redis Set
exists = redisTemplate.opsForSet().isMember(redisKey, idStr);
// 防线 3:如果依然没有 -> 说明这是布隆过滤器的【误判】,或者是刚下架的资源
if (Boolean.FALSE.equals(exists)) {
// 写入【空对象缓存】,有效期 5 分钟
// 这样未来 5 分钟内,即使布隆误判,也会被防线 0 拦截,不会再触发查 DB 预热
redisTemplate.opsForValue().set(nullCacheKey, "", 5, TimeUnit.MINUTES);
throw new RuntimeException("该资源不存在或已下架");
}
}
}避坑
- 空对象缓存要定期清理,不然会占满Redis的内存,可以设置TTL。
- 布隆过滤器是会有误判的,一定要处理误判的逻辑。
- 布隆过滤器不支持删除元素,必须引入Redis白名单Set,当资源下架的时候从Set中删除对应的Id。
总结
通过 布隆过滤器 -> Redis 白名单 Set -> 空对象缓存 -> 数据库的结构,实现了一个优雅的能够防止缓存穿透的业务接口。
- 布隆过滤器以极小的内存代价挡住了 99% 的无效流量。
- Redis 白名单保证了热点数据的高速处理。
- 空对象缓存解决了布隆过滤器误判的情况。






