一粟 · yisu.app
· 12 min read
缓存

缓存击穿、穿透、雪崩:工程级的三道防线设计

缓存问题年年讲,年年有人栽跟头。本文不讲定义,而是给工程上可以落地的方案:互斥锁、单飞任务、提前回种、布隆过滤器,以及它们各自的失败兜底策略。

把三个概念放进同一张图

很多人把击穿、穿透、雪崩混在一起讲,但它们的根因不一样:

  • 击穿:单个热点 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。

常见错误与避雷

  1. 用 Redis SET 多个字段当 cache key,导致不必要的复杂度。
  2. 把 JWT Token / 一次性验证码缓存在 Redis,安全风险。
  3. 缓存更新顺序:先写缓存后写 DB,导致 DB 失败而缓存看起来对。
  4. TTL 用同一常量,雪崩风险。
  5. 穿透用空对象缓存,但 TTL 设为 1 小时,真实存在时无法生效。

监控:哪些指标值得看

  • 命中率:高于 90% 是基本水平。
  • 击穿事件:单 key 突增直达后端。
  • Redis 慢命令:可能与 TTL 一致性有关。
  • 回源 TPS:回到 DB 的请求量。
  • DB 连接池饱和:缓存失效最终体现在 DB 队列里。

给团队的几条通用建议

  • 每个缓存场景打标:击穿 / 穿透 / 雪崩对应不同防护。
  • 预热一定要做,搭配 TTL 随机化。
  • 空值缓存 TTL 必须比正常 TTL 短很多。
  • 用 singleflight 解决进程内重复查询。
  • 缓存更新失败要监控,不能默默吞错。

缓存看起来是一个简单的 KV 查询,但它的设计包含了关于「业务特征、并发、故障」的诸多考量。把它做扎实,远比学一个新的框架更难,也更有价值。