Redis 缓存穿透、击穿与雪崩:策略与命令详解
缓存穿透、缓存击穿和缓存雪崩是缓存灾难的三驾马车。它们听起来相似,但成因和解决方案截然不同。本指南用具体的 Redis 命令拆解每个问题。
缓存穿透
问题描述
缓存穿透发生在请求查询的数据既不在缓存中也不在数据库中。每个请求都绕过缓存直接打到数据库。
例子:攻击者发送数百万个查询 user:99999999 的请求,而这个用户根本不存在。每个请求都缓存未命中,直接查库。
方案一:缓存空值
最简单的修复——用短 TTL 缓存"未找到"的结果:
SET cache:user:99999999 "NULL" EX 60
GET cache:user:99999999
应用逻辑:如果缓存值是 "NULL",直接返回 404,不查库。
方案二:布隆过滤器
布隆过滤器是一种概率数据结构,能告诉你"一定不在集合中"或"可能在集合中"。
在查询 Redis 或数据库之前,先检查布隆过滤器:
BF.ADD users:filter "user:1001"
BF.ADD users:filter "user:1002"
BF.EXISTS users:filter "user:99999999"
BF.EXISTS users:filter "user:1001"
注意:
BF.*命令需要 RedisBloom 模块。没有的话,可以用SETBIT和GETBIT实现简易布隆过滤器:
SETBIT bloom:users 42 1
SETBIT bloom:users 87 1
SETBIT bloom:users 156 1
GETBIT bloom:users 42
GETBIT bloom:users 99
坑点
空值的 TTL 不要设太长。如果数据后来被创建了,用户会一直看到过期的"未找到"响应,直到 TTL 过期。
缓存击穿
问题描述
缓存击穿发生在单个热点 Key 过期时,数百个并发请求同时打到数据库去重建缓存。数据库被"惊群效应"击垮。
方案一:互斥锁 SET NX
用分布式锁确保只有一个请求重建缓存:
SET lock:product:2001 "rebuilding" NX EX 10
应用逻辑:
- 尝试用
SET lock:key NX EX 10获取锁。 - 如果获取成功(返回 OK),查库、重建缓存、释放锁。
- 如果获取失败(返回 nil),短暂等待后重试读缓存。
SET lock:product:2001 "rebuilding" NX EX 10
SET cache:product:2001 '{"name":"Widget","price":9.99}' EX 600
DEL lock:product:2001
方案二:逻辑过期
不依赖 Redis TTL,而是在值内部存储过期时间戳:
SET cache:product:2001 '{"data":{"name":"Widget"},"expire":1739700000}'
GET cache:product:2001
应用检查 expire 字段。如果过期,触发异步重建,同时继续返回旧数据。确保零停机。
方案三:热点 Key 永不过期
对于真正关键的热点 Key,不设 TTL。通过后台任务主动更新:
SET hotkey:homepage:banner '{"title":"大促!","img":"banner.jpg"}'
定时任务或消息消费者在源数据变化时更新这个 Key。
坑点
互斥锁方案在极端负载下可能导致请求排队。设置合理的锁 TTL(如 10 秒),并实现带退避的重试。
缓存雪崩
问题描述
缓存雪崩发生在大量 Key 同时过期,导致洪水般的请求同时打到数据库。
常见原因:启动时加载所有缓存数据,使用相同的 TTL。
SET cache:product:1 "data1" EX 3600
SET cache:product:2 "data2" EX 3600
SET cache:product:3 "data3" EX 3600
三个 Key 在完全相同的时刻过期。数据库被打爆。
方案一:TTL 加随机抖动
通过添加随机偏移量分散过期时间:
SET cache:product:1 "data1" EX 3600
SET cache:product:2 "data2" EX 3720
SET cache:product:3 "data3" EX 3540
应用代码中:TTL = 基础TTL + random(0, 300)。
方案二:多级缓存
在 Redis(L2)前面加一层本地内存缓存(L1):
请求 → L1(进程内缓存)→ L2(Redis)→ 数据库
即使 Redis 缓存过期,L1 缓存也能吸收流量尖峰。L1 通常有更短的 TTL(如 30 秒)。
方案三:熔断器
当数据库错误率飙升时,触发熔断器,返回降级响应(旧缓存、默认值或错误页面),而不是放所有请求通过。
方案四:预热缓存
在已知的流量高峰前(如促销活动),预加载关键数据:
SET cache:product:1 '{"name":"Widget","price":4.99}' EX 7200
SET cache:product:2 '{"name":"Gadget","price":14.99}' EX 7320
SET cache:product:3 '{"name":"Doohickey","price":2.99}' EX 7140
坑点
仅靠随机抖动是不够的,如果基础 TTL 太短的话。如果所有 Key 的 TTL 在 50-60 秒之间,它们仍然会在一个很窄的时间窗口内过期。使用有意义的基础 TTL(如 1 小时),抖动范围 5-10 分钟。
快速对比
| 问题 | 原因 | 关键症状 | 主要修复方案 |
|---|---|---|---|
| 穿透 | 查询不存在的数据 | 每个请求都打到数据库 | 布隆过滤器 / 缓存空值 |
| 击穿 | 热点 Key 过期 | 单个 Key 的惊群效应 | 互斥锁 / 逻辑过期 |
| 雪崩 | 大量 Key 同时过期 | 数据库负载飙升 | TTL 抖动 / 多级缓存 |
在线编辑器试一试
前往 Redis 在线编辑器 模拟这些场景:
SET cache:user:99999999 "NULL" EX 60
GET cache:user:99999999
TTL cache:user:99999999
SET lock:product:2001 "rebuilding" NX EX 10
SET lock:product:2001 "rebuilding" NX EX 10
SET cache:product:1 "data1" EX 3600
SET cache:product:2 "data2" EX 3720
SET cache:product:3 "data3" EX 3540
TTL cache:product:1
TTL cache:product:2
TTL cache:product:3
试试第二个 SET NX——它返回 nil,因为锁已经被持有了。这就是互斥锁模式的实际效果。