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;
}
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);
}
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 图展示了从请求入口到数据库的完整链路,以及每种异常对应的拦截位置和解决方案:
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、锁等待时间设置监控阈值,及时发现异常。