人人都会AI编程

21.3 缓存体系优化:多级缓存、缓存穿透 / 击穿 / 雪崩解决方案

更新时间:2026-07-11

缓存是提升系统读取性能、降低数据库压力的核心手段,但引入缓存后也带来了数据一致性、可用性等一系列新挑战。仅仅“把数据放进 Redis”远不足以构建一个健壮的缓存体系,真正可靠的方案必须从架构设计与异常防护两个维度同时入手。

21.3.1 构建多级缓存:从本地到远程的纵深防御

单级缓存(例如仅使用 Redis)在应对极高并发时仍可能出现瓶颈:所有缓存请求都通过网络 IO 访问,当热点数据被密集读取时,Redis 本身的吞吐量和网络延迟都会成为限制。多级缓存的思路是在应用进程内增加一层更快的本地缓存,形成“本地缓存 → 远程缓存 → 数据库”的查询链路。

典型的二级缓存结构如下:

请求 → [Caffeine 本地缓存] → (未命中) → [Redis 远程缓存] → (未命中) → 数据库
  • 一级缓存(L1):基于 JVM 堆内的本地缓存,如 Caffeine、Guava Cache,访问延迟可达微秒级,容量受堆内存限制,通常缓存最热的数据。
  • 二级缓存(L2):独立部署的分布式缓存,如 Redis、Memcached,容量可水平扩展,数据跨实例共享,用于保存全量热点和温数据。

实现方式:Spring Cache 抽象 + 自定义 CacheManager

Spring Cache 提供了一个统一的抽象层,但内置实现通常只对应一级缓存。我们可以通过组合多个 CacheManager 或实现自定义的 Cache 接口来构建多级缓存。

最简单的集成是利用 Spring Data RedisCaffeine 协同工作。下面是一种实用的封装思路:

@Component
public class MultiLevelCache implements Cache {
    private final Cache localCache;   // Caffeine
    private final Cache remoteCache;  // Redis

    public MultiLevelCache() {
        this.localCache = new CaffeineCache("local", Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(Duration.ofMinutes(1))
                .build());
        this.remoteCache = new RedisCache("remote", ...);
    }

    @Override
    public ValueWrapper get(Object key) {
        // 1. 先查本地
        ValueWrapper value = localCache.get(key);
        if (value != null) return value;

        // 2. 再查远程
        value = remoteCache.get(key);
        if (value != null) {
            // 回填本地缓存,防止本地再次穿透
            localCache.put(key, value.get());
        }
        return value;
    }

    @Override
    public void put(Object key, Object value) {
        // 同时写入本地和远程(或只写远程,通过消息失效本地缓存)
        remoteCache.put(key, value);
        localCache.put(key, value);
    }

    // 其他方法省略...
}

此框架下,所有缓存读取先经过近端的 Caffeine,减少 Redis 网络调用次数;写操作同步更新两级缓存,或采用“主动失效 + 懒加载”的组合策略。此外,生产环境中通常还会结合 Redis 的 Pub/Sub 或消息队列,在集群内广播本地缓存失效通知,以保证多实例间的本地缓存一致性。

多级缓存收益的量化感知:在千万级日活的场景中,将核心接口的缓存命中率从 95% 提升至 99%(通过引入本地缓存吸收热点),数据库 QPS 可降低数个数量级,平均响应时间也会从毫秒级缩短到微秒级。

21.3.2 缓存穿透:查不到的数据绕过缓存

问题描述:查询一个数据库中根本不存在的数据(例如 id 人为伪造为 -1),每一次请求都会穿透缓存直击数据库。若有人恶意构造大量不存在 key 的并发请求,数据库将瞬间面临巨大压力。

解决方案

  • 方案一:缓存空值

当数据库查询结果为空时,将一个特殊标记值(如 "NULL""")写入缓存,并设置较短的过期时间(如 30 秒)。后续同样的非法查询可以直接从缓存返回空结果,不再访问数据库。
注意:空值不宜长期占用内存,过期时间必须短于正常数据。同时需要兼容“数据后续被创建”的场景:当新数据插入时,主动删除对应的空值缓存。

  • 方案二:布隆过滤器(Bloom Filter)

在缓存层之前加一层布隆过滤器,预先将所有可能存在的数据 key 通过哈希映射到位数组中。请求进来时,布隆过滤器可以高效判断一个 key “一定不存在”还是“可能存在”。若判断为不存在,则直接返回,避免击穿到数据库。
布隆过滤器的优势是内存占用极小,缺点是有一定的误判率(将不存在判断为存在),需要定期重建,且无法支持数据删除(如要删除可用计数式布隆过滤器,但复杂度会上升)。

实用选择:对于 key 空间有限、数据变动不频繁的场景(如商品 ID、用户 ID),布隆过滤器是更彻底的防御手段;对于一般业务,缓存空值实现简单,配合短过期时间即可有效防穿透。

21.3.3 缓存击穿:热点数据瞬间失效

问题描述:一个被极高并发访问的热点 key,在缓存过期的瞬间,大量请求同时涌入数据库加载该数据。此时数据库很可能因瞬时压力飙升而响应缓慢,进而拖垮整个服务。

解决方案的核心是“限制数据库的并发加载数”,常见方法如下:

  • 方案一:互斥锁(Mutex)

当缓存失效时,只允许一个请求去数据库查询并回写缓存,其他请求等待锁释放后直接从缓存获取。在分布式环境中,可以使用 Redis 的 SETNX 实现分布式锁:

  public String getData(String key) {
      String value = redis.get(key);
      if (value != null) return value;

      // 尝试获取锁,锁的key与数据key关联,比如 "lock:" + key
      String lockKey = "lock:" + key;
      if (redis.setnx(lockKey, "1", 30, TimeUnit.SECONDS)) {
          try {
              // 双重检查,防止在获锁期间其它线程已重建缓存
              value = redis.get(key);
              if (value != null) return value;

              value = db.query(key);
              redis.set(key, value, 300, TimeUnit.SECONDS);
          } finally {
              redis.del(lockKey);
          }
      } else {
          // 等待一小段时间后重试,或直接返回降级数据
          Thread.sleep(100);
          return getData(key); // 自旋重试
      }
  }
  
  • 方案二:逻辑过期(永不过期 + 异步刷新)

在缓存值中附加一个逻辑过期时间字段,物理上缓存在 Redis 中永不失效。读取时如果发现逻辑时间已过期,立即返回旧值(保证可用性),同时异步启动一个后台任务去更新缓存。
这种方式避免了互斥锁的阻塞等待,在高并发下性能更佳,但需要容忍短暂的数据不一致。

真实案例:某电商大促期间,首页秒杀商品的详情缓存设置了 10 秒过期。瞬时百万用户刷新页面,一旦缓存失效,DB 瞬间被打满。改用“逻辑过期 + 异步刷新”后,即便逻辑过期那一刻,用户仍然能看到旧数据(最多延迟数秒),而 DB 只有一个异步任务在更新,压力骤降。

21.3.4 缓存雪崩:大规模同时失效或集群故障

问题描述:缓存雪崩一般指两种场景:

  1. 大批量 key 在同一时间窗口内集中过期,导致所有请求像雪崩一样塌向数据库。
  2. 缓存集群宕机或网络中断,所有流量直接打到数据库,造成系统整体不可用。

解决方案

  • 针对集中过期

在设置缓存过期时间时,加上一个随机偏移量,避免批量 key 同时失效。例如基础过期时间为 1 小时,实际写入时使用 60 * 60 + Random.nextInt(300) 秒,将过期时间打散到不同时刻。

  • 针对缓存服务不可用
  • 缓存高可用架构:Redis 采用哨兵模式(Sentinel)或集群(Cluster)模式,确保单点故障时自动切换,避免整个缓存层雪崩。
  • 限流与降级:在网关或服务层引入限流组件(如 Sentinel、Resilience4j),当数据库负载超过阈值时主动拒绝部分请求或返回降级内容。同时可配合 Hystrix 线程池隔离,防止数据库慢查询占满 HTTP 线程。
  • 本地缓存兜底:利用 Caffeine 等本地缓存保存少量兜底数据,即使 Redis 完全不可用,也能提供部分基础服务,避免全盘崩溃。

实操建议:缓存雪崩往往由“依赖链”引发,因此必须通过混沌工程验证系统的真实弹性。定期在预生产环境模拟 Redis 宕机,观察熔断、降级、本地缓存兜底是否按预期生效,而不是等线上事故来检验。

21.3.5 缓存一致性策略选型

多级缓存引入后,数据更新的顺序与失效机制直接影响一致性。常见模式包括:

  • Cache Aside(旁路缓存):读时先查缓存,未命中则查库并回写;写时先更新数据库,再删除缓存。删除缓存而不是更新缓存,可避免并发写造成的脏数据。
  • Write Through(直写模式):更新数据库的同时同步更新缓存,适用于数据强一致性要求,但会略微增加写延迟。
  • 延迟双删:写操作时先删除缓存,再更新数据库,稍后(如 sleep 几百毫秒)再次删除缓存,以解决并发读写入旧值的问题。这种方法在极高并发下仍有不一致窗口,但比简单删除更可靠。

实际开发中,绝大多数业务场景使用 Cache Aside + 过期时间兜底 即可满足一致性要求,再辅以数据库 binlog 订阅(如 Canal)异步刷新缓存,可以构建最终一致的高性能缓存体系。

21.3.6 监控与告警:让缓存状态透明化

再好的缓存策略缺少监控也形同虚设。至少应对以下指标进行监控:

  • 命中率(本地、远程分别统计)
  • 缓存加载耗时(尤其是重建互斥锁的等待时间)
  • 缓存空值占比(空值太多可能意味着穿透攻击或数据异常)
  • Redis 内存使用率、驱逐策略触发次数
  • 数据库 QPS 与缓存命中率的相关性(缓存命中率陡降时需触发告警)

Spring 生态中可通过 Micrometer 结合 Prometheus + Grafana 面板,将这些指标统一暴露并进行可视化,建立“命中率低于 95% 告警”、“Redis 连接池耗尽告警”等规则,实现缓存健康的实时感知。

综合来看,缓存优化并非简单的技术堆叠,而是一项需要结合业务容忍度、运维能力、并发量级的系统性工作。从多级缓存的架构设计,到穿透、击穿、雪崩的层层防护,再到最终一致性与可观测性的保障,每一项取舍都应在实际压测数据下做出判断,而非单纯依赖理论。当这套体系搭建完成后,你的应用才能在流量洪峰中保持稳定、高效的服务能力。