Featured image of post Redis 缓存穿透、击穿与雪崩:原理、场景与工程解法
数据库

Redis 缓存穿透、击穿与雪崩:原理、场景与工程解法

Redis 缓存穿透、击穿与雪崩:原理、场景与工程解法

在高并发系统里,Redis 常常被放在数据库前面做读加速。一旦缓存设计不当,问题往往不是“Redis 慢一点”,而是数据库被瞬间打穿。工程上最常见、也最容易被混为一谈的三类故障是:缓存穿透缓存击穿缓存雪崩

这三类问题听起来相似,根因却不同:

问题典型特征直接伤害
缓存穿透查的是不存在的数据,缓存和 DB 都没有恶意或异常请求反复打到 DB
缓存击穿某个热点 key 刚好过期同一时刻大量请求打到 DB
缓存雪崩大量 key 同时失效,或缓存层整体不可用系统级读流量直接压垮 DB

本文从现象、根因、可落地方案三层拆开,并结合 Redis 官方能力(SETNX/EXEXPIRE、淘汰策略、分布式锁模式)给出可复用实践。

一、先把请求路径画清楚

一个典型读路径是:

Client -> App -> Redis -> (miss) -> Database -> write back Redis -> response

缓存层的价值在于:

  1. 把高频读从数据库剥离出来;
  2. 用更低延迟响应热点数据;
  3. 给数据库争取容量和稳定性。

因此,任何“绕过缓存直接打 DB”的路径,都要被当成故障设计点来处理。

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

1. 现象

请求的 key 在业务上不存在,例如:

  • 用户 ID -1
  • 商品 ID 999999999
  • 被爬虫扫出来的非法参数

此时 Redis 没有缓存,数据库也查不到记录。如果应用只在“查到结果时才写缓存”,那么每次请求都会落到数据库。

2. 根因

穿透的本质不是“缓存过期”,而是:

空结果没有被负缓存(negative caching)保护。

3. 解法

方案 A:缓存空对象(最常用)

查库为空时,也写入一个短 TTL 的空值标记:

# 伪命令:空结果缓存 60 秒
SET product:999999999 __NULL__ EX 60

应用读到 __NULL__ 时直接返回“不存在”,不再访问数据库。

注意:

  • TTL 不要太长,避免真实数据刚创建后长时间读不到;
  • 空值要有统一约定,避免和正常业务值冲突;
  • 对写路径做缓存失效/更新,保证最终一致。

方案 B:布隆过滤器(Bloom Filter)前置拦截

对“可能存在”的 ID 集合做布隆过滤器:

  1. 数据写入时同步/异步加入过滤器;
  2. 查询前先判断“一定不存在还是可能存在”;
  3. 一定不存在则直接拒绝,不打 Redis/DB。

适合海量 ID、爬虫扫描、开放查询接口。代价是:

  • 有极低误判率(把不存在判断成可能存在);
  • 需要维护过滤器更新与扩容。

方案 C:参数与权限校验前置

很多穿透其实是脏流量:

  • 非数字 ID
  • 超范围分页
  • 未登录却访问私有资源

在网关/应用入口直接拦截,比把压力交给缓存更划算。

三、缓存击穿:热点 key 刚好失效

1. 现象

某个非常热的 key(首页配置、秒杀库存视图、热门商品详情)在某一瞬间过期。
下一毫秒,成千上万请求同时看到 miss,一起去查数据库,形成“单点热点打穿”。

2. 根因

击穿关注的是:

单个热点 key 过期窗口内的并发回源。

它和穿透不同:数据本来存在,只是缓存刚好空了。

3. 解法

方案 A:互斥重建(singleflight / 分布式锁)

只有一个请求负责回源重建,其他请求等待或短暂失败重试。

Redis 官方 SET 支持原子条件写入,可用 NX + EX 做互斥:

# 抢重建锁,5 秒自动过期,防止进程崩溃后死锁
SET rebuild:lock:product:1001 1 NX EX 5

流程:

  1. 读缓存 miss;
  2. SET lock NX EX 抢锁;
  3. 抢到锁的请求查 DB,写回缓存,释放锁;
  4. 没抢到锁的请求短暂 sleep/重试读缓存,或返回旧兜底。

官方文档明确:SET key value NX EX seconds 是设置值并带过期时间的常用原子写法;SETNX 在新代码中更推荐用 SET ... NX 替代。

方案 B:逻辑过期(logical expire)

缓存 value 内嵌业务过期时间,物理 key 不过期或过期很久:

{
  "data": {"id": 1001, "price": 99},
  "expireAt": 1720857600
}

读取时:

  1. 未逻辑过期:直接返回;
  2. 已逻辑过期:返回旧数据,同时异步触发重建。

优点是用户几乎无感 miss;代价是要接受短暂脏读,并做好异步重建失败补偿。

方案 C:热点 key 永不过期 + 主动更新

对真正的超级热点,不依赖 TTL 被动失效,而由写路径/定时任务主动更新。
TTL 更适合普通数据;热点数据更适合“写时更新 + 监控”。

四、缓存雪崩:大面积失效或缓存层故障

1. 现象

两类雪崩最常见:

  1. 过期雪崩:大量 key 在同一时间点过期;
  2. 可用性雪崩:Redis 集群故障、网络分区、连接打满,导致缓存层整体不可用。

结果都是数据库在短时间内接到远超容量的读请求。

2. 根因

雪崩的核心不是某一个 key,而是:

缓存保护面在同一时间失效,流量失去缓冲层。

3. 解法

方案 A:TTL 加随机抖动

不要让同类数据使用完全相同的过期时间:

ttl = base_ttl + random(0, jitter)
# 例如 3600 + random(0, 300)

这样可以把过期曲线打散,避免整点集体回源。

方案 B:多级缓存与本地缓存

  • 本地 Caffeine/Guava Cache 挡住进程内重复读;
  • Redis 作为分布式缓存;
  • DB 作为最终数据源。

本地缓存 TTL 更短,可显著降低 Redis 抖动对 DB 的传导。

方案 C:限流、熔断、降级

缓存层异常时,应用不能无脑回源:

  • 对回源 QPS 做令牌桶/漏桶限流;
  • 数据库错误率升高时熔断;
  • 非核心接口返回兜底页/默认配置;
  • 核心读接口优先返回短暂旧数据。

方案 D:正确使用 Redis 内存淘汰策略

当 Redis 用作缓存时,官方建议配置最大内存和淘汰策略,以便内存触顶时按策略驱逐 key,而不是写失败或无规划崩溃。常见策略包括:

  • allkeys-lru / allkeys-lfu:在全部 key 中淘汰;
  • volatile-lru / volatile-lfu / volatile-ttl:只在设置了过期时间的 key 中淘汰。

关键点:

  1. 缓存场景应显式设置 maxmemory
  2. 按业务选择 LRU/LFU;
  3. 监控 evicted_keys、命中率、内存使用,避免“默默淘汰关键数据”却无人知晓。

淘汰策略解决的是内存压力下的可持续性,不能替代业务层的穿透/击穿防护,但能降低缓存层自身失控概率。

五、一套可落地的组合策略

真实系统很少只选一种方案,更常见的是分层组合:

1. 入口校验:挡住非法参数
2. 布隆过滤器/存在性索引:挡住明显不存在的 key
3. Redis 读:命中则返回
4. miss 时:
   - 热点 key:互斥重建或逻辑过期
   - 普通 key:允许有限并发回源
5. DB 空结果:写短 TTL 空缓存
6. DB 有结果:写缓存,TTL 加抖动
7. 全链路:限流 + 熔断 + 监控告警

伪代码示例

def get_product(product_id: str):
    if not valid_id(product_id):
        return None

    if bloom and not bloom.might_contain(product_id):
        return None

    cached = redis.get(f"product:{product_id}")
    if cached == "__NULL__":
        return None
    if cached is not None:
        return decode(cached)

    # 热点互斥重建
    locked = redis.set(f"rebuild:lock:product:{product_id}", "1", nx=True, ex=5)
    if not locked:
        time.sleep(0.05)
        return get_product(product_id)  # 简化:重试读缓存

    try:
        row = db.query_product(product_id)
        if row is None:
            redis.set(f"product:{product_id}", "__NULL__", ex=60)
            return None
        redis.set(f"product:{product_id}", encode(row), ex=3600 + random.randint(0, 300))
        return row
    finally:
        redis.delete(f"rebuild:lock:product:{product_id}")

说明:

  • SET ... NX EX 用于短锁,避免击穿时并发回源;
  • 空值缓存用于防穿透;
  • TTL 抖动用于打散过期,降低雪崩概率。

六、排查清单:线上怎么快速判断是哪一种

1. 先看指标

  • Redis 命中率是否骤降
  • Redis 连接数/超时是否升高
  • 数据库 QPS、慢查询、连接池是否打满
  • 是否集中在某几个 key,还是大面积 key

2. 判断路径

观察更可能是
大量非法/不存在 ID,DB 查空很多穿透
单个热 key 流量尖峰,过期瞬间 DB 飙升击穿
整点或批次任务后大面积 miss过期雪崩
Redis 超时/主从切换/集群异常同时发生可用性雪崩

3. 临时止血

  1. 对异常查询参数限流;
  2. 对热点 key 预热并临时延长 TTL;
  3. 打开空值缓存;
  4. 给 DB 回源加总开关/配额;
  5. 非核心读降级。

七、设计原则总结

  1. 缓存不是数据库的影子,是保护层。 任何 miss 路径都要有预算。
  2. 空结果也是结果。 不做负缓存,就容易被穿透。
  3. 热点不能靠“碰巧没过期”。 要有互斥重建、逻辑过期或主动更新。
  4. 过期时间要工程化。 相同 TTL 是雪崩温床,抖动是基本操作。
  5. 缓存故障要有降级。 没有限流和熔断的缓存架构,只是把风险延后到数据库。
  6. 可观测性比“感觉很快”更重要。 命中率、回源 QPS、淘汰数、锁竞争都要可监控。

八、什么时候不该过度设计

不是所有系统都要上布隆过滤器 + 逻辑过期 + 多级缓存:

  • 读 QPS 不高、数据几乎都存在:做好空值缓存和基础 TTL 即可;
  • 写极少、读极多的配置类数据:适合主动更新 + 较长 TTL;
  • 强一致要求极高的余额/库存:缓存只能做辅助,核心以数据库/专用组件为准,并明确一致性模型。

架构选择应匹配流量模型和一致性要求,而不是堆名词。

总结

缓存穿透、击穿、雪崩并不是三个神秘黑话,而是三条清晰的失败路径:

  • 穿透:不存在的数据反复打穿 DB;
  • 击穿:热点 key 失效瞬间被并发打穿;
  • 雪崩:保护面大面积失效,流量失去缓冲。

把问题定义清楚后,解法会自然收敛到:入口校验、空值缓存、互斥重建、TTL 抖动、淘汰策略、限流熔断与监控。
Redis 提供了 SET NX/EXEXPIRE、内存淘汰和分布式锁模式等基础能力,但真正决定系统稳不稳的,是你有没有把这些能力嵌进完整的读路径设计里。

参考资料

使用 Hugo 构建
主题 StackJimmy 设计