缓存是什么
缓存是数据交换的缓冲区,是存贮数据的临时地方,一般读写性能较高。

作用和成本:

添加redis缓存
缓存作用模型

根据id查询商铺缓存流程

代码
controller:

serviceimpl:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result quetyById(Long id) {
String key ="cache:shop:" + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
//2.判断存在
if (StrUtil.isNotBlank(shopJson)){
//3.存在返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//4.不存在根据id查询数据库
Shop shop = getById(id);
//5.不存在返回错误
if (shop==null){
return Result.fail("不存在");
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));
//返回
return Result.ok(shop);
}

练习:给商品类型加上缓存
controller:
@GetMapping("list")
public Result queryTypeList() {
// List<ShopType> typeList = typeService
// .query().orderByAsc("sort").list();
List<ShopType> typeList = typeService.queryTypeList();
return Result.ok(typeList);
}
impl:
@Autowired
private ShopTypeMapper shopTypeMapper;
@Override
public List<ShopType> queryTypeList() {
String key ="cache:shopType:";
//1.从redis查询商铺缓存
String shopType = stringRedisTemplate.opsForValue().get(key);
//2.判断存在
if (StrUtil.isNotBlank(shopType)){
//3.存在返回
return JSONUtil.toList(shopType, ShopType.class);
}
//4.不存在查询数据库
// 4. 不存在,查询数据库
List<ShopType> typeList = query().list();
// 5. 不存在,返回空列表
if (typeList.isEmpty()) {
return new ArrayList<>();
}
//6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(typeList));
//返回
return typeList;
}
}
缓存更新策略
一般有三种:

主动更新一般有三种模式

Cache Aside Pattern
需要考虑的问题:

先操作缓存和先操作数据库的区别:
先操作缓存时,可能会出现线程不安全问题。
比如有一个缓存和数据库都是10.此时有两个并发的线程。线程1先删除缓存,再将数据库的数据更新为20。而线程2查询缓存,由于删除缓存未命中,再查询数据库,查询到20。再将数据写道=倒缓存。

而正常来说这是我们需要实现的,可往往会有这样一个情况:一个线程执行过程中,另一个线程也进行执行:
比如线程1业务比较复杂,更新数据库比较缓慢,线程2突然运行,查不到缓存,查询数据库又只能查询到没有完成更新的数据库,写入缓存就是旧数据,10。而线程1终于更新完毕数据库,数据库数据就与缓存数据不一致。这就是线程安全问题。

先操作数据库再删除缓存:
线程2想要完成更新,先更新数据库,再删除缓存。此时无论是哪个线程查询 ,都是未命中查询数据库再写入缓存。

但是也会出现多线程穿插情况:就像先删除一样。但也有一种特殊情况:
假设一个线程正在查询,恰好缓存失效,线程1查询发现没有,未命中,去查询数据库为10,准备写入缓存为10,但还没有写。在此时,线程二来更新数据库为20,由于没有缓存,不用删除缓存,线程1写入缓存成功为10,而数据库为20。

但是这样的线程不安全可能性并不高,因为缓存速度远高于数据库操作。一旦发生写一个缓存超时时间即可,明显先操作数据库更优。

案例

1说起来很多,其实就是之前shopserviceimpl中写的那样,在写入缓存多加一个超时时间而已:

2:

Read/Write Through Pattern
对调用者简单,但是维护这样一个服务比较困难,多为自己开发,成本高。并且调用者不知道操作的是缓存还是数据库。
Write Behind Caching Pattern
和方案二的区别就是调用者明确知道自己操作的是缓存。缺点就是成本高还有一致性难以保证。
缓存穿透
缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,这样缓存永不生效,这些请求都会打到数据库上。
客户发起一个不存在的请求,数据库返回空,如果并发发送多个这样的请求,所有的请求到数据库这样会把数据库搞垮。
为了解决这样的问题,有两种解决方法:缓存空对象以及布隆过滤。
缓存空对象:简单暴力。
思路,你发起的空值,为了不再请求数据库,将空的值(指缓存和数据库都没有的值)缓存到redis中。
优劣明显:简单维护方便,但是内存损耗多,短期也会不一致,不过缺点可可以通过设置一个短期的TTL来进行清除。

布隆过滤:
准确来讲是一种算法,做法是在客户端与redis之间加了一层过滤。请求先经过布隆过滤,判断是否存在。


布隆过滤又是怎么知道是否有数据的?
原理简单来说可以认为是一个byte数组,里面存储的二进制位。当判断是否存在时并不是真的将数据存储到布隆过滤器里,而是将这些数据基于某一种哈希算法,计算出哈希值,再将哈希值转为二进制保存到布隆过滤器。而后判断是否存在时就去看对应的位置是0还是1.
当然这不一定准确,不存在一定不存在。存在的话不一定是存在的。
案例:查询商铺信息
方案1(缓存空对象)流程变化:

StrUtil的方法:isNotBlank具体:

只有是字符串的才是true,意味着只有商铺数据才是true。

第三步存在返回这里逻辑有点难缕清:
if (StrUtil.isNotBlank(shopJson)){
//3.存在返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//判断命中的是否为空值,因为字符串会在上个if,这里就两种情况,null和空字符串
if (shopJson != null) {
//返回错误信息
return Result.fail("店铺不存在");
}
假设传进来的有正确商铺,那么它会被isnotblank拦截返回。因为这是根据id查询店铺,所以攻击方式并不多,传入数据库不存在的id,传入null以及空id:

正确的成功返回,不正确的分为不存在的id,直接查询数据库再写入空缓存。
shopJson != null 就等价于:Redis 里存的是「空字符串」!
传入null和""并不完全相同:
情况 A:前端传入 id: null
id = null
key = "cache:shop:null"
情况 B:前端传入 id: ""
id = ""
key = "cache:shop:" // 后面是空
当然都传入的是:
if (shopJson != null) → 成立 return 店铺不存在
他们的区别是:
区别 1:生成的 Redis Key 不同
id=null→cache:shop:nullid=""→cache:shop:
区别 2:数据库查询行为不同
getById(null)→ 很多 ORM 框架直接返回 null,不会执行 SQLgetById("")→ 可能会执行 SQL:select * from shop where id=''(但依然查不到数据)
区别 3:业务含义不同
null:没传这个字段"":传了,但值是空
缓存雪崩
缓存雪崩是指同一时段大量的缓存key同时失效或者redis服务宕机,导致大量请求到达数据库带来巨大压力。
缓存key失效还好,重点是避免redis服务宕机。提高redis高可用性,比如说打点redis集群形成主从,主宕机哨兵会从从群中选出,并且主从还可以数据同步。

缓存击穿
这也叫热点key问题,就是一个被高并发访问并且缓存重建业务的key突然失效,无数的请求访问会在瞬间给数据库带来巨大冲击。

互斥锁:
发现未命中中想要去做重建就得先获取锁,写入缓存后再释放锁,期间其他进程获取不到锁,获取失败就休眠一会再重新获取。

逻辑过期:某些重要数据不设置TTL,不设置的话又是如何知道这个缓存过期的:

这里的过期时间并不是ttl,而是添加缓存时在当前时间的基础上加上一个过期时间得到的一个时间加入进去。理论上只要写进去了以后都能查到。要是查询时发现过期(进行判断)就获取互斥锁,开启一个独立的线程进行查询重建缓存写入重置过期,并且释放锁,旧线程直接返回旧的数据,此时来个线程三去查询,由于分出来的独立线程二还没有结束,线程三会查询到旧的数据,获取互斥锁失败。
但是不会休眠重试,而是直接返回旧数据,等待线程二解放。


案例,基于互斥锁解决缓存击穿

这个锁是自定义的。
什么东西能达成互斥:redis有一个命令:setnx
用处是给一个key赋值,当key不存在的时候去执行,key存在就不执行。

如果有很多线程执行setnx操作,只会有第一个是成功的,其他线程指挥失败,类似于互斥。
释放锁就是将这个锁删除即可。

为了防止死锁,需要加上一个有效期。
实现业务功能:
先在shopserviceimpl中添加两个方法,分别是获取锁和释放锁:
/*
获取锁
*/
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);
}

将先前的缓存穿透封装成方法,因为我们要学互斥锁:


代码:
/*
互斥锁方法封装
*/
public Shop queryWithPassMutex(Long id) {
String key = "cache:shop:" + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
//2.判断存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在返回
Shop shop1 = JSONUtil.toBean(shopJson, Shop.class);
return shop1;
}
//判断命中的是否为空值,因为字符串会在上个if,这里就两种情况,null和空字符串
if (shopJson != null) {
//返回错误信息
return null;
}
//4.实现缓存重建
//4.1 获取互斥锁
String lockkey = "lock:shop:" + id;
Shop shop;
try {
boolean isLock = tryLock(lockkey);
//4.2判断是否获取成功
if (!isLock) {
//4.3失败休眠并重试
Thread.sleep(50);//休眠50毫秒
//重试就是重新开始查询语句
return queryWithPassMutex(id);
}
//获取成功,根据id查询数据库
shop = getById(id);
//5.不存在返回错误
if (shop == null) {
//将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
return null;
}
//6.存在,写入redis,设置超时时间
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//7.释放互斥锁
unLock(lockkey);
}
//8返回
return shop;
}
逻辑过期解决缓存击穿


首先是逻辑过期时间,这其实是一个字段,但是shop这个实体类没有这个字段。
解决的话可以直接在实体类中添加字段,当然这样并不友好。因为对原来的进行修改。
有两种思路:
1.在util包定义一个对象:
定义:
这样之后如何让shop实体类具备逻辑过期时间的属性,第一种方法就是让shop继承RedisData,这样还是会需要修改源代码。
第二种方法是在RediData添加Object属性就叫做data
也就是说RedisData自己带有过期时间并且里面带有数据,这样的可能稍微复杂一点。像这些热点数据,缓存是需要提前给他导入的。
由于没有后台管理,基于单元测试给这个缓存加入。
shopserviceimpl:

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:" + id, JSONUtil.toJsonStr(redisData));
}
接下来进行单元测试:

打开redis客户端:

数据预热完成了,接下来真正解决缓存击穿问题:
先进行方法调用:

然后配置线程池,因为获取锁后需要一个独立线程:

private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
逻辑过期方法解决缓存击穿:
public Shop queryWithLogicalExpire(Long id) {
String key ="cache:shop:" + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get("cache:shop:" + id);
//2.判断存在
if (StrUtil.isNotBlank(shopJson)){
//3.存在返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
//4.命中需要看到过期时间,先将json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
//店铺信息在它的data中
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:"+id;
boolean islock = tryLock(lockKey);
//6.2判断是否获取成功
if (islock){
//6.3成功,开启独立线程实现缓存重建,使用线程池去做
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//重建缓存
this.saveShop2Redis(id,30L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
//返回过期的商铺信息
return shop;
}

缓存工具封装
并不是封装一个完整的工具类而是举例

解决缓存穿透:
shopimpl:

工具类:

缓存击穿:
工具类:




shopimpl修改调用工具类:
