缓存击穿、雪崩、穿透:三兄弟到底差在哪,以及怎么一次性防住

缓存击穿、雪崩、穿透:三兄弟到底差在哪,以及怎么一次性防住

「缓存」这两个字在个人站长的优化清单里几乎是标配:Nginx 页面缓存、Redis 对象缓存、浏览器缓存。加了缓存,TTFB 从 800ms 掉到 50ms,感觉很美好。但只要线上跑一段时间,你大概率会碰到这样的场景 —— 某个瞬间 QPS 暴涨,数据库连接被打满,网站整体雪崩,而你的缓存 CPU 占用却很低。

这不是缓存不够用,而是踩到了缓存的三种经典失效模式:缓存穿透、缓存击穿、缓存雪崩。它们读起来很像,成因却完全不同,用错了招数根本修不好。这篇文章把三者掰开揉碎讲清楚,再给一套 Nginx + Redis 双层的防护配置。

一、先把三个概念分清楚

缓存穿透(Cache Penetration)

查一个数据库里根本不存在的数据。比如有人拿脚本狂刷 /post/99999999,这个 ID 在库里没有对应记录。正常的缓存逻辑是「先查缓存,没有就查库,查到再回写缓存」—— 但这条数据本来就查不到,于是永远缓存不进去。每次请求都直接打到数据库。攻击者用一个不存在的 ID 就能把库打挂,这叫穿透。

关键词:查不存在的数据,缓存永远不命中。

缓存击穿(Cache Breakdown / 热点 Key 失效)

某个访问量极高的热点 key 突然过期。比如首页缓存设了 60 秒 TTL,到点的那一刻,大量并发的首页请求同时发现缓存没了,于是全部涌向数据库去重建缓存。假设这一秒有 5000 个请求,就有 5000 个查询同时砸向数据库 —— 这就是击穿。

关键词:热点 key 过期瞬间,并发请求同时回源。

缓存雪崩(Cache Avalanche)

大量 key 在同一时刻集体过期。最典型的坑是:上线时给所有缓存设了统一的 3600 秒 TTL,那么一小时后它们会同时失效,数据库瞬间承受全站的压力。另一种雪崩是缓存服务本身宕机了,所有请求直接穿透到数据库。

关键词:大批 key 同时失效,或缓存服务整体不可用。

一句话总结区别:穿透是「查不存在的」,击穿是「单个热点过期」,雪崩是「一大批同时过期」。

二、防缓存击穿:加锁 + 逻辑过期

击穿的核心矛盾是「同一个 key 被并发重建」。最直接的解法是互斥锁:只允许一个请求去查库重建,其他请求短暂等待。

伪代码逻辑:

def get(key):
    val = redis.get(key)
    if val is not None:
        return val

    # 缓存没命中,尝试抢锁
    if redis.set(key + ":lock", "1", nx=True, ex=10):
        try:
            val = db.query(key)
            redis.set(key, val, ex=60)   # 回写缓存
            return val
        finally:
            redis.delete(key + ":lock")
    else:
        # 没抢到锁,短暂等待后重试
        time.sleep(0.05)
        return get(key)

nx=True 保证只有一个请求能设上锁,ex=10 是防止持锁进程崩了导致死锁。其他没抢到锁的请求等 50ms 后重试,此时缓存多半已经重建好了。

另一种更优雅的方案叫逻辑过期:缓存永远不设物理 TTL,而是在 value 里存一个过期时间戳。读到数据时如果发现逻辑上过期了,就异步起一个线程去重建,当前请求继续返回旧数据。这样连等待都没有,绝不阻塞。适合对一致性要求不高的热点数据(比如首页排行榜)。

三、防缓存穿透:空值缓存 + 布隆过滤器

穿透的根源是「查不存在的数据」,那就想办法让不存在这件事本身也被缓存。

方案一:空值缓存

查库没查到,也往缓存里写一个特殊标记(比如空字符串或 __NULL__),TTL 设短一点(如 60 秒):

val = db.query(key)
if val is None:
    redis.set(key, "__NULL__", ex=60)   # 缓存空结果
else:
    redis.set(key, val, ex=300)

这样攻击者刷同一个不存在的 ID,第二次就直接命中「空值」缓存,不会打到库。缺点是如果是大量随机的不存在 ID,缓存会被塞满大量空值,浪费内存且防不住随机攻击。

方案二:布隆过滤器

布隆过滤器(Bloom Filter)是一个极省空间的概率型数据结构,能快速判断「一个 key 一定不存在」或「可能存在」。把所有合法 ID 预先塞进布隆过滤器,查询前先过一遍:如果过滤器说「一定不存在」,直接返回空,连缓存和库都不用查。

Redis 从 4.0 起通过 RedisBloom 模块支持,命令很简单:

BF.RESERVE valid_ids 0.001 1000000
BF.ADD valid_ids 1001
BF.ADD valid_ids 1002
BF.EXISTS valid_ids 99999999   # 返回 0 表示一定不存在

0.001 是允许的误判率,1000000 是预计元素数量。误判率意味着过滤器偶尔会把不存在的说成「可能存在」,但绝不会把存在的说成「不存在」—— 所以不会漏掉合法请求。这一层放在最前面,能挡住绝大部分穿透攻击。

四、防缓存雪崩:TTL 加随机 + 高可用

雪崩的本质是「同时失效」,最简单的破法就是让过期时间打散。不要写固定 TTL,而是加一个随机抖动:

import random
ttl = 3600 + random.randint(0, 600)   # 3600~4200 秒之间随机
redis.set(key, val, ex=ttl)

把原本同时过期的一批 key 分散到 10 分钟的时间窗里,压力被摊平,不会再有「整点暴击」。

第二种雪崩是缓存服务宕机。对策是缓存高可用:单机 Redis 换 Redis Sentinel 哨兵(主从自动故障转移),或者在应用侧做降级 —— 检测到 Redis 连不上时,直接走数据库但限制并发(比如用信号量限流),避免数据库被瞬间压垮。

五、Nginx 层的页面缓存也要防

上面的讨论偏应用层(Redis)。但个人站更常用的是 Nginx 页面缓存(proxy_cache / fastcgi_cache),它同样会遇到击穿问题 —— 热点页面缓存过期瞬间,所有请求同时回源 PHP。

Nginx 早就给了原生解法:proxy_cache_lock。开启后,当多个请求同时发现缓存 MISS,只有第一个请求去回源,其余请求排队等待,等第一个把缓存写好后再一起命中:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=mycache:10m
                 max_size=1g inactive=60m use_temp_path=off;

server {
    location / {
        proxy_cache mycache;
        proxy_cache_valid 200 60s;

        proxy_cache_lock on;              # 只让一个请求回源
        proxy_cache_lock_timeout 5s;      # 等待上限
        proxy_cache_lock_age 5s;          # 超过这个时间还没写完,放行下一个

        proxy_cache_use_stale updating error timeout http_500 http_502;
        proxy_cache_background_update on;

        proxy_pass http://127.0.0.1:8080;
    }
}

两个参数配合起来效果最好:proxy_cache_use_stale updating 表示「缓存正在后台更新时,用旧内容先响应」,proxy_cache_background_update 表示「返回旧内容的同时后台异步更新缓存」。这样一来,缓存过期时用户永远不会被阻塞,也不会引发回源风暴 —— 这本质上就是 Nginx 版的「逻辑过期」。

六、完整防护矩阵

问题成因解法
穿透查不存在的数据空值缓存(短 TTL)+ 布隆过滤器前置
击穿单个热点 key 过期互斥锁 / 逻辑过期;Nginx 用 proxy_cache_lock
雪崩大批 key 同时过期TTL 加随机抖动 + 缓存高可用 + 应用降级

七、上线前自查清单

  1. 我的缓存 TTL 是固定的吗?固定就加随机抖动。
  2. 查询不存在的 ID 时,应用会不会每次都打到数据库?会就加空值缓存或布隆过滤器。
  3. 热点数据过期时,会不会有大量并发同时回源?会就加锁或逻辑过期。
  4. Nginx 页面缓存有没有开 proxy_cache_lock 和 use_stale updating?没开就补上。
  5. Redis 是单点吗?是就上 Sentinel,或者至少配好降级策略。

把这五条过一遍,你就能避开 90% 的「加了缓存反而更容易挂」的坑。缓存不是加得越多越好,关键是要理解数据在什么情况下会「集体消失」,然后针对性地给每一层兜底。

Last modification:October 4th, 2026 at 08:26 pm

Leave a Comment