缓存系统刚上线时,最容易看到的是收益:接口响应更快,数据库压力下降,吞吐量明显提升。但随着访问量增长,缓存也可能从保护层变成故障放大器。
一个不存在的参数被持续请求,流量会绕过缓存直达数据库;一个热门 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"
}
}
读取时:
- 数据未逻辑过期,直接返回。
- 数据已逻辑过期,尝试获取重建锁。
- 获取锁的请求异步重建缓存。
- 其他请求暂时返回旧数据。
这种方法用“短时间旧数据”换取稳定响应,适合允许最终一致性的场景。价格结算、库存扣减、权限判断等强一致业务不应盲目使用。
3.3 热点数据提前预热
如果活动开始时间已知,可以在流量到来前完成:
- 热点 Key 写入。
- 数据库连接池预热。
- 应用实例扩容。
- CDN 或本地缓存预热。
- 限流和降级开关验证。
预热无法解决运行期间的所有问题,但能避免“活动开始和缓存重建同时发生”。
4. 缓存雪崩:大量 Key 集中失效
缓存雪崩是指大量缓存同时不可用,导致请求大规模回源。常见原因有两类:
- 大量 Key 设置了相同或接近的过期时间。
- 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. 一套可复用的生产治理流程
面对缓存风险,可以按照下面的顺序建设:
- 为入口增加参数校验和频率限制。
- 对不存在的数据缓存短期空值。
- 对大规模有效集合评估布隆过滤器。
- 为热点 Key 使用互斥重建或逻辑过期。
- 为批量缓存 TTL 增加随机抖动。
- 为 Redis 故障设置超时、退避、熔断和降级。
- 识别热 Key、大 Key 和不合理的数据结构。
- 建立缓存与数据库的一致性补偿机制。
- 压测缓存全失效和热点 Key 过期场景。
- 定期演练 Redis 故障与数据库保护策略。
总结
缓存穿透、击穿和雪崩的区别可以简单概括为:
- 穿透:查询的是不存在的数据,缓存和数据库都没有。
- 击穿:单个热点 Key 失效,大量请求同时回源。
- 雪崩:大量 Key 或整个缓存层同时不可用。
对应的治理重点分别是过滤无效请求、合并热点回源和分散失效风险。但生产系统不能只部署某一个技巧,还需要把一致性、限流、熔断、降级、监控和故障演练组合起来。
一个真正可靠的缓存系统,不只是“多数时候能命中”,而是在缓存未命中甚至缓存整体故障时,仍然能够保护数据库并维持可控的服务质量。

1058

被折叠的 条评论
为什么被折叠?



