我先说一下正常的业务流程:需要查询店铺数据,我们会先从redis中查询,判断是否能命中,若命中说明redis中有需要的数据就直接返回;没有命中就需要去mysql数据库查询,在数据库中查到了就返回数据并把该数据存入redis中,若mysql数据库中也查不到就返回null,并返回错误信息:该信息不存在。
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务复杂的存储在redis中的key突然失效,无数请求就会瞬间打到数据库造成巨大冲击。
解决方法有有俩个,一个是用互斥锁,一个是逻辑过期时间。互斥锁方法的实现写在了另一篇文章中,需要的可以去看一下。
逻辑过期时间:这个是不给存入的key设置过期时间,而是将过期时间写入value中,时间过期后,一个线程获取互斥锁然后另开一个新线程去查询数据库,写入缓存并释放锁。而老线程直接返回查到的旧数据,期间其他获取互斥锁失败的线程查询也会返回旧数据。缺点:有额外的内存消耗,不保证数据一致性,实现优点复杂。
在原来我们只需要把数据库查到的实体类信息保存到redis中就可以了,现在用逻辑过期时间解决缓存击穿,我们还需要把过期时间和实体类信息一起保存到redis中。所以我们需要新写一个包装类把过期时间和实体类封装到一个类中,如下:
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
考虑到该包装类的复用,所以实体类直接传一个Object类,而没有用泛型
所以流程是:去redis查询数据,没命中直接返回null;命中就取出过期时间信息,查看是否过期,若未过期,则直接返回实体类信息;若过期就进行缓存重建,重试获取互斥锁,获取成功就另开启一个新线程去访问数据库进行数据重建,而本线程返回旧数据;若没有获取到锁,就直接返回旧数据。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
public Result queryById(Long id) {
//缓存穿透
//Shop shop = queryWithPassThrough(id);
//用互斥锁解决缓存击穿
/*Shop shop = queryWithMutex(id);
if (shop==null){
return Result.fail("店铺不存在");
}*/
//用逻辑过期时间解决缓存击穿
Shop shop = queryWithLoginExpire(id);
return Result.ok(shop);
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR =Executors.newFixedThreadPool(10);
public Shop queryWithLoginExpire(Long id) {
//1.从redis查询数据缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
//3.不存在,返回空值
return null;
}
//4.存在,将json字段反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, 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;
boolean lock = tryLock(lockKey);
//6.2判断是否获取锁成功
if (lock){
//6.3获取成功,开启新线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
//重建缓存
try {
saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
//释放锁
UnLock(lockKey);
}
});
}
//6.3失败,返回旧消息
return shop;
}
//下面这是进行访问数据库缓存重建的方法
public void saveShop2Redis(Long id,Long expireTime) throws InterruptedException {
//1、获取店铺数据
Shop shop = getById(id);
Thread.sleep(200);
//2、封装逻辑过期时间
RedisData redisData=new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
//3.保存到缓存中
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id, JSONUtil.toJsonStr(redisData));
}
}
下面让我们用Jmeter测试一下,看看高并发的状态下,会访问数据库几次:
因为前面代码中,我们写的是在redis中如果找不到就直接返回null了,所以在测试之前我们应该先用测试方法,执行一下saveShop2Redis方法,把数据缓存到redis中。
@Test
void testSaveShop2Redis() throws InterruptedException {
shopService.saveShop2Redis(1L,10L);
}
这样我们就把实体类id为1,过期时间为10s缓存到了redis中了。
redis中已经有了我们存的数据了,也存日了过期时间,然后等个10s,等过了过期时间,在用Jmeter做高并发测试
idea控制台返回
信息可知缓存中逻辑时间过期后就会进行缓存重建,并只会访问数据库一次。