在 Redis 缓存设计里,「穿透、击穿、雪崩」是三个最容易踩的坑,名字相似但成因和后果完全不同。先把它们分辨清楚,再谈解决方案。

一、三个问题一次说清

问题成因后果
穿透查询的数据根本不存在,缓存永远不命中恶意请求直接打到数据库
击穿某个热点 key 恰好过期,大量请求同时回源单个 key 打崩数据库
雪崩大量 key 在同一时间过期数据库被整体压垮

二、缓存穿透:缓存空值 / 布隆过滤器

穿透的本质是「查了不存在的数据」。两种主流解法:

缓存空值:查询结果为空时,也往缓存里写一个短 TTL 的空值,后续相同请求直接命中:

Object val = cache.get(key);
if (val == null) {
    val = db.query(key);
    if (val == null) {
        cache.set(key, NULL_PLACEHOLDER, 60); // 60 秒空值
    } else {
        cache.set(key, val, 300);
    }
}

布隆过滤器:在请求入口用布隆过滤器判断「该 key 是否存在」,不存在的直接拦截。布隆过滤器存在误判(可能误报存在),但不会漏报,适合 key 集合稳定的场景。

三、缓存击穿:互斥锁 / 逻辑过期

击穿是单个热点 key 过期瞬间,并发请求同时回源。解法是「让回源只有一个请求」:

互斥锁:发现缓存过期时,先抢锁,只有一个请求能回源并回写缓存,其余请求等待后从缓存读取:

val = cache.get(key);
if (val == null) {
    if (lock.tryLock(key)) {
        try {
            val = cache.get(key);   // 双重检查
            if (val == null) {
                val = db.query(key);
                cache.set(key, val, 300);
            }
        } finally {
            lock.unlock(key);
        }
    } else {
        Thread.sleep(50);
        val = cache.get(key);
    }
}

逻辑过期:缓存永不过期,但值里带一个逻辑过期时间;读取时发现逻辑过期就异步重建,先返回旧值。适合对一致性要求不高的场景。

四、缓存雪崩:过期时间加随机值

雪崩是大量 key 同时过期。最简单的解法是让过期时间「错开」:

cache.set(key, val, 300 + ThreadLocalRandom.current().nextInt(300));

给每个 key 的基础 TTL 加上一个随机偏移,避免集体过期的波峰。更进一步,热点数据用「热点记录 + 手动续期」的方式,不让它自然过期。

五、请求回源兜底

无论哪种方案,都建议在数据库层加一层保护:

  • 限流:对回源数据库的查询做限流,超限直接降级。
  • 熔断:数据库出现异常时快速失败,而不是继续堆积请求。
  • 降级:缓存不可用时返回默认数据或空列表,保证服务可用。

小结

穿透要「拦」(空值/布隆),击穿要「锁」(互斥/逻辑过期),雪崩要「散」(随机 TTL)。三层防护配合限流熔断,缓存层才能稳定扛住高并发。