数据库

缓存穿透、击穿与雪崩:三种 Redis 故障的识别与防护

线上接口突然变慢、数据库被打挂,多半是缓存的三类故障在作祟。本文用真实日志现象教你区分穿透/击穿/雪崩,并给出互斥锁、空值缓存、热点永不过期等可落地的防护方案。

作者:巧匠团队·8 分钟阅读·更新于 2026-09-06

先用三个真实线上症状对号入座

很多团队把三类故障混为一谈,其实从观测表现就能快速区分。缓存穿透是请求的数据在库和缓存里都不存在,每次请求都穿过缓存直达数据库;缓存击穿是某个热点 key 突然过期,同时有大量并发请求重建它;缓存雪崩则是大量 key 在同一时刻大面积过期,导致数据库瞬时被压垮。

先从监控指标区分:穿透会让数据库的读 QPS 异常升高而命中率走低,但返回的多是空数据;击穿表现为单一热点 key 的延迟突然飙升,错误率集中在某几个接口;雪崩则是多个接口同时出现尖峰,呈瀑布状整体下探。你可以先看一眼缓存命中率变化曲线的形态再判断方向。

redis-cli -n 0 info keyspace
#              ^-- keyspace 看命中率与过期 key 总数
redis-cli -n 0 info stats | grep -E "expired_keys|keyspace_hits|keyspace_misses"

再加一层布隆过滤器,挡掉根本不存在的 key

穿透的根本原因是请求用了无效参数或扫描了不存在的 ID。在缓存和数据库之间加一层布隆过滤器,能先判断这个 key 是否可能存在,判断为不存在就直接返回空,从而挡住绝大多数伪造请求。

布隆过滤器有误判率但不会漏判:它可能说一个 key 存在(误判为存在),但绝不会说一个不存在的 key 不存在(即"不存在"的结论是可信的)。所以它负责挡掉明确不存在的请求,命中过滤器后再走缓存和库。实现上用 Redis 的 bloom 模块(BF.ADD / BF.EXISTS)最省事,或引入 google/guava 的 BloomFilter 在应用层维护一份。

redismodule-dev
redis-cli BF.ADD blacklist 1000001
redis-cli BF.EXISTS blacklist 1000001   # 1
redis-cli BF.EXISTS blacklist 5030304   # 0 不存在,直接挡
# 应用层配合:命中过滤器且查库为空 -> 给 key 写入一个短 TTL 的空值缓存

击穿用互斥锁重构,别让并发请求全去回源

热点 key 过期瞬间,若几十个线程同时发现缓存里没有值,就都会冲向数据库重建,这就是击穿。最通用的解法是互斥锁:缓存 miss 时只允许一个请求去回源,其它请求等它写入缓存后直接读缓存。

用 Redis 的 SET NX 做分布式锁要注意两点:一是必须带过期时间来防止持有者宕机死锁,二是加锁和回源之间要尽量短。以 Go 为例,只有当 SetNX 返回成功(抢到锁)的协程才真正查库并回填,其余协程自旋等待后读取缓存。另一个思路是"永不过期 + 后台刷新":热点 key 不设过期时间,另起任务定时刷新,彻底避免过期窗口。

func Get(key string) (string, error) {
  if v, err := rdb.Get(ctx, key).Result(); err == nil {
    return v, nil
  }
  // 抢锁:SET key 1 EX 5 NX,抢到才回源
  ok, _ := rdb.SetNX(ctx, "lock:"+key, 1, 5*time.Second).Result()
  if !ok {
    time.Sleep(50 * time.Millisecond) // 未抢到,稍候再读
    return rdb.Get(ctx, key).Result()
  }
  defer rdb.Del(ctx, "lock:"+key) // 回填完释放锁
  v := loadFromDB(key)
  rdb.Set(ctx, key, v, 60*time.Second)
  return v, nil
}

雪崩的根子是过期时间的"同构性",随机化即可化解

雪崩往往不是某一个 key 出错,而是缓存设置里给所有 key 统一设置了相同 TTL,导致某个时间点集中过期。化解的核心是打破过期的同构性:给 TTL 加一个随机偏移,让各 key 的过期时间错开。

比如 base TTL 是 30 分钟,就生成 30 分钟到 35 分钟之间的随机值。也可以对高可用要求高的 key 做"永久 + 异步双写",主缓存不过期,用旁路任务在低峰期悄悄刷新。另一方面,数据库侧要预留降级手段:熔断器、限流、只读副本分担查询,避免雪崩时数据库被一次性打穿。

const base = 60 * 30          // 基础 30 分钟
const jitter = 60 * int(rand.Float64()*300) // 0-5 分钟随机偏移
rdb.Set(ctx, key, v, time.Duration(base+jitter)*time.Second)

验证三层防护是否真的生效

配完不要凭感觉说"应该没问题"。先构造三类低成本的验证:穿透验证用一批不存在的 ID 压一下看数据库读 QPS 是否不再飙升;击穿验证设一个热点 key,手动 DEL 后并发发请求看命中是否仍稳定;雪崩验证把 TTL 统一临时设成同值再观察曲线是否错开。

用 Redis 的 monitor 命令观察实际回源次数是最高效的手段:正常情况下缓存命中时不会出现在 monitor 里,一旦出现大量数据库重建命令就说明防护没拦住。建议把 keyspace_hits / keyspace_misses 命中率、数据库读 QPS、核心接口 P99 延迟作为三道告警线,任何一道异常都优先复查缓存层。

redis-cli -n 0 monitor   # 观察实时命令,若瞬间出现大量 db 重建命令说明防护失效
redis-cli -n 0 info stats | grep -E "keyspace_hits|keyspace_misses" | awk -F: '{print $1": "$2}'

官方参考来源

下方为命令对应的官方权威文档,供你核对最新用法与深入查阅。