缓存击穿、穿透、雪崩:工程级的三道防线设计
缓存问题年年讲,年年有人栽跟头。本文不讲定义,而是给工程上可以落地的方案:互斥锁、单飞任务、提前回种、布隆过滤器,以及它们各自的失败兜底策略。
把三个概念放进同一张图
很多人把击穿、穿透、雪崩混在一起讲,但它们的根因不一样:
- 击穿:单个热点 key 突然失效,瞬间大量请求直达后端。
- 穿透:查询不存在的数据,每次都绕过缓存直达后端。
- 雪崩:大量 key 在同一时间失效,或缓存本身宕机。
它们各自的解法不同。下面一一展开。
击穿:单点热点 key 失效的工程方案
最常见的击穿:某个商品详情缓存 TTL 设置为 60s。在 60s 内的最后一秒有大流量,到期瞬间所有请求穿透到 DB。
方案一:互斥锁(分布式)
发现缓存未命中时,先尝试 SETNX 一个「锁」。拿到的线程读 DB 并回种,没拿到的线程短暂 sleep + 重新尝试缓存:
def get(key):
val = redis.get(key)
if val is not None:
return val
lock_key = f"lock:{key}"
if redis.set(lock_key, '1', nx=True, ex=3):
try:
val = db.query(key)
redis.set(key, val, ex=60)
return val
finally:
redis.delete(lock_key)
else:
time.sleep(0.05)
return get(key)
方案二:单飞任务(singleflight)
在进程内 / RPC 客户端用 singleflight 模式,保证同一时刻只有一个请求真正查后端:
var g singleflight.Group
func GetUser(uid int64) (*User, error) {
v, err, _ := g.Do(strconv.FormatInt(uid, 10), func() (interface{}, error) {
return loadFromCache(uid) // 内部尝试 redis,未命中再 DB
})
return v.(*User), err
}
穿透:查不到的数据怎么防
穿透比击穿更隐蔽。常见原因:
- 爬虫用不存在 ID 列表扫。
- 恶意攻击。
- 用户输入错误。
方案一:缓存空值
查询 DB 没结果时,把空值(比如 "nil")短 TTL 缓存(如 60s)。注意一定 短 TTL,否则真实存在的 key 因过期被卡死。
方案二:布隆过滤器
把所有存在的 ID 放进布隆过滤器(Bloom filter),查询先过过滤器。误判存在时再去 DB,真实拦截掉不存在的 ID:
from pybloomfilter import BloomFilter
bf = BloomFilter(1_000_000, error_rate=0.001)
def get(key):
if key not in bf:
return None # 一定不存在
val = redis.get(key)
if val is None:
val = db.query(key)
if val is None:
redis.set(key, "nil", ex=30)
else:
redis.set(key, val, ex=600)
return val
布隆过滤器是「低成本高拦截率」的典型代表,但要小心误判。
雪崩:大量 key 同时失效
雪崩的两种场景:
- TTL 集中在某一刻:比如为方便管理,全部 key 60s 过期;
- Redis 自身宕机:所有流量直奔 DB。
方案一:TTL 随机化
设置过期时间时加一个随机偏移:base + random(0, 30) 秒。这样就能避免「同时过期」。
方案二:多级缓存
本地缓存(如 Caffeine / sync.Map)+ 集中缓存(Redis)。本地缓存承担一部分流量,Redis 被打穿的概率大大降低:
| 层级 | 失效模式 | 恢复策略 |
|---|---|---|
| L1 进程缓存 | TTL 长,可以阻塞式回种 | 回种一致依赖 MQ |
| L2 Redis | 缓存 + 异步刷新 | 客户端重试 |
| L3 回源 | 受保护查询 | 降级 + 限流 |
Redis 自身的故障兜底
当 Redis 真的挂掉,缓存击穿、穿透、雪崩会一起发生。这时候最佳实践是:
- 客户端立即切到「降级模式」:直接走后端 + 限流。
- 限流挡不住的请求,nginx 直接 503 + Retry-After。
- Redis 恢复后,先预热热点数据再放开流量。
一致性:缓存是「速度换正确性」
做缓存最容易忽略的,是它和数据库的一致性窗口。常见「双写」「失效」方案:
| 方案 | 一致性窗口 | 实现难度 |
|---|---|---|
| Cache Aside(读时缓存) | 数十 ms | 低 |
| Write Through | 写时同步 | 中 |
| Write Behind | 异步批刷 | 高 |
| 分布式事务 + 缓存最终一致 | 秒级 | 高 |
Cache Aside 是绝大多数系统的默认选择:先更新 DB,再失效缓存。注意一定是「失效」而非「覆盖」,避免并发写入时的覆盖错误。
热点的提前预热
击穿最彻底的解决方案是「不让它发生」。预热常见方法:
- 运营后台手动触发预热。
- MQ 异步投放,由消费者写到缓存。
- 定长 LRU 缓存,按访问频率自动调高 TTL。
常见错误与避雷
- 用 Redis SET 多个字段当 cache key,导致不必要的复杂度。
- 把 JWT Token / 一次性验证码缓存在 Redis,安全风险。
- 缓存更新顺序:先写缓存后写 DB,导致 DB 失败而缓存看起来对。
- TTL 用同一常量,雪崩风险。
- 穿透用空对象缓存,但 TTL 设为 1 小时,真实存在时无法生效。
监控:哪些指标值得看
- 命中率:高于 90% 是基本水平。
- 击穿事件:单 key 突增直达后端。
- Redis 慢命令:可能与 TTL 一致性有关。
- 回源 TPS:回到 DB 的请求量。
- DB 连接池饱和:缓存失效最终体现在 DB 队列里。
给团队的几条通用建议
- 每个缓存场景打标:击穿 / 穿透 / 雪崩对应不同防护。
- 预热一定要做,搭配 TTL 随机化。
- 空值缓存 TTL 必须比正常 TTL 短很多。
- 用 singleflight 解决进程内重复查询。
- 缓存更新失败要监控,不能默默吞错。
缓存看起来是一个简单的 KV 查询,但它的设计包含了关于「业务特征、并发、故障」的诸多考量。把它做扎实,远比学一个新的框架更难,也更有价值。