本博客为个人学习笔记,学习网站:黑马程序员Redis入门到实战 实战篇之商户查询缓存
目录
什么是缓存
添加Redis缓存
缓存更新策略
数据库缓存不一致解决方案
案例:给查询商铺的缓存添加超时剔除和主动更新策略
缓存穿透
案例:实现缓存空对象方案
缓存穿透小结
缓存雪崩
缓存击穿
基于互斥锁解决缓存击穿逻辑
基于逻辑过期解决缓存击穿逻辑
互斥锁与逻辑过期对比总结
基于互斥锁方式解决缓存击穿问题
基于逻辑过期方式解决缓存击穿问题
什么是缓存
1. 缓存就是数据交换的缓冲区(称作Cache),是存贮数据的临时地方,一般读写性能较高。
2. 缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,因此缓存可以大大降低用户访问并发量带来的服务器读写压力。
3. 实际开发过程中,企业的数据量少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术。
但是缓存也会增加代码复杂度和运营的成本:
缓存的作用:降低后端负载、提高读写效率,降低响应时间
缓存的成本:数据一致性成本:确保缓存和数据库中的数据是一致的
添加Redis缓存
原始的根据id查询商铺代码(直接访问数据库拿到商铺信息)
/*** 根据id查询商铺信息* @param id 商铺id* @return 商铺详情数据*/
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {return Result.ok(shopService.getById(id));
}
现在我们需要对根据id查询商铺这段代码进行改造,为其添加Redis缓存
修改controller层代码
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {return shopService.queryById(id);
}
修改Service层代码
//Service接口类代码
Result queryById(Long id);//Service接口实现类代码
@Override
public Result queryById(Long id) {String key = "cache:shop:" + id;// 1.从redis查询商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(key);// 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.若存在,将该商铺信息写入redisstringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop));// 7.返回return Result.ok(shop);
}
缓存更新策略
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行筛选更新,或者淘汰。
数据库缓存不一致解决方案
由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在。
解决缓存与数据库问题的方案有如下3种:(详细内容可参考视频P38黑马程序员Redis从入门到实战)
1. Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案。
2. Read/Write Through Pattern : 缓存与数据库整合为一个服务由系统本身去维护一致性。调用者调用该服务,无需关心缓存一致性问题。
3. Write Behind Caching Pattern :调用者只操作缓存,由其它线程异步的将缓存数据持久化到数据库,保证最终一致。
综合考虑使用方案一,但是方案一调用者如何处理呢?这里有几个问题:
1. 当我们对数据库中的数据进行修改时,我们应该选择删除对应缓存还是更新对应缓存?
更新缓存:若每次更新数据库时都更新缓存,如果在一连串的更新中都没有对数据进行查询,那么只有最后一次更新是有意义的,中间的更新产生的无效写操作较多(X)
删除缓存:更新数据库时让删除缓存,等待下次查询时再把数据从数据库中更新到缓存 (✔)
2. 为确保缓存与数据库的数据一致性,应该如何保证缓存与数据库的操作同时成功或失败?
单体系统:将缓存与数据库操作放在一个事务
分布式系统:利用TCC等分布式事务方案
3. 我们应该选择先操作缓存还是先操作数据库?
先删除缓存,再操作数据库(X)
先操作数据库,再删除缓存 (✔)
解析(见下图,不要嫌麻烦,建议直接看视频理解更快 P38黑马程序员Redis从入门到实战):我们应选择先操作数据库,再删除缓存。原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程1先来,它先把缓存删了,此时线程2过来,由于线程2查询缓存数据并不存在,此时线程2写入缓存,当线程2写入缓存后,线程1再执行更新动作时,实际上线程2写入缓存的就是旧数据,导致缓存中新数据被旧数据覆盖了。
案例:给查询商铺的缓存添加超时剔除和主动更新策略
需求1. 修改之前接口实现类中的部分代码,为其添加设置超时时间:如果在缓存中未找到所需数据,则从数据库查询,在将该数据填入缓存的同时为其设置超时时间。
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),30L, TimeUnit.MINUTES);
需求2. 根据id修改店铺时,先修改数据库,再删除缓存
Controller层代码
/*** 更新商铺信息** @param shop 商铺数据* @return 无*/@PutMappingpublic Result updateShop(@RequestBody Shop shop) {// 写入数据库return shopService.update(shop);}
Service层代码
//Service接口类方法
Result update(Shop shop);//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());// 3.返回结果return Result.ok();
}
缓存穿透
案例:实现缓存空对象方案
修改和新增查询商铺Service层接口实现类中的代码
1.代码修改: 在数据库查询不到该商铺信息时,向缓存中添加空值。
2. 代码新增:新增判断代码:当拿到缓存中的值,并判断该值为空(""或null)后,再次判断该值是否为空值(""),如果为空值,则说明该空值是先前在数据库查找不到店铺信息时向缓存写入的,间接说明数据库不存在该店铺信息,因此直接返回结果信息即可。
缓存穿透小结
缓存穿透产生的原因是什么?
用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求给数据库带来巨大压力。
缓存穿透的解决方案有哪些?
1. 缓存null值
2. 布隆过滤
3. 增强id的复杂度,避免被猜测id规律,从而发起恶意请求
4. 做好数据的基础格式校验
5. 加强用户权限校验
6. 做好热点参数的限流
缓存雪崩
缓存雪崩这部分内容视频里只做了介绍,没有给案例学习。
缓存击穿
相比缓存雪崩是大量的key失效,缓存击穿指的是部分特殊的key失效带来的问题。缓存击穿问题也称作热点Key问题,就是一些被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大
缓存击穿的常见解决方案有:
1.互斥锁 2.逻辑过期
基于互斥锁解决缓存击穿逻辑
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
基于逻辑过期解决缓存击穿逻辑
我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,数据不会失效,就不会有缓存击穿的问题。但是不设置过期时间,我们怎么能知道数据是否过期呢,因此我们选择采用逻辑过期方案。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个单独的线程去进行以前重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。
互斥锁与逻辑过期对比总结
互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响。
逻辑过期方案:线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦。
基于互斥锁方式解决缓存击穿问题
核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是 进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿
基于逻辑过期方式解决缓存击穿问题
思路分析:当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。