Redis实战篇教程(二)
文章目录
前言
本系列文章是针对于黑马的Redis教学视频中的实战篇,本篇文章是实战篇的第二部分——缓存更新策略
缓存更新策略
内存淘汰 | 超时剔除 | 主动更新 | |
---|---|---|---|
说明 | 不用自己维护,利用Redis的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存 | 给缓存数据添加TTL时间,到期后自动删除缓存,下次查询时更新缓存 | 自己编写业务逻辑,在修改数据库的同时,更新缓存 |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
业务场景:
- 低一致性需求:使用内存淘汰机制,例如商铺类型的查询缓存
- 高一致性需求:主动更新,并以超时剔除为兜底方案,例如店铺详情查询的缓存
主动更新策略
- Cache Aside Pattern:自己写代码,在更新数据库的同时更新缓存
- Read/Write Through Pattern:将缓存和数据库整合为一个服务,有服务来维护一致性,调用者调用该服务,无需关心缓存一致性问题。
- Write Behind Caching Pattern:调用者只操作缓存,有其他线程异步的将缓存数据持久化到数据库,保证最终一致。
Cache Aside Pattern 优点:可控性高
Read/Write Through Pattern 优点:调用者无需关心缓存一致性问题
Write Behind Caching Pattern 优点:效率较高
Read/Write Through Pattern 缺点:维护这样一个服务是比较复杂的,市面上现成的服务也不好找
Write Behind Caching Pattern 缺点:需要实时监控缓存的数据,一致性难以保证,如果缓存执行上百次,数据库还没操作,如果缓存这时候宕机了,那这些数据就丢失了。
综合优缺点,我们选择 Cache Aside Pattern,可控性高。
但操作缓存和数据库有三个问题需要考虑:
- 删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
- 如何保证缓存与数据库的操作的同时成功或失败
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用 TCC等分布式事务方案
- 先操作缓存还是先操作数据库
这里的流程还是推荐大家看原视频的操作,黑马的百万ppt太强了🤗https://www.bilibili.com/video/BV1cr4y1671t?p=38&vd_source=2be9bcfdb17e0c4fb30c17a7d91482aa
先删除缓存,再操作数据库的正常情况
先删除缓存,再操作数据库的异常情况(在将要更新数据库时,线程2趁虚而入)
先操作数据库,再删除缓存正常情况(
先操作数据库,再删除缓存异常情况(在更新数据库前,就进行查询缓存)
综上,这两种情况都会发生异常情况,但是 先操作数据库,再删除缓存的可能性较低(因为缓存执行的时间很短)
总结:
- 低一致性需求:使用内存淘汰机制,例如商铺类型的查询缓存
- 高一致性需求:主动更新,并以超时剔除为兜底方案,例如店铺详情查询的缓存
- 读操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库,并写入缓存,设定超时时间
- 写操作
- 先写数据库,在删除缓存
- 要保证数据库与缓存操作的原子性。
实现商铺缓存与数据库的读写一致
public Result update(Shop shop) {
//1.先更新数据库
this.updateById(shop);
Long id = shop.getId();
if(id==null){
return Result.fail("店铺id不能未空");
}
//2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY +id);
return Result.ok();
}
由于这个项目没有管理员端,所以这个更新测试可以用 postman 来测试或者也可以在接口文档中进行测试
缓存穿透的解决思路
缓存穿透是指客户端请求的数据在缓存中都不存在,这样缓存永远都不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:
- 额外的内存消耗
- 可能造成数据短期的不一致(当在redis查询id1时,不存在,数据库也不存在,缓存null,就在这时,这条数据插入数据库了,那再查时还是null)
- 布隆过滤(布隆过滤器里面存的是 byte数组,将对象基于某种算法计算 hash值 )
- 优点:内存占用较少,没有多余的key
- 缺点:
- 实现复杂
- 存在误判可能(不存在是真不存在,存在是不一定存在)
解决商铺缓存的缓存穿透问题
public Result queryById(Long id) {
//1. 从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.判断缓存是否命中
if (StrUtil.isNotBlank(shopJson)) {
//3.缓存命中直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 判断命中的是否是空值
if(shopJson!=null){
return Result.fail("店铺不存在");
}
//4.缓存未命中,根据商铺id查询数据库
Shop shop = this.getById(id);
if(shop==null){
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//5. 数据库中查询失败,直接返回
return Result.fail("店铺不存在");
}
//6. 数据库中查询成功,将数据缓存到Redis中
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
}
其实 布隆过滤 和 设置空值都是相对被动的解决方案,都是别人发起错误请求后,我们想办法拦截,我们也可以主动采取一些措施
- 增加 id 的复杂度,避免被猜测 id 的规律
- 加强用户权限校验
- 做好热点参数的限流
缓存雪崩
缓存雪崩是指同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的key的TTL添加随机值
- 利用Redis集群提高服务的可用性(Redis哨兵集群,当一个主Redis宕机后,哨兵会选择一个从Redis替代他)
- 给缓存业务添加降级限流策略 (这个老师总是打他的springCloud的广告😂 https://www.bilibili.com/video/BV1LQ4y127n4/?spm_id_from=333.788.b_636f6d6d656e74.19&vd_source=2be9bcfdb17e0c4fb30c17a7d91482aa)
- 给业务添加多级缓存(Redis高级篇,和他的springCloud中有🤗)
实战篇中没有写解决缓存雪崩的代码,大家可以去Redis高级篇和这个老师的springCloud课程中看看
缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务复杂的Key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击
常见的解决方案:
- 互斥锁
- 逻辑过期
互斥锁
逻辑过期
解决方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | - 没有额外的内存消耗 - 保证一致性 - 实现简单 | - 线程需要等待,性能受影响 - 可能有死锁的风险 |
逻辑过期 | - 线程无需等待,性能较好 | - 不保证一致性 - 有额外内存消耗 - 实现复杂 |
利用互斥锁解决缓存击穿问题
可以用 setnx 命令来做互斥锁
利
public Shop queryWithMutex(Long id){
//1. 从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.判断缓存是否命中
if (StrUtil.isNotBlank(shopJson)) {
//2.1.缓存命中直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 判断命中的是否是空值
if(shopJson!=null){
return null;
}
//2.2 缓存未命中
// 4.实现缓存重建
// 4.1 获取互斥锁
String lockKey=LOCK_SHOP_KEY+id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 判断是否获取成功
if(!isLock){
// 4.3 失败,则休眠并重试
Thread.sleep(50);
queryWithMutex(id);
}
// 获取锁成功,应再次检查缓存,只有第一次获取锁的时候要重建缓存,其他时候再拿到锁的时候,应该缓存已经重建好了,无需再次重建
//其实这里跟第一次查询的代码一样,可以封装一个函数,但是就是这个函数的结果
//不好搞,因为它的两种合适情况一个返回null,一个返回对象,所有如果要判断未命中
//要再弄个封装类这类的。。。我比较懒,就不弄了😁
String shopJsonAgain = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.判断缓存是否命中
if (StrUtil.isNotBlank(shopJsonAgain)) {
//2.1.缓存命中直接返回
return JSONUtil.toBean(shopJsonAgain, Shop.class);
}
//判断命中的是否是空值
if(shopJson!=null){
return null;
}
// 4.4 根据商铺id查询数据库
shop = this.getById(id);
Thread.sleep(200);
if(shop==null){
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//5. 数据库中查询失败,直接返回
return null;
}
//6. 数据库中查询成功,将数据缓存到Redis中
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//7. 释放互斥锁
unlock(lockKey);
}
return shop;
}
高并发的测试需要用到 jmeter 测试工具,或者Apifox也可以,我用的是 jmeter,下面把 jmeter的包放在下面
由于语雀不让上传文件😭,我把一个讲解 jmeter 的文章放在下面
https://blog.csdn.net/zuojunyuan/article/details/94152368
基于逻辑过期解决缓存击穿问题
{
//1. 从Redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.判断缓存是否命中
if (StrUtil.isBlank(shopJson)) {
//3.缓存未命中直接返回
return null;
}
// 命中,需要先把json反序列化未对象
RedisData<Shop> redisData = JSONUtil.toBean(shopJson, new TypeReference<RedisData<Shop>>() {},false);
//朋友们,
// RedisData<Shop> redisData = JSONUtil.toBean(shopJson, RedisData.class);
// Shop shop = redisData.getData();
//这样用泛型转是不对的,会报
// class cn.hutool.json.JSONObject cannot be cast to class com.hmdp.entity.Shop (cn.hutool.json.JSONObject and com.hmdp.entity.Shop are in unnamed module of loader 'app')
Shop shop = redisData.getData();
LocalDateTime expireTime = redisData.getExpireTime();
// 判断是否已经过期
if(expireTime.isAfter(LocalDateTime.now())){
//未过期
return shop;
}
System.out.println(LocalDateTime.now());
// 已过期,需要缓存重建
// 重建 1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 2.判断是否获取成功
if(isLock){
// 获取锁成功应该再次检查redis缓存是否过期,还是第一个线程获得锁了,然后去缓存重建,然后第二个线程来获取锁,然后之间返回了旧数据
// 这时候缓存还没重建好了,然后第三个线程来了,然后判断缓存过期了没,由于缓存还没重建好,还是过期,就在这时,缓存重建好了,锁也
// 释放了,然后刚好第三个线程获得了锁,这时候因为已经刚刚重建好了缓存,不用再查一遍数据库了
String shopJsonAgain = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.判断缓存是否命中
if (StrUtil.isBlank(shopJsonAgain)) {
//3.缓存命中直接返回
return null;
}
// 3.成功需要开始额外的线程去数据库里查询
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
this.saveShopToRedis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
//释放锁
unlock(lockKey);
}
});
}
// 返回查询到的数据
return shop;
}
这个20秒是为了方便测试数据用的,按理来说应该是几十分钟,所以大家测试的时候不用测试10000条以上的线程,要不然容易报下面的错误
大家在看这个 jmeter 的响应数的时候,中文可能会乱码,大家可以看这篇文章https://www.cnblogs.com/xiaxiaoxu/p/9607017.html,里面有两种方法解决
还有一个问题,就是Json串转对象的时候,如果大家的RedisData类里面用泛型的话,要用这个方法转
JSONUtil.toBean(shopJson, new TypeReference<RedisData<Shop>>() {},false);
这个在反序列化的时候也是经常用的,具体原理大家可以参考下面这篇文章https://blog.csdn.net/zhuzj12345/article/details/102914545
封装缓存工具类
这个 工具类很强,对于我这种泛型和函数式编程接触较少的人来说,确实是一个很好的提示。
(上面那个RedisData用泛型的话,这里方法参数好像不能用泛型写,我尝试了不行,可能是我比较采,希望会写的小伙伴可以补充一下)😄
package com.hmdp.utils;
import cn.hutool.core.lang.TypeReference;
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.Shop;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import static com.hmdp.utils.RedisConstants.*;
@Component
public class CacheClient {
@Resource
private StringRedisTemplate stringRedisTemplate;
public void set(String key, Object value, Long time, TimeUnit unit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
public void setWithLogical(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));
}
public <R,ID> R queryWithPassThrough(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.isNotBlank(json)) {
//3.缓存命中直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if(json!=null){
return null;
}
//4.缓存未命中,根据商铺id查询数据库
R r = dbFallback.apply(id);
if(r==null){
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//5. 数据库中查询失败,直接返回
return null;
}
//6. 数据库中查询成功,将数据缓存到Redis中
this.set(key,r,time,unit);
return r;
}
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public <R,ID> R queryWithLogicalExpire(String keyPrefix,ID id,Class<R> type,Function<ID,R> dbFallback,
Long time, TimeUnit unit){
//1. 从Redis中查询商铺缓存
String key=keyPrefix+id;
String json = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存是否命中
if (StrUtil.isBlank(json)) {
//3.缓存未命中直接返回
return null;
}
// 命中,需要先把json反序列化未对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 判断是否已经过期
if(expireTime.isAfter(LocalDateTime.now())){
//未过期
return r ;
}
System.out.println(LocalDateTime.now());
// 已过期,需要缓存重建
// 重建 1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 2.判断是否获取成功
if(isLock){
// 获取锁成功应该再次检查redis缓存是否过期,还是第一个线程获得锁了,然后去缓存重建,然后第二个线程来获取锁,然后之间返回了旧数据
// 这时候缓存还没重建好了,然后第三个线程来了,然后判断缓存过期了没,由于缓存还没重建好,还是过期,就在这时,缓存重建好了,锁也
// 释放了,然后刚好第三个线程获得了锁,这时候因为已经刚刚重建好了缓存,不用再查一遍数据库了
String shopJsonAgain = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存是否命中
if (StrUtil.isBlank(shopJsonAgain)) {
//3.缓存命中直接返回
return null;
}
// 3.成功需要开始额外的线程去数据库里查询
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
R r1 = dbFallback.apply(id);
this.setWithLogical(key,r,time,unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
//释放锁
unlock(lockKey);
}
});
}
// 返回查询到的数据
return r;
}
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 这里用到了一个 BooleanUtil 将 Boolean类型的 flag 转换为 boolean,如果直接返回Boolean类型的 flag,会自动拆箱,可能会出现null
return BooleanUtil.isTrue(flag);
}
private void unlock(String key){
stringRedisTemplate.delete(key);
}
}
总结
本篇内容讲解了Redis的缓存更新策略,可以帮助我们更好的了解如何实现缓存中的数据与数据库的数据进行一定的更新,在下一篇文章中,我们将通过优惠券的秒杀业务将我们上文所述内容进行一个综合的练习
最后,我是Mayphyr,从一点点到亿点点,我们下次再见