缓存击穿
1、定义
缓存的某个热点数据过期,此时大量的用户请求过来,缓存未命中,然后都打到数据库去,导致数据库压力骤增。
2、解决方案
原因就是此时大量请求未命中缓存,导致都打到数据库,此时数据库压力剧增,解决方案分为是单机还是分布式情况部署。
2.1、双重检索(本地方案)
一、基本实现原理
public Object getData(String key) {
// 第一次检查(无锁)
Object value = cache.get(key);
if (value == null) {
synchronized (this) {
// 第二次检查(有锁)
value = cache.get(key);
if (value == null) {
value = db.query(key); // 查询数据库
cache.set(key, value); // 写入缓存
}
}
}
return value;
}
二、技术细节分析
- 双重检查的意义
- 第一次检查:无锁快速判断,解决大多数缓存命中情况
- 第二次检查:防止多个线程同时通过第一次检查后重复创建缓存
- 与简单同步方案的对比
// 简单同步方案(性能较差)
public synchronized Object getDataSlow(String key) {
Object value = cache.get(key);
if (value == null) {
value = db.query(key);
cache.set(key, value);
}
return value;
}
性能对比:
双重检查:只在缓存未命中时加锁
简单同步:每次调用都加锁,导致性能上面的浪费。
2.2、互斥锁(分布式方案)
核心思想:只允许一个请求重建缓存,其他请求等待
流程就是:先查询缓存是否命中,未命中,尝试获取互斥锁,获取锁成功,进行缓存重构,未获取锁的就进行递归,进行递归判断是否缓存命中,命中直接返回,未命中继续尝试获取锁,失败继续递归。
@Nullable
// todo 3、缓存击穿 -> 互斥锁:只能由一个线程进行缓存构建,其他线程等待,吞吐量较低
private Shop huchi(Long id) {
String shopJsonStr = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
if (StrUtil.isNotBlank(shopJsonStr)) {
return JSONUtil.toBean(shopJsonStr, Shop.class);
}
// 未命中获取锁
String tryLockKey = "cache:shop:lock:" + id;
Shop shop = null;
try {
boolean tryLock = getLock(tryLockKey);
// 未命中:不断休眠直至获取成功
if(!tryLock) {
Thread.sleep(50);
return huchi(id);
}
// 获取互斥锁,进行缓存的构建
shop = getById(id);
if (shop == null) {
// 数据库中也不存在时候,进行空字符串缓存
stringRedisTemplate.opsForValue().set("cache:shop:" + id, "", 2, TimeUnit.MINUTES);
return null;
}
stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(shop), 2, TimeUnit.MINUTES);
} catch (Exception e) {
e.getStackTrace();
} finally {
unLock(tryLockKey);
}
return shop;
}
优点:保证数据一致性
缺点:可能出现递归导致栈溢出情况,但是这种情况较少可能性。
2.3、逻辑过期(分布式方案)
步骤:
- 进行缓存预热:将设置逻辑过期的写入缓存中
- 然后获取redis中的值,根据缓存逻辑设置时间是否过期来决定是否进行缓存的重新构建。如果未过期,直接返回。过期,设置互斥锁,获取锁成功的单独开设一个子线程去进行缓存的更新构建,主线程直接返回结果。获取锁失败,直接返回旧数据
代码实现:
@Nullable
// todo 3、缓存击穿 -> 逻辑过期:通过设置逻辑过期时间,然后判断是否过期来确定是否进行缓存更新
private Shop exLogical(Long id) {
ExecutorService executorService = Executors.newFixedThreadPool(10);
String shopJsonStr = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
// 如果不存在那就是一定不存在
if (StrUtil.isBlank(shopJsonStr)) {
return null;
}
//
RedisDate redisDate = JSONUtil.toBean(shopJsonStr, RedisDate.class);
Shop shop = JSONUtil.toBean((JSONObject) redisDate.getObject(), Shop.class);
// 未逻辑过期
if (redisDate.getEx().isAfter(LocalDateTime.now())) {
return shop;
}
// 逻辑过期
// 缓存重建
String tryLockKey = "cache:shop:lock:" + id;
boolean tryLock = getLock(tryLockKey);
if (tryLock) {
// 开启独立的线程去独立的进行缓存
executorService.submit(() -> {
try {
this.saveShopRedis(id, 20L);
} finally {
unLock(tryLockKey);
}
});
}
return shop;
}
// 手动设置逻辑过期时间
private void saveShopRedis(Long id, Long ex) {
Shop shop = getById(id);
RedisDate redisDate = new RedisDate();
redisDate.setEx(LocalDateTime.now().plusSeconds(ex));
redisDate.setObject(shop);
stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(redisDate));
}
优点:无等待时间,性能较好
缺点:可能会出现短暂的数据性不一致的情况