Redis缓存穿透、缓存击穿和缓存雪崩有什么区别?解决方案汇总

只要网站用上了 Redis 做缓存,就迟早会遇到这三个听起来很像、实际成因和应对完全不同的故障:缓存穿透、缓存击穿、缓存雪崩。它们都会表现为"数据库压力骤增",很多人一看到 DB 慢就笼统地归结为"缓存问题",然后盲目加缓存、调超时,结果没打中要害。三个词一字之差,解决思路却分别是"挡不存在的 key""防单个热点 key 失效""避免大批 key 同时失效",搞混了就会事倍功半。

这篇文章要解决的就是把这三个概念彻底掰扯清楚:每个问题分别是什么、在什么条件下会发生、各自有哪些成熟的解决方案,以及它们之间怎么区分。读者读完应该能在监控看到 DB QPS 飙升时,快速判断到底是哪一种,并知道对应的处置手段。

文章先建立一个"请求到缓存再到数据库"的模型,然后逐个讲解三个问题,用一张对比表横向对照,再给出代码和配置层面的解决方案,最后讲怎么用监控提前发现。文中数值都是经验量级,用来建立概念,不代表具体系统的承诺。

一、先建立一个访问模型

几乎所有缓存问题都能用一个简单的分层访问模型来解释。

请求 → 应用 → 查缓存(Redis)
                ├─ 命中 → 返回
                └─ 未命中 → 查数据库 → 回填缓存 → 返回

正常的缓存逻辑是:先查缓存,命中就返回;未命中才去查库,查到后写回缓存,下次就命中了。绝大部分请求命中缓存,数据库只承担少部分流量,这就是缓存的价值。

问题就出在"未命中"这条路径上:如果大量请求同时走上"未命中 → 查库"这条线,缓存就形同虚设,数据库被直接顶上来。三个问题,本质都是"未命中"被异常放大,只是放大的原因不同。

二、缓存穿透:查的是根本不存在的数据

穿透指请求的数据在缓存和数据库里都不存在。既然不存在,缓存里永远不会有它的值,每次请求都会"未命中",然后去查数据库,数据库也查不到,无法回填缓存。于是每一个这样的请求都直接打到数据库上。

典型场景:恶意攻击者用随机 ID(如 /user/999999999)狂刷接口,这些 ID 根本不存在,缓存拦不住,数据库被大量无效查询压垮。也可能是业务 bug,用了一个非法参数反复查询。

穿透的特征是:缓存命中率正常但数据库被大量"查不到结果"的请求淹没,而且这些 key 在缓存里压根不存在。

解决穿透的核心思路是"让不存在的请求也被缓存拦下来":

方案一:缓存空值(空对象)。查库为空时,也往缓存里写一个特殊值(如空字符串或 null 标记),并设一个较短的过期时间(如 30~60 秒)。这样后续同样的请求直接命中缓存里的空值,不再查库。

// 伪代码示意
String cacheKey = "user:" + id;
String val = redis.get(cacheKey);
if (val != null) {
    // 可能是真实值,也可能是空标记
    return EMPTY.equals(val) ? null : parse(val);
}
User user = db.queryById(id);
if (user == null) {
    redis.setex(cacheKey, 60, EMPTY);   // 缓存空值 60 秒
    return null;
}
redis.setex(cacheKey, 3600, toJson(user));
return user;

方案二:布隆过滤器(Bloom Filter)。把所有存在的 key(或 ID)预先加载进布隆过滤器,请求进来先问过滤器"这个 key 可能存在吗",不存在直接返回。布隆过滤器有极小的误判率(只会把不存在的判为存在,不会把存在的判为不存在),空间效率极高,适合海量 key 的过滤。

# Redis 的布隆过滤器需加载 RedisBloom 模块
# 添加元素
BF.ADD user_ids 1001
# 判断是否存在(返回 1 可能存在,0 一定不存在)
BF.EXISTS user_ids 999999999

布隆过滤器的代价是:新增 key 要同步维护、不支持删除(要用 Counting Bloom Filter 或定期重建)。适合作业如"用户 ID 白名单""商品 ID 过滤"这类集合相对稳定的场景。

三、缓存击穿:单个热点 key 失效瞬间

击穿指某一个热点 key在缓存中过期的瞬间,恰好有大量并发请求同时来访问它。这些请求同时发现缓存没了,于是同时去查数据库并回填,把数据库瞬间打爆。

穿透和击穿的区别很关键:穿透查的是"一直不存在"的数据,击穿是"本来存在、刚好过期了"的某个热点数据。比如首页的某个爆款商品缓存设了 5 分钟过期,一到过期点,如果这时正好有几千并发在访问它,就会同时挤向数据库。

解决击穿的核心是"让同一时刻只有一个请求去查库回填,其他请求等待":

方案一:互斥锁(分布式锁)。缓存失效时,只允许一个线程去查库回填,其他线程稍等再读缓存。

String val = redis.get(key);
if (val != null) return parse(val);

String lockKey = "lock:" + key;
// 尝试获取分布式锁,超时短一点
if (redis.set(lockKey, "1", "NX", "EX", 10)) {
    try {
        // 双重检查:可能别人已经回填了
        val = redis.get(key);
        if (val == null) {
            val = db.query(key);
            redis.setex(key, 300, val);   // 回填
        }
    } finally {
        redis.del(lockKey);
    }
} else {
    // 没抢到锁,短暂休眠后重试读缓存
    Thread.sleep(50);
    return redis.get(key);
}

方案二:热点数据永不过期(逻辑过期)。对真正的高频热点 key,不设物理过期时间,而是在值里存一个逻辑过期时间,由一个后台线程异步刷新。这样永远不会出现"过期瞬间全体击穿"。

方案三:多级缓存。本地缓存(如 Caffeine)+ Redis,即使 Redis 里某个 key 失效,本地缓存还能挡一层。

四、缓存雪崩:大批 key 同时失效

雪崩指大量 key 在同一时间集中过期,或者 Redis 服务整体宕机,导致海量请求同时涌向数据库,数据库扛不住而崩,进而引发整个系统连锁崩溃。

雪崩和击穿的区别是规模:击穿是"一个热点 key",雪崩是"一大批 key 同时失效"或"整个缓存层不可用"。典型诱因:系统启动时把大批数据用同一个过期时间(如都设 1 小时)写入缓存,一小时后整齐地一起过期;或者 Redis 实例挂了、网络抖动导致大面积不可用。

解决雪崩的核心是"打散过期时间 + 提高缓存层可用性":

方案一:过期时间加随机值。写入缓存时,在基础过期时间上加一个随机抖动,避免同一时刻集中失效。

// 基础 30 分钟,额外加 0~5 分钟随机
int baseTtl = 1800;
int jitter = ThreadLocalRandom.current().nextInt(0, 300);
redis.setex(key, baseTtl + jitter, value);

方案二:缓存高可用。Redis 用主从 + 哨兵,或 Redis Cluster 集群,避免单点。某台挂了,其他节点还能顶上。

方案三:限流与降级。在数据库前加限流(如 Sentinel、网关限流),即使缓存大面积失效,也只放行数据库能承受的流量;同时准备降级策略(返回兜底数据、默认值、静态页)。

方案四:多级缓存。本地缓存作为第二道防线,减少 Redis 完全失效时的冲击。

方案五:预热。系统上线或 Redis 重启后,先跑脚本把热点数据批量加载进缓存,避免冷启动时全部击穿到数据库。

五、三者对比与区分

维度 缓存穿透 缓存击穿 缓存雪崩
数据是否存在 数据库里也不存在 数据库里存在 数据库里存在
触发原因 查询不存在的 key(多为恶意或非法参数) 单个热点 key 过期瞬间高并发 大批 key 同时过期 / Redis 整体不可用
影响范围 每个无效请求都打库 单个热点 key 的并发请求打库 全站或大面积请求打库
缓存命中率表现 命中率正常,大量空查询 局部短暂未命中 命中率骤降
核心应对 缓存空值、布隆过滤器 互斥锁、逻辑过期、热点永不过期 过期加随机、高可用、限流降级、预热
典型场景 恶意刷不存在的 ID 爆款商品缓存过期 大批缓存同刻过期、Redis 宕机

记忆口诀:穿透是"查不到",击穿是"一个热点没了",雪崩是"一大批全没了"。 应对上分别是"挡住假请求""锁住单点回填""分散失效并兜底"。

六、方案落地的注意点

缓存空值要控制量。如果攻击者用海量随机 key 刷,缓存空值本身也会把 Redis 撑爆(全是空标记的 key)。所以空值过期时间要短,并配合布隆过滤器或参数校验在前置拦截。

分布式锁要防死锁。加锁一定要设过期时间,避免持锁进程崩溃导致锁永不释放;解锁要校验是否是自己加的锁(用唯一 value 判断),避免误删别人的锁。生产环境更推荐 Redisson 这类成熟库。

布隆过滤器要同步维护。新增数据忘记加进过滤器,会把正常请求误挡;删除数据不支持,要考虑定期重建或换 Counting 结构。

过期时间随机抖动是低成本高收益的做法。多写一行随机数代码,就能大幅缓解雪崩,几乎所有用到批量缓存的系统都该加。

限流降级是最后防线。缓存无论怎么设计都可能失效,数据库前必须有兜底。限流阈值按数据库实测能承受的 QPS 来定。

七、监控与提前发现

# Redis 命中率(info stats 里的 keyspace_hits / keyspace_misses)
redis-cli info stats | grep -E 'keyspace_hits|keyspace_misses'
# 命中率 = hits / (hits + misses),经验值的话正常业务应在 90% 以上

关注指标:

  • 缓存命中率:持续走低可能预示穿透或大量 key 失效。
  • 数据库 QPS:突然飙升且查询多为"查不到结果",疑似穿透。
  • Redis 连接数、内存、过期 key 速率:异常波动时排查。
  • Redis 主从/集群健康:避免单点,及时告警。

用 redis-cli --stat 或 --hotkeys(需开启 LFU)也能观察热点分布。

redis-cli --stat            # 实时观察 ops、命中、内存
redis-cli --hotkeys         # 需 maxmemory-policy 为 LFU 系列

常见问题

Q:缓存穿透和击穿最容易混,怎么快速区分?
看数据库里数据是否存在。查的是不存在的 key(如随机 ID),是穿透;查的是存在的数据但刚好缓存过期,是击穿。

Q:缓存空值和布隆过滤器能一起用吗?
可以,而且常配合使用。布隆过滤器前置拦截绝大部分不存在的 key,空值缓存兜住漏网的和误判的,两者互补。

Q:热点 key 永不过期,那数据更新了怎么办?
采用"逻辑过期 + 异步刷新":值里带逻辑过期时间,读到时若逻辑过期,由一个后台线程或拿到锁的线程异步更新,其他请求先返回旧值。牺牲一定实时性换稳定性。

Q:Redis 集群能完全避免雪崩吗?
不能完全避免,但能显著降低"整层不可用"的概率。集群解决的是单点故障,解决不了"大批 key 同刻过期",后者仍要靠随机过期和预热。

Q:限流会不会误伤正常用户?
会。限流是"两害相权取其轻"的兜底手段,阈值要按数据库真实容量设定,并区分核心接口与非核心接口,优先保核心业务。

总结

三个问题都表现为"数据库被大量未命中请求压垮",但成因不同:穿透查的是不存在的数据,击穿是单个热点 key 过期,雪崩是大批 key 同刻失效或缓存层整体不可用。

对穿透,用缓存空值(短过期)加布隆过滤器拦截不存在的 key;对击穿,用分布式互斥锁、逻辑过期或多级缓存,保证同一时刻只有一个请求回填;对雪崩,用过期时间加随机抖动、Redis 高可用、限流降级和缓存预热多管齐下。

任何缓存方案都要有兜底:缓存无论怎么设计都可能失效,数据库前的限流和降级是不可省略的最后一道防线。

落地时注意细节:空值缓存要控量、分布式锁要设过期并防误删、布隆过滤器要同步维护、随机抖动的成本极低收益极高。

用缓存命中率、数据库 QPS、Redis 健康度等监控提前发现异常,比等数据库崩了再救火要主动得多。