4. 缓存穿透、缓存击穿、缓存雪崩:成因、危害与经典解决方案

在构建高并发缓存系统时,缓存穿透、缓存击穿和缓存雪崩是三种最典型的“缓存灾难”。它们虽然名称相似,但成因、影响范围和解决方案截然不同。本章将从底层原理出发,逐一剖析这三种异常,并给出在 Spring Boot 环境下的工程化应对策略。

4.1 三种缓存异常的成因与危害对比

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

异常类型 触发条件 影响范围 典型后果
缓存穿透 查询一个不存在的数据(如无效 ID) 单次请求 → 直接穿透到数据库 数据库压力激增,可能被恶意攻击利用
缓存击穿 某个热点 key 在缓存失效的瞬间,大量并发请求涌入 单个热点 key → 数据库单点压力 数据库瞬间负载过高,导致服务雪崩
缓存雪崩 大量 key 同时过期,或缓存节点宕机 全局/大面积 key → 数据库整体压力 数据库被打垮,系统整体不可用
⚠️ 危害等级:缓存雪崩 > 缓存击穿 > 缓存穿透。雪崩往往导致整个服务集群的级联故障,恢复时间最长;穿透则容易被恶意利用,造成持续性的数据库压力。

4.2 缓存穿透:空值与布隆过滤器

缓存穿透的核心问题是:缓存层无法存储“不存在”的语义。当请求一个数据库中也不存在的数据时,缓存永远无法命中,每次请求都会直接打到数据库。

4.2.1 经典解决方案一:缓存空对象

将查询结果为 null 的数据也缓存起来,设置一个较短的过期时间(如 30~60 秒)。这样后续相同的无效请求就不会穿透到数据库。

// Spring Boot 示例:缓存空对象
public User getUserById(Long id) {
    // 1. 先查缓存
    User user = redisTemplate.opsForValue().get("user:" + id);
    if (user != null) {
        return user;
    }
    // 2. 缓存未命中,查数据库
    user = userMapper.selectById(id);
    // 3. 无论是否为空,都写入缓存
    if (user == null) {
        // 缓存空对象,过期时间 60 秒
        redisTemplate.opsForValue().set("user:" + id, new User(), 60, TimeUnit.SECONDS);
    } else {
        redisTemplate.opsForValue().set("user:" + id, user, 3600, TimeUnit.SECONDS);
    }
    return user;
}
💡 注意:缓存空对象会占用额外的内存空间。对于恶意攻击(如遍历大量不存在的 ID),建议结合布隆过滤器进行第一层拦截。

4.2.2 经典解决方案二:布隆过滤器

布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素一定不存在或可能存在。在缓存层之前加一层布隆过滤器,可以快速过滤掉绝大多数不存在的 key。

// 初始化布隆过滤器(假设已注入 BloomFilterHelper)
@PostConstruct
public void initBloomFilter() {
    // 预计插入 100 万条数据,误判率 1%
    bloomFilterHelper = new BloomFilterHelper<>(1000000, 0.01);
    // 将数据库中所有用户 ID 加载到布隆过滤器
    List<Long> allIds = userMapper.selectAllIds();
    allIds.forEach(id -> bloomFilterHelper.add("user:" + id));
}

// 查询时先过布隆过滤器
public User getUserByIdSafe(Long id) {
    String key = "user:" + id;
    // 布隆过滤器判断不存在,直接返回 null
    if (!bloomFilterHelper.mightContain(key)) {
        return null;
    }
    // 后续走正常缓存 + 数据库查询
    return getUserById(id);
}
📊 效果对比:缓存空对象方案实现简单,但会消耗额外内存;布隆过滤器方案内存占用极低(100 万条数据仅需约 1.2MB),但存在一定的误判率(可能把不存在的 key 误判为存在,导致少量穿透)。生产环境通常两者结合使用:布隆过滤器做第一层拦截,缓存空对象做第二层兜底。

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

缓存击穿针对的是单个热点 key。当这个 key 在缓存中过期的一瞬间,大量并发请求同时涌入,全部穿透到数据库。解决方案的核心思路是:让同一时刻只有一个线程去加载数据。

4.3.1 方案一:互斥锁(Mutex Lock)

在缓存失效时,使用分布式锁(如 Redis SETNX)控制只有一个线程去查询数据库并重建缓存,其他线程等待或重试。

public String getHotData(String key) {
    // 1. 尝试从缓存获取
    String value = redisTemplate.opsForValue().get(key);
    if (value != null) {
        return value;
    }
    // 2. 缓存未命中,尝试获取分布式锁
    String lockKey = "lock:" + key;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    if (Boolean.TRUE.equals(locked)) {
        // 3. 获取锁成功,再次检查缓存(双重检测)
        value = redisTemplate.opsForValue().get(key);
        if (value != null) {
            redisTemplate.delete(lockKey);
            return value;
        }
        // 4. 查询数据库并重建缓存
        value = queryFromDB(key);
        redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS);
        // 5. 释放锁
        redisTemplate.delete(lockKey);
    } else {
        // 6. 获取锁失败,等待后重试
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getHotData(key); // 递归重试
    }
    return value;
}

4.3.2 方案二:逻辑过期(主动刷新)

不设置物理过期时间,而是在缓存数据中额外存储一个逻辑过期时间。当发现逻辑过期时,由后台线程异步刷新缓存,前台请求仍然返回旧数据。

// 缓存数据结构:value + 逻辑过期时间
public class CacheData<T> {
    private T data;
    private long expireTime; // 逻辑过期时间戳
}

// 异步刷新线程
public String getHotDataAsync(String key) {
    CacheData<String> cacheData = redisTemplate.opsForValue().get(key);
    if (cacheData == null) {
        // 缓存不存在,同步加载
        return loadDataSync(key);
    }
    // 检查逻辑是否过期
    if (cacheData.getExpireTime() > System.currentTimeMillis()) {
        return cacheData.getData(); // 未过期,直接返回
    }
    // 逻辑过期:异步刷新,返回旧数据
    asyncExecutor.submit(() -> {
        String newData = queryFromDB(key);
        CacheData<String> newCache = new CacheData<>(newData, System.currentTimeMillis() + 3600000);
        redisTemplate.opsForValue().set(key, newCache);
    });
    return cacheData.getData(); // 返回旧数据
}
💡 选型建议:互斥锁方案能保证数据强一致性,但会阻塞请求,适合对一致性要求高的场景;逻辑过期方案性能更好(无阻塞),但会短暂返回旧数据,适合允许最终一致性的场景。

4.4 缓存雪崩:过期时间分散与高可用

缓存雪崩的成因有两个:一是大量 key 在同一时间过期,二是缓存节点宕机。前者可以通过过期时间随机化解决,后者需要缓存集群高可用方案。

4.4.1 过期时间随机化

在设置缓存过期时间时,在基础过期时间上增加一个随机偏移量,避免大量 key 同时过期。

// 基础过期时间 1 小时,随机偏移 ±10 分钟
long baseExpire = 3600;
long randomOffset = ThreadLocalRandom.current().nextLong(-600, 600);
redisTemplate.opsForValue().set(key, value, baseExpire + randomOffset, TimeUnit.SECONDS);

4.4.2 缓存集群高可用

针对缓存节点宕机导致的雪崩,需要从架构层面保障缓存层的可用性:

  • Redis 主从 + 哨兵:自动故障转移,主节点宕机后从节点自动提升为主。
  • Redis Cluster:数据分片 + 自动故障转移,单个节点宕机不影响整体服务。
  • 本地缓存兜底:在 Redis 完全不可用时,使用 Caffeine 等本地缓存作为二级缓存,保证核心业务不中断。

4.5 三种异常解决方案全景图

下面的 SVG 图展示了从请求入口到数据库的完整链路,以及每种异常对应的拦截位置和解决方案:

缓存穿透 / 击穿 / 雪崩 解决方案全景图 客户端请求 布隆过滤器(穿透拦截) Redis 缓存层 互斥锁 / 逻辑过期 (击穿防护) 数据库 过期时间随机化 (雪崩预防) 📌 分层防护策略 1. 第一层(入口):布隆过滤器 → 拦截 99% 以上的穿透请求 2. 第二层(缓存):过期时间随机化 + 缓存空对象 → 预防雪崩和穿透 3. 第三层(热点):互斥锁 / 逻辑过期 → 防止击穿 4. 第四层(兜底):数据库连接池限流 + 熔断 → 保护数据库

4.6 工程实践:Spring Boot 整合方案

在 Spring Boot 项目中,推荐使用 Spring Cache + Redis + Redisson 的组合,通过注解和配置快速集成上述方案:

// 1. 引入依赖(pom.xml)
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.27.0</version>
</dependency>

// 2. 配置类:启用缓存 + 自定义过期时间随机化
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofSeconds(3600 + ThreadLocalRandom.current().nextLong(-600, 600))) // 随机化
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
        return RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .build();
    }

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        return Redisson.create(config);
    }
}

// 3. 使用 @Cacheable 注解,结合 Redisson 分布式锁防止击穿
@Service
public class UserService {

    @Autowired
    private RedissonClient redissonClient;

    @Cacheable(value = "user", key = "#id", unless = "#result == null")
    public User getUserById(Long id) {
        // 使用 Redisson 的锁机制防止缓存击穿
        RLock lock = redissonClient.getLock("lock:user:" + id);
        try {
            if (lock.tryLock(2, 10, TimeUnit.SECONDS)) {
                return userMapper.selectById(id);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
        return null;
    }
}
📈 性能调优建议:
  • 穿透防护:布隆过滤器建议在应用启动时预热,使用 Guava 的 BloomFilter 或 Redisson 的 RBloomFilter。
  • 击穿防护:互斥锁的超时时间应大于数据库查询耗时 + 缓存重建耗时,避免锁提前释放导致重复加载。
  • 雪崩防护:除了过期时间随机化,还可以使用“二级缓存”策略(Caffeine + Redis),本地缓存扛住第一波流量。
  • 监控告警:对缓存命中率、数据库 QPS、锁等待时间设置监控阈值,及时发现异常。