Redis缓存三灾:穿透/击穿/雪崩全解析+Spring Boot实战避坑指南

Redis 缓存三灾

[!abstract] 本文要点
缓存雪崩、穿透、击穿三大灾难的原理、防护思路与 Spring Boot 代码示例,以及对应的避坑要点。

1. 缓存雪崩

一句话理解:大量缓存同时过期,瞬间流量全部涌向数据库,DB 直接懵圈。

1.1. 防护思路

  • TTL 随机化:给过期时间撒点盐,错开高峰。
  • 定时预热:把热点数据提前塞进缓存,DB 永远不被冷启动。

1.2. 代码示例

// 1. 设置随机TTL,错开过期时间
redisTemplate.opsForValue()
    .set("key", value,
         30 + ThreadLocalRandom.current().nextInt(31), // 随机30~60分钟
         TimeUnit.MINUTES);

// 2. 定时任务提前预热热点数据
@Scheduled(fixedDelay = 30, timeUnit = TimeUnit.MINUTES)
public void preloadHotData() {
    List<HotData> list = dbService.getHotData();
    list.forEach(data -> {
        redisTemplate.opsForValue()
            .set("hot:" + data.getId(),
                 data,
                 1 + ThreadLocalRandom.current().nextInt(2), // 随机1~2小时
                 TimeUnit.HOURS);
    });
}

1.2.1. 要点说明

  1. ThreadLocalRandom 线程安全,性能比 new Random() 高一倍。
  2. 预热任务加随机 TTL,防止下一次又一起过期。
  3. 用 @Scheduled 自动跑,线上零人工干预。

2. 缓存穿透

一句话理解:缓存没有,数据库也没有,每次请求都穿透到底层。

2.1. 防护思路

  • 缓存空对象:把"查不到"的结果也缓存起来,5 分钟后再说。
  • 布隆过滤器:提前把所有合法 ID 放到过滤器,非法请求直接挡在门外。

2.2. 代码示例

// 1. 缓存空对象
Object data = redisTemplate.opsForValue().get(key);
if (data == null) {
    data = dbService.queryById(id);
    if (data == null) {
        // 存个"NULL"占位,防止穿透
        redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
    } else {
        redisTemplate.opsForValue().set(key, data, 30, TimeUnit.MINUTES);
    }
}
if ("NULL".equals(data)) return null;

// 2. 布隆过滤器
BloomFilter<Integer> bloomFilter = BloomFilter.create(
        Funnels.integerFunnel(), 50_000, 0.01);

@PostConstruct
public void initBloom() {
    List<Integer> ids = dbService.getAllIds();
    ids.forEach(bloomFilter::put);
}

public Object query(Integer id) {
    if (!bloomFilter.mightContain(id)) {
        return null; // 非法ID直接返回
    }
    return redisTemplate.opsForValue().get("user:" + id);
}

2.2.1. 要点说明

  1. 空对象用固定字符串"NULL",避免与真实数据混淆。
  2. 布隆过滤器误报率设为 1%,50K 数据只占用约 58KB 内存。
  3. @PostConstruct 容器启动时加载,服务一上线就生效。

3. 缓存击穿

一句话理解:某个超热门 Key 刚好过期,千军万马同时去 DB 查同一条数据。

3.1. 防护思路

  • 本地锁:单机用 synchronized,简单粗暴。
  • 逻辑过期 + 异步刷新:让老数据"续命",后台异步更新,用户无感知。

3.2. 代码示例

// 1. 本地锁版本
Object data = redisTemplate.opsForValue().get(key);
if (data == null) {
    synchronized (key.intern()) { // 同一个key只让一个线程查库
        data = redisTemplate.opsForValue().get(key);
        if (data == null) { // 双重检查
            data = dbService.query(id);
            redisTemplate.opsForValue().set(key, data, 30, TimeUnit.MINUTES);
        }
    }
}

// 2. 逻辑过期+异步刷新
@Data
@AllArgsConstructor
public static class CacheData<T> {
    private T data;
    private long expireAt; // 毫秒时间戳
}

public void setLogicExpire(String key, Object value, long ttl, TimeUnit unit) {
    long expireAt = System.currentTimeMillis() + unit.toMillis(ttl);
    redisTemplate.opsForValue().set(key, new CacheData<>(value, expireAt));
}

public Object getLogicExpire(String key, Supplier<Object> dbFallback) {
    CacheData cache = (CacheData) redisTemplate.opsForValue().get(key);
    if (cache == null) return null;
    
    if (System.currentTimeMillis() > cache.getExpireAt()) {
        // 异步刷新,老数据先返回
        RLock lock = redissonClient.getLock("refresh:" + key);
        if (lock.tryLock()) {
            executor.submit(() -> {
                try {
                    Object fresh = dbFallback.get();
                    setLogicExpire(key, fresh, 30, TimeUnit.MINUTES);
                } finally {
                    lock.unlock();
                }
            });
        }
    }
    return cache.getData();
}

3.2.1. 要点说明

  1. key.intern() 保证相同字符串共用一把锁,锁粒度最细。
  2. 逻辑过期把过期时间存在 value 里,Redis 里永不过期,击穿风险为 0。
  3. Redisson 分布式锁 + 线程池异步刷新,高并发也稳如老狗。

4. 总结

场景 特征 防御策略
雪崩 大批 Key 同时失效 TTL 随机化 + 定时预热
穿透 缓存和 DB 都没有 布隆过滤器 + 空对象缓存
击穿 热点 Key 突然失效 本地锁/Redisson + 逻辑过期异步刷新

推荐阅读

推荐文章:java 面试 100 题讲解源文件 markdown 格式