1. 缓存击穿
缓存击穿是指一个被频繁访问(高并发访问并且缓存重建业务较复杂)的缓存键因为过期失效,同时又有大量并发请求访问此键,导致请求直接落到数据库或后端服务上,增加了系统的负载并可能导致系统崩溃
常见的解决方案两种:
互斥锁
逻辑过期
互斥锁的优点是它可以确保只有一个线程在访问缓存内容,并且在缓存中没有命中时,只会读取一次后端数据库(或其他数据源),其余线程会等待读取完毕后再次读取缓存,这避免了大量的并发请求直接落到后端,从而减少了并发压力,保证系统的稳定性,并且可以保证数据的一致性;
互斥锁的缺点是会增加单个请求的响应时间,因为只有一个线程能够读取缓存值,其他线程则需要等待,这可能会在高并发场景下导致线程池饱和。
逻辑过期的优点是可以减少缓存的更新次数,避免在没有必要的情况下过多地读取后端数据源,并且在数据本身有频繁更新的情况下可以避免缓存数据过时;
逻辑过期的缺点是在某些极端情况下会出现缓存为空的情况,如果此时恰巧有大量请求同时访问缓存,则可能导致缓存击穿,并且无法避免大量的并发请求直接落到后端,并且实现起来也是比较复杂和数据无法保证一致性(因为可能返回旧数据)。
2. 互斥锁解决缓存击穿问题
要与缓存穿透区分开来
使用 setnx 命令(在Java封装Redis功能的API中使用的是
setIfAbsent()
方法)可以实现互斥锁的功能, setnx 命令可以原子性地设置一个关键字,如果关键字不存在,则设置并返回 1,如果关键字已存在,则不做任何操作并返回 0;
在 Redis 中,该命令会在关键字不存在时将其设置为指定的值(锁),同时返回设置结果,因此可以在线程尝试去设置同一个关键字时,只有一个线程能够成功获取锁,其他线程会返回设置失败;
在释放锁时,应该先对锁进行校验(如,判断当前操作是否为拥有锁的线程),然后再执行删除操作,以确保当前线程不会释放其他线程加的锁;
最后在高并发的场景中,这种操作是会增加Redis服务器的负载的,因此需要合理设置 Redis 参数和优化 Redis 集群架构(在Redis高级中给出方案);
Boolean 类型的值可以自动拆箱为 boolean 类型的值,但是在进行自动拆箱时,如果 Boolean 类型的值为 null,则会抛出 NullPointerException 异常。因此,在进行自动拆箱时,需要注意可能出现的空指针异常。
在使用 Redis 进行分布式锁时,setIfAbsent 方法返回的是 Boolean 类型的值,因此在返回该值时,可能会出现自动拆箱引发的空指针异常。如果出现空指针异常,一般是因为 Redis 连接池未初始化或注入失败,或者 Redis 服务出现了故障。
加锁和释放锁的代码:
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 这里的布尔类型自动拆箱可能会出现空指针异常
return BooleanUtil.isTrue(flag);
}
private void unLock(String key){
stringRedisTemplate.delete(key);
}
封装之前缓存穿透的代码:
/**
* 缓存穿透功能封装
* @param id
* @return
*/
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
//1. 从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2. 判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3. 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 这里要先判断命中的是否是null,因为是null的话也是被上面逻辑判断为不存在
// 这里要做缓存穿透处理,所以要对null多做一次判断,如果命中的是null则shopJson为""
if("".equals(shopJson)){
return null;
}
//4. 不存在,根据id查询数据库
Shop byId = getById(id);
if(byId == null) {
//5. 不存在,将null写入redis,以便下次继续查询缓存时,如果还是查询空值可以直接返回false信息
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6. 存在,写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(byId), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7. 返回
return byId;
}
合并封装缓存穿透和使用互斥锁实现缓存击穿的代码:
/**
* 缓存击穿和缓存穿透功能合并封装
* @param id
* @return
*/
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
//1. 从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2. 判断是否存在
if(StrUtil.isNotBlank(shopJson)){
//3. 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 这里要先判断命中的是否是null,因为是null的话也是被上面逻辑判断为不存在
// 这里要做缓存穿透处理,所以要对null多做一次判断,如果命中的是null则shopJson为""
if("".equals(shopJson)){
return null;
}
//4. 实现缓存重建
//4.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
//4.2 判断获取是否成功
if(!isLock) {
//4.3 失败,则休眠并重试
Thread.sleep(50);
// 递归重试
return queryWithMutex(id);
}
//4.4 成功,根据id查询数据库
shop = getById(id);
if(shop == null) {
//5. 不存在,将null写入redis,以便下次继续查询缓存时,如果还是查询空值可以直接返回false信息
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6. 存在,写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7. 释放互斥锁
unLock(lockKey);
}
//8. 返回
return shop;
}
这里并发测试采用JMeter
也可以看出查询数据库的操作只出现了一次
3. 逻辑过期方式解决缓存击穿问题
这里案例中的缓存key默认为高频key,这类key可看做是不会过期,所以这里不做缓存穿透和击穿处理;
这里获取锁之后,如果获取到了则是开启独立线程进行查询操作,如果没有获取到则是直接返回旧数据;
容易出现数据不一致;
将数据写入Redis,需要设置一个逻辑的过期时间;
目前写入的Shop是没有逻辑过期时间的字段,直接在该类中加入字段不推荐,有侵入性;
新建一个类,设置一个data属性来存Shop对象数据,并且添加字段逻辑过期时间;
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
注:热点key一般是提前写入的,缓存预热;
缓存预热代码示例,要进行单元测试,以便热点key缓存写入Redis中:
/**
* 给热点key缓存预热
* @param id
* @param expireSeconds
*/
private void saveShop2Redis(Long id, Long expireSeconds) {
// 1.查询店铺数据
Shop shop = getById(id);
// 2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3.写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
对我们提前写入的key进行逻辑过期方式处理,解决缓存击穿问题;
这里要避免更新过期数据,当缓存失效(逻辑过期),部分线程经过逻辑过期判断之后,会进行获取锁的操作并进入阻塞状态,可能已经有线程通过更新缓存已经将数据写入缓存中了,这是该线程会释放锁。如果不进行二次逻辑过期判断,当前等待互斥锁的线程可能会将已经更新的数据再次从数据库中读取并写入缓存,导致缓存中存储的是重复的数据。通过逻辑过期判断,可以避免这种重复更新的情况发生。
/**
* 缓存击穿(逻辑过期)功能封装
* @param id
* @return
*/
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
//1. 从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2. 判断是否存在
if(StrUtil.isBlank(shopJson)){
//3. 不存在,直接返回(这里做的事热点key预热,所以已经假定热点key已经在缓存中)
return null;
}
//4. 存在,需要判断过期时间,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//5. 判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
//5.1 未过期,直接返回店铺信息
return shop;
}
//5.2 已过期,需要缓存重建
//6. 缓存重建
//6.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
//6.2 判断是否获取锁成功
boolean isLock = tryLock(lockKey);
if(isLock) {
// 二次验证是否过期,防止多线程下出现缓存重建多次
String shopJson2 = stringRedisTemplate.opsForValue().get(key);
// 这里假定key存在,所以不做存在校验
// 存在,需要判断过期时间,需要先把json反序列化为对象
RedisData redisData2 = JSONUtil.toBean(shopJson2, RedisData.class);
Shop shop2 = JSONUtil.toBean((JSONObject) redisData2.getData(), Shop.class);
LocalDateTime expireTime2 = redisData2.getExpireTime();
if(expireTime2.isAfter(LocalDateTime.now())) {
// 未过期,直接返回店铺信息
return shop2;
}
//6.3 成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 重建缓存,这里设置的值小一点,方便观察程序执行效果,实际开发应该设为30min
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unLock(lockKey);
}
});
}
//7. 返回
return shop;
}
对逻辑过期处理缓存击穿测试;
启动服务,并进行测试(将热点key值写入);
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private ShopServiceImpl shopService;
@Test
void testSaveShop() throws InterruptedException {
shopService.saveShop2Redis(1L, 10L);
}
}
这里修改一下数据数据,好观察重建缓存;
启动JMeter测试多线程下结果变化;
可以看出在更新缓存前获得的是旧数据,更新之后是新数据;