目录
前言:
1.认识缓存
2.添加Redis缓存
2.1.根据id查询商铺缓存
2.2.优化根据id查询商铺缓存
3.缓存更新策略
3.1.三种策略
3.2.策略选择
3.3.主动更新的方案
3.4. Cache Aside的模式选择
3.5.最佳实践方案
4.缓存三大问题
4.1.缓存穿透
4.1.1.介绍
4.1.2.解决方案
4.1.3.实现
4.2.缓存雪崩
4.2.1.介绍
4.2.2.解决方案
4.3.缓存击穿
4.3.1.介绍
4.3.2.解决方案
4.3.3.实现
4.4.封装缓存工具
前言:
了解什么是缓存,怎么缓存,缓存的更新策略,缓存的三大问题及解决方案(缓存穿透,缓存雪崩,缓存击穿)
1.认识缓存
1.1.缓存的介绍
缓存就是数据交换的缓冲区,是储存数据的临时地方( 一种具备高效读写能力的数据暂存区域)
1.2.缓存的作用
-
降低后端负载
-
提高读写速率,降低响应时间
1.3.缓存的成本
-
1.开发成本 (代码维护成本)
-
2.运维成本
-
3.数据一致性成本
图:
2.添加Redis缓存
2.1.根据id查询商铺缓存
步骤:
前端提交商铺id
==》从Redis中查询缓存
==》判断缓存是否存在(是否命中)
==》命中返回商铺数据
-------------------
==》未命中
==》根据id查询数据库
==》判断数据是否存在
==》不存在返回404,存在将数据写入Redis
==》返回商铺数据
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
Shop shop = queryShopPenetrate(id);
if (shop == null){
return Result.fail("商铺不存在");
}
//6.返回商铺数据
return Result.ok(shop);
}
public Shop queryShopPenetrate(Long id) {
//1.查询Redis
String key = RedisConstants.CACHE_SHOP_KEY + id;
String strShop = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(strShop)) {
//存在直接返回
Shop shop = JSONUtil.toBean(strShop, Shop.class);
return shop;
}
//3.不存在,查询数据库
Shop shop = getById(id);
//4.判断是否存在
if (shop == null) {
return null;
}
//5.存在,存入Redis
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key, jsonStr);
return shop;
}
解释:
- 1.由于商铺信息一般不进行修改,而用户却需要频繁的访问这些数据,如果突然有大量用户同时访问该数据,那么数据库的压力会很大,因此我们需要增加用户访问速度和降低对数据库的压力,所以我们使用Redis来进行缓存(基于内存,读写速度更快,降低数据库的压力)
- 2.用户点击商铺,前端返回对应id,那么后端接收到id在Redis查询(没有数据Redis会返回null),因此我们需要判断其是否命中,缓存存在直接返回缓存数据即可,不存在没有数据,那么我们需要查询数据库,再次判断数据是否存在,没有存在那么就是根本就没有这个商铺的信息直接返回错误信息,数据存在,我们需要先将数据写入Redis以便以后访问再返回数据给前端
2.2.优化根据id查询商铺缓存
步骤:
前端提交商铺id
==》从Redis中查询缓存
==》判断缓存是否存在(是否命中)
==》命中返回商铺数据
----------------------------
==》未命中
==》根据id查询数据库
==》判断数据是否存在
==》不存在返回404,存在将数据写入Redis,并且设置过期时间(过期淘汰)
==》返回商铺数据
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
Shop shop = queryShopPenetrate(id);
if (shop == null){
return Result.fail("商铺不存在");
}
//6.返回商铺数据
return Result.ok(shop);
}
public Shop queryShopPenetrate(Long id) {
//1.查询Redis
String key = RedisConstants.CACHE_SHOP_KEY + id;
String strShop = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(strShop)) {
//存在直接返回
Shop shop = JSONUtil.toBean(strShop, Shop.class);
return shop;
}
//3.不存在,查询数据库
Shop shop = getById(id);
//4.判断是否存在
if (shop == null) {
return null;
}
//5.存在,存入Redis
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key, jsonStr, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return shop;
}
解释:为什么要设置过期时间,要保证缓存数据定时更新
3.缓存更新策略
3.1.三种策略
1.内存淘汰:Redis自带的内存淘汰机制,不需要自己维护,当Redis内存不足时会自动的淘汰(清理)部分数据,等下次查询时更新缓存即可
------------------
特性:一致性差 ,没有维护成本
2.过期淘汰:给缓存数据添加过期时间(利用expire命令设置),到期自动删除缓存,等下次查询时更新缓存即可
--------------------
特性:一致性一般,维护成本低
3.主动更新:自己编写业务逻辑,在修改数据库的同时更新缓存(主动完成数据库和缓存的同时更新)
----------------------
特性:一致性好,维护成本高
图:
3.2.策略选择
要求数据低一致性
- 内存淘汰或过期淘汰
要求数据高一致性
- 主动更新为主,过期淘汰兜底
图:
3.3.主动更新的方案
方案一:Cache Aside
介绍:由缓存调用者在更新数据库的同时更新缓存
-----------------
特性:一致性良好,实现难度一般
方案二:Read/Write Through
介绍:缓存与数据库集成为一个服务,由服务保证两者的一致性,对外暴露API接口 ,调用者调用API即可,无需知道自己操作的是数据库还是缓存,不关心一致性问题
------------------
特性:一致性优秀,实现复杂,性能一般
方案三:Write Back
介绍:调用者只操作缓存,由其他线程来异步将缓存数据持久化到数据库,保证最终一致
-------------------
特性:一致性差,性能好,实现复杂
图:
3.4. Cache Aside的模式选择
1.该模式就是开发人员手动进行数据库与缓存的代码实现
2.思考更新缓存还是删除缓存:当数据库内的数据发生改变时,那么Redis缓存是不是也需要修改(保存数据一致性),那么我们是去更新缓存,还是直接删除缓存,等要使用该数据时(此时缓存无数据,查询数据库再写入)才进行写入缓存
更新缓存:是不是每次更新数据库时都需要进行更新缓存(无效操作较大且复杂),存在较大的线程安全问题
----------------------
解释:在一个极短的时间内数据库进行了多次的更新操作,那么缓存是不是也需要进行相同次操作,但其实数据库最后一次修改时缓存更新才是有效的
删除缓存:删除缓存的本质就是延迟更新,没有无效更新,线程安全问题相对较低
-----------------------
解释: 在一个极短的时间内数据库进行了多次的更新操作,而缓存在第一次更新操作时就进行了删除缓存,不管后面有多少次更新操作都影响不到缓存,一直等到用户点击,查询数据库时(用到数据时)才会进行缓存更新
3.思考在写操作时是先操作数据库还是缓存
先删除缓存,再更新数据库 :安全问题概率高
----------------------------
解释:
前提:假设数据库与Redis现在存的数据是100
----------------------------
反例:当数据库进行更新时,将数据100更新为120而在更新的同时进行了查询操作
==》线程1先执行
==》线程1删除缓存(100)
==》线程2抢到执行权
==》线程2执行查询数据操作
==》线程2查询缓存没有数据(无)
==》线程2查询数据库(100)
==》线程2再将数据写入Redis缓存中(100)
==》线程2执行完,线程1执行
==》线程1更新数据库(120)
------------------------------
那么下次查询数据时由于缓存有数据,并不会更新缓存,我们发现缓存数据为100,数据库数据为120,数据不一致
先更新数据,再删除缓存: 在满足原子性的情况下,安全问题较低
--------------------------
解释:(也有反例,不过概率很低)
前提:假设数据库存的数据是100,Redis没有存数据
-------------------------
反例:在查询数据库的同时进行了更新数据库操作将100更新为120
==》线程1先执行
==》线程1查询缓存(无),不存在
==》线程1查询数据库(100)
==》线程2抢到执行权
==》线程2更新数据库(120)
==》线程2删除缓存
==》线程2执行完,线程1执行
==》线程1将数据100写入缓存(100)
--------------------------
那么下次查询数据时由于缓存有数据,并不会更新缓存,我们发现缓存数据为100,数据库数据为120,数据依旧不一致
-------------------------
注意:为什么这种概率极低呢,因为缓存的读写是基于内存的,而数据库读写基于硬盘,缓存的操作远远快于数据库操作,因此在线程1写入缓存之前,线程2要想抢到执行权来进行数据库查询的操作的概率极低
4. 如何保证数据库与缓存操作原子性
-
单体系统:利用事务机制
-
分布式系统:利用分布式事务机制
图:
3.5.最佳实践方案
1.低一致性需求:使用Redis自带的内存淘汰机制
2.高一致性需求:主动更新,并以超时剔除作为兜底方案
读操作:
- 缓存命中直接返回
- 没命中查询数据库,并写入缓存,设置超时时间
例子:
前端提交商铺id
==》从Redis中查询缓存
==》判断缓存是否存在(是否命中)
==》命中返回商铺数据
------------------------
==》未命中
==》根据id查询数据库
==》判断数据是否存在
==》不存在返回404,存在将数据写入Redis,并且设置过期时间(过期淘汰)
==》返回商铺数据
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
Shop shop = queryShopPenetrate(id);
if (shop == null){
return Result.fail("商铺不存在");
}
//6.返回商铺数据
return Result.ok(shop);
}
public Shop queryShopPenetrate(Long id) {
//1.查询Redis
String key = RedisConstants.CACHE_SHOP_KEY + id;
String strShop = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(strShop)) {
//存在直接返回
Shop shop = JSONUtil.toBean(strShop, Shop.class);
return shop;
}
//3.不存在,查询数据库
Shop shop = getById(id);
//4.判断是否存在
if (shop == null) {
return null;
}
//5.存在,存入Redis
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key, jsonStr, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return shop;
}
写操作:
- 先写数据库,然后再删除缓存
- 确保数据库与缓存操作的原子性
例子:
@Override
@Transactional
public Result updateShop(Shop shop) {
//1.判断商铺是否存在
Long id = shop.getId();
String key = RedisConstants.CACHE_SHOP_KEY + id;
if (id == null) {
return Result.fail("商铺不存在");
}
//2.先更新数据库
updateById(shop);
//3.删除Redis
stringRedisTemplate.delete(key);
return Result.ok();
}
图:
4.缓存三大问题
4.1.缓存穿透
4.1.1.介绍
缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会生效,这些请求最终都会打到数据库中
例子:数据库和Redis缓存中都没有数据,但是用户一直频繁访问发出请求,导致大量请求直接打到数据库上,导致数据库崩塌
4.1.2.解决方案
方案一:缓存空对象
- 思路:对不存在的数据也在Redis中建立缓存值,值为空,并且设置一个较短的时间
- 优点:实现简单,维护方便
- 缺点:有额外的内存消耗,短期的数据不一致问题
解释:为什么要设置一个有过期时间的缓存空值,不是用户频繁请求吗,那么我们就给它一个值,防止压力数据库,不过这样会造成数据不一致问题,就是当数据设置空值后,正好数据库添加了相应的数据,那么此时数据将不一致(不过由于我们设置的是较短的过期时间,所以数据不一致时间存在时间不会太久),由于你设置了空值(不必要值),那么会造成内存的消耗
方案二:布隆过滤
- 思路:利用布隆过滤算法,在请求进入Redis之前先判断是否存在,如果不存在则直接拒绝请求
- 优点:内存占用少
- 缺点:实现复杂,存在误判的可能性
解释:本质就是将数据库,Redis中的数据基于一种哈希算法计算出哈希值,再转化成二进制,最终存入过滤器中(1就是存在值,0就是不存在值)
注意:基于哈希算法,那么就会出现哈希冲突问题,导致过滤器判断存在数据可能数据库/Redis中并没有数据(不存在数据就一定不存在,存在有可能不存在)
方案三:细节
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
4.1.3.实现
步骤:
前端提交商铺id
==》从Redis中查询缓存
==》判断缓存是否存在(是否命中)
==》命中
==》判断数据是否为空值
==》空值直接返回错误信息,不为空返回商铺数据
------------------------
==》未命中
==》根据id查询数据库
==》判断数据是否存在
==》不存在将空值(设置过期时间)存入Redis,存在将数据写入Redis,并且设置过期时间(过期淘汰)
==》返回商铺数据
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
//缓存穿透
Shop shop = queryShopPenetrate(id);
if (shop == null){
return Result.fail("商铺不存在");
}
//6.返回商铺数据
return Result.ok(shop);
}
//穿透
public Shop queryShopPenetrate(Long id) {
//1.查询Redis
String key = RedisConstants.CACHE_SHOP_KEY + id;
String strShop = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(strShop)) {
//存在直接返回
Shop shop = JSONUtil.toBean(strShop, Shop.class);
return shop;
}
if (strShop != null) {
return null;
}
//3.不存在,查询数据库
Shop shop = getById(id);
//4.判断是否存在
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//5.存在,存入Redis
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key, jsonStr, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return shop;
}
图:
4.2.缓存雪崩
4.2.1.介绍
缓存雪崩是在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
4.2.2.解决方案
-
给不同的Key的过期时间添加随机值
-
利用Redis集群提高服务的可用性
-
给缓存业务添加降级限流策略
-
给业务添加多级缓存
解释:
给不同的Key的过期时间添加随机值:避免key同时失效
利用Redis集群提高服务的可用性:利用集群,主从,哨兵机制(主机宕机,从来代主实现并且从与主的数据一致)
给缓存业务添加降级限流策略:当整个机房都挂了(Redis都掉了),出现了超大故障时,直接返回拒绝服务,避免请求压力到数据库
给业务添加多级缓存:1.浏览器缓存静态数据 2.nginx缓存数据 3.jvm内部本地缓存 4.Redis缓存 5.数据库储存
图:
4.3.缓存击穿
4.3.1.介绍
缓存击穿就是热点key问题:就是一个被高并发访问(访问频率高)并且缓存重建业务较复杂(查询数据库业务复杂,耗时长)的key突然失效了,那么无数的请求访问会在一瞬间给数据库带来巨大冲击
4.3.2.解决方案
方案一:互斥锁
- 思路:给缓存重建过程加锁,确保重建过程只有一个线程执行,其他线程等待它执行完成
- 优点:实现简单,没有额外的内存消耗,一致性好
- 缺点:等待导致性能下降,有死锁风险
解释:基于Redis中的命令setnx来实现锁,由于setnx命令是key有值就不赋值,没有才创建key并且赋值,利用这个特性实现自定义锁(只有第一个人可以成功写入数据,其他人就不能),而由于多个线程同时访问时都需要等待(如果重建时间久)那么性能将会减低
方案二:逻辑过期
- 思路:热点key缓存永不过期,而是设置一个逻辑过期时间,查询到数据时通过对逻辑过期时间判断,来决定是否需要重建缓存
- 优点:线程无需等待,性能较好
- 缺点:不保证一致性,有额外内存消耗,实现复杂
解释:由于是热点key那么在一段时间(活动时间内),key应该不会去修改(活动之前就会缓存好key),那么我们也不需要进行key的自动删除(设置真正的过期时间),设置逻辑时间,根据实际时间与逻辑时间对比,那么我们就可以知道key是否过期,来进行对应操作
4.3.3.实现
方案一:互斥锁
步骤:
前端提交商铺id
==》线程1从Redis中查询缓存
==》线程1判断缓存是否存在(是否命中)
==》命中
==》线程1判断数据是否为空值
==》空值直接返回错误信息,不为空返回商铺数据
------------------------
==》未命中
==》线程1尝试获取互斥锁
==》线程1判断是否获取到锁
==》线程1获取到锁
==》线程1再次检查缓存是否存在
==》缓存存在直接返回缓存,不存在查询
==》线程1根据id查询数据库
==》线程1判断数据是否存在
==》线程1不存在将空值(设置过期时间)存入Redis,存在将数据写入Redis,并且设置过期时间(过期淘汰)
==》线程1释放锁
==》线程1返回商铺数据
--------------------------
==》线程2在线程1还未释放锁时也执行查询操作
==》线程2尝试获取锁
==》线程2判断是否获取到锁
==》线程2未获取到锁
==》线程2休眠一段时间并且返回到查询Redis缓存操作阶段
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryShopById(Long id) {
//互斥锁缓存击穿
Shop shop = queryShopBreakdown(id);
if (shop == null){
return Result.fail("商铺不存在");
}
//返回商铺数据
return Result.ok(shop);
}
//基于互斥锁,击穿
public Shop queryShopBreakdown(Long id) {
//1.查询Redis
String key = RedisConstants.CACHE_SHOP_KEY + id;
String strShop = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(strShop)) {
//存在直接返回
Shop shop = JSONUtil.toBean(strShop, Shop.class);
return shop;
}
if (strShop != null) {
return null;
}
//获取锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Shop shop = null;
try {
Boolean lock = lock(lockKey);
if(!lock){
//获取锁失败,递归
Thread.sleep(50);
return queryShopBreakdown(id);
}
//获取锁,再次查询缓存
strShop = stringRedisTemplate.opsForValue().get(key);
//判断缓存是否存在
if (StrUtil.isNotBlank(strShop)) {
//存在直接返回
Shop shop = JSONUtil.toBean(strShop, Shop.class);
return shop;
}
if (strShop != null) {
return null;
}
//3.不存在,查询数据库
shop = getById(id);
//4.判断是否存在
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//5.存在,存入Redis
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(key, jsonStr, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//移除锁
removeLock(lockKey);
}
return shop;
}
//获取锁
public Boolean lock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁
public void removeLock(String key) {
stringRedisTemplate.delete(key);
}
解释:就是当第一个线程获取到锁后并且还没有释放锁,而其本质就是利用命令setnx来建立key赋值并且设置过期时间,在没有线程获取到锁时(没有线程赋值key)那么此时setnx命令是可以执行成功的,执行成功返回对应数字(成功返回1,不成功返回0)根据数字判断是否成功赋值从而判断是否获取到锁。
那么其他线程获取不到锁那就说明锁未释放(删除key),线程就一直等待直到第一个线程释放锁
注意:我们在删除锁时(没有删除)或者是程序出错了,导致锁没有释放,那么就会出现死锁,因此我们预估业务执行时间,给锁设置一个过期时间防止出现该问题
当线程拿到锁时,我们还需要查询Redis来判断缓存是否存在,可能会出现在线程拿到锁之前正好有一个线程刚好释放了锁(已经完成了写入缓存的操作),那么为了效率我们要再次判断缓存是否存在
方案二:逻辑过期
步骤:
前端提交商铺id
==》线程1从Redis中查询缓存
==》线程1判断缓存是否存在(是否命中)
==》未命中
==》直接返回空值
------------------------
==》命中
==》线程1判断缓存是否过期(逻辑时间)
==》过期
==》线程1尝试获取互斥锁
==》线程1判断是否获取到锁
==》线程1获取到锁
==》线程1开启新线程2
==》线程1直接返回旧商铺数据
-------------------------
==》线程2再次检查缓存是否过期
==》缓存没有过期直接返回缓存,过期查询
==》线程2根据id查询数据库
==》线程2判断数据是否存在
==》线程2不存在将空值(设置过期时间)存入Redis,存在将数据(设置逻辑过期时间)写入Redis
==》线程2释放锁
--------------------------
==》线程1未获取到锁
==》线程1直接返回旧商铺数据
@Autowired
private StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
@Override
public Result queryShopById(Long id) {
//逻辑
Shop shop = queryExpireTime(id);
if (shop == null){
return Result.fail("商铺不存在");
}
//返回商铺数据
return Result.ok(shop);
}
//逻辑
public Shop queryExpireTime(Long id) {
//1.查询Redis
String key = RedisConstants.CACHE_SHOP_KEY + id;
String strShop = stringRedisTemplate.opsForValue().get(key);//一定存在
//2.判断是否存在
if (StrUtil.isBlank(strShop)) {
//不存在直接返回
return null;
}
//3.存在,判断过期时间
RedisData redisData = JSONUtil.toBean(strShop, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
if(expireTime.isAfter(LocalDateTime.now())){
//没有过期,直接返回
return shop;
}
//4.过期
//获取锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Boolean lock = lock(lockKey);
if(lock){
//获取锁
//再次判断缓存是否过期
strShop = stringRedisTemplate.opsForValue().get(key);//一定存在
//判断缓存是否存在
if (StrUtil.isBlank(strShop)) {
//不存在直接返回
return null;
}
//存在,判断过期时间
RedisData redisData = JSONUtil.toBean(strShop, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
if(expireTime.isAfter(LocalDateTime.now())){
//没有过期,直接返回
return shop;
}
//过期,开启线程
CACHE_REBUILD_EXECUTOR.submit(() ->{
try {
this.expireTime(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
removeLock(lockKey);
}
});
}
//没有获取锁
return shop;
}
//存入逻辑Redis
public void expireTime(Long id,Long expire){
//根据id查询数据库
Shop shop = getById(id);
//存入Redis
RedisData redisData = new RedisData();
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expire));
redisData.setData(shop);
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(redisData));
}
解释:由于是热点key问题(key不会过期),你想一般在活动开始之前这些key是不是就需要准备好(已经缓存好了),所以说明什么,key一定是存在的(不存在,那么该key不是属于该活动返回空值就行),那么我们可以将之前设置给key的过期时间改为逻辑时间(key在活动时间内一定存在,逻辑时间就是活动时间),我们之后只需要判断活动是否已经结束就行(将逻辑时间与实际时间对比),未过期直接返回数据
过期,线程1获取锁,没有获取到说明已经有线程在执行,那么线程1也不需要等待直接返回一个旧的数据(只要锁没有释放,其他线程无需等待直接返回旧的数据),获取到锁,线程1开启一个新的线程2来执行重建缓存操作,而线程1还是直接返回旧的数据
注意:获取到锁成功后还需要判断Redis缓存是否过期,可能在线程拿到锁之前正好有另外一个线程刚好重建了缓存(更新了逻辑时间),那么我们需要再次判断避免重复构建
细节:由于之前实体类你没有单独设置一个逻辑时间属性,那么此时你需要用到该属性该怎么办
方法一:创建一个新的实体类写入时间属性,让原先实体类来继承
缺点:修改了原先实体类数据,并且以后每次需要实现逻辑时间属性时你都需要继承该类,过于繁琐
方法二:创建一个新实体类,写入时间属性并且写入Object类型属性,将原先的实体类数据封装到Object中即可
优点:实现了复用性,不需要修改原先实体类数据
总结:组合优先于继承
图:
4.4.封装缓存工具
实现:
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.entity.RedisData;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
@Slf4j
@Component
public class CacheUtils {
//注入
private final StringRedisTemplate stringRedisTemplate;
public CacheUtils(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
//线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
//穿透,写入Redis
private void set(Long time, TimeUnit unit, String key, Object value) {
String jsonStr = JSONUtil.toJsonStr(value);
stringRedisTemplate.opsForValue().set(key, jsonStr, time, unit);
}
//击穿,写入Redis
private void setTime(Long time, TimeUnit unit, String key, Object value) {
RedisData redisData = new RedisData();
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
redisData.setData(value);
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
//穿透
public <R,ID> R queryPenetrate(String keyPrefix, ID id, Class<R> type, Function<ID,R> function,Long time,TimeUnit unit) {
//1.查询Redis
String key = keyPrefix + id;
String JSON = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(JSON)) {
//存在直接返回
return JSONUtil.toBean(JSON, type);
}
if (JSON != null) {
return null;
}
//3.不存在,查询数据库
R r = function.apply(id);
//4.判断是否存在
if (r == null) {
set(RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES, key, "");
return null;
}
//5.存在,存入Redis
this.set(time, unit, key, r);
return r;
}
//逻辑击穿
public <R,ID> R queryExpireTime(String keyPrefix, String lockPrefix,ID id, Class<R> type, Function<ID,R> function,Long time,TimeUnit unit) {
//1.查询Redis
String key = keyPrefix + id;
String JSON = stringRedisTemplate.opsForValue().get(key);//一定存在
//2.判断是否存在
if (StrUtil.isBlank(JSON)) {
//不存在直接返回
return null;
}
//3.存在,判断过期时间
RedisData redisData = JSONUtil.toBean(JSON, RedisData.class);
LocalDateTime expireTime = redisData.getExpireTime();
JSONObject data = (JSONObject) redisData.getData();
R r = JSONUtil.toBean(data, type);
if(expireTime.isAfter(LocalDateTime.now())){
//没有过期,直接返回
return r;
}
//4.过期
//获取锁
String lockKey = lockPrefix + id;
Boolean lock = lock(lockKey);
if(lock){
//获取锁
//开启线程
CACHE_REBUILD_EXECUTOR.submit(() ->{
try {
//根据id查询数据库
R r1 = function.apply(id);
//存入Redis
this.setTime(time,unit,key,r1);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
removeLock(lockKey);
}
});
}
//没有获取锁
return r;
}
//获取锁
public Boolean lock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//释放锁
public void removeLock(String key) {
stringRedisTemplate.delete(key);
}
}
解释:由于是封装工具,那么我们需要做到多样性,方法传参时不能定义死,采用泛型来实现复用性,由于使用的是mybatis-plus工具(需要查询数据库)而我们的实体类不能确定,因此需要传参Class以及泛型函数