Redis缓存击穿问题诊断与现象识别
Redis缓存击穿是指某个热点key在过期的瞬间,大量并发请求同时穿透缓存直接打到数据库,导致数据库瞬间压力激增甚至连接池耗尽。与缓存穿透(查询不存在的key)和缓存雪崩(大量key同时过期)不同,缓存击穿针对的是单个热点key的并发访问场景。
典型的缓存击穿场景:电商系统中商品详情页的缓存key在过期时恰好遇到促销活动的流量峰值,数百个请求同时发现缓存失效,全部涌向数据库执行相同的查询SQL。数据库连接池在几秒内被占满,后续请求超时,最终触发服务降级。
诊断缓存击穿的方法:检查Redis监控中特定key的miss率是否在某个时间点突然飙升;查看数据库慢查询日志中是否出现大量相同的SQL在短时间内重复执行;监控数据库连接池的使用率是否突然达到上限。
互斥锁(Mutex)解决方案实现
互斥锁的核心思路是当缓存未命中时,只允许一个请求去查询数据库并重建缓存,其他请求等待或返回旧数据。Redis的SETNX命令天然适合实现分布式互斥锁:
public String getProductInfo(String productId) {
String cacheKey = "product:info:" + productId;
String lockKey = "lock:product:" + productId;
// 1. 查询缓存
String cached = redis.get(cacheKey);
if (cached != null) {
return cached;
}
// 2. 缓存未命中,尝试获取互斥锁
// SET lockKey requestId NX EX 10
String requestId = UUID.randomUUID().toString();
boolean locked = redis.set(lockKey, requestId, "NX", "EX", 10);
if (locked) {
try {
// 双重检查:获取锁后再次查询缓存
cached = redis.get(cacheKey);
if (cached != null) {
return cached;
}
// 3. 查询数据库
Product product = productMapper.selectById(productId);
String json = JSON.toJSONString(product);
// 4. 写入缓存(设置随机过期时间避免雪崩)
int expire = 300 + ThreadLocalRandom.current().nextInt(60);
redis.setex(cacheKey, expire, json);
return json;
} finally {
// 5. 释放锁(使用Lua保证原子性)
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) " +
"else return 0 end";
redis.eval(script, Collections.singletonList(lockKey),
Collections.singletonList(requestId));
}
} else {
// 6. 未获取到锁,短暂等待后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductInfo(productId); // 递归重试
}
}
互斥锁方案有几个关键点:lockKey的过期时间设置为10秒,防止持有锁的线程异常退出导致死锁;释放锁使用Lua脚本对比requestId后删除,避免误删其他线程的锁;获取锁后执行双重检查,避免在等待锁期间缓存已被其他线程重建。
互斥锁方案的缺点是增加了请求的等待时间。在高并发场景下,等待锁的请求会堆积,可能触发线程池满。对于响应时间敏感的场景,可以设置最大重试次数,超过后返回降级数据。
布隆过滤器配置与缓存穿透防护
布隆过滤器用于解决缓存穿透问题——查询不存在的key时缓存和数据库都没有数据,每次请求都打到数据库。布隆过滤器通过位图判断元素”可能存在”或”一定不存在”,空间效率远高于HashSet。
// 使用Redisson的布隆过滤器
public class BloomFilterService {
private RBloomFilter<String> productFilter;
public void initBloomFilter() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setDatabase(0);
RedissonClient client = Redisson.create(config);
productFilter = client.getBloomFilter("product:bloom");
// 初始化:预计元素数量100万,误判率0.01%
productFilter.tryInit(1_000_000L, 0.0001);
// 加载所有商品ID到布隆过滤器
List<String> productIds = productMapper.selectAllIds();
for (String id : productIds) {
productFilter.add(id);
}
}
public String getProduct(String productId) {
// 布隆过滤器前置检查
if (!productFilter.contains(productId)) {
// 一定不存在,直接返回空
return null;
}
String cacheKey = "product:info:" + productId;
String cached = redis.get(cacheKey);
if (cached != null) {
return cached;
}
// 查询数据库并更新缓存
Product product = productMapper.selectById(productId);
if (product == null) {
// 数据库也不存在,缓存空值防止穿透
redis.setex(cacheKey, 60, "");
return null;
}
redis.setex(cacheKey, 300, JSON.toJSONString(product));
return JSON.toJSONString(product);
}
}
tryInit方法的两个参数:expectedInsertions是预计要插入的元素数量,falseProbability是可接受的误判率。误判率越低,位图需要的位数越多。100万元素、0.01%误判率需要约1.8MB内存。
布隆过滤器的局限性:存在误判率,不存在的key有极小概率被判断为存在(假阳性),但存在的key不会被判断为不存在(无假阴性)。不支持删除操作,当商品下架后布隆过滤器中的记录无法移除。如果需要删除功能,使用Counting Bloom Filter变体。
缓存雪崩预防与多级缓存架构
缓存雪崩是指大量key在同一时间过期,导致请求全部打到数据库。预防方法是给过期时间添加随机值,避免同时失效:
// 过期时间 = 基础时间 + 随机偏移
int baseExpire = 3600; // 1小时
int randomOffset = ThreadLocalRandom.current().nextInt(300); // 0-5分钟
redis.setex(cacheKey, baseExpire + randomOffset, value);
多级缓存架构通过在Redis之上增加本地缓存层,进一步降低Redis压力和请求延迟:
public class MultiLevelCache {
// L1: 本地缓存(Caffeine)
private Cache<String, String> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.recordStats()
.build();
// L2: Redis分布式缓存
@Autowired
private RedisTemplate<String, String> redis;
public String get(String key) {
// L1查找
String value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// L2查找
value = redis.opsForValue().get(key);
if (value != null) {
// 回填L1
localCache.put(key, value);
return value;
}
// 数据库查找
value = dbQuery(key);
if (value != null) {
// 同时写入L1和L2
localCache.put(key, value);
redis.opsForValue().set(key, value, 300, TimeUnit.SECONDS);
}
return value;
}
public void evict(String key) {
localCache.invalidate(key);
redis.delete(key);
}
}
L1本地缓存使用Caffeine,过期时间设置较短(60秒),减少本地内存占用。L2 Redis缓存过期时间设置较长(300秒),作为本地缓存失效后的后备。数据库查询只在L1和L2都未命中时触发。
监控指标与告警配置
Redis缓存运维需要持续监控以下核心指标:
# Prometheus + Grafana监控配置示例
# redis_exporter关键指标
redis_connected_clients # 当前连接数
redis_used_memory # 已用内存
redis_keyspace_hits_total # 缓存命中次数
redis_keyspace_misses_total # 缓存未命中次数
# Grafana告警规则
# 1. 命中率低于90%告警
(hit_rate = hits / (hits + misses)) < 0.90
# 2. 内存使用超过80%告警
used_memory / maxmemory > 0.80
# 3. 连接数超过最大连接数的80%
connected_clients / maxclients > 0.80
# 4. 慢日志告警
slowlog_len > 10
命中率是衡量缓存策略有效性的核心指标,低于90%说明缓存策略需要调整。可能的原因包括过期时间设置过短、缓存key设计不合理导致频繁失效、缓存预热不充分。
Redis缓存击穿、穿透和雪崩是三种不同的缓存异常场景,对应的解决方案也各不相同。互斥锁解决热点key击穿、布隆过滤器解决不存在key穿透、随机过期时间解决批量key雪崩。在生产环境中,这三种方案通常组合使用,配合多级缓存架构和完善的监控告警体系,构建稳定可靠的缓存层。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-ji-chuan-yu-xue-beng-wen-ti-zhen-duan-bu/