1. 面试场景还原:当谢飞机遭遇缓存击穿
"说说Redis缓存击穿的解决方案"——这个看似平常的面试题背后,藏着微服务架构中最致命的性能杀手之一。去年我在电商大促期间就曾遇到过这样的生产事故:某热门商品页面QPS突然暴跌,数据库CPU飙升到98%,整个微服务链路雪崩。而这一切的始作俑者,正是缓存击穿。
缓存击穿(Cache Breakdown)特指某个热点key过期瞬间,突发海量请求直接穿透缓存层压垮数据库的现象。与缓存穿透(访问不存在的key)不同,击穿针对的是真实存在但暂时过期的热点数据。在Spring Boot微服务架构中,这个问题会被放大——服务拆分后各模块依赖缓存更甚,一次击穿可能引发级联故障。
关键区别:缓存穿透是查不存在的数据,缓存击穿是查存在但过期的热点数据
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Redis缓存击穿的三大核心防御策略
2.1 互斥锁方案:分布式锁的精细控制
在Spring Boot中最直接的解决方案是通过Redis分布式锁实现互斥访问。当缓存失效时,不是所有线程都去查库,而是让第一个线程加锁查库重建缓存,其他线程等待或轮询:
java复制public Product getProduct(String id) {
String cacheKey = "product:" + id;
// 1.先查缓存
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product == null) {
// 2.获取分布式锁
String lockKey = "lock:" + cacheKey;
try {
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (locked) {
// 3.二次检查缓存(防止重复查询)
product = redisTemplate.opsForValue().get(cacheKey);
if (product == null) {
// 4.查数据库并重建缓存
product = productMapper.selectById(id);
redisTemplate.opsForValue().set(
cacheKey, product, 1, TimeUnit.HOURS);
}
} else {
// 5.未获取锁的线程短暂休眠后重试
Thread.sleep(100);
return getProduct(id);
}
} finally {
redisTemplate.delete(lockKey);
}
}
return product;
}
避坑指南:
- 锁过期时间要大于数据库查询时间但不宜过长(建议10-30秒)
- 必须设置finally块释放锁,否则会导致死锁
- 双重检查缓存可避免获得锁后重复查询
2.2 逻辑过期方案:空间换时间的艺术
对于极致性能场景,可以采用"逻辑过期"方案——实际不设置Redis过期时间,而是在value中嵌入过期时间戳:
java复制@Data
public class RedisData<T> {
private LocalDateTime expireTime;
private T data;
}
// 写入缓存时
RedisData<Product> redisData = new RedisData<>();
redisData.setData(product);
redisData.setExpireTime(LocalDateTime.now().plusHours(1));
redisTemplate.opsForValue().set(cacheKey, redisData);
// 读取时判断逻辑过期
RedisData<Product> redisData = redisTemplate.opsForValue().get(cacheKey);
if (redisData == null) {
return null;
}
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
return redisData.getData();
}
// 异步重建缓存...
适用场景:
- 读多写少的热点数据
- 可以接受短暂数据不一致
- 需要避免任何请求直接穿透到DB
2.3 缓存预热与多级过期策略
在微服务启动时,通过Spring Boot的ApplicationRunner预热热点数据:
java复制@Bean
public ApplicationRunner cachePreloader() {
return args -> {
List<Product> hotProducts = productMapper.selectHotProducts();
hotProducts.forEach(p -> {
String key = "product:" + p.getId();
redisTemplate.opsForValue().set(
key, p,
// 基础过期时间 + 随机偏移量(避免集体失效)
60 + new Random().nextInt(30),
TimeUnit.MINUTES);
});
};
}
多级过期策略要点:
- 核心数据设置较长TTL(如24小时)
- 非核心数据设置较短TTL(如30分钟)
- 所有key增加随机过期时间偏移量(±10%)
3. Spring Boot中的工程化实践
3.1 注解式解决方案:@Cacheable的增强
通过自定义CacheManager实现注解级别的防护:
java复制@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheManager cacheManager = RedisCacheManager.builder(factory)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues()
.serializeValuesWith(SerializationPair.fromSerializer(
new Jackson2JsonRedisSerializer<>(Object.class))))
.transactionAware()
.build();
// 装饰器模式添加防击穿逻辑
return new BreakdownProtectedCacheManager(cacheManager);
}
}
public class BreakdownProtectedCacheManager implements CacheManager {
// 实现逻辑过期和互斥锁组合策略...
}
3.2 微服务间的缓存一致性
在微服务架构下,还需要考虑缓存更新问题。推荐两种模式:
- 消息队列通知:
java复制@RabbitListener(queues = "product.update")
public void handleProductUpdate(ProductUpdateMsg msg) {
String cacheKey = "product:" + msg.getProductId();
redisTemplate.delete(cacheKey);
// 可选用:立即重建缓存或等待下次查询懒加载
}
- Binlog监听:
通过Canal监听数据库变更,实时更新缓存。这种方案对业务代码无侵入,但架构复杂度较高。
4. 压力测试与监控体系
4.1 JMeter测试方案设计
构建三种测试场景:
- 正常流量(缓存命中率>95%)
- 缓存突然失效(模拟击穿场景)
- 防御策略生效状态
关键监控指标:
- 数据库QPS
- 接口平均响应时间
- Redis连接数
- 线程阻塞数量
4.2 Prometheus监控指标暴露
通过Spring Boot Actuator暴露自定义指标:
java复制@Bean
public MeterRegistryCustomizer<MeterRegistry> metrics() {
return registry -> {
Gauge.builder("cache.breakdown.protection",
() -> getCurrentLockCount())
.description("当前防击穿锁数量")
.register(registry);
};
}
推荐监控看板配置:
- 缓存命中率波动报警(<90%触发)
- 数据库查询突增报警
- 分布式锁等待时间百分位
5. 高级防御:热点探测与动态防护
对于秒杀等极端场景,需要更智能的方案:
5.1 热点Key实时探测
java复制// 通过AOP统计key访问频次
@Aspect
@Component
public class HotKeyDetector {
private final ConcurrentHashMap<String, AtomicLong> counter
= new ConcurrentHashMap<>();
@Around("@annotation(cacheable)")
public Object detectHotKey(ProceedingJoinPoint pjp, Cacheable cacheable) {
String key = parseKey(pjp, cacheable);
counter.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
// 定期将counter数据上报到监控系统...
return pjp.proceed();
}
}
5.2 本地缓存兜底
使用Caffeine作为二级缓存:
java复制@Bean
public CaffeineCacheManager caffeineCacheManager() {
Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats();
return new CaffeineCacheManager("productCache", caffeine);
}
多级缓存访问策略:
- 先查Redis
- 不存在则查本地缓存
- 最后才查数据库
我在实际项目中发现,当Redis集群出现网络分区时,这种架构能保证核心业务继续运行,虽然数据可能不是最新的,但比完全不可用要好得多。特别是在微服务环境下,某个缓存集群的故障不应该导致整个系统崩溃。
