本文目录导读:

缓存穿透是指查询一个不存在的数据,由于缓存中查不到,请求会直接打到数据库,如果并发量高,数据库会压力巨大甚至被打垮,因为缓存没命中,所以也无法回填缓存,导致每次请求都会穿透。
以下是几种经典且可落地的解决案例,按防御层次由浅入深排序:
第一层:接口层校验(前置拦截)
适用场景:最常见的一层保障,ID 为负数、超长字符串、非法字符。
案例描述:
用户查询商品详情,productId 都是正数,如果请求 id=-1 或 id=abc,直接返回参数错误,不进入缓存和数据库查询逻辑。
代码示例(伪代码):
public Product getProduct(String productId) {
// 1. 前置校验
if (productId == null || !productId.matches("\\d+")) {
return null; // 直接返回,拦截非法请求
}
long id = Long.parseLong(productId);
if (id <= 0) {
return null;
}
// 2. 先去缓存查...
// 3. 再去数据库查...
}
效果:过滤掉大部分恶意攻击或代码 bug 导致的无效请求。
第二层:缓存空对象(主流方案)
适用场景:数据库确实没有数据,但请求量很大(用户查询一个不存在的优惠券码)。
案例描述:
假设用户输入了不存在的优惠券码 COUPON_9999,数据库查无结果,此时将空值(null 或空字符串)也写入缓存,并设置一个较短的过期时间(3~5 分钟),这样后续相同的请求在缓存中就能直接命中,不会打到数据库。
核心步骤:
- 从缓存取数据。
- 如果缓存未命中,查数据库。
- 如果数据库查不到,将
null写入缓存(key 为COUPON_9999,value 为null),并设置过期时间(如 300 秒)。 - 返回
null给前端。
代码示例(伪代码):
public String getCoupon(String couponCode) {
// 1. 查缓存
Object cacheValue = redis.get("coupon:" + couponCode);
// 2. 判断是否为“空缓存”
if (cacheValue != null) {
// 如果缓存的是空字符串,说明数据库也没有,直接返回空
if ("EMPTY".equals(cacheValue)) {
return null;
}
return (String) cacheValue;
}
// 3. 缓存未命中,去数据库查
String dbValue = couponMapper.selectByCode(couponCode);
// 4. 数据库没查到,缓存空值
if (dbValue == null) {
redis.set("coupon:" + couponCode, "EMPTY", 300); // 5分钟过期
return null;
}
// 5. 数据库查到了,缓存真实值
redis.set("coupon:" + couponCode, dbValue, 3600);
return dbValue;
}
优缺点:
- 优点:实现简单,能有效拦截大量重复的穿透请求。
- 缺点:消耗部分缓存内存;如果恶意攻击的 key 是随机的(如
id=1,id=2...),缓存中会堆积大量空值,占用内存,需要配合下面的布隆过滤器使用。
第三层:布隆过滤器(最强防御)
适用场景:数据量极大,且 key 是随机生成的(如 UUID),缓存空对象会撑爆内存。
案例描述: 用户通过 ID 查询文章,文章 ID 是自增的,但内容被删除或根本不存在,为了防止攻击者用不存在的 ID 随机轰炸,在系统初始化时,将所有存在的文章 ID 提前放入布隆过滤器中。
处理流程:
- 请求到达,先检查布隆过滤器。
- 如果布隆过滤器判定 不存在,直接返回
null,不查数据库。 - 如果布隆过滤器判定 可能存在,再走“缓存 -> 数据库”的流程。
代码实现(以 Redisson 为例):
// 初始化布隆过滤器(系统启动时,将100W个合法ID灌入)
RBloomFilter<Long> bloomFilter = redisson.getBloomFilter("articleIdFilter");
bloomFilter.tryInit(1000000L, 0.01); // 期望100万条数据,误差率1%
// 请求处理
public Article getArticle(Long articleId) {
// 1. 布隆过滤器拦截
if (!bloomFilter.contains(articleId)) {
System.out.println("布隆过滤器拦截不存在ID: " + articleId);
return null; // 直接返回,不查DB
}
// 2. 查缓存 ...
// 3. 查数据库 ...
}
优缺点:
- 优点:空间占用极小(100万条数据仅占约 1MB 内存),性能极高。
- 缺点:存在误判率(过滤器说存在,但数据库可能没有,需配合缓存空值兜底);不删除数据(若 ID 被删除,无法从布隆过滤器中移除,需要定期重建或容忍误查)。
第四层:热点数据兜底(限流降级)
适用场景:数据库或缓存被击穿瞬间的压力保护。
案例描述: 如果布隆过滤器或缓存空值都失效,且发生了极端并发(同一时刻 10000 个请求同时穿透),数据库可能瞬间崩溃,此时可以加一个互斥锁,让请求排队。
代码示例(双重检查锁):
public String getData(String key) {
// 1. 查缓存
String value = redis.get(key);
if (value != null) {
return value;
}
// 2. 缓存未命中,加锁
String lockKey = "lock:" + key;
boolean lock = redis.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (!lock) {
// 拿不到锁,说明有其他线程正在查库,短暂等待后重试
Thread.sleep(50);
return getData(key); // 递归重试
}
try {
// 3. 拿到锁,再查一次缓存(可能其他线程已经填入了)
value = redis.get(key);
if (value != null) {
return value;
}
// 4. 真正查询数据库
value = queryDB(key);
redis.setex(key, 3600, value);
return value;
} finally {
// 释放锁
redis.delete(lockKey);
}
}
综合架构案例(企业级实践)
以电商系统的商品查询为例,应对恶意攻击的完整链路:
- 请求入口:
Nginx层做 IP 限流(每 IP 每秒 10 次)。 - 应用层:校验
productId格式是否合法。 - 布隆过滤器:判断
productId是否可能存在于数据库。- 不存在 → 返回“商品不存在”。
- 可能存在 → 进入下一步。
- Redis 缓存:查询是否命中。
- 命中了“空值标记” → 返回“商品不存在”。
- 命中了真实数据 → 返回商品。
- 未命中 → 进入下一步。
- 互斥锁:获取锁(防止多个请求同时查库)。
- 数据库查询:如果查到就回填缓存;如果没查到,在 Redis 中写入“空值标记”(
null或特殊符号),设置 5 分钟过期。 - 返回响应。
通过这种多重组合方案,既能防止恶意攻击(用布隆过滤器挡掉大头),又能防止正常业务数据缺失导致的问题(用缓存空值兜底),还能保护数据库不被高并发打崩(用互斥锁降级)。