在高并发系统里,Redis 常常被放在数据库前面做读加速。一旦缓存设计不当,问题往往不是“Redis 慢一点”,而是数据库被瞬间打穿。工程上最常见、也最容易被混为一谈的三类故障是:缓存穿透、缓存击穿和缓存雪崩。
这三类问题听起来相似,根因却不同:
| 问题 | 典型特征 | 直接伤害 |
|---|---|---|
| 缓存穿透 | 查的是不存在的数据,缓存和 DB 都没有 | 恶意或异常请求反复打到 DB |
| 缓存击穿 | 某个热点 key 刚好过期 | 同一时刻大量请求打到 DB |
| 缓存雪崩 | 大量 key 同时失效,或缓存层整体不可用 | 系统级读流量直接压垮 DB |
本文从现象、根因、可落地方案三层拆开,并结合 Redis 官方能力(SET 的 NX/EX、EXPIRE、淘汰策略、分布式锁模式)给出可复用实践。
一、先把请求路径画清楚
一个典型读路径是:
Client -> App -> Redis -> (miss) -> Database -> write back Redis -> response
缓存层的价值在于:
- 把高频读从数据库剥离出来;
- 用更低延迟响应热点数据;
- 给数据库争取容量和稳定性。
因此,任何“绕过缓存直接打 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 集合做布隆过滤器:
- 数据写入时同步/异步加入过滤器;
- 查询前先判断“一定不存在还是可能存在”;
- 一定不存在则直接拒绝,不打 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
流程:
- 读缓存 miss;
SET lock NX EX抢锁;- 抢到锁的请求查 DB,写回缓存,释放锁;
- 没抢到锁的请求短暂 sleep/重试读缓存,或返回旧兜底。
官方文档明确:SET key value NX EX seconds 是设置值并带过期时间的常用原子写法;SETNX 在新代码中更推荐用 SET ... NX 替代。
方案 B:逻辑过期(logical expire)
缓存 value 内嵌业务过期时间,物理 key 不过期或过期很久:
{
"data": {"id": 1001, "price": 99},
"expireAt": 1720857600
}
读取时:
- 未逻辑过期:直接返回;
- 已逻辑过期:返回旧数据,同时异步触发重建。
优点是用户几乎无感 miss;代价是要接受短暂脏读,并做好异步重建失败补偿。
方案 C:热点 key 永不过期 + 主动更新
对真正的超级热点,不依赖 TTL 被动失效,而由写路径/定时任务主动更新。
TTL 更适合普通数据;热点数据更适合“写时更新 + 监控”。
四、缓存雪崩:大面积失效或缓存层故障
1. 现象
两类雪崩最常见:
- 过期雪崩:大量 key 在同一时间点过期;
- 可用性雪崩: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 中淘汰。
关键点:
- 缓存场景应显式设置
maxmemory; - 按业务选择 LRU/LFU;
- 监控
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. 临时止血
- 对异常查询参数限流;
- 对热点 key 预热并临时延长 TTL;
- 打开空值缓存;
- 给 DB 回源加总开关/配额;
- 非核心读降级。
七、设计原则总结
- 缓存不是数据库的影子,是保护层。 任何 miss 路径都要有预算。
- 空结果也是结果。 不做负缓存,就容易被穿透。
- 热点不能靠“碰巧没过期”。 要有互斥重建、逻辑过期或主动更新。
- 过期时间要工程化。 相同 TTL 是雪崩温床,抖动是基本操作。
- 缓存故障要有降级。 没有限流和熔断的缓存架构,只是把风险延后到数据库。
- 可观测性比“感觉很快”更重要。 命中率、回源 QPS、淘汰数、锁竞争都要可监控。
八、什么时候不该过度设计
不是所有系统都要上布隆过滤器 + 逻辑过期 + 多级缓存:
- 读 QPS 不高、数据几乎都存在:做好空值缓存和基础 TTL 即可;
- 写极少、读极多的配置类数据:适合主动更新 + 较长 TTL;
- 强一致要求极高的余额/库存:缓存只能做辅助,核心以数据库/专用组件为准,并明确一致性模型。
架构选择应匹配流量模型和一致性要求,而不是堆名词。
总结
缓存穿透、击穿、雪崩并不是三个神秘黑话,而是三条清晰的失败路径:
- 穿透:不存在的数据反复打穿 DB;
- 击穿:热点 key 失效瞬间被并发打穿;
- 雪崩:保护面大面积失效,流量失去缓冲。
把问题定义清楚后,解法会自然收敛到:入口校验、空值缓存、互斥重建、TTL 抖动、淘汰策略、限流熔断与监控。
Redis 提供了 SET NX/EX、EXPIRE、内存淘汰和分布式锁模式等基础能力,但真正决定系统稳不稳的,是你有没有把这些能力嵌进完整的读路径设计里。
参考资料
- Redis 官方文档:
SET命令(含NX/EX等选项) - Redis 官方文档:
EXPIRE命令 - Redis 官方文档:
SETNX命令 - Redis 官方文档:Key eviction 内存淘汰策略
- Redis 官方文档:Distributed locks 分布式锁模式