跳转到内容

缓存三大问题


问题定义危害核心解决方案
缓存穿透查询不存在的数据,缓存和 DB 都没有大量请求直击 DB空值缓存 / 布隆过滤器
缓存击穿热点 key 过期瞬间,大量请求同时打到 DBDB 瞬间压力暴增互斥锁 / 逻辑过期
缓存雪崩大量 key 同时过期,或 Redis 宕机DB 被整体压垮随机 TTL / 多级缓存 / 集群
三大问题示意:
穿透:请求 ──▶ 缓存(miss) ──▶ DB(miss) ──▶ null ← 恶意构造不存在的 key
↑ 每次都穿透,DB 空转
击穿:热点 key 到期 ──▶ 1000 并发请求同时 miss ──▶ 1000 次 DB 查询
雪崩:大量 key 同时到期 ──▶ 缓存集体失效 ──▶ 全量请求压向 DB

查询一个在缓存和数据库中都不存在的 key(如 id = -1),缓存永远 miss,每次请求都落到数据库。攻击者可以构造大量这样的请求来压垮系统。

查询 DB 返回 null 时,将 null 也写入缓存,设置较短过期时间。

@Service
@RequiredArgsConstructor
public class ProductService {
private final StringRedisTemplate redisTemplate;
private final ProductMapper productMapper;
private final ObjectMapper objectMapper;
private static final String CACHE_NULL = "NULL"; // 空值标记
private static final long CACHE_TTL = 30; // 正常数据缓存 30 分钟
private static final long CACHE_NULL_TTL = 2; // 空值缓存 2 分钟(短 TTL)
public Product getById(Long id) throws JsonProcessingException {
String key = "product:" + id;
String value = redisTemplate.opsForValue().get(key);
// 1. 缓存命中
if (value != null) {
if (CACHE_NULL.equals(value)) return null; // 命中空值缓存
return objectMapper.readValue(value, Product.class);
}
// 2. 缓存未命中,查 DB
Product product = productMapper.selectById(id);
if (product == null) {
// DB 也没有:缓存空值,防止穿透
redisTemplate.opsForValue().set(key, CACHE_NULL, CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 3. DB 有数据:写入缓存
redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(product),
CACHE_TTL, TimeUnit.MINUTES);
return product;
}
}

在缓存层前置一个布隆过滤器,快速判断 key 是否可能存在,不存在则直接返回,拦截非法请求。详见「布隆过滤器」章节。

请求 ──▶ 布隆过滤器
├── 不存在(一定不存在)──▶ 直接返回 null,拦截成功
└── 可能存在 ──▶ 查缓存 ──▶ 查 DB

某个访问量极高的热点 key过期,恰好此时大量并发请求同时进来,缓存全部 miss,所有请求同时查询数据库,造成 DB 瞬间压力激增。

缓存 miss 后,只允许一个线程查询 DB 并重建缓存,其他线程等待后重试。

public Product getByIdWithMutex(Long id) throws Exception {
String key = "product:" + id;
String lockKey = "lock:product:" + id;
// 1. 查缓存
String value = redisTemplate.opsForValue().get(key);
if (value != null) return objectMapper.readValue(value, Product.class);
// 2. 缓存 miss,尝试获取互斥锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); // SET NX EX 10
if (Boolean.TRUE.equals(locked)) {
try {
// 3. 获得锁,双重检查(可能其他线程已重建)
value = redisTemplate.opsForValue().get(key);
if (value != null) return objectMapper.readValue(value, Product.class);
// 4. 查 DB,重建缓存
Product product = productMapper.selectById(id);
String json = product == null ? "NULL"
: objectMapper.writeValueAsString(product);
redisTemplate.opsForValue().set(key, json, 30, TimeUnit.MINUTES);
return product;
} finally {
redisTemplate.delete(lockKey); // 释放锁
}
} else {
// 5. 未获得锁,等待后重试
Thread.sleep(50);
return getByIdWithMutex(id);
}
}

互斥锁方案流程:

线程A(获得锁):miss ──▶ 加锁 ──▶ 查DB ──▶ 写缓存 ──▶ 释放锁
线程B(等待): miss ──▶ 加锁失败 ──▶ sleep(50ms) ──▶ 重试 ──▶ 命中缓存
线程C(等待): miss ──▶ 加锁失败 ──▶ sleep(50ms) ──▶ 重试 ──▶ 命中缓存

3.3. 方案二:逻辑过期(高可用)

Section titled “3.3. 方案二:逻辑过期(高可用)”

缓存永不设置 TTL,而是在 value 中存储一个逻辑过期时间。查询时检查是否逻辑过期,过期则异步重建,当前请求返回旧数据。

@Data
@AllArgsConstructor
public class CacheWrapper<T> {
private T data;
private LocalDateTime expireTime; // 逻辑过期时间
}
private final ExecutorService rebuildExecutor = Executors.newFixedThreadPool(5);
public Product getByIdWithLogicalExpire(Long id) throws Exception {
String key = "product:" + id;
String value = redisTemplate.opsForValue().get(key);
// 缓存不存在(热点数据应提前预热,正常情况一定在缓存中)
if (value == null) return null;
CacheWrapper<Product> wrapper = objectMapper.readValue(value,
new TypeReference<CacheWrapper<Product>>() {});
// 未过期,直接返回
if (wrapper.getExpireTime().isAfter(LocalDateTime.now())) {
return wrapper.getData();
}
// 逻辑已过期,尝试获取锁并异步重建
String lockKey = "lock:product:" + id;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 异步重建缓存,当前请求立即返回旧数据
rebuildExecutor.submit(() -> {
try {
Product product = productMapper.selectById(id);
CacheWrapper<Product> newWrapper =
new CacheWrapper<>(product, LocalDateTime.now().plusMinutes(30));
redisTemplate.opsForValue().set(key,
objectMapper.writeValueAsString(newWrapper));
} finally {
redisTemplate.delete(lockKey);
}
});
}
// 返回旧数据(即使已逻辑过期)
return wrapper.getData();
}

逻辑过期方案流程:

线程A(发现过期):返回旧数据 ──▶ 异步重建(不阻塞)
线程B: 返回旧数据(重建完成前都返回旧数据)
线程C(重建完): 返回新数据
对比项互斥锁逻辑过期
一致性强一致 ✅短暂不一致
可用性等待期间响应慢始终有响应 ✅
实现复杂度简单较复杂
适用场景库存、价格、强一致场景商品详情、排行榜、高可用场景

情况一:大量 key 设置了相同或相近的过期时间,在某一时刻集体失效,大量请求同时打到 DB。

情况二:Redis 服务宕机,所有缓存同时失效。

4.2. 方案:随机 TTL + 多级缓存 + 集群

Section titled “4.2. 方案:随机 TTL + 多级缓存 + 集群”

方案一:随机 TTL(防止批量同时过期)

// ❌ 错误:所有商品缓存 TTL 相同,批量导入时会同时过期
redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
// ✅ 正确:TTL 加上随机偏移量,分散过期时间
int randomOffset = ThreadLocalRandom.current().nextInt(0, 10); // 0~10 分钟随机
redisTemplate.opsForValue().set(key, value, 30 + randomOffset, TimeUnit.MINUTES);

方案二:多级缓存(Redis 宕机兜底)

@Service
@RequiredArgsConstructor
public class ProductService {
// L1:本地缓存(Caffeine),极速,但容量小
private final Cache<Long, Product> localCache = Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.maximumSize(1000)
.build();
// L2:Redis 分布式缓存
private final StringRedisTemplate redisTemplate;
private final ProductMapper productMapper;
public Product getById(Long id) {
// 1. 查本地缓存
Product product = localCache.getIfPresent(id);
if (product != null) return product;
// 2. 查 Redis
String json = redisTemplate.opsForValue().get("product:" + id);
if (json != null) {
product = parse(json);
localCache.put(id, product); // 回填本地缓存
return product;
}
// 3. 查 DB
product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set("product:" + id, toJson(product),
30, TimeUnit.MINUTES);
localCache.put(id, product);
}
return product;
}
}

方案三:Redis 高可用部署(防止单点宕机导致雪崩)

开发环境:单节点
测试环境:主从复制(1主1从)
生产环境:哨兵模式 或 集群模式

缓存穿透(不存在的 key)
├── 空值缓存:简单,适合固定 key 范围
└── 布隆过滤器:适合海量 key,内存高效
缓存击穿(热点 key 过期)
├── 互斥锁:强一致,响应慢
└── 逻辑过期:高可用,短暂不一致
缓存雪崩(大量 key 同时过期 / Redis 宕机)
├── 随机 TTL:防集体过期
├── 多级缓存:Redis 宕机时 L1 兜底
└── Redis 集群:消除单点故障