问题背景与适用场景
在分布式系统中,Redis作为高性能缓存被广泛应用。然而,在高并发场景下,缓存可能面临穿透、击穿和雪崩三大问题,这些问题会导致数据库压力剧增,甚至导致系统崩溃。
缓存穿透通常发生在查询不存在的数据时,请求直接穿透缓存到达数据库;缓存击穿则是当某个热点key失效时,大量并发请求直接打到数据库;缓存雪崩则是大量key同时失效或Redis宕机,导致所有请求涌向数据库。
这些问题常见于电商秒杀、热点新闻推送、微博热搜等高并发场景,一旦发生,轻则服务响应变慢,重则数据库宕机。
缓存原理
缓存穿透
缓存穿透是指查询一个根本不存在的数据,由于缓存中没有,请求会直接打到数据库。如果大量这类请求并发,数据库压力会骤增。
成因:
- 恶意攻击,大量请求不存在的数据
- 业务逻辑错误,大量查询不存在的数据
- 数据库中的数据被删除,但缓存未及时更新
缓存击穿
缓存击穿是指某个热点key在某一刻突然失效,此时大量并发请求同时发起,由于缓存未命中,这些请求会直接打到数据库。
成因:
- 热点key设置了较短的过期时间
- 热点key被大量并发请求访问
- 缓存更新策略不当
缓存雪崩
缓存雪崩是指大量key在同一时间失效或Redis服务宕机,导致所有请求直接访问数据库。
成因:
- 大量key设置了相同的过期时间
- Redis服务宕机
- 集群故障导致缓存不可用
操作步骤
缓存穿透解决方案
- 缓存空对象
- 查询数据库后,即使数据不存在,也将结果缓存
- 设置较短的过期时间,避免长期占用内存
- 布隆过滤器
- 使用布隆过滤器预先过滤不存在的请求
- 在请求查询缓存前,先通过布隆过滤器判断
缓存击穿解决方案
- 互斥锁(Mutex Lock)
- 当缓存失效时,只允许一个线程查询数据库
- 其他线程等待或返回默认值
- 查询完成后更新缓存
- 永不过期(逻辑过期)
- 热点key不设置物理过期时间
- 通过逻辑标记判断是否需要更新
- 后台异步更新缓存
- 随机过期时间
- 为相同类型的key设置不同的过期时间
- 避免大量key同时失效
缓存雪崩解决方案
- 缓存预热
- 系统启动时主动加载热点数据到缓存
- 定期刷新热点数据
- 错开过期时间
- 为key设置基础过期时间加上随机值
- 避免大量key同时失效
- 多级缓存
- 使用本地缓存+分布式缓存
- 即使Redis宕机,本地缓存仍可提供服务
- 降级与熔断
- 当缓存不可用时,降级直接访问数据库
- 设置熔断机制,防止数据库被压垮
示例代码
缓存空对象实现
public Object getWithCacheNull(String key) {
// 1. 先从缓存获取
Object value = redis.get(key);
// 2. 缓存命中,直接返回
if (value != null) {
return value.equals("") ? null : value;
}
// 3. 缓存未命中,查询数据库
value = db.query(key);
// 4. 无论数据是否存在,都缓存结果
if (value == null) {
// 缓存空对象,设置较短的过期时间
redis.setex(key, 60, "");
} else {
redis.setex(key, 3600, value.toString());
}
return value;
}互斥锁解决缓存击穿
public Object getWithMutexLock(String key) {
// 1. 先从缓存获取
Object value = redis.get(key);
if (value != null) {
return value.equals("") ? null : value;
}
// 2. 尝试获取锁
String lockKey = "lock:" + key;
try {
boolean locked = redis.setnx(lockKey, "1", 10); // 10秒过期
if (locked) {
// 3. 获取锁成功,查询数据库
value = db.query(key);
// 4. 更新缓存
if (value == null) {
redis.setex(key, 60, "");
} else {
redis.setex(key, 3600, value.toString());
}
return value;
} else {
// 5. 获取锁失败,休眠后重试
Thread.sleep(50);
return getWithMutexLock(key);
}
} catch (Exception e) {
// 异常处理
return null;
} finally {
// 6. 释放锁
redis.del(lockKey);
}
}布隆过滤器实现
// 初始化布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(), 1000000);
// 添加已存在的key到布隆过滤器
for (String key : db.getAllKeys()) {
bloomFilter.put(key);
}
// 使用布隆过滤器过滤请求
public Object getWithBloomFilter(String key) {
// 1. 先通过布隆过滤器判断
if (!bloomFilter.mightContain(key)) {
return null; // 一定不存在,直接返回
}
// 2. 从缓存获取
Object value = redis.get(key);
if (value != null) {
return value.equals("") ? null : value;
}
// 3. 缓存未命中,查询数据库
value = db.query(key);
// 4. 更新缓存和布隆过滤器
if (value == null) {
redis.setex(key, 60, "");
} else {
redis.setex(key, 3600, value.toString());
bloomFilter.put(key);
}
return value;
}常见问题与注意事项
- 缓存空对象问题
- 空对象会占用内存,需设置合理的过期时间
- 可能导致缓存穿透问题依然存在,只是转移到数据库
- 适用于数据相对稳定的场景
- 互斥锁问题
- 可能导致性能下降,线程等待
- 需要合理设置锁的超时时间,避免死锁
- 高并发场景下可能成为瓶颈
- 布隆过滤器问题
- 存在误判率,可能将存在的key误判为不存在
- 不适合动态变化的数据集
- 需要预估数据量,合理设置容量
- 缓存预热问题
- 预热时机选择很重要,系统启动时可能影响性能
- 需要识别真正的热点数据
- 预热过程需要监控,避免影响正常服务
- 多级缓存问题
- 本地缓存和分布式缓存的数据一致性问题
- 内存占用问题
- 缓存更新策略复杂
排错思路
- 监控缓存命中率
- 设置缓存命中率监控,及时发现异常
- 不同业务场景有不同的正常命中率范围
- 判断问题类型
- 缓存穿透:特定key的请求大量增加,数据库压力大
- 缓存击穿:单个key的请求量突增,数据库压力大
- 缓存雪崩:整体请求量突增,数据库压力大
- 日志分析
- 记录缓存命中情况,分析异常模式
- 记录数据库查询日志,识别高频查询
- 性能监控
- 监控Redis响应时间,发现性能下降
- 监控数据库连接数,判断是否过载
- 报警机制
- 设置缓存命中率过低报警
- 设置数据库响应时间过长报警
- 设置Redis内存使用率报警
总结
缓存穿透、击穿和雪崩是Redis缓存的常见问题,需要根据业务场景选择合适的解决方案。缓存穿透可通过缓存空对象和布隆过滤器解决;缓存击穿可通过互斥锁和永不过期策略解决;缓存雪崩可通过缓存预热、错开过期时间、多级缓存和降级熔断解决。
系统设计时需综合考虑性能、可用性和一致性,同时建立完善的监控和报警机制,以便及时发现和解决问题。在实际应用中,往往是多种策略的组合使用,以达到最佳效果。
主楼