【Redis万字学习记录】-----Day4
缓存是数据交换的缓冲区,是存储数据的临时位置,缓存数据存储于 代码 中,而代码运行在内存中,内存的读写性能远远高于磁盘,因此使用缓存可以大大降低 用户访问并发量 带来的服务器读写压力。
一. 什么是缓存
1.简介
-
缓存是数据交换的缓冲区,是存储数据的临时位置,缓存数据存储于 代码 中,而代码运行在 内存 中,内存的读写性能远远高于磁盘,因此使用缓存可以大大降低 用户访问并发量 带来的服务器读写压力

2.缓存的作用和成本
-
缓存的作用:降低后端负载(存在缓存里就不用去数据库查),提高读写效率(用缓存不进磁盘读写)
-
缓存的成本:数据一致性成本,需要维护代码,运维(部署集群)
二. 添加商户缓存
1.根据id查询商户
-
流程图:

随着查询次数越来越多,Redis的命中率就会越来越高,就不用每次都查数据库了,形成良性循环
-
功能逻辑:
-
根据id从Redis里查询商户缓存,存在直接返回,不存在查询数据库---数据库不存在返回404,存在--将数据添加到Redis缓存--返回结果
-
代码:
//根据id查询商户信息
@Override
public Result queryById(Long id) {
String key=CACHE_SHOP_KEY+id;
//1.从缓存(Redis)中查询数据
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.如果有直接返回
if(StrUtil.isNotEmpty(shopJson)){
Shop shop= JSONUtil.toBean(shopJson,Shop.class);
return Result.ok(shop);
}
//3.如果缓存中没有,到数据库查询数据
Shop shop=getById(id);
//4.如果数据库没有,返回404
if(shop==null){
return Result.fail("店铺不存在呵呵!!");
}
//5.如果数据库有,将数据添加写入Redis中
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
//6.返回结果
return Result.ok(shop);
}
2.查询店铺列表
-
代码:添加缓存后,第一次访问接口速度1.8s,第二次就20ms了,速度大大提升
@Override
public Result queryTypeList() {
// 1、从Redis中查询店铺类型
String key = CACHE_SHOP_TYPE_KEY + UUID.randomUUID().toString(true);
//将店铺类型在Redis的数据类型设置为String
String shopTypeJSON = stringRedisTemplate.opsForValue().get(key);
List<ShopType> typeList = null;
// 2、判断缓存是否命中
if (StrUtil.isNotBlank(shopTypeJSON)) {
// 2.1 缓存命中,直接返回缓存数据
typeList = JSONUtil.toList(shopTypeJSON, ShopType.class);
return Result.ok(typeList);
}
// 2.1 缓存未命中,查询数据库
typeList = this.list(new LambdaQueryWrapper<ShopType>()
.orderByAsc(ShopType::getSort));
// 3、判断数据库中是否存在该数据
if (Objects.isNull(typeList)) {
// 3.1 数据库中不存在该数据,返回失败信息
return Result.fail("店铺类型不存在");
}
// 3.2 店铺数据存在,写入Redis,并返回查询的数据
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(typeList),
CACHE_SHOP_TYPE_TTL, TimeUnit.MINUTES);
return Result.ok(typeList);
}
}
三. 缓存的问题
-
既然Redis做缓存这么好,既能减轻数据库压力,也能大大减少查询用时,那是不是我们在所有查询接口都加缓存就万事大吉了呢?并不是,Redis在带来方便的同时,也有它的问题
1.数据一致性问题
-
假如我们更新了数据库中的数据,Redis里的数据并不会更新,由此带来了数据一致性问题,为了解决这个问题,我们必须将缓存和数据库中的数据做同步,也就是要更新缓存
-
缓存更新策略:主动更新策略胜出

-
主动更新策略


-
解决问题:我们采取给缓存中的店铺数据增加 超时时间 的方案

添加过期时间

四. 缓存穿透,缓存雪崩,缓存击穿
1.缓存穿透
-
概念:缓存穿透指的是 客户端请求的数据 在 缓存和数据库中都不存在,这样请求一定会到达数据库,如果有人大量查询不存在的数据,会给数据库造成巨大压力
2.解决方法:缓存空对象 和 布隆过滤

-
缓存空对象解决方案
-
既然缓存穿透是 查询的对象在缓存和数据库中都不存在,那么我们就无论查询的对象在数据库中存在与否,都把它添加到缓存中,这样下一次查询相同的对象,请求就不会到达数据库
-
业务逻辑
-
和之前我们按照id查询商铺的逻辑基本一致,只不过这次无论数据库中是否有这个数据,我们都把它添加到缓存中

空对象也添加进缓存中

-
代码
@Override
public Result queryById(Long id) {
String key=CACHE_SHOP_KEY+id;
//1.从缓存(Redis)中查询数据
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.如果缓存命中直接返回
if(StrUtil.isNotEmpty(shopJson)){
Shop shop= JSONUtil.toBean(shopJson,Shop.class);
return Result.ok(shop);
}
//判断命中的是否是空值
if(shopJson != null){
//返回错误信息
return Result.fail("店铺不存在呵呵!!");
}
//3.如果缓存中没有,到数据库查询数据
Shop shop=getById(id);
//4.如果数据库没有,把空值写入到Redis中 并设置短的过期时间
if(shop==null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在呵呵!!");
}
//5.如果数据库有,将数据添加写入Redis中
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
//6.返回结果
return Result.ok(shop);
}
2.缓存雪崩
-
概念
-
缓存雪崩指的是在同一时间 大量key同时失效 或者 Redis崩了导致大量请求到达数据库
-
一些解决方案
-
给不同Key的 TTL添加随机值----防止大量KEY同时过期
-
利用 Redis集群 提高服务的可用性
-
给缓存业务添加降级限流策略
-

3.缓存击穿
-
概念:缓存击穿也叫热点key问题,指的是某些 高并发访问 并且缓存重建过程复杂的KEY突然失效,无数的请求访问会给数据库带来巨大冲击
-
解决方案:互斥锁 和 逻辑过期

五. 互斥锁和逻辑过期解决缓存击穿
1.互斥锁
-
互斥锁的逻辑
-
让一个线程获取锁后,只由它来查询数据库,完成缓存重建,其他线程重复查询缓存,获取锁的过程,直到缓存完成重建,其他线程就可以查询缓存了
-
互斥锁的功能
-
1)获取锁和释放锁必须是原子操作,不能被分割 ---原子性
-
2)同一把锁只能被一个线程持有 ---唯一性
-
3)同一线程可多次获取同一把锁 ---可重入性(可选)
-
4)锁必须有一个过期时间,防止死锁
-
为什么不用本地锁synchronized?
-
多台Tomcat服务器时,本地锁不起作用。两个请求分别由两个Tomcat发出时,两个请求各会获取一把锁,都到达数据库
-
最终解决方案 ---Redis的 SETNX(setIfAbsent)命令
// 获取锁
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// ↑
// 只有 key 不存在时,才能设置成功!
//释放互斥锁
private void unlock(String key){
stringRedisTemplate.delete(key);
}
-
互斥锁解决缓存击穿
//互斥锁解决缓存击穿
private Shop queryWithMutex(Long id) {
String key=CACHE_SHOP_KEY+id;
//1.从缓存(Redis)中查询数据
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.如果缓存命中直接返回
if(StrUtil.isNotEmpty(shopJson)){
Shop shop= JSONUtil.toBean(shopJson,Shop.class);
return shop;
}
//3.判断命中的是否是空值
if(shopJson != null){
//返回错误信息
return null;
}
//4.实现缓存重建
//4.1获取互斥锁
String lockKey="lock:shop"+id;
Shop shop= null;
try {
boolean isLock=tryLock(lockKey);
//4.2判断是否获取成功,这里为了代码简洁优雅,我们依旧选择反逻辑写
if(!isLock){
//4.3失败则休眠并重试
Thread.sleep(50);
return queryWithMutex(id);
}
//5.成功,则到数据库查询数据
shop = getById(id);
//6.如果数据库没有,把空值写入到Redis中 并设置短的过期时间
if(shop==null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//7.如果数据库有,将数据添加写入Redis中
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//8.释放互斥锁
unlock(lockKey);
}
//8.释放互斥锁
unlock(lockKey);
//9.返回结果
return shop;
}
-
用互斥锁的情况
-
在代码中我们可以看到,没获取到互斥锁的线程,会一直重复获取直到缓存重建完成,这就会导致查询变慢,但查询到的数据一定是准确的,所以 互斥锁解决缓存击穿 适合 数据必须最新,数据变更频繁的业务
2.逻辑过期
-
概念
-
既然缓存击穿的发生原因是 热点KEY 突然在Redis中过期 那我们直接将它设置成永不过期不就行了吗??!!但有一个问题,难道这个缓存数据就不更新了吗?不可能,所以我们要给它额外设置一个逻辑过期的字段,当时间超过逻辑过期的时间,就代表这条数据需要更新了
-
用什么数据结构?
-
因为我们需要给数据额外添加过期时间,就需要把过期时间和原对象打包到一起,我们新建一个类,不但可以实现“打包”,还可以逻辑复用给 其他所有对象
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
然后我们把RedisData存进Redis里就行了,还用String
-
逻辑过期解决缓存击穿
//逻辑过期解决缓存击穿
private Shop queryWithLogicalExpire(Long id) {
String key=CACHE_SHOP_KEY+id;
//1.从缓存(Redis)中查询数据
String shopJson=stringRedisTemplate.opsForValue().get(key);
//2.如果缓存未命中直接返回
if(StrUtil.isEmpty(shopJson)){
return null;
}
//3.命中,需要先把Json反序列化为对象
RedisData redisData=JSONUtil.toBean(shopJson,RedisData.class);
Shop shop=JSONUtil.toBean((JSONObject) redisData.getData(),Shop.class);
LocalDateTime expiretime=redisData.getExpireTime();
//4.判断是否过期
if(expiretime.isAfter(LocalDateTime.now())){
//4.1未过期,直接返回店铺信息
return shop;
}
//4.2已过期,需要缓存重建
//5.缓存重建
//5.1 拿互斥锁
String lockKey="lock:shop"+id;
boolean islock=tryLock(lockKey);
//5.2 判断是否拿到锁
//5.3 成功则开启独立线程,实现缓存重建
if(islock){
//用线程池
CACHE_REBUILD_EXECUTOR.submit(() ->{
//重建缓存
try {
this.saveShop(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unlock(lockKey);
}
});
}
//5.4 失败直接返回过期的商铺信息
return shop;
}
-
什么时候用逻辑过期?
-
在代码中我们可以发现,对于没拿到锁的线程,直接返回了过期的旧数据,甚至拿到锁的线程,进入if的true块后,我们用线程池异步 重建缓存,这样甚至拿到锁的这个线程也直接拿了过期数据返回,性能很高,所以逻辑过期适用的情况是 访问量大,需要高并发,数据更新不频繁,对数据准确不做高要求的情况
六. 逻辑抽离和复用
-
我们将刚才解决缓存穿透 和 缓存击穿的逻辑全部抽出 封装到一个工具类,这样就可以对所有对象,而不但是我们的查询商铺功能使用了
-
public class CacheClient { private final StringRedisTemplate stringRedisTemplate; //线程池,用来开启线程 private static final ExecutorService CACHE_REBUILD_EXECUTOR=java.util.concurrent.Executors.newFixedThreadPool(10); public CacheClient(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } //1.将任意Java对象转为JSON字符串并存储到String类型的key中,并可以设置过期时间 public void set(String key, Object value, long time, TimeUnit Unit) { stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, Unit); } //2.将任意Java对象转为JSON字符串并存储到String类型的key中,并可以设置逻辑过期时间,用于解决缓存击穿 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 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); } //3.根据指定的key查询缓存,并反序列化为指定类型,用缓存空值的方式解决缓存穿透问题 public <R,ID> R queryWithPathThrough(String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time,TimeUnit unit) { String key=keyPrefix+id; //1.从缓存(Redis)中查询数据 String json=stringRedisTemplate.opsForValue().get(key); //2.如果缓存命中直接返回 if(StrUtil.isNotEmpty(json)){ return JSONUtil.toBean(json,type); } //判断命中的是否是空值 if(json != null){ //返回错误信息 return null; } //3.如果缓存中没有,到数据库查询数据 R r=dbFallback.apply(id); //4.如果数据库没有,把空值写入到Redis中 并设置短的过期时间 if(r==null){ stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } //5.如果数据库有,将数据添加写入Redis中 this.set(key,r,time,unit); //6.返回结果 return r; } //4.根据指定的key查询缓存,并反序列化为指定类型,用逻辑过期的方式解决缓存击穿问题 //获取互斥锁 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); } }
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)