缓存三大问题
1. 三大问题总览
Section titled “1. 三大问题总览”| 问题 | 定义 | 危害 | 核心解决方案 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的数据,缓存和 DB 都没有 | 大量请求直击 DB | 空值缓存 / 布隆过滤器 |
| 缓存击穿 | 热点 key 过期瞬间,大量请求同时打到 DB | DB 瞬间压力暴增 | 互斥锁 / 逻辑过期 |
| 缓存雪崩 | 大量 key 同时过期,或 Redis 宕机 | DB 被整体压垮 | 随机 TTL / 多级缓存 / 集群 |
三大问题示意:
穿透:请求 ──▶ 缓存(miss) ──▶ DB(miss) ──▶ null ← 恶意构造不存在的 key ↑ 每次都穿透,DB 空转
击穿:热点 key 到期 ──▶ 1000 并发请求同时 miss ──▶ 1000 次 DB 查询
雪崩:大量 key 同时到期 ──▶ 缓存集体失效 ──▶ 全量请求压向 DB2. 缓存穿透
Section titled “2. 缓存穿透”2.1. 原因
Section titled “2.1. 原因”查询一个在缓存和数据库中都不存在的 key(如 id = -1),缓存永远 miss,每次请求都落到数据库。攻击者可以构造大量这样的请求来压垮系统。
2.2. 方案一:缓存空值
Section titled “2.2. 方案一:缓存空值”查询 DB 返回 null 时,将 null 也写入缓存,设置较短过期时间。
@Service@RequiredArgsConstructorpublic 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; }}2.3. 方案二:布隆过滤器
Section titled “2.3. 方案二:布隆过滤器”在缓存层前置一个布隆过滤器,快速判断 key 是否可能存在,不存在则直接返回,拦截非法请求。详见「布隆过滤器」章节。
请求 ──▶ 布隆过滤器 │ ├── 不存在(一定不存在)──▶ 直接返回 null,拦截成功 │ └── 可能存在 ──▶ 查缓存 ──▶ 查 DB3. 缓存击穿
Section titled “3. 缓存击穿”3.1. 原因
Section titled “3.1. 原因”某个访问量极高的热点 key过期,恰好此时大量并发请求同时进来,缓存全部 miss,所有请求同时查询数据库,造成 DB 瞬间压力激增。
3.2. 方案一:互斥锁(强一致)
Section titled “3.2. 方案一:互斥锁(强一致)”缓存 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@AllArgsConstructorpublic 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(重建完): 返回新数据3.4. 两种方案对比
Section titled “3.4. 两种方案对比”| 对比项 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 一致性 | 强一致 ✅ | 短暂不一致 |
| 可用性 | 等待期间响应慢 | 始终有响应 ✅ |
| 实现复杂度 | 简单 | 较复杂 |
| 适用场景 | 库存、价格、强一致场景 | 商品详情、排行榜、高可用场景 |
4. 缓存雪崩
Section titled “4. 缓存雪崩”4.1. 原因
Section titled “4.1. 原因”情况一:大量 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@RequiredArgsConstructorpublic 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从)生产环境:哨兵模式 或 集群模式5. 缓存问题解决方案总结
Section titled “5. 缓存问题解决方案总结”缓存穿透(不存在的 key)├── 空值缓存:简单,适合固定 key 范围└── 布隆过滤器:适合海量 key,内存高效
缓存击穿(热点 key 过期)├── 互斥锁:强一致,响应慢└── 逻辑过期:高可用,短暂不一致
缓存雪崩(大量 key 同时过期 / Redis 宕机)├── 随机 TTL:防集体过期├── 多级缓存:Redis 宕机时 L1 兜底└── Redis 集群:消除单点故障