redis(三)- 缓存问题_etceriksen的博客-爱代码爱编程
缓存更新策略
内存淘汰:不用自己维护,利用Redis的内存淘汰机制,当内存不足时自动进行淘汰部分数据。下次查询时更新缓存。一致性较差。维护成本 基本无
超时剔除:给缓存数据进行添加TTL时间(具体是expire方法),到期后自己会自动进行删除缓存。下次查询时进行更新缓存。
主动更新策略
操作缓存和数据库时有三个问题需要考虑:
1.删除缓存还是更新缓存?
我们选择的是删除缓存,更新数据库时让缓存失效,查询时再更新缓存。
因为我们利用缓存,就是仅仅当我们对数据库进行查询时,利用缓存来代替直接查询数据库的操作。但是对于更新数据库数据的操作,我
们本来就不经过缓存而是直接去进行数据库进行操作的,所以说同时更新缓存是无效的。
使用的是延迟加载策略,当查询数据库数据的时候再进行更新缓存中的数据。
2.如何保证缓存与数据库的操作的同时成功或失败?
对于单体系统,将缓存与数据库操作放在一个事务
对于分布式系统,利用的是TCC等分布式事务方案
3.先操作缓存还是先操作数据库?
我们使用的是:先操作数据库再删除缓存,后面通过出现异常的概率来说明为什么
无论是先操作缓存还是先操作数据库,我们都要明白一个点:
对于缓存的操作的速率是远远大于操作数据库的速率的 。
当是正常情况时(无穿插):
当出现异常情况,穿插的情况时:我们分析这两种情况触发的概率:
1.先操作数据库,再删除缓存:
先开启一个线程1,在查询的时候,缓存恰好失效。那么此时缓存显然未命中,那么要去进行查询数据库的数据。但是在查询的过程中,
正好开启的线程2进行了更新数据库的操作v=20并且执行删除缓存操作。
此时线程1查询数据库数据查询到了为v=10,并且把v=10写入缓存。但其实线程2已经把数据库数据更新为v=20了,所以此时出现异常。
分析概率大小:
我们知道查询缓存的速率是远远快于查询数据库数据的。
前提条件为:缓存恰好在查询缓存的时候失效。
在查询数据库并且写入缓存的时候,又更新数据库并且删除缓存的情况是极低的
2.先删除缓存,再操作数据库:
这种出现异常的概率较大。因为更新数据库的操作这一时间段是远远大于操作缓存的时间的。所以说发生的可能性会大很大相比于上一个
操作顺序
通过概率进行比较可以得出,我们选择发生异常概率小的情况2:先操作数据库,再删除缓存
缓存更新策略的最佳实践方案:
1.低一致性需求:使用Redis自带的内存淘汰机制
2.高一致性需求:主动更新,并以超时剔除作为兜底方案
2.1 读操作:缓存命中则直接返回
缓存未命中则查询数据库,并写入缓存,设定超时时间(即是expire方法)
当超过超时时间还没有查询该缓存中的数据时,就会缓存自己死亡。这就是超时时间的意义。
2.2 写操作:
先写数据库,然后再删除缓存
要确保数据库与缓存操作的原子性
案例演示:
1.
2.
缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库,然后数据库会返回一
个null空值给查询的服务器接口。
当我们查询Redis缓存没有查询到,并且查询数据库没有查询到对应数据时,我们在Redis缓存一个null。当出现上述的情况时,就会直接
返回一个null。目的就是为了进行避免多次甚至更多次的恶意请求导致一直访问攻击数据库 ,导致缓存穿透。
常见的两种解决方案:
1.缓存空对象:
优点:实现简单,维护方便。
缺点:1.额外的内存消耗
分析:我们想要查询一个id对应在数据库中的数据,当我们查询Redis缓存中没有时,数据库中进行查询也没有对应请求想要查
询的数据。我们此时在redis缓存中进行缓存一个null(垃圾数据)。但是会产生额外的内存消耗,我们可以进行设定TTL这个exprie方法对应
的失效时间。我们尽可能的缩短这个TTL对应的时间,这样一段时间没有命中缓存null时,就会失效,减小一点额外内存的消耗。
2.可能造成短期的不一致
分析:当我们查询Redis缓存没有查询到,并且查询数据库没有查询到对应数据时,我们在Redis缓存一个null。目的就是为了进
行避免多次甚至更多次的恶意请求导致一直访问攻击数据库 ,导致缓存穿透。
但是在缓存一个null之后,我们对请求的这个数据 对应在数据库中进行了更新操作。意思就是可以在数据库查询到id对应到数
据库的数据 ,但是我们是先访问redis缓存的,所以就直接返回null。
这可能会导致短期的不一致。因此我们可以尽可能的缩短缓存null的TTL 缓存销毁时间。
2.布隆过滤器:
就是一个算法模型。底层维护的就是一些占用内存较小的字节数组。
它可以进行判断是否客户端发送的请求是否在数据库中存在,存在则放行。不存在则进行拒绝 !
但是这种方案 对于判断是否存在 是具有一定的误差的。并且实现起来比较复杂 !
优点就是:占用内存较少
缺点:同样会存在一些误差
缓存穿透解决实现
代码:
缓存雪崩
缓存雪崩是指在同一时间段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,给数据库带来巨大压力。
所以说我们给缓存中的不同的key尽量设置不同的的TTL 设置一个随机值
解决方法:
1.给不同的Key的TTL添加随机值
分析:我们给不同的Key设置随机的过期时间可以避免不同的Key同时失效
2.利用Redis集群提高服务的可用性
分析:使用Redis服务器的主从集群操作,当主机的服务器异常,大量Key失效之后或宕机时,但是我们从机中依然保留着我们的数据
3.给缓存业务添加降级限流策略
分析:以后学微服务再记录
4.给业务添加多级缓存
缓存击穿
缓存击穿问题也叫做热点Key问题,就是一个被高并发访问并且缓存重建较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
分析业务场景:
对于一个key对应的业务,无数的请求去进行访问(开启无数个线程去执行操作),查询缓存未命中之后会进行查询数据库。对于缓存重建业
务比较复杂的key来说,查询数据库以及写入对应数据到缓存中的过程是比较复杂的。因此查询数据库的过程是一个较长的过程,在这个
较长的过程中无数的请求对应的线程会进来执行,同样未命中 同样会打到数据库去进行请求访问。
总结:
这样多个请求高并发访问同一个key并且缓存重建业务较复杂,但是该key在Redis缓存中不存在即是失效了,那么就会造成缓存击穿
缓存击穿解决方案:
1.互斥锁
分析流程:
添加一个互斥锁 当一个线程进行访问的时候 进行获取互斥锁,获取成功则进行作用。
如果其他线程也进入之后,获取互斥锁失败,此时会进入休眠等待然后继续重试获取互斥锁,直到线程执行完查询数据库重建缓存数据并
且写入缓存之后的操作之后,进行释放锁。其他线程才可以获取锁成功。
优点:
1.保证了数据的一致性,返回的一定是最新的数据。
2.实现简单,没有额外的内存消耗
缺点:
1.线程需要进行等待,性能受到很大影响。这就是化并行为串行
2.可能会有死锁风险
死锁:对于一个业务来说,我们可能会搞出多个缓存重建的操作。所以说我们加上的锁不止有一个,当我们进行获取锁的时候
可能会获取别的锁,导致别的线程获取锁失败,这种获取不到对应使用锁的情况。我们就称之为死锁!
2.逻辑过期
额外存储开启一个逻辑时间。
分析流程:
当线程1获取互斥锁成功之后,他不会在线程1内进行一系列的操作,它会额外开启一个线程2去进行操作。线程1返回过期数据即可。
当线程3进行请求数据的时候 发现获取互斥锁失败,比较佛系 直接返回旧数据
当线程4开启并且正好在开启的线程2作用完毕之后进行开启的,那么会直接命中缓存 并且这个数据是最新的数据。因为是恰好在线程2操
作完成之后开启作用的 !
优点:
线程无需等待,性能好
缺点:
1.不能保证数据一致性
2.因为多开启了一个逻辑时间 ,所以有额外的内存消耗
3.实现起来比较复杂
对比总结:
基于互斥锁方式解决缓存击穿问题
命令行进行模拟开启锁和释放锁的流程:
开启锁 近似于 setnx key value
分析:这个setnx 的含义:只有当没有存在key以及对应的值时我们才可以setnx成功!如果当我们设置了:setnx a 1。那么我们再去
setnx a 2 时,那么此时就失败 操作不成功!
释放锁 近似于 delete key
有上面命令行操作,我们近似引出下面两个方法:
//获取锁 --》setnx key value(命令行操作) private boolean tryLock(String key) { //类似于setnx这种方法 进行设置key-value 相当于是获取锁 Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10L,TimeUnit.MINUTES) ; return BooleanUtil.isTrue(flag) ; } //释放锁 --》delete key(命令行操作) private void unlock(String key) { stringRedisTemplate.delete(key) ; }
代码:
@Service public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService { @Resource private StringRedisTemplate stringRedisTemplate ; @Override public Result queryById(Long id) { //缓存穿透 // Shop shop = queryWithPassThrough(id) ; //互斥锁解决缓存击穿 Shop shop = queryWithMutex(id) ; if (shop == null) { return Result.fail("店铺不存在 !") ; } return Result.ok(shop) ; } //使用互斥锁进行解决缓存击穿问题 public Shop queryWithMutex(Long id) { //确定一个key String CacheShopKey = "cache:shop:"+id ; //1.从redis查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get(CacheShopKey) ; //2.判断是否存在 if (StrUtil.isNotBlank(shopJson)) { //3.存在,直接返回 return JSONUtil.toBean(shopJson,Shop.class) ; } //判断命中的是否是空值 if (shopJson != null) { //shopJson为 "" 或 "\t\n" return null ; } //4.实现缓存重建 //4.1获取互斥锁 String localKey = "local:shop:" + id ; Shop shop = null; try { boolean isLock = tryLock(localKey) ;//获取互斥锁 //4.2判断是否获取成功 if (!isLock) { //4.3失败,则休眠并且重试 Thread.sleep(50) ; return queryWithMutex(id) ;//递归调用 } //4.4成功,根据id查询数据库 shop = getById(id); //5.不存在,返回错误 if (shop == null) { //将空值写入redis缓存中 stringRedisTemplate.opsForValue().set(CacheShopKey,"",10L,TimeUnit.MINUTES) ; //返回错误信息 return null ; } //6.在数据库中存在,那么把其写入到redis缓存中 stringRedisTemplate.opsForValue().set(CacheShopKey,JSONUtil.toJsonStr(shop),20L,TimeUnit.MINUTES) ; } catch (InterruptedException e) { //只是一个打断的异常 那么直接向上抛出即可 throw new RuntimeException(e) ; } finally { //7.释放互斥锁 unlock(localKey) ; } //8.进行返回 return shop ; } //缓存穿透代码: public Shop queryWithPassThrough(Long id) { String CacheShopKey = "cache:shop:"+id ; //1.从redis中进行查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get(CacheShopKey) ; //2.判断是否存在 if (StrUtil.isNotBlank(shopJson)) { //3.存在直接返回 //在返回之前,我们要先进行转化为Java对象 return JSONUtil.toBean(shopJson,Shop.class) ; } //判断命中的是否为空值 if (shopJson != null) { //返回一个错误信息 return null ; } //4.缓存中不存在,根据id查询数据库 Shop shop = getById(id) ; //5.数据库中数据不存在该要查询的数据 返回错误 if (shop == null) { //将空值写入redis 并且对这个null空值对应的销毁时间设置短一点 stringRedisTemplate.opsForValue().set(CacheShopKey,"",10L,TimeUnit.MINUTES) ; return null ; } //6.数据库中数据存在,写入redis缓存 stringRedisTemplate.opsForValue().set(CacheShopKey,JSONUtil.toJsonStr(shopJson),30L, TimeUnit.MINUTES) ; //7.返回数据库中查询到的数据 return shop ; } //获取锁 --》setnx key value(命令行操作) private boolean tryLock(String key) { //类似于setnx这种方法 进行设置key-value 相当于是获取锁 Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10L,TimeUnit.MINUTES) ; return BooleanUtil.isTrue(flag) ; } //释放锁 --》delete key(命令行操作) private void unlock(String key) { stringRedisTemplate.delete(key) ; } }
基于逻辑过期方式解决缓存击穿问题
反正记住一点就是逻辑过期不是真正的过期,而是一种假过期。
代码:
1.构建一个缓存类
2.搞出一个线程池 手动设定创建的线程个数
3.核心代码
//使用逻辑过期解决缓存击穿问题 public Shop queryWithLogicalExpire(Long id) { String CacheShopKey = "cache:shop:"+id ; //1.从redis查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get(CacheShopKey) ; //2.判断是否存在 if (StrUtil.isBlank(shopJson)) { //3.未命中 直接返回空 return null ; } //4.命中 则需要先把Json反序列化为RedisData对象 RedisData redisData = JSONUtil.toBean(shopJson,RedisData.class) ; JSONObject redisDataObj = (JSONObject) redisData.getData() ; Shop shop = JSONUtil.toBean(redisDataObj,Shop.class) ; LocalDateTime expireTime = redisData.getExpireTime() ; //5.判断是否过期 if (expireTime.isAfter(LocalDateTime.now())) { //5.1 未过期,直接返回店铺信息 return shop ; } //5.2 已过期,需要缓存重建 //6.缓存重建 //6.1获取互斥锁 String lockKey = "local:shop:" + id ; boolean isLock = tryLock(lockKey) ; //6.2判断是否获取互斥锁成功 if (isLock) { //6.3 成功,开启一个独立的线程 实现缓存重建 CACHE_REBUILD_EXECUTOR.submit(()->{ //重建缓存 this.saveShop2Redis(id,20L) ; //释放锁 unlock(lockKey) ; }) ; } //6.4 返回过期的商铺信息 return shop ; }
4.缓存重建
private void saveShop2Redis(Long id,Long expireSeconds) { String CacheShopKey = "cache:shop:"+id ; //1.查询店铺数据 Shop shop = getById(id) ; //2.封装逻辑过期时间 RedisData redisData = new RedisData() ; redisData.setData(shop) ; redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); //3.写入redis stringRedisTemplate.opsForValue().set(CacheShopKey,JSONUtil.toJsonStr(redisData)); }
缓存工具封装
改造缓存穿透:
1.
2.
//工具类 public class CacheClient { private final StringRedisTemplate stringRedisTemplate ; //创建带有10个线程的线程池 private final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10) ; public CacheClient(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } //time 表示传入的时间数量大小值 //unit 表示单位 分钟或秒 或。。。 public void set(String key, Object value, Long time, TimeUnit unit) { stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit) ; } //逻辑过期 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)); } /** * 缓存穿透代码: * @param keyPrefix 表示前缀 * @param id 唯一性标识 * @param type 返回值类型为R 但是不确定 需要调用者告诉我们 所以设置一个type参数进行确定泛型R对应的类型 * @param dbFallback 函数式编程类型 我们不确定查询数据库时 调用的是哪一个方法 所以这里让调用者传入即可 * @param time 时间数量值 * @param unit 时间数量对应的单位 * @param <R> 泛型 返回的数据对应的类型 * @param <ID> 对于id不可能是固定的类型 所以泛型 * @return */ 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) ; } //4.缓存中不存在,根据id查询数据库 //apply(id)表示执行传入的方法 R r = dbFallback.apply(id) ; //5.数据库中不存在,返回错误 if (r == null) { //将空值写入到redis stringRedisTemplate.opsForValue().set(key,"",20L,TimeUnit.MINUTES) ; //返回错误信息 return null ; } //6.数据库中存在,写入redis this.set(key,r,time,unit) ; return r ; } }
改造逻辑过期:
工具类:
//工具类 public class CacheClient { private final StringRedisTemplate stringRedisTemplate ; //创建带有10个线程的线程池 private final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10) ; public CacheClient(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } //time 表示传入的时间数量大小值 //unit 表示单位 分钟或秒 或。。。 public void set(String key, Object value, Long time, TimeUnit unit) { stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit) ; } //逻辑过期 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)); } /** * 缓存穿透代码: * @param keyPrefix 表示前缀 * @param id 唯一性标识 * @param type 返回值类型为R 但是不确定 需要调用者告诉我们 所以设置一个type参数进行确定泛型R对应的类型 * @param dbFallback 函数式编程类型 我们不确定查询数据库时 调用的是哪一个方法 所以这里让调用者传入即可 * @param time 时间数量值 * @param unit 时间数量对应的单位 * @param <R> 泛型 返回的数据对应的类型 * @param <ID> 对于id不可能是固定的类型 所以泛型 * @return */ 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) ; } //4.缓存中不存在,根据id查询数据库 //apply(id)表示执行传入的方法 R r = dbFallback.apply(id) ; //5.数据库中不存在,返回错误 if (r == null) { //将空值写入到redis stringRedisTemplate.opsForValue().set(key,"",20L,TimeUnit.MINUTES) ; //返回错误信息 return null ; } //6.数据库中存在,写入redis this.set(key,r,time,unit) ; return r ; } //同理即可 public <R,ID> R queryWithLogicExpire(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) ; } //4.缓存命中 需要先把json反序列化为对象 RedisData redisData = JSONUtil.toBean(json,RedisData.class) ; JSONObject jsonObject = (JSONObject) redisData.getData() ; R r = JSONUtil.toBean(jsonObject,type) ; LocalDateTime expireTime = redisData.getExpireTime() ; //5.判断是否过期 if (expireTime.isAfter(LocalDateTime.now())) { //5.1未过期 直接返回 return r ; } //5.2已过期 需要缓存重建 //6.缓存重建 //6.1获取互斥锁 String localKey = LOCK_SHOP_KEY + id ; boolean isLock = tryLock(localKey) ; //6.2判断是否获取锁成功 if (isLock) { //6.3 获取锁成功则开启独立线程 实现缓存重建 CACHE_REBUILD_EXECUTOR.submit(()->{ //6.4重建缓存分为三步 //6.4.1查询数据库 R r1 = dbFallback.apply(id) ; //6.4.2把查询到的数据写入到缓存redis中 this.setWithLogicalExpire(key,r1,time,unit) ; //6.4.3释放锁 unlock(localKey); }) ; } return r ; } //获取锁 --》setnx key value(命令行操作) private boolean tryLock(String key) { //类似于setnx这种方法 进行设置key-value 相当于是获取锁 Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1",10L,TimeUnit.MINUTES) ; return BooleanUtil.isTrue(flag) ; } //释放锁 --》delete key(命令行操作) private void unlock(String key) { stringRedisTemplate.delete(key) ; } }