缓存是后端系统抵御数据库压力的第一道防线。但缓存本身并不是银弹,它在"缓存与数据源的边界"上存在三类经典失效场景:穿透、击穿、雪崩。它们的名字容易混淆,本质却是三种完全不同的失效模式。理解它们的区别,是设计高可用缓存层的前提。

场景:为什么缓存会"失守"

正常情况下,读请求的路径是:先查 Redis,命中则直接返回;未命中则回源数据库,再把结果写回 Redis。这条路径的隐含假设是——绝大多数请求都会命中缓存,数据库只承担少量"冷启动"流量。

三大问题的共同点,就是这个假设被破坏了:大量请求绕过缓存直接砸向数据库。区别在于"为什么绕过"。

缓存穿透:查询根本不存在的数据

穿透指的是请求的数据在缓存和数据库中都不存在。比如有人用 id=-1 或随机 UUID 疯狂查询。因为数据库里查不到,你永远无法把结果写进缓存,于是每一次请求都穿过缓存层直达数据库。这往往是恶意攻击或爬虫造成的。

两种主流防御:

方案一:缓存空值。 查不到时,也往 Redis 写一个特殊的空标记,并设置较短的过期时间:

1
2
# 数据库查不到,缓存一个空占位符,TTL 设短(如 60s)
SET user:-1 "__NULL__" EX 60

缺点是,如果攻击者用海量不同的 key,会撑大内存。所以空值 TTL 必须短。

方案二:布隆过滤器(Bloom Filter)。 在缓存前置一层布隆过滤器,把所有合法 key 预先哈希进位图。请求先问布隆过滤器:“这个 key 可能存在吗?”

布隆过滤器的核心是用 k 个哈希函数把元素映射到一个 m 位的位数组上。它有一个关键特性:会误判存在(假阳性),但绝不会漏判存在(无假阴性)。也就是说,过滤器说"不存在"就一定不存在,可以直接拒绝;说"存在"则可能误报,放行后再查缓存/库。

假阳性率近似为 (1 - e^(-kn/m))^k,其中 n 是已插入元素数。工程上通过调大 m 和选取最优 k = (m/n)ln2 来把误判率压到 1% 以下。代价是位图占用内存,且标准布隆过滤器不支持删除。

缓存击穿:热点 key 在过期瞬间失效

击穿是单个热点 key 过期的瞬间,大量并发请求同时未命中,一起涌向数据库去重建这个 key。注意:数据是存在的,问题出在"过期的临界时刻"。

比如一个秒杀商品页的缓存恰好在高峰期过期,这一毫秒内涌入上万请求,全部回源,数据库瞬间被打挂。

解决思路是让回源串行化——同一时刻只允许一个线程去重建缓存,其余线程等待结果。常用互斥锁(分布式锁):

1
2
# 只有抢到锁的线程才回源重建,SET NX 保证原子性
SET lock:product:1001 <uuid> NX EX 10

抢到锁的线程查库并回写缓存;没抢到的线程短暂自旋等待后重试读缓存。这里有几个工程权衡:

  • 锁必须带过期时间(EX),否则持锁线程崩溃会导致死锁。
  • 释放锁时要校验 value(用 Lua 脚本判断 uuid 再删),避免误删别人的锁。
  • 锁的粒度细到单个 key,不能用全局锁,否则会把击穿问题变成全局串行。

另一种思路是逻辑过期:value 里存一个逻辑过期时间字段,key 本身永不物理过期。读到逻辑过期时,异步开一个线程去重建,当前请求先返回旧值。这样牺牲一致性换取了零阻塞,适合允许短暂脏读的场景。

缓存雪崩:大面积 key 同时失效

雪崩是大量 key 在同一时间集中过期,或者 Redis 实例整体宕机,导致几乎所有请求同时回源,数据库被洪峰压垮。

最典型的诱因是:系统启动时批量预热缓存,所有 key 都设了相同的 TTL(比如统一 3600s),一小时后它们整齐划一地集体过期。

应对策略分两层:

针对集中过期——打散 TTL。 给每个 key 的过期时间加一个随机扰动:

1
实际 TTL = 基础 TTL + random(0, 300) 秒

让过期时间分散到一个时间窗内,削平回源的尖峰。

针对实例宕机——架构层兜底。 这是雪崩里更严重的一种。手段包括:Redis 集群/主从 + 哨兵保证高可用;给数据库访问加限流和熔断(如 Sentinel、Hystrix 思路),宁可拒绝部分请求也不让数据库崩溃;以及多级缓存(本地 Caffeine + Redis),即使 Redis 挂了本地缓存还能扛一阵。

三者对比与常见误区

问题 数据是否存在 触发点 核心解法
穿透 都不存在 非法 key 持续查询 空值缓存 / 布隆过滤器
击穿 存在 单个热点 key 过期 互斥锁 / 逻辑过期
雪崩 存在 大量 key 同时过期或宕机 TTL 打散 / 高可用 + 限流

线上踩坑提醒:

  1. 空值缓存和布隆过滤器要配合用。 只缓存空值挡不住海量随机 key 的内存膨胀;布隆过滤器又有假阳性。两者结合才稳。
  2. 互斥锁别用本地锁。 分布式环境下,本地锁只能锁住单个节点,其他节点照样回源。必须用 Redis/Zookeeper 的分布式锁。
  3. TTL 随机化别忘了。 很多雪崩事故的根因就是一行"统一 TTL"的代码,平时无感,预热重启后准时爆炸。
  4. 缓存与数据库的一致性问题独立于以上三者。 别把"缓存更新策略"(如 Cache Aside、延迟双删)和失效防护混为一谈。

小结

穿透、击穿、雪崩三者的本质,都是"缓存命中假设被破坏后,数据库直接承压"。理解它们的差异——非法 key、热点过期、批量过期——才能对症下药。真正健壮的缓存层,往往是布隆过滤器、互斥锁、随机 TTL、多级缓存与限流熔断的组合拳,而不是单一招式。