缓存穿透、击穿、雪崩:三个概念傻傻分不清楚?
这三个概念面试必考,但很多人记混了。
一句话区分:
- 穿透: 查的数据根本不存在
- 击穿: 热点数据的缓存突然过期
- 雪崩: 大量缓存同时过期
缓存穿透
场景: 查询一个数据库里根本不存在的数据。
请求 id=-1 → 查缓存(没有)→ 查数据库(也没有)→ 返回null
下次再请求 id=-1 → 查缓存(还是没有)→ 查数据库(还是没有)
每次请求都打到数据库,缓存完全失效。恶意攻击者可以用这种方式打垮数据库。
解法一:缓存空值
查数据库发现没有,就在缓存里存一个空值,设置较短的过期时间(比如5分钟)。
Object data = cache.get(key);
if (data == null) {
data = db.query(key);
if (data == null) {
cache.set(key, "NULL", 5, TimeUnit.MINUTES); // 缓存空值
} else {
cache.set(key, data, 30, TimeUnit.MINUTES);
}
}
return "NULL".equals(data) ? null : data;
缺点:如果攻击者用不同的不存在的key,缓存里会存大量空值,占用内存。
解法二:布隆过滤器(推荐)
布隆过滤器是一个位数组+多个哈希函数,可以判断一个元素"一定不存在"或"可能存在"。
// 启动时,把所有合法的id加入布隆过滤器
bloomFilter.add(id);
// 请求进来时,先过布隆过滤器
if (!bloomFilter.mightContain(requestId)) {
return null; // 布隆过滤器说不存在,直接返回,不查缓存和数据库
}
// 布隆过滤器说可能存在,再查缓存和数据库
布隆过滤器有一定的误判率(说存在但实际不存在),但不会漏判(说不存在就一定不存在)。
布隆过滤器误判率与位数组大小的关系: 误判率由位数组长度 m 和哈希函数个数 k 决定,元素越多、位数组越小则误判率越高。Guava实现中可在创建时指定期望误判率:
// Guava BloomFilter:期望插入100万元素,误判率1%
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
1_000_000, // 预期元素数量
0.01 // 期望误判率(1%)
);
// Guava会自动计算最优的位数组大小和哈希函数个数
// Redis实现(RedisBloom模块)
// BF.RESERVE key error_rate capacity
// BF.RESERVE user:bloom 0.01 1000000
// BF.ADD user:bloom 12345
// BF.EXISTS user:bloom 12345
数据更新时布隆过滤器的维护: 布隆过滤器不支持删除操作。新增数据时向过滤器添加即可;删除数据时无法从过滤器中移除(若需要支持删除,可使用 Counting Bloom Filter)。可以定期重建过滤器解决过时数据问题。
缓存空值方案的注意事项:
// ⚠️ 边界场景:数据库后续真正新增了该数据,空值缓存会导致短暂不一致
// 解决:新增数据时主动删除空值缓存
public void createUser(User user) {
userMapper.insert(user);
// 主动删除可能存在的空值缓存,防止缓存与数据库不一致
cache.delete("user:" + user.getId());
}
缓存击穿
场景: 一个热点数据(比如明星微博、秒杀商品)的缓存突然过期,大量请求同时涌入,全部打到数据库。
缓存过期的那一刻:
1000个并发请求 → 查缓存(全部miss)→ 全部打到数据库 → 数据库可能崩
解法一:互斥锁
缓存miss时,只让一个线程去查数据库,其他线程等待。
public Object getData(String key) {
Object data = cache.get(key);
if (data != null) return data;
// 缓存miss,尝试获取互斥锁
String lockKey = "lock:" + key;
if (tryLock(lockKey)) {
try {
// 双重检查,防止其他线程已经重建了缓存
data = cache.get(key);
if (data != null) return data;
// 查数据库,重建缓存
data = db.query(key);
cache.set(key, data, 30, TimeUnit.MINUTES);
return data;
} finally {
unlock(lockKey);
}
} else {
// 没拿到锁,等一会儿重试
Thread.sleep(50);
return getData(key);
}
}
缺点:等待期间请求被阻塞,响应变慢。
解法二:逻辑过期(热点数据永不过期)
不设置TTL,而是在数据里存一个"逻辑过期时间"。发现逻辑过期时,异步更新缓存,同时返回旧数据。
public Object getData(String key) {
CacheData cacheData = cache.get(key);
// 缓存存在且未逻辑过期
if (cacheData != null && !isExpired(cacheData)) {
return cacheData.getData();
}
// 逻辑过期,异步重建
if (tryLock("lock:" + key)) {
// 异步线程去更新缓存
executor.submit(() -> {
Object newData = db.query(key);
cache.set(key, new CacheData(newData, System.currentTimeMillis() + 30 * 60 * 1000));
unlock("lock:" + key);
});
}
// 返回旧数据(允许短暂不一致)
return cacheData != null ? cacheData.getData() : null;
}
适合读多写少、允许短暂不一致的热点数据。
缓存雪崩
场景: 大量缓存在同一时间段过期,或者Redis服务宕机,导致所有请求全部打到数据库。
解法:
1. 过期时间加随机值
// ❌ 所有缓存同时过期
cache.set(key, data, 30, TimeUnit.MINUTES);
// ✅ 过期时间加随机扰动,避免同时过期
int randomOffset = new Random().nextInt(300); // 0-300秒随机
cache.set(key, data, 30 * 60 + randomOffset, TimeUnit.SECONDS);
2. Redis高可用
部署Redis集群(主从+哨兵,或者Cluster),避免单点故障导致全量缓存失效。
3. 服务降级
Redis挂了,不要让请求直接打到数据库,而是返回降级数据(比如空数据、本地缓存、兜底数据)。
try {
data = cache.get(key);
if (data == null) {
data = db.query(key);
}
} catch (RedisException e) {
// Redis挂了,返回降级数据
data = getDefaultData(key);
log.error("Redis异常,走降级", e);
}
4. 限流
即使缓存雪崩发生了,通过限流保护数据库不被打垮。
三种问题对比
| 问题 | 触发条件 | 核心解法 |
|---|---|---|
| 穿透 | 查不存在的数据 | 布隆过滤器 |
| 击穿 | 热点数据缓存过期 | 互斥锁 / 逻辑过期 |
| 雪崩 | 大量缓存同时过期 / Redis宕机 | 过期时间加随机值 / 高可用 / 限流 |
面试时把这三个概念的区别和对应解法说清楚,不要混淆,基本就过了。
浙公网安备 33010602011771号