缓存雪崩 / 击穿 / 穿透的防御

一句话速记

  • 雪崩 = 大量缓存同时过期 → 请求全打 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,击穿逻辑过期,穿透布隆过滤」

状态

  • 已背速记(三种区别)
  • 能写逻辑过期的伪代码
  • 能解释布隆过滤器假阳性