文章目录
- 引言
- 一、缓存穿透:恶意请求直击数据库
- 二、缓存击穿:热点Key失效引发的灾难
- 三、缓存雪崩:大规模缓存失效的连锁反应
- 四、实战:缓存防护体系
- 五、缓存稳定性保障最佳实践
-
- [1. 缓存命中率监控](#1. 缓存命中率监控)
- [2. 缓存服务压测](#2. 缓存服务压测)
- [3. 关键指标监控体系](#3. 关键指标监控体系)
- 六、总结

引言
在高并发的应用系统中,缓存是提升系统性能、减轻数据库压力的关键技术。然而,缓存并非万能药,如果使用不当,反而会带来严重的系统风险。缓存穿透、缓存击穿和缓存雪崩这三大问题,是每个开发者在设计缓存系统时必须认真考虑的挑战。
一、缓存穿透:恶意请求直击数据库
问题本质
缓存穿透是指业务请求穿过了缓存层,直接落到持久化存储上。当大量请求访问不存在的数据时,由于缓存中没有对应数据,这些请求会直接打到数据库,可能导致数据库压力剧增甚至崩溃。
典型场景:
- 恶意攻击者利用不存在的ID发起大量请求
- 系统存在大量"垃圾数据"查询
- 缓存与数据库数据不一致
实战解决
客户端 布隆过滤器 缓存层 (Redis) 数据库 请求 Key: user_666 检查 Key 是否可能存在 不存在,直接拦截 请求 Key: user_123 Key 可能存在 查询 Key: user_123 未命中 (null) 查询数据库 数据不存在 写入空对象 (TTL: 5min) 防止后续穿透 返回 null 再次请求 Key: user_123 查询 Key: user_123 命中空对象 返回 null (来自缓存) 客户端 布隆过滤器 缓存层 (Redis) 数据库
方案1:缓存空对象(Null Value Caching)
java
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductRepository productRepository;
// 缓存空对象的过期时间(短于正常数据)
private static final long NULL_CACHE_EXPIRE_SECONDS = 60;
// 防止缓存穿透的特殊标记
private static final String NULL_VALUE = "NULL";
public Product getProductById(String productId) {
// 1. 先从缓存中获取
String cacheKey = "product:" + productId;
Object cacheValue = redisTemplate.opsForValue().get(cacheKey);
// 2. 缓存存在且不是空标记,直接返回
if (cacheValue != null) {
if (NULL_VALUE.equals(cacheValue)) {
return null; // 返回空对象
}
return (Product) cacheValue;
}
// 3. 缓存不存在,查询数据库
Product product = productRepository.findById(productId).orElse(null);
// 4. 数据库存在,缓存结果
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
return product;
}
// 5. 数据库不存在,缓存空对象
redisTemplate.opsForValue().set(cacheKey, NULL_VALUE, NULL_CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS);
return null;
}
}
关键点:
- 使用特殊标记(如"NULL")表示空值,避免与正常数据混淆
- 空对象的过期时间应短于正常数据,防止数据长期不一致
- 注意内存占用问题,避免缓存大量空值
方案2:布隆过滤器(Bloom Filter)
布隆过滤器是一种空间效率极高的概率型数据结构,可以判断一个元素一定不存在 或可能存在。
java
@Configuration
public class BloomFilterConfig {
// 假设系统中商品总数约为100万
private static final int EXPECTED_INSERTIONS = 1_000_000;
// 误判率控制在3%
private static final double FALSE_POSITIVE_RATE = 0.03;
@Bean
public BloomFilter<String> productBloomFilter() {
return BloomFilter.create(
Funnels.stringFunnel(Charsets.UTF_8),
EXPECTED_INSERTIONS,
FALSE_POSITIVE_RATE
);
}
@Bean
public CommandLineRunner bloomFilterInitializer(
BloomFilter<String> bloomFilter,
ProductRepository productRepository) {
return args -> {
// 系统启动时,将所有商品ID加载到布隆过滤器
productRepository.findAllIds().forEach(id -> bloomFilter.put(id));
System.out.println("布隆过滤器初始化完成,已加载" + EXPECTED_INSERTIONS + "个商品ID");
};
}
}
java
@Service
public class ProductService {
@Autowired
private BloomFilter<String> productBloomFilter;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductRepository productRepository;
public Product getProductById(String productId) {
// 1. 先通过布隆过滤器判断
if (!productBloomFilter.mightContain(productId)) {
// 肯定不存在,直接返回null
return null;
}
// 2. 布隆过滤器说可能存在,继续检查缓存
String cacheKey = "product:" + productId;
Object cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
return (Product) cacheValue;
}
// 3. 缓存不存在,查询数据库
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
} else {
// 为防止缓存穿透,缓存空值(可选)
redisTemplate.opsForValue().set(cacheKey, "NULL", 60, TimeUnit.SECONDS);
}
return product;
}
}
关键点:
- 布隆过滤器初始化时需要加载所有可能存在的数据ID
- 选择合适的误判率和容量,平衡内存使用和准确性
- 适用于数据集合相对固定、新增数据不频繁的场景
二、缓存击穿:热点Key失效引发的灾难
问题本质
缓存击穿是指大量请求同时访问某个热点Key,而这个Key恰好在此时失效,导致所有请求直接打到数据库上。这与二八定律密切相关------系统中20%的热点数据往往承担了80%的访问量。
典型场景:
- 电商大促期间的秒杀商品
- 突发热点事件相关的数据
- 首页推荐内容
实战解决方案
客户端 A 客户端 B 缓存层 (Redis) 分布式锁 数据库 热点Key: product_100 已过期 获取 product_100 未命中 获取 product_100 未命中 尝试获取锁 (lock:product_100) 获取成功 尝试获取锁 (lock:product_100) 获取失败 短暂休眠,稍后重试 查询数据库 返回数据 写入新数据 (带随机TTL) 写入成功 释放锁 释放成功 返回数据 重试获取 product_100 命中! 返回数据 客户端 A 客户端 B 缓存层 (Redis) 分布式锁 数据库
方案1:互斥锁重建缓存
java
@Service
public class HotProductService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductRepository productRepository;
// 使用ConcurrentHashMap作为本地锁容器
private final Map<String, Object> localLockMap = new ConcurrentHashMap<>();
public Product getHotProduct(String productId) {
String cacheKey = "hot:product:" + productId;
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 缓存不存在,尝试获取本地锁
Object lock = localLockMap.computeIfAbsent(cacheKey, k -> new Object());
synchronized (lock) {
try {
// 双重检查,防止多个线程同时重建缓存
product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 从数据库加载数据
product = productRepository.findById(productId)
.orElseThrow(() -> new RuntimeException("Product not found"));
// 设置缓存,注意设置随机过期时间
long expireTime = 300 + new Random().nextInt(300); // 5-10分钟随机
redisTemplate.opsForValue().set(cacheKey, product, expireTime, TimeUnit.SECONDS);
return product;
} finally {
// 移除本地锁(注意:不能直接remove,可能导致并发问题)
localLockMap.computeIfPresent(cacheKey, (k, v) -> null);
}
}
}
}
方案2:逻辑过期(永不过期策略)
java
// 自定义缓存值结构,包含数据和逻辑过期时间
@Data
public class CacheData<T> {
private T data;
private long expireTime; // 逻辑过期时间戳
}
@Service
public class HotProductService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductRepository productRepository;
// 逻辑过期时间,比如设置为10分钟
private static final long LOGICAL_EXPIRE_TIME = 600;
public Product getHotProductWithLogicalExpire(String productId) {
String cacheKey = "hot:product:logical:" + productId;
CacheData<Product> cacheData = (CacheData<Product>) redisTemplate.opsForValue().get(cacheKey);
// 缓存存在且未逻辑过期,直接返回
if (cacheData != null && cacheData.getExpireTime() > System.currentTimeMillis()) {
return cacheData.getData();
}
// 缓存不存在或已过期,尝试重建缓存
if (cacheData == null || cacheData.getExpireTime() <= System.currentTimeMillis()) {
// 使用Redis分布式锁,防止缓存重建时的并发问题
String lockKey = "lock:" + cacheKey;
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "LOCK", 3, TimeUnit.SECONDS);
if (locked) {
try {
// 重新检查,防止其他线程已经重建了缓存
cacheData = (CacheData<Product>) redisTemplate.opsForValue().get(cacheKey);
if (cacheData == null || cacheData.getExpireTime() <= System.currentTimeMillis()) {
// 从数据库加载数据
Product product = productRepository.findById(productId)
.orElseThrow(() -> new RuntimeException("Product not found"));
// 设置新的逻辑过期时间
long newExpireTime = System.currentTimeMillis() + LOGICAL_EXPIRE_TIME * 1000;
CacheData<Product> newCacheData = new CacheData<>();
newCacheData.setData(product);
newCacheData.setExpireTime(newExpireTime);
// 永久缓存(实际设置较长过期时间)
redisTemplate.opsForValue().set(cacheKey, newCacheData, 24, TimeUnit.HOURS);
}
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}
}
// 无论是否成功重建缓存,都返回当前缓存中的数据(可能过期)
cacheData = (CacheData<Product>) redisTemplate.opsForValue().get(cacheKey);
return cacheData != null ? cacheData.getData() : null;
}
}
关键点:
- 互斥锁方案简单但可能造成请求阻塞
- 逻辑过期方案能保证服务可用性,但可能返回短暂过期数据
- 对于极高并发的热点Key,建议结合二级缓存(本地缓存+分布式缓存)
三、缓存雪崩:大规模缓存失效的连锁反应
问题本质
缓存雪崩是指大量缓存数据在同一时刻失效 ,或者缓存服务整体不可用,导致所有请求直接打到数据库,可能引发整个系统崩溃。
典型场景:
- 系统启动时大量缓存同时设置相同过期时间
- Redis集群故障或网络问题
- 缓存预热不足导致大流量涌入
Java实战解决方案
客户端 本地缓存 (Caffeine) 分布式缓存 (Redis) 熔断器 (Hystrix/Sentinel) 数据库 请求数据 (Key: config_001) 查询本地缓存 未命中 查询分布式缓存 未命中 (可能已集体过期) 查询数据库 返回数据 写入数据 (TTL: 1h + 随机 0-10min) 写入成功 回填本地缓存 (TTL: 5min) 回填成功 返回真实数据 保护数据库,快速失败 返回降级数据/错误 alt [系统健康 (熔断器关闭)] [系统过载 (熔断器打开)] 客户端 本地缓存 (Caffeine) 分布式缓存 (Redis) 熔断器 (Hystrix/Sentinel) 数据库
方案1:差异化过期时间
java
@Service
public class CacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 基础过期时间(分钟)
private static final int BASE_EXPIRE_MINUTES = 30;
// 随机延长范围(分钟)
private static final int RANDOM_EXTEND_MINUTES = 10;
public void setCacheWithRandomExpire(String key, Object value) {
// 随机生成额外的过期时间(0-10分钟)
int randomExtra = new Random().nextInt(RANDOM_EXTEND_MINUTES);
int totalExpireMinutes = BASE_EXPIRE_MINUTES + randomExtra;
redisTemplate.opsForValue().set(key, value, totalExpireMinutes, TimeUnit.MINUTES);
}
}
方案2:缓存高可用架构
在Spring Boot中配置Redis Cluster:
yaml
# application.yml
spring:
redis:
cluster:
nodes:
- redis-node1:6379
- redis-node2:6379
- redis-node3:6379
- redis-node4:6379
- redis-node5:6379
- redis-node6:6379
lettuce:
cluster:
refresh:
adaptive: true
period: 30s
pool:
max-active: 200
max-wait: -1ms
max-idle: 10
min-idle: 5
方案3:服务降级与熔断
使用Resilience4j实现服务降级:
java
@Service
public class ProductServiceWithFallback {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductRepository productRepository;
private final CircuitBreaker circuitBreaker;
public ProductServiceWithFallback() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率阈值
.waitDurationInOpenState(Duration.ofMillis(1000)) // 熔断后等待时间
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.slidingWindowSize(5) // 滑动窗口大小
.build();
circuitBreaker = CircuitBreaker.of("productService", config);
}
public Product getProductWithFallback(String productId) {
Supplier<Product> supplier = CircuitBreaker.decorateSupplier(circuitBreaker, () -> {
String cacheKey = "product:" + productId;
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 缓存不存在,查询数据库
product = productRepository.findById(productId).orElse(null);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
}
return product;
});
// 当熔断开启时,使用降级策略
Try<Product> result = Try.ofSupplier(supplier)
.recover(throwable -> {
// 熔断或异常时,返回降级数据
return getFallbackProduct(productId);
});
return result.get();
}
private Product getFallbackProduct(String productId) {
// 可以返回默认产品、空对象或从其他渠道获取数据
Product fallbackProduct = new Product();
fallbackProduct.setId(productId);
fallbackProduct.setName("商品信息加载中...");
fallbackProduct.setPrice(0);
fallbackProduct.setStock(0);
fallbackProduct.setFallback(true);
return fallbackProduct;
}
}
关键点:
- 差异化过期时间是预防缓存雪崩最简单有效的方法
- Redis Cluster是实现缓存高可用的行业标准方案
- 服务降级确保在缓存不可用时系统仍能提供基本服务
四、实战:缓存防护体系
让我们通过一个大促场景,整合应用上述所有技术方案:
java
/**
* 电商大促场景下的商品服务
* 集成缓存穿透、缓存击穿、缓存雪崩防护
*/
@Service
@RequiredArgsConstructor
public class FlashSaleService {
private final RedisTemplate<String, Object> redisTemplate;
private final ProductRepository productRepository;
private final BloomFilter<String> productBloomFilter;
private final CircuitBreaker circuitBreaker;
private final Map<String, Object> localLockMap = new ConcurrentHashMap<>();
// 缓存配置
private static final String PRODUCT_CACHE_PREFIX = "flashsale:product:";
private static final String NULL_CACHE_PREFIX = "flashsale:null:";
private static final long BASE_CACHE_EXPIRE_MINUTES = 30;
private static final long NULL_CACHE_EXPIRE_SECONDS = 60;
private static final String NULL_VALUE = "NULL";
/**
* 获取秒杀商品信息(完整防护方案)
*/
public Product getFlashSaleProduct(String productId) {
// 1. 布隆过滤器检查(防缓存穿透第一道防线)
if (!productBloomFilter.mightContain(productId)) {
return null;
}
// 2. 尝试从缓存获取
String cacheKey = PRODUCT_CACHE_PREFIX + productId;
Object cacheValue = redisTemplate.opsForValue().get(cacheKey);
// 3. 缓存存在且不是空值
if (cacheValue != null) {
if (NULL_VALUE.equals(cacheValue)) {
return null;
}
return (Product) cacheValue;
}
// 4. 缓存为空,使用熔断器保护数据库
return circuitBreaker.executeSupplier(() -> {
// 5. 互斥锁防止缓存击穿
Object lock = localLockMap.computeIfAbsent(cacheKey, k -> new Object());
synchronized (lock) {
try {
// 双重检查
cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
return (Product) (NULL_VALUE.equals(cacheValue) ? null : cacheValue);
}
// 6. 查询数据库
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
// 设置随机过期时间(防缓存雪崩)
int randomExtra = new Random().nextInt(10);
redisTemplate.opsForValue().set(
cacheKey,
product,
BASE_CACHE_EXPIRE_MINUTES + randomExtra,
TimeUnit.MINUTES
);
return product;
}
// 7. 数据库不存在,缓存空值(防缓存穿透)
redisTemplate.opsForValue().set(
cacheKey,
NULL_VALUE,
NULL_CACHE_EXPIRE_SECONDS,
TimeUnit.SECONDS
);
return null;
} finally {
localLockMap.remove(cacheKey);
}
}
}, throwable -> {
// 8. 熔断降级处理
return getFallbackProduct(productId);
});
}
private Product getFallbackProduct(String productId) {
Product fallbackProduct = new Product();
fallbackProduct.setId(productId);
fallbackProduct.setName("秒杀商品加载中...");
fallbackProduct.setPrice(0);
fallbackProduct.setStock(0);
fallbackProduct.setFallback(true);
return fallbackProduct;
}
/**
* 缓存预热(大促前执行)
*/
@PostConstruct
public void warmUpCache() {
List<String> hotProductIds = productRepository.findTop100HotProducts();
hotProductIds.forEach(productId -> {
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
// 预热缓存,设置随机过期时间
int randomExtra = new Random().nextInt(10);
redisTemplate.opsForValue().set(
PRODUCT_CACHE_PREFIX + productId,
product,
BASE_CACHE_EXPIRE_MINUTES + randomExtra,
TimeUnit.MINUTES
);
}
});
System.out.println("缓存预热完成,已加载" + hotProductIds.size() + "个热门商品");
}
}
五、缓存稳定性保障最佳实践
1. 缓存命中率监控
java
/**
* 缓存命中率监控组件
*/
@Component
public class CacheMonitor {
private final AtomicLong cacheHits = new AtomicLong(0);
private final AtomicLong cacheMisses = new AtomicLong(0);
@Autowired
public CacheMonitor(CacheManager cacheManager) {
// 注册缓存事件监听器
if (cacheManager instanceof RedisCacheManager) {
RedisCacheManager redisCacheManager = (RedisCacheManager) cacheManager;
redisCacheManager.getCacheWriter().getRedisConnectionFactory()
.getConnection()
.setKeySerializer(new StringRedisSerializer());
redisCacheManager.getCacheWriter().getRedisConnectionFactory()
.getConnection()
.setValueSerializer(new JdkSerializationRedisSerializer());
}
}
public void recordHit() {
cacheHits.incrementAndGet();
}
public void recordMiss() {
cacheMisses.incrementAndGet();
}
public double getHitRate() {
long hits = cacheHits.get();
long misses = cacheMisses.get();
long total = hits + misses;
return total == 0 ? 0 : (double) hits / total;
}
@Scheduled(fixedRate = 60000)
public void logHitRate() {
double hitRate = getHitRate();
System.out.println("缓存命中率: " + String.format("%.2f%%", hitRate * 100));
// 命中率低于阈值时告警
if (hitRate < 0.9) {
System.out.println("警告:缓存命中率低于90%!");
// 实际应用中可以发送邮件或短信告警
}
}
}
2. 缓存服务压测
在生产环境部署前,务必对缓存服务进行压力测试:
java
/**
* Redis压测工具类
*/
public class RedisStressTest {
private static final int THREAD_COUNT = 50;
private static final int REQUESTS_PER_THREAD = 1000;
public static void main(String[] args) {
String redisHost = "localhost";
int redisPort = 6379;
try (JedisPool jedisPool = new JedisPool(redisHost, redisPort)) {
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
long startTime = System.currentTimeMillis();
for (int i = 0; i < THREAD_COUNT; i++) {
executor.submit(() -> {
try (Jedis jedis = jedisPool.getResource()) {
for (int j = 0; j < REQUESTS_PER_THREAD; j++) {
String key = "test:key:" + j;
String value = "value:" + j;
// SET操作
jedis.set(key, value);
// GET操作
jedis.get(key);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
});
}
latch.await();
long endTime = System.currentTimeMillis();
long totalTime = endTime - startTime;
long totalRequests = THREAD_COUNT * REQUESTS_PER_THREAD * 2; // SET+GET
double qps = (double) totalRequests / (totalTime / 1000.0);
System.out.println("测试完成,总请求数: " + totalRequests);
System.out.println("总耗时: " + totalTime + "ms");
System.out.println("QPS: " + String.format("%.2f", qps));
executor.shutdown();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
3. 关键指标监控体系
指标类别 | 关键指标 | 健康阈值 | 监控频率 |
---|---|---|---|
缓存数据 | 缓存命中率 | ≥90% (大促≥99%) | 实时 |
缓存使用率 | ≤70% | 5分钟 | |
平均响应时间 | ≤5ms | 实时 | |
缓存服务 | CPU使用率 | ≤70% | 1分钟 |
内存使用率 | ≤80% | 1分钟 | |
连接数 | ≤最大连接数的80% | 1分钟 | |
持久化状态 | RDB/AOF正常 | 5分钟 |
六、总结
我们系统性地分析了缓存应用中的三大经典问题,并提供了基于Java生态的完整解决方案。作为Java开发者,在设计缓存系统时,应当牢记以下原则:
- 预防优于补救:在系统设计阶段就考虑缓存问题,而非等到问题发生
- 分层防御策略:针对不同问题场景,采用多层防护机制
- 数据驱动决策:通过监控指标指导缓存策略调整
- 场景化解决方案:没有银弹,需根据业务特点选择合适方案
特别提醒 :在实际项目中,曾见过不少团队过度依赖缓存,将缓存视为解决所有性能问题的万能钥匙。实际上,缓存只是系统性能优化的手段之一,合理的数据库设计、索引优化、代码重构同样重要。当你的缓存命中率持续低于70%时,或许应该先思考:是不是缓存策略有问题,还是根本不需要缓存这些数据?
