← 返回最佳实践列表

*Redis 缓存穿透、击穿和雪崩:区别与防护方法

缓存穿透、击穿和雪崩都会增加后端数据源压力,但触发原因不同:穿透是反复查询不存在的数据,击穿是热点键失效后的集中回源,雪崩是大量键同时失效或缓存服务不可用。处理时应先区分问题,再选择限流、缓存空值、请求合并或高可用措施。

*三种问题对比

问题 典型触发原因 主要影响 常用防护
缓存穿透 查询缓存和数据库都不存在的数据,尤其是随机非法参数 无效请求持续访问数据库 参数校验、短期空值缓存、布隆过滤器
缓存击穿 某个热点键过期或被删除,大量请求同时重建 单个热点数据造成集中回源 单航班、互斥锁、逻辑过期、旧值降级
缓存雪崩 大量键集中过期,或 Redis 故障导致缓存整体不可用 大范围请求涌向数据库,可能形成级联故障 TTL 离散化、限流降级、多级缓存、高可用与容量保护

缓存未命中本身不是故障。正常的小规模未命中是缓存系统的一部分,需要防护的是异常请求量超过数据源承载能力。

*缓存穿透

*先拦截明显无效的请求

在访问缓存和数据库之前检查参数范围、格式和权限。例如,商品 ID 必须是正整数,请求不能超过合理长度,未授权租户不能探测其他租户的数据。参数校验成本最低,也不会占用缓存空间。

*对真实空结果使用短期空值缓存

数据库明确返回“不存在”时,可以写入一个与正常值可区分的空标记:

NULL_MARKER = b"\x00__not_found__"
cache_key = f"cache:item:{item_id}"

raw = redis.get(cache_key)
if raw == NULL_MARKER:
    return None
if raw is not None:
    return decode(raw)

item = load_item_from_db(item_id)  # 数据库异常不能当作空结果
if item is None:
    redis.set(cache_key, NULL_MARKER, ex=30)
    return None

redis.set(cache_key, encode(item), ex=300)
return item

注意三点:

  • 只有数据库明确返回空结果时才写入空标记;超时、连接错误和权限错误不能直接缓存为“不存在”。
  • 空标记不能和任何合法序列化结果冲突。
  • 新数据创建后,应主动删除或更新对应空值缓存,否则在 TTL 内可能仍读到不存在。

随机变化的非法 ID 会产生大量不同的空键,因此空值缓存不能替代参数校验和限流。

*布隆过滤器适合大规模合法集合预判

布隆过滤器可以快速判断“一定不存在”或“可能存在”。它存在误判:可能把不存在的数据判断为存在,但不能把已经正确加入的数据判断为不存在。

布隆过滤器不是 Redis 核心命令的默认组成部分,通常由 RedisBloom 模块或应用侧组件提供。使用时必须设计:

  • 合法数据如何初始化和增量加入过滤器。
  • 数据删除后如何处理;标准布隆过滤器不支持直接删除。
  • 误判率、容量和内存之间的关系。
  • 过滤器重建或故障时如何保护数据库。

它过滤的是大部分无效请求,不能替代数据库的真实查询和权限校验。

*缓存击穿

*用单航班合并同一键的回源

同一应用进程内,可以为每个缓存键维护一个正在执行的重建任务,让其他请求等待同一个结果。这通常称为 singleflight 或请求合并。

多实例环境下,进程内单航班不能限制所有实例,可以再使用带唯一值和过期时间的分布式锁:

SET lock:cache:item:1001 "unique-request-token" NX PX 10000

只有获得锁的请求负责重建。没有获得锁的请求可以:

  • 短暂等待后重新读缓存。
  • 返回仍在逻辑有效期内的旧值。
  • 返回经过定义的降级结果。

解锁必须校验唯一值,不能直接 DEL。完整示例见分布式锁文档。

*逻辑过期需要旧值和异步更新机制

逻辑过期把过期时间保存在值中,而不是让 Redis 自动删除键。发现值已逻辑过期后,当前请求可以继续返回旧值,同时由受控的后台任务刷新。

这种方案适合“短暂旧值可接受”的场景。它不适用于库存扣减、权限状态等必须及时准确的数据,并且需要处理刷新失败、长期不更新和旧值最大保留时间。

“热点数据永不过期”并不是无成本的解决方案。没有物理 TTL 后,必须另外提供数据更新、错误修正和容量清理机制。

*缓存雪崩

*打散集中过期时间

批量预热或写入缓存时,在业务允许的范围内加入随机 TTL:

import random

base_ttl = 300
redis.set(cache_key, encode(item), ex=base_ttl + random.randint(0, 60))

随机量应结合数据新鲜度要求确定。它只能把回源时间分散开,不能降低总未命中量。

*限制到达数据源的流量

缓存层故障时,数据库仍需要独立的保护边界:

  • 按接口或租户限制回源并发和请求速率。
  • 对可降级的数据返回旧值、默认值或稍后重试。
  • 设置合理的数据库连接池上限和查询超时。
  • 限制重试次数并加入随机退避,避免超时后形成重试风暴。
  • 对关键数据源保留容量,不让非关键缓存重建占满全部资源。

简单地把所有请求重试几次,可能在缓存故障时进一步放大数据库压力。

*高可用不能替代降级和容量规划

主从复制、哨兵和 Cluster 可以减少单点故障造成的不可用时间,但故障切换期间仍可能出现短暂错误或旧数据。应用必须处理连接错误、超时和只读状态变化。

多级缓存可以降低对 Redis 的集中依赖,但会引入一致性和失效管理。本地缓存应有容量和 TTL 上限,并明确哪些数据允许短暂不一致。

*如何验证防护是否有效

不要只检查代码是否包含锁或随机 TTL,应在测试环境模拟以下情况:

  1. 连续查询大量不存在的随机 ID,观察数据库是否仍被访问。
  2. 让热点键在高并发下失效,检查同一时刻的回源数量。
  3. 让一批键集中过期,验证限流、降级和数据库连接池是否按预期工作。
  4. 停止 Redis 或模拟网络超时,检查客户端重试是否放大流量。
  5. 模拟重建失败,确认锁会过期、旧值不会无限使用、错误不会被缓存为正常空结果。

*需要监控的指标

  • 缓存命中和未命中率,以及按接口划分的未命中请求量。
  • 回源并发、回源耗时和数据库错误率。
  • 单个键或键前缀的访问热点。
  • Redis 延迟、连接数、错误和故障切换事件。
  • 数据库连接池等待、慢查询和资源使用率。
  • 锁等待、重建失败和旧值返回次数。

告警阈值应来自正常业务基线和数据源容量,不建议直接套用固定命中率或固定 QPS。

*方案选择

  • 无效 ID 较少且重复出现:参数校验加短期空值缓存。
  • 无效 ID 数量巨大且合法集合可维护:评估布隆过滤器,同时保留限流。
  • 单热点集中重建:优先单航班或请求合并,必要时增加分布式锁。
  • 可接受短暂旧值:逻辑过期或旧值降级。
  • 大量键同时过期:TTL 离散化并限制回源并发。
  • Redis 整体故障:高可用、客户端退避、降级和数据源容量保护必须一起设计。

相关阅读:Redis 最佳实践、性能优化指南、大 Key 与热 Key 排查。