Redis 缓存穿透、击穿与雪崩:从问题区别到生产级治理

缓存系统刚上线时,最容易看到的是收益:接口响应更快,数据库压力下降,吞吐量明显提升。但随着访问量增长,缓存也可能从保护层变成故障放大器。

一个不存在的参数被持续请求,流量会绕过缓存直达数据库;一个热门 Key 刚好过期,大量请求会同时回源;一批 Key 在同一时刻失效,数据库连接池、线程池和下游服务可能一起被压垮。

这些问题通常被概括为缓存穿透、缓存击穿和缓存雪崩。三者表现相似,都会造成数据库压力上升,但触发条件和治理方法并不相同。本文从请求链路出发,整理一套适合生产环境的识别与治理思路。

1. 先建立标准的缓存访问模型

最常见的 Cache Aside 模式如下:

读取请求
  │
  ├─ 查询缓存
  │    ├─ 命中:返回结果
  │    └─ 未命中:查询数据库
  │                  ├─ 有数据:写入缓存并返回
  │                  └─ 无数据:返回空结果

伪代码:

def get_product(product_id):
    key = f"product:{product_id}"
    cached = redis.get(key)

    if cached is not None:
        return deserialize(cached)

    product = database.find_product(product_id)
    if product is None:
        return None

    redis.setex(key, 1800, serialize(product))
    return product

这个实现能运行,但还有三个缺口:

  • 数据不存在时没有缓存,重复无效请求会持续访问数据库。
  • 热点 Key 失效时,没有限制并发回源。
  • 过期时间固定,大量 Key 可能集中失效。

穿透、击穿和雪崩,正好对应这三个缺口。

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

缓存穿透是指请求查询一个不存在于缓存、也不存在于数据库的数据。由于系统无法把真实数据写入缓存,每次请求都会进入数据库。

典型场景包括:

  • 用户输入了不存在的商品 ID。
  • 爬虫或攻击者批量构造随机参数。
  • 接口缺少参数校验,非法值直接进入查询链路。
  • 数据已经删除,但旧链接仍被大量访问。

如果只看缓存命中率,穿透可能表现为命中率突然下降;如果看数据库,则会看到大量结构相似、返回空结果的查询。

2.1 第一层:参数与业务边界校验

不要让明显非法的请求进入缓存和数据库。

def valid_product_id(product_id):
    return isinstance(product_id, int) and 0 < product_id <= MAX_PRODUCT_ID

可以校验:

  • ID 是否为正数。
  • 字符串长度是否超过业务上限。
  • 枚举值是否在允许范围内。
  • 租户、用户与资源类型是否匹配。
  • 请求频率是否异常。

参数校验成本低,应该尽量靠近入口。但它只能拦截明显非法值,无法判断一个格式正确的 ID 是否真实存在。

2.2 第二层:缓存空值

数据库返回不存在时,将一个特殊空值写入缓存:

NULL_VALUE = "__NULL__"

def get_product(product_id):
    key = f"product:{product_id}"
    cached = redis.get(key)

    if cached == NULL_VALUE:
        return None

    if cached is not None:
        return deserialize(cached)

    product = database.find_product(product_id)
    if product is None:
        redis.setex(key, 60, NULL_VALUE)
        return None

    redis.setex(key, 1800, serialize(product))
    return product

空值的过期时间通常应短于正常数据,因为不存在的数据可能稍后被创建。如果空值缓存时间过长,新数据写入后可能暂时不可见。

还要防止攻击者制造海量随机 Key,占用 Redis 内存。可以结合参数校验、限流和 Key 数量监控,而不是无条件缓存所有空结果。

2.3 第三层:布隆过滤器

当有效数据集合相对稳定、查询规模很大时,可以在缓存前增加布隆过滤器:

请求
  │
  ├─ 布隆过滤器判断“肯定不存在”
  │       └─ 直接返回
  │
  └─ “可能存在”再查询缓存与数据库

布隆过滤器有两个重要特点:

  • 判断不存在时,结果是可靠的。
  • 判断存在时,可能有误判。

因此它只能挡住大量明确不存在的请求,不能替代数据库查询。生产使用时还要解决新增数据同步、删除数据处理、容量评估和误判率设置。

3. 缓存击穿:一个热点 Key 突然失效

缓存击穿通常发生在单个热点 Key 上。这个 Key 平时承载大量请求,一旦过期,短时间内的并发请求会同时查询数据库。

例如热门商品、首页配置、直播间信息或热榜数据:

热点 Key 过期
   │
   ├─ 请求 1 回源数据库
   ├─ 请求 2 回源数据库
   ├─ 请求 3 回源数据库
   └─ 大量请求同时回源

击穿的核心不是“缓存未命中”,而是“同一个 Key 的大量请求同时回源”。

3.1 使用互斥锁合并回源

只允许一个请求重建缓存,其他请求短暂等待后重试:

def get_product(product_id):
    key = f"product:{product_id}"
    lock_key = f"lock:{key}"

    cached = redis.get(key)
    if cached is not None:
        return decode(cached)

    acquired = redis.set(lock_key, "1", nx=True, ex=10)
    if acquired:
        try:
            # 双重检查,避免等待期间缓存已经被其他请求写入
            cached = redis.get(key)
            if cached is not None:
                return decode(cached)

            product = database.find_product(product_id)
            write_cache(key, product)
            return product
        finally:
            safe_unlock(lock_key)

    sleep_short_random_time()
    return retry_get_product(product_id)

实现时要注意:

  • 锁必须设置过期时间,避免持有者异常退出后永不释放。
  • 释放锁时应校验锁的唯一标识,避免删除别人的锁。
  • 等待请求要有超时和最大重试次数。
  • 缓存重建时间不能超过锁租期。
  • 数据库失败时不能把错误结果长期写入缓存。

互斥锁保护的是数据库,但会增加未命中请求的等待时间。因此需要结合业务 SLA 调整。

3.2 使用逻辑过期

对于极热点数据,可以让 Redis 中的 Key 不直接物理过期,而是在值中保存业务过期时间:

{
  "expire_at": 1781798400,
  "data": {
    "id": 1001,
    "name": "example"
  }
}

读取时:

  1. 数据未逻辑过期,直接返回。
  2. 数据已逻辑过期,尝试获取重建锁。
  3. 获取锁的请求异步重建缓存。
  4. 其他请求暂时返回旧数据。

这种方法用“短时间旧数据”换取稳定响应,适合允许最终一致性的场景。价格结算、库存扣减、权限判断等强一致业务不应盲目使用。

3.3 热点数据提前预热

如果活动开始时间已知,可以在流量到来前完成:

  • 热点 Key 写入。
  • 数据库连接池预热。
  • 应用实例扩容。
  • CDN 或本地缓存预热。
  • 限流和降级开关验证。

预热无法解决运行期间的所有问题,但能避免“活动开始和缓存重建同时发生”。

4. 缓存雪崩:大量 Key 集中失效

缓存雪崩是指大量缓存同时不可用,导致请求大规模回源。常见原因有两类:

  1. 大量 Key 设置了相同或接近的过期时间。
  2. Redis 实例、网络或机房发生故障。

第一类是数据过期策略问题,第二类是缓存基础设施不可用。两者都可能让数据库在短时间内承担远超正常水平的流量。

4.1 给过期时间增加随机抖动

不要让同一批数据精确地在同一秒过期:

import random

base_ttl = 1800
jitter = random.randint(0, 300)
redis.setex(key, base_ttl + jitter, value)

随机时间的范围要根据业务刷新周期和数据容忍度设置。它能把集中回源摊开,但无法解决 Redis 整体不可用。

4.2 多级缓存

可以在 Redis 前增加进程内缓存:

请求 → 本地缓存 → Redis → 数据库

Redis 短暂故障时,本地缓存可以承担部分热点读取。代价是多个应用实例之间的数据可能短暂不一致,并且每个实例都会占用额外内存。

适合本地缓存的数据通常具有这些特点:

  • 数据量小。
  • 读取频率高。
  • 更新频率低。
  • 允许短暂旧值。
  • 不包含需要即时撤销的权限信息。

4.3 高可用不是“不出故障”

Redis 主从、哨兵或集群可以降低单节点故障的影响,但故障切换仍可能出现:

  • 短暂连接失败。
  • 连接池持有旧节点连接。
  • 读写切换期间数据延迟。
  • 客户端拓扑尚未刷新。
  • 大量请求同时重试。

应用层仍需要设置连接超时、命令超时、有限重试、退避和熔断。没有边界的自动重试会在故障期间制造重试风暴。

5. 缓存与数据库一致性怎么处理

Cache Aside 常见的更新方式是:

先更新数据库,再删除缓存

伪代码:

def update_product(product_id, payload):
    database.update_product(product_id, payload)
    redis.delete(f"product:{product_id}")

下一次读取会从数据库加载新数据。一般不建议在更新数据库后直接更新缓存,因为缓存结构可能由多张表聚合而成,并发更新时也更容易发生旧值覆盖新值。

不过“更新数据库后删除缓存”仍存在短暂不一致窗口,需要根据业务选择:

  • 接受最终一致性。
  • 通过消息队列异步重试删除。
  • 使用变更数据捕获同步缓存。
  • 为缓存值加入版本号。
  • 对关键业务直接绕过缓存或使用更强的一致性方案。

缓存不是数据真相来源。无法接受旧数据的流程,不能只靠 TTL 期待问题自动消失。

6. 热 Key 与大 Key 也会放大风险

缓存治理不能只看命中率。

热 Key 会导致:

  • 单个分片负载集中。
  • 网络带宽集中。
  • Key 失效时出现击穿。
  • 单线程命令处理出现排队。

大 Key 会导致:

  • 单次读写网络包过大。
  • 删除和过期带来额外开销。
  • 主从同步和持久化压力增大。
  • 客户端反序列化耗时升高。

可以采用:

  • 将大对象拆分为多个合理粒度的 Key。
  • 对热数据使用本地缓存。
  • 对超热点 Key 做副本或分片读取。
  • 避免一次读取超大集合。
  • 对删除操作采用非阻塞策略。

优化前应先测量 Key 的访问频率、数据大小和命令耗时,而不是仅凭名称判断。

7. 降级、限流和熔断是最后防线

缓存故障时,如果所有请求都无条件访问数据库,数据库很可能成为下一个故障点。

可以针对不同接口设计:

  • 限流:控制进入核心链路的请求数。
  • 熔断:数据库或 Redis 异常率过高时快速失败。
  • 降级:返回默认值、旧数据或简化数据。
  • 隔离:不同业务使用独立线程池、连接池或实例。
  • 排队:非实时任务进入消息队列异步处理。

例如商品详情可以返回短时间旧数据,但支付校验不能返回缓存旧值。降级策略必须由业务语义决定,不能用同一套模板覆盖所有接口。

8. 监控指标应该覆盖完整链路

至少应关注:

  • 缓存命中率与未命中率。
  • 空值缓存命中量。
  • Redis 命令延迟和超时率。
  • 连接池使用率与等待时间。
  • 热 Key、大 Key 和内存增长。
  • Key 过期、淘汰和驱逐数量。
  • 数据库查询量、慢查询和连接数。
  • 缓存重建耗时与锁等待时间。
  • 降级、限流和熔断触发次数。

仅看平均命中率可能掩盖局部问题。一个接口命中率很高,不代表它的某个热点 Key 不会在失效瞬间压垮数据库。

建议按接口、业务类型、Key 前缀和实例维度拆分指标。

9. 一套可复用的生产治理流程

面对缓存风险,可以按照下面的顺序建设:

  1. 为入口增加参数校验和频率限制。
  2. 对不存在的数据缓存短期空值。
  3. 对大规模有效集合评估布隆过滤器。
  4. 为热点 Key 使用互斥重建或逻辑过期。
  5. 为批量缓存 TTL 增加随机抖动。
  6. 为 Redis 故障设置超时、退避、熔断和降级。
  7. 识别热 Key、大 Key 和不合理的数据结构。
  8. 建立缓存与数据库的一致性补偿机制。
  9. 压测缓存全失效和热点 Key 过期场景。
  10. 定期演练 Redis 故障与数据库保护策略。

总结

缓存穿透、击穿和雪崩的区别可以简单概括为:

  • 穿透:查询的是不存在的数据,缓存和数据库都没有。
  • 击穿:单个热点 Key 失效,大量请求同时回源。
  • 雪崩:大量 Key 或整个缓存层同时不可用。

对应的治理重点分别是过滤无效请求、合并热点回源和分散失效风险。但生产系统不能只部署某一个技巧,还需要把一致性、限流、熔断、降级、监控和故障演练组合起来。

一个真正可靠的缓存系统,不只是“多数时候能命中”,而是在缓存未命中甚至缓存整体故障时,仍然能够保护数据库并维持可控的服务质量。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值