先回顾一个典型的业务场景。在一个促销活动中,商品详情页使用了Redis进行缓存加速,核心代码如下:
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, Product> redisTemplate;
@Autowired
private ProductMapper productMapper;
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 查缓存
Product product = redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
// 缓存没有,查数据库
product = productMapper.selectById(productId);
// 放入缓存,过期时间1小时
redisTemplate.opsForValue().set(key, product, 1, TimeUnit.HOURS);
return product;
}
}
问题现象:
- 促销活动开始,瞬时10万用户涌入访问商品详情页。
- 由于缓存过期时间统一设置为1小时,导致大量缓存键(Key)在同一时刻失效。
- 海量请求瞬间穿透缓存,直接击穿到数据库,导致数据库CPU使用率飙升至100%。
- 服务响应时间从平均50ms恶化到5秒以上,出现大量超时错误。
初步排查思路:
- 查看Redis监控,发现缓存命中率从95%骤降至10%。
- 查看数据库监控,QPS从平时100激增到10000。
- 应用日志中出现大量
RedisConnectionFailureException。
- 网络层面检查,Redis的Ping延迟正常。
初步判断:这看起来是典型的缓存雪崩,当时认为简单地给缓存过期时间加上随机值就能解决。
首次尝试:引入随机过期时间
第一次优化,我为缓存过期时间添加了随机偏移量,避免同时失效。
public Product getProduct(Long productId) {
String key = "product:" + productId;
Product product = redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
product = productMapper.selectById(productId);
// 加随机过期时间,避免同时过期
int randomTime = 3600 + new Random().nextInt(600); // 3600-4200秒
redisTemplate.opsForValue().set(key, product, randomTime, TimeUnit.SECONDS);
return product;
}
结果:缓存雪崩现象得到一定缓解,但数据库依然承受着巨大压力,频繁告警。
深入排查:揭开复合问题的面纱
单一的“加随机数”方案未能根治问题,促使我们进行更深入的排查。
第一步:分析Redis缓存命中率
通过Redis命令行工具查看核心指标,发现命中率极低。
$ redis-cli info stats | grep keyspace_hits
keyspace_hits:1000
$ redis-cli info stats | grep keyspace_misses
keyspace_misses:9000
// 命中率 = 1000 / (1000 + 9000) = 10%
第二步:分析Key分布
使用--hotkeys命令试图找出热点Key,并使用keys命令(生产环境慎用)分析特定模式的Key数量,发现可能存在大量对不存在的商品ID的查询。
第三步:分析数据库慢查询
检查MySQL慢查询日志,发现了大量查询不存在的商品ID的语句,例如:
SELECT * FROM product WHERE id = 99999; -- 0 rows
真相浮出水面:
- 缓存穿透:大量请求查询数据库中根本不存在的数据(如无效商品ID),缓存无法命中,请求直达数据库。
- 缓存雪崩:大量Key同时过期,请求并发查询数据库。
- 热点Key失效:热点商品的缓存过期后,瞬间收到海量查询请求。
在缓存穿透、雪崩、热点失效的三重打击下,数据库最终不堪重负。
根源分析:不完善的缓存设计
回头审视最初的代码,发现了几个关键的设计缺陷:
public Product getProduct(Long productId) {
...
product = productMapper.selectById(productId);
// 问题1:无论商品是否存在,都进行缓存
// 问题2:对于不存在的商品,也设置了长达1小时的缓存,且缓存的是null
redisTemplate.opsForValue().set(key, product, 1, TimeUnit.HOURS);
return product;
}
主要问题包括:无防穿透机制、未使用布隆过滤器、缓存过期时间策略单一、未合理缓存空值。
综合治理方案
方案1:缓存空值(防御穿透)
对数据库中不存在的数据,也进行短时间缓存,避免反复穿透。
public Product getProduct(Long productId) {
String key = "product:" + productId;
Product product = redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
product = productMapper.selectById(productId);
if (product != null) {
// 商品存在,缓存1小时(加随机时间)
int randomTime = 3600 + new Random().nextInt(600);
redisTemplate.opsForValue().set(key, product, randomTime, TimeUnit.SECONDS);
} else {
// 商品不存在,缓存一个空对象5分钟,拦截后续穿透请求
redisTemplate.opsForValue().set(key, new Product(), 300, TimeUnit.SECONDS);
}
return product;
}
方案2:引入布隆过滤器(终极防穿透)
在查询缓存和数据库前,先用布隆过滤器判断数据是否存在,可以高效拦截大量无效请求。在使用Java和Spring Boot构建后端服务时,引入此类组件需注意初始化时机。
@Service
public class ProductService {
private BloomFilter<Long> productBloomFilter;
@PostConstruct
public void initBloomFilter() {
// 初始化布隆过滤器,预计100万商品,误判率1%
productBloomFilter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01);
// 预热:加载所有已存在的商品ID
List<Long> allProductIds = productMapper.selectAllIds();
for (Long id : allProductIds) {
productBloomFilter.put(id);
}
}
public Product getProduct(Long productId) {
// 先过布隆过滤器
if (!productBloomFilter.mightContain(productId)) {
return null; // 大概率不存在,直接返回
}
// ... 后续缓存查询逻辑
}
}
依赖:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
方案3:构建多级缓存(缓解雪崩)
引入本地缓存(如Caffeine)作为一级缓存,Redis作为二级缓存。即使Redis集群出现波动或Key大面积失效,本地缓存也能抵挡大部分请求,为系统提供缓冲。
@Service
public class ProductService {
@Autowired
private Cache<String, Product> localCache; // Caffeine本地缓存
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 1. 查本地缓存
Product product = localCache.getIfPresent(key);
if (product != null) return product;
// 2. 查Redis缓存
product = redisTemplate.opsForValue().get(key);
if (product != null) {
localCache.put(key, product); // 回填本地缓存
return product;
}
// 3. 查数据库并回填两级缓存...
return product;
}
}
本地缓存配置示例:
@Bean
public Cache<String, Product> localCache() {
return Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
}
方案4:熔断降级(保障最终可用性)
当数据库压力过大或响应超时时,启动熔断机制,快速失败并返回降级数据(如默认商品、友好提示),保护下游数据库和中间件不被拖垮。
public Product getProduct(Long productId) {
CircuitBreaker circuitBreaker = circuitBreakerFactory.create("product");
return circuitBreaker.run(() -> {
// 正常业务逻辑
return getProductFromCache(productId);
}, throwable -> {
// 降级逻辑
return new Product(); // 返回降级/默认商品
});
}
熔断器配置(Resilience4j示例):
resilience4j:
circuitbreaker:
instances:
product:
failureRateThreshold: 60
waitDurationInOpenState: 10000ms
ringBufferSizeInClosedState: 100
监控与告警闭环
建立完善的监控体系至关重要,除了监控缓存命中率,还需关注数据库和中间件如Redis的连接数、内存使用率等。
缓存命中率监控示例:
@Component
public class CacheMetrics {
@Scheduled(fixedRate = 60000)
public void reportCacheMetrics() {
// 获取Redis info stats信息,计算命中率
// 命中率 = keyspace_hits / (keyspace_hits + keyspace_misses)
// 可将此数据上报至监控系统(如Prometheus)
}
}
Prometheus告警规则示例:
groups:
- name: cache
rules:
- alert: CacheHitRateLow
expr: cache_hit_rate < 0.5
for: 5m
labels:
severity: warning
annotations:
summary: “缓存命中率低于50%”
总结与实践模板
排查四步法:
- 看命中率:
redis-cli info stats,命中率低于50%需警惕。
- 看Key分布:分析热点Key和大Key(生产环境慎用
keys *,建议用scan)。
- 看数据库:检查慢查询日志,识别异常查询模式。
- 看系统资源:监控Redis、数据库的连接数、CPU、网络IO。
防御组合拳(按优先级):
- 必须做:缓存空值 + 随机过期时间 + 布隆过滤器。
- 建议做:多级本地缓存 + 熔断降级 + 请求限流。
- 可选做:缓存预热 + 热点Key探测与分离。
终极版缓存模板:
public Product getProduct(Long productId) {
// 1. 布隆过滤器拦截
if (!bloomFilter.mightContain(productId)) return null;
String key = "product:" + productId;
// 2. 本地缓存
Product product = localCache.getIfPresent(key);
if (product != null) return product;
// 3. Redis缓存
product = redisTemplate.opsForValue().get(key);
if (product != null) {
localCache.put(key, product);
return product;
}
// 4. 数据库查询(带熔断保护)
try {
product = circuitBreaker.run(() -> productMapper.selectById(productId));
// 5. 回填缓存
if (product != null) {
int randomTime = 3600 + new Random().nextInt(600);
redisTemplate.opsForValue().set(key, product, randomTime, TimeUnit.SECONDS);
localCache.put(key, product);
} else {
redisTemplate.opsForValue().set(key, new Product(), 300, TimeUnit.SECONDS);
}
return product;
} catch (Exception e) {
// 降级逻辑
return new Product();
}
}
缓存问题往往是“穿透、雪崩、击穿”的复合体。抓住 “缓存空值、随机过期、布隆过滤” 这三个核心点,能解决绝大部分问题。剩余的极端场景,则由 “多级缓存、熔断降级” 体系来兜底,从而构建起一个健壮、高性能的缓存系统。