目录
一、基本概述
数据交换的缓冲区(称为Cache),是存储数据的临时地方,一般读写性能较高
举例:
计算机主要的构造是CPU、内存、磁盘,CPU的读写数据能力已经远远超过了内存、磁盘的读写数据的能力,但是CPU所做的任何的运算都需要先从我们的内存或者磁盘里读到数据并且放到寄存器才可以做运算
因为读写的能力远远低于CPU的运算能力,那这样计算机性能受到了一个限制,为了解决这个问题,人们在CPU内部添加了一个缓存
CPU会把经常需要读写的一些数据放到CPU缓存里面,当我们去做高速运算时,就不要每次都从内存或者磁盘中把数据读取出来,而是直接从缓存里面拿到数据进行运算
举例:
浏览器缓存:比如说一些静态的图片、css、js等基本上一成不变,也不用每次都进行请求,浏览器可以将其缓存到本地,大大的降低网络的延迟,提高页面响应速度
浏览器缓存未命中的怎么办?
去到tomcat,我们所编写的java应用
我们也可以在java代码中编写应用层缓存
把从数据库查到的数据放入到缓存中,之后读取时先从缓存中读取,如果缓存中没有再向数据库进行读取,具体代码以及细节,我们会在下面编写
二、添加Redis缓存
2.1 添加缓存概述
2.2 Controller层
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
2.3 Service层
参考流程图
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 1.从redis查询商铺缓存 这个地方可以用hash,我们这里用String演示一下
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:"+id);
// 2.判断Redis中是否存在
if(StrUtil.isNotBlank(shopJson)){
// 3.不为空,存在,直接返回(得将JSON字符串转化成对应的对象)
Shop shop = JSONUtil.toBean(shopJson,Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5. 判断数据库中是否存在
if(shop == null){
// 6. 不存在返回错误
return Result.fail("店铺不存在");
}
// 7.存在,写入Redis
stringRedisTemplate.opsForValue().set("cache:shop:"+id,JSONUtil.toJsonStr(shop));
// 8.返回
return Result.ok(shop);
}
2.4 效果图
当我们刷新页面之后,有关商铺的信息就不会走数据库,而是进入Redis查找
三、缓存更新策略
业务场景:
低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存(这个一般是不会更改的)
高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情查询的缓存
内存淘汰:
在一定程度上能保证数据一致性,当内存不足时,把一部分数据淘汰了,这个时候如果用户来查询这部分内容,在缓存中是查不到的,就去数据库查询,并且写入到缓存当中,完成数据一致
但是这个数据的一致性我们是不能控制的,他淘汰的是哪部分数据、什么时候淘汰我们实际是无法控制的
还有一点,如果数据一直没有被淘汰,那查询到的一直都是旧的数据,无法保证数据的一致性
所以内存淘汰策略的一致性是比较差的
但是好处是维护成本是几乎为0,全部由Redis进行控制
超时剔除:
利用Redis里面的expire设置过期时间
我们可以通过控制过期时间来控制一致性,但是也不是完全一致的。
比如说设置过期时间为30分钟,如果在这30分钟内数据库发生了变化,此时Redis缓存数据又与数据库中数据不一致
主动更新:
数据一致性还是不错的,毕竟没有一个东西能保证数据完全一致
维护成本高一点,需要自己敲代码写缓存等操作
3.1 主动更新策略
解释一下3
增删改查等所有的操作全部对缓存进行操作,这样一来缓存中的数据就是最新的数据,数据库中的数据就是旧的数据
现在有一个线程他们会及时的看一看缓存中有没有变化,如果有,它会把缓存数据写到数据库中,而且这个线程是异步的
这样有什么好处呢?
比如说我们在缓存中操作了读写十次,第十次的时候刚好异步线程来看看缓存和数据库有什么不同,进行更新,相当于这十次操作对于数据库来说就是一次,对效率也有大大的提升
最大的问题
我们要维护这么一个异步任务是比较复杂的,需要实时监控缓存中数据的一个变更
除此之外,一致性也挺难保证的,如果缓存已经执行了很多操作,但是没有触发异步更新操作写入数据库,在这一段时间内,缓存数据与数据库的数据是完全不一致的。
而且如果缓存出现了宕机,那这一部分数据就丢失了
所以说一致性和可靠性,也并没有想想的那么高
3.2 操作缓存和数据库时三个问题的考虑
3.2.1 删除缓存还是更新缓存?
- 更新缓存:每次更新数据库时都需要更新缓存,无效写入操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓
综上所述:选择删除缓存
假如我们对数据库操作了100次,然后此时并没有请求访问Redis的缓存,那岂不是我们还要更新100Redis? 这显然很不合理,所以选择删除!当第一次数据库操作的时候我们就把对应的缓存删除,所以接下来99次数据库操作都与Redis无关,只需要静静的等待有人发起对应请求访问Redis,然后发现Redis并没有对应缓存,然后访问数据库返回数据并且更新缓存
3.2.2 如何保证缓存与数据库的操作同时成功或失败?
假如我们在更新数据库的时候,对数据库的操作成功了,但是对缓存的操作失败了,这是很不合理的
那怎么保证?
- 单体系统:将缓存与数据库操作放在一个事务
- 分布式系统:利用TCC等分布式事物方案
3.2.3先操作缓存还是先操作数据库?
还要考虑线程安全的问题,在多线程并发的时候这两个操作可能有多个线程来回穿插执行,那这样谁先操作谁后操作就会造成不一样的线程安全问题
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存
答案是这两种都可以
上述的两种方案都会存在问题,如下图所示,都有很大的可能造成缓存与数据库不一致的问题
左侧的可能性出现比较高,右侧的可能性出现比较低 ,但是都有问题
先删除缓存,再操作数据库
左图
这种发生的概率挺高的,因为删除缓存的操作挺快的,但是更新数据库的操作比较慢,除此之外写缓存、查询数据库都是挺快的
刚开始数据一致,线程1删除缓存,但是此时线程1还没操作完
线程2查询缓存后,未命中,查询数据库并更新缓存
线程1的程序继续执行,更新了数据库,但是没有更新缓存,造成了数据不一致
先操作数据库,再删除缓存
假如expire时间到了,缓存恰好失效
这种情况发生的可能性高不高?
首先是两个线程并行执行、线程1查询时恰好缓存失效、恰好线程1要写缓存之前(但是写缓存的操作是微妙级别)线程2更新数据库(速度比较慢)并且删除缓存
其实在微妙级别中完成线程2的所有内容,还是有难度的。所以这个操作虽然有数据不一致性的可能性,但是很小
3.3 缓存更新策略最佳实践方案
-
低一致性需求:使用Redis自带的内存淘汰机制
-
高一致性需求:主动更新,并以超时剔除作为兜底方案
读操作
-
缓存命中则直接返回
-
缓存未命中则查询数据库,并写入缓存,设定超时时间
写操作
-
先写数据库,然后再删除缓存
-
要确保数据库与缓存操作的原子性
-
四、实现商铺缓存与数据库的双写一致
修改ShopController中的业务逻辑,满足下面的需求:
- 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存并设置超时时间
- 根据id修改店铺时,先修改数据库,再删除缓存
给查询商铺的缓存添加超时剔除和主动更新的策略
4.1 修改Service层查询代码
比之前多了一个设置超时时间
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 1.从redis查询商铺缓存 这个地方可以用hash,我们这里用String演示一下
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:"+id);
// 2.判断Redis中是否存在
if(StrUtil.isNotBlank(shopJson)){
// 3.不为空,存在,直接返回(得将JSON字符串转化成对应的对象)
Shop shop = JSONUtil.toBean(shopJson,Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5. 判断数据库中是否存在
if(shop == null){
// 6. 不存在返回错误
return Result.fail("店铺不存在");
}
// 7.存在,写入Redis
stringRedisTemplate.opsForValue().set("cache:shop:"+id,JSONUtil.toJsonStr(shop),30, TimeUnit.MINUTES);
// 8.返回
return Result.ok(shop);
}
4.2 更新业务的Controller层
/**
* 更新商铺信息
* @param shop 商铺数据
* @return 无
*/
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
return shopService.update(shop);
}
4.3 更新业务的Service层
@Override
@Transactional //如果报异常的话,整个记得回滚
public Result update(Shop shop) {
if(shop.getId() == null){
return Result.fail("店铺id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete("cache:shop:"+shop.getId());
return Result.ok();
}
五、缓存穿透
5.1 缓存穿透的解决思路
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
简洁说就是:当用户一直请求一个不存在的id时,从客户端向Redis请求,发现没有再向数据库找那个请求,发现也没有,然后将数据库中查询的null信息返回给客户端,如果被别有用心之人一直请求,会给我们的数据库造成很大的压力
解决方案
- 缓存空对象(可以是空白字符串)
优点:实现简单,维护方便
缺点:额外的内存消耗,可能造成数据短期不一致的问题
额外的内存消耗是可以理解的,这样就在Redis中存储了很多没有用的垃圾null信息(解决就是设置消失的时间)
短期不一致时因为我们在往Redis中缓存对应的null时,设置了一个时间,在这个时间内只要客户端访问就会访问Redis,但是万一这段时间内我们数据库改变了,但是Redis缓存没有改变,这样就造成了短期数据不一致(解决方案是每次更新数据库时更新缓存)
- 布隆过滤
其实是一个算法
优点:内存占用少,没有多余key
缺点:实现复杂,存在误判的可能(可能发生穿透)
布隆过滤器的原理:
简单理解成一个byte数组,里面存储的就是二进制位,当我们去判断数据库中的数据是否存在时,并不是真正的把数据存放到布隆过滤器,而是把这些数据基于某一种哈希算法,计算出哈希值转化为二进制位保存到布隆过滤器中
我们判断数据是否存在的时候后,以0或者1判断数据是否存在
存在与否是概率上的统计,并不是百分之百准确
它说不存在的时候,那就一定不存在
它说存在的时候,可能不存在
5.2 解决商铺查询缓存穿透问题
与之前的区别就是当在数据库中查询到对应id不存在的时候不再返回404.而是将空值写入Redis中
还有就是缓存命中的时候也有区别,我们需要判断一下是否是空值,如果是空值直接结束
5.2.1 修改查询商铺Service层
下面的空值值得都是空白字符串
与之前的区别就是在shop==null的时候将空值写入到Redis
值得说明的是if(StrUtil.isNotBlank(shopJson)) 只有里面是真正字符串的时候才是true
另一个区别就是在if(StrUtil.isNotBlank(shopJson)) 语句之后又添加了一个判断是不是空值,如果是空值就查询数据库,不是空值就是空白字符串就查询Redis防止穿透
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// TODO 1. 从Redis查询商铺缓存
//可以选择Hash结构,没问题,也能String
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
// TODO 2. 判断时Redis是否命中
if (StrUtil.isNotBlank(shopJson)){
// TODO 3. 存在,返回商户信息
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// TODO 多判断一步,命中的是否是空值
// 运行到这里,说明上面的if没有进去,->说明StrUtil.isNotBlank(shopJson)是false ->shopJson两种情况 空白字符串或者null
if( shopJson !=null){
// 不能等于null,就一定是一个空字符串
return Result.fail("店铺不存在");
}
// TODO 4. 不存在,向数据库进行查询
Shop shop = getById(id);
// TODO 5. 数据库不存在,返回错误
if (shop==null){
// 将空值写入redis
stringRedisTemplate.opsForValue().set("cache:shop:"+id,"",2, TimeUnit.MINUTES);
// 返回错误信息
return Result.fail("商户不存在");
}
// TODO 6. 存在,写入Redis
String shopTOJson = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set("cache:shop:"+id,shopTOJson,30, TimeUnit.MINUTES);
// TODO 7. 返回最终结果
return Result.ok(shop);
}
5.3 总结
5.3.1 缓存穿透产生的原因是什么?
用户查询的数据在Redis与数据库中都没有
5.3.2 缓存穿透解决方案有哪些?
- 缓存空白字符串
- 布隆过滤
- 增强id复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强数据权限校验
- 做好热点参数的限流
六、缓存雪崩
6.1雪崩问题及解决思路
指同一时间段大量的缓存key同时失效或者Redis服务宕(dang)机,导致大量请求到达数据库带来巨大压力
解决方案
- 1.给不同的key的TTL添加随机值
在批量导入缓存的时候添加随机TTL,这样就避免了TTL同时到期(分散了)
- 2.利用Redis集群提高服务的可用性
这是针对宕机的解决方案
- 3.给缓存业务添加降级限流策略
比如说拒绝部分服务访问数据库
- 4.给业务添加多级缓存
在Ngix中添加缓存,Redis崩了之后,还有其他的缓存方式
七、缓存击穿
7.1 缓存击穿问题及解决思路
也叫热点Key问题,就是一个高并发访问并且缓存重建业务较复杂的key突然失效,无数的请求访问会在瞬间给数据库带来巨大的冲击,如下图所示
常见的解决方案
- 互斥锁
此方案利用锁的方式,不让所有的请求都创建缓存数据,只让一个就可以了,但是性能较差
假设一万个线程来了,只有一个线程进行缓存处理,其他的都在等待
如果构建的时间比较久(比如200ms),在这段时间内其他线程只能够等待,所以性能比较差一点
线程1不释放锁,其他县城是无法获取锁的,那就没法命中缓存,其他线程会一直重试,直到线程1释放锁后才能继续向下执行
- 逻辑过期
不设置TTL,那我们不设置TTL怎么说明过期? 在VALUE中重新加一个字段来当做过期时间
很佛系发现过期并且获取不到锁,就返回旧数据
具体实现过程如下图所示
优缺点对比
7.2 基于互斥锁方式解决缓存击穿问题
需求:修改根据id查询商铺的业务,基于互斥锁方式来解决缓存击穿问题
这个地方有一个关键的点,这个锁并不是我们平常使用的那个锁(synchronize或者lock)
这个地方拿到过或者拿不到是需要我们自己进行定义的(自定义锁)
互斥锁:只有一个成功,其他失败
怎么自定义一个互斥锁?
借助String类型的setnx命令, Redis命令——通用命令、String类型、Key层级结构、Hash类型、List类型、Set类型、SortedSet类型redis多个层级命令查询,可以参照这个文章
这个命令有一个特点: 添加一个String类型的键值对,前提是这个key不存在,否则不执行(真正的新增功能),假如说添加的key在redis中已经有了,我们setnx再执行这key是,无法修改(key存在时无法写)
7.2.1 Controller层代码
与之前一模一样
/**
* 根据id查询商铺信息
* @param id 商铺id
* @return 商铺详情数据
*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
7.2.2 修改Service层代码
@Override
public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queryWithPassThrough(id);
// 利用互斥锁解决缓存击穿
Shop shop = queryWithMutex(id);
if(shop ==null){
return Result.fail("店铺不存在");
}
// 8.返回
return Result.ok(shop);
}
为什么做了两次缓存检查?
第二次检查是为了少检查几次数据库而已
/**
* 缓存击穿
*
* @param id
* @return
*/
public Shop queryWithMutex(Long id) {
// 1.从redis查询商铺缓存 这个地方可以用hash,我们这里用String演示一下
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
// 2.判断Redis中是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.不为空,存在,直接返回(得将JSON字符串转化成对应的对象)
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 缓存穿透这里得再添加一个看看是否是空值
if (shopJson != null) {
// 不能等于null,就一定是一个空字符串
return null;
}
// TODO 4.实现缓存重建
Shop shop = null;
try {
// 4.1获取互斥锁 锁的key和缓存key不一样
boolean isLock = tryLock("lock:shop" + id);
// 4.2判断是否获取成功
if (!isLock) {
// 4.3失败,则休眠并重试 重试就是重新执行查询动作,使用递归
Thread.sleep(50);
return queryWithMutex(id);
}
// 4.4.成功,根据id查询数据库
// 在此做缓存DoubleCheck,看看缓存中有无对应数据
shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
//判断Redis中是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 不为空,存在,直接返回(得将JSON字符串转化成对应的对象)
shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 缓存穿透这里得再添加一个看看是否是空值
if (shopJson != null) {
// 不能等于null,就一定是一个空字符串
return null;
}
// 根据id查询数据库
shop = getById(id);
// 5. 判断数据库中是否存在
if (shop == null) {
// 6. 不存在返回错误,并且将空值写入Redis 此时存放Redis就是空值
stringRedisTemplate.opsForValue().set("cache:shop:" + id, "", 2, TimeUnit.MINUTES);
return null;
}
// 7. 存在,写入Redis
stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(shop), 30, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 8.释放互斥锁
unlock("lock:shop" + id);
}
// 9.返回
return shop;
}
在Service层获取锁与释放锁代码逻辑
// 拿到锁
private boolean tryLock(String key){
//setIfAbsent方法就是Redis中的setnx
//在Redis命令行中的运行结果就是0或者1,但是在这的运行结果是true或false,但是返回的是Boolean类型,封装类
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//不建议直接返回:会自动拆箱,有时候会出现空指针
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unlock(String key){
stringRedisTemplate.delete(key)
}
7.3 基于逻辑过期方式解决缓存击穿问题
需求:修改根据id查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题
过期时间由程序员自己判断,这种方式Redis中是一直存在的,除非人工删除(一般活动结束的时候人工删除)
7.3.1 新增RedisData封装数据
将来我们存储Redis中的数据就是下面这个样子的
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
但是现在我们没有后台系统,只能用代码模拟一下
// 将店铺信息保存到Redis中
public void saveShop2Redis(Long id,Long expireSeconds) {
// 1.查询店铺数据
Shop shop = getById(id);
// 2.封装成逻辑过期
RedisData redisData = new RedisData();
redisData.setData(shop);
// plusSeconds(expireSeconds) 在当前时间的基础上增加多少秒
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3.写入Redis
stringRedisTemplate.opsForValue().set("cache:shop:" + id, JSONUtil.toJsonStr(redisData));
}
进行测试
@Resource
private ShopServiceImpl shopService;
@Test
void test(){
shopService.saveShop2Redis(1L,10L);
}
7.3.2 Service层代码
@Override
public Result queryById(Long id) {
// 缓存穿透
// Shop shop = queryWithPassThrough(id);
// 利用互斥锁解决缓存击穿
// Shop shop = queryWithMutex(id);
// 利用逻辑过期解决缓存击穿
Shop shop = queryWithLogicalExpire(id);
if(shop ==null){
return Result.fail("店铺不存在");
}
// 8.返回
return Result.ok(shop);
}
线程池知识Java——线程池详细讲解java线程池教程我爱布朗熊的博客-优快云博客
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);;
// 利用逻辑过期解决缓存击穿 "cache:shop:","lock:shop:",10L
public Shop queryWithLogicalExpire(Long id) {
String key = "cache:shop:" + id;
// TODO 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
// TODO 2. 判断时Redis是否命中
if (StrUtil.isBlank(shopJson)) {
// TODO 3.缓存不存在,直接返回空
return null;
}
// TODO 4.存在,需要先把JSON反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
// 因为我们在RedisData中设置data属性就是Object类型,所以当我们取的时候程序并不知道我们是什么类型,我们加一个强转就好了
JSONObject shopData = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(shopData, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// TODO 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// TODO 5.1 未过期,返回商铺信息
return shop;
}
// TODO 5.2 已过期,需要缓存重建
// TODO 6. 缓存重建
// TODO 6.1 获取互斥锁
String lockKey = "lock:shop:" + id;
boolean isLock = tryLock(lockKey);
// TODO 6.2 判断是否获取锁成功
if (isLock) {
// TODO 6.3 成功,获取锁成功应该再次检测Redis缓存是否过期,做DoubleCheck,如果存在则无序重建缓存
shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
redisData = JSONUtil.toBean(shopJson, RedisData.class);
shopData = (JSONObject) redisData.getData();
shop = JSONUtil.toBean(shopData, Shop.class);
expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
// TODO 未过期,返回商铺信息
return shop;
}
// TODO 成功,但是缓存过期了,开启独立线程,实现缓存重建(建议使用线程池)
CACHE_REBUILD_EXECUTOR.submit(()->{
try{
// 重建缓存
this.saveShop2Redis(id,30L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// TODO 6.4 失败,返回已经过期的商品信息
return shop;
}
// 将店铺信息保存到Redis中
private void saveShop2Redis(Long id,Long expireSeconds){
// 1.查询店铺数据
Shop shop = getById(id);
// 2.封装成逻辑过期
RedisData redisData = new RedisData();
redisData.setData(shop);
// plusSeconds(expireSeconds) 在当前时间的基础上增加多少秒
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3.写入Redis
stringRedisTemplate.opsForValue().set("cache:shop:"+id,JSONUtil.toJsonStr(redisData));
}
// 定义两个关于锁的方法
// 获取锁的代码
private boolean tryLock(String key){
// 这段代码执行完成之后返回的应该是0或者1,在这里帮我们封装成了Boolean
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10,TimeUnit.SECONDS);
// 因为封装成了Boolean,这里我们不能直接返回,直接返回会拆箱然后可能出现空指针,所以使用一个工具类
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unlock(String key){
stringRedisTemplate.delete(key);
}
八、缓存工具封装
方法1:将任意ava对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
方法2:将任意java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
方法1与方法3对应,解决普通缓存问题,解决缓存穿透(缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库)
方法2与方法4对应,解决缓存击穿问题(针对热点key的)
封装的时候降低我们以后使用难度
前两个是存,后两个是取
方法一和方法三是针对普通的缓存,解决穿透
方法二和方法四是针对热点key的,解决击穿
8.1 两个set方法(方法一与方法二)
@Slf4j
@Component
public class CacheClient {
// @Resource 也可以用注解注入,但是我们这里是用的构造方法
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
// 方法1
public void set(String key, Object value, Long time, TimeUnit unit) {
// 我们往Redis存的时候不能是Object类型,我们需要把Object序列化为JSON字符串
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
// 方法2 逻辑过期 比上面的方法的操作多了一个逻辑过期字段而已
public void setWithLogicalExpire(String key, Object value,Long time,TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 我们往Redis存的时候不能是Object类型,我们需要把Object序列化为JSON字符串
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
}
8.2两个get方法(方法三、方法四)
Function<ID, R> dbFallback参数解释:
我们在下面进行数据库查询的时候肯定不能和下面这样
Shop shop = getById(id);如果是这样就把查询类型固定了,万一我们不查询Shop呢?
但是我们也无法再代码中决定调用哪个Service中的getById,那这样怎么办呢?
交给调用者,调用者肯定知道。所以我们要求调用者在调用方法的时候把调用哪个Service的数据库的逻辑传进来就好了
怎么传递逻辑呢?
使用函数式编程。我们在这里有参有返回值,使用Function<ID, R> dbFallback,ID表示参数的类型,R表示返回值的类型
8.2.1 方法三 解决穿透
// 方法3 解决穿透
// 返回值不确定,我们要使用泛型,比如<R>R,具体是什么类型由用户传入Class<R> type
public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback,Long time, TimeUnit unit) {
String key = keyPrefix+id;
// TODO 1. 从Redis查询商铺缓存
//可以选择Hash结构,没问题,也能String
String json = stringRedisTemplate.opsForValue().get(key );
// TODO 2. 判断时Redis是否命中
if (StrUtil.isNotBlank(json)) {
// TODO 3. 存在,返回商户信息
return JSONUtil.toBean(json, type);
}
// TODO 多判断一步,命中的是否是空值
// 运行到这里,说明上面的if没有进去,->说明StrUtil.isNotBlank(shopJson)是false ->shopJson两种情况 空白字符串或者null
if (json != null) {
// 不能等于null,就一定是一个空字符串
return null;
}
// TODO 4. 不存在,向数据库进行查询
R r = dbFallback.apply(id);
// TODO 5. 数据库不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// TODO 6. 存在,写入Redis
String shopTOJson = JSONUtil.toJsonStr(r);
stringRedisTemplate.opsForValue().set(key, shopTOJson, time, unit);
// TODO 7. 返回最终结果
return r;
}
封装好了怎么用呢?
@Resource
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
// 工具类解决缓存穿透
Function<Long,Shop> function = new Function<Long, Shop>() {
@Override
public Shop apply(Long id) {
return getById(id);
}
};
Shop shop = cacheClient.queryWithPassThrough("cache:shop:",id,Shop.class,function,20L,TimeUnit.MINUTES);
if(shop==null){
return Result.fail("商铺不存在");
}
return Result.ok(shop);
}
或者是下面这种方式,使用Lambda表达式
@Resource
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
// 缓存穿透
Shop shop = cacheClient.queryWithPassThrough("cache:shop:",id,Shop.class,id2->getById(id2),30L,TimeUnit.MINUTES);
if(shop ==null){
return Result.fail("店铺不存在");
}
// 8.返回
return Result.ok(shop);
}
8.2.2 方法4 解决穿透
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);;
// 方法4:解决缓存击穿
public <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// TODO 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
// TODO 2. 判断时Redis是否命中
if (StrUtil.isBlank(json)) {
// TODO 3.缓存不存在,直接返回空
return null;
}
// TODO 4.存在,需要先把JSON反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
// 因为我们在RedisData中设置data属性就是Object类型,所以当我们取的时候程序并不知道我们是什么类型,我们加一个强转就好了
JSONObject shopData = (JSONObject) redisData.getData();
R r = JSONUtil.toBean(shopData, type);
LocalDateTime expireTime = redisData.getExpireTime();
// TODO 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// TODO 5.1 未过期,返回商铺信息
return r;
}
// TODO 5.2 已过期,需要缓存重建
// TODO 6. 缓存重建
// TODO 6.1 获取互斥锁
String lockKey = "lock:shop:" + id;
boolean isLock = tryLock(lockKey);
// TODO 6.2 判断是否获取锁成功
if (isLock) {
// TODO 6.3 成功,获取锁成功应该再次检测Redis缓存是否过期,做DoubleCheck,如果存在则无序重建缓存
json = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
redisData = JSONUtil.toBean(json, RedisData.class);
shopData = (JSONObject) redisData.getData();
r = JSONUtil.toBean(shopData, type);
expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
// TODO 未过期,返回商铺信息
return r;
}
// TODO 成功,但是缓存过期了,开启独立线程,实现缓存重建(建议使用线程池)
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//TODO 重建缓存 先查数据库,再写入Redis
R r1 = dbFallback.apply(id);
//TODO 写入缓存要带有逻辑过期
this.setWithLogicalExpire(key,r1,time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
// TODO 6.4 失败,返回已经过期的商品信息
return r;
}
// 拿到锁
private boolean tryLock(String key) {
//setIfAbsent方法就是Redis中的setnx
//在Redis命令行中的运行结果就是0或者1,但是在这的运行结果是true或false,但是返回的是Boolean类型,封装类
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//不建议直接返回:会自动拆箱,有时候会出现空指针
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
8.3 工具类代码总结
@Slf4j
@Component
public class CacheClient {
//@Resource 也可以用注解注入
private final StringRedisTemplate stringRedisTemplate;
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
//方法1 解决穿透
public void set(String key, Object value, Long time, TimeUnit unit) {
// 我们往Redis存的时候不能是Object类型,我们需要把Object序列化为JSON字符串
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
//方法2 解决击穿 使用逻辑过期 比上面的方法的操作多了一个逻辑过期字段而已
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 我们往Redis存的时候不能是Object类型,我们需要把Object序列化为JSON字符串
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
// 方法3 解决穿透
// 返回值不确定,我们要使用泛型,比如<R>R,具体是什么类型由用户传入Class<R> type
public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// TODO 1. 从Redis查询商铺缓存
//可以选择Hash结构,没问题,也能String
String json = stringRedisTemplate.opsForValue().get(key);
// TODO 2. 判断时Redis是否命中
if (StrUtil.isNotBlank(json)) {
// TODO 3. 存在,返回商户信息
return JSONUtil.toBean(json, type);
}
// TODO 多判断一步,命中的是否是空值
// 运行到这里,说明上面的if没有进去,->说明StrUtil.isNotBlank(shopJson)是false ->shopJson两种情况 空白字符串或者null
if (json != null) {
// 不能等于null,就一定是一个空字符串
return null;
}
// TODO 4. 不存在,向数据库进行查询
R r = dbFallback.apply(id);
// TODO 5. 数据库不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// TODO 6. 存在,写入Redis
String shopTOJson = JSONUtil.toJsonStr(r);
stringRedisTemplate.opsForValue().set(key, shopTOJson, time, unit);
// TODO 7. 返回最终结果
return r;
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);;
// 方法4:解决缓存击穿
public <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// TODO 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
// TODO 2. 判断时Redis是否命中
if (StrUtil.isBlank(json)) {
// TODO 3.缓存不存在,直接返回空
return null;
}
// TODO 4.存在,需要先把JSON反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
// 因为我们在RedisData中设置data属性就是Object类型,所以当我们取的时候程序并不知道我们是什么类型,我们加一个强转就好了
JSONObject shopData = (JSONObject) redisData.getData();
R r = JSONUtil.toBean(shopData, type);
LocalDateTime expireTime = redisData.getExpireTime();
// TODO 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// TODO 5.1 未过期,返回商铺信息
return r;
}
// TODO 5.2 已过期,需要缓存重建
// TODO 6. 缓存重建
// TODO 6.1 获取互斥锁
String lockKey = "lock:shop:" + id;
boolean isLock = tryLock(lockKey);
// TODO 6.2 判断是否获取锁成功
if (isLock) {
// TODO 6.3 成功,获取锁成功应该再次检测Redis缓存是否过期,做DoubleCheck,如果存在则无序重建缓存
json = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
redisData = JSONUtil.toBean(json, RedisData.class);
shopData = (JSONObject) redisData.getData();
r = JSONUtil.toBean(shopData, type);
expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
// TODO 未过期,返回商铺信息
return r;
}
// TODO 成功,但是缓存过期了,开启独立线程,实现缓存重建(建议使用线程池)
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
//TODO 重建缓存 先查数据库,再写入Redis
R r1 = dbFallback.apply(id);
//TODO 写入缓存要带有逻辑过期
this.setWithLogicalExpire(key,r1,time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
// TODO 6.4 失败,返回已经过期的商品信息
return r;
}
// 拿到锁
private boolean tryLock(String key) {
//setIfAbsent方法就是Redis中的setnx
//在Redis命令行中的运行结果就是0或者1,但是在这的运行结果是true或false,但是返回的是Boolean类型,封装类
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//不建议直接返回:会自动拆箱,有时候会出现空指针
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}