跳到主要内容

缓存问题

问题

什么是缓存穿透、缓存击穿、缓存雪崩?分别如何解决?

答案

三大缓存问题对比

问题描述核心原因
缓存穿透请求的数据在缓存和数据库中都不存在恶意请求 / 数据不存在
缓存击穿热点 key 过期瞬间,大量请求打到数据库热点 key 集中过期
缓存雪崩大面积缓存同时失效,请求全部打到数据库大量 key 同时过期 / Redis 宕机

缓存穿透

请求的数据在缓存和数据库中都不存在,每次请求都穿透到数据库。

解决方案

方案原理优点缺点
缓存空值数据库查不到时,缓存一个空值(短 TTL)简单有效空值占内存
布隆过滤器请求前先判断数据是否可能存在内存占用小有误判率,不支持删除
参数校验过滤明显非法参数(如负数 ID)零成本只能挡住部分请求
缓存空值
public Object getData(String key) {
// 1. 查缓存
Object value = redis.get(key);
if (value != null) {
return "NULL".equals(value) ? null : value; // 识别空值标记
}

// 2. 查数据库
Object dbValue = db.get(key);
if (dbValue != null) {
redis.set(key, dbValue, 3600); // 正常 TTL
} else {
// 缓存空值,短 TTL 防止长期占用
redis.set(key, "NULL", 300); // 5 分钟
}
return dbValue;
}
布隆过滤器
// 初始化时将所有合法 ID 加入布隆过滤器
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(), 1000000, 0.01); // 100 万数据,1% 误判率

public Object getData(Long id) {
// 1. 布隆过滤器判断
if (!bloomFilter.mightContain(id)) {
return null; // 一定不存在,直接返回
}

// 2. 查缓存 → 查数据库(正常流程)
// ...
}
布隆过滤器特点
  • 判断不存在一定不存在(100% 准确)
  • 判断存在可能不存在(有 1~3% 的误判率)
  • Redis 模块 RedisBloom 提供了原生布隆过滤器支持:BF.ADDBF.EXISTS

缓存击穿

某个热点 key 在过期的瞬间,大量并发请求同时查询该 key,全部穿透到数据库。

解决方案

方案原理
互斥锁只允许一个线程重建缓存,其他线程等待
逻辑过期key 永不过期,在 value 中存过期时间,异步更新
热点预加载提前感知热点 key,在过期前刷新
互斥锁方案
public Object getDataWithMutex(String key) {
Object value = redis.get(key);
if (value != null) {
return value;
}

// 尝试获取分布式锁
String lockKey = "lock:" + key;
boolean locked = redis.set(lockKey, "1", "NX", "EX", 10);

if (locked) {
try {
// 双重检查:拿到锁后再查一次缓存
value = redis.get(key);
if (value != null) {
return value;
}
// 查数据库,重建缓存
value = db.get(key);
redis.set(key, value, 3600);
} finally {
redis.del(lockKey);
}
} else {
// 未获取到锁,短暂等待后重试
Thread.sleep(50);
return getDataWithMutex(key); // 递归重试
}
return value;
}
逻辑过期方案
// value 中包含逻辑过期时间
public Object getDataWithLogicalExpire(String key) {
String json = redis.get(key);
if (json == null) {
return null; // 缓存不存在(冷启动需预热)
}

CacheData cacheData = JSON.parseObject(json, CacheData.class);
if (cacheData.getExpireTime().isAfter(LocalDateTime.now())) {
return cacheData.getData(); // 未过期,直接返回
}

// 已逻辑过期,尝试获取锁异步更新
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1")) {
// 开启异步线程更新缓存
executorService.submit(() -> {
try {
Object newData = db.get(key);
CacheData newCache = new CacheData(newData,
LocalDateTime.now().plusHours(1));
redis.set(key, JSON.toJSONString(newCache));
} finally {
redis.del(lockKey);
}
});
}

// 返回旧数据(数据不是最新的,但不会阻塞)
return cacheData.getData();
}
对比互斥锁逻辑过期
一致性高(等待最新数据)低(短暂返回旧数据)
可用性低(需等待锁)高(立即返回)
适用场景数据一致性要求高高可用优先

缓存雪崩

大面积缓存 key 同时过期,或 Redis 服务宕机,导致请求全部打到数据库。

解决方案

场景方案
key 同时过期过期时间加随机值,分散过期时间
Redis 宕机集群部署 + 哨兵/Cluster
DB 保护限流降级
提前预防多级缓存
过期时间加随机值
int baseTTL = 3600;  // 基础过期时间 1 小时
int randomTTL = new Random().nextInt(600); // 随机 0~600 秒
redis.set(key, value, baseTTL + randomTTL);
多级缓存
// L1: 本地缓存(Caffeine / Guava Cache)
// L2: Redis
// L3: 数据库

public Object getDataWithMultiLevel(String key) {
// L1: 本地缓存
Object value = localCache.get(key);
if (value != null) return value;

// L2: Redis
value = redis.get(key);
if (value != null) {
localCache.put(key, value);
return value;
}

// L3: 数据库
value = db.get(key);
if (value != null) {
redis.set(key, value, 3600 + random(600));
localCache.put(key, value);
}
return value;
}

三大问题解决方案速查

问题首选方案备选方案
穿透布隆过滤器缓存空值 + 参数校验
击穿互斥锁 / 逻辑过期热点 key 永不过期
雪崩TTL 加随机值 + 集群多级缓存 + 限流降级

常见面试问题

Q1: 缓存穿透、击穿、雪崩的区别?

答案

  • 穿透:数据根本不存在(缓存和DB都没有),恶意请求可以利用这一点攻击
  • 击穿:数据存在但热点 key 刚好过期,大量并发请求同时穿透到 DB
  • 雪崩大面积 key 同时失效或 Redis 宕机,流量全部打到 DB

一句话区分:穿透是"查不到",击穿是"一个热点 key 过期",雪崩是"大量 key 同时过期"。

Q2: 布隆过滤器的原理?

答案

布隆过滤器使用一个位数组和多个哈希函数:

  1. 添加元素:用 k 个哈希函数计算 k 个位置,将这些位置设为 1
  2. 查询元素:用相同的 k 个哈希函数计算位置,如果全部为 1则"可能存在",任一为 0则"一定不存在"

特点:

  • 空间效率极高(100 万数据约 1.2MB)
  • 说不存在一定不存在,但说存在可能不存在(误判率可控,通常 1~3%)
  • 不支持删除(可用 Counting Bloom Filter 支持)

Q3: 互斥锁和逻辑过期哪个好?

答案

取决于业务对一致性可用性的优先级:

  • 互斥锁:保证数据一致性,但缓存重建期间其他线程需要等待(可能增加延迟)。适合金融、交易等场景。
  • 逻辑过期:保证高可用(立即返回旧数据),但短暂时间内数据可能不是最新的。适合社交、内容等场景。

实际中还可以结合使用:热点 key 用逻辑过期 + 异步更新,普通 key 用互斥锁。

Q4: 如何防止缓存雪崩?

答案

分为预防兜底两个层面:

预防措施

  1. 过期时间加随机值(最简单有效):TTL = baseTTL + random(0, 600)
  2. Redis 高可用部署:Sentinel 或 Cluster,避免单点故障
  3. 热点 key 永不过期:使用逻辑过期 + 异步更新
  4. 多级缓存:本地缓存(Caffeine)→ Redis → 数据库

兜底方案

  1. 限流降级:使用 Sentinel 或 Hystrix 对数据库访问限流
  2. 服务熔断:数据库压力过大时直接返回降级数据
  3. 请求排队:热点数据重建时使用队列串行化请求

相关链接