缓存雪崩 / 击穿 / 穿透的防御
一句话速记
- 雪崩 = 大量缓存同时过期 → 请求全打 DB → DB 崩溃;防御:随机化 TTL
- 击穿 = 热点 key 过期瞬间 → 大量并发请求同时打 DB;防御:逻辑过期 + 互斥锁
- 穿透 = 请求的 key 根本不存在(查 DB 也没有)→ 每次都打 DB;防御:布隆过滤器 / 空值缓存
通俗解释
类比:
雪崩:超市所有货架的货同时卖完(缓存同时过期)→ 所有顾客去仓库取货(DB)→ 仓库被挤爆
击穿:某款热销商品突然卖完(热点 key 过期)→ 大量顾客同时去仓库取这一件商品 → 仓库同一时刻只能接待一个顾客
穿透:有顾客专门要不存在的商品(根本不存在的 key)→ 每次都折腾仓库却什么都找不到
关键细节
1)缓存雪崩(Cache Avalanche)
触发条件:
情况 1:大量 key 在同一时刻失效(如活动结束时大量商品缓存同时设置了相同 TTL)
情况 2:Redis 服务宕机(全量缓存失效)
防御方案:
# 方案 1:随机化 TTL(最简单,应对情况 1)
base_ttl = 3600 # 1小时
random_delta = random.randint(-300, 300) # ±5分钟随机抖动
redis.set(key, value, ex=base_ttl + random_delta)
# 避免大量 key 同时过期
# 方案 2:多级缓存(应对 Redis 宕机)
# L1:本地缓存(Guava Cache / Caffeine),TTL 极短(30s)
# L2:Redis 分布式缓存
# L3:DB
# 好处:Redis 宕机时 L1 提供兜底
# 坏处:本地缓存多节点不一致
# 方案 3:热点数据永不过期(逻辑过期,见击穿方案)
# 方案 4:Redis 高可用(主从 + 哨兵 / Cluster)
# 方案 5:限流 + 熔断(Sentinel/Hystrix)
# 即使雪崩,限流保护 DB 不被打死2)缓存击穿(Cache Breakdown / Hotspot Invalid)
触发条件:
单个热点 key(如明星发微博、秒杀商品)在高并发时刻恰好过期
瞬间大量请求 → 都 cache miss → 都去 DB 查 → DB 压力暴增
防御方案:
方案 A:互斥锁(只允许一个请求重建缓存)
def get_with_mutex(key: str) -> str:
value = redis.get(key)
if value:
return value
# 缓存 miss → 尝试获取重建锁
lock_key = f"lock:{key}"
if redis.set(lock_key, "1", nx=True, ex=10): # SET NX EX,互斥锁
try:
# 只有一个请求进来重建缓存
value = db.query(key)
redis.set(key, value, ex=3600)
return value
finally:
redis.delete(lock_key)
else:
# 其他请求等待并重试
time.sleep(0.05)
return get_with_mutex(key) # 递归重试(有限次数)
# 优点:简单,DB 只被打一次
# 缺点:其他请求等待,有短暂的额外延迟方案 B:逻辑过期(不设 TTL,后台异步刷新)
# 缓存结构:{"value": ..., "expire_time": timestamp}
# Redis 里的 key 不设 TTL(永不自动过期)
# 在 value 里记录逻辑过期时间
def get_with_logical_expiry(key: str) -> str:
cache_data = redis.get(key)
if not cache_data:
return None # 冷启动时没有数据(特殊处理)
data = json.loads(cache_data)
if time.time() < data["expire_time"]:
return data["value"] # 未过期,直接返回
# 逻辑过期了,异步刷新
lock_key = f"lock:{key}"
if redis.set(lock_key, "1", nx=True, ex=10):
# 只有一个线程去刷新
threading.Thread(target=async_refresh, args=(key, lock_key)).start()
# 所有请求立即返回旧值(不等待刷新)
return data["value"]
def async_refresh(key, lock_key):
try:
new_value = db.query(key)
new_data = {
"value": new_value,
"expire_time": time.time() + 3600
}
redis.set(key, json.dumps(new_data)) # 不设 TTL
finally:
redis.delete(lock_key)
# 优点:请求始终有返回值(返回旧值),无额外等待
# 缺点:短暂可能返回过期数据(最终一致)
# 适用:允许短暂不一致的热点数据(如排行榜、商品详情)3)缓存穿透(Cache Penetration)
触发条件:
恶意攻击:故意请求大量不存在的 ID(如 user_id=-1, id=99999999 不存在)
缓存:miss(key 不存在),不缓存
DB:查不到,返回 null
→ 每次请求都穿透缓存,直达 DB
防御方案:
方案 A:空值缓存(简单)
def get_user(user_id: int):
cache_key = f"user:{user_id}"
cached = redis.get(cache_key)
if cached is not None:
if cached == "NULL": # 特殊标记
return None # 缓存了"不存在"
return json.loads(cached)
# DB 查询
user = db.query_user(user_id)
if user is None:
redis.set(cache_key, "NULL", ex=60) # 缓存不存在,TTL 短(60s)
return None
redis.set(cache_key, json.dumps(user), ex=3600)
return user
# 优点:简单
# 缺点:大量不存在的 key 占用 Redis 内存
# 适用:少量非法请求(不是大规模攻击)方案 B:布隆过滤器(应对大规模攻击)
from bloomfilter import BloomFilter
# 系统启动时,把所有合法 ID 加入布隆过滤器
bf = BloomFilter(capacity=10000000, error_rate=0.001)
for user_id in db.get_all_user_ids():
bf.add(str(user_id))
def get_user(user_id: int):
# 布隆过滤器检查(极快,O(1))
if not bf.contains(str(user_id)):
return None # 一定不存在,直接拒绝
# 可能存在(布隆过滤器有假阳性)
cache_key = f"user:{user_id}"
cached = redis.get(cache_key)
if cached:
return json.loads(cached)
user = db.query_user(user_id)
if user:
redis.set(cache_key, json.dumps(user), ex=3600)
return user
# 布隆过滤器:
# 有假阳性(说存在但实际不存在)→ 少量穿透 DB,可接受
# 无假阴性(说不存在则一定不存在)→ 阻止所有非法 ID
# 空间极小:1 千万数据约 14MB(error_rate=0.001)
# 缺点:不支持删除(新增 user_id 需要更新过滤器)Redis 原生布隆过滤器(Redis Stack / RedisBloom 模块):
BF.RESERVE user_ids 0.001 10000000 # 创建
BF.ADD user_ids "1001" # 添加
BF.EXISTS user_ids "9999" # 查询
4)三种场景对比
问题 触发条件 防御方案 适用场景
────────────────────────────────────────────────────────────────────
雪崩 大量 key 同时过期/Redis 宕机 随机 TTL + 多级缓存 所有场景基础配置
击穿 热点 key 过期+高并发 互斥锁 / 逻辑过期 秒杀、热点文章
穿透 请求不存在的 key 布隆过滤器 / 空值缓存 防爬虫、恶意攻击
延伸追问
- Q:布隆过滤器不支持删除,用户注销后怎么处理? → 方案 1:使用 Counting Bloom Filter(每个 bit 位改成计数器,支持删除,但空间大 4x);方案 2:布隆过滤器保守设计(包含已删除 ID),被”通过”后再查缓存/DB(查不到则空值缓存);方案 3:定期重建过滤器(低频操作,凌晨重建)。
- Q:互斥锁方案下,其他请求自旋等待,会有大量线程阻塞吗? → 是潜在问题。优化:加最大重试次数(如 3 次,每次 sleep 50ms),超过后直接降级(返回默认值/空值);或者使用本地计数器,只有部分请求去刷新(其他直接降级)。生产中推荐逻辑过期(旧值兜底)比互斥锁等待更优雅。
- Q:热 key 和击穿的关系? → 击穿是热 key 过期的特殊场景。即使热 key 不过期(逻辑过期),也可能因为大量并发打到同一 Redis 实例导致”热 key”问题(单节点 Redis QPS 瓶颈)——这时需要热 key 本地缓存(每台机器缓存热 key 副本)。
我的记法
- 雪崩 = 大量过期 → 随机 TTL 抖动
- 击穿 = 热点 key 过期 → 逻辑过期(推荐) 或互斥锁
- 穿透 = 不存在的 key → 布隆过滤器(大流量)或空值缓存(小流量)
- 逻辑过期:不设 TTL,value 里存过期时间,过期后返回旧值 + 异步刷新
- 一句话:「雪崩随机 TTL,击穿逻辑过期,穿透布隆过滤」
状态
- 已背速记(三种区别)
- 能写逻辑过期的伪代码
- 能解释布隆过滤器假阳性