技术技术2026-09-19 13:33:18(编辑于 2026-09-19 13:33:40)

缓存三大问题:穿透、击穿、雪崩

零、先建立整体观

  • 三个问题是三个不同故障场景,可同时发生,防御手段叠加在一套缓存代码里(组合拳)
  • 课程拆开讲是教学需要,真实系统是组合用
  • 平时它们都在默默工作(挡请求在 Redis 层),只是感觉不到
查缓存
  ├─ 命中 → 返回 ✅(正常路径,绝大多数请求)
  ├─ 命中"空值" → 返回"不存在"(防穿透①)
  └─ 没命中 →
       抢互斥锁 / 逻辑过期(防击穿②,二选一)
       查库 → 写缓存 → TTL 加随机值(防雪崩③)

一、缓存穿透(Cache Penetration)

定义

查询一个不存在的 key(比如恶意 id=999999),Redis 没有、数据库也没有,每次都打到数据库。

危害

恶意遍历不存在的 id → 无限打库 → 数据库被无用查询打垮(数据库才是最后防线,打穿就崩)。

解决方案

① 空值缓存(最简单,课程用这个)
查不到也往缓存存个 null(短 TTL),下次同 id 直接命中空值,不再打库。

// 写入空值(CACHE_NULL_TTL = 2L,短 TTL)
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);

// 读取端:空串命中 → isNotBlank 为 false → 走查库;但缓存了空值 → 不再打库
  • 优点:实现简单
  • 缺点:恶意随机 id 会占用大量缓存空间(每个 id 都存一份空值)

② 布隆过滤器(升级方案)
把所有存在的 id 预存进布隆过滤器,查询前先过滤——不存在的 id 直接拒绝,连 Redis 都不查。

  • 优点:不占缓存空间
  • 缺点:有一定误判率(存在可能误判为不存在,不存在不会漏),需要预热所有 id

二、缓存击穿(Cache Breakdown)

定义

一个热点 key 在过期的那一瞬间,大量请求同时发现缓存没了 → 一起打到数据库。

与穿透的区别

穿透 击穿
打的 key 不存在的 key 存在但刚过期的热点 key
数据 数据库也没有 数据库有,缓存刚好过期
核心 查不到 查到但缓存瞬间失效

危害

热点商品/新闻的 key 一过期,瞬间成千上万的并发冲垮数据库。

解决方案(两个并列方案,二选一)

① 互斥锁(Mutex Lock)——"过期了等新的"

// 抢锁:setIfAbsent 成功才算抢到(Redis 单线程保证原子)
boolean isLock = tryLock(LOCK_SHOP_KEY + id);

if (!isLock) {
    Thread.sleep(50);          // 没抢到 → 睡一会重试
    return queryWithMutex(id); // 递归重试
}
// 抢到 → 双重检查缓存 → 查库 → 写缓存 → finally 释放锁

流程:第一个请求拿锁去重建缓存,其他请求等锁(sleep 重试),缓存好了再读。

  • 优点:数据一致性强
  • 缺点:重建期间请求在等待,有阻塞;锁有 TTL,处理太久会失效

② 逻辑过期(Logical Expire)——"过期了先给旧的,后台悄悄换" ⭐ 最难

核心:缓存不设 TTL(永远在 Redis 里),而是用 RedisData 包装一个"逻辑过期时间",自己判断。

// RedisData:逻辑过期包装类
public class RedisData {
    private LocalDateTime expireTime;   // 逻辑过期时间
    private Object data;                // 真实数据
}
// 缓存写入(无 TTL!只带逻辑过期时间)
private void saveShop2Redis(Long id, Long expireTime) {
    Shop shop = getById(id);
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
// 查询
private Shop queryWithLogicalExpire(Long id) {
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    if (StrUtil.isBlank(shopJson)) {
        return null;               // 缓存不存在 → 返回 null(逻辑过期不自愈,靠预热)
    }
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();

    if (expireTime.isAfter(LocalDateTime.now())) {
        return shop;               // 未过期 → 直接返回 ✅
    }
    // 已过期 → 抢锁重建
    if (tryLock(LOCK_SHOP_KEY + id)) {
        // 抢到 → 异步线程重建缓存(不阻塞主线程)
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            this.saveShop2Redis(id, 20L);
        });
        // finally 释放锁
    }
    return shop;                    // 抢到没抢到都返回旧数据
}

为什么逻辑过期难懂:

时间轴:
T0   Redis 存 RedisData{expireTime=20秒后, data=店铺}(无 TTL,不会自动删)
T20  逻辑过期 → 请求来:
      抢到锁 → 异步重建(用户无感,等下次读到新的)
      没抢到 → 直接返回旧数据(旧数据先顶着)

关键:过期了也先给旧数据,后台悄悄换新的。 这就是和互斥锁的本质区别。

互斥锁 逻辑过期
思想 过期了等新的 过期了先给旧的
请求是否阻塞 是(sleep 重试) 否(直接返回旧数据)
数据一致性 强 弱(有短暂旧数据)
缓存从哪来 懒加载(自动建) 必须预热(不自动建)
实现复杂度 简单 难(绕)

选型:实时性要求高 → 互斥锁;读多写少、不想阻塞 → 逻辑过期。


三、缓存雪崩(Cache Avalanche)

定义

大量 key 同时过期(或 Redis 宕机),同一时刻大量请求一起打到数据库。

与击穿的区别

击穿 雪崩
范围 一个热点 key 大量 key 同时
原因 单个 key 过期瞬间 批量同时过期 / Redis 宕机

解决方案

① TTL 加随机值(核心,最简单)
把过期时间打散,避免同一批 key 同时到期。

// 假设基础 TTL 30 分钟,加 0~5 随机分钟
int ttl = 30 + RandomUtil.randomInt(0, 5);
stringRedisTemplate.opsForValue().set(key, json, ttl, TimeUnit.MINUTES);

② 多级缓存(本地 + Redis)
Redis 之上加一层本地缓存(Caffeine/Guava),Redis 挂了本地还能顶一阵。

③ Redis 高可用
主从 + 哨兵 / 集群,避免 Redis 单点宕机导致全崩。


四、三问题对比表(面试速记)

穿透 击穿 雪崩
场景 不存在 key 热点 key 过期瞬间 大量 key 同时过期
核心 查不到 查到但缓存瞬间没 批量同时失效
解决 空值缓存 / 布隆 互斥锁 / 逻辑过期 TTL 随机 / 多级缓存 / 高可用
防线 挡在缓存前 挡在缓存重建时 挡在过期策略

五、容易踩的坑

  1. 互斥锁和逻辑过期是二选一,别一起写进一个方法(会打架)
  2. 逻辑过期不设 TTL,缓存永远在 Redis,靠逻辑时间判断——必须配预热,缓存丢了不会自愈
  3. 互斥锁的锁 TTL 不能太短,否则重建没完成锁就失效
  4. 释放锁时要判断是不是自己的锁(防止锁过期后被别人拿到,自己误删)——课程简化版直接 delete,生产要加判断
  5. 空值缓存要设短 TTL(如 2 分钟),否则长期占空间

一句话速记

穿透 = 查不存在的 key → 空值/布隆挡住;击穿 = 热点 key 过期瞬间 → 互斥锁(等新的)或逻辑过期(先给旧的)二选一;雪崩 = 大批 key 同时过期 → TTL 加随机值打散。三者是叠加关系,生产是一套组合拳。