2. 缓存基础:缓存穿透、缓存击穿、缓存雪崩的定义与经典解决方案

在分布式缓存系统中,缓存穿透、缓存击穿和缓存雪崩是三种最典型的高频故障场景。它们虽然名称相似,但成因、影响范围和治理策略截然不同。本节将逐一拆解其定义,并给出经过生产验证的经典解决方案,为后续基于 OpenAPI 契约的失效策略治理奠定基础。

2.1 三大故障的定义与对比

下表从触发条件、影响范围和典型表现三个维度,对三种故障进行横向对比:

故障类型 触发条件 影响范围 典型表现
缓存穿透 查询一个不存在的数据(key 在缓存和数据库中均不存在) 所有不存在 key 的请求直接打到数据库 数据库 QPS 突增,缓存命中率骤降
缓存击穿 一个热点 key 在缓存失效的瞬间,大量并发请求同时回源 单个热点 key 失效导致数据库瞬时压力 数据库单行记录被高频并发查询
缓存雪崩 大量 key 在同一时间窗口内批量失效,或缓存节点宕机 大面积缓存失效,数据库被海量请求淹没 数据库整体 QPS 飙升,服务级联宕机
核心认知:穿透是“查不到”,击穿是“热点失效”,雪崩是“批量失效”。三种故障的治理策略分别对应:空值兜底、互斥回源、失效分散。

2.2 缓存穿透:空值兜底与布隆过滤器

2.2.1 问题定义

当客户端持续请求一个数据库中也不存在的数据(如查询一个已被删除的用户 ID),缓存层永远无法命中,所有请求都会穿透缓存直达数据库。攻击者常利用此特性发起“穿透攻击”,导致数据库连接池耗尽。

2.2.2 经典解决方案

  • 方案一:缓存空值(Null Object Pattern)
    • 将查询结果为 null 的 key 也写入缓存,设置较短的 TTL(如 30~60 秒)。
    • 优点:实现简单,对现有代码侵入小。
    • 缺点:需要额外的存储空间存储空 key,且无法完全防御恶意构造的不同 key。
  • 方案二:布隆过滤器(Bloom Filter)
    • 在缓存层之前增加布隆过滤器,用于快速判断 key 是否“可能存在”。
    • 如果布隆过滤器判定 key 不存在,则直接拒绝请求,不再查询缓存和数据库。
    • 优点:空间效率极高,能拦截绝大多数不存在 key 的请求。
    • 缺点:存在一定的误判率(可能把不存在判为存在),且无法删除元素(需定期重建)。
生产建议:对于用户 ID、订单号等有明确范围的 key,优先使用布隆过滤器;对于查询条件不可枚举的场景,采用“缓存空值 + 短 TTL”作为兜底。

2.3 缓存击穿:互斥锁与逻辑过期

2.3.1 问题定义

某个被高频访问的“热点 key”(如秒杀商品库存、首页 Banner 配置)在缓存失效的瞬间,大量并发请求同时发现缓存未命中,全部回源查询数据库。数据库在极短时间内承受数倍于平时的并发压力,可能导致单行记录锁竞争或连接超时。

2.3.2 经典解决方案

  • 方案一:互斥锁(Mutex Lock)
    • 当缓存未命中时,先尝试获取分布式锁(如 Redis SETNX)。
    • 只有成功获取锁的线程才去查询数据库并重建缓存;其他线程等待或重试。
    • 优点:保证只有一个请求回源,数据库压力可控。
    • 缺点:引入锁等待,可能增加响应延迟;需处理锁超时和死锁问题。
  • 方案二:逻辑过期(Logical Expiration)
    • 缓存数据永不过期(物理 TTL 很大),但在数据中嵌入一个逻辑过期时间字段。
    • 当读取到逻辑过期时,当前线程立即返回旧数据,同时异步启动一个后台线程去更新缓存。
    • 优点:无锁、无等待,响应速度极快。
    • 缺点:数据存在短暂的不一致窗口;需要额外的异步更新机制。
避坑提示:互斥锁方案中,锁的粒度必须精确到 key 级别,切勿使用全局锁;逻辑过期方案中,异步更新线程必须做去重控制,防止重复回源。

2.4 缓存雪崩:失效分散与高可用

2.4.1 问题定义

缓存雪崩有两种典型诱因:一是大量 key 设置了相同的过期时间(如统一在凌晨 0 点失效),导致缓存同时大面积失效;二是缓存节点宕机或网络分区,导致整个缓存层不可用。两种情况下,所有请求都会瞬间涌向数据库,极易引发数据库宕机和服务雪崩。

2.4.2 经典解决方案

  • 方案一:失效时间随机化
    • 在设置缓存 TTL 时,在基础过期时间上增加一个随机偏移量(如 ±10%~30%)。
    • 避免大量 key 在同一时刻集中失效。
  • 方案二:多级缓存架构
    • 引入本地缓存(如 Caffeine、Guava Cache)作为 L1 缓存,Redis 作为 L2 缓存。
    • 即使 Redis 宕机,本地缓存仍能承载部分流量,为数据库争取恢复时间。
  • 方案三:缓存高可用与限流熔断
    • Redis 采用主从 + Sentinel 或 Cluster 模式,避免单点故障。
    • 在数据库访问层配置限流(如 Sentinel、Hystrix),当数据库压力超过阈值时直接熔断,返回降级数据。

2.5 三种故障治理策略总览

下面的 SVG 图从“请求路径”视角,展示了三种故障的发生位置与对应的治理策略:

缓存穿透 / 击穿 / 雪崩 治理策略总览 客户端请求 布隆过滤器 (拦截穿透) 缓存层 (Redis / 本地缓存) 数据库 (回源查询) 穿透:空值缓存 + 布隆过滤器 击穿:互斥锁 + 逻辑过期 雪崩:TTL 随机化 + 多级缓存 治理策略矩阵 故障类型 核心策略 关键指标 缓存穿透 布隆过滤器 + 空值缓存 误判率 < 1%,空值 TTL ≤ 60s 缓存击穿 互斥锁 / 逻辑过期 锁等待 ≤ 50ms,异步更新延迟 ≤ 200ms 缓存雪崩 TTL 随机化 + 多级缓存 失效窗口分散 ≥ 30%,本地缓存命中率 ≥ 20%

2.6 经典代码示例:互斥锁防止缓存击穿

以下代码展示了使用 Redis SETNX 实现分布式互斥锁,防止热点 key 击穿数据库:

public String getWithMutex(String key) {
    // 1. 尝试从缓存获取
    String value = redis.get(key);
    if (value != null) {
        return value;
    }

    // 2. 缓存未命中,尝试获取分布式锁
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS);

    if (locked) {
        try {
            // 3. 双重检查:防止在等待锁期间缓存已被重建
            value = redis.get(key);
            if (value != null) {
                return value;
            }

            // 4. 查询数据库
            value = database.query(key);

            // 5. 重建缓存(设置随机 TTL 防止雪崩)
            int ttl = 300 + new Random().nextInt(60);
            redis.setex(key, ttl, value);

            return value;
        } finally {
            // 6. 释放锁
            redis.del(lockKey);
        }
    } else {
        // 7. 未获取到锁,等待后重试
        Thread.sleep(50);
        return getWithMutex(key);  // 递归重试
    }
}
设计要点:锁的 TTL 必须大于数据库查询耗时(通常设为 2~3 秒);释放锁时务必使用 finally 块,防止异常导致死锁;递归重试需要设置最大重试次数,避免无限循环。

2.7 本章小结

  • 缓存穿透的核心治理思路是“拦截不存在 key”,布隆过滤器是首选方案,空值缓存作为兜底。
  • 缓存击穿的核心治理思路是“控制热点 key 的回源并发”,互斥锁保证单线程回源,逻辑过期实现无锁异步更新。
  • 缓存雪崩的核心治理思路是“分散失效窗口 + 提升缓存可用性”,TTL 随机化、多级缓存和限流熔断缺一不可。
  • 三种故障的治理策略并非互斥,在实际生产系统中往往需要组合使用,并通过 OpenAPI 契约将失效策略标准化、可配置化——这正是本课程后续章节将要深入展开的内容。